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.
- package/dist/engine.d.ts +237 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +793 -64
- package/dist/physics/autofit.d.ts +147 -0
- package/dist/physics/autofit.d.ts.map +1 -0
- package/dist/physics/autofit.js +501 -0
- package/dist/shaders/cast-api.d.ts +1 -1
- package/dist/shaders/cast-api.d.ts.map +1 -1
- package/dist/shaders/cast-layout.d.ts +44 -1
- package/dist/shaders/cast-layout.d.ts.map +1 -1
- package/dist/shaders/cast-layout.js +44 -1
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +7 -1
- package/dist/shaders/materials/nodes.d.ts +1 -1
- package/dist/shaders/materials/nodes.d.ts.map +1 -1
- package/dist/shaders/materials/nodes.js +17 -9
- package/dist/shaders/passes/composite.d.ts +1 -1
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/depth-prepass.d.ts +1 -1
- package/dist/shaders/passes/depth-prepass.d.ts.map +1 -1
- package/dist/shaders/passes/depth-prepass.js +52 -12
- package/dist/shaders/passes/field-blit.d.ts +26 -0
- package/dist/shaders/passes/field-blit.d.ts.map +1 -0
- package/dist/shaders/passes/field-blit.js +65 -0
- package/dist/shaders/passes/ground-noise.d.ts +7 -0
- package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
- package/dist/shaders/passes/ground-noise.js +88 -0
- package/dist/shaders/passes/ground.d.ts +16 -0
- package/dist/shaders/passes/ground.d.ts.map +1 -1
- package/dist/shaders/passes/ground.js +131 -27
- package/dist/shaders/passes/outline.d.ts +1 -1
- package/dist/shaders/passes/outline.d.ts.map +1 -1
- package/dist/shaders/passes/outline.js +12 -3
- package/dist/shaders/passes/particles.d.ts.map +1 -1
- package/dist/shaders/passes/particles.js +6 -2
- package/dist/shaders/passes/scene-contract.d.ts +38 -6
- package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
- package/dist/shaders/passes/scene-contract.js +53 -16
- package/dist/shaders/passes/sim.d.ts +34 -0
- package/dist/shaders/passes/sim.d.ts.map +1 -0
- package/dist/shaders/passes/sim.js +169 -0
- package/dist/shaders/passes/trails.d.ts.map +1 -1
- package/dist/shaders/passes/trails.js +36 -8
- package/dist/shaders/score-api.d.ts +10 -0
- package/dist/shaders/score-api.d.ts.map +1 -0
- package/dist/shaders/score-api.js +114 -0
- package/package.json +1 -1
- package/src/engine.ts +839 -56
- package/src/shaders/cast-layout.ts +44 -1
- package/src/shaders/materials/common.ts +7 -1
- package/src/shaders/materials/nodes.ts +17 -9
- package/src/shaders/passes/depth-prepass.ts +53 -12
- package/src/shaders/passes/ground.ts +133 -27
- package/src/shaders/passes/outline.ts +14 -3
- package/src/shaders/passes/particles.ts +6 -2
- package/src/shaders/passes/scene-contract.ts +55 -16
- package/src/shaders/passes/trails.ts +36 -8
- package/dist/physics-debug.d.ts +0 -30
- package/dist/physics-debug.d.ts.map +0 -1
- package/dist/physics-debug.js +0 -526
- package/dist/shaders/materials/body.d.ts +0 -2
- package/dist/shaders/materials/body.d.ts.map +0 -1
- package/dist/shaders/materials/body.js +0 -95
- package/dist/shaders/materials/cloth_rough.d.ts +0 -2
- package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
- package/dist/shaders/materials/cloth_rough.js +0 -69
- package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
- package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
- package/dist/shaders/materials/cloth_smooth.js +0 -61
- package/dist/shaders/materials/default.d.ts +0 -2
- package/dist/shaders/materials/default.d.ts.map +0 -1
- package/dist/shaders/materials/default.js +0 -43
- package/dist/shaders/materials/eye.d.ts +0 -2
- package/dist/shaders/materials/eye.d.ts.map +0 -1
- package/dist/shaders/materials/eye.js +0 -60
- package/dist/shaders/materials/face.d.ts +0 -2
- package/dist/shaders/materials/face.d.ts.map +0 -1
- package/dist/shaders/materials/face.js +0 -95
- package/dist/shaders/materials/hair.d.ts +0 -2
- package/dist/shaders/materials/hair.d.ts.map +0 -1
- package/dist/shaders/materials/hair.js +0 -90
- package/dist/shaders/materials/metal.d.ts +0 -2
- package/dist/shaders/materials/metal.d.ts.map +0 -1
- package/dist/shaders/materials/metal.js +0 -77
- package/dist/shaders/materials/mmd_classic.d.ts +0 -2
- package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
- package/dist/shaders/materials/mmd_classic.js +0 -66
- package/dist/shaders/materials/stockings.d.ts +0 -2
- package/dist/shaders/materials/stockings.d.ts.map +0 -1
- package/dist/shaders/materials/stockings.js +0 -122
- package/dist/shaders/passes/physics-debug.d.ts +0 -2
- package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
- 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 {
|
|
30
|
-
import {
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
4101
|
+
code: transparentDepthPrepassWgsl(),
|
|
3900
4102
|
});
|
|
3901
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
5770
|
-
* must not draw
|
|
5771
|
-
|
|
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
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
//
|
|
6505
|
-
//
|
|
6506
|
-
//
|
|
6507
|
-
//
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|