@woosh/meep-engine 3.14.3 → 3.14.4
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/package.json +1 -1
- package/src/engine/Engine.d.ts.map +1 -1
- package/src/engine/Engine.js +733 -726
- package/src/engine/network/NetworkSession.d.ts +24 -4
- package/src/engine/network/NetworkSession.d.ts.map +1 -1
- package/src/engine/network/NetworkSession.js +28 -4
- package/src/engine/network/orchestrator/NetworkPeer.d.ts +47 -6
- package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -1
- package/src/engine/network/orchestrator/NetworkPeer.js +184 -56
- package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +97 -11
- package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -1
- package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +304 -22
- package/src/engine/network/replication/Replicator.d.ts +14 -1
- package/src/engine/network/replication/Replicator.d.ts.map +1 -1
- package/src/engine/network/replication/Replicator.js +44 -3
- package/src/engine/network/sim/ActionLog.d.ts +21 -3
- package/src/engine/network/sim/ActionLog.d.ts.map +1 -1
- package/src/engine/network/sim/ActionLog.js +24 -3
- package/src/engine/network/sim/SimAction.d.ts +15 -0
- package/src/engine/network/sim/SimAction.d.ts.map +1 -1
- package/src/engine/network/sim/SimAction.js +15 -0
- package/src/engine/network/sim/SimActionExecutor.d.ts +51 -1
- package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -1
- package/src/engine/network/sim/SimActionExecutor.js +73 -5
- package/src/engine/network/transport/Channel.d.ts +1 -1
- package/src/engine/network/transport/Channel.js +1 -1
- package/src/engine/simulation/Ticker.d.ts.map +1 -1
- package/src/engine/simulation/Ticker.js +4 -1
- package/src/engine/ui/GUIEngine.d.ts.map +1 -1
- package/src/engine/ui/GUIEngine.js +6 -0
- package/src/shade/playground/particle_system/README.md +98 -0
- package/src/shade/playground/particle_system/index.html +124 -0
- package/src/shade/playground/particle_system/main.d.ts +2 -0
- package/src/shade/playground/particle_system/main.d.ts.map +1 -0
- package/src/shade/playground/particle_system/main.js +180 -0
- package/src/shade/playground/particle_system/particle_scene.d.ts +52 -0
- package/src/shade/playground/particle_system/particle_scene.d.ts.map +1 -0
- package/src/shade/playground/particle_system/particle_scene.js +552 -0
- package/src/shade/playground/skinned_mesh_soup/README.md +122 -0
- package/src/shade/playground/skinned_mesh_soup/index.html +199 -0
- package/src/shade/playground/skinned_mesh_soup/main.d.ts +7 -0
- package/src/shade/playground/skinned_mesh_soup/main.d.ts.map +1 -0
- package/src/shade/playground/skinned_mesh_soup/main.js +564 -0
- package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts +23 -0
- package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts.map +1 -0
- package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.js +409 -0
- package/src/shade/renderer/particles/cull/chunk_particle_cull.d.ts.map +1 -1
- package/src/shade/renderer/particles/cull/chunk_particle_cull.js +3 -1
- package/src/shade/renderer/particles/data/chunk_particle_emitter.js +1 -1
- package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts +1 -1
- package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts.map +1 -1
- package/src/shade/renderer/particles/isa/InstructionStream.d.ts +89 -0
- package/src/shade/renderer/particles/isa/InstructionStream.d.ts.map +1 -0
- package/src/shade/renderer/particles/isa/InstructionStream.js +162 -0
- package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts +1 -48
- package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts.map +1 -1
- package/src/shade/renderer/particles/isa/ParticleAssembler.js +25 -114
- package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.d.ts.map +1 -1
- package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.js +4 -2
- package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts +4 -4
- package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts.map +1 -1
- package/src/shade/renderer/particles/shaders/chunk_particle_lighting.js +7 -5
- package/src/shade/renderer/particles/shaders/chunk_particle_render_math.d.ts.map +1 -1
- package/src/shade/renderer/particles/shaders/chunk_particle_render_math.js +3 -1
- package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
- package/src/shade/renderer/particles/shaders/shader_particle_emit.js +3 -1
- package/src/shade/renderer/particles/shaders/shader_particle_finalize.d.ts.map +1 -1
- package/src/shade/renderer/particles/shaders/shader_particle_finalize.js +5 -2
- package/src/shade/renderer/particles/shaders/shader_particle_render.d.ts.map +1 -1
- package/src/shade/renderer/particles/shaders/shader_particle_render.js +9 -2
- package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
- package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +3 -1
- package/src/shade/renderer/particles/prototypeParticleSystem.d.ts +0 -2
- package/src/shade/renderer/particles/prototypeParticleSystem.d.ts.map +0 -1
- package/src/shade/renderer/particles/prototypeParticleSystem.js +0 -448
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { m4_allocate } from "../../../core/geom/3d/mat4/m4_allocate.js";
|
|
2
|
+
import { m4_invert } from "../../../core/geom/3d/mat4/m4_invert.js";
|
|
3
|
+
import { m4_look_at } from "../../../core/geom/3d/mat4/m4_look_at.js";
|
|
4
|
+
import { m4_multiply } from "../../../core/geom/3d/mat4/m4_multiply.js";
|
|
5
|
+
import { ShadeGPUCommandContext } from "../../device/ShadeGPUCommandContext.js";
|
|
6
|
+
import { CAMERA_UNIFORM_STRUCT } from "../../renderer/camera/CAMERA_UNIFORM_STRUCT.js";
|
|
7
|
+
import { PARTICLE_RECORD_WORD_COUNT } from "../../renderer/particles/ParticleConstants.js";
|
|
8
|
+
import { PARTICLE_COUNTER_COUNT } from "../../renderer/particles/data/PARTICLE_COUNTERS.js";
|
|
9
|
+
import { EMITTER_BLEND, EMITTER_PROJECTION } from "../../renderer/particles/data/PARTICLE_EMITTER_LAYOUT.js";
|
|
10
|
+
import { ParticleGraph } from "../../renderer/particles/graph/ParticleGraph.js";
|
|
11
|
+
import { VM_BUILTIN, VM_RANDOM_MODE } from "../../renderer/particles/isa/ParticleVMISA.js";
|
|
12
|
+
import { ParticleLayout } from "../../renderer/particles/layout/ParticleLayout.js";
|
|
13
|
+
import { GPUParticleSystem } from "../../renderer/particles/GPUParticleSystem.js";
|
|
14
|
+
import { create_particle_effect } from "../../renderer/particles/runtime/create_particle_effect.js";
|
|
15
|
+
import { fire_effect } from "../../renderer/particles/particle_prototype.js";
|
|
16
|
+
import {
|
|
17
|
+
PARTICLE_BILLBOARD_RESOURCES,
|
|
18
|
+
create_particle_billboard_pipeline,
|
|
19
|
+
} from "../../renderer/particles/shaders/shader_particle_render.js";
|
|
20
|
+
import { shader_particle_emit } from "../../renderer/particles/shaders/shader_particle_emit.js";
|
|
21
|
+
import {
|
|
22
|
+
shader_particle_build_indirect,
|
|
23
|
+
shader_particle_reset_counters,
|
|
24
|
+
} from "../../renderer/particles/shaders/shader_particle_finalize.js";
|
|
25
|
+
import {
|
|
26
|
+
PARTICLE_SIMULATE_WORKGROUP_SIZE,
|
|
27
|
+
shader_particle_simulate,
|
|
28
|
+
} from "../../renderer/particles/shaders/shader_particle_simulate.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The scene the particle playground draws, with every reference to the page removed: it takes a
|
|
32
|
+
* {@link GraphicsContext} and a colour target, and encodes one frame into a command context.
|
|
33
|
+
*
|
|
34
|
+
* Separate from `main.js` so the whole frame — every pass, binding, buffer usage and immediate
|
|
35
|
+
* block — can be encoded against {@link ../../device/mock/SoftwareGPUDevice.js} in a vitest spec
|
|
36
|
+
* (see `particle_scene.spec.js`). That is the only tier available to CI here; the mock validates
|
|
37
|
+
* the WebGPU API surface but executes no shaders, so what a spec can prove is that the frame is
|
|
38
|
+
* *legal*, not what it looks like. The picture is the browser's job.
|
|
39
|
+
*
|
|
40
|
+
* What the scene contains:
|
|
41
|
+
*
|
|
42
|
+
* - lit solid geometry (a ground plane + boxes) rendered into a reverse-Z depth buffer,
|
|
43
|
+
* - four emitters with distinct settings — an additive fire, an alpha soft-depth smoke column,
|
|
44
|
+
* additive velocity-stretched sparks, and a wide soft-depth dust drift,
|
|
45
|
+
* - a billboard pass that depth-tests against the scene, so particles are occluded by the boxes and
|
|
46
|
+
* soft-depth emitters fade as they approach surfaces.
|
|
47
|
+
*
|
|
48
|
+
* The loop is fully GPU-driven: the simulate dispatch runs via `dispatchWorkgroupsIndirect` and the
|
|
49
|
+
* billboard draw via `drawIndirect`, both from args the build-indirect pass writes on the GPU. No
|
|
50
|
+
* CPU readback participates in sizing anything — a stale CPU count would orphan alive-list entries
|
|
51
|
+
* when the population grows and double-free slots when it shrinks.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** Maximum simultaneous particles across every emitter — they share one pool. */
|
|
55
|
+
const PARTICLE_CAPACITY = 1 << 15; // 32k
|
|
56
|
+
|
|
57
|
+
/** Camera near plane. Also the numerator of the reverse-Z depth linearisation (see {@link write_camera}). */
|
|
58
|
+
const PARTICLE_NEAR = 0.2;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The largest immediate block any of the three compute passes declares (emit's 20-byte settings
|
|
62
|
+
* struct). Immediate data is an optional WebGPU capability, so callers check for it up front rather
|
|
63
|
+
* than letting pipeline creation fail with an error that names neither pass nor feature.
|
|
64
|
+
*/
|
|
65
|
+
export const PARTICLE_IMMEDIATE_BYTES = 20;
|
|
66
|
+
|
|
67
|
+
/** Bytes of stats {@link ParticleScene#encode_stats_copy} writes: 8 counters, then both arg buffers. */
|
|
68
|
+
export const PARTICLE_STATS_BYTES = 64;
|
|
69
|
+
|
|
70
|
+
const STATS_OFFSET_DISPATCH_ARGS = 32;
|
|
71
|
+
const STATS_OFFSET_DRAW_ARGS = 48;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {object} ParticleSceneStats
|
|
75
|
+
* @property {number} alive particles alive at the end of the captured frame
|
|
76
|
+
* @property {number} dead free slots left in the pool
|
|
77
|
+
* @property {number} alive_in the snapshot next frame's simulate reads as its input length
|
|
78
|
+
* @property {number[]} dispatch simulate's indirect dispatch args, `[x, y, z]` workgroups
|
|
79
|
+
* @property {number[]} draw the billboard draw's indirect args, `[vertices, instances]`
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the scene against a live (or mock) device.
|
|
84
|
+
*
|
|
85
|
+
* @param {GraphicsContext} graphics
|
|
86
|
+
* @param {object} options
|
|
87
|
+
* @param {GPUTextureFormat} options.color_format format of the colour target frames are encoded into
|
|
88
|
+
* @param {number} [options.capacity] particle pool size
|
|
89
|
+
* @returns {object} the scene, see the returned object's own members
|
|
90
|
+
*/
|
|
91
|
+
export function create_particle_scene(graphics, { color_format, capacity = PARTICLE_CAPACITY }) {
|
|
92
|
+
const device = graphics.device;
|
|
93
|
+
|
|
94
|
+
// --- emitters: four, each with different settings -----------------------------------------
|
|
95
|
+
const system = new GPUParticleSystem(graphics, { capacity });
|
|
96
|
+
|
|
97
|
+
// fire — additive, billboard, at the base of the tall pillar
|
|
98
|
+
system.addEmitter({
|
|
99
|
+
...fire_effect().emitter,
|
|
100
|
+
spawn_rate: 500, flipbook: [1, 1], atlas_region: [0, 0, 1, 1],
|
|
101
|
+
flags: { blend: EMITTER_BLEND.ADDITIVE, projection: EMITTER_PROJECTION.BILLBOARD },
|
|
102
|
+
transform: translation(-1, 0, 0),
|
|
103
|
+
});
|
|
104
|
+
// smoke — alpha, soft-depth, rising column above the fire
|
|
105
|
+
system.addEmitter({
|
|
106
|
+
effect: make_effect({ velocity: [0, 1.3, 0], velocityJitter: 0.35, posJitter: 0.5, accel: 0.25, life: [3, 5], size: 1.4, color: [0.45, 0.45, 0.48, 0.28] }),
|
|
107
|
+
spawn_rate: 60, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
|
|
108
|
+
flags: { blend: EMITTER_BLEND.ALPHA, projection: EMITTER_PROJECTION.BILLBOARD, soft_depth: true },
|
|
109
|
+
render: { position: "position", size: "size", color: "color" },
|
|
110
|
+
transform: translation(-1, 2.5, 0),
|
|
111
|
+
});
|
|
112
|
+
// sparks — additive, velocity-stretched, fast, short-lived, gravity, near the box
|
|
113
|
+
system.addEmitter({
|
|
114
|
+
effect: make_effect({ velocity: [0, 5, 0], velocityJitter: 4.5, posJitter: 0.15, accel: -14, life: [0.3, 0.8], size: 0.09, color: [1.0, 0.8, 0.35, 1.0] }),
|
|
115
|
+
spawn_rate: 350, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
|
|
116
|
+
flags: { blend: EMITTER_BLEND.ADDITIVE, projection: EMITTER_PROJECTION.STRETCHED },
|
|
117
|
+
render: { position: "position", size: "size", color: "color", velocity: "velocity" },
|
|
118
|
+
transform: translation(2, 1.9, -1),
|
|
119
|
+
});
|
|
120
|
+
// dust — alpha, soft-depth, slow wide drift across the ground
|
|
121
|
+
system.addEmitter({
|
|
122
|
+
effect: make_effect({ velocity: [0.3, 0.15, 0.1], velocityJitter: 0.35, posJitter: 6, accel: 0, life: [4, 7], size: 0.7, color: [0.55, 0.5, 0.42, 0.16] }),
|
|
123
|
+
spawn_rate: 120, atlas_region: [0, 0, 1, 1], flipbook: [1, 1],
|
|
124
|
+
flags: { blend: EMITTER_BLEND.ALPHA, projection: EMITTER_PROJECTION.BILLBOARD, soft_depth: true },
|
|
125
|
+
render: { position: "position", size: "size", color: "color" },
|
|
126
|
+
transform: translation(0, 0.3, 0),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const built = system.buildEmitterData();
|
|
130
|
+
|
|
131
|
+
// --- particle GPU buffers -----------------------------------------------------------------
|
|
132
|
+
const storage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC;
|
|
133
|
+
const pool = device.createBuffer({ label: "particles/pool", size: capacity * PARTICLE_RECORD_WORD_COUNT * 4, usage: storage });
|
|
134
|
+
const alive_a = device.createBuffer({ label: "particles/alive_a", size: capacity * 4, usage: storage });
|
|
135
|
+
const alive_b = device.createBuffer({ label: "particles/alive_b", size: capacity * 4, usage: storage });
|
|
136
|
+
const dead = device.createBuffer({ label: "particles/dead", size: capacity * 4, usage: storage });
|
|
137
|
+
const counters = device.createBuffer({ label: "particles/counters", size: PARTICLE_COUNTER_COUNT * 4, usage: storage });
|
|
138
|
+
const spawn_requests = device.createBuffer({ label: "particles/spawn_requests", size: capacity * 4, usage: storage });
|
|
139
|
+
const emitters = data_buffer(device, "particles/emitters", built.emitters, storage);
|
|
140
|
+
const program = data_buffer(device, "particles/program", built.program, storage);
|
|
141
|
+
const constants = data_buffer(device, "particles/constants", built.constants, storage);
|
|
142
|
+
|
|
143
|
+
// Indirect args, written on-GPU by the build-indirect pass. Zero-initialised (WebGPU spec), so
|
|
144
|
+
// the first frame's indirect dispatch/draw are no-ops until the first snapshot lands. COPY_SRC
|
|
145
|
+
// only so the stats copy can *display* them; nothing read back here sizes a pass.
|
|
146
|
+
const args_usage = GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_SRC;
|
|
147
|
+
const dispatch_args = device.createBuffer({ label: "particles/dispatch_args", size: 3 * 4, usage: args_usage });
|
|
148
|
+
const draw_args = device.createBuffer({ label: "particles/draw_args", size: 4 * 4, usage: args_usage });
|
|
149
|
+
|
|
150
|
+
const initial = system.initialFreeList();
|
|
151
|
+
device.queue.writeBuffer(dead, 0, initial.dead);
|
|
152
|
+
device.queue.writeBuffer(counters, 0, initial.counters);
|
|
153
|
+
|
|
154
|
+
// --- camera + geometry --------------------------------------------------------------------
|
|
155
|
+
const camera_buffer = device.createBuffer({ label: "particles/camera", size: CAMERA_UNIFORM_STRUCT.aligned_size, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
|
156
|
+
const vp_buffer = device.createBuffer({ label: "particles/vp", size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
|
157
|
+
|
|
158
|
+
const geometry = build_geometry();
|
|
159
|
+
const geometry_buffer = device.createBuffer({ label: "particles/geometry", size: geometry.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
|
|
160
|
+
device.queue.writeBuffer(geometry_buffer, 0, geometry);
|
|
161
|
+
const geometry_vertex_count = geometry.length / GEOMETRY_FLOATS_PER_VERTEX;
|
|
162
|
+
const { geometry_pipeline, geometry_bind_group } = make_geometry_pipeline(device, color_format, vp_buffer);
|
|
163
|
+
|
|
164
|
+
// atlas sprite — soft round falloff — and the filtering sampler the billboard shader declares
|
|
165
|
+
const atlas_view = make_sprite_texture(device).createView();
|
|
166
|
+
const atlas_sampler = device.createSampler({ magFilter: "linear", minFilter: "linear", addressModeU: "clamp-to-edge", addressModeV: "clamp-to-edge" });
|
|
167
|
+
|
|
168
|
+
// billboard pipeline: reverse-Z depth test (same compare as the geometry pass), no write;
|
|
169
|
+
// soft-depth reads the scene depth texture
|
|
170
|
+
const billboard_pipeline = create_particle_billboard_pipeline({
|
|
171
|
+
color_format,
|
|
172
|
+
depth_format: DEPTH_FORMAT,
|
|
173
|
+
depth_compare: "greater",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// reverse-Z depth buffer, recreated on resize
|
|
177
|
+
let depth_texture = null;
|
|
178
|
+
let depth_view = null;
|
|
179
|
+
let depth_width = 0;
|
|
180
|
+
let depth_height = 0;
|
|
181
|
+
|
|
182
|
+
function ensure_depth(width, height) {
|
|
183
|
+
if (depth_view !== null && depth_width === width && depth_height === height) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
depth_texture?.destroy();
|
|
188
|
+
depth_texture = device.createTexture({
|
|
189
|
+
label: "particles/scene_depth",
|
|
190
|
+
size: [width, height],
|
|
191
|
+
format: DEPTH_FORMAT,
|
|
192
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
193
|
+
});
|
|
194
|
+
depth_view = depth_texture.createView();
|
|
195
|
+
depth_width = width;
|
|
196
|
+
depth_height = height;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let alive_read = alive_a;
|
|
200
|
+
let alive_write = alive_b;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
system,
|
|
204
|
+
capacity,
|
|
205
|
+
|
|
206
|
+
/** Buffers a harness may inspect. Not for the frame loop, which never reads them back. */
|
|
207
|
+
buffers: { pool, alive_a, alive_b, dead, counters, spawn_requests, emitters, program, constants, dispatch_args, draw_args },
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Encode one whole frame: geometry, then emit + simulate + build-indirect, then the
|
|
211
|
+
* billboard draw. Does not submit — the caller finishes the context.
|
|
212
|
+
*
|
|
213
|
+
* @param {ShadeGPUCommandContext} cmd
|
|
214
|
+
* @param {object} frame
|
|
215
|
+
* @param {GPUTextureView} frame.color_view where the frame is drawn
|
|
216
|
+
* @param {number} frame.width colour target width, in texels
|
|
217
|
+
* @param {number} frame.height colour target height, in texels
|
|
218
|
+
* @param {number} frame.dt seconds since the previous frame
|
|
219
|
+
* @param {number} frame.time seconds since start, for the camera orbit and the VM's clock
|
|
220
|
+
*/
|
|
221
|
+
encode_frame(cmd, { color_view, width, height, dt, time }) {
|
|
222
|
+
ensure_depth(width, height);
|
|
223
|
+
write_camera(device, camera_buffer, vp_buffer, width / height, time);
|
|
224
|
+
|
|
225
|
+
const requests = system.update(dt);
|
|
226
|
+
if (requests.length > 0) {
|
|
227
|
+
device.queue.writeBuffer(spawn_requests, 0, requests);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- geometry pass: clear colour + reverse-Z depth (far = 0), lit, writes depth ---
|
|
231
|
+
const geometry_pass = cmd.beginRenderPass({
|
|
232
|
+
label: "particles/geometry",
|
|
233
|
+
colorAttachments: [{ view: color_view, loadOp: "clear", storeOp: "store", clearValue: { r: 0.03, g: 0.035, b: 0.05, a: 1 } }],
|
|
234
|
+
depthStencilAttachment: { view: depth_view, depthLoadOp: "clear", depthStoreOp: "store", depthClearValue: 0.0 },
|
|
235
|
+
});
|
|
236
|
+
geometry_pass.setPipeline(geometry_pipeline);
|
|
237
|
+
geometry_pass.setBindGroup(0, geometry_bind_group);
|
|
238
|
+
geometry_pass.setVertexBuffer(0, geometry_buffer);
|
|
239
|
+
geometry_pass.draw(geometry_vertex_count);
|
|
240
|
+
geometry_pass.end();
|
|
241
|
+
|
|
242
|
+
// --- emit + simulate (simulate is sized by LAST frame's GPU-written dispatch args) ---
|
|
243
|
+
shader_particle_reset_counters.dispatch({ encoder: cmd, bindings: { counters }, group_count_x: 1 });
|
|
244
|
+
|
|
245
|
+
if (requests.length > 0) {
|
|
246
|
+
shader_particle_emit.dispatchThreads({
|
|
247
|
+
encoder: cmd,
|
|
248
|
+
bindings: {
|
|
249
|
+
emitters, program, vm_constants: constants, pool, spawn_requests,
|
|
250
|
+
alive_out: alive_write, dead, counters,
|
|
251
|
+
settings: { u_dt: dt, u_time: time, u_frame: system.frame, u_count: requests.length, u_capacity: capacity },
|
|
252
|
+
},
|
|
253
|
+
thread_count_x: requests.length,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
shader_particle_simulate.dispatchIndirect({
|
|
258
|
+
encoder: cmd,
|
|
259
|
+
bindings: {
|
|
260
|
+
emitters, program, vm_constants: constants, pool,
|
|
261
|
+
alive_in: alive_read, alive_out: alive_write, dead, counters,
|
|
262
|
+
settings: { u_dt: dt, u_time: time, u_frame: system.frame },
|
|
263
|
+
},
|
|
264
|
+
command: dispatch_args,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Snapshot the final alive count (next frame's simulate input) + write this frame's draw args.
|
|
268
|
+
shader_particle_build_indirect.dispatch({
|
|
269
|
+
encoder: cmd,
|
|
270
|
+
bindings: { counters, dispatch_args, draw_args, u_workgroup_size: PARTICLE_SIMULATE_WORKGROUP_SIZE },
|
|
271
|
+
group_count_x: 1,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// --- billboard pass: load colour, depth-test against the scene (read-only), indirect draw ---
|
|
275
|
+
const bindings = PARTICLE_BILLBOARD_RESOURCES.generateBindings({
|
|
276
|
+
camera: camera_buffer, pool, sorted: alive_write, emitters,
|
|
277
|
+
atlas: atlas_view, atlas_sampler, scene_depth: depth_view,
|
|
278
|
+
}, cmd);
|
|
279
|
+
|
|
280
|
+
const billboard_pass = cmd.constructRenderPass({
|
|
281
|
+
pipeline: billboard_pipeline,
|
|
282
|
+
bindings,
|
|
283
|
+
colorAttachments: [{ view: color_view, loadOp: "load", storeOp: "store" }],
|
|
284
|
+
depthStencilAttachment: { view: depth_view, depthReadOnly: true },
|
|
285
|
+
});
|
|
286
|
+
billboard_pass.drawIndirect(draw_args, 0);
|
|
287
|
+
billboard_pass.end();
|
|
288
|
+
|
|
289
|
+
const previous = alive_read;
|
|
290
|
+
alive_read = alive_write;
|
|
291
|
+
alive_write = previous;
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Copy the counters and both indirect arg buffers into a {@link PARTICLE_STATS_BYTES}-byte
|
|
296
|
+
* MAP_READ destination, for display. Read back to *look at*: nothing here may size a pass.
|
|
297
|
+
*
|
|
298
|
+
* @param {ShadeGPUCommandContext} cmd
|
|
299
|
+
* @param {GPUBuffer} destination
|
|
300
|
+
*/
|
|
301
|
+
encode_stats_copy(cmd, destination) {
|
|
302
|
+
cmd.copyBufferToBuffer(counters, 0, destination, 0, PARTICLE_COUNTER_COUNT * 4);
|
|
303
|
+
cmd.copyBufferToBuffer(dispatch_args, 0, destination, STATS_OFFSET_DISPATCH_ARGS, 3 * 4);
|
|
304
|
+
cmd.copyBufferToBuffer(draw_args, 0, destination, STATS_OFFSET_DRAW_ARGS, 4 * 4);
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Interpret the bytes {@link encode_stats_copy} wrote.
|
|
309
|
+
*
|
|
310
|
+
* @param {ArrayBuffer} bytes
|
|
311
|
+
* @returns {ParticleSceneStats}
|
|
312
|
+
*/
|
|
313
|
+
read_stats(bytes) {
|
|
314
|
+
const words = new Uint32Array(bytes);
|
|
315
|
+
const dispatch_base = STATS_OFFSET_DISPATCH_ARGS / 4;
|
|
316
|
+
const draw_base = STATS_OFFSET_DRAW_ARGS / 4;
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
alive: words[0],
|
|
320
|
+
dead: words[1],
|
|
321
|
+
alive_in: words[4],
|
|
322
|
+
dispatch: [words[dispatch_base], words[dispatch_base + 1], words[dispatch_base + 2]],
|
|
323
|
+
draw: [words[draw_base], words[draw_base + 1]],
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------------------------
|
|
330
|
+
// Effect authoring
|
|
331
|
+
// ---------------------------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* A parametric point-emission effect: emit with a base velocity + jitter from a disk, integrate
|
|
335
|
+
* under a vertical acceleration, and die at end of life. Only nAge.x is consumed (kill), so the
|
|
336
|
+
* NaN lanes from the scalar age/lifetime divide are harmless.
|
|
337
|
+
*/
|
|
338
|
+
function make_effect({ velocity, velocityJitter = 0, posJitter = 0, accel = 0, life = [1, 2], size = 1, color = [1, 1, 1, 1] }) {
|
|
339
|
+
const layout = new ParticleLayout([
|
|
340
|
+
{ name: "position", components: 3 }, { name: "velocity", components: 3 },
|
|
341
|
+
{ name: "age", components: 1 }, { name: "lifetime", components: 1 },
|
|
342
|
+
{ name: "size", components: 1 }, { name: "color", components: 4 },
|
|
343
|
+
]);
|
|
344
|
+
|
|
345
|
+
const spawn = new ParticleGraph();
|
|
346
|
+
let posN = spawn.add("builtin", { id: VM_BUILTIN.EMITTER_POSITION });
|
|
347
|
+
if (posJitter > 0) {
|
|
348
|
+
const d = spawn.add("random", { mode: VM_RANDOM_MODE.UNIT_DISK });
|
|
349
|
+
const j = spawn.add("scale"); spawn.wire(j, "v", d); spawn.wire(j, "s", [posJitter]);
|
|
350
|
+
const p = spawn.add("add"); spawn.wire(p, "a", posN); spawn.wire(p, "b", j); posN = p;
|
|
351
|
+
}
|
|
352
|
+
spawn.wire(spawn.add("setAttribute", { name: "position" }), "value", posN);
|
|
353
|
+
|
|
354
|
+
let velN = spawn.add("const", { value: [velocity[0], velocity[1], velocity[2], 0] });
|
|
355
|
+
if (velocityJitter > 0) {
|
|
356
|
+
const r = spawn.add("random", { mode: VM_RANDOM_MODE.UNIFORM_SIGNED });
|
|
357
|
+
const rs = spawn.add("scale"); spawn.wire(rs, "v", r); spawn.wire(rs, "s", [velocityJitter]);
|
|
358
|
+
const vv = spawn.add("add"); spawn.wire(vv, "a", velN); spawn.wire(vv, "b", rs); velN = vv;
|
|
359
|
+
}
|
|
360
|
+
spawn.wire(spawn.add("setAttribute", { name: "velocity" }), "value", velN);
|
|
361
|
+
spawn.wire(spawn.add("setAttribute", { name: "age" }), "value", [0]);
|
|
362
|
+
const lr = spawn.add("random", { mode: VM_RANDOM_MODE.UNIFORM });
|
|
363
|
+
const lm = spawn.add("mad"); spawn.wire(lm, "a", lr); spawn.wire(lm, "b", [life[1] - life[0]]); spawn.wire(lm, "c", [life[0]]);
|
|
364
|
+
spawn.wire(spawn.add("setAttribute", { name: "lifetime" }), "value", lm);
|
|
365
|
+
spawn.wire(spawn.add("setAttribute", { name: "size" }), "value", [size]);
|
|
366
|
+
spawn.wire(spawn.add("setAttribute", { name: "color" }), "value", color);
|
|
367
|
+
|
|
368
|
+
const update = new ParticleGraph();
|
|
369
|
+
const dt = update.add("builtin", { id: VM_BUILTIN.DELTA_TIME });
|
|
370
|
+
const age = update.add("attribute", { name: "age" });
|
|
371
|
+
const nage = update.add("add"); update.wire(nage, "a", age); update.wire(nage, "b", dt);
|
|
372
|
+
update.wire(update.add("setAttribute", { name: "age" }), "value", nage);
|
|
373
|
+
const lifeA = update.add("attribute", { name: "lifetime" });
|
|
374
|
+
const na = update.add("div"); update.wire(na, "a", nage); update.wire(na, "b", lifeA);
|
|
375
|
+
const dead = update.add("compare", { op: "ge" }); update.wire(dead, "a", na); update.wire(dead, "b", [1]);
|
|
376
|
+
update.wire(update.add("kill"), "condition", dead);
|
|
377
|
+
let velN2 = update.add("attribute", { name: "velocity" });
|
|
378
|
+
if (accel !== 0) {
|
|
379
|
+
const g = update.add("const", { value: [0, accel, 0, 0] });
|
|
380
|
+
const gd = update.add("scale"); update.wire(gd, "v", g); update.wire(gd, "s", dt);
|
|
381
|
+
const nv = update.add("add"); update.wire(nv, "a", velN2); update.wire(nv, "b", gd);
|
|
382
|
+
update.wire(update.add("setAttribute", { name: "velocity" }), "value", nv); velN2 = nv;
|
|
383
|
+
}
|
|
384
|
+
const pos = update.add("attribute", { name: "position" });
|
|
385
|
+
const pd = update.add("scale"); update.wire(pd, "v", velN2); update.wire(pd, "s", dt);
|
|
386
|
+
const np = update.add("add"); update.wire(np, "a", pos); update.wire(np, "b", pd);
|
|
387
|
+
update.wire(update.add("setAttribute", { name: "position" }), "value", np);
|
|
388
|
+
|
|
389
|
+
return create_particle_effect({ layout, spawn, update });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------------------------
|
|
393
|
+
// Geometry + camera + helpers
|
|
394
|
+
// ---------------------------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
const DEPTH_FORMAT = "depth32float";
|
|
397
|
+
|
|
398
|
+
/** Interleaved `[x, y, z, nx, ny, nz]`. */
|
|
399
|
+
const GEOMETRY_FLOATS_PER_VERTEX = 6;
|
|
400
|
+
|
|
401
|
+
const GEOM_WGSL = /* wgsl */ `
|
|
402
|
+
@group(0) @binding(0) var<uniform> vp : mat4x4<f32>;
|
|
403
|
+
struct VOut { @builtin(position) clip : vec4<f32>, @location(0) normal : vec3<f32> }
|
|
404
|
+
@vertex fn vs(@location(0) pos : vec3<f32>, @location(1) normal : vec3<f32>) -> VOut {
|
|
405
|
+
var o : VOut;
|
|
406
|
+
o.clip = vp * vec4<f32>(pos, 1.0);
|
|
407
|
+
o.normal = normal;
|
|
408
|
+
return o;
|
|
409
|
+
}
|
|
410
|
+
@fragment fn fs(in : VOut) -> @location(0) vec4<f32> {
|
|
411
|
+
let N = normalize(in.normal);
|
|
412
|
+
let L = normalize(vec3<f32>(0.35, 1.0, 0.4));
|
|
413
|
+
let diffuse = max(dot(N, L), 0.0);
|
|
414
|
+
let albedo = select(vec3<f32>(0.30, 0.32, 0.36), vec3<f32>(0.22, 0.24, 0.28), N.y < 0.9);
|
|
415
|
+
let color = albedo * (0.18 + diffuse * 0.9);
|
|
416
|
+
return vec4<f32>(color, 1.0);
|
|
417
|
+
}
|
|
418
|
+
`;
|
|
419
|
+
|
|
420
|
+
function make_geometry_pipeline(device, format, vp_buffer) {
|
|
421
|
+
const module = device.createShaderModule({ label: "particles/geometry", code: GEOM_WGSL });
|
|
422
|
+
const layout = device.createBindGroupLayout({
|
|
423
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const geometry_pipeline = device.createRenderPipeline({
|
|
427
|
+
label: "particles/geometry",
|
|
428
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
|
429
|
+
vertex: {
|
|
430
|
+
module,
|
|
431
|
+
entryPoint: "vs",
|
|
432
|
+
buffers: [{
|
|
433
|
+
arrayStride: GEOMETRY_FLOATS_PER_VERTEX * 4,
|
|
434
|
+
attributes: [
|
|
435
|
+
{ shaderLocation: 0, offset: 0, format: "float32x3" },
|
|
436
|
+
{ shaderLocation: 1, offset: 12, format: "float32x3" },
|
|
437
|
+
],
|
|
438
|
+
}],
|
|
439
|
+
},
|
|
440
|
+
fragment: { module, entryPoint: "fs", targets: [{ format }] },
|
|
441
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
442
|
+
depthStencil: { format: DEPTH_FORMAT, depthWriteEnabled: true, depthCompare: "greater" }, // reverse-Z
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const geometry_bind_group = device.createBindGroup({ layout, entries: [{ binding: 0, resource: { buffer: vp_buffer } }] });
|
|
446
|
+
|
|
447
|
+
return { geometry_pipeline, geometry_bind_group };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Ground plane + a few boxes as an interleaved `[x,y,z, nx,ny,nz]` vertex array. */
|
|
451
|
+
function build_geometry() {
|
|
452
|
+
const v = [];
|
|
453
|
+
const quad = (a, b, c, d, n) => { for (const p of [a, b, c, a, c, d]) v.push(p[0], p[1], p[2], n[0], n[1], n[2]); };
|
|
454
|
+
const box = (cx, cy, cz, sx, sy, sz) => {
|
|
455
|
+
const x0 = cx - sx / 2, x1 = cx + sx / 2, y0 = cy - sy / 2, y1 = cy + sy / 2, z0 = cz - sz / 2, z1 = cz + sz / 2;
|
|
456
|
+
quad([x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [0, 0, 1]);
|
|
457
|
+
quad([x1, y0, z0], [x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [0, 0, -1]);
|
|
458
|
+
quad([x1, y0, z1], [x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [1, 0, 0]);
|
|
459
|
+
quad([x0, y0, z0], [x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [-1, 0, 0]);
|
|
460
|
+
quad([x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0], [0, 1, 0]);
|
|
461
|
+
quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [0, -1, 0]);
|
|
462
|
+
};
|
|
463
|
+
quad([-15, 0, -15], [15, 0, -15], [15, 0, 15], [-15, 0, 15], [0, 1, 0]); // ground
|
|
464
|
+
box(-1, 1.5, 0, 1, 3, 1); // tall pillar next to the fire
|
|
465
|
+
box(2, 0.9, -1, 1.6, 1.8, 1.6); // block near the sparks
|
|
466
|
+
box(0, 0.6, 3.5, 7, 1.2, 0.6); // low wall the dust drifts against
|
|
467
|
+
return new Float32Array(v);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Scratch for write_camera. One scene drives one camera, and the loop runs every frame, so these
|
|
471
|
+
// are reused rather than reallocated 60 times a second.
|
|
472
|
+
const camera_view = m4_allocate();
|
|
473
|
+
const camera_world = m4_allocate();
|
|
474
|
+
const camera_projection = m4_allocate();
|
|
475
|
+
const camera_view_projection = m4_allocate();
|
|
476
|
+
const camera_bytes = new Float32Array(CAMERA_UNIFORM_STRUCT.aligned_size / 4);
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Reverse-Z, infinite-far perspective (WebGPU 0..1 depth: near -> 1, far -> 0).
|
|
480
|
+
*
|
|
481
|
+
* @param {Float32Array} out column-major 4x4
|
|
482
|
+
* @param {number} fov_y vertical field of view, radians
|
|
483
|
+
* @param {number} aspect width / height
|
|
484
|
+
* @param {number} near near plane distance
|
|
485
|
+
* @returns {Float32Array} `out`
|
|
486
|
+
*/
|
|
487
|
+
function perspective_reverse_z(out, fov_y, aspect, near) {
|
|
488
|
+
const f = 1 / Math.tan(fov_y / 2);
|
|
489
|
+
|
|
490
|
+
out.fill(0);
|
|
491
|
+
out[0] = f / aspect;
|
|
492
|
+
out[5] = f;
|
|
493
|
+
out[11] = -1;
|
|
494
|
+
out[14] = near;
|
|
495
|
+
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Orbit the camera and upload both camera buffers: the engine's full camera struct for the
|
|
501
|
+
* billboard shader, and a bare view-projection for the geometry pass.
|
|
502
|
+
*/
|
|
503
|
+
function write_camera(device, camera_buffer, vp_buffer, aspect, t) {
|
|
504
|
+
const radius = 12, height = 5.5;
|
|
505
|
+
const eye = [Math.cos(t * 0.22) * radius, height, Math.sin(t * 0.22) * radius];
|
|
506
|
+
const center = [0, 1.6, 0];
|
|
507
|
+
|
|
508
|
+
const view = m4_look_at(camera_view, eye, center, [0, 1, 0]);
|
|
509
|
+
// A look-at basis is orthonormal plus a translation, so this never reports singular.
|
|
510
|
+
m4_invert(camera_world, view);
|
|
511
|
+
const world = camera_world;
|
|
512
|
+
const projection = perspective_reverse_z(camera_projection, (58 * Math.PI) / 180, aspect, PARTICLE_NEAR);
|
|
513
|
+
const view_projection = m4_multiply(camera_view_projection, projection, view);
|
|
514
|
+
|
|
515
|
+
const put = (field, m) => camera_bytes.set(m, CAMERA_UNIFORM_STRUCT.get(field).offset / 4);
|
|
516
|
+
put("transform", world);
|
|
517
|
+
put("view_matrix", view);
|
|
518
|
+
put("projection_matrix", projection);
|
|
519
|
+
put("view_projection_matrix", view_projection);
|
|
520
|
+
// Reverse-Z infinite-far linearisation: view_depth = A / (device_depth + B), with A=near, B=0.
|
|
521
|
+
camera_bytes.set([0, PARTICLE_NEAR, 0, 0], CAMERA_UNIFORM_STRUCT.get("device_depth_to_view_space").offset / 4);
|
|
522
|
+
|
|
523
|
+
device.queue.writeBuffer(camera_buffer, 0, camera_bytes);
|
|
524
|
+
device.queue.writeBuffer(vp_buffer, 0, view_projection);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function translation(x, y, z) {
|
|
528
|
+
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function data_buffer(device, label, data, usage) {
|
|
532
|
+
const buffer = device.createBuffer({ label, size: Math.max(data.byteLength, 4), usage });
|
|
533
|
+
device.queue.writeBuffer(buffer, 0, data);
|
|
534
|
+
return buffer;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** A 64x64 radial-falloff sprite (white, alpha falloff) for soft additive/alpha blobs. */
|
|
538
|
+
function make_sprite_texture(device) {
|
|
539
|
+
const N = 64;
|
|
540
|
+
const px = new Uint8Array(N * N * 4);
|
|
541
|
+
for (let y = 0; y < N; y++) {
|
|
542
|
+
for (let x = 0; x < N; x++) {
|
|
543
|
+
const dx = (x + 0.5) / N * 2 - 1, dy = (y + 0.5) / N * 2 - 1;
|
|
544
|
+
const a = Math.max(0, 1 - Math.hypot(dx, dy));
|
|
545
|
+
const i = (y * N + x) * 4;
|
|
546
|
+
px[i] = 255; px[i + 1] = 255; px[i + 2] = 255; px[i + 3] = Math.round(a * a * 255);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const tex = device.createTexture({ label: "particles/sprite", size: [N, N], format: "rgba8unorm", usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
|
|
550
|
+
device.queue.writeTexture({ texture: tex }, px, { bytesPerRow: N * 4, rowsPerImage: N }, [N, N]);
|
|
551
|
+
return tex;
|
|
552
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# skinned mesh soup — repro
|
|
2
|
+
|
|
3
|
+
The character from `skinned_blas_refit`, loaded the way the game loads one, with the pieces that
|
|
4
|
+
could be making soup on switches.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm run dev
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
then open `/src/shade/playground/skinned_mesh_soup/index.html`.
|
|
11
|
+
|
|
12
|
+
`?count=N` for N characters, `?skin=off|bind|animated`, `?trace=1` for the path tracer. Every switch
|
|
13
|
+
is in the url so a state worth showing someone can be sent as a link.
|
|
14
|
+
|
|
15
|
+
## What it turned out to be
|
|
16
|
+
|
|
17
|
+
**`chunk_skin_blend_mesh_local` requires `inverse(mesh.global) × joint.global × inverse_bind` to be
|
|
18
|
+
rigid. For `Michelle.glb` it carries a uniform scale of exactly 100, and a dual quaternion cannot
|
|
19
|
+
carry a scale.**
|
|
20
|
+
|
|
21
|
+
The chunk's own doc comment states the precondition — *"the four products must be rigid, which holds
|
|
22
|
+
whenever the joints and the mesh node share the scaling ancestors"* — and names the shape it was
|
|
23
|
+
written for: an FBX-derived glTF that puts its unit conversion on a root node and authors the
|
|
24
|
+
inverse-bind matrices in **pre-scale** mesh space, so `joint.global × inverse_bind` carries the same
|
|
25
|
+
scale `mesh.global` does and the fold cancels it.
|
|
26
|
+
|
|
27
|
+
Michelle is the other legal shape. Her POSITION data and her inverse-bind matrices are authored in
|
|
28
|
+
the **already-scaled** space — 1.66 m tall, Y-up, as they stand — so
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
joint.global × inverse_bind = I for every one of her 65 joints
|
|
32
|
+
mesh.global = 0.01 scale × the up-axis rotation, from her "Character" root
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
and the fold is `inverse(mesh.global) × I`, a bare **100×**. Measured, at bind and under the dance:
|
|
36
|
+
every folded matrix's column length is `100.0000`, where rigid is 1.
|
|
37
|
+
|
|
38
|
+
`dual_quat_from_m4` builds its quaternion from `sqrt(max(0, 1 ± m00 ± m11 ± m22))`. Those terms are
|
|
39
|
+
`1 + 100·(…)`, so the `1` is swamped and every component whose sum goes negative is **clamped to
|
|
40
|
+
zero** by the `max`. The rotation it extracts is not the matrix's rotation, and how wrong it is
|
|
41
|
+
depends on the matrix — so it is wrong differently for every joint. That is the soup: not one
|
|
42
|
+
transform applied badly, but sixty-five of them disagreeing.
|
|
43
|
+
|
|
44
|
+
The two modes make the mechanism visible:
|
|
45
|
+
|
|
46
|
+
| `?skin=` | what you see | why |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `bind` | the right character, exactly 1/100 the size | every joint folds to the *same* matrix, `100·Rx(90°)`. Its `w` and `x` terms are both `1 + 100`, its `y` and `z` both clamp to zero, and the normalize recovers `Rx(90°)` — by luck. Only the scale is lost. Sampled output is `expected / 100` to the digit. |
|
|
49
|
+
| `animated` | soup | the joints' matrices differ, each loses a different amount, and the mesh comes apart. Mean error 121 in a mesh-local space where the body is 224 across; worst 165, on vertices bound to **a single joint at weight 1** — so the blend is not even involved. |
|
|
50
|
+
|
|
51
|
+
`__soup_blend_probe.spec.js` is that measurement: the WGSL of `dual_quat_from_m4`,
|
|
52
|
+
`dual_quat_blend4` and `dual_quat_transform_point` ported to JS, run over the character's own
|
|
53
|
+
matrices at bind and at `t = 0.5 s` of `SambaDance`, against the position the glTF formula puts each
|
|
54
|
+
vertex at. No device.
|
|
55
|
+
|
|
56
|
+
### What it is not
|
|
57
|
+
|
|
58
|
+
`__soup_probe.spec.js` is the ruling-out, and every one of these passes on the parsed asset before a
|
|
59
|
+
GPU is involved:
|
|
60
|
+
|
|
61
|
+
- the geometry is a surface, not a soup — 28 106 triangles, no degenerates, longest edge 0.093
|
|
62
|
+
against a 2.24 diagonal, every meshlet index in range;
|
|
63
|
+
- the skinning attributes are sound — all 19 106 vertices land back on their rest position under
|
|
64
|
+
`joint.global × inverse_bind`, no zero-weight vertices, no out-of-range joint;
|
|
65
|
+
- the source BLAS is exact — 56 211 nodes all reachable, 28 106 leaves each naming a distinct
|
|
66
|
+
triangle whose box matches to the bit, all 28 105 internal nodes exactly their children's union.
|
|
67
|
+
|
|
68
|
+
So it is not the loader, not the meshlet build, not the tree, and not the refit. `skinned_blas_refit`'s
|
|
69
|
+
`verify()` cannot see this at all, and that is worth saying plainly: it holds the tree's root box
|
|
70
|
+
against the bounds chain's box, and **both are derived from the clone's vertex region**. Vertices
|
|
71
|
+
that are wrong in the same way agree with each other perfectly. A green `verify()` and a soup
|
|
72
|
+
picture are consistent.
|
|
73
|
+
|
|
74
|
+
## The fix, and why it is a choice
|
|
75
|
+
|
|
76
|
+
The scale is uniform, so it factors: `M = s · Rigid` means `M · p = s · (Rigid · p)`. Dividing the
|
|
77
|
+
folded matrix's 3×3 and its translation by `s = length(column 0)` before the conversion, and scaling
|
|
78
|
+
the transformed position by `s` after, is exact for any uniform scale and costs a length and three
|
|
79
|
+
multiplies per joint. It does not help a *non-uniform* one, which a dual quaternion cannot carry
|
|
80
|
+
either — that would want the linear-blend path the DQ one replaced.
|
|
81
|
+
|
|
82
|
+
The other end is the loader: normalise a skin on load so `joint.global × inverse_bind` and
|
|
83
|
+
`mesh.global` always agree about the scale, and the shader's precondition becomes true by
|
|
84
|
+
construction rather than by the exporter's habits. That is a bigger change and it touches every
|
|
85
|
+
asset, not just this one.
|
|
86
|
+
|
|
87
|
+
Not taken here — this page is the repro and the measurement. Whichever end it is fixed at,
|
|
88
|
+
`chunk_skin_blend_mesh_local.spec.js` is where the red test belongs: a folded matrix carrying a
|
|
89
|
+
uniform scale, and the position it must still produce.
|
|
90
|
+
|
|
91
|
+
## The switches
|
|
92
|
+
|
|
93
|
+
- **`?skin=off`** — no skin registered. A `SkinnedMesh` still gets a vertex clone
|
|
94
|
+
(`GPUMeshSkinningContext#obtain_geometry_index` allocates one for every skinned mesh), but nothing
|
|
95
|
+
deforms it, so what is drawn is the memcpy of the source: the rest pose under the mesh node's own
|
|
96
|
+
transform. For Michelle a *correct* result is a 1.7 cm figure on her side. Soup here would be the
|
|
97
|
+
geometry upload — the meshlet copy or its address patch — and nothing to do with skinning.
|
|
98
|
+
- **`?skin=bind`** — the skin registered, no clip. `await diagnose()` in the console reads the
|
|
99
|
+
clone's vertices back off the GPU and holds them against `inverse(mesh.global) × rest`, which is
|
|
100
|
+
what the bind pose must produce. It reports which vertices are wrong and in what shape: a
|
|
101
|
+
vertex-slot range says the workgroup stride loop, whole meshlets say addressing, and a uniform
|
|
102
|
+
factor says this bug. It checks the clone's meshlet records address a region of their own first —
|
|
103
|
+
a clone still pointing at the source's vertex data would be deforming the shared rest pose in
|
|
104
|
+
place.
|
|
105
|
+
- **`?skin=animated`** — the file's dance, as `skinned_blas_refit` runs it.
|
|
106
|
+
- **Path tracer** — off by default. The rasterizer reads the deformed vertices directly, with no
|
|
107
|
+
tree in the way, so it is the reading to establish the answer on: soup in *both* is the vertices,
|
|
108
|
+
soup in the tracer alone is the tree.
|
|
109
|
+
|
|
110
|
+
## Why through `GLTFSceneBundleAssetLoader`
|
|
111
|
+
|
|
112
|
+
`skinned_blas_refit` calls `load_gltf` directly and parses the file once per character. The asset
|
|
113
|
+
loader is what the game and the editor register, and it hands out **the same bundle** to every
|
|
114
|
+
caller — so a second character is not a second parse, it is `instantiate_scene_bundle`: its own node
|
|
115
|
+
tree, its own skins bound to that tree's joints, its own clips retargeted onto it, sharing the
|
|
116
|
+
geometry and the inverse-bind matrices. Running the repro on that path is what makes a fix found
|
|
117
|
+
here a fix for the engine rather than for one prototype page.
|
|
118
|
+
|
|
119
|
+
## Assets
|
|
120
|
+
|
|
121
|
+
`/test_assets/Michelle.glb`, gitignored and unpublished — see `test_assets/README.md`. The two
|
|
122
|
+
probes skip themselves when it is absent.
|