reze-engine 0.57.5 → 0.58.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.
package/src/engine.ts CHANGED
@@ -1428,15 +1428,24 @@ interface EffectParticles {
1428
1428
  counts: Uint32Array
1429
1429
  compute: GPUComputePipeline
1430
1430
  computeLayout: GPUBindGroupLayout
1431
- computeBind: GPUBindGroup
1431
+ /** Indexed by the effect's GRID PARITY, so the pool samples the grid the grid
1432
+ * pass wrote this frame. Both entries are identical without a grid. */
1433
+ computeBinds: [GPUBindGroup, GPUBindGroup]
1434
+ /** The cutout's depth prepass, when the effect defined particleCover. Drawn
1435
+ * first, with the same bind group; `render` then tests equal. */
1436
+ depth: GPURenderPipeline | null
1432
1437
  render: GPURenderPipeline
1433
1438
  renderLayout: GPUBindGroupLayout
1434
- renderBind: GPUBindGroup
1439
+ renderBinds: [GPUBindGroup, GPUBindGroup]
1435
1440
  /** Same draw, the MIRRORED camera: how particles join the floor mirror. The
1436
1441
  * billboards face whichever eye is bound, so one extra bind group is the
1437
1442
  * whole cost. */
1438
- mirrorRenderBind: GPUBindGroup
1439
- rebind: () => { computeBind: GPUBindGroup; renderBind: GPUBindGroup; mirrorRenderBind: GPUBindGroup }
1443
+ mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
1444
+ rebind: () => {
1445
+ computeBinds: [GPUBindGroup, GPUBindGroup]
1446
+ renderBinds: [GPUBindGroup, GPUBindGroup]
1447
+ mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
1448
+ }
1440
1449
  }
1441
1450
 
1442
1451
  /**
@@ -1501,9 +1510,10 @@ interface EffectTrails {
1501
1510
  data: Float32Array
1502
1511
  pipeline: GPURenderPipeline
1503
1512
  layout: GPUBindGroupLayout
1504
- bind: GPUBindGroup
1513
+ /** Indexed by the effect's GRID PARITY, like the particle pool's. */
1514
+ binds: [GPUBindGroup, GPUBindGroup]
1505
1515
  /** The mirrored camera's view of the same ribbons — the floor mirror's. */
1506
- mirrorBind: GPUBindGroup
1516
+ mirrorBinds: [GPUBindGroup, GPUBindGroup]
1507
1517
  /** The effect's declared parameters, or null when it declares none.
1508
1518
  *
1509
1519
  * Kept for the same reason `layout` is: both bind groups are REBUILT on every
@@ -1675,6 +1685,8 @@ interface EffectInstance {
1675
1685
  * wins over the constant — see dissolveTimingsOf.
1676
1686
  */
1677
1687
  dissolve: DissolveTimings | null
1688
+ /** `#ground`: the floor this effect asks for — colour, linear, and grain. */
1689
+ ground: { color: Vec3; noise: number } | null
1678
1690
  /** This effect's OWN clock, as a uniform the field shader reads. Per effect
1679
1691
  * because the shared one (viewU[6].x) is measured from the first installed
1680
1692
  * effect's epoch, so everything later started mid-stream. Null when the
@@ -2063,7 +2075,12 @@ export class Engine {
2063
2075
  * prefix sum and readback a compacted draw list would need every frame.
2064
2076
  */
2065
2077
  /** Ceiling for `#particles`. Past this an author is asking for a stall. */
2066
- private static readonly MAX_PARTICLES = 65536
2078
+ // Raised for the lawn: 65536 blades at real density is a 40-unit patch and
2079
+ // an 80-unit one wants four hundred thousand. A cutout pool's cost is
2080
+ // bounded by the pixels it covers, not by the count — the step is one
2081
+ // compute invocation per slot, the pool buffer is 48 bytes each (24 MB at
2082
+ // the cap), and a culled blade is a degenerate quad the rasteriser drops.
2083
+ private static readonly MAX_PARTICLES = 524288
2067
2084
  private particleFrame = 0
2068
2085
  /**
2069
2086
  * The installed effect's persistent grid, or null when it declared none.
@@ -4306,9 +4323,9 @@ export class Engine {
4306
4323
  for (const e of this.effects) {
4307
4324
  if (e.particles) {
4308
4325
  const b = e.particles.rebind()
4309
- e.particles.computeBind = b.computeBind
4310
- e.particles.renderBind = b.renderBind
4311
- e.particles.mirrorRenderBind = b.mirrorRenderBind
4326
+ e.particles.computeBinds = b.computeBinds
4327
+ e.particles.renderBinds = b.renderBinds
4328
+ e.particles.mirrorRenderBinds = b.mirrorRenderBinds
4312
4329
  }
4313
4330
  if (e.grid) e.grid.binds = [this.gridBindGroup(e.grid, 0), this.gridBindGroup(e.grid, 1)]
4314
4331
  if (e.lights) e.lights.bind = this.lightEmitBindGroup(e.lights.layout, e.lights.uniform, e.lights.params)
@@ -4852,16 +4869,19 @@ export class Engine {
4852
4869
  trails?.uniform.destroy()
4853
4870
  return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false }
4854
4871
  }
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
- }
4872
+ // THE GRID FIRST. Particles and ribbons READ it — a blade of grass reads the
4873
+ // bend a foot left there — so the textures have to exist before their bind
4874
+ // groups are built. Nothing reads them back, so the grid depends on neither.
4860
4875
  if (gridEntryPoint(wgsl)) {
4861
4876
  const built = await this.buildSim(wgsl, d, anchors, alias, paramsFor)
4862
4877
  if (!built.ok) return abandon(built.diagnostics)
4863
4878
  grid = built.state
4864
4879
  }
4880
+ if (wantsParticles) {
4881
+ const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor, grid)
4882
+ if (!built.ok) return abandon(built.diagnostics)
4883
+ particles = built.state
4884
+ }
4865
4885
  if (wantsTrails) {
4866
4886
  // Only anchors that asked for `trail` have a path to draw; a ribbon on a
4867
4887
  // bone recorded without one would read zeroes and paint a line to the origin.
@@ -4869,7 +4889,7 @@ export class Engine {
4869
4889
  if (trailSlots === 0) {
4870
4890
  return abandon(["a ribbon effect needs at least one #anchor <bone> trail"])
4871
4891
  }
4872
- const built = await this.buildTrails(wgsl, d, anchors, alias, paramsFor)
4892
+ const built = await this.buildTrails(wgsl, d, anchors, alias, paramsFor, grid)
4873
4893
  if (!built.ok) return abandon(built.diagnostics)
4874
4894
  trails = built.state
4875
4895
  }
@@ -4949,6 +4969,7 @@ export class Engine {
4949
4969
  simStep: 0,
4950
4970
  simReset: false,
4951
4971
  dissolve: d.dissolve ? dissolveConstants(authored) : null,
4972
+ ground: d.ground ? { color: new Vec3(...d.ground.color), noise: d.ground.noise } : null,
4952
4973
  // Its OWN resolution, no longer the scene's: an effect that never asked
4953
4974
  // for full res is not promoted because a neighbour did.
4954
4975
  // FULL RESOLUTION UNLESS TOLD OTHERWISE.
@@ -5209,11 +5230,23 @@ export class Engine {
5209
5230
  /** The effect's declared dials. Spliced into both stages and bound at 7,
5210
5231
  * the same binding the composite gives them. */
5211
5232
  params: EffectParamsBinding,
5233
+ /** This effect's own grid, already built, or null when it declared none.
5234
+ * Read-only here: the pool samples it, the grid pass owns it. */
5235
+ grid: EffectGrid | null,
5212
5236
  ): Promise<{ ok: true; state: EffectParticles } | { ok: false; diagnostics: string[] }> {
5213
5237
  // No pragma means "some": an author who wrote the trio clearly wants
5214
5238
  // particles, and failing over a missing comment would be pedantry.
5215
5239
  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) }
5240
+ const src = {
5241
+ wgsl,
5242
+ count,
5243
+ blend: d.particleBlend,
5244
+ bloom: d.bloom,
5245
+ paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
5246
+ gridSize: grid?.size ?? 0,
5247
+ cover: particleEntryPoints(wgsl).cover,
5248
+ }
5249
+ const prepass = src.blend === "cutout" && src.cover
5217
5250
  // Sparks want to spawn where a trail is, so the particle stages see the same
5218
5251
  // cast buffer the trail draw reads.
5219
5252
  const cast = {
@@ -5282,22 +5315,35 @@ export class Engine {
5282
5315
  // Only when the effect declared any: WGSL has no empty struct, so a
5283
5316
  // param-less effect must not carry the decl OR the binding.
5284
5317
  ...(params.buffer ? [{ binding: 7, visibility, buffer: { type: "uniform" as const } }] : []),
5318
+ // The grid, ALWAYS — the accessor is compiled in unconditionally, and a
5319
+ // binding the shader declares must exist in the layout whether the
5320
+ // author calls it or not. With no grid it is the 1x1 of zeroes.
5321
+ { binding: 8, visibility, texture: { sampleType: "float" as const } },
5322
+ { binding: 9, visibility, sampler: { type: "filtering" as const } },
5285
5323
  ],
5286
5324
  })
5325
+ /** One per GRID PARITY, for the reason rebuildFieldBindGroup gives: the grid
5326
+ * alternates which texture is current, and rebuilding a single group every
5327
+ * frame is waste for a change that only ever toggles between two known
5328
+ * states. Both entries are the same view when there is no grid. */
5287
5329
  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
- })
5330
+ [0, 1].map((parity) =>
5331
+ this.device.createBindGroup({
5332
+ layout,
5333
+ entries: [
5334
+ { binding: 0, resource: { buffer } },
5335
+ { binding: 1, resource: { buffer: uniform } },
5336
+ { binding: 2, resource: { buffer: camera } },
5337
+ { binding: 3, resource: { buffer: this.castBuffer } },
5338
+ { binding: 4, resource: { buffer: this.audioBuffer } },
5339
+ { binding: 5, resource: { buffer: this.midiBuffer } },
5340
+ { binding: 6, resource: { buffer: this.lyricsBuffer } },
5341
+ ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
5342
+ { binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
5343
+ { binding: 9, resource: this.simSampler },
5344
+ ],
5345
+ }),
5346
+ ) as [GPUBindGroup, GPUBindGroup]
5301
5347
 
5302
5348
  const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE)
5303
5349
  const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT)
@@ -5306,7 +5352,12 @@ export class Engine {
5306
5352
  // a glow does not claim coverage it never occluded. The MASK sums with it —
5307
5353
  // otherwise an additive effect could never reach the bloom gate. Both live
5308
5354
  // in scene-contract as the "particle-additive" class.
5355
+ //
5356
+ // Cutout blends exactly like alpha and differs only in depth and coverage,
5357
+ // below — so it shares alpha's targets rather than adding a class whose
5358
+ // blends would be a copy.
5309
5359
  const targets = sceneTargetsFor(src.blend === "additive" ? "particle-additive" : "particle", this.sceneFormats)
5360
+ const cutout = src.blend === "cutout"
5310
5361
 
5311
5362
  this.device.pushErrorScope("validation")
5312
5363
  try {
@@ -5315,15 +5366,41 @@ export class Engine {
5315
5366
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
5316
5367
  compute: { module: computeModule, entryPoint: "main" },
5317
5368
  })
5369
+ const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] })
5370
+ // The prepass, when the effect gave the cutout a cheap shape: coverage to
5371
+ // depth, colour targets at writeMask 0 (the depth-prepass class), and
5372
+ // the shading pass below then tests EQUAL and writes no depth.
5373
+ const depth = prepass
5374
+ ? await this.device.createRenderPipelineAsync({
5375
+ label: "particle depth prepass pipeline",
5376
+ layout: renderLayoutObj,
5377
+ vertex: { module: renderModule, entryPoint: "vs" },
5378
+ fragment: { module: renderModule, entryPoint: "fsDepth", targets: sceneTargetsFor("depth-prepass", this.sceneFormats) },
5379
+ primitive: { topology: "triangle-list", cullMode: "none" },
5380
+ depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
5381
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
5382
+ })
5383
+ : null
5318
5384
  const render = await this.device.createRenderPipelineAsync({
5319
5385
  label: "particle render pipeline",
5320
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] }),
5386
+ layout: renderLayoutObj,
5321
5387
  vertex: { module: renderModule, entryPoint: "vs" },
5322
5388
  fragment: { module: renderModule, entryPoint: "fs", targets },
5323
5389
  primitive: { topology: "triangle-list", cullMode: "none" },
5324
5390
  // Tested but not WRITTEN: particles are transparent, so writing depth
5325
5391
  // would make whichever quad drew first occlude the ones behind it.
5326
- depthStencil: { format: this.depthFormat, depthWriteEnabled: false, depthCompare: this.depthAhead },
5392
+ //
5393
+ // Except a CUTOUT, which is opaque where it is drawn at all. Its alpha
5394
+ // becomes per-sample coverage (in the shader — see FSOut.samples, the
5395
+ // HDR target has no alpha for the hardware path), so its edges stay
5396
+ // antialiased under MSAA while its depth is real — and a blade behind
5397
+ // another blade is then rejected at the depth test rather than shaded
5398
+ // and blended under it.
5399
+ // For a lawn stacked ten blades deep that is the difference between
5400
+ // shading ten layers and shading about three.
5401
+ depthStencil: prepass
5402
+ ? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
5403
+ : { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
5327
5404
  multisample: { count: Engine.MULTISAMPLE_COUNT },
5328
5405
  })
5329
5406
  const scoped = await this.device.popErrorScope()
@@ -5345,15 +5422,16 @@ export class Engine {
5345
5422
  counts: uniformView.uints,
5346
5423
  compute,
5347
5424
  computeLayout,
5348
- computeBind: bindFor(computeLayout, this.cameraUniformBuffer),
5425
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer),
5426
+ depth,
5349
5427
  render,
5350
5428
  renderLayout,
5351
- renderBind: bindFor(renderLayout, this.cameraUniformBuffer),
5352
- mirrorRenderBind: bindFor(renderLayout, this.mirrorCameraBuffer),
5429
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer),
5430
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer),
5353
5431
  rebind: () => ({
5354
- computeBind: bindFor(computeLayout, this.cameraUniformBuffer),
5355
- renderBind: bindFor(renderLayout, this.cameraUniformBuffer),
5356
- mirrorRenderBind: bindFor(renderLayout, this.mirrorCameraBuffer),
5432
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer),
5433
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer),
5434
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer),
5357
5435
  }),
5358
5436
  },
5359
5437
  }
@@ -5553,7 +5631,7 @@ export class Engine {
5553
5631
  this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer as ArrayBuffer)
5554
5632
  const cp = encoder.beginComputePass({ label: "particles" })
5555
5633
  cp.setPipeline(p.compute)
5556
- cp.setBindGroup(0, p.computeBind)
5634
+ cp.setBindGroup(0, p.computeBinds[e.grid?.parity ?? 0])
5557
5635
  cp.dispatchWorkgroups(Math.ceil(p.count / 64))
5558
5636
  cp.end()
5559
5637
  }
@@ -5564,8 +5642,15 @@ export class Engine {
5564
5642
  for (const e of this.effects) {
5565
5643
  const p = e.particles
5566
5644
  if (!p || e.weight === 0) continue
5645
+ const parity = e.grid?.parity ?? 0
5646
+ const bind = view === "mirror" ? p.mirrorRenderBinds[parity] : p.renderBinds[parity]
5647
+ if (p.depth) {
5648
+ pass.setPipeline(p.depth)
5649
+ pass.setBindGroup(0, bind)
5650
+ pass.draw(6, p.count)
5651
+ }
5567
5652
  pass.setPipeline(p.render)
5568
- pass.setBindGroup(0, view === "mirror" ? p.mirrorRenderBind : p.renderBind)
5653
+ pass.setBindGroup(0, bind)
5569
5654
  pass.draw(6, p.count)
5570
5655
  }
5571
5656
  }
@@ -5590,6 +5675,9 @@ export class Engine {
5590
5675
  alias: number[],
5591
5676
  /** The effect's declared dials, spliced and bound at 7 as everywhere else. */
5592
5677
  params: EffectParamsBinding,
5678
+ /** This effect's own grid, already built, or null. Ribbons read it; the grid
5679
+ * pass owns it. */
5680
+ grid: EffectGrid | null,
5593
5681
  ): Promise<{ ok: true; state: EffectTrails } | { ok: false; diagnostics: string[] }> {
5594
5682
  // `slots` here is how many RIBBONS to draw — one per trailed anchor — which
5595
5683
  // is a different number from the anchor ADDRESS SPACE the accessors index
@@ -5600,7 +5688,15 @@ export class Engine {
5600
5688
  // nothing before: ribbon i was read as anchor slot i.
5601
5689
  const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0)
5602
5690
  const slots = ribbonSlots.length
5603
- const src = { wgsl, slots, ribbonSlots, blend: d.particleBlend, bloom: d.bloom, paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING) }
5691
+ const src = {
5692
+ wgsl,
5693
+ slots,
5694
+ ribbonSlots,
5695
+ blend: (d.particleBlend === "additive" ? "additive" : "alpha") as "alpha" | "additive",
5696
+ bloom: d.bloom,
5697
+ paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
5698
+ gridSize: grid?.size ?? 0,
5699
+ }
5604
5700
  const code = buildTrailShader(src, {
5605
5701
  subjects: MAX_EFFECT_SUBJECTS,
5606
5702
  samples: TRAIL_SAMPLES,
@@ -5647,6 +5743,13 @@ export class Engine {
5647
5743
  ...(params.buffer
5648
5744
  ? [{ binding: 7, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } }]
5649
5745
  : []),
5746
+ // The grid, always — compiled in whether the author calls it or not.
5747
+ {
5748
+ binding: 8,
5749
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
5750
+ texture: { sampleType: "float" as const },
5751
+ },
5752
+ { binding: 9, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, sampler: { type: "filtering" as const } },
5650
5753
  ],
5651
5754
  })
5652
5755
  // TWO targets, the scene pass's own: HDR colour and the aux (bloom mask,
@@ -5692,30 +5795,8 @@ export class Engine {
5692
5795
  data: new Float32Array(4),
5693
5796
  pipeline,
5694
5797
  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
- }),
5798
+ binds: this.trailBindGroups(layout, uniform, params.buffer, this.cameraUniformBuffer, grid),
5799
+ mirrorBinds: this.trailBindGroups(layout, uniform, params.buffer, this.mirrorCameraBuffer, grid),
5719
5800
  },
5720
5801
  }
5721
5802
  } catch (e) {
@@ -5776,7 +5857,8 @@ export class Engine {
5776
5857
  this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
5777
5858
  }
5778
5859
  pass.setPipeline(t.pipeline)
5779
- pass.setBindGroup(0, view === "mirror" ? t.mirrorBind : t.bind)
5860
+ const parity = e.grid?.parity ?? 0
5861
+ pass.setBindGroup(0, view === "mirror" ? t.mirrorBinds[parity] : t.binds[parity])
5780
5862
  pass.draw(6, t.slots * live * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS)
5781
5863
  }
5782
5864
  }
@@ -5904,34 +5986,45 @@ export class Engine {
5904
5986
  // one, so it has to be part of every group built against that layout —
5905
5987
  // including this one. Omitting it here is not a missing uniform, it is a
5906
5988
  // 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
- })
5989
+ t.binds = this.trailBindGroups(t.layout, t.uniform, t.params, this.cameraUniformBuffer, e.grid)
5990
+ t.mirrorBinds = this.trailBindGroups(t.layout, t.uniform, t.params, this.mirrorCameraBuffer, e.grid)
5932
5991
  }
5933
5992
  }
5934
5993
 
5994
+ /**
5995
+ * A ribbon's bind group, ONE PER GRID PARITY.
5996
+ *
5997
+ * One author for both the build and the rebind, because they drifted once
5998
+ * already: the params binding is part of the LAYOUT whenever the effect
5999
+ * declared one, so it has to be part of every group built against that
6000
+ * layout, and a group missing it is not a missing uniform but one WebGPU
6001
+ * refuses outright, taking the whole frame down.
6002
+ */
6003
+ private trailBindGroups(
6004
+ layout: GPUBindGroupLayout,
6005
+ uniform: GPUBuffer,
6006
+ params: GPUBuffer | null,
6007
+ camera: GPUBuffer,
6008
+ grid: EffectGrid | null,
6009
+ ): [GPUBindGroup, GPUBindGroup] {
6010
+ return [0, 1].map((parity) =>
6011
+ this.device.createBindGroup({
6012
+ layout,
6013
+ entries: [
6014
+ { binding: 0, resource: { buffer: this.castBuffer } },
6015
+ { binding: 1, resource: { buffer: uniform } },
6016
+ { binding: 2, resource: { buffer: camera } },
6017
+ { binding: 4, resource: { buffer: this.audioBuffer } },
6018
+ { binding: 5, resource: { buffer: this.midiBuffer } },
6019
+ { binding: 6, resource: { buffer: this.lyricsBuffer } },
6020
+ ...(params ? [{ binding: EFFECT_PARAMS_BINDING, resource: { buffer: params } }] : []),
6021
+ { binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
6022
+ { binding: 9, resource: this.simSampler },
6023
+ ],
6024
+ }),
6025
+ ) as [GPUBindGroup, GPUBindGroup]
6026
+ }
6027
+
5935
6028
  private releaseParticles(): void {
5936
6029
  for (const e of this.effects) {
5937
6030
  e.particles?.buffer.destroy()
@@ -12247,6 +12340,45 @@ export class Engine {
12247
12340
  /** The ground's uniform block, kept so the caster sphere can be refreshed in
12248
12341
  * it every frame rather than rebuilding the buffer (addGround allocates). */
12249
12342
  private groundMaterialData: Float32Array | null = null
12343
+ /** What addGround was given, so an effect's `#ground` can be lifted again
12344
+ * without rebuilding the floor. */
12345
+ private groundBaseColor = new Vec3(1, 1, 1)
12346
+ private groundBaseNoise = 0
12347
+
12348
+ /**
12349
+ * Dress the floor in whatever the installed effects ask for.
12350
+ *
12351
+ * The LAST effect declaring `#ground` wins, faded by its own weight so a
12352
+ * scheduled lawn brings its soil in with it and takes it away again. Three
12353
+ * floats at the top of the ground block, written only when they change —
12354
+ * the same bargain writeGroundCasterSphere strikes, for the same reason.
12355
+ */
12356
+ private writeGroundDress(): void {
12357
+ const gb = this.groundMaterialData
12358
+ if (!gb || !this.groundShadowMaterialBuffer) return
12359
+ let want = this.groundBaseColor
12360
+ let noise = this.groundBaseNoise
12361
+ for (const e of this.effects) {
12362
+ if (!e.ground || e.weight <= 0) continue
12363
+ const w = e.weight
12364
+ want = new Vec3(
12365
+ want.x + (e.ground.color.x - want.x) * w,
12366
+ want.y + (e.ground.color.y - want.y) * w,
12367
+ want.z + (e.ground.color.z - want.z) * w,
12368
+ )
12369
+ noise += (e.ground.noise - noise) * w
12370
+ }
12371
+ if (gb[0] === want.x && gb[1] === want.y && gb[2] === want.z && gb[10] === noise) return
12372
+ gb[0] = want.x
12373
+ gb[1] = want.y
12374
+ gb[2] = want.z
12375
+ gb[10] = noise
12376
+ // Two writes rather than one over the span: the seven floats between hold
12377
+ // the fades and the shadow strength, and this is not the place that owns
12378
+ // them.
12379
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb.subarray(0, 3) as Float32Array<ArrayBuffer>)
12380
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 40, gb.subarray(10, 11) as Float32Array<ArrayBuffer>)
12381
+ }
12250
12382
 
12251
12383
  /**
12252
12384
  * Push this frame's caster sphere into the ground's uniform.
@@ -13221,6 +13353,8 @@ export class Engine {
13221
13353
  // keeping the uniform vec4-aligned.
13222
13354
  const gb = new Float32Array(24)
13223
13355
  this.groundMaterialData = gb
13356
+ this.groundBaseColor = new Vec3(diffuseColor.x, diffuseColor.y, diffuseColor.z)
13357
+ this.groundBaseNoise = noiseStrength
13224
13358
  gb[0] = diffuseColor.x
13225
13359
  gb[1] = diffuseColor.y
13226
13360
  gb[2] = diffuseColor.z
@@ -15242,6 +15376,7 @@ export class Engine {
15242
15376
  if (hasModels) this.dispatchCull(encoder)
15243
15377
  // After the cull, which is what recomputes the spheres it unions.
15244
15378
  this.writeGroundCasterSphere()
15379
+ this.writeGroundDress()
15245
15380
 
15246
15381
  // After the cull, because a rebuild there can reallocate the argument
15247
15382
  // 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
@@ -225,19 +225,19 @@ fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
225
225
  //
226
226
  // Lit is the answer when it is off. Returning 0 would be "fully shadowed",
227
227
  // which is the opposite of what a disabled shadow means.
228
- let cast = light.lights[0].direction.w;
229
- if (cast <= 0.0) { return 1.0; }
228
+ let castAmt = light.lights[0].direction.w;
229
+ if (castAmt <= 0.0) { return 1.0; }
230
230
  if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
231
231
  let biasedPos = worldPos + n * 0.08;
232
232
  let c0 = lightVP.viewProj[0] * vec4f(biasedPos, 1.0);
233
233
  let n0 = c0.xyz / max(c0.w, 1e-6);
234
234
  if (all(abs(n0.xy) < vec2f(0.98)) && n0.z > 0.0 && n0.z < 1.0) {
235
- return mix(1.0, sampleShadowNear(n0), cast);
235
+ return mix(1.0, sampleShadowNear(n0), castAmt);
236
236
  }
237
237
  let c1 = lightVP.viewProj[1] * vec4f(biasedPos, 1.0);
238
238
  let n1 = c1.xyz / max(c1.w, 1e-6);
239
239
  if (all(abs(n1.xy) < vec2f(0.98)) && n1.z > 0.0 && n1.z < 1.0) {
240
- return mix(1.0, sampleShadowFar(n1), cast);
240
+ return mix(1.0, sampleShadowFar(n1), castAmt);
241
241
  }
242
242
  // Outside every cascade there is no occlusion information; lit is the only
243
243
  // honest answer.