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/src/engine.ts CHANGED
@@ -32,7 +32,8 @@ import { ID_DEBUG_SHADER_WGSL } from "./shaders/passes/id-debug"
32
32
  import { paramChanged, sampleParamTrack, type ParamKey, type ParamValue } from "./param-track"
33
33
  import { effectState, type EffectWindow } from "./effect-schedule"
34
34
  import { SHADOW_CASCADES, buildShadowVP } from "./shadow-cascades"
35
- import { REFLECTION_DEBUG_WGSL, buildMirrorCamera } from "./reflection"
35
+ import { REFLECTION_DEBUG_WGSL, buildMirrorCamera, planeFromPointNormal } from "./reflection"
36
+ import { MIRROR_MASK_DOWNSAMPLE_WGSL, MIRROR_MAT_BYTES, mirrorShaderWgsl, mirrorShadowWgsl } from "./shaders/passes/mirror"
36
37
  import { packHalf, type HdrImage } from "./hdr"
37
38
  import { evalIrradianceSH, projectIrradianceSH } from "./ibl"
38
39
  import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, lyricsApi, packLyrics, type LyricLine, type LyricRect } from "./shaders/lyrics-api"
@@ -64,6 +65,7 @@ import {
64
65
  boneMarkerPositions,
65
66
  buildOverlayShapes,
66
67
  jointOverlay,
68
+ lineBetween,
67
69
  rigidbodyOverlay,
68
70
  writeOverlayInstance,
69
71
  OVERLAY_INSTANCE_FLOATS,
@@ -77,6 +79,7 @@ import {
77
79
  type OverlayGeometry,
78
80
  type OverlayPrimitive,
79
81
  type OverlayShape,
82
+ type RGBA,
80
83
  type RigidbodyOverlayOptions,
81
84
  } from "./overlay"
82
85
  import {
@@ -283,6 +286,11 @@ type GroupInstall = {
283
286
  pipelineNoDepthWrite: GPURenderPipeline
284
287
  /** hair render-class only: the stencil-matched IS_OVER_EYES=true variant. */
285
288
  overEyesPipeline?: GPURenderPipeline
289
+ /** eye render-class only: the same pipeline with its cull flipped, for the
290
+ * mirror pass. The eye is the ONE material class that culls a face, and a
291
+ * reflection flips winding, so in the mirror the ordinary pipeline keeps
292
+ * exactly the faces it exists to discard. */
293
+ mirrorPipeline?: GPURenderPipeline
286
294
  uniformBuffer: GPUBuffer
287
295
  /** The group's own image maps, uploaded once per apply and owned here — the
288
296
  * install destroys them when it is replaced, so a re-apply cannot leak. */
@@ -1458,6 +1466,9 @@ interface EffectInstance {
1458
1466
  paramLayout: Map<string, { offset: number; comps: 1 | 3 }>
1459
1467
  paramsBuffer: GPUBuffer | null
1460
1468
  paramsData: Float32Array<ArrayBuffer>
1469
+ /** This effect IS a mirror — see the `#mirror` directive. Draws no shader;
1470
+ * the engine reads its dials and folds the scene through the plane. */
1471
+ hasMirror: boolean
1461
1472
  /** Mounted under the scene. */
1462
1473
  hasBackground: boolean
1463
1474
  /** Mounted over the finished frame — and the reason the scene pass has to
@@ -1610,6 +1621,9 @@ export class Engine {
1610
1621
  private groundSoft = false
1611
1622
  private groundShadowBindGroupLayout!: GPUBindGroupLayout
1612
1623
  private outlinePipeline!: GPURenderPipeline
1624
+ /** The same hull with its winding answered — see the pipeline's own note. */
1625
+ private outlineMirrorPipeline!: GPURenderPipeline
1626
+ private outlineMirrorPerFrameBindGroup!: GPUBindGroup
1613
1627
  private selectedMaterial: { modelName: string; materialName: string } | null = null
1614
1628
  private selectionMaskTexture?: GPUTexture
1615
1629
  private selectionMaskView?: GPUTextureView
@@ -1665,12 +1679,19 @@ export class Engine {
1665
1679
  * buffer twice would give both draws the second value. */
1666
1680
  private wireframeSeamUniformBuffer!: GPUBuffer
1667
1681
  private wireframeSeamBindGroup!: GPUBindGroup
1668
- /** Same reason: the hovered material draws its own stroke, self-occluding, in
1669
- * the same frame as the base mesh and the seams. */
1670
- private wireframeHoverUniformBuffer!: GPUBuffer
1671
- private wireframeHoverBindGroup!: GPUBindGroup
1672
1682
  private wireframeSkinLayout!: GPUBindGroupLayout
1673
1683
  private wireframeColorData = new Float32Array(8)
1684
+ // ─── Selection fill ────────────────────────────────────────────────
1685
+ // The box-select highlight, filled rather than just outlined — the same
1686
+ // depth-prepass vertex/fragment pair the wireframe's solid pass already
1687
+ // uses, over an index buffer built fresh per selection (see
1688
+ // setSelectionFill) rather than sliced from the model's own, since a
1689
+ // selection is an arbitrary, non-contiguous subset of one material's faces.
1690
+ private selectionFillPipeline!: GPURenderPipeline
1691
+ private selectionFillUniformBuffer!: GPUBuffer
1692
+ private selectionFillBindGroup!: GPUBindGroup
1693
+ private selectionFill: { modelName: string; buffer: GPUBuffer; count: number; skinBindGroup: GPUBindGroup } | null =
1694
+ null
1674
1695
  /** Rebuilt every frame into these, grouped by shape so each shape is one draw. */
1675
1696
  private overlayByShape = new Map<OverlayShape, OverlayPrimitive[]>()
1676
1697
  private overlayScratch: OverlayPrimitive[] = []
@@ -2084,11 +2105,17 @@ export class Engine {
2084
2105
  private cullCameraArgs: GPUBuffer | null = null
2085
2106
  private cullShadowArgs: GPUBuffer | null = null
2086
2107
  private cullMirrorArgs: GPUBuffer | null = null
2087
- // ── The floor mirror (step 7C) ──
2108
+ // ── The mirror (step 7C) ──
2088
2109
  // Half-res scene-contract attachments a mirrored draw renders into, plus the
2089
- // mirror's own camera block. The plane is the ground plane: MMD floors live
2090
- // at y = 0 by convention and addGround builds its quad there.
2091
- private static readonly REFLECTION_PLANE_Y = 0
2110
+ // mirror's own camera block.
2111
+ //
2112
+ // ONE reflection target, so ONE plane at a time. The floor's is (0, 1, 0, 0)
2113
+ // — MMD floors live at y = 0 by convention and addGround builds its quad
2114
+ // there — and a placed mirror surface REPLACES it for as long as it exists,
2115
+ // because two planes would need two of every attachment below. Which one is
2116
+ // in force is mirrorPlane, refreshed by updateMirrorCamera.
2117
+ private static readonly GROUND_PLANE: readonly number[] = [0, 1, 0, 0]
2118
+ private mirrorPlane = new Float32Array([0, 1, 0, 0])
2092
2119
  private mirrorCameraData = new Float32Array(40)
2093
2120
  private mirrorCameraBuffer!: GPUBuffer
2094
2121
  // proj x mirrorView for the ground's projective sample and the cull planes,
@@ -2105,6 +2132,24 @@ export class Engine {
2105
2132
  private mirrorBlurBindGroups: GPUBindGroup[] | null = null
2106
2133
  private groundMirrorBlur = 0
2107
2134
  private mirrorMaskMsTexture: GPUTexture | null = null
2135
+ /** The mirror pass's aux, RESOLVED and kept — `.g` is accumulated alpha, and
2136
+ * it is the only coverage the reflection has. The HDR format is
2137
+ * rg11b10ufloat wherever the device allows it, which has no alpha channel at
2138
+ * all, so a mirror that read `refl.a` read 1 everywhere and painted the
2139
+ * reflection target's empty black across the whole pane. */
2140
+ private mirrorMaskTexture: GPUTexture | null = null
2141
+ private mirrorMaskView: GPUTextureView | null = null
2142
+ /** Coverage carries the SAME mip chain as the colour — the two are one
2143
+ * premultiplied quantity and a blur has to move them together. */
2144
+ private mirrorMaskMipViews: GPUTextureView[] = []
2145
+ private mirrorMaskBlurBindGroups: GPUBindGroup[] | null = null
2146
+ private mirrorMaskDownsamplePipeline: GPURenderPipeline | null = null
2147
+ /** Two blocks the ground reads its clip from: the camera's is inert, the
2148
+ * mirror's carries the live plane. Which one a draw sees is decided by which
2149
+ * bind group it uses, so nothing has to know which pass is running. */
2150
+ private groundClipOffBuffer!: GPUBuffer
2151
+ private groundClipMirrorBuffer!: GPUBuffer
2152
+ private groundClipData = new Float32Array(8)
2108
2153
  private mirrorIdMsTexture: GPUTexture | null = null
2109
2154
  private mirrorDepthTexture: GPUTexture | null = null
2110
2155
  private mirrorDepthReadView: GPUTextureView | null = null
@@ -2113,12 +2158,30 @@ export class Engine {
2113
2158
  private mirrorTransparentBundle: GPURenderBundle | null = null
2114
2159
  /** setGroundMirror lands in step 7D; the debug dial is what exercises C. */
2115
2160
  private groundMirror = 0
2161
+ // ── The mirror SURFACE: the reflection as a plane you can put anywhere ──
2162
+ // Null when there is none, which is also what keeps the reflection pass off.
2163
+ // No vertex or index buffer: the quad is six generated vertices and the model
2164
+ // matrix is the whole of the difference between one mirror and another.
2165
+ private mirrorSurface: { tint: Vec3; blur: number } | null = null
2166
+ /** Position, rotation and the two extents, baked. Column 2 is the normal the
2167
+ * reflection plane is built from, so this is the single source for both the
2168
+ * draw and the fold — they cannot disagree about where the glass is. */
2169
+ private mirrorSurfaceModel = new Float32Array(16)
2170
+ private mirrorSurfaceMatData = new Float32Array(28)
2171
+ private mirrorSurfaceMatBuffer: GPUBuffer | null = null
2172
+ private mirrorSurfaceBindGroupLayout: GPUBindGroupLayout | null = null
2173
+ private mirrorSurfacePipeline: GPURenderPipeline | null = null
2174
+ private mirrorSurfaceBindGroup: GPUBindGroup | null = null
2175
+ /** The pane in the shadow pass — the frame throwing shade on the floor. One
2176
+ * bind group per cascade, each carrying its own index. */
2177
+ private mirrorShadowPipeline: GPURenderPipeline | null = null
2178
+ private mirrorShadowBindGroups: GPUBindGroup[] = []
2116
2179
  private reflectionDebug = false
2117
2180
  private reflectionDebugPipeline: GPURenderPipeline | null = null
2118
2181
  private reflectionDebugBindGroupLayout: GPUBindGroupLayout | null = null
2119
2182
  private reflectionDebugBindGroup: GPUBindGroup | null = null
2120
2183
  private get reflectionActive(): boolean {
2121
- return this.reflectionDebug || this.groundMirror > 0
2184
+ return this.reflectionDebug || this.groundMirror > 0 || this.mirrorSurface !== null
2122
2185
  }
2123
2186
  private cullFrustaBuffer: GPUBuffer | null = null
2124
2187
  // 18 planes (camera, shadow, mirror) x 16 bytes, then the counts vec4u.
@@ -2303,6 +2366,18 @@ export class Engine {
2303
2366
  // All cascades' view-projections, 16 floats each, inner to outer.
2304
2367
  private shadowLightVPMatrix = new Float32Array(16 * SHADOW_CASCADES.length)
2305
2368
  private groundShadowBindGroup?: GPUBindGroup
2369
+ /** The same ground, bound to the MIRROR camera — the floor inside the
2370
+ * reflection. Identical to the pair above but for binding 0, so the floor
2371
+ * shades from the mirrored eye, which is what a reflected floor is. */
2372
+ private groundMirrorViewBindGroup?: GPUBindGroup
2373
+ // Stand-ins for the three mirror textures the ground's layout demands, bound
2374
+ // ONLY by the group that draws the floor INSIDE the mirror pass. Those
2375
+ // textures are that pass's own attachments, and WebGPU rejects a pass that
2376
+ // both writes a texture and binds it — regardless of whether the shader
2377
+ // reaches the sample. `material.mirror` is 0 in that pass (the draw is
2378
+ // skipped when the floor's own mirror is on), so nothing ever reads these.
2379
+ private mirrorDummyColorView: GPUTextureView | null = null
2380
+ private mirrorDummyDepthView: GPUTextureView | null = null
2306
2381
  private shadowComparisonSampler!: GPUSampler
2307
2382
  private groundShadowMaterialBuffer?: GPUBuffer
2308
2383
  private groundDrawCall: DrawCall | null = null
@@ -2777,12 +2852,358 @@ export class Engine {
2777
2852
  if (!this.groundShadowMaterialBuffer) return false
2778
2853
  this.groundMirror = on ? 1 : 0
2779
2854
  if (blur !== undefined) this.groundMirrorBlur = Math.min(Math.max(blur, 0), 1)
2855
+ this.writeGroundMirrorField()
2856
+ return true
2857
+ }
2858
+
2859
+ /**
2860
+ * The floor's mirror field, as the shader should see it.
2861
+ *
2862
+ * A PLACED mirror takes the plane — there is one reflection target and one
2863
+ * fold, and a floor still trying to reflect through a wall's plane shows the
2864
+ * scene lying on its side. So the field is forced to 0 while a mirror surface
2865
+ * exists, without touching what the caller set: remove the mirror and the
2866
+ * floor's own setting comes back.
2867
+ *
2868
+ * It is also what lets the reflection carry the FLOOR. The mirror pass draws
2869
+ * the ground now, and a ground whose own mirror is on would be sampling the
2870
+ * very texture that pass is writing.
2871
+ */
2872
+ private writeGroundMirrorField(): void {
2873
+ if (!this.groundShadowMaterialBuffer) return
2874
+ const effective = this.mirrorSurface ? 0 : this.groundMirror
2780
2875
  this.device.queue.writeBuffer(
2781
2876
  this.groundShadowMaterialBuffer,
2782
2877
  15 * 4,
2783
- new Float32Array([this.groundMirror, this.groundMirrorBlur]),
2878
+ new Float32Array([effective, this.groundMirrorBlur]),
2784
2879
  )
2785
- return true
2880
+ }
2881
+
2882
+ /**
2883
+ * Place a mirror in the scene, or remove it with null.
2884
+ *
2885
+ * PRIVATE, and the only caller is syncMirrorFromEffects. A mirror reaches a
2886
+ * scene by applying the effect that declares `#mirror`, which is what gives
2887
+ * it dials, a schedule, a library row and a publish — a second public door
2888
+ * straight to the plane would be the same feature with none of that, and two
2889
+ * ways to place one mirror when there can only be one.
2890
+ *
2891
+ * A real planar reflection: the scene is drawn a second time from the camera
2892
+ * folded through this plane, so what the glass shows is the SCENE — lit by
2893
+ * the same sun, carrying the same shadows, with particle and ribbon effects
2894
+ * in it — rather than a copy of what the camera already sees.
2895
+ *
2896
+ * ONE at a time. The reflection pass owns a single set of attachments and a
2897
+ * single camera block, and a second mirror is a second of each; placing one
2898
+ * also takes the plane away from the floor mirror, which is the same target.
2899
+ *
2900
+ * `width` and `height` are metres of glass. `rotation` orients a quad whose
2901
+ * face is +Z at identity, so a mirror dropped in with no rotation faces the
2902
+ * way the default camera looks from — what someone means by putting one in
2903
+ * front of her.
2904
+ */
2905
+ private setMirror(
2906
+ options: {
2907
+ /** Centre of the glass, world space. */
2908
+ position?: XYZ
2909
+ /** Orientation of the quad. Identity faces +Z. */
2910
+ rotation?: { x: number; y: number; z: number; w: number }
2911
+ width?: number
2912
+ height?: number
2913
+ /** How a mirror falls short of perfect, as a colour rather than a fade.
2914
+ * Default (1, 1, 1) — every photon back. */
2915
+ tint?: XYZ
2916
+ /** Frosting, 0-1: 0 a polished mirror, 1 the softest level of the
2917
+ * reflection's own blur chain. */
2918
+ blur?: number
2919
+ /** Moulding width in world units, measured in from each edge. 0 is a
2920
+ * bare pane of glass. */
2921
+ frame?: number
2922
+ frameColor?: XYZ
2923
+ } | null,
2924
+ ): void {
2925
+ if (!this.device) return
2926
+ if (!options) {
2927
+ this.mirrorSurface = null
2928
+ return
2929
+ }
2930
+ // MMD UNITS, not metres. A character stands about 20 tall here — the
2931
+ // engine's own numbers say so: the camera targets y = 11, spawnOffsetX
2932
+ // stands the next cast member 9 aside, and the ground fades out at 80. A
2933
+ // mirror sized in metres is a postage stamp at her ankle.
2934
+ //
2935
+ // POSITION IS THE FOOT, not the centre: the quad runs 0..1 upward from it,
2936
+ // so shortening the mirror takes the height off the top and turning it
2937
+ // pivots on the floor. See the vertex shader.
2938
+ //
2939
+ // AND IT STANDS BEHIND HER, NOT IN FRONT. A mirror shows what is on its own
2940
+ // front side and nothing at all of what is behind it, so a plane dropped
2941
+ // between the camera and the cast is a WINDOW: the subject is on the far
2942
+ // side of the glass and what reflects is the empty room. The default camera
2943
+ // orbits to z = -31 (alpha = pi, distance 33), so the default here sits at
2944
+ // +z, off to one side and turned back toward both — which is the
2945
+ // arrangement every mirror shot uses, and the reason it is not centred is
2946
+ // that a mirror directly behind her reflects her into her own silhouette.
2947
+ const px = options.position?.x ?? -11
2948
+ const py = options.position?.y ?? 0
2949
+ const pz = options.position?.z ?? 2
2950
+ const r = options.rotation ?? { x: 0, y: 0, z: 0, w: 1 }
2951
+ const width = options.width ?? 14
2952
+ const height = options.height ?? 24
2953
+ const tint = options.tint ?? { x: 1, y: 1, z: 1 }
2954
+ const blur = Math.min(Math.max(options.blur ?? 0, 0), 1)
2955
+ const frame = options.frame ?? 0.48
2956
+ const frameColor = options.frameColor ?? { x: 0.631, y: 0.631, z: 0.667 }
2957
+
2958
+ // Rotation, then a NON-UNIFORM scale on the two in-plane axes — which is
2959
+ // why this is not fromPositionRotationScaleInto: a mirror is 2 x 3, not a
2960
+ // square someone scaled. Column 2 is left unit length; it is the normal.
2961
+ const m = this.mirrorSurfaceModel
2962
+ Mat4.fromQuatInto(r.x, r.y, r.z, r.w, m, 0)
2963
+ m[0] *= width; m[1] *= width; m[2] *= width
2964
+ m[4] *= height; m[5] *= height; m[6] *= height
2965
+ m[12] = px
2966
+ m[13] = py
2967
+ m[14] = pz
2968
+
2969
+ this.mirrorSurface = { tint: new Vec3(tint.x, tint.y, tint.z), blur }
2970
+ const d = this.mirrorSurfaceMatData
2971
+ d.set(m, 0)
2972
+ d[16] = tint.x
2973
+ d[17] = tint.y
2974
+ d[18] = tint.z
2975
+ d[19] = blur
2976
+ d[20] = frameColor.x
2977
+ d[21] = frameColor.y
2978
+ d[22] = frameColor.z
2979
+ // Clamped to under half the short side: a band wider than the glass leaves
2980
+ // no glass, and a mirror that is all moulding is a plank.
2981
+ d[23] = Math.min(Math.max(frame, 0), Math.min(width, height) * 0.45)
2982
+ d[24] = width
2983
+ d[25] = height
2984
+ this.ensureMirrorSurfaceResources()
2985
+ this.device.queue.writeBuffer(this.mirrorSurfaceMatBuffer!, 0, this.mirrorSurfaceMatData)
2986
+ }
2987
+
2988
+ /** Called by the sync once the surface exists, so the floor yields the plane
2989
+ * on the same frame the mirror appears. */
2990
+ private mirrorSurfaceAppeared(): void {
2991
+ this.writeGroundMirrorField()
2992
+ }
2993
+
2994
+ /**
2995
+ * The dials a `#mirror` effect is READ BY — the contract between an effect
2996
+ * file and the reflection.
2997
+ *
2998
+ * By name rather than by position, and with a fallback each, so that an
2999
+ * effect declaring only some of them still works: a mirror that wants to sit
3000
+ * at the origin facing the camera declares nothing but its size, and the ones
3001
+ * it leaves out are not missing, they are default.
3002
+ *
3003
+ * Names in the built-ins' own convention (SCREAMING_SNAKE), because these
3004
+ * appear in the params panel beside every other effect's.
3005
+ */
3006
+ private static readonly MIRROR_DIALS = {
3007
+ posX: "POS_X",
3008
+ posY: "POS_Y",
3009
+ posZ: "POS_Z",
3010
+ /** Degrees, the unit the slider shows — nothing converts on the way in. */
3011
+ rotX: "ROT_X",
3012
+ rotY: "ROT_Y",
3013
+ rotZ: "ROT_Z",
3014
+ width: "WIDTH",
3015
+ height: "HEIGHT",
3016
+ tint: "TINT",
3017
+ blur: "BLUR",
3018
+ frame: "FRAME",
3019
+ frameColor: "FRAME_COLOR",
3020
+ } as const
3021
+
3022
+ /** One dial's live value, or the fallback when the effect never declared it.
3023
+ * Reads the CPU mirror of the params buffer, which setEffectParam keeps
3024
+ * current — so dragging a slider moves the glass on the same frame. */
3025
+ private effectDial(fx: EffectInstance, name: string, fallback: number): number {
3026
+ const slot = fx.paramLayout.get(name)
3027
+ return slot ? fx.paramsData[slot.offset] : fallback
3028
+ }
3029
+
3030
+ /**
3031
+ * Drive the mirror from whichever effect declares one.
3032
+ *
3033
+ * Runs before reflectionActive is read, because the answer to "is there a
3034
+ * mirror" is this function's output. FIRST match wins and the rest are
3035
+ * ignored: there is one reflection target, so a second mirror is not a second
3036
+ * mirror, it is a fight over the same plane — and a scene that quietly
3037
+ * reflected the wrong one would be far harder to understand than one where
3038
+ * the second mirror does nothing.
3039
+ *
3040
+ * Gated on `weight`, so a scheduled mirror appears and leaves with its strip
3041
+ * and a faded-out one costs no reflection pass at all.
3042
+ */
3043
+ private syncMirrorFromEffects(): void {
3044
+ let fx: EffectInstance | null = null
3045
+ for (const e of this.effects) {
3046
+ if (e.hasMirror && e.weight > 0) {
3047
+ fx = e
3048
+ break
3049
+ }
3050
+ }
3051
+ if (!fx) {
3052
+ if (this.mirrorSurface) {
3053
+ this.mirrorSurface = null
3054
+ this.writeGroundMirrorField()
3055
+ }
3056
+ return
3057
+ }
3058
+ const had = this.mirrorSurface !== null
3059
+ const D = Engine.MIRROR_DIALS
3060
+ const deg = Math.PI / 180
3061
+ const rot = Quat.fromEuler(
3062
+ this.effectDial(fx, D.rotX, 0) * deg,
3063
+ this.effectDial(fx, D.rotY, 0) * deg,
3064
+ this.effectDial(fx, D.rotZ, 0) * deg,
3065
+ )
3066
+ const colour = (name: string, r: number, g: number, b: number) => {
3067
+ const slot = fx!.paramLayout.get(name)
3068
+ return slot
3069
+ ? { x: fx!.paramsData[slot.offset], y: fx!.paramsData[slot.offset + 1], z: fx!.paramsData[slot.offset + 2] }
3070
+ : { x: r, y: g, z: b }
3071
+ }
3072
+ const tint = colour(D.tint, 1, 1, 1)
3073
+ this.setMirror({
3074
+ position: {
3075
+ x: this.effectDial(fx, D.posX, -11),
3076
+ y: this.effectDial(fx, D.posY, 0),
3077
+ z: this.effectDial(fx, D.posZ, 2),
3078
+ },
3079
+ rotation: rot,
3080
+ // Floored well above zero: a mirror scaled to nothing is a degenerate
3081
+ // quad whose normal is still read for the plane, and the reflection would
3082
+ // fold through a plane nobody can see.
3083
+ width: Math.max(this.effectDial(fx, D.width, 14), 0.1),
3084
+ height: Math.max(this.effectDial(fx, D.height, 24), 0.1),
3085
+ tint,
3086
+ blur: this.effectDial(fx, D.blur, 0),
3087
+ frame: this.effectDial(fx, D.frame, 0.48),
3088
+ frameColor: colour(D.frameColor, 0.631, 0.631, 0.667),
3089
+ })
3090
+ if (!had) this.mirrorSurfaceAppeared()
3091
+ }
3092
+
3093
+ /** Built on the first setMirror rather than at init: a shader compile and a
3094
+ * pipeline cost load time, and most scenes never place one. */
3095
+ private ensureMirrorSurfaceResources(): void {
3096
+ if (this.mirrorSurfacePipeline) return
3097
+ this.mirrorSurfaceMatBuffer = this.device.createBuffer({
3098
+ label: "mirror surface material",
3099
+ size: MIRROR_MAT_BYTES,
3100
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3101
+ })
3102
+ this.mirrorSurfaceBindGroupLayout = this.device.createBindGroupLayout({
3103
+ label: "mirror surface layout",
3104
+ entries: [
3105
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
3106
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
3107
+ { binding: 2, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
3108
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
3109
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
3110
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
3111
+ ],
3112
+ })
3113
+ this.mirrorSurfacePipeline = this.createRenderPipeline({
3114
+ label: "mirror surface pipeline",
3115
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.mirrorSurfaceBindGroupLayout] }),
3116
+ shaderModule: this.device.createShaderModule({ label: "mirror surface", code: mirrorShaderWgsl() }),
3117
+ // None: the quad is generated from the vertex index.
3118
+ vertexBuffers: [],
3119
+ fragmentTargets: sceneTargetsFor("mirror", this.sceneFormats),
3120
+ // Both faces. The shader answers for the back — see its side test. Culling
3121
+ // it instead would make a mirror rotated the wrong way INVISIBLE, which is
3122
+ // the one failure a transform panel cannot show you.
3123
+ cullMode: "none",
3124
+ depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
3125
+ })
3126
+ this.buildMirrorSurfaceBindGroup()
3127
+
3128
+ // ── The pane in the shadow pass ──
3129
+ const shadowLayout = this.device.createBindGroupLayout({
3130
+ label: "mirror shadow layout",
3131
+ entries: [
3132
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
3133
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
3134
+ { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
3135
+ ],
3136
+ })
3137
+ this.mirrorShadowPipeline = this.device.createRenderPipeline({
3138
+ label: "mirror shadow pipeline",
3139
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [shadowLayout] }),
3140
+ vertex: {
3141
+ module: this.device.createShaderModule({
3142
+ label: "mirror shadow",
3143
+ code: mirrorShadowWgsl(SHADOW_CASCADES.length),
3144
+ }),
3145
+ entryPoint: "vs",
3146
+ },
3147
+ // Depth only: no fragment stage at all, the pane being opaque across its
3148
+ // whole rectangle with nothing to alpha-test.
3149
+ primitive: { cullMode: "none" },
3150
+ depthStencil: {
3151
+ format: Engine.SHADOW_DEPTH_FORMAT,
3152
+ depthWriteEnabled: true,
3153
+ depthCompare: "less-equal",
3154
+ // The shadow map's own non-reversed convention, and the same bias the
3155
+ // model shadows take — see shadowDepthPipeline.
3156
+ depthBias: 2,
3157
+ depthBiasSlopeScale: 1.5,
3158
+ depthBiasClamp: 0,
3159
+ },
3160
+ })
3161
+ this.mirrorShadowBindGroups = SHADOW_CASCADES.map((_, ci) => {
3162
+ const index = this.device.createBuffer({
3163
+ label: `mirror shadow cascade ${ci}`,
3164
+ size: 16,
3165
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3166
+ })
3167
+ this.device.queue.writeBuffer(index, 0, new Uint32Array([ci, 0, 0, 0]))
3168
+ return this.device.createBindGroup({
3169
+ label: `mirror shadow bind ${ci}`,
3170
+ layout: shadowLayout,
3171
+ entries: [
3172
+ { binding: 0, resource: { buffer: this.shadowLightVPBuffer } },
3173
+ { binding: 1, resource: { buffer: index } },
3174
+ { binding: 2, resource: { buffer: this.mirrorSurfaceMatBuffer! } },
3175
+ ],
3176
+ })
3177
+ })
3178
+ }
3179
+
3180
+ /** Rebound whenever the reflection target is recreated, alongside the
3181
+ * ground's — the two read the same texture. */
3182
+ private buildMirrorSurfaceBindGroup(): void {
3183
+ if (!this.mirrorSurfaceBindGroupLayout || !this.mirrorSurfaceMatBuffer) return
3184
+ if (!this.mirrorColorView || !this.mirrorMaskView) return
3185
+ this.mirrorSurfaceBindGroup = this.device.createBindGroup({
3186
+ label: "mirror surface bind",
3187
+ layout: this.mirrorSurfaceBindGroupLayout,
3188
+ entries: [
3189
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
3190
+ { binding: 1, resource: { buffer: this.mirrorVPBuffer } },
3191
+ { binding: 2, resource: { buffer: this.mirrorSurfaceMatBuffer } },
3192
+ { binding: 3, resource: this.mirrorColorView },
3193
+ { binding: 4, resource: this.materialSampler },
3194
+ { binding: 5, resource: this.mirrorMaskView },
3195
+ ],
3196
+ })
3197
+ }
3198
+
3199
+ /** The glass itself, in the camera pass. Never in the MIRROR pass: a mirror
3200
+ * drawn into its own reflection is a corridor, and with one target it is a
3201
+ * corridor of last frame's frame. */
3202
+ private renderMirrorSurface(pass: GPURenderPassEncoder): void {
3203
+ if (!this.mirrorSurface || !this.mirrorSurfacePipeline || !this.mirrorSurfaceBindGroup) return
3204
+ pass.setPipeline(this.mirrorSurfacePipeline)
3205
+ pass.setBindGroup(0, this.mirrorSurfaceBindGroup)
3206
+ pass.draw(6)
2786
3207
  }
2787
3208
 
2788
3209
  private ensureReflectionDebugPipeline(): boolean {
@@ -2836,7 +3257,24 @@ export class Engine {
2836
3257
  /** Refold the live camera with the reflection — a copy and a handful of
2837
3258
  * sign flips; cheap enough to run every frame a mirror is on. */
2838
3259
  private updateMirrorCamera(): void {
2839
- buildMirrorCamera(this.cameraMatrixData, Engine.REFLECTION_PLANE_Y, this.mirrorCameraData)
3260
+ // The placed surface wins over the floor — see the note on mirrorPlane.
3261
+ // Recomputed rather than cached because the mirror is the thing a gizmo
3262
+ // drags, and a plane that lagged the surface by a frame is a reflection
3263
+ // that slides on it.
3264
+ if (this.mirrorSurface) {
3265
+ const m = this.mirrorSurfaceModel
3266
+ planeFromPointNormal(m[12], m[13], m[14], m[8], m[9], m[10], this.mirrorPlane)
3267
+ } else {
3268
+ this.mirrorPlane.set(Engine.GROUND_PLANE)
3269
+ }
3270
+ // The ground inside the mirror pass clips against this same plane — a
3271
+ // mirror shows nothing behind itself, and the floor is what is behind one.
3272
+ if (this.groundClipMirrorBuffer) {
3273
+ this.groundClipData.set(this.mirrorPlane, 0)
3274
+ this.groundClipData[4] = 1
3275
+ this.device.queue.writeBuffer(this.groundClipMirrorBuffer, 0, this.groundClipData)
3276
+ }
3277
+ buildMirrorCamera(this.cameraMatrixData, this.mirrorPlane, this.mirrorCameraData)
2840
3278
  this.device.queue.writeBuffer(this.mirrorCameraBuffer, 0, this.mirrorCameraData)
2841
3279
  Mat4.multiplyArrays(this.cameraMatrixData, 16, this.mirrorCameraData, 0, this.mirrorVPData, 0)
2842
3280
  // projA/projB ARE m[10] and m[14] of the projection (the dofU discipline:
@@ -2862,19 +3300,22 @@ export class Engine {
2862
3300
  */
2863
3301
  private renderMirrorPass(encoder: GPUCommandEncoder): void {
2864
3302
  if (!this.reflectionActive || !this.mirrorPassDescriptor) return
2865
- if (!this.mirrorOpaqueBundle && !this.mirrorTransparentBundle) return
2866
- // A mirror reflects the sky, not the void: clear to the scene's background
2867
- // so the empty regions of the reflection read as backdrop instead of
2868
- // black. Linearised, because the mirror lives in scene-linear HDR and the
2869
- // stored colour is display sRGB — and honestly APPROXIMATE: the real
2870
- // backdrop composites after the view transform, so the mirrored patch
2871
- // rides through AgX/filmic and lands close, not identical. A transparent
2872
- // background keeps the black clear; a 360 equirect gets the flat colour —
2873
- // sampling the skybox along reflected rays is the recorded follow-up.
2874
- const bg = this.backgroundColor
2875
- const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))
2876
- const atts = this.mirrorPassDescriptor.colorAttachments as GPURenderPassColorAttachment[]
2877
- 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 }
3303
+ // No early return on an empty cast. The CLEAR is the load-bearing half of
3304
+ // this pass: a mirror surface samples the target whether or not anything
3305
+ // was drawn into it, and a pass skipped for having no models leaves it
3306
+ // holding whatever the allocation held — undefined memory, sampled onto a
3307
+ // plane in the middle of the scene.
3308
+ // CLEARS TRANSPARENT, and the alpha is the whole point: it is 1 exactly
3309
+ // where the pass drew something and 0 where it drew nothing, so every
3310
+ // consumer can composite the reflection over its own surface instead of
3311
+ // over a guess.
3312
+ //
3313
+ // It used to clear to the scene's background, linearised, so that empty
3314
+ // regions read as backdrop rather than black. That colour then rode the
3315
+ // view transform a SECOND time — the real backdrop composites after it —
3316
+ // and the result was a panel paler and flatter than the backdrop beside it.
3317
+ // A mirror that fogs. Coverage costs nothing and lets the genuine backdrop
3318
+ // through, which is also what an empty mirror shows: the room.
2878
3319
  // The descriptor is reused every frame, so the stamp is set on it rather
2879
3320
  // than passed — same as the scene pass, which is built once too.
2880
3321
  this.mirrorPassDescriptor.timestampWrites = this.stamps("mirror")
@@ -2883,12 +3324,25 @@ export class Engine {
2883
3324
  const bundles: GPURenderBundle[] = []
2884
3325
  if (this.mirrorOpaqueBundle) bundles.push(this.mirrorOpaqueBundle)
2885
3326
  if (this.mirrorTransparentBundle) bundles.push(this.mirrorTransparentBundle)
2886
- pass.executeBundles(bundles)
3327
+ if (bundles.length > 0) pass.executeBundles(bundles)
2887
3328
  // Particles and ribbons are scene geometry, and a mirror that dropped them
2888
3329
  // showed a dancer whose hand ribbon cast no reflection. Field effects stay
2889
3330
  // out BY DESIGN: they are display-space overlays composited after the view
2890
3331
  // transform, with no world position to mirror. executeBundles reset the
2891
3332
  // pass state, so these draws bind everything themselves — which they do.
3333
+ // THE FLOOR, inside the reflection.
3334
+ //
3335
+ // Without it a standing mirror shows a figure hanging in empty colour, with
3336
+ // nothing under her feet to say where she is — which is most of what made
3337
+ // the pane read as a dark sheet rather than as glass. It never came up for
3338
+ // the floor mirror because a floor does not reflect itself.
3339
+ //
3340
+ // Skipped while the FLOOR's own mirror is on: that would be the floor
3341
+ // sampling a reflection it is currently drawing into, one frame stale, and
3342
+ // the two mirrors are already fighting over the single reflection plane.
3343
+ if (this.hasGround && (this.mirrorSurface !== null || this.groundMirror === 0) && this.groundMirrorViewBindGroup) {
3344
+ this.renderGroundWith(pass, this.groundMirrorViewBindGroup, true)
3345
+ }
2892
3346
  this.renderParticles(pass, "mirror")
2893
3347
  this.drawTrails(pass, "mirror")
2894
3348
  pass.end()
@@ -2901,7 +3355,11 @@ export class Engine {
2901
3355
  * samples level 0 exactly and the chain would be work nobody reads.
2902
3356
  */
2903
3357
  private renderMirrorBlurChain(encoder: GPUCommandEncoder): void {
2904
- if (this.groundMirrorBlur <= 0 || this.mirrorMipCount < 2) return
3358
+ // EITHER consumer's dial. Gated on the ground's alone, a mirror surface with
3359
+ // blur up sampled mip levels that had never been rendered into — undefined
3360
+ // memory, which read as the model going black the moment the slider moved.
3361
+ const blur = Math.max(this.groundMirrorBlur, this.mirrorSurface?.blur ?? 0)
3362
+ if (blur <= 0 || this.mirrorMipCount < 2) return
2905
3363
  if (!this.mirrorBlurBindGroups) {
2906
3364
  const layout = this.bloomDownsamplePipeline.getBindGroupLayout(0)
2907
3365
  this.mirrorBlurBindGroups = []
@@ -2928,6 +3386,50 @@ export class Engine {
2928
3386
  p.draw(3)
2929
3387
  p.end()
2930
3388
  }
3389
+
3390
+ // COVERAGE, down the same chain. Colour in the reflection target is
3391
+ // premultiplied by this, so the pair has to be blurred together: blur the
3392
+ // colour alone and the pane divides a colour that spread into the empty sky
3393
+ // by a coverage that did not, and the reflection darkens as the dial rises.
3394
+ if (!this.mirrorMaskDownsamplePipeline) {
3395
+ const module = this.device.createShaderModule({
3396
+ label: "mirror coverage downsample",
3397
+ code: MIRROR_MASK_DOWNSAMPLE_WGSL,
3398
+ })
3399
+ this.mirrorMaskDownsamplePipeline = this.device.createRenderPipeline({
3400
+ label: "mirror coverage downsample pipeline",
3401
+ layout: "auto",
3402
+ vertex: { module, entryPoint: "vs" },
3403
+ fragment: { module, entryPoint: "fs", targets: [{ format: Engine.BLOOM_MASK_FORMAT }] },
3404
+ primitive: { topology: "triangle-list" },
3405
+ })
3406
+ }
3407
+ if (!this.mirrorMaskBlurBindGroups) {
3408
+ const layout = this.mirrorMaskDownsamplePipeline.getBindGroupLayout(0)
3409
+ this.mirrorMaskBlurBindGroups = []
3410
+ for (let i = 1; i < this.mirrorMipCount; i++) {
3411
+ this.mirrorMaskBlurBindGroups.push(
3412
+ this.device.createBindGroup({
3413
+ label: `mirror coverage blur ${i}`,
3414
+ layout,
3415
+ entries: [
3416
+ { binding: 0, resource: this.mirrorMaskMipViews[i - 1] },
3417
+ { binding: 1, resource: this.bloomSampler },
3418
+ ],
3419
+ }),
3420
+ )
3421
+ }
3422
+ }
3423
+ for (let i = 1; i < this.mirrorMipCount; i++) {
3424
+ const p = encoder.beginRenderPass({
3425
+ label: `mirror coverage blur ${i}`,
3426
+ colorAttachments: [{ view: this.mirrorMaskMipViews[i], loadOp: "clear", storeOp: "store" }],
3427
+ })
3428
+ p.setPipeline(this.mirrorMaskDownsamplePipeline)
3429
+ p.setBindGroup(0, this.mirrorMaskBlurBindGroups[i - 1])
3430
+ p.draw(3)
3431
+ p.end()
3432
+ }
2931
3433
  }
2932
3434
 
2933
3435
  /**
@@ -3692,13 +4194,17 @@ export class Engine {
3692
4194
  // lightEmit counts as a mount on its own: a pure lighting rig draws nothing
3693
4195
  // and is still an effect — it is how a scene gets stage lights without also
3694
4196
  // getting geometry it did not ask for.
3695
- if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl)) {
4197
+ // #mirror is a mount on the same footing, and for the same reason: a
4198
+ // reflection re-renders the scene from a folded camera, which is a pass and
4199
+ // not a shader, so the effect declares it and draws nothing itself.
4200
+ if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl) && !d.mirror) {
3696
4201
  return { ok: false, diagnostics: [
3697
4202
  "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
3698
4203
  "fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
3699
4204
  "the particle trio (particleInit/particleStep/particleShade), " +
3700
4205
  "the ribbon pair (trailWidth/trailShade), " +
3701
- "or fn lightEmit(i: u32) -> RzLight with #lights <n>",
4206
+ "fn lightEmit(i: u32) -> RzLight with #lights <n>, " +
4207
+ "or #mirror",
3702
4208
  ], mounts: noMounts, params: [], duration: 0 }
3703
4209
  }
3704
4210
  const mounts = { background: hasBackground, foreground: hasForeground }
@@ -4004,6 +4510,7 @@ export class Engine {
4004
4510
  paramLayout: layout,
4005
4511
  paramsBuffer,
4006
4512
  paramsData,
4513
+ hasMirror: d.mirror,
4007
4514
  hasBackground,
4008
4515
  hasForeground,
4009
4516
  // The author's OWN source, not the assembled module: the assembled one
@@ -6397,6 +6904,11 @@ export class Engine {
6397
6904
  // The baked frost tile — see bakeGroundNoise. Sampled with binding 10's
6398
6905
  // repeat sampler, so it brings no sampler of its own.
6399
6906
  { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6907
+ // The mirror pass's resolved aux. .g is the reflection's COVERAGE, and
6908
+ // the colour target cannot carry it — rg11b10ufloat has no alpha.
6909
+ { binding: 13, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6910
+ // The mirror's plane, and whether this draw is inside the mirror pass.
6911
+ { binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
6400
6912
  ],
6401
6913
  })
6402
6914
  this.groundShadowPipelineDesc = {
@@ -6447,12 +6959,35 @@ export class Engine {
6447
6959
  ],
6448
6960
  })
6449
6961
 
6962
+ // The same group on the MIRROR camera. An outline is geometry expanded
6963
+ // along its normals in CLIP space, so it has to be expanded through the
6964
+ // camera that is drawing it — handed the real camera it would trace a hull
6965
+ // around where she is standing, not around her reflection.
6966
+ this.outlineMirrorPerFrameBindGroup = this.device.createBindGroup({
6967
+ label: "outline per-frame bind group (mirror)",
6968
+ layout: this.outlinePerFrameBindGroupLayout,
6969
+ entries: [
6970
+ { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
6971
+ { binding: 1, resource: this.materialSampler },
6972
+ ],
6973
+ })
6974
+
6450
6975
  const outlineShaderModule = this.device.createShaderModule({
6451
6976
  label: "outline shaders",
6452
6977
  code: outlineShaderWgsl(),
6453
6978
  })
6454
6979
 
6455
- this.outlinePipeline = this.createRenderPipeline({
6980
+ // FRONT, not back, and not "none". The outline is an inverted hull: the
6981
+ // model expanded along its normals, drawn with only its BACK faces showing
6982
+ // so the silhouette survives and the interior does not ink over the model.
6983
+ // A reflection has determinant -1 (reflection.ts), so those very triangles
6984
+ // arrive front-facing in the mirror pass and `back` culls every one of them
6985
+ // — which is why the mirror had no outline at all. `none` would draw the
6986
+ // hull's front faces too, painting the model over with ink.
6987
+ // Annotated so the string fields keep their literal types: pulled out of the
6988
+ // call into a const, "back" widens to string and the two pipelines below
6989
+ // stop type-checking.
6990
+ const outlineDesc: Parameters<Engine["createRenderPipeline"]>[0] = {
6456
6991
  label: "outline pipeline",
6457
6992
  layout: outlinePipelineLayout,
6458
6993
  shaderModule: outlineShaderModule,
@@ -6489,6 +7024,12 @@ export class Engine {
6489
7024
  stencilReadMask: 0xff,
6490
7025
  stencilWriteMask: 0,
6491
7026
  },
7027
+ }
7028
+ this.outlinePipeline = this.createRenderPipeline(outlineDesc)
7029
+ this.outlineMirrorPipeline = this.createRenderPipeline({
7030
+ ...outlineDesc,
7031
+ label: "outline pipeline (mirror)",
7032
+ cullMode: "front",
6492
7033
  })
6493
7034
 
6494
7035
  // ─── Selection overlay (screen-space edge-detect on a per-material mask) ───
@@ -7092,12 +7633,25 @@ export class Engine {
7092
7633
  }
7093
7634
  this.mirrorBlurBindGroups = null
7094
7635
  this.mirrorMaskMsTexture = this.device.createTexture({
7095
- label: "mirror aux (msaa, discarded)",
7636
+ label: "mirror aux (msaa)",
7096
7637
  size: [mw, mh],
7097
7638
  sampleCount: Engine.MULTISAMPLE_COUNT,
7098
7639
  format: Engine.BLOOM_MASK_FORMAT,
7099
7640
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
7100
7641
  })
7642
+ this.mirrorMaskTexture = this.device.createTexture({
7643
+ label: "mirror aux resolve (coverage)",
7644
+ size: [mw, mh],
7645
+ mipLevelCount: this.mirrorMipCount,
7646
+ format: Engine.BLOOM_MASK_FORMAT,
7647
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
7648
+ })
7649
+ this.mirrorMaskView = this.mirrorMaskTexture.createView()
7650
+ this.mirrorMaskMipViews = []
7651
+ for (let i = 0; i < this.mirrorMipCount; i++) {
7652
+ this.mirrorMaskMipViews.push(this.mirrorMaskTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }))
7653
+ }
7654
+ this.mirrorMaskBlurBindGroups = null
7101
7655
  this.mirrorIdMsTexture = mrtIdsEnabled()
7102
7656
  ? this.device.createTexture({
7103
7657
  label: "mirror id (msaa, discarded)",
@@ -7126,6 +7680,10 @@ export class Engine {
7126
7680
  }
7127
7681
  const mirrorMask: GPURenderPassColorAttachment = {
7128
7682
  view: this.mirrorMaskMsTexture.createView(),
7683
+ // RESOLVED now rather than discarded: .g is the reflection's coverage.
7684
+ // Mip 0 of the chain, not the whole-chain view: a resolve target is a
7685
+ // single level.
7686
+ resolveTarget: this.mirrorMaskMipViews[0],
7129
7687
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
7130
7688
  loadOp: "clear",
7131
7689
  storeOp: "discard",
@@ -7153,8 +7711,10 @@ export class Engine {
7153
7711
  stencilStoreOp: "discard",
7154
7712
  },
7155
7713
  }
7156
- // The ground binds the reflection resolve; rebind it against the new one.
7714
+ // The ground and the mirror surface both bind the reflection resolve;
7715
+ // rebind both against the new one.
7157
7716
  this.buildGroundBindGroup()
7717
+ this.buildMirrorSurfaceBindGroup()
7158
7718
 
7159
7719
  // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
7160
7720
  // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
@@ -7703,11 +8263,6 @@ export class Engine {
7703
8263
  size: 32,
7704
8264
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7705
8265
  })
7706
- this.wireframeHoverUniformBuffer = this.device.createBuffer({
7707
- label: "wireframe color (hovered material)",
7708
- size: 32,
7709
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7710
- })
7711
8266
  const wireBg0 = this.device.createBindGroupLayout({
7712
8267
  label: "wireframe group 0 layout (camera + wire)",
7713
8268
  entries: [
@@ -7817,6 +8372,63 @@ export class Engine {
7817
8372
  multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
7818
8373
  })
7819
8374
 
8375
+ // The selection fill: vsDepth/fs again, no bias needed since it draws
8376
+ // ALWAYS-ahead like the rest of this layer's basic primitives (matching
8377
+ // the red edge lines selectMaterialFaces hands back — the two are meant
8378
+ // to read as one highlight, not fight each other's depth handling) —
8379
+ // never occluded, which a selection drawn over the far side of a body
8380
+ // the box also caught would otherwise need X-ray to explain.
8381
+ this.selectionFillUniformBuffer = this.device.createBuffer({
8382
+ label: "selection fill color",
8383
+ size: 32,
8384
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8385
+ })
8386
+ this.device.queue.writeBuffer(
8387
+ this.selectionFillUniformBuffer,
8388
+ 0,
8389
+ new Float32Array([1, 0.15, 0.15, 0.45, 0, 0, 0, 0]),
8390
+ )
8391
+ this.selectionFillBindGroup = this.device.createBindGroup({
8392
+ label: "selection fill bind group",
8393
+ layout: wireBg0,
8394
+ entries: [
8395
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
8396
+ { binding: 1, resource: { buffer: this.selectionFillUniformBuffer } },
8397
+ ],
8398
+ })
8399
+ this.selectionFillPipeline = this.device.createRenderPipeline({
8400
+ label: "selection fill pipeline",
8401
+ layout: this.device.createPipelineLayout({
8402
+ label: "selection fill pipeline layout",
8403
+ bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
8404
+ }),
8405
+ vertex: {
8406
+ module: wireShader,
8407
+ entryPoint: "vsDepth",
8408
+ buffers: [
8409
+ { arrayStride: 8 * 4, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat }] },
8410
+ { arrayStride: 4 * 2, attributes: [{ shaderLocation: 1, offset: 0, format: "uint16x4" as GPUVertexFormat }] },
8411
+ { arrayStride: 4, attributes: [{ shaderLocation: 2, offset: 0, format: "unorm8x4" as GPUVertexFormat }] },
8412
+ ],
8413
+ },
8414
+ fragment: {
8415
+ module: wireShader,
8416
+ entryPoint: "fs",
8417
+ targets: [
8418
+ {
8419
+ format: this.presentationFormat,
8420
+ blend: {
8421
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
8422
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
8423
+ },
8424
+ },
8425
+ ],
8426
+ },
8427
+ primitive: { topology: "triangle-list", cullMode: "none" },
8428
+ depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
8429
+ multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
8430
+ })
8431
+
7820
8432
  this.wireframeBindGroup = this.device.createBindGroup({
7821
8433
  label: "wireframe bind group",
7822
8434
  layout: wireBg0,
@@ -7833,14 +8445,6 @@ export class Engine {
7833
8445
  { binding: 1, resource: { buffer: this.wireframeSeamUniformBuffer } },
7834
8446
  ],
7835
8447
  })
7836
- this.wireframeHoverBindGroup = this.device.createBindGroup({
7837
- label: "wireframe bind group (hovered material)",
7838
- layout: wireBg0,
7839
- entries: [
7840
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
7841
- { binding: 1, resource: { buffer: this.wireframeHoverUniformBuffer } },
7842
- ],
7843
- })
7844
8448
 
7845
8449
  this.overlayPassDescriptor = {
7846
8450
  label: "overlay pass",
@@ -8379,12 +8983,19 @@ export class Engine {
8379
8983
  }
8380
8984
  }
8381
8985
 
8986
+
8382
8987
  getCameraDistance(): number {
8383
8988
  return this.camera.radius
8384
8989
  }
8385
8990
  setCameraDistance(d: number): void {
8386
8991
  this.camera.radius = d
8387
8992
  }
8993
+ /** Which button drags orbit the view — see Camera.setOrbitButton. Default
8994
+ * "left" (this engine's own, original binding, every existing consumer's
8995
+ * default); "middle" is Blender's, opt-in only. */
8996
+ setCameraOrbitButton(button: "left" | "middle"): void {
8997
+ this.camera?.setOrbitButton(button)
8998
+ }
8388
8999
  getCameraAlpha(): number {
8389
9000
  return this.camera.alpha
8390
9001
  }
@@ -9126,6 +9737,26 @@ export class Engine {
9126
9737
  * cleared rather than re-clearing it every frame. */
9127
9738
  private shadowCascadeCleared: boolean[] = []
9128
9739
 
9740
+ /**
9741
+ * Moves a model to a new key. Nothing about it is rebuilt — same GPU
9742
+ * buffers, same style groups, same physics — only which string the rest
9743
+ * of the engine finds it under changes.
9744
+ *
9745
+ * What a document reload uses to swap in a freshly-loaded replacement
9746
+ * under the ORIGINAL id with no visible gap: the replacement loads under
9747
+ * a throwaway key while the current one keeps rendering untouched, and
9748
+ * only this call — a Map move, nothing async, nothing rebuilt — hands it
9749
+ * the name everything else in the app still expects.
9750
+ */
9751
+ renameModel(oldName: string, newName: string): void {
9752
+ const inst = this.modelInstances.get(oldName)
9753
+ if (!inst || oldName === newName || this.modelInstances.has(newName)) return
9754
+ this.modelInstances.delete(oldName)
9755
+ inst.name = newName
9756
+ inst.model.setName(newName)
9757
+ this.modelInstances.set(newName, inst)
9758
+ }
9759
+
9129
9760
  removeModel(name: string): void {
9130
9761
  const inst = this.modelInstances.get(name)
9131
9762
  if (!inst) return
@@ -9586,6 +10217,219 @@ export class Engine {
9586
10217
  return best
9587
10218
  }
9588
10219
 
10220
+ /** Scratch for selectMaterialFaces — same shape as materialPickScratch,
10221
+ * its own buffer since a drag can end while a click is still in flight
10222
+ * from a different frame's hover. */
10223
+ private materialSelectScratch: Float32Array | null = null
10224
+ /** Opaque red — a selection is a working state, not a material property,
10225
+ * so it reads as unmistakably distinct from anything a model's own
10226
+ * colours could be. */
10227
+ private static readonly SELECTION_COLOR: RGBA = [1, 0.15, 0.15, 1]
10228
+
10229
+ /**
10230
+ * Every face of one material fully inside a screen-space rectangle, on the
10231
+ * POSED mesh — same reason pickMaterial reads it posed: a drag selects
10232
+ * what a costume actually looks like, not the bind pose underneath it.
10233
+ *
10234
+ * A face counts as "inside" only when all three corners project into the
10235
+ * rect. A triangle straddling the edge stays out — splitting a material by
10236
+ * a box is a spatial cut, and a half-caught triangle would tear a seam
10237
+ * that started nowhere the box actually touched.
10238
+ *
10239
+ * `faceIndices` are LOCAL to the material's own face list (0 = its first
10240
+ * triangle) — what a document transform like splitMaterial needs, since it
10241
+ * never has to know where in the shared index buffer this material's run
10242
+ * starts. `lines` is every caught face's own three edges, in POSED world
10243
+ * space and ready to hand straight to setOverlay — a selection reads as the
10244
+ * FACES it covers, not a scatter of points at their corners, and a second
10245
+ * query back through skinning just to draw it would double the cost of
10246
+ * every drag.
10247
+ *
10248
+ * x0/y0/x1/y1 are CSS pixels relative to the canvas, same origin
10249
+ * pickMaterial's x/y use. Order does not matter — a drag can go in any
10250
+ * direction.
10251
+ */
10252
+ selectMaterialFaces(
10253
+ modelName: string,
10254
+ materialName: string,
10255
+ x0: number,
10256
+ y0: number,
10257
+ x1: number,
10258
+ y1: number,
10259
+ ): { faceIndices: number[]; lines: OverlayPrimitive[] } {
10260
+ const empty = { faceIndices: [], lines: [] }
10261
+ const inst = this.modelInstances.get(modelName)
10262
+ if (!inst || !this.camera || inst.isStage || inst.isPlane || inst.isProp) return empty
10263
+ const width = this.canvas.clientWidth
10264
+ const height = this.canvas.clientHeight
10265
+ if (width <= 0 || height <= 0) return empty
10266
+ const left = Math.min(x0, x1)
10267
+ const right = Math.max(x0, x1)
10268
+ const top = Math.min(y0, y1)
10269
+ const bottom = Math.max(y0, y1)
10270
+
10271
+ const model = inst.model
10272
+ const materials = model.getMaterials()
10273
+ const mIndex = materials.findIndex((m) => m.name === materialName)
10274
+ if (mIndex < 0) return empty
10275
+
10276
+ const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values
10277
+ const { positions } = model.getGeometry()
10278
+ const count = positions.length / 3
10279
+ const { joints, weights } = model.getSkinning()
10280
+ const skin = model.getSkinMatrices()
10281
+
10282
+ // Skin once, same as pickMaterial: three slots per vertex, screen x/y and
10283
+ // clip w — cw doubles as "behind the camera" for the in-rect test below.
10284
+ if (!this.materialSelectScratch || this.materialSelectScratch.length !== count * 3) {
10285
+ this.materialSelectScratch = new Float32Array(count * 3)
10286
+ }
10287
+ const proj = this.materialSelectScratch
10288
+ const skinnedWorld = (v: number): [number, number, number] => {
10289
+ const bx = positions[v * 3]
10290
+ const by = positions[v * 3 + 1]
10291
+ const bz = positions[v * 3 + 2]
10292
+ let px = 0
10293
+ let py = 0
10294
+ let pz = 0
10295
+ for (let k = 0; k < 4; k++) {
10296
+ const w = weights[v * 4 + k] / 255
10297
+ if (w === 0) continue
10298
+ const m = joints[v * 4 + k] * 16
10299
+ px += w * (skin[m] * bx + skin[m + 4] * by + skin[m + 8] * bz + skin[m + 12])
10300
+ py += w * (skin[m + 1] * bx + skin[m + 5] * by + skin[m + 9] * bz + skin[m + 13])
10301
+ pz += w * (skin[m + 2] * bx + skin[m + 6] * by + skin[m + 10] * bz + skin[m + 14])
10302
+ }
10303
+ return [px, py, pz]
10304
+ }
10305
+ for (let v = 0; v < count; v++) {
10306
+ const [px, py, pz] = skinnedWorld(v)
10307
+ const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15]
10308
+ proj[v * 3 + 2] = cw
10309
+ if (cw <= 1e-6) continue
10310
+ const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12]
10311
+ const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13]
10312
+ proj[v * 3] = ((cx / cw) * 0.5 + 0.5) * width
10313
+ proj[v * 3 + 1] = (1 - ((cy / cw) * 0.5 + 0.5)) * height
10314
+ }
10315
+ const inRect = (v: number): boolean => {
10316
+ const cw = proj[v * 3 + 2]
10317
+ if (cw <= 1e-6) return false
10318
+ const sx = proj[v * 3]
10319
+ const sy = proj[v * 3 + 1]
10320
+ return sx >= left && sx <= right && sy >= top && sy <= bottom
10321
+ }
10322
+
10323
+ // Walk to this material's own run, the same running sum pickMaterial's
10324
+ // matEnd walk uses — materials own a CONTIGUOUS run of the index buffer.
10325
+ const indices = model.getIndices()
10326
+ let start = 0
10327
+ for (let i = 0; i < mIndex; i++) start += materials[i].vertexCount
10328
+ const end = start + materials[mIndex].vertexCount
10329
+
10330
+ const faceIndices: number[] = []
10331
+ const lines: OverlayPrimitive[] = []
10332
+ let faceIndex = 0
10333
+ for (let i = start; i < end; i += 3, faceIndex++) {
10334
+ const a = indices[i]
10335
+ const b = indices[i + 1]
10336
+ const c = indices[i + 2]
10337
+ if (!inRect(a) || !inRect(b) || !inRect(c)) continue
10338
+ faceIndices.push(faceIndex)
10339
+ const pa = skinnedWorld(a)
10340
+ const pb = skinnedWorld(b)
10341
+ const pc = skinnedWorld(c)
10342
+ // Every edge, including the ones shared with a neighbouring caught
10343
+ // face — drawn twice there, which costs nothing a triangle this size
10344
+ // notices and is simpler than tracking which edges are interior.
10345
+ for (const [p, q] of [
10346
+ [pa, pb],
10347
+ [pb, pc],
10348
+ [pc, pa],
10349
+ ] as const) {
10350
+ const line = lineBetween(p, q, Engine.SELECTION_COLOR)
10351
+ if (line) lines.push(line)
10352
+ }
10353
+ }
10354
+ return { faceIndices, lines }
10355
+ }
10356
+
10357
+ /**
10358
+ * Fill the given faces of one material solid red, live-skinned — unlike
10359
+ * the edge `lines` selectMaterialFaces hands back, which are a one-shot
10360
+ * snapshot at the moment of the drag, this reads the model's CURRENT skin
10361
+ * matrices every frame, so it keeps tracking the mesh through a pose
10362
+ * change the same way the wireframe overlay does.
10363
+ *
10364
+ * `faceIndices` are the same LOCAL (material-relative) numbers
10365
+ * selectMaterialFaces returns and splitMaterial takes. Pass an empty list
10366
+ * (or a null modelName/materialName) to clear it.
10367
+ */
10368
+ setSelectionFill(modelName: string | null, materialName: string | null, faceIndices: readonly number[]): void {
10369
+ if (this.selectionFill) {
10370
+ this.selectionFill.buffer.destroy()
10371
+ this.selectionFill = null
10372
+ }
10373
+ if (!modelName || !materialName || faceIndices.length === 0) return
10374
+ const inst = this.modelInstances.get(modelName)
10375
+ if (!inst) return
10376
+ const materials = inst.model.getMaterials()
10377
+ const mIndex = materials.findIndex((m) => m.name === materialName)
10378
+ if (mIndex < 0) return
10379
+ let start = 0
10380
+ for (let i = 0; i < mIndex; i++) start += materials[i].vertexCount
10381
+ const faceCount = materials[mIndex].vertexCount / 3
10382
+ const modelIndices = inst.model.getIndices()
10383
+
10384
+ const data = new Uint32Array(faceIndices.length * 3)
10385
+ let n = 0
10386
+ for (const f of faceIndices) {
10387
+ if (f < 0 || f >= faceCount) continue
10388
+ const i = start + f * 3
10389
+ data[n++] = modelIndices[i]
10390
+ data[n++] = modelIndices[i + 1]
10391
+ data[n++] = modelIndices[i + 2]
10392
+ }
10393
+ if (n === 0) return
10394
+
10395
+ const buffer = this.device.createBuffer({
10396
+ label: `selection fill ${modelName}/${materialName}`,
10397
+ size: n * 4,
10398
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
10399
+ })
10400
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, 0, n * 4)
10401
+ // wireframeSkinLayout's binding 4 is the wireframe's own edge list, which
10402
+ // vsDepth never reads — inst.vertexBuffer (already bound as storage
10403
+ // elsewhere, for that same edge-drawing use) satisfies the layout's slot
10404
+ // as a harmless stand-in rather than a fifth buffer built only to sit unused.
10405
+ const skinBindGroup = this.device.createBindGroup({
10406
+ label: `selection fill skin ${modelName}`,
10407
+ layout: this.wireframeSkinLayout,
10408
+ entries: [
10409
+ { binding: 0, resource: { buffer: inst.skinMatrixBuffer } },
10410
+ { binding: 1, resource: { buffer: inst.vertexBuffer } },
10411
+ { binding: 2, resource: { buffer: inst.jointsBuffer } },
10412
+ { binding: 3, resource: { buffer: inst.weightsBuffer } },
10413
+ { binding: 4, resource: { buffer: inst.vertexBuffer } },
10414
+ ],
10415
+ })
10416
+ this.selectionFill = { modelName, buffer, count: n, skinBindGroup }
10417
+ }
10418
+
10419
+ private renderSelectionFill(pass: GPURenderPassEncoder): void {
10420
+ if (!this.selectionFill) return
10421
+ const inst = this.modelInstances.get(this.selectionFill.modelName)
10422
+ if (!inst) return
10423
+ pass.setPipeline(this.selectionFillPipeline)
10424
+ pass.setBindGroup(0, this.selectionFillBindGroup)
10425
+ pass.setBindGroup(1, this.selectionFill.skinBindGroup)
10426
+ pass.setVertexBuffer(0, inst.vertexBuffer)
10427
+ pass.setVertexBuffer(1, inst.jointsBuffer)
10428
+ pass.setVertexBuffer(2, inst.weightsBuffer)
10429
+ pass.setIndexBuffer(this.selectionFill.buffer, "uint32")
10430
+ pass.drawIndexed(this.selectionFill.count)
10431
+ }
10432
+
9589
10433
  /** Draw an octahedron per bone of `modelName`, rebuilt each frame. Null off. */
9590
10434
  setBoneOverlay(modelName: string | null, options: BoneOverlayOptions = {}): void {
9591
10435
  this.overlayBones = modelName ? { modelName, options } : null
@@ -11228,10 +12072,17 @@ export class Engine {
11228
12072
  * compiles, so the two are built from one description and cannot drift. */
11229
12073
  private groundShadowPipelineDesc!: Omit<Parameters<Engine["createRenderPipeline"]>[0], "shaderModule">
11230
12074
 
11231
- private buildGroundPipeline(soft: boolean): GPURenderPipeline {
12075
+ private buildGroundPipeline(soft: boolean, mirrored = false): GPURenderPipeline {
11232
12076
  return this.createRenderPipeline({
11233
12077
  ...this.groundShadowPipelineDesc,
11234
- label: soft ? "ground shadow pipeline (soft)" : "ground shadow pipeline",
12078
+ // A REFLECTION FLIPS WINDING — determinant -1, see reflection.ts. The
12079
+ // ground culls back faces, so drawn into the mirror pass with the ordinary
12080
+ // pipeline every one of its triangles faces away and the floor vanishes
12081
+ // whole: no grid, and no received shadow either, since the shadow is a
12082
+ // layer of this shader. It is the same fact that sits the OUTLINE out of
12083
+ // the mirror, and the same answer the scene-pass pipelines already use.
12084
+ cullMode: mirrored ? "none" : this.groundShadowPipelineDesc.cullMode,
12085
+ label: `ground shadow pipeline${soft ? " (soft)" : ""}${mirrored ? " (mirror)" : ""}`,
11235
12086
  shaderModule: this.device.createShaderModule({
11236
12087
  label: soft ? "ground shadow (soft)" : "ground shadow",
11237
12088
  code: groundShaderWgsl(soft),
@@ -11239,6 +12090,20 @@ export class Engine {
11239
12090
  })
11240
12091
  }
11241
12092
 
12093
+ private groundMirrorPipeline: GPURenderPipeline | null = null
12094
+ private groundMirrorSoftPipeline: GPURenderPipeline | null = null
12095
+
12096
+ /** The ground's pipeline for the mirror pass, built on the first frame a
12097
+ * mirror actually needs it. */
12098
+ private ensureGroundMirrorPipeline(soft: boolean): GPURenderPipeline {
12099
+ if (soft) {
12100
+ if (!this.groundMirrorSoftPipeline) this.groundMirrorSoftPipeline = this.buildGroundPipeline(true, true)
12101
+ return this.groundMirrorSoftPipeline
12102
+ }
12103
+ if (!this.groundMirrorPipeline) this.groundMirrorPipeline = this.buildGroundPipeline(false, true)
12104
+ return this.groundMirrorPipeline
12105
+ }
12106
+
11242
12107
  /** Built on the first frame that actually needs it. A shader compile costs
11243
12108
  * load time, and the overwhelming majority of scenes never soften a shadow. */
11244
12109
  private ensureGroundSoftPipeline(): GPURenderPipeline {
@@ -11337,8 +12202,46 @@ export class Engine {
11337
12202
  * and a bind group holding the old view would sample a destroyed texture on
11338
12203
  * the first resized frame with a mirror on.
11339
12204
  */
12205
+ /** The stand-ins above, made once. 4x4 rather than 1x1: the depth one has to
12206
+ * be multisampled to match `texture_depth_multisampled_2d`, and a
12207
+ * multisampled attachment is the one kind of texture worth giving room. */
12208
+ private ensureMirrorDummies(): void {
12209
+ if (this.mirrorDummyColorView && this.mirrorDummyDepthView) return
12210
+ this.mirrorDummyColorView = this.device
12211
+ .createTexture({
12212
+ label: "mirror binding stand-in (colour)",
12213
+ size: [4, 4],
12214
+ format: this.hdrFormat,
12215
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
12216
+ })
12217
+ .createView()
12218
+ this.mirrorDummyDepthView = this.device
12219
+ .createTexture({
12220
+ label: "mirror binding stand-in (depth)",
12221
+ size: [4, 4],
12222
+ sampleCount: Engine.MULTISAMPLE_COUNT,
12223
+ format: this.depthFormat,
12224
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
12225
+ })
12226
+ .createView({ aspect: "depth-only" })
12227
+ }
12228
+
11340
12229
  private buildGroundBindGroup(): void {
11341
12230
  if (!this.groundShadowMaterialBuffer) return
12231
+ this.ensureMirrorDummies()
12232
+ if (!this.groundClipOffBuffer) {
12233
+ const make = (label: string, active: number) => {
12234
+ const b = this.device.createBuffer({
12235
+ label,
12236
+ size: 32,
12237
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
12238
+ })
12239
+ this.device.queue.writeBuffer(b, 0, new Float32Array([0, 1, 0, 0, active, 0, 0, 0]))
12240
+ return b
12241
+ }
12242
+ this.groundClipOffBuffer = make("ground clip (camera: off)", 0)
12243
+ this.groundClipMirrorBuffer = make("ground clip (mirror)", 1)
12244
+ }
11342
12245
  this.groundShadowBindGroup = this.device.createBindGroup({
11343
12246
  label: "ground shadow bind",
11344
12247
  layout: this.groundShadowBindGroupLayout,
@@ -11358,6 +12261,31 @@ export class Engine {
11358
12261
  { binding: 10, resource: this.materialSampler },
11359
12262
  { binding: 11, resource: this.mirrorDepthReadView! },
11360
12263
  { binding: 12, resource: this.groundNoiseView },
12264
+ { binding: 13, resource: this.mirrorMaskView! },
12265
+ { binding: 14, resource: { buffer: this.groundClipOffBuffer } },
12266
+ ],
12267
+ })
12268
+ this.groundMirrorViewBindGroup = this.device.createBindGroup({
12269
+ label: "ground shadow bind (mirror view)",
12270
+ layout: this.groundShadowBindGroupLayout,
12271
+ entries: [
12272
+ { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
12273
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
12274
+ { binding: 2, resource: this.shadowMapDepthViews[0] },
12275
+ { binding: 3, resource: this.shadowComparisonSampler },
12276
+ { binding: 4, resource: { buffer: this.groundShadowMaterialBuffer } },
12277
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
12278
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
12279
+ { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
12280
+ { binding: 8, resource: { buffer: this.mirrorVPBuffer } },
12281
+ // Stand-ins, not the live mirror textures: this group draws INTO the
12282
+ // mirror pass, where those are the attachments.
12283
+ { binding: 9, resource: this.mirrorDummyColorView! },
12284
+ { binding: 10, resource: this.materialSampler },
12285
+ { binding: 11, resource: this.mirrorDummyDepthView! },
12286
+ { binding: 12, resource: this.groundNoiseView },
12287
+ { binding: 13, resource: this.mirrorDummyColorView! },
12288
+ { binding: 14, resource: { buffer: this.groundClipMirrorBuffer } },
11361
12289
  ],
11362
12290
  })
11363
12291
  if (this.groundDrawCall) this.groundDrawCall.bindGroup = this.groundShadowBindGroup
@@ -11998,6 +12926,24 @@ export class Engine {
11998
12926
  this.device.queue.submit([encoder.finish()])
11999
12927
  }
12000
12928
 
12929
+ /** The ground draw, against a caller's bind group — the camera's, or the
12930
+ * mirror's. One body, so the reflected floor cannot drift from the real one. */
12931
+ private renderGroundWith(pass: GPURenderPassEncoder, bindGroup: GPUBindGroup, mirrored = false) {
12932
+ if (this.groundIsSuppressed()) return
12933
+ if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
12934
+ pass.setPipeline(
12935
+ mirrored
12936
+ ? this.ensureGroundMirrorPipeline(this.groundSoft)
12937
+ : this.groundSoft
12938
+ ? this.ensureGroundSoftPipeline()
12939
+ : this.groundShadowPipeline,
12940
+ )
12941
+ pass.setVertexBuffer(0, this.groundVertexBuffer)
12942
+ pass.setIndexBuffer(this.groundIndexBuffer, "uint16")
12943
+ pass.setBindGroup(0, bindGroup)
12944
+ pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0)
12945
+ }
12946
+
12001
12947
  private renderGround(pass: GPURenderPassEncoder) {
12002
12948
  // A stage brings its own floor. Both sit at y=0, so drawing the built-in
12003
12949
  // plane underneath produces z-fighting across the whole scene — enforced
@@ -12207,7 +13153,18 @@ export class Engine {
12207
13153
  private renderWireframe(pass: GPURenderPassEncoder): void {
12208
13154
  if (!this.overlayVertices) return
12209
13155
  const inst = this.overlayModel(this.overlayVertices.modelName)
12210
- const { material, xray } = this.overlayVertices
13156
+ const { xray } = this.overlayVertices
13157
+
13158
+ // A hovered material previews EXACTLY what clicking it would pick — the
13159
+ // same self-occluding, ISOLATED view, nothing else drawn. It substitutes
13160
+ // for `material` only while nothing is actually picked, so the section
13161
+ // opens on the whole mesh and narrows to one material the moment the
13162
+ // pointer (in the viewport OR the list — both just call
13163
+ // setHoveredMaterial) settles on it. A real pick always wins, and there
13164
+ // is nothing left to preview once one is made.
13165
+ const hm = this.hoverMaterial
13166
+ const material = this.overlayVertices.material ?? (hm && hm.modelName === inst?.name ? hm.materialName : null)
13167
+
12211
13168
  if (!inst || !this.ensureEdgeBuffer(inst, material)) return
12212
13169
  const edges = inst.wireEdges.get(material ?? "")
12213
13170
  if (!edges) return
@@ -12221,16 +13178,6 @@ export class Engine {
12221
13178
  ? (inst.wireEdges.get(Engine.SEAM_KEY) ?? null)
12222
13179
  : null
12223
13180
 
12224
- // A pointer over a material previews EXACTLY what clicking it would pick —
12225
- // the same self-occluding reveal, layered over the section-wide view rather
12226
- // than replacing it, so the rest of the mesh stays legible while one
12227
- // material calls attention to itself. Meaningless once something IS
12228
- // picked, since only the picked material draws at all then.
12229
- const hm = this.hoverMaterial
12230
- const hoverName = material === null && hm?.modelName === inst.name ? hm.materialName : null
12231
- const hoverRuns = hoverName ? this.materialIndexRanges(inst, hoverName) : []
12232
- const hover = hoverRuns.length > 0 && this.ensureEdgeBuffer(inst, hoverName) ? inst.wireEdges.get(hoverName!) : null
12233
-
12234
13181
  this.wireframeColorData.set(DEFAULT_VERTEX_COLOR)
12235
13182
  this.wireframeColorData[4] = this.canvas.width
12236
13183
  this.wireframeColorData[5] = this.canvas.height
@@ -12244,43 +13191,10 @@ export class Engine {
12244
13191
  this.wireframeColorData[6] = OVERLAY_STYLE.seamStrokePx
12245
13192
  this.device.queue.writeBuffer(this.wireframeSeamUniformBuffer, 0, this.wireframeColorData)
12246
13193
  }
12247
- if (hover) {
12248
- this.wireframeColorData[6] = OVERLAY_STYLE.hoverStrokePx
12249
- this.device.queue.writeBuffer(this.wireframeHoverUniformBuffer, 0, this.wireframeColorData)
12250
- }
12251
13194
 
12252
- // Both bind groups, before the FIRST draw call below, regardless of which
12253
- // branch runs first — the depth prepass reads the camera from group 0 and
12254
- // the skin matrices from group 1 same as the edge pass does, and every
12255
- // draw in this function needs both set to SOMETHING before it runs. Each
12256
- // block below is free to swap either one out for its own draws.
12257
13195
  pass.setBindGroup(0, this.wireframeBindGroup)
12258
13196
  pass.setBindGroup(1, edges.bindGroup)
12259
13197
 
12260
- const bindMesh = () => {
12261
- pass.setPipeline(this.wireframeDepthPipeline)
12262
- pass.setVertexBuffer(0, inst.vertexBuffer)
12263
- pass.setVertexBuffer(1, inst.jointsBuffer)
12264
- pass.setVertexBuffer(2, inst.weightsBuffer)
12265
- pass.setIndexBuffer(inst.indexBuffer, "uint32")
12266
- }
12267
-
12268
- // The hover preview writes and draws against its OWN depth first, while the
12269
- // shared depth buffer is still empty — the same trick a pick uses, run
12270
- // before the base mesh below gets a chance to occlude it. The base mesh's
12271
- // depth write further down repeats the SAME geometry for these faces
12272
- // (identical z), so it neither disturbs this nor needs to skip them.
12273
- if (hover && !xray) {
12274
- bindMesh()
12275
- for (const [start, end] of hoverRuns) pass.drawIndexed(end - start, 1, start)
12276
- pass.setBindGroup(0, this.wireframeHoverBindGroup)
12277
- pass.setBindGroup(1, hover.bindGroup)
12278
- pass.setPipeline(this.wireframePipeline)
12279
- pass.draw(6, hover.count / 2)
12280
- pass.setBindGroup(0, this.wireframeBindGroup)
12281
- pass.setBindGroup(1, edges.bindGroup)
12282
- }
12283
-
12284
13198
  // What writes depth is what is allowed to hide the wireframe, and that
12285
13199
  // differs between the two views.
12286
13200
  //
@@ -12289,14 +13203,18 @@ export class Engine {
12289
13203
  // mode and Maya included, and seeing both walls at once is moire rather than
12290
13204
  // information.
12291
13205
  //
12292
- // A PICKED material writes only its OWN faces. The question a pick asks is
13206
+ // A PICKED (or hovered) material writes only its OWN faces. The question is
12293
13207
  // where this material is, and half of it is usually under a coat; letting
12294
13208
  // the coat hide it does not answer that. Its own depth still goes in, so its
12295
13209
  // back faces stay hidden and it reads as an object instead of a haze —
12296
13210
  // which is the difference between this and turning x-ray on.
12297
13211
  const runs = material !== null ? this.materialIndexRanges(inst, material) : null
12298
13212
  if (!xray) {
12299
- bindMesh()
13213
+ pass.setPipeline(this.wireframeDepthPipeline)
13214
+ pass.setVertexBuffer(0, inst.vertexBuffer)
13215
+ pass.setVertexBuffer(1, inst.jointsBuffer)
13216
+ pass.setVertexBuffer(2, inst.weightsBuffer)
13217
+ pass.setIndexBuffer(inst.indexBuffer, "uint32")
12300
13218
  if (runs) for (const [start, end] of runs) pass.drawIndexed(end - start, 1, start)
12301
13219
  else pass.drawIndexed(inst.model.getIndices().length)
12302
13220
  }
@@ -12331,7 +13249,8 @@ export class Engine {
12331
13249
  this.overlayBones !== null ||
12332
13250
  this.overlayBodies !== null ||
12333
13251
  this.overlayJoints !== null ||
12334
- this.overlayVertices !== null
13252
+ this.overlayVertices !== null ||
13253
+ this.selectionFill !== null
12335
13254
  )
12336
13255
  }
12337
13256
 
@@ -12453,7 +13372,7 @@ export class Engine {
12453
13372
  const inst = this.overlayModel(this.overlayJoints.modelName)
12454
13373
  if (inst) collect(jointOverlay(inst.model, inst.physics, this.overlayJoints.options))
12455
13374
  }
12456
- if (total === 0 && !this.overlayVertices) return
13375
+ if (total === 0 && !this.overlayVertices && !this.selectionFill) return
12457
13376
 
12458
13377
  this.ensureOverlayInstanceCapacity(total)
12459
13378
  const data = this.overlayInstanceData
@@ -12487,6 +13406,10 @@ export class Engine {
12487
13406
  const pass = encoder.beginRenderPass(this.overlayPassDescriptor)
12488
13407
  // Under everything: the mesh is the haze the rig is read against.
12489
13408
  this.renderWireframe(pass)
13409
+ // The selection wash, under its own edge lines (drawn below with the rest
13410
+ // of this layer's basic primitives) so the crisp outline still reads on
13411
+ // top of the fill rather than being washed out by it.
13412
+ this.renderSelectionFill(pass)
12490
13413
  pass.setBindGroup(0, this.overlayBindGroup)
12491
13414
  pass.setVertexBuffer(0, this.overlayVertexBuffer)
12492
13415
  pass.setVertexBuffer(1, this.overlayInstanceBuffer!)
@@ -13218,6 +14141,8 @@ export class Engine {
13218
14141
 
13219
14142
  // Frustum cull into indirect arguments. After the camera and shadow matrices
13220
14143
  // are settled, before the passes that draw from them.
14144
+ // Before reflectionActive is read: this is what decides it.
14145
+ this.syncMirrorFromEffects()
13221
14146
  if (this.reflectionActive) this.updateMirrorCamera()
13222
14147
  if (hasModels) this.dispatchCull(encoder)
13223
14148
  // After the cull, which is what recomputes the spheres it unions.
@@ -13268,6 +14193,14 @@ export class Engine {
13268
14193
  // model re-record. It lives in the cull compute now, which zeroes the
13269
14194
  // instance count of an invisible model's draws.
13270
14195
  if (wanted && this.shadowBundles[ci]) sp.executeBundles([this.shadowBundles[ci]])
14196
+ // The mirror throws shade like anything else standing on the floor.
14197
+ // Direct rather than in the bundle: the bundles are recorded on scene
14198
+ // STRUCTURE, and a mirror comes and goes with an effect's weight.
14199
+ if (wanted && this.mirrorSurface && this.mirrorShadowPipeline) {
14200
+ sp.setPipeline(this.mirrorShadowPipeline)
14201
+ sp.setBindGroup(0, this.mirrorShadowBindGroups[ci])
14202
+ sp.draw(6)
14203
+ }
13271
14204
  sp.end()
13272
14205
  this.shadowCascadeCleared[ci] = !wanted
13273
14206
  }
@@ -13319,6 +14252,10 @@ export class Engine {
13319
14252
  // One redundant word against a whole class of invisible failure.
13320
14253
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
13321
14254
  if (this.hasGround) this.renderGround(pass)
14255
+ // After the ground for the same early-z reason, and before the transparent
14256
+ // phase so sheer fabric blends over the glass rather than being depth
14257
+ // -rejected behind it.
14258
+ this.renderMirrorSurface(pass)
13322
14259
  // The transparent phase is drawn DIRECTLY, and must stay that way. It is the
13323
14260
  // one part of this pass that is not bundled, so the reason is worth keeping.
13324
14261
  //
@@ -13543,13 +14480,21 @@ export class Engine {
13543
14480
  }
13544
14481
  }
13545
14482
 
13546
- // Tear down installs no longer present.
14483
+ // Installs no longer present lose their CLAIM now (the generation bump
14484
+ // invalidates any in-flight compile for that id) but keep their BUFFER
14485
+ // until every draw call has stopped pointing at it. This was the actual
14486
+ // bug: destroying here, synchronously, left the render loop — which runs
14487
+ // independently on its own rAF and does not know this function is
14488
+ // mid-await — still submitting frames through a bind group whose buffer
14489
+ // no longer existed, for however many frames compileAndInstallGroup's own
14490
+ // shader compiles below took. "used in submit while destroyed" was that
14491
+ // race, not a one-off.
13547
14492
  const nextIds = new Set(groups.map((g) => g.id))
14493
+ const outgoing: [string, GroupInstall][] = []
13548
14494
  for (const [id, install] of inst.styleGroups) {
13549
14495
  if (!nextIds.has(id)) {
13550
14496
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1)
13551
- this.destroyInstall(install)
13552
- inst.styleGroups.delete(id)
14497
+ outgoing.push([id, install])
13553
14498
  }
13554
14499
  }
13555
14500
 
@@ -13559,7 +14504,13 @@ export class Engine {
13559
14504
  groupResults.push({ groupId: g.id, diagnostics: r.diagnostics, ok: r.ok })
13560
14505
  }
13561
14506
 
14507
+ // Repoints every draw call away from the outgoing installs (among
14508
+ // everything else) — only past this line is destroying them safe.
13562
14509
  this.assignDrawCallGroups(inst, claimed)
14510
+ for (const [id, install] of outgoing) {
14511
+ this.destroyInstall(install)
14512
+ inst.styleGroups.delete(id)
14513
+ }
13563
14514
  return {
13564
14515
  ok: groupResults.every((r) => r.ok),
13565
14516
  groups: groupResults,
@@ -13852,12 +14803,17 @@ export class Engine {
13852
14803
  let pipeline: GPURenderPipeline
13853
14804
  let pipelineNoDepthWrite: GPURenderPipeline
13854
14805
  let overEyesPipeline: GPURenderPipeline | undefined
14806
+ let mirrorPipeline: GPURenderPipeline | undefined
13855
14807
  try {
13856
14808
  pipeline = await this.createRenderClassPipeline(renderClass, module, false)
13857
14809
  // The depth-write-off twin: stage transparency draws with it (see
13858
14810
  // pipelineForDrawCall), and a future OIT path would too.
13859
14811
  pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false)
13860
14812
  if (renderClass === "hair") overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true)
14813
+ // Only the eye needs one: every other class culls "none", which a flipped
14814
+ // winding leaves alone.
14815
+ if (renderClass === "eye")
14816
+ mirrorPipeline = await this.createRenderClassPipeline(renderClass, module, false, true, true)
13861
14817
  } catch (e) {
13862
14818
  diagnostics.push({ severity: "error", message: `pipeline creation failed: ${(e as Error).message}` })
13863
14819
  return { ok: false, diagnostics, slotMap: result.slotMap }
@@ -13887,6 +14843,7 @@ export class Engine {
13887
14843
  pipeline,
13888
14844
  pipelineNoDepthWrite,
13889
14845
  overEyesPipeline,
14846
+ mirrorPipeline,
13890
14847
  uniformBuffer,
13891
14848
  images: this.uploadGroupImages(group),
13892
14849
  slotMap: result.slotMap,
@@ -14003,12 +14960,21 @@ export class Engine {
14003
14960
  module: GPUShaderModule,
14004
14961
  overEyes: boolean,
14005
14962
  depthWrite = true,
14963
+ mirrored = false,
14006
14964
  ): Promise<GPURenderPipeline> {
14007
14965
  const base = {
14008
14966
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
14009
14967
  layout: this.mainPipelineLayout,
14010
14968
  vertex: { module, buffers: this.fullVertexBufferLayouts },
14011
- primitive: { cullMode: (renderClass === "eye" ? "front" : "none") as GPUCullMode },
14969
+ // The eye front-culls — the see-through-hair look is draw order plus that
14970
+ // cull plus the stencil stamp, and it is the deployed appearance of every
14971
+ // published scene. In the MIRROR the winding is flipped (determinant -1,
14972
+ // reflection.ts), so the same cull keeps the back of the eyeball and
14973
+ // discards its front. `mirrored` flips it back rather than changing what
14974
+ // the camera sees.
14975
+ primitive: {
14976
+ cullMode: (renderClass === "eye" ? (mirrored ? "back" : "front") : "none") as GPUCullMode,
14977
+ },
14012
14978
  multisample: { count: Engine.MULTISAMPLE_COUNT },
14013
14979
  }
14014
14980
  const plainDepth: GPUDepthStencilState = {
@@ -14071,11 +15037,14 @@ export class Engine {
14071
15037
  // rain vanished the instant the camera crossed a glass dome or a curtain
14072
15038
  // (reported: binary vanish/recover with camera angle, stage loaded). Stage
14073
15039
  // transparency blends and leaves depth alone.
14074
- private pipelineForDrawCall(inst: ModelInstance, dc: DrawCall): GPURenderPipeline {
15040
+ private pipelineForDrawCall(inst: ModelInstance, dc: DrawCall, mirrored = false): GPURenderPipeline {
14075
15041
  const stageGlass = inst.isStage && dc.type === "transparent"
14076
15042
  if (dc.groupId) {
14077
15043
  const install = inst.styleGroups.get(dc.groupId)
14078
- if (install) return stageGlass ? install.pipelineNoDepthWrite : install.pipeline
15044
+ if (install) {
15045
+ if (mirrored && install.mirrorPipeline) return install.mirrorPipeline
15046
+ return stageGlass ? install.pipelineNoDepthWrite : install.pipeline
15047
+ }
14079
15048
  }
14080
15049
  return stageGlass ? this.neutralPipelineNoDepthWrite : this.neutralPipeline
14081
15050
  }
@@ -14115,7 +15084,7 @@ export class Engine {
14115
15084
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
14116
15085
  bound = true
14117
15086
  }
14118
- const pipeline = this.pipelineForDrawCall(inst, draw)
15087
+ const pipeline = this.pipelineForDrawCall(inst, draw, view.args === "mirror")
14119
15088
  if (pipeline !== currentPipeline) {
14120
15089
  pass.setPipeline(pipeline)
14121
15090
  currentPipeline = pipeline
@@ -14126,8 +15095,9 @@ export class Engine {
14126
15095
  // Same index range; own pipeline + groups 0/2. Group 1 (skinMats) is
14127
15096
  // layout-identical between the main and outline pipelines and stays
14128
15097
  // bound. Restore group 0 afterwards and force a pipeline re-set.
14129
- pass.setPipeline(this.outlinePipeline)
14130
- pass.setBindGroup(0, this.outlinePerFrameBindGroup)
15098
+ const mirrored = view.args === "mirror"
15099
+ pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline)
15100
+ pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup)
14131
15101
  pass.setBindGroup(2, draw.outline.bindGroup)
14132
15102
  this.issueDraw(pass, draw, view.args)
14133
15103
  pass.setBindGroup(0, view.perFrame)
@@ -14167,10 +15137,13 @@ export class Engine {
14167
15137
  args: "camera" | "mirror"
14168
15138
  outlines: boolean
14169
15139
  } {
15140
+ // Outlines in BOTH now. They were off in the mirror because the hull culls
15141
+ // back faces and a reflection flips winding, so every hull triangle was
15142
+ // culled — a reflection of a toon character with no ink line beside the
15143
+ // real one reads as broken. The draw picks the flipped-cull pipeline and
15144
+ // the mirror camera's own group; see outlineMirrorPipeline.
14170
15145
  return kind === "mirror"
14171
- ? // No outlines in the mirror: the hull pipeline culls back faces, and a
14172
- // reflection flips winding, so the hull would ink over the model.
14173
- { perFrame: this.mirrorPerFrameBindGroup, args: "mirror", outlines: false }
15146
+ ? { perFrame: this.mirrorPerFrameBindGroup, args: "mirror", outlines: true }
14174
15147
  : { perFrame: this.perFrameBindGroup, args: "camera", outlines: true }
14175
15148
  }
14176
15149