reze-engine 0.58.1 → 0.58.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/effect-points.d.ts +30 -0
  2. package/dist/effect-points.d.ts.map +1 -0
  3. package/dist/effect-points.js +49 -0
  4. package/dist/engine.d.ts +25 -2
  5. package/dist/engine.d.ts.map +1 -1
  6. package/dist/engine.js +199 -35
  7. package/dist/graph/style-group.d.ts +7 -0
  8. package/dist/graph/style-group.d.ts.map +1 -1
  9. package/dist/light-grid.d.ts +25 -0
  10. package/dist/light-grid.d.ts.map +1 -0
  11. package/dist/light-grid.js +91 -0
  12. package/dist/model.d.ts +9 -0
  13. package/dist/model.d.ts.map +1 -1
  14. package/dist/model.js +6 -0
  15. package/dist/pmx-loader.d.ts.map +1 -1
  16. package/dist/pmx-loader.js +11 -6
  17. package/dist/shaders/cast-api.d.ts +1 -1
  18. package/dist/shaders/cast-layout.d.ts +5 -3
  19. package/dist/shaders/cast-layout.d.ts.map +1 -1
  20. package/dist/shaders/cast-layout.js +5 -3
  21. package/dist/shaders/directives.d.ts +3 -0
  22. package/dist/shaders/directives.d.ts.map +1 -1
  23. package/dist/shaders/directives.js +5 -0
  24. package/dist/shaders/lights.d.ts +14 -9
  25. package/dist/shaders/lights.d.ts.map +1 -1
  26. package/dist/shaders/lights.js +118 -65
  27. package/dist/shaders/passes/composite.d.ts +1 -1
  28. package/dist/shaders/passes/composite.d.ts.map +1 -1
  29. package/dist/shaders/passes/composite.js +2 -0
  30. package/dist/shaders/passes/grid.d.ts.map +1 -1
  31. package/dist/shaders/passes/grid.js +2 -0
  32. package/dist/shaders/passes/particles.d.ts +20 -1
  33. package/dist/shaders/passes/particles.d.ts.map +1 -1
  34. package/dist/shaders/passes/particles.js +36 -1
  35. package/dist/shaders/passes/trails.d.ts.map +1 -1
  36. package/dist/shaders/passes/trails.js +2 -1
  37. package/dist/shaders/points-api.d.ts +11 -0
  38. package/dist/shaders/points-api.d.ts.map +1 -0
  39. package/dist/shaders/points-api.js +58 -0
  40. package/package.json +1 -1
  41. package/src/effect-points.ts +67 -0
  42. package/src/engine.ts +215 -35
  43. package/src/graph/style-group.ts +12 -1
  44. package/src/light-grid.ts +139 -0
  45. package/src/model.ts +12 -0
  46. package/src/pmx-loader.ts +13 -6
  47. package/src/shaders/cast-layout.ts +5 -3
  48. package/src/shaders/directives.ts +8 -0
  49. package/src/shaders/lights.ts +118 -65
  50. package/src/shaders/passes/composite.ts +2 -0
  51. package/src/shaders/passes/grid.ts +2 -0
  52. package/src/shaders/passes/particles.ts +46 -2
  53. package/src/shaders/passes/trails.ts +2 -1
  54. package/src/shaders/points-api.ts +66 -0
package/src/engine.ts CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  type SceneFormats,
60
60
  } from "./shaders/passes/scene-contract"
61
61
  import {
62
+ LIGHT_GRID_BASE,
62
63
  LIGHT_HEADER,
63
64
  LIGHT_STRIDE,
64
65
  LIGHTS_FLOATS,
@@ -66,6 +67,7 @@ import {
66
67
  buildLightEmitShader,
67
68
  hasLightEmit,
68
69
  } from "./shaders/lights"
70
+ import { buildLightGrid } from "./light-grid"
69
71
  import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground"
70
72
  import { outlineShaderWgsl, RZ_OUTLINE_DISSOLVE_OFFSET } from "./shaders/passes/outline"
71
73
  import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass"
@@ -114,9 +116,15 @@ import {
114
116
  buildParticleComputeShader,
115
117
  buildParticleRenderShader,
116
118
  PARTICLE_LIGHT_BINDING,
119
+ PARTICLE_POINTS_BINDING,
117
120
  particleEntryPoints,
121
+ PARTICLE_INDIRECT_BINDING,
122
+ PARTICLE_INDIRECT_BYTES,
123
+ PARTICLE_INDIRECT_DRAW_OFFSET,
118
124
  PARTICLE_STRIDE,
119
125
  } from "./shaders/passes/particles"
126
+ import { MAX_EFFECT_POINTS, POINTS_FLOATS } from "./shaders/points-api"
127
+ import { bonesWithPrefix, pointsData, writeBonePoint } from "./effect-points"
120
128
  import {
121
129
  SIM_FORMAT,
122
130
  GRID_MAX,
@@ -1446,7 +1454,16 @@ interface EffectParticles {
1446
1454
  computeBinds: [GPUBindGroup, GPUBindGroup]
1447
1455
  renderBinds: [GPUBindGroup, GPUBindGroup]
1448
1456
  mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
1449
- }
1457
+ countBinds: [GPUBindGroup, GPUBindGroup] | null
1458
+ }
1459
+ /** `#points`: the bone-name prefix and the buffer its matches are written
1460
+ * to each frame. Null when the effect declared none — it then reads the
1461
+ * engine's empty fallback. */
1462
+ points: { prefix: string; buffer: GPUBuffer; data: Float32Array } | null
1463
+ /** `particleCount`: the kernel that writes the frame's live count and the
1464
+ * indirect arguments it writes them to. Null when the effect declared none —
1465
+ * the whole pool is then stepped and drawn. */
1466
+ live: { pipeline: GPUComputePipeline; indirect: GPUBuffer; binds: [GPUBindGroup, GPUBindGroup] } | null
1450
1467
  }
1451
1468
 
1452
1469
  /**
@@ -2076,12 +2093,13 @@ export class Engine {
2076
2093
  * prefix sum and readback a compacted draw list would need every frame.
2077
2094
  */
2078
2095
  /** Ceiling for `#particles`. Past this an author is asking for a stall. */
2079
- // Raised for the lawn: 65536 blades at real density is a 40-unit patch and
2080
- // an 80-unit one wants four hundred thousand. A cutout pool's cost is
2096
+ // Raised for the lawn: an 80-unit field at 36 blades a unit wants seven
2097
+ // hundred thousand, and a turf one three million. A cutout pool's cost is
2081
2098
  // bounded by the pixels it covers, not by the count — the step is one
2082
- // compute invocation per slot, the pool buffer is 48 bytes each (24 MB at
2083
- // the cap), and a culled blade is a degenerate quad the rasteriser drops.
2084
- private static readonly MAX_PARTICLES = 524288
2099
+ // compute invocation per slot, the pool buffer is 48 bytes each (96 MiB at
2100
+ // the cap, under the 128 MiB a storage binding may be without asking for
2101
+ // more), and a culled blade is a degenerate quad the rasteriser drops.
2102
+ private static readonly MAX_PARTICLES = 2097152
2085
2103
  private particleFrame = 0
2086
2104
  /**
2087
2105
  * The installed effect's persistent grid, or null when it declared none.
@@ -2498,9 +2516,20 @@ export class Engine {
2498
2516
  * Allocated once at full size and zero-filled, so "no lights" is a count of
2499
2517
  * zero rather than an absent binding. */
2500
2518
  private lightsBuffer!: GPUBuffer
2501
- private lightsData!: Float32Array<ArrayBuffer>
2502
- /** Just the header, for rewriting the total without touching a record. */
2519
+ /** What a particle effect with no `#points` reads at its points binding: a
2520
+ * count of zero. One buffer for all of them — nothing ever writes it. */
2521
+ private pointsFallback!: GPUBuffer
2522
+ /** Per model, per prefix, the bones `#points` matched. A rig's bones never
2523
+ * change after load, so the names are walked once rather than every frame. */
2524
+ private pointBones = new WeakMap<Model, Map<string, number[]>>()
2525
+ /** The CPU copy, from construction rather than init: a host sets a scene's
2526
+ * lamps as soon as its state exists, which can be before the device. */
2527
+ private lightsData = new Float32Array(LIGHTS_FLOATS)
2528
+ /** The same bytes as words — the grid's lamp bits are u32, not floats. */
2529
+ private lightsWords = new Uint32Array(this.lightsData.buffer)
2530
+ /** Just the header: the counts and the grid's placement. */
2503
2531
  private lightHeader = new Float32Array(LIGHT_HEADER)
2532
+ private lightHeaderWords = new Uint32Array(this.lightHeader.buffer)
2504
2533
  /** How many of the slots belong to the DOCUMENT. Effects get what follows. */
2505
2534
  private docLightCount = 0
2506
2535
  private castData!: Float32Array<ArrayBuffer>
@@ -2634,7 +2663,9 @@ export class Engine {
2634
2663
  // GPU texture can't be read back cheaply, and PMX diffuse alpha is usually 1.0
2635
2664
  // even for see-through cloth: the translucency lives in the texture).
2636
2665
  private textureAlphaCache = new Map<string, { a: Uint8ClampedArray; w: number; h: number } | null>()
2637
- private mipBlitPipeline: GPURenderPipeline | null = null
2666
+ /** One per format: a render pipeline is bound to its target's format, and
2667
+ * material textures are sRGB while a group's data maps are not. */
2668
+ private mipBlitPipelines = new Map<GPUTextureFormat, GPURenderPipeline>()
2638
2669
  private mipBlitSampler: GPUSampler | null = null
2639
2670
  private _nextDefaultModelId = 0
2640
2671
 
@@ -4327,6 +4358,7 @@ export class Engine {
4327
4358
  e.particles.computeBinds = b.computeBinds
4328
4359
  e.particles.renderBinds = b.renderBinds
4329
4360
  e.particles.mirrorRenderBinds = b.mirrorRenderBinds
4361
+ if (e.particles.live && b.countBinds) e.particles.live.binds = b.countBinds
4330
4362
  }
4331
4363
  if (e.grid) e.grid.binds = [this.gridBindGroup(e.grid, 0), this.gridBindGroup(e.grid, 1)]
4332
4364
  if (e.lights) e.lights.bind = this.lightEmitBindGroup(e.lights.layout, e.lights.uniform, e.lights.params)
@@ -4864,6 +4896,8 @@ export class Engine {
4864
4896
  paramsBuffer?.destroy()
4865
4897
  particles?.buffer.destroy()
4866
4898
  particles?.uniform.destroy()
4899
+ particles?.points?.buffer.destroy()
4900
+ particles?.live?.indirect.destroy()
4867
4901
  grid?.textures[0].destroy()
4868
4902
  grid?.textures[1].destroy()
4869
4903
  grid?.uniform.destroy()
@@ -5246,8 +5280,20 @@ export class Engine {
5246
5280
  paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
5247
5281
  gridSize: grid?.size ?? 0,
5248
5282
  cover: particleEntryPoints(wgsl).cover,
5283
+ live: particleEntryPoints(wgsl).count,
5249
5284
  }
5250
5285
  const prepass = src.blend === "cutout" && src.cover
5286
+ const points = d.points
5287
+ ? {
5288
+ prefix: d.points,
5289
+ buffer: this.device.createBuffer({
5290
+ label: `points "${d.points}"`,
5291
+ size: POINTS_FLOATS * 4,
5292
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
5293
+ }),
5294
+ data: pointsData(),
5295
+ }
5296
+ : null
5251
5297
  // Sparks want to spawn where a trail is, so the particle stages see the same
5252
5298
  // cast buffer the trail draw reads.
5253
5299
  const cast = {
@@ -5274,9 +5320,15 @@ export class Engine {
5274
5320
  }
5275
5321
 
5276
5322
  const computeModule = await compile(buildParticleComputeShader(src, cast), "particle compute")
5277
- if (Array.isArray(computeModule)) return { ok: false, diagnostics: computeModule }
5323
+ if (Array.isArray(computeModule)) {
5324
+ points?.buffer.destroy()
5325
+ return { ok: false, diagnostics: computeModule }
5326
+ }
5278
5327
  const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render")
5279
- if (Array.isArray(renderModule)) return { ok: false, diagnostics: renderModule }
5328
+ if (Array.isArray(renderModule)) {
5329
+ points?.buffer.destroy()
5330
+ return { ok: false, diagnostics: renderModule }
5331
+ }
5280
5332
 
5281
5333
  // COPY_DST so a scheduled effect can empty its pool when its window starts.
5282
5334
  const buffer = this.device.createBuffer({
@@ -5294,6 +5346,16 @@ export class Engine {
5294
5346
  })
5295
5347
  const uniformBytes = new ArrayBuffer(32)
5296
5348
  const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) }
5349
+ // The live count's arguments — written by rzCount, read by the step's
5350
+ // dispatch and the quads' draws. Zero until the first step, so a pool
5351
+ // drawn before it has stepped draws nothing rather than everything.
5352
+ const indirect = src.live
5353
+ ? this.device.createBuffer({
5354
+ label: "particle indirect",
5355
+ size: PARTICLE_INDIRECT_BYTES,
5356
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT,
5357
+ })
5358
+ : null
5297
5359
 
5298
5360
  // Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
5299
5361
  // visible to the vertex stage at all (WebGPU forbids it — a vertex shader
@@ -5301,7 +5363,7 @@ export class Engine {
5301
5363
  // Declaring one set of flags for both layouts is what made the pipeline
5302
5364
  // layout invalid, and the error surfaces later and unhelpfully as "invalid
5303
5365
  // due to a previous error".
5304
- const layoutFor = (storage: GPUBufferBindingType, visibility: number, shadow: boolean) =>
5366
+ const layoutFor = (storage: GPUBufferBindingType, visibility: number, shadow: boolean, withIndirect: boolean) =>
5305
5367
  this.device.createBindGroupLayout({
5306
5368
  entries: [
5307
5369
  { binding: 0, visibility, buffer: { type: storage } },
@@ -5321,6 +5383,9 @@ export class Engine {
5321
5383
  // author calls it or not. With no grid it is the 1x1 of zeroes.
5322
5384
  { binding: 8, visibility, texture: { sampleType: "float" as const } },
5323
5385
  { binding: 9, visibility, sampler: { type: "filtering" as const } },
5386
+ // The named points, ALWAYS, for the grid's reason: the accessor is
5387
+ // compiled in whether or not the effect declared #points.
5388
+ { binding: PARTICLE_POINTS_BINDING, visibility, buffer: { type: "read-only-storage" as const } },
5324
5389
  // The scene's light, for rzShadow and rzWorldAmbient — the shading
5325
5390
  // stage only; the compute module compiles the stubs. See
5326
5391
  // scene-light-api.ts.
@@ -5333,13 +5398,21 @@ export class Engine {
5333
5398
  { binding: PARTICLE_LIGHT_BINDING + 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" as const } },
5334
5399
  ]
5335
5400
  : []),
5401
+ // The live count's arguments, for the COUNT kernel's layout alone. The
5402
+ // step's layout must not carry them: a bound group's entries count
5403
+ // toward a pass's usage whether the shader reads them or not, and a
5404
+ // buffer may not be writable storage and indirect arguments in one
5405
+ // pass.
5406
+ ...(withIndirect
5407
+ ? [{ binding: PARTICLE_INDIRECT_BINDING, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" as const } }]
5408
+ : []),
5336
5409
  ],
5337
5410
  })
5338
5411
  /** One per GRID PARITY, for the reason rebuildFieldBindGroup gives: the grid
5339
5412
  * alternates which texture is current, and rebuilding a single group every
5340
5413
  * frame is waste for a change that only ever toggles between two known
5341
5414
  * states. Both entries are the same view when there is no grid. */
5342
- const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer, shadow: boolean) =>
5415
+ const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer, shadow: boolean, withIndirect: boolean) =>
5343
5416
  [0, 1].map((parity) =>
5344
5417
  this.device.createBindGroup({
5345
5418
  layout,
@@ -5354,6 +5427,7 @@ export class Engine {
5354
5427
  ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
5355
5428
  { binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
5356
5429
  { binding: 9, resource: this.simSampler },
5430
+ { binding: PARTICLE_POINTS_BINDING, resource: { buffer: points?.buffer ?? this.pointsFallback } },
5357
5431
  ...(shadow
5358
5432
  ? [
5359
5433
  { binding: PARTICLE_LIGHT_BINDING, resource: { buffer: this.lightUniformBuffer } },
@@ -5363,12 +5437,14 @@ export class Engine {
5363
5437
  { binding: PARTICLE_LIGHT_BINDING + 4, resource: this.shadowComparisonSampler },
5364
5438
  ]
5365
5439
  : []),
5440
+ ...(withIndirect && indirect ? [{ binding: PARTICLE_INDIRECT_BINDING, resource: { buffer: indirect } }] : []),
5366
5441
  ],
5367
5442
  }),
5368
5443
  ) as [GPUBindGroup, GPUBindGroup]
5369
5444
 
5370
- const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false)
5371
- const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true)
5445
+ const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false, false)
5446
+ const countLayout = indirect ? layoutFor("storage", GPUShaderStage.COMPUTE, false, true) : null
5447
+ const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true, false)
5372
5448
 
5373
5449
  // Additive keeps the destination and adds to it, and leaves alpha alone, so
5374
5450
  // a glow does not claim coverage it never occluded. The MASK sums with it —
@@ -5383,11 +5459,24 @@ export class Engine {
5383
5459
 
5384
5460
  this.device.pushErrorScope("validation")
5385
5461
  try {
5462
+ const computePipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] })
5386
5463
  const compute = await this.device.createComputePipelineAsync({
5387
5464
  label: "particle compute pipeline",
5388
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
5465
+ layout: computePipelineLayout,
5389
5466
  compute: { module: computeModule, entryPoint: "main" },
5390
5467
  })
5468
+ const live =
5469
+ indirect && countLayout
5470
+ ? {
5471
+ pipeline: await this.device.createComputePipelineAsync({
5472
+ label: "particle count pipeline",
5473
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [countLayout] }),
5474
+ compute: { module: computeModule, entryPoint: "rzCount" },
5475
+ }),
5476
+ indirect,
5477
+ binds: bindFor(countLayout, this.cameraUniformBuffer, false, true),
5478
+ }
5479
+ : null
5391
5480
  const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] })
5392
5481
  // The prepass, when the effect gave the cutout a cheap shape: coverage to
5393
5482
  // depth, colour targets at writeMask 0 (the depth-prepass class), and
@@ -5429,6 +5518,8 @@ export class Engine {
5429
5518
  if (scoped) {
5430
5519
  buffer.destroy()
5431
5520
  uniform.destroy()
5521
+ points?.buffer.destroy()
5522
+ indirect?.destroy()
5432
5523
  return { ok: false, diagnostics: [scoped.message] }
5433
5524
  }
5434
5525
  return {
@@ -5444,23 +5535,28 @@ export class Engine {
5444
5535
  counts: uniformView.uints,
5445
5536
  compute,
5446
5537
  computeLayout,
5447
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
5538
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false, false),
5448
5539
  depth,
5449
5540
  render,
5450
5541
  renderLayout,
5451
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
5452
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
5542
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true, false),
5543
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true, false),
5453
5544
  rebind: () => ({
5454
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
5455
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
5456
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
5545
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false, false),
5546
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true, false),
5547
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true, false),
5548
+ countBinds: countLayout ? bindFor(countLayout, this.cameraUniformBuffer, false, true) : null,
5457
5549
  }),
5550
+ points,
5551
+ live,
5458
5552
  },
5459
5553
  }
5460
5554
  } catch (e) {
5461
5555
  await this.device.popErrorScope()
5462
5556
  buffer.destroy()
5463
5557
  uniform.destroy()
5558
+ points?.buffer.destroy()
5559
+ indirect?.destroy()
5464
5560
  return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
5465
5561
  }
5466
5562
  }
@@ -5591,6 +5687,7 @@ export class Engine {
5591
5687
  this.device.queue.writeBuffer(e.lights.uniform, 0, e.lights.data.buffer as ArrayBuffer)
5592
5688
  }
5593
5689
  this.lightHeader[0] = Math.min(next, MAX_LIGHTS)
5690
+ this.lightHeader[1] = this.docLightCount
5594
5691
  this.device.queue.writeBuffer(this.lightsBuffer, 0, this.lightHeader)
5595
5692
  }
5596
5693
 
@@ -5651,14 +5748,56 @@ export class Engine {
5651
5748
  p.counts[2] = p.count
5652
5749
  p.counts[3] = this.particleFrame++
5653
5750
  this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer as ArrayBuffer)
5751
+ if (p.points) this.writePoints(p.points)
5752
+ const bind = p.computeBinds[e.grid?.parity ?? 0]
5753
+ if (p.live) {
5754
+ // The frame's live count first, in a pass of its own: a buffer may
5755
+ // not be written as storage and read as indirect arguments inside one
5756
+ // pass, and a pass is the synchronization scope.
5757
+ const count = encoder.beginComputePass({ label: "particle count" })
5758
+ count.setPipeline(p.live.pipeline)
5759
+ count.setBindGroup(0, p.live.binds[e.grid?.parity ?? 0])
5760
+ count.dispatchWorkgroups(1)
5761
+ count.end()
5762
+ }
5654
5763
  const cp = encoder.beginComputePass({ label: "particles" })
5655
5764
  cp.setPipeline(p.compute)
5656
- cp.setBindGroup(0, p.computeBinds[e.grid?.parity ?? 0])
5657
- cp.dispatchWorkgroups(Math.ceil(p.count / 64))
5765
+ cp.setBindGroup(0, bind)
5766
+ if (p.live) cp.dispatchWorkgroupsIndirect(p.live.indirect, 0)
5767
+ else cp.dispatchWorkgroups(Math.ceil(p.count / 64))
5658
5768
  cp.end()
5659
5769
  }
5660
5770
  }
5661
5771
 
5772
+ /**
5773
+ * Every bone matching an effect's `#points` prefix, on every visible model,
5774
+ * posed and placed this frame. Every frame rather than on load: a candle a
5775
+ * character carries moves with the hand, and a stage can be moved.
5776
+ *
5777
+ * The order is the models' order, then rig order — stable while nothing is
5778
+ * added or removed, so particle i stays on point i and a flame does not
5779
+ * jump to another wick between frames.
5780
+ */
5781
+ private writePoints(points: { prefix: string; buffer: GPUBuffer; data: Float32Array }): void {
5782
+ let n = 0
5783
+ for (const inst of this.modelInstances.values()) {
5784
+ const m = inst.model
5785
+ if (!m.visible) continue
5786
+ let byPrefix = this.pointBones.get(m)
5787
+ if (!byPrefix) this.pointBones.set(m, (byPrefix = new Map()))
5788
+ let bones = byPrefix.get(points.prefix)
5789
+ const rig = m.getSkeleton().bones
5790
+ if (!bones) byPrefix.set(points.prefix, (bones = bonesWithPrefix(rig, points.prefix)))
5791
+ for (const i of bones) {
5792
+ if (n >= MAX_EFFECT_POINTS) break
5793
+ const world = m.getBoneWorldMatrixAt(i)
5794
+ if (world) writeBonePoint(points.data, n++, world, rig[i].tail, m)
5795
+ }
5796
+ }
5797
+ points.data[0] = n
5798
+ this.device.queue.writeBuffer(points.buffer, 0, points.data.buffer as ArrayBuffer, 0, (4 + n * 8) * 4)
5799
+ }
5800
+
5662
5801
  /** Draw the pool. Inside the scene pass, so it is depth-tested and pre-bloom. */
5663
5802
  private renderParticles(pass: GPURenderPassEncoder, view: "camera" | "mirror"): void {
5664
5803
  for (const e of this.effects) {
@@ -5666,14 +5805,19 @@ export class Engine {
5666
5805
  if (!p || e.weight === 0) continue
5667
5806
  const parity = e.grid?.parity ?? 0
5668
5807
  const bind = view === "mirror" ? p.mirrorRenderBinds[parity] : p.renderBinds[parity]
5808
+ // The live count's quads when the effect declared one, else the pool's.
5809
+ const draw = () => {
5810
+ if (p.live) pass.drawIndirect(p.live.indirect, PARTICLE_INDIRECT_DRAW_OFFSET)
5811
+ else pass.draw(6, p.count)
5812
+ }
5669
5813
  if (p.depth) {
5670
5814
  pass.setPipeline(p.depth)
5671
5815
  pass.setBindGroup(0, bind)
5672
- pass.draw(6, p.count)
5816
+ draw()
5673
5817
  }
5674
5818
  pass.setPipeline(p.render)
5675
5819
  pass.setBindGroup(0, bind)
5676
- pass.draw(6, p.count)
5820
+ draw()
5677
5821
  }
5678
5822
  }
5679
5823
 
@@ -6051,6 +6195,8 @@ export class Engine {
6051
6195
  for (const e of this.effects) {
6052
6196
  e.particles?.buffer.destroy()
6053
6197
  e.particles?.uniform.destroy()
6198
+ e.particles?.points?.buffer.destroy()
6199
+ e.particles?.live?.indirect.destroy()
6054
6200
  e.particles = null
6055
6201
  }
6056
6202
  }
@@ -7488,15 +7634,21 @@ export class Engine {
7488
7634
 
7489
7635
  // BEFORE the bind group below, which binds it. Full size from the start:
7490
7636
  // every material pipeline binds this, so sizing it to the light count would
7491
- // mean rebuilding bind groups whenever a scene gained a lamp. Zero-filled,
7492
- // and float 0 is a count of 0.
7493
- this.lightsData = new Float32Array(LIGHTS_FLOATS)
7637
+ // mean rebuilding bind groups whenever a scene gained a lamp. Uploaded from
7638
+ // the CPU copy, which holds any lamps set before the device existed; the
7639
+ // header after it, from the one writer that owns it.
7494
7640
  this.lightsBuffer = this.device.createBuffer({
7495
7641
  label: "positional lights",
7496
7642
  size: this.lightsData.byteLength,
7497
7643
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
7498
7644
  })
7499
7645
  this.device.queue.writeBuffer(this.lightsBuffer, 0, this.lightsData)
7646
+ this.allocateLightSlots()
7647
+ this.pointsFallback = this.device.createBuffer({
7648
+ label: "no points",
7649
+ size: 16,
7650
+ usage: GPUBufferUsage.STORAGE,
7651
+ })
7500
7652
  // BEFORE the per-frame groups, which bind it. The composite needs this 1x1
7501
7653
  // stand-in too, and it used to be created with the composite's own
7502
7654
  // resources further down — far enough down that a material group built up
@@ -10047,6 +10199,20 @@ export class Engine {
10047
10199
  this.lightsData[b + 14] = 0
10048
10200
  this.lightsData[b + 15] = 0
10049
10201
  }
10202
+ // The grid, from the records as STORED — normalised aim, clamped reach, the
10203
+ // cosines in f32 — so it indexes exactly what the shader will evaluate.
10204
+ const grid = buildLightGrid(
10205
+ list.map((_, i) => {
10206
+ const b = LIGHT_HEADER + i * LIGHT_STRIDE
10207
+ const d = this.lightsData
10208
+ return { x: d[b], y: d[b + 1], z: d[b + 2], radius: d[b + 3], ax: d[b + 8], ay: d[b + 9], az: d[b + 10], cosOuter: d[b + 11] }
10209
+ }),
10210
+ )
10211
+ this.lightHeader.set(grid.origin, 4)
10212
+ this.lightHeader[7] = 1 / grid.cell
10213
+ this.lightHeaderWords.set(grid.dims, 8)
10214
+ this.lightHeaderWords.set(grid.outside, 12)
10215
+ this.lightsWords.set(grid.cells, LIGHT_GRID_BASE)
10050
10216
  // The CPU copy is written either way; only the upload needs a device. A
10051
10217
  // scene whose lamps arrive before init keeps them, and init uploads them.
10052
10218
  if (list.length && this.device && this.lightsBuffer) {
@@ -10057,6 +10223,13 @@ export class Engine {
10057
10223
  LIGHT_HEADER * 4,
10058
10224
  list.length * LIGHT_STRIDE * 4,
10059
10225
  )
10226
+ this.device.queue.writeBuffer(
10227
+ this.lightsBuffer,
10228
+ LIGHT_GRID_BASE * 4,
10229
+ this.lightsData.buffer as ArrayBuffer,
10230
+ LIGHT_GRID_BASE * 4,
10231
+ grid.cells.byteLength,
10232
+ )
10060
10233
  }
10061
10234
  // The effects' bases move when the document's count does; the header —
10062
10235
  // count included — is written there.
@@ -11621,9 +11794,11 @@ export class Engine {
11621
11794
  const src = wrapped ? entry.source : entry
11622
11795
  const width = Math.max(1, "naturalWidth" in src ? src.naturalWidth : src.width)
11623
11796
  const height = Math.max(1, "naturalHeight" in src ? src.naturalHeight : src.height)
11797
+ const mipLevelCount = wrapped && entry.mipmaps ? Math.floor(Math.log2(Math.max(width, height))) + 1 : 1
11624
11798
  const tex = this.device.createTexture({
11625
11799
  label: `group map: ${group.id}`,
11626
11800
  size: [width, height],
11801
+ mipLevelCount,
11627
11802
  // Colour maps decode to linear on sample, the way material textures do;
11628
11803
  // data maps must not, or every threshold packed in their channels moves.
11629
11804
  format: wrapped && entry.srgb ? "rgba8unorm-srgb" : "rgba8unorm",
@@ -11634,6 +11809,7 @@ export class Engine {
11634
11809
  { texture: tex, premultipliedAlpha: wrapped && entry.premultiplied === true },
11635
11810
  [width, height],
11636
11811
  )
11812
+ if (mipLevelCount > 1) this.generateMipmaps(tex, mipLevelCount)
11637
11813
  return tex
11638
11814
  })
11639
11815
  }
@@ -14137,24 +14313,28 @@ export class Engine {
14137
14313
  // Bilinear box-filter downsample per level. Reads srgb view (hardware linearizes on sample,
14138
14314
  // re-encodes on write), so intensities are filtered in linear space — matching EEVEE/Blender.
14139
14315
  private generateMipmaps(texture: GPUTexture, mipLevelCount: number) {
14140
- if (!this.mipBlitPipeline || !this.mipBlitSampler) {
14316
+ if (!this.mipBlitSampler) {
14141
14317
  this.mipBlitSampler = this.device.createSampler({
14142
14318
  magFilter: "linear",
14143
14319
  minFilter: "linear",
14144
14320
  addressModeU: "clamp-to-edge",
14145
14321
  addressModeV: "clamp-to-edge",
14146
14322
  })
14323
+ }
14324
+ let pipeline = this.mipBlitPipelines.get(texture.format)
14325
+ if (!pipeline) {
14147
14326
  const module = this.device.createShaderModule({
14148
14327
  label: "mipmap blit",
14149
14328
  code: MIPMAP_BLIT_SHADER_WGSL,
14150
14329
  })
14151
- this.mipBlitPipeline = this.device.createRenderPipeline({
14152
- label: "mipmap blit pipeline",
14330
+ pipeline = this.device.createRenderPipeline({
14331
+ label: `mipmap blit pipeline (${texture.format})`,
14153
14332
  layout: "auto",
14154
14333
  vertex: { module, entryPoint: "vs" },
14155
- fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm-srgb" }] },
14334
+ fragment: { module, entryPoint: "fs", targets: [{ format: texture.format }] },
14156
14335
  primitive: { topology: "triangle-list" },
14157
14336
  })
14337
+ this.mipBlitPipelines.set(texture.format, pipeline)
14158
14338
  }
14159
14339
 
14160
14340
  const encoder = this.device.createCommandEncoder({ label: "mipgen" })
@@ -14162,7 +14342,7 @@ export class Engine {
14162
14342
  const srcView = texture.createView({ baseMipLevel: level - 1, mipLevelCount: 1 })
14163
14343
  const dstView = texture.createView({ baseMipLevel: level, mipLevelCount: 1 })
14164
14344
  const bindGroup = this.device.createBindGroup({
14165
- layout: this.mipBlitPipeline.getBindGroupLayout(0),
14345
+ layout: pipeline.getBindGroupLayout(0),
14166
14346
  entries: [
14167
14347
  { binding: 0, resource: srcView },
14168
14348
  { binding: 1, resource: this.mipBlitSampler },
@@ -14173,7 +14353,7 @@ export class Engine {
14173
14353
  { view: dstView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
14174
14354
  ],
14175
14355
  })
14176
- pass.setPipeline(this.mipBlitPipeline)
14356
+ pass.setPipeline(pipeline)
14177
14357
  pass.setBindGroup(0, bindGroup)
14178
14358
  pass.draw(3)
14179
14359
  pass.end()
@@ -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