9

HP-Model Protein Fold (3D)

drag to orbit

A 3D, off-lattice caricature of Ken Dill's HP lattice model of protein folding. A chain of ~30 residues is randomly labeled either H (hydrophobic, red) or P (polar, blue). Adjacent residues are tied by bond springs; all residues feel short-range excluded-volume repulsion; H residues weakly attract other H residues within a cutoff — the burial driver that produces a hydrophobic core. A simulated-annealing schedule cools a fictional 'temperature' (the amplitude of Gaussian thermal noise) from hot to cold over ~15 s, and the straggly chain collapses into a compact globule with the H beads buried and the P beads on the surface. After holding folded for a few seconds the system reheats and refolds with a fresh sequence, so every cycle is a different molecule searching for its native-ish state.

idle
259 lines · three
view source
// HP-model protein fold caricature.
//
// A 1D chain of beads, each labeled H (hydrophobic) or P (polar), is "folded"
// by a soft-bodied simulated-annealing-ish dynamics: bond springs, weak H–H
// attraction inside a cutoff (the "burial" driver), and short-range repulsion
// for excluded volume. A "temperature" (thermal noise amplitude) cools from
// hot to cold over ~15 s; the straggly chain collapses into a hydrophobic-core
// / polar-shell blob. Hold for 3 s, then reheat with a fresh sequence.
//
// NOTE: this is *not* a real protein simulation — it's a 3D, off-lattice
// caricature of Ken Dill's HP lattice model, optimized for visual punch.

const N = 30;                  // residues
const BOND_LEN = 1.6;
const K_BOND = 60;             // bond spring stiffness
const K_HH = 1.4;               // H–H attraction strength (within cutoff)
const HH_CUTOFF = 4.0;          // H–H attraction radius
const K_REP = 25;               // excluded-volume repulsion
const REP_RADIUS = 1.4;
const DAMPING = 4.0;            // velocity damping (per second)
const T_HOT = 6.0;              // starting noise stddev (units of accel)
const T_COLD = 0.05;
const COOL_TIME = 15.0;         // seconds to cool from T_HOT to T_COLD
const HOLD_TIME = 3.0;          // seconds folded before reheat
const FOLD_THRESHOLD = 0.15;    // T below this counts as "folded"
const DT_SIM = 1 / 120;         // sim substep
const BEAD_RADIUS = 0.45;

// State.
let seq;                        // Uint8Array of 0=P, 1=H
let pos;                        // Float32Array (3*N), positions
let vel;                        // Float32Array (3*N)
let acc;                        // Float32Array (3*N)
let chainGroup;                 // THREE.Group of meshes + line
let beadMeshes = [];
let bondLine;
let bondPositions;              // Float32Array (3*N) for the polyline
let temperature = T_HOT;
let phase = "fold";             // "fold" | "hold"
let phaseTimer = 0;
let coolElapsed = 0;
let userYaw = 0, userPitch = 0.2;
let isDragging = false;
let lastMouseX = 0, lastMouseY = 0;
let idleTime = 0;
let simAccum = 0;

function randSeq() {
  // Roughly 50/50 H/P, but with a guaranteed minimum H count so the core
  // is visible.
  const out = new Uint8Array(N);
  let hCount = 0;
  for (let i = 0; i < N; i++) {
    out[i] = Math.random() < 0.55 ? 1 : 0;
    if (out[i]) hCount++;
  }
  // If too few H, flip random Ps until we have ~12.
  while (hCount < 12) {
    const i = (Math.random() * N) | 0;
    if (!out[i]) { out[i] = 1; hCount++; }
  }
  return out;
}

function initChain() {
  // Lay the chain out in a wobbly stretched line so it starts "denatured".
  for (let i = 0; i < N; i++) {
    const t = i - (N - 1) / 2;
    pos[3*i]   = t * BOND_LEN * 0.9 + (Math.random() - 0.5) * 0.3;
    pos[3*i+1] = (Math.random() - 0.5) * 0.6;
    pos[3*i+2] = (Math.random() - 0.5) * 0.6;
    vel[3*i] = vel[3*i+1] = vel[3*i+2] = 0;
  }
}

function rebuildBeadColors() {
  for (let i = 0; i < N; i++) {
    const m = beadMeshes[i];
    // H = warm red, P = cool blue.
    if (seq[i]) m.material.color.setHex(0xff5a4a);
    else m.material.color.setHex(0x4aa8ff);
  }
}

function init({ scene, camera, renderer, width, height }) {
  renderer.setClearColor(0x080a12, 1);
  camera.position.set(0, 4, 22);
  camera.lookAt(0, 0, 0);

  // Lights.
  const amb = new THREE.AmbientLight(0xffffff, 0.45);
  scene.add(amb);
  const dir = new THREE.DirectionalLight(0xffffff, 0.85);
  dir.position.set(6, 10, 7);
  scene.add(dir);
  const fill = new THREE.DirectionalLight(0x88aaff, 0.25);
  fill.position.set(-8, -3, -4);
  scene.add(fill);

  seq = randSeq();
  pos = new Float32Array(3 * N);
  vel = new Float32Array(3 * N);
  acc = new Float32Array(3 * N);
  initChain();

  chainGroup = new THREE.Group();
  scene.add(chainGroup);

  const sphereGeo = new THREE.SphereGeometry(BEAD_RADIUS, 16, 12);
  for (let i = 0; i < N; i++) {
    const mat = new THREE.MeshStandardMaterial({
      color: 0xffffff,
      roughness: 0.4,
      metalness: 0.1,
    });
    const mesh = new THREE.Mesh(sphereGeo, mat);
    mesh.position.set(pos[3*i], pos[3*i+1], pos[3*i+2]);
    chainGroup.add(mesh);
    beadMeshes.push(mesh);
  }
  rebuildBeadColors();

  // Bond polyline.
  bondPositions = new Float32Array(3 * N);
  for (let i = 0; i < N; i++) {
    bondPositions[3*i]   = pos[3*i];
    bondPositions[3*i+1] = pos[3*i+1];
    bondPositions[3*i+2] = pos[3*i+2];
  }
  const bondGeo = new THREE.BufferGeometry();
  bondGeo.setAttribute(
    "position",
    new THREE.BufferAttribute(bondPositions, 3).setUsage(THREE.DynamicDrawUsage),
  );
  const bondMat = new THREE.LineBasicMaterial({
    color: 0xcfd6e6,
    transparent: true,
    opacity: 0.55,
  });
  bondLine = new THREE.Line(bondGeo, bondMat);
  chainGroup.add(bondLine);

  temperature = T_HOT;
  phase = "fold";
  phaseTimer = 0;
  coolElapsed = 0;

  return { scene, camera };
}

function gaussian() {
  // Box–Muller.
  let u = 0, v = 0;
  while (u === 0) u = Math.random();
  while (v === 0) v = Math.random();
  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}

function step(h) {
  // Zero accelerations.
  acc.fill(0);

  // Bond springs (adjacent residues).
  for (let i = 0; i < N - 1; i++) {
    const ax = pos[3*i],   ay = pos[3*i+1],   az = pos[3*i+2];
    const bx = pos[3*i+3], by = pos[3*i+4],   bz = pos[3*i+5];
    const dx = bx - ax, dy = by - ay, dz = bz - az;
    const r = Math.sqrt(dx*dx + dy*dy + dz*dz) + 1e-9;
    const f = K_BOND * (r - BOND_LEN) / r;
    const fx = f * dx, fy = f * dy, fz = f * dz;
    acc[3*i]     += fx; acc[3*i+1] += fy; acc[3*i+2] += fz;
    acc[3*i+3]   -= fx; acc[3*i+4] -= fy; acc[3*i+5] -= fz;
  }

  // Pairwise: excluded volume always; H–H attraction inside cutoff.
  const cutoff2 = HH_CUTOFF * HH_CUTOFF;
  const rep2 = REP_RADIUS * REP_RADIUS;
  for (let i = 0; i < N; i++) {
    for (let j = i + 2; j < N; j++) {
      const dx = pos[3*j]   - pos[3*i];
      const dy = pos[3*j+1] - pos[3*i+1];
      const dz = pos[3*j+2] - pos[3*i+2];
      const d2 = dx*dx + dy*dy + dz*dz + 1e-9;

      // Repulsion: soft, 1/r^2-ish, only when overlapping.
      if (d2 < rep2) {
        const d = Math.sqrt(d2);
        const overlap = REP_RADIUS - d;
        const f = K_REP * overlap / d;
        const fx = f * dx, fy = f * dy, fz = f * dz;
        acc[3*i]   -= fx; acc[3*i+1] -= fy; acc[3*i+2] -= fz;
        acc[3*j]   += fx; acc[3*j+1] += fy; acc[3*j+2] += fz;
      }

      // H–H attraction: constant pull within cutoff (with a smooth falloff).
      if (seq[i] && seq[j] && d2 < cutoff2) {
        const d = Math.sqrt(d2);
        // Falloff factor 1 at d=REP_RADIUS, 0 at d=HH_CUTOFF.
        const t = Math.max(0, Math.min(1, (HH_CUTOFF - d) / (HH_CUTOFF - REP_RADIUS)));
        const f = K_HH * t / d;
        const fx = f * dx, fy = f * dy, fz = f * dz;
        acc[3*i]   += fx; acc[3*i+1] += fy; acc[3*i+2] += fz;
        acc[3*j]   -= fx; acc[3*j+1] -= fy; acc[3*j+2] -= fz;
      }
    }
  }

  // Thermal noise + damping + integrate.
  const noise = temperature;
  const damp = Math.exp(-DAMPING * h);
  for (let i = 0; i < N; i++) {
    acc[3*i]   += gaussian() * noise;
    acc[3*i+1] += gaussian() * noise;
    acc[3*i+2] += gaussian() * noise;

    vel[3*i]   = (vel[3*i]   + acc[3*i]   * h) * damp;
    vel[3*i+1] = (vel[3*i+1] + acc[3*i+1] * h) * damp;
    vel[3*i+2] = (vel[3*i+2] + acc[3*i+2] * h) * damp;

    pos[3*i]   += vel[3*i]   * h;
    pos[3*i+1] += vel[3*i+1] * h;
    pos[3*i+2] += vel[3*i+2] * h;
  }

  // Gentle pull toward origin so the molecule doesn't drift off-camera.
  let cx = 0, cy = 0, cz = 0;
  for (let i = 0; i < N; i++) {
    cx += pos[3*i]; cy += pos[3*i+1]; cz += pos[3*i+2];
  }
  cx /= N; cy /= N; cz /= N;
  for (let i = 0; i < N; i++) {
    pos[3*i]   -= cx;
    pos[3*i+1] -= cy;
    pos[3*i+2] -= cz;
  }
}

function reheat() {
  seq = randSeq();
  rebuildBeadColors();
  initChain();
  temperature = T_HOT;
  phase = "fold";
  phaseTimer = 0;
  coolElapsed = 0;
}

function tick({ dt, scene, camera, renderer, width, height, input }) {
  // Drain clicks; nothing to do with them here.
  input.consumeClicks();

  // Camera orbit.
  if (input.mouseDown) {
    if (!isDragging) {
      isDragging = true;
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    } else {
      const dx = input.mouseX - lastMouseX;
      const dy = input.mouseY - lastMouseY;
      userYaw -= dx * 0.005;
      userPitch -= dy * 0.005;
      userPitch = Math.max(-1.2, Math.min(1.2, userPitch));
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    }
    idleTime = 0;
  } else {
    isDragging = false;
    idleTime += dt;
    if (idleTime > 0.5) {
      userYaw += dt * 0.15;
    }
  }

  const camR = 22;
  const cy0 = Math.cos(userYaw), sy0 = Math.sin(userYaw);
  const cp = Math.cos(userPitch), sp = Math.sin(userPitch);
  camera.position.set(camR * cp * sy0, camR * sp, camR * cp * cy0);
  camera.lookAt(0, 0, 0);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height, false);

  // Annealing schedule.
  if (phase === "fold") {
    coolElapsed += dt;
    const t = Math.min(1, coolElapsed / COOL_TIME);
    // Geometric cooling between T_HOT and T_COLD (linear in log).
    const logT = Math.log(T_HOT) + (Math.log(T_COLD) - Math.log(T_HOT)) * t;
    temperature = Math.exp(logT);
    if (temperature < FOLD_THRESHOLD || coolElapsed >= COOL_TIME) {
      temperature = T_COLD;
      phase = "hold";
      phaseTimer = 0;
    }
  } else if (phase === "hold") {
    temperature = T_COLD;
    phaseTimer += dt;
    if (phaseTimer >= HOLD_TIME) {
      reheat();
    }
  }

  // Fixed-step sim, clamped to keep things stable when tab regains focus.
  simAccum += Math.min(dt, 0.1);
  let steps = 0;
  while (simAccum >= DT_SIM && steps < 30) {
    step(DT_SIM);
    simAccum -= DT_SIM;
    steps++;
  }

  // Sync bead meshes + bond line.
  for (let i = 0; i < N; i++) {
    beadMeshes[i].position.set(pos[3*i], pos[3*i+1], pos[3*i+2]);
    bondPositions[3*i]     = pos[3*i];
    bondPositions[3*i+1]   = pos[3*i+1];
    bondPositions[3*i+2]   = pos[3*i+2];
  }
  bondLine.geometry.attributes.position.needsUpdate = true;

  renderer.render(scene, camera);
}

Comments (0)

Log in to comment.