reze-engine 0.50.2 → 0.50.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.
Files changed (42) hide show
  1. package/dist/engine.d.ts +294 -7
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +900 -109
  4. package/dist/shaders/cast-api.d.ts +1 -1
  5. package/dist/shaders/cast-api.d.ts.map +1 -1
  6. package/dist/shaders/cast-layout.d.ts +44 -1
  7. package/dist/shaders/cast-layout.d.ts.map +1 -1
  8. package/dist/shaders/cast-layout.js +44 -1
  9. package/dist/shaders/materials/common.d.ts.map +1 -1
  10. package/dist/shaders/materials/common.js +7 -1
  11. package/dist/shaders/materials/nodes.d.ts +1 -1
  12. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  13. package/dist/shaders/materials/nodes.js +17 -9
  14. package/dist/shaders/passes/composite.d.ts +1 -1
  15. package/dist/shaders/passes/composite.d.ts.map +1 -1
  16. package/dist/shaders/passes/depth-prepass.d.ts +1 -1
  17. package/dist/shaders/passes/depth-prepass.d.ts.map +1 -1
  18. package/dist/shaders/passes/depth-prepass.js +52 -12
  19. package/dist/shaders/passes/ground.d.ts +16 -0
  20. package/dist/shaders/passes/ground.d.ts.map +1 -1
  21. package/dist/shaders/passes/ground.js +143 -49
  22. package/dist/shaders/passes/outline.d.ts +1 -1
  23. package/dist/shaders/passes/outline.d.ts.map +1 -1
  24. package/dist/shaders/passes/outline.js +12 -3
  25. package/dist/shaders/passes/particles.d.ts.map +1 -1
  26. package/dist/shaders/passes/particles.js +6 -2
  27. package/dist/shaders/passes/scene-contract.d.ts +38 -6
  28. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  29. package/dist/shaders/passes/scene-contract.js +53 -16
  30. package/dist/shaders/passes/trails.d.ts.map +1 -1
  31. package/dist/shaders/passes/trails.js +36 -8
  32. package/package.json +2 -2
  33. package/src/engine.ts +952 -100
  34. package/src/shaders/cast-layout.ts +44 -1
  35. package/src/shaders/materials/common.ts +7 -1
  36. package/src/shaders/materials/nodes.ts +17 -9
  37. package/src/shaders/passes/depth-prepass.ts +53 -12
  38. package/src/shaders/passes/ground.ts +145 -49
  39. package/src/shaders/passes/outline.ts +14 -3
  40. package/src/shaders/passes/particles.ts +6 -2
  41. package/src/shaders/passes/scene-contract.ts +55 -16
  42. package/src/shaders/passes/trails.ts +36 -8
package/src/engine.ts CHANGED
@@ -50,9 +50,9 @@ import {
50
50
  hasLightEmit,
51
51
  parseLightCount,
52
52
  } from "./shaders/lights"
53
- import { groundShaderWgsl } from "./shaders/passes/ground"
54
- import { OUTLINE_SHADER_WGSL } from "./shaders/passes/outline"
55
- import { TRANSPARENT_DEPTH_PREPASS_WGSL } from "./shaders/passes/depth-prepass"
53
+ import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground"
54
+ import { outlineShaderWgsl } from "./shaders/passes/outline"
55
+ import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass"
56
56
  import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection"
57
57
  import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo"
58
58
  import {
@@ -779,6 +779,28 @@ interface GpuMorph {
779
779
  // vertex sampling would misclassify hair (which must stay opaque-bucket for
780
780
  // stencil interplay and shadows).
781
781
 
782
+ /**
783
+ * A 2D context for the alpha readback, from whichever canvas this browser has.
784
+ *
785
+ * OffscreenCanvas's 2D context is not universal — Safari only gained it in
786
+ * 16.4, and a worker-less fallback has to be a DOM canvas. This used to be an
787
+ * unguarded `new OffscreenCanvas`, so a browser without it took the catch below
788
+ * and every material on the model was classified opaque. That is a rendering
789
+ * difference produced by a feature probe failing, which is the kind of thing
790
+ * that must never be silent.
791
+ */
792
+ function alphaReadbackContext(w: number, h: number): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null {
793
+ if (typeof OffscreenCanvas !== "undefined") {
794
+ const cx = new OffscreenCanvas(w, h).getContext("2d", { willReadFrequently: true })
795
+ if (cx) return cx
796
+ }
797
+ if (typeof document === "undefined") return null
798
+ const el = document.createElement("canvas")
799
+ el.width = w
800
+ el.height = h
801
+ return el.getContext("2d", { willReadFrequently: true })
802
+ }
803
+
782
804
  /** Downsampled alpha plane of a decoded texture (≤128², nearest-sampled). */
783
805
  function buildAlphaSampler(
784
806
  source: ImageBitmap | null,
@@ -789,20 +811,37 @@ function buildAlphaSampler(
789
811
  try {
790
812
  const w = Math.max(1, Math.min(128, width))
791
813
  const h = Math.max(1, Math.min(128, height))
792
- const canvas = new OffscreenCanvas(w, h)
793
- const cx = canvas.getContext("2d", { willReadFrequently: true })
794
- if (!cx) return null
795
- if (source) {
796
- cx.drawImage(source, 0, 0, w, h)
797
- } else if (rgba) {
798
- const tmp = new OffscreenCanvas(width, height)
799
- const tcx = tmp.getContext("2d")
800
- if (!tcx) return null
801
- tcx.putImageData(new ImageData(new Uint8ClampedArray(rgba), width, height), 0, 0)
802
- cx.drawImage(tmp, 0, 0, w, h)
803
- } else {
804
- return null
814
+ // Raw RGBA needs no canvas at all, and must not use one. It arrives from the
815
+ // TGA/DDS/PSD decoders as exact, straight-alpha bytes; the old path pushed it
816
+ // through putImageData → drawImage → getImageData, which is two premultiply
817
+ // round-trips and a resample to learn what was already in hand. Box-filtered
818
+ // straight off the array instead: same ≤128² plane, exact values, no canvas
819
+ // to be unavailable and no alpha to lose.
820
+ if (rgba) {
821
+ const a = new Uint8ClampedArray(w * h)
822
+ for (let y = 0; y < h; y++) {
823
+ const y0 = Math.floor((y * height) / h)
824
+ const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * height) / h))
825
+ for (let x = 0; x < w; x++) {
826
+ const x0 = Math.floor((x * width) / w)
827
+ const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * width) / w))
828
+ let sum = 0
829
+ let n = 0
830
+ for (let sy = y0; sy < y1; sy++) {
831
+ for (let sx = x0; sx < x1; sx++) {
832
+ sum += rgba[(sy * width + sx) * 4 + 3]
833
+ n++
834
+ }
835
+ }
836
+ a[y * w + x] = n > 0 ? sum / n : 255
837
+ }
838
+ }
839
+ return { a, w, h }
805
840
  }
841
+ if (!source) return null
842
+ const cx = alphaReadbackContext(w, h)
843
+ if (!cx) return null
844
+ cx.drawImage(source, 0, 0, w, h)
806
845
  const img = cx.getImageData(0, 0, w, h).data
807
846
  const a = new Uint8ClampedArray(w * h)
808
847
  for (let i = 0; i < w * h; i++) a[i] = img[i * 4 + 3]
@@ -1152,7 +1191,10 @@ interface EffectGrid {
1152
1191
  * ribbon read through rzTrail, so a trail costs one draw and nothing recorded.
1153
1192
  */
1154
1193
  interface EffectTrails {
1155
- instances: number
1194
+ /** Ribbons this effect declared — one per trailed anchor. The instance count
1195
+ * is derived from it per draw, against the live subject count, rather than
1196
+ * baked here against the four-subject cap. See drawTrails. */
1197
+ slots: number
1156
1198
  uniform: GPUBuffer
1157
1199
  data: Float32Array
1158
1200
  pipeline: GPURenderPipeline
@@ -1216,6 +1258,19 @@ interface EffectInstance {
1216
1258
  /** Mounted over the finished frame — and the reason the scene pass has to
1217
1259
  * STORE its depth, which it otherwise discards into tile memory. */
1218
1260
  hasForeground: boolean
1261
+ /** Does this source actually call rzObjectAt / rzMaterialAt?
1262
+ *
1263
+ * The exact sibling of hasForeground above, for the exact same reason. The id
1264
+ * attachment is the pass's most expensive STORE — rg16uint at the pass's
1265
+ * sample count, around 33MB a frame at 1080p — and it is written out for
1266
+ * every scene whether or not a single effect ever reads it. Declaring
1267
+ * the attachment is what keeps the pipelines agreeing; STORING it is what
1268
+ * costs, and only a reader can justify that.
1269
+ *
1270
+ * Parsed once at install rather than tested per frame: the answer cannot
1271
+ * change while an effect is installed, and the frame path should not be
1272
+ * running regexes. */
1273
+ readsIds: boolean
1219
1274
  /** Bones this source asked for, in ITS OWN declaration order. The scene table
1220
1275
  * maps these onto shared addresses; this list is what it is rebuilt from. */
1221
1276
  anchors: { bone: string; trail: boolean }[]
@@ -1232,6 +1287,8 @@ interface EffectInstance {
1232
1287
  lights: {
1233
1288
  pipeline: GPUComputePipeline
1234
1289
  bind: GPUBindGroup
1290
+ /** Kept so `bind` can be rebuilt when a shared buffer it names is replaced. */
1291
+ layout: GPUBindGroupLayout
1235
1292
  uniform: GPUBuffer
1236
1293
  data: Float32Array<ArrayBuffer>
1237
1294
  /** How many slots it asked for. Its base is assigned by the engine and can
@@ -1285,7 +1342,9 @@ export class Engine {
1285
1342
  // Grouped materials use their group's own compiled pipeline.
1286
1343
  private neutralPipeline!: GPURenderPipeline
1287
1344
  private neutralPipelineNoDepthWrite!: GPURenderPipeline
1288
- private transparentDepthPrepassPipeline!: GPURenderPipeline
1345
+ private depthPrepassPipeline!: GPURenderPipeline
1346
+ private solidPrepassPipeline!: GPURenderPipeline
1347
+ private hairPrimePipeline!: GPURenderPipeline
1289
1348
  // ── Style group runtime ──
1290
1349
  // Shared 256 B zero StyleUniforms buffer (group(2) binding(4)) bound by every ungrouped
1291
1350
  // material; grouped materials rebind to their group's own buffer (per-model, in the
@@ -1370,6 +1429,23 @@ export class Engine {
1370
1429
  private multisampleTexture!: GPUTexture
1371
1430
  private hdrResolveTexture!: GPUTexture
1372
1431
  private static readonly MULTISAMPLE_COUNT = 4
1432
+ /**
1433
+ * Shadow map depth format — 16-bit, deliberately.
1434
+ *
1435
+ * The maps are ORTHOGRAPHIC, so depth is linear across the box: 65,536 steps
1436
+ * over the near cascade's 140-unit range is 0.002 units per step, and every
1437
+ * bias in play dwarfs it — the samplers subtract 0.0035 ndc (~229 of these
1438
+ * steps) and the materials offset along the normal by 0.08 units (~37 steps)
1439
+ * before the compare ever runs. Quantisation cannot flip an answer the biases
1440
+ * have already moved that far, so the pixels are identical to depth32float's.
1441
+ *
1442
+ * What is NOT identical is the bandwidth, which is the term WebKit pays
1443
+ * hardest: every PCF tap is a hardware-bilinear compare reading four texels,
1444
+ * so nine taps read half the bytes at 2 B/texel — 72 B/pixel instead of 144
1445
+ * across every shadowed surface on screen — and the 4096² map's clear+store
1446
+ * each frame drops from 64 MB to 32.
1447
+ */
1448
+ private static readonly SHADOW_DEPTH_FORMAT: GPUTextureFormat = "depth16unorm"
1373
1449
  // HDR intermediate format. rg11b10ufloat when the adapter exposes the
1374
1450
  // `rg11b10ufloat-renderable` feature (Chrome + Safari on Apple Silicon both
1375
1451
  // do), else fall back to rgba16float.
@@ -1387,6 +1463,24 @@ export class Engine {
1387
1463
  // the fragment shader and treats missing dst.a as 1, so the blend math is
1388
1464
  // unchanged).
1389
1465
  private hdrFormat: GPUTextureFormat = "rgba16float"
1466
+ /**
1467
+ * Force the HDR format instead of taking the device's answer. Null = probe,
1468
+ * which is what ships.
1469
+ *
1470
+ * A diagnostic, and deliberately a coarse one. The choice above is the ONE
1471
+ * render-target difference between a Safari device and a desktop Chrome that
1472
+ * lacks the feature, which makes it the first thing to eliminate when
1473
+ * something renders correctly on one and not the other — and specifically when
1474
+ * the something involves alpha, because rg11b10ufloat is the path with no
1475
+ * alpha channel to carry it. Setting this to "rgba16float" on the device puts
1476
+ * Safari back on the desktop's path at the cost of the tile-memory win, so a
1477
+ * symptom that survives is not about the format and a symptom that vanishes
1478
+ * is.
1479
+ *
1480
+ * Static, like MRT_IDS: read once in init(), before any texture or pipeline
1481
+ * exists, so there is no such thing as changing it on a live engine.
1482
+ */
1483
+ static HDR_FORMAT_OVERRIDE: GPUTextureFormat | null = null
1390
1484
  /** Main-pass depth. Float when the adapter offers depth32float-stencil8, which
1391
1485
  * is also what makes reversed-Z worth switching on. */
1392
1486
  private depthFormat: GPUTextureFormat = "depth24plus-stencil8"
@@ -1573,7 +1667,6 @@ export class Engine {
1573
1667
  private cullRebuilds = 0
1574
1668
  // ── Render bundles ──
1575
1669
  private opaqueBundle: GPURenderBundle | null = null
1576
- private transparentBundle: GPURenderBundle | null = null
1577
1670
  private shadowBundles: GPURenderBundle[] = []
1578
1671
  /** Set by scene STRUCTURE only. Every frame of animation, every physics step
1579
1672
  * and every camera move must leave this alone — re-recording constantly is
@@ -1597,7 +1690,34 @@ export class Engine {
1597
1690
  * field restructure moves. Restructuring it while it was the only untimed
1598
1691
  * pass in the frame would have meant reasoning about the cost instead of
1599
1692
  * reading it. */
1600
- private static readonly TIMED_PASSES = ["cull", "shadow", "scene", "field", "composite"] as const
1693
+ /**
1694
+ * The passes worth a number, in the order the frame runs them.
1695
+ *
1696
+ * These ARE the boxes on the architecture figure, deliberately: a reading that
1697
+ * cannot be pointed at a component is a reading nobody acts on. Three were
1698
+ * missing and each is a real per-frame cost a report of "it feels slower"
1699
+ * could have been about — the morph compute, the mirror's second pass over the
1700
+ * whole cast, and the bloom pyramid, which is NINE render passes and was the
1701
+ * largest unmeasured thing in the frame.
1702
+ *
1703
+ * The per-effect computes (particles, grids, lights) are deliberately absent:
1704
+ * they are a loop of one pass per effect, so there is no single span to stamp
1705
+ * and a number attributed to the wrong one is worse than no number. They fall
1706
+ * into the "rest" the readout derives from the frame time.
1707
+ *
1708
+ * Adding one costs two query slots and nothing else; the query set is sized
1709
+ * from this array's length.
1710
+ */
1711
+ private static readonly TIMED_PASSES = [
1712
+ "cull",
1713
+ "morph",
1714
+ "shadow",
1715
+ "mirror",
1716
+ "scene",
1717
+ "field",
1718
+ "bloom",
1719
+ "composite",
1720
+ ] as const
1601
1721
  private timestampQuerySet: GPUQuerySet | null = null
1602
1722
  private timestampResolve: GPUBuffer | null = null
1603
1723
  private timestampRead: GPUBuffer | null = null
@@ -1701,6 +1821,9 @@ export class Engine {
1701
1821
  * the plural is the whole point of this step and a singleton that has to be
1702
1822
  * "generalised later" is a singleton that shapes every call site against it.
1703
1823
  */
1824
+ /** Subjects the cast actually holds, set while it is filled. The ribbons size
1825
+ * their instance count by this rather than by the four-subject cap. */
1826
+ private castSubjectCount = 0
1704
1827
  private effects: EffectInstance[] = []
1705
1828
  /** The first installed effect, for the many places that legitimately want
1706
1829
  * "is anything installed" or the singleton API's one effect. */
@@ -2013,6 +2136,27 @@ export class Engine {
2013
2136
  }
2014
2137
  }
2015
2138
 
2139
+ /**
2140
+ * Whether bloom will actually reach the frame this frame.
2141
+ *
2142
+ * The composite multiplies the pyramid by this same effective intensity, so a
2143
+ * zero here means every pass that BUILDS the pyramid is work whose result is
2144
+ * multiplied by nothing. That was the state of it: `enabled` reached exactly
2145
+ * one line — the intensity uniform below — and the nine render passes that
2146
+ * fill the pyramid ran regardless, on every frame, of every scene, whether or
2147
+ * not anyone had asked for bloom.
2148
+ *
2149
+ * Nine passes is the number that matters rather than the pixels: on a
2150
+ * tile-based GPU a render pass is a tile load and store whatever it draws, so
2151
+ * this is paid in full on Apple hardware and largely hidden on a desktop
2152
+ * immediate-mode one. It is the same asymmetry as the bundle bug — cheap where
2153
+ * it was written, expensive where it was reported.
2154
+ */
2155
+ private bloomContributes(): boolean {
2156
+ const b = this.bloomSettings
2157
+ return b.enabled && b.intensity > 0
2158
+ }
2159
+
2016
2160
  private writeCompositeViewUniforms(): void {
2017
2161
  const v = this.viewTransform
2018
2162
  const b = this.bloomSettings
@@ -2216,11 +2360,10 @@ export class Engine {
2216
2360
  * sibling of addGround's own options. False when there is no ground.
2217
2361
  *
2218
2362
  * ON OR OFF, deliberately not a strength: the reflection is an independent
2219
- * LAYER over the floor surface, and it shows whatever the ground's opacity
2220
- * is a mirror you can hide by making the floor solid is not a switch, it
2221
- * is a second strength dial wearing a boolean's clothes. Blur 0 is a
2222
- * polished mirror; 1 samples the softest level, scaled by how far the
2223
- * reflected geometry sits behind the surface.
2363
+ * LAYER beneath the floor surface, and how much of it shows is the ground's
2364
+ * own opacity covering it. Blur 0 is a polished mirror; 1 samples the
2365
+ * softest level, scaled by how far the reflected geometry sits behind the
2366
+ * surface.
2224
2367
  */
2225
2368
  setGroundMirror(on: boolean, blur?: number): boolean {
2226
2369
  if (!this.groundShadowMaterialBuffer) return false
@@ -2324,6 +2467,9 @@ export class Engine {
2324
2467
  const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))
2325
2468
  const atts = this.mirrorPassDescriptor.colorAttachments as GPURenderPassColorAttachment[]
2326
2469
  atts[0].clearValue = bg ? { r: lin(bg.x), g: lin(bg.y), b: lin(bg.z), a: 1 } : { r: 0, g: 0, b: 0, a: 0 }
2470
+ // The descriptor is reused every frame, so the stamp is set on it rather
2471
+ // than passed — same as the scene pass, which is built once too.
2472
+ this.mirrorPassDescriptor.timestampWrites = this.stamps("mirror")
2327
2473
  const pass = encoder.beginRenderPass(this.mirrorPassDescriptor)
2328
2474
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
2329
2475
  const bundles: GPURenderBundle[] = []
@@ -2411,6 +2557,67 @@ export class Engine {
2411
2557
  return probe !== null && !err
2412
2558
  }
2413
2559
 
2560
+ /**
2561
+ * Record an uncaptured validation error, once per distinct message.
2562
+ *
2563
+ * Distinct, because the interesting property of these is WHICH ones happened,
2564
+ * not how many times — a pass that fails validation fails identically every
2565
+ * frame, so the second occurrence carries no information the first did not.
2566
+ * The count is kept anyway: "1×" and "94000×" distinguish a one-off at init
2567
+ * from something the render loop is doing, and that distinction is the first
2568
+ * question anyone reading the report will have.
2569
+ */
2570
+ private noteGpuError(message: string): void {
2571
+ const seen = this.gpuErrors.get(message)
2572
+ if (seen !== undefined) {
2573
+ this.gpuErrors.set(message, seen + 1)
2574
+ return
2575
+ }
2576
+ // The cap is on DISTINCT messages, so it is reached only by a device
2577
+ // disagreeing about many different things — at which point the first 32
2578
+ // have said what the device is, and the rest are noise.
2579
+ if (this.gpuErrors.size >= 32) return
2580
+ this.gpuErrors.set(message, 1)
2581
+ // First occurrence only, and console.error rather than a silent buffer: a
2582
+ // validation error means something did not draw, and a developer with the
2583
+ // console open should not have to know this report exists to find out.
2584
+ console.error(`[reze] WebGPU validation: ${message}`)
2585
+ }
2586
+
2587
+ /** Distinct uncaptured validation messages → how many times each arrived. */
2588
+ private readonly gpuErrors = new Map<string, number>()
2589
+
2590
+ /**
2591
+ * What this device actually gave us, and what it refused.
2592
+ *
2593
+ * The report exists because the three answers below are the ones that differ
2594
+ * between two browsers on the same machine, and a scene that renders wrong on
2595
+ * one of them is otherwise indistinguishable from a scene that is wrong. It is
2596
+ * meant to be read off a phone that cannot be attached to a debugger, which is
2597
+ * why it returns a value rather than logging: the host decides where to put it.
2598
+ */
2599
+ gpuReport(): {
2600
+ hdrFormat: GPUTextureFormat
2601
+ depthFormat: GPUTextureFormat
2602
+ reversedZ: boolean
2603
+ ids: boolean
2604
+ sampleCount: number
2605
+ presentationFormat: GPUTextureFormat
2606
+ features: string[]
2607
+ errors: { message: string; count: number }[]
2608
+ } {
2609
+ return {
2610
+ hdrFormat: this.hdrFormat,
2611
+ depthFormat: this.depthFormat,
2612
+ reversedZ: this.reversedZ,
2613
+ ids: mrtIdsEnabled(),
2614
+ sampleCount: Engine.MULTISAMPLE_COUNT,
2615
+ presentationFormat: this.presentationFormat,
2616
+ features: this.device ? [...this.device.features].sort() : [],
2617
+ errors: [...this.gpuErrors].map(([message, count]) => ({ message, count })),
2618
+ }
2619
+ }
2620
+
2414
2621
  private rebuildCompositeBindGroup(): void {
2415
2622
  if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
2416
2623
  if (!this.castBuffer) return
@@ -2542,6 +2749,72 @@ export class Engine {
2542
2749
  }
2543
2750
  }
2544
2751
 
2752
+ /**
2753
+ * The grid mount's bind group for one parity.
2754
+ *
2755
+ * A method rather than a closure at the creation site because the SET of
2756
+ * buffers in here is a contract with two parties: the grid is built once, and
2757
+ * rebuilt whenever a shared buffer it names is replaced (see
2758
+ * rebindSharedBuffers). Written twice, the rebuild silently keeps a binding
2759
+ * the creation grew — and a bind group that names a destroyed buffer does not
2760
+ * fail where it was written, it fails at the next submit.
2761
+ */
2762
+ private gridBindGroup(
2763
+ g: { layout: GPUBindGroupLayout; uniform: GPUBuffer; read: [GPUTextureView, GPUTextureView]; textures: [GPUTexture, GPUTexture] },
2764
+ i: number,
2765
+ ): GPUBindGroup {
2766
+ return this.device.createBindGroup({
2767
+ layout: g.layout,
2768
+ entries: [
2769
+ { binding: 0, resource: { buffer: g.uniform } },
2770
+ { binding: 1, resource: g.read[i] },
2771
+ { binding: 2, resource: this.simSampler },
2772
+ { binding: 3, resource: g.textures[1 - i].createView() },
2773
+ { binding: 4, resource: { buffer: this.castBuffer } },
2774
+ { binding: 5, resource: { buffer: this.audioBuffer } },
2775
+ { binding: 6, resource: { buffer: this.compositeUniformBuffer } },
2776
+ { binding: 7, resource: { buffer: this.midiBuffer } },
2777
+ { binding: 8, resource: { buffer: this.lyricsBuffer } },
2778
+ ],
2779
+ })
2780
+ }
2781
+
2782
+ /**
2783
+ * Re-point EVERY bind group that names a shared scene buffer at the buffer
2784
+ * that is there NOW.
2785
+ *
2786
+ * setAudioData and setMidiNotes do not write their buffer, they REPLACE it:
2787
+ * the payload is a different length each time, so the old one is destroyed and
2788
+ * a new one takes its place. Every bind group built before that moment still
2789
+ * names the dead buffer, and a bind group is not re-read — it holds the
2790
+ * resource it was given. The failure is therefore not at the swap but one
2791
+ * frame later, as `[Buffer "score"] used in submit while destroyed`, with the
2792
+ * scene dead and nothing pointing at the setter that did it.
2793
+ *
2794
+ * Both setters used to rebind three of the six families that hold these
2795
+ * buffers — composite, ribbons, particles — and miss the field mount, the grid
2796
+ * mount and the light emitter. Which is to say it worked for every effect that
2797
+ * happened not to have a field, and a falling-note effect is exactly the kind
2798
+ * that does. So the list lives HERE, once, and both setters call it: the
2799
+ * question "who holds this buffer?" now has one place to be answered, and the
2800
+ * next binding added is added to a list that everything already consults.
2801
+ */
2802
+ private rebindSharedBuffers(): void {
2803
+ this.rebuildCompositeBindGroup()
2804
+ this.rebindTrails()
2805
+ this.rebuildFieldBindGroup()
2806
+ for (const e of this.effects) {
2807
+ if (e.particles) {
2808
+ const b = e.particles.rebind()
2809
+ e.particles.computeBind = b.computeBind
2810
+ e.particles.renderBind = b.renderBind
2811
+ e.particles.mirrorRenderBind = b.mirrorRenderBind
2812
+ }
2813
+ if (e.grid) e.grid.binds = [this.gridBindGroup(e.grid, 0), this.gridBindGroup(e.grid, 1)]
2814
+ if (e.lights) e.lights.bind = this.lightEmitBindGroup(e.lights.layout, e.lights.uniform)
2815
+ }
2816
+ }
2817
+
2545
2818
  /**
2546
2819
  * Set a 360° backdrop from an equirectangular (2:1) image — a PhotoDome-style
2547
2820
  * skybox at infinity, sampled per-pixel by view direction so it follows the
@@ -3006,6 +3279,11 @@ export class Engine {
3006
3279
  paramsData,
3007
3280
  hasBackground,
3008
3281
  hasForeground,
3282
+ // The author's OWN source, not the assembled module: the assembled one
3283
+ // always carries the accessors (as real readers or as the zero stubs),
3284
+ // so matching against it would report every effect as a reader and the
3285
+ // attachment would be stored exactly as often as before.
3286
+ readsIds: /\brz(?:ObjectAt|MaterialAt)\s*\(/.test(wgsl),
3009
3287
  anchors,
3010
3288
  // The effect's own clock starts now. Per effect so that one installed
3011
3289
  // later still gets a frame where rzGridFrame() is 0 and can seed.
@@ -3402,7 +3680,16 @@ export class Engine {
3402
3680
  uniform.destroy()
3403
3681
  return { ok: false, diagnostics: [scoped.message] }
3404
3682
  }
3405
- const bind = this.device.createBindGroup({
3683
+ const bind = this.lightEmitBindGroup(layout, uniform)
3684
+ // The layout travels with the state so the emitter can be rebound when a
3685
+ // shared buffer under it is replaced — see rebindSharedBuffers.
3686
+ return { ok: true, state: { pipeline, bind, layout, uniform, data, count } }
3687
+ }
3688
+
3689
+ /** The light emitter's bind group. One author, for the reason gridBindGroup
3690
+ * gives: it is built once and rebuilt on every shared-buffer swap. */
3691
+ private lightEmitBindGroup(layout: GPUBindGroupLayout, uniform: GPUBuffer): GPUBindGroup {
3692
+ return this.device.createBindGroup({
3406
3693
  label: "light emit bind",
3407
3694
  layout,
3408
3695
  entries: [
@@ -3415,7 +3702,6 @@ export class Engine {
3415
3702
  { binding: 6, resource: { buffer: this.lyricsBuffer } },
3416
3703
  ],
3417
3704
  })
3418
- return { ok: true, state: { pipeline, bind, uniform, data, count } }
3419
3705
  }
3420
3706
 
3421
3707
  /**
@@ -3597,7 +3883,10 @@ export class Engine {
3597
3883
  return {
3598
3884
  ok: true,
3599
3885
  state: {
3600
- instances: slots * MAX_EFFECT_SUBJECTS * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS,
3886
+ // Ribbons declared by this effect. The INSTANCE count is no longer
3887
+ // baked here — it follows the live subject count and is computed per
3888
+ // draw (see drawTrails).
3889
+ slots,
3601
3890
  uniform,
3602
3891
  data: new Float32Array(4),
3603
3892
  pipeline,
@@ -3659,13 +3948,27 @@ export class Engine {
3659
3948
  // The clock upload happens once, on the camera draw: queue writes land
3660
3949
  // before the encoder submits, so both passes read the same value — the
3661
3950
  // mirror draw writing it again would only write it twice.
3951
+ // Instances follow the LIVE subject count, not MAX_EFFECT_SUBJECTS.
3952
+ //
3953
+ // This used to be baked at install as slots x 4 x (samples-1) x subs, so a
3954
+ // scene with ONE character issued four characters' worth of ribbon quads
3955
+ // and threw three quarters of them away as degenerate — every frame, at
3956
+ // every sample length. Vertex invocations with no fragments are cheap, not
3957
+ // free, and they scale with the sample count, which is what made a longer
3958
+ // trail expensive.
3959
+ //
3960
+ // The shader decodes [ribbon][subject][segment] with the same number out
3961
+ // of its uniform, so the two cannot drift: change one without the other
3962
+ // and ribbons land on the wrong subject rather than merely costing more.
3963
+ const live = Math.max(1, this.castSubjectCount)
3662
3964
  if (view === "camera") {
3663
3965
  t.data[0] = this.sceneClock - e.epochScene
3966
+ t.data[1] = live
3664
3967
  this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
3665
3968
  }
3666
3969
  pass.setPipeline(t.pipeline)
3667
3970
  pass.setBindGroup(0, view === "mirror" ? t.mirrorBind : t.bind)
3668
- pass.draw(6, t.instances)
3971
+ pass.draw(6, t.slots * live * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS)
3669
3972
  }
3670
3973
  }
3671
3974
 
@@ -3851,21 +4154,7 @@ export class Engine {
3851
4154
  return { ok: false, diagnostics: [scoped.message] }
3852
4155
  }
3853
4156
  // One per parity: binds[i] READS textures[i] and WRITES the other.
3854
- const bindFor = (i: number) =>
3855
- this.device.createBindGroup({
3856
- layout,
3857
- entries: [
3858
- { binding: 0, resource: { buffer: uniform } },
3859
- { binding: 1, resource: read[i] },
3860
- { binding: 2, resource: this.simSampler },
3861
- { binding: 3, resource: textures[1 - i].createView() },
3862
- { binding: 4, resource: { buffer: this.castBuffer } },
3863
- { binding: 5, resource: { buffer: this.audioBuffer } },
3864
- { binding: 6, resource: { buffer: this.compositeUniformBuffer } },
3865
- { binding: 7, resource: { buffer: this.midiBuffer } },
3866
- { binding: 8, resource: { buffer: this.lyricsBuffer } },
3867
- ],
3868
- })
4157
+ const bindFor = (i: number) => this.gridBindGroup({ layout, uniform, read, textures }, i)
3869
4158
  return {
3870
4159
  ok: true,
3871
4160
  state: {
@@ -4108,7 +4397,37 @@ export class Engine {
4108
4397
  throw new Error("WebGPU is not supported in this browser.")
4109
4398
  }
4110
4399
  this.device = device
4400
+ // Every validation error this device ever raises, kept.
4401
+ //
4402
+ // WebGPU does not throw for a bad pipeline: createRenderPipeline hands back
4403
+ // an object that is already invalid, and the complaint arrives here instead
4404
+ // — or nowhere, if nobody is listening. Nobody was. That is why a device
4405
+ // that disagrees with this engine has, until now, had no way to say so: the
4406
+ // pipeline is built, setPipeline poisons the pass that uses it, and the
4407
+ // symptom reaches the user as geometry that is simply absent, with a clean
4408
+ // console. A browser is not obliged to agree with Dawn about what is legal,
4409
+ // and the two places this engine knowingly leans on Dawn's reading are both
4410
+ // in the scene pass (see scene-contract's writeMask-0 note).
4411
+ //
4412
+ // Bounded, and not on the console by default: a pass that fails validation
4413
+ // fails it again every frame, so an unbounded log is a memory leak with a
4414
+ // frame counter and an unconditional console.error is a browser tab that
4415
+ // stops responding. First N distinct messages, counted thereafter.
4416
+ device.addEventListener("uncapturederror", (e) => {
4417
+ const message = (e as GPUUncapturedErrorEvent).error.message
4418
+ this.noteGpuError(message)
4419
+ })
4111
4420
  if (hasRg11b10) this.hdrFormat = "rg11b10ufloat"
4421
+ // The override has the last word, including over a device that would have
4422
+ // been left on the fallback anyway — asking for the format you are already
4423
+ // getting is a no-op, not a contradiction. See HDR_FORMAT_OVERRIDE.
4424
+ if (Engine.HDR_FORMAT_OVERRIDE) {
4425
+ this.hdrFormat = Engine.HDR_FORMAT_OVERRIDE
4426
+ // Only when forced. The probed answer is the normal one and does not need
4427
+ // announcing on every boot; a forced one is a state someone set and will
4428
+ // want confirmed, and is the state they will forget they left on.
4429
+ console.info(`[reze] HDR target forced to ${this.hdrFormat}`)
4430
+ }
4112
4431
  // The id attachment, if this device will multisample a uint texture at the
4113
4432
  // pass's sample count. Probed by ASKING — creating one inside an error
4114
4433
  // scope — rather than by reading a feature flag, because there is no
@@ -4163,6 +4482,15 @@ export class Engine {
4163
4482
  this.createPipelines()
4164
4483
  this.setupResize()
4165
4484
  Engine.instance = this
4485
+ // One line, at init, naming the three answers that differ between two
4486
+ // browsers on the same machine. Not a debug flag and not a readout — it is
4487
+ // the identity of the renderer that was actually built, and on a device that
4488
+ // cannot be attached to a debugger it is the only way to know which of the
4489
+ // three paths is running. Every graphics application prints this.
4490
+ const r = this.gpuReport()
4491
+ console.info(
4492
+ `[reze] hdr=${r.hdrFormat} depth=${r.depthFormat} reversedZ=${r.reversedZ} ids=${r.ids} msaa=${r.sampleCount}`,
4493
+ )
4166
4494
  }
4167
4495
 
4168
4496
  // One-shot bake of EEVEE's combined BRDF LUT — DFG (bsdf_lut_frag.glsl) packed
@@ -4171,6 +4499,45 @@ export class Engine {
4171
4499
  // .ba = LTC magnitude → ltc_brdf_scale_from_lut
4172
4500
  // One texture fetch per fragment replaces the previous 2–3 taps. rgba8unorm
4173
4501
  // (vs rgba16float) halves sample bandwidth; DFG/LTC values fit [0,1] cleanly.
4502
+ /** The frost tile the ground samples instead of evaluating fbm per pixel. */
4503
+ private groundNoiseTexture!: GPUTexture
4504
+ private groundNoiseView!: GPUTextureView
4505
+
4506
+ /**
4507
+ * Bake the ground's frost noise once — the same fbm the shader used to run
4508
+ * per pixel, rendered to a seamless 1024² r8unorm tile at init.
4509
+ *
4510
+ * Why this exists is measured, not argued: on WebKit the ground's whole cost
4511
+ * was this evaluation (see the note at the sample site in ground.ts). The
4512
+ * bake is one fullscreen pass at init — under a millisecond, once — and the
4513
+ * per-pixel cost becomes a single level-0 texture read.
4514
+ */
4515
+ private bakeGroundNoise() {
4516
+ this.groundNoiseTexture = this.device.createTexture({
4517
+ label: "ground frost noise (baked)",
4518
+ size: [GROUND_NOISE_SIZE, GROUND_NOISE_SIZE],
4519
+ format: "r8unorm",
4520
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
4521
+ })
4522
+ this.groundNoiseView = this.groundNoiseTexture.createView()
4523
+ const module = this.device.createShaderModule({ label: "ground noise bake", code: GROUND_NOISE_BAKE_WGSL })
4524
+ const pipeline = this.device.createRenderPipeline({
4525
+ label: "ground noise bake",
4526
+ layout: "auto",
4527
+ vertex: { module, entryPoint: "vs" },
4528
+ fragment: { module, entryPoint: "fs", targets: [{ format: "r8unorm" }] },
4529
+ primitive: { topology: "triangle-list" },
4530
+ })
4531
+ const encoder = this.device.createCommandEncoder({ label: "ground noise bake" })
4532
+ const pass = encoder.beginRenderPass({
4533
+ colorAttachments: [{ view: this.groundNoiseView, loadOp: "clear", storeOp: "store" }],
4534
+ })
4535
+ pass.setPipeline(pipeline)
4536
+ pass.draw(3)
4537
+ pass.end()
4538
+ this.device.queue.submit([encoder.finish()])
4539
+ }
4540
+
4174
4541
  private bakeBrdfLut() {
4175
4542
  if (BRDF_LUT_SIZE !== LTC_MAG_LUT_SIZE) {
4176
4543
  throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).")
@@ -4709,23 +5076,62 @@ export class Engine {
4709
5076
  // occluded behind it. Color targets kept for pass compatibility, writeMask 0.
4710
5077
  const prepassModule = this.device.createShaderModule({
4711
5078
  label: "transparent depth prepass",
4712
- code: TRANSPARENT_DEPTH_PREPASS_WGSL,
5079
+ code: transparentDepthPrepassWgsl(),
4713
5080
  })
4714
- this.transparentDepthPrepassPipeline = this.device.createRenderPipeline({
4715
- label: "transparent depth prepass",
5081
+ const prepassDesc = {
4716
5082
  layout: mainPipelineLayout,
4717
5083
  vertex: { module: prepassModule, entryPoint: "vs", buffers: fullVertexBuffers as GPUVertexBufferLayout[] },
5084
+ primitive: { cullMode: "none" as GPUCullMode },
5085
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
5086
+ depthStencil: {
5087
+ format: this.depthFormat,
5088
+ depthWriteEnabled: true,
5089
+ depthCompare: this.depthAhead,
5090
+ },
5091
+ }
5092
+ this.depthPrepassPipeline = this.device.createRenderPipeline({
5093
+ label: "opaque depth prepass",
5094
+ ...prepassDesc,
4718
5095
  fragment: {
4719
5096
  module: prepassModule,
4720
5097
  entryPoint: "fs",
4721
5098
  targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
4722
5099
  },
4723
- primitive: { cullMode: "none" },
4724
- multisample: { count: Engine.MULTISAMPLE_COUNT },
5100
+ })
5101
+ // The SOLID prime: same module, cutoff forced to exactly 1.0. Only texels
5102
+ // whose blend ignores the destination may pre-claim depth in the
5103
+ // transparent phase — see the override's note in depth-prepass.ts.
5104
+ this.solidPrepassPipeline = this.device.createRenderPipeline({
5105
+ label: "transparent solid prepass",
5106
+ ...prepassDesc,
5107
+ fragment: {
5108
+ module: prepassModule,
5109
+ entryPoint: "fs",
5110
+ constants: { CUTOFF: 1.0 },
5111
+ targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
5112
+ },
5113
+ })
5114
+ // The HAIR prime: solid texels only, and stencil-fenced off the eye
5115
+ // silhouette. It records after the non-hair opaque draws, so the eye has
5116
+ // already written its stencil — not-equal here is what keeps the primed
5117
+ // hair depth from ever claiming the pixels the see-through-hair pass needs
5118
+ // the eye to survive on. (Bundle draws use the PASS's stencil reference;
5119
+ // only pipeline/bind/vertex state resets across executeBundles.)
5120
+ this.hairPrimePipeline = this.device.createRenderPipeline({
5121
+ label: "hair depth prime",
5122
+ ...prepassDesc,
4725
5123
  depthStencil: {
4726
- format: this.depthFormat,
4727
- depthWriteEnabled: true,
4728
- depthCompare: this.depthAhead,
5124
+ ...prepassDesc.depthStencil,
5125
+ stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
5126
+ stencilBack: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
5127
+ stencilReadMask: 0xff,
5128
+ stencilWriteMask: 0,
5129
+ },
5130
+ fragment: {
5131
+ module: prepassModule,
5132
+ entryPoint: "fs",
5133
+ constants: { CUTOFF: 1.0 },
5134
+ targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
4729
5135
  },
4730
5136
  })
4731
5137
 
@@ -4763,7 +5169,7 @@ export class Engine {
4763
5169
  fragment: { module: shadowShader, entryPoint: "fs", targets: [] },
4764
5170
  primitive: { cullMode: "none" },
4765
5171
  depthStencil: {
4766
- format: "depth32float",
5172
+ format: Engine.SHADOW_DEPTH_FORMAT,
4767
5173
  depthWriteEnabled: true,
4768
5174
  depthCompare: "less-equal",
4769
5175
  // The shadow map keeps the NON-reversed convention (orthographicLh maps
@@ -4785,7 +5191,7 @@ export class Engine {
4785
5191
  this.device.createTexture({
4786
5192
  label: `shadow map cascade ${i}`,
4787
5193
  size: [c.mapSize, c.mapSize],
4788
- format: "depth32float",
5194
+ format: Engine.SHADOW_DEPTH_FORMAT,
4789
5195
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
4790
5196
  }),
4791
5197
  )
@@ -4793,6 +5199,7 @@ export class Engine {
4793
5199
 
4794
5200
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
4795
5201
  this.bakeBrdfLut()
5202
+ this.bakeGroundNoise()
4796
5203
  this.agxFallbackTexture = this.device.createTexture({
4797
5204
  label: "AgX LUT fallback",
4798
5205
  size: [1, 1, 1],
@@ -4885,6 +5292,9 @@ export class Engine {
4885
5292
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4886
5293
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
4887
5294
  { binding: 11, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth", multisampled: true } },
5295
+ // The baked frost tile — see bakeGroundNoise. Sampled with binding 10's
5296
+ // repeat sampler, so it brings no sampler of its own.
5297
+ { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4888
5298
  ],
4889
5299
  })
4890
5300
  const groundShadowShader = this.device.createShaderModule({
@@ -4941,7 +5351,7 @@ export class Engine {
4941
5351
 
4942
5352
  const outlineShaderModule = this.device.createShaderModule({
4943
5353
  label: "outline shaders",
4944
- code: OUTLINE_SHADER_WGSL,
5354
+ code: outlineShaderWgsl(),
4945
5355
  })
4946
5356
 
4947
5357
  this.outlinePipeline = this.createRenderPipeline({
@@ -5429,6 +5839,21 @@ export class Engine {
5429
5839
  }
5430
5840
 
5431
5841
  private handleResize() {
5842
+ // No device, nothing to size.
5843
+ //
5844
+ // Three callers reach this, and two of them can arrive before init() has a
5845
+ // device or after teardown has released one: setRenderSize is PUBLIC and
5846
+ // unordered with respect to init, and the ResizeObserver keeps firing across
5847
+ // a hot reload while the replaced engine is still mounted. Both landed on
5848
+ // `this.device.createTexture` and threw — which is why this only shows up
5849
+ // during development, and why 0.43 never saw it: setRenderSize did not exist
5850
+ // to be called early.
5851
+ //
5852
+ // Returning is correct rather than merely quiet. fixedRenderSize has already
5853
+ // been recorded by the time we get here, and init() ends with its own
5854
+ // handleResize — so the size asked for before the device existed is applied
5855
+ // in full the moment there is something to apply it to.
5856
+ if (!this.device) return
5432
5857
  // Fixed override (offline/video rendering) wins; otherwise track CSS size × dpr.
5433
5858
  const dpr = window.devicePixelRatio || 1
5434
5859
  const width = this.fixedRenderSize ? this.fixedRenderSize.width : Math.floor(this.canvas.clientWidth * dpr)
@@ -6137,17 +6562,7 @@ export class Engine {
6137
6562
  }
6138
6563
  // Every consumer holds the buffer by reference in a bind group; all of them
6139
6564
  // re-bind so audio arriving after an effect (or before one) both work.
6140
- this.rebuildCompositeBindGroup()
6141
- this.rebindTrails()
6142
- // EVERY effect: a buffer arriving after install must reach all of them, or
6143
- // the ones installed earlier keep reading the buffer this replaced.
6144
- for (const e of this.effects) {
6145
- if (!e.particles) continue
6146
- const b = e.particles.rebind()
6147
- e.particles.computeBind = b.computeBind
6148
- e.particles.renderBind = b.renderBind
6149
- e.particles.mirrorRenderBind = b.mirrorRenderBind
6150
- }
6565
+ this.rebindSharedBuffers()
6151
6566
  }
6152
6567
 
6153
6568
  /**
@@ -6248,17 +6663,7 @@ export class Engine {
6248
6663
  // Same reason as setAudioData: every consumer holds the buffer by reference,
6249
6664
  // so all of them re-bind and a score arriving before or after an effect both
6250
6665
  // work.
6251
- this.rebuildCompositeBindGroup()
6252
- this.rebindTrails()
6253
- // EVERY effect: a buffer arriving after install must reach all of them, or
6254
- // the ones installed earlier keep reading the buffer this replaced.
6255
- for (const e of this.effects) {
6256
- if (!e.particles) continue
6257
- const b = e.particles.rebind()
6258
- e.particles.computeBind = b.computeBind
6259
- e.particles.renderBind = b.renderBind
6260
- e.particles.mirrorRenderBind = b.mirrorRenderBind
6261
- }
6666
+ this.rebindSharedBuffers()
6262
6667
  }
6263
6668
 
6264
6669
  /**
@@ -6767,13 +7172,25 @@ export class Engine {
6767
7172
  return key
6768
7173
  }
6769
7174
 
7175
+ /** True while a stage is in the scene. Two things turn on it: the built-in
7176
+ * ground plane must not draw, and the far shadow cascade has nothing to
7177
+ * cover without one (see the cascade loop). */
7178
+ hasStage(): boolean {
7179
+ for (const inst of this.modelInstances.values()) if (inst.isStage) return true
7180
+ return false
7181
+ }
7182
+
6770
7183
  /** True while a stage is in the scene, which is when the built-in ground plane
6771
7184
  * must not draw. */
6772
7185
  groundIsSuppressed(): boolean {
6773
- for (const inst of this.modelInstances.values()) if (inst.isStage) return true
6774
- return false
7186
+ return this.hasStage()
6775
7187
  }
6776
7188
 
7189
+ /** Per cascade: does its map currently hold nothing but the cleared far plane?
7190
+ * Set by the cascade loop, which skips a cascade that is unwanted and already
7191
+ * cleared rather than re-clearing it every frame. */
7192
+ private shadowCascadeCleared: boolean[] = []
7193
+
6777
7194
  removeModel(name: string): void {
6778
7195
  const inst = this.modelInstances.get(name)
6779
7196
  if (!inst) return
@@ -7136,7 +7553,7 @@ export class Engine {
7136
7553
  const gm = inst.gpuMorph
7137
7554
  if (!gm || !gm.dispatchNeeded) continue
7138
7555
  if (!pass) {
7139
- pass = encoder.beginComputePass({ label: "morph compute" })
7556
+ pass = encoder.beginComputePass({ label: "morph compute", timestampWrites: this.stamps("morph") })
7140
7557
  pass.setPipeline(this.morphComputePipeline)
7141
7558
  }
7142
7559
  pass.setBindGroup(0, gm.bindGroup)
@@ -7389,6 +7806,111 @@ export class Engine {
7389
7806
  flags[o + 20] = f
7390
7807
  }
7391
7808
  if (this.cullModelBuffer) this.device.queue.writeBuffer(this.cullModelBuffer, 0, data.buffer as ArrayBuffer)
7809
+ this.updateCasterSphere(data)
7810
+ }
7811
+
7812
+ /**
7813
+ * One sphere containing every shadow caster in the scene, for the ground.
7814
+ *
7815
+ * The ground's PCF is the most expensive thing in the frame on a tile-based
7816
+ * GPU — nine hardware-bilinear comparisons per pixel on a full-coverage draw,
7817
+ * which is what 0.33.2 was about and what a second cascade quietly undid. But
7818
+ * the floor is vastly larger than the thing standing on it, and a pixel the
7819
+ * character cannot possibly shadow does not need to ask the shadow map: the
7820
+ * answer is lit, and nine taps is an expensive way to spell it.
7821
+ *
7822
+ * So the ground gets a bound and tests against it in ALU. This reuses the
7823
+ * spheres the cull already builds every frame — an AABB over POSED bone
7824
+ * positions grown by the skin margin, which its own note calls a bound rather
7825
+ * than an estimate, so a jump or a physics-driven skirt is inside it by
7826
+ * construction. Union, not per model: one sphere is one test, and the ground
7827
+ * shader must not loop over the cast.
7828
+ *
7829
+ * A RIGID caster (a stage) leaves its cull sphere zeroed deliberately — the
7830
+ * cull reads its boxes instead — so any rigid model disables this entirely by
7831
+ * setting radius to -1. Wrong here is a missing shadow, and a scene with a
7832
+ * stage keeps the taps rather than risk one.
7833
+ */
7834
+ private updateCasterSphere(data: Float32Array): void {
7835
+ const out = this.casterSphere
7836
+ out[3] = 0
7837
+ let cx = 0
7838
+ let cy = 0
7839
+ let cz = 0
7840
+ let r = 0
7841
+ let any = false
7842
+ for (let i = 0; i < this.cullModels.length; i++) {
7843
+ const inst = this.cullModels[i]
7844
+ if (!inst.model.visible || inst.shadowDrawCalls.length === 0) continue
7845
+ if (inst.rigid) {
7846
+ // No sphere to read. Bail out of the whole optimisation.
7847
+ out[3] = -1
7848
+ return
7849
+ }
7850
+ const o = i * Engine.CULL_MODEL_FLOATS + 16
7851
+ const x = data[o]
7852
+ const y = data[o + 1]
7853
+ const z = data[o + 2]
7854
+ const rad = data[o + 3]
7855
+ if (rad <= 0) continue
7856
+ if (!any) {
7857
+ cx = x
7858
+ cy = y
7859
+ cz = z
7860
+ r = rad
7861
+ any = true
7862
+ continue
7863
+ }
7864
+ // Union of two spheres, the standard construction: if one already contains
7865
+ // the other keep it, else grow along the line between the centres.
7866
+ const dx = x - cx
7867
+ const dy = y - cy
7868
+ const dz = z - cz
7869
+ const d = Math.hypot(dx, dy, dz)
7870
+ if (d + rad <= r) continue
7871
+ if (d + r <= rad) {
7872
+ cx = x
7873
+ cy = y
7874
+ cz = z
7875
+ r = rad
7876
+ continue
7877
+ }
7878
+ const nr = (d + r + rad) * 0.5
7879
+ const t = (nr - r) / d
7880
+ cx += dx * t
7881
+ cy += dy * t
7882
+ cz += dz * t
7883
+ r = nr
7884
+ }
7885
+ out[0] = cx
7886
+ out[1] = cy
7887
+ out[2] = cz
7888
+ out[3] = any ? r : 0
7889
+ }
7890
+
7891
+ /** Every shadow caster in one sphere: (x, y, z, radius). radius 0 = nothing
7892
+ * casts, -1 = do not use (a rigid caster has no sphere). See updateCasterSphere. */
7893
+ private casterSphere = new Float32Array(4)
7894
+
7895
+ /** The ground's uniform block, kept so the caster sphere can be refreshed in
7896
+ * it every frame rather than rebuilding the buffer (addGround allocates). */
7897
+ private groundMaterialData: Float32Array | null = null
7898
+
7899
+ /**
7900
+ * Push this frame's caster sphere into the ground's uniform.
7901
+ *
7902
+ * Four floats, one writeBuffer, and only while a ground exists. Rebuilding the
7903
+ * block the way addGround does would allocate a buffer and a bind group per
7904
+ * frame, which is the cost this is trying to remove rather than a way to pay
7905
+ * it somewhere else.
7906
+ */
7907
+ private writeGroundCasterSphere(): void {
7908
+ const gb = this.groundMaterialData
7909
+ if (!gb || !this.groundShadowMaterialBuffer) return
7910
+ if (gb[20] === this.casterSphere[0] && gb[21] === this.casterSphere[1] &&
7911
+ gb[22] === this.casterSphere[2] && gb[23] === this.casterSphere[3]) return
7912
+ gb.set(this.casterSphere, 20)
7913
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 80, this.casterSphere as Float32Array<ArrayBuffer>)
7392
7914
  }
7393
7915
 
7394
7916
  /**
@@ -7525,7 +8047,6 @@ export class Engine {
7525
8047
  }
7526
8048
  if (this.modelInstances.size === 0) {
7527
8049
  this.opaqueBundle = null
7528
- this.transparentBundle = null
7529
8050
  this.mirrorOpaqueBundle = null
7530
8051
  this.mirrorTransparentBundle = null
7531
8052
  this.shadowBundles = []
@@ -7537,14 +8058,15 @@ export class Engine {
7537
8058
  this.forEachInstance((inst) => this.renderModelOpaquePhase(opaque, inst, camView))
7538
8059
  this.opaqueBundle = opaque.finish({ label: "opaque phase" })
7539
8060
 
7540
- const transparent = this.device.createRenderBundleEncoder({ label: "transparent phase", ...scene })
7541
- this.forEachInstance((inst) => this.renderModelTransparentPhase(transparent, inst, camView))
7542
- this.transparentBundle = transparent.finish({ label: "transparent phase" })
7543
-
7544
- // The mirror pair: the same draws against the same formats, with the
7545
- // mirrored camera baked into bind group 0 and the mirror cull args baked
7546
- // into the indirect draws. Recorded whether or not a mirror is active
7547
- // recording is cheap, and the bundles only execute when the pass runs.
8061
+ // NO camera transparent bundle. The camera pass draws that phase directly
8062
+ // see the note at the executeBundles call for what recording one cost on
8063
+ // WebKit. Recording it anyway "in case" is not free and not harmless: it is
8064
+ // work on every rebuild, and a live bundle beside a direct draw of the same
8065
+ // phase is an invitation to execute it again.
8066
+ //
8067
+ // The MIRROR pair below keeps both bundles, and is allowed to: that pass
8068
+ // hands them to a single executeBundles with nothing direct in between,
8069
+ // which is the pattern that works.
7548
8070
  const mirrorView = this.sceneView("mirror")
7549
8071
  const mo = this.device.createRenderBundleEncoder({ label: "mirror opaque phase", ...scene })
7550
8072
  this.forEachInstance((inst) => this.renderModelOpaquePhase(mo, inst, mirrorView))
@@ -7562,7 +8084,7 @@ export class Engine {
7562
8084
  const shadow = this.device.createRenderBundleEncoder({
7563
8085
  label: `shadow pass, cascade ${ci}`,
7564
8086
  colorFormats: [],
7565
- depthStencilFormat: "depth32float",
8087
+ depthStencilFormat: Engine.SHADOW_DEPTH_FORMAT,
7566
8088
  })
7567
8089
  shadow.setPipeline(this.shadowDepthPipeline)
7568
8090
  this.forEachInstance((inst) => this.drawInstanceShadow(shadow, inst, ci))
@@ -7579,6 +8101,25 @@ export class Engine {
7579
8101
  return { querySet: this.timestampQuerySet, beginningOfPassWriteIndex: i * 2, endOfPassWriteIndex: i * 2 + 1 }
7580
8102
  }
7581
8103
 
8104
+ /**
8105
+ * Half a stamp, for a component that is several passes rather than one.
8106
+ *
8107
+ * Bloom is nine render passes — a prefilter blit, a downsample chain and an
8108
+ * upsample chain — and what anyone wants to know is what the PYRAMID cost, not
8109
+ * what its fourth mip cost. Both fields of GPURenderPassTimestampWrites are
8110
+ * optional, so the opening query goes on the first pass and the closing one on
8111
+ * the last, and the pair reads as one span across everything between.
8112
+ */
8113
+ private stampOpen(pass: (typeof Engine.TIMED_PASSES)[number]): GPURenderPassTimestampWrites | undefined {
8114
+ if (!this.timestampQuerySet) return undefined
8115
+ return { querySet: this.timestampQuerySet, beginningOfPassWriteIndex: Engine.TIMED_PASSES.indexOf(pass) * 2 }
8116
+ }
8117
+
8118
+ private stampClose(pass: (typeof Engine.TIMED_PASSES)[number]): GPURenderPassTimestampWrites | undefined {
8119
+ if (!this.timestampQuerySet) return undefined
8120
+ return { querySet: this.timestampQuerySet, endOfPassWriteIndex: Engine.TIMED_PASSES.indexOf(pass) * 2 + 1 }
8121
+ }
8122
+
7582
8123
  /**
7583
8124
  * Resolve this frame's timings and start a readback, at most one in flight.
7584
8125
  *
@@ -7590,6 +8131,8 @@ export class Engine {
7590
8131
  private resolveTimestamps(encoder: GPUCommandEncoder): void {
7591
8132
  const qs = this.timestampQuerySet
7592
8133
  if (!qs || !this.timestampResolve || !this.timestampRead) return
8134
+ // Nobody has asked. See getGpuTimings — the read is what enrols.
8135
+ if (!this.timestampsWanted) return
7593
8136
  const count = Engine.TIMED_PASSES.length * 2
7594
8137
  encoder.resolveQuerySet(qs, 0, count, this.timestampResolve, 0)
7595
8138
  if (this.timestampBusy) return
@@ -7629,11 +8172,27 @@ export class Engine {
7629
8172
  * The regression guard for the draw-path work: these are the numbers that say
7630
8173
  * whether restructuring cost anything, which is the claim being made — not
7631
8174
  * whether it made the scene faster, which was never the goal.
8175
+ *
8176
+ * ASKING IS WHAT TURNS IT ON. The first call to this enrols the engine in the
8177
+ * per-frame readback; until then resolveTimestamps does nothing. That is why
8178
+ * the first call returns null even on a device that can measure — the answer
8179
+ * arrives a frame or two later, which is already true of these numbers and
8180
+ * documented on resolveTimestamps.
8181
+ *
8182
+ * The alternative was what this used to do: resolve the query set, copy it to
8183
+ * a staging buffer and map that buffer, every frame, on every device, for a
8184
+ * reader that in this codebase did not exist. A map is a synchronisation point
8185
+ * and the whole path is instrumentation — paying for it unasked is the same
8186
+ * mistake as shipping a debug flag, only invisible.
7632
8187
  */
7633
8188
  getGpuTimings(): Record<string, number> | null {
8189
+ this.timestampsWanted = true
7634
8190
  return this.gpuPassMs
7635
8191
  }
7636
8192
 
8193
+ /** Set by the first getGpuTimings() call. See it for why asking is the switch. */
8194
+ private timestampsWanted = false
8195
+
7637
8196
  private dispatchCull(encoder: GPUCommandEncoder): void {
7638
8197
  if (this.cullListDirty) this.rebuildCullList()
7639
8198
  if (!this.cullBindGroup || this.cullDraws.length === 0) return
@@ -8228,7 +8787,8 @@ export class Engine {
8228
8787
  // Shadow map is already created in setupPipelines()
8229
8788
  // 20 floats: 16 for the original block, then (mirrorBlur, pad, pad, pad)
8230
8789
  // keeping the uniform vec4-aligned.
8231
- const gb = new Float32Array(20)
8790
+ const gb = new Float32Array(24)
8791
+ this.groundMaterialData = gb
8232
8792
  gb[0] = diffuseColor.x
8233
8793
  gb[1] = diffuseColor.y
8234
8794
  gb[2] = diffuseColor.z
@@ -8248,6 +8808,23 @@ export class Engine {
8248
8808
  this.groundMirror = gb[15]
8249
8809
  gb[16] = Math.min(Math.max(mirrorBlur, 0), 1)
8250
8810
  this.groundMirrorBlur = gb[16]
8811
+ // gb[17] — does the FAR cascade hold anything?
8812
+ //
8813
+ // It holds something only when a stage is loaded; that is what it exists for
8814
+ // and the cascade loop already skips drawing into it otherwise, leaving it
8815
+ // cleared. A cleared depth map compares as "no occluder", so the ground's far
8816
+ // branch is nine comparison taps whose answer is known in advance.
8817
+ //
8818
+ // That branch runs wherever the NEAR cascade does not reach, and the near one
8819
+ // is a 64-unit box around the camera target — so on a floor receding to the
8820
+ // horizon it is most of the visible pixels, on the most expensive
8821
+ // full-coverage draw in the frame. Skipping it is free in the exact sense:
8822
+ // the shader takes vis = 1.0, which is what the taps would have returned.
8823
+ gb[17] = this.hasStage() ? 1 : 0
8824
+ // gb[20..23] — the caster sphere, refreshed every frame by
8825
+ // writeGroundCasterSphere. Zero here so a frame that renders before the
8826
+ // first cull (there is one) reads "nothing casts" and skips the taps, which
8827
+ // is true: no model has been posed yet.
8251
8828
  this.groundShadowMaterialBuffer = this.device.createBuffer({
8252
8829
  size: gb.byteLength,
8253
8830
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -8282,6 +8859,7 @@ export class Engine {
8282
8859
  { binding: 9, resource: this.mirrorColorView! },
8283
8860
  { binding: 10, resource: this.materialSampler },
8284
8861
  { binding: 11, resource: this.mirrorDepthReadView! },
8862
+ { binding: 12, resource: this.groundNoiseView },
8285
8863
  ],
8286
8864
  })
8287
8865
  if (this.groundDrawCall) this.groundDrawCall.bindGroup = this.groundShadowBindGroup
@@ -8804,7 +9382,19 @@ export class Engine {
8804
9382
 
8805
9383
  // CPU alpha sampler for sheerness classification (see textureAlphaCache).
8806
9384
  // Canvas 2D premultiplies RGB on readback, but the ALPHA channel is exact.
8807
- this.textureAlphaCache.set(cacheKey, buildAlphaSampler(source, rgba, width, height))
9385
+ const alphaPlane = buildAlphaSampler(source, rgba, width, height)
9386
+ // Loud, because the fallback is WRONG rather than merely absent: a material
9387
+ // with no alpha plane scores avg 1 / translucentFrac 0, which routes sheer
9388
+ // fabric into the OPAQUE bucket and changes what the frame looks like. A
9389
+ // readback that fails is therefore a rendering bug, not a missing nicety,
9390
+ // and it must not reach the user as "the dress looks different on my phone".
9391
+ if (!alphaPlane) {
9392
+ console.warn(
9393
+ `[reze] alpha readback failed for ${cacheKey} — this material will be classified OPAQUE, ` +
9394
+ `so sheer fabric will not blend. The canvas 2D readback is what failed.`,
9395
+ )
9396
+ }
9397
+ this.textureAlphaCache.set(cacheKey, alphaPlane)
8808
9398
 
8809
9399
  const mipLevelCount = Math.floor(Math.log2(Math.max(width, height))) + 1
8810
9400
  const texture = this.device.createTexture({
@@ -9565,11 +10155,50 @@ export class Engine {
9565
10155
  const dofOn = this.depthOfField.enabled
9566
10156
  // ANY effect: one foreground mount anywhere in the scene, or one ribbon,
9567
10157
  // is enough to make the pass store its depth instead of discarding it.
9568
- const depthRead =
9569
- dofOn || this.effects.some((e) => e.hasForeground) || this.effects.some((e) => e.trails !== null)
10158
+ // Ribbons are NOT in this list, and removing them is the single largest
10159
+ // bandwidth saving in the frame on a tile-based GPU.
10160
+ //
10161
+ // They were, from when a ribbon was its own layer drawn after the scene and
10162
+ // depth-tested BY HAND against the stored buffer. That layer is gone —
10163
+ // ribbons draw inside this pass and the hardware depth test replaced what
10164
+ // they read it for (see trails.ts, "Binding 3 is GONE"). The clause outlived
10165
+ // the change by about twelve hours and then sat here.
10166
+ //
10167
+ // What it cost: this flag decides whether the pass STORES its depth or
10168
+ // discards it into tile memory, and the buffer is depth32float-stencil8 at
10169
+ // the pass's sample count — on a retina canvas that is a nine-figure number
10170
+ // of bytes written to RAM every frame, for a texture nothing then sampled.
10171
+ // Chrome hides it (an immediate-mode GPU has depth in memory regardless);
10172
+ // Apple's TBDR does not, which is exactly the reported shape: adding a hand
10173
+ // ribbon costs a lot of fps on Safari and almost nothing on Chrome.
10174
+ //
10175
+ // The two real readers are both in the composite and both have their own
10176
+ // flag above: linearDepth() feeds the DoF gather and the depth handed to a
10177
+ // foreground mount. Nothing else binds depthTex at all.
10178
+ const depthRead = dofOn || this.effects.some((e) => e.hasForeground)
9570
10179
  this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = depthRead ? "store" : "discard"
9571
10180
  if (depthRead) this.writeDepthOfFieldUniforms()
9572
10181
 
10182
+ // The id attachment, on exactly the same terms as the depth above it.
10183
+ //
10184
+ // It is the most expensive STORE in the pass — rg16uint at the pass's sample
10185
+ // count, ~33MB a frame at 1080p — and a uint target cannot be resolved, so
10186
+ // storing is the only way to get it out. It was stored unconditionally, for
10187
+ // every scene, whether or not anything read it. Nothing usually does: the
10188
+ // readers are rzObjectAt / rzMaterialAt in an effect that masks itself to one
10189
+ // character, and the id-buffer debug view.
10190
+ //
10191
+ // Discarding is not the same as removing. Every pipeline still declares the
10192
+ // attachment and the pass still carries it, so nothing is rebuilt and no
10193
+ // shader changes — the frame is bit-identical either way, because the only
10194
+ // difference is whether tile memory is written back to RAM after a pass
10195
+ // whose result no one is going to read.
10196
+ const idAtt = (this.renderPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[2]
10197
+ if (idAtt) {
10198
+ const idsRead = this.idDebug || this.effects.some((e) => e.readsIds)
10199
+ idAtt.storeOp = idsRead ? "store" : "discard"
10200
+ }
10201
+
9573
10202
  const encoder = this.device.createCommandEncoder()
9574
10203
 
9575
10204
  // GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
@@ -9580,6 +10209,8 @@ export class Engine {
9580
10209
  // are settled, before the passes that draw from them.
9581
10210
  if (this.reflectionActive) this.updateMirrorCamera()
9582
10211
  if (hasModels) this.dispatchCull(encoder)
10212
+ // After the cull, which is what recomputes the spheres it unions.
10213
+ this.writeGroundCasterSphere()
9583
10214
 
9584
10215
  // After the cull, because a rebuild there can reallocate the argument
9585
10216
  // buffers and a bundle captures the buffer it recorded against.
@@ -9591,7 +10222,25 @@ export class Engine {
9591
10222
  // keeps PCF-sampling a character that is no longer in the scene. One clearing
9592
10223
  // pass on the transition to empty, then it stops.
9593
10224
  if (hasModels || this.shadowMapPopulated) {
10225
+ // The far cascade is the STAGE cascade, and it costs a full pass over the
10226
+ // whole cast every frame to say so. Its own spec explains what it is for —
10227
+ // "a set piece 100 units out still throws" — and a scene with no stage has
10228
+ // no set piece: every caster sits inside the near cascade's 64-unit box,
10229
+ // which follows the camera target, and the far map's only readers are
10230
+ // ground pixels beyond that box, where nothing is casting.
10231
+ //
10232
+ // So when no stage is loaded it is drawn ONCE, cleared, and then skipped —
10233
+ // the same shape as shadowMapPopulated above, and for the same reason. A
10234
+ // cleared depth map reads as "no occluder", which is the correct answer
10235
+ // here rather than a missing one. Load a stage and it comes straight back.
10236
+ //
10237
+ // 0.43 had ONE shadow map. This is half of what the second one costs.
10238
+ const stage = this.hasStage()
9594
10239
  for (let ci = 0; ci < SHADOW_CASCADES.length; ci++) {
10240
+ const wanted = ci === 0 || stage
10241
+ // Already cleared and still unwanted — nothing to do, and the map still
10242
+ // holds the far plane from the pass that cleared it.
10243
+ if (!wanted && this.shadowCascadeCleared[ci]) continue
9595
10244
  const sp = encoder.beginRenderPass({
9596
10245
  // One timestamp pair exists for "shadow"; the near cascade wears it.
9597
10246
  timestampWrites: ci === 0 ? this.stamps("shadow") : undefined,
@@ -9607,8 +10256,9 @@ export class Engine {
9607
10256
  // per-frame boolean, and baking it into a bundle would make toggling a
9608
10257
  // model re-record. It lives in the cull compute now, which zeroes the
9609
10258
  // instance count of an invisible model's draws.
9610
- if (this.shadowBundles[ci]) sp.executeBundles([this.shadowBundles[ci]])
10259
+ if (wanted && this.shadowBundles[ci]) sp.executeBundles([this.shadowBundles[ci]])
9611
10260
  sp.end()
10261
+ this.shadowCascadeCleared[ci] = !wanted
9612
10262
  }
9613
10263
  this.shadowMapPopulated = hasModels
9614
10264
  }
@@ -9639,8 +10289,44 @@ export class Engine {
9639
10289
  // anyway — eye writes it, hair tests not-equal, hairOverEyes tests equal.
9640
10290
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
9641
10291
  if (this.opaqueBundle) pass.executeBundles([this.opaqueBundle])
10292
+ // Re-asserted after the bundle, not merely set once before it.
10293
+ //
10294
+ // Stencil reference is pass state a bundle cannot carry — GPURenderBundleEncoder
10295
+ // has no setStencilReference — which is why it was hoisted above the bundle in
10296
+ // the first place. But "cannot carry" and "cannot disturb" are different
10297
+ // claims, and only the first is specified. Everything below this line that
10298
+ // stencil-tests (hair at not-equal, outline hulls at not-equal) reads a
10299
+ // reference of 0 instead of 1 if a replay resets it, and not-equal against 0
10300
+ // is FALSE for the cleared buffer — every such fragment silently rejected.
10301
+ // One redundant word against a whole class of invisible failure.
10302
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
9642
10303
  if (this.hasGround) this.renderGround(pass)
9643
- if (this.transparentBundle) pass.executeBundles([this.transparentBundle])
10304
+ // The transparent phase is drawn DIRECTLY, and must stay that way. It is the
10305
+ // one part of this pass that is not bundled, so the reason is worth keeping.
10306
+ //
10307
+ // It WAS a bundle, and on WebKit the entire transparent bucket vanished while
10308
+ // the opaque bucket and the ground rendered perfectly — sheer fabric simply
10309
+ // absent, with no validation error anywhere. It was not the fragments: with
10310
+ // alpha forced to 1 they still never appeared, the cull reported every draw
10311
+ // visible with its GPU and CPU halves agreeing, and a cast model's
10312
+ // transparent draws use the SAME pipeline, bind groups and depth state as its
10313
+ // opaque ones (pipelineForDrawCall, forceDepthWrite). Identical draws,
10314
+ // identical state, one bucket rendering.
10315
+ //
10316
+ // What differed was only how they reached the pass: the opaque bundle is the
10317
+ // FIRST executeBundles here, and the transparent one was the SECOND, issued
10318
+ // after direct commands (the ground). Legal, and correct on Dawn. Not
10319
+ // replayed on WebKit. The mirror pass is the counter-example that pins the
10320
+ // shape of it — it passes BOTH bundles to a single executeBundles with
10321
+ // nothing direct in between, and has never lost a draw.
10322
+ //
10323
+ // So the rule this pass now keeps: at most one executeBundles, and nothing
10324
+ // direct before it. Bundling this phase again means first moving the ground
10325
+ // into the opaque bundle so the two can go in one call, the way the mirror
10326
+ // does it. The saving that buys is CPU encode time over a handful of draws,
10327
+ // which was never this renderer's bottleneck.
10328
+ const camView = this.sceneView("camera")
10329
+ this.forEachInstance((inst) => this.renderModelTransparentPhase(pass, inst, camView))
9644
10330
  // Last in the pass: depth-tested against everything drawn above, so a
9645
10331
  // particle behind the character is simply hidden, and still inside the HDR
9646
10332
  // target so an `@bloom` effect reaches the pyramid below.
@@ -9661,11 +10347,18 @@ export class Engine {
9661
10347
  // 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
9662
10348
  // then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
9663
10349
  // Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
9664
- if (this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
10350
+ // bloomContributes() gates the whole pyramid, not just its intensity. The
10351
+ // composite still SAMPLES bloomUp[0] unconditionally, which is safe and
10352
+ // deliberate: it scales what it reads by the same effective intensity, so a
10353
+ // stale or never-written pyramid is multiplied by zero. Skipping the build
10354
+ // is therefore invisible in the frame and nine render passes cheaper.
10355
+ if (this.bloomContributes() && this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
9665
10356
  const bloomAtt = this.bloomPassDescriptor.colorAttachments as GPURenderPassColorAttachment[]
9666
10357
 
9667
- // 1. Blit
10358
+ // 1. Blit — opens the pyramid's timing span. See stampOpen: the nine
10359
+ // passes below read as ONE component, which is the only useful grain.
9668
10360
  bloomAtt[0].view = this.bloomDownMipViews[0]
10361
+ this.bloomPassDescriptor.timestampWrites = this.stampOpen("bloom")
9669
10362
  const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor)
9670
10363
  pBlit.setPipeline(this.bloomBlitPipeline)
9671
10364
  pBlit.setBindGroup(0, this.bloomBlitBindGroup)
@@ -9673,6 +10366,7 @@ export class Engine {
9673
10366
  pBlit.end()
9674
10367
 
9675
10368
  // 2. Downsample chain
10369
+ this.bloomPassDescriptor.timestampWrites = undefined
9676
10370
  for (let i = 1; i < this.bloomMipCount; i++) {
9677
10371
  bloomAtt[0].view = this.bloomDownMipViews[i]
9678
10372
  const p = encoder.beginRenderPass(this.bloomPassDescriptor)
@@ -9688,6 +10382,8 @@ export class Engine {
9688
10382
  for (let k = 0; k < upSteps; k++) {
9689
10383
  const levelIdx = topIdx - k // writes bloomUp[levelIdx]
9690
10384
  bloomAtt[0].view = this.bloomUpMipViews[levelIdx]
10385
+ // The LAST upsample closes the span opened on the blit.
10386
+ this.bloomPassDescriptor.timestampWrites = k === upSteps - 1 ? this.stampClose("bloom") : undefined
9691
10387
  const p = encoder.beginRenderPass(this.bloomPassDescriptor)
9692
10388
  p.setPipeline(this.bloomUpsamplePipeline)
9693
10389
  p.setBindGroup(0, this.bloomUpsampleBindGroups[k])
@@ -10256,16 +10952,29 @@ export class Engine {
10256
10952
  * makes outlines compose like MMD: every material drawn later in the author's
10257
10953
  * order covers earlier hulls, and each hull sits over everything drawn before it.
10258
10954
  */
10955
+ /** Is this draw's compiled class "hair"? Ungrouped draws never are — the
10956
+ * neutral pipeline is the auto class. */
10957
+ private isHairDraw(inst: ModelInstance, dc: DrawCall): boolean {
10958
+ if (!dc.groupId) return false
10959
+ const install = inst.styleGroups.get(dc.groupId)
10960
+ return install?.renderClass === "hair"
10961
+ }
10962
+
10259
10963
  private drawMaterials(
10260
10964
  pass: GPURenderPassEncoder | GPURenderBundleEncoder,
10261
10965
  inst: ModelInstance,
10262
10966
  type: "opaque" | "transparent",
10263
10967
  view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
10968
+ // The opaque phase walks its author order twice — non-hair, then hair — so
10969
+ // the hair depth prime can sit between the eye's stencil write and the hair
10970
+ // colour that must respect it. See renderModelOpaquePhase.
10971
+ only?: "hair" | "non-hair",
10264
10972
  ): void {
10265
10973
  let currentPipeline: GPURenderPipeline | null = null
10266
10974
  let bound = false
10267
10975
  for (const draw of inst.drawCalls) {
10268
10976
  if (draw.type !== type) continue
10977
+ if (only && (only === "hair") !== this.isHairDraw(inst, draw)) continue
10269
10978
  if (!bound) {
10270
10979
  pass.setBindGroup(0, view.perFrame)
10271
10980
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
@@ -10336,16 +11045,155 @@ export class Engine {
10336
11045
  view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
10337
11046
  ): void {
10338
11047
  this.setModelDrawState(pass, inst)
10339
- this.drawMaterials(pass, inst, "opaque", view)
11048
+ // Depth first, colour second — the close-up fix, and the oldest one there
11049
+ // is. See drawOpaqueDepthPrepass.
11050
+ this.drawOpaqueDepthPrepass(pass, inst, view)
11051
+ // The opaque author order, in two walks with the hair prime between them.
11052
+ //
11053
+ // Hair could not join the plain prepass: primed hair depth would depth-
11054
+ // reject the eye before it writes the stencil the see-through-hair pass
11055
+ // needs. But the trick only needs the eye BEFORE hair, not before
11056
+ // everything — so the non-hair walk runs first (the eye writes stencil
11057
+ // against real face depth, exactly as it always did), the prime then lays
11058
+ // hair depth down stencil-fenced off the eye silhouette, and the hair walk
11059
+ // shades once per pixel instead of once per card.
11060
+ //
11061
+ // The one thing this reorders: hair now draws after any opaque material
11062
+ // authored later than it. A soft hair edge over such a material blends
11063
+ // over the material instead of over whatever the framebuffer held mid-
11064
+ // order — deterministic where it used to be accidental, and only at
11065
+ // sub-alpha edge texels over late-authored geometry.
11066
+ this.drawMaterials(pass, inst, "opaque", view, "non-hair")
11067
+ this.drawHairDepthPrime(pass, inst, view)
11068
+ this.drawMaterials(pass, inst, "opaque", view, "hair")
10340
11069
  this.drawHairOverEyes(pass, inst, view)
10341
11070
  }
10342
11071
 
11072
+ /** Depth-only prime of the hair's alpha-1 texels, stencil-fenced off the eye
11073
+ * silhouette. See the note at its call site and hairPrimePipeline. */
11074
+ private drawHairDepthPrime(
11075
+ pass: GPURenderPassEncoder | GPURenderBundleEncoder,
11076
+ inst: ModelInstance,
11077
+ view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
11078
+ ): void {
11079
+ let bound = false
11080
+ for (const draw of inst.drawCalls) {
11081
+ if (draw.type !== "opaque" || !this.isHairDraw(inst, draw)) continue
11082
+ if (!bound) {
11083
+ pass.setPipeline(this.hairPrimePipeline)
11084
+ pass.setBindGroup(0, view.perFrame)
11085
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
11086
+ bound = true
11087
+ }
11088
+ pass.setBindGroup(2, draw.bindGroup)
11089
+ this.issueDraw(pass, draw, view.args)
11090
+ }
11091
+ }
11092
+
11093
+ /**
11094
+ * Depth-only prime of the plain opaque draws, so each covered pixel SHADES
11095
+ * once instead of once per layer.
11096
+ *
11097
+ * The oldest fps complaint this engine has — zoom close and the frame drops,
11098
+ * in every material generation back to the earliest — was never the vertices
11099
+ * and never one shader's fault: with the fragment shaders flattened to a
11100
+ * constant the close-up ran smooth with identical geometry, overdraw and
11101
+ * MSAA. The cost is per-fragment shading TIMES how many times a pixel runs
11102
+ * it, and an MMD model at close-up is layers all the way down: cloth over
11103
+ * body, sleeves over cloth, hair over everything. Author-order drawing
11104
+ * shades every layer and then buries all but one.
11105
+ *
11106
+ * So the plain opaque draws lay their depth down first, through the same
11107
+ * depth-only pipeline the transparent bucket keeps for its dormant prepass —
11108
+ * same skinned vertex path (position marked @invariant in both modules, so
11109
+ * the colour pass lands on exactly these depths and its less-equal test
11110
+ * keeps the visible surface and rejects the buried ones), same alpha-0.5
11111
+ * cutout, writeMask 0 on every colour target. The pixels are identical by
11112
+ * construction: this pass writes no colour, and the colour pass draws
11113
+ * exactly what it always drew minus the fragments something opaque provably
11114
+ * covers.
11115
+ *
11116
+ * WHO IS IN. Only render-class "auto" with alpha-mode "opaque" — the body,
11117
+ * face and cloth materials that are the bulk of every model — plus every
11118
+ * ungrouped material (the neutral pipeline is that same class). WHO IS OUT,
11119
+ * each for a reason that would change pixels: EYE front-culls and gates on a
11120
+ * bone read, and pre-filled hair depth over the socket would depth-reject
11121
+ * the eye before it could write the stencil the see-through-hair pass needs
11122
+ * — which is also why HAIR stays out entirely. HASHED alpha (stockings)
11123
+ * discards by a position hash this pass does not run, so priming it would
11124
+ * punch its cutout into the depth buffer at the wrong texels. They all still
11125
+ * BENEFIT: their fragments early-z against the primed depth of whatever
11126
+ * plain opaque surface sits in front of them.
11127
+ */
11128
+ private drawOpaqueDepthPrepass(
11129
+ pass: GPURenderPassEncoder | GPURenderBundleEncoder,
11130
+ inst: ModelInstance,
11131
+ view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
11132
+ ): void {
11133
+ let bound = false
11134
+ for (const draw of inst.drawCalls) {
11135
+ if (draw.type !== "opaque") continue
11136
+ if (draw.groupId) {
11137
+ const install = inst.styleGroups.get(draw.groupId)
11138
+ if (install && !(install.renderClass === "auto" && install.alphaMode === "opaque")) continue
11139
+ }
11140
+ if (!bound) {
11141
+ pass.setPipeline(this.depthPrepassPipeline)
11142
+ pass.setBindGroup(0, view.perFrame)
11143
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
11144
+ bound = true
11145
+ }
11146
+ pass.setBindGroup(2, draw.bindGroup)
11147
+ this.issueDraw(pass, draw, view.args)
11148
+ }
11149
+ }
11150
+
11151
+ /**
11152
+ * Depth-only prime of the transparent bucket's FULLY SOLID texels.
11153
+ *
11154
+ * The dress problem. A "transparent" MMD material is mostly weave at alpha
11155
+ * exactly 1 with sheer margins, and its layers draw in author order — so a
11156
+ * close-up skirt shades every buried panel and then covers the work. The
11157
+ * buried SHEER fragments must shade (their blend reads what is behind), but
11158
+ * at alpha 1 over-blending is plain replacement: the destination cannot
11159
+ * matter, so a fragment buried behind an alpha-1 texel contributes nothing.
11160
+ * Priming depth for exactly those texels (CUTOFF 1.0) rejects the buried
11161
+ * work and cannot move a pixel.
11162
+ *
11163
+ * A STAGE's transparent draws are excluded the way their colour path already
11164
+ * is: stage glass deliberately leaves depth alone so rain and particles
11165
+ * survive behind a dome (see pipelineForDrawCall), and a prime would put the
11166
+ * occlusion right back.
11167
+ */
11168
+ private drawTransparentSolidPrepass(
11169
+ pass: GPURenderPassEncoder | GPURenderBundleEncoder,
11170
+ inst: ModelInstance,
11171
+ view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
11172
+ ): void {
11173
+ if (inst.isStage) return
11174
+ let bound = false
11175
+ for (const draw of inst.drawCalls) {
11176
+ if (draw.type !== "transparent") continue
11177
+ if (!bound) {
11178
+ pass.setPipeline(this.solidPrepassPipeline)
11179
+ pass.setBindGroup(0, view.perFrame)
11180
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
11181
+ bound = true
11182
+ }
11183
+ pass.setBindGroup(2, draw.bindGroup)
11184
+ this.issueDraw(pass, draw, view.args)
11185
+ }
11186
+ }
11187
+
10343
11188
  private renderModelTransparentPhase(
10344
11189
  pass: GPURenderPassEncoder | GPURenderBundleEncoder,
10345
11190
  inst: ModelInstance,
10346
11191
  view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
10347
11192
  ): void {
11193
+ // Draw state FIRST — each phase records into its own bundle encoder, and a
11194
+ // bundle starts with nothing bound.
10348
11195
  this.setModelDrawState(pass, inst)
11196
+ this.drawTransparentSolidPrepass(pass, inst, view)
10349
11197
  // Transparent: babylon-mmd's forceDepthWrite blending — PMX author order
10350
11198
  // with depth write ON. The accepted trade-off after trying every variant:
10351
11199
  // · depth-write ON (this): a fold hides its far side; rare view-dependent
@@ -10365,7 +11213,7 @@ export class Engine {
10365
11213
  for (const draw of inst.drawCalls) {
10366
11214
  if (draw.type !== "transparent") continue
10367
11215
  if (!bound) {
10368
- pass.setPipeline(this.transparentDepthPrepassPipeline)
11216
+ pass.setPipeline(this.depthPrepassPipeline)
10369
11217
  pass.setBindGroup(0, this.perFrameBindGroup)
10370
11218
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
10371
11219
  bound = true
@@ -10515,6 +11363,10 @@ export class Engine {
10515
11363
  n++
10516
11364
  })
10517
11365
  u[43] = n
11366
+ // The same number the ribbons size their instance count by — see
11367
+ // drawTrails. Recorded rather than recomputed: this loop is the one place
11368
+ // that knows how many subjects the cast actually ended up holding.
11369
+ this.castSubjectCount = n
10518
11370
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
10519
11371
  // Only what an effect declared, and only while one is installed. A scene
10520
11372
  // with no effect writes nothing here at all.