spoint 0.1.649 → 0.1.651
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/core/PlayerVAT.js +4 -232
- package/client/core/PlayerVATBake.js +230 -0
- package/package.json +1 -1
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
|
+
}
|