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