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.
- package/dist/engine.d.ts +247 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +822 -64
- package/dist/model.d.ts +49 -1
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +160 -4
- 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/physics/physics.d.ts +35 -0
- package/dist/physics/physics.d.ts.map +1 -1
- package/dist/physics/physics.js +64 -0
- package/dist/physics/world.d.ts +4 -0
- package/dist/physics/world.d.ts.map +1 -1
- package/dist/physics/world.js +6 -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 +867 -56
- package/src/model.ts +163 -4
- package/src/physics/physics.ts +63 -0
- package/src/physics/world.ts +7 -0
- 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)
|
|
@@ -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
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
//
|
|
6505
|
-
//
|
|
6506
|
-
//
|
|
6507
|
-
//
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|