reze-engine 0.63.0 → 0.65.0

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