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.
- package/client/core/PlayerVAT.js +4 -232
- package/client/core/PlayerVATBake.js +230 -0
- package/package.json +1 -1
- package/src/sdk/StaticCache.js +281 -0
- package/src/sdk/StaticHandler.js +8 -274
package/client/core/PlayerVAT.js
CHANGED
|
@@ -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
|
-
//
|
|
54
|
-
//
|
|
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
|
@@ -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
|
+
}
|
package/src/sdk/StaticHandler.js
CHANGED
|
@@ -1,26 +1,18 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
12
|
-
|
|
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
|