reze-engine 0.55.16 → 0.56.1

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.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,15 @@ 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 the small per-vertex writeBuffer calls setVertexPositions
1184
+ * and setBoneBindPositions make — one float32x3, reused so a live drag
1185
+ * calling either hundreds of times a frame does not also allocate hundreds
1186
+ * of throwaway arrays. */
1187
+ this.vec3Scratch = new Float32Array(3);
1188
+ /** Scratch for selectMaterialFaces — same shape as materialPickScratch,
1189
+ * its own buffer since a drag can end while a click is still in flight
1190
+ * from a different frame's hover. */
1191
+ this.materialSelectScratch = null;
1141
1192
  // CPU frame-time breakdown (EMA-smoothed into getStats): where a frame's
1142
1193
  // milliseconds actually go — animation/IK/blending vs physics vs everything
1143
1194
  // else on the render thread. The first question of any perf report.
@@ -1156,6 +1207,8 @@ export class Engine {
1156
1207
  this.groundMaterialData = null;
1157
1208
  /** Set by the first getGpuTimings() call. See it for why asking is the switch. */
1158
1209
  this.timestampsWanted = false;
1210
+ this.groundMirrorPipeline = null;
1211
+ this.groundMirrorSoftPipeline = null;
1159
1212
  // Shadow is cast from the visible sun direction — same vector the shader lights with.
1160
1213
  /** Whether the shadow map needs clearing — see the shadow pass in `render`.
1161
1214
  *
@@ -1725,9 +1778,304 @@ export class Engine {
1725
1778
  this.groundMirror = on ? 1 : 0;
1726
1779
  if (blur !== undefined)
1727
1780
  this.groundMirrorBlur = Math.min(Math.max(blur, 0), 1);
1728
- this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 15 * 4, new Float32Array([this.groundMirror, this.groundMirrorBlur]));
1781
+ this.writeGroundMirrorField();
1729
1782
  return true;
1730
1783
  }
1784
+ /**
1785
+ * The floor's mirror field, as the shader should see it.
1786
+ *
1787
+ * A PLACED mirror takes the plane — there is one reflection target and one
1788
+ * fold, and a floor still trying to reflect through a wall's plane shows the
1789
+ * scene lying on its side. So the field is forced to 0 while a mirror surface
1790
+ * exists, without touching what the caller set: remove the mirror and the
1791
+ * floor's own setting comes back.
1792
+ *
1793
+ * It is also what lets the reflection carry the FLOOR. The mirror pass draws
1794
+ * the ground now, and a ground whose own mirror is on would be sampling the
1795
+ * very texture that pass is writing.
1796
+ */
1797
+ writeGroundMirrorField() {
1798
+ if (!this.groundShadowMaterialBuffer)
1799
+ return;
1800
+ const effective = this.mirrorSurface ? 0 : this.groundMirror;
1801
+ this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 15 * 4, new Float32Array([effective, this.groundMirrorBlur]));
1802
+ }
1803
+ /**
1804
+ * Place a mirror in the scene, or remove it with null.
1805
+ *
1806
+ * PRIVATE, and the only caller is syncMirrorFromEffects. A mirror reaches a
1807
+ * scene by applying the effect that declares `#mirror`, which is what gives
1808
+ * it dials, a schedule, a library row and a publish — a second public door
1809
+ * straight to the plane would be the same feature with none of that, and two
1810
+ * ways to place one mirror when there can only be one.
1811
+ *
1812
+ * A real planar reflection: the scene is drawn a second time from the camera
1813
+ * folded through this plane, so what the glass shows is the SCENE — lit by
1814
+ * the same sun, carrying the same shadows, with particle and ribbon effects
1815
+ * in it — rather than a copy of what the camera already sees.
1816
+ *
1817
+ * ONE at a time. The reflection pass owns a single set of attachments and a
1818
+ * single camera block, and a second mirror is a second of each; placing one
1819
+ * also takes the plane away from the floor mirror, which is the same target.
1820
+ *
1821
+ * `width` and `height` are metres of glass. `rotation` orients a quad whose
1822
+ * face is +Z at identity, so a mirror dropped in with no rotation faces the
1823
+ * way the default camera looks from — what someone means by putting one in
1824
+ * front of her.
1825
+ */
1826
+ setMirror(options) {
1827
+ if (!this.device)
1828
+ return;
1829
+ if (!options) {
1830
+ this.mirrorSurface = null;
1831
+ return;
1832
+ }
1833
+ // MMD UNITS, not metres. A character stands about 20 tall here — the
1834
+ // engine's own numbers say so: the camera targets y = 11, spawnOffsetX
1835
+ // stands the next cast member 9 aside, and the ground fades out at 80. A
1836
+ // mirror sized in metres is a postage stamp at her ankle.
1837
+ //
1838
+ // POSITION IS THE FOOT, not the centre: the quad runs 0..1 upward from it,
1839
+ // so shortening the mirror takes the height off the top and turning it
1840
+ // pivots on the floor. See the vertex shader.
1841
+ //
1842
+ // AND IT STANDS BEHIND HER, NOT IN FRONT. A mirror shows what is on its own
1843
+ // front side and nothing at all of what is behind it, so a plane dropped
1844
+ // between the camera and the cast is a WINDOW: the subject is on the far
1845
+ // side of the glass and what reflects is the empty room. The default camera
1846
+ // orbits to z = -31 (alpha = pi, distance 33), so the default here sits at
1847
+ // +z, off to one side and turned back toward both — which is the
1848
+ // arrangement every mirror shot uses, and the reason it is not centred is
1849
+ // that a mirror directly behind her reflects her into her own silhouette.
1850
+ const px = options.position?.x ?? -11;
1851
+ const py = options.position?.y ?? 0;
1852
+ const pz = options.position?.z ?? 2;
1853
+ const r = options.rotation ?? { x: 0, y: 0, z: 0, w: 1 };
1854
+ const width = options.width ?? 14;
1855
+ const height = options.height ?? 24;
1856
+ const tint = options.tint ?? { x: 1, y: 1, z: 1 };
1857
+ const blur = Math.min(Math.max(options.blur ?? 0, 0), 1);
1858
+ const frame = options.frame ?? 0.48;
1859
+ const frameColor = options.frameColor ?? { x: 0.631, y: 0.631, z: 0.667 };
1860
+ // Rotation, then a NON-UNIFORM scale on the two in-plane axes — which is
1861
+ // why this is not fromPositionRotationScaleInto: a mirror is 2 x 3, not a
1862
+ // square someone scaled. Column 2 is left unit length; it is the normal.
1863
+ const m = this.mirrorSurfaceModel;
1864
+ Mat4.fromQuatInto(r.x, r.y, r.z, r.w, m, 0);
1865
+ m[0] *= width;
1866
+ m[1] *= width;
1867
+ m[2] *= width;
1868
+ m[4] *= height;
1869
+ m[5] *= height;
1870
+ m[6] *= height;
1871
+ m[12] = px;
1872
+ m[13] = py;
1873
+ m[14] = pz;
1874
+ this.mirrorSurface = { tint: new Vec3(tint.x, tint.y, tint.z), blur };
1875
+ const d = this.mirrorSurfaceMatData;
1876
+ d.set(m, 0);
1877
+ d[16] = tint.x;
1878
+ d[17] = tint.y;
1879
+ d[18] = tint.z;
1880
+ d[19] = blur;
1881
+ d[20] = frameColor.x;
1882
+ d[21] = frameColor.y;
1883
+ d[22] = frameColor.z;
1884
+ // Clamped to under half the short side: a band wider than the glass leaves
1885
+ // no glass, and a mirror that is all moulding is a plank.
1886
+ d[23] = Math.min(Math.max(frame, 0), Math.min(width, height) * 0.45);
1887
+ d[24] = width;
1888
+ d[25] = height;
1889
+ this.ensureMirrorSurfaceResources();
1890
+ this.device.queue.writeBuffer(this.mirrorSurfaceMatBuffer, 0, this.mirrorSurfaceMatData);
1891
+ }
1892
+ /** Called by the sync once the surface exists, so the floor yields the plane
1893
+ * on the same frame the mirror appears. */
1894
+ mirrorSurfaceAppeared() {
1895
+ this.writeGroundMirrorField();
1896
+ }
1897
+ /** One dial's live value, or the fallback when the effect never declared it.
1898
+ * Reads the CPU mirror of the params buffer, which setEffectParam keeps
1899
+ * current — so dragging a slider moves the glass on the same frame. */
1900
+ effectDial(fx, name, fallback) {
1901
+ const slot = fx.paramLayout.get(name);
1902
+ return slot ? fx.paramsData[slot.offset] : fallback;
1903
+ }
1904
+ /**
1905
+ * Drive the mirror from whichever effect declares one.
1906
+ *
1907
+ * Runs before reflectionActive is read, because the answer to "is there a
1908
+ * mirror" is this function's output. FIRST match wins and the rest are
1909
+ * ignored: there is one reflection target, so a second mirror is not a second
1910
+ * mirror, it is a fight over the same plane — and a scene that quietly
1911
+ * reflected the wrong one would be far harder to understand than one where
1912
+ * the second mirror does nothing.
1913
+ *
1914
+ * Gated on `weight`, so a scheduled mirror appears and leaves with its strip
1915
+ * and a faded-out one costs no reflection pass at all.
1916
+ */
1917
+ syncMirrorFromEffects() {
1918
+ let fx = null;
1919
+ for (const e of this.effects) {
1920
+ if (e.hasMirror && e.weight > 0) {
1921
+ fx = e;
1922
+ break;
1923
+ }
1924
+ }
1925
+ if (!fx) {
1926
+ if (this.mirrorSurface) {
1927
+ this.mirrorSurface = null;
1928
+ this.writeGroundMirrorField();
1929
+ }
1930
+ return;
1931
+ }
1932
+ const had = this.mirrorSurface !== null;
1933
+ const D = Engine.MIRROR_DIALS;
1934
+ const deg = Math.PI / 180;
1935
+ 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);
1936
+ const colour = (name, r, g, b) => {
1937
+ const slot = fx.paramLayout.get(name);
1938
+ return slot
1939
+ ? { x: fx.paramsData[slot.offset], y: fx.paramsData[slot.offset + 1], z: fx.paramsData[slot.offset + 2] }
1940
+ : { x: r, y: g, z: b };
1941
+ };
1942
+ const tint = colour(D.tint, 1, 1, 1);
1943
+ this.setMirror({
1944
+ position: {
1945
+ x: this.effectDial(fx, D.posX, -11),
1946
+ y: this.effectDial(fx, D.posY, 0),
1947
+ z: this.effectDial(fx, D.posZ, 2),
1948
+ },
1949
+ rotation: rot,
1950
+ // Floored well above zero: a mirror scaled to nothing is a degenerate
1951
+ // quad whose normal is still read for the plane, and the reflection would
1952
+ // fold through a plane nobody can see.
1953
+ width: Math.max(this.effectDial(fx, D.width, 14), 0.1),
1954
+ height: Math.max(this.effectDial(fx, D.height, 24), 0.1),
1955
+ tint,
1956
+ blur: this.effectDial(fx, D.blur, 0),
1957
+ frame: this.effectDial(fx, D.frame, 0.48),
1958
+ frameColor: colour(D.frameColor, 0.631, 0.631, 0.667),
1959
+ });
1960
+ if (!had)
1961
+ this.mirrorSurfaceAppeared();
1962
+ }
1963
+ /** Built on the first setMirror rather than at init: a shader compile and a
1964
+ * pipeline cost load time, and most scenes never place one. */
1965
+ ensureMirrorSurfaceResources() {
1966
+ if (this.mirrorSurfacePipeline)
1967
+ return;
1968
+ this.mirrorSurfaceMatBuffer = this.device.createBuffer({
1969
+ label: "mirror surface material",
1970
+ size: MIRROR_MAT_BYTES,
1971
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1972
+ });
1973
+ this.mirrorSurfaceBindGroupLayout = this.device.createBindGroupLayout({
1974
+ label: "mirror surface layout",
1975
+ entries: [
1976
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1977
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1978
+ { binding: 2, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
1979
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
1980
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
1981
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
1982
+ ],
1983
+ });
1984
+ this.mirrorSurfacePipeline = this.createRenderPipeline({
1985
+ label: "mirror surface pipeline",
1986
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.mirrorSurfaceBindGroupLayout] }),
1987
+ shaderModule: this.device.createShaderModule({ label: "mirror surface", code: mirrorShaderWgsl() }),
1988
+ // None: the quad is generated from the vertex index.
1989
+ vertexBuffers: [],
1990
+ fragmentTargets: sceneTargetsFor("mirror", this.sceneFormats),
1991
+ // Both faces. The shader answers for the back — see its side test. Culling
1992
+ // it instead would make a mirror rotated the wrong way INVISIBLE, which is
1993
+ // the one failure a transform panel cannot show you.
1994
+ cullMode: "none",
1995
+ depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
1996
+ });
1997
+ this.buildMirrorSurfaceBindGroup();
1998
+ // ── The pane in the shadow pass ──
1999
+ const shadowLayout = this.device.createBindGroupLayout({
2000
+ label: "mirror shadow layout",
2001
+ entries: [
2002
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
2003
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
2004
+ { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
2005
+ ],
2006
+ });
2007
+ this.mirrorShadowPipeline = this.device.createRenderPipeline({
2008
+ label: "mirror shadow pipeline",
2009
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [shadowLayout] }),
2010
+ vertex: {
2011
+ module: this.device.createShaderModule({
2012
+ label: "mirror shadow",
2013
+ code: mirrorShadowWgsl(SHADOW_CASCADES.length),
2014
+ }),
2015
+ entryPoint: "vs",
2016
+ },
2017
+ // Depth only: no fragment stage at all, the pane being opaque across its
2018
+ // whole rectangle with nothing to alpha-test.
2019
+ primitive: { cullMode: "none" },
2020
+ depthStencil: {
2021
+ format: Engine.SHADOW_DEPTH_FORMAT,
2022
+ depthWriteEnabled: true,
2023
+ depthCompare: "less-equal",
2024
+ // The shadow map's own non-reversed convention, and the same bias the
2025
+ // model shadows take — see shadowDepthPipeline.
2026
+ depthBias: 2,
2027
+ depthBiasSlopeScale: 1.5,
2028
+ depthBiasClamp: 0,
2029
+ },
2030
+ });
2031
+ this.mirrorShadowBindGroups = SHADOW_CASCADES.map((_, ci) => {
2032
+ const index = this.device.createBuffer({
2033
+ label: `mirror shadow cascade ${ci}`,
2034
+ size: 16,
2035
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2036
+ });
2037
+ this.device.queue.writeBuffer(index, 0, new Uint32Array([ci, 0, 0, 0]));
2038
+ return this.device.createBindGroup({
2039
+ label: `mirror shadow bind ${ci}`,
2040
+ layout: shadowLayout,
2041
+ entries: [
2042
+ { binding: 0, resource: { buffer: this.shadowLightVPBuffer } },
2043
+ { binding: 1, resource: { buffer: index } },
2044
+ { binding: 2, resource: { buffer: this.mirrorSurfaceMatBuffer } },
2045
+ ],
2046
+ });
2047
+ });
2048
+ }
2049
+ /** Rebound whenever the reflection target is recreated, alongside the
2050
+ * ground's — the two read the same texture. */
2051
+ buildMirrorSurfaceBindGroup() {
2052
+ if (!this.mirrorSurfaceBindGroupLayout || !this.mirrorSurfaceMatBuffer)
2053
+ return;
2054
+ if (!this.mirrorColorView || !this.mirrorMaskView)
2055
+ return;
2056
+ this.mirrorSurfaceBindGroup = this.device.createBindGroup({
2057
+ label: "mirror surface bind",
2058
+ layout: this.mirrorSurfaceBindGroupLayout,
2059
+ entries: [
2060
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
2061
+ { binding: 1, resource: { buffer: this.mirrorVPBuffer } },
2062
+ { binding: 2, resource: { buffer: this.mirrorSurfaceMatBuffer } },
2063
+ { binding: 3, resource: this.mirrorColorView },
2064
+ { binding: 4, resource: this.materialSampler },
2065
+ { binding: 5, resource: this.mirrorMaskView },
2066
+ ],
2067
+ });
2068
+ }
2069
+ /** The glass itself, in the camera pass. Never in the MIRROR pass: a mirror
2070
+ * drawn into its own reflection is a corridor, and with one target it is a
2071
+ * corridor of last frame's frame. */
2072
+ renderMirrorSurface(pass) {
2073
+ if (!this.mirrorSurface || !this.mirrorSurfacePipeline || !this.mirrorSurfaceBindGroup)
2074
+ return;
2075
+ pass.setPipeline(this.mirrorSurfacePipeline);
2076
+ pass.setBindGroup(0, this.mirrorSurfaceBindGroup);
2077
+ pass.draw(6);
2078
+ }
1731
2079
  ensureReflectionDebugPipeline() {
1732
2080
  if (!this.mirrorColorView)
1733
2081
  return false;
@@ -1779,7 +2127,25 @@ export class Engine {
1779
2127
  /** Refold the live camera with the reflection — a copy and a handful of
1780
2128
  * sign flips; cheap enough to run every frame a mirror is on. */
1781
2129
  updateMirrorCamera() {
1782
- buildMirrorCamera(this.cameraMatrixData, Engine.REFLECTION_PLANE_Y, this.mirrorCameraData);
2130
+ // The placed surface wins over the floor — see the note on mirrorPlane.
2131
+ // Recomputed rather than cached because the mirror is the thing a gizmo
2132
+ // drags, and a plane that lagged the surface by a frame is a reflection
2133
+ // that slides on it.
2134
+ if (this.mirrorSurface) {
2135
+ const m = this.mirrorSurfaceModel;
2136
+ planeFromPointNormal(m[12], m[13], m[14], m[8], m[9], m[10], this.mirrorPlane);
2137
+ }
2138
+ else {
2139
+ this.mirrorPlane.set(Engine.GROUND_PLANE);
2140
+ }
2141
+ // The ground inside the mirror pass clips against this same plane — a
2142
+ // mirror shows nothing behind itself, and the floor is what is behind one.
2143
+ if (this.groundClipMirrorBuffer) {
2144
+ this.groundClipData.set(this.mirrorPlane, 0);
2145
+ this.groundClipData[4] = 1;
2146
+ this.device.queue.writeBuffer(this.groundClipMirrorBuffer, 0, this.groundClipData);
2147
+ }
2148
+ buildMirrorCamera(this.cameraMatrixData, this.mirrorPlane, this.mirrorCameraData);
1783
2149
  this.device.queue.writeBuffer(this.mirrorCameraBuffer, 0, this.mirrorCameraData);
1784
2150
  Mat4.multiplyArrays(this.cameraMatrixData, 16, this.mirrorCameraData, 0, this.mirrorVPData, 0);
1785
2151
  // projA/projB ARE m[10] and m[14] of the projection (the dofU discipline:
@@ -1805,20 +2171,22 @@ export class Engine {
1805
2171
  renderMirrorPass(encoder) {
1806
2172
  if (!this.reflectionActive || !this.mirrorPassDescriptor)
1807
2173
  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 };
2174
+ // No early return on an empty cast. The CLEAR is the load-bearing half of
2175
+ // this pass: a mirror surface samples the target whether or not anything
2176
+ // was drawn into it, and a pass skipped for having no models leaves it
2177
+ // holding whatever the allocation held — undefined memory, sampled onto a
2178
+ // plane in the middle of the scene.
2179
+ // CLEARS TRANSPARENT, and the alpha is the whole point: it is 1 exactly
2180
+ // where the pass drew something and 0 where it drew nothing, so every
2181
+ // consumer can composite the reflection over its own surface instead of
2182
+ // over a guess.
2183
+ //
2184
+ // It used to clear to the scene's background, linearised, so that empty
2185
+ // regions read as backdrop rather than black. That colour then rode the
2186
+ // view transform a SECOND time — the real backdrop composites after it —
2187
+ // and the result was a panel paler and flatter than the backdrop beside it.
2188
+ // A mirror that fogs. Coverage costs nothing and lets the genuine backdrop
2189
+ // through, which is also what an empty mirror shows: the room.
1822
2190
  // The descriptor is reused every frame, so the stamp is set on it rather
1823
2191
  // than passed — same as the scene pass, which is built once too.
1824
2192
  this.mirrorPassDescriptor.timestampWrites = this.stamps("mirror");
@@ -1829,12 +2197,26 @@ export class Engine {
1829
2197
  bundles.push(this.mirrorOpaqueBundle);
1830
2198
  if (this.mirrorTransparentBundle)
1831
2199
  bundles.push(this.mirrorTransparentBundle);
1832
- pass.executeBundles(bundles);
2200
+ if (bundles.length > 0)
2201
+ pass.executeBundles(bundles);
1833
2202
  // Particles and ribbons are scene geometry, and a mirror that dropped them
1834
2203
  // showed a dancer whose hand ribbon cast no reflection. Field effects stay
1835
2204
  // out BY DESIGN: they are display-space overlays composited after the view
1836
2205
  // transform, with no world position to mirror. executeBundles reset the
1837
2206
  // pass state, so these draws bind everything themselves — which they do.
2207
+ // THE FLOOR, inside the reflection.
2208
+ //
2209
+ // Without it a standing mirror shows a figure hanging in empty colour, with
2210
+ // nothing under her feet to say where she is — which is most of what made
2211
+ // the pane read as a dark sheet rather than as glass. It never came up for
2212
+ // the floor mirror because a floor does not reflect itself.
2213
+ //
2214
+ // Skipped while the FLOOR's own mirror is on: that would be the floor
2215
+ // sampling a reflection it is currently drawing into, one frame stale, and
2216
+ // the two mirrors are already fighting over the single reflection plane.
2217
+ if (this.hasGround && (this.mirrorSurface !== null || this.groundMirror === 0) && this.groundMirrorViewBindGroup) {
2218
+ this.renderGroundWith(pass, this.groundMirrorViewBindGroup, true);
2219
+ }
1838
2220
  this.renderParticles(pass, "mirror");
1839
2221
  this.drawTrails(pass, "mirror");
1840
2222
  pass.end();
@@ -1846,7 +2228,11 @@ export class Engine {
1846
2228
  * samples level 0 exactly and the chain would be work nobody reads.
1847
2229
  */
1848
2230
  renderMirrorBlurChain(encoder) {
1849
- if (this.groundMirrorBlur <= 0 || this.mirrorMipCount < 2)
2231
+ // EITHER consumer's dial. Gated on the ground's alone, a mirror surface with
2232
+ // blur up sampled mip levels that had never been rendered into — undefined
2233
+ // memory, which read as the model going black the moment the slider moved.
2234
+ const blur = Math.max(this.groundMirrorBlur, this.mirrorSurface?.blur ?? 0);
2235
+ if (blur <= 0 || this.mirrorMipCount < 2)
1850
2236
  return;
1851
2237
  if (!this.mirrorBlurBindGroups) {
1852
2238
  const layout = this.bloomDownsamplePipeline.getBindGroupLayout(0);
@@ -1872,6 +2258,47 @@ export class Engine {
1872
2258
  p.draw(3);
1873
2259
  p.end();
1874
2260
  }
2261
+ // COVERAGE, down the same chain. Colour in the reflection target is
2262
+ // premultiplied by this, so the pair has to be blurred together: blur the
2263
+ // colour alone and the pane divides a colour that spread into the empty sky
2264
+ // by a coverage that did not, and the reflection darkens as the dial rises.
2265
+ if (!this.mirrorMaskDownsamplePipeline) {
2266
+ const module = this.device.createShaderModule({
2267
+ label: "mirror coverage downsample",
2268
+ code: MIRROR_MASK_DOWNSAMPLE_WGSL,
2269
+ });
2270
+ this.mirrorMaskDownsamplePipeline = this.device.createRenderPipeline({
2271
+ label: "mirror coverage downsample pipeline",
2272
+ layout: "auto",
2273
+ vertex: { module, entryPoint: "vs" },
2274
+ fragment: { module, entryPoint: "fs", targets: [{ format: Engine.BLOOM_MASK_FORMAT }] },
2275
+ primitive: { topology: "triangle-list" },
2276
+ });
2277
+ }
2278
+ if (!this.mirrorMaskBlurBindGroups) {
2279
+ const layout = this.mirrorMaskDownsamplePipeline.getBindGroupLayout(0);
2280
+ this.mirrorMaskBlurBindGroups = [];
2281
+ for (let i = 1; i < this.mirrorMipCount; i++) {
2282
+ this.mirrorMaskBlurBindGroups.push(this.device.createBindGroup({
2283
+ label: `mirror coverage blur ${i}`,
2284
+ layout,
2285
+ entries: [
2286
+ { binding: 0, resource: this.mirrorMaskMipViews[i - 1] },
2287
+ { binding: 1, resource: this.bloomSampler },
2288
+ ],
2289
+ }));
2290
+ }
2291
+ }
2292
+ for (let i = 1; i < this.mirrorMipCount; i++) {
2293
+ const p = encoder.beginRenderPass({
2294
+ label: `mirror coverage blur ${i}`,
2295
+ colorAttachments: [{ view: this.mirrorMaskMipViews[i], loadOp: "clear", storeOp: "store" }],
2296
+ });
2297
+ p.setPipeline(this.mirrorMaskDownsamplePipeline);
2298
+ p.setBindGroup(0, this.mirrorMaskBlurBindGroups[i - 1]);
2299
+ p.draw(3);
2300
+ p.end();
2301
+ }
1875
2302
  }
1876
2303
  /**
1877
2304
  * Can this device multisample the id format at the pass's sample count?
@@ -2595,13 +3022,17 @@ export class Engine {
2595
3022
  // lightEmit counts as a mount on its own: a pure lighting rig draws nothing
2596
3023
  // and is still an effect — it is how a scene gets stage lights without also
2597
3024
  // getting geometry it did not ask for.
2598
- if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl)) {
3025
+ // #mirror is a mount on the same footing, and for the same reason: a
3026
+ // reflection re-renders the scene from a folded camera, which is a pass and
3027
+ // not a shader, so the effect declares it and draws nothing itself.
3028
+ if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl) && !d.mirror) {
2599
3029
  return { ok: false, diagnostics: [
2600
3030
  "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
2601
3031
  "fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
2602
3032
  "the particle trio (particleInit/particleStep/particleShade), " +
2603
3033
  "the ribbon pair (trailWidth/trailShade), " +
2604
- "or fn lightEmit(i: u32) -> RzLight with #lights <n>",
3034
+ "fn lightEmit(i: u32) -> RzLight with #lights <n>, " +
3035
+ "or #mirror",
2605
3036
  ], mounts: noMounts, params: [], duration: 0 };
2606
3037
  }
2607
3038
  const mounts = { background: hasBackground, foreground: hasForeground };
@@ -2901,6 +3332,7 @@ export class Engine {
2901
3332
  paramLayout: layout,
2902
3333
  paramsBuffer,
2903
3334
  paramsData,
3335
+ hasMirror: d.mirror,
2904
3336
  hasBackground,
2905
3337
  hasForeground,
2906
3338
  // The author's OWN source, not the assembled module: the assembled one
@@ -5176,6 +5608,11 @@ export class Engine {
5176
5608
  // The baked frost tile — see bakeGroundNoise. Sampled with binding 10's
5177
5609
  // repeat sampler, so it brings no sampler of its own.
5178
5610
  { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5611
+ // The mirror pass's resolved aux. .g is the reflection's COVERAGE, and
5612
+ // the colour target cannot carry it — rg11b10ufloat has no alpha.
5613
+ { binding: 13, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5614
+ // The mirror's plane, and whether this draw is inside the mirror pass.
5615
+ { binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
5179
5616
  ],
5180
5617
  });
5181
5618
  this.groundShadowPipelineDesc = {
@@ -5222,11 +5659,33 @@ export class Engine {
5222
5659
  { binding: 1, resource: this.materialSampler },
5223
5660
  ],
5224
5661
  });
5662
+ // The same group on the MIRROR camera. An outline is geometry expanded
5663
+ // along its normals in CLIP space, so it has to be expanded through the
5664
+ // camera that is drawing it — handed the real camera it would trace a hull
5665
+ // around where she is standing, not around her reflection.
5666
+ this.outlineMirrorPerFrameBindGroup = this.device.createBindGroup({
5667
+ label: "outline per-frame bind group (mirror)",
5668
+ layout: this.outlinePerFrameBindGroupLayout,
5669
+ entries: [
5670
+ { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
5671
+ { binding: 1, resource: this.materialSampler },
5672
+ ],
5673
+ });
5225
5674
  const outlineShaderModule = this.device.createShaderModule({
5226
5675
  label: "outline shaders",
5227
5676
  code: outlineShaderWgsl(),
5228
5677
  });
5229
- this.outlinePipeline = this.createRenderPipeline({
5678
+ // FRONT, not back, and not "none". The outline is an inverted hull: the
5679
+ // model expanded along its normals, drawn with only its BACK faces showing
5680
+ // so the silhouette survives and the interior does not ink over the model.
5681
+ // A reflection has determinant -1 (reflection.ts), so those very triangles
5682
+ // arrive front-facing in the mirror pass and `back` culls every one of them
5683
+ // — which is why the mirror had no outline at all. `none` would draw the
5684
+ // hull's front faces too, painting the model over with ink.
5685
+ // Annotated so the string fields keep their literal types: pulled out of the
5686
+ // call into a const, "back" widens to string and the two pipelines below
5687
+ // stop type-checking.
5688
+ const outlineDesc = {
5230
5689
  label: "outline pipeline",
5231
5690
  layout: outlinePipelineLayout,
5232
5691
  shaderModule: outlineShaderModule,
@@ -5263,6 +5722,12 @@ export class Engine {
5263
5722
  stencilReadMask: 0xff,
5264
5723
  stencilWriteMask: 0,
5265
5724
  },
5725
+ };
5726
+ this.outlinePipeline = this.createRenderPipeline(outlineDesc);
5727
+ this.outlineMirrorPipeline = this.createRenderPipeline({
5728
+ ...outlineDesc,
5729
+ label: "outline pipeline (mirror)",
5730
+ cullMode: "front",
5266
5731
  });
5267
5732
  // ─── Selection overlay (screen-space edge-detect on a per-material mask) ───
5268
5733
  // Reuses outline camera + main skinMats bind group layouts. No group 2 (no per-mat uniform).
@@ -5820,12 +6285,25 @@ export class Engine {
5820
6285
  }
5821
6286
  this.mirrorBlurBindGroups = null;
5822
6287
  this.mirrorMaskMsTexture = this.device.createTexture({
5823
- label: "mirror aux (msaa, discarded)",
6288
+ label: "mirror aux (msaa)",
5824
6289
  size: [mw, mh],
5825
6290
  sampleCount: Engine.MULTISAMPLE_COUNT,
5826
6291
  format: Engine.BLOOM_MASK_FORMAT,
5827
6292
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
5828
6293
  });
6294
+ this.mirrorMaskTexture = this.device.createTexture({
6295
+ label: "mirror aux resolve (coverage)",
6296
+ size: [mw, mh],
6297
+ mipLevelCount: this.mirrorMipCount,
6298
+ format: Engine.BLOOM_MASK_FORMAT,
6299
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
6300
+ });
6301
+ this.mirrorMaskView = this.mirrorMaskTexture.createView();
6302
+ this.mirrorMaskMipViews = [];
6303
+ for (let i = 0; i < this.mirrorMipCount; i++) {
6304
+ this.mirrorMaskMipViews.push(this.mirrorMaskTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
6305
+ }
6306
+ this.mirrorMaskBlurBindGroups = null;
5829
6307
  this.mirrorIdMsTexture = mrtIdsEnabled()
5830
6308
  ? this.device.createTexture({
5831
6309
  label: "mirror id (msaa, discarded)",
@@ -5854,6 +6332,10 @@ export class Engine {
5854
6332
  };
5855
6333
  const mirrorMask = {
5856
6334
  view: this.mirrorMaskMsTexture.createView(),
6335
+ // RESOLVED now rather than discarded: .g is the reflection's coverage.
6336
+ // Mip 0 of the chain, not the whole-chain view: a resolve target is a
6337
+ // single level.
6338
+ resolveTarget: this.mirrorMaskMipViews[0],
5857
6339
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
5858
6340
  loadOp: "clear",
5859
6341
  storeOp: "discard",
@@ -5881,8 +6363,10 @@ export class Engine {
5881
6363
  stencilStoreOp: "discard",
5882
6364
  },
5883
6365
  };
5884
- // The ground binds the reflection resolve; rebind it against the new one.
6366
+ // The ground and the mirror surface both bind the reflection resolve;
6367
+ // rebind both against the new one.
5885
6368
  this.buildGroundBindGroup();
6369
+ this.buildMirrorSurfaceBindGroup();
5886
6370
  // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
5887
6371
  // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
5888
6372
  const bw = Math.max(1, Math.floor(width / 2));
@@ -6400,11 +6884,6 @@ export class Engine {
6400
6884
  size: 32,
6401
6885
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6402
6886
  });
6403
- this.wireframeHoverUniformBuffer = this.device.createBuffer({
6404
- label: "wireframe color (hovered material)",
6405
- size: 32,
6406
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6407
- });
6408
6887
  const wireBg0 = this.device.createBindGroupLayout({
6409
6888
  label: "wireframe group 0 layout (camera + wire)",
6410
6889
  entries: [
@@ -6513,6 +6992,58 @@ export class Engine {
6513
6992
  },
6514
6993
  multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
6515
6994
  });
6995
+ // The selection fill: vsDepth/fs again, no bias needed since it draws
6996
+ // ALWAYS-ahead like the rest of this layer's basic primitives (matching
6997
+ // the red edge lines selectMaterialFaces hands back — the two are meant
6998
+ // to read as one highlight, not fight each other's depth handling) —
6999
+ // never occluded, which a selection drawn over the far side of a body
7000
+ // the box also caught would otherwise need X-ray to explain.
7001
+ this.selectionFillUniformBuffer = this.device.createBuffer({
7002
+ label: "selection fill color",
7003
+ size: 32,
7004
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7005
+ });
7006
+ this.device.queue.writeBuffer(this.selectionFillUniformBuffer, 0, new Float32Array([1, 0.15, 0.15, 0.45, 0, 0, 0, 0]));
7007
+ this.selectionFillBindGroup = this.device.createBindGroup({
7008
+ label: "selection fill bind group",
7009
+ layout: wireBg0,
7010
+ entries: [
7011
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
7012
+ { binding: 1, resource: { buffer: this.selectionFillUniformBuffer } },
7013
+ ],
7014
+ });
7015
+ this.selectionFillPipeline = this.device.createRenderPipeline({
7016
+ label: "selection fill pipeline",
7017
+ layout: this.device.createPipelineLayout({
7018
+ label: "selection fill pipeline layout",
7019
+ bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
7020
+ }),
7021
+ vertex: {
7022
+ module: wireShader,
7023
+ entryPoint: "vsDepth",
7024
+ buffers: [
7025
+ { arrayStride: 8 * 4, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" }] },
7026
+ { arrayStride: 4 * 2, attributes: [{ shaderLocation: 1, offset: 0, format: "uint16x4" }] },
7027
+ { arrayStride: 4, attributes: [{ shaderLocation: 2, offset: 0, format: "unorm8x4" }] },
7028
+ ],
7029
+ },
7030
+ fragment: {
7031
+ module: wireShader,
7032
+ entryPoint: "fs",
7033
+ targets: [
7034
+ {
7035
+ format: this.presentationFormat,
7036
+ blend: {
7037
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
7038
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
7039
+ },
7040
+ },
7041
+ ],
7042
+ },
7043
+ primitive: { topology: "triangle-list", cullMode: "none" },
7044
+ depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
7045
+ multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
7046
+ });
6516
7047
  this.wireframeBindGroup = this.device.createBindGroup({
6517
7048
  label: "wireframe bind group",
6518
7049
  layout: wireBg0,
@@ -6529,14 +7060,6 @@ export class Engine {
6529
7060
  { binding: 1, resource: { buffer: this.wireframeSeamUniformBuffer } },
6530
7061
  ],
6531
7062
  });
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
7063
  this.overlayPassDescriptor = {
6541
7064
  label: "overlay pass",
6542
7065
  timestampWrites: this.stamps("overlay"),
@@ -7047,6 +7570,12 @@ export class Engine {
7047
7570
  setCameraDistance(d) {
7048
7571
  this.camera.radius = d;
7049
7572
  }
7573
+ /** Which button drags orbit the view — see Camera.setOrbitButton. Default
7574
+ * "left" (this engine's own, original binding, every existing consumer's
7575
+ * default); "middle" is Blender's, opt-in only. */
7576
+ setCameraOrbitButton(button) {
7577
+ this.camera?.setOrbitButton(button);
7578
+ }
7050
7579
  getCameraAlpha() {
7051
7580
  return this.camera.alpha;
7052
7581
  }
@@ -7663,6 +8192,26 @@ export class Engine {
7663
8192
  setGroundVisible(on) {
7664
8193
  this.groundHidden = !on;
7665
8194
  }
8195
+ /**
8196
+ * Moves a model to a new key. Nothing about it is rebuilt — same GPU
8197
+ * buffers, same style groups, same physics — only which string the rest
8198
+ * of the engine finds it under changes.
8199
+ *
8200
+ * What a document reload uses to swap in a freshly-loaded replacement
8201
+ * under the ORIGINAL id with no visible gap: the replacement loads under
8202
+ * a throwaway key while the current one keeps rendering untouched, and
8203
+ * only this call — a Map move, nothing async, nothing rebuilt — hands it
8204
+ * the name everything else in the app still expects.
8205
+ */
8206
+ renameModel(oldName, newName) {
8207
+ const inst = this.modelInstances.get(oldName);
8208
+ if (!inst || oldName === newName || this.modelInstances.has(newName))
8209
+ return;
8210
+ this.modelInstances.delete(oldName);
8211
+ inst.name = newName;
8212
+ inst.model.setName(newName);
8213
+ this.modelInstances.set(newName, inst);
8214
+ }
7666
8215
  removeModel(name) {
7667
8216
  const inst = this.modelInstances.get(name);
7668
8217
  if (!inst)
@@ -8112,6 +8661,269 @@ export class Engine {
8112
8661
  }
8113
8662
  return best;
8114
8663
  }
8664
+ /**
8665
+ * Overwrites BASE (pre-morph) vertex positions — a PERMANENT geometry edit
8666
+ * (a bone scale's own vertex half, say), not a transient morph: nothing
8667
+ * here is undone by a weight going back to 0.
8668
+ *
8669
+ * Three places have to agree, or the very next frame — or the next morph
8670
+ * weight change — quietly reverts this: the model's own CPU copy
8671
+ * (Model.setVertexPositions), the live render buffer, and, for a model
8672
+ * whose morphs run on the GPU, the compute pass's OWN base-positions
8673
+ * buffer, which it recomputes from on its next dispatch regardless of
8674
+ * whatever a direct vertex-buffer write just put there.
8675
+ */
8676
+ setVertexPositions(modelName, updates) {
8677
+ const inst = this.modelInstances.get(modelName);
8678
+ if (!inst || updates.length === 0)
8679
+ return;
8680
+ inst.model.setVertexPositions(updates);
8681
+ const scratch = this.vec3Scratch;
8682
+ for (const { index, position } of updates) {
8683
+ scratch[0] = position[0];
8684
+ scratch[1] = position[1];
8685
+ scratch[2] = position[2];
8686
+ this.device.queue.writeBuffer(inst.vertexBuffer, index * 32, scratch);
8687
+ if (inst.gpuMorph)
8688
+ this.device.queue.writeBuffer(inst.gpuMorph.baseBuf, index * 12, scratch);
8689
+ }
8690
+ }
8691
+ /**
8692
+ * Moves bones to new WORLD bind (rest) positions — a permanent rig edit
8693
+ * (a bone scale's own bone half), never a pose. Patches bindTranslation
8694
+ * (relative to the bone's CURRENT parent world position, read fresh off
8695
+ * its own inverseBindMatrix — pass parents before children in one call if
8696
+ * a bone's OWN local offset needs to stay exactly consistent, though nothing
8697
+ * downstream of this actually reads bindTranslation live: skinning, the
8698
+ * bone overlay and picking all read inverseBindMatrices directly, which
8699
+ * this always writes correctly regardless of order) and the cached
8700
+ * inverse-bind matrix directly, so every one of those picks the new
8701
+ * position up on the very next frame with nothing else to recompute.
8702
+ */
8703
+ setBoneBindPositions(modelName, updates) {
8704
+ const inst = this.modelInstances.get(modelName);
8705
+ if (!inst || updates.length === 0)
8706
+ return;
8707
+ const skeleton = inst.model.getSkeleton();
8708
+ const invBind = skeleton.inverseBindMatrices;
8709
+ for (const { index, position } of updates) {
8710
+ const bone = skeleton.bones[index];
8711
+ if (!bone)
8712
+ continue;
8713
+ const parent = bone.parentIndex >= 0 ? bone.parentIndex : -1;
8714
+ const parentPos = parent >= 0
8715
+ ? [-invBind[parent * 16 + 12], -invBind[parent * 16 + 13], -invBind[parent * 16 + 14]]
8716
+ : [0, 0, 0];
8717
+ bone.bindTranslation = [position[0] - parentPos[0], position[1] - parentPos[1], position[2] - parentPos[2]];
8718
+ invBind[index * 16 + 12] = -position[0];
8719
+ invBind[index * 16 + 13] = -position[1];
8720
+ invBind[index * 16 + 14] = -position[2];
8721
+ }
8722
+ }
8723
+ /**
8724
+ * Every face of one material fully inside a screen-space rectangle, on the
8725
+ * POSED mesh — same reason pickMaterial reads it posed: a drag selects
8726
+ * what a costume actually looks like, not the bind pose underneath it.
8727
+ *
8728
+ * A face counts as "inside" only when all three corners project into the
8729
+ * rect. A triangle straddling the edge stays out — splitting a material by
8730
+ * a box is a spatial cut, and a half-caught triangle would tear a seam
8731
+ * that started nowhere the box actually touched.
8732
+ *
8733
+ * `faceIndices` are LOCAL to the material's own face list (0 = its first
8734
+ * triangle) — what a document transform like splitMaterial needs, since it
8735
+ * never has to know where in the shared index buffer this material's run
8736
+ * starts. `lines` is every caught face's own three edges, in POSED world
8737
+ * space and ready to hand straight to setOverlay — a selection reads as the
8738
+ * FACES it covers, not a scatter of points at their corners, and a second
8739
+ * query back through skinning just to draw it would double the cost of
8740
+ * every drag.
8741
+ *
8742
+ * x0/y0/x1/y1 are CSS pixels relative to the canvas, same origin
8743
+ * pickMaterial's x/y use. Order does not matter — a drag can go in any
8744
+ * direction.
8745
+ */
8746
+ selectMaterialFaces(modelName, materialName, x0, y0, x1, y1) {
8747
+ const empty = { faceIndices: [], lines: [] };
8748
+ const inst = this.modelInstances.get(modelName);
8749
+ if (!inst || !this.camera || inst.isStage || inst.isPlane || inst.isProp)
8750
+ return empty;
8751
+ const width = this.canvas.clientWidth;
8752
+ const height = this.canvas.clientHeight;
8753
+ if (width <= 0 || height <= 0)
8754
+ return empty;
8755
+ const left = Math.min(x0, x1);
8756
+ const right = Math.max(x0, x1);
8757
+ const top = Math.min(y0, y1);
8758
+ const bottom = Math.max(y0, y1);
8759
+ const model = inst.model;
8760
+ const materials = model.getMaterials();
8761
+ const mIndex = materials.findIndex((m) => m.name === materialName);
8762
+ if (mIndex < 0)
8763
+ return empty;
8764
+ const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values;
8765
+ const { positions } = model.getGeometry();
8766
+ const count = positions.length / 3;
8767
+ const { joints, weights } = model.getSkinning();
8768
+ const skin = model.getSkinMatrices();
8769
+ // Skin once, same as pickMaterial: three slots per vertex, screen x/y and
8770
+ // clip w — cw doubles as "behind the camera" for the in-rect test below.
8771
+ if (!this.materialSelectScratch || this.materialSelectScratch.length !== count * 3) {
8772
+ this.materialSelectScratch = new Float32Array(count * 3);
8773
+ }
8774
+ const proj = this.materialSelectScratch;
8775
+ const skinnedWorld = (v) => {
8776
+ const bx = positions[v * 3];
8777
+ const by = positions[v * 3 + 1];
8778
+ const bz = positions[v * 3 + 2];
8779
+ let px = 0;
8780
+ let py = 0;
8781
+ let pz = 0;
8782
+ for (let k = 0; k < 4; k++) {
8783
+ const w = weights[v * 4 + k] / 255;
8784
+ if (w === 0)
8785
+ continue;
8786
+ const m = joints[v * 4 + k] * 16;
8787
+ px += w * (skin[m] * bx + skin[m + 4] * by + skin[m + 8] * bz + skin[m + 12]);
8788
+ py += w * (skin[m + 1] * bx + skin[m + 5] * by + skin[m + 9] * bz + skin[m + 13]);
8789
+ pz += w * (skin[m + 2] * bx + skin[m + 6] * by + skin[m + 10] * bz + skin[m + 14]);
8790
+ }
8791
+ return [px, py, pz];
8792
+ };
8793
+ for (let v = 0; v < count; v++) {
8794
+ const [px, py, pz] = skinnedWorld(v);
8795
+ const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15];
8796
+ proj[v * 3 + 2] = cw;
8797
+ if (cw <= 1e-6)
8798
+ continue;
8799
+ const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12];
8800
+ const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13];
8801
+ proj[v * 3] = ((cx / cw) * 0.5 + 0.5) * width;
8802
+ proj[v * 3 + 1] = (1 - ((cy / cw) * 0.5 + 0.5)) * height;
8803
+ }
8804
+ const inRect = (v) => {
8805
+ const cw = proj[v * 3 + 2];
8806
+ if (cw <= 1e-6)
8807
+ return false;
8808
+ const sx = proj[v * 3];
8809
+ const sy = proj[v * 3 + 1];
8810
+ return sx >= left && sx <= right && sy >= top && sy <= bottom;
8811
+ };
8812
+ // Walk to this material's own run, the same running sum pickMaterial's
8813
+ // matEnd walk uses — materials own a CONTIGUOUS run of the index buffer.
8814
+ const indices = model.getIndices();
8815
+ let start = 0;
8816
+ for (let i = 0; i < mIndex; i++)
8817
+ start += materials[i].vertexCount;
8818
+ const end = start + materials[mIndex].vertexCount;
8819
+ const faceIndices = [];
8820
+ const lines = [];
8821
+ let faceIndex = 0;
8822
+ for (let i = start; i < end; i += 3, faceIndex++) {
8823
+ const a = indices[i];
8824
+ const b = indices[i + 1];
8825
+ const c = indices[i + 2];
8826
+ if (!inRect(a) || !inRect(b) || !inRect(c))
8827
+ continue;
8828
+ faceIndices.push(faceIndex);
8829
+ const pa = skinnedWorld(a);
8830
+ const pb = skinnedWorld(b);
8831
+ const pc = skinnedWorld(c);
8832
+ // Every edge, including the ones shared with a neighbouring caught
8833
+ // face — drawn twice there, which costs nothing a triangle this size
8834
+ // notices and is simpler than tracking which edges are interior.
8835
+ for (const [p, q] of [
8836
+ [pa, pb],
8837
+ [pb, pc],
8838
+ [pc, pa],
8839
+ ]) {
8840
+ const line = lineBetween(p, q, Engine.SELECTION_COLOR);
8841
+ if (line)
8842
+ lines.push(line);
8843
+ }
8844
+ }
8845
+ return { faceIndices, lines };
8846
+ }
8847
+ /**
8848
+ * Fill the given faces of one material solid red, live-skinned — unlike
8849
+ * the edge `lines` selectMaterialFaces hands back, which are a one-shot
8850
+ * snapshot at the moment of the drag, this reads the model's CURRENT skin
8851
+ * matrices every frame, so it keeps tracking the mesh through a pose
8852
+ * change the same way the wireframe overlay does.
8853
+ *
8854
+ * `faceIndices` are the same LOCAL (material-relative) numbers
8855
+ * selectMaterialFaces returns and splitMaterial takes. Pass an empty list
8856
+ * (or a null modelName/materialName) to clear it.
8857
+ */
8858
+ setSelectionFill(modelName, materialName, faceIndices) {
8859
+ if (this.selectionFill) {
8860
+ this.selectionFill.buffer.destroy();
8861
+ this.selectionFill = null;
8862
+ }
8863
+ if (!modelName || !materialName || faceIndices.length === 0)
8864
+ return;
8865
+ const inst = this.modelInstances.get(modelName);
8866
+ if (!inst)
8867
+ return;
8868
+ const materials = inst.model.getMaterials();
8869
+ const mIndex = materials.findIndex((m) => m.name === materialName);
8870
+ if (mIndex < 0)
8871
+ return;
8872
+ let start = 0;
8873
+ for (let i = 0; i < mIndex; i++)
8874
+ start += materials[i].vertexCount;
8875
+ const faceCount = materials[mIndex].vertexCount / 3;
8876
+ const modelIndices = inst.model.getIndices();
8877
+ const data = new Uint32Array(faceIndices.length * 3);
8878
+ let n = 0;
8879
+ for (const f of faceIndices) {
8880
+ if (f < 0 || f >= faceCount)
8881
+ continue;
8882
+ const i = start + f * 3;
8883
+ data[n++] = modelIndices[i];
8884
+ data[n++] = modelIndices[i + 1];
8885
+ data[n++] = modelIndices[i + 2];
8886
+ }
8887
+ if (n === 0)
8888
+ return;
8889
+ const buffer = this.device.createBuffer({
8890
+ label: `selection fill ${modelName}/${materialName}`,
8891
+ size: n * 4,
8892
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
8893
+ });
8894
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, 0, n * 4);
8895
+ // wireframeSkinLayout's binding 4 is the wireframe's own edge list, which
8896
+ // vsDepth never reads — inst.vertexBuffer (already bound as storage
8897
+ // elsewhere, for that same edge-drawing use) satisfies the layout's slot
8898
+ // as a harmless stand-in rather than a fifth buffer built only to sit unused.
8899
+ const skinBindGroup = this.device.createBindGroup({
8900
+ label: `selection fill skin ${modelName}`,
8901
+ layout: this.wireframeSkinLayout,
8902
+ entries: [
8903
+ { binding: 0, resource: { buffer: inst.skinMatrixBuffer } },
8904
+ { binding: 1, resource: { buffer: inst.vertexBuffer } },
8905
+ { binding: 2, resource: { buffer: inst.jointsBuffer } },
8906
+ { binding: 3, resource: { buffer: inst.weightsBuffer } },
8907
+ { binding: 4, resource: { buffer: inst.vertexBuffer } },
8908
+ ],
8909
+ });
8910
+ this.selectionFill = { modelName, buffer, count: n, skinBindGroup };
8911
+ }
8912
+ renderSelectionFill(pass) {
8913
+ if (!this.selectionFill)
8914
+ return;
8915
+ const inst = this.modelInstances.get(this.selectionFill.modelName);
8916
+ if (!inst)
8917
+ return;
8918
+ pass.setPipeline(this.selectionFillPipeline);
8919
+ pass.setBindGroup(0, this.selectionFillBindGroup);
8920
+ pass.setBindGroup(1, this.selectionFill.skinBindGroup);
8921
+ pass.setVertexBuffer(0, inst.vertexBuffer);
8922
+ pass.setVertexBuffer(1, inst.jointsBuffer);
8923
+ pass.setVertexBuffer(2, inst.weightsBuffer);
8924
+ pass.setIndexBuffer(this.selectionFill.buffer, "uint32");
8925
+ pass.drawIndexed(this.selectionFill.count);
8926
+ }
8115
8927
  /** Draw an octahedron per bone of `modelName`, rebuilt each frame. Null off. */
8116
8928
  setBoneOverlay(modelName, options = {}) {
8117
8929
  this.overlayBones = modelName ? { modelName, options } : null;
@@ -9554,6 +10366,7 @@ export class Engine {
9554
10366
  weightsData: new Float32Array(data.morphCount),
9555
10367
  workgroups: Math.ceil(data.vertexCount / 64),
9556
10368
  dispatchNeeded: false, // vertex buffer already holds base; dispatch on first weight change
10369
+ baseBuf,
9557
10370
  };
9558
10371
  }
9559
10372
  createGroundGeometry(width = 100, height = 100) {
@@ -9620,16 +10433,35 @@ export class Engine {
9620
10433
  });
9621
10434
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
9622
10435
  }
9623
- buildGroundPipeline(soft) {
10436
+ buildGroundPipeline(soft, mirrored = false) {
9624
10437
  return this.createRenderPipeline({
9625
10438
  ...this.groundShadowPipelineDesc,
9626
- label: soft ? "ground shadow pipeline (soft)" : "ground shadow pipeline",
10439
+ // A REFLECTION FLIPS WINDING — determinant -1, see reflection.ts. The
10440
+ // ground culls back faces, so drawn into the mirror pass with the ordinary
10441
+ // pipeline every one of its triangles faces away and the floor vanishes
10442
+ // whole: no grid, and no received shadow either, since the shadow is a
10443
+ // layer of this shader. It is the same fact that sits the OUTLINE out of
10444
+ // the mirror, and the same answer the scene-pass pipelines already use.
10445
+ cullMode: mirrored ? "none" : this.groundShadowPipelineDesc.cullMode,
10446
+ label: `ground shadow pipeline${soft ? " (soft)" : ""}${mirrored ? " (mirror)" : ""}`,
9627
10447
  shaderModule: this.device.createShaderModule({
9628
10448
  label: soft ? "ground shadow (soft)" : "ground shadow",
9629
10449
  code: groundShaderWgsl(soft),
9630
10450
  }),
9631
10451
  });
9632
10452
  }
10453
+ /** The ground's pipeline for the mirror pass, built on the first frame a
10454
+ * mirror actually needs it. */
10455
+ ensureGroundMirrorPipeline(soft) {
10456
+ if (soft) {
10457
+ if (!this.groundMirrorSoftPipeline)
10458
+ this.groundMirrorSoftPipeline = this.buildGroundPipeline(true, true);
10459
+ return this.groundMirrorSoftPipeline;
10460
+ }
10461
+ if (!this.groundMirrorPipeline)
10462
+ this.groundMirrorPipeline = this.buildGroundPipeline(false, true);
10463
+ return this.groundMirrorPipeline;
10464
+ }
9633
10465
  /** Built on the first frame that actually needs it. A shader compile costs
9634
10466
  * load time, and the overwhelming majority of scenes never soften a shadow. */
9635
10467
  ensureGroundSoftPipeline() {
@@ -9699,9 +10531,47 @@ export class Engine {
9699
10531
  * and a bind group holding the old view would sample a destroyed texture on
9700
10532
  * the first resized frame with a mirror on.
9701
10533
  */
10534
+ /** The stand-ins above, made once. 4x4 rather than 1x1: the depth one has to
10535
+ * be multisampled to match `texture_depth_multisampled_2d`, and a
10536
+ * multisampled attachment is the one kind of texture worth giving room. */
10537
+ ensureMirrorDummies() {
10538
+ if (this.mirrorDummyColorView && this.mirrorDummyDepthView)
10539
+ return;
10540
+ this.mirrorDummyColorView = this.device
10541
+ .createTexture({
10542
+ label: "mirror binding stand-in (colour)",
10543
+ size: [4, 4],
10544
+ format: this.hdrFormat,
10545
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
10546
+ })
10547
+ .createView();
10548
+ this.mirrorDummyDepthView = this.device
10549
+ .createTexture({
10550
+ label: "mirror binding stand-in (depth)",
10551
+ size: [4, 4],
10552
+ sampleCount: Engine.MULTISAMPLE_COUNT,
10553
+ format: this.depthFormat,
10554
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
10555
+ })
10556
+ .createView({ aspect: "depth-only" });
10557
+ }
9702
10558
  buildGroundBindGroup() {
9703
10559
  if (!this.groundShadowMaterialBuffer)
9704
10560
  return;
10561
+ this.ensureMirrorDummies();
10562
+ if (!this.groundClipOffBuffer) {
10563
+ const make = (label, active) => {
10564
+ const b = this.device.createBuffer({
10565
+ label,
10566
+ size: 32,
10567
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
10568
+ });
10569
+ this.device.queue.writeBuffer(b, 0, new Float32Array([0, 1, 0, 0, active, 0, 0, 0]));
10570
+ return b;
10571
+ };
10572
+ this.groundClipOffBuffer = make("ground clip (camera: off)", 0);
10573
+ this.groundClipMirrorBuffer = make("ground clip (mirror)", 1);
10574
+ }
9705
10575
  this.groundShadowBindGroup = this.device.createBindGroup({
9706
10576
  label: "ground shadow bind",
9707
10577
  layout: this.groundShadowBindGroupLayout,
@@ -9721,6 +10591,31 @@ export class Engine {
9721
10591
  { binding: 10, resource: this.materialSampler },
9722
10592
  { binding: 11, resource: this.mirrorDepthReadView },
9723
10593
  { binding: 12, resource: this.groundNoiseView },
10594
+ { binding: 13, resource: this.mirrorMaskView },
10595
+ { binding: 14, resource: { buffer: this.groundClipOffBuffer } },
10596
+ ],
10597
+ });
10598
+ this.groundMirrorViewBindGroup = this.device.createBindGroup({
10599
+ label: "ground shadow bind (mirror view)",
10600
+ layout: this.groundShadowBindGroupLayout,
10601
+ entries: [
10602
+ { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
10603
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
10604
+ { binding: 2, resource: this.shadowMapDepthViews[0] },
10605
+ { binding: 3, resource: this.shadowComparisonSampler },
10606
+ { binding: 4, resource: { buffer: this.groundShadowMaterialBuffer } },
10607
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
10608
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
10609
+ { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
10610
+ { binding: 8, resource: { buffer: this.mirrorVPBuffer } },
10611
+ // Stand-ins, not the live mirror textures: this group draws INTO the
10612
+ // mirror pass, where those are the attachments.
10613
+ { binding: 9, resource: this.mirrorDummyColorView },
10614
+ { binding: 10, resource: this.materialSampler },
10615
+ { binding: 11, resource: this.mirrorDummyDepthView },
10616
+ { binding: 12, resource: this.groundNoiseView },
10617
+ { binding: 13, resource: this.mirrorDummyColorView },
10618
+ { binding: 14, resource: { buffer: this.groundClipMirrorBuffer } },
9724
10619
  ],
9725
10620
  });
9726
10621
  if (this.groundDrawCall)
@@ -10297,6 +11192,23 @@ export class Engine {
10297
11192
  }
10298
11193
  this.device.queue.submit([encoder.finish()]);
10299
11194
  }
11195
+ /** The ground draw, against a caller's bind group — the camera's, or the
11196
+ * mirror's. One body, so the reflected floor cannot drift from the real one. */
11197
+ renderGroundWith(pass, bindGroup, mirrored = false) {
11198
+ if (this.groundIsSuppressed())
11199
+ return;
11200
+ if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall)
11201
+ return;
11202
+ pass.setPipeline(mirrored
11203
+ ? this.ensureGroundMirrorPipeline(this.groundSoft)
11204
+ : this.groundSoft
11205
+ ? this.ensureGroundSoftPipeline()
11206
+ : this.groundShadowPipeline);
11207
+ pass.setVertexBuffer(0, this.groundVertexBuffer);
11208
+ pass.setIndexBuffer(this.groundIndexBuffer, "uint16");
11209
+ pass.setBindGroup(0, bindGroup);
11210
+ pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0);
11211
+ }
10300
11212
  renderGround(pass) {
10301
11213
  // A stage brings its own floor. Both sit at y=0, so drawing the built-in
10302
11214
  // plane underneath produces z-fighting across the whole scene — enforced
@@ -10472,7 +11384,16 @@ export class Engine {
10472
11384
  if (!this.overlayVertices)
10473
11385
  return;
10474
11386
  const inst = this.overlayModel(this.overlayVertices.modelName);
10475
- const { material, xray } = this.overlayVertices;
11387
+ const { xray } = this.overlayVertices;
11388
+ // A hovered material previews EXACTLY what clicking it would pick — the
11389
+ // same self-occluding, ISOLATED view, nothing else drawn. It substitutes
11390
+ // for `material` only while nothing is actually picked, so the section
11391
+ // opens on the whole mesh and narrows to one material the moment the
11392
+ // pointer (in the viewport OR the list — both just call
11393
+ // setHoveredMaterial) settles on it. A real pick always wins, and there
11394
+ // is nothing left to preview once one is made.
11395
+ const hm = this.hoverMaterial;
11396
+ const material = this.overlayVertices.material ?? (hm && hm.modelName === inst?.name ? hm.materialName : null);
10476
11397
  if (!inst || !this.ensureEdgeBuffer(inst, material))
10477
11398
  return;
10478
11399
  const edges = inst.wireEdges.get(material ?? "");
@@ -10485,15 +11406,6 @@ export class Engine {
10485
11406
  const seams = material === null && this.ensureEdgeBuffer(inst, null, true)
10486
11407
  ? (inst.wireEdges.get(Engine.SEAM_KEY) ?? null)
10487
11408
  : 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
11409
  this.wireframeColorData.set(DEFAULT_VERTEX_COLOR);
10498
11410
  this.wireframeColorData[4] = this.canvas.width;
10499
11411
  this.wireframeColorData[5] = this.canvas.height;
@@ -10508,40 +11420,8 @@ export class Engine {
10508
11420
  this.wireframeColorData[6] = OVERLAY_STYLE.seamStrokePx;
10509
11421
  this.device.queue.writeBuffer(this.wireframeSeamUniformBuffer, 0, this.wireframeColorData);
10510
11422
  }
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
11423
  pass.setBindGroup(0, this.wireframeBindGroup);
10521
11424
  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
11425
  // What writes depth is what is allowed to hide the wireframe, and that
10546
11426
  // differs between the two views.
10547
11427
  //
@@ -10550,14 +11430,18 @@ export class Engine {
10550
11430
  // mode and Maya included, and seeing both walls at once is moire rather than
10551
11431
  // information.
10552
11432
  //
10553
- // A PICKED material writes only its OWN faces. The question a pick asks is
11433
+ // A PICKED (or hovered) material writes only its OWN faces. The question is
10554
11434
  // where this material is, and half of it is usually under a coat; letting
10555
11435
  // the coat hide it does not answer that. Its own depth still goes in, so its
10556
11436
  // back faces stay hidden and it reads as an object instead of a haze —
10557
11437
  // which is the difference between this and turning x-ray on.
10558
11438
  const runs = material !== null ? this.materialIndexRanges(inst, material) : null;
10559
11439
  if (!xray) {
10560
- bindMesh();
11440
+ pass.setPipeline(this.wireframeDepthPipeline);
11441
+ pass.setVertexBuffer(0, inst.vertexBuffer);
11442
+ pass.setVertexBuffer(1, inst.jointsBuffer);
11443
+ pass.setVertexBuffer(2, inst.weightsBuffer);
11444
+ pass.setIndexBuffer(inst.indexBuffer, "uint32");
10561
11445
  if (runs)
10562
11446
  for (const [start, end] of runs)
10563
11447
  pass.drawIndexed(end - start, 1, start);
@@ -10590,7 +11474,8 @@ export class Engine {
10590
11474
  this.overlayBones !== null ||
10591
11475
  this.overlayBodies !== null ||
10592
11476
  this.overlayJoints !== null ||
10593
- this.overlayVertices !== null);
11477
+ this.overlayVertices !== null ||
11478
+ this.selectionFill !== null);
10594
11479
  }
10595
11480
  overlayModel(name) {
10596
11481
  return this.modelInstances.get(name) ?? null;
@@ -10709,7 +11594,7 @@ export class Engine {
10709
11594
  if (inst)
10710
11595
  collect(jointOverlay(inst.model, inst.physics, this.overlayJoints.options));
10711
11596
  }
10712
- if (total === 0 && !this.overlayVertices)
11597
+ if (total === 0 && !this.overlayVertices && !this.selectionFill)
10713
11598
  return;
10714
11599
  this.ensureOverlayInstanceCapacity(total);
10715
11600
  const data = this.overlayInstanceData;
@@ -10736,6 +11621,10 @@ export class Engine {
10736
11621
  const pass = encoder.beginRenderPass(this.overlayPassDescriptor);
10737
11622
  // Under everything: the mesh is the haze the rig is read against.
10738
11623
  this.renderWireframe(pass);
11624
+ // The selection wash, under its own edge lines (drawn below with the rest
11625
+ // of this layer's basic primitives) so the crisp outline still reads on
11626
+ // top of the fill rather than being washed out by it.
11627
+ this.renderSelectionFill(pass);
10739
11628
  pass.setBindGroup(0, this.overlayBindGroup);
10740
11629
  pass.setVertexBuffer(0, this.overlayVertexBuffer);
10741
11630
  pass.setVertexBuffer(1, this.overlayInstanceBuffer);
@@ -11260,6 +12149,8 @@ export class Engine {
11260
12149
  this.dispatchMorphCompute(encoder);
11261
12150
  // Frustum cull into indirect arguments. After the camera and shadow matrices
11262
12151
  // are settled, before the passes that draw from them.
12152
+ // Before reflectionActive is read: this is what decides it.
12153
+ this.syncMirrorFromEffects();
11263
12154
  if (this.reflectionActive)
11264
12155
  this.updateMirrorCamera();
11265
12156
  if (hasModels)
@@ -11313,6 +12204,14 @@ export class Engine {
11313
12204
  // instance count of an invisible model's draws.
11314
12205
  if (wanted && this.shadowBundles[ci])
11315
12206
  sp.executeBundles([this.shadowBundles[ci]]);
12207
+ // The mirror throws shade like anything else standing on the floor.
12208
+ // Direct rather than in the bundle: the bundles are recorded on scene
12209
+ // STRUCTURE, and a mirror comes and goes with an effect's weight.
12210
+ if (wanted && this.mirrorSurface && this.mirrorShadowPipeline) {
12211
+ sp.setPipeline(this.mirrorShadowPipeline);
12212
+ sp.setBindGroup(0, this.mirrorShadowBindGroups[ci]);
12213
+ sp.draw(6);
12214
+ }
11316
12215
  sp.end();
11317
12216
  this.shadowCascadeCleared[ci] = !wanted;
11318
12217
  }
@@ -11364,6 +12263,10 @@ export class Engine {
11364
12263
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
11365
12264
  if (this.hasGround)
11366
12265
  this.renderGround(pass);
12266
+ // After the ground for the same early-z reason, and before the transparent
12267
+ // phase so sheer fabric blends over the glass rather than being depth
12268
+ // -rejected behind it.
12269
+ this.renderMirrorSurface(pass);
11367
12270
  // The transparent phase is drawn DIRECTLY, and must stay that way. It is the
11368
12271
  // one part of this pass that is not bundled, so the reason is worth keeping.
11369
12272
  //
@@ -11578,13 +12481,21 @@ export class Engine {
11578
12481
  claimed.set(m, g.id);
11579
12482
  }
11580
12483
  }
11581
- // Tear down installs no longer present.
12484
+ // Installs no longer present lose their CLAIM now (the generation bump
12485
+ // invalidates any in-flight compile for that id) but keep their BUFFER
12486
+ // until every draw call has stopped pointing at it. This was the actual
12487
+ // bug: destroying here, synchronously, left the render loop — which runs
12488
+ // independently on its own rAF and does not know this function is
12489
+ // mid-await — still submitting frames through a bind group whose buffer
12490
+ // no longer existed, for however many frames compileAndInstallGroup's own
12491
+ // shader compiles below took. "used in submit while destroyed" was that
12492
+ // race, not a one-off.
11582
12493
  const nextIds = new Set(groups.map((g) => g.id));
12494
+ const outgoing = [];
11583
12495
  for (const [id, install] of inst.styleGroups) {
11584
12496
  if (!nextIds.has(id)) {
11585
12497
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1);
11586
- this.destroyInstall(install);
11587
- inst.styleGroups.delete(id);
12498
+ outgoing.push([id, install]);
11588
12499
  }
11589
12500
  }
11590
12501
  const groupResults = [];
@@ -11592,7 +12503,13 @@ export class Engine {
11592
12503
  const r = await this.compileAndInstallGroup(inst, g);
11593
12504
  groupResults.push({ groupId: g.id, diagnostics: r.diagnostics, ok: r.ok });
11594
12505
  }
12506
+ // Repoints every draw call away from the outgoing installs (among
12507
+ // everything else) — only past this line is destroying them safe.
11595
12508
  this.assignDrawCallGroups(inst, claimed);
12509
+ for (const [id, install] of outgoing) {
12510
+ this.destroyInstall(install);
12511
+ inst.styleGroups.delete(id);
12512
+ }
11596
12513
  return {
11597
12514
  ok: groupResults.every((r) => r.ok),
11598
12515
  groups: groupResults,
@@ -11880,6 +12797,7 @@ export class Engine {
11880
12797
  let pipeline;
11881
12798
  let pipelineNoDepthWrite;
11882
12799
  let overEyesPipeline;
12800
+ let mirrorPipeline;
11883
12801
  try {
11884
12802
  pipeline = await this.createRenderClassPipeline(renderClass, module, false);
11885
12803
  // The depth-write-off twin: stage transparency draws with it (see
@@ -11887,6 +12805,10 @@ export class Engine {
11887
12805
  pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false);
11888
12806
  if (renderClass === "hair")
11889
12807
  overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true);
12808
+ // Only the eye needs one: every other class culls "none", which a flipped
12809
+ // winding leaves alone.
12810
+ if (renderClass === "eye")
12811
+ mirrorPipeline = await this.createRenderClassPipeline(renderClass, module, false, true, true);
11890
12812
  }
11891
12813
  catch (e) {
11892
12814
  diagnostics.push({ severity: "error", message: `pipeline creation failed: ${e.message}` });
@@ -11913,6 +12835,7 @@ export class Engine {
11913
12835
  pipeline,
11914
12836
  pipelineNoDepthWrite,
11915
12837
  overEyesPipeline,
12838
+ mirrorPipeline,
11916
12839
  uniformBuffer,
11917
12840
  images: this.uploadGroupImages(group),
11918
12841
  slotMap: result.slotMap,
@@ -12012,12 +12935,20 @@ export class Engine {
12012
12935
  * render-class owns pass integration (stencil interplay, depth bias, cull). auto = plain;
12013
12936
  * eye = stamp + front cull + bias; hair = stencil-test (+ the over-eyes variant).
12014
12937
  */
12015
- createRenderClassPipeline(renderClass, module, overEyes, depthWrite = true) {
12938
+ createRenderClassPipeline(renderClass, module, overEyes, depthWrite = true, mirrored = false) {
12016
12939
  const base = {
12017
12940
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
12018
12941
  layout: this.mainPipelineLayout,
12019
12942
  vertex: { module, buffers: this.fullVertexBufferLayouts },
12020
- primitive: { cullMode: (renderClass === "eye" ? "front" : "none") },
12943
+ // The eye front-culls — the see-through-hair look is draw order plus that
12944
+ // cull plus the stencil stamp, and it is the deployed appearance of every
12945
+ // published scene. In the MIRROR the winding is flipped (determinant -1,
12946
+ // reflection.ts), so the same cull keeps the back of the eyeball and
12947
+ // discards its front. `mirrored` flips it back rather than changing what
12948
+ // the camera sees.
12949
+ primitive: {
12950
+ cullMode: (renderClass === "eye" ? (mirrored ? "back" : "front") : "none"),
12951
+ },
12021
12952
  multisample: { count: Engine.MULTISAMPLE_COUNT },
12022
12953
  };
12023
12954
  const plainDepth = {
@@ -12081,12 +13012,15 @@ export class Engine {
12081
13012
  // rain vanished the instant the camera crossed a glass dome or a curtain
12082
13013
  // (reported: binary vanish/recover with camera angle, stage loaded). Stage
12083
13014
  // transparency blends and leaves depth alone.
12084
- pipelineForDrawCall(inst, dc) {
13015
+ pipelineForDrawCall(inst, dc, mirrored = false) {
12085
13016
  const stageGlass = inst.isStage && dc.type === "transparent";
12086
13017
  if (dc.groupId) {
12087
13018
  const install = inst.styleGroups.get(dc.groupId);
12088
- if (install)
13019
+ if (install) {
13020
+ if (mirrored && install.mirrorPipeline)
13021
+ return install.mirrorPipeline;
12089
13022
  return stageGlass ? install.pipelineNoDepthWrite : install.pipeline;
13023
+ }
12090
13024
  }
12091
13025
  return stageGlass ? this.neutralPipelineNoDepthWrite : this.neutralPipeline;
12092
13026
  }
@@ -12122,7 +13056,7 @@ export class Engine {
12122
13056
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
12123
13057
  bound = true;
12124
13058
  }
12125
- const pipeline = this.pipelineForDrawCall(inst, draw);
13059
+ const pipeline = this.pipelineForDrawCall(inst, draw, view.args === "mirror");
12126
13060
  if (pipeline !== currentPipeline) {
12127
13061
  pass.setPipeline(pipeline);
12128
13062
  currentPipeline = pipeline;
@@ -12133,8 +13067,9 @@ export class Engine {
12133
13067
  // Same index range; own pipeline + groups 0/2. Group 1 (skinMats) is
12134
13068
  // layout-identical between the main and outline pipelines and stays
12135
13069
  // bound. Restore group 0 afterwards and force a pipeline re-set.
12136
- pass.setPipeline(this.outlinePipeline);
12137
- pass.setBindGroup(0, this.outlinePerFrameBindGroup);
13070
+ const mirrored = view.args === "mirror";
13071
+ pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline);
13072
+ pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup);
12138
13073
  pass.setBindGroup(2, draw.outline.bindGroup);
12139
13074
  this.issueDraw(pass, draw, view.args);
12140
13075
  pass.setBindGroup(0, view.perFrame);
@@ -12168,10 +13103,13 @@ export class Engine {
12168
13103
  * main camera's bind group.
12169
13104
  */
12170
13105
  sceneView(kind) {
13106
+ // Outlines in BOTH now. They were off in the mirror because the hull culls
13107
+ // back faces and a reflection flips winding, so every hull triangle was
13108
+ // culled — a reflection of a toon character with no ink line beside the
13109
+ // real one reads as broken. The draw picks the flipped-cull pipeline and
13110
+ // the mirror camera's own group; see outlineMirrorPipeline.
12171
13111
  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 }
13112
+ ? { perFrame: this.mirrorPerFrameBindGroup, args: "mirror", outlines: true }
12175
13113
  : { perFrame: this.perFrameBindGroup, args: "camera", outlines: true };
12176
13114
  }
12177
13115
  renderModelOpaquePhase(pass, inst, view) {
@@ -12956,11 +13894,16 @@ Engine.TIMED_PASSES = [
12956
13894
  "composite",
12957
13895
  "overlay",
12958
13896
  ];
12959
- // ── The floor mirror (step 7C) ──
13897
+ // ── The mirror (step 7C) ──
12960
13898
  // 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;
13899
+ // mirror's own camera block.
13900
+ //
13901
+ // ONE reflection target, so ONE plane at a time. The floor's is (0, 1, 0, 0)
13902
+ // — MMD floors live at y = 0 by convention and addGround builds its quad
13903
+ // there — and a placed mirror surface REPLACES it for as long as it exists,
13904
+ // because two planes would need two of every attachment below. Which one is
13905
+ // in force is mirrorPlane, refreshed by updateMirrorCamera.
13906
+ Engine.GROUND_PLANE = [0, 1, 0, 0];
12964
13907
  Engine.BLOOM_MAX_LEVELS = 5;
12965
13908
  // Width of the baked Filmic tone LUT (composite.ts FILMIC_LUT_W must match).
12966
13909
  Engine.FILMIC_LUT_WIDTH = 256;
@@ -12968,6 +13911,37 @@ Engine.FILMIC_LUT_WIDTH = 256;
12968
13911
  // (vsync-to-vsync), recomputed at STATS_REFRESH_MS so the readout doesn't flicker.
12969
13912
  Engine.STATS_WINDOW = 120;
12970
13913
  Engine.STATS_REFRESH_MS = 500;
13914
+ /**
13915
+ * The dials a `#mirror` effect is READ BY — the contract between an effect
13916
+ * file and the reflection.
13917
+ *
13918
+ * By name rather than by position, and with a fallback each, so that an
13919
+ * effect declaring only some of them still works: a mirror that wants to sit
13920
+ * at the origin facing the camera declares nothing but its size, and the ones
13921
+ * it leaves out are not missing, they are default.
13922
+ *
13923
+ * Names in the built-ins' own convention (SCREAMING_SNAKE), because these
13924
+ * appear in the params panel beside every other effect's.
13925
+ */
13926
+ Engine.MIRROR_DIALS = {
13927
+ posX: "POS_X",
13928
+ posY: "POS_Y",
13929
+ posZ: "POS_Z",
13930
+ /** Degrees, the unit the slider shows — nothing converts on the way in. */
13931
+ rotX: "ROT_X",
13932
+ rotY: "ROT_Y",
13933
+ rotZ: "ROT_Z",
13934
+ width: "WIDTH",
13935
+ height: "HEIGHT",
13936
+ tint: "TINT",
13937
+ blur: "BLUR",
13938
+ frame: "FRAME",
13939
+ frameColor: "FRAME_COLOR",
13940
+ };
13941
+ /** Opaque red — a selection is a working state, not a material property,
13942
+ * so it reads as unmistakably distinct from anything a model's own
13943
+ * colours could be. */
13944
+ Engine.SELECTION_COLOR = [1, 0.15, 0.15, 1];
12971
13945
  // ── GPU frustum cull ────────────────────────────────────────────────────────
12972
13946
  //
12973
13947
  // Sizes, once, so the arithmetic below is readable: a DrawMeta is 32 bytes