reze-engine 0.57.6 → 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/dist/engine.js CHANGED
@@ -1305,6 +1305,10 @@ export class Engine {
1305
1305
  /** The ground's uniform block, kept so the caster sphere can be refreshed in
1306
1306
  * it every frame rather than rebuilding the buffer (addGround allocates). */
1307
1307
  this.groundMaterialData = null;
1308
+ /** What addGround was given, so an effect's `#ground` can be lifted again
1309
+ * without rebuilding the floor. */
1310
+ this.groundBaseColor = new Vec3(1, 1, 1);
1311
+ this.groundBaseNoise = 0;
1308
1312
  /** Set by the first getGpuTimings() call. See it for why asking is the switch. */
1309
1313
  this.timestampsWanted = false;
1310
1314
  this.groundMirrorPipeline = null;
@@ -3034,9 +3038,9 @@ export class Engine {
3034
3038
  for (const e of this.effects) {
3035
3039
  if (e.particles) {
3036
3040
  const b = e.particles.rebind();
3037
- e.particles.computeBind = b.computeBind;
3038
- e.particles.renderBind = b.renderBind;
3039
- e.particles.mirrorRenderBind = b.mirrorRenderBind;
3041
+ e.particles.computeBinds = b.computeBinds;
3042
+ e.particles.renderBinds = b.renderBinds;
3043
+ e.particles.mirrorRenderBinds = b.mirrorRenderBinds;
3040
3044
  }
3041
3045
  if (e.grid)
3042
3046
  e.grid.binds = [this.gridBindGroup(e.grid, 0), this.gridBindGroup(e.grid, 1)];
@@ -3567,18 +3571,21 @@ export class Engine {
3567
3571
  trails?.uniform.destroy();
3568
3572
  return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false };
3569
3573
  };
3570
- if (wantsParticles) {
3571
- const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor);
3572
- if (!built.ok)
3573
- return abandon(built.diagnostics);
3574
- particles = built.state;
3575
- }
3574
+ // THE GRID FIRST. Particles and ribbons READ it — a blade of grass reads the
3575
+ // bend a foot left there — so the textures have to exist before their bind
3576
+ // groups are built. Nothing reads them back, so the grid depends on neither.
3576
3577
  if (gridEntryPoint(wgsl)) {
3577
3578
  const built = await this.buildSim(wgsl, d, anchors, alias, paramsFor);
3578
3579
  if (!built.ok)
3579
3580
  return abandon(built.diagnostics);
3580
3581
  grid = built.state;
3581
3582
  }
3583
+ if (wantsParticles) {
3584
+ const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor, grid);
3585
+ if (!built.ok)
3586
+ return abandon(built.diagnostics);
3587
+ particles = built.state;
3588
+ }
3582
3589
  if (wantsTrails) {
3583
3590
  // Only anchors that asked for `trail` have a path to draw; a ribbon on a
3584
3591
  // bone recorded without one would read zeroes and paint a line to the origin.
@@ -3586,7 +3593,7 @@ export class Engine {
3586
3593
  if (trailSlots === 0) {
3587
3594
  return abandon(["a ribbon effect needs at least one #anchor <bone> trail"]);
3588
3595
  }
3589
- const built = await this.buildTrails(wgsl, d, anchors, alias, paramsFor);
3596
+ const built = await this.buildTrails(wgsl, d, anchors, alias, paramsFor, grid);
3590
3597
  if (!built.ok)
3591
3598
  return abandon(built.diagnostics);
3592
3599
  trails = built.state;
@@ -3665,6 +3672,7 @@ export class Engine {
3665
3672
  simStep: 0,
3666
3673
  simReset: false,
3667
3674
  dissolve: d.dissolve ? dissolveConstants(authored) : null,
3675
+ ground: d.ground ? { color: new Vec3(...d.ground.color), noise: d.ground.noise } : null,
3668
3676
  // Its OWN resolution, no longer the scene's: an effect that never asked
3669
3677
  // for full res is not promoted because a neighbour did.
3670
3678
  // FULL RESOLUTION UNLESS TOLD OTHERWISE.
@@ -3895,11 +3903,23 @@ export class Engine {
3895
3903
  alias,
3896
3904
  /** The effect's declared dials. Spliced into both stages and bound at 7,
3897
3905
  * the same binding the composite gives them. */
3898
- params) {
3906
+ params,
3907
+ /** This effect's own grid, already built, or null when it declared none.
3908
+ * Read-only here: the pool samples it, the grid pass owns it. */
3909
+ grid) {
3899
3910
  // No pragma means "some": an author who wrote the trio clearly wants
3900
3911
  // particles, and failing over a missing comment would be pedantry.
3901
3912
  const count = Math.min(d.particles || 1024, Engine.MAX_PARTICLES);
3902
- const src = { wgsl, count, blend: d.particleBlend, bloom: d.bloom, paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING) };
3913
+ const src = {
3914
+ wgsl,
3915
+ count,
3916
+ blend: d.particleBlend,
3917
+ bloom: d.bloom,
3918
+ paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
3919
+ gridSize: grid?.size ?? 0,
3920
+ cover: particleEntryPoints(wgsl).cover,
3921
+ };
3922
+ const prepass = src.blend === "cutout" && src.cover;
3903
3923
  // Sparks want to spawn where a trail is, so the particle stages see the same
3904
3924
  // cast buffer the trail draw reads.
3905
3925
  const cast = {
@@ -3966,9 +3986,18 @@ export class Engine {
3966
3986
  // Only when the effect declared any: WGSL has no empty struct, so a
3967
3987
  // param-less effect must not carry the decl OR the binding.
3968
3988
  ...(params.buffer ? [{ binding: 7, visibility, buffer: { type: "uniform" } }] : []),
3989
+ // The grid, ALWAYS — the accessor is compiled in unconditionally, and a
3990
+ // binding the shader declares must exist in the layout whether the
3991
+ // author calls it or not. With no grid it is the 1x1 of zeroes.
3992
+ { binding: 8, visibility, texture: { sampleType: "float" } },
3993
+ { binding: 9, visibility, sampler: { type: "filtering" } },
3969
3994
  ],
3970
3995
  });
3971
- const bindFor = (layout, camera) => this.device.createBindGroup({
3996
+ /** One per GRID PARITY, for the reason rebuildFieldBindGroup gives: the grid
3997
+ * alternates which texture is current, and rebuilding a single group every
3998
+ * frame is waste for a change that only ever toggles between two known
3999
+ * states. Both entries are the same view when there is no grid. */
4000
+ const bindFor = (layout, camera) => [0, 1].map((parity) => this.device.createBindGroup({
3972
4001
  layout,
3973
4002
  entries: [
3974
4003
  { binding: 0, resource: { buffer } },
@@ -3979,15 +4008,22 @@ export class Engine {
3979
4008
  { binding: 5, resource: { buffer: this.midiBuffer } },
3980
4009
  { binding: 6, resource: { buffer: this.lyricsBuffer } },
3981
4010
  ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
4011
+ { binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
4012
+ { binding: 9, resource: this.simSampler },
3982
4013
  ],
3983
- });
4014
+ }));
3984
4015
  const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE);
3985
4016
  const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT);
3986
4017
  // Additive keeps the destination and adds to it, and leaves alpha alone, so
3987
4018
  // a glow does not claim coverage it never occluded. The MASK sums with it —
3988
4019
  // otherwise an additive effect could never reach the bloom gate. Both live
3989
4020
  // in scene-contract as the "particle-additive" class.
4021
+ //
4022
+ // Cutout blends exactly like alpha and differs only in depth and coverage,
4023
+ // below — so it shares alpha's targets rather than adding a class whose
4024
+ // blends would be a copy.
3990
4025
  const targets = sceneTargetsFor(src.blend === "additive" ? "particle-additive" : "particle", this.sceneFormats);
4026
+ const cutout = src.blend === "cutout";
3991
4027
  this.device.pushErrorScope("validation");
3992
4028
  try {
3993
4029
  const compute = await this.device.createComputePipelineAsync({
@@ -3995,15 +4031,41 @@ export class Engine {
3995
4031
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
3996
4032
  compute: { module: computeModule, entryPoint: "main" },
3997
4033
  });
4034
+ const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] });
4035
+ // The prepass, when the effect gave the cutout a cheap shape: coverage to
4036
+ // depth, colour targets at writeMask 0 (the depth-prepass class), and
4037
+ // the shading pass below then tests EQUAL and writes no depth.
4038
+ const depth = prepass
4039
+ ? await this.device.createRenderPipelineAsync({
4040
+ label: "particle depth prepass pipeline",
4041
+ layout: renderLayoutObj,
4042
+ vertex: { module: renderModule, entryPoint: "vs" },
4043
+ fragment: { module: renderModule, entryPoint: "fsDepth", targets: sceneTargetsFor("depth-prepass", this.sceneFormats) },
4044
+ primitive: { topology: "triangle-list", cullMode: "none" },
4045
+ depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
4046
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
4047
+ })
4048
+ : null;
3998
4049
  const render = await this.device.createRenderPipelineAsync({
3999
4050
  label: "particle render pipeline",
4000
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] }),
4051
+ layout: renderLayoutObj,
4001
4052
  vertex: { module: renderModule, entryPoint: "vs" },
4002
4053
  fragment: { module: renderModule, entryPoint: "fs", targets },
4003
4054
  primitive: { topology: "triangle-list", cullMode: "none" },
4004
4055
  // Tested but not WRITTEN: particles are transparent, so writing depth
4005
4056
  // would make whichever quad drew first occlude the ones behind it.
4006
- depthStencil: { format: this.depthFormat, depthWriteEnabled: false, depthCompare: this.depthAhead },
4057
+ //
4058
+ // Except a CUTOUT, which is opaque where it is drawn at all. Its alpha
4059
+ // becomes per-sample coverage (in the shader — see FSOut.samples, the
4060
+ // HDR target has no alpha for the hardware path), so its edges stay
4061
+ // antialiased under MSAA while its depth is real — and a blade behind
4062
+ // another blade is then rejected at the depth test rather than shaded
4063
+ // and blended under it.
4064
+ // For a lawn stacked ten blades deep that is the difference between
4065
+ // shading ten layers and shading about three.
4066
+ depthStencil: prepass
4067
+ ? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
4068
+ : { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
4007
4069
  multisample: { count: Engine.MULTISAMPLE_COUNT },
4008
4070
  });
4009
4071
  const scoped = await this.device.popErrorScope();
@@ -4025,15 +4087,16 @@ export class Engine {
4025
4087
  counts: uniformView.uints,
4026
4088
  compute,
4027
4089
  computeLayout,
4028
- computeBind: bindFor(computeLayout, this.cameraUniformBuffer),
4090
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer),
4091
+ depth,
4029
4092
  render,
4030
4093
  renderLayout,
4031
- renderBind: bindFor(renderLayout, this.cameraUniformBuffer),
4032
- mirrorRenderBind: bindFor(renderLayout, this.mirrorCameraBuffer),
4094
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer),
4095
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer),
4033
4096
  rebind: () => ({
4034
- computeBind: bindFor(computeLayout, this.cameraUniformBuffer),
4035
- renderBind: bindFor(renderLayout, this.cameraUniformBuffer),
4036
- mirrorRenderBind: bindFor(renderLayout, this.mirrorCameraBuffer),
4097
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer),
4098
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer),
4099
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer),
4037
4100
  }),
4038
4101
  },
4039
4102
  };
@@ -4226,7 +4289,7 @@ export class Engine {
4226
4289
  this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer);
4227
4290
  const cp = encoder.beginComputePass({ label: "particles" });
4228
4291
  cp.setPipeline(p.compute);
4229
- cp.setBindGroup(0, p.computeBind);
4292
+ cp.setBindGroup(0, p.computeBinds[e.grid?.parity ?? 0]);
4230
4293
  cp.dispatchWorkgroups(Math.ceil(p.count / 64));
4231
4294
  cp.end();
4232
4295
  }
@@ -4237,8 +4300,15 @@ export class Engine {
4237
4300
  const p = e.particles;
4238
4301
  if (!p || e.weight === 0)
4239
4302
  continue;
4303
+ const parity = e.grid?.parity ?? 0;
4304
+ const bind = view === "mirror" ? p.mirrorRenderBinds[parity] : p.renderBinds[parity];
4305
+ if (p.depth) {
4306
+ pass.setPipeline(p.depth);
4307
+ pass.setBindGroup(0, bind);
4308
+ pass.draw(6, p.count);
4309
+ }
4240
4310
  pass.setPipeline(p.render);
4241
- pass.setBindGroup(0, view === "mirror" ? p.mirrorRenderBind : p.renderBind);
4311
+ pass.setBindGroup(0, bind);
4242
4312
  pass.draw(6, p.count);
4243
4313
  }
4244
4314
  }
@@ -4260,7 +4330,10 @@ export class Engine {
4260
4330
  * describes the effect that is still on screen. */
4261
4331
  alias,
4262
4332
  /** The effect's declared dials, spliced and bound at 7 as everywhere else. */
4263
- params) {
4333
+ params,
4334
+ /** This effect's own grid, already built, or null. Ribbons read it; the grid
4335
+ * pass owns it. */
4336
+ grid) {
4264
4337
  // `slots` here is how many RIBBONS to draw — one per trailed anchor — which
4265
4338
  // is a different number from the anchor ADDRESS SPACE the accessors index
4266
4339
  // by. Conflating the two is what made a trail declared after a bare anchor
@@ -4270,7 +4343,15 @@ export class Engine {
4270
4343
  // nothing before: ribbon i was read as anchor slot i.
4271
4344
  const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0);
4272
4345
  const slots = ribbonSlots.length;
4273
- const src = { wgsl, slots, ribbonSlots, blend: d.particleBlend, bloom: d.bloom, paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING) };
4346
+ const src = {
4347
+ wgsl,
4348
+ slots,
4349
+ ribbonSlots,
4350
+ blend: (d.particleBlend === "additive" ? "additive" : "alpha"),
4351
+ bloom: d.bloom,
4352
+ paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
4353
+ gridSize: grid?.size ?? 0,
4354
+ };
4274
4355
  const code = buildTrailShader(src, {
4275
4356
  subjects: MAX_EFFECT_SUBJECTS,
4276
4357
  samples: TRAIL_SAMPLES,
@@ -4318,6 +4399,13 @@ export class Engine {
4318
4399
  ...(params.buffer
4319
4400
  ? [{ binding: 7, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }]
4320
4401
  : []),
4402
+ // The grid, always — compiled in whether the author calls it or not.
4403
+ {
4404
+ binding: 8,
4405
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
4406
+ texture: { sampleType: "float" },
4407
+ },
4408
+ { binding: 9, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
4321
4409
  ],
4322
4410
  });
4323
4411
  // TWO targets, the scene pass's own: HDR colour and the aux (bloom mask,
@@ -4363,30 +4451,8 @@ export class Engine {
4363
4451
  data: new Float32Array(4),
4364
4452
  pipeline,
4365
4453
  layout,
4366
- bind: this.device.createBindGroup({
4367
- layout,
4368
- entries: [
4369
- { binding: 0, resource: { buffer: this.castBuffer } },
4370
- { binding: 1, resource: { buffer: uniform } },
4371
- { binding: 2, resource: { buffer: this.cameraUniformBuffer } },
4372
- { binding: 4, resource: { buffer: this.audioBuffer } },
4373
- { binding: 5, resource: { buffer: this.midiBuffer } },
4374
- { binding: 6, resource: { buffer: this.lyricsBuffer } },
4375
- ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
4376
- ],
4377
- }),
4378
- mirrorBind: this.device.createBindGroup({
4379
- layout,
4380
- entries: [
4381
- { binding: 0, resource: { buffer: this.castBuffer } },
4382
- { binding: 1, resource: { buffer: uniform } },
4383
- { binding: 2, resource: { buffer: this.mirrorCameraBuffer } },
4384
- { binding: 4, resource: { buffer: this.audioBuffer } },
4385
- { binding: 5, resource: { buffer: this.midiBuffer } },
4386
- { binding: 6, resource: { buffer: this.lyricsBuffer } },
4387
- ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
4388
- ],
4389
- }),
4454
+ binds: this.trailBindGroups(layout, uniform, params.buffer, this.cameraUniformBuffer, grid),
4455
+ mirrorBinds: this.trailBindGroups(layout, uniform, params.buffer, this.mirrorCameraBuffer, grid),
4390
4456
  },
4391
4457
  };
4392
4458
  }
@@ -4447,7 +4513,8 @@ export class Engine {
4447
4513
  this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer);
4448
4514
  }
4449
4515
  pass.setPipeline(t.pipeline);
4450
- pass.setBindGroup(0, view === "mirror" ? t.mirrorBind : t.bind);
4516
+ const parity = e.grid?.parity ?? 0;
4517
+ pass.setBindGroup(0, view === "mirror" ? t.mirrorBinds[parity] : t.binds[parity]);
4451
4518
  pass.draw(6, t.slots * live * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS);
4452
4519
  }
4453
4520
  }
@@ -4575,33 +4642,35 @@ export class Engine {
4575
4642
  // one, so it has to be part of every group built against that layout —
4576
4643
  // including this one. Omitting it here is not a missing uniform, it is a
4577
4644
  // group WebGPU refuses outright, which takes the whole frame down.
4578
- const paramsEntry = t.params ? [{ binding: EFFECT_PARAMS_BINDING, resource: { buffer: t.params } }] : [];
4579
- t.bind = this.device.createBindGroup({
4580
- layout: t.layout,
4581
- entries: [
4582
- { binding: 0, resource: { buffer: this.castBuffer } },
4583
- { binding: 1, resource: { buffer: t.uniform } },
4584
- { binding: 2, resource: { buffer: this.cameraUniformBuffer } },
4585
- { binding: 4, resource: { buffer: this.audioBuffer } },
4586
- { binding: 5, resource: { buffer: this.midiBuffer } },
4587
- { binding: 6, resource: { buffer: this.lyricsBuffer } },
4588
- ...paramsEntry,
4589
- ],
4590
- });
4591
- t.mirrorBind = this.device.createBindGroup({
4592
- layout: t.layout,
4593
- entries: [
4594
- { binding: 0, resource: { buffer: this.castBuffer } },
4595
- { binding: 1, resource: { buffer: t.uniform } },
4596
- { binding: 2, resource: { buffer: this.mirrorCameraBuffer } },
4597
- { binding: 4, resource: { buffer: this.audioBuffer } },
4598
- { binding: 5, resource: { buffer: this.midiBuffer } },
4599
- { binding: 6, resource: { buffer: this.lyricsBuffer } },
4600
- ...paramsEntry,
4601
- ],
4602
- });
4645
+ t.binds = this.trailBindGroups(t.layout, t.uniform, t.params, this.cameraUniformBuffer, e.grid);
4646
+ t.mirrorBinds = this.trailBindGroups(t.layout, t.uniform, t.params, this.mirrorCameraBuffer, e.grid);
4603
4647
  }
4604
4648
  }
4649
+ /**
4650
+ * A ribbon's bind group, ONE PER GRID PARITY.
4651
+ *
4652
+ * One author for both the build and the rebind, because they drifted once
4653
+ * already: the params binding is part of the LAYOUT whenever the effect
4654
+ * declared one, so it has to be part of every group built against that
4655
+ * layout, and a group missing it is not a missing uniform but one WebGPU
4656
+ * refuses outright, taking the whole frame down.
4657
+ */
4658
+ trailBindGroups(layout, uniform, params, camera, grid) {
4659
+ return [0, 1].map((parity) => this.device.createBindGroup({
4660
+ layout,
4661
+ entries: [
4662
+ { binding: 0, resource: { buffer: this.castBuffer } },
4663
+ { binding: 1, resource: { buffer: uniform } },
4664
+ { binding: 2, resource: { buffer: camera } },
4665
+ { binding: 4, resource: { buffer: this.audioBuffer } },
4666
+ { binding: 5, resource: { buffer: this.midiBuffer } },
4667
+ { binding: 6, resource: { buffer: this.lyricsBuffer } },
4668
+ ...(params ? [{ binding: EFFECT_PARAMS_BINDING, resource: { buffer: params } }] : []),
4669
+ { binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
4670
+ { binding: 9, resource: this.simSampler },
4671
+ ],
4672
+ }));
4673
+ }
4605
4674
  releaseParticles() {
4606
4675
  for (const e of this.effects) {
4607
4676
  e.particles?.buffer.destroy();
@@ -10435,6 +10504,39 @@ export class Engine {
10435
10504
  out[2] = cz;
10436
10505
  out[3] = any ? r : 0;
10437
10506
  }
10507
+ /**
10508
+ * Dress the floor in whatever the installed effects ask for.
10509
+ *
10510
+ * The LAST effect declaring `#ground` wins, faded by its own weight so a
10511
+ * scheduled lawn brings its soil in with it and takes it away again. Three
10512
+ * floats at the top of the ground block, written only when they change —
10513
+ * the same bargain writeGroundCasterSphere strikes, for the same reason.
10514
+ */
10515
+ writeGroundDress() {
10516
+ const gb = this.groundMaterialData;
10517
+ if (!gb || !this.groundShadowMaterialBuffer)
10518
+ return;
10519
+ let want = this.groundBaseColor;
10520
+ let noise = this.groundBaseNoise;
10521
+ for (const e of this.effects) {
10522
+ if (!e.ground || e.weight <= 0)
10523
+ continue;
10524
+ const w = e.weight;
10525
+ want = new Vec3(want.x + (e.ground.color.x - want.x) * w, want.y + (e.ground.color.y - want.y) * w, want.z + (e.ground.color.z - want.z) * w);
10526
+ noise += (e.ground.noise - noise) * w;
10527
+ }
10528
+ if (gb[0] === want.x && gb[1] === want.y && gb[2] === want.z && gb[10] === noise)
10529
+ return;
10530
+ gb[0] = want.x;
10531
+ gb[1] = want.y;
10532
+ gb[2] = want.z;
10533
+ gb[10] = noise;
10534
+ // Two writes rather than one over the span: the seven floats between hold
10535
+ // the fades and the shadow strength, and this is not the place that owns
10536
+ // them.
10537
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb.subarray(0, 3));
10538
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 40, gb.subarray(10, 11));
10539
+ }
10438
10540
  /**
10439
10541
  * Push this frame's caster sphere into the ground's uniform.
10440
10542
  *
@@ -11307,6 +11409,8 @@ export class Engine {
11307
11409
  // keeping the uniform vec4-aligned.
11308
11410
  const gb = new Float32Array(24);
11309
11411
  this.groundMaterialData = gb;
11412
+ this.groundBaseColor = new Vec3(diffuseColor.x, diffuseColor.y, diffuseColor.z);
11413
+ this.groundBaseNoise = noiseStrength;
11310
11414
  gb[0] = diffuseColor.x;
11311
11415
  gb[1] = diffuseColor.y;
11312
11416
  gb[2] = diffuseColor.z;
@@ -13006,6 +13110,7 @@ export class Engine {
13006
13110
  this.dispatchCull(encoder);
13007
13111
  // After the cull, which is what recomputes the spheres it unions.
13008
13112
  this.writeGroundCasterSphere();
13113
+ this.writeGroundDress();
13009
13114
  // After the cull, because a rebuild there can reallocate the argument
13010
13115
  // buffers and a bundle captures the buffer it recorded against.
13011
13116
  if (this.bundlesDirty)
@@ -14843,7 +14948,12 @@ Engine.JIGGLE_DAMPING_SCALE = 0.5;
14843
14948
  * prefix sum and readback a compacted draw list would need every frame.
14844
14949
  */
14845
14950
  /** Ceiling for `#particles`. Past this an author is asking for a stall. */
14846
- Engine.MAX_PARTICLES = 65536;
14951
+ // Raised for the lawn: 65536 blades at real density is a 40-unit patch and
14952
+ // an 80-unit one wants four hundred thousand. A cutout pool's cost is
14953
+ // bounded by the pixels it covers, not by the count — the step is one
14954
+ // compute invocation per slot, the pool buffer is 48 bytes each (24 MB at
14955
+ // the cap), and a culled blade is a degenerate quad the rasteriser drops.
14956
+ Engine.MAX_PARTICLES = 524288;
14847
14957
  /**
14848
14958
  * The field layer: user background/foreground mounts, ONE TARGET PAIR PER
14849
14959
  * RESOLUTION. Index 0 is full, index 1 is half — coarsest last, so the
@@ -20,13 +20,30 @@ export type EffectDirectives = {
20
20
  /** The field layer composites additively rather than over. */
21
21
  additiveLayer: boolean;
22
22
  /** Particle blend, which is a different axis from the field layer's. */
23
- particleBlend: "alpha" | "additive";
23
+ /** `cutout` is alpha WITH DEPTH: the pipeline writes depth and turns the
24
+ * fragment's alpha into MSAA sample coverage, so a blade of grass behind
25
+ * another is rejected by the depth test instead of blended under it. What
26
+ * every real grass renderer does, and the only thing that bounds a dense
27
+ * lawn's cost by the pixels it covers rather than by how deep it stacks. */
28
+ particleBlend: "alpha" | "additive" | "cutout";
24
29
  particles: number;
25
30
  lights: number;
26
31
  grid: number;
27
32
  bloom: boolean;
28
33
  /** This effect takes the cast apart — the host reads the timing. */
29
34
  dissolve: boolean;
35
+ /** `#ground #rrggbb [noise]`: how this effect DRESSES THE FLOOR while it is
36
+ * installed — a colour as linear RGB, and the strength of the ground's own
37
+ * grain (0 flat, 1 strong), which is what keeps soil from being a swatch.
38
+ * A lawn wants earth under it, and the earth has to be the engine's own
39
+ * ground rather than a layer painted over the frame: the ground is geometry
40
+ * in the scene pass, so a shadow lands on it and it tone-maps with
41
+ * everything else. The scene's own floor returns when the effect goes.
42
+ * Null when the file declares none. */
43
+ ground: {
44
+ color: [number, number, number];
45
+ noise: number;
46
+ } | null;
30
47
  /**
31
48
  * This effect IS a mirror: a plane in the scene showing a true reflection of
32
49
  * it, folded and re-rendered by the engine.
@@ -1 +1 @@
1
- {"version":3,"file":"directives.d.ts","sourceRoot":"","sources":["../../src/shaders/directives.ts"],"names":[],"mappings":"AA8BA,oEAAoE;AACpE,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,CAAA;IAChC,+EAA+E;IAC/E,KAAK,EAAE,MAAM,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAA;IACjD,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,6EAA6E;IAC7E,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,EAAE,CAAA;IAC3C,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,0EAA0E;IAC1E,UAAU,EAAE,CAAC,GAAG,CAAC,CAAA;IACjB,8DAA8D;IAC9D,aAAa,EAAE,OAAO,CAAA;IACtB,wEAAwE;IACxE,aAAa,EAAE,OAAO,GAAG,UAAU,CAAA;IACnC,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,OAAO,CAAA;IACd,oEAAoE;IACpE,QAAQ,EAAE,OAAO,CAAA;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,EAAE,OAAO,CAAA;IACf;;;;;;;;;;;;;OAaG;IACH,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAkBD;;;+BAG+B;AAC/B,eAAO,MAAM,cAAc,QAAmC,CAAA;AAG9D;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,QAA8B,CAAA;AAmBzD,MAAM,MAAM,eAAe,GAAG;IAAE,UAAU,EAAE,gBAAgB,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAAA;AAEhF,2EAA2E;AAC3E,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,CAyJ7D;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKpD"}
1
+ {"version":3,"file":"directives.d.ts","sourceRoot":"","sources":["../../src/shaders/directives.ts"],"names":[],"mappings":"AA8BA,oEAAoE;AACpE,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,CAAA;IAChC,+EAA+E;IAC/E,KAAK,EAAE,MAAM,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAA;IACjD,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,6EAA6E;IAC7E,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,EAAE,CAAA;IAC3C,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,0EAA0E;IAC1E,UAAU,EAAE,CAAC,GAAG,CAAC,CAAA;IACjB,8DAA8D;IAC9D,aAAa,EAAE,OAAO,CAAA;IACtB,wEAAwE;IACxE;;;;iFAI6E;IAC7E,aAAa,EAAE,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAA;IAC9C,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,OAAO,CAAA;IACd,oEAAoE;IACpE,QAAQ,EAAE,OAAO,CAAA;IACjB;;;;;;;4CAOwC;IACxC,MAAM,EAAE;QAAE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IACjE;;;;;;;;;;;;;OAaG;IACH,MAAM,EAAE,OAAO,CAAA;IACf;;;;;;;;;;;;;OAaG;IACH,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAmBD;;;+BAG+B;AAC/B,eAAO,MAAM,cAAc,QAAmC,CAAA;AAG9D;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,QAA8B,CAAA;AAmBzD,MAAM,MAAM,eAAe,GAAG;IAAE,UAAU,EAAE,gBAAgB,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAAA;AAEhF,2EAA2E;AAC3E,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,CA2K7D;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKpD"}
@@ -41,6 +41,7 @@ const SPEC = {
41
41
  dissolve: 0,
42
42
  mirror: 0,
43
43
  duration: 1,
44
+ ground: "rest",
44
45
  };
45
46
  /** A line that declares something, and what it declares. Exported because an
46
47
  * editor highlights by the same rule the parser reads by — a highlighter with
@@ -89,6 +90,7 @@ export function parseDirectives(wgsl) {
89
90
  grid: 0,
90
91
  bloom: false,
91
92
  dissolve: false,
93
+ ground: null,
92
94
  mirror: false,
93
95
  duration: 0,
94
96
  };
@@ -173,15 +175,32 @@ export function parseDirectives(wgsl) {
173
175
  d.additiveLayer = true;
174
176
  return;
175
177
  case "blend":
176
- if (args[0] !== "additive") {
177
- errors.push(`${at}: #blend takes "additive" — alpha is the default`);
178
+ if (args[0] !== "additive" && args[0] !== "cutout") {
179
+ errors.push(`${at}: #blend takes "additive" or "cutout" — alpha is the default`);
178
180
  return;
179
181
  }
180
- d.particleBlend = "additive";
182
+ d.particleBlend = args[0];
181
183
  return;
182
184
  case "bloom":
183
185
  d.bloom = true;
184
186
  return;
187
+ case "ground": {
188
+ const m = /^#([0-9a-fA-F]{6})$/.exec(args[0] ?? "");
189
+ const noise = args[1] === undefined ? 0 : num(args[1]);
190
+ if (!m || args.length > 2 || noise === null) {
191
+ errors.push(`${at}: #ground takes a colour like #4b4026 and optionally a grain strength 0..1`);
192
+ return;
193
+ }
194
+ const v = parseInt(m[1], 16);
195
+ // sRGB in the file, linear in the uniform — the same transfer the
196
+ // ground's own colour goes through on the way in.
197
+ const lin = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
198
+ d.ground = {
199
+ color: [lin(((v >> 16) & 255) / 255), lin(((v >> 8) & 255) / 255), lin((v & 255) / 255)],
200
+ noise: Math.min(Math.max(noise, 0), 1),
201
+ };
202
+ return;
203
+ }
185
204
  case "dissolve":
186
205
  d.dissolve = true;
187
206
  return;
@@ -14,7 +14,9 @@ export type CastLayout = {
14
14
  alias: number[];
15
15
  };
16
16
  /** How the author's quads combine with the scene. */
17
- type ParticleBlend = "alpha" | "additive";
17
+ /** `cutout` shades exactly like `alpha`; the difference is entirely in the
18
+ * pipeline — depth write on, alpha-to-coverage on. See directives.ts. */
19
+ type ParticleBlend = "alpha" | "additive" | "cutout";
18
20
  type ParticleSource = {
19
21
  /** The author's WGSL verbatim. */
20
22
  wgsl: string;
@@ -27,14 +29,28 @@ type ParticleSource = {
27
29
  * rule reads the same dial the shading does, and rain's fall speed is used
28
30
  * in the compute half. */
29
31
  paramsDecl: string;
32
+ /** The effect defines `particleCover(p, uv) -> f32`: the shape alone, cheap.
33
+ * A cutout then draws TWICE — coverage to depth first, then the shading at
34
+ * depthCompare equal — so the lighting runs only for the visible layer. A
35
+ * discard forbids the hardware's own early rejection on a tile-based GPU,
36
+ * where every stacked fragment is otherwise shaded in full; the prepass is
37
+ * the shape of that rejection, done by hand and cheaply. */
38
+ cover: boolean;
39
+ /** The side of this effect's grid, or 0 when it declared none. Particles READ
40
+ * the grid; the kernel that steps it lives in the grid pass. A blade of grass
41
+ * bending where a foot pressed is the whole case: the bend has to persist and
42
+ * recover, which is memory, and memory is what the grid is for. */
43
+ gridSize: number;
30
44
  };
31
45
  /** Bytes per particle. Explicitly padded — see the struct below. */
32
46
  export declare const PARTICLE_STRIDE = 48;
33
- /** Does the source define the particle contract? All three are required together. */
47
+ /** Does the source define the particle contract? All three are required
48
+ * together; `cover` is optional and only means anything to a cutout. */
34
49
  export declare function particleEntryPoints(wgsl: string): {
35
50
  init: boolean;
36
51
  step: boolean;
37
52
  shade: boolean;
53
+ cover: boolean;
38
54
  };
39
55
  /**
40
56
  * Spawn, age, recycle.
@@ -1 +1 @@
1
- {"version":3,"file":"particles.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/particles.ts"],"names":[],"mappings":"AA4BA,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAA;IACb;;mFAE+E;IAC/E,UAAU,EAAE,MAAM,CAAA;IAClB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,EAAE,CAAA;CAChB,CAAA;AAkCD,qDAAqD;AACrD,KAAK,aAAa,GAAG,OAAO,GAAG,UAAU,CAAA;AAEzC,KAAK,cAAc,GAAG;IACpB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,aAAa,CAAA;IACpB,qEAAqE;IACrE,KAAK,EAAE,OAAO,CAAA;IACd;;+BAE2B;IAC3B,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,oEAAoE;AACpE,eAAO,MAAM,eAAe,KAAK,CAAA;AAwDjC,qFAAqF;AACrF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAMlG;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CA8CxF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CA6HvF"}
1
+ {"version":3,"file":"particles.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/particles.ts"],"names":[],"mappings":"AA6BA,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAA;IACb;;mFAE+E;IAC/E,UAAU,EAAE,MAAM,CAAA;IAClB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,EAAE,CAAA;CAChB,CAAA;AAkCD,qDAAqD;AACrD;0EAC0E;AAC1E,KAAK,aAAa,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAA;AAEpD,KAAK,cAAc,GAAG;IACpB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,aAAa,CAAA;IACpB,qEAAqE;IACrE,KAAK,EAAE,OAAO,CAAA;IACd;;+BAE2B;IAC3B,UAAU,EAAE,MAAM,CAAA;IAClB;;;;;iEAK6D;IAC7D,KAAK,EAAE,OAAO,CAAA;IACd;;;wEAGoE;IACpE,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,oEAAoE;AACpE,eAAO,MAAM,eAAe,KAAK,CAAA;AAwDjC;yEACyE;AACzE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAOlH;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAmDxF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAuLvF"}