reze-engine 0.58.2 → 0.58.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.ts CHANGED
@@ -118,6 +118,9 @@ import {
118
118
  PARTICLE_LIGHT_BINDING,
119
119
  PARTICLE_POINTS_BINDING,
120
120
  particleEntryPoints,
121
+ PARTICLE_INDIRECT_BINDING,
122
+ PARTICLE_INDIRECT_BYTES,
123
+ PARTICLE_INDIRECT_DRAW_OFFSET,
121
124
  PARTICLE_STRIDE,
122
125
  } from "./shaders/passes/particles"
123
126
  import { MAX_EFFECT_POINTS, POINTS_FLOATS } from "./shaders/points-api"
@@ -863,6 +866,8 @@ interface ModelInstance {
863
866
  shadowDrawCalls: DrawCall[]
864
867
  shadowBindGroups: GPUBindGroup[]
865
868
  mainPerInstanceBindGroup: GPUBindGroup
869
+ /** Its own fill, when setModelFill gave it one. */
870
+ fillBuffer: GPUBuffer | null
866
871
  pickPerInstanceBindGroup: GPUBindGroup
867
872
  pickDrawCalls: PickDrawCall[]
868
873
  /** Environment geometry added via addStage — no physics, no IK, and it
@@ -1451,11 +1456,16 @@ interface EffectParticles {
1451
1456
  computeBinds: [GPUBindGroup, GPUBindGroup]
1452
1457
  renderBinds: [GPUBindGroup, GPUBindGroup]
1453
1458
  mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
1459
+ countBinds: [GPUBindGroup, GPUBindGroup] | null
1454
1460
  }
1455
1461
  /** `#points`: the bone-name prefix and the buffer its matches are written
1456
1462
  * to each frame. Null when the effect declared none — it then reads the
1457
1463
  * engine's empty fallback. */
1458
1464
  points: { prefix: string; buffer: GPUBuffer; data: Float32Array } | null
1465
+ /** `particleCount`: the kernel that writes the frame's live count and the
1466
+ * indirect arguments it writes them to. Null when the effect declared none —
1467
+ * the whole pool is then stepped and drawn. */
1468
+ live: { pipeline: GPUComputePipeline; indirect: GPUBuffer; binds: [GPUBindGroup, GPUBindGroup] } | null
1459
1469
  }
1460
1470
 
1461
1471
  /**
@@ -1950,6 +1960,8 @@ export class Engine {
1950
1960
  } | null = null
1951
1961
  private mainPerFrameBindGroupLayout!: GPUBindGroupLayout
1952
1962
  private mainPerInstanceBindGroupLayout!: GPUBindGroupLayout
1963
+ /** What a model with no fill of its own binds: zero light. */
1964
+ private noFillBuffer!: GPUBuffer
1953
1965
  private mainPerMaterialBindGroupLayout!: GPUBindGroupLayout
1954
1966
  private outlinePerFrameBindGroupLayout!: GPUBindGroupLayout
1955
1967
  private outlinePerMaterialBindGroupLayout!: GPUBindGroupLayout
@@ -2085,12 +2097,13 @@ export class Engine {
2085
2097
  * prefix sum and readback a compacted draw list would need every frame.
2086
2098
  */
2087
2099
  /** Ceiling for `#particles`. Past this an author is asking for a stall. */
2088
- // Raised for the lawn: 65536 blades at real density is a 40-unit patch and
2089
- // an 80-unit one wants four hundred thousand. A cutout pool's cost is
2100
+ // Raised for the lawn: an 80-unit field at 36 blades a unit wants seven
2101
+ // hundred thousand, and a turf one three million. A cutout pool's cost is
2090
2102
  // bounded by the pixels it covers, not by the count — the step is one
2091
- // compute invocation per slot, the pool buffer is 48 bytes each (24 MB at
2092
- // the cap), and a culled blade is a degenerate quad the rasteriser drops.
2093
- private static readonly MAX_PARTICLES = 524288
2103
+ // compute invocation per slot, the pool buffer is 48 bytes each (96 MiB at
2104
+ // the cap, under the 128 MiB a storage binding may be without asking for
2105
+ // more), and a culled blade is a degenerate quad the rasteriser drops.
2106
+ private static readonly MAX_PARTICLES = 2097152
2094
2107
  private particleFrame = 0
2095
2108
  /**
2096
2109
  * The installed effect's persistent grid, or null when it declared none.
@@ -2654,7 +2667,9 @@ export class Engine {
2654
2667
  // GPU texture can't be read back cheaply, and PMX diffuse alpha is usually 1.0
2655
2668
  // even for see-through cloth: the translucency lives in the texture).
2656
2669
  private textureAlphaCache = new Map<string, { a: Uint8ClampedArray; w: number; h: number } | null>()
2657
- private mipBlitPipeline: GPURenderPipeline | null = null
2670
+ /** One per format: a render pipeline is bound to its target's format, and
2671
+ * material textures are sRGB while a group's data maps are not. */
2672
+ private mipBlitPipelines = new Map<GPUTextureFormat, GPURenderPipeline>()
2658
2673
  private mipBlitSampler: GPUSampler | null = null
2659
2674
  private _nextDefaultModelId = 0
2660
2675
 
@@ -4347,6 +4362,7 @@ export class Engine {
4347
4362
  e.particles.computeBinds = b.computeBinds
4348
4363
  e.particles.renderBinds = b.renderBinds
4349
4364
  e.particles.mirrorRenderBinds = b.mirrorRenderBinds
4365
+ if (e.particles.live && b.countBinds) e.particles.live.binds = b.countBinds
4350
4366
  }
4351
4367
  if (e.grid) e.grid.binds = [this.gridBindGroup(e.grid, 0), this.gridBindGroup(e.grid, 1)]
4352
4368
  if (e.lights) e.lights.bind = this.lightEmitBindGroup(e.lights.layout, e.lights.uniform, e.lights.params)
@@ -4885,6 +4901,7 @@ export class Engine {
4885
4901
  particles?.buffer.destroy()
4886
4902
  particles?.uniform.destroy()
4887
4903
  particles?.points?.buffer.destroy()
4904
+ particles?.live?.indirect.destroy()
4888
4905
  grid?.textures[0].destroy()
4889
4906
  grid?.textures[1].destroy()
4890
4907
  grid?.uniform.destroy()
@@ -5267,6 +5284,7 @@ export class Engine {
5267
5284
  paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
5268
5285
  gridSize: grid?.size ?? 0,
5269
5286
  cover: particleEntryPoints(wgsl).cover,
5287
+ live: particleEntryPoints(wgsl).count,
5270
5288
  }
5271
5289
  const prepass = src.blend === "cutout" && src.cover
5272
5290
  const points = d.points
@@ -5332,6 +5350,16 @@ export class Engine {
5332
5350
  })
5333
5351
  const uniformBytes = new ArrayBuffer(32)
5334
5352
  const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) }
5353
+ // The live count's arguments — written by rzCount, read by the step's
5354
+ // dispatch and the quads' draws. Zero until the first step, so a pool
5355
+ // drawn before it has stepped draws nothing rather than everything.
5356
+ const indirect = src.live
5357
+ ? this.device.createBuffer({
5358
+ label: "particle indirect",
5359
+ size: PARTICLE_INDIRECT_BYTES,
5360
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT,
5361
+ })
5362
+ : null
5335
5363
 
5336
5364
  // Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
5337
5365
  // visible to the vertex stage at all (WebGPU forbids it — a vertex shader
@@ -5339,7 +5367,7 @@ export class Engine {
5339
5367
  // Declaring one set of flags for both layouts is what made the pipeline
5340
5368
  // layout invalid, and the error surfaces later and unhelpfully as "invalid
5341
5369
  // due to a previous error".
5342
- const layoutFor = (storage: GPUBufferBindingType, visibility: number, shadow: boolean) =>
5370
+ const layoutFor = (storage: GPUBufferBindingType, visibility: number, shadow: boolean, withIndirect: boolean) =>
5343
5371
  this.device.createBindGroupLayout({
5344
5372
  entries: [
5345
5373
  { binding: 0, visibility, buffer: { type: storage } },
@@ -5374,13 +5402,21 @@ export class Engine {
5374
5402
  { binding: PARTICLE_LIGHT_BINDING + 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" as const } },
5375
5403
  ]
5376
5404
  : []),
5405
+ // The live count's arguments, for the COUNT kernel's layout alone. The
5406
+ // step's layout must not carry them: a bound group's entries count
5407
+ // toward a pass's usage whether the shader reads them or not, and a
5408
+ // buffer may not be writable storage and indirect arguments in one
5409
+ // pass.
5410
+ ...(withIndirect
5411
+ ? [{ binding: PARTICLE_INDIRECT_BINDING, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" as const } }]
5412
+ : []),
5377
5413
  ],
5378
5414
  })
5379
5415
  /** One per GRID PARITY, for the reason rebuildFieldBindGroup gives: the grid
5380
5416
  * alternates which texture is current, and rebuilding a single group every
5381
5417
  * frame is waste for a change that only ever toggles between two known
5382
5418
  * states. Both entries are the same view when there is no grid. */
5383
- const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer, shadow: boolean) =>
5419
+ const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer, shadow: boolean, withIndirect: boolean) =>
5384
5420
  [0, 1].map((parity) =>
5385
5421
  this.device.createBindGroup({
5386
5422
  layout,
@@ -5405,12 +5441,14 @@ export class Engine {
5405
5441
  { binding: PARTICLE_LIGHT_BINDING + 4, resource: this.shadowComparisonSampler },
5406
5442
  ]
5407
5443
  : []),
5444
+ ...(withIndirect && indirect ? [{ binding: PARTICLE_INDIRECT_BINDING, resource: { buffer: indirect } }] : []),
5408
5445
  ],
5409
5446
  }),
5410
5447
  ) as [GPUBindGroup, GPUBindGroup]
5411
5448
 
5412
- const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false)
5413
- const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true)
5449
+ const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false, false)
5450
+ const countLayout = indirect ? layoutFor("storage", GPUShaderStage.COMPUTE, false, true) : null
5451
+ const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true, false)
5414
5452
 
5415
5453
  // Additive keeps the destination and adds to it, and leaves alpha alone, so
5416
5454
  // a glow does not claim coverage it never occluded. The MASK sums with it —
@@ -5425,11 +5463,24 @@ export class Engine {
5425
5463
 
5426
5464
  this.device.pushErrorScope("validation")
5427
5465
  try {
5466
+ const computePipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] })
5428
5467
  const compute = await this.device.createComputePipelineAsync({
5429
5468
  label: "particle compute pipeline",
5430
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
5469
+ layout: computePipelineLayout,
5431
5470
  compute: { module: computeModule, entryPoint: "main" },
5432
5471
  })
5472
+ const live =
5473
+ indirect && countLayout
5474
+ ? {
5475
+ pipeline: await this.device.createComputePipelineAsync({
5476
+ label: "particle count pipeline",
5477
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [countLayout] }),
5478
+ compute: { module: computeModule, entryPoint: "rzCount" },
5479
+ }),
5480
+ indirect,
5481
+ binds: bindFor(countLayout, this.cameraUniformBuffer, false, true),
5482
+ }
5483
+ : null
5433
5484
  const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] })
5434
5485
  // The prepass, when the effect gave the cutout a cheap shape: coverage to
5435
5486
  // depth, colour targets at writeMask 0 (the depth-prepass class), and
@@ -5472,6 +5523,7 @@ export class Engine {
5472
5523
  buffer.destroy()
5473
5524
  uniform.destroy()
5474
5525
  points?.buffer.destroy()
5526
+ indirect?.destroy()
5475
5527
  return { ok: false, diagnostics: [scoped.message] }
5476
5528
  }
5477
5529
  return {
@@ -5487,18 +5539,20 @@ export class Engine {
5487
5539
  counts: uniformView.uints,
5488
5540
  compute,
5489
5541
  computeLayout,
5490
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
5542
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false, false),
5491
5543
  depth,
5492
5544
  render,
5493
5545
  renderLayout,
5494
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
5495
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
5546
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true, false),
5547
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true, false),
5496
5548
  rebind: () => ({
5497
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
5498
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
5499
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
5549
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false, false),
5550
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true, false),
5551
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true, false),
5552
+ countBinds: countLayout ? bindFor(countLayout, this.cameraUniformBuffer, false, true) : null,
5500
5553
  }),
5501
5554
  points,
5555
+ live,
5502
5556
  },
5503
5557
  }
5504
5558
  } catch (e) {
@@ -5506,6 +5560,7 @@ export class Engine {
5506
5560
  buffer.destroy()
5507
5561
  uniform.destroy()
5508
5562
  points?.buffer.destroy()
5563
+ indirect?.destroy()
5509
5564
  return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
5510
5565
  }
5511
5566
  }
@@ -5698,10 +5753,22 @@ export class Engine {
5698
5753
  p.counts[3] = this.particleFrame++
5699
5754
  this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer as ArrayBuffer)
5700
5755
  if (p.points) this.writePoints(p.points)
5756
+ const bind = p.computeBinds[e.grid?.parity ?? 0]
5757
+ if (p.live) {
5758
+ // The frame's live count first, in a pass of its own: a buffer may
5759
+ // not be written as storage and read as indirect arguments inside one
5760
+ // pass, and a pass is the synchronization scope.
5761
+ const count = encoder.beginComputePass({ label: "particle count" })
5762
+ count.setPipeline(p.live.pipeline)
5763
+ count.setBindGroup(0, p.live.binds[e.grid?.parity ?? 0])
5764
+ count.dispatchWorkgroups(1)
5765
+ count.end()
5766
+ }
5701
5767
  const cp = encoder.beginComputePass({ label: "particles" })
5702
5768
  cp.setPipeline(p.compute)
5703
- cp.setBindGroup(0, p.computeBinds[e.grid?.parity ?? 0])
5704
- cp.dispatchWorkgroups(Math.ceil(p.count / 64))
5769
+ cp.setBindGroup(0, bind)
5770
+ if (p.live) cp.dispatchWorkgroupsIndirect(p.live.indirect, 0)
5771
+ else cp.dispatchWorkgroups(Math.ceil(p.count / 64))
5705
5772
  cp.end()
5706
5773
  }
5707
5774
  }
@@ -5742,14 +5809,19 @@ export class Engine {
5742
5809
  if (!p || e.weight === 0) continue
5743
5810
  const parity = e.grid?.parity ?? 0
5744
5811
  const bind = view === "mirror" ? p.mirrorRenderBinds[parity] : p.renderBinds[parity]
5812
+ // The live count's quads when the effect declared one, else the pool's.
5813
+ const draw = () => {
5814
+ if (p.live) pass.drawIndirect(p.live.indirect, PARTICLE_INDIRECT_DRAW_OFFSET)
5815
+ else pass.draw(6, p.count)
5816
+ }
5745
5817
  if (p.depth) {
5746
5818
  pass.setPipeline(p.depth)
5747
5819
  pass.setBindGroup(0, bind)
5748
- pass.draw(6, p.count)
5820
+ draw()
5749
5821
  }
5750
5822
  pass.setPipeline(p.render)
5751
5823
  pass.setBindGroup(0, bind)
5752
- pass.draw(6, p.count)
5824
+ draw()
5753
5825
  }
5754
5826
  }
5755
5827
 
@@ -6128,6 +6200,7 @@ export class Engine {
6128
6200
  e.particles?.buffer.destroy()
6129
6201
  e.particles?.uniform.destroy()
6130
6202
  e.particles?.points?.buffer.destroy()
6203
+ e.particles?.live?.indirect.destroy()
6131
6204
  e.particles = null
6132
6205
  }
6133
6206
  }
@@ -7233,6 +7306,12 @@ export class Engine {
7233
7306
  [1, 1],
7234
7307
  )
7235
7308
 
7309
+ this.noFillBuffer = this.device.createBuffer({
7310
+ label: "model fill (none)",
7311
+ size: 16,
7312
+ usage: GPUBufferUsage.UNIFORM,
7313
+ })
7314
+
7236
7315
  // Generic shared-toon ramp: lit white down to a soft cool shadow tone with
7237
7316
  // a tight terminator around the midpoint, approximating MMD's toon ramps.
7238
7317
  const TOON_H = 64
@@ -7343,6 +7422,8 @@ export class Engine {
7343
7422
  visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
7344
7423
  buffer: { type: "read-only-storage" },
7345
7424
  },
7425
+ // The model's own fill — see setModelFill.
7426
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
7346
7427
  ],
7347
7428
  })
7348
7429
  // group 2: per-material (textures + material uniforms) — bound per draw call.
@@ -10661,6 +10742,54 @@ export class Engine {
10661
10742
  this.modelInstances.set(newName, inst)
10662
10743
  }
10663
10744
 
10745
+ /**
10746
+ * Light one model with more than the world gives it: a FILL, linear RGB added
10747
+ * to its ambient everywhere on it. Null takes it away again.
10748
+ *
10749
+ * A game lights its stage and its characters apart. Aether Gazer's rooms are
10750
+ * lit by their lamps and a near-black ambient, while its characters take a
10751
+ * flat base light of their own — so a stage whose World is right for the room
10752
+ * leaves a face turned from the lamps in the dark. This is that second light,
10753
+ * for the models the host counts as cast.
10754
+ */
10755
+ setModelFill(name: string, fill: Vec3 | null): boolean {
10756
+ const inst = this.modelInstances.get(name)
10757
+ if (!inst || !this.device) return false
10758
+ if (!fill) {
10759
+ if (!inst.fillBuffer) return true
10760
+ const retired = inst.fillBuffer
10761
+ inst.fillBuffer = null
10762
+ inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, null)
10763
+ this.bundlesDirty = true
10764
+ // Retired once the GPU is done with it: an encoded frame may still name it.
10765
+ void this.device.queue.onSubmittedWorkDone().then(() => retired.destroy())
10766
+ return true
10767
+ }
10768
+ if (!inst.fillBuffer) {
10769
+ inst.fillBuffer = this.device.createBuffer({
10770
+ label: `${name}: fill`,
10771
+ size: 16,
10772
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
10773
+ })
10774
+ inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, inst.fillBuffer)
10775
+ this.bundlesDirty = true
10776
+ }
10777
+ this.device.queue.writeBuffer(inst.fillBuffer, 0, new Float32Array([fill.x, fill.y, fill.z, 0]))
10778
+ return true
10779
+ }
10780
+
10781
+ /** The per-model group: its skinning matrices and its fill, or the zero stand-in. */
10782
+ private perInstanceBindGroup(name: string, skinMatrixBuffer: GPUBuffer, fill: GPUBuffer | null): GPUBindGroup {
10783
+ return this.device.createBindGroup({
10784
+ label: `${name}: main per-instance bind group`,
10785
+ layout: this.mainPerInstanceBindGroupLayout,
10786
+ entries: [
10787
+ { binding: 0, resource: { buffer: skinMatrixBuffer } },
10788
+ { binding: 1, resource: { buffer: fill ?? this.noFillBuffer } },
10789
+ ],
10790
+ })
10791
+ }
10792
+
10664
10793
  removeModel(name: string): void {
10665
10794
  const inst = this.modelInstances.get(name)
10666
10795
  if (!inst) return
@@ -10690,6 +10819,7 @@ export class Engine {
10690
10819
  for (const buf of inst.gpuBuffers) {
10691
10820
  buf.destroy()
10692
10821
  }
10822
+ inst.fillBuffer?.destroy()
10693
10823
  // Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
10694
10824
  for (const install of inst.styleGroups.values()) this.destroyInstall(install)
10695
10825
  this.modelInstances.delete(name)
@@ -11725,9 +11855,11 @@ export class Engine {
11725
11855
  const src = wrapped ? entry.source : entry
11726
11856
  const width = Math.max(1, "naturalWidth" in src ? src.naturalWidth : src.width)
11727
11857
  const height = Math.max(1, "naturalHeight" in src ? src.naturalHeight : src.height)
11858
+ const mipLevelCount = wrapped && entry.mipmaps ? Math.floor(Math.log2(Math.max(width, height))) + 1 : 1
11728
11859
  const tex = this.device.createTexture({
11729
11860
  label: `group map: ${group.id}`,
11730
11861
  size: [width, height],
11862
+ mipLevelCount,
11731
11863
  // Colour maps decode to linear on sample, the way material textures do;
11732
11864
  // data maps must not, or every threshold packed in their channels moves.
11733
11865
  format: wrapped && entry.srgb ? "rgba8unorm-srgb" : "rgba8unorm",
@@ -11738,6 +11870,7 @@ export class Engine {
11738
11870
  { texture: tex, premultipliedAlpha: wrapped && entry.premultiplied === true },
11739
11871
  [width, height],
11740
11872
  )
11873
+ if (mipLevelCount > 1) this.generateMipmaps(tex, mipLevelCount)
11741
11874
  return tex
11742
11875
  })
11743
11876
  }
@@ -13192,11 +13325,7 @@ export class Engine {
13192
13325
  }),
13193
13326
  )
13194
13327
 
13195
- const mainPerInstanceBindGroup = this.device.createBindGroup({
13196
- label: `${name}: main per-instance bind group`,
13197
- layout: this.mainPerInstanceBindGroupLayout,
13198
- entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
13199
- })
13328
+ const mainPerInstanceBindGroup = this.perInstanceBindGroup(name, skinMatrixBuffer, null)
13200
13329
 
13201
13330
  const pickPerInstanceBindGroup = this.device.createBindGroup({
13202
13331
  label: `${name}: pick per-instance bind group`,
@@ -13235,6 +13364,7 @@ export class Engine {
13235
13364
  shadowDrawCalls: [],
13236
13365
  shadowBindGroups,
13237
13366
  mainPerInstanceBindGroup,
13367
+ fillBuffer: null,
13238
13368
  pickPerInstanceBindGroup,
13239
13369
  pickDrawCalls: [],
13240
13370
  isStage,
@@ -14241,24 +14371,28 @@ export class Engine {
14241
14371
  // Bilinear box-filter downsample per level. Reads srgb view (hardware linearizes on sample,
14242
14372
  // re-encodes on write), so intensities are filtered in linear space — matching EEVEE/Blender.
14243
14373
  private generateMipmaps(texture: GPUTexture, mipLevelCount: number) {
14244
- if (!this.mipBlitPipeline || !this.mipBlitSampler) {
14374
+ if (!this.mipBlitSampler) {
14245
14375
  this.mipBlitSampler = this.device.createSampler({
14246
14376
  magFilter: "linear",
14247
14377
  minFilter: "linear",
14248
14378
  addressModeU: "clamp-to-edge",
14249
14379
  addressModeV: "clamp-to-edge",
14250
14380
  })
14381
+ }
14382
+ let pipeline = this.mipBlitPipelines.get(texture.format)
14383
+ if (!pipeline) {
14251
14384
  const module = this.device.createShaderModule({
14252
14385
  label: "mipmap blit",
14253
14386
  code: MIPMAP_BLIT_SHADER_WGSL,
14254
14387
  })
14255
- this.mipBlitPipeline = this.device.createRenderPipeline({
14256
- label: "mipmap blit pipeline",
14388
+ pipeline = this.device.createRenderPipeline({
14389
+ label: `mipmap blit pipeline (${texture.format})`,
14257
14390
  layout: "auto",
14258
14391
  vertex: { module, entryPoint: "vs" },
14259
- fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm-srgb" }] },
14392
+ fragment: { module, entryPoint: "fs", targets: [{ format: texture.format }] },
14260
14393
  primitive: { topology: "triangle-list" },
14261
14394
  })
14395
+ this.mipBlitPipelines.set(texture.format, pipeline)
14262
14396
  }
14263
14397
 
14264
14398
  const encoder = this.device.createCommandEncoder({ label: "mipgen" })
@@ -14266,7 +14400,7 @@ export class Engine {
14266
14400
  const srcView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 })
14267
14401
  const dstView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 })
14268
14402
  const bindGroup = this.device.createBindGroup({
14269
- layout: this.mipBlitPipeline.getBindGroupLayout(0),
14403
+ layout: pipeline.getBindGroupLayout(0),
14270
14404
  entries: [
14271
14405
  { binding: 0, resource: srcView },
14272
14406
  { binding: 1, resource: this.mipBlitSampler },
@@ -14277,7 +14411,7 @@ export class Engine {
14277
14411
  { view: dstView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
14278
14412
  ],
14279
14413
  })
14280
- pass.setPipeline(this.mipBlitPipeline)
14414
+ pass.setPipeline(pipeline)
14281
14415
  pass.setBindGroup(0, bindGroup)
14282
14416
  pass.draw(3)
14283
14417
  pass.end()
@@ -133,7 +133,7 @@ ${gate}
133
133
  let sun = light.lights[0].color.xyz * light.lights[0].color.w;
134
134
  // The world: flat colour, or the HDRI's irradiance at this normal — which
135
135
  // is what makes a loaded sky actually light her instead of only backing her.
136
- let amb = rzWorldAmbient(n);
136
+ let amb = rzWorldAmbient(n) + modelFill.rgb;
137
137
  let shadow = sampleShadow(input.worldPos, n);
138
138
  let tex_color = tex_s.rgb;
139
139
 
@@ -80,7 +80,18 @@ export type GroupImageSource = ImageBitmap | HTMLImageElement | HTMLCanvasElemen
80
80
  * converts to the destination's tagged alpha mode, so a premultiplied ImageBitmap
81
81
  * copied into an untagged texture is un-premultiplied again on the way in.
82
82
  */
83
- export type GroupImage = { source: GroupImageSource; srgb?: boolean; premultiplied?: boolean }
83
+ export type GroupImage = {
84
+ source: GroupImageSource
85
+ srgb?: boolean
86
+ premultiplied?: boolean
87
+ /**
88
+ * Give it a mip chain. For a map TILED across a surface — a relief map, a
89
+ * water's ripples — whose far tiles otherwise shrink to a pixel each and
90
+ * sample as noise that sparkles with every camera move. Off by default: a
91
+ * ramp read at a computed coordinate would blur at its own terminator.
92
+ */
93
+ mipmaps?: boolean
94
+ }
84
95
 
85
96
  export type GroupDiagnostic = { groupId: string; diagnostics: Diagnostic[]; ok: boolean }
86
97
 
@@ -146,6 +146,9 @@ struct LightVP { viewProj: array<mat4x4f, ${SHADOW_CASCADES.length}>, };
146
146
  @group(0) @binding(8) var worldEnvTexture: texture_2d<f32>;
147
147
  // binding(9) brdfLut is declared inside NODES_WGSL (nodes.ts).
148
148
  @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
149
+ // Light this model receives beyond the world's — see Engine.setModelFill. Zero
150
+ // for a model nobody gave one, which leaves its ambient exactly the world's.
151
+ @group(1) @binding(1) var<uniform> modelFill: vec4f;
149
152
  @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
150
153
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
151
154
  // Reserved for future sphere/toon graph nodes; graphs that don't read them get the
@@ -101,6 +101,15 @@ type ParticleSource = {
101
101
  * where every stacked fragment is otherwise shaded in full; the prepass is
102
102
  * the shape of that rejection, done by hand and cheaply. */
103
103
  cover: boolean
104
+ /** The effect defines `particleCount() -> u32`: how many of the pool are
105
+ * live this frame, as a function of its dials. The step is dispatched and
106
+ * the quads drawn for that many slots and no more, through indirect
107
+ * arguments a one-thread kernel writes first. A pool sized for its densest
108
+ * setting then costs its current setting: a lawn's spare 1.3M slots were
109
+ * read, written and issued as degenerate quads every frame, and that
110
+ * traffic alone was a third of the frame. The live slots are always the
111
+ * first N — an author whose live set is not a prefix has no use for this. */
112
+ live: boolean
104
113
  /** The side of this effect's grid, or 0 when it declared none. Particles READ
105
114
  * the grid; the kernel that steps it lives in the grid pass. A blade of grass
106
115
  * bending where a foot pressed is the whole case: the bend has to persist and
@@ -112,6 +121,12 @@ type ParticleSource = {
112
121
  export const PARTICLE_LIGHT_BINDING = 10
113
122
  /** The effect's named points (`#points`), in both stages — see points-api.ts. */
114
123
  export const PARTICLE_POINTS_BINDING = 15
124
+ /** The indirect arguments a `particleCount` effect writes — compute stage only.
125
+ * Eight u32: the step's dispatch (groups, 1, 1, pad) then the quads' draw
126
+ * (6, count, 0, 0), so the draw reads at byte 16. */
127
+ export const PARTICLE_INDIRECT_BINDING = 16
128
+ export const PARTICLE_INDIRECT_BYTES = 32
129
+ export const PARTICLE_INDIRECT_DRAW_OFFSET = 16
115
130
 
116
131
  /** Bytes per particle. Explicitly padded — see the struct below. */
117
132
  export const PARTICLE_STRIDE = 48
@@ -171,13 +186,15 @@ fn rzCamPos() -> vec3f { return cam.camPos; }
171
186
  `
172
187
 
173
188
  /** Does the source define the particle contract? All three are required
174
- * together; `cover` is optional and only means anything to a cutout. */
175
- export function particleEntryPoints(wgsl: string): { init: boolean; step: boolean; shade: boolean; cover: boolean } {
189
+ * together; `cover` is optional and only means anything to a cutout, and
190
+ * `count` is optional and bounds the frame's work — see particleCount. */
191
+ export function particleEntryPoints(wgsl: string): { init: boolean; step: boolean; shade: boolean; cover: boolean; count: boolean } {
176
192
  return {
177
193
  init: /\bfn\s+particleInit\s*\(/.test(wgsl),
178
194
  step: /\bfn\s+particleStep\s*\(/.test(wgsl),
179
195
  shade: /\bfn\s+particleShade\s*\(/.test(wgsl),
180
196
  cover: /\bfn\s+particleCover\s*\(/.test(wgsl),
197
+ count: /\bfn\s+particleCount\s*\(/.test(wgsl),
181
198
  }
182
199
  }
183
200
 
@@ -217,8 +234,29 @@ ${src.paramsDecl}
217
234
  idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) +
218
235
  // The named points, for real: particleInit is where a flame is put on a wick.
219
236
  pointsApi(true, 0, PARTICLE_POINTS_BINDING) +
237
+ (src.live
238
+ ? `@group(0) @binding(${PARTICLE_INDIRECT_BINDING}) var<storage, read_write> _rzIndirect: array<u32, 8>;\n`
239
+ : "") +
220
240
  "\n// ── user effect ──\n" +
221
241
  src.wgsl +
242
+ (src.live
243
+ ? /* wgsl */ `
244
+ // The frame's live count, from the author's dials, into the arguments the
245
+ // step dispatch and both draws read. One thread; runs before the step.
246
+ @compute @workgroup_size(1)
247
+ fn rzCount() {
248
+ let n = min(particleCount(), pu.count);
249
+ _rzIndirect[0] = (n + 63u) / 64u;
250
+ _rzIndirect[1] = 1u;
251
+ _rzIndirect[2] = 1u;
252
+ _rzIndirect[3] = 0u;
253
+ _rzIndirect[4] = 6u;
254
+ _rzIndirect[5] = n;
255
+ _rzIndirect[6] = 0u;
256
+ _rzIndirect[7] = 0u;
257
+ }
258
+ `
259
+ : "") +
222
260
  /* wgsl */ `
223
261
  @compute @workgroup_size(64)
224
262
  fn main(@builtin(global_invocation_id) gid: vec3u) {