reze-engine 0.64.0 → 0.66.0

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