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.
Files changed (56) hide show
  1. package/dist/engine.d.ts +146 -54
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +734 -582
  4. package/dist/graph/slots.d.ts.map +1 -1
  5. package/dist/graph/slots.js +7 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/model.d.ts +10 -1
  9. package/dist/model.d.ts.map +1 -1
  10. package/dist/model.js +15 -1
  11. package/dist/outline-normals.d.ts +12 -0
  12. package/dist/outline-normals.d.ts.map +1 -0
  13. package/dist/outline-normals.js +100 -0
  14. package/dist/pmx-loader.d.ts +3 -0
  15. package/dist/pmx-loader.d.ts.map +1 -1
  16. package/dist/pmx-loader.js +7 -2
  17. package/dist/shaders/materials/common.d.ts.map +1 -1
  18. package/dist/shaders/materials/common.js +4 -2
  19. package/dist/shaders/passes/bloom.d.ts +4 -3
  20. package/dist/shaders/passes/bloom.d.ts.map +1 -1
  21. package/dist/shaders/passes/bloom.js +58 -87
  22. package/dist/shaders/passes/composite.d.ts.map +1 -1
  23. package/dist/shaders/passes/composite.js +118 -72
  24. package/dist/shaders/passes/mirror.d.ts +18 -12
  25. package/dist/shaders/passes/mirror.d.ts.map +1 -1
  26. package/dist/shaders/passes/mirror.js +59 -13
  27. package/dist/shaders/passes/outline.d.ts.map +1 -1
  28. package/dist/shaders/passes/outline.js +71 -14
  29. package/dist/unity/host.d.ts +15 -0
  30. package/dist/unity/host.d.ts.map +1 -1
  31. package/dist/unity/host.js +103 -63
  32. package/dist/unity/looks.d.ts +5 -0
  33. package/dist/unity/looks.d.ts.map +1 -1
  34. package/dist/unity/looks.js +19 -2
  35. package/dist/unity/skin.d.ts.map +1 -1
  36. package/dist/unity/skin.js +5 -2
  37. package/dist/unity/stage.d.ts +7 -0
  38. package/dist/unity/stage.d.ts.map +1 -1
  39. package/dist/unity/stage.js +37 -2
  40. package/package.json +1 -1
  41. package/src/engine.ts +1042 -798
  42. package/src/graph/slots.ts +7 -1
  43. package/src/index.ts +1 -0
  44. package/src/model.ts +18 -1
  45. package/src/outline-normals.ts +110 -0
  46. package/src/pmx-loader.ts +8 -2
  47. package/src/shaders/materials/common.ts +4 -2
  48. package/src/shaders/passes/bloom.ts +59 -87
  49. package/src/shaders/passes/composite.ts +118 -72
  50. package/src/shaders/passes/mirror.ts +60 -13
  51. package/src/shaders/passes/outline.ts +71 -14
  52. package/src/unity/host.ts +57 -14
  53. package/src/unity/looks.ts +21 -2
  54. package/src/unity/skin.ts +5 -2
  55. package/src/unity/stage.ts +38 -2
  56. 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 { MIRROR_MASK_DOWNSAMPLE_WGSL, MIRROR_MAT_BYTES, mirrorShaderWgsl, mirrorShadowWgsl } from "./shaders/passes/mirror"
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
- BLOOM_BLIT_SHADER_WGSL,
108
- BLOOM_DOWNSAMPLE_SHADER_WGSL,
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
- /** EEVEE Bloom panel (3D Viewport > Render > Bloom). Fields map 1:1 to Blender's UI. */
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
- knee: number
564
- radius: number
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.5,
573
- knee: 0.5,
574
- radius: 4.0,
575
- color: new Vec3(1.0, 0.7247558832168579, 0.6487361788749695),
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
- /** Blender Color Management / View (rendering.txt: Filmic, exposure, gamma). `look` is reserved for future curve tweaks. */
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 Filmic: `linear *= 2^exposure`. */
629
+ /** Stops applied before the transform: `linear *= 2^exposure`. */
619
630
  exposure: number
620
- /** After Filmic, display gamma (`pow(rgb, 1/gamma)`). */
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
- * "filmic" is Blender 3.6's Filmic, Medium High Contrast, baked as a LUT.
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
- * "aether-gazer" is that game's own final curve, (1 - e^(-2.5x))^1.4 — what
633
- * its scenes are graded through (composite.ts, agTransform).
642
+ * All four end in the sRGB encoding.
634
643
  */
635
- transform: "agx" | "filmic" | "standard" | "aether-gazer"
644
+ transform: ViewTransformName
636
645
  }
637
646
 
638
- // Matches the reference Blender project: Filmic view, Medium High Contrast look,
639
- // exposure 0.3, gamma 1.0, sRGB display, no curves.
647
+ export type ViewTransformName = "soft" | "neutral" | "aces" | "none"
648
+
640
649
  export const DEFAULT_VIEW_TRANSFORM: ViewTransformOptions = {
641
- exposure: 0.6,
650
+ exposure: 0,
642
651
  gamma: 1.0,
643
- transform: "filmic",
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 EEVEE-style bloom; tune at runtime with `setBloomOptions`. */
747
+ /** Initial bloom; tune at runtime with `setBloomOptions`. */
739
748
  bloom?: Partial<BloomOptions>
740
- /** View transform (exposure/gamma) applied in composite before/after Filmic. */
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
- // EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
2718
- // blit (HDR → half-res, 4-tap Karis + soft threshold/knee)
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
- private bloomBlitUniformBuffer!: GPUBuffer
2747
- private bloomUpsampleUniformBuffer!: GPUBuffer
2748
- private readonly bloomBlitUniformData = new Float32Array(4)
2749
- private readonly bloomUpsampleUniformData = new Float32Array(4)
2750
- private bloomBlitPipeline!: GPURenderPipeline
2751
- private bloomDownsamplePipeline!: GPURenderPipeline
2752
- private bloomUpsamplePipeline!: GPURenderPipeline
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 static readonly BLOOM_MAX_LEVELS = 5
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 EEVEE defaults (same as constructor). */
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
- knee: partial?.knee ?? d.knee,
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 (Blender names; tint is a copied `Vec3`). */
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
- knee: b.knee,
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] = v.transform === "aether-gazer" ? 3 : v.transform === "agx" ? 2 : v.transform === "standard" ? 1 : 0
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 — the bloom pyramid's own 13-tap downsample,
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 = this.bloomDownsamplePipeline.getBindGroupLayout(0)
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(this.bloomDownsamplePipeline)
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
- private makeCompositePipeline(module: GPUShaderModule, applyGamma: boolean, label: string): GPURenderPipeline {
4794
- return this.device.createRenderPipeline({
4795
- label,
4796
- layout: this.compositePipelineLayout,
4797
- vertex: { module, entryPoint: "vs" },
4798
- fragment: {
4799
- module,
4800
- entryPoint: "fs",
4801
- constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
4802
- targets: [{ format: this.presentationFormat }],
4803
- },
4804
- primitive: { topology: "triangle-list" },
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
- const source = buildCompositeShader(fieldEffect)
5046
- this.device.pushErrorScope("validation")
5047
- const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
5048
- const scopeErr = await this.device.popErrorScope()
5049
- if (scopeErr) return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration, readsCast: false }
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.device.createShaderModule({ label: "field shader (effect)", code: fieldSource })
5062
- const info = await fieldModule.getCompilationInfo()
5063
- const fieldScopeErr = await this.device.popErrorScope()
5064
- const diagnostics = info.messages
5065
- .filter((m) => m.type === "error")
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
- const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) })
5381
- this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)")
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
- const hasBackground = instances.some((e) => e.hasBackground)
5490
- const hasForeground = instances.some((e) => e.hasForeground)
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.device.createShaderModule({ label, code })
5603
- const info = await module.getCompilationInfo()
5604
- const scopeErr = await this.device.popErrorScope()
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 compile(buildParticleComputeShader(src, cast), "particle compute")
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.device.createBindGroupLayout({
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
- try {
5773
- const computePipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] })
5774
- const compute = await this.device.createComputePipelineAsync({
5818
+ const renderLayoutObj = this.cachedPipelineLayout([renderLayout])
5819
+ const builds = Promise.all([
5820
+ this.cachedComputePipeline({
5775
5821
  label: "particle compute pipeline",
5776
- layout: computePipelineLayout,
5822
+ layout: this.cachedPipelineLayout([computeLayout]),
5777
5823
  compute: { module: computeModule, entryPoint: "main" },
5778
- })
5779
- const live =
5780
- indirect && countLayout
5781
- ? {
5782
- pipeline: await this.device.createComputePipelineAsync({
5783
- label: "particle count pipeline",
5784
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [countLayout] }),
5785
- compute: { module: computeModule, entryPoint: "rzCount" },
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
- const depth = prepass
5796
- ? await this.device.createRenderPipelineAsync({
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
- const render = await this.device.createRenderPipelineAsync({
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
- const scoped = await this.device.popErrorScope()
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 this.device.popErrorScope()
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.knee !== undefined) b.knee = patch.knee
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 (patch.clamp !== undefined) b.clamp = patch.clamp
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
- // EEVEE prefilter uniforms (blit stage) + upsample sample scale. Intensity/tint live in composite.
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 bu = this.bloomBlitUniformData
7142
- // EEVEE prefilter: threshold, knee_half, clamp (0 → disabled), _unused
7143
- // Blender halves the knee before passing to the shader (eevee_bloom.c: knee * 0.5f).
7144
- // The blit shader's quadratic soft-knee curve uses knee_half as the offset from threshold,
7145
- // so the soft ramp spans [threshold - knee/2 .. threshold + knee/2] — NOT [threshold - knee .. threshold + knee].
7146
- bu[0] = b.threshold
7147
- bu[1] = b.knee * 0.5
7148
- bu[2] = b.clamp
7149
- bu[3] = 0.0
7150
- this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu)
7151
- const us = this.bloomUpsampleUniformData
7152
- // Blender: bloom.radius directly controls the tent-filter sample scale in texel units.
7153
- us[0] = Math.max(0.5, b.radius)
7154
- us[1] = 0
7155
- us[2] = 0
7156
- us[3] = 0
7157
- this.device.queue.writeBuffer(this.bloomUpsampleUniformBuffer, 0, us)
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
- // Bake the Blender 3.6 Filmic MHC tone curve into a WIDTH×1 r16float LUT sampled by the
7417
- // composite pass. The 14 anchors are the same as the old inline array; we fit a monotone
7418
- // cubic (Fritsch–Carlson) through them so the curve is C1-continuous (no Mach banding in
7419
- // smooth gradients) while still passing through every anchor (look preserved) and staying
7420
- // monotone (no tonemap overshoot/ringing). Domain is uniform in log2 space: anchor k sits
7421
- // at t=k, k=0..13 (t = log2(linear)+10). See composite.ts::filmic for the sampling map.
7422
- /**
7423
- * Decompress and upload Blender's AgX cube.
7424
- *
7425
- * Deliberately off the critical path: it is 723 KB once inflated, and a frame
7426
- * rendered before it lands should show the scene under whatever transform is
7427
- * already there rather than wait. Until then binding 10 holds a 1×1×1 stand-in,
7428
- * which is only ever sampled if someone selects AgX in that window.
7429
- */
7430
- private async loadAgxLut(): Promise<void> {
7431
- try {
7432
- const packed = Uint8Array.from(atob(AGX_LUT_GZ), (ch) => ch.charCodeAt(0))
7433
- const stream = new Blob([packed]).stream().pipeThrough(new DecompressionStream("gzip"))
7434
- const bytes = new Uint8Array(await new Response(stream).arrayBuffer())
7435
- const n = AGX_LUT_SIZE
7436
- if (bytes.byteLength !== n * n * n * 4) throw new Error(`AgX LUT is ${bytes.byteLength} bytes, expected ${n ** 3 * 4}`)
7437
- const tex = this.device.createTexture({
7438
- label: "AgX 57³ LUT",
7439
- size: [n, n, n],
7440
- dimension: "3d",
7441
- format: "rgb10a2unorm",
7442
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
7443
- })
7444
- // .cube order is red fastest, which is exactly a 3D texture's own layout.
7445
- this.device.queue.writeTexture({ texture: tex }, bytes, { bytesPerRow: n * 4, rowsPerImage: n }, [n, n, n])
7446
- this.agxLutTexture = tex
7447
- this.rebuildCompositeBindGroup()
7448
- } catch {
7449
- // A missing LUT costs AgX, not the renderer — the other transforms stand.
7450
- }
7451
- }
7452
-
7453
- private bakeFilmicLut() {
7454
- const anchors = [
7455
- 0.0028, 0.0068, 0.0151, 0.0313, 0.061, 0.112, 0.192, 0.306, 0.459, 0.631, 0.82, 0.907, 0.962, 0.989,
7456
- ]
7457
- const n = anchors.length
7458
- // Secant slopes (unit spacing, so d_k = y_{k+1} - y_k).
7459
- const d = new Array<number>(n - 1)
7460
- for (let k = 0; k < n - 1; k++) d[k] = anchors[k + 1] - anchors[k]
7461
- // Endpoint + interior tangents.
7462
- const m = new Array<number>(n)
7463
- m[0] = d[0]
7464
- m[n - 1] = d[n - 2]
7465
- for (let k = 1; k < n - 1; k++) m[k] = (d[k - 1] + d[k]) * 0.5
7466
- // Fritsch–Carlson monotonicity clamp.
7467
- for (let k = 0; k < n - 1; k++) {
7468
- if (d[k] === 0) {
7469
- m[k] = 0
7470
- m[k + 1] = 0
7471
- continue
7472
- }
7473
- const a = m[k] / d[k]
7474
- const b = m[k + 1] / d[k]
7475
- const s = a * a + b * b
7476
- if (s > 9) {
7477
- const tau = 3 / Math.sqrt(s)
7478
- m[k] = tau * a * d[k]
7479
- m[k + 1] = tau * b * d[k]
7480
- }
7481
- }
7482
- const W = Engine.FILMIC_LUT_WIDTH
7483
- const values = new Float32Array(W)
7484
- for (let j = 0; j < W; j++) {
7485
- const t = ((n - 1) * j) / (W - 1) // t ∈ [0, n-1]
7486
- const k = Math.min(Math.floor(t), n - 2)
7487
- const s = t - k // local param in [0,1], unit-spaced segment
7488
- const s2 = s * s
7489
- const s3 = s2 * s
7490
- // Hermite basis (h=1).
7491
- const h00 = 2 * s3 - 3 * s2 + 1
7492
- const h10 = s3 - 2 * s2 + s
7493
- const h01 = -2 * s3 + 3 * s2
7494
- const h11 = s3 - s2
7495
- values[j] = h00 * anchors[k] + h10 * m[k] + h01 * anchors[k + 1] + h11 * m[k + 1]
7496
- }
7497
-
7498
- // f32 → f16 bits (same conversion as bakeBrdfLut).
7499
- const half = new Uint16Array(W)
7500
- const f32 = new Float32Array(1)
7501
- const u32 = new Uint32Array(f32.buffer)
7502
- for (let j = 0; j < W; j++) {
7503
- f32[0] = values[j]
7504
- const x = u32[0]
7505
- const sign = (x >>> 16) & 0x8000
7506
- const exp = ((x >>> 23) & 0xff) - 127 + 15
7507
- const mant = x & 0x7fffff
7508
- if (exp <= 0) {
7509
- half[j] = sign
7510
- } else if (exp >= 31) {
7511
- half[j] = sign | 0x7c00
7512
- } else {
7513
- half[j] = sign | (exp << 10) | (mant >>> 13)
7514
- }
7515
- }
7516
-
7517
- this.filmicLutTexture = this.device.createTexture({
7518
- label: "Filmic tone LUT",
7519
- size: [W, 1],
7520
- format: "r16float",
7521
- usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
7522
- })
7523
- this.filmicLutView = this.filmicLutTexture.createView()
7524
- this.device.queue.writeTexture(
7525
- { texture: this.filmicLutTexture },
7526
- half,
7527
- { bytesPerRow: W * 2, rowsPerImage: 1 },
7528
- { width: W, height: 1, depthOrArrayLayers: 1 },
7529
- )
7606
+ private createRenderPipeline(config: Parameters<Engine["renderPipelineDesc"]>[0]): GPURenderPipeline {
7607
+ return this.createModelPipeline(this.renderPipelineDesc(config))
7530
7608
  }
7531
7609
 
7532
- private createRenderPipeline(config: {
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
- }): GPURenderPipeline {
7621
+ }): GPURenderPipelineDescriptor {
7544
7622
  const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined)
7545
- return this.createModelPipeline({
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.castSeedPipeline = this.device.createRenderPipeline({
7712
- label: "cast distance seed",
7713
- layout: "auto",
7714
- vertex: { module: seedModule, entryPoint: "vs" },
7715
- fragment: {
7716
- module: seedModule,
7717
- entryPoint: "fs",
7718
- targets: [{ format: CAST_SEED_FORMAT }, { format: CAST_COVERAGE_FORMAT }],
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
- primitive: { topology: "triangle-list" },
7721
- })
7722
- this.castStepPipeline = this.device.createRenderPipeline({
7723
- label: "cast distance step",
7724
- layout: "auto",
7725
- vertex: { module: stepModule, entryPoint: "vs" },
7726
- fragment: { module: stepModule, entryPoint: "fs", targets: [{ format: CAST_SEED_FORMAT }] },
7727
- primitive: { topology: "triangle-list" },
7728
- })
7729
- this.castResolvePipeline = this.device.createRenderPipeline({
7730
- label: "cast distance resolve",
7731
- layout: "auto",
7732
- vertex: { module: resolveModule, entryPoint: "vs" },
7733
- fragment: { module: resolveModule, entryPoint: "fs", targets: [{ format: CAST_DIST_FORMAT }] },
7734
- primitive: { topology: "triangle-list" },
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.device.createShaderModule({ label: "neutral base (default graph)", code: neutral.wgsl })
7937
- this.neutralPipeline = this.createRenderPipeline({
7938
- label: "neutral base pipeline",
7939
- layout: mainPipelineLayout,
7940
- shaderModule: neutralModule,
7941
- vertexBuffers: fullVertexBuffers,
7942
- fragmentTargets: sceneTargets,
7943
- cullMode: "none",
7944
- depthStencil: {
7945
- format: this.depthFormat,
7946
- depthWriteEnabled: true,
7947
- depthCompare: this.depthAhead,
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.neutralPipelineNoDepthWrite = this.createRenderPipeline({
7952
- label: "neutral base pipeline (no depth write)",
7953
- layout: mainPipelineLayout,
7954
- shaderModule: neutralModule,
7955
- vertexBuffers: fullVertexBuffers,
7956
- fragmentTargets: sceneTargets,
7957
- cullMode: "none",
7958
- depthStencil: {
7959
- format: this.depthFormat,
7960
- depthWriteEnabled: false,
7961
- depthCompare: this.depthAhead,
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.depthPrepassPipeline = this.createModelPipeline({
7983
- label: "opaque depth prepass",
7984
- ...prepassDesc,
7985
- fragment: {
7986
- module: prepassModule,
7987
- entryPoint: "fs",
7988
- targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
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.solidPrepassPipeline = this.createModelPipeline({
7995
- label: "transparent solid prepass",
7996
- ...prepassDesc,
7997
- fragment: {
7998
- module: prepassModule,
7999
- entryPoint: "fs",
8000
- constants: { CUTOFF: 1.0 },
8001
- targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
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.hairPrimePipeline = this.createModelPipeline({
8011
- label: "hair depth prime",
8012
- ...prepassDesc,
8013
- depthStencil: {
8014
- ...prepassDesc.depthStencil,
8015
- stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
8016
- stencilBack: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
8017
- stencilReadMask: 0xff,
8018
- stencilWriteMask: 0,
8019
- },
8020
- fragment: {
8021
- module: prepassModule,
8022
- entryPoint: "fs",
8023
- constants: { CUTOFF: 1.0 },
8024
- targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
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.shadowDepthPipeline = this.device.createRenderPipeline({
8054
- label: "shadow depth pipeline",
8055
- // Group 1 is the main pass's per-material layout so each shadow draw can
8056
- // rebind the draw call's existing material bind group for the alpha test.
8057
- layout: this.device.createPipelineLayout({
8058
- bindGroupLayouts: [shadowBindGroupLayout, this.mainPerMaterialBindGroupLayout],
8059
- }),
8060
- vertex: { module: shadowShader, entryPoint: "vs", buffers: fullVertexBuffers as GPUVertexBufferLayout[] },
8061
- fragment: { module: shadowShader, entryPoint: "fs", targets: [] },
8062
- primitive: { cullMode: "none" },
8063
- depthStencil: {
8064
- format: Engine.SHADOW_DEPTH_FORMAT,
8065
- depthWriteEnabled: true,
8066
- depthCompare: "less-equal",
8067
- // The shadow map keeps the NON-reversed convention (orthographicLh maps
8068
- // [0,1] with far = 1, and this pass never flipped) — so this bias must
8069
- // NOT follow reversedZ. It is the camera-pass biases that flip.
8070
- depthBias: 2,
8071
- depthBiasSlopeScale: 1.5,
8072
- depthBiasClamp: 0,
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
- this.agxFallbackTexture = this.device.createTexture({
8110
- label: "AgX LUT fallback",
8111
- size: [1, 1, 1],
8112
- dimension: "3d",
8113
- format: "rgb10a2unorm",
8114
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
8115
- })
8116
- void this.loadAgxLut()
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.groundShadowPipeline = this.buildGroundPipeline(false)
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
- vertexBuffers: outlineVertexBuffers,
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.outlinePipeline = this.createRenderPipeline(outlineDesc)
8320
- this.outlineMirrorPipeline = this.createRenderPipeline({
8321
- ...outlineDesc,
8322
- label: "outline pipeline (mirror)",
8323
- cullMode: "front",
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.selectionMaskPipeline = this.device.createRenderPipeline({
8337
- label: "selection mask pipeline",
8338
- layout: selectionMaskPipelineLayout,
8339
- vertex: { module: selectionMaskShaderModule, entryPoint: "vs", buffers: outlineVertexBuffers },
8340
- fragment: {
8341
- module: selectionMaskShaderModule,
8342
- entryPoint: "fs",
8343
- targets: [{ format: "r8unorm" }],
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
- primitive: { cullMode: "none" },
8346
- // Single-sample, no depth (depth-always via not attaching a depth buffer at all).
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.selectionEdgePipeline = this.device.createRenderPipeline({
8367
- label: "selection edge pipeline",
8368
- layout: selectionEdgePipelineLayout,
8369
- vertex: { module: selectionEdgeShaderModule, entryPoint: "vs" },
8370
- fragment: {
8371
- module: selectionEdgeShaderModule,
8372
- entryPoint: "fs",
8373
- targets: [
8374
- {
8375
- format: this.presentationFormat,
8376
- blend: {
8377
- color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
8378
- alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
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
- primitive: { topology: "triangle-list" },
8384
- multisample: { count: 1 },
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 (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
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.bloomBlitUniformBuffer = this.device.createBuffer({
8417
- label: "bloom blit uniforms",
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.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
8436
- label: "bloom downsample layout",
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.bloomUpsampleBindGroupLayout = this.device.createBindGroupLayout({
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
- const bloomBlitShader = this.device.createShaderModule({
8453
- label: "bloom blit (Karis prefilter)",
8454
- code: BLOOM_BLIT_SHADER_WGSL,
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
- const compositeShader = this.device.createShaderModule({
8584
- label: "composite shader",
8585
- code: buildCompositeShader(null),
8586
- })
8587
- this.compositePipelineIdentity = this.makeCompositePipeline(compositeShader, false, "composite pipeline (gamma=1)")
8588
- this.compositePipelineGamma = this.makeCompositePipeline(compositeShader, true, "composite pipeline (gamma!=1)")
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.morphComputePipeline = this.device.createComputePipeline({
8607
- label: "morph compute pipeline",
8608
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.morphComputeBindGroupLayout] }),
8609
- compute: {
8610
- module: this.device.createShaderModule({ label: "morph compute shader", code: MORPH_COMPUTE_WGSL }),
8611
- entryPoint: "cs",
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
- this.device.pushErrorScope("validation")
8635
- this.cullPipeline = this.device.createComputePipeline({
8636
- label: "cull pipeline",
8637
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.cullBindGroupLayout] }),
8638
- compute: {
8639
- module: this.device.createShaderModule({ label: "cull compute shader", code: CULL_COMPUTE_WGSL }),
8640
- entryPoint: "cs",
8641
- },
8642
- })
8643
- void this.device.popErrorScope().then((err) => {
8644
- if (!err) return
8645
- console.error(`[cull] pipeline failed to compile — frustum culling disabled:\n${err.message}`)
8646
- this.cullPipeline = null
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.pickPipeline = this.device.createRenderPipeline({
8695
- label: "pick pipeline",
8696
- layout: pickPipelineLayout,
8697
- vertex: { module: pickShaderModule, buffers: fullVertexBuffers },
8698
- fragment: {
8699
- module: pickShaderModule,
8700
- targets: [{ format: "rgba8unorm" }],
8701
- },
8702
- primitive: { cullMode: "none" },
8703
- depthStencil: {
8704
- format: "depth24plus",
8705
- depthWriteEnabled: true,
8706
- depthCompare: this.depthAhead,
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.writeBloomUniforms()
9161
-
9162
- if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
9163
- // Blit: reads HDR resolve texture (full-res), writes bloomDown mip 0.
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 = this.device.createRenderPipeline(overlayPipelineDescriptor)
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.overlaySolidPipeline = this.device.createRenderPipeline({
9488
- ...overlayPipelineDescriptor,
9489
- label: "overlay solid pipeline",
9490
- primitive: { topology: "triangle-list", cullMode: "none" },
9491
- depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
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.wireframePipeline = this.device.createRenderPipeline({
9584
- label: "wireframe pipeline",
9585
- layout: this.device.createPipelineLayout({
9586
- label: "wireframe pipeline layout",
9587
- bindGroupLayouts: [wireBg0, this.wireframeSkinLayout],
9588
- }),
9589
- // No vertex stream: an edge quad's corners come from two different model
9590
- // vertices, so the mesh is read through storage instead.
9591
- vertex: { module: wireShader, entryPoint: "vs" },
9592
- fragment: {
9593
- module: wireShader,
9594
- entryPoint: "fs",
9595
- targets: [
9596
- {
9597
- format: this.presentationFormat,
9598
- blend: {
9599
- color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
9600
- alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
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
- primitive: { topology: "triangle-list", cullMode: "none" },
9606
- // Depth-TESTED but not written: the mesh is a haze the rig reads against,
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 materials/common.ts.
14075
- const objectLight = new Float32Array(44)
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.device.createShaderModule({
14323
- label: soft ? "ground shadow (soft)" : "ground shadow",
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 pyramid (EEVEE 3.6):
16629
- // 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
16630
- // 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
16631
- // 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
16632
- // then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
16633
- // Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
16634
- // bloomContributes() gates the whole pyramid, not just its intensity. The
16635
- // composite still SAMPLES bloomUp[0] unconditionally, which is safe and
16636
- // deliberate: it scales what it reads by the same effective intensity, so a
16637
- // stale or never-written pyramid is multiplied by zero. Skipping the build
16638
- // is therefore invisible in the frame and nine render passes cheaper.
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
- const groupResults: GroupDiagnostic[] = []
16831
- for (const g of groups) {
16832
- const r = await this.compileAndInstallGroup(inst, g)
16833
- groupResults.push({ groupId: g.id, diagnostics: r.diagnostics, ok: r.ok })
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.device.createShaderModule({ label: `style group: ${group.id} (${renderClass})`, code: result.wgsl })
17213
- const info = await module.getCompilationInfo()
17214
- const scopeError = await this.device.popErrorScope()
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
- const blend = group.blend ?? "over"
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.device.createRenderPipelineAsync(desc).then((pipeline) => {
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 cache = mirrored ? this.singleSidedMirror : this.singleSided
17514
- let twin = cache.get(pipeline)
17515
- if (!twin) {
17516
- twin = this.device.createRenderPipeline({
17517
- ...desc,
17518
- label: `${desc.label ?? "pipeline"} (single-sided${mirrored ? ", mirror" : ""})`,
17519
- primitive: { ...desc.primitive, cullMode: mirrored ? "back" : "front" },
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 {