reze-engine 0.55.16 → 0.56.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/camera.d.ts +12 -0
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +20 -3
- package/dist/engine.d.ts +253 -5
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1078 -104
- package/dist/model.d.ts +10 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +16 -0
- package/dist/overlay.d.ts +0 -5
- package/dist/overlay.d.ts.map +1 -1
- package/dist/overlay.js +0 -5
- package/dist/reflection.d.ts +17 -4
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +93 -38
- package/dist/shaders/directives.d.ts +15 -0
- package/dist/shaders/directives.d.ts.map +1 -1
- package/dist/shaders/directives.js +5 -0
- package/dist/shaders/passes/ground.d.ts.map +1 -1
- package/dist/shaders/passes/ground.js +42 -6
- package/dist/shaders/passes/mirror.d.ts +32 -0
- package/dist/shaders/passes/mirror.d.ts.map +1 -0
- package/dist/shaders/passes/mirror.js +284 -0
- package/dist/shaders/passes/scene-contract.d.ts +6 -0
- package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
- package/dist/shaders/passes/scene-contract.js +8 -0
- package/package.json +1 -1
- package/src/camera.ts +21 -4
- package/src/engine.ts +1158 -108
- package/src/model.ts +16 -0
- package/src/overlay.ts +0 -5
- package/src/reflection.ts +103 -37
- package/src/shaders/directives.ts +20 -0
- package/src/shaders/passes/ground.ts +42 -6
- package/src/shaders/passes/mirror.ts +289 -0
- 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. */
|
|
@@ -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
|
|
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.
|
|
2090
|
-
//
|
|
2091
|
-
|
|
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([
|
|
2884
|
+
new Float32Array([effective, this.groundMirrorBlur]),
|
|
2784
2885
|
)
|
|
2785
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2866
|
-
//
|
|
2867
|
-
//
|
|
2868
|
-
//
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
//
|
|
2872
|
-
//
|
|
2873
|
-
//
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
14130
|
-
pass.
|
|
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
|
-
?
|
|
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
|
|