reze-engine 0.50.3 → 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 (93) hide show
  1. package/dist/engine.d.ts +237 -2
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +793 -64
  4. package/dist/physics/autofit.d.ts +147 -0
  5. package/dist/physics/autofit.d.ts.map +1 -0
  6. package/dist/physics/autofit.js +501 -0
  7. package/dist/shaders/cast-api.d.ts +1 -1
  8. package/dist/shaders/cast-api.d.ts.map +1 -1
  9. package/dist/shaders/cast-layout.d.ts +44 -1
  10. package/dist/shaders/cast-layout.d.ts.map +1 -1
  11. package/dist/shaders/cast-layout.js +44 -1
  12. package/dist/shaders/materials/common.d.ts.map +1 -1
  13. package/dist/shaders/materials/common.js +7 -1
  14. package/dist/shaders/materials/nodes.d.ts +1 -1
  15. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  16. package/dist/shaders/materials/nodes.js +17 -9
  17. package/dist/shaders/passes/composite.d.ts +1 -1
  18. package/dist/shaders/passes/composite.d.ts.map +1 -1
  19. package/dist/shaders/passes/depth-prepass.d.ts +1 -1
  20. package/dist/shaders/passes/depth-prepass.d.ts.map +1 -1
  21. package/dist/shaders/passes/depth-prepass.js +52 -12
  22. package/dist/shaders/passes/field-blit.d.ts +26 -0
  23. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  24. package/dist/shaders/passes/field-blit.js +65 -0
  25. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  26. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  27. package/dist/shaders/passes/ground-noise.js +88 -0
  28. package/dist/shaders/passes/ground.d.ts +16 -0
  29. package/dist/shaders/passes/ground.d.ts.map +1 -1
  30. package/dist/shaders/passes/ground.js +131 -27
  31. package/dist/shaders/passes/outline.d.ts +1 -1
  32. package/dist/shaders/passes/outline.d.ts.map +1 -1
  33. package/dist/shaders/passes/outline.js +12 -3
  34. package/dist/shaders/passes/particles.d.ts.map +1 -1
  35. package/dist/shaders/passes/particles.js +6 -2
  36. package/dist/shaders/passes/scene-contract.d.ts +38 -6
  37. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  38. package/dist/shaders/passes/scene-contract.js +53 -16
  39. package/dist/shaders/passes/sim.d.ts +34 -0
  40. package/dist/shaders/passes/sim.d.ts.map +1 -0
  41. package/dist/shaders/passes/sim.js +169 -0
  42. package/dist/shaders/passes/trails.d.ts.map +1 -1
  43. package/dist/shaders/passes/trails.js +36 -8
  44. package/dist/shaders/score-api.d.ts +10 -0
  45. package/dist/shaders/score-api.d.ts.map +1 -0
  46. package/dist/shaders/score-api.js +114 -0
  47. package/package.json +1 -1
  48. package/src/engine.ts +839 -56
  49. package/src/shaders/cast-layout.ts +44 -1
  50. package/src/shaders/materials/common.ts +7 -1
  51. package/src/shaders/materials/nodes.ts +17 -9
  52. package/src/shaders/passes/depth-prepass.ts +53 -12
  53. package/src/shaders/passes/ground.ts +133 -27
  54. package/src/shaders/passes/outline.ts +14 -3
  55. package/src/shaders/passes/particles.ts +6 -2
  56. package/src/shaders/passes/scene-contract.ts +55 -16
  57. package/src/shaders/passes/trails.ts +36 -8
  58. package/dist/physics-debug.d.ts +0 -30
  59. package/dist/physics-debug.d.ts.map +0 -1
  60. package/dist/physics-debug.js +0 -526
  61. package/dist/shaders/materials/body.d.ts +0 -2
  62. package/dist/shaders/materials/body.d.ts.map +0 -1
  63. package/dist/shaders/materials/body.js +0 -95
  64. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  65. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  66. package/dist/shaders/materials/cloth_rough.js +0 -69
  67. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  68. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  69. package/dist/shaders/materials/cloth_smooth.js +0 -61
  70. package/dist/shaders/materials/default.d.ts +0 -2
  71. package/dist/shaders/materials/default.d.ts.map +0 -1
  72. package/dist/shaders/materials/default.js +0 -43
  73. package/dist/shaders/materials/eye.d.ts +0 -2
  74. package/dist/shaders/materials/eye.d.ts.map +0 -1
  75. package/dist/shaders/materials/eye.js +0 -60
  76. package/dist/shaders/materials/face.d.ts +0 -2
  77. package/dist/shaders/materials/face.d.ts.map +0 -1
  78. package/dist/shaders/materials/face.js +0 -95
  79. package/dist/shaders/materials/hair.d.ts +0 -2
  80. package/dist/shaders/materials/hair.d.ts.map +0 -1
  81. package/dist/shaders/materials/hair.js +0 -90
  82. package/dist/shaders/materials/metal.d.ts +0 -2
  83. package/dist/shaders/materials/metal.d.ts.map +0 -1
  84. package/dist/shaders/materials/metal.js +0 -77
  85. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  86. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  87. package/dist/shaders/materials/mmd_classic.js +0 -66
  88. package/dist/shaders/materials/stockings.d.ts +0 -2
  89. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  90. package/dist/shaders/materials/stockings.js +0 -122
  91. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  92. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  93. package/dist/shaders/passes/physics-debug.js +0 -69
package/dist/engine.js CHANGED
@@ -25,9 +25,9 @@ import { evalIrradianceSH, projectIrradianceSH } from "./ibl";
25
25
  import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, packLyrics } from "./shaders/lyrics-api";
26
26
  import { sceneTargets as sceneTargetsFor, sceneColorFormats, setMrtIds, mrtIdsEnabled, SCENE_ID_FORMAT, } from "./shaders/passes/scene-contract";
27
27
  import { LIGHT_HEADER, LIGHT_STRIDE, LIGHTS_FLOATS, MAX_LIGHTS, buildLightEmitShader, hasLightEmit, parseLightCount, } from "./shaders/lights";
28
- import { groundShaderWgsl } from "./shaders/passes/ground";
29
- import { OUTLINE_SHADER_WGSL } from "./shaders/passes/outline";
30
- import { TRANSPARENT_DEPTH_PREPASS_WGSL } from "./shaders/passes/depth-prepass";
28
+ import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground";
29
+ import { outlineShaderWgsl } from "./shaders/passes/outline";
30
+ import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass";
31
31
  import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shaders/passes/selection";
32
32
  import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
33
33
  import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
@@ -249,29 +249,67 @@ const DEFAULT_ENGINE_OPTIONS = {
249
249
  // vertices, because hair-card corners sit in transparent texture margins and
250
250
  // vertex sampling would misclassify hair (which must stay opaque-bucket for
251
251
  // stencil interplay and shadows).
252
+ /**
253
+ * A 2D context for the alpha readback, from whichever canvas this browser has.
254
+ *
255
+ * OffscreenCanvas's 2D context is not universal — Safari only gained it in
256
+ * 16.4, and a worker-less fallback has to be a DOM canvas. This used to be an
257
+ * unguarded `new OffscreenCanvas`, so a browser without it took the catch below
258
+ * and every material on the model was classified opaque. That is a rendering
259
+ * difference produced by a feature probe failing, which is the kind of thing
260
+ * that must never be silent.
261
+ */
262
+ function alphaReadbackContext(w, h) {
263
+ if (typeof OffscreenCanvas !== "undefined") {
264
+ const cx = new OffscreenCanvas(w, h).getContext("2d", { willReadFrequently: true });
265
+ if (cx)
266
+ return cx;
267
+ }
268
+ if (typeof document === "undefined")
269
+ return null;
270
+ const el = document.createElement("canvas");
271
+ el.width = w;
272
+ el.height = h;
273
+ return el.getContext("2d", { willReadFrequently: true });
274
+ }
252
275
  /** Downsampled alpha plane of a decoded texture (≤128², nearest-sampled). */
253
276
  function buildAlphaSampler(source, rgba, width, height) {
254
277
  try {
255
278
  const w = Math.max(1, Math.min(128, width));
256
279
  const h = Math.max(1, Math.min(128, height));
257
- const canvas = new OffscreenCanvas(w, h);
258
- const cx = canvas.getContext("2d", { willReadFrequently: true });
259
- if (!cx)
260
- return null;
261
- if (source) {
262
- cx.drawImage(source, 0, 0, w, h);
263
- }
264
- else if (rgba) {
265
- const tmp = new OffscreenCanvas(width, height);
266
- const tcx = tmp.getContext("2d");
267
- if (!tcx)
268
- return null;
269
- tcx.putImageData(new ImageData(new Uint8ClampedArray(rgba), width, height), 0, 0);
270
- cx.drawImage(tmp, 0, 0, w, h);
280
+ // Raw RGBA needs no canvas at all, and must not use one. It arrives from the
281
+ // TGA/DDS/PSD decoders as exact, straight-alpha bytes; the old path pushed it
282
+ // through putImageData → drawImage → getImageData, which is two premultiply
283
+ // round-trips and a resample to learn what was already in hand. Box-filtered
284
+ // straight off the array instead: same ≤128² plane, exact values, no canvas
285
+ // to be unavailable and no alpha to lose.
286
+ if (rgba) {
287
+ const a = new Uint8ClampedArray(w * h);
288
+ for (let y = 0; y < h; y++) {
289
+ const y0 = Math.floor((y * height) / h);
290
+ const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * height) / h));
291
+ for (let x = 0; x < w; x++) {
292
+ const x0 = Math.floor((x * width) / w);
293
+ const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * width) / w));
294
+ let sum = 0;
295
+ let n = 0;
296
+ for (let sy = y0; sy < y1; sy++) {
297
+ for (let sx = x0; sx < x1; sx++) {
298
+ sum += rgba[(sy * width + sx) * 4 + 3];
299
+ n++;
300
+ }
301
+ }
302
+ a[y * w + x] = n > 0 ? sum / n : 255;
303
+ }
304
+ }
305
+ return { a, w, h };
271
306
  }
272
- else {
307
+ if (!source)
273
308
  return null;
274
- }
309
+ const cx = alphaReadbackContext(w, h);
310
+ if (!cx)
311
+ return null;
312
+ cx.drawImage(source, 0, 0, w, h);
275
313
  const img = cx.getImageData(0, 0, w, h).data;
276
314
  const a = new Uint8ClampedArray(w * h);
277
315
  for (let i = 0; i < w * h; i++)
@@ -709,7 +747,6 @@ export class Engine {
709
747
  this.cullRebuilds = 0;
710
748
  // ── Render bundles ──
711
749
  this.opaqueBundle = null;
712
- this.transparentBundle = null;
713
750
  this.shadowBundles = [];
714
751
  /** Set by scene STRUCTURE only. Every frame of animation, every physics step
715
752
  * and every camera move must leave this alone — re-recording constantly is
@@ -804,6 +841,9 @@ export class Engine {
804
841
  * the plural is the whole point of this step and a singleton that has to be
805
842
  * "generalised later" is a singleton that shapes every call site against it.
806
843
  */
844
+ /** Subjects the cast actually holds, set while it is filled. The ribbons size
845
+ * their instance count by this rather than by the four-subject cap. */
846
+ this.castSubjectCount = 0;
807
847
  this.effects = [];
808
848
  /** Just the header, for rewriting the total without touching a record. */
809
849
  this.lightHeader = new Float32Array(LIGHT_HEADER);
@@ -921,9 +961,15 @@ export class Engine {
921
961
  // cloth. The full MMD-faithful machinery (interleaved per-material hulls,
922
962
  // texture-alpha-modulated rims) stays in place behind setOutlineEnabled(true).
923
963
  this.outlineEnabled = false;
964
+ /** Distinct uncaptured validation messages → how many times each arrived. */
965
+ this.gpuErrors = new Map();
924
966
  /** When set, render resolution is pinned to this size instead of tracking the
925
967
  * canvas's CSS size × devicePixelRatio (see setRenderSize). */
926
968
  this.fixedRenderSize = null;
969
+ /** Per cascade: does its map currently hold nothing but the cleared far plane?
970
+ * Set by the cascade loop, which skips a cascade that is unwanted and already
971
+ * cleared rather than re-clearing it every frame. */
972
+ this.shadowCascadeCleared = [];
927
973
  // CPU frame-time breakdown (EMA-smoothed into getStats): where a frame's
928
974
  // milliseconds actually go — animation/IK/blending vs physics vs everything
929
975
  // else on the render thread. The first question of any perf report.
@@ -932,6 +978,14 @@ export class Engine {
932
978
  this.cpuRenderMs = 0;
933
979
  this.frameAnimMsRaw = 0;
934
980
  this.framePhysicsMsRaw = 0;
981
+ /** Every shadow caster in one sphere: (x, y, z, radius). radius 0 = nothing
982
+ * casts, -1 = do not use (a rigid caster has no sphere). See updateCasterSphere. */
983
+ this.casterSphere = new Float32Array(4);
984
+ /** The ground's uniform block, kept so the caster sphere can be refreshed in
985
+ * it every frame rather than rebuilding the buffer (addGround allocates). */
986
+ this.groundMaterialData = null;
987
+ /** Set by the first getGpuTimings() call. See it for why asking is the switch. */
988
+ this.timestampsWanted = false;
935
989
  // Shadow is cast from the visible sun direction — same vector the shader lights with.
936
990
  /** Whether the shadow map needs clearing — see the shadow pass in `render`.
937
991
  *
@@ -1253,6 +1307,26 @@ export class Engine {
1253
1307
  this.writeCompositeViewUniforms();
1254
1308
  }
1255
1309
  }
1310
+ /**
1311
+ * Whether bloom will actually reach the frame this frame.
1312
+ *
1313
+ * The composite multiplies the pyramid by this same effective intensity, so a
1314
+ * zero here means every pass that BUILDS the pyramid is work whose result is
1315
+ * multiplied by nothing. That was the state of it: `enabled` reached exactly
1316
+ * one line — the intensity uniform below — and the nine render passes that
1317
+ * fill the pyramid ran regardless, on every frame, of every scene, whether or
1318
+ * not anyone had asked for bloom.
1319
+ *
1320
+ * Nine passes is the number that matters rather than the pixels: on a
1321
+ * tile-based GPU a render pass is a tile load and store whatever it draws, so
1322
+ * this is paid in full on Apple hardware and largely hidden on a desktop
1323
+ * immediate-mode one. It is the same asymmetry as the bundle bug — cheap where
1324
+ * it was written, expensive where it was reported.
1325
+ */
1326
+ bloomContributes() {
1327
+ const b = this.bloomSettings;
1328
+ return b.enabled && b.intensity > 0;
1329
+ }
1256
1330
  writeCompositeViewUniforms() {
1257
1331
  const v = this.viewTransform;
1258
1332
  const b = this.bloomSettings;
@@ -1543,6 +1617,9 @@ export class Engine {
1543
1617
  const lin = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
1544
1618
  const atts = this.mirrorPassDescriptor.colorAttachments;
1545
1619
  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 };
1620
+ // The descriptor is reused every frame, so the stamp is set on it rather
1621
+ // than passed — same as the scene pass, which is built once too.
1622
+ this.mirrorPassDescriptor.timestampWrites = this.stamps("mirror");
1546
1623
  const pass = encoder.beginRenderPass(this.mirrorPassDescriptor);
1547
1624
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
1548
1625
  const bundles = [];
@@ -1629,6 +1706,54 @@ export class Engine {
1629
1706
  probe?.destroy();
1630
1707
  return probe !== null && !err;
1631
1708
  }
1709
+ /**
1710
+ * Record an uncaptured validation error, once per distinct message.
1711
+ *
1712
+ * Distinct, because the interesting property of these is WHICH ones happened,
1713
+ * not how many times — a pass that fails validation fails identically every
1714
+ * frame, so the second occurrence carries no information the first did not.
1715
+ * The count is kept anyway: "1×" and "94000×" distinguish a one-off at init
1716
+ * from something the render loop is doing, and that distinction is the first
1717
+ * question anyone reading the report will have.
1718
+ */
1719
+ noteGpuError(message) {
1720
+ const seen = this.gpuErrors.get(message);
1721
+ if (seen !== undefined) {
1722
+ this.gpuErrors.set(message, seen + 1);
1723
+ return;
1724
+ }
1725
+ // The cap is on DISTINCT messages, so it is reached only by a device
1726
+ // disagreeing about many different things — at which point the first 32
1727
+ // have said what the device is, and the rest are noise.
1728
+ if (this.gpuErrors.size >= 32)
1729
+ return;
1730
+ this.gpuErrors.set(message, 1);
1731
+ // First occurrence only, and console.error rather than a silent buffer: a
1732
+ // validation error means something did not draw, and a developer with the
1733
+ // console open should not have to know this report exists to find out.
1734
+ console.error(`[reze] WebGPU validation: ${message}`);
1735
+ }
1736
+ /**
1737
+ * What this device actually gave us, and what it refused.
1738
+ *
1739
+ * The report exists because the three answers below are the ones that differ
1740
+ * between two browsers on the same machine, and a scene that renders wrong on
1741
+ * one of them is otherwise indistinguishable from a scene that is wrong. It is
1742
+ * meant to be read off a phone that cannot be attached to a debugger, which is
1743
+ * why it returns a value rather than logging: the host decides where to put it.
1744
+ */
1745
+ gpuReport() {
1746
+ return {
1747
+ hdrFormat: this.hdrFormat,
1748
+ depthFormat: this.depthFormat,
1749
+ reversedZ: this.reversedZ,
1750
+ ids: mrtIdsEnabled(),
1751
+ sampleCount: Engine.MULTISAMPLE_COUNT,
1752
+ presentationFormat: this.presentationFormat,
1753
+ features: this.device ? [...this.device.features].sort() : [],
1754
+ errors: [...this.gpuErrors].map(([message, count]) => ({ message, count })),
1755
+ };
1756
+ }
1632
1757
  rebuildCompositeBindGroup() {
1633
1758
  if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView)
1634
1759
  return;
@@ -2267,6 +2392,11 @@ export class Engine {
2267
2392
  paramsData,
2268
2393
  hasBackground,
2269
2394
  hasForeground,
2395
+ // The author's OWN source, not the assembled module: the assembled one
2396
+ // always carries the accessors (as real readers or as the zero stubs),
2397
+ // so matching against it would report every effect as a reader and the
2398
+ // attachment would be stored exactly as often as before.
2399
+ readsIds: /\brz(?:ObjectAt|MaterialAt)\s*\(/.test(wgsl),
2270
2400
  anchors,
2271
2401
  // The effect's own clock starts now. Per effect so that one installed
2272
2402
  // later still gets a frame where rzGridFrame() is 0 and can seed.
@@ -2833,7 +2963,10 @@ export class Engine {
2833
2963
  return {
2834
2964
  ok: true,
2835
2965
  state: {
2836
- instances: slots * MAX_EFFECT_SUBJECTS * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS,
2966
+ // Ribbons declared by this effect. The INSTANCE count is no longer
2967
+ // baked here — it follows the live subject count and is computed per
2968
+ // draw (see drawTrails).
2969
+ slots,
2837
2970
  uniform,
2838
2971
  data: new Float32Array(4),
2839
2972
  pipeline,
@@ -2895,13 +3028,27 @@ export class Engine {
2895
3028
  // The clock upload happens once, on the camera draw: queue writes land
2896
3029
  // before the encoder submits, so both passes read the same value — the
2897
3030
  // mirror draw writing it again would only write it twice.
3031
+ // Instances follow the LIVE subject count, not MAX_EFFECT_SUBJECTS.
3032
+ //
3033
+ // This used to be baked at install as slots x 4 x (samples-1) x subs, so a
3034
+ // scene with ONE character issued four characters' worth of ribbon quads
3035
+ // and threw three quarters of them away as degenerate — every frame, at
3036
+ // every sample length. Vertex invocations with no fragments are cheap, not
3037
+ // free, and they scale with the sample count, which is what made a longer
3038
+ // trail expensive.
3039
+ //
3040
+ // The shader decodes [ribbon][subject][segment] with the same number out
3041
+ // of its uniform, so the two cannot drift: change one without the other
3042
+ // and ribbons land on the wrong subject rather than merely costing more.
3043
+ const live = Math.max(1, this.castSubjectCount);
2898
3044
  if (view === "camera") {
2899
3045
  t.data[0] = this.sceneClock - e.epochScene;
3046
+ t.data[1] = live;
2900
3047
  this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer);
2901
3048
  }
2902
3049
  pass.setPipeline(t.pipeline);
2903
3050
  pass.setBindGroup(0, view === "mirror" ? t.mirrorBind : t.bind);
2904
- pass.draw(6, t.instances);
3051
+ pass.draw(6, t.slots * live * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS);
2905
3052
  }
2906
3053
  }
2907
3054
  /** The user's field mounts, drawn at half resolution for the composite to
@@ -3335,6 +3482,26 @@ export class Engine {
3335
3482
  throw new Error("WebGPU is not supported in this browser.");
3336
3483
  }
3337
3484
  this.device = device;
3485
+ // Every validation error this device ever raises, kept.
3486
+ //
3487
+ // WebGPU does not throw for a bad pipeline: createRenderPipeline hands back
3488
+ // an object that is already invalid, and the complaint arrives here instead
3489
+ // — or nowhere, if nobody is listening. Nobody was. That is why a device
3490
+ // that disagrees with this engine has, until now, had no way to say so: the
3491
+ // pipeline is built, setPipeline poisons the pass that uses it, and the
3492
+ // symptom reaches the user as geometry that is simply absent, with a clean
3493
+ // console. A browser is not obliged to agree with Dawn about what is legal,
3494
+ // and the two places this engine knowingly leans on Dawn's reading are both
3495
+ // in the scene pass (see scene-contract's writeMask-0 note).
3496
+ //
3497
+ // Bounded, and not on the console by default: a pass that fails validation
3498
+ // fails it again every frame, so an unbounded log is a memory leak with a
3499
+ // frame counter and an unconditional console.error is a browser tab that
3500
+ // stops responding. First N distinct messages, counted thereafter.
3501
+ device.addEventListener("uncapturederror", (e) => {
3502
+ const message = e.error.message;
3503
+ this.noteGpuError(message);
3504
+ });
3338
3505
  if (hasRg11b10)
3339
3506
  this.hdrFormat = "rg11b10ufloat";
3340
3507
  // The override has the last word, including over a device that would have
@@ -3397,13 +3564,48 @@ export class Engine {
3397
3564
  this.createPipelines();
3398
3565
  this.setupResize();
3399
3566
  Engine.instance = this;
3567
+ // One line, at init, naming the three answers that differ between two
3568
+ // browsers on the same machine. Not a debug flag and not a readout — it is
3569
+ // the identity of the renderer that was actually built, and on a device that
3570
+ // cannot be attached to a debugger it is the only way to know which of the
3571
+ // three paths is running. Every graphics application prints this.
3572
+ const r = this.gpuReport();
3573
+ console.info(`[reze] hdr=${r.hdrFormat} depth=${r.depthFormat} reversedZ=${r.reversedZ} ids=${r.ids} msaa=${r.sampleCount}`);
3574
+ }
3575
+ /**
3576
+ * Bake the ground's frost noise once — the same fbm the shader used to run
3577
+ * per pixel, rendered to a seamless 1024² r8unorm tile at init.
3578
+ *
3579
+ * Why this exists is measured, not argued: on WebKit the ground's whole cost
3580
+ * was this evaluation (see the note at the sample site in ground.ts). The
3581
+ * bake is one fullscreen pass at init — under a millisecond, once — and the
3582
+ * per-pixel cost becomes a single level-0 texture read.
3583
+ */
3584
+ bakeGroundNoise() {
3585
+ this.groundNoiseTexture = this.device.createTexture({
3586
+ label: "ground frost noise (baked)",
3587
+ size: [GROUND_NOISE_SIZE, GROUND_NOISE_SIZE],
3588
+ format: "r8unorm",
3589
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
3590
+ });
3591
+ this.groundNoiseView = this.groundNoiseTexture.createView();
3592
+ const module = this.device.createShaderModule({ label: "ground noise bake", code: GROUND_NOISE_BAKE_WGSL });
3593
+ const pipeline = this.device.createRenderPipeline({
3594
+ label: "ground noise bake",
3595
+ layout: "auto",
3596
+ vertex: { module, entryPoint: "vs" },
3597
+ fragment: { module, entryPoint: "fs", targets: [{ format: "r8unorm" }] },
3598
+ primitive: { topology: "triangle-list" },
3599
+ });
3600
+ const encoder = this.device.createCommandEncoder({ label: "ground noise bake" });
3601
+ const pass = encoder.beginRenderPass({
3602
+ colorAttachments: [{ view: this.groundNoiseView, loadOp: "clear", storeOp: "store" }],
3603
+ });
3604
+ pass.setPipeline(pipeline);
3605
+ pass.draw(3);
3606
+ pass.end();
3607
+ this.device.queue.submit([encoder.finish()]);
3400
3608
  }
3401
- // One-shot bake of EEVEE's combined BRDF LUT — DFG (bsdf_lut_frag.glsl) packed
3402
- // with ltc_mag_ggx (eevee_lut.c) into a single 64×64 rgba8unorm texture:
3403
- // .rg = split-sum DFG → F_brdf_*_scatter
3404
- // .ba = LTC magnitude → ltc_brdf_scale_from_lut
3405
- // One texture fetch per fragment replaces the previous 2–3 taps. rgba8unorm
3406
- // (vs rgba16float) halves sample bandwidth; DFG/LTC values fit [0,1] cleanly.
3407
3609
  bakeBrdfLut() {
3408
3610
  if (BRDF_LUT_SIZE !== LTC_MAG_LUT_SIZE) {
3409
3611
  throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).");
@@ -3896,17 +4098,11 @@ export class Engine {
3896
4098
  // occluded behind it. Color targets kept for pass compatibility, writeMask 0.
3897
4099
  const prepassModule = this.device.createShaderModule({
3898
4100
  label: "transparent depth prepass",
3899
- code: TRANSPARENT_DEPTH_PREPASS_WGSL,
4101
+ code: transparentDepthPrepassWgsl(),
3900
4102
  });
3901
- this.transparentDepthPrepassPipeline = this.device.createRenderPipeline({
3902
- label: "transparent depth prepass",
4103
+ const prepassDesc = {
3903
4104
  layout: mainPipelineLayout,
3904
4105
  vertex: { module: prepassModule, entryPoint: "vs", buffers: fullVertexBuffers },
3905
- fragment: {
3906
- module: prepassModule,
3907
- entryPoint: "fs",
3908
- targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
3909
- },
3910
4106
  primitive: { cullMode: "none" },
3911
4107
  multisample: { count: Engine.MULTISAMPLE_COUNT },
3912
4108
  depthStencil: {
@@ -3914,6 +4110,51 @@ export class Engine {
3914
4110
  depthWriteEnabled: true,
3915
4111
  depthCompare: this.depthAhead,
3916
4112
  },
4113
+ };
4114
+ this.depthPrepassPipeline = this.device.createRenderPipeline({
4115
+ label: "opaque depth prepass",
4116
+ ...prepassDesc,
4117
+ fragment: {
4118
+ module: prepassModule,
4119
+ entryPoint: "fs",
4120
+ targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
4121
+ },
4122
+ });
4123
+ // The SOLID prime: same module, cutoff forced to exactly 1.0. Only texels
4124
+ // whose blend ignores the destination may pre-claim depth in the
4125
+ // transparent phase — see the override's note in depth-prepass.ts.
4126
+ this.solidPrepassPipeline = this.device.createRenderPipeline({
4127
+ label: "transparent solid prepass",
4128
+ ...prepassDesc,
4129
+ fragment: {
4130
+ module: prepassModule,
4131
+ entryPoint: "fs",
4132
+ constants: { CUTOFF: 1.0 },
4133
+ targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
4134
+ },
4135
+ });
4136
+ // The HAIR prime: solid texels only, and stencil-fenced off the eye
4137
+ // silhouette. It records after the non-hair opaque draws, so the eye has
4138
+ // already written its stencil — not-equal here is what keeps the primed
4139
+ // hair depth from ever claiming the pixels the see-through-hair pass needs
4140
+ // the eye to survive on. (Bundle draws use the PASS's stencil reference;
4141
+ // only pipeline/bind/vertex state resets across executeBundles.)
4142
+ this.hairPrimePipeline = this.device.createRenderPipeline({
4143
+ label: "hair depth prime",
4144
+ ...prepassDesc,
4145
+ depthStencil: {
4146
+ ...prepassDesc.depthStencil,
4147
+ stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
4148
+ stencilBack: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
4149
+ stencilReadMask: 0xff,
4150
+ stencilWriteMask: 0,
4151
+ },
4152
+ fragment: {
4153
+ module: prepassModule,
4154
+ entryPoint: "fs",
4155
+ constants: { CUTOFF: 1.0 },
4156
+ targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
4157
+ },
3917
4158
  });
3918
4159
  this.shadowLightVPBuffer = this.device.createBuffer({
3919
4160
  size: 64 * SHADOW_CASCADES.length,
@@ -3947,7 +4188,7 @@ export class Engine {
3947
4188
  fragment: { module: shadowShader, entryPoint: "fs", targets: [] },
3948
4189
  primitive: { cullMode: "none" },
3949
4190
  depthStencil: {
3950
- format: "depth32float",
4191
+ format: Engine.SHADOW_DEPTH_FORMAT,
3951
4192
  depthWriteEnabled: true,
3952
4193
  depthCompare: "less-equal",
3953
4194
  // The shadow map keeps the NON-reversed convention (orthographicLh maps
@@ -3968,12 +4209,13 @@ export class Engine {
3968
4209
  this.shadowMapTextures = SHADOW_CASCADES.map((c, i) => this.device.createTexture({
3969
4210
  label: `shadow map cascade ${i}`,
3970
4211
  size: [c.mapSize, c.mapSize],
3971
- format: "depth32float",
4212
+ format: Engine.SHADOW_DEPTH_FORMAT,
3972
4213
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
3973
4214
  }));
3974
4215
  this.shadowMapDepthViews = this.shadowMapTextures.map((t) => t.createView());
3975
4216
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
3976
4217
  this.bakeBrdfLut();
4218
+ this.bakeGroundNoise();
3977
4219
  this.agxFallbackTexture = this.device.createTexture({
3978
4220
  label: "AgX LUT fallback",
3979
4221
  size: [1, 1, 1],
@@ -4063,6 +4305,9 @@ export class Engine {
4063
4305
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4064
4306
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
4065
4307
  { binding: 11, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth", multisampled: true } },
4308
+ // The baked frost tile — see bakeGroundNoise. Sampled with binding 10's
4309
+ // repeat sampler, so it brings no sampler of its own.
4310
+ { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4066
4311
  ],
4067
4312
  });
4068
4313
  const groundShadowShader = this.device.createShaderModule({
@@ -4115,7 +4360,7 @@ export class Engine {
4115
4360
  });
4116
4361
  const outlineShaderModule = this.device.createShaderModule({
4117
4362
  label: "outline shaders",
4118
- code: OUTLINE_SHADER_WGSL,
4363
+ code: outlineShaderWgsl(),
4119
4364
  });
4120
4365
  this.outlinePipeline = this.createRenderPipeline({
4121
4366
  label: "outline pipeline",
@@ -4562,6 +4807,22 @@ export class Engine {
4562
4807
  this.handleResize();
4563
4808
  }
4564
4809
  handleResize() {
4810
+ // No device, nothing to size.
4811
+ //
4812
+ // Three callers reach this, and two of them can arrive before init() has a
4813
+ // device or after teardown has released one: setRenderSize is PUBLIC and
4814
+ // unordered with respect to init, and the ResizeObserver keeps firing across
4815
+ // a hot reload while the replaced engine is still mounted. Both landed on
4816
+ // `this.device.createTexture` and threw — which is why this only shows up
4817
+ // during development, and why 0.43 never saw it: setRenderSize did not exist
4818
+ // to be called early.
4819
+ //
4820
+ // Returning is correct rather than merely quiet. fixedRenderSize has already
4821
+ // been recorded by the time we get here, and init() ends with its own
4822
+ // handleResize — so the size asked for before the device existed is applied
4823
+ // in full the moment there is something to apply it to.
4824
+ if (!this.device)
4825
+ return;
4565
4826
  // Fixed override (offline/video rendering) wins; otherwise track CSS size × dpr.
4566
4827
  const dpr = window.devicePixelRatio || 1;
4567
4828
  const width = this.fixedRenderSize ? this.fixedRenderSize.width : Math.floor(this.canvas.clientWidth * dpr);
@@ -5766,14 +6027,20 @@ export class Engine {
5766
6027
  this.setModelTransform(key, options.transform);
5767
6028
  return key;
5768
6029
  }
5769
- /** True while a stage is in the scene, which is when the built-in ground plane
5770
- * must not draw. */
5771
- groundIsSuppressed() {
6030
+ /** True while a stage is in the scene. Two things turn on it: the built-in
6031
+ * ground plane must not draw, and the far shadow cascade has nothing to
6032
+ * cover without one (see the cascade loop). */
6033
+ hasStage() {
5772
6034
  for (const inst of this.modelInstances.values())
5773
6035
  if (inst.isStage)
5774
6036
  return true;
5775
6037
  return false;
5776
6038
  }
6039
+ /** True while a stage is in the scene, which is when the built-in ground plane
6040
+ * must not draw. */
6041
+ groundIsSuppressed() {
6042
+ return this.hasStage();
6043
+ }
5777
6044
  removeModel(name) {
5778
6045
  const inst = this.modelInstances.get(name);
5779
6046
  if (!inst)
@@ -6112,7 +6379,7 @@ export class Engine {
6112
6379
  if (!gm || !gm.dispatchNeeded)
6113
6380
  continue;
6114
6381
  if (!pass) {
6115
- pass = encoder.beginComputePass({ label: "morph compute" });
6382
+ pass = encoder.beginComputePass({ label: "morph compute", timestampWrites: this.stamps("morph") });
6116
6383
  pass.setPipeline(this.morphComputePipeline);
6117
6384
  }
6118
6385
  pass.setBindGroup(0, gm.bindGroup);
@@ -6347,6 +6614,106 @@ export class Engine {
6347
6614
  }
6348
6615
  if (this.cullModelBuffer)
6349
6616
  this.device.queue.writeBuffer(this.cullModelBuffer, 0, data.buffer);
6617
+ this.updateCasterSphere(data);
6618
+ }
6619
+ /**
6620
+ * One sphere containing every shadow caster in the scene, for the ground.
6621
+ *
6622
+ * The ground's PCF is the most expensive thing in the frame on a tile-based
6623
+ * GPU — nine hardware-bilinear comparisons per pixel on a full-coverage draw,
6624
+ * which is what 0.33.2 was about and what a second cascade quietly undid. But
6625
+ * the floor is vastly larger than the thing standing on it, and a pixel the
6626
+ * character cannot possibly shadow does not need to ask the shadow map: the
6627
+ * answer is lit, and nine taps is an expensive way to spell it.
6628
+ *
6629
+ * So the ground gets a bound and tests against it in ALU. This reuses the
6630
+ * spheres the cull already builds every frame — an AABB over POSED bone
6631
+ * positions grown by the skin margin, which its own note calls a bound rather
6632
+ * than an estimate, so a jump or a physics-driven skirt is inside it by
6633
+ * construction. Union, not per model: one sphere is one test, and the ground
6634
+ * shader must not loop over the cast.
6635
+ *
6636
+ * A RIGID caster (a stage) leaves its cull sphere zeroed deliberately — the
6637
+ * cull reads its boxes instead — so any rigid model disables this entirely by
6638
+ * setting radius to -1. Wrong here is a missing shadow, and a scene with a
6639
+ * stage keeps the taps rather than risk one.
6640
+ */
6641
+ updateCasterSphere(data) {
6642
+ const out = this.casterSphere;
6643
+ out[3] = 0;
6644
+ let cx = 0;
6645
+ let cy = 0;
6646
+ let cz = 0;
6647
+ let r = 0;
6648
+ let any = false;
6649
+ for (let i = 0; i < this.cullModels.length; i++) {
6650
+ const inst = this.cullModels[i];
6651
+ if (!inst.model.visible || inst.shadowDrawCalls.length === 0)
6652
+ continue;
6653
+ if (inst.rigid) {
6654
+ // No sphere to read. Bail out of the whole optimisation.
6655
+ out[3] = -1;
6656
+ return;
6657
+ }
6658
+ const o = i * Engine.CULL_MODEL_FLOATS + 16;
6659
+ const x = data[o];
6660
+ const y = data[o + 1];
6661
+ const z = data[o + 2];
6662
+ const rad = data[o + 3];
6663
+ if (rad <= 0)
6664
+ continue;
6665
+ if (!any) {
6666
+ cx = x;
6667
+ cy = y;
6668
+ cz = z;
6669
+ r = rad;
6670
+ any = true;
6671
+ continue;
6672
+ }
6673
+ // Union of two spheres, the standard construction: if one already contains
6674
+ // the other keep it, else grow along the line between the centres.
6675
+ const dx = x - cx;
6676
+ const dy = y - cy;
6677
+ const dz = z - cz;
6678
+ const d = Math.hypot(dx, dy, dz);
6679
+ if (d + rad <= r)
6680
+ continue;
6681
+ if (d + r <= rad) {
6682
+ cx = x;
6683
+ cy = y;
6684
+ cz = z;
6685
+ r = rad;
6686
+ continue;
6687
+ }
6688
+ const nr = (d + r + rad) * 0.5;
6689
+ const t = (nr - r) / d;
6690
+ cx += dx * t;
6691
+ cy += dy * t;
6692
+ cz += dz * t;
6693
+ r = nr;
6694
+ }
6695
+ out[0] = cx;
6696
+ out[1] = cy;
6697
+ out[2] = cz;
6698
+ out[3] = any ? r : 0;
6699
+ }
6700
+ /**
6701
+ * Push this frame's caster sphere into the ground's uniform.
6702
+ *
6703
+ * Four floats, one writeBuffer, and only while a ground exists. Rebuilding the
6704
+ * block the way addGround does would allocate a buffer and a bind group per
6705
+ * frame, which is the cost this is trying to remove rather than a way to pay
6706
+ * it somewhere else.
6707
+ */
6708
+ writeGroundCasterSphere() {
6709
+ const gb = this.groundMaterialData;
6710
+ if (!gb || !this.groundShadowMaterialBuffer)
6711
+ return;
6712
+ if (gb[20] === this.casterSphere[0] && gb[21] === this.casterSphere[1] &&
6713
+ gb[22] === this.casterSphere[2] && gb[23] === this.casterSphere[3])
6714
+ return;
6715
+ gb.set(this.casterSphere, 20);
6716
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 80, this.casterSphere);
6350
6717
  }
6351
6718
  /**
6352
6719
  * The world sphere for a skinned model: an AABB over its POSED bone positions,
@@ -6488,7 +6855,6 @@ export class Engine {
6488
6855
  };
6489
6856
  if (this.modelInstances.size === 0) {
6490
6857
  this.opaqueBundle = null;
6491
- this.transparentBundle = null;
6492
6858
  this.mirrorOpaqueBundle = null;
6493
6859
  this.mirrorTransparentBundle = null;
6494
6860
  this.shadowBundles = [];
@@ -6498,13 +6864,15 @@ export class Engine {
6498
6864
  const opaque = this.device.createRenderBundleEncoder({ label: "opaque phase", ...scene });
6499
6865
  this.forEachInstance((inst) => this.renderModelOpaquePhase(opaque, inst, camView));
6500
6866
  this.opaqueBundle = opaque.finish({ label: "opaque phase" });
6501
- const transparent = this.device.createRenderBundleEncoder({ label: "transparent phase", ...scene });
6502
- this.forEachInstance((inst) => this.renderModelTransparentPhase(transparent, inst, camView));
6503
- this.transparentBundle = transparent.finish({ label: "transparent phase" });
6504
- // The mirror pair: the same draws against the same formats, with the
6505
- // mirrored camera baked into bind group 0 and the mirror cull args baked
6506
- // into the indirect draws. Recorded whether or not a mirror is active —
6507
- // recording is cheap, and the bundles only execute when the pass runs.
6867
+ // NO camera transparent bundle. The camera pass draws that phase directly
6868
+ // see the note at the executeBundles call for what recording one cost on
6869
+ // WebKit. Recording it anyway "in case" is not free and not harmless: it is
6870
+ // work on every rebuild, and a live bundle beside a direct draw of the same
6871
+ // phase is an invitation to execute it again.
6872
+ //
6873
+ // The MIRROR pair below keeps both bundles, and is allowed to: that pass
6874
+ // hands them to a single executeBundles with nothing direct in between,
6875
+ // which is the pattern that works.
6508
6876
  const mirrorView = this.sceneView("mirror");
6509
6877
  const mo = this.device.createRenderBundleEncoder({ label: "mirror opaque phase", ...scene });
6510
6878
  this.forEachInstance((inst) => this.renderModelOpaquePhase(mo, inst, mirrorView));
@@ -6520,7 +6888,7 @@ export class Engine {
6520
6888
  const shadow = this.device.createRenderBundleEncoder({
6521
6889
  label: `shadow pass, cascade ${ci}`,
6522
6890
  colorFormats: [],
6523
- depthStencilFormat: "depth32float",
6891
+ depthStencilFormat: Engine.SHADOW_DEPTH_FORMAT,
6524
6892
  });
6525
6893
  shadow.setPipeline(this.shadowDepthPipeline);
6526
6894
  this.forEachInstance((inst) => this.drawInstanceShadow(shadow, inst, ci));
@@ -6536,6 +6904,25 @@ export class Engine {
6536
6904
  const i = Engine.TIMED_PASSES.indexOf(pass);
6537
6905
  return { querySet: this.timestampQuerySet, beginningOfPassWriteIndex: i * 2, endOfPassWriteIndex: i * 2 + 1 };
6538
6906
  }
6907
+ /**
6908
+ * Half a stamp, for a component that is several passes rather than one.
6909
+ *
6910
+ * Bloom is nine render passes — a prefilter blit, a downsample chain and an
6911
+ * upsample chain — and what anyone wants to know is what the PYRAMID cost, not
6912
+ * what its fourth mip cost. Both fields of GPURenderPassTimestampWrites are
6913
+ * optional, so the opening query goes on the first pass and the closing one on
6914
+ * the last, and the pair reads as one span across everything between.
6915
+ */
6916
+ stampOpen(pass) {
6917
+ if (!this.timestampQuerySet)
6918
+ return undefined;
6919
+ return { querySet: this.timestampQuerySet, beginningOfPassWriteIndex: Engine.TIMED_PASSES.indexOf(pass) * 2 };
6920
+ }
6921
+ stampClose(pass) {
6922
+ if (!this.timestampQuerySet)
6923
+ return undefined;
6924
+ return { querySet: this.timestampQuerySet, endOfPassWriteIndex: Engine.TIMED_PASSES.indexOf(pass) * 2 + 1 };
6925
+ }
6539
6926
  /**
6540
6927
  * Resolve this frame's timings and start a readback, at most one in flight.
6541
6928
  *
@@ -6548,6 +6935,9 @@ export class Engine {
6548
6935
  const qs = this.timestampQuerySet;
6549
6936
  if (!qs || !this.timestampResolve || !this.timestampRead)
6550
6937
  return;
6938
+ // Nobody has asked. See getGpuTimings — the read is what enrols.
6939
+ if (!this.timestampsWanted)
6940
+ return;
6551
6941
  const count = Engine.TIMED_PASSES.length * 2;
6552
6942
  encoder.resolveQuerySet(qs, 0, count, this.timestampResolve, 0);
6553
6943
  if (this.timestampBusy)
@@ -6588,8 +6978,21 @@ export class Engine {
6588
6978
  * The regression guard for the draw-path work: these are the numbers that say
6589
6979
  * whether restructuring cost anything, which is the claim being made — not
6590
6980
  * whether it made the scene faster, which was never the goal.
6981
+ *
6982
+ * ASKING IS WHAT TURNS IT ON. The first call to this enrols the engine in the
6983
+ * per-frame readback; until then resolveTimestamps does nothing. That is why
6984
+ * the first call returns null even on a device that can measure — the answer
6985
+ * arrives a frame or two later, which is already true of these numbers and
6986
+ * documented on resolveTimestamps.
6987
+ *
6988
+ * The alternative was what this used to do: resolve the query set, copy it to
6989
+ * a staging buffer and map that buffer, every frame, on every device, for a
6990
+ * reader that in this codebase did not exist. A map is a synchronisation point
6991
+ * and the whole path is instrumentation — paying for it unasked is the same
6992
+ * mistake as shipping a debug flag, only invisible.
6591
6993
  */
6592
6994
  getGpuTimings() {
6995
+ this.timestampsWanted = true;
6593
6996
  return this.gpuPassMs;
6594
6997
  }
6595
6998
  dispatchCull(encoder) {
@@ -7097,7 +7500,8 @@ export class Engine {
7097
7500
  // Shadow map is already created in setupPipelines()
7098
7501
  // 20 floats: 16 for the original block, then (mirrorBlur, pad, pad, pad)
7099
7502
  // keeping the uniform vec4-aligned.
7100
- const gb = new Float32Array(20);
7503
+ const gb = new Float32Array(24);
7504
+ this.groundMaterialData = gb;
7101
7505
  gb[0] = diffuseColor.x;
7102
7506
  gb[1] = diffuseColor.y;
7103
7507
  gb[2] = diffuseColor.z;
@@ -7117,6 +7521,23 @@ export class Engine {
7117
7521
  this.groundMirror = gb[15];
7118
7522
  gb[16] = Math.min(Math.max(mirrorBlur, 0), 1);
7119
7523
  this.groundMirrorBlur = gb[16];
7524
+ // gb[17] — does the FAR cascade hold anything?
7525
+ //
7526
+ // It holds something only when a stage is loaded; that is what it exists for
7527
+ // and the cascade loop already skips drawing into it otherwise, leaving it
7528
+ // cleared. A cleared depth map compares as "no occluder", so the ground's far
7529
+ // branch is nine comparison taps whose answer is known in advance.
7530
+ //
7531
+ // That branch runs wherever the NEAR cascade does not reach, and the near one
7532
+ // is a 64-unit box around the camera target — so on a floor receding to the
7533
+ // horizon it is most of the visible pixels, on the most expensive
7534
+ // full-coverage draw in the frame. Skipping it is free in the exact sense:
7535
+ // the shader takes vis = 1.0, which is what the taps would have returned.
7536
+ gb[17] = this.hasStage() ? 1 : 0;
7537
+ // gb[20..23] — the caster sphere, refreshed every frame by
7538
+ // writeGroundCasterSphere. Zero here so a frame that renders before the
7539
+ // first cull (there is one) reads "nothing casts" and skips the taps, which
7540
+ // is true: no model has been posed yet.
7120
7541
  this.groundShadowMaterialBuffer = this.device.createBuffer({
7121
7542
  size: gb.byteLength,
7122
7543
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -7151,6 +7572,7 @@ export class Engine {
7151
7572
  { binding: 9, resource: this.mirrorColorView },
7152
7573
  { binding: 10, resource: this.materialSampler },
7153
7574
  { binding: 11, resource: this.mirrorDepthReadView },
7575
+ { binding: 12, resource: this.groundNoiseView },
7154
7576
  ],
7155
7577
  });
7156
7578
  if (this.groundDrawCall)
@@ -7620,7 +8042,17 @@ export class Engine {
7620
8042
  }
7621
8043
  // CPU alpha sampler for sheerness classification (see textureAlphaCache).
7622
8044
  // Canvas 2D premultiplies RGB on readback, but the ALPHA channel is exact.
7623
- this.textureAlphaCache.set(cacheKey, buildAlphaSampler(source, rgba, width, height));
8045
+ const alphaPlane = buildAlphaSampler(source, rgba, width, height);
8046
+ // Loud, because the fallback is WRONG rather than merely absent: a material
8047
+ // with no alpha plane scores avg 1 / translucentFrac 0, which routes sheer
8048
+ // fabric into the OPAQUE bucket and changes what the frame looks like. A
8049
+ // readback that fails is therefore a rendering bug, not a missing nicety,
8050
+ // and it must not reach the user as "the dress looks different on my phone".
8051
+ if (!alphaPlane) {
8052
+ console.warn(`[reze] alpha readback failed for ${cacheKey} — this material will be classified OPAQUE, ` +
8053
+ `so sheer fabric will not blend. The canvas 2D readback is what failed.`);
8054
+ }
8055
+ this.textureAlphaCache.set(cacheKey, alphaPlane);
7624
8056
  const mipLevelCount = Math.floor(Math.log2(Math.max(width, height))) + 1;
7625
8057
  const texture = this.device.createTexture({
7626
8058
  label: `texture: ${cacheKey}`,
@@ -8130,10 +8562,49 @@ export class Engine {
8130
8562
  const dofOn = this.depthOfField.enabled;
8131
8563
  // ANY effect: one foreground mount anywhere in the scene, or one ribbon,
8132
8564
  // is enough to make the pass store its depth instead of discarding it.
8133
- const depthRead = dofOn || this.effects.some((e) => e.hasForeground) || this.effects.some((e) => e.trails !== null);
8565
+ // Ribbons are NOT in this list, and removing them is the single largest
8566
+ // bandwidth saving in the frame on a tile-based GPU.
8567
+ //
8568
+ // They were, from when a ribbon was its own layer drawn after the scene and
8569
+ // depth-tested BY HAND against the stored buffer. That layer is gone —
8570
+ // ribbons draw inside this pass and the hardware depth test replaced what
8571
+ // they read it for (see trails.ts, "Binding 3 is GONE"). The clause outlived
8572
+ // the change by about twelve hours and then sat here.
8573
+ //
8574
+ // What it cost: this flag decides whether the pass STORES its depth or
8575
+ // discards it into tile memory, and the buffer is depth32float-stencil8 at
8576
+ // the pass's sample count — on a retina canvas that is a nine-figure number
8577
+ // of bytes written to RAM every frame, for a texture nothing then sampled.
8578
+ // Chrome hides it (an immediate-mode GPU has depth in memory regardless);
8579
+ // Apple's TBDR does not, which is exactly the reported shape: adding a hand
8580
+ // ribbon costs a lot of fps on Safari and almost nothing on Chrome.
8581
+ //
8582
+ // The two real readers are both in the composite and both have their own
8583
+ // flag above: linearDepth() feeds the DoF gather and the depth handed to a
8584
+ // foreground mount. Nothing else binds depthTex at all.
8585
+ const depthRead = dofOn || this.effects.some((e) => e.hasForeground);
8134
8586
  this.renderPassDescriptor.depthStencilAttachment.depthStoreOp = depthRead ? "store" : "discard";
8135
8587
  if (depthRead)
8136
8588
  this.writeDepthOfFieldUniforms();
8589
+ // The id attachment, on exactly the same terms as the depth above it.
8590
+ //
8591
+ // It is the most expensive STORE in the pass — rg16uint at the pass's sample
8592
+ // count, ~33MB a frame at 1080p — and a uint target cannot be resolved, so
8593
+ // storing is the only way to get it out. It was stored unconditionally, for
8594
+ // every scene, whether or not anything read it. Nothing usually does: the
8595
+ // readers are rzObjectAt / rzMaterialAt in an effect that masks itself to one
8596
+ // character, and the id-buffer debug view.
8597
+ //
8598
+ // Discarding is not the same as removing. Every pipeline still declares the
8599
+ // attachment and the pass still carries it, so nothing is rebuilt and no
8600
+ // shader changes — the frame is bit-identical either way, because the only
8601
+ // difference is whether tile memory is written back to RAM after a pass
8602
+ // whose result no one is going to read.
8603
+ const idAtt = this.renderPassDescriptor.colorAttachments[2];
8604
+ if (idAtt) {
8605
+ const idsRead = this.idDebug || this.effects.some((e) => e.readsIds);
8606
+ idAtt.storeOp = idsRead ? "store" : "discard";
8607
+ }
8137
8608
  const encoder = this.device.createCommandEncoder();
8138
8609
  // GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
8139
8610
  // them. WebGPU inserts the storage→vertex barrier between this pass and the render passes.
@@ -8145,6 +8616,8 @@ export class Engine {
8145
8616
  this.updateMirrorCamera();
8146
8617
  if (hasModels)
8147
8618
  this.dispatchCull(encoder);
8619
+ // After the cull, which is what recomputes the spheres it unions.
8620
+ this.writeGroundCasterSphere();
8148
8621
  // After the cull, because a rebuild there can reallocate the argument
8149
8622
  // buffers and a bundle captures the buffer it recorded against.
8150
8623
  if (this.bundlesDirty)
@@ -8155,7 +8628,26 @@ export class Engine {
8155
8628
  // keeps PCF-sampling a character that is no longer in the scene. One clearing
8156
8629
  // pass on the transition to empty, then it stops.
8157
8630
  if (hasModels || this.shadowMapPopulated) {
8631
+ // The far cascade is the STAGE cascade, and it costs a full pass over the
8632
+ // whole cast every frame to say so. Its own spec explains what it is for —
8633
+ // "a set piece 100 units out still throws" — and a scene with no stage has
8634
+ // no set piece: every caster sits inside the near cascade's 64-unit box,
8635
+ // which follows the camera target, and the far map's only readers are
8636
+ // ground pixels beyond that box, where nothing is casting.
8637
+ //
8638
+ // So when no stage is loaded it is drawn ONCE, cleared, and then skipped —
8639
+ // the same shape as shadowMapPopulated above, and for the same reason. A
8640
+ // cleared depth map reads as "no occluder", which is the correct answer
8641
+ // here rather than a missing one. Load a stage and it comes straight back.
8642
+ //
8643
+ // 0.43 had ONE shadow map. This is half of what the second one costs.
8644
+ const stage = this.hasStage();
8158
8645
  for (let ci = 0; ci < SHADOW_CASCADES.length; ci++) {
8646
+ const wanted = ci === 0 || stage;
8647
+ // Already cleared and still unwanted — nothing to do, and the map still
8648
+ // holds the far plane from the pass that cleared it.
8649
+ if (!wanted && this.shadowCascadeCleared[ci])
8650
+ continue;
8159
8651
  const sp = encoder.beginRenderPass({
8160
8652
  // One timestamp pair exists for "shadow"; the near cascade wears it.
8161
8653
  timestampWrites: ci === 0 ? this.stamps("shadow") : undefined,
@@ -8171,9 +8663,10 @@ export class Engine {
8171
8663
  // per-frame boolean, and baking it into a bundle would make toggling a
8172
8664
  // model re-record. It lives in the cull compute now, which zeroes the
8173
8665
  // instance count of an invisible model's draws.
8174
- if (this.shadowBundles[ci])
8666
+ if (wanted && this.shadowBundles[ci])
8175
8667
  sp.executeBundles([this.shadowBundles[ci]]);
8176
8668
  sp.end();
8669
+ this.shadowCascadeCleared[ci] = !wanted;
8177
8670
  }
8178
8671
  this.shadowMapPopulated = hasModels;
8179
8672
  }
@@ -8203,10 +8696,45 @@ export class Engine {
8203
8696
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
8204
8697
  if (this.opaqueBundle)
8205
8698
  pass.executeBundles([this.opaqueBundle]);
8699
+ // Re-asserted after the bundle, not merely set once before it.
8700
+ //
8701
+ // Stencil reference is pass state a bundle cannot carry — GPURenderBundleEncoder
8702
+ // has no setStencilReference — which is why it was hoisted above the bundle in
8703
+ // the first place. But "cannot carry" and "cannot disturb" are different
8704
+ // claims, and only the first is specified. Everything below this line that
8705
+ // stencil-tests (hair at not-equal, outline hulls at not-equal) reads a
8706
+ // reference of 0 instead of 1 if a replay resets it, and not-equal against 0
8707
+ // is FALSE for the cleared buffer — every such fragment silently rejected.
8708
+ // One redundant word against a whole class of invisible failure.
8709
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
8206
8710
  if (this.hasGround)
8207
8711
  this.renderGround(pass);
8208
- if (this.transparentBundle)
8209
- pass.executeBundles([this.transparentBundle]);
8712
+ // The transparent phase is drawn DIRECTLY, and must stay that way. It is the
8713
+ // one part of this pass that is not bundled, so the reason is worth keeping.
8714
+ //
8715
+ // It WAS a bundle, and on WebKit the entire transparent bucket vanished while
8716
+ // the opaque bucket and the ground rendered perfectly — sheer fabric simply
8717
+ // absent, with no validation error anywhere. It was not the fragments: with
8718
+ // alpha forced to 1 they still never appeared, the cull reported every draw
8719
+ // visible with its GPU and CPU halves agreeing, and a cast model's
8720
+ // transparent draws use the SAME pipeline, bind groups and depth state as its
8721
+ // opaque ones (pipelineForDrawCall, forceDepthWrite). Identical draws,
8722
+ // identical state, one bucket rendering.
8723
+ //
8724
+ // What differed was only how they reached the pass: the opaque bundle is the
8725
+ // FIRST executeBundles here, and the transparent one was the SECOND, issued
8726
+ // after direct commands (the ground). Legal, and correct on Dawn. Not
8727
+ // replayed on WebKit. The mirror pass is the counter-example that pins the
8728
+ // shape of it — it passes BOTH bundles to a single executeBundles with
8729
+ // nothing direct in between, and has never lost a draw.
8730
+ //
8731
+ // So the rule this pass now keeps: at most one executeBundles, and nothing
8732
+ // direct before it. Bundling this phase again means first moving the ground
8733
+ // into the opaque bundle so the two can go in one call, the way the mirror
8734
+ // does it. The saving that buys is CPU encode time over a handful of draws,
8735
+ // which was never this renderer's bottleneck.
8736
+ const camView = this.sceneView("camera");
8737
+ this.forEachInstance((inst) => this.renderModelTransparentPhase(pass, inst, camView));
8210
8738
  // Last in the pass: depth-tested against everything drawn above, so a
8211
8739
  // particle behind the character is simply hidden, and still inside the HDR
8212
8740
  // target so an `@bloom` effect reaches the pyramid below.
@@ -8226,16 +8754,24 @@ export class Engine {
8226
8754
  // 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
8227
8755
  // then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
8228
8756
  // Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
8229
- if (this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
8757
+ // bloomContributes() gates the whole pyramid, not just its intensity. The
8758
+ // composite still SAMPLES bloomUp[0] unconditionally, which is safe and
8759
+ // deliberate: it scales what it reads by the same effective intensity, so a
8760
+ // stale or never-written pyramid is multiplied by zero. Skipping the build
8761
+ // is therefore invisible in the frame and nine render passes cheaper.
8762
+ if (this.bloomContributes() && this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
8230
8763
  const bloomAtt = this.bloomPassDescriptor.colorAttachments;
8231
- // 1. Blit
8764
+ // 1. Blit — opens the pyramid's timing span. See stampOpen: the nine
8765
+ // passes below read as ONE component, which is the only useful grain.
8232
8766
  bloomAtt[0].view = this.bloomDownMipViews[0];
8767
+ this.bloomPassDescriptor.timestampWrites = this.stampOpen("bloom");
8233
8768
  const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor);
8234
8769
  pBlit.setPipeline(this.bloomBlitPipeline);
8235
8770
  pBlit.setBindGroup(0, this.bloomBlitBindGroup);
8236
8771
  pBlit.draw(3);
8237
8772
  pBlit.end();
8238
8773
  // 2. Downsample chain
8774
+ this.bloomPassDescriptor.timestampWrites = undefined;
8239
8775
  for (let i = 1; i < this.bloomMipCount; i++) {
8240
8776
  bloomAtt[0].view = this.bloomDownMipViews[i];
8241
8777
  const p = encoder.beginRenderPass(this.bloomPassDescriptor);
@@ -8250,6 +8786,8 @@ export class Engine {
8250
8786
  for (let k = 0; k < upSteps; k++) {
8251
8787
  const levelIdx = topIdx - k; // writes bloomUp[levelIdx]
8252
8788
  bloomAtt[0].view = this.bloomUpMipViews[levelIdx];
8789
+ // The LAST upsample closes the span opened on the blit.
8790
+ this.bloomPassDescriptor.timestampWrites = k === upSteps - 1 ? this.stampClose("bloom") : undefined;
8253
8791
  const p = encoder.beginRenderPass(this.bloomPassDescriptor);
8254
8792
  p.setPipeline(this.bloomUpsamplePipeline);
8255
8793
  p.setBindGroup(0, this.bloomUpsampleBindGroups[k]);
@@ -8783,12 +9321,26 @@ export class Engine {
8783
9321
  * makes outlines compose like MMD: every material drawn later in the author's
8784
9322
  * order covers earlier hulls, and each hull sits over everything drawn before it.
8785
9323
  */
8786
- drawMaterials(pass, inst, type, view) {
9324
+ /** Is this draw's compiled class "hair"? Ungrouped draws never are — the
9325
+ * neutral pipeline is the auto class. */
9326
+ isHairDraw(inst, dc) {
9327
+ if (!dc.groupId)
9328
+ return false;
9329
+ const install = inst.styleGroups.get(dc.groupId);
9330
+ return install?.renderClass === "hair";
9331
+ }
9332
+ drawMaterials(pass, inst, type, view,
9333
+ // The opaque phase walks its author order twice — non-hair, then hair — so
9334
+ // the hair depth prime can sit between the eye's stencil write and the hair
9335
+ // colour that must respect it. See renderModelOpaquePhase.
9336
+ only) {
8787
9337
  let currentPipeline = null;
8788
9338
  let bound = false;
8789
9339
  for (const draw of inst.drawCalls) {
8790
9340
  if (draw.type !== type)
8791
9341
  continue;
9342
+ if (only && (only === "hair") !== this.isHairDraw(inst, draw))
9343
+ continue;
8792
9344
  if (!bound) {
8793
9345
  pass.setBindGroup(0, view.perFrame);
8794
9346
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
@@ -8848,11 +9400,140 @@ export class Engine {
8848
9400
  }
8849
9401
  renderModelOpaquePhase(pass, inst, view) {
8850
9402
  this.setModelDrawState(pass, inst);
8851
- this.drawMaterials(pass, inst, "opaque", view);
9403
+ // Depth first, colour second — the close-up fix, and the oldest one there
9404
+ // is. See drawOpaqueDepthPrepass.
9405
+ this.drawOpaqueDepthPrepass(pass, inst, view);
9406
+ // The opaque author order, in two walks with the hair prime between them.
9407
+ //
9408
+ // Hair could not join the plain prepass: primed hair depth would depth-
9409
+ // reject the eye before it writes the stencil the see-through-hair pass
9410
+ // needs. But the trick only needs the eye BEFORE hair, not before
9411
+ // everything — so the non-hair walk runs first (the eye writes stencil
9412
+ // against real face depth, exactly as it always did), the prime then lays
9413
+ // hair depth down stencil-fenced off the eye silhouette, and the hair walk
9414
+ // shades once per pixel instead of once per card.
9415
+ //
9416
+ // The one thing this reorders: hair now draws after any opaque material
9417
+ // authored later than it. A soft hair edge over such a material blends
9418
+ // over the material instead of over whatever the framebuffer held mid-
9419
+ // order — deterministic where it used to be accidental, and only at
9420
+ // sub-alpha edge texels over late-authored geometry.
9421
+ this.drawMaterials(pass, inst, "opaque", view, "non-hair");
9422
+ this.drawHairDepthPrime(pass, inst, view);
9423
+ this.drawMaterials(pass, inst, "opaque", view, "hair");
8852
9424
  this.drawHairOverEyes(pass, inst, view);
8853
9425
  }
9426
+ /** Depth-only prime of the hair's alpha-1 texels, stencil-fenced off the eye
9427
+ * silhouette. See the note at its call site and hairPrimePipeline. */
9428
+ drawHairDepthPrime(pass, inst, view) {
9429
+ let bound = false;
9430
+ for (const draw of inst.drawCalls) {
9431
+ if (draw.type !== "opaque" || !this.isHairDraw(inst, draw))
9432
+ continue;
9433
+ if (!bound) {
9434
+ pass.setPipeline(this.hairPrimePipeline);
9435
+ pass.setBindGroup(0, view.perFrame);
9436
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
9437
+ bound = true;
9438
+ }
9439
+ pass.setBindGroup(2, draw.bindGroup);
9440
+ this.issueDraw(pass, draw, view.args);
9441
+ }
9442
+ }
9443
+ /**
9444
+ * Depth-only prime of the plain opaque draws, so each covered pixel SHADES
9445
+ * once instead of once per layer.
9446
+ *
9447
+ * The oldest fps complaint this engine has — zoom close and the frame drops,
9448
+ * in every material generation back to the earliest — was never the vertices
9449
+ * and never one shader's fault: with the fragment shaders flattened to a
9450
+ * constant the close-up ran smooth with identical geometry, overdraw and
9451
+ * MSAA. The cost is per-fragment shading TIMES how many times a pixel runs
9452
+ * it, and an MMD model at close-up is layers all the way down: cloth over
9453
+ * body, sleeves over cloth, hair over everything. Author-order drawing
9454
+ * shades every layer and then buries all but one.
9455
+ *
9456
+ * So the plain opaque draws lay their depth down first, through the same
9457
+ * depth-only pipeline the transparent bucket keeps for its dormant prepass —
9458
+ * same skinned vertex path (position marked @invariant in both modules, so
9459
+ * the colour pass lands on exactly these depths and its less-equal test
9460
+ * keeps the visible surface and rejects the buried ones), same alpha-0.5
9461
+ * cutout, writeMask 0 on every colour target. The pixels are identical by
9462
+ * construction: this pass writes no colour, and the colour pass draws
9463
+ * exactly what it always drew minus the fragments something opaque provably
9464
+ * covers.
9465
+ *
9466
+ * WHO IS IN. Only render-class "auto" with alpha-mode "opaque" — the body,
9467
+ * face and cloth materials that are the bulk of every model — plus every
9468
+ * ungrouped material (the neutral pipeline is that same class). WHO IS OUT,
9469
+ * each for a reason that would change pixels: EYE front-culls and gates on a
9470
+ * bone read, and pre-filled hair depth over the socket would depth-reject
9471
+ * the eye before it could write the stencil the see-through-hair pass needs
9472
+ * — which is also why HAIR stays out entirely. HASHED alpha (stockings)
9473
+ * discards by a position hash this pass does not run, so priming it would
9474
+ * punch its cutout into the depth buffer at the wrong texels. They all still
9475
+ * BENEFIT: their fragments early-z against the primed depth of whatever
9476
+ * plain opaque surface sits in front of them.
9477
+ */
9478
+ drawOpaqueDepthPrepass(pass, inst, view) {
9479
+ let bound = false;
9480
+ for (const draw of inst.drawCalls) {
9481
+ if (draw.type !== "opaque")
9482
+ continue;
9483
+ if (draw.groupId) {
9484
+ const install = inst.styleGroups.get(draw.groupId);
9485
+ if (install && !(install.renderClass === "auto" && install.alphaMode === "opaque"))
9486
+ continue;
9487
+ }
9488
+ if (!bound) {
9489
+ pass.setPipeline(this.depthPrepassPipeline);
9490
+ pass.setBindGroup(0, view.perFrame);
9491
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
9492
+ bound = true;
9493
+ }
9494
+ pass.setBindGroup(2, draw.bindGroup);
9495
+ this.issueDraw(pass, draw, view.args);
9496
+ }
9497
+ }
9498
+ /**
9499
+ * Depth-only prime of the transparent bucket's FULLY SOLID texels.
9500
+ *
9501
+ * The dress problem. A "transparent" MMD material is mostly weave at alpha
9502
+ * exactly 1 with sheer margins, and its layers draw in author order — so a
9503
+ * close-up skirt shades every buried panel and then covers the work. The
9504
+ * buried SHEER fragments must shade (their blend reads what is behind), but
9505
+ * at alpha 1 over-blending is plain replacement: the destination cannot
9506
+ * matter, so a fragment buried behind an alpha-1 texel contributes nothing.
9507
+ * Priming depth for exactly those texels (CUTOFF 1.0) rejects the buried
9508
+ * work and cannot move a pixel.
9509
+ *
9510
+ * A STAGE's transparent draws are excluded the way their colour path already
9511
+ * is: stage glass deliberately leaves depth alone so rain and particles
9512
+ * survive behind a dome (see pipelineForDrawCall), and a prime would put the
9513
+ * occlusion right back.
9514
+ */
9515
+ drawTransparentSolidPrepass(pass, inst, view) {
9516
+ if (inst.isStage)
9517
+ return;
9518
+ let bound = false;
9519
+ for (const draw of inst.drawCalls) {
9520
+ if (draw.type !== "transparent")
9521
+ continue;
9522
+ if (!bound) {
9523
+ pass.setPipeline(this.solidPrepassPipeline);
9524
+ pass.setBindGroup(0, view.perFrame);
9525
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
9526
+ bound = true;
9527
+ }
9528
+ pass.setBindGroup(2, draw.bindGroup);
9529
+ this.issueDraw(pass, draw, view.args);
9530
+ }
9531
+ }
8854
9532
  renderModelTransparentPhase(pass, inst, view) {
9533
+ // Draw state FIRST — each phase records into its own bundle encoder, and a
9534
+ // bundle starts with nothing bound.
8855
9535
  this.setModelDrawState(pass, inst);
9536
+ this.drawTransparentSolidPrepass(pass, inst, view);
8856
9537
  // Transparent: babylon-mmd's forceDepthWrite blending — PMX author order
8857
9538
  // with depth write ON. The accepted trade-off after trying every variant:
8858
9539
  // · depth-write ON (this): a fold hides its far side; rare view-dependent
@@ -8872,7 +9553,7 @@ export class Engine {
8872
9553
  if (draw.type !== "transparent")
8873
9554
  continue;
8874
9555
  if (!bound) {
8875
- pass.setPipeline(this.transparentDepthPrepassPipeline);
9556
+ pass.setPipeline(this.depthPrepassPipeline);
8876
9557
  pass.setBindGroup(0, this.perFrameBindGroup);
8877
9558
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
8878
9559
  bound = true;
@@ -9019,6 +9700,10 @@ export class Engine {
9019
9700
  n++;
9020
9701
  });
9021
9702
  u[43] = n;
9703
+ // The same number the ribbons size their instance count by — see
9704
+ // drawTrails. Recorded rather than recomputed: this loop is the one place
9705
+ // that knows how many subjects the cast actually ended up holding.
9706
+ this.castSubjectCount = n;
9022
9707
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
9023
9708
  // Only what an effect declared, and only while one is installed. A scene
9024
9709
  // with no effect writes nothing here at all.
@@ -9308,6 +9993,23 @@ Engine.GIZMO_WORLD_SIZE = 1.5;
9308
9993
  Engine.GIZMO_THICKNESS_PX = 15.0;
9309
9994
  Engine.GIZMO_PICK_THRESHOLD_PX = 17.0;
9310
9995
  Engine.MULTISAMPLE_COUNT = 4;
9996
+ /**
9997
+ * Shadow map depth format — 16-bit, deliberately.
9998
+ *
9999
+ * The maps are ORTHOGRAPHIC, so depth is linear across the box: 65,536 steps
10000
+ * over the near cascade's 140-unit range is 0.002 units per step, and every
10001
+ * bias in play dwarfs it — the samplers subtract 0.0035 ndc (~229 of these
10002
+ * steps) and the materials offset along the normal by 0.08 units (~37 steps)
10003
+ * before the compare ever runs. Quantisation cannot flip an answer the biases
10004
+ * have already moved that far, so the pixels are identical to depth32float's.
10005
+ *
10006
+ * What is NOT identical is the bandwidth, which is the term WebKit pays
10007
+ * hardest: every PCF tap is a hardware-bilinear compare reading four texels,
10008
+ * so nine taps read half the bytes at 2 B/texel — 72 B/pixel instead of 144
10009
+ * across every shadowed surface on screen — and the 4096² map's clear+store
10010
+ * each frame drops from 64 MB to 32.
10011
+ */
10012
+ Engine.SHADOW_DEPTH_FORMAT = "depth16unorm";
9311
10013
  /**
9312
10014
  * Force the HDR format instead of taking the device's answer. Null = probe,
9313
10015
  * which is what ships.
@@ -9400,7 +10102,34 @@ Engine.FIELD_SCALES = [1, 2];
9400
10102
  * field restructure moves. Restructuring it while it was the only untimed
9401
10103
  * pass in the frame would have meant reasoning about the cost instead of
9402
10104
  * reading it. */
9403
- Engine.TIMED_PASSES = ["cull", "shadow", "scene", "field", "composite"];
10105
+ /**
10106
+ * The passes worth a number, in the order the frame runs them.
10107
+ *
10108
+ * These ARE the boxes on the architecture figure, deliberately: a reading that
10109
+ * cannot be pointed at a component is a reading nobody acts on. Three were
10110
+ * missing and each is a real per-frame cost a report of "it feels slower"
10111
+ * could have been about — the morph compute, the mirror's second pass over the
10112
+ * whole cast, and the bloom pyramid, which is NINE render passes and was the
10113
+ * largest unmeasured thing in the frame.
10114
+ *
10115
+ * The per-effect computes (particles, grids, lights) are deliberately absent:
10116
+ * they are a loop of one pass per effect, so there is no single span to stamp
10117
+ * and a number attributed to the wrong one is worse than no number. They fall
10118
+ * into the "rest" the readout derives from the frame time.
10119
+ *
10120
+ * Adding one costs two query slots and nothing else; the query set is sized
10121
+ * from this array's length.
10122
+ */
10123
+ Engine.TIMED_PASSES = [
10124
+ "cull",
10125
+ "morph",
10126
+ "shadow",
10127
+ "mirror",
10128
+ "scene",
10129
+ "field",
10130
+ "bloom",
10131
+ "composite",
10132
+ ];
9404
10133
  // ── The floor mirror (step 7C) ──
9405
10134
  // Half-res scene-contract attachments a mirrored draw renders into, plus the
9406
10135
  // mirror's own camera block. The plane is the ground plane: MMD floors live