@woosh/meep-engine 3.42.0 → 3.43.0

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 (62) hide show
  1. package/package.json +91 -96
  2. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  3. package/src/shade/playground/vgeo_runtime/README.md +5 -4
  4. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  5. package/src/shade/renderer/Renderer.js +6 -5
  6. package/src/shade/renderer/atmosphere/GPUSky.d.ts +12 -0
  7. package/src/shade/renderer/atmosphere/GPUSky.d.ts.map +1 -1
  8. package/src/shade/renderer/atmosphere/GPUSky.js +162 -147
  9. package/src/shade/renderer/lightmap/bake/shader_lightmap_denoise_sh.d.ts.map +1 -1
  10. package/src/shade/renderer/lightmap/bake/shader_lightmap_denoise_sh.js +10 -3
  11. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts +1 -1
  12. package/src/shade/renderer/particles/DESIGN.md +13 -9
  13. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.d.ts +1 -1
  14. package/src/shade/renderer/particles/shaders/chunk_particle_lighting.js +1 -1
  15. package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
  16. package/src/shade/renderer/rasterize/native/avboit/AVBOITSideChannel.d.ts +3 -2
  17. package/src/shade/renderer/rasterize/native/avboit/AVBOITSideChannel.d.ts.map +1 -1
  18. package/src/shade/renderer/rasterize/native/avboit/AVBOITSideChannel.js +3 -2
  19. package/src/shade/renderer/scene/GPUSceneContext.d.ts +9 -1
  20. package/src/shade/renderer/scene/GPUSceneContext.d.ts.map +1 -1
  21. package/src/shade/renderer/scene/GPUSceneContext.js +23 -1
  22. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts +4 -1
  23. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts.map +1 -1
  24. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.js +74 -3
  25. package/src/shade/renderer/volumetrics/NOTES.md +1023 -957
  26. package/src/shade/renderer/volumetrics/chunk_volumetrics_irradiance_sh.d.ts +5 -4
  27. package/src/shade/renderer/volumetrics/chunk_volumetrics_irradiance_sh.d.ts.map +1 -1
  28. package/src/shade/renderer/volumetrics/chunk_volumetrics_irradiance_sh.js +5 -4
  29. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi.d.ts +1 -1
  30. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi.js +1 -1
  31. package/src/shade/renderer/volumetrics/gi/graph_build_froxel_gi.d.ts +1 -1
  32. package/src/shade/renderer/volumetrics/gi/graph_build_froxel_gi.js +1 -1
  33. package/src/shade/renderer/volumetrics/graph_build_froxel_irradiance.d.ts +41 -0
  34. package/src/shade/renderer/volumetrics/graph_build_froxel_irradiance.d.ts.map +1 -0
  35. package/src/shade/renderer/volumetrics/graph_build_froxel_irradiance.js +112 -0
  36. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts +9 -23
  37. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts.map +1 -1
  38. package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +18 -126
  39. package/src/shade/renderer/volumetrics/shader_volumetrics_build_irradiance.d.ts +22 -0
  40. package/src/shade/renderer/volumetrics/shader_volumetrics_build_irradiance.d.ts.map +1 -0
  41. package/src/shade/renderer/volumetrics/shader_volumetrics_build_irradiance.js +228 -0
  42. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts.map +1 -1
  43. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.js +0 -53
  44. package/src/shade/renderer/water/surface/chunk_water_surface_sample.d.ts +4 -3
  45. package/src/shade/renderer/water/surface/chunk_water_surface_sample.d.ts.map +1 -1
  46. package/src/shade/renderer/water/surface/chunk_water_surface_sample.js +17 -7
  47. package/src/shade/renderer/water/surface/particles/GPUWaterParticles.d.ts.map +1 -1
  48. package/src/shade/renderer/water/surface/particles/GPUWaterParticles.js +69 -23
  49. package/src/shade/renderer/water/surface/particles/WATER_PARTICLE_STRUCT.d.ts.map +1 -1
  50. package/src/shade/renderer/water/surface/particles/WATER_PARTICLE_STRUCT.js +13 -5
  51. package/src/shade/renderer/water/surface/particles/chunk_water_particle.d.ts +29 -8
  52. package/src/shade/renderer/water/surface/particles/chunk_water_particle.d.ts.map +1 -1
  53. package/src/shade/renderer/water/surface/particles/chunk_water_particle.js +155 -30
  54. package/src/shade/renderer/water/surface/particles/chunk_water_particle_step.d.ts +6 -1
  55. package/src/shade/renderer/water/surface/particles/chunk_water_particle_step.d.ts.map +1 -1
  56. package/src/shade/renderer/water/surface/particles/chunk_water_particle_step.js +157 -39
  57. package/src/shade/renderer/water/surface/particles/shader_water_particles.d.ts +91 -21
  58. package/src/shade/renderer/water/surface/particles/shader_water_particles.d.ts.map +1 -1
  59. package/src/shade/renderer/water/surface/particles/shader_water_particles.js +442 -70
  60. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +11 -3
  61. package/src/shade/wgsl/emulator/WGSLLanguageCore.d.ts.map +1 -1
  62. package/src/shade/wgsl/emulator/WGSLLanguageCore.js +3 -0
@@ -1,3 +1,4 @@
1
+ import { WebGPUExtensionType } from "../../../../descriptor/WebGPUExtensionType.js";
1
2
  import { ComputeShader } from "../../../shader/ComputeShader.js";
2
3
  import { CodeChunk } from "../../../shader/compiler/CodeChunk.js";
3
4
  import { ShaderResourceSetDescriptor } from "../../../shader/resource/ShaderResourceSetDescriptor.js";
@@ -8,6 +9,7 @@ import { WGSLAttributes } from "../../../shader/type/WGSLAttributes.js";
8
9
  import { WGSLStructField } from "../../../shader/type/WGSLStructField.js";
9
10
  import { atomic_u32, f32, u32 } from "../../../shader/type/WEBGPU_WGSL_PRIMITIVE_TYPES.js";
10
11
  import { COLLECTION_U32_STRUCT_READ } from "../../../gpu_primitive/COLLECTION_U32_STRUCT_READ.js";
12
+ import { STRUCT_DISPATCH_COMPUTE_COMMAND } from "../../../gpu_primitive/indirect/STRUCT_DISPATCH_COMPUTE_COMMAND.js";
11
13
  import { GEOMETRY_METADATA_STRUCT } from "../../../geometry/GEOMETRY_METADATA_STRUCT.js";
12
14
  import { MESHLET_METADATA_STRUCT } from "../../../geometry/meshlet/encoding/MESHLET_METADATA_STRUCT.js";
13
15
  import { BVH2_NODE_STRUCT } from "../../../shader/chunk/bvh/BVH2_NODE_STRUCT.js";
@@ -20,6 +22,7 @@ import { WATER_PARTICLE_ENTRY_STRUCT, WATER_PARTICLE_STRUCT } from "./WATER_PART
20
22
  import { WATER_WAVE_IMPULSE_LIMIT } from "../WATER_WAVE_RECORD_STRUCT.js";
21
23
  import { chunk_water_particle } from "./chunk_water_particle.js";
22
24
  import { chunk_water_particle_step } from "./chunk_water_particle_step.js";
25
+ import { WATER_PARTICLE_GATHER_BUDGET } from "../chunk_water_surface_sample.js";
23
26
 
24
27
  /** A particle array with its live count in front, as the indirect commands read it. */
25
28
  export const WATER_PARTICLE_ARRAY_STRUCT = WebGPUStruct.fromFields(
@@ -49,11 +52,46 @@ export const WATER_PARTICLE_CORNERS = 4;
49
52
  export const WATER_PARTICLE_RANK_LIMIT = 64;
50
53
 
51
54
  /**
52
- * Packets a crowded cell keeps: where more crowd a cell, each is drawn by its share of the cell's summed steepness
53
- * times this, so about this many stay, the steepest whole. Well under the surface sampler's gather budget and the rank
54
- * limit, so a point reads all of its cell and every bucket keeps the particles' order.
55
+ * Packets a crowded point keeps. A point reads a cell of every level its body has, and a cell is crowded where its most
56
+ * crowded point reads more than this: a point of it at its level and the coarser ones, or one under it where finer
57
+ * levels hold packets too. Then each run of a front in it is drawn by its packets' least share of their cells: this
58
+ * many times the packet's steepness over the summed steepness that point reads, whatever the levels it is read at. So
59
+ * about this many stay at a point, the steepest whole wherever they sit, and a front stays whole or goes. What is
60
+ * elsewhere on the body does not count, so a splash far off, or a front moving to a coarser level, changes nothing here.
61
+ *
62
+ * A front's lot is its own for life and the same in every cell it reaches, so the fronts of the lowest lots are kept in
63
+ * every crowd they are in, whatever their size: the count at a point varies by fronts' worth around this, and a front
64
+ * the outline broke is drawn particle by particle, ~20 entries in one cell. Where a point would read more than the
65
+ * gather budget, its cells keep no more of it than their steepness's part, the lowest keys first
66
+ * ({@link WATER_PARTICLE_CROWD_BINS}), so no point reads past the budget. A whole front is drawn as the seven it was
67
+ * emitted as (water_particle_group), so three fresh splashes cross whole, whatever their steepness and levels; a fourth
68
+ * over them thins one.
69
+ *
70
+ * A cell only one front reaches is not crowded, however many of its packets it holds: one front puts at most ~40 in
71
+ * a cell, within the gather budget.
72
+ */
73
+ export const WATER_PARTICLE_CELL_SHARE = 24;
74
+
75
+ /**
76
+ * Octaves a crowded cell counts its entries in, by the key its lottery keeps them by (a front's lot over its
77
+ * steepness; the lowest kept first), below the most it can keep: bin j holds keys from 2^-(j+1) of that reach to
78
+ * 2^-j of it, the last everything below. A cell whose point could read past the gather budget drops its octaves from
79
+ * the top until the rest fit its part of the budget, so it may keep up to an octave's worth fewer than would fit.
80
+ */
81
+ export const WATER_PARTICLE_CROWD_BINS = 4;
82
+
83
+ /**
84
+ * Words a bucket's crowd takes, as the weigh pass sums it: its summed steepness, its largest front and complement of a
85
+ * front entered, and its entries.
86
+ */
87
+ export const WATER_PARTICLE_CROWD_WORDS = 4;
88
+
89
+ /**
90
+ * Words the nest pass adds to a bucket's crowd, in a buffer of their own so that it reads weigh's sums as plain loads:
91
+ * the most entries and the most summed steepness a point under it reads where finer levels hold packets, and its
92
+ * entries by key, {@link WATER_PARTICLE_CROWD_BINS} octaves.
55
93
  */
56
- export const WATER_PARTICLE_CELL_SHARE = 16;
94
+ export const WATER_PARTICLE_NEST_WORDS = 2 + WATER_PARTICLE_CROWD_BINS;
57
95
 
58
96
  /** Width of the ramp a thinned packet fades over, a fraction of its share. */
59
97
  export const WATER_PARTICLE_THINNING_RAMP = 0.25;
@@ -62,6 +100,9 @@ export const WATER_PARTICLE_THINNING_RAMP = 0.25;
62
100
  const WATER_PARTICLE_WEIGHT_SCALE = 1 << 20;
63
101
 
64
102
  const STRIDE = WATER_PARTICLE_BODY_BOUNDS_STRIDE;
103
+ const WORDS = WATER_PARTICLE_CROWD_WORDS;
104
+ const NEST = WATER_PARTICLE_NEST_WORDS;
105
+ const BINS = WATER_PARTICLE_CROWD_BINS;
65
106
 
66
107
  /** The step's settings: one immediate per entry point. */
67
108
  export const WATER_PARTICLE_STEP_STRUCT = WebGPUStruct.from({ dt: f32, capacity: u32 }, 'WaterParticleStepSettings');
@@ -74,6 +115,7 @@ update_resources.setImmediate('frame', WATER_PARTICLE_STEP_STRUCT);
74
115
  update_resources.createGroup()
75
116
  .addStorageBuffer('particles', WATER_PARTICLE_ARRAY_STRUCT, true)
76
117
  .addStorageBuffer('children', COLLECTION_U32_STRUCT_READ, true)
118
+ .addStorageBuffer('images', WebGPUArray.from(WATER_PARTICLE_STRUCT), true)
77
119
  .addStorageBuffer('activity', WebGPUArray.u32)
78
120
  .addStorageBuffer('scene_database', WebGPUArray.u32)
79
121
  .addStorageBuffer('geometries', WebGPUArray.from(GEOMETRY_METADATA_STRUCT))
@@ -83,47 +125,91 @@ update_resources.createGroup()
83
125
  .addStorageBuffer('meshlet_data', WebGPUArray.from(f32));
84
126
 
85
127
  /**
86
- * Advance every live particle of a body simulated this frame in place and count what it leaves: 0, 1 or 3. A paused
128
+ * Advance every live particle of a body simulated this frame in place and count what it leaves: 0, 1, 2 (it and the
129
+ * mirror image it left at the outline, which goes to `images` at its index), 3 or 6 (each split into three). A paused
87
130
  * body's particles stand still and leave themselves.
88
131
  */
89
- export const shader_water_particles_update = ComputeShader.from({ label: 'Water / particles update', resources: update_resources,
90
- body: CodeChunk.from(/* language=WGSL */ `
132
+ const update_body = CodeChunk.from(/* language=WGSL */ `
91
133
  @compute @workgroup_size(64)
92
- fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
93
- let i = folded_workgroup_index(group) * 64u + lane;
134
+ fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) index: u32,
135
+ @builtin(subgroup_invocation_id) lane: u32, @builtin(subgroup_size) lanes: u32) {
136
+ let i = folded_workgroup_index(group) * 64u + index;
94
137
  let count = min(particles.count, frame.capacity);
95
- if (i >= count) { return; }
96
- var particle = particles.elements[i];
97
- var left = 0u;
138
+ // every lane reaches the shuffles below: one past the count reads the last particle and writes nothing
139
+ let live = i < count;
140
+ var particle = particles.elements[min(i, max(count, 1u) - 1u)];
98
141
  let node = ${waves.marshalling_method_read_field('node')}(&scene_database, particle.body);
99
- if ((activity[particle.body] & ${WaterActivity.Active}u) == 0u) {
100
- left = 1u;
101
- } else if (node != 0u && node != ~0u) {
142
+ let paused = (activity[particle.body] & ${WaterActivity.Active}u) == 0u;
143
+ let stepping = live && !paused && node != 0u && node != ~0u;
144
+ var scale = vec2<f32>(1.0);
145
+ var geometry = 0u;
146
+ // the axis point of its leading arc a step on, and whether it is over the body
147
+ var axis = vec2<f32>(0.0);
148
+ var hint = WaterParticleArcHint(0u, 0u, 0u);
149
+ if (stepping) {
102
150
  let global = scene_read_node(&scene_database, node).global;
103
- let scale = max(vec2<f32>(length(global[0].xyz), length(global[2].xyz)), vec2<f32>(1e-20));
151
+ scale = max(vec2<f32>(length(global[0].xyz), length(global[2].xyz)), vec2<f32>(1e-20));
152
+ geometry = ${waves.marshalling_method_read_field('geometry')}(&scene_database, particle.body);
153
+ if (particle.reflected == 0u) {
154
+ axis = water_particle_leading_point(particle, frame.dt, 0u);
155
+ let hit = water_particle_over_body(geometry, axis / scale);
156
+ hint = WaterParticleArcHint(1u, select(0u, 1u, hit.t >= 0.0), hit.triangle);
157
+ }
158
+ }
159
+ // A front's particles stand in a row β apart, so the ends of one's window are its neighbours' axis points: each
160
+ // takes what the lanes either side found there rather than casting its own down-rays.
161
+ let state = hint.known | (hint.over << 1u) | (particle.body << 2u);
162
+ let below = max(lane, 1u) - 1u;
163
+ let above = min(lane + 1u, lanes - 1u);
164
+ let below_axis = subgroupShuffle(axis, below);
165
+ let below_state = subgroupShuffle(state, below);
166
+ let above_axis = subgroupShuffle(axis, above);
167
+ let above_state = subgroupShuffle(state, above);
168
+ if (!live) { return; }
169
+ var left = 1u;
170
+ if (stepping) {
171
+ if (particle.reflected == 0u) {
172
+ let tolerance = 2e-3 * particle.radius;
173
+ for (var k = 1u; k < 3u; k++) {
174
+ let end = water_particle_leading_point(particle, frame.dt, k);
175
+ var neighbour = 0u;
176
+ if ((below_state >> 2u) == particle.body && distance(end, below_axis) <= tolerance) { neighbour = below_state; }
177
+ if ((above_state >> 2u) == particle.body && distance(end, above_axis) <= tolerance) { neighbour = above_state; }
178
+ hint.known |= (neighbour & 1u) << k;
179
+ hint.over |= ((neighbour >> 1u) & 1u) << k;
180
+ }
181
+ }
104
182
  let threshold = water_particle_threshold(${waves.marshalling_method_read_field('slope_variance')}(&scene_database, particle.body),
105
183
  count, frame.capacity);
106
- let advanced = water_particle_advance(particle, frame.dt, ${waves.marshalling_method_read_field('geometry')}(&scene_database, particle.body),
107
- scale, ${waves.marshalling_method_read_field('wind')}(&scene_database, particle.body).w, threshold);
184
+ let advanced = water_particle_advance(particle, frame.dt, geometry, scale,
185
+ ${waves.marshalling_method_read_field('wind')}(&scene_database, particle.body).w, threshold, hint);
108
186
  particle = advanced.particle;
109
187
  left = advanced.children;
188
+ if (left == 2u || left == 6u) { images[i] = advanced.image; }
189
+ } else if (!paused) {
190
+ left = 0u;
110
191
  }
111
192
  particles.elements[i] = particle;
112
193
  children.elements[i] = left;
113
194
  }
114
195
  `, [chunk_water_particle_step, chunk_scene_database_access, waves.chunk_read_field('node'), waves.chunk_read_field('slope_variance'),
115
- waves.chunk_read_field('geometry'), waves.chunk_read_field('wind'), chunk_folded_workgroup_index]) });
196
+ waves.chunk_read_field('geometry'), waves.chunk_read_field('wind'), chunk_folded_workgroup_index]);
197
+ update_body.addExtension(WebGPUExtensionType.Subgroups);
198
+ export const shader_water_particles_update = ComputeShader.from({ label: 'Water / particles update', resources: update_resources,
199
+ body: update_body });
116
200
 
117
201
  const split_resources = new ShaderResourceSetDescriptor();
118
202
  split_resources.setImmediate('capacity', u32);
119
203
  split_resources.createGroup()
120
204
  .addStorageBuffer('particles', WATER_PARTICLE_ARRAY_STRUCT)
121
205
  .addStorageBuffer('children', COLLECTION_U32_STRUCT_READ)
206
+ .addStorageBuffer('images', WebGPUArray.from(WATER_PARTICLE_STRUCT))
122
207
  .addStorageBuffer('next', WATER_PARTICLE_ARRAY_STRUCT, true);
123
208
 
124
209
  /**
125
- * Write what each particle leaves into the next array at its scanned offset, in order: a split particle's
126
- * three at θ − β/3, θ, θ + β/3 with β/3 each. Past capacity, the tail is dropped.
210
+ * Write what each particle leaves into the next array at its scanned offset, in order: the particle, then the mirror
211
+ * image it left; each split one's three at θ − β/3, θ, θ + β/3 with β/3 each. So a front's particles stay in a row
212
+ * and in order, and an image follows its packet as a front of its own. Past capacity, the tail is dropped.
127
213
  */
128
214
  export const shader_water_particles_split = ComputeShader.from({ label: 'Water / particles split', resources: split_resources,
129
215
  body: CodeChunk.from(/* language=WGSL */ `
@@ -140,10 +226,16 @@ fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) la
140
226
  if (start < capacity) { next.elements[start] = particle; }
141
227
  return;
142
228
  }
229
+ // three of each when they split
230
+ let split = (end - start) % 3u == 0u;
231
+ let each = select(1u, 3u, split);
143
232
  for (var k = start; k < end && k < capacity; k++) {
144
233
  var child = particle;
145
- child.dispersion = particle.dispersion / 3.0;
146
- child.angle = particle.angle + (f32(k - start) - 1.0) * child.dispersion;
234
+ if (k - start >= each) { child = images[i]; }
235
+ if (split) {
236
+ child.dispersion = child.dispersion / 3.0;
237
+ child.angle += (f32((k - start) % 3u) - 1.0) * child.dispersion;
238
+ }
147
239
  next.elements[k] = child;
148
240
  }
149
241
  }
@@ -178,7 +270,7 @@ emit_resources.createGroup()
178
270
  /**
179
271
  * Each queued splash of a row on the culling pass's emit list becomes a ring
180
272
  * (WATER_SURFACE_SIMULATION_PLAN_2026_10_06.md §5.3): packets of the splash's radius r, two radii out from its centre,
181
- * a front's width apart, ridge height strength·r/c (a vertical velocity over the time a crest takes to cross the
273
+ * their windows' arcs no longer than their envelopes, ridge height strength·r/c (a vertical velocity over the time a crest takes to cross the
182
274
  * packet), no steeper than 0.1. The row's queue is spent. A paused body's ring stands still with its other particles,
183
275
  * which this frame's binning does not visit, so the ring's bound is added to the body's own here.
184
276
  */
@@ -200,8 +292,9 @@ fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) la
200
292
  let impulse = wave.impulses[j];
201
293
  let radius = clamp(impulse.z, 1e-3, 16.0);
202
294
  let height = clamp(impulse.w * radius / water_particle_phase_speed(radius), -0.1 * radius, 0.1 * radius);
203
- let reach = 2.0 * radius;
204
- let count = u32(ceil(WATER_TAU * reach / (2.0 * radius)));
295
+ let reach = water_particle_launch_distance(radius);
296
+ // as many as keep each window's arc within the envelope's half-length, as the step splits them
297
+ let count = u32(ceil(WATER_TAU * reach / water_particle_half_length_at(radius, reach)));
205
298
  let dispersion = WATER_TAU / f32(count);
206
299
  let age = reach / water_particle_group_speed(radius);
207
300
  let base = atomicAdd(&next.count, count);
@@ -210,7 +303,7 @@ fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) la
210
303
  let ridge = select(0.0, height, base + count <= capacity);
211
304
  for (var k = 0u; k < count; k++) {
212
305
  if (base + k >= capacity) { break; }
213
- next.elements[base + k] = ${WATER_PARTICLE_STRUCT.wgsl_ref}(impulse.xy * scale, f32(k) * dispersion, age, ridge, radius, dispersion, row);
306
+ next.elements[base + k] = ${WATER_PARTICLE_STRUCT.wgsl_ref}(impulse.xy * scale, f32(k) * dispersion, age, ridge, radius, dispersion, row, 0u);
214
307
  }
215
308
  held += f32(count) * abs(ridge);
216
309
  }
@@ -228,11 +321,13 @@ const finalize_resources = new ShaderResourceSetDescriptor();
228
321
  finalize_resources.setImmediate('limits', WATER_PARTICLE_LIMITS_STRUCT);
229
322
  finalize_resources.createGroup()
230
323
  .addStorageBuffer('next', WATER_PARTICLE_ARRAY_STRUCT, true)
231
- .addStorageBuffer('offsets', COLLECTION_U32_STRUCT_READ, true);
324
+ .addStorageBuffer('offsets', COLLECTION_U32_STRUCT_READ, true)
325
+ .addStorageBuffer('table_command', STRUCT_DISPATCH_COMPUTE_COMMAND, true);
232
326
 
233
327
  /**
234
328
  * The live count, clamped to capacity (emitters append past it), and the frame's hash table size: twice the
235
- * entries the particles can take, a power of two, at least ${WATER_PARTICLE_TABLE_MIN}.
329
+ * entries the particles can take, a power of two, at least ${WATER_PARTICLE_TABLE_MIN}; and the command that clears
330
+ * that much of the table, a thread a bucket.
236
331
  */
237
332
  export const shader_water_particles_finalize = ComputeShader.from({ label: 'Water / particles finalize', resources: finalize_resources,
238
333
  body: CodeChunk.from(/* language=WGSL */ `
@@ -241,10 +336,38 @@ fn main() {
241
336
  let live = min(next.count, limits.capacity);
242
337
  next.count = live;
243
338
  let wanted = max(2u * ${WATER_PARTICLE_CORNERS}u * live, ${WATER_PARTICLE_TABLE_MIN}u);
244
- offsets.count = min(1u << (32u - countLeadingZeros(wanted - 1u)), limits.table_limit);
339
+ let table = min(1u << (32u - countLeadingZeros(wanted - 1u)), limits.table_limit);
340
+ offsets.count = table;
341
+ table_command.workgroupCountX = (table + 63u) / 64u;
342
+ table_command.workgroupCountY = 1u;
343
+ table_command.workgroupCountZ = 1u;
245
344
  }
246
345
  `, [WATER_PARTICLE_STRUCT.declaration_chunk]) });
247
346
 
347
+ const clear_resources = new ShaderResourceSetDescriptor();
348
+ clear_resources.createGroup()
349
+ .addStorageBuffer('buckets', WATER_PARTICLE_BUCKETS_STRUCT, true)
350
+ .addStorageBuffer('bucket_amplitude', WebGPUArray.u32, true)
351
+ .addStorageBuffer('bucket_weight', WebGPUArray.u32, true)
352
+ .addStorageBuffer('bucket_nest', WebGPUArray.u32, true);
353
+
354
+ /**
355
+ * Zero the frame's table, as far as finalize sized it: each bucket's count, largest |a| and crowd. A frame pays for the
356
+ * table its particles take, not for the pool's capacity; the buckets past it are never read.
357
+ */
358
+ export const shader_water_particles_clear = ComputeShader.from({ label: 'Water / particles clear', resources: clear_resources,
359
+ body: CodeChunk.from(/* language=WGSL */ `
360
+ @compute @workgroup_size(64)
361
+ fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
362
+ let b = folded_workgroup_index(group) * 64u + lane;
363
+ if (b >= buckets.count) { return; }
364
+ atomicStore(&buckets.elements[b], 0u);
365
+ bucket_amplitude[b] = 0u;
366
+ for (var word = 0u; word < ${WORDS}u; word++) { bucket_weight[${WORDS}u * b + word] = 0u; }
367
+ for (var word = 0u; word < ${NEST}u; word++) { bucket_nest[${NEST}u * b + word] = 0u; }
368
+ }
369
+ `, [chunk_folded_workgroup_index]) });
370
+
248
371
  /**
249
372
  * A live particle's place in the hash this frame: its kernel at this time, its level and the cells its
250
373
  * support overlaps (one or two a side, the level's cell being at least the support's diameter).
@@ -261,14 +384,16 @@ fn water_particle_cells(particle: ${WATER_PARTICLE_STRUCT.wgsl_ref}, source: u32
261
384
  var cells: WaterParticleCells;
262
385
  let position = water_particle_position(particle);
263
386
  let width = water_particle_width(particle);
264
- let support = water_particle_support(particle.radius, width);
387
+ let distance = water_particle_distance(particle);
388
+ let half_length = water_particle_half_length_at(particle.radius, distance);
389
+ let support = water_particle_support(half_length, width, particle.dispersion);
265
390
  cells.level = water_particle_level(support);
266
391
  let size = water_particle_cell_size(cells.level);
267
392
  cells.first = vec2<i32>(floor((position - vec2<f32>(support)) / size));
268
393
  let last = vec2<i32>(floor((position + vec2<f32>(support)) / size));
269
394
  cells.span = vec2<u32>(clamp(last - cells.first, vec2<i32>(0), vec2<i32>(1))) + vec2<u32>(1u);
270
395
  cells.entry = ${WATER_PARTICLE_ENTRY_STRUCT.wgsl_ref}(position, cells.first, particle.angle, particle.amplitude, particle.radius,
271
- width, water_particle_phase(particle), (particle.body << 4u) | cells.level, source, 0u);
396
+ particle.dispersion, water_particle_phase(particle), water_particle_key(particle.body, cells.level), source, distance, half_length);
272
397
  return cells;
273
398
  }
274
399
 
@@ -285,30 +410,135 @@ fn water_particle_weight(particle: ${WATER_PARTICLE_STRUCT.wgsl_ref}) -> u32 {
285
410
  return u32(min(abs(particle.amplitude) / max(particle.radius, 1e-6), 1.0) * ${WATER_PARTICLE_WEIGHT_SCALE}.0);
286
411
  }
287
412
 
288
- // A number in [0, 1) that stays with a packet while it travels: from its front's centre and direction.
413
+ // The front a packet belongs to: from its centre, the same for every packet of the front.
414
+ fn water_particle_front(particle: ${WATER_PARTICLE_STRUCT.wgsl_ref}) -> u32 {
415
+ return pcg3d(vec3<u32>(bitcast<u32>(particle.origin.x), bitcast<u32>(particle.origin.y), particle.body)).x;
416
+ }
417
+
418
+ // A number in [0, 1) that stays with a front while it travels, the same for all its packets.
289
419
  fn water_particle_lot(particle: ${WATER_PARTICLE_STRUCT.wgsl_ref}) -> f32 {
290
- let bits = pcg3d(vec3<u32>(bitcast<u32>(particle.origin.x), bitcast<u32>(particle.origin.y), bitcast<u32>(particle.angle)));
291
- return f32(bits.x >> 8u) * (1.0 / 16777216.0);
420
+ return f32(water_particle_front(particle) >> 8u) * (1.0 / 16777216.0);
421
+ }
422
+
423
+ // The bucket of the cell holding a cell's points at a level as coarse as its own or coarser, as the sampler finds it.
424
+ fn water_particle_bucket_over(body: u32, level: u32, cell: vec2<i32>, over: u32, table_size: u32) -> u32 {
425
+ return water_particle_bucket(body, over, cell >> vec2<u32>(over - level), table_size);
426
+ }
427
+
428
+ // The key a crowd keeps a packet by: its front's lot over its steepness. A packet is drawn while its lot is under its
429
+ // share, which is its steepness times its cell's, so a crowd keeps the packets of its lowest keys, the steepest first.
430
+ fn water_particle_precedence(particle: ${WATER_PARTICLE_STRUCT.wgsl_ref}) -> f32 {
431
+ return water_particle_lot(particle) / f32(water_particle_weight(particle));
432
+ }
433
+
434
+ // The largest key a crowded cell's lottery can keep, from the summed steepness a point of it reads at its level and
435
+ // the coarser ones: its most crowded point reads at least that, and the ramp draws a lot up to its share above it.
436
+ fn water_particle_crowd_reach(steepness: u32) -> f32 {
437
+ return ${1 + WATER_PARTICLE_THINNING_RAMP} * ${WATER_PARTICLE_CELL_SHARE}.0 / f32(steepness);
438
+ }
439
+
440
+ // The octave of a cell's reach a key falls in, from the exponent of their ratio: [2^-(j+1), 2^-j) is bin j, the last
441
+ // takes everything below, and one past the reach is not counted (${BINS}u).
442
+ fn water_particle_crowd_bin(precedence: f32, reach: f32) -> u32 {
443
+ let ratio = precedence / reach;
444
+ if (!(ratio < 1.0)) { return ${BINS}u; }
445
+ return min(126u - ((bitcast<u32>(ratio) >> 23u) & 0xffu), ${BINS - 1}u);
292
446
  }
293
447
 
294
448
  // How much of a packet its crowd leaves drawn, from its share of the most crowded of its cells: all of it from a share
295
- // of one, and below that, all of it while its lot is under its share, fading to none a ramp of that share above. So a
296
- // cell keeps about its share whatever its crowd.
449
+ // of one, and below that, all of it while its lot is under its share, fading to none a ramp of that share above, and
450
+ // none from a share of none. So a cell keeps about its share whatever its crowd.
297
451
  fn water_particle_visibility(share: f32, lot: f32) -> f32 {
452
+ if (!(share > 0.0)) { return 0.0; }
298
453
  return clamp((share - lot) / (${WATER_PARTICLE_THINNING_RAMP} * share) + 1.0, 0.0, 1.0);
299
454
  }
300
455
  `, [chunk_water_particle]);
301
456
 
457
+ const fronts_resources = new ShaderResourceSetDescriptor();
458
+ fronts_resources.createGroup()
459
+ .addStorageBuffer('next', WATER_PARTICLE_ARRAY_STRUCT)
460
+ .addStorageBuffer('fronts', COLLECTION_U32_STRUCT_READ, true);
461
+
462
+ /**
463
+ * Where each front's run of particles begins: 1 at a particle not of the front before it, else 0. A front keeps its
464
+ * particles in a row and in order — a ring is emitted whole, a split puts its three in its place, the dead leave no
465
+ * gap — so the inclusive scan of these numbers each run from 1. A mirror image follows its packet in the row: it
466
+ * breaks its front's run there, as a death does, and is a front of its own.
467
+ */
468
+ export const shader_water_particles_fronts = ComputeShader.from({ label: 'Water / particles fronts', resources: fronts_resources,
469
+ body: CodeChunk.from(/* language=WGSL */ `
470
+ @compute @workgroup_size(64)
471
+ fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
472
+ let i = folded_workgroup_index(group) * 64u + lane;
473
+ if (i >= next.count) { return; }
474
+ fronts.elements[i] = select(1u, 0u, i > 0u && water_particle_same_front(next.elements[i - 1u], next.elements[i]));
475
+ }
476
+ `, [chunk_water_particle, chunk_folded_workgroup_index]) });
477
+
478
+ const runs_resources = new ShaderResourceSetDescriptor();
479
+ runs_resources.createGroup()
480
+ .addStorageBuffer('next', WATER_PARTICLE_ARRAY_STRUCT)
481
+ .addStorageBuffer('fronts', COLLECTION_U32_STRUCT_READ)
482
+ .addStorageBuffer('runs', WebGPUArray.u32, true);
483
+
484
+ /**
485
+ * Two words a run: its first particle, and the share its crowd leaves it, none yet (the share pass lowers it).
486
+ */
487
+ export const shader_water_particles_runs = ComputeShader.from({ label: 'Water / particles runs', resources: runs_resources,
488
+ body: CodeChunk.from(/* language=WGSL */ `
489
+ @compute @workgroup_size(64)
490
+ fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
491
+ let i = folded_workgroup_index(group) * 64u + lane;
492
+ if (i >= next.count) { return; }
493
+ let run = fronts.elements[i];
494
+ if (i == 0u || fronts.elements[i - 1u] != run) {
495
+ runs[2u * (run - 1u)] = i;
496
+ runs[2u * (run - 1u) + 1u] = bitcast<u32>(1e30);
497
+ }
498
+ }
499
+ `, [WATER_PARTICLE_STRUCT.declaration_chunk, chunk_folded_workgroup_index]) });
500
+
501
+ const groups_resources = new ShaderResourceSetDescriptor();
502
+ groups_resources.createGroup()
503
+ .addStorageBuffer('next', WATER_PARTICLE_ARRAY_STRUCT)
504
+ .addStorageBuffer('fronts', COLLECTION_U32_STRUCT_READ)
505
+ .addStorageBuffer('runs', WebGPUArray.u32)
506
+ .addStorageBuffer('draw', WebGPUArray.u32, true);
507
+
508
+ /**
509
+ * Two words a particle: its run, and how the frame draws it ({@link chunk_water_particle}'s water_particle_group): 1 as
510
+ * itself, 0 not at all, 3^g for the middle of a whole front's group of 3^g, drawn as one packet.
511
+ */
512
+ export const shader_water_particles_groups = ComputeShader.from({ label: 'Water / particles groups', resources: groups_resources,
513
+ body: CodeChunk.from(/* language=WGSL */ `
514
+ @compute @workgroup_size(64)
515
+ fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
516
+ let i = folded_workgroup_index(group) * 64u + lane;
517
+ let count = next.count;
518
+ if (i >= count) { return; }
519
+ let run = fronts.elements[i] - 1u;
520
+ let start = runs[2u * run];
521
+ var end = count;
522
+ if (run + 1u < fronts.elements[count - 1u]) { end = runs[2u * (run + 1u)]; }
523
+ draw[2u * i] = run;
524
+ draw[2u * i + 1u] = water_particle_group(next.elements[i], end - start, i - start);
525
+ }
526
+ `, [chunk_water_particle, chunk_folded_workgroup_index]) });
527
+
302
528
  const weigh_resources = new ShaderResourceSetDescriptor();
303
529
  weigh_resources.createGroup()
304
530
  .addStorageBuffer('next', WATER_PARTICLE_ARRAY_STRUCT)
305
531
  .addStorageBuffer('activity', WebGPUArray.u32)
306
532
  .addStorageBuffer('offsets', COLLECTION_U32_STRUCT_READ)
307
- .addStorageBuffer('bucket_weight', WebGPUArray.from(atomic_u32), true);
533
+ .addStorageBuffer('draw', WebGPUArray.u32)
534
+ .addStorageBuffer('bucket_weight', WebGPUArray.from(atomic_u32), true)
535
+ .addStorageBuffer('body_bounds', WebGPUArray.from(atomic_u32), true);
308
536
 
309
537
  /**
310
- * Each particle of a body simulated this frame adds its steepness to every cell its support overlaps: how crowded a
311
- * cell is, for the bin pass to thin it by.
538
+ * Each packet drawn of a body simulated this frame adds its steepness to every cell its support overlaps: how crowded
539
+ * a cell is, for the share pass to thin it by. Per bucket ({@link WATER_PARTICLE_CROWD_WORDS}) the sum, the largest
540
+ * front and complement of a front entered, which are each other's complements only where one front alone was, and the
541
+ * entries. And its level to its body's: a point reads a cell of every level its body has.
312
542
  */
313
543
  export const shader_water_particles_weigh = ComputeShader.from({ label: 'Water / particles weigh', resources: weigh_resources,
314
544
  body: CodeChunk.from(/* language=WGSL */ `
@@ -316,14 +546,154 @@ export const shader_water_particles_weigh = ComputeShader.from({ label: 'Water /
316
546
  fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
317
547
  let i = folded_workgroup_index(group) * 64u + lane;
318
548
  if (i >= next.count) { return; }
319
- let particle = next.elements[i];
549
+ let drawn = draw[2u * i + 1u];
550
+ if (drawn == 0u) { return; }
551
+ let particle = water_particle_drawn(next.elements[i], drawn);
320
552
  if ((activity[particle.body] & ${WaterActivity.Active}u) == 0u) { return; }
321
553
  let cells = water_particle_cells(particle, i);
322
554
  let weight = water_particle_weight(particle);
555
+ let front = water_particle_front(particle);
556
+ atomicOr(&body_bounds[particle.body * ${STRIDE}u], 1u << cells.level);
557
+ for (var corner = 0u; corner < ${WATER_PARTICLE_CORNERS}u; corner++) {
558
+ if (!water_particle_has_corner(cells, corner)) { continue; }
559
+ let at = ${WATER_PARTICLE_CROWD_WORDS}u * water_particle_bucket(particle.body, cells.level, water_particle_corner_cell(cells, corner), offsets.count);
560
+ atomicAdd(&bucket_weight[at], weight);
561
+ atomicMax(&bucket_weight[at + 1u], front);
562
+ atomicMax(&bucket_weight[at + 2u], ~front);
563
+ atomicAdd(&bucket_weight[at + 3u], 1u);
564
+ }
565
+ }
566
+ `, [chunk_water_particle_cells, chunk_folded_workgroup_index]) });
567
+
568
+ const nest_resources = new ShaderResourceSetDescriptor();
569
+ nest_resources.createGroup()
570
+ .addStorageBuffer('next', WATER_PARTICLE_ARRAY_STRUCT)
571
+ .addStorageBuffer('activity', WebGPUArray.u32)
572
+ .addStorageBuffer('offsets', COLLECTION_U32_STRUCT_READ)
573
+ .addStorageBuffer('draw', WebGPUArray.u32)
574
+ .addStorageBuffer('body_bounds', WebGPUArray.u32)
575
+ .addStorageBuffer('bucket_weight', WebGPUArray.u32)
576
+ .addStorageBuffer('bucket_nest', WebGPUArray.from(atomic_u32), true);
577
+
578
+ /**
579
+ * Each packet drawn of a body simulated this frame, its cells weighed: what a point of each of its cells reads at its
580
+ * level and the coarser ones, entries and summed steepness, raised onto the cell over it at every coarser level its
581
+ * body has, so a cell knows its most crowded point where finer levels hold packets under it; and its entry counted
582
+ * among its cell's by its key (water_particle_crowd_bin), for the share pass to cap a crowd by.
583
+ */
584
+ export const shader_water_particles_nest = ComputeShader.from({ label: 'Water / particles nest', resources: nest_resources,
585
+ body: CodeChunk.from(/* language=WGSL */ `
586
+ @compute @workgroup_size(64)
587
+ fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
588
+ let i = folded_workgroup_index(group) * 64u + lane;
589
+ if (i >= next.count) { return; }
590
+ let drawn = draw[2u * i + 1u];
591
+ if (drawn == 0u) { return; }
592
+ let particle = water_particle_drawn(next.elements[i], drawn);
593
+ if ((activity[particle.body] & ${WaterActivity.Active}u) == 0u) { return; }
594
+ let cells = water_particle_cells(particle, i);
595
+ let precedence = water_particle_precedence(particle);
596
+ // its body's levels coarser than its own
597
+ let coarser = body_bounds[particle.body * ${STRIDE}u] & ~((2u << cells.level) - 1u);
598
+ for (var corner = 0u; corner < ${WATER_PARTICLE_CORNERS}u; corner++) {
599
+ if (!water_particle_has_corner(cells, corner)) { continue; }
600
+ let cell = water_particle_corner_cell(cells, corner);
601
+ let bucket = water_particle_bucket(particle.body, cells.level, cell, offsets.count);
602
+ // what a point of the cell reads at its level and the coarser ones
603
+ var entries = bucket_weight[${WORDS}u * bucket + 3u];
604
+ var steepness = bucket_weight[${WORDS}u * bucket];
605
+ var above = coarser;
606
+ for (var n = 0u; n < WATER_PARTICLE_LEVELS && above != 0u; n++) {
607
+ let over = ${WORDS}u * water_particle_bucket_over(particle.body, cells.level, cell, firstTrailingBit(above), offsets.count);
608
+ above &= above - 1u;
609
+ entries += bucket_weight[over + 3u];
610
+ steepness += bucket_weight[over];
611
+ }
612
+ // onto every cell over it
613
+ above = coarser;
614
+ for (var n = 0u; n < WATER_PARTICLE_LEVELS && above != 0u; n++) {
615
+ let over = ${NEST}u * water_particle_bucket_over(particle.body, cells.level, cell, firstTrailingBit(above), offsets.count);
616
+ above &= above - 1u;
617
+ atomicMax(&bucket_nest[over], entries);
618
+ atomicMax(&bucket_nest[over + 1u], steepness);
619
+ }
620
+ let octave = water_particle_crowd_bin(precedence, water_particle_crowd_reach(steepness));
621
+ if (octave < ${BINS}u) { atomicAdd(&bucket_nest[${NEST}u * bucket + 2u + octave], 1u); }
622
+ }
623
+ }
624
+ `, [chunk_water_particle_cells, chunk_folded_workgroup_index]) });
625
+
626
+ const share_resources = new ShaderResourceSetDescriptor();
627
+ share_resources.createGroup()
628
+ .addStorageBuffer('next', WATER_PARTICLE_ARRAY_STRUCT)
629
+ .addStorageBuffer('activity', WebGPUArray.u32)
630
+ .addStorageBuffer('offsets', COLLECTION_U32_STRUCT_READ)
631
+ .addStorageBuffer('bucket_weight', WebGPUArray.u32)
632
+ .addStorageBuffer('bucket_nest', WebGPUArray.u32)
633
+ .addStorageBuffer('body_bounds', WebGPUArray.u32)
634
+ .addStorageBuffer('draw', WebGPUArray.u32)
635
+ .addStorageBuffer('runs', WebGPUArray.from(atomic_u32), true);
636
+
637
+ /**
638
+ * Each packet drawn its share of the most crowded of its cells, lowered onto its run: a run is thinned whole, by the most
639
+ * crowded cell any of its packets is in, so a front is never holed. A cell is crowded where its most crowded point (one
640
+ * of it at its level and the coarser ones, or one under it the nest pass raised) reads more than
641
+ * {@link WATER_PARTICLE_CELL_SHARE} entries, and a packet's share there is that many times its steepness over the summed
642
+ * steepness that point reads. Where that point would read more than the gather budget, the cell keeps no more of it
643
+ * than its own steepness's part: its keys' octaves dropped from the top until the rest fit
644
+ * ({@link WATER_PARTICLE_CROWD_BINS}). A cell only one front reaches does not count.
645
+ */
646
+ export const shader_water_particles_share = ComputeShader.from({ label: 'Water / particles share', resources: share_resources,
647
+ body: CodeChunk.from(/* language=WGSL */ `
648
+ @compute @workgroup_size(64)
649
+ fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
650
+ let i = folded_workgroup_index(group) * 64u + lane;
651
+ if (i >= next.count) { return; }
652
+ let drawn = draw[2u * i + 1u];
653
+ if (drawn == 0u) { return; }
654
+ let particle = water_particle_drawn(next.elements[i], drawn);
655
+ if ((activity[particle.body] & ${WaterActivity.Active}u) == 0u) { return; }
656
+ let cells = water_particle_cells(particle, i);
657
+ let weight = f32(water_particle_weight(particle));
658
+ let coarser = body_bounds[particle.body * ${STRIDE}u] & ~((2u << cells.level) - 1u);
659
+ var share = 1e30;
323
660
  for (var corner = 0u; corner < ${WATER_PARTICLE_CORNERS}u; corner++) {
324
661
  if (!water_particle_has_corner(cells, corner)) { continue; }
325
- atomicAdd(&bucket_weight[water_particle_bucket(particle.body, cells.level, water_particle_corner_cell(cells, corner), offsets.count)], weight);
662
+ let cell = water_particle_corner_cell(cells, corner);
663
+ let bucket = water_particle_bucket(particle.body, cells.level, cell, offsets.count);
664
+ let at = ${WORDS}u * bucket;
665
+ let nested = ${NEST}u * bucket;
666
+ if (bucket_weight[at + 1u] == ~bucket_weight[at + 2u]) { continue; }
667
+ // what a point of the cell reads at its level and the coarser ones
668
+ var entries = bucket_weight[at + 3u];
669
+ var steepness = bucket_weight[at];
670
+ var above = coarser;
671
+ for (var n = 0u; n < WATER_PARTICLE_LEVELS && above != 0u; n++) {
672
+ let over = ${WORDS}u * water_particle_bucket_over(particle.body, cells.level, cell, firstTrailingBit(above), offsets.count);
673
+ above &= above - 1u;
674
+ entries += bucket_weight[over + 3u];
675
+ steepness += bucket_weight[over];
676
+ }
677
+ // and what its most crowded point reads
678
+ let crowd = max(entries, bucket_nest[nested]);
679
+ let total = max(steepness, bucket_nest[nested + 1u]);
680
+ if (crowd <= ${WATER_PARTICLE_CELL_SHARE}u || total == 0u) { continue; }
681
+ share = min(share, ${WATER_PARTICLE_CELL_SHARE}.0 * weight / f32(total));
682
+ if (crowd <= ${WATER_PARTICLE_GATHER_BUDGET}u) { continue; }
683
+ // no more kept than its part of the budget: the octaves of its keys dropped from the top until the rest fit
684
+ let quota = ${WATER_PARTICLE_GATHER_BUDGET}.0 * f32(bucket_weight[at]) / f32(total);
685
+ var below = 0u;
686
+ for (var octave = 0u; octave < ${BINS}u; octave++) { below += bucket_nest[nested + 2u + octave]; }
687
+ var reach = water_particle_crowd_reach(steepness);
688
+ for (var octave = 0u; octave < ${BINS}u && f32(below) > quota; octave++) {
689
+ below -= bucket_nest[nested + 2u + octave];
690
+ reach = select(0.0, 0.5 * reach, octave + 1u < ${BINS}u);
691
+ }
692
+ // drawn while its lot is under the ramp above its share: while its key is under the reach left
693
+ share = min(share, reach * weight / ${1 + WATER_PARTICLE_THINNING_RAMP});
326
694
  }
695
+ // non-negative floats order as their bits do
696
+ atomicMin(&runs[2u * draw[2u * i] + 1u], bitcast<u32>(share));
327
697
  }
328
698
  `, [chunk_water_particle_cells, chunk_folded_workgroup_index]) });
329
699
 
@@ -335,44 +705,37 @@ bin_resources.createGroup()
335
705
  .addStorageBuffer('slots', WebGPUArray.u32, true)
336
706
  .addStorageBuffer('bucket_amplitude', WebGPUArray.from(atomic_u32), true)
337
707
  .addStorageBuffer('body_bounds', WebGPUArray.from(atomic_u32), true)
338
- .addStorageBuffer('bucket_weight', WebGPUArray.u32)
708
+ .addStorageBuffer('draw', WebGPUArray.u32)
709
+ .addStorageBuffer('runs', WebGPUArray.u32)
339
710
  .addStorageBuffer('visible', WebGPUArray.from(f32), true);
340
711
 
341
712
  /**
342
- * Enter each particle of a body simulated this frame in every hash cell its support overlaps: count it into the cell's
343
- * bucket, keep its slot there, raise the bucket's largest |a| to its own, and mark its level on its body. One atomic
344
- * each: no compare-exchange loop a crowded bucket would serialize. A paused body's particles take no slot: nothing
345
- * draws them this frame.
713
+ * Enter each packet drawn of a body simulated this frame in every hash cell its support overlaps: count it into the
714
+ * cell's bucket, keep its slot there, raise the bucket's largest |a| to its own, and mark its level on its body. One
715
+ * atomic each: no compare-exchange loop a crowded bucket would serialize. A paused body's particles take no slot:
716
+ * nothing draws them this frame.
346
717
  *
347
- * Where cells are crowded (the weigh pass), a particle is drawn by how much its most crowded cell leaves it
348
- * ({@link WATER_PARTICLE_CELL_SHARE}): one decision for all its cells, so the surface stays continuous across them, and
349
- * from its own lot, so it does not change with the order particles arrive in. One left out takes no slot.
718
+ * Where cells are crowded (the share pass), a run is drawn by the share its most crowded cell leaves it and its
719
+ * front's lot: one decision for all its packets and all their cells, so neither a front nor the surface across cells
720
+ * is holed, and it does not change with the order particles arrive in. One left out takes no slot.
350
721
  */
351
722
  export const shader_water_particles_bin = ComputeShader.from({ label: 'Water / particles bin', resources: bin_resources,
352
723
  body: CodeChunk.from(/* language=WGSL */ `
724
+ fn water_particle_unslotted(i: u32) {
725
+ for (var corner = 0u; corner < ${WATER_PARTICLE_CORNERS}u; corner++) { slots[i * ${WATER_PARTICLE_CORNERS}u + corner] = ~0u; }
726
+ }
727
+
353
728
  @compute @workgroup_size(64)
354
729
  fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
355
730
  let i = folded_workgroup_index(group) * 64u + lane;
356
731
  if (i >= next.count) { return; }
357
- let particle = next.elements[i];
358
- if ((activity[particle.body] & ${WaterActivity.Active}u) == 0u) {
359
- for (var corner = 0u; corner < ${WATER_PARTICLE_CORNERS}u; corner++) { slots[i * ${WATER_PARTICLE_CORNERS}u + corner] = ~0u; }
360
- return;
361
- }
732
+ let drawn = draw[2u * i + 1u];
733
+ let particle = water_particle_drawn(next.elements[i], max(drawn, 1u));
734
+ if (drawn == 0u || (activity[particle.body] & ${WaterActivity.Active}u) == 0u) { water_particle_unslotted(i); return; }
362
735
  let cells = water_particle_cells(particle, i);
363
- let weight = f32(water_particle_weight(particle));
364
- var share = 1e30;
365
- for (var corner = 0u; corner < ${WATER_PARTICLE_CORNERS}u; corner++) {
366
- if (!water_particle_has_corner(cells, corner)) { continue; }
367
- let total = f32(bucket_weight[water_particle_bucket(particle.body, cells.level, water_particle_corner_cell(cells, corner), buckets.count)]);
368
- if (total > 0.0) { share = min(share, ${WATER_PARTICLE_CELL_SHARE}.0 * weight / total); }
369
- }
370
- let visibility = water_particle_visibility(share, water_particle_lot(particle));
736
+ let visibility = water_particle_visibility(bitcast<f32>(runs[2u * draw[2u * i] + 1u]), water_particle_lot(particle));
371
737
  visible[i] = visibility;
372
- if (!(visibility > 0.0)) {
373
- for (var corner = 0u; corner < ${WATER_PARTICLE_CORNERS}u; corner++) { slots[i * ${WATER_PARTICLE_CORNERS}u + corner] = ~0u; }
374
- return;
375
- }
738
+ if (!(visibility > 0.0)) { water_particle_unslotted(i); return; }
376
739
  atomicOr(&body_bounds[particle.body * ${STRIDE}u], 1u << cells.level);
377
740
  for (var corner = 0u; corner < ${WATER_PARTICLE_CORNERS}u; corner++) {
378
741
  var slot = ~0u;
@@ -393,6 +756,7 @@ scatter_resources.createGroup()
393
756
  .addStorageBuffer('offsets', COLLECTION_U32_STRUCT_READ)
394
757
  .addStorageBuffer('slots', WebGPUArray.u32)
395
758
  .addStorageBuffer('visible', WebGPUArray.from(f32))
759
+ .addStorageBuffer('draw', WebGPUArray.u32)
396
760
  .addStorageBuffer('staging', WebGPUArray.from(WATER_PARTICLE_ENTRY_STRUCT), true);
397
761
 
398
762
  /** Each entry to its bucket's range, in arrival order, at the height its crowd leaves it; the rank pass orders it. */
@@ -402,7 +766,9 @@ export const shader_water_particles_scatter = ComputeShader.from({ label: 'Water
402
766
  fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
403
767
  let i = folded_workgroup_index(group) * 64u + lane;
404
768
  if (i >= next.count) { return; }
405
- let particle = next.elements[i];
769
+ let drawn = draw[2u * i + 1u];
770
+ if (drawn == 0u) { return; }
771
+ let particle = water_particle_drawn(next.elements[i], drawn);
406
772
  let cells = water_particle_cells(particle, i);
407
773
  for (var corner = 0u; corner < ${WATER_PARTICLE_CORNERS}u; corner++) {
408
774
  let slot = slots[i * ${WATER_PARTICLE_CORNERS}u + corner];
@@ -424,6 +790,7 @@ rank_resources.createGroup()
424
790
  .addStorageBuffer('next', WATER_PARTICLE_ARRAY_STRUCT)
425
791
  .addStorageBuffer('offsets', COLLECTION_U32_STRUCT_READ)
426
792
  .addStorageBuffer('slots', WebGPUArray.u32)
793
+ .addStorageBuffer('draw', WebGPUArray.u32)
427
794
  .addStorageBuffer('staging', WebGPUArray.from(WATER_PARTICLE_ENTRY_STRUCT))
428
795
  .addStorageBuffer('entries', WebGPUArray.from(WATER_PARTICLE_ENTRY_STRUCT), true);
429
796
 
@@ -444,7 +811,9 @@ fn water_entry_before(a: ${WATER_PARTICLE_ENTRY_STRUCT.wgsl_ref}, b: ${WATER_PAR
444
811
  fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
445
812
  let i = folded_workgroup_index(group) * 64u + lane;
446
813
  if (i >= next.count) { return; }
447
- let particle = next.elements[i];
814
+ let drawn = draw[2u * i + 1u];
815
+ if (drawn == 0u) { return; }
816
+ let particle = water_particle_drawn(next.elements[i], drawn);
448
817
  let cells = water_particle_cells(particle, i);
449
818
  for (var corner = 0u; corner < ${WATER_PARTICLE_CORNERS}u; corner++) {
450
819
  let slot = slots[i * ${WATER_PARTICLE_CORNERS}u + corner];
@@ -470,6 +839,7 @@ bounds_resources.createGroup()
470
839
  .addStorageBuffer('activity', WebGPUArray.u32)
471
840
  .addStorageBuffer('offsets', COLLECTION_U32_STRUCT_READ)
472
841
  .addStorageBuffer('bucket_amplitude', WebGPUArray.u32)
842
+ .addStorageBuffer('draw', WebGPUArray.u32)
473
843
  .addStorageBuffer('body_bounds', WebGPUArray.from(atomic_u32), true);
474
844
 
475
845
  /**
@@ -484,7 +854,9 @@ export const shader_water_particles_bounds = ComputeShader.from({ label: 'Water
484
854
  fn main(@builtin(workgroup_id) group: vec3u, @builtin(local_invocation_index) lane: u32) {
485
855
  let i = folded_workgroup_index(group) * 64u + lane;
486
856
  if (i >= next.count) { return; }
487
- let particle = next.elements[i];
857
+ let drawn = draw[2u * i + 1u];
858
+ if (drawn == 0u) { return; }
859
+ let particle = water_particle_drawn(next.elements[i], drawn);
488
860
  // a paused body keeps the bounds its particles last had
489
861
  if ((activity[particle.body] & ${WaterActivity.Active}u) == 0u) { return; }
490
862
  let cells = water_particle_cells(particle, i);