reze-engine 0.55.15 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/camera.d.ts +12 -0
  2. package/dist/camera.d.ts.map +1 -1
  3. package/dist/camera.js +20 -3
  4. package/dist/engine.d.ts +210 -5
  5. package/dist/engine.d.ts.map +1 -1
  6. package/dist/engine.js +1013 -104
  7. package/dist/model.d.ts +12 -2
  8. package/dist/model.d.ts.map +1 -1
  9. package/dist/model.js +47 -4
  10. package/dist/overlay.d.ts +0 -5
  11. package/dist/overlay.d.ts.map +1 -1
  12. package/dist/overlay.js +0 -5
  13. package/dist/reflection.d.ts +17 -4
  14. package/dist/reflection.d.ts.map +1 -1
  15. package/dist/reflection.js +93 -38
  16. package/dist/shaders/cast-api.d.ts +1 -1
  17. package/dist/shaders/cast-api.d.ts.map +1 -1
  18. package/dist/shaders/cast-api.js +7 -6
  19. package/dist/shaders/directives.d.ts +15 -0
  20. package/dist/shaders/directives.d.ts.map +1 -1
  21. package/dist/shaders/directives.js +5 -0
  22. package/dist/shaders/passes/composite.d.ts +1 -1
  23. package/dist/shaders/passes/composite.d.ts.map +1 -1
  24. package/dist/shaders/passes/ground.d.ts.map +1 -1
  25. package/dist/shaders/passes/ground.js +42 -6
  26. package/dist/shaders/passes/mirror.d.ts +32 -0
  27. package/dist/shaders/passes/mirror.d.ts.map +1 -0
  28. package/dist/shaders/passes/mirror.js +284 -0
  29. package/dist/shaders/passes/scene-contract.d.ts +6 -0
  30. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  31. package/dist/shaders/passes/scene-contract.js +8 -0
  32. package/package.json +1 -1
  33. package/src/camera.ts +21 -4
  34. package/src/engine.ts +1081 -108
  35. package/src/model.ts +46 -4
  36. package/src/overlay.ts +0 -5
  37. package/src/reflection.ts +103 -37
  38. package/src/shaders/cast-api.ts +7 -6
  39. package/src/shaders/directives.ts +20 -0
  40. package/src/shaders/passes/ground.ts +42 -6
  41. package/src/shaders/passes/mirror.ts +289 -0
  42. package/src/shaders/passes/scene-contract.ts +14 -0
package/dist/engine.js CHANGED
@@ -22,7 +22,8 @@ import { ID_DEBUG_SHADER_WGSL } from "./shaders/passes/id-debug";
22
22
  import { paramChanged, sampleParamTrack } from "./param-track";
23
23
  import { effectState } from "./effect-schedule";
24
24
  import { SHADOW_CASCADES, buildShadowVP } from "./shadow-cascades";
25
- import { REFLECTION_DEBUG_WGSL, buildMirrorCamera } from "./reflection";
25
+ import { REFLECTION_DEBUG_WGSL, buildMirrorCamera, planeFromPointNormal } from "./reflection";
26
+ import { MIRROR_MASK_DOWNSAMPLE_WGSL, MIRROR_MAT_BYTES, mirrorShaderWgsl, mirrorShadowWgsl } from "./shaders/passes/mirror";
26
27
  import { packHalf } from "./hdr";
27
28
  import { evalIrradianceSH, projectIrradianceSH } from "./ibl";
28
29
  import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, packLyrics } from "./shaders/lyrics-api";
@@ -35,7 +36,7 @@ import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shader
35
36
  import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
36
37
  import { OVERLAY_SHADER_WGSL, OVERLAY_COMPOSITE_SHADER_WGSL } from "./shaders/passes/overlay";
37
38
  import { WIREFRAME_SHADER_WGSL } from "./shaders/passes/wireframe";
38
- import { boneOverlay, boneMarkerPositions, buildOverlayShapes, jointOverlay, rigidbodyOverlay, writeOverlayInstance, OVERLAY_INSTANCE_FLOATS, OVERLAY_VERTEX_FLOATS, OVERLAY_SHAPES, OVERLAY_SOLID_SHAPES, DEFAULT_VERTEX_COLOR, OVERLAY_STYLE, } from "./overlay";
39
+ import { boneOverlay, boneMarkerPositions, buildOverlayShapes, jointOverlay, lineBetween, rigidbodyOverlay, writeOverlayInstance, OVERLAY_INSTANCE_FLOATS, OVERLAY_VERTEX_FLOATS, OVERLAY_SHAPES, OVERLAY_SOLID_SHAPES, DEFAULT_VERTEX_COLOR, OVERLAY_STYLE, } from "./overlay";
39
40
  import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
40
41
  import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
41
42
  import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
@@ -693,7 +694,7 @@ export class Engine {
693
694
  return this.reversedZ ? 0 : 1;
694
695
  }
695
696
  get reflectionActive() {
696
- return this.reflectionDebug || this.groundMirror > 0;
697
+ return this.reflectionDebug || this.groundMirror > 0 || this.mirrorSurface !== null;
697
698
  }
698
699
  /** The first installed effect, for the many places that legitimately want
699
700
  * "is anything installed" or the singleton API's one effect. */
@@ -730,6 +731,7 @@ export class Engine {
730
731
  this.overlayJoints = null;
731
732
  this.overlayVertices = null;
732
733
  this.wireframeColorData = new Float32Array(8);
734
+ this.selectionFill = null;
733
735
  /** Rebuilt every frame into these, grouped by shape so each shape is one draw. */
734
736
  this.overlayByShape = new Map();
735
737
  this.overlayScratch = [];
@@ -893,6 +895,7 @@ export class Engine {
893
895
  this.cullCameraArgs = null;
894
896
  this.cullShadowArgs = null;
895
897
  this.cullMirrorArgs = null;
898
+ this.mirrorPlane = new Float32Array([0, 1, 0, 0]);
896
899
  this.mirrorCameraData = new Float32Array(40);
897
900
  // proj x mirrorView for the ground's projective sample and the cull planes,
898
901
  // then (projA, projB, 0, 0) — the depth-linearisation pair, read off the
@@ -906,6 +909,19 @@ export class Engine {
906
909
  this.mirrorBlurBindGroups = null;
907
910
  this.groundMirrorBlur = 0;
908
911
  this.mirrorMaskMsTexture = null;
912
+ /** The mirror pass's aux, RESOLVED and kept — `.g` is accumulated alpha, and
913
+ * it is the only coverage the reflection has. The HDR format is
914
+ * rg11b10ufloat wherever the device allows it, which has no alpha channel at
915
+ * all, so a mirror that read `refl.a` read 1 everywhere and painted the
916
+ * reflection target's empty black across the whole pane. */
917
+ this.mirrorMaskTexture = null;
918
+ this.mirrorMaskView = null;
919
+ /** Coverage carries the SAME mip chain as the colour — the two are one
920
+ * premultiplied quantity and a blur has to move them together. */
921
+ this.mirrorMaskMipViews = [];
922
+ this.mirrorMaskBlurBindGroups = null;
923
+ this.mirrorMaskDownsamplePipeline = null;
924
+ this.groundClipData = new Float32Array(8);
909
925
  this.mirrorIdMsTexture = null;
910
926
  this.mirrorDepthTexture = null;
911
927
  this.mirrorDepthReadView = null;
@@ -914,6 +930,24 @@ export class Engine {
914
930
  this.mirrorTransparentBundle = null;
915
931
  /** setGroundMirror lands in step 7D; the debug dial is what exercises C. */
916
932
  this.groundMirror = 0;
933
+ // ── The mirror SURFACE: the reflection as a plane you can put anywhere ──
934
+ // Null when there is none, which is also what keeps the reflection pass off.
935
+ // No vertex or index buffer: the quad is six generated vertices and the model
936
+ // matrix is the whole of the difference between one mirror and another.
937
+ this.mirrorSurface = null;
938
+ /** Position, rotation and the two extents, baked. Column 2 is the normal the
939
+ * reflection plane is built from, so this is the single source for both the
940
+ * draw and the fold — they cannot disagree about where the glass is. */
941
+ this.mirrorSurfaceModel = new Float32Array(16);
942
+ this.mirrorSurfaceMatData = new Float32Array(28);
943
+ this.mirrorSurfaceMatBuffer = null;
944
+ this.mirrorSurfaceBindGroupLayout = null;
945
+ this.mirrorSurfacePipeline = null;
946
+ this.mirrorSurfaceBindGroup = null;
947
+ /** The pane in the shadow pass — the frame throwing shade on the floor. One
948
+ * bind group per cascade, each carrying its own index. */
949
+ this.mirrorShadowPipeline = null;
950
+ this.mirrorShadowBindGroups = [];
917
951
  this.reflectionDebug = false;
918
952
  this.reflectionDebugPipeline = null;
919
953
  this.reflectionDebugBindGroupLayout = null;
@@ -1042,6 +1076,14 @@ export class Engine {
1042
1076
  this.shadowCascadeVPBuffers = [];
1043
1077
  // All cascades' view-projections, 16 floats each, inner to outer.
1044
1078
  this.shadowLightVPMatrix = new Float32Array(16 * SHADOW_CASCADES.length);
1079
+ // Stand-ins for the three mirror textures the ground's layout demands, bound
1080
+ // ONLY by the group that draws the floor INSIDE the mirror pass. Those
1081
+ // textures are that pass's own attachments, and WebGPU rejects a pass that
1082
+ // both writes a texture and binds it — regardless of whether the shader
1083
+ // reaches the sample. `material.mirror` is 0 in that pass (the draw is
1084
+ // skipped when the floor's own mirror is on), so nothing ever reads these.
1085
+ this.mirrorDummyColorView = null;
1086
+ this.mirrorDummyDepthView = null;
1045
1087
  this.groundDrawCall = null;
1046
1088
  this.lastTouchTime = 0;
1047
1089
  this.DOUBLE_TAP_DELAY = 300;
@@ -1138,6 +1180,10 @@ export class Engine {
1138
1180
  /** Skinned positions for picking, grown on demand. One click's worth of work
1139
1181
  * reused across clicks — a model's vertex count does not change. */
1140
1182
  this.materialPickScratch = null;
1183
+ /** Scratch for selectMaterialFaces — same shape as materialPickScratch,
1184
+ * its own buffer since a drag can end while a click is still in flight
1185
+ * from a different frame's hover. */
1186
+ this.materialSelectScratch = null;
1141
1187
  // CPU frame-time breakdown (EMA-smoothed into getStats): where a frame's
1142
1188
  // milliseconds actually go — animation/IK/blending vs physics vs everything
1143
1189
  // else on the render thread. The first question of any perf report.
@@ -1156,6 +1202,8 @@ export class Engine {
1156
1202
  this.groundMaterialData = null;
1157
1203
  /** Set by the first getGpuTimings() call. See it for why asking is the switch. */
1158
1204
  this.timestampsWanted = false;
1205
+ this.groundMirrorPipeline = null;
1206
+ this.groundMirrorSoftPipeline = null;
1159
1207
  // Shadow is cast from the visible sun direction — same vector the shader lights with.
1160
1208
  /** Whether the shadow map needs clearing — see the shadow pass in `render`.
1161
1209
  *
@@ -1725,9 +1773,304 @@ export class Engine {
1725
1773
  this.groundMirror = on ? 1 : 0;
1726
1774
  if (blur !== undefined)
1727
1775
  this.groundMirrorBlur = Math.min(Math.max(blur, 0), 1);
1728
- this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 15 * 4, new Float32Array([this.groundMirror, this.groundMirrorBlur]));
1776
+ this.writeGroundMirrorField();
1729
1777
  return true;
1730
1778
  }
1779
+ /**
1780
+ * The floor's mirror field, as the shader should see it.
1781
+ *
1782
+ * A PLACED mirror takes the plane — there is one reflection target and one
1783
+ * fold, and a floor still trying to reflect through a wall's plane shows the
1784
+ * scene lying on its side. So the field is forced to 0 while a mirror surface
1785
+ * exists, without touching what the caller set: remove the mirror and the
1786
+ * floor's own setting comes back.
1787
+ *
1788
+ * It is also what lets the reflection carry the FLOOR. The mirror pass draws
1789
+ * the ground now, and a ground whose own mirror is on would be sampling the
1790
+ * very texture that pass is writing.
1791
+ */
1792
+ writeGroundMirrorField() {
1793
+ if (!this.groundShadowMaterialBuffer)
1794
+ return;
1795
+ const effective = this.mirrorSurface ? 0 : this.groundMirror;
1796
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 15 * 4, new Float32Array([effective, this.groundMirrorBlur]));
1797
+ }
1798
+ /**
1799
+ * Place a mirror in the scene, or remove it with null.
1800
+ *
1801
+ * PRIVATE, and the only caller is syncMirrorFromEffects. A mirror reaches a
1802
+ * scene by applying the effect that declares `#mirror`, which is what gives
1803
+ * it dials, a schedule, a library row and a publish — a second public door
1804
+ * straight to the plane would be the same feature with none of that, and two
1805
+ * ways to place one mirror when there can only be one.
1806
+ *
1807
+ * A real planar reflection: the scene is drawn a second time from the camera
1808
+ * folded through this plane, so what the glass shows is the SCENE — lit by
1809
+ * the same sun, carrying the same shadows, with particle and ribbon effects
1810
+ * in it — rather than a copy of what the camera already sees.
1811
+ *
1812
+ * ONE at a time. The reflection pass owns a single set of attachments and a
1813
+ * single camera block, and a second mirror is a second of each; placing one
1814
+ * also takes the plane away from the floor mirror, which is the same target.
1815
+ *
1816
+ * `width` and `height` are metres of glass. `rotation` orients a quad whose
1817
+ * face is +Z at identity, so a mirror dropped in with no rotation faces the
1818
+ * way the default camera looks from — what someone means by putting one in
1819
+ * front of her.
1820
+ */
1821
+ setMirror(options) {
1822
+ if (!this.device)
1823
+ return;
1824
+ if (!options) {
1825
+ this.mirrorSurface = null;
1826
+ return;
1827
+ }
1828
+ // MMD UNITS, not metres. A character stands about 20 tall here — the
1829
+ // engine's own numbers say so: the camera targets y = 11, spawnOffsetX
1830
+ // stands the next cast member 9 aside, and the ground fades out at 80. A
1831
+ // mirror sized in metres is a postage stamp at her ankle.
1832
+ //
1833
+ // POSITION IS THE FOOT, not the centre: the quad runs 0..1 upward from it,
1834
+ // so shortening the mirror takes the height off the top and turning it
1835
+ // pivots on the floor. See the vertex shader.
1836
+ //
1837
+ // AND IT STANDS BEHIND HER, NOT IN FRONT. A mirror shows what is on its own
1838
+ // front side and nothing at all of what is behind it, so a plane dropped
1839
+ // between the camera and the cast is a WINDOW: the subject is on the far
1840
+ // side of the glass and what reflects is the empty room. The default camera
1841
+ // orbits to z = -31 (alpha = pi, distance 33), so the default here sits at
1842
+ // +z, off to one side and turned back toward both — which is the
1843
+ // arrangement every mirror shot uses, and the reason it is not centred is
1844
+ // that a mirror directly behind her reflects her into her own silhouette.
1845
+ const px = options.position?.x ?? -11;
1846
+ const py = options.position?.y ?? 0;
1847
+ const pz = options.position?.z ?? 2;
1848
+ const r = options.rotation ?? { x: 0, y: 0, z: 0, w: 1 };
1849
+ const width = options.width ?? 14;
1850
+ const height = options.height ?? 24;
1851
+ const tint = options.tint ?? { x: 1, y: 1, z: 1 };
1852
+ const blur = Math.min(Math.max(options.blur ?? 0, 0), 1);
1853
+ const frame = options.frame ?? 0.48;
1854
+ const frameColor = options.frameColor ?? { x: 0.631, y: 0.631, z: 0.667 };
1855
+ // Rotation, then a NON-UNIFORM scale on the two in-plane axes — which is
1856
+ // why this is not fromPositionRotationScaleInto: a mirror is 2 x 3, not a
1857
+ // square someone scaled. Column 2 is left unit length; it is the normal.
1858
+ const m = this.mirrorSurfaceModel;
1859
+ Mat4.fromQuatInto(r.x, r.y, r.z, r.w, m, 0);
1860
+ m[0] *= width;
1861
+ m[1] *= width;
1862
+ m[2] *= width;
1863
+ m[4] *= height;
1864
+ m[5] *= height;
1865
+ m[6] *= height;
1866
+ m[12] = px;
1867
+ m[13] = py;
1868
+ m[14] = pz;
1869
+ this.mirrorSurface = { tint: new Vec3(tint.x, tint.y, tint.z), blur };
1870
+ const d = this.mirrorSurfaceMatData;
1871
+ d.set(m, 0);
1872
+ d[16] = tint.x;
1873
+ d[17] = tint.y;
1874
+ d[18] = tint.z;
1875
+ d[19] = blur;
1876
+ d[20] = frameColor.x;
1877
+ d[21] = frameColor.y;
1878
+ d[22] = frameColor.z;
1879
+ // Clamped to under half the short side: a band wider than the glass leaves
1880
+ // no glass, and a mirror that is all moulding is a plank.
1881
+ d[23] = Math.min(Math.max(frame, 0), Math.min(width, height) * 0.45);
1882
+ d[24] = width;
1883
+ d[25] = height;
1884
+ this.ensureMirrorSurfaceResources();
1885
+ this.device.queue.writeBuffer(this.mirrorSurfaceMatBuffer, 0, this.mirrorSurfaceMatData);
1886
+ }
1887
+ /** Called by the sync once the surface exists, so the floor yields the plane
1888
+ * on the same frame the mirror appears. */
1889
+ mirrorSurfaceAppeared() {
1890
+ this.writeGroundMirrorField();
1891
+ }
1892
+ /** One dial's live value, or the fallback when the effect never declared it.
1893
+ * Reads the CPU mirror of the params buffer, which setEffectParam keeps
1894
+ * current — so dragging a slider moves the glass on the same frame. */
1895
+ effectDial(fx, name, fallback) {
1896
+ const slot = fx.paramLayout.get(name);
1897
+ return slot ? fx.paramsData[slot.offset] : fallback;
1898
+ }
1899
+ /**
1900
+ * Drive the mirror from whichever effect declares one.
1901
+ *
1902
+ * Runs before reflectionActive is read, because the answer to "is there a
1903
+ * mirror" is this function's output. FIRST match wins and the rest are
1904
+ * ignored: there is one reflection target, so a second mirror is not a second
1905
+ * mirror, it is a fight over the same plane — and a scene that quietly
1906
+ * reflected the wrong one would be far harder to understand than one where
1907
+ * the second mirror does nothing.
1908
+ *
1909
+ * Gated on `weight`, so a scheduled mirror appears and leaves with its strip
1910
+ * and a faded-out one costs no reflection pass at all.
1911
+ */
1912
+ syncMirrorFromEffects() {
1913
+ let fx = null;
1914
+ for (const e of this.effects) {
1915
+ if (e.hasMirror && e.weight > 0) {
1916
+ fx = e;
1917
+ break;
1918
+ }
1919
+ }
1920
+ if (!fx) {
1921
+ if (this.mirrorSurface) {
1922
+ this.mirrorSurface = null;
1923
+ this.writeGroundMirrorField();
1924
+ }
1925
+ return;
1926
+ }
1927
+ const had = this.mirrorSurface !== null;
1928
+ const D = Engine.MIRROR_DIALS;
1929
+ const deg = Math.PI / 180;
1930
+ const rot = Quat.fromEuler(this.effectDial(fx, D.rotX, 0) * deg, this.effectDial(fx, D.rotY, 0) * deg, this.effectDial(fx, D.rotZ, 0) * deg);
1931
+ const colour = (name, r, g, b) => {
1932
+ const slot = fx.paramLayout.get(name);
1933
+ return slot
1934
+ ? { x: fx.paramsData[slot.offset], y: fx.paramsData[slot.offset + 1], z: fx.paramsData[slot.offset + 2] }
1935
+ : { x: r, y: g, z: b };
1936
+ };
1937
+ const tint = colour(D.tint, 1, 1, 1);
1938
+ this.setMirror({
1939
+ position: {
1940
+ x: this.effectDial(fx, D.posX, -11),
1941
+ y: this.effectDial(fx, D.posY, 0),
1942
+ z: this.effectDial(fx, D.posZ, 2),
1943
+ },
1944
+ rotation: rot,
1945
+ // Floored well above zero: a mirror scaled to nothing is a degenerate
1946
+ // quad whose normal is still read for the plane, and the reflection would
1947
+ // fold through a plane nobody can see.
1948
+ width: Math.max(this.effectDial(fx, D.width, 14), 0.1),
1949
+ height: Math.max(this.effectDial(fx, D.height, 24), 0.1),
1950
+ tint,
1951
+ blur: this.effectDial(fx, D.blur, 0),
1952
+ frame: this.effectDial(fx, D.frame, 0.48),
1953
+ frameColor: colour(D.frameColor, 0.631, 0.631, 0.667),
1954
+ });
1955
+ if (!had)
1956
+ this.mirrorSurfaceAppeared();
1957
+ }
1958
+ /** Built on the first setMirror rather than at init: a shader compile and a
1959
+ * pipeline cost load time, and most scenes never place one. */
1960
+ ensureMirrorSurfaceResources() {
1961
+ if (this.mirrorSurfacePipeline)
1962
+ return;
1963
+ this.mirrorSurfaceMatBuffer = this.device.createBuffer({
1964
+ label: "mirror surface material",
1965
+ size: MIRROR_MAT_BYTES,
1966
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1967
+ });
1968
+ this.mirrorSurfaceBindGroupLayout = this.device.createBindGroupLayout({
1969
+ label: "mirror surface layout",
1970
+ entries: [
1971
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1972
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1973
+ { binding: 2, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1974
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
1975
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
1976
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
1977
+ ],
1978
+ });
1979
+ this.mirrorSurfacePipeline = this.createRenderPipeline({
1980
+ label: "mirror surface pipeline",
1981
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.mirrorSurfaceBindGroupLayout] }),
1982
+ shaderModule: this.device.createShaderModule({ label: "mirror surface", code: mirrorShaderWgsl() }),
1983
+ // None: the quad is generated from the vertex index.
1984
+ vertexBuffers: [],
1985
+ fragmentTargets: sceneTargetsFor("mirror", this.sceneFormats),
1986
+ // Both faces. The shader answers for the back — see its side test. Culling
1987
+ // it instead would make a mirror rotated the wrong way INVISIBLE, which is
1988
+ // the one failure a transform panel cannot show you.
1989
+ cullMode: "none",
1990
+ depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
1991
+ });
1992
+ this.buildMirrorSurfaceBindGroup();
1993
+ // ── The pane in the shadow pass ──
1994
+ const shadowLayout = this.device.createBindGroupLayout({
1995
+ label: "mirror shadow layout",
1996
+ entries: [
1997
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
1998
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
1999
+ { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
2000
+ ],
2001
+ });
2002
+ this.mirrorShadowPipeline = this.device.createRenderPipeline({
2003
+ label: "mirror shadow pipeline",
2004
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [shadowLayout] }),
2005
+ vertex: {
2006
+ module: this.device.createShaderModule({
2007
+ label: "mirror shadow",
2008
+ code: mirrorShadowWgsl(SHADOW_CASCADES.length),
2009
+ }),
2010
+ entryPoint: "vs",
2011
+ },
2012
+ // Depth only: no fragment stage at all, the pane being opaque across its
2013
+ // whole rectangle with nothing to alpha-test.
2014
+ primitive: { cullMode: "none" },
2015
+ depthStencil: {
2016
+ format: Engine.SHADOW_DEPTH_FORMAT,
2017
+ depthWriteEnabled: true,
2018
+ depthCompare: "less-equal",
2019
+ // The shadow map's own non-reversed convention, and the same bias the
2020
+ // model shadows take — see shadowDepthPipeline.
2021
+ depthBias: 2,
2022
+ depthBiasSlopeScale: 1.5,
2023
+ depthBiasClamp: 0,
2024
+ },
2025
+ });
2026
+ this.mirrorShadowBindGroups = SHADOW_CASCADES.map((_, ci) => {
2027
+ const index = this.device.createBuffer({
2028
+ label: `mirror shadow cascade ${ci}`,
2029
+ size: 16,
2030
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2031
+ });
2032
+ this.device.queue.writeBuffer(index, 0, new Uint32Array([ci, 0, 0, 0]));
2033
+ return this.device.createBindGroup({
2034
+ label: `mirror shadow bind ${ci}`,
2035
+ layout: shadowLayout,
2036
+ entries: [
2037
+ { binding: 0, resource: { buffer: this.shadowLightVPBuffer } },
2038
+ { binding: 1, resource: { buffer: index } },
2039
+ { binding: 2, resource: { buffer: this.mirrorSurfaceMatBuffer } },
2040
+ ],
2041
+ });
2042
+ });
2043
+ }
2044
+ /** Rebound whenever the reflection target is recreated, alongside the
2045
+ * ground's — the two read the same texture. */
2046
+ buildMirrorSurfaceBindGroup() {
2047
+ if (!this.mirrorSurfaceBindGroupLayout || !this.mirrorSurfaceMatBuffer)
2048
+ return;
2049
+ if (!this.mirrorColorView || !this.mirrorMaskView)
2050
+ return;
2051
+ this.mirrorSurfaceBindGroup = this.device.createBindGroup({
2052
+ label: "mirror surface bind",
2053
+ layout: this.mirrorSurfaceBindGroupLayout,
2054
+ entries: [
2055
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
2056
+ { binding: 1, resource: { buffer: this.mirrorVPBuffer } },
2057
+ { binding: 2, resource: { buffer: this.mirrorSurfaceMatBuffer } },
2058
+ { binding: 3, resource: this.mirrorColorView },
2059
+ { binding: 4, resource: this.materialSampler },
2060
+ { binding: 5, resource: this.mirrorMaskView },
2061
+ ],
2062
+ });
2063
+ }
2064
+ /** The glass itself, in the camera pass. Never in the MIRROR pass: a mirror
2065
+ * drawn into its own reflection is a corridor, and with one target it is a
2066
+ * corridor of last frame's frame. */
2067
+ renderMirrorSurface(pass) {
2068
+ if (!this.mirrorSurface || !this.mirrorSurfacePipeline || !this.mirrorSurfaceBindGroup)
2069
+ return;
2070
+ pass.setPipeline(this.mirrorSurfacePipeline);
2071
+ pass.setBindGroup(0, this.mirrorSurfaceBindGroup);
2072
+ pass.draw(6);
2073
+ }
1731
2074
  ensureReflectionDebugPipeline() {
1732
2075
  if (!this.mirrorColorView)
1733
2076
  return false;
@@ -1779,7 +2122,25 @@ export class Engine {
1779
2122
  /** Refold the live camera with the reflection — a copy and a handful of
1780
2123
  * sign flips; cheap enough to run every frame a mirror is on. */
1781
2124
  updateMirrorCamera() {
1782
- buildMirrorCamera(this.cameraMatrixData, Engine.REFLECTION_PLANE_Y, this.mirrorCameraData);
2125
+ // The placed surface wins over the floor — see the note on mirrorPlane.
2126
+ // Recomputed rather than cached because the mirror is the thing a gizmo
2127
+ // drags, and a plane that lagged the surface by a frame is a reflection
2128
+ // that slides on it.
2129
+ if (this.mirrorSurface) {
2130
+ const m = this.mirrorSurfaceModel;
2131
+ planeFromPointNormal(m[12], m[13], m[14], m[8], m[9], m[10], this.mirrorPlane);
2132
+ }
2133
+ else {
2134
+ this.mirrorPlane.set(Engine.GROUND_PLANE);
2135
+ }
2136
+ // The ground inside the mirror pass clips against this same plane — a
2137
+ // mirror shows nothing behind itself, and the floor is what is behind one.
2138
+ if (this.groundClipMirrorBuffer) {
2139
+ this.groundClipData.set(this.mirrorPlane, 0);
2140
+ this.groundClipData[4] = 1;
2141
+ this.device.queue.writeBuffer(this.groundClipMirrorBuffer, 0, this.groundClipData);
2142
+ }
2143
+ buildMirrorCamera(this.cameraMatrixData, this.mirrorPlane, this.mirrorCameraData);
1783
2144
  this.device.queue.writeBuffer(this.mirrorCameraBuffer, 0, this.mirrorCameraData);
1784
2145
  Mat4.multiplyArrays(this.cameraMatrixData, 16, this.mirrorCameraData, 0, this.mirrorVPData, 0);
1785
2146
  // projA/projB ARE m[10] and m[14] of the projection (the dofU discipline:
@@ -1805,20 +2166,22 @@ export class Engine {
1805
2166
  renderMirrorPass(encoder) {
1806
2167
  if (!this.reflectionActive || !this.mirrorPassDescriptor)
1807
2168
  return;
1808
- if (!this.mirrorOpaqueBundle && !this.mirrorTransparentBundle)
1809
- return;
1810
- // A mirror reflects the sky, not the void: clear to the scene's background
1811
- // so the empty regions of the reflection read as backdrop instead of
1812
- // black. Linearised, because the mirror lives in scene-linear HDR and the
1813
- // stored colour is display sRGB — and honestly APPROXIMATE: the real
1814
- // backdrop composites after the view transform, so the mirrored patch
1815
- // rides through AgX/filmic and lands close, not identical. A transparent
1816
- // background keeps the black clear; a 360 equirect gets the flat colour —
1817
- // sampling the skybox along reflected rays is the recorded follow-up.
1818
- const bg = this.backgroundColor;
1819
- const lin = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
1820
- const atts = this.mirrorPassDescriptor.colorAttachments;
1821
- 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 };
2169
+ // No early return on an empty cast. The CLEAR is the load-bearing half of
2170
+ // this pass: a mirror surface samples the target whether or not anything
2171
+ // was drawn into it, and a pass skipped for having no models leaves it
2172
+ // holding whatever the allocation held — undefined memory, sampled onto a
2173
+ // plane in the middle of the scene.
2174
+ // CLEARS TRANSPARENT, and the alpha is the whole point: it is 1 exactly
2175
+ // where the pass drew something and 0 where it drew nothing, so every
2176
+ // consumer can composite the reflection over its own surface instead of
2177
+ // over a guess.
2178
+ //
2179
+ // It used to clear to the scene's background, linearised, so that empty
2180
+ // regions read as backdrop rather than black. That colour then rode the
2181
+ // view transform a SECOND time — the real backdrop composites after it —
2182
+ // and the result was a panel paler and flatter than the backdrop beside it.
2183
+ // A mirror that fogs. Coverage costs nothing and lets the genuine backdrop
2184
+ // through, which is also what an empty mirror shows: the room.
1822
2185
  // The descriptor is reused every frame, so the stamp is set on it rather
1823
2186
  // than passed — same as the scene pass, which is built once too.
1824
2187
  this.mirrorPassDescriptor.timestampWrites = this.stamps("mirror");
@@ -1829,12 +2192,26 @@ export class Engine {
1829
2192
  bundles.push(this.mirrorOpaqueBundle);
1830
2193
  if (this.mirrorTransparentBundle)
1831
2194
  bundles.push(this.mirrorTransparentBundle);
1832
- pass.executeBundles(bundles);
2195
+ if (bundles.length > 0)
2196
+ pass.executeBundles(bundles);
1833
2197
  // Particles and ribbons are scene geometry, and a mirror that dropped them
1834
2198
  // showed a dancer whose hand ribbon cast no reflection. Field effects stay
1835
2199
  // out BY DESIGN: they are display-space overlays composited after the view
1836
2200
  // transform, with no world position to mirror. executeBundles reset the
1837
2201
  // pass state, so these draws bind everything themselves — which they do.
2202
+ // THE FLOOR, inside the reflection.
2203
+ //
2204
+ // Without it a standing mirror shows a figure hanging in empty colour, with
2205
+ // nothing under her feet to say where she is — which is most of what made
2206
+ // the pane read as a dark sheet rather than as glass. It never came up for
2207
+ // the floor mirror because a floor does not reflect itself.
2208
+ //
2209
+ // Skipped while the FLOOR's own mirror is on: that would be the floor
2210
+ // sampling a reflection it is currently drawing into, one frame stale, and
2211
+ // the two mirrors are already fighting over the single reflection plane.
2212
+ if (this.hasGround && (this.mirrorSurface !== null || this.groundMirror === 0) && this.groundMirrorViewBindGroup) {
2213
+ this.renderGroundWith(pass, this.groundMirrorViewBindGroup, true);
2214
+ }
1838
2215
  this.renderParticles(pass, "mirror");
1839
2216
  this.drawTrails(pass, "mirror");
1840
2217
  pass.end();
@@ -1846,7 +2223,11 @@ export class Engine {
1846
2223
  * samples level 0 exactly and the chain would be work nobody reads.
1847
2224
  */
1848
2225
  renderMirrorBlurChain(encoder) {
1849
- if (this.groundMirrorBlur <= 0 || this.mirrorMipCount < 2)
2226
+ // EITHER consumer's dial. Gated on the ground's alone, a mirror surface with
2227
+ // blur up sampled mip levels that had never been rendered into — undefined
2228
+ // memory, which read as the model going black the moment the slider moved.
2229
+ const blur = Math.max(this.groundMirrorBlur, this.mirrorSurface?.blur ?? 0);
2230
+ if (blur <= 0 || this.mirrorMipCount < 2)
1850
2231
  return;
1851
2232
  if (!this.mirrorBlurBindGroups) {
1852
2233
  const layout = this.bloomDownsamplePipeline.getBindGroupLayout(0);
@@ -1872,6 +2253,47 @@ export class Engine {
1872
2253
  p.draw(3);
1873
2254
  p.end();
1874
2255
  }
2256
+ // COVERAGE, down the same chain. Colour in the reflection target is
2257
+ // premultiplied by this, so the pair has to be blurred together: blur the
2258
+ // colour alone and the pane divides a colour that spread into the empty sky
2259
+ // by a coverage that did not, and the reflection darkens as the dial rises.
2260
+ if (!this.mirrorMaskDownsamplePipeline) {
2261
+ const module = this.device.createShaderModule({
2262
+ label: "mirror coverage downsample",
2263
+ code: MIRROR_MASK_DOWNSAMPLE_WGSL,
2264
+ });
2265
+ this.mirrorMaskDownsamplePipeline = this.device.createRenderPipeline({
2266
+ label: "mirror coverage downsample pipeline",
2267
+ layout: "auto",
2268
+ vertex: { module, entryPoint: "vs" },
2269
+ fragment: { module, entryPoint: "fs", targets: [{ format: Engine.BLOOM_MASK_FORMAT }] },
2270
+ primitive: { topology: "triangle-list" },
2271
+ });
2272
+ }
2273
+ if (!this.mirrorMaskBlurBindGroups) {
2274
+ const layout = this.mirrorMaskDownsamplePipeline.getBindGroupLayout(0);
2275
+ this.mirrorMaskBlurBindGroups = [];
2276
+ for (let i = 1; i < this.mirrorMipCount; i++) {
2277
+ this.mirrorMaskBlurBindGroups.push(this.device.createBindGroup({
2278
+ label: `mirror coverage blur ${i}`,
2279
+ layout,
2280
+ entries: [
2281
+ { binding: 0, resource: this.mirrorMaskMipViews[i - 1] },
2282
+ { binding: 1, resource: this.bloomSampler },
2283
+ ],
2284
+ }));
2285
+ }
2286
+ }
2287
+ for (let i = 1; i < this.mirrorMipCount; i++) {
2288
+ const p = encoder.beginRenderPass({
2289
+ label: `mirror coverage blur ${i}`,
2290
+ colorAttachments: [{ view: this.mirrorMaskMipViews[i], loadOp: "clear", storeOp: "store" }],
2291
+ });
2292
+ p.setPipeline(this.mirrorMaskDownsamplePipeline);
2293
+ p.setBindGroup(0, this.mirrorMaskBlurBindGroups[i - 1]);
2294
+ p.draw(3);
2295
+ p.end();
2296
+ }
1875
2297
  }
1876
2298
  /**
1877
2299
  * Can this device multisample the id format at the pass's sample count?
@@ -2595,13 +3017,17 @@ export class Engine {
2595
3017
  // lightEmit counts as a mount on its own: a pure lighting rig draws nothing
2596
3018
  // and is still an effect — it is how a scene gets stage lights without also
2597
3019
  // getting geometry it did not ask for.
2598
- if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl)) {
3020
+ // #mirror is a mount on the same footing, and for the same reason: a
3021
+ // reflection re-renders the scene from a folded camera, which is a pass and
3022
+ // not a shader, so the effect declares it and draws nothing itself.
3023
+ if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl) && !d.mirror) {
2599
3024
  return { ok: false, diagnostics: [
2600
3025
  "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
2601
3026
  "fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
2602
3027
  "the particle trio (particleInit/particleStep/particleShade), " +
2603
3028
  "the ribbon pair (trailWidth/trailShade), " +
2604
- "or fn lightEmit(i: u32) -> RzLight with #lights <n>",
3029
+ "fn lightEmit(i: u32) -> RzLight with #lights <n>, " +
3030
+ "or #mirror",
2605
3031
  ], mounts: noMounts, params: [], duration: 0 };
2606
3032
  }
2607
3033
  const mounts = { background: hasBackground, foreground: hasForeground };
@@ -2901,6 +3327,7 @@ export class Engine {
2901
3327
  paramLayout: layout,
2902
3328
  paramsBuffer,
2903
3329
  paramsData,
3330
+ hasMirror: d.mirror,
2904
3331
  hasBackground,
2905
3332
  hasForeground,
2906
3333
  // The author's OWN source, not the assembled module: the assembled one
@@ -5176,6 +5603,11 @@ export class Engine {
5176
5603
  // The baked frost tile — see bakeGroundNoise. Sampled with binding 10's
5177
5604
  // repeat sampler, so it brings no sampler of its own.
5178
5605
  { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5606
+ // The mirror pass's resolved aux. .g is the reflection's COVERAGE, and
5607
+ // the colour target cannot carry it — rg11b10ufloat has no alpha.
5608
+ { binding: 13, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5609
+ // The mirror's plane, and whether this draw is inside the mirror pass.
5610
+ { binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
5179
5611
  ],
5180
5612
  });
5181
5613
  this.groundShadowPipelineDesc = {
@@ -5222,11 +5654,33 @@ export class Engine {
5222
5654
  { binding: 1, resource: this.materialSampler },
5223
5655
  ],
5224
5656
  });
5657
+ // The same group on the MIRROR camera. An outline is geometry expanded
5658
+ // along its normals in CLIP space, so it has to be expanded through the
5659
+ // camera that is drawing it — handed the real camera it would trace a hull
5660
+ // around where she is standing, not around her reflection.
5661
+ this.outlineMirrorPerFrameBindGroup = this.device.createBindGroup({
5662
+ label: "outline per-frame bind group (mirror)",
5663
+ layout: this.outlinePerFrameBindGroupLayout,
5664
+ entries: [
5665
+ { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
5666
+ { binding: 1, resource: this.materialSampler },
5667
+ ],
5668
+ });
5225
5669
  const outlineShaderModule = this.device.createShaderModule({
5226
5670
  label: "outline shaders",
5227
5671
  code: outlineShaderWgsl(),
5228
5672
  });
5229
- this.outlinePipeline = this.createRenderPipeline({
5673
+ // FRONT, not back, and not "none". The outline is an inverted hull: the
5674
+ // model expanded along its normals, drawn with only its BACK faces showing
5675
+ // so the silhouette survives and the interior does not ink over the model.
5676
+ // A reflection has determinant -1 (reflection.ts), so those very triangles
5677
+ // arrive front-facing in the mirror pass and `back` culls every one of them
5678
+ // — which is why the mirror had no outline at all. `none` would draw the
5679
+ // hull's front faces too, painting the model over with ink.
5680
+ // Annotated so the string fields keep their literal types: pulled out of the
5681
+ // call into a const, "back" widens to string and the two pipelines below
5682
+ // stop type-checking.
5683
+ const outlineDesc = {
5230
5684
  label: "outline pipeline",
5231
5685
  layout: outlinePipelineLayout,
5232
5686
  shaderModule: outlineShaderModule,
@@ -5263,6 +5717,12 @@ export class Engine {
5263
5717
  stencilReadMask: 0xff,
5264
5718
  stencilWriteMask: 0,
5265
5719
  },
5720
+ };
5721
+ this.outlinePipeline = this.createRenderPipeline(outlineDesc);
5722
+ this.outlineMirrorPipeline = this.createRenderPipeline({
5723
+ ...outlineDesc,
5724
+ label: "outline pipeline (mirror)",
5725
+ cullMode: "front",
5266
5726
  });
5267
5727
  // ─── Selection overlay (screen-space edge-detect on a per-material mask) ───
5268
5728
  // Reuses outline camera + main skinMats bind group layouts. No group 2 (no per-mat uniform).
@@ -5820,12 +6280,25 @@ export class Engine {
5820
6280
  }
5821
6281
  this.mirrorBlurBindGroups = null;
5822
6282
  this.mirrorMaskMsTexture = this.device.createTexture({
5823
- label: "mirror aux (msaa, discarded)",
6283
+ label: "mirror aux (msaa)",
5824
6284
  size: [mw, mh],
5825
6285
  sampleCount: Engine.MULTISAMPLE_COUNT,
5826
6286
  format: Engine.BLOOM_MASK_FORMAT,
5827
6287
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
5828
6288
  });
6289
+ this.mirrorMaskTexture = this.device.createTexture({
6290
+ label: "mirror aux resolve (coverage)",
6291
+ size: [mw, mh],
6292
+ mipLevelCount: this.mirrorMipCount,
6293
+ format: Engine.BLOOM_MASK_FORMAT,
6294
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
6295
+ });
6296
+ this.mirrorMaskView = this.mirrorMaskTexture.createView();
6297
+ this.mirrorMaskMipViews = [];
6298
+ for (let i = 0; i < this.mirrorMipCount; i++) {
6299
+ this.mirrorMaskMipViews.push(this.mirrorMaskTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
6300
+ }
6301
+ this.mirrorMaskBlurBindGroups = null;
5829
6302
  this.mirrorIdMsTexture = mrtIdsEnabled()
5830
6303
  ? this.device.createTexture({
5831
6304
  label: "mirror id (msaa, discarded)",
@@ -5854,6 +6327,10 @@ export class Engine {
5854
6327
  };
5855
6328
  const mirrorMask = {
5856
6329
  view: this.mirrorMaskMsTexture.createView(),
6330
+ // RESOLVED now rather than discarded: .g is the reflection's coverage.
6331
+ // Mip 0 of the chain, not the whole-chain view: a resolve target is a
6332
+ // single level.
6333
+ resolveTarget: this.mirrorMaskMipViews[0],
5857
6334
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
5858
6335
  loadOp: "clear",
5859
6336
  storeOp: "discard",
@@ -5881,8 +6358,10 @@ export class Engine {
5881
6358
  stencilStoreOp: "discard",
5882
6359
  },
5883
6360
  };
5884
- // The ground binds the reflection resolve; rebind it against the new one.
6361
+ // The ground and the mirror surface both bind the reflection resolve;
6362
+ // rebind both against the new one.
5885
6363
  this.buildGroundBindGroup();
6364
+ this.buildMirrorSurfaceBindGroup();
5886
6365
  // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
5887
6366
  // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
5888
6367
  const bw = Math.max(1, Math.floor(width / 2));
@@ -6400,11 +6879,6 @@ export class Engine {
6400
6879
  size: 32,
6401
6880
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6402
6881
  });
6403
- this.wireframeHoverUniformBuffer = this.device.createBuffer({
6404
- label: "wireframe color (hovered material)",
6405
- size: 32,
6406
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6407
- });
6408
6882
  const wireBg0 = this.device.createBindGroupLayout({
6409
6883
  label: "wireframe group 0 layout (camera + wire)",
6410
6884
  entries: [
@@ -6513,6 +6987,58 @@ export class Engine {
6513
6987
  },
6514
6988
  multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
6515
6989
  });
6990
+ // The selection fill: vsDepth/fs again, no bias needed since it draws
6991
+ // ALWAYS-ahead like the rest of this layer's basic primitives (matching
6992
+ // the red edge lines selectMaterialFaces hands back — the two are meant
6993
+ // to read as one highlight, not fight each other's depth handling) —
6994
+ // never occluded, which a selection drawn over the far side of a body
6995
+ // the box also caught would otherwise need X-ray to explain.
6996
+ this.selectionFillUniformBuffer = this.device.createBuffer({
6997
+ label: "selection fill color",
6998
+ size: 32,
6999
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7000
+ });
7001
+ this.device.queue.writeBuffer(this.selectionFillUniformBuffer, 0, new Float32Array([1, 0.15, 0.15, 0.45, 0, 0, 0, 0]));
7002
+ this.selectionFillBindGroup = this.device.createBindGroup({
7003
+ label: "selection fill bind group",
7004
+ layout: wireBg0,
7005
+ entries: [
7006
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
7007
+ { binding: 1, resource: { buffer: this.selectionFillUniformBuffer } },
7008
+ ],
7009
+ });
7010
+ this.selectionFillPipeline = this.device.createRenderPipeline({
7011
+ label: "selection fill pipeline",
7012
+ layout: this.device.createPipelineLayout({
7013
+ label: "selection fill pipeline layout",
7014
+ bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
7015
+ }),
7016
+ vertex: {
7017
+ module: wireShader,
7018
+ entryPoint: "vsDepth",
7019
+ buffers: [
7020
+ { arrayStride: 8 * 4, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" }] },
7021
+ { arrayStride: 4 * 2, attributes: [{ shaderLocation: 1, offset: 0, format: "uint16x4" }] },
7022
+ { arrayStride: 4, attributes: [{ shaderLocation: 2, offset: 0, format: "unorm8x4" }] },
7023
+ ],
7024
+ },
7025
+ fragment: {
7026
+ module: wireShader,
7027
+ entryPoint: "fs",
7028
+ targets: [
7029
+ {
7030
+ format: this.presentationFormat,
7031
+ blend: {
7032
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
7033
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
7034
+ },
7035
+ },
7036
+ ],
7037
+ },
7038
+ primitive: { topology: "triangle-list", cullMode: "none" },
7039
+ depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
7040
+ multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
7041
+ });
6516
7042
  this.wireframeBindGroup = this.device.createBindGroup({
6517
7043
  label: "wireframe bind group",
6518
7044
  layout: wireBg0,
@@ -6529,14 +7055,6 @@ export class Engine {
6529
7055
  { binding: 1, resource: { buffer: this.wireframeSeamUniformBuffer } },
6530
7056
  ],
6531
7057
  });
6532
- this.wireframeHoverBindGroup = this.device.createBindGroup({
6533
- label: "wireframe bind group (hovered material)",
6534
- layout: wireBg0,
6535
- entries: [
6536
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
6537
- { binding: 1, resource: { buffer: this.wireframeHoverUniformBuffer } },
6538
- ],
6539
- });
6540
7058
  this.overlayPassDescriptor = {
6541
7059
  label: "overlay pass",
6542
7060
  timestampWrites: this.stamps("overlay"),
@@ -7047,6 +7565,12 @@ export class Engine {
7047
7565
  setCameraDistance(d) {
7048
7566
  this.camera.radius = d;
7049
7567
  }
7568
+ /** Which button drags orbit the view — see Camera.setOrbitButton. Default
7569
+ * "left" (this engine's own, original binding, every existing consumer's
7570
+ * default); "middle" is Blender's, opt-in only. */
7571
+ setCameraOrbitButton(button) {
7572
+ this.camera?.setOrbitButton(button);
7573
+ }
7050
7574
  getCameraAlpha() {
7051
7575
  return this.camera.alpha;
7052
7576
  }
@@ -7663,6 +8187,26 @@ export class Engine {
7663
8187
  setGroundVisible(on) {
7664
8188
  this.groundHidden = !on;
7665
8189
  }
8190
+ /**
8191
+ * Moves a model to a new key. Nothing about it is rebuilt — same GPU
8192
+ * buffers, same style groups, same physics — only which string the rest
8193
+ * of the engine finds it under changes.
8194
+ *
8195
+ * What a document reload uses to swap in a freshly-loaded replacement
8196
+ * under the ORIGINAL id with no visible gap: the replacement loads under
8197
+ * a throwaway key while the current one keeps rendering untouched, and
8198
+ * only this call — a Map move, nothing async, nothing rebuilt — hands it
8199
+ * the name everything else in the app still expects.
8200
+ */
8201
+ renameModel(oldName, newName) {
8202
+ const inst = this.modelInstances.get(oldName);
8203
+ if (!inst || oldName === newName || this.modelInstances.has(newName))
8204
+ return;
8205
+ this.modelInstances.delete(oldName);
8206
+ inst.name = newName;
8207
+ inst.model.setName(newName);
8208
+ this.modelInstances.set(newName, inst);
8209
+ }
7666
8210
  removeModel(name) {
7667
8211
  const inst = this.modelInstances.get(name);
7668
8212
  if (!inst)
@@ -8112,6 +8656,210 @@ export class Engine {
8112
8656
  }
8113
8657
  return best;
8114
8658
  }
8659
+ /**
8660
+ * Every face of one material fully inside a screen-space rectangle, on the
8661
+ * POSED mesh — same reason pickMaterial reads it posed: a drag selects
8662
+ * what a costume actually looks like, not the bind pose underneath it.
8663
+ *
8664
+ * A face counts as "inside" only when all three corners project into the
8665
+ * rect. A triangle straddling the edge stays out — splitting a material by
8666
+ * a box is a spatial cut, and a half-caught triangle would tear a seam
8667
+ * that started nowhere the box actually touched.
8668
+ *
8669
+ * `faceIndices` are LOCAL to the material's own face list (0 = its first
8670
+ * triangle) — what a document transform like splitMaterial needs, since it
8671
+ * never has to know where in the shared index buffer this material's run
8672
+ * starts. `lines` is every caught face's own three edges, in POSED world
8673
+ * space and ready to hand straight to setOverlay — a selection reads as the
8674
+ * FACES it covers, not a scatter of points at their corners, and a second
8675
+ * query back through skinning just to draw it would double the cost of
8676
+ * every drag.
8677
+ *
8678
+ * x0/y0/x1/y1 are CSS pixels relative to the canvas, same origin
8679
+ * pickMaterial's x/y use. Order does not matter — a drag can go in any
8680
+ * direction.
8681
+ */
8682
+ selectMaterialFaces(modelName, materialName, x0, y0, x1, y1) {
8683
+ const empty = { faceIndices: [], lines: [] };
8684
+ const inst = this.modelInstances.get(modelName);
8685
+ if (!inst || !this.camera || inst.isStage || inst.isPlane || inst.isProp)
8686
+ return empty;
8687
+ const width = this.canvas.clientWidth;
8688
+ const height = this.canvas.clientHeight;
8689
+ if (width <= 0 || height <= 0)
8690
+ return empty;
8691
+ const left = Math.min(x0, x1);
8692
+ const right = Math.max(x0, x1);
8693
+ const top = Math.min(y0, y1);
8694
+ const bottom = Math.max(y0, y1);
8695
+ const model = inst.model;
8696
+ const materials = model.getMaterials();
8697
+ const mIndex = materials.findIndex((m) => m.name === materialName);
8698
+ if (mIndex < 0)
8699
+ return empty;
8700
+ const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values;
8701
+ const { positions } = model.getGeometry();
8702
+ const count = positions.length / 3;
8703
+ const { joints, weights } = model.getSkinning();
8704
+ const skin = model.getSkinMatrices();
8705
+ // Skin once, same as pickMaterial: three slots per vertex, screen x/y and
8706
+ // clip w — cw doubles as "behind the camera" for the in-rect test below.
8707
+ if (!this.materialSelectScratch || this.materialSelectScratch.length !== count * 3) {
8708
+ this.materialSelectScratch = new Float32Array(count * 3);
8709
+ }
8710
+ const proj = this.materialSelectScratch;
8711
+ const skinnedWorld = (v) => {
8712
+ const bx = positions[v * 3];
8713
+ const by = positions[v * 3 + 1];
8714
+ const bz = positions[v * 3 + 2];
8715
+ let px = 0;
8716
+ let py = 0;
8717
+ let pz = 0;
8718
+ for (let k = 0; k < 4; k++) {
8719
+ const w = weights[v * 4 + k] / 255;
8720
+ if (w === 0)
8721
+ continue;
8722
+ const m = joints[v * 4 + k] * 16;
8723
+ px += w * (skin[m] * bx + skin[m + 4] * by + skin[m + 8] * bz + skin[m + 12]);
8724
+ py += w * (skin[m + 1] * bx + skin[m + 5] * by + skin[m + 9] * bz + skin[m + 13]);
8725
+ pz += w * (skin[m + 2] * bx + skin[m + 6] * by + skin[m + 10] * bz + skin[m + 14]);
8726
+ }
8727
+ return [px, py, pz];
8728
+ };
8729
+ for (let v = 0; v < count; v++) {
8730
+ const [px, py, pz] = skinnedWorld(v);
8731
+ const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15];
8732
+ proj[v * 3 + 2] = cw;
8733
+ if (cw <= 1e-6)
8734
+ continue;
8735
+ const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12];
8736
+ const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13];
8737
+ proj[v * 3] = ((cx / cw) * 0.5 + 0.5) * width;
8738
+ proj[v * 3 + 1] = (1 - ((cy / cw) * 0.5 + 0.5)) * height;
8739
+ }
8740
+ const inRect = (v) => {
8741
+ const cw = proj[v * 3 + 2];
8742
+ if (cw <= 1e-6)
8743
+ return false;
8744
+ const sx = proj[v * 3];
8745
+ const sy = proj[v * 3 + 1];
8746
+ return sx >= left && sx <= right && sy >= top && sy <= bottom;
8747
+ };
8748
+ // Walk to this material's own run, the same running sum pickMaterial's
8749
+ // matEnd walk uses — materials own a CONTIGUOUS run of the index buffer.
8750
+ const indices = model.getIndices();
8751
+ let start = 0;
8752
+ for (let i = 0; i < mIndex; i++)
8753
+ start += materials[i].vertexCount;
8754
+ const end = start + materials[mIndex].vertexCount;
8755
+ const faceIndices = [];
8756
+ const lines = [];
8757
+ let faceIndex = 0;
8758
+ for (let i = start; i < end; i += 3, faceIndex++) {
8759
+ const a = indices[i];
8760
+ const b = indices[i + 1];
8761
+ const c = indices[i + 2];
8762
+ if (!inRect(a) || !inRect(b) || !inRect(c))
8763
+ continue;
8764
+ faceIndices.push(faceIndex);
8765
+ const pa = skinnedWorld(a);
8766
+ const pb = skinnedWorld(b);
8767
+ const pc = skinnedWorld(c);
8768
+ // Every edge, including the ones shared with a neighbouring caught
8769
+ // face — drawn twice there, which costs nothing a triangle this size
8770
+ // notices and is simpler than tracking which edges are interior.
8771
+ for (const [p, q] of [
8772
+ [pa, pb],
8773
+ [pb, pc],
8774
+ [pc, pa],
8775
+ ]) {
8776
+ const line = lineBetween(p, q, Engine.SELECTION_COLOR);
8777
+ if (line)
8778
+ lines.push(line);
8779
+ }
8780
+ }
8781
+ return { faceIndices, lines };
8782
+ }
8783
+ /**
8784
+ * Fill the given faces of one material solid red, live-skinned — unlike
8785
+ * the edge `lines` selectMaterialFaces hands back, which are a one-shot
8786
+ * snapshot at the moment of the drag, this reads the model's CURRENT skin
8787
+ * matrices every frame, so it keeps tracking the mesh through a pose
8788
+ * change the same way the wireframe overlay does.
8789
+ *
8790
+ * `faceIndices` are the same LOCAL (material-relative) numbers
8791
+ * selectMaterialFaces returns and splitMaterial takes. Pass an empty list
8792
+ * (or a null modelName/materialName) to clear it.
8793
+ */
8794
+ setSelectionFill(modelName, materialName, faceIndices) {
8795
+ if (this.selectionFill) {
8796
+ this.selectionFill.buffer.destroy();
8797
+ this.selectionFill = null;
8798
+ }
8799
+ if (!modelName || !materialName || faceIndices.length === 0)
8800
+ return;
8801
+ const inst = this.modelInstances.get(modelName);
8802
+ if (!inst)
8803
+ return;
8804
+ const materials = inst.model.getMaterials();
8805
+ const mIndex = materials.findIndex((m) => m.name === materialName);
8806
+ if (mIndex < 0)
8807
+ return;
8808
+ let start = 0;
8809
+ for (let i = 0; i < mIndex; i++)
8810
+ start += materials[i].vertexCount;
8811
+ const faceCount = materials[mIndex].vertexCount / 3;
8812
+ const modelIndices = inst.model.getIndices();
8813
+ const data = new Uint32Array(faceIndices.length * 3);
8814
+ let n = 0;
8815
+ for (const f of faceIndices) {
8816
+ if (f < 0 || f >= faceCount)
8817
+ continue;
8818
+ const i = start + f * 3;
8819
+ data[n++] = modelIndices[i];
8820
+ data[n++] = modelIndices[i + 1];
8821
+ data[n++] = modelIndices[i + 2];
8822
+ }
8823
+ if (n === 0)
8824
+ return;
8825
+ const buffer = this.device.createBuffer({
8826
+ label: `selection fill ${modelName}/${materialName}`,
8827
+ size: n * 4,
8828
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
8829
+ });
8830
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, 0, n * 4);
8831
+ // wireframeSkinLayout's binding 4 is the wireframe's own edge list, which
8832
+ // vsDepth never reads — inst.vertexBuffer (already bound as storage
8833
+ // elsewhere, for that same edge-drawing use) satisfies the layout's slot
8834
+ // as a harmless stand-in rather than a fifth buffer built only to sit unused.
8835
+ const skinBindGroup = this.device.createBindGroup({
8836
+ label: `selection fill skin ${modelName}`,
8837
+ layout: this.wireframeSkinLayout,
8838
+ entries: [
8839
+ { binding: 0, resource: { buffer: inst.skinMatrixBuffer } },
8840
+ { binding: 1, resource: { buffer: inst.vertexBuffer } },
8841
+ { binding: 2, resource: { buffer: inst.jointsBuffer } },
8842
+ { binding: 3, resource: { buffer: inst.weightsBuffer } },
8843
+ { binding: 4, resource: { buffer: inst.vertexBuffer } },
8844
+ ],
8845
+ });
8846
+ this.selectionFill = { modelName, buffer, count: n, skinBindGroup };
8847
+ }
8848
+ renderSelectionFill(pass) {
8849
+ if (!this.selectionFill)
8850
+ return;
8851
+ const inst = this.modelInstances.get(this.selectionFill.modelName);
8852
+ if (!inst)
8853
+ return;
8854
+ pass.setPipeline(this.selectionFillPipeline);
8855
+ pass.setBindGroup(0, this.selectionFillBindGroup);
8856
+ pass.setBindGroup(1, this.selectionFill.skinBindGroup);
8857
+ pass.setVertexBuffer(0, inst.vertexBuffer);
8858
+ pass.setVertexBuffer(1, inst.jointsBuffer);
8859
+ pass.setVertexBuffer(2, inst.weightsBuffer);
8860
+ pass.setIndexBuffer(this.selectionFill.buffer, "uint32");
8861
+ pass.drawIndexed(this.selectionFill.count);
8862
+ }
8115
8863
  /** Draw an octahedron per bone of `modelName`, rebuilt each frame. Null off. */
8116
8864
  setBoneOverlay(modelName, options = {}) {
8117
8865
  this.overlayBones = modelName ? { modelName, options } : null;
@@ -9620,16 +10368,35 @@ export class Engine {
9620
10368
  });
9621
10369
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
9622
10370
  }
9623
- buildGroundPipeline(soft) {
10371
+ buildGroundPipeline(soft, mirrored = false) {
9624
10372
  return this.createRenderPipeline({
9625
10373
  ...this.groundShadowPipelineDesc,
9626
- label: soft ? "ground shadow pipeline (soft)" : "ground shadow pipeline",
10374
+ // A REFLECTION FLIPS WINDING — determinant -1, see reflection.ts. The
10375
+ // ground culls back faces, so drawn into the mirror pass with the ordinary
10376
+ // pipeline every one of its triangles faces away and the floor vanishes
10377
+ // whole: no grid, and no received shadow either, since the shadow is a
10378
+ // layer of this shader. It is the same fact that sits the OUTLINE out of
10379
+ // the mirror, and the same answer the scene-pass pipelines already use.
10380
+ cullMode: mirrored ? "none" : this.groundShadowPipelineDesc.cullMode,
10381
+ label: `ground shadow pipeline${soft ? " (soft)" : ""}${mirrored ? " (mirror)" : ""}`,
9627
10382
  shaderModule: this.device.createShaderModule({
9628
10383
  label: soft ? "ground shadow (soft)" : "ground shadow",
9629
10384
  code: groundShaderWgsl(soft),
9630
10385
  }),
9631
10386
  });
9632
10387
  }
10388
+ /** The ground's pipeline for the mirror pass, built on the first frame a
10389
+ * mirror actually needs it. */
10390
+ ensureGroundMirrorPipeline(soft) {
10391
+ if (soft) {
10392
+ if (!this.groundMirrorSoftPipeline)
10393
+ this.groundMirrorSoftPipeline = this.buildGroundPipeline(true, true);
10394
+ return this.groundMirrorSoftPipeline;
10395
+ }
10396
+ if (!this.groundMirrorPipeline)
10397
+ this.groundMirrorPipeline = this.buildGroundPipeline(false, true);
10398
+ return this.groundMirrorPipeline;
10399
+ }
9633
10400
  /** Built on the first frame that actually needs it. A shader compile costs
9634
10401
  * load time, and the overwhelming majority of scenes never soften a shadow. */
9635
10402
  ensureGroundSoftPipeline() {
@@ -9699,9 +10466,47 @@ export class Engine {
9699
10466
  * and a bind group holding the old view would sample a destroyed texture on
9700
10467
  * the first resized frame with a mirror on.
9701
10468
  */
10469
+ /** The stand-ins above, made once. 4x4 rather than 1x1: the depth one has to
10470
+ * be multisampled to match `texture_depth_multisampled_2d`, and a
10471
+ * multisampled attachment is the one kind of texture worth giving room. */
10472
+ ensureMirrorDummies() {
10473
+ if (this.mirrorDummyColorView && this.mirrorDummyDepthView)
10474
+ return;
10475
+ this.mirrorDummyColorView = this.device
10476
+ .createTexture({
10477
+ label: "mirror binding stand-in (colour)",
10478
+ size: [4, 4],
10479
+ format: this.hdrFormat,
10480
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
10481
+ })
10482
+ .createView();
10483
+ this.mirrorDummyDepthView = this.device
10484
+ .createTexture({
10485
+ label: "mirror binding stand-in (depth)",
10486
+ size: [4, 4],
10487
+ sampleCount: Engine.MULTISAMPLE_COUNT,
10488
+ format: this.depthFormat,
10489
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
10490
+ })
10491
+ .createView({ aspect: "depth-only" });
10492
+ }
9702
10493
  buildGroundBindGroup() {
9703
10494
  if (!this.groundShadowMaterialBuffer)
9704
10495
  return;
10496
+ this.ensureMirrorDummies();
10497
+ if (!this.groundClipOffBuffer) {
10498
+ const make = (label, active) => {
10499
+ const b = this.device.createBuffer({
10500
+ label,
10501
+ size: 32,
10502
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
10503
+ });
10504
+ this.device.queue.writeBuffer(b, 0, new Float32Array([0, 1, 0, 0, active, 0, 0, 0]));
10505
+ return b;
10506
+ };
10507
+ this.groundClipOffBuffer = make("ground clip (camera: off)", 0);
10508
+ this.groundClipMirrorBuffer = make("ground clip (mirror)", 1);
10509
+ }
9705
10510
  this.groundShadowBindGroup = this.device.createBindGroup({
9706
10511
  label: "ground shadow bind",
9707
10512
  layout: this.groundShadowBindGroupLayout,
@@ -9721,6 +10526,31 @@ export class Engine {
9721
10526
  { binding: 10, resource: this.materialSampler },
9722
10527
  { binding: 11, resource: this.mirrorDepthReadView },
9723
10528
  { binding: 12, resource: this.groundNoiseView },
10529
+ { binding: 13, resource: this.mirrorMaskView },
10530
+ { binding: 14, resource: { buffer: this.groundClipOffBuffer } },
10531
+ ],
10532
+ });
10533
+ this.groundMirrorViewBindGroup = this.device.createBindGroup({
10534
+ label: "ground shadow bind (mirror view)",
10535
+ layout: this.groundShadowBindGroupLayout,
10536
+ entries: [
10537
+ { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
10538
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
10539
+ { binding: 2, resource: this.shadowMapDepthViews[0] },
10540
+ { binding: 3, resource: this.shadowComparisonSampler },
10541
+ { binding: 4, resource: { buffer: this.groundShadowMaterialBuffer } },
10542
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
10543
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
10544
+ { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
10545
+ { binding: 8, resource: { buffer: this.mirrorVPBuffer } },
10546
+ // Stand-ins, not the live mirror textures: this group draws INTO the
10547
+ // mirror pass, where those are the attachments.
10548
+ { binding: 9, resource: this.mirrorDummyColorView },
10549
+ { binding: 10, resource: this.materialSampler },
10550
+ { binding: 11, resource: this.mirrorDummyDepthView },
10551
+ { binding: 12, resource: this.groundNoiseView },
10552
+ { binding: 13, resource: this.mirrorDummyColorView },
10553
+ { binding: 14, resource: { buffer: this.groundClipMirrorBuffer } },
9724
10554
  ],
9725
10555
  });
9726
10556
  if (this.groundDrawCall)
@@ -10297,6 +11127,23 @@ export class Engine {
10297
11127
  }
10298
11128
  this.device.queue.submit([encoder.finish()]);
10299
11129
  }
11130
+ /** The ground draw, against a caller's bind group — the camera's, or the
11131
+ * mirror's. One body, so the reflected floor cannot drift from the real one. */
11132
+ renderGroundWith(pass, bindGroup, mirrored = false) {
11133
+ if (this.groundIsSuppressed())
11134
+ return;
11135
+ if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall)
11136
+ return;
11137
+ pass.setPipeline(mirrored
11138
+ ? this.ensureGroundMirrorPipeline(this.groundSoft)
11139
+ : this.groundSoft
11140
+ ? this.ensureGroundSoftPipeline()
11141
+ : this.groundShadowPipeline);
11142
+ pass.setVertexBuffer(0, this.groundVertexBuffer);
11143
+ pass.setIndexBuffer(this.groundIndexBuffer, "uint16");
11144
+ pass.setBindGroup(0, bindGroup);
11145
+ pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0);
11146
+ }
10300
11147
  renderGround(pass) {
10301
11148
  // A stage brings its own floor. Both sit at y=0, so drawing the built-in
10302
11149
  // plane underneath produces z-fighting across the whole scene — enforced
@@ -10472,7 +11319,16 @@ export class Engine {
10472
11319
  if (!this.overlayVertices)
10473
11320
  return;
10474
11321
  const inst = this.overlayModel(this.overlayVertices.modelName);
10475
- const { material, xray } = this.overlayVertices;
11322
+ const { xray } = this.overlayVertices;
11323
+ // A hovered material previews EXACTLY what clicking it would pick — the
11324
+ // same self-occluding, ISOLATED view, nothing else drawn. It substitutes
11325
+ // for `material` only while nothing is actually picked, so the section
11326
+ // opens on the whole mesh and narrows to one material the moment the
11327
+ // pointer (in the viewport OR the list — both just call
11328
+ // setHoveredMaterial) settles on it. A real pick always wins, and there
11329
+ // is nothing left to preview once one is made.
11330
+ const hm = this.hoverMaterial;
11331
+ const material = this.overlayVertices.material ?? (hm && hm.modelName === inst?.name ? hm.materialName : null);
10476
11332
  if (!inst || !this.ensureEdgeBuffer(inst, material))
10477
11333
  return;
10478
11334
  const edges = inst.wireEdges.get(material ?? "");
@@ -10485,15 +11341,6 @@ export class Engine {
10485
11341
  const seams = material === null && this.ensureEdgeBuffer(inst, null, true)
10486
11342
  ? (inst.wireEdges.get(Engine.SEAM_KEY) ?? null)
10487
11343
  : null;
10488
- // A pointer over a material previews EXACTLY what clicking it would pick —
10489
- // the same self-occluding reveal, layered over the section-wide view rather
10490
- // than replacing it, so the rest of the mesh stays legible while one
10491
- // material calls attention to itself. Meaningless once something IS
10492
- // picked, since only the picked material draws at all then.
10493
- const hm = this.hoverMaterial;
10494
- const hoverName = material === null && hm?.modelName === inst.name ? hm.materialName : null;
10495
- const hoverRuns = hoverName ? this.materialIndexRanges(inst, hoverName) : [];
10496
- const hover = hoverRuns.length > 0 && this.ensureEdgeBuffer(inst, hoverName) ? inst.wireEdges.get(hoverName) : null;
10497
11344
  this.wireframeColorData.set(DEFAULT_VERTEX_COLOR);
10498
11345
  this.wireframeColorData[4] = this.canvas.width;
10499
11346
  this.wireframeColorData[5] = this.canvas.height;
@@ -10508,40 +11355,8 @@ export class Engine {
10508
11355
  this.wireframeColorData[6] = OVERLAY_STYLE.seamStrokePx;
10509
11356
  this.device.queue.writeBuffer(this.wireframeSeamUniformBuffer, 0, this.wireframeColorData);
10510
11357
  }
10511
- if (hover) {
10512
- this.wireframeColorData[6] = OVERLAY_STYLE.hoverStrokePx;
10513
- this.device.queue.writeBuffer(this.wireframeHoverUniformBuffer, 0, this.wireframeColorData);
10514
- }
10515
- // Both bind groups, before the FIRST draw call below, regardless of which
10516
- // branch runs first — the depth prepass reads the camera from group 0 and
10517
- // the skin matrices from group 1 same as the edge pass does, and every
10518
- // draw in this function needs both set to SOMETHING before it runs. Each
10519
- // block below is free to swap either one out for its own draws.
10520
11358
  pass.setBindGroup(0, this.wireframeBindGroup);
10521
11359
  pass.setBindGroup(1, edges.bindGroup);
10522
- const bindMesh = () => {
10523
- pass.setPipeline(this.wireframeDepthPipeline);
10524
- pass.setVertexBuffer(0, inst.vertexBuffer);
10525
- pass.setVertexBuffer(1, inst.jointsBuffer);
10526
- pass.setVertexBuffer(2, inst.weightsBuffer);
10527
- pass.setIndexBuffer(inst.indexBuffer, "uint32");
10528
- };
10529
- // The hover preview writes and draws against its OWN depth first, while the
10530
- // shared depth buffer is still empty — the same trick a pick uses, run
10531
- // before the base mesh below gets a chance to occlude it. The base mesh's
10532
- // depth write further down repeats the SAME geometry for these faces
10533
- // (identical z), so it neither disturbs this nor needs to skip them.
10534
- if (hover && !xray) {
10535
- bindMesh();
10536
- for (const [start, end] of hoverRuns)
10537
- pass.drawIndexed(end - start, 1, start);
10538
- pass.setBindGroup(0, this.wireframeHoverBindGroup);
10539
- pass.setBindGroup(1, hover.bindGroup);
10540
- pass.setPipeline(this.wireframePipeline);
10541
- pass.draw(6, hover.count / 2);
10542
- pass.setBindGroup(0, this.wireframeBindGroup);
10543
- pass.setBindGroup(1, edges.bindGroup);
10544
- }
10545
11360
  // What writes depth is what is allowed to hide the wireframe, and that
10546
11361
  // differs between the two views.
10547
11362
  //
@@ -10550,14 +11365,18 @@ export class Engine {
10550
11365
  // mode and Maya included, and seeing both walls at once is moire rather than
10551
11366
  // information.
10552
11367
  //
10553
- // A PICKED material writes only its OWN faces. The question a pick asks is
11368
+ // A PICKED (or hovered) material writes only its OWN faces. The question is
10554
11369
  // where this material is, and half of it is usually under a coat; letting
10555
11370
  // the coat hide it does not answer that. Its own depth still goes in, so its
10556
11371
  // back faces stay hidden and it reads as an object instead of a haze —
10557
11372
  // which is the difference between this and turning x-ray on.
10558
11373
  const runs = material !== null ? this.materialIndexRanges(inst, material) : null;
10559
11374
  if (!xray) {
10560
- bindMesh();
11375
+ pass.setPipeline(this.wireframeDepthPipeline);
11376
+ pass.setVertexBuffer(0, inst.vertexBuffer);
11377
+ pass.setVertexBuffer(1, inst.jointsBuffer);
11378
+ pass.setVertexBuffer(2, inst.weightsBuffer);
11379
+ pass.setIndexBuffer(inst.indexBuffer, "uint32");
10561
11380
  if (runs)
10562
11381
  for (const [start, end] of runs)
10563
11382
  pass.drawIndexed(end - start, 1, start);
@@ -10590,7 +11409,8 @@ export class Engine {
10590
11409
  this.overlayBones !== null ||
10591
11410
  this.overlayBodies !== null ||
10592
11411
  this.overlayJoints !== null ||
10593
- this.overlayVertices !== null);
11412
+ this.overlayVertices !== null ||
11413
+ this.selectionFill !== null);
10594
11414
  }
10595
11415
  overlayModel(name) {
10596
11416
  return this.modelInstances.get(name) ?? null;
@@ -10709,7 +11529,7 @@ export class Engine {
10709
11529
  if (inst)
10710
11530
  collect(jointOverlay(inst.model, inst.physics, this.overlayJoints.options));
10711
11531
  }
10712
- if (total === 0 && !this.overlayVertices)
11532
+ if (total === 0 && !this.overlayVertices && !this.selectionFill)
10713
11533
  return;
10714
11534
  this.ensureOverlayInstanceCapacity(total);
10715
11535
  const data = this.overlayInstanceData;
@@ -10736,6 +11556,10 @@ export class Engine {
10736
11556
  const pass = encoder.beginRenderPass(this.overlayPassDescriptor);
10737
11557
  // Under everything: the mesh is the haze the rig is read against.
10738
11558
  this.renderWireframe(pass);
11559
+ // The selection wash, under its own edge lines (drawn below with the rest
11560
+ // of this layer's basic primitives) so the crisp outline still reads on
11561
+ // top of the fill rather than being washed out by it.
11562
+ this.renderSelectionFill(pass);
10739
11563
  pass.setBindGroup(0, this.overlayBindGroup);
10740
11564
  pass.setVertexBuffer(0, this.overlayVertexBuffer);
10741
11565
  pass.setVertexBuffer(1, this.overlayInstanceBuffer);
@@ -11260,6 +12084,8 @@ export class Engine {
11260
12084
  this.dispatchMorphCompute(encoder);
11261
12085
  // Frustum cull into indirect arguments. After the camera and shadow matrices
11262
12086
  // are settled, before the passes that draw from them.
12087
+ // Before reflectionActive is read: this is what decides it.
12088
+ this.syncMirrorFromEffects();
11263
12089
  if (this.reflectionActive)
11264
12090
  this.updateMirrorCamera();
11265
12091
  if (hasModels)
@@ -11313,6 +12139,14 @@ export class Engine {
11313
12139
  // instance count of an invisible model's draws.
11314
12140
  if (wanted && this.shadowBundles[ci])
11315
12141
  sp.executeBundles([this.shadowBundles[ci]]);
12142
+ // The mirror throws shade like anything else standing on the floor.
12143
+ // Direct rather than in the bundle: the bundles are recorded on scene
12144
+ // STRUCTURE, and a mirror comes and goes with an effect's weight.
12145
+ if (wanted && this.mirrorSurface && this.mirrorShadowPipeline) {
12146
+ sp.setPipeline(this.mirrorShadowPipeline);
12147
+ sp.setBindGroup(0, this.mirrorShadowBindGroups[ci]);
12148
+ sp.draw(6);
12149
+ }
11316
12150
  sp.end();
11317
12151
  this.shadowCascadeCleared[ci] = !wanted;
11318
12152
  }
@@ -11364,6 +12198,10 @@ export class Engine {
11364
12198
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
11365
12199
  if (this.hasGround)
11366
12200
  this.renderGround(pass);
12201
+ // After the ground for the same early-z reason, and before the transparent
12202
+ // phase so sheer fabric blends over the glass rather than being depth
12203
+ // -rejected behind it.
12204
+ this.renderMirrorSurface(pass);
11367
12205
  // The transparent phase is drawn DIRECTLY, and must stay that way. It is the
11368
12206
  // one part of this pass that is not bundled, so the reason is worth keeping.
11369
12207
  //
@@ -11578,13 +12416,21 @@ export class Engine {
11578
12416
  claimed.set(m, g.id);
11579
12417
  }
11580
12418
  }
11581
- // Tear down installs no longer present.
12419
+ // Installs no longer present lose their CLAIM now (the generation bump
12420
+ // invalidates any in-flight compile for that id) but keep their BUFFER
12421
+ // until every draw call has stopped pointing at it. This was the actual
12422
+ // bug: destroying here, synchronously, left the render loop — which runs
12423
+ // independently on its own rAF and does not know this function is
12424
+ // mid-await — still submitting frames through a bind group whose buffer
12425
+ // no longer existed, for however many frames compileAndInstallGroup's own
12426
+ // shader compiles below took. "used in submit while destroyed" was that
12427
+ // race, not a one-off.
11582
12428
  const nextIds = new Set(groups.map((g) => g.id));
12429
+ const outgoing = [];
11583
12430
  for (const [id, install] of inst.styleGroups) {
11584
12431
  if (!nextIds.has(id)) {
11585
12432
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1);
11586
- this.destroyInstall(install);
11587
- inst.styleGroups.delete(id);
12433
+ outgoing.push([id, install]);
11588
12434
  }
11589
12435
  }
11590
12436
  const groupResults = [];
@@ -11592,7 +12438,13 @@ export class Engine {
11592
12438
  const r = await this.compileAndInstallGroup(inst, g);
11593
12439
  groupResults.push({ groupId: g.id, diagnostics: r.diagnostics, ok: r.ok });
11594
12440
  }
12441
+ // Repoints every draw call away from the outgoing installs (among
12442
+ // everything else) — only past this line is destroying them safe.
11595
12443
  this.assignDrawCallGroups(inst, claimed);
12444
+ for (const [id, install] of outgoing) {
12445
+ this.destroyInstall(install);
12446
+ inst.styleGroups.delete(id);
12447
+ }
11596
12448
  return {
11597
12449
  ok: groupResults.every((r) => r.ok),
11598
12450
  groups: groupResults,
@@ -11880,6 +12732,7 @@ export class Engine {
11880
12732
  let pipeline;
11881
12733
  let pipelineNoDepthWrite;
11882
12734
  let overEyesPipeline;
12735
+ let mirrorPipeline;
11883
12736
  try {
11884
12737
  pipeline = await this.createRenderClassPipeline(renderClass, module, false);
11885
12738
  // The depth-write-off twin: stage transparency draws with it (see
@@ -11887,6 +12740,10 @@ export class Engine {
11887
12740
  pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false);
11888
12741
  if (renderClass === "hair")
11889
12742
  overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true);
12743
+ // Only the eye needs one: every other class culls "none", which a flipped
12744
+ // winding leaves alone.
12745
+ if (renderClass === "eye")
12746
+ mirrorPipeline = await this.createRenderClassPipeline(renderClass, module, false, true, true);
11890
12747
  }
11891
12748
  catch (e) {
11892
12749
  diagnostics.push({ severity: "error", message: `pipeline creation failed: ${e.message}` });
@@ -11913,6 +12770,7 @@ export class Engine {
11913
12770
  pipeline,
11914
12771
  pipelineNoDepthWrite,
11915
12772
  overEyesPipeline,
12773
+ mirrorPipeline,
11916
12774
  uniformBuffer,
11917
12775
  images: this.uploadGroupImages(group),
11918
12776
  slotMap: result.slotMap,
@@ -12012,12 +12870,20 @@ export class Engine {
12012
12870
  * render-class owns pass integration (stencil interplay, depth bias, cull). auto = plain;
12013
12871
  * eye = stamp + front cull + bias; hair = stencil-test (+ the over-eyes variant).
12014
12872
  */
12015
- createRenderClassPipeline(renderClass, module, overEyes, depthWrite = true) {
12873
+ createRenderClassPipeline(renderClass, module, overEyes, depthWrite = true, mirrored = false) {
12016
12874
  const base = {
12017
12875
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
12018
12876
  layout: this.mainPipelineLayout,
12019
12877
  vertex: { module, buffers: this.fullVertexBufferLayouts },
12020
- primitive: { cullMode: (renderClass === "eye" ? "front" : "none") },
12878
+ // The eye front-culls — the see-through-hair look is draw order plus that
12879
+ // cull plus the stencil stamp, and it is the deployed appearance of every
12880
+ // published scene. In the MIRROR the winding is flipped (determinant -1,
12881
+ // reflection.ts), so the same cull keeps the back of the eyeball and
12882
+ // discards its front. `mirrored` flips it back rather than changing what
12883
+ // the camera sees.
12884
+ primitive: {
12885
+ cullMode: (renderClass === "eye" ? (mirrored ? "back" : "front") : "none"),
12886
+ },
12021
12887
  multisample: { count: Engine.MULTISAMPLE_COUNT },
12022
12888
  };
12023
12889
  const plainDepth = {
@@ -12081,12 +12947,15 @@ export class Engine {
12081
12947
  // rain vanished the instant the camera crossed a glass dome or a curtain
12082
12948
  // (reported: binary vanish/recover with camera angle, stage loaded). Stage
12083
12949
  // transparency blends and leaves depth alone.
12084
- pipelineForDrawCall(inst, dc) {
12950
+ pipelineForDrawCall(inst, dc, mirrored = false) {
12085
12951
  const stageGlass = inst.isStage && dc.type === "transparent";
12086
12952
  if (dc.groupId) {
12087
12953
  const install = inst.styleGroups.get(dc.groupId);
12088
- if (install)
12954
+ if (install) {
12955
+ if (mirrored && install.mirrorPipeline)
12956
+ return install.mirrorPipeline;
12089
12957
  return stageGlass ? install.pipelineNoDepthWrite : install.pipeline;
12958
+ }
12090
12959
  }
12091
12960
  return stageGlass ? this.neutralPipelineNoDepthWrite : this.neutralPipeline;
12092
12961
  }
@@ -12122,7 +12991,7 @@ export class Engine {
12122
12991
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
12123
12992
  bound = true;
12124
12993
  }
12125
- const pipeline = this.pipelineForDrawCall(inst, draw);
12994
+ const pipeline = this.pipelineForDrawCall(inst, draw, view.args === "mirror");
12126
12995
  if (pipeline !== currentPipeline) {
12127
12996
  pass.setPipeline(pipeline);
12128
12997
  currentPipeline = pipeline;
@@ -12133,8 +13002,9 @@ export class Engine {
12133
13002
  // Same index range; own pipeline + groups 0/2. Group 1 (skinMats) is
12134
13003
  // layout-identical between the main and outline pipelines and stays
12135
13004
  // bound. Restore group 0 afterwards and force a pipeline re-set.
12136
- pass.setPipeline(this.outlinePipeline);
12137
- pass.setBindGroup(0, this.outlinePerFrameBindGroup);
13005
+ const mirrored = view.args === "mirror";
13006
+ pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline);
13007
+ pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup);
12138
13008
  pass.setBindGroup(2, draw.outline.bindGroup);
12139
13009
  this.issueDraw(pass, draw, view.args);
12140
13010
  pass.setBindGroup(0, view.perFrame);
@@ -12168,10 +13038,13 @@ export class Engine {
12168
13038
  * main camera's bind group.
12169
13039
  */
12170
13040
  sceneView(kind) {
13041
+ // Outlines in BOTH now. They were off in the mirror because the hull culls
13042
+ // back faces and a reflection flips winding, so every hull triangle was
13043
+ // culled — a reflection of a toon character with no ink line beside the
13044
+ // real one reads as broken. The draw picks the flipped-cull pipeline and
13045
+ // the mirror camera's own group; see outlineMirrorPipeline.
12171
13046
  return kind === "mirror"
12172
- ? // No outlines in the mirror: the hull pipeline culls back faces, and a
12173
- // reflection flips winding, so the hull would ink over the model.
12174
- { perFrame: this.mirrorPerFrameBindGroup, args: "mirror", outlines: false }
13047
+ ? { perFrame: this.mirrorPerFrameBindGroup, args: "mirror", outlines: true }
12175
13048
  : { perFrame: this.perFrameBindGroup, args: "camera", outlines: true };
12176
13049
  }
12177
13050
  renderModelOpaquePhase(pass, inst, view) {
@@ -12956,11 +13829,16 @@ Engine.TIMED_PASSES = [
12956
13829
  "composite",
12957
13830
  "overlay",
12958
13831
  ];
12959
- // ── The floor mirror (step 7C) ──
13832
+ // ── The mirror (step 7C) ──
12960
13833
  // Half-res scene-contract attachments a mirrored draw renders into, plus the
12961
- // mirror's own camera block. The plane is the ground plane: MMD floors live
12962
- // at y = 0 by convention and addGround builds its quad there.
12963
- Engine.REFLECTION_PLANE_Y = 0;
13834
+ // mirror's own camera block.
13835
+ //
13836
+ // ONE reflection target, so ONE plane at a time. The floor's is (0, 1, 0, 0)
13837
+ // — MMD floors live at y = 0 by convention and addGround builds its quad
13838
+ // there — and a placed mirror surface REPLACES it for as long as it exists,
13839
+ // because two planes would need two of every attachment below. Which one is
13840
+ // in force is mirrorPlane, refreshed by updateMirrorCamera.
13841
+ Engine.GROUND_PLANE = [0, 1, 0, 0];
12964
13842
  Engine.BLOOM_MAX_LEVELS = 5;
12965
13843
  // Width of the baked Filmic tone LUT (composite.ts FILMIC_LUT_W must match).
12966
13844
  Engine.FILMIC_LUT_WIDTH = 256;
@@ -12968,6 +13846,37 @@ Engine.FILMIC_LUT_WIDTH = 256;
12968
13846
  // (vsync-to-vsync), recomputed at STATS_REFRESH_MS so the readout doesn't flicker.
12969
13847
  Engine.STATS_WINDOW = 120;
12970
13848
  Engine.STATS_REFRESH_MS = 500;
13849
+ /**
13850
+ * The dials a `#mirror` effect is READ BY — the contract between an effect
13851
+ * file and the reflection.
13852
+ *
13853
+ * By name rather than by position, and with a fallback each, so that an
13854
+ * effect declaring only some of them still works: a mirror that wants to sit
13855
+ * at the origin facing the camera declares nothing but its size, and the ones
13856
+ * it leaves out are not missing, they are default.
13857
+ *
13858
+ * Names in the built-ins' own convention (SCREAMING_SNAKE), because these
13859
+ * appear in the params panel beside every other effect's.
13860
+ */
13861
+ Engine.MIRROR_DIALS = {
13862
+ posX: "POS_X",
13863
+ posY: "POS_Y",
13864
+ posZ: "POS_Z",
13865
+ /** Degrees, the unit the slider shows — nothing converts on the way in. */
13866
+ rotX: "ROT_X",
13867
+ rotY: "ROT_Y",
13868
+ rotZ: "ROT_Z",
13869
+ width: "WIDTH",
13870
+ height: "HEIGHT",
13871
+ tint: "TINT",
13872
+ blur: "BLUR",
13873
+ frame: "FRAME",
13874
+ frameColor: "FRAME_COLOR",
13875
+ };
13876
+ /** Opaque red — a selection is a working state, not a material property,
13877
+ * so it reads as unmistakably distinct from anything a model's own
13878
+ * colours could be. */
13879
+ Engine.SELECTION_COLOR = [1, 0.15, 0.15, 1];
12971
13880
  // ── GPU frustum cull ────────────────────────────────────────────────────────
12972
13881
  //
12973
13882
  // Sizes, once, so the arithmetic below is readable: a DrawMeta is 32 bytes