@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.
Files changed (75) hide show
  1. package/package.json +1 -1
  2. package/src/engine/Engine.d.ts.map +1 -1
  3. package/src/engine/Engine.js +733 -726
  4. package/src/engine/network/NetworkSession.d.ts +24 -4
  5. package/src/engine/network/NetworkSession.d.ts.map +1 -1
  6. package/src/engine/network/NetworkSession.js +28 -4
  7. package/src/engine/network/orchestrator/NetworkPeer.d.ts +47 -6
  8. package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -1
  9. package/src/engine/network/orchestrator/NetworkPeer.js +184 -56
  10. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +97 -11
  11. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -1
  12. package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +304 -22
  13. package/src/engine/network/replication/Replicator.d.ts +14 -1
  14. package/src/engine/network/replication/Replicator.d.ts.map +1 -1
  15. package/src/engine/network/replication/Replicator.js +44 -3
  16. package/src/engine/network/sim/ActionLog.d.ts +21 -3
  17. package/src/engine/network/sim/ActionLog.d.ts.map +1 -1
  18. package/src/engine/network/sim/ActionLog.js +24 -3
  19. package/src/engine/network/sim/SimAction.d.ts +15 -0
  20. package/src/engine/network/sim/SimAction.d.ts.map +1 -1
  21. package/src/engine/network/sim/SimAction.js +15 -0
  22. package/src/engine/network/sim/SimActionExecutor.d.ts +51 -1
  23. package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -1
  24. package/src/engine/network/sim/SimActionExecutor.js +73 -5
  25. package/src/engine/network/transport/Channel.d.ts +1 -1
  26. package/src/engine/network/transport/Channel.js +1 -1
  27. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  28. package/src/engine/simulation/Ticker.js +4 -1
  29. package/src/engine/ui/GUIEngine.d.ts.map +1 -1
  30. package/src/engine/ui/GUIEngine.js +6 -0
  31. package/src/shade/playground/particle_system/README.md +98 -0
  32. package/src/shade/playground/particle_system/index.html +124 -0
  33. package/src/shade/playground/particle_system/main.d.ts +2 -0
  34. package/src/shade/playground/particle_system/main.d.ts.map +1 -0
  35. package/src/shade/playground/particle_system/main.js +180 -0
  36. package/src/shade/playground/particle_system/particle_scene.d.ts +52 -0
  37. package/src/shade/playground/particle_system/particle_scene.d.ts.map +1 -0
  38. package/src/shade/playground/particle_system/particle_scene.js +552 -0
  39. package/src/shade/playground/skinned_mesh_soup/README.md +122 -0
  40. package/src/shade/playground/skinned_mesh_soup/index.html +199 -0
  41. package/src/shade/playground/skinned_mesh_soup/main.d.ts +7 -0
  42. package/src/shade/playground/skinned_mesh_soup/main.d.ts.map +1 -0
  43. package/src/shade/playground/skinned_mesh_soup/main.js +564 -0
  44. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts +23 -0
  45. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.d.ts.map +1 -0
  46. package/src/shade/playground/skinned_mesh_soup/verify_clone_vertices.js +409 -0
  47. package/src/shade/renderer/particles/cull/chunk_particle_cull.d.ts.map +1 -1
  48. package/src/shade/renderer/particles/cull/chunk_particle_cull.js +3 -1
  49. package/src/shade/renderer/particles/data/chunk_particle_emitter.js +1 -1
  50. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts +1 -1
  51. package/src/shade/renderer/particles/graph/compile_particle_graph.d.ts.map +1 -1
  52. package/src/shade/renderer/particles/isa/InstructionStream.d.ts +89 -0
  53. package/src/shade/renderer/particles/isa/InstructionStream.d.ts.map +1 -0
  54. package/src/shade/renderer/particles/isa/InstructionStream.js +162 -0
  55. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts +1 -48
  56. package/src/shade/renderer/particles/isa/ParticleAssembler.d.ts.map +1 -1
  57. package/src/shade/renderer/particles/isa/ParticleAssembler.js +25 -114
  58. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.d.ts.map +1 -1
  59. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.js +4 -2
  60. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts +4 -4
  61. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts.map +1 -1
  62. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.js +7 -5
  63. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.d.ts.map +1 -1
  64. package/src/shade/renderer/particles/shaders/chunk_particle_render_math.js +3 -1
  65. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  66. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +3 -1
  67. package/src/shade/renderer/particles/shaders/shader_particle_finalize.d.ts.map +1 -1
  68. package/src/shade/renderer/particles/shaders/shader_particle_finalize.js +5 -2
  69. package/src/shade/renderer/particles/shaders/shader_particle_render.d.ts.map +1 -1
  70. package/src/shade/renderer/particles/shaders/shader_particle_render.js +9 -2
  71. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  72. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +3 -1
  73. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts +0 -2
  74. package/src/shade/renderer/particles/prototypeParticleSystem.d.ts.map +0 -1
  75. 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)) continue;
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) continue;
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 (peer that originated the action; local-only,
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
- * Note that `sender_id` is recorded in-buffer for local rollback orchestrators
26
- * (stable-sort tie-breaking by sender on replay) but never crosses the wire —
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;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;;;;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"}
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 (peer that originated the action; local-only,
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
- * Note that `sender_id` is recorded in-buffer for local rollback orchestrators
28
- * (stable-sort tie-breaking by sender on replay) but never crosses the wire —
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;;;;;;;;;;;;;;;;;;GAkBG;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"}
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. Valid peer ids must therefore be in `[0, 254]`.
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;IAyCI;;;;;;;;;OASG;IACH,sGATW;QACV,KAAS,yBAAyB;QAClC,UAAc,YAAY;QAC1B,eAAmB,oBAAoB;QACvC,kBAAsB,8BAA8B;QACpD,UAAc,uBAAuB;QACrC,gBAAoB,CAAC,mBAAmB;KACrC,EAyFH;IArID;;;;;;;;;OASG;IACH,gBAFU,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAEP;IAEtB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,uBAFuB,MAAM,aAAa,MAAM,wBAAwB,OAAO,CAE9D;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,CAiInB;;CACJ;mBAxTkB,uCAAuC"}
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. Valid peer ids must therefore be in `[0, 254]`.
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: peer that originated this action. Local-only record; not
296
- // sent over the wire (Replicator strips it during pack). Used by
297
- // rollback orchestrators for stable-sort tie-breaking.
298
- buffer.writeUint8(sender_id);
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
- * `seq_to_frame_end` leak in the orchestrator). Capping in-flight makes
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
- * `seq_to_frame_end` leak in the orchestrator). Capping in-flight makes
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,QA8EhB;IAED,cAWC;IAED,eAaC;IAED,aAYC;;CACJ;kBAxUiB,aAAa"}
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
- requestAnimationFrame(animationFrameCallback);
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,2BAYC;CACJ;iBAxgBgB,oCAAoC;gCAyBrB,4BAA4B;+BAf7B,sCAAsC;kCAFnC,yCAAyC;mBAexD,yBAAyB;sBAjBtB,kCAAkC;0BAG9B,qCAAqC;2BAPpC,oCAAoC;mBAiB5C,kBAAkB;iBAPpB,oBAAoB;gCATqB,uCAAuC"}
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.