reze-engine 0.63.0 → 0.65.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 +226 -96
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1187 -732
- package/dist/graph/compile.d.ts.map +1 -1
- package/dist/graph/compile.js +4 -1
- package/dist/graph/registry.d.ts +3 -0
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +133 -0
- package/dist/graph/schema.d.ts +12 -0
- package/dist/graph/schema.d.ts.map +1 -1
- package/dist/graph/slots.d.ts.map +1 -1
- package/dist/graph/slots.js +15 -18
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/model.d.ts +10 -1
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +15 -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 +3 -0
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +7 -2
- package/dist/shaders/lights.d.ts +3 -2
- package/dist/shaders/lights.d.ts.map +1 -1
- package/dist/shaders/lights.js +16 -3
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +65 -56
- 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 +210 -0
- 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.map +1 -1
- package/dist/shaders/passes/composite.js +122 -64
- package/dist/shaders/passes/cull.d.ts +1 -1
- package/dist/shaders/passes/cull.d.ts.map +1 -1
- package/dist/shaders/passes/cull.js +7 -3
- package/dist/shaders/passes/ground.d.ts.map +1 -1
- package/dist/shaders/passes/ground.js +32 -55
- 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 +1 -1
- package/dist/shaders/passes/particles.js +1 -1
- package/dist/shaders/scene-light-api.d.ts +18 -10
- package/dist/shaders/scene-light-api.d.ts.map +1 -1
- package/dist/shaders/scene-light-api.js +58 -46
- package/dist/shadow-cascades.d.ts +47 -20
- package/dist/shadow-cascades.d.ts.map +1 -1
- package/dist/shadow-cascades.js +36 -13
- package/dist/unity/character.d.ts +59 -0
- package/dist/unity/character.d.ts.map +1 -0
- package/dist/unity/character.js +120 -0
- package/dist/unity/globals.d.ts +93 -0
- package/dist/unity/globals.d.ts.map +1 -0
- package/dist/unity/globals.js +340 -0
- package/dist/unity/host.d.ts +152 -0
- package/dist/unity/host.d.ts.map +1 -0
- package/dist/unity/host.js +529 -0
- package/dist/unity/looks.d.ts +93 -0
- package/dist/unity/looks.d.ts.map +1 -0
- package/dist/unity/looks.js +312 -0
- package/dist/unity/skin.d.ts +9 -0
- package/dist/unity/skin.d.ts.map +1 -0
- package/dist/unity/skin.js +120 -0
- package/dist/unity/stage.d.ts +155 -0
- package/dist/unity/stage.d.ts.map +1 -0
- package/dist/unity/stage.js +410 -0
- package/dist/unity/state.d.ts +22 -0
- package/dist/unity/state.d.ts.map +1 -0
- package/dist/unity/state.js +170 -0
- package/package.json +1 -1
- package/src/engine.ts +1535 -964
- package/src/graph/compile.ts +3 -1
- package/src/graph/registry.ts +142 -0
- package/src/graph/schema.ts +9 -0
- package/src/graph/slots.ts +15 -18
- package/src/index.ts +12 -0
- package/src/model.ts +18 -1
- package/src/outline-normals.ts +110 -0
- package/src/pmx-loader.ts +8 -2
- package/src/shaders/lights.ts +16 -3
- package/src/shaders/materials/common.ts +65 -57
- package/src/shaders/materials/nodes.ts +210 -0
- package/src/shaders/passes/bloom.ts +59 -87
- package/src/shaders/passes/composite.ts +122 -64
- package/src/shaders/passes/cull.ts +7 -3
- package/src/shaders/passes/ground.ts +32 -55
- package/src/shaders/passes/mirror.ts +60 -13
- package/src/shaders/passes/outline.ts +71 -14
- package/src/shaders/passes/particles.ts +1 -1
- package/src/shaders/scene-light-api.ts +59 -46
- package/src/shadow-cascades.ts +61 -32
- package/src/unity/character.ts +169 -0
- package/src/unity/globals.ts +443 -0
- package/src/unity/host.ts +630 -0
- package/src/unity/looks.ts +391 -0
- package/src/unity/skin.ts +131 -0
- package/src/unity/stage.ts +604 -0
- package/src/unity/state.ts +172 -0
- package/src/shaders/agx-lut.ts +0 -30
package/src/engine.ts
CHANGED
|
@@ -44,9 +44,21 @@ import {
|
|
|
44
44
|
type SimClock,
|
|
45
45
|
} from "./effect-schedule"
|
|
46
46
|
import { parentKeySpan, type ModelParentKey } from "./parent-keys"
|
|
47
|
-
import {
|
|
47
|
+
import { NativeHost } from "./unity/host"
|
|
48
|
+
import { NativeLooks, type NativeLook } from "./unity/looks"
|
|
49
|
+
import { NativeStage, type NativeStagePackage, type NativeStageReader } from "./unity/stage"
|
|
50
|
+
import { gameDir, unityFrameGlobals } from "./unity/globals"
|
|
51
|
+
import { outlineMaxOffsetMultiplier, towardMatrix } from "./unity/character"
|
|
52
|
+
import type { NativeValue } from "./unity/host"
|
|
53
|
+
import { SHADOW_ATLAS_SIZE, SHADOW_CASCADES, buildShadowCascades, cascadeSpheres, type ShadowBounds, type ShadowView } from "./shadow-cascades"
|
|
48
54
|
import { REFLECTION_DEBUG_WGSL, buildMirrorCamera, planeFromPointNormal } from "./reflection"
|
|
49
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
MIRROR_DOWNSAMPLE_WGSL,
|
|
57
|
+
MIRROR_MASK_DOWNSAMPLE_WGSL,
|
|
58
|
+
MIRROR_MAT_BYTES,
|
|
59
|
+
mirrorShaderWgsl,
|
|
60
|
+
mirrorShadowWgsl,
|
|
61
|
+
} from "./shaders/passes/mirror"
|
|
50
62
|
import { packHalf, type HdrImage } from "./hdr"
|
|
51
63
|
import { evalIrradianceSH, projectIrradianceSH, gradientIrradianceSH } from "./ibl"
|
|
52
64
|
import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, lyricsApi, packLyrics, type LyricLine, type LyricRect } from "./shaders/lyrics-api"
|
|
@@ -98,11 +110,11 @@ import {
|
|
|
98
110
|
type RigidbodyOverlayOptions,
|
|
99
111
|
} from "./overlay"
|
|
100
112
|
import {
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
BLOOM_BLUR_H_SHADER_WGSL,
|
|
114
|
+
BLOOM_BLUR_V_SHADER_WGSL,
|
|
115
|
+
BLOOM_PREFILTER_SHADER_WGSL,
|
|
103
116
|
BLOOM_UPSAMPLE_SHADER_WGSL,
|
|
104
117
|
} from "./shaders/passes/bloom"
|
|
105
|
-
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut"
|
|
106
118
|
import {
|
|
107
119
|
buildCompositeShader,
|
|
108
120
|
EFFECT_SCENE_API,
|
|
@@ -464,6 +476,9 @@ type ParentTrack = { keys: HeldParentKey[]; applied: number; seat: [number, numb
|
|
|
464
476
|
type SunOptions = {
|
|
465
477
|
/** Linear color of the sun lamp (Blender: Light > Color). */
|
|
466
478
|
color?: Vec3
|
|
479
|
+
/** The rendering layers it keys, as bits; every layer by default. A
|
|
480
|
+
* directional light from setLights takes the layers the sun leaves. */
|
|
481
|
+
layers?: number
|
|
467
482
|
/** Lamp power in Blender units (Blender: Light > Strength). */
|
|
468
483
|
strength?: number
|
|
469
484
|
/** Direction sunlight travels (points FROM sun TO scene, Blender: -light.rotation.Z). */
|
|
@@ -547,25 +562,29 @@ type CameraOptions = {
|
|
|
547
562
|
fov?: number
|
|
548
563
|
}
|
|
549
564
|
|
|
550
|
-
/**
|
|
565
|
+
/** Bloom — Aether Gazer's own (shaders/passes/bloom.ts): a soft-knee prefilter,
|
|
566
|
+
* a Gaussian chain down to a few pixels, and a scatter blend back up, added to
|
|
567
|
+
* the scene before the view transform. The defaults ARE the game. */
|
|
551
568
|
export type BloomOptions = {
|
|
552
569
|
enabled: boolean
|
|
570
|
+
/** Brightest-channel level where the glow starts (the game's per-scene
|
|
571
|
+
* threshold). The soft knee is always half of it. */
|
|
553
572
|
threshold: number
|
|
554
|
-
|
|
555
|
-
|
|
573
|
+
/** 0..1: how far each level leans toward the wider one on the way up — low is
|
|
574
|
+
* a tight halo, high a wide haze. The game: 0.77. */
|
|
575
|
+
scatter: number
|
|
576
|
+
/** Tint on the glow; white is the game. */
|
|
556
577
|
color: Vec3
|
|
578
|
+
/** Multiplier on the glow added in the composite; 1 is the game. */
|
|
557
579
|
intensity: number
|
|
558
|
-
clamp: number
|
|
559
580
|
}
|
|
560
581
|
|
|
561
582
|
export const DEFAULT_BLOOM_OPTIONS: BloomOptions = {
|
|
562
583
|
enabled: true,
|
|
563
|
-
threshold: 0.
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
intensity: 0.05,
|
|
568
|
-
clamp: 0.0,
|
|
584
|
+
threshold: 0.7,
|
|
585
|
+
scatter: 0.77,
|
|
586
|
+
color: new Vec3(1, 1, 1),
|
|
587
|
+
intensity: 1,
|
|
569
588
|
}
|
|
570
589
|
|
|
571
590
|
/** Camera depth of field — a bokeh gather in the composite pass. Costs nothing
|
|
@@ -604,31 +623,33 @@ export const DEFAULT_DEPTH_OF_FIELD_OPTIONS: DepthOfFieldOptions = {
|
|
|
604
623
|
quality: "balanced",
|
|
605
624
|
}
|
|
606
625
|
|
|
607
|
-
/**
|
|
626
|
+
/** How the scene-linear frame becomes a display image: exposure, then a tone
|
|
627
|
+
* transform, then display gamma. */
|
|
608
628
|
export type ViewTransformOptions = {
|
|
609
|
-
/** Stops applied before
|
|
629
|
+
/** Stops applied before the transform: `linear *= 2^exposure`. */
|
|
610
630
|
exposure: number
|
|
611
|
-
/** After
|
|
631
|
+
/** After the transform, display gamma (`pow(rgb, 1/gamma)`). */
|
|
612
632
|
gamma: number
|
|
613
633
|
/**
|
|
614
|
-
* Which display transform the frame is formed with.
|
|
634
|
+
* Which display transform the frame is formed with (composite.ts, viewTransform).
|
|
615
635
|
*
|
|
616
|
-
* "
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
636
|
+
* "soft" — Aether Gazer's own final curve, (1 - e^(-2.5x))^1.4. The default.
|
|
637
|
+
* "neutral" — Unity URP's Neutral, the Khronos PBR Neutral operator: colour
|
|
638
|
+
* passes through until the top fifth, which rolls off.
|
|
639
|
+
* "aces" — Unity URP's ACES (RRT + ODT approximation from the Core RP).
|
|
640
|
+
* "none" — the sRGB encoding and nothing else.
|
|
620
641
|
*
|
|
621
|
-
*
|
|
642
|
+
* All four end in the sRGB encoding.
|
|
622
643
|
*/
|
|
623
|
-
transform:
|
|
644
|
+
transform: ViewTransformName
|
|
624
645
|
}
|
|
625
646
|
|
|
626
|
-
|
|
627
|
-
|
|
647
|
+
export type ViewTransformName = "soft" | "neutral" | "aces" | "none"
|
|
648
|
+
|
|
628
649
|
export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
|
|
629
|
-
exposure: 0
|
|
650
|
+
exposure: 0,
|
|
630
651
|
gamma: 1.0,
|
|
631
|
-
transform: "
|
|
652
|
+
transform: "soft",
|
|
632
653
|
}
|
|
633
654
|
|
|
634
655
|
/** Color grading applied to the tonemapped scene (ASC CDL — see grade() in
|
|
@@ -723,9 +744,9 @@ export type EngineOptions = {
|
|
|
723
744
|
/** Canvas background (display-space sRGB 0–1), composited under the scene after
|
|
724
745
|
* tonemapping. Omit/null = transparent canvas (see setBackgroundColor). */
|
|
725
746
|
background?: Vec3 | null
|
|
726
|
-
/** Initial
|
|
747
|
+
/** Initial bloom; tune at runtime with `setBloomOptions`. */
|
|
727
748
|
bloom?: Partial<BloomOptions>
|
|
728
|
-
/** View transform (exposure
|
|
749
|
+
/** View transform (exposure, tone transform, gamma) applied in the composite. */
|
|
729
750
|
view?: Partial<ViewTransformOptions>
|
|
730
751
|
onRaycast?: RaycastCallback
|
|
731
752
|
/** See {@link GizmoDragCallback}. */
|
|
@@ -918,16 +939,19 @@ interface ModelInstance {
|
|
|
918
939
|
indexBuffer: GPUBuffer
|
|
919
940
|
jointsBuffer: GPUBuffer
|
|
920
941
|
weightsBuffer: GPUBuffer
|
|
942
|
+
/** The outline hull's own stream — smoothed normal + PMX edge scale per
|
|
943
|
+
* vertex (outline-normals.ts). Made with the first edge-flagged material,
|
|
944
|
+
* so a model with no outline never pays for it. */
|
|
945
|
+
outlineVertexBuffer?: GPUBuffer
|
|
921
946
|
skinMatrixBuffer: GPUBuffer
|
|
922
947
|
drawCalls: DrawCall[]
|
|
923
948
|
shadowDrawCalls: DrawCall[]
|
|
924
949
|
shadowBindGroups: GPUBindGroup[]
|
|
925
950
|
mainPerInstanceBindGroup: GPUBindGroup
|
|
926
|
-
/** Its
|
|
927
|
-
*
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
modelLight: Float32Array | null
|
|
951
|
+
/** Its ObjectLight — rendering layers, its own ambient, its fill — and the CPU copy it
|
|
952
|
+
* is written from. Every model has one: its layers decide its lights. */
|
|
953
|
+
lightBuffer: GPUBuffer
|
|
954
|
+
objectLight: Float32Array<ArrayBuffer>
|
|
931
955
|
pickPerInstanceBindGroup: GPUBindGroup
|
|
932
956
|
pickDrawCalls: PickDrawCall[]
|
|
933
957
|
/** Environment geometry added via addStage — no physics, no IK, and it
|
|
@@ -1827,8 +1851,33 @@ export type SceneLight = {
|
|
|
1827
1851
|
aim?: XYZ
|
|
1828
1852
|
angle?: number
|
|
1829
1853
|
innerAngle?: number
|
|
1854
|
+
/**
|
|
1855
|
+
* "directional": light along `aim` from infinitely far, as the sun is —
|
|
1856
|
+
* position and radius unused; up to three, beside the sun. Otherwise a spot
|
|
1857
|
+
* where an aim is given and a point where none is.
|
|
1858
|
+
*/
|
|
1859
|
+
kind?: "point" | "spot" | "directional"
|
|
1860
|
+
/**
|
|
1861
|
+
* The rendering layers it reaches, as bits (RENDERING_LAYER_*). Omitted
|
|
1862
|
+
* reaches every layer. A stage's daylight on RENDERING_LAYER_DEFAULT and the
|
|
1863
|
+
* cast's key on RENDERING_LAYER_CHARACTER is how a game lights the two apart.
|
|
1864
|
+
*/
|
|
1865
|
+
layers?: number
|
|
1830
1866
|
}
|
|
1831
1867
|
|
|
1868
|
+
/**
|
|
1869
|
+
* Rendering layers, as the game's pipeline (URP's rendering layers) uses them:
|
|
1870
|
+
* every drawing carries layer bits and a light reaches it only where its mask
|
|
1871
|
+
* shares one. A stage, prop or plane draws on DEFAULT; the cast on DEFAULT and
|
|
1872
|
+
* CHARACTER, the bits Aether Gazer's characters carry (0x40000001).
|
|
1873
|
+
*/
|
|
1874
|
+
export const RENDERING_LAYER_DEFAULT = 1
|
|
1875
|
+
export const RENDERING_LAYER_CHARACTER = 1 << 30
|
|
1876
|
+
/** Every layer — what a light without a mask reaches. */
|
|
1877
|
+
const ALL_LAYERS = 0xffffffff
|
|
1878
|
+
/** Where the directional slots' layer bits sit in the light uniform, in words. */
|
|
1879
|
+
const DIR_LAYERS_AT = 108
|
|
1880
|
+
|
|
1832
1881
|
export class Engine {
|
|
1833
1882
|
private static instance: Engine | null = null
|
|
1834
1883
|
|
|
@@ -1857,7 +1906,11 @@ export class Engine {
|
|
|
1857
1906
|
// padded to 80. sh[0].w is the IBL flag: 0 = flat world colour, 1 = the sky.
|
|
1858
1907
|
// …then the scene fog at [72..87] — see setSceneFog — and the cast's
|
|
1859
1908
|
// shadow on a stage at [88..107] (colour, amount; its view-projection).
|
|
1909
|
+
// …and the directional slots' rendering layers at [108..111], as u32 bits
|
|
1910
|
+
// through lightDataWords — never through a float.
|
|
1860
1911
|
private lightData = new Float32Array(112)
|
|
1912
|
+
private lightDataWords = new Uint32Array(this.lightData.buffer)
|
|
1913
|
+
private sunLayers = ALL_LAYERS
|
|
1861
1914
|
private castShadow: StageCastShadow | null = null
|
|
1862
1915
|
private castShadowTexture!: GPUTexture
|
|
1863
1916
|
private castShadowView!: GPUTextureView
|
|
@@ -2036,7 +2089,6 @@ export class Engine {
|
|
|
2036
2089
|
private mainPerFrameBindGroupLayout!: GPUBindGroupLayout
|
|
2037
2090
|
private mainPerInstanceBindGroupLayout!: GPUBindGroupLayout
|
|
2038
2091
|
/** What a model with no fill of its own binds: zero light. */
|
|
2039
|
-
private noFillBuffer!: GPUBuffer
|
|
2040
2092
|
private mainPerMaterialBindGroupLayout!: GPUBindGroupLayout
|
|
2041
2093
|
private outlinePerFrameBindGroupLayout!: GPUBindGroupLayout
|
|
2042
2094
|
private outlinePerMaterialBindGroupLayout!: GPUBindGroupLayout
|
|
@@ -2466,6 +2518,7 @@ export class Engine {
|
|
|
2466
2518
|
* premultiplied quantity and a blur has to move them together. */
|
|
2467
2519
|
private mirrorMaskMipViews: GPUTextureView[] = []
|
|
2468
2520
|
private mirrorMaskBlurBindGroups: GPUBindGroup[] | null = null
|
|
2521
|
+
private mirrorDownsamplePipeline: GPURenderPipeline | null = null
|
|
2469
2522
|
private mirrorMaskDownsamplePipeline: GPURenderPipeline | null = null
|
|
2470
2523
|
/** Two blocks the ground reads its clip from: the camera's is inert, the
|
|
2471
2524
|
* mirror's carries the live plane. Which one a draw sees is decided by which
|
|
@@ -2642,12 +2695,24 @@ export class Engine {
|
|
|
2642
2695
|
/** Scene seconds, advanced by the frame delta — NOT wall time, so an offline
|
|
2643
2696
|
* export samples the same path the editor showed. */
|
|
2644
2697
|
private sceneClock = 0
|
|
2698
|
+
/** Models dressed in the game's own materials (unity/looks.ts); built on the
|
|
2699
|
+
* first look installed. */
|
|
2700
|
+
private nativeLooks: NativeLooks | null = null
|
|
2701
|
+
/** The scene pass's host for the game's shaders, and the shadow atlas's. */
|
|
2702
|
+
private nativeHost: NativeHost | null = null
|
|
2703
|
+
private nativeShadowHost: NativeHost | null = null
|
|
2704
|
+
/** A game stage drawn by its own shaders (unity/stage.ts). */
|
|
2705
|
+
private nativeStage: NativeStage | null = null
|
|
2706
|
+
/** The frame's Unity globals, computed once for every native draw. */
|
|
2707
|
+
private nativeGlobals: Record<string, NativeValue> = {}
|
|
2708
|
+
private nativeFallback: Record<string, GPUTextureView> = {}
|
|
2709
|
+
/** A 1x1 depth map at the far plane: a game shader's shadow lookup into a
|
|
2710
|
+
* map the scene does not have reads lit. */
|
|
2711
|
+
private nativeNoShadowView: GPUTextureView | null = null
|
|
2645
2712
|
private trailAccum = 0
|
|
2646
2713
|
/** Trail samples owed this frame, computed once so every trail on every
|
|
2647
2714
|
* character samples in lockstep and their paths stay comparable. */
|
|
2648
2715
|
private trailDue = 0
|
|
2649
|
-
private agxLutTexture: GPUTexture | null = null
|
|
2650
|
-
private agxFallbackTexture!: GPUTexture
|
|
2651
2716
|
/** The scene's own grade (setStageGrade): the cube as handed over, kept so a
|
|
2652
2717
|
* call before init() still lands, and its texture once uploaded. */
|
|
2653
2718
|
private stageGradeCube: StageGradeLut | null = null
|
|
@@ -2661,12 +2726,8 @@ export class Engine {
|
|
|
2661
2726
|
* `time` is measured from here — see where it is written. */
|
|
2662
2727
|
private compositeBloomView: GPUTextureView | null = null
|
|
2663
2728
|
|
|
2664
|
-
//
|
|
2665
|
-
//
|
|
2666
|
-
// N-1 downsamples (13-tap Jimenez/COD box filter, 5 group averages)
|
|
2667
|
-
// N-1 upsamples (9-tap tent, additively combined with corresponding downsample mip)
|
|
2668
|
-
// composite adds bloomUp mip 0 × (color × intensity) to HDR before Filmic.
|
|
2669
|
-
// Matches EEVEE energy: tint/intensity applied at composite, not prefilter.
|
|
2729
|
+
// Linear clamp sampler for the bloom chain (also the mirror blur and the
|
|
2730
|
+
// composite's bloom read).
|
|
2670
2731
|
private bloomSampler!: GPUSampler
|
|
2671
2732
|
// Screen-space subsurface scattering (passes/subsurface.ts). The scratch target
|
|
2672
2733
|
// is the X pass's output and lives with the canvas size; the bind groups name
|
|
@@ -2684,33 +2745,56 @@ export class Engine {
|
|
|
2684
2745
|
private readonly pipelineDescs = new WeakMap<GPURenderPipeline, GPURenderPipelineDescriptor>()
|
|
2685
2746
|
private readonly singleSided = new WeakMap<GPURenderPipeline, GPURenderPipeline>()
|
|
2686
2747
|
private readonly singleSidedMirror = new WeakMap<GPURenderPipeline, GPURenderPipeline>()
|
|
2748
|
+
/** Twins whose async build is in flight, so a draw asks for each once. */
|
|
2749
|
+
private readonly sidedPending = new WeakSet<GPURenderPipeline>()
|
|
2750
|
+
private readonly sidedPendingMirror = new WeakSet<GPURenderPipeline>()
|
|
2751
|
+
// The engine-wide shader cache (see cachedRenderPipeline). Modules by WGSL
|
|
2752
|
+
// source, pipelines by module + descriptor state: one compile per source,
|
|
2753
|
+
// whoever asks — a graph on two models, an effect reinstalled, the composite.
|
|
2754
|
+
private readonly shaderModuleCache = new Map<string, GPUShaderModule>()
|
|
2755
|
+
private readonly pipelineCache = new Map<string, Promise<GPURenderPipeline | GPUComputePipeline>>()
|
|
2756
|
+
private readonly bindGroupLayoutCache = new Map<string, GPUBindGroupLayout>()
|
|
2757
|
+
private readonly pipelineLayoutCache = new Map<string, GPUPipelineLayout>()
|
|
2758
|
+
private readonly gpuObjectIds = new WeakMap<object, number>()
|
|
2759
|
+
private gpuObjectNext = 1
|
|
2760
|
+
/** Init's pipelines, built async and in parallel; init awaits them. Null
|
|
2761
|
+
* outside init, where a pipeline job builds the way it always did. */
|
|
2762
|
+
private initPipelineJobs: Promise<unknown>[] | null = null
|
|
2687
2763
|
// Stepped motion (#stepped). The models whose pose is being HELD this frame —
|
|
2688
2764
|
// their skinning and morph uploads wait for the stepped clock's next tick —
|
|
2689
2765
|
// and the tick each was last shown at. Empty while no effect declares it.
|
|
2690
2766
|
private readonly steppedHeld = new Set<string>()
|
|
2691
2767
|
private readonly steppedTick = new Map<string, number>()
|
|
2692
2768
|
private steppedLastClock = -1
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
private
|
|
2699
|
-
private
|
|
2700
|
-
private bloomBlitBindGroupLayout!: GPUBindGroupLayout
|
|
2701
|
-
private bloomDownsampleBindGroupLayout!: GPUBindGroupLayout
|
|
2702
|
-
private bloomUpsampleBindGroupLayout!: GPUBindGroupLayout
|
|
2703
|
-
private bloomDownTexture!: GPUTexture
|
|
2704
|
-
private bloomUpTexture!: GPUTexture
|
|
2705
|
-
private bloomMipCount = 0
|
|
2706
|
-
private bloomDownMipViews: GPUTextureView[] = []
|
|
2707
|
-
private bloomUpMipViews: GPUTextureView[] = []
|
|
2708
|
-
private bloomBlitBindGroup!: GPUBindGroup
|
|
2709
|
-
private bloomDownsampleBindGroups: GPUBindGroup[] = []
|
|
2710
|
-
private bloomUpsampleBindGroups: GPUBindGroup[] = []
|
|
2769
|
+
|
|
2770
|
+
// Bloom (shaders/passes/bloom.ts), Aether Gazer's chain. One level per halving
|
|
2771
|
+
// down to a few pixels (the game's count, up to 16), each a down/up pair;
|
|
2772
|
+
// up[i] holds the horizontal blur on the way down and the scatter blend on the
|
|
2773
|
+
// way up. The composite adds up[0] × (color × intensity) before the view transform.
|
|
2774
|
+
private bloomPrefilterBindGroupLayout!: GPUBindGroupLayout
|
|
2775
|
+
private bloomBlurBindGroupLayout!: GPUBindGroupLayout
|
|
2711
2776
|
/** Single-attachment pass; colorAttachments[0].view set per bloom step. */
|
|
2712
2777
|
private bloomPassDescriptor!: GPURenderPassDescriptor
|
|
2713
|
-
private
|
|
2778
|
+
private bloomPrefilterPipeline: GPURenderPipeline | null = null
|
|
2779
|
+
private bloomBlurHPipeline: GPURenderPipeline | null = null
|
|
2780
|
+
private bloomBlurVPipeline: GPURenderPipeline | null = null
|
|
2781
|
+
private bloomUpsamplePipeline: GPURenderPipeline | null = null
|
|
2782
|
+
private bloomUpsampleBindGroupLayout: GPUBindGroupLayout | null = null
|
|
2783
|
+
private bloomUniformBuffer: GPUBuffer | null = null
|
|
2784
|
+
private readonly bloomUniformData = new Float32Array(4)
|
|
2785
|
+
private bloomDownTexture: GPUTexture | null = null
|
|
2786
|
+
private bloomUpTexture: GPUTexture | null = null
|
|
2787
|
+
private bloomLevels = 0
|
|
2788
|
+
private bloomDownViews: GPUTextureView[] = []
|
|
2789
|
+
private bloomUpViews: GPUTextureView[] = []
|
|
2790
|
+
private bloomPrefilterBindGroup: GPUBindGroup | null = null
|
|
2791
|
+
private bloomBlurHBindGroups: GPUBindGroup[] = []
|
|
2792
|
+
private bloomBlurVBindGroups: GPUBindGroup[] = []
|
|
2793
|
+
private bloomUpsampleBindGroups: GPUBindGroup[] = []
|
|
2794
|
+
/** The game's prefilter clamps at 6550.4 — a tenth of the half-float ceiling
|
|
2795
|
+
* (its BloomPass _Params.y of 100 never reaches the shader). */
|
|
2796
|
+
private static readonly BLOOM_CLAMP = 6550.4
|
|
2797
|
+
private static readonly BLOOM_MAX_LEVELS = 16
|
|
2714
2798
|
|
|
2715
2799
|
// Ground properties (shadow only)
|
|
2716
2800
|
private groundVertexBuffer?: GPUBuffer
|
|
@@ -2719,14 +2803,11 @@ export class Engine {
|
|
|
2719
2803
|
/** The user's own "no ground" switch — see setGroundVisible. Distinct from
|
|
2720
2804
|
* hasGround, which records whether a ground was ever built. */
|
|
2721
2805
|
private groundHidden = false
|
|
2722
|
-
|
|
2723
|
-
private
|
|
2806
|
+
/** The sun's shadow atlas: every cascade in its own tile (shadow-cascades.ts). */
|
|
2807
|
+
private shadowAtlasTexture!: GPUTexture
|
|
2808
|
+
private shadowAtlasView!: GPUTextureView
|
|
2724
2809
|
private brdfLutTexture!: GPUTexture
|
|
2725
2810
|
private brdfLutView!: GPUTextureView
|
|
2726
|
-
private filmicLutTexture!: GPUTexture
|
|
2727
|
-
private filmicLutView!: GPUTextureView
|
|
2728
|
-
// Width of the baked Filmic tone LUT (composite.ts FILMIC_LUT_W must match).
|
|
2729
|
-
private static readonly FILMIC_LUT_WIDTH = 256
|
|
2730
2811
|
private shadowDepthPipeline!: GPURenderPipeline
|
|
2731
2812
|
private shadowLightVPBuffer!: GPUBuffer
|
|
2732
2813
|
// The shadow PASS reads one cascade's matrix per pass, and a uniform binding
|
|
@@ -2868,18 +2949,16 @@ export class Engine {
|
|
|
2868
2949
|
this.backgroundColor = bg ? new Vec3(bg.x, bg.y, bg.z) : null
|
|
2869
2950
|
}
|
|
2870
2951
|
|
|
2871
|
-
/** Merge partial bloom with
|
|
2952
|
+
/** Merge partial bloom with the defaults (same as constructor). */
|
|
2872
2953
|
static mergeBloomDefaults(partial?: Partial<BloomOptions>): BloomOptions {
|
|
2873
2954
|
const d = DEFAULT_BLOOM_OPTIONS
|
|
2874
2955
|
const c = partial?.color
|
|
2875
2956
|
return {
|
|
2876
2957
|
enabled: partial?.enabled ?? d.enabled,
|
|
2877
2958
|
threshold: partial?.threshold ?? d.threshold,
|
|
2878
|
-
|
|
2879
|
-
radius: partial?.radius ?? d.radius,
|
|
2959
|
+
scatter: partial?.scatter ?? d.scatter,
|
|
2880
2960
|
color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
|
|
2881
2961
|
intensity: partial?.intensity ?? d.intensity,
|
|
2882
|
-
clamp: partial?.clamp ?? d.clamp,
|
|
2883
2962
|
}
|
|
2884
2963
|
}
|
|
2885
2964
|
|
|
@@ -2892,17 +2971,15 @@ export class Engine {
|
|
|
2892
2971
|
}
|
|
2893
2972
|
}
|
|
2894
2973
|
|
|
2895
|
-
/** Current bloom settings (
|
|
2974
|
+
/** Current bloom settings (tint is a copied `Vec3`). */
|
|
2896
2975
|
getBloomOptions(): BloomOptions {
|
|
2897
2976
|
const b = this.bloomSettings
|
|
2898
2977
|
return {
|
|
2899
2978
|
enabled: b.enabled,
|
|
2900
2979
|
threshold: b.threshold,
|
|
2901
|
-
|
|
2902
|
-
radius: b.radius,
|
|
2980
|
+
scatter: b.scatter,
|
|
2903
2981
|
color: new Vec3(b.color.x, b.color.y, b.color.z),
|
|
2904
2982
|
intensity: b.intensity,
|
|
2905
|
-
clamp: b.clamp,
|
|
2906
2983
|
}
|
|
2907
2984
|
}
|
|
2908
2985
|
|
|
@@ -3056,6 +3133,14 @@ export class Engine {
|
|
|
3056
3133
|
return b.enabled && b.intensity > 0
|
|
3057
3134
|
}
|
|
3058
3135
|
|
|
3136
|
+
/** viewU[6].y per transform — see viewTransform in composite.ts. */
|
|
3137
|
+
private static readonly VIEW_TRANSFORM_ID: Record<string, number> = {
|
|
3138
|
+
soft: 0,
|
|
3139
|
+
none: 1,
|
|
3140
|
+
neutral: 2,
|
|
3141
|
+
aces: 3,
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3059
3144
|
private writeCompositeViewUniforms(): void {
|
|
3060
3145
|
const v = this.viewTransform
|
|
3061
3146
|
const b = this.bloomSettings
|
|
@@ -3098,7 +3183,7 @@ export class Engine {
|
|
|
3098
3183
|
// rebuilt per effect, so the compiled variant IS the flag.
|
|
3099
3184
|
u[11] = this.backdropEquirectView ? 2 : showingWorld ? 3 : bg ? 1 : 0
|
|
3100
3185
|
// Which display transform forms the frame (see viewTransform in composite.ts).
|
|
3101
|
-
u[25] =
|
|
3186
|
+
u[25] = Engine.VIEW_TRANSFORM_ID[v.transform] ?? 0
|
|
3102
3187
|
u[26] = this.canvas.width
|
|
3103
3188
|
u[27] = this.canvas.height
|
|
3104
3189
|
// ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
|
|
@@ -3828,7 +3913,7 @@ export class Engine {
|
|
|
3828
3913
|
}
|
|
3829
3914
|
|
|
3830
3915
|
/**
|
|
3831
|
-
* Fill the mirror's mip levels —
|
|
3916
|
+
* Fill the mirror's mip levels — a 13-tap downsample (MIRROR_DOWNSAMPLE_WGSL),
|
|
3832
3917
|
* one pass per level. Only when the blur dial is up: at zero the ground
|
|
3833
3918
|
* samples level 0 exactly and the chain would be work nobody reads.
|
|
3834
3919
|
*/
|
|
@@ -3838,8 +3923,19 @@ export class Engine {
|
|
|
3838
3923
|
// memory, which read as the model going black the moment the slider moved.
|
|
3839
3924
|
const blur = Math.max(this.groundMirrorBlur, this.mirrorSurface?.blur ?? 0)
|
|
3840
3925
|
if (blur <= 0 || this.mirrorMipCount < 2) return
|
|
3926
|
+
if (!this.mirrorDownsamplePipeline) {
|
|
3927
|
+
const module = this.device.createShaderModule({ label: "mirror downsample", code: MIRROR_DOWNSAMPLE_WGSL })
|
|
3928
|
+
this.mirrorDownsamplePipeline = this.device.createRenderPipeline({
|
|
3929
|
+
label: "mirror downsample pipeline",
|
|
3930
|
+
layout: "auto",
|
|
3931
|
+
vertex: { module, entryPoint: "vs" },
|
|
3932
|
+
fragment: { module, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
3933
|
+
primitive: { topology: "triangle-list" },
|
|
3934
|
+
})
|
|
3935
|
+
}
|
|
3936
|
+
const downsample = this.mirrorDownsamplePipeline
|
|
3841
3937
|
if (!this.mirrorBlurBindGroups) {
|
|
3842
|
-
const layout =
|
|
3938
|
+
const layout = downsample.getBindGroupLayout(0)
|
|
3843
3939
|
this.mirrorBlurBindGroups = []
|
|
3844
3940
|
for (let i = 1; i < this.mirrorMipCount; i++) {
|
|
3845
3941
|
this.mirrorBlurBindGroups.push(
|
|
@@ -3859,7 +3955,7 @@ export class Engine {
|
|
|
3859
3955
|
label: `mirror blur ${i}`,
|
|
3860
3956
|
colorAttachments: [{ view: this.mirrorMipViews[i], loadOp: "clear", storeOp: "store" }],
|
|
3861
3957
|
})
|
|
3862
|
-
p.setPipeline(
|
|
3958
|
+
p.setPipeline(downsample)
|
|
3863
3959
|
p.setBindGroup(0, this.mirrorBlurBindGroups[i - 1])
|
|
3864
3960
|
p.draw(3)
|
|
3865
3961
|
p.end()
|
|
@@ -4027,7 +4123,6 @@ export class Engine {
|
|
|
4027
4123
|
{ binding: 2, resource: this.bloomSampler },
|
|
4028
4124
|
{ binding: 3, resource: { buffer: this.compositeUniformBuffer } },
|
|
4029
4125
|
{ binding: 4, resource: this.maskResolveView },
|
|
4030
|
-
{ binding: 5, resource: this.filmicLutView },
|
|
4031
4126
|
// Whichever equirect is SHOWING — the backdrop if there is one, the
|
|
4032
4127
|
// world otherwise. The world's light does not come through here; it
|
|
4033
4128
|
// rides worldSH into the material shells.
|
|
@@ -4035,7 +4130,6 @@ export class Engine {
|
|
|
4035
4130
|
{ binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
|
|
4036
4131
|
{ binding: 8, resource: this.depthReadView },
|
|
4037
4132
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
4038
|
-
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
4039
4133
|
{ binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
|
|
4040
4134
|
{ binding: 11, resource: { buffer: this.castBuffer } },
|
|
4041
4135
|
{ binding: 13, resource: { buffer: this.audioBuffer } },
|
|
@@ -4473,8 +4567,6 @@ export class Engine {
|
|
|
4473
4567
|
{ binding: 31, resource: layers[2] },
|
|
4474
4568
|
{ binding: 32, resource: layers[3] },
|
|
4475
4569
|
{ binding: 2, resource: this.bloomSampler },
|
|
4476
|
-
{ binding: 5, resource: this.filmicLutView },
|
|
4477
|
-
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
4478
4570
|
{ binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
|
|
4479
4571
|
{ binding: 1, resource: bloom },
|
|
4480
4572
|
{ binding: 4, resource: this.maskResolveView },
|
|
@@ -4736,21 +4828,38 @@ export class Engine {
|
|
|
4736
4828
|
if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
|
|
4737
4829
|
}
|
|
4738
4830
|
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4831
|
+
/**
|
|
4832
|
+
* The scene composite's pair (gamma = 1, gamma ≠ 1), async and from the
|
|
4833
|
+
* shader cache. Its source depends only on which field mounts the scene
|
|
4834
|
+
* samples, so there are four at most, and an effect install that keeps the
|
|
4835
|
+
* mounts it had gets the pair already built.
|
|
4836
|
+
*/
|
|
4837
|
+
private compositePipelines(hasBackground: boolean, hasForeground: boolean): Promise<[GPURenderPipeline, GPURenderPipeline]> {
|
|
4838
|
+
const code = buildCompositeShader(
|
|
4839
|
+
hasBackground || hasForeground
|
|
4840
|
+
? // No wgsl and so no trails: the composite hosts no effect source at
|
|
4841
|
+
// all, it only decides whether to sample the layer the field pass drew.
|
|
4842
|
+
{ wgsl: "", paramsDecl: "", hasBackground, hasForeground, gridSize: 0, trailCount: 0 }
|
|
4843
|
+
: null,
|
|
4844
|
+
)
|
|
4845
|
+
const module = this.cachedShaderModule(code, "composite shader")
|
|
4846
|
+
const make = (applyGamma: boolean, label: string) =>
|
|
4847
|
+
this.cachedRenderPipeline({
|
|
4848
|
+
label,
|
|
4849
|
+
layout: this.compositePipelineLayout,
|
|
4850
|
+
vertex: { module, entryPoint: "vs" },
|
|
4851
|
+
fragment: {
|
|
4852
|
+
module,
|
|
4853
|
+
entryPoint: "fs",
|
|
4854
|
+
constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
|
|
4855
|
+
targets: [{ format: this.presentationFormat }],
|
|
4856
|
+
},
|
|
4857
|
+
primitive: { topology: "triangle-list" },
|
|
4858
|
+
})
|
|
4859
|
+
return Promise.all([make(false, "composite pipeline (gamma=1)"), make(true, "composite pipeline (gamma!=1)")])
|
|
4752
4860
|
}
|
|
4753
4861
|
|
|
4862
|
+
|
|
4754
4863
|
/**
|
|
4755
4864
|
* Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
|
|
4756
4865
|
* composite pass. ONE effect per scene, and the code says where it mounts by
|
|
@@ -4988,11 +5097,11 @@ export class Engine {
|
|
|
4988
5097
|
hasBackground || hasForeground
|
|
4989
5098
|
? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length, filter, additiveLayer: d.additiveLayer }
|
|
4990
5099
|
: null
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
5100
|
+
// No composite is built here. One used to be — a module and two pipelines
|
|
5101
|
+
// per effect, then dropped: the composite is static (no user code reaches
|
|
5102
|
+
// it) and setEffects builds the scene's one after the list is in. Those
|
|
5103
|
+
// two compiles were most of a second each and validated nothing an author
|
|
5104
|
+
// wrote.
|
|
4996
5105
|
|
|
4997
5106
|
// Declared like every other mount property: by what the source says, not by
|
|
4998
5107
|
// a setting somewhere else that an author cannot see from the file.
|
|
@@ -5004,16 +5113,11 @@ export class Engine {
|
|
|
5004
5113
|
const fieldSource = buildFieldShader({ ...fieldEffect, ids: mrtIdsEnabled() })
|
|
5005
5114
|
const userLineOffset = fieldSource.slice(0, fieldSource.indexOf(wgsl)).split("\n").length - 1
|
|
5006
5115
|
this.device.pushErrorScope("validation")
|
|
5007
|
-
const fieldModule = this.
|
|
5008
|
-
const
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
.map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
|
|
5013
|
-
if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
|
|
5014
|
-
if (diagnostics.length > 0) return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
5015
|
-
try {
|
|
5016
|
-
fieldPipeline = await this.device.createRenderPipelineAsync({
|
|
5116
|
+
const fieldModule = this.cachedShaderModule(fieldSource, "field shader (effect)")
|
|
5117
|
+
const fieldScope = this.device.popErrorScope()
|
|
5118
|
+
// Started before the diagnostics are read, which only decide whether it
|
|
5119
|
+
// is kept: the compile and the check overlap.
|
|
5120
|
+
const fieldBuild = this.cachedRenderPipeline({
|
|
5017
5121
|
label: "field layer pipeline",
|
|
5018
5122
|
layout: this.fieldPipelineLayout,
|
|
5019
5123
|
vertex: { module: fieldModule, entryPoint: "fieldVs" },
|
|
@@ -5041,33 +5145,22 @@ export class Engine {
|
|
|
5041
5145
|
primitive: { topology: "triangle-list" },
|
|
5042
5146
|
multisample: { count: 1 },
|
|
5043
5147
|
})
|
|
5148
|
+
fieldBuild.catch(() => {})
|
|
5149
|
+
const [info, fieldScopeErr] = await Promise.all([fieldModule.getCompilationInfo(), fieldScope])
|
|
5150
|
+
const diagnostics = info.messages
|
|
5151
|
+
.filter((m) => m.type === "error")
|
|
5152
|
+
.map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
|
|
5153
|
+
if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
|
|
5154
|
+
if (diagnostics.length > 0) {
|
|
5155
|
+
this.forgetShaderModule(fieldSource)
|
|
5156
|
+
return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
5157
|
+
}
|
|
5158
|
+
try {
|
|
5159
|
+
fieldPipeline = await fieldBuild
|
|
5044
5160
|
} catch (e) {
|
|
5045
5161
|
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
5046
5162
|
}
|
|
5047
5163
|
}
|
|
5048
|
-
let identity: GPURenderPipeline
|
|
5049
|
-
let gamma: GPURenderPipeline
|
|
5050
|
-
try {
|
|
5051
|
-
const make = (applyGamma: boolean, label: string) =>
|
|
5052
|
-
this.device.createRenderPipelineAsync({
|
|
5053
|
-
label,
|
|
5054
|
-
layout: this.compositePipelineLayout,
|
|
5055
|
-
vertex: { module, entryPoint: "vs" },
|
|
5056
|
-
fragment: {
|
|
5057
|
-
module,
|
|
5058
|
-
entryPoint: "fs",
|
|
5059
|
-
constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
|
|
5060
|
-
targets: [{ format: this.presentationFormat }],
|
|
5061
|
-
},
|
|
5062
|
-
primitive: { topology: "triangle-list" },
|
|
5063
|
-
})
|
|
5064
|
-
;[identity, gamma] = await Promise.all([
|
|
5065
|
-
make(false, "composite pipeline (effect, gamma=1)"),
|
|
5066
|
-
make(true, "composite pipeline (effect, gamma!=1)"),
|
|
5067
|
-
])
|
|
5068
|
-
} catch (e) {
|
|
5069
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
5070
|
-
}
|
|
5071
5164
|
|
|
5072
5165
|
// Built BEFORE the swap: a particle stage that fails to compile has to leave
|
|
5073
5166
|
// the previously installed effect running, exactly as a bad composite does.
|
|
@@ -5311,6 +5404,9 @@ export class Engine {
|
|
|
5311
5404
|
|
|
5312
5405
|
const requested = list ?? []
|
|
5313
5406
|
if (requested.length === 0) {
|
|
5407
|
+
// Built (or found) before anything is torn down, so no frame draws
|
|
5408
|
+
// between the old effects and the composite that no longer samples them.
|
|
5409
|
+
const [identity, gamma] = await this.compositePipelines(false, false)
|
|
5314
5410
|
for (const e of this.effects) {
|
|
5315
5411
|
e.paramsBuffer?.destroy()
|
|
5316
5412
|
e.lights?.uniform.destroy()
|
|
@@ -5323,9 +5419,8 @@ export class Engine {
|
|
|
5323
5419
|
this.allocateLightSlots()
|
|
5324
5420
|
this.anchorTable = EMPTY_ANCHOR_TABLE
|
|
5325
5421
|
this.clearTrailHistory()
|
|
5326
|
-
|
|
5327
|
-
this.
|
|
5328
|
-
this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)")
|
|
5422
|
+
this.compositePipelineIdentity = identity
|
|
5423
|
+
this.compositePipelineGamma = gamma
|
|
5329
5424
|
this.rebuildCompositeBindGroup()
|
|
5330
5425
|
this.writeCompositeViewUniforms()
|
|
5331
5426
|
return []
|
|
@@ -5381,6 +5476,14 @@ export class Engine {
|
|
|
5381
5476
|
if (r) r.diagnostics.push(`anchor "${d.bone}" dropped: the scene is already using all ${MAX_EFFECT_ANCHORS} slots`)
|
|
5382
5477
|
}
|
|
5383
5478
|
|
|
5479
|
+
// The composite for the new list, built before the swap below so the swap
|
|
5480
|
+
// stays one synchronous step. From the shader cache: it was rebuilt, and
|
|
5481
|
+
// synchronously, on every install.
|
|
5482
|
+
const [compositeIdentity, compositeGamma] = await this.compositePipelines(
|
|
5483
|
+
instances.some((e) => e.hasBackground),
|
|
5484
|
+
instances.some((e) => e.hasForeground),
|
|
5485
|
+
)
|
|
5486
|
+
|
|
5384
5487
|
// ── Swap. Everything above either succeeded or was excluded, so the scene
|
|
5385
5488
|
// that was running is only torn down now.
|
|
5386
5489
|
for (const e of this.effects) {
|
|
@@ -5432,20 +5535,8 @@ export class Engine {
|
|
|
5432
5535
|
// Scene-level, from the union: the composite decides only whether to SAMPLE
|
|
5433
5536
|
// the field layers, so one effect with a background is enough to turn that
|
|
5434
5537
|
// on for the frame.
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
const compositeModule = this.device.createShaderModule({
|
|
5438
|
-
label: "composite shader (effects)",
|
|
5439
|
-
code: buildCompositeShader(
|
|
5440
|
-
hasBackground || hasForeground
|
|
5441
|
-
? // No wgsl and so no trails: the composite hosts no effect source at
|
|
5442
|
-
// all, it only decides whether to sample the layer the field pass drew.
|
|
5443
|
-
{ wgsl: "", paramsDecl: "", hasBackground, hasForeground, gridSize: 0, trailCount: 0 }
|
|
5444
|
-
: null,
|
|
5445
|
-
),
|
|
5446
|
-
})
|
|
5447
|
-
this.compositePipelineIdentity = this.makeCompositePipeline(compositeModule, false, "composite pipeline (gamma=1)")
|
|
5448
|
-
this.compositePipelineGamma = this.makeCompositePipeline(compositeModule, true, "composite pipeline (gamma!=1)")
|
|
5538
|
+
this.compositePipelineIdentity = compositeIdentity
|
|
5539
|
+
this.compositePipelineGamma = compositeGamma
|
|
5449
5540
|
|
|
5450
5541
|
// Nothing to promote any more: `#fullres` is per effect, read into
|
|
5451
5542
|
// fieldLayer when the instance is built, and both target pairs exist for
|
|
@@ -5542,26 +5633,32 @@ export class Engine {
|
|
|
5542
5633
|
alias,
|
|
5543
5634
|
}
|
|
5544
5635
|
|
|
5636
|
+
// The scope is popped before the first await: compiles run side by side
|
|
5637
|
+
// (both modules here, other effects, style groups), and a scope held open
|
|
5638
|
+
// across an await would catch whichever of them errored first.
|
|
5545
5639
|
const compile = async (code: string, label: string): Promise<GPUShaderModule | string[]> => {
|
|
5546
5640
|
const offset = code.slice(0, code.indexOf(wgsl)).split("\n").length - 1
|
|
5547
5641
|
this.device.pushErrorScope("validation")
|
|
5548
|
-
const module = this.
|
|
5549
|
-
const
|
|
5550
|
-
const scopeErr = await
|
|
5642
|
+
const module = this.cachedShaderModule(code, label)
|
|
5643
|
+
const scope = this.device.popErrorScope()
|
|
5644
|
+
const [info, scopeErr] = await Promise.all([module.getCompilationInfo(), scope])
|
|
5551
5645
|
const diagnostics = info.messages
|
|
5552
5646
|
.filter((m) => m.type === "error")
|
|
5553
5647
|
.map((m) => `${Math.max(0, m.lineNum - offset)}:${m.linePos} ${m.message}`)
|
|
5554
5648
|
if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
|
|
5649
|
+
if (diagnostics.length) this.forgetShaderModule(code)
|
|
5555
5650
|
return diagnostics.length ? diagnostics : module
|
|
5556
5651
|
}
|
|
5557
5652
|
|
|
5558
|
-
const computeModule = await
|
|
5653
|
+
const [computeModule, renderModule] = await Promise.all([
|
|
5654
|
+
compile(buildParticleComputeShader(src, cast), "particle compute"),
|
|
5655
|
+
compile(buildParticleRenderShader(src, cast), "particle render"),
|
|
5656
|
+
])
|
|
5559
5657
|
if (Array.isArray(computeModule)) {
|
|
5560
5658
|
points?.buffer.destroy()
|
|
5561
5659
|
dropTextures()
|
|
5562
5660
|
return { ok: false, diagnostics: computeModule }
|
|
5563
5661
|
}
|
|
5564
|
-
const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render")
|
|
5565
5662
|
if (Array.isArray(renderModule)) {
|
|
5566
5663
|
points?.buffer.destroy()
|
|
5567
5664
|
dropTextures()
|
|
@@ -5602,7 +5699,7 @@ export class Engine {
|
|
|
5602
5699
|
// layout invalid, and the error surfaces later and unhelpfully as "invalid
|
|
5603
5700
|
// due to a previous error".
|
|
5604
5701
|
const layoutFor = (storage: GPUBufferBindingType, visibility: number, shadow: boolean, withIndirect: boolean) =>
|
|
5605
|
-
this.
|
|
5702
|
+
this.cachedBindGroupLayout({
|
|
5606
5703
|
entries: [
|
|
5607
5704
|
{ binding: 0, visibility, buffer: { type: storage } },
|
|
5608
5705
|
{ binding: 1, visibility, buffer: { type: "uniform" } },
|
|
@@ -5632,8 +5729,7 @@ export class Engine {
|
|
|
5632
5729
|
{ binding: PARTICLE_LIGHT_BINDING, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
|
|
5633
5730
|
{ binding: PARTICLE_LIGHT_BINDING + 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
|
|
5634
5731
|
{ binding: PARTICLE_LIGHT_BINDING + 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" as const } },
|
|
5635
|
-
{ binding: PARTICLE_LIGHT_BINDING + 3, visibility: GPUShaderStage.FRAGMENT,
|
|
5636
|
-
{ binding: PARTICLE_LIGHT_BINDING + 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" as const } },
|
|
5732
|
+
{ binding: PARTICLE_LIGHT_BINDING + 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" as const } },
|
|
5637
5733
|
]
|
|
5638
5734
|
: []),
|
|
5639
5735
|
// The live count's arguments, for the COUNT kernel's layout alone. The
|
|
@@ -5682,9 +5778,8 @@ export class Engine {
|
|
|
5682
5778
|
? [
|
|
5683
5779
|
{ binding: PARTICLE_LIGHT_BINDING, resource: { buffer: this.lightUniformBuffer } },
|
|
5684
5780
|
{ binding: PARTICLE_LIGHT_BINDING + 1, resource: { buffer: this.shadowLightVPBuffer } },
|
|
5685
|
-
{ binding: PARTICLE_LIGHT_BINDING + 2, resource: this.
|
|
5686
|
-
{ binding: PARTICLE_LIGHT_BINDING + 3, resource: this.
|
|
5687
|
-
{ binding: PARTICLE_LIGHT_BINDING + 4, resource: this.shadowComparisonSampler },
|
|
5781
|
+
{ binding: PARTICLE_LIGHT_BINDING + 2, resource: this.shadowAtlasView },
|
|
5782
|
+
{ binding: PARTICLE_LIGHT_BINDING + 3, resource: this.shadowComparisonSampler },
|
|
5688
5783
|
]
|
|
5689
5784
|
: []),
|
|
5690
5785
|
...(withIndirect && indirect ? [{ binding: PARTICLE_INDIRECT_BINDING, resource: { buffer: indirect } }] : []),
|
|
@@ -5716,32 +5811,29 @@ export class Engine {
|
|
|
5716
5811
|
const targets = sceneTargetsFor(src.blend === "additive" ? "particle-additive" : "particle", this.sceneFormats)
|
|
5717
5812
|
const cutout = src.blend === "cutout"
|
|
5718
5813
|
|
|
5814
|
+
// ALL STAGES AT ONCE: compute, count, prepass and shading compile side by
|
|
5815
|
+
// side rather than in turn. The scope closes before the first await, for
|
|
5816
|
+
// the reason compile() gives.
|
|
5719
5817
|
this.device.pushErrorScope("validation")
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5818
|
+
const renderLayoutObj = this.cachedPipelineLayout([renderLayout])
|
|
5819
|
+
const builds = Promise.all([
|
|
5820
|
+
this.cachedComputePipeline({
|
|
5723
5821
|
label: "particle compute pipeline",
|
|
5724
|
-
layout:
|
|
5822
|
+
layout: this.cachedPipelineLayout([computeLayout]),
|
|
5725
5823
|
compute: { module: computeModule, entryPoint: "main" },
|
|
5726
|
-
})
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
}),
|
|
5735
|
-
indirect,
|
|
5736
|
-
binds: bindFor(countLayout, this.cameraUniformBuffer, false, true),
|
|
5737
|
-
}
|
|
5738
|
-
: null
|
|
5739
|
-
const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] })
|
|
5824
|
+
}),
|
|
5825
|
+
indirect && countLayout
|
|
5826
|
+
? this.cachedComputePipeline({
|
|
5827
|
+
label: "particle count pipeline",
|
|
5828
|
+
layout: this.cachedPipelineLayout([countLayout]),
|
|
5829
|
+
compute: { module: computeModule, entryPoint: "rzCount" },
|
|
5830
|
+
})
|
|
5831
|
+
: null,
|
|
5740
5832
|
// The prepass, when the effect gave the cutout a cheap shape: coverage to
|
|
5741
5833
|
// depth, colour targets at writeMask 0 (the depth-prepass class), and
|
|
5742
5834
|
// the shading pass below then tests EQUAL and writes no depth.
|
|
5743
|
-
|
|
5744
|
-
?
|
|
5835
|
+
prepass
|
|
5836
|
+
? this.cachedRenderPipeline({
|
|
5745
5837
|
label: "particle depth prepass pipeline",
|
|
5746
5838
|
layout: renderLayoutObj,
|
|
5747
5839
|
vertex: { module: renderModule, entryPoint: "vs" },
|
|
@@ -5750,8 +5842,8 @@ export class Engine {
|
|
|
5750
5842
|
depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
|
|
5751
5843
|
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
5752
5844
|
})
|
|
5753
|
-
: null
|
|
5754
|
-
|
|
5845
|
+
: null,
|
|
5846
|
+
this.cachedRenderPipeline({
|
|
5755
5847
|
label: "particle render pipeline",
|
|
5756
5848
|
layout: renderLayoutObj,
|
|
5757
5849
|
vertex: { module: renderModule, entryPoint: "vs" },
|
|
@@ -5772,8 +5864,20 @@ export class Engine {
|
|
|
5772
5864
|
? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
|
|
5773
5865
|
: { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
|
|
5774
5866
|
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
5775
|
-
})
|
|
5776
|
-
|
|
5867
|
+
}),
|
|
5868
|
+
])
|
|
5869
|
+
const scope = this.device.popErrorScope()
|
|
5870
|
+
try {
|
|
5871
|
+
const [compute, countPipeline, depth, render] = await builds
|
|
5872
|
+
const live =
|
|
5873
|
+
indirect && countLayout && countPipeline
|
|
5874
|
+
? {
|
|
5875
|
+
pipeline: countPipeline,
|
|
5876
|
+
indirect,
|
|
5877
|
+
binds: bindFor(countLayout, this.cameraUniformBuffer, false, true),
|
|
5878
|
+
}
|
|
5879
|
+
: null
|
|
5880
|
+
const scoped = await scope
|
|
5777
5881
|
if (scoped) {
|
|
5778
5882
|
buffer.destroy()
|
|
5779
5883
|
uniform.destroy()
|
|
@@ -5813,7 +5917,7 @@ export class Engine {
|
|
|
5813
5917
|
},
|
|
5814
5918
|
}
|
|
5815
5919
|
} catch (e) {
|
|
5816
|
-
await
|
|
5920
|
+
await scope
|
|
5817
5921
|
buffer.destroy()
|
|
5818
5922
|
uniform.destroy()
|
|
5819
5923
|
points?.buffer.destroy()
|
|
@@ -7068,41 +7172,170 @@ export class Engine {
|
|
|
7068
7172
|
const b = this.bloomSettings
|
|
7069
7173
|
if (patch.enabled !== undefined) b.enabled = patch.enabled
|
|
7070
7174
|
if (patch.threshold !== undefined) b.threshold = patch.threshold
|
|
7071
|
-
if (patch.
|
|
7072
|
-
if (patch.radius !== undefined) b.radius = patch.radius
|
|
7175
|
+
if (patch.scatter !== undefined) b.scatter = patch.scatter
|
|
7073
7176
|
if (patch.color !== undefined) {
|
|
7074
7177
|
b.color.x = patch.color.x
|
|
7075
7178
|
b.color.y = patch.color.y
|
|
7076
7179
|
b.color.z = patch.color.z
|
|
7077
7180
|
}
|
|
7078
7181
|
if (patch.intensity !== undefined) b.intensity = patch.intensity
|
|
7079
|
-
if (
|
|
7080
|
-
if (this.device && this.bloomBlitUniformBuffer) {
|
|
7182
|
+
if (this.device && this.compositeUniformBuffer) {
|
|
7081
7183
|
this.writeBloomUniforms()
|
|
7082
7184
|
this.writeCompositeViewUniforms()
|
|
7083
7185
|
}
|
|
7084
7186
|
}
|
|
7085
7187
|
|
|
7086
|
-
|
|
7188
|
+
private ensureBloomPipelines(): void {
|
|
7189
|
+
// The buffer, not a pipeline, says "already built": at init the pipelines
|
|
7190
|
+
// land async (initRenderPipeline) and are absent for a moment.
|
|
7191
|
+
if (this.bloomUniformBuffer) return
|
|
7192
|
+
const device = this.device
|
|
7193
|
+
this.bloomUniformBuffer = device.createBuffer({
|
|
7194
|
+
label: "bloomuniforms",
|
|
7195
|
+
size: 16,
|
|
7196
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
7197
|
+
})
|
|
7198
|
+
this.bloomUpsampleBindGroupLayout = device.createBindGroupLayout({
|
|
7199
|
+
label: "bloomupsample layout",
|
|
7200
|
+
entries: [
|
|
7201
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
7202
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
7203
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
7204
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
7205
|
+
],
|
|
7206
|
+
})
|
|
7207
|
+
const pipeline = (label: string, code: string, layout: GPUBindGroupLayout, assign: (p: GPURenderPipeline) => void) => {
|
|
7208
|
+
const module = device.createShaderModule({ label, code })
|
|
7209
|
+
this.initRenderPipeline(
|
|
7210
|
+
{
|
|
7211
|
+
label,
|
|
7212
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
|
7213
|
+
vertex: { module, entryPoint: "vs" },
|
|
7214
|
+
fragment: { module, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
7215
|
+
primitive: { topology: "triangle-list" },
|
|
7216
|
+
},
|
|
7217
|
+
assign,
|
|
7218
|
+
)
|
|
7219
|
+
}
|
|
7220
|
+
pipeline("bloomprefilter", BLOOM_PREFILTER_SHADER_WGSL, this.bloomPrefilterBindGroupLayout, (p) => (this.bloomPrefilterPipeline = p))
|
|
7221
|
+
pipeline("bloomblur H", BLOOM_BLUR_H_SHADER_WGSL, this.bloomBlurBindGroupLayout, (p) => (this.bloomBlurHPipeline = p))
|
|
7222
|
+
pipeline("bloomblur V", BLOOM_BLUR_V_SHADER_WGSL, this.bloomBlurBindGroupLayout, (p) => (this.bloomBlurVPipeline = p))
|
|
7223
|
+
pipeline("bloomupsample", BLOOM_UPSAMPLE_SHADER_WGSL, this.bloomUpsampleBindGroupLayout, (p) => (this.bloomUpsamplePipeline = p))
|
|
7224
|
+
}
|
|
7225
|
+
|
|
7226
|
+
/** The game's chain at the current canvas size (AGSimPostFX.Bloom): half
|
|
7227
|
+
* size, then floor(log2(longest side)) - 1 levels, each half the last. */
|
|
7228
|
+
private buildBloomTargets(): void {
|
|
7229
|
+
if (!this.device || !this.hdrResolveTexture || !this.maskResolveView || !this.bloomPrefilterBindGroupLayout) return
|
|
7230
|
+
this.ensureBloomPipelines()
|
|
7231
|
+
const w0 = Math.max(1, Math.floor(this.hdrResolveTexture.width / 2))
|
|
7232
|
+
const h0 = Math.max(1, Math.floor(this.hdrResolveTexture.height / 2))
|
|
7233
|
+
const levels = Math.min(Engine.BLOOM_MAX_LEVELS, Math.max(1, Math.floor(Math.log2(Math.max(w0, h0)) - 1)))
|
|
7234
|
+
// Mip chains, whose sizes are floor-halved exactly as the game's targets are.
|
|
7235
|
+
const make = (label: string) =>
|
|
7236
|
+
this.device.createTexture({
|
|
7237
|
+
label,
|
|
7238
|
+
size: [w0, h0],
|
|
7239
|
+
mipLevelCount: levels,
|
|
7240
|
+
format: this.hdrFormat,
|
|
7241
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
7242
|
+
})
|
|
7243
|
+
this.bloomDownTexture?.destroy()
|
|
7244
|
+
this.bloomUpTexture?.destroy()
|
|
7245
|
+
this.bloomDownTexture = make("bloomdown")
|
|
7246
|
+
this.bloomUpTexture = make("bloomup")
|
|
7247
|
+
this.bloomLevels = levels
|
|
7248
|
+
const views = (t: GPUTexture) =>
|
|
7249
|
+
Array.from({ length: levels }, (_, i) => t.createView({ baseMipLevel: i, mipLevelCount: 1 }))
|
|
7250
|
+
this.bloomDownViews = views(this.bloomDownTexture)
|
|
7251
|
+
this.bloomUpViews = views(this.bloomUpTexture)
|
|
7252
|
+
this.writeBloomUniforms()
|
|
7253
|
+
const uniform = { buffer: this.bloomUniformBuffer! }
|
|
7254
|
+
this.bloomPrefilterBindGroup = this.device.createBindGroup({
|
|
7255
|
+
label: "bloomprefilter",
|
|
7256
|
+
layout: this.bloomPrefilterBindGroupLayout,
|
|
7257
|
+
entries: [
|
|
7258
|
+
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
7259
|
+
{ binding: 1, resource: uniform },
|
|
7260
|
+
{ binding: 2, resource: this.maskResolveView },
|
|
7261
|
+
],
|
|
7262
|
+
})
|
|
7263
|
+
const blur = (label: string, src: GPUTextureView) =>
|
|
7264
|
+
this.device.createBindGroup({
|
|
7265
|
+
label,
|
|
7266
|
+
layout: this.bloomBlurBindGroupLayout,
|
|
7267
|
+
entries: [
|
|
7268
|
+
{ binding: 0, resource: src },
|
|
7269
|
+
{ binding: 1, resource: this.bloomSampler },
|
|
7270
|
+
],
|
|
7271
|
+
})
|
|
7272
|
+
// [i - 1] is level i: blurH reads down[i-1] into up[i], blurV up[i] into down[i].
|
|
7273
|
+
this.bloomBlurHBindGroups = []
|
|
7274
|
+
this.bloomBlurVBindGroups = []
|
|
7275
|
+
for (let i = 1; i < levels; i++) {
|
|
7276
|
+
this.bloomBlurHBindGroups.push(blur(`bloomblur H ${i}`, this.bloomDownViews[i - 1]))
|
|
7277
|
+
this.bloomBlurVBindGroups.push(blur(`bloomblur V ${i}`, this.bloomUpViews[i]))
|
|
7278
|
+
}
|
|
7279
|
+
// [k] writes up[levels - 2 - k], coarsest first.
|
|
7280
|
+
this.bloomUpsampleBindGroups = []
|
|
7281
|
+
for (let i = levels - 2; i >= 0; i--) {
|
|
7282
|
+
const low = i === levels - 2 ? this.bloomDownViews[i + 1] : this.bloomUpViews[i + 1]
|
|
7283
|
+
this.bloomUpsampleBindGroups.push(
|
|
7284
|
+
this.device.createBindGroup({
|
|
7285
|
+
label: `bloomupsample ${i}`,
|
|
7286
|
+
layout: this.bloomUpsampleBindGroupLayout!,
|
|
7287
|
+
entries: [
|
|
7288
|
+
{ binding: 0, resource: this.bloomDownViews[i] },
|
|
7289
|
+
{ binding: 1, resource: low },
|
|
7290
|
+
{ binding: 2, resource: this.bloomSampler },
|
|
7291
|
+
{ binding: 3, resource: uniform },
|
|
7292
|
+
],
|
|
7293
|
+
}),
|
|
7294
|
+
)
|
|
7295
|
+
}
|
|
7296
|
+
}
|
|
7297
|
+
|
|
7087
7298
|
private writeBloomUniforms(): void {
|
|
7299
|
+
if (!this.bloomUniformBuffer) return
|
|
7088
7300
|
const b = this.bloomSettings
|
|
7089
|
-
const
|
|
7090
|
-
//
|
|
7091
|
-
//
|
|
7092
|
-
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
|
|
7101
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7301
|
+
const u = this.bloomUniformData
|
|
7302
|
+
// Threshold, its soft knee (half of it, as the game derives it), the clamp,
|
|
7303
|
+
// and scatter. Tint and intensity live in the composite.
|
|
7304
|
+
u[0] = Math.max(0, b.threshold)
|
|
7305
|
+
u[1] = Math.max(0, b.threshold) * 0.5
|
|
7306
|
+
u[2] = Engine.BLOOM_CLAMP
|
|
7307
|
+
u[3] = Math.min(1, Math.max(0, b.scatter))
|
|
7308
|
+
this.device.queue.writeBuffer(this.bloomUniformBuffer, 0, u)
|
|
7309
|
+
}
|
|
7310
|
+
|
|
7311
|
+
/** The game's chain, pass for pass (AGSimPostFX.Bloom). */
|
|
7312
|
+
private renderBloom(encoder: GPUCommandEncoder): void {
|
|
7313
|
+
const levels = this.bloomLevels
|
|
7314
|
+
const att = (this.bloomPassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
|
|
7315
|
+
const total = 1 + 3 * (levels - 1)
|
|
7316
|
+
let n = 0
|
|
7317
|
+
const draw = (target: GPUTextureView, pipeline: GPURenderPipeline, group: GPUBindGroup) => {
|
|
7318
|
+
att.view = target
|
|
7319
|
+
// One timing span for the whole chain, as for the pyramid.
|
|
7320
|
+
const open = n === 0 ? this.stampOpen("bloom") : undefined
|
|
7321
|
+
const close = n === total - 1 ? this.stampClose("bloom") : undefined
|
|
7322
|
+
this.bloomPassDescriptor.timestampWrites = open && close ? { ...open, ...close } : (open ?? close)
|
|
7323
|
+
n++
|
|
7324
|
+
const p = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
7325
|
+
p.setPipeline(pipeline)
|
|
7326
|
+
p.setBindGroup(0, group)
|
|
7327
|
+
p.draw(3)
|
|
7328
|
+
p.end()
|
|
7329
|
+
}
|
|
7330
|
+
draw(this.bloomDownViews[0], this.bloomPrefilterPipeline!, this.bloomPrefilterBindGroup!)
|
|
7331
|
+
for (let i = 1; i < levels; i++) {
|
|
7332
|
+
draw(this.bloomUpViews[i], this.bloomBlurHPipeline!, this.bloomBlurHBindGroups[i - 1])
|
|
7333
|
+
draw(this.bloomDownViews[i], this.bloomBlurVPipeline!, this.bloomBlurVBindGroups[i - 1])
|
|
7334
|
+
}
|
|
7335
|
+
for (let k = 0; k < levels - 1; k++) {
|
|
7336
|
+
draw(this.bloomUpViews[levels - 2 - k], this.bloomUpsamplePipeline!, this.bloomUpsampleBindGroups[k])
|
|
7337
|
+
}
|
|
7338
|
+
this.bloomPassDescriptor.timestampWrites = undefined
|
|
7106
7339
|
}
|
|
7107
7340
|
|
|
7108
7341
|
// Step 1: Get WebGPU device and context
|
|
@@ -7230,7 +7463,16 @@ export class Engine {
|
|
|
7230
7463
|
this.setupCamera()
|
|
7231
7464
|
this.setupLighting()
|
|
7232
7465
|
const tPipelines = performance.now()
|
|
7466
|
+
// Built async and side by side (see initRenderPipeline): a synchronous
|
|
7467
|
+
// build holds the GPU process for the whole compile, one after another,
|
|
7468
|
+
// which was a 2–3 s frozen frame on a cold open.
|
|
7469
|
+
this.initPipelineJobs = []
|
|
7233
7470
|
this.createPipelines()
|
|
7471
|
+
try {
|
|
7472
|
+
await Promise.all(this.initPipelineJobs)
|
|
7473
|
+
} finally {
|
|
7474
|
+
this.initPipelineJobs = null
|
|
7475
|
+
}
|
|
7234
7476
|
this.setupResize()
|
|
7235
7477
|
const ms = (a: number, b: number) => Math.round(b - a)
|
|
7236
7478
|
console.info(
|
|
@@ -7361,123 +7603,11 @@ export class Engine {
|
|
|
7361
7603
|
ltcTemp.destroy()
|
|
7362
7604
|
}
|
|
7363
7605
|
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
// cubic (Fritsch–Carlson) through them so the curve is C1-continuous (no Mach banding in
|
|
7367
|
-
// smooth gradients) while still passing through every anchor (look preserved) and staying
|
|
7368
|
-
// monotone (no tonemap overshoot/ringing). Domain is uniform in log2 space: anchor k sits
|
|
7369
|
-
// at t=k, k=0..13 (t = log2(linear)+10). See composite.ts::filmic for the sampling map.
|
|
7370
|
-
/**
|
|
7371
|
-
* Decompress and upload Blender's AgX cube.
|
|
7372
|
-
*
|
|
7373
|
-
* Deliberately off the critical path: it is 723 KB once inflated, and a frame
|
|
7374
|
-
* rendered before it lands should show the scene under whatever transform is
|
|
7375
|
-
* already there rather than wait. Until then binding 10 holds a 1×1×1 stand-in,
|
|
7376
|
-
* which is only ever sampled if someone selects AgX in that window.
|
|
7377
|
-
*/
|
|
7378
|
-
private async loadAgxLut(): Promise<void> {
|
|
7379
|
-
try {
|
|
7380
|
-
const packed = Uint8Array.from(atob(AGX_LUT_GZ), (ch) => ch.charCodeAt(0))
|
|
7381
|
-
const stream = new Blob([packed]).stream().pipeThrough(new DecompressionStream("gzip"))
|
|
7382
|
-
const bytes = new Uint8Array(await new Response(stream).arrayBuffer())
|
|
7383
|
-
const n = AGX_LUT_SIZE
|
|
7384
|
-
if (bytes.byteLength !== n * n * n * 4) throw new Error(`AgX LUT is ${bytes.byteLength} bytes, expected ${n ** 3 * 4}`)
|
|
7385
|
-
const tex = this.device.createTexture({
|
|
7386
|
-
label: "AgX 57³ LUT",
|
|
7387
|
-
size: [n, n, n],
|
|
7388
|
-
dimension: "3d",
|
|
7389
|
-
format: "rgb10a2unorm",
|
|
7390
|
-
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
7391
|
-
})
|
|
7392
|
-
// .cube order is red fastest, which is exactly a 3D texture's own layout.
|
|
7393
|
-
this.device.queue.writeTexture({ texture: tex }, bytes, { bytesPerRow: n * 4, rowsPerImage: n }, [n, n, n])
|
|
7394
|
-
this.agxLutTexture = tex
|
|
7395
|
-
this.rebuildCompositeBindGroup()
|
|
7396
|
-
} catch {
|
|
7397
|
-
// A missing LUT costs AgX, not the renderer — the other transforms stand.
|
|
7398
|
-
}
|
|
7399
|
-
}
|
|
7400
|
-
|
|
7401
|
-
private bakeFilmicLut() {
|
|
7402
|
-
const anchors = [
|
|
7403
|
-
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,
|
|
7404
|
-
]
|
|
7405
|
-
const n = anchors.length
|
|
7406
|
-
// Secant slopes (unit spacing, so d_k = y_{k+1} - y_k).
|
|
7407
|
-
const d = new Array<number>(n - 1)
|
|
7408
|
-
for (let k = 0; k < n - 1; k++) d[k] = anchors[k + 1] - anchors[k]
|
|
7409
|
-
// Endpoint + interior tangents.
|
|
7410
|
-
const m = new Array<number>(n)
|
|
7411
|
-
m[0] = d[0]
|
|
7412
|
-
m[n - 1] = d[n - 2]
|
|
7413
|
-
for (let k = 1; k < n - 1; k++) m[k] = (d[k - 1] + d[k]) * 0.5
|
|
7414
|
-
// Fritsch–Carlson monotonicity clamp.
|
|
7415
|
-
for (let k = 0; k < n - 1; k++) {
|
|
7416
|
-
if (d[k] === 0) {
|
|
7417
|
-
m[k] = 0
|
|
7418
|
-
m[k + 1] = 0
|
|
7419
|
-
continue
|
|
7420
|
-
}
|
|
7421
|
-
const a = m[k] / d[k]
|
|
7422
|
-
const b = m[k + 1] / d[k]
|
|
7423
|
-
const s = a * a + b * b
|
|
7424
|
-
if (s > 9) {
|
|
7425
|
-
const tau = 3 / Math.sqrt(s)
|
|
7426
|
-
m[k] = tau * a * d[k]
|
|
7427
|
-
m[k + 1] = tau * b * d[k]
|
|
7428
|
-
}
|
|
7429
|
-
}
|
|
7430
|
-
const W = Engine.FILMIC_LUT_WIDTH
|
|
7431
|
-
const values = new Float32Array(W)
|
|
7432
|
-
for (let j = 0; j < W; j++) {
|
|
7433
|
-
const t = ((n - 1) * j) / (W - 1) // t ∈ [0, n-1]
|
|
7434
|
-
const k = Math.min(Math.floor(t), n - 2)
|
|
7435
|
-
const s = t - k // local param in [0,1], unit-spaced segment
|
|
7436
|
-
const s2 = s * s
|
|
7437
|
-
const s3 = s2 * s
|
|
7438
|
-
// Hermite basis (h=1).
|
|
7439
|
-
const h00 = 2 * s3 - 3 * s2 + 1
|
|
7440
|
-
const h10 = s3 - 2 * s2 + s
|
|
7441
|
-
const h01 = -2 * s3 + 3 * s2
|
|
7442
|
-
const h11 = s3 - s2
|
|
7443
|
-
values[j] = h00 * anchors[k] + h10 * m[k] + h01 * anchors[k + 1] + h11 * m[k + 1]
|
|
7444
|
-
}
|
|
7445
|
-
|
|
7446
|
-
// f32 → f16 bits (same conversion as bakeBrdfLut).
|
|
7447
|
-
const half = new Uint16Array(W)
|
|
7448
|
-
const f32 = new Float32Array(1)
|
|
7449
|
-
const u32 = new Uint32Array(f32.buffer)
|
|
7450
|
-
for (let j = 0; j < W; j++) {
|
|
7451
|
-
f32[0] = values[j]
|
|
7452
|
-
const x = u32[0]
|
|
7453
|
-
const sign = (x >>> 16) & 0x8000
|
|
7454
|
-
const exp = ((x >>> 23) & 0xff) - 127 + 15
|
|
7455
|
-
const mant = x & 0x7fffff
|
|
7456
|
-
if (exp <= 0) {
|
|
7457
|
-
half[j] = sign
|
|
7458
|
-
} else if (exp >= 31) {
|
|
7459
|
-
half[j] = sign | 0x7c00
|
|
7460
|
-
} else {
|
|
7461
|
-
half[j] = sign | (exp << 10) | (mant >>> 13)
|
|
7462
|
-
}
|
|
7463
|
-
}
|
|
7464
|
-
|
|
7465
|
-
this.filmicLutTexture = this.device.createTexture({
|
|
7466
|
-
label: "Filmic tone LUT",
|
|
7467
|
-
size: [W, 1],
|
|
7468
|
-
format: "r16float",
|
|
7469
|
-
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
|
|
7470
|
-
})
|
|
7471
|
-
this.filmicLutView = this.filmicLutTexture.createView()
|
|
7472
|
-
this.device.queue.writeTexture(
|
|
7473
|
-
{ texture: this.filmicLutTexture },
|
|
7474
|
-
half,
|
|
7475
|
-
{ bytesPerRow: W * 2, rowsPerImage: 1 },
|
|
7476
|
-
{ width: W, height: 1, depthOrArrayLayers: 1 },
|
|
7477
|
-
)
|
|
7606
|
+
private createRenderPipeline(config: Parameters<Engine["renderPipelineDesc"]>[0]): GPURenderPipeline {
|
|
7607
|
+
return this.createModelPipeline(this.renderPipelineDesc(config))
|
|
7478
7608
|
}
|
|
7479
7609
|
|
|
7480
|
-
private
|
|
7610
|
+
private renderPipelineDesc(config: {
|
|
7481
7611
|
label: string
|
|
7482
7612
|
layout: GPUPipelineLayout
|
|
7483
7613
|
shaderModule: GPUShaderModule
|
|
@@ -7488,9 +7618,9 @@ export class Engine {
|
|
|
7488
7618
|
cullMode?: GPUCullMode
|
|
7489
7619
|
depthStencil?: GPUDepthStencilState
|
|
7490
7620
|
multisample?: GPUMultisampleState
|
|
7491
|
-
}):
|
|
7621
|
+
}): GPURenderPipelineDescriptor {
|
|
7492
7622
|
const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined)
|
|
7493
|
-
return
|
|
7623
|
+
return {
|
|
7494
7624
|
label: config.label,
|
|
7495
7625
|
layout: config.layout,
|
|
7496
7626
|
vertex: {
|
|
@@ -7507,7 +7637,7 @@ export class Engine {
|
|
|
7507
7637
|
primitive: { cullMode: config.cullMode ?? "none" },
|
|
7508
7638
|
depthStencil: config.depthStencil,
|
|
7509
7639
|
multisample: config.multisample ?? { count: Engine.MULTISAMPLE_COUNT },
|
|
7510
|
-
}
|
|
7640
|
+
}
|
|
7511
7641
|
}
|
|
7512
7642
|
|
|
7513
7643
|
private createPipelines() {
|
|
@@ -7633,8 +7763,6 @@ export class Engine {
|
|
|
7633
7763
|
// creation with "binding doesn't exist", naming a binding no effect
|
|
7634
7764
|
// author ever wrote. Sharing the code meant sharing these.
|
|
7635
7765
|
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
7636
|
-
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
7637
|
-
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
|
|
7638
7766
|
{ binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
|
|
7639
7767
|
// And the scene's COVERAGE and bloom, without which the tap cannot
|
|
7640
7768
|
// reconstruct a pixel: the HDR target is premultiplied, so colour alone
|
|
@@ -7656,31 +7784,40 @@ export class Engine {
|
|
|
7656
7784
|
const seedModule = this.device.createShaderModule({ label: "cast distance seed", code: buildCastSeedShader(Engine.MULTISAMPLE_COUNT) })
|
|
7657
7785
|
const stepModule = this.device.createShaderModule({ label: "cast distance step", code: buildCastStepShader() })
|
|
7658
7786
|
const resolveModule = this.device.createShaderModule({ label: "cast distance resolve", code: buildCastResolveShader() })
|
|
7659
|
-
this.
|
|
7660
|
-
|
|
7661
|
-
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7787
|
+
this.initRenderPipeline(
|
|
7788
|
+
{
|
|
7789
|
+
label: "cast distance seed",
|
|
7790
|
+
layout: "auto",
|
|
7791
|
+
vertex: { module: seedModule, entryPoint: "vs" },
|
|
7792
|
+
fragment: {
|
|
7793
|
+
module: seedModule,
|
|
7794
|
+
entryPoint: "fs",
|
|
7795
|
+
targets: [{ format: CAST_SEED_FORMAT }, { format: CAST_COVERAGE_FORMAT }],
|
|
7796
|
+
},
|
|
7797
|
+
primitive: { topology: "triangle-list" },
|
|
7667
7798
|
},
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
this.
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
7678
|
-
|
|
7679
|
-
|
|
7680
|
-
|
|
7681
|
-
|
|
7682
|
-
|
|
7683
|
-
|
|
7799
|
+
(p) => (this.castSeedPipeline = p),
|
|
7800
|
+
)
|
|
7801
|
+
this.initRenderPipeline(
|
|
7802
|
+
{
|
|
7803
|
+
label: "cast distance step",
|
|
7804
|
+
layout: "auto",
|
|
7805
|
+
vertex: { module: stepModule, entryPoint: "vs" },
|
|
7806
|
+
fragment: { module: stepModule, entryPoint: "fs", targets: [{ format: CAST_SEED_FORMAT }] },
|
|
7807
|
+
primitive: { topology: "triangle-list" },
|
|
7808
|
+
},
|
|
7809
|
+
(p) => (this.castStepPipeline = p),
|
|
7810
|
+
)
|
|
7811
|
+
this.initRenderPipeline(
|
|
7812
|
+
{
|
|
7813
|
+
label: "cast distance resolve",
|
|
7814
|
+
layout: "auto",
|
|
7815
|
+
vertex: { module: resolveModule, entryPoint: "vs" },
|
|
7816
|
+
fragment: { module: resolveModule, entryPoint: "fs", targets: [{ format: CAST_DIST_FORMAT }] },
|
|
7817
|
+
primitive: { topology: "triangle-list" },
|
|
7818
|
+
},
|
|
7819
|
+
(p) => (this.castResolvePipeline = p),
|
|
7820
|
+
)
|
|
7684
7821
|
// 65504 is the largest half float. Bound wherever the field is not
|
|
7685
7822
|
// running, so rzCastDistance answers "unreachably far" and an effect keyed
|
|
7686
7823
|
// on it draws nothing at all.
|
|
@@ -7712,12 +7849,6 @@ export class Engine {
|
|
|
7712
7849
|
[1, 1],
|
|
7713
7850
|
)
|
|
7714
7851
|
|
|
7715
|
-
this.noFillBuffer = this.device.createBuffer({
|
|
7716
|
-
label: "model light (none)",
|
|
7717
|
-
size: 48,
|
|
7718
|
-
usage: GPUBufferUsage.UNIFORM,
|
|
7719
|
-
})
|
|
7720
|
-
|
|
7721
7852
|
// Generic shared-toon ramp: lit white down to a soft cool shadow tone with
|
|
7722
7853
|
// a tight terminator around the midpoint, approximating MMD's toon ramps.
|
|
7723
7854
|
const TOON_H = 64
|
|
@@ -7813,10 +7944,10 @@ export class Engine {
|
|
|
7813
7944
|
// The positional lights. Always bound, empty or not, so every material
|
|
7814
7945
|
// pipeline shares one layout whether or not the scene has any.
|
|
7815
7946
|
{ binding: 6, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
7816
|
-
// The
|
|
7817
|
-
//
|
|
7818
|
-
//
|
|
7819
|
-
|
|
7947
|
+
// The world's sky (8) and the BRDF LUT (9). The sky is always bound —
|
|
7948
|
+
// the 1x1 fallback when the scene has none — so every material
|
|
7949
|
+
// pipeline keeps sharing one layout. (7 was the far cascade's map,
|
|
7950
|
+
// which the atlas at 3 replaces.)
|
|
7820
7951
|
{ binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
7821
7952
|
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
7822
7953
|
// The cast's shadow on a stage — setStageCastShadow.
|
|
@@ -7887,34 +8018,42 @@ export class Engine {
|
|
|
7887
8018
|
// hand shaders are retired in favor of graphs.
|
|
7888
8019
|
const neutral = compileGraph(DEFAULT_GRAPH, { renderClass: "auto", alphaMode: "opaque" })
|
|
7889
8020
|
if (!neutral.ok) throw new Error("failed to compile the neutral default graph")
|
|
7890
|
-
const neutralModule = this.
|
|
7891
|
-
this.
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
7897
|
-
|
|
7898
|
-
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
|
|
7903
|
-
|
|
8021
|
+
const neutralModule = this.cachedShaderModule(neutral.wgsl, "neutral base (default graph)")
|
|
8022
|
+
this.initRenderPipeline(
|
|
8023
|
+
this.renderPipelineDesc({
|
|
8024
|
+
label: "neutral base pipeline",
|
|
8025
|
+
layout: mainPipelineLayout,
|
|
8026
|
+
shaderModule: neutralModule,
|
|
8027
|
+
vertexBuffers: fullVertexBuffers,
|
|
8028
|
+
fragmentTargets: sceneTargets,
|
|
8029
|
+
cullMode: "none",
|
|
8030
|
+
depthStencil: {
|
|
8031
|
+
format: this.depthFormat,
|
|
8032
|
+
depthWriteEnabled: true,
|
|
8033
|
+
depthCompare: this.depthAhead,
|
|
8034
|
+
},
|
|
8035
|
+
}),
|
|
8036
|
+
(p) => (this.neutralPipeline = p),
|
|
8037
|
+
true,
|
|
8038
|
+
)
|
|
7904
8039
|
// Depth-write-off twin for transparent-bucket draws (see pipelineForDrawCall).
|
|
7905
|
-
this.
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
8040
|
+
this.initRenderPipeline(
|
|
8041
|
+
this.renderPipelineDesc({
|
|
8042
|
+
label: "neutral base pipeline (no depth write)",
|
|
8043
|
+
layout: mainPipelineLayout,
|
|
8044
|
+
shaderModule: neutralModule,
|
|
8045
|
+
vertexBuffers: fullVertexBuffers,
|
|
8046
|
+
fragmentTargets: sceneTargets,
|
|
8047
|
+
cullMode: "none",
|
|
8048
|
+
depthStencil: {
|
|
8049
|
+
format: this.depthFormat,
|
|
8050
|
+
depthWriteEnabled: false,
|
|
8051
|
+
depthCompare: this.depthAhead,
|
|
8052
|
+
},
|
|
8053
|
+
}),
|
|
8054
|
+
(p) => (this.neutralPipelineNoDepthWrite = p),
|
|
8055
|
+
true,
|
|
8056
|
+
)
|
|
7918
8057
|
// Depth-only prepass for transparent draws (see depth-prepass.ts): writes the
|
|
7919
8058
|
// fabric's depth AFTER its color blended, so outlines drawn later are
|
|
7920
8059
|
// occluded behind it. Color targets kept for pass compatibility, writeMask 0.
|
|
@@ -7933,51 +8072,63 @@ export class Engine {
|
|
|
7933
8072
|
depthCompare: this.depthAhead,
|
|
7934
8073
|
},
|
|
7935
8074
|
}
|
|
7936
|
-
this.
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
8075
|
+
this.initRenderPipeline(
|
|
8076
|
+
{
|
|
8077
|
+
label: "opaque depth prepass",
|
|
8078
|
+
...prepassDesc,
|
|
8079
|
+
fragment: {
|
|
8080
|
+
module: prepassModule,
|
|
8081
|
+
entryPoint: "fs",
|
|
8082
|
+
targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
|
|
8083
|
+
},
|
|
7943
8084
|
},
|
|
7944
|
-
|
|
8085
|
+
(p) => (this.depthPrepassPipeline = p),
|
|
8086
|
+
true,
|
|
8087
|
+
)
|
|
7945
8088
|
// The SOLID prime: same module, cutoff forced to exactly 1.0. Only texels
|
|
7946
8089
|
// whose blend ignores the destination may pre-claim depth in the
|
|
7947
8090
|
// transparent phase — see the override's note in depth-prepass.ts.
|
|
7948
|
-
this.
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
7955
|
-
|
|
8091
|
+
this.initRenderPipeline(
|
|
8092
|
+
{
|
|
8093
|
+
label: "transparent solid prepass",
|
|
8094
|
+
...prepassDesc,
|
|
8095
|
+
fragment: {
|
|
8096
|
+
module: prepassModule,
|
|
8097
|
+
entryPoint: "fs",
|
|
8098
|
+
constants: { CUTOFF: 1.0 },
|
|
8099
|
+
targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
|
|
8100
|
+
},
|
|
7956
8101
|
},
|
|
7957
|
-
|
|
8102
|
+
(p) => (this.solidPrepassPipeline = p),
|
|
8103
|
+
true,
|
|
8104
|
+
)
|
|
7958
8105
|
// The HAIR prime: solid texels only, and stencil-fenced off the eye
|
|
7959
8106
|
// silhouette. It records after the non-hair opaque draws, so the eye has
|
|
7960
8107
|
// already written its stencil — not-equal here is what keeps the primed
|
|
7961
8108
|
// hair depth from ever claiming the pixels the see-through-hair pass needs
|
|
7962
8109
|
// the eye to survive on. (Bundle draws use the PASS's stencil reference;
|
|
7963
8110
|
// only pipeline/bind/vertex state resets across executeBundles.)
|
|
7964
|
-
this.
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
|
|
7975
|
-
|
|
7976
|
-
|
|
7977
|
-
|
|
7978
|
-
|
|
8111
|
+
this.initRenderPipeline(
|
|
8112
|
+
{
|
|
8113
|
+
label: "hair depth prime",
|
|
8114
|
+
...prepassDesc,
|
|
8115
|
+
depthStencil: {
|
|
8116
|
+
...prepassDesc.depthStencil,
|
|
8117
|
+
stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
|
|
8118
|
+
stencilBack: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
|
|
8119
|
+
stencilReadMask: 0xff,
|
|
8120
|
+
stencilWriteMask: 0,
|
|
8121
|
+
},
|
|
8122
|
+
fragment: {
|
|
8123
|
+
module: prepassModule,
|
|
8124
|
+
entryPoint: "fs",
|
|
8125
|
+
constants: { CUTOFF: 1.0 },
|
|
8126
|
+
targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
|
|
8127
|
+
},
|
|
7979
8128
|
},
|
|
7980
|
-
|
|
8129
|
+
(p) => (this.hairPrimePipeline = p),
|
|
8130
|
+
true,
|
|
8131
|
+
)
|
|
7981
8132
|
|
|
7982
8133
|
// The matrices, then one vec4: the caster sphere, which effects' rzShadow
|
|
7983
8134
|
// tests before any tap. The materials and the ground bind the matrices only.
|
|
@@ -8004,44 +8155,45 @@ export class Engine {
|
|
|
8004
8155
|
label: "shadow depth",
|
|
8005
8156
|
code: SHADOW_DEPTH_SHADER_WGSL,
|
|
8006
8157
|
})
|
|
8007
|
-
this.
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8158
|
+
this.initRenderPipeline(
|
|
8159
|
+
{
|
|
8160
|
+
label: "shadow depth pipeline",
|
|
8161
|
+
// Group 1 is the main pass's per-material layout so each shadow draw can
|
|
8162
|
+
// rebind the draw call's existing material bind group for the alpha test.
|
|
8163
|
+
layout: this.device.createPipelineLayout({
|
|
8164
|
+
bindGroupLayouts: [shadowBindGroupLayout, this.mainPerMaterialBindGroupLayout],
|
|
8165
|
+
}),
|
|
8166
|
+
vertex: { module: shadowShader, entryPoint: "vs", buffers: fullVertexBuffers as GPUVertexBufferLayout[] },
|
|
8167
|
+
fragment: { module: shadowShader, entryPoint: "fs", targets: [] },
|
|
8168
|
+
primitive: { cullMode: "none" },
|
|
8169
|
+
depthStencil: {
|
|
8170
|
+
format: Engine.SHADOW_DEPTH_FORMAT,
|
|
8171
|
+
depthWriteEnabled: true,
|
|
8172
|
+
depthCompare: "less-equal",
|
|
8173
|
+
// The shadow map keeps the NON-reversed convention (orthographicLh maps
|
|
8174
|
+
// [0,1] with far = 1, and this pass never flipped) — so this bias must
|
|
8175
|
+
// NOT follow reversedZ. It is the camera-pass biases that flip.
|
|
8176
|
+
depthBias: 2,
|
|
8177
|
+
depthBiasSlopeScale: 1.5,
|
|
8178
|
+
depthBiasClamp: 0,
|
|
8179
|
+
},
|
|
8027
8180
|
},
|
|
8028
|
-
|
|
8181
|
+
(p) => (this.shadowDepthPipeline = p),
|
|
8182
|
+
)
|
|
8029
8183
|
this.shadowComparisonSampler = this.device.createSampler({
|
|
8030
8184
|
compare: "less",
|
|
8031
8185
|
magFilter: "linear",
|
|
8032
8186
|
minFilter: "linear",
|
|
8033
8187
|
})
|
|
8034
|
-
// One
|
|
8035
|
-
//
|
|
8036
|
-
this.
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
8042
|
-
|
|
8043
|
-
)
|
|
8044
|
-
this.shadowMapDepthViews = this.shadowMapTextures.map((t) => t.createView())
|
|
8188
|
+
// One atlas, a tile per cascade — the layout the game's pipeline uses, and
|
|
8189
|
+
// one binding for every reader.
|
|
8190
|
+
this.shadowAtlasTexture = this.device.createTexture({
|
|
8191
|
+
label: "sun shadow atlas",
|
|
8192
|
+
size: [SHADOW_ATLAS_SIZE, SHADOW_ATLAS_SIZE],
|
|
8193
|
+
format: Engine.SHADOW_DEPTH_FORMAT,
|
|
8194
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
8195
|
+
})
|
|
8196
|
+
this.shadowAtlasView = this.shadowAtlasTexture.createView()
|
|
8045
8197
|
// The cast's shadow on a stage (setStageCastShadow): one map, only the cast
|
|
8046
8198
|
// in it, from the stage's own direction. Always allocated so every material
|
|
8047
8199
|
// bind group and every instance's shadow bind group stays one shape; drawn
|
|
@@ -8062,14 +8214,23 @@ export class Engine {
|
|
|
8062
8214
|
// One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
|
|
8063
8215
|
this.bakeBrdfLut()
|
|
8064
8216
|
this.bakeGroundNoise()
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
|
|
8072
|
-
|
|
8217
|
+
// The mipmap blit for the formats model textures arrive in, built here so
|
|
8218
|
+
// the first texture of the first model does not compile it synchronously
|
|
8219
|
+
// in the middle of a load (generateMipmaps builds any other on demand).
|
|
8220
|
+
{
|
|
8221
|
+
const mipModule = this.device.createShaderModule({ label: "mipmap blit", code: MIPMAP_BLIT_SHADER_WGSL })
|
|
8222
|
+
for (const format of ["rgba8unorm", "rgba8unorm-srgb"] as const)
|
|
8223
|
+
this.initRenderPipeline(
|
|
8224
|
+
{
|
|
8225
|
+
label: `mipmap blit pipeline (${format})`,
|
|
8226
|
+
layout: "auto",
|
|
8227
|
+
vertex: { module: mipModule, entryPoint: "vs" },
|
|
8228
|
+
fragment: { module: mipModule, entryPoint: "fs", targets: [{ format }] },
|
|
8229
|
+
primitive: { topology: "triangle-list" },
|
|
8230
|
+
},
|
|
8231
|
+
(p) => this.mipBlitPipelines.set(format, p),
|
|
8232
|
+
)
|
|
8233
|
+
}
|
|
8073
8234
|
this.stageGradeFallback = this.device.createTexture({
|
|
8074
8235
|
label: "stage grade fallback",
|
|
8075
8236
|
size: [1, 1, 1],
|
|
@@ -8078,7 +8239,6 @@ export class Engine {
|
|
|
8078
8239
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
8079
8240
|
})
|
|
8080
8241
|
this.uploadStageGrade()
|
|
8081
|
-
this.bakeFilmicLut()
|
|
8082
8242
|
|
|
8083
8243
|
// BEFORE the bind group below, which binds it. Full size from the start:
|
|
8084
8244
|
// every material pipeline binds this, so sizing it to the light count would
|
|
@@ -8141,7 +8301,6 @@ export class Engine {
|
|
|
8141
8301
|
// Same lights the materials read. A lamp that lit the cast and not the
|
|
8142
8302
|
// floor under her would read as a sticker.
|
|
8143
8303
|
{ binding: 6, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
8144
|
-
{ binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
|
|
8145
8304
|
// The floor mirror: the mirror camera's view-projection, the reflection
|
|
8146
8305
|
// resolve, an ordinary sampler beside the comparison one, and the
|
|
8147
8306
|
// mirror pass's own depth for the depth-proportional blur.
|
|
@@ -8170,7 +8329,10 @@ export class Engine {
|
|
|
8170
8329
|
cullMode: "back",
|
|
8171
8330
|
depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
|
|
8172
8331
|
}
|
|
8173
|
-
this.
|
|
8332
|
+
this.initRenderPipeline(
|
|
8333
|
+
this.renderPipelineDesc(this.groundPipelineConfig(false)),
|
|
8334
|
+
(p) => (this.groundShadowPipeline = p),
|
|
8335
|
+
)
|
|
8174
8336
|
|
|
8175
8337
|
// Outline: group 0 = per-frame (camera), group 1 = per-instance (skinMats), group 2 = per-material (edge uniforms)
|
|
8176
8338
|
this.outlinePerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
@@ -8239,7 +8401,12 @@ export class Engine {
|
|
|
8239
8401
|
label: "outline pipeline",
|
|
8240
8402
|
layout: outlinePipelineLayout,
|
|
8241
8403
|
shaderModule: outlineShaderModule,
|
|
8242
|
-
|
|
8404
|
+
// The selection mask's streams plus the hull's own: smoothed normal and
|
|
8405
|
+
// PMX edge scale (ModelInstance.outlineVertexBuffer).
|
|
8406
|
+
vertexBuffers: [
|
|
8407
|
+
...outlineVertexBuffers,
|
|
8408
|
+
{ arrayStride: 4 * 4, attributes: [{ shaderLocation: 5, offset: 0, format: "float32x4" }] },
|
|
8409
|
+
],
|
|
8243
8410
|
fragmentTargets: sceneTargetsFor("outline", this.sceneFormats),
|
|
8244
8411
|
cullMode: "back",
|
|
8245
8412
|
depthStencil: {
|
|
@@ -8273,12 +8440,16 @@ export class Engine {
|
|
|
8273
8440
|
stencilWriteMask: 0,
|
|
8274
8441
|
},
|
|
8275
8442
|
}
|
|
8276
|
-
this.
|
|
8277
|
-
this.
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8443
|
+
this.initRenderPipeline(this.renderPipelineDesc(outlineDesc), (p) => (this.outlinePipeline = p), true)
|
|
8444
|
+
this.initRenderPipeline(
|
|
8445
|
+
this.renderPipelineDesc({
|
|
8446
|
+
...outlineDesc,
|
|
8447
|
+
label: "outline pipeline (mirror)",
|
|
8448
|
+
cullMode: "front",
|
|
8449
|
+
}),
|
|
8450
|
+
(p) => (this.outlineMirrorPipeline = p),
|
|
8451
|
+
true,
|
|
8452
|
+
)
|
|
8282
8453
|
|
|
8283
8454
|
// ─── Selection overlay (screen-space edge-detect on a per-material mask) ───
|
|
8284
8455
|
// Reuses outline camera + main skinMats bind group layouts. No group 2 (no per-mat uniform).
|
|
@@ -8290,19 +8461,22 @@ export class Engine {
|
|
|
8290
8461
|
label: "selection mask shader",
|
|
8291
8462
|
code: SELECTION_MASK_SHADER_WGSL,
|
|
8292
8463
|
})
|
|
8293
|
-
this.
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
8297
|
-
|
|
8298
|
-
|
|
8299
|
-
|
|
8300
|
-
|
|
8464
|
+
this.initRenderPipeline(
|
|
8465
|
+
{
|
|
8466
|
+
label: "selection mask pipeline",
|
|
8467
|
+
layout: selectionMaskPipelineLayout,
|
|
8468
|
+
vertex: { module: selectionMaskShaderModule, entryPoint: "vs", buffers: outlineVertexBuffers },
|
|
8469
|
+
fragment: {
|
|
8470
|
+
module: selectionMaskShaderModule,
|
|
8471
|
+
entryPoint: "fs",
|
|
8472
|
+
targets: [{ format: "r8unorm" }],
|
|
8473
|
+
},
|
|
8474
|
+
primitive: { cullMode: "none" },
|
|
8475
|
+
// Single-sample, no depth (depth-always via not attaching a depth buffer at all).
|
|
8476
|
+
multisample: { count: 1 },
|
|
8301
8477
|
},
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
multisample: { count: 1 },
|
|
8305
|
-
})
|
|
8478
|
+
(p) => (this.selectionMaskPipeline = p),
|
|
8479
|
+
)
|
|
8306
8480
|
|
|
8307
8481
|
this.selectionEdgeBindGroupLayout = this.device.createBindGroupLayout({
|
|
8308
8482
|
label: "selection edge bind group layout",
|
|
@@ -8320,26 +8494,29 @@ export class Engine {
|
|
|
8320
8494
|
label: "selection edge shader",
|
|
8321
8495
|
code: SELECTION_EDGE_SHADER_WGSL,
|
|
8322
8496
|
})
|
|
8323
|
-
this.
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8497
|
+
this.initRenderPipeline(
|
|
8498
|
+
{
|
|
8499
|
+
label: "selection edge pipeline",
|
|
8500
|
+
layout: selectionEdgePipelineLayout,
|
|
8501
|
+
vertex: { module: selectionEdgeShaderModule, entryPoint: "vs" },
|
|
8502
|
+
fragment: {
|
|
8503
|
+
module: selectionEdgeShaderModule,
|
|
8504
|
+
entryPoint: "fs",
|
|
8505
|
+
targets: [
|
|
8506
|
+
{
|
|
8507
|
+
format: this.presentationFormat,
|
|
8508
|
+
blend: {
|
|
8509
|
+
color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
8510
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
8511
|
+
},
|
|
8336
8512
|
},
|
|
8337
|
-
|
|
8338
|
-
|
|
8513
|
+
],
|
|
8514
|
+
},
|
|
8515
|
+
primitive: { topology: "triangle-list" },
|
|
8516
|
+
multisample: { count: 1 },
|
|
8339
8517
|
},
|
|
8340
|
-
|
|
8341
|
-
|
|
8342
|
-
})
|
|
8518
|
+
(p) => (this.selectionEdgePipeline = p),
|
|
8519
|
+
)
|
|
8343
8520
|
this.selectionSampler = this.device.createSampler({
|
|
8344
8521
|
label: "selection sampler",
|
|
8345
8522
|
magFilter: "linear",
|
|
@@ -8359,10 +8536,7 @@ export class Engine {
|
|
|
8359
8536
|
// ─── Editor overlays (instanced wireframe primitives) ────────────
|
|
8360
8537
|
this.setupOverlay()
|
|
8361
8538
|
|
|
8362
|
-
// ─── Bloom
|
|
8363
|
-
// Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
|
|
8364
|
-
// Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
|
|
8365
|
-
// Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
|
|
8539
|
+
// ─── Bloom: Aether Gazer's chain (prefilter → Gaussian down chain → scatter up chain) ───
|
|
8366
8540
|
this.bloomSampler = this.device.createSampler({
|
|
8367
8541
|
label: "bloom sampler",
|
|
8368
8542
|
magFilter: "linear",
|
|
@@ -8370,88 +8544,26 @@ export class Engine {
|
|
|
8370
8544
|
addressModeU: "clamp-to-edge",
|
|
8371
8545
|
addressModeV: "clamp-to-edge",
|
|
8372
8546
|
})
|
|
8373
|
-
this.
|
|
8374
|
-
label: "bloom
|
|
8375
|
-
size: 16,
|
|
8376
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
8377
|
-
})
|
|
8378
|
-
this.bloomUpsampleUniformBuffer = this.device.createBuffer({
|
|
8379
|
-
label: "bloom upsample uniforms",
|
|
8380
|
-
size: 16,
|
|
8381
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
8382
|
-
})
|
|
8383
|
-
|
|
8384
|
-
this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
|
|
8385
|
-
label: "bloom blit layout",
|
|
8547
|
+
this.bloomPrefilterBindGroupLayout = this.device.createBindGroupLayout({
|
|
8548
|
+
label: "bloom prefilter layout",
|
|
8386
8549
|
entries: [
|
|
8387
8550
|
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
8388
8551
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
8389
8552
|
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
8390
8553
|
],
|
|
8391
8554
|
})
|
|
8392
|
-
this.
|
|
8393
|
-
label: "bloom
|
|
8555
|
+
this.bloomBlurBindGroupLayout = this.device.createBindGroupLayout({
|
|
8556
|
+
label: "bloom blur layout",
|
|
8394
8557
|
entries: [
|
|
8395
8558
|
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8396
8559
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
8397
8560
|
],
|
|
8398
8561
|
})
|
|
8399
|
-
this.
|
|
8400
|
-
label: "bloom upsample layout",
|
|
8401
|
-
entries: [
|
|
8402
|
-
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
|
|
8403
|
-
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
|
|
8404
|
-
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
8405
|
-
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
8406
|
-
],
|
|
8407
|
-
})
|
|
8408
|
-
|
|
8409
|
-
const bloomBlitShader = this.device.createShaderModule({
|
|
8410
|
-
label: "bloom blit (Karis prefilter)",
|
|
8411
|
-
code: BLOOM_BLIT_SHADER_WGSL,
|
|
8412
|
-
})
|
|
8413
|
-
|
|
8414
|
-
const bloomDownsampleShader = this.device.createShaderModule({
|
|
8415
|
-
label: "bloom downsample 13-tap",
|
|
8416
|
-
code: BLOOM_DOWNSAMPLE_SHADER_WGSL,
|
|
8417
|
-
})
|
|
8418
|
-
|
|
8419
|
-
const bloomUpsampleShader = this.device.createShaderModule({
|
|
8420
|
-
label: "bloom upsample 9-tap tent",
|
|
8421
|
-
code: BLOOM_UPSAMPLE_SHADER_WGSL,
|
|
8422
|
-
})
|
|
8562
|
+
this.ensureBloomPipelines()
|
|
8423
8563
|
|
|
8424
|
-
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
})
|
|
8428
|
-
const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] })
|
|
8429
|
-
|
|
8430
|
-
this.bloomBlitPipeline = this.device.createRenderPipeline({
|
|
8431
|
-
label: "bloom blit pipeline",
|
|
8432
|
-
layout: bloomBlitLayout,
|
|
8433
|
-
vertex: { module: bloomBlitShader, entryPoint: "vs" },
|
|
8434
|
-
fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
8435
|
-
primitive: { topology: "triangle-list" },
|
|
8436
|
-
})
|
|
8437
|
-
this.bloomDownsamplePipeline = this.device.createRenderPipeline({
|
|
8438
|
-
label: "bloom downsample pipeline",
|
|
8439
|
-
layout: bloomDownLayout,
|
|
8440
|
-
vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
|
|
8441
|
-
fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
8442
|
-
primitive: { topology: "triangle-list" },
|
|
8443
|
-
})
|
|
8444
|
-
this.bloomUpsamplePipeline = this.device.createRenderPipeline({
|
|
8445
|
-
label: "bloom upsample pipeline",
|
|
8446
|
-
layout: bloomUpLayout,
|
|
8447
|
-
vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
|
|
8448
|
-
fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
8449
|
-
primitive: { topology: "triangle-list" },
|
|
8450
|
-
})
|
|
8451
|
-
|
|
8452
|
-
// ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
|
|
8453
|
-
// Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
|
|
8454
|
-
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
8564
|
+
// ─── Composite: HDR + bloom → view transform → swapchain (premultiplied) ───
|
|
8565
|
+
// Bloom color/intensity applied HERE: the chain is pure energy, tint and
|
|
8566
|
+
// strength belong to the combine step.
|
|
8455
8567
|
this.compositeUniformBuffer = this.device.createBuffer({
|
|
8456
8568
|
label: "composite view uniforms",
|
|
8457
8569
|
// 15 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
|
|
@@ -8493,8 +8605,6 @@ export class Engine {
|
|
|
8493
8605
|
// Aux mask/alpha texture — composite reads .g to reconstruct the alpha that
|
|
8494
8606
|
// used to live in the HDR target before the rg11b10ufloat switch.
|
|
8495
8607
|
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8496
|
-
// Filmic tone LUT (r16float, filterable) — sampled with the binding-2 sampler.
|
|
8497
|
-
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8498
8608
|
// 360 backdrop equirect (PhotoDome-style skybox) — 1×1 fallback when unset.
|
|
8499
8609
|
{ binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8500
8610
|
// User background-effect params — dummy buffer when no effect is set. The
|
|
@@ -8509,9 +8619,6 @@ export class Engine {
|
|
|
8509
8619
|
texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
|
|
8510
8620
|
},
|
|
8511
8621
|
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
8512
|
-
// AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
|
|
8513
|
-
// 1×1×1 stand-in keeps the bind group valid until it arrives.
|
|
8514
|
-
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
|
|
8515
8622
|
// The scene's own grade cube (setStageGrade) — 1×1×1 stand-in while it
|
|
8516
8623
|
// has none, which the flag bit keeps from ever being sampled.
|
|
8517
8624
|
{ binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
|
|
@@ -8537,12 +8644,14 @@ export class Engine {
|
|
|
8537
8644
|
this.compositePipelineLayout = this.device.createPipelineLayout({
|
|
8538
8645
|
bindGroupLayouts: [this.compositeBindGroupLayout],
|
|
8539
8646
|
})
|
|
8540
|
-
|
|
8541
|
-
|
|
8542
|
-
|
|
8543
|
-
|
|
8544
|
-
|
|
8545
|
-
|
|
8647
|
+
// The same pair setEffects asks for with no field mounts, so an install
|
|
8648
|
+
// that adds none finds it built.
|
|
8649
|
+
this.initPipelineJobs?.push(
|
|
8650
|
+
this.compositePipelines(false, false).then(([identity, gamma]) => {
|
|
8651
|
+
this.compositePipelineIdentity = identity
|
|
8652
|
+
this.compositePipelineGamma = gamma
|
|
8653
|
+
}),
|
|
8654
|
+
)
|
|
8546
8655
|
|
|
8547
8656
|
// GPU vertex-morph compute pipeline (shared by all models; per-model bind groups).
|
|
8548
8657
|
// Bindings: 0-4 read-only storage (base pos, CSR rowStart/colMorph/colOffset, weights),
|
|
@@ -8560,14 +8669,17 @@ export class Engine {
|
|
|
8560
8669
|
{ binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
|
|
8561
8670
|
],
|
|
8562
8671
|
})
|
|
8563
|
-
this.
|
|
8564
|
-
|
|
8565
|
-
|
|
8566
|
-
|
|
8567
|
-
|
|
8568
|
-
|
|
8672
|
+
this.initComputePipeline(
|
|
8673
|
+
{
|
|
8674
|
+
label: "morph compute pipeline",
|
|
8675
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.morphComputeBindGroupLayout] }),
|
|
8676
|
+
compute: {
|
|
8677
|
+
module: this.device.createShaderModule({ label: "morph compute shader", code: MORPH_COMPUTE_WGSL }),
|
|
8678
|
+
entryPoint: "cs",
|
|
8679
|
+
},
|
|
8569
8680
|
},
|
|
8570
|
-
|
|
8681
|
+
(p) => (this.morphComputePipeline = p),
|
|
8682
|
+
)
|
|
8571
8683
|
|
|
8572
8684
|
// GPU frustum cull. One pipeline for the whole scene; the bind group is rebuilt
|
|
8573
8685
|
// with the buffers whenever the draw list changes. See shaders/passes/cull.ts.
|
|
@@ -8588,20 +8700,26 @@ export class Engine {
|
|
|
8588
8700
|
// encoder it is set on, so a WGSL slip here would take the whole frame down —
|
|
8589
8701
|
// every pass, every model, an unrelated-looking cascade of style-group and
|
|
8590
8702
|
// effect failures. Catching it turns that into "culling is off".
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8703
|
+
// Async, so a failure arrives as the rejection rather than a scoped error.
|
|
8704
|
+
const cullBuild = this.device
|
|
8705
|
+
.createComputePipelineAsync({
|
|
8706
|
+
label: "cull pipeline",
|
|
8707
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.cullBindGroupLayout] }),
|
|
8708
|
+
compute: {
|
|
8709
|
+
module: this.device.createShaderModule({ label: "cull compute shader", code: CULL_COMPUTE_WGSL }),
|
|
8710
|
+
entryPoint: "cs",
|
|
8711
|
+
},
|
|
8712
|
+
})
|
|
8713
|
+
.then(
|
|
8714
|
+
(p) => {
|
|
8715
|
+
this.cullPipeline = p
|
|
8716
|
+
},
|
|
8717
|
+
(err) => {
|
|
8718
|
+
console.error(`[cull] pipeline failed to compile — frustum culling disabled:\n${(err as Error).message}`)
|
|
8719
|
+
this.cullPipeline = null
|
|
8720
|
+
},
|
|
8721
|
+
)
|
|
8722
|
+
this.initPipelineJobs?.push(cullBuild)
|
|
8605
8723
|
|
|
8606
8724
|
this.bloomPassDescriptor = {
|
|
8607
8725
|
label: "bloom pass",
|
|
@@ -8648,21 +8766,24 @@ export class Engine {
|
|
|
8648
8766
|
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
|
|
8649
8767
|
})
|
|
8650
8768
|
|
|
8651
|
-
this.
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8660
|
-
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
|
|
8769
|
+
this.initRenderPipeline(
|
|
8770
|
+
{
|
|
8771
|
+
label: "pick pipeline",
|
|
8772
|
+
layout: pickPipelineLayout,
|
|
8773
|
+
vertex: { module: pickShaderModule, buffers: fullVertexBuffers },
|
|
8774
|
+
fragment: {
|
|
8775
|
+
module: pickShaderModule,
|
|
8776
|
+
targets: [{ format: "rgba8unorm" }],
|
|
8777
|
+
},
|
|
8778
|
+
primitive: { cullMode: "none" },
|
|
8779
|
+
depthStencil: {
|
|
8780
|
+
format: "depth24plus",
|
|
8781
|
+
depthWriteEnabled: true,
|
|
8782
|
+
depthCompare: this.depthAhead,
|
|
8783
|
+
},
|
|
8664
8784
|
},
|
|
8665
|
-
|
|
8785
|
+
(p) => (this.pickPipeline = p),
|
|
8786
|
+
)
|
|
8666
8787
|
|
|
8667
8788
|
this.pickReadbackBuffer = this.device.createBuffer({
|
|
8668
8789
|
label: "pick readback",
|
|
@@ -8739,6 +8860,9 @@ export class Engine {
|
|
|
8739
8860
|
// handleResize — so the size asked for before the device existed is applied
|
|
8740
8861
|
// in full the moment there is something to apply it to.
|
|
8741
8862
|
if (!this.device) return
|
|
8863
|
+
// Nor while init's pipelines are still compiling: init ends with its own
|
|
8864
|
+
// handleResize, which applies whatever was asked for meanwhile.
|
|
8865
|
+
if (this.initPipelineJobs) return
|
|
8742
8866
|
// Fixed override (offline/video rendering) wins; otherwise track CSS size × dpr.
|
|
8743
8867
|
const dpr = window.devicePixelRatio || 1
|
|
8744
8868
|
const width = this.fixedRenderSize ? this.fixedRenderSize.width : Math.floor(this.canvas.clientWidth * dpr)
|
|
@@ -8963,38 +9087,6 @@ export class Engine {
|
|
|
8963
9087
|
this.buildGroundBindGroup()
|
|
8964
9088
|
this.buildMirrorSurfaceBindGroup()
|
|
8965
9089
|
|
|
8966
|
-
// Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
|
|
8967
|
-
// Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
|
|
8968
|
-
const bw = Math.max(1, Math.floor(width / 2))
|
|
8969
|
-
const bh = Math.max(1, Math.floor(height / 2))
|
|
8970
|
-
const shortSide = Math.max(1, Math.min(bw, bh))
|
|
8971
|
-
this.bloomMipCount = Math.max(1, Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1))
|
|
8972
|
-
this.bloomDownTexture?.destroy()
|
|
8973
|
-
this.bloomDownTexture = this.device.createTexture({
|
|
8974
|
-
label: "bloom down pyramid",
|
|
8975
|
-
size: [bw, bh],
|
|
8976
|
-
mipLevelCount: this.bloomMipCount,
|
|
8977
|
-
format: this.hdrFormat,
|
|
8978
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
8979
|
-
})
|
|
8980
|
-
this.bloomUpTexture?.destroy()
|
|
8981
|
-
this.bloomUpTexture = this.device.createTexture({
|
|
8982
|
-
label: "bloom up pyramid",
|
|
8983
|
-
size: [bw, bh],
|
|
8984
|
-
mipLevelCount: Math.max(1, this.bloomMipCount - 1),
|
|
8985
|
-
format: this.hdrFormat,
|
|
8986
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
8987
|
-
})
|
|
8988
|
-
this.bloomDownMipViews = []
|
|
8989
|
-
for (let i = 0; i < this.bloomMipCount; i++) {
|
|
8990
|
-
this.bloomDownMipViews.push(this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }))
|
|
8991
|
-
}
|
|
8992
|
-
this.bloomUpMipViews = []
|
|
8993
|
-
const upLevels = Math.max(1, this.bloomMipCount - 1)
|
|
8994
|
-
for (let i = 0; i < upLevels; i++) {
|
|
8995
|
-
this.bloomUpMipViews.push(this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }))
|
|
8996
|
-
}
|
|
8997
|
-
|
|
8998
9090
|
this.depthTexture?.destroy()
|
|
8999
9091
|
this.depthTexture = this.device.createTexture({
|
|
9000
9092
|
label: "depth texture",
|
|
@@ -9108,60 +9200,16 @@ export class Engine {
|
|
|
9108
9200
|
colorAttachments: [
|
|
9109
9201
|
{
|
|
9110
9202
|
view: undefined as unknown as GPUTextureView,
|
|
9111
|
-
loadOp: "load",
|
|
9112
|
-
storeOp: "store",
|
|
9113
|
-
},
|
|
9114
|
-
],
|
|
9115
|
-
}
|
|
9116
|
-
|
|
9117
|
-
this.
|
|
9118
|
-
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
this.bloomBlitBindGroup = this.device.createBindGroup({
|
|
9122
|
-
label: "bloom blit bind group",
|
|
9123
|
-
layout: this.bloomBlitBindGroupLayout,
|
|
9124
|
-
entries: [
|
|
9125
|
-
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
9126
|
-
{ binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
|
|
9127
|
-
{ binding: 2, resource: this.maskResolveView },
|
|
9128
|
-
],
|
|
9129
|
-
})
|
|
9130
|
-
// Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
|
|
9131
|
-
this.bloomDownsampleBindGroups = []
|
|
9132
|
-
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
9133
|
-
this.bloomDownsampleBindGroups.push(
|
|
9134
|
-
this.device.createBindGroup({
|
|
9135
|
-
label: `bloom downsample ${i}`,
|
|
9136
|
-
layout: this.bloomDownsampleBindGroupLayout,
|
|
9137
|
-
entries: [
|
|
9138
|
-
{ binding: 0, resource: this.bloomDownMipViews[i - 1] },
|
|
9139
|
-
{ binding: 1, resource: this.bloomSampler },
|
|
9140
|
-
],
|
|
9141
|
-
}),
|
|
9142
|
-
)
|
|
9143
|
-
}
|
|
9144
|
-
// Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
|
|
9145
|
-
// subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
|
|
9146
|
-
this.bloomUpsampleBindGroups = []
|
|
9147
|
-
const topIdx = this.bloomMipCount - 2
|
|
9148
|
-
for (let i = topIdx; i >= 0; i--) {
|
|
9149
|
-
const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1]
|
|
9150
|
-
this.bloomUpsampleBindGroups.push(
|
|
9151
|
-
this.device.createBindGroup({
|
|
9152
|
-
label: `bloom upsample ${i}`,
|
|
9153
|
-
layout: this.bloomUpsampleBindGroupLayout,
|
|
9154
|
-
entries: [
|
|
9155
|
-
{ binding: 0, resource: srcView },
|
|
9156
|
-
{ binding: 1, resource: this.bloomDownMipViews[i] },
|
|
9157
|
-
{ binding: 2, resource: this.bloomSampler },
|
|
9158
|
-
{ binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
|
|
9159
|
-
],
|
|
9160
|
-
}),
|
|
9161
|
-
)
|
|
9162
|
-
}
|
|
9163
|
-
// Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
|
|
9164
|
-
this.compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0]
|
|
9203
|
+
loadOp: "load",
|
|
9204
|
+
storeOp: "store",
|
|
9205
|
+
},
|
|
9206
|
+
],
|
|
9207
|
+
}
|
|
9208
|
+
|
|
9209
|
+
if (this.compositeBindGroupLayout && this.bloomPrefilterBindGroupLayout) {
|
|
9210
|
+
this.buildBloomTargets()
|
|
9211
|
+
// The composite reads the finished chain: up[0], or down[0] when there is one level.
|
|
9212
|
+
this.compositeBloomView = this.bloomLevels > 1 ? this.bloomUpViews[0] : this.bloomDownViews[0]
|
|
9165
9213
|
this.rebuildCompositeBindGroup()
|
|
9166
9214
|
}
|
|
9167
9215
|
|
|
@@ -9436,17 +9484,20 @@ export class Engine {
|
|
|
9436
9484
|
},
|
|
9437
9485
|
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
9438
9486
|
} satisfies GPURenderPipelineDescriptor
|
|
9439
|
-
this.overlayPipeline =
|
|
9487
|
+
this.initRenderPipeline(overlayPipelineDescriptor, (p) => (this.overlayPipeline = p))
|
|
9440
9488
|
|
|
9441
9489
|
// The solid volumes: the same shader and layout, with no depth write and no
|
|
9442
9490
|
// culling. A translucent body must not hide the rig behind it, and you have
|
|
9443
9491
|
// to see its far wall for it to read as a volume rather than a silhouette.
|
|
9444
|
-
this.
|
|
9445
|
-
|
|
9446
|
-
|
|
9447
|
-
|
|
9448
|
-
|
|
9449
|
-
|
|
9492
|
+
this.initRenderPipeline(
|
|
9493
|
+
{
|
|
9494
|
+
...overlayPipelineDescriptor,
|
|
9495
|
+
label: "overlay solid pipeline",
|
|
9496
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
9497
|
+
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
|
|
9498
|
+
},
|
|
9499
|
+
(p) => (this.overlaySolidPipeline = p),
|
|
9500
|
+
)
|
|
9450
9501
|
|
|
9451
9502
|
const compositeShader = this.device.createShaderModule({
|
|
9452
9503
|
label: "overlay composite shader",
|
|
@@ -9537,34 +9588,37 @@ export class Engine {
|
|
|
9537
9588
|
],
|
|
9538
9589
|
})
|
|
9539
9590
|
const wireShader = this.device.createShaderModule({ label: "wireframe shader", code: WIREFRAME_SHADER_WGSL })
|
|
9540
|
-
this.
|
|
9541
|
-
|
|
9542
|
-
|
|
9543
|
-
|
|
9544
|
-
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
|
|
9553
|
-
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
|
-
|
|
9557
|
-
|
|
9591
|
+
this.initRenderPipeline(
|
|
9592
|
+
{
|
|
9593
|
+
label: "wireframe pipeline",
|
|
9594
|
+
layout: this.device.createPipelineLayout({
|
|
9595
|
+
label: "wireframe pipeline layout",
|
|
9596
|
+
bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
|
|
9597
|
+
}),
|
|
9598
|
+
// No vertex stream: an edge quad's corners come from two different model
|
|
9599
|
+
// vertices, so the mesh is read through storage instead.
|
|
9600
|
+
vertex: { module: wireShader, entryPoint: "vs" },
|
|
9601
|
+
fragment: {
|
|
9602
|
+
module: wireShader,
|
|
9603
|
+
entryPoint: "fs",
|
|
9604
|
+
targets: [
|
|
9605
|
+
{
|
|
9606
|
+
format: this.presentationFormat,
|
|
9607
|
+
blend: {
|
|
9608
|
+
color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
9609
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
9610
|
+
},
|
|
9558
9611
|
},
|
|
9559
|
-
|
|
9560
|
-
|
|
9612
|
+
],
|
|
9613
|
+
},
|
|
9614
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
9615
|
+
// Depth-TESTED but not written: the mesh is a haze the rig reads against,
|
|
9616
|
+
// so a bone behind a triangle must not be punched out by it.
|
|
9617
|
+
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: this.depthAhead },
|
|
9618
|
+
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
9561
9619
|
},
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
// so a bone behind a triangle must not be punched out by it.
|
|
9565
|
-
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: this.depthAhead },
|
|
9566
|
-
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
9567
|
-
})
|
|
9620
|
+
(p) => (this.wireframePipeline = p),
|
|
9621
|
+
)
|
|
9568
9622
|
// The mesh's own depth, so the wireframe can be occluded by the body it
|
|
9569
9623
|
// belongs to. Occluded is the default everywhere — Blender's edit mode, Maya,
|
|
9570
9624
|
// three's and Babylon's wireframe materials all depth-test, and X-ray is a
|
|
@@ -10347,7 +10401,7 @@ export class Engine {
|
|
|
10347
10401
|
if (
|
|
10348
10402
|
!this.device ||
|
|
10349
10403
|
!this.mainPerFrameBindGroupLayout ||
|
|
10350
|
-
!this.
|
|
10404
|
+
!this.shadowAtlasView ||
|
|
10351
10405
|
!this.cameraUniformBuffer ||
|
|
10352
10406
|
!this.mirrorCameraBuffer ||
|
|
10353
10407
|
!this.lightUniformBuffer ||
|
|
@@ -10373,11 +10427,10 @@ export class Engine {
|
|
|
10373
10427
|
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
10374
10428
|
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
10375
10429
|
{ binding: 2, resource: this.materialSampler },
|
|
10376
|
-
{ binding: 3, resource: this.
|
|
10430
|
+
{ binding: 3, resource: this.shadowAtlasView },
|
|
10377
10431
|
{ binding: 4, resource: this.shadowComparisonSampler },
|
|
10378
10432
|
{ binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
|
|
10379
10433
|
{ binding: 6, resource: { buffer: this.lightsBuffer } },
|
|
10380
|
-
{ binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
|
|
10381
10434
|
{ binding: 8, resource: env },
|
|
10382
10435
|
{ binding: 9, resource: this.brdfLutView },
|
|
10383
10436
|
{ binding: 10, resource: this.castShadowView },
|
|
@@ -10390,11 +10443,10 @@ export class Engine {
|
|
|
10390
10443
|
{ binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
|
|
10391
10444
|
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
10392
10445
|
{ binding: 2, resource: this.materialSampler },
|
|
10393
|
-
{ binding: 3, resource: this.
|
|
10446
|
+
{ binding: 3, resource: this.shadowAtlasView },
|
|
10394
10447
|
{ binding: 4, resource: this.shadowComparisonSampler },
|
|
10395
10448
|
{ binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
|
|
10396
10449
|
{ binding: 6, resource: { buffer: this.lightsBuffer } },
|
|
10397
|
-
{ binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
|
|
10398
10450
|
{ binding: 8, resource: env },
|
|
10399
10451
|
{ binding: 9, resource: this.brdfLutView },
|
|
10400
10452
|
{ binding: 10, resource: this.castShadowView },
|
|
@@ -10460,6 +10512,7 @@ export class Engine {
|
|
|
10460
10512
|
this.lightData[base + 5] = this.sun.color.y
|
|
10461
10513
|
this.lightData[base + 6] = this.sun.color.z
|
|
10462
10514
|
this.lightData[base + 7] = this.sun.strength
|
|
10515
|
+
this.lightDataWords[DIR_LAYERS_AT + index] = this.sunLayers
|
|
10463
10516
|
if (index >= this.lightCount) this.lightCount = index + 1
|
|
10464
10517
|
this.updateLightBuffer()
|
|
10465
10518
|
}
|
|
@@ -10594,6 +10647,7 @@ export class Engine {
|
|
|
10594
10647
|
if (options.color) this.sun.color = options.color
|
|
10595
10648
|
if (options.strength !== undefined) this.sun.strength = options.strength
|
|
10596
10649
|
if (options.shadow !== undefined) this.sunShadow = Math.min(Math.max(options.shadow, 0), 1)
|
|
10650
|
+
if (options.layers !== undefined) this.sunLayers = options.layers >>> 0
|
|
10597
10651
|
if (options.direction) {
|
|
10598
10652
|
this.sun.direction = options.direction
|
|
10599
10653
|
this.shadowLightVPDirty = true
|
|
@@ -10728,7 +10782,23 @@ export class Engine {
|
|
|
10728
10782
|
* literal typed into a console. Vec3 satisfies it either way. */
|
|
10729
10783
|
lights: SceneLight[] | null,
|
|
10730
10784
|
): void {
|
|
10731
|
-
|
|
10785
|
+
// The directional ones go to the light uniform's slots beside the sun's —
|
|
10786
|
+
// three of them, in order; the rest are records the grid indexes.
|
|
10787
|
+
const directional = (lights ?? []).filter((l) => l.kind === "directional")
|
|
10788
|
+
for (let k = 1; k < 4; k++) {
|
|
10789
|
+
const l = directional[k - 1]
|
|
10790
|
+
const base = 4 + k * 8
|
|
10791
|
+
const a = l?.aim
|
|
10792
|
+
const len = a ? Math.hypot(a.x, a.y, a.z) : 0
|
|
10793
|
+
const i = l ? Math.max(l.intensity ?? 1, 0) : 0
|
|
10794
|
+
this.lightData.set(
|
|
10795
|
+
l && len > 0 ? [a!.x / len, a!.y / len, a!.z / len, 0, l.color.x * i, l.color.y * i, l.color.z * i, 1] : [0, 0, -1, 0, 0, 0, 0, 0],
|
|
10796
|
+
base,
|
|
10797
|
+
)
|
|
10798
|
+
this.lightDataWords[DIR_LAYERS_AT + k] = l && len > 0 ? (l.layers ?? ALL_LAYERS) >>> 0 : 0
|
|
10799
|
+
}
|
|
10800
|
+
this.updateLightBuffer()
|
|
10801
|
+
const list = (lights ?? []).filter((l) => l.kind !== "directional").slice(0, MAX_LIGHTS)
|
|
10732
10802
|
this.docLightCount = list.length
|
|
10733
10803
|
// RECORDS only — the header belongs to allocateLightSlots, the one writer.
|
|
10734
10804
|
// This used to zero-and-upload the header region too, which left two CPU
|
|
@@ -10757,7 +10827,7 @@ export class Engine {
|
|
|
10757
10827
|
// vector cannot be normalised, and a cone around nothing would light
|
|
10758
10828
|
// nothing at all — a light that vanishes because its aim was left at the
|
|
10759
10829
|
// default is worse than one that shines everywhere.
|
|
10760
|
-
const len = l.aim ? Math.hypot(l.aim.x, l.aim.y, l.aim.z) : 0
|
|
10830
|
+
const len = l.aim && l.kind !== "point" ? Math.hypot(l.aim.x, l.aim.y, l.aim.z) : 0
|
|
10761
10831
|
this.lightsData[b + 7] = len > 0 ? 1 : 0
|
|
10762
10832
|
this.lightsData[b + 8] = len > 0 ? l.aim!.x / len : 0
|
|
10763
10833
|
this.lightsData[b + 9] = len > 0 ? l.aim!.y / len : 0
|
|
@@ -10769,7 +10839,8 @@ export class Engine {
|
|
|
10769
10839
|
const inner = len > 0 ? Math.cos((Math.min(Math.max(l.innerAngle ?? (l.angle ?? 45) * 0.8, 0), 179) / 2) * (Math.PI / 180)) : -1
|
|
10770
10840
|
this.lightsData[b + 11] = outer
|
|
10771
10841
|
this.lightsData[b + 12] = Math.max(inner, outer)
|
|
10772
|
-
|
|
10842
|
+
// The layers it does NOT reach, as bits — inverted so a zero reaches all.
|
|
10843
|
+
this.lightsWords[b + 13] = ~(l.layers ?? ALL_LAYERS) >>> 0
|
|
10773
10844
|
this.lightsData[b + 14] = 0
|
|
10774
10845
|
this.lightsData[b + 15] = 0
|
|
10775
10846
|
}
|
|
@@ -11252,6 +11323,7 @@ export class Engine {
|
|
|
11252
11323
|
* ground plane must not draw, and the far shadow cascade has nothing to
|
|
11253
11324
|
* cover without one (see the cascade loop). */
|
|
11254
11325
|
hasStage(): boolean {
|
|
11326
|
+
if (this.nativeStage) return true
|
|
11255
11327
|
for (const inst of this.modelInstances.values()) if (inst.isStage) return true
|
|
11256
11328
|
return false
|
|
11257
11329
|
}
|
|
@@ -11281,11 +11353,6 @@ export class Engine {
|
|
|
11281
11353
|
this.groundHidden = !on
|
|
11282
11354
|
}
|
|
11283
11355
|
|
|
11284
|
-
/** Per cascade: does its map currently hold nothing but the cleared far plane?
|
|
11285
|
-
* Set by the cascade loop, which skips a cascade that is unwanted and already
|
|
11286
|
-
* cleared rather than re-clearing it every frame. */
|
|
11287
|
-
private shadowCascadeCleared: boolean[] = []
|
|
11288
|
-
|
|
11289
11356
|
/**
|
|
11290
11357
|
* Moves a model to a new key. Nothing about it is rebuilt — same GPU
|
|
11291
11358
|
* buffers, same style groups, same physics — only which string the rest
|
|
@@ -11306,11 +11373,32 @@ export class Engine {
|
|
|
11306
11373
|
this.modelInstances.set(newName, inst)
|
|
11307
11374
|
}
|
|
11308
11375
|
|
|
11376
|
+
/**
|
|
11377
|
+
* One model's own ambient: the irradiance arriving at it, as the 27 floats of
|
|
11378
|
+
* folded SH setWorldAmbient takes (ibl.ts), in place of the world's. Null
|
|
11379
|
+
* gives it the world's again.
|
|
11380
|
+
*
|
|
11381
|
+
* The game's pipeline lights every renderer by its own probe sample, and a
|
|
11382
|
+
* character by an ambient of its own; a loader that carries those hands them
|
|
11383
|
+
* in here. Linear radiance, strength included.
|
|
11384
|
+
*/
|
|
11385
|
+
setModelAmbient(name: string, sh: ArrayLike<number> | null): boolean {
|
|
11386
|
+
const inst = this.modelInstances.get(name)
|
|
11387
|
+
if (!inst || !this.device) return false
|
|
11388
|
+
const o = inst.objectLight
|
|
11389
|
+
o[4] = sh && sh.length >= 27 ? 1 : 0
|
|
11390
|
+
for (let i = 0; i < 9; i++) {
|
|
11391
|
+
o.set(sh && sh.length >= 27 ? [sh[i * 3], sh[i * 3 + 1], sh[i * 3 + 2], 0] : [0, 0, 0, 0], 8 + i * 4)
|
|
11392
|
+
}
|
|
11393
|
+
this.device.queue.writeBuffer(inst.lightBuffer, 0, o)
|
|
11394
|
+
return true
|
|
11395
|
+
}
|
|
11396
|
+
|
|
11309
11397
|
/**
|
|
11310
11398
|
* Light one model with more than the world gives it: a FILL, linear RGB times
|
|
11311
11399
|
* its surface colour, added after its material graph. Null takes it away again.
|
|
11312
11400
|
*
|
|
11313
|
-
* After the graph, not in its ambient: an NPR ramp
|
|
11401
|
+
* After the graph, not in its ambient: an NPR ramp reads an ambient fill as
|
|
11314
11402
|
* light, so sliding it moved her shadows and flipped whole regions across a
|
|
11315
11403
|
* hard step. Added after, it brightens her evenly and every shadow stays put.
|
|
11316
11404
|
*
|
|
@@ -11318,85 +11406,25 @@ export class Engine {
|
|
|
11318
11406
|
* lit by their lamps and a near-black ambient, while its characters take a
|
|
11319
11407
|
* flat base light of their own — so a stage whose World is right for the room
|
|
11320
11408
|
* leaves a face turned from the lamps in the dark. This is that second light,
|
|
11321
|
-
* for the models the host counts as cast.
|
|
11322
|
-
|
|
11323
|
-
setModelFill(name: string, fill: Vec3 | null): boolean {
|
|
11324
|
-
return this.writeModelLight(name, 0, fill)
|
|
11325
|
-
}
|
|
11326
|
-
|
|
11327
|
-
/**
|
|
11328
|
-
* A model's own sun: the colour and strength the scene's sun has FOR THIS
|
|
11329
|
-
* MODEL, in place of the scene's. Null gives it the scene's sun back.
|
|
11330
|
-
*
|
|
11331
|
-
* The other half of lighting a stage and its cast apart. A game's stage is
|
|
11332
|
-
* lit by its own daylight — Aether Gazer's kitchen at twenty times white —
|
|
11333
|
-
* while its characters take a key light of their own that the stage never
|
|
11334
|
-
* states. So a stage carries the sun it was lit by, and the scene's sun
|
|
11335
|
-
* stays the cast's. Direction and shadow are still the scene's: one sun
|
|
11336
|
-
* casts, and it casts the same way on both.
|
|
11337
|
-
*/
|
|
11338
|
-
setModelSun(name: string, sun: Vec3 | null): boolean {
|
|
11339
|
-
return this.writeModelLight(name, 4, sun)
|
|
11340
|
-
}
|
|
11341
|
-
|
|
11342
|
-
/**
|
|
11343
|
-
* Light one model by the sky's AVERAGE rather than its shape: the world's
|
|
11344
|
-
* colour and brightness from every direction alike. False gives it the
|
|
11345
|
-
* directional sky back.
|
|
11346
|
-
*
|
|
11347
|
-
* For a cast under an anime look. An HDRI's sky is brighter and warmer one
|
|
11348
|
-
* way than another, and taken at each normal it shades a face with soft
|
|
11349
|
-
* realistic gradients that no toon ramp drew — PBR on an anime face. Aether
|
|
11350
|
-
* Gazer gives its characters one flat base light instead. World strength and
|
|
11351
|
-
* colour still reach her; only the shape is gone. A flat World is its own
|
|
11352
|
-
* average, so without an HDRI this changes nothing.
|
|
11409
|
+
* for the models the host counts as cast. A model drawn by the game's own
|
|
11410
|
+
* shaders (src/unity) lights itself and takes none of it.
|
|
11353
11411
|
*/
|
|
11354
|
-
|
|
11355
|
-
return this.writeModelLight(name, 8, flat ? new Vec3(1, 0, 0) : null)
|
|
11356
|
-
}
|
|
11357
|
-
|
|
11358
|
-
/** One part of a model's light, at `at` (0 the fill, 4 the sun, 8 its
|
|
11359
|
-
* options) in its ModelLight; w says whether that part is set. All unset
|
|
11360
|
-
* releases the buffer and the model takes the shared zero stand-in again. */
|
|
11361
|
-
private writeModelLight(name: string, at: 0 | 4 | 8, value: Vec3 | null): boolean {
|
|
11412
|
+
setModelFill(name: string, fill: { x: number; y: number; z: number } | null): boolean {
|
|
11362
11413
|
const inst = this.modelInstances.get(name)
|
|
11363
11414
|
if (!inst || !this.device) return false
|
|
11364
|
-
|
|
11365
|
-
|
|
11366
|
-
const any = light[3] > 0 || light[7] > 0 || light[11] > 0
|
|
11367
|
-
if (!any) {
|
|
11368
|
-
if (!inst.lightBuffer) return true
|
|
11369
|
-
const retired = inst.lightBuffer
|
|
11370
|
-
inst.lightBuffer = null
|
|
11371
|
-
inst.modelLight = null
|
|
11372
|
-
inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, null)
|
|
11373
|
-
this.bundlesDirty = true
|
|
11374
|
-
// Retired once the GPU is done with it: an encoded frame may still name it.
|
|
11375
|
-
void this.device.queue.onSubmittedWorkDone().then(() => retired.destroy())
|
|
11376
|
-
return true
|
|
11377
|
-
}
|
|
11378
|
-
inst.modelLight = light
|
|
11379
|
-
if (!inst.lightBuffer) {
|
|
11380
|
-
inst.lightBuffer = this.device.createBuffer({
|
|
11381
|
-
label: `${name}: light`,
|
|
11382
|
-
size: 48,
|
|
11383
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
11384
|
-
})
|
|
11385
|
-
inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, inst.lightBuffer)
|
|
11386
|
-
this.bundlesDirty = true
|
|
11387
|
-
}
|
|
11388
|
-
this.device.queue.writeBuffer(inst.lightBuffer, 0, light.buffer as ArrayBuffer)
|
|
11415
|
+
inst.objectLight.set(fill ? [fill.x, fill.y, fill.z, 0] : [0, 0, 0, 0], 44)
|
|
11416
|
+
this.device.queue.writeBuffer(inst.lightBuffer, 0, inst.objectLight)
|
|
11389
11417
|
return true
|
|
11390
11418
|
}
|
|
11391
11419
|
|
|
11392
|
-
/** The per-model group: its skinning matrices and its
|
|
11393
|
-
private perInstanceBindGroup(name: string, skinMatrixBuffer: GPUBuffer, light: GPUBuffer
|
|
11420
|
+
/** The per-model group: its skinning matrices and its ObjectLight. */
|
|
11421
|
+
private perInstanceBindGroup(name: string, skinMatrixBuffer: GPUBuffer, light: GPUBuffer): GPUBindGroup {
|
|
11394
11422
|
return this.device.createBindGroup({
|
|
11395
11423
|
label: `${name}: main per-instance bind group`,
|
|
11396
11424
|
layout: this.mainPerInstanceBindGroupLayout,
|
|
11397
11425
|
entries: [
|
|
11398
11426
|
{ binding: 0, resource: { buffer: skinMatrixBuffer } },
|
|
11399
|
-
{ binding: 1, resource: { buffer: light
|
|
11427
|
+
{ binding: 1, resource: { buffer: light } },
|
|
11400
11428
|
],
|
|
11401
11429
|
})
|
|
11402
11430
|
}
|
|
@@ -11407,6 +11435,9 @@ export class Engine {
|
|
|
11407
11435
|
// Before the texture cache below frees it: a stale entry here would hand a
|
|
11408
11436
|
// destroyed texture to the next setPlaneFrame.
|
|
11409
11437
|
this.planeTextures.delete(name)
|
|
11438
|
+
// Its native look too: it skins from this model's buffers, which are
|
|
11439
|
+
// destroyed below, and would otherwise go on drawing from them.
|
|
11440
|
+
this.nativeLooks?.remove(name)
|
|
11410
11441
|
inst.model.stop()
|
|
11411
11442
|
for (const path of inst.textureCacheKeys) {
|
|
11412
11443
|
const tex = this.textureCache.get(path)
|
|
@@ -13081,8 +13112,10 @@ export class Engine {
|
|
|
13081
13112
|
let changed = force
|
|
13082
13113
|
for (let i = 0; i < this.cullDraws.length; i++) {
|
|
13083
13114
|
const { inst, draw } = this.cullDraws[i]
|
|
13115
|
+
// bit 0: switched off; bit 1: drawn by its native look, not its graph
|
|
13084
13116
|
const v =
|
|
13085
|
-
inst.hiddenMaterials.has(draw.materialName) || inst.morphHiddenMaterials.has(draw.materialName) ? 1 : 0
|
|
13117
|
+
(inst.hiddenMaterials.has(draw.materialName) || inst.morphHiddenMaterials.has(draw.materialName) ? 1 : 0) |
|
|
13118
|
+
(this.nativeLooks?.dresses(inst.name, draw.materialName) ? 2 : 0)
|
|
13086
13119
|
if (out[i] !== v) {
|
|
13087
13120
|
out[i] = v
|
|
13088
13121
|
changed = true
|
|
@@ -13220,6 +13253,12 @@ export class Engine {
|
|
|
13220
13253
|
minZ = Math.min(minZ, data[o + 18] - r); maxZ = Math.max(maxZ, data[o + 18] + r)
|
|
13221
13254
|
}
|
|
13222
13255
|
}
|
|
13256
|
+
if (this.nativeStage) {
|
|
13257
|
+
const nb = this.nativeStage.bounds
|
|
13258
|
+
minX = Math.min(minX, nb.min[0]); maxX = Math.max(maxX, nb.max[0])
|
|
13259
|
+
minY = Math.min(minY, nb.min[1]); maxY = Math.max(maxY, nb.max[1])
|
|
13260
|
+
minZ = Math.min(minZ, nb.min[2]); maxZ = Math.max(maxZ, nb.max[2])
|
|
13261
|
+
}
|
|
13223
13262
|
if (!Number.isFinite(minX)) {
|
|
13224
13263
|
this.shadowSceneBounds = null
|
|
13225
13264
|
return
|
|
@@ -14067,7 +14106,20 @@ export class Engine {
|
|
|
14067
14106
|
}),
|
|
14068
14107
|
)
|
|
14069
14108
|
|
|
14070
|
-
|
|
14109
|
+
// Its ObjectLight: the world's ambient, and the layers its kind draws on —
|
|
14110
|
+
// the cast on the character layer as well as the default one. Words:
|
|
14111
|
+
// layers [0..3], ambient flag [4..7], SH [8..43], fill [44..47] — see
|
|
14112
|
+
// materials/common.ts.
|
|
14113
|
+
const objectLight = new Float32Array(48)
|
|
14114
|
+
new Uint32Array(objectLight.buffer)[0] =
|
|
14115
|
+
isStage || isPlane || isProp ? RENDERING_LAYER_DEFAULT : (RENDERING_LAYER_DEFAULT | RENDERING_LAYER_CHARACTER) >>> 0
|
|
14116
|
+
const lightBuffer = this.device.createBuffer({
|
|
14117
|
+
label: `${name}: object light`,
|
|
14118
|
+
size: objectLight.byteLength,
|
|
14119
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
14120
|
+
})
|
|
14121
|
+
this.device.queue.writeBuffer(lightBuffer, 0, objectLight)
|
|
14122
|
+
const mainPerInstanceBindGroup = this.perInstanceBindGroup(name, skinMatrixBuffer, lightBuffer)
|
|
14071
14123
|
|
|
14072
14124
|
const pickPerInstanceBindGroup = this.device.createBindGroup({
|
|
14073
14125
|
label: `${name}: pick per-instance bind group`,
|
|
@@ -14106,8 +14158,8 @@ export class Engine {
|
|
|
14106
14158
|
shadowDrawCalls: [],
|
|
14107
14159
|
shadowBindGroups,
|
|
14108
14160
|
mainPerInstanceBindGroup,
|
|
14109
|
-
lightBuffer
|
|
14110
|
-
|
|
14161
|
+
lightBuffer,
|
|
14162
|
+
objectLight,
|
|
14111
14163
|
pickPerInstanceBindGroup,
|
|
14112
14164
|
pickDrawCalls: [],
|
|
14113
14165
|
isStage,
|
|
@@ -14295,7 +14347,11 @@ export class Engine {
|
|
|
14295
14347
|
private groundShadowPipelineDesc!: Omit<Parameters<Engine["createRenderPipeline"]>[0], "shaderModule">
|
|
14296
14348
|
|
|
14297
14349
|
private buildGroundPipeline(soft: boolean, mirrored = false): GPURenderPipeline {
|
|
14298
|
-
return this.createRenderPipeline(
|
|
14350
|
+
return this.createRenderPipeline(this.groundPipelineConfig(soft, mirrored))
|
|
14351
|
+
}
|
|
14352
|
+
|
|
14353
|
+
private groundPipelineConfig(soft: boolean, mirrored = false): Parameters<Engine["renderPipelineDesc"]>[0] {
|
|
14354
|
+
return {
|
|
14299
14355
|
...this.groundShadowPipelineDesc,
|
|
14300
14356
|
// A REFLECTION FLIPS WINDING — determinant -1, see reflection.ts. The
|
|
14301
14357
|
// ground culls back faces, so drawn into the mirror pass with the ordinary
|
|
@@ -14305,11 +14361,8 @@ export class Engine {
|
|
|
14305
14361
|
// the mirror, and the same answer the scene-pass pipelines already use.
|
|
14306
14362
|
cullMode: mirrored ? "none" : this.groundShadowPipelineDesc.cullMode,
|
|
14307
14363
|
label: `ground shadow pipeline${soft ? " (soft)" : ""}${mirrored ? " (mirror)" : ""}`,
|
|
14308
|
-
shaderModule: this.
|
|
14309
|
-
|
|
14310
|
-
code: groundShaderWgsl(soft),
|
|
14311
|
-
}),
|
|
14312
|
-
})
|
|
14364
|
+
shaderModule: this.cachedShaderModule(groundShaderWgsl(soft), soft ? "ground shadow (soft)" : "ground shadow"),
|
|
14365
|
+
}
|
|
14313
14366
|
}
|
|
14314
14367
|
|
|
14315
14368
|
private groundMirrorPipeline: GPURenderPipeline | null = null
|
|
@@ -14378,7 +14431,6 @@ export class Engine {
|
|
|
14378
14431
|
gb[3] = fadeStart
|
|
14379
14432
|
gb[4] = fadeEnd
|
|
14380
14433
|
gb[5] = shadowStrength
|
|
14381
|
-
gb[6] = 1 / SHADOW_CASCADES[0].mapSize
|
|
14382
14434
|
gb[7] = gridSpacing
|
|
14383
14435
|
gb[8] = gridLineWidth
|
|
14384
14436
|
gb[9] = gridLineOpacity
|
|
@@ -14400,19 +14452,6 @@ export class Engine {
|
|
|
14400
14452
|
// Which variant the draw picks. Zero is the sharp shader, which is the one
|
|
14401
14453
|
// that existed before softness did.
|
|
14402
14454
|
this.groundSoft = gb[18] > 0
|
|
14403
|
-
// gb[17] — does the FAR cascade hold anything?
|
|
14404
|
-
//
|
|
14405
|
-
// It holds something only when a stage is loaded; that is what it exists for
|
|
14406
|
-
// and the cascade loop already skips drawing into it otherwise, leaving it
|
|
14407
|
-
// cleared. A cleared depth map compares as "no occluder", so the ground's far
|
|
14408
|
-
// branch is nine comparison taps whose answer is known in advance.
|
|
14409
|
-
//
|
|
14410
|
-
// That branch runs wherever the NEAR cascade does not reach, and the near one
|
|
14411
|
-
// is a 64-unit box around the camera target — so on a floor receding to the
|
|
14412
|
-
// horizon it is most of the visible pixels, on the most expensive
|
|
14413
|
-
// full-coverage draw in the frame. Skipping it is free in the exact sense:
|
|
14414
|
-
// the shader takes vis = 1.0, which is what the taps would have returned.
|
|
14415
|
-
gb[17] = this.hasStage() ? 1 : 0
|
|
14416
14455
|
// gb[20..23] — the caster sphere, refreshed every frame by
|
|
14417
14456
|
// writeGroundCasterSphere. Zero here so a frame that renders before the
|
|
14418
14457
|
// first cull (there is one) reads "nothing casts" and skips the taps, which
|
|
@@ -14477,12 +14516,11 @@ export class Engine {
|
|
|
14477
14516
|
entries: [
|
|
14478
14517
|
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
14479
14518
|
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
14480
|
-
{ binding: 2, resource: this.
|
|
14519
|
+
{ binding: 2, resource: this.shadowAtlasView },
|
|
14481
14520
|
{ binding: 3, resource: this.shadowComparisonSampler },
|
|
14482
14521
|
{ binding: 4, resource: { buffer: this.groundShadowMaterialBuffer } },
|
|
14483
14522
|
{ binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
|
|
14484
14523
|
{ binding: 6, resource: { buffer: this.lightsBuffer } },
|
|
14485
|
-
{ binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
|
|
14486
14524
|
{ binding: 8, resource: { buffer: this.mirrorVPBuffer } },
|
|
14487
14525
|
// Created in handleResize, which runs during init — before any ground
|
|
14488
14526
|
// can exist to bind it.
|
|
@@ -14500,12 +14538,11 @@ export class Engine {
|
|
|
14500
14538
|
entries: [
|
|
14501
14539
|
{ binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
|
|
14502
14540
|
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
14503
|
-
{ binding: 2, resource: this.
|
|
14541
|
+
{ binding: 2, resource: this.shadowAtlasView },
|
|
14504
14542
|
{ binding: 3, resource: this.shadowComparisonSampler },
|
|
14505
14543
|
{ binding: 4, resource: { buffer: this.groundShadowMaterialBuffer } },
|
|
14506
14544
|
{ binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
|
|
14507
14545
|
{ binding: 6, resource: { buffer: this.lightsBuffer } },
|
|
14508
|
-
{ binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
|
|
14509
14546
|
{ binding: 8, resource: { buffer: this.mirrorVPBuffer } },
|
|
14510
14547
|
// Stand-ins, not the live mirror textures: this group draws INTO the
|
|
14511
14548
|
// mirror pass, where those are the attachments.
|
|
@@ -14775,6 +14812,17 @@ export class Engine {
|
|
|
14775
14812
|
],
|
|
14776
14813
|
})
|
|
14777
14814
|
outline = { bindGroup: outlineBindGroup }
|
|
14815
|
+
if (!inst.outlineVertexBuffer) {
|
|
14816
|
+
const data = inst.model.getOutlineVertices()
|
|
14817
|
+
const buf = this.device.createBuffer({
|
|
14818
|
+
label: `${prefix}outline normals`,
|
|
14819
|
+
size: data.byteLength,
|
|
14820
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
14821
|
+
})
|
|
14822
|
+
this.device.queue.writeBuffer(buf, 0, data)
|
|
14823
|
+
inst.gpuBuffers.push(buf)
|
|
14824
|
+
inst.outlineVertexBuffer = buf
|
|
14825
|
+
}
|
|
14778
14826
|
}
|
|
14779
14827
|
|
|
14780
14828
|
// Model-space AABB for the cull compute, grown by the three things that can
|
|
@@ -16456,52 +16504,51 @@ export class Engine {
|
|
|
16456
16504
|
// the final frame's depth — and the ground, which draws on `hasGround` alone,
|
|
16457
16505
|
// keeps PCF-sampling a character that is no longer in the scene. One clearing
|
|
16458
16506
|
// pass on the transition to empty, then it stops.
|
|
16459
|
-
|
|
16460
|
-
|
|
16461
|
-
|
|
16462
|
-
|
|
16463
|
-
//
|
|
16464
|
-
//
|
|
16465
|
-
//
|
|
16466
|
-
//
|
|
16467
|
-
// So
|
|
16468
|
-
// the
|
|
16469
|
-
//
|
|
16470
|
-
// here rather than a missing one. Load a stage and it comes straight back.
|
|
16471
|
-
//
|
|
16472
|
-
// 0.43 had ONE shadow map. This is half of what the second one costs.
|
|
16507
|
+
// The game's globals before anything native draws: the stage's casters
|
|
16508
|
+
// into the atlas are the first.
|
|
16509
|
+
if (this.nativeStage || this.nativeLooks?.size) this.prepareNativeLooks(encoder)
|
|
16510
|
+
if (hasModels || this.nativeStage || this.shadowMapPopulated) {
|
|
16511
|
+
// The outer cascades are the STAGE's, and each costs a pass over the whole
|
|
16512
|
+
// cast. A scene with no stage has no set piece out there: every caster
|
|
16513
|
+
// sits inside the inner cascade, which follows the camera target, and
|
|
16514
|
+
// the outer tiles' only readers are ground pixels beyond it, where
|
|
16515
|
+
// nothing is casting. So without a stage only the inner tile is drawn;
|
|
16516
|
+
// the others keep the far plane cascade 0's pass cleared the whole atlas
|
|
16517
|
+
// to, which compares as "no occluder" — the correct answer there.
|
|
16473
16518
|
const stage = this.hasStage()
|
|
16474
16519
|
for (let ci = 0; ci < SHADOW_CASCADES.length; ci++) {
|
|
16475
|
-
|
|
16476
|
-
|
|
16477
|
-
// holds the far plane from the pass that cleared it.
|
|
16478
|
-
if (!wanted && this.shadowCascadeCleared[ci]) continue
|
|
16520
|
+
if (ci > 0 && !stage) continue
|
|
16521
|
+
const c = SHADOW_CASCADES[ci]
|
|
16479
16522
|
const sp = encoder.beginRenderPass({
|
|
16480
|
-
// One timestamp pair exists for "shadow"; the
|
|
16523
|
+
// One timestamp pair exists for "shadow"; the inner cascade wears it.
|
|
16481
16524
|
timestampWrites: ci === 0 ? this.stamps("shadow") : undefined,
|
|
16482
16525
|
colorAttachments: [],
|
|
16483
16526
|
depthStencilAttachment: {
|
|
16484
|
-
view: this.
|
|
16527
|
+
view: this.shadowAtlasView,
|
|
16485
16528
|
depthClearValue: 1.0,
|
|
16486
|
-
depthLoadOp: "clear",
|
|
16529
|
+
depthLoadOp: ci === 0 ? "clear" : "load",
|
|
16487
16530
|
depthStoreOp: "store",
|
|
16488
16531
|
},
|
|
16489
16532
|
})
|
|
16533
|
+
// The tile: a bundle draws through the pass's viewport, which it
|
|
16534
|
+
// cannot set itself, so one recording serves whichever tile is live.
|
|
16535
|
+
sp.setViewport(c.origin[0], c.origin[1], c.mapSize, c.mapSize, 0, 1)
|
|
16490
16536
|
// The per-model `visible` test that used to guard this is gone: it is a
|
|
16491
16537
|
// per-frame boolean, and baking it into a bundle would make toggling a
|
|
16492
16538
|
// model re-record. It lives in the cull compute now, which zeroes the
|
|
16493
16539
|
// instance count of an invisible model's draws.
|
|
16494
|
-
if (
|
|
16540
|
+
if (this.shadowBundles[ci]) sp.executeBundles([this.shadowBundles[ci]])
|
|
16495
16541
|
// The mirror throws shade like anything else standing on the floor.
|
|
16496
16542
|
// Direct rather than in the bundle: the bundles are recorded on scene
|
|
16497
16543
|
// STRUCTURE, and a mirror comes and goes with an effect's weight.
|
|
16498
|
-
if (
|
|
16544
|
+
if (this.mirrorSurface && this.mirrorShadowPipeline) {
|
|
16499
16545
|
sp.setPipeline(this.mirrorShadowPipeline)
|
|
16500
16546
|
sp.setBindGroup(0, this.mirrorShadowBindGroups[ci])
|
|
16501
16547
|
sp.draw(6)
|
|
16502
16548
|
}
|
|
16549
|
+
if (this.nativeStage && this.nativeShadowHost)
|
|
16550
|
+
this.nativeStage.drawShadow(sp, this.nativeShadowHost, this.nativeGlobals, ci, this.shadowLightVPMatrix.subarray(ci * 16, ci * 16 + 16), 8)
|
|
16503
16551
|
sp.end()
|
|
16504
|
-
this.shadowCascadeCleared[ci] = !wanted
|
|
16505
16552
|
}
|
|
16506
16553
|
// The cast's shadow on a stage: drawn while a stage asks for it and has a
|
|
16507
16554
|
// cast to shade it with; cleared once when it stops, then left alone.
|
|
@@ -16515,7 +16562,7 @@ export class Engine {
|
|
|
16515
16562
|
cp.end()
|
|
16516
16563
|
this.castShadowCleared = !castOn
|
|
16517
16564
|
}
|
|
16518
|
-
this.shadowMapPopulated = hasModels
|
|
16565
|
+
this.shadowMapPopulated = hasModels || !!this.nativeStage
|
|
16519
16566
|
}
|
|
16520
16567
|
|
|
16521
16568
|
// Before the particles and before the field pass: both may read the grid,
|
|
@@ -16561,6 +16608,17 @@ export class Engine {
|
|
|
16561
16608
|
// is FALSE for the cleared buffer — every such fragment silently rejected.
|
|
16562
16609
|
// One redundant word against a whole class of invisible failure.
|
|
16563
16610
|
pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
|
|
16611
|
+
// The models dressed in the game's materials, and the game's character
|
|
16612
|
+
// passes after them (unity/looks.ts). They set their own stencil
|
|
16613
|
+
// references, so the engine's is put back after.
|
|
16614
|
+
if (this.nativeLooks?.size) {
|
|
16615
|
+
this.nativeLooks.drawOpaque(pass)
|
|
16616
|
+
pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
|
|
16617
|
+
}
|
|
16618
|
+
if (this.nativeStage && this.nativeHost) {
|
|
16619
|
+
this.nativeStage.drawOpaque(pass, this.nativeHost, this.nativeGlobals, this.nativeFrameTextures(), this.eyeArray())
|
|
16620
|
+
pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
|
|
16621
|
+
}
|
|
16564
16622
|
if (this.hasGround) this.renderGround(pass)
|
|
16565
16623
|
// After the ground for the same early-z reason, and before the transparent
|
|
16566
16624
|
// phase so sheer fabric blends over the glass rather than being depth
|
|
@@ -16592,6 +16650,14 @@ export class Engine {
|
|
|
16592
16650
|
// which was never this renderer's bottleneck.
|
|
16593
16651
|
const camView = this.sceneView("camera")
|
|
16594
16652
|
this.forEachInstance((inst) => this.renderModelTransparentPhase(pass, inst, camView))
|
|
16653
|
+
if (this.nativeStage && this.nativeHost) {
|
|
16654
|
+
this.nativeStage.drawTransparent(pass, this.nativeHost, this.nativeGlobals, this.nativeFrameTextures(), this.eyeArray())
|
|
16655
|
+
pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
|
|
16656
|
+
}
|
|
16657
|
+
if (this.nativeLooks?.size) {
|
|
16658
|
+
this.nativeLooks.drawTransparent(pass)
|
|
16659
|
+
pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
|
|
16660
|
+
}
|
|
16595
16661
|
// Last in the pass: depth-tested against everything drawn above, so a
|
|
16596
16662
|
// particle behind the character is simply hidden, and still inside the HDR
|
|
16597
16663
|
// target so an `#bloom` effect reaches the pyramid below.
|
|
@@ -16609,58 +16675,17 @@ export class Engine {
|
|
|
16609
16675
|
// depth, before the composite that samples both layers.
|
|
16610
16676
|
this.renderFieldPass(encoder)
|
|
16611
16677
|
|
|
16612
|
-
// Bloom
|
|
16613
|
-
//
|
|
16614
|
-
//
|
|
16615
|
-
//
|
|
16616
|
-
//
|
|
16617
|
-
//
|
|
16618
|
-
//
|
|
16619
|
-
|
|
16620
|
-
|
|
16621
|
-
|
|
16622
|
-
//
|
|
16623
|
-
if (this.bloomContributes() && this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
|
|
16624
|
-
const bloomAtt = this.bloomPassDescriptor.colorAttachments as GPURenderPassColorAttachment[]
|
|
16625
|
-
|
|
16626
|
-
// 1. Blit — opens the pyramid's timing span. See stampOpen: the nine
|
|
16627
|
-
// passes below read as ONE component, which is the only useful grain.
|
|
16628
|
-
bloomAtt[0].view = this.bloomDownMipViews[0]
|
|
16629
|
-
this.bloomPassDescriptor.timestampWrites = this.stampOpen("bloom")
|
|
16630
|
-
const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
16631
|
-
pBlit.setPipeline(this.bloomBlitPipeline)
|
|
16632
|
-
pBlit.setBindGroup(0, this.bloomBlitBindGroup)
|
|
16633
|
-
pBlit.draw(3)
|
|
16634
|
-
pBlit.end()
|
|
16635
|
-
|
|
16636
|
-
// 2. Downsample chain
|
|
16637
|
-
this.bloomPassDescriptor.timestampWrites = undefined
|
|
16638
|
-
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
16639
|
-
bloomAtt[0].view = this.bloomDownMipViews[i]
|
|
16640
|
-
const p = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
16641
|
-
p.setPipeline(this.bloomDownsamplePipeline)
|
|
16642
|
-
p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1])
|
|
16643
|
-
p.draw(3)
|
|
16644
|
-
p.end()
|
|
16645
|
-
}
|
|
16646
|
-
|
|
16647
|
-
// 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
|
|
16648
|
-
const upSteps = this.bloomUpsampleBindGroups.length
|
|
16649
|
-
const topIdx = this.bloomMipCount - 2
|
|
16650
|
-
for (let k = 0; k < upSteps; k++) {
|
|
16651
|
-
const levelIdx = topIdx - k // writes bloomUp[levelIdx]
|
|
16652
|
-
bloomAtt[0].view = this.bloomUpMipViews[levelIdx]
|
|
16653
|
-
// The LAST upsample closes the span opened on the blit.
|
|
16654
|
-
this.bloomPassDescriptor.timestampWrites = k === upSteps - 1 ? this.stampClose("bloom") : undefined
|
|
16655
|
-
const p = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
16656
|
-
p.setPipeline(this.bloomUpsamplePipeline)
|
|
16657
|
-
p.setBindGroup(0, this.bloomUpsampleBindGroups[k])
|
|
16658
|
-
p.draw(3)
|
|
16659
|
-
p.end()
|
|
16660
|
-
}
|
|
16661
|
-
}
|
|
16662
|
-
|
|
16663
|
-
// Composite: HDR + bloom → Filmic tonemap → swapchain.
|
|
16678
|
+
// Bloom (renderBloom): prefilter, the Gaussian chain down, the scatter
|
|
16679
|
+
// blend back up; the composite adds up[0] × tint × intensity before the view
|
|
16680
|
+
// transform. bloomContributes() gates the whole chain, not just its
|
|
16681
|
+
// intensity. The composite still SAMPLES up[0] unconditionally, which is safe
|
|
16682
|
+
// and deliberate: it scales what it reads by the same effective intensity, so
|
|
16683
|
+
// a stale or never-written chain is multiplied by zero. Skipping the build is
|
|
16684
|
+
// therefore invisible in the frame and a couple of dozen render passes cheaper.
|
|
16685
|
+
if (this.bloomContributes() && this.bloomPrefilterBindGroup && this.compositeBindGroup && this.bloomLevels > 0)
|
|
16686
|
+
this.renderBloom(encoder)
|
|
16687
|
+
|
|
16688
|
+
// Composite: HDR + bloom → view transform → swapchain.
|
|
16664
16689
|
const swapchainView = this.context.getCurrentTexture().createView()
|
|
16665
16690
|
const compositeAttachment = (this.compositePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
|
|
16666
16691
|
compositeAttachment.view = swapchainView
|
|
@@ -16811,11 +16836,14 @@ export class Engine {
|
|
|
16811
16836
|
}
|
|
16812
16837
|
}
|
|
16813
16838
|
|
|
16814
|
-
|
|
16815
|
-
|
|
16816
|
-
|
|
16817
|
-
|
|
16818
|
-
|
|
16839
|
+
// ALL GROUPS AT ONCE. Each install touches only its own id — its entry in
|
|
16840
|
+
// styleGroups, its uniform buffer, the draw calls already bound to it — so
|
|
16841
|
+
// the order they land in changes nothing, and each keeps its own staleness
|
|
16842
|
+
// guard (styleGroupGen). Awaiting them in turn made a model's styling the
|
|
16843
|
+
// sum of its compiles; the GPU process runs them side by side.
|
|
16844
|
+
const groupResults: GroupDiagnostic[] = (
|
|
16845
|
+
await Promise.all(groups.map((g) => this.compileAndInstallGroup(inst, g)))
|
|
16846
|
+
).map((r, i) => ({ groupId: groups[i].id, diagnostics: r.diagnostics, ok: r.ok }))
|
|
16819
16847
|
|
|
16820
16848
|
// Repoints every draw call away from the outgoing installs (among
|
|
16821
16849
|
// everything else) — only past this line is destroying them safe.
|
|
@@ -16832,6 +16860,38 @@ export class Engine {
|
|
|
16832
16860
|
}
|
|
16833
16861
|
}
|
|
16834
16862
|
|
|
16863
|
+
/**
|
|
16864
|
+
* Compile style groups' pipelines before any model needs them — a scene's
|
|
16865
|
+
* groups while its bundle is still downloading. Installs nothing: the
|
|
16866
|
+
* pipelines land in the shader cache, and applyStyleGroups later finds them
|
|
16867
|
+
* there (or still in flight). A group that does not compile is skipped
|
|
16868
|
+
* silently; its real install reports it.
|
|
16869
|
+
*/
|
|
16870
|
+
async prewarmStyleGroups(groups: readonly StyleGroup[]): Promise<void> {
|
|
16871
|
+
if (!this.device) return
|
|
16872
|
+
await Promise.all(
|
|
16873
|
+
groups.map(async (g) => {
|
|
16874
|
+
const renderClass = g.renderClass ?? "auto"
|
|
16875
|
+
const alphaMode = g.alphaMode ?? "opaque"
|
|
16876
|
+
const blend = g.blend ?? "over"
|
|
16877
|
+
let result: ReturnType<typeof compileGraph>
|
|
16878
|
+
try {
|
|
16879
|
+
result = compileGraph(g.graph, { renderClass, alphaMode, blend })
|
|
16880
|
+
} catch {
|
|
16881
|
+
return
|
|
16882
|
+
}
|
|
16883
|
+
if (!result.ok) return
|
|
16884
|
+
const module = this.cachedShaderModule(result.wgsl, `style group: ${g.id} (${renderClass})`)
|
|
16885
|
+
await Promise.all([
|
|
16886
|
+
this.createRenderClassPipeline(renderClass, module, false, true, false, blend),
|
|
16887
|
+
this.createRenderClassPipeline(renderClass, module, false, false, false, blend),
|
|
16888
|
+
renderClass === "hair" ? this.createRenderClassPipeline(renderClass, module, true, true, false, blend) : null,
|
|
16889
|
+
renderClass === "eye" ? this.createRenderClassPipeline(renderClass, module, false, true, true, blend) : null,
|
|
16890
|
+
]).catch(() => {})
|
|
16891
|
+
}),
|
|
16892
|
+
)
|
|
16893
|
+
}
|
|
16894
|
+
|
|
16835
16895
|
/** Add or replace a single style group by id. `opts` may carry a `previewNode` for the
|
|
16836
16896
|
* editor's node-output preview workflow. */
|
|
16837
16897
|
async upsertStyleGroup(modelName: string, group: StyleGroup, opts?: CompileOptions): Promise<ApplyStyleGroupResult> {
|
|
@@ -17126,11 +17186,44 @@ export class Engine {
|
|
|
17126
17186
|
|
|
17127
17187
|
// Compile a group's graph → WGSL → pipeline(s), install keyed by group id. Reuses the
|
|
17128
17188
|
// install (pipeline + uniform buffer) when the graph/integration is byte-unchanged.
|
|
17189
|
+
/** A graph's own images (ShaderGraph.images), fetched and decoded once per URL. */
|
|
17190
|
+
private graphImageCache = new Map<string, Promise<ImageBitmap | null>>()
|
|
17191
|
+
|
|
17192
|
+
private graphImage(url: string): Promise<ImageBitmap | null> {
|
|
17193
|
+
let p = this.graphImageCache.get(url)
|
|
17194
|
+
if (!p) {
|
|
17195
|
+
p = fetch(url)
|
|
17196
|
+
.then((r) => (r.ok ? r.blob() : Promise.reject(new Error(`${r.status} ${url}`))))
|
|
17197
|
+
.then((b) => createImageBitmap(b, { colorSpaceConversion: "none", premultiplyAlpha: "none" }))
|
|
17198
|
+
.catch((e) => {
|
|
17199
|
+
console.warn(`[style] graph image: ${(e as Error).message}`)
|
|
17200
|
+
return null
|
|
17201
|
+
})
|
|
17202
|
+
this.graphImageCache.set(url, p)
|
|
17203
|
+
}
|
|
17204
|
+
return p
|
|
17205
|
+
}
|
|
17206
|
+
|
|
17207
|
+
/** The group with its graph's images, where the group brings none of its own. */
|
|
17208
|
+
private async withGraphImages(group: StyleGroup): Promise<StyleGroup> {
|
|
17209
|
+
const wanted = group.graph.images
|
|
17210
|
+
if (!wanted?.length || group.images?.length) return group
|
|
17211
|
+
const images = await Promise.all(
|
|
17212
|
+
wanted.slice(0, 4).map(async (w) => {
|
|
17213
|
+
if (!w) return null
|
|
17214
|
+
const source = await this.graphImage(w.url)
|
|
17215
|
+
return source ? { source, srgb: w.srgb ?? false } : null
|
|
17216
|
+
}),
|
|
17217
|
+
)
|
|
17218
|
+
return { ...group, images }
|
|
17219
|
+
}
|
|
17220
|
+
|
|
17129
17221
|
private async compileAndInstallGroup(
|
|
17130
17222
|
inst: ModelInstance,
|
|
17131
|
-
|
|
17223
|
+
groupIn: StyleGroup,
|
|
17132
17224
|
opts?: CompileOptions,
|
|
17133
17225
|
): Promise<ApplyStyleGroupResult> {
|
|
17226
|
+
const group = await this.withGraphImages(groupIn)
|
|
17134
17227
|
const renderClass = group.renderClass ?? "auto"
|
|
17135
17228
|
const alphaMode = group.alphaMode ?? "opaque"
|
|
17136
17229
|
// THE MAPS ARE PART OF THE SIGNATURE, because a matching one skips the
|
|
@@ -17160,9 +17253,35 @@ export class Engine {
|
|
|
17160
17253
|
inst.styleGroupGen.set(group.id, generation)
|
|
17161
17254
|
|
|
17162
17255
|
this.device.pushErrorScope("validation")
|
|
17163
|
-
const module = this.
|
|
17164
|
-
const
|
|
17165
|
-
|
|
17256
|
+
const module = this.cachedShaderModule(result.wgsl, `style group: ${group.id} (${renderClass})`)
|
|
17257
|
+
const scopePromise = this.device.popErrorScope()
|
|
17258
|
+
// EVERY VARIANT AT ONCE, and the module's diagnostics read beside them
|
|
17259
|
+
// rather than first: the compiles run in parallel in the GPU process, and
|
|
17260
|
+
// awaiting each in turn (and the diagnostics before any) made a group cost
|
|
17261
|
+
// the sum of its pipelines instead of the longest.
|
|
17262
|
+
const blend = group.blend ?? "over"
|
|
17263
|
+
// The single-sided twins a draw of this group will ask for (sidedPipeline),
|
|
17264
|
+
// built with their pipelines rather than synchronously at the first draw.
|
|
17265
|
+
const singleSided = inst.drawCalls.some((dc) => !dc.doubleSided && group.materials.includes(dc.materialName))
|
|
17266
|
+
const variant = (overEyes: boolean, depthWrite: boolean, mirrored: boolean) =>
|
|
17267
|
+
this.createRenderClassPipeline(renderClass, module, overEyes, depthWrite, mirrored, blend).then(async (p) => {
|
|
17268
|
+
if (singleSided && !mirrored) await this.buildSidedTwin(p, false)
|
|
17269
|
+
return p
|
|
17270
|
+
})
|
|
17271
|
+
const variants = Promise.all([
|
|
17272
|
+
variant(false, true, false),
|
|
17273
|
+
// The depth-write-off twin: stage transparency draws with it (see
|
|
17274
|
+
// pipelineForDrawCall), and a future OIT path would too.
|
|
17275
|
+
variant(false, false, false),
|
|
17276
|
+
renderClass === "hair" ? variant(true, true, false) : undefined,
|
|
17277
|
+
// Only the eye needs one: every other class culls "none", which a flipped
|
|
17278
|
+
// winding leaves alone.
|
|
17279
|
+
renderClass === "eye" ? variant(false, true, true) : undefined,
|
|
17280
|
+
])
|
|
17281
|
+
// Handled below either way; this keeps a rejection off the console while
|
|
17282
|
+
// the diagnostics are read.
|
|
17283
|
+
variants.catch(() => {})
|
|
17284
|
+
const [info, scopeError] = await Promise.all([module.getCompilationInfo(), scopePromise])
|
|
17166
17285
|
const diagnostics = [...result.diagnostics]
|
|
17167
17286
|
for (const msg of info.messages) {
|
|
17168
17287
|
if (msg.type !== "error") continue
|
|
@@ -17171,6 +17290,7 @@ export class Engine {
|
|
|
17171
17290
|
if (diagnostics.some((d) => d.severity === "error") || scopeError) {
|
|
17172
17291
|
if (scopeError && !diagnostics.some((d) => d.severity === "error"))
|
|
17173
17292
|
diagnostics.push({ severity: "error", message: `WGSL: ${scopeError.message}` })
|
|
17293
|
+
this.forgetShaderModule(result.wgsl)
|
|
17174
17294
|
return { ok: false, diagnostics, slotMap: result.slotMap }
|
|
17175
17295
|
}
|
|
17176
17296
|
|
|
@@ -17179,16 +17299,7 @@ export class Engine {
|
|
|
17179
17299
|
let overEyesPipeline: GPURenderPipeline | undefined
|
|
17180
17300
|
let mirrorPipeline: GPURenderPipeline | undefined
|
|
17181
17301
|
try {
|
|
17182
|
-
|
|
17183
|
-
pipeline = await this.createRenderClassPipeline(renderClass, module, false, true, false, blend)
|
|
17184
|
-
// The depth-write-off twin: stage transparency draws with it (see
|
|
17185
|
-
// pipelineForDrawCall), and a future OIT path would too.
|
|
17186
|
-
pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false, false, blend)
|
|
17187
|
-
if (renderClass === "hair") overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true, true, false, blend)
|
|
17188
|
-
// Only the eye needs one: every other class culls "none", which a flipped
|
|
17189
|
-
// winding leaves alone.
|
|
17190
|
-
if (renderClass === "eye")
|
|
17191
|
-
mirrorPipeline = await this.createRenderClassPipeline(renderClass, module, false, true, true, blend)
|
|
17302
|
+
;[pipeline, pipelineNoDepthWrite, overEyesPipeline, mirrorPipeline] = await variants
|
|
17192
17303
|
} catch (e) {
|
|
17193
17304
|
diagnostics.push({ severity: "error", message: `pipeline creation failed: ${(e as Error).message}` })
|
|
17194
17305
|
return { ok: false, diagnostics, slotMap: result.slotMap }
|
|
@@ -17430,8 +17541,8 @@ export class Engine {
|
|
|
17430
17541
|
fragment: { module, constants, targets: blend === "additive" ? this.sceneTargetsAdditive : this.sceneTargets },
|
|
17431
17542
|
depthStencil,
|
|
17432
17543
|
}
|
|
17433
|
-
return this.
|
|
17434
|
-
this.pipelineDescs.set(pipeline, desc)
|
|
17544
|
+
return this.cachedRenderPipeline(desc).then((pipeline) => {
|
|
17545
|
+
if (!this.pipelineDescs.has(pipeline)) this.pipelineDescs.set(pipeline, desc)
|
|
17435
17546
|
return pipeline
|
|
17436
17547
|
})
|
|
17437
17548
|
}
|
|
@@ -17444,6 +17555,181 @@ export class Engine {
|
|
|
17444
17555
|
return pipeline
|
|
17445
17556
|
}
|
|
17446
17557
|
|
|
17558
|
+
// ─── The shader cache ─────────────────────────────────────────────
|
|
17559
|
+
//
|
|
17560
|
+
// A compile is most of a second per distinct fragment shader on a cold
|
|
17561
|
+
// browser cache, and the same source used to be compiled once per CALLER: a
|
|
17562
|
+
// graph per model that carried it, the composite per effect install. These
|
|
17563
|
+
// key a module by its WGSL and a pipeline by its module and every descriptor
|
|
17564
|
+
// field but the label, so a second request for the same thing — in flight or
|
|
17565
|
+
// done — is the first one's promise. Bounded, oldest first, because an editor
|
|
17566
|
+
// session compiles a new source per keystroke.
|
|
17567
|
+
|
|
17568
|
+
private static readonly SHADER_CACHE_MAX = 256
|
|
17569
|
+
private static readonly PIPELINE_CACHE_MAX = 512
|
|
17570
|
+
|
|
17571
|
+
private static trimCache<K, V>(cache: Map<K, V>, max: number): void {
|
|
17572
|
+
while (cache.size > max) cache.delete(cache.keys().next().value as K)
|
|
17573
|
+
}
|
|
17574
|
+
|
|
17575
|
+
/** A small stable id per GPU object, for cache keys. */
|
|
17576
|
+
private gpuObjectId(o: object): number {
|
|
17577
|
+
let id = this.gpuObjectIds.get(o)
|
|
17578
|
+
if (id === undefined) {
|
|
17579
|
+
id = this.gpuObjectNext++
|
|
17580
|
+
this.gpuObjectIds.set(o, id)
|
|
17581
|
+
}
|
|
17582
|
+
return id
|
|
17583
|
+
}
|
|
17584
|
+
|
|
17585
|
+
/** One module per WGSL source. */
|
|
17586
|
+
private cachedShaderModule(code: string, label: string): GPUShaderModule {
|
|
17587
|
+
let module = this.shaderModuleCache.get(code)
|
|
17588
|
+
if (module) {
|
|
17589
|
+
this.shaderModuleCache.delete(code)
|
|
17590
|
+
} else {
|
|
17591
|
+
module = this.device.createShaderModule({ label, code })
|
|
17592
|
+
}
|
|
17593
|
+
this.shaderModuleCache.set(code, module)
|
|
17594
|
+
Engine.trimCache(this.shaderModuleCache, Engine.SHADER_CACHE_MAX)
|
|
17595
|
+
return module
|
|
17596
|
+
}
|
|
17597
|
+
|
|
17598
|
+
/** Drop a source whose module did not validate, so a retry reports afresh. */
|
|
17599
|
+
private forgetShaderModule(code: string): void {
|
|
17600
|
+
this.shaderModuleCache.delete(code)
|
|
17601
|
+
}
|
|
17602
|
+
|
|
17603
|
+
/** One bind group layout per entry list. */
|
|
17604
|
+
private cachedBindGroupLayout(desc: GPUBindGroupLayoutDescriptor): GPUBindGroupLayout {
|
|
17605
|
+
const key = JSON.stringify(desc.entries)
|
|
17606
|
+
let layout = this.bindGroupLayoutCache.get(key)
|
|
17607
|
+
if (!layout) {
|
|
17608
|
+
layout = this.device.createBindGroupLayout(desc)
|
|
17609
|
+
this.bindGroupLayoutCache.set(key, layout)
|
|
17610
|
+
}
|
|
17611
|
+
return layout
|
|
17612
|
+
}
|
|
17613
|
+
|
|
17614
|
+
/** One pipeline layout per bind group layout list. */
|
|
17615
|
+
private cachedPipelineLayout(bindGroupLayouts: GPUBindGroupLayout[]): GPUPipelineLayout {
|
|
17616
|
+
const key = bindGroupLayouts.map((l) => this.gpuObjectId(l)).join(",")
|
|
17617
|
+
let layout = this.pipelineLayoutCache.get(key)
|
|
17618
|
+
if (!layout) {
|
|
17619
|
+
layout = this.device.createPipelineLayout({ bindGroupLayouts })
|
|
17620
|
+
this.pipelineLayoutCache.set(key, layout)
|
|
17621
|
+
}
|
|
17622
|
+
return layout
|
|
17623
|
+
}
|
|
17624
|
+
|
|
17625
|
+
private pipelineKey(kind: string, desc: GPURenderPipelineDescriptor | GPUComputePipelineDescriptor): string {
|
|
17626
|
+
return (
|
|
17627
|
+
kind +
|
|
17628
|
+
JSON.stringify(desc, (k, v) =>
|
|
17629
|
+
k === "label" ? undefined : (k === "module" || k === "layout") && v && typeof v === "object" ? `#${this.gpuObjectId(v)}` : v,
|
|
17630
|
+
)
|
|
17631
|
+
)
|
|
17632
|
+
}
|
|
17633
|
+
|
|
17634
|
+
private cachedPipeline<T extends GPURenderPipeline | GPUComputePipeline>(key: string, build: () => Promise<T>): Promise<T> {
|
|
17635
|
+
let p = this.pipelineCache.get(key) as Promise<T> | undefined
|
|
17636
|
+
if (p) {
|
|
17637
|
+
this.pipelineCache.delete(key)
|
|
17638
|
+
} else {
|
|
17639
|
+
p = build().catch((e) => {
|
|
17640
|
+
this.pipelineCache.delete(key)
|
|
17641
|
+
throw e
|
|
17642
|
+
})
|
|
17643
|
+
}
|
|
17644
|
+
this.pipelineCache.set(key, p)
|
|
17645
|
+
Engine.trimCache(this.pipelineCache, Engine.PIPELINE_CACHE_MAX)
|
|
17646
|
+
return p
|
|
17647
|
+
}
|
|
17648
|
+
|
|
17649
|
+
/** createRenderPipelineAsync, once per module + state. */
|
|
17650
|
+
private cachedRenderPipeline(desc: GPURenderPipelineDescriptor): Promise<GPURenderPipeline> {
|
|
17651
|
+
return this.cachedPipeline(this.pipelineKey("render", desc), () => this.device.createRenderPipelineAsync(desc))
|
|
17652
|
+
}
|
|
17653
|
+
|
|
17654
|
+
/** createComputePipelineAsync, once per module + state. */
|
|
17655
|
+
private cachedComputePipeline(desc: GPUComputePipelineDescriptor): Promise<GPUComputePipeline> {
|
|
17656
|
+
return this.cachedPipeline(this.pipelineKey("compute", desc), () => this.device.createComputePipelineAsync(desc))
|
|
17657
|
+
}
|
|
17658
|
+
|
|
17659
|
+
/**
|
|
17660
|
+
* A pipeline init builds: async, in parallel with the rest, and in place by
|
|
17661
|
+
* the time init returns (it awaits every job). Outside init — a pass built
|
|
17662
|
+
* on first use — it is the synchronous build it always was.
|
|
17663
|
+
*/
|
|
17664
|
+
private initRenderPipeline(desc: GPURenderPipelineDescriptor, assign: (p: GPURenderPipeline) => void, twins = false): void {
|
|
17665
|
+
if (!this.initPipelineJobs) {
|
|
17666
|
+
const pipeline = this.device.createRenderPipeline(desc)
|
|
17667
|
+
if (twins) this.pipelineDescs.set(pipeline, desc)
|
|
17668
|
+
assign(pipeline)
|
|
17669
|
+
return
|
|
17670
|
+
}
|
|
17671
|
+
this.initPipelineJobs.push(
|
|
17672
|
+
this.cachedRenderPipeline(desc)
|
|
17673
|
+
// A rejected build is rebuilt the old way, which hands back the invalid
|
|
17674
|
+
// pipeline and reports through uncapturederror — a bad init pipeline
|
|
17675
|
+
// was never fatal to init, and is not now.
|
|
17676
|
+
.catch(() => this.device.createRenderPipeline(desc))
|
|
17677
|
+
.then((pipeline) => {
|
|
17678
|
+
assign(pipeline)
|
|
17679
|
+
if (!twins) return
|
|
17680
|
+
this.pipelineDescs.set(pipeline, desc)
|
|
17681
|
+
// The mirror's twin too, but not waited for: only a scene with a
|
|
17682
|
+
// mirror draws it, and it is a cache hit on the module just built.
|
|
17683
|
+
void this.buildSidedTwin(pipeline, true)
|
|
17684
|
+
return this.buildSidedTwin(pipeline, false)
|
|
17685
|
+
}),
|
|
17686
|
+
)
|
|
17687
|
+
}
|
|
17688
|
+
|
|
17689
|
+
/** The compute twin of initRenderPipeline. */
|
|
17690
|
+
private initComputePipeline(desc: GPUComputePipelineDescriptor, assign: (p: GPUComputePipeline) => void): void {
|
|
17691
|
+
if (!this.initPipelineJobs) {
|
|
17692
|
+
assign(this.device.createComputePipeline(desc))
|
|
17693
|
+
return
|
|
17694
|
+
}
|
|
17695
|
+
this.initPipelineJobs.push(
|
|
17696
|
+
this.cachedComputePipeline(desc)
|
|
17697
|
+
.catch(() => this.device.createComputePipeline(desc))
|
|
17698
|
+
.then(assign),
|
|
17699
|
+
)
|
|
17700
|
+
}
|
|
17701
|
+
|
|
17702
|
+
/**
|
|
17703
|
+
* Build a model pipeline's single-sided twin off the draw path (see
|
|
17704
|
+
* sidedPipeline). Resolves once it is in place; never rejects — a twin that
|
|
17705
|
+
* fails leaves the draw on its double-sided pipeline, as before it existed.
|
|
17706
|
+
*/
|
|
17707
|
+
private buildSidedTwin(pipeline: GPURenderPipeline, mirrored: boolean): Promise<void> {
|
|
17708
|
+
const desc = this.pipelineDescs.get(pipeline)
|
|
17709
|
+
if (!desc || (desc.primitive?.cullMode ?? "none") !== "none") return Promise.resolve()
|
|
17710
|
+
const cache = mirrored ? this.singleSidedMirror : this.singleSided
|
|
17711
|
+
const pending = mirrored ? this.sidedPendingMirror : this.sidedPending
|
|
17712
|
+
if (cache.has(pipeline) || pending.has(pipeline)) return Promise.resolve()
|
|
17713
|
+
pending.add(pipeline)
|
|
17714
|
+
return this.cachedRenderPipeline({
|
|
17715
|
+
...desc,
|
|
17716
|
+
label: `${desc.label ?? "pipeline"} (single-sided${mirrored ? ", mirror" : ""})`,
|
|
17717
|
+
primitive: { ...desc.primitive, cullMode: mirrored ? "back" : "front" },
|
|
17718
|
+
}).then(
|
|
17719
|
+
(twin) => {
|
|
17720
|
+
cache.set(pipeline, twin)
|
|
17721
|
+
pending.delete(pipeline)
|
|
17722
|
+
// A bundle recorded meanwhile holds the double-sided stand-in.
|
|
17723
|
+
this.bundlesDirty = true
|
|
17724
|
+
},
|
|
17725
|
+
() => {
|
|
17726
|
+
// Remembered as itself, so the draw stops asking.
|
|
17727
|
+
cache.set(pipeline, pipeline)
|
|
17728
|
+
pending.delete(pipeline)
|
|
17729
|
+
},
|
|
17730
|
+
)
|
|
17731
|
+
}
|
|
17732
|
+
|
|
17447
17733
|
/**
|
|
17448
17734
|
* The pipeline a draw should use once its double-sided flag is honoured.
|
|
17449
17735
|
*
|
|
@@ -17461,17 +17747,14 @@ export class Engine {
|
|
|
17461
17747
|
if (draw.doubleSided) return pipeline
|
|
17462
17748
|
const desc = this.pipelineDescs.get(pipeline)
|
|
17463
17749
|
if (!desc || (desc.primitive?.cullMode ?? "none") !== "none") return pipeline
|
|
17464
|
-
const
|
|
17465
|
-
|
|
17466
|
-
|
|
17467
|
-
|
|
17468
|
-
|
|
17469
|
-
|
|
17470
|
-
|
|
17471
|
-
|
|
17472
|
-
cache.set(pipeline, twin)
|
|
17473
|
-
}
|
|
17474
|
-
return twin
|
|
17750
|
+
const twin = (mirrored ? this.singleSidedMirror : this.singleSided).get(pipeline)
|
|
17751
|
+
if (twin) return twin
|
|
17752
|
+
// NEVER BUILT HERE SYNCHRONOUSLY: a sync build stalls the GPU process for
|
|
17753
|
+
// the whole compile, mid-frame. The camera's twins are built beside their
|
|
17754
|
+
// pipelines; one asked for first here (a mirror's) is started now and the
|
|
17755
|
+
// draw keeps its double-sided pipeline until it lands.
|
|
17756
|
+
void this.buildSidedTwin(pipeline, mirrored)
|
|
17757
|
+
return pipeline
|
|
17475
17758
|
}
|
|
17476
17759
|
|
|
17477
17760
|
// Pipeline for a material draw call: its group's compiled pipeline when grouped, else
|
|
@@ -17547,6 +17830,9 @@ export class Engine {
|
|
|
17547
17830
|
pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline)
|
|
17548
17831
|
pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup)
|
|
17549
17832
|
pass.setBindGroup(2, draw.outline.bindGroup)
|
|
17833
|
+
// Slot 3: the hull's smoothed normals + edge scale. Only the outline
|
|
17834
|
+
// pipeline reads it; the others leave the slot alone.
|
|
17835
|
+
if (inst.outlineVertexBuffer) pass.setVertexBuffer(3, inst.outlineVertexBuffer)
|
|
17550
17836
|
this.issueDraw(pass, draw, view.args)
|
|
17551
17837
|
pass.setBindGroup(0, view.perFrame)
|
|
17552
17838
|
currentPipeline = null
|
|
@@ -18284,6 +18570,264 @@ export class Engine {
|
|
|
18284
18570
|
cd[anchorBase + 11] = count
|
|
18285
18571
|
}
|
|
18286
18572
|
|
|
18573
|
+
/**
|
|
18574
|
+
* Dress a model in the game's own materials — a look exported with ag-rip
|
|
18575
|
+
* (translated shaders, their images, per-material passes and values, and a
|
|
18576
|
+
* character rig) — or take it off with null. The materials it names are
|
|
18577
|
+
* drawn by the game's shaders from then on; the rest keep their graphs, and
|
|
18578
|
+
* every one still casts its shadow through the engine's pass.
|
|
18579
|
+
*/
|
|
18580
|
+
setModelNativeLook(name: string, look: NativeLook | null): boolean {
|
|
18581
|
+
const inst = this.modelInstances.get(name)
|
|
18582
|
+
if (!inst || !this.device) return false
|
|
18583
|
+
if (!look) {
|
|
18584
|
+
this.nativeLooks?.remove(name)
|
|
18585
|
+
this.writeCullHidden(true)
|
|
18586
|
+
return true
|
|
18587
|
+
}
|
|
18588
|
+
if (!this.nativeLooks) this.nativeLooks = new NativeLooks(this.device, this.ensureNativeHosts())
|
|
18589
|
+
const skeleton = inst.model.getSkeleton()
|
|
18590
|
+
const head = skeleton.bones.findIndex((b) => b.name === "頭")
|
|
18591
|
+
// The head's rest position: a rigid inverse bind [R t] is the bind pose
|
|
18592
|
+
// inverted, so the bind position is −Rᵀt.
|
|
18593
|
+
let headRest: [number, number, number] = [0, 0, 0]
|
|
18594
|
+
if (head >= 0) {
|
|
18595
|
+
const m = skeleton.inverseBindMatrices.subarray(head * 16, head * 16 + 16)
|
|
18596
|
+
headRest = [
|
|
18597
|
+
-(m[0] * m[12] + m[1] * m[13] + m[2] * m[14]),
|
|
18598
|
+
-(m[4] * m[12] + m[5] * m[13] + m[6] * m[14]),
|
|
18599
|
+
-(m[8] * m[12] + m[9] * m[13] + m[10] * m[14]),
|
|
18600
|
+
]
|
|
18601
|
+
}
|
|
18602
|
+
const draws = inst.drawCalls
|
|
18603
|
+
.filter((dc) => dc.baseBindGroupEntries)
|
|
18604
|
+
.map((dc) => ({
|
|
18605
|
+
materialName: dc.materialName,
|
|
18606
|
+
firstIndex: dc.firstIndex,
|
|
18607
|
+
count: dc.count,
|
|
18608
|
+
diffuse: dc.baseBindGroupEntries!.find((e) => e.binding === 0)!.resource as GPUTextureView,
|
|
18609
|
+
}))
|
|
18610
|
+
this.nativeLooks.install(
|
|
18611
|
+
{
|
|
18612
|
+
name,
|
|
18613
|
+
vertices: inst.model.getVertices(),
|
|
18614
|
+
indices: inst.model.getIndices(),
|
|
18615
|
+
vertexBuffer: inst.vertexBuffer,
|
|
18616
|
+
jointsBuffer: inst.jointsBuffer,
|
|
18617
|
+
weightsBuffer: inst.weightsBuffer,
|
|
18618
|
+
skinMatrixBuffer: inst.skinMatrixBuffer,
|
|
18619
|
+
indexBuffer: inst.indexBuffer,
|
|
18620
|
+
draws,
|
|
18621
|
+
hidden: (m) => inst.hiddenMaterials.has(m) || inst.morphHiddenMaterials.has(m),
|
|
18622
|
+
headSkin: () => (head >= 0 ? inst.model.getSkinMatrices().subarray(head * 16, head * 16 + 16) : null),
|
|
18623
|
+
headRest,
|
|
18624
|
+
layers: new Uint32Array(inst.objectLight.buffer)[0],
|
|
18625
|
+
},
|
|
18626
|
+
look,
|
|
18627
|
+
)
|
|
18628
|
+
this.writeCullHidden(true)
|
|
18629
|
+
return true
|
|
18630
|
+
}
|
|
18631
|
+
|
|
18632
|
+
/** Resolves once a model's native look can draw: every pass's pipeline is
|
|
18633
|
+
* compiled (async, at setModelNativeLook). Until then its dressed materials
|
|
18634
|
+
* sit out — a host reveals the model after this to avoid that gap. */
|
|
18635
|
+
nativeLookReady(name: string): Promise<void> {
|
|
18636
|
+
return this.nativeLooks?.ready(name) ?? Promise.resolve()
|
|
18637
|
+
}
|
|
18638
|
+
|
|
18639
|
+
/** The hosts for the game's shaders: the scene pass's and the shadow atlas's,
|
|
18640
|
+
* with the stand-ins an empty slot reads. Built on first use. */
|
|
18641
|
+
private ensureNativeHosts(): NativeHost {
|
|
18642
|
+
if (this.nativeHost) return this.nativeHost
|
|
18643
|
+
const d = this.device
|
|
18644
|
+
const noShadow = d.createTexture({
|
|
18645
|
+
label: "native: no shadow",
|
|
18646
|
+
size: [1, 1],
|
|
18647
|
+
format: "depth32float",
|
|
18648
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
18649
|
+
})
|
|
18650
|
+
const enc = d.createCommandEncoder()
|
|
18651
|
+
enc
|
|
18652
|
+
.beginRenderPass({
|
|
18653
|
+
colorAttachments: [],
|
|
18654
|
+
depthStencilAttachment: { view: noShadow.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
|
|
18655
|
+
})
|
|
18656
|
+
.end()
|
|
18657
|
+
d.queue.submit([enc.finish()])
|
|
18658
|
+
this.nativeNoShadowView = noShadow.createView()
|
|
18659
|
+
const solid = (label: string, rgba: number[], cube = false) => {
|
|
18660
|
+
const t = d.createTexture({
|
|
18661
|
+
label,
|
|
18662
|
+
size: [1, 1, cube ? 6 : 1],
|
|
18663
|
+
format: "rgba8unorm",
|
|
18664
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
18665
|
+
})
|
|
18666
|
+
for (let l = 0; l < (cube ? 6 : 1); l++) d.queue.writeTexture({ texture: t, origin: [0, 0, l] }, new Uint8Array(rgba), { bytesPerRow: 4 }, [1, 1])
|
|
18667
|
+
return t.createView(cube ? { dimension: "cube" } : undefined)
|
|
18668
|
+
}
|
|
18669
|
+
const white = solid("native: white", [255, 255, 255, 255])
|
|
18670
|
+
const black = solid("native: black", [0, 0, 0, 255])
|
|
18671
|
+
// Unity's stand-ins for an empty slot, by the name a shader declares.
|
|
18672
|
+
this.nativeFallback = {
|
|
18673
|
+
white,
|
|
18674
|
+
black,
|
|
18675
|
+
"": white,
|
|
18676
|
+
grey: solid("native: grey", [128, 128, 128, 255]),
|
|
18677
|
+
gray: solid("native: gray", [128, 128, 128, 255]),
|
|
18678
|
+
bump: solid("native: bump", [128, 128, 255, 255]),
|
|
18679
|
+
red: solid("native: red", [255, 0, 0, 255]),
|
|
18680
|
+
}
|
|
18681
|
+
const fallback = {
|
|
18682
|
+
white,
|
|
18683
|
+
black,
|
|
18684
|
+
blackCube: solid("native: black cube", [0, 0, 0, 255], true),
|
|
18685
|
+
depth: this.nativeNoShadowView,
|
|
18686
|
+
comparison: this.shadowComparisonSampler,
|
|
18687
|
+
}
|
|
18688
|
+
this.nativeHost = new NativeHost(
|
|
18689
|
+
d,
|
|
18690
|
+
{
|
|
18691
|
+
colorFormats: sceneColorFormats(this.sceneFormats),
|
|
18692
|
+
depthFormat: this.depthFormat,
|
|
18693
|
+
sampleCount: Engine.MULTISAMPLE_COUNT,
|
|
18694
|
+
reversedZ: this.reversedZ,
|
|
18695
|
+
},
|
|
18696
|
+
fallback,
|
|
18697
|
+
)
|
|
18698
|
+
this.nativeShadowHost = new NativeHost(d, { colorFormats: [], depthFormat: Engine.SHADOW_DEPTH_FORMAT, sampleCount: 1, reversedZ: false }, fallback)
|
|
18699
|
+
return this.nativeHost
|
|
18700
|
+
}
|
|
18701
|
+
|
|
18702
|
+
/**
|
|
18703
|
+
* Load a game stage as the game draws it - a package ag-rip's
|
|
18704
|
+
* stage_native.py exported (stage.json and the files beside it, read through
|
|
18705
|
+
* `read` by their paths in it) - or remove the one loaded with null. Its
|
|
18706
|
+
* scene settings (fog, tint, ambient, environment) become the game shaders'
|
|
18707
|
+
* for the whole scene; its lights are the caller's to set, as lights.
|
|
18708
|
+
*/
|
|
18709
|
+
async setNativeStage(pkg: NativeStagePackage | null, read?: NativeStageReader, onProgress?: (done: number, total: number) => void): Promise<void> {
|
|
18710
|
+
const old = this.nativeStage
|
|
18711
|
+
this.nativeStage = null
|
|
18712
|
+
if (old) old.destroy([this.nativeHost!, this.nativeShadowHost!])
|
|
18713
|
+
if (!pkg || !read || !this.device) return
|
|
18714
|
+
const scene = this.ensureNativeHosts()
|
|
18715
|
+
this.nativeStage = await NativeStage.load(
|
|
18716
|
+
this.device,
|
|
18717
|
+
pkg,
|
|
18718
|
+
read,
|
|
18719
|
+
{ scene, shadow: this.nativeShadowHost! },
|
|
18720
|
+
{ mipmaps: (t, levels) => this.generateMipmaps(t, levels), fallback: this.nativeFallback },
|
|
18721
|
+
onProgress,
|
|
18722
|
+
)
|
|
18723
|
+
|
|
18724
|
+
}
|
|
18725
|
+
|
|
18726
|
+
/** What went wrong in the game's shaders, and the names nothing supplied. */
|
|
18727
|
+
nativeReport(): { errors: string[]; missing: Record<string, number> } {
|
|
18728
|
+
const errors = [...(this.nativeHost?.errors ?? []), ...(this.nativeShadowHost?.errors ?? [])]
|
|
18729
|
+
const missing: Record<string, number> = {}
|
|
18730
|
+
for (const h of [this.nativeHost, this.nativeShadowHost]) for (const [k, v] of h?.missing ?? []) missing[k] = (missing[k] ?? 0) + v
|
|
18731
|
+
return { errors, missing }
|
|
18732
|
+
}
|
|
18733
|
+
|
|
18734
|
+
private nativeFrameTextures(): Record<string, GPUTextureView | undefined> {
|
|
18735
|
+
return { _MainLightShadowmapTexture: this.shadowAtlasView, sim_CharacterShadowmap: this.nativeNoShadowView ?? undefined }
|
|
18736
|
+
}
|
|
18737
|
+
|
|
18738
|
+
private eyeArray(): [number, number, number] {
|
|
18739
|
+
const e = this.camera.getEyePosition()
|
|
18740
|
+
return [e.x, e.y, e.z]
|
|
18741
|
+
}
|
|
18742
|
+
|
|
18743
|
+
/** Before the scene pass: the dressed models' skinning and the game's globals. */
|
|
18744
|
+
private prepareNativeLooks(encoder: GPUCommandEncoder): void {
|
|
18745
|
+
const looks = this.nativeLooks
|
|
18746
|
+
const view = this.camera.getViewMatrix().values
|
|
18747
|
+
const proj = this.camera.getProjectionMatrix().values
|
|
18748
|
+
const eye = this.camera.getEyePosition()
|
|
18749
|
+
const sun = this.sun
|
|
18750
|
+
const dir = sun.direction.normalize()
|
|
18751
|
+
const k = sun.strength
|
|
18752
|
+
// The document's lamps as their records store them.
|
|
18753
|
+
const lights = []
|
|
18754
|
+
for (let i = 0; i < this.docLightCount; i++) {
|
|
18755
|
+
const b = LIGHT_HEADER + i * LIGHT_STRIDE
|
|
18756
|
+
const d = this.lightsData
|
|
18757
|
+
lights.push({
|
|
18758
|
+
position: [d[b], d[b + 1], d[b + 2]] as [number, number, number],
|
|
18759
|
+
radius: d[b + 3],
|
|
18760
|
+
color: [d[b + 4], d[b + 5], d[b + 6]] as [number, number, number],
|
|
18761
|
+
aim: [d[b + 8], d[b + 9], d[b + 10]] as [number, number, number],
|
|
18762
|
+
cosOuter: d[b + 11],
|
|
18763
|
+
cosInner: d[b + 12],
|
|
18764
|
+
layers: ~this.lightsWords[b + 13] >>> 0,
|
|
18765
|
+
})
|
|
18766
|
+
}
|
|
18767
|
+
// A game stage brings its own lights: the game's shaders get exactly those
|
|
18768
|
+
// (its sun, its lamps, all layers), whatever the scene's lamps are.
|
|
18769
|
+
const stage = this.nativeStage
|
|
18770
|
+
let sunDirection: [number, number, number] = [dir.x, dir.y, dir.z]
|
|
18771
|
+
let sunColor: [number, number, number] = [sun.color.x * k, sun.color.y * k, sun.color.z * k]
|
|
18772
|
+
let sunShadow = this.sunShadow
|
|
18773
|
+
if (stage) {
|
|
18774
|
+
// Its lamps are the stage's own; its key is the scene's sun, which the
|
|
18775
|
+
// host sets from the stage (and a person may then move).
|
|
18776
|
+
const L = stage.pkg.lights
|
|
18777
|
+
const sc = stage.pkg.scale
|
|
18778
|
+
lights.length = 0
|
|
18779
|
+
for (const l of L.additional) {
|
|
18780
|
+
lights.push({
|
|
18781
|
+
position: [-l.position[0] * sc, l.position[1] * sc, -l.position[2] * sc] as [number, number, number],
|
|
18782
|
+
radius: l.range * sc,
|
|
18783
|
+
color: [l.color[0] * sc * sc, l.color[1] * sc * sc, l.color[2] * sc * sc] as [number, number, number],
|
|
18784
|
+
aim: (l.aim ? gameDir(l.aim) : [0, 0, 0]) as [number, number, number],
|
|
18785
|
+
cosOuter: l.cosOuter ?? -1,
|
|
18786
|
+
cosInner: l.cosInner ?? -1,
|
|
18787
|
+
layers: 0xffffffff,
|
|
18788
|
+
unity: l.unity,
|
|
18789
|
+
})
|
|
18790
|
+
}
|
|
18791
|
+
}
|
|
18792
|
+
const s = this.world.strength
|
|
18793
|
+
const sh = this.worldAmbientSH ?? this.worldSH ?? this.worldGradientSH
|
|
18794
|
+
const g = unityFrameGlobals({
|
|
18795
|
+
scale: 8,
|
|
18796
|
+
view,
|
|
18797
|
+
proj,
|
|
18798
|
+
eye: [eye.x, eye.y, eye.z],
|
|
18799
|
+
near: this.camera.near,
|
|
18800
|
+
far: this.camera.far,
|
|
18801
|
+
width: this.canvas.width,
|
|
18802
|
+
height: this.canvas.height,
|
|
18803
|
+
time: this.sceneClock,
|
|
18804
|
+
dt: 1 / 60,
|
|
18805
|
+
sunDirection,
|
|
18806
|
+
sunColor,
|
|
18807
|
+
sunShadow,
|
|
18808
|
+
lights,
|
|
18809
|
+
ambientSH: sh ? Array.from(sh, (v) => v * s) : null,
|
|
18810
|
+
ambientFlat: [this.world.color.x * s, this.world.color.y * s, this.world.color.z * s],
|
|
18811
|
+
shadow: {
|
|
18812
|
+
viewProj: SHADOW_CASCADES.map((_, i) => this.shadowLightVPMatrix.subarray(i * 16, i * 16 + 16)),
|
|
18813
|
+
tiles: SHADOW_CASCADES.map((c) => [c.origin[0], c.origin[1]] as [number, number]),
|
|
18814
|
+
tileSize: SHADOW_CASCADES[0].mapSize,
|
|
18815
|
+
atlasSize: SHADOW_ATLAS_SIZE,
|
|
18816
|
+
spheres: cascadeSpheres(this.shadowView, this.shadowSceneBounds).map((p) => ({
|
|
18817
|
+
center: [p.center.x, p.center.y, p.center.z] as [number, number, number],
|
|
18818
|
+
radius: p.radius,
|
|
18819
|
+
})),
|
|
18820
|
+
},
|
|
18821
|
+
settings: this.nativeStage?.pkg.settings ?? null,
|
|
18822
|
+
})
|
|
18823
|
+
g.sim_TowardMatrix = towardMatrix(gameDir([view[2], view[6], view[10]]))
|
|
18824
|
+
g._OutlineMaxOffsetMultiplier = outlineMaxOffsetMultiplier(this.canvas.width)
|
|
18825
|
+
this.nativeGlobals = stableGlobals(this.nativeGlobals, g)
|
|
18826
|
+
this.nativeHost?.beginFrame()
|
|
18827
|
+
this.nativeShadowHost?.beginFrame()
|
|
18828
|
+
looks?.prepare(encoder, this.nativeGlobals, 8, this.nativeFrameTextures())
|
|
18829
|
+
}
|
|
18830
|
+
|
|
18287
18831
|
private updateSkinMatrices() {
|
|
18288
18832
|
this.forEachInstance((inst) => {
|
|
18289
18833
|
// Only a pose pass can change these, and an idle stage did not run one —
|
|
@@ -18360,3 +18904,30 @@ export class Engine {
|
|
|
18360
18904
|
this.stats.cpuRenderMs = Math.round(this.cpuRenderMs * 100) / 100
|
|
18361
18905
|
}
|
|
18362
18906
|
}
|
|
18907
|
+
|
|
18908
|
+
/**
|
|
18909
|
+
* This frame's Unity globals, keeping last frame's object for every value that
|
|
18910
|
+
* did not change: the native host refills a uniform member only when its value
|
|
18911
|
+
* object is new, so a light table or an SH that holds still costs nothing.
|
|
18912
|
+
*/
|
|
18913
|
+
function stableGlobals(prev: Record<string, NativeValue>, next: Record<string, NativeValue>): Record<string, NativeValue> {
|
|
18914
|
+
for (const k in next) {
|
|
18915
|
+
const a = prev[k]
|
|
18916
|
+
if (a !== undefined && sameValue(a, next[k])) next[k] = a
|
|
18917
|
+
}
|
|
18918
|
+
return next
|
|
18919
|
+
}
|
|
18920
|
+
|
|
18921
|
+
function sameValue(a: NativeValue, b: NativeValue): boolean {
|
|
18922
|
+
if (typeof a === "number" || typeof b === "number") return a === b
|
|
18923
|
+
if (a.length !== b.length) return false
|
|
18924
|
+
if (a instanceof Uint32Array !== b instanceof Uint32Array) return false
|
|
18925
|
+
for (let i = 0; i < a.length; i++) {
|
|
18926
|
+
const x = a[i] as number | ArrayLike<number>
|
|
18927
|
+
const y = b[i] as number | ArrayLike<number>
|
|
18928
|
+
if (typeof x === "number" || typeof y === "number") {
|
|
18929
|
+
if (x !== y) return false
|
|
18930
|
+
} else if (!sameValue(x, y)) return false
|
|
18931
|
+
}
|
|
18932
|
+
return true
|
|
18933
|
+
}
|