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/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. */
@@ -898,6 +906,12 @@ interface GpuMorph {
898
906
  weightsData: Float32Array // staging copy uploaded when weights change
899
907
  workgroups: number
900
908
  dispatchNeeded: boolean
909
+ /** The compute pass's OWN base positions — what it recomputes FROM on its
910
+ * next dispatch, regardless of what a direct vertex-buffer write just put
911
+ * there. A permanent geometry edit under a morph-capable model (a bone
912
+ * scale's own vertex half) has to land here too, or the very next morph
913
+ * weight change quietly reverts it. */
914
+ baseBuf: GPUBuffer
901
915
  }
902
916
 
903
917
  // ── Sheer-material detection ──────────────────────────────────────────────────
@@ -1458,6 +1472,9 @@ interface EffectInstance {
1458
1472
  paramLayout: Map<string, { offset: number; comps: 1 | 3 }>
1459
1473
  paramsBuffer: GPUBuffer | null
1460
1474
  paramsData: Float32Array<ArrayBuffer>
1475
+ /** This effect IS a mirror — see the `#mirror` directive. Draws no shader;
1476
+ * the engine reads its dials and folds the scene through the plane. */
1477
+ hasMirror: boolean
1461
1478
  /** Mounted under the scene. */
1462
1479
  hasBackground: boolean
1463
1480
  /** Mounted over the finished frame — and the reason the scene pass has to
@@ -1610,6 +1627,9 @@ export class Engine {
1610
1627
  private groundSoft = false
1611
1628
  private groundShadowBindGroupLayout!: GPUBindGroupLayout
1612
1629
  private outlinePipeline!: GPURenderPipeline
1630
+ /** The same hull with its winding answered — see the pipeline's own note. */
1631
+ private outlineMirrorPipeline!: GPURenderPipeline
1632
+ private outlineMirrorPerFrameBindGroup!: GPUBindGroup
1613
1633
  private selectedMaterial: { modelName: string; materialName: string } | null = null
1614
1634
  private selectionMaskTexture?: GPUTexture
1615
1635
  private selectionMaskView?: GPUTextureView
@@ -1665,12 +1685,19 @@ export class Engine {
1665
1685
  * buffer twice would give both draws the second value. */
1666
1686
  private wireframeSeamUniformBuffer!: GPUBuffer
1667
1687
  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
1688
  private wireframeSkinLayout!: GPUBindGroupLayout
1673
1689
  private wireframeColorData = new Float32Array(8)
1690
+ // ─── Selection fill ────────────────────────────────────────────────
1691
+ // The box-select highlight, filled rather than just outlined — the same
1692
+ // depth-prepass vertex/fragment pair the wireframe's solid pass already
1693
+ // uses, over an index buffer built fresh per selection (see
1694
+ // setSelectionFill) rather than sliced from the model's own, since a
1695
+ // selection is an arbitrary, non-contiguous subset of one material's faces.
1696
+ private selectionFillPipeline!: GPURenderPipeline
1697
+ private selectionFillUniformBuffer!: GPUBuffer
1698
+ private selectionFillBindGroup!: GPUBindGroup
1699
+ private selectionFill: { modelName: string; buffer: GPUBuffer; count: number; skinBindGroup: GPUBindGroup } | null =
1700
+ null
1674
1701
  /** Rebuilt every frame into these, grouped by shape so each shape is one draw. */
1675
1702
  private overlayByShape = new Map<OverlayShape, OverlayPrimitive[]>()
1676
1703
  private overlayScratch: OverlayPrimitive[] = []
@@ -2084,11 +2111,17 @@ export class Engine {
2084
2111
  private cullCameraArgs: GPUBuffer | null = null
2085
2112
  private cullShadowArgs: GPUBuffer | null = null
2086
2113
  private cullMirrorArgs: GPUBuffer | null = null
2087
- // ── The floor mirror (step 7C) ──
2114
+ // ── The mirror (step 7C) ──
2088
2115
  // 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
2116
+ // mirror's own camera block.
2117
+ //
2118
+ // ONE reflection target, so ONE plane at a time. The floor's is (0, 1, 0, 0)
2119
+ // — MMD floors live at y = 0 by convention and addGround builds its quad
2120
+ // there — and a placed mirror surface REPLACES it for as long as it exists,
2121
+ // because two planes would need two of every attachment below. Which one is
2122
+ // in force is mirrorPlane, refreshed by updateMirrorCamera.
2123
+ private static readonly GROUND_PLANE: readonly number[] = [0, 1, 0, 0]
2124
+ private mirrorPlane = new Float32Array([0, 1, 0, 0])
2092
2125
  private mirrorCameraData = new Float32Array(40)
2093
2126
  private mirrorCameraBuffer!: GPUBuffer
2094
2127
  // proj x mirrorView for the ground's projective sample and the cull planes,
@@ -2105,6 +2138,24 @@ export class Engine {
2105
2138
  private mirrorBlurBindGroups: GPUBindGroup[] | null = null
2106
2139
  private groundMirrorBlur = 0
2107
2140
  private mirrorMaskMsTexture: GPUTexture | null = null
2141
+ /** The mirror pass's aux, RESOLVED and kept — `.g` is accumulated alpha, and
2142
+ * it is the only coverage the reflection has. The HDR format is
2143
+ * rg11b10ufloat wherever the device allows it, which has no alpha channel at
2144
+ * all, so a mirror that read `refl.a` read 1 everywhere and painted the
2145
+ * reflection target's empty black across the whole pane. */
2146
+ private mirrorMaskTexture: GPUTexture | null = null
2147
+ private mirrorMaskView: GPUTextureView | null = null
2148
+ /** Coverage carries the SAME mip chain as the colour — the two are one
2149
+ * premultiplied quantity and a blur has to move them together. */
2150
+ private mirrorMaskMipViews: GPUTextureView[] = []
2151
+ private mirrorMaskBlurBindGroups: GPUBindGroup[] | null = null
2152
+ private mirrorMaskDownsamplePipeline: GPURenderPipeline | null = null
2153
+ /** Two blocks the ground reads its clip from: the camera's is inert, the
2154
+ * mirror's carries the live plane. Which one a draw sees is decided by which
2155
+ * bind group it uses, so nothing has to know which pass is running. */
2156
+ private groundClipOffBuffer!: GPUBuffer
2157
+ private groundClipMirrorBuffer!: GPUBuffer
2158
+ private groundClipData = new Float32Array(8)
2108
2159
  private mirrorIdMsTexture: GPUTexture | null = null
2109
2160
  private mirrorDepthTexture: GPUTexture | null = null
2110
2161
  private mirrorDepthReadView: GPUTextureView | null = null
@@ -2113,12 +2164,30 @@ export class Engine {
2113
2164
  private mirrorTransparentBundle: GPURenderBundle | null = null
2114
2165
  /** setGroundMirror lands in step 7D; the debug dial is what exercises C. */
2115
2166
  private groundMirror = 0
2167
+ // ── The mirror SURFACE: the reflection as a plane you can put anywhere ──
2168
+ // Null when there is none, which is also what keeps the reflection pass off.
2169
+ // No vertex or index buffer: the quad is six generated vertices and the model
2170
+ // matrix is the whole of the difference between one mirror and another.
2171
+ private mirrorSurface: { tint: Vec3; blur: number } | null = null
2172
+ /** Position, rotation and the two extents, baked. Column 2 is the normal the
2173
+ * reflection plane is built from, so this is the single source for both the
2174
+ * draw and the fold — they cannot disagree about where the glass is. */
2175
+ private mirrorSurfaceModel = new Float32Array(16)
2176
+ private mirrorSurfaceMatData = new Float32Array(28)
2177
+ private mirrorSurfaceMatBuffer: GPUBuffer | null = null
2178
+ private mirrorSurfaceBindGroupLayout: GPUBindGroupLayout | null = null
2179
+ private mirrorSurfacePipeline: GPURenderPipeline | null = null
2180
+ private mirrorSurfaceBindGroup: GPUBindGroup | null = null
2181
+ /** The pane in the shadow pass — the frame throwing shade on the floor. One
2182
+ * bind group per cascade, each carrying its own index. */
2183
+ private mirrorShadowPipeline: GPURenderPipeline | null = null
2184
+ private mirrorShadowBindGroups: GPUBindGroup[] = []
2116
2185
  private reflectionDebug = false
2117
2186
  private reflectionDebugPipeline: GPURenderPipeline | null = null
2118
2187
  private reflectionDebugBindGroupLayout: GPUBindGroupLayout | null = null
2119
2188
  private reflectionDebugBindGroup: GPUBindGroup | null = null
2120
2189
  private get reflectionActive(): boolean {
2121
- return this.reflectionDebug || this.groundMirror > 0
2190
+ return this.reflectionDebug || this.groundMirror > 0 || this.mirrorSurface !== null
2122
2191
  }
2123
2192
  private cullFrustaBuffer: GPUBuffer | null = null
2124
2193
  // 18 planes (camera, shadow, mirror) x 16 bytes, then the counts vec4u.
@@ -2303,6 +2372,18 @@ export class Engine {
2303
2372
  // All cascades' view-projections, 16 floats each, inner to outer.
2304
2373
  private shadowLightVPMatrix = new Float32Array(16 * SHADOW_CASCADES.length)
2305
2374
  private groundShadowBindGroup?: GPUBindGroup
2375
+ /** The same ground, bound to the MIRROR camera — the floor inside the
2376
+ * reflection. Identical to the pair above but for binding 0, so the floor
2377
+ * shades from the mirrored eye, which is what a reflected floor is. */
2378
+ private groundMirrorViewBindGroup?: GPUBindGroup
2379
+ // Stand-ins for the three mirror textures the ground's layout demands, bound
2380
+ // ONLY by the group that draws the floor INSIDE the mirror pass. Those
2381
+ // textures are that pass's own attachments, and WebGPU rejects a pass that
2382
+ // both writes a texture and binds it — regardless of whether the shader
2383
+ // reaches the sample. `material.mirror` is 0 in that pass (the draw is
2384
+ // skipped when the floor's own mirror is on), so nothing ever reads these.
2385
+ private mirrorDummyColorView: GPUTextureView | null = null
2386
+ private mirrorDummyDepthView: GPUTextureView | null = null
2306
2387
  private shadowComparisonSampler!: GPUSampler
2307
2388
  private groundShadowMaterialBuffer?: GPUBuffer
2308
2389
  private groundDrawCall: DrawCall | null = null
@@ -2777,12 +2858,358 @@ export class Engine {
2777
2858
  if (!this.groundShadowMaterialBuffer) return false
2778
2859
  this.groundMirror = on ? 1 : 0
2779
2860
  if (blur !== undefined) this.groundMirrorBlur = Math.min(Math.max(blur, 0), 1)
2861
+ this.writeGroundMirrorField()
2862
+ return true
2863
+ }
2864
+
2865
+ /**
2866
+ * The floor's mirror field, as the shader should see it.
2867
+ *
2868
+ * A PLACED mirror takes the plane — there is one reflection target and one
2869
+ * fold, and a floor still trying to reflect through a wall's plane shows the
2870
+ * scene lying on its side. So the field is forced to 0 while a mirror surface
2871
+ * exists, without touching what the caller set: remove the mirror and the
2872
+ * floor's own setting comes back.
2873
+ *
2874
+ * It is also what lets the reflection carry the FLOOR. The mirror pass draws
2875
+ * the ground now, and a ground whose own mirror is on would be sampling the
2876
+ * very texture that pass is writing.
2877
+ */
2878
+ private writeGroundMirrorField(): void {
2879
+ if (!this.groundShadowMaterialBuffer) return
2880
+ const effective = this.mirrorSurface ? 0 : this.groundMirror
2780
2881
  this.device.queue.writeBuffer(
2781
2882
  this.groundShadowMaterialBuffer,
2782
2883
  15 * 4,
2783
- new Float32Array([this.groundMirror, this.groundMirrorBlur]),
2884
+ new Float32Array([effective, this.groundMirrorBlur]),
2784
2885
  )
2785
- return true
2886
+ }
2887
+
2888
+ /**
2889
+ * Place a mirror in the scene, or remove it with null.
2890
+ *
2891
+ * PRIVATE, and the only caller is syncMirrorFromEffects. A mirror reaches a
2892
+ * scene by applying the effect that declares `#mirror`, which is what gives
2893
+ * it dials, a schedule, a library row and a publish — a second public door
2894
+ * straight to the plane would be the same feature with none of that, and two
2895
+ * ways to place one mirror when there can only be one.
2896
+ *
2897
+ * A real planar reflection: the scene is drawn a second time from the camera
2898
+ * folded through this plane, so what the glass shows is the SCENE — lit by
2899
+ * the same sun, carrying the same shadows, with particle and ribbon effects
2900
+ * in it — rather than a copy of what the camera already sees.
2901
+ *
2902
+ * ONE at a time. The reflection pass owns a single set of attachments and a
2903
+ * single camera block, and a second mirror is a second of each; placing one
2904
+ * also takes the plane away from the floor mirror, which is the same target.
2905
+ *
2906
+ * `width` and `height` are metres of glass. `rotation` orients a quad whose
2907
+ * face is +Z at identity, so a mirror dropped in with no rotation faces the
2908
+ * way the default camera looks from — what someone means by putting one in
2909
+ * front of her.
2910
+ */
2911
+ private setMirror(
2912
+ options: {
2913
+ /** Centre of the glass, world space. */
2914
+ position?: XYZ
2915
+ /** Orientation of the quad. Identity faces +Z. */
2916
+ rotation?: { x: number; y: number; z: number; w: number }
2917
+ width?: number
2918
+ height?: number
2919
+ /** How a mirror falls short of perfect, as a colour rather than a fade.
2920
+ * Default (1, 1, 1) — every photon back. */
2921
+ tint?: XYZ
2922
+ /** Frosting, 0-1: 0 a polished mirror, 1 the softest level of the
2923
+ * reflection's own blur chain. */
2924
+ blur?: number
2925
+ /** Moulding width in world units, measured in from each edge. 0 is a
2926
+ * bare pane of glass. */
2927
+ frame?: number
2928
+ frameColor?: XYZ
2929
+ } | null,
2930
+ ): void {
2931
+ if (!this.device) return
2932
+ if (!options) {
2933
+ this.mirrorSurface = null
2934
+ return
2935
+ }
2936
+ // MMD UNITS, not metres. A character stands about 20 tall here — the
2937
+ // engine's own numbers say so: the camera targets y = 11, spawnOffsetX
2938
+ // stands the next cast member 9 aside, and the ground fades out at 80. A
2939
+ // mirror sized in metres is a postage stamp at her ankle.
2940
+ //
2941
+ // POSITION IS THE FOOT, not the centre: the quad runs 0..1 upward from it,
2942
+ // so shortening the mirror takes the height off the top and turning it
2943
+ // pivots on the floor. See the vertex shader.
2944
+ //
2945
+ // AND IT STANDS BEHIND HER, NOT IN FRONT. A mirror shows what is on its own
2946
+ // front side and nothing at all of what is behind it, so a plane dropped
2947
+ // between the camera and the cast is a WINDOW: the subject is on the far
2948
+ // side of the glass and what reflects is the empty room. The default camera
2949
+ // orbits to z = -31 (alpha = pi, distance 33), so the default here sits at
2950
+ // +z, off to one side and turned back toward both — which is the
2951
+ // arrangement every mirror shot uses, and the reason it is not centred is
2952
+ // that a mirror directly behind her reflects her into her own silhouette.
2953
+ const px = options.position?.x ?? -11
2954
+ const py = options.position?.y ?? 0
2955
+ const pz = options.position?.z ?? 2
2956
+ const r = options.rotation ?? { x: 0, y: 0, z: 0, w: 1 }
2957
+ const width = options.width ?? 14
2958
+ const height = options.height ?? 24
2959
+ const tint = options.tint ?? { x: 1, y: 1, z: 1 }
2960
+ const blur = Math.min(Math.max(options.blur ?? 0, 0), 1)
2961
+ const frame = options.frame ?? 0.48
2962
+ const frameColor = options.frameColor ?? { x: 0.631, y: 0.631, z: 0.667 }
2963
+
2964
+ // Rotation, then a NON-UNIFORM scale on the two in-plane axes — which is
2965
+ // why this is not fromPositionRotationScaleInto: a mirror is 2 x 3, not a
2966
+ // square someone scaled. Column 2 is left unit length; it is the normal.
2967
+ const m = this.mirrorSurfaceModel
2968
+ Mat4.fromQuatInto(r.x, r.y, r.z, r.w, m, 0)
2969
+ m[0] *= width; m[1] *= width; m[2] *= width
2970
+ m[4] *= height; m[5] *= height; m[6] *= height
2971
+ m[12] = px
2972
+ m[13] = py
2973
+ m[14] = pz
2974
+
2975
+ this.mirrorSurface = { tint: new Vec3(tint.x, tint.y, tint.z), blur }
2976
+ const d = this.mirrorSurfaceMatData
2977
+ d.set(m, 0)
2978
+ d[16] = tint.x
2979
+ d[17] = tint.y
2980
+ d[18] = tint.z
2981
+ d[19] = blur
2982
+ d[20] = frameColor.x
2983
+ d[21] = frameColor.y
2984
+ d[22] = frameColor.z
2985
+ // Clamped to under half the short side: a band wider than the glass leaves
2986
+ // no glass, and a mirror that is all moulding is a plank.
2987
+ d[23] = Math.min(Math.max(frame, 0), Math.min(width, height) * 0.45)
2988
+ d[24] = width
2989
+ d[25] = height
2990
+ this.ensureMirrorSurfaceResources()
2991
+ this.device.queue.writeBuffer(this.mirrorSurfaceMatBuffer!, 0, this.mirrorSurfaceMatData)
2992
+ }
2993
+
2994
+ /** Called by the sync once the surface exists, so the floor yields the plane
2995
+ * on the same frame the mirror appears. */
2996
+ private mirrorSurfaceAppeared(): void {
2997
+ this.writeGroundMirrorField()
2998
+ }
2999
+
3000
+ /**
3001
+ * The dials a `#mirror` effect is READ BY — the contract between an effect
3002
+ * file and the reflection.
3003
+ *
3004
+ * By name rather than by position, and with a fallback each, so that an
3005
+ * effect declaring only some of them still works: a mirror that wants to sit
3006
+ * at the origin facing the camera declares nothing but its size, and the ones
3007
+ * it leaves out are not missing, they are default.
3008
+ *
3009
+ * Names in the built-ins' own convention (SCREAMING_SNAKE), because these
3010
+ * appear in the params panel beside every other effect's.
3011
+ */
3012
+ private static readonly MIRROR_DIALS = {
3013
+ posX: "POS_X",
3014
+ posY: "POS_Y",
3015
+ posZ: "POS_Z",
3016
+ /** Degrees, the unit the slider shows — nothing converts on the way in. */
3017
+ rotX: "ROT_X",
3018
+ rotY: "ROT_Y",
3019
+ rotZ: "ROT_Z",
3020
+ width: "WIDTH",
3021
+ height: "HEIGHT",
3022
+ tint: "TINT",
3023
+ blur: "BLUR",
3024
+ frame: "FRAME",
3025
+ frameColor: "FRAME_COLOR",
3026
+ } as const
3027
+
3028
+ /** One dial's live value, or the fallback when the effect never declared it.
3029
+ * Reads the CPU mirror of the params buffer, which setEffectParam keeps
3030
+ * current — so dragging a slider moves the glass on the same frame. */
3031
+ private effectDial(fx: EffectInstance, name: string, fallback: number): number {
3032
+ const slot = fx.paramLayout.get(name)
3033
+ return slot ? fx.paramsData[slot.offset] : fallback
3034
+ }
3035
+
3036
+ /**
3037
+ * Drive the mirror from whichever effect declares one.
3038
+ *
3039
+ * Runs before reflectionActive is read, because the answer to "is there a
3040
+ * mirror" is this function's output. FIRST match wins and the rest are
3041
+ * ignored: there is one reflection target, so a second mirror is not a second
3042
+ * mirror, it is a fight over the same plane — and a scene that quietly
3043
+ * reflected the wrong one would be far harder to understand than one where
3044
+ * the second mirror does nothing.
3045
+ *
3046
+ * Gated on `weight`, so a scheduled mirror appears and leaves with its strip
3047
+ * and a faded-out one costs no reflection pass at all.
3048
+ */
3049
+ private syncMirrorFromEffects(): void {
3050
+ let fx: EffectInstance | null = null
3051
+ for (const e of this.effects) {
3052
+ if (e.hasMirror && e.weight > 0) {
3053
+ fx = e
3054
+ break
3055
+ }
3056
+ }
3057
+ if (!fx) {
3058
+ if (this.mirrorSurface) {
3059
+ this.mirrorSurface = null
3060
+ this.writeGroundMirrorField()
3061
+ }
3062
+ return
3063
+ }
3064
+ const had = this.mirrorSurface !== null
3065
+ const D = Engine.MIRROR_DIALS
3066
+ const deg = Math.PI / 180
3067
+ const rot = Quat.fromEuler(
3068
+ this.effectDial(fx, D.rotX, 0) * deg,
3069
+ this.effectDial(fx, D.rotY, 0) * deg,
3070
+ this.effectDial(fx, D.rotZ, 0) * deg,
3071
+ )
3072
+ const colour = (name: string, r: number, g: number, b: number) => {
3073
+ const slot = fx!.paramLayout.get(name)
3074
+ return slot
3075
+ ? { x: fx!.paramsData[slot.offset], y: fx!.paramsData[slot.offset + 1], z: fx!.paramsData[slot.offset + 2] }
3076
+ : { x: r, y: g, z: b }
3077
+ }
3078
+ const tint = colour(D.tint, 1, 1, 1)
3079
+ this.setMirror({
3080
+ position: {
3081
+ x: this.effectDial(fx, D.posX, -11),
3082
+ y: this.effectDial(fx, D.posY, 0),
3083
+ z: this.effectDial(fx, D.posZ, 2),
3084
+ },
3085
+ rotation: rot,
3086
+ // Floored well above zero: a mirror scaled to nothing is a degenerate
3087
+ // quad whose normal is still read for the plane, and the reflection would
3088
+ // fold through a plane nobody can see.
3089
+ width: Math.max(this.effectDial(fx, D.width, 14), 0.1),
3090
+ height: Math.max(this.effectDial(fx, D.height, 24), 0.1),
3091
+ tint,
3092
+ blur: this.effectDial(fx, D.blur, 0),
3093
+ frame: this.effectDial(fx, D.frame, 0.48),
3094
+ frameColor: colour(D.frameColor, 0.631, 0.631, 0.667),
3095
+ })
3096
+ if (!had) this.mirrorSurfaceAppeared()
3097
+ }
3098
+
3099
+ /** Built on the first setMirror rather than at init: a shader compile and a
3100
+ * pipeline cost load time, and most scenes never place one. */
3101
+ private ensureMirrorSurfaceResources(): void {
3102
+ if (this.mirrorSurfacePipeline) return
3103
+ this.mirrorSurfaceMatBuffer = this.device.createBuffer({
3104
+ label: "mirror surface material",
3105
+ size: MIRROR_MAT_BYTES,
3106
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3107
+ })
3108
+ this.mirrorSurfaceBindGroupLayout = this.device.createBindGroupLayout({
3109
+ label: "mirror surface layout",
3110
+ entries: [
3111
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
3112
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
3113
+ { binding: 2, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
3114
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
3115
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
3116
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
3117
+ ],
3118
+ })
3119
+ this.mirrorSurfacePipeline = this.createRenderPipeline({
3120
+ label: "mirror surface pipeline",
3121
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.mirrorSurfaceBindGroupLayout] }),
3122
+ shaderModule: this.device.createShaderModule({ label: "mirror surface", code: mirrorShaderWgsl() }),
3123
+ // None: the quad is generated from the vertex index.
3124
+ vertexBuffers: [],
3125
+ fragmentTargets: sceneTargetsFor("mirror", this.sceneFormats),
3126
+ // Both faces. The shader answers for the back — see its side test. Culling
3127
+ // it instead would make a mirror rotated the wrong way INVISIBLE, which is
3128
+ // the one failure a transform panel cannot show you.
3129
+ cullMode: "none",
3130
+ depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
3131
+ })
3132
+ this.buildMirrorSurfaceBindGroup()
3133
+
3134
+ // ── The pane in the shadow pass ──
3135
+ const shadowLayout = this.device.createBindGroupLayout({
3136
+ label: "mirror shadow layout",
3137
+ entries: [
3138
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
3139
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
3140
+ { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
3141
+ ],
3142
+ })
3143
+ this.mirrorShadowPipeline = this.device.createRenderPipeline({
3144
+ label: "mirror shadow pipeline",
3145
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [shadowLayout] }),
3146
+ vertex: {
3147
+ module: this.device.createShaderModule({
3148
+ label: "mirror shadow",
3149
+ code: mirrorShadowWgsl(SHADOW_CASCADES.length),
3150
+ }),
3151
+ entryPoint: "vs",
3152
+ },
3153
+ // Depth only: no fragment stage at all, the pane being opaque across its
3154
+ // whole rectangle with nothing to alpha-test.
3155
+ primitive: { cullMode: "none" },
3156
+ depthStencil: {
3157
+ format: Engine.SHADOW_DEPTH_FORMAT,
3158
+ depthWriteEnabled: true,
3159
+ depthCompare: "less-equal",
3160
+ // The shadow map's own non-reversed convention, and the same bias the
3161
+ // model shadows take — see shadowDepthPipeline.
3162
+ depthBias: 2,
3163
+ depthBiasSlopeScale: 1.5,
3164
+ depthBiasClamp: 0,
3165
+ },
3166
+ })
3167
+ this.mirrorShadowBindGroups = SHADOW_CASCADES.map((_, ci) => {
3168
+ const index = this.device.createBuffer({
3169
+ label: `mirror shadow cascade ${ci}`,
3170
+ size: 16,
3171
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3172
+ })
3173
+ this.device.queue.writeBuffer(index, 0, new Uint32Array([ci, 0, 0, 0]))
3174
+ return this.device.createBindGroup({
3175
+ label: `mirror shadow bind ${ci}`,
3176
+ layout: shadowLayout,
3177
+ entries: [
3178
+ { binding: 0, resource: { buffer: this.shadowLightVPBuffer } },
3179
+ { binding: 1, resource: { buffer: index } },
3180
+ { binding: 2, resource: { buffer: this.mirrorSurfaceMatBuffer! } },
3181
+ ],
3182
+ })
3183
+ })
3184
+ }
3185
+
3186
+ /** Rebound whenever the reflection target is recreated, alongside the
3187
+ * ground's — the two read the same texture. */
3188
+ private buildMirrorSurfaceBindGroup(): void {
3189
+ if (!this.mirrorSurfaceBindGroupLayout || !this.mirrorSurfaceMatBuffer) return
3190
+ if (!this.mirrorColorView || !this.mirrorMaskView) return
3191
+ this.mirrorSurfaceBindGroup = this.device.createBindGroup({
3192
+ label: "mirror surface bind",
3193
+ layout: this.mirrorSurfaceBindGroupLayout,
3194
+ entries: [
3195
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
3196
+ { binding: 1, resource: { buffer: this.mirrorVPBuffer } },
3197
+ { binding: 2, resource: { buffer: this.mirrorSurfaceMatBuffer } },
3198
+ { binding: 3, resource: this.mirrorColorView },
3199
+ { binding: 4, resource: this.materialSampler },
3200
+ { binding: 5, resource: this.mirrorMaskView },
3201
+ ],
3202
+ })
3203
+ }
3204
+
3205
+ /** The glass itself, in the camera pass. Never in the MIRROR pass: a mirror
3206
+ * drawn into its own reflection is a corridor, and with one target it is a
3207
+ * corridor of last frame's frame. */
3208
+ private renderMirrorSurface(pass: GPURenderPassEncoder): void {
3209
+ if (!this.mirrorSurface || !this.mirrorSurfacePipeline || !this.mirrorSurfaceBindGroup) return
3210
+ pass.setPipeline(this.mirrorSurfacePipeline)
3211
+ pass.setBindGroup(0, this.mirrorSurfaceBindGroup)
3212
+ pass.draw(6)
2786
3213
  }
2787
3214
 
2788
3215
  private ensureReflectionDebugPipeline(): boolean {
@@ -2836,7 +3263,24 @@ export class Engine {
2836
3263
  /** Refold the live camera with the reflection — a copy and a handful of
2837
3264
  * sign flips; cheap enough to run every frame a mirror is on. */
2838
3265
  private updateMirrorCamera(): void {
2839
- buildMirrorCamera(this.cameraMatrixData, Engine.REFLECTION_PLANE_Y, this.mirrorCameraData)
3266
+ // The placed surface wins over the floor — see the note on mirrorPlane.
3267
+ // Recomputed rather than cached because the mirror is the thing a gizmo
3268
+ // drags, and a plane that lagged the surface by a frame is a reflection
3269
+ // that slides on it.
3270
+ if (this.mirrorSurface) {
3271
+ const m = this.mirrorSurfaceModel
3272
+ planeFromPointNormal(m[12], m[13], m[14], m[8], m[9], m[10], this.mirrorPlane)
3273
+ } else {
3274
+ this.mirrorPlane.set(Engine.GROUND_PLANE)
3275
+ }
3276
+ // The ground inside the mirror pass clips against this same plane — a
3277
+ // mirror shows nothing behind itself, and the floor is what is behind one.
3278
+ if (this.groundClipMirrorBuffer) {
3279
+ this.groundClipData.set(this.mirrorPlane, 0)
3280
+ this.groundClipData[4] = 1
3281
+ this.device.queue.writeBuffer(this.groundClipMirrorBuffer, 0, this.groundClipData)
3282
+ }
3283
+ buildMirrorCamera(this.cameraMatrixData, this.mirrorPlane, this.mirrorCameraData)
2840
3284
  this.device.queue.writeBuffer(this.mirrorCameraBuffer, 0, this.mirrorCameraData)
2841
3285
  Mat4.multiplyArrays(this.cameraMatrixData, 16, this.mirrorCameraData, 0, this.mirrorVPData, 0)
2842
3286
  // projA/projB ARE m[10] and m[14] of the projection (the dofU discipline:
@@ -2862,19 +3306,22 @@ export class Engine {
2862
3306
  */
2863
3307
  private renderMirrorPass(encoder: GPUCommandEncoder): void {
2864
3308
  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 }
3309
+ // No early return on an empty cast. The CLEAR is the load-bearing half of
3310
+ // this pass: a mirror surface samples the target whether or not anything
3311
+ // was drawn into it, and a pass skipped for having no models leaves it
3312
+ // holding whatever the allocation held — undefined memory, sampled onto a
3313
+ // plane in the middle of the scene.
3314
+ // CLEARS TRANSPARENT, and the alpha is the whole point: it is 1 exactly
3315
+ // where the pass drew something and 0 where it drew nothing, so every
3316
+ // consumer can composite the reflection over its own surface instead of
3317
+ // over a guess.
3318
+ //
3319
+ // It used to clear to the scene's background, linearised, so that empty
3320
+ // regions read as backdrop rather than black. That colour then rode the
3321
+ // view transform a SECOND time — the real backdrop composites after it —
3322
+ // and the result was a panel paler and flatter than the backdrop beside it.
3323
+ // A mirror that fogs. Coverage costs nothing and lets the genuine backdrop
3324
+ // through, which is also what an empty mirror shows: the room.
2878
3325
  // The descriptor is reused every frame, so the stamp is set on it rather
2879
3326
  // than passed — same as the scene pass, which is built once too.
2880
3327
  this.mirrorPassDescriptor.timestampWrites = this.stamps("mirror")
@@ -2883,12 +3330,25 @@ export class Engine {
2883
3330
  const bundles: GPURenderBundle[] = []
2884
3331
  if (this.mirrorOpaqueBundle) bundles.push(this.mirrorOpaqueBundle)
2885
3332
  if (this.mirrorTransparentBundle) bundles.push(this.mirrorTransparentBundle)
2886
- pass.executeBundles(bundles)
3333
+ if (bundles.length > 0) pass.executeBundles(bundles)
2887
3334
  // Particles and ribbons are scene geometry, and a mirror that dropped them
2888
3335
  // showed a dancer whose hand ribbon cast no reflection. Field effects stay
2889
3336
  // out BY DESIGN: they are display-space overlays composited after the view
2890
3337
  // transform, with no world position to mirror. executeBundles reset the
2891
3338
  // pass state, so these draws bind everything themselves — which they do.
3339
+ // THE FLOOR, inside the reflection.
3340
+ //
3341
+ // Without it a standing mirror shows a figure hanging in empty colour, with
3342
+ // nothing under her feet to say where she is — which is most of what made
3343
+ // the pane read as a dark sheet rather than as glass. It never came up for
3344
+ // the floor mirror because a floor does not reflect itself.
3345
+ //
3346
+ // Skipped while the FLOOR's own mirror is on: that would be the floor
3347
+ // sampling a reflection it is currently drawing into, one frame stale, and
3348
+ // the two mirrors are already fighting over the single reflection plane.
3349
+ if (this.hasGround && (this.mirrorSurface !== null || this.groundMirror === 0) && this.groundMirrorViewBindGroup) {
3350
+ this.renderGroundWith(pass, this.groundMirrorViewBindGroup, true)
3351
+ }
2892
3352
  this.renderParticles(pass, "mirror")
2893
3353
  this.drawTrails(pass, "mirror")
2894
3354
  pass.end()
@@ -2901,7 +3361,11 @@ export class Engine {
2901
3361
  * samples level 0 exactly and the chain would be work nobody reads.
2902
3362
  */
2903
3363
  private renderMirrorBlurChain(encoder: GPUCommandEncoder): void {
2904
- if (this.groundMirrorBlur <= 0 || this.mirrorMipCount < 2) return
3364
+ // EITHER consumer's dial. Gated on the ground's alone, a mirror surface with
3365
+ // blur up sampled mip levels that had never been rendered into — undefined
3366
+ // memory, which read as the model going black the moment the slider moved.
3367
+ const blur = Math.max(this.groundMirrorBlur, this.mirrorSurface?.blur ?? 0)
3368
+ if (blur <= 0 || this.mirrorMipCount < 2) return
2905
3369
  if (!this.mirrorBlurBindGroups) {
2906
3370
  const layout = this.bloomDownsamplePipeline.getBindGroupLayout(0)
2907
3371
  this.mirrorBlurBindGroups = []
@@ -2928,6 +3392,50 @@ export class Engine {
2928
3392
  p.draw(3)
2929
3393
  p.end()
2930
3394
  }
3395
+
3396
+ // COVERAGE, down the same chain. Colour in the reflection target is
3397
+ // premultiplied by this, so the pair has to be blurred together: blur the
3398
+ // colour alone and the pane divides a colour that spread into the empty sky
3399
+ // by a coverage that did not, and the reflection darkens as the dial rises.
3400
+ if (!this.mirrorMaskDownsamplePipeline) {
3401
+ const module = this.device.createShaderModule({
3402
+ label: "mirror coverage downsample",
3403
+ code: MIRROR_MASK_DOWNSAMPLE_WGSL,
3404
+ })
3405
+ this.mirrorMaskDownsamplePipeline = this.device.createRenderPipeline({
3406
+ label: "mirror coverage downsample pipeline",
3407
+ layout: "auto",
3408
+ vertex: { module, entryPoint: "vs" },
3409
+ fragment: { module, entryPoint: "fs", targets: [{ format: Engine.BLOOM_MASK_FORMAT }] },
3410
+ primitive: { topology: "triangle-list" },
3411
+ })
3412
+ }
3413
+ if (!this.mirrorMaskBlurBindGroups) {
3414
+ const layout = this.mirrorMaskDownsamplePipeline.getBindGroupLayout(0)
3415
+ this.mirrorMaskBlurBindGroups = []
3416
+ for (let i = 1; i < this.mirrorMipCount; i++) {
3417
+ this.mirrorMaskBlurBindGroups.push(
3418
+ this.device.createBindGroup({
3419
+ label: `mirror coverage blur ${i}`,
3420
+ layout,
3421
+ entries: [
3422
+ { binding: 0, resource: this.mirrorMaskMipViews[i - 1] },
3423
+ { binding: 1, resource: this.bloomSampler },
3424
+ ],
3425
+ }),
3426
+ )
3427
+ }
3428
+ }
3429
+ for (let i = 1; i < this.mirrorMipCount; i++) {
3430
+ const p = encoder.beginRenderPass({
3431
+ label: `mirror coverage blur ${i}`,
3432
+ colorAttachments: [{ view: this.mirrorMaskMipViews[i], loadOp: "clear", storeOp: "store" }],
3433
+ })
3434
+ p.setPipeline(this.mirrorMaskDownsamplePipeline)
3435
+ p.setBindGroup(0, this.mirrorMaskBlurBindGroups[i - 1])
3436
+ p.draw(3)
3437
+ p.end()
3438
+ }
2931
3439
  }
2932
3440
 
2933
3441
  /**
@@ -3692,13 +4200,17 @@ export class Engine {
3692
4200
  // lightEmit counts as a mount on its own: a pure lighting rig draws nothing
3693
4201
  // and is still an effect — it is how a scene gets stage lights without also
3694
4202
  // getting geometry it did not ask for.
3695
- if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl)) {
4203
+ // #mirror is a mount on the same footing, and for the same reason: a
4204
+ // reflection re-renders the scene from a folded camera, which is a pass and
4205
+ // not a shader, so the effect declares it and draws nothing itself.
4206
+ if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl) && !d.mirror) {
3696
4207
  return { ok: false, diagnostics: [
3697
4208
  "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
3698
4209
  "fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
3699
4210
  "the particle trio (particleInit/particleStep/particleShade), " +
3700
4211
  "the ribbon pair (trailWidth/trailShade), " +
3701
- "or fn lightEmit(i: u32) -> RzLight with #lights <n>",
4212
+ "fn lightEmit(i: u32) -> RzLight with #lights <n>, " +
4213
+ "or #mirror",
3702
4214
  ], mounts: noMounts, params: [], duration: 0 }
3703
4215
  }
3704
4216
  const mounts = { background: hasBackground, foreground: hasForeground }
@@ -4004,6 +4516,7 @@ export class Engine {
4004
4516
  paramLayout: layout,
4005
4517
  paramsBuffer,
4006
4518
  paramsData,
4519
+ hasMirror: d.mirror,
4007
4520
  hasBackground,
4008
4521
  hasForeground,
4009
4522
  // The author's OWN source, not the assembled module: the assembled one
@@ -6397,6 +6910,11 @@ export class Engine {
6397
6910
  // The baked frost tile — see bakeGroundNoise. Sampled with binding 10's
6398
6911
  // repeat sampler, so it brings no sampler of its own.
6399
6912
  { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6913
+ // The mirror pass's resolved aux. .g is the reflection's COVERAGE, and
6914
+ // the colour target cannot carry it — rg11b10ufloat has no alpha.
6915
+ { binding: 13, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6916
+ // The mirror's plane, and whether this draw is inside the mirror pass.
6917
+ { binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
6400
6918
  ],
6401
6919
  })
6402
6920
  this.groundShadowPipelineDesc = {
@@ -6447,12 +6965,35 @@ export class Engine {
6447
6965
  ],
6448
6966
  })
6449
6967
 
6968
+ // The same group on the MIRROR camera. An outline is geometry expanded
6969
+ // along its normals in CLIP space, so it has to be expanded through the
6970
+ // camera that is drawing it — handed the real camera it would trace a hull
6971
+ // around where she is standing, not around her reflection.
6972
+ this.outlineMirrorPerFrameBindGroup = this.device.createBindGroup({
6973
+ label: "outline per-frame bind group (mirror)",
6974
+ layout: this.outlinePerFrameBindGroupLayout,
6975
+ entries: [
6976
+ { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
6977
+ { binding: 1, resource: this.materialSampler },
6978
+ ],
6979
+ })
6980
+
6450
6981
  const outlineShaderModule = this.device.createShaderModule({
6451
6982
  label: "outline shaders",
6452
6983
  code: outlineShaderWgsl(),
6453
6984
  })
6454
6985
 
6455
- this.outlinePipeline = this.createRenderPipeline({
6986
+ // FRONT, not back, and not "none". The outline is an inverted hull: the
6987
+ // model expanded along its normals, drawn with only its BACK faces showing
6988
+ // so the silhouette survives and the interior does not ink over the model.
6989
+ // A reflection has determinant -1 (reflection.ts), so those very triangles
6990
+ // arrive front-facing in the mirror pass and `back` culls every one of them
6991
+ // — which is why the mirror had no outline at all. `none` would draw the
6992
+ // hull's front faces too, painting the model over with ink.
6993
+ // Annotated so the string fields keep their literal types: pulled out of the
6994
+ // call into a const, "back" widens to string and the two pipelines below
6995
+ // stop type-checking.
6996
+ const outlineDesc: Parameters<Engine["createRenderPipeline"]>[0] = {
6456
6997
  label: "outline pipeline",
6457
6998
  layout: outlinePipelineLayout,
6458
6999
  shaderModule: outlineShaderModule,
@@ -6489,6 +7030,12 @@ export class Engine {
6489
7030
  stencilReadMask: 0xff,
6490
7031
  stencilWriteMask: 0,
6491
7032
  },
7033
+ }
7034
+ this.outlinePipeline = this.createRenderPipeline(outlineDesc)
7035
+ this.outlineMirrorPipeline = this.createRenderPipeline({
7036
+ ...outlineDesc,
7037
+ label: "outline pipeline (mirror)",
7038
+ cullMode: "front",
6492
7039
  })
6493
7040
 
6494
7041
  // ─── Selection overlay (screen-space edge-detect on a per-material mask) ───
@@ -7092,12 +7639,25 @@ export class Engine {
7092
7639
  }
7093
7640
  this.mirrorBlurBindGroups = null
7094
7641
  this.mirrorMaskMsTexture = this.device.createTexture({
7095
- label: "mirror aux (msaa, discarded)",
7642
+ label: "mirror aux (msaa)",
7096
7643
  size: [mw, mh],
7097
7644
  sampleCount: Engine.MULTISAMPLE_COUNT,
7098
7645
  format: Engine.BLOOM_MASK_FORMAT,
7099
7646
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
7100
7647
  })
7648
+ this.mirrorMaskTexture = this.device.createTexture({
7649
+ label: "mirror aux resolve (coverage)",
7650
+ size: [mw, mh],
7651
+ mipLevelCount: this.mirrorMipCount,
7652
+ format: Engine.BLOOM_MASK_FORMAT,
7653
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
7654
+ })
7655
+ this.mirrorMaskView = this.mirrorMaskTexture.createView()
7656
+ this.mirrorMaskMipViews = []
7657
+ for (let i = 0; i < this.mirrorMipCount; i++) {
7658
+ this.mirrorMaskMipViews.push(this.mirrorMaskTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }))
7659
+ }
7660
+ this.mirrorMaskBlurBindGroups = null
7101
7661
  this.mirrorIdMsTexture = mrtIdsEnabled()
7102
7662
  ? this.device.createTexture({
7103
7663
  label: "mirror id (msaa, discarded)",
@@ -7126,6 +7686,10 @@ export class Engine {
7126
7686
  }
7127
7687
  const mirrorMask: GPURenderPassColorAttachment = {
7128
7688
  view: this.mirrorMaskMsTexture.createView(),
7689
+ // RESOLVED now rather than discarded: .g is the reflection's coverage.
7690
+ // Mip 0 of the chain, not the whole-chain view: a resolve target is a
7691
+ // single level.
7692
+ resolveTarget: this.mirrorMaskMipViews[0],
7129
7693
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
7130
7694
  loadOp: "clear",
7131
7695
  storeOp: "discard",
@@ -7153,8 +7717,10 @@ export class Engine {
7153
7717
  stencilStoreOp: "discard",
7154
7718
  },
7155
7719
  }
7156
- // The ground binds the reflection resolve; rebind it against the new one.
7720
+ // The ground and the mirror surface both bind the reflection resolve;
7721
+ // rebind both against the new one.
7157
7722
  this.buildGroundBindGroup()
7723
+ this.buildMirrorSurfaceBindGroup()
7158
7724
 
7159
7725
  // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
7160
7726
  // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
@@ -7703,11 +8269,6 @@ export class Engine {
7703
8269
  size: 32,
7704
8270
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7705
8271
  })
7706
- this.wireframeHoverUniformBuffer = this.device.createBuffer({
7707
- label: "wireframe color (hovered material)",
7708
- size: 32,
7709
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7710
- })
7711
8272
  const wireBg0 = this.device.createBindGroupLayout({
7712
8273
  label: "wireframe group 0 layout (camera + wire)",
7713
8274
  entries: [
@@ -7817,6 +8378,63 @@ export class Engine {
7817
8378
  multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
7818
8379
  })
7819
8380
 
8381
+ // The selection fill: vsDepth/fs again, no bias needed since it draws
8382
+ // ALWAYS-ahead like the rest of this layer's basic primitives (matching
8383
+ // the red edge lines selectMaterialFaces hands back — the two are meant
8384
+ // to read as one highlight, not fight each other's depth handling) —
8385
+ // never occluded, which a selection drawn over the far side of a body
8386
+ // the box also caught would otherwise need X-ray to explain.
8387
+ this.selectionFillUniformBuffer = this.device.createBuffer({
8388
+ label: "selection fill color",
8389
+ size: 32,
8390
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8391
+ })
8392
+ this.device.queue.writeBuffer(
8393
+ this.selectionFillUniformBuffer,
8394
+ 0,
8395
+ new Float32Array([1, 0.15, 0.15, 0.45, 0, 0, 0, 0]),
8396
+ )
8397
+ this.selectionFillBindGroup = this.device.createBindGroup({
8398
+ label: "selection fill bind group",
8399
+ layout: wireBg0,
8400
+ entries: [
8401
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
8402
+ { binding: 1, resource: { buffer: this.selectionFillUniformBuffer } },
8403
+ ],
8404
+ })
8405
+ this.selectionFillPipeline = this.device.createRenderPipeline({
8406
+ label: "selection fill pipeline",
8407
+ layout: this.device.createPipelineLayout({
8408
+ label: "selection fill pipeline layout",
8409
+ bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
8410
+ }),
8411
+ vertex: {
8412
+ module: wireShader,
8413
+ entryPoint: "vsDepth",
8414
+ buffers: [
8415
+ { arrayStride: 8 * 4, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" as GPUVertexFormat }] },
8416
+ { arrayStride: 4 * 2, attributes: [{ shaderLocation: 1, offset: 0, format: "uint16x4" as GPUVertexFormat }] },
8417
+ { arrayStride: 4, attributes: [{ shaderLocation: 2, offset: 0, format: "unorm8x4" as GPUVertexFormat }] },
8418
+ ],
8419
+ },
8420
+ fragment: {
8421
+ module: wireShader,
8422
+ entryPoint: "fs",
8423
+ targets: [
8424
+ {
8425
+ format: this.presentationFormat,
8426
+ blend: {
8427
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
8428
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
8429
+ },
8430
+ },
8431
+ ],
8432
+ },
8433
+ primitive: { topology: "triangle-list", cullMode: "none" },
8434
+ depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
8435
+ multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
8436
+ })
8437
+
7820
8438
  this.wireframeBindGroup = this.device.createBindGroup({
7821
8439
  label: "wireframe bind group",
7822
8440
  layout: wireBg0,
@@ -7833,14 +8451,6 @@ export class Engine {
7833
8451
  { binding: 1, resource: { buffer: this.wireframeSeamUniformBuffer } },
7834
8452
  ],
7835
8453
  })
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
8454
 
7845
8455
  this.overlayPassDescriptor = {
7846
8456
  label: "overlay pass",
@@ -8379,12 +8989,19 @@ export class Engine {
8379
8989
  }
8380
8990
  }
8381
8991
 
8992
+
8382
8993
  getCameraDistance(): number {
8383
8994
  return this.camera.radius
8384
8995
  }
8385
8996
  setCameraDistance(d: number): void {
8386
8997
  this.camera.radius = d
8387
8998
  }
8999
+ /** Which button drags orbit the view — see Camera.setOrbitButton. Default
9000
+ * "left" (this engine's own, original binding, every existing consumer's
9001
+ * default); "middle" is Blender's, opt-in only. */
9002
+ setCameraOrbitButton(button: "left" | "middle"): void {
9003
+ this.camera?.setOrbitButton(button)
9004
+ }
8388
9005
  getCameraAlpha(): number {
8389
9006
  return this.camera.alpha
8390
9007
  }
@@ -9126,6 +9743,26 @@ export class Engine {
9126
9743
  * cleared rather than re-clearing it every frame. */
9127
9744
  private shadowCascadeCleared: boolean[] = []
9128
9745
 
9746
+ /**
9747
+ * Moves a model to a new key. Nothing about it is rebuilt — same GPU
9748
+ * buffers, same style groups, same physics — only which string the rest
9749
+ * of the engine finds it under changes.
9750
+ *
9751
+ * What a document reload uses to swap in a freshly-loaded replacement
9752
+ * under the ORIGINAL id with no visible gap: the replacement loads under
9753
+ * a throwaway key while the current one keeps rendering untouched, and
9754
+ * only this call — a Map move, nothing async, nothing rebuilt — hands it
9755
+ * the name everything else in the app still expects.
9756
+ */
9757
+ renameModel(oldName: string, newName: string): void {
9758
+ const inst = this.modelInstances.get(oldName)
9759
+ if (!inst || oldName === newName || this.modelInstances.has(newName)) return
9760
+ this.modelInstances.delete(oldName)
9761
+ inst.name = newName
9762
+ inst.model.setName(newName)
9763
+ this.modelInstances.set(newName, inst)
9764
+ }
9765
+
9129
9766
  removeModel(name: string): void {
9130
9767
  const inst = this.modelInstances.get(name)
9131
9768
  if (!inst) return
@@ -9586,6 +10223,289 @@ export class Engine {
9586
10223
  return best
9587
10224
  }
9588
10225
 
10226
+ /** Scratch for the small per-vertex writeBuffer calls setVertexPositions
10227
+ * and setBoneBindPositions make — one float32x3, reused so a live drag
10228
+ * calling either hundreds of times a frame does not also allocate hundreds
10229
+ * of throwaway arrays. */
10230
+ private vec3Scratch = new Float32Array(3)
10231
+
10232
+ /**
10233
+ * Overwrites BASE (pre-morph) vertex positions — a PERMANENT geometry edit
10234
+ * (a bone scale's own vertex half, say), not a transient morph: nothing
10235
+ * here is undone by a weight going back to 0.
10236
+ *
10237
+ * Three places have to agree, or the very next frame — or the next morph
10238
+ * weight change — quietly reverts this: the model's own CPU copy
10239
+ * (Model.setVertexPositions), the live render buffer, and, for a model
10240
+ * whose morphs run on the GPU, the compute pass's OWN base-positions
10241
+ * buffer, which it recomputes from on its next dispatch regardless of
10242
+ * whatever a direct vertex-buffer write just put there.
10243
+ */
10244
+ setVertexPositions(
10245
+ modelName: string,
10246
+ updates: readonly { index: number; position: readonly [number, number, number] }[],
10247
+ ): void {
10248
+ const inst = this.modelInstances.get(modelName)
10249
+ if (!inst || updates.length === 0) return
10250
+ inst.model.setVertexPositions(updates)
10251
+ const scratch = this.vec3Scratch
10252
+ for (const { index, position } of updates) {
10253
+ scratch[0] = position[0]
10254
+ scratch[1] = position[1]
10255
+ scratch[2] = position[2]
10256
+ this.device.queue.writeBuffer(inst.vertexBuffer, index * 32, scratch)
10257
+ if (inst.gpuMorph) this.device.queue.writeBuffer(inst.gpuMorph.baseBuf, index * 12, scratch)
10258
+ }
10259
+ }
10260
+
10261
+ /**
10262
+ * Moves bones to new WORLD bind (rest) positions — a permanent rig edit
10263
+ * (a bone scale's own bone half), never a pose. Patches bindTranslation
10264
+ * (relative to the bone's CURRENT parent world position, read fresh off
10265
+ * its own inverseBindMatrix — pass parents before children in one call if
10266
+ * a bone's OWN local offset needs to stay exactly consistent, though nothing
10267
+ * downstream of this actually reads bindTranslation live: skinning, the
10268
+ * bone overlay and picking all read inverseBindMatrices directly, which
10269
+ * this always writes correctly regardless of order) and the cached
10270
+ * inverse-bind matrix directly, so every one of those picks the new
10271
+ * position up on the very next frame with nothing else to recompute.
10272
+ */
10273
+ setBoneBindPositions(
10274
+ modelName: string,
10275
+ updates: readonly { index: number; position: readonly [number, number, number] }[],
10276
+ ): void {
10277
+ const inst = this.modelInstances.get(modelName)
10278
+ if (!inst || updates.length === 0) return
10279
+ const skeleton = inst.model.getSkeleton()
10280
+ const invBind = skeleton.inverseBindMatrices
10281
+ for (const { index, position } of updates) {
10282
+ const bone = skeleton.bones[index]
10283
+ if (!bone) continue
10284
+ const parent = bone.parentIndex >= 0 ? bone.parentIndex : -1
10285
+ const parentPos: [number, number, number] =
10286
+ parent >= 0
10287
+ ? [-invBind[parent * 16 + 12], -invBind[parent * 16 + 13], -invBind[parent * 16 + 14]]
10288
+ : [0, 0, 0]
10289
+ bone.bindTranslation = [position[0] - parentPos[0], position[1] - parentPos[1], position[2] - parentPos[2]]
10290
+ invBind[index * 16 + 12] = -position[0]
10291
+ invBind[index * 16 + 13] = -position[1]
10292
+ invBind[index * 16 + 14] = -position[2]
10293
+ }
10294
+ }
10295
+
10296
+ /** Scratch for selectMaterialFaces — same shape as materialPickScratch,
10297
+ * its own buffer since a drag can end while a click is still in flight
10298
+ * from a different frame's hover. */
10299
+ private materialSelectScratch: Float32Array | null = null
10300
+ /** Opaque red — a selection is a working state, not a material property,
10301
+ * so it reads as unmistakably distinct from anything a model's own
10302
+ * colours could be. */
10303
+ private static readonly SELECTION_COLOR: RGBA = [1, 0.15, 0.15, 1]
10304
+
10305
+ /**
10306
+ * Every face of one material fully inside a screen-space rectangle, on the
10307
+ * POSED mesh — same reason pickMaterial reads it posed: a drag selects
10308
+ * what a costume actually looks like, not the bind pose underneath it.
10309
+ *
10310
+ * A face counts as "inside" only when all three corners project into the
10311
+ * rect. A triangle straddling the edge stays out — splitting a material by
10312
+ * a box is a spatial cut, and a half-caught triangle would tear a seam
10313
+ * that started nowhere the box actually touched.
10314
+ *
10315
+ * `faceIndices` are LOCAL to the material's own face list (0 = its first
10316
+ * triangle) — what a document transform like splitMaterial needs, since it
10317
+ * never has to know where in the shared index buffer this material's run
10318
+ * starts. `lines` is every caught face's own three edges, in POSED world
10319
+ * space and ready to hand straight to setOverlay — a selection reads as the
10320
+ * FACES it covers, not a scatter of points at their corners, and a second
10321
+ * query back through skinning just to draw it would double the cost of
10322
+ * every drag.
10323
+ *
10324
+ * x0/y0/x1/y1 are CSS pixels relative to the canvas, same origin
10325
+ * pickMaterial's x/y use. Order does not matter — a drag can go in any
10326
+ * direction.
10327
+ */
10328
+ selectMaterialFaces(
10329
+ modelName: string,
10330
+ materialName: string,
10331
+ x0: number,
10332
+ y0: number,
10333
+ x1: number,
10334
+ y1: number,
10335
+ ): { faceIndices: number[]; lines: OverlayPrimitive[] } {
10336
+ const empty = { faceIndices: [], lines: [] }
10337
+ const inst = this.modelInstances.get(modelName)
10338
+ if (!inst || !this.camera || inst.isStage || inst.isPlane || inst.isProp) return empty
10339
+ const width = this.canvas.clientWidth
10340
+ const height = this.canvas.clientHeight
10341
+ if (width <= 0 || height <= 0) return empty
10342
+ const left = Math.min(x0, x1)
10343
+ const right = Math.max(x0, x1)
10344
+ const top = Math.min(y0, y1)
10345
+ const bottom = Math.max(y0, y1)
10346
+
10347
+ const model = inst.model
10348
+ const materials = model.getMaterials()
10349
+ const mIndex = materials.findIndex((m) => m.name === materialName)
10350
+ if (mIndex < 0) return empty
10351
+
10352
+ const vp = this.camera.getProjectionMatrix().multiply(this.camera.getViewMatrix()).values
10353
+ const { positions } = model.getGeometry()
10354
+ const count = positions.length / 3
10355
+ const { joints, weights } = model.getSkinning()
10356
+ const skin = model.getSkinMatrices()
10357
+
10358
+ // Skin once, same as pickMaterial: three slots per vertex, screen x/y and
10359
+ // clip w — cw doubles as "behind the camera" for the in-rect test below.
10360
+ if (!this.materialSelectScratch || this.materialSelectScratch.length !== count * 3) {
10361
+ this.materialSelectScratch = new Float32Array(count * 3)
10362
+ }
10363
+ const proj = this.materialSelectScratch
10364
+ const skinnedWorld = (v: number): [number, number, number] => {
10365
+ const bx = positions[v * 3]
10366
+ const by = positions[v * 3 + 1]
10367
+ const bz = positions[v * 3 + 2]
10368
+ let px = 0
10369
+ let py = 0
10370
+ let pz = 0
10371
+ for (let k = 0; k < 4; k++) {
10372
+ const w = weights[v * 4 + k] / 255
10373
+ if (w === 0) continue
10374
+ const m = joints[v * 4 + k] * 16
10375
+ px += w * (skin[m] * bx + skin[m + 4] * by + skin[m + 8] * bz + skin[m + 12])
10376
+ py += w * (skin[m + 1] * bx + skin[m + 5] * by + skin[m + 9] * bz + skin[m + 13])
10377
+ pz += w * (skin[m + 2] * bx + skin[m + 6] * by + skin[m + 10] * bz + skin[m + 14])
10378
+ }
10379
+ return [px, py, pz]
10380
+ }
10381
+ for (let v = 0; v < count; v++) {
10382
+ const [px, py, pz] = skinnedWorld(v)
10383
+ const cw = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15]
10384
+ proj[v * 3 + 2] = cw
10385
+ if (cw <= 1e-6) continue
10386
+ const cx = vp[0] * px + vp[4] * py + vp[8] * pz + vp[12]
10387
+ const cy = vp[1] * px + vp[5] * py + vp[9] * pz + vp[13]
10388
+ proj[v * 3] = ((cx / cw) * 0.5 + 0.5) * width
10389
+ proj[v * 3 + 1] = (1 - ((cy / cw) * 0.5 + 0.5)) * height
10390
+ }
10391
+ const inRect = (v: number): boolean => {
10392
+ const cw = proj[v * 3 + 2]
10393
+ if (cw <= 1e-6) return false
10394
+ const sx = proj[v * 3]
10395
+ const sy = proj[v * 3 + 1]
10396
+ return sx >= left && sx <= right && sy >= top && sy <= bottom
10397
+ }
10398
+
10399
+ // Walk to this material's own run, the same running sum pickMaterial's
10400
+ // matEnd walk uses — materials own a CONTIGUOUS run of the index buffer.
10401
+ const indices = model.getIndices()
10402
+ let start = 0
10403
+ for (let i = 0; i < mIndex; i++) start += materials[i].vertexCount
10404
+ const end = start + materials[mIndex].vertexCount
10405
+
10406
+ const faceIndices: number[] = []
10407
+ const lines: OverlayPrimitive[] = []
10408
+ let faceIndex = 0
10409
+ for (let i = start; i < end; i += 3, faceIndex++) {
10410
+ const a = indices[i]
10411
+ const b = indices[i + 1]
10412
+ const c = indices[i + 2]
10413
+ if (!inRect(a) || !inRect(b) || !inRect(c)) continue
10414
+ faceIndices.push(faceIndex)
10415
+ const pa = skinnedWorld(a)
10416
+ const pb = skinnedWorld(b)
10417
+ const pc = skinnedWorld(c)
10418
+ // Every edge, including the ones shared with a neighbouring caught
10419
+ // face — drawn twice there, which costs nothing a triangle this size
10420
+ // notices and is simpler than tracking which edges are interior.
10421
+ for (const [p, q] of [
10422
+ [pa, pb],
10423
+ [pb, pc],
10424
+ [pc, pa],
10425
+ ] as const) {
10426
+ const line = lineBetween(p, q, Engine.SELECTION_COLOR)
10427
+ if (line) lines.push(line)
10428
+ }
10429
+ }
10430
+ return { faceIndices, lines }
10431
+ }
10432
+
10433
+ /**
10434
+ * Fill the given faces of one material solid red, live-skinned — unlike
10435
+ * the edge `lines` selectMaterialFaces hands back, which are a one-shot
10436
+ * snapshot at the moment of the drag, this reads the model's CURRENT skin
10437
+ * matrices every frame, so it keeps tracking the mesh through a pose
10438
+ * change the same way the wireframe overlay does.
10439
+ *
10440
+ * `faceIndices` are the same LOCAL (material-relative) numbers
10441
+ * selectMaterialFaces returns and splitMaterial takes. Pass an empty list
10442
+ * (or a null modelName/materialName) to clear it.
10443
+ */
10444
+ setSelectionFill(modelName: string | null, materialName: string | null, faceIndices: readonly number[]): void {
10445
+ if (this.selectionFill) {
10446
+ this.selectionFill.buffer.destroy()
10447
+ this.selectionFill = null
10448
+ }
10449
+ if (!modelName || !materialName || faceIndices.length === 0) return
10450
+ const inst = this.modelInstances.get(modelName)
10451
+ if (!inst) return
10452
+ const materials = inst.model.getMaterials()
10453
+ const mIndex = materials.findIndex((m) => m.name === materialName)
10454
+ if (mIndex < 0) return
10455
+ let start = 0
10456
+ for (let i = 0; i < mIndex; i++) start += materials[i].vertexCount
10457
+ const faceCount = materials[mIndex].vertexCount / 3
10458
+ const modelIndices = inst.model.getIndices()
10459
+
10460
+ const data = new Uint32Array(faceIndices.length * 3)
10461
+ let n = 0
10462
+ for (const f of faceIndices) {
10463
+ if (f < 0 || f >= faceCount) continue
10464
+ const i = start + f * 3
10465
+ data[n++] = modelIndices[i]
10466
+ data[n++] = modelIndices[i + 1]
10467
+ data[n++] = modelIndices[i + 2]
10468
+ }
10469
+ if (n === 0) return
10470
+
10471
+ const buffer = this.device.createBuffer({
10472
+ label: `selection fill ${modelName}/${materialName}`,
10473
+ size: n * 4,
10474
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
10475
+ })
10476
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, 0, n * 4)
10477
+ // wireframeSkinLayout's binding 4 is the wireframe's own edge list, which
10478
+ // vsDepth never reads — inst.vertexBuffer (already bound as storage
10479
+ // elsewhere, for that same edge-drawing use) satisfies the layout's slot
10480
+ // as a harmless stand-in rather than a fifth buffer built only to sit unused.
10481
+ const skinBindGroup = this.device.createBindGroup({
10482
+ label: `selection fill skin ${modelName}`,
10483
+ layout: this.wireframeSkinLayout,
10484
+ entries: [
10485
+ { binding: 0, resource: { buffer: inst.skinMatrixBuffer } },
10486
+ { binding: 1, resource: { buffer: inst.vertexBuffer } },
10487
+ { binding: 2, resource: { buffer: inst.jointsBuffer } },
10488
+ { binding: 3, resource: { buffer: inst.weightsBuffer } },
10489
+ { binding: 4, resource: { buffer: inst.vertexBuffer } },
10490
+ ],
10491
+ })
10492
+ this.selectionFill = { modelName, buffer, count: n, skinBindGroup }
10493
+ }
10494
+
10495
+ private renderSelectionFill(pass: GPURenderPassEncoder): void {
10496
+ if (!this.selectionFill) return
10497
+ const inst = this.modelInstances.get(this.selectionFill.modelName)
10498
+ if (!inst) return
10499
+ pass.setPipeline(this.selectionFillPipeline)
10500
+ pass.setBindGroup(0, this.selectionFillBindGroup)
10501
+ pass.setBindGroup(1, this.selectionFill.skinBindGroup)
10502
+ pass.setVertexBuffer(0, inst.vertexBuffer)
10503
+ pass.setVertexBuffer(1, inst.jointsBuffer)
10504
+ pass.setVertexBuffer(2, inst.weightsBuffer)
10505
+ pass.setIndexBuffer(this.selectionFill.buffer, "uint32")
10506
+ pass.drawIndexed(this.selectionFill.count)
10507
+ }
10508
+
9589
10509
  /** Draw an octahedron per bone of `modelName`, rebuilt each frame. Null off. */
9590
10510
  setBoneOverlay(modelName: string | null, options: BoneOverlayOptions = {}): void {
9591
10511
  this.overlayBones = modelName ? { modelName, options } : null
@@ -11149,6 +12069,7 @@ export class Engine {
11149
12069
  weightsData: new Float32Array(data.morphCount),
11150
12070
  workgroups: Math.ceil(data.vertexCount / 64),
11151
12071
  dispatchNeeded: false, // vertex buffer already holds base; dispatch on first weight change
12072
+ baseBuf,
11152
12073
  }
11153
12074
  }
11154
12075
 
@@ -11228,10 +12149,17 @@ export class Engine {
11228
12149
  * compiles, so the two are built from one description and cannot drift. */
11229
12150
  private groundShadowPipelineDesc!: Omit<Parameters<Engine["createRenderPipeline"]>[0], "shaderModule">
11230
12151
 
11231
- private buildGroundPipeline(soft: boolean): GPURenderPipeline {
12152
+ private buildGroundPipeline(soft: boolean, mirrored = false): GPURenderPipeline {
11232
12153
  return this.createRenderPipeline({
11233
12154
  ...this.groundShadowPipelineDesc,
11234
- label: soft ? "ground shadow pipeline (soft)" : "ground shadow pipeline",
12155
+ // A REFLECTION FLIPS WINDING — determinant -1, see reflection.ts. The
12156
+ // ground culls back faces, so drawn into the mirror pass with the ordinary
12157
+ // pipeline every one of its triangles faces away and the floor vanishes
12158
+ // whole: no grid, and no received shadow either, since the shadow is a
12159
+ // layer of this shader. It is the same fact that sits the OUTLINE out of
12160
+ // the mirror, and the same answer the scene-pass pipelines already use.
12161
+ cullMode: mirrored ? "none" : this.groundShadowPipelineDesc.cullMode,
12162
+ label: `ground shadow pipeline${soft ? " (soft)" : ""}${mirrored ? " (mirror)" : ""}`,
11235
12163
  shaderModule: this.device.createShaderModule({
11236
12164
  label: soft ? "ground shadow (soft)" : "ground shadow",
11237
12165
  code: groundShaderWgsl(soft),
@@ -11239,6 +12167,20 @@ export class Engine {
11239
12167
  })
11240
12168
  }
11241
12169
 
12170
+ private groundMirrorPipeline: GPURenderPipeline | null = null
12171
+ private groundMirrorSoftPipeline: GPURenderPipeline | null = null
12172
+
12173
+ /** The ground's pipeline for the mirror pass, built on the first frame a
12174
+ * mirror actually needs it. */
12175
+ private ensureGroundMirrorPipeline(soft: boolean): GPURenderPipeline {
12176
+ if (soft) {
12177
+ if (!this.groundMirrorSoftPipeline) this.groundMirrorSoftPipeline = this.buildGroundPipeline(true, true)
12178
+ return this.groundMirrorSoftPipeline
12179
+ }
12180
+ if (!this.groundMirrorPipeline) this.groundMirrorPipeline = this.buildGroundPipeline(false, true)
12181
+ return this.groundMirrorPipeline
12182
+ }
12183
+
11242
12184
  /** Built on the first frame that actually needs it. A shader compile costs
11243
12185
  * load time, and the overwhelming majority of scenes never soften a shadow. */
11244
12186
  private ensureGroundSoftPipeline(): GPURenderPipeline {
@@ -11337,8 +12279,46 @@ export class Engine {
11337
12279
  * and a bind group holding the old view would sample a destroyed texture on
11338
12280
  * the first resized frame with a mirror on.
11339
12281
  */
12282
+ /** The stand-ins above, made once. 4x4 rather than 1x1: the depth one has to
12283
+ * be multisampled to match `texture_depth_multisampled_2d`, and a
12284
+ * multisampled attachment is the one kind of texture worth giving room. */
12285
+ private ensureMirrorDummies(): void {
12286
+ if (this.mirrorDummyColorView && this.mirrorDummyDepthView) return
12287
+ this.mirrorDummyColorView = this.device
12288
+ .createTexture({
12289
+ label: "mirror binding stand-in (colour)",
12290
+ size: [4, 4],
12291
+ format: this.hdrFormat,
12292
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
12293
+ })
12294
+ .createView()
12295
+ this.mirrorDummyDepthView = this.device
12296
+ .createTexture({
12297
+ label: "mirror binding stand-in (depth)",
12298
+ size: [4, 4],
12299
+ sampleCount: Engine.MULTISAMPLE_COUNT,
12300
+ format: this.depthFormat,
12301
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
12302
+ })
12303
+ .createView({ aspect: "depth-only" })
12304
+ }
12305
+
11340
12306
  private buildGroundBindGroup(): void {
11341
12307
  if (!this.groundShadowMaterialBuffer) return
12308
+ this.ensureMirrorDummies()
12309
+ if (!this.groundClipOffBuffer) {
12310
+ const make = (label: string, active: number) => {
12311
+ const b = this.device.createBuffer({
12312
+ label,
12313
+ size: 32,
12314
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
12315
+ })
12316
+ this.device.queue.writeBuffer(b, 0, new Float32Array([0, 1, 0, 0, active, 0, 0, 0]))
12317
+ return b
12318
+ }
12319
+ this.groundClipOffBuffer = make("ground clip (camera: off)", 0)
12320
+ this.groundClipMirrorBuffer = make("ground clip (mirror)", 1)
12321
+ }
11342
12322
  this.groundShadowBindGroup = this.device.createBindGroup({
11343
12323
  label: "ground shadow bind",
11344
12324
  layout: this.groundShadowBindGroupLayout,
@@ -11358,6 +12338,31 @@ export class Engine {
11358
12338
  { binding: 10, resource: this.materialSampler },
11359
12339
  { binding: 11, resource: this.mirrorDepthReadView! },
11360
12340
  { binding: 12, resource: this.groundNoiseView },
12341
+ { binding: 13, resource: this.mirrorMaskView! },
12342
+ { binding: 14, resource: { buffer: this.groundClipOffBuffer } },
12343
+ ],
12344
+ })
12345
+ this.groundMirrorViewBindGroup = this.device.createBindGroup({
12346
+ label: "ground shadow bind (mirror view)",
12347
+ layout: this.groundShadowBindGroupLayout,
12348
+ entries: [
12349
+ { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
12350
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
12351
+ { binding: 2, resource: this.shadowMapDepthViews[0] },
12352
+ { binding: 3, resource: this.shadowComparisonSampler },
12353
+ { binding: 4, resource: { buffer: this.groundShadowMaterialBuffer } },
12354
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
12355
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
12356
+ { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
12357
+ { binding: 8, resource: { buffer: this.mirrorVPBuffer } },
12358
+ // Stand-ins, not the live mirror textures: this group draws INTO the
12359
+ // mirror pass, where those are the attachments.
12360
+ { binding: 9, resource: this.mirrorDummyColorView! },
12361
+ { binding: 10, resource: this.materialSampler },
12362
+ { binding: 11, resource: this.mirrorDummyDepthView! },
12363
+ { binding: 12, resource: this.groundNoiseView },
12364
+ { binding: 13, resource: this.mirrorDummyColorView! },
12365
+ { binding: 14, resource: { buffer: this.groundClipMirrorBuffer } },
11361
12366
  ],
11362
12367
  })
11363
12368
  if (this.groundDrawCall) this.groundDrawCall.bindGroup = this.groundShadowBindGroup
@@ -11998,6 +13003,24 @@ export class Engine {
11998
13003
  this.device.queue.submit([encoder.finish()])
11999
13004
  }
12000
13005
 
13006
+ /** The ground draw, against a caller's bind group — the camera's, or the
13007
+ * mirror's. One body, so the reflected floor cannot drift from the real one. */
13008
+ private renderGroundWith(pass: GPURenderPassEncoder, bindGroup: GPUBindGroup, mirrored = false) {
13009
+ if (this.groundIsSuppressed()) return
13010
+ if (!this.hasGround || !this.groundVertexBuffer || !this.groundIndexBuffer || !this.groundDrawCall) return
13011
+ pass.setPipeline(
13012
+ mirrored
13013
+ ? this.ensureGroundMirrorPipeline(this.groundSoft)
13014
+ : this.groundSoft
13015
+ ? this.ensureGroundSoftPipeline()
13016
+ : this.groundShadowPipeline,
13017
+ )
13018
+ pass.setVertexBuffer(0, this.groundVertexBuffer)
13019
+ pass.setIndexBuffer(this.groundIndexBuffer, "uint16")
13020
+ pass.setBindGroup(0, bindGroup)
13021
+ pass.drawIndexed(this.groundDrawCall.count, 1, this.groundDrawCall.firstIndex, 0, 0)
13022
+ }
13023
+
12001
13024
  private renderGround(pass: GPURenderPassEncoder) {
12002
13025
  // A stage brings its own floor. Both sit at y=0, so drawing the built-in
12003
13026
  // plane underneath produces z-fighting across the whole scene — enforced
@@ -12207,7 +13230,18 @@ export class Engine {
12207
13230
  private renderWireframe(pass: GPURenderPassEncoder): void {
12208
13231
  if (!this.overlayVertices) return
12209
13232
  const inst = this.overlayModel(this.overlayVertices.modelName)
12210
- const { material, xray } = this.overlayVertices
13233
+ const { xray } = this.overlayVertices
13234
+
13235
+ // A hovered material previews EXACTLY what clicking it would pick — the
13236
+ // same self-occluding, ISOLATED view, nothing else drawn. It substitutes
13237
+ // for `material` only while nothing is actually picked, so the section
13238
+ // opens on the whole mesh and narrows to one material the moment the
13239
+ // pointer (in the viewport OR the list — both just call
13240
+ // setHoveredMaterial) settles on it. A real pick always wins, and there
13241
+ // is nothing left to preview once one is made.
13242
+ const hm = this.hoverMaterial
13243
+ const material = this.overlayVertices.material ?? (hm && hm.modelName === inst?.name ? hm.materialName : null)
13244
+
12211
13245
  if (!inst || !this.ensureEdgeBuffer(inst, material)) return
12212
13246
  const edges = inst.wireEdges.get(material ?? "")
12213
13247
  if (!edges) return
@@ -12221,16 +13255,6 @@ export class Engine {
12221
13255
  ? (inst.wireEdges.get(Engine.SEAM_KEY) ?? null)
12222
13256
  : null
12223
13257
 
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
13258
  this.wireframeColorData.set(DEFAULT_VERTEX_COLOR)
12235
13259
  this.wireframeColorData[4] = this.canvas.width
12236
13260
  this.wireframeColorData[5] = this.canvas.height
@@ -12244,43 +13268,10 @@ export class Engine {
12244
13268
  this.wireframeColorData[6] = OVERLAY_STYLE.seamStrokePx
12245
13269
  this.device.queue.writeBuffer(this.wireframeSeamUniformBuffer, 0, this.wireframeColorData)
12246
13270
  }
12247
- if (hover) {
12248
- this.wireframeColorData[6] = OVERLAY_STYLE.hoverStrokePx
12249
- this.device.queue.writeBuffer(this.wireframeHoverUniformBuffer, 0, this.wireframeColorData)
12250
- }
12251
13271
 
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
13272
  pass.setBindGroup(0, this.wireframeBindGroup)
12258
13273
  pass.setBindGroup(1, edges.bindGroup)
12259
13274
 
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
13275
  // What writes depth is what is allowed to hide the wireframe, and that
12285
13276
  // differs between the two views.
12286
13277
  //
@@ -12289,14 +13280,18 @@ export class Engine {
12289
13280
  // mode and Maya included, and seeing both walls at once is moire rather than
12290
13281
  // information.
12291
13282
  //
12292
- // A PICKED material writes only its OWN faces. The question a pick asks is
13283
+ // A PICKED (or hovered) material writes only its OWN faces. The question is
12293
13284
  // where this material is, and half of it is usually under a coat; letting
12294
13285
  // the coat hide it does not answer that. Its own depth still goes in, so its
12295
13286
  // back faces stay hidden and it reads as an object instead of a haze —
12296
13287
  // which is the difference between this and turning x-ray on.
12297
13288
  const runs = material !== null ? this.materialIndexRanges(inst, material) : null
12298
13289
  if (!xray) {
12299
- bindMesh()
13290
+ pass.setPipeline(this.wireframeDepthPipeline)
13291
+ pass.setVertexBuffer(0, inst.vertexBuffer)
13292
+ pass.setVertexBuffer(1, inst.jointsBuffer)
13293
+ pass.setVertexBuffer(2, inst.weightsBuffer)
13294
+ pass.setIndexBuffer(inst.indexBuffer, "uint32")
12300
13295
  if (runs) for (const [start, end] of runs) pass.drawIndexed(end - start, 1, start)
12301
13296
  else pass.drawIndexed(inst.model.getIndices().length)
12302
13297
  }
@@ -12331,7 +13326,8 @@ export class Engine {
12331
13326
  this.overlayBones !== null ||
12332
13327
  this.overlayBodies !== null ||
12333
13328
  this.overlayJoints !== null ||
12334
- this.overlayVertices !== null
13329
+ this.overlayVertices !== null ||
13330
+ this.selectionFill !== null
12335
13331
  )
12336
13332
  }
12337
13333
 
@@ -12453,7 +13449,7 @@ export class Engine {
12453
13449
  const inst = this.overlayModel(this.overlayJoints.modelName)
12454
13450
  if (inst) collect(jointOverlay(inst.model, inst.physics, this.overlayJoints.options))
12455
13451
  }
12456
- if (total === 0 && !this.overlayVertices) return
13452
+ if (total === 0 && !this.overlayVertices && !this.selectionFill) return
12457
13453
 
12458
13454
  this.ensureOverlayInstanceCapacity(total)
12459
13455
  const data = this.overlayInstanceData
@@ -12487,6 +13483,10 @@ export class Engine {
12487
13483
  const pass = encoder.beginRenderPass(this.overlayPassDescriptor)
12488
13484
  // Under everything: the mesh is the haze the rig is read against.
12489
13485
  this.renderWireframe(pass)
13486
+ // The selection wash, under its own edge lines (drawn below with the rest
13487
+ // of this layer's basic primitives) so the crisp outline still reads on
13488
+ // top of the fill rather than being washed out by it.
13489
+ this.renderSelectionFill(pass)
12490
13490
  pass.setBindGroup(0, this.overlayBindGroup)
12491
13491
  pass.setVertexBuffer(0, this.overlayVertexBuffer)
12492
13492
  pass.setVertexBuffer(1, this.overlayInstanceBuffer!)
@@ -13218,6 +14218,8 @@ export class Engine {
13218
14218
 
13219
14219
  // Frustum cull into indirect arguments. After the camera and shadow matrices
13220
14220
  // are settled, before the passes that draw from them.
14221
+ // Before reflectionActive is read: this is what decides it.
14222
+ this.syncMirrorFromEffects()
13221
14223
  if (this.reflectionActive) this.updateMirrorCamera()
13222
14224
  if (hasModels) this.dispatchCull(encoder)
13223
14225
  // After the cull, which is what recomputes the spheres it unions.
@@ -13268,6 +14270,14 @@ export class Engine {
13268
14270
  // model re-record. It lives in the cull compute now, which zeroes the
13269
14271
  // instance count of an invisible model's draws.
13270
14272
  if (wanted && this.shadowBundles[ci]) sp.executeBundles([this.shadowBundles[ci]])
14273
+ // The mirror throws shade like anything else standing on the floor.
14274
+ // Direct rather than in the bundle: the bundles are recorded on scene
14275
+ // STRUCTURE, and a mirror comes and goes with an effect's weight.
14276
+ if (wanted && this.mirrorSurface && this.mirrorShadowPipeline) {
14277
+ sp.setPipeline(this.mirrorShadowPipeline)
14278
+ sp.setBindGroup(0, this.mirrorShadowBindGroups[ci])
14279
+ sp.draw(6)
14280
+ }
13271
14281
  sp.end()
13272
14282
  this.shadowCascadeCleared[ci] = !wanted
13273
14283
  }
@@ -13319,6 +14329,10 @@ export class Engine {
13319
14329
  // One redundant word against a whole class of invisible failure.
13320
14330
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
13321
14331
  if (this.hasGround) this.renderGround(pass)
14332
+ // After the ground for the same early-z reason, and before the transparent
14333
+ // phase so sheer fabric blends over the glass rather than being depth
14334
+ // -rejected behind it.
14335
+ this.renderMirrorSurface(pass)
13322
14336
  // The transparent phase is drawn DIRECTLY, and must stay that way. It is the
13323
14337
  // one part of this pass that is not bundled, so the reason is worth keeping.
13324
14338
  //
@@ -13543,13 +14557,21 @@ export class Engine {
13543
14557
  }
13544
14558
  }
13545
14559
 
13546
- // Tear down installs no longer present.
14560
+ // Installs no longer present lose their CLAIM now (the generation bump
14561
+ // invalidates any in-flight compile for that id) but keep their BUFFER
14562
+ // until every draw call has stopped pointing at it. This was the actual
14563
+ // bug: destroying here, synchronously, left the render loop — which runs
14564
+ // independently on its own rAF and does not know this function is
14565
+ // mid-await — still submitting frames through a bind group whose buffer
14566
+ // no longer existed, for however many frames compileAndInstallGroup's own
14567
+ // shader compiles below took. "used in submit while destroyed" was that
14568
+ // race, not a one-off.
13547
14569
  const nextIds = new Set(groups.map((g) => g.id))
14570
+ const outgoing: [string, GroupInstall][] = []
13548
14571
  for (const [id, install] of inst.styleGroups) {
13549
14572
  if (!nextIds.has(id)) {
13550
14573
  inst.styleGroupGen.set(id, (inst.styleGroupGen.get(id) ?? 0) + 1)
13551
- this.destroyInstall(install)
13552
- inst.styleGroups.delete(id)
14574
+ outgoing.push([id, install])
13553
14575
  }
13554
14576
  }
13555
14577
 
@@ -13559,7 +14581,13 @@ export class Engine {
13559
14581
  groupResults.push({ groupId: g.id, diagnostics: r.diagnostics, ok: r.ok })
13560
14582
  }
13561
14583
 
14584
+ // Repoints every draw call away from the outgoing installs (among
14585
+ // everything else) — only past this line is destroying them safe.
13562
14586
  this.assignDrawCallGroups(inst, claimed)
14587
+ for (const [id, install] of outgoing) {
14588
+ this.destroyInstall(install)
14589
+ inst.styleGroups.delete(id)
14590
+ }
13563
14591
  return {
13564
14592
  ok: groupResults.every((r) => r.ok),
13565
14593
  groups: groupResults,
@@ -13852,12 +14880,17 @@ export class Engine {
13852
14880
  let pipeline: GPURenderPipeline
13853
14881
  let pipelineNoDepthWrite: GPURenderPipeline
13854
14882
  let overEyesPipeline: GPURenderPipeline | undefined
14883
+ let mirrorPipeline: GPURenderPipeline | undefined
13855
14884
  try {
13856
14885
  pipeline = await this.createRenderClassPipeline(renderClass, module, false)
13857
14886
  // The depth-write-off twin: stage transparency draws with it (see
13858
14887
  // pipelineForDrawCall), and a future OIT path would too.
13859
14888
  pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false)
13860
14889
  if (renderClass === "hair") overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true)
14890
+ // Only the eye needs one: every other class culls "none", which a flipped
14891
+ // winding leaves alone.
14892
+ if (renderClass === "eye")
14893
+ mirrorPipeline = await this.createRenderClassPipeline(renderClass, module, false, true, true)
13861
14894
  } catch (e) {
13862
14895
  diagnostics.push({ severity: "error", message: `pipeline creation failed: ${(e as Error).message}` })
13863
14896
  return { ok: false, diagnostics, slotMap: result.slotMap }
@@ -13887,6 +14920,7 @@ export class Engine {
13887
14920
  pipeline,
13888
14921
  pipelineNoDepthWrite,
13889
14922
  overEyesPipeline,
14923
+ mirrorPipeline,
13890
14924
  uniformBuffer,
13891
14925
  images: this.uploadGroupImages(group),
13892
14926
  slotMap: result.slotMap,
@@ -14003,12 +15037,21 @@ export class Engine {
14003
15037
  module: GPUShaderModule,
14004
15038
  overEyes: boolean,
14005
15039
  depthWrite = true,
15040
+ mirrored = false,
14006
15041
  ): Promise<GPURenderPipeline> {
14007
15042
  const base = {
14008
15043
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
14009
15044
  layout: this.mainPipelineLayout,
14010
15045
  vertex: { module, buffers: this.fullVertexBufferLayouts },
14011
- primitive: { cullMode: (renderClass === "eye" ? "front" : "none") as GPUCullMode },
15046
+ // The eye front-culls — the see-through-hair look is draw order plus that
15047
+ // cull plus the stencil stamp, and it is the deployed appearance of every
15048
+ // published scene. In the MIRROR the winding is flipped (determinant -1,
15049
+ // reflection.ts), so the same cull keeps the back of the eyeball and
15050
+ // discards its front. `mirrored` flips it back rather than changing what
15051
+ // the camera sees.
15052
+ primitive: {
15053
+ cullMode: (renderClass === "eye" ? (mirrored ? "back" : "front") : "none") as GPUCullMode,
15054
+ },
14012
15055
  multisample: { count: Engine.MULTISAMPLE_COUNT },
14013
15056
  }
14014
15057
  const plainDepth: GPUDepthStencilState = {
@@ -14071,11 +15114,14 @@ export class Engine {
14071
15114
  // rain vanished the instant the camera crossed a glass dome or a curtain
14072
15115
  // (reported: binary vanish/recover with camera angle, stage loaded). Stage
14073
15116
  // transparency blends and leaves depth alone.
14074
- private pipelineForDrawCall(inst: ModelInstance, dc: DrawCall): GPURenderPipeline {
15117
+ private pipelineForDrawCall(inst: ModelInstance, dc: DrawCall, mirrored = false): GPURenderPipeline {
14075
15118
  const stageGlass = inst.isStage && dc.type === "transparent"
14076
15119
  if (dc.groupId) {
14077
15120
  const install = inst.styleGroups.get(dc.groupId)
14078
- if (install) return stageGlass ? install.pipelineNoDepthWrite : install.pipeline
15121
+ if (install) {
15122
+ if (mirrored && install.mirrorPipeline) return install.mirrorPipeline
15123
+ return stageGlass ? install.pipelineNoDepthWrite : install.pipeline
15124
+ }
14079
15125
  }
14080
15126
  return stageGlass ? this.neutralPipelineNoDepthWrite : this.neutralPipeline
14081
15127
  }
@@ -14115,7 +15161,7 @@ export class Engine {
14115
15161
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
14116
15162
  bound = true
14117
15163
  }
14118
- const pipeline = this.pipelineForDrawCall(inst, draw)
15164
+ const pipeline = this.pipelineForDrawCall(inst, draw, view.args === "mirror")
14119
15165
  if (pipeline !== currentPipeline) {
14120
15166
  pass.setPipeline(pipeline)
14121
15167
  currentPipeline = pipeline
@@ -14126,8 +15172,9 @@ export class Engine {
14126
15172
  // Same index range; own pipeline + groups 0/2. Group 1 (skinMats) is
14127
15173
  // layout-identical between the main and outline pipelines and stays
14128
15174
  // bound. Restore group 0 afterwards and force a pipeline re-set.
14129
- pass.setPipeline(this.outlinePipeline)
14130
- pass.setBindGroup(0, this.outlinePerFrameBindGroup)
15175
+ const mirrored = view.args === "mirror"
15176
+ pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline)
15177
+ pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup)
14131
15178
  pass.setBindGroup(2, draw.outline.bindGroup)
14132
15179
  this.issueDraw(pass, draw, view.args)
14133
15180
  pass.setBindGroup(0, view.perFrame)
@@ -14167,10 +15214,13 @@ export class Engine {
14167
15214
  args: "camera" | "mirror"
14168
15215
  outlines: boolean
14169
15216
  } {
15217
+ // Outlines in BOTH now. They were off in the mirror because the hull culls
15218
+ // back faces and a reflection flips winding, so every hull triangle was
15219
+ // culled — a reflection of a toon character with no ink line beside the
15220
+ // real one reads as broken. The draw picks the flipped-cull pipeline and
15221
+ // the mirror camera's own group; see outlineMirrorPipeline.
14170
15222
  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 }
15223
+ ? { perFrame: this.mirrorPerFrameBindGroup, args: "mirror", outlines: true }
14174
15224
  : { perFrame: this.perFrameBindGroup, args: "camera", outlines: true }
14175
15225
  }
14176
15226