reze-engine 0.65.0 → 0.66.1

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 (151) hide show
  1. package/dist/engine.d.ts +156 -42
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +876 -186
  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 +5 -1
  26. package/dist/model.d.ts +14 -1
  27. package/dist/model.d.ts.map +1 -1
  28. package/dist/model.js +27 -1
  29. package/dist/pmx-loader.d.ts +4 -0
  30. package/dist/pmx-loader.d.ts.map +1 -1
  31. package/dist/pmx-loader.js +11 -2
  32. package/dist/shaders/anchor-table.d.ts +11 -0
  33. package/dist/shaders/anchor-table.d.ts.map +1 -1
  34. package/dist/shaders/anchor-table.js +9 -3
  35. package/dist/shaders/directives.d.ts +42 -5
  36. package/dist/shaders/directives.d.ts.map +1 -1
  37. package/dist/shaders/directives.js +51 -4
  38. package/dist/shaders/lights.d.ts +11 -7
  39. package/dist/shaders/lights.d.ts.map +1 -1
  40. package/dist/shaders/lights.js +315 -271
  41. package/dist/shaders/materials/common.d.ts.map +1 -1
  42. package/dist/shaders/materials/common.js +26 -3
  43. package/dist/shaders/materials/nodes.d.ts +1 -1
  44. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  45. package/dist/shaders/materials/nodes.js +191 -271
  46. package/dist/shaders/passes/composite.d.ts +1 -1
  47. package/dist/shaders/passes/composite.d.ts.map +1 -1
  48. package/dist/shaders/passes/composite.js +3 -5
  49. package/dist/shaders/passes/ground.js +1 -1
  50. package/dist/shaders/passes/particles.d.ts.map +1 -1
  51. package/dist/shaders/passes/particles.js +9 -3
  52. package/dist/shaders/passes/probe.d.ts +45 -0
  53. package/dist/shaders/passes/probe.d.ts.map +1 -0
  54. package/dist/shaders/passes/probe.js +230 -0
  55. package/dist/shaders/passes/scene-contract.d.ts +13 -0
  56. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  57. package/dist/shaders/passes/scene-contract.js +4 -0
  58. package/dist/shaders/scene-light-api.d.ts.map +1 -1
  59. package/dist/shaders/scene-light-api.js +1 -0
  60. package/dist/unity/globals.d.ts +7 -0
  61. package/dist/unity/globals.d.ts.map +1 -1
  62. package/dist/unity/globals.js +4 -3
  63. package/dist/unity/host.d.ts +3 -0
  64. package/dist/unity/host.d.ts.map +1 -1
  65. package/dist/unity/host.js +41 -7
  66. package/dist/unity/looks.d.ts +42 -0
  67. package/dist/unity/looks.d.ts.map +1 -1
  68. package/dist/unity/looks.js +131 -4
  69. package/dist/unity/stage.d.ts +17 -0
  70. package/dist/unity/stage.d.ts.map +1 -1
  71. package/dist/unity/stage.js +22 -5
  72. package/package.json +1 -1
  73. package/src/engine.ts +949 -215
  74. package/src/graph/compile.ts +4 -0
  75. package/src/graph/presets/body.ts +26 -24
  76. package/src/graph/presets/cloth_rough.ts +8 -8
  77. package/src/graph/presets/cloth_smooth.ts +8 -8
  78. package/src/graph/presets/default.ts +4 -4
  79. package/src/graph/presets/eye.ts +10 -10
  80. package/src/graph/presets/face.ts +28 -26
  81. package/src/graph/presets/hair.ts +6 -6
  82. package/src/graph/presets/metal.ts +8 -8
  83. package/src/graph/presets/stockings.ts +4 -11
  84. package/src/graph/presets/unlit.ts +9 -13
  85. package/src/graph/registry.ts +133 -158
  86. package/src/graph/render-class.ts +5 -2
  87. package/src/graph/slots.ts +5 -1
  88. package/src/model.ts +33 -1
  89. package/src/pmx-loader.ts +11 -2
  90. package/src/shaders/anchor-table.ts +15 -3
  91. package/src/shaders/directives.ts +88 -6
  92. package/src/shaders/lights.ts +464 -415
  93. package/src/shaders/materials/common.ts +26 -3
  94. package/src/shaders/materials/nodes.ts +191 -271
  95. package/src/shaders/passes/composite.ts +3 -5
  96. package/src/shaders/passes/ground.ts +1 -1
  97. package/src/shaders/passes/particles.ts +9 -3
  98. package/src/shaders/passes/probe.ts +240 -0
  99. package/src/shaders/passes/scene-contract.ts +17 -0
  100. package/src/shaders/scene-light-api.ts +1 -0
  101. package/src/unity/globals.ts +7 -3
  102. package/src/unity/host.ts +36 -6
  103. package/src/unity/looks.ts +161 -7
  104. package/src/unity/stage.ts +28 -5
  105. package/dist/physics-debug.d.ts +0 -30
  106. package/dist/physics-debug.d.ts.map +0 -1
  107. package/dist/physics-debug.js +0 -526
  108. package/dist/shaders/agx-lut.d.ts +0 -11
  109. package/dist/shaders/agx-lut.d.ts.map +0 -1
  110. package/dist/shaders/agx-lut.js +0 -29
  111. package/dist/shaders/dfg_lut.d.ts +0 -3
  112. package/dist/shaders/dfg_lut.d.ts.map +0 -1
  113. package/dist/shaders/dfg_lut.js +0 -129
  114. package/dist/shaders/ltc_mag_lut.d.ts +0 -3
  115. package/dist/shaders/ltc_mag_lut.d.ts.map +0 -1
  116. package/dist/shaders/ltc_mag_lut.js +0 -1033
  117. package/dist/shaders/materials/body.d.ts +0 -2
  118. package/dist/shaders/materials/body.d.ts.map +0 -1
  119. package/dist/shaders/materials/body.js +0 -95
  120. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  121. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  122. package/dist/shaders/materials/cloth_rough.js +0 -69
  123. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  124. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  125. package/dist/shaders/materials/cloth_smooth.js +0 -61
  126. package/dist/shaders/materials/default.d.ts +0 -2
  127. package/dist/shaders/materials/default.d.ts.map +0 -1
  128. package/dist/shaders/materials/default.js +0 -43
  129. package/dist/shaders/materials/eye.d.ts +0 -2
  130. package/dist/shaders/materials/eye.d.ts.map +0 -1
  131. package/dist/shaders/materials/eye.js +0 -60
  132. package/dist/shaders/materials/face.d.ts +0 -2
  133. package/dist/shaders/materials/face.d.ts.map +0 -1
  134. package/dist/shaders/materials/face.js +0 -95
  135. package/dist/shaders/materials/hair.d.ts +0 -2
  136. package/dist/shaders/materials/hair.d.ts.map +0 -1
  137. package/dist/shaders/materials/hair.js +0 -90
  138. package/dist/shaders/materials/metal.d.ts +0 -2
  139. package/dist/shaders/materials/metal.d.ts.map +0 -1
  140. package/dist/shaders/materials/metal.js +0 -77
  141. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  142. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  143. package/dist/shaders/materials/mmd_classic.js +0 -66
  144. package/dist/shaders/materials/stockings.d.ts +0 -2
  145. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  146. package/dist/shaders/materials/stockings.js +0 -122
  147. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  148. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  149. package/dist/shaders/passes/physics-debug.js +0 -69
  150. package/src/shaders/dfg_lut.ts +0 -131
  151. 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,10 +44,10 @@ 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
53
  import {
@@ -159,6 +157,17 @@ import {
159
157
  import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails"
160
158
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
161
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"
162
171
  import { compileGraph, type CompileOptions, type StyleSlot } from "./graph/compile"
163
172
  import type { Diagnostic, ShaderGraph } from "./graph/schema"
164
173
  import type { AlphaMode, RenderClass, StyleBlend } from "./graph/render-class"
@@ -361,12 +370,13 @@ export type LoadModelFromFilesOptions = {
361
370
  pmxFile?: File
362
371
  }
363
372
 
364
- // Blender-style scene config. World = environment lighting (ambient);
365
- // 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.
366
376
  type WorldOptions = {
367
- /** 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). */
368
378
  color?: Vec3
369
- /** Multiplier on world color (Blender: World > Surface > Strength). */
379
+ /** Multiplier on the ambient and the sky's reflection (Unity: the Environment Lighting intensity multiplier). */
370
380
  strength?: number
371
381
  /**
372
382
  * A sky that changes with the direction you look: one colour overhead, one at
@@ -474,14 +484,14 @@ type HeldParentKey = { time: number; parent: string | null; bone: string; positi
474
484
  type ParentTrack = { keys: HeldParentKey[]; applied: number; seat: [number, number, number] }
475
485
 
476
486
  type SunOptions = {
477
- /** Linear color of the sun lamp (Blender: Light > Color). */
487
+ /** Linear colour of the main light (Unity: Light > Color). */
478
488
  color?: Vec3
479
489
  /** The rendering layers it keys, as bits; every layer by default. A
480
490
  * directional light from setLights takes the layers the sun leaves. */
481
491
  layers?: number
482
- /** 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. */
483
493
  strength?: number
484
- /** 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). */
485
495
  direction?: Vec3
486
496
  /**
487
497
  * How much shadow the sun casts: 1 full, 0 none, and anything between.
@@ -570,8 +580,9 @@ export type BloomOptions = {
570
580
  /** Brightest-channel level where the glow starts (the game's per-scene
571
581
  * threshold). The soft knee is always half of it. */
572
582
  threshold: number
573
- /** 0..1: how far each level leans toward the wider one on the way up — low is
574
- * a tight halo, high a wide haze. The game: 0.77. */
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. */
575
586
  scatter: number
576
587
  /** Tint on the glow; white is the game. */
577
588
  color: Vec3
@@ -582,7 +593,7 @@ export type BloomOptions = {
582
593
  export const DEFAULT_BLOOM_OPTIONS: BloomOptions = {
583
594
  enabled: true,
584
595
  threshold: 0.7,
585
- scatter: 0.77,
596
+ scatter: 0.8,
586
597
  color: new Vec3(1, 1, 1),
587
598
  intensity: 1,
588
599
  }
@@ -755,7 +766,7 @@ export type EngineOptions = {
755
766
 
756
767
  const DEFAULT_ENGINE_OPTIONS = {
757
768
  world: { color: new Vec3(0.4014, 0.4944, 0.647), strength: 0.3 },
758
- 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) },
759
770
  camera: { distance: 26.6, target: new Vec3(0, 12.5, 0), fov: Math.PI / 4 },
760
771
  onRaycast: undefined,
761
772
  }
@@ -939,6 +950,9 @@ interface ModelInstance {
939
950
  indexBuffer: GPUBuffer
940
951
  jointsBuffer: GPUBuffer
941
952
  weightsBuffer: GPUBuffer
953
+ /** Vertex colour and second UV, per vertex — the material pipelines' slot 4.
954
+ * See createAttributeBuffer. */
955
+ attributeBuffer: GPUBuffer
942
956
  /** The outline hull's own stream — smoothed normal + PMX edge scale per
943
957
  * vertex (outline-normals.ts). Made with the first edge-flagged material,
944
958
  * so a model with no outline never pays for it. */
@@ -1758,7 +1772,7 @@ interface EffectInstance {
1758
1772
  distVariant: number
1759
1773
  /** Bones this source asked for, in ITS OWN declaration order. The scene table
1760
1774
  * maps these onto shared addresses; this list is what it is rebuilt from. */
1761
- anchors: { bone: string; trail: boolean }[]
1775
+ anchors: { bone: string; trail: boolean; along?: number; step?: number }[]
1762
1776
  /** Where this effect's own clock started, in scene seconds. */
1763
1777
  epochScene: number
1764
1778
  /**
@@ -1863,6 +1877,17 @@ export type SceneLight = {
1863
1877
  * cast's key on RENDERING_LAYER_CHARACTER is how a game lights the two apart.
1864
1878
  */
1865
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
1866
1891
  }
1867
1892
 
1868
1893
  /**
@@ -1938,6 +1963,9 @@ export class Engine {
1938
1963
  private mainPipelineLayout!: GPUPipelineLayout
1939
1964
  private sceneTargets!: GPUColorTargetState[]
1940
1965
  private sceneTargetsAdditive!: GPUColorTargetState[]
1966
+ /** A group blending "premultiplied" — see scene-contract's
1967
+ * material-premultiplied. */
1968
+ private sceneTargetsPremultiplied!: GPUColorTargetState[]
1941
1969
  /** The scene pass's attachment formats, settled at init once the device has
1942
1970
  * said which HDR format it will blend. Every scene-pass pipeline asks
1943
1971
  * scene-contract for its targets against these. */
@@ -1945,6 +1973,11 @@ export class Engine {
1945
1973
  return { hdr: this.hdrFormat, aux: Engine.BLOOM_MASK_FORMAT }
1946
1974
  }
1947
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)[]
1948
1981
  // 1×64 vertical ramp for shared-toon materials: lit (top) → soft shadow
1949
1982
  // tone (bottom). Stand-in for MMD's toon01–10.bmp, which we can't ship.
1950
1983
  private defaultToonRampTexture!: GPUTexture
@@ -2097,6 +2130,8 @@ export class Engine {
2097
2130
  private multisampleTexture!: GPUTexture
2098
2131
  private hdrResolveTexture!: GPUTexture
2099
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
2100
2135
  /**
2101
2136
  * Shadow map depth format — 16-bit, deliberately.
2102
2137
  *
@@ -2490,6 +2525,9 @@ export class Engine {
2490
2525
  /** Where addGround put the floor. The mirror reflects across it, so a raised
2491
2526
  * floor that kept y = 0 would reflect the scene into the wrong plane. */
2492
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]
2493
2531
  private mirrorPlane = new Float32Array([0, 1, 0, 0])
2494
2532
  private mirrorCameraData = new Float32Array(40)
2495
2533
  private mirrorCameraBuffer!: GPUBuffer
@@ -2624,6 +2662,50 @@ export class Engine {
2624
2662
  private worldAmbientSH: Float32Array | null = null
2625
2663
  private fallbackEquirectTexture!: GPUTexture
2626
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
2627
2709
  // The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
2628
2710
  // scene, over it, or both — whichever of background()/foreground() the code
2629
2711
  // defines. The composite pipelines are REBUILT with the user code injected;
@@ -2691,7 +2773,7 @@ export class Engine {
2691
2773
  * shader's index 0 is now — written by unshifting rather than by tracking a
2692
2774
  * head, because 64 is short and the alternative is an index the GPU side
2693
2775
  * would also have to know about. */
2694
- private anchorTrail = new Map<string, { pos: number[]; t: number[] }>()
2776
+ private anchorTrail = new Map<string, { pos: number[]; t: number[]; live?: boolean }>()
2695
2777
  /** Scene seconds, advanced by the frame delta — NOT wall time, so an offline
2696
2778
  * export samples the same path the editor showed. */
2697
2779
  private sceneClock = 0
@@ -2806,8 +2888,6 @@ export class Engine {
2806
2888
  /** The sun's shadow atlas: every cascade in its own tile (shadow-cascades.ts). */
2807
2889
  private shadowAtlasTexture!: GPUTexture
2808
2890
  private shadowAtlasView!: GPUTextureView
2809
- private brdfLutTexture!: GPUTexture
2810
- private brdfLutView!: GPUTextureView
2811
2891
  private shadowDepthPipeline!: GPURenderPipeline
2812
2892
  private shadowLightVPBuffer!: GPUBuffer
2813
2893
  // The shadow PASS reads one cascade's matrix per pass, and a uniform binding
@@ -3075,8 +3155,8 @@ export class Engine {
3075
3155
  }
3076
3156
  }
3077
3157
 
3078
- /** Sensor grain: how much, and whether it moves. */
3079
- private grain = { amount: 0, animated: true }
3158
+ /** Sensor grain: how much. */
3159
+ private grain = { amount: 0 }
3080
3160
 
3081
3161
  /**
3082
3162
  * Film grain over the rendered scene, 0–1.
@@ -3086,19 +3166,14 @@ export class Engine {
3086
3166
  * a background image or a backdrop video, which arrived with grain of their
3087
3167
  * own and would be graded rather than matched by a second helping.
3088
3168
  *
3089
- * `animated` false freezes it. A still photograph's grain does not move, and
3090
- * noise crawling over a frozen picture makes the rendering look more alive
3091
- * than the thing it is standing in.
3092
- *
3093
3169
  * Costs one hash per pixel in a pass that already runs, and nothing at all at
3094
3170
  * zero — the branch is on a uniform.
3095
3171
  */
3096
- setFilmGrain(amount: number, animated = true): void {
3172
+ setFilmGrain(amount: number): void {
3097
3173
  this.grain.amount = Math.min(Math.max(amount, 0), 1)
3098
- this.grain.animated = animated
3099
3174
  if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
3100
3175
  }
3101
- getFilmGrain(): Readonly<{ amount: number; animated: boolean }> {
3176
+ getFilmGrain(): Readonly<{ amount: number }> {
3102
3177
  return this.grain
3103
3178
  }
3104
3179
 
@@ -3152,10 +3227,8 @@ export class Engine {
3152
3227
  // a uniform branch that skips the pow entirely in the common case.
3153
3228
  u[1] = 1.0 / Math.max(v.gamma, 1e-4)
3154
3229
  u[2] = this.grain.amount
3155
- // The seed. Zero means STILL: a plate that is one photograph has grain that
3156
- // does not move, and CG noise crawling over a frozen picture makes the CG
3157
- // look more alive than the footage — the opposite of the point.
3158
- 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
3159
3232
  u[4] = b.color.x
3160
3233
  u[5] = b.color.y
3161
3234
  u[6] = b.color.z
@@ -3167,7 +3240,7 @@ export class Engine {
3167
3240
  // exposure and view transform as the scene — a sun rolls off like a sun).
3168
3241
  // The camera basis at u[12..23] is refreshed per frame.
3169
3242
  // In modes 2 and 3 the colour slot is dead, so mode 3 carries the world
3170
- // 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
3171
3244
  // scales the irradiance. A sky you can see at full brightness while it
3172
3245
  // lights at two thirds is two skies.
3173
3246
  const bg = this.backgroundColor
@@ -4688,7 +4761,7 @@ export class Engine {
4688
4761
  * whether or not it is the thing you see — and it IS the thing you see until
4689
4762
  * a backdrop is set, which is what an HDRI on its own has always done.
4690
4763
  *
4691
- * `strength` is Blender's world-strength dial and is folded into the
4764
+ * `strength` is the World's intensity and is folded into the
4692
4765
  * coefficients, so what lights her is what you see.
4693
4766
  */
4694
4767
  setWorldEquirect(source: HdrImage | null): void {
@@ -4718,7 +4791,8 @@ export class Engine {
4718
4791
  size: [source.width, source.height],
4719
4792
  format: "rgba16float",
4720
4793
  mipLevelCount: levels,
4721
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
4794
+ // COPY_SRC: prefilterSky copies its level 0 into the filtered chain.
4795
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC,
4722
4796
  })
4723
4797
  let data = source.data
4724
4798
  let w = source.width
@@ -4755,8 +4829,11 @@ export class Engine {
4755
4829
  [w, h],
4756
4830
  )
4757
4831
  }
4758
- this.worldEquirectTexture = tex
4759
- this.worldEquirectView = tex.createView()
4832
+ // The box chain above is the SOURCE: the levels the materials read are
4833
+ // the GGX convolution of it, one roughness per level (prefilterSky).
4834
+ const filtered = this.prefilterSky(tex, source.width, source.height, levels)
4835
+ this.worldEquirectTexture = filtered
4836
+ this.worldEquirectView = filtered.createView()
4760
4837
  // The sky lights the scene, not only backs it. The sun keeps the toon
4761
4838
  // ramp — this is the ambient term, exactly where the flat world colour
4762
4839
  // used to sit.
@@ -4776,6 +4853,9 @@ export class Engine {
4776
4853
  this.worldBindingsDirty = true
4777
4854
  this.rebuildPerFrameBindGroups()
4778
4855
  this.rebuildCompositeBindGroup()
4856
+ // The probe's compose step binds the sky it lays in behind the stage.
4857
+ this.dropProbeCapture()
4858
+ this.captureReflectionProbe()
4779
4859
  if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
4780
4860
  }
4781
4861
 
@@ -4902,7 +4982,7 @@ export class Engine {
4902
4982
  params: Record<string, EffectParamValue> | undefined,
4903
4983
  /** This effect's own declarations, already parsed by the caller — which had
4904
4984
  * to read them anyway to build the scene table. */
4905
- anchors: { bone: string; trail: boolean }[],
4985
+ anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
4906
4986
  /** Its row of that table: local slot → scene slot. */
4907
4987
  alias: number[],
4908
4988
  /** The pictures for `#textures`, as the host passed them. */
@@ -5574,7 +5654,7 @@ export class Engine {
5574
5654
  /** What the file declared. Read here rather than re-parsed: the source no
5575
5655
  * longer carries the lines, and two readers is how they drift. */
5576
5656
  d: EffectDirectives,
5577
- anchors: { bone: string; trail: boolean }[],
5657
+ anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
5578
5658
  /** This effect's local→scene slot map. Passed rather than read off the
5579
5659
  * engine: the builders run BEFORE the swap, so this.anchorTable still
5580
5660
  * describes the effect that is still on screen. */
@@ -5862,7 +5942,11 @@ export class Engine {
5862
5942
  // shading ten layers and shading about three.
5863
5943
  depthStencil: prepass
5864
5944
  ? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
5865
- : { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
5945
+ : d.depthAlways
5946
+ ? // `#depth always`: over the scene, Unity's ZTest Always (a moon's halo
5947
+ // drawn in front of the sky dome it sits behind)
5948
+ { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "always" }
5949
+ : { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
5866
5950
  multisample: { count: Engine.MULTISAMPLE_COUNT },
5867
5951
  }),
5868
5952
  ])
@@ -5946,7 +6030,7 @@ export class Engine {
5946
6030
  wgsl: string,
5947
6031
  count: number,
5948
6032
  alias: number[],
5949
- anchors: { bone: string; trail: boolean }[],
6033
+ anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
5950
6034
  params: EffectParamsBinding,
5951
6035
  ): Promise<{ ok: true; state: NonNullable<EffectInstance["lights"]> } | { ok: false; diagnostics: string[] }> {
5952
6036
  const layout = this.device.createBindGroupLayout({
@@ -6200,7 +6284,7 @@ export class Engine {
6200
6284
  /** What the file declared. Read here rather than re-parsed: the source no
6201
6285
  * longer carries the lines, and two readers is how they drift. */
6202
6286
  d: EffectDirectives,
6203
- anchors: { bone: string; trail: boolean }[],
6287
+ anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
6204
6288
  /** This effect's local→scene slot map. Passed rather than read off the
6205
6289
  * engine: the builders run BEFORE the swap, so this.anchorTable still
6206
6290
  * describes the effect that is still on screen. */
@@ -6290,8 +6374,10 @@ export class Engine {
6290
6374
  // point: a layer composited after tone mapping can never bloom.
6291
6375
  //
6292
6376
  // Additive, where this used to be MAX. Max was right for a post-tonemap
6293
- // layer; in HDR before bloom, overlapping light sums.
6294
- const targets = sceneTargetsFor("trail", this.sceneFormats)
6377
+ // layer; in HDR before bloom, overlapping light sums. `#blend over` lays the
6378
+ // ribbon over the scene by its alpha instead — a game trail's own blend,
6379
+ // under which its dark smoke darkens rather than vanishing.
6380
+ const targets = sceneTargetsFor(d.trailBlend === "over" ? "trail-over" : "trail", this.sceneFormats)
6295
6381
  this.device.pushErrorScope("validation")
6296
6382
  try {
6297
6383
  const pipeline = await this.device.createRenderPipelineAsync({
@@ -6709,7 +6795,7 @@ export class Engine {
6709
6795
  /** What the file declared. Read here rather than re-parsed: the source no
6710
6796
  * longer carries the lines, and two readers is how they drift. */
6711
6797
  d: EffectDirectives,
6712
- anchors: { bone: string; trail: boolean }[],
6798
+ anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
6713
6799
  /** This effect's local→scene slot map. Passed rather than read off the
6714
6800
  * engine: the builders run BEFORE the swap, so this.anchorTable still
6715
6801
  * describes the effect that is still on screen. */
@@ -7304,7 +7390,9 @@ export class Engine {
7304
7390
  u[0] = Math.max(0, b.threshold)
7305
7391
  u[1] = Math.max(0, b.threshold) * 0.5
7306
7392
  u[2] = Engine.BLOOM_CLAMP
7307
- u[3] = Math.min(1, Math.max(0, b.scatter))
7393
+ // URP's mapping: never all the way to the widest level, which alone is a
7394
+ // few pixels smeared over the screen — the glow would vanish.
7395
+ u[3] = 0.05 + 0.9 * Math.min(1, Math.max(0, b.scatter))
7308
7396
  this.device.queue.writeBuffer(this.bloomUniformBuffer, 0, u)
7309
7397
  }
7310
7398
 
@@ -7528,81 +7616,6 @@ export class Engine {
7528
7616
  this.device.queue.submit([encoder.finish()])
7529
7617
  }
7530
7618
 
7531
- private bakeBrdfLut() {
7532
- if (BRDF_LUT_SIZE !== LTC_MAG_LUT_SIZE) {
7533
- throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).")
7534
- }
7535
-
7536
- // Temp rg16float LTC source — loaded 1:1 by the bake fragment shader, then dropped.
7537
- const ltcTemp = this.device.createTexture({
7538
- label: "LTC mag LUT (bake input)",
7539
- size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
7540
- format: "rg16float",
7541
- usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
7542
- })
7543
- const n = LTC_MAG_LUT_DATA.length
7544
- const half = new Uint16Array(n)
7545
- const f32 = new Float32Array(1)
7546
- const u32 = new Uint32Array(f32.buffer)
7547
- for (let i = 0; i < n; i++) {
7548
- f32[0] = LTC_MAG_LUT_DATA[i]
7549
- const x = u32[0]
7550
- const sign = (x >>> 16) & 0x8000
7551
- let exp = ((x >>> 23) & 0xff) - 127 + 15
7552
- const mant = x & 0x7fffff
7553
- if (exp <= 0) {
7554
- half[i] = sign
7555
- } else if (exp >= 31) {
7556
- half[i] = sign | 0x7c00
7557
- } else {
7558
- half[i] = sign | (exp << 10) | (mant >>> 13)
7559
- }
7560
- }
7561
- this.device.queue.writeTexture(
7562
- { texture: ltcTemp },
7563
- half,
7564
- { bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE },
7565
- { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 },
7566
- )
7567
-
7568
- this.brdfLutTexture = this.device.createTexture({
7569
- label: "BRDF LUT (DFG + LTC packed)",
7570
- size: [BRDF_LUT_SIZE, BRDF_LUT_SIZE],
7571
- format: "rgba8unorm",
7572
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
7573
- })
7574
- this.brdfLutView = this.brdfLutTexture.createView()
7575
-
7576
- const module = this.device.createShaderModule({ label: "BRDF LUT bake", code: BRDF_LUT_BAKE_WGSL })
7577
- const pipeline = this.device.createRenderPipeline({
7578
- label: "BRDF LUT bake pipeline",
7579
- layout: "auto",
7580
- vertex: { module, entryPoint: "vs" },
7581
- fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
7582
- primitive: { topology: "triangle-list" },
7583
- })
7584
-
7585
- const bakeBindGroup = this.device.createBindGroup({
7586
- label: "BRDF LUT bake bind group",
7587
- layout: pipeline.getBindGroupLayout(0),
7588
- entries: [{ binding: 0, resource: ltcTemp.createView() }],
7589
- })
7590
-
7591
- const enc = this.device.createCommandEncoder({ label: "BRDF LUT bake encoder" })
7592
- const pass = enc.beginRenderPass({
7593
- colorAttachments: [
7594
- { view: this.brdfLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
7595
- ],
7596
- })
7597
- pass.setPipeline(pipeline)
7598
- pass.setBindGroup(0, bakeBindGroup)
7599
- pass.draw(3, 1, 0, 0)
7600
- pass.end()
7601
- this.device.queue.submit([enc.finish()])
7602
-
7603
- ltcTemp.destroy()
7604
- }
7605
-
7606
7619
  private createRenderPipeline(config: Parameters<Engine["renderPipelineDesc"]>[0]): GPURenderPipeline {
7607
7620
  return this.createModelPipeline(this.renderPipelineDesc(config))
7608
7621
  }
@@ -7611,7 +7624,7 @@ export class Engine {
7611
7624
  label: string
7612
7625
  layout: GPUPipelineLayout
7613
7626
  shaderModule: GPUShaderModule
7614
- vertexBuffers: GPUVertexBufferLayout[]
7627
+ vertexBuffers: (GPUVertexBufferLayout | null)[]
7615
7628
  fragmentTarget?: GPUColorTargetState
7616
7629
  fragmentTargets?: GPUColorTargetState[]
7617
7630
  fragmentEntryPoint?: string
@@ -7928,7 +7941,22 @@ export class Engine {
7928
7941
  // The same attachments blended as light — for a group that declared
7929
7942
  // blend: "additive". See scene-contract's material-additive.
7930
7943
  this.sceneTargetsAdditive = sceneTargetsFor("material-additive", this.sceneFormats)
7944
+ this.sceneTargetsPremultiplied = sceneTargetsFor("material-premultiplied", this.sceneFormats)
7931
7945
  this.fullVertexBufferLayouts = fullVertexBuffers
7946
+ // Slot 3 stays empty: the outline hull binds its own stream there between
7947
+ // two material draws, and a material reading slot 3 would pick it up.
7948
+ const materialVertexBuffers: (GPUVertexBufferLayout | null)[] = [
7949
+ ...fullVertexBuffers,
7950
+ null,
7951
+ {
7952
+ arrayStride: Engine.ATTRIBUTE_FLOATS * 4,
7953
+ attributes: [
7954
+ { shaderLocation: 5, offset: 0, format: "float32x4" as GPUVertexFormat },
7955
+ { shaderLocation: 6, offset: 4 * 4, format: "float32x2" as GPUVertexFormat },
7956
+ ],
7957
+ },
7958
+ ]
7959
+ this.materialVertexBufferLayouts = materialVertexBuffers
7932
7960
 
7933
7961
  // group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
7934
7962
  this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
@@ -7944,14 +7972,19 @@ export class Engine {
7944
7972
  // The positional lights. Always bound, empty or not, so every material
7945
7973
  // pipeline shares one layout whether or not the scene has any.
7946
7974
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
7947
- // The world's sky (8) and the BRDF LUT (9). The sky is always bound —
7948
- // the 1x1 fallback when the scene has none — so every material
7949
- // pipeline keeps sharing one layout. (7 was the far cascade's map,
7950
- // which the atlas at 3 replaces.)
7975
+ // The world's sky (8). Always bound — the 1x1 fallback when the scene
7976
+ // has none — so every material pipeline keeps sharing one layout. (7
7977
+ // was the far cascade's map, which the atlas at 3 replaces.)
7951
7978
  { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
7952
- { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
7979
+ // The spot lamps' cookies (loadLightCookie), a 1x1 white until one loads.
7980
+ { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d-array" } },
7953
7981
  // The cast's shadow on a stage — setStageCastShadow.
7954
7982
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
7983
+ // The stage's reflection probe and where it projects — see
7984
+ // captureReflectionProbe. Always bound: a 1x1 cube and the off flag
7985
+ // when there is no stage.
7986
+ { binding: 11, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "cube" } },
7987
+ { binding: 12, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
7955
7988
  ],
7956
7989
  })
7957
7990
  // group 1: per-instance (skinMats) — bound once per model
@@ -8024,7 +8057,7 @@ export class Engine {
8024
8057
  label: "neutral base pipeline",
8025
8058
  layout: mainPipelineLayout,
8026
8059
  shaderModule: neutralModule,
8027
- vertexBuffers: fullVertexBuffers,
8060
+ vertexBuffers: materialVertexBuffers,
8028
8061
  fragmentTargets: sceneTargets,
8029
8062
  cullMode: "none",
8030
8063
  depthStencil: {
@@ -8042,7 +8075,7 @@ export class Engine {
8042
8075
  label: "neutral base pipeline (no depth write)",
8043
8076
  layout: mainPipelineLayout,
8044
8077
  shaderModule: neutralModule,
8045
- vertexBuffers: fullVertexBuffers,
8078
+ vertexBuffers: materialVertexBuffers,
8046
8079
  fragmentTargets: sceneTargets,
8047
8080
  cullMode: "none",
8048
8081
  depthStencil: {
@@ -8212,7 +8245,6 @@ export class Engine {
8212
8245
  })
8213
8246
 
8214
8247
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
8215
- this.bakeBrdfLut()
8216
8248
  this.bakeGroundNoise()
8217
8249
  // The mipmap blit for the formats model textures arrive in, built here so
8218
8250
  // the first texture of the first model does not compile it synchronously
@@ -8269,6 +8301,48 @@ export class Engine {
8269
8301
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
8270
8302
  })
8271
8303
  this.fallbackEquirectView = this.fallbackEquirectTexture.createView()
8304
+ // The reflection probe's resources, bound from the first frame: the cube
8305
+ // (empty and switched off until a stage is captured), its stand-in and the
8306
+ // uniforms. Before the per-frame groups, which bind all of them.
8307
+ this.probeTexture = this.device.createTexture({
8308
+ label: "reflection probe",
8309
+ size: [PROBE_SIZE, PROBE_SIZE, 6],
8310
+ format: "rgba16float",
8311
+ mipLevelCount: PROBE_LEVELS,
8312
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
8313
+ })
8314
+ this.probeView = this.probeTexture.createView({ dimension: "cube" })
8315
+ this.probeFallbackView = this.device
8316
+ .createTexture({
8317
+ label: "reflection probe stand-in",
8318
+ size: [1, 1, 6],
8319
+ format: "rgba16float",
8320
+ usage: GPUTextureUsage.TEXTURE_BINDING,
8321
+ })
8322
+ .createView({ dimension: "cube" })
8323
+ this.cookieTexture = this.device.createTexture({
8324
+ label: "light cookies (none yet)",
8325
+ size: [1, 1, 1],
8326
+ format: "rgba8unorm",
8327
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
8328
+ })
8329
+ this.device.queue.writeTexture({ texture: this.cookieTexture }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1, 1])
8330
+ this.cookieView = this.cookieTexture.createView({ dimension: "2d-array" })
8331
+ this.probeUniformBuffer = this.device.createBuffer({
8332
+ label: "reflection probe uniforms",
8333
+ size: 48,
8334
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8335
+ })
8336
+ this.probeOffBuffer = this.device.createBuffer({
8337
+ label: "reflection probe uniforms (off)",
8338
+ size: 48,
8339
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8340
+ })
8341
+ this.probeCameraBuffer = this.device.createBuffer({
8342
+ label: "reflection probe capture camera",
8343
+ size: 40 * 4,
8344
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8345
+ })
8272
8346
 
8273
8347
  // Zero-filled = zero lines, which every accessor answers gracefully.
8274
8348
  this.lyricsBuffer = this.device.createBuffer({
@@ -8316,6 +8390,8 @@ export class Engine {
8316
8390
  { binding: 13, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
8317
8391
  // The mirror's plane, and whether this draw is inside the mirror pass.
8318
8392
  { binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
8393
+ // The spot lamps' cookies — the floor takes their patterns too.
8394
+ { binding: 15, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d-array" } },
8319
8395
  ],
8320
8396
  })
8321
8397
  this.groundShadowPipelineDesc = {
@@ -10391,6 +10467,415 @@ export class Engine {
10391
10467
  * Retried at the top of a frame until one of them takes. */
10392
10468
  private worldBindingsDirty = false
10393
10469
 
10470
+ // ─── The stage's reflection probe ───────────────────────────────────
10471
+ //
10472
+ // What a game bakes for every room: a cube of the stage itself, seen from
10473
+ // its middle, prefiltered down its mips by the specular lobe, and read by
10474
+ // every surface box-projected onto the stage's bounds — so a lacquered
10475
+ // counter reflects the bottles behind it and a wet floor the lamps above,
10476
+ // not a sky the room has no window onto. Captured here automatically once a
10477
+ // stage has loaded and its looks have settled; nothing to set.
10478
+
10479
+ /** Drop the capture's resources, so the next capture rebinds the sky. */
10480
+ private dropProbeCapture(): void {
10481
+ const cap = this.probeCapture
10482
+ if (!cap) return
10483
+ this.probeCapture = null
10484
+ void this.device.queue.onSubmittedWorkDone().then(() => {
10485
+ for (const t of cap.msTargets) t.destroy()
10486
+ cap.depth.destroy()
10487
+ cap.color.destroy()
10488
+ cap.aux.destroy()
10489
+ cap.cube.destroy()
10490
+ cap.steps.destroy()
10491
+ })
10492
+ }
10493
+
10494
+ /** Ask for a fresh capture of the stage's reflection probe. It runs at the
10495
+ * start of a frame once the scene has held still for a moment — asked for
10496
+ * by the engine itself whenever the stage or its looks change; a host may
10497
+ * call it after changing something the engine cannot see. */
10498
+ captureReflectionProbe(): void {
10499
+ if (!this.probeDirty) this.probeDirtyFrame = this.probeFrame
10500
+ this.probeDirty = true
10501
+ }
10502
+
10503
+ /** The stage's world-space bounds: every stage model's material boxes
10504
+ * through its placement. Null without a stage. */
10505
+ private stageBounds(): { min: [number, number, number]; max: [number, number, number] } | null {
10506
+ const min: [number, number, number] = [Infinity, Infinity, Infinity]
10507
+ const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]
10508
+ const p = new Vec3(0, 0, 0)
10509
+ let any = false
10510
+ for (const inst of this.modelInstances.values()) {
10511
+ if (!inst.isStage || !inst.model.visible) continue
10512
+ const m = inst.model
10513
+ for (const draw of inst.drawCalls) {
10514
+ const b = draw.bounds
10515
+ if (!b || b.length < 6 || !(b[0] <= b[3])) continue
10516
+ // The eight corners of the material's model-space box, placed.
10517
+ for (let k = 0; k < 8; k++) {
10518
+ 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)
10519
+ Quat.rotateVecInto(m.rotation, p, p)
10520
+ const w = [p.x + m.position.x, p.y + m.position.y, p.z + m.position.z]
10521
+ for (let a = 0; a < 3; a++) {
10522
+ if (w[a] < min[a]) min[a] = w[a]
10523
+ if (w[a] > max[a]) max[a] = w[a]
10524
+ }
10525
+ any = true
10526
+ }
10527
+ }
10528
+ }
10529
+ return any ? { min, max } : null
10530
+ }
10531
+
10532
+ /** Run a pending capture once the scene has settled — see captureReflectionProbe. */
10533
+ private maybeCaptureProbe(): void {
10534
+ this.probeFrame++
10535
+ if (!this.probeDirty || !this.probeCaptureBindGroup) return
10536
+ if (this.probeFrame - this.probeDirtyFrame < Engine.PROBE_SETTLE_FRAMES) return
10537
+ this.probeDirty = false
10538
+ const bounds = this.stageBounds()
10539
+ if (!bounds) {
10540
+ // No stage: the probe goes dark and every reader falls back to the sky.
10541
+ this.probeData.fill(0)
10542
+ this.device.queue.writeBuffer(this.probeUniformBuffer, 0, this.probeData)
10543
+ return
10544
+ }
10545
+ try {
10546
+ this.renderProbe(bounds)
10547
+ } catch (e) {
10548
+ console.warn("[reze-engine] reflection probe capture failed:", e)
10549
+ }
10550
+ }
10551
+
10552
+ private ensureProbeCapture(): NonNullable<Engine["probeCapture"]> {
10553
+ if (this.probeCapture) return this.probeCapture
10554
+ const size = PROBE_CAPTURE_SIZE
10555
+ const formats = sceneColorFormats(this.sceneFormats)
10556
+ const msTargets = formats.map((format, i) =>
10557
+ this.device.createTexture({
10558
+ label: `reflection probe capture target ${i}`,
10559
+ size: [size, size],
10560
+ sampleCount: Engine.MULTISAMPLE_COUNT,
10561
+ format,
10562
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
10563
+ }),
10564
+ )
10565
+ const depth = this.device.createTexture({
10566
+ label: "reflection probe capture depth",
10567
+ size: [size, size],
10568
+ sampleCount: Engine.MULTISAMPLE_COUNT,
10569
+ format: this.depthFormat,
10570
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
10571
+ })
10572
+ const layered = (label: string, format: GPUTextureFormat) =>
10573
+ this.device.createTexture({
10574
+ label,
10575
+ size: [size, size, 6],
10576
+ format,
10577
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
10578
+ })
10579
+ // The resolves take the scene pass's own formats: colour and coverage.
10580
+ const color = layered("reflection probe capture colour", formats[0])
10581
+ const aux = layered("reflection probe capture coverage", formats[1])
10582
+ const cube = this.device.createTexture({
10583
+ label: "reflection probe capture cube",
10584
+ size: [size, size, 6],
10585
+ format: "rgba16float",
10586
+ mipLevelCount: PROBE_CAPTURE_LEVELS,
10587
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
10588
+ })
10589
+ // One 256-byte slot per step: 6 composes, then the capture's mips, then
10590
+ // the probe's levels, six faces each.
10591
+ const slots = 6 + PROBE_CAPTURE_LEVELS * 6 + PROBE_LEVELS * 6
10592
+ const steps = this.device.createBuffer({
10593
+ label: "reflection probe steps",
10594
+ size: slots * 256,
10595
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
10596
+ })
10597
+ const composeModule = this.device.createShaderModule({ label: "reflection probe compose", code: PROBE_COMPOSE_WGSL })
10598
+ const composePipeline = this.device.createRenderPipeline({
10599
+ label: "reflection probe compose",
10600
+ layout: "auto",
10601
+ vertex: { module: composeModule, entryPoint: "vs_full" },
10602
+ fragment: { module: composeModule, entryPoint: "fs_compose", targets: [{ format: "rgba16float" }] },
10603
+ primitive: { topology: "triangle-list" },
10604
+ })
10605
+ const filterModule = this.device.createShaderModule({ label: "reflection probe filter", code: PROBE_FILTER_WGSL })
10606
+ const filterPipeline = this.device.createRenderPipeline({
10607
+ label: "reflection probe filter",
10608
+ layout: "auto",
10609
+ vertex: { module: filterModule, entryPoint: "vs_full" },
10610
+ fragment: { module: filterModule, entryPoint: "fs_filter", targets: [{ format: "rgba16float" }] },
10611
+ primitive: { topology: "triangle-list" },
10612
+ })
10613
+ const colorArray = color.createView({ dimension: "2d-array" })
10614
+ const auxArray = aux.createView({ dimension: "2d-array" })
10615
+ const composeLayout = composePipeline.getBindGroupLayout(0)
10616
+ const composeGroups: GPUBindGroup[] = []
10617
+ for (let f = 0; f < 6; f++) {
10618
+ composeGroups.push(
10619
+ this.device.createBindGroup({
10620
+ label: `reflection probe compose ${f}`,
10621
+ layout: composeLayout,
10622
+ entries: [
10623
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
10624
+ { binding: 2, resource: this.materialSampler },
10625
+ { binding: 8, resource: this.worldEquirectView ?? this.fallbackEquirectView },
10626
+ { binding: 20, resource: colorArray },
10627
+ { binding: 21, resource: auxArray },
10628
+ { binding: 22, resource: { buffer: steps, offset: f * 256, size: 32 } },
10629
+ ],
10630
+ }),
10631
+ )
10632
+ }
10633
+ const filterLayout = filterPipeline.getBindGroupLayout(0)
10634
+ const cubeView = cube.createView({ dimension: "cube" })
10635
+ const filterGroups: GPUBindGroup[] = []
10636
+ for (let i = 0; i < PROBE_LEVELS * 6; i++) {
10637
+ filterGroups.push(
10638
+ this.device.createBindGroup({
10639
+ label: `reflection probe level ${i}`,
10640
+ layout: filterLayout,
10641
+ entries: [
10642
+ { binding: 0, resource: cubeView },
10643
+ { binding: 1, resource: this.materialSampler },
10644
+ { binding: 2, resource: { buffer: steps, offset: (6 + PROBE_CAPTURE_LEVELS * 6 + i) * 256, size: 32 } },
10645
+ ],
10646
+ }),
10647
+ )
10648
+ }
10649
+ this.probeCapture = {
10650
+ msTargets,
10651
+ depth,
10652
+ color,
10653
+ aux,
10654
+ cube,
10655
+ steps,
10656
+ composePipeline,
10657
+ filterPipeline,
10658
+ composeGroups,
10659
+ filterGroups,
10660
+ }
10661
+ return this.probeCapture
10662
+ }
10663
+
10664
+ /**
10665
+ * Capture the probe: the stage drawn six times from one point into a 128²
10666
+ * cube, the sky laid in behind it, a mip chain, and the GGX convolution
10667
+ * into the 64² cube the materials read.
10668
+ *
10669
+ * WHERE: the middle of the stage's bounds across the floor, at about a
10670
+ * character's chest height above the floor (y = 0 where the bounds span it,
10671
+ * the MMD convention, else their bottom) — where a game puts the probe of a
10672
+ * room the player stands in. The box it projects onto is the bounds.
10673
+ *
10674
+ * WHAT: the stage alone, in its own looks, lit by the scene's sun, lamps
10675
+ * and sky as they stand. The cast is not in it (a baked probe never holds
10676
+ * the characters), nor are effects.
10677
+ */
10678
+ private renderProbe(bounds: { min: [number, number, number]; max: [number, number, number] }): void {
10679
+ const cap = this.ensureProbeCapture()
10680
+ const { min, max } = bounds
10681
+ const floor = min[1] <= 0 && max[1] >= 0 ? 0 : min[1]
10682
+ const eye: [number, number, number] = [
10683
+ (min[0] + max[0]) * 0.5,
10684
+ Math.min(floor + 12, (min[1] + max[1]) * 0.5),
10685
+ (min[2] + max[2]) * 0.5,
10686
+ ]
10687
+ const span = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2])
10688
+ const near = 0.25
10689
+ const far = Math.max(span * 2, 100)
10690
+ // Each face's forward and up, in the convention the GPU samples a cube
10691
+ // with (see rz_face_dir in passes/probe.ts): +X, -X, +Y, -Y, +Z, -Z.
10692
+ const faces: [number, number, number, number, number, number][] = [
10693
+ [1, 0, 0, 0, 1, 0],
10694
+ [-1, 0, 0, 0, 1, 0],
10695
+ [0, 1, 0, 0, 0, -1],
10696
+ [0, -1, 0, 0, 0, 1],
10697
+ [0, 0, 1, 0, 1, 0],
10698
+ [0, 0, -1, 0, 1, 0],
10699
+ ]
10700
+ const cam = this.probeCameraData
10701
+ const view = { perFrame: this.probeCaptureBindGroup!, args: "probe" as const, outlines: false }
10702
+ const formats = sceneColorFormats(this.sceneFormats)
10703
+ for (let f = 0; f < 6; f++) {
10704
+ const [fx, fy, fz, ux, uy, uz] = faces[f]
10705
+ Mat4.lookAtInto(cam.subarray(0, 16), eye[0], eye[1], eye[2], eye[0] + fx, eye[1] + fy, eye[2] + fz, ux, uy, uz)
10706
+ if (this.reversedZ) Mat4.perspectiveInto(cam.subarray(16, 32), Math.PI / 2, 1, far, near)
10707
+ else Mat4.perspectiveInto(cam.subarray(16, 32), Math.PI / 2, 1, near, far)
10708
+ cam[32] = eye[0]
10709
+ cam[33] = eye[1]
10710
+ cam[34] = eye[2]
10711
+ cam[35] = PROBE_CAPTURE_SIZE
10712
+ cam[36] = this.sceneClock
10713
+ this.device.queue.writeBuffer(this.probeCameraBuffer, 0, cam)
10714
+ const encoder = this.device.createCommandEncoder({ label: `reflection probe face ${f}` })
10715
+ const colorAttachments: GPURenderPassColorAttachment[] = formats.map((_, i) => ({
10716
+ view: cap.msTargets[i].createView(),
10717
+ resolveTarget:
10718
+ i === 0
10719
+ ? cap.color.createView({ dimension: "2d", baseArrayLayer: f, arrayLayerCount: 1 })
10720
+ : i === 1
10721
+ ? cap.aux.createView({ dimension: "2d", baseArrayLayer: f, arrayLayerCount: 1 })
10722
+ : undefined,
10723
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
10724
+ loadOp: "clear",
10725
+ storeOp: "discard",
10726
+ }))
10727
+ const pass = encoder.beginRenderPass({
10728
+ label: `reflection probe face ${f}`,
10729
+ colorAttachments,
10730
+ depthStencilAttachment: {
10731
+ view: cap.depth.createView(),
10732
+ depthClearValue: this.depthClear,
10733
+ depthLoadOp: "clear",
10734
+ depthStoreOp: "discard",
10735
+ stencilClearValue: 0,
10736
+ stencilLoadOp: "clear",
10737
+ stencilStoreOp: "discard",
10738
+ },
10739
+ })
10740
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
10741
+ for (const inst of this.modelInstances.values()) {
10742
+ if (!inst.isStage || !inst.model.visible) continue
10743
+ this.setModelDrawState(pass, inst)
10744
+ this.drawMaterials(pass, inst, "opaque", view)
10745
+ }
10746
+ for (const inst of this.modelInstances.values()) {
10747
+ if (!inst.isStage || !inst.model.visible) continue
10748
+ this.setModelDrawState(pass, inst)
10749
+ this.drawMaterials(pass, inst, "transparent", view)
10750
+ }
10751
+ pass.end()
10752
+ this.device.queue.submit([encoder.finish()])
10753
+ }
10754
+
10755
+ // The steps: compose, capture mips, then the probe's levels.
10756
+ const steps = new ArrayBuffer((6 + PROBE_CAPTURE_LEVELS * 6 + PROBE_LEVELS * 6) * 256)
10757
+ const put = (slot: number, face: number, lod: number, roughness: number, srcSize: number, mode: number) => {
10758
+ const u = new Uint32Array(steps, slot * 256, 8)
10759
+ const fl = new Float32Array(steps, slot * 256, 8)
10760
+ u[0] = face
10761
+ fl[1] = lod
10762
+ fl[2] = roughness
10763
+ fl[3] = srcSize
10764
+ u[4] = mode
10765
+ }
10766
+ for (let f = 0; f < 6; f++) put(f, f, 0, 0, PROBE_CAPTURE_SIZE, 0)
10767
+ for (let m = 0; m < PROBE_CAPTURE_LEVELS; m++)
10768
+ for (let f = 0; f < 6; f++) put(6 + m * 6 + f, f, Math.max(m - 1, 0), 0, PROBE_CAPTURE_SIZE, 0)
10769
+ for (let m = 0; m < PROBE_LEVELS; m++)
10770
+ for (let f = 0; f < 6; f++)
10771
+ // Level 0 is the capture at the probe's size: one tap of the 128²
10772
+ // level-0 cube at a 64² texel's centre is its 2×2 average.
10773
+ put(6 + PROBE_CAPTURE_LEVELS * 6 + m * 6 + f, f, 0, probeLevelRoughness(m), PROBE_CAPTURE_SIZE, m === 0 ? 0 : 1)
10774
+ this.device.queue.writeBuffer(cap.steps, 0, steps)
10775
+
10776
+ const encoder = this.device.createCommandEncoder({ label: "reflection probe filter" })
10777
+ const run = (target: GPUTextureView, pipeline: GPURenderPipeline, group: GPUBindGroup) => {
10778
+ const p = encoder.beginRenderPass({ colorAttachments: [{ view: target, loadOp: "clear", storeOp: "store" }] })
10779
+ p.setPipeline(pipeline)
10780
+ p.setBindGroup(0, group)
10781
+ p.draw(3)
10782
+ p.end()
10783
+ }
10784
+ const face = (tex: GPUTexture, level: number, f: number) =>
10785
+ tex.createView({ dimension: "2d", baseMipLevel: level, mipLevelCount: 1, baseArrayLayer: f, arrayLayerCount: 1 })
10786
+ for (let f = 0; f < 6; f++) run(face(cap.cube, 0, f), cap.composePipeline, cap.composeGroups[f])
10787
+ // Each level from the one above. The source view spans the whole chain,
10788
+ // which a pass writing one of its levels may not bind — so the reads go
10789
+ // through a view of the levels ABOVE the one being written.
10790
+ for (let m = 1; m < PROBE_CAPTURE_LEVELS; m++) {
10791
+ const above = cap.cube.createView({ dimension: "cube", baseMipLevel: 0, mipLevelCount: m })
10792
+ for (let f = 0; f < 6; f++) {
10793
+ const group = this.device.createBindGroup({
10794
+ label: `reflection probe capture mip ${m}/${f}`,
10795
+ layout: cap.filterPipeline.getBindGroupLayout(0),
10796
+ entries: [
10797
+ { binding: 0, resource: above },
10798
+ { binding: 1, resource: this.materialSampler },
10799
+ { binding: 2, resource: { buffer: cap.steps, offset: (6 + m * 6 + f) * 256, size: 32 } },
10800
+ ],
10801
+ })
10802
+ run(face(cap.cube, m, f), cap.filterPipeline, group)
10803
+ }
10804
+ }
10805
+ for (let m = 0; m < PROBE_LEVELS; m++)
10806
+ for (let f = 0; f < 6; f++) run(face(this.probeTexture, m, f), cap.filterPipeline, cap.filterGroups[m * 6 + f])
10807
+ this.device.queue.submit([encoder.finish()])
10808
+
10809
+ this.probeData.set([eye[0], eye[1], eye[2], 1, min[0], min[1], min[2], 1, max[0], max[1], max[2], 0])
10810
+ this.device.queue.writeBuffer(this.probeUniformBuffer, 0, this.probeData)
10811
+ }
10812
+
10813
+ /**
10814
+ * The sky's mips, convolved with the GGX lobe each stands for — the box
10815
+ * average the upload built is only the source. Level 0 is copied as it is
10816
+ * (it is also what the backdrop draws); every level below it becomes the
10817
+ * sky as a surface of that roughness reflects it (skyLevelRoughness).
10818
+ */
10819
+ private prefilterSky(source: GPUTexture, width: number, height: number, levels: number): GPUTexture {
10820
+ if (levels <= 1) return source
10821
+ if (!this.skyPrefilterPipeline) {
10822
+ const module = this.device.createShaderModule({ label: "sky prefilter", code: SKY_PREFILTER_WGSL })
10823
+ this.skyPrefilterPipeline = this.device.createRenderPipeline({
10824
+ label: "sky prefilter",
10825
+ layout: "auto",
10826
+ vertex: { module, entryPoint: "vs_full" },
10827
+ fragment: { module, entryPoint: "fs_sky", targets: [{ format: "rgba16float" }] },
10828
+ primitive: { topology: "triangle-list" },
10829
+ })
10830
+ }
10831
+ const pipeline = this.skyPrefilterPipeline
10832
+ const out = this.device.createTexture({
10833
+ label: "world equirect (HDR, GGX-prefiltered)",
10834
+ size: [width, height],
10835
+ format: "rgba16float",
10836
+ mipLevelCount: levels,
10837
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
10838
+ })
10839
+ const encoder = this.device.createCommandEncoder({ label: "sky prefilter" })
10840
+ encoder.copyTextureToTexture({ texture: source, mipLevel: 0 }, { texture: out, mipLevel: 0 }, [width, height])
10841
+ const params = this.device.createBuffer({
10842
+ label: "sky prefilter steps",
10843
+ size: levels * 256,
10844
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
10845
+ })
10846
+ const data = new Float32Array(levels * 64)
10847
+ for (let m = 1; m < levels; m++) data.set([skyLevelRoughness(m, levels), width, height, 0], m * 64)
10848
+ this.device.queue.writeBuffer(params, 0, data)
10849
+ const srcView = source.createView()
10850
+ for (let m = 1; m < levels; m++) {
10851
+ const group = this.device.createBindGroup({
10852
+ label: `sky prefilter ${m}`,
10853
+ layout: pipeline.getBindGroupLayout(0),
10854
+ entries: [
10855
+ { binding: 0, resource: srcView },
10856
+ { binding: 1, resource: this.materialSampler },
10857
+ { binding: 2, resource: { buffer: params, offset: m * 256, size: 16 } },
10858
+ ],
10859
+ })
10860
+ const p = encoder.beginRenderPass({
10861
+ label: `sky prefilter ${m}`,
10862
+ colorAttachments: [
10863
+ { view: out.createView({ baseMipLevel: m, mipLevelCount: 1 }), loadOp: "clear", storeOp: "store" },
10864
+ ],
10865
+ })
10866
+ p.setPipeline(pipeline)
10867
+ p.setBindGroup(0, group)
10868
+ p.draw(3)
10869
+ p.end()
10870
+ }
10871
+ this.device.queue.submit([encoder.finish()])
10872
+ // The box chain and the steps are done with once that work is: the source
10873
+ // retires with the skies (destroyed at the next frame's start).
10874
+ this.retiredSkies.push(source)
10875
+ void this.device.queue.onSubmittedWorkDone().then(() => params.destroy())
10876
+ return out
10877
+ }
10878
+
10394
10879
  /** Returns whether it actually rebuilt — see rebuildCompositeBindGroup. */
10395
10880
  private rebuildPerFrameBindGroups(): boolean {
10396
10881
  // EVERY resource, not just the layout. This runs twice: once at init, where
@@ -10409,7 +10894,7 @@ export class Engine {
10409
10894
  !this.shadowLightVPBuffer ||
10410
10895
  !this.materialSampler ||
10411
10896
  !this.shadowComparisonSampler ||
10412
- !this.brdfLutView ||
10897
+ !this.probeUniformBuffer ||
10413
10898
  !(this.worldEquirectView ?? this.fallbackEquirectView)
10414
10899
  ) {
10415
10900
  return false
@@ -10432,8 +10917,31 @@ export class Engine {
10432
10917
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
10433
10918
  { binding: 6, resource: { buffer: this.lightsBuffer } },
10434
10919
  { binding: 8, resource: env },
10435
- { binding: 9, resource: this.brdfLutView },
10920
+ { binding: 9, resource: this.cookieView },
10436
10921
  { binding: 10, resource: this.castShadowView },
10922
+ { binding: 11, resource: this.probeView },
10923
+ { binding: 12, resource: { buffer: this.probeUniformBuffer } },
10924
+ ],
10925
+ })
10926
+ // The probe's own view of the scene: one cube face at a time through its
10927
+ // capture camera, and the probe itself switched off — a material inside
10928
+ // the capture reflects the sky, never the cube being filled.
10929
+ this.probeCaptureBindGroup = this.device.createBindGroup({
10930
+ label: "reflection probe capture per-frame bind group",
10931
+ layout: this.mainPerFrameBindGroupLayout,
10932
+ entries: [
10933
+ { binding: 0, resource: { buffer: this.probeCameraBuffer } },
10934
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
10935
+ { binding: 2, resource: this.materialSampler },
10936
+ { binding: 3, resource: this.shadowAtlasView },
10937
+ { binding: 4, resource: this.shadowComparisonSampler },
10938
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
10939
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
10940
+ { binding: 8, resource: env },
10941
+ { binding: 9, resource: this.cookieView },
10942
+ { binding: 10, resource: this.castShadowView },
10943
+ { binding: 11, resource: this.probeFallbackView },
10944
+ { binding: 12, resource: { buffer: this.probeOffBuffer } },
10437
10945
  ],
10438
10946
  })
10439
10947
  this.mirrorPerFrameBindGroup = this.device.createBindGroup({
@@ -10448,8 +10956,10 @@ export class Engine {
10448
10956
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
10449
10957
  { binding: 6, resource: { buffer: this.lightsBuffer } },
10450
10958
  { binding: 8, resource: env },
10451
- { binding: 9, resource: this.brdfLutView },
10959
+ { binding: 9, resource: this.cookieView },
10452
10960
  { binding: 10, resource: this.castShadowView },
10961
+ { binding: 11, resource: this.probeView },
10962
+ { binding: 12, resource: { buffer: this.probeUniformBuffer } },
10453
10963
  ],
10454
10964
  })
10455
10965
  return true
@@ -10511,13 +11021,13 @@ export class Engine {
10511
11021
  this.lightData[base + 4] = this.sun.color.x
10512
11022
  this.lightData[base + 5] = this.sun.color.y
10513
11023
  this.lightData[base + 6] = this.sun.color.z
11024
+ // Unity's light intensity: a white sun of 1 lights albedo 1:1 at N·L = 1.
10514
11025
  this.lightData[base + 7] = this.sun.strength
10515
11026
  this.lightDataWords[DIR_LAYERS_AT + index] = this.sunLayers
10516
11027
  if (index >= this.lightCount) this.lightCount = index + 1
10517
11028
  this.updateLightBuffer()
10518
11029
  }
10519
11030
 
10520
- /** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
10521
11031
  /**
10522
11032
  * Distance fog on every lit surface, as a game on SimPipeline lays it: per
10523
11033
  * layer, t = saturate(f − (1 − f)·h) with f = saturate(depth·distance[0] +
@@ -10642,7 +11152,7 @@ export class Engine {
10642
11152
  this.writeWorld()
10643
11153
  }
10644
11154
 
10645
- /** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
11155
+ /** Update the main light (Unity's directional Sun). Direction change marks shadow VP dirty. */
10646
11156
  setSun(options: SunOptions): void {
10647
11157
  if (options.color) this.sun.color = options.color
10648
11158
  if (options.strength !== undefined) this.sun.strength = options.strength
@@ -10730,6 +11240,7 @@ export class Engine {
10730
11240
  ...options,
10731
11241
  }
10732
11242
  this.groundY = opts.y
11243
+ this.groundHalf = [opts.width / 2, opts.height / 2]
10733
11244
  this.createGroundGeometry(opts.width, opts.height, opts.y)
10734
11245
  this.createShadowGroundResources(opts)
10735
11246
  this.hasGround = true
@@ -10782,6 +11293,7 @@ export class Engine {
10782
11293
  * literal typed into a console. Vec3 satisfies it either way. */
10783
11294
  lights: SceneLight[] | null,
10784
11295
  ): void {
11296
+ this.sceneLights = lights
10785
11297
  // The directional ones go to the light uniform's slots beside the sun's —
10786
11298
  // three of them, in order; the rest are records the grid indexes.
10787
11299
  const directional = (lights ?? []).filter((l) => l.kind === "directional")
@@ -10841,8 +11353,12 @@ export class Engine {
10841
11353
  this.lightsData[b + 12] = Math.max(inner, outer)
10842
11354
  // The layers it does NOT reach, as bits — inverted so a zero reaches all.
10843
11355
  this.lightsWords[b + 13] = ~(l.layers ?? ALL_LAYERS) >>> 0
10844
- this.lightsData[b + 14] = 0
10845
- this.lightsData[b + 15] = 0
11356
+ // The bulb's radius (rzLightNear): 0 keeps the engine's default
11357
+ this.lightsData[b + 14] = l.near !== undefined && Number.isFinite(l.near) && l.near > 0 ? l.near : 0
11358
+ // Its cookie's layer + 1, 0 for none or one not loaded yet (a load
11359
+ // rewrites the lamps when it lands).
11360
+ const cookie = l.cookie !== undefined ? this.cookieLayers.get(l.cookie) : undefined
11361
+ this.lightsData[b + 15] = cookie !== undefined ? cookie + 1 : 0
10846
11362
  }
10847
11363
  // The grid, from the records as STORED — normalised aim, clamped reach, the
10848
11364
  // cosines in f32 — so it indexes exactly what the shader will evaluate.
@@ -10881,6 +11397,58 @@ export class Engine {
10881
11397
  this.allocateLightSlots()
10882
11398
  }
10883
11399
 
11400
+ /**
11401
+ * A cookie, under `key`, for every spot lamp that names it (SceneLight.cookie):
11402
+ * the picture a stage light throws through its gobo, projected along the
11403
+ * cone — its RGB is the light let through, so black blocks, white passes and
11404
+ * a colour tints. Square, stretched to 512². Up to 16 at once; loading a key
11405
+ * again replaces its picture. False when every layer is taken.
11406
+ */
11407
+ async loadLightCookie(key: string, source: Blob | ImageBitmap): Promise<boolean> {
11408
+ if (!this.device) return false
11409
+ let layer = this.cookieLayers.get(key)
11410
+ if (layer === undefined) {
11411
+ const used = new Set(this.cookieLayers.values())
11412
+ for (let k = 0; k < Engine.COOKIE_LAYERS && layer === undefined; k++) if (!used.has(k)) layer = k
11413
+ if (layer === undefined) {
11414
+ console.warn(`[reze] light cookie "${key}": all ${Engine.COOKIE_LAYERS} cookie layers are in use`)
11415
+ return false
11416
+ }
11417
+ }
11418
+ const size = Engine.COOKIE_SIZE
11419
+ const bmp = await createImageBitmap(source, {
11420
+ resizeWidth: size,
11421
+ resizeHeight: size,
11422
+ resizeQuality: "high",
11423
+ colorSpaceConversion: "none",
11424
+ premultiplyAlpha: "none",
11425
+ })
11426
+ // The real array on the first cookie, and every group that binds the
11427
+ // stand-in rebuilt against it (the stand-in itself is a 1x1 and stays).
11428
+ if (this.cookieTexture.width === 1) {
11429
+ this.cookieTexture = this.device.createTexture({
11430
+ label: "light cookies",
11431
+ size: [size, size, Engine.COOKIE_LAYERS],
11432
+ format: "rgba8unorm",
11433
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
11434
+ })
11435
+ this.cookieView = this.cookieTexture.createView({ dimension: "2d-array" })
11436
+ this.worldBindingsDirty = true
11437
+ this.buildGroundBindGroup()
11438
+ }
11439
+ this.device.queue.copyExternalImageToTexture({ source: bmp }, { texture: this.cookieTexture, origin: [0, 0, layer] }, [size, size, 1])
11440
+ bmp.close()
11441
+ this.cookieLayers.set(key, layer)
11442
+ if (this.sceneLights) this.setLights(this.sceneLights)
11443
+ return true
11444
+ }
11445
+
11446
+ /** Forget a cookie: lamps naming it light plainly again. */
11447
+ removeLightCookie(key: string): void {
11448
+ if (!this.cookieLayers.delete(key)) return
11449
+ if (this.sceneLights) this.setLights(this.sceneLights)
11450
+ }
11451
+
10884
11452
  /** How many positional lights the scene is carrying. */
10885
11453
  getLightCount(): number {
10886
11454
  return this.lightHeader[0]
@@ -13171,8 +13739,12 @@ export class Engine {
13171
13739
  const d = positions[i] * positions[i] + positions[i + 1] * positions[i + 1] + positions[i + 2] * positions[i + 2]
13172
13740
  if (d > most) most = d
13173
13741
  }
13174
- const extent = Math.sqrt(most)
13175
- if (extent <= this.sceneExtent) return
13742
+ this.raiseSceneExtent(Math.sqrt(most))
13743
+ }
13744
+
13745
+ /** Raise the scene's reach (never lower it) and hand it to the camera. */
13746
+ private raiseSceneExtent(extent: number): void {
13747
+ if (!(extent > this.sceneExtent)) return
13176
13748
  this.sceneExtent = extent
13177
13749
  this.camera?.setSceneExtent(extent)
13178
13750
  }
@@ -13253,6 +13825,16 @@ export class Engine {
13253
13825
  minZ = Math.min(minZ, data[o + 18] - r); maxZ = Math.max(maxZ, data[o + 18] + r)
13254
13826
  }
13255
13827
  }
13828
+ // The built-in floor, while it is drawn. It is no draw of the cull table,
13829
+ // so without this the box ended at the cast: the cascades stopped just
13830
+ // past her, and a low sun's long shadow ran off the last of them — the
13831
+ // floor beyond read as lit and her head and shoulders went missing.
13832
+ if (this.hasGround && !this.groundIsSuppressed()) {
13833
+ const [hx, hz] = this.groundHalf
13834
+ minX = Math.min(minX, -hx); maxX = Math.max(maxX, hx)
13835
+ minY = Math.min(minY, this.groundY); maxY = Math.max(maxY, this.groundY)
13836
+ minZ = Math.min(minZ, -hz); maxZ = Math.max(maxZ, hz)
13837
+ }
13256
13838
  if (this.nativeStage) {
13257
13839
  const nb = this.nativeStage.bounds
13258
13840
  minX = Math.min(minX, nb.min[0]); maxX = Math.max(maxX, nb.max[0])
@@ -13557,6 +14139,10 @@ export class Engine {
13557
14139
  */
13558
14140
  private recordBundles(): void {
13559
14141
  this.bundlesDirty = false
14142
+ // What the bundles record is what the probe would see: a stage arriving or
14143
+ // leaving, a look installed on it, a pipeline landing. Re-captured once it
14144
+ // settles (and switched off when the stage is gone).
14145
+ this.captureReflectionProbe()
13560
14146
  const scene = {
13561
14147
  colorFormats: sceneColorFormats(this.sceneFormats),
13562
14148
  depthStencilFormat: this.depthFormat,
@@ -13820,10 +14406,17 @@ export class Engine {
13820
14406
  private issueDraw(
13821
14407
  pass: GPURenderPassEncoder | GPURenderBundleEncoder,
13822
14408
  draw: DrawCall,
13823
- kind: "camera" | "shadow" | "mirror",
14409
+ kind: "camera" | "shadow" | "mirror" | "probe",
13824
14410
  ): void {
14411
+ // The probe's cube faces have no cull of their own: drawn whole.
13825
14412
  const args =
13826
- kind === "shadow" ? this.cullShadowArgs : kind === "mirror" ? this.cullMirrorArgs : this.cullCameraArgs
14413
+ kind === "probe"
14414
+ ? null
14415
+ : kind === "shadow"
14416
+ ? this.cullShadowArgs
14417
+ : kind === "mirror"
14418
+ ? this.cullMirrorArgs
14419
+ : this.cullCameraArgs
13827
14420
  if (args && draw.cullIndex >= 0) {
13828
14421
  pass.drawIndexedIndirect(args, draw.cullIndex * Engine.CULL_ARG_WORDS * 4)
13829
14422
  } else {
@@ -14127,7 +14720,8 @@ export class Engine {
14127
14720
  entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
14128
14721
  })
14129
14722
 
14130
- const gpuBuffers: GPUBuffer[] = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer]
14723
+ const attributeBuffer = this.createAttributeBuffer(name, model)
14724
+ const gpuBuffers: GPUBuffer[] = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer, attributeBuffer]
14131
14725
 
14132
14726
  const gpuMorph = this.createGpuMorph(name, model, vertexBuffer, gpuBuffers)
14133
14727
 
@@ -14152,6 +14746,7 @@ export class Engine {
14152
14746
  indexBuffer,
14153
14747
  jointsBuffer,
14154
14748
  weightsBuffer,
14749
+ attributeBuffer,
14155
14750
  skinMatrixBuffer,
14156
14751
  wireEdges: new Map(),
14157
14752
  drawCalls: [],
@@ -14198,6 +14793,46 @@ export class Engine {
14198
14793
  this.updateOrderDirty = true
14199
14794
  }
14200
14795
 
14796
+ /**
14797
+ * A model's attribute stream: vertex colour (RGBA) and a second UV set, six
14798
+ * floats a vertex, read by the material pipelines at slot 4.
14799
+ *
14800
+ * PMX has no vertex colour and one UV, but it has four ADDITIONAL UV
14801
+ * channels of four floats each, and that is where a model that needs more
14802
+ * keeps it: the first additional UV is the vertex colour, the second's xy
14803
+ * the second UV — the convention a converted game stage is written in (a
14804
+ * plant's occlusion rides in its vertex alpha, a light card's mask in its
14805
+ * second UV). A model without them reads white and its own UV, which is
14806
+ * what every graph that reads neither already assumed.
14807
+ */
14808
+ private createAttributeBuffer(name: string, model: Model): GPUBuffer {
14809
+ const vertices = model.getVertices()
14810
+ const count = Math.floor(vertices.length / 8)
14811
+ const data = new Float32Array(count * Engine.ATTRIBUTE_FLOATS)
14812
+ const color = model.getAdditionalUv(1)
14813
+ const uv2 = model.getAdditionalUv(2)
14814
+ for (let i = 0; i < count; i++) {
14815
+ const o = i * Engine.ATTRIBUTE_FLOATS
14816
+ if (color) {
14817
+ data[o] = color[i * 4]
14818
+ data[o + 1] = color[i * 4 + 1]
14819
+ data[o + 2] = color[i * 4 + 2]
14820
+ data[o + 3] = color[i * 4 + 3]
14821
+ } else {
14822
+ data[o] = data[o + 1] = data[o + 2] = data[o + 3] = 1
14823
+ }
14824
+ data[o + 4] = uv2 ? uv2[i * 4] : vertices[i * 8 + 6]
14825
+ data[o + 5] = uv2 ? uv2[i * 4 + 1] : vertices[i * 8 + 7]
14826
+ }
14827
+ const buffer = this.device.createBuffer({
14828
+ label: `${name}: attribute buffer`,
14829
+ size: Math.max(data.byteLength, 4 * Engine.ATTRIBUTE_FLOATS),
14830
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
14831
+ })
14832
+ if (data.byteLength > 0) this.device.queue.writeBuffer(buffer, 0, data)
14833
+ return buffer
14834
+ }
14835
+
14201
14836
  // Build the per-model GPU vertex-morph state. Returns null (and leaves the model on the
14202
14837
  // CPU morph path) when the model has no vertex morphs. Created buffers are pushed into
14203
14838
  // gpuBuffers so they're released with the instance.
@@ -14530,6 +15165,7 @@ export class Engine {
14530
15165
  { binding: 12, resource: this.groundNoiseView },
14531
15166
  { binding: 13, resource: this.mirrorMaskView! },
14532
15167
  { binding: 14, resource: { buffer: this.groundClipOffBuffer } },
15168
+ { binding: 15, resource: this.cookieView },
14533
15169
  ],
14534
15170
  })
14535
15171
  this.groundMirrorViewBindGroup = this.device.createBindGroup({
@@ -14552,6 +15188,7 @@ export class Engine {
14552
15188
  { binding: 12, resource: this.groundNoiseView },
14553
15189
  { binding: 13, resource: this.mirrorDummyColorView! },
14554
15190
  { binding: 14, resource: { buffer: this.groundClipMirrorBuffer } },
15191
+ { binding: 15, resource: this.cookieView },
14555
15192
  ],
14556
15193
  })
14557
15194
  if (this.groundDrawCall) this.groundDrawCall.bindGroup = this.groundShadowBindGroup
@@ -15854,38 +16491,6 @@ export class Engine {
15854
16491
  }
15855
16492
 
15856
16493
  // World-space ray from camera through a canvas pixel. Uses WebGPU's NDC z ∈ [0,1].
15857
- /**
15858
- * Where a point on the canvas lands on a horizontal plane.
15859
- *
15860
- * `px,py` are canvas-relative pixels, top-left origin — what a pointer event
15861
- * gives you after subtracting the element's rect. Returns null when the ray
15862
- * cannot reach the plane: parallel to it, or pointing the other way, which is
15863
- * what a click on the sky above the horizon is.
15864
- *
15865
- * The one primitive a placement UI needs. Dragging a thing across the floor is
15866
- * otherwise three sliders in world units, which asks someone to guess numbers
15867
- * that have no visible relation to the picture they are looking at — and it
15868
- * throws away the property that makes pointing work at all: under perspective,
15869
- * moving something further away makes it smaller by exactly the right amount,
15870
- * so position and size stop being two controls to tune against each other.
15871
- */
15872
- groundPointAt(px: number, py: number, planeY = 0): Vec3 | null {
15873
- const ray = this.buildMouseRay(px, py)
15874
- if (!ray) return null
15875
- // Parallel to the plane: no intersection, and a huge one is not an answer.
15876
- if (Math.abs(ray.dir.y) < 1e-6) return null
15877
- const t = (planeY - ray.origin.y) / ray.dir.y
15878
- // Behind the camera — the plane is there, but not in this shot.
15879
- if (!(t > 0) || !isFinite(t)) return null
15880
- return new Vec3(ray.origin.x + ray.dir.x * t, planeY, ray.origin.z + ray.dir.z * t)
15881
- }
15882
-
15883
- /** Hand the pointer to something else — a placement drag, a gizmo, a host's own
15884
- * overlay — so the orbit does not also act on it. */
15885
- setCameraInputLocked(locked: boolean): void {
15886
- this.camera?.setInputLocked(locked)
15887
- }
15888
-
15889
16494
  private buildMouseRay(px: number, py: number): { origin: Vec3; dir: Vec3 } | null {
15890
16495
  if (!this.camera) return null
15891
16496
  const width = this.canvas.clientWidth
@@ -16478,6 +17083,10 @@ export class Engine {
16478
17083
  idAtt.storeOp = idsRead ? "store" : "discard"
16479
17084
  }
16480
17085
 
17086
+ // The stage's reflection probe, when one is due: its own submits, ahead of
17087
+ // this frame's, so every material below reads the fresh cube.
17088
+ if (hasModels) this.maybeCaptureProbe()
17089
+
16481
17090
  const encoder = this.device.createCommandEncoder()
16482
17091
 
16483
17092
  // GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
@@ -17481,7 +18090,7 @@ export class Engine {
17481
18090
  const base = {
17482
18091
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
17483
18092
  layout: this.mainPipelineLayout,
17484
- vertex: { module, buffers: this.fullVertexBufferLayouts },
18093
+ vertex: { module, buffers: this.materialVertexBufferLayouts },
17485
18094
  // The eye front-culls — the see-through-hair look is draw order plus that
17486
18095
  // cull plus the stencil stamp, and it is the deployed appearance of every
17487
18096
  // published scene. In the MIRROR the winding is flipped (determinant -1,
@@ -17538,7 +18147,16 @@ export class Engine {
17538
18147
  }
17539
18148
  const desc: GPURenderPipelineDescriptor = {
17540
18149
  ...base,
17541
- fragment: { module, constants, targets: blend === "additive" ? this.sceneTargetsAdditive : this.sceneTargets },
18150
+ fragment: {
18151
+ module,
18152
+ constants,
18153
+ targets:
18154
+ blend === "additive"
18155
+ ? this.sceneTargetsAdditive
18156
+ : blend === "premultiplied"
18157
+ ? this.sceneTargetsPremultiplied
18158
+ : this.sceneTargets,
18159
+ },
17542
18160
  depthStencil,
17543
18161
  }
17544
18162
  return this.cachedRenderPipeline(desc).then((pipeline) => {
@@ -17786,6 +18404,46 @@ export class Engine {
17786
18404
  * makes outlines compose like MMD: every material drawn later in the author's
17787
18405
  * order covers earlier hulls, and each hull sits over everything drawn before it.
17788
18406
  */
18407
+ /**
18408
+ * A stage's draws, far to near from the eye: its transparent queue, in the
18409
+ * order a game sorts one. Each draw's distance is its material box's centre,
18410
+ * placed, against the camera — a pane or a water surface is one material,
18411
+ * which is the unit a game sorts too. The array is kept per instance and
18412
+ * re-sorted in place, so an unchanged order costs a comparison per draw.
18413
+ */
18414
+ private backToFront(inst: ModelInstance): DrawCall[] {
18415
+ let list = this.sortedDraws.get(inst)
18416
+ if (!list || list.length !== inst.drawCalls.length || list.some((d) => !inst.drawCalls.includes(d))) {
18417
+ list = [...inst.drawCalls]
18418
+ this.sortedDraws.set(inst, list)
18419
+ }
18420
+ const m = inst.model
18421
+ const ex = this.cameraMatrixData[32]
18422
+ const ey = this.cameraMatrixData[33]
18423
+ const ez = this.cameraMatrixData[34]
18424
+ const p = this.sortScratch
18425
+ const dist = this.sortDistance
18426
+ dist.clear()
18427
+ for (const d of list) {
18428
+ const b = d.bounds
18429
+ if (!b || b.length < 6 || !(b[0] <= b[3])) {
18430
+ dist.set(d, 0)
18431
+ continue
18432
+ }
18433
+ 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)
18434
+ Quat.rotateVecInto(m.rotation, p, p)
18435
+ const dx = p.x + m.position.x - ex
18436
+ const dy = p.y + m.position.y - ey
18437
+ const dz = p.z + m.position.z - ez
18438
+ dist.set(d, dx * dx + dy * dy + dz * dz)
18439
+ }
18440
+ list.sort((a, b) => dist.get(b)! - dist.get(a)!)
18441
+ return list
18442
+ }
18443
+ private sortedDraws = new WeakMap<ModelInstance, DrawCall[]>()
18444
+ private sortScratch = new Vec3(0, 0, 0)
18445
+ private sortDistance = new Map<DrawCall, number>()
18446
+
17789
18447
  /** Is this draw's compiled class "hair"? Ungrouped draws never are — the
17790
18448
  * neutral pipeline is the auto class. */
17791
18449
  private isHairDraw(inst: ModelInstance, dc: DrawCall): boolean {
@@ -17798,7 +18456,7 @@ export class Engine {
17798
18456
  pass: GPURenderPassEncoder | GPURenderBundleEncoder,
17799
18457
  inst: ModelInstance,
17800
18458
  type: "opaque" | "transparent",
17801
- view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
18459
+ view: { perFrame: GPUBindGroup; args: "camera" | "mirror" | "probe"; outlines: boolean },
17802
18460
  // The opaque phase walks its author order twice — non-hair, then hair — so
17803
18461
  // the hair depth prime can sit between the eye's stencil write and the hair
17804
18462
  // colour that must respect it. See renderModelOpaquePhase.
@@ -17806,9 +18464,17 @@ export class Engine {
17806
18464
  ): void {
17807
18465
  let currentPipeline: GPURenderPipeline | null = null
17808
18466
  let bound = false
17809
- for (const draw of inst.drawCalls) {
18467
+ const outlined: DrawCall[] = []
18468
+ // A stage's glass and water in the camera pass go far to near, as a
18469
+ // game's transparent queue does: author order put a far pane over a near
18470
+ // one wherever the author listed it last. Characters keep MMD's author
18471
+ // order — their layers are fabric, built around it.
18472
+ const draws = type === "transparent" && inst.isStage && view.args === "camera" ? this.backToFront(inst) : inst.drawCalls
18473
+ for (const draw of draws) {
17810
18474
  if (draw.type !== type) continue
17811
18475
  if (only && (only === "hair") !== this.isHairDraw(inst, draw)) continue
18476
+ // The probe draws directly, past the cull: honour the switches it holds.
18477
+ if (view.args === "probe" && !this.shouldRenderDrawCall(inst, draw)) continue
17812
18478
  if (!bound) {
17813
18479
  pass.setBindGroup(0, view.perFrame)
17814
18480
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
@@ -17822,35 +18488,42 @@ export class Engine {
17822
18488
  }
17823
18489
  pass.setBindGroup(2, draw.bindGroup)
17824
18490
  this.issueDraw(pass, draw, view.args)
17825
- if (draw.outline && this.outlineEnabled && view.outlines) {
17826
- // Same index range; own pipeline + groups 0/2. Group 1 (skinMats) is
17827
- // layout-identical between the main and outline pipelines and stays
17828
- // bound. Restore group 0 afterwards and force a pipeline re-set.
17829
- const mirrored = view.args === "mirror"
17830
- pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline)
17831
- pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup)
17832
- pass.setBindGroup(2, draw.outline.bindGroup)
17833
- // Slot 3: the hull's smoothed normals + edge scale. Only the outline
17834
- // pipeline reads it; the others leave the slot alone.
17835
- if (inst.outlineVertexBuffer) pass.setVertexBuffer(3, inst.outlineVertexBuffer)
18491
+ if (draw.outline && this.outlineEnabled && view.outlines) outlined.push(draw)
18492
+ }
18493
+ // THE HULLS AFTER THE SURFACES, as MMD draws a model's edges. A hull held
18494
+ // at the minimum width is faded, and it still writes depth: drawn between
18495
+ // two materials it hid the second wherever it stood in front of it — a
18496
+ // shoe's sole's hull cut the upper behind it, and the floor showed through
18497
+ // as pale lines along the seam. After every surface of this phase it only
18498
+ // ever blends over finished ones. Same index ranges; own pipeline and
18499
+ // groups 0/2 — group 1 (skinMats) is layout-identical and stays bound.
18500
+ if (outlined.length) {
18501
+ const mirrored = view.args === "mirror"
18502
+ pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline)
18503
+ pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup)
18504
+ // Slot 3: the hull's smoothed normals + edge scale. Only the outline
18505
+ // pipeline reads it; the others leave the slot alone.
18506
+ if (inst.outlineVertexBuffer) pass.setVertexBuffer(3, inst.outlineVertexBuffer)
18507
+ for (const draw of outlined) {
18508
+ pass.setBindGroup(2, draw.outline!.bindGroup)
17836
18509
  this.issueDraw(pass, draw, view.args)
17837
- pass.setBindGroup(0, view.perFrame)
17838
- currentPipeline = null
17839
18510
  }
18511
+ pass.setBindGroup(0, view.perFrame)
17840
18512
  }
17841
18513
  }
17842
18514
 
17843
18515
  /**
17844
18516
  * Main-pass render sequence for one model instance — babylon-mmd parity:
17845
18517
  * opaque bucket, the hair-over-eyes stencil pass, then alpha-blend materials
17846
- * in PMX author order with depth write ON (forceDepthWrite). Outlines are not
17847
- * a separate phase: drawMaterials draws each edge-flagged material's hull
17848
- * right after the material itself, like MMD's per-mesh outline stage.
18518
+ * in PMX author order with depth write ON (forceDepthWrite). Outlines close
18519
+ * each drawMaterials call: the edge-flagged materials' hulls after all of its
18520
+ * surfaces, as MMD draws a model's edges.
17849
18521
  */
17850
18522
  private setModelDrawState(pass: GPURenderPassEncoder | GPURenderBundleEncoder, inst: ModelInstance): void {
17851
18523
  pass.setVertexBuffer(0, inst.vertexBuffer)
17852
18524
  pass.setVertexBuffer(1, inst.jointsBuffer)
17853
18525
  pass.setVertexBuffer(2, inst.weightsBuffer)
18526
+ pass.setVertexBuffer(4, inst.attributeBuffer)
17854
18527
  pass.setIndexBuffer(inst.indexBuffer, "uint32")
17855
18528
  // The stencil reference used to be set here. It is pass state, not bundle
17856
18529
  // state — GPURenderBundleEncoder has no setStencilReference at all — so it
@@ -18217,7 +18890,7 @@ export class Engine {
18217
18890
  // still pattern welded to the picture. On the SCENE clock like everything
18218
18891
  // else here, so an export reproduces the editor exactly rather than
18219
18892
  // scattering differently at whatever rate the encoder ran.
18220
- u[3] = this.grain.animated ? Math.floor((this.sceneClock * 24) % 1024) : 0
18893
+ u[3] = Math.floor((this.sceneClock * 24) % 1024)
18221
18894
  u[26] = this.canvas.width
18222
18895
  u[27] = this.canvas.height
18223
18896
  // Camera world position (viewU[10]) — the other half of bgWorldPos. It
@@ -18451,7 +19124,10 @@ export class Engine {
18451
19124
  }
18452
19125
  for (let s = 0; s < anchors.length; s++) {
18453
19126
  const a = CAST_SUBJECT_VEC4S * 4 + (s * MAX_EFFECT_SUBJECTS + n) * 12
18454
- const pos = m.getBoneWorldPosition(anchors[s].bone)
19127
+ // `along`: the point that far down the bone's own axis, still model space
19128
+ // here, so the placement below turns and scales it like the joint.
19129
+ const along = anchors[s].along
19130
+ const pos = along ? m.getBonePointAlong(anchors[s].bone, along) : m.getBoneWorldPosition(anchors[s].bone)
18455
19131
  if (!pos) {
18456
19132
  cd[a + 3] = 0
18457
19133
  continue
@@ -18473,7 +19149,7 @@ export class Engine {
18473
19149
  cd[a + 4] = vx
18474
19150
  cd[a + 5] = vy
18475
19151
  cd[a + 6] = vz
18476
- if (anchors[s].trail) this.writeTrail(inst.name, s, n, pos, cd, a)
19152
+ if (anchors[s].trail) this.writeTrail(inst.name, s, n, pos, cd, a, anchors[s].step)
18477
19153
  const fwd = m.getBoneWorldForward(anchors[s].bone)
18478
19154
  if (fwd) {
18479
19155
  Quat.rotateVecInto(m.rotation, fwd, fwd)
@@ -18515,7 +19191,7 @@ export class Engine {
18515
19191
  this.castData.fill(0, CAST_SUBJECT_VEC4S * 4)
18516
19192
  }
18517
19193
 
18518
- private writeTrail(model: string, slot: number, n: number, pos: Vec3, cd: Float32Array, anchorBase: number): void {
19194
+ private writeTrail(model: string, slot: number, n: number, pos: Vec3, cd: Float32Array, anchorBase: number, step?: number): void {
18519
19195
  const key = `${model}\u0000${slot}`
18520
19196
  let ring = this.anchorTrail.get(key)
18521
19197
  if (!ring) {
@@ -18536,9 +19212,44 @@ export class Engine {
18536
19212
  if (Math.hypot(dx, dy, dz) / dt > 50) {
18537
19213
  ring.pos.length = 0
18538
19214
  ring.t.length = 0
19215
+ ring.live = false
18539
19216
  }
18540
19217
  }
18541
- if (this.trailDue > 0 || ring.pos.length === 0) {
19218
+ if (step && ring.pos.length > 0) {
19219
+ // `trail step d` — the path a game's trail keeps (Unity's
19220
+ // minVertexDistance). Point 0 is the LIVE head: it follows the bone every
19221
+ // frame. It is left behind as a kept point only on a tick, and only once
19222
+ // the bone is `step` from the last point kept, so no segment is shorter
19223
+ // than that and a slowing hand cannot leave a knot of jittery stubs for a
19224
+ // wide ribbon to fan across. Still at most one point per tick, so a stepped
19225
+ // ring never covers LESS time than a sampled one.
19226
+ const live = ring.live === true
19227
+ const k = live ? 3 : 0
19228
+ const far = Math.hypot(pos.x - ring.pos[k], pos.y - ring.pos[k + 1], pos.z - ring.pos[k + 2]) >= step
19229
+ if (live && !(far && this.trailDue > 0)) {
19230
+ ring.pos[0] = pos.x
19231
+ ring.pos[1] = pos.y
19232
+ ring.pos[2] = pos.z
19233
+ ring.t[0] = this.sceneClock
19234
+ } else {
19235
+ // keep the head where it is as a point (live) or start a new head
19236
+ if (live) {
19237
+ ring.pos[0] = pos.x
19238
+ ring.pos[1] = pos.y
19239
+ ring.pos[2] = pos.z
19240
+ ring.t[0] = this.sceneClock
19241
+ ring.live = false
19242
+ } else {
19243
+ ring.pos.unshift(pos.x, pos.y, pos.z)
19244
+ ring.t.unshift(this.sceneClock)
19245
+ ring.live = !(far && this.trailDue > 0)
19246
+ }
19247
+ if (ring.t.length > TRAIL_SAMPLES) {
19248
+ ring.t.length = TRAIL_SAMPLES
19249
+ ring.pos.length = TRAIL_SAMPLES * 3
19250
+ }
19251
+ }
19252
+ } else if (this.trailDue > 0 || ring.pos.length === 0) {
18542
19253
  // ONE sample per frame, never one per due tick. A frame that spanned
18543
19254
  // several 60Hz ticks only knows where the bone is NOW, and unshifting that
18544
19255
  // position once per tick fabricated duplicate samples — same point, same
@@ -18622,6 +19333,9 @@ export class Engine {
18622
19333
  headSkin: () => (head >= 0 ? inst.model.getSkinMatrices().subarray(head * 16, head * 16 + 16) : null),
18623
19334
  headRest,
18624
19335
  layers: new Uint32Array(inst.objectLight.buffer)[0],
19336
+ additionalUv: (channel) => inst.model.getAdditionalUv(channel),
19337
+ morphs: inst.model.getMorphing().morphs,
19338
+ morphWeights: () => inst.model.getEffectiveMorphWeights(),
18625
19339
  },
18626
19340
  look,
18627
19341
  )
@@ -18636,6 +19350,20 @@ export class Engine {
18636
19350
  return this.nativeLooks?.ready(name) ?? Promise.resolve()
18637
19351
  }
18638
19352
 
19353
+ /**
19354
+ * Set some of the values of one material a model's native look dresses —
19355
+ * its Unity material properties by name, colours linear, as the look's
19356
+ * `values` hold them — over the look's own, until set again. What a game
19357
+ * animates on a material over a take (its timeline's material curves)
19358
+ * reaches the shaders this way, one frame at a time: the caller samples its
19359
+ * keys on its own clock and sets what it got. Only the names the material's
19360
+ * look carries are taken, and a value equal to the one in place writes
19361
+ * nothing. False when the model wears no look dressing that material.
19362
+ */
19363
+ setModelNativeUniforms(name: string, material: string, values: Record<string, number | ArrayLike<number>>): boolean {
19364
+ return this.nativeLooks?.setUniforms(name, material, values) ?? false
19365
+ }
19366
+
18639
19367
  /** The hosts for the game's shaders: the scene pass's and the shadow atlas's,
18640
19368
  * with the stand-ins an empty slot reads. Built on first use. */
18641
19369
  private ensureNativeHosts(): NativeHost {
@@ -18682,6 +19410,18 @@ export class Engine {
18682
19410
  white,
18683
19411
  black,
18684
19412
  blackCube: solid("native: black cube", [0, 0, 0, 255], true),
19413
+ // an empty volume (a froxel buffer no pass filled): no in-scatter, no extinction
19414
+ emptyVolume: (() => {
19415
+ const t = d.createTexture({
19416
+ label: "native: empty volume",
19417
+ size: [1, 1, 1],
19418
+ dimension: "3d",
19419
+ format: "rgba8unorm",
19420
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
19421
+ })
19422
+ d.queue.writeTexture({ texture: t }, new Uint8Array([0, 0, 0, 0]), { bytesPerRow: 4, rowsPerImage: 1 }, [1, 1, 1])
19423
+ return t.createView({ dimension: "3d" })
19424
+ })(),
18685
19425
  depth: this.nativeNoShadowView,
18686
19426
  comparison: this.shadowComparisonSampler,
18687
19427
  }
@@ -18720,7 +19460,8 @@ export class Engine {
18720
19460
  { mipmaps: (t, levels) => this.generateMipmaps(t, levels), fallback: this.nativeFallback },
18721
19461
  onProgress,
18722
19462
  )
18723
-
19463
+ // The stage's sky is in it: the far plane reaches as far as the stage does.
19464
+ this.raiseSceneExtent(this.nativeStage.extent)
18724
19465
  }
18725
19466
 
18726
19467
  /** What went wrong in the game's shaders, and the names nothing supplied. */
@@ -18770,11 +19511,17 @@ export class Engine {
18770
19511
  let sunDirection: [number, number, number] = [dir.x, dir.y, dir.z]
18771
19512
  let sunColor: [number, number, number] = [sun.color.x * k, sun.color.y * k, sun.color.z * k]
18772
19513
  let sunShadow = this.sunShadow
19514
+ let sunUnity: NativeStageLights["main"]["unity"] | null = null
18773
19515
  if (stage) {
18774
19516
  // Its lamps are the stage's own; its key is the scene's sun, which the
18775
- // host sets from the stage (and a person may then move).
19517
+ // host sets from the stage (and a person may then move) - along the
19518
+ // scene sun's direction, in the stage's own main-light colour. The scene
19519
+ // sun's colour is the cast's key, which the game keeps apart (a
19520
+ // character's light is _ProbeLightingBase, not the stage's main light).
18776
19521
  const L = stage.pkg.lights
18777
19522
  const sc = stage.pkg.scale
19523
+ sunColor = [L.main.color[0], L.main.color[1], L.main.color[2]]
19524
+ sunUnity = L.main.unity ?? null
18778
19525
  lights.length = 0
18779
19526
  for (const l of L.additional) {
18780
19527
  lights.push({
@@ -18804,6 +19551,7 @@ export class Engine {
18804
19551
  dt: 1 / 60,
18805
19552
  sunDirection,
18806
19553
  sunColor,
19554
+ sunUnity,
18807
19555
  sunShadow,
18808
19556
  lights,
18809
19557
  ambientSH: sh ? Array.from(sh, (v) => v * s) : null,
@@ -18917,17 +19665,3 @@ function stableGlobals(prev: Record<string, NativeValue>, next: Record<string, N
18917
19665
  }
18918
19666
  return next
18919
19667
  }
18920
-
18921
- function sameValue(a: NativeValue, b: NativeValue): boolean {
18922
- if (typeof a === "number" || typeof b === "number") return a === b
18923
- if (a.length !== b.length) return false
18924
- if (a instanceof Uint32Array !== b instanceof Uint32Array) return false
18925
- for (let i = 0; i < a.length; i++) {
18926
- const x = a[i] as number | ArrayLike<number>
18927
- const y = b[i] as number | ArrayLike<number>
18928
- if (typeof x === "number" || typeof y === "number") {
18929
- if (x !== y) return false
18930
- } else if (!sameValue(x, y)) return false
18931
- }
18932
- return true
18933
- }