reze-engine 0.64.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 +146 -54
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +734 -582
- package/dist/graph/slots.d.ts.map +1 -1
- package/dist/graph/slots.js +7 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +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/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +4 -2
- 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 +118 -72
- 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/unity/host.d.ts +15 -0
- package/dist/unity/host.d.ts.map +1 -1
- package/dist/unity/host.js +103 -63
- package/dist/unity/looks.d.ts +5 -0
- package/dist/unity/looks.d.ts.map +1 -1
- package/dist/unity/looks.js +19 -2
- package/dist/unity/skin.d.ts.map +1 -1
- package/dist/unity/skin.js +5 -2
- package/dist/unity/stage.d.ts +7 -0
- package/dist/unity/stage.d.ts.map +1 -1
- package/dist/unity/stage.js +37 -2
- package/package.json +1 -1
- package/src/engine.ts +1042 -798
- package/src/graph/slots.ts +7 -1
- package/src/index.ts +1 -0
- package/src/model.ts +18 -1
- package/src/outline-normals.ts +110 -0
- package/src/pmx-loader.ts +8 -2
- package/src/shaders/materials/common.ts +4 -2
- package/src/shaders/passes/bloom.ts +59 -87
- package/src/shaders/passes/composite.ts +118 -72
- package/src/shaders/passes/mirror.ts +60 -13
- package/src/shaders/passes/outline.ts +71 -14
- package/src/unity/host.ts +57 -14
- package/src/unity/looks.ts +21 -2
- package/src/unity/skin.ts +5 -2
- package/src/unity/stage.ts +38 -2
- package/src/shaders/agx-lut.ts +0 -30
package/src/engine.ts
CHANGED
|
@@ -52,7 +52,13 @@ import { outlineMaxOffsetMultiplier, towardMatrix } from "./unity/character"
|
|
|
52
52
|
import type { NativeValue } from "./unity/host"
|
|
53
53
|
import { SHADOW_ATLAS_SIZE, SHADOW_CASCADES, buildShadowCascades, cascadeSpheres, type ShadowBounds, type ShadowView } from "./shadow-cascades"
|
|
54
54
|
import { REFLECTION_DEBUG_WGSL, buildMirrorCamera, planeFromPointNormal } from "./reflection"
|
|
55
|
-
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"
|
|
56
62
|
import { packHalf, type HdrImage } from "./hdr"
|
|
57
63
|
import { evalIrradianceSH, projectIrradianceSH, gradientIrradianceSH } from "./ibl"
|
|
58
64
|
import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, lyricsApi, packLyrics, type LyricLine, type LyricRect } from "./shaders/lyrics-api"
|
|
@@ -104,11 +110,11 @@ import {
|
|
|
104
110
|
type RigidbodyOverlayOptions,
|
|
105
111
|
} from "./overlay"
|
|
106
112
|
import {
|
|
107
|
-
|
|
108
|
-
|
|
113
|
+
BLOOM_BLUR_H_SHADER_WGSL,
|
|
114
|
+
BLOOM_BLUR_V_SHADER_WGSL,
|
|
115
|
+
BLOOM_PREFILTER_SHADER_WGSL,
|
|
109
116
|
BLOOM_UPSAMPLE_SHADER_WGSL,
|
|
110
117
|
} from "./shaders/passes/bloom"
|
|
111
|
-
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut"
|
|
112
118
|
import {
|
|
113
119
|
buildCompositeShader,
|
|
114
120
|
EFFECT_SCENE_API,
|
|
@@ -556,25 +562,29 @@ type CameraOptions = {
|
|
|
556
562
|
fov?: number
|
|
557
563
|
}
|
|
558
564
|
|
|
559
|
-
/**
|
|
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. */
|
|
560
568
|
export type BloomOptions = {
|
|
561
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. */
|
|
562
572
|
threshold: number
|
|
563
|
-
|
|
564
|
-
|
|
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. */
|
|
565
577
|
color: Vec3
|
|
578
|
+
/** Multiplier on the glow added in the composite; 1 is the game. */
|
|
566
579
|
intensity: number
|
|
567
|
-
clamp: number
|
|
568
580
|
}
|
|
569
581
|
|
|
570
582
|
export const DEFAULT_BLOOM_OPTIONS: BloomOptions = {
|
|
571
583
|
enabled: true,
|
|
572
|
-
threshold: 0.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
intensity: 0.05,
|
|
577
|
-
clamp: 0.0,
|
|
584
|
+
threshold: 0.7,
|
|
585
|
+
scatter: 0.77,
|
|
586
|
+
color: new Vec3(1, 1, 1),
|
|
587
|
+
intensity: 1,
|
|
578
588
|
}
|
|
579
589
|
|
|
580
590
|
/** Camera depth of field — a bokeh gather in the composite pass. Costs nothing
|
|
@@ -613,34 +623,33 @@ export const DEFAULT_DEPTH_OF_FIELD_OPTIONS: DepthOfFieldOptions = {
|
|
|
613
623
|
quality: "balanced",
|
|
614
624
|
}
|
|
615
625
|
|
|
616
|
-
/**
|
|
626
|
+
/** How the scene-linear frame becomes a display image: exposure, then a tone
|
|
627
|
+
* transform, then display gamma. */
|
|
617
628
|
export type ViewTransformOptions = {
|
|
618
|
-
/** Stops applied before
|
|
629
|
+
/** Stops applied before the transform: `linear *= 2^exposure`. */
|
|
619
630
|
exposure: number
|
|
620
|
-
/** After
|
|
631
|
+
/** After the transform, display gamma (`pow(rgb, 1/gamma)`). */
|
|
621
632
|
gamma: number
|
|
622
633
|
/**
|
|
623
|
-
* Which display transform the frame is formed with.
|
|
624
|
-
*
|
|
625
|
-
* "standard" is Blender's Standard: the sRGB encoding and nothing else, which
|
|
626
|
-
* is what NPR and anime work uses — the colours the graph computes are the
|
|
627
|
-
* colours that land, with no film curve reinterpreting them. Both of the
|
|
628
|
-
* reference Wuthering Waves projects render this way.
|
|
634
|
+
* Which display transform the frame is formed with (composite.ts, viewTransform).
|
|
629
635
|
*
|
|
630
|
-
* "
|
|
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.
|
|
631
641
|
*
|
|
632
|
-
*
|
|
633
|
-
* its scenes are graded through (composite.ts, agTransform).
|
|
642
|
+
* All four end in the sRGB encoding.
|
|
634
643
|
*/
|
|
635
|
-
transform:
|
|
644
|
+
transform: ViewTransformName
|
|
636
645
|
}
|
|
637
646
|
|
|
638
|
-
|
|
639
|
-
|
|
647
|
+
export type ViewTransformName = "soft" | "neutral" | "aces" | "none"
|
|
648
|
+
|
|
640
649
|
export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
|
|
641
|
-
exposure: 0
|
|
650
|
+
exposure: 0,
|
|
642
651
|
gamma: 1.0,
|
|
643
|
-
transform: "
|
|
652
|
+
transform: "soft",
|
|
644
653
|
}
|
|
645
654
|
|
|
646
655
|
/** Color grading applied to the tonemapped scene (ASC CDL — see grade() in
|
|
@@ -735,9 +744,9 @@ export type EngineOptions = {
|
|
|
735
744
|
/** Canvas background (display-space sRGB 0–1), composited under the scene after
|
|
736
745
|
* tonemapping. Omit/null = transparent canvas (see setBackgroundColor). */
|
|
737
746
|
background?: Vec3 | null
|
|
738
|
-
/** Initial
|
|
747
|
+
/** Initial bloom; tune at runtime with `setBloomOptions`. */
|
|
739
748
|
bloom?: Partial<BloomOptions>
|
|
740
|
-
/** View transform (exposure
|
|
749
|
+
/** View transform (exposure, tone transform, gamma) applied in the composite. */
|
|
741
750
|
view?: Partial<ViewTransformOptions>
|
|
742
751
|
onRaycast?: RaycastCallback
|
|
743
752
|
/** See {@link GizmoDragCallback}. */
|
|
@@ -930,12 +939,16 @@ interface ModelInstance {
|
|
|
930
939
|
indexBuffer: GPUBuffer
|
|
931
940
|
jointsBuffer: GPUBuffer
|
|
932
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
|
|
933
946
|
skinMatrixBuffer: GPUBuffer
|
|
934
947
|
drawCalls: DrawCall[]
|
|
935
948
|
shadowDrawCalls: DrawCall[]
|
|
936
949
|
shadowBindGroups: GPUBindGroup[]
|
|
937
950
|
mainPerInstanceBindGroup: GPUBindGroup
|
|
938
|
-
/** Its ObjectLight — rendering layers, its own ambient — and the CPU copy it
|
|
951
|
+
/** Its ObjectLight — rendering layers, its own ambient, its fill — and the CPU copy it
|
|
939
952
|
* is written from. Every model has one: its layers decide its lights. */
|
|
940
953
|
lightBuffer: GPUBuffer
|
|
941
954
|
objectLight: Float32Array<ArrayBuffer>
|
|
@@ -2505,6 +2518,7 @@ export class Engine {
|
|
|
2505
2518
|
* premultiplied quantity and a blur has to move them together. */
|
|
2506
2519
|
private mirrorMaskMipViews: GPUTextureView[] = []
|
|
2507
2520
|
private mirrorMaskBlurBindGroups: GPUBindGroup[] | null = null
|
|
2521
|
+
private mirrorDownsamplePipeline: GPURenderPipeline | null = null
|
|
2508
2522
|
private mirrorMaskDownsamplePipeline: GPURenderPipeline | null = null
|
|
2509
2523
|
/** Two blocks the ground reads its clip from: the camera's is inert, the
|
|
2510
2524
|
* mirror's carries the live plane. Which one a draw sees is decided by which
|
|
@@ -2699,8 +2713,6 @@ export class Engine {
|
|
|
2699
2713
|
/** Trail samples owed this frame, computed once so every trail on every
|
|
2700
2714
|
* character samples in lockstep and their paths stay comparable. */
|
|
2701
2715
|
private trailDue = 0
|
|
2702
|
-
private agxLutTexture: GPUTexture | null = null
|
|
2703
|
-
private agxFallbackTexture!: GPUTexture
|
|
2704
2716
|
/** The scene's own grade (setStageGrade): the cube as handed over, kept so a
|
|
2705
2717
|
* call before init() still lands, and its texture once uploaded. */
|
|
2706
2718
|
private stageGradeCube: StageGradeLut | null = null
|
|
@@ -2714,12 +2726,8 @@ export class Engine {
|
|
|
2714
2726
|
* `time` is measured from here — see where it is written. */
|
|
2715
2727
|
private compositeBloomView: GPUTextureView | null = null
|
|
2716
2728
|
|
|
2717
|
-
//
|
|
2718
|
-
//
|
|
2719
|
-
// N-1 downsamples (13-tap Jimenez/COD box filter, 5 group averages)
|
|
2720
|
-
// N-1 upsamples (9-tap tent, additively combined with corresponding downsample mip)
|
|
2721
|
-
// composite adds bloomUp mip 0 × (color × intensity) to HDR before Filmic.
|
|
2722
|
-
// Matches EEVEE energy: tint/intensity applied at composite, not prefilter.
|
|
2729
|
+
// Linear clamp sampler for the bloom chain (also the mirror blur and the
|
|
2730
|
+
// composite's bloom read).
|
|
2723
2731
|
private bloomSampler!: GPUSampler
|
|
2724
2732
|
// Screen-space subsurface scattering (passes/subsurface.ts). The scratch target
|
|
2725
2733
|
// is the X pass's output and lives with the canvas size; the bind groups name
|
|
@@ -2737,33 +2745,56 @@ export class Engine {
|
|
|
2737
2745
|
private readonly pipelineDescs = new WeakMap<GPURenderPipeline, GPURenderPipelineDescriptor>()
|
|
2738
2746
|
private readonly singleSided = new WeakMap<GPURenderPipeline, GPURenderPipeline>()
|
|
2739
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
|
|
2740
2763
|
// Stepped motion (#stepped). The models whose pose is being HELD this frame —
|
|
2741
2764
|
// their skinning and morph uploads wait for the stepped clock's next tick —
|
|
2742
2765
|
// and the tick each was last shown at. Empty while no effect declares it.
|
|
2743
2766
|
private readonly steppedHeld = new Set<string>()
|
|
2744
2767
|
private readonly steppedTick = new Map<string, number>()
|
|
2745
2768
|
private steppedLastClock = -1
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
private
|
|
2752
|
-
private
|
|
2753
|
-
private bloomBlitBindGroupLayout!: GPUBindGroupLayout
|
|
2754
|
-
private bloomDownsampleBindGroupLayout!: GPUBindGroupLayout
|
|
2755
|
-
private bloomUpsampleBindGroupLayout!: GPUBindGroupLayout
|
|
2756
|
-
private bloomDownTexture!: GPUTexture
|
|
2757
|
-
private bloomUpTexture!: GPUTexture
|
|
2758
|
-
private bloomMipCount = 0
|
|
2759
|
-
private bloomDownMipViews: GPUTextureView[] = []
|
|
2760
|
-
private bloomUpMipViews: GPUTextureView[] = []
|
|
2761
|
-
private bloomBlitBindGroup!: GPUBindGroup
|
|
2762
|
-
private bloomDownsampleBindGroups: GPUBindGroup[] = []
|
|
2763
|
-
private bloomUpsampleBindGroups: GPUBindGroup[] = []
|
|
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
|
|
2764
2776
|
/** Single-attachment pass; colorAttachments[0].view set per bloom step. */
|
|
2765
2777
|
private bloomPassDescriptor!: GPURenderPassDescriptor
|
|
2766
|
-
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
|
|
2767
2798
|
|
|
2768
2799
|
// Ground properties (shadow only)
|
|
2769
2800
|
private groundVertexBuffer?: GPUBuffer
|
|
@@ -2777,10 +2808,6 @@ export class Engine {
|
|
|
2777
2808
|
private shadowAtlasView!: GPUTextureView
|
|
2778
2809
|
private brdfLutTexture!: GPUTexture
|
|
2779
2810
|
private brdfLutView!: GPUTextureView
|
|
2780
|
-
private filmicLutTexture!: GPUTexture
|
|
2781
|
-
private filmicLutView!: GPUTextureView
|
|
2782
|
-
// Width of the baked Filmic tone LUT (composite.ts FILMIC_LUT_W must match).
|
|
2783
|
-
private static readonly FILMIC_LUT_WIDTH = 256
|
|
2784
2811
|
private shadowDepthPipeline!: GPURenderPipeline
|
|
2785
2812
|
private shadowLightVPBuffer!: GPUBuffer
|
|
2786
2813
|
// The shadow PASS reads one cascade's matrix per pass, and a uniform binding
|
|
@@ -2922,18 +2949,16 @@ export class Engine {
|
|
|
2922
2949
|
this.backgroundColor = bg ? new Vec3(bg.x, bg.y, bg.z) : null
|
|
2923
2950
|
}
|
|
2924
2951
|
|
|
2925
|
-
/** Merge partial bloom with
|
|
2952
|
+
/** Merge partial bloom with the defaults (same as constructor). */
|
|
2926
2953
|
static mergeBloomDefaults(partial?: Partial<BloomOptions>): BloomOptions {
|
|
2927
2954
|
const d = DEFAULT_BLOOM_OPTIONS
|
|
2928
2955
|
const c = partial?.color
|
|
2929
2956
|
return {
|
|
2930
2957
|
enabled: partial?.enabled ?? d.enabled,
|
|
2931
2958
|
threshold: partial?.threshold ?? d.threshold,
|
|
2932
|
-
|
|
2933
|
-
radius: partial?.radius ?? d.radius,
|
|
2959
|
+
scatter: partial?.scatter ?? d.scatter,
|
|
2934
2960
|
color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
|
|
2935
2961
|
intensity: partial?.intensity ?? d.intensity,
|
|
2936
|
-
clamp: partial?.clamp ?? d.clamp,
|
|
2937
2962
|
}
|
|
2938
2963
|
}
|
|
2939
2964
|
|
|
@@ -2946,17 +2971,15 @@ export class Engine {
|
|
|
2946
2971
|
}
|
|
2947
2972
|
}
|
|
2948
2973
|
|
|
2949
|
-
/** Current bloom settings (
|
|
2974
|
+
/** Current bloom settings (tint is a copied `Vec3`). */
|
|
2950
2975
|
getBloomOptions(): BloomOptions {
|
|
2951
2976
|
const b = this.bloomSettings
|
|
2952
2977
|
return {
|
|
2953
2978
|
enabled: b.enabled,
|
|
2954
2979
|
threshold: b.threshold,
|
|
2955
|
-
|
|
2956
|
-
radius: b.radius,
|
|
2980
|
+
scatter: b.scatter,
|
|
2957
2981
|
color: new Vec3(b.color.x, b.color.y, b.color.z),
|
|
2958
2982
|
intensity: b.intensity,
|
|
2959
|
-
clamp: b.clamp,
|
|
2960
2983
|
}
|
|
2961
2984
|
}
|
|
2962
2985
|
|
|
@@ -3110,6 +3133,14 @@ export class Engine {
|
|
|
3110
3133
|
return b.enabled && b.intensity > 0
|
|
3111
3134
|
}
|
|
3112
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
|
+
|
|
3113
3144
|
private writeCompositeViewUniforms(): void {
|
|
3114
3145
|
const v = this.viewTransform
|
|
3115
3146
|
const b = this.bloomSettings
|
|
@@ -3152,7 +3183,7 @@ export class Engine {
|
|
|
3152
3183
|
// rebuilt per effect, so the compiled variant IS the flag.
|
|
3153
3184
|
u[11] = this.backdropEquirectView ? 2 : showingWorld ? 3 : bg ? 1 : 0
|
|
3154
3185
|
// Which display transform forms the frame (see viewTransform in composite.ts).
|
|
3155
|
-
u[25] =
|
|
3186
|
+
u[25] = Engine.VIEW_TRANSFORM_ID[v.transform] ?? 0
|
|
3156
3187
|
u[26] = this.canvas.width
|
|
3157
3188
|
u[27] = this.canvas.height
|
|
3158
3189
|
// ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
|
|
@@ -3882,7 +3913,7 @@ export class Engine {
|
|
|
3882
3913
|
}
|
|
3883
3914
|
|
|
3884
3915
|
/**
|
|
3885
|
-
* Fill the mirror's mip levels —
|
|
3916
|
+
* Fill the mirror's mip levels — a 13-tap downsample (MIRROR_DOWNSAMPLE_WGSL),
|
|
3886
3917
|
* one pass per level. Only when the blur dial is up: at zero the ground
|
|
3887
3918
|
* samples level 0 exactly and the chain would be work nobody reads.
|
|
3888
3919
|
*/
|
|
@@ -3892,8 +3923,19 @@ export class Engine {
|
|
|
3892
3923
|
// memory, which read as the model going black the moment the slider moved.
|
|
3893
3924
|
const blur = Math.max(this.groundMirrorBlur, this.mirrorSurface?.blur ?? 0)
|
|
3894
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
|
|
3895
3937
|
if (!this.mirrorBlurBindGroups) {
|
|
3896
|
-
const layout =
|
|
3938
|
+
const layout = downsample.getBindGroupLayout(0)
|
|
3897
3939
|
this.mirrorBlurBindGroups = []
|
|
3898
3940
|
for (let i = 1; i < this.mirrorMipCount; i++) {
|
|
3899
3941
|
this.mirrorBlurBindGroups.push(
|
|
@@ -3913,7 +3955,7 @@ export class Engine {
|
|
|
3913
3955
|
label: `mirror blur ${i}`,
|
|
3914
3956
|
colorAttachments: [{ view: this.mirrorMipViews[i], loadOp: "clear", storeOp: "store" }],
|
|
3915
3957
|
})
|
|
3916
|
-
p.setPipeline(
|
|
3958
|
+
p.setPipeline(downsample)
|
|
3917
3959
|
p.setBindGroup(0, this.mirrorBlurBindGroups[i - 1])
|
|
3918
3960
|
p.draw(3)
|
|
3919
3961
|
p.end()
|
|
@@ -4081,7 +4123,6 @@ export class Engine {
|
|
|
4081
4123
|
{ binding: 2, resource: this.bloomSampler },
|
|
4082
4124
|
{ binding: 3, resource: { buffer: this.compositeUniformBuffer } },
|
|
4083
4125
|
{ binding: 4, resource: this.maskResolveView },
|
|
4084
|
-
{ binding: 5, resource: this.filmicLutView },
|
|
4085
4126
|
// Whichever equirect is SHOWING — the backdrop if there is one, the
|
|
4086
4127
|
// world otherwise. The world's light does not come through here; it
|
|
4087
4128
|
// rides worldSH into the material shells.
|
|
@@ -4089,7 +4130,6 @@ export class Engine {
|
|
|
4089
4130
|
{ binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
|
|
4090
4131
|
{ binding: 8, resource: this.depthReadView },
|
|
4091
4132
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
4092
|
-
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
4093
4133
|
{ binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
|
|
4094
4134
|
{ binding: 11, resource: { buffer: this.castBuffer } },
|
|
4095
4135
|
{ binding: 13, resource: { buffer: this.audioBuffer } },
|
|
@@ -4527,8 +4567,6 @@ export class Engine {
|
|
|
4527
4567
|
{ binding: 31, resource: layers[2] },
|
|
4528
4568
|
{ binding: 32, resource: layers[3] },
|
|
4529
4569
|
{ binding: 2, resource: this.bloomSampler },
|
|
4530
|
-
{ binding: 5, resource: this.filmicLutView },
|
|
4531
|
-
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
4532
4570
|
{ binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
|
|
4533
4571
|
{ binding: 1, resource: bloom },
|
|
4534
4572
|
{ binding: 4, resource: this.maskResolveView },
|
|
@@ -4790,21 +4828,38 @@ export class Engine {
|
|
|
4790
4828
|
if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
|
|
4791
4829
|
}
|
|
4792
4830
|
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
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)")])
|
|
4806
4860
|
}
|
|
4807
4861
|
|
|
4862
|
+
|
|
4808
4863
|
/**
|
|
4809
4864
|
* Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
|
|
4810
4865
|
* composite pass. ONE effect per scene, and the code says where it mounts by
|
|
@@ -5042,11 +5097,11 @@ export class Engine {
|
|
|
5042
5097
|
hasBackground || hasForeground
|
|
5043
5098
|
? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length, filter, additiveLayer: d.additiveLayer }
|
|
5044
5099
|
: null
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
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.
|
|
5050
5105
|
|
|
5051
5106
|
// Declared like every other mount property: by what the source says, not by
|
|
5052
5107
|
// a setting somewhere else that an author cannot see from the file.
|
|
@@ -5058,16 +5113,11 @@ export class Engine {
|
|
|
5058
5113
|
const fieldSource = buildFieldShader({ ...fieldEffect, ids: mrtIdsEnabled() })
|
|
5059
5114
|
const userLineOffset = fieldSource.slice(0, fieldSource.indexOf(wgsl)).split("\n").length - 1
|
|
5060
5115
|
this.device.pushErrorScope("validation")
|
|
5061
|
-
const fieldModule = this.
|
|
5062
|
-
const
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
.map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
|
|
5067
|
-
if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
|
|
5068
|
-
if (diagnostics.length > 0) return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
5069
|
-
try {
|
|
5070
|
-
fieldPipeline = await this.device.createRenderPipelineAsync({
|
|
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({
|
|
5071
5121
|
label: "field layer pipeline",
|
|
5072
5122
|
layout: this.fieldPipelineLayout,
|
|
5073
5123
|
vertex: { module: fieldModule, entryPoint: "fieldVs" },
|
|
@@ -5095,33 +5145,22 @@ export class Engine {
|
|
|
5095
5145
|
primitive: { topology: "triangle-list" },
|
|
5096
5146
|
multisample: { count: 1 },
|
|
5097
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
|
|
5098
5160
|
} catch (e) {
|
|
5099
5161
|
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
5100
5162
|
}
|
|
5101
5163
|
}
|
|
5102
|
-
let identity: GPURenderPipeline
|
|
5103
|
-
let gamma: GPURenderPipeline
|
|
5104
|
-
try {
|
|
5105
|
-
const make = (applyGamma: boolean, label: string) =>
|
|
5106
|
-
this.device.createRenderPipelineAsync({
|
|
5107
|
-
label,
|
|
5108
|
-
layout: this.compositePipelineLayout,
|
|
5109
|
-
vertex: { module, entryPoint: "vs" },
|
|
5110
|
-
fragment: {
|
|
5111
|
-
module,
|
|
5112
|
-
entryPoint: "fs",
|
|
5113
|
-
constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
|
|
5114
|
-
targets: [{ format: this.presentationFormat }],
|
|
5115
|
-
},
|
|
5116
|
-
primitive: { topology: "triangle-list" },
|
|
5117
|
-
})
|
|
5118
|
-
;[identity, gamma] = await Promise.all([
|
|
5119
|
-
make(false, "composite pipeline (effect, gamma=1)"),
|
|
5120
|
-
make(true, "composite pipeline (effect, gamma!=1)"),
|
|
5121
|
-
])
|
|
5122
|
-
} catch (e) {
|
|
5123
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
5124
|
-
}
|
|
5125
5164
|
|
|
5126
5165
|
// Built BEFORE the swap: a particle stage that fails to compile has to leave
|
|
5127
5166
|
// the previously installed effect running, exactly as a bad composite does.
|
|
@@ -5365,6 +5404,9 @@ export class Engine {
|
|
|
5365
5404
|
|
|
5366
5405
|
const requested = list ?? []
|
|
5367
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)
|
|
5368
5410
|
for (const e of this.effects) {
|
|
5369
5411
|
e.paramsBuffer?.destroy()
|
|
5370
5412
|
e.lights?.uniform.destroy()
|
|
@@ -5377,9 +5419,8 @@ export class Engine {
|
|
|
5377
5419
|
this.allocateLightSlots()
|
|
5378
5420
|
this.anchorTable = EMPTY_ANCHOR_TABLE
|
|
5379
5421
|
this.clearTrailHistory()
|
|
5380
|
-
|
|
5381
|
-
this.
|
|
5382
|
-
this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)")
|
|
5422
|
+
this.compositePipelineIdentity = identity
|
|
5423
|
+
this.compositePipelineGamma = gamma
|
|
5383
5424
|
this.rebuildCompositeBindGroup()
|
|
5384
5425
|
this.writeCompositeViewUniforms()
|
|
5385
5426
|
return []
|
|
@@ -5435,6 +5476,14 @@ export class Engine {
|
|
|
5435
5476
|
if (r) r.diagnostics.push(`anchor "${d.bone}" dropped: the scene is already using all ${MAX_EFFECT_ANCHORS} slots`)
|
|
5436
5477
|
}
|
|
5437
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
|
+
|
|
5438
5487
|
// ── Swap. Everything above either succeeded or was excluded, so the scene
|
|
5439
5488
|
// that was running is only torn down now.
|
|
5440
5489
|
for (const e of this.effects) {
|
|
@@ -5486,20 +5535,8 @@ export class Engine {
|
|
|
5486
5535
|
// Scene-level, from the union: the composite decides only whether to SAMPLE
|
|
5487
5536
|
// the field layers, so one effect with a background is enough to turn that
|
|
5488
5537
|
// on for the frame.
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
const compositeModule = this.device.createShaderModule({
|
|
5492
|
-
label: "composite shader (effects)",
|
|
5493
|
-
code: buildCompositeShader(
|
|
5494
|
-
hasBackground || hasForeground
|
|
5495
|
-
? // No wgsl and so no trails: the composite hosts no effect source at
|
|
5496
|
-
// all, it only decides whether to sample the layer the field pass drew.
|
|
5497
|
-
{ wgsl: "", paramsDecl: "", hasBackground, hasForeground, gridSize: 0, trailCount: 0 }
|
|
5498
|
-
: null,
|
|
5499
|
-
),
|
|
5500
|
-
})
|
|
5501
|
-
this.compositePipelineIdentity = this.makeCompositePipeline(compositeModule, false, "composite pipeline (gamma=1)")
|
|
5502
|
-
this.compositePipelineGamma = this.makeCompositePipeline(compositeModule, true, "composite pipeline (gamma!=1)")
|
|
5538
|
+
this.compositePipelineIdentity = compositeIdentity
|
|
5539
|
+
this.compositePipelineGamma = compositeGamma
|
|
5503
5540
|
|
|
5504
5541
|
// Nothing to promote any more: `#fullres` is per effect, read into
|
|
5505
5542
|
// fieldLayer when the instance is built, and both target pairs exist for
|
|
@@ -5596,26 +5633,32 @@ export class Engine {
|
|
|
5596
5633
|
alias,
|
|
5597
5634
|
}
|
|
5598
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.
|
|
5599
5639
|
const compile = async (code: string, label: string): Promise<GPUShaderModule | string[]> => {
|
|
5600
5640
|
const offset = code.slice(0, code.indexOf(wgsl)).split("\n").length - 1
|
|
5601
5641
|
this.device.pushErrorScope("validation")
|
|
5602
|
-
const module = this.
|
|
5603
|
-
const
|
|
5604
|
-
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])
|
|
5605
5645
|
const diagnostics = info.messages
|
|
5606
5646
|
.filter((m) => m.type === "error")
|
|
5607
5647
|
.map((m) => `${Math.max(0, m.lineNum - offset)}:${m.linePos} ${m.message}`)
|
|
5608
5648
|
if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
|
|
5649
|
+
if (diagnostics.length) this.forgetShaderModule(code)
|
|
5609
5650
|
return diagnostics.length ? diagnostics : module
|
|
5610
5651
|
}
|
|
5611
5652
|
|
|
5612
|
-
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
|
+
])
|
|
5613
5657
|
if (Array.isArray(computeModule)) {
|
|
5614
5658
|
points?.buffer.destroy()
|
|
5615
5659
|
dropTextures()
|
|
5616
5660
|
return { ok: false, diagnostics: computeModule }
|
|
5617
5661
|
}
|
|
5618
|
-
const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render")
|
|
5619
5662
|
if (Array.isArray(renderModule)) {
|
|
5620
5663
|
points?.buffer.destroy()
|
|
5621
5664
|
dropTextures()
|
|
@@ -5656,7 +5699,7 @@ export class Engine {
|
|
|
5656
5699
|
// layout invalid, and the error surfaces later and unhelpfully as "invalid
|
|
5657
5700
|
// due to a previous error".
|
|
5658
5701
|
const layoutFor = (storage: GPUBufferBindingType, visibility: number, shadow: boolean, withIndirect: boolean) =>
|
|
5659
|
-
this.
|
|
5702
|
+
this.cachedBindGroupLayout({
|
|
5660
5703
|
entries: [
|
|
5661
5704
|
{ binding: 0, visibility, buffer: { type: storage } },
|
|
5662
5705
|
{ binding: 1, visibility, buffer: { type: "uniform" } },
|
|
@@ -5768,32 +5811,29 @@ export class Engine {
|
|
|
5768
5811
|
const targets = sceneTargetsFor(src.blend === "additive" ? "particle-additive" : "particle", this.sceneFormats)
|
|
5769
5812
|
const cutout = src.blend === "cutout"
|
|
5770
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.
|
|
5771
5817
|
this.device.pushErrorScope("validation")
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5818
|
+
const renderLayoutObj = this.cachedPipelineLayout([renderLayout])
|
|
5819
|
+
const builds = Promise.all([
|
|
5820
|
+
this.cachedComputePipeline({
|
|
5775
5821
|
label: "particle compute pipeline",
|
|
5776
|
-
layout:
|
|
5822
|
+
layout: this.cachedPipelineLayout([computeLayout]),
|
|
5777
5823
|
compute: { module: computeModule, entryPoint: "main" },
|
|
5778
|
-
})
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
}),
|
|
5787
|
-
indirect,
|
|
5788
|
-
binds: bindFor(countLayout, this.cameraUniformBuffer, false, true),
|
|
5789
|
-
}
|
|
5790
|
-
: null
|
|
5791
|
-
const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] })
|
|
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,
|
|
5792
5832
|
// The prepass, when the effect gave the cutout a cheap shape: coverage to
|
|
5793
5833
|
// depth, colour targets at writeMask 0 (the depth-prepass class), and
|
|
5794
5834
|
// the shading pass below then tests EQUAL and writes no depth.
|
|
5795
|
-
|
|
5796
|
-
?
|
|
5835
|
+
prepass
|
|
5836
|
+
? this.cachedRenderPipeline({
|
|
5797
5837
|
label: "particle depth prepass pipeline",
|
|
5798
5838
|
layout: renderLayoutObj,
|
|
5799
5839
|
vertex: { module: renderModule, entryPoint: "vs" },
|
|
@@ -5802,8 +5842,8 @@ export class Engine {
|
|
|
5802
5842
|
depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
|
|
5803
5843
|
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
5804
5844
|
})
|
|
5805
|
-
: null
|
|
5806
|
-
|
|
5845
|
+
: null,
|
|
5846
|
+
this.cachedRenderPipeline({
|
|
5807
5847
|
label: "particle render pipeline",
|
|
5808
5848
|
layout: renderLayoutObj,
|
|
5809
5849
|
vertex: { module: renderModule, entryPoint: "vs" },
|
|
@@ -5824,8 +5864,20 @@ export class Engine {
|
|
|
5824
5864
|
? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
|
|
5825
5865
|
: { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
|
|
5826
5866
|
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
5827
|
-
})
|
|
5828
|
-
|
|
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
|
|
5829
5881
|
if (scoped) {
|
|
5830
5882
|
buffer.destroy()
|
|
5831
5883
|
uniform.destroy()
|
|
@@ -5865,7 +5917,7 @@ export class Engine {
|
|
|
5865
5917
|
},
|
|
5866
5918
|
}
|
|
5867
5919
|
} catch (e) {
|
|
5868
|
-
await
|
|
5920
|
+
await scope
|
|
5869
5921
|
buffer.destroy()
|
|
5870
5922
|
uniform.destroy()
|
|
5871
5923
|
points?.buffer.destroy()
|
|
@@ -7120,41 +7172,170 @@ export class Engine {
|
|
|
7120
7172
|
const b = this.bloomSettings
|
|
7121
7173
|
if (patch.enabled !== undefined) b.enabled = patch.enabled
|
|
7122
7174
|
if (patch.threshold !== undefined) b.threshold = patch.threshold
|
|
7123
|
-
if (patch.
|
|
7124
|
-
if (patch.radius !== undefined) b.radius = patch.radius
|
|
7175
|
+
if (patch.scatter !== undefined) b.scatter = patch.scatter
|
|
7125
7176
|
if (patch.color !== undefined) {
|
|
7126
7177
|
b.color.x = patch.color.x
|
|
7127
7178
|
b.color.y = patch.color.y
|
|
7128
7179
|
b.color.z = patch.color.z
|
|
7129
7180
|
}
|
|
7130
7181
|
if (patch.intensity !== undefined) b.intensity = patch.intensity
|
|
7131
|
-
if (
|
|
7132
|
-
if (this.device && this.bloomBlitUniformBuffer) {
|
|
7182
|
+
if (this.device && this.compositeUniformBuffer) {
|
|
7133
7183
|
this.writeBloomUniforms()
|
|
7134
7184
|
this.writeCompositeViewUniforms()
|
|
7135
7185
|
}
|
|
7136
7186
|
}
|
|
7137
7187
|
|
|
7138
|
-
|
|
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
|
+
|
|
7139
7298
|
private writeBloomUniforms(): void {
|
|
7299
|
+
if (!this.bloomUniformBuffer) return
|
|
7140
7300
|
const b = this.bloomSettings
|
|
7141
|
-
const
|
|
7142
|
-
//
|
|
7143
|
-
//
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
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
|
|
7158
7339
|
}
|
|
7159
7340
|
|
|
7160
7341
|
// Step 1: Get WebGPU device and context
|
|
@@ -7282,7 +7463,16 @@ export class Engine {
|
|
|
7282
7463
|
this.setupCamera()
|
|
7283
7464
|
this.setupLighting()
|
|
7284
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 = []
|
|
7285
7470
|
this.createPipelines()
|
|
7471
|
+
try {
|
|
7472
|
+
await Promise.all(this.initPipelineJobs)
|
|
7473
|
+
} finally {
|
|
7474
|
+
this.initPipelineJobs = null
|
|
7475
|
+
}
|
|
7286
7476
|
this.setupResize()
|
|
7287
7477
|
const ms = (a: number, b: number) => Math.round(b - a)
|
|
7288
7478
|
console.info(
|
|
@@ -7413,123 +7603,11 @@ export class Engine {
|
|
|
7413
7603
|
ltcTemp.destroy()
|
|
7414
7604
|
}
|
|
7415
7605
|
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
// cubic (Fritsch–Carlson) through them so the curve is C1-continuous (no Mach banding in
|
|
7419
|
-
// smooth gradients) while still passing through every anchor (look preserved) and staying
|
|
7420
|
-
// monotone (no tonemap overshoot/ringing). Domain is uniform in log2 space: anchor k sits
|
|
7421
|
-
// at t=k, k=0..13 (t = log2(linear)+10). See composite.ts::filmic for the sampling map.
|
|
7422
|
-
/**
|
|
7423
|
-
* Decompress and upload Blender's AgX cube.
|
|
7424
|
-
*
|
|
7425
|
-
* Deliberately off the critical path: it is 723 KB once inflated, and a frame
|
|
7426
|
-
* rendered before it lands should show the scene under whatever transform is
|
|
7427
|
-
* already there rather than wait. Until then binding 10 holds a 1×1×1 stand-in,
|
|
7428
|
-
* which is only ever sampled if someone selects AgX in that window.
|
|
7429
|
-
*/
|
|
7430
|
-
private async loadAgxLut(): Promise<void> {
|
|
7431
|
-
try {
|
|
7432
|
-
const packed = Uint8Array.from(atob(AGX_LUT_GZ), (ch) => ch.charCodeAt(0))
|
|
7433
|
-
const stream = new Blob([packed]).stream().pipeThrough(new DecompressionStream("gzip"))
|
|
7434
|
-
const bytes = new Uint8Array(await new Response(stream).arrayBuffer())
|
|
7435
|
-
const n = AGX_LUT_SIZE
|
|
7436
|
-
if (bytes.byteLength !== n * n * n * 4) throw new Error(`AgX LUT is ${bytes.byteLength} bytes, expected ${n ** 3 * 4}`)
|
|
7437
|
-
const tex = this.device.createTexture({
|
|
7438
|
-
label: "AgX 57³ LUT",
|
|
7439
|
-
size: [n, n, n],
|
|
7440
|
-
dimension: "3d",
|
|
7441
|
-
format: "rgb10a2unorm",
|
|
7442
|
-
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
7443
|
-
})
|
|
7444
|
-
// .cube order is red fastest, which is exactly a 3D texture's own layout.
|
|
7445
|
-
this.device.queue.writeTexture({ texture: tex }, bytes, { bytesPerRow: n * 4, rowsPerImage: n }, [n, n, n])
|
|
7446
|
-
this.agxLutTexture = tex
|
|
7447
|
-
this.rebuildCompositeBindGroup()
|
|
7448
|
-
} catch {
|
|
7449
|
-
// A missing LUT costs AgX, not the renderer — the other transforms stand.
|
|
7450
|
-
}
|
|
7451
|
-
}
|
|
7452
|
-
|
|
7453
|
-
private bakeFilmicLut() {
|
|
7454
|
-
const anchors = [
|
|
7455
|
-
0.0028, 0.0068, 0.0151, 0.0313, 0.061, 0.112, 0.192, 0.306, 0.459, 0.631, 0.82, 0.907, 0.962, 0.989,
|
|
7456
|
-
]
|
|
7457
|
-
const n = anchors.length
|
|
7458
|
-
// Secant slopes (unit spacing, so d_k = y_{k+1} - y_k).
|
|
7459
|
-
const d = new Array<number>(n - 1)
|
|
7460
|
-
for (let k = 0; k < n - 1; k++) d[k] = anchors[k + 1] - anchors[k]
|
|
7461
|
-
// Endpoint + interior tangents.
|
|
7462
|
-
const m = new Array<number>(n)
|
|
7463
|
-
m[0] = d[0]
|
|
7464
|
-
m[n - 1] = d[n - 2]
|
|
7465
|
-
for (let k = 1; k < n - 1; k++) m[k] = (d[k - 1] + d[k]) * 0.5
|
|
7466
|
-
// Fritsch–Carlson monotonicity clamp.
|
|
7467
|
-
for (let k = 0; k < n - 1; k++) {
|
|
7468
|
-
if (d[k] === 0) {
|
|
7469
|
-
m[k] = 0
|
|
7470
|
-
m[k + 1] = 0
|
|
7471
|
-
continue
|
|
7472
|
-
}
|
|
7473
|
-
const a = m[k] / d[k]
|
|
7474
|
-
const b = m[k + 1] / d[k]
|
|
7475
|
-
const s = a * a + b * b
|
|
7476
|
-
if (s > 9) {
|
|
7477
|
-
const tau = 3 / Math.sqrt(s)
|
|
7478
|
-
m[k] = tau * a * d[k]
|
|
7479
|
-
m[k + 1] = tau * b * d[k]
|
|
7480
|
-
}
|
|
7481
|
-
}
|
|
7482
|
-
const W = Engine.FILMIC_LUT_WIDTH
|
|
7483
|
-
const values = new Float32Array(W)
|
|
7484
|
-
for (let j = 0; j < W; j++) {
|
|
7485
|
-
const t = ((n - 1) * j) / (W - 1) // t ∈ [0, n-1]
|
|
7486
|
-
const k = Math.min(Math.floor(t), n - 2)
|
|
7487
|
-
const s = t - k // local param in [0,1], unit-spaced segment
|
|
7488
|
-
const s2 = s * s
|
|
7489
|
-
const s3 = s2 * s
|
|
7490
|
-
// Hermite basis (h=1).
|
|
7491
|
-
const h00 = 2 * s3 - 3 * s2 + 1
|
|
7492
|
-
const h10 = s3 - 2 * s2 + s
|
|
7493
|
-
const h01 = -2 * s3 + 3 * s2
|
|
7494
|
-
const h11 = s3 - s2
|
|
7495
|
-
values[j] = h00 * anchors[k] + h10 * m[k] + h01 * anchors[k + 1] + h11 * m[k + 1]
|
|
7496
|
-
}
|
|
7497
|
-
|
|
7498
|
-
// f32 → f16 bits (same conversion as bakeBrdfLut).
|
|
7499
|
-
const half = new Uint16Array(W)
|
|
7500
|
-
const f32 = new Float32Array(1)
|
|
7501
|
-
const u32 = new Uint32Array(f32.buffer)
|
|
7502
|
-
for (let j = 0; j < W; j++) {
|
|
7503
|
-
f32[0] = values[j]
|
|
7504
|
-
const x = u32[0]
|
|
7505
|
-
const sign = (x >>> 16) & 0x8000
|
|
7506
|
-
const exp = ((x >>> 23) & 0xff) - 127 + 15
|
|
7507
|
-
const mant = x & 0x7fffff
|
|
7508
|
-
if (exp <= 0) {
|
|
7509
|
-
half[j] = sign
|
|
7510
|
-
} else if (exp >= 31) {
|
|
7511
|
-
half[j] = sign | 0x7c00
|
|
7512
|
-
} else {
|
|
7513
|
-
half[j] = sign | (exp << 10) | (mant >>> 13)
|
|
7514
|
-
}
|
|
7515
|
-
}
|
|
7516
|
-
|
|
7517
|
-
this.filmicLutTexture = this.device.createTexture({
|
|
7518
|
-
label: "Filmic tone LUT",
|
|
7519
|
-
size: [W, 1],
|
|
7520
|
-
format: "r16float",
|
|
7521
|
-
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
|
|
7522
|
-
})
|
|
7523
|
-
this.filmicLutView = this.filmicLutTexture.createView()
|
|
7524
|
-
this.device.queue.writeTexture(
|
|
7525
|
-
{ texture: this.filmicLutTexture },
|
|
7526
|
-
half,
|
|
7527
|
-
{ bytesPerRow: W * 2, rowsPerImage: 1 },
|
|
7528
|
-
{ width: W, height: 1, depthOrArrayLayers: 1 },
|
|
7529
|
-
)
|
|
7606
|
+
private createRenderPipeline(config: Parameters<Engine["renderPipelineDesc"]>[0]): GPURenderPipeline {
|
|
7607
|
+
return this.createModelPipeline(this.renderPipelineDesc(config))
|
|
7530
7608
|
}
|
|
7531
7609
|
|
|
7532
|
-
private
|
|
7610
|
+
private renderPipelineDesc(config: {
|
|
7533
7611
|
label: string
|
|
7534
7612
|
layout: GPUPipelineLayout
|
|
7535
7613
|
shaderModule: GPUShaderModule
|
|
@@ -7540,9 +7618,9 @@ export class Engine {
|
|
|
7540
7618
|
cullMode?: GPUCullMode
|
|
7541
7619
|
depthStencil?: GPUDepthStencilState
|
|
7542
7620
|
multisample?: GPUMultisampleState
|
|
7543
|
-
}):
|
|
7621
|
+
}): GPURenderPipelineDescriptor {
|
|
7544
7622
|
const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined)
|
|
7545
|
-
return
|
|
7623
|
+
return {
|
|
7546
7624
|
label: config.label,
|
|
7547
7625
|
layout: config.layout,
|
|
7548
7626
|
vertex: {
|
|
@@ -7559,7 +7637,7 @@ export class Engine {
|
|
|
7559
7637
|
primitive: { cullMode: config.cullMode ?? "none" },
|
|
7560
7638
|
depthStencil: config.depthStencil,
|
|
7561
7639
|
multisample: config.multisample ?? { count: Engine.MULTISAMPLE_COUNT },
|
|
7562
|
-
}
|
|
7640
|
+
}
|
|
7563
7641
|
}
|
|
7564
7642
|
|
|
7565
7643
|
private createPipelines() {
|
|
@@ -7685,8 +7763,6 @@ export class Engine {
|
|
|
7685
7763
|
// creation with "binding doesn't exist", naming a binding no effect
|
|
7686
7764
|
// author ever wrote. Sharing the code meant sharing these.
|
|
7687
7765
|
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
7688
|
-
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
7689
|
-
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
|
|
7690
7766
|
{ binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
|
|
7691
7767
|
// And the scene's COVERAGE and bloom, without which the tap cannot
|
|
7692
7768
|
// reconstruct a pixel: the HDR target is premultiplied, so colour alone
|
|
@@ -7708,31 +7784,40 @@ export class Engine {
|
|
|
7708
7784
|
const seedModule = this.device.createShaderModule({ label: "cast distance seed", code: buildCastSeedShader(Engine.MULTISAMPLE_COUNT) })
|
|
7709
7785
|
const stepModule = this.device.createShaderModule({ label: "cast distance step", code: buildCastStepShader() })
|
|
7710
7786
|
const resolveModule = this.device.createShaderModule({ label: "cast distance resolve", code: buildCastResolveShader() })
|
|
7711
|
-
this.
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
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" },
|
|
7719
7798
|
},
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
this.
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7735
|
-
|
|
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
|
+
)
|
|
7736
7821
|
// 65504 is the largest half float. Bound wherever the field is not
|
|
7737
7822
|
// running, so rzCastDistance answers "unreachably far" and an effect keyed
|
|
7738
7823
|
// on it draws nothing at all.
|
|
@@ -7933,34 +8018,42 @@ export class Engine {
|
|
|
7933
8018
|
// hand shaders are retired in favor of graphs.
|
|
7934
8019
|
const neutral = compileGraph(DEFAULT_GRAPH, { renderClass: "auto", alphaMode: "opaque" })
|
|
7935
8020
|
if (!neutral.ok) throw new Error("failed to compile the neutral default graph")
|
|
7936
|
-
const neutralModule = this.
|
|
7937
|
-
this.
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
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
|
+
)
|
|
7950
8039
|
// Depth-write-off twin for transparent-bucket draws (see pipelineForDrawCall).
|
|
7951
|
-
this.
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
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
|
+
)
|
|
7964
8057
|
// Depth-only prepass for transparent draws (see depth-prepass.ts): writes the
|
|
7965
8058
|
// fabric's depth AFTER its color blended, so outlines drawn later are
|
|
7966
8059
|
// occluded behind it. Color targets kept for pass compatibility, writeMask 0.
|
|
@@ -7979,51 +8072,63 @@ export class Engine {
|
|
|
7979
8072
|
depthCompare: this.depthAhead,
|
|
7980
8073
|
},
|
|
7981
8074
|
}
|
|
7982
|
-
this.
|
|
7983
|
-
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
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
|
+
},
|
|
7989
8084
|
},
|
|
7990
|
-
|
|
8085
|
+
(p) => (this.depthPrepassPipeline = p),
|
|
8086
|
+
true,
|
|
8087
|
+
)
|
|
7991
8088
|
// The SOLID prime: same module, cutoff forced to exactly 1.0. Only texels
|
|
7992
8089
|
// whose blend ignores the destination may pre-claim depth in the
|
|
7993
8090
|
// transparent phase — see the override's note in depth-prepass.ts.
|
|
7994
|
-
this.
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
|
|
8001
|
-
|
|
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
|
+
},
|
|
8002
8101
|
},
|
|
8003
|
-
|
|
8102
|
+
(p) => (this.solidPrepassPipeline = p),
|
|
8103
|
+
true,
|
|
8104
|
+
)
|
|
8004
8105
|
// The HAIR prime: solid texels only, and stencil-fenced off the eye
|
|
8005
8106
|
// silhouette. It records after the non-hair opaque draws, so the eye has
|
|
8006
8107
|
// already written its stencil — not-equal here is what keeps the primed
|
|
8007
8108
|
// hair depth from ever claiming the pixels the see-through-hair pass needs
|
|
8008
8109
|
// the eye to survive on. (Bundle draws use the PASS's stencil reference;
|
|
8009
8110
|
// only pipeline/bind/vertex state resets across executeBundles.)
|
|
8010
|
-
this.
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
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
|
+
},
|
|
8025
8128
|
},
|
|
8026
|
-
|
|
8129
|
+
(p) => (this.hairPrimePipeline = p),
|
|
8130
|
+
true,
|
|
8131
|
+
)
|
|
8027
8132
|
|
|
8028
8133
|
// The matrices, then one vec4: the caster sphere, which effects' rzShadow
|
|
8029
8134
|
// tests before any tap. The materials and the ground bind the matrices only.
|
|
@@ -8050,28 +8155,31 @@ export class Engine {
|
|
|
8050
8155
|
label: "shadow depth",
|
|
8051
8156
|
code: SHADOW_DEPTH_SHADER_WGSL,
|
|
8052
8157
|
})
|
|
8053
|
-
this.
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
8059
|
-
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
|
|
8072
|
-
|
|
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
|
+
},
|
|
8073
8180
|
},
|
|
8074
|
-
|
|
8181
|
+
(p) => (this.shadowDepthPipeline = p),
|
|
8182
|
+
)
|
|
8075
8183
|
this.shadowComparisonSampler = this.device.createSampler({
|
|
8076
8184
|
compare: "less",
|
|
8077
8185
|
magFilter: "linear",
|
|
@@ -8106,14 +8214,23 @@ export class Engine {
|
|
|
8106
8214
|
// One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
|
|
8107
8215
|
this.bakeBrdfLut()
|
|
8108
8216
|
this.bakeGroundNoise()
|
|
8109
|
-
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
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
|
+
}
|
|
8117
8234
|
this.stageGradeFallback = this.device.createTexture({
|
|
8118
8235
|
label: "stage grade fallback",
|
|
8119
8236
|
size: [1, 1, 1],
|
|
@@ -8122,7 +8239,6 @@ export class Engine {
|
|
|
8122
8239
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
8123
8240
|
})
|
|
8124
8241
|
this.uploadStageGrade()
|
|
8125
|
-
this.bakeFilmicLut()
|
|
8126
8242
|
|
|
8127
8243
|
// BEFORE the bind group below, which binds it. Full size from the start:
|
|
8128
8244
|
// every material pipeline binds this, so sizing it to the light count would
|
|
@@ -8213,7 +8329,10 @@ export class Engine {
|
|
|
8213
8329
|
cullMode: "back",
|
|
8214
8330
|
depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
|
|
8215
8331
|
}
|
|
8216
|
-
this.
|
|
8332
|
+
this.initRenderPipeline(
|
|
8333
|
+
this.renderPipelineDesc(this.groundPipelineConfig(false)),
|
|
8334
|
+
(p) => (this.groundShadowPipeline = p),
|
|
8335
|
+
)
|
|
8217
8336
|
|
|
8218
8337
|
// Outline: group 0 = per-frame (camera), group 1 = per-instance (skinMats), group 2 = per-material (edge uniforms)
|
|
8219
8338
|
this.outlinePerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
@@ -8282,7 +8401,12 @@ export class Engine {
|
|
|
8282
8401
|
label: "outline pipeline",
|
|
8283
8402
|
layout: outlinePipelineLayout,
|
|
8284
8403
|
shaderModule: outlineShaderModule,
|
|
8285
|
-
|
|
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
|
+
],
|
|
8286
8410
|
fragmentTargets: sceneTargetsFor("outline", this.sceneFormats),
|
|
8287
8411
|
cullMode: "back",
|
|
8288
8412
|
depthStencil: {
|
|
@@ -8316,12 +8440,16 @@ export class Engine {
|
|
|
8316
8440
|
stencilWriteMask: 0,
|
|
8317
8441
|
},
|
|
8318
8442
|
}
|
|
8319
|
-
this.
|
|
8320
|
-
this.
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
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
|
+
)
|
|
8325
8453
|
|
|
8326
8454
|
// ─── Selection overlay (screen-space edge-detect on a per-material mask) ───
|
|
8327
8455
|
// Reuses outline camera + main skinMats bind group layouts. No group 2 (no per-mat uniform).
|
|
@@ -8333,19 +8461,22 @@ export class Engine {
|
|
|
8333
8461
|
label: "selection mask shader",
|
|
8334
8462
|
code: SELECTION_MASK_SHADER_WGSL,
|
|
8335
8463
|
})
|
|
8336
|
-
this.
|
|
8337
|
-
|
|
8338
|
-
|
|
8339
|
-
|
|
8340
|
-
|
|
8341
|
-
|
|
8342
|
-
|
|
8343
|
-
|
|
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 },
|
|
8344
8477
|
},
|
|
8345
|
-
|
|
8346
|
-
|
|
8347
|
-
multisample: { count: 1 },
|
|
8348
|
-
})
|
|
8478
|
+
(p) => (this.selectionMaskPipeline = p),
|
|
8479
|
+
)
|
|
8349
8480
|
|
|
8350
8481
|
this.selectionEdgeBindGroupLayout = this.device.createBindGroupLayout({
|
|
8351
8482
|
label: "selection edge bind group layout",
|
|
@@ -8363,26 +8494,29 @@ export class Engine {
|
|
|
8363
8494
|
label: "selection edge shader",
|
|
8364
8495
|
code: SELECTION_EDGE_SHADER_WGSL,
|
|
8365
8496
|
})
|
|
8366
|
-
this.
|
|
8367
|
-
|
|
8368
|
-
|
|
8369
|
-
|
|
8370
|
-
|
|
8371
|
-
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
|
|
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
|
+
},
|
|
8379
8512
|
},
|
|
8380
|
-
|
|
8381
|
-
|
|
8513
|
+
],
|
|
8514
|
+
},
|
|
8515
|
+
primitive: { topology: "triangle-list" },
|
|
8516
|
+
multisample: { count: 1 },
|
|
8382
8517
|
},
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
})
|
|
8518
|
+
(p) => (this.selectionEdgePipeline = p),
|
|
8519
|
+
)
|
|
8386
8520
|
this.selectionSampler = this.device.createSampler({
|
|
8387
8521
|
label: "selection sampler",
|
|
8388
8522
|
magFilter: "linear",
|
|
@@ -8402,10 +8536,7 @@ export class Engine {
|
|
|
8402
8536
|
// ─── Editor overlays (instanced wireframe primitives) ────────────
|
|
8403
8537
|
this.setupOverlay()
|
|
8404
8538
|
|
|
8405
|
-
// ─── Bloom
|
|
8406
|
-
// Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
|
|
8407
|
-
// Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
|
|
8408
|
-
// Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
|
|
8539
|
+
// ─── Bloom: Aether Gazer's chain (prefilter → Gaussian down chain → scatter up chain) ───
|
|
8409
8540
|
this.bloomSampler = this.device.createSampler({
|
|
8410
8541
|
label: "bloom sampler",
|
|
8411
8542
|
magFilter: "linear",
|
|
@@ -8413,88 +8544,26 @@ export class Engine {
|
|
|
8413
8544
|
addressModeU: "clamp-to-edge",
|
|
8414
8545
|
addressModeV: "clamp-to-edge",
|
|
8415
8546
|
})
|
|
8416
|
-
this.
|
|
8417
|
-
label: "bloom
|
|
8418
|
-
size: 16,
|
|
8419
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
8420
|
-
})
|
|
8421
|
-
this.bloomUpsampleUniformBuffer = this.device.createBuffer({
|
|
8422
|
-
label: "bloom upsample uniforms",
|
|
8423
|
-
size: 16,
|
|
8424
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
8425
|
-
})
|
|
8426
|
-
|
|
8427
|
-
this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
|
|
8428
|
-
label: "bloom blit layout",
|
|
8547
|
+
this.bloomPrefilterBindGroupLayout = this.device.createBindGroupLayout({
|
|
8548
|
+
label: "bloom prefilter layout",
|
|
8429
8549
|
entries: [
|
|
8430
8550
|
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
8431
8551
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
8432
8552
|
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
8433
8553
|
],
|
|
8434
8554
|
})
|
|
8435
|
-
this.
|
|
8436
|
-
label: "bloom
|
|
8555
|
+
this.bloomBlurBindGroupLayout = this.device.createBindGroupLayout({
|
|
8556
|
+
label: "bloom blur layout",
|
|
8437
8557
|
entries: [
|
|
8438
8558
|
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8439
8559
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
8440
8560
|
],
|
|
8441
8561
|
})
|
|
8442
|
-
this.
|
|
8443
|
-
label: "bloom upsample layout",
|
|
8444
|
-
entries: [
|
|
8445
|
-
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
|
|
8446
|
-
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
|
|
8447
|
-
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
8448
|
-
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
8449
|
-
],
|
|
8450
|
-
})
|
|
8562
|
+
this.ensureBloomPipelines()
|
|
8451
8563
|
|
|
8452
|
-
|
|
8453
|
-
|
|
8454
|
-
|
|
8455
|
-
})
|
|
8456
|
-
|
|
8457
|
-
const bloomDownsampleShader = this.device.createShaderModule({
|
|
8458
|
-
label: "bloom downsample 13-tap",
|
|
8459
|
-
code: BLOOM_DOWNSAMPLE_SHADER_WGSL,
|
|
8460
|
-
})
|
|
8461
|
-
|
|
8462
|
-
const bloomUpsampleShader = this.device.createShaderModule({
|
|
8463
|
-
label: "bloom upsample 9-tap tent",
|
|
8464
|
-
code: BLOOM_UPSAMPLE_SHADER_WGSL,
|
|
8465
|
-
})
|
|
8466
|
-
|
|
8467
|
-
const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] })
|
|
8468
|
-
const bloomDownLayout = this.device.createPipelineLayout({
|
|
8469
|
-
bindGroupLayouts: [this.bloomDownsampleBindGroupLayout],
|
|
8470
|
-
})
|
|
8471
|
-
const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] })
|
|
8472
|
-
|
|
8473
|
-
this.bloomBlitPipeline = this.device.createRenderPipeline({
|
|
8474
|
-
label: "bloom blit pipeline",
|
|
8475
|
-
layout: bloomBlitLayout,
|
|
8476
|
-
vertex: { module: bloomBlitShader, entryPoint: "vs" },
|
|
8477
|
-
fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
8478
|
-
primitive: { topology: "triangle-list" },
|
|
8479
|
-
})
|
|
8480
|
-
this.bloomDownsamplePipeline = this.device.createRenderPipeline({
|
|
8481
|
-
label: "bloom downsample pipeline",
|
|
8482
|
-
layout: bloomDownLayout,
|
|
8483
|
-
vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
|
|
8484
|
-
fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
8485
|
-
primitive: { topology: "triangle-list" },
|
|
8486
|
-
})
|
|
8487
|
-
this.bloomUpsamplePipeline = this.device.createRenderPipeline({
|
|
8488
|
-
label: "bloom upsample pipeline",
|
|
8489
|
-
layout: bloomUpLayout,
|
|
8490
|
-
vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
|
|
8491
|
-
fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
|
|
8492
|
-
primitive: { topology: "triangle-list" },
|
|
8493
|
-
})
|
|
8494
|
-
|
|
8495
|
-
// ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
|
|
8496
|
-
// Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
|
|
8497
|
-
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
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.
|
|
8498
8567
|
this.compositeUniformBuffer = this.device.createBuffer({
|
|
8499
8568
|
label: "composite view uniforms",
|
|
8500
8569
|
// 15 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
|
|
@@ -8536,8 +8605,6 @@ export class Engine {
|
|
|
8536
8605
|
// Aux mask/alpha texture — composite reads .g to reconstruct the alpha that
|
|
8537
8606
|
// used to live in the HDR target before the rg11b10ufloat switch.
|
|
8538
8607
|
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8539
|
-
// Filmic tone LUT (r16float, filterable) — sampled with the binding-2 sampler.
|
|
8540
|
-
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8541
8608
|
// 360 backdrop equirect (PhotoDome-style skybox) — 1×1 fallback when unset.
|
|
8542
8609
|
{ binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
8543
8610
|
// User background-effect params — dummy buffer when no effect is set. The
|
|
@@ -8552,9 +8619,6 @@ export class Engine {
|
|
|
8552
8619
|
texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
|
|
8553
8620
|
},
|
|
8554
8621
|
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
8555
|
-
// AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
|
|
8556
|
-
// 1×1×1 stand-in keeps the bind group valid until it arrives.
|
|
8557
|
-
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
|
|
8558
8622
|
// The scene's own grade cube (setStageGrade) — 1×1×1 stand-in while it
|
|
8559
8623
|
// has none, which the flag bit keeps from ever being sampled.
|
|
8560
8624
|
{ binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
|
|
@@ -8580,12 +8644,14 @@ export class Engine {
|
|
|
8580
8644
|
this.compositePipelineLayout = this.device.createPipelineLayout({
|
|
8581
8645
|
bindGroupLayouts: [this.compositeBindGroupLayout],
|
|
8582
8646
|
})
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
|
|
8586
|
-
|
|
8587
|
-
|
|
8588
|
-
|
|
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
|
+
)
|
|
8589
8655
|
|
|
8590
8656
|
// GPU vertex-morph compute pipeline (shared by all models; per-model bind groups).
|
|
8591
8657
|
// Bindings: 0-4 read-only storage (base pos, CSR rowStart/colMorph/colOffset, weights),
|
|
@@ -8603,14 +8669,17 @@ export class Engine {
|
|
|
8603
8669
|
{ binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
|
|
8604
8670
|
],
|
|
8605
8671
|
})
|
|
8606
|
-
this.
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
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
|
+
},
|
|
8612
8680
|
},
|
|
8613
|
-
|
|
8681
|
+
(p) => (this.morphComputePipeline = p),
|
|
8682
|
+
)
|
|
8614
8683
|
|
|
8615
8684
|
// GPU frustum cull. One pipeline for the whole scene; the bind group is rebuilt
|
|
8616
8685
|
// with the buffers whenever the draw list changes. See shaders/passes/cull.ts.
|
|
@@ -8631,20 +8700,26 @@ export class Engine {
|
|
|
8631
8700
|
// encoder it is set on, so a WGSL slip here would take the whole frame down —
|
|
8632
8701
|
// every pass, every model, an unrelated-looking cascade of style-group and
|
|
8633
8702
|
// effect failures. Catching it turns that into "culling is off".
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8638
|
-
|
|
8639
|
-
|
|
8640
|
-
|
|
8641
|
-
|
|
8642
|
-
|
|
8643
|
-
|
|
8644
|
-
|
|
8645
|
-
|
|
8646
|
-
|
|
8647
|
-
|
|
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)
|
|
8648
8723
|
|
|
8649
8724
|
this.bloomPassDescriptor = {
|
|
8650
8725
|
label: "bloom pass",
|
|
@@ -8691,21 +8766,24 @@ export class Engine {
|
|
|
8691
8766
|
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
|
|
8692
8767
|
})
|
|
8693
8768
|
|
|
8694
|
-
this.
|
|
8695
|
-
|
|
8696
|
-
|
|
8697
|
-
|
|
8698
|
-
|
|
8699
|
-
|
|
8700
|
-
|
|
8701
|
-
|
|
8702
|
-
|
|
8703
|
-
|
|
8704
|
-
|
|
8705
|
-
|
|
8706
|
-
|
|
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
|
+
},
|
|
8707
8784
|
},
|
|
8708
|
-
|
|
8785
|
+
(p) => (this.pickPipeline = p),
|
|
8786
|
+
)
|
|
8709
8787
|
|
|
8710
8788
|
this.pickReadbackBuffer = this.device.createBuffer({
|
|
8711
8789
|
label: "pick readback",
|
|
@@ -8782,6 +8860,9 @@ export class Engine {
|
|
|
8782
8860
|
// handleResize — so the size asked for before the device existed is applied
|
|
8783
8861
|
// in full the moment there is something to apply it to.
|
|
8784
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
|
|
8785
8866
|
// Fixed override (offline/video rendering) wins; otherwise track CSS size × dpr.
|
|
8786
8867
|
const dpr = window.devicePixelRatio || 1
|
|
8787
8868
|
const width = this.fixedRenderSize ? this.fixedRenderSize.width : Math.floor(this.canvas.clientWidth * dpr)
|
|
@@ -9006,38 +9087,6 @@ export class Engine {
|
|
|
9006
9087
|
this.buildGroundBindGroup()
|
|
9007
9088
|
this.buildMirrorSurfaceBindGroup()
|
|
9008
9089
|
|
|
9009
|
-
// Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
|
|
9010
|
-
// Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
|
|
9011
|
-
const bw = Math.max(1, Math.floor(width / 2))
|
|
9012
|
-
const bh = Math.max(1, Math.floor(height / 2))
|
|
9013
|
-
const shortSide = Math.max(1, Math.min(bw, bh))
|
|
9014
|
-
this.bloomMipCount = Math.max(1, Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1))
|
|
9015
|
-
this.bloomDownTexture?.destroy()
|
|
9016
|
-
this.bloomDownTexture = this.device.createTexture({
|
|
9017
|
-
label: "bloom down pyramid",
|
|
9018
|
-
size: [bw, bh],
|
|
9019
|
-
mipLevelCount: this.bloomMipCount,
|
|
9020
|
-
format: this.hdrFormat,
|
|
9021
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
9022
|
-
})
|
|
9023
|
-
this.bloomUpTexture?.destroy()
|
|
9024
|
-
this.bloomUpTexture = this.device.createTexture({
|
|
9025
|
-
label: "bloom up pyramid",
|
|
9026
|
-
size: [bw, bh],
|
|
9027
|
-
mipLevelCount: Math.max(1, this.bloomMipCount - 1),
|
|
9028
|
-
format: this.hdrFormat,
|
|
9029
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
9030
|
-
})
|
|
9031
|
-
this.bloomDownMipViews = []
|
|
9032
|
-
for (let i = 0; i < this.bloomMipCount; i++) {
|
|
9033
|
-
this.bloomDownMipViews.push(this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }))
|
|
9034
|
-
}
|
|
9035
|
-
this.bloomUpMipViews = []
|
|
9036
|
-
const upLevels = Math.max(1, this.bloomMipCount - 1)
|
|
9037
|
-
for (let i = 0; i < upLevels; i++) {
|
|
9038
|
-
this.bloomUpMipViews.push(this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }))
|
|
9039
|
-
}
|
|
9040
|
-
|
|
9041
9090
|
this.depthTexture?.destroy()
|
|
9042
9091
|
this.depthTexture = this.device.createTexture({
|
|
9043
9092
|
label: "depth texture",
|
|
@@ -9157,54 +9206,10 @@ export class Engine {
|
|
|
9157
9206
|
],
|
|
9158
9207
|
}
|
|
9159
9208
|
|
|
9160
|
-
this.
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
this.bloomBlitBindGroup = this.device.createBindGroup({
|
|
9165
|
-
label: "bloom blit bind group",
|
|
9166
|
-
layout: this.bloomBlitBindGroupLayout,
|
|
9167
|
-
entries: [
|
|
9168
|
-
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
9169
|
-
{ binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
|
|
9170
|
-
{ binding: 2, resource: this.maskResolveView },
|
|
9171
|
-
],
|
|
9172
|
-
})
|
|
9173
|
-
// Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
|
|
9174
|
-
this.bloomDownsampleBindGroups = []
|
|
9175
|
-
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
9176
|
-
this.bloomDownsampleBindGroups.push(
|
|
9177
|
-
this.device.createBindGroup({
|
|
9178
|
-
label: `bloom downsample ${i}`,
|
|
9179
|
-
layout: this.bloomDownsampleBindGroupLayout,
|
|
9180
|
-
entries: [
|
|
9181
|
-
{ binding: 0, resource: this.bloomDownMipViews[i - 1] },
|
|
9182
|
-
{ binding: 1, resource: this.bloomSampler },
|
|
9183
|
-
],
|
|
9184
|
-
}),
|
|
9185
|
-
)
|
|
9186
|
-
}
|
|
9187
|
-
// Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
|
|
9188
|
-
// subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
|
|
9189
|
-
this.bloomUpsampleBindGroups = []
|
|
9190
|
-
const topIdx = this.bloomMipCount - 2
|
|
9191
|
-
for (let i = topIdx; i >= 0; i--) {
|
|
9192
|
-
const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1]
|
|
9193
|
-
this.bloomUpsampleBindGroups.push(
|
|
9194
|
-
this.device.createBindGroup({
|
|
9195
|
-
label: `bloom upsample ${i}`,
|
|
9196
|
-
layout: this.bloomUpsampleBindGroupLayout,
|
|
9197
|
-
entries: [
|
|
9198
|
-
{ binding: 0, resource: srcView },
|
|
9199
|
-
{ binding: 1, resource: this.bloomDownMipViews[i] },
|
|
9200
|
-
{ binding: 2, resource: this.bloomSampler },
|
|
9201
|
-
{ binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
|
|
9202
|
-
],
|
|
9203
|
-
}),
|
|
9204
|
-
)
|
|
9205
|
-
}
|
|
9206
|
-
// Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
|
|
9207
|
-
this.compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0]
|
|
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]
|
|
9208
9213
|
this.rebuildCompositeBindGroup()
|
|
9209
9214
|
}
|
|
9210
9215
|
|
|
@@ -9479,17 +9484,20 @@ export class Engine {
|
|
|
9479
9484
|
},
|
|
9480
9485
|
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
9481
9486
|
} satisfies GPURenderPipelineDescriptor
|
|
9482
|
-
this.overlayPipeline =
|
|
9487
|
+
this.initRenderPipeline(overlayPipelineDescriptor, (p) => (this.overlayPipeline = p))
|
|
9483
9488
|
|
|
9484
9489
|
// The solid volumes: the same shader and layout, with no depth write and no
|
|
9485
9490
|
// culling. A translucent body must not hide the rig behind it, and you have
|
|
9486
9491
|
// to see its far wall for it to read as a volume rather than a silhouette.
|
|
9487
|
-
this.
|
|
9488
|
-
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
|
|
9492
|
-
|
|
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
|
+
)
|
|
9493
9501
|
|
|
9494
9502
|
const compositeShader = this.device.createShaderModule({
|
|
9495
9503
|
label: "overlay composite shader",
|
|
@@ -9580,34 +9588,37 @@ export class Engine {
|
|
|
9580
9588
|
],
|
|
9581
9589
|
})
|
|
9582
9590
|
const wireShader = this.device.createShaderModule({ label: "wireframe shader", code: WIREFRAME_SHADER_WGSL })
|
|
9583
|
-
this.
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9587
|
-
|
|
9588
|
-
|
|
9589
|
-
|
|
9590
|
-
|
|
9591
|
-
|
|
9592
|
-
|
|
9593
|
-
|
|
9594
|
-
|
|
9595
|
-
|
|
9596
|
-
|
|
9597
|
-
|
|
9598
|
-
|
|
9599
|
-
|
|
9600
|
-
|
|
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
|
+
},
|
|
9601
9611
|
},
|
|
9602
|
-
|
|
9603
|
-
|
|
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 },
|
|
9604
9619
|
},
|
|
9605
|
-
|
|
9606
|
-
|
|
9607
|
-
// so a bone behind a triangle must not be punched out by it.
|
|
9608
|
-
depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: this.depthAhead },
|
|
9609
|
-
multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
|
|
9610
|
-
})
|
|
9620
|
+
(p) => (this.wireframePipeline = p),
|
|
9621
|
+
)
|
|
9611
9622
|
// The mesh's own depth, so the wireframe can be occluded by the body it
|
|
9612
9623
|
// belongs to. Occluded is the default everywhere — Blender's edit mode, Maya,
|
|
9613
9624
|
// three's and Babylon's wireframe materials all depth-test, and X-ray is a
|
|
@@ -11383,6 +11394,29 @@ export class Engine {
|
|
|
11383
11394
|
return true
|
|
11384
11395
|
}
|
|
11385
11396
|
|
|
11397
|
+
/**
|
|
11398
|
+
* Light one model with more than the world gives it: a FILL, linear RGB times
|
|
11399
|
+
* its surface colour, added after its material graph. Null takes it away again.
|
|
11400
|
+
*
|
|
11401
|
+
* After the graph, not in its ambient: an NPR ramp reads an ambient fill as
|
|
11402
|
+
* light, so sliding it moved her shadows and flipped whole regions across a
|
|
11403
|
+
* hard step. Added after, it brightens her evenly and every shadow stays put.
|
|
11404
|
+
*
|
|
11405
|
+
* A game lights its stage and its characters apart. Aether Gazer's rooms are
|
|
11406
|
+
* lit by their lamps and a near-black ambient, while its characters take a
|
|
11407
|
+
* flat base light of their own — so a stage whose World is right for the room
|
|
11408
|
+
* leaves a face turned from the lamps in the dark. This is that second light,
|
|
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.
|
|
11411
|
+
*/
|
|
11412
|
+
setModelFill(name: string, fill: { x: number; y: number; z: number } | null): boolean {
|
|
11413
|
+
const inst = this.modelInstances.get(name)
|
|
11414
|
+
if (!inst || !this.device) return false
|
|
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)
|
|
11417
|
+
return true
|
|
11418
|
+
}
|
|
11419
|
+
|
|
11386
11420
|
/** The per-model group: its skinning matrices and its ObjectLight. */
|
|
11387
11421
|
private perInstanceBindGroup(name: string, skinMatrixBuffer: GPUBuffer, light: GPUBuffer): GPUBindGroup {
|
|
11388
11422
|
return this.device.createBindGroup({
|
|
@@ -11401,6 +11435,9 @@ export class Engine {
|
|
|
11401
11435
|
// Before the texture cache below frees it: a stale entry here would hand a
|
|
11402
11436
|
// destroyed texture to the next setPlaneFrame.
|
|
11403
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)
|
|
11404
11441
|
inst.model.stop()
|
|
11405
11442
|
for (const path of inst.textureCacheKeys) {
|
|
11406
11443
|
const tex = this.textureCache.get(path)
|
|
@@ -14071,8 +14108,9 @@ export class Engine {
|
|
|
14071
14108
|
|
|
14072
14109
|
// Its ObjectLight: the world's ambient, and the layers its kind draws on —
|
|
14073
14110
|
// the cast on the character layer as well as the default one. Words:
|
|
14074
|
-
// layers [0..3], ambient flag [4..7], SH [8..43] — see
|
|
14075
|
-
|
|
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)
|
|
14076
14114
|
new Uint32Array(objectLight.buffer)[0] =
|
|
14077
14115
|
isStage || isPlane || isProp ? RENDERING_LAYER_DEFAULT : (RENDERING_LAYER_DEFAULT | RENDERING_LAYER_CHARACTER) >>> 0
|
|
14078
14116
|
const lightBuffer = this.device.createBuffer({
|
|
@@ -14309,7 +14347,11 @@ export class Engine {
|
|
|
14309
14347
|
private groundShadowPipelineDesc!: Omit<Parameters<Engine["createRenderPipeline"]>[0], "shaderModule">
|
|
14310
14348
|
|
|
14311
14349
|
private buildGroundPipeline(soft: boolean, mirrored = false): GPURenderPipeline {
|
|
14312
|
-
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 {
|
|
14313
14355
|
...this.groundShadowPipelineDesc,
|
|
14314
14356
|
// A REFLECTION FLIPS WINDING — determinant -1, see reflection.ts. The
|
|
14315
14357
|
// ground culls back faces, so drawn into the mirror pass with the ordinary
|
|
@@ -14319,11 +14361,8 @@ export class Engine {
|
|
|
14319
14361
|
// the mirror, and the same answer the scene-pass pipelines already use.
|
|
14320
14362
|
cullMode: mirrored ? "none" : this.groundShadowPipelineDesc.cullMode,
|
|
14321
14363
|
label: `ground shadow pipeline${soft ? " (soft)" : ""}${mirrored ? " (mirror)" : ""}`,
|
|
14322
|
-
shaderModule: this.
|
|
14323
|
-
|
|
14324
|
-
code: groundShaderWgsl(soft),
|
|
14325
|
-
}),
|
|
14326
|
-
})
|
|
14364
|
+
shaderModule: this.cachedShaderModule(groundShaderWgsl(soft), soft ? "ground shadow (soft)" : "ground shadow"),
|
|
14365
|
+
}
|
|
14327
14366
|
}
|
|
14328
14367
|
|
|
14329
14368
|
private groundMirrorPipeline: GPURenderPipeline | null = null
|
|
@@ -14773,6 +14812,17 @@ export class Engine {
|
|
|
14773
14812
|
],
|
|
14774
14813
|
})
|
|
14775
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
|
+
}
|
|
14776
14826
|
}
|
|
14777
14827
|
|
|
14778
14828
|
// Model-space AABB for the cull compute, grown by the three things that can
|
|
@@ -16625,58 +16675,17 @@ export class Engine {
|
|
|
16625
16675
|
// depth, before the composite that samples both layers.
|
|
16626
16676
|
this.renderFieldPass(encoder)
|
|
16627
16677
|
|
|
16628
|
-
// Bloom
|
|
16629
|
-
//
|
|
16630
|
-
//
|
|
16631
|
-
//
|
|
16632
|
-
//
|
|
16633
|
-
//
|
|
16634
|
-
//
|
|
16635
|
-
|
|
16636
|
-
|
|
16637
|
-
|
|
16638
|
-
//
|
|
16639
|
-
if (this.bloomContributes() && this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
|
|
16640
|
-
const bloomAtt = this.bloomPassDescriptor.colorAttachments as GPURenderPassColorAttachment[]
|
|
16641
|
-
|
|
16642
|
-
// 1. Blit — opens the pyramid's timing span. See stampOpen: the nine
|
|
16643
|
-
// passes below read as ONE component, which is the only useful grain.
|
|
16644
|
-
bloomAtt[0].view = this.bloomDownMipViews[0]
|
|
16645
|
-
this.bloomPassDescriptor.timestampWrites = this.stampOpen("bloom")
|
|
16646
|
-
const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
16647
|
-
pBlit.setPipeline(this.bloomBlitPipeline)
|
|
16648
|
-
pBlit.setBindGroup(0, this.bloomBlitBindGroup)
|
|
16649
|
-
pBlit.draw(3)
|
|
16650
|
-
pBlit.end()
|
|
16651
|
-
|
|
16652
|
-
// 2. Downsample chain
|
|
16653
|
-
this.bloomPassDescriptor.timestampWrites = undefined
|
|
16654
|
-
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
16655
|
-
bloomAtt[0].view = this.bloomDownMipViews[i]
|
|
16656
|
-
const p = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
16657
|
-
p.setPipeline(this.bloomDownsamplePipeline)
|
|
16658
|
-
p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1])
|
|
16659
|
-
p.draw(3)
|
|
16660
|
-
p.end()
|
|
16661
|
-
}
|
|
16662
|
-
|
|
16663
|
-
// 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
|
|
16664
|
-
const upSteps = this.bloomUpsampleBindGroups.length
|
|
16665
|
-
const topIdx = this.bloomMipCount - 2
|
|
16666
|
-
for (let k = 0; k < upSteps; k++) {
|
|
16667
|
-
const levelIdx = topIdx - k // writes bloomUp[levelIdx]
|
|
16668
|
-
bloomAtt[0].view = this.bloomUpMipViews[levelIdx]
|
|
16669
|
-
// The LAST upsample closes the span opened on the blit.
|
|
16670
|
-
this.bloomPassDescriptor.timestampWrites = k === upSteps - 1 ? this.stampClose("bloom") : undefined
|
|
16671
|
-
const p = encoder.beginRenderPass(this.bloomPassDescriptor)
|
|
16672
|
-
p.setPipeline(this.bloomUpsamplePipeline)
|
|
16673
|
-
p.setBindGroup(0, this.bloomUpsampleBindGroups[k])
|
|
16674
|
-
p.draw(3)
|
|
16675
|
-
p.end()
|
|
16676
|
-
}
|
|
16677
|
-
}
|
|
16678
|
-
|
|
16679
|
-
// Composite: HDR + bloom → Filmic tonemap → swapchain.
|
|
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.
|
|
16680
16689
|
const swapchainView = this.context.getCurrentTexture().createView()
|
|
16681
16690
|
const compositeAttachment = (this.compositePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
|
|
16682
16691
|
compositeAttachment.view = swapchainView
|
|
@@ -16827,11 +16836,14 @@ export class Engine {
|
|
|
16827
16836
|
}
|
|
16828
16837
|
}
|
|
16829
16838
|
|
|
16830
|
-
|
|
16831
|
-
|
|
16832
|
-
|
|
16833
|
-
|
|
16834
|
-
|
|
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 }))
|
|
16835
16847
|
|
|
16836
16848
|
// Repoints every draw call away from the outgoing installs (among
|
|
16837
16849
|
// everything else) — only past this line is destroying them safe.
|
|
@@ -16848,6 +16860,38 @@ export class Engine {
|
|
|
16848
16860
|
}
|
|
16849
16861
|
}
|
|
16850
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
|
+
|
|
16851
16895
|
/** Add or replace a single style group by id. `opts` may carry a `previewNode` for the
|
|
16852
16896
|
* editor's node-output preview workflow. */
|
|
16853
16897
|
async upsertStyleGroup(modelName: string, group: StyleGroup, opts?: CompileOptions): Promise<ApplyStyleGroupResult> {
|
|
@@ -17209,9 +17253,35 @@ export class Engine {
|
|
|
17209
17253
|
inst.styleGroupGen.set(group.id, generation)
|
|
17210
17254
|
|
|
17211
17255
|
this.device.pushErrorScope("validation")
|
|
17212
|
-
const module = this.
|
|
17213
|
-
const
|
|
17214
|
-
|
|
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])
|
|
17215
17285
|
const diagnostics = [...result.diagnostics]
|
|
17216
17286
|
for (const msg of info.messages) {
|
|
17217
17287
|
if (msg.type !== "error") continue
|
|
@@ -17220,6 +17290,7 @@ export class Engine {
|
|
|
17220
17290
|
if (diagnostics.some((d) => d.severity === "error") || scopeError) {
|
|
17221
17291
|
if (scopeError && !diagnostics.some((d) => d.severity === "error"))
|
|
17222
17292
|
diagnostics.push({ severity: "error", message: `WGSL: ${scopeError.message}` })
|
|
17293
|
+
this.forgetShaderModule(result.wgsl)
|
|
17223
17294
|
return { ok: false, diagnostics, slotMap: result.slotMap }
|
|
17224
17295
|
}
|
|
17225
17296
|
|
|
@@ -17228,16 +17299,7 @@ export class Engine {
|
|
|
17228
17299
|
let overEyesPipeline: GPURenderPipeline | undefined
|
|
17229
17300
|
let mirrorPipeline: GPURenderPipeline | undefined
|
|
17230
17301
|
try {
|
|
17231
|
-
|
|
17232
|
-
pipeline = await this.createRenderClassPipeline(renderClass, module, false, true, false, blend)
|
|
17233
|
-
// The depth-write-off twin: stage transparency draws with it (see
|
|
17234
|
-
// pipelineForDrawCall), and a future OIT path would too.
|
|
17235
|
-
pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false, false, blend)
|
|
17236
|
-
if (renderClass === "hair") overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true, true, false, blend)
|
|
17237
|
-
// Only the eye needs one: every other class culls "none", which a flipped
|
|
17238
|
-
// winding leaves alone.
|
|
17239
|
-
if (renderClass === "eye")
|
|
17240
|
-
mirrorPipeline = await this.createRenderClassPipeline(renderClass, module, false, true, true, blend)
|
|
17302
|
+
;[pipeline, pipelineNoDepthWrite, overEyesPipeline, mirrorPipeline] = await variants
|
|
17241
17303
|
} catch (e) {
|
|
17242
17304
|
diagnostics.push({ severity: "error", message: `pipeline creation failed: ${(e as Error).message}` })
|
|
17243
17305
|
return { ok: false, diagnostics, slotMap: result.slotMap }
|
|
@@ -17479,8 +17541,8 @@ export class Engine {
|
|
|
17479
17541
|
fragment: { module, constants, targets: blend === "additive" ? this.sceneTargetsAdditive : this.sceneTargets },
|
|
17480
17542
|
depthStencil,
|
|
17481
17543
|
}
|
|
17482
|
-
return this.
|
|
17483
|
-
this.pipelineDescs.set(pipeline, desc)
|
|
17544
|
+
return this.cachedRenderPipeline(desc).then((pipeline) => {
|
|
17545
|
+
if (!this.pipelineDescs.has(pipeline)) this.pipelineDescs.set(pipeline, desc)
|
|
17484
17546
|
return pipeline
|
|
17485
17547
|
})
|
|
17486
17548
|
}
|
|
@@ -17493,6 +17555,181 @@ export class Engine {
|
|
|
17493
17555
|
return pipeline
|
|
17494
17556
|
}
|
|
17495
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
|
+
|
|
17496
17733
|
/**
|
|
17497
17734
|
* The pipeline a draw should use once its double-sided flag is honoured.
|
|
17498
17735
|
*
|
|
@@ -17510,17 +17747,14 @@ export class Engine {
|
|
|
17510
17747
|
if (draw.doubleSided) return pipeline
|
|
17511
17748
|
const desc = this.pipelineDescs.get(pipeline)
|
|
17512
17749
|
if (!desc || (desc.primitive?.cullMode ?? "none") !== "none") return pipeline
|
|
17513
|
-
const
|
|
17514
|
-
|
|
17515
|
-
|
|
17516
|
-
|
|
17517
|
-
|
|
17518
|
-
|
|
17519
|
-
|
|
17520
|
-
|
|
17521
|
-
cache.set(pipeline, twin)
|
|
17522
|
-
}
|
|
17523
|
-
return twin
|
|
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
|
|
17524
17758
|
}
|
|
17525
17759
|
|
|
17526
17760
|
// Pipeline for a material draw call: its group's compiled pipeline when grouped, else
|
|
@@ -17596,6 +17830,9 @@ export class Engine {
|
|
|
17596
17830
|
pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline)
|
|
17597
17831
|
pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup)
|
|
17598
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)
|
|
17599
17836
|
this.issueDraw(pass, draw, view.args)
|
|
17600
17837
|
pass.setBindGroup(0, view.perFrame)
|
|
17601
17838
|
currentPipeline = null
|
|
@@ -18392,6 +18629,13 @@ export class Engine {
|
|
|
18392
18629
|
return true
|
|
18393
18630
|
}
|
|
18394
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
|
+
|
|
18395
18639
|
/** The hosts for the game's shaders: the scene pass's and the shadow atlas's,
|
|
18396
18640
|
* with the stand-ins an empty slot reads. Built on first use. */
|
|
18397
18641
|
private ensureNativeHosts(): NativeHost {
|