@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
|
@@ -140,18 +140,40 @@ export class Replicator {
|
|
|
140
140
|
*
|
|
141
141
|
* Writes nothing if no in-scope actions are found.
|
|
142
142
|
*
|
|
143
|
+
* `max_bytes` caps how many bytes this call may add. Frame groups are
|
|
144
|
+
* written whole or not at all: when the next group would cross the cap, the
|
|
145
|
+
* pack stops before it and the return value reports how far it got, so the
|
|
146
|
+
* caller can credit exactly what it sent and pick the rest up on a later
|
|
147
|
+
* pass. The one exception is a first group that does not fit by itself —
|
|
148
|
+
* that one is written anyway, because no smaller pack could ever carry the
|
|
149
|
+
* frame and dropping it would strand those actions forever.
|
|
150
|
+
*
|
|
143
151
|
* @param {number} peer_id
|
|
144
152
|
* @param {number} start_frame inclusive; if no frame in range is in the log, that frame is skipped
|
|
145
153
|
* @param {number} end_frame inclusive
|
|
146
154
|
* @param {BinaryBuffer} out_buffer
|
|
155
|
+
* @param {number} [max_bytes] byte budget for this call; unbounded by default
|
|
156
|
+
* @returns {number} the last frame this pack covers, or `start_frame - 1` if
|
|
157
|
+
* it covers nothing. A frame that was skipped — absent from the log, or
|
|
158
|
+
* holding nothing in scope — counts as covered: the receiver has nothing
|
|
159
|
+
* to apply for it, so the caller may credit it.
|
|
147
160
|
*/
|
|
148
|
-
pack_for_peer(peer_id, start_frame, end_frame, out_buffer) {
|
|
161
|
+
pack_for_peer(peer_id, start_frame, end_frame, out_buffer, max_bytes = Number.MAX_SAFE_INTEGER) {
|
|
149
162
|
assert.isNonNegativeInteger(peer_id, 'peer_id');
|
|
150
163
|
assert.isNonNegativeInteger(start_frame, 'start_frame');
|
|
151
164
|
assert.isNonNegativeInteger(end_frame, 'end_frame');
|
|
152
165
|
|
|
166
|
+
// Budget is measured from wherever the caller had already written to,
|
|
167
|
+
// so a caller that prefixed a packet-type byte gets it counted.
|
|
168
|
+
const budget_end = out_buffer.position + max_bytes;
|
|
169
|
+
let last_packed = start_frame - 1;
|
|
170
|
+
let wrote_any_group = false;
|
|
171
|
+
|
|
153
172
|
for (let frame = start_frame; frame <= end_frame; frame++) {
|
|
154
|
-
if (!this.action_log.has_frame(frame))
|
|
173
|
+
if (!this.action_log.has_frame(frame)) {
|
|
174
|
+
last_packed = frame;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
155
177
|
|
|
156
178
|
const buffer = this.action_log.buffer_for(frame);
|
|
157
179
|
const end = this.action_log.write_end_for(frame);
|
|
@@ -173,9 +195,15 @@ export class Replicator {
|
|
|
173
195
|
}
|
|
174
196
|
}
|
|
175
197
|
|
|
176
|
-
if (in_scope_count === 0)
|
|
198
|
+
if (in_scope_count === 0) {
|
|
199
|
+
last_packed = frame;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
177
202
|
|
|
178
203
|
// Second pass: write the frame header + each in-scope action's payload only.
|
|
204
|
+
// The group's encoded size is only known once written (varint frame
|
|
205
|
+
// numbers and counts), so write it and roll back if it overran.
|
|
206
|
+
const group_start = out_buffer.position;
|
|
179
207
|
out_buffer.writeUintVar(frame);
|
|
180
208
|
out_buffer.writeUintVar(in_scope_count);
|
|
181
209
|
|
|
@@ -184,7 +212,20 @@ export class Replicator {
|
|
|
184
212
|
buffer.position = rs;
|
|
185
213
|
this.#copy_action_to(buffer, out_buffer);
|
|
186
214
|
}
|
|
215
|
+
|
|
216
|
+
if (wrote_any_group && out_buffer.position > budget_end) {
|
|
217
|
+
// Doesn't fit alongside what we already have. Un-write it and
|
|
218
|
+
// stop — the caller credits only up to the previous frame and
|
|
219
|
+
// this one leads the next pack.
|
|
220
|
+
out_buffer.position = group_start;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
wrote_any_group = true;
|
|
225
|
+
last_packed = frame;
|
|
187
226
|
}
|
|
227
|
+
|
|
228
|
+
return last_packed;
|
|
188
229
|
}
|
|
189
230
|
|
|
190
231
|
/**
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* uint32: prior_payload_len
|
|
13
13
|
* bytes: prior_payload (adapter.serialize of the component's prior state)
|
|
14
14
|
* uint8: action_type_id
|
|
15
|
-
* uint8: sender_id (
|
|
15
|
+
* uint8: sender_id (provenance of the record; local-only,
|
|
16
16
|
* STRIPPED by Replicator before send)
|
|
17
17
|
* uint32: action_payload_len
|
|
18
18
|
* bytes: action_payload (the action's own serialize output)
|
|
@@ -22,8 +22,18 @@
|
|
|
22
22
|
* which means rewind code can walk records forward to find boundaries, then
|
|
23
23
|
* iterate backward to apply prior states — no need to instantiate any actions.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* `sender_id` says where the record came from, which is what a rollback replay
|
|
26
|
+
* needs in order to decide what to do with it:
|
|
27
|
+
*
|
|
28
|
+
* - a peer id `[0, 253]` — arrived from that peer;
|
|
29
|
+
* - `SENDER_LOCAL` (255) — authored on this peer;
|
|
30
|
+
* - `SENDER_DERIVED` (254) — computed by this peer's simulation from the
|
|
31
|
+
* frame's own state. A replay of the frame recomputes these rather than
|
|
32
|
+
* reapplying them (see `ServerAuthoritativeServer.__read_historical`), so
|
|
33
|
+
* the sim's output does not accumulate a copy per replay.
|
|
34
|
+
*
|
|
35
|
+
* It also gives the replay a stable sort key, so multi-client action order is
|
|
36
|
+
* deterministic across peers. It never crosses the wire —
|
|
27
37
|
* {@link Replicator#pack_for_peer} strips it and the receiver derives sender
|
|
28
38
|
* from the inbound packet's peer_id, so a hostile peer cannot impersonate.
|
|
29
39
|
*
|
|
@@ -60,6 +70,14 @@ export class ActionLog {
|
|
|
60
70
|
* Close the currently-open frame.
|
|
61
71
|
*/
|
|
62
72
|
end_frame(): void;
|
|
73
|
+
/**
|
|
74
|
+
* Is a frame open for writing? {@link current_buffer} — and therefore
|
|
75
|
+
* `SimActionExecutor.execute` — throws when it is not, so a caller that can
|
|
76
|
+
* run either inside or outside a frame checks this to pick its path.
|
|
77
|
+
*
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
has_open_frame(): boolean;
|
|
63
81
|
/**
|
|
64
82
|
* Get the buffer for the currently-open frame for direct writing.
|
|
65
83
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ActionLog.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/sim/ActionLog.js"],"names":[],"mappings":"AAEA
|
|
1
|
+
{"version":3,"file":"ActionLog.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/sim/ActionLog.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH;IAoBI;;OAEG;IACH,qDAFW;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;KAAE,EAIlE;IAED;;;;OAIG;IACH,sCAEC;IAED;;;;OAIG;IACH,mBAFW,MAAM,QAShB;IAED;;OAEG;IACH,kBASC;IAED;;;;;;OAMG;IACH,kBAFa,OAAO,CAInB;IAED;;;;OAIG;IACH,+BAMC;IAED;;;;;OAKG;IACH,iBAHW,MAAM,GACJ,OAAO,CAInB;IAED;;;;;;;;;;;OAWG;IACH,kBAHW,MAAM,gBAKhB;IAED;;;;OAIG;IACH,qBAHW,MAAM,GACJ,MAAM,CAIlB;;CACJ"}
|
|
@@ -14,7 +14,7 @@ import { FrameBufferRing } from "../state/FrameBufferRing.js";
|
|
|
14
14
|
* uint32: prior_payload_len
|
|
15
15
|
* bytes: prior_payload (adapter.serialize of the component's prior state)
|
|
16
16
|
* uint8: action_type_id
|
|
17
|
-
* uint8: sender_id (
|
|
17
|
+
* uint8: sender_id (provenance of the record; local-only,
|
|
18
18
|
* STRIPPED by Replicator before send)
|
|
19
19
|
* uint32: action_payload_len
|
|
20
20
|
* bytes: action_payload (the action's own serialize output)
|
|
@@ -24,8 +24,18 @@ import { FrameBufferRing } from "../state/FrameBufferRing.js";
|
|
|
24
24
|
* which means rewind code can walk records forward to find boundaries, then
|
|
25
25
|
* iterate backward to apply prior states — no need to instantiate any actions.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* `sender_id` says where the record came from, which is what a rollback replay
|
|
28
|
+
* needs in order to decide what to do with it:
|
|
29
|
+
*
|
|
30
|
+
* - a peer id `[0, 253]` — arrived from that peer;
|
|
31
|
+
* - `SENDER_LOCAL` (255) — authored on this peer;
|
|
32
|
+
* - `SENDER_DERIVED` (254) — computed by this peer's simulation from the
|
|
33
|
+
* frame's own state. A replay of the frame recomputes these rather than
|
|
34
|
+
* reapplying them (see `ServerAuthoritativeServer.__read_historical`), so
|
|
35
|
+
* the sim's output does not accumulate a copy per replay.
|
|
36
|
+
*
|
|
37
|
+
* It also gives the replay a stable sort key, so multi-client action order is
|
|
38
|
+
* deterministic across peers. It never crosses the wire —
|
|
29
39
|
* {@link Replicator#pack_for_peer} strips it and the receiver derives sender
|
|
30
40
|
* from the inbound packet's peer_id, so a hostile peer cannot impersonate.
|
|
31
41
|
*
|
|
@@ -102,6 +112,17 @@ export class ActionLog {
|
|
|
102
112
|
this.#current_buffer = null;
|
|
103
113
|
}
|
|
104
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Is a frame open for writing? {@link current_buffer} — and therefore
|
|
117
|
+
* `SimActionExecutor.execute` — throws when it is not, so a caller that can
|
|
118
|
+
* run either inside or outside a frame checks this to pick its path.
|
|
119
|
+
*
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
has_open_frame() {
|
|
123
|
+
return this.#current_frame !== -1;
|
|
124
|
+
}
|
|
125
|
+
|
|
105
126
|
/**
|
|
106
127
|
* Get the buffer for the currently-open frame for direct writing.
|
|
107
128
|
*
|
|
@@ -11,6 +11,21 @@
|
|
|
11
11
|
* deterministic consequence of the simulation, or rewind / replay /
|
|
12
12
|
* replication will desync.
|
|
13
13
|
*
|
|
14
|
+
* "Deterministic consequence" is a narrow licence, and it is worth being exact
|
|
15
|
+
* about what it buys. A rewind restores component state from the prior-bytes
|
|
16
|
+
* captured per action record, so a mutation made outside an action is never
|
|
17
|
+
* undone — it survives into the replayed frame and the sim must overwrite it
|
|
18
|
+
* with the same answer it produced before. That holds for a function of the
|
|
19
|
+
* frame's post-input state that fully determines what it writes (a clamp, a
|
|
20
|
+
* position solved from inputs) and fails for anything that reads the value it
|
|
21
|
+
* is about to change (`hp -= 10`, a counter, an accumulator). Those need a
|
|
22
|
+
* `SimAction`, whose prior-bytes capture makes them reversible.
|
|
23
|
+
*
|
|
24
|
+
* Going through an action does not, on its own, make a mutation replayable
|
|
25
|
+
* input: on a rollback orchestrator, a record's `sender_id` marks whether it
|
|
26
|
+
* arrived, was authored, or was derived, and only the first two are reapplied
|
|
27
|
+
* from the log. See `ServerAuthoritativeServer#onLocalSim`.
|
|
28
|
+
*
|
|
14
29
|
* Wire vs local: actions reference entities by `network_id` (peer-shared);
|
|
15
30
|
* `executor.slot_table.entity_for(network_id)` translates to the local id.
|
|
16
31
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SimAction.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/sim/SimAction.js"],"names":[],"mappings":"AAEA
|
|
1
|
+
{"version":3,"file":"SimAction.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/sim/SimAction.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH;IAEI;;;;OAIG;IACH,gBAFU,MAAM,CAEI;IAEpB;;;;;;;;;OASG;IACH,mEAFa,IAAI,CAIhB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,qCAHoB,MAAM,qBAAa,IAAI,qCAK1C;IAED;;;;;;OAMG;IACH,iCAFa,IAAI,CAIhB;IAED;;;;;OAKG;IACH,mCAFa,IAAI,CAIhB;IAED;;;OAGG;IACH,cAEC;IAGL;;;;OAIG;IACH,sBAFU,OAAO,CAEc;CAP9B;;IAwCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAsCG;IACH;;;;;;;iBAmGC"}
|
|
@@ -13,6 +13,21 @@ import { FunctionCompiler } from "../../../core/function/FunctionCompiler.js";
|
|
|
13
13
|
* deterministic consequence of the simulation, or rewind / replay /
|
|
14
14
|
* replication will desync.
|
|
15
15
|
*
|
|
16
|
+
* "Deterministic consequence" is a narrow licence, and it is worth being exact
|
|
17
|
+
* about what it buys. A rewind restores component state from the prior-bytes
|
|
18
|
+
* captured per action record, so a mutation made outside an action is never
|
|
19
|
+
* undone — it survives into the replayed frame and the sim must overwrite it
|
|
20
|
+
* with the same answer it produced before. That holds for a function of the
|
|
21
|
+
* frame's post-input state that fully determines what it writes (a clamp, a
|
|
22
|
+
* position solved from inputs) and fails for anything that reads the value it
|
|
23
|
+
* is about to change (`hp -= 10`, a counter, an accumulator). Those need a
|
|
24
|
+
* `SimAction`, whose prior-bytes capture makes them reversible.
|
|
25
|
+
*
|
|
26
|
+
* Going through an action does not, on its own, make a mutation replayable
|
|
27
|
+
* input: on a rollback orchestrator, a record's `sender_id` marks whether it
|
|
28
|
+
* arrived, was authored, or was derived, and only the first two are reapplied
|
|
29
|
+
* from the log. See `ServerAuthoritativeServer#onLocalSim`.
|
|
30
|
+
*
|
|
16
31
|
* Wire vs local: actions reference entities by `network_id` (peer-shared);
|
|
17
32
|
* `executor.slot_table.entity_for(network_id)` translates to the local id.
|
|
18
33
|
*
|
|
@@ -2,11 +2,43 @@
|
|
|
2
2
|
* Sentinel sender id for actions that originated on the local peer (not
|
|
3
3
|
* received over the wire). Used as the default `sender_id` for
|
|
4
4
|
* {@link SimActionExecutor#execute} so existing single-peer code keeps
|
|
5
|
-
* working.
|
|
5
|
+
* working.
|
|
6
6
|
*
|
|
7
7
|
* @type {number}
|
|
8
8
|
*/
|
|
9
9
|
export const SENDER_LOCAL: number;
|
|
10
|
+
/**
|
|
11
|
+
* Sentinel sender id recorded for actions the local simulation DERIVED from
|
|
12
|
+
* the frame's state, rather than received or authored. Written in place of
|
|
13
|
+
* the caller's `sender_id` while {@link SimActionExecutor#deriving} is set.
|
|
14
|
+
*
|
|
15
|
+
* The sender byte is the record's provenance, and provenance is what decides
|
|
16
|
+
* whether a rollback replay may reuse a record. Input — remote or locally
|
|
17
|
+
* authored — happened once and cannot be recomputed, so a replay reapplies it
|
|
18
|
+
* from the log. Derived output is a function of the frame's post-input state,
|
|
19
|
+
* which a replay recomputes anyway; reapplying the previous pass's copy on top
|
|
20
|
+
* of the recomputed one applies the effect twice. So the two are separated
|
|
21
|
+
* here, at the point of execution, where the distinction is actually known.
|
|
22
|
+
*
|
|
23
|
+
* Local-only, like `SENDER_LOCAL` — {@link Replicator#pack_for_peer} strips
|
|
24
|
+
* the byte before send, so this never reaches the wire.
|
|
25
|
+
*
|
|
26
|
+
* Both sentinels are reserved: valid peer ids are `[0, 253]`, enforced by
|
|
27
|
+
* {@link SimActionExecutor#execute} and at {@link NetworkPeer#connect_peer}.
|
|
28
|
+
* A peer allowed to take 254 would have its records read back as the local
|
|
29
|
+
* sim's own output and dropped from every replay — its input would apply once
|
|
30
|
+
* and then silently vanish the first time a rollback crossed its frame.
|
|
31
|
+
*
|
|
32
|
+
* @type {number}
|
|
33
|
+
*/
|
|
34
|
+
export const SENDER_DERIVED: number;
|
|
35
|
+
/**
|
|
36
|
+
* Largest id a remote peer may take. Above this are the two local provenance
|
|
37
|
+
* sentinels, {@link SENDER_DERIVED} and {@link SENDER_LOCAL}.
|
|
38
|
+
*
|
|
39
|
+
* @type {number}
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_PEER_ID: number;
|
|
10
42
|
/**
|
|
11
43
|
* The single legitimate gateway for replicated state mutations.
|
|
12
44
|
*
|
|
@@ -83,6 +115,24 @@ export class SimActionExecutor {
|
|
|
83
115
|
* @type {((sender_id: number, entity_id: number, action: SimAction) => boolean) | null}
|
|
84
116
|
*/
|
|
85
117
|
authorize: (sender_id: number, entity_id: number, action: SimAction) => boolean;
|
|
118
|
+
/**
|
|
119
|
+
* When true, records written by {@link execute} are stamped
|
|
120
|
+
* {@link SENDER_DERIVED} instead of the caller's `sender_id`, marking them
|
|
121
|
+
* as output the simulation recomputes rather than input it must replay.
|
|
122
|
+
*
|
|
123
|
+
* Set by a rollback orchestrator for the span in which the frame's local
|
|
124
|
+
* sim runs (see `ServerAuthoritativeServer.__replay_frame`) and cleared
|
|
125
|
+
* immediately after, so it covers nested `execute` calls made from an
|
|
126
|
+
* action's `apply` during that span. Everything else — arrived input,
|
|
127
|
+
* locally-authored actions — is left with its real sender.
|
|
128
|
+
*
|
|
129
|
+
* Only the RECORDED byte changes. `sender_id` still drives the
|
|
130
|
+
* {@link authorize} gate and {@link current_sender_id}, so an action that
|
|
131
|
+
* self-validates on its sender sees the truth.
|
|
132
|
+
*
|
|
133
|
+
* @type {boolean}
|
|
134
|
+
*/
|
|
135
|
+
deriving: boolean;
|
|
86
136
|
/**
|
|
87
137
|
* @type {EntityComponentDataset}
|
|
88
138
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SimActionExecutor.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/sim/SimActionExecutor.js"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,2BAFU,MAAM,CAEiB;AAEjC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH;
|
|
1
|
+
{"version":3,"file":"SimActionExecutor.d.ts","sourceRoot":"","sources":["../../../../../src/engine/network/sim/SimActionExecutor.js"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,2BAFU,MAAM,CAEiB;AAEjC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,6BAFU,MAAM,CAEmB;AAEnC;;;;;GAKG;AACH,0BAFU,MAAM,CAEgB;AAEhC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH;IA4DI;;;;;;;;;OASG;IACH,sGATW;QACV,KAAS,yBAAyB;QAClC,UAAc,YAAY;QAC1B,eAAmB,oBAAoB;QACvC,kBAAsB,8BAA8B;QACpD,UAAc,uBAAuB;QACrC,gBAAoB,CAAC,mBAAmB;KACrC,EAyFH;IAxJD;;;;;;;;;OASG;IACH,gBAFU,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAEP;IAEtB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,uBAFuB,MAAM,aAAa,MAAM,wBAAwB,OAAO,CAE9D;IAEjB;;;;;;;;;;;;;;;;OAgBG;IACH,UAFU,OAAO,CAEA;IAab;;OAEG;IACH,8BAAkB;IAElB;;OAEG;IACH,sBAA4B;IAE5B;;OAEG;IACH,mCAAsC;IAEtC;;OAEG;IACH,gDAA4C;IAE5C;;;;OAIG;IACH,iCAA4B;IAE5B;;;;;;;;;;;OAWG;IACH,kBAFU,mBAAiB,IAAI,CAES;IA4BxC;;;;;;;OAOG;IACH,mBAFU,MAAM,CAEqB;IAErC;;;;;;;OAOG;IACH,kBAFU,MAAM,CAEoB;IAGxC;;;;;;;;;;OAUG;IACH,uCAPW,MAAM,GAGJ,OAAO,CAgJnB;;CACJ;mBA5XkB,uCAAuC"}
|
|
@@ -5,12 +5,46 @@ import Signal from "../../../core/events/signal/Signal.js";
|
|
|
5
5
|
* Sentinel sender id for actions that originated on the local peer (not
|
|
6
6
|
* received over the wire). Used as the default `sender_id` for
|
|
7
7
|
* {@link SimActionExecutor#execute} so existing single-peer code keeps
|
|
8
|
-
* working.
|
|
8
|
+
* working.
|
|
9
9
|
*
|
|
10
10
|
* @type {number}
|
|
11
11
|
*/
|
|
12
12
|
export const SENDER_LOCAL = 0xFF;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Sentinel sender id recorded for actions the local simulation DERIVED from
|
|
16
|
+
* the frame's state, rather than received or authored. Written in place of
|
|
17
|
+
* the caller's `sender_id` while {@link SimActionExecutor#deriving} is set.
|
|
18
|
+
*
|
|
19
|
+
* The sender byte is the record's provenance, and provenance is what decides
|
|
20
|
+
* whether a rollback replay may reuse a record. Input — remote or locally
|
|
21
|
+
* authored — happened once and cannot be recomputed, so a replay reapplies it
|
|
22
|
+
* from the log. Derived output is a function of the frame's post-input state,
|
|
23
|
+
* which a replay recomputes anyway; reapplying the previous pass's copy on top
|
|
24
|
+
* of the recomputed one applies the effect twice. So the two are separated
|
|
25
|
+
* here, at the point of execution, where the distinction is actually known.
|
|
26
|
+
*
|
|
27
|
+
* Local-only, like `SENDER_LOCAL` — {@link Replicator#pack_for_peer} strips
|
|
28
|
+
* the byte before send, so this never reaches the wire.
|
|
29
|
+
*
|
|
30
|
+
* Both sentinels are reserved: valid peer ids are `[0, 253]`, enforced by
|
|
31
|
+
* {@link SimActionExecutor#execute} and at {@link NetworkPeer#connect_peer}.
|
|
32
|
+
* A peer allowed to take 254 would have its records read back as the local
|
|
33
|
+
* sim's own output and dropped from every replay — its input would apply once
|
|
34
|
+
* and then silently vanish the first time a rollback crossed its frame.
|
|
35
|
+
*
|
|
36
|
+
* @type {number}
|
|
37
|
+
*/
|
|
38
|
+
export const SENDER_DERIVED = 0xFE;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Largest id a remote peer may take. Above this are the two local provenance
|
|
42
|
+
* sentinels, {@link SENDER_DERIVED} and {@link SENDER_LOCAL}.
|
|
43
|
+
*
|
|
44
|
+
* @type {number}
|
|
45
|
+
*/
|
|
46
|
+
export const MAX_PEER_ID = 0xFD;
|
|
47
|
+
|
|
14
48
|
/**
|
|
15
49
|
* The single legitimate gateway for replicated state mutations.
|
|
16
50
|
*
|
|
@@ -75,6 +109,25 @@ export class SimActionExecutor {
|
|
|
75
109
|
*/
|
|
76
110
|
authorize = null;
|
|
77
111
|
|
|
112
|
+
/**
|
|
113
|
+
* When true, records written by {@link execute} are stamped
|
|
114
|
+
* {@link SENDER_DERIVED} instead of the caller's `sender_id`, marking them
|
|
115
|
+
* as output the simulation recomputes rather than input it must replay.
|
|
116
|
+
*
|
|
117
|
+
* Set by a rollback orchestrator for the span in which the frame's local
|
|
118
|
+
* sim runs (see `ServerAuthoritativeServer.__replay_frame`) and cleared
|
|
119
|
+
* immediately after, so it covers nested `execute` calls made from an
|
|
120
|
+
* action's `apply` during that span. Everything else — arrived input,
|
|
121
|
+
* locally-authored actions — is left with its real sender.
|
|
122
|
+
*
|
|
123
|
+
* Only the RECORDED byte changes. `sender_id` still drives the
|
|
124
|
+
* {@link authorize} gate and {@link current_sender_id}, so an action that
|
|
125
|
+
* self-validates on its sender sees the truth.
|
|
126
|
+
*
|
|
127
|
+
* @type {boolean}
|
|
128
|
+
*/
|
|
129
|
+
deriving = false;
|
|
130
|
+
|
|
78
131
|
/**
|
|
79
132
|
* @param {{
|
|
80
133
|
* world: EntityComponentDataset,
|
|
@@ -197,6 +250,17 @@ export class SimActionExecutor {
|
|
|
197
250
|
`execute: action class '${action.constructor.action_type_name ?? action.constructor.name}' has no wire type_id — register it (NetworkSession.defineAction, or SimActionRegistry.register) before executing it`,
|
|
198
251
|
);
|
|
199
252
|
assert.isNonNegativeInteger(sender_id, 'sender_id');
|
|
253
|
+
// A remote sender that reaches a provenance sentinel would be recorded
|
|
254
|
+
// as local-authored or sim-derived, and a rollback replay reads the
|
|
255
|
+
// sentinel back as "this record is mine to recompute" — so the peer's
|
|
256
|
+
// input would apply once and then disappear on the first replay of its
|
|
257
|
+
// frame, with nothing logged. Real runtime check: asserts strip in prod,
|
|
258
|
+
// and silent input loss is not something to ship behind one.
|
|
259
|
+
if (sender_id > MAX_PEER_ID && sender_id !== SENDER_LOCAL) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`SimActionExecutor.execute: sender_id ${sender_id} is reserved (SENDER_DERIVED); remote peer ids must be in [0, ${MAX_PEER_ID}]`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
200
264
|
assert.ok(sender_id <= 0xFF, 'sender_id must fit in a uint8');
|
|
201
265
|
|
|
202
266
|
// Expose the originating sender to apply / affected_components / the
|
|
@@ -292,10 +356,14 @@ export class SimActionExecutor {
|
|
|
292
356
|
|
|
293
357
|
// 5. Append the action itself.
|
|
294
358
|
buffer.writeUint8(action.constructor.type_id);
|
|
295
|
-
// sender_id:
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
359
|
+
// sender_id: where this record came from. Local-only; not sent over the
|
|
360
|
+
// wire (Replicator strips it during pack). Used by rollback
|
|
361
|
+
// orchestrators for stable-sort tie-breaking, and to tell input (which
|
|
362
|
+
// a replay reapplies) from derived output (which a replay recomputes).
|
|
363
|
+
// While `deriving` is set the record is stamped SENDER_DERIVED — the
|
|
364
|
+
// originating sender is not meaningful for output the sim computed for
|
|
365
|
+
// itself, and nothing reads it back on that path.
|
|
366
|
+
buffer.writeUint8(this.deriving ? SENDER_DERIVED : sender_id);
|
|
299
367
|
|
|
300
368
|
// Reserve 4 bytes for action_payload_len, write payload, then back-patch.
|
|
301
369
|
const action_len_position = buffer.position;
|
|
@@ -9,7 +9,7 @@ export class Channel {
|
|
|
9
9
|
* an unacked seq falls more than half-range behind the newest sent, the
|
|
10
10
|
* signed `seq16_distance` used for loss detection wraps and the entry can
|
|
11
11
|
* neither be acked nor declared lost — a permanent leak (and a downstream
|
|
12
|
-
* `
|
|
12
|
+
* `seq_to_pending` leak in the orchestrator). Capping in-flight makes
|
|
13
13
|
* that lap impossible. Default 1024 ≫ any realistic in-flight window
|
|
14
14
|
* (RTT × send-rate) yet ≪ 32768. Only reached under a sustained one-way
|
|
15
15
|
* stall, where eviction-as-lost is the correct verdict anyway.
|
|
@@ -64,7 +64,7 @@ export class Channel {
|
|
|
64
64
|
* an unacked seq falls more than half-range behind the newest sent, the
|
|
65
65
|
* signed `seq16_distance` used for loss detection wraps and the entry can
|
|
66
66
|
* neither be acked nor declared lost — a permanent leak (and a downstream
|
|
67
|
-
* `
|
|
67
|
+
* `seq_to_pending` leak in the orchestrator). Capping in-flight makes
|
|
68
68
|
* that lap impossible. Default 1024 ≫ any realistic in-flight window
|
|
69
69
|
* (RTT × send-rate) yet ≪ 32768. Only reached under a sustained one-way
|
|
70
70
|
* stall, where eviction-as-lost is the correct verdict anyway.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Ticker.d.ts","sourceRoot":"","sources":["../../../../src/engine/simulation/Ticker.js"],"names":[],"mappings":";AAIA;;;;;;;;;;;;;;GAcG;AACH;IACI;;;OAGG;IACH,gBAFU,KAAK,CAEK;IAEpB;;;;;;;;;;;;OAYG;IACH,uBAFU,OAAO,CAEY;IAkC7B;;;;OAIG;IACH,qBAAsB;IAUtB;;;;OAIG;IACH,yBAEC;IAED;;;;OAIG;IACH,yBAEC;IAuGD;;;OAGG;IACH,uBAFW,MAAM,
|
|
1
|
+
{"version":3,"file":"Ticker.d.ts","sourceRoot":"","sources":["../../../../src/engine/simulation/Ticker.js"],"names":[],"mappings":";AAIA;;;;;;;;;;;;;;GAcG;AACH;IACI;;;OAGG;IACH,gBAFU,KAAK,CAEK;IAEpB;;;;;;;;;;;;OAYG;IACH,uBAFU,OAAO,CAEY;IAkC7B;;;;OAIG;IACH,qBAAsB;IAUtB;;;;OAIG;IACH,yBAEC;IAED;;;;OAIG;IACH,yBAEC;IAuGD;;;OAGG;IACH,uBAFW,MAAM,QAiFhB;IAED,cAWC;IAED,eAaC;IAED,aAYC;;CACJ;kBA3UiB,aAAa"}
|
|
@@ -278,7 +278,10 @@ class Ticker {
|
|
|
278
278
|
|
|
279
279
|
this.#addLifecycleListeners();
|
|
280
280
|
|
|
281
|
-
|
|
281
|
+
//recorded, so a stop() taken before the first frame lands can cancel it. The generation guard
|
|
282
|
+
//already makes the callback a no-op, but an uncancelled frame request keeps the closures (and
|
|
283
|
+
//everything the tick listeners hold) alive until it fires
|
|
284
|
+
this.#animationFrameHandle = requestAnimationFrame(animationFrameCallback);
|
|
282
285
|
|
|
283
286
|
//if we are starting while the page is already in the background, suspend right away
|
|
284
287
|
const doc = globalThis.document;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"GUIEngine.d.ts","sourceRoot":"","sources":["../../../../src/engine/ui/GUIEngine.js"],"names":[],"mappings":";AA4BA;IACI,mBAAqB;IAErB;;;OAGG;IACH,eAFU,gBAAc,IAAI,CAEP;IAErB;;;OAGG;IACH,eAAc;IAEd;;;OAGG;IACH,eAFU,eAAe,eAAe,CAAC,CAEX;IAE9B;;;OAGG;IACH,UAFU,cAAc,CAEQ;IAEhC;;;OAGG;IACH,cAFU,iBAAiB,CAEyB;IAGpD;;;OAGG;IACH,QAFU,MAAM,CAEM;IAEtB,gBAOG;IAEH;;;OAGG;IACH,KAFU,SAAS,CAEG;IAEtB;;;OAGG;IACH,QAFU,cAAc,CAEuB;IAG/C;;;OAGG;IACH,cAFU,eAAa,IAAI,CAEP;IAqBpB;;;;;;OAMG;IACH,mDANW,OAAO,GAIL,MAAM,CAoClB;IAED;;;;;;OAMG;IACH,0CALW,IAAI,GAGF,eAAe,CA+D3B;IAED;;;;;;OAMG;IACH,iEALW,MAAM,GAGJ,QAAQ,GAAG,CAAC,CA0DxB;IAED;;;;OAIG;IACH,mCAJW,MAAM,gBAWhB;IAED;;;;;OAKG;IACH,iCAJW,MAAM,gBAUhB;IAED;;;;;;;;OAQG;IACH,gEAPW,IAAI,gBA0Dd;IAED;;;;OAIG;IACH,kCAFY,eAAe,CAgB1B;IAED;;OAEG;IACH,yBAFa,eAAe,GAAC,IAAI,CAkBhC;IAED;;;OAGG;IACH,8BAKC;IAED;;;OAGG;IACH,wCAiEC;IAED,
|
|
1
|
+
{"version":3,"file":"GUIEngine.d.ts","sourceRoot":"","sources":["../../../../src/engine/ui/GUIEngine.js"],"names":[],"mappings":";AA4BA;IACI,mBAAqB;IAErB;;;OAGG;IACH,eAFU,gBAAc,IAAI,CAEP;IAErB;;;OAGG;IACH,eAAc;IAEd;;;OAGG;IACH,eAFU,eAAe,eAAe,CAAC,CAEX;IAE9B;;;OAGG;IACH,UAFU,cAAc,CAEQ;IAEhC;;;OAGG;IACH,cAFU,iBAAiB,CAEyB;IAGpD;;;OAGG;IACH,QAFU,MAAM,CAEM;IAEtB,gBAOG;IAEH;;;OAGG;IACH,KAFU,SAAS,CAEG;IAEtB;;;OAGG;IACH,QAFU,cAAc,CAEuB;IAG/C;;;OAGG;IACH,cAFU,eAAa,IAAI,CAEP;IAqBpB;;;;;;OAMG;IACH,mDANW,OAAO,GAIL,MAAM,CAoClB;IAED;;;;;;OAMG;IACH,0CALW,IAAI,GAGF,eAAe,CA+D3B;IAED;;;;;;OAMG;IACH,iEALW,MAAM,GAGJ,QAAQ,GAAG,CAAC,CA0DxB;IAED;;;;OAIG;IACH,mCAJW,MAAM,gBAWhB;IAED;;;;;OAKG;IACH,iCAJW,MAAM,gBAUhB;IAED;;;;;;;;OAQG;IACH,gEAPW,IAAI,gBA0Dd;IAED;;;;OAIG;IACH,kCAFY,eAAe,CAgB1B;IAED;;OAEG;IACH,yBAFa,eAAe,GAAC,IAAI,CAkBhC;IAED;;;OAGG;IACH,8BAKC;IAED;;;OAGG;IACH,wCAiEC;IAED,2BAkBC;CACJ;iBA9gBgB,oCAAoC;gCAyBrB,4BAA4B;+BAf7B,sCAAsC;kCAFnC,yCAAyC;mBAexD,yBAAyB;sBAjBtB,kCAAkC;0BAG9B,qCAAqC;2BAPpC,oCAAoC;mBAiB5C,kBAAkB;iBAPpB,oBAAoB;gCATqB,uCAAuC"}
|
|
@@ -506,6 +506,12 @@ class GUIEngine {
|
|
|
506
506
|
}
|
|
507
507
|
|
|
508
508
|
shutdown() {
|
|
509
|
+
// startup() armed this loop, so shutdown() is what has to disarm it. Left running it keeps
|
|
510
|
+
// animating views against a GUI that has been torn down, and its frame/timer chain outlives
|
|
511
|
+
// the host it belonged to - under a test environment that means calling
|
|
512
|
+
// requestAnimationFrame after the environment that provided it is gone.
|
|
513
|
+
this.ticker.stop();
|
|
514
|
+
|
|
509
515
|
this.windows.reset();
|
|
510
516
|
this.entityManager = null;
|
|
511
517
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# GPU particle system — playground
|
|
2
|
+
|
|
3
|
+
The whole GPU particle path running in a real scene: emitters, the VM, the alive/dead lists and the
|
|
4
|
+
billboard renderer, driven end to end by the GPU with no renderer, no assets and no `Renderer`
|
|
5
|
+
dependency. It moved here from `renderer/particles/prototypeParticleSystem.js`, which had no page to
|
|
6
|
+
open it with.
|
|
7
|
+
|
|
8
|
+
## Running it
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm run dev --workspace @woosh/meep-engine
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
then open <http://localhost:5173/src/shade/playground/particle_system/index.html>.
|
|
15
|
+
|
|
16
|
+
It has to be `http://localhost` rather than `file://`: WebGPU needs a secure context, and module
|
|
17
|
+
imports from `file://` are blocked by CORS anyway. The page imports nothing but meep's own relative
|
|
18
|
+
ESM, so any static server rooted at the repo also serves it.
|
|
19
|
+
|
|
20
|
+
**It needs immediate data**, and refuses to start without it. Emit, simulate and build-indirect each
|
|
21
|
+
carry their per-dispatch state (`u_dt`, `u_time`, `u_frame`, and for emit the request count and the
|
|
22
|
+
pool capacity) as an immediate block rather than as a transient uniform buffer, so an adapter whose
|
|
23
|
+
WGSL lacks the `immediate` address space cannot build any of the three. The refusal names the byte
|
|
24
|
+
counts. Chrome/Edge 113+ with immediates; Claude Code's in-app browser pane is **not** one — it
|
|
25
|
+
reports no adapter at all today, and no `immediate_address_space` in `wgslLanguageFeatures`.
|
|
26
|
+
|
|
27
|
+
## What is on screen
|
|
28
|
+
|
|
29
|
+
Four emitters sharing one 32k pool, chosen so each renderer path is visible at once:
|
|
30
|
+
|
|
31
|
+
| emitter | blend | projection | what it is there to show |
|
|
32
|
+
|---|---|---|---|
|
|
33
|
+
| fire | additive | billboard | `fire_effect()` straight out of `particle_prototype.js`, at the foot of the pillar |
|
|
34
|
+
| smoke | alpha | billboard, soft-depth | a rising column that fades where it meets the pillar rather than cutting into it |
|
|
35
|
+
| sparks | additive | **stretched** | short-lived, gravity-fed, stretched along velocity — the one non-billboard projection |
|
|
36
|
+
| dust | alpha | billboard, soft-depth | a wide slow drift, so the ground plane and the low wall both get a soft intersection |
|
|
37
|
+
|
|
38
|
+
Ground plane and three boxes are drawn first into a reverse-Z `depth32float` buffer. The billboard
|
|
39
|
+
pass loads that colour target and depth-tests against the same buffer read-only, so particles are
|
|
40
|
+
**occluded** by the boxes and the soft-depth emitters sample the depth they were occluded by.
|
|
41
|
+
|
|
42
|
+
Things worth looking at:
|
|
43
|
+
|
|
44
|
+
- particles disappearing behind the pillar as the camera orbits — that is the depth test,
|
|
45
|
+
- smoke and dust softening where they touch a surface instead of showing a hard intersection line —
|
|
46
|
+
that is `PARTICLE_FLAG_SOFT_DEPTH` and the reverse-Z linearisation,
|
|
47
|
+
- sparks leaning into their own velocity — `EMITTER_PROJECTION.STRETCHED`,
|
|
48
|
+
- both blend modes in **one** draw with one pipeline: `particle_premultiply` picks per particle, so
|
|
49
|
+
there is no additive pass and no alpha pass.
|
|
50
|
+
|
|
51
|
+
The overlay shows fps and, every 30 frames, the alive count, the free-list length and the two
|
|
52
|
+
indirect arg buffers. Those are read back **to look at**. Nothing on the CPU sizes a pass.
|
|
53
|
+
|
|
54
|
+
## The property the page exists to demonstrate
|
|
55
|
+
|
|
56
|
+
The frame is GPU-driven end to end. `shader_particle_build_indirect` writes both the simulate
|
|
57
|
+
dispatch args and the billboard draw args on the GPU; the next frame's simulate runs through
|
|
58
|
+
`dispatchWorkgroupsIndirect` and this frame's draw through `drawIndirect`. The host's only sizing
|
|
59
|
+
decision is how many *new* particles to spawn, which is a per-emitter rate times `dt` and has
|
|
60
|
+
nothing to do with how many are already alive.
|
|
61
|
+
|
|
62
|
+
Sizing either from a CPU readback instead would be a correctness bug rather than a latency one: a
|
|
63
|
+
stale count that is too small leaves the tail of the alive list unsimulated, so those slots are
|
|
64
|
+
never freed, and one that is too large re-simulates entries past the end, pushing a dead slot onto
|
|
65
|
+
the free list twice — it is then popped twice and two live particles share one record.
|
|
66
|
+
|
|
67
|
+
## Verifying it
|
|
68
|
+
|
|
69
|
+
`particle_scene.js` holds the scene with every reference to the page removed, so
|
|
70
|
+
`particle_scene.spec.js` can encode the same frames against `SoftwareGPUDevice`:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx vitest run src/shade/playground/particle_system/particle_scene.spec.js
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
That mock runs no shaders, so the spec asserts nothing about what a pass computed. What it does
|
|
77
|
+
assert is that the frame is legal and wired the way the design requires — pass order and
|
|
78
|
+
attachments, the alive lists ping-ponging so no frame reads the list it appends to, the immediate
|
|
79
|
+
blocks written before every dispatch that declares them, and simulate and the draw both taking their
|
|
80
|
+
counts from the indirect buffers. Any WebGPU validation failure anywhere in the frame arrives as an
|
|
81
|
+
uncaptured error, which every test fails on.
|
|
82
|
+
|
|
83
|
+
The picture is the browser's job; the spec cannot see it.
|
|
84
|
+
|
|
85
|
+
### Stepping frames by hand
|
|
86
|
+
|
|
87
|
+
Add `?debug` to the URL. `requestAnimationFrame` is then not used at all, and `window.__step(n)`
|
|
88
|
+
runs exactly `n` frames and returns the frame counter — which is what makes the loop drivable in a
|
|
89
|
+
hidden or headless tab, where rAF is suspended. `window.__stats` carries the last counters snapshot.
|
|
90
|
+
|
|
91
|
+
## What this page does not cover
|
|
92
|
+
|
|
93
|
+
Depth sorting. `EMITTER_FLAG.SORT` and the bucketed depth sort in `particles/sort/` (histogram →
|
|
94
|
+
csdldf scan → scatter) are not wired in here: the billboard pass draws the alive list directly, so
|
|
95
|
+
alpha-blended emitters are in emission order rather than back-to-front. The two soft-depth emitters are faint and diffuse enough that it
|
|
96
|
+
does not read as wrong, which is exactly why it should not be taken as evidence that sorting works.
|
|
97
|
+
Lighting is the unlit hook, too — `chunk_particle_lighting_unlit`, not the shading-chunk-backed one
|
|
98
|
+
the renderer feature swaps in.
|