creatures/sdfmesh.js
213 lines · in natura-src-1.0.0.zip as natura/creatures/sdfmesh.js · a module · its entry in the module reference
// creatures/sdfmesh.js -- UNIFY the elephant primitives into ONE watertight mesh.
//
// The parametric build (render3d.js buildElephant) is separate primitives that INTERSECT -- so junctions
// (leg-body, trunk-face, ear-head) z-fight and never truly connect. This instead defines each primitive as a
// SIGNED-DISTANCE field, SMOOTH-UNIONs them (so they fuse with a filleted blend at every junction), and
// polygonizes the isosurface with naive SURFACE NETS into a single connected quad mesh. Shared vertices ->
// render3d's smooth shading flows seamlessly; one surface -> no interpenetration. Build ~50ms, so build-once
// and (later) skin, not per-frame.
//
// buildElephantSDF(pose, opt) -> array of render3d faces [{v:[V..], c:[r,g,b]}] (SKIN body only; caller adds
// tusks/eyes/etc as small prims on top). UMD: window.CreaturesSDF.
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.CreaturesSDF = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const V = (x, y, z) => ({ x, y, z });
// ---- SDF primitives ----
function smin(a, b, k) { const h = Math.max(0, Math.min(1, 0.5 + 0.5 * (b - a) / k)); return b * (1 - h) + a * h - k * h * (1 - h); }
function sdEllipsoid(px, py, pz, cx, cy, cz, rx, ry, rz) { // IQ's approximate ellipsoid SDF
const qx = (px - cx) / rx, qy = (py - cy) / ry, qz = (pz - cz) / rz;
const k0 = Math.sqrt(qx * qx + qy * qy + qz * qz); if (k0 === 0) return -Math.min(rx, ry, rz);
const k1 = Math.sqrt(qx * qx / (rx * rx) + qy * qy / (ry * ry) + qz * qz / (rz * rz));
return k0 * (k0 - 1.0) / k1;
}
function sdSeg(px, py, pz, ax, ay, az, bx, by, bz, r, r2) { // capsule (r at a -> r2 at b if given)
const pax = px - ax, pay = py - ay, paz = pz - az, bax = bx - ax, bay = by - ay, baz = bz - az;
const h = Math.max(0, Math.min(1, (pax * bax + pay * bay + paz * baz) / (bax * bax + bay * bay + baz * baz || 1)));
const rr = r2 == null ? r : r + (r2 - r) * h;
return Math.hypot(pax - bax * h, pay - bay * h, paz - baz * h) - rr;
}
// ---- the elephant field (pose-parameterized so it can be re-rigged by rebuilding) ----
function makeField(pose) {
const asian = pose.species === 'asian';
const curl = pose.trunkCurl || 0;
const flare = pose.earFlare || 0;
const headDown = pose.headDown || 0;
const hx = 1.34, hy = 1.74 - headDown * 0.34;
const K = 0.16; // junction blend radius (the fillet)
// trunk centreline (curls with pose)
const rootx = hx + 0.30, rooty = hy - 0.12;
const tp = [
[rootx, rooty, 0],
[rootx + 0.24, rooty - 0.44 - curl * 0.05, 0],
[rootx + 0.32 - curl * 0.5, rooty - 0.86 + curl * 0.35, 0],
[rootx + 0.24 - curl * 1.0, rooty - 1.22 + curl * 0.9, 0],
[rootx + 0.10 - curl * 1.6, rooty - 1.50 + curl * 1.5, 0], // a longer, thinner tip that hangs lower
];
// front legs top at the high SHOULDER, rear legs top at the LOWER HIP (elephants -- esp. African -- carry the
// shoulder higher than the hip, so the rear leg is set lower and the back slopes down to the tail).
const shY = asian ? 1.62 : 1.70, hipY = asian ? 1.58 : 1.50;
// [lx, lz, topY, front]. Front legs are NOT a vertical column: the shoulder sits WIDER than the foot, so the
// limb angles gently inward (shoulder->elbow->foot), which reads as a natural stance instead of a stiff post.
const legs = [[0.80, 0.42, 1.42, 1], [0.80, -0.42, 1.42, 1], [-0.86, 0.42, hipY - 0.16, 0], [-0.86, -0.42, hipY - 0.16, 0]];
return function field(px, py, pz) {
// body -- a barrel plus a high shoulder mass and a lower haunch mass (the sloping topline)
let d = sdEllipsoid(px, py, pz, -0.05, 1.58, 0, 1.30, 0.58, 0.60);
d = smin(d, sdEllipsoid(px, py, pz, 0.62, shY, 0, 0.52, 0.52, 0.60), K); // high shoulder / withers
d = smin(d, sdEllipsoid(px, py, pz, -0.74, hipY, 0, 0.58, 0.53, 0.60), K); // lower haunch
// legs: thick graviportal pillars, capsule from inside the body down to the foot. A SMALLER blend (KL) than
// the body masses keeps each leg a distinct pillar (not merged high into the belly = stubby) while still
// fusing cleanly at the hip. A broad flat foot pad at the base.
const KL = 0.10;
for (const [lx, lz, topY, front] of legs) {
const lzTop = lz * (front ? 1.14 : 1.05), lzBot = lz * (front ? 0.90 : 1.0), fxo = front ? 0.03 : 0; // front limb angles in (wide shoulder -> narrower foot), foot set a touch forward
d = smin(d, sdSeg(px, py, pz, lx - fxo, topY, lzTop, lx + fxo, 0.16, lzBot, 0.25, 0.205), KL);
d = smin(d, sdEllipsoid(px, py, pz, lx + fxo + 0.07, 0.72, lzBot, 0.155, 0.22, 0.185), 0.09); // slight knee swell on the FRONT of the leg (bone landmark for rigging)
d = smin(d, sdEllipsoid(px, py, pz, lx + fxo + 0.04, 0.09, lzBot, 0.31, 0.095, 0.31), 0.08); // broad foot pad
}
// neck + head -- a longer, deeper neck and a head with more front-to-back DEPTH
d = smin(d, sdSeg(px, py, pz, 0.84, 1.66, 0, hx - 0.12, hy - 0.04, 0, 0.50, 0.46), K);
d = smin(d, sdEllipsoid(px, py, pz, hx + 0.02, hy, 0, asian ? 0.52 : 0.50, asian ? 0.55 : 0.53, asian ? 0.50 : 0.47), K);
// WITHERS filler: bridge the head-back to the shoulder-top with a BROAD, high blob + big blend so the neck
// saddle rounds out instead of creasing (surface nets sharpens a deep concave valley into a dark crack)
d = smin(d, sdEllipsoid(px, py, pz, 0.92, 1.94, 0, 0.62, 0.38, 0.54), 0.30);
// trunk chain, tapering root->tip. The tip radius is floored at ~0.085 so it stays ABOVE the grid cell size --
// a thinner tip under-resolves in surface nets and pinches / flips normals at the very end.
const trad = (i) => Math.max(0.085, 0.20 - 0.030 * i);
for (let i = 0; i < tp.length - 1; i++) {
d = smin(d, sdSeg(px, py, pz, tp[i][0], tp[i][1], tp[i][2], tp[i + 1][0], tp[i + 1][1], tp[i + 1][2], trad(i), trad(i + 1)), 0.07);
}
// ears are thin fanning FLAPS -- an SDF blob can't capture a grand thin ear, so they are added as sheet
// geometry on top (earFan) rather than unioned here. (opt.sdfEars unions a crude blob flap if ever wanted.)
if (pose.__sdfEars) { const esz = asian ? 0.42 : 0.64, eth = asian ? 0.05 : 0.055;
for (const s of [-1, 1]) { const ecz = s * (0.40 + esz * 0.5 + flare * 0.08);
d = smin(d, sdEllipsoid(px, py, pz, hx - 0.34, hy - 0.04, ecz, esz * 0.62, esz * 1.02, eth), 0.05); } }
return d;
};
}
// ---- naive surface nets -> connected quad mesh ----
const CORN = [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1], [0, 1, 0], [1, 1, 0], [1, 1, 1], [0, 1, 1]];
const EDGE = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]];
function buildElephantSDF(pose, opt) {
pose = pose || {}; opt = opt || {};
const N = opt.N || 46, SKIN = opt.skin || [138, 133, 126];
const field = makeField(pose);
const x0 = -1.55, x1 = 2.35, y0 = -0.06, y1 = 2.55, z0 = -1.2, z1 = 1.2;
const nx = N, ny = Math.max(4, Math.round(N * (y1 - y0) / (x1 - x0))), nz = Math.max(4, Math.round(N * (z1 - z0) / (x1 - x0)));
const dx = (x1 - x0) / nx, dy = (y1 - y0) / ny, dz = (z1 - z0) / nz;
const gx = nx + 1, gy = ny + 1, gz = nz + 1;
const F = new Float32Array(gx * gy * gz);
const gi = (i, j, k) => (i * gy + j) * gz + k;
for (let i = 0; i < gx; i++) for (let j = 0; j < gy; j++) for (let k = 0; k < gz; k++) F[gi(i, j, k)] = field(x0 + i * dx, y0 + j * dy, z0 + k * dz);
const vidx = new Int32Array(nx * ny * nz).fill(-1);
const ci = (i, j, k) => (i * ny + j) * nz + k;
const verts = [];
for (let i = 0; i < nx; i++) for (let j = 0; j < ny; j++) for (let k = 0; k < nz; k++) {
let neg = 0; const s = [];
for (let c = 0; c < 8; c++) { const v = F[gi(i + CORN[c][0], j + CORN[c][1], k + CORN[c][2])]; s.push(v); if (v < 0) neg++; }
if (neg === 0 || neg === 8) continue;
let ex = 0, ey = 0, ez = 0, cnt = 0;
for (const [a, b] of EDGE) { const va = s[a], vb = s[b]; if ((va < 0) !== (vb < 0)) { const t = va / (va - vb); ex += CORN[a][0] + (CORN[b][0] - CORN[a][0]) * t; ey += CORN[a][1] + (CORN[b][1] - CORN[a][1]) * t; ez += CORN[a][2] + (CORN[b][2] - CORN[a][2]) * t; cnt++; } }
vidx[ci(i, j, k)] = verts.length;
verts.push(V(x0 + (i + ex / cnt) * dx, y0 + (j + ey / cnt) * dy, z0 + (k + ez / cnt) * dz));
}
const Vc = (i, j, k) => (i < 0 || j < 0 || k < 0 || i >= nx || j >= ny || k >= nz) ? -1 : vidx[ci(i, j, k)];
const faceIdx = [], adj = [];
const addEdge = (a, b) => { (adj[a] || (adj[a] = new Set())).add(b); (adj[b] || (adj[b] = new Set())).add(a); };
const emit = (q) => { faceIdx.push(q); for (let m = 0; m < q.length; m++) addEdge(q[m], q[(m + 1) % q.length]); };
for (let i = 0; i < gx; i++) for (let j = 0; j < gy; j++) for (let k = 0; k < gz; k++) {
const f0 = F[gi(i, j, k)];
if (i < nx && j > 0 && k > 0) { const f1 = F[gi(i + 1, j, k)]; if ((f0 < 0) !== (f1 < 0)) { const a = Vc(i, j - 1, k - 1), b = Vc(i, j, k - 1), c = Vc(i, j, k), e = Vc(i, j - 1, k); if (a >= 0 && b >= 0 && c >= 0 && e >= 0) emit(f0 < 0 ? [a, b, c, e] : [a, e, c, b]); } }
if (j < ny && i > 0 && k > 0) { const f1 = F[gi(i, j + 1, k)]; if ((f0 < 0) !== (f1 < 0)) { const a = Vc(i - 1, j, k - 1), b = Vc(i, j, k - 1), c = Vc(i, j, k), e = Vc(i - 1, j, k); if (a >= 0 && b >= 0 && c >= 0 && e >= 0) emit(f0 < 0 ? [a, e, c, b] : [a, b, c, e]); } }
if (k < nz && i > 0 && j > 0) { const f1 = F[gi(i, j, k + 1)]; if ((f0 < 0) !== (f1 < 0)) { const a = Vc(i - 1, j - 1, k), b = Vc(i, j - 1, k), c = Vc(i, j, k), e = Vc(i - 1, j, k); if (a >= 0 && b >= 0 && c >= 0 && e >= 0) emit(f0 < 0 ? [a, b, c, e] : [a, e, c, b]); } }
}
// LAPLACIAN RELAXATION: surface-nets puts a vertex at each cell's edge-crossing average, which leaves a faint
// quilted micro-texture. A couple of gentle neighbour-averaging passes smooth that out into a clean skin.
const iters = opt.relax == null ? 3 : opt.relax, L = 0.5;
for (let it = 0; it < iters; it++) {
const np = verts.map((p, vi) => { const ns = adj[vi]; if (!ns || !ns.size) return p;
let sx = 0, sy = 0, sz = 0; ns.forEach((n) => { sx += verts[n].x; sy += verts[n].y; sz += verts[n].z; });
const k = ns.size; return V(p.x + (sx / k - p.x) * L, p.y + (sy / k - p.y) * L, p.z + (sz / k - p.z) * L); });
for (let vi = 0; vi < verts.length; vi++) { verts[vi].x = np[vi].x; verts[vi].y = np[vi].y; verts[vi].z = np[vi].z; }
}
// ANALYTIC normals = SDF gradient at the (relaxed) vertex -> exact smooth normals, no shading facets
const e2 = 0.02, vnorms = verts.map((p) => {
const gxn = field(p.x + e2, p.y, p.z) - field(p.x - e2, p.y, p.z), gyn = field(p.x, p.y + e2, p.z) - field(p.x, p.y - e2, p.z), gzn = field(p.x, p.y, p.z + e2) - field(p.x, p.y, p.z - e2);
const gl = Math.hypot(gxn, gyn, gzn) || 1; return V(gxn / gl, gyn / gl, gzn / gl);
});
// procedural HIDE colour: broad low-frequency tonal mottle + dusty warmth low on the body, so the flat grey reads
// as weathered elephant skin. Kept LOW frequency (patches >> a face) so adjacent faces stay close in tone and it
// does NOT accentuate the mesh facets; a little medium-freq breakup rides on top.
const h3 = (x, y, z) => { const s = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453; return s - Math.floor(s); };
const vn = (x, y, z) => { const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z), xf = x - xi, yf = y - yi, zf = z - zi;
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf), w = zf * zf * (3 - 2 * zf), L = (a, b, t) => a + (b - a) * t, c = (i, j, k) => h3(xi + i, yi + j, zi + k);
return L(L(L(c(0,0,0),c(1,0,0),u),L(c(0,1,0),c(1,1,0),u),v), L(L(c(0,0,1),c(1,0,1),u),L(c(0,1,1),c(1,1,1),u),v), w); };
const DUST = [168, 153, 131];
return faceIdx.map((q) => { const vv = q.map((z) => verts[z]);
let cx = 0, cy = 0, cz = 0; for (const p of vv) { cx += p.x; cy += p.y; cz += p.z; } cx /= vv.length; cy /= vv.length; cz /= vv.length;
// rich hide texture that is COHERENT (patterns span many faces -> read as mottle/creases, NOT per-face blocks).
// The blockiness came from HIGH-freq per-face terms, not from contrast -- so keep the contrast, drop the high freqs.
const mott = 0.82 + 0.22 * vn(cx * 1.5, cy * 1.5, cz * 1.5); // broad tonal patches (the visible detail), good contrast, spanning many faces
const wrink = vn(cx * 1.8 + 3, cy * 3.6, cz * 1.8 + 7); // ANISOTROPIC wrinkle field: fast vertical / slow horizontal -> creases run HORIZONTALLY as body-folds (and ring the trunk), the way real elephant hide folds
const wf = wrink > 0.54 ? 1 - (wrink - 0.54) * 0.8 : 1; // darken along the fold ridges (visible weathered wrinkles)
const dap = 0.05 * (vn(cx * 5.5 + 1, cy * 5.5, cz * 5.5) - 0.5); // a FINE dapple for micro-texture -- low contrast so its per-face steps stay sub-perceptual
const dust = Math.max(0, Math.min(0.6, (1.15 - cy) * 0.5)); // dustier/warmer toward legs + belly
const base = mott * wf + dap;
const col = [ (SKIN[0] * base) * (1 - dust) + DUST[0] * dust, (SKIN[1] * base) * (1 - dust) + DUST[1] * dust, (SKIN[2] * base) * (1 - dust) + DUST[2] * dust ];
return { v: vv, n: q.map((z) => vnorms[z]), c: col }; });
}
// full unified elephant: SDF body (one mesh) + ear sheets + tusks + eyes on top. `prims` = Creatures3D.prims.
function buildElephantUnified(pose, prims, opt) {
pose = pose || {}; opt = opt || {};
const faces = buildElephantSDF(pose, opt);
const asian = pose.species === 'asian';
const hx = 1.34, hy = 1.74 - (pose.headDown || 0) * 0.34, flare = pose.earFlare || 0;
const res = pose.detail == null ? 0.85 : pose.detail;
const SKIN = [138, 133, 126], INNER = [150, 132, 128], IVORY = [232, 226, 208], AMBER = [92, 63, 38], PUP = [22, 17, 13];
// ears: RAISED + moved FORWARD (hinge high and forward on the head), sized so the flap does not hang into the legs
const eSize = asian ? 0.56 : 0.94;
// hinge on the head's SIDE SURFACE (z ~0.44, not buried at 0.24) and on the side of the skull (not the crown),
// so the ear ATTACHES to the outside of the head instead of emerging from inside it
for (const s of [-1, 1]) { const e0 = faces.length; prims.earFan(faces, hx - 0.10, hy + 0.16, s * (0.44 + flare * 0.05), s, eSize + flare * 0.06, SKIN, INNER, res); for (let i = e0; i < faces.length; i++) { faces[i].part = 'ear'; faces[i].side = s; } }
for (const f of faces) { if (f.part !== 'ear' || !f.c) continue; // ear detail: a subtle base tone + a branching VEIN network radiating from the ear base (colour, not geometry -- like the hide mottle)
let ex = 0, ey = 0, ez = 0; for (const p of f.v) { ex += p.x; ey += p.y; ez += p.z; } const n = f.v.length; ex /= n; ey /= n; ez /= n;
const es = f.side || 1, hby = hy + 0.10, hbz = es * 0.44; // the ear hinges on the head's side; veins fan OUT from this base across the flap
const dy = ey - hby, dz = (ez - hbz) * es, r = Math.hypot(dy, dz) + 1e-3, ang = Math.atan2(dy, dz);
const vein = Math.abs(Math.sin(ang * 3.2 + Math.sin(r * 5.0) * 0.5)); // ~a few veins radiating from the base, wavering + branching
const vf = vein < 0.13 ? 1 - (1 - vein / 0.13) * 0.22 * Math.min(1, r * 1.8) : 1; // darken along the veins, stronger toward the thin outer flap where real veins show most
const m = (0.94 + 0.04 * Math.sin(ex * 3.1 + ez * 4.1)) * vf; // subtle base tone * the vein darkening
f.c = [f.c[0] * m, f.c[1] * m, f.c[2] * m]; }
// tusks: flat IVORY, marked UNLIT + double-sided so lighting/winding can't render them weirdly; move with the head.
// opt.noTusks skips them entirely -- for a CALF (young elephants are tuskless).
if (!opt.noTusks) {
const t0 = faces.length;
for (const s of [-1, 1]) {
const bx = hx + 0.26, by = hy - 0.24, bz = s * 0.27, rr = asian ? 0.05 : 0.085, ext = asian ? 0.42 : 1.0; // set WIDER so the tusk clears the trunk (no z-jitter at the overlap)
const T0 = V(bx, by, bz), T1 = V(bx + 0.45 * ext, by - 0.30 * ext, bz + s * 0.05), T2 = V(bx + 0.75 * ext, by - 0.45 * ext, bz + s * 0.02), T3 = V(bx + 0.92 * ext, by - 0.30 * ext, bz);
const path = [], rad = [], NB = 8; for (let i = 0; i <= NB; i++) { const u = i / NB; path.push(prims.bez(T0, T1, T2, T3, u)); rad.push(rr * (1 - 0.8 * u) + 0.012); }
prims.tube(faces, path, rad, 8, IVORY, true);
}
for (let i = t0; i < faces.length; i++) { faces[i].unlit = true; faces[i].dbl = true; faces[i].part = 'head'; }
}
// eyes -- move with the head
const y0 = faces.length;
// eyes sit PROUD on the head's side surface (the deeper head had buried them) -- pushed out in x+z, pupil outward
// seat each eye ON the head's side surface (ellipsoid ~center x1.36, rz0.47, narrowing forward): back from the snout + in from 0.42 so it nests in a socket -- visible from its own side, OCCLUDED by the head bulk from the far side (fixes the far eye poking past the face at a 3/4 angle)
for (const s of [-1, 1]) { const ex = hx + 0.24, ey = hy + 0.03, ez = s * 0.37; prims.ellipsoid(faces, V(ex, ey, ez), V(0.060, 0.063, 0.058), 10, 8, AMBER); prims.ellipsoid(faces, V(ex + 0.028, ey, ez + s * 0.028), V(0.034, 0.036, 0.032), 8, 6, PUP); }
for (let i = y0; i < faces.length; i++) faces[i].part = 'head';
// tail: a thin tube hanging from the rump, with a dark tuft
{ const a0 = faces.length; const T0 = V(-1.12, 1.62, 0), T1 = V(-1.30, 1.28, 0), T2 = V(-1.32, 0.92, 0.02), T3 = V(-1.27, 0.66, 0);
const path = [], rad = [], NB = 7; for (let i = 0; i <= NB; i++) { const u = i / NB; path.push(prims.bez(T0, T1, T2, T3, u)); rad.push(0.055 * (1 - 0.5 * u)); }
prims.tube(faces, path, rad, 6, SKIN); prims.ellipsoid(faces, path[NB], V(0.07, 0.12, 0.07), 6, 5, [40, 37, 34]);
for (let i = a0; i < faces.length; i++) faces[i].part = 'tail'; }
return faces;
}
return { buildElephantSDF, buildElephantUnified, makeField, smin };
});