1// ããåã¢ãã«å·¥æ¿ â the "actor" layer on top of the ç§å¯ã®æ£® Kashiko GLB. 2// 3// What it adds to the plain clips: 4// * locomotion that blends idle â walk â run â sprint by speed, with the three 5// gaits phase-locked and played at the rate the feet actually travel 6// (stride speeds are measured from the clips at load, so feet don't skate) 7// * upper-body actions (wave while walking) via split upper / lower tracks, 8// and full-body actions that cross-fade in and out of locomotion 9// * procedural acting on top: lean into turns and acceleration, breathing, 10// head / neck look-at, idle glances 11// * secondary motion: hair morphs, the two hair tails (TailRoot bones) and 12// the smock hem react to her movement through damped springs 13// * the natural blink from ããåã¨ãã£ã³ãã© (motions.js), plus eye states 14// (closed for sleep, squeezed for effort) 15const T = window.THREE; 16const clamp = (v, a, b) => v < a ? a : v > b ? b : v; 17const damp = (a, b, k, dt) => a + (b - a) * (1 - Math.exp(-k * dt)); 18 19// Bones that belong to the upper body. Everything else (hips, legs, the lowest 20// spine, the hem morph) stays with the legs. 21const UPPER = /^(Spine01|Spine02|neck|Head|head_end|headfront|(Left|Right)(Shoulder|Arm|ForeArm|Hand|Thumb|Index|Middle|Ring|Pinky)\d?|TailRoot_[LR]|(Left|Right)SleeveForearm|char1|EyeIndependent_[LR])\./; 22 23function splitClip(clip) { 24 const up = [], low = []; 25 for (const tr of clip.tracks) (UPPER.test(tr.name) ? up : low).push(tr); 26 return {upper: new T.AnimationClip(clip.name + '#upper', clip.duration, up), lower: new T.AnimationClip(clip.name + '#lower', clip.duration, low)}; 27} 28 29export const ACTIONS = { 30 // key: clip, how it layers, label, playback speed, whether she can keep walking 31 wave: {clip: 'greeting', layer: 'upper', label: 'æããµã', speed: 1.1, cut: [0.2, 3.2], happy: true, arms: 'wave'}, 32 clap: {clip: 'clap_dance', layer: 'full', label: 'ææåãã³ã¹', speed: 1}, 33 dance: {clip: 'dance', layer: 'full', label: 'ãã³ã¹', speed: 1}, 34 disco: {clip: 'disco', layer: 'full', label: 'ãã£ã¹ã³', speed: 1}, 35 twist: {clip: 'twist', layer: 'full', label: 'ãã¤ã¹ã', speed: 1}, 36 turn: {clip: 'turn_step', layer: 'full', label: 'ããã£ã¨ã¹ããã', speed: 1}, 37 punch: {clip: 'punch_combo', layer: 'full', label: 'ãã³ã飿', speed: 1.15, effort: true, tuck: .85}, 38 kick: {clip: 'front_kick', layer: 'full', label: 'åãã', speed: 1.15, effort: true, arms: 'guard'}, 39 dodge: {clip: 'dodge', layer: 'full', label: 'ããã', speed: 1.2, tuck: .85}, 40 bow: {clip: 'bow', layer: 'full', label: 'ããã', speed: 1, arms: 'hang'}, 41 victory: {clip: 'victory', layer: 'full', label: 'ãã£ãã¼', speed: 1, happy: true}, 42 hop: {clip: 'hop', layer: 'full', label: 'ã´ãã', speed: 1}, 43 swing: {clip: 'swing', layer: 'full', label: 'ã¹ã¤ã³ã°', speed: 1, cut: [1.15, 3.13], fade: .3}, 44 sweep: {clip: 'rogue_sweep', layer: 'full', label: 'ãªãã¯ãã', speed: .9, effort: true}, 45 thrust: {clip: 'rogue_thrust',layer: 'full', label: 'ã¤ã', speed: .9, effort: true}, 46 smash: {clip: 'rogue_smash', layer: 'full', label: 'ãããã¤ã', speed: .9, effort: true}, 47 throw: {clip: 'rogue_throw', layer: 'upper', label: 'ã²ããã»ãªãã', speed: .9}, 48 cast: {clip: 'rogue_cast', layer: 'upper', label: 'ã¾ã»ã', speed: .8}, 49 hook: {clip: 'rogue_hook', layer: 'full', label: 'ããã¯', speed: .9, effort: true, tuck: .6}, 50 sleep: {clip: 'sleep_prone', layer: 'full', label: 'ãã²ãã', speed: 1, loop: true, eyes: 0, unplant: 50, fade: .6, kneel: 1}, 51 // Snappy moves cut from the long show clips. Pressing again while one plays 52 // carries on into the next segment of the same clip (no pose jump). 53 jab1: {clip: 'punch_combo', layer: 'upper', label: 'ãã³ã', speed: 1.7, cut: [0.5, 1.45], effort: true, fade: .14, tuck: .85}, 54 jab2: {clip: 'punch_combo', layer: 'upper', label: 'ãã³ã', speed: 1.7, cut: [1.45, 2.3], effort: true, fade: .14, tuck: .85}, 55 jab3: {clip: 'punch_combo', layer: 'upper', label: 'ãã³ã', speed: 1.7, cut: [2.3, 3.15], effort: true, fade: .14, tuck: .85}, 56 jab4: {clip: 'punch_combo', layer: 'upper', label: 'ãã³ã', speed: 1.7, cut: [3.15, 4.05], effort: true, fade: .14, tuck: .85}, 57 kick1: {clip: 'front_kick', layer: 'full', label: 'ããã¯', speed: 1.6, cut: [0.55, 2.1], effort: true, fade: .16, arms: 'guard'}, 58 kick2: {clip: 'front_kick', layer: 'full', label: 'ããã¯', speed: 1.6, cut: [2.1, 4.3], effort: true, fade: .16, arms: 'guard'}, 59 weave: {clip: 'dodge', layer: 'full', label: 'ã¦ã£ã¼ãã³ã°', speed: 1.7, cut: [0.25, 2.4], fade: .16, tuck: .85}, 60}; 61const GAITS = ['walk', 'run', 'sprint']; 62 63export function createActor(gltf, {height = 1.43, eyeStates = null} = {}) { 64 // eyeStates: {texture, region: [x0, y0, w, h]} â a 4-row flipbook (open / half / closed / smile) 65 // painted over the baked front view of the eyes; when given, blinks and eye 66 // expressions swap pictures instead of folding the painted eye. 67 const hero = gltf.scene; 68 const box = new T.Box3().setFromObject(hero); 69 const s = height / box.getSize(new T.Vector3()).y; 70 hero.scale.setScalar(s);
71 hero.position.set(-(box.min.x + box.max.x) * s / 2, -box.min.y * s, -(box.min.z + box.max.z) * s / 2); 72 const root = new T.Group(); root.add(hero); 73 hero.traverse(n => { if (n.isMesh) { n.castShadow = true; n.frustumCulled = false; } }); 74 const bone = n => hero.getObjectByName(n); 75 const B = {hips: bone('Hips'), spine: bone('Spine'), spine1: bone('Spine01'), spine2: bone('Spine02'), neck: bone('neck'), head: bone('Head'), 76 tailL: bone('TailRoot_L'), tailR: bone('TailRoot_R'), footL: bone('LeftFoot'), footR: bone('RightFoot'), armL: bone('LeftArm'), armR: bone('RightArm'), foreL: bone('LeftForeArm'), foreR: bone('RightForeArm'), 77 handL: bone('LeftHand'), handR: bone('RightHand'), clavL: bone('LeftShoulder'), clavR: bone('RightShoulder'), midL: bone('LeftMiddle1'), midR: bone('RightMiddle1')}; 78 // NB the spine chain runs Hips â Spine02 â Spine01 â Spine (chest) â neck / shoulders 79 const clips = {}; for (const c of gltf.animations) clips[c.name] = c; 80 // The looping clips were exported with the loop pose doubled at both ends 81 // (A,A,Bâ¦Y,A,A): every cycle the whole body stood still for 2 frames 82 // (67 ms â 13% of a sprint stride) and then jumped. Keep one A at each end. 83 for (const n of ['walk', 'run', 'sprint']) { 84 const c = clips[n]; if (!c || window.__seamQA === 0) continue; 85 const m = Math.max(...c.tracks.map(t => t.times.length)); if (m < 5) continue; 86 const full = c.tracks.filter(t => t.times.length === m), ref = full[0]; 87 const dup = t => { const s = t.getValueSize(), v = t.values, eq = (i, j) => { for (let k = 0; k < s; k++) if (Math.abs(v[i * s + k] - v[j * s + k]) > 1e-4) return false; return true; }; return eq(0, 1) && eq(m - 2, m - 1); }; 88 if (full.filter(dup).length < full.length * .6) continue; 89 const dt = ref.times[1] - ref.times[0]; 90 // resample every track on k1â¦k(m-2) of the original timeline 91 const N = m - 2, times = new Float32Array(N); 92 for (let j = 0; j < N; j++) times[j] = j * dt; 93 for (const t of c.tracks) { 94 const s = t.getValueSize(), it = t.createInterpolant(), vals = new Float32Array(N * s); 95 for (let j = 0; j < N; j++) { const r = it.evaluate(ref.times[0] + (j + 1) * dt); for (let k = 0; k < s; k++) vals[j * s + k] = r[k]; } 96 t.times = times.slice(); t.values = vals; 97 if (t instanceof T.QuaternionKeyframeTrack) for (let j = 0; j < N; j++) { const q = new T.Quaternion().fromArray(vals, j * 4).normalize(); q.toArray(vals, j * 4); } 98 } 99 c.duration = (N - 1) * dt; 100 } 101 // The legs also jump from standing still to full speed within one key 102 // (knee 3° â 21° per 33 ms at toe-off): soften the gait legs with a light 103 // [1 2 1] filter round the loop (the last key repeats the first). 104 for (const n of ['walk', 'run', 'sprint']) { 105 const c = clips[n]; if (!c || window.__smoothQA === 0) continue; 106 for (const t of c.tracks) { 107 if (!(t instanceof T.QuaternionKeyframeTrack) || !/(UpLeg|Leg|Foot|Hips)\.quaternion$/.test(t.name)) continue; 108 const N = t.times.length - 1; if (N < 4) continue; // unique keys in the cycle 109 const q = [...Array(N)].map((_, j) => new T.Quaternion().fromArray(t.values, j * 4)); 110 const a = new T.Quaternion(), out = new Float32Array(t.values.length); 111 for (let j = 0; j < N; j++) { 112 a.copy(q[(j + N - 1) % N]).slerp(q[(j + 1) % N], .5).slerp(q[j], .5); 113 a.toArray(out, j * 4); 114 } 115 for (let k = 0; k < 4; k++) out[N * 4 + k] = out[k]; 116 t.values = out; 117 } 118 } 119 const mixer = new T.AnimationMixer(hero); 120 121 // ---------- locomotion (split so an upper-body action can replace the top) ---------- 122 const loco = {}; 123 for (const n of ['idle', ...GAITS]) { 124 const {upper, lower} = splitClip(clips[n]); 125 loco[n] = {up: mixer.clipAction(upper), low: mixer.clipAction(lower), w: n === 'idle' ? 1 : 0, dur: clips[n].duration}; 126 for (const a of [loco[n].up, loco[n].low]) { a.play(); a.weight = loco[n].w; if (n !== 'idle') a.timeScale = 0; } 127 } 128 129 // Natural ground speed of each gait: how fast the planted foot slides back 130 // in the in-place clip (model units â metres through the fit scale). 131 const gaitSpeed = {walk: .33, run: 1, sprint: 1.4}; 132 // How fast she actually travels in each gait. Her chibi legs have a tiny 133 // stride, so the clips are played faster (up to MAX_RATE) to keep up. 134 const moveSpeed = {walk: .7, run: 1.9, sprint: 2.8}, MAX_RATE = 2.0; 135 // Stride lengthening: played at clip stride, her little steps meant ~225 136 // steps/min walking (young children: ~150-170). The feet are carried 137 // further fore and aft by leg IK instead, so the same speed needs fewer steps. 138 const STRIDE = {walk: 1.3, run: 1.35, sprint: 1.3}; 139 function measureGaits() { 140 const m2 = new T.AnimationMixer(hero), p = new T.Vector3(), inv = new T.Matrix4(); 141 for (const n of GAITS) { 142 const a = m2.clipAction(clips[n]); a.play(); const dur = clips[n].duration, N = 48, pts = []; 143 for (let i = 0; i <= N; i++) {
144 m2.setTime(dur * i / N); hero.updateMatrixWorld(true); inv.copy(root.matrixWorld).invert(); 145 for (const f of [B.footL, B.footR]) { f.getWorldPosition(p).applyMatrix4(inv); pts.push({i, f, y: p.y, z: p.z}); } 146 } 147 let sum = 0, cnt = 0; 148 for (const f of [B.footL, B.footR]) { 149 const seq = pts.filter(q => q.f === f), minY = Math.min(...seq.map(q => q.y)); 150 for (let i = 1; i < seq.length; i++) if (seq[i].y < minY + .02 && seq[i - 1].y < minY + .02 && seq[i].z < seq[i - 1].z) { sum += (seq[i - 1].z - seq[i].z) / (dur / N); cnt++; } 151 } 152 if (cnt > 2 && sum / cnt > .2) gaitSpeed[n] = sum / cnt; 153 if (n === 'walk') ankleY = Math.min(...pts.map(q => q.y)); 154 a.stop(); m2.uncacheClip(clips[n]); 155 } 156 m2.stopAllAction(); 157 } 158 // Store the fit transform so a measurement pass can't drift the model. 159 const heroPos = hero.position.clone(); 160 let ankleY = .07; 161 try { measureGaits(); } catch (e) { console.warn('gait measure', e); } 162 hero.position.copy(heroPos); 163 164 // ---------- actions ---------- 165 const act = {cur: null, def: null, t: 0, len: 0, w: 0, fade: .22, a: null, key: null, queue: null, onEnd: null}; 166 const actionCache = {}; 167 function actionFor(def) { 168 const k = def.clip + '|' + def.layer; 169 if (!actionCache[k]) { 170 const c = def.layer === 'upper' ? splitClip(clips[def.clip]).upper : clips[def.clip]; 171 const a = mixer.clipAction(c); a.setLoop(T.LoopRepeat, Infinity); a.weight = 0; actionCache[k] = a; 172 } 173 return actionCache[k]; 174 } 175 function play(key, {onEnd} = {}) { 176 const def = ACTIONS[key]; if (!def || !clips[def.clip]) return 0; 177 // chain: the same clip, starting where the current segment ends â just keep playing 178 if (busy() && act.def.clip === def.clip && act.def.layer === def.layer && def.cut && Math.abs(def.cut[0] - act.cut[1]) < .08) { 179 act.len += (def.cut[1] - def.cut[0]) / (def.speed || 1); act.cut[1] = def.cut[1]; act.def = def; act.key = key; 180 if (def.effort) face.effort = .6; 181 return act.len - act.t; 182 } 183 // hand the old action over to a short fade-out instead of cutting it 184 if (act.a && act.a !== actionFor(def)) { 185 if (act.prev) act.prev.a.stop(); 186 act.prev = act.w > .02 ? {a: act.a, w: act.w, full: act.def?.layer === 'full'} : (act.a.stop(), null); 187 } 188 const a = actionFor(def); a.reset(); a.play(); a.timeScale = def.speed || 1; 189 const [c0, c1] = def.cut || [0, clips[def.clip].duration]; 190 a.time = c0; act.cut = [c0, c1]; 191 act.fade = def.fade || .22; 192 act.a = a; act.def = def; act.key = key; act.t = 0; act.len = def.loop ? Infinity : (c1 - c0) / (def.speed || 1); act.onEnd = onEnd; 193 act.w = act.prev ? 0 : act.w > 0 && act.prevLayer === def.layer ? act.w : 0; act.prevLayer = def.layer; 194 if (def.effort) face.effort = .6; 195 if (def.happy) face.happy = Math.min(act.len, 2.2); 196 return act.len; 197 } 198 function stop() { if (act.def) act.len = Math.min(act.len, act.t + act.fade); } 199 const busy = () => !!act.def && act.t < act.len; 200 const blocksMove = () => busy() && act.def.layer === 'full'; 201 202 // ---------- eyes: natural blink (from ããåã¨ãã£ã³ãã© motions.js) ---------- 203 const face = {override: null, effort: 0, happy: 0}; 204 const lidMorphs = []; 205 hero.traverse(n => { const dict = n.morphTargetDictionary; if (!dict) return; 206 for (const key of ['Blink_L', 'Blink_R', 'LidArc_L', 'LidArc_R']) if (dict[key] !== undefined) lidMorphs.push([n, dict[key]]); }); 207 const eyes = {L: {open: {value: 1}}, R: {open: {value: 1}}}, faceParts = []; 208 hero.traverse(n => { if (!n.isMesh) return; const m = /^EyeOriginal_([LR])_([123])$/.exec(n.name); if (m) { faceParts.push(n); if (m[2] === '2') eyes[m[1]].eye = n; } }); 209 const EYE_GLSL = ` 210 float eyeW(float x, vec4 a, vec4 b, float soft) { 211 float dx = (x - a.x) * b.y, lim = dx > 0.0 ? a.y + b.z : a.y + .014; 212 return 1.0 - smoothstep(lim - soft, lim + b.w, abs(dx)); 213 } 214 float eyeTop(float x, vec4 a, vec4 b) { 215 float dx = (x - a.x) * b.y, lim = dx > 0.0 ? a.y + b.z : a.y + .014; 216 float t = clamp(abs(dx) / (lim * 1.12), 0.0, 1.0); 217 return a.z + (a.w - a.z) * (.35 + .65 * pow(1.0 - t * t * t, 1.0 / 3.0)) + .006 * t; 218 } 219 // Where the lids meet. Normally the lower lid line (a.z); smiling lifts its 220 // middle into an arch, so a closed eye reads as a happy "^". 221 float eyePivot(float x, vec4 a, vec4 b) { 222 float dx = (x - a.x) * b.y, lim = dx > 0.0 ? a.y + b.z : a.y + .014; 223 float t = clamp(abs(dx) / lim, 0.0, 1.0); 224 return a.z + (a.w - a.z) * .42 * uEyeSmile * (1.0 - t * t); 225 } 226 // Lowest point that folds in: the lower lid, or the eye's bottom edge when 227 // the whole eye must close into one line (sleep, smile). 228 float eyeLow(vec4 a, vec4 b) { return mix(a.z, b.x, max(uEyeSmile, uEyeShut)); } 229 float eyeLid(float x, float y, vec4 a, vec4 b, float open) { 230 float w = eyeW(x, a, b, 0.0), top = eyeTop(x, a, b), lo = eyeLow(a, b); 231 if (w <= 0.0 || y <= lo || y > top) return y; 232 float piv = eyePivot(x, a, b); 233 // a full close (sleep / smile) is painted by eyeDraw, so don't fold 234 open = mix(open, 1.0, max(uEyeSmile, uEyeShut) * (1.0 - smoothstep(.15, .5, open))); 235 return y - (y - piv) * (1.0 - open) * w; 236 }
237 float eyeRemnant(vec2 p, vec4 a, vec4 b, float open) { 238 float w = eyeW(p.x, a, b, .004), top = eyeTop(p.x, a, b); 239 if (w <= 0.0 || p.y <= top - .002 || p.y > top + .015) return 0.0; 240 return w * smoothstep(0.0, .35, 1.0 - open) * smoothstep(top - .002, top + .001, p.y) * (1.0 - smoothstep(top + .009, top + .015, p.y)); 241 } 242 float eyeBand(vec2 p, vec4 a, vec4 b, float open) { 243 float w = eyeW(p.x, a, b, 0.0); 244 if (w <= 0.0 || open > .995) return 0.0; 245 float top = eyeTop(p.x, a, b), piv = eyePivot(p.x, a, b), lo = eyeLow(a, b); 246 float movedTop = top - (top - piv) * (1.0 - open) * w, movedLow = lo + (piv - lo) * (1.0 - open) * w; 247 return ((p.y > movedTop && p.y <= top + .0005) || (p.y < movedLow - .0015 && p.y >= lo - .0005)) ? 1.0 : 0.0; 248 } 249 // Fully closed eyes (sleep / smile) are painted, not folded: the eye area 250 // becomes lid skin and a lash-coloured line is drawn across it â a soft 251 // downward arc when sleeping, a "^" arch when smiling. 252 vec2 eyeDraw(vec2 p, vec4 a, vec4 b, float open) { 253 float closing = (1.0 - smoothstep(.15, .5, open)) * max(uEyeSmile, uEyeShut); 254 if (closing <= 0.0) return vec2(0.0); 255 float dx = (p.x - a.x) * b.y, lim = dx > 0.0 ? a.y + b.z + .012 : a.y + .04; 256 if (abs(dx) > lim || abs(p.x) > .166) return vec2(0.0); // never over the side locks (storybook hair beyond |x| .17) 257 float ts = clamp(abs(dx) / lim, 0.0, 1.0); 258 float top = eyeTop(p.x, a, b) + .005 + .02 * ts * ts, lo = eyeLow(a, b) - .012; // reach the painted rim at the corners too 259 if (p.y < lo || p.y > min(top, .912)) return vec2(0.0); 260 float h = a.w - a.z, t = clamp(abs(dx) / (a.y + .004), 0.0, 1.0); 261 float bow = uEyeSmile * .5 - uEyeShut * (1.0 - uEyeSmile) * .16; 262 float lineY = a.z + h * (.28 - uEyeSmile * .08) + h * bow * (1.0 - t * t); 263 float thick = .0045 * (1.0 - pow(t, 3.0)) + .0008; 264 float ink = (1.0 - smoothstep(thick, thick + .0014, abs(p.y - lineY))) * (1.0 - smoothstep(.9, 1.0, t)); 265 return vec2(closing, ink * closing); 266 } 267 float eyeGaze(vec2 p, vec4 a, vec4 b) { 268 vec2 c = vec2(a.x, (b.x + a.w) * .5), r = vec2(a.y * .98, (a.w - b.x) * .5); 269 float d = length((p - c) / r); 270 return 1.0 - smoothstep(.35, 1.0, d); 271 } 272 // Gaze: vertices inside each eye slide toward where she looks, most at the 273 // centre and not at all at the rim, so the painted iris glides inside the 274 // white (the texture moves with the vertices, whatever its UV layout). 275 // Wide (surprised) eyes: the eye swells from its centre, fading out a bit 276 // beyond its rim so the skin around stays joined. 277 vec2 eyeBulge(vec2 p, vec4 a, vec4 b) { 278 vec2 c = vec2(a.x, (b.x + a.w) * .5), r = vec2(a.y * 1.05, (a.w - b.x) * .52); 279 float d = length((p - c) / r); 280 return (p - c) * uEyeWide * .14 * (1.0 - smoothstep(.85, 1.45, d)); 281 } 282 vec2 eyeGazeShift(vec2 p) { 283 return uEyeLook * max(eyeGaze(p, uEyeLA, uEyeLB), eyeGaze(p, uEyeRA, uEyeRB)) + eyeBulge(p, uEyeLA, uEyeLB) + eyeBulge(p, uEyeRA, uEyeRB); 284 } 285 float eyeLids(vec2 p) { 286 p += eyeGazeShift(p); 287 float y = eyeLid(p.x, p.y, uEyeLA, uEyeLB, uEyeOpenL); 288 return eyeLid(p.x, y, uEyeRA, uEyeRB, uEyeOpenR); 289 }`; 290 const eyeU = {uEyeLA: {value: new T.Vector4()}, uEyeLB: {value: new T.Vector4()}, uEyeRA: {value: new T.Vector4()}, uEyeRB: {value: new T.Vector4()}, 291 uEyeOpenL: eyes.L.open, uEyeOpenR: eyes.R.open, uEyeSkin: {value: new T.Color('#fbdcc8')}, uEyeSmile: {value: 0}, uEyeShut: {value: 0}, uEyeLook: {value: new T.Vector2()}, uEyeWide: {value: 0}, uBlush: {value: 0}, 292 uEyeTex: {value: eyeStates?.texture || null}, uEyeRegion: {value: new T.Vector4(...(eyeStates?.region || [0, 0, 1, 1]))}, uEyeState: {value: 0}, uEyeOn: {value: eyeStates ? 1 : 0}}; 293 const decl = 'uniform vec4 uEyeLA; uniform vec4 uEyeLB; uniform vec4 uEyeRA; uniform vec4 uEyeRB; uniform float uEyeOpenL; uniform float uEyeOpenR; uniform vec3 uEyeSkin; uniform float uEyeSmile; uniform float uEyeShut; uniform vec2 uEyeLook; uniform float uEyeWide; uniform float uBlush; uniform sampler2D uEyeTex; uniform vec4 uEyeRegion; uniform float uEyeState; uniform float uEyeOn;\n' 294 + 'varying vec2 vEyeOrig; varying float vEyeDefY;\n'; 295 function sampleSkin(mesh, point) { 296 try { 297 const pos = mesh.geometry.attributes.position, uv = mesh.geometry.attributes.uv, img = mesh.material.map?.image; 298 if (!uv || !img) return; 299 let best = -1, bd = Infinity; 300 for (let i = 0; i < pos.count; i += 3) { const d = (pos.getX(i) - point.x) ** 2 + (pos.getY(i) - point.y) ** 2; if (d < bd) { bd = d; best = i; } } 301 const c = document.createElement('canvas'); c.width = c.height = 1; const g = c.getContext('2d');
302 const w = img.width, h = img.height, u = uv.getX(best), v = uv.getY(best); 303 g.drawImage(img, Math.floor(u * w) - 2, Math.floor(v * h) - 2, 5, 5, 0, 0, 1, 1); 304 const [r, gg, bb] = g.getImageData(0, 0, 1, 1).data; 305 eyeU.uEyeSkin.value.setRGB(r / 255, gg / 255, bb / 255, T.SRGBColorSpace); 306 } catch (_) { /* keep the default skin tone */ } 307 } 308 // The storybook look paints a BOX of hair colour on the side of the head 309 // (behind the side locks); its bottom-front corner showed as a square block 310 // of hair in front of the ear. Without it the side of the face is plain 311 // skin up to the side locks, and the front and 3/4 views don't change. 312 // (a sloping "sideburn" edge instead still read as a hard wedge) 313 function softenHairBacking() { 314 const seen = new Set(); 315 hero.traverse(n => { 316 if (!n.isMesh) return; 317 for (const m of Array.isArray(n.material) ? n.material : [n.material]) { 318 if (!m || seen.has(m) || !m.onBeforeCompile || m.userData.hairline) continue; 319 seen.add(m); m.userData.hairline = true; 320 const inner = m.onBeforeCompile, key = m.customProgramCacheKey; 321 m.onBeforeCompile = function (shader, renderer) { 322 inner.call(this, shader, renderer); 323 // keep the backing behind the fringe (without it skin showed inside 324 // the side locks) but end it softly above the ear instead of a box corner 325 if (window.__hbQA !== 1) shader.fragmentShader = shader.fragmentShader.replace(/float hairBacking=[^;]*;/, `float hbE=length(vec2((paperPosition.y-0.95)/(0.95-HB_LO),(paperPosition.z-0.075)/0.068)); 326 float hairBacking=smoothstep(0.143,0.155,abs(paperPosition.x)) 327 *(1.0-smoothstep(0.224,0.239,abs(paperPosition.x))) 328 *(1.0-smoothstep(0.965,1.0,hbE)) 329 *(1.0-smoothstep(0.940,0.958,paperPosition.y));`.replace('HB_LO', (window.__hbLo ?? .76).toFixed(3))); 330 }; 331 m.customProgramCacheKey = function () { return (key ? key.call(this) : '') + '-backing' + (window.__hbQA === 1 ? '-qa' : '') + (window.__hbLo ?? .76); }; 332 m.needsUpdate = true; 333 } 334 }); 335 } 336 // Call after the storybook materials are attached (it wraps them). 337 function setupBlink() { 338 setupPacifier(); setupBag(); softenHairBacking(); 339 for (const [side, eye] of Object.entries(eyes)) { 340 if (!eye.eye) continue; 341 eye.eye.geometry.computeBoundingBox(); 342 const b = eye.eye.geometry.boundingBox, h = b.max.y - b.min.y, cx = (b.min.x + b.max.x) / 2; 343 eyeU['uEye' + side + 'A'].value.set(cx, (b.max.x - b.min.x) / 2, b.min.y + h * .16, Math.min(b.max.y + h * .2, .909)); 344 const soft = .006, outer = Math.max(0, Math.min(h * .22, .168 - (Math.abs(cx) + (b.max.x - b.min.x) / 2) - soft)); 345 eyeU['uEye' + side + 'B'].value.set(b.min.y - h * .04, Math.sign(cx) || 1, outer, soft); 346 if (side === 'L') { const patch = faceParts.find(n => n.name === 'EyeOriginal_L_1'); if (patch) sampleSkin(patch, {x: cx - Math.sign(cx) * ((b.max.x - b.min.x) / 2 + h * .35), y: (b.min.y + b.max.y) / 2 + h * .15}); } 347 } 348 const cache = new Map(); 349 for (const n of faceParts) { 350 const base = n.material; 351 if (!cache.has(base)) { 352 const mat = base.clone(), inner = base.onBeforeCompile; 353 mat.onBeforeCompile = (shader, renderer) => { 354 if (inner) inner.call(mat, shader, renderer); 355 // The storybook look repaints hair-coloured paint as fringe everywhere 356 // except a RECTANGULAR "eye window"; its corners showed as a square 357 // edge in the hair beside the eyes. Use a soft ellipse round each eye. 358 shader.fragmentShader = shader.fragmentShader.replace(/float eyeWindow=step\(0\.035[^;]*;/, 359 `float eyeWindow=1.0-smoothstep(.92,1.0,min(length((paperPosition.xy-vec2(-.105,.858))/vec2(.071,.08)),length((paperPosition.xy-vec2(.105,.858))/vec2(.071,.08))));`); 360 Object.assign(shader.uniforms, eyeU); 361 shader.vertexShader = decl + EYE_GLSL + '\n' + shader.vertexShader.replace('#include <begin_vertex>', `#include <begin_vertex> 362 vEyeOrig = transformed.xy; 363 transformed.x += eyeGazeShift(vEyeOrig).x; 364 transformed.y = eyeLids(vEyeOrig); 365 vEyeDefY = transformed.y;`); 366 shader.fragmentShader = decl + EYE_GLSL + '\n' + shader.fragmentShader.replace('#include <map_fragment>', `#include <map_fragment> 367 vec2 eyeHere = vec2(vEyeOrig.x, vEyeDefY); 368 if (max(eyeBand(eyeHere, uEyeLA, uEyeLB, uEyeOpenL), eyeBand(eyeHere, uEyeRA, uEyeRB, uEyeOpenR)) > 0.5 369 || abs(eyeLids(vEyeOrig) - vEyeDefY) > .004) 370 diffuseColor.rgb = uEyeSkin; 371 else { 372 float eyeRem = max(eyeRemnant(vEyeOrig, uEyeLA, uEyeLB, uEyeOpenL), eyeRemnant(vEyeOrig, uEyeRA, uEyeRB, uEyeOpenR)); 373 vec3 eyeC = diffuseColor.rgb; float eyeMx = max(eyeC.r, max(eyeC.g, eyeC.b)), eyeSat = (eyeMx - min(eyeC.r, min(eyeC.g, eyeC.b))) / max(eyeMx, 1e-4); 374 float eyeDark = max(1.0 - smoothstep(.2, .6, dot(eyeC, vec3(.299, .587, .114))), 1.0 - smoothstep(.3, .5, eyeSat / .55)); 375 diffuseColor.rgb = mix(diffuseColor.rgb, uEyeSkin, eyeRem * eyeDark); 376 } 377 // cheeks flush (shy, happy, out of breath): a soft pink under each eye 378 if (uBlush > .001) { 379 for (int i = 0; i < 2; i++) { 380 vec4 ea = i == 0 ? uEyeLA : uEyeRA; vec4 eb = i == 0 ? uEyeLB : uEyeRB; 381 vec2 c = vec2(ea.x + eb.y * ea.y * .35, eb.x - .004), r = vec2(ea.y * 1.0, (ea.w - eb.x) * .28); 382 float bl = uBlush * (1.0 - smoothstep(.2, 1.0, length((vEyeOrig - c) / r))); 383 diffuseColor.rgb = mix(diffuseColor.rgb, diffuseColor.rgb * vec3(1.0, .74, .76), bl * .6); 384 } 385 } 386 vec2 eyeDL = eyeDraw(vEyeOrig, uEyeLA, uEyeLB, uEyeOpenL), eyeDR = eyeDraw(vEyeOrig, uEyeRA, uEyeRB, uEyeOpenR); 387 vec2 eyeD = max(eyeDL, eyeDR); 388 diffuseColor.rgb = mix(diffuseColor.rgb, uEyeSkin, eyeD.x); 389 diffuseColor.rgb = mix(diffuseColor.rgb, vec3(.16, .09, .07), eyeD.y); 390 // flipbook: the baked eye picture for the current state, looked up by
391 // where this pixel sits on the face (so gaze / wide-eye warps carry it) 392 if (uEyeOn > .5) { 393 vec2 fq = (vEyeOrig - uEyeRegion.xy) / uEyeRegion.zw; 394 if (fq.x > 0.0 && fq.x < 1.0 && fq.y > 0.0 && fq.y < 1.0) { 395 vec4 fb = texture2D(uEyeTex, vec2(fq.x, (3.0 - uEyeState + fq.y) * .25)); 396 // only around each eye (the ellipse the states were painted in), 397 // fading out at its rim, so the picture's own edge never shows 398 vec2 fp = fq * vec2(1400.0, 700.0); fp.y = 700.0 - fp.y; 399 float fr = min(length((fp - vec2(280.0, 300.0)) / vec2(262.0, 292.0)), length((fp - vec2(1120.0, 300.0)) / vec2(262.0, 292.0))); 400 float fw = (1.0 - smoothstep(.9, 1.0, fr)) * step(.5, fb.a); 401 // open eyes: the model's own painting, untouched 402 fw *= step(.5, uEyeState) * (1.0 - smoothstep(.163, .172, abs(vEyeOrig.x))); 403 #ifdef EYE_BACKING 404 // only where the OPEN picture has the dark upper lash (that is where the 405 // lid patch cracks); gaps elsewhere (between the bangs) stay as they were 406 vec4 fb0 = texture2D(uEyeTex, vec2(fq.x, (3.0 + fq.y) * .25)); 407 if (fb.a < .5 || fr > .9 || dot(fb0.rgb, vec3(.299, .587, .114)) > .2) discard; 408 fw = 1.0; // the backing always shows the picture (open state = the bake) 409 #endif 410 #ifdef USE_MAP 411 vec3 fcol = diffuse * fb.rgb; 412 #else 413 vec3 fcol = fb.rgb; // the white of the eye (no texture): the picture already holds its colour 414 #endif 415 diffuseColor.rgb = mix(diffuseColor.rgb, fcol, fw); 416 } 417 }`); 418 }; 419 mat.customProgramCacheKey = () => (base.customProgramCacheKey?.() || '') + '|eye-flip-6'; 420 cache.set(base, mat); 421 } 422 n.material = cache.get(base); 423 } 424 if (eyeStates) { buildBacking(cache); splitLashBits(); } // specks on a closed eye: cracks in the lid patch AND tiny hair bits 425 } 426 // A few tiny islands of the hair mesh sit on the upper lash line. Open, they 427 // vanish into the dark lash; on a closed eye they float as specks. Move them 428 // into their own mesh so they can be hidden while the eye is not open. 429 const lashBits = []; 430 function splitLashBits() { 431 for (const nm of ['EyeOriginal_L_4', 'EyeOriginal_R_4']) { 432 const m = hero.getObjectByName(nm); if (!m || !m.geometry.index) continue; 433 const g = m.geometry, P = g.attributes.position, I = g.index, n = P.count; 434 const par = new Int32Array(n).map((_, i) => i), f = x => { while (par[x] !== x) { par[x] = par[par[x]]; x = par[x]; } return x; }; 435 const weld = new Map(); 436 for (let i = 0; i < n; i++) { const k = Math.round(P.getX(i) * 2e4) + ',' + Math.round(P.getY(i) * 2e4) + ',' + Math.round(P.getZ(i) * 2e4); if (weld.has(k)) par[f(i)] = f(weld.get(k)); else weld.set(k, i); } 437 for (let t = 0; t < I.count; t += 3) { const a = f(I.getX(t)); par[f(I.getX(t + 1))] = a; par[f(I.getX(t + 2))] = a; } 438 const box = new Map(); 439 for (let t = 0; t < I.count; t += 3) { const r = f(I.getX(t)); let c = box.get(r); if (!c) box.set(r, c = {x0: 9, x1: -9, y0: 9, y1: -9}); 440 for (let k = 0; k < 3; k++) { const v = I.getX(t + k), x = P.getX(v), y = P.getY(v); c.x0 = Math.min(c.x0, x); c.x1 = Math.max(c.x1, x); c.y0 = Math.min(c.y0, y); c.y1 = Math.max(c.y1, y); } } 441 const isBit = c => c.y0 > .905 && c.y1 < .926 && Math.abs((c.x0 + c.x1) / 2) > .03 && Math.abs((c.x0 + c.x1) / 2) < .175 && c.x1 - c.x0 < .04; 442 const keep = [], bits = []; 443 for (let t = 0; t < I.count; t += 3) (isBit(box.get(f(I.getX(t)))) ? bits : keep).push(I.getX(t), I.getX(t + 1), I.getX(t + 2)); 444 if (!bits.length) continue; 445 g.setIndex(keep); // the outline mesh shares this geometry, so it follows 446 const bg = new T.BufferGeometry(); for (const [k, a] of Object.entries(g.attributes)) bg.setAttribute(k, a); bg.setIndex(bits); 447 if (g.morphAttributes) bg.morphAttributes = g.morphAttributes; 448 const bm = new T.SkinnedMesh(bg, m.material); bm.name = nm + '_lashbits'; 449 bm.position.copy(m.position); bm.quaternion.copy(m.quaternion); bm.scale.copy(m.scale); 450 bm.bindMode = m.bindMode; bm.bind(m.skeleton, m.bindMatrix); bm.frustumCulled = false; bm.castShadow = m.castShadow; 451 m.parent.add(bm); lashBits.push(bm); 452 } 453 } 454 // The lid patch has hairline cracks along the top of the eye; with the eye 455 // open they vanish into the dark lash, but on a closed eye the dark hair far 456 // behind shows through them as specks. A backing sheet just behind the face 457 // (depth, skin weights copied from the nearest face vertex) shows the same 458 // eye picture through every crack. 459 function buildBacking(cache) { 460 const src = faceParts.filter(n => n.geometry.attributes.uv); 461 for (const side of [-1, 1]) { 462 const parts = src.filter(n => n.name.includes(side < 0 ? '_R_' : '_L_')); if (!parts.length) continue; 463 const P = parts.map(n => n.geometry.attributes.position), SI = parts.map(n => n.geometry.attributes.skinIndex), SW = parts.map(n => n.geometry.attributes.skinWeight); 464 const nx = 60, ny = 14, x0 = side * .035, x1 = side * .172, y0 = .898, y1 = .928; // just the band where the lid patch cracks 465 const pos = [], si = [], sw = [], idx = []; 466 for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) { 467 const x = x0 + (x1 - x0) * i / (nx - 1), y = y0 + (y1 - y0) * j / (ny - 1); 468 let best = 1e9, bp = 0, bv = 0, zMin = 1e9; 469 for (let p = 0; p < P.length; p++) { const A = P[p]; for (let v = 0; v < A.count; v += 2) { const d = (A.getX(v) - x) ** 2 + (A.getY(v) - y) ** 2; if (d < best) { best = d; bp = p; bv = v; } if (d < .008 * .008) zMin = Math.min(zMin, A.getZ(v)); } } 470 // behind every face layer here (so bangs and lids stay in front of it) 471 pos.push(x, y, P[bp].getZ(bv) - .0025);
472 for (let c = 0; c < 4; c++) { si.push(SI[bp].getComponent(bv, c)); sw.push(SW[bp].getComponent(bv, c)); } 473 } 474 for (let j = 0; j < ny - 1; j++) for (let i = 0; i < nx - 1; i++) { const a = j * nx + i; idx.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); } 475 const g = new T.BufferGeometry(); 476 g.setAttribute('position', new T.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new T.Float32BufferAttribute(new Array(nx * ny * 2).fill(0), 2)); 477 g.setAttribute('skinIndex', new T.Uint16BufferAttribute(si, 4)); g.setAttribute('skinWeight', new T.Float32BufferAttribute(sw, 4)); g.setIndex(idx); g.computeVertexNormals(); 478 const ref = parts[0], mat = cache.get(ref.userData.eyeBase || [...cache.keys()][0]).clone(); 479 mat.onBeforeCompile = cache.get([...cache.keys()][0]).onBeforeCompile; mat.defines = {...(mat.defines || {}), EYE_BACKING: 1}; mat.side = T.DoubleSide; 480 mat.customProgramCacheKey = () => 'eye-backing-10'; 481 const m = new T.SkinnedMesh(g, mat); m.name = 'EyeBacking_' + (side < 0 ? 'R' : 'L'); 482 m.position.copy(ref.position); m.quaternion.copy(ref.quaternion); m.scale.copy(ref.scale); 483 m.bindMode = ref.bindMode; m.bind(ref.skeleton, ref.bindMatrix); m.frustumCulled = false; 484 ref.parent.add(m); 485 } 486 } 487 // ---------- pacifier: a little "chupa chupa" now and then ---------- 488 const suckU = {value: 0}; 489 function setupPacifier() { 490 const cache = new Map(); 491 hero.traverse(n => { 492 if (!n.isMesh || !/Pacifier/.test(n.material?.name || '')) return; 493 const base = n.material; 494 if (!cache.has(base)) { 495 const mat = base.clone(), inner = base.onBeforeCompile; 496 mat.onBeforeCompile = (shader, renderer) => { 497 if (inner) inner.call(mat, shader, renderer); 498 shader.uniforms.uSuck = suckU; 499 shader.vertexShader = 'uniform float uSuck;\n' + shader.vertexShader.replace('#include <begin_vertex>', `#include <begin_vertex> 500 transformed.z -= uSuck * .014;`); 501 }; 502 mat.customProgramCacheKey = () => (base.customProgramCacheKey?.() || '') + '|suck1'; 503 cache.set(base, mat); 504 } 505 n.material = cache.get(base); 506 }); 507 } 508 // ---------- shoulder bag: swings on its strap ---------- 509 // The bag is part of the props mesh, so its vertices (a box on her left hip, 510 // in bind space) are turned about the strap's lower end, fading out at the 511 // box edge; the hat and everything else stay put. 512 const bagU = {value: new T.Vector2()}; 513 function setupBag() { 514 const cache = new Map(); 515 hero.traverse(n => { 516 if (!n.isMesh || n.userData.storybookOutline || !/Gloss_Props(_v17|_Trim_v19|_Strap_v19)/.test(n.material?.name || '')) return; 517 const base = n.material; 518 if (!cache.has(base)) { 519 const mat = base.clone(), inner = base.onBeforeCompile; 520 mat.onBeforeCompile = (shader, renderer) => { 521 if (inner) inner.call(mat, shader, renderer); 522 shader.uniforms.uBag = bagU; 523 shader.vertexShader = 'uniform vec2 uBag;\n' + shader.vertexShader.replace('#include <begin_vertex>', `#include <begin_vertex> 524 { 525 vec3 bp = vec3(.168, .352, .09); 526 float bw = (1.0 - smoothstep(.30, .37, transformed.y)) * smoothstep(.05, .085, transformed.x) * (1.0 - smoothstep(.245, .28, transformed.x)) 527 * smoothstep(-.06, -.02, transformed.z) * (1.0 - smoothstep(.2, .24, transformed.z)); 528 if (bw > 0.0) { 529 vec3 d = transformed - bp; 530 float cx = cos(uBag.x), sx = sin(uBag.x), cz = cos(uBag.y), sz = sin(uBag.y); 531 vec3 r = vec3(d.x, d.y * cx - d.z * sx, d.y * sx + d.z * cx); // swing forward / back 532 r = vec3(r.x * cz - r.y * sz, r.x * sz + r.y * cz, r.z); // swing out / in 533 transformed = bp + mix(d, r, bw); 534 } 535 }`); 536 }; 537 mat.customProgramCacheKey = () => (base.customProgramCacheKey?.() || '') + '|bag1'; 538 cache.set(base, mat); 539 } 540 n.material = cache.get(base); 541 });
542 } 543 const suck = {t: 3, n: 0, ph: 0}; 544 function updateSuck(dt) { 545 const asleep = act.def && act.def.eyes === 0 && busy(); 546 suck.t -= dt; 547 if (suck.t <= 0 && suck.n <= 0) { suck.n = 2 + (Math.random() * 3 | 0); suck.ph = 0; suck.t = asleep ? 2 + Math.random() * 2 : 4 + Math.random() * 6; } 548 let v = 0; 549 if (suck.n > 0) { suck.ph += dt * (asleep ? 3.2 : 5.5); v = Math.max(0, Math.sin(suck.ph * Math.PI)); if (suck.ph >= 1) { suck.ph = 0; suck.n--; } } 550 suckU.value = window.__suckQA ?? v; 551 } 552 const blink = {t: 0, next: 1.6, dur: .2, openS: 1}; 553 const blinkCurve = t => { const c = .07, h = .03, o = .1; 554 if (t < c) return 1 - t / c; if (t < c + h) return 0; if (t < c + h + o) { const k = (t - c - h) / o; return k * k * (3 - 2 * k); } return 1; }; 555 function updateEyes(dt) { 556 blink.t += dt; let open = 1; 557 if (blink.t >= blink.next) { 558 const tt = blink.t - blink.next; open = blinkCurve(tt); 559 if (tt > blink.dur) { blink.t = 0; blink.next = 2.2 + Math.random() * 3.2; if (Math.random() < .18) blink.next = .25; } 560 } 561 // Held states: sleeping (closed), effort (half-squeezed), happy (narrowed). 562 face.effort = Math.max(0, face.effort - dt); face.happy = Math.max(0, face.happy - dt); face.surprise = Math.max(0, (face.surprise || 0) - dt); 563 face.wideS = damp(face.wideS || 0, face.surprise > 0 || face.expr === 'surprise' ? 1 : 0, face.surprise > 0 ? 30 : 6, dt); 564 eyeU.uEyeWide.value = window.__wideQA ?? face.wideS; 565 face.blushS = damp(face.blushS || 0, Math.max(face.happy > 0 ? .8 : 0, face.expr === 'smile' ? .8 : 0, face.shy || 0, (st.tiredS || 0) * .7), 3, dt); 566 eyeU.uBlush.value = window.__blushQA ?? face.blushS; 567 let hold = 1, smile = 0, shut = 0; 568 if (act.def && act.def.eyes != null && busy()) { hold = act.def.eyes; shut = 1; } 569 else if (face.surprise > 0) hold = 1; 570 else if (face.effort > 0) hold = .45; 571 else if (face.happy > 0) { hold = .05; smile = 1; } 572 if (face.expr === 'smile') { hold = .05; smile = 1; } else if (face.expr === 'sleep') { hold = 0; shut = 1; } else if (face.expr === 'squint') hold = .45; 573 if (face.override != null) hold = face.override; 574 // the lid shape changes only while the eye is (nearly) closed, so it never pops 575 face.smileS = damp(face.smileS || 0, smile, 10, dt); face.shutS = damp(face.shutS || 0, shut, 10, dt); 576 eyeU.uEyeSmile.value = face.smileS; eyeU.uEyeShut.value = face.shutS; 577 blink.openS = damp(blink.openS, hold, 14, dt); 578 open = Math.min(open, blink.openS); 579 if (window.__eyeQA != null) open = window.__eyeQA; 580 if (eyeU.uEyeOn.value > .5) { 581 // anime-style: pick a drawn state (no folding) â open / half / closed, or the smile arch 582 const closedPic = face.smileS > .5 ? 3 : 2; 583 eyeU.uEyeState.value = window.__stateQA ?? (open > .72 ? 0 : open > .22 ? 1 : closedPic); 584 for (const b of lashBits) b.visible = eyeU.uEyeState.value < .5; 585 eyeU.uEyeSmile.value = 0; eyeU.uEyeShut.value = 0; 586 for (const eye of Object.values(eyes)) eye.open.value = 1; 587 } else for (const eye of Object.values(eyes)) eye.open.value = Math.max(.04, open); 588 for (const [n, i] of lidMorphs) n.morphTargetInfluences[i] = 0; 589 } 590 591 // ---------- procedural layer state ---------- 592 const hairMesh = (() => { let m = null; hero.traverse(n => { if (n.morphTargetDictionary && n.morphTargetDictionary.Hair_Sway_L !== undefined && !m) m = n; }); return m; })(); 593 const hemMesh = (() => { let m = null; hero.traverse(n => { if (n.morphTargetDictionary && n.morphTargetDictionary.Hem_Sway !== undefined && !m) m = n; }); return m; })(); 594 const morphI = (m, k) => m ? m.morphTargetDictionary[k] : undefined; 595 const legs = {}; 596 for (const [k, pre] of [['L', 'Left'], ['R', 'Right']]) legs[k] = {up: bone(pre + 'UpLeg'), knee: bone(pre + 'Leg'), foot: bone(pre + 'Foot'), toe: bone(pre + 'ToeBase'), lock: null, w: 0, down: false};
597 const FING = ['Thumb1', 'Thumb2', 'Index1', 'Index2', 'Middle1', 'Middle2', 'Ring1', 'Ring2', 'Pinky1', 'Pinky2']; 598 const hands = ['Left', 'Right'].map(pre => ({hand: bone(pre + 'Hand'), fingers: FING.map(f => bone(pre + f)).filter(Boolean), 599 index: bone(pre + 'Index1'), pinky: bone(pre + 'Pinky1'), middle: bone(pre + 'Middle1'), thumb: bone(pre + 'Thumb1')})); 600 const edited = [...hands.flatMap(h => h.fingers), B.spine, B.spine1, B.spine2, B.neck, B.head, B.tailL, B.tailR, B.hips, B.armL, B.armR, B.foreL, B.foreR, B.handL, B.handR, B.clavL, B.clavR, ...Object.values(legs).flatMap(l => [l.up, l.knee, l.foot])].filter(Boolean); 601 const baseQ = new Map(edited.map(b => [b, {q: b.quaternion.clone(), p: b.position.clone()}])); 602 const morphBase = []; 603 for (const m of [hairMesh, hemMesh]) if (m) morphBase.push([m, m.morphTargetInfluences.slice()]); 604 function restore() { 605 for (const [b, v] of baseQ) { b.quaternion.copy(v.q); b.position.copy(v.p); } 606 for (const [m, arr] of morphBase) for (let i = 0; i < arr.length; i++) m.morphTargetInfluences[i] = arr[i]; 607 } 608 function remember() { 609 for (const [b, v] of baseQ) { v.q.copy(b.quaternion); v.p.copy(b.position); } 610 for (const [m, arr] of morphBase) for (let i = 0; i < arr.length; i++) arr[i] = m.morphTargetInfluences[i]; 611 } 612 613 const st = { 614 speed: 0, prevSpeed: 0, accel: 0, yawRate: 0, air: 0, phase: 0, 615 lean: 0, bank: 0, look: {yaw: 0, pitch: 0, w: 0}, lookTarget: null, glance: {t: 2, yaw: 0, pitch: 0, hold: 0}, 616 breath: 0, idleTime: 0, crouch: 0, landS: [0, 0], airPose: 0, rise: 0, 617 // springs: [value, velocity] 618 hairX: [0, 0], hairZ: [0, 0], tailX: [0, 0], tailZ: [0, 0], hem: [0, 0], lift: [0, 0], 619 headPrev: null, headVel: new T.Vector3(), headAcc: new T.Vector3(), 620 }; 621 const spring = (sv, target, k, c, dt) => { const a = (target - sv[0]) * k - sv[1] * c; sv[1] += a * dt; sv[0] += sv[1] * dt; return sv[0]; }; 622 623 const qT = new T.Quaternion(), qP = new T.Quaternion(), qB = new T.Quaternion(), vA = new T.Vector3(), vB = new T.Vector3(), rootQ = new T.Quaternion(), invRootQ = new T.Quaternion(); 624 // Rotate a bone about a WORLD axis (bone-axis conventions don't matter). 625 function turnWorld(b, axis, ang) { 626 if (!b || !ang) return; 627 qT.setFromAxisAngle(axis, ang); b.parent.getWorldQuaternion(qP); b.getWorldQuaternion(qB); 628 b.quaternion.copy(qP.invert().multiply(qT.multiply(qB))); b.updateMatrixWorld(true); 629 } 630 const axX = new T.Vector3(), axY = new T.Vector3(), axZ = new T.Vector3(); 631 632 // ---------- foot planting ---------- 633 // A foot whose ankle comes down to the ground is pinned where it landed until 634 // the clip lifts it again (or it has been dragged too far, e.g. turning on 635 // the spot), so planted feet don't skate however fast the clip is played. 636 const iv = {s: new T.Vector3(), e: new T.Vector3(), w: new T.Vector3(), t: new T.Vector3(), d: new T.Vector3(), p: new T.Vector3(), ed: new T.Vector3(), fq: new T.Quaternion(), inv: new T.Matrix4()}; 637 function rotateBoneWorld(b, from, to) { 638 qT.setFromUnitVectors(from.normalize(), to.normalize()); 639 b.parent.getWorldQuaternion(qP); b.getWorldQuaternion(qB); 640 b.quaternion.copy(qP.invert().multiply(qT.multiply(qB))); b.updateMatrixWorld(true); 641 } 642 function solveLeg(leg, target, pole, keepPlane = false) { 643 const {up, knee, foot} = leg; 644 foot.getWorldQuaternion(iv.fq); // keep the clip's foot angle 645 up.getWorldPosition(iv.s); knee.getWorldPosition(iv.e); foot.getWorldPosition(iv.w); 646 const a = iv.e.distanceTo(iv.s), b = iv.w.distanceTo(iv.e); 647 iv.d.copy(target).sub(iv.s); 648 // soft reach limit: past 94% of the leg length the target is eased in, so 649 // the knee never locks straight and then snaps bent a frame later 650 let dist = iv.d.length(); const L0 = (a + b) * .94, Lr = (a + b) * .05; 651 if (dist > L0) dist = L0 + Lr * (1 - Math.exp(-(dist - L0) / Lr)); 652 dist = clamp(dist, Math.abs(a - b) + 1e-3, a + b - 1e-3); iv.d.normalize(); 653 const cosA = clamp((a * a + dist * dist - b * b) / (2 * a * dist), -1, 1); 654 // bend in the plane the knee already bends in (so a tiny correction is a 655 // tiny change, never a snap to another plane); `pole` only for a straight leg 656 // (blended by how bent it is, so a nearly straight leg hands over smoothly; 657 // keepPlane may also be a 0-1 weight)
658 const keep = keepPlane === true ? 1 : keepPlane || 0; 659 iv.t.copy(pole).addScaledVector(iv.d, -pole.dot(iv.d)).normalize(); 660 if (keep > 0) { 661 iv.p.copy(iv.e).sub(iv.s); iv.p.addScaledVector(iv.d, -iv.p.dot(iv.d)); 662 const bend = iv.p.length() / a, k = keep * clamp((bend - .03) / .12, 0, 1); 663 if (k > 0) { // turn the plane round the hip-foot line (a lerp flips when they're opposite) 664 iv.p.normalize(); 665 const ang = Math.atan2(iv.w.crossVectors(iv.t, iv.p).dot(iv.d), iv.t.dot(iv.p)); 666 iv.t.applyAxisAngle(iv.d, ang * k).normalize(); 667 } 668 } 669 iv.p.copy(iv.t); 670 iv.ed.copy(iv.s).addScaledVector(iv.d, a * cosA).addScaledVector(iv.p, a * Math.sqrt(1 - cosA * cosA)); 671 rotateBoneWorld(up, iv.e.clone().sub(iv.s), iv.ed.clone().sub(iv.s)); 672 knee.getWorldPosition(iv.e); foot.getWorldPosition(iv.w); 673 rotateBoneWorld(knee, iv.w.clone().sub(iv.e), iv.s.clone().addScaledVector(iv.d, dist).sub(iv.e)); 674 foot.parent.getWorldQuaternion(qP); foot.quaternion.copy(qP.invert().multiply(iv.fq)); foot.updateMatrixWorld(true); 675 } 676 const vFoot = new T.Vector3(), vLocal = new T.Vector3(), vPole = new T.Vector3(); 677 let groundY = 0; const events = []; 678 const groundYNow = () => root.getWorldPosition(vLocal).y; 679 function plantFeet(dt, weight) { 680 iv.inv.copy(root.matrixWorld).invert(); groundY = root.getWorldPosition(vLocal).y; 681 for (const [k, leg] of Object.entries(legs)) { 682 if (!leg.foot) continue; 683 leg.foot.getWorldPosition(vFoot); vLocal.copy(vFoot).applyMatrix4(iv.inv); 684 const h = vLocal.y - ankleY; 685 if (!leg.down && h < .016 && weight > .5) { leg.down = true; leg.lock = (leg.lock || new T.Vector3()).copy(vFoot); events.push({type: 'step', side: k, pos: vFoot.clone(), speed: st.speed}); } 686 else if (leg.down && (h > .024 || weight < .5)) leg.down = false; 687 // dragged too far (turning on the spot, a clip that slides): ease the pin 688 // toward where the clip has the foot instead of letting it pop back 689 if (leg.down && vFoot.distanceTo(leg.lock) > (Math.abs(st.yawRate) > 1.2 ? .07 : .14)) leg.lock.lerp(vFoot, 1 - Math.exp(-dt * 10)); 690 leg.w = damp(leg.w, leg.down ? 1 : 0, leg.down ? Math.max(14, 30 - st.speed * 5.5) : 16, dt); // fast runs: pin a touch softer (no knee snap at touchdown) 691 // Never below the ground (a crouch lowers the body; the knees bend instead). 692 const floorY = groundY + ankleY * .98, sink = floorY - vFoot.y; 693 if ((leg.w < .01 || !leg.lock) && (sink <= .002 || weight < .3)) continue; 694 const tgt = leg.lock && leg.w > .01 ? vFoot.clone().lerp(leg.lock, leg.w * weight) : vFoot.clone(); 695 tgt.y += Math.max(0, floorY - tgt.y) * clamp((weight - .3) / .4, 0, 1); // kept above the floor, eased off as the pin lets go 696 vPole.set(k === 'L' ? .15 : -.15, 0, 1).applyQuaternion(rootQ); // knees forward, a little out 697 solveLeg(leg, tgt, vPole, true); 698 } 699 } 700 701 // ---------- arms ---------- 702 // degrees: swing offset / amplitude (shoulder flexion), abduction, elbow base / extra on the forward swing 703 const ARM = { 704 idle: {off: 3, amp: 0, abd: 15, elb: 16, elbF: 0, lag: .0}, 705 walk: {off: -3, amp: 17, abd: 13, elb: 24, elbF: 14, lag: .05}, 706 run: {off: -8, amp: 36, abd: 12, elb: 86, elbF: 12, lag: .04}, 707 sprint: {off: -10, amp: 46, abd: 13, elb: 80, elbF: 16, lag: .03}, 708 }; 709 const deg = Math.PI / 180, av = {u: new T.Vector3(), f: new T.Vector3(), b: new T.Vector3(), s: new T.Vector3(), e: new T.Vector3(), h: new T.Vector3(), m: new T.Vector3(), q: new T.Quaternion(), q2: new T.Quaternion()}; 710 // turn bone b so that (child - b) points along `dir` (world), by weight w 711 function aimBone(b, child, dir, w) { 712 if (w < .001) return; 713 b.getWorldPosition(av.s); child.getWorldPosition(av.e); av.e.sub(av.s).normalize(); 714 w *= clamp((av.e.dot(dir) + .85) / .6, 0, 1); // nearly opposite: the turn axis is arbitrary, so don't 715 if (w < .001) return; 716 av.q.setFromUnitVectors(av.e, dir); 717 if (w < .999) av.q.slerp(av.q2.identity(), 1 - w); 718 b.parent.getWorldQuaternion(qP); b.getWorldQuaternion(qB); 719 b.quaternion.copy(qP.invert().multiply(av.q.multiply(qB))); b.updateMatrixWorld(true); 720 } 721 const armSt = {w: 0}; 722 // turn unit vector v toward unit vector to by fraction t (on the sphere) 723 const tq = new T.Quaternion(), tq0 = new T.Quaternion(); 724 function turnToward(v, to, t) { tq.setFromUnitVectors(v, to); tq0.identity().slerp(tq, t); v.applyQuaternion(tq0).normalize(); return v; } 725 function armsLayer(dt, speed, aw, full, upperOnly, m = {}) { 726 // how much of the arm pose is ours: all of locomotion, none under an action 727 // actions that bring their own arm pose (kicks: a guard; wave: a real wave) 728 const mode = busy() && act.def?.arms ? act.def.arms : null; 729 armSt.modeW = damp(armSt.modeW || 0, mode ? 1 : 0, 9, dt); if (mode) armSt.mode = mode; 730 const mw = armSt.modeW, mname = armSt.mode; 731 const want = Math.max((1 - aw) * (1 - (st.holdS || 0)), mode ? 1 : 0) * (window.__armsQA === 0 ? 0 : 1); 732 armSt.w = damp(armSt.w, want, 10, dt); const w = armSt.w; 733 if (w < .01) return; 734 armSt.t = (armSt.t || 0) + dt; 735 // blend the parameter sets by the gait weights 736 const P = {off: 0, amp: 0, abd: 0, elb: 0, elbF: 0, lag: 0};
737 for (const n of ['idle', 'walk', 'run', 'sprint']) { const k = loco[n].w; for (const key in P) P[key] += ARM[n][key] * k; } 738 // sitting: forearms rest forward on the lap 739 const sit = st.sitS || 0; 740 const legFwd = Math.sin(2 * Math.PI * (st.phase - P.lag)); // +1: left foot forward (phase .25) 741 const breathe = Math.sin(st.breath) * 1.2; 742 // Shoulder line: the clip twists it ±13-20°; people keep it to roughly 743 // ±5° walking and ±10° running, turned against the pelvis (the right 744 // shoulder comes forward with the right arm). Set it, don't add to it. 745 if (B.armL && B.armR && window.__chestQA !== 0) { 746 const want = legFwd * P.amp * .3 * deg * (1 - sit); // left foot forward â right shoulder forward 747 B.armL.getWorldPosition(av.s); B.armR.getWorldPosition(av.e); av.s.sub(av.e).applyQuaternion(invRootQ); 748 const cur = Math.atan2(-av.s.z, av.s.x), rest = st.shoulderRest ?? (st.shoulderRest = cur); 749 // yaw measured this way grows as the left shoulder goes back, and a 750 // positive turn about her up axis takes the left shoulder back 751 const err = (rest + want) - cur; 752 const k = .85 * w; 753 turnWorld(B.spine1, axY, err * k * .4); turnWorld(B.spine, axY, err * k * .6); 754 } 755 for (const [side, arm, fore, hand, clav] of [[1, B.armL, B.foreL, B.handL, B.clavL], [-1, B.armR, B.foreR, B.handR, B.clavR]]) { 756 if (!arm || !fore || !hand) continue; 757 const swing = side > 0 ? -legFwd : legFwd; // opposite arm to leg 758 let flex = P.off + P.amp * swing + breathe * (1 - Math.min(1, speed)); 759 let abd = P.abd + Math.abs(swing) * P.amp * .08; // a touch wider at the ends of the swing 760 let elb = P.elb + P.elbF * Math.max(0, swing); 761 flex = flex * (1 - sit) + 28 * sit; abd = abd * (1 - sit) + 16 * sit; elb = elb * (1 - sit) + 55 * sit; 762 // jumping (standing more than running): arms swing back in the crouch, 763 // up-forward on take-off, and forward-out to meet the landing 764 { const still = clamp(1 - speed / 2.2, .35, 1); 765 const prepW = clamp((m.crouch || 0) / .085, 0, 1) * (1 - st.air) * still, airJ = st.air * still; 766 if (prepW > .01) { flex += (-42 - flex) * prepW; elb += (22 - elb) * prepW; abd += (12 - abd) * prepW; } 767 if (airJ > .01) { const up = Math.max(0, st.rise || 0), down = Math.max(0, -(st.rise || 0)); 768 const jf = 30 + 70 * up - 5 * down, je = 35 + 15 * up, ja = 16 + 14 * down; 769 flex += (jf - flex) * airJ; elb += (je - elb) * airJ; abd += (ja - abd) * airJ; } } 770 if (mname === 'guard' && mw > .01) { flex += (24 - flex) * mw; abd += (18 - abd) * mw; elb += (100 - elb) * mw; } // fists up in front of the chest 771 // relaxed shoulders: the collar bone drops a little and follows the swing 772 if (clav) { turnWorld(clav, axZ, -side * .06 * w); turnWorld(clav, axY, side * flex * deg * .12 * w); } 773 // upper arm: hang, swing (flexion about her left-right axis), open out (abduction) 774 const fl = flex * deg, ab = abd * deg; 775 av.u.set(side * Math.sin(ab), -Math.cos(ab) * Math.cos(fl), Math.cos(ab) * Math.sin(fl)).applyQuaternion(rootQ).normalize(); 776 // forearm: bends forward and a little inward (hands toward, never across, the midline) 777 av.b.set(-side * .28, .12, 1).applyQuaternion(rootQ); av.b.addScaledVector(av.u, -av.b.dot(av.u)).normalize(); 778 av.f.copy(av.u).multiplyScalar(Math.cos(elb * deg)).addScaledVector(av.b, Math.sin(elb * deg)).normalize(); 779 let palmFwd = 0; 780 if (mname === 'wave' && side < 0 && mw > .01) { 781 // a real wave (her right hand): upper arm up and out in front, forearm 782 // upright, palm to the front, the forearm swinging side to side 783 const sw = Math.sin(armSt.t * 2 * Math.PI * 2.3) * .38; 784 const uw = new T.Vector3(side * .78, .42, .46).applyQuaternion(rootQ).normalize(); 785 const fw = new T.Vector3(side * (.12 + sw), 1, .12).applyQuaternion(rootQ).normalize(); 786 turnToward(av.u, uw, mw); turnToward(av.f, fw, mw); palmFwd = mw; // along the arc, not a straight-line blend 787 } 788 aimBone(arm, fore, av.u, w); 789 aimBone(fore, hand, av.f, w); 790 // hand: wrist straight along the forearm, palm facing her body 791 const mid = side > 0 ? B.midL : B.midR; 792 if (mid) { 793 aimBone(hand, mid, av.f, w * .8); 794 hand.getWorldPosition(av.h); mid.getWorldPosition(av.m); const fdir = av.m.sub(av.h).normalize(); 795 const idx = hand.children.find(c => /Index1/.test(c.name)), pin = hand.children.find(c => /Pinky1/.test(c.name)), th = hand.children.find(c => /Thumb1/.test(c.name)); 796 if (idx && pin && th) { 797 // Aim the thumb side (pinky â index knuckles), not a guessed palm 798 // normal: arms down, thumbs forward and palms toward the thighs 799 // (the old palm sign was flipped: thumbs pointed backward); waving, 800 // the palm faces forward, so the thumb side points toward her middle. 801 const ip = idx.getWorldPosition(new T.Vector3()).sub(pin.getWorldPosition(new T.Vector3())); 802 ip.addScaledVector(fdir, -ip.dot(fdir)).normalize(); 803 const wantIp = new T.Vector3(-side * (.25 * (1 - palmFwd) + palmFwd), 0, 1 - palmFwd).applyQuaternion(rootQ); 804 wantIp.addScaledVector(fdir, -wantIp.dot(fdir)).normalize(); 805 const ang = Math.atan2(new T.Vector3().crossVectors(ip, wantIp).dot(fdir), ip.dot(wantIp)); 806 turnWorld(hand, fdir, ang * w * clamp((Math.PI - Math.abs(ang)) / 1.2, 0, 1)); // fades out near ±180° (no flip) 807 } 808 } 809 } 810 811 } 812 813 const tv = {s: new T.Vector3(), e: new T.Vector3(), h: new T.Vector3(), d: new T.Vector3(), c: new T.Vector3(), p: new T.Vector3(), m: new T.Vector3()}; 814 function tuckArms(k) { 815 if (k < .01) return; 816 for (const [side, arm, fore, hand, mid] of [[1, B.armL, B.foreL, B.handL, B.midL], [-1, B.armR, B.foreR, B.handR, B.midR]]) { 817 if (!arm || !fore || !hand) continue; 818 arm.getWorldPosition(tv.s); fore.getWorldPosition(tv.e); hand.getWorldPosition(tv.h); 819 tv.d.copy(tv.h).sub(tv.s).normalize(); 820 tv.c.copy(tv.e).sub(tv.s); tv.c.addScaledVector(tv.d, -tv.c.dot(tv.d));
820 // where the elbow points now 821 tv.p.set(side * .3, -1, -.2).applyQuaternion(rootQ); tv.p.addScaledVector(tv.d, -tv.p.dot(tv.d)); // down, a little out and back 822 if (tv.c.lengthSq() < 1e-6 || tv.p.lengthSq() < 1e-6) continue; 823 tv.c.normalize(); tv.p.normalize(); 824 tv.c.lerp(tv.p, k); if (tv.c.lengthSq() < 1e-6) continue; 825 solveLeg({up: arm, knee: fore, foot: hand}, tv.h.clone(), tv.c.normalize()); 826 // wrist in line with the forearm: a fist, not a drooping hand 827 if (mid) { hand.getWorldPosition(tv.h); fore.getWorldPosition(tv.e); aimBone(hand, mid, tv.m.copy(tv.h).sub(tv.e).normalize(), k * .7); } 828 // a punch turns the forearm in (pronation): the fist lands palm down, 829 // thumb side toward her middle â not thumbs-up 830 const idx = hand.children.find(c => /Index1/.test(c.name)), pin = hand.children.find(c => /Pinky1/.test(c.name)); 831 if (mid && idx && pin) { 832 hand.getWorldPosition(tv.h); const fdir = mid.getWorldPosition(tv.d).sub(tv.h).normalize(); 833 const ip = idx.getWorldPosition(tv.c).sub(pin.getWorldPosition(tv.p)); ip.addScaledVector(fdir, -ip.dot(fdir)).normalize(); 834 const want = tv.s.set(-side, -.3, 0).applyQuaternion(rootQ); want.addScaledVector(fdir, -want.dot(fdir)); 835 if (want.lengthSq() > 1e-4) { 836 want.normalize(); 837 const ang = Math.atan2(tv.e.crossVectors(ip, want).dot(fdir), ip.dot(want)); 838 const fax = fore.getWorldPosition(tv.m); fax.subVectors(tv.h, fax).normalize(); // the forearm's own long axis 839 turnWorld(fore, fax, ang * k * .5); // (twisting about it keeps the fist where it is) 840 hand.getWorldPosition(tv.h); const fd2 = mid.getWorldPosition(tv.d).sub(tv.h).normalize(); 841 idx.getWorldPosition(tv.c).sub(pin.getWorldPosition(tv.p)); tv.c.addScaledVector(fd2, -tv.c.dot(fd2)).normalize(); 842 want.set(-side, -.3, 0).applyQuaternion(rootQ).addScaledVector(fd2, -want.dot(fd2)).normalize(); 843 turnWorld(hand, fd2, Math.atan2(tv.e.crossVectors(tv.c, want).dot(fd2), tv.c.dot(want)) * k); // the wrist does the rest 844 } 845 } 846 } 847 } 848 849 // ---------- hands ---------- 850 // Each finger joint turns about the knuckle line, toward the palm (the side 851 // the thumb is on), so the curl direction never depends on bone axes. 852 const hv = {a: new T.Vector3(), b: new T.Vector3(), c: new T.Vector3(), d: new T.Vector3(), k: new T.Vector3(), n: new T.Vector3()}; 853 function curlHands(amount) { 854 if (amount < .01) return; 855 for (const h of hands) { 856 if (!h.index || !h.pinky || !h.middle || !h.thumb) continue; 857 h.hand.getWorldPosition(hv.a); h.middle.getWorldPosition(hv.b); const f = hv.b.sub(hv.a).normalize(); 858 h.index.getWorldPosition(hv.c); h.pinky.getWorldPosition(hv.d); const k = hv.k.copy(hv.c).sub(hv.d).normalize(); 859 const n = hv.n.crossVectors(f, k).normalize(); 860 // palm side: +n on the left hand, -n on the right (measured; guessing it 861 // from the thumb's position got both hands backwards) 862 const sp = window.__curlQA === 0 ? (Math.sign(h.thumb.getWorldPosition(hv.c).sub(hv.a).dot(n)) || 1) : h === hands[0] ? 1 : -1; 863 for (const b of h.fingers) { 864 const thumb = /Thumb/.test(b.name), j2 = b.name.endsWith('2'); 865 const ang = -sp * amount * (thumb ? .5 : j2 ? 1.25 : 1.1); 866 if (thumb) { turnWorld(b, f, ang * (window.__thF ?? 1.6)); if (window.__thK) turnWorld(b, k, ang * window.__thK); } // thumb: folds over the fingers (a real fist, not a thumbs-up) 867 else turnWorld(b, k, ang); 868 } 869 } 870 } 871 872 // ---------- per frame ---------- 873 // m: {speed (m/s along facing), yawRate (rad/s), air (0..1), vy} 874 function update(dt, m = {}) { 875 dt = Math.min(dt, 1 / 20); 876 const speed = m.speed || 0; 877 st.accel = damp(st.accel, (speed - st.prevSpeed) / Math.max(dt, 1e-3), 6, dt); st.prevSpeed = speed; 878 st.speed = speed; st.yawRate = damp(st.yawRate, m.yawRate || 0, 8, dt); st.air = damp(st.air, m.air || 0, 12, dt); 879 880 // Gait weights by speed (piecewise between measured natural speeds). 881 const vW = moveSpeed.walk, vR = moveSpeed.run, vS = moveSpeed.sprint; 882 const target = {idle: 0, walk: 0, run: 0, sprint: 0}; 883 // Turning on the spot: little steps instead of spinning on planted feet. 884 const turnStep = Math.min(.5, Math.max(0, Math.abs(st.yawRate) - .6) * .18); 885 const gaitSpeed_ = speed < .45 ? Math.max(speed, turnStep) : speed; 886 { const speed = gaitSpeed_; 887 if (speed < .08) target.idle = 1;
888 else if (speed < vW * .55) { const k = speed / (vW * .55); target.idle = 1 - k; target.walk = k; } 889 else if (speed < vW) target.walk = 1; 890 else if (speed < vR) { const k = (speed - vW) / (vR - vW); const e = k * k * (3 - 2 * k); target.walk = 1 - e; target.run = e; } 891 else if (speed < vS) { const k = (speed - vR) / (vS - vR); target.run = 1 - k; target.sprint = k; } 892 else target.sprint = 1; } 893 let sum = 0; for (const n in loco) { loco[n].w = damp(loco[n].w, target[n], 10, dt); sum += loco[n].w; } 894 for (const n in loco) loco[n].w /= sum || 1; 895 896 // Phase-locked gaits: one shared stride phase, advanced at the rate the 897 // feet travel so planted feet stay put. 898 let mw = 0, D = 0, V = 0; 899 let SS = 0; 900 for (const n of GAITS) { mw += loco[n].w; D += loco[n].w * loco[n].dur; V += loco[n].w * gaitSpeed[n] * (window.__strideQA === 0 ? 1 : STRIDE[n]); SS += loco[n].w * STRIDE[n]; } 901 st.stride = mw > 1e-3 ? (window.__strideQA === 0 ? 1 : SS / mw) : 1; 902 if (mw > 1e-3) { D /= mw; V /= mw; const rate = clamp(Math.max(gaitSpeed_, .5 * V) / V, .5, MAX_RATE); st.phase = (st.phase + dt * rate / D) % 1; } 903 for (const n of GAITS) { const L = loco[n]; L.up.time = L.low.time = st.phase * L.dur; } 904 905 // Action weight and time. 906 if (act.def) { 907 act.t += dt; 908 const ending = act.t > act.len - act.fade; 909 act.w = damp(act.w, ending ? 0 : 1, Math.min(ending ? 12 : 14, (ending ? 2.6 : 3.1) / act.fade), dt); // rate from the fade time (never faster than before; long fades are slower) 910 if (act.a.time > act.cut[1] && !act.def.loop) act.a.time = act.cut[1]; 911 if (act.t >= act.len && act.w < .02) { 912 act.a.weight = 0; act.a.stop(); const cb = act.onEnd; act.def = null; act.a = null; act.w = 0; cb?.(); 913 } 914 } 915 const aw = act.def ? act.w : 0, full = act.def?.layer === 'full', upperOnly = act.def?.layer === 'upper'; 916 const pv = act.prev; 917 if (pv) { pv.w = damp(pv.w, 0, 16, dt); pv.a.weight = pv.w; if (pv.w < .01) { pv.a.stop(); pv.a.weight = 0; act.prev = null; } } 918 for (const n in loco) { 919 loco[n].low.weight = loco[n].w * Math.max(0, 1 - (full ? aw : 0) - (pv && pv.full ? pv.w : 0)); 920 loco[n].up.weight = loco[n].w * Math.max(0, 1 - aw - (pv ? pv.w : 0)); 921 } 922 if (act.a) act.a.weight = aw; 923 924 restore(); mixer.update(dt); remember(); hero.updateMatrixWorld(true); 925 updateEyes(dt); updateSuck(dt); 926 927 // ----- procedural acting ----- 928 root.getWorldQuaternion(rootQ); invRootQ.copy(rootQ).invert(); 929 axX.set(1, 0, 0).applyQuaternion(rootQ); axY.set(0, 1, 0).applyQuaternion(rootQ); axZ.set(0, 0, 1).applyQuaternion(rootQ); 930 const freeW = 1 - (full ? aw : 0); // procedural lean fades out under full actions 931 932 // Lean forward with speed and acceleration; bank into turns (like a runner). 933 const leanT = clamp(speed * .02 + st.accel * .035, -.12, .3) * freeW; 934 const bankT = clamp(-st.yawRate * speed * .06, -.32, .32) * freeW; 935 st.lean = damp(st.lean, leanT, 7, dt); st.bank = damp(st.bank, bankT, 7, dt); 936 turnWorld(B.hips, axZ, st.bank * .55); 937 turnWorld(B.spine2, axX, st.lean * .5); turnWorld(B.spine1, axX, st.lean * .5); // lean from the lower back 938 turnWorld(B.spine1, axZ, st.bank * .35); 939 // Twist the chest toward where she is turning (after the head, before the hips). 940 turnWorld(B.spine1, axY, clamp(st.yawRate * .05 + (st.goalYaw || 0) * .18, -.3, .3) * freeW); 941 942 // Breathing when still (a slow rise of the chest and shoulders). After a 943 // long run she is out of breath: fast, deep breaths, bent over a little. 944 st.puff = clamp((st.puff || 0) + (speed > moveSpeed.run * .9 ? dt * .22 : -dt * .25), 0, 1.2); // builds while running hard 945 const tired = speed < .4 ? clamp(st.puff, 0, 1) : 0; 946 st.tiredS = damp(st.tiredS || 0, tired, 4, dt); 947 st.breath += dt * (1.6 + Math.min(speed, 4) * .6 + st.tiredS * 5); 948 const breathe = Math.sin(st.breath) * (.018 + .01 * Math.min(1, Math.abs(st.accel) / 4) + st.tiredS * .05); 949 turnWorld(B.spine, axX, -breathe * (1 - aw * .7)); // breathe with the chest 950 turnWorld(B.spine1, axX, st.tiredS * .22 * freeW); // hands-on-knees lean 951 if (st.tiredS > .3 && !busy()) face.effort = Math.max(face.effort, .1); 952 953 // Head stabilisation: people keep the head (and eyes) level while the body 954 // bobs and rocks; take out part of the clip's head pitch/roll. 955 if (B.head) {
956 const k = clamp(.25 + speed * .12, .25, .5) * freeW * (window.__stabQA === 0 ? 0 : 1); 957 B.head.getWorldQuaternion(qB); 958 const fwd = vA.set(0, 0, 1).applyQuaternion(qB); fwd.y = 0; 959 if (fwd.lengthSq() > 1e-6) { 960 fwd.normalize(); 961 const up = vB.set(0, 1, 0), right = new T.Vector3().crossVectors(up, fwd).normalize(); 962 // a level head with the same heading, keeping the rest pose's own tilt relative to the body 963 const lvl = new T.Quaternion().setFromRotationMatrix(new T.Matrix4().makeBasis(right, up, fwd)); 964 const cur = B.head.getWorldQuaternion(new T.Quaternion()); 965 const rest = st.headRest || (st.headRest = new T.Quaternion().copy(lvl).invert().multiply(cur)); // idle offset captured once 966 const target = lvl.multiply(rest); 967 const delta = target.multiply(cur.clone().invert()); // world rotation that levels it 968 const q = new T.Quaternion().slerp(delta, k); 969 B.head.parent.getWorldQuaternion(qP); 970 B.head.quaternion.copy(qP.invert().multiply(q.multiply(cur))); B.head.updateMatrixWorld(true); 971 } 972 } 973 974 // Look-at: a target point, or idle glances around when nothing to look at. 975 st.idleTime = speed < .1 && !busy() ? st.idleTime + dt : 0; 976 let wantYaw = 0, wantPitch = 0, wantW = 0; 977 if (st.lookTarget && B.head) { 978 B.head.getWorldPosition(vA); vB.copy(st.lookTarget).sub(vA).applyQuaternion(invRootQ); 979 wantYaw = Math.atan2(vB.x, vB.z); wantPitch = Math.atan2(vB.y, Math.hypot(vB.x, vB.z)); 980 wantW = Math.abs(wantYaw) < 1.9 ? 1 : 0; // don't twist around to look behind 981 st.eyeExtra = wantW ? [wantYaw - clamp(wantYaw, -1.05, 1.05), wantPitch - clamp(wantPitch, -.45, .5)] : [0, 0]; 982 wantYaw = clamp(wantYaw, -1.05, 1.05); wantPitch = clamp(wantPitch, -.45, .5); 983 } 984 const g = st.glance; g.t -= dt; 985 if (g.t <= 0) { g.t = 1.6 + Math.random() * 3.5; const idleish = st.idleTime > 1.5; g.yaw = idleish && Math.random() < .55 ? (Math.random() - .5) * 1.6 : 0; g.pitch = idleish ? (Math.random() - .6) * .4 : 0; } 986 if (g.yaw || g.pitch) { const k = st.lookTarget ? .45 : 1; wantYaw = wantYaw * (1 - k) + g.yaw * k; wantPitch = wantPitch * (1 - k) + g.pitch * k; wantW = Math.max(wantW, .9); } 987 // Moving: look where she is going. In a turn the eyes and head lead (they 988 // reach the new direction first), the chest follows, the feet come last. 989 st.goalYaw = damp(st.goalYaw || 0, m.goalYaw || 0, 14, dt); 990 if (speed > .5 || Math.abs(st.goalYaw) > .2) { 991 wantYaw = clamp(st.goalYaw * .85 + st.yawRate * .08, -1.0, 1.0); wantPitch = -.05; wantW = 1; 992 st.eyeExtra = [clamp(st.goalYaw - wantYaw, -.6, .6), 0]; 993 } 994 const lk = st.look, lookFree = 1 - aw * (full ? .85 : .5); 995 if (Math.abs(wantYaw * wantW - lk.yaw) > .7 && blink.t < blink.next - .05 && blink.t > .4) blink.t = blink.next; // a big head turn brings a blink 996 lk.yaw = damp(lk.yaw, wantYaw, 5, dt); lk.pitch = damp(lk.pitch, wantPitch, 5, dt); lk.w = damp(lk.w, wantW * lookFree, 4, dt); 997 const yaw = lk.yaw * lk.w, pitch = lk.pitch * lk.w; 998 st.headYawWant = yaw; // where the head should point (relative to her body) 999 // now and then, while looking at someone, a little head tilt 1000 { const tl = st.tilt || (st.tilt = {t: 4, v: 0, want: 0}); 1001 tl.t -= dt; if (tl.t <= 0) { tl.want = st.lookTarget && speed < .1 && !busy() && Math.random() < .5 ? (Math.random() < .5 ? -1 : 1) * (.16 + Math.random() * .1) : 0; tl.t = tl.want ? 1.6 + Math.random() * 1.4 : 2.5 + Math.random() * 3; } 1002 tl.v = damp(tl.v, speed < .1 ? tl.want : 0, 4, dt); 1003 if (B.head && tl.v) { vA.set(0, 0, 1).applyQuaternion(rootQ).applyAxisAngle(axY, yaw); turnWorld(B.head, vA, tl.v); turnWorld(B.neck, vA, tl.v * .3); } } 1004 // Eyes lead the head: they jump to the target at once and settle back as the 1005 // head catches up, cover what the neck can't turn, and flick about a little. 1006 { const ex = st.eyeExtra || [0, 0], sc = st.sacc || (st.sacc = {t: 0, x: 0, y: 0}); 1007 sc.t -= dt; if (sc.t <= 0) { sc.t = .6 + Math.random() * 1.8; const calm = speed < .1 ? 1 : .3; sc.x = (Math.random() - .5) * .007 * calm; sc.y = (Math.random() - .5) * .003 * calm; } 1008 const ey = (wantYaw * wantW - yaw) + ex[0], ep = (wantPitch * wantW - pitch) + ex[1]; 1009 const gx = clamp(ey * .022 + sc.x, -.016, .016), gy = clamp(ep * .014 + sc.y, -.006, .007); 1010 const gl = eyeU.uEyeLook.value, k = 1 - Math.exp(-dt * 28); 1011 gl.x += (gx - gl.x) * k; gl.y += (gy - gl.y) * k; 1012 if (window.__gazeShy) { gl.x += (window.__gazeShy[0] - gl.x) * k; gl.y += (window.__gazeShy[1] - gl.y) * k; } 1013 if (window.__gazeQA) gl.set(window.__gazeQA[0], window.__gazeQA[1]); 1014 } 1015 turnWorld(B.spine, axY, yaw * .2); 1016 turnWorld(B.neck, axY, yaw * .3); turnWorld(B.head, axY, yaw * .5); 1017 // pitch about her own right axis after the yaw 1018 if (B.head) { 1019 vA.set(1, 0, 0).applyQuaternion(rootQ).applyAxisAngle(axY, yaw * .8); 1020 turnWorld(B.neck, vA, -pitch * .4); turnWorld(B.head, vA, -pitch * .6); 1021 // keep the head a bit more level than the leaning body (eyes on the horizon) 1022 turnWorld(B.head, axX, -st.lean * .45); turnWorld(B.head, axZ, -st.bank * .5); 1023 } 1024 1025 // ----- crouch (jump anticipation, landing squash) and the airborne pose ----- 1026 const landing = spring(st.landS, 0, 140, 13, dt); 1027 st.crouch = damp(st.crouch, (m.crouch || 0) + Math.min(speed, 3) * .006, 18, dt); 1028 hero.position.y = heroPos.y - clamp(st.crouch - landing, -.02, .11); 1029 hero.updateMatrixWorld(true); 1030 // landing: the chest dips with the squash 1031 turnWorld(B.spine1, axX, clamp(-landing * 2.2, 0, .3) * (1 - (full ? aw : 0))); 1032 // standing still: a slow weight shift from foot to foot (the planted feet 1033 // stay put, so the knees take it) â nobody stands perfectly still 1034 { const stillW = clamp(1 - speed * 3, 0, 1) * (1 - aw) * (1 - st.air) * (1 - (st.sitS || 0)); 1035 st.swayT = (st.swayT || 0) + dt; 1036 const sway = (Math.sin(st.swayT * .55) * .6 + Math.sin(st.swayT * 1.3 + 1) * .25) * stillW; 1037 turnWorld(B.hips, axZ, sway * .045); turnWorld(B.spine1, axZ, -sway * .03); 1038 hero.position.x = heroPos.x + sway * .012; hero.updateMatrixWorld(true); } 1039 // ----- arms: a procedural, human-measured arm swing replaces the clip's ----- 1040 // The Meshy walk/run clips hold the arms out at 25-50° (up to 155° when 1041 // running) with the elbows flapping. Gait studies: walking arms swing 1042 // mostly fore-aft, opposite the same-side leg and a little behind it in 1043 // time, amplitude growing with speed, elbows ~20-35° bent (more on the 1044 // forward swing); running elbows ~90°, relaxed shoulders, hands not 1045 // crossing the midline; palms face the body. The chest counter-rotates. 1046 armsLayer(dt, speed, aw, full, upperOnly, m); 1047 // Action clips (punches, dodges) hold the elbows out level with the 1048 // shoulders ("chicken wings"). Keep each fist where the clip puts it, but 1049 // swing the elbow down and in, under the fist, the way a real guard is held.
1050 st.tuckW = damp(st.tuckW || 0, busy() ? (act.def?.tuck || 0) : 0, 10, dt); 1051 st.tuckK = damp(st.tuckK || 0, st.tuckW * aw * (1 - (armSt.modeW || 0)) * (window.__tuckQA ?? 1), 12, dt); // eased: switching actions resets aw 1052 tuckArms(st.tuckK); 1053 1054 // lying down / getting up: the blend alone tips her over stiff as a plank; 1055 // children go down knees first, so fold the legs while the blend is halfway 1056 if (full && act.def?.kneel && window.__kneelQA !== 0) { 1057 const k = 4 * aw * (1 - aw) * act.def.kneel; 1058 for (const [leg, side] of [[legs.L, 1], [legs.R, -1]]) { 1059 turnWorld(leg.up, axX, -1.0 * k); turnWorld(leg.knee, axX, 1.8 * k); 1060 } 1061 turnWorld(B.spine2, axX, .25 * k); 1062 } 1063 const airW = st.air * (1 - (full ? aw : 0)); 1064 if (airW > .01) { 1065 const vy = m.vy || 0; 1066 st.rise = damp(st.rise, clamp(vy / 3.5, -1, 1), 10, dt); // +1 rising ⦠-1 falling 1067 const tuck = (.55 + .35 * Math.max(0, st.rise) - .45 * Math.max(0, -st.rise)) * airW; 1068 const armsUp = (.25 + .45 * Math.max(0, st.rise)) * airW, armsOut = (.38 - .12 * Math.max(0, st.rise)) * airW; // arms swing up-forward on the way up, open a little for balance as she drops 1069 for (const [leg, side] of [[legs.L, 1], [legs.R, -1]]) { 1070 const lead = side === 1 ? 1.15 : .8; // one knee a bit higher: less stiff 1071 turnWorld(leg.up, axX, -1.25 * tuck * lead); turnWorld(leg.knee, axX, 1.9 * tuck * lead); 1072 turnWorld(leg.up, axZ, side * .12 * airW); 1073 } 1074 // (the arms in the air are part of the arm layer now) 1075 turnWorld(B.spine1, axX, -.12 * st.rise * airW); // arch up on the way up, curl as she falls 1076 } 1077 1078 // bending down (to pick something up) 1079 st.bendS = damp(st.bendS || 0, m.bend || 0, 14, dt); 1080 if (st.bendS > .01) { turnWorld(B.spine2, axX, st.bendS * .45); turnWorld(B.spine1, axX, st.bendS * .55); turnWorld(B.head, axX, -st.bendS * .3); } 1081 1082 // ----- carrying something in both arms (hugged to the chest) ----- 1083 st.holdS = damp(st.holdS || 0, m.hold && !(busy() && act.def.layer) ? 1 : 0, 6, dt); 1084 if (st.holdS > .01) { 1085 // both hands on the sides of the ball, a little behind its middle, 1086 // elbows down and out: a hug in front of the tummy 1087 const h = st.holdS, R = m.holdR || .17; 1088 const fwd = vA.set(0, 0, 1).applyQuaternion(rootQ), side = vB.set(1, 0, 0).applyQuaternion(rootQ); 1089 B.spine.getWorldPosition(st.holdC || (st.holdC = new T.Vector3())); 1090 st.holdC.addScaledVector(fwd, R + .1).y -= .1; 1091 for (const [s, arm, fore, hand, mid] of [[1, B.armL, B.foreL, B.handL, B.midL], [-1, B.armR, B.foreR, B.handR, B.midR]]) { 1092 if (!arm || !fore || !hand) continue; 1093 const tgt = st.holdC.clone().addScaledVector(side, s * R * .95).addScaledVector(fwd, -.03); 1094 tgt.lerp(hand.getWorldPosition(tv.h), 1 - h); 1095 solveLeg({up: arm, knee: fore, foot: hand}, tgt, tv.p.set(s * .7, -1, -.4).applyQuaternion(rootQ).normalize(), 1 - h); 1096 // fingers forward round the ball, palm against it 1097 if (mid) aimBone(hand, mid, tv.m.copy(fwd).multiplyScalar(.85).addScaledVector(side, -s * .35).normalize(), h * .9); 1098 } 1099 } 1100 1101 // ----- wrists: a hand never spins faster than a real wrist (~720°/s) ----- 1102 // (blending two clips whose hands face opposite ways turned them 50°+ a frame) 1103 if (window.__wristQA !== 0) for (const hand of [B.handL, B.handR]) { 1104 if (!hand) continue; 1105 hand.getWorldQuaternion(qB); 1106 const prev = hand.userData.wq || (hand.userData.wq = qB.clone()); 1107 const ang = prev.angleTo(qB), max = 12.5 * dt; 1108 if (ang > max) { prev.slerp(qB, max / ang); hand.parent.getWorldQuaternion(qP); hand.quaternion.copy(qP.invert().multiply(prev)); hand.updateMatrixWorld(true); } 1109 else prev.copy(qB); 1110 } 1111 // ----- hands: fists for effort moves, a loose curl when running ----- 1112 { const want = Math.max((act.def?.effort && busy() ? 1 : 0) * aw + clamp((speed - .9) / 2, 0, .45) * (1 - aw) + .12, m.fist || 0); // m.fist: holding a tool 1113 st.fist = damp(st.fist || 0, want, 12, dt); curlHands(window.__fistQA ?? st.fist); } 1114 1115 // ----- sitting on the ground, legs stretched out in front ----- 1116 st.sitS = damp(st.sitS || 0, m.sit ? 1 : 0, 5, dt); 1117 const sitK = st.sitS * st.sitS * (3 - 2 * st.sitS); 1118 if (sitK > .01) { 1119 B.hips.getWorldPosition(vA);
1119 const hipH = vA.y - groundYNow(); 1120 hero.position.y -= sitK * Math.max(0, hipH - .13); hero.updateMatrixWorld(true); // bottom down onto the grass 1121 turnWorld(B.spine2, axX, -.12 * sitK); turnWorld(B.spine1, axX, .1 * sitK); // lean back a touch, chest over 1122 for (const [leg, side] of [[legs.L, 1], [legs.R, -1]]) { 1123 if (!leg.foot) continue; 1124 leg.foot.getWorldPosition(vB); 1125 const tgt = root.localToWorld(new T.Vector3(side * .085, ankleY * .8, .34)); 1126 tgt.lerp(vB, 1 - sitK); 1127 solveLeg(leg, tgt, vPole.set(0, 1, .35).applyQuaternion(rootQ)); // knees up a little 1128 leg.down = false; leg.w = 0; 1129 } 1130 } 1131 // ----- step width: feet land nearer the midline when moving ----- 1132 // (the clips keep the feet ~19 cm apart â a toddler's waddle; walking 1133 // adults place them ~1/3 of that apart, runners almost on one line) 1134 { const gw = loco.walk.w + loco.run.w + loco.sprint.w, narrow = (.33 * loco.walk.w + .45 * (loco.run.w + loco.sprint.w)) * (1 - (full ? aw : 0)) * (1 - sitK); 1135 if (gw > .02 && narrow > .01 && window.__narrowQA !== 0) { 1136 iv.inv.copy(root.matrixWorld).invert(); 1137 for (const [leg, side] of [[legs.L, 1], [legs.R, -1]]) { 1138 if (!leg.foot) continue; 1139 leg.foot.getWorldPosition(vFoot); vLocal.copy(vFoot).applyMatrix4(iv.inv); 1140 const toward = (vLocal.x - side * .035) * narrow; // keep a few cm either side of the line 1141 const reach = (vLocal.z - .01) * ((st.stride || 1) - 1) * Math.min(1, gw * 1.5); // longer strides fore and aft 1142 if (Math.abs(toward) < .002 && Math.abs(reach) < .002) continue; 1143 vLocal.x -= toward; vLocal.z += reach; const tgt = vLocal.applyMatrix4(root.matrixWorld); 1144 solveLeg(leg, tgt, vPole.set(side * .08, 0, 1).applyQuaternion(rootQ), true); 1145 } 1146 } } 1147 // ----- feet: plant the foot that touches the ground (two-bone leg IK) ----- 1148 { const want = (1 - st.air) * (1 - (full ? Math.min(1, aw * (act.def?.unplant || 1)) : 0)) * (1 - sitK) * (m.plant === false || window.__plantQA === 0 ? 0 : 1); 1149 st.plantW = want > (st.plantW ?? 1) || m.air || (full && act.def?.unplant >= 10) ? want : damp(st.plantW ?? 1, want, 14, dt); // let go of the ground smoothly (a jump lets go at once) 1150 plantFeet(dt, st.plantW); } 1151 // ----- toe-off: the heel comes up first and the foot rolls over the toes ----- 1152 // (the clip lifts the trailing foot flat, toes and all, 5 cm off the ground) 1153 { const gw = (loco.walk.w + loco.run.w + loco.sprint.w) * (1 - st.air) * (1 - (full ? aw : 0)) * (1 - sitK); 1154 if (gw > .02 && window.__toeQA !== 0) { 1155 iv.inv.copy(root.matrixWorld).invert(); 1156 B.hips.getWorldPosition(vA); const hipZ = vA.applyMatrix4(iv.inv).z; 1157 const toeRest = ankleY * .16; 1158 for (const leg of [legs.L, legs.R]) { 1159 if (!leg.foot || !leg.toe) continue; 1160 leg.foot.getWorldPosition(iv.s); leg.toe.getWorldPosition(iv.e); 1161 vLocal.copy(iv.s).applyMatrix4(iv.inv); const fz = vLocal.z, ankH = vLocal.y - ankleY, toeH = iv.w.copy(iv.e).applyMatrix4(iv.inv).y; 1162 iv.d.copy(iv.e).sub(iv.s); const len = iv.d.length(); 1163 const pitch = Math.asin(clamp(iv.d.y / len, -1, 1)); 1164 if (leg.down) leg.flat = leg.flat === undefined ? pitch : leg.flat + (pitch - leg.flat) * .1; // the foot's angle standing flat 1165 // toe-off: only the trailing foot, only while it is leaving the ground 1166 const trail = clamp((hipZ - fz - .02) / .08, 0, 1); 1167 const behind = trail * clamp(1 - (ankH - .035) / .035, 0, 1), lift = toeH - toeRest; 1168 let np = pitch; 1169 if (behind > .01 && lift > .002) np += (Math.max(pitch - .6, Math.asin(clamp((iv.d.y - lift) / len, -1, 1))) - pitch) * behind * gw; // at most ~35° more 1170 // swing: the clip points the toes down 30-40°; people bring the ankle 1171 // back to neutral so the toes clear the ground (at most 10° down) 1172 const swing = (1 - trail) * clamp((ankH - .01) / .02, 0, 1) * (leg.down ? 0 : 1); 1173 if (swing > .01 && leg.flat !== undefined) np += (Math.max(pitch, leg.flat - .17) - pitch) * swing * gw * .85; 1174 leg.toeOff = damp(leg.toeOff || 0, np - pitch, 22, dt); np = pitch + leg.toeOff; // eased in and out, never a snap 1175 if (Math.abs(np - pitch) < 1e-3) continue; 1176 iv.t.set(iv.d.x, 0, iv.d.z).normalize(); 1177 iv.p.copy(iv.t).multiplyScalar(Math.cos(np)); iv.p.y = Math.sin(np); 1178 rotateBoneWorld(leg.foot, iv.d, iv.p); 1179 } 1180 } } 1181 1182 // ----- head yaw: the torso twists under a steady head (no side-to-side wag) ----- 1183 if (B.head && window.__stabQA !== 0) { 1184 B.head.getWorldQuaternion(qB); 1185 const f = vA.set(0, 0, 1).applyQuaternion(qB).applyQuaternion(invRootQ); 1186 const cur = Math.atan2(f.x, f.z), rest = st.headYawRest ?? (st.headYawRest = cur);
1187 let err = (rest + (st.headYawWant || 0)) - cur; while (err > Math.PI) err -= 2 * Math.PI; while (err < -Math.PI) err += 2 * Math.PI; 1188 const k = .85 * (1 - (full ? aw : 0)); 1189 turnWorld(B.neck, axY, err * k * .4); turnWorld(B.head, axY, err * k * .6); 1190 } 1191 // ----- head roll: keep the eyes level (apart from a deliberate head tilt) ----- 1192 if (B.head && window.__stabQA !== 0) { 1193 B.head.getWorldQuaternion(qB); 1194 const fwdH = vA.set(0, 0, 1).applyQuaternion(qB), rightH = vB.set(1, 0, 0).applyQuaternion(qB); 1195 const roll = Math.asin(clamp(rightH.y, -1, 1)) - (st.headRollRest ?? (st.headRollRest = Math.asin(clamp(rightH.y, -1, 1)))); 1196 const keep = (st.tilt?.v || 0); // the cute head tilt stays 1197 const k = .7 * (1 - (full ? aw : 0)) * (1 - st.air); 1198 turnWorld(B.head, fwdH.normalize(), -roll * k * (Math.abs(keep) > .02 ? .3 : 1)); // rotating about +forward raises the right side 1199 } 1200 1201 // ----- secondary motion: springs driven by the head's acceleration ----- 1202 if (B.head) { 1203 B.head.getWorldPosition(vA); 1204 if (!st.headPrev) st.headPrev = vA.clone(); 1205 vB.copy(vA).sub(st.headPrev).divideScalar(Math.max(dt, 1e-3)); st.headPrev.copy(vA); 1206 st.headAcc.copy(vB).sub(st.headVel).divideScalar(Math.max(dt, 1e-3)); st.headVel.lerp(vB, .5); 1207 const acc = st.headAcc.clone().applyQuaternion(invRootQ), vel = st.headVel.clone().applyQuaternion(invRootQ); 1208 // hair lags behind: sideways and back/forward; also pushed back by speed (air drag) 1209 // the same breeze that sways the grass lifts her hair a little 1210 st.windT = (st.windT || 0) + dt; const breeze = (Math.sin(st.windT * 1.7) * .5 + Math.sin(st.windT * 2.9 + 1.3) * .2) * .12; 1211 const hx = spring(st.hairX, clamp(-acc.x * .02 - vel.x * .12 + breeze, -1, 1), 60, 7, dt); 1212 const hz = spring(st.hairZ, clamp(-acc.z * .015 + Math.max(0, vel.z) * .22 + (m.vy || 0) * -.05, -.6, 1), 50, 6, dt); 1213 const tx = spring(st.tailX, clamp(-acc.x * .012 - vel.x * .08 - st.yawRate * speed * .05 + breeze * .6, -.7, .7), 34, 4.5, dt); 1214 const tz = spring(st.tailZ, clamp(-acc.z * .01 + vel.z * .12 - (m.vy || 0) * .12 + acc.y * -.004, -.6, .8), 30, 4, dt); 1215 if (hairMesh) { 1216 const inf = hairMesh.morphTargetInfluences, iL = morphI(hairMesh, 'Hair_Sway_L'), iR = morphI(hairMesh, 'Hair_Sway_R'), bL = morphI(hairMesh, 'Hair_Back_L'), bR = morphI(hairMesh, 'Hair_Back_R'); 1217 inf[iL] = clamp(inf[iL] + Math.max(0, hx), 0, 1); inf[iR] = clamp(inf[iR] + Math.max(0, -hx), 0, 1); 1218 inf[bL] = clamp(inf[bL] + hz, 0, 1); inf[bR] = clamp(inf[bR] + hz, 0, 1); 1219 } 1220 // hair tails swing from their roots 1221 turnWorld(B.tailL, axZ, tx * .9); turnWorld(B.tailR, axZ, tx * .9); 1222 turnWorld(B.tailL, axX, tz * .8); turnWorld(B.tailR, axX, tz * .8); 1223 st.tails = [tx, tz]; st.hair = [hx, hz]; 1224 // bag: a heavier, slower pendulum driven by the same motion 1225 const bx = spring(st.bagX || (st.bagX = [0, 0]), clamp(-acc.z * .012 + vel.z * .05 - (m.vy || 0) * .05, -.45, .45), 26, 3.2, dt); 1226 const bz = spring(st.bagZ || (st.bagZ = [0, 0]), clamp(acc.x * .01 + vel.x * .05 + st.yawRate * speed * .06, -.2, .45), 26, 3.2, dt); 1227 bagU.value.set(window.__bagQA?.[0] ?? -bx, window.__bagQA?.[1] ?? Math.max(-.15, bz)); 1228 } 1229 if (hemMesh) { 1230 const inf = hemMesh.morphTargetInfluences, iS = morphI(hemMesh, 'Hem_Sway'), iL = morphI(hemMesh, 'Hem_Lift'); 1231 const sw = spring(st.hem, clamp(st.yawRate * speed * .12 + st.bank * .6, -1, 1), 40, 5, dt); 1232 const li = spring(st.lift, clamp(speed * .07 + st.air * .6 + Math.max(0, -(m.vy || 0)) * .08, 0, 1), 45, 6, dt); 1233 inf[iS] = clamp(inf[iS] + Math.abs(sw), 0, 1); inf[iL] = clamp(inf[iL] + li, 0, 1); 1234 } 1235 } 1236 1237 return { 1238 root, hero, clips, mixer, B, gaitSpeed, moveSpeed, loco, st, act, face, legs, eyeU, suckU, bagU, get ankleY() { return ankleY; }, 1239 setupBlink, update, play, stop, busy, blocksMove, 1240 events, // footsteps since the caller last emptied this list 1241 land(impact) { st.landS[1] -= clamp(impact, 0, 9) * .32; }, 1242 lookAt(p) { st.lookTarget = p ? (st.lookTarget || new T.Vector3()).copy(p) : null; }, 1243 get actionKey() { return busy() ? act.key : null; }, 1244 get holdCentre() { return st.holdS > .5 ? st.holdC : null; }, // where the hugged ball sits 1245 // Put a hand at a world point (after update): arm IK, elbow down and out. 1246 // side +1 = her left, -1 = her right. Optional fingers / thumb-side 1247 // directions (world) turn the hand, e.g. to grip a knife handle. 1248 reach(side, target, w = 1, {fingers = null, thumb = null} = {}) { 1249 const arm = side > 0 ? B.armL : B.armR, fore = side > 0 ? B.foreL : B.foreR, hand = side > 0 ? B.handL : B.handR, mid = side > 0 ? B.midL : B.midR; 1250 if (!arm || !fore || !hand || w <= 0) return; 1251 const tgt = hand.getWorldPosition(new T.Vector3()).lerp(target, w); 1252 solveLeg({up: arm, knee: fore, foot: hand}, tgt, new T.Vector3(side * .7, -1, -.4).applyQuaternion(rootQ).normalize(), 1 - w); 1253 if (fingers && mid) aimBone(hand, mid, fingers.clone().normalize(), w); 1254 if (thumb && mid) { 1255 const idx = hand.children.find(c => /Index1/.test(c.name)), pin = hand.children.find(c => /Pinky1/.test(c.name)); 1256 if (idx && pin) { 1257 const h = hand.getWorldPosition(new T.Vector3()), f = mid.getWorldPosition(new T.Vector3()).sub(h).normalize(); 1258 const ip = idx.getWorldPosition(new T.Vector3()).sub(pin.getWorldPosition(new T.Vector3())); ip.addScaledVector(f, -ip.dot(f)).normalize(); 1259 const want = thumb.clone().addScaledVector(f, -thumb.dot(f)); if (want.lengthSq() < 1e-6) return; want.normalize(); 1260 turnWorld(hand, f, Math.atan2(new T.Vector3().crossVectors(ip, want).dot(f), ip.dot(want)) * w); 1261 } 1262 } 1263 hand.userData.wq?.copy(hand.getWorldQuaternion(new T.Quaternion())); // the wrist limiter starts from here next frame 1264 }, 1265 }; 1266}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.