reze-engine 0.50.3 → 0.50.5

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 (105) hide show
  1. package/dist/engine.d.ts +247 -2
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +822 -64
  4. package/dist/model.d.ts +49 -1
  5. package/dist/model.d.ts.map +1 -1
  6. package/dist/model.js +160 -4
  7. package/dist/physics/autofit.d.ts +147 -0
  8. package/dist/physics/autofit.d.ts.map +1 -0
  9. package/dist/physics/autofit.js +501 -0
  10. package/dist/physics/physics.d.ts +35 -0
  11. package/dist/physics/physics.d.ts.map +1 -1
  12. package/dist/physics/physics.js +64 -0
  13. package/dist/physics/world.d.ts +4 -0
  14. package/dist/physics/world.d.ts.map +1 -1
  15. package/dist/physics/world.js +6 -0
  16. package/dist/shaders/cast-api.d.ts +1 -1
  17. package/dist/shaders/cast-api.d.ts.map +1 -1
  18. package/dist/shaders/cast-layout.d.ts +44 -1
  19. package/dist/shaders/cast-layout.d.ts.map +1 -1
  20. package/dist/shaders/cast-layout.js +44 -1
  21. package/dist/shaders/materials/common.d.ts.map +1 -1
  22. package/dist/shaders/materials/common.js +7 -1
  23. package/dist/shaders/materials/nodes.d.ts +1 -1
  24. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  25. package/dist/shaders/materials/nodes.js +17 -9
  26. package/dist/shaders/passes/composite.d.ts +1 -1
  27. package/dist/shaders/passes/composite.d.ts.map +1 -1
  28. package/dist/shaders/passes/depth-prepass.d.ts +1 -1
  29. package/dist/shaders/passes/depth-prepass.d.ts.map +1 -1
  30. package/dist/shaders/passes/depth-prepass.js +52 -12
  31. package/dist/shaders/passes/field-blit.d.ts +26 -0
  32. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  33. package/dist/shaders/passes/field-blit.js +65 -0
  34. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  35. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  36. package/dist/shaders/passes/ground-noise.js +88 -0
  37. package/dist/shaders/passes/ground.d.ts +16 -0
  38. package/dist/shaders/passes/ground.d.ts.map +1 -1
  39. package/dist/shaders/passes/ground.js +131 -27
  40. package/dist/shaders/passes/outline.d.ts +1 -1
  41. package/dist/shaders/passes/outline.d.ts.map +1 -1
  42. package/dist/shaders/passes/outline.js +12 -3
  43. package/dist/shaders/passes/particles.d.ts.map +1 -1
  44. package/dist/shaders/passes/particles.js +6 -2
  45. package/dist/shaders/passes/scene-contract.d.ts +38 -6
  46. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  47. package/dist/shaders/passes/scene-contract.js +53 -16
  48. package/dist/shaders/passes/sim.d.ts +34 -0
  49. package/dist/shaders/passes/sim.d.ts.map +1 -0
  50. package/dist/shaders/passes/sim.js +169 -0
  51. package/dist/shaders/passes/trails.d.ts.map +1 -1
  52. package/dist/shaders/passes/trails.js +36 -8
  53. package/dist/shaders/score-api.d.ts +10 -0
  54. package/dist/shaders/score-api.d.ts.map +1 -0
  55. package/dist/shaders/score-api.js +114 -0
  56. package/package.json +1 -1
  57. package/src/engine.ts +867 -56
  58. package/src/model.ts +163 -4
  59. package/src/physics/physics.ts +63 -0
  60. package/src/physics/world.ts +7 -0
  61. package/src/shaders/cast-layout.ts +44 -1
  62. package/src/shaders/materials/common.ts +7 -1
  63. package/src/shaders/materials/nodes.ts +17 -9
  64. package/src/shaders/passes/depth-prepass.ts +53 -12
  65. package/src/shaders/passes/ground.ts +133 -27
  66. package/src/shaders/passes/outline.ts +14 -3
  67. package/src/shaders/passes/particles.ts +6 -2
  68. package/src/shaders/passes/scene-contract.ts +55 -16
  69. package/src/shaders/passes/trails.ts +36 -8
  70. package/dist/physics-debug.d.ts +0 -30
  71. package/dist/physics-debug.d.ts.map +0 -1
  72. package/dist/physics-debug.js +0 -526
  73. package/dist/shaders/materials/body.d.ts +0 -2
  74. package/dist/shaders/materials/body.d.ts.map +0 -1
  75. package/dist/shaders/materials/body.js +0 -95
  76. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  77. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  78. package/dist/shaders/materials/cloth_rough.js +0 -69
  79. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  80. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  81. package/dist/shaders/materials/cloth_smooth.js +0 -61
  82. package/dist/shaders/materials/default.d.ts +0 -2
  83. package/dist/shaders/materials/default.d.ts.map +0 -1
  84. package/dist/shaders/materials/default.js +0 -43
  85. package/dist/shaders/materials/eye.d.ts +0 -2
  86. package/dist/shaders/materials/eye.d.ts.map +0 -1
  87. package/dist/shaders/materials/eye.js +0 -60
  88. package/dist/shaders/materials/face.d.ts +0 -2
  89. package/dist/shaders/materials/face.d.ts.map +0 -1
  90. package/dist/shaders/materials/face.js +0 -95
  91. package/dist/shaders/materials/hair.d.ts +0 -2
  92. package/dist/shaders/materials/hair.d.ts.map +0 -1
  93. package/dist/shaders/materials/hair.js +0 -90
  94. package/dist/shaders/materials/metal.d.ts +0 -2
  95. package/dist/shaders/materials/metal.d.ts.map +0 -1
  96. package/dist/shaders/materials/metal.js +0 -77
  97. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  98. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  99. package/dist/shaders/materials/mmd_classic.js +0 -66
  100. package/dist/shaders/materials/stockings.d.ts +0 -2
  101. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  102. package/dist/shaders/materials/stockings.js +0 -122
  103. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  104. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  105. 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)
@@ -6068,6 +6335,10 @@ export class Engine {
6068
6335
  if (inst.physics && this.physicsEnabled && inst.model.visible) {
6069
6336
  const tPhys = performance.now();
6070
6337
  inst.physics.step(deltaTime, inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices());
6338
+ // The step published new world matrices for the simulated bones; the
6339
+ // bones that INHERIT from them are still wearing the animated pose.
6340
+ // Returns immediately unless this rig actually has such a bone.
6341
+ inst.model.applyPhysicsAppend();
6071
6342
  physicsMs += performance.now() - tPhys;
6072
6343
  }
6073
6344
  if (inst.vertexBufferNeedsUpdate)
@@ -6112,7 +6383,7 @@ export class Engine {
6112
6383
  if (!gm || !gm.dispatchNeeded)
6113
6384
  continue;
6114
6385
  if (!pass) {
6115
- pass = encoder.beginComputePass({ label: "morph compute" });
6386
+ pass = encoder.beginComputePass({ label: "morph compute", timestampWrites: this.stamps("morph") });
6116
6387
  pass.setPipeline(this.morphComputePipeline);
6117
6388
  }
6118
6389
  pass.setBindGroup(0, gm.bindGroup);
@@ -6347,6 +6618,106 @@ export class Engine {
6347
6618
  }
6348
6619
  if (this.cullModelBuffer)
6349
6620
  this.device.queue.writeBuffer(this.cullModelBuffer, 0, data.buffer);
6621
+ this.updateCasterSphere(data);
6622
+ }
6623
+ /**
6624
+ * One sphere containing every shadow caster in the scene, for the ground.
6625
+ *
6626
+ * The ground's PCF is the most expensive thing in the frame on a tile-based
6627
+ * GPU — nine hardware-bilinear comparisons per pixel on a full-coverage draw,
6628
+ * which is what 0.33.2 was about and what a second cascade quietly undid. But
6629
+ * the floor is vastly larger than the thing standing on it, and a pixel the
6630
+ * character cannot possibly shadow does not need to ask the shadow map: the
6631
+ * answer is lit, and nine taps is an expensive way to spell it.
6632
+ *
6633
+ * So the ground gets a bound and tests against it in ALU. This reuses the
6634
+ * spheres the cull already builds every frame — an AABB over POSED bone
6635
+ * positions grown by the skin margin, which its own note calls a bound rather
6636
+ * than an estimate, so a jump or a physics-driven skirt is inside it by
6637
+ * construction. Union, not per model: one sphere is one test, and the ground
6638
+ * shader must not loop over the cast.
6639
+ *
6640
+ * A RIGID caster (a stage) leaves its cull sphere zeroed deliberately — the
6641
+ * cull reads its boxes instead — so any rigid model disables this entirely by
6642
+ * setting radius to -1. Wrong here is a missing shadow, and a scene with a
6643
+ * stage keeps the taps rather than risk one.
6644
+ */
6645
+ updateCasterSphere(data) {
6646
+ const out = this.casterSphere;
6647
+ out[3] = 0;
6648
+ let cx = 0;
6649
+ let cy = 0;
6650
+ let cz = 0;
6651
+ let r = 0;
6652
+ let any = false;
6653
+ for (let i = 0; i < this.cullModels.length; i++) {
6654
+ const inst = this.cullModels[i];
6655
+ if (!inst.model.visible || inst.shadowDrawCalls.length === 0)
6656
+ continue;
6657
+ if (inst.rigid) {
6658
+ // No sphere to read. Bail out of the whole optimisation.
6659
+ out[3] = -1;
6660
+ return;
6661
+ }
6662
+ const o = i * Engine.CULL_MODEL_FLOATS + 16;
6663
+ const x = data[o];
6664
+ const y = data[o + 1];
6665
+ const z = data[o + 2];
6666
+ const rad = data[o + 3];
6667
+ if (rad <= 0)
6668
+ continue;
6669
+ if (!any) {
6670
+ cx = x;
6671
+ cy = y;
6672
+ cz = z;
6673
+ r = rad;
6674
+ any = true;
6675
+ continue;
6676
+ }
6677
+ // Union of two spheres, the standard construction: if one already contains
6678
+ // the other keep it, else grow along the line between the centres.
6679
+ const dx = x - cx;
6680
+ const dy = y - cy;
6681
+ const dz = z - cz;
6682
+ const d = Math.hypot(dx, dy, dz);
6683
+ if (d + rad <= r)
6684
+ continue;
6685
+ if (d + r <= rad) {
6686
+ cx = x;
6687
+ cy = y;
6688
+ cz = z;
6689
+ r = rad;
6690
+ continue;
6691
+ }
6692
+ const nr = (d + r + rad) * 0.5;
6693
+ const t = (nr - r) / d;
6694
+ cx += dx * t;
6695
+ cy += dy * t;
6696
+ cz += dz * t;
6697
+ r = nr;
6698
+ }
6699
+ out[0] = cx;
6700
+ out[1] = cy;
6701
+ out[2] = cz;
6702
+ out[3] = any ? r : 0;
6703
+ }
6704
+ /**
6705
+ * Push this frame's caster sphere into the ground's uniform.
6706
+ *
6707
+ * Four floats, one writeBuffer, and only while a ground exists. Rebuilding the
6708
+ * block the way addGround does would allocate a buffer and a bind group per
6709
+ * frame, which is the cost this is trying to remove rather than a way to pay
6710
+ * it somewhere else.
6711
+ */
6712
+ writeGroundCasterSphere() {
6713
+ const gb = this.groundMaterialData;
6714
+ if (!gb || !this.groundShadowMaterialBuffer)
6715
+ return;
6716
+ if (gb[20] === this.casterSphere[0] && gb[21] === this.casterSphere[1] &&
6717
+ gb[22] === this.casterSphere[2] && gb[23] === this.casterSphere[3])
6718
+ return;
6719
+ gb.set(this.casterSphere, 20);
6720
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 80, this.casterSphere);
6350
6721
  }
6351
6722
  /**
6352
6723
  * The world sphere for a skinned model: an AABB over its POSED bone positions,
@@ -6488,7 +6859,6 @@ export class Engine {
6488
6859
  };
6489
6860
  if (this.modelInstances.size === 0) {
6490
6861
  this.opaqueBundle = null;
6491
- this.transparentBundle = null;
6492
6862
  this.mirrorOpaqueBundle = null;
6493
6863
  this.mirrorTransparentBundle = null;
6494
6864
  this.shadowBundles = [];
@@ -6498,13 +6868,15 @@ export class Engine {
6498
6868
  const opaque = this.device.createRenderBundleEncoder({ label: "opaque phase", ...scene });
6499
6869
  this.forEachInstance((inst) => this.renderModelOpaquePhase(opaque, inst, camView));
6500
6870
  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.
6871
+ // NO camera transparent bundle. The camera pass draws that phase directly —
6872
+ // see the note at the executeBundles call for what recording one cost on
6873
+ // WebKit. Recording it anyway "in case" is not free and not harmless: it is
6874
+ // work on every rebuild, and a live bundle beside a direct draw of the same
6875
+ // phase is an invitation to execute it again.
6876
+ //
6877
+ // The MIRROR pair below keeps both bundles, and is allowed to: that pass
6878
+ // hands them to a single executeBundles with nothing direct in between,
6879
+ // which is the pattern that works.
6508
6880
  const mirrorView = this.sceneView("mirror");
6509
6881
  const mo = this.device.createRenderBundleEncoder({ label: "mirror opaque phase", ...scene });
6510
6882
  this.forEachInstance((inst) => this.renderModelOpaquePhase(mo, inst, mirrorView));
@@ -6520,7 +6892,7 @@ export class Engine {
6520
6892
  const shadow = this.device.createRenderBundleEncoder({
6521
6893
  label: `shadow pass, cascade ${ci}`,
6522
6894
  colorFormats: [],
6523
- depthStencilFormat: "depth32float",
6895
+ depthStencilFormat: Engine.SHADOW_DEPTH_FORMAT,
6524
6896
  });
6525
6897
  shadow.setPipeline(this.shadowDepthPipeline);
6526
6898
  this.forEachInstance((inst) => this.drawInstanceShadow(shadow, inst, ci));
@@ -6536,6 +6908,25 @@ export class Engine {
6536
6908
  const i = Engine.TIMED_PASSES.indexOf(pass);
6537
6909
  return { querySet: this.timestampQuerySet, beginningOfPassWriteIndex: i * 2, endOfPassWriteIndex: i * 2 + 1 };
6538
6910
  }
6911
+ /**
6912
+ * Half a stamp, for a component that is several passes rather than one.
6913
+ *
6914
+ * Bloom is nine render passes — a prefilter blit, a downsample chain and an
6915
+ * upsample chain — and what anyone wants to know is what the PYRAMID cost, not
6916
+ * what its fourth mip cost. Both fields of GPURenderPassTimestampWrites are
6917
+ * optional, so the opening query goes on the first pass and the closing one on
6918
+ * the last, and the pair reads as one span across everything between.
6919
+ */
6920
+ stampOpen(pass) {
6921
+ if (!this.timestampQuerySet)
6922
+ return undefined;
6923
+ return { querySet: this.timestampQuerySet, beginningOfPassWriteIndex: Engine.TIMED_PASSES.indexOf(pass) * 2 };
6924
+ }
6925
+ stampClose(pass) {
6926
+ if (!this.timestampQuerySet)
6927
+ return undefined;
6928
+ return { querySet: this.timestampQuerySet, endOfPassWriteIndex: Engine.TIMED_PASSES.indexOf(pass) * 2 + 1 };
6929
+ }
6539
6930
  /**
6540
6931
  * Resolve this frame's timings and start a readback, at most one in flight.
6541
6932
  *
@@ -6548,6 +6939,9 @@ export class Engine {
6548
6939
  const qs = this.timestampQuerySet;
6549
6940
  if (!qs || !this.timestampResolve || !this.timestampRead)
6550
6941
  return;
6942
+ // Nobody has asked. See getGpuTimings — the read is what enrols.
6943
+ if (!this.timestampsWanted)
6944
+ return;
6551
6945
  const count = Engine.TIMED_PASSES.length * 2;
6552
6946
  encoder.resolveQuerySet(qs, 0, count, this.timestampResolve, 0);
6553
6947
  if (this.timestampBusy)
@@ -6588,8 +6982,21 @@ export class Engine {
6588
6982
  * The regression guard for the draw-path work: these are the numbers that say
6589
6983
  * whether restructuring cost anything, which is the claim being made — not
6590
6984
  * whether it made the scene faster, which was never the goal.
6985
+ *
6986
+ * ASKING IS WHAT TURNS IT ON. The first call to this enrols the engine in the
6987
+ * per-frame readback; until then resolveTimestamps does nothing. That is why
6988
+ * the first call returns null even on a device that can measure — the answer
6989
+ * arrives a frame or two later, which is already true of these numbers and
6990
+ * documented on resolveTimestamps.
6991
+ *
6992
+ * The alternative was what this used to do: resolve the query set, copy it to
6993
+ * a staging buffer and map that buffer, every frame, on every device, for a
6994
+ * reader that in this codebase did not exist. A map is a synchronisation point
6995
+ * and the whole path is instrumentation — paying for it unasked is the same
6996
+ * mistake as shipping a debug flag, only invisible.
6591
6997
  */
6592
6998
  getGpuTimings() {
6999
+ this.timestampsWanted = true;
6593
7000
  return this.gpuPassMs;
6594
7001
  }
6595
7002
  dispatchCull(encoder) {
@@ -6887,6 +7294,21 @@ export class Engine {
6887
7294
  // solver for the heaviest mesh in the scene and dropping it afterwards was
6888
7295
  // both wasted work and an invariant maintained in the wrong place.
6889
7296
  const physics = !isStage && rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null;
7297
+ // Which bones the simulation will overwrite, handed to the pose pipeline so
7298
+ // the append (付与) pass can consume the simulated result instead of the
7299
+ // animated one. Precomputed here, once, because the answer is topology —
7300
+ // see Model.setPhysicsDrivenBones for what it costs when a rig needs it and
7301
+ // why it costs nothing when none does.
7302
+ if (physics) {
7303
+ model.setPhysicsDrivenBones(physics.getPhysicsDrivenBones());
7304
+ // The bodies an inherited-from bone rides on are damped less than the
7305
+ // rest, so they swing longer WITHOUT hanging lower — see
7306
+ // RezePhysics.setJiggleDamping for why damping is the separable knob and
7307
+ // solver iterations are not.
7308
+ const appendSources = model.getAppendSourceBones();
7309
+ if (appendSources.length > 0)
7310
+ physics.setJiggleDamping(appendSources, Engine.JIGGLE_DAMPING_SCALE);
7311
+ }
6890
7312
  // Adopt the scene's air, or a model added mid-session would fall under
6891
7313
  // different gravity from the ones already on stage.
6892
7314
  if (physics) {
@@ -7097,7 +7519,8 @@ export class Engine {
7097
7519
  // Shadow map is already created in setupPipelines()
7098
7520
  // 20 floats: 16 for the original block, then (mirrorBlur, pad, pad, pad)
7099
7521
  // keeping the uniform vec4-aligned.
7100
- const gb = new Float32Array(20);
7522
+ const gb = new Float32Array(24);
7523
+ this.groundMaterialData = gb;
7101
7524
  gb[0] = diffuseColor.x;
7102
7525
  gb[1] = diffuseColor.y;
7103
7526
  gb[2] = diffuseColor.z;
@@ -7117,6 +7540,23 @@ export class Engine {
7117
7540
  this.groundMirror = gb[15];
7118
7541
  gb[16] = Math.min(Math.max(mirrorBlur, 0), 1);
7119
7542
  this.groundMirrorBlur = gb[16];
7543
+ // gb[17] — does the FAR cascade hold anything?
7544
+ //
7545
+ // It holds something only when a stage is loaded; that is what it exists for
7546
+ // and the cascade loop already skips drawing into it otherwise, leaving it
7547
+ // cleared. A cleared depth map compares as "no occluder", so the ground's far
7548
+ // branch is nine comparison taps whose answer is known in advance.
7549
+ //
7550
+ // That branch runs wherever the NEAR cascade does not reach, and the near one
7551
+ // is a 64-unit box around the camera target — so on a floor receding to the
7552
+ // horizon it is most of the visible pixels, on the most expensive
7553
+ // full-coverage draw in the frame. Skipping it is free in the exact sense:
7554
+ // the shader takes vis = 1.0, which is what the taps would have returned.
7555
+ gb[17] = this.hasStage() ? 1 : 0;
7556
+ // gb[20..23] — the caster sphere, refreshed every frame by
7557
+ // writeGroundCasterSphere. Zero here so a frame that renders before the
7558
+ // first cull (there is one) reads "nothing casts" and skips the taps, which
7559
+ // is true: no model has been posed yet.
7120
7560
  this.groundShadowMaterialBuffer = this.device.createBuffer({
7121
7561
  size: gb.byteLength,
7122
7562
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
@@ -7151,6 +7591,7 @@ export class Engine {
7151
7591
  { binding: 9, resource: this.mirrorColorView },
7152
7592
  { binding: 10, resource: this.materialSampler },
7153
7593
  { binding: 11, resource: this.mirrorDepthReadView },
7594
+ { binding: 12, resource: this.groundNoiseView },
7154
7595
  ],
7155
7596
  });
7156
7597
  if (this.groundDrawCall)
@@ -7620,7 +8061,17 @@ export class Engine {
7620
8061
  }
7621
8062
  // CPU alpha sampler for sheerness classification (see textureAlphaCache).
7622
8063
  // Canvas 2D premultiplies RGB on readback, but the ALPHA channel is exact.
7623
- this.textureAlphaCache.set(cacheKey, buildAlphaSampler(source, rgba, width, height));
8064
+ const alphaPlane = buildAlphaSampler(source, rgba, width, height);
8065
+ // Loud, because the fallback is WRONG rather than merely absent: a material
8066
+ // with no alpha plane scores avg 1 / translucentFrac 0, which routes sheer
8067
+ // fabric into the OPAQUE bucket and changes what the frame looks like. A
8068
+ // readback that fails is therefore a rendering bug, not a missing nicety,
8069
+ // and it must not reach the user as "the dress looks different on my phone".
8070
+ if (!alphaPlane) {
8071
+ console.warn(`[reze] alpha readback failed for ${cacheKey} — this material will be classified OPAQUE, ` +
8072
+ `so sheer fabric will not blend. The canvas 2D readback is what failed.`);
8073
+ }
8074
+ this.textureAlphaCache.set(cacheKey, alphaPlane);
7624
8075
  const mipLevelCount = Math.floor(Math.log2(Math.max(width, height))) + 1;
7625
8076
  const texture = this.device.createTexture({
7626
8077
  label: `texture: ${cacheKey}`,
@@ -8130,10 +8581,49 @@ export class Engine {
8130
8581
  const dofOn = this.depthOfField.enabled;
8131
8582
  // ANY effect: one foreground mount anywhere in the scene, or one ribbon,
8132
8583
  // 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);
8584
+ // Ribbons are NOT in this list, and removing them is the single largest
8585
+ // bandwidth saving in the frame on a tile-based GPU.
8586
+ //
8587
+ // They were, from when a ribbon was its own layer drawn after the scene and
8588
+ // depth-tested BY HAND against the stored buffer. That layer is gone —
8589
+ // ribbons draw inside this pass and the hardware depth test replaced what
8590
+ // they read it for (see trails.ts, "Binding 3 is GONE"). The clause outlived
8591
+ // the change by about twelve hours and then sat here.
8592
+ //
8593
+ // What it cost: this flag decides whether the pass STORES its depth or
8594
+ // discards it into tile memory, and the buffer is depth32float-stencil8 at
8595
+ // the pass's sample count — on a retina canvas that is a nine-figure number
8596
+ // of bytes written to RAM every frame, for a texture nothing then sampled.
8597
+ // Chrome hides it (an immediate-mode GPU has depth in memory regardless);
8598
+ // Apple's TBDR does not, which is exactly the reported shape: adding a hand
8599
+ // ribbon costs a lot of fps on Safari and almost nothing on Chrome.
8600
+ //
8601
+ // The two real readers are both in the composite and both have their own
8602
+ // flag above: linearDepth() feeds the DoF gather and the depth handed to a
8603
+ // foreground mount. Nothing else binds depthTex at all.
8604
+ const depthRead = dofOn || this.effects.some((e) => e.hasForeground);
8134
8605
  this.renderPassDescriptor.depthStencilAttachment.depthStoreOp = depthRead ? "store" : "discard";
8135
8606
  if (depthRead)
8136
8607
  this.writeDepthOfFieldUniforms();
8608
+ // The id attachment, on exactly the same terms as the depth above it.
8609
+ //
8610
+ // It is the most expensive STORE in the pass — rg16uint at the pass's sample
8611
+ // count, ~33MB a frame at 1080p — and a uint target cannot be resolved, so
8612
+ // storing is the only way to get it out. It was stored unconditionally, for
8613
+ // every scene, whether or not anything read it. Nothing usually does: the
8614
+ // readers are rzObjectAt / rzMaterialAt in an effect that masks itself to one
8615
+ // character, and the id-buffer debug view.
8616
+ //
8617
+ // Discarding is not the same as removing. Every pipeline still declares the
8618
+ // attachment and the pass still carries it, so nothing is rebuilt and no
8619
+ // shader changes — the frame is bit-identical either way, because the only
8620
+ // difference is whether tile memory is written back to RAM after a pass
8621
+ // whose result no one is going to read.
8622
+ const idAtt = this.renderPassDescriptor.colorAttachments[2];
8623
+ if (idAtt) {
8624
+ const idsRead = this.idDebug || this.effects.some((e) => e.readsIds);
8625
+ idAtt.storeOp = idsRead ? "store" : "discard";
8626
+ }
8137
8627
  const encoder = this.device.createCommandEncoder();
8138
8628
  // GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
8139
8629
  // them. WebGPU inserts the storage→vertex barrier between this pass and the render passes.
@@ -8145,6 +8635,8 @@ export class Engine {
8145
8635
  this.updateMirrorCamera();
8146
8636
  if (hasModels)
8147
8637
  this.dispatchCull(encoder);
8638
+ // After the cull, which is what recomputes the spheres it unions.
8639
+ this.writeGroundCasterSphere();
8148
8640
  // After the cull, because a rebuild there can reallocate the argument
8149
8641
  // buffers and a bundle captures the buffer it recorded against.
8150
8642
  if (this.bundlesDirty)
@@ -8155,7 +8647,26 @@ export class Engine {
8155
8647
  // keeps PCF-sampling a character that is no longer in the scene. One clearing
8156
8648
  // pass on the transition to empty, then it stops.
8157
8649
  if (hasModels || this.shadowMapPopulated) {
8650
+ // The far cascade is the STAGE cascade, and it costs a full pass over the
8651
+ // whole cast every frame to say so. Its own spec explains what it is for —
8652
+ // "a set piece 100 units out still throws" — and a scene with no stage has
8653
+ // no set piece: every caster sits inside the near cascade's 64-unit box,
8654
+ // which follows the camera target, and the far map's only readers are
8655
+ // ground pixels beyond that box, where nothing is casting.
8656
+ //
8657
+ // So when no stage is loaded it is drawn ONCE, cleared, and then skipped —
8658
+ // the same shape as shadowMapPopulated above, and for the same reason. A
8659
+ // cleared depth map reads as "no occluder", which is the correct answer
8660
+ // here rather than a missing one. Load a stage and it comes straight back.
8661
+ //
8662
+ // 0.43 had ONE shadow map. This is half of what the second one costs.
8663
+ const stage = this.hasStage();
8158
8664
  for (let ci = 0; ci < SHADOW_CASCADES.length; ci++) {
8665
+ const wanted = ci === 0 || stage;
8666
+ // Already cleared and still unwanted — nothing to do, and the map still
8667
+ // holds the far plane from the pass that cleared it.
8668
+ if (!wanted && this.shadowCascadeCleared[ci])
8669
+ continue;
8159
8670
  const sp = encoder.beginRenderPass({
8160
8671
  // One timestamp pair exists for "shadow"; the near cascade wears it.
8161
8672
  timestampWrites: ci === 0 ? this.stamps("shadow") : undefined,
@@ -8171,9 +8682,10 @@ export class Engine {
8171
8682
  // per-frame boolean, and baking it into a bundle would make toggling a
8172
8683
  // model re-record. It lives in the cull compute now, which zeroes the
8173
8684
  // instance count of an invisible model's draws.
8174
- if (this.shadowBundles[ci])
8685
+ if (wanted && this.shadowBundles[ci])
8175
8686
  sp.executeBundles([this.shadowBundles[ci]]);
8176
8687
  sp.end();
8688
+ this.shadowCascadeCleared[ci] = !wanted;
8177
8689
  }
8178
8690
  this.shadowMapPopulated = hasModels;
8179
8691
  }
@@ -8203,10 +8715,45 @@ export class Engine {
8203
8715
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
8204
8716
  if (this.opaqueBundle)
8205
8717
  pass.executeBundles([this.opaqueBundle]);
8718
+ // Re-asserted after the bundle, not merely set once before it.
8719
+ //
8720
+ // Stencil reference is pass state a bundle cannot carry — GPURenderBundleEncoder
8721
+ // has no setStencilReference — which is why it was hoisted above the bundle in
8722
+ // the first place. But "cannot carry" and "cannot disturb" are different
8723
+ // claims, and only the first is specified. Everything below this line that
8724
+ // stencil-tests (hair at not-equal, outline hulls at not-equal) reads a
8725
+ // reference of 0 instead of 1 if a replay resets it, and not-equal against 0
8726
+ // is FALSE for the cleared buffer — every such fragment silently rejected.
8727
+ // One redundant word against a whole class of invisible failure.
8728
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
8206
8729
  if (this.hasGround)
8207
8730
  this.renderGround(pass);
8208
- if (this.transparentBundle)
8209
- pass.executeBundles([this.transparentBundle]);
8731
+ // The transparent phase is drawn DIRECTLY, and must stay that way. It is the
8732
+ // one part of this pass that is not bundled, so the reason is worth keeping.
8733
+ //
8734
+ // It WAS a bundle, and on WebKit the entire transparent bucket vanished while
8735
+ // the opaque bucket and the ground rendered perfectly — sheer fabric simply
8736
+ // absent, with no validation error anywhere. It was not the fragments: with
8737
+ // alpha forced to 1 they still never appeared, the cull reported every draw
8738
+ // visible with its GPU and CPU halves agreeing, and a cast model's
8739
+ // transparent draws use the SAME pipeline, bind groups and depth state as its
8740
+ // opaque ones (pipelineForDrawCall, forceDepthWrite). Identical draws,
8741
+ // identical state, one bucket rendering.
8742
+ //
8743
+ // What differed was only how they reached the pass: the opaque bundle is the
8744
+ // FIRST executeBundles here, and the transparent one was the SECOND, issued
8745
+ // after direct commands (the ground). Legal, and correct on Dawn. Not
8746
+ // replayed on WebKit. The mirror pass is the counter-example that pins the
8747
+ // shape of it — it passes BOTH bundles to a single executeBundles with
8748
+ // nothing direct in between, and has never lost a draw.
8749
+ //
8750
+ // So the rule this pass now keeps: at most one executeBundles, and nothing
8751
+ // direct before it. Bundling this phase again means first moving the ground
8752
+ // into the opaque bundle so the two can go in one call, the way the mirror
8753
+ // does it. The saving that buys is CPU encode time over a handful of draws,
8754
+ // which was never this renderer's bottleneck.
8755
+ const camView = this.sceneView("camera");
8756
+ this.forEachInstance((inst) => this.renderModelTransparentPhase(pass, inst, camView));
8210
8757
  // Last in the pass: depth-tested against everything drawn above, so a
8211
8758
  // particle behind the character is simply hidden, and still inside the HDR
8212
8759
  // target so an `@bloom` effect reaches the pyramid below.
@@ -8226,16 +8773,24 @@ export class Engine {
8226
8773
  // 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
8227
8774
  // then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
8228
8775
  // Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
8229
- if (this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
8776
+ // bloomContributes() gates the whole pyramid, not just its intensity. The
8777
+ // composite still SAMPLES bloomUp[0] unconditionally, which is safe and
8778
+ // deliberate: it scales what it reads by the same effective intensity, so a
8779
+ // stale or never-written pyramid is multiplied by zero. Skipping the build
8780
+ // is therefore invisible in the frame and nine render passes cheaper.
8781
+ if (this.bloomContributes() && this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
8230
8782
  const bloomAtt = this.bloomPassDescriptor.colorAttachments;
8231
- // 1. Blit
8783
+ // 1. Blit — opens the pyramid's timing span. See stampOpen: the nine
8784
+ // passes below read as ONE component, which is the only useful grain.
8232
8785
  bloomAtt[0].view = this.bloomDownMipViews[0];
8786
+ this.bloomPassDescriptor.timestampWrites = this.stampOpen("bloom");
8233
8787
  const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor);
8234
8788
  pBlit.setPipeline(this.bloomBlitPipeline);
8235
8789
  pBlit.setBindGroup(0, this.bloomBlitBindGroup);
8236
8790
  pBlit.draw(3);
8237
8791
  pBlit.end();
8238
8792
  // 2. Downsample chain
8793
+ this.bloomPassDescriptor.timestampWrites = undefined;
8239
8794
  for (let i = 1; i < this.bloomMipCount; i++) {
8240
8795
  bloomAtt[0].view = this.bloomDownMipViews[i];
8241
8796
  const p = encoder.beginRenderPass(this.bloomPassDescriptor);
@@ -8250,6 +8805,8 @@ export class Engine {
8250
8805
  for (let k = 0; k < upSteps; k++) {
8251
8806
  const levelIdx = topIdx - k; // writes bloomUp[levelIdx]
8252
8807
  bloomAtt[0].view = this.bloomUpMipViews[levelIdx];
8808
+ // The LAST upsample closes the span opened on the blit.
8809
+ this.bloomPassDescriptor.timestampWrites = k === upSteps - 1 ? this.stampClose("bloom") : undefined;
8253
8810
  const p = encoder.beginRenderPass(this.bloomPassDescriptor);
8254
8811
  p.setPipeline(this.bloomUpsamplePipeline);
8255
8812
  p.setBindGroup(0, this.bloomUpsampleBindGroups[k]);
@@ -8783,12 +9340,26 @@ export class Engine {
8783
9340
  * makes outlines compose like MMD: every material drawn later in the author's
8784
9341
  * order covers earlier hulls, and each hull sits over everything drawn before it.
8785
9342
  */
8786
- drawMaterials(pass, inst, type, view) {
9343
+ /** Is this draw's compiled class "hair"? Ungrouped draws never are — the
9344
+ * neutral pipeline is the auto class. */
9345
+ isHairDraw(inst, dc) {
9346
+ if (!dc.groupId)
9347
+ return false;
9348
+ const install = inst.styleGroups.get(dc.groupId);
9349
+ return install?.renderClass === "hair";
9350
+ }
9351
+ drawMaterials(pass, inst, type, view,
9352
+ // The opaque phase walks its author order twice — non-hair, then hair — so
9353
+ // the hair depth prime can sit between the eye's stencil write and the hair
9354
+ // colour that must respect it. See renderModelOpaquePhase.
9355
+ only) {
8787
9356
  let currentPipeline = null;
8788
9357
  let bound = false;
8789
9358
  for (const draw of inst.drawCalls) {
8790
9359
  if (draw.type !== type)
8791
9360
  continue;
9361
+ if (only && (only === "hair") !== this.isHairDraw(inst, draw))
9362
+ continue;
8792
9363
  if (!bound) {
8793
9364
  pass.setBindGroup(0, view.perFrame);
8794
9365
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
@@ -8848,11 +9419,140 @@ export class Engine {
8848
9419
  }
8849
9420
  renderModelOpaquePhase(pass, inst, view) {
8850
9421
  this.setModelDrawState(pass, inst);
8851
- this.drawMaterials(pass, inst, "opaque", view);
9422
+ // Depth first, colour second — the close-up fix, and the oldest one there
9423
+ // is. See drawOpaqueDepthPrepass.
9424
+ this.drawOpaqueDepthPrepass(pass, inst, view);
9425
+ // The opaque author order, in two walks with the hair prime between them.
9426
+ //
9427
+ // Hair could not join the plain prepass: primed hair depth would depth-
9428
+ // reject the eye before it writes the stencil the see-through-hair pass
9429
+ // needs. But the trick only needs the eye BEFORE hair, not before
9430
+ // everything — so the non-hair walk runs first (the eye writes stencil
9431
+ // against real face depth, exactly as it always did), the prime then lays
9432
+ // hair depth down stencil-fenced off the eye silhouette, and the hair walk
9433
+ // shades once per pixel instead of once per card.
9434
+ //
9435
+ // The one thing this reorders: hair now draws after any opaque material
9436
+ // authored later than it. A soft hair edge over such a material blends
9437
+ // over the material instead of over whatever the framebuffer held mid-
9438
+ // order — deterministic where it used to be accidental, and only at
9439
+ // sub-alpha edge texels over late-authored geometry.
9440
+ this.drawMaterials(pass, inst, "opaque", view, "non-hair");
9441
+ this.drawHairDepthPrime(pass, inst, view);
9442
+ this.drawMaterials(pass, inst, "opaque", view, "hair");
8852
9443
  this.drawHairOverEyes(pass, inst, view);
8853
9444
  }
9445
+ /** Depth-only prime of the hair's alpha-1 texels, stencil-fenced off the eye
9446
+ * silhouette. See the note at its call site and hairPrimePipeline. */
9447
+ drawHairDepthPrime(pass, inst, view) {
9448
+ let bound = false;
9449
+ for (const draw of inst.drawCalls) {
9450
+ if (draw.type !== "opaque" || !this.isHairDraw(inst, draw))
9451
+ continue;
9452
+ if (!bound) {
9453
+ pass.setPipeline(this.hairPrimePipeline);
9454
+ pass.setBindGroup(0, view.perFrame);
9455
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
9456
+ bound = true;
9457
+ }
9458
+ pass.setBindGroup(2, draw.bindGroup);
9459
+ this.issueDraw(pass, draw, view.args);
9460
+ }
9461
+ }
9462
+ /**
9463
+ * Depth-only prime of the plain opaque draws, so each covered pixel SHADES
9464
+ * once instead of once per layer.
9465
+ *
9466
+ * The oldest fps complaint this engine has — zoom close and the frame drops,
9467
+ * in every material generation back to the earliest — was never the vertices
9468
+ * and never one shader's fault: with the fragment shaders flattened to a
9469
+ * constant the close-up ran smooth with identical geometry, overdraw and
9470
+ * MSAA. The cost is per-fragment shading TIMES how many times a pixel runs
9471
+ * it, and an MMD model at close-up is layers all the way down: cloth over
9472
+ * body, sleeves over cloth, hair over everything. Author-order drawing
9473
+ * shades every layer and then buries all but one.
9474
+ *
9475
+ * So the plain opaque draws lay their depth down first, through the same
9476
+ * depth-only pipeline the transparent bucket keeps for its dormant prepass —
9477
+ * same skinned vertex path (position marked @invariant in both modules, so
9478
+ * the colour pass lands on exactly these depths and its less-equal test
9479
+ * keeps the visible surface and rejects the buried ones), same alpha-0.5
9480
+ * cutout, writeMask 0 on every colour target. The pixels are identical by
9481
+ * construction: this pass writes no colour, and the colour pass draws
9482
+ * exactly what it always drew minus the fragments something opaque provably
9483
+ * covers.
9484
+ *
9485
+ * WHO IS IN. Only render-class "auto" with alpha-mode "opaque" — the body,
9486
+ * face and cloth materials that are the bulk of every model — plus every
9487
+ * ungrouped material (the neutral pipeline is that same class). WHO IS OUT,
9488
+ * each for a reason that would change pixels: EYE front-culls and gates on a
9489
+ * bone read, and pre-filled hair depth over the socket would depth-reject
9490
+ * the eye before it could write the stencil the see-through-hair pass needs
9491
+ * — which is also why HAIR stays out entirely. HASHED alpha (stockings)
9492
+ * discards by a position hash this pass does not run, so priming it would
9493
+ * punch its cutout into the depth buffer at the wrong texels. They all still
9494
+ * BENEFIT: their fragments early-z against the primed depth of whatever
9495
+ * plain opaque surface sits in front of them.
9496
+ */
9497
+ drawOpaqueDepthPrepass(pass, inst, view) {
9498
+ let bound = false;
9499
+ for (const draw of inst.drawCalls) {
9500
+ if (draw.type !== "opaque")
9501
+ continue;
9502
+ if (draw.groupId) {
9503
+ const install = inst.styleGroups.get(draw.groupId);
9504
+ if (install && !(install.renderClass === "auto" && install.alphaMode === "opaque"))
9505
+ continue;
9506
+ }
9507
+ if (!bound) {
9508
+ pass.setPipeline(this.depthPrepassPipeline);
9509
+ pass.setBindGroup(0, view.perFrame);
9510
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
9511
+ bound = true;
9512
+ }
9513
+ pass.setBindGroup(2, draw.bindGroup);
9514
+ this.issueDraw(pass, draw, view.args);
9515
+ }
9516
+ }
9517
+ /**
9518
+ * Depth-only prime of the transparent bucket's FULLY SOLID texels.
9519
+ *
9520
+ * The dress problem. A "transparent" MMD material is mostly weave at alpha
9521
+ * exactly 1 with sheer margins, and its layers draw in author order — so a
9522
+ * close-up skirt shades every buried panel and then covers the work. The
9523
+ * buried SHEER fragments must shade (their blend reads what is behind), but
9524
+ * at alpha 1 over-blending is plain replacement: the destination cannot
9525
+ * matter, so a fragment buried behind an alpha-1 texel contributes nothing.
9526
+ * Priming depth for exactly those texels (CUTOFF 1.0) rejects the buried
9527
+ * work and cannot move a pixel.
9528
+ *
9529
+ * A STAGE's transparent draws are excluded the way their colour path already
9530
+ * is: stage glass deliberately leaves depth alone so rain and particles
9531
+ * survive behind a dome (see pipelineForDrawCall), and a prime would put the
9532
+ * occlusion right back.
9533
+ */
9534
+ drawTransparentSolidPrepass(pass, inst, view) {
9535
+ if (inst.isStage)
9536
+ return;
9537
+ let bound = false;
9538
+ for (const draw of inst.drawCalls) {
9539
+ if (draw.type !== "transparent")
9540
+ continue;
9541
+ if (!bound) {
9542
+ pass.setPipeline(this.solidPrepassPipeline);
9543
+ pass.setBindGroup(0, view.perFrame);
9544
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
9545
+ bound = true;
9546
+ }
9547
+ pass.setBindGroup(2, draw.bindGroup);
9548
+ this.issueDraw(pass, draw, view.args);
9549
+ }
9550
+ }
8854
9551
  renderModelTransparentPhase(pass, inst, view) {
9552
+ // Draw state FIRST — each phase records into its own bundle encoder, and a
9553
+ // bundle starts with nothing bound.
8855
9554
  this.setModelDrawState(pass, inst);
9555
+ this.drawTransparentSolidPrepass(pass, inst, view);
8856
9556
  // Transparent: babylon-mmd's forceDepthWrite blending — PMX author order
8857
9557
  // with depth write ON. The accepted trade-off after trying every variant:
8858
9558
  // · depth-write ON (this): a fold hides its far side; rare view-dependent
@@ -8872,7 +9572,7 @@ export class Engine {
8872
9572
  if (draw.type !== "transparent")
8873
9573
  continue;
8874
9574
  if (!bound) {
8875
- pass.setPipeline(this.transparentDepthPrepassPipeline);
9575
+ pass.setPipeline(this.depthPrepassPipeline);
8876
9576
  pass.setBindGroup(0, this.perFrameBindGroup);
8877
9577
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
8878
9578
  bound = true;
@@ -9019,6 +9719,10 @@ export class Engine {
9019
9719
  n++;
9020
9720
  });
9021
9721
  u[43] = n;
9722
+ // The same number the ribbons size their instance count by — see
9723
+ // drawTrails. Recorded rather than recomputed: this loop is the one place
9724
+ // that knows how many subjects the cast actually ended up holding.
9725
+ this.castSubjectCount = n;
9022
9726
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
9023
9727
  // Only what an effect declared, and only while one is installed. A scene
9024
9728
  // with no effect writes nothing here at all.
@@ -9308,6 +10012,23 @@ Engine.GIZMO_WORLD_SIZE = 1.5;
9308
10012
  Engine.GIZMO_THICKNESS_PX = 15.0;
9309
10013
  Engine.GIZMO_PICK_THRESHOLD_PX = 17.0;
9310
10014
  Engine.MULTISAMPLE_COUNT = 4;
10015
+ /**
10016
+ * Shadow map depth format — 16-bit, deliberately.
10017
+ *
10018
+ * The maps are ORTHOGRAPHIC, so depth is linear across the box: 65,536 steps
10019
+ * over the near cascade's 140-unit range is 0.002 units per step, and every
10020
+ * bias in play dwarfs it — the samplers subtract 0.0035 ndc (~229 of these
10021
+ * steps) and the materials offset along the normal by 0.08 units (~37 steps)
10022
+ * before the compare ever runs. Quantisation cannot flip an answer the biases
10023
+ * have already moved that far, so the pixels are identical to depth32float's.
10024
+ *
10025
+ * What is NOT identical is the bandwidth, which is the term WebKit pays
10026
+ * hardest: every PCF tap is a hardware-bilinear compare reading four texels,
10027
+ * so nine taps read half the bytes at 2 B/texel — 72 B/pixel instead of 144
10028
+ * across every shadowed surface on screen — and the 4096² map's clear+store
10029
+ * each frame drops from 64 MB to 32.
10030
+ */
10031
+ Engine.SHADOW_DEPTH_FORMAT = "depth16unorm";
9311
10032
  /**
9312
10033
  * Force the HDR format instead of taking the device's answer. Null = probe,
9313
10034
  * which is what ships.
@@ -9356,6 +10077,16 @@ Engine.BLOOM_MASK_FORMAT = "rg8unorm";
9356
10077
  * this line, and the accessors then answer 0 rather than failing to compile.
9357
10078
  */
9358
10079
  Engine.MRT_IDS = true;
10080
+ /**
10081
+ * What fraction of its authored damping a chest rig's body keeps.
10082
+ *
10083
+ * The whole tuning surface for how long those rigs swing: lower rings
10084
+ * longer, 1 restores the authored value exactly. It does NOT change where
10085
+ * they hang at rest — that is the property that made damping the right knob
10086
+ * (see RezePhysics.setJiggleDamping). Judge it against the models that
10087
+ * motivated it; it is a starting point, not a measurement.
10088
+ */
10089
+ Engine.JIGGLE_DAMPING_SCALE = 0.5;
9359
10090
  /**
9360
10091
  * The installed effect's particle system, or null when it declared none.
9361
10092
  *
@@ -9400,7 +10131,34 @@ Engine.FIELD_SCALES = [1, 2];
9400
10131
  * field restructure moves. Restructuring it while it was the only untimed
9401
10132
  * pass in the frame would have meant reasoning about the cost instead of
9402
10133
  * reading it. */
9403
- Engine.TIMED_PASSES = ["cull", "shadow", "scene", "field", "composite"];
10134
+ /**
10135
+ * The passes worth a number, in the order the frame runs them.
10136
+ *
10137
+ * These ARE the boxes on the architecture figure, deliberately: a reading that
10138
+ * cannot be pointed at a component is a reading nobody acts on. Three were
10139
+ * missing and each is a real per-frame cost a report of "it feels slower"
10140
+ * could have been about — the morph compute, the mirror's second pass over the
10141
+ * whole cast, and the bloom pyramid, which is NINE render passes and was the
10142
+ * largest unmeasured thing in the frame.
10143
+ *
10144
+ * The per-effect computes (particles, grids, lights) are deliberately absent:
10145
+ * they are a loop of one pass per effect, so there is no single span to stamp
10146
+ * and a number attributed to the wrong one is worse than no number. They fall
10147
+ * into the "rest" the readout derives from the frame time.
10148
+ *
10149
+ * Adding one costs two query slots and nothing else; the query set is sized
10150
+ * from this array's length.
10151
+ */
10152
+ Engine.TIMED_PASSES = [
10153
+ "cull",
10154
+ "morph",
10155
+ "shadow",
10156
+ "mirror",
10157
+ "scene",
10158
+ "field",
10159
+ "bloom",
10160
+ "composite",
10161
+ ];
9404
10162
  // ── The floor mirror (step 7C) ──
9405
10163
  // Half-res scene-contract attachments a mirrored draw renders into, plus the
9406
10164
  // mirror's own camera block. The plane is the ground plane: MMD floors live