spoint 0.1.650 → 0.1.651

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,239 +49,11 @@
49
49
 
50
50
  import * as THREE from 'three'
51
51
  import { InstancedMesh2 } from '@three.ez/instanced-mesh'
52
+ import { bakeVAT, bakeVATMultiClip } from './PlayerVATBake.js'
52
53
 
53
- // window.__tickAnimTiming / _tickAnimSamples live in app.js (the actual call site) -- this module only
54
- // consumes the resulting bake/renderer, it doesn't own the timing surface.
55
-
56
- // --- Bake -------------------------------------------------------------------------------------------
57
- // Drives a REAL AnimationMixer against a REAL cloned SkinnedMesh hierarchy at a fixed sample rate,
58
- // reading back post-skin world-space vertex positions each sample via CPU skinning (THREE's own
59
- // SkinnedMesh.boneTransform, the same math the GPU skinning path performs, just run on CPU once at
60
- // bake time instead of every frame at runtime) and encoding position DELTA (post-skin minus bind-pose
61
- // local position) into a float RGBA DataTexture: one texel row per sampled animation frame, one texel
62
- // column per source vertex. A GPU vertex shader then reconstructs the animated position at runtime by
63
- // sampling this texture at (vertexIndex, phase) and adding the delta to the bind-pose position -- no
64
- // bone matrices, no skinning weights needed at runtime, just one texelFetch.
65
- const VAT_SAMPLE_HZ = 24 // resample rate baked into the texture; independent of the source clip's authored keyframe spacing (same discipline as AnimationClipCache.js's RESAMPLE_HZ)
66
-
67
- const _vtmp = new THREE.Vector3()
68
- const _baseVec = new THREE.Vector3()
69
- const _bindPos = new THREE.Vector3()
70
- const _boneMtx = new THREE.Matrix4()
71
- /**
72
- * Computes the post-skin world-space (mesh-local-space, i.e. relative to the SkinnedMesh's own
73
- * unmoved transform) position of vertex `vi` on `skinnedMesh` at its CURRENT pose (caller must have
74
- * already advanced the driving AnimationMixer + called skeleton.update() before calling this).
75
- * Exactly mirrors THREE.SkinnedMesh.applyBoneTransform's own CPU skin math (see
76
- * three/src/objects/SkinnedMesh.js): baseVector = bindPos * bindMatrix, accumulate per-bone
77
- * (bone.matrixWorld * boneInverse) * baseVector weighted, then multiply by bindMatrixInverse -- NOT the
78
- * skeleton's own precomputed boneMatrices array directly (that buffer already premultiplies bindMatrix
79
- * differently for GPU upload; reproducing the CPU-path formula verbatim, bone-by-bone, is what keeps
80
- * this bake bit-identical to what the GPU skinning path would have rendered).
81
- */
82
- function boneTransformInto(skinnedMesh, vi, target) {
83
- const geometry = skinnedMesh.geometry
84
- const skeleton = skinnedMesh.skeleton
85
- const posAttr = geometry.attributes.position
86
- const skinIndex = geometry.attributes.skinIndex
87
- const skinWeight = geometry.attributes.skinWeight
88
- // _baseVec is a SEPARATE scratch from `target` -- target is caller-supplied and may alias a module-level
89
- // scratch (bakeVAT passes _vtmp as target); reusing the same scratch for the internal base-vector AND the
90
- // output accumulator caused target.set(0,0,0) below to wipe the base vector before it was consumed
91
- // (found live: every baked frame read back as the raw un-skinned bind pose, a large CONSTANT delta at
92
- // "frame 0" that should have been ~0 -- traced to exactly this aliasing bug).
93
- _baseVec.fromBufferAttribute(posAttr, vi).applyMatrix4(skinnedMesh.bindMatrix)
94
- target.set(0, 0, 0)
95
- for (let j = 0; j < 4; j++) {
96
- const weight = skinWeight.getComponent(vi, j)
97
- if (weight === 0) continue
98
- const boneIndex = skinIndex.getComponent(vi, j)
99
- _boneMtx.multiplyMatrices(skeleton.bones[boneIndex].matrixWorld, skeleton.boneInverses[boneIndex])
100
- const p = _baseVec.clone().applyMatrix4(_boneMtx)
101
- target.x += p.x * weight; target.y += p.y * weight; target.z += p.z * weight
102
- }
103
- target.applyMatrix4(skinnedMesh.bindMatrixInverse)
104
- return target
105
- }
106
-
107
- const _baseNrm = new THREE.Vector3()
108
- const _skinnedNrm = new THREE.Vector3()
109
- const _skinMtx = new THREE.Matrix4()
110
- const _accumMtx = new THREE.Matrix4()
111
- const _weightedMtx = new THREE.Matrix4()
112
- /**
113
- * Computes the post-skin (mesh-local-space) NORMAL of vertex `vi` at the CURRENT pose, writing it into
114
- * `target`. Mirrors three's own GPU `skinnormal_vertex` chunk (see
115
- * three/src/renderers/shaders/ShaderChunk/skinnormal_vertex.glsl.js) verbatim rather than a generic
116
- * inverse-transpose normal-matrix recompute: skinMatrix = bindMatrixInverse * (per-bone-weighted sum of
117
- * boneMatrices) * bindMatrix, then objectNormal = skinMatrix * vec4(objectNormal, 0.0) -- a plain LINEAR
118
- * transform (w=0, no translation row), NOT a proper inverse-transpose normal-matrix; GPU skinning doesn't
119
- * correct for non-uniform scale either, so reproducing that exact (non-)correction is what keeps this
120
- * bake bit-identical to what the GPU skinning path would have rendered, same discipline as
121
- * boneTransformInto above. Matrix summation IS valid here (not an approximation): matrix multiplication
122
- * is linear, so sum(w_i * M_i) * v === sum(w_i * (M_i * v)) for any v -- weighting the MATRICES first
123
- * (like the GPU chunk does) and weighting the TRANSFORMED VECTORS first (like boneTransformInto does for
124
- * position) are mathematically identical; the position path already accumulates transformed vectors, this
125
- * one accumulates the matrices per source verbatim to mirror the shader chunk line-for-line for easy
126
- * cross-reference, both are correct.
127
- */
128
- function boneTransformNormalInto(skinnedMesh, vi, target) {
129
- const geometry = skinnedMesh.geometry
130
- const skeleton = skinnedMesh.skeleton
131
- const normalAttr = geometry.attributes.normal
132
- const skinIndex = geometry.attributes.skinIndex
133
- const skinWeight = geometry.attributes.skinWeight
134
- _baseNrm.fromBufferAttribute(normalAttr, vi)
135
- _accumMtx.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) // zero matrix accumulator (skinMatrix += ...)
136
- for (let j = 0; j < 4; j++) {
137
- const weight = skinWeight.getComponent(vi, j)
138
- if (weight === 0) continue
139
- const boneIndex = skinIndex.getComponent(vi, j)
140
- _boneMtx.multiplyMatrices(skeleton.bones[boneIndex].matrixWorld, skeleton.boneInverses[boneIndex])
141
- _weightedMtx.copy(_boneMtx)
142
- for (let k = 0; k < 16; k++) _weightedMtx.elements[k] *= weight
143
- for (let k = 0; k < 16; k++) _accumMtx.elements[k] += _weightedMtx.elements[k]
144
- }
145
- _skinMtx.multiplyMatrices(skinnedMesh.bindMatrixInverse, _accumMtx)
146
- _skinMtx.multiply(skinnedMesh.bindMatrix)
147
- // Deliberately NOT Vector3.transformDirection (it normalizes) -- the GPU skinnormal_vertex chunk this
148
- // mirrors does a plain un-normalized mat4*vec4(n,0) transform; normalize_vertex/normal_fragment_begin
149
- // downstream normalize exactly once, after normalMatrix + instancing are also applied, so normalizing
150
- // here would double-normalize a not-yet-fully-transformed intermediate and desync from what the GPU
151
- // skinning path (and this bake's own bit-identical-parity goal) actually produces.
152
- const e = _skinMtx.elements
153
- target.set(
154
- e[0] * _baseNrm.x + e[4] * _baseNrm.y + e[8] * _baseNrm.z,
155
- e[1] * _baseNrm.x + e[5] * _baseNrm.y + e[9] * _baseNrm.z,
156
- e[2] * _baseNrm.x + e[6] * _baseNrm.y + e[10] * _baseNrm.z
157
- )
158
- return target
159
- }
160
-
161
- /**
162
- * Bakes `clip` on `skinnedMesh` (must already be skeleton-bound, i.e. skinnedMesh.skeleton is the real
163
- * pose skeleton, bindMatrixInverse set) into a VAT DataTexture pair. Returns
164
- * {texture, normalTexture, frameCount, vertexCount, duration, sampleHz} -- both textures are
165
- * (vertexCount wide) x (frameCount tall), RGBA32F, same (vertexIndex, frame) texel addressing. `texture`'s
166
- * rgb = (post-skin - bind-pose) POSITION delta for that vertex at that sampled frame (unchanged from the
167
- * first slice). `normalTexture`'s rgb = (post-skin - bind-pose) un-normalized NORMAL delta (see
168
- * boneTransformNormalInto's header for why it's deliberately un-normalized), animation-vat-normal-delta-
169
- * lighting's follow-on: sampling+adding this alongside the position delta lets REDUCED-tier crowd
170
- * lighting respond to the animated pose instead of shading against the static bind-pose normal baked into
171
- * the base geometry. Alpha unused on both (reserved, kept at 1 so non-EXT_color_buffer_float readback
172
- * tooling still sees a valid alpha channel). normalTexture is null if the source mesh has no `normal`
173
- * attribute (degrades to the pre-existing bind-pose-normal-only behavior, same as before this follow-on).
174
- *
175
- * Runs on a REAL THREE.AnimationMixer bound to the mesh's root (mixer.clipAction(clip).play()),
176
- * advancing mixer.update(dt) at VAT_SAMPLE_HZ and reading back the real post-skin vertex positions (and
177
- * normals) each step -- not an approximation, the literal same CPU skin math THREE performs to render a
178
- * frame, captured once instead of every frame forever. The normal sample piggybacks on the SAME per-frame
179
- * per-vertex loop the position sample already runs (incremental cost on an already-running pass, not a
180
- * second bake pass), per the row's explicit guidance.
181
- */
182
- export function bakeVAT(skinnedMesh, mixerRoot, clip, opts = {}) {
183
- const sampleHz = opts.sampleHz || VAT_SAMPLE_HZ
184
- const geometry = skinnedMesh.geometry
185
- const posAttr = geometry.attributes.position
186
- const normalAttr = geometry.attributes.normal
187
- const hasNormals = !!normalAttr
188
- const vertexCount = posAttr.count
189
- const dt = 1 / sampleHz
190
- const frameCount = Math.max(2, Math.ceil(clip.duration * sampleHz) + 1)
191
-
192
- const mixer = new THREE.AnimationMixer(mixerRoot)
193
- const action = mixer.clipAction(clip)
194
- action.play()
195
- action.paused = true
196
-
197
- // Cap texture width at a hardware-safe size; vertexCount for a typical VRM body mesh (a few thousand)
198
- // comfortably fits one row, so this only matters for an unusually dense source mesh.
199
- const maxTexSize = opts.maxTexSize || 4096
200
- const width = Math.min(vertexCount, maxTexSize)
201
- const rowsPerFrame = Math.ceil(vertexCount / width)
202
- const height = frameCount * rowsPerFrame
203
-
204
- const data = new Float32Array(width * height * 4)
205
- const normalData = hasNormals ? new Float32Array(width * height * 4) : null
206
- const _bindNrm = new THREE.Vector3()
207
-
208
- for (let f = 0; f < frameCount; f++) {
209
- const t = Math.min(f * dt, clip.duration)
210
- action.time = t
211
- mixer.update(0) // 0-dt update after directly setting action.time -- applies the pose for this exact sample time without accumulating drift
212
- // mixer.update only writes the new LOCAL bone quaternion/position; matrixWorld (what boneTransformInto
213
- // actually reads) is stale until the hierarchy is re-propagated. skeleton.update() alone is NOT enough
214
- // -- it recomputes boneMatrices FROM bone.matrixWorld, so a missing updateMatrixWorld here silently
215
- // bakes every frame at the bind pose (found live: frame-0 delta was a large CONSTANT offset instead of
216
- // ~0, traced to exactly this missing call).
217
- mixerRoot.updateMatrixWorld(true)
218
- skinnedMesh.skeleton.update()
219
- for (let vi = 0; vi < vertexCount; vi++) {
220
- boneTransformInto(skinnedMesh, vi, _vtmp)
221
- _bindPos.fromBufferAttribute(posAttr, vi)
222
- const row = f * rowsPerFrame + Math.floor(vi / width)
223
- const col = vi % width
224
- const idx = (row * width + col) * 4
225
- data[idx] = _vtmp.x - _bindPos.x
226
- data[idx + 1] = _vtmp.y - _bindPos.y
227
- data[idx + 2] = _vtmp.z - _bindPos.z
228
- data[idx + 3] = 1
229
- if (hasNormals) {
230
- boneTransformNormalInto(skinnedMesh, vi, _skinnedNrm)
231
- _bindNrm.fromBufferAttribute(normalAttr, vi)
232
- normalData[idx] = _skinnedNrm.x - _bindNrm.x
233
- normalData[idx + 1] = _skinnedNrm.y - _bindNrm.y
234
- normalData[idx + 2] = _skinnedNrm.z - _bindNrm.z
235
- normalData[idx + 3] = 1
236
- }
237
- }
238
- }
239
-
240
- mixer.stopAllAction()
241
- mixer.uncacheAction(clip, mixerRoot)
242
-
243
- const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.FloatType)
244
- texture.needsUpdate = true
245
- texture.minFilter = THREE.NearestFilter
246
- texture.magFilter = THREE.NearestFilter
247
- texture.wrapS = THREE.ClampToEdgeWrapping
248
- texture.wrapT = THREE.ClampToEdgeWrapping
249
- texture.generateMipmaps = false
250
-
251
- let normalTexture = null
252
- if (hasNormals) {
253
- normalTexture = new THREE.DataTexture(normalData, width, height, THREE.RGBAFormat, THREE.FloatType)
254
- normalTexture.needsUpdate = true
255
- normalTexture.minFilter = THREE.NearestFilter
256
- normalTexture.magFilter = THREE.NearestFilter
257
- normalTexture.wrapS = THREE.ClampToEdgeWrapping
258
- normalTexture.wrapT = THREE.ClampToEdgeWrapping
259
- normalTexture.generateMipmaps = false
260
- }
261
-
262
- return { texture, normalTexture, frameCount, vertexCount, width, rowsPerFrame, duration: clip.duration, sampleHz }
263
- }
264
-
265
- /**
266
- * Bakes MULTIPLE clips against the same skinnedMesh/mixerRoot into independent VAT textures sharing one
267
- * vertex-index layout (same skinnedMesh.geometry -> same vatVertexIndex attribute works for all of them).
268
- * `clipsByName` is a Map/plain-object of name -> THREE.AnimationClip; `names` picks which entries to bake
269
- * and in what order (defaults to every key). Returns { idle, move, names, ... } -- `idle` and `move` are
270
- * the first two baked vatData results (the only two createVATCrowdRenderer's blend path consumes today),
271
- * plus `byName` for direct lookup if more than 2 are ever baked.
272
- */
273
- export function bakeVATMultiClip(skinnedMesh, mixerRoot, clipsByName, opts = {}) {
274
- const entries = clipsByName instanceof Map ? Array.from(clipsByName.entries()) : Object.entries(clipsByName)
275
- const names = opts.names || entries.map(([n]) => n)
276
- const byName = {}
277
- for (const name of names) {
278
- const clip = clipsByName instanceof Map ? clipsByName.get(name) : clipsByName[name]
279
- if (!clip) continue
280
- byName[name] = bakeVAT(skinnedMesh, mixerRoot, clip, opts)
281
- }
282
- const baked = names.map(n => byName[n]).filter(Boolean)
283
- return { idle: baked[0] || null, move: baked[1] || baked[0] || null, names, byName }
284
- }
54
+ // Bake pipeline (bakeVAT/bakeVATMultiClip) lives in PlayerVATBake.js -- re-exported here for backward
55
+ // compatibility with app.js's existing single-file import.
56
+ export { bakeVAT, bakeVATMultiClip }
285
57
 
286
58
  // --- GPU crowd material -------------------------------------------------------------------------------
287
59
  // Builds a MeshLambertMaterial patched (onBeforeCompile) to displace `position` by a VAT-sampled delta
@@ -0,0 +1,230 @@
1
+ // Baked vertex-animation-texture (VAT) bake pipeline: samples a real THREE.AnimationMixer at a fixed
2
+ // rate and encodes post-skin position+normal deltas into float DataTextures. Split from PlayerVAT.js --
3
+ // the GPU crowd material/renderer that CONSUMES these bakes stays there. See that file's own header for
4
+ // the full VAT design rationale (why this exists, multi-clip blend, normal-delta lighting follow-on).
5
+
6
+ import * as THREE from 'three'
7
+
8
+ // window.__tickAnimTiming / _tickAnimSamples live in app.js (the actual call site) -- this module only
9
+ // consumes the resulting bake/renderer, it doesn't own the timing surface.
10
+
11
+ const VAT_SAMPLE_HZ = 24 // resample rate baked into the texture; independent of the source clip's authored keyframe spacing (same discipline as AnimationClipCache.js's RESAMPLE_HZ)
12
+
13
+ const _vtmp = new THREE.Vector3()
14
+ const _baseVec = new THREE.Vector3()
15
+ const _bindPos = new THREE.Vector3()
16
+ const _boneMtx = new THREE.Matrix4()
17
+ /**
18
+ * Computes the post-skin world-space (mesh-local-space, i.e. relative to the SkinnedMesh's own
19
+ * unmoved transform) position of vertex `vi` on `skinnedMesh` at its CURRENT pose (caller must have
20
+ * already advanced the driving AnimationMixer + called skeleton.update() before calling this).
21
+ * Exactly mirrors THREE.SkinnedMesh.applyBoneTransform's own CPU skin math (see
22
+ * three/src/objects/SkinnedMesh.js): baseVector = bindPos * bindMatrix, accumulate per-bone
23
+ * (bone.matrixWorld * boneInverse) * baseVector weighted, then multiply by bindMatrixInverse -- NOT the
24
+ * skeleton's own precomputed boneMatrices array directly (that buffer already premultiplies bindMatrix
25
+ * differently for GPU upload; reproducing the CPU-path formula verbatim, bone-by-bone, is what keeps
26
+ * this bake bit-identical to what the GPU skinning path would have rendered).
27
+ */
28
+ function boneTransformInto(skinnedMesh, vi, target) {
29
+ const geometry = skinnedMesh.geometry
30
+ const skeleton = skinnedMesh.skeleton
31
+ const posAttr = geometry.attributes.position
32
+ const skinIndex = geometry.attributes.skinIndex
33
+ const skinWeight = geometry.attributes.skinWeight
34
+ // _baseVec is a SEPARATE scratch from `target` -- target is caller-supplied and may alias a module-level
35
+ // scratch (bakeVAT passes _vtmp as target); reusing the same scratch for the internal base-vector AND the
36
+ // output accumulator caused target.set(0,0,0) below to wipe the base vector before it was consumed
37
+ // (found live: every baked frame read back as the raw un-skinned bind pose, a large CONSTANT delta at
38
+ // "frame 0" that should have been ~0 -- traced to exactly this aliasing bug).
39
+ _baseVec.fromBufferAttribute(posAttr, vi).applyMatrix4(skinnedMesh.bindMatrix)
40
+ target.set(0, 0, 0)
41
+ for (let j = 0; j < 4; j++) {
42
+ const weight = skinWeight.getComponent(vi, j)
43
+ if (weight === 0) continue
44
+ const boneIndex = skinIndex.getComponent(vi, j)
45
+ _boneMtx.multiplyMatrices(skeleton.bones[boneIndex].matrixWorld, skeleton.boneInverses[boneIndex])
46
+ const p = _baseVec.clone().applyMatrix4(_boneMtx)
47
+ target.x += p.x * weight; target.y += p.y * weight; target.z += p.z * weight
48
+ }
49
+ target.applyMatrix4(skinnedMesh.bindMatrixInverse)
50
+ return target
51
+ }
52
+
53
+ const _baseNrm = new THREE.Vector3()
54
+ const _skinnedNrm = new THREE.Vector3()
55
+ const _skinMtx = new THREE.Matrix4()
56
+ const _accumMtx = new THREE.Matrix4()
57
+ const _weightedMtx = new THREE.Matrix4()
58
+ /**
59
+ * Computes the post-skin (mesh-local-space) NORMAL of vertex `vi` at the CURRENT pose, writing it into
60
+ * `target`. Mirrors three's own GPU `skinnormal_vertex` chunk (see
61
+ * three/src/renderers/shaders/ShaderChunk/skinnormal_vertex.glsl.js) verbatim rather than a generic
62
+ * inverse-transpose normal-matrix recompute: skinMatrix = bindMatrixInverse * (per-bone-weighted sum of
63
+ * boneMatrices) * bindMatrix, then objectNormal = skinMatrix * vec4(objectNormal, 0.0) -- a plain LINEAR
64
+ * transform (w=0, no translation row), NOT a proper inverse-transpose normal-matrix; GPU skinning doesn't
65
+ * correct for non-uniform scale either, so reproducing that exact (non-)correction is what keeps this
66
+ * bake bit-identical to what the GPU skinning path would have rendered, same discipline as
67
+ * boneTransformInto above. Matrix summation IS valid here (not an approximation): matrix multiplication
68
+ * is linear, so sum(w_i * M_i) * v === sum(w_i * (M_i * v)) for any v -- weighting the MATRICES first
69
+ * (like the GPU chunk does) and weighting the TRANSFORMED VECTORS first (like boneTransformInto does for
70
+ * position) are mathematically identical; the position path already accumulates transformed vectors, this
71
+ * one accumulates the matrices per source verbatim to mirror the shader chunk line-for-line for easy
72
+ * cross-reference, both are correct.
73
+ */
74
+ function boneTransformNormalInto(skinnedMesh, vi, target) {
75
+ const geometry = skinnedMesh.geometry
76
+ const skeleton = skinnedMesh.skeleton
77
+ const normalAttr = geometry.attributes.normal
78
+ const skinIndex = geometry.attributes.skinIndex
79
+ const skinWeight = geometry.attributes.skinWeight
80
+ _baseNrm.fromBufferAttribute(normalAttr, vi)
81
+ _accumMtx.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) // zero matrix accumulator (skinMatrix += ...)
82
+ for (let j = 0; j < 4; j++) {
83
+ const weight = skinWeight.getComponent(vi, j)
84
+ if (weight === 0) continue
85
+ const boneIndex = skinIndex.getComponent(vi, j)
86
+ _boneMtx.multiplyMatrices(skeleton.bones[boneIndex].matrixWorld, skeleton.boneInverses[boneIndex])
87
+ _weightedMtx.copy(_boneMtx)
88
+ for (let k = 0; k < 16; k++) _weightedMtx.elements[k] *= weight
89
+ for (let k = 0; k < 16; k++) _accumMtx.elements[k] += _weightedMtx.elements[k]
90
+ }
91
+ _skinMtx.multiplyMatrices(skinnedMesh.bindMatrixInverse, _accumMtx)
92
+ _skinMtx.multiply(skinnedMesh.bindMatrix)
93
+ // Deliberately NOT Vector3.transformDirection (it normalizes) -- the GPU skinnormal_vertex chunk this
94
+ // mirrors does a plain un-normalized mat4*vec4(n,0) transform; normalize_vertex/normal_fragment_begin
95
+ // downstream normalize exactly once, after normalMatrix + instancing are also applied, so normalizing
96
+ // here would double-normalize a not-yet-fully-transformed intermediate and desync from what the GPU
97
+ // skinning path (and this bake's own bit-identical-parity goal) actually produces.
98
+ const e = _skinMtx.elements
99
+ target.set(
100
+ e[0] * _baseNrm.x + e[4] * _baseNrm.y + e[8] * _baseNrm.z,
101
+ e[1] * _baseNrm.x + e[5] * _baseNrm.y + e[9] * _baseNrm.z,
102
+ e[2] * _baseNrm.x + e[6] * _baseNrm.y + e[10] * _baseNrm.z
103
+ )
104
+ return target
105
+ }
106
+
107
+ /**
108
+ * Bakes `clip` on `skinnedMesh` (must already be skeleton-bound, i.e. skinnedMesh.skeleton is the real
109
+ * pose skeleton, bindMatrixInverse set) into a VAT DataTexture pair. Returns
110
+ * {texture, normalTexture, frameCount, vertexCount, duration, sampleHz} -- both textures are
111
+ * (vertexCount wide) x (frameCount tall), RGBA32F, same (vertexIndex, frame) texel addressing. `texture`'s
112
+ * rgb = (post-skin - bind-pose) POSITION delta for that vertex at that sampled frame (unchanged from the
113
+ * first slice). `normalTexture`'s rgb = (post-skin - bind-pose) un-normalized NORMAL delta (see
114
+ * boneTransformNormalInto's header for why it's deliberately un-normalized), animation-vat-normal-delta-
115
+ * lighting's follow-on: sampling+adding this alongside the position delta lets REDUCED-tier crowd
116
+ * lighting respond to the animated pose instead of shading against the static bind-pose normal baked into
117
+ * the base geometry. Alpha unused on both (reserved, kept at 1 so non-EXT_color_buffer_float readback
118
+ * tooling still sees a valid alpha channel). normalTexture is null if the source mesh has no `normal`
119
+ * attribute (degrades to the pre-existing bind-pose-normal-only behavior, same as before this follow-on).
120
+ *
121
+ * Runs on a REAL THREE.AnimationMixer bound to the mesh's root (mixer.clipAction(clip).play()),
122
+ * advancing mixer.update(dt) at VAT_SAMPLE_HZ and reading back the real post-skin vertex positions (and
123
+ * normals) each step -- not an approximation, the literal same CPU skin math THREE performs to render a
124
+ * frame, captured once instead of every frame forever. The normal sample piggybacks on the SAME per-frame
125
+ * per-vertex loop the position sample already runs (incremental cost on an already-running pass, not a
126
+ * second bake pass), per the row's explicit guidance.
127
+ */
128
+ export function bakeVAT(skinnedMesh, mixerRoot, clip, opts = {}) {
129
+ const sampleHz = opts.sampleHz || VAT_SAMPLE_HZ
130
+ const geometry = skinnedMesh.geometry
131
+ const posAttr = geometry.attributes.position
132
+ const normalAttr = geometry.attributes.normal
133
+ const hasNormals = !!normalAttr
134
+ const vertexCount = posAttr.count
135
+ const dt = 1 / sampleHz
136
+ const frameCount = Math.max(2, Math.ceil(clip.duration * sampleHz) + 1)
137
+
138
+ const mixer = new THREE.AnimationMixer(mixerRoot)
139
+ const action = mixer.clipAction(clip)
140
+ action.play()
141
+ action.paused = true
142
+
143
+ // Cap texture width at a hardware-safe size; vertexCount for a typical VRM body mesh (a few thousand)
144
+ // comfortably fits one row, so this only matters for an unusually dense source mesh.
145
+ const maxTexSize = opts.maxTexSize || 4096
146
+ const width = Math.min(vertexCount, maxTexSize)
147
+ const rowsPerFrame = Math.ceil(vertexCount / width)
148
+ const height = frameCount * rowsPerFrame
149
+
150
+ const data = new Float32Array(width * height * 4)
151
+ const normalData = hasNormals ? new Float32Array(width * height * 4) : null
152
+ const _bindNrm = new THREE.Vector3()
153
+
154
+ for (let f = 0; f < frameCount; f++) {
155
+ const t = Math.min(f * dt, clip.duration)
156
+ action.time = t
157
+ mixer.update(0) // 0-dt update after directly setting action.time -- applies the pose for this exact sample time without accumulating drift
158
+ // mixer.update only writes the new LOCAL bone quaternion/position; matrixWorld (what boneTransformInto
159
+ // actually reads) is stale until the hierarchy is re-propagated. skeleton.update() alone is NOT enough
160
+ // -- it recomputes boneMatrices FROM bone.matrixWorld, so a missing updateMatrixWorld here silently
161
+ // bakes every frame at the bind pose (found live: frame-0 delta was a large CONSTANT offset instead of
162
+ // ~0, traced to exactly this missing call).
163
+ mixerRoot.updateMatrixWorld(true)
164
+ skinnedMesh.skeleton.update()
165
+ for (let vi = 0; vi < vertexCount; vi++) {
166
+ boneTransformInto(skinnedMesh, vi, _vtmp)
167
+ _bindPos.fromBufferAttribute(posAttr, vi)
168
+ const row = f * rowsPerFrame + Math.floor(vi / width)
169
+ const col = vi % width
170
+ const idx = (row * width + col) * 4
171
+ data[idx] = _vtmp.x - _bindPos.x
172
+ data[idx + 1] = _vtmp.y - _bindPos.y
173
+ data[idx + 2] = _vtmp.z - _bindPos.z
174
+ data[idx + 3] = 1
175
+ if (hasNormals) {
176
+ boneTransformNormalInto(skinnedMesh, vi, _skinnedNrm)
177
+ _bindNrm.fromBufferAttribute(normalAttr, vi)
178
+ normalData[idx] = _skinnedNrm.x - _bindNrm.x
179
+ normalData[idx + 1] = _skinnedNrm.y - _bindNrm.y
180
+ normalData[idx + 2] = _skinnedNrm.z - _bindNrm.z
181
+ normalData[idx + 3] = 1
182
+ }
183
+ }
184
+ }
185
+
186
+ mixer.stopAllAction()
187
+ mixer.uncacheAction(clip, mixerRoot)
188
+
189
+ const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.FloatType)
190
+ texture.needsUpdate = true
191
+ texture.minFilter = THREE.NearestFilter
192
+ texture.magFilter = THREE.NearestFilter
193
+ texture.wrapS = THREE.ClampToEdgeWrapping
194
+ texture.wrapT = THREE.ClampToEdgeWrapping
195
+ texture.generateMipmaps = false
196
+
197
+ let normalTexture = null
198
+ if (hasNormals) {
199
+ normalTexture = new THREE.DataTexture(normalData, width, height, THREE.RGBAFormat, THREE.FloatType)
200
+ normalTexture.needsUpdate = true
201
+ normalTexture.minFilter = THREE.NearestFilter
202
+ normalTexture.magFilter = THREE.NearestFilter
203
+ normalTexture.wrapS = THREE.ClampToEdgeWrapping
204
+ normalTexture.wrapT = THREE.ClampToEdgeWrapping
205
+ normalTexture.generateMipmaps = false
206
+ }
207
+
208
+ return { texture, normalTexture, frameCount, vertexCount, width, rowsPerFrame, duration: clip.duration, sampleHz }
209
+ }
210
+
211
+ /**
212
+ * Bakes MULTIPLE clips against the same skinnedMesh/mixerRoot into independent VAT textures sharing one
213
+ * vertex-index layout (same skinnedMesh.geometry -> same vatVertexIndex attribute works for all of them).
214
+ * `clipsByName` is a Map/plain-object of name -> THREE.AnimationClip; `names` picks which entries to bake
215
+ * and in what order (defaults to every key). Returns { idle, move, names, ... } -- `idle` and `move` are
216
+ * the first two baked vatData results (the only two createVATCrowdRenderer's blend path consumes today),
217
+ * plus `byName` for direct lookup if more than 2 are ever baked.
218
+ */
219
+ export function bakeVATMultiClip(skinnedMesh, mixerRoot, clipsByName, opts = {}) {
220
+ const entries = clipsByName instanceof Map ? Array.from(clipsByName.entries()) : Object.entries(clipsByName)
221
+ const names = opts.names || entries.map(([n]) => n)
222
+ const byName = {}
223
+ for (const name of names) {
224
+ const clip = clipsByName instanceof Map ? clipsByName.get(name) : clipsByName[name]
225
+ if (!clip) continue
226
+ byName[name] = bakeVAT(skinnedMesh, mixerRoot, clip, opts)
227
+ }
228
+ const baked = names.map(n => byName[n]).filter(Boolean)
229
+ return { idle: baked[0] || null, move: baked[1] || baked[0] || null, names, byName }
230
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.650",
3
+ "version": "0.1.651",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [