Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239import * as T from './vendor/three.mjs';import { clamp } from './controls.mjs';import { OrnamentPhysics } from './ornament-physics.mjs';const unit = new T.Quaternion(), angles = new T.Euler();
export function headRotation(pose, out = new T.Quaternion()) { angles.set( T.MathUtils.degToRad(pose.pitch), T.MathUtils.degToRad(pose.yaw), T.MathUtils.degToRad(pose.roll), 'XYZ', ); // The exporter already aligns the skeleton to glTF. Pose rotations are in the // bone's own axes (Y along the head), which remain unchanged by that conversion. return out.setFromEuler(angles);}export function sampleFace(surface, x, y) { const grid = surface.depth, last = grid.length - 1, range = surface.range ?? 1, u = ((clamp(x, -range, range) / range + 1) * last) / 2, v = ((clamp(y, -range, range) / range + 1) * last) / 2; const ix = Math.min(last - 1, Math.floor(u)), iy = Math.min(last - 1, Math.floor(v)), fx = u - ix, fy = v - iy; return T.MathUtils.lerp( T.MathUtils.lerp(grid[iy][ix], grid[iy][ix + 1], fx), T.MathUtils.lerp(grid[iy + 1][ix], grid[iy + 1][ix + 1], fx), fy, );}export function springStep(state, target, dt, response) { // Exact critically damped spring: no cloth solver, integration drift or wind-up. const omega = 2 / response, decay = Math.exp(-omega * dt), error = state.value - target, c = state.velocity + omega * error; state.value = target + (error + c * dt) * decay; state.velocity = (state.velocity - omega * c * dt) * decay; return state.value;}
export class GhostDeformation { constructor(model, metadata) { this.model = model; this.metadata = metadata; this.head = model.getObjectByName('head'); if (!this.head) throw Error('Head control is missing'); model.updateMatrixWorld(true); this.headRest = this.head.quaternion.clone(); this.headWorld = this.head.getWorldQuaternion(new T.Quaternion()); this.headWorldInverse = this.headWorld.clone().invert(); this.pivot = this.head.getWorldPosition(new T.Vector3()); this.headInverse = new T.Matrix4().copy(this.head.matrixWorld).invert(); this.directionToHead = new T.Matrix3() .setFromMatrix4(this.headInverse) .multiply(new T.Matrix3().setFromMatrix4(model.matrixWorld)); this.eyes = []; // Rigid black buttons follow the head; gaze only ever changes their positions. // Bake the original skin binding into head-local geometry once, including blink morphs. for (const side of ['L', 'R']) { const originals = []; model.traverse((o) => { if ( o.isSkinnedMesh && /^Eye[.]?/.test(o.name) && o.name.replaceAll('.', '').startsWith('Eye' + side) && o.morphTargetDictionary?.Blink !== undefined ) originals.push(o); }); if (!originals.length) throw Error('Eye mesh is missing'); for (const original of originals) { original.skeleton.update(); const indices = original.geometry.getAttribute('skinIndex'), weights = original.geometry.getAttribute('skinWeight'), joint = indices.getX(0); for (let i = 0; i < indices.count; i++) { if (indices.getX(i) !== joint || Math.abs(weights.getX(i) - 1) > 1e-5) throw Error('Eye must have a rigid skin binding'); } const transform = this.headInverse .clone() .multiply(original.skeleton.bones[joint].matrixWorld) .multiply(original.skeleton.boneInverses[joint]) .multiply(original.bindMatrix); const geometry = original.geometry.clone(); geometry.applyMatrix4(transform); const linear = new T.Matrix3().setFromMatrix4(transform), normal = new T.Matrix3().getNormalMatrix(transform), v = new T.Vector3(); for (const [kind, attributes] of Object.entries(geometry.morphAttributes)) for (const attr of attributes) { for (let i = 0; i < attr.count; i++) { v.fromBufferAttribute(attr, i); if (kind === 'position') geometry.morphTargetsRelative ? v.applyMatrix3(linear) : v.applyMatrix4(transform); else if (kind === 'normal') v.applyMatrix3(normal); attr.setXYZ(i, v.x, v.y, v.z); } attr.needsUpdate = true; } geometry.deleteAttribute('skinIndex'); geometry.deleteAttribute('skinWeight'); geometry.computeBoundingBox(); geometry.computeBoundingSphere(); const eye = new T.Mesh(geometry, original.material); eye.name = original.name + '_sliding'; eye.castShadow = true; eye.receiveShadow = true; eye.morphTargetDictionary = { ...original.morphTargetDictionary }; eye.morphTargetInfluences = [...original.morphTargetInfluences]; this.head.add(eye); original.visible = false; this.eyes.push({ side, mesh: eye }); } } this.rings = (metadata.sheet_rings ?? []).map(({ name, height }) => { const bone = model.getObjectByName(name) ?? model.getObjectByName(name.replaceAll('.', '')); if (!bone) throw Error('Sheet deformation ring is missing'); return { bone, height, restPosition: bone.position.clone(), restQuaternion: bone.quaternion.clone(), worldPosition: bone.getWorldPosition(new T.Vector3()), worldQuaternion: bone.getWorldQuaternion(new T.Quaternion()), spring: Object.fromEntries( ['pitch', 'yaw', 'roll'].map((k) => [k, { value: 0, velocity: 0 }]), ), }; }); if (this.rings.length !== 6) throw Error('Sheet needs six deformation rings'); // The authored hat already has a continuous, weighted chain. Leave its base // alone and delay only the hanging section; stitches and pom fibers follow it. this.hatTail = []; for (let index = 3; index <= 7; index++) { const bone = model.getObjectByName('hat' + String(index).padStart(2, '0')); if (!bone) throw Error('Hat tail control is missing'); this.hatTail.push({ bone, progress: (index - 2) / 5, restPosition: bone.position.clone(), restQuaternion: bone.quaternion.clone(), worldQuaternion: bone.getWorldQuaternion(new T.Quaternion()), spring: Object.fromEntries( ['pitch', 'yaw', 'roll'].map((k) => [k, { value: 0, velocity: 0 }]), ), }); } this.ornaments = new OrnamentPhysics(model, { includePom: true }); this.reset(); } reset() { for (const control of [...this.rings, ...this.hatTail]) { for (const state of Object.values(control.spring)) { state.value = 0; state.velocity = 0; } if (this.hatTail.includes(control)) { control.bone.position.copy(control.restPosition); control.bone.quaternion.copy(control.restQuaternion); } } this.ornaments?.reset(); } update(pose, dt, hatSoftness = 1, hatGain = 1, ornamentsEnabled = true, pomSettings = {}) { this.head.quaternion.copy(this.headRest).multiply(headRotation(pose)); for (const eye of this.eyes) { const surface = this.metadata.gaze_surfaces[eye.side], depth = sampleFace(surface, pose.gazeX, pose.gazeY); eye.mesh.position .set(surface.travel[0] * pose.gazeX, surface.travel[1] * pose.gazeY, -depth) .applyMatrix3(this.directionToHead); // Identity relative orientation is deliberate: gaze never rotates an eye. eye.mesh.quaternion.identity(); eye.mesh.morphTargetInfluences[eye.mesh.morphTargetDictionary.Blink] = eye.side === 'L' ? pose.blinkL : pose.blinkR; } for (const ring of this.rings) { const t = clamp((ring.height - 0.12) / (2.05 - 0.12), 0, 1), follow = t * t * (3 - 2 * t), lagMix = 0.58 * (1 - t); const delayed = {}; for (const key of ['pitch', 'yaw', 'roll']) { const lagged = springStep( ring.spring[key], pose[key], Math.min(dt, 0.05), 0.2 + 0.36 * (1 - t), ); delayed[key] = T.MathUtils.lerp(pose[key], lagged, lagMix); } const worldRotation = headRotation(delayed); // Express the partial head turn in avatar/world space around the neck pivot. worldRotation.premultiply(this.headWorld).multiply(this.headWorldInverse); const rotation = unit.clone().slerp(worldRotation, follow); const position = ring.worldPosition .clone() .sub(this.pivot) .applyQuaternion(rotation) .add(this.pivot); const quaternion = rotation.clone().multiply(ring.worldQuaternion); ring.bone.parent.updateWorldMatrix(true, false); ring.bone.position.copy(ring.bone.parent.worldToLocal(position)); ring.bone.quaternion .copy(ring.bone.parent.getWorldQuaternion(new T.Quaternion()).invert()) .multiply(quaternion); } for (const tail of this.hatTail) { const softness = clamp(hatSoftness, 0, 2), t = tail.progress, blend = t * t * (3 - 2 * t) * Math.min(softness, 1) * 0.95, delayed = {}; for (const key of ['pitch', 'yaw', 'roll']) { const lagged = springStep( tail.spring[key], pose[key], Math.min(dt, 0.05), (0.18 + 0.34 * t) * Math.max(0.1, softness), ); delayed[key] = T.MathUtils.lerp(pose[key], lagged, blend * clamp(hatGain, 0, 4)); } const rotation = headRotation(delayed) .premultiply(this.headWorld) .multiply(this.headWorldInverse); const desired = rotation.multiply(tail.worldQuaternion); tail.bone.parent.updateWorldMatrix(true, false); tail.bone.position.copy(tail.restPosition); tail.bone.quaternion .copy(tail.bone.parent.getWorldQuaternion(new T.Quaternion()).invert()) .multiply(desired); } this.ornaments.update(dt, ornamentsEnabled, pomSettings); }}