246 lines
11 KiB
JavaScript
246 lines
11 KiB
JavaScript
import * as THREE from 'three';
|
|
import { physiqueFromBodyStyle } from '../../shared/bodyStyle.js';
|
|
import { FWD, V3, clamp, lerp, mergeGeoms, smooth } from '../core/math.js';
|
|
|
|
export const PART = { TORSO: 0, HEAD: 1, ARM_L: 2, ARM_R: 3, LEG_L: 4, LEG_R: 5 };
|
|
|
|
const _t1 = new THREE.Vector3();
|
|
const _t2 = new THREE.Vector3();
|
|
const _t3 = new THREE.Vector3();
|
|
|
|
/**
|
|
* Loft a tube along keyframed rings.
|
|
* keys: [{ t, c: Vector3, rx, rz }] — cross-section radii along the ring basis
|
|
* u/w, which is derived from the path tangent. `shape` harmonics deform the
|
|
* silhouette so no two seeds share a profile.
|
|
*/
|
|
export function loftPart(keys, ringCount, radial, partId, shape) {
|
|
const rings = [];
|
|
for (let i = 0; i < ringCount; i++) {
|
|
const t = i / (ringCount - 1);
|
|
let k = 0;
|
|
while (k < keys.length - 2 && keys[k + 1].t < t) k++;
|
|
const a = keys[k];
|
|
const b = keys[k + 1];
|
|
const ft = smooth(clamp((t - a.t) / Math.max(1e-6, b.t - a.t), 0, 1));
|
|
rings.push({ t, c: a.c.clone().lerp(b.c, ft), rx: lerp(a.rx, b.rx, ft), rz: lerp(a.rz, b.rz, ft) });
|
|
}
|
|
for (let i = 0; i < ringCount; i++) {
|
|
const p0 = rings[Math.max(0, i - 1)].c;
|
|
const p1 = rings[Math.min(ringCount - 1, i + 1)].c;
|
|
const tan = _t1.subVectors(p1, p0).normalize();
|
|
let u = _t2.crossVectors(tan, FWD);
|
|
if (u.lengthSq() < 1e-6) u = _t2.set(1, 0, 0);
|
|
else u.normalize();
|
|
const w = _t3.crossVectors(tan, u).normalize();
|
|
rings[i].u = u.clone();
|
|
rings[i].w = w.clone();
|
|
}
|
|
|
|
const pos = [], uv = [], aPart = [], aT = [], idx = [];
|
|
const cols = radial + 1;
|
|
for (let i = 0; i < ringCount; i++) {
|
|
const r = rings[i];
|
|
for (let j = 0; j <= radial; j++) {
|
|
const th = (j / radial) * Math.PI * 2;
|
|
const ct = Math.cos(th);
|
|
const st = Math.sin(th);
|
|
let sh = 1;
|
|
if (shape) sh += shape.a1 * Math.cos(2 * th + shape.p1) + shape.a2 * Math.cos(3 * th + shape.p2);
|
|
const px = r.rx * ct * sh;
|
|
const pz = r.rz * st * sh;
|
|
pos.push(
|
|
r.c.x + r.u.x * px + r.w.x * pz,
|
|
r.c.y + r.u.y * px + r.w.y * pz,
|
|
r.c.z + r.u.z * px + r.w.z * pz,
|
|
);
|
|
uv.push(j / radial, r.t);
|
|
aPart.push(partId);
|
|
aT.push(r.t);
|
|
}
|
|
}
|
|
for (let i = 0; i < ringCount - 1; i++) {
|
|
for (let j = 0; j < radial; j++) {
|
|
const a = i * cols + j;
|
|
const b = a + cols;
|
|
idx.push(a, a + 1, b, b, a + 1, b + 1);
|
|
}
|
|
}
|
|
const cap = (ringIdx, flip) => {
|
|
const r = rings[ringIdx];
|
|
const ci = pos.length / 3;
|
|
pos.push(r.c.x, r.c.y, r.c.z);
|
|
uv.push(0.5, r.t);
|
|
aPart.push(partId);
|
|
aT.push(r.t);
|
|
for (let j = 0; j < radial; j++) {
|
|
const a = ringIdx * cols + j;
|
|
const b = ringIdx * cols + j + 1;
|
|
if (flip) idx.push(ci, b, a);
|
|
else idx.push(ci, a, b);
|
|
}
|
|
};
|
|
cap(0, true);
|
|
cap(ringCount - 1, false);
|
|
|
|
const g = new THREE.BufferGeometry();
|
|
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
|
g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
|
|
g.setAttribute('aPart', new THREE.Float32BufferAttribute(aPart, 1));
|
|
g.setAttribute('aT', new THREE.Float32BufferAttribute(aT, 1));
|
|
g.setIndex(idx);
|
|
g.computeVertexNormals();
|
|
return g;
|
|
}
|
|
|
|
/**
|
|
* Full body geometry for one fighter. `build` carries the physique parameters
|
|
* so they can be reported to the physics layer: reach, centre of mass and limb
|
|
* mass all follow from the same numbers that shaped the mesh (GDD 8).
|
|
*
|
|
* @param {*} rng seeded RNG (small natural jitter)
|
|
* @param {{ mass?: number, muscle?: number, fat?: number } | null} [bodyStyle]
|
|
* loadout body sliders (dreamfall-style mass / muscle / fat)
|
|
*/
|
|
export function buildBodyGeometry(rng, bodyStyle = null) {
|
|
const phy = physiqueFromBodyStyle(bodyStyle, rng);
|
|
const { bulk, waistF, shoulderF, headF, armF, legF } = phy;
|
|
const parts = [];
|
|
|
|
// Girdle half-width: follows physique, but floors so extreme lean never
|
|
// collapses the clavicle to a point the deltoid cannot meet.
|
|
const girdleRx = Math.max(0.105, 0.176 * bulk * shoulderF);
|
|
const collarRx = Math.max(0.092, 0.15 * bulk * shoulderF);
|
|
|
|
const tKeys = [
|
|
{ t: 0.0, c: V3(0, 0.885, 0.002), rx: 0.15 * bulk, rz: 0.1 * bulk },
|
|
{ t: 0.08, c: V3(0, 0.935, 0.004), rx: 0.172 * bulk, rz: 0.118 * bulk },
|
|
{ t: 0.18, c: V3(0, 1.0, 0.005), rx: 0.164 * bulk, rz: 0.108 * bulk },
|
|
{ t: 0.32, c: V3(0, 1.075, 0.004), rx: 0.15 * bulk * waistF, rz: 0.1 * bulk * waistF },
|
|
{ t: 0.48, c: V3(0, 1.165, 0.006), rx: 0.156 * bulk, rz: 0.104 * bulk },
|
|
{ t: 0.62, c: V3(0, 1.255, 0.008), rx: 0.168 * bulk, rz: 0.116 * bulk },
|
|
{ t: 0.76, c: V3(0, 1.335, 0.009), rx: girdleRx, rz: 0.12 * bulk },
|
|
{ t: 0.88, c: V3(0, 1.405, 0.01), rx: collarRx, rz: 0.105 * bulk },
|
|
{ t: 0.95, c: V3(0, 1.445, 0.012), rx: 0.078 * bulk, rz: 0.072 * bulk },
|
|
{ t: 1.0, c: V3(0, 1.475, 0.013), rx: 0.058 * bulk, rz: 0.056 * bulk },
|
|
];
|
|
parts.push(
|
|
loftPart(tKeys, 36, 24, PART.TORSO, {
|
|
a1: rng.range(-0.03, 0.03), p1: rng.range(0, 6.28),
|
|
a2: rng.range(-0.02, 0.02), p2: rng.range(0, 6.28),
|
|
}),
|
|
);
|
|
|
|
const hKeys = [
|
|
{ t: 0.0, c: V3(0, 1.425, 0.012), rx: 0.056, rz: 0.058 },
|
|
{ t: 0.14, c: V3(0, 1.47, 0.014), rx: 0.06 * headF, rz: 0.064 * headF },
|
|
{ t: 0.3, c: V3(0, 1.52, 0.02), rx: 0.074 * headF, rz: 0.08 * headF },
|
|
{ t: 0.48, c: V3(0, 1.575, 0.026), rx: 0.088 * headF, rz: 0.094 * headF },
|
|
{ t: 0.64, c: V3(0, 1.625, 0.024), rx: 0.094 * headF, rz: 0.1 * headF },
|
|
{ t: 0.8, c: V3(0, 1.668, 0.016), rx: 0.084 * headF, rz: 0.088 * headF },
|
|
{ t: 0.92, c: V3(0, 1.7, 0.01), rx: 0.052 * headF, rz: 0.054 * headF },
|
|
{ t: 1.0, c: V3(0, 1.716, 0.008), rx: 0.012, rz: 0.012 },
|
|
];
|
|
parts.push(loftPart(hKeys, 24, 20, PART.HEAD, { a1: rng.range(-0.02, 0.02), p1: rng.range(0, 6.28), a2: 0, p2: 0 }));
|
|
|
|
// ---- Arms + spherical shoulder sockets ---------------------------------
|
|
//
|
|
// Extreme skinny (mass/muscle floors) used to leave a hole between a thin
|
|
// torso and a fixed arm root at x=0.15 — the "spike" sockets in the kit
|
|
// preview. Rebuild the deltoid as a sphere that always spans from the
|
|
// clavicle root (inside the torso half-width) out to the upper-arm shaft.
|
|
//
|
|
// shoulderHalf matches the torso girdle ring (same floor as girdleRx).
|
|
const shoulderHalf = girdleRx;
|
|
// Deltoid boulder radius: floors hard so lean builds still have a round
|
|
// joint; grows with bulk/arm muscle for heavy / cut.
|
|
const deltoidR = Math.max(
|
|
0.064,
|
|
0.072 * Math.sqrt(Math.max(bulk, 0.55)) * (0.72 + 0.38 * Math.min(armF, 1.45)),
|
|
);
|
|
// Clavicle / socket layout in the coronal plane (absolute X later mirrored).
|
|
const clavY = 1.402;
|
|
const clavZ = 0.008;
|
|
// Root sits inside the torso so the sphere always meets clavicle + neck.
|
|
const clavRootX = Math.max(0.038, shoulderHalf * 0.42);
|
|
// Sphere centre sits on the torso shoulder edge.
|
|
const socketX = Math.max(shoulderHalf * 0.92, clavRootX + deltoidR * 0.55);
|
|
// Outer deltoid / upper-arm takeoff — past the boulder equator.
|
|
const armRootX = socketX + deltoidR * 0.72;
|
|
|
|
for (const s of [1, -1]) {
|
|
const partId = s > 0 ? PART.ARM_L : PART.ARM_R;
|
|
const P = (x, y, z) => V3(s * x, y, z);
|
|
// Near-equal rx/rz + short arc through one centre ⇒ spherical deltoid.
|
|
// Mild shape harmonics only on the shaft so the boulder stays round.
|
|
const aKeys = [
|
|
// Clavicle root — buried in the torso, always connected.
|
|
{ t: 0.0, c: P(clavRootX, clavY + 0.012, clavZ + 0.004), rx: deltoidR * 0.92, rz: deltoidR * 0.88 },
|
|
// Inner hemisphere (toward neck / traps).
|
|
{ t: 0.05, c: P(socketX * 0.78, clavY + 0.006, clavZ), rx: deltoidR * 1.02, rz: deltoidR * 0.98 },
|
|
// Deltoid equator — the shoulder boulder.
|
|
{ t: 0.11, c: P(socketX, clavY, clavZ), rx: deltoidR, rz: deltoidR },
|
|
// Outer hemisphere → upper-arm takeoff.
|
|
{ t: 0.18, c: P(armRootX, clavY - 0.012, clavZ + 0.002), rx: deltoidR * 0.86, rz: deltoidR * 0.82 },
|
|
// Upper arm shaft (path kept close to the original A-pose reach).
|
|
{ t: 0.28, c: P(Math.max(0.28, armRootX + 0.06), 1.30, 0.008), rx: 0.056 * armF, rz: 0.052 * armF },
|
|
{ t: 0.40, c: P(0.355, 1.16, 0.01), rx: 0.048 * armF, rz: 0.044 * armF },
|
|
{ t: 0.50, c: P(0.392, 1.098, 0.011), rx: 0.041 * armF, rz: 0.039 * armF },
|
|
{ t: 0.66, c: P(0.445, 0.985, 0.014), rx: 0.045 * armF, rz: 0.042 * armF },
|
|
{ t: 0.78, c: P(0.48, 0.905, 0.017), rx: 0.035 * armF, rz: 0.032 * armF },
|
|
{ t: 0.86, c: P(0.5, 0.855, 0.02), rx: 0.038, rz: 0.026 },
|
|
{ t: 0.95, c: P(0.52, 0.805, 0.024), rx: 0.034, rz: 0.02 },
|
|
{ t: 1.0, c: P(0.53, 0.778, 0.026), rx: 0.012, rz: 0.01 },
|
|
];
|
|
parts.push(
|
|
// Extra rings through the deltoid so the sphere reads smooth, not faceted.
|
|
loftPart(aKeys, 32, 20, partId, {
|
|
a1: rng.range(-0.02, 0.02), p1: rng.range(0, 6.28),
|
|
a2: rng.range(-0.01, 0.01), p2: rng.range(0, 6.28),
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const s of [1, -1]) {
|
|
const partId = s > 0 ? PART.LEG_L : PART.LEG_R;
|
|
const P = (x, y, z) => V3(s * x, y, z);
|
|
const lKeys = [
|
|
{ t: 0.0, c: P(0.088, 1.02, 0.004), rx: 0.108 * bulk, rz: 0.102 * bulk },
|
|
{ t: 0.1, c: P(0.112, 0.93, 0.006), rx: 0.104 * legF, rz: 0.098 * legF },
|
|
{ t: 0.28, c: P(0.125, 0.76, 0.008), rx: 0.088 * legF, rz: 0.084 * legF },
|
|
{ t: 0.44, c: P(0.13, 0.6, 0.009), rx: 0.068 * legF, rz: 0.064 * legF },
|
|
{ t: 0.52, c: P(0.13, 0.512, 0.008), rx: 0.058 * legF, rz: 0.056 * legF },
|
|
{ t: 0.64, c: P(0.132, 0.38, 0.006), rx: 0.064 * legF, rz: 0.06 * legF },
|
|
{ t: 0.78, c: P(0.133, 0.22, 0.002), rx: 0.05 * legF, rz: 0.046 * legF },
|
|
{ t: 0.86, c: P(0.132, 0.11, -0.004), rx: 0.042, rz: 0.038 },
|
|
{ t: 0.92, c: P(0.13, 0.062, 0.03), rx: 0.044, rz: 0.034 },
|
|
{ t: 0.97, c: P(0.128, 0.04, 0.095), rx: 0.042, rz: 0.028 },
|
|
{ t: 1.0, c: P(0.126, 0.032, 0.155), rx: 0.02, rz: 0.014 },
|
|
];
|
|
parts.push(
|
|
loftPart(lKeys, 30, 18, partId, {
|
|
a1: rng.range(-0.03, 0.03), p1: rng.range(0, 6.28),
|
|
a2: rng.range(-0.015, 0.015), p2: rng.range(0, 6.28),
|
|
}),
|
|
);
|
|
}
|
|
|
|
const merged = mergeGeoms(parts);
|
|
merged.computeVertexNormals();
|
|
merged.userData.physique = { bulk, waistF, shoulderF, headF, armF, legF, style: phy.style };
|
|
return merged;
|
|
}
|
|
|
|
export function buildBodyMesh(geo, skelData, materials) {
|
|
const mesh = new THREE.SkinnedMesh(geo, materials.skin);
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
mesh.frustumCulled = false;
|
|
mesh.add(skelData.bones.root);
|
|
mesh.updateMatrixWorld(true);
|
|
mesh.bind(skelData.skeleton, mesh.matrixWorld.clone());
|
|
mesh.userData.heatMat = new THREE.MeshBasicMaterial({ vertexColors: true });
|
|
mesh.userData.origMat = materials.skin;
|
|
return mesh;
|
|
}
|