reze-engine 0.65.0 → 0.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/dist/engine.d.ts +156 -42
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +876 -186
  4. package/dist/graph/compile.d.ts.map +1 -1
  5. package/dist/graph/compile.js +5 -0
  6. package/dist/graph/presets/body.d.ts.map +1 -1
  7. package/dist/graph/presets/body.js +26 -24
  8. package/dist/graph/presets/cloth_rough.js +8 -8
  9. package/dist/graph/presets/cloth_smooth.js +8 -8
  10. package/dist/graph/presets/default.js +4 -4
  11. package/dist/graph/presets/eye.js +10 -10
  12. package/dist/graph/presets/face.d.ts.map +1 -1
  13. package/dist/graph/presets/face.js +28 -26
  14. package/dist/graph/presets/hair.js +6 -6
  15. package/dist/graph/presets/metal.js +8 -8
  16. package/dist/graph/presets/stockings.d.ts.map +1 -1
  17. package/dist/graph/presets/stockings.js +4 -11
  18. package/dist/graph/presets/unlit.d.ts.map +1 -1
  19. package/dist/graph/presets/unlit.js +9 -13
  20. package/dist/graph/registry.d.ts.map +1 -1
  21. package/dist/graph/registry.js +128 -157
  22. package/dist/graph/render-class.d.ts +5 -2
  23. package/dist/graph/render-class.d.ts.map +1 -1
  24. package/dist/graph/slots.d.ts.map +1 -1
  25. package/dist/graph/slots.js +5 -1
  26. package/dist/model.d.ts +14 -1
  27. package/dist/model.d.ts.map +1 -1
  28. package/dist/model.js +27 -1
  29. package/dist/pmx-loader.d.ts +4 -0
  30. package/dist/pmx-loader.d.ts.map +1 -1
  31. package/dist/pmx-loader.js +11 -2
  32. package/dist/shaders/anchor-table.d.ts +11 -0
  33. package/dist/shaders/anchor-table.d.ts.map +1 -1
  34. package/dist/shaders/anchor-table.js +9 -3
  35. package/dist/shaders/directives.d.ts +42 -5
  36. package/dist/shaders/directives.d.ts.map +1 -1
  37. package/dist/shaders/directives.js +51 -4
  38. package/dist/shaders/lights.d.ts +11 -7
  39. package/dist/shaders/lights.d.ts.map +1 -1
  40. package/dist/shaders/lights.js +315 -271
  41. package/dist/shaders/materials/common.d.ts.map +1 -1
  42. package/dist/shaders/materials/common.js +26 -3
  43. package/dist/shaders/materials/nodes.d.ts +1 -1
  44. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  45. package/dist/shaders/materials/nodes.js +191 -271
  46. package/dist/shaders/passes/composite.d.ts +1 -1
  47. package/dist/shaders/passes/composite.d.ts.map +1 -1
  48. package/dist/shaders/passes/composite.js +3 -5
  49. package/dist/shaders/passes/ground.js +1 -1
  50. package/dist/shaders/passes/particles.d.ts.map +1 -1
  51. package/dist/shaders/passes/particles.js +9 -3
  52. package/dist/shaders/passes/probe.d.ts +45 -0
  53. package/dist/shaders/passes/probe.d.ts.map +1 -0
  54. package/dist/shaders/passes/probe.js +230 -0
  55. package/dist/shaders/passes/scene-contract.d.ts +13 -0
  56. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  57. package/dist/shaders/passes/scene-contract.js +4 -0
  58. package/dist/shaders/scene-light-api.d.ts.map +1 -1
  59. package/dist/shaders/scene-light-api.js +1 -0
  60. package/dist/unity/globals.d.ts +7 -0
  61. package/dist/unity/globals.d.ts.map +1 -1
  62. package/dist/unity/globals.js +4 -3
  63. package/dist/unity/host.d.ts +3 -0
  64. package/dist/unity/host.d.ts.map +1 -1
  65. package/dist/unity/host.js +41 -7
  66. package/dist/unity/looks.d.ts +42 -0
  67. package/dist/unity/looks.d.ts.map +1 -1
  68. package/dist/unity/looks.js +131 -4
  69. package/dist/unity/stage.d.ts +17 -0
  70. package/dist/unity/stage.d.ts.map +1 -1
  71. package/dist/unity/stage.js +22 -5
  72. package/package.json +1 -1
  73. package/src/engine.ts +949 -215
  74. package/src/graph/compile.ts +4 -0
  75. package/src/graph/presets/body.ts +26 -24
  76. package/src/graph/presets/cloth_rough.ts +8 -8
  77. package/src/graph/presets/cloth_smooth.ts +8 -8
  78. package/src/graph/presets/default.ts +4 -4
  79. package/src/graph/presets/eye.ts +10 -10
  80. package/src/graph/presets/face.ts +28 -26
  81. package/src/graph/presets/hair.ts +6 -6
  82. package/src/graph/presets/metal.ts +8 -8
  83. package/src/graph/presets/stockings.ts +4 -11
  84. package/src/graph/presets/unlit.ts +9 -13
  85. package/src/graph/registry.ts +133 -158
  86. package/src/graph/render-class.ts +5 -2
  87. package/src/graph/slots.ts +5 -1
  88. package/src/model.ts +33 -1
  89. package/src/pmx-loader.ts +11 -2
  90. package/src/shaders/anchor-table.ts +15 -3
  91. package/src/shaders/directives.ts +88 -6
  92. package/src/shaders/lights.ts +464 -415
  93. package/src/shaders/materials/common.ts +26 -3
  94. package/src/shaders/materials/nodes.ts +191 -271
  95. package/src/shaders/passes/composite.ts +3 -5
  96. package/src/shaders/passes/ground.ts +1 -1
  97. package/src/shaders/passes/particles.ts +9 -3
  98. package/src/shaders/passes/probe.ts +240 -0
  99. package/src/shaders/passes/scene-contract.ts +17 -0
  100. package/src/shaders/scene-light-api.ts +1 -0
  101. package/src/unity/globals.ts +7 -3
  102. package/src/unity/host.ts +36 -6
  103. package/src/unity/looks.ts +161 -7
  104. package/src/unity/stage.ts +28 -5
  105. package/dist/physics-debug.d.ts +0 -30
  106. package/dist/physics-debug.d.ts.map +0 -1
  107. package/dist/physics-debug.js +0 -526
  108. package/dist/shaders/agx-lut.d.ts +0 -11
  109. package/dist/shaders/agx-lut.d.ts.map +0 -1
  110. package/dist/shaders/agx-lut.js +0 -29
  111. package/dist/shaders/dfg_lut.d.ts +0 -3
  112. package/dist/shaders/dfg_lut.d.ts.map +0 -1
  113. package/dist/shaders/dfg_lut.js +0 -129
  114. package/dist/shaders/ltc_mag_lut.d.ts +0 -3
  115. package/dist/shaders/ltc_mag_lut.d.ts.map +0 -1
  116. package/dist/shaders/ltc_mag_lut.js +0 -1033
  117. package/dist/shaders/materials/body.d.ts +0 -2
  118. package/dist/shaders/materials/body.d.ts.map +0 -1
  119. package/dist/shaders/materials/body.js +0 -95
  120. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  121. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  122. package/dist/shaders/materials/cloth_rough.js +0 -69
  123. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  124. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  125. package/dist/shaders/materials/cloth_smooth.js +0 -61
  126. package/dist/shaders/materials/default.d.ts +0 -2
  127. package/dist/shaders/materials/default.d.ts.map +0 -1
  128. package/dist/shaders/materials/default.js +0 -43
  129. package/dist/shaders/materials/eye.d.ts +0 -2
  130. package/dist/shaders/materials/eye.d.ts.map +0 -1
  131. package/dist/shaders/materials/eye.js +0 -60
  132. package/dist/shaders/materials/face.d.ts +0 -2
  133. package/dist/shaders/materials/face.d.ts.map +0 -1
  134. package/dist/shaders/materials/face.js +0 -95
  135. package/dist/shaders/materials/hair.d.ts +0 -2
  136. package/dist/shaders/materials/hair.d.ts.map +0 -1
  137. package/dist/shaders/materials/hair.js +0 -90
  138. package/dist/shaders/materials/metal.d.ts +0 -2
  139. package/dist/shaders/materials/metal.d.ts.map +0 -1
  140. package/dist/shaders/materials/metal.js +0 -77
  141. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  142. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  143. package/dist/shaders/materials/mmd_classic.js +0 -66
  144. package/dist/shaders/materials/stockings.d.ts +0 -2
  145. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  146. package/dist/shaders/materials/stockings.js +0 -122
  147. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  148. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  149. package/dist/shaders/passes/physics-debug.js +0 -69
  150. package/src/shaders/dfg_lut.ts +0 -131
  151. package/src/shaders/ltc_mag_lut.ts +0 -1035
package/dist/engine.js CHANGED
@@ -15,8 +15,6 @@ import { CameraAnimation } from "./camera-animation";
15
15
  import { PmxLoader } from "./pmx-loader";
16
16
  import { RezePhysics } from "./physics";
17
17
  import { createFetchAssetReader, createFileMapAssetReader, deriveBasePathFromPmxPath, fileListToMap, findFirstPmxFileInList, joinAssetPath, normalizeAssetPath, } from "./asset-reader";
18
- import { BRDF_LUT_SIZE, BRDF_LUT_BAKE_WGSL } from "./shaders/dfg_lut";
19
- import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
20
18
  import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow";
21
19
  import { ID_DEBUG_SHADER_WGSL } from "./shaders/passes/id-debug";
22
20
  import { paramChanged, sampleParamTrack } from "./param-track";
@@ -27,6 +25,7 @@ import { NativeLooks } from "./unity/looks";
27
25
  import { NativeStage } from "./unity/stage";
28
26
  import { gameDir, unityFrameGlobals } from "./unity/globals";
29
27
  import { outlineMaxOffsetMultiplier, towardMatrix } from "./unity/character";
28
+ import { sameValue } from "./unity/host";
30
29
  import { SHADOW_ATLAS_SIZE, SHADOW_CASCADES, buildShadowCascades, cascadeSpheres } from "./shadow-cascades";
31
30
  import { REFLECTION_DEBUG_WGSL, buildMirrorCamera, planeFromPointNormal } from "./reflection";
32
31
  import { MIRROR_DOWNSAMPLE_WGSL, MIRROR_MASK_DOWNSAMPLE_WGSL, MIRROR_MAT_BYTES, mirrorShaderWgsl, mirrorShadowWgsl, } from "./shaders/passes/mirror";
@@ -55,6 +54,7 @@ import { buildCastResolveShader, buildCastSeedShader, buildCastStepShader, castD
55
54
  import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails";
56
55
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
57
56
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap";
57
+ import { PROBE_CAPTURE_LEVELS, PROBE_CAPTURE_SIZE, PROBE_COMPOSE_WGSL, PROBE_FILTER_WGSL, PROBE_LEVELS, PROBE_SIZE, SKY_PREFILTER_WGSL, probeLevelRoughness, skyLevelRoughness, } from "./shaders/passes/probe";
58
58
  import { compileGraph } from "./graph/compile";
59
59
  import { DEFAULT_GRAPH } from "./graph/presets/default";
60
60
  import { parseDirectives, stripDirectives } from "./shaders/directives";
@@ -228,7 +228,7 @@ const CAST_VEC4S = CAST_TRAIL_BASE + MAX_EFFECT_ANCHORS * MAX_EFFECT_SUBJECTS *
228
228
  export const DEFAULT_BLOOM_OPTIONS = {
229
229
  enabled: true,
230
230
  threshold: 0.7,
231
- scatter: 0.77,
231
+ scatter: 0.8,
232
232
  color: new Vec3(1, 1, 1),
233
233
  intensity: 1,
234
234
  };
@@ -257,7 +257,7 @@ export const DEFAULT_COLOR_GRADING = {
257
257
  };
258
258
  const DEFAULT_ENGINE_OPTIONS = {
259
259
  world: { color: new Vec3(0.4014, 0.4944, 0.647), strength: 0.3 },
260
- sun: { color: new Vec3(1.0, 1.0, 1.0), strength: 2.0, direction: new Vec3(-0.0873, -0.3844, 0.919) },
260
+ sun: { color: new Vec3(1.0, 1.0, 1.0), strength: 0.64, direction: new Vec3(-0.0873, -0.3844, 0.919) },
261
261
  camera: { distance: 26.6, target: new Vec3(0, 12.5, 0), fov: Math.PI / 4 },
262
262
  onRaycast: undefined,
263
263
  };
@@ -979,6 +979,9 @@ export class Engine {
979
979
  /** Where addGround put the floor. The mirror reflects across it, so a raised
980
980
  * floor that kept y = 0 would reflect the scene into the wrong plane. */
981
981
  this.groundY = 0;
982
+ /** The built-in floor's half size on x and z — what it covers, for the
983
+ * shadow's reach (updateShadowSceneBounds). */
984
+ this.groundHalf = [0, 0];
982
985
  this.mirrorPlane = new Float32Array([0, 1, 0, 0]);
983
986
  this.mirrorCameraData = new Float32Array(40);
984
987
  // proj x mirrorView for the ground's projective sample and the cull planes,
@@ -1096,6 +1099,19 @@ export class Engine {
1096
1099
  /** A diffuse ambient stated outright (setWorldAmbient), outranking the one
1097
1100
  * fitted to the picture; the picture still answers reflections. */
1098
1101
  this.worldAmbientSH = null;
1102
+ this.cookieLayers = new Map();
1103
+ /** What setLights was last given, rewritten when a cookie it names arrives. */
1104
+ this.sceneLights = null;
1105
+ this.probeData = new Float32Array(12);
1106
+ this.probeCameraData = new Float32Array(40);
1107
+ this.probeCaptureBindGroup = null;
1108
+ /** Set when the stage or what it wears changes; the capture runs once the
1109
+ * scene has stayed put for PROBE_SETTLE_FRAMES (see maybeCaptureProbe). */
1110
+ this.probeDirty = false;
1111
+ this.probeDirtyFrame = 0;
1112
+ this.probeFrame = 0;
1113
+ this.probeCapture = null;
1114
+ this.skyPrefilterPipeline = null;
1099
1115
  // The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
1100
1116
  // scene, over it, or both — whichever of background()/foreground() the code
1101
1117
  // defines. The composite pipelines are REBUILT with the user code injected;
@@ -1308,8 +1324,8 @@ export class Engine {
1308
1324
  contrast: DEFAULT_COLOR_GRADING.contrast,
1309
1325
  saturation: DEFAULT_COLOR_GRADING.saturation,
1310
1326
  };
1311
- /** Sensor grain: how much, and whether it moves. */
1312
- this.grain = { amount: 0, animated: true };
1327
+ /** Sensor grain: how much. */
1328
+ this.grain = { amount: 0 };
1313
1329
  /** Debug/diagnostic: skip every inverted-hull outline draw. */
1314
1330
  // OFF by default — the product aesthetic. Modern high-detail models read
1315
1331
  // better without hulls (babylon-mmd's own demos disable its outline renderer
@@ -1656,6 +1672,9 @@ export class Engine {
1656
1672
  // install (pipeline + uniform buffer) when the graph/integration is byte-unchanged.
1657
1673
  /** A graph's own images (ShaderGraph.images), fetched and decoded once per URL. */
1658
1674
  this.graphImageCache = new Map();
1675
+ this.sortedDraws = new WeakMap();
1676
+ this.sortScratch = new Vec3(0, 0, 0);
1677
+ this.sortDistance = new Map();
1659
1678
  this.canvas = canvas;
1660
1679
  const d = DEFAULT_ENGINE_OPTIONS;
1661
1680
  this.world = {
@@ -1813,16 +1832,11 @@ export class Engine {
1813
1832
  * a background image or a backdrop video, which arrived with grain of their
1814
1833
  * own and would be graded rather than matched by a second helping.
1815
1834
  *
1816
- * `animated` false freezes it. A still photograph's grain does not move, and
1817
- * noise crawling over a frozen picture makes the rendering look more alive
1818
- * than the thing it is standing in.
1819
- *
1820
1835
  * Costs one hash per pixel in a pass that already runs, and nothing at all at
1821
1836
  * zero — the branch is on a uniform.
1822
1837
  */
1823
- setFilmGrain(amount, animated = true) {
1838
+ setFilmGrain(amount) {
1824
1839
  this.grain.amount = Math.min(Math.max(amount, 0), 1);
1825
- this.grain.animated = animated;
1826
1840
  if (this.device && this.compositeUniformBuffer)
1827
1841
  this.writeCompositeViewUniforms();
1828
1842
  }
@@ -1872,10 +1886,8 @@ export class Engine {
1872
1886
  // a uniform branch that skips the pow entirely in the common case.
1873
1887
  u[1] = 1.0 / Math.max(v.gamma, 1e-4);
1874
1888
  u[2] = this.grain.amount;
1875
- // The seed. Zero means STILL: a plate that is one photograph has grain that
1876
- // does not move, and CG noise crawling over a frozen picture makes the CG
1877
- // look more alive than the footage — the opposite of the point.
1878
- u[3] = this.grain.animated ? Math.floor(this.sceneClock * 24) % 1024 : 0;
1889
+ // The seed, stepped at 24 a second like a film gate.
1890
+ u[3] = Math.floor(this.sceneClock * 24) % 1024;
1879
1891
  u[4] = b.color.x;
1880
1892
  u[5] = b.color.y;
1881
1893
  u[6] = b.color.z;
@@ -1887,7 +1899,7 @@ export class Engine {
1887
1899
  // exposure and view transform as the scene — a sun rolls off like a sun).
1888
1900
  // The camera basis at u[12..23] is refreshed per frame.
1889
1901
  // In modes 2 and 3 the colour slot is dead, so mode 3 carries the world
1890
- // STRENGTH in u[8] — Blender's world-strength dial, the SAME number that
1902
+ // STRENGTH in u[8] — the World's intensity, the SAME number that
1891
1903
  // scales the irradiance. A sky you can see at full brightness while it
1892
1904
  // lights at two thirds is two skies.
1893
1905
  const bg = this.backgroundColor;
@@ -3285,7 +3297,7 @@ export class Engine {
3285
3297
  * whether or not it is the thing you see — and it IS the thing you see until
3286
3298
  * a backdrop is set, which is what an HDRI on its own has always done.
3287
3299
  *
3288
- * `strength` is Blender's world-strength dial and is folded into the
3300
+ * `strength` is the World's intensity and is folded into the
3289
3301
  * coefficients, so what lights her is what you see.
3290
3302
  */
3291
3303
  setWorldEquirect(source) {
@@ -3316,7 +3328,8 @@ export class Engine {
3316
3328
  size: [source.width, source.height],
3317
3329
  format: "rgba16float",
3318
3330
  mipLevelCount: levels,
3319
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
3331
+ // COPY_SRC: prefilterSky copies its level 0 into the filtered chain.
3332
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC,
3320
3333
  });
3321
3334
  let data = source.data;
3322
3335
  let w = source.width;
@@ -3348,8 +3361,11 @@ export class Engine {
3348
3361
  }
3349
3362
  this.device.queue.writeTexture({ texture: tex, mipLevel: level }, packHalf(data), { bytesPerRow: w * 8, rowsPerImage: h }, [w, h]);
3350
3363
  }
3351
- this.worldEquirectTexture = tex;
3352
- this.worldEquirectView = tex.createView();
3364
+ // The box chain above is the SOURCE: the levels the materials read are
3365
+ // the GGX convolution of it, one roughness per level (prefilterSky).
3366
+ const filtered = this.prefilterSky(tex, source.width, source.height, levels);
3367
+ this.worldEquirectTexture = filtered;
3368
+ this.worldEquirectView = filtered.createView();
3353
3369
  // The sky lights the scene, not only backs it. The sun keeps the toon
3354
3370
  // ramp — this is the ambient term, exactly where the flat world colour
3355
3371
  // used to sit.
@@ -3370,6 +3386,9 @@ export class Engine {
3370
3386
  this.worldBindingsDirty = true;
3371
3387
  this.rebuildPerFrameBindGroups();
3372
3388
  this.rebuildCompositeBindGroup();
3389
+ // The probe's compose step binds the sky it lays in behind the stage.
3390
+ this.dropProbeCapture();
3391
+ this.captureReflectionProbe();
3373
3392
  if (this.device && this.compositeUniformBuffer)
3374
3393
  this.writeCompositeViewUniforms();
3375
3394
  }
@@ -4397,7 +4416,11 @@ export class Engine {
4397
4416
  // shading ten layers and shading about three.
4398
4417
  depthStencil: prepass
4399
4418
  ? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
4400
- : { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
4419
+ : d.depthAlways
4420
+ ? // `#depth always`: over the scene, Unity's ZTest Always (a moon's halo
4421
+ // drawn in front of the sky dome it sits behind)
4422
+ { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "always" }
4423
+ : { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
4401
4424
  multisample: { count: Engine.MULTISAMPLE_COUNT },
4402
4425
  }),
4403
4426
  ]);
@@ -4824,8 +4847,10 @@ export class Engine {
4824
4847
  // point: a layer composited after tone mapping can never bloom.
4825
4848
  //
4826
4849
  // Additive, where this used to be MAX. Max was right for a post-tonemap
4827
- // layer; in HDR before bloom, overlapping light sums.
4828
- const targets = sceneTargetsFor("trail", this.sceneFormats);
4850
+ // layer; in HDR before bloom, overlapping light sums. `#blend over` lays the
4851
+ // ribbon over the scene by its alpha instead — a game trail's own blend,
4852
+ // under which its dark smoke darkens rather than vanishing.
4853
+ const targets = sceneTargetsFor(d.trailBlend === "over" ? "trail-over" : "trail", this.sceneFormats);
4829
4854
  this.device.pushErrorScope("validation");
4830
4855
  try {
4831
4856
  const pipeline = await this.device.createRenderPipelineAsync({
@@ -5815,7 +5840,9 @@ export class Engine {
5815
5840
  u[0] = Math.max(0, b.threshold);
5816
5841
  u[1] = Math.max(0, b.threshold) * 0.5;
5817
5842
  u[2] = Engine.BLOOM_CLAMP;
5818
- u[3] = Math.min(1, Math.max(0, b.scatter));
5843
+ // URP's mapping: never all the way to the widest level, which alone is a
5844
+ // few pixels smeared over the screen — the glow would vanish.
5845
+ u[3] = 0.05 + 0.9 * Math.min(1, Math.max(0, b.scatter));
5819
5846
  this.device.queue.writeBuffer(this.bloomUniformBuffer, 0, u);
5820
5847
  }
5821
5848
  /** The game's chain, pass for pass (AGSimPostFX.Bloom). */
@@ -6022,71 +6049,6 @@ export class Engine {
6022
6049
  pass.end();
6023
6050
  this.device.queue.submit([encoder.finish()]);
6024
6051
  }
6025
- bakeBrdfLut() {
6026
- if (BRDF_LUT_SIZE !== LTC_MAG_LUT_SIZE) {
6027
- throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).");
6028
- }
6029
- // Temp rg16float LTC source — loaded 1:1 by the bake fragment shader, then dropped.
6030
- const ltcTemp = this.device.createTexture({
6031
- label: "LTC mag LUT (bake input)",
6032
- size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
6033
- format: "rg16float",
6034
- usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
6035
- });
6036
- const n = LTC_MAG_LUT_DATA.length;
6037
- const half = new Uint16Array(n);
6038
- const f32 = new Float32Array(1);
6039
- const u32 = new Uint32Array(f32.buffer);
6040
- for (let i = 0; i < n; i++) {
6041
- f32[0] = LTC_MAG_LUT_DATA[i];
6042
- const x = u32[0];
6043
- const sign = (x >>> 16) & 0x8000;
6044
- let exp = ((x >>> 23) & 0xff) - 127 + 15;
6045
- const mant = x & 0x7fffff;
6046
- if (exp <= 0) {
6047
- half[i] = sign;
6048
- }
6049
- else if (exp >= 31) {
6050
- half[i] = sign | 0x7c00;
6051
- }
6052
- else {
6053
- half[i] = sign | (exp << 10) | (mant >>> 13);
6054
- }
6055
- }
6056
- this.device.queue.writeTexture({ texture: ltcTemp }, half, { bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE }, { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 });
6057
- this.brdfLutTexture = this.device.createTexture({
6058
- label: "BRDF LUT (DFG + LTC packed)",
6059
- size: [BRDF_LUT_SIZE, BRDF_LUT_SIZE],
6060
- format: "rgba8unorm",
6061
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
6062
- });
6063
- this.brdfLutView = this.brdfLutTexture.createView();
6064
- const module = this.device.createShaderModule({ label: "BRDF LUT bake", code: BRDF_LUT_BAKE_WGSL });
6065
- const pipeline = this.device.createRenderPipeline({
6066
- label: "BRDF LUT bake pipeline",
6067
- layout: "auto",
6068
- vertex: { module, entryPoint: "vs" },
6069
- fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
6070
- primitive: { topology: "triangle-list" },
6071
- });
6072
- const bakeBindGroup = this.device.createBindGroup({
6073
- label: "BRDF LUT bake bind group",
6074
- layout: pipeline.getBindGroupLayout(0),
6075
- entries: [{ binding: 0, resource: ltcTemp.createView() }],
6076
- });
6077
- const enc = this.device.createCommandEncoder({ label: "BRDF LUT bake encoder" });
6078
- const pass = enc.beginRenderPass({
6079
- colorAttachments: [
6080
- { view: this.brdfLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
6081
- ],
6082
- });
6083
- pass.setPipeline(pipeline);
6084
- pass.setBindGroup(0, bakeBindGroup);
6085
- pass.draw(3, 1, 0, 0);
6086
- pass.end();
6087
- this.device.queue.submit([enc.finish()]);
6088
- ltcTemp.destroy();
6089
- }
6090
6052
  createRenderPipeline(config) {
6091
6053
  return this.createModelPipeline(this.renderPipelineDesc(config));
6092
6054
  }
@@ -6367,7 +6329,22 @@ export class Engine {
6367
6329
  // The same attachments blended as light — for a group that declared
6368
6330
  // blend: "additive". See scene-contract's material-additive.
6369
6331
  this.sceneTargetsAdditive = sceneTargetsFor("material-additive", this.sceneFormats);
6332
+ this.sceneTargetsPremultiplied = sceneTargetsFor("material-premultiplied", this.sceneFormats);
6370
6333
  this.fullVertexBufferLayouts = fullVertexBuffers;
6334
+ // Slot 3 stays empty: the outline hull binds its own stream there between
6335
+ // two material draws, and a material reading slot 3 would pick it up.
6336
+ const materialVertexBuffers = [
6337
+ ...fullVertexBuffers,
6338
+ null,
6339
+ {
6340
+ arrayStride: Engine.ATTRIBUTE_FLOATS * 4,
6341
+ attributes: [
6342
+ { shaderLocation: 5, offset: 0, format: "float32x4" },
6343
+ { shaderLocation: 6, offset: 4 * 4, format: "float32x2" },
6344
+ ],
6345
+ },
6346
+ ];
6347
+ this.materialVertexBufferLayouts = materialVertexBuffers;
6371
6348
  // group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
6372
6349
  this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
6373
6350
  label: "main per-frame bind group layout",
@@ -6382,14 +6359,19 @@ export class Engine {
6382
6359
  // The positional lights. Always bound, empty or not, so every material
6383
6360
  // pipeline shares one layout whether or not the scene has any.
6384
6361
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
6385
- // The world's sky (8) and the BRDF LUT (9). The sky is always bound —
6386
- // the 1x1 fallback when the scene has none — so every material
6387
- // pipeline keeps sharing one layout. (7 was the far cascade's map,
6388
- // which the atlas at 3 replaces.)
6362
+ // The world's sky (8). Always bound — the 1x1 fallback when the scene
6363
+ // has none — so every material pipeline keeps sharing one layout. (7
6364
+ // was the far cascade's map, which the atlas at 3 replaces.)
6389
6365
  { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6390
- { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6366
+ // The spot lamps' cookies (loadLightCookie), a 1x1 white until one loads.
6367
+ { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d-array" } },
6391
6368
  // The cast's shadow on a stage — setStageCastShadow.
6392
6369
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
6370
+ // The stage's reflection probe and where it projects — see
6371
+ // captureReflectionProbe. Always bound: a 1x1 cube and the off flag
6372
+ // when there is no stage.
6373
+ { binding: 11, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "cube" } },
6374
+ { binding: 12, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
6393
6375
  ],
6394
6376
  });
6395
6377
  // group 1: per-instance (skinMats) — bound once per model
@@ -6458,7 +6440,7 @@ export class Engine {
6458
6440
  label: "neutral base pipeline",
6459
6441
  layout: mainPipelineLayout,
6460
6442
  shaderModule: neutralModule,
6461
- vertexBuffers: fullVertexBuffers,
6443
+ vertexBuffers: materialVertexBuffers,
6462
6444
  fragmentTargets: sceneTargets,
6463
6445
  cullMode: "none",
6464
6446
  depthStencil: {
@@ -6472,7 +6454,7 @@ export class Engine {
6472
6454
  label: "neutral base pipeline (no depth write)",
6473
6455
  layout: mainPipelineLayout,
6474
6456
  shaderModule: neutralModule,
6475
- vertexBuffers: fullVertexBuffers,
6457
+ vertexBuffers: materialVertexBuffers,
6476
6458
  fragmentTargets: sceneTargets,
6477
6459
  cullMode: "none",
6478
6460
  depthStencil: {
@@ -6620,7 +6602,6 @@ export class Engine {
6620
6602
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6621
6603
  });
6622
6604
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
6623
- this.bakeBrdfLut();
6624
6605
  this.bakeGroundNoise();
6625
6606
  // The mipmap blit for the formats model textures arrive in, built here so
6626
6607
  // the first texture of the first model does not compile it synchronously
@@ -6673,6 +6654,48 @@ export class Engine {
6673
6654
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
6674
6655
  });
6675
6656
  this.fallbackEquirectView = this.fallbackEquirectTexture.createView();
6657
+ // The reflection probe's resources, bound from the first frame: the cube
6658
+ // (empty and switched off until a stage is captured), its stand-in and the
6659
+ // uniforms. Before the per-frame groups, which bind all of them.
6660
+ this.probeTexture = this.device.createTexture({
6661
+ label: "reflection probe",
6662
+ size: [PROBE_SIZE, PROBE_SIZE, 6],
6663
+ format: "rgba16float",
6664
+ mipLevelCount: PROBE_LEVELS,
6665
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
6666
+ });
6667
+ this.probeView = this.probeTexture.createView({ dimension: "cube" });
6668
+ this.probeFallbackView = this.device
6669
+ .createTexture({
6670
+ label: "reflection probe stand-in",
6671
+ size: [1, 1, 6],
6672
+ format: "rgba16float",
6673
+ usage: GPUTextureUsage.TEXTURE_BINDING,
6674
+ })
6675
+ .createView({ dimension: "cube" });
6676
+ this.cookieTexture = this.device.createTexture({
6677
+ label: "light cookies (none yet)",
6678
+ size: [1, 1, 1],
6679
+ format: "rgba8unorm",
6680
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
6681
+ });
6682
+ this.device.queue.writeTexture({ texture: this.cookieTexture }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1, 1]);
6683
+ this.cookieView = this.cookieTexture.createView({ dimension: "2d-array" });
6684
+ this.probeUniformBuffer = this.device.createBuffer({
6685
+ label: "reflection probe uniforms",
6686
+ size: 48,
6687
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6688
+ });
6689
+ this.probeOffBuffer = this.device.createBuffer({
6690
+ label: "reflection probe uniforms (off)",
6691
+ size: 48,
6692
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6693
+ });
6694
+ this.probeCameraBuffer = this.device.createBuffer({
6695
+ label: "reflection probe capture camera",
6696
+ size: 40 * 4,
6697
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6698
+ });
6676
6699
  // Zero-filled = zero lines, which every accessor answers gracefully.
6677
6700
  this.lyricsBuffer = this.device.createBuffer({
6678
6701
  label: "lyrics",
@@ -6717,6 +6740,8 @@ export class Engine {
6717
6740
  { binding: 13, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6718
6741
  // The mirror's plane, and whether this draw is inside the mirror pass.
6719
6742
  { binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
6743
+ // The spot lamps' cookies — the floor takes their patterns too.
6744
+ { binding: 15, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d-array" } },
6720
6745
  ],
6721
6746
  });
6722
6747
  this.groundShadowPipelineDesc = {
@@ -8615,6 +8640,416 @@ export class Engine {
8615
8640
  this.writeWorld();
8616
8641
  this.writeSun(0);
8617
8642
  }
8643
+ // ─── The stage's reflection probe ───────────────────────────────────
8644
+ //
8645
+ // What a game bakes for every room: a cube of the stage itself, seen from
8646
+ // its middle, prefiltered down its mips by the specular lobe, and read by
8647
+ // every surface box-projected onto the stage's bounds — so a lacquered
8648
+ // counter reflects the bottles behind it and a wet floor the lamps above,
8649
+ // not a sky the room has no window onto. Captured here automatically once a
8650
+ // stage has loaded and its looks have settled; nothing to set.
8651
+ /** Drop the capture's resources, so the next capture rebinds the sky. */
8652
+ dropProbeCapture() {
8653
+ const cap = this.probeCapture;
8654
+ if (!cap)
8655
+ return;
8656
+ this.probeCapture = null;
8657
+ void this.device.queue.onSubmittedWorkDone().then(() => {
8658
+ for (const t of cap.msTargets)
8659
+ t.destroy();
8660
+ cap.depth.destroy();
8661
+ cap.color.destroy();
8662
+ cap.aux.destroy();
8663
+ cap.cube.destroy();
8664
+ cap.steps.destroy();
8665
+ });
8666
+ }
8667
+ /** Ask for a fresh capture of the stage's reflection probe. It runs at the
8668
+ * start of a frame once the scene has held still for a moment — asked for
8669
+ * by the engine itself whenever the stage or its looks change; a host may
8670
+ * call it after changing something the engine cannot see. */
8671
+ captureReflectionProbe() {
8672
+ if (!this.probeDirty)
8673
+ this.probeDirtyFrame = this.probeFrame;
8674
+ this.probeDirty = true;
8675
+ }
8676
+ /** The stage's world-space bounds: every stage model's material boxes
8677
+ * through its placement. Null without a stage. */
8678
+ stageBounds() {
8679
+ const min = [Infinity, Infinity, Infinity];
8680
+ const max = [-Infinity, -Infinity, -Infinity];
8681
+ const p = new Vec3(0, 0, 0);
8682
+ let any = false;
8683
+ for (const inst of this.modelInstances.values()) {
8684
+ if (!inst.isStage || !inst.model.visible)
8685
+ continue;
8686
+ const m = inst.model;
8687
+ for (const draw of inst.drawCalls) {
8688
+ const b = draw.bounds;
8689
+ if (!b || b.length < 6 || !(b[0] <= b[3]))
8690
+ continue;
8691
+ // The eight corners of the material's model-space box, placed.
8692
+ for (let k = 0; k < 8; k++) {
8693
+ 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);
8694
+ Quat.rotateVecInto(m.rotation, p, p);
8695
+ const w = [p.x + m.position.x, p.y + m.position.y, p.z + m.position.z];
8696
+ for (let a = 0; a < 3; a++) {
8697
+ if (w[a] < min[a])
8698
+ min[a] = w[a];
8699
+ if (w[a] > max[a])
8700
+ max[a] = w[a];
8701
+ }
8702
+ any = true;
8703
+ }
8704
+ }
8705
+ }
8706
+ return any ? { min, max } : null;
8707
+ }
8708
+ /** Run a pending capture once the scene has settled — see captureReflectionProbe. */
8709
+ maybeCaptureProbe() {
8710
+ this.probeFrame++;
8711
+ if (!this.probeDirty || !this.probeCaptureBindGroup)
8712
+ return;
8713
+ if (this.probeFrame - this.probeDirtyFrame < Engine.PROBE_SETTLE_FRAMES)
8714
+ return;
8715
+ this.probeDirty = false;
8716
+ const bounds = this.stageBounds();
8717
+ if (!bounds) {
8718
+ // No stage: the probe goes dark and every reader falls back to the sky.
8719
+ this.probeData.fill(0);
8720
+ this.device.queue.writeBuffer(this.probeUniformBuffer, 0, this.probeData);
8721
+ return;
8722
+ }
8723
+ try {
8724
+ this.renderProbe(bounds);
8725
+ }
8726
+ catch (e) {
8727
+ console.warn("[reze-engine] reflection probe capture failed:", e);
8728
+ }
8729
+ }
8730
+ ensureProbeCapture() {
8731
+ if (this.probeCapture)
8732
+ return this.probeCapture;
8733
+ const size = PROBE_CAPTURE_SIZE;
8734
+ const formats = sceneColorFormats(this.sceneFormats);
8735
+ const msTargets = formats.map((format, i) => this.device.createTexture({
8736
+ label: `reflection probe capture target ${i}`,
8737
+ size: [size, size],
8738
+ sampleCount: Engine.MULTISAMPLE_COUNT,
8739
+ format,
8740
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
8741
+ }));
8742
+ const depth = this.device.createTexture({
8743
+ label: "reflection probe capture depth",
8744
+ size: [size, size],
8745
+ sampleCount: Engine.MULTISAMPLE_COUNT,
8746
+ format: this.depthFormat,
8747
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
8748
+ });
8749
+ const layered = (label, format) => this.device.createTexture({
8750
+ label,
8751
+ size: [size, size, 6],
8752
+ format,
8753
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
8754
+ });
8755
+ // The resolves take the scene pass's own formats: colour and coverage.
8756
+ const color = layered("reflection probe capture colour", formats[0]);
8757
+ const aux = layered("reflection probe capture coverage", formats[1]);
8758
+ const cube = this.device.createTexture({
8759
+ label: "reflection probe capture cube",
8760
+ size: [size, size, 6],
8761
+ format: "rgba16float",
8762
+ mipLevelCount: PROBE_CAPTURE_LEVELS,
8763
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
8764
+ });
8765
+ // One 256-byte slot per step: 6 composes, then the capture's mips, then
8766
+ // the probe's levels, six faces each.
8767
+ const slots = 6 + PROBE_CAPTURE_LEVELS * 6 + PROBE_LEVELS * 6;
8768
+ const steps = this.device.createBuffer({
8769
+ label: "reflection probe steps",
8770
+ size: slots * 256,
8771
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8772
+ });
8773
+ const composeModule = this.device.createShaderModule({ label: "reflection probe compose", code: PROBE_COMPOSE_WGSL });
8774
+ const composePipeline = this.device.createRenderPipeline({
8775
+ label: "reflection probe compose",
8776
+ layout: "auto",
8777
+ vertex: { module: composeModule, entryPoint: "vs_full" },
8778
+ fragment: { module: composeModule, entryPoint: "fs_compose", targets: [{ format: "rgba16float" }] },
8779
+ primitive: { topology: "triangle-list" },
8780
+ });
8781
+ const filterModule = this.device.createShaderModule({ label: "reflection probe filter", code: PROBE_FILTER_WGSL });
8782
+ const filterPipeline = this.device.createRenderPipeline({
8783
+ label: "reflection probe filter",
8784
+ layout: "auto",
8785
+ vertex: { module: filterModule, entryPoint: "vs_full" },
8786
+ fragment: { module: filterModule, entryPoint: "fs_filter", targets: [{ format: "rgba16float" }] },
8787
+ primitive: { topology: "triangle-list" },
8788
+ });
8789
+ const colorArray = color.createView({ dimension: "2d-array" });
8790
+ const auxArray = aux.createView({ dimension: "2d-array" });
8791
+ const composeLayout = composePipeline.getBindGroupLayout(0);
8792
+ const composeGroups = [];
8793
+ for (let f = 0; f < 6; f++) {
8794
+ composeGroups.push(this.device.createBindGroup({
8795
+ label: `reflection probe compose ${f}`,
8796
+ layout: composeLayout,
8797
+ entries: [
8798
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
8799
+ { binding: 2, resource: this.materialSampler },
8800
+ { binding: 8, resource: this.worldEquirectView ?? this.fallbackEquirectView },
8801
+ { binding: 20, resource: colorArray },
8802
+ { binding: 21, resource: auxArray },
8803
+ { binding: 22, resource: { buffer: steps, offset: f * 256, size: 32 } },
8804
+ ],
8805
+ }));
8806
+ }
8807
+ const filterLayout = filterPipeline.getBindGroupLayout(0);
8808
+ const cubeView = cube.createView({ dimension: "cube" });
8809
+ const filterGroups = [];
8810
+ for (let i = 0; i < PROBE_LEVELS * 6; i++) {
8811
+ filterGroups.push(this.device.createBindGroup({
8812
+ label: `reflection probe level ${i}`,
8813
+ layout: filterLayout,
8814
+ entries: [
8815
+ { binding: 0, resource: cubeView },
8816
+ { binding: 1, resource: this.materialSampler },
8817
+ { binding: 2, resource: { buffer: steps, offset: (6 + PROBE_CAPTURE_LEVELS * 6 + i) * 256, size: 32 } },
8818
+ ],
8819
+ }));
8820
+ }
8821
+ this.probeCapture = {
8822
+ msTargets,
8823
+ depth,
8824
+ color,
8825
+ aux,
8826
+ cube,
8827
+ steps,
8828
+ composePipeline,
8829
+ filterPipeline,
8830
+ composeGroups,
8831
+ filterGroups,
8832
+ };
8833
+ return this.probeCapture;
8834
+ }
8835
+ /**
8836
+ * Capture the probe: the stage drawn six times from one point into a 128²
8837
+ * cube, the sky laid in behind it, a mip chain, and the GGX convolution
8838
+ * into the 64² cube the materials read.
8839
+ *
8840
+ * WHERE: the middle of the stage's bounds across the floor, at about a
8841
+ * character's chest height above the floor (y = 0 where the bounds span it,
8842
+ * the MMD convention, else their bottom) — where a game puts the probe of a
8843
+ * room the player stands in. The box it projects onto is the bounds.
8844
+ *
8845
+ * WHAT: the stage alone, in its own looks, lit by the scene's sun, lamps
8846
+ * and sky as they stand. The cast is not in it (a baked probe never holds
8847
+ * the characters), nor are effects.
8848
+ */
8849
+ renderProbe(bounds) {
8850
+ const cap = this.ensureProbeCapture();
8851
+ const { min, max } = bounds;
8852
+ const floor = min[1] <= 0 && max[1] >= 0 ? 0 : min[1];
8853
+ const eye = [
8854
+ (min[0] + max[0]) * 0.5,
8855
+ Math.min(floor + 12, (min[1] + max[1]) * 0.5),
8856
+ (min[2] + max[2]) * 0.5,
8857
+ ];
8858
+ const span = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
8859
+ const near = 0.25;
8860
+ const far = Math.max(span * 2, 100);
8861
+ // Each face's forward and up, in the convention the GPU samples a cube
8862
+ // with (see rz_face_dir in passes/probe.ts): +X, -X, +Y, -Y, +Z, -Z.
8863
+ const faces = [
8864
+ [1, 0, 0, 0, 1, 0],
8865
+ [-1, 0, 0, 0, 1, 0],
8866
+ [0, 1, 0, 0, 0, -1],
8867
+ [0, -1, 0, 0, 0, 1],
8868
+ [0, 0, 1, 0, 1, 0],
8869
+ [0, 0, -1, 0, 1, 0],
8870
+ ];
8871
+ const cam = this.probeCameraData;
8872
+ const view = { perFrame: this.probeCaptureBindGroup, args: "probe", outlines: false };
8873
+ const formats = sceneColorFormats(this.sceneFormats);
8874
+ for (let f = 0; f < 6; f++) {
8875
+ const [fx, fy, fz, ux, uy, uz] = faces[f];
8876
+ Mat4.lookAtInto(cam.subarray(0, 16), eye[0], eye[1], eye[2], eye[0] + fx, eye[1] + fy, eye[2] + fz, ux, uy, uz);
8877
+ if (this.reversedZ)
8878
+ Mat4.perspectiveInto(cam.subarray(16, 32), Math.PI / 2, 1, far, near);
8879
+ else
8880
+ Mat4.perspectiveInto(cam.subarray(16, 32), Math.PI / 2, 1, near, far);
8881
+ cam[32] = eye[0];
8882
+ cam[33] = eye[1];
8883
+ cam[34] = eye[2];
8884
+ cam[35] = PROBE_CAPTURE_SIZE;
8885
+ cam[36] = this.sceneClock;
8886
+ this.device.queue.writeBuffer(this.probeCameraBuffer, 0, cam);
8887
+ const encoder = this.device.createCommandEncoder({ label: `reflection probe face ${f}` });
8888
+ const colorAttachments = formats.map((_, i) => ({
8889
+ view: cap.msTargets[i].createView(),
8890
+ resolveTarget: i === 0
8891
+ ? cap.color.createView({ dimension: "2d", baseArrayLayer: f, arrayLayerCount: 1 })
8892
+ : i === 1
8893
+ ? cap.aux.createView({ dimension: "2d", baseArrayLayer: f, arrayLayerCount: 1 })
8894
+ : undefined,
8895
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
8896
+ loadOp: "clear",
8897
+ storeOp: "discard",
8898
+ }));
8899
+ const pass = encoder.beginRenderPass({
8900
+ label: `reflection probe face ${f}`,
8901
+ colorAttachments,
8902
+ depthStencilAttachment: {
8903
+ view: cap.depth.createView(),
8904
+ depthClearValue: this.depthClear,
8905
+ depthLoadOp: "clear",
8906
+ depthStoreOp: "discard",
8907
+ stencilClearValue: 0,
8908
+ stencilLoadOp: "clear",
8909
+ stencilStoreOp: "discard",
8910
+ },
8911
+ });
8912
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
8913
+ for (const inst of this.modelInstances.values()) {
8914
+ if (!inst.isStage || !inst.model.visible)
8915
+ continue;
8916
+ this.setModelDrawState(pass, inst);
8917
+ this.drawMaterials(pass, inst, "opaque", view);
8918
+ }
8919
+ for (const inst of this.modelInstances.values()) {
8920
+ if (!inst.isStage || !inst.model.visible)
8921
+ continue;
8922
+ this.setModelDrawState(pass, inst);
8923
+ this.drawMaterials(pass, inst, "transparent", view);
8924
+ }
8925
+ pass.end();
8926
+ this.device.queue.submit([encoder.finish()]);
8927
+ }
8928
+ // The steps: compose, capture mips, then the probe's levels.
8929
+ const steps = new ArrayBuffer((6 + PROBE_CAPTURE_LEVELS * 6 + PROBE_LEVELS * 6) * 256);
8930
+ const put = (slot, face, lod, roughness, srcSize, mode) => {
8931
+ const u = new Uint32Array(steps, slot * 256, 8);
8932
+ const fl = new Float32Array(steps, slot * 256, 8);
8933
+ u[0] = face;
8934
+ fl[1] = lod;
8935
+ fl[2] = roughness;
8936
+ fl[3] = srcSize;
8937
+ u[4] = mode;
8938
+ };
8939
+ for (let f = 0; f < 6; f++)
8940
+ put(f, f, 0, 0, PROBE_CAPTURE_SIZE, 0);
8941
+ for (let m = 0; m < PROBE_CAPTURE_LEVELS; m++)
8942
+ for (let f = 0; f < 6; f++)
8943
+ put(6 + m * 6 + f, f, Math.max(m - 1, 0), 0, PROBE_CAPTURE_SIZE, 0);
8944
+ for (let m = 0; m < PROBE_LEVELS; m++)
8945
+ for (let f = 0; f < 6; f++)
8946
+ // Level 0 is the capture at the probe's size: one tap of the 128²
8947
+ // level-0 cube at a 64² texel's centre is its 2×2 average.
8948
+ put(6 + PROBE_CAPTURE_LEVELS * 6 + m * 6 + f, f, 0, probeLevelRoughness(m), PROBE_CAPTURE_SIZE, m === 0 ? 0 : 1);
8949
+ this.device.queue.writeBuffer(cap.steps, 0, steps);
8950
+ const encoder = this.device.createCommandEncoder({ label: "reflection probe filter" });
8951
+ const run = (target, pipeline, group) => {
8952
+ const p = encoder.beginRenderPass({ colorAttachments: [{ view: target, loadOp: "clear", storeOp: "store" }] });
8953
+ p.setPipeline(pipeline);
8954
+ p.setBindGroup(0, group);
8955
+ p.draw(3);
8956
+ p.end();
8957
+ };
8958
+ const face = (tex, level, f) => tex.createView({ dimension: "2d", baseMipLevel: level, mipLevelCount: 1, baseArrayLayer: f, arrayLayerCount: 1 });
8959
+ for (let f = 0; f < 6; f++)
8960
+ run(face(cap.cube, 0, f), cap.composePipeline, cap.composeGroups[f]);
8961
+ // Each level from the one above. The source view spans the whole chain,
8962
+ // which a pass writing one of its levels may not bind — so the reads go
8963
+ // through a view of the levels ABOVE the one being written.
8964
+ for (let m = 1; m < PROBE_CAPTURE_LEVELS; m++) {
8965
+ const above = cap.cube.createView({ dimension: "cube", baseMipLevel: 0, mipLevelCount: m });
8966
+ for (let f = 0; f < 6; f++) {
8967
+ const group = this.device.createBindGroup({
8968
+ label: `reflection probe capture mip ${m}/${f}`,
8969
+ layout: cap.filterPipeline.getBindGroupLayout(0),
8970
+ entries: [
8971
+ { binding: 0, resource: above },
8972
+ { binding: 1, resource: this.materialSampler },
8973
+ { binding: 2, resource: { buffer: cap.steps, offset: (6 + m * 6 + f) * 256, size: 32 } },
8974
+ ],
8975
+ });
8976
+ run(face(cap.cube, m, f), cap.filterPipeline, group);
8977
+ }
8978
+ }
8979
+ for (let m = 0; m < PROBE_LEVELS; m++)
8980
+ for (let f = 0; f < 6; f++)
8981
+ run(face(this.probeTexture, m, f), cap.filterPipeline, cap.filterGroups[m * 6 + f]);
8982
+ this.device.queue.submit([encoder.finish()]);
8983
+ this.probeData.set([eye[0], eye[1], eye[2], 1, min[0], min[1], min[2], 1, max[0], max[1], max[2], 0]);
8984
+ this.device.queue.writeBuffer(this.probeUniformBuffer, 0, this.probeData);
8985
+ }
8986
+ /**
8987
+ * The sky's mips, convolved with the GGX lobe each stands for — the box
8988
+ * average the upload built is only the source. Level 0 is copied as it is
8989
+ * (it is also what the backdrop draws); every level below it becomes the
8990
+ * sky as a surface of that roughness reflects it (skyLevelRoughness).
8991
+ */
8992
+ prefilterSky(source, width, height, levels) {
8993
+ if (levels <= 1)
8994
+ return source;
8995
+ if (!this.skyPrefilterPipeline) {
8996
+ const module = this.device.createShaderModule({ label: "sky prefilter", code: SKY_PREFILTER_WGSL });
8997
+ this.skyPrefilterPipeline = this.device.createRenderPipeline({
8998
+ label: "sky prefilter",
8999
+ layout: "auto",
9000
+ vertex: { module, entryPoint: "vs_full" },
9001
+ fragment: { module, entryPoint: "fs_sky", targets: [{ format: "rgba16float" }] },
9002
+ primitive: { topology: "triangle-list" },
9003
+ });
9004
+ }
9005
+ const pipeline = this.skyPrefilterPipeline;
9006
+ const out = this.device.createTexture({
9007
+ label: "world equirect (HDR, GGX-prefiltered)",
9008
+ size: [width, height],
9009
+ format: "rgba16float",
9010
+ mipLevelCount: levels,
9011
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
9012
+ });
9013
+ const encoder = this.device.createCommandEncoder({ label: "sky prefilter" });
9014
+ encoder.copyTextureToTexture({ texture: source, mipLevel: 0 }, { texture: out, mipLevel: 0 }, [width, height]);
9015
+ const params = this.device.createBuffer({
9016
+ label: "sky prefilter steps",
9017
+ size: levels * 256,
9018
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
9019
+ });
9020
+ const data = new Float32Array(levels * 64);
9021
+ for (let m = 1; m < levels; m++)
9022
+ data.set([skyLevelRoughness(m, levels), width, height, 0], m * 64);
9023
+ this.device.queue.writeBuffer(params, 0, data);
9024
+ const srcView = source.createView();
9025
+ for (let m = 1; m < levels; m++) {
9026
+ const group = this.device.createBindGroup({
9027
+ label: `sky prefilter ${m}`,
9028
+ layout: pipeline.getBindGroupLayout(0),
9029
+ entries: [
9030
+ { binding: 0, resource: srcView },
9031
+ { binding: 1, resource: this.materialSampler },
9032
+ { binding: 2, resource: { buffer: params, offset: m * 256, size: 16 } },
9033
+ ],
9034
+ });
9035
+ const p = encoder.beginRenderPass({
9036
+ label: `sky prefilter ${m}`,
9037
+ colorAttachments: [
9038
+ { view: out.createView({ baseMipLevel: m, mipLevelCount: 1 }), loadOp: "clear", storeOp: "store" },
9039
+ ],
9040
+ });
9041
+ p.setPipeline(pipeline);
9042
+ p.setBindGroup(0, group);
9043
+ p.draw(3);
9044
+ p.end();
9045
+ }
9046
+ this.device.queue.submit([encoder.finish()]);
9047
+ // The box chain and the steps are done with once that work is: the source
9048
+ // retires with the skies (destroyed at the next frame's start).
9049
+ this.retiredSkies.push(source);
9050
+ void this.device.queue.onSubmittedWorkDone().then(() => params.destroy());
9051
+ return out;
9052
+ }
8618
9053
  /** Returns whether it actually rebuilt — see rebuildCompositeBindGroup. */
8619
9054
  rebuildPerFrameBindGroups() {
8620
9055
  // EVERY resource, not just the layout. This runs twice: once at init, where
@@ -8632,7 +9067,7 @@ export class Engine {
8632
9067
  !this.shadowLightVPBuffer ||
8633
9068
  !this.materialSampler ||
8634
9069
  !this.shadowComparisonSampler ||
8635
- !this.brdfLutView ||
9070
+ !this.probeUniformBuffer ||
8636
9071
  !(this.worldEquirectView ?? this.fallbackEquirectView)) {
8637
9072
  return false;
8638
9073
  }
@@ -8654,8 +9089,31 @@ export class Engine {
8654
9089
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
8655
9090
  { binding: 6, resource: { buffer: this.lightsBuffer } },
8656
9091
  { binding: 8, resource: env },
8657
- { binding: 9, resource: this.brdfLutView },
9092
+ { binding: 9, resource: this.cookieView },
9093
+ { binding: 10, resource: this.castShadowView },
9094
+ { binding: 11, resource: this.probeView },
9095
+ { binding: 12, resource: { buffer: this.probeUniformBuffer } },
9096
+ ],
9097
+ });
9098
+ // The probe's own view of the scene: one cube face at a time through its
9099
+ // capture camera, and the probe itself switched off — a material inside
9100
+ // the capture reflects the sky, never the cube being filled.
9101
+ this.probeCaptureBindGroup = this.device.createBindGroup({
9102
+ label: "reflection probe capture per-frame bind group",
9103
+ layout: this.mainPerFrameBindGroupLayout,
9104
+ entries: [
9105
+ { binding: 0, resource: { buffer: this.probeCameraBuffer } },
9106
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
9107
+ { binding: 2, resource: this.materialSampler },
9108
+ { binding: 3, resource: this.shadowAtlasView },
9109
+ { binding: 4, resource: this.shadowComparisonSampler },
9110
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
9111
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
9112
+ { binding: 8, resource: env },
9113
+ { binding: 9, resource: this.cookieView },
8658
9114
  { binding: 10, resource: this.castShadowView },
9115
+ { binding: 11, resource: this.probeFallbackView },
9116
+ { binding: 12, resource: { buffer: this.probeOffBuffer } },
8659
9117
  ],
8660
9118
  });
8661
9119
  this.mirrorPerFrameBindGroup = this.device.createBindGroup({
@@ -8670,8 +9128,10 @@ export class Engine {
8670
9128
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
8671
9129
  { binding: 6, resource: { buffer: this.lightsBuffer } },
8672
9130
  { binding: 8, resource: env },
8673
- { binding: 9, resource: this.brdfLutView },
9131
+ { binding: 9, resource: this.cookieView },
8674
9132
  { binding: 10, resource: this.castShadowView },
9133
+ { binding: 11, resource: this.probeView },
9134
+ { binding: 12, resource: { buffer: this.probeUniformBuffer } },
8675
9135
  ],
8676
9136
  });
8677
9137
  return true;
@@ -8733,13 +9193,13 @@ export class Engine {
8733
9193
  this.lightData[base + 4] = this.sun.color.x;
8734
9194
  this.lightData[base + 5] = this.sun.color.y;
8735
9195
  this.lightData[base + 6] = this.sun.color.z;
9196
+ // Unity's light intensity: a white sun of 1 lights albedo 1:1 at N·L = 1.
8736
9197
  this.lightData[base + 7] = this.sun.strength;
8737
9198
  this.lightDataWords[DIR_LAYERS_AT + index] = this.sunLayers;
8738
9199
  if (index >= this.lightCount)
8739
9200
  this.lightCount = index + 1;
8740
9201
  this.updateLightBuffer();
8741
9202
  }
8742
- /** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
8743
9203
  /**
8744
9204
  * Distance fog on every lit surface, as a game on SimPipeline lays it: per
8745
9205
  * layer, t = saturate(f − (1 − f)·h) with f = saturate(depth·distance[0] +
@@ -8867,7 +9327,7 @@ export class Engine {
8867
9327
  }
8868
9328
  this.writeWorld();
8869
9329
  }
8870
- /** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
9330
+ /** Update the main light (Unity's directional Sun). Direction change marks shadow VP dirty. */
8871
9331
  setSun(options) {
8872
9332
  if (options.color)
8873
9333
  this.sun.color = options.color;
@@ -8918,6 +9378,7 @@ export class Engine {
8918
9378
  ...options,
8919
9379
  };
8920
9380
  this.groundY = opts.y;
9381
+ this.groundHalf = [opts.width / 2, opts.height / 2];
8921
9382
  this.createGroundGeometry(opts.width, opts.height, opts.y);
8922
9383
  this.createShadowGroundResources(opts);
8923
9384
  this.hasGround = true;
@@ -8968,6 +9429,7 @@ export class Engine {
8968
9429
  * params make: a scene document's JSON passes straight in, and so does a
8969
9430
  * literal typed into a console. Vec3 satisfies it either way. */
8970
9431
  lights) {
9432
+ this.sceneLights = lights;
8971
9433
  // The directional ones go to the light uniform's slots beside the sun's —
8972
9434
  // three of them, in order; the rest are records the grid indexes.
8973
9435
  const directional = (lights ?? []).filter((l) => l.kind === "directional");
@@ -9024,8 +9486,12 @@ export class Engine {
9024
9486
  this.lightsData[b + 12] = Math.max(inner, outer);
9025
9487
  // The layers it does NOT reach, as bits — inverted so a zero reaches all.
9026
9488
  this.lightsWords[b + 13] = ~(l.layers ?? ALL_LAYERS) >>> 0;
9027
- this.lightsData[b + 14] = 0;
9028
- this.lightsData[b + 15] = 0;
9489
+ // The bulb's radius (rzLightNear): 0 keeps the engine's default
9490
+ this.lightsData[b + 14] = l.near !== undefined && Number.isFinite(l.near) && l.near > 0 ? l.near : 0;
9491
+ // Its cookie's layer + 1, 0 for none or one not loaded yet (a load
9492
+ // rewrites the lamps when it lands).
9493
+ const cookie = l.cookie !== undefined ? this.cookieLayers.get(l.cookie) : undefined;
9494
+ this.lightsData[b + 15] = cookie !== undefined ? cookie + 1 : 0;
9029
9495
  }
9030
9496
  // The grid, from the records as STORED — normalised aim, clamped reach, the
9031
9497
  // cosines in f32 — so it indexes exactly what the shader will evaluate.
@@ -9049,6 +9515,62 @@ export class Engine {
9049
9515
  // count included — is written there.
9050
9516
  this.allocateLightSlots();
9051
9517
  }
9518
+ /**
9519
+ * A cookie, under `key`, for every spot lamp that names it (SceneLight.cookie):
9520
+ * the picture a stage light throws through its gobo, projected along the
9521
+ * cone — its RGB is the light let through, so black blocks, white passes and
9522
+ * a colour tints. Square, stretched to 512². Up to 16 at once; loading a key
9523
+ * again replaces its picture. False when every layer is taken.
9524
+ */
9525
+ async loadLightCookie(key, source) {
9526
+ if (!this.device)
9527
+ return false;
9528
+ let layer = this.cookieLayers.get(key);
9529
+ if (layer === undefined) {
9530
+ const used = new Set(this.cookieLayers.values());
9531
+ for (let k = 0; k < Engine.COOKIE_LAYERS && layer === undefined; k++)
9532
+ if (!used.has(k))
9533
+ layer = k;
9534
+ if (layer === undefined) {
9535
+ console.warn(`[reze] light cookie "${key}": all ${Engine.COOKIE_LAYERS} cookie layers are in use`);
9536
+ return false;
9537
+ }
9538
+ }
9539
+ const size = Engine.COOKIE_SIZE;
9540
+ const bmp = await createImageBitmap(source, {
9541
+ resizeWidth: size,
9542
+ resizeHeight: size,
9543
+ resizeQuality: "high",
9544
+ colorSpaceConversion: "none",
9545
+ premultiplyAlpha: "none",
9546
+ });
9547
+ // The real array on the first cookie, and every group that binds the
9548
+ // stand-in rebuilt against it (the stand-in itself is a 1x1 and stays).
9549
+ if (this.cookieTexture.width === 1) {
9550
+ this.cookieTexture = this.device.createTexture({
9551
+ label: "light cookies",
9552
+ size: [size, size, Engine.COOKIE_LAYERS],
9553
+ format: "rgba8unorm",
9554
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
9555
+ });
9556
+ this.cookieView = this.cookieTexture.createView({ dimension: "2d-array" });
9557
+ this.worldBindingsDirty = true;
9558
+ this.buildGroundBindGroup();
9559
+ }
9560
+ this.device.queue.copyExternalImageToTexture({ source: bmp }, { texture: this.cookieTexture, origin: [0, 0, layer] }, [size, size, 1]);
9561
+ bmp.close();
9562
+ this.cookieLayers.set(key, layer);
9563
+ if (this.sceneLights)
9564
+ this.setLights(this.sceneLights);
9565
+ return true;
9566
+ }
9567
+ /** Forget a cookie: lamps naming it light plainly again. */
9568
+ removeLightCookie(key) {
9569
+ if (!this.cookieLayers.delete(key))
9570
+ return;
9571
+ if (this.sceneLights)
9572
+ this.setLights(this.sceneLights);
9573
+ }
9052
9574
  /** How many positional lights the scene is carrying. */
9053
9575
  getLightCount() {
9054
9576
  return this.lightHeader[0];
@@ -11152,8 +11674,11 @@ export class Engine {
11152
11674
  if (d > most)
11153
11675
  most = d;
11154
11676
  }
11155
- const extent = Math.sqrt(most);
11156
- if (extent <= this.sceneExtent)
11677
+ this.raiseSceneExtent(Math.sqrt(most));
11678
+ }
11679
+ /** Raise the scene's reach (never lower it) and hand it to the camera. */
11680
+ raiseSceneExtent(extent) {
11681
+ if (!(extent > this.sceneExtent))
11157
11682
  return;
11158
11683
  this.sceneExtent = extent;
11159
11684
  this.camera?.setSceneExtent(extent);
@@ -11245,6 +11770,19 @@ export class Engine {
11245
11770
  maxZ = Math.max(maxZ, data[o + 18] + r);
11246
11771
  }
11247
11772
  }
11773
+ // The built-in floor, while it is drawn. It is no draw of the cull table,
11774
+ // so without this the box ended at the cast: the cascades stopped just
11775
+ // past her, and a low sun's long shadow ran off the last of them — the
11776
+ // floor beyond read as lit and her head and shoulders went missing.
11777
+ if (this.hasGround && !this.groundIsSuppressed()) {
11778
+ const [hx, hz] = this.groundHalf;
11779
+ minX = Math.min(minX, -hx);
11780
+ maxX = Math.max(maxX, hx);
11781
+ minY = Math.min(minY, this.groundY);
11782
+ maxY = Math.max(maxY, this.groundY);
11783
+ minZ = Math.min(minZ, -hz);
11784
+ maxZ = Math.max(maxZ, hz);
11785
+ }
11248
11786
  if (this.nativeStage) {
11249
11787
  const nb = this.nativeStage.bounds;
11250
11788
  minX = Math.min(minX, nb.min[0]);
@@ -11544,6 +12082,10 @@ export class Engine {
11544
12082
  */
11545
12083
  recordBundles() {
11546
12084
  this.bundlesDirty = false;
12085
+ // What the bundles record is what the probe would see: a stage arriving or
12086
+ // leaving, a look installed on it, a pipeline landing. Re-captured once it
12087
+ // settles (and switched off when the stage is gone).
12088
+ this.captureReflectionProbe();
11547
12089
  const scene = {
11548
12090
  colorFormats: sceneColorFormats(this.sceneFormats),
11549
12091
  depthStencilFormat: this.depthFormat,
@@ -11805,7 +12347,14 @@ export class Engine {
11805
12347
  * and any draw whose slot has not been assigned yet.
11806
12348
  */
11807
12349
  issueDraw(pass, draw, kind) {
11808
- const args = kind === "shadow" ? this.cullShadowArgs : kind === "mirror" ? this.cullMirrorArgs : this.cullCameraArgs;
12350
+ // The probe's cube faces have no cull of their own: drawn whole.
12351
+ const args = kind === "probe"
12352
+ ? null
12353
+ : kind === "shadow"
12354
+ ? this.cullShadowArgs
12355
+ : kind === "mirror"
12356
+ ? this.cullMirrorArgs
12357
+ : this.cullCameraArgs;
11809
12358
  if (args && draw.cullIndex >= 0) {
11810
12359
  pass.drawIndexedIndirect(args, draw.cullIndex * Engine.CULL_ARG_WORDS * 4);
11811
12360
  }
@@ -12066,7 +12615,8 @@ export class Engine {
12066
12615
  layout: this.pickPerInstanceBindGroupLayout,
12067
12616
  entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
12068
12617
  });
12069
- const gpuBuffers = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer];
12618
+ const attributeBuffer = this.createAttributeBuffer(name, model);
12619
+ const gpuBuffers = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer, attributeBuffer];
12070
12620
  const gpuMorph = this.createGpuMorph(name, model, vertexBuffer, gpuBuffers);
12071
12621
  // Cull bounds. The margin is the skinning reach plus the largest single
12072
12622
  // vertex-morph displacement — a face morph is millimetres against a reach of
@@ -12087,6 +12637,7 @@ export class Engine {
12087
12637
  indexBuffer,
12088
12638
  jointsBuffer,
12089
12639
  weightsBuffer,
12640
+ attributeBuffer,
12090
12641
  skinMatrixBuffer,
12091
12642
  wireEdges: new Map(),
12092
12643
  drawCalls: [],
@@ -12132,6 +12683,47 @@ export class Engine {
12132
12683
  this.bundlesDirty = true;
12133
12684
  this.updateOrderDirty = true;
12134
12685
  }
12686
+ /**
12687
+ * A model's attribute stream: vertex colour (RGBA) and a second UV set, six
12688
+ * floats a vertex, read by the material pipelines at slot 4.
12689
+ *
12690
+ * PMX has no vertex colour and one UV, but it has four ADDITIONAL UV
12691
+ * channels of four floats each, and that is where a model that needs more
12692
+ * keeps it: the first additional UV is the vertex colour, the second's xy
12693
+ * the second UV — the convention a converted game stage is written in (a
12694
+ * plant's occlusion rides in its vertex alpha, a light card's mask in its
12695
+ * second UV). A model without them reads white and its own UV, which is
12696
+ * what every graph that reads neither already assumed.
12697
+ */
12698
+ createAttributeBuffer(name, model) {
12699
+ const vertices = model.getVertices();
12700
+ const count = Math.floor(vertices.length / 8);
12701
+ const data = new Float32Array(count * Engine.ATTRIBUTE_FLOATS);
12702
+ const color = model.getAdditionalUv(1);
12703
+ const uv2 = model.getAdditionalUv(2);
12704
+ for (let i = 0; i < count; i++) {
12705
+ const o = i * Engine.ATTRIBUTE_FLOATS;
12706
+ if (color) {
12707
+ data[o] = color[i * 4];
12708
+ data[o + 1] = color[i * 4 + 1];
12709
+ data[o + 2] = color[i * 4 + 2];
12710
+ data[o + 3] = color[i * 4 + 3];
12711
+ }
12712
+ else {
12713
+ data[o] = data[o + 1] = data[o + 2] = data[o + 3] = 1;
12714
+ }
12715
+ data[o + 4] = uv2 ? uv2[i * 4] : vertices[i * 8 + 6];
12716
+ data[o + 5] = uv2 ? uv2[i * 4 + 1] : vertices[i * 8 + 7];
12717
+ }
12718
+ const buffer = this.device.createBuffer({
12719
+ label: `${name}: attribute buffer`,
12720
+ size: Math.max(data.byteLength, 4 * Engine.ATTRIBUTE_FLOATS),
12721
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
12722
+ });
12723
+ if (data.byteLength > 0)
12724
+ this.device.queue.writeBuffer(buffer, 0, data);
12725
+ return buffer;
12726
+ }
12135
12727
  // Build the per-model GPU vertex-morph state. Returns null (and leaves the model on the
12136
12728
  // CPU morph path) when the model has no vertex morphs. Created buffers are pushed into
12137
12729
  // gpuBuffers so they're released with the instance.
@@ -12407,6 +12999,7 @@ export class Engine {
12407
12999
  { binding: 12, resource: this.groundNoiseView },
12408
13000
  { binding: 13, resource: this.mirrorMaskView },
12409
13001
  { binding: 14, resource: { buffer: this.groundClipOffBuffer } },
13002
+ { binding: 15, resource: this.cookieView },
12410
13003
  ],
12411
13004
  });
12412
13005
  this.groundMirrorViewBindGroup = this.device.createBindGroup({
@@ -12429,6 +13022,7 @@ export class Engine {
12429
13022
  { binding: 12, resource: this.groundNoiseView },
12430
13023
  { binding: 13, resource: this.mirrorDummyColorView },
12431
13024
  { binding: 14, resource: { buffer: this.groundClipMirrorBuffer } },
13025
+ { binding: 15, resource: this.cookieView },
12432
13026
  ],
12433
13027
  });
12434
13028
  if (this.groundDrawCall)
@@ -13586,39 +14180,6 @@ export class Engine {
13586
14180
  return new Vec3(x / w, y / w, z / w);
13587
14181
  }
13588
14182
  // World-space ray from camera through a canvas pixel. Uses WebGPU's NDC z ∈ [0,1].
13589
- /**
13590
- * Where a point on the canvas lands on a horizontal plane.
13591
- *
13592
- * `px,py` are canvas-relative pixels, top-left origin — what a pointer event
13593
- * gives you after subtracting the element's rect. Returns null when the ray
13594
- * cannot reach the plane: parallel to it, or pointing the other way, which is
13595
- * what a click on the sky above the horizon is.
13596
- *
13597
- * The one primitive a placement UI needs. Dragging a thing across the floor is
13598
- * otherwise three sliders in world units, which asks someone to guess numbers
13599
- * that have no visible relation to the picture they are looking at — and it
13600
- * throws away the property that makes pointing work at all: under perspective,
13601
- * moving something further away makes it smaller by exactly the right amount,
13602
- * so position and size stop being two controls to tune against each other.
13603
- */
13604
- groundPointAt(px, py, planeY = 0) {
13605
- const ray = this.buildMouseRay(px, py);
13606
- if (!ray)
13607
- return null;
13608
- // Parallel to the plane: no intersection, and a huge one is not an answer.
13609
- if (Math.abs(ray.dir.y) < 1e-6)
13610
- return null;
13611
- const t = (planeY - ray.origin.y) / ray.dir.y;
13612
- // Behind the camera — the plane is there, but not in this shot.
13613
- if (!(t > 0) || !isFinite(t))
13614
- return null;
13615
- return new Vec3(ray.origin.x + ray.dir.x * t, planeY, ray.origin.z + ray.dir.z * t);
13616
- }
13617
- /** Hand the pointer to something else — a placement drag, a gizmo, a host's own
13618
- * overlay — so the orbit does not also act on it. */
13619
- setCameraInputLocked(locked) {
13620
- this.camera?.setInputLocked(locked);
13621
- }
13622
14183
  buildMouseRay(px, py) {
13623
14184
  if (!this.camera)
13624
14185
  return null;
@@ -14018,6 +14579,10 @@ export class Engine {
14018
14579
  const idsRead = this.idDebug || this.effects.some((e) => e.readsIds || e.readsCastDistance);
14019
14580
  idAtt.storeOp = idsRead ? "store" : "discard";
14020
14581
  }
14582
+ // The stage's reflection probe, when one is due: its own submits, ahead of
14583
+ // this frame's, so every material below reads the fresh cube.
14584
+ if (hasModels)
14585
+ this.maybeCaptureProbe();
14021
14586
  const encoder = this.device.createCommandEncoder();
14022
14587
  // GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
14023
14588
  // them. WebGPU inserts the storage→vertex barrier between this pass and the render passes.
@@ -14981,7 +15546,7 @@ export class Engine {
14981
15546
  const base = {
14982
15547
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
14983
15548
  layout: this.mainPipelineLayout,
14984
- vertex: { module, buffers: this.fullVertexBufferLayouts },
15549
+ vertex: { module, buffers: this.materialVertexBufferLayouts },
14985
15550
  // The eye front-culls — the see-through-hair look is draw order plus that
14986
15551
  // cull plus the stencil stamp, and it is the deployed appearance of every
14987
15552
  // published scene. In the MIRROR the winding is flipped (determinant -1,
@@ -15040,7 +15605,15 @@ export class Engine {
15040
15605
  }
15041
15606
  const desc = {
15042
15607
  ...base,
15043
- fragment: { module, constants, targets: blend === "additive" ? this.sceneTargetsAdditive : this.sceneTargets },
15608
+ fragment: {
15609
+ module,
15610
+ constants,
15611
+ targets: blend === "additive"
15612
+ ? this.sceneTargetsAdditive
15613
+ : blend === "premultiplied"
15614
+ ? this.sceneTargetsPremultiplied
15615
+ : this.sceneTargets,
15616
+ },
15044
15617
  depthStencil,
15045
15618
  };
15046
15619
  return this.cachedRenderPipeline(desc).then((pipeline) => {
@@ -15259,6 +15832,42 @@ export class Engine {
15259
15832
  * makes outlines compose like MMD: every material drawn later in the author's
15260
15833
  * order covers earlier hulls, and each hull sits over everything drawn before it.
15261
15834
  */
15835
+ /**
15836
+ * A stage's draws, far to near from the eye: its transparent queue, in the
15837
+ * order a game sorts one. Each draw's distance is its material box's centre,
15838
+ * placed, against the camera — a pane or a water surface is one material,
15839
+ * which is the unit a game sorts too. The array is kept per instance and
15840
+ * re-sorted in place, so an unchanged order costs a comparison per draw.
15841
+ */
15842
+ backToFront(inst) {
15843
+ let list = this.sortedDraws.get(inst);
15844
+ if (!list || list.length !== inst.drawCalls.length || list.some((d) => !inst.drawCalls.includes(d))) {
15845
+ list = [...inst.drawCalls];
15846
+ this.sortedDraws.set(inst, list);
15847
+ }
15848
+ const m = inst.model;
15849
+ const ex = this.cameraMatrixData[32];
15850
+ const ey = this.cameraMatrixData[33];
15851
+ const ez = this.cameraMatrixData[34];
15852
+ const p = this.sortScratch;
15853
+ const dist = this.sortDistance;
15854
+ dist.clear();
15855
+ for (const d of list) {
15856
+ const b = d.bounds;
15857
+ if (!b || b.length < 6 || !(b[0] <= b[3])) {
15858
+ dist.set(d, 0);
15859
+ continue;
15860
+ }
15861
+ 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);
15862
+ Quat.rotateVecInto(m.rotation, p, p);
15863
+ const dx = p.x + m.position.x - ex;
15864
+ const dy = p.y + m.position.y - ey;
15865
+ const dz = p.z + m.position.z - ez;
15866
+ dist.set(d, dx * dx + dy * dy + dz * dz);
15867
+ }
15868
+ list.sort((a, b) => dist.get(b) - dist.get(a));
15869
+ return list;
15870
+ }
15262
15871
  /** Is this draw's compiled class "hair"? Ungrouped draws never are — the
15263
15872
  * neutral pipeline is the auto class. */
15264
15873
  isHairDraw(inst, dc) {
@@ -15274,11 +15883,20 @@ export class Engine {
15274
15883
  only) {
15275
15884
  let currentPipeline = null;
15276
15885
  let bound = false;
15277
- for (const draw of inst.drawCalls) {
15886
+ const outlined = [];
15887
+ // A stage's glass and water in the camera pass go far to near, as a
15888
+ // game's transparent queue does: author order put a far pane over a near
15889
+ // one wherever the author listed it last. Characters keep MMD's author
15890
+ // order — their layers are fabric, built around it.
15891
+ const draws = type === "transparent" && inst.isStage && view.args === "camera" ? this.backToFront(inst) : inst.drawCalls;
15892
+ for (const draw of draws) {
15278
15893
  if (draw.type !== type)
15279
15894
  continue;
15280
15895
  if (only && (only === "hair") !== this.isHairDraw(inst, draw))
15281
15896
  continue;
15897
+ // The probe draws directly, past the cull: honour the switches it holds.
15898
+ if (view.args === "probe" && !this.shouldRenderDrawCall(inst, draw))
15899
+ continue;
15282
15900
  if (!bound) {
15283
15901
  pass.setBindGroup(0, view.perFrame);
15284
15902
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
@@ -15292,35 +15910,43 @@ export class Engine {
15292
15910
  }
15293
15911
  pass.setBindGroup(2, draw.bindGroup);
15294
15912
  this.issueDraw(pass, draw, view.args);
15295
- if (draw.outline && this.outlineEnabled && view.outlines) {
15296
- // Same index range; own pipeline + groups 0/2. Group 1 (skinMats) is
15297
- // layout-identical between the main and outline pipelines and stays
15298
- // bound. Restore group 0 afterwards and force a pipeline re-set.
15299
- const mirrored = view.args === "mirror";
15300
- pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline);
15301
- pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup);
15913
+ if (draw.outline && this.outlineEnabled && view.outlines)
15914
+ outlined.push(draw);
15915
+ }
15916
+ // THE HULLS AFTER THE SURFACES, as MMD draws a model's edges. A hull held
15917
+ // at the minimum width is faded, and it still writes depth: drawn between
15918
+ // two materials it hid the second wherever it stood in front of it — a
15919
+ // shoe's sole's hull cut the upper behind it, and the floor showed through
15920
+ // as pale lines along the seam. After every surface of this phase it only
15921
+ // ever blends over finished ones. Same index ranges; own pipeline and
15922
+ // groups 0/2 — group 1 (skinMats) is layout-identical and stays bound.
15923
+ if (outlined.length) {
15924
+ const mirrored = view.args === "mirror";
15925
+ pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline);
15926
+ pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup);
15927
+ // Slot 3: the hull's smoothed normals + edge scale. Only the outline
15928
+ // pipeline reads it; the others leave the slot alone.
15929
+ if (inst.outlineVertexBuffer)
15930
+ pass.setVertexBuffer(3, inst.outlineVertexBuffer);
15931
+ for (const draw of outlined) {
15302
15932
  pass.setBindGroup(2, draw.outline.bindGroup);
15303
- // Slot 3: the hull's smoothed normals + edge scale. Only the outline
15304
- // pipeline reads it; the others leave the slot alone.
15305
- if (inst.outlineVertexBuffer)
15306
- pass.setVertexBuffer(3, inst.outlineVertexBuffer);
15307
15933
  this.issueDraw(pass, draw, view.args);
15308
- pass.setBindGroup(0, view.perFrame);
15309
- currentPipeline = null;
15310
15934
  }
15935
+ pass.setBindGroup(0, view.perFrame);
15311
15936
  }
15312
15937
  }
15313
15938
  /**
15314
15939
  * Main-pass render sequence for one model instance — babylon-mmd parity:
15315
15940
  * opaque bucket, the hair-over-eyes stencil pass, then alpha-blend materials
15316
- * in PMX author order with depth write ON (forceDepthWrite). Outlines are not
15317
- * a separate phase: drawMaterials draws each edge-flagged material's hull
15318
- * right after the material itself, like MMD's per-mesh outline stage.
15941
+ * in PMX author order with depth write ON (forceDepthWrite). Outlines close
15942
+ * each drawMaterials call: the edge-flagged materials' hulls after all of its
15943
+ * surfaces, as MMD draws a model's edges.
15319
15944
  */
15320
15945
  setModelDrawState(pass, inst) {
15321
15946
  pass.setVertexBuffer(0, inst.vertexBuffer);
15322
15947
  pass.setVertexBuffer(1, inst.jointsBuffer);
15323
15948
  pass.setVertexBuffer(2, inst.weightsBuffer);
15949
+ pass.setVertexBuffer(4, inst.attributeBuffer);
15324
15950
  pass.setIndexBuffer(inst.indexBuffer, "uint32");
15325
15951
  // The stencil reference used to be set here. It is pass state, not bundle
15326
15952
  // state — GPURenderBundleEncoder has no setStencilReference at all — so it
@@ -15654,7 +16280,7 @@ export class Engine {
15654
16280
  // still pattern welded to the picture. On the SCENE clock like everything
15655
16281
  // else here, so an export reproduces the editor exactly rather than
15656
16282
  // scattering differently at whatever rate the encoder ran.
15657
- u[3] = this.grain.animated ? Math.floor((this.sceneClock * 24) % 1024) : 0;
16283
+ u[3] = Math.floor((this.sceneClock * 24) % 1024);
15658
16284
  u[26] = this.canvas.width;
15659
16285
  u[27] = this.canvas.height;
15660
16286
  // Camera world position (viewU[10]) — the other half of bgWorldPos. It
@@ -15895,7 +16521,10 @@ export class Engine {
15895
16521
  }
15896
16522
  for (let s = 0; s < anchors.length; s++) {
15897
16523
  const a = CAST_SUBJECT_VEC4S * 4 + (s * MAX_EFFECT_SUBJECTS + n) * 12;
15898
- const pos = m.getBoneWorldPosition(anchors[s].bone);
16524
+ // `along`: the point that far down the bone's own axis, still model space
16525
+ // here, so the placement below turns and scales it like the joint.
16526
+ const along = anchors[s].along;
16527
+ const pos = along ? m.getBonePointAlong(anchors[s].bone, along) : m.getBoneWorldPosition(anchors[s].bone);
15899
16528
  if (!pos) {
15900
16529
  cd[a + 3] = 0;
15901
16530
  continue;
@@ -15918,7 +16547,7 @@ export class Engine {
15918
16547
  cd[a + 5] = vy;
15919
16548
  cd[a + 6] = vz;
15920
16549
  if (anchors[s].trail)
15921
- this.writeTrail(inst.name, s, n, pos, cd, a);
16550
+ this.writeTrail(inst.name, s, n, pos, cd, a, anchors[s].step);
15922
16551
  const fwd = m.getBoneWorldForward(anchors[s].bone);
15923
16552
  if (fwd) {
15924
16553
  Quat.rotateVecInto(m.rotation, fwd, fwd);
@@ -15958,7 +16587,7 @@ export class Engine {
15958
16587
  // effect's counts before the next upload replaces them.
15959
16588
  this.castData.fill(0, CAST_SUBJECT_VEC4S * 4);
15960
16589
  }
15961
- writeTrail(model, slot, n, pos, cd, anchorBase) {
16590
+ writeTrail(model, slot, n, pos, cd, anchorBase, step) {
15962
16591
  const key = `${model}\u0000${slot}`;
15963
16592
  let ring = this.anchorTrail.get(key);
15964
16593
  if (!ring) {
@@ -15979,9 +16608,47 @@ export class Engine {
15979
16608
  if (Math.hypot(dx, dy, dz) / dt > 50) {
15980
16609
  ring.pos.length = 0;
15981
16610
  ring.t.length = 0;
16611
+ ring.live = false;
16612
+ }
16613
+ }
16614
+ if (step && ring.pos.length > 0) {
16615
+ // `trail step d` — the path a game's trail keeps (Unity's
16616
+ // minVertexDistance). Point 0 is the LIVE head: it follows the bone every
16617
+ // frame. It is left behind as a kept point only on a tick, and only once
16618
+ // the bone is `step` from the last point kept, so no segment is shorter
16619
+ // than that and a slowing hand cannot leave a knot of jittery stubs for a
16620
+ // wide ribbon to fan across. Still at most one point per tick, so a stepped
16621
+ // ring never covers LESS time than a sampled one.
16622
+ const live = ring.live === true;
16623
+ const k = live ? 3 : 0;
16624
+ const far = Math.hypot(pos.x - ring.pos[k], pos.y - ring.pos[k + 1], pos.z - ring.pos[k + 2]) >= step;
16625
+ if (live && !(far && this.trailDue > 0)) {
16626
+ ring.pos[0] = pos.x;
16627
+ ring.pos[1] = pos.y;
16628
+ ring.pos[2] = pos.z;
16629
+ ring.t[0] = this.sceneClock;
16630
+ }
16631
+ else {
16632
+ // keep the head where it is as a point (live) or start a new head
16633
+ if (live) {
16634
+ ring.pos[0] = pos.x;
16635
+ ring.pos[1] = pos.y;
16636
+ ring.pos[2] = pos.z;
16637
+ ring.t[0] = this.sceneClock;
16638
+ ring.live = false;
16639
+ }
16640
+ else {
16641
+ ring.pos.unshift(pos.x, pos.y, pos.z);
16642
+ ring.t.unshift(this.sceneClock);
16643
+ ring.live = !(far && this.trailDue > 0);
16644
+ }
16645
+ if (ring.t.length > TRAIL_SAMPLES) {
16646
+ ring.t.length = TRAIL_SAMPLES;
16647
+ ring.pos.length = TRAIL_SAMPLES * 3;
16648
+ }
15982
16649
  }
15983
16650
  }
15984
- if (this.trailDue > 0 || ring.pos.length === 0) {
16651
+ else if (this.trailDue > 0 || ring.pos.length === 0) {
15985
16652
  // ONE sample per frame, never one per due tick. A frame that spanned
15986
16653
  // several 60Hz ticks only knows where the bone is NOW, and unshifting that
15987
16654
  // position once per tick fabricated duplicate samples — same point, same
@@ -16065,6 +16732,9 @@ export class Engine {
16065
16732
  headSkin: () => (head >= 0 ? inst.model.getSkinMatrices().subarray(head * 16, head * 16 + 16) : null),
16066
16733
  headRest,
16067
16734
  layers: new Uint32Array(inst.objectLight.buffer)[0],
16735
+ additionalUv: (channel) => inst.model.getAdditionalUv(channel),
16736
+ morphs: inst.model.getMorphing().morphs,
16737
+ morphWeights: () => inst.model.getEffectiveMorphWeights(),
16068
16738
  }, look);
16069
16739
  this.writeCullHidden(true);
16070
16740
  return true;
@@ -16075,6 +16745,19 @@ export class Engine {
16075
16745
  nativeLookReady(name) {
16076
16746
  return this.nativeLooks?.ready(name) ?? Promise.resolve();
16077
16747
  }
16748
+ /**
16749
+ * Set some of the values of one material a model's native look dresses —
16750
+ * its Unity material properties by name, colours linear, as the look's
16751
+ * `values` hold them — over the look's own, until set again. What a game
16752
+ * animates on a material over a take (its timeline's material curves)
16753
+ * reaches the shaders this way, one frame at a time: the caller samples its
16754
+ * keys on its own clock and sets what it got. Only the names the material's
16755
+ * look carries are taken, and a value equal to the one in place writes
16756
+ * nothing. False when the model wears no look dressing that material.
16757
+ */
16758
+ setModelNativeUniforms(name, material, values) {
16759
+ return this.nativeLooks?.setUniforms(name, material, values) ?? false;
16760
+ }
16078
16761
  /** The hosts for the game's shaders: the scene pass's and the shadow atlas's,
16079
16762
  * with the stand-ins an empty slot reads. Built on first use. */
16080
16763
  ensureNativeHosts() {
@@ -16123,6 +16806,18 @@ export class Engine {
16123
16806
  white,
16124
16807
  black,
16125
16808
  blackCube: solid("native: black cube", [0, 0, 0, 255], true),
16809
+ // an empty volume (a froxel buffer no pass filled): no in-scatter, no extinction
16810
+ emptyVolume: (() => {
16811
+ const t = d.createTexture({
16812
+ label: "native: empty volume",
16813
+ size: [1, 1, 1],
16814
+ dimension: "3d",
16815
+ format: "rgba8unorm",
16816
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
16817
+ });
16818
+ d.queue.writeTexture({ texture: t }, new Uint8Array([0, 0, 0, 0]), { bytesPerRow: 4, rowsPerImage: 1 }, [1, 1, 1]);
16819
+ return t.createView({ dimension: "3d" });
16820
+ })(),
16126
16821
  depth: this.nativeNoShadowView,
16127
16822
  comparison: this.shadowComparisonSampler,
16128
16823
  };
@@ -16151,6 +16846,8 @@ export class Engine {
16151
16846
  return;
16152
16847
  const scene = this.ensureNativeHosts();
16153
16848
  this.nativeStage = await NativeStage.load(this.device, pkg, read, { scene, shadow: this.nativeShadowHost }, { mipmaps: (t, levels) => this.generateMipmaps(t, levels), fallback: this.nativeFallback }, onProgress);
16849
+ // The stage's sky is in it: the far plane reaches as far as the stage does.
16850
+ this.raiseSceneExtent(this.nativeStage.extent);
16154
16851
  }
16155
16852
  /** What went wrong in the game's shaders, and the names nothing supplied. */
16156
16853
  nativeReport() {
@@ -16198,11 +16895,17 @@ export class Engine {
16198
16895
  let sunDirection = [dir.x, dir.y, dir.z];
16199
16896
  let sunColor = [sun.color.x * k, sun.color.y * k, sun.color.z * k];
16200
16897
  let sunShadow = this.sunShadow;
16898
+ let sunUnity = null;
16201
16899
  if (stage) {
16202
16900
  // Its lamps are the stage's own; its key is the scene's sun, which the
16203
- // host sets from the stage (and a person may then move).
16901
+ // host sets from the stage (and a person may then move) - along the
16902
+ // scene sun's direction, in the stage's own main-light colour. The scene
16903
+ // sun's colour is the cast's key, which the game keeps apart (a
16904
+ // character's light is _ProbeLightingBase, not the stage's main light).
16204
16905
  const L = stage.pkg.lights;
16205
16906
  const sc = stage.pkg.scale;
16907
+ sunColor = [L.main.color[0], L.main.color[1], L.main.color[2]];
16908
+ sunUnity = L.main.unity ?? null;
16206
16909
  lights.length = 0;
16207
16910
  for (const l of L.additional) {
16208
16911
  lights.push({
@@ -16232,6 +16935,7 @@ export class Engine {
16232
16935
  dt: 1 / 60,
16233
16936
  sunDirection,
16234
16937
  sunColor,
16938
+ sunUnity,
16235
16939
  sunShadow,
16236
16940
  lights,
16237
16941
  ambientSH: sh ? Array.from(sh, (v) => v * s) : null,
@@ -16342,6 +17046,8 @@ Engine.GIZMO_WORLD_SIZE = 1.5;
16342
17046
  Engine.GIZMO_THICKNESS_PX = 15.0;
16343
17047
  Engine.GIZMO_PICK_THRESHOLD_PX = 17.0;
16344
17048
  Engine.MULTISAMPLE_COUNT = 4;
17049
+ /** Floats per vertex in a model's attribute stream: colour RGBA, then UV2. */
17050
+ Engine.ATTRIBUTE_FLOATS = 6;
16345
17051
  /**
16346
17052
  * Shadow map depth format — 16-bit, deliberately.
16347
17053
  *
@@ -16509,6 +17215,9 @@ Engine.TIMED_PASSES = [
16509
17215
  // because two planes would need two of every attachment below. Which one is
16510
17216
  // in force is mirrorPlane, refreshed by updateMirrorCamera.
16511
17217
  Engine.GROUND_PLANE = [0, 1, 0, 0];
17218
+ Engine.COOKIE_SIZE = 512;
17219
+ Engine.COOKIE_LAYERS = 16;
17220
+ Engine.PROBE_SETTLE_FRAMES = 20;
16512
17221
  /** The game's prefilter clamps at 6550.4 — a tenth of the half-float ceiling
16513
17222
  * (its BloomPass _Params.y of 100 never reaches the shader). */
16514
17223
  Engine.BLOOM_CLAMP = 6550.4;
@@ -16611,22 +17320,3 @@ function stableGlobals(prev, next) {
16611
17320
  }
16612
17321
  return next;
16613
17322
  }
16614
- function sameValue(a, b) {
16615
- if (typeof a === "number" || typeof b === "number")
16616
- return a === b;
16617
- if (a.length !== b.length)
16618
- return false;
16619
- if (a instanceof Uint32Array !== b instanceof Uint32Array)
16620
- return false;
16621
- for (let i = 0; i < a.length; i++) {
16622
- const x = a[i];
16623
- const y = b[i];
16624
- if (typeof x === "number" || typeof y === "number") {
16625
- if (x !== y)
16626
- return false;
16627
- }
16628
- else if (!sameValue(x, y))
16629
- return false;
16630
- }
16631
- return true;
16632
- }