reze-engine 0.64.0 → 0.66.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/engine.d.ts +300 -94
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1628 -787
- package/dist/graph/compile.d.ts.map +1 -1
- package/dist/graph/compile.js +5 -0
- package/dist/graph/presets/body.d.ts.map +1 -1
- package/dist/graph/presets/body.js +26 -24
- package/dist/graph/presets/cloth_rough.js +8 -8
- package/dist/graph/presets/cloth_smooth.js +8 -8
- package/dist/graph/presets/default.js +4 -4
- package/dist/graph/presets/eye.js +10 -10
- package/dist/graph/presets/face.d.ts.map +1 -1
- package/dist/graph/presets/face.js +28 -26
- package/dist/graph/presets/hair.js +6 -6
- package/dist/graph/presets/metal.js +8 -8
- package/dist/graph/presets/stockings.d.ts.map +1 -1
- package/dist/graph/presets/stockings.js +4 -11
- package/dist/graph/presets/unlit.d.ts.map +1 -1
- package/dist/graph/presets/unlit.js +9 -13
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +128 -157
- package/dist/graph/render-class.d.ts +5 -2
- package/dist/graph/render-class.d.ts.map +1 -1
- package/dist/graph/slots.d.ts.map +1 -1
- package/dist/graph/slots.js +12 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/model.d.ts +23 -1
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +41 -1
- package/dist/outline-normals.d.ts +12 -0
- package/dist/outline-normals.d.ts.map +1 -0
- package/dist/outline-normals.js +100 -0
- package/dist/pmx-loader.d.ts +7 -0
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +17 -3
- package/dist/shaders/anchor-table.d.ts +11 -0
- package/dist/shaders/anchor-table.d.ts.map +1 -1
- package/dist/shaders/anchor-table.js +9 -3
- package/dist/shaders/directives.d.ts +42 -5
- package/dist/shaders/directives.d.ts.map +1 -1
- package/dist/shaders/directives.js +51 -4
- package/dist/shaders/lights.d.ts +11 -7
- package/dist/shaders/lights.d.ts.map +1 -1
- package/dist/shaders/lights.js +315 -271
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +30 -5
- package/dist/shaders/materials/nodes.d.ts +1 -1
- package/dist/shaders/materials/nodes.d.ts.map +1 -1
- package/dist/shaders/materials/nodes.js +191 -271
- package/dist/shaders/passes/bloom.d.ts +4 -3
- package/dist/shaders/passes/bloom.d.ts.map +1 -1
- package/dist/shaders/passes/bloom.js +58 -87
- package/dist/shaders/passes/composite.d.ts +1 -1
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +121 -77
- package/dist/shaders/passes/ground.js +1 -1
- package/dist/shaders/passes/mirror.d.ts +18 -12
- package/dist/shaders/passes/mirror.d.ts.map +1 -1
- package/dist/shaders/passes/mirror.js +59 -13
- package/dist/shaders/passes/outline.d.ts.map +1 -1
- package/dist/shaders/passes/outline.js +71 -14
- package/dist/shaders/passes/particles.d.ts.map +1 -1
- package/dist/shaders/passes/particles.js +9 -3
- package/dist/shaders/passes/probe.d.ts +45 -0
- package/dist/shaders/passes/probe.d.ts.map +1 -0
- package/dist/shaders/passes/probe.js +230 -0
- package/dist/shaders/passes/scene-contract.d.ts +13 -0
- package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
- package/dist/shaders/passes/scene-contract.js +4 -0
- package/dist/shaders/scene-light-api.d.ts.map +1 -1
- package/dist/shaders/scene-light-api.js +1 -0
- package/dist/unity/globals.d.ts +7 -0
- package/dist/unity/globals.d.ts.map +1 -1
- package/dist/unity/globals.js +4 -3
- package/dist/unity/host.d.ts +18 -0
- package/dist/unity/host.d.ts.map +1 -1
- package/dist/unity/host.js +144 -70
- package/dist/unity/looks.d.ts +47 -0
- package/dist/unity/looks.d.ts.map +1 -1
- package/dist/unity/looks.js +150 -6
- package/dist/unity/skin.d.ts.map +1 -1
- package/dist/unity/skin.js +5 -2
- package/dist/unity/stage.d.ts +24 -0
- package/dist/unity/stage.d.ts.map +1 -1
- package/dist/unity/stage.js +59 -7
- package/package.json +1 -1
- package/src/engine.ts +2010 -1033
- package/src/graph/compile.ts +4 -0
- package/src/graph/presets/body.ts +26 -24
- package/src/graph/presets/cloth_rough.ts +8 -8
- package/src/graph/presets/cloth_smooth.ts +8 -8
- package/src/graph/presets/default.ts +4 -4
- package/src/graph/presets/eye.ts +10 -10
- package/src/graph/presets/face.ts +28 -26
- package/src/graph/presets/hair.ts +6 -6
- package/src/graph/presets/metal.ts +8 -8
- package/src/graph/presets/stockings.ts +4 -11
- package/src/graph/presets/unlit.ts +9 -13
- package/src/graph/registry.ts +133 -158
- package/src/graph/render-class.ts +5 -2
- package/src/graph/slots.ts +12 -2
- package/src/index.ts +1 -0
- package/src/model.ts +50 -1
- package/src/outline-normals.ts +110 -0
- package/src/pmx-loader.ts +18 -3
- package/src/shaders/anchor-table.ts +15 -3
- package/src/shaders/directives.ts +88 -6
- package/src/shaders/lights.ts +464 -415
- package/src/shaders/materials/common.ts +30 -5
- package/src/shaders/materials/nodes.ts +191 -271
- package/src/shaders/passes/bloom.ts +59 -87
- package/src/shaders/passes/composite.ts +121 -77
- package/src/shaders/passes/ground.ts +1 -1
- package/src/shaders/passes/mirror.ts +60 -13
- package/src/shaders/passes/outline.ts +71 -14
- package/src/shaders/passes/particles.ts +9 -3
- package/src/shaders/passes/probe.ts +240 -0
- package/src/shaders/passes/scene-contract.ts +17 -0
- package/src/shaders/scene-light-api.ts +1 -0
- package/src/unity/globals.ts +7 -3
- package/src/unity/host.ts +93 -20
- package/src/unity/looks.ts +182 -9
- package/src/unity/skin.ts +5 -2
- package/src/unity/stage.ts +66 -7
- package/dist/physics-debug.d.ts +0 -30
- package/dist/physics-debug.d.ts.map +0 -1
- package/dist/physics-debug.js +0 -526
- package/dist/shaders/agx-lut.d.ts +0 -11
- package/dist/shaders/agx-lut.d.ts.map +0 -1
- package/dist/shaders/agx-lut.js +0 -29
- package/dist/shaders/dfg_lut.d.ts +0 -3
- package/dist/shaders/dfg_lut.d.ts.map +0 -1
- package/dist/shaders/dfg_lut.js +0 -129
- package/dist/shaders/ltc_mag_lut.d.ts +0 -3
- package/dist/shaders/ltc_mag_lut.d.ts.map +0 -1
- package/dist/shaders/ltc_mag_lut.js +0 -1033
- package/dist/shaders/materials/body.d.ts +0 -2
- package/dist/shaders/materials/body.d.ts.map +0 -1
- package/dist/shaders/materials/body.js +0 -95
- package/dist/shaders/materials/cloth_rough.d.ts +0 -2
- package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
- package/dist/shaders/materials/cloth_rough.js +0 -69
- package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
- package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
- package/dist/shaders/materials/cloth_smooth.js +0 -61
- package/dist/shaders/materials/default.d.ts +0 -2
- package/dist/shaders/materials/default.d.ts.map +0 -1
- package/dist/shaders/materials/default.js +0 -43
- package/dist/shaders/materials/eye.d.ts +0 -2
- package/dist/shaders/materials/eye.d.ts.map +0 -1
- package/dist/shaders/materials/eye.js +0 -60
- package/dist/shaders/materials/face.d.ts +0 -2
- package/dist/shaders/materials/face.d.ts.map +0 -1
- package/dist/shaders/materials/face.js +0 -95
- package/dist/shaders/materials/hair.d.ts +0 -2
- package/dist/shaders/materials/hair.d.ts.map +0 -1
- package/dist/shaders/materials/hair.js +0 -90
- package/dist/shaders/materials/metal.d.ts +0 -2
- package/dist/shaders/materials/metal.d.ts.map +0 -1
- package/dist/shaders/materials/metal.js +0 -77
- package/dist/shaders/materials/mmd_classic.d.ts +0 -2
- package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
- package/dist/shaders/materials/mmd_classic.js +0 -66
- package/dist/shaders/materials/stockings.d.ts +0 -2
- package/dist/shaders/materials/stockings.d.ts.map +0 -1
- package/dist/shaders/materials/stockings.js +0 -122
- package/dist/shaders/passes/physics-debug.d.ts +0 -2
- package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
- package/dist/shaders/passes/physics-debug.js +0 -69
- package/src/shaders/agx-lut.ts +0 -30
- package/src/shaders/dfg_lut.ts +0 -131
- package/src/shaders/ltc_mag_lut.ts +0 -1035
package/src/engine.ts
CHANGED
|
@@ -25,8 +25,6 @@ import {
|
|
|
25
25
|
normalizeAssetPath,
|
|
26
26
|
type AssetReader,
|
|
27
27
|
} from "./asset-reader"
|
|
28
|
-
import { BRDF_LUT_SIZE, BRDF_LUT_BAKE_WGSL } from "./shaders/dfg_lut"
|
|
29
|
-
import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut"
|
|
30
28
|
import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow"
|
|
31
29
|
import { ID_DEBUG_SHADER_WGSL } from "./shaders/passes/id-debug"
|
|
32
30
|
import { paramChanged, sampleParamTrack, type ParamKey, type ParamValue } from "./param-track"
|
|
@@ -46,13 +44,19 @@ import {
|
|
|
46
44
|
import { parentKeySpan, type ModelParentKey } from "./parent-keys"
|
|
47
45
|
import { NativeHost } from "./unity/host"
|
|
48
46
|
import { NativeLooks, type NativeLook } from "./unity/looks"
|
|
49
|
-
import { NativeStage, type NativeStagePackage, type NativeStageReader } from "./unity/stage"
|
|
47
|
+
import { NativeStage, type NativeStageLights, type NativeStagePackage, type NativeStageReader } from "./unity/stage"
|
|
50
48
|
import { gameDir, unityFrameGlobals } from "./unity/globals"
|
|
51
49
|
import { outlineMaxOffsetMultiplier, towardMatrix } from "./unity/character"
|
|
52
|
-
import type
|
|
50
|
+
import { sameValue, type NativeValue } from "./unity/host"
|
|
53
51
|
import { SHADOW_ATLAS_SIZE, SHADOW_CASCADES, buildShadowCascades, cascadeSpheres, type ShadowBounds, type ShadowView } from "./shadow-cascades"
|
|
54
52
|
import { REFLECTION_DEBUG_WGSL, buildMirrorCamera, planeFromPointNormal } from "./reflection"
|
|
55
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
MIRROR_DOWNSAMPLE_WGSL,
|
|
55
|
+
MIRROR_MASK_DOWNSAMPLE_WGSL,
|
|
56
|
+
MIRROR_MAT_BYTES,
|
|
57
|
+
mirrorShaderWgsl,
|
|
58
|
+
mirrorShadowWgsl,
|
|
59
|
+
} from "./shaders/passes/mirror"
|
|
56
60
|
import { packHalf, type HdrImage } from "./hdr"
|
|
57
61
|
import { evalIrradianceSH, projectIrradianceSH, gradientIrradianceSH } from "./ibl"
|
|
58
62
|
import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, lyricsApi, packLyrics, type LyricLine, type LyricRect } from "./shaders/lyrics-api"
|
|
@@ -104,11 +108,11 @@ import {
|
|
|
104
108
|
type RigidbodyOverlayOptions,
|
|
105
109
|
} from "./overlay"
|
|
106
110
|
import {
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
BLOOM_BLUR_H_SHADER_WGSL,
|
|
112
|
+
BLOOM_BLUR_V_SHADER_WGSL,
|
|
113
|
+
BLOOM_PREFILTER_SHADER_WGSL,
|
|
109
114
|
BLOOM_UPSAMPLE_SHADER_WGSL,
|
|
110
115
|
} from "./shaders/passes/bloom"
|
|
111
|
-
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut"
|
|
112
116
|
import {
|
|
113
117
|
buildCompositeShader,
|
|
114
118
|
EFFECT_SCENE_API,
|
|
@@ -153,6 +157,17 @@ import {
|
|
|
153
157
|
import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails"
|
|
154
158
|
import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
|
|
155
159
|
import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
|
|
160
|
+
import {
|
|
161
|
+
PROBE_CAPTURE_LEVELS,
|
|
162
|
+
PROBE_CAPTURE_SIZE,
|
|
163
|
+
PROBE_COMPOSE_WGSL,
|
|
164
|
+
PROBE_FILTER_WGSL,
|
|
165
|
+
PROBE_LEVELS,
|
|
166
|
+
PROBE_SIZE,
|
|
167
|
+
SKY_PREFILTER_WGSL,
|
|
168
|
+
probeLevelRoughness,
|
|
169
|
+
skyLevelRoughness,
|
|
170
|
+
} from "./shaders/passes/probe"
|
|
156
171
|
import { compileGraph, type CompileOptions, type StyleSlot } from "./graph/compile"
|
|
157
172
|
import type { Diagnostic, ShaderGraph } from "./graph/schema"
|
|
158
173
|
import type { AlphaMode, RenderClass, StyleBlend } from "./graph/render-class"
|
|
@@ -355,12 +370,13 @@ export type LoadModelFromFilesOptions = {
|
|
|
355
370
|
pmxFile?: File
|
|
356
371
|
}
|
|
357
372
|
|
|
358
|
-
//
|
|
359
|
-
// Sun = the
|
|
373
|
+
// Scene config, as Unity's Lighting window lays it out: World = Environment
|
|
374
|
+
// Lighting (ambient and reflections); Sun = the main directional light;
|
|
375
|
+
// Camera = view framing.
|
|
360
376
|
type WorldOptions = {
|
|
361
|
-
/** Linear
|
|
377
|
+
/** Linear colour of the ambient when the World is a flat colour (Unity: Environment Lighting > Source Color). */
|
|
362
378
|
color?: Vec3
|
|
363
|
-
/** Multiplier on
|
|
379
|
+
/** Multiplier on the ambient and the sky's reflection (Unity: the Environment Lighting intensity multiplier). */
|
|
364
380
|
strength?: number
|
|
365
381
|
/**
|
|
366
382
|
* A sky that changes with the direction you look: one colour overhead, one at
|
|
@@ -468,14 +484,14 @@ type HeldParentKey = { time: number; parent: string | null; bone: string; positi
|
|
|
468
484
|
type ParentTrack = { keys: HeldParentKey[]; applied: number; seat: [number, number, number] }
|
|
469
485
|
|
|
470
486
|
type SunOptions = {
|
|
471
|
-
/** Linear
|
|
487
|
+
/** Linear colour of the main light (Unity: Light > Color). */
|
|
472
488
|
color?: Vec3
|
|
473
489
|
/** The rendering layers it keys, as bits; every layer by default. A
|
|
474
490
|
* directional light from setLights takes the layers the sun leaves. */
|
|
475
491
|
layers?: number
|
|
476
|
-
/**
|
|
492
|
+
/** Unity's light intensity: a white sun of 1 lights albedo 1:1 at N·L = 1. */
|
|
477
493
|
strength?: number
|
|
478
|
-
/** Direction sunlight travels (points FROM sun TO scene,
|
|
494
|
+
/** Direction sunlight travels (points FROM the sun TO the scene, Unity: the light's forward). */
|
|
479
495
|
direction?: Vec3
|
|
480
496
|
/**
|
|
481
497
|
* How much shadow the sun casts: 1 full, 0 none, and anything between.
|
|
@@ -556,25 +572,30 @@ type CameraOptions = {
|
|
|
556
572
|
fov?: number
|
|
557
573
|
}
|
|
558
574
|
|
|
559
|
-
/**
|
|
575
|
+
/** Bloom — Aether Gazer's own (shaders/passes/bloom.ts): a soft-knee prefilter,
|
|
576
|
+
* a Gaussian chain down to a few pixels, and a scatter blend back up, added to
|
|
577
|
+
* the scene before the view transform. The defaults ARE the game. */
|
|
560
578
|
export type BloomOptions = {
|
|
561
579
|
enabled: boolean
|
|
580
|
+
/** Brightest-channel level where the glow starts (the game's per-scene
|
|
581
|
+
* threshold). The soft knee is always half of it. */
|
|
562
582
|
threshold: number
|
|
563
|
-
|
|
564
|
-
|
|
583
|
+
/** 0..1, URP's Scatter: how far each level leans toward the wider one on the
|
|
584
|
+
* way up, as lerp(0.05, 0.95, scatter) — low is a tight halo, high a wide
|
|
585
|
+
* haze, and the top still keeps the finest level. 0.8 is the game's 0.77. */
|
|
586
|
+
scatter: number
|
|
587
|
+
/** Tint on the glow; white is the game. */
|
|
565
588
|
color: Vec3
|
|
589
|
+
/** Multiplier on the glow added in the composite; 1 is the game. */
|
|
566
590
|
intensity: number
|
|
567
|
-
clamp: number
|
|
568
591
|
}
|
|
569
592
|
|
|
570
593
|
export const DEFAULT_BLOOM_OPTIONS: BloomOptions = {
|
|
571
594
|
enabled: true,
|
|
572
|
-
threshold: 0.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
intensity: 0.05,
|
|
577
|
-
clamp: 0.0,
|
|
595
|
+
threshold: 0.7,
|
|
596
|
+
scatter: 0.8,
|
|
597
|
+
color: new Vec3(1, 1, 1),
|
|
598
|
+
intensity: 1,
|
|
578
599
|
}
|
|
579
600
|
|
|
580
601
|
/** Camera depth of field — a bokeh gather in the composite pass. Costs nothing
|
|
@@ -613,34 +634,33 @@ export const DEFAULT_DEPTH_OF_FIELD_OPTIONS: DepthOfFieldOptions = {
|
|
|
613
634
|
quality: "balanced",
|
|
614
635
|
}
|
|
615
636
|
|
|
616
|
-
/**
|
|
637
|
+
/** How the scene-linear frame becomes a display image: exposure, then a tone
|
|
638
|
+
* transform, then display gamma. */
|
|
617
639
|
export type ViewTransformOptions = {
|
|
618
|
-
/** Stops applied before
|
|
640
|
+
/** Stops applied before the transform: `linear *= 2^exposure`. */
|
|
619
641
|
exposure: number
|
|
620
|
-
/** After
|
|
642
|
+
/** After the transform, display gamma (`pow(rgb, 1/gamma)`). */
|
|
621
643
|
gamma: number
|
|
622
644
|
/**
|
|
623
|
-
* Which display transform the frame is formed with.
|
|
645
|
+
* Which display transform the frame is formed with (composite.ts, viewTransform).
|
|
624
646
|
*
|
|
625
|
-
* "
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
647
|
+
* "soft" — Aether Gazer's own final curve, (1 - e^(-2.5x))^1.4. The default.
|
|
648
|
+
* "neutral" — Unity URP's Neutral, the Khronos PBR Neutral operator: colour
|
|
649
|
+
* passes through until the top fifth, which rolls off.
|
|
650
|
+
* "aces" — Unity URP's ACES (RRT + ODT approximation from the Core RP).
|
|
651
|
+
* "none" — the sRGB encoding and nothing else.
|
|
629
652
|
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
* "aether-gazer" is that game's own final curve, (1 - e^(-2.5x))^1.4 — what
|
|
633
|
-
* its scenes are graded through (composite.ts, agTransform).
|
|
653
|
+
* All four end in the sRGB encoding.
|
|
634
654
|
*/
|
|
635
|
-
transform:
|
|
655
|
+
transform: ViewTransformName
|
|
636
656
|
}
|
|
637
657
|
|
|
638
|
-
|
|
639
|
-
|
|
658
|
+
export type ViewTransformName = "soft" | "neutral" | "aces" | "none"
|
|
659
|
+
|
|
640
660
|
export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
|
|
641
|
-
exposure: 0
|
|
661
|
+
exposure: 0,
|
|
642
662
|
gamma: 1.0,
|
|
643
|
-
transform: "
|
|
663
|
+
transform: "soft",
|
|
644
664
|
}
|
|
645
665
|
|
|
646
666
|
/** Color grading applied to the tonemapped scene (ASC CDL — see grade() in
|
|
@@ -735,9 +755,9 @@ export type EngineOptions = {
|
|
|
735
755
|
/** Canvas background (display-space sRGB 0–1), composited under the scene after
|
|
736
756
|
* tonemapping. Omit/null = transparent canvas (see setBackgroundColor). */
|
|
737
757
|
background?: Vec3 | null
|
|
738
|
-
/** Initial
|
|
758
|
+
/** Initial bloom; tune at runtime with `setBloomOptions`. */
|
|
739
759
|
bloom?: Partial<BloomOptions>
|
|
740
|
-
/** View transform (exposure
|
|
760
|
+
/** View transform (exposure, tone transform, gamma) applied in the composite. */
|
|
741
761
|
view?: Partial<ViewTransformOptions>
|
|
742
762
|
onRaycast?: RaycastCallback
|
|
743
763
|
/** See {@link GizmoDragCallback}. */
|
|
@@ -746,7 +766,7 @@ export type EngineOptions = {
|
|
|
746
766
|
|
|
747
767
|
const DEFAULT_ENGINE_OPTIONS = {
|
|
748
768
|
world: { color: new Vec3(0.4014, 0.4944, 0.647), strength: 0.3 },
|
|
749
|
-
sun: { color: new Vec3(1.0, 1.0, 1.0), strength:
|
|
769
|
+
sun: { color: new Vec3(1.0, 1.0, 1.0), strength: 0.64, direction: new Vec3(-0.0873, -0.3844, 0.919) },
|
|
750
770
|
camera: { distance: 26.6, target: new Vec3(0, 12.5, 0), fov: Math.PI / 4 },
|
|
751
771
|
onRaycast: undefined,
|
|
752
772
|
}
|
|
@@ -930,12 +950,19 @@ interface ModelInstance {
|
|
|
930
950
|
indexBuffer: GPUBuffer
|
|
931
951
|
jointsBuffer: GPUBuffer
|
|
932
952
|
weightsBuffer: GPUBuffer
|
|
953
|
+
/** Vertex colour and second UV, per vertex — the material pipelines' slot 4.
|
|
954
|
+
* See createAttributeBuffer. */
|
|
955
|
+
attributeBuffer: GPUBuffer
|
|
956
|
+
/** The outline hull's own stream — smoothed normal + PMX edge scale per
|
|
957
|
+
* vertex (outline-normals.ts). Made with the first edge-flagged material,
|
|
958
|
+
* so a model with no outline never pays for it. */
|
|
959
|
+
outlineVertexBuffer?: GPUBuffer
|
|
933
960
|
skinMatrixBuffer: GPUBuffer
|
|
934
961
|
drawCalls: DrawCall[]
|
|
935
962
|
shadowDrawCalls: DrawCall[]
|
|
936
963
|
shadowBindGroups: GPUBindGroup[]
|
|
937
964
|
mainPerInstanceBindGroup: GPUBindGroup
|
|
938
|
-
/** Its ObjectLight — rendering layers, its own ambient — and the CPU copy it
|
|
965
|
+
/** Its ObjectLight — rendering layers, its own ambient, its fill — and the CPU copy it
|
|
939
966
|
* is written from. Every model has one: its layers decide its lights. */
|
|
940
967
|
lightBuffer: GPUBuffer
|
|
941
968
|
objectLight: Float32Array<ArrayBuffer>
|
|
@@ -1745,7 +1772,7 @@ interface EffectInstance {
|
|
|
1745
1772
|
distVariant: number
|
|
1746
1773
|
/** Bones this source asked for, in ITS OWN declaration order. The scene table
|
|
1747
1774
|
* maps these onto shared addresses; this list is what it is rebuilt from. */
|
|
1748
|
-
anchors: { bone: string; trail: boolean }[]
|
|
1775
|
+
anchors: { bone: string; trail: boolean; along?: number; step?: number }[]
|
|
1749
1776
|
/** Where this effect's own clock started, in scene seconds. */
|
|
1750
1777
|
epochScene: number
|
|
1751
1778
|
/**
|
|
@@ -1850,6 +1877,17 @@ export type SceneLight = {
|
|
|
1850
1877
|
* cast's key on RENDERING_LAYER_CHARACTER is how a game lights the two apart.
|
|
1851
1878
|
*/
|
|
1852
1879
|
layers?: number
|
|
1880
|
+
/**
|
|
1881
|
+
* The bulb's radius in engine units: inside it the lamp stops getting
|
|
1882
|
+
* brighter (intensity / max(d², near²)). Omitted keeps the default small
|
|
1883
|
+
* bulb; a large soft lamp (a game rig light's shape radius) is wider.
|
|
1884
|
+
*/
|
|
1885
|
+
near?: number
|
|
1886
|
+
/**
|
|
1887
|
+
* A spot's cookie: the key of a picture given to loadLightCookie, projected
|
|
1888
|
+
* along the cone the way a stage light throws a gobo. Ignored on a point.
|
|
1889
|
+
*/
|
|
1890
|
+
cookie?: string
|
|
1853
1891
|
}
|
|
1854
1892
|
|
|
1855
1893
|
/**
|
|
@@ -1925,6 +1963,9 @@ export class Engine {
|
|
|
1925
1963
|
private mainPipelineLayout!: GPUPipelineLayout
|
|
1926
1964
|
private sceneTargets!: GPUColorTargetState[]
|
|
1927
1965
|
private sceneTargetsAdditive!: GPUColorTargetState[]
|
|
1966
|
+
/** A group blending "premultiplied" — see scene-contract's
|
|
1967
|
+
* material-premultiplied. */
|
|
1968
|
+
private sceneTargetsPremultiplied!: GPUColorTargetState[]
|
|
1928
1969
|
/** The scene pass's attachment formats, settled at init once the device has
|
|
1929
1970
|
* said which HDR format it will blend. Every scene-pass pipeline asks
|
|
1930
1971
|
* scene-contract for its targets against these. */
|
|
@@ -1932,6 +1973,11 @@ export class Engine {
|
|
|
1932
1973
|
return { hdr: this.hdrFormat, aux: Engine.BLOOM_MASK_FORMAT }
|
|
1933
1974
|
}
|
|
1934
1975
|
private fullVertexBufferLayouts!: GPUVertexBufferLayout[]
|
|
1976
|
+
/** What a MATERIAL pipeline reads: the full set, then (slot 4, past the
|
|
1977
|
+
* outline hull's slot 3) the model's attribute stream — vertex colour and a
|
|
1978
|
+
* second UV (see createAttributeBuffer). Only the material shaders declare
|
|
1979
|
+
* those inputs; every other pass keeps fullVertexBufferLayouts. */
|
|
1980
|
+
private materialVertexBufferLayouts!: (GPUVertexBufferLayout | null)[]
|
|
1935
1981
|
// 1×64 vertical ramp for shared-toon materials: lit (top) → soft shadow
|
|
1936
1982
|
// tone (bottom). Stand-in for MMD's toon01–10.bmp, which we can't ship.
|
|
1937
1983
|
private defaultToonRampTexture!: GPUTexture
|
|
@@ -2084,6 +2130,8 @@ export class Engine {
|
|
|
2084
2130
|
private multisampleTexture!: GPUTexture
|
|
2085
2131
|
private hdrResolveTexture!: GPUTexture
|
|
2086
2132
|
private static readonly MULTISAMPLE_COUNT = 4
|
|
2133
|
+
/** Floats per vertex in a model's attribute stream: colour RGBA, then UV2. */
|
|
2134
|
+
private static readonly ATTRIBUTE_FLOATS = 6
|
|
2087
2135
|
/**
|
|
2088
2136
|
* Shadow map depth format — 16-bit, deliberately.
|
|
2089
2137
|
*
|
|
@@ -2477,6 +2525,9 @@ export class Engine {
|
|
|
2477
2525
|
/** Where addGround put the floor. The mirror reflects across it, so a raised
|
|
2478
2526
|
* floor that kept y = 0 would reflect the scene into the wrong plane. */
|
|
2479
2527
|
private groundY = 0
|
|
2528
|
+
/** The built-in floor's half size on x and z — what it covers, for the
|
|
2529
|
+
* shadow's reach (updateShadowSceneBounds). */
|
|
2530
|
+
private groundHalf: [number, number] = [0, 0]
|
|
2480
2531
|
private mirrorPlane = new Float32Array([0, 1, 0, 0])
|
|
2481
2532
|
private mirrorCameraData = new Float32Array(40)
|
|
2482
2533
|
private mirrorCameraBuffer!: GPUBuffer
|
|
@@ -2505,6 +2556,7 @@ export class Engine {
|
|
|
2505
2556
|
* premultiplied quantity and a blur has to move them together. */
|
|
2506
2557
|
private mirrorMaskMipViews: GPUTextureView[] = []
|
|
2507
2558
|
private mirrorMaskBlurBindGroups: GPUBindGroup[] | null = null
|
|
2559
|
+
private mirrorDownsamplePipeline: GPURenderPipeline | null = null
|
|
2508
2560
|
private mirrorMaskDownsamplePipeline: GPURenderPipeline | null = null
|
|
2509
2561
|
/** Two blocks the ground reads its clip from: the camera's is inert, the
|
|
2510
2562
|
* mirror's carries the live plane. Which one a draw sees is decided by which
|
|
@@ -2610,6 +2662,50 @@ export class Engine {
|
|
|
2610
2662
|
private worldAmbientSH: Float32Array | null = null
|
|
2611
2663
|
private fallbackEquirectTexture!: GPUTexture
|
|
2612
2664
|
private fallbackEquirectView!: GPUTextureView
|
|
2665
|
+
// ── The stage's reflection probe (captureReflectionProbe) ──
|
|
2666
|
+
/** The cube the materials read: 64² GGX-prefiltered down seven levels. */
|
|
2667
|
+
private probeTexture!: GPUTexture
|
|
2668
|
+
private probeView!: GPUTextureView
|
|
2669
|
+
/** 1×1 black cube, bound while a probe is being captured so no material
|
|
2670
|
+
* reads the cube it is drawing into. */
|
|
2671
|
+
private probeFallbackView!: GPUTextureView
|
|
2672
|
+
/** The spot lamps' cookies: one picture a layer of a 512² array, made on the
|
|
2673
|
+
* first cookie (a 1×1 white stand-in until then), and the layer each key holds. */
|
|
2674
|
+
private cookieTexture!: GPUTexture
|
|
2675
|
+
private cookieView!: GPUTextureView
|
|
2676
|
+
private cookieLayers = new Map<string, number>()
|
|
2677
|
+
/** What setLights was last given, rewritten when a cookie it names arrives. */
|
|
2678
|
+
private sceneLights: SceneLight[] | null = null
|
|
2679
|
+
private static readonly COOKIE_SIZE = 512
|
|
2680
|
+
private static readonly COOKIE_LAYERS = 16
|
|
2681
|
+
/** ProbeUniforms for the scene (centre + strength, box min + on-flag, box
|
|
2682
|
+
* max), and a zeroed copy — the off flag — for the capture's own draws. */
|
|
2683
|
+
private probeUniformBuffer!: GPUBuffer
|
|
2684
|
+
private probeOffBuffer!: GPUBuffer
|
|
2685
|
+
private probeData = new Float32Array(12)
|
|
2686
|
+
/** The capture camera: CameraUniforms for one cube face at a time. */
|
|
2687
|
+
private probeCameraBuffer!: GPUBuffer
|
|
2688
|
+
private probeCameraData = new Float32Array(40)
|
|
2689
|
+
private probeCaptureBindGroup: GPUBindGroup | null = null
|
|
2690
|
+
/** Set when the stage or what it wears changes; the capture runs once the
|
|
2691
|
+
* scene has stayed put for PROBE_SETTLE_FRAMES (see maybeCaptureProbe). */
|
|
2692
|
+
private probeDirty = false
|
|
2693
|
+
private probeDirtyFrame = 0
|
|
2694
|
+
private probeFrame = 0
|
|
2695
|
+
private probeCapture: {
|
|
2696
|
+
msTargets: GPUTexture[]
|
|
2697
|
+
depth: GPUTexture
|
|
2698
|
+
color: GPUTexture
|
|
2699
|
+
aux: GPUTexture
|
|
2700
|
+
cube: GPUTexture
|
|
2701
|
+
steps: GPUBuffer
|
|
2702
|
+
composePipeline: GPURenderPipeline
|
|
2703
|
+
filterPipeline: GPURenderPipeline
|
|
2704
|
+
composeGroups: GPUBindGroup[]
|
|
2705
|
+
filterGroups: GPUBindGroup[]
|
|
2706
|
+
} | null = null
|
|
2707
|
+
private static readonly PROBE_SETTLE_FRAMES = 20
|
|
2708
|
+
private skyPrefilterPipeline: GPURenderPipeline | null = null
|
|
2613
2709
|
// The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
|
|
2614
2710
|
// scene, over it, or both — whichever of background()/foreground() the code
|
|
2615
2711
|
// defines. The composite pipelines are REBUILT with the user code injected;
|
|
@@ -2677,7 +2773,7 @@ export class Engine {
|
|
|
2677
2773
|
* shader's index 0 is now — written by unshifting rather than by tracking a
|
|
2678
2774
|
* head, because 64 is short and the alternative is an index the GPU side
|
|
2679
2775
|
* would also have to know about. */
|
|
2680
|
-
private anchorTrail = new Map<string, { pos: number[]; t: number[] }>()
|
|
2776
|
+
private anchorTrail = new Map<string, { pos: number[]; t: number[]; live?: boolean }>()
|
|
2681
2777
|
/** Scene seconds, advanced by the frame delta — NOT wall time, so an offline
|
|
2682
2778
|
* export samples the same path the editor showed. */
|
|
2683
2779
|
private sceneClock = 0
|
|
@@ -2699,8 +2795,6 @@ export class Engine {
|
|
|
2699
2795
|
/** Trail samples owed this frame, computed once so every trail on every
|
|
2700
2796
|
* character samples in lockstep and their paths stay comparable. */
|
|
2701
2797
|
private trailDue = 0
|
|
2702
|
-
private agxLutTexture: GPUTexture | null = null
|
|
2703
|
-
private agxFallbackTexture!: GPUTexture
|
|
2704
2798
|
/** The scene's own grade (setStageGrade): the cube as handed over, kept so a
|
|
2705
2799
|
* call before init() still lands, and its texture once uploaded. */
|
|
2706
2800
|
private stageGradeCube: StageGradeLut | null = null
|
|
@@ -2714,12 +2808,8 @@ export class Engine {
|
|
|
2714
2808
|
* `time` is measured from here — see where it is written. */
|
|
2715
2809
|
private compositeBloomView: GPUTextureView | null = null
|
|
2716
2810
|
|
|
2717
|
-
//
|
|
2718
|
-
//
|
|
2719
|
-
// N-1 downsamples (13-tap Jimenez/COD box filter, 5 group averages)
|
|
2720
|
-
// N-1 upsamples (9-tap tent, additively combined with corresponding downsample mip)
|
|
2721
|
-
// composite adds bloomUp mip 0 × (color × intensity) to HDR before Filmic.
|
|
2722
|
-
// Matches EEVEE energy: tint/intensity applied at composite, not prefilter.
|
|
2811
|
+
// Linear clamp sampler for the bloom chain (also the mirror blur and the
|
|
2812
|
+
// composite's bloom read).
|
|
2723
2813
|
private bloomSampler!: GPUSampler
|
|
2724
2814
|
// Screen-space subsurface scattering (passes/subsurface.ts). The scratch target
|
|
2725
2815
|
// is the X pass's output and lives with the canvas size; the bind groups name
|
|
@@ -2737,33 +2827,56 @@ export class Engine {
|
|
|
2737
2827
|
private readonly pipelineDescs = new WeakMap<GPURenderPipeline, GPURenderPipelineDescriptor>()
|
|
2738
2828
|
private readonly singleSided = new WeakMap<GPURenderPipeline, GPURenderPipeline>()
|
|
2739
2829
|
private readonly singleSidedMirror = new WeakMap<GPURenderPipeline, GPURenderPipeline>()
|
|
2830
|
+
/** Twins whose async build is in flight, so a draw asks for each once. */
|
|
2831
|
+
private readonly sidedPending = new WeakSet<GPURenderPipeline>()
|
|
2832
|
+
private readonly sidedPendingMirror = new WeakSet<GPURenderPipeline>()
|
|
2833
|
+
// The engine-wide shader cache (see cachedRenderPipeline). Modules by WGSL
|
|
2834
|
+
// source, pipelines by module + descriptor state: one compile per source,
|
|
2835
|
+
// whoever asks — a graph on two models, an effect reinstalled, the composite.
|
|
2836
|
+
private readonly shaderModuleCache = new Map<string, GPUShaderModule>()
|
|
2837
|
+
private readonly pipelineCache = new Map<string, Promise<GPURenderPipeline | GPUComputePipeline>>()
|
|
2838
|
+
private readonly bindGroupLayoutCache = new Map<string, GPUBindGroupLayout>()
|
|
2839
|
+
private readonly pipelineLayoutCache = new Map<string, GPUPipelineLayout>()
|
|
2840
|
+
private readonly gpuObjectIds = new WeakMap<object, number>()
|
|
2841
|
+
private gpuObjectNext = 1
|
|
2842
|
+
/** Init's pipelines, built async and in parallel; init awaits them. Null
|
|
2843
|
+
* outside init, where a pipeline job builds the way it always did. */
|
|
2844
|
+
private initPipelineJobs: Promise<unknown>[] | null = null
|
|
2740
2845
|
// Stepped motion (#stepped). The models whose pose is being HELD this frame —
|
|
2741
2846
|
// their skinning and morph uploads wait for the stepped clock's next tick —
|
|
2742
2847
|
// and the tick each was last shown at. Empty while no effect declares it.
|
|
2743
2848
|
private readonly steppedHeld = new Set<string>()
|
|
2744
2849
|
private readonly steppedTick = new Map<string, number>()
|
|
2745
2850
|
private steppedLastClock = -1
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
private
|
|
2752
|
-
private
|
|
2753
|
-
private bloomBlitBindGroupLayout!: GPUBindGroupLayout
|
|
2754
|
-
private bloomDownsampleBindGroupLayout!: GPUBindGroupLayout
|
|
2755
|
-
private bloomUpsampleBindGroupLayout!: GPUBindGroupLayout
|
|
2756
|
-
private bloomDownTexture!: GPUTexture
|
|
2757
|
-
private bloomUpTexture!: GPUTexture
|
|
2758
|
-
private bloomMipCount = 0
|
|
2759
|
-
private bloomDownMipViews: GPUTextureView[] = []
|
|
2760
|
-
private bloomUpMipViews: GPUTextureView[] = []
|
|
2761
|
-
private bloomBlitBindGroup!: GPUBindGroup
|
|
2762
|
-
private bloomDownsampleBindGroups: GPUBindGroup[] = []
|
|
2763
|
-
private bloomUpsampleBindGroups: GPUBindGroup[] = []
|
|
2851
|
+
|
|
2852
|
+
// Bloom (shaders/passes/bloom.ts), Aether Gazer's chain. One level per halving
|
|
2853
|
+
// down to a few pixels (the game's count, up to 16), each a down/up pair;
|
|
2854
|
+
// up[i] holds the horizontal blur on the way down and the scatter blend on the
|
|
2855
|
+
// way up. The composite adds up[0] × (color × intensity) before the view transform.
|
|
2856
|
+
private bloomPrefilterBindGroupLayout!: GPUBindGroupLayout
|
|
2857
|
+
private bloomBlurBindGroupLayout!: GPUBindGroupLayout
|
|
2764
2858
|
/** Single-attachment pass; colorAttachments[0].view set per bloom step. */
|
|
2765
2859
|
private bloomPassDescriptor!: GPURenderPassDescriptor
|
|
2766
|
-
private
|
|
2860
|
+
private bloomPrefilterPipeline: GPURenderPipeline | null = null
|
|
2861
|
+
private bloomBlurHPipeline: GPURenderPipeline | null = null
|
|
2862
|
+
private bloomBlurVPipeline: GPURenderPipeline | null = null
|
|
2863
|
+
private bloomUpsamplePipeline: GPURenderPipeline | null = null
|
|
2864
|
+
private bloomUpsampleBindGroupLayout: GPUBindGroupLayout | null = null
|
|
2865
|
+
private bloomUniformBuffer: GPUBuffer | null = null
|
|
2866
|
+
private readonly bloomUniformData = new Float32Array(4)
|
|
2867
|
+
private bloomDownTexture: GPUTexture | null = null
|
|
2868
|
+
private bloomUpTexture: GPUTexture | null = null
|
|
2869
|
+
private bloomLevels = 0
|
|
2870
|
+
private bloomDownViews: GPUTextureView[] = []
|
|
2871
|
+
private bloomUpViews: GPUTextureView[] = []
|
|
2872
|
+
private bloomPrefilterBindGroup: GPUBindGroup | null = null
|
|
2873
|
+
private bloomBlurHBindGroups: GPUBindGroup[] = []
|
|
2874
|
+
private bloomBlurVBindGroups: GPUBindGroup[] = []
|
|
2875
|
+
private bloomUpsampleBindGroups: GPUBindGroup[] = []
|
|
2876
|
+
/** The game's prefilter clamps at 6550.4 — a tenth of the half-float ceiling
|
|
2877
|
+
* (its BloomPass _Params.y of 100 never reaches the shader). */
|
|
2878
|
+
private static readonly BLOOM_CLAMP = 6550.4
|
|
2879
|
+
private static readonly BLOOM_MAX_LEVELS = 16
|
|
2767
2880
|
|
|
2768
2881
|
// Ground properties (shadow only)
|
|
2769
2882
|
private groundVertexBuffer?: GPUBuffer
|
|
@@ -2775,12 +2888,6 @@ export class Engine {
|
|
|
2775
2888
|
/** The sun's shadow atlas: every cascade in its own tile (shadow-cascades.ts). */
|
|
2776
2889
|
private shadowAtlasTexture!: GPUTexture
|
|
2777
2890
|
private shadowAtlasView!: GPUTextureView
|
|
2778
|
-
private brdfLutTexture!: GPUTexture
|
|
2779
|
-
private brdfLutView!: GPUTextureView
|
|
2780
|
-
private filmicLutTexture!: GPUTexture
|
|
2781
|
-
private filmicLutView!: GPUTextureView
|
|
2782
|
-
// Width of the baked Filmic tone LUT (composite.ts FILMIC_LUT_W must match).
|
|
2783
|
-
private static readonly FILMIC_LUT_WIDTH = 256
|
|
2784
2891
|
private shadowDepthPipeline!: GPURenderPipeline
|
|
2785
2892
|
private shadowLightVPBuffer!: GPUBuffer
|
|
2786
2893
|
// The shadow PASS reads one cascade's matrix per pass, and a uniform binding
|
|
@@ -2922,18 +3029,16 @@ export class Engine {
|
|
|
2922
3029
|
this.backgroundColor = bg ? new Vec3(bg.x, bg.y, bg.z) : null
|
|
2923
3030
|
}
|
|
2924
3031
|
|
|
2925
|
-
/** Merge partial bloom with
|
|
3032
|
+
/** Merge partial bloom with the defaults (same as constructor). */
|
|
2926
3033
|
static mergeBloomDefaults(partial?: Partial<BloomOptions>): BloomOptions {
|
|
2927
3034
|
const d = DEFAULT_BLOOM_OPTIONS
|
|
2928
3035
|
const c = partial?.color
|
|
2929
3036
|
return {
|
|
2930
3037
|
enabled: partial?.enabled ?? d.enabled,
|
|
2931
3038
|
threshold: partial?.threshold ?? d.threshold,
|
|
2932
|
-
|
|
2933
|
-
radius: partial?.radius ?? d.radius,
|
|
3039
|
+
scatter: partial?.scatter ?? d.scatter,
|
|
2934
3040
|
color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
|
|
2935
3041
|
intensity: partial?.intensity ?? d.intensity,
|
|
2936
|
-
clamp: partial?.clamp ?? d.clamp,
|
|
2937
3042
|
}
|
|
2938
3043
|
}
|
|
2939
3044
|
|
|
@@ -2946,17 +3051,15 @@ export class Engine {
|
|
|
2946
3051
|
}
|
|
2947
3052
|
}
|
|
2948
3053
|
|
|
2949
|
-
/** Current bloom settings (
|
|
3054
|
+
/** Current bloom settings (tint is a copied `Vec3`). */
|
|
2950
3055
|
getBloomOptions(): BloomOptions {
|
|
2951
3056
|
const b = this.bloomSettings
|
|
2952
3057
|
return {
|
|
2953
3058
|
enabled: b.enabled,
|
|
2954
3059
|
threshold: b.threshold,
|
|
2955
|
-
|
|
2956
|
-
radius: b.radius,
|
|
3060
|
+
scatter: b.scatter,
|
|
2957
3061
|
color: new Vec3(b.color.x, b.color.y, b.color.z),
|
|
2958
3062
|
intensity: b.intensity,
|
|
2959
|
-
clamp: b.clamp,
|
|
2960
3063
|
}
|
|
2961
3064
|
}
|
|
2962
3065
|
|
|
@@ -3052,8 +3155,8 @@ export class Engine {
|
|
|
3052
3155
|
}
|
|
3053
3156
|
}
|
|
3054
3157
|
|
|
3055
|
-
/** Sensor grain: how much
|
|
3056
|
-
private grain = { amount: 0
|
|
3158
|
+
/** Sensor grain: how much. */
|
|
3159
|
+
private grain = { amount: 0 }
|
|
3057
3160
|
|
|
3058
3161
|
/**
|
|
3059
3162
|
* Film grain over the rendered scene, 0–1.
|
|
@@ -3063,19 +3166,14 @@ export class Engine {
|
|
|
3063
3166
|
* a background image or a backdrop video, which arrived with grain of their
|
|
3064
3167
|
* own and would be graded rather than matched by a second helping.
|
|
3065
3168
|
*
|
|
3066
|
-
* `animated` false freezes it. A still photograph's grain does not move, and
|
|
3067
|
-
* noise crawling over a frozen picture makes the rendering look more alive
|
|
3068
|
-
* than the thing it is standing in.
|
|
3069
|
-
*
|
|
3070
3169
|
* Costs one hash per pixel in a pass that already runs, and nothing at all at
|
|
3071
3170
|
* zero — the branch is on a uniform.
|
|
3072
3171
|
*/
|
|
3073
|
-
setFilmGrain(amount: number
|
|
3172
|
+
setFilmGrain(amount: number): void {
|
|
3074
3173
|
this.grain.amount = Math.min(Math.max(amount, 0), 1)
|
|
3075
|
-
this.grain.animated = animated
|
|
3076
3174
|
if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
|
|
3077
3175
|
}
|
|
3078
|
-
getFilmGrain(): Readonly<{ amount: number
|
|
3176
|
+
getFilmGrain(): Readonly<{ amount: number }> {
|
|
3079
3177
|
return this.grain
|
|
3080
3178
|
}
|
|
3081
3179
|
|
|
@@ -3110,6 +3208,14 @@ export class Engine {
|
|
|
3110
3208
|
return b.enabled && b.intensity > 0
|
|
3111
3209
|
}
|
|
3112
3210
|
|
|
3211
|
+
/** viewU[6].y per transform — see viewTransform in composite.ts. */
|
|
3212
|
+
private static readonly VIEW_TRANSFORM_ID: Record<string, number> = {
|
|
3213
|
+
soft: 0,
|
|
3214
|
+
none: 1,
|
|
3215
|
+
neutral: 2,
|
|
3216
|
+
aces: 3,
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3113
3219
|
private writeCompositeViewUniforms(): void {
|
|
3114
3220
|
const v = this.viewTransform
|
|
3115
3221
|
const b = this.bloomSettings
|
|
@@ -3121,10 +3227,8 @@ export class Engine {
|
|
|
3121
3227
|
// a uniform branch that skips the pow entirely in the common case.
|
|
3122
3228
|
u[1] = 1.0 / Math.max(v.gamma, 1e-4)
|
|
3123
3229
|
u[2] = this.grain.amount
|
|
3124
|
-
// The seed
|
|
3125
|
-
|
|
3126
|
-
// look more alive than the footage — the opposite of the point.
|
|
3127
|
-
u[3] = this.grain.animated ? Math.floor(this.sceneClock * 24) % 1024 : 0
|
|
3230
|
+
// The seed, stepped at 24 a second like a film gate.
|
|
3231
|
+
u[3] = Math.floor(this.sceneClock * 24) % 1024
|
|
3128
3232
|
u[4] = b.color.x
|
|
3129
3233
|
u[5] = b.color.y
|
|
3130
3234
|
u[6] = b.color.z
|
|
@@ -3136,7 +3240,7 @@ export class Engine {
|
|
|
3136
3240
|
// exposure and view transform as the scene — a sun rolls off like a sun).
|
|
3137
3241
|
// The camera basis at u[12..23] is refreshed per frame.
|
|
3138
3242
|
// In modes 2 and 3 the colour slot is dead, so mode 3 carries the world
|
|
3139
|
-
// STRENGTH in u[8] —
|
|
3243
|
+
// STRENGTH in u[8] — the World's intensity, the SAME number that
|
|
3140
3244
|
// scales the irradiance. A sky you can see at full brightness while it
|
|
3141
3245
|
// lights at two thirds is two skies.
|
|
3142
3246
|
const bg = this.backgroundColor
|
|
@@ -3152,7 +3256,7 @@ export class Engine {
|
|
|
3152
3256
|
// rebuilt per effect, so the compiled variant IS the flag.
|
|
3153
3257
|
u[11] = this.backdropEquirectView ? 2 : showingWorld ? 3 : bg ? 1 : 0
|
|
3154
3258
|
// Which display transform forms the frame (see viewTransform in composite.ts).
|
|
3155
|
-
u[25] =
|
|
3259
|
+
u[25] = Engine.VIEW_TRANSFORM_ID[v.transform] ?? 0
|
|
3156
3260
|
u[26] = this.canvas.width
|
|
3157
3261
|
u[27] = this.canvas.height
|
|
3158
3262
|
// ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
|
|
@@ -3882,7 +3986,7 @@ export class Engine {
|
|
|
3882
3986
|
}
|
|
3883
3987
|
|
|
3884
3988
|
/**
|
|
3885
|
-
* Fill the mirror's mip levels —
|
|
3989
|
+
* Fill the mirror's mip levels — a 13-tap downsample (MIRROR_DOWNSAMPLE_WGSL),
|
|
3886
3990
|
* one pass per level. Only when the blur dial is up: at zero the ground
|
|
3887
3991
|
* samples level 0 exactly and the chain would be work nobody reads.
|
|
3888
3992
|
*/
|
|
@@ -3892,8 +3996,19 @@ export class Engine {
|
|
|
3892
3996
|
// memory, which read as the model going black the moment the slider moved.
|
|
3893
3997
|
const blur = Math.max(this.groundMirrorBlur, this.mirrorSurface?.blur ?? 0)
|
|
3894
3998
|
if (blur <= 0 || this.mirrorMipCount < 2) return
|
|
3999
|
+
if (!this.mirrorDownsamplePipeline) {
|
|
4000
|
+
const module = this.device.createShaderModule({ label: "mirror downsample", code: MIRROR_DOWNSAMPLE_WGSL })
|
|
4001
|
+
this.mirrorDownsamplePipeline = this.device.createRenderPipeline({
|
|
4002
|
+
label: "mirror downsample pipeline",
|
|
4003
|
+
layout: "auto",
|
|
4004
|
+
vertex: { module, entryPoint: "vs" },
|
|
4005
|
+
fragment: { module, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
4006
|
+
primitive: { topology: "triangle-list" },
|
|
4007
|
+
})
|
|
4008
|
+
}
|
|
4009
|
+
const downsample = this.mirrorDownsamplePipeline
|
|
3895
4010
|
if (!this.mirrorBlurBindGroups) {
|
|
3896
|
-
const layout =
|
|
4011
|
+
const layout = downsample.getBindGroupLayout(0)
|
|
3897
4012
|
this.mirrorBlurBindGroups = []
|
|
3898
4013
|
for (let i = 1; i < this.mirrorMipCount; i++) {
|
|
3899
4014
|
this.mirrorBlurBindGroups.push(
|
|
@@ -3913,7 +4028,7 @@ export class Engine {
|
|
|
3913
4028
|
label: `mirror blur ${i}`,
|
|
3914
4029
|
colorAttachments: [{ view: this.mirrorMipViews[i], loadOp: "clear", storeOp: "store" }],
|
|
3915
4030
|
})
|
|
3916
|
-
p.setPipeline(
|
|
4031
|
+
p.setPipeline(downsample)
|
|
3917
4032
|
p.setBindGroup(0, this.mirrorBlurBindGroups[i - 1])
|
|
3918
4033
|
p.draw(3)
|
|
3919
4034
|
p.end()
|
|
@@ -4081,7 +4196,6 @@ export class Engine {
|
|
|
4081
4196
|
{ binding: 2, resource: this.bloomSampler },
|
|
4082
4197
|
{ binding: 3, resource: { buffer: this.compositeUniformBuffer } },
|
|
4083
4198
|
{ binding: 4, resource: this.maskResolveView },
|
|
4084
|
-
{ binding: 5, resource: this.filmicLutView },
|
|
4085
4199
|
// Whichever equirect is SHOWING — the backdrop if there is one, the
|
|
4086
4200
|
// world otherwise. The world's light does not come through here; it
|
|
4087
4201
|
// rides worldSH into the material shells.
|
|
@@ -4089,7 +4203,6 @@ export class Engine {
|
|
|
4089
4203
|
{ binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
|
|
4090
4204
|
{ binding: 8, resource: this.depthReadView },
|
|
4091
4205
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
4092
|
-
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
4093
4206
|
{ binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
|
|
4094
4207
|
{ binding: 11, resource: { buffer: this.castBuffer } },
|
|
4095
4208
|
{ binding: 13, resource: { buffer: this.audioBuffer } },
|
|
@@ -4527,8 +4640,6 @@ export class Engine {
|
|
|
4527
4640
|
{ binding: 31, resource: layers[2] },
|
|
4528
4641
|
{ binding: 32, resource: layers[3] },
|
|
4529
4642
|
{ binding: 2, resource: this.bloomSampler },
|
|
4530
|
-
{ binding: 5, resource: this.filmicLutView },
|
|
4531
|
-
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
4532
4643
|
{ binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
|
|
4533
4644
|
{ binding: 1, resource: bloom },
|
|
4534
4645
|
{ binding: 4, resource: this.maskResolveView },
|
|
@@ -4650,7 +4761,7 @@ export class Engine {
|
|
|
4650
4761
|
* whether or not it is the thing you see — and it IS the thing you see until
|
|
4651
4762
|
* a backdrop is set, which is what an HDRI on its own has always done.
|
|
4652
4763
|
*
|
|
4653
|
-
* `strength` is
|
|
4764
|
+
* `strength` is the World's intensity and is folded into the
|
|
4654
4765
|
* coefficients, so what lights her is what you see.
|
|
4655
4766
|
*/
|
|
4656
4767
|
setWorldEquirect(source: HdrImage | null): void {
|
|
@@ -4717,8 +4828,11 @@ export class Engine {
|
|
|
4717
4828
|
[w, h],
|
|
4718
4829
|
)
|
|
4719
4830
|
}
|
|
4720
|
-
|
|
4721
|
-
|
|
4831
|
+
// The box chain above is the SOURCE: the levels the materials read are
|
|
4832
|
+
// the GGX convolution of it, one roughness per level (prefilterSky).
|
|
4833
|
+
const filtered = this.prefilterSky(tex, source.width, source.height, levels)
|
|
4834
|
+
this.worldEquirectTexture = filtered
|
|
4835
|
+
this.worldEquirectView = filtered.createView()
|
|
4722
4836
|
// The sky lights the scene, not only backs it. The sun keeps the toon
|
|
4723
4837
|
// ramp — this is the ambient term, exactly where the flat world colour
|
|
4724
4838
|
// used to sit.
|
|
@@ -4738,6 +4852,9 @@ export class Engine {
|
|
|
4738
4852
|
this.worldBindingsDirty = true
|
|
4739
4853
|
this.rebuildPerFrameBindGroups()
|
|
4740
4854
|
this.rebuildCompositeBindGroup()
|
|
4855
|
+
// The probe's compose step binds the sky it lays in behind the stage.
|
|
4856
|
+
this.dropProbeCapture()
|
|
4857
|
+
this.captureReflectionProbe()
|
|
4741
4858
|
if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
|
|
4742
4859
|
}
|
|
4743
4860
|
|
|
@@ -4790,21 +4907,38 @@ export class Engine {
|
|
|
4790
4907
|
if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
|
|
4791
4908
|
}
|
|
4792
4909
|
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4910
|
+
/**
|
|
4911
|
+
* The scene composite's pair (gamma = 1, gamma ≠ 1), async and from the
|
|
4912
|
+
* shader cache. Its source depends only on which field mounts the scene
|
|
4913
|
+
* samples, so there are four at most, and an effect install that keeps the
|
|
4914
|
+
* mounts it had gets the pair already built.
|
|
4915
|
+
*/
|
|
4916
|
+
private compositePipelines(hasBackground: boolean, hasForeground: boolean): Promise<[GPURenderPipeline, GPURenderPipeline]> {
|
|
4917
|
+
const code = buildCompositeShader(
|
|
4918
|
+
hasBackground || hasForeground
|
|
4919
|
+
? // No wgsl and so no trails: the composite hosts no effect source at
|
|
4920
|
+
// all, it only decides whether to sample the layer the field pass drew.
|
|
4921
|
+
{ wgsl: "", paramsDecl: "", hasBackground, hasForeground, gridSize: 0, trailCount: 0 }
|
|
4922
|
+
: null,
|
|
4923
|
+
)
|
|
4924
|
+
const module = this.cachedShaderModule(code, "composite shader")
|
|
4925
|
+
const make = (applyGamma: boolean, label: string) =>
|
|
4926
|
+
this.cachedRenderPipeline({
|
|
4927
|
+
label,
|
|
4928
|
+
layout: this.compositePipelineLayout,
|
|
4929
|
+
vertex: { module, entryPoint: "vs" },
|
|
4930
|
+
fragment: {
|
|
4931
|
+
module,
|
|
4932
|
+
entryPoint: "fs",
|
|
4933
|
+
constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
|
|
4934
|
+
targets: [{ format: this.presentationFormat }],
|
|
4935
|
+
},
|
|
4936
|
+
primitive: { topology: "triangle-list" },
|
|
4937
|
+
})
|
|
4938
|
+
return Promise.all([make(false, "composite pipeline (gamma=1)"), make(true, "composite pipeline (gamma!=1)")])
|
|
4806
4939
|
}
|
|
4807
4940
|
|
|
4941
|
+
|
|
4808
4942
|
/**
|
|
4809
4943
|
* Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
|
|
4810
4944
|
* composite pass. ONE effect per scene, and the code says where it mounts by
|
|
@@ -4847,7 +4981,7 @@ export class Engine {
|
|
|
4847
4981
|
params: Record<string, EffectParamValue> | undefined,
|
|
4848
4982
|
/** This effect's own declarations, already parsed by the caller — which had
|
|
4849
4983
|
* to read them anyway to build the scene table. */
|
|
4850
|
-
anchors: { bone: string; trail: boolean }[],
|
|
4984
|
+
anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
|
|
4851
4985
|
/** Its row of that table: local slot → scene slot. */
|
|
4852
4986
|
alias: number[],
|
|
4853
4987
|
/** The pictures for `#textures`, as the host passed them. */
|
|
@@ -5042,11 +5176,11 @@ export class Engine {
|
|
|
5042
5176
|
hasBackground || hasForeground
|
|
5043
5177
|
? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length, filter, additiveLayer: d.additiveLayer }
|
|
5044
5178
|
: null
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5179
|
+
// No composite is built here. One used to be — a module and two pipelines
|
|
5180
|
+
// per effect, then dropped: the composite is static (no user code reaches
|
|
5181
|
+
// it) and setEffects builds the scene's one after the list is in. Those
|
|
5182
|
+
// two compiles were most of a second each and validated nothing an author
|
|
5183
|
+
// wrote.
|
|
5050
5184
|
|
|
5051
5185
|
// Declared like every other mount property: by what the source says, not by
|
|
5052
5186
|
// a setting somewhere else that an author cannot see from the file.
|
|
@@ -5058,16 +5192,11 @@ export class Engine {
|
|
|
5058
5192
|
const fieldSource = buildFieldShader({ ...fieldEffect, ids: mrtIdsEnabled() })
|
|
5059
5193
|
const userLineOffset = fieldSource.slice(0, fieldSource.indexOf(wgsl)).split("\n").length - 1
|
|
5060
5194
|
this.device.pushErrorScope("validation")
|
|
5061
|
-
const fieldModule = this.
|
|
5062
|
-
const
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
.map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
|
|
5067
|
-
if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
|
|
5068
|
-
if (diagnostics.length > 0) return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
5069
|
-
try {
|
|
5070
|
-
fieldPipeline = await this.device.createRenderPipelineAsync({
|
|
5195
|
+
const fieldModule = this.cachedShaderModule(fieldSource, "field shader (effect)")
|
|
5196
|
+
const fieldScope = this.device.popErrorScope()
|
|
5197
|
+
// Started before the diagnostics are read, which only decide whether it
|
|
5198
|
+
// is kept: the compile and the check overlap.
|
|
5199
|
+
const fieldBuild = this.cachedRenderPipeline({
|
|
5071
5200
|
label: "field layer pipeline",
|
|
5072
5201
|
layout: this.fieldPipelineLayout,
|
|
5073
5202
|
vertex: { module: fieldModule, entryPoint: "fieldVs" },
|
|
@@ -5095,33 +5224,22 @@ export class Engine {
|
|
|
5095
5224
|
primitive: { topology: "triangle-list" },
|
|
5096
5225
|
multisample: { count: 1 },
|
|
5097
5226
|
})
|
|
5227
|
+
fieldBuild.catch(() => {})
|
|
5228
|
+
const [info, fieldScopeErr] = await Promise.all([fieldModule.getCompilationInfo(), fieldScope])
|
|
5229
|
+
const diagnostics = info.messages
|
|
5230
|
+
.filter((m) => m.type === "error")
|
|
5231
|
+
.map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
|
|
5232
|
+
if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
|
|
5233
|
+
if (diagnostics.length > 0) {
|
|
5234
|
+
this.forgetShaderModule(fieldSource)
|
|
5235
|
+
return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
5236
|
+
}
|
|
5237
|
+
try {
|
|
5238
|
+
fieldPipeline = await fieldBuild
|
|
5098
5239
|
} catch (e) {
|
|
5099
5240
|
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
5100
5241
|
}
|
|
5101
5242
|
}
|
|
5102
|
-
let identity: GPURenderPipeline
|
|
5103
|
-
let gamma: GPURenderPipeline
|
|
5104
|
-
try {
|
|
5105
|
-
const make = (applyGamma: boolean, label: string) =>
|
|
5106
|
-
this.device.createRenderPipelineAsync({
|
|
5107
|
-
label,
|
|
5108
|
-
layout: this.compositePipelineLayout,
|
|
5109
|
-
vertex: { module, entryPoint: "vs" },
|
|
5110
|
-
fragment: {
|
|
5111
|
-
module,
|
|
5112
|
-
entryPoint: "fs",
|
|
5113
|
-
constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
|
|
5114
|
-
targets: [{ format: this.presentationFormat }],
|
|
5115
|
-
},
|
|
5116
|
-
primitive: { topology: "triangle-list" },
|
|
5117
|
-
})
|
|
5118
|
-
;[identity, gamma] = await Promise.all([
|
|
5119
|
-
make(false, "composite pipeline (effect, gamma=1)"),
|
|
5120
|
-
make(true, "composite pipeline (effect, gamma!=1)"),
|
|
5121
|
-
])
|
|
5122
|
-
} catch (e) {
|
|
5123
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
5124
|
-
}
|
|
5125
5243
|
|
|
5126
5244
|
// Built BEFORE the swap: a particle stage that fails to compile has to leave
|
|
5127
5245
|
// the previously installed effect running, exactly as a bad composite does.
|
|
@@ -5365,6 +5483,9 @@ export class Engine {
|
|
|
5365
5483
|
|
|
5366
5484
|
const requested = list ?? []
|
|
5367
5485
|
if (requested.length === 0) {
|
|
5486
|
+
// Built (or found) before anything is torn down, so no frame draws
|
|
5487
|
+
// between the old effects and the composite that no longer samples them.
|
|
5488
|
+
const [identity, gamma] = await this.compositePipelines(false, false)
|
|
5368
5489
|
for (const e of this.effects) {
|
|
5369
5490
|
e.paramsBuffer?.destroy()
|
|
5370
5491
|
e.lights?.uniform.destroy()
|
|
@@ -5377,9 +5498,8 @@ export class Engine {
|
|
|
5377
5498
|
this.allocateLightSlots()
|
|
5378
5499
|
this.anchorTable = EMPTY_ANCHOR_TABLE
|
|
5379
5500
|
this.clearTrailHistory()
|
|
5380
|
-
|
|
5381
|
-
this.
|
|
5382
|
-
this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)")
|
|
5501
|
+
this.compositePipelineIdentity = identity
|
|
5502
|
+
this.compositePipelineGamma = gamma
|
|
5383
5503
|
this.rebuildCompositeBindGroup()
|
|
5384
5504
|
this.writeCompositeViewUniforms()
|
|
5385
5505
|
return []
|
|
@@ -5435,6 +5555,14 @@ export class Engine {
|
|
|
5435
5555
|
if (r) r.diagnostics.push(`anchor "${d.bone}" dropped: the scene is already using all ${MAX_EFFECT_ANCHORS} slots`)
|
|
5436
5556
|
}
|
|
5437
5557
|
|
|
5558
|
+
// The composite for the new list, built before the swap below so the swap
|
|
5559
|
+
// stays one synchronous step. From the shader cache: it was rebuilt, and
|
|
5560
|
+
// synchronously, on every install.
|
|
5561
|
+
const [compositeIdentity, compositeGamma] = await this.compositePipelines(
|
|
5562
|
+
instances.some((e) => e.hasBackground),
|
|
5563
|
+
instances.some((e) => e.hasForeground),
|
|
5564
|
+
)
|
|
5565
|
+
|
|
5438
5566
|
// ── Swap. Everything above either succeeded or was excluded, so the scene
|
|
5439
5567
|
// that was running is only torn down now.
|
|
5440
5568
|
for (const e of this.effects) {
|
|
@@ -5486,20 +5614,8 @@ export class Engine {
|
|
|
5486
5614
|
// Scene-level, from the union: the composite decides only whether to SAMPLE
|
|
5487
5615
|
// the field layers, so one effect with a background is enough to turn that
|
|
5488
5616
|
// on for the frame.
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
const compositeModule = this.device.createShaderModule({
|
|
5492
|
-
label: "composite shader (effects)",
|
|
5493
|
-
code: buildCompositeShader(
|
|
5494
|
-
hasBackground || hasForeground
|
|
5495
|
-
? // No wgsl and so no trails: the composite hosts no effect source at
|
|
5496
|
-
// all, it only decides whether to sample the layer the field pass drew.
|
|
5497
|
-
{ wgsl: "", paramsDecl: "", hasBackground, hasForeground, gridSize: 0, trailCount: 0 }
|
|
5498
|
-
: null,
|
|
5499
|
-
),
|
|
5500
|
-
})
|
|
5501
|
-
this.compositePipelineIdentity = this.makeCompositePipeline(compositeModule, false, "composite pipeline (gamma=1)")
|
|
5502
|
-
this.compositePipelineGamma = this.makeCompositePipeline(compositeModule, true, "composite pipeline (gamma!=1)")
|
|
5617
|
+
this.compositePipelineIdentity = compositeIdentity
|
|
5618
|
+
this.compositePipelineGamma = compositeGamma
|
|
5503
5619
|
|
|
5504
5620
|
// Nothing to promote any more: `#fullres` is per effect, read into
|
|
5505
5621
|
// fieldLayer when the instance is built, and both target pairs exist for
|
|
@@ -5537,7 +5653,7 @@ export class Engine {
|
|
|
5537
5653
|
/** What the file declared. Read here rather than re-parsed: the source no
|
|
5538
5654
|
* longer carries the lines, and two readers is how they drift. */
|
|
5539
5655
|
d: EffectDirectives,
|
|
5540
|
-
anchors: { bone: string; trail: boolean }[],
|
|
5656
|
+
anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
|
|
5541
5657
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
5542
5658
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
5543
5659
|
* describes the effect that is still on screen. */
|
|
@@ -5596,26 +5712,32 @@ export class Engine {
|
|
|
5596
5712
|
alias,
|
|
5597
5713
|
}
|
|
5598
5714
|
|
|
5715
|
+
// The scope is popped before the first await: compiles run side by side
|
|
5716
|
+
// (both modules here, other effects, style groups), and a scope held open
|
|
5717
|
+
// across an await would catch whichever of them errored first.
|
|
5599
5718
|
const compile = async (code: string, label: string): Promise<GPUShaderModule | string[]> => {
|
|
5600
5719
|
const offset = code.slice(0, code.indexOf(wgsl)).split("\n").length - 1
|
|
5601
5720
|
this.device.pushErrorScope("validation")
|
|
5602
|
-
const module = this.
|
|
5603
|
-
const
|
|
5604
|
-
const scopeErr = await
|
|
5721
|
+
const module = this.cachedShaderModule(code, label)
|
|
5722
|
+
const scope = this.device.popErrorScope()
|
|
5723
|
+
const [info, scopeErr] = await Promise.all([module.getCompilationInfo(), scope])
|
|
5605
5724
|
const diagnostics = info.messages
|
|
5606
5725
|
.filter((m) => m.type === "error")
|
|
5607
5726
|
.map((m) => `${Math.max(0, m.lineNum - offset)}:${m.linePos} ${m.message}`)
|
|
5608
5727
|
if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
|
|
5728
|
+
if (diagnostics.length) this.forgetShaderModule(code)
|
|
5609
5729
|
return diagnostics.length ? diagnostics : module
|
|
5610
5730
|
}
|
|
5611
5731
|
|
|
5612
|
-
const computeModule = await
|
|
5732
|
+
const [computeModule, renderModule] = await Promise.all([
|
|
5733
|
+
compile(buildParticleComputeShader(src, cast), "particle compute"),
|
|
5734
|
+
compile(buildParticleRenderShader(src, cast), "particle render"),
|
|
5735
|
+
])
|
|
5613
5736
|
if (Array.isArray(computeModule)) {
|
|
5614
5737
|
points?.buffer.destroy()
|
|
5615
5738
|
dropTextures()
|
|
5616
5739
|
return { ok: false, diagnostics: computeModule }
|
|
5617
5740
|
}
|
|
5618
|
-
const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render")
|
|
5619
5741
|
if (Array.isArray(renderModule)) {
|
|
5620
5742
|
points?.buffer.destroy()
|
|
5621
5743
|
dropTextures()
|
|
@@ -5656,7 +5778,7 @@ export class Engine {
|
|
|
5656
5778
|
// layout invalid, and the error surfaces later and unhelpfully as "invalid
|
|
5657
5779
|
// due to a previous error".
|
|
5658
5780
|
const layoutFor = (storage: GPUBufferBindingType, visibility: number, shadow: boolean, withIndirect: boolean) =>
|
|
5659
|
-
this.
|
|
5781
|
+
this.cachedBindGroupLayout({
|
|
5660
5782
|
entries: [
|
|
5661
5783
|
{ binding: 0, visibility, buffer: { type: storage } },
|
|
5662
5784
|
{ binding: 1, visibility, buffer: { type: "uniform" } },
|
|
@@ -5768,32 +5890,29 @@ export class Engine {
|
|
|
5768
5890
|
const targets = sceneTargetsFor(src.blend === "additive" ? "particle-additive" : "particle", this.sceneFormats)
|
|
5769
5891
|
const cutout = src.blend === "cutout"
|
|
5770
5892
|
|
|
5893
|
+
// ALL STAGES AT ONCE: compute, count, prepass and shading compile side by
|
|
5894
|
+
// side rather than in turn. The scope closes before the first await, for
|
|
5895
|
+
// the reason compile() gives.
|
|
5771
5896
|
this.device.pushErrorScope("validation")
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5897
|
+
const renderLayoutObj = this.cachedPipelineLayout([renderLayout])
|
|
5898
|
+
const builds = Promise.all([
|
|
5899
|
+
this.cachedComputePipeline({
|
|
5775
5900
|
label: "particle compute pipeline",
|
|
5776
|
-
layout:
|
|
5901
|
+
layout: this.cachedPipelineLayout([computeLayout]),
|
|
5777
5902
|
compute: { module: computeModule, entryPoint: "main" },
|
|
5778
|
-
})
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
}),
|
|
5787
|
-
indirect,
|
|
5788
|
-
binds: bindFor(countLayout, this.cameraUniformBuffer, false, true),
|
|
5789
|
-
}
|
|
5790
|
-
: null
|
|
5791
|
-
const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] })
|
|
5903
|
+
}),
|
|
5904
|
+
indirect && countLayout
|
|
5905
|
+
? this.cachedComputePipeline({
|
|
5906
|
+
label: "particle count pipeline",
|
|
5907
|
+
layout: this.cachedPipelineLayout([countLayout]),
|
|
5908
|
+
compute: { module: computeModule, entryPoint: "rzCount" },
|
|
5909
|
+
})
|
|
5910
|
+
: null,
|
|
5792
5911
|
// The prepass, when the effect gave the cutout a cheap shape: coverage to
|
|
5793
5912
|
// depth, colour targets at writeMask 0 (the depth-prepass class), and
|
|
5794
5913
|
// the shading pass below then tests EQUAL and writes no depth.
|
|
5795
|
-
|
|
5796
|
-
?
|
|
5914
|
+
prepass
|
|
5915
|
+
? this.cachedRenderPipeline({
|
|
5797
5916
|
label: "particle depth prepass pipeline",
|
|
5798
5917
|
layout: renderLayoutObj,
|
|
5799
5918
|
vertex: { module: renderModule, entryPoint: "vs" },
|
|
@@ -5802,8 +5921,8 @@ export class Engine {
|
|
|
5802
5921
|
depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
|
|
5803
5922
|
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
5804
5923
|
})
|
|
5805
|
-
: null
|
|
5806
|
-
|
|
5924
|
+
: null,
|
|
5925
|
+
this.cachedRenderPipeline({
|
|
5807
5926
|
label: "particle render pipeline",
|
|
5808
5927
|
layout: renderLayoutObj,
|
|
5809
5928
|
vertex: { module: renderModule, entryPoint: "vs" },
|
|
@@ -5822,10 +5941,26 @@ export class Engine {
|
|
|
5822
5941
|
// shading ten layers and shading about three.
|
|
5823
5942
|
depthStencil: prepass
|
|
5824
5943
|
? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
|
|
5825
|
-
:
|
|
5944
|
+
: d.depthAlways
|
|
5945
|
+
? // `#depth always`: over the scene, Unity's ZTest Always (a moon's halo
|
|
5946
|
+
// drawn in front of the sky dome it sits behind)
|
|
5947
|
+
{ format: this.depthFormat, depthWriteEnabled: false, depthCompare: "always" }
|
|
5948
|
+
: { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
|
|
5826
5949
|
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
5827
|
-
})
|
|
5828
|
-
|
|
5950
|
+
}),
|
|
5951
|
+
])
|
|
5952
|
+
const scope = this.device.popErrorScope()
|
|
5953
|
+
try {
|
|
5954
|
+
const [compute, countPipeline, depth, render] = await builds
|
|
5955
|
+
const live =
|
|
5956
|
+
indirect && countLayout && countPipeline
|
|
5957
|
+
? {
|
|
5958
|
+
pipeline: countPipeline,
|
|
5959
|
+
indirect,
|
|
5960
|
+
binds: bindFor(countLayout, this.cameraUniformBuffer, false, true),
|
|
5961
|
+
}
|
|
5962
|
+
: null
|
|
5963
|
+
const scoped = await scope
|
|
5829
5964
|
if (scoped) {
|
|
5830
5965
|
buffer.destroy()
|
|
5831
5966
|
uniform.destroy()
|
|
@@ -5865,7 +6000,7 @@ export class Engine {
|
|
|
5865
6000
|
},
|
|
5866
6001
|
}
|
|
5867
6002
|
} catch (e) {
|
|
5868
|
-
await
|
|
6003
|
+
await scope
|
|
5869
6004
|
buffer.destroy()
|
|
5870
6005
|
uniform.destroy()
|
|
5871
6006
|
points?.buffer.destroy()
|
|
@@ -5894,7 +6029,7 @@ export class Engine {
|
|
|
5894
6029
|
wgsl: string,
|
|
5895
6030
|
count: number,
|
|
5896
6031
|
alias: number[],
|
|
5897
|
-
anchors: { bone: string; trail: boolean }[],
|
|
6032
|
+
anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
|
|
5898
6033
|
params: EffectParamsBinding,
|
|
5899
6034
|
): Promise<{ ok: true; state: NonNullable<EffectInstance["lights"]> } | { ok: false; diagnostics: string[] }> {
|
|
5900
6035
|
const layout = this.device.createBindGroupLayout({
|
|
@@ -6148,7 +6283,7 @@ export class Engine {
|
|
|
6148
6283
|
/** What the file declared. Read here rather than re-parsed: the source no
|
|
6149
6284
|
* longer carries the lines, and two readers is how they drift. */
|
|
6150
6285
|
d: EffectDirectives,
|
|
6151
|
-
anchors: { bone: string; trail: boolean }[],
|
|
6286
|
+
anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
|
|
6152
6287
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
6153
6288
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
6154
6289
|
* describes the effect that is still on screen. */
|
|
@@ -6238,8 +6373,10 @@ export class Engine {
|
|
|
6238
6373
|
// point: a layer composited after tone mapping can never bloom.
|
|
6239
6374
|
//
|
|
6240
6375
|
// Additive, where this used to be MAX. Max was right for a post-tonemap
|
|
6241
|
-
// layer; in HDR before bloom, overlapping light sums.
|
|
6242
|
-
|
|
6376
|
+
// layer; in HDR before bloom, overlapping light sums. `#blend over` lays the
|
|
6377
|
+
// ribbon over the scene by its alpha instead — a game trail's own blend,
|
|
6378
|
+
// under which its dark smoke darkens rather than vanishing.
|
|
6379
|
+
const targets = sceneTargetsFor(d.trailBlend === "over" ? "trail-over" : "trail", this.sceneFormats)
|
|
6243
6380
|
this.device.pushErrorScope("validation")
|
|
6244
6381
|
try {
|
|
6245
6382
|
const pipeline = await this.device.createRenderPipelineAsync({
|
|
@@ -6657,7 +6794,7 @@ export class Engine {
|
|
|
6657
6794
|
/** What the file declared. Read here rather than re-parsed: the source no
|
|
6658
6795
|
* longer carries the lines, and two readers is how they drift. */
|
|
6659
6796
|
d: EffectDirectives,
|
|
6660
|
-
anchors: { bone: string; trail: boolean }[],
|
|
6797
|
+
anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
|
|
6661
6798
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
6662
6799
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
6663
6800
|
* describes the effect that is still on screen. */
|
|
@@ -7120,41 +7257,172 @@ export class Engine {
|
|
|
7120
7257
|
const b = this.bloomSettings
|
|
7121
7258
|
if (patch.enabled !== undefined) b.enabled = patch.enabled
|
|
7122
7259
|
if (patch.threshold !== undefined) b.threshold = patch.threshold
|
|
7123
|
-
if (patch.
|
|
7124
|
-
if (patch.radius !== undefined) b.radius = patch.radius
|
|
7260
|
+
if (patch.scatter !== undefined) b.scatter = patch.scatter
|
|
7125
7261
|
if (patch.color !== undefined) {
|
|
7126
7262
|
b.color.x = patch.color.x
|
|
7127
7263
|
b.color.y = patch.color.y
|
|
7128
7264
|
b.color.z = patch.color.z
|
|
7129
7265
|
}
|
|
7130
7266
|
if (patch.intensity !== undefined) b.intensity = patch.intensity
|
|
7131
|
-
if (
|
|
7132
|
-
if (this.device && this.bloomBlitUniformBuffer) {
|
|
7267
|
+
if (this.device && this.compositeUniformBuffer) {
|
|
7133
7268
|
this.writeBloomUniforms()
|
|
7134
7269
|
this.writeCompositeViewUniforms()
|
|
7135
7270
|
}
|
|
7136
7271
|
}
|
|
7137
7272
|
|
|
7138
|
-
|
|
7273
|
+
private ensureBloomPipelines(): void {
|
|
7274
|
+
// The buffer, not a pipeline, says "already built": at init the pipelines
|
|
7275
|
+
// land async (initRenderPipeline) and are absent for a moment.
|
|
7276
|
+
if (this.bloomUniformBuffer) return
|
|
7277
|
+
const device = this.device
|
|
7278
|
+
this.bloomUniformBuffer = device.createBuffer({
|
|
7279
|
+
label: "bloomuniforms",
|
|
7280
|
+
size: 16,
|
|
7281
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
7282
|
+
})
|
|
7283
|
+
this.bloomUpsampleBindGroupLayout = device.createBindGroupLayout({
|
|
7284
|
+
label: "bloomupsample layout",
|
|
7285
|
+
entries: [
|
|
7286
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
7287
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
7288
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
7289
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
7290
|
+
],
|
|
7291
|
+
})
|
|
7292
|
+
const pipeline = (label: string, code: string, layout: GPUBindGroupLayout, assign: (p: GPURenderPipeline) => void) => {
|
|
7293
|
+
const module = device.createShaderModule({ label, code })
|
|
7294
|
+
this.initRenderPipeline(
|
|
7295
|
+
{
|
|
7296
|
+
label,
|
|
7297
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
|
7298
|
+
vertex: { module, entryPoint: "vs" },
|
|
7299
|
+
fragment: { module, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
7300
|
+
primitive: { topology: "triangle-list" },
|
|
7301
|
+
},
|
|
7302
|
+
assign,
|
|
7303
|
+
)
|
|
7304
|
+
}
|
|
7305
|
+
pipeline("bloomprefilter", BLOOM_PREFILTER_SHADER_WGSL, this.bloomPrefilterBindGroupLayout, (p) => (this.bloomPrefilterPipeline = p))
|
|
7306
|
+
pipeline("bloomblur H", BLOOM_BLUR_H_SHADER_WGSL, this.bloomBlurBindGroupLayout, (p) => (this.bloomBlurHPipeline = p))
|
|
7307
|
+
pipeline("bloomblur V", BLOOM_BLUR_V_SHADER_WGSL, this.bloomBlurBindGroupLayout, (p) => (this.bloomBlurVPipeline = p))
|
|
7308
|
+
pipeline("bloomupsample", BLOOM_UPSAMPLE_SHADER_WGSL, this.bloomUpsampleBindGroupLayout, (p) => (this.bloomUpsamplePipeline = p))
|
|
7309
|
+
}
|
|
7310
|
+
|
|
7311
|
+
/** The game's chain at the current canvas size (AGSimPostFX.Bloom): half
|
|
7312
|
+
* size, then floor(log2(longest side)) - 1 levels, each half the last. */
|
|
7313
|
+
private buildBloomTargets(): void {
|
|
7314
|
+
if (!this.device || !this.hdrResolveTexture || !this.maskResolveView || !this.bloomPrefilterBindGroupLayout) return
|
|
7315
|
+
this.ensureBloomPipelines()
|
|
7316
|
+
const w0 = Math.max(1, Math.floor(this.hdrResolveTexture.width / 2))
|
|
7317
|
+
const h0 = Math.max(1, Math.floor(this.hdrResolveTexture.height / 2))
|
|
7318
|
+
const levels = Math.min(Engine.BLOOM_MAX_LEVELS, Math.max(1, Math.floor(Math.log2(Math.max(w0, h0)) - 1)))
|
|
7319
|
+
// Mip chains, whose sizes are floor-halved exactly as the game's targets are.
|
|
7320
|
+
const make = (label: string) =>
|
|
7321
|
+
this.device.createTexture({
|
|
7322
|
+
label,
|
|
7323
|
+
size: [w0, h0],
|
|
7324
|
+
mipLevelCount: levels,
|
|
7325
|
+
format: this.hdrFormat,
|
|
7326
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
7327
|
+
})
|
|
7328
|
+
this.bloomDownTexture?.destroy()
|
|
7329
|
+
this.bloomUpTexture?.destroy()
|
|
7330
|
+
this.bloomDownTexture = make("bloomdown")
|
|
7331
|
+
this.bloomUpTexture = make("bloomup")
|
|
7332
|
+
this.bloomLevels = levels
|
|
7333
|
+
const views = (t: GPUTexture) =>
|
|
7334
|
+
Array.from({ length: levels }, (_, i) => t.createView({ baseMipLevel: i, mipLevelCount: 1 }))
|
|
7335
|
+
this.bloomDownViews = views(this.bloomDownTexture)
|
|
7336
|
+
this.bloomUpViews = views(this.bloomUpTexture)
|
|
7337
|
+
this.writeBloomUniforms()
|
|
7338
|
+
const uniform = { buffer: this.bloomUniformBuffer! }
|
|
7339
|
+
this.bloomPrefilterBindGroup = this.device.createBindGroup({
|
|
7340
|
+
label: "bloomprefilter",
|
|
7341
|
+
layout: this.bloomPrefilterBindGroupLayout,
|
|
7342
|
+
entries: [
|
|
7343
|
+
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
7344
|
+
{ binding: 1, resource: uniform },
|
|
7345
|
+
{ binding: 2, resource: this.maskResolveView },
|
|
7346
|
+
],
|
|
7347
|
+
})
|
|
7348
|
+
const blur = (label: string, src: GPUTextureView) =>
|
|
7349
|
+
this.device.createBindGroup({
|
|
7350
|
+
label,
|
|
7351
|
+
layout: this.bloomBlurBindGroupLayout,
|
|
7352
|
+
entries: [
|
|
7353
|
+
{ binding: 0, resource: src },
|
|
7354
|
+
{ binding: 1, resource: this.bloomSampler },
|
|
7355
|
+
],
|
|
7356
|
+
})
|
|
7357
|
+
// [i - 1] is level i: blurH reads down[i-1] into up[i], blurV up[i] into down[i].
|
|
7358
|
+
this.bloomBlurHBindGroups = []
|
|
7359
|
+
this.bloomBlurVBindGroups = []
|
|
7360
|
+
for (let i = 1; i < levels; i++) {
|
|
7361
|
+
this.bloomBlurHBindGroups.push(blur(`bloomblur H ${i}`, this.bloomDownViews[i - 1]))
|
|
7362
|
+
this.bloomBlurVBindGroups.push(blur(`bloomblur V ${i}`, this.bloomUpViews[i]))
|
|
7363
|
+
}
|
|
7364
|
+
// [k] writes up[levels - 2 - k], coarsest first.
|
|
7365
|
+
this.bloomUpsampleBindGroups = []
|
|
7366
|
+
for (let i = levels - 2; i >= 0; i--) {
|
|
7367
|
+
const low = i === levels - 2 ? this.bloomDownViews[i + 1] : this.bloomUpViews[i + 1]
|
|
7368
|
+
this.bloomUpsampleBindGroups.push(
|
|
7369
|
+
this.device.createBindGroup({
|
|
7370
|
+
label: `bloomupsample ${i}`,
|
|
7371
|
+
layout: this.bloomUpsampleBindGroupLayout!,
|
|
7372
|
+
entries: [
|
|
7373
|
+
{ binding: 0, resource: this.bloomDownViews[i] },
|
|
7374
|
+
{ binding: 1, resource: low },
|
|
7375
|
+
{ binding: 2, resource: this.bloomSampler },
|
|
7376
|
+
{ binding: 3, resource: uniform },
|
|
7377
|
+
],
|
|
7378
|
+
}),
|
|
7379
|
+
)
|
|
7380
|
+
}
|
|
7381
|
+
}
|
|
7382
|
+
|
|
7139
7383
|
private writeBloomUniforms(): void {
|
|
7384
|
+
if (!this.bloomUniformBuffer) return
|
|
7140
7385
|
const b = this.bloomSettings
|
|
7141
|
-
const
|
|
7142
|
-
//
|
|
7143
|
-
//
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
this.device.queue.writeBuffer(this.
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7386
|
+
const u = this.bloomUniformData
|
|
7387
|
+
// Threshold, its soft knee (half of it, as the game derives it), the clamp,
|
|
7388
|
+
// and scatter. Tint and intensity live in the composite.
|
|
7389
|
+
u[0] = Math.max(0, b.threshold)
|
|
7390
|
+
u[1] = Math.max(0, b.threshold) * 0.5
|
|
7391
|
+
u[2] = Engine.BLOOM_CLAMP
|
|
7392
|
+
// URP's mapping: never all the way to the widest level, which alone is a
|
|
7393
|
+
// few pixels smeared over the screen — the glow would vanish.
|
|
7394
|
+
u[3] = 0.05 + 0.9 * Math.min(1, Math.max(0, b.scatter))
|
|
7395
|
+
this.device.queue.writeBuffer(this.bloomUniformBuffer, 0, u)
|
|
7396
|
+
}
|
|
7397
|
+
|
|
7398
|
+
/** The game's chain, pass for pass (AGSimPostFX.Bloom). */
|
|
7399
|
+
private renderBloom(encoder: GPUCommandEncoder): void {
|
|
7400
|
+
const levels = this.bloomLevels
|
|
7401
|
+
const att = (this.bloomPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
|
|
7402
|
+
const total = 1 + 3 * (levels - 1)
|
|
7403
|
+
let n = 0
|
|
7404
|
+
const draw = (target: GPUTextureView, pipeline: GPURenderPipeline, group: GPUBindGroup) => {
|
|
7405
|
+
att.view = target
|
|
7406
|
+
// One timing span for the whole chain, as for the pyramid.
|
|
7407
|
+
const open = n === 0 ? this.stampOpen("bloom") : undefined
|
|
7408
|
+
const close = n === total - 1 ? this.stampClose("bloom") : undefined
|
|
7409
|
+
this.bloomPassDescriptor.timestampWrites = open && close ? { ...open, ...close } : (open ?? close)
|
|
7410
|
+
n++
|
|
7411
|
+
const p = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
7412
|
+
p.setPipeline(pipeline)
|
|
7413
|
+
p.setBindGroup(0, group)
|
|
7414
|
+
p.draw(3)
|
|
7415
|
+
p.end()
|
|
7416
|
+
}
|
|
7417
|
+
draw(this.bloomDownViews[0], this.bloomPrefilterPipeline!, this.bloomPrefilterBindGroup!)
|
|
7418
|
+
for (let i = 1; i < levels; i++) {
|
|
7419
|
+
draw(this.bloomUpViews[i], this.bloomBlurHPipeline!, this.bloomBlurHBindGroups[i - 1])
|
|
7420
|
+
draw(this.bloomDownViews[i], this.bloomBlurVPipeline!, this.bloomBlurVBindGroups[i - 1])
|
|
7421
|
+
}
|
|
7422
|
+
for (let k = 0; k < levels - 1; k++) {
|
|
7423
|
+
draw(this.bloomUpViews[levels - 2 - k], this.bloomUpsamplePipeline!, this.bloomUpsampleBindGroups[k])
|
|
7424
|
+
}
|
|
7425
|
+
this.bloomPassDescriptor.timestampWrites = undefined
|
|
7158
7426
|
}
|
|
7159
7427
|
|
|
7160
7428
|
// Step 1: Get WebGPU device and context
|
|
@@ -7282,7 +7550,16 @@ export class Engine {
|
|
|
7282
7550
|
this.setupCamera()
|
|
7283
7551
|
this.setupLighting()
|
|
7284
7552
|
const tPipelines = performance.now()
|
|
7553
|
+
// Built async and side by side (see initRenderPipeline): a synchronous
|
|
7554
|
+
// build holds the GPU process for the whole compile, one after another,
|
|
7555
|
+
// which was a 2–3 s frozen frame on a cold open.
|
|
7556
|
+
this.initPipelineJobs = []
|
|
7285
7557
|
this.createPipelines()
|
|
7558
|
+
try {
|
|
7559
|
+
await Promise.all(this.initPipelineJobs)
|
|
7560
|
+
} finally {
|
|
7561
|
+
this.initPipelineJobs = null
|
|
7562
|
+
}
|
|
7286
7563
|
this.setupResize()
|
|
7287
7564
|
const ms = (a: number, b: number) => Math.round(b - a)
|
|
7288
7565
|
console.info(
|
|
@@ -7338,211 +7615,24 @@ export class Engine {
|
|
|
7338
7615
|
this.device.queue.submit([encoder.finish()])
|
|
7339
7616
|
}
|
|
7340
7617
|
|
|
7341
|
-
private
|
|
7342
|
-
|
|
7343
|
-
throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).")
|
|
7344
|
-
}
|
|
7345
|
-
|
|
7346
|
-
// Temp rg16float LTC source — loaded 1:1 by the bake fragment shader, then dropped.
|
|
7347
|
-
const ltcTemp = this.device.createTexture({
|
|
7348
|
-
label: "LTC mag LUT (bake input)",
|
|
7349
|
-
size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
|
|
7350
|
-
format: "rg16float",
|
|
7351
|
-
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
|
|
7352
|
-
})
|
|
7353
|
-
const n = LTC_MAG_LUT_DATA.length
|
|
7354
|
-
const half = new Uint16Array(n)
|
|
7355
|
-
const f32 = new Float32Array(1)
|
|
7356
|
-
const u32 = new Uint32Array(f32.buffer)
|
|
7357
|
-
for (let i = 0; i < n; i++) {
|
|
7358
|
-
f32[0] = LTC_MAG_LUT_DATA[i]
|
|
7359
|
-
const x = u32[0]
|
|
7360
|
-
const sign = (x >>> 16) & 0x8000
|
|
7361
|
-
let exp = ((x >>> 23) & 0xff) - 127 + 15
|
|
7362
|
-
const mant = x & 0x7fffff
|
|
7363
|
-
if (exp <= 0) {
|
|
7364
|
-
half[i] = sign
|
|
7365
|
-
} else if (exp >= 31) {
|
|
7366
|
-
half[i] = sign | 0x7c00
|
|
7367
|
-
} else {
|
|
7368
|
-
half[i] = sign | (exp << 10) | (mant >>> 13)
|
|
7369
|
-
}
|
|
7370
|
-
}
|
|
7371
|
-
this.device.queue.writeTexture(
|
|
7372
|
-
{ texture: ltcTemp },
|
|
7373
|
-
half,
|
|
7374
|
-
{ bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE },
|
|
7375
|
-
{ width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 },
|
|
7376
|
-
)
|
|
7377
|
-
|
|
7378
|
-
this.brdfLutTexture = this.device.createTexture({
|
|
7379
|
-
label: "BRDF LUT (DFG + LTC packed)",
|
|
7380
|
-
size: [BRDF_LUT_SIZE, BRDF_LUT_SIZE],
|
|
7381
|
-
format: "rgba8unorm",
|
|
7382
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
7383
|
-
})
|
|
7384
|
-
this.brdfLutView = this.brdfLutTexture.createView()
|
|
7385
|
-
|
|
7386
|
-
const module = this.device.createShaderModule({ label: "BRDF LUT bake", code: BRDF_LUT_BAKE_WGSL })
|
|
7387
|
-
const pipeline = this.device.createRenderPipeline({
|
|
7388
|
-
label: "BRDF LUT bake pipeline",
|
|
7389
|
-
layout: "auto",
|
|
7390
|
-
vertex: { module, entryPoint: "vs" },
|
|
7391
|
-
fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
|
|
7392
|
-
primitive: { topology: "triangle-list" },
|
|
7393
|
-
})
|
|
7394
|
-
|
|
7395
|
-
const bakeBindGroup = this.device.createBindGroup({
|
|
7396
|
-
label: "BRDF LUT bake bind group",
|
|
7397
|
-
layout: pipeline.getBindGroupLayout(0),
|
|
7398
|
-
entries: [{ binding: 0, resource: ltcTemp.createView() }],
|
|
7399
|
-
})
|
|
7400
|
-
|
|
7401
|
-
const enc = this.device.createCommandEncoder({ label: "BRDF LUT bake encoder" })
|
|
7402
|
-
const pass = enc.beginRenderPass({
|
|
7403
|
-
colorAttachments: [
|
|
7404
|
-
{ view: this.brdfLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
|
|
7405
|
-
],
|
|
7406
|
-
})
|
|
7407
|
-
pass.setPipeline(pipeline)
|
|
7408
|
-
pass.setBindGroup(0, bakeBindGroup)
|
|
7409
|
-
pass.draw(3, 1, 0, 0)
|
|
7410
|
-
pass.end()
|
|
7411
|
-
this.device.queue.submit([enc.finish()])
|
|
7412
|
-
|
|
7413
|
-
ltcTemp.destroy()
|
|
7414
|
-
}
|
|
7415
|
-
|
|
7416
|
-
// Bake the Blender 3.6 Filmic MHC tone curve into a WIDTH×1 r16float LUT sampled by the
|
|
7417
|
-
// composite pass. The 14 anchors are the same as the old inline array; we fit a monotone
|
|
7418
|
-
// cubic (Fritsch–Carlson) through them so the curve is C1-continuous (no Mach banding in
|
|
7419
|
-
// smooth gradients) while still passing through every anchor (look preserved) and staying
|
|
7420
|
-
// monotone (no tonemap overshoot/ringing). Domain is uniform in log2 space: anchor k sits
|
|
7421
|
-
// at t=k, k=0..13 (t = log2(linear)+10). See composite.ts::filmic for the sampling map.
|
|
7422
|
-
/**
|
|
7423
|
-
* Decompress and upload Blender's AgX cube.
|
|
7424
|
-
*
|
|
7425
|
-
* Deliberately off the critical path: it is 723 KB once inflated, and a frame
|
|
7426
|
-
* rendered before it lands should show the scene under whatever transform is
|
|
7427
|
-
* already there rather than wait. Until then binding 10 holds a 1×1×1 stand-in,
|
|
7428
|
-
* which is only ever sampled if someone selects AgX in that window.
|
|
7429
|
-
*/
|
|
7430
|
-
private async loadAgxLut(): Promise<void> {
|
|
7431
|
-
try {
|
|
7432
|
-
const packed = Uint8Array.from(atob(AGX_LUT_GZ), (ch) => ch.charCodeAt(0))
|
|
7433
|
-
const stream = new Blob([packed]).stream().pipeThrough(new DecompressionStream("gzip"))
|
|
7434
|
-
const bytes = new Uint8Array(await new Response(stream).arrayBuffer())
|
|
7435
|
-
const n = AGX_LUT_SIZE
|
|
7436
|
-
if (bytes.byteLength !== n * n * n * 4) throw new Error(`AgX LUT is ${bytes.byteLength} bytes, expected ${n ** 3 * 4}`)
|
|
7437
|
-
const tex = this.device.createTexture({
|
|
7438
|
-
label: "AgX 57³ LUT",
|
|
7439
|
-
size: [n, n, n],
|
|
7440
|
-
dimension: "3d",
|
|
7441
|
-
format: "rgb10a2unorm",
|
|
7442
|
-
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
7443
|
-
})
|
|
7444
|
-
// .cube order is red fastest, which is exactly a 3D texture's own layout.
|
|
7445
|
-
this.device.queue.writeTexture({ texture: tex }, bytes, { bytesPerRow: n * 4, rowsPerImage: n }, [n, n, n])
|
|
7446
|
-
this.agxLutTexture = tex
|
|
7447
|
-
this.rebuildCompositeBindGroup()
|
|
7448
|
-
} catch {
|
|
7449
|
-
// A missing LUT costs AgX, not the renderer — the other transforms stand.
|
|
7450
|
-
}
|
|
7451
|
-
}
|
|
7452
|
-
|
|
7453
|
-
private bakeFilmicLut() {
|
|
7454
|
-
const anchors = [
|
|
7455
|
-
0.0028, 0.0068, 0.0151, 0.0313, 0.061, 0.112, 0.192, 0.306, 0.459, 0.631, 0.82, 0.907, 0.962, 0.989,
|
|
7456
|
-
]
|
|
7457
|
-
const n = anchors.length
|
|
7458
|
-
// Secant slopes (unit spacing, so d_k = y_{k+1} - y_k).
|
|
7459
|
-
const d = new Array<number>(n - 1)
|
|
7460
|
-
for (let k = 0; k < n - 1; k++) d[k] = anchors[k + 1] - anchors[k]
|
|
7461
|
-
// Endpoint + interior tangents.
|
|
7462
|
-
const m = new Array<number>(n)
|
|
7463
|
-
m[0] = d[0]
|
|
7464
|
-
m[n - 1] = d[n - 2]
|
|
7465
|
-
for (let k = 1; k < n - 1; k++) m[k] = (d[k - 1] + d[k]) * 0.5
|
|
7466
|
-
// Fritsch–Carlson monotonicity clamp.
|
|
7467
|
-
for (let k = 0; k < n - 1; k++) {
|
|
7468
|
-
if (d[k] === 0) {
|
|
7469
|
-
m[k] = 0
|
|
7470
|
-
m[k + 1] = 0
|
|
7471
|
-
continue
|
|
7472
|
-
}
|
|
7473
|
-
const a = m[k] / d[k]
|
|
7474
|
-
const b = m[k + 1] / d[k]
|
|
7475
|
-
const s = a * a + b * b
|
|
7476
|
-
if (s > 9) {
|
|
7477
|
-
const tau = 3 / Math.sqrt(s)
|
|
7478
|
-
m[k] = tau * a * d[k]
|
|
7479
|
-
m[k + 1] = tau * b * d[k]
|
|
7480
|
-
}
|
|
7481
|
-
}
|
|
7482
|
-
const W = Engine.FILMIC_LUT_WIDTH
|
|
7483
|
-
const values = new Float32Array(W)
|
|
7484
|
-
for (let j = 0; j < W; j++) {
|
|
7485
|
-
const t = ((n - 1) * j) / (W - 1) // t ∈ [0, n-1]
|
|
7486
|
-
const k = Math.min(Math.floor(t), n - 2)
|
|
7487
|
-
const s = t - k // local param in [0,1], unit-spaced segment
|
|
7488
|
-
const s2 = s * s
|
|
7489
|
-
const s3 = s2 * s
|
|
7490
|
-
// Hermite basis (h=1).
|
|
7491
|
-
const h00 = 2 * s3 - 3 * s2 + 1
|
|
7492
|
-
const h10 = s3 - 2 * s2 + s
|
|
7493
|
-
const h01 = -2 * s3 + 3 * s2
|
|
7494
|
-
const h11 = s3 - s2
|
|
7495
|
-
values[j] = h00 * anchors[k] + h10 * m[k] + h01 * anchors[k + 1] + h11 * m[k + 1]
|
|
7496
|
-
}
|
|
7497
|
-
|
|
7498
|
-
// f32 → f16 bits (same conversion as bakeBrdfLut).
|
|
7499
|
-
const half = new Uint16Array(W)
|
|
7500
|
-
const f32 = new Float32Array(1)
|
|
7501
|
-
const u32 = new Uint32Array(f32.buffer)
|
|
7502
|
-
for (let j = 0; j < W; j++) {
|
|
7503
|
-
f32[0] = values[j]
|
|
7504
|
-
const x = u32[0]
|
|
7505
|
-
const sign = (x >>> 16) & 0x8000
|
|
7506
|
-
const exp = ((x >>> 23) & 0xff) - 127 + 15
|
|
7507
|
-
const mant = x & 0x7fffff
|
|
7508
|
-
if (exp <= 0) {
|
|
7509
|
-
half[j] = sign
|
|
7510
|
-
} else if (exp >= 31) {
|
|
7511
|
-
half[j] = sign | 0x7c00
|
|
7512
|
-
} else {
|
|
7513
|
-
half[j] = sign | (exp << 10) | (mant >>> 13)
|
|
7514
|
-
}
|
|
7515
|
-
}
|
|
7516
|
-
|
|
7517
|
-
this.filmicLutTexture = this.device.createTexture({
|
|
7518
|
-
label: "Filmic tone LUT",
|
|
7519
|
-
size: [W, 1],
|
|
7520
|
-
format: "r16float",
|
|
7521
|
-
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
|
|
7522
|
-
})
|
|
7523
|
-
this.filmicLutView = this.filmicLutTexture.createView()
|
|
7524
|
-
this.device.queue.writeTexture(
|
|
7525
|
-
{ texture: this.filmicLutTexture },
|
|
7526
|
-
half,
|
|
7527
|
-
{ bytesPerRow: W * 2, rowsPerImage: 1 },
|
|
7528
|
-
{ width: W, height: 1, depthOrArrayLayers: 1 },
|
|
7529
|
-
)
|
|
7618
|
+
private createRenderPipeline(config: Parameters<Engine["renderPipelineDesc"]>[0]): GPURenderPipeline {
|
|
7619
|
+
return this.createModelPipeline(this.renderPipelineDesc(config))
|
|
7530
7620
|
}
|
|
7531
7621
|
|
|
7532
|
-
private
|
|
7622
|
+
private renderPipelineDesc(config: {
|
|
7533
7623
|
label: string
|
|
7534
7624
|
layout: GPUPipelineLayout
|
|
7535
7625
|
shaderModule: GPUShaderModule
|
|
7536
|
-
vertexBuffers: GPUVertexBufferLayout[]
|
|
7626
|
+
vertexBuffers: (GPUVertexBufferLayout | null)[]
|
|
7537
7627
|
fragmentTarget?: GPUColorTargetState
|
|
7538
7628
|
fragmentTargets?: GPUColorTargetState[]
|
|
7539
7629
|
fragmentEntryPoint?: string
|
|
7540
7630
|
cullMode?: GPUCullMode
|
|
7541
7631
|
depthStencil?: GPUDepthStencilState
|
|
7542
7632
|
multisample?: GPUMultisampleState
|
|
7543
|
-
}):
|
|
7633
|
+
}): GPURenderPipelineDescriptor {
|
|
7544
7634
|
const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined)
|
|
7545
|
-
return
|
|
7635
|
+
return {
|
|
7546
7636
|
label: config.label,
|
|
7547
7637
|
layout: config.layout,
|
|
7548
7638
|
vertex: {
|
|
@@ -7559,7 +7649,7 @@ export class Engine {
|
|
|
7559
7649
|
primitive: { cullMode: config.cullMode ?? "none" },
|
|
7560
7650
|
depthStencil: config.depthStencil,
|
|
7561
7651
|
multisample: config.multisample ?? { count: Engine.MULTISAMPLE_COUNT },
|
|
7562
|
-
}
|
|
7652
|
+
}
|
|
7563
7653
|
}
|
|
7564
7654
|
|
|
7565
7655
|
private createPipelines() {
|
|
@@ -7685,8 +7775,6 @@ export class Engine {
|
|
|
7685
7775
|
// creation with "binding doesn't exist", naming a binding no effect
|
|
7686
7776
|
// author ever wrote. Sharing the code meant sharing these.
|
|
7687
7777
|
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
7688
|
-
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
7689
|
-
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
|
|
7690
7778
|
{ binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
|
|
7691
7779
|
// And the scene's COVERAGE and bloom, without which the tap cannot
|
|
7692
7780
|
// reconstruct a pixel: the HDR target is premultiplied, so colour alone
|
|
@@ -7708,31 +7796,40 @@ export class Engine {
|
|
|
7708
7796
|
const seedModule = this.device.createShaderModule({ label: "cast distance seed", code: buildCastSeedShader(Engine.MULTISAMPLE_COUNT) })
|
|
7709
7797
|
const stepModule = this.device.createShaderModule({ label: "cast distance step", code: buildCastStepShader() })
|
|
7710
7798
|
const resolveModule = this.device.createShaderModule({ label: "cast distance resolve", code: buildCastResolveShader() })
|
|
7711
|
-
this.
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7799
|
+
this.initRenderPipeline(
|
|
7800
|
+
{
|
|
7801
|
+
label: "cast distance seed",
|
|
7802
|
+
layout: "auto",
|
|
7803
|
+
vertex: { module: seedModule, entryPoint: "vs" },
|
|
7804
|
+
fragment: {
|
|
7805
|
+
module: seedModule,
|
|
7806
|
+
entryPoint: "fs",
|
|
7807
|
+
targets: [{ format: CAST_SEED_FORMAT }, { format: CAST_COVERAGE_FORMAT }],
|
|
7808
|
+
},
|
|
7809
|
+
primitive: { topology: "triangle-list" },
|
|
7719
7810
|
},
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
this.
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7735
|
-
|
|
7811
|
+
(p) => (this.castSeedPipeline = p),
|
|
7812
|
+
)
|
|
7813
|
+
this.initRenderPipeline(
|
|
7814
|
+
{
|
|
7815
|
+
label: "cast distance step",
|
|
7816
|
+
layout: "auto",
|
|
7817
|
+
vertex: { module: stepModule, entryPoint: "vs" },
|
|
7818
|
+
fragment: { module: stepModule, entryPoint: "fs", targets: [{ format: CAST_SEED_FORMAT }] },
|
|
7819
|
+
primitive: { topology: "triangle-list" },
|
|
7820
|
+
},
|
|
7821
|
+
(p) => (this.castStepPipeline = p),
|
|
7822
|
+
)
|
|
7823
|
+
this.initRenderPipeline(
|
|
7824
|
+
{
|
|
7825
|
+
label: "cast distance resolve",
|
|
7826
|
+
layout: "auto",
|
|
7827
|
+
vertex: { module: resolveModule, entryPoint: "vs" },
|
|
7828
|
+
fragment: { module: resolveModule, entryPoint: "fs", targets: [{ format: CAST_DIST_FORMAT }] },
|
|
7829
|
+
primitive: { topology: "triangle-list" },
|
|
7830
|
+
},
|
|
7831
|
+
(p) => (this.castResolvePipeline = p),
|
|
7832
|
+
)
|
|
7736
7833
|
// 65504 is the largest half float. Bound wherever the field is not
|
|
7737
7834
|
// running, so rzCastDistance answers "unreachably far" and an effect keyed
|
|
7738
7835
|
// on it draws nothing at all.
|
|
@@ -7843,7 +7940,22 @@ export class Engine {
|
|
|
7843
7940
|
// The same attachments blended as light — for a group that declared
|
|
7844
7941
|
// blend: "additive". See scene-contract's material-additive.
|
|
7845
7942
|
this.sceneTargetsAdditive = sceneTargetsFor("material-additive", this.sceneFormats)
|
|
7943
|
+
this.sceneTargetsPremultiplied = sceneTargetsFor("material-premultiplied", this.sceneFormats)
|
|
7846
7944
|
this.fullVertexBufferLayouts = fullVertexBuffers
|
|
7945
|
+
// Slot 3 stays empty: the outline hull binds its own stream there between
|
|
7946
|
+
// two material draws, and a material reading slot 3 would pick it up.
|
|
7947
|
+
const materialVertexBuffers: (GPUVertexBufferLayout | null)[] = [
|
|
7948
|
+
...fullVertexBuffers,
|
|
7949
|
+
null,
|
|
7950
|
+
{
|
|
7951
|
+
arrayStride: Engine.ATTRIBUTE_FLOATS * 4,
|
|
7952
|
+
attributes: [
|
|
7953
|
+
{ shaderLocation: 5, offset: 0, format: "float32x4" as GPUVertexFormat },
|
|
7954
|
+
{ shaderLocation: 6, offset: 4 * 4, format: "float32x2" as GPUVertexFormat },
|
|
7955
|
+
],
|
|
7956
|
+
},
|
|
7957
|
+
]
|
|
7958
|
+
this.materialVertexBufferLayouts = materialVertexBuffers
|
|
7847
7959
|
|
|
7848
7960
|
// group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
|
|
7849
7961
|
this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
@@ -7859,14 +7971,19 @@ export class Engine {
|
|
|
7859
7971
|
// The positional lights. Always bound, empty or not, so every material
|
|
7860
7972
|
// pipeline shares one layout whether or not the scene has any.
|
|
7861
7973
|
{ binding: 6, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
7862
|
-
// The world's sky (8)
|
|
7863
|
-
//
|
|
7864
|
-
//
|
|
7865
|
-
// which the atlas at 3 replaces.)
|
|
7974
|
+
// The world's sky (8). Always bound — the 1x1 fallback when the scene
|
|
7975
|
+
// has none — so every material pipeline keeps sharing one layout. (7
|
|
7976
|
+
// was the far cascade's map, which the atlas at 3 replaces.)
|
|
7866
7977
|
{ binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
7867
|
-
|
|
7978
|
+
// The spot lamps' cookies (loadLightCookie), a 1x1 white until one loads.
|
|
7979
|
+
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d-array" } },
|
|
7868
7980
|
// The cast's shadow on a stage — setStageCastShadow.
|
|
7869
7981
|
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
|
|
7982
|
+
// The stage's reflection probe and where it projects — see
|
|
7983
|
+
// captureReflectionProbe. Always bound: a 1x1 cube and the off flag
|
|
7984
|
+
// when there is no stage.
|
|
7985
|
+
{ binding: 11, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "cube" } },
|
|
7986
|
+
{ binding: 12, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
7870
7987
|
],
|
|
7871
7988
|
})
|
|
7872
7989
|
// group 1: per-instance (skinMats) — bound once per model
|
|
@@ -7933,34 +8050,42 @@ export class Engine {
|
|
|
7933
8050
|
// hand shaders are retired in favor of graphs.
|
|
7934
8051
|
const neutral = compileGraph(DEFAULT_GRAPH, { renderClass: "auto", alphaMode: "opaque" })
|
|
7935
8052
|
if (!neutral.ok) throw new Error("failed to compile the neutral default graph")
|
|
7936
|
-
const neutralModule = this.
|
|
7937
|
-
this.
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
8053
|
+
const neutralModule = this.cachedShaderModule(neutral.wgsl, "neutral base (default graph)")
|
|
8054
|
+
this.initRenderPipeline(
|
|
8055
|
+
this.renderPipelineDesc({
|
|
8056
|
+
label: "neutral base pipeline",
|
|
8057
|
+
layout: mainPipelineLayout,
|
|
8058
|
+
shaderModule: neutralModule,
|
|
8059
|
+
vertexBuffers: materialVertexBuffers,
|
|
8060
|
+
fragmentTargets: sceneTargets,
|
|
8061
|
+
cullMode: "none",
|
|
8062
|
+
depthStencil: {
|
|
8063
|
+
format: this.depthFormat,
|
|
8064
|
+
depthWriteEnabled: true,
|
|
8065
|
+
depthCompare: this.depthAhead,
|
|
8066
|
+
},
|
|
8067
|
+
}),
|
|
8068
|
+
(p) => (this.neutralPipeline = p),
|
|
8069
|
+
true,
|
|
8070
|
+
)
|
|
7950
8071
|
// Depth-write-off twin for transparent-bucket draws (see pipelineForDrawCall).
|
|
7951
|
-
this.
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
8072
|
+
this.initRenderPipeline(
|
|
8073
|
+
this.renderPipelineDesc({
|
|
8074
|
+
label: "neutral base pipeline (no depth write)",
|
|
8075
|
+
layout: mainPipelineLayout,
|
|
8076
|
+
shaderModule: neutralModule,
|
|
8077
|
+
vertexBuffers: materialVertexBuffers,
|
|
8078
|
+
fragmentTargets: sceneTargets,
|
|
8079
|
+
cullMode: "none",
|
|
8080
|
+
depthStencil: {
|
|
8081
|
+
format: this.depthFormat,
|
|
8082
|
+
depthWriteEnabled: false,
|
|
8083
|
+
depthCompare: this.depthAhead,
|
|
8084
|
+
},
|
|
8085
|
+
}),
|
|
8086
|
+
(p) => (this.neutralPipelineNoDepthWrite = p),
|
|
8087
|
+
true,
|
|
8088
|
+
)
|
|
7964
8089
|
// Depth-only prepass for transparent draws (see depth-prepass.ts): writes the
|
|
7965
8090
|
// fabric's depth AFTER its color blended, so outlines drawn later are
|
|
7966
8091
|
// occluded behind it. Color targets kept for pass compatibility, writeMask 0.
|
|
@@ -7979,51 +8104,63 @@ export class Engine {
|
|
|
7979
8104
|
depthCompare: this.depthAhead,
|
|
7980
8105
|
},
|
|
7981
8106
|
}
|
|
7982
|
-
this.
|
|
7983
|
-
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
8107
|
+
this.initRenderPipeline(
|
|
8108
|
+
{
|
|
8109
|
+
label: "opaque depth prepass",
|
|
8110
|
+
...prepassDesc,
|
|
8111
|
+
fragment: {
|
|
8112
|
+
module: prepassModule,
|
|
8113
|
+
entryPoint: "fs",
|
|
8114
|
+
targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
|
|
8115
|
+
},
|
|
7989
8116
|
},
|
|
7990
|
-
|
|
8117
|
+
(p) => (this.depthPrepassPipeline = p),
|
|
8118
|
+
true,
|
|
8119
|
+
)
|
|
7991
8120
|
// The SOLID prime: same module, cutoff forced to exactly 1.0. Only texels
|
|
7992
8121
|
// whose blend ignores the destination may pre-claim depth in the
|
|
7993
8122
|
// transparent phase — see the override's note in depth-prepass.ts.
|
|
7994
|
-
this.
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
|
|
8001
|
-
|
|
8123
|
+
this.initRenderPipeline(
|
|
8124
|
+
{
|
|
8125
|
+
label: "transparent solid prepass",
|
|
8126
|
+
...prepassDesc,
|
|
8127
|
+
fragment: {
|
|
8128
|
+
module: prepassModule,
|
|
8129
|
+
entryPoint: "fs",
|
|
8130
|
+
constants: { CUTOFF: 1.0 },
|
|
8131
|
+
targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
|
|
8132
|
+
},
|
|
8002
8133
|
},
|
|
8003
|
-
|
|
8134
|
+
(p) => (this.solidPrepassPipeline = p),
|
|
8135
|
+
true,
|
|
8136
|
+
)
|
|
8004
8137
|
// The HAIR prime: solid texels only, and stencil-fenced off the eye
|
|
8005
8138
|
// silhouette. It records after the non-hair opaque draws, so the eye has
|
|
8006
8139
|
// already written its stencil — not-equal here is what keeps the primed
|
|
8007
8140
|
// hair depth from ever claiming the pixels the see-through-hair pass needs
|
|
8008
8141
|
// the eye to survive on. (Bundle draws use the PASS's stencil reference;
|
|
8009
8142
|
// only pipeline/bind/vertex state resets across executeBundles.)
|
|
8010
|
-
this.
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8143
|
+
this.initRenderPipeline(
|
|
8144
|
+
{
|
|
8145
|
+
label: "hair depth prime",
|
|
8146
|
+
...prepassDesc,
|
|
8147
|
+
depthStencil: {
|
|
8148
|
+
...prepassDesc.depthStencil,
|
|
8149
|
+
stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
|
|
8150
|
+
stencilBack: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
|
|
8151
|
+
stencilReadMask: 0xff,
|
|
8152
|
+
stencilWriteMask: 0,
|
|
8153
|
+
},
|
|
8154
|
+
fragment: {
|
|
8155
|
+
module: prepassModule,
|
|
8156
|
+
entryPoint: "fs",
|
|
8157
|
+
constants: { CUTOFF: 1.0 },
|
|
8158
|
+
targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
|
|
8159
|
+
},
|
|
8025
8160
|
},
|
|
8026
|
-
|
|
8161
|
+
(p) => (this.hairPrimePipeline = p),
|
|
8162
|
+
true,
|
|
8163
|
+
)
|
|
8027
8164
|
|
|
8028
8165
|
// The matrices, then one vec4: the caster sphere, which effects' rzShadow
|
|
8029
8166
|
// tests before any tap. The materials and the ground bind the matrices only.
|
|
@@ -8050,28 +8187,31 @@ export class Engine {
|
|
|
8050
8187
|
label: "shadow depth",
|
|
8051
8188
|
code: SHADOW_DEPTH_SHADER_WGSL,
|
|
8052
8189
|
})
|
|
8053
|
-
this.
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
8059
|
-
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
|
|
8072
|
-
|
|
8190
|
+
this.initRenderPipeline(
|
|
8191
|
+
{
|
|
8192
|
+
label: "shadow depth pipeline",
|
|
8193
|
+
// Group 1 is the main pass's per-material layout so each shadow draw can
|
|
8194
|
+
// rebind the draw call's existing material bind group for the alpha test.
|
|
8195
|
+
layout: this.device.createPipelineLayout({
|
|
8196
|
+
bindGroupLayouts: [shadowBindGroupLayout, this.mainPerMaterialBindGroupLayout],
|
|
8197
|
+
}),
|
|
8198
|
+
vertex: { module: shadowShader, entryPoint: "vs", buffers: fullVertexBuffers as GPUVertexBufferLayout[] },
|
|
8199
|
+
fragment: { module: shadowShader, entryPoint: "fs", targets: [] },
|
|
8200
|
+
primitive: { cullMode: "none" },
|
|
8201
|
+
depthStencil: {
|
|
8202
|
+
format: Engine.SHADOW_DEPTH_FORMAT,
|
|
8203
|
+
depthWriteEnabled: true,
|
|
8204
|
+
depthCompare: "less-equal",
|
|
8205
|
+
// The shadow map keeps the NON-reversed convention (orthographicLh maps
|
|
8206
|
+
// [0,1] with far = 1, and this pass never flipped) — so this bias must
|
|
8207
|
+
// NOT follow reversedZ. It is the camera-pass biases that flip.
|
|
8208
|
+
depthBias: 2,
|
|
8209
|
+
depthBiasSlopeScale: 1.5,
|
|
8210
|
+
depthBiasClamp: 0,
|
|
8211
|
+
},
|
|
8073
8212
|
},
|
|
8074
|
-
|
|
8213
|
+
(p) => (this.shadowDepthPipeline = p),
|
|
8214
|
+
)
|
|
8075
8215
|
this.shadowComparisonSampler = this.device.createSampler({
|
|
8076
8216
|
compare: "less",
|
|
8077
8217
|
magFilter: "linear",
|
|
@@ -8104,16 +8244,24 @@ export class Engine {
|
|
|
8104
8244
|
})
|
|
8105
8245
|
|
|
8106
8246
|
// One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
|
|
8107
|
-
this.bakeBrdfLut()
|
|
8108
8247
|
this.bakeGroundNoise()
|
|
8109
|
-
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8248
|
+
// The mipmap blit for the formats model textures arrive in, built here so
|
|
8249
|
+
// the first texture of the first model does not compile it synchronously
|
|
8250
|
+
// in the middle of a load (generateMipmaps builds any other on demand).
|
|
8251
|
+
{
|
|
8252
|
+
const mipModule = this.device.createShaderModule({ label: "mipmap blit", code: MIPMAP_BLIT_SHADER_WGSL })
|
|
8253
|
+
for (const format of ["rgba8unorm", "rgba8unorm-srgb"] as const)
|
|
8254
|
+
this.initRenderPipeline(
|
|
8255
|
+
{
|
|
8256
|
+
label: `mipmap blit pipeline (${format})`,
|
|
8257
|
+
layout: "auto",
|
|
8258
|
+
vertex: { module: mipModule, entryPoint: "vs" },
|
|
8259
|
+
fragment: { module: mipModule, entryPoint: "fs", targets: [{ format }] },
|
|
8260
|
+
primitive: { topology: "triangle-list" },
|
|
8261
|
+
},
|
|
8262
|
+
(p) => this.mipBlitPipelines.set(format, p),
|
|
8263
|
+
)
|
|
8264
|
+
}
|
|
8117
8265
|
this.stageGradeFallback = this.device.createTexture({
|
|
8118
8266
|
label: "stage grade fallback",
|
|
8119
8267
|
size: [1, 1, 1],
|
|
@@ -8122,7 +8270,6 @@ export class Engine {
|
|
|
8122
8270
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
8123
8271
|
})
|
|
8124
8272
|
this.uploadStageGrade()
|
|
8125
|
-
this.bakeFilmicLut()
|
|
8126
8273
|
|
|
8127
8274
|
// BEFORE the bind group below, which binds it. Full size from the start:
|
|
8128
8275
|
// every material pipeline binds this, so sizing it to the light count would
|
|
@@ -8153,6 +8300,48 @@ export class Engine {
|
|
|
8153
8300
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
8154
8301
|
})
|
|
8155
8302
|
this.fallbackEquirectView = this.fallbackEquirectTexture.createView()
|
|
8303
|
+
// The reflection probe's resources, bound from the first frame: the cube
|
|
8304
|
+
// (empty and switched off until a stage is captured), its stand-in and the
|
|
8305
|
+
// uniforms. Before the per-frame groups, which bind all of them.
|
|
8306
|
+
this.probeTexture = this.device.createTexture({
|
|
8307
|
+
label: "reflection probe",
|
|
8308
|
+
size: [PROBE_SIZE, PROBE_SIZE, 6],
|
|
8309
|
+
format: "rgba16float",
|
|
8310
|
+
mipLevelCount: PROBE_LEVELS,
|
|
8311
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
8312
|
+
})
|
|
8313
|
+
this.probeView = this.probeTexture.createView({ dimension: "cube" })
|
|
8314
|
+
this.probeFallbackView = this.device
|
|
8315
|
+
.createTexture({
|
|
8316
|
+
label: "reflection probe stand-in",
|
|
8317
|
+
size: [1, 1, 6],
|
|
8318
|
+
format: "rgba16float",
|
|
8319
|
+
usage: GPUTextureUsage.TEXTURE_BINDING,
|
|
8320
|
+
})
|
|
8321
|
+
.createView({ dimension: "cube" })
|
|
8322
|
+
this.cookieTexture = this.device.createTexture({
|
|
8323
|
+
label: "light cookies (none yet)",
|
|
8324
|
+
size: [1, 1, 1],
|
|
8325
|
+
format: "rgba8unorm",
|
|
8326
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
8327
|
+
})
|
|
8328
|
+
this.device.queue.writeTexture({ texture: this.cookieTexture }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1, 1])
|
|
8329
|
+
this.cookieView = this.cookieTexture.createView({ dimension: "2d-array" })
|
|
8330
|
+
this.probeUniformBuffer = this.device.createBuffer({
|
|
8331
|
+
label: "reflection probe uniforms",
|
|
8332
|
+
size: 48,
|
|
8333
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
8334
|
+
})
|
|
8335
|
+
this.probeOffBuffer = this.device.createBuffer({
|
|
8336
|
+
label: "reflection probe uniforms (off)",
|
|
8337
|
+
size: 48,
|
|
8338
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
8339
|
+
})
|
|
8340
|
+
this.probeCameraBuffer = this.device.createBuffer({
|
|
8341
|
+
label: "reflection probe capture camera",
|
|
8342
|
+
size: 40 * 4,
|
|
8343
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
8344
|
+
})
|
|
8156
8345
|
|
|
8157
8346
|
// Zero-filled = zero lines, which every accessor answers gracefully.
|
|
8158
8347
|
this.lyricsBuffer = this.device.createBuffer({
|
|
@@ -8200,6 +8389,8 @@ export class Engine {
|
|
|
8200
8389
|
{ binding: 13, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
8201
8390
|
// The mirror's plane, and whether this draw is inside the mirror pass.
|
|
8202
8391
|
{ binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
8392
|
+
// The spot lamps' cookies — the floor takes their patterns too.
|
|
8393
|
+
{ binding: 15, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d-array" } },
|
|
8203
8394
|
],
|
|
8204
8395
|
})
|
|
8205
8396
|
this.groundShadowPipelineDesc = {
|
|
@@ -8213,7 +8404,10 @@ export class Engine {
|
|
|
8213
8404
|
cullMode: "back",
|
|
8214
8405
|
depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
|
|
8215
8406
|
}
|
|
8216
|
-
this.
|
|
8407
|
+
this.initRenderPipeline(
|
|
8408
|
+
this.renderPipelineDesc(this.groundPipelineConfig(false)),
|
|
8409
|
+
(p) => (this.groundShadowPipeline = p),
|
|
8410
|
+
)
|
|
8217
8411
|
|
|
8218
8412
|
// Outline: group 0 = per-frame (camera), group 1 = per-instance (skinMats), group 2 = per-material (edge uniforms)
|
|
8219
8413
|
this.outlinePerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
@@ -8282,7 +8476,12 @@ export class Engine {
|
|
|
8282
8476
|
label: "outline pipeline",
|
|
8283
8477
|
layout: outlinePipelineLayout,
|
|
8284
8478
|
shaderModule: outlineShaderModule,
|
|
8285
|
-
|
|
8479
|
+
// The selection mask's streams plus the hull's own: smoothed normal and
|
|
8480
|
+
// PMX edge scale (ModelInstance.outlineVertexBuffer).
|
|
8481
|
+
vertexBuffers: [
|
|
8482
|
+
...outlineVertexBuffers,
|
|
8483
|
+
{ arrayStride: 4 * 4, attributes: [{ shaderLocation: 5, offset: 0, format: "float32x4" }] },
|
|
8484
|
+
],
|
|
8286
8485
|
fragmentTargets: sceneTargetsFor("outline", this.sceneFormats),
|
|
8287
8486
|
cullMode: "back",
|
|
8288
8487
|
depthStencil: {
|
|
@@ -8316,12 +8515,16 @@ export class Engine {
|
|
|
8316
8515
|
stencilWriteMask: 0,
|
|
8317
8516
|
},
|
|
8318
8517
|
}
|
|
8319
|
-
this.
|
|
8320
|
-
this.
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8518
|
+
this.initRenderPipeline(this.renderPipelineDesc(outlineDesc), (p) => (this.outlinePipeline = p), true)
|
|
8519
|
+
this.initRenderPipeline(
|
|
8520
|
+
this.renderPipelineDesc({
|
|
8521
|
+
...outlineDesc,
|
|
8522
|
+
label: "outline pipeline (mirror)",
|
|
8523
|
+
cullMode: "front",
|
|
8524
|
+
}),
|
|
8525
|
+
(p) => (this.outlineMirrorPipeline = p),
|
|
8526
|
+
true,
|
|
8527
|
+
)
|
|
8325
8528
|
|
|
8326
8529
|
// ─── Selection overlay (screen-space edge-detect on a per-material mask) ───
|
|
8327
8530
|
// Reuses outline camera + main skinMats bind group layouts. No group 2 (no per-mat uniform).
|
|
@@ -8333,19 +8536,22 @@ export class Engine {
|
|
|
8333
8536
|
label: "selection mask shader",
|
|
8334
8537
|
code: SELECTION_MASK_SHADER_WGSL,
|
|
8335
8538
|
})
|
|
8336
|
-
this.
|
|
8337
|
-
|
|
8338
|
-
|
|
8339
|
-
|
|
8340
|
-
|
|
8341
|
-
|
|
8342
|
-
|
|
8343
|
-
|
|
8539
|
+
this.initRenderPipeline(
|
|
8540
|
+
{
|
|
8541
|
+
label: "selection mask pipeline",
|
|
8542
|
+
layout: selectionMaskPipelineLayout,
|
|
8543
|
+
vertex: { module: selectionMaskShaderModule, entryPoint: "vs", buffers: outlineVertexBuffers },
|
|
8544
|
+
fragment: {
|
|
8545
|
+
module: selectionMaskShaderModule,
|
|
8546
|
+
entryPoint: "fs",
|
|
8547
|
+
targets: [{ format: "r8unorm" }],
|
|
8548
|
+
},
|
|
8549
|
+
primitive: { cullMode: "none" },
|
|
8550
|
+
// Single-sample, no depth (depth-always via not attaching a depth buffer at all).
|
|
8551
|
+
multisample: { count: 1 },
|
|
8344
8552
|
},
|
|
8345
|
-
|
|
8346
|
-
|
|
8347
|
-
multisample: { count: 1 },
|
|
8348
|
-
})
|
|
8553
|
+
(p) => (this.selectionMaskPipeline = p),
|
|
8554
|
+
)
|
|
8349
8555
|
|
|
8350
8556
|
this.selectionEdgeBindGroupLayout = this.device.createBindGroupLayout({
|
|
8351
8557
|
label: "selection edge bind group layout",
|
|
@@ -8363,26 +8569,29 @@ export class Engine {
|
|
|
8363
8569
|
label: "selection edge shader",
|
|
8364
8570
|
code: SELECTION_EDGE_SHADER_WGSL,
|
|
8365
8571
|
})
|
|
8366
|
-
this.
|
|
8367
|
-
|
|
8368
|
-
|
|
8369
|
-
|
|
8370
|
-
|
|
8371
|
-
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
|
|
8572
|
+
this.initRenderPipeline(
|
|
8573
|
+
{
|
|
8574
|
+
label: "selection edge pipeline",
|
|
8575
|
+
layout: selectionEdgePipelineLayout,
|
|
8576
|
+
vertex: { module: selectionEdgeShaderModule, entryPoint: "vs" },
|
|
8577
|
+
fragment: {
|
|
8578
|
+
module: selectionEdgeShaderModule,
|
|
8579
|
+
entryPoint: "fs",
|
|
8580
|
+
targets: [
|
|
8581
|
+
{
|
|
8582
|
+
format: this.presentationFormat,
|
|
8583
|
+
blend: {
|
|
8584
|
+
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
8585
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
8586
|
+
},
|
|
8379
8587
|
},
|
|
8380
|
-
|
|
8381
|
-
|
|
8588
|
+
],
|
|
8589
|
+
},
|
|
8590
|
+
primitive: { topology: "triangle-list" },
|
|
8591
|
+
multisample: { count: 1 },
|
|
8382
8592
|
},
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
})
|
|
8593
|
+
(p) => (this.selectionEdgePipeline = p),
|
|
8594
|
+
)
|
|
8386
8595
|
this.selectionSampler = this.device.createSampler({
|
|
8387
8596
|
label: "selection sampler",
|
|
8388
8597
|
magFilter: "linear",
|
|
@@ -8402,10 +8611,7 @@ export class Engine {
|
|
|
8402
8611
|
// ─── Editor overlays (instanced wireframe primitives) ────────────
|
|
8403
8612
|
this.setupOverlay()
|
|
8404
8613
|
|
|
8405
|
-
// ─── Bloom
|
|
8406
|
-
// Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
|
|
8407
|
-
// Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
|
|
8408
|
-
// Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
|
|
8614
|
+
// ─── Bloom: Aether Gazer's chain (prefilter → Gaussian down chain → scatter up chain) ───
|
|
8409
8615
|
this.bloomSampler = this.device.createSampler({
|
|
8410
8616
|
label: "bloom sampler",
|
|
8411
8617
|
magFilter: "linear",
|
|
@@ -8413,88 +8619,26 @@ export class Engine {
|
|
|
8413
8619
|
addressModeU: "clamp-to-edge",
|
|
8414
8620
|
addressModeV: "clamp-to-edge",
|
|
8415
8621
|
})
|
|
8416
|
-
this.
|
|
8417
|
-
label: "bloom
|
|
8418
|
-
size: 16,
|
|
8419
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
8420
|
-
})
|
|
8421
|
-
this.bloomUpsampleUniformBuffer = this.device.createBuffer({
|
|
8422
|
-
label: "bloom upsample uniforms",
|
|
8423
|
-
size: 16,
|
|
8424
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
8425
|
-
})
|
|
8426
|
-
|
|
8427
|
-
this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
|
|
8428
|
-
label: "bloom blit layout",
|
|
8622
|
+
this.bloomPrefilterBindGroupLayout = this.device.createBindGroupLayout({
|
|
8623
|
+
label: "bloom prefilter layout",
|
|
8429
8624
|
entries: [
|
|
8430
8625
|
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
8431
8626
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
8432
8627
|
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
8433
8628
|
],
|
|
8434
8629
|
})
|
|
8435
|
-
this.
|
|
8436
|
-
label: "bloom
|
|
8630
|
+
this.bloomBlurBindGroupLayout = this.device.createBindGroupLayout({
|
|
8631
|
+
label: "bloom blur layout",
|
|
8437
8632
|
entries: [
|
|
8438
8633
|
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8439
8634
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
8440
8635
|
],
|
|
8441
8636
|
})
|
|
8442
|
-
this.
|
|
8443
|
-
label: "bloom upsample layout",
|
|
8444
|
-
entries: [
|
|
8445
|
-
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
|
|
8446
|
-
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
|
|
8447
|
-
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
8448
|
-
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
8449
|
-
],
|
|
8450
|
-
})
|
|
8451
|
-
|
|
8452
|
-
const bloomBlitShader = this.device.createShaderModule({
|
|
8453
|
-
label: "bloom blit (Karis prefilter)",
|
|
8454
|
-
code: BLOOM_BLIT_SHADER_WGSL,
|
|
8455
|
-
})
|
|
8637
|
+
this.ensureBloomPipelines()
|
|
8456
8638
|
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
})
|
|
8461
|
-
|
|
8462
|
-
const bloomUpsampleShader = this.device.createShaderModule({
|
|
8463
|
-
label: "bloom upsample 9-tap tent",
|
|
8464
|
-
code: BLOOM_UPSAMPLE_SHADER_WGSL,
|
|
8465
|
-
})
|
|
8466
|
-
|
|
8467
|
-
const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] })
|
|
8468
|
-
const bloomDownLayout = this.device.createPipelineLayout({
|
|
8469
|
-
bindGroupLayouts: [this.bloomDownsampleBindGroupLayout],
|
|
8470
|
-
})
|
|
8471
|
-
const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] })
|
|
8472
|
-
|
|
8473
|
-
this.bloomBlitPipeline = this.device.createRenderPipeline({
|
|
8474
|
-
label: "bloom blit pipeline",
|
|
8475
|
-
layout: bloomBlitLayout,
|
|
8476
|
-
vertex: { module: bloomBlitShader, entryPoint: "vs" },
|
|
8477
|
-
fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
8478
|
-
primitive: { topology: "triangle-list" },
|
|
8479
|
-
})
|
|
8480
|
-
this.bloomDownsamplePipeline = this.device.createRenderPipeline({
|
|
8481
|
-
label: "bloom downsample pipeline",
|
|
8482
|
-
layout: bloomDownLayout,
|
|
8483
|
-
vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
|
|
8484
|
-
fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
8485
|
-
primitive: { topology: "triangle-list" },
|
|
8486
|
-
})
|
|
8487
|
-
this.bloomUpsamplePipeline = this.device.createRenderPipeline({
|
|
8488
|
-
label: "bloom upsample pipeline",
|
|
8489
|
-
layout: bloomUpLayout,
|
|
8490
|
-
vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
|
|
8491
|
-
fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
8492
|
-
primitive: { topology: "triangle-list" },
|
|
8493
|
-
})
|
|
8494
|
-
|
|
8495
|
-
// ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
|
|
8496
|
-
// Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
|
|
8497
|
-
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
8639
|
+
// ─── Composite: HDR + bloom → view transform → swapchain (premultiplied) ───
|
|
8640
|
+
// Bloom color/intensity applied HERE: the chain is pure energy, tint and
|
|
8641
|
+
// strength belong to the combine step.
|
|
8498
8642
|
this.compositeUniformBuffer = this.device.createBuffer({
|
|
8499
8643
|
label: "composite view uniforms",
|
|
8500
8644
|
// 15 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
|
|
@@ -8536,8 +8680,6 @@ export class Engine {
|
|
|
8536
8680
|
// Aux mask/alpha texture — composite reads .g to reconstruct the alpha that
|
|
8537
8681
|
// used to live in the HDR target before the rg11b10ufloat switch.
|
|
8538
8682
|
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8539
|
-
// Filmic tone LUT (r16float, filterable) — sampled with the binding-2 sampler.
|
|
8540
|
-
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8541
8683
|
// 360 backdrop equirect (PhotoDome-style skybox) — 1×1 fallback when unset.
|
|
8542
8684
|
{ binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8543
8685
|
// User background-effect params — dummy buffer when no effect is set. The
|
|
@@ -8552,9 +8694,6 @@ export class Engine {
|
|
|
8552
8694
|
texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
|
|
8553
8695
|
},
|
|
8554
8696
|
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
8555
|
-
// AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
|
|
8556
|
-
// 1×1×1 stand-in keeps the bind group valid until it arrives.
|
|
8557
|
-
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
|
|
8558
8697
|
// The scene's own grade cube (setStageGrade) — 1×1×1 stand-in while it
|
|
8559
8698
|
// has none, which the flag bit keeps from ever being sampled.
|
|
8560
8699
|
{ binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
|
|
@@ -8580,12 +8719,14 @@ export class Engine {
|
|
|
8580
8719
|
this.compositePipelineLayout = this.device.createPipelineLayout({
|
|
8581
8720
|
bindGroupLayouts: [this.compositeBindGroupLayout],
|
|
8582
8721
|
})
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
|
|
8586
|
-
|
|
8587
|
-
|
|
8588
|
-
|
|
8722
|
+
// The same pair setEffects asks for with no field mounts, so an install
|
|
8723
|
+
// that adds none finds it built.
|
|
8724
|
+
this.initPipelineJobs?.push(
|
|
8725
|
+
this.compositePipelines(false, false).then(([identity, gamma]) => {
|
|
8726
|
+
this.compositePipelineIdentity = identity
|
|
8727
|
+
this.compositePipelineGamma = gamma
|
|
8728
|
+
}),
|
|
8729
|
+
)
|
|
8589
8730
|
|
|
8590
8731
|
// GPU vertex-morph compute pipeline (shared by all models; per-model bind groups).
|
|
8591
8732
|
// Bindings: 0-4 read-only storage (base pos, CSR rowStart/colMorph/colOffset, weights),
|
|
@@ -8603,14 +8744,17 @@ export class Engine {
|
|
|
8603
8744
|
{ binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
|
|
8604
8745
|
],
|
|
8605
8746
|
})
|
|
8606
|
-
this.
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
8747
|
+
this.initComputePipeline(
|
|
8748
|
+
{
|
|
8749
|
+
label: "morph compute pipeline",
|
|
8750
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.morphComputeBindGroupLayout] }),
|
|
8751
|
+
compute: {
|
|
8752
|
+
module: this.device.createShaderModule({ label: "morph compute shader", code: MORPH_COMPUTE_WGSL }),
|
|
8753
|
+
entryPoint: "cs",
|
|
8754
|
+
},
|
|
8612
8755
|
},
|
|
8613
|
-
|
|
8756
|
+
(p) => (this.morphComputePipeline = p),
|
|
8757
|
+
)
|
|
8614
8758
|
|
|
8615
8759
|
// GPU frustum cull. One pipeline for the whole scene; the bind group is rebuilt
|
|
8616
8760
|
// with the buffers whenever the draw list changes. See shaders/passes/cull.ts.
|
|
@@ -8631,20 +8775,26 @@ export class Engine {
|
|
|
8631
8775
|
// encoder it is set on, so a WGSL slip here would take the whole frame down —
|
|
8632
8776
|
// every pass, every model, an unrelated-looking cascade of style-group and
|
|
8633
8777
|
// effect failures. Catching it turns that into "culling is off".
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8638
|
-
|
|
8639
|
-
|
|
8640
|
-
|
|
8641
|
-
|
|
8642
|
-
|
|
8643
|
-
|
|
8644
|
-
|
|
8645
|
-
|
|
8646
|
-
|
|
8647
|
-
|
|
8778
|
+
// Async, so a failure arrives as the rejection rather than a scoped error.
|
|
8779
|
+
const cullBuild = this.device
|
|
8780
|
+
.createComputePipelineAsync({
|
|
8781
|
+
label: "cull pipeline",
|
|
8782
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.cullBindGroupLayout] }),
|
|
8783
|
+
compute: {
|
|
8784
|
+
module: this.device.createShaderModule({ label: "cull compute shader", code: CULL_COMPUTE_WGSL }),
|
|
8785
|
+
entryPoint: "cs",
|
|
8786
|
+
},
|
|
8787
|
+
})
|
|
8788
|
+
.then(
|
|
8789
|
+
(p) => {
|
|
8790
|
+
this.cullPipeline = p
|
|
8791
|
+
},
|
|
8792
|
+
(err) => {
|
|
8793
|
+
console.error(`[cull] pipeline failed to compile — frustum culling disabled:\n${(err as Error).message}`)
|
|
8794
|
+
this.cullPipeline = null
|
|
8795
|
+
},
|
|
8796
|
+
)
|
|
8797
|
+
this.initPipelineJobs?.push(cullBuild)
|
|
8648
8798
|
|
|
8649
8799
|
this.bloomPassDescriptor = {
|
|
8650
8800
|
label: "bloom pass",
|
|
@@ -8691,21 +8841,24 @@ export class Engine {
|
|
|
8691
8841
|
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
|
|
8692
8842
|
})
|
|
8693
8843
|
|
|
8694
|
-
this.
|
|
8695
|
-
|
|
8696
|
-
|
|
8697
|
-
|
|
8698
|
-
|
|
8699
|
-
|
|
8700
|
-
|
|
8701
|
-
|
|
8702
|
-
|
|
8703
|
-
|
|
8704
|
-
|
|
8705
|
-
|
|
8706
|
-
|
|
8844
|
+
this.initRenderPipeline(
|
|
8845
|
+
{
|
|
8846
|
+
label: "pick pipeline",
|
|
8847
|
+
layout: pickPipelineLayout,
|
|
8848
|
+
vertex: { module: pickShaderModule, buffers: fullVertexBuffers },
|
|
8849
|
+
fragment: {
|
|
8850
|
+
module: pickShaderModule,
|
|
8851
|
+
targets: [{ format: "rgba8unorm" }],
|
|
8852
|
+
},
|
|
8853
|
+
primitive: { cullMode: "none" },
|
|
8854
|
+
depthStencil: {
|
|
8855
|
+
format: "depth24plus",
|
|
8856
|
+
depthWriteEnabled: true,
|
|
8857
|
+
depthCompare: this.depthAhead,
|
|
8858
|
+
},
|
|
8707
8859
|
},
|
|
8708
|
-
|
|
8860
|
+
(p) => (this.pickPipeline = p),
|
|
8861
|
+
)
|
|
8709
8862
|
|
|
8710
8863
|
this.pickReadbackBuffer = this.device.createBuffer({
|
|
8711
8864
|
label: "pick readback",
|
|
@@ -8782,6 +8935,9 @@ export class Engine {
|
|
|
8782
8935
|
// handleResize — so the size asked for before the device existed is applied
|
|
8783
8936
|
// in full the moment there is something to apply it to.
|
|
8784
8937
|
if (!this.device) return
|
|
8938
|
+
// Nor while init's pipelines are still compiling: init ends with its own
|
|
8939
|
+
// handleResize, which applies whatever was asked for meanwhile.
|
|
8940
|
+
if (this.initPipelineJobs) return
|
|
8785
8941
|
// Fixed override (offline/video rendering) wins; otherwise track CSS size × dpr.
|
|
8786
8942
|
const dpr = window.devicePixelRatio || 1
|
|
8787
8943
|
const width = this.fixedRenderSize ? this.fixedRenderSize.width : Math.floor(this.canvas.clientWidth * dpr)
|
|
@@ -9006,38 +9162,6 @@ export class Engine {
|
|
|
9006
9162
|
this.buildGroundBindGroup()
|
|
9007
9163
|
this.buildMirrorSurfaceBindGroup()
|
|
9008
9164
|
|
|
9009
|
-
// Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
|
|
9010
|
-
// Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
|
|
9011
|
-
const bw = Math.max(1, Math.floor(width / 2))
|
|
9012
|
-
const bh = Math.max(1, Math.floor(height / 2))
|
|
9013
|
-
const shortSide = Math.max(1, Math.min(bw, bh))
|
|
9014
|
-
this.bloomMipCount = Math.max(1, Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1))
|
|
9015
|
-
this.bloomDownTexture?.destroy()
|
|
9016
|
-
this.bloomDownTexture = this.device.createTexture({
|
|
9017
|
-
label: "bloom down pyramid",
|
|
9018
|
-
size: [bw, bh],
|
|
9019
|
-
mipLevelCount: this.bloomMipCount,
|
|
9020
|
-
format: this.hdrFormat,
|
|
9021
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
9022
|
-
})
|
|
9023
|
-
this.bloomUpTexture?.destroy()
|
|
9024
|
-
this.bloomUpTexture = this.device.createTexture({
|
|
9025
|
-
label: "bloom up pyramid",
|
|
9026
|
-
size: [bw, bh],
|
|
9027
|
-
mipLevelCount: Math.max(1, this.bloomMipCount - 1),
|
|
9028
|
-
format: this.hdrFormat,
|
|
9029
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
9030
|
-
})
|
|
9031
|
-
this.bloomDownMipViews = []
|
|
9032
|
-
for (let i = 0; i < this.bloomMipCount; i++) {
|
|
9033
|
-
this.bloomDownMipViews.push(this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }))
|
|
9034
|
-
}
|
|
9035
|
-
this.bloomUpMipViews = []
|
|
9036
|
-
const upLevels = Math.max(1, this.bloomMipCount - 1)
|
|
9037
|
-
for (let i = 0; i < upLevels; i++) {
|
|
9038
|
-
this.bloomUpMipViews.push(this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }))
|
|
9039
|
-
}
|
|
9040
|
-
|
|
9041
9165
|
this.depthTexture?.destroy()
|
|
9042
9166
|
this.depthTexture = this.device.createTexture({
|
|
9043
9167
|
label: "depth texture",
|
|
@@ -9157,54 +9281,10 @@ export class Engine {
|
|
|
9157
9281
|
],
|
|
9158
9282
|
}
|
|
9159
9283
|
|
|
9160
|
-
this.
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
this.bloomBlitBindGroup = this.device.createBindGroup({
|
|
9165
|
-
label: "bloom blit bind group",
|
|
9166
|
-
layout: this.bloomBlitBindGroupLayout,
|
|
9167
|
-
entries: [
|
|
9168
|
-
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
9169
|
-
{ binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
|
|
9170
|
-
{ binding: 2, resource: this.maskResolveView },
|
|
9171
|
-
],
|
|
9172
|
-
})
|
|
9173
|
-
// Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
|
|
9174
|
-
this.bloomDownsampleBindGroups = []
|
|
9175
|
-
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
9176
|
-
this.bloomDownsampleBindGroups.push(
|
|
9177
|
-
this.device.createBindGroup({
|
|
9178
|
-
label: `bloom downsample ${i}`,
|
|
9179
|
-
layout: this.bloomDownsampleBindGroupLayout,
|
|
9180
|
-
entries: [
|
|
9181
|
-
{ binding: 0, resource: this.bloomDownMipViews[i - 1] },
|
|
9182
|
-
{ binding: 1, resource: this.bloomSampler },
|
|
9183
|
-
],
|
|
9184
|
-
}),
|
|
9185
|
-
)
|
|
9186
|
-
}
|
|
9187
|
-
// Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
|
|
9188
|
-
// subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
|
|
9189
|
-
this.bloomUpsampleBindGroups = []
|
|
9190
|
-
const topIdx = this.bloomMipCount - 2
|
|
9191
|
-
for (let i = topIdx; i >= 0; i--) {
|
|
9192
|
-
const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1]
|
|
9193
|
-
this.bloomUpsampleBindGroups.push(
|
|
9194
|
-
this.device.createBindGroup({
|
|
9195
|
-
label: `bloom upsample ${i}`,
|
|
9196
|
-
layout: this.bloomUpsampleBindGroupLayout,
|
|
9197
|
-
entries: [
|
|
9198
|
-
{ binding: 0, resource: srcView },
|
|
9199
|
-
{ binding: 1, resource: this.bloomDownMipViews[i] },
|
|
9200
|
-
{ binding: 2, resource: this.bloomSampler },
|
|
9201
|
-
{ binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
|
|
9202
|
-
],
|
|
9203
|
-
}),
|
|
9204
|
-
)
|
|
9205
|
-
}
|
|
9206
|
-
// Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
|
|
9207
|
-
this.compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0]
|
|
9284
|
+
if (this.compositeBindGroupLayout && this.bloomPrefilterBindGroupLayout) {
|
|
9285
|
+
this.buildBloomTargets()
|
|
9286
|
+
// The composite reads the finished chain: up[0], or down[0] when there is one level.
|
|
9287
|
+
this.compositeBloomView = this.bloomLevels > 1 ? this.bloomUpViews[0] : this.bloomDownViews[0]
|
|
9208
9288
|
this.rebuildCompositeBindGroup()
|
|
9209
9289
|
}
|
|
9210
9290
|
|
|
@@ -9479,17 +9559,20 @@ export class Engine {
|
|
|
9479
9559
|
},
|
|
9480
9560
|
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
9481
9561
|
} satisfies GPURenderPipelineDescriptor
|
|
9482
|
-
this.overlayPipeline =
|
|
9562
|
+
this.initRenderPipeline(overlayPipelineDescriptor, (p) => (this.overlayPipeline = p))
|
|
9483
9563
|
|
|
9484
9564
|
// The solid volumes: the same shader and layout, with no depth write and no
|
|
9485
9565
|
// culling. A translucent body must not hide the rig behind it, and you have
|
|
9486
9566
|
// to see its far wall for it to read as a volume rather than a silhouette.
|
|
9487
|
-
this.
|
|
9488
|
-
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
|
|
9492
|
-
|
|
9567
|
+
this.initRenderPipeline(
|
|
9568
|
+
{
|
|
9569
|
+
...overlayPipelineDescriptor,
|
|
9570
|
+
label: "overlay solid pipeline",
|
|
9571
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
9572
|
+
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
|
|
9573
|
+
},
|
|
9574
|
+
(p) => (this.overlaySolidPipeline = p),
|
|
9575
|
+
)
|
|
9493
9576
|
|
|
9494
9577
|
const compositeShader = this.device.createShaderModule({
|
|
9495
9578
|
label: "overlay composite shader",
|
|
@@ -9580,34 +9663,37 @@ export class Engine {
|
|
|
9580
9663
|
],
|
|
9581
9664
|
})
|
|
9582
9665
|
const wireShader = this.device.createShaderModule({ label: "wireframe shader", code: WIREFRAME_SHADER_WGSL })
|
|
9583
|
-
this.
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9587
|
-
|
|
9588
|
-
|
|
9589
|
-
|
|
9590
|
-
|
|
9591
|
-
|
|
9592
|
-
|
|
9593
|
-
|
|
9594
|
-
|
|
9595
|
-
|
|
9596
|
-
|
|
9597
|
-
|
|
9598
|
-
|
|
9599
|
-
|
|
9600
|
-
|
|
9666
|
+
this.initRenderPipeline(
|
|
9667
|
+
{
|
|
9668
|
+
label: "wireframe pipeline",
|
|
9669
|
+
layout: this.device.createPipelineLayout({
|
|
9670
|
+
label: "wireframe pipeline layout",
|
|
9671
|
+
bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
|
|
9672
|
+
}),
|
|
9673
|
+
// No vertex stream: an edge quad's corners come from two different model
|
|
9674
|
+
// vertices, so the mesh is read through storage instead.
|
|
9675
|
+
vertex: { module: wireShader, entryPoint: "vs" },
|
|
9676
|
+
fragment: {
|
|
9677
|
+
module: wireShader,
|
|
9678
|
+
entryPoint: "fs",
|
|
9679
|
+
targets: [
|
|
9680
|
+
{
|
|
9681
|
+
format: this.presentationFormat,
|
|
9682
|
+
blend: {
|
|
9683
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
9684
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
9685
|
+
},
|
|
9601
9686
|
},
|
|
9602
|
-
|
|
9603
|
-
|
|
9687
|
+
],
|
|
9688
|
+
},
|
|
9689
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
9690
|
+
// Depth-TESTED but not written: the mesh is a haze the rig reads against,
|
|
9691
|
+
// so a bone behind a triangle must not be punched out by it.
|
|
9692
|
+
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: this.depthAhead },
|
|
9693
|
+
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
9604
9694
|
},
|
|
9605
|
-
|
|
9606
|
-
|
|
9607
|
-
// so a bone behind a triangle must not be punched out by it.
|
|
9608
|
-
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: this.depthAhead },
|
|
9609
|
-
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
9610
|
-
})
|
|
9695
|
+
(p) => (this.wireframePipeline = p),
|
|
9696
|
+
)
|
|
9611
9697
|
// The mesh's own depth, so the wireframe can be occluded by the body it
|
|
9612
9698
|
// belongs to. Occluded is the default everywhere — Blender's edit mode, Maya,
|
|
9613
9699
|
// three's and Babylon's wireframe materials all depth-test, and X-ray is a
|
|
@@ -10348,37 +10434,446 @@ export class Engine {
|
|
|
10348
10434
|
}
|
|
10349
10435
|
|
|
10350
10436
|
/**
|
|
10351
|
-
* The per-frame bind groups, the camera's and the mirror's.
|
|
10352
|
-
*
|
|
10353
|
-
* A METHOD rather than two literals at init, because one of their resources
|
|
10354
|
-
* changes after it: the world's sky texture is swapped whenever an HDRI is
|
|
10355
|
-
* installed or cleared, and a bind group holds the view it was built with.
|
|
10356
|
-
* The mirror's is identical but for the camera — a reflection is the same
|
|
10357
|
-
* scene lit the same way, seen from a reflected eye.
|
|
10358
|
-
*/
|
|
10359
|
-
/**
|
|
10360
|
-
* Skies this engine has replaced, kept alive rather than destroyed.
|
|
10361
|
-
*
|
|
10362
|
-
* NOT DESTROYED AT ALL, after three attempts that each deferred it further:
|
|
10363
|
-
* one frame, then three, then "once both bind groups have rebuilt". Every one
|
|
10364
|
-
* still produced "destroyed texture used in a submit" somewhere — a command
|
|
10365
|
-
* buffer encoded before the swap, a rebuild that bailed on half-built
|
|
10366
|
-
* resources, an engine torn down by a hot reload whose last frame is still in
|
|
10367
|
-
* flight. The engine cannot see all of those, and each attempt to enumerate
|
|
10368
|
-
* them found another.
|
|
10369
|
-
*
|
|
10370
|
-
* So the trade is taken explicitly: a replaced sky is a megabyte or two held
|
|
10371
|
-
* until the page goes away, against a validation error every frame. A scene
|
|
10372
|
-
* swaps its world a handful of times in a session, and the GPU frees all of
|
|
10373
|
-
* it when the device does.
|
|
10437
|
+
* The per-frame bind groups, the camera's and the mirror's.
|
|
10438
|
+
*
|
|
10439
|
+
* A METHOD rather than two literals at init, because one of their resources
|
|
10440
|
+
* changes after it: the world's sky texture is swapped whenever an HDRI is
|
|
10441
|
+
* installed or cleared, and a bind group holds the view it was built with.
|
|
10442
|
+
* The mirror's is identical but for the camera — a reflection is the same
|
|
10443
|
+
* scene lit the same way, seen from a reflected eye.
|
|
10444
|
+
*/
|
|
10445
|
+
/**
|
|
10446
|
+
* Skies this engine has replaced, kept alive rather than destroyed.
|
|
10447
|
+
*
|
|
10448
|
+
* NOT DESTROYED AT ALL, after three attempts that each deferred it further:
|
|
10449
|
+
* one frame, then three, then "once both bind groups have rebuilt". Every one
|
|
10450
|
+
* still produced "destroyed texture used in a submit" somewhere — a command
|
|
10451
|
+
* buffer encoded before the swap, a rebuild that bailed on half-built
|
|
10452
|
+
* resources, an engine torn down by a hot reload whose last frame is still in
|
|
10453
|
+
* flight. The engine cannot see all of those, and each attempt to enumerate
|
|
10454
|
+
* them found another.
|
|
10455
|
+
*
|
|
10456
|
+
* So the trade is taken explicitly: a replaced sky is a megabyte or two held
|
|
10457
|
+
* until the page goes away, against a validation error every frame. A scene
|
|
10458
|
+
* swaps its world a handful of times in a session, and the GPU frees all of
|
|
10459
|
+
* it when the device does.
|
|
10460
|
+
*/
|
|
10461
|
+
private retiredSkies: GPUTexture[] = []
|
|
10462
|
+
|
|
10463
|
+
/** A world was installed or cleared and the bind groups that name its texture
|
|
10464
|
+
* may not have been rebuilt yet — both rebuilds bail when their own
|
|
10465
|
+
* resources are half-built, which is exactly the moment a host swaps a sky.
|
|
10466
|
+
* Retried at the top of a frame until one of them takes. */
|
|
10467
|
+
private worldBindingsDirty = false
|
|
10468
|
+
|
|
10469
|
+
// ─── The stage's reflection probe ───────────────────────────────────
|
|
10470
|
+
//
|
|
10471
|
+
// What a game bakes for every room: a cube of the stage itself, seen from
|
|
10472
|
+
// its middle, prefiltered down its mips by the specular lobe, and read by
|
|
10473
|
+
// every surface box-projected onto the stage's bounds — so a lacquered
|
|
10474
|
+
// counter reflects the bottles behind it and a wet floor the lamps above,
|
|
10475
|
+
// not a sky the room has no window onto. Captured here automatically once a
|
|
10476
|
+
// stage has loaded and its looks have settled; nothing to set.
|
|
10477
|
+
|
|
10478
|
+
/** Drop the capture's resources, so the next capture rebinds the sky. */
|
|
10479
|
+
private dropProbeCapture(): void {
|
|
10480
|
+
const cap = this.probeCapture
|
|
10481
|
+
if (!cap) return
|
|
10482
|
+
this.probeCapture = null
|
|
10483
|
+
void this.device.queue.onSubmittedWorkDone().then(() => {
|
|
10484
|
+
for (const t of cap.msTargets) t.destroy()
|
|
10485
|
+
cap.depth.destroy()
|
|
10486
|
+
cap.color.destroy()
|
|
10487
|
+
cap.aux.destroy()
|
|
10488
|
+
cap.cube.destroy()
|
|
10489
|
+
cap.steps.destroy()
|
|
10490
|
+
})
|
|
10491
|
+
}
|
|
10492
|
+
|
|
10493
|
+
/** Ask for a fresh capture of the stage's reflection probe. It runs at the
|
|
10494
|
+
* start of a frame once the scene has held still for a moment — asked for
|
|
10495
|
+
* by the engine itself whenever the stage or its looks change; a host may
|
|
10496
|
+
* call it after changing something the engine cannot see. */
|
|
10497
|
+
captureReflectionProbe(): void {
|
|
10498
|
+
if (!this.probeDirty) this.probeDirtyFrame = this.probeFrame
|
|
10499
|
+
this.probeDirty = true
|
|
10500
|
+
}
|
|
10501
|
+
|
|
10502
|
+
/** The stage's world-space bounds: every stage model's material boxes
|
|
10503
|
+
* through its placement. Null without a stage. */
|
|
10504
|
+
private stageBounds(): { min: [number, number, number]; max: [number, number, number] } | null {
|
|
10505
|
+
const min: [number, number, number] = [Infinity, Infinity, Infinity]
|
|
10506
|
+
const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]
|
|
10507
|
+
const p = new Vec3(0, 0, 0)
|
|
10508
|
+
let any = false
|
|
10509
|
+
for (const inst of this.modelInstances.values()) {
|
|
10510
|
+
if (!inst.isStage || !inst.model.visible) continue
|
|
10511
|
+
const m = inst.model
|
|
10512
|
+
for (const draw of inst.drawCalls) {
|
|
10513
|
+
const b = draw.bounds
|
|
10514
|
+
if (!b || b.length < 6 || !(b[0] <= b[3])) continue
|
|
10515
|
+
// The eight corners of the material's model-space box, placed.
|
|
10516
|
+
for (let k = 0; k < 8; k++) {
|
|
10517
|
+
p.setXYZ((k & 1 ? b[3] : b[0]) * m.scale, (k & 2 ? b[4] : b[1]) * m.scale, (k & 4 ? b[5] : b[2]) * m.scale)
|
|
10518
|
+
Quat.rotateVecInto(m.rotation, p, p)
|
|
10519
|
+
const w = [p.x + m.position.x, p.y + m.position.y, p.z + m.position.z]
|
|
10520
|
+
for (let a = 0; a < 3; a++) {
|
|
10521
|
+
if (w[a] < min[a]) min[a] = w[a]
|
|
10522
|
+
if (w[a] > max[a]) max[a] = w[a]
|
|
10523
|
+
}
|
|
10524
|
+
any = true
|
|
10525
|
+
}
|
|
10526
|
+
}
|
|
10527
|
+
}
|
|
10528
|
+
return any ? { min, max } : null
|
|
10529
|
+
}
|
|
10530
|
+
|
|
10531
|
+
/** Run a pending capture once the scene has settled — see captureReflectionProbe. */
|
|
10532
|
+
private maybeCaptureProbe(): void {
|
|
10533
|
+
this.probeFrame++
|
|
10534
|
+
if (!this.probeDirty || !this.probeCaptureBindGroup) return
|
|
10535
|
+
if (this.probeFrame - this.probeDirtyFrame < Engine.PROBE_SETTLE_FRAMES) return
|
|
10536
|
+
this.probeDirty = false
|
|
10537
|
+
const bounds = this.stageBounds()
|
|
10538
|
+
if (!bounds) {
|
|
10539
|
+
// No stage: the probe goes dark and every reader falls back to the sky.
|
|
10540
|
+
this.probeData.fill(0)
|
|
10541
|
+
this.device.queue.writeBuffer(this.probeUniformBuffer, 0, this.probeData)
|
|
10542
|
+
return
|
|
10543
|
+
}
|
|
10544
|
+
try {
|
|
10545
|
+
this.renderProbe(bounds)
|
|
10546
|
+
} catch (e) {
|
|
10547
|
+
console.warn("[reze-engine] reflection probe capture failed:", e)
|
|
10548
|
+
}
|
|
10549
|
+
}
|
|
10550
|
+
|
|
10551
|
+
private ensureProbeCapture(): NonNullable<Engine["probeCapture"]> {
|
|
10552
|
+
if (this.probeCapture) return this.probeCapture
|
|
10553
|
+
const size = PROBE_CAPTURE_SIZE
|
|
10554
|
+
const formats = sceneColorFormats(this.sceneFormats)
|
|
10555
|
+
const msTargets = formats.map((format, i) =>
|
|
10556
|
+
this.device.createTexture({
|
|
10557
|
+
label: `reflection probe capture target ${i}`,
|
|
10558
|
+
size: [size, size],
|
|
10559
|
+
sampleCount: Engine.MULTISAMPLE_COUNT,
|
|
10560
|
+
format,
|
|
10561
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
10562
|
+
}),
|
|
10563
|
+
)
|
|
10564
|
+
const depth = this.device.createTexture({
|
|
10565
|
+
label: "reflection probe capture depth",
|
|
10566
|
+
size: [size, size],
|
|
10567
|
+
sampleCount: Engine.MULTISAMPLE_COUNT,
|
|
10568
|
+
format: this.depthFormat,
|
|
10569
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
10570
|
+
})
|
|
10571
|
+
const layered = (label: string, format: GPUTextureFormat) =>
|
|
10572
|
+
this.device.createTexture({
|
|
10573
|
+
label,
|
|
10574
|
+
size: [size, size, 6],
|
|
10575
|
+
format,
|
|
10576
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
10577
|
+
})
|
|
10578
|
+
// The resolves take the scene pass's own formats: colour and coverage.
|
|
10579
|
+
const color = layered("reflection probe capture colour", formats[0])
|
|
10580
|
+
const aux = layered("reflection probe capture coverage", formats[1])
|
|
10581
|
+
const cube = this.device.createTexture({
|
|
10582
|
+
label: "reflection probe capture cube",
|
|
10583
|
+
size: [size, size, 6],
|
|
10584
|
+
format: "rgba16float",
|
|
10585
|
+
mipLevelCount: PROBE_CAPTURE_LEVELS,
|
|
10586
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
10587
|
+
})
|
|
10588
|
+
// One 256-byte slot per step: 6 composes, then the capture's mips, then
|
|
10589
|
+
// the probe's levels, six faces each.
|
|
10590
|
+
const slots = 6 + PROBE_CAPTURE_LEVELS * 6 + PROBE_LEVELS * 6
|
|
10591
|
+
const steps = this.device.createBuffer({
|
|
10592
|
+
label: "reflection probe steps",
|
|
10593
|
+
size: slots * 256,
|
|
10594
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
10595
|
+
})
|
|
10596
|
+
const composeModule = this.device.createShaderModule({ label: "reflection probe compose", code: PROBE_COMPOSE_WGSL })
|
|
10597
|
+
const composePipeline = this.device.createRenderPipeline({
|
|
10598
|
+
label: "reflection probe compose",
|
|
10599
|
+
layout: "auto",
|
|
10600
|
+
vertex: { module: composeModule, entryPoint: "vs_full" },
|
|
10601
|
+
fragment: { module: composeModule, entryPoint: "fs_compose", targets: [{ format: "rgba16float" }] },
|
|
10602
|
+
primitive: { topology: "triangle-list" },
|
|
10603
|
+
})
|
|
10604
|
+
const filterModule = this.device.createShaderModule({ label: "reflection probe filter", code: PROBE_FILTER_WGSL })
|
|
10605
|
+
const filterPipeline = this.device.createRenderPipeline({
|
|
10606
|
+
label: "reflection probe filter",
|
|
10607
|
+
layout: "auto",
|
|
10608
|
+
vertex: { module: filterModule, entryPoint: "vs_full" },
|
|
10609
|
+
fragment: { module: filterModule, entryPoint: "fs_filter", targets: [{ format: "rgba16float" }] },
|
|
10610
|
+
primitive: { topology: "triangle-list" },
|
|
10611
|
+
})
|
|
10612
|
+
const colorArray = color.createView({ dimension: "2d-array" })
|
|
10613
|
+
const auxArray = aux.createView({ dimension: "2d-array" })
|
|
10614
|
+
const composeLayout = composePipeline.getBindGroupLayout(0)
|
|
10615
|
+
const composeGroups: GPUBindGroup[] = []
|
|
10616
|
+
for (let f = 0; f < 6; f++) {
|
|
10617
|
+
composeGroups.push(
|
|
10618
|
+
this.device.createBindGroup({
|
|
10619
|
+
label: `reflection probe compose ${f}`,
|
|
10620
|
+
layout: composeLayout,
|
|
10621
|
+
entries: [
|
|
10622
|
+
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
10623
|
+
{ binding: 2, resource: this.materialSampler },
|
|
10624
|
+
{ binding: 8, resource: this.worldEquirectView ?? this.fallbackEquirectView },
|
|
10625
|
+
{ binding: 20, resource: colorArray },
|
|
10626
|
+
{ binding: 21, resource: auxArray },
|
|
10627
|
+
{ binding: 22, resource: { buffer: steps, offset: f * 256, size: 32 } },
|
|
10628
|
+
],
|
|
10629
|
+
}),
|
|
10630
|
+
)
|
|
10631
|
+
}
|
|
10632
|
+
const filterLayout = filterPipeline.getBindGroupLayout(0)
|
|
10633
|
+
const cubeView = cube.createView({ dimension: "cube" })
|
|
10634
|
+
const filterGroups: GPUBindGroup[] = []
|
|
10635
|
+
for (let i = 0; i < PROBE_LEVELS * 6; i++) {
|
|
10636
|
+
filterGroups.push(
|
|
10637
|
+
this.device.createBindGroup({
|
|
10638
|
+
label: `reflection probe level ${i}`,
|
|
10639
|
+
layout: filterLayout,
|
|
10640
|
+
entries: [
|
|
10641
|
+
{ binding: 0, resource: cubeView },
|
|
10642
|
+
{ binding: 1, resource: this.materialSampler },
|
|
10643
|
+
{ binding: 2, resource: { buffer: steps, offset: (6 + PROBE_CAPTURE_LEVELS * 6 + i) * 256, size: 32 } },
|
|
10644
|
+
],
|
|
10645
|
+
}),
|
|
10646
|
+
)
|
|
10647
|
+
}
|
|
10648
|
+
this.probeCapture = {
|
|
10649
|
+
msTargets,
|
|
10650
|
+
depth,
|
|
10651
|
+
color,
|
|
10652
|
+
aux,
|
|
10653
|
+
cube,
|
|
10654
|
+
steps,
|
|
10655
|
+
composePipeline,
|
|
10656
|
+
filterPipeline,
|
|
10657
|
+
composeGroups,
|
|
10658
|
+
filterGroups,
|
|
10659
|
+
}
|
|
10660
|
+
return this.probeCapture
|
|
10661
|
+
}
|
|
10662
|
+
|
|
10663
|
+
/**
|
|
10664
|
+
* Capture the probe: the stage drawn six times from one point into a 128²
|
|
10665
|
+
* cube, the sky laid in behind it, a mip chain, and the GGX convolution
|
|
10666
|
+
* into the 64² cube the materials read.
|
|
10667
|
+
*
|
|
10668
|
+
* WHERE: the middle of the stage's bounds across the floor, at about a
|
|
10669
|
+
* character's chest height above the floor (y = 0 where the bounds span it,
|
|
10670
|
+
* the MMD convention, else their bottom) — where a game puts the probe of a
|
|
10671
|
+
* room the player stands in. The box it projects onto is the bounds.
|
|
10672
|
+
*
|
|
10673
|
+
* WHAT: the stage alone, in its own looks, lit by the scene's sun, lamps
|
|
10674
|
+
* and sky as they stand. The cast is not in it (a baked probe never holds
|
|
10675
|
+
* the characters), nor are effects.
|
|
10676
|
+
*/
|
|
10677
|
+
private renderProbe(bounds: { min: [number, number, number]; max: [number, number, number] }): void {
|
|
10678
|
+
const cap = this.ensureProbeCapture()
|
|
10679
|
+
const { min, max } = bounds
|
|
10680
|
+
const floor = min[1] <= 0 && max[1] >= 0 ? 0 : min[1]
|
|
10681
|
+
const eye: [number, number, number] = [
|
|
10682
|
+
(min[0] + max[0]) * 0.5,
|
|
10683
|
+
Math.min(floor + 12, (min[1] + max[1]) * 0.5),
|
|
10684
|
+
(min[2] + max[2]) * 0.5,
|
|
10685
|
+
]
|
|
10686
|
+
const span = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2])
|
|
10687
|
+
const near = 0.25
|
|
10688
|
+
const far = Math.max(span * 2, 100)
|
|
10689
|
+
// Each face's forward and up, in the convention the GPU samples a cube
|
|
10690
|
+
// with (see rz_face_dir in passes/probe.ts): +X, -X, +Y, -Y, +Z, -Z.
|
|
10691
|
+
const faces: [number, number, number, number, number, number][] = [
|
|
10692
|
+
[1, 0, 0, 0, 1, 0],
|
|
10693
|
+
[-1, 0, 0, 0, 1, 0],
|
|
10694
|
+
[0, 1, 0, 0, 0, -1],
|
|
10695
|
+
[0, -1, 0, 0, 0, 1],
|
|
10696
|
+
[0, 0, 1, 0, 1, 0],
|
|
10697
|
+
[0, 0, -1, 0, 1, 0],
|
|
10698
|
+
]
|
|
10699
|
+
const cam = this.probeCameraData
|
|
10700
|
+
const view = { perFrame: this.probeCaptureBindGroup!, args: "probe" as const, outlines: false }
|
|
10701
|
+
const formats = sceneColorFormats(this.sceneFormats)
|
|
10702
|
+
for (let f = 0; f < 6; f++) {
|
|
10703
|
+
const [fx, fy, fz, ux, uy, uz] = faces[f]
|
|
10704
|
+
Mat4.lookAtInto(cam.subarray(0, 16), eye[0], eye[1], eye[2], eye[0] + fx, eye[1] + fy, eye[2] + fz, ux, uy, uz)
|
|
10705
|
+
if (this.reversedZ) Mat4.perspectiveInto(cam.subarray(16, 32), Math.PI / 2, 1, far, near)
|
|
10706
|
+
else Mat4.perspectiveInto(cam.subarray(16, 32), Math.PI / 2, 1, near, far)
|
|
10707
|
+
cam[32] = eye[0]
|
|
10708
|
+
cam[33] = eye[1]
|
|
10709
|
+
cam[34] = eye[2]
|
|
10710
|
+
cam[35] = PROBE_CAPTURE_SIZE
|
|
10711
|
+
cam[36] = this.sceneClock
|
|
10712
|
+
this.device.queue.writeBuffer(this.probeCameraBuffer, 0, cam)
|
|
10713
|
+
const encoder = this.device.createCommandEncoder({ label: `reflection probe face ${f}` })
|
|
10714
|
+
const colorAttachments: GPURenderPassColorAttachment[] = formats.map((_, i) => ({
|
|
10715
|
+
view: cap.msTargets[i].createView(),
|
|
10716
|
+
resolveTarget:
|
|
10717
|
+
i === 0
|
|
10718
|
+
? cap.color.createView({ dimension: "2d", baseArrayLayer: f, arrayLayerCount: 1 })
|
|
10719
|
+
: i === 1
|
|
10720
|
+
? cap.aux.createView({ dimension: "2d", baseArrayLayer: f, arrayLayerCount: 1 })
|
|
10721
|
+
: undefined,
|
|
10722
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
10723
|
+
loadOp: "clear",
|
|
10724
|
+
storeOp: "discard",
|
|
10725
|
+
}))
|
|
10726
|
+
const pass = encoder.beginRenderPass({
|
|
10727
|
+
label: `reflection probe face ${f}`,
|
|
10728
|
+
colorAttachments,
|
|
10729
|
+
depthStencilAttachment: {
|
|
10730
|
+
view: cap.depth.createView(),
|
|
10731
|
+
depthClearValue: this.depthClear,
|
|
10732
|
+
depthLoadOp: "clear",
|
|
10733
|
+
depthStoreOp: "discard",
|
|
10734
|
+
stencilClearValue: 0,
|
|
10735
|
+
stencilLoadOp: "clear",
|
|
10736
|
+
stencilStoreOp: "discard",
|
|
10737
|
+
},
|
|
10738
|
+
})
|
|
10739
|
+
pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
|
|
10740
|
+
for (const inst of this.modelInstances.values()) {
|
|
10741
|
+
if (!inst.isStage || !inst.model.visible) continue
|
|
10742
|
+
this.setModelDrawState(pass, inst)
|
|
10743
|
+
this.drawMaterials(pass, inst, "opaque", view)
|
|
10744
|
+
}
|
|
10745
|
+
for (const inst of this.modelInstances.values()) {
|
|
10746
|
+
if (!inst.isStage || !inst.model.visible) continue
|
|
10747
|
+
this.setModelDrawState(pass, inst)
|
|
10748
|
+
this.drawMaterials(pass, inst, "transparent", view)
|
|
10749
|
+
}
|
|
10750
|
+
pass.end()
|
|
10751
|
+
this.device.queue.submit([encoder.finish()])
|
|
10752
|
+
}
|
|
10753
|
+
|
|
10754
|
+
// The steps: compose, capture mips, then the probe's levels.
|
|
10755
|
+
const steps = new ArrayBuffer((6 + PROBE_CAPTURE_LEVELS * 6 + PROBE_LEVELS * 6) * 256)
|
|
10756
|
+
const put = (slot: number, face: number, lod: number, roughness: number, srcSize: number, mode: number) => {
|
|
10757
|
+
const u = new Uint32Array(steps, slot * 256, 8)
|
|
10758
|
+
const fl = new Float32Array(steps, slot * 256, 8)
|
|
10759
|
+
u[0] = face
|
|
10760
|
+
fl[1] = lod
|
|
10761
|
+
fl[2] = roughness
|
|
10762
|
+
fl[3] = srcSize
|
|
10763
|
+
u[4] = mode
|
|
10764
|
+
}
|
|
10765
|
+
for (let f = 0; f < 6; f++) put(f, f, 0, 0, PROBE_CAPTURE_SIZE, 0)
|
|
10766
|
+
for (let m = 0; m < PROBE_CAPTURE_LEVELS; m++)
|
|
10767
|
+
for (let f = 0; f < 6; f++) put(6 + m * 6 + f, f, Math.max(m - 1, 0), 0, PROBE_CAPTURE_SIZE, 0)
|
|
10768
|
+
for (let m = 0; m < PROBE_LEVELS; m++)
|
|
10769
|
+
for (let f = 0; f < 6; f++)
|
|
10770
|
+
// Level 0 is the capture at the probe's size: one tap of the 128²
|
|
10771
|
+
// level-0 cube at a 64² texel's centre is its 2×2 average.
|
|
10772
|
+
put(6 + PROBE_CAPTURE_LEVELS * 6 + m * 6 + f, f, 0, probeLevelRoughness(m), PROBE_CAPTURE_SIZE, m === 0 ? 0 : 1)
|
|
10773
|
+
this.device.queue.writeBuffer(cap.steps, 0, steps)
|
|
10774
|
+
|
|
10775
|
+
const encoder = this.device.createCommandEncoder({ label: "reflection probe filter" })
|
|
10776
|
+
const run = (target: GPUTextureView, pipeline: GPURenderPipeline, group: GPUBindGroup) => {
|
|
10777
|
+
const p = encoder.beginRenderPass({ colorAttachments: [{ view: target, loadOp: "clear", storeOp: "store" }] })
|
|
10778
|
+
p.setPipeline(pipeline)
|
|
10779
|
+
p.setBindGroup(0, group)
|
|
10780
|
+
p.draw(3)
|
|
10781
|
+
p.end()
|
|
10782
|
+
}
|
|
10783
|
+
const face = (tex: GPUTexture, level: number, f: number) =>
|
|
10784
|
+
tex.createView({ dimension: "2d", baseMipLevel: level, mipLevelCount: 1, baseArrayLayer: f, arrayLayerCount: 1 })
|
|
10785
|
+
for (let f = 0; f < 6; f++) run(face(cap.cube, 0, f), cap.composePipeline, cap.composeGroups[f])
|
|
10786
|
+
// Each level from the one above. The source view spans the whole chain,
|
|
10787
|
+
// which a pass writing one of its levels may not bind — so the reads go
|
|
10788
|
+
// through a view of the levels ABOVE the one being written.
|
|
10789
|
+
for (let m = 1; m < PROBE_CAPTURE_LEVELS; m++) {
|
|
10790
|
+
const above = cap.cube.createView({ dimension: "cube", baseMipLevel: 0, mipLevelCount: m })
|
|
10791
|
+
for (let f = 0; f < 6; f++) {
|
|
10792
|
+
const group = this.device.createBindGroup({
|
|
10793
|
+
label: `reflection probe capture mip ${m}/${f}`,
|
|
10794
|
+
layout: cap.filterPipeline.getBindGroupLayout(0),
|
|
10795
|
+
entries: [
|
|
10796
|
+
{ binding: 0, resource: above },
|
|
10797
|
+
{ binding: 1, resource: this.materialSampler },
|
|
10798
|
+
{ binding: 2, resource: { buffer: cap.steps, offset: (6 + m * 6 + f) * 256, size: 32 } },
|
|
10799
|
+
],
|
|
10800
|
+
})
|
|
10801
|
+
run(face(cap.cube, m, f), cap.filterPipeline, group)
|
|
10802
|
+
}
|
|
10803
|
+
}
|
|
10804
|
+
for (let m = 0; m < PROBE_LEVELS; m++)
|
|
10805
|
+
for (let f = 0; f < 6; f++) run(face(this.probeTexture, m, f), cap.filterPipeline, cap.filterGroups[m * 6 + f])
|
|
10806
|
+
this.device.queue.submit([encoder.finish()])
|
|
10807
|
+
|
|
10808
|
+
this.probeData.set([eye[0], eye[1], eye[2], 1, min[0], min[1], min[2], 1, max[0], max[1], max[2], 0])
|
|
10809
|
+
this.device.queue.writeBuffer(this.probeUniformBuffer, 0, this.probeData)
|
|
10810
|
+
}
|
|
10811
|
+
|
|
10812
|
+
/**
|
|
10813
|
+
* The sky's mips, convolved with the GGX lobe each stands for — the box
|
|
10814
|
+
* average the upload built is only the source. Level 0 is copied as it is
|
|
10815
|
+
* (it is also what the backdrop draws); every level below it becomes the
|
|
10816
|
+
* sky as a surface of that roughness reflects it (skyLevelRoughness).
|
|
10374
10817
|
*/
|
|
10375
|
-
private
|
|
10376
|
-
|
|
10377
|
-
|
|
10378
|
-
|
|
10379
|
-
|
|
10380
|
-
|
|
10381
|
-
|
|
10818
|
+
private prefilterSky(source: GPUTexture, width: number, height: number, levels: number): GPUTexture {
|
|
10819
|
+
if (levels <= 1) return source
|
|
10820
|
+
if (!this.skyPrefilterPipeline) {
|
|
10821
|
+
const module = this.device.createShaderModule({ label: "sky prefilter", code: SKY_PREFILTER_WGSL })
|
|
10822
|
+
this.skyPrefilterPipeline = this.device.createRenderPipeline({
|
|
10823
|
+
label: "sky prefilter",
|
|
10824
|
+
layout: "auto",
|
|
10825
|
+
vertex: { module, entryPoint: "vs_full" },
|
|
10826
|
+
fragment: { module, entryPoint: "fs_sky", targets: [{ format: "rgba16float" }] },
|
|
10827
|
+
primitive: { topology: "triangle-list" },
|
|
10828
|
+
})
|
|
10829
|
+
}
|
|
10830
|
+
const pipeline = this.skyPrefilterPipeline
|
|
10831
|
+
const out = this.device.createTexture({
|
|
10832
|
+
label: "world equirect (HDR, GGX-prefiltered)",
|
|
10833
|
+
size: [width, height],
|
|
10834
|
+
format: "rgba16float",
|
|
10835
|
+
mipLevelCount: levels,
|
|
10836
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
10837
|
+
})
|
|
10838
|
+
const encoder = this.device.createCommandEncoder({ label: "sky prefilter" })
|
|
10839
|
+
encoder.copyTextureToTexture({ texture: source, mipLevel: 0 }, { texture: out, mipLevel: 0 }, [width, height])
|
|
10840
|
+
const params = this.device.createBuffer({
|
|
10841
|
+
label: "sky prefilter steps",
|
|
10842
|
+
size: levels * 256,
|
|
10843
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
10844
|
+
})
|
|
10845
|
+
const data = new Float32Array(levels * 64)
|
|
10846
|
+
for (let m = 1; m < levels; m++) data.set([skyLevelRoughness(m, levels), width, height, 0], m * 64)
|
|
10847
|
+
this.device.queue.writeBuffer(params, 0, data)
|
|
10848
|
+
const srcView = source.createView()
|
|
10849
|
+
for (let m = 1; m < levels; m++) {
|
|
10850
|
+
const group = this.device.createBindGroup({
|
|
10851
|
+
label: `sky prefilter ${m}`,
|
|
10852
|
+
layout: pipeline.getBindGroupLayout(0),
|
|
10853
|
+
entries: [
|
|
10854
|
+
{ binding: 0, resource: srcView },
|
|
10855
|
+
{ binding: 1, resource: this.materialSampler },
|
|
10856
|
+
{ binding: 2, resource: { buffer: params, offset: m * 256, size: 16 } },
|
|
10857
|
+
],
|
|
10858
|
+
})
|
|
10859
|
+
const p = encoder.beginRenderPass({
|
|
10860
|
+
label: `sky prefilter ${m}`,
|
|
10861
|
+
colorAttachments: [
|
|
10862
|
+
{ view: out.createView({ baseMipLevel: m, mipLevelCount: 1 }), loadOp: "clear", storeOp: "store" },
|
|
10863
|
+
],
|
|
10864
|
+
})
|
|
10865
|
+
p.setPipeline(pipeline)
|
|
10866
|
+
p.setBindGroup(0, group)
|
|
10867
|
+
p.draw(3)
|
|
10868
|
+
p.end()
|
|
10869
|
+
}
|
|
10870
|
+
this.device.queue.submit([encoder.finish()])
|
|
10871
|
+
// The box chain and the steps are done with once that work is: the source
|
|
10872
|
+
// retires with the skies (destroyed at the next frame's start).
|
|
10873
|
+
this.retiredSkies.push(source)
|
|
10874
|
+
void this.device.queue.onSubmittedWorkDone().then(() => params.destroy())
|
|
10875
|
+
return out
|
|
10876
|
+
}
|
|
10382
10877
|
|
|
10383
10878
|
/** Returns whether it actually rebuilt — see rebuildCompositeBindGroup. */
|
|
10384
10879
|
private rebuildPerFrameBindGroups(): boolean {
|
|
@@ -10398,7 +10893,7 @@ export class Engine {
|
|
|
10398
10893
|
!this.shadowLightVPBuffer ||
|
|
10399
10894
|
!this.materialSampler ||
|
|
10400
10895
|
!this.shadowComparisonSampler ||
|
|
10401
|
-
!this.
|
|
10896
|
+
!this.probeUniformBuffer ||
|
|
10402
10897
|
!(this.worldEquirectView ?? this.fallbackEquirectView)
|
|
10403
10898
|
) {
|
|
10404
10899
|
return false
|
|
@@ -10421,8 +10916,31 @@ export class Engine {
|
|
|
10421
10916
|
{ binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
|
|
10422
10917
|
{ binding: 6, resource: { buffer: this.lightsBuffer } },
|
|
10423
10918
|
{ binding: 8, resource: env },
|
|
10424
|
-
{ binding: 9, resource: this.
|
|
10919
|
+
{ binding: 9, resource: this.cookieView },
|
|
10920
|
+
{ binding: 10, resource: this.castShadowView },
|
|
10921
|
+
{ binding: 11, resource: this.probeView },
|
|
10922
|
+
{ binding: 12, resource: { buffer: this.probeUniformBuffer } },
|
|
10923
|
+
],
|
|
10924
|
+
})
|
|
10925
|
+
// The probe's own view of the scene: one cube face at a time through its
|
|
10926
|
+
// capture camera, and the probe itself switched off — a material inside
|
|
10927
|
+
// the capture reflects the sky, never the cube being filled.
|
|
10928
|
+
this.probeCaptureBindGroup = this.device.createBindGroup({
|
|
10929
|
+
label: "reflection probe capture per-frame bind group",
|
|
10930
|
+
layout: this.mainPerFrameBindGroupLayout,
|
|
10931
|
+
entries: [
|
|
10932
|
+
{ binding: 0, resource: { buffer: this.probeCameraBuffer } },
|
|
10933
|
+
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
10934
|
+
{ binding: 2, resource: this.materialSampler },
|
|
10935
|
+
{ binding: 3, resource: this.shadowAtlasView },
|
|
10936
|
+
{ binding: 4, resource: this.shadowComparisonSampler },
|
|
10937
|
+
{ binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
|
|
10938
|
+
{ binding: 6, resource: { buffer: this.lightsBuffer } },
|
|
10939
|
+
{ binding: 8, resource: env },
|
|
10940
|
+
{ binding: 9, resource: this.cookieView },
|
|
10425
10941
|
{ binding: 10, resource: this.castShadowView },
|
|
10942
|
+
{ binding: 11, resource: this.probeFallbackView },
|
|
10943
|
+
{ binding: 12, resource: { buffer: this.probeOffBuffer } },
|
|
10426
10944
|
],
|
|
10427
10945
|
})
|
|
10428
10946
|
this.mirrorPerFrameBindGroup = this.device.createBindGroup({
|
|
@@ -10437,8 +10955,10 @@ export class Engine {
|
|
|
10437
10955
|
{ binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
|
|
10438
10956
|
{ binding: 6, resource: { buffer: this.lightsBuffer } },
|
|
10439
10957
|
{ binding: 8, resource: env },
|
|
10440
|
-
{ binding: 9, resource: this.
|
|
10958
|
+
{ binding: 9, resource: this.cookieView },
|
|
10441
10959
|
{ binding: 10, resource: this.castShadowView },
|
|
10960
|
+
{ binding: 11, resource: this.probeView },
|
|
10961
|
+
{ binding: 12, resource: { buffer: this.probeUniformBuffer } },
|
|
10442
10962
|
],
|
|
10443
10963
|
})
|
|
10444
10964
|
return true
|
|
@@ -10500,13 +11020,13 @@ export class Engine {
|
|
|
10500
11020
|
this.lightData[base + 4] = this.sun.color.x
|
|
10501
11021
|
this.lightData[base + 5] = this.sun.color.y
|
|
10502
11022
|
this.lightData[base + 6] = this.sun.color.z
|
|
11023
|
+
// Unity's light intensity: a white sun of 1 lights albedo 1:1 at N·L = 1.
|
|
10503
11024
|
this.lightData[base + 7] = this.sun.strength
|
|
10504
11025
|
this.lightDataWords[DIR_LAYERS_AT + index] = this.sunLayers
|
|
10505
11026
|
if (index >= this.lightCount) this.lightCount = index + 1
|
|
10506
11027
|
this.updateLightBuffer()
|
|
10507
11028
|
}
|
|
10508
11029
|
|
|
10509
|
-
/** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
|
|
10510
11030
|
/**
|
|
10511
11031
|
* Distance fog on every lit surface, as a game on SimPipeline lays it: per
|
|
10512
11032
|
* layer, t = saturate(f − (1 − f)·h) with f = saturate(depth·distance[0] +
|
|
@@ -10631,7 +11151,7 @@ export class Engine {
|
|
|
10631
11151
|
this.writeWorld()
|
|
10632
11152
|
}
|
|
10633
11153
|
|
|
10634
|
-
/** Update the
|
|
11154
|
+
/** Update the main light (Unity's directional Sun). Direction change marks shadow VP dirty. */
|
|
10635
11155
|
setSun(options: SunOptions): void {
|
|
10636
11156
|
if (options.color) this.sun.color = options.color
|
|
10637
11157
|
if (options.strength !== undefined) this.sun.strength = options.strength
|
|
@@ -10719,6 +11239,7 @@ export class Engine {
|
|
|
10719
11239
|
...options,
|
|
10720
11240
|
}
|
|
10721
11241
|
this.groundY = opts.y
|
|
11242
|
+
this.groundHalf = [opts.width / 2, opts.height / 2]
|
|
10722
11243
|
this.createGroundGeometry(opts.width, opts.height, opts.y)
|
|
10723
11244
|
this.createShadowGroundResources(opts)
|
|
10724
11245
|
this.hasGround = true
|
|
@@ -10771,6 +11292,7 @@ export class Engine {
|
|
|
10771
11292
|
* literal typed into a console. Vec3 satisfies it either way. */
|
|
10772
11293
|
lights: SceneLight[] | null,
|
|
10773
11294
|
): void {
|
|
11295
|
+
this.sceneLights = lights
|
|
10774
11296
|
// The directional ones go to the light uniform's slots beside the sun's —
|
|
10775
11297
|
// three of them, in order; the rest are records the grid indexes.
|
|
10776
11298
|
const directional = (lights ?? []).filter((l) => l.kind === "directional")
|
|
@@ -10830,8 +11352,12 @@ export class Engine {
|
|
|
10830
11352
|
this.lightsData[b + 12] = Math.max(inner, outer)
|
|
10831
11353
|
// The layers it does NOT reach, as bits — inverted so a zero reaches all.
|
|
10832
11354
|
this.lightsWords[b + 13] = ~(l.layers ?? ALL_LAYERS) >>> 0
|
|
10833
|
-
|
|
10834
|
-
this.lightsData[b +
|
|
11355
|
+
// The bulb's radius (rzLightNear): 0 keeps the engine's default
|
|
11356
|
+
this.lightsData[b + 14] = l.near !== undefined && Number.isFinite(l.near) && l.near > 0 ? l.near : 0
|
|
11357
|
+
// Its cookie's layer + 1, 0 for none or one not loaded yet (a load
|
|
11358
|
+
// rewrites the lamps when it lands).
|
|
11359
|
+
const cookie = l.cookie !== undefined ? this.cookieLayers.get(l.cookie) : undefined
|
|
11360
|
+
this.lightsData[b + 15] = cookie !== undefined ? cookie + 1 : 0
|
|
10835
11361
|
}
|
|
10836
11362
|
// The grid, from the records as STORED — normalised aim, clamped reach, the
|
|
10837
11363
|
// cosines in f32 — so it indexes exactly what the shader will evaluate.
|
|
@@ -10870,6 +11396,58 @@ export class Engine {
|
|
|
10870
11396
|
this.allocateLightSlots()
|
|
10871
11397
|
}
|
|
10872
11398
|
|
|
11399
|
+
/**
|
|
11400
|
+
* A cookie, under `key`, for every spot lamp that names it (SceneLight.cookie):
|
|
11401
|
+
* the picture a stage light throws through its gobo, projected along the
|
|
11402
|
+
* cone — its RGB is the light let through, so black blocks, white passes and
|
|
11403
|
+
* a colour tints. Square, stretched to 512². Up to 16 at once; loading a key
|
|
11404
|
+
* again replaces its picture. False when every layer is taken.
|
|
11405
|
+
*/
|
|
11406
|
+
async loadLightCookie(key: string, source: Blob | ImageBitmap): Promise<boolean> {
|
|
11407
|
+
if (!this.device) return false
|
|
11408
|
+
let layer = this.cookieLayers.get(key)
|
|
11409
|
+
if (layer === undefined) {
|
|
11410
|
+
const used = new Set(this.cookieLayers.values())
|
|
11411
|
+
for (let k = 0; k < Engine.COOKIE_LAYERS && layer === undefined; k++) if (!used.has(k)) layer = k
|
|
11412
|
+
if (layer === undefined) {
|
|
11413
|
+
console.warn(`[reze] light cookie "${key}": all ${Engine.COOKIE_LAYERS} cookie layers are in use`)
|
|
11414
|
+
return false
|
|
11415
|
+
}
|
|
11416
|
+
}
|
|
11417
|
+
const size = Engine.COOKIE_SIZE
|
|
11418
|
+
const bmp = await createImageBitmap(source, {
|
|
11419
|
+
resizeWidth: size,
|
|
11420
|
+
resizeHeight: size,
|
|
11421
|
+
resizeQuality: "high",
|
|
11422
|
+
colorSpaceConversion: "none",
|
|
11423
|
+
premultiplyAlpha: "none",
|
|
11424
|
+
})
|
|
11425
|
+
// The real array on the first cookie, and every group that binds the
|
|
11426
|
+
// stand-in rebuilt against it (the stand-in itself is a 1x1 and stays).
|
|
11427
|
+
if (this.cookieTexture.width === 1) {
|
|
11428
|
+
this.cookieTexture = this.device.createTexture({
|
|
11429
|
+
label: "light cookies",
|
|
11430
|
+
size: [size, size, Engine.COOKIE_LAYERS],
|
|
11431
|
+
format: "rgba8unorm",
|
|
11432
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
11433
|
+
})
|
|
11434
|
+
this.cookieView = this.cookieTexture.createView({ dimension: "2d-array" })
|
|
11435
|
+
this.worldBindingsDirty = true
|
|
11436
|
+
this.buildGroundBindGroup()
|
|
11437
|
+
}
|
|
11438
|
+
this.device.queue.copyExternalImageToTexture({ source: bmp }, { texture: this.cookieTexture, origin: [0, 0, layer] }, [size, size, 1])
|
|
11439
|
+
bmp.close()
|
|
11440
|
+
this.cookieLayers.set(key, layer)
|
|
11441
|
+
if (this.sceneLights) this.setLights(this.sceneLights)
|
|
11442
|
+
return true
|
|
11443
|
+
}
|
|
11444
|
+
|
|
11445
|
+
/** Forget a cookie: lamps naming it light plainly again. */
|
|
11446
|
+
removeLightCookie(key: string): void {
|
|
11447
|
+
if (!this.cookieLayers.delete(key)) return
|
|
11448
|
+
if (this.sceneLights) this.setLights(this.sceneLights)
|
|
11449
|
+
}
|
|
11450
|
+
|
|
10873
11451
|
/** How many positional lights the scene is carrying. */
|
|
10874
11452
|
getLightCount(): number {
|
|
10875
11453
|
return this.lightHeader[0]
|
|
@@ -11383,6 +11961,29 @@ export class Engine {
|
|
|
11383
11961
|
return true
|
|
11384
11962
|
}
|
|
11385
11963
|
|
|
11964
|
+
/**
|
|
11965
|
+
* Light one model with more than the world gives it: a FILL, linear RGB times
|
|
11966
|
+
* its surface colour, added after its material graph. Null takes it away again.
|
|
11967
|
+
*
|
|
11968
|
+
* After the graph, not in its ambient: an NPR ramp reads an ambient fill as
|
|
11969
|
+
* light, so sliding it moved her shadows and flipped whole regions across a
|
|
11970
|
+
* hard step. Added after, it brightens her evenly and every shadow stays put.
|
|
11971
|
+
*
|
|
11972
|
+
* A game lights its stage and its characters apart. Aether Gazer's rooms are
|
|
11973
|
+
* lit by their lamps and a near-black ambient, while its characters take a
|
|
11974
|
+
* flat base light of their own — so a stage whose World is right for the room
|
|
11975
|
+
* leaves a face turned from the lamps in the dark. This is that second light,
|
|
11976
|
+
* for the models the host counts as cast. A model drawn by the game's own
|
|
11977
|
+
* shaders (src/unity) lights itself and takes none of it.
|
|
11978
|
+
*/
|
|
11979
|
+
setModelFill(name: string, fill: { x: number; y: number; z: number } | null): boolean {
|
|
11980
|
+
const inst = this.modelInstances.get(name)
|
|
11981
|
+
if (!inst || !this.device) return false
|
|
11982
|
+
inst.objectLight.set(fill ? [fill.x, fill.y, fill.z, 0] : [0, 0, 0, 0], 44)
|
|
11983
|
+
this.device.queue.writeBuffer(inst.lightBuffer, 0, inst.objectLight)
|
|
11984
|
+
return true
|
|
11985
|
+
}
|
|
11986
|
+
|
|
11386
11987
|
/** The per-model group: its skinning matrices and its ObjectLight. */
|
|
11387
11988
|
private perInstanceBindGroup(name: string, skinMatrixBuffer: GPUBuffer, light: GPUBuffer): GPUBindGroup {
|
|
11388
11989
|
return this.device.createBindGroup({
|
|
@@ -11401,6 +12002,9 @@ export class Engine {
|
|
|
11401
12002
|
// Before the texture cache below frees it: a stale entry here would hand a
|
|
11402
12003
|
// destroyed texture to the next setPlaneFrame.
|
|
11403
12004
|
this.planeTextures.delete(name)
|
|
12005
|
+
// Its native look too: it skins from this model's buffers, which are
|
|
12006
|
+
// destroyed below, and would otherwise go on drawing from them.
|
|
12007
|
+
this.nativeLooks?.remove(name)
|
|
11404
12008
|
inst.model.stop()
|
|
11405
12009
|
for (const path of inst.textureCacheKeys) {
|
|
11406
12010
|
const tex = this.textureCache.get(path)
|
|
@@ -13134,8 +13738,12 @@ export class Engine {
|
|
|
13134
13738
|
const d = positions[i] * positions[i] + positions[i + 1] * positions[i + 1] + positions[i + 2] * positions[i + 2]
|
|
13135
13739
|
if (d > most) most = d
|
|
13136
13740
|
}
|
|
13137
|
-
|
|
13138
|
-
|
|
13741
|
+
this.raiseSceneExtent(Math.sqrt(most))
|
|
13742
|
+
}
|
|
13743
|
+
|
|
13744
|
+
/** Raise the scene's reach (never lower it) and hand it to the camera. */
|
|
13745
|
+
private raiseSceneExtent(extent: number): void {
|
|
13746
|
+
if (!(extent > this.sceneExtent)) return
|
|
13139
13747
|
this.sceneExtent = extent
|
|
13140
13748
|
this.camera?.setSceneExtent(extent)
|
|
13141
13749
|
}
|
|
@@ -13216,6 +13824,16 @@ export class Engine {
|
|
|
13216
13824
|
minZ = Math.min(minZ, data[o + 18] - r); maxZ = Math.max(maxZ, data[o + 18] + r)
|
|
13217
13825
|
}
|
|
13218
13826
|
}
|
|
13827
|
+
// The built-in floor, while it is drawn. It is no draw of the cull table,
|
|
13828
|
+
// so without this the box ended at the cast: the cascades stopped just
|
|
13829
|
+
// past her, and a low sun's long shadow ran off the last of them — the
|
|
13830
|
+
// floor beyond read as lit and her head and shoulders went missing.
|
|
13831
|
+
if (this.hasGround && !this.groundIsSuppressed()) {
|
|
13832
|
+
const [hx, hz] = this.groundHalf
|
|
13833
|
+
minX = Math.min(minX, -hx); maxX = Math.max(maxX, hx)
|
|
13834
|
+
minY = Math.min(minY, this.groundY); maxY = Math.max(maxY, this.groundY)
|
|
13835
|
+
minZ = Math.min(minZ, -hz); maxZ = Math.max(maxZ, hz)
|
|
13836
|
+
}
|
|
13219
13837
|
if (this.nativeStage) {
|
|
13220
13838
|
const nb = this.nativeStage.bounds
|
|
13221
13839
|
minX = Math.min(minX, nb.min[0]); maxX = Math.max(maxX, nb.max[0])
|
|
@@ -13520,6 +14138,10 @@ export class Engine {
|
|
|
13520
14138
|
*/
|
|
13521
14139
|
private recordBundles(): void {
|
|
13522
14140
|
this.bundlesDirty = false
|
|
14141
|
+
// What the bundles record is what the probe would see: a stage arriving or
|
|
14142
|
+
// leaving, a look installed on it, a pipeline landing. Re-captured once it
|
|
14143
|
+
// settles (and switched off when the stage is gone).
|
|
14144
|
+
this.captureReflectionProbe()
|
|
13523
14145
|
const scene = {
|
|
13524
14146
|
colorFormats: sceneColorFormats(this.sceneFormats),
|
|
13525
14147
|
depthStencilFormat: this.depthFormat,
|
|
@@ -13783,10 +14405,17 @@ export class Engine {
|
|
|
13783
14405
|
private issueDraw(
|
|
13784
14406
|
pass: GPURenderPassEncoder | GPURenderBundleEncoder,
|
|
13785
14407
|
draw: DrawCall,
|
|
13786
|
-
kind: "camera" | "shadow" | "mirror",
|
|
14408
|
+
kind: "camera" | "shadow" | "mirror" | "probe",
|
|
13787
14409
|
): void {
|
|
14410
|
+
// The probe's cube faces have no cull of their own: drawn whole.
|
|
13788
14411
|
const args =
|
|
13789
|
-
kind === "
|
|
14412
|
+
kind === "probe"
|
|
14413
|
+
? null
|
|
14414
|
+
: kind === "shadow"
|
|
14415
|
+
? this.cullShadowArgs
|
|
14416
|
+
: kind === "mirror"
|
|
14417
|
+
? this.cullMirrorArgs
|
|
14418
|
+
: this.cullCameraArgs
|
|
13790
14419
|
if (args && draw.cullIndex >= 0) {
|
|
13791
14420
|
pass.drawIndexedIndirect(args, draw.cullIndex * Engine.CULL_ARG_WORDS * 4)
|
|
13792
14421
|
} else {
|
|
@@ -14071,8 +14700,9 @@ export class Engine {
|
|
|
14071
14700
|
|
|
14072
14701
|
// Its ObjectLight: the world's ambient, and the layers its kind draws on —
|
|
14073
14702
|
// the cast on the character layer as well as the default one. Words:
|
|
14074
|
-
// layers [0..3], ambient flag [4..7], SH [8..43] — see
|
|
14075
|
-
|
|
14703
|
+
// layers [0..3], ambient flag [4..7], SH [8..43], fill [44..47] — see
|
|
14704
|
+
// materials/common.ts.
|
|
14705
|
+
const objectLight = new Float32Array(48)
|
|
14076
14706
|
new Uint32Array(objectLight.buffer)[0] =
|
|
14077
14707
|
isStage || isPlane || isProp ? RENDERING_LAYER_DEFAULT : (RENDERING_LAYER_DEFAULT | RENDERING_LAYER_CHARACTER) >>> 0
|
|
14078
14708
|
const lightBuffer = this.device.createBuffer({
|
|
@@ -14089,7 +14719,8 @@ export class Engine {
|
|
|
14089
14719
|
entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
|
|
14090
14720
|
})
|
|
14091
14721
|
|
|
14092
|
-
const
|
|
14722
|
+
const attributeBuffer = this.createAttributeBuffer(name, model)
|
|
14723
|
+
const gpuBuffers: GPUBuffer[] = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer, attributeBuffer]
|
|
14093
14724
|
|
|
14094
14725
|
const gpuMorph = this.createGpuMorph(name, model, vertexBuffer, gpuBuffers)
|
|
14095
14726
|
|
|
@@ -14114,6 +14745,7 @@ export class Engine {
|
|
|
14114
14745
|
indexBuffer,
|
|
14115
14746
|
jointsBuffer,
|
|
14116
14747
|
weightsBuffer,
|
|
14748
|
+
attributeBuffer,
|
|
14117
14749
|
skinMatrixBuffer,
|
|
14118
14750
|
wireEdges: new Map(),
|
|
14119
14751
|
drawCalls: [],
|
|
@@ -14160,6 +14792,46 @@ export class Engine {
|
|
|
14160
14792
|
this.updateOrderDirty = true
|
|
14161
14793
|
}
|
|
14162
14794
|
|
|
14795
|
+
/**
|
|
14796
|
+
* A model's attribute stream: vertex colour (RGBA) and a second UV set, six
|
|
14797
|
+
* floats a vertex, read by the material pipelines at slot 4.
|
|
14798
|
+
*
|
|
14799
|
+
* PMX has no vertex colour and one UV, but it has four ADDITIONAL UV
|
|
14800
|
+
* channels of four floats each, and that is where a model that needs more
|
|
14801
|
+
* keeps it: the first additional UV is the vertex colour, the second's xy
|
|
14802
|
+
* the second UV — the convention a converted game stage is written in (a
|
|
14803
|
+
* plant's occlusion rides in its vertex alpha, a light card's mask in its
|
|
14804
|
+
* second UV). A model without them reads white and its own UV, which is
|
|
14805
|
+
* what every graph that reads neither already assumed.
|
|
14806
|
+
*/
|
|
14807
|
+
private createAttributeBuffer(name: string, model: Model): GPUBuffer {
|
|
14808
|
+
const vertices = model.getVertices()
|
|
14809
|
+
const count = Math.floor(vertices.length / 8)
|
|
14810
|
+
const data = new Float32Array(count * Engine.ATTRIBUTE_FLOATS)
|
|
14811
|
+
const color = model.getAdditionalUv(1)
|
|
14812
|
+
const uv2 = model.getAdditionalUv(2)
|
|
14813
|
+
for (let i = 0; i < count; i++) {
|
|
14814
|
+
const o = i * Engine.ATTRIBUTE_FLOATS
|
|
14815
|
+
if (color) {
|
|
14816
|
+
data[o] = color[i * 4]
|
|
14817
|
+
data[o + 1] = color[i * 4 + 1]
|
|
14818
|
+
data[o + 2] = color[i * 4 + 2]
|
|
14819
|
+
data[o + 3] = color[i * 4 + 3]
|
|
14820
|
+
} else {
|
|
14821
|
+
data[o] = data[o + 1] = data[o + 2] = data[o + 3] = 1
|
|
14822
|
+
}
|
|
14823
|
+
data[o + 4] = uv2 ? uv2[i * 4] : vertices[i * 8 + 6]
|
|
14824
|
+
data[o + 5] = uv2 ? uv2[i * 4 + 1] : vertices[i * 8 + 7]
|
|
14825
|
+
}
|
|
14826
|
+
const buffer = this.device.createBuffer({
|
|
14827
|
+
label: `${name}: attribute buffer`,
|
|
14828
|
+
size: Math.max(data.byteLength, 4 * Engine.ATTRIBUTE_FLOATS),
|
|
14829
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
14830
|
+
})
|
|
14831
|
+
if (data.byteLength > 0) this.device.queue.writeBuffer(buffer, 0, data)
|
|
14832
|
+
return buffer
|
|
14833
|
+
}
|
|
14834
|
+
|
|
14163
14835
|
// Build the per-model GPU vertex-morph state. Returns null (and leaves the model on the
|
|
14164
14836
|
// CPU morph path) when the model has no vertex morphs. Created buffers are pushed into
|
|
14165
14837
|
// gpuBuffers so they're released with the instance.
|
|
@@ -14309,7 +14981,11 @@ export class Engine {
|
|
|
14309
14981
|
private groundShadowPipelineDesc!: Omit<Parameters<Engine["createRenderPipeline"]>[0], "shaderModule">
|
|
14310
14982
|
|
|
14311
14983
|
private buildGroundPipeline(soft: boolean, mirrored = false): GPURenderPipeline {
|
|
14312
|
-
return this.createRenderPipeline(
|
|
14984
|
+
return this.createRenderPipeline(this.groundPipelineConfig(soft, mirrored))
|
|
14985
|
+
}
|
|
14986
|
+
|
|
14987
|
+
private groundPipelineConfig(soft: boolean, mirrored = false): Parameters<Engine["renderPipelineDesc"]>[0] {
|
|
14988
|
+
return {
|
|
14313
14989
|
...this.groundShadowPipelineDesc,
|
|
14314
14990
|
// A REFLECTION FLIPS WINDING — determinant -1, see reflection.ts. The
|
|
14315
14991
|
// ground culls back faces, so drawn into the mirror pass with the ordinary
|
|
@@ -14319,11 +14995,8 @@ export class Engine {
|
|
|
14319
14995
|
// the mirror, and the same answer the scene-pass pipelines already use.
|
|
14320
14996
|
cullMode: mirrored ? "none" : this.groundShadowPipelineDesc.cullMode,
|
|
14321
14997
|
label: `ground shadow pipeline${soft ? " (soft)" : ""}${mirrored ? " (mirror)" : ""}`,
|
|
14322
|
-
shaderModule: this.
|
|
14323
|
-
|
|
14324
|
-
code: groundShaderWgsl(soft),
|
|
14325
|
-
}),
|
|
14326
|
-
})
|
|
14998
|
+
shaderModule: this.cachedShaderModule(groundShaderWgsl(soft), soft ? "ground shadow (soft)" : "ground shadow"),
|
|
14999
|
+
}
|
|
14327
15000
|
}
|
|
14328
15001
|
|
|
14329
15002
|
private groundMirrorPipeline: GPURenderPipeline | null = null
|
|
@@ -14491,6 +15164,7 @@ export class Engine {
|
|
|
14491
15164
|
{ binding: 12, resource: this.groundNoiseView },
|
|
14492
15165
|
{ binding: 13, resource: this.mirrorMaskView! },
|
|
14493
15166
|
{ binding: 14, resource: { buffer: this.groundClipOffBuffer } },
|
|
15167
|
+
{ binding: 15, resource: this.cookieView },
|
|
14494
15168
|
],
|
|
14495
15169
|
})
|
|
14496
15170
|
this.groundMirrorViewBindGroup = this.device.createBindGroup({
|
|
@@ -14513,6 +15187,7 @@ export class Engine {
|
|
|
14513
15187
|
{ binding: 12, resource: this.groundNoiseView },
|
|
14514
15188
|
{ binding: 13, resource: this.mirrorDummyColorView! },
|
|
14515
15189
|
{ binding: 14, resource: { buffer: this.groundClipMirrorBuffer } },
|
|
15190
|
+
{ binding: 15, resource: this.cookieView },
|
|
14516
15191
|
],
|
|
14517
15192
|
})
|
|
14518
15193
|
if (this.groundDrawCall) this.groundDrawCall.bindGroup = this.groundShadowBindGroup
|
|
@@ -14773,6 +15448,17 @@ export class Engine {
|
|
|
14773
15448
|
],
|
|
14774
15449
|
})
|
|
14775
15450
|
outline = { bindGroup: outlineBindGroup }
|
|
15451
|
+
if (!inst.outlineVertexBuffer) {
|
|
15452
|
+
const data = inst.model.getOutlineVertices()
|
|
15453
|
+
const buf = this.device.createBuffer({
|
|
15454
|
+
label: `${prefix}outline normals`,
|
|
15455
|
+
size: data.byteLength,
|
|
15456
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
15457
|
+
})
|
|
15458
|
+
this.device.queue.writeBuffer(buf, 0, data)
|
|
15459
|
+
inst.gpuBuffers.push(buf)
|
|
15460
|
+
inst.outlineVertexBuffer = buf
|
|
15461
|
+
}
|
|
14776
15462
|
}
|
|
14777
15463
|
|
|
14778
15464
|
// Model-space AABB for the cull compute, grown by the three things that can
|
|
@@ -15804,38 +16490,6 @@ export class Engine {
|
|
|
15804
16490
|
}
|
|
15805
16491
|
|
|
15806
16492
|
// World-space ray from camera through a canvas pixel. Uses WebGPU's NDC z ∈ [0,1].
|
|
15807
|
-
/**
|
|
15808
|
-
* Where a point on the canvas lands on a horizontal plane.
|
|
15809
|
-
*
|
|
15810
|
-
* `px,py` are canvas-relative pixels, top-left origin — what a pointer event
|
|
15811
|
-
* gives you after subtracting the element's rect. Returns null when the ray
|
|
15812
|
-
* cannot reach the plane: parallel to it, or pointing the other way, which is
|
|
15813
|
-
* what a click on the sky above the horizon is.
|
|
15814
|
-
*
|
|
15815
|
-
* The one primitive a placement UI needs. Dragging a thing across the floor is
|
|
15816
|
-
* otherwise three sliders in world units, which asks someone to guess numbers
|
|
15817
|
-
* that have no visible relation to the picture they are looking at — and it
|
|
15818
|
-
* throws away the property that makes pointing work at all: under perspective,
|
|
15819
|
-
* moving something further away makes it smaller by exactly the right amount,
|
|
15820
|
-
* so position and size stop being two controls to tune against each other.
|
|
15821
|
-
*/
|
|
15822
|
-
groundPointAt(px: number, py: number, planeY = 0): Vec3 | null {
|
|
15823
|
-
const ray = this.buildMouseRay(px, py)
|
|
15824
|
-
if (!ray) return null
|
|
15825
|
-
// Parallel to the plane: no intersection, and a huge one is not an answer.
|
|
15826
|
-
if (Math.abs(ray.dir.y) < 1e-6) return null
|
|
15827
|
-
const t = (planeY - ray.origin.y) / ray.dir.y
|
|
15828
|
-
// Behind the camera — the plane is there, but not in this shot.
|
|
15829
|
-
if (!(t > 0) || !isFinite(t)) return null
|
|
15830
|
-
return new Vec3(ray.origin.x + ray.dir.x * t, planeY, ray.origin.z + ray.dir.z * t)
|
|
15831
|
-
}
|
|
15832
|
-
|
|
15833
|
-
/** Hand the pointer to something else — a placement drag, a gizmo, a host's own
|
|
15834
|
-
* overlay — so the orbit does not also act on it. */
|
|
15835
|
-
setCameraInputLocked(locked: boolean): void {
|
|
15836
|
-
this.camera?.setInputLocked(locked)
|
|
15837
|
-
}
|
|
15838
|
-
|
|
15839
16493
|
private buildMouseRay(px: number, py: number): { origin: Vec3; dir: Vec3 } | null {
|
|
15840
16494
|
if (!this.camera) return null
|
|
15841
16495
|
const width = this.canvas.clientWidth
|
|
@@ -16428,6 +17082,10 @@ export class Engine {
|
|
|
16428
17082
|
idAtt.storeOp = idsRead ? "store" : "discard"
|
|
16429
17083
|
}
|
|
16430
17084
|
|
|
17085
|
+
// The stage's reflection probe, when one is due: its own submits, ahead of
|
|
17086
|
+
// this frame's, so every material below reads the fresh cube.
|
|
17087
|
+
if (hasModels) this.maybeCaptureProbe()
|
|
17088
|
+
|
|
16431
17089
|
const encoder = this.device.createCommandEncoder()
|
|
16432
17090
|
|
|
16433
17091
|
// GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
|
|
@@ -16625,58 +17283,17 @@ export class Engine {
|
|
|
16625
17283
|
// depth, before the composite that samples both layers.
|
|
16626
17284
|
this.renderFieldPass(encoder)
|
|
16627
17285
|
|
|
16628
|
-
// Bloom
|
|
16629
|
-
//
|
|
16630
|
-
//
|
|
16631
|
-
//
|
|
16632
|
-
//
|
|
16633
|
-
//
|
|
16634
|
-
//
|
|
16635
|
-
|
|
16636
|
-
|
|
16637
|
-
|
|
16638
|
-
//
|
|
16639
|
-
if (this.bloomContributes() && this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
|
|
16640
|
-
const bloomAtt = this.bloomPassDescriptor.colorAttachments as GPURenderPassColorAttachment[]
|
|
16641
|
-
|
|
16642
|
-
// 1. Blit — opens the pyramid's timing span. See stampOpen: the nine
|
|
16643
|
-
// passes below read as ONE component, which is the only useful grain.
|
|
16644
|
-
bloomAtt[0].view = this.bloomDownMipViews[0]
|
|
16645
|
-
this.bloomPassDescriptor.timestampWrites = this.stampOpen("bloom")
|
|
16646
|
-
const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
16647
|
-
pBlit.setPipeline(this.bloomBlitPipeline)
|
|
16648
|
-
pBlit.setBindGroup(0, this.bloomBlitBindGroup)
|
|
16649
|
-
pBlit.draw(3)
|
|
16650
|
-
pBlit.end()
|
|
16651
|
-
|
|
16652
|
-
// 2. Downsample chain
|
|
16653
|
-
this.bloomPassDescriptor.timestampWrites = undefined
|
|
16654
|
-
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
16655
|
-
bloomAtt[0].view = this.bloomDownMipViews[i]
|
|
16656
|
-
const p = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
16657
|
-
p.setPipeline(this.bloomDownsamplePipeline)
|
|
16658
|
-
p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1])
|
|
16659
|
-
p.draw(3)
|
|
16660
|
-
p.end()
|
|
16661
|
-
}
|
|
16662
|
-
|
|
16663
|
-
// 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
|
|
16664
|
-
const upSteps = this.bloomUpsampleBindGroups.length
|
|
16665
|
-
const topIdx = this.bloomMipCount - 2
|
|
16666
|
-
for (let k = 0; k < upSteps; k++) {
|
|
16667
|
-
const levelIdx = topIdx - k // writes bloomUp[levelIdx]
|
|
16668
|
-
bloomAtt[0].view = this.bloomUpMipViews[levelIdx]
|
|
16669
|
-
// The LAST upsample closes the span opened on the blit.
|
|
16670
|
-
this.bloomPassDescriptor.timestampWrites = k === upSteps - 1 ? this.stampClose("bloom") : undefined
|
|
16671
|
-
const p = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
16672
|
-
p.setPipeline(this.bloomUpsamplePipeline)
|
|
16673
|
-
p.setBindGroup(0, this.bloomUpsampleBindGroups[k])
|
|
16674
|
-
p.draw(3)
|
|
16675
|
-
p.end()
|
|
16676
|
-
}
|
|
16677
|
-
}
|
|
16678
|
-
|
|
16679
|
-
// Composite: HDR + bloom → Filmic tonemap → swapchain.
|
|
17286
|
+
// Bloom (renderBloom): prefilter, the Gaussian chain down, the scatter
|
|
17287
|
+
// blend back up; the composite adds up[0] × tint × intensity before the view
|
|
17288
|
+
// transform. bloomContributes() gates the whole chain, not just its
|
|
17289
|
+
// intensity. The composite still SAMPLES up[0] unconditionally, which is safe
|
|
17290
|
+
// and deliberate: it scales what it reads by the same effective intensity, so
|
|
17291
|
+
// a stale or never-written chain is multiplied by zero. Skipping the build is
|
|
17292
|
+
// therefore invisible in the frame and a couple of dozen render passes cheaper.
|
|
17293
|
+
if (this.bloomContributes() && this.bloomPrefilterBindGroup && this.compositeBindGroup && this.bloomLevels > 0)
|
|
17294
|
+
this.renderBloom(encoder)
|
|
17295
|
+
|
|
17296
|
+
// Composite: HDR + bloom → view transform → swapchain.
|
|
16680
17297
|
const swapchainView = this.context.getCurrentTexture().createView()
|
|
16681
17298
|
const compositeAttachment = (this.compositePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
|
|
16682
17299
|
compositeAttachment.view = swapchainView
|
|
@@ -16827,11 +17444,14 @@ export class Engine {
|
|
|
16827
17444
|
}
|
|
16828
17445
|
}
|
|
16829
17446
|
|
|
16830
|
-
|
|
16831
|
-
|
|
16832
|
-
|
|
16833
|
-
|
|
16834
|
-
|
|
17447
|
+
// ALL GROUPS AT ONCE. Each install touches only its own id — its entry in
|
|
17448
|
+
// styleGroups, its uniform buffer, the draw calls already bound to it — so
|
|
17449
|
+
// the order they land in changes nothing, and each keeps its own staleness
|
|
17450
|
+
// guard (styleGroupGen). Awaiting them in turn made a model's styling the
|
|
17451
|
+
// sum of its compiles; the GPU process runs them side by side.
|
|
17452
|
+
const groupResults: GroupDiagnostic[] = (
|
|
17453
|
+
await Promise.all(groups.map((g) => this.compileAndInstallGroup(inst, g)))
|
|
17454
|
+
).map((r, i) => ({ groupId: groups[i].id, diagnostics: r.diagnostics, ok: r.ok }))
|
|
16835
17455
|
|
|
16836
17456
|
// Repoints every draw call away from the outgoing installs (among
|
|
16837
17457
|
// everything else) — only past this line is destroying them safe.
|
|
@@ -16848,6 +17468,38 @@ export class Engine {
|
|
|
16848
17468
|
}
|
|
16849
17469
|
}
|
|
16850
17470
|
|
|
17471
|
+
/**
|
|
17472
|
+
* Compile style groups' pipelines before any model needs them — a scene's
|
|
17473
|
+
* groups while its bundle is still downloading. Installs nothing: the
|
|
17474
|
+
* pipelines land in the shader cache, and applyStyleGroups later finds them
|
|
17475
|
+
* there (or still in flight). A group that does not compile is skipped
|
|
17476
|
+
* silently; its real install reports it.
|
|
17477
|
+
*/
|
|
17478
|
+
async prewarmStyleGroups(groups: readonly StyleGroup[]): Promise<void> {
|
|
17479
|
+
if (!this.device) return
|
|
17480
|
+
await Promise.all(
|
|
17481
|
+
groups.map(async (g) => {
|
|
17482
|
+
const renderClass = g.renderClass ?? "auto"
|
|
17483
|
+
const alphaMode = g.alphaMode ?? "opaque"
|
|
17484
|
+
const blend = g.blend ?? "over"
|
|
17485
|
+
let result: ReturnType<typeof compileGraph>
|
|
17486
|
+
try {
|
|
17487
|
+
result = compileGraph(g.graph, { renderClass, alphaMode, blend })
|
|
17488
|
+
} catch {
|
|
17489
|
+
return
|
|
17490
|
+
}
|
|
17491
|
+
if (!result.ok) return
|
|
17492
|
+
const module = this.cachedShaderModule(result.wgsl, `style group: ${g.id} (${renderClass})`)
|
|
17493
|
+
await Promise.all([
|
|
17494
|
+
this.createRenderClassPipeline(renderClass, module, false, true, false, blend),
|
|
17495
|
+
this.createRenderClassPipeline(renderClass, module, false, false, false, blend),
|
|
17496
|
+
renderClass === "hair" ? this.createRenderClassPipeline(renderClass, module, true, true, false, blend) : null,
|
|
17497
|
+
renderClass === "eye" ? this.createRenderClassPipeline(renderClass, module, false, true, true, blend) : null,
|
|
17498
|
+
]).catch(() => {})
|
|
17499
|
+
}),
|
|
17500
|
+
)
|
|
17501
|
+
}
|
|
17502
|
+
|
|
16851
17503
|
/** Add or replace a single style group by id. `opts` may carry a `previewNode` for the
|
|
16852
17504
|
* editor's node-output preview workflow. */
|
|
16853
17505
|
async upsertStyleGroup(modelName: string, group: StyleGroup, opts?: CompileOptions): Promise<ApplyStyleGroupResult> {
|
|
@@ -17209,9 +17861,35 @@ export class Engine {
|
|
|
17209
17861
|
inst.styleGroupGen.set(group.id, generation)
|
|
17210
17862
|
|
|
17211
17863
|
this.device.pushErrorScope("validation")
|
|
17212
|
-
const module = this.
|
|
17213
|
-
const
|
|
17214
|
-
|
|
17864
|
+
const module = this.cachedShaderModule(result.wgsl, `style group: ${group.id} (${renderClass})`)
|
|
17865
|
+
const scopePromise = this.device.popErrorScope()
|
|
17866
|
+
// EVERY VARIANT AT ONCE, and the module's diagnostics read beside them
|
|
17867
|
+
// rather than first: the compiles run in parallel in the GPU process, and
|
|
17868
|
+
// awaiting each in turn (and the diagnostics before any) made a group cost
|
|
17869
|
+
// the sum of its pipelines instead of the longest.
|
|
17870
|
+
const blend = group.blend ?? "over"
|
|
17871
|
+
// The single-sided twins a draw of this group will ask for (sidedPipeline),
|
|
17872
|
+
// built with their pipelines rather than synchronously at the first draw.
|
|
17873
|
+
const singleSided = inst.drawCalls.some((dc) => !dc.doubleSided && group.materials.includes(dc.materialName))
|
|
17874
|
+
const variant = (overEyes: boolean, depthWrite: boolean, mirrored: boolean) =>
|
|
17875
|
+
this.createRenderClassPipeline(renderClass, module, overEyes, depthWrite, mirrored, blend).then(async (p) => {
|
|
17876
|
+
if (singleSided && !mirrored) await this.buildSidedTwin(p, false)
|
|
17877
|
+
return p
|
|
17878
|
+
})
|
|
17879
|
+
const variants = Promise.all([
|
|
17880
|
+
variant(false, true, false),
|
|
17881
|
+
// The depth-write-off twin: stage transparency draws with it (see
|
|
17882
|
+
// pipelineForDrawCall), and a future OIT path would too.
|
|
17883
|
+
variant(false, false, false),
|
|
17884
|
+
renderClass === "hair" ? variant(true, true, false) : undefined,
|
|
17885
|
+
// Only the eye needs one: every other class culls "none", which a flipped
|
|
17886
|
+
// winding leaves alone.
|
|
17887
|
+
renderClass === "eye" ? variant(false, true, true) : undefined,
|
|
17888
|
+
])
|
|
17889
|
+
// Handled below either way; this keeps a rejection off the console while
|
|
17890
|
+
// the diagnostics are read.
|
|
17891
|
+
variants.catch(() => {})
|
|
17892
|
+
const [info, scopeError] = await Promise.all([module.getCompilationInfo(), scopePromise])
|
|
17215
17893
|
const diagnostics = [...result.diagnostics]
|
|
17216
17894
|
for (const msg of info.messages) {
|
|
17217
17895
|
if (msg.type !== "error") continue
|
|
@@ -17220,6 +17898,7 @@ export class Engine {
|
|
|
17220
17898
|
if (diagnostics.some((d) => d.severity === "error") || scopeError) {
|
|
17221
17899
|
if (scopeError && !diagnostics.some((d) => d.severity === "error"))
|
|
17222
17900
|
diagnostics.push({ severity: "error", message: `WGSL: ${scopeError.message}` })
|
|
17901
|
+
this.forgetShaderModule(result.wgsl)
|
|
17223
17902
|
return { ok: false, diagnostics, slotMap: result.slotMap }
|
|
17224
17903
|
}
|
|
17225
17904
|
|
|
@@ -17228,16 +17907,7 @@ export class Engine {
|
|
|
17228
17907
|
let overEyesPipeline: GPURenderPipeline | undefined
|
|
17229
17908
|
let mirrorPipeline: GPURenderPipeline | undefined
|
|
17230
17909
|
try {
|
|
17231
|
-
|
|
17232
|
-
pipeline = await this.createRenderClassPipeline(renderClass, module, false, true, false, blend)
|
|
17233
|
-
// The depth-write-off twin: stage transparency draws with it (see
|
|
17234
|
-
// pipelineForDrawCall), and a future OIT path would too.
|
|
17235
|
-
pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false, false, blend)
|
|
17236
|
-
if (renderClass === "hair") overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true, true, false, blend)
|
|
17237
|
-
// Only the eye needs one: every other class culls "none", which a flipped
|
|
17238
|
-
// winding leaves alone.
|
|
17239
|
-
if (renderClass === "eye")
|
|
17240
|
-
mirrorPipeline = await this.createRenderClassPipeline(renderClass, module, false, true, true, blend)
|
|
17910
|
+
;[pipeline, pipelineNoDepthWrite, overEyesPipeline, mirrorPipeline] = await variants
|
|
17241
17911
|
} catch (e) {
|
|
17242
17912
|
diagnostics.push({ severity: "error", message: `pipeline creation failed: ${(e as Error).message}` })
|
|
17243
17913
|
return { ok: false, diagnostics, slotMap: result.slotMap }
|
|
@@ -17419,7 +18089,7 @@ export class Engine {
|
|
|
17419
18089
|
const base = {
|
|
17420
18090
|
label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
|
|
17421
18091
|
layout: this.mainPipelineLayout,
|
|
17422
|
-
vertex: { module, buffers: this.
|
|
18092
|
+
vertex: { module, buffers: this.materialVertexBufferLayouts },
|
|
17423
18093
|
// The eye front-culls — the see-through-hair look is draw order plus that
|
|
17424
18094
|
// cull plus the stencil stamp, and it is the deployed appearance of every
|
|
17425
18095
|
// published scene. In the MIRROR the winding is flipped (determinant -1,
|
|
@@ -17476,11 +18146,20 @@ export class Engine {
|
|
|
17476
18146
|
}
|
|
17477
18147
|
const desc: GPURenderPipelineDescriptor = {
|
|
17478
18148
|
...base,
|
|
17479
|
-
fragment: {
|
|
18149
|
+
fragment: {
|
|
18150
|
+
module,
|
|
18151
|
+
constants,
|
|
18152
|
+
targets:
|
|
18153
|
+
blend === "additive"
|
|
18154
|
+
? this.sceneTargetsAdditive
|
|
18155
|
+
: blend === "premultiplied"
|
|
18156
|
+
? this.sceneTargetsPremultiplied
|
|
18157
|
+
: this.sceneTargets,
|
|
18158
|
+
},
|
|
17480
18159
|
depthStencil,
|
|
17481
18160
|
}
|
|
17482
|
-
return this.
|
|
17483
|
-
this.pipelineDescs.set(pipeline, desc)
|
|
18161
|
+
return this.cachedRenderPipeline(desc).then((pipeline) => {
|
|
18162
|
+
if (!this.pipelineDescs.has(pipeline)) this.pipelineDescs.set(pipeline, desc)
|
|
17484
18163
|
return pipeline
|
|
17485
18164
|
})
|
|
17486
18165
|
}
|
|
@@ -17493,6 +18172,181 @@ export class Engine {
|
|
|
17493
18172
|
return pipeline
|
|
17494
18173
|
}
|
|
17495
18174
|
|
|
18175
|
+
// ─── The shader cache ─────────────────────────────────────────────
|
|
18176
|
+
//
|
|
18177
|
+
// A compile is most of a second per distinct fragment shader on a cold
|
|
18178
|
+
// browser cache, and the same source used to be compiled once per CALLER: a
|
|
18179
|
+
// graph per model that carried it, the composite per effect install. These
|
|
18180
|
+
// key a module by its WGSL and a pipeline by its module and every descriptor
|
|
18181
|
+
// field but the label, so a second request for the same thing — in flight or
|
|
18182
|
+
// done — is the first one's promise. Bounded, oldest first, because an editor
|
|
18183
|
+
// session compiles a new source per keystroke.
|
|
18184
|
+
|
|
18185
|
+
private static readonly SHADER_CACHE_MAX = 256
|
|
18186
|
+
private static readonly PIPELINE_CACHE_MAX = 512
|
|
18187
|
+
|
|
18188
|
+
private static trimCache<K, V>(cache: Map<K, V>, max: number): void {
|
|
18189
|
+
while (cache.size > max) cache.delete(cache.keys().next().value as K)
|
|
18190
|
+
}
|
|
18191
|
+
|
|
18192
|
+
/** A small stable id per GPU object, for cache keys. */
|
|
18193
|
+
private gpuObjectId(o: object): number {
|
|
18194
|
+
let id = this.gpuObjectIds.get(o)
|
|
18195
|
+
if (id === undefined) {
|
|
18196
|
+
id = this.gpuObjectNext++
|
|
18197
|
+
this.gpuObjectIds.set(o, id)
|
|
18198
|
+
}
|
|
18199
|
+
return id
|
|
18200
|
+
}
|
|
18201
|
+
|
|
18202
|
+
/** One module per WGSL source. */
|
|
18203
|
+
private cachedShaderModule(code: string, label: string): GPUShaderModule {
|
|
18204
|
+
let module = this.shaderModuleCache.get(code)
|
|
18205
|
+
if (module) {
|
|
18206
|
+
this.shaderModuleCache.delete(code)
|
|
18207
|
+
} else {
|
|
18208
|
+
module = this.device.createShaderModule({ label, code })
|
|
18209
|
+
}
|
|
18210
|
+
this.shaderModuleCache.set(code, module)
|
|
18211
|
+
Engine.trimCache(this.shaderModuleCache, Engine.SHADER_CACHE_MAX)
|
|
18212
|
+
return module
|
|
18213
|
+
}
|
|
18214
|
+
|
|
18215
|
+
/** Drop a source whose module did not validate, so a retry reports afresh. */
|
|
18216
|
+
private forgetShaderModule(code: string): void {
|
|
18217
|
+
this.shaderModuleCache.delete(code)
|
|
18218
|
+
}
|
|
18219
|
+
|
|
18220
|
+
/** One bind group layout per entry list. */
|
|
18221
|
+
private cachedBindGroupLayout(desc: GPUBindGroupLayoutDescriptor): GPUBindGroupLayout {
|
|
18222
|
+
const key = JSON.stringify(desc.entries)
|
|
18223
|
+
let layout = this.bindGroupLayoutCache.get(key)
|
|
18224
|
+
if (!layout) {
|
|
18225
|
+
layout = this.device.createBindGroupLayout(desc)
|
|
18226
|
+
this.bindGroupLayoutCache.set(key, layout)
|
|
18227
|
+
}
|
|
18228
|
+
return layout
|
|
18229
|
+
}
|
|
18230
|
+
|
|
18231
|
+
/** One pipeline layout per bind group layout list. */
|
|
18232
|
+
private cachedPipelineLayout(bindGroupLayouts: GPUBindGroupLayout[]): GPUPipelineLayout {
|
|
18233
|
+
const key = bindGroupLayouts.map((l) => this.gpuObjectId(l)).join(",")
|
|
18234
|
+
let layout = this.pipelineLayoutCache.get(key)
|
|
18235
|
+
if (!layout) {
|
|
18236
|
+
layout = this.device.createPipelineLayout({ bindGroupLayouts })
|
|
18237
|
+
this.pipelineLayoutCache.set(key, layout)
|
|
18238
|
+
}
|
|
18239
|
+
return layout
|
|
18240
|
+
}
|
|
18241
|
+
|
|
18242
|
+
private pipelineKey(kind: string, desc: GPURenderPipelineDescriptor | GPUComputePipelineDescriptor): string {
|
|
18243
|
+
return (
|
|
18244
|
+
kind +
|
|
18245
|
+
JSON.stringify(desc, (k, v) =>
|
|
18246
|
+
k === "label" ? undefined : (k === "module" || k === "layout") && v && typeof v === "object" ? `#${this.gpuObjectId(v)}` : v,
|
|
18247
|
+
)
|
|
18248
|
+
)
|
|
18249
|
+
}
|
|
18250
|
+
|
|
18251
|
+
private cachedPipeline<T extends GPURenderPipeline | GPUComputePipeline>(key: string, build: () => Promise<T>): Promise<T> {
|
|
18252
|
+
let p = this.pipelineCache.get(key) as Promise<T> | undefined
|
|
18253
|
+
if (p) {
|
|
18254
|
+
this.pipelineCache.delete(key)
|
|
18255
|
+
} else {
|
|
18256
|
+
p = build().catch((e) => {
|
|
18257
|
+
this.pipelineCache.delete(key)
|
|
18258
|
+
throw e
|
|
18259
|
+
})
|
|
18260
|
+
}
|
|
18261
|
+
this.pipelineCache.set(key, p)
|
|
18262
|
+
Engine.trimCache(this.pipelineCache, Engine.PIPELINE_CACHE_MAX)
|
|
18263
|
+
return p
|
|
18264
|
+
}
|
|
18265
|
+
|
|
18266
|
+
/** createRenderPipelineAsync, once per module + state. */
|
|
18267
|
+
private cachedRenderPipeline(desc: GPURenderPipelineDescriptor): Promise<GPURenderPipeline> {
|
|
18268
|
+
return this.cachedPipeline(this.pipelineKey("render", desc), () => this.device.createRenderPipelineAsync(desc))
|
|
18269
|
+
}
|
|
18270
|
+
|
|
18271
|
+
/** createComputePipelineAsync, once per module + state. */
|
|
18272
|
+
private cachedComputePipeline(desc: GPUComputePipelineDescriptor): Promise<GPUComputePipeline> {
|
|
18273
|
+
return this.cachedPipeline(this.pipelineKey("compute", desc), () => this.device.createComputePipelineAsync(desc))
|
|
18274
|
+
}
|
|
18275
|
+
|
|
18276
|
+
/**
|
|
18277
|
+
* A pipeline init builds: async, in parallel with the rest, and in place by
|
|
18278
|
+
* the time init returns (it awaits every job). Outside init — a pass built
|
|
18279
|
+
* on first use — it is the synchronous build it always was.
|
|
18280
|
+
*/
|
|
18281
|
+
private initRenderPipeline(desc: GPURenderPipelineDescriptor, assign: (p: GPURenderPipeline) => void, twins = false): void {
|
|
18282
|
+
if (!this.initPipelineJobs) {
|
|
18283
|
+
const pipeline = this.device.createRenderPipeline(desc)
|
|
18284
|
+
if (twins) this.pipelineDescs.set(pipeline, desc)
|
|
18285
|
+
assign(pipeline)
|
|
18286
|
+
return
|
|
18287
|
+
}
|
|
18288
|
+
this.initPipelineJobs.push(
|
|
18289
|
+
this.cachedRenderPipeline(desc)
|
|
18290
|
+
// A rejected build is rebuilt the old way, which hands back the invalid
|
|
18291
|
+
// pipeline and reports through uncapturederror — a bad init pipeline
|
|
18292
|
+
// was never fatal to init, and is not now.
|
|
18293
|
+
.catch(() => this.device.createRenderPipeline(desc))
|
|
18294
|
+
.then((pipeline) => {
|
|
18295
|
+
assign(pipeline)
|
|
18296
|
+
if (!twins) return
|
|
18297
|
+
this.pipelineDescs.set(pipeline, desc)
|
|
18298
|
+
// The mirror's twin too, but not waited for: only a scene with a
|
|
18299
|
+
// mirror draws it, and it is a cache hit on the module just built.
|
|
18300
|
+
void this.buildSidedTwin(pipeline, true)
|
|
18301
|
+
return this.buildSidedTwin(pipeline, false)
|
|
18302
|
+
}),
|
|
18303
|
+
)
|
|
18304
|
+
}
|
|
18305
|
+
|
|
18306
|
+
/** The compute twin of initRenderPipeline. */
|
|
18307
|
+
private initComputePipeline(desc: GPUComputePipelineDescriptor, assign: (p: GPUComputePipeline) => void): void {
|
|
18308
|
+
if (!this.initPipelineJobs) {
|
|
18309
|
+
assign(this.device.createComputePipeline(desc))
|
|
18310
|
+
return
|
|
18311
|
+
}
|
|
18312
|
+
this.initPipelineJobs.push(
|
|
18313
|
+
this.cachedComputePipeline(desc)
|
|
18314
|
+
.catch(() => this.device.createComputePipeline(desc))
|
|
18315
|
+
.then(assign),
|
|
18316
|
+
)
|
|
18317
|
+
}
|
|
18318
|
+
|
|
18319
|
+
/**
|
|
18320
|
+
* Build a model pipeline's single-sided twin off the draw path (see
|
|
18321
|
+
* sidedPipeline). Resolves once it is in place; never rejects — a twin that
|
|
18322
|
+
* fails leaves the draw on its double-sided pipeline, as before it existed.
|
|
18323
|
+
*/
|
|
18324
|
+
private buildSidedTwin(pipeline: GPURenderPipeline, mirrored: boolean): Promise<void> {
|
|
18325
|
+
const desc = this.pipelineDescs.get(pipeline)
|
|
18326
|
+
if (!desc || (desc.primitive?.cullMode ?? "none") !== "none") return Promise.resolve()
|
|
18327
|
+
const cache = mirrored ? this.singleSidedMirror : this.singleSided
|
|
18328
|
+
const pending = mirrored ? this.sidedPendingMirror : this.sidedPending
|
|
18329
|
+
if (cache.has(pipeline) || pending.has(pipeline)) return Promise.resolve()
|
|
18330
|
+
pending.add(pipeline)
|
|
18331
|
+
return this.cachedRenderPipeline({
|
|
18332
|
+
...desc,
|
|
18333
|
+
label: `${desc.label ?? "pipeline"} (single-sided${mirrored ? ", mirror" : ""})`,
|
|
18334
|
+
primitive: { ...desc.primitive, cullMode: mirrored ? "back" : "front" },
|
|
18335
|
+
}).then(
|
|
18336
|
+
(twin) => {
|
|
18337
|
+
cache.set(pipeline, twin)
|
|
18338
|
+
pending.delete(pipeline)
|
|
18339
|
+
// A bundle recorded meanwhile holds the double-sided stand-in.
|
|
18340
|
+
this.bundlesDirty = true
|
|
18341
|
+
},
|
|
18342
|
+
() => {
|
|
18343
|
+
// Remembered as itself, so the draw stops asking.
|
|
18344
|
+
cache.set(pipeline, pipeline)
|
|
18345
|
+
pending.delete(pipeline)
|
|
18346
|
+
},
|
|
18347
|
+
)
|
|
18348
|
+
}
|
|
18349
|
+
|
|
17496
18350
|
/**
|
|
17497
18351
|
* The pipeline a draw should use once its double-sided flag is honoured.
|
|
17498
18352
|
*
|
|
@@ -17510,17 +18364,14 @@ export class Engine {
|
|
|
17510
18364
|
if (draw.doubleSided) return pipeline
|
|
17511
18365
|
const desc = this.pipelineDescs.get(pipeline)
|
|
17512
18366
|
if (!desc || (desc.primitive?.cullMode ?? "none") !== "none") return pipeline
|
|
17513
|
-
const
|
|
17514
|
-
|
|
17515
|
-
|
|
17516
|
-
|
|
17517
|
-
|
|
17518
|
-
|
|
17519
|
-
|
|
17520
|
-
|
|
17521
|
-
cache.set(pipeline, twin)
|
|
17522
|
-
}
|
|
17523
|
-
return twin
|
|
18367
|
+
const twin = (mirrored ? this.singleSidedMirror : this.singleSided).get(pipeline)
|
|
18368
|
+
if (twin) return twin
|
|
18369
|
+
// NEVER BUILT HERE SYNCHRONOUSLY: a sync build stalls the GPU process for
|
|
18370
|
+
// the whole compile, mid-frame. The camera's twins are built beside their
|
|
18371
|
+
// pipelines; one asked for first here (a mirror's) is started now and the
|
|
18372
|
+
// draw keeps its double-sided pipeline until it lands.
|
|
18373
|
+
void this.buildSidedTwin(pipeline, mirrored)
|
|
18374
|
+
return pipeline
|
|
17524
18375
|
}
|
|
17525
18376
|
|
|
17526
18377
|
// Pipeline for a material draw call: its group's compiled pipeline when grouped, else
|
|
@@ -17552,6 +18403,46 @@ export class Engine {
|
|
|
17552
18403
|
* makes outlines compose like MMD: every material drawn later in the author's
|
|
17553
18404
|
* order covers earlier hulls, and each hull sits over everything drawn before it.
|
|
17554
18405
|
*/
|
|
18406
|
+
/**
|
|
18407
|
+
* A stage's draws, far to near from the eye: its transparent queue, in the
|
|
18408
|
+
* order a game sorts one. Each draw's distance is its material box's centre,
|
|
18409
|
+
* placed, against the camera — a pane or a water surface is one material,
|
|
18410
|
+
* which is the unit a game sorts too. The array is kept per instance and
|
|
18411
|
+
* re-sorted in place, so an unchanged order costs a comparison per draw.
|
|
18412
|
+
*/
|
|
18413
|
+
private backToFront(inst: ModelInstance): DrawCall[] {
|
|
18414
|
+
let list = this.sortedDraws.get(inst)
|
|
18415
|
+
if (!list || list.length !== inst.drawCalls.length || list.some((d) => !inst.drawCalls.includes(d))) {
|
|
18416
|
+
list = [...inst.drawCalls]
|
|
18417
|
+
this.sortedDraws.set(inst, list)
|
|
18418
|
+
}
|
|
18419
|
+
const m = inst.model
|
|
18420
|
+
const ex = this.cameraMatrixData[32]
|
|
18421
|
+
const ey = this.cameraMatrixData[33]
|
|
18422
|
+
const ez = this.cameraMatrixData[34]
|
|
18423
|
+
const p = this.sortScratch
|
|
18424
|
+
const dist = this.sortDistance
|
|
18425
|
+
dist.clear()
|
|
18426
|
+
for (const d of list) {
|
|
18427
|
+
const b = d.bounds
|
|
18428
|
+
if (!b || b.length < 6 || !(b[0] <= b[3])) {
|
|
18429
|
+
dist.set(d, 0)
|
|
18430
|
+
continue
|
|
18431
|
+
}
|
|
18432
|
+
p.setXYZ(((b[0] + b[3]) * 0.5) * m.scale, ((b[1] + b[4]) * 0.5) * m.scale, ((b[2] + b[5]) * 0.5) * m.scale)
|
|
18433
|
+
Quat.rotateVecInto(m.rotation, p, p)
|
|
18434
|
+
const dx = p.x + m.position.x - ex
|
|
18435
|
+
const dy = p.y + m.position.y - ey
|
|
18436
|
+
const dz = p.z + m.position.z - ez
|
|
18437
|
+
dist.set(d, dx * dx + dy * dy + dz * dz)
|
|
18438
|
+
}
|
|
18439
|
+
list.sort((a, b) => dist.get(b)! - dist.get(a)!)
|
|
18440
|
+
return list
|
|
18441
|
+
}
|
|
18442
|
+
private sortedDraws = new WeakMap<ModelInstance, DrawCall[]>()
|
|
18443
|
+
private sortScratch = new Vec3(0, 0, 0)
|
|
18444
|
+
private sortDistance = new Map<DrawCall, number>()
|
|
18445
|
+
|
|
17555
18446
|
/** Is this draw's compiled class "hair"? Ungrouped draws never are — the
|
|
17556
18447
|
* neutral pipeline is the auto class. */
|
|
17557
18448
|
private isHairDraw(inst: ModelInstance, dc: DrawCall): boolean {
|
|
@@ -17564,7 +18455,7 @@ export class Engine {
|
|
|
17564
18455
|
pass: GPURenderPassEncoder | GPURenderBundleEncoder,
|
|
17565
18456
|
inst: ModelInstance,
|
|
17566
18457
|
type: "opaque" | "transparent",
|
|
17567
|
-
view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
|
|
18458
|
+
view: { perFrame: GPUBindGroup; args: "camera" | "mirror" | "probe"; outlines: boolean },
|
|
17568
18459
|
// The opaque phase walks its author order twice — non-hair, then hair — so
|
|
17569
18460
|
// the hair depth prime can sit between the eye's stencil write and the hair
|
|
17570
18461
|
// colour that must respect it. See renderModelOpaquePhase.
|
|
@@ -17572,9 +18463,17 @@ export class Engine {
|
|
|
17572
18463
|
): void {
|
|
17573
18464
|
let currentPipeline: GPURenderPipeline | null = null
|
|
17574
18465
|
let bound = false
|
|
17575
|
-
|
|
18466
|
+
const outlined: DrawCall[] = []
|
|
18467
|
+
// A stage's glass and water in the camera pass go far to near, as a
|
|
18468
|
+
// game's transparent queue does: author order put a far pane over a near
|
|
18469
|
+
// one wherever the author listed it last. Characters keep MMD's author
|
|
18470
|
+
// order — their layers are fabric, built around it.
|
|
18471
|
+
const draws = type === "transparent" && inst.isStage && view.args === "camera" ? this.backToFront(inst) : inst.drawCalls
|
|
18472
|
+
for (const draw of draws) {
|
|
17576
18473
|
if (draw.type !== type) continue
|
|
17577
18474
|
if (only && (only === "hair") !== this.isHairDraw(inst, draw)) continue
|
|
18475
|
+
// The probe draws directly, past the cull: honour the switches it holds.
|
|
18476
|
+
if (view.args === "probe" && !this.shouldRenderDrawCall(inst, draw)) continue
|
|
17578
18477
|
if (!bound) {
|
|
17579
18478
|
pass.setBindGroup(0, view.perFrame)
|
|
17580
18479
|
pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
|
|
@@ -17588,32 +18487,42 @@ export class Engine {
|
|
|
17588
18487
|
}
|
|
17589
18488
|
pass.setBindGroup(2, draw.bindGroup)
|
|
17590
18489
|
this.issueDraw(pass, draw, view.args)
|
|
17591
|
-
if (draw.outline && this.outlineEnabled && view.outlines)
|
|
17592
|
-
|
|
17593
|
-
|
|
17594
|
-
|
|
17595
|
-
|
|
17596
|
-
|
|
17597
|
-
|
|
17598
|
-
|
|
18490
|
+
if (draw.outline && this.outlineEnabled && view.outlines) outlined.push(draw)
|
|
18491
|
+
}
|
|
18492
|
+
// THE HULLS AFTER THE SURFACES, as MMD draws a model's edges. A hull held
|
|
18493
|
+
// at the minimum width is faded, and it still writes depth: drawn between
|
|
18494
|
+
// two materials it hid the second wherever it stood in front of it — a
|
|
18495
|
+
// shoe's sole's hull cut the upper behind it, and the floor showed through
|
|
18496
|
+
// as pale lines along the seam. After every surface of this phase it only
|
|
18497
|
+
// ever blends over finished ones. Same index ranges; own pipeline and
|
|
18498
|
+
// groups 0/2 — group 1 (skinMats) is layout-identical and stays bound.
|
|
18499
|
+
if (outlined.length) {
|
|
18500
|
+
const mirrored = view.args === "mirror"
|
|
18501
|
+
pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline)
|
|
18502
|
+
pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup)
|
|
18503
|
+
// Slot 3: the hull's smoothed normals + edge scale. Only the outline
|
|
18504
|
+
// pipeline reads it; the others leave the slot alone.
|
|
18505
|
+
if (inst.outlineVertexBuffer) pass.setVertexBuffer(3, inst.outlineVertexBuffer)
|
|
18506
|
+
for (const draw of outlined) {
|
|
18507
|
+
pass.setBindGroup(2, draw.outline!.bindGroup)
|
|
17599
18508
|
this.issueDraw(pass, draw, view.args)
|
|
17600
|
-
pass.setBindGroup(0, view.perFrame)
|
|
17601
|
-
currentPipeline = null
|
|
17602
18509
|
}
|
|
18510
|
+
pass.setBindGroup(0, view.perFrame)
|
|
17603
18511
|
}
|
|
17604
18512
|
}
|
|
17605
18513
|
|
|
17606
18514
|
/**
|
|
17607
18515
|
* Main-pass render sequence for one model instance — babylon-mmd parity:
|
|
17608
18516
|
* opaque bucket, the hair-over-eyes stencil pass, then alpha-blend materials
|
|
17609
|
-
* in PMX author order with depth write ON (forceDepthWrite). Outlines
|
|
17610
|
-
*
|
|
17611
|
-
*
|
|
18517
|
+
* in PMX author order with depth write ON (forceDepthWrite). Outlines close
|
|
18518
|
+
* each drawMaterials call: the edge-flagged materials' hulls after all of its
|
|
18519
|
+
* surfaces, as MMD draws a model's edges.
|
|
17612
18520
|
*/
|
|
17613
18521
|
private setModelDrawState(pass: GPURenderPassEncoder | GPURenderBundleEncoder, inst: ModelInstance): void {
|
|
17614
18522
|
pass.setVertexBuffer(0, inst.vertexBuffer)
|
|
17615
18523
|
pass.setVertexBuffer(1, inst.jointsBuffer)
|
|
17616
18524
|
pass.setVertexBuffer(2, inst.weightsBuffer)
|
|
18525
|
+
pass.setVertexBuffer(4, inst.attributeBuffer)
|
|
17617
18526
|
pass.setIndexBuffer(inst.indexBuffer, "uint32")
|
|
17618
18527
|
// The stencil reference used to be set here. It is pass state, not bundle
|
|
17619
18528
|
// state — GPURenderBundleEncoder has no setStencilReference at all — so it
|
|
@@ -17980,7 +18889,7 @@ export class Engine {
|
|
|
17980
18889
|
// still pattern welded to the picture. On the SCENE clock like everything
|
|
17981
18890
|
// else here, so an export reproduces the editor exactly rather than
|
|
17982
18891
|
// scattering differently at whatever rate the encoder ran.
|
|
17983
|
-
u[3] =
|
|
18892
|
+
u[3] = Math.floor((this.sceneClock * 24) % 1024)
|
|
17984
18893
|
u[26] = this.canvas.width
|
|
17985
18894
|
u[27] = this.canvas.height
|
|
17986
18895
|
// Camera world position (viewU[10]) — the other half of bgWorldPos. It
|
|
@@ -18214,7 +19123,10 @@ export class Engine {
|
|
|
18214
19123
|
}
|
|
18215
19124
|
for (let s = 0; s < anchors.length; s++) {
|
|
18216
19125
|
const a = CAST_SUBJECT_VEC4S * 4 + (s * MAX_EFFECT_SUBJECTS + n) * 12
|
|
18217
|
-
|
|
19126
|
+
// `along`: the point that far down the bone's own axis, still model space
|
|
19127
|
+
// here, so the placement below turns and scales it like the joint.
|
|
19128
|
+
const along = anchors[s].along
|
|
19129
|
+
const pos = along ? m.getBonePointAlong(anchors[s].bone, along) : m.getBoneWorldPosition(anchors[s].bone)
|
|
18218
19130
|
if (!pos) {
|
|
18219
19131
|
cd[a + 3] = 0
|
|
18220
19132
|
continue
|
|
@@ -18236,7 +19148,7 @@ export class Engine {
|
|
|
18236
19148
|
cd[a + 4] = vx
|
|
18237
19149
|
cd[a + 5] = vy
|
|
18238
19150
|
cd[a + 6] = vz
|
|
18239
|
-
if (anchors[s].trail) this.writeTrail(inst.name, s, n, pos, cd, a)
|
|
19151
|
+
if (anchors[s].trail) this.writeTrail(inst.name, s, n, pos, cd, a, anchors[s].step)
|
|
18240
19152
|
const fwd = m.getBoneWorldForward(anchors[s].bone)
|
|
18241
19153
|
if (fwd) {
|
|
18242
19154
|
Quat.rotateVecInto(m.rotation, fwd, fwd)
|
|
@@ -18278,7 +19190,7 @@ export class Engine {
|
|
|
18278
19190
|
this.castData.fill(0, CAST_SUBJECT_VEC4S * 4)
|
|
18279
19191
|
}
|
|
18280
19192
|
|
|
18281
|
-
private writeTrail(model: string, slot: number, n: number, pos: Vec3, cd: Float32Array, anchorBase: number): void {
|
|
19193
|
+
private writeTrail(model: string, slot: number, n: number, pos: Vec3, cd: Float32Array, anchorBase: number, step?: number): void {
|
|
18282
19194
|
const key = `${model}\u0000${slot}`
|
|
18283
19195
|
let ring = this.anchorTrail.get(key)
|
|
18284
19196
|
if (!ring) {
|
|
@@ -18299,9 +19211,44 @@ export class Engine {
|
|
|
18299
19211
|
if (Math.hypot(dx, dy, dz) / dt > 50) {
|
|
18300
19212
|
ring.pos.length = 0
|
|
18301
19213
|
ring.t.length = 0
|
|
19214
|
+
ring.live = false
|
|
18302
19215
|
}
|
|
18303
19216
|
}
|
|
18304
|
-
if (
|
|
19217
|
+
if (step && ring.pos.length > 0) {
|
|
19218
|
+
// `trail step d` — the path a game's trail keeps (Unity's
|
|
19219
|
+
// minVertexDistance). Point 0 is the LIVE head: it follows the bone every
|
|
19220
|
+
// frame. It is left behind as a kept point only on a tick, and only once
|
|
19221
|
+
// the bone is `step` from the last point kept, so no segment is shorter
|
|
19222
|
+
// than that and a slowing hand cannot leave a knot of jittery stubs for a
|
|
19223
|
+
// wide ribbon to fan across. Still at most one point per tick, so a stepped
|
|
19224
|
+
// ring never covers LESS time than a sampled one.
|
|
19225
|
+
const live = ring.live === true
|
|
19226
|
+
const k = live ? 3 : 0
|
|
19227
|
+
const far = Math.hypot(pos.x - ring.pos[k], pos.y - ring.pos[k + 1], pos.z - ring.pos[k + 2]) >= step
|
|
19228
|
+
if (live && !(far && this.trailDue > 0)) {
|
|
19229
|
+
ring.pos[0] = pos.x
|
|
19230
|
+
ring.pos[1] = pos.y
|
|
19231
|
+
ring.pos[2] = pos.z
|
|
19232
|
+
ring.t[0] = this.sceneClock
|
|
19233
|
+
} else {
|
|
19234
|
+
// keep the head where it is as a point (live) or start a new head
|
|
19235
|
+
if (live) {
|
|
19236
|
+
ring.pos[0] = pos.x
|
|
19237
|
+
ring.pos[1] = pos.y
|
|
19238
|
+
ring.pos[2] = pos.z
|
|
19239
|
+
ring.t[0] = this.sceneClock
|
|
19240
|
+
ring.live = false
|
|
19241
|
+
} else {
|
|
19242
|
+
ring.pos.unshift(pos.x, pos.y, pos.z)
|
|
19243
|
+
ring.t.unshift(this.sceneClock)
|
|
19244
|
+
ring.live = !(far && this.trailDue > 0)
|
|
19245
|
+
}
|
|
19246
|
+
if (ring.t.length > TRAIL_SAMPLES) {
|
|
19247
|
+
ring.t.length = TRAIL_SAMPLES
|
|
19248
|
+
ring.pos.length = TRAIL_SAMPLES * 3
|
|
19249
|
+
}
|
|
19250
|
+
}
|
|
19251
|
+
} else if (this.trailDue > 0 || ring.pos.length === 0) {
|
|
18305
19252
|
// ONE sample per frame, never one per due tick. A frame that spanned
|
|
18306
19253
|
// several 60Hz ticks only knows where the bone is NOW, and unshifting that
|
|
18307
19254
|
// position once per tick fabricated duplicate samples — same point, same
|
|
@@ -18385,6 +19332,9 @@ export class Engine {
|
|
|
18385
19332
|
headSkin: () => (head >= 0 ? inst.model.getSkinMatrices().subarray(head * 16, head * 16 + 16) : null),
|
|
18386
19333
|
headRest,
|
|
18387
19334
|
layers: new Uint32Array(inst.objectLight.buffer)[0],
|
|
19335
|
+
additionalUv: (channel) => inst.model.getAdditionalUv(channel),
|
|
19336
|
+
morphs: inst.model.getMorphing().morphs,
|
|
19337
|
+
morphWeights: () => inst.model.getEffectiveMorphWeights(),
|
|
18388
19338
|
},
|
|
18389
19339
|
look,
|
|
18390
19340
|
)
|
|
@@ -18392,6 +19342,27 @@ export class Engine {
|
|
|
18392
19342
|
return true
|
|
18393
19343
|
}
|
|
18394
19344
|
|
|
19345
|
+
/** Resolves once a model's native look can draw: every pass's pipeline is
|
|
19346
|
+
* compiled (async, at setModelNativeLook). Until then its dressed materials
|
|
19347
|
+
* sit out — a host reveals the model after this to avoid that gap. */
|
|
19348
|
+
nativeLookReady(name: string): Promise<void> {
|
|
19349
|
+
return this.nativeLooks?.ready(name) ?? Promise.resolve()
|
|
19350
|
+
}
|
|
19351
|
+
|
|
19352
|
+
/**
|
|
19353
|
+
* Set some of the values of one material a model's native look dresses —
|
|
19354
|
+
* its Unity material properties by name, colours linear, as the look's
|
|
19355
|
+
* `values` hold them — over the look's own, until set again. What a game
|
|
19356
|
+
* animates on a material over a take (its timeline's material curves)
|
|
19357
|
+
* reaches the shaders this way, one frame at a time: the caller samples its
|
|
19358
|
+
* keys on its own clock and sets what it got. Only the names the material's
|
|
19359
|
+
* look carries are taken, and a value equal to the one in place writes
|
|
19360
|
+
* nothing. False when the model wears no look dressing that material.
|
|
19361
|
+
*/
|
|
19362
|
+
setModelNativeUniforms(name: string, material: string, values: Record<string, number | ArrayLike<number>>): boolean {
|
|
19363
|
+
return this.nativeLooks?.setUniforms(name, material, values) ?? false
|
|
19364
|
+
}
|
|
19365
|
+
|
|
18395
19366
|
/** The hosts for the game's shaders: the scene pass's and the shadow atlas's,
|
|
18396
19367
|
* with the stand-ins an empty slot reads. Built on first use. */
|
|
18397
19368
|
private ensureNativeHosts(): NativeHost {
|
|
@@ -18438,6 +19409,18 @@ export class Engine {
|
|
|
18438
19409
|
white,
|
|
18439
19410
|
black,
|
|
18440
19411
|
blackCube: solid("native: black cube", [0, 0, 0, 255], true),
|
|
19412
|
+
// an empty volume (a froxel buffer no pass filled): no in-scatter, no extinction
|
|
19413
|
+
emptyVolume: (() => {
|
|
19414
|
+
const t = d.createTexture({
|
|
19415
|
+
label: "native: empty volume",
|
|
19416
|
+
size: [1, 1, 1],
|
|
19417
|
+
dimension: "3d",
|
|
19418
|
+
format: "rgba8unorm",
|
|
19419
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
19420
|
+
})
|
|
19421
|
+
d.queue.writeTexture({ texture: t }, new Uint8Array([0, 0, 0, 0]), { bytesPerRow: 4, rowsPerImage: 1 }, [1, 1, 1])
|
|
19422
|
+
return t.createView({ dimension: "3d" })
|
|
19423
|
+
})(),
|
|
18441
19424
|
depth: this.nativeNoShadowView,
|
|
18442
19425
|
comparison: this.shadowComparisonSampler,
|
|
18443
19426
|
}
|
|
@@ -18476,7 +19459,8 @@ export class Engine {
|
|
|
18476
19459
|
{ mipmaps: (t, levels) => this.generateMipmaps(t, levels), fallback: this.nativeFallback },
|
|
18477
19460
|
onProgress,
|
|
18478
19461
|
)
|
|
18479
|
-
|
|
19462
|
+
// The stage's sky is in it: the far plane reaches as far as the stage does.
|
|
19463
|
+
this.raiseSceneExtent(this.nativeStage.extent)
|
|
18480
19464
|
}
|
|
18481
19465
|
|
|
18482
19466
|
/** What went wrong in the game's shaders, and the names nothing supplied. */
|
|
@@ -18526,11 +19510,17 @@ export class Engine {
|
|
|
18526
19510
|
let sunDirection: [number, number, number] = [dir.x, dir.y, dir.z]
|
|
18527
19511
|
let sunColor: [number, number, number] = [sun.color.x * k, sun.color.y * k, sun.color.z * k]
|
|
18528
19512
|
let sunShadow = this.sunShadow
|
|
19513
|
+
let sunUnity: NativeStageLights["main"]["unity"] | null = null
|
|
18529
19514
|
if (stage) {
|
|
18530
19515
|
// Its lamps are the stage's own; its key is the scene's sun, which the
|
|
18531
|
-
// host sets from the stage (and a person may then move)
|
|
19516
|
+
// host sets from the stage (and a person may then move) - along the
|
|
19517
|
+
// scene sun's direction, in the stage's own main-light colour. The scene
|
|
19518
|
+
// sun's colour is the cast's key, which the game keeps apart (a
|
|
19519
|
+
// character's light is _ProbeLightingBase, not the stage's main light).
|
|
18532
19520
|
const L = stage.pkg.lights
|
|
18533
19521
|
const sc = stage.pkg.scale
|
|
19522
|
+
sunColor = [L.main.color[0], L.main.color[1], L.main.color[2]]
|
|
19523
|
+
sunUnity = L.main.unity ?? null
|
|
18534
19524
|
lights.length = 0
|
|
18535
19525
|
for (const l of L.additional) {
|
|
18536
19526
|
lights.push({
|
|
@@ -18560,6 +19550,7 @@ export class Engine {
|
|
|
18560
19550
|
dt: 1 / 60,
|
|
18561
19551
|
sunDirection,
|
|
18562
19552
|
sunColor,
|
|
19553
|
+
sunUnity,
|
|
18563
19554
|
sunShadow,
|
|
18564
19555
|
lights,
|
|
18565
19556
|
ambientSH: sh ? Array.from(sh, (v) => v * s) : null,
|
|
@@ -18673,17 +19664,3 @@ function stableGlobals(prev: Record<string, NativeValue>, next: Record<string, N
|
|
|
18673
19664
|
}
|
|
18674
19665
|
return next
|
|
18675
19666
|
}
|
|
18676
|
-
|
|
18677
|
-
function sameValue(a: NativeValue, b: NativeValue): boolean {
|
|
18678
|
-
if (typeof a === "number" || typeof b === "number") return a === b
|
|
18679
|
-
if (a.length !== b.length) return false
|
|
18680
|
-
if (a instanceof Uint32Array !== b instanceof Uint32Array) return false
|
|
18681
|
-
for (let i = 0; i < a.length; i++) {
|
|
18682
|
-
const x = a[i] as number | ArrayLike<number>
|
|
18683
|
-
const y = b[i] as number | ArrayLike<number>
|
|
18684
|
-
if (typeof x === "number" || typeof y === "number") {
|
|
18685
|
-
if (x !== y) return false
|
|
18686
|
-
} else if (!sameValue(x, y)) return false
|
|
18687
|
-
}
|
|
18688
|
-
return true
|
|
18689
|
-
}
|