← Natura·Docs/Modules/Source

creatures/rig.js

170 lines · in natura-src-1.0.0.zip as natura/creatures/rig.js · a module · its entry in the module reference

// creatures/rig.js -- SKIN the unified SDF elephant so it ANIMATES without a per-frame rebuild.
//
// buildElephantUnified makes ONE watertight mesh (~100-200ms) -- too slow to rebuild every frame. So build it ONCE
// (rest pose), classify each vertex into a part (trunk / leg0..3 / ear / head / body), and per frame DEFORM the rest
// vertices by the pose with linear-blend-ish skinning: every deformation is weighted to ZERO at its attachment
// (trunk root, hip, neck) so the single connected mesh bends without tearing at the joints. Cheap per frame.
//
// buildElephantRigged(prims, opt{species,detail,N}) -> { faces, setPose(pose) }.  UMD: window.CreaturesRig.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory(require('./sdfmesh.js'));
  else root.CreaturesRig = factory(root.CreaturesSDF);
})(typeof self !== 'undefined' ? self : this, function (SDF) {
  'use strict';
  const V = (x, y, z) => ({ x, y, z });
  const smooth = (t) => t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t);
  function rotZ(p, cx, cy, a) { const s = Math.sin(a), c = Math.cos(a), dx = p.x - cx, dy = p.y - cy; return V(cx + dx * c - dy * s, cy + dx * s + dy * c, p.z); }
  function rotY(p, cx, cz, a) { const s = Math.sin(a), c = Math.cos(a), dx = p.x - cx, dz = p.z - cz; return V(cx + dx * c + dz * s, p.y, cz - dx * s + dz * c); }

  function buildElephantRigged(prims, opt) {
    opt = opt || {};
    const species = opt.species || 'african', asian = species === 'asian';
    const rest0 = { species, detail: opt.detail == null ? 0.9 : opt.detail, trunkCurl: 0, earFlare: 0, headDown: 0 };
    const faces = SDF.buildElephantUnified(rest0, prims, opt);

    // anatomy (rest, headDown=0) -- must match sdfmesh makeField
    const hx = 1.34, hy = 1.74;
    const rootx = hx + 0.30, rooty = hy - 0.12;      // trunk root
    const neck = V(0.95, 1.66, 0);                    // head/neck pivot
    const hipY = asian ? 1.58 : 1.50;
    const legs = [V(0.80, 1.42, 0.42), V(0.80, 1.42, -0.42), V(-0.86, hipY - 0.16, 0.42), V(-0.86, hipY - 0.16, -0.42)];
    const GAITPHASE = [0.25, 0.75, 0.0, 0.5], KNEEDIR = [1, 1, 1, 1], footY0 = 0.10;   // FL,FR,RL,RR: lateral-sequence walk; elephants are columnar -> all knees bend the SAME (subtle) way, not front-vs-rear opposed; foot ground level
    // rest trunk centreline (curl 0)
    const TP = [V(rootx, rooty, 0), V(rootx + 0.24, rooty - 0.44, 0), V(rootx + 0.32, rooty - 0.86, 0), V(rootx + 0.24, rooty - 1.22, 0), V(rootx + 0.10, rooty - 1.50, 0)];
    function trunkParam(p) { // nearest point on TP polyline -> {t 0..1, dist}
      let best = 1e9, bt = 0, acc = 0, total = 0; const segLen = [];
      for (let i = 0; i < TP.length - 1; i++) { const l = Math.hypot(TP[i + 1].x - TP[i].x, TP[i + 1].y - TP[i].y); segLen.push(l); total += l; }
      for (let i = 0; i < TP.length - 1; i++) {
        const ax = TP[i].x, ay = TP[i].y, bx = TP[i + 1].x, by = TP[i + 1].y;
        const dx = bx - ax, dy = by - ay, L2 = dx * dx + dy * dy || 1;
        let h = ((p.x - ax) * dx + (p.y - ay) * dy) / L2; h = Math.max(0, Math.min(1, h));
        const qx = ax + dx * h, qy = ay + dy * h; const d = Math.hypot(p.x - qx, p.y - qy, p.z);
        if (d < best) { best = d; bt = (acc + h * segLen[i]) / total; }
        acc += segLen[i];
      }
      return { t: bt, dist: best };
    }
    // point + tangent-angle at arclength fraction t along a polyline (x-y arclength; carries z)
    function polyAt(pts, t) {
      const n = pts.length, seg = []; let tot = 0;
      for (let i = 0; i < n - 1; i++) { const l = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); seg.push(l); tot += l; }
      let d = Math.max(0, Math.min(1, t)) * tot;
      for (let i = 0; i < n - 1; i++) { if (d <= seg[i] || i === n - 2) { const f = seg[i] ? d / seg[i] : 0, a = pts[i], b = pts[i + 1];
        return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, z: a.z + (b.z - a.z) * f, ang: Math.atan2(b.y - a.y, b.x - a.x) }; } d -= seg[i]; }
      const e = pts[n - 1]; return { x: e.x, y: e.y, z: e.z, ang: 0 };
    }
    function bezier(P0, P1, P2, P3, N) { const out = []; for (let s = 0; s <= N; s++) { const u = s / N, m = 1 - u;
      out.push(V(m*m*m*P0.x+3*m*m*u*P1.x+3*m*u*u*P2.x+u*u*u*P3.x, m*m*m*P0.y+3*m*m*u*P1.y+3*m*u*u*P2.y+u*u*u*P3.y, m*m*m*P0.z+3*m*m*u*P1.z+3*m*u*u*P2.z+u*u*u*P3.z)); } return out; }

    // ---- unique verts (+ their shared normal objects) + rest snapshot + per-vert skin meta ----
    const uniq = [], unorm = [], seen = new Set(), META = [];
    for (const f of faces) { const part = f.part; for (let k = 0; k < f.v.length; k++) { const p = f.v[k]; if (!seen.has(p)) { seen.add(p); uniq.push(p); unorm.push(f.n ? f.n[k] : null); META.push({ part: part || null, side: f.side || 0 }); } } }
    const REST = uniq.map((p) => V(p.x, p.y, p.z));
    const RESTN = unorm.map((n) => n ? V(n.x, n.y, n.z) : null);   // rest normals, transformed (not rebuilt) each frame
    // classify the SDF-body verts (untagged) by position
    for (let i = 0; i < uniq.length; i++) {
      const p = REST[i], m = META[i];
      if (m.part === 'ear' || m.part === 'head' || m.part === 'tail') { /* keep tag */ }
      else {
        const tr = trunkParam(p);
        if (p.x > 1.42 && tr.dist < 0.30) { m.part = 'trunk'; m.t = tr.t; const F = polyAt(TP, tr.t); m.ox = p.x - F.x; m.oy = p.y - F.y; m.oz = p.z - F.z; m.aRest = F.ang; } // curve-reskin data: offset from the rest centreline + rest tangent
        else if (p.y < 1.35) { // maybe a leg (up toward the shoulder/hip so the leg bends at the knee, not at the body join)
          let li = -1, bd = 0.46;
          for (let k = 0; k < 4; k++) { const d = Math.hypot(p.x - legs[k].x, p.z - legs[k].z); if (d < bd) { bd = d; li = k; } }
          if (li >= 0) { m.part = 'leg'; m.leg = li; m.h = smooth((legs[li].y - p.y) / (legs[li].y - 0.10));
            m.t2 = Math.max(0, Math.min(1, (legs[li].y - p.y) / (legs[li].y - 0.10))); m.lox = p.x - legs[li].x; m.loz = p.z - legs[li].z; }  // leg IK: raw hip->foot param + cross-section offset for the 2-bone reskin
          else m.part = 'body';
        } else if (Math.hypot(p.x - hx, p.y - hy) < 0.55) { m.part = 'head'; m.wh = smooth((p.x - 1.05) / 0.45); } // wh fades the head-pitch to 0 toward the neck (no seam on the withers)
        else m.part = 'body';
      }
    }

    function setPose(pose) {
      pose = pose || {};
      const hd = (pose.headDown || 0) * 0.5;
      const curl = pose.trunkCurl || 0;
      const flare = pose.earFlare || 0;
      const stride = pose.gait || 0;
      const reach = pose.trunkReach || null;
      // POSED trunk centreline: a bezier to the reach target (tip lands ON it) or the curl chain; then head-pitched.
      // The trunk is curve-RESKINNED onto this, so it truly reaches -- it no longer just bends back through the head.
      let PP;
      if (reach) { const tz = reach.z || 0;
        PP = bezier(V(rootx, rooty, 0), V(rootx + 0.30, rooty - 0.30, tz * 0.2), V((rootx + reach.x) / 2, (rooty + reach.y) / 2 - 0.15, tz * 0.6), V(reach.x, reach.y, tz), 8);
      } else { const c = curl;
        PP = [V(rootx, rooty, 0), V(rootx + 0.24, rooty - 0.44 - c * 0.05, 0), V(rootx + 0.32 - c * 0.5, rooty - 0.86 + c * 0.35, 0), V(rootx + 0.24 - c * 1.0, rooty - 1.22 + c * 0.9, 0), V(rootx + 0.10 - c * 1.6, rooty - 1.50 + c * 1.5, 0)];
      }
      if (hd) PP = PP.map((pp) => rotZ(pp, neck.x, neck.y, -hd));
      // ---- planted walk: per-leg 2-bone IK + a per-HIP amble (operator's fix): each hip FALLS as its leg bears weight
      // and RISES as it swings, so the BODY MASS follows where the leg can reach -- no hovering, no stretched leg ----
      const walking = pose.walking != null ? pose.walking : (stride > 0.0001);
      if (walking && !reach) { const sway = 0.15 * Math.sin(stride * 2 * Math.PI), n = PP.length - 1; for (let i = 0; i <= n; i++) PP[i] = V(PP[i].x, PP[i].y, PP[i].z + sway * (i / n) * (i / n)); }   // trunk sways side-to-side with the stride, most at the tip
      const hipBob = [0, 0, 0, 0], duty = 0.6;
      if (walking) for (let li = 0; li < 4; li++) { const p = (((stride + GAITPHASE[li]) % 1) + 1) % 1;
        hipBob[li] = p < duty ? -0.07 * Math.sin((p / duty) * Math.PI) : 0.035 * Math.sin(((p - duty) / (1 - duty)) * Math.PI); }  // stance hip sinks, swing hip lifts, one at a time
      const bodyFollow = (px, pz) => { if (!walking) return 0; let ws = 0, ds = 0; for (let k = 0; k < 4; k++) { const w = 1 / ((px - legs[k].x) * (px - legs[k].x) + (pz - legs[k].z) * (pz - legs[k].z) + 0.5); ws += w; ds += w * hipBob[k]; } return ds / ws; };   // body rocks, weighted to the nearest hips
      let legPose = null;
      if (walking) {
        legPose = [];
        for (let li = 0; li < 4; li++) {
          const lg = legs[li], Hy = lg.y + hipBob[li], L = lg.y - footY0, l1 = L * 0.55, l2 = L * 0.50;   // l1+l2 = 1.05L: a little reach slack so a planted foot never hovers
          const p = (((stride + GAITPHASE[li]) % 1) + 1) % 1, amp = 0.34, lifH = 0.16;
          let fx, fy;
          if (p < duty) { const u = p / duty; fx = lg.x + amp * (0.5 - u); fy = footY0; }                              // stance: planted, rolling from front to back
          else { const u = (p - duty) / (1 - duty); fx = lg.x + amp * (u - 0.5); fy = footY0 + lifH * Math.sin(u * Math.PI); }  // swing: lift + reach forward
          let dx = fx - lg.x, dy = fy - Hy, d = Math.hypot(dx, dy); d = Math.max(1e-3, Math.min(l1 + l2 - 1e-3, d));
          const dirA = Math.atan2(dy, dx), cosK = Math.max(-1, Math.min(1, (l1 * l1 + d * d - l2 * l2) / (2 * l1 * d)));
          const kneeA = dirA + KNEEDIR[li] * Math.acos(cosK);
          const Kx = lg.x + Math.cos(kneeA) * l1, Ky = Hy + Math.sin(kneeA) * l1;
          legPose.push({ Hx: lg.x, Hy, Kx, Ky, Fx: fx, Fy: fy, rot1: Math.atan2(Ky - Hy, Kx - lg.x) + Math.PI / 2, rot2: Math.atan2(fy - Ky, fx - Kx) + Math.PI / 2, z: lg.z });
        }
      }
      for (let i = 0; i < uniq.length; i++) {
        const r = REST[i], m = META[i], rn = RESTN[i];
        if (m.part === 'trunk') {   // curve-reskin: follow the posed centreline, carry the cross-section offset
          const F = polyAt(PP, m.t), dA = F.ang - m.aRest, c = Math.cos(dA), s = Math.sin(dA);
          uniq[i].x = F.x + m.ox * c - m.oy * s; uniq[i].y = F.y + m.ox * s + m.oy * c + bodyFollow(r.x, r.z); uniq[i].z = F.z + m.oz;
          if (rn) { const nq = rotZ({ x: rn.x, y: rn.y, z: rn.z }, 0, 0, dA); unorm[i].x = nq.x; unorm[i].y = nq.y; unorm[i].z = nq.z; }
          continue;
        }
        let x = r.x, y = r.y, z = r.z; let nx = rn ? rn.x : 0, ny = rn ? rn.y : 0, nz = rn ? rn.z : 0;
        if (hd && (m.part === 'head' || m.part === 'ear')) { const a = -hd * (m.part === 'head' ? (m.wh == null ? 1 : m.wh) : 1); const q = rotZ({ x, y, z }, neck.x, neck.y, a); x = q.x; y = q.y; z = q.z; if (rn) { const nq = rotZ({ x: nx, y: ny, z: nz }, 0, 0, a); nx = nq.x; ny = nq.y; nz = nq.z; } }
        if (m.part === 'ear') { const hgx = hx - 0.10, hgz = m.side * 0.44, a = m.side * flare * 0.9 + (walking ? m.side * 0.17 * Math.sin(stride * 2 * Math.PI) : 0); const q = rotY({ x, y, z }, hgx, hgz, a); x = q.x; y = q.y; z = q.z; if (rn) { const nq = rotY({ x: nx, y: ny, z: nz }, 0, 0, a); nx = nq.x; ny = nq.y; nz = nq.z; } }   // ears flap with the stride, not just idle
        else if (m.part === 'leg') {
          if (walking && legPose && m.t2 != null) {   // curve-reskin the leg onto the bent 2-bone chain (hip -> knee -> planted foot)
            const lp = legPose[m.leg], t = m.t2; let cx, cy, br;
            if (t < 0.5) { const tt = t / 0.5; cx = lp.Hx + (lp.Kx - lp.Hx) * tt; cy = lp.Hy + (lp.Ky - lp.Hy) * tt; br = lp.rot1; }
            else { const tt = (t - 0.5) / 0.5; cx = lp.Kx + (lp.Fx - lp.Kx) * tt; cy = lp.Ky + (lp.Fy - lp.Ky) * tt; br = lp.rot2; }
            const flat = Math.max(0, (t - 0.80) / 0.20); br *= (1 - flat);   // keep the FOOT PAD flat on the ground (heel down) instead of tiptoeing with the shin angle
            const c = Math.cos(br), s = Math.sin(br), lo = m.lox || 0;
            const ikx = cx + lo * c, iky = cy + lo * s, ikz = lp.z + (m.loz || 0);
            // SMOOTH skinning weight: 0 up at the chest/belly join (so the leg IK doesn't over-pull the body or tear a seam),
            // easing to 1 down the leg -- a smoothstep falloff, not the hard/linear one that clipped at the junction.
            const wv = Math.max(0, Math.min(1, (t - 0.06) / 0.30)), w = wv * wv * (3 - 2 * wv), bf = bodyFollow(r.x, r.z);   // stronger, earlier IK follow so the leg keeps its round TUBE (was flattening); still 0 at the very join so no tear
            x = r.x * (1 - w) + ikx * w; y = (r.y + bf) * (1 - w) + iky * w; z = r.z * (1 - w) + ikz * w;
            if (rn) { const nq = rotZ({ x: rn.x, y: rn.y, z: rn.z }, 0, 0, br); nx = rn.x * (1 - w) + nq.x * w; ny = rn.y * (1 - w) + nq.y * w; nz = rn.z * (1 - w) + nq.z * w; const nl = Math.hypot(nx, ny, nz) || 1; nx /= nl; ny /= nl; nz /= nl; }
          }
          /* not walking -> leg stays at REST */
        }
        if (m.part !== 'leg') y += bodyFollow(r.x, r.z);   // body/head/ear/tail rock with the hips (legs already carry hipBob at their pivot)
        if (m.part === 'tail' && walking) { const dn = Math.max(0, Math.min(1, (1.62 - r.y) / 0.9)); z += 0.22 * dn * dn * Math.sin(stride * 2 * Math.PI + 1.0); x += 0.05 * dn * dn * Math.sin(stride * 4 * Math.PI); }   // tail swishes side-to-side (+ a little fore-aft), most at the tuft
        uniq[i].x = x; uniq[i].y = y; uniq[i].z = z;
        if (rn) { unorm[i].x = nx; unorm[i].y = ny; unorm[i].z = nz; }
      }
      // POSTURE PITCH: rotate the whole animal about a low mid-body pivot (positive = CCW = shoulder up / hip down),
      // so she sits on the ground plane for a camera that tilts down (fixes the "front feet beneath the rear" read).
      const pitch = pose.pitch || 0;
      if (pitch) { const px = 0.0, py = 1.02;
        for (let i = 0; i < uniq.length; i++) { const q = rotZ(uniq[i], px, py, pitch); uniq[i].x = q.x; uniq[i].y = q.y; uniq[i].z = q.z;
          if (unorm[i]) { const nq = rotZ({ x: unorm[i].x, y: unorm[i].y, z: unorm[i].z }, 0, 0, pitch); unorm[i].x = nq.x; unorm[i].y = nq.y; unorm[i].z = nq.z; } } }
      return faces;
    }
    setPose(rest0);
    // EXPOSED for a rigged-COLLADA exporter: the deduped rest vertices (shared object refs with faces' f.v),
    // their per-vertex skin classification (part/params), and the anatomy constants -- everything needed to
    // build a real bone skeleton + weighted skinning without re-deriving the classification.
    const rigData = { uniq, REST, META, anatomy: { rootx, rooty, neck, hipY, legs, TP, footY0, hx, hy } };
    return { faces, setPose, rigData };
  }
  return { buildElephantRigged };
});