reze-engine 0.57.6 → 0.58.1

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 (37) hide show
  1. package/dist/engine.d.ts +31 -0
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +231 -82
  4. package/dist/shaders/directives.d.ts +18 -1
  5. package/dist/shaders/directives.d.ts.map +1 -1
  6. package/dist/shaders/directives.js +22 -3
  7. package/dist/shaders/lights.d.ts +2 -10
  8. package/dist/shaders/lights.d.ts.map +1 -1
  9. package/dist/shaders/lights.js +4 -24
  10. package/dist/shaders/materials/common.d.ts.map +1 -1
  11. package/dist/shaders/materials/common.js +3 -17
  12. package/dist/shaders/passes/composite.d.ts.map +1 -1
  13. package/dist/shaders/passes/composite.js +2 -0
  14. package/dist/shaders/passes/grid.d.ts.map +1 -1
  15. package/dist/shaders/passes/grid.js +2 -0
  16. package/dist/shaders/passes/particles.d.ts +20 -2
  17. package/dist/shaders/passes/particles.d.ts.map +1 -1
  18. package/dist/shaders/passes/particles.js +77 -5
  19. package/dist/shaders/passes/trails.d.ts +3 -0
  20. package/dist/shaders/passes/trails.d.ts.map +1 -1
  21. package/dist/shaders/passes/trails.js +7 -1
  22. package/dist/shaders/scene-light-api.d.ts +42 -0
  23. package/dist/shaders/scene-light-api.d.ts.map +1 -0
  24. package/dist/shaders/scene-light-api.js +144 -0
  25. package/dist/shaders/shadow-api.d.ts +29 -0
  26. package/dist/shaders/shadow-api.d.ts.map +1 -0
  27. package/dist/shaders/shadow-api.js +112 -0
  28. package/package.json +1 -1
  29. package/src/engine.ts +272 -96
  30. package/src/shaders/directives.ts +37 -4
  31. package/src/shaders/lights.ts +4 -24
  32. package/src/shaders/materials/common.ts +3 -17
  33. package/src/shaders/passes/composite.ts +2 -0
  34. package/src/shaders/passes/grid.ts +2 -0
  35. package/src/shaders/passes/particles.ts +93 -6
  36. package/src/shaders/passes/trails.ts +10 -1
  37. package/src/shaders/scene-light-api.ts +148 -0
package/src/engine.ts CHANGED
@@ -113,6 +113,7 @@ import {
113
113
  import {
114
114
  buildParticleComputeShader,
115
115
  buildParticleRenderShader,
116
+ PARTICLE_LIGHT_BINDING,
116
117
  particleEntryPoints,
117
118
  PARTICLE_STRIDE,
118
119
  } from "./shaders/passes/particles"
@@ -1428,15 +1429,24 @@ interface EffectParticles {
1428
1429
  counts: Uint32Array
1429
1430
  compute: GPUComputePipeline
1430
1431
  computeLayout: GPUBindGroupLayout
1431
- computeBind: GPUBindGroup
1432
+ /** Indexed by the effect's GRID PARITY, so the pool samples the grid the grid
1433
+ * pass wrote this frame. Both entries are identical without a grid. */
1434
+ computeBinds: [GPUBindGroup, GPUBindGroup]
1435
+ /** The cutout's depth prepass, when the effect defined particleCover. Drawn
1436
+ * first, with the same bind group; `render` then tests equal. */
1437
+ depth: GPURenderPipeline | null
1432
1438
  render: GPURenderPipeline
1433
1439
  renderLayout: GPUBindGroupLayout
1434
- renderBind: GPUBindGroup
1440
+ renderBinds: [GPUBindGroup, GPUBindGroup]
1435
1441
  /** Same draw, the MIRRORED camera: how particles join the floor mirror. The
1436
1442
  * billboards face whichever eye is bound, so one extra bind group is the
1437
1443
  * whole cost. */
1438
- mirrorRenderBind: GPUBindGroup
1439
- rebind: () => { computeBind: GPUBindGroup; renderBind: GPUBindGroup; mirrorRenderBind: GPUBindGroup }
1444
+ mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
1445
+ rebind: () => {
1446
+ computeBinds: [GPUBindGroup, GPUBindGroup]
1447
+ renderBinds: [GPUBindGroup, GPUBindGroup]
1448
+ mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
1449
+ }
1440
1450
  }
1441
1451
 
1442
1452
  /**
@@ -1501,9 +1511,10 @@ interface EffectTrails {
1501
1511
  data: Float32Array
1502
1512
  pipeline: GPURenderPipeline
1503
1513
  layout: GPUBindGroupLayout
1504
- bind: GPUBindGroup
1514
+ /** Indexed by the effect's GRID PARITY, like the particle pool's. */
1515
+ binds: [GPUBindGroup, GPUBindGroup]
1505
1516
  /** The mirrored camera's view of the same ribbons — the floor mirror's. */
1506
- mirrorBind: GPUBindGroup
1517
+ mirrorBinds: [GPUBindGroup, GPUBindGroup]
1507
1518
  /** The effect's declared parameters, or null when it declares none.
1508
1519
  *
1509
1520
  * Kept for the same reason `layout` is: both bind groups are REBUILT on every
@@ -1675,6 +1686,8 @@ interface EffectInstance {
1675
1686
  * wins over the constant — see dissolveTimingsOf.
1676
1687
  */
1677
1688
  dissolve: DissolveTimings | null
1689
+ /** `#ground`: the floor this effect asks for — colour, linear, and grain. */
1690
+ ground: { color: Vec3; noise: number } | null
1678
1691
  /** This effect's OWN clock, as a uniform the field shader reads. Per effect
1679
1692
  * because the shared one (viewU[6].x) is measured from the first installed
1680
1693
  * effect's epoch, so everything later started mid-stream. Null when the
@@ -2063,7 +2076,12 @@ export class Engine {
2063
2076
  * prefix sum and readback a compacted draw list would need every frame.
2064
2077
  */
2065
2078
  /** Ceiling for `#particles`. Past this an author is asking for a stall. */
2066
- private static readonly MAX_PARTICLES = 65536
2079
+ // Raised for the lawn: 65536 blades at real density is a 40-unit patch and
2080
+ // an 80-unit one wants four hundred thousand. A cutout pool's cost is
2081
+ // bounded by the pixels it covers, not by the count — the step is one
2082
+ // compute invocation per slot, the pool buffer is 48 bytes each (24 MB at
2083
+ // the cap), and a culled blade is a degenerate quad the rasteriser drops.
2084
+ private static readonly MAX_PARTICLES = 524288
2067
2085
  private particleFrame = 0
2068
2086
  /**
2069
2087
  * The installed effect's persistent grid, or null when it declared none.
@@ -4306,9 +4324,9 @@ export class Engine {
4306
4324
  for (const e of this.effects) {
4307
4325
  if (e.particles) {
4308
4326
  const b = e.particles.rebind()
4309
- e.particles.computeBind = b.computeBind
4310
- e.particles.renderBind = b.renderBind
4311
- e.particles.mirrorRenderBind = b.mirrorRenderBind
4327
+ e.particles.computeBinds = b.computeBinds
4328
+ e.particles.renderBinds = b.renderBinds
4329
+ e.particles.mirrorRenderBinds = b.mirrorRenderBinds
4312
4330
  }
4313
4331
  if (e.grid) e.grid.binds = [this.gridBindGroup(e.grid, 0), this.gridBindGroup(e.grid, 1)]
4314
4332
  if (e.lights) e.lights.bind = this.lightEmitBindGroup(e.lights.layout, e.lights.uniform, e.lights.params)
@@ -4852,16 +4870,19 @@ export class Engine {
4852
4870
  trails?.uniform.destroy()
4853
4871
  return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false }
4854
4872
  }
4855
- if (wantsParticles) {
4856
- const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor)
4857
- if (!built.ok) return abandon(built.diagnostics)
4858
- particles = built.state
4859
- }
4873
+ // THE GRID FIRST. Particles and ribbons READ it — a blade of grass reads the
4874
+ // bend a foot left there — so the textures have to exist before their bind
4875
+ // groups are built. Nothing reads them back, so the grid depends on neither.
4860
4876
  if (gridEntryPoint(wgsl)) {
4861
4877
  const built = await this.buildSim(wgsl, d, anchors, alias, paramsFor)
4862
4878
  if (!built.ok) return abandon(built.diagnostics)
4863
4879
  grid = built.state
4864
4880
  }
4881
+ if (wantsParticles) {
4882
+ const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor, grid)
4883
+ if (!built.ok) return abandon(built.diagnostics)
4884
+ particles = built.state
4885
+ }
4865
4886
  if (wantsTrails) {
4866
4887
  // Only anchors that asked for `trail` have a path to draw; a ribbon on a
4867
4888
  // bone recorded without one would read zeroes and paint a line to the origin.
@@ -4869,7 +4890,7 @@ export class Engine {
4869
4890
  if (trailSlots === 0) {
4870
4891
  return abandon(["a ribbon effect needs at least one #anchor <bone> trail"])
4871
4892
  }
4872
- const built = await this.buildTrails(wgsl, d, anchors, alias, paramsFor)
4893
+ const built = await this.buildTrails(wgsl, d, anchors, alias, paramsFor, grid)
4873
4894
  if (!built.ok) return abandon(built.diagnostics)
4874
4895
  trails = built.state
4875
4896
  }
@@ -4949,6 +4970,7 @@ export class Engine {
4949
4970
  simStep: 0,
4950
4971
  simReset: false,
4951
4972
  dissolve: d.dissolve ? dissolveConstants(authored) : null,
4973
+ ground: d.ground ? { color: new Vec3(...d.ground.color), noise: d.ground.noise } : null,
4952
4974
  // Its OWN resolution, no longer the scene's: an effect that never asked
4953
4975
  // for full res is not promoted because a neighbour did.
4954
4976
  // FULL RESOLUTION UNLESS TOLD OTHERWISE.
@@ -5209,11 +5231,23 @@ export class Engine {
5209
5231
  /** The effect's declared dials. Spliced into both stages and bound at 7,
5210
5232
  * the same binding the composite gives them. */
5211
5233
  params: EffectParamsBinding,
5234
+ /** This effect's own grid, already built, or null when it declared none.
5235
+ * Read-only here: the pool samples it, the grid pass owns it. */
5236
+ grid: EffectGrid | null,
5212
5237
  ): Promise<{ ok: true; state: EffectParticles } | { ok: false; diagnostics: string[] }> {
5213
5238
  // No pragma means "some": an author who wrote the trio clearly wants
5214
5239
  // particles, and failing over a missing comment would be pedantry.
5215
5240
  const count = Math.min(d.particles || 1024, Engine.MAX_PARTICLES)
5216
- const src = { wgsl, count, blend: d.particleBlend, bloom: d.bloom, paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING) }
5241
+ const src = {
5242
+ wgsl,
5243
+ count,
5244
+ blend: d.particleBlend,
5245
+ bloom: d.bloom,
5246
+ paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
5247
+ gridSize: grid?.size ?? 0,
5248
+ cover: particleEntryPoints(wgsl).cover,
5249
+ }
5250
+ const prepass = src.blend === "cutout" && src.cover
5217
5251
  // Sparks want to spawn where a trail is, so the particle stages see the same
5218
5252
  // cast buffer the trail draw reads.
5219
5253
  const cast = {
@@ -5267,7 +5301,7 @@ export class Engine {
5267
5301
  // Declaring one set of flags for both layouts is what made the pipeline
5268
5302
  // layout invalid, and the error surfaces later and unhelpfully as "invalid
5269
5303
  // due to a previous error".
5270
- const layoutFor = (storage: GPUBufferBindingType, visibility: number) =>
5304
+ const layoutFor = (storage: GPUBufferBindingType, visibility: number, shadow: boolean) =>
5271
5305
  this.device.createBindGroupLayout({
5272
5306
  entries: [
5273
5307
  { binding: 0, visibility, buffer: { type: storage } },
@@ -5282,31 +5316,70 @@ export class Engine {
5282
5316
  // Only when the effect declared any: WGSL has no empty struct, so a
5283
5317
  // param-less effect must not carry the decl OR the binding.
5284
5318
  ...(params.buffer ? [{ binding: 7, visibility, buffer: { type: "uniform" as const } }] : []),
5319
+ // The grid, ALWAYS — the accessor is compiled in unconditionally, and a
5320
+ // binding the shader declares must exist in the layout whether the
5321
+ // author calls it or not. With no grid it is the 1x1 of zeroes.
5322
+ { binding: 8, visibility, texture: { sampleType: "float" as const } },
5323
+ { binding: 9, visibility, sampler: { type: "filtering" as const } },
5324
+ // The scene's light, for rzShadow and rzWorldAmbient — the shading
5325
+ // stage only; the compute module compiles the stubs. See
5326
+ // scene-light-api.ts.
5327
+ ...(shadow
5328
+ ? [
5329
+ { binding: PARTICLE_LIGHT_BINDING, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
5330
+ { binding: PARTICLE_LIGHT_BINDING + 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
5331
+ { binding: PARTICLE_LIGHT_BINDING + 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" as const } },
5332
+ { binding: PARTICLE_LIGHT_BINDING + 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" as const } },
5333
+ { binding: PARTICLE_LIGHT_BINDING + 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" as const } },
5334
+ ]
5335
+ : []),
5285
5336
  ],
5286
5337
  })
5287
- const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer) =>
5288
- this.device.createBindGroup({
5289
- layout,
5290
- entries: [
5291
- { binding: 0, resource: { buffer } },
5292
- { binding: 1, resource: { buffer: uniform } },
5293
- { binding: 2, resource: { buffer: camera } },
5294
- { binding: 3, resource: { buffer: this.castBuffer } },
5295
- { binding: 4, resource: { buffer: this.audioBuffer } },
5296
- { binding: 5, resource: { buffer: this.midiBuffer } },
5297
- { binding: 6, resource: { buffer: this.lyricsBuffer } },
5298
- ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
5299
- ],
5300
- })
5338
+ /** One per GRID PARITY, for the reason rebuildFieldBindGroup gives: the grid
5339
+ * alternates which texture is current, and rebuilding a single group every
5340
+ * frame is waste for a change that only ever toggles between two known
5341
+ * states. Both entries are the same view when there is no grid. */
5342
+ const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer, shadow: boolean) =>
5343
+ [0, 1].map((parity) =>
5344
+ this.device.createBindGroup({
5345
+ layout,
5346
+ entries: [
5347
+ { binding: 0, resource: { buffer } },
5348
+ { binding: 1, resource: { buffer: uniform } },
5349
+ { binding: 2, resource: { buffer: camera } },
5350
+ { binding: 3, resource: { buffer: this.castBuffer } },
5351
+ { binding: 4, resource: { buffer: this.audioBuffer } },
5352
+ { binding: 5, resource: { buffer: this.midiBuffer } },
5353
+ { binding: 6, resource: { buffer: this.lyricsBuffer } },
5354
+ ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
5355
+ { binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
5356
+ { binding: 9, resource: this.simSampler },
5357
+ ...(shadow
5358
+ ? [
5359
+ { binding: PARTICLE_LIGHT_BINDING, resource: { buffer: this.lightUniformBuffer } },
5360
+ { binding: PARTICLE_LIGHT_BINDING + 1, resource: { buffer: this.shadowLightVPBuffer } },
5361
+ { binding: PARTICLE_LIGHT_BINDING + 2, resource: this.shadowMapDepthViews[0] },
5362
+ { binding: PARTICLE_LIGHT_BINDING + 3, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
5363
+ { binding: PARTICLE_LIGHT_BINDING + 4, resource: this.shadowComparisonSampler },
5364
+ ]
5365
+ : []),
5366
+ ],
5367
+ }),
5368
+ ) as [GPUBindGroup, GPUBindGroup]
5301
5369
 
5302
- const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE)
5303
- const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT)
5370
+ const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false)
5371
+ const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true)
5304
5372
 
5305
5373
  // Additive keeps the destination and adds to it, and leaves alpha alone, so
5306
5374
  // a glow does not claim coverage it never occluded. The MASK sums with it —
5307
5375
  // otherwise an additive effect could never reach the bloom gate. Both live
5308
5376
  // in scene-contract as the "particle-additive" class.
5377
+ //
5378
+ // Cutout blends exactly like alpha and differs only in depth and coverage,
5379
+ // below — so it shares alpha's targets rather than adding a class whose
5380
+ // blends would be a copy.
5309
5381
  const targets = sceneTargetsFor(src.blend === "additive" ? "particle-additive" : "particle", this.sceneFormats)
5382
+ const cutout = src.blend === "cutout"
5310
5383
 
5311
5384
  this.device.pushErrorScope("validation")
5312
5385
  try {
@@ -5315,15 +5388,41 @@ export class Engine {
5315
5388
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
5316
5389
  compute: { module: computeModule, entryPoint: "main" },
5317
5390
  })
5391
+ const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] })
5392
+ // The prepass, when the effect gave the cutout a cheap shape: coverage to
5393
+ // depth, colour targets at writeMask 0 (the depth-prepass class), and
5394
+ // the shading pass below then tests EQUAL and writes no depth.
5395
+ const depth = prepass
5396
+ ? await this.device.createRenderPipelineAsync({
5397
+ label: "particle depth prepass pipeline",
5398
+ layout: renderLayoutObj,
5399
+ vertex: { module: renderModule, entryPoint: "vs" },
5400
+ fragment: { module: renderModule, entryPoint: "fsDepth", targets: sceneTargetsFor("depth-prepass", this.sceneFormats) },
5401
+ primitive: { topology: "triangle-list", cullMode: "none" },
5402
+ depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
5403
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
5404
+ })
5405
+ : null
5318
5406
  const render = await this.device.createRenderPipelineAsync({
5319
5407
  label: "particle render pipeline",
5320
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] }),
5408
+ layout: renderLayoutObj,
5321
5409
  vertex: { module: renderModule, entryPoint: "vs" },
5322
5410
  fragment: { module: renderModule, entryPoint: "fs", targets },
5323
5411
  primitive: { topology: "triangle-list", cullMode: "none" },
5324
5412
  // Tested but not WRITTEN: particles are transparent, so writing depth
5325
5413
  // would make whichever quad drew first occlude the ones behind it.
5326
- depthStencil: { format: this.depthFormat, depthWriteEnabled: false, depthCompare: this.depthAhead },
5414
+ //
5415
+ // Except a CUTOUT, which is opaque where it is drawn at all. Its alpha
5416
+ // becomes per-sample coverage (in the shader — see FSOut.samples, the
5417
+ // HDR target has no alpha for the hardware path), so its edges stay
5418
+ // antialiased under MSAA while its depth is real — and a blade behind
5419
+ // another blade is then rejected at the depth test rather than shaded
5420
+ // and blended under it.
5421
+ // For a lawn stacked ten blades deep that is the difference between
5422
+ // shading ten layers and shading about three.
5423
+ depthStencil: prepass
5424
+ ? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
5425
+ : { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
5327
5426
  multisample: { count: Engine.MULTISAMPLE_COUNT },
5328
5427
  })
5329
5428
  const scoped = await this.device.popErrorScope()
@@ -5345,15 +5444,16 @@ export class Engine {
5345
5444
  counts: uniformView.uints,
5346
5445
  compute,
5347
5446
  computeLayout,
5348
- computeBind: bindFor(computeLayout, this.cameraUniformBuffer),
5447
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
5448
+ depth,
5349
5449
  render,
5350
5450
  renderLayout,
5351
- renderBind: bindFor(renderLayout, this.cameraUniformBuffer),
5352
- mirrorRenderBind: bindFor(renderLayout, this.mirrorCameraBuffer),
5451
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
5452
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
5353
5453
  rebind: () => ({
5354
- computeBind: bindFor(computeLayout, this.cameraUniformBuffer),
5355
- renderBind: bindFor(renderLayout, this.cameraUniformBuffer),
5356
- mirrorRenderBind: bindFor(renderLayout, this.mirrorCameraBuffer),
5454
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
5455
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
5456
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
5357
5457
  }),
5358
5458
  },
5359
5459
  }
@@ -5553,7 +5653,7 @@ export class Engine {
5553
5653
  this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer as ArrayBuffer)
5554
5654
  const cp = encoder.beginComputePass({ label: "particles" })
5555
5655
  cp.setPipeline(p.compute)
5556
- cp.setBindGroup(0, p.computeBind)
5656
+ cp.setBindGroup(0, p.computeBinds[e.grid?.parity ?? 0])
5557
5657
  cp.dispatchWorkgroups(Math.ceil(p.count / 64))
5558
5658
  cp.end()
5559
5659
  }
@@ -5564,8 +5664,15 @@ export class Engine {
5564
5664
  for (const e of this.effects) {
5565
5665
  const p = e.particles
5566
5666
  if (!p || e.weight === 0) continue
5667
+ const parity = e.grid?.parity ?? 0
5668
+ const bind = view === "mirror" ? p.mirrorRenderBinds[parity] : p.renderBinds[parity]
5669
+ if (p.depth) {
5670
+ pass.setPipeline(p.depth)
5671
+ pass.setBindGroup(0, bind)
5672
+ pass.draw(6, p.count)
5673
+ }
5567
5674
  pass.setPipeline(p.render)
5568
- pass.setBindGroup(0, view === "mirror" ? p.mirrorRenderBind : p.renderBind)
5675
+ pass.setBindGroup(0, bind)
5569
5676
  pass.draw(6, p.count)
5570
5677
  }
5571
5678
  }
@@ -5590,6 +5697,9 @@ export class Engine {
5590
5697
  alias: number[],
5591
5698
  /** The effect's declared dials, spliced and bound at 7 as everywhere else. */
5592
5699
  params: EffectParamsBinding,
5700
+ /** This effect's own grid, already built, or null. Ribbons read it; the grid
5701
+ * pass owns it. */
5702
+ grid: EffectGrid | null,
5593
5703
  ): Promise<{ ok: true; state: EffectTrails } | { ok: false; diagnostics: string[] }> {
5594
5704
  // `slots` here is how many RIBBONS to draw — one per trailed anchor — which
5595
5705
  // is a different number from the anchor ADDRESS SPACE the accessors index
@@ -5600,7 +5710,15 @@ export class Engine {
5600
5710
  // nothing before: ribbon i was read as anchor slot i.
5601
5711
  const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0)
5602
5712
  const slots = ribbonSlots.length
5603
- const src = { wgsl, slots, ribbonSlots, blend: d.particleBlend, bloom: d.bloom, paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING) }
5713
+ const src = {
5714
+ wgsl,
5715
+ slots,
5716
+ ribbonSlots,
5717
+ blend: (d.particleBlend === "additive" ? "additive" : "alpha") as "alpha" | "additive",
5718
+ bloom: d.bloom,
5719
+ paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
5720
+ gridSize: grid?.size ?? 0,
5721
+ }
5604
5722
  const code = buildTrailShader(src, {
5605
5723
  subjects: MAX_EFFECT_SUBJECTS,
5606
5724
  samples: TRAIL_SAMPLES,
@@ -5647,6 +5765,13 @@ export class Engine {
5647
5765
  ...(params.buffer
5648
5766
  ? [{ binding: 7, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } }]
5649
5767
  : []),
5768
+ // The grid, always — compiled in whether the author calls it or not.
5769
+ {
5770
+ binding: 8,
5771
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
5772
+ texture: { sampleType: "float" as const },
5773
+ },
5774
+ { binding: 9, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, sampler: { type: "filtering" as const } },
5650
5775
  ],
5651
5776
  })
5652
5777
  // TWO targets, the scene pass's own: HDR colour and the aux (bloom mask,
@@ -5692,30 +5817,8 @@ export class Engine {
5692
5817
  data: new Float32Array(4),
5693
5818
  pipeline,
5694
5819
  layout,
5695
- bind: this.device.createBindGroup({
5696
- layout,
5697
- entries: [
5698
- { binding: 0, resource: { buffer: this.castBuffer } },
5699
- { binding: 1, resource: { buffer: uniform } },
5700
- { binding: 2, resource: { buffer: this.cameraUniformBuffer } },
5701
- { binding: 4, resource: { buffer: this.audioBuffer } },
5702
- { binding: 5, resource: { buffer: this.midiBuffer } },
5703
- { binding: 6, resource: { buffer: this.lyricsBuffer } },
5704
- ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
5705
- ],
5706
- }),
5707
- mirrorBind: this.device.createBindGroup({
5708
- layout,
5709
- entries: [
5710
- { binding: 0, resource: { buffer: this.castBuffer } },
5711
- { binding: 1, resource: { buffer: uniform } },
5712
- { binding: 2, resource: { buffer: this.mirrorCameraBuffer } },
5713
- { binding: 4, resource: { buffer: this.audioBuffer } },
5714
- { binding: 5, resource: { buffer: this.midiBuffer } },
5715
- { binding: 6, resource: { buffer: this.lyricsBuffer } },
5716
- ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
5717
- ],
5718
- }),
5820
+ binds: this.trailBindGroups(layout, uniform, params.buffer, this.cameraUniformBuffer, grid),
5821
+ mirrorBinds: this.trailBindGroups(layout, uniform, params.buffer, this.mirrorCameraBuffer, grid),
5719
5822
  },
5720
5823
  }
5721
5824
  } catch (e) {
@@ -5776,7 +5879,8 @@ export class Engine {
5776
5879
  this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
5777
5880
  }
5778
5881
  pass.setPipeline(t.pipeline)
5779
- pass.setBindGroup(0, view === "mirror" ? t.mirrorBind : t.bind)
5882
+ const parity = e.grid?.parity ?? 0
5883
+ pass.setBindGroup(0, view === "mirror" ? t.mirrorBinds[parity] : t.binds[parity])
5780
5884
  pass.draw(6, t.slots * live * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS)
5781
5885
  }
5782
5886
  }
@@ -5904,34 +6008,45 @@ export class Engine {
5904
6008
  // one, so it has to be part of every group built against that layout —
5905
6009
  // including this one. Omitting it here is not a missing uniform, it is a
5906
6010
  // group WebGPU refuses outright, which takes the whole frame down.
5907
- const paramsEntry = t.params ? [{ binding: EFFECT_PARAMS_BINDING, resource: { buffer: t.params } }] : []
5908
- t.bind = this.device.createBindGroup({
5909
- layout: t.layout,
5910
- entries: [
5911
- { binding: 0, resource: { buffer: this.castBuffer } },
5912
- { binding: 1, resource: { buffer: t.uniform } },
5913
- { binding: 2, resource: { buffer: this.cameraUniformBuffer } },
5914
- { binding: 4, resource: { buffer: this.audioBuffer } },
5915
- { binding: 5, resource: { buffer: this.midiBuffer } },
5916
- { binding: 6, resource: { buffer: this.lyricsBuffer } },
5917
- ...paramsEntry,
5918
- ],
5919
- })
5920
- t.mirrorBind = this.device.createBindGroup({
5921
- layout: t.layout,
5922
- entries: [
5923
- { binding: 0, resource: { buffer: this.castBuffer } },
5924
- { binding: 1, resource: { buffer: t.uniform } },
5925
- { binding: 2, resource: { buffer: this.mirrorCameraBuffer } },
5926
- { binding: 4, resource: { buffer: this.audioBuffer } },
5927
- { binding: 5, resource: { buffer: this.midiBuffer } },
5928
- { binding: 6, resource: { buffer: this.lyricsBuffer } },
5929
- ...paramsEntry,
5930
- ],
5931
- })
6011
+ t.binds = this.trailBindGroups(t.layout, t.uniform, t.params, this.cameraUniformBuffer, e.grid)
6012
+ t.mirrorBinds = this.trailBindGroups(t.layout, t.uniform, t.params, this.mirrorCameraBuffer, e.grid)
5932
6013
  }
5933
6014
  }
5934
6015
 
6016
+ /**
6017
+ * A ribbon's bind group, ONE PER GRID PARITY.
6018
+ *
6019
+ * One author for both the build and the rebind, because they drifted once
6020
+ * already: the params binding is part of the LAYOUT whenever the effect
6021
+ * declared one, so it has to be part of every group built against that
6022
+ * layout, and a group missing it is not a missing uniform but one WebGPU
6023
+ * refuses outright, taking the whole frame down.
6024
+ */
6025
+ private trailBindGroups(
6026
+ layout: GPUBindGroupLayout,
6027
+ uniform: GPUBuffer,
6028
+ params: GPUBuffer | null,
6029
+ camera: GPUBuffer,
6030
+ grid: EffectGrid | null,
6031
+ ): [GPUBindGroup, GPUBindGroup] {
6032
+ return [0, 1].map((parity) =>
6033
+ this.device.createBindGroup({
6034
+ layout,
6035
+ entries: [
6036
+ { binding: 0, resource: { buffer: this.castBuffer } },
6037
+ { binding: 1, resource: { buffer: uniform } },
6038
+ { binding: 2, resource: { buffer: camera } },
6039
+ { binding: 4, resource: { buffer: this.audioBuffer } },
6040
+ { binding: 5, resource: { buffer: this.midiBuffer } },
6041
+ { binding: 6, resource: { buffer: this.lyricsBuffer } },
6042
+ ...(params ? [{ binding: EFFECT_PARAMS_BINDING, resource: { buffer: params } }] : []),
6043
+ { binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
6044
+ { binding: 9, resource: this.simSampler },
6045
+ ],
6046
+ }),
6047
+ ) as [GPUBindGroup, GPUBindGroup]
6048
+ }
6049
+
5935
6050
  private releaseParticles(): void {
5936
6051
  for (const e of this.effects) {
5937
6052
  e.particles?.buffer.destroy()
@@ -7294,8 +7409,10 @@ export class Engine {
7294
7409
  },
7295
7410
  })
7296
7411
 
7412
+ // The matrices, then one vec4: the caster sphere, which effects' rzShadow
7413
+ // tests before any tap. The materials and the ground bind the matrices only.
7297
7414
  this.shadowLightVPBuffer = this.device.createBuffer({
7298
- size: 64 * SHADOW_CASCADES.length,
7415
+ size: 64 * SHADOW_CASCADES.length + 16,
7299
7416
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7300
7417
  })
7301
7418
  this.shadowCascadeVPBuffers = SHADOW_CASCADES.map((_, i) =>
@@ -12247,6 +12364,61 @@ export class Engine {
12247
12364
  /** The ground's uniform block, kept so the caster sphere can be refreshed in
12248
12365
  * it every frame rather than rebuilding the buffer (addGround allocates). */
12249
12366
  private groundMaterialData: Float32Array | null = null
12367
+ /** What addGround was given, so an effect's `#ground` can be lifted again
12368
+ * without rebuilding the floor. */
12369
+ private groundBaseColor = new Vec3(1, 1, 1)
12370
+ private groundBaseNoise = 0
12371
+
12372
+ /**
12373
+ * Dress the floor in whatever the installed effects ask for.
12374
+ *
12375
+ * The LAST effect declaring `#ground` wins, faded by its own weight so a
12376
+ * scheduled lawn brings its soil in with it and takes it away again. Three
12377
+ * floats at the top of the ground block, written only when they change —
12378
+ * the same bargain writeGroundCasterSphere strikes, for the same reason.
12379
+ */
12380
+ private writeGroundDress(): void {
12381
+ const gb = this.groundMaterialData
12382
+ if (!gb || !this.groundShadowMaterialBuffer) return
12383
+ let want = this.groundBaseColor
12384
+ let noise = this.groundBaseNoise
12385
+ for (const e of this.effects) {
12386
+ if (!e.ground || e.weight <= 0) continue
12387
+ const w = e.weight
12388
+ want = new Vec3(
12389
+ want.x + (e.ground.color.x - want.x) * w,
12390
+ want.y + (e.ground.color.y - want.y) * w,
12391
+ want.z + (e.ground.color.z - want.z) * w,
12392
+ )
12393
+ noise += (e.ground.noise - noise) * w
12394
+ }
12395
+ if (gb[0] === want.x && gb[1] === want.y && gb[2] === want.z && gb[10] === noise) return
12396
+ gb[0] = want.x
12397
+ gb[1] = want.y
12398
+ gb[2] = want.z
12399
+ gb[10] = noise
12400
+ // Two writes rather than one over the span: the seven floats between hold
12401
+ // the fades and the shadow strength, and this is not the place that owns
12402
+ // them.
12403
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb.subarray(0, 3) as Float32Array<ArrayBuffer>)
12404
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 40, gb.subarray(10, 11) as Float32Array<ArrayBuffer>)
12405
+ }
12406
+
12407
+ /** The caster sphere as last written after the cascade matrices; NaN so the
12408
+ * first frame always writes. */
12409
+ private shadowCastersWritten = new Float32Array([NaN, NaN, NaN, NaN])
12410
+
12411
+ /**
12412
+ * The same sphere, into the cascade block after its matrices — rzShadow's
12413
+ * copy. There whether or not a ground exists: a lawn is often the only floor.
12414
+ */
12415
+ private writeShadowCasters(): void {
12416
+ const w = this.shadowCastersWritten
12417
+ const c = this.casterSphere
12418
+ if (w[0] === c[0] && w[1] === c[1] && w[2] === c[2] && w[3] === c[3]) return
12419
+ w.set(c)
12420
+ this.device.queue.writeBuffer(this.shadowLightVPBuffer, 64 * SHADOW_CASCADES.length, c as Float32Array<ArrayBuffer>)
12421
+ }
12250
12422
 
12251
12423
  /**
12252
12424
  * Push this frame's caster sphere into the ground's uniform.
@@ -13221,6 +13393,8 @@ export class Engine {
13221
13393
  // keeping the uniform vec4-aligned.
13222
13394
  const gb = new Float32Array(24)
13223
13395
  this.groundMaterialData = gb
13396
+ this.groundBaseColor = new Vec3(diffuseColor.x, diffuseColor.y, diffuseColor.z)
13397
+ this.groundBaseNoise = noiseStrength
13224
13398
  gb[0] = diffuseColor.x
13225
13399
  gb[1] = diffuseColor.y
13226
13400
  gb[2] = diffuseColor.z
@@ -15242,6 +15416,8 @@ export class Engine {
15242
15416
  if (hasModels) this.dispatchCull(encoder)
15243
15417
  // After the cull, which is what recomputes the spheres it unions.
15244
15418
  this.writeGroundCasterSphere()
15419
+ this.writeShadowCasters()
15420
+ this.writeGroundDress()
15245
15421
 
15246
15422
  // After the cull, because a rebuild there can reallocate the argument
15247
15423
  // buffers and a bundle captures the buffer it recorded against.
@@ -48,13 +48,27 @@ export type EffectDirectives = {
48
48
  /** The field layer composites additively rather than over. */
49
49
  additiveLayer: boolean
50
50
  /** Particle blend, which is a different axis from the field layer's. */
51
- particleBlend: "alpha" | "additive"
51
+ /** `cutout` is alpha WITH DEPTH: the pipeline writes depth and turns the
52
+ * fragment's alpha into MSAA sample coverage, so a blade of grass behind
53
+ * another is rejected by the depth test instead of blended under it. What
54
+ * every real grass renderer does, and the only thing that bounds a dense
55
+ * lawn's cost by the pixels it covers rather than by how deep it stacks. */
56
+ particleBlend: "alpha" | "additive" | "cutout"
52
57
  particles: number
53
58
  lights: number
54
59
  grid: number
55
60
  bloom: boolean
56
61
  /** This effect takes the cast apart — the host reads the timing. */
57
62
  dissolve: boolean
63
+ /** `#ground #rrggbb [noise]`: how this effect DRESSES THE FLOOR while it is
64
+ * installed — a colour as linear RGB, and the strength of the ground's own
65
+ * grain (0 flat, 1 strong), which is what keeps soil from being a swatch.
66
+ * A lawn wants earth under it, and the earth has to be the engine's own
67
+ * ground rather than a layer painted over the frame: the ground is geometry
68
+ * in the scene pass, so a shadow lands on it and it tone-maps with
69
+ * everything else. The scene's own floor returns when the effect goes.
70
+ * Null when the file declares none. */
71
+ ground: { color: [number, number, number]; noise: number } | null
58
72
  /**
59
73
  * This effect IS a mirror: a plane in the scene showing a true reflection of
60
74
  * it, folded and re-rendered by the engine.
@@ -101,6 +115,7 @@ const SPEC = {
101
115
  dissolve: 0,
102
116
  mirror: 0,
103
117
  duration: 1,
118
+ ground: "rest",
104
119
  } as const
105
120
 
106
121
  /** A line that declares something, and what it declares. Exported because an
@@ -155,6 +170,7 @@ export function parseDirectives(wgsl: string): DirectiveResult {
155
170
  grid: 0,
156
171
  bloom: false,
157
172
  dissolve: false,
173
+ ground: null,
158
174
  mirror: false,
159
175
  duration: 0,
160
176
  }
@@ -240,15 +256,32 @@ export function parseDirectives(wgsl: string): DirectiveResult {
240
256
  d.additiveLayer = true
241
257
  return
242
258
  case "blend":
243
- if (args[0] !== "additive") {
244
- errors.push(`${at}: #blend takes "additive" — alpha is the default`)
259
+ if (args[0] !== "additive" && args[0] !== "cutout") {
260
+ errors.push(`${at}: #blend takes "additive" or "cutout" — alpha is the default`)
245
261
  return
246
262
  }
247
- d.particleBlend = "additive"
263
+ d.particleBlend = args[0]
248
264
  return
249
265
  case "bloom":
250
266
  d.bloom = true
251
267
  return
268
+ case "ground": {
269
+ const m = /^#([0-9a-fA-F]{6})$/.exec(args[0] ?? "")
270
+ const noise = args[1] === undefined ? 0 : num(args[1])
271
+ if (!m || args.length > 2 || noise === null) {
272
+ errors.push(`${at}: #ground takes a colour like #4b4026 and optionally a grain strength 0..1`)
273
+ return
274
+ }
275
+ const v = parseInt(m[1], 16)
276
+ // sRGB in the file, linear in the uniform — the same transfer the
277
+ // ground's own colour goes through on the way in.
278
+ const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))
279
+ d.ground = {
280
+ color: [lin(((v >> 16) & 255) / 255), lin(((v >> 8) & 255) / 255), lin((v & 255) / 255)],
281
+ noise: Math.min(Math.max(noise, 0), 1),
282
+ }
283
+ return
284
+ }
252
285
  case "dissolve":
253
286
  d.dissolve = true
254
287
  return