spoint 0.1.650 → 0.1.652

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.652",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -0,0 +1,281 @@
1
+ // Byte-budgeted LRU caching + gzip/brotli compression infrastructure for StaticHandler.js's static
2
+ // file server: raw file bytes, compressed variants, and transformed (GLB/VRM-optimized) variants.
3
+ // No HTTP request/response handling here -- pure caching/compression, split out for a smaller,
4
+ // single-responsibility file.
5
+
6
+ import { readFileSync, existsSync, statSync, writeFileSync, readdirSync } from 'node:fs'
7
+ import { join, extname, sep } from 'node:path'
8
+ import { gzipSync, brotliCompressSync, gzip, brotliCompress, constants as zlibConstants } from 'node:zlib'
9
+ import { promisify } from 'node:util'
10
+
11
+ // quality 5: q11 default is 100x+ slower for marginal gain; q5 still beats gzip -6 by ~14% (measured on anim-lib.glb)
12
+ const BROTLI_OPTS = { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } }
13
+
14
+ const gzipAsync = promisify(gzip)
15
+ const brotliCompressAsync = promisify(brotliCompress)
16
+
17
+ // Below this size, the sync zlib call costs sub-millisecond -- not worth the promise/microtask
18
+ // overhead, and small-file callers (e.g. tests driving the handler with a bare mock `res` and
19
+ // reading `res` synchronously right after the call returns) rely on the response being written
20
+ // before the call returns. Above it (large JS bundles, GLB/VRM/wasm) sync compression can run
21
+ // long enough to visibly stall the 128Hz tick sharing this event loop, so it goes through the
22
+ // async zlib API instead.
23
+ const ASYNC_COMPRESS_THRESHOLD = 50 * 1024
24
+
25
+ export function compress(raw, encoding) {
26
+ return encoding === 'br' ? brotliCompressSync(raw, BROTLI_OPTS) : gzipSync(raw)
27
+ }
28
+
29
+ export async function compressAsync(raw, encoding) {
30
+ if (raw.length < ASYNC_COMPRESS_THRESHOLD) return compress(raw, encoding)
31
+ return encoding === 'br' ? brotliCompressAsync(raw, BROTLI_OPTS) : gzipAsync(raw)
32
+ }
33
+
34
+ // excludes already-compressed/high-entropy image formats; GLB/VRM/glTF still win since they carry uncompressed JSON+animation data
35
+ export const GZIP_EXTENSIONS = new Set(['.glb', '.vrm', '.gltf', '.js', '.mjs', '.css', '.html', '.json'])
36
+
37
+ // Raw bytes for anything bigger than this never enter the in-memory cache -- a single huge asset
38
+ // (large baked GLB, video, etc) would otherwise dominate the byte budget and evict everything else
39
+ // for one requester's benefit. Still served fine, just re-read from disk (OS page cache absorbs the
40
+ // repeat cost) instead of being pinned in process memory.
41
+ export const MAX_CACHEABLE_BYTES = 20 * 1024 * 1024
42
+
43
+ // Total byte budget across both LRU caches combined (raw file bytes + compressed variants +
44
+ // transformed/optimized GLB variants). Split proportionally isn't necessary -- one shared budget,
45
+ // evicted oldest-first, keeps the accounting simple and self-balancing between the two caches.
46
+ const CACHE_BYTE_BUDGET = 256 * 1024 * 1024
47
+
48
+ // Minimal Map-based LRU: `Map` iterates insertion order, so a re-set on touch (delete+set) moves an
49
+ // entry to the "most recently used" end for free, and eviction just shifts from the front.
50
+ export class ByteBudgetLRU {
51
+ constructor(budget) {
52
+ this.budget = budget
53
+ this.bytes = 0
54
+ this.map = new Map()
55
+ }
56
+ _sizeOf(entry) {
57
+ // entry.raw for fileCache rows, entry.variants Map values, entry.content for pass-through rows
58
+ let n = entry.raw ? entry.raw.length : 0
59
+ if (entry.variants) for (const v of entry.variants.values()) n += v.length
60
+ if (entry.content) n += entry.content.length
61
+ return n
62
+ }
63
+ get(key) {
64
+ const entry = this.map.get(key)
65
+ if (!entry) return undefined
66
+ // touch: move to MRU position
67
+ this.map.delete(key)
68
+ this.map.set(key, entry)
69
+ return entry
70
+ }
71
+ set(key, entry) {
72
+ const prior = this.map.get(key)
73
+ if (prior) this.bytes -= this._sizeOf(prior)
74
+ this.map.delete(key)
75
+ this.map.set(key, entry)
76
+ this.bytes += this._sizeOf(entry)
77
+ this._evictOverBudget()
78
+ }
79
+ // call after mutating an entry already in the map in-place (e.g. adding a new compressed variant)
80
+ // so the tracked byte total stays accurate without a full re-set/re-promote.
81
+ resync(key) {
82
+ if (!this.map.has(key)) return
83
+ let total = 0
84
+ for (const entry of this.map.values()) total += this._sizeOf(entry)
85
+ this.bytes = total
86
+ this._evictOverBudget()
87
+ }
88
+ delete(key) {
89
+ const entry = this.map.get(key)
90
+ if (entry) this.bytes -= this._sizeOf(entry)
91
+ this.map.delete(key)
92
+ }
93
+ _evictOverBudget() {
94
+ while (this.bytes > this.budget && this.map.size > 0) {
95
+ const oldestKey = this.map.keys().next().value
96
+ this.delete(oldestKey)
97
+ }
98
+ }
99
+ }
100
+
101
+ export const fileCache = new ByteBudgetLRU(CACHE_BYTE_BUDGET)
102
+ export const transformedCache = new ByteBudgetLRU(CACHE_BYTE_BUDGET)
103
+
104
+ // Content-hash ETag for /node_modules: third-party deps are re-materialized byte-identical on every
105
+ // redeploy (fresh `npm install`/checkout gives every file a NEW mtime even when its bytes didn't
106
+ // change), so an mtime-based ETag (the general path below) forces a needless revalidation round-trip
107
+ // on every redeploy. Hashing raw content instead means an unchanged file keeps the SAME ETag across
108
+ // redeploys, so a client's cached copy still 304s. Same fnv1a-1a used by SnapshotEncoder.js for
109
+ // dirty-detection -- non-cryptographic, fast, adequate for a weak validator (ETag is not a security
110
+ // boundary). Cached per (path, mtime) so a warm process only hashes each file once; a real content
111
+ // edit still gets a fresh mtime and recomputes.
112
+ const _contentHashCache = new Map() // fp -> { mtime, hash }
113
+ export function contentHashETag(fp, raw, mtime) {
114
+ const cached = _contentHashCache.get(fp)
115
+ if (cached && cached.mtime === mtime) return cached.hash
116
+ let hash = 2166136261
117
+ for (let i = 0; i < raw.length; i++) { hash ^= raw[i]; hash = Math.imul(hash, 16777619) }
118
+ const hex = (hash >>> 0).toString(16)
119
+ _contentHashCache.set(fp, { mtime, hash: hex })
120
+ return hex
121
+ }
122
+ export function isNodeModulesPath(fp) {
123
+ return fp.includes(sep + 'node_modules' + sep) || fp.endsWith(sep + 'node_modules')
124
+ }
125
+
126
+ const SIBLING_EXT = { br: '.br', gzip: '.gz' }
127
+
128
+ // Disk-persisted sibling (<file>.br / <file>.gz next to the source) so a compressed variant
129
+ // survives a process restart/redeploy instead of being recomputed from scratch every boot --
130
+ // this is the actual "precompress at bake time" behavior; the in-memory Map above is still the
131
+ // hot per-process cache layered on top so a warm process never touches disk twice for the same
132
+ // (file, encoding) pair. A stale sibling (source mtime moved on) is detected via a ".meta" JSON
133
+ // stamp recording the source mtime it was built from, same pattern as GLBTransformer's cache.
134
+ function siblingPaths(fp, encoding) {
135
+ const ext = SIBLING_EXT[encoding]
136
+ return { body: fp + ext, meta: fp + ext + '.meta' }
137
+ }
138
+
139
+ function readSiblingIfFresh(fp, encoding, srcMtime) {
140
+ const { body, meta } = siblingPaths(fp, encoding)
141
+ if (!existsSync(body) || !existsSync(meta)) return null
142
+ try {
143
+ const m = JSON.parse(readFileSync(meta, 'utf8'))
144
+ if (m.srcMtime !== srcMtime) return null
145
+ return readFileSync(body)
146
+ } catch { return null }
147
+ }
148
+
149
+ function writeSibling(fp, encoding, srcMtime, content) {
150
+ const { body, meta } = siblingPaths(fp, encoding)
151
+ try {
152
+ writeFileSync(body, content)
153
+ writeFileSync(meta, JSON.stringify({ srcMtime }))
154
+ } catch { /* read-only fs (e.g. some CDN/edge mounts) -- in-memory cache above still serves fine */ }
155
+ }
156
+
157
+ // lazily-populated compressed variants keyed by encoding, so each of a br- and non-br-capable client pays the compression cost once
158
+ export async function getCached(fp, ext, encoding) {
159
+ const key = fp
160
+ const mtime = statSync(fp).mtimeMs
161
+ let cached = fileCache.get(key)
162
+ const size = cached?.raw ? cached.raw.length : statSync(fp).size
163
+ const cacheable = size <= MAX_CACHEABLE_BYTES
164
+ if (!cached || cached.mtime !== mtime) {
165
+ const raw = readFileSync(fp)
166
+ cached = { mtime, raw, variants: new Map() }
167
+ if (raw.length <= MAX_CACHEABLE_BYTES) fileCache.set(key, cached)
168
+ else fileCache.delete(key)
169
+ }
170
+ const shouldCompress = encoding && GZIP_EXTENSIONS.has(ext) && cached.raw.length > 100
171
+ if (!shouldCompress) return { mtime: cached.mtime, content: cached.raw, encoding: null, raw: cached.raw }
172
+ let variant = cached.variants.get(encoding)
173
+ if (!variant) {
174
+ variant = readSiblingIfFresh(fp, encoding, cached.mtime)
175
+ if (!variant) {
176
+ variant = await compressAsync(cached.raw, encoding)
177
+ writeSibling(fp, encoding, cached.mtime, variant)
178
+ }
179
+ cached.variants.set(encoding, variant)
180
+ if (cacheable) fileCache.resync(key)
181
+ }
182
+ return { mtime: cached.mtime, content: variant, encoding, raw: cached.raw }
183
+ }
184
+
185
+ export async function getTransformedCached(fp, srcMtime, rawBuffer, encoding) {
186
+ let cached = transformedCache.get(fp)
187
+ if (!cached || cached.srcMtime !== srcMtime) {
188
+ cached = { srcMtime, variants: new Map(), raw: rawBuffer.length <= MAX_CACHEABLE_BYTES ? rawBuffer : null }
189
+ if (rawBuffer.length <= MAX_CACHEABLE_BYTES) transformedCache.set(fp, cached)
190
+ else transformedCache.delete(fp)
191
+ }
192
+ if (!encoding) return { srcMtime, content: rawBuffer, encoding: null }
193
+ let variant = cached.variants.get(encoding)
194
+ if (!variant) {
195
+ variant = await compressAsync(rawBuffer, encoding)
196
+ cached.variants.set(encoding, variant)
197
+ if (rawBuffer.length <= MAX_CACHEABLE_BYTES) transformedCache.resync(fp)
198
+ }
199
+ return { srcMtime, content: variant, encoding }
200
+ }
201
+
202
+ // Bake-time precompression: walk each mounted static dir and populate the .br/.gz disk siblings
203
+ // for every GZIP_EXTENSIONS file up front, so the very first request for any given asset already
204
+ // hits a warm sibling instead of paying brotli-q5 compression inline. Safe to call repeatedly
205
+ // (mtime-gated, same as the lazy path) -- intended to run once at server boot, backgrounded.
206
+ // A node_modules-rooted mount (third-party deps, can be 10⁴-10⁵ files) is deliberately excluded --
207
+ // walking + brotli-compressing the whole dependency tree at boot is unbounded work for code this
208
+ // app doesn't own; those files still compress fine on the lazy per-request path (getCached), just
209
+ // without the boot-time head start. Same for any nested node_modules encountered mid-walk.
210
+ const PREWARM_SKIP_DIRS = new Set(['node_modules', '.glb-cache', '.progressive-cache', '.git'])
211
+
212
+ export async function prewarmCompression(dirs) {
213
+ let count = 0
214
+ async function walk(dir) {
215
+ let entries
216
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
217
+ for (const e of entries) {
218
+ if (e.isDirectory() && PREWARM_SKIP_DIRS.has(e.name)) continue
219
+ const fp = join(dir, e.name)
220
+ if (e.isDirectory()) { await walk(fp); continue }
221
+ const ext = extname(e.name)
222
+ if (!GZIP_EXTENSIONS.has(ext)) continue
223
+ if (ext === '.br' || ext === '.gz') continue
224
+ try {
225
+ if (statSync(fp).size <= 100) continue
226
+ await getCached(fp, ext, 'br')
227
+ await getCached(fp, ext, 'gzip')
228
+ count++
229
+ } catch { /* unreadable file -- skip, request-time path still covers it */ }
230
+ }
231
+ }
232
+ for (const { dir, prefix } of dirs) {
233
+ if (prefix === '/node_modules/' || dir.endsWith(sep + 'node_modules') || dir.endsWith('/node_modules')) continue
234
+ await walk(dir)
235
+ }
236
+ return count
237
+ }
238
+
239
+ // Parses a single-range `Range: bytes=start-end` header (the only form browsers/download managers
240
+ // send for a resumed GLB/wasm fetch; multi-range is not worth supporting here). Returns null for
241
+ // anything absent/malformed/unsatisfiable so the caller falls back to a plain 200.
242
+ export function parseRange(rangeHeader, totalSize) {
243
+ if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null
244
+ const spec = rangeHeader.slice(6).split(',')[0].trim()
245
+ const m = /^(\d*)-(\d*)$/.exec(spec)
246
+ if (!m) return null
247
+ let start, end
248
+ if (m[1] === '' && m[2] === '') return null
249
+ if (m[1] === '') {
250
+ // suffix range: last N bytes
251
+ const suffixLen = parseInt(m[2], 10)
252
+ if (!Number.isFinite(suffixLen) || suffixLen <= 0) return null
253
+ start = Math.max(0, totalSize - suffixLen)
254
+ end = totalSize - 1
255
+ } else {
256
+ start = parseInt(m[1], 10)
257
+ end = m[2] === '' ? totalSize - 1 : parseInt(m[2], 10)
258
+ }
259
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start < 0 || start >= totalSize) return null
260
+ end = Math.min(end, totalSize - 1)
261
+ return { start, end }
262
+ }
263
+
264
+ // Range/206 is only meaningful against the UNCOMPRESSED body -- a byte offset into a brotli/gzip
265
+ // stream is meaningless to the client, so a Range request always gets the identity encoding.
266
+ export function serveRangeable(req, res, buf, headers) {
267
+ headers['Accept-Ranges'] = 'bytes'
268
+ const range = parseRange(req.headers['range'], buf.length)
269
+ if (!range) {
270
+ headers['Content-Length'] = buf.length
271
+ res.writeHead(200, headers)
272
+ res.end(buf)
273
+ return
274
+ }
275
+ const { start, end } = range
276
+ headers['Content-Range'] = `bytes ${start}-${end}/${buf.length}`
277
+ headers['Content-Length'] = end - start + 1
278
+ delete headers['ETag'] // ETag above was computed for the whole-file 200 case; a 206 still names the same resource via Content-Range so omit rather than mismatch
279
+ res.writeHead(206, headers)
280
+ res.end(buf.subarray(start, end + 1))
281
+ }
@@ -1,26 +1,18 @@
1
- import { readFileSync, existsSync, statSync, realpathSync, writeFileSync, readdirSync } from 'node:fs'
1
+ import { existsSync, statSync, realpathSync } from 'node:fs'
2
2
  import { join, extname, resolve, sep } from 'node:path'
3
- import { gzipSync, brotliCompressSync, gzip, brotliCompress, constants as zlibConstants } from 'node:zlib'
4
- import { promisify } from 'node:util'
5
3
  import { getTransformedAsync, getTransformedHashAsync } from '../static/GLBTransformer.js'
6
4
  import { getProgressive, resolveBakedFile } from '../static/ProgressiveBake.js'
7
5
  import { getKtx2Extracted, resolveKtx2File } from '../static/KTX2Extract.js'
8
6
  import { buildFetchManifest } from '../static/FetchManifest.js'
9
7
  import { getServerIdentity } from '../sdk/ServerIdentity.js'
8
+ import {
9
+ GZIP_EXTENSIONS, contentHashETag, isNodeModulesPath, getCached, getTransformedCached,
10
+ prewarmCompression, serveRangeable
11
+ } from './StaticCache.js'
10
12
 
11
- // quality 5: q11 default is 100x+ slower for marginal gain; q5 still beats gzip -6 by ~14% (measured on anim-lib.glb)
12
- const BROTLI_OPTS = { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } }
13
-
14
- const gzipAsync = promisify(gzip)
15
- const brotliCompressAsync = promisify(brotliCompress)
16
-
17
- // Below this size, the sync zlib call costs sub-millisecond -- not worth the promise/microtask
18
- // overhead, and small-file callers (e.g. tests driving the handler with a bare mock `res` and
19
- // reading `res` synchronously right after the call returns) rely on the response being written
20
- // before the call returns. Above it (large JS bundles, GLB/VRM/wasm) sync compression can run
21
- // long enough to visibly stall the 128Hz tick sharing this event loop, so it goes through the
22
- // async zlib API instead.
23
- const ASYNC_COMPRESS_THRESHOLD = 50 * 1024
13
+ // Re-exported from StaticCache.js for backward compatibility -- server.js/ServerBoot.js imports
14
+ // prewarmCompression from this file's own path.
15
+ export { prewarmCompression }
24
16
 
25
17
  function negotiateEncoding(req) {
26
18
  const ae = req.headers['accept-encoding'] || ''
@@ -29,15 +21,6 @@ function negotiateEncoding(req) {
29
21
  return null
30
22
  }
31
23
 
32
- function compress(raw, encoding) {
33
- return encoding === 'br' ? brotliCompressSync(raw, BROTLI_OPTS) : gzipSync(raw)
34
- }
35
-
36
- async function compressAsync(raw, encoding) {
37
- if (raw.length < ASYNC_COMPRESS_THRESHOLD) return compress(raw, encoding)
38
- return encoding === 'br' ? brotliCompressAsync(raw, BROTLI_OPTS) : gzipAsync(raw)
39
- }
40
-
41
24
  const MIME_TYPES = {
42
25
  '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css',
43
26
  '.json': 'application/json', '.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json', '.vrm': 'model/gltf-binary',
@@ -57,9 +40,6 @@ const MIME_TYPES = {
57
40
  // of GLBTransformer's separate transformed-bytes hash cache.
58
41
  const CONTENT_HASHED_EXTENSIONS = new Set(['.hf'])
59
42
 
60
- // excludes already-compressed/high-entropy image formats; GLB/VRM/glTF still win since they carry uncompressed JSON+animation data
61
- const GZIP_EXTENSIONS = new Set(['.glb', '.vrm', '.gltf', '.js', '.mjs', '.css', '.html', '.json'])
62
-
63
43
  // Extensions worth serving Range/206 for -- large downloadable binaries where a dropped connection
64
44
  // resuming from a byte offset beats re-downloading from zero. Text/JS assets are small and revalidated
65
45
  // per-request anyway, so Range support for them buys nothing and adds surface area. .ktx2 added for the
@@ -79,252 +59,6 @@ const RANGE_EXTENSIONS = new Set(['.glb', '.vrm', '.gltf', '.wasm', '.ktx2'])
79
59
  // would itself become a real bytes-on-the-wire cost paid before any hint work even starts.
80
60
  const EARLY_HINTS_MAX = 12
81
61
 
82
- // Raw bytes for anything bigger than this never enter the in-memory cache -- a single huge asset
83
- // (large baked GLB, video, etc) would otherwise dominate the byte budget and evict everything else
84
- // for one requester's benefit. Still served fine, just re-read from disk (OS page cache absorbs the
85
- // repeat cost) instead of being pinned in process memory.
86
- const MAX_CACHEABLE_BYTES = 20 * 1024 * 1024
87
-
88
- // Total byte budget across both LRU caches combined (raw file bytes + compressed variants +
89
- // transformed/optimized GLB variants). Split proportionally isn't necessary -- one shared budget,
90
- // evicted oldest-first, keeps the accounting simple and self-balancing between the two caches.
91
- const CACHE_BYTE_BUDGET = 256 * 1024 * 1024
92
-
93
- // Minimal Map-based LRU: `Map` iterates insertion order, so a re-set on touch (delete+set) moves an
94
- // entry to the "most recently used" end for free, and eviction just shifts from the front.
95
- class ByteBudgetLRU {
96
- constructor(budget) {
97
- this.budget = budget
98
- this.bytes = 0
99
- this.map = new Map()
100
- }
101
- _sizeOf(entry) {
102
- // entry.raw for fileCache rows, entry.variants Map values, entry.content for pass-through rows
103
- let n = entry.raw ? entry.raw.length : 0
104
- if (entry.variants) for (const v of entry.variants.values()) n += v.length
105
- if (entry.content) n += entry.content.length
106
- return n
107
- }
108
- get(key) {
109
- const entry = this.map.get(key)
110
- if (!entry) return undefined
111
- // touch: move to MRU position
112
- this.map.delete(key)
113
- this.map.set(key, entry)
114
- return entry
115
- }
116
- set(key, entry) {
117
- const prior = this.map.get(key)
118
- if (prior) this.bytes -= this._sizeOf(prior)
119
- this.map.delete(key)
120
- this.map.set(key, entry)
121
- this.bytes += this._sizeOf(entry)
122
- this._evictOverBudget()
123
- }
124
- // call after mutating an entry already in the map in-place (e.g. adding a new compressed variant)
125
- // so the tracked byte total stays accurate without a full re-set/re-promote.
126
- resync(key) {
127
- if (!this.map.has(key)) return
128
- let total = 0
129
- for (const entry of this.map.values()) total += this._sizeOf(entry)
130
- this.bytes = total
131
- this._evictOverBudget()
132
- }
133
- delete(key) {
134
- const entry = this.map.get(key)
135
- if (entry) this.bytes -= this._sizeOf(entry)
136
- this.map.delete(key)
137
- }
138
- _evictOverBudget() {
139
- while (this.bytes > this.budget && this.map.size > 0) {
140
- const oldestKey = this.map.keys().next().value
141
- this.delete(oldestKey)
142
- }
143
- }
144
- }
145
-
146
- const fileCache = new ByteBudgetLRU(CACHE_BYTE_BUDGET)
147
- const transformedCache = new ByteBudgetLRU(CACHE_BYTE_BUDGET)
148
-
149
- // Content-hash ETag for /node_modules: third-party deps are re-materialized byte-identical on every
150
- // redeploy (fresh `npm install`/checkout gives every file a NEW mtime even when its bytes didn't
151
- // change), so an mtime-based ETag (the general path below) forces a needless revalidation round-trip
152
- // on every redeploy. Hashing raw content instead means an unchanged file keeps the SAME ETag across
153
- // redeploys, so a client's cached copy still 304s. Same fnv1a-1a used by SnapshotEncoder.js for
154
- // dirty-detection -- non-cryptographic, fast, adequate for a weak validator (ETag is not a security
155
- // boundary). Cached per (path, mtime) so a warm process only hashes each file once; a real content
156
- // edit still gets a fresh mtime and recomputes.
157
- const _contentHashCache = new Map() // fp -> { mtime, hash }
158
- function contentHashETag(fp, raw, mtime) {
159
- const cached = _contentHashCache.get(fp)
160
- if (cached && cached.mtime === mtime) return cached.hash
161
- let hash = 2166136261
162
- for (let i = 0; i < raw.length; i++) { hash ^= raw[i]; hash = Math.imul(hash, 16777619) }
163
- const hex = (hash >>> 0).toString(16)
164
- _contentHashCache.set(fp, { mtime, hash: hex })
165
- return hex
166
- }
167
- function isNodeModulesPath(fp) {
168
- return fp.includes(sep + 'node_modules' + sep) || fp.endsWith(sep + 'node_modules')
169
- }
170
-
171
- const SIBLING_EXT = { br: '.br', gzip: '.gz' }
172
-
173
- // Disk-persisted sibling (<file>.br / <file>.gz next to the source) so a compressed variant
174
- // survives a process restart/redeploy instead of being recomputed from scratch every boot --
175
- // this is the actual "precompress at bake time" behavior; the in-memory Map above is still the
176
- // hot per-process cache layered on top so a warm process never touches disk twice for the same
177
- // (file, encoding) pair. A stale sibling (source mtime moved on) is detected via a ".meta" JSON
178
- // stamp recording the source mtime it was built from, same pattern as GLBTransformer's cache.
179
- function siblingPaths(fp, encoding) {
180
- const ext = SIBLING_EXT[encoding]
181
- return { body: fp + ext, meta: fp + ext + '.meta' }
182
- }
183
-
184
- function readSiblingIfFresh(fp, encoding, srcMtime) {
185
- const { body, meta } = siblingPaths(fp, encoding)
186
- if (!existsSync(body) || !existsSync(meta)) return null
187
- try {
188
- const m = JSON.parse(readFileSync(meta, 'utf8'))
189
- if (m.srcMtime !== srcMtime) return null
190
- return readFileSync(body)
191
- } catch { return null }
192
- }
193
-
194
- function writeSibling(fp, encoding, srcMtime, content) {
195
- const { body, meta } = siblingPaths(fp, encoding)
196
- try {
197
- writeFileSync(body, content)
198
- writeFileSync(meta, JSON.stringify({ srcMtime }))
199
- } catch { /* read-only fs (e.g. some CDN/edge mounts) -- in-memory cache above still serves fine */ }
200
- }
201
-
202
- // lazily-populated compressed variants keyed by encoding, so each of a br- and non-br-capable client pays the compression cost once
203
- async function getCached(fp, ext, encoding) {
204
- const key = fp
205
- const mtime = statSync(fp).mtimeMs
206
- let cached = fileCache.get(key)
207
- const size = cached?.raw ? cached.raw.length : statSync(fp).size
208
- const cacheable = size <= MAX_CACHEABLE_BYTES
209
- if (!cached || cached.mtime !== mtime) {
210
- const raw = readFileSync(fp)
211
- cached = { mtime, raw, variants: new Map() }
212
- if (raw.length <= MAX_CACHEABLE_BYTES) fileCache.set(key, cached)
213
- else fileCache.delete(key)
214
- }
215
- const shouldCompress = encoding && GZIP_EXTENSIONS.has(ext) && cached.raw.length > 100
216
- if (!shouldCompress) return { mtime: cached.mtime, content: cached.raw, encoding: null, raw: cached.raw }
217
- let variant = cached.variants.get(encoding)
218
- if (!variant) {
219
- variant = readSiblingIfFresh(fp, encoding, cached.mtime)
220
- if (!variant) {
221
- variant = await compressAsync(cached.raw, encoding)
222
- writeSibling(fp, encoding, cached.mtime, variant)
223
- }
224
- cached.variants.set(encoding, variant)
225
- if (cacheable) fileCache.resync(key)
226
- }
227
- return { mtime: cached.mtime, content: variant, encoding, raw: cached.raw }
228
- }
229
-
230
- async function getTransformedCached(fp, srcMtime, rawBuffer, encoding) {
231
- let cached = transformedCache.get(fp)
232
- if (!cached || cached.srcMtime !== srcMtime) {
233
- cached = { srcMtime, variants: new Map(), raw: rawBuffer.length <= MAX_CACHEABLE_BYTES ? rawBuffer : null }
234
- if (rawBuffer.length <= MAX_CACHEABLE_BYTES) transformedCache.set(fp, cached)
235
- else transformedCache.delete(fp)
236
- }
237
- if (!encoding) return { srcMtime, content: rawBuffer, encoding: null }
238
- let variant = cached.variants.get(encoding)
239
- if (!variant) {
240
- variant = await compressAsync(rawBuffer, encoding)
241
- cached.variants.set(encoding, variant)
242
- if (rawBuffer.length <= MAX_CACHEABLE_BYTES) transformedCache.resync(fp)
243
- }
244
- return { srcMtime, content: variant, encoding }
245
- }
246
-
247
- // Bake-time precompression: walk each mounted static dir and populate the .br/.gz disk siblings
248
- // for every GZIP_EXTENSIONS file up front, so the very first request for any given asset already
249
- // hits a warm sibling instead of paying brotli-q5 compression inline. Safe to call repeatedly
250
- // (mtime-gated, same as the lazy path) -- intended to run once at server boot, backgrounded.
251
- // A node_modules-rooted mount (third-party deps, can be 10⁴-10⁵ files) is deliberately excluded --
252
- // walking + brotli-compressing the whole dependency tree at boot is unbounded work for code this
253
- // app doesn't own; those files still compress fine on the lazy per-request path (getCached), just
254
- // without the boot-time head start. Same for any nested node_modules encountered mid-walk.
255
- const PREWARM_SKIP_DIRS = new Set(['node_modules', '.glb-cache', '.progressive-cache', '.git'])
256
-
257
- export async function prewarmCompression(dirs) {
258
- let count = 0
259
- async function walk(dir) {
260
- let entries
261
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
262
- for (const e of entries) {
263
- if (e.isDirectory() && PREWARM_SKIP_DIRS.has(e.name)) continue
264
- const fp = join(dir, e.name)
265
- if (e.isDirectory()) { await walk(fp); continue }
266
- const ext = extname(e.name)
267
- if (!GZIP_EXTENSIONS.has(ext)) continue
268
- if (ext === '.br' || ext === '.gz') continue
269
- try {
270
- if (statSync(fp).size <= 100) continue
271
- await getCached(fp, ext, 'br')
272
- await getCached(fp, ext, 'gzip')
273
- count++
274
- } catch { /* unreadable file -- skip, request-time path still covers it */ }
275
- }
276
- }
277
- for (const { dir, prefix } of dirs) {
278
- if (prefix === '/node_modules/' || dir.endsWith(sep + 'node_modules') || dir.endsWith('/node_modules')) continue
279
- await walk(dir)
280
- }
281
- return count
282
- }
283
-
284
- // Parses a single-range `Range: bytes=start-end` header (the only form browsers/download managers
285
- // send for a resumed GLB/wasm fetch; multi-range is not worth supporting here). Returns null for
286
- // anything absent/malformed/unsatisfiable so the caller falls back to a plain 200.
287
- function parseRange(rangeHeader, totalSize) {
288
- if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null
289
- const spec = rangeHeader.slice(6).split(',')[0].trim()
290
- const m = /^(\d*)-(\d*)$/.exec(spec)
291
- if (!m) return null
292
- let start, end
293
- if (m[1] === '' && m[2] === '') return null
294
- if (m[1] === '') {
295
- // suffix range: last N bytes
296
- const suffixLen = parseInt(m[2], 10)
297
- if (!Number.isFinite(suffixLen) || suffixLen <= 0) return null
298
- start = Math.max(0, totalSize - suffixLen)
299
- end = totalSize - 1
300
- } else {
301
- start = parseInt(m[1], 10)
302
- end = m[2] === '' ? totalSize - 1 : parseInt(m[2], 10)
303
- }
304
- if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start < 0 || start >= totalSize) return null
305
- end = Math.min(end, totalSize - 1)
306
- return { start, end }
307
- }
308
-
309
- // Range/206 is only meaningful against the UNCOMPRESSED body -- a byte offset into a brotli/gzip
310
- // stream is meaningless to the client, so a Range request always gets the identity encoding.
311
- function serveRangeable(req, res, buf, headers) {
312
- headers['Accept-Ranges'] = 'bytes'
313
- const range = parseRange(req.headers['range'], buf.length)
314
- if (!range) {
315
- headers['Content-Length'] = buf.length
316
- res.writeHead(200, headers)
317
- res.end(buf)
318
- return
319
- }
320
- const { start, end } = range
321
- headers['Content-Range'] = `bytes ${start}-${end}/${buf.length}`
322
- headers['Content-Length'] = end - start + 1
323
- delete headers['ETag'] // ETag above was computed for the whole-file 200 case; a 206 still names the same resource via Content-Range so omit rather than mismatch
324
- res.writeHead(206, headers)
325
- res.end(buf.subarray(start, end + 1))
326
- }
327
-
328
62
  // buildEarlyHintsLinks(manifest) -> ARRAY of individual Link header value strings (one per hinted
329
63
  // entry: `<url>; rel=preload; as=X`), or null if there is nothing to hint (no worldDef, empty
330
64
  // manifest). Node's real res.writeEarlyHints({link}) contract requires `link` to be a string OR an