reze-engine 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +874 -185
  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 +947 -214
  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 {
@@ -4755,8 +4828,11 @@ export class Engine {
4755
4828
  [w, h],
4756
4829
  )
4757
4830
  }
4758
- this.worldEquirectTexture = tex
4759
- this.worldEquirectView = tex.createView()
4831
+ // The box chain above is the SOURCE: the levels the materials read are
4832
+ // the GGX convolution of it, one roughness per level (prefilterSky).
4833
+ const filtered = this.prefilterSky(tex, source.width, source.height, levels)
4834
+ this.worldEquirectTexture = filtered
4835
+ this.worldEquirectView = filtered.createView()
4760
4836
  // The sky lights the scene, not only backs it. The sun keeps the toon
4761
4837
  // ramp — this is the ambient term, exactly where the flat world colour
4762
4838
  // used to sit.
@@ -4776,6 +4852,9 @@ export class Engine {
4776
4852
  this.worldBindingsDirty = true
4777
4853
  this.rebuildPerFrameBindGroups()
4778
4854
  this.rebuildCompositeBindGroup()
4855
+ // The probe's compose step binds the sky it lays in behind the stage.
4856
+ this.dropProbeCapture()
4857
+ this.captureReflectionProbe()
4779
4858
  if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
4780
4859
  }
4781
4860
 
@@ -4902,7 +4981,7 @@ export class Engine {
4902
4981
  params: Record<string, EffectParamValue> | undefined,
4903
4982
  /** This effect's own declarations, already parsed by the caller — which had
4904
4983
  * to read them anyway to build the scene table. */
4905
- anchors: { bone: string; trail: boolean }[],
4984
+ anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
4906
4985
  /** Its row of that table: local slot → scene slot. */
4907
4986
  alias: number[],
4908
4987
  /** The pictures for `#textures`, as the host passed them. */
@@ -5574,7 +5653,7 @@ export class Engine {
5574
5653
  /** What the file declared. Read here rather than re-parsed: the source no
5575
5654
  * longer carries the lines, and two readers is how they drift. */
5576
5655
  d: EffectDirectives,
5577
- anchors: { bone: string; trail: boolean }[],
5656
+ anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
5578
5657
  /** This effect's local→scene slot map. Passed rather than read off the
5579
5658
  * engine: the builders run BEFORE the swap, so this.anchorTable still
5580
5659
  * describes the effect that is still on screen. */
@@ -5862,7 +5941,11 @@ export class Engine {
5862
5941
  // shading ten layers and shading about three.
5863
5942
  depthStencil: prepass
5864
5943
  ? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
5865
- : { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
5944
+ : d.depthAlways
5945
+ ? // `#depth always`: over the scene, Unity's ZTest Always (a moon's halo
5946
+ // drawn in front of the sky dome it sits behind)
5947
+ { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "always" }
5948
+ : { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
5866
5949
  multisample: { count: Engine.MULTISAMPLE_COUNT },
5867
5950
  }),
5868
5951
  ])
@@ -5946,7 +6029,7 @@ export class Engine {
5946
6029
  wgsl: string,
5947
6030
  count: number,
5948
6031
  alias: number[],
5949
- anchors: { bone: string; trail: boolean }[],
6032
+ anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
5950
6033
  params: EffectParamsBinding,
5951
6034
  ): Promise<{ ok: true; state: NonNullable<EffectInstance["lights"]> } | { ok: false; diagnostics: string[] }> {
5952
6035
  const layout = this.device.createBindGroupLayout({
@@ -6200,7 +6283,7 @@ export class Engine {
6200
6283
  /** What the file declared. Read here rather than re-parsed: the source no
6201
6284
  * longer carries the lines, and two readers is how they drift. */
6202
6285
  d: EffectDirectives,
6203
- anchors: { bone: string; trail: boolean }[],
6286
+ anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
6204
6287
  /** This effect's local→scene slot map. Passed rather than read off the
6205
6288
  * engine: the builders run BEFORE the swap, so this.anchorTable still
6206
6289
  * describes the effect that is still on screen. */
@@ -6290,8 +6373,10 @@ export class Engine {
6290
6373
  // point: a layer composited after tone mapping can never bloom.
6291
6374
  //
6292
6375
  // 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)
6376
+ // layer; in HDR before bloom, overlapping light sums. `#blend over` lays the
6377
+ // ribbon over the scene by its alpha instead — a game trail's own blend,
6378
+ // under which its dark smoke darkens rather than vanishing.
6379
+ const targets = sceneTargetsFor(d.trailBlend === "over" ? "trail-over" : "trail", this.sceneFormats)
6295
6380
  this.device.pushErrorScope("validation")
6296
6381
  try {
6297
6382
  const pipeline = await this.device.createRenderPipelineAsync({
@@ -6709,7 +6794,7 @@ export class Engine {
6709
6794
  /** What the file declared. Read here rather than re-parsed: the source no
6710
6795
  * longer carries the lines, and two readers is how they drift. */
6711
6796
  d: EffectDirectives,
6712
- anchors: { bone: string; trail: boolean }[],
6797
+ anchors: { bone: string; trail: boolean; along?: number; step?: number }[],
6713
6798
  /** This effect's local→scene slot map. Passed rather than read off the
6714
6799
  * engine: the builders run BEFORE the swap, so this.anchorTable still
6715
6800
  * describes the effect that is still on screen. */
@@ -7304,7 +7389,9 @@ export class Engine {
7304
7389
  u[0] = Math.max(0, b.threshold)
7305
7390
  u[1] = Math.max(0, b.threshold) * 0.5
7306
7391
  u[2] = Engine.BLOOM_CLAMP
7307
- u[3] = Math.min(1, Math.max(0, b.scatter))
7392
+ // URP's mapping: never all the way to the widest level, which alone is a
7393
+ // few pixels smeared over the screen — the glow would vanish.
7394
+ u[3] = 0.05 + 0.9 * Math.min(1, Math.max(0, b.scatter))
7308
7395
  this.device.queue.writeBuffer(this.bloomUniformBuffer, 0, u)
7309
7396
  }
7310
7397
 
@@ -7528,81 +7615,6 @@ export class Engine {
7528
7615
  this.device.queue.submit([encoder.finish()])
7529
7616
  }
7530
7617
 
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
7618
  private createRenderPipeline(config: Parameters<Engine["renderPipelineDesc"]>[0]): GPURenderPipeline {
7607
7619
  return this.createModelPipeline(this.renderPipelineDesc(config))
7608
7620
  }
@@ -7611,7 +7623,7 @@ export class Engine {
7611
7623
  label: string
7612
7624
  layout: GPUPipelineLayout
7613
7625
  shaderModule: GPUShaderModule
7614
- vertexBuffers: GPUVertexBufferLayout[]
7626
+ vertexBuffers: (GPUVertexBufferLayout | null)[]
7615
7627
  fragmentTarget?: GPUColorTargetState
7616
7628
  fragmentTargets?: GPUColorTargetState[]
7617
7629
  fragmentEntryPoint?: string
@@ -7928,7 +7940,22 @@ export class Engine {
7928
7940
  // The same attachments blended as light — for a group that declared
7929
7941
  // blend: "additive". See scene-contract's material-additive.
7930
7942
  this.sceneTargetsAdditive = sceneTargetsFor("material-additive", this.sceneFormats)
7943
+ this.sceneTargetsPremultiplied = sceneTargetsFor("material-premultiplied", this.sceneFormats)
7931
7944
  this.fullVertexBufferLayouts = fullVertexBuffers
7945
+ // Slot 3 stays empty: the outline hull binds its own stream there between
7946
+ // two material draws, and a material reading slot 3 would pick it up.
7947
+ const materialVertexBuffers: (GPUVertexBufferLayout | null)[] = [
7948
+ ...fullVertexBuffers,
7949
+ null,
7950
+ {
7951
+ arrayStride: Engine.ATTRIBUTE_FLOATS * 4,
7952
+ attributes: [
7953
+ { shaderLocation: 5, offset: 0, format: "float32x4" as GPUVertexFormat },
7954
+ { shaderLocation: 6, offset: 4 * 4, format: "float32x2" as GPUVertexFormat },
7955
+ ],
7956
+ },
7957
+ ]
7958
+ this.materialVertexBufferLayouts = materialVertexBuffers
7932
7959
 
7933
7960
  // group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
7934
7961
  this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
@@ -7944,14 +7971,19 @@ export class Engine {
7944
7971
  // The positional lights. Always bound, empty or not, so every material
7945
7972
  // pipeline shares one layout whether or not the scene has any.
7946
7973
  { 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.)
7974
+ // The world's sky (8). Always bound — the 1x1 fallback when the scene
7975
+ // has none — so every material pipeline keeps sharing one layout. (7
7976
+ // was the far cascade's map, which the atlas at 3 replaces.)
7951
7977
  { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
7952
- { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
7978
+ // The spot lamps' cookies (loadLightCookie), a 1x1 white until one loads.
7979
+ { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d-array" } },
7953
7980
  // The cast's shadow on a stage — setStageCastShadow.
7954
7981
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
7982
+ // The stage's reflection probe and where it projects — see
7983
+ // captureReflectionProbe. Always bound: a 1x1 cube and the off flag
7984
+ // when there is no stage.
7985
+ { binding: 11, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "cube" } },
7986
+ { binding: 12, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
7955
7987
  ],
7956
7988
  })
7957
7989
  // group 1: per-instance (skinMats) — bound once per model
@@ -8024,7 +8056,7 @@ export class Engine {
8024
8056
  label: "neutral base pipeline",
8025
8057
  layout: mainPipelineLayout,
8026
8058
  shaderModule: neutralModule,
8027
- vertexBuffers: fullVertexBuffers,
8059
+ vertexBuffers: materialVertexBuffers,
8028
8060
  fragmentTargets: sceneTargets,
8029
8061
  cullMode: "none",
8030
8062
  depthStencil: {
@@ -8042,7 +8074,7 @@ export class Engine {
8042
8074
  label: "neutral base pipeline (no depth write)",
8043
8075
  layout: mainPipelineLayout,
8044
8076
  shaderModule: neutralModule,
8045
- vertexBuffers: fullVertexBuffers,
8077
+ vertexBuffers: materialVertexBuffers,
8046
8078
  fragmentTargets: sceneTargets,
8047
8079
  cullMode: "none",
8048
8080
  depthStencil: {
@@ -8212,7 +8244,6 @@ export class Engine {
8212
8244
  })
8213
8245
 
8214
8246
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
8215
- this.bakeBrdfLut()
8216
8247
  this.bakeGroundNoise()
8217
8248
  // The mipmap blit for the formats model textures arrive in, built here so
8218
8249
  // the first texture of the first model does not compile it synchronously
@@ -8269,6 +8300,48 @@ export class Engine {
8269
8300
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
8270
8301
  })
8271
8302
  this.fallbackEquirectView = this.fallbackEquirectTexture.createView()
8303
+ // The reflection probe's resources, bound from the first frame: the cube
8304
+ // (empty and switched off until a stage is captured), its stand-in and the
8305
+ // uniforms. Before the per-frame groups, which bind all of them.
8306
+ this.probeTexture = this.device.createTexture({
8307
+ label: "reflection probe",
8308
+ size: [PROBE_SIZE, PROBE_SIZE, 6],
8309
+ format: "rgba16float",
8310
+ mipLevelCount: PROBE_LEVELS,
8311
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
8312
+ })
8313
+ this.probeView = this.probeTexture.createView({ dimension: "cube" })
8314
+ this.probeFallbackView = this.device
8315
+ .createTexture({
8316
+ label: "reflection probe stand-in",
8317
+ size: [1, 1, 6],
8318
+ format: "rgba16float",
8319
+ usage: GPUTextureUsage.TEXTURE_BINDING,
8320
+ })
8321
+ .createView({ dimension: "cube" })
8322
+ this.cookieTexture = this.device.createTexture({
8323
+ label: "light cookies (none yet)",
8324
+ size: [1, 1, 1],
8325
+ format: "rgba8unorm",
8326
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
8327
+ })
8328
+ this.device.queue.writeTexture({ texture: this.cookieTexture }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1, 1])
8329
+ this.cookieView = this.cookieTexture.createView({ dimension: "2d-array" })
8330
+ this.probeUniformBuffer = this.device.createBuffer({
8331
+ label: "reflection probe uniforms",
8332
+ size: 48,
8333
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8334
+ })
8335
+ this.probeOffBuffer = this.device.createBuffer({
8336
+ label: "reflection probe uniforms (off)",
8337
+ size: 48,
8338
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8339
+ })
8340
+ this.probeCameraBuffer = this.device.createBuffer({
8341
+ label: "reflection probe capture camera",
8342
+ size: 40 * 4,
8343
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8344
+ })
8272
8345
 
8273
8346
  // Zero-filled = zero lines, which every accessor answers gracefully.
8274
8347
  this.lyricsBuffer = this.device.createBuffer({
@@ -8316,6 +8389,8 @@ export class Engine {
8316
8389
  { binding: 13, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
8317
8390
  // The mirror's plane, and whether this draw is inside the mirror pass.
8318
8391
  { binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
8392
+ // The spot lamps' cookies — the floor takes their patterns too.
8393
+ { binding: 15, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d-array" } },
8319
8394
  ],
8320
8395
  })
8321
8396
  this.groundShadowPipelineDesc = {
@@ -10391,6 +10466,415 @@ export class Engine {
10391
10466
  * Retried at the top of a frame until one of them takes. */
10392
10467
  private worldBindingsDirty = false
10393
10468
 
10469
+ // ─── The stage's reflection probe ───────────────────────────────────
10470
+ //
10471
+ // What a game bakes for every room: a cube of the stage itself, seen from
10472
+ // its middle, prefiltered down its mips by the specular lobe, and read by
10473
+ // every surface box-projected onto the stage's bounds — so a lacquered
10474
+ // counter reflects the bottles behind it and a wet floor the lamps above,
10475
+ // not a sky the room has no window onto. Captured here automatically once a
10476
+ // stage has loaded and its looks have settled; nothing to set.
10477
+
10478
+ /** Drop the capture's resources, so the next capture rebinds the sky. */
10479
+ private dropProbeCapture(): void {
10480
+ const cap = this.probeCapture
10481
+ if (!cap) return
10482
+ this.probeCapture = null
10483
+ void this.device.queue.onSubmittedWorkDone().then(() => {
10484
+ for (const t of cap.msTargets) t.destroy()
10485
+ cap.depth.destroy()
10486
+ cap.color.destroy()
10487
+ cap.aux.destroy()
10488
+ cap.cube.destroy()
10489
+ cap.steps.destroy()
10490
+ })
10491
+ }
10492
+
10493
+ /** Ask for a fresh capture of the stage's reflection probe. It runs at the
10494
+ * start of a frame once the scene has held still for a moment — asked for
10495
+ * by the engine itself whenever the stage or its looks change; a host may
10496
+ * call it after changing something the engine cannot see. */
10497
+ captureReflectionProbe(): void {
10498
+ if (!this.probeDirty) this.probeDirtyFrame = this.probeFrame
10499
+ this.probeDirty = true
10500
+ }
10501
+
10502
+ /** The stage's world-space bounds: every stage model's material boxes
10503
+ * through its placement. Null without a stage. */
10504
+ private stageBounds(): { min: [number, number, number]; max: [number, number, number] } | null {
10505
+ const min: [number, number, number] = [Infinity, Infinity, Infinity]
10506
+ const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]
10507
+ const p = new Vec3(0, 0, 0)
10508
+ let any = false
10509
+ for (const inst of this.modelInstances.values()) {
10510
+ if (!inst.isStage || !inst.model.visible) continue
10511
+ const m = inst.model
10512
+ for (const draw of inst.drawCalls) {
10513
+ const b = draw.bounds
10514
+ if (!b || b.length < 6 || !(b[0] <= b[3])) continue
10515
+ // The eight corners of the material's model-space box, placed.
10516
+ for (let k = 0; k < 8; k++) {
10517
+ p.setXYZ((k & 1 ? b[3] : b[0]) * m.scale, (k & 2 ? b[4] : b[1]) * m.scale, (k & 4 ? b[5] : b[2]) * m.scale)
10518
+ Quat.rotateVecInto(m.rotation, p, p)
10519
+ const w = [p.x + m.position.x, p.y + m.position.y, p.z + m.position.z]
10520
+ for (let a = 0; a < 3; a++) {
10521
+ if (w[a] < min[a]) min[a] = w[a]
10522
+ if (w[a] > max[a]) max[a] = w[a]
10523
+ }
10524
+ any = true
10525
+ }
10526
+ }
10527
+ }
10528
+ return any ? { min, max } : null
10529
+ }
10530
+
10531
+ /** Run a pending capture once the scene has settled — see captureReflectionProbe. */
10532
+ private maybeCaptureProbe(): void {
10533
+ this.probeFrame++
10534
+ if (!this.probeDirty || !this.probeCaptureBindGroup) return
10535
+ if (this.probeFrame - this.probeDirtyFrame < Engine.PROBE_SETTLE_FRAMES) return
10536
+ this.probeDirty = false
10537
+ const bounds = this.stageBounds()
10538
+ if (!bounds) {
10539
+ // No stage: the probe goes dark and every reader falls back to the sky.
10540
+ this.probeData.fill(0)
10541
+ this.device.queue.writeBuffer(this.probeUniformBuffer, 0, this.probeData)
10542
+ return
10543
+ }
10544
+ try {
10545
+ this.renderProbe(bounds)
10546
+ } catch (e) {
10547
+ console.warn("[reze-engine] reflection probe capture failed:", e)
10548
+ }
10549
+ }
10550
+
10551
+ private ensureProbeCapture(): NonNullable<Engine["probeCapture"]> {
10552
+ if (this.probeCapture) return this.probeCapture
10553
+ const size = PROBE_CAPTURE_SIZE
10554
+ const formats = sceneColorFormats(this.sceneFormats)
10555
+ const msTargets = formats.map((format, i) =>
10556
+ this.device.createTexture({
10557
+ label: `reflection probe capture target ${i}`,
10558
+ size: [size, size],
10559
+ sampleCount: Engine.MULTISAMPLE_COUNT,
10560
+ format,
10561
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
10562
+ }),
10563
+ )
10564
+ const depth = this.device.createTexture({
10565
+ label: "reflection probe capture depth",
10566
+ size: [size, size],
10567
+ sampleCount: Engine.MULTISAMPLE_COUNT,
10568
+ format: this.depthFormat,
10569
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
10570
+ })
10571
+ const layered = (label: string, format: GPUTextureFormat) =>
10572
+ this.device.createTexture({
10573
+ label,
10574
+ size: [size, size, 6],
10575
+ format,
10576
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
10577
+ })
10578
+ // The resolves take the scene pass's own formats: colour and coverage.
10579
+ const color = layered("reflection probe capture colour", formats[0])
10580
+ const aux = layered("reflection probe capture coverage", formats[1])
10581
+ const cube = this.device.createTexture({
10582
+ label: "reflection probe capture cube",
10583
+ size: [size, size, 6],
10584
+ format: "rgba16float",
10585
+ mipLevelCount: PROBE_CAPTURE_LEVELS,
10586
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
10587
+ })
10588
+ // One 256-byte slot per step: 6 composes, then the capture's mips, then
10589
+ // the probe's levels, six faces each.
10590
+ const slots = 6 + PROBE_CAPTURE_LEVELS * 6 + PROBE_LEVELS * 6
10591
+ const steps = this.device.createBuffer({
10592
+ label: "reflection probe steps",
10593
+ size: slots * 256,
10594
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
10595
+ })
10596
+ const composeModule = this.device.createShaderModule({ label: "reflection probe compose", code: PROBE_COMPOSE_WGSL })
10597
+ const composePipeline = this.device.createRenderPipeline({
10598
+ label: "reflection probe compose",
10599
+ layout: "auto",
10600
+ vertex: { module: composeModule, entryPoint: "vs_full" },
10601
+ fragment: { module: composeModule, entryPoint: "fs_compose", targets: [{ format: "rgba16float" }] },
10602
+ primitive: { topology: "triangle-list" },
10603
+ })
10604
+ const filterModule = this.device.createShaderModule({ label: "reflection probe filter", code: PROBE_FILTER_WGSL })
10605
+ const filterPipeline = this.device.createRenderPipeline({
10606
+ label: "reflection probe filter",
10607
+ layout: "auto",
10608
+ vertex: { module: filterModule, entryPoint: "vs_full" },
10609
+ fragment: { module: filterModule, entryPoint: "fs_filter", targets: [{ format: "rgba16float" }] },
10610
+ primitive: { topology: "triangle-list" },
10611
+ })
10612
+ const colorArray = color.createView({ dimension: "2d-array" })
10613
+ const auxArray = aux.createView({ dimension: "2d-array" })
10614
+ const composeLayout = composePipeline.getBindGroupLayout(0)
10615
+ const composeGroups: GPUBindGroup[] = []
10616
+ for (let f = 0; f < 6; f++) {
10617
+ composeGroups.push(
10618
+ this.device.createBindGroup({
10619
+ label: `reflection probe compose ${f}`,
10620
+ layout: composeLayout,
10621
+ entries: [
10622
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
10623
+ { binding: 2, resource: this.materialSampler },
10624
+ { binding: 8, resource: this.worldEquirectView ?? this.fallbackEquirectView },
10625
+ { binding: 20, resource: colorArray },
10626
+ { binding: 21, resource: auxArray },
10627
+ { binding: 22, resource: { buffer: steps, offset: f * 256, size: 32 } },
10628
+ ],
10629
+ }),
10630
+ )
10631
+ }
10632
+ const filterLayout = filterPipeline.getBindGroupLayout(0)
10633
+ const cubeView = cube.createView({ dimension: "cube" })
10634
+ const filterGroups: GPUBindGroup[] = []
10635
+ for (let i = 0; i < PROBE_LEVELS * 6; i++) {
10636
+ filterGroups.push(
10637
+ this.device.createBindGroup({
10638
+ label: `reflection probe level ${i}`,
10639
+ layout: filterLayout,
10640
+ entries: [
10641
+ { binding: 0, resource: cubeView },
10642
+ { binding: 1, resource: this.materialSampler },
10643
+ { binding: 2, resource: { buffer: steps, offset: (6 + PROBE_CAPTURE_LEVELS * 6 + i) * 256, size: 32 } },
10644
+ ],
10645
+ }),
10646
+ )
10647
+ }
10648
+ this.probeCapture = {
10649
+ msTargets,
10650
+ depth,
10651
+ color,
10652
+ aux,
10653
+ cube,
10654
+ steps,
10655
+ composePipeline,
10656
+ filterPipeline,
10657
+ composeGroups,
10658
+ filterGroups,
10659
+ }
10660
+ return this.probeCapture
10661
+ }
10662
+
10663
+ /**
10664
+ * Capture the probe: the stage drawn six times from one point into a 128²
10665
+ * cube, the sky laid in behind it, a mip chain, and the GGX convolution
10666
+ * into the 64² cube the materials read.
10667
+ *
10668
+ * WHERE: the middle of the stage's bounds across the floor, at about a
10669
+ * character's chest height above the floor (y = 0 where the bounds span it,
10670
+ * the MMD convention, else their bottom) — where a game puts the probe of a
10671
+ * room the player stands in. The box it projects onto is the bounds.
10672
+ *
10673
+ * WHAT: the stage alone, in its own looks, lit by the scene's sun, lamps
10674
+ * and sky as they stand. The cast is not in it (a baked probe never holds
10675
+ * the characters), nor are effects.
10676
+ */
10677
+ private renderProbe(bounds: { min: [number, number, number]; max: [number, number, number] }): void {
10678
+ const cap = this.ensureProbeCapture()
10679
+ const { min, max } = bounds
10680
+ const floor = min[1] <= 0 && max[1] >= 0 ? 0 : min[1]
10681
+ const eye: [number, number, number] = [
10682
+ (min[0] + max[0]) * 0.5,
10683
+ Math.min(floor + 12, (min[1] + max[1]) * 0.5),
10684
+ (min[2] + max[2]) * 0.5,
10685
+ ]
10686
+ const span = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2])
10687
+ const near = 0.25
10688
+ const far = Math.max(span * 2, 100)
10689
+ // Each face's forward and up, in the convention the GPU samples a cube
10690
+ // with (see rz_face_dir in passes/probe.ts): +X, -X, +Y, -Y, +Z, -Z.
10691
+ const faces: [number, number, number, number, number, number][] = [
10692
+ [1, 0, 0, 0, 1, 0],
10693
+ [-1, 0, 0, 0, 1, 0],
10694
+ [0, 1, 0, 0, 0, -1],
10695
+ [0, -1, 0, 0, 0, 1],
10696
+ [0, 0, 1, 0, 1, 0],
10697
+ [0, 0, -1, 0, 1, 0],
10698
+ ]
10699
+ const cam = this.probeCameraData
10700
+ const view = { perFrame: this.probeCaptureBindGroup!, args: "probe" as const, outlines: false }
10701
+ const formats = sceneColorFormats(this.sceneFormats)
10702
+ for (let f = 0; f < 6; f++) {
10703
+ const [fx, fy, fz, ux, uy, uz] = faces[f]
10704
+ Mat4.lookAtInto(cam.subarray(0, 16), eye[0], eye[1], eye[2], eye[0] + fx, eye[1] + fy, eye[2] + fz, ux, uy, uz)
10705
+ if (this.reversedZ) Mat4.perspectiveInto(cam.subarray(16, 32), Math.PI / 2, 1, far, near)
10706
+ else Mat4.perspectiveInto(cam.subarray(16, 32), Math.PI / 2, 1, near, far)
10707
+ cam[32] = eye[0]
10708
+ cam[33] = eye[1]
10709
+ cam[34] = eye[2]
10710
+ cam[35] = PROBE_CAPTURE_SIZE
10711
+ cam[36] = this.sceneClock
10712
+ this.device.queue.writeBuffer(this.probeCameraBuffer, 0, cam)
10713
+ const encoder = this.device.createCommandEncoder({ label: `reflection probe face ${f}` })
10714
+ const colorAttachments: GPURenderPassColorAttachment[] = formats.map((_, i) => ({
10715
+ view: cap.msTargets[i].createView(),
10716
+ resolveTarget:
10717
+ i === 0
10718
+ ? cap.color.createView({ dimension: "2d", baseArrayLayer: f, arrayLayerCount: 1 })
10719
+ : i === 1
10720
+ ? cap.aux.createView({ dimension: "2d", baseArrayLayer: f, arrayLayerCount: 1 })
10721
+ : undefined,
10722
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
10723
+ loadOp: "clear",
10724
+ storeOp: "discard",
10725
+ }))
10726
+ const pass = encoder.beginRenderPass({
10727
+ label: `reflection probe face ${f}`,
10728
+ colorAttachments,
10729
+ depthStencilAttachment: {
10730
+ view: cap.depth.createView(),
10731
+ depthClearValue: this.depthClear,
10732
+ depthLoadOp: "clear",
10733
+ depthStoreOp: "discard",
10734
+ stencilClearValue: 0,
10735
+ stencilLoadOp: "clear",
10736
+ stencilStoreOp: "discard",
10737
+ },
10738
+ })
10739
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
10740
+ for (const inst of this.modelInstances.values()) {
10741
+ if (!inst.isStage || !inst.model.visible) continue
10742
+ this.setModelDrawState(pass, inst)
10743
+ this.drawMaterials(pass, inst, "opaque", view)
10744
+ }
10745
+ for (const inst of this.modelInstances.values()) {
10746
+ if (!inst.isStage || !inst.model.visible) continue
10747
+ this.setModelDrawState(pass, inst)
10748
+ this.drawMaterials(pass, inst, "transparent", view)
10749
+ }
10750
+ pass.end()
10751
+ this.device.queue.submit([encoder.finish()])
10752
+ }
10753
+
10754
+ // The steps: compose, capture mips, then the probe's levels.
10755
+ const steps = new ArrayBuffer((6 + PROBE_CAPTURE_LEVELS * 6 + PROBE_LEVELS * 6) * 256)
10756
+ const put = (slot: number, face: number, lod: number, roughness: number, srcSize: number, mode: number) => {
10757
+ const u = new Uint32Array(steps, slot * 256, 8)
10758
+ const fl = new Float32Array(steps, slot * 256, 8)
10759
+ u[0] = face
10760
+ fl[1] = lod
10761
+ fl[2] = roughness
10762
+ fl[3] = srcSize
10763
+ u[4] = mode
10764
+ }
10765
+ for (let f = 0; f < 6; f++) put(f, f, 0, 0, PROBE_CAPTURE_SIZE, 0)
10766
+ for (let m = 0; m < PROBE_CAPTURE_LEVELS; m++)
10767
+ for (let f = 0; f < 6; f++) put(6 + m * 6 + f, f, Math.max(m - 1, 0), 0, PROBE_CAPTURE_SIZE, 0)
10768
+ for (let m = 0; m < PROBE_LEVELS; m++)
10769
+ for (let f = 0; f < 6; f++)
10770
+ // Level 0 is the capture at the probe's size: one tap of the 128²
10771
+ // level-0 cube at a 64² texel's centre is its 2×2 average.
10772
+ put(6 + PROBE_CAPTURE_LEVELS * 6 + m * 6 + f, f, 0, probeLevelRoughness(m), PROBE_CAPTURE_SIZE, m === 0 ? 0 : 1)
10773
+ this.device.queue.writeBuffer(cap.steps, 0, steps)
10774
+
10775
+ const encoder = this.device.createCommandEncoder({ label: "reflection probe filter" })
10776
+ const run = (target: GPUTextureView, pipeline: GPURenderPipeline, group: GPUBindGroup) => {
10777
+ const p = encoder.beginRenderPass({ colorAttachments: [{ view: target, loadOp: "clear", storeOp: "store" }] })
10778
+ p.setPipeline(pipeline)
10779
+ p.setBindGroup(0, group)
10780
+ p.draw(3)
10781
+ p.end()
10782
+ }
10783
+ const face = (tex: GPUTexture, level: number, f: number) =>
10784
+ tex.createView({ dimension: "2d", baseMipLevel: level, mipLevelCount: 1, baseArrayLayer: f, arrayLayerCount: 1 })
10785
+ for (let f = 0; f < 6; f++) run(face(cap.cube, 0, f), cap.composePipeline, cap.composeGroups[f])
10786
+ // Each level from the one above. The source view spans the whole chain,
10787
+ // which a pass writing one of its levels may not bind — so the reads go
10788
+ // through a view of the levels ABOVE the one being written.
10789
+ for (let m = 1; m < PROBE_CAPTURE_LEVELS; m++) {
10790
+ const above = cap.cube.createView({ dimension: "cube", baseMipLevel: 0, mipLevelCount: m })
10791
+ for (let f = 0; f < 6; f++) {
10792
+ const group = this.device.createBindGroup({
10793
+ label: `reflection probe capture mip ${m}/${f}`,
10794
+ layout: cap.filterPipeline.getBindGroupLayout(0),
10795
+ entries: [
10796
+ { binding: 0, resource: above },
10797
+ { binding: 1, resource: this.materialSampler },
10798
+ { binding: 2, resource: { buffer: cap.steps, offset: (6 + m * 6 + f) * 256, size: 32 } },
10799
+ ],
10800
+ })
10801
+ run(face(cap.cube, m, f), cap.filterPipeline, group)
10802
+ }
10803
+ }
10804
+ for (let m = 0; m < PROBE_LEVELS; m++)
10805
+ for (let f = 0; f < 6; f++) run(face(this.probeTexture, m, f), cap.filterPipeline, cap.filterGroups[m * 6 + f])
10806
+ this.device.queue.submit([encoder.finish()])
10807
+
10808
+ this.probeData.set([eye[0], eye[1], eye[2], 1, min[0], min[1], min[2], 1, max[0], max[1], max[2], 0])
10809
+ this.device.queue.writeBuffer(this.probeUniformBuffer, 0, this.probeData)
10810
+ }
10811
+
10812
+ /**
10813
+ * The sky's mips, convolved with the GGX lobe each stands for — the box
10814
+ * average the upload built is only the source. Level 0 is copied as it is
10815
+ * (it is also what the backdrop draws); every level below it becomes the
10816
+ * sky as a surface of that roughness reflects it (skyLevelRoughness).
10817
+ */
10818
+ private prefilterSky(source: GPUTexture, width: number, height: number, levels: number): GPUTexture {
10819
+ if (levels <= 1) return source
10820
+ if (!this.skyPrefilterPipeline) {
10821
+ const module = this.device.createShaderModule({ label: "sky prefilter", code: SKY_PREFILTER_WGSL })
10822
+ this.skyPrefilterPipeline = this.device.createRenderPipeline({
10823
+ label: "sky prefilter",
10824
+ layout: "auto",
10825
+ vertex: { module, entryPoint: "vs_full" },
10826
+ fragment: { module, entryPoint: "fs_sky", targets: [{ format: "rgba16float" }] },
10827
+ primitive: { topology: "triangle-list" },
10828
+ })
10829
+ }
10830
+ const pipeline = this.skyPrefilterPipeline
10831
+ const out = this.device.createTexture({
10832
+ label: "world equirect (HDR, GGX-prefiltered)",
10833
+ size: [width, height],
10834
+ format: "rgba16float",
10835
+ mipLevelCount: levels,
10836
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
10837
+ })
10838
+ const encoder = this.device.createCommandEncoder({ label: "sky prefilter" })
10839
+ encoder.copyTextureToTexture({ texture: source, mipLevel: 0 }, { texture: out, mipLevel: 0 }, [width, height])
10840
+ const params = this.device.createBuffer({
10841
+ label: "sky prefilter steps",
10842
+ size: levels * 256,
10843
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
10844
+ })
10845
+ const data = new Float32Array(levels * 64)
10846
+ for (let m = 1; m < levels; m++) data.set([skyLevelRoughness(m, levels), width, height, 0], m * 64)
10847
+ this.device.queue.writeBuffer(params, 0, data)
10848
+ const srcView = source.createView()
10849
+ for (let m = 1; m < levels; m++) {
10850
+ const group = this.device.createBindGroup({
10851
+ label: `sky prefilter ${m}`,
10852
+ layout: pipeline.getBindGroupLayout(0),
10853
+ entries: [
10854
+ { binding: 0, resource: srcView },
10855
+ { binding: 1, resource: this.materialSampler },
10856
+ { binding: 2, resource: { buffer: params, offset: m * 256, size: 16 } },
10857
+ ],
10858
+ })
10859
+ const p = encoder.beginRenderPass({
10860
+ label: `sky prefilter ${m}`,
10861
+ colorAttachments: [
10862
+ { view: out.createView({ baseMipLevel: m, mipLevelCount: 1 }), loadOp: "clear", storeOp: "store" },
10863
+ ],
10864
+ })
10865
+ p.setPipeline(pipeline)
10866
+ p.setBindGroup(0, group)
10867
+ p.draw(3)
10868
+ p.end()
10869
+ }
10870
+ this.device.queue.submit([encoder.finish()])
10871
+ // The box chain and the steps are done with once that work is: the source
10872
+ // retires with the skies (destroyed at the next frame's start).
10873
+ this.retiredSkies.push(source)
10874
+ void this.device.queue.onSubmittedWorkDone().then(() => params.destroy())
10875
+ return out
10876
+ }
10877
+
10394
10878
  /** Returns whether it actually rebuilt — see rebuildCompositeBindGroup. */
10395
10879
  private rebuildPerFrameBindGroups(): boolean {
10396
10880
  // EVERY resource, not just the layout. This runs twice: once at init, where
@@ -10409,7 +10893,7 @@ export class Engine {
10409
10893
  !this.shadowLightVPBuffer ||
10410
10894
  !this.materialSampler ||
10411
10895
  !this.shadowComparisonSampler ||
10412
- !this.brdfLutView ||
10896
+ !this.probeUniformBuffer ||
10413
10897
  !(this.worldEquirectView ?? this.fallbackEquirectView)
10414
10898
  ) {
10415
10899
  return false
@@ -10432,8 +10916,31 @@ export class Engine {
10432
10916
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
10433
10917
  { binding: 6, resource: { buffer: this.lightsBuffer } },
10434
10918
  { binding: 8, resource: env },
10435
- { binding: 9, resource: this.brdfLutView },
10919
+ { binding: 9, resource: this.cookieView },
10436
10920
  { binding: 10, resource: this.castShadowView },
10921
+ { binding: 11, resource: this.probeView },
10922
+ { binding: 12, resource: { buffer: this.probeUniformBuffer } },
10923
+ ],
10924
+ })
10925
+ // The probe's own view of the scene: one cube face at a time through its
10926
+ // capture camera, and the probe itself switched off — a material inside
10927
+ // the capture reflects the sky, never the cube being filled.
10928
+ this.probeCaptureBindGroup = this.device.createBindGroup({
10929
+ label: "reflection probe capture per-frame bind group",
10930
+ layout: this.mainPerFrameBindGroupLayout,
10931
+ entries: [
10932
+ { binding: 0, resource: { buffer: this.probeCameraBuffer } },
10933
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
10934
+ { binding: 2, resource: this.materialSampler },
10935
+ { binding: 3, resource: this.shadowAtlasView },
10936
+ { binding: 4, resource: this.shadowComparisonSampler },
10937
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
10938
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
10939
+ { binding: 8, resource: env },
10940
+ { binding: 9, resource: this.cookieView },
10941
+ { binding: 10, resource: this.castShadowView },
10942
+ { binding: 11, resource: this.probeFallbackView },
10943
+ { binding: 12, resource: { buffer: this.probeOffBuffer } },
10437
10944
  ],
10438
10945
  })
10439
10946
  this.mirrorPerFrameBindGroup = this.device.createBindGroup({
@@ -10448,8 +10955,10 @@ export class Engine {
10448
10955
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
10449
10956
  { binding: 6, resource: { buffer: this.lightsBuffer } },
10450
10957
  { binding: 8, resource: env },
10451
- { binding: 9, resource: this.brdfLutView },
10958
+ { binding: 9, resource: this.cookieView },
10452
10959
  { binding: 10, resource: this.castShadowView },
10960
+ { binding: 11, resource: this.probeView },
10961
+ { binding: 12, resource: { buffer: this.probeUniformBuffer } },
10453
10962
  ],
10454
10963
  })
10455
10964
  return true
@@ -10511,13 +11020,13 @@ export class Engine {
10511
11020
  this.lightData[base + 4] = this.sun.color.x
10512
11021
  this.lightData[base + 5] = this.sun.color.y
10513
11022
  this.lightData[base + 6] = this.sun.color.z
11023
+ // Unity's light intensity: a white sun of 1 lights albedo 1:1 at N·L = 1.
10514
11024
  this.lightData[base + 7] = this.sun.strength
10515
11025
  this.lightDataWords[DIR_LAYERS_AT + index] = this.sunLayers
10516
11026
  if (index >= this.lightCount) this.lightCount = index + 1
10517
11027
  this.updateLightBuffer()
10518
11028
  }
10519
11029
 
10520
- /** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
10521
11030
  /**
10522
11031
  * Distance fog on every lit surface, as a game on SimPipeline lays it: per
10523
11032
  * layer, t = saturate(f − (1 − f)·h) with f = saturate(depth·distance[0] +
@@ -10642,7 +11151,7 @@ export class Engine {
10642
11151
  this.writeWorld()
10643
11152
  }
10644
11153
 
10645
- /** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
11154
+ /** Update the main light (Unity's directional Sun). Direction change marks shadow VP dirty. */
10646
11155
  setSun(options: SunOptions): void {
10647
11156
  if (options.color) this.sun.color = options.color
10648
11157
  if (options.strength !== undefined) this.sun.strength = options.strength
@@ -10730,6 +11239,7 @@ export class Engine {
10730
11239
  ...options,
10731
11240
  }
10732
11241
  this.groundY = opts.y
11242
+ this.groundHalf = [opts.width / 2, opts.height / 2]
10733
11243
  this.createGroundGeometry(opts.width, opts.height, opts.y)
10734
11244
  this.createShadowGroundResources(opts)
10735
11245
  this.hasGround = true
@@ -10782,6 +11292,7 @@ export class Engine {
10782
11292
  * literal typed into a console. Vec3 satisfies it either way. */
10783
11293
  lights: SceneLight[] | null,
10784
11294
  ): void {
11295
+ this.sceneLights = lights
10785
11296
  // The directional ones go to the light uniform's slots beside the sun's —
10786
11297
  // three of them, in order; the rest are records the grid indexes.
10787
11298
  const directional = (lights ?? []).filter((l) => l.kind === "directional")
@@ -10841,8 +11352,12 @@ export class Engine {
10841
11352
  this.lightsData[b + 12] = Math.max(inner, outer)
10842
11353
  // The layers it does NOT reach, as bits — inverted so a zero reaches all.
10843
11354
  this.lightsWords[b + 13] = ~(l.layers ?? ALL_LAYERS) >>> 0
10844
- this.lightsData[b + 14] = 0
10845
- this.lightsData[b + 15] = 0
11355
+ // The bulb's radius (rzLightNear): 0 keeps the engine's default
11356
+ this.lightsData[b + 14] = l.near !== undefined && Number.isFinite(l.near) && l.near > 0 ? l.near : 0
11357
+ // Its cookie's layer + 1, 0 for none or one not loaded yet (a load
11358
+ // rewrites the lamps when it lands).
11359
+ const cookie = l.cookie !== undefined ? this.cookieLayers.get(l.cookie) : undefined
11360
+ this.lightsData[b + 15] = cookie !== undefined ? cookie + 1 : 0
10846
11361
  }
10847
11362
  // The grid, from the records as STORED — normalised aim, clamped reach, the
10848
11363
  // cosines in f32 — so it indexes exactly what the shader will evaluate.
@@ -10881,6 +11396,58 @@ export class Engine {
10881
11396
  this.allocateLightSlots()
10882
11397
  }
10883
11398
 
11399
+ /**
11400
+ * A cookie, under `key`, for every spot lamp that names it (SceneLight.cookie):
11401
+ * the picture a stage light throws through its gobo, projected along the
11402
+ * cone — its RGB is the light let through, so black blocks, white passes and
11403
+ * a colour tints. Square, stretched to 512². Up to 16 at once; loading a key
11404
+ * again replaces its picture. False when every layer is taken.
11405
+ */
11406
+ async loadLightCookie(key: string, source: Blob | ImageBitmap): Promise<boolean> {
11407
+ if (!this.device) return false
11408
+ let layer = this.cookieLayers.get(key)
11409
+ if (layer === undefined) {
11410
+ const used = new Set(this.cookieLayers.values())
11411
+ for (let k = 0; k < Engine.COOKIE_LAYERS && layer === undefined; k++) if (!used.has(k)) layer = k
11412
+ if (layer === undefined) {
11413
+ console.warn(`[reze] light cookie "${key}": all ${Engine.COOKIE_LAYERS} cookie layers are in use`)
11414
+ return false
11415
+ }
11416
+ }
11417
+ const size = Engine.COOKIE_SIZE
11418
+ const bmp = await createImageBitmap(source, {
11419
+ resizeWidth: size,
11420
+ resizeHeight: size,
11421
+ resizeQuality: "high",
11422
+ colorSpaceConversion: "none",
11423
+ premultiplyAlpha: "none",
11424
+ })
11425
+ // The real array on the first cookie, and every group that binds the
11426
+ // stand-in rebuilt against it (the stand-in itself is a 1x1 and stays).
11427
+ if (this.cookieTexture.width === 1) {
11428
+ this.cookieTexture = this.device.createTexture({
11429
+ label: "light cookies",
11430
+ size: [size, size, Engine.COOKIE_LAYERS],
11431
+ format: "rgba8unorm",
11432
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
11433
+ })
11434
+ this.cookieView = this.cookieTexture.createView({ dimension: "2d-array" })
11435
+ this.worldBindingsDirty = true
11436
+ this.buildGroundBindGroup()
11437
+ }
11438
+ this.device.queue.copyExternalImageToTexture({ source: bmp }, { texture: this.cookieTexture, origin: [0, 0, layer] }, [size, size, 1])
11439
+ bmp.close()
11440
+ this.cookieLayers.set(key, layer)
11441
+ if (this.sceneLights) this.setLights(this.sceneLights)
11442
+ return true
11443
+ }
11444
+
11445
+ /** Forget a cookie: lamps naming it light plainly again. */
11446
+ removeLightCookie(key: string): void {
11447
+ if (!this.cookieLayers.delete(key)) return
11448
+ if (this.sceneLights) this.setLights(this.sceneLights)
11449
+ }
11450
+
10884
11451
  /** How many positional lights the scene is carrying. */
10885
11452
  getLightCount(): number {
10886
11453
  return this.lightHeader[0]
@@ -13171,8 +13738,12 @@ export class Engine {
13171
13738
  const d = positions[i] * positions[i] + positions[i + 1] * positions[i + 1] + positions[i + 2] * positions[i + 2]
13172
13739
  if (d > most) most = d
13173
13740
  }
13174
- const extent = Math.sqrt(most)
13175
- if (extent <= this.sceneExtent) return
13741
+ this.raiseSceneExtent(Math.sqrt(most))
13742
+ }
13743
+
13744
+ /** Raise the scene's reach (never lower it) and hand it to the camera. */
13745
+ private raiseSceneExtent(extent: number): void {
13746
+ if (!(extent > this.sceneExtent)) return
13176
13747
  this.sceneExtent = extent
13177
13748
  this.camera?.setSceneExtent(extent)
13178
13749
  }
@@ -13253,6 +13824,16 @@ export class Engine {
13253
13824
  minZ = Math.min(minZ, data[o + 18] - r); maxZ = Math.max(maxZ, data[o + 18] + r)
13254
13825
  }
13255
13826
  }
13827
+ // The built-in floor, while it is drawn. It is no draw of the cull table,
13828
+ // so without this the box ended at the cast: the cascades stopped just
13829
+ // past her, and a low sun's long shadow ran off the last of them — the
13830
+ // floor beyond read as lit and her head and shoulders went missing.
13831
+ if (this.hasGround && !this.groundIsSuppressed()) {
13832
+ const [hx, hz] = this.groundHalf
13833
+ minX = Math.min(minX, -hx); maxX = Math.max(maxX, hx)
13834
+ minY = Math.min(minY, this.groundY); maxY = Math.max(maxY, this.groundY)
13835
+ minZ = Math.min(minZ, -hz); maxZ = Math.max(maxZ, hz)
13836
+ }
13256
13837
  if (this.nativeStage) {
13257
13838
  const nb = this.nativeStage.bounds
13258
13839
  minX = Math.min(minX, nb.min[0]); maxX = Math.max(maxX, nb.max[0])
@@ -13557,6 +14138,10 @@ export class Engine {
13557
14138
  */
13558
14139
  private recordBundles(): void {
13559
14140
  this.bundlesDirty = false
14141
+ // What the bundles record is what the probe would see: a stage arriving or
14142
+ // leaving, a look installed on it, a pipeline landing. Re-captured once it
14143
+ // settles (and switched off when the stage is gone).
14144
+ this.captureReflectionProbe()
13560
14145
  const scene = {
13561
14146
  colorFormats: sceneColorFormats(this.sceneFormats),
13562
14147
  depthStencilFormat: this.depthFormat,
@@ -13820,10 +14405,17 @@ export class Engine {
13820
14405
  private issueDraw(
13821
14406
  pass: GPURenderPassEncoder | GPURenderBundleEncoder,
13822
14407
  draw: DrawCall,
13823
- kind: "camera" | "shadow" | "mirror",
14408
+ kind: "camera" | "shadow" | "mirror" | "probe",
13824
14409
  ): void {
14410
+ // The probe's cube faces have no cull of their own: drawn whole.
13825
14411
  const args =
13826
- kind === "shadow" ? this.cullShadowArgs : kind === "mirror" ? this.cullMirrorArgs : this.cullCameraArgs
14412
+ kind === "probe"
14413
+ ? null
14414
+ : kind === "shadow"
14415
+ ? this.cullShadowArgs
14416
+ : kind === "mirror"
14417
+ ? this.cullMirrorArgs
14418
+ : this.cullCameraArgs
13827
14419
  if (args && draw.cullIndex >= 0) {
13828
14420
  pass.drawIndexedIndirect(args, draw.cullIndex * Engine.CULL_ARG_WORDS * 4)
13829
14421
  } else {
@@ -14127,7 +14719,8 @@ export class Engine {
14127
14719
  entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
14128
14720
  })
14129
14721
 
14130
- const gpuBuffers: GPUBuffer[] = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer]
14722
+ const attributeBuffer = this.createAttributeBuffer(name, model)
14723
+ const gpuBuffers: GPUBuffer[] = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer, attributeBuffer]
14131
14724
 
14132
14725
  const gpuMorph = this.createGpuMorph(name, model, vertexBuffer, gpuBuffers)
14133
14726
 
@@ -14152,6 +14745,7 @@ export class Engine {
14152
14745
  indexBuffer,
14153
14746
  jointsBuffer,
14154
14747
  weightsBuffer,
14748
+ attributeBuffer,
14155
14749
  skinMatrixBuffer,
14156
14750
  wireEdges: new Map(),
14157
14751
  drawCalls: [],
@@ -14198,6 +14792,46 @@ export class Engine {
14198
14792
  this.updateOrderDirty = true
14199
14793
  }
14200
14794
 
14795
+ /**
14796
+ * A model's attribute stream: vertex colour (RGBA) and a second UV set, six
14797
+ * floats a vertex, read by the material pipelines at slot 4.
14798
+ *
14799
+ * PMX has no vertex colour and one UV, but it has four ADDITIONAL UV
14800
+ * channels of four floats each, and that is where a model that needs more
14801
+ * keeps it: the first additional UV is the vertex colour, the second's xy
14802
+ * the second UV — the convention a converted game stage is written in (a
14803
+ * plant's occlusion rides in its vertex alpha, a light card's mask in its
14804
+ * second UV). A model without them reads white and its own UV, which is
14805
+ * what every graph that reads neither already assumed.
14806
+ */
14807
+ private createAttributeBuffer(name: string, model: Model): GPUBuffer {
14808
+ const vertices = model.getVertices()
14809
+ const count = Math.floor(vertices.length / 8)
14810
+ const data = new Float32Array(count * Engine.ATTRIBUTE_FLOATS)
14811
+ const color = model.getAdditionalUv(1)
14812
+ const uv2 = model.getAdditionalUv(2)
14813
+ for (let i = 0; i < count; i++) {
14814
+ const o = i * Engine.ATTRIBUTE_FLOATS
14815
+ if (color) {
14816
+ data[o] = color[i * 4]
14817
+ data[o + 1] = color[i * 4 + 1]
14818
+ data[o + 2] = color[i * 4 + 2]
14819
+ data[o + 3] = color[i * 4 + 3]
14820
+ } else {
14821
+ data[o] = data[o + 1] = data[o + 2] = data[o + 3] = 1
14822
+ }
14823
+ data[o + 4] = uv2 ? uv2[i * 4] : vertices[i * 8 + 6]
14824
+ data[o + 5] = uv2 ? uv2[i * 4 + 1] : vertices[i * 8 + 7]
14825
+ }
14826
+ const buffer = this.device.createBuffer({
14827
+ label: `${name}: attribute buffer`,
14828
+ size: Math.max(data.byteLength, 4 * Engine.ATTRIBUTE_FLOATS),
14829
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
14830
+ })
14831
+ if (data.byteLength > 0) this.device.queue.writeBuffer(buffer, 0, data)
14832
+ return buffer
14833
+ }
14834
+
14201
14835
  // Build the per-model GPU vertex-morph state. Returns null (and leaves the model on the
14202
14836
  // CPU morph path) when the model has no vertex morphs. Created buffers are pushed into
14203
14837
  // gpuBuffers so they're released with the instance.
@@ -14530,6 +15164,7 @@ export class Engine {
14530
15164
  { binding: 12, resource: this.groundNoiseView },
14531
15165
  { binding: 13, resource: this.mirrorMaskView! },
14532
15166
  { binding: 14, resource: { buffer: this.groundClipOffBuffer } },
15167
+ { binding: 15, resource: this.cookieView },
14533
15168
  ],
14534
15169
  })
14535
15170
  this.groundMirrorViewBindGroup = this.device.createBindGroup({
@@ -14552,6 +15187,7 @@ export class Engine {
14552
15187
  { binding: 12, resource: this.groundNoiseView },
14553
15188
  { binding: 13, resource: this.mirrorDummyColorView! },
14554
15189
  { binding: 14, resource: { buffer: this.groundClipMirrorBuffer } },
15190
+ { binding: 15, resource: this.cookieView },
14555
15191
  ],
14556
15192
  })
14557
15193
  if (this.groundDrawCall) this.groundDrawCall.bindGroup = this.groundShadowBindGroup
@@ -15854,38 +16490,6 @@ export class Engine {
15854
16490
  }
15855
16491
 
15856
16492
  // 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
16493
  private buildMouseRay(px: number, py: number): { origin: Vec3; dir: Vec3 } | null {
15890
16494
  if (!this.camera) return null
15891
16495
  const width = this.canvas.clientWidth
@@ -16478,6 +17082,10 @@ export class Engine {
16478
17082
  idAtt.storeOp = idsRead ? "store" : "discard"
16479
17083
  }
16480
17084
 
17085
+ // The stage's reflection probe, when one is due: its own submits, ahead of
17086
+ // this frame's, so every material below reads the fresh cube.
17087
+ if (hasModels) this.maybeCaptureProbe()
17088
+
16481
17089
  const encoder = this.device.createCommandEncoder()
16482
17090
 
16483
17091
  // GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
@@ -17481,7 +18089,7 @@ export class Engine {
17481
18089
  const base = {
17482
18090
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
17483
18091
  layout: this.mainPipelineLayout,
17484
- vertex: { module, buffers: this.fullVertexBufferLayouts },
18092
+ vertex: { module, buffers: this.materialVertexBufferLayouts },
17485
18093
  // The eye front-culls — the see-through-hair look is draw order plus that
17486
18094
  // cull plus the stencil stamp, and it is the deployed appearance of every
17487
18095
  // published scene. In the MIRROR the winding is flipped (determinant -1,
@@ -17538,7 +18146,16 @@ export class Engine {
17538
18146
  }
17539
18147
  const desc: GPURenderPipelineDescriptor = {
17540
18148
  ...base,
17541
- fragment: { module, constants, targets: blend === "additive" ? this.sceneTargetsAdditive : this.sceneTargets },
18149
+ fragment: {
18150
+ module,
18151
+ constants,
18152
+ targets:
18153
+ blend === "additive"
18154
+ ? this.sceneTargetsAdditive
18155
+ : blend === "premultiplied"
18156
+ ? this.sceneTargetsPremultiplied
18157
+ : this.sceneTargets,
18158
+ },
17542
18159
  depthStencil,
17543
18160
  }
17544
18161
  return this.cachedRenderPipeline(desc).then((pipeline) => {
@@ -17786,6 +18403,46 @@ export class Engine {
17786
18403
  * makes outlines compose like MMD: every material drawn later in the author's
17787
18404
  * order covers earlier hulls, and each hull sits over everything drawn before it.
17788
18405
  */
18406
+ /**
18407
+ * A stage's draws, far to near from the eye: its transparent queue, in the
18408
+ * order a game sorts one. Each draw's distance is its material box's centre,
18409
+ * placed, against the camera — a pane or a water surface is one material,
18410
+ * which is the unit a game sorts too. The array is kept per instance and
18411
+ * re-sorted in place, so an unchanged order costs a comparison per draw.
18412
+ */
18413
+ private backToFront(inst: ModelInstance): DrawCall[] {
18414
+ let list = this.sortedDraws.get(inst)
18415
+ if (!list || list.length !== inst.drawCalls.length || list.some((d) => !inst.drawCalls.includes(d))) {
18416
+ list = [...inst.drawCalls]
18417
+ this.sortedDraws.set(inst, list)
18418
+ }
18419
+ const m = inst.model
18420
+ const ex = this.cameraMatrixData[32]
18421
+ const ey = this.cameraMatrixData[33]
18422
+ const ez = this.cameraMatrixData[34]
18423
+ const p = this.sortScratch
18424
+ const dist = this.sortDistance
18425
+ dist.clear()
18426
+ for (const d of list) {
18427
+ const b = d.bounds
18428
+ if (!b || b.length < 6 || !(b[0] <= b[3])) {
18429
+ dist.set(d, 0)
18430
+ continue
18431
+ }
18432
+ p.setXYZ(((b[0] + b[3]) * 0.5) * m.scale, ((b[1] + b[4]) * 0.5) * m.scale, ((b[2] + b[5]) * 0.5) * m.scale)
18433
+ Quat.rotateVecInto(m.rotation, p, p)
18434
+ const dx = p.x + m.position.x - ex
18435
+ const dy = p.y + m.position.y - ey
18436
+ const dz = p.z + m.position.z - ez
18437
+ dist.set(d, dx * dx + dy * dy + dz * dz)
18438
+ }
18439
+ list.sort((a, b) => dist.get(b)! - dist.get(a)!)
18440
+ return list
18441
+ }
18442
+ private sortedDraws = new WeakMap<ModelInstance, DrawCall[]>()
18443
+ private sortScratch = new Vec3(0, 0, 0)
18444
+ private sortDistance = new Map<DrawCall, number>()
18445
+
17789
18446
  /** Is this draw's compiled class "hair"? Ungrouped draws never are — the
17790
18447
  * neutral pipeline is the auto class. */
17791
18448
  private isHairDraw(inst: ModelInstance, dc: DrawCall): boolean {
@@ -17798,7 +18455,7 @@ export class Engine {
17798
18455
  pass: GPURenderPassEncoder | GPURenderBundleEncoder,
17799
18456
  inst: ModelInstance,
17800
18457
  type: "opaque" | "transparent",
17801
- view: { perFrame: GPUBindGroup; args: "camera" | "mirror"; outlines: boolean },
18458
+ view: { perFrame: GPUBindGroup; args: "camera" | "mirror" | "probe"; outlines: boolean },
17802
18459
  // The opaque phase walks its author order twice — non-hair, then hair — so
17803
18460
  // the hair depth prime can sit between the eye's stencil write and the hair
17804
18461
  // colour that must respect it. See renderModelOpaquePhase.
@@ -17806,9 +18463,17 @@ export class Engine {
17806
18463
  ): void {
17807
18464
  let currentPipeline: GPURenderPipeline | null = null
17808
18465
  let bound = false
17809
- for (const draw of inst.drawCalls) {
18466
+ const outlined: DrawCall[] = []
18467
+ // A stage's glass and water in the camera pass go far to near, as a
18468
+ // game's transparent queue does: author order put a far pane over a near
18469
+ // one wherever the author listed it last. Characters keep MMD's author
18470
+ // order — their layers are fabric, built around it.
18471
+ const draws = type === "transparent" && inst.isStage && view.args === "camera" ? this.backToFront(inst) : inst.drawCalls
18472
+ for (const draw of draws) {
17810
18473
  if (draw.type !== type) continue
17811
18474
  if (only && (only === "hair") !== this.isHairDraw(inst, draw)) continue
18475
+ // The probe draws directly, past the cull: honour the switches it holds.
18476
+ if (view.args === "probe" && !this.shouldRenderDrawCall(inst, draw)) continue
17812
18477
  if (!bound) {
17813
18478
  pass.setBindGroup(0, view.perFrame)
17814
18479
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
@@ -17822,35 +18487,42 @@ export class Engine {
17822
18487
  }
17823
18488
  pass.setBindGroup(2, draw.bindGroup)
17824
18489
  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)
18490
+ if (draw.outline && this.outlineEnabled && view.outlines) outlined.push(draw)
18491
+ }
18492
+ // THE HULLS AFTER THE SURFACES, as MMD draws a model's edges. A hull held
18493
+ // at the minimum width is faded, and it still writes depth: drawn between
18494
+ // two materials it hid the second wherever it stood in front of it — a
18495
+ // shoe's sole's hull cut the upper behind it, and the floor showed through
18496
+ // as pale lines along the seam. After every surface of this phase it only
18497
+ // ever blends over finished ones. Same index ranges; own pipeline and
18498
+ // groups 0/2 — group 1 (skinMats) is layout-identical and stays bound.
18499
+ if (outlined.length) {
18500
+ const mirrored = view.args === "mirror"
18501
+ pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline)
18502
+ pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup)
18503
+ // Slot 3: the hull's smoothed normals + edge scale. Only the outline
18504
+ // pipeline reads it; the others leave the slot alone.
18505
+ if (inst.outlineVertexBuffer) pass.setVertexBuffer(3, inst.outlineVertexBuffer)
18506
+ for (const draw of outlined) {
18507
+ pass.setBindGroup(2, draw.outline!.bindGroup)
17836
18508
  this.issueDraw(pass, draw, view.args)
17837
- pass.setBindGroup(0, view.perFrame)
17838
- currentPipeline = null
17839
18509
  }
18510
+ pass.setBindGroup(0, view.perFrame)
17840
18511
  }
17841
18512
  }
17842
18513
 
17843
18514
  /**
17844
18515
  * Main-pass render sequence for one model instance — babylon-mmd parity:
17845
18516
  * 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.
18517
+ * in PMX author order with depth write ON (forceDepthWrite). Outlines close
18518
+ * each drawMaterials call: the edge-flagged materials' hulls after all of its
18519
+ * surfaces, as MMD draws a model's edges.
17849
18520
  */
17850
18521
  private setModelDrawState(pass: GPURenderPassEncoder | GPURenderBundleEncoder, inst: ModelInstance): void {
17851
18522
  pass.setVertexBuffer(0, inst.vertexBuffer)
17852
18523
  pass.setVertexBuffer(1, inst.jointsBuffer)
17853
18524
  pass.setVertexBuffer(2, inst.weightsBuffer)
18525
+ pass.setVertexBuffer(4, inst.attributeBuffer)
17854
18526
  pass.setIndexBuffer(inst.indexBuffer, "uint32")
17855
18527
  // The stencil reference used to be set here. It is pass state, not bundle
17856
18528
  // state — GPURenderBundleEncoder has no setStencilReference at all — so it
@@ -18217,7 +18889,7 @@ export class Engine {
18217
18889
  // still pattern welded to the picture. On the SCENE clock like everything
18218
18890
  // else here, so an export reproduces the editor exactly rather than
18219
18891
  // scattering differently at whatever rate the encoder ran.
18220
- u[3] = this.grain.animated ? Math.floor((this.sceneClock * 24) % 1024) : 0
18892
+ u[3] = Math.floor((this.sceneClock * 24) % 1024)
18221
18893
  u[26] = this.canvas.width
18222
18894
  u[27] = this.canvas.height
18223
18895
  // Camera world position (viewU[10]) — the other half of bgWorldPos. It
@@ -18451,7 +19123,10 @@ export class Engine {
18451
19123
  }
18452
19124
  for (let s = 0; s < anchors.length; s++) {
18453
19125
  const a = CAST_SUBJECT_VEC4S * 4 + (s * MAX_EFFECT_SUBJECTS + n) * 12
18454
- const pos = m.getBoneWorldPosition(anchors[s].bone)
19126
+ // `along`: the point that far down the bone's own axis, still model space
19127
+ // here, so the placement below turns and scales it like the joint.
19128
+ const along = anchors[s].along
19129
+ const pos = along ? m.getBonePointAlong(anchors[s].bone, along) : m.getBoneWorldPosition(anchors[s].bone)
18455
19130
  if (!pos) {
18456
19131
  cd[a + 3] = 0
18457
19132
  continue
@@ -18473,7 +19148,7 @@ export class Engine {
18473
19148
  cd[a + 4] = vx
18474
19149
  cd[a + 5] = vy
18475
19150
  cd[a + 6] = vz
18476
- if (anchors[s].trail) this.writeTrail(inst.name, s, n, pos, cd, a)
19151
+ if (anchors[s].trail) this.writeTrail(inst.name, s, n, pos, cd, a, anchors[s].step)
18477
19152
  const fwd = m.getBoneWorldForward(anchors[s].bone)
18478
19153
  if (fwd) {
18479
19154
  Quat.rotateVecInto(m.rotation, fwd, fwd)
@@ -18515,7 +19190,7 @@ export class Engine {
18515
19190
  this.castData.fill(0, CAST_SUBJECT_VEC4S * 4)
18516
19191
  }
18517
19192
 
18518
- private writeTrail(model: string, slot: number, n: number, pos: Vec3, cd: Float32Array, anchorBase: number): void {
19193
+ private writeTrail(model: string, slot: number, n: number, pos: Vec3, cd: Float32Array, anchorBase: number, step?: number): void {
18519
19194
  const key = `${model}\u0000${slot}`
18520
19195
  let ring = this.anchorTrail.get(key)
18521
19196
  if (!ring) {
@@ -18536,9 +19211,44 @@ export class Engine {
18536
19211
  if (Math.hypot(dx, dy, dz) / dt > 50) {
18537
19212
  ring.pos.length = 0
18538
19213
  ring.t.length = 0
19214
+ ring.live = false
18539
19215
  }
18540
19216
  }
18541
- if (this.trailDue > 0 || ring.pos.length === 0) {
19217
+ if (step && ring.pos.length > 0) {
19218
+ // `trail step d` — the path a game's trail keeps (Unity's
19219
+ // minVertexDistance). Point 0 is the LIVE head: it follows the bone every
19220
+ // frame. It is left behind as a kept point only on a tick, and only once
19221
+ // the bone is `step` from the last point kept, so no segment is shorter
19222
+ // than that and a slowing hand cannot leave a knot of jittery stubs for a
19223
+ // wide ribbon to fan across. Still at most one point per tick, so a stepped
19224
+ // ring never covers LESS time than a sampled one.
19225
+ const live = ring.live === true
19226
+ const k = live ? 3 : 0
19227
+ const far = Math.hypot(pos.x - ring.pos[k], pos.y - ring.pos[k + 1], pos.z - ring.pos[k + 2]) >= step
19228
+ if (live && !(far && this.trailDue > 0)) {
19229
+ ring.pos[0] = pos.x
19230
+ ring.pos[1] = pos.y
19231
+ ring.pos[2] = pos.z
19232
+ ring.t[0] = this.sceneClock
19233
+ } else {
19234
+ // keep the head where it is as a point (live) or start a new head
19235
+ if (live) {
19236
+ ring.pos[0] = pos.x
19237
+ ring.pos[1] = pos.y
19238
+ ring.pos[2] = pos.z
19239
+ ring.t[0] = this.sceneClock
19240
+ ring.live = false
19241
+ } else {
19242
+ ring.pos.unshift(pos.x, pos.y, pos.z)
19243
+ ring.t.unshift(this.sceneClock)
19244
+ ring.live = !(far && this.trailDue > 0)
19245
+ }
19246
+ if (ring.t.length > TRAIL_SAMPLES) {
19247
+ ring.t.length = TRAIL_SAMPLES
19248
+ ring.pos.length = TRAIL_SAMPLES * 3
19249
+ }
19250
+ }
19251
+ } else if (this.trailDue > 0 || ring.pos.length === 0) {
18542
19252
  // ONE sample per frame, never one per due tick. A frame that spanned
18543
19253
  // several 60Hz ticks only knows where the bone is NOW, and unshifting that
18544
19254
  // position once per tick fabricated duplicate samples — same point, same
@@ -18622,6 +19332,9 @@ export class Engine {
18622
19332
  headSkin: () => (head >= 0 ? inst.model.getSkinMatrices().subarray(head * 16, head * 16 + 16) : null),
18623
19333
  headRest,
18624
19334
  layers: new Uint32Array(inst.objectLight.buffer)[0],
19335
+ additionalUv: (channel) => inst.model.getAdditionalUv(channel),
19336
+ morphs: inst.model.getMorphing().morphs,
19337
+ morphWeights: () => inst.model.getEffectiveMorphWeights(),
18625
19338
  },
18626
19339
  look,
18627
19340
  )
@@ -18636,6 +19349,20 @@ export class Engine {
18636
19349
  return this.nativeLooks?.ready(name) ?? Promise.resolve()
18637
19350
  }
18638
19351
 
19352
+ /**
19353
+ * Set some of the values of one material a model's native look dresses —
19354
+ * its Unity material properties by name, colours linear, as the look's
19355
+ * `values` hold them — over the look's own, until set again. What a game
19356
+ * animates on a material over a take (its timeline's material curves)
19357
+ * reaches the shaders this way, one frame at a time: the caller samples its
19358
+ * keys on its own clock and sets what it got. Only the names the material's
19359
+ * look carries are taken, and a value equal to the one in place writes
19360
+ * nothing. False when the model wears no look dressing that material.
19361
+ */
19362
+ setModelNativeUniforms(name: string, material: string, values: Record<string, number | ArrayLike<number>>): boolean {
19363
+ return this.nativeLooks?.setUniforms(name, material, values) ?? false
19364
+ }
19365
+
18639
19366
  /** The hosts for the game's shaders: the scene pass's and the shadow atlas's,
18640
19367
  * with the stand-ins an empty slot reads. Built on first use. */
18641
19368
  private ensureNativeHosts(): NativeHost {
@@ -18682,6 +19409,18 @@ export class Engine {
18682
19409
  white,
18683
19410
  black,
18684
19411
  blackCube: solid("native: black cube", [0, 0, 0, 255], true),
19412
+ // an empty volume (a froxel buffer no pass filled): no in-scatter, no extinction
19413
+ emptyVolume: (() => {
19414
+ const t = d.createTexture({
19415
+ label: "native: empty volume",
19416
+ size: [1, 1, 1],
19417
+ dimension: "3d",
19418
+ format: "rgba8unorm",
19419
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
19420
+ })
19421
+ d.queue.writeTexture({ texture: t }, new Uint8Array([0, 0, 0, 0]), { bytesPerRow: 4, rowsPerImage: 1 }, [1, 1, 1])
19422
+ return t.createView({ dimension: "3d" })
19423
+ })(),
18685
19424
  depth: this.nativeNoShadowView,
18686
19425
  comparison: this.shadowComparisonSampler,
18687
19426
  }
@@ -18720,7 +19459,8 @@ export class Engine {
18720
19459
  { mipmaps: (t, levels) => this.generateMipmaps(t, levels), fallback: this.nativeFallback },
18721
19460
  onProgress,
18722
19461
  )
18723
-
19462
+ // The stage's sky is in it: the far plane reaches as far as the stage does.
19463
+ this.raiseSceneExtent(this.nativeStage.extent)
18724
19464
  }
18725
19465
 
18726
19466
  /** What went wrong in the game's shaders, and the names nothing supplied. */
@@ -18770,11 +19510,17 @@ export class Engine {
18770
19510
  let sunDirection: [number, number, number] = [dir.x, dir.y, dir.z]
18771
19511
  let sunColor: [number, number, number] = [sun.color.x * k, sun.color.y * k, sun.color.z * k]
18772
19512
  let sunShadow = this.sunShadow
19513
+ let sunUnity: NativeStageLights["main"]["unity"] | null = null
18773
19514
  if (stage) {
18774
19515
  // 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).
19516
+ // host sets from the stage (and a person may then move) - along the
19517
+ // scene sun's direction, in the stage's own main-light colour. The scene
19518
+ // sun's colour is the cast's key, which the game keeps apart (a
19519
+ // character's light is _ProbeLightingBase, not the stage's main light).
18776
19520
  const L = stage.pkg.lights
18777
19521
  const sc = stage.pkg.scale
19522
+ sunColor = [L.main.color[0], L.main.color[1], L.main.color[2]]
19523
+ sunUnity = L.main.unity ?? null
18778
19524
  lights.length = 0
18779
19525
  for (const l of L.additional) {
18780
19526
  lights.push({
@@ -18804,6 +19550,7 @@ export class Engine {
18804
19550
  dt: 1 / 60,
18805
19551
  sunDirection,
18806
19552
  sunColor,
19553
+ sunUnity,
18807
19554
  sunShadow,
18808
19555
  lights,
18809
19556
  ambientSH: sh ? Array.from(sh, (v) => v * s) : null,
@@ -18917,17 +19664,3 @@ function stableGlobals(prev: Record<string, NativeValue>, next: Record<string, N
18917
19664
  }
18918
19665
  return next
18919
19666
  }
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
- }