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 };
});