reze-engine 0.42.2 → 0.43.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.
Files changed (71) hide show
  1. package/README.md +29 -1
  2. package/dist/camera-animation.d.ts +8 -0
  3. package/dist/camera-animation.d.ts.map +1 -1
  4. package/dist/camera-animation.js +10 -0
  5. package/dist/engine.d.ts +110 -0
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +746 -23
  8. package/dist/model.d.ts.map +1 -1
  9. package/dist/model.js +29 -0
  10. package/dist/physics/autofit.d.ts +147 -0
  11. package/dist/physics/autofit.d.ts.map +1 -0
  12. package/dist/physics/autofit.js +501 -0
  13. package/dist/shaders/audio-api.d.ts +3 -0
  14. package/dist/shaders/audio-api.d.ts.map +1 -0
  15. package/dist/shaders/audio-api.js +81 -0
  16. package/dist/shaders/passes/composite.d.ts +10 -0
  17. package/dist/shaders/passes/composite.d.ts.map +1 -1
  18. package/dist/shaders/passes/composite.js +75 -12
  19. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  20. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  21. package/dist/shaders/passes/ground-noise.js +88 -0
  22. package/dist/shaders/passes/particles.d.ts +58 -0
  23. package/dist/shaders/passes/particles.d.ts.map +1 -0
  24. package/dist/shaders/passes/particles.js +351 -0
  25. package/dist/shaders/passes/trails.d.ts +47 -0
  26. package/dist/shaders/passes/trails.d.ts.map +1 -0
  27. package/dist/shaders/passes/trails.js +375 -0
  28. package/package.json +2 -2
  29. package/src/camera-animation.ts +11 -0
  30. package/src/engine.ts +806 -20
  31. package/src/model.ts +28 -0
  32. package/src/shaders/audio-api.ts +82 -0
  33. package/src/shaders/passes/composite.ts +76 -11
  34. package/src/shaders/passes/particles.ts +398 -0
  35. package/src/shaders/passes/trails.ts +406 -0
  36. package/dist/physics-debug.d.ts +0 -30
  37. package/dist/physics-debug.d.ts.map +0 -1
  38. package/dist/physics-debug.js +0 -526
  39. package/dist/shaders/materials/body.d.ts +0 -2
  40. package/dist/shaders/materials/body.d.ts.map +0 -1
  41. package/dist/shaders/materials/body.js +0 -95
  42. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  43. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  44. package/dist/shaders/materials/cloth_rough.js +0 -69
  45. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  46. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  47. package/dist/shaders/materials/cloth_smooth.js +0 -61
  48. package/dist/shaders/materials/default.d.ts +0 -2
  49. package/dist/shaders/materials/default.d.ts.map +0 -1
  50. package/dist/shaders/materials/default.js +0 -43
  51. package/dist/shaders/materials/eye.d.ts +0 -2
  52. package/dist/shaders/materials/eye.d.ts.map +0 -1
  53. package/dist/shaders/materials/eye.js +0 -60
  54. package/dist/shaders/materials/face.d.ts +0 -2
  55. package/dist/shaders/materials/face.d.ts.map +0 -1
  56. package/dist/shaders/materials/face.js +0 -95
  57. package/dist/shaders/materials/hair.d.ts +0 -2
  58. package/dist/shaders/materials/hair.d.ts.map +0 -1
  59. package/dist/shaders/materials/hair.js +0 -90
  60. package/dist/shaders/materials/metal.d.ts +0 -2
  61. package/dist/shaders/materials/metal.d.ts.map +0 -1
  62. package/dist/shaders/materials/metal.js +0 -77
  63. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  64. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  65. package/dist/shaders/materials/mmd_classic.js +0 -66
  66. package/dist/shaders/materials/stockings.d.ts +0 -2
  67. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  68. package/dist/shaders/materials/stockings.js +0 -122
  69. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  70. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  71. package/dist/shaders/passes/physics-debug.js +0 -69
package/src/engine.ts CHANGED
@@ -36,12 +36,23 @@ import {
36
36
  import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut"
37
37
  import {
38
38
  buildCompositeShader,
39
+ buildFieldShader,
39
40
  parseEffectAnchors,
40
41
  EFFECT_ANCHORS,
41
42
  EFFECT_SUBJECTS,
42
43
  EFFECT_TRAIL_BASE,
43
44
  EFFECT_TRAIL_SAMPLES,
44
45
  } from "./shaders/passes/composite"
46
+ import {
47
+ buildParticleComputeShader,
48
+ buildParticleRenderShader,
49
+ parseParticleBlend,
50
+ parseParticleBloom,
51
+ parseParticleCount,
52
+ particleEntryPoints,
53
+ PARTICLE_STRIDE,
54
+ } from "./shaders/passes/particles"
55
+ import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails"
45
56
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
46
57
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
47
58
  import { compileGraph, type CompileOptions, type StyleSlot } from "./graph/compile"
@@ -831,6 +842,75 @@ export class Engine {
831
842
  private multisampleMaskTexture!: GPUTexture
832
843
  private maskResolveTexture!: GPUTexture
833
844
  private maskResolveView!: GPUTextureView
845
+ /**
846
+ * The installed effect's particle system, or null when it declared none.
847
+ *
848
+ * A fixed pool: the count is chosen at install and the slots recycle, so there
849
+ * is no allocation and no spawn-rate bookkeeping in the hot path. Dead slots
850
+ * cost a degenerate quad the rasteriser rejects, which is cheaper than the
851
+ * prefix sum and readback a compacted draw list would need every frame.
852
+ */
853
+ private particles: {
854
+ count: number
855
+ buffer: GPUBuffer
856
+ uniform: GPUBuffer
857
+ data: Float32Array
858
+ counts: Uint32Array
859
+ compute: GPUComputePipeline
860
+ computeLayout: GPUBindGroupLayout
861
+ computeBind: GPUBindGroup
862
+ render: GPURenderPipeline
863
+ renderLayout: GPUBindGroupLayout
864
+ renderBind: GPUBindGroup
865
+ rebind: () => { computeBind: GPUBindGroup; renderBind: GPUBindGroup }
866
+ } | null = null
867
+ /** Ceiling for `// @particles`. Past this an author is asking for a stall. */
868
+ private static readonly MAX_PARTICLES = 65536
869
+ private particleFrame = 0
870
+ /**
871
+ * The installed effect's ribbons, or null when it declared none.
872
+ *
873
+ * No buffer of its own: it reads the very same path history the field-based
874
+ * ribbon read through rzTrail, so a trail costs one draw and nothing recorded.
875
+ */
876
+ private trails: {
877
+ instances: number
878
+ uniform: GPUBuffer
879
+ data: Float32Array
880
+ pipeline: GPURenderPipeline
881
+ layout: GPUBindGroupLayout
882
+ bind: GPUBindGroup
883
+ } | null = null
884
+ /** The ribbons' own offscreen target — max-blended, composited after tone map. */
885
+ private trailLayerTexture: GPUTexture | null = null
886
+ private trailLayerView: GPUTextureView | null = null
887
+ /** 1×1 transparent stand-in so the composite layout binds with no trails installed. */
888
+ private trailFallbackView!: GPUTextureView
889
+ /** The field layer: user background/foreground mounts at half resolution. */
890
+ private fieldBgTexture: GPUTexture | null = null
891
+ private fieldBgView: GPUTextureView | null = null
892
+ private fieldFgTexture: GPUTexture | null = null
893
+ private fieldFgView: GPUTextureView | null = null
894
+ private fieldUniformBuffer!: GPUBuffer
895
+ /** 2 = half resolution (the default); 1 = full, for effects that declare
896
+ * `// @fullres` because they draw sub-pixel detail no upsample can carry. */
897
+ private fieldScale = 2
898
+ private fieldFullW = 0
899
+ private fieldFullH = 0
900
+ private fieldPipeline: GPURenderPipeline | null = null
901
+ private fieldBindGroupLayout!: GPUBindGroupLayout
902
+ private fieldPipelineLayout!: GPUPipelineLayout
903
+ private fieldBindGroup: GPUBindGroup | null = null
904
+ /**
905
+ * The audio analysis buffer every effect module binds: header
906
+ * [frames, bands, secondsPerFrame, audioTime], then [level, band0..bandN-1]
907
+ * per frame. Precomputed by the host for the whole track — never a live
908
+ * analyser, which would render silence during an export. Falls back to four
909
+ * zeroes (frames = 0) so layouts always bind.
910
+ */
911
+ private audioBuffer!: GPUBuffer
912
+ private audioFallbackBuffer!: GPUBuffer
913
+ private audioTimeScratch = new Float32Array(2)
834
914
  private renderPassDescriptor!: GPURenderPassDescriptor
835
915
  private compositePassDescriptor!: GPURenderPassDescriptor
836
916
  // Two specialized composite pipelines via WGSL pipeline-override constants.
@@ -1268,6 +1348,51 @@ export class Engine {
1268
1348
  { binding: 9, resource: { buffer: this.dofUniformBuffer } },
1269
1349
  { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
1270
1350
  { binding: 11, resource: { buffer: this.castBuffer } },
1351
+ { binding: 12, resource: this.trails && this.trailLayerView ? this.trailLayerView : this.trailFallbackView },
1352
+ { binding: 13, resource: { buffer: this.audioBuffer } },
1353
+ { binding: 15, resource: this.fieldBgView ?? this.trailFallbackView },
1354
+ { binding: 16, resource: this.fieldFgView ?? this.trailFallbackView },
1355
+ ],
1356
+ })
1357
+ this.rebuildFieldBindGroup()
1358
+ }
1359
+
1360
+ private createFieldTargets(): void {
1361
+ if (!this.device || this.fieldFullW === 0) return
1362
+ const w = Math.max(1, Math.ceil(this.fieldFullW / this.fieldScale))
1363
+ const h = Math.max(1, Math.ceil(this.fieldFullH / this.fieldScale))
1364
+ this.fieldBgTexture?.destroy()
1365
+ this.fieldFgTexture?.destroy()
1366
+ this.fieldBgTexture = this.device.createTexture({
1367
+ label: "field layer (background)",
1368
+ size: [w, h],
1369
+ format: "rgba16float",
1370
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1371
+ })
1372
+ this.fieldFgTexture = this.device.createTexture({
1373
+ label: "field layer (foreground)",
1374
+ size: [w, h],
1375
+ format: "rgba16float",
1376
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
1377
+ })
1378
+ this.fieldBgView = this.fieldBgTexture.createView()
1379
+ this.fieldFgView = this.fieldFgTexture.createView()
1380
+ this.device.queue.writeBuffer(this.fieldUniformBuffer, 0, new Float32Array([w, h, this.fieldFullW, this.fieldFullH]))
1381
+ }
1382
+
1383
+ private rebuildFieldBindGroup(): void {
1384
+ if (!this.device || !this.depthReadView || !this.fieldUniformBuffer) return
1385
+ this.fieldBindGroup = this.device.createBindGroup({
1386
+ label: "field layer bind group",
1387
+ layout: this.fieldBindGroupLayout,
1388
+ entries: [
1389
+ { binding: 3, resource: { buffer: this.compositeUniformBuffer } },
1390
+ { binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
1391
+ { binding: 8, resource: this.depthReadView },
1392
+ { binding: 9, resource: { buffer: this.dofUniformBuffer } },
1393
+ { binding: 11, resource: { buffer: this.castBuffer } },
1394
+ { binding: 13, resource: { buffer: this.audioBuffer } },
1395
+ { binding: 14, resource: { buffer: this.fieldUniformBuffer } },
1271
1396
  ],
1272
1397
  })
1273
1398
  }
@@ -1373,6 +1498,9 @@ export class Engine {
1373
1498
  if (wgsl === null) {
1374
1499
  this.effect?.paramsBuffer?.destroy()
1375
1500
  this.effect = null
1501
+ this.releaseParticles()
1502
+ this.releaseTrails()
1503
+ this.fieldPipeline = null
1376
1504
  const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) })
1377
1505
  this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)")
1378
1506
  this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)")
@@ -1387,12 +1515,62 @@ export class Engine {
1387
1515
  // to one — those never follow `fn`.
1388
1516
  const hasBackground = /\bfn\s+background\s*\(/.test(wgsl)
1389
1517
  const hasForeground = /\bfn\s+foreground\s*\(/.test(wgsl)
1390
- if (!hasBackground && !hasForeground) {
1518
+ // Particles are a THIRD mount, declared the same way — by the functions the
1519
+ // source defines. All three are required together: a pool with no shader to
1520
+ // draw it, or a draw with nothing spawning into it, is a silent blank rather
1521
+ // than an error, which is the worst way for an effect to fail.
1522
+ const pe = particleEntryPoints(wgsl)
1523
+ const wantsParticles = pe.init || pe.step || pe.shade
1524
+ const te = trailEntryPoints(wgsl)
1525
+ const wantsTrails = te.width || te.shade
1526
+ if (wantsTrails && !(te.width && te.shade)) {
1527
+ return {
1528
+ ok: false,
1529
+ diagnostics: [
1530
+ `a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
1531
+ `fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
1532
+ ],
1533
+ mounts: noMounts,
1534
+ }
1535
+ }
1536
+ if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
1537
+ const missing = [
1538
+ pe.init ? null : "fn particleInit(id: u32, seed: f32) -> Particle",
1539
+ pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
1540
+ pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
1541
+ ].filter(Boolean)
1542
+ return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts }
1543
+ }
1544
+ // One file, one kind — for now.
1545
+ //
1546
+ // The two kinds compile into different modules: field functions belong to the
1547
+ // composite pass, particle functions to the particle pair. A file holding both
1548
+ // would have to be spliced into both, and each module would then need the
1549
+ // OTHER's scaffolding (the Particle struct in the composite; the composite's
1550
+ // uniforms in the particle stages) for the dead half to compile — several
1551
+ // declarations that exist only so unused code type-checks, and a handful of
1552
+ // accessors that would silently return zero on the wrong side. Splitting into
1553
+ // two effects costs the author nothing once a scene can hold a list, and this
1554
+ // says so plainly instead of failing with "unresolved type Particle" from a
1555
+ // pass they did not know they were compiling into.
1556
+ if ((wantsParticles || wantsTrails) && (hasBackground || hasForeground)) {
1557
+ return {
1558
+ ok: false,
1559
+ diagnostics: [
1560
+ "an effect declares field mounts (background/foreground) or particles, not both — " +
1561
+ "split them into two effects",
1562
+ ],
1563
+ mounts: noMounts,
1564
+ }
1565
+ }
1566
+ if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails) {
1391
1567
  return {
1392
1568
  ok: false,
1393
1569
  diagnostics: [
1394
- "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f " +
1395
- "or fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f (or both)",
1570
+ "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
1571
+ "fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
1572
+ "the particle trio (particleInit/particleStep/particleShade), " +
1573
+ "or the ribbon pair (trailWidth/trailShade)",
1396
1574
  ],
1397
1575
  mounts: noMounts,
1398
1576
  }
@@ -1442,17 +1620,47 @@ export class Engine {
1442
1620
 
1443
1621
  // ── Compile with validation captured, not thrown at the console. Line
1444
1622
  // numbers in diagnostics are rebased to the USER's source.
1445
- const source = buildCompositeShader({ wgsl, paramsDecl, hasBackground, hasForeground })
1446
- const userLineOffset = source.slice(0, source.indexOf(wgsl)).split("\n").length - 1
1623
+ // The composite is STATIC: user field code compiles in its own half-res
1624
+ // module (buildFieldShader), so a bad effect can no longer produce errors at
1625
+ // line numbers in a shader the author never wrote — and installing one no
1626
+ // longer recompiles the composite's tone-mapping half at all.
1627
+ const fieldEffect = hasBackground || hasForeground ? { wgsl, paramsDecl, hasBackground, hasForeground } : null
1628
+ const source = buildCompositeShader(fieldEffect)
1447
1629
  this.device.pushErrorScope("validation")
1448
1630
  const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
1449
- const info = await module.getCompilationInfo()
1450
1631
  const scopeErr = await this.device.popErrorScope()
1451
- const diagnostics = info.messages
1452
- .filter((m) => m.type === "error")
1453
- .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
1454
- if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
1455
- if (diagnostics.length > 0) return { ok: false, diagnostics, mounts }
1632
+ if (scopeErr) return { ok: false, diagnostics: [scopeErr.message], mounts }
1633
+
1634
+ let fieldPipeline: GPURenderPipeline | null = null
1635
+ if (fieldEffect) {
1636
+ const fieldSource = buildFieldShader(fieldEffect)
1637
+ const userLineOffset = fieldSource.slice(0, fieldSource.indexOf(wgsl)).split("\n").length - 1
1638
+ this.device.pushErrorScope("validation")
1639
+ const fieldModule = this.device.createShaderModule({ label: "field shader (effect)", code: fieldSource })
1640
+ const info = await fieldModule.getCompilationInfo()
1641
+ const fieldScopeErr = await this.device.popErrorScope()
1642
+ const diagnostics = info.messages
1643
+ .filter((m) => m.type === "error")
1644
+ .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
1645
+ if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
1646
+ if (diagnostics.length > 0) return { ok: false, diagnostics, mounts }
1647
+ try {
1648
+ fieldPipeline = await this.device.createRenderPipelineAsync({
1649
+ label: "field layer pipeline",
1650
+ layout: this.fieldPipelineLayout,
1651
+ vertex: { module: fieldModule, entryPoint: "fieldVs" },
1652
+ fragment: {
1653
+ module: fieldModule,
1654
+ entryPoint: "fieldFs",
1655
+ targets: [{ format: "rgba16float" }, { format: "rgba16float" }],
1656
+ },
1657
+ primitive: { topology: "triangle-list" },
1658
+ multisample: { count: 1 },
1659
+ })
1660
+ } catch (e) {
1661
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
1662
+ }
1663
+ }
1456
1664
  let identity: GPURenderPipeline
1457
1665
  let gamma: GPURenderPipeline
1458
1666
  try {
@@ -1477,8 +1685,46 @@ export class Engine {
1477
1685
  return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
1478
1686
  }
1479
1687
 
1688
+ // Built BEFORE the swap: a particle stage that fails to compile has to leave
1689
+ // the previously installed effect running, exactly as a bad composite does.
1690
+ let particles: NonNullable<Engine["particles"]> | null = null
1691
+ if (wantsParticles) {
1692
+ const built = await this.buildParticles(wgsl, anchors.filter((a) => a.trail).length)
1693
+ if (!built.ok) return { ok: false, diagnostics: built.diagnostics, mounts }
1694
+ particles = built.state
1695
+ }
1696
+ let trails: NonNullable<Engine["trails"]> | null = null
1697
+ if (wantsTrails) {
1698
+ // Only anchors that asked for `trail` have a path to draw; a ribbon on a
1699
+ // bone recorded without one would read zeroes and paint a line to the origin.
1700
+ const trailSlots = anchors.filter((a) => a.trail).length
1701
+ if (trailSlots === 0) {
1702
+ return {
1703
+ ok: false,
1704
+ diagnostics: ["a ribbon effect needs at least one // @anchor <bone> trail"],
1705
+ mounts,
1706
+ }
1707
+ }
1708
+ const built = await this.buildTrails(wgsl, trailSlots)
1709
+ if (!built.ok) return { ok: false, diagnostics: built.diagnostics, mounts }
1710
+ trails = built.state
1711
+ }
1712
+
1480
1713
  // ── Swap — only now does the old effect (and its params buffer) go away.
1481
1714
  this.effect?.paramsBuffer?.destroy()
1715
+ this.releaseParticles()
1716
+ this.releaseTrails()
1717
+ this.particles = particles
1718
+ this.trails = trails
1719
+ this.fieldPipeline = fieldPipeline
1720
+ // `// @fullres`: an effect that draws SUB-PIXEL detail — hairline curves,
1721
+ // scanlines — declares it and pays full price; everything soft stays at
1722
+ // half. The field shader reads its size from fieldU, so nothing else moves.
1723
+ const wantScale = /^\s*\/\/\s*@fullres\s*$/m.test(wgsl) ? 1 : 2
1724
+ if (wantScale !== this.fieldScale) {
1725
+ this.fieldScale = wantScale
1726
+ this.createFieldTargets()
1727
+ }
1482
1728
  let paramsBuffer: GPUBuffer | null = null
1483
1729
  if (entries.length) {
1484
1730
  paramsBuffer = this.device.createBuffer({
@@ -1503,6 +1749,381 @@ export class Engine {
1503
1749
  return { ok: true, diagnostics: [], mounts }
1504
1750
  }
1505
1751
 
1752
+ /**
1753
+ * Compile an effect's particle stages and allocate its pool.
1754
+ *
1755
+ * Two modules, not one: the compute and render stages bind the same buffer
1756
+ * with different access (read_write vs read), and a single module would have
1757
+ * to pick one. Compiling them separately also means an author's helper names
1758
+ * live in their own compilation unit, which is what lets two effects both
1759
+ * define `hash21` without meeting.
1760
+ */
1761
+ private async buildParticles(
1762
+ wgsl: string,
1763
+ trailSlots: number,
1764
+ ): Promise<{ ok: true; state: NonNullable<Engine["particles"]> } | { ok: false; diagnostics: string[] }> {
1765
+ // No pragma means "some": an author who wrote the trio clearly wants
1766
+ // particles, and failing over a missing comment would be pedantry.
1767
+ const count = parseParticleCount(wgsl, Engine.MAX_PARTICLES) || 1024
1768
+ const src = { wgsl, count, blend: parseParticleBlend(wgsl), bloom: parseParticleBloom(wgsl) }
1769
+ // Sparks want to spawn where a trail is, so the particle stages see the same
1770
+ // cast buffer the trail draw reads.
1771
+ const cast = {
1772
+ subjects: MAX_EFFECT_SUBJECTS,
1773
+ samples: TRAIL_SAMPLES,
1774
+ base: MAX_EFFECT_SUBJECTS * 3,
1775
+ trailBase: CAST_TRAIL_BASE,
1776
+ slots: trailSlots,
1777
+ }
1778
+
1779
+ const compile = async (code: string, label: string): Promise<GPUShaderModule | string[]> => {
1780
+ const offset = code.slice(0, code.indexOf(wgsl)).split("\n").length - 1
1781
+ this.device.pushErrorScope("validation")
1782
+ const module = this.device.createShaderModule({ label, code })
1783
+ const info = await module.getCompilationInfo()
1784
+ const scopeErr = await this.device.popErrorScope()
1785
+ const diagnostics = info.messages
1786
+ .filter((m) => m.type === "error")
1787
+ .map((m) => `${Math.max(0, m.lineNum - offset)}:${m.linePos} ${m.message}`)
1788
+ if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
1789
+ return diagnostics.length ? diagnostics : module
1790
+ }
1791
+
1792
+ const computeModule = await compile(buildParticleComputeShader(src, cast), "particle compute")
1793
+ if (Array.isArray(computeModule)) return { ok: false, diagnostics: computeModule }
1794
+ const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render")
1795
+ if (Array.isArray(renderModule)) return { ok: false, diagnostics: renderModule }
1796
+
1797
+ const buffer = this.device.createBuffer({
1798
+ label: "particle pool",
1799
+ size: count * PARTICLE_STRIDE,
1800
+ usage: GPUBufferUsage.STORAGE,
1801
+ })
1802
+ const uniform = this.device.createBuffer({
1803
+ label: "particle uniforms",
1804
+ size: 16,
1805
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1806
+ })
1807
+ const uniformBytes = new ArrayBuffer(16)
1808
+ const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) }
1809
+
1810
+ // Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
1811
+ // visible to the vertex stage at all (WebGPU forbids it — a vertex shader
1812
+ // that could write memory has no defined ordering against the rasteriser).
1813
+ // Declaring one set of flags for both layouts is what made the pipeline
1814
+ // layout invalid, and the error surfaces later and unhelpfully as "invalid
1815
+ // due to a previous error".
1816
+ const layoutFor = (storage: GPUBufferBindingType, visibility: number) =>
1817
+ this.device.createBindGroupLayout({
1818
+ entries: [
1819
+ { binding: 0, visibility, buffer: { type: storage } },
1820
+ { binding: 1, visibility, buffer: { type: "uniform" } },
1821
+ { binding: 2, visibility, buffer: { type: "uniform" } },
1822
+ { binding: 3, visibility, buffer: { type: "read-only-storage" } },
1823
+ { binding: 4, visibility, buffer: { type: "read-only-storage" } },
1824
+ ],
1825
+ })
1826
+ const bindFor = (layout: GPUBindGroupLayout) =>
1827
+ this.device.createBindGroup({
1828
+ layout,
1829
+ entries: [
1830
+ { binding: 0, resource: { buffer } },
1831
+ { binding: 1, resource: { buffer: uniform } },
1832
+ { binding: 2, resource: { buffer: this.cameraUniformBuffer } },
1833
+ { binding: 3, resource: { buffer: this.castBuffer } },
1834
+ { binding: 4, resource: { buffer: this.audioBuffer } },
1835
+ ],
1836
+ })
1837
+
1838
+ const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE)
1839
+ const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT)
1840
+
1841
+ // Additive keeps the destination and adds to it; the alpha channel is left
1842
+ // alone (dst factor one, src zero) so a glow does not also claim coverage
1843
+ // it never occluded.
1844
+ // Additive effects need the MASK to sum like the colour does — see the
1845
+ // fragment shaders' mask comment. rg8unorm clamps the sum at 1, which is the
1846
+ // saturation alpha-over would reach anyway.
1847
+ const maskTarget: GPUColorTargetState =
1848
+ src.blend === "additive"
1849
+ ? {
1850
+ format: Engine.BLOOM_MASK_FORMAT,
1851
+ blend: {
1852
+ color: { srcFactor: "one", dstFactor: "one", operation: "add" },
1853
+ alpha: { srcFactor: "one", dstFactor: "one", operation: "add" },
1854
+ },
1855
+ }
1856
+ : this.sceneTargets[1]
1857
+ const colorTarget: GPUColorTargetState =
1858
+ src.blend === "additive"
1859
+ ? {
1860
+ format: this.hdrFormat,
1861
+ blend: {
1862
+ color: { srcFactor: "one", dstFactor: "one", operation: "add" },
1863
+ alpha: { srcFactor: "zero", dstFactor: "one", operation: "add" },
1864
+ },
1865
+ }
1866
+ : this.sceneTargets[0]
1867
+
1868
+ this.device.pushErrorScope("validation")
1869
+ try {
1870
+ const compute = await this.device.createComputePipelineAsync({
1871
+ label: "particle compute pipeline",
1872
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
1873
+ compute: { module: computeModule, entryPoint: "main" },
1874
+ })
1875
+ const render = await this.device.createRenderPipelineAsync({
1876
+ label: "particle render pipeline",
1877
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] }),
1878
+ vertex: { module: renderModule, entryPoint: "vs" },
1879
+ fragment: { module: renderModule, entryPoint: "fs", targets: [colorTarget, maskTarget] },
1880
+ primitive: { topology: "triangle-list", cullMode: "none" },
1881
+ // Tested but not WRITTEN: particles are transparent, so writing depth
1882
+ // would make whichever quad drew first occlude the ones behind it.
1883
+ depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: false, depthCompare: "less-equal" },
1884
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
1885
+ })
1886
+ const scoped = await this.device.popErrorScope()
1887
+ if (scoped) {
1888
+ buffer.destroy()
1889
+ uniform.destroy()
1890
+ return { ok: false, diagnostics: [scoped.message] }
1891
+ }
1892
+ return {
1893
+ ok: true,
1894
+ state: {
1895
+ count,
1896
+ buffer,
1897
+ uniform,
1898
+ // One 16-byte block, two views: time/dt are floats and count/frame are
1899
+ // integers, and writing them through separate arrays would upload two
1900
+ // different buffers with the same name.
1901
+ data: uniformView.floats,
1902
+ counts: uniformView.uints,
1903
+ compute,
1904
+ computeLayout,
1905
+ computeBind: bindFor(computeLayout),
1906
+ render,
1907
+ renderLayout,
1908
+ renderBind: bindFor(renderLayout),
1909
+ rebind: () => ({ computeBind: bindFor(computeLayout), renderBind: bindFor(renderLayout) }),
1910
+ },
1911
+ }
1912
+ } catch (e) {
1913
+ await this.device.popErrorScope()
1914
+ buffer.destroy()
1915
+ uniform.destroy()
1916
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
1917
+ }
1918
+ }
1919
+
1920
+ /**
1921
+ * Step the pool, before the scene pass.
1922
+ *
1923
+ * Outside the render pass because a compute dispatch cannot be encoded inside
1924
+ * one — and it has to precede the draw that reads the same buffer, or the
1925
+ * quads render last frame's positions.
1926
+ */
1927
+ private stepParticles(encoder: GPUCommandEncoder, deltaTime: number): void {
1928
+ const p = this.particles
1929
+ if (!p) return
1930
+ p.data[0] = this.sceneClock - this.effectEpochScene
1931
+ // Clamped: a backgrounded tab returns with a delta of whole seconds, and an
1932
+ // unclamped step flings every particle out of the scene in one frame.
1933
+ p.data[1] = Math.min(0.1, Math.max(0, deltaTime))
1934
+ p.counts[2] = p.count
1935
+ p.counts[3] = this.particleFrame++
1936
+ this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer as ArrayBuffer)
1937
+ const cp = encoder.beginComputePass({ label: "particles" })
1938
+ cp.setPipeline(p.compute)
1939
+ cp.setBindGroup(0, p.computeBind)
1940
+ cp.dispatchWorkgroups(Math.ceil(p.count / 64))
1941
+ cp.end()
1942
+ }
1943
+
1944
+ /** Draw the pool. Inside the scene pass, so it is depth-tested and pre-bloom. */
1945
+ private renderParticles(pass: GPURenderPassEncoder): void {
1946
+ const p = this.particles
1947
+ if (!p) return
1948
+ pass.setPipeline(p.render)
1949
+ pass.setBindGroup(0, p.renderBind)
1950
+ pass.draw(6, p.count)
1951
+ }
1952
+
1953
+ /**
1954
+ * Compile an effect's ribbon stage.
1955
+ *
1956
+ * One instance per (slot, subject, segment), so a scene with several dancers
1957
+ * and several declared bones is still one draw and nothing is computed per
1958
+ * frame on the CPU.
1959
+ */
1960
+ private async buildTrails(
1961
+ wgsl: string,
1962
+ slots: number,
1963
+ ): Promise<{ ok: true; state: NonNullable<Engine["trails"]> } | { ok: false; diagnostics: string[] }> {
1964
+ const src = { wgsl, slots, blend: parseParticleBlend(wgsl), bloom: parseParticleBloom(wgsl) }
1965
+ const code = buildTrailShader(src, {
1966
+ subjects: MAX_EFFECT_SUBJECTS,
1967
+ samples: TRAIL_SAMPLES,
1968
+ base: MAX_EFFECT_SUBJECTS * 3,
1969
+ trailBase: CAST_TRAIL_BASE,
1970
+ })
1971
+ const offset = code.slice(0, code.indexOf(wgsl)).split("\n").length - 1
1972
+ this.device.pushErrorScope("validation")
1973
+ const module = this.device.createShaderModule({ label: "trail shader", code })
1974
+ const info = await module.getCompilationInfo()
1975
+ const scopeErr = await this.device.popErrorScope()
1976
+ const diagnostics = info.messages
1977
+ .filter((m) => m.type === "error")
1978
+ .map((m) => `${Math.max(0, m.lineNum - offset)}:${m.linePos} ${m.message}`)
1979
+ if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
1980
+ if (diagnostics.length) return { ok: false, diagnostics }
1981
+
1982
+ const uniform = this.device.createBuffer({
1983
+ label: "trail uniforms",
1984
+ size: 16,
1985
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1986
+ })
1987
+ const layout = this.device.createBindGroupLayout({
1988
+ entries: [
1989
+ {
1990
+ binding: 0,
1991
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
1992
+ buffer: { type: "read-only-storage" },
1993
+ },
1994
+ { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1995
+ { binding: 2, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1996
+ // The scene's depth, for the fragment's manual occlusion test.
1997
+ {
1998
+ binding: 3,
1999
+ visibility: GPUShaderStage.FRAGMENT,
2000
+ texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
2001
+ },
2002
+ // The audio analysis, for rzAudio* in width and shade alike.
2003
+ { binding: 4, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
2004
+ ],
2005
+ })
2006
+ // ONE target: the ribbons' own layer, blended with MAX in both channels.
2007
+ // Max is the original's core-takes-the-max rule as a blend mode — parallel
2008
+ // strands of a circling hand meet as max and cannot double into bright
2009
+ // dashes, which every additive variant of this pipeline drew. The layer is
2010
+ // composited over the frame after tone mapping (see composite.ts), which is
2011
+ // where the fullscreen ribbon always ran.
2012
+ const layerTarget: GPUColorTargetState = {
2013
+ format: "rgba16float",
2014
+ blend: {
2015
+ color: { srcFactor: "one", dstFactor: "one", operation: "max" },
2016
+ alpha: { srcFactor: "one", dstFactor: "one", operation: "max" },
2017
+ },
2018
+ }
2019
+ this.device.pushErrorScope("validation")
2020
+ try {
2021
+ const pipeline = await this.device.createRenderPipelineAsync({
2022
+ label: "trail pipeline",
2023
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [layout] }),
2024
+ vertex: { module, entryPoint: "vs" },
2025
+ fragment: { module, entryPoint: "fs", targets: [layerTarget] },
2026
+ primitive: { topology: "triangle-list", cullMode: "none" },
2027
+ // No depth attachment and no MSAA: the layer is a lone colour target,
2028
+ // and occlusion happens in the fragment against the scene's own depth.
2029
+ multisample: { count: 1 },
2030
+ })
2031
+ const scoped = await this.device.popErrorScope()
2032
+ if (scoped) {
2033
+ uniform.destroy()
2034
+ return { ok: false, diagnostics: [scoped.message] }
2035
+ }
2036
+ return {
2037
+ ok: true,
2038
+ state: {
2039
+ instances: slots * MAX_EFFECT_SUBJECTS * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS,
2040
+ uniform,
2041
+ data: new Float32Array(4),
2042
+ pipeline,
2043
+ layout,
2044
+ bind: this.device.createBindGroup({
2045
+ layout,
2046
+ entries: [
2047
+ { binding: 0, resource: { buffer: this.castBuffer } },
2048
+ { binding: 1, resource: { buffer: uniform } },
2049
+ { binding: 2, resource: { buffer: this.cameraUniformBuffer } },
2050
+ { binding: 3, resource: this.depthReadView! },
2051
+ { binding: 4, resource: { buffer: this.audioBuffer } },
2052
+ ],
2053
+ }),
2054
+ },
2055
+ }
2056
+ } catch (e) {
2057
+ await this.device.popErrorScope()
2058
+ uniform.destroy()
2059
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
2060
+ }
2061
+ }
2062
+
2063
+ private releaseTrails(): void {
2064
+ this.trails?.uniform.destroy()
2065
+ this.trails = null
2066
+ }
2067
+
2068
+ /** Draw the ribbons into their own layer — cleared, max-blended, and
2069
+ * composited over the frame after tone mapping. */
2070
+ private renderTrailLayer(encoder: GPUCommandEncoder): void {
2071
+ const t = this.trails
2072
+ if (!t || !this.trailLayerView) return
2073
+ t.data[0] = this.sceneClock - this.effectEpochScene
2074
+ this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
2075
+ const pass = encoder.beginRenderPass({
2076
+ label: "trail layer",
2077
+ colorAttachments: [
2078
+ { view: this.trailLayerView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
2079
+ ],
2080
+ })
2081
+ pass.setPipeline(t.pipeline)
2082
+ pass.setBindGroup(0, t.bind)
2083
+ pass.draw(6, t.instances)
2084
+ pass.end()
2085
+ }
2086
+
2087
+ /** The user's field mounts, drawn at half resolution for the composite to
2088
+ * upsample. Runs the whole quad — uniform control flow, so effects may use
2089
+ * derivatives freely, which the old inline path had to forbid. */
2090
+ private renderFieldPass(encoder: GPUCommandEncoder): void {
2091
+ if (!this.fieldPipeline || !this.fieldBgView || !this.fieldFgView || !this.fieldBindGroup) return
2092
+ const pass = encoder.beginRenderPass({
2093
+ label: "field layer",
2094
+ colorAttachments: [
2095
+ { view: this.fieldBgView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
2096
+ { view: this.fieldFgView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
2097
+ ],
2098
+ })
2099
+ pass.setPipeline(this.fieldPipeline)
2100
+ pass.setBindGroup(0, this.fieldBindGroup)
2101
+ pass.draw(3)
2102
+ pass.end()
2103
+ }
2104
+
2105
+ /** The trail bind group holds the depth view, which a resize recreates. */
2106
+ private rebindTrails(): void {
2107
+ const t = this.trails
2108
+ if (!t || !this.depthReadView) return
2109
+ t.bind = this.device.createBindGroup({
2110
+ layout: t.layout,
2111
+ entries: [
2112
+ { binding: 0, resource: { buffer: this.castBuffer } },
2113
+ { binding: 1, resource: { buffer: t.uniform } },
2114
+ { binding: 2, resource: { buffer: this.cameraUniformBuffer } },
2115
+ { binding: 3, resource: this.depthReadView },
2116
+ { binding: 4, resource: { buffer: this.audioBuffer } },
2117
+ ],
2118
+ })
2119
+ }
2120
+
2121
+ private releaseParticles(): void {
2122
+ this.particles?.buffer.destroy()
2123
+ this.particles?.uniform.destroy()
2124
+ this.particles = null
2125
+ }
2126
+
1506
2127
  /** Which mounts the installed effect declared. Both false when none is set. */
1507
2128
  getEffectMounts(): { background: boolean; foreground: boolean } {
1508
2129
  return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false }
@@ -1924,6 +2545,51 @@ export class Engine {
1924
2545
  addressModeV: "repeat",
1925
2546
  })
1926
2547
 
2548
+ this.trailFallbackView = this.device
2549
+ .createTexture({
2550
+ label: "trail layer fallback (1x1 transparent)",
2551
+ size: [1, 1],
2552
+ format: "rgba16float",
2553
+ usage: GPUTextureUsage.TEXTURE_BINDING,
2554
+ })
2555
+ .createView()
2556
+
2557
+ this.audioFallbackBuffer = this.device.createBuffer({
2558
+ label: "audio analysis fallback (silence)",
2559
+ size: 32,
2560
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
2561
+ })
2562
+ this.audioBuffer = this.audioFallbackBuffer
2563
+
2564
+ this.fieldUniformBuffer = this.device.createBuffer({
2565
+ label: "field layer uniforms (half size, full size)",
2566
+ size: 16,
2567
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2568
+ })
2569
+ // The field pass's own layout: the subset of the composite's bindings the
2570
+ // user's code can statically reach, WITHOUT the field textures themselves —
2571
+ // a pass may not sample its own attachments, and WebGPU counts every
2572
+ // resource in a bound group whether the shader reads it or not.
2573
+ this.fieldBindGroupLayout = this.device.createBindGroupLayout({
2574
+ label: "field layer bind layout",
2575
+ entries: [
2576
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
2577
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
2578
+ {
2579
+ binding: 8,
2580
+ visibility: GPUShaderStage.FRAGMENT,
2581
+ texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
2582
+ },
2583
+ { binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
2584
+ { binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
2585
+ { binding: 13, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
2586
+ { binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
2587
+ ],
2588
+ })
2589
+ this.fieldPipelineLayout = this.device.createPipelineLayout({
2590
+ bindGroupLayouts: [this.fieldBindGroupLayout],
2591
+ })
2592
+
1927
2593
  this.fallbackMaterialTexture = this.device.createTexture({
1928
2594
  label: "fallback material texture (1x1 white)",
1929
2595
  size: [1, 1],
@@ -2574,6 +3240,15 @@ export class Engine {
2574
3240
  // The cast, for rzSubject/rzAnchor. Always bound so the base shader's
2575
3241
  // layout matches; the base shader simply never reads it.
2576
3242
  { binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
3243
+ // The trail layer. Bound to a transparent 1×1 when no ribbon effect is
3244
+ // installed, so the base shader's layout always matches.
3245
+ { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: {} },
3246
+ // The audio analysis, for rzAudio*. Silence fallback when the scene has
3247
+ // no track.
3248
+ { binding: 13, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
3249
+ // The field layer's two halves. Fallback-bound when no field effect runs.
3250
+ { binding: 15, visibility: GPUShaderStage.FRAGMENT, texture: {} },
3251
+ { binding: 16, visibility: GPUShaderStage.FRAGMENT, texture: {} },
2577
3252
  ],
2578
3253
  })
2579
3254
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -2767,6 +3442,23 @@ export class Engine {
2767
3442
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
2768
3443
  })
2769
3444
 
3445
+ // rgba16float explicitly, NOT hdrFormat: the composite reads this layer's
3446
+ // ALPHA to composite it over the frame, and an rg11b10 hdr fallback has no
3447
+ // alpha channel to read.
3448
+ this.trailLayerTexture?.destroy()
3449
+ this.trailLayerTexture = this.device.createTexture({
3450
+ label: "trail layer",
3451
+ size: [width, height],
3452
+ format: "rgba16float",
3453
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
3454
+ })
3455
+ this.trailLayerView = this.trailLayerTexture.createView()
3456
+
3457
+ // The field layer — half resolution by default, full for @fullres effects.
3458
+ this.fieldFullW = width
3459
+ this.fieldFullH = height
3460
+ this.createFieldTargets()
3461
+
2770
3462
  // Bloom-mask MRT attachments — same dims + MSAA as HDR so they share the render pass.
2771
3463
  // MS buffer gets resolved into maskResolveTexture, which the bloom blit pass samples.
2772
3464
  this.multisampleMaskTexture = this.device.createTexture({
@@ -2826,6 +3518,7 @@ export class Engine {
2826
3518
 
2827
3519
  const depthTextureView = this.depthTexture.createView()
2828
3520
  this.depthReadView = this.depthTexture.createView({ aspect: "depth-only" })
3521
+ this.rebindTrails()
2829
3522
 
2830
3523
  // storeOp="discard" on MSAA views keeps per-sample data in Apple TBDR tile memory —
2831
3524
  // only the resolveTarget (hdrResolveTexture / maskResolveView) gets written to RAM.
@@ -3255,6 +3948,63 @@ export class Engine {
3255
3948
  return this.cameraAnimation?.duration ?? 0
3256
3949
  }
3257
3950
 
3951
+ /**
3952
+ * Install a track's precomputed analysis for the rzAudio* effect functions:
3953
+ * `data` is frames × (2 + bands) floats — loudness, bass onset, then the band
3954
+ * magnitudes, all 0..1 — sampled by the clock given to setAudioTime. Null
3955
+ * clears back to silence.
3956
+ *
3957
+ * Precomputed for the WHOLE track, never fed live from an analyser: an export
3958
+ * steps the engine frame by frame rather than playing in real time, so live
3959
+ * analysis would render silence into the exported video.
3960
+ */
3961
+ setAudioData(data: Float32Array | null, bandsPerFrame: number, secondsPerFrame: number): void {
3962
+ if (this.audioBuffer !== this.audioFallbackBuffer) this.audioBuffer.destroy()
3963
+ if (!data || data.length === 0) {
3964
+ this.audioBuffer = this.audioFallbackBuffer
3965
+ } else {
3966
+ const frames = Math.floor(data.length / (bandsPerFrame + 2))
3967
+ const payload = new Float32Array(8 + data.length)
3968
+ payload[0] = frames
3969
+ payload[1] = bandsPerFrame
3970
+ payload[2] = secondsPerFrame
3971
+ payload.set(data, 8)
3972
+ this.audioBuffer = this.device.createBuffer({
3973
+ label: "audio analysis",
3974
+ size: payload.byteLength,
3975
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
3976
+ })
3977
+ this.device.queue.writeBuffer(this.audioBuffer, 0, payload)
3978
+ }
3979
+ // Every consumer holds the buffer by reference in a bind group; all of them
3980
+ // re-bind so audio arriving after an effect (or before one) both work.
3981
+ this.rebuildCompositeBindGroup()
3982
+ this.rebindTrails()
3983
+ if (this.particles) {
3984
+ const b = this.particles.rebind()
3985
+ this.particles.computeBind = b.computeBind
3986
+ this.particles.renderBind = b.renderBind
3987
+ }
3988
+ }
3989
+
3990
+ /**
3991
+ * Where the track is NOW, in seconds — written by whoever owns playback: the
3992
+ * editor's audio clock, the viewer's, or the export loop with its exact
3993
+ * per-frame time. A 4-byte header write, cheap enough for every frame.
3994
+ */
3995
+ setAudioTime(seconds: number, playing = true): void {
3996
+ if (this.audioBuffer === this.audioFallbackBuffer) return
3997
+ this.audioTimeScratch[0] = seconds
3998
+ this.audioTimeScratch[1] = playing ? 1 : 0
3999
+ this.device.queue.writeBuffer(this.audioBuffer, 12, this.audioTimeScratch)
4000
+ }
4001
+
4002
+ /** Every camera keyframe's frame index — what a timeline draws as its cuts.
4003
+ * Empty when no camera VMD is loaded. */
4004
+ getCameraVmdKeyframes(): number[] {
4005
+ return this.cameraAnimation?.keyframeIndices() ?? []
4006
+ }
4007
+
3258
4008
  /** Drop the loaded camera VMD and return to orbit control. */
3259
4009
  clearCameraVmd(): void {
3260
4010
  this.cameraAnimation = null
@@ -5564,7 +6314,7 @@ export class Engine {
5564
6314
  // write leaves dofU[0].x at 0 while DoF is off, so refreshing it does not
5565
6315
  // switch the gather on.
5566
6316
  const dofOn = this.depthOfField.enabled
5567
- const depthRead = dofOn || (this.effect?.hasForeground ?? false)
6317
+ const depthRead = dofOn || (this.effect?.hasForeground ?? false) || this.trails !== null
5568
6318
  this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = depthRead ? "store" : "discard"
5569
6319
  if (depthRead) this.writeDepthOfFieldUniforms()
5570
6320
 
@@ -5597,6 +6347,8 @@ export class Engine {
5597
6347
  this.shadowMapPopulated = hasModels
5598
6348
  }
5599
6349
 
6350
+ this.stepParticles(encoder, deltaTime)
6351
+
5600
6352
  const pass = encoder.beginRenderPass(this.renderPassDescriptor)
5601
6353
  // Phase order: opaque models → ground → transparent fabric.
5602
6354
  // The ground shader is the most expensive full-coverage draw in the frame
@@ -5614,8 +6366,19 @@ export class Engine {
5614
6366
  this.forEachInstance((inst) => {
5615
6367
  if (inst.model.visible) this.renderModelTransparentPhase(pass, inst)
5616
6368
  })
6369
+ // Last in the pass: depth-tested against everything drawn above, so a
6370
+ // particle behind the character is simply hidden, and still inside the HDR
6371
+ // target so an `@bloom` effect reaches the pyramid below.
6372
+ this.renderParticles(pass)
5617
6373
  pass.end()
5618
6374
 
6375
+ // Ribbons draw AFTER the scene pass ends, so its depth is resolved for
6376
+ // their manual occlusion test — and before the composite that reads them.
6377
+ this.renderTrailLayer(encoder)
6378
+ // The field mounts, likewise: after the scene so foregrounds can read its
6379
+ // depth, before the composite that samples both layers.
6380
+ this.renderFieldPass(encoder)
6381
+
5619
6382
  // Bloom pyramid (EEVEE 3.6):
5620
6383
  // 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
5621
6384
  // 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
@@ -6465,15 +7228,38 @@ export class Engine {
6465
7228
  ring = { pos: [], t: [] }
6466
7229
  this.anchorTrail.set(key, ring)
6467
7230
  }
7231
+ // A TELEPORT is not motion. A model popping from the origin to its place at
7232
+ // load, a scrub, a scene swap — the bone genuinely moves many units in one
7233
+ // frame, and a recorder that faithfully keeps both ends hands every reader a
7234
+ // path across the world: the ribbon drew it as a streak and the sparks
7235
+ // seeded a burst along it. Fifty units per second is far beyond any dance
7236
+ // (a hard flick peaks around twenty); past it, the history restarts here.
7237
+ if (ring.pos.length > 0) {
7238
+ const dx = pos.x - ring.pos[0]
7239
+ const dy = pos.y - ring.pos[1]
7240
+ const dz = pos.z - ring.pos[2]
7241
+ const dt = Math.max(1 / 120, this.sceneClock - ring.t[0])
7242
+ if (Math.hypot(dx, dy, dz) / dt > 50) {
7243
+ ring.pos.length = 0
7244
+ ring.t.length = 0
7245
+ }
7246
+ }
6468
7247
  if (this.trailDue > 0 || ring.pos.length === 0) {
6469
- const steps = Math.min(this.trailDue, 4)
6470
- for (let k = 0; k < Math.max(1, steps); k++) {
6471
- ring.pos.unshift(pos.x, pos.y, pos.z)
6472
- ring.t.unshift(this.sceneClock)
6473
- if (ring.t.length > TRAIL_SAMPLES) {
6474
- ring.t.length = TRAIL_SAMPLES
6475
- ring.pos.length = TRAIL_SAMPLES * 3
6476
- }
7248
+ // ONE sample per frame, never one per due tick. A frame that spanned
7249
+ // several 60Hz ticks only knows where the bone is NOW, and unshifting that
7250
+ // position once per tick fabricated duplicate samples — same point, same
7251
+ // timestamp, up to four copies — precisely when the scene ran heavy. Every
7252
+ // duplicate pair kinked the spline, and each kink drew as a bright bar
7253
+ // across the ribbon: banding that appeared under load, was spaced once per
7254
+ // frame, and survived every renderer fix because the renderer was
7255
+ // faithfully drawing corrupted history. Coarser spacing under load is
7256
+ // honest — each sample carries its true timestamp, and the spline and the
7257
+ // central-difference weight exist to handle uneven spacing.
7258
+ ring.pos.unshift(pos.x, pos.y, pos.z)
7259
+ ring.t.unshift(this.sceneClock)
7260
+ if (ring.t.length > TRAIL_SAMPLES) {
7261
+ ring.t.length = TRAIL_SAMPLES
7262
+ ring.pos.length = TRAIL_SAMPLES * 3
6477
7263
  }
6478
7264
  }
6479
7265
  const count = ring.t.length