reze-engine 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/dist/engine.d.ts +300 -94
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +1628 -787
  4. package/dist/graph/compile.d.ts.map +1 -1
  5. package/dist/graph/compile.js +5 -0
  6. package/dist/graph/presets/body.d.ts.map +1 -1
  7. package/dist/graph/presets/body.js +26 -24
  8. package/dist/graph/presets/cloth_rough.js +8 -8
  9. package/dist/graph/presets/cloth_smooth.js +8 -8
  10. package/dist/graph/presets/default.js +4 -4
  11. package/dist/graph/presets/eye.js +10 -10
  12. package/dist/graph/presets/face.d.ts.map +1 -1
  13. package/dist/graph/presets/face.js +28 -26
  14. package/dist/graph/presets/hair.js +6 -6
  15. package/dist/graph/presets/metal.js +8 -8
  16. package/dist/graph/presets/stockings.d.ts.map +1 -1
  17. package/dist/graph/presets/stockings.js +4 -11
  18. package/dist/graph/presets/unlit.d.ts.map +1 -1
  19. package/dist/graph/presets/unlit.js +9 -13
  20. package/dist/graph/registry.d.ts.map +1 -1
  21. package/dist/graph/registry.js +128 -157
  22. package/dist/graph/render-class.d.ts +5 -2
  23. package/dist/graph/render-class.d.ts.map +1 -1
  24. package/dist/graph/slots.d.ts.map +1 -1
  25. package/dist/graph/slots.js +12 -2
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/model.d.ts +23 -1
  29. package/dist/model.d.ts.map +1 -1
  30. package/dist/model.js +41 -1
  31. package/dist/outline-normals.d.ts +12 -0
  32. package/dist/outline-normals.d.ts.map +1 -0
  33. package/dist/outline-normals.js +100 -0
  34. package/dist/pmx-loader.d.ts +7 -0
  35. package/dist/pmx-loader.d.ts.map +1 -1
  36. package/dist/pmx-loader.js +17 -3
  37. package/dist/shaders/anchor-table.d.ts +11 -0
  38. package/dist/shaders/anchor-table.d.ts.map +1 -1
  39. package/dist/shaders/anchor-table.js +9 -3
  40. package/dist/shaders/directives.d.ts +42 -5
  41. package/dist/shaders/directives.d.ts.map +1 -1
  42. package/dist/shaders/directives.js +51 -4
  43. package/dist/shaders/lights.d.ts +11 -7
  44. package/dist/shaders/lights.d.ts.map +1 -1
  45. package/dist/shaders/lights.js +315 -271
  46. package/dist/shaders/materials/common.d.ts.map +1 -1
  47. package/dist/shaders/materials/common.js +30 -5
  48. package/dist/shaders/materials/nodes.d.ts +1 -1
  49. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  50. package/dist/shaders/materials/nodes.js +191 -271
  51. package/dist/shaders/passes/bloom.d.ts +4 -3
  52. package/dist/shaders/passes/bloom.d.ts.map +1 -1
  53. package/dist/shaders/passes/bloom.js +58 -87
  54. package/dist/shaders/passes/composite.d.ts +1 -1
  55. package/dist/shaders/passes/composite.d.ts.map +1 -1
  56. package/dist/shaders/passes/composite.js +121 -77
  57. package/dist/shaders/passes/ground.js +1 -1
  58. package/dist/shaders/passes/mirror.d.ts +18 -12
  59. package/dist/shaders/passes/mirror.d.ts.map +1 -1
  60. package/dist/shaders/passes/mirror.js +59 -13
  61. package/dist/shaders/passes/outline.d.ts.map +1 -1
  62. package/dist/shaders/passes/outline.js +71 -14
  63. package/dist/shaders/passes/particles.d.ts.map +1 -1
  64. package/dist/shaders/passes/particles.js +9 -3
  65. package/dist/shaders/passes/probe.d.ts +45 -0
  66. package/dist/shaders/passes/probe.d.ts.map +1 -0
  67. package/dist/shaders/passes/probe.js +230 -0
  68. package/dist/shaders/passes/scene-contract.d.ts +13 -0
  69. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  70. package/dist/shaders/passes/scene-contract.js +4 -0
  71. package/dist/shaders/scene-light-api.d.ts.map +1 -1
  72. package/dist/shaders/scene-light-api.js +1 -0
  73. package/dist/unity/globals.d.ts +7 -0
  74. package/dist/unity/globals.d.ts.map +1 -1
  75. package/dist/unity/globals.js +4 -3
  76. package/dist/unity/host.d.ts +18 -0
  77. package/dist/unity/host.d.ts.map +1 -1
  78. package/dist/unity/host.js +144 -70
  79. package/dist/unity/looks.d.ts +47 -0
  80. package/dist/unity/looks.d.ts.map +1 -1
  81. package/dist/unity/looks.js +150 -6
  82. package/dist/unity/skin.d.ts.map +1 -1
  83. package/dist/unity/skin.js +5 -2
  84. package/dist/unity/stage.d.ts +24 -0
  85. package/dist/unity/stage.d.ts.map +1 -1
  86. package/dist/unity/stage.js +59 -7
  87. package/package.json +1 -1
  88. package/src/engine.ts +2010 -1033
  89. package/src/graph/compile.ts +4 -0
  90. package/src/graph/presets/body.ts +26 -24
  91. package/src/graph/presets/cloth_rough.ts +8 -8
  92. package/src/graph/presets/cloth_smooth.ts +8 -8
  93. package/src/graph/presets/default.ts +4 -4
  94. package/src/graph/presets/eye.ts +10 -10
  95. package/src/graph/presets/face.ts +28 -26
  96. package/src/graph/presets/hair.ts +6 -6
  97. package/src/graph/presets/metal.ts +8 -8
  98. package/src/graph/presets/stockings.ts +4 -11
  99. package/src/graph/presets/unlit.ts +9 -13
  100. package/src/graph/registry.ts +133 -158
  101. package/src/graph/render-class.ts +5 -2
  102. package/src/graph/slots.ts +12 -2
  103. package/src/index.ts +1 -0
  104. package/src/model.ts +50 -1
  105. package/src/outline-normals.ts +110 -0
  106. package/src/pmx-loader.ts +18 -3
  107. package/src/shaders/anchor-table.ts +15 -3
  108. package/src/shaders/directives.ts +88 -6
  109. package/src/shaders/lights.ts +464 -415
  110. package/src/shaders/materials/common.ts +30 -5
  111. package/src/shaders/materials/nodes.ts +191 -271
  112. package/src/shaders/passes/bloom.ts +59 -87
  113. package/src/shaders/passes/composite.ts +121 -77
  114. package/src/shaders/passes/ground.ts +1 -1
  115. package/src/shaders/passes/mirror.ts +60 -13
  116. package/src/shaders/passes/outline.ts +71 -14
  117. package/src/shaders/passes/particles.ts +9 -3
  118. package/src/shaders/passes/probe.ts +240 -0
  119. package/src/shaders/passes/scene-contract.ts +17 -0
  120. package/src/shaders/scene-light-api.ts +1 -0
  121. package/src/unity/globals.ts +7 -3
  122. package/src/unity/host.ts +93 -20
  123. package/src/unity/looks.ts +182 -9
  124. package/src/unity/skin.ts +5 -2
  125. package/src/unity/stage.ts +66 -7
  126. package/dist/physics-debug.d.ts +0 -30
  127. package/dist/physics-debug.d.ts.map +0 -1
  128. package/dist/physics-debug.js +0 -526
  129. package/dist/shaders/agx-lut.d.ts +0 -11
  130. package/dist/shaders/agx-lut.d.ts.map +0 -1
  131. package/dist/shaders/agx-lut.js +0 -29
  132. package/dist/shaders/dfg_lut.d.ts +0 -3
  133. package/dist/shaders/dfg_lut.d.ts.map +0 -1
  134. package/dist/shaders/dfg_lut.js +0 -129
  135. package/dist/shaders/ltc_mag_lut.d.ts +0 -3
  136. package/dist/shaders/ltc_mag_lut.d.ts.map +0 -1
  137. package/dist/shaders/ltc_mag_lut.js +0 -1033
  138. package/dist/shaders/materials/body.d.ts +0 -2
  139. package/dist/shaders/materials/body.d.ts.map +0 -1
  140. package/dist/shaders/materials/body.js +0 -95
  141. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  142. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  143. package/dist/shaders/materials/cloth_rough.js +0 -69
  144. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  145. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  146. package/dist/shaders/materials/cloth_smooth.js +0 -61
  147. package/dist/shaders/materials/default.d.ts +0 -2
  148. package/dist/shaders/materials/default.d.ts.map +0 -1
  149. package/dist/shaders/materials/default.js +0 -43
  150. package/dist/shaders/materials/eye.d.ts +0 -2
  151. package/dist/shaders/materials/eye.d.ts.map +0 -1
  152. package/dist/shaders/materials/eye.js +0 -60
  153. package/dist/shaders/materials/face.d.ts +0 -2
  154. package/dist/shaders/materials/face.d.ts.map +0 -1
  155. package/dist/shaders/materials/face.js +0 -95
  156. package/dist/shaders/materials/hair.d.ts +0 -2
  157. package/dist/shaders/materials/hair.d.ts.map +0 -1
  158. package/dist/shaders/materials/hair.js +0 -90
  159. package/dist/shaders/materials/metal.d.ts +0 -2
  160. package/dist/shaders/materials/metal.d.ts.map +0 -1
  161. package/dist/shaders/materials/metal.js +0 -77
  162. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  163. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  164. package/dist/shaders/materials/mmd_classic.js +0 -66
  165. package/dist/shaders/materials/stockings.d.ts +0 -2
  166. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  167. package/dist/shaders/materials/stockings.js +0 -122
  168. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  169. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  170. package/dist/shaders/passes/physics-debug.js +0 -69
  171. package/src/shaders/agx-lut.ts +0 -30
  172. package/src/shaders/dfg_lut.ts +0 -131
  173. package/src/shaders/ltc_mag_lut.ts +0 -1035
package/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,9 +25,10 @@ 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
- import { MIRROR_MASK_DOWNSAMPLE_WGSL, MIRROR_MAT_BYTES, mirrorShaderWgsl, mirrorShadowWgsl } from "./shaders/passes/mirror";
31
+ import { MIRROR_DOWNSAMPLE_WGSL, MIRROR_MASK_DOWNSAMPLE_WGSL, MIRROR_MAT_BYTES, mirrorShaderWgsl, mirrorShadowWgsl, } from "./shaders/passes/mirror";
33
32
  import { packHalf } from "./hdr";
34
33
  import { evalIrradianceSH, projectIrradianceSH, gradientIrradianceSH } from "./ibl";
35
34
  import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, packLyrics } from "./shaders/lyrics-api";
@@ -44,8 +43,7 @@ import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
44
43
  import { OVERLAY_SHADER_WGSL, OVERLAY_COMPOSITE_SHADER_WGSL } from "./shaders/passes/overlay";
45
44
  import { WIREFRAME_SHADER_WGSL } from "./shaders/passes/wireframe";
46
45
  import { boneOverlay, boneMarkerPositions, buildOverlayShapes, jointOverlay, lineBetween, rigidbodyOverlay, writeOverlayInstance, OVERLAY_INSTANCE_FLOATS, OVERLAY_VERTEX_FLOATS, OVERLAY_SHAPES, OVERLAY_SOLID_SHAPES, DEFAULT_VERTEX_COLOR, OVERLAY_STYLE, } from "./overlay";
47
- import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
48
- import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
46
+ import { BLOOM_BLUR_H_SHADER_WGSL, BLOOM_BLUR_V_SHADER_WGSL, BLOOM_PREFILTER_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
49
47
  import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
50
48
  import { SUBSURFACE_WGSL, SSS_SPIKE } from "./shaders/passes/subsurface";
51
49
  import { buildParticleComputeShader, buildParticleRenderShader, PARTICLE_LIGHT_BINDING, PARTICLE_POINTS_BINDING, PARTICLE_TEXTURE_BINDING, PARTICLE_TEXTURE_SAMPLER_BINDING, particleEntryPoints, PARTICLE_INDIRECT_BINDING, PARTICLE_INDIRECT_BYTES, PARTICLE_INDIRECT_DRAW_OFFSET, PARTICLE_STRIDE, } from "./shaders/passes/particles";
@@ -56,6 +54,7 @@ import { buildCastResolveShader, buildCastSeedShader, buildCastStepShader, castD
56
54
  import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails";
57
55
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
58
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";
59
58
  import { compileGraph } from "./graph/compile";
60
59
  import { DEFAULT_GRAPH } from "./graph/presets/default";
61
60
  import { parseDirectives, stripDirectives } from "./shaders/directives";
@@ -228,12 +227,10 @@ const CAST_TRAIL_BASE = EFFECT_TRAIL_BASE;
228
227
  const CAST_VEC4S = CAST_TRAIL_BASE + MAX_EFFECT_ANCHORS * MAX_EFFECT_SUBJECTS * TRAIL_SAMPLES;
229
228
  export const DEFAULT_BLOOM_OPTIONS = {
230
229
  enabled: true,
231
- threshold: 0.5,
232
- knee: 0.5,
233
- radius: 4.0,
234
- color: new Vec3(1.0, 0.7247558832168579, 0.6487361788749695),
235
- intensity: 0.05,
236
- clamp: 0.0,
230
+ threshold: 0.7,
231
+ scatter: 0.8,
232
+ color: new Vec3(1, 1, 1),
233
+ intensity: 1,
237
234
  };
238
235
  export const DEFAULT_DEPTH_OF_FIELD_OPTIONS = {
239
236
  enabled: false,
@@ -245,12 +242,10 @@ export const DEFAULT_DEPTH_OF_FIELD_OPTIONS = {
245
242
  bladeCount: 6,
246
243
  quality: "balanced",
247
244
  };
248
- // Matches the reference Blender project: Filmic view, Medium High Contrast look,
249
- // exposure 0.3, gamma 1.0, sRGB display, no curves.
250
245
  export const DEFAULT_VIEW_TRANSFORM = {
251
- exposure: 0.6,
246
+ exposure: 0,
252
247
  gamma: 1.0,
253
- transform: "filmic",
248
+ transform: "soft",
254
249
  };
255
250
  const NEUTRAL_GRADE_CHANNEL = 0.5;
256
251
  export const DEFAULT_COLOR_GRADING = {
@@ -262,7 +257,7 @@ export const DEFAULT_COLOR_GRADING = {
262
257
  };
263
258
  const DEFAULT_ENGINE_OPTIONS = {
264
259
  world: { color: new Vec3(0.4014, 0.4944, 0.647), strength: 0.3 },
265
- 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) },
266
261
  camera: { distance: 26.6, target: new Vec3(0, 12.5, 0), fov: Math.PI / 4 },
267
262
  onRaycast: undefined,
268
263
  };
@@ -984,6 +979,9 @@ export class Engine {
984
979
  /** Where addGround put the floor. The mirror reflects across it, so a raised
985
980
  * floor that kept y = 0 would reflect the scene into the wrong plane. */
986
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];
987
985
  this.mirrorPlane = new Float32Array([0, 1, 0, 0]);
988
986
  this.mirrorCameraData = new Float32Array(40);
989
987
  // proj x mirrorView for the ground's projective sample and the cull planes,
@@ -1009,6 +1007,7 @@ export class Engine {
1009
1007
  * premultiplied quantity and a blur has to move them together. */
1010
1008
  this.mirrorMaskMipViews = [];
1011
1009
  this.mirrorMaskBlurBindGroups = null;
1010
+ this.mirrorDownsamplePipeline = null;
1012
1011
  this.mirrorMaskDownsamplePipeline = null;
1013
1012
  this.groundClipData = new Float32Array(8);
1014
1013
  this.mirrorIdMsTexture = null;
@@ -1100,6 +1099,19 @@ export class Engine {
1100
1099
  /** A diffuse ambient stated outright (setWorldAmbient), outranking the one
1101
1100
  * fitted to the picture; the picture still answers reflections. */
1102
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;
1103
1115
  // The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
1104
1116
  // scene, over it, or both — whichever of background()/foreground() the code
1105
1117
  // defines. The composite pipelines are REBUILT with the user code injected;
@@ -1173,7 +1185,6 @@ export class Engine {
1173
1185
  /** Trail samples owed this frame, computed once so every trail on every
1174
1186
  * character samples in lockstep and their paths stay comparable. */
1175
1187
  this.trailDue = 0;
1176
- this.agxLutTexture = null;
1177
1188
  /** The scene's own grade (setStageGrade): the cube as handed over, kept so a
1178
1189
  * call before init() still lands, and its texture once uploaded. */
1179
1190
  this.stageGradeCube = null;
@@ -1198,18 +1209,42 @@ export class Engine {
1198
1209
  this.pipelineDescs = new WeakMap();
1199
1210
  this.singleSided = new WeakMap();
1200
1211
  this.singleSidedMirror = new WeakMap();
1212
+ /** Twins whose async build is in flight, so a draw asks for each once. */
1213
+ this.sidedPending = new WeakSet();
1214
+ this.sidedPendingMirror = new WeakSet();
1215
+ // The engine-wide shader cache (see cachedRenderPipeline). Modules by WGSL
1216
+ // source, pipelines by module + descriptor state: one compile per source,
1217
+ // whoever asks — a graph on two models, an effect reinstalled, the composite.
1218
+ this.shaderModuleCache = new Map();
1219
+ this.pipelineCache = new Map();
1220
+ this.bindGroupLayoutCache = new Map();
1221
+ this.pipelineLayoutCache = new Map();
1222
+ this.gpuObjectIds = new WeakMap();
1223
+ this.gpuObjectNext = 1;
1224
+ /** Init's pipelines, built async and in parallel; init awaits them. Null
1225
+ * outside init, where a pipeline job builds the way it always did. */
1226
+ this.initPipelineJobs = null;
1201
1227
  // Stepped motion (#stepped). The models whose pose is being HELD this frame —
1202
1228
  // their skinning and morph uploads wait for the stepped clock's next tick —
1203
1229
  // and the tick each was last shown at. Empty while no effect declares it.
1204
1230
  this.steppedHeld = new Set();
1205
1231
  this.steppedTick = new Map();
1206
1232
  this.steppedLastClock = -1;
1207
- this.bloomBlitUniformData = new Float32Array(4);
1208
- this.bloomUpsampleUniformData = new Float32Array(4);
1209
- this.bloomMipCount = 0;
1210
- this.bloomDownMipViews = [];
1211
- this.bloomUpMipViews = [];
1212
- this.bloomDownsampleBindGroups = [];
1233
+ this.bloomPrefilterPipeline = null;
1234
+ this.bloomBlurHPipeline = null;
1235
+ this.bloomBlurVPipeline = null;
1236
+ this.bloomUpsamplePipeline = null;
1237
+ this.bloomUpsampleBindGroupLayout = null;
1238
+ this.bloomUniformBuffer = null;
1239
+ this.bloomUniformData = new Float32Array(4);
1240
+ this.bloomDownTexture = null;
1241
+ this.bloomUpTexture = null;
1242
+ this.bloomLevels = 0;
1243
+ this.bloomDownViews = [];
1244
+ this.bloomUpViews = [];
1245
+ this.bloomPrefilterBindGroup = null;
1246
+ this.bloomBlurHBindGroups = [];
1247
+ this.bloomBlurVBindGroups = [];
1213
1248
  this.bloomUpsampleBindGroups = [];
1214
1249
  this.hasGround = false;
1215
1250
  /** The user's own "no ground" switch — see setGroundVisible. Distinct from
@@ -1289,8 +1324,8 @@ export class Engine {
1289
1324
  contrast: DEFAULT_COLOR_GRADING.contrast,
1290
1325
  saturation: DEFAULT_COLOR_GRADING.saturation,
1291
1326
  };
1292
- /** Sensor grain: how much, and whether it moves. */
1293
- this.grain = { amount: 0, animated: true };
1327
+ /** Sensor grain: how much. */
1328
+ this.grain = { amount: 0 };
1294
1329
  /** Debug/diagnostic: skip every inverted-hull outline draw. */
1295
1330
  // OFF by default — the product aesthetic. Modern high-detail models read
1296
1331
  // better without hulls (babylon-mmd's own demos disable its outline renderer
@@ -1637,6 +1672,9 @@ export class Engine {
1637
1672
  // install (pipeline + uniform buffer) when the graph/integration is byte-unchanged.
1638
1673
  /** A graph's own images (ShaderGraph.images), fetched and decoded once per URL. */
1639
1674
  this.graphImageCache = new Map();
1675
+ this.sortedDraws = new WeakMap();
1676
+ this.sortScratch = new Vec3(0, 0, 0);
1677
+ this.sortDistance = new Map();
1640
1678
  this.canvas = canvas;
1641
1679
  const d = DEFAULT_ENGINE_OPTIONS;
1642
1680
  this.world = {
@@ -1668,18 +1706,16 @@ export class Engine {
1668
1706
  const bg = options?.background;
1669
1707
  this.backgroundColor = bg ? new Vec3(bg.x, bg.y, bg.z) : null;
1670
1708
  }
1671
- /** Merge partial bloom with EEVEE defaults (same as constructor). */
1709
+ /** Merge partial bloom with the defaults (same as constructor). */
1672
1710
  static mergeBloomDefaults(partial) {
1673
1711
  const d = DEFAULT_BLOOM_OPTIONS;
1674
1712
  const c = partial?.color;
1675
1713
  return {
1676
1714
  enabled: partial?.enabled ?? d.enabled,
1677
1715
  threshold: partial?.threshold ?? d.threshold,
1678
- knee: partial?.knee ?? d.knee,
1679
- radius: partial?.radius ?? d.radius,
1716
+ scatter: partial?.scatter ?? d.scatter,
1680
1717
  color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
1681
1718
  intensity: partial?.intensity ?? d.intensity,
1682
- clamp: partial?.clamp ?? d.clamp,
1683
1719
  };
1684
1720
  }
1685
1721
  static mergeViewTransformDefaults(partial) {
@@ -1690,17 +1726,15 @@ export class Engine {
1690
1726
  transform: partial?.transform ?? d.transform,
1691
1727
  };
1692
1728
  }
1693
- /** Current bloom settings (Blender names; tint is a copied `Vec3`). */
1729
+ /** Current bloom settings (tint is a copied `Vec3`). */
1694
1730
  getBloomOptions() {
1695
1731
  const b = this.bloomSettings;
1696
1732
  return {
1697
1733
  enabled: b.enabled,
1698
1734
  threshold: b.threshold,
1699
- knee: b.knee,
1700
- radius: b.radius,
1735
+ scatter: b.scatter,
1701
1736
  color: new Vec3(b.color.x, b.color.y, b.color.z),
1702
1737
  intensity: b.intensity,
1703
- clamp: b.clamp,
1704
1738
  };
1705
1739
  }
1706
1740
  getViewTransformOptions() {
@@ -1798,16 +1832,11 @@ export class Engine {
1798
1832
  * a background image or a backdrop video, which arrived with grain of their
1799
1833
  * own and would be graded rather than matched by a second helping.
1800
1834
  *
1801
- * `animated` false freezes it. A still photograph's grain does not move, and
1802
- * noise crawling over a frozen picture makes the rendering look more alive
1803
- * than the thing it is standing in.
1804
- *
1805
1835
  * Costs one hash per pixel in a pass that already runs, and nothing at all at
1806
1836
  * zero — the branch is on a uniform.
1807
1837
  */
1808
- setFilmGrain(amount, animated = true) {
1838
+ setFilmGrain(amount) {
1809
1839
  this.grain.amount = Math.min(Math.max(amount, 0), 1);
1810
- this.grain.animated = animated;
1811
1840
  if (this.device && this.compositeUniformBuffer)
1812
1841
  this.writeCompositeViewUniforms();
1813
1842
  }
@@ -1857,10 +1886,8 @@ export class Engine {
1857
1886
  // a uniform branch that skips the pow entirely in the common case.
1858
1887
  u[1] = 1.0 / Math.max(v.gamma, 1e-4);
1859
1888
  u[2] = this.grain.amount;
1860
- // The seed. Zero means STILL: a plate that is one photograph has grain that
1861
- // does not move, and CG noise crawling over a frozen picture makes the CG
1862
- // look more alive than the footage — the opposite of the point.
1863
- 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;
1864
1891
  u[4] = b.color.x;
1865
1892
  u[5] = b.color.y;
1866
1893
  u[6] = b.color.z;
@@ -1872,7 +1899,7 @@ export class Engine {
1872
1899
  // exposure and view transform as the scene — a sun rolls off like a sun).
1873
1900
  // The camera basis at u[12..23] is refreshed per frame.
1874
1901
  // In modes 2 and 3 the colour slot is dead, so mode 3 carries the world
1875
- // 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
1876
1903
  // scales the irradiance. A sky you can see at full brightness while it
1877
1904
  // lights at two thirds is two skies.
1878
1905
  const bg = this.backgroundColor;
@@ -1888,7 +1915,7 @@ export class Engine {
1888
1915
  // rebuilt per effect, so the compiled variant IS the flag.
1889
1916
  u[11] = this.backdropEquirectView ? 2 : showingWorld ? 3 : bg ? 1 : 0;
1890
1917
  // Which display transform forms the frame (see viewTransform in composite.ts).
1891
- u[25] = v.transform === "aether-gazer" ? 3 : v.transform === "agx" ? 2 : v.transform === "standard" ? 1 : 0;
1918
+ u[25] = Engine.VIEW_TRANSFORM_ID[v.transform] ?? 0;
1892
1919
  u[26] = this.canvas.width;
1893
1920
  u[27] = this.canvas.height;
1894
1921
  // ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
@@ -2533,7 +2560,7 @@ export class Engine {
2533
2560
  this.renderMirrorBlurChain(encoder);
2534
2561
  }
2535
2562
  /**
2536
- * Fill the mirror's mip levels — the bloom pyramid's own 13-tap downsample,
2563
+ * Fill the mirror's mip levels — a 13-tap downsample (MIRROR_DOWNSAMPLE_WGSL),
2537
2564
  * one pass per level. Only when the blur dial is up: at zero the ground
2538
2565
  * samples level 0 exactly and the chain would be work nobody reads.
2539
2566
  */
@@ -2544,8 +2571,19 @@ export class Engine {
2544
2571
  const blur = Math.max(this.groundMirrorBlur, this.mirrorSurface?.blur ?? 0);
2545
2572
  if (blur <= 0 || this.mirrorMipCount < 2)
2546
2573
  return;
2574
+ if (!this.mirrorDownsamplePipeline) {
2575
+ const module = this.device.createShaderModule({ label: "mirror downsample", code: MIRROR_DOWNSAMPLE_WGSL });
2576
+ this.mirrorDownsamplePipeline = this.device.createRenderPipeline({
2577
+ label: "mirror downsample pipeline",
2578
+ layout: "auto",
2579
+ vertex: { module, entryPoint: "vs" },
2580
+ fragment: { module, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
2581
+ primitive: { topology: "triangle-list" },
2582
+ });
2583
+ }
2584
+ const downsample = this.mirrorDownsamplePipeline;
2547
2585
  if (!this.mirrorBlurBindGroups) {
2548
- const layout = this.bloomDownsamplePipeline.getBindGroupLayout(0);
2586
+ const layout = downsample.getBindGroupLayout(0);
2549
2587
  this.mirrorBlurBindGroups = [];
2550
2588
  for (let i = 1; i < this.mirrorMipCount; i++) {
2551
2589
  this.mirrorBlurBindGroups.push(this.device.createBindGroup({
@@ -2563,7 +2601,7 @@ export class Engine {
2563
2601
  label: `mirror blur ${i}`,
2564
2602
  colorAttachments: [{ view: this.mirrorMipViews[i], loadOp: "clear", storeOp: "store" }],
2565
2603
  });
2566
- p.setPipeline(this.bloomDownsamplePipeline);
2604
+ p.setPipeline(downsample);
2567
2605
  p.setBindGroup(0, this.mirrorBlurBindGroups[i - 1]);
2568
2606
  p.draw(3);
2569
2607
  p.end();
@@ -2716,7 +2754,6 @@ export class Engine {
2716
2754
  { binding: 2, resource: this.bloomSampler },
2717
2755
  { binding: 3, resource: { buffer: this.compositeUniformBuffer } },
2718
2756
  { binding: 4, resource: this.maskResolveView },
2719
- { binding: 5, resource: this.filmicLutView },
2720
2757
  // Whichever equirect is SHOWING — the backdrop if there is one, the
2721
2758
  // world otherwise. The world's light does not come through here; it
2722
2759
  // rides worldSH into the material shells.
@@ -2724,7 +2761,6 @@ export class Engine {
2724
2761
  { binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
2725
2762
  { binding: 8, resource: this.depthReadView },
2726
2763
  { binding: 9, resource: { buffer: this.dofUniformBuffer } },
2727
- { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
2728
2764
  { binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
2729
2765
  { binding: 11, resource: { buffer: this.castBuffer } },
2730
2766
  { binding: 13, resource: { buffer: this.audioBuffer } },
@@ -3148,8 +3184,6 @@ export class Engine {
3148
3184
  { binding: 31, resource: layers[2] },
3149
3185
  { binding: 32, resource: layers[3] },
3150
3186
  { binding: 2, resource: this.bloomSampler },
3151
- { binding: 5, resource: this.filmicLutView },
3152
- { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
3153
3187
  { binding: 12, resource: (this.stageGradeTexture ?? this.stageGradeFallback).createView({ dimension: "3d" }) },
3154
3188
  { binding: 1, resource: bloom },
3155
3189
  { binding: 4, resource: this.maskResolveView },
@@ -3263,7 +3297,7 @@ export class Engine {
3263
3297
  * whether or not it is the thing you see — and it IS the thing you see until
3264
3298
  * a backdrop is set, which is what an HDRI on its own has always done.
3265
3299
  *
3266
- * `strength` is Blender's world-strength dial and is folded into the
3300
+ * `strength` is the World's intensity and is folded into the
3267
3301
  * coefficients, so what lights her is what you see.
3268
3302
  */
3269
3303
  setWorldEquirect(source) {
@@ -3326,8 +3360,11 @@ export class Engine {
3326
3360
  }
3327
3361
  this.device.queue.writeTexture({ texture: tex, mipLevel: level }, packHalf(data), { bytesPerRow: w * 8, rowsPerImage: h }, [w, h]);
3328
3362
  }
3329
- this.worldEquirectTexture = tex;
3330
- this.worldEquirectView = tex.createView();
3363
+ // The box chain above is the SOURCE: the levels the materials read are
3364
+ // the GGX convolution of it, one roughness per level (prefilterSky).
3365
+ const filtered = this.prefilterSky(tex, source.width, source.height, levels);
3366
+ this.worldEquirectTexture = filtered;
3367
+ this.worldEquirectView = filtered.createView();
3331
3368
  // The sky lights the scene, not only backs it. The sun keeps the toon
3332
3369
  // ramp — this is the ambient term, exactly where the flat world colour
3333
3370
  // used to sit.
@@ -3348,6 +3385,9 @@ export class Engine {
3348
3385
  this.worldBindingsDirty = true;
3349
3386
  this.rebuildPerFrameBindGroups();
3350
3387
  this.rebuildCompositeBindGroup();
3388
+ // The probe's compose step binds the sky it lays in behind the stage.
3389
+ this.dropProbeCapture();
3390
+ this.captureReflectionProbe();
3351
3391
  if (this.device && this.compositeUniformBuffer)
3352
3392
  this.writeCompositeViewUniforms();
3353
3393
  }
@@ -3400,8 +3440,20 @@ export class Engine {
3400
3440
  if (this.device && this.compositeUniformBuffer)
3401
3441
  this.writeCompositeViewUniforms();
3402
3442
  }
3403
- makeCompositePipeline(module, applyGamma, label) {
3404
- return this.device.createRenderPipeline({
3443
+ /**
3444
+ * The scene composite's pair (gamma = 1, gamma ≠ 1), async and from the
3445
+ * shader cache. Its source depends only on which field mounts the scene
3446
+ * samples, so there are four at most, and an effect install that keeps the
3447
+ * mounts it had gets the pair already built.
3448
+ */
3449
+ compositePipelines(hasBackground, hasForeground) {
3450
+ const code = buildCompositeShader(hasBackground || hasForeground
3451
+ ? // No wgsl and so no trails: the composite hosts no effect source at
3452
+ // all, it only decides whether to sample the layer the field pass drew.
3453
+ { wgsl: "", paramsDecl: "", hasBackground, hasForeground, gridSize: 0, trailCount: 0 }
3454
+ : null);
3455
+ const module = this.cachedShaderModule(code, "composite shader");
3456
+ const make = (applyGamma, label) => this.cachedRenderPipeline({
3405
3457
  label,
3406
3458
  layout: this.compositePipelineLayout,
3407
3459
  vertex: { module, entryPoint: "vs" },
@@ -3413,6 +3465,7 @@ export class Engine {
3413
3465
  },
3414
3466
  primitive: { topology: "triangle-list" },
3415
3467
  });
3468
+ return Promise.all([make(false, "composite pipeline (gamma=1)"), make(true, "composite pipeline (gamma!=1)")]);
3416
3469
  }
3417
3470
  /**
3418
3471
  * Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
@@ -3641,12 +3694,11 @@ export class Engine {
3641
3694
  const fieldEffect = hasBackground || hasForeground
3642
3695
  ? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length, filter, additiveLayer: d.additiveLayer }
3643
3696
  : null;
3644
- const source = buildCompositeShader(fieldEffect);
3645
- this.device.pushErrorScope("validation");
3646
- const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source });
3647
- const scopeErr = await this.device.popErrorScope();
3648
- if (scopeErr)
3649
- return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration, readsCast: false };
3697
+ // No composite is built here. One used to be — a module and two pipelines
3698
+ // per effect, then dropped: the composite is static (no user code reaches
3699
+ // it) and setEffects builds the scene's one after the list is in. Those
3700
+ // two compiles were most of a second each and validated nothing an author
3701
+ // wrote.
3650
3702
  // Declared like every other mount property: by what the source says, not by
3651
3703
  // a setting somewhere else that an author cannot see from the file.
3652
3704
  const layerBlend = d.additiveLayer
@@ -3657,73 +3709,56 @@ export class Engine {
3657
3709
  const fieldSource = buildFieldShader({ ...fieldEffect, ids: mrtIdsEnabled() });
3658
3710
  const userLineOffset = fieldSource.slice(0, fieldSource.indexOf(wgsl)).split("\n").length - 1;
3659
3711
  this.device.pushErrorScope("validation");
3660
- const fieldModule = this.device.createShaderModule({ label: "field shader (effect)", code: fieldSource });
3661
- const info = await fieldModule.getCompilationInfo();
3662
- const fieldScopeErr = await this.device.popErrorScope();
3712
+ const fieldModule = this.cachedShaderModule(fieldSource, "field shader (effect)");
3713
+ const fieldScope = this.device.popErrorScope();
3714
+ // Started before the diagnostics are read, which only decide whether it
3715
+ // is kept: the compile and the check overlap.
3716
+ const fieldBuild = this.cachedRenderPipeline({
3717
+ label: "field layer pipeline",
3718
+ layout: this.fieldPipelineLayout,
3719
+ vertex: { module: fieldModule, entryPoint: "fieldVs" },
3720
+ fragment: {
3721
+ module: fieldModule,
3722
+ entryPoint: "fieldFs",
3723
+ // OVER, accumulating PREMULTIPLIED colour: several effects draw into
3724
+ // these two targets in document order, and each must layer onto what
3725
+ // the earlier ones left rather than replace it. src-alpha on colour
3726
+ // premultiplies as it writes; alpha accumulates as one-over. An
3727
+ // author still returns STRAIGHT colour+alpha, exactly as before —
3728
+ // the premultiplication happens here, and the composite reads it
3729
+ // back knowing that. With one effect over a cleared target the
3730
+ // result is identical to the replace it used to do.
3731
+ // A filter composes over the layers in its own shader and writes
3732
+ // the result outright: blending would layer it onto a page that
3733
+ // holds nothing.
3734
+ targets: filter
3735
+ ? [{ format: "rgba16float" }, { format: "rgba16float" }]
3736
+ : [
3737
+ { format: "rgba16float", blend: layerBlend },
3738
+ { format: "rgba16float", blend: layerBlend },
3739
+ ],
3740
+ },
3741
+ primitive: { topology: "triangle-list" },
3742
+ multisample: { count: 1 },
3743
+ });
3744
+ fieldBuild.catch(() => { });
3745
+ const [info, fieldScopeErr] = await Promise.all([fieldModule.getCompilationInfo(), fieldScope]);
3663
3746
  const diagnostics = info.messages
3664
3747
  .filter((m) => m.type === "error")
3665
3748
  .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`);
3666
3749
  if (diagnostics.length === 0 && fieldScopeErr)
3667
3750
  diagnostics.push(fieldScopeErr.message);
3668
- if (diagnostics.length > 0)
3751
+ if (diagnostics.length > 0) {
3752
+ this.forgetShaderModule(fieldSource);
3669
3753
  return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false };
3754
+ }
3670
3755
  try {
3671
- fieldPipeline = await this.device.createRenderPipelineAsync({
3672
- label: "field layer pipeline",
3673
- layout: this.fieldPipelineLayout,
3674
- vertex: { module: fieldModule, entryPoint: "fieldVs" },
3675
- fragment: {
3676
- module: fieldModule,
3677
- entryPoint: "fieldFs",
3678
- // OVER, accumulating PREMULTIPLIED colour: several effects draw into
3679
- // these two targets in document order, and each must layer onto what
3680
- // the earlier ones left rather than replace it. src-alpha on colour
3681
- // premultiplies as it writes; alpha accumulates as one-over. An
3682
- // author still returns STRAIGHT colour+alpha, exactly as before —
3683
- // the premultiplication happens here, and the composite reads it
3684
- // back knowing that. With one effect over a cleared target the
3685
- // result is identical to the replace it used to do.
3686
- // A filter composes over the layers in its own shader and writes
3687
- // the result outright: blending would layer it onto a page that
3688
- // holds nothing.
3689
- targets: filter
3690
- ? [{ format: "rgba16float" }, { format: "rgba16float" }]
3691
- : [
3692
- { format: "rgba16float", blend: layerBlend },
3693
- { format: "rgba16float", blend: layerBlend },
3694
- ],
3695
- },
3696
- primitive: { topology: "triangle-list" },
3697
- multisample: { count: 1 },
3698
- });
3756
+ fieldPipeline = await fieldBuild;
3699
3757
  }
3700
3758
  catch (e) {
3701
3759
  return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false };
3702
3760
  }
3703
3761
  }
3704
- let identity;
3705
- let gamma;
3706
- try {
3707
- const make = (applyGamma, label) => this.device.createRenderPipelineAsync({
3708
- label,
3709
- layout: this.compositePipelineLayout,
3710
- vertex: { module, entryPoint: "vs" },
3711
- fragment: {
3712
- module,
3713
- entryPoint: "fs",
3714
- constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
3715
- targets: [{ format: this.presentationFormat }],
3716
- },
3717
- primitive: { topology: "triangle-list" },
3718
- });
3719
- [identity, gamma] = await Promise.all([
3720
- make(false, "composite pipeline (effect, gamma=1)"),
3721
- make(true, "composite pipeline (effect, gamma!=1)"),
3722
- ]);
3723
- }
3724
- catch (e) {
3725
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration, readsCast: false };
3726
- }
3727
3762
  // Built BEFORE the swap: a particle stage that fails to compile has to leave
3728
3763
  // the previously installed effect running, exactly as a bad composite does.
3729
3764
  // A stage that fails after an earlier one succeeded would otherwise strand
@@ -3950,6 +3985,9 @@ export class Engine {
3950
3985
  }
3951
3986
  const requested = list ?? [];
3952
3987
  if (requested.length === 0) {
3988
+ // Built (or found) before anything is torn down, so no frame draws
3989
+ // between the old effects and the composite that no longer samples them.
3990
+ const [identity, gamma] = await this.compositePipelines(false, false);
3953
3991
  for (const e of this.effects) {
3954
3992
  e.paramsBuffer?.destroy();
3955
3993
  e.lights?.uniform.destroy();
@@ -3962,9 +4000,8 @@ export class Engine {
3962
4000
  this.allocateLightSlots();
3963
4001
  this.anchorTable = EMPTY_ANCHOR_TABLE;
3964
4002
  this.clearTrailHistory();
3965
- const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) });
3966
- this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)");
3967
- this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)");
4003
+ this.compositePipelineIdentity = identity;
4004
+ this.compositePipelineGamma = gamma;
3968
4005
  this.rebuildCompositeBindGroup();
3969
4006
  this.writeCompositeViewUniforms();
3970
4007
  return [];
@@ -4010,6 +4047,10 @@ export class Engine {
4010
4047
  if (r)
4011
4048
  r.diagnostics.push(`anchor "${d.bone}" dropped: the scene is already using all ${MAX_EFFECT_ANCHORS} slots`);
4012
4049
  }
4050
+ // The composite for the new list, built before the swap below so the swap
4051
+ // stays one synchronous step. From the shader cache: it was rebuilt, and
4052
+ // synchronously, on every install.
4053
+ const [compositeIdentity, compositeGamma] = await this.compositePipelines(instances.some((e) => e.hasBackground), instances.some((e) => e.hasForeground));
4013
4054
  // ── Swap. Everything above either succeeded or was excluded, so the scene
4014
4055
  // that was running is only torn down now.
4015
4056
  for (const e of this.effects) {
@@ -4060,18 +4101,8 @@ export class Engine {
4060
4101
  // Scene-level, from the union: the composite decides only whether to SAMPLE
4061
4102
  // the field layers, so one effect with a background is enough to turn that
4062
4103
  // on for the frame.
4063
- const hasBackground = instances.some((e) => e.hasBackground);
4064
- const hasForeground = instances.some((e) => e.hasForeground);
4065
- const compositeModule = this.device.createShaderModule({
4066
- label: "composite shader (effects)",
4067
- code: buildCompositeShader(hasBackground || hasForeground
4068
- ? // No wgsl and so no trails: the composite hosts no effect source at
4069
- // all, it only decides whether to sample the layer the field pass drew.
4070
- { wgsl: "", paramsDecl: "", hasBackground, hasForeground, gridSize: 0, trailCount: 0 }
4071
- : null),
4072
- });
4073
- this.compositePipelineIdentity = this.makeCompositePipeline(compositeModule, false, "composite pipeline (gamma=1)");
4074
- this.compositePipelineGamma = this.makeCompositePipeline(compositeModule, true, "composite pipeline (gamma!=1)");
4104
+ this.compositePipelineIdentity = compositeIdentity;
4105
+ this.compositePipelineGamma = compositeGamma;
4075
4106
  // Nothing to promote any more: `#fullres` is per effect, read into
4076
4107
  // fieldLayer when the instance is built, and both target pairs exist for
4077
4108
  // the life of the surface. What used to be a scene-wide decision made here
@@ -4163,26 +4194,33 @@ export class Engine {
4163
4194
  trailCount: anchors.filter((x) => x.trail).length,
4164
4195
  alias,
4165
4196
  };
4197
+ // The scope is popped before the first await: compiles run side by side
4198
+ // (both modules here, other effects, style groups), and a scope held open
4199
+ // across an await would catch whichever of them errored first.
4166
4200
  const compile = async (code, label) => {
4167
4201
  const offset = code.slice(0, code.indexOf(wgsl)).split("\n").length - 1;
4168
4202
  this.device.pushErrorScope("validation");
4169
- const module = this.device.createShaderModule({ label, code });
4170
- const info = await module.getCompilationInfo();
4171
- const scopeErr = await this.device.popErrorScope();
4203
+ const module = this.cachedShaderModule(code, label);
4204
+ const scope = this.device.popErrorScope();
4205
+ const [info, scopeErr] = await Promise.all([module.getCompilationInfo(), scope]);
4172
4206
  const diagnostics = info.messages
4173
4207
  .filter((m) => m.type === "error")
4174
4208
  .map((m) => `${Math.max(0, m.lineNum - offset)}:${m.linePos} ${m.message}`);
4175
4209
  if (diagnostics.length === 0 && scopeErr)
4176
4210
  diagnostics.push(scopeErr.message);
4211
+ if (diagnostics.length)
4212
+ this.forgetShaderModule(code);
4177
4213
  return diagnostics.length ? diagnostics : module;
4178
4214
  };
4179
- const computeModule = await compile(buildParticleComputeShader(src, cast), "particle compute");
4215
+ const [computeModule, renderModule] = await Promise.all([
4216
+ compile(buildParticleComputeShader(src, cast), "particle compute"),
4217
+ compile(buildParticleRenderShader(src, cast), "particle render"),
4218
+ ]);
4180
4219
  if (Array.isArray(computeModule)) {
4181
4220
  points?.buffer.destroy();
4182
4221
  dropTextures();
4183
4222
  return { ok: false, diagnostics: computeModule };
4184
4223
  }
4185
- const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render");
4186
4224
  if (Array.isArray(renderModule)) {
4187
4225
  points?.buffer.destroy();
4188
4226
  dropTextures();
@@ -4220,7 +4258,7 @@ export class Engine {
4220
4258
  // Declaring one set of flags for both layouts is what made the pipeline
4221
4259
  // layout invalid, and the error surfaces later and unhelpfully as "invalid
4222
4260
  // due to a previous error".
4223
- const layoutFor = (storage, visibility, shadow, withIndirect) => this.device.createBindGroupLayout({
4261
+ const layoutFor = (storage, visibility, shadow, withIndirect) => this.cachedBindGroupLayout({
4224
4262
  entries: [
4225
4263
  { binding: 0, visibility, buffer: { type: storage } },
4226
4264
  { binding: 1, visibility, buffer: { type: "uniform" } },
@@ -4326,31 +4364,29 @@ export class Engine {
4326
4364
  // blends would be a copy.
4327
4365
  const targets = sceneTargetsFor(src.blend === "additive" ? "particle-additive" : "particle", this.sceneFormats);
4328
4366
  const cutout = src.blend === "cutout";
4367
+ // ALL STAGES AT ONCE: compute, count, prepass and shading compile side by
4368
+ // side rather than in turn. The scope closes before the first await, for
4369
+ // the reason compile() gives.
4329
4370
  this.device.pushErrorScope("validation");
4330
- try {
4331
- const computePipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] });
4332
- const compute = await this.device.createComputePipelineAsync({
4371
+ const renderLayoutObj = this.cachedPipelineLayout([renderLayout]);
4372
+ const builds = Promise.all([
4373
+ this.cachedComputePipeline({
4333
4374
  label: "particle compute pipeline",
4334
- layout: computePipelineLayout,
4375
+ layout: this.cachedPipelineLayout([computeLayout]),
4335
4376
  compute: { module: computeModule, entryPoint: "main" },
4336
- });
4337
- const live = indirect && countLayout
4338
- ? {
4339
- pipeline: await this.device.createComputePipelineAsync({
4340
- label: "particle count pipeline",
4341
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [countLayout] }),
4342
- compute: { module: computeModule, entryPoint: "rzCount" },
4343
- }),
4344
- indirect,
4345
- binds: bindFor(countLayout, this.cameraUniformBuffer, false, true),
4346
- }
4347
- : null;
4348
- const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] });
4377
+ }),
4378
+ indirect && countLayout
4379
+ ? this.cachedComputePipeline({
4380
+ label: "particle count pipeline",
4381
+ layout: this.cachedPipelineLayout([countLayout]),
4382
+ compute: { module: computeModule, entryPoint: "rzCount" },
4383
+ })
4384
+ : null,
4349
4385
  // The prepass, when the effect gave the cutout a cheap shape: coverage to
4350
4386
  // depth, colour targets at writeMask 0 (the depth-prepass class), and
4351
4387
  // the shading pass below then tests EQUAL and writes no depth.
4352
- const depth = prepass
4353
- ? await this.device.createRenderPipelineAsync({
4388
+ prepass
4389
+ ? this.cachedRenderPipeline({
4354
4390
  label: "particle depth prepass pipeline",
4355
4391
  layout: renderLayoutObj,
4356
4392
  vertex: { module: renderModule, entryPoint: "vs" },
@@ -4359,8 +4395,8 @@ export class Engine {
4359
4395
  depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
4360
4396
  multisample: { count: Engine.MULTISAMPLE_COUNT },
4361
4397
  })
4362
- : null;
4363
- const render = await this.device.createRenderPipelineAsync({
4398
+ : null,
4399
+ this.cachedRenderPipeline({
4364
4400
  label: "particle render pipeline",
4365
4401
  layout: renderLayoutObj,
4366
4402
  vertex: { module: renderModule, entryPoint: "vs" },
@@ -4379,10 +4415,25 @@ export class Engine {
4379
4415
  // shading ten layers and shading about three.
4380
4416
  depthStencil: prepass
4381
4417
  ? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
4382
- : { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
4418
+ : d.depthAlways
4419
+ ? // `#depth always`: over the scene, Unity's ZTest Always (a moon's halo
4420
+ // drawn in front of the sky dome it sits behind)
4421
+ { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "always" }
4422
+ : { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
4383
4423
  multisample: { count: Engine.MULTISAMPLE_COUNT },
4384
- });
4385
- const scoped = await this.device.popErrorScope();
4424
+ }),
4425
+ ]);
4426
+ const scope = this.device.popErrorScope();
4427
+ try {
4428
+ const [compute, countPipeline, depth, render] = await builds;
4429
+ const live = indirect && countLayout && countPipeline
4430
+ ? {
4431
+ pipeline: countPipeline,
4432
+ indirect,
4433
+ binds: bindFor(countLayout, this.cameraUniformBuffer, false, true),
4434
+ }
4435
+ : null;
4436
+ const scoped = await scope;
4386
4437
  if (scoped) {
4387
4438
  buffer.destroy();
4388
4439
  uniform.destroy();
@@ -4423,7 +4474,7 @@ export class Engine {
4423
4474
  };
4424
4475
  }
4425
4476
  catch (e) {
4426
- await this.device.popErrorScope();
4477
+ await scope;
4427
4478
  buffer.destroy();
4428
4479
  uniform.destroy();
4429
4480
  points?.buffer.destroy();
@@ -4795,8 +4846,10 @@ export class Engine {
4795
4846
  // point: a layer composited after tone mapping can never bloom.
4796
4847
  //
4797
4848
  // Additive, where this used to be MAX. Max was right for a post-tonemap
4798
- // layer; in HDR before bloom, overlapping light sums.
4799
- const targets = sceneTargetsFor("trail", this.sceneFormats);
4849
+ // layer; in HDR before bloom, overlapping light sums. `#blend over` lays the
4850
+ // ribbon over the scene by its alpha instead — a game trail's own blend,
4851
+ // under which its dark smoke darkens rather than vanishing.
4852
+ const targets = sceneTargetsFor(d.trailBlend === "over" ? "trail-over" : "trail", this.sceneFormats);
4800
4853
  this.device.pushErrorScope("validation");
4801
4854
  try {
4802
4855
  const pipeline = await this.device.createRenderPipelineAsync({
@@ -5660,10 +5713,8 @@ export class Engine {
5660
5713
  b.enabled = patch.enabled;
5661
5714
  if (patch.threshold !== undefined)
5662
5715
  b.threshold = patch.threshold;
5663
- if (patch.knee !== undefined)
5664
- b.knee = patch.knee;
5665
- if (patch.radius !== undefined)
5666
- b.radius = patch.radius;
5716
+ if (patch.scatter !== undefined)
5717
+ b.scatter = patch.scatter;
5667
5718
  if (patch.color !== undefined) {
5668
5719
  b.color.x = patch.color.x;
5669
5720
  b.color.y = patch.color.y;
@@ -5671,33 +5722,156 @@ export class Engine {
5671
5722
  }
5672
5723
  if (patch.intensity !== undefined)
5673
5724
  b.intensity = patch.intensity;
5674
- if (patch.clamp !== undefined)
5675
- b.clamp = patch.clamp;
5676
- if (this.device && this.bloomBlitUniformBuffer) {
5725
+ if (this.device && this.compositeUniformBuffer) {
5677
5726
  this.writeBloomUniforms();
5678
5727
  this.writeCompositeViewUniforms();
5679
5728
  }
5680
5729
  }
5681
- // EEVEE prefilter uniforms (blit stage) + upsample sample scale. Intensity/tint live in composite.
5730
+ ensureBloomPipelines() {
5731
+ // The buffer, not a pipeline, says "already built": at init the pipelines
5732
+ // land async (initRenderPipeline) and are absent for a moment.
5733
+ if (this.bloomUniformBuffer)
5734
+ return;
5735
+ const device = this.device;
5736
+ this.bloomUniformBuffer = device.createBuffer({
5737
+ label: "bloomuniforms",
5738
+ size: 16,
5739
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
5740
+ });
5741
+ this.bloomUpsampleBindGroupLayout = device.createBindGroupLayout({
5742
+ label: "bloomupsample layout",
5743
+ entries: [
5744
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
5745
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
5746
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
5747
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
5748
+ ],
5749
+ });
5750
+ const pipeline = (label, code, layout, assign) => {
5751
+ const module = device.createShaderModule({ label, code });
5752
+ this.initRenderPipeline({
5753
+ label,
5754
+ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
5755
+ vertex: { module, entryPoint: "vs" },
5756
+ fragment: { module, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
5757
+ primitive: { topology: "triangle-list" },
5758
+ }, assign);
5759
+ };
5760
+ pipeline("bloomprefilter", BLOOM_PREFILTER_SHADER_WGSL, this.bloomPrefilterBindGroupLayout, (p) => (this.bloomPrefilterPipeline = p));
5761
+ pipeline("bloomblur H", BLOOM_BLUR_H_SHADER_WGSL, this.bloomBlurBindGroupLayout, (p) => (this.bloomBlurHPipeline = p));
5762
+ pipeline("bloomblur V", BLOOM_BLUR_V_SHADER_WGSL, this.bloomBlurBindGroupLayout, (p) => (this.bloomBlurVPipeline = p));
5763
+ pipeline("bloomupsample", BLOOM_UPSAMPLE_SHADER_WGSL, this.bloomUpsampleBindGroupLayout, (p) => (this.bloomUpsamplePipeline = p));
5764
+ }
5765
+ /** The game's chain at the current canvas size (AGSimPostFX.Bloom): half
5766
+ * size, then floor(log2(longest side)) - 1 levels, each half the last. */
5767
+ buildBloomTargets() {
5768
+ if (!this.device || !this.hdrResolveTexture || !this.maskResolveView || !this.bloomPrefilterBindGroupLayout)
5769
+ return;
5770
+ this.ensureBloomPipelines();
5771
+ const w0 = Math.max(1, Math.floor(this.hdrResolveTexture.width / 2));
5772
+ const h0 = Math.max(1, Math.floor(this.hdrResolveTexture.height / 2));
5773
+ const levels = Math.min(Engine.BLOOM_MAX_LEVELS, Math.max(1, Math.floor(Math.log2(Math.max(w0, h0)) - 1)));
5774
+ // Mip chains, whose sizes are floor-halved exactly as the game's targets are.
5775
+ const make = (label) => this.device.createTexture({
5776
+ label,
5777
+ size: [w0, h0],
5778
+ mipLevelCount: levels,
5779
+ format: this.hdrFormat,
5780
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
5781
+ });
5782
+ this.bloomDownTexture?.destroy();
5783
+ this.bloomUpTexture?.destroy();
5784
+ this.bloomDownTexture = make("bloomdown");
5785
+ this.bloomUpTexture = make("bloomup");
5786
+ this.bloomLevels = levels;
5787
+ const views = (t) => Array.from({ length: levels }, (_, i) => t.createView({ baseMipLevel: i, mipLevelCount: 1 }));
5788
+ this.bloomDownViews = views(this.bloomDownTexture);
5789
+ this.bloomUpViews = views(this.bloomUpTexture);
5790
+ this.writeBloomUniforms();
5791
+ const uniform = { buffer: this.bloomUniformBuffer };
5792
+ this.bloomPrefilterBindGroup = this.device.createBindGroup({
5793
+ label: "bloomprefilter",
5794
+ layout: this.bloomPrefilterBindGroupLayout,
5795
+ entries: [
5796
+ { binding: 0, resource: this.hdrResolveTexture.createView() },
5797
+ { binding: 1, resource: uniform },
5798
+ { binding: 2, resource: this.maskResolveView },
5799
+ ],
5800
+ });
5801
+ const blur = (label, src) => this.device.createBindGroup({
5802
+ label,
5803
+ layout: this.bloomBlurBindGroupLayout,
5804
+ entries: [
5805
+ { binding: 0, resource: src },
5806
+ { binding: 1, resource: this.bloomSampler },
5807
+ ],
5808
+ });
5809
+ // [i - 1] is level i: blurH reads down[i-1] into up[i], blurV up[i] into down[i].
5810
+ this.bloomBlurHBindGroups = [];
5811
+ this.bloomBlurVBindGroups = [];
5812
+ for (let i = 1; i < levels; i++) {
5813
+ this.bloomBlurHBindGroups.push(blur(`bloomblur H ${i}`, this.bloomDownViews[i - 1]));
5814
+ this.bloomBlurVBindGroups.push(blur(`bloomblur V ${i}`, this.bloomUpViews[i]));
5815
+ }
5816
+ // [k] writes up[levels - 2 - k], coarsest first.
5817
+ this.bloomUpsampleBindGroups = [];
5818
+ for (let i = levels - 2; i >= 0; i--) {
5819
+ const low = i === levels - 2 ? this.bloomDownViews[i + 1] : this.bloomUpViews[i + 1];
5820
+ this.bloomUpsampleBindGroups.push(this.device.createBindGroup({
5821
+ label: `bloomupsample ${i}`,
5822
+ layout: this.bloomUpsampleBindGroupLayout,
5823
+ entries: [
5824
+ { binding: 0, resource: this.bloomDownViews[i] },
5825
+ { binding: 1, resource: low },
5826
+ { binding: 2, resource: this.bloomSampler },
5827
+ { binding: 3, resource: uniform },
5828
+ ],
5829
+ }));
5830
+ }
5831
+ }
5682
5832
  writeBloomUniforms() {
5833
+ if (!this.bloomUniformBuffer)
5834
+ return;
5683
5835
  const b = this.bloomSettings;
5684
- const bu = this.bloomBlitUniformData;
5685
- // EEVEE prefilter: threshold, knee_half, clamp (0 → disabled), _unused
5686
- // Blender halves the knee before passing to the shader (eevee_bloom.c: knee * 0.5f).
5687
- // The blit shader's quadratic soft-knee curve uses knee_half as the offset from threshold,
5688
- // so the soft ramp spans [threshold - knee/2 .. threshold + knee/2] — NOT [threshold - knee .. threshold + knee].
5689
- bu[0] = b.threshold;
5690
- bu[1] = b.knee * 0.5;
5691
- bu[2] = b.clamp;
5692
- bu[3] = 0.0;
5693
- this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu);
5694
- const us = this.bloomUpsampleUniformData;
5695
- // Blender: bloom.radius directly controls the tent-filter sample scale in texel units.
5696
- us[0] = Math.max(0.5, b.radius);
5697
- us[1] = 0;
5698
- us[2] = 0;
5699
- us[3] = 0;
5700
- this.device.queue.writeBuffer(this.bloomUpsampleUniformBuffer, 0, us);
5836
+ const u = this.bloomUniformData;
5837
+ // Threshold, its soft knee (half of it, as the game derives it), the clamp,
5838
+ // and scatter. Tint and intensity live in the composite.
5839
+ u[0] = Math.max(0, b.threshold);
5840
+ u[1] = Math.max(0, b.threshold) * 0.5;
5841
+ u[2] = Engine.BLOOM_CLAMP;
5842
+ // URP's mapping: never all the way to the widest level, which alone is a
5843
+ // few pixels smeared over the screen — the glow would vanish.
5844
+ u[3] = 0.05 + 0.9 * Math.min(1, Math.max(0, b.scatter));
5845
+ this.device.queue.writeBuffer(this.bloomUniformBuffer, 0, u);
5846
+ }
5847
+ /** The game's chain, pass for pass (AGSimPostFX.Bloom). */
5848
+ renderBloom(encoder) {
5849
+ const levels = this.bloomLevels;
5850
+ const att = this.bloomPassDescriptor.colorAttachments[0];
5851
+ const total = 1 + 3 * (levels - 1);
5852
+ let n = 0;
5853
+ const draw = (target, pipeline, group) => {
5854
+ att.view = target;
5855
+ // One timing span for the whole chain, as for the pyramid.
5856
+ const open = n === 0 ? this.stampOpen("bloom") : undefined;
5857
+ const close = n === total - 1 ? this.stampClose("bloom") : undefined;
5858
+ this.bloomPassDescriptor.timestampWrites = open && close ? { ...open, ...close } : (open ?? close);
5859
+ n++;
5860
+ const p = encoder.beginRenderPass(this.bloomPassDescriptor);
5861
+ p.setPipeline(pipeline);
5862
+ p.setBindGroup(0, group);
5863
+ p.draw(3);
5864
+ p.end();
5865
+ };
5866
+ draw(this.bloomDownViews[0], this.bloomPrefilterPipeline, this.bloomPrefilterBindGroup);
5867
+ for (let i = 1; i < levels; i++) {
5868
+ draw(this.bloomUpViews[i], this.bloomBlurHPipeline, this.bloomBlurHBindGroups[i - 1]);
5869
+ draw(this.bloomDownViews[i], this.bloomBlurVPipeline, this.bloomBlurVBindGroups[i - 1]);
5870
+ }
5871
+ for (let k = 0; k < levels - 1; k++) {
5872
+ draw(this.bloomUpViews[levels - 2 - k], this.bloomUpsamplePipeline, this.bloomUpsampleBindGroups[k]);
5873
+ }
5874
+ this.bloomPassDescriptor.timestampWrites = undefined;
5701
5875
  }
5702
5876
  // Step 1: Get WebGPU device and context
5703
5877
  async init() {
@@ -5822,7 +5996,17 @@ export class Engine {
5822
5996
  this.setupCamera();
5823
5997
  this.setupLighting();
5824
5998
  const tPipelines = performance.now();
5999
+ // Built async and side by side (see initRenderPipeline): a synchronous
6000
+ // build holds the GPU process for the whole compile, one after another,
6001
+ // which was a 2–3 s frozen frame on a cold open.
6002
+ this.initPipelineJobs = [];
5825
6003
  this.createPipelines();
6004
+ try {
6005
+ await Promise.all(this.initPipelineJobs);
6006
+ }
6007
+ finally {
6008
+ this.initPipelineJobs = null;
6009
+ }
5826
6010
  this.setupResize();
5827
6011
  const ms = (a, b) => Math.round(b - a);
5828
6012
  console.info(`[reze] init ${ms(t0, performance.now())}ms — adapter ${ms(t0, tAdapter)}ms · ` +
@@ -5864,187 +6048,12 @@ export class Engine {
5864
6048
  pass.end();
5865
6049
  this.device.queue.submit([encoder.finish()]);
5866
6050
  }
5867
- bakeBrdfLut() {
5868
- if (BRDF_LUT_SIZE !== LTC_MAG_LUT_SIZE) {
5869
- throw new Error("BRDF LUT bake requires DFG size == LTC size (both 64).");
5870
- }
5871
- // Temp rg16float LTC source — loaded 1:1 by the bake fragment shader, then dropped.
5872
- const ltcTemp = this.device.createTexture({
5873
- label: "LTC mag LUT (bake input)",
5874
- size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
5875
- format: "rg16float",
5876
- usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
5877
- });
5878
- const n = LTC_MAG_LUT_DATA.length;
5879
- const half = new Uint16Array(n);
5880
- const f32 = new Float32Array(1);
5881
- const u32 = new Uint32Array(f32.buffer);
5882
- for (let i = 0; i < n; i++) {
5883
- f32[0] = LTC_MAG_LUT_DATA[i];
5884
- const x = u32[0];
5885
- const sign = (x >>> 16) & 0x8000;
5886
- let exp = ((x >>> 23) & 0xff) - 127 + 15;
5887
- const mant = x & 0x7fffff;
5888
- if (exp <= 0) {
5889
- half[i] = sign;
5890
- }
5891
- else if (exp >= 31) {
5892
- half[i] = sign | 0x7c00;
5893
- }
5894
- else {
5895
- half[i] = sign | (exp << 10) | (mant >>> 13);
5896
- }
5897
- }
5898
- 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 });
5899
- this.brdfLutTexture = this.device.createTexture({
5900
- label: "BRDF LUT (DFG + LTC packed)",
5901
- size: [BRDF_LUT_SIZE, BRDF_LUT_SIZE],
5902
- format: "rgba8unorm",
5903
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
5904
- });
5905
- this.brdfLutView = this.brdfLutTexture.createView();
5906
- const module = this.device.createShaderModule({ label: "BRDF LUT bake", code: BRDF_LUT_BAKE_WGSL });
5907
- const pipeline = this.device.createRenderPipeline({
5908
- label: "BRDF LUT bake pipeline",
5909
- layout: "auto",
5910
- vertex: { module, entryPoint: "vs" },
5911
- fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
5912
- primitive: { topology: "triangle-list" },
5913
- });
5914
- const bakeBindGroup = this.device.createBindGroup({
5915
- label: "BRDF LUT bake bind group",
5916
- layout: pipeline.getBindGroupLayout(0),
5917
- entries: [{ binding: 0, resource: ltcTemp.createView() }],
5918
- });
5919
- const enc = this.device.createCommandEncoder({ label: "BRDF LUT bake encoder" });
5920
- const pass = enc.beginRenderPass({
5921
- colorAttachments: [
5922
- { view: this.brdfLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
5923
- ],
5924
- });
5925
- pass.setPipeline(pipeline);
5926
- pass.setBindGroup(0, bakeBindGroup);
5927
- pass.draw(3, 1, 0, 0);
5928
- pass.end();
5929
- this.device.queue.submit([enc.finish()]);
5930
- ltcTemp.destroy();
5931
- }
5932
- // Bake the Blender 3.6 Filmic MHC tone curve into a WIDTH×1 r16float LUT sampled by the
5933
- // composite pass. The 14 anchors are the same as the old inline array; we fit a monotone
5934
- // cubic (Fritsch–Carlson) through them so the curve is C1-continuous (no Mach banding in
5935
- // smooth gradients) while still passing through every anchor (look preserved) and staying
5936
- // monotone (no tonemap overshoot/ringing). Domain is uniform in log2 space: anchor k sits
5937
- // at t=k, k=0..13 (t = log2(linear)+10). See composite.ts::filmic for the sampling map.
5938
- /**
5939
- * Decompress and upload Blender's AgX cube.
5940
- *
5941
- * Deliberately off the critical path: it is 723 KB once inflated, and a frame
5942
- * rendered before it lands should show the scene under whatever transform is
5943
- * already there rather than wait. Until then binding 10 holds a 1×1×1 stand-in,
5944
- * which is only ever sampled if someone selects AgX in that window.
5945
- */
5946
- async loadAgxLut() {
5947
- try {
5948
- const packed = Uint8Array.from(atob(AGX_LUT_GZ), (ch) => ch.charCodeAt(0));
5949
- const stream = new Blob([packed]).stream().pipeThrough(new DecompressionStream("gzip"));
5950
- const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
5951
- const n = AGX_LUT_SIZE;
5952
- if (bytes.byteLength !== n * n * n * 4)
5953
- throw new Error(`AgX LUT is ${bytes.byteLength} bytes, expected ${n ** 3 * 4}`);
5954
- const tex = this.device.createTexture({
5955
- label: "AgX 57³ LUT",
5956
- size: [n, n, n],
5957
- dimension: "3d",
5958
- format: "rgb10a2unorm",
5959
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
5960
- });
5961
- // .cube order is red fastest, which is exactly a 3D texture's own layout.
5962
- this.device.queue.writeTexture({ texture: tex }, bytes, { bytesPerRow: n * 4, rowsPerImage: n }, [n, n, n]);
5963
- this.agxLutTexture = tex;
5964
- this.rebuildCompositeBindGroup();
5965
- }
5966
- catch {
5967
- // A missing LUT costs AgX, not the renderer — the other transforms stand.
5968
- }
5969
- }
5970
- bakeFilmicLut() {
5971
- const anchors = [
5972
- 0.0028, 0.0068, 0.0151, 0.0313, 0.061, 0.112, 0.192, 0.306, 0.459, 0.631, 0.82, 0.907, 0.962, 0.989,
5973
- ];
5974
- const n = anchors.length;
5975
- // Secant slopes (unit spacing, so d_k = y_{k+1} - y_k).
5976
- const d = new Array(n - 1);
5977
- for (let k = 0; k < n - 1; k++)
5978
- d[k] = anchors[k + 1] - anchors[k];
5979
- // Endpoint + interior tangents.
5980
- const m = new Array(n);
5981
- m[0] = d[0];
5982
- m[n - 1] = d[n - 2];
5983
- for (let k = 1; k < n - 1; k++)
5984
- m[k] = (d[k - 1] + d[k]) * 0.5;
5985
- // Fritsch–Carlson monotonicity clamp.
5986
- for (let k = 0; k < n - 1; k++) {
5987
- if (d[k] === 0) {
5988
- m[k] = 0;
5989
- m[k + 1] = 0;
5990
- continue;
5991
- }
5992
- const a = m[k] / d[k];
5993
- const b = m[k + 1] / d[k];
5994
- const s = a * a + b * b;
5995
- if (s > 9) {
5996
- const tau = 3 / Math.sqrt(s);
5997
- m[k] = tau * a * d[k];
5998
- m[k + 1] = tau * b * d[k];
5999
- }
6000
- }
6001
- const W = Engine.FILMIC_LUT_WIDTH;
6002
- const values = new Float32Array(W);
6003
- for (let j = 0; j < W; j++) {
6004
- const t = ((n - 1) * j) / (W - 1); // t ∈ [0, n-1]
6005
- const k = Math.min(Math.floor(t), n - 2);
6006
- const s = t - k; // local param in [0,1], unit-spaced segment
6007
- const s2 = s * s;
6008
- const s3 = s2 * s;
6009
- // Hermite basis (h=1).
6010
- const h00 = 2 * s3 - 3 * s2 + 1;
6011
- const h10 = s3 - 2 * s2 + s;
6012
- const h01 = -2 * s3 + 3 * s2;
6013
- const h11 = s3 - s2;
6014
- values[j] = h00 * anchors[k] + h10 * m[k] + h01 * anchors[k + 1] + h11 * m[k + 1];
6015
- }
6016
- // f32 → f16 bits (same conversion as bakeBrdfLut).
6017
- const half = new Uint16Array(W);
6018
- const f32 = new Float32Array(1);
6019
- const u32 = new Uint32Array(f32.buffer);
6020
- for (let j = 0; j < W; j++) {
6021
- f32[0] = values[j];
6022
- const x = u32[0];
6023
- const sign = (x >>> 16) & 0x8000;
6024
- const exp = ((x >>> 23) & 0xff) - 127 + 15;
6025
- const mant = x & 0x7fffff;
6026
- if (exp <= 0) {
6027
- half[j] = sign;
6028
- }
6029
- else if (exp >= 31) {
6030
- half[j] = sign | 0x7c00;
6031
- }
6032
- else {
6033
- half[j] = sign | (exp << 10) | (mant >>> 13);
6034
- }
6035
- }
6036
- this.filmicLutTexture = this.device.createTexture({
6037
- label: "Filmic tone LUT",
6038
- size: [W, 1],
6039
- format: "r16float",
6040
- usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
6041
- });
6042
- this.filmicLutView = this.filmicLutTexture.createView();
6043
- this.device.queue.writeTexture({ texture: this.filmicLutTexture }, half, { bytesPerRow: W * 2, rowsPerImage: 1 }, { width: W, height: 1, depthOrArrayLayers: 1 });
6044
- }
6045
6051
  createRenderPipeline(config) {
6052
+ return this.createModelPipeline(this.renderPipelineDesc(config));
6053
+ }
6054
+ renderPipelineDesc(config) {
6046
6055
  const targets = config.fragmentTargets ?? (config.fragmentTarget ? [config.fragmentTarget] : undefined);
6047
- return this.createModelPipeline({
6056
+ return {
6048
6057
  label: config.label,
6049
6058
  layout: config.layout,
6050
6059
  vertex: {
@@ -6061,7 +6070,7 @@ export class Engine {
6061
6070
  primitive: { cullMode: config.cullMode ?? "none" },
6062
6071
  depthStencil: config.depthStencil,
6063
6072
  multisample: config.multisample ?? { count: Engine.MULTISAMPLE_COUNT },
6064
- });
6073
+ };
6065
6074
  }
6066
6075
  createPipelines() {
6067
6076
  this.materialSampler = this.device.createSampler({
@@ -6179,8 +6188,6 @@ export class Engine {
6179
6188
  // creation with "binding doesn't exist", naming a binding no effect
6180
6189
  // author ever wrote. Sharing the code meant sharing these.
6181
6190
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
6182
- { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6183
- { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
6184
6191
  { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "3d" } },
6185
6192
  // And the scene's COVERAGE and bloom, without which the tap cannot
6186
6193
  // reconstruct a pixel: the HDR target is premultiplied, so colour alone
@@ -6201,7 +6208,7 @@ export class Engine {
6201
6208
  const seedModule = this.device.createShaderModule({ label: "cast distance seed", code: buildCastSeedShader(Engine.MULTISAMPLE_COUNT) });
6202
6209
  const stepModule = this.device.createShaderModule({ label: "cast distance step", code: buildCastStepShader() });
6203
6210
  const resolveModule = this.device.createShaderModule({ label: "cast distance resolve", code: buildCastResolveShader() });
6204
- this.castSeedPipeline = this.device.createRenderPipeline({
6211
+ this.initRenderPipeline({
6205
6212
  label: "cast distance seed",
6206
6213
  layout: "auto",
6207
6214
  vertex: { module: seedModule, entryPoint: "vs" },
@@ -6211,21 +6218,21 @@ export class Engine {
6211
6218
  targets: [{ format: CAST_SEED_FORMAT }, { format: CAST_COVERAGE_FORMAT }],
6212
6219
  },
6213
6220
  primitive: { topology: "triangle-list" },
6214
- });
6215
- this.castStepPipeline = this.device.createRenderPipeline({
6221
+ }, (p) => (this.castSeedPipeline = p));
6222
+ this.initRenderPipeline({
6216
6223
  label: "cast distance step",
6217
6224
  layout: "auto",
6218
6225
  vertex: { module: stepModule, entryPoint: "vs" },
6219
6226
  fragment: { module: stepModule, entryPoint: "fs", targets: [{ format: CAST_SEED_FORMAT }] },
6220
6227
  primitive: { topology: "triangle-list" },
6221
- });
6222
- this.castResolvePipeline = this.device.createRenderPipeline({
6228
+ }, (p) => (this.castStepPipeline = p));
6229
+ this.initRenderPipeline({
6223
6230
  label: "cast distance resolve",
6224
6231
  layout: "auto",
6225
6232
  vertex: { module: resolveModule, entryPoint: "vs" },
6226
6233
  fragment: { module: resolveModule, entryPoint: "fs", targets: [{ format: CAST_DIST_FORMAT }] },
6227
6234
  primitive: { topology: "triangle-list" },
6228
- });
6235
+ }, (p) => (this.castResolvePipeline = p));
6229
6236
  // 65504 is the largest half float. Bound wherever the field is not
6230
6237
  // running, so rzCastDistance answers "unreachably far" and an effect keyed
6231
6238
  // on it draws nothing at all.
@@ -6321,7 +6328,22 @@ export class Engine {
6321
6328
  // The same attachments blended as light — for a group that declared
6322
6329
  // blend: "additive". See scene-contract's material-additive.
6323
6330
  this.sceneTargetsAdditive = sceneTargetsFor("material-additive", this.sceneFormats);
6331
+ this.sceneTargetsPremultiplied = sceneTargetsFor("material-premultiplied", this.sceneFormats);
6324
6332
  this.fullVertexBufferLayouts = fullVertexBuffers;
6333
+ // Slot 3 stays empty: the outline hull binds its own stream there between
6334
+ // two material draws, and a material reading slot 3 would pick it up.
6335
+ const materialVertexBuffers = [
6336
+ ...fullVertexBuffers,
6337
+ null,
6338
+ {
6339
+ arrayStride: Engine.ATTRIBUTE_FLOATS * 4,
6340
+ attributes: [
6341
+ { shaderLocation: 5, offset: 0, format: "float32x4" },
6342
+ { shaderLocation: 6, offset: 4 * 4, format: "float32x2" },
6343
+ ],
6344
+ },
6345
+ ];
6346
+ this.materialVertexBufferLayouts = materialVertexBuffers;
6325
6347
  // group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
6326
6348
  this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
6327
6349
  label: "main per-frame bind group layout",
@@ -6336,14 +6358,19 @@ export class Engine {
6336
6358
  // The positional lights. Always bound, empty or not, so every material
6337
6359
  // pipeline shares one layout whether or not the scene has any.
6338
6360
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
6339
- // The world's sky (8) and the BRDF LUT (9). The sky is always bound —
6340
- // the 1x1 fallback when the scene has none — so every material
6341
- // pipeline keeps sharing one layout. (7 was the far cascade's map,
6342
- // which the atlas at 3 replaces.)
6361
+ // The world's sky (8). Always bound — the 1x1 fallback when the scene
6362
+ // has none — so every material pipeline keeps sharing one layout. (7
6363
+ // was the far cascade's map, which the atlas at 3 replaces.)
6343
6364
  { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6344
- { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6365
+ // The spot lamps' cookies (loadLightCookie), a 1x1 white until one loads.
6366
+ { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d-array" } },
6345
6367
  // The cast's shadow on a stage — setStageCastShadow.
6346
6368
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
6369
+ // The stage's reflection probe and where it projects — see
6370
+ // captureReflectionProbe. Always bound: a 1x1 cube and the off flag
6371
+ // when there is no stage.
6372
+ { binding: 11, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "cube" } },
6373
+ { binding: 12, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
6347
6374
  ],
6348
6375
  });
6349
6376
  // group 1: per-instance (skinMats) — bound once per model
@@ -6407,12 +6434,12 @@ export class Engine {
6407
6434
  const neutral = compileGraph(DEFAULT_GRAPH, { renderClass: "auto", alphaMode: "opaque" });
6408
6435
  if (!neutral.ok)
6409
6436
  throw new Error("failed to compile the neutral default graph");
6410
- const neutralModule = this.device.createShaderModule({ label: "neutral base (default graph)", code: neutral.wgsl });
6411
- this.neutralPipeline = this.createRenderPipeline({
6437
+ const neutralModule = this.cachedShaderModule(neutral.wgsl, "neutral base (default graph)");
6438
+ this.initRenderPipeline(this.renderPipelineDesc({
6412
6439
  label: "neutral base pipeline",
6413
6440
  layout: mainPipelineLayout,
6414
6441
  shaderModule: neutralModule,
6415
- vertexBuffers: fullVertexBuffers,
6442
+ vertexBuffers: materialVertexBuffers,
6416
6443
  fragmentTargets: sceneTargets,
6417
6444
  cullMode: "none",
6418
6445
  depthStencil: {
@@ -6420,13 +6447,13 @@ export class Engine {
6420
6447
  depthWriteEnabled: true,
6421
6448
  depthCompare: this.depthAhead,
6422
6449
  },
6423
- });
6450
+ }), (p) => (this.neutralPipeline = p), true);
6424
6451
  // Depth-write-off twin for transparent-bucket draws (see pipelineForDrawCall).
6425
- this.neutralPipelineNoDepthWrite = this.createRenderPipeline({
6452
+ this.initRenderPipeline(this.renderPipelineDesc({
6426
6453
  label: "neutral base pipeline (no depth write)",
6427
6454
  layout: mainPipelineLayout,
6428
6455
  shaderModule: neutralModule,
6429
- vertexBuffers: fullVertexBuffers,
6456
+ vertexBuffers: materialVertexBuffers,
6430
6457
  fragmentTargets: sceneTargets,
6431
6458
  cullMode: "none",
6432
6459
  depthStencil: {
@@ -6434,7 +6461,7 @@ export class Engine {
6434
6461
  depthWriteEnabled: false,
6435
6462
  depthCompare: this.depthAhead,
6436
6463
  },
6437
- });
6464
+ }), (p) => (this.neutralPipelineNoDepthWrite = p), true);
6438
6465
  // Depth-only prepass for transparent draws (see depth-prepass.ts): writes the
6439
6466
  // fabric's depth AFTER its color blended, so outlines drawn later are
6440
6467
  // occluded behind it. Color targets kept for pass compatibility, writeMask 0.
@@ -6453,7 +6480,7 @@ export class Engine {
6453
6480
  depthCompare: this.depthAhead,
6454
6481
  },
6455
6482
  };
6456
- this.depthPrepassPipeline = this.createModelPipeline({
6483
+ this.initRenderPipeline({
6457
6484
  label: "opaque depth prepass",
6458
6485
  ...prepassDesc,
6459
6486
  fragment: {
@@ -6461,11 +6488,11 @@ export class Engine {
6461
6488
  entryPoint: "fs",
6462
6489
  targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
6463
6490
  },
6464
- });
6491
+ }, (p) => (this.depthPrepassPipeline = p), true);
6465
6492
  // The SOLID prime: same module, cutoff forced to exactly 1.0. Only texels
6466
6493
  // whose blend ignores the destination may pre-claim depth in the
6467
6494
  // transparent phase — see the override's note in depth-prepass.ts.
6468
- this.solidPrepassPipeline = this.createModelPipeline({
6495
+ this.initRenderPipeline({
6469
6496
  label: "transparent solid prepass",
6470
6497
  ...prepassDesc,
6471
6498
  fragment: {
@@ -6474,14 +6501,14 @@ export class Engine {
6474
6501
  constants: { CUTOFF: 1.0 },
6475
6502
  targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
6476
6503
  },
6477
- });
6504
+ }, (p) => (this.solidPrepassPipeline = p), true);
6478
6505
  // The HAIR prime: solid texels only, and stencil-fenced off the eye
6479
6506
  // silhouette. It records after the non-hair opaque draws, so the eye has
6480
6507
  // already written its stencil — not-equal here is what keeps the primed
6481
6508
  // hair depth from ever claiming the pixels the see-through-hair pass needs
6482
6509
  // the eye to survive on. (Bundle draws use the PASS's stencil reference;
6483
6510
  // only pipeline/bind/vertex state resets across executeBundles.)
6484
- this.hairPrimePipeline = this.createModelPipeline({
6511
+ this.initRenderPipeline({
6485
6512
  label: "hair depth prime",
6486
6513
  ...prepassDesc,
6487
6514
  depthStencil: {
@@ -6497,7 +6524,7 @@ export class Engine {
6497
6524
  constants: { CUTOFF: 1.0 },
6498
6525
  targets: sceneTargetsFor("depth-prepass", this.sceneFormats),
6499
6526
  },
6500
- });
6527
+ }, (p) => (this.hairPrimePipeline = p), true);
6501
6528
  // The matrices, then one vec4: the caster sphere, which effects' rzShadow
6502
6529
  // tests before any tap. The materials and the ground bind the matrices only.
6503
6530
  this.shadowLightVPBuffer = this.device.createBuffer({
@@ -6521,7 +6548,7 @@ export class Engine {
6521
6548
  label: "shadow depth",
6522
6549
  code: SHADOW_DEPTH_SHADER_WGSL,
6523
6550
  });
6524
- this.shadowDepthPipeline = this.device.createRenderPipeline({
6551
+ this.initRenderPipeline({
6525
6552
  label: "shadow depth pipeline",
6526
6553
  // Group 1 is the main pass's per-material layout so each shadow draw can
6527
6554
  // rebind the draw call's existing material bind group for the alpha test.
@@ -6542,7 +6569,7 @@ export class Engine {
6542
6569
  depthBiasSlopeScale: 1.5,
6543
6570
  depthBiasClamp: 0,
6544
6571
  },
6545
- });
6572
+ }, (p) => (this.shadowDepthPipeline = p));
6546
6573
  this.shadowComparisonSampler = this.device.createSampler({
6547
6574
  compare: "less",
6548
6575
  magFilter: "linear",
@@ -6574,16 +6601,21 @@ export class Engine {
6574
6601
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6575
6602
  });
6576
6603
  // One-shot bake of Blender EEVEE's combined BRDF LUT (DFG + LTC packed rgba8unorm).
6577
- this.bakeBrdfLut();
6578
6604
  this.bakeGroundNoise();
6579
- this.agxFallbackTexture = this.device.createTexture({
6580
- label: "AgX LUT fallback",
6581
- size: [1, 1, 1],
6582
- dimension: "3d",
6583
- format: "rgb10a2unorm",
6584
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
6585
- });
6586
- void this.loadAgxLut();
6605
+ // The mipmap blit for the formats model textures arrive in, built here so
6606
+ // the first texture of the first model does not compile it synchronously
6607
+ // in the middle of a load (generateMipmaps builds any other on demand).
6608
+ {
6609
+ const mipModule = this.device.createShaderModule({ label: "mipmap blit", code: MIPMAP_BLIT_SHADER_WGSL });
6610
+ for (const format of ["rgba8unorm", "rgba8unorm-srgb"])
6611
+ this.initRenderPipeline({
6612
+ label: `mipmap blit pipeline (${format})`,
6613
+ layout: "auto",
6614
+ vertex: { module: mipModule, entryPoint: "vs" },
6615
+ fragment: { module: mipModule, entryPoint: "fs", targets: [{ format }] },
6616
+ primitive: { topology: "triangle-list" },
6617
+ }, (p) => this.mipBlitPipelines.set(format, p));
6618
+ }
6587
6619
  this.stageGradeFallback = this.device.createTexture({
6588
6620
  label: "stage grade fallback",
6589
6621
  size: [1, 1, 1],
@@ -6592,7 +6624,6 @@ export class Engine {
6592
6624
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
6593
6625
  });
6594
6626
  this.uploadStageGrade();
6595
- this.bakeFilmicLut();
6596
6627
  // BEFORE the bind group below, which binds it. Full size from the start:
6597
6628
  // every material pipeline binds this, so sizing it to the light count would
6598
6629
  // mean rebuilding bind groups whenever a scene gained a lamp. Uploaded from
@@ -6622,6 +6653,48 @@ export class Engine {
6622
6653
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
6623
6654
  });
6624
6655
  this.fallbackEquirectView = this.fallbackEquirectTexture.createView();
6656
+ // The reflection probe's resources, bound from the first frame: the cube
6657
+ // (empty and switched off until a stage is captured), its stand-in and the
6658
+ // uniforms. Before the per-frame groups, which bind all of them.
6659
+ this.probeTexture = this.device.createTexture({
6660
+ label: "reflection probe",
6661
+ size: [PROBE_SIZE, PROBE_SIZE, 6],
6662
+ format: "rgba16float",
6663
+ mipLevelCount: PROBE_LEVELS,
6664
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
6665
+ });
6666
+ this.probeView = this.probeTexture.createView({ dimension: "cube" });
6667
+ this.probeFallbackView = this.device
6668
+ .createTexture({
6669
+ label: "reflection probe stand-in",
6670
+ size: [1, 1, 6],
6671
+ format: "rgba16float",
6672
+ usage: GPUTextureUsage.TEXTURE_BINDING,
6673
+ })
6674
+ .createView({ dimension: "cube" });
6675
+ this.cookieTexture = this.device.createTexture({
6676
+ label: "light cookies (none yet)",
6677
+ size: [1, 1, 1],
6678
+ format: "rgba8unorm",
6679
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
6680
+ });
6681
+ this.device.queue.writeTexture({ texture: this.cookieTexture }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1, 1]);
6682
+ this.cookieView = this.cookieTexture.createView({ dimension: "2d-array" });
6683
+ this.probeUniformBuffer = this.device.createBuffer({
6684
+ label: "reflection probe uniforms",
6685
+ size: 48,
6686
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6687
+ });
6688
+ this.probeOffBuffer = this.device.createBuffer({
6689
+ label: "reflection probe uniforms (off)",
6690
+ size: 48,
6691
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6692
+ });
6693
+ this.probeCameraBuffer = this.device.createBuffer({
6694
+ label: "reflection probe capture camera",
6695
+ size: 40 * 4,
6696
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6697
+ });
6625
6698
  // Zero-filled = zero lines, which every accessor answers gracefully.
6626
6699
  this.lyricsBuffer = this.device.createBuffer({
6627
6700
  label: "lyrics",
@@ -6666,6 +6739,8 @@ export class Engine {
6666
6739
  { binding: 13, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6667
6740
  // The mirror's plane, and whether this draw is inside the mirror pass.
6668
6741
  { binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
6742
+ // The spot lamps' cookies — the floor takes their patterns too.
6743
+ { binding: 15, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float", viewDimension: "2d-array" } },
6669
6744
  ],
6670
6745
  });
6671
6746
  this.groundShadowPipelineDesc = {
@@ -6679,7 +6754,7 @@ export class Engine {
6679
6754
  cullMode: "back",
6680
6755
  depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
6681
6756
  };
6682
- this.groundShadowPipeline = this.buildGroundPipeline(false);
6757
+ this.initRenderPipeline(this.renderPipelineDesc(this.groundPipelineConfig(false)), (p) => (this.groundShadowPipeline = p));
6683
6758
  // Outline: group 0 = per-frame (camera), group 1 = per-instance (skinMats), group 2 = per-material (edge uniforms)
6684
6759
  this.outlinePerFrameBindGroupLayout = this.device.createBindGroupLayout({
6685
6760
  label: "outline per-frame bind group layout",
@@ -6742,7 +6817,12 @@ export class Engine {
6742
6817
  label: "outline pipeline",
6743
6818
  layout: outlinePipelineLayout,
6744
6819
  shaderModule: outlineShaderModule,
6745
- vertexBuffers: outlineVertexBuffers,
6820
+ // The selection mask's streams plus the hull's own: smoothed normal and
6821
+ // PMX edge scale (ModelInstance.outlineVertexBuffer).
6822
+ vertexBuffers: [
6823
+ ...outlineVertexBuffers,
6824
+ { arrayStride: 4 * 4, attributes: [{ shaderLocation: 5, offset: 0, format: "float32x4" }] },
6825
+ ],
6746
6826
  fragmentTargets: sceneTargetsFor("outline", this.sceneFormats),
6747
6827
  cullMode: "back",
6748
6828
  depthStencil: {
@@ -6776,12 +6856,12 @@ export class Engine {
6776
6856
  stencilWriteMask: 0,
6777
6857
  },
6778
6858
  };
6779
- this.outlinePipeline = this.createRenderPipeline(outlineDesc);
6780
- this.outlineMirrorPipeline = this.createRenderPipeline({
6859
+ this.initRenderPipeline(this.renderPipelineDesc(outlineDesc), (p) => (this.outlinePipeline = p), true);
6860
+ this.initRenderPipeline(this.renderPipelineDesc({
6781
6861
  ...outlineDesc,
6782
6862
  label: "outline pipeline (mirror)",
6783
6863
  cullMode: "front",
6784
- });
6864
+ }), (p) => (this.outlineMirrorPipeline = p), true);
6785
6865
  // ─── Selection overlay (screen-space edge-detect on a per-material mask) ───
6786
6866
  // Reuses outline camera + main skinMats bind group layouts. No group 2 (no per-mat uniform).
6787
6867
  const selectionMaskPipelineLayout = this.device.createPipelineLayout({
@@ -6792,7 +6872,7 @@ export class Engine {
6792
6872
  label: "selection mask shader",
6793
6873
  code: SELECTION_MASK_SHADER_WGSL,
6794
6874
  });
6795
- this.selectionMaskPipeline = this.device.createRenderPipeline({
6875
+ this.initRenderPipeline({
6796
6876
  label: "selection mask pipeline",
6797
6877
  layout: selectionMaskPipelineLayout,
6798
6878
  vertex: { module: selectionMaskShaderModule, entryPoint: "vs", buffers: outlineVertexBuffers },
@@ -6804,7 +6884,7 @@ export class Engine {
6804
6884
  primitive: { cullMode: "none" },
6805
6885
  // Single-sample, no depth (depth-always via not attaching a depth buffer at all).
6806
6886
  multisample: { count: 1 },
6807
- });
6887
+ }, (p) => (this.selectionMaskPipeline = p));
6808
6888
  this.selectionEdgeBindGroupLayout = this.device.createBindGroupLayout({
6809
6889
  label: "selection edge bind group layout",
6810
6890
  entries: [
@@ -6821,7 +6901,7 @@ export class Engine {
6821
6901
  label: "selection edge shader",
6822
6902
  code: SELECTION_EDGE_SHADER_WGSL,
6823
6903
  });
6824
- this.selectionEdgePipeline = this.device.createRenderPipeline({
6904
+ this.initRenderPipeline({
6825
6905
  label: "selection edge pipeline",
6826
6906
  layout: selectionEdgePipelineLayout,
6827
6907
  vertex: { module: selectionEdgeShaderModule, entryPoint: "vs" },
@@ -6840,7 +6920,7 @@ export class Engine {
6840
6920
  },
6841
6921
  primitive: { topology: "triangle-list" },
6842
6922
  multisample: { count: 1 },
6843
- });
6923
+ }, (p) => (this.selectionEdgePipeline = p));
6844
6924
  this.selectionSampler = this.device.createSampler({
6845
6925
  label: "selection sampler",
6846
6926
  magFilter: "linear",
@@ -6857,10 +6937,7 @@ export class Engine {
6857
6937
  this.setupGizmo();
6858
6938
  // ─── Editor overlays (instanced wireframe primitives) ────────────
6859
6939
  this.setupOverlay();
6860
- // ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
6861
- // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
6862
- // Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
6863
- // Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
6940
+ // ─── Bloom: Aether Gazer's chain (prefilter → Gaussian down chain → scatter up chain) ───
6864
6941
  this.bloomSampler = this.device.createSampler({
6865
6942
  label: "bloom sampler",
6866
6943
  magFilter: "linear",
@@ -6868,81 +6945,25 @@ export class Engine {
6868
6945
  addressModeU: "clamp-to-edge",
6869
6946
  addressModeV: "clamp-to-edge",
6870
6947
  });
6871
- this.bloomBlitUniformBuffer = this.device.createBuffer({
6872
- label: "bloom blit uniforms",
6873
- size: 16,
6874
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6875
- });
6876
- this.bloomUpsampleUniformBuffer = this.device.createBuffer({
6877
- label: "bloom upsample uniforms",
6878
- size: 16,
6879
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
6880
- });
6881
- this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
6882
- label: "bloom blit layout",
6948
+ this.bloomPrefilterBindGroupLayout = this.device.createBindGroupLayout({
6949
+ label: "bloom prefilter layout",
6883
6950
  entries: [
6884
6951
  { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
6885
6952
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
6886
6953
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
6887
6954
  ],
6888
6955
  });
6889
- this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
6890
- label: "bloom downsample layout",
6956
+ this.bloomBlurBindGroupLayout = this.device.createBindGroupLayout({
6957
+ label: "bloom blur layout",
6891
6958
  entries: [
6892
6959
  { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
6893
6960
  { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
6894
6961
  ],
6895
6962
  });
6896
- this.bloomUpsampleBindGroupLayout = this.device.createBindGroupLayout({
6897
- label: "bloom upsample layout",
6898
- entries: [
6899
- { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
6900
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
6901
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
6902
- { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
6903
- ],
6904
- });
6905
- const bloomBlitShader = this.device.createShaderModule({
6906
- label: "bloom blit (Karis prefilter)",
6907
- code: BLOOM_BLIT_SHADER_WGSL,
6908
- });
6909
- const bloomDownsampleShader = this.device.createShaderModule({
6910
- label: "bloom downsample 13-tap",
6911
- code: BLOOM_DOWNSAMPLE_SHADER_WGSL,
6912
- });
6913
- const bloomUpsampleShader = this.device.createShaderModule({
6914
- label: "bloom upsample 9-tap tent",
6915
- code: BLOOM_UPSAMPLE_SHADER_WGSL,
6916
- });
6917
- const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] });
6918
- const bloomDownLayout = this.device.createPipelineLayout({
6919
- bindGroupLayouts: [this.bloomDownsampleBindGroupLayout],
6920
- });
6921
- const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] });
6922
- this.bloomBlitPipeline = this.device.createRenderPipeline({
6923
- label: "bloom blit pipeline",
6924
- layout: bloomBlitLayout,
6925
- vertex: { module: bloomBlitShader, entryPoint: "vs" },
6926
- fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
6927
- primitive: { topology: "triangle-list" },
6928
- });
6929
- this.bloomDownsamplePipeline = this.device.createRenderPipeline({
6930
- label: "bloom downsample pipeline",
6931
- layout: bloomDownLayout,
6932
- vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
6933
- fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
6934
- primitive: { topology: "triangle-list" },
6935
- });
6936
- this.bloomUpsamplePipeline = this.device.createRenderPipeline({
6937
- label: "bloom upsample pipeline",
6938
- layout: bloomUpLayout,
6939
- vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
6940
- fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: this.hdrFormat }] },
6941
- primitive: { topology: "triangle-list" },
6942
- });
6943
- // ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
6944
- // Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
6945
- // mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
6963
+ this.ensureBloomPipelines();
6964
+ // ─── Composite: HDR + bloom → view transform → swapchain (premultiplied) ───
6965
+ // Bloom color/intensity applied HERE: the chain is pure energy, tint and
6966
+ // strength belong to the combine step.
6946
6967
  this.compositeUniformBuffer = this.device.createBuffer({
6947
6968
  label: "composite view uniforms",
6948
6969
  // 15 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
@@ -6984,8 +7005,6 @@ export class Engine {
6984
7005
  // Aux mask/alpha texture — composite reads .g to reconstruct the alpha that
6985
7006
  // used to live in the HDR target before the rg11b10ufloat switch.
6986
7007
  { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: {} },
6987
- // Filmic tone LUT (r16float, filterable) — sampled with the binding-2 sampler.
6988
- { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: {} },
6989
7008
  // 360 backdrop equirect (PhotoDome-style skybox) — 1×1 fallback when unset.
6990
7009
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: {} },
6991
7010
  // User background-effect params — dummy buffer when no effect is set. The
@@ -7000,9 +7019,6 @@ export class Engine {
7000
7019
  texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
7001
7020
  },
7002
7021
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
7003
- // AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
7004
- // 1×1×1 stand-in keeps the bind group valid until it arrives.
7005
- { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
7006
7022
  // The scene's own grade cube (setStageGrade) — 1×1×1 stand-in while it
7007
7023
  // has none, which the flag bit keeps from ever being sampled.
7008
7024
  { binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
@@ -7028,12 +7044,12 @@ export class Engine {
7028
7044
  this.compositePipelineLayout = this.device.createPipelineLayout({
7029
7045
  bindGroupLayouts: [this.compositeBindGroupLayout],
7030
7046
  });
7031
- const compositeShader = this.device.createShaderModule({
7032
- label: "composite shader",
7033
- code: buildCompositeShader(null),
7034
- });
7035
- this.compositePipelineIdentity = this.makeCompositePipeline(compositeShader, false, "composite pipeline (gamma=1)");
7036
- this.compositePipelineGamma = this.makeCompositePipeline(compositeShader, true, "composite pipeline (gamma!=1)");
7047
+ // The same pair setEffects asks for with no field mounts, so an install
7048
+ // that adds none finds it built.
7049
+ this.initPipelineJobs?.push(this.compositePipelines(false, false).then(([identity, gamma]) => {
7050
+ this.compositePipelineIdentity = identity;
7051
+ this.compositePipelineGamma = gamma;
7052
+ }));
7037
7053
  // GPU vertex-morph compute pipeline (shared by all models; per-model bind groups).
7038
7054
  // Bindings: 0-4 read-only storage (base pos, CSR rowStart/colMorph/colOffset, weights),
7039
7055
  // 5 read-write storage (vertex buffer), 6 uniform (params).
@@ -7050,14 +7066,14 @@ export class Engine {
7050
7066
  { binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
7051
7067
  ],
7052
7068
  });
7053
- this.morphComputePipeline = this.device.createComputePipeline({
7069
+ this.initComputePipeline({
7054
7070
  label: "morph compute pipeline",
7055
7071
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.morphComputeBindGroupLayout] }),
7056
7072
  compute: {
7057
7073
  module: this.device.createShaderModule({ label: "morph compute shader", code: MORPH_COMPUTE_WGSL }),
7058
7074
  entryPoint: "cs",
7059
7075
  },
7060
- });
7076
+ }, (p) => (this.morphComputePipeline = p));
7061
7077
  // GPU frustum cull. One pipeline for the whole scene; the bind group is rebuilt
7062
7078
  // with the buffers whenever the draw list changes. See shaders/passes/cull.ts.
7063
7079
  this.cullBindGroupLayout = this.device.createBindGroupLayout({
@@ -7077,21 +7093,23 @@ export class Engine {
7077
7093
  // encoder it is set on, so a WGSL slip here would take the whole frame down —
7078
7094
  // every pass, every model, an unrelated-looking cascade of style-group and
7079
7095
  // effect failures. Catching it turns that into "culling is off".
7080
- this.device.pushErrorScope("validation");
7081
- this.cullPipeline = this.device.createComputePipeline({
7096
+ // Async, so a failure arrives as the rejection rather than a scoped error.
7097
+ const cullBuild = this.device
7098
+ .createComputePipelineAsync({
7082
7099
  label: "cull pipeline",
7083
7100
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.cullBindGroupLayout] }),
7084
7101
  compute: {
7085
7102
  module: this.device.createShaderModule({ label: "cull compute shader", code: CULL_COMPUTE_WGSL }),
7086
7103
  entryPoint: "cs",
7087
7104
  },
7088
- });
7089
- void this.device.popErrorScope().then((err) => {
7090
- if (!err)
7091
- return;
7105
+ })
7106
+ .then((p) => {
7107
+ this.cullPipeline = p;
7108
+ }, (err) => {
7092
7109
  console.error(`[cull] pipeline failed to compile — frustum culling disabled:\n${err.message}`);
7093
7110
  this.cullPipeline = null;
7094
7111
  });
7112
+ this.initPipelineJobs?.push(cullBuild);
7095
7113
  this.bloomPassDescriptor = {
7096
7114
  label: "bloom pass",
7097
7115
  colorAttachments: [
@@ -7132,7 +7150,7 @@ export class Engine {
7132
7150
  layout: this.pickPerFrameBindGroupLayout,
7133
7151
  entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
7134
7152
  });
7135
- this.pickPipeline = this.device.createRenderPipeline({
7153
+ this.initRenderPipeline({
7136
7154
  label: "pick pipeline",
7137
7155
  layout: pickPipelineLayout,
7138
7156
  vertex: { module: pickShaderModule, buffers: fullVertexBuffers },
@@ -7146,7 +7164,7 @@ export class Engine {
7146
7164
  depthWriteEnabled: true,
7147
7165
  depthCompare: this.depthAhead,
7148
7166
  },
7149
- });
7167
+ }, (p) => (this.pickPipeline = p));
7150
7168
  this.pickReadbackBuffer = this.device.createBuffer({
7151
7169
  label: "pick readback",
7152
7170
  size: 256,
@@ -7203,6 +7221,10 @@ export class Engine {
7203
7221
  // in full the moment there is something to apply it to.
7204
7222
  if (!this.device)
7205
7223
  return;
7224
+ // Nor while init's pipelines are still compiling: init ends with its own
7225
+ // handleResize, which applies whatever was asked for meanwhile.
7226
+ if (this.initPipelineJobs)
7227
+ return;
7206
7228
  // Fixed override (offline/video rendering) wins; otherwise track CSS size × dpr.
7207
7229
  const dpr = window.devicePixelRatio || 1;
7208
7230
  const width = this.fixedRenderSize ? this.fixedRenderSize.width : Math.floor(this.canvas.clientWidth * dpr);
@@ -7420,37 +7442,6 @@ export class Engine {
7420
7442
  // rebind both against the new one.
7421
7443
  this.buildGroundBindGroup();
7422
7444
  this.buildMirrorSurfaceBindGroup();
7423
- // Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
7424
- // Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
7425
- const bw = Math.max(1, Math.floor(width / 2));
7426
- const bh = Math.max(1, Math.floor(height / 2));
7427
- const shortSide = Math.max(1, Math.min(bw, bh));
7428
- this.bloomMipCount = Math.max(1, Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1));
7429
- this.bloomDownTexture?.destroy();
7430
- this.bloomDownTexture = this.device.createTexture({
7431
- label: "bloom down pyramid",
7432
- size: [bw, bh],
7433
- mipLevelCount: this.bloomMipCount,
7434
- format: this.hdrFormat,
7435
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
7436
- });
7437
- this.bloomUpTexture?.destroy();
7438
- this.bloomUpTexture = this.device.createTexture({
7439
- label: "bloom up pyramid",
7440
- size: [bw, bh],
7441
- mipLevelCount: Math.max(1, this.bloomMipCount - 1),
7442
- format: this.hdrFormat,
7443
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
7444
- });
7445
- this.bloomDownMipViews = [];
7446
- for (let i = 0; i < this.bloomMipCount; i++) {
7447
- this.bloomDownMipViews.push(this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
7448
- }
7449
- this.bloomUpMipViews = [];
7450
- const upLevels = Math.max(1, this.bloomMipCount - 1);
7451
- for (let i = 0; i < upLevels; i++) {
7452
- this.bloomUpMipViews.push(this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
7453
- }
7454
7445
  this.depthTexture?.destroy();
7455
7446
  this.depthTexture = this.device.createTexture({
7456
7447
  label: "depth texture",
@@ -7562,49 +7553,10 @@ export class Engine {
7562
7553
  },
7563
7554
  ],
7564
7555
  };
7565
- this.writeBloomUniforms();
7566
- if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
7567
- // Blit: reads HDR resolve texture (full-res), writes bloomDown mip 0.
7568
- this.bloomBlitBindGroup = this.device.createBindGroup({
7569
- label: "bloom blit bind group",
7570
- layout: this.bloomBlitBindGroupLayout,
7571
- entries: [
7572
- { binding: 0, resource: this.hdrResolveTexture.createView() },
7573
- { binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
7574
- { binding: 2, resource: this.maskResolveView },
7575
- ],
7576
- });
7577
- // Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
7578
- this.bloomDownsampleBindGroups = [];
7579
- for (let i = 1; i < this.bloomMipCount; i++) {
7580
- this.bloomDownsampleBindGroups.push(this.device.createBindGroup({
7581
- label: `bloom downsample ${i}`,
7582
- layout: this.bloomDownsampleBindGroupLayout,
7583
- entries: [
7584
- { binding: 0, resource: this.bloomDownMipViews[i - 1] },
7585
- { binding: 1, resource: this.bloomSampler },
7586
- ],
7587
- }));
7588
- }
7589
- // Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
7590
- // subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
7591
- this.bloomUpsampleBindGroups = [];
7592
- const topIdx = this.bloomMipCount - 2;
7593
- for (let i = topIdx; i >= 0; i--) {
7594
- const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1];
7595
- this.bloomUpsampleBindGroups.push(this.device.createBindGroup({
7596
- label: `bloom upsample ${i}`,
7597
- layout: this.bloomUpsampleBindGroupLayout,
7598
- entries: [
7599
- { binding: 0, resource: srcView },
7600
- { binding: 1, resource: this.bloomDownMipViews[i] },
7601
- { binding: 2, resource: this.bloomSampler },
7602
- { binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
7603
- ],
7604
- }));
7605
- }
7606
- // Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
7607
- this.compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0];
7556
+ if (this.compositeBindGroupLayout && this.bloomPrefilterBindGroupLayout) {
7557
+ this.buildBloomTargets();
7558
+ // The composite reads the finished chain: up[0], or down[0] when there is one level.
7559
+ this.compositeBloomView = this.bloomLevels > 1 ? this.bloomUpViews[0] : this.bloomDownViews[0];
7608
7560
  this.rebuildCompositeBindGroup();
7609
7561
  }
7610
7562
  this.writeCompositeViewUniforms();
@@ -7867,16 +7819,16 @@ export class Engine {
7867
7819
  },
7868
7820
  multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
7869
7821
  };
7870
- this.overlayPipeline = this.device.createRenderPipeline(overlayPipelineDescriptor);
7822
+ this.initRenderPipeline(overlayPipelineDescriptor, (p) => (this.overlayPipeline = p));
7871
7823
  // The solid volumes: the same shader and layout, with no depth write and no
7872
7824
  // culling. A translucent body must not hide the rig behind it, and you have
7873
7825
  // to see its far wall for it to read as a volume rather than a silhouette.
7874
- this.overlaySolidPipeline = this.device.createRenderPipeline({
7826
+ this.initRenderPipeline({
7875
7827
  ...overlayPipelineDescriptor,
7876
7828
  label: "overlay solid pipeline",
7877
7829
  primitive: { topology: "triangle-list", cullMode: "none" },
7878
7830
  depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" },
7879
- });
7831
+ }, (p) => (this.overlaySolidPipeline = p));
7880
7832
  const compositeShader = this.device.createShaderModule({
7881
7833
  label: "overlay composite shader",
7882
7834
  code: OVERLAY_COMPOSITE_SHADER_WGSL,
@@ -7964,7 +7916,7 @@ export class Engine {
7964
7916
  ],
7965
7917
  });
7966
7918
  const wireShader = this.device.createShaderModule({ label: "wireframe shader", code: WIREFRAME_SHADER_WGSL });
7967
- this.wireframePipeline = this.device.createRenderPipeline({
7919
+ this.initRenderPipeline({
7968
7920
  label: "wireframe pipeline",
7969
7921
  layout: this.device.createPipelineLayout({
7970
7922
  label: "wireframe pipeline layout",
@@ -7991,7 +7943,7 @@ export class Engine {
7991
7943
  // so a bone behind a triangle must not be punched out by it.
7992
7944
  depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: this.depthAhead },
7993
7945
  multisample: { count: Engine.OVERLAY_SAMPLE_COUNT },
7994
- });
7946
+ }, (p) => (this.wireframePipeline = p));
7995
7947
  // The mesh's own depth, so the wireframe can be occluded by the body it
7996
7948
  // belongs to. Occluded is the default everywhere — Blender's edit mode, Maya,
7997
7949
  // three's and Babylon's wireframe materials all depth-test, and X-ray is a
@@ -8656,36 +8608,446 @@ export class Engine {
8656
8608
  getCameraRoll() {
8657
8609
  return this.camera.roll;
8658
8610
  }
8659
- /** Vertical field of view in radians (default π/4). While a camera VMD
8660
- * drives the view it animates fov itself; the orbit value set here is
8661
- * restored when the VMD releases the camera. */
8611
+ /** Vertical field of view in radians (default π/4). While a camera VMD
8612
+ * drives the view it animates fov itself; the orbit value set here is
8613
+ * restored when the VMD releases the camera. */
8614
+ /**
8615
+ * The shot as MMD states it — target, euler rotation, distance, fov.
8616
+ *
8617
+ * For a host writing the camera out to something else: an AE composition, a
8618
+ * .vmd, a log. The same five channels whichever mode is driving, so the
8619
+ * caller never asks and never decomposes a view matrix to find out.
8620
+ */
8621
+ getCameraPose() {
8622
+ return this.camera.getPose();
8623
+ }
8624
+ getCameraFov() {
8625
+ return this.camera.fov;
8626
+ }
8627
+ setCameraFov(fov) {
8628
+ this.camera.fov = fov;
8629
+ }
8630
+ // Step 5: Create lighting buffers
8631
+ setupLighting() {
8632
+ this.lightUniformBuffer = this.device.createBuffer({
8633
+ label: "light uniforms",
8634
+ size: 112 * 4, // ambient (4) + 4 lights x 2 vec4 (32) + irradiance SH 9 x vec4 (36) + fog 4 x vec4 (16) + cast shadow (4 + 16)
8635
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8636
+ });
8637
+ this.lightData.fill(0);
8638
+ this.lightCount = 0;
8639
+ this.writeWorld();
8640
+ this.writeSun(0);
8641
+ }
8642
+ // ─── The stage's reflection probe ───────────────────────────────────
8643
+ //
8644
+ // What a game bakes for every room: a cube of the stage itself, seen from
8645
+ // its middle, prefiltered down its mips by the specular lobe, and read by
8646
+ // every surface box-projected onto the stage's bounds — so a lacquered
8647
+ // counter reflects the bottles behind it and a wet floor the lamps above,
8648
+ // not a sky the room has no window onto. Captured here automatically once a
8649
+ // stage has loaded and its looks have settled; nothing to set.
8650
+ /** Drop the capture's resources, so the next capture rebinds the sky. */
8651
+ dropProbeCapture() {
8652
+ const cap = this.probeCapture;
8653
+ if (!cap)
8654
+ return;
8655
+ this.probeCapture = null;
8656
+ void this.device.queue.onSubmittedWorkDone().then(() => {
8657
+ for (const t of cap.msTargets)
8658
+ t.destroy();
8659
+ cap.depth.destroy();
8660
+ cap.color.destroy();
8661
+ cap.aux.destroy();
8662
+ cap.cube.destroy();
8663
+ cap.steps.destroy();
8664
+ });
8665
+ }
8666
+ /** Ask for a fresh capture of the stage's reflection probe. It runs at the
8667
+ * start of a frame once the scene has held still for a moment — asked for
8668
+ * by the engine itself whenever the stage or its looks change; a host may
8669
+ * call it after changing something the engine cannot see. */
8670
+ captureReflectionProbe() {
8671
+ if (!this.probeDirty)
8672
+ this.probeDirtyFrame = this.probeFrame;
8673
+ this.probeDirty = true;
8674
+ }
8675
+ /** The stage's world-space bounds: every stage model's material boxes
8676
+ * through its placement. Null without a stage. */
8677
+ stageBounds() {
8678
+ const min = [Infinity, Infinity, Infinity];
8679
+ const max = [-Infinity, -Infinity, -Infinity];
8680
+ const p = new Vec3(0, 0, 0);
8681
+ let any = false;
8682
+ for (const inst of this.modelInstances.values()) {
8683
+ if (!inst.isStage || !inst.model.visible)
8684
+ continue;
8685
+ const m = inst.model;
8686
+ for (const draw of inst.drawCalls) {
8687
+ const b = draw.bounds;
8688
+ if (!b || b.length < 6 || !(b[0] <= b[3]))
8689
+ continue;
8690
+ // The eight corners of the material's model-space box, placed.
8691
+ for (let k = 0; k < 8; k++) {
8692
+ 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);
8693
+ Quat.rotateVecInto(m.rotation, p, p);
8694
+ const w = [p.x + m.position.x, p.y + m.position.y, p.z + m.position.z];
8695
+ for (let a = 0; a < 3; a++) {
8696
+ if (w[a] < min[a])
8697
+ min[a] = w[a];
8698
+ if (w[a] > max[a])
8699
+ max[a] = w[a];
8700
+ }
8701
+ any = true;
8702
+ }
8703
+ }
8704
+ }
8705
+ return any ? { min, max } : null;
8706
+ }
8707
+ /** Run a pending capture once the scene has settled — see captureReflectionProbe. */
8708
+ maybeCaptureProbe() {
8709
+ this.probeFrame++;
8710
+ if (!this.probeDirty || !this.probeCaptureBindGroup)
8711
+ return;
8712
+ if (this.probeFrame - this.probeDirtyFrame < Engine.PROBE_SETTLE_FRAMES)
8713
+ return;
8714
+ this.probeDirty = false;
8715
+ const bounds = this.stageBounds();
8716
+ if (!bounds) {
8717
+ // No stage: the probe goes dark and every reader falls back to the sky.
8718
+ this.probeData.fill(0);
8719
+ this.device.queue.writeBuffer(this.probeUniformBuffer, 0, this.probeData);
8720
+ return;
8721
+ }
8722
+ try {
8723
+ this.renderProbe(bounds);
8724
+ }
8725
+ catch (e) {
8726
+ console.warn("[reze-engine] reflection probe capture failed:", e);
8727
+ }
8728
+ }
8729
+ ensureProbeCapture() {
8730
+ if (this.probeCapture)
8731
+ return this.probeCapture;
8732
+ const size = PROBE_CAPTURE_SIZE;
8733
+ const formats = sceneColorFormats(this.sceneFormats);
8734
+ const msTargets = formats.map((format, i) => this.device.createTexture({
8735
+ label: `reflection probe capture target ${i}`,
8736
+ size: [size, size],
8737
+ sampleCount: Engine.MULTISAMPLE_COUNT,
8738
+ format,
8739
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
8740
+ }));
8741
+ const depth = this.device.createTexture({
8742
+ label: "reflection probe capture depth",
8743
+ size: [size, size],
8744
+ sampleCount: Engine.MULTISAMPLE_COUNT,
8745
+ format: this.depthFormat,
8746
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
8747
+ });
8748
+ const layered = (label, format) => this.device.createTexture({
8749
+ label,
8750
+ size: [size, size, 6],
8751
+ format,
8752
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
8753
+ });
8754
+ // The resolves take the scene pass's own formats: colour and coverage.
8755
+ const color = layered("reflection probe capture colour", formats[0]);
8756
+ const aux = layered("reflection probe capture coverage", formats[1]);
8757
+ const cube = this.device.createTexture({
8758
+ label: "reflection probe capture cube",
8759
+ size: [size, size, 6],
8760
+ format: "rgba16float",
8761
+ mipLevelCount: PROBE_CAPTURE_LEVELS,
8762
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
8763
+ });
8764
+ // One 256-byte slot per step: 6 composes, then the capture's mips, then
8765
+ // the probe's levels, six faces each.
8766
+ const slots = 6 + PROBE_CAPTURE_LEVELS * 6 + PROBE_LEVELS * 6;
8767
+ const steps = this.device.createBuffer({
8768
+ label: "reflection probe steps",
8769
+ size: slots * 256,
8770
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8771
+ });
8772
+ const composeModule = this.device.createShaderModule({ label: "reflection probe compose", code: PROBE_COMPOSE_WGSL });
8773
+ const composePipeline = this.device.createRenderPipeline({
8774
+ label: "reflection probe compose",
8775
+ layout: "auto",
8776
+ vertex: { module: composeModule, entryPoint: "vs_full" },
8777
+ fragment: { module: composeModule, entryPoint: "fs_compose", targets: [{ format: "rgba16float" }] },
8778
+ primitive: { topology: "triangle-list" },
8779
+ });
8780
+ const filterModule = this.device.createShaderModule({ label: "reflection probe filter", code: PROBE_FILTER_WGSL });
8781
+ const filterPipeline = this.device.createRenderPipeline({
8782
+ label: "reflection probe filter",
8783
+ layout: "auto",
8784
+ vertex: { module: filterModule, entryPoint: "vs_full" },
8785
+ fragment: { module: filterModule, entryPoint: "fs_filter", targets: [{ format: "rgba16float" }] },
8786
+ primitive: { topology: "triangle-list" },
8787
+ });
8788
+ const colorArray = color.createView({ dimension: "2d-array" });
8789
+ const auxArray = aux.createView({ dimension: "2d-array" });
8790
+ const composeLayout = composePipeline.getBindGroupLayout(0);
8791
+ const composeGroups = [];
8792
+ for (let f = 0; f < 6; f++) {
8793
+ composeGroups.push(this.device.createBindGroup({
8794
+ label: `reflection probe compose ${f}`,
8795
+ layout: composeLayout,
8796
+ entries: [
8797
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
8798
+ { binding: 2, resource: this.materialSampler },
8799
+ { binding: 8, resource: this.worldEquirectView ?? this.fallbackEquirectView },
8800
+ { binding: 20, resource: colorArray },
8801
+ { binding: 21, resource: auxArray },
8802
+ { binding: 22, resource: { buffer: steps, offset: f * 256, size: 32 } },
8803
+ ],
8804
+ }));
8805
+ }
8806
+ const filterLayout = filterPipeline.getBindGroupLayout(0);
8807
+ const cubeView = cube.createView({ dimension: "cube" });
8808
+ const filterGroups = [];
8809
+ for (let i = 0; i < PROBE_LEVELS * 6; i++) {
8810
+ filterGroups.push(this.device.createBindGroup({
8811
+ label: `reflection probe level ${i}`,
8812
+ layout: filterLayout,
8813
+ entries: [
8814
+ { binding: 0, resource: cubeView },
8815
+ { binding: 1, resource: this.materialSampler },
8816
+ { binding: 2, resource: { buffer: steps, offset: (6 + PROBE_CAPTURE_LEVELS * 6 + i) * 256, size: 32 } },
8817
+ ],
8818
+ }));
8819
+ }
8820
+ this.probeCapture = {
8821
+ msTargets,
8822
+ depth,
8823
+ color,
8824
+ aux,
8825
+ cube,
8826
+ steps,
8827
+ composePipeline,
8828
+ filterPipeline,
8829
+ composeGroups,
8830
+ filterGroups,
8831
+ };
8832
+ return this.probeCapture;
8833
+ }
8834
+ /**
8835
+ * Capture the probe: the stage drawn six times from one point into a 128²
8836
+ * cube, the sky laid in behind it, a mip chain, and the GGX convolution
8837
+ * into the 64² cube the materials read.
8838
+ *
8839
+ * WHERE: the middle of the stage's bounds across the floor, at about a
8840
+ * character's chest height above the floor (y = 0 where the bounds span it,
8841
+ * the MMD convention, else their bottom) — where a game puts the probe of a
8842
+ * room the player stands in. The box it projects onto is the bounds.
8843
+ *
8844
+ * WHAT: the stage alone, in its own looks, lit by the scene's sun, lamps
8845
+ * and sky as they stand. The cast is not in it (a baked probe never holds
8846
+ * the characters), nor are effects.
8847
+ */
8848
+ renderProbe(bounds) {
8849
+ const cap = this.ensureProbeCapture();
8850
+ const { min, max } = bounds;
8851
+ const floor = min[1] <= 0 && max[1] >= 0 ? 0 : min[1];
8852
+ const eye = [
8853
+ (min[0] + max[0]) * 0.5,
8854
+ Math.min(floor + 12, (min[1] + max[1]) * 0.5),
8855
+ (min[2] + max[2]) * 0.5,
8856
+ ];
8857
+ const span = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
8858
+ const near = 0.25;
8859
+ const far = Math.max(span * 2, 100);
8860
+ // Each face's forward and up, in the convention the GPU samples a cube
8861
+ // with (see rz_face_dir in passes/probe.ts): +X, -X, +Y, -Y, +Z, -Z.
8862
+ const faces = [
8863
+ [1, 0, 0, 0, 1, 0],
8864
+ [-1, 0, 0, 0, 1, 0],
8865
+ [0, 1, 0, 0, 0, -1],
8866
+ [0, -1, 0, 0, 0, 1],
8867
+ [0, 0, 1, 0, 1, 0],
8868
+ [0, 0, -1, 0, 1, 0],
8869
+ ];
8870
+ const cam = this.probeCameraData;
8871
+ const view = { perFrame: this.probeCaptureBindGroup, args: "probe", outlines: false };
8872
+ const formats = sceneColorFormats(this.sceneFormats);
8873
+ for (let f = 0; f < 6; f++) {
8874
+ const [fx, fy, fz, ux, uy, uz] = faces[f];
8875
+ Mat4.lookAtInto(cam.subarray(0, 16), eye[0], eye[1], eye[2], eye[0] + fx, eye[1] + fy, eye[2] + fz, ux, uy, uz);
8876
+ if (this.reversedZ)
8877
+ Mat4.perspectiveInto(cam.subarray(16, 32), Math.PI / 2, 1, far, near);
8878
+ else
8879
+ Mat4.perspectiveInto(cam.subarray(16, 32), Math.PI / 2, 1, near, far);
8880
+ cam[32] = eye[0];
8881
+ cam[33] = eye[1];
8882
+ cam[34] = eye[2];
8883
+ cam[35] = PROBE_CAPTURE_SIZE;
8884
+ cam[36] = this.sceneClock;
8885
+ this.device.queue.writeBuffer(this.probeCameraBuffer, 0, cam);
8886
+ const encoder = this.device.createCommandEncoder({ label: `reflection probe face ${f}` });
8887
+ const colorAttachments = formats.map((_, i) => ({
8888
+ view: cap.msTargets[i].createView(),
8889
+ resolveTarget: i === 0
8890
+ ? cap.color.createView({ dimension: "2d", baseArrayLayer: f, arrayLayerCount: 1 })
8891
+ : i === 1
8892
+ ? cap.aux.createView({ dimension: "2d", baseArrayLayer: f, arrayLayerCount: 1 })
8893
+ : undefined,
8894
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
8895
+ loadOp: "clear",
8896
+ storeOp: "discard",
8897
+ }));
8898
+ const pass = encoder.beginRenderPass({
8899
+ label: `reflection probe face ${f}`,
8900
+ colorAttachments,
8901
+ depthStencilAttachment: {
8902
+ view: cap.depth.createView(),
8903
+ depthClearValue: this.depthClear,
8904
+ depthLoadOp: "clear",
8905
+ depthStoreOp: "discard",
8906
+ stencilClearValue: 0,
8907
+ stencilLoadOp: "clear",
8908
+ stencilStoreOp: "discard",
8909
+ },
8910
+ });
8911
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
8912
+ for (const inst of this.modelInstances.values()) {
8913
+ if (!inst.isStage || !inst.model.visible)
8914
+ continue;
8915
+ this.setModelDrawState(pass, inst);
8916
+ this.drawMaterials(pass, inst, "opaque", view);
8917
+ }
8918
+ for (const inst of this.modelInstances.values()) {
8919
+ if (!inst.isStage || !inst.model.visible)
8920
+ continue;
8921
+ this.setModelDrawState(pass, inst);
8922
+ this.drawMaterials(pass, inst, "transparent", view);
8923
+ }
8924
+ pass.end();
8925
+ this.device.queue.submit([encoder.finish()]);
8926
+ }
8927
+ // The steps: compose, capture mips, then the probe's levels.
8928
+ const steps = new ArrayBuffer((6 + PROBE_CAPTURE_LEVELS * 6 + PROBE_LEVELS * 6) * 256);
8929
+ const put = (slot, face, lod, roughness, srcSize, mode) => {
8930
+ const u = new Uint32Array(steps, slot * 256, 8);
8931
+ const fl = new Float32Array(steps, slot * 256, 8);
8932
+ u[0] = face;
8933
+ fl[1] = lod;
8934
+ fl[2] = roughness;
8935
+ fl[3] = srcSize;
8936
+ u[4] = mode;
8937
+ };
8938
+ for (let f = 0; f < 6; f++)
8939
+ put(f, f, 0, 0, PROBE_CAPTURE_SIZE, 0);
8940
+ for (let m = 0; m < PROBE_CAPTURE_LEVELS; m++)
8941
+ for (let f = 0; f < 6; f++)
8942
+ put(6 + m * 6 + f, f, Math.max(m - 1, 0), 0, PROBE_CAPTURE_SIZE, 0);
8943
+ for (let m = 0; m < PROBE_LEVELS; m++)
8944
+ for (let f = 0; f < 6; f++)
8945
+ // Level 0 is the capture at the probe's size: one tap of the 128²
8946
+ // level-0 cube at a 64² texel's centre is its 2×2 average.
8947
+ put(6 + PROBE_CAPTURE_LEVELS * 6 + m * 6 + f, f, 0, probeLevelRoughness(m), PROBE_CAPTURE_SIZE, m === 0 ? 0 : 1);
8948
+ this.device.queue.writeBuffer(cap.steps, 0, steps);
8949
+ const encoder = this.device.createCommandEncoder({ label: "reflection probe filter" });
8950
+ const run = (target, pipeline, group) => {
8951
+ const p = encoder.beginRenderPass({ colorAttachments: [{ view: target, loadOp: "clear", storeOp: "store" }] });
8952
+ p.setPipeline(pipeline);
8953
+ p.setBindGroup(0, group);
8954
+ p.draw(3);
8955
+ p.end();
8956
+ };
8957
+ const face = (tex, level, f) => tex.createView({ dimension: "2d", baseMipLevel: level, mipLevelCount: 1, baseArrayLayer: f, arrayLayerCount: 1 });
8958
+ for (let f = 0; f < 6; f++)
8959
+ run(face(cap.cube, 0, f), cap.composePipeline, cap.composeGroups[f]);
8960
+ // Each level from the one above. The source view spans the whole chain,
8961
+ // which a pass writing one of its levels may not bind — so the reads go
8962
+ // through a view of the levels ABOVE the one being written.
8963
+ for (let m = 1; m < PROBE_CAPTURE_LEVELS; m++) {
8964
+ const above = cap.cube.createView({ dimension: "cube", baseMipLevel: 0, mipLevelCount: m });
8965
+ for (let f = 0; f < 6; f++) {
8966
+ const group = this.device.createBindGroup({
8967
+ label: `reflection probe capture mip ${m}/${f}`,
8968
+ layout: cap.filterPipeline.getBindGroupLayout(0),
8969
+ entries: [
8970
+ { binding: 0, resource: above },
8971
+ { binding: 1, resource: this.materialSampler },
8972
+ { binding: 2, resource: { buffer: cap.steps, offset: (6 + m * 6 + f) * 256, size: 32 } },
8973
+ ],
8974
+ });
8975
+ run(face(cap.cube, m, f), cap.filterPipeline, group);
8976
+ }
8977
+ }
8978
+ for (let m = 0; m < PROBE_LEVELS; m++)
8979
+ for (let f = 0; f < 6; f++)
8980
+ run(face(this.probeTexture, m, f), cap.filterPipeline, cap.filterGroups[m * 6 + f]);
8981
+ this.device.queue.submit([encoder.finish()]);
8982
+ this.probeData.set([eye[0], eye[1], eye[2], 1, min[0], min[1], min[2], 1, max[0], max[1], max[2], 0]);
8983
+ this.device.queue.writeBuffer(this.probeUniformBuffer, 0, this.probeData);
8984
+ }
8662
8985
  /**
8663
- * The shot as MMD states it — target, euler rotation, distance, fov.
8664
- *
8665
- * For a host writing the camera out to something else: an AE composition, a
8666
- * .vmd, a log. The same five channels whichever mode is driving, so the
8667
- * caller never asks and never decomposes a view matrix to find out.
8986
+ * The sky's mips, convolved with the GGX lobe each stands for — the box
8987
+ * average the upload built is only the source. Level 0 is copied as it is
8988
+ * (it is also what the backdrop draws); every level below it becomes the
8989
+ * sky as a surface of that roughness reflects it (skyLevelRoughness).
8668
8990
  */
8669
- getCameraPose() {
8670
- return this.camera.getPose();
8671
- }
8672
- getCameraFov() {
8673
- return this.camera.fov;
8674
- }
8675
- setCameraFov(fov) {
8676
- this.camera.fov = fov;
8677
- }
8678
- // Step 5: Create lighting buffers
8679
- setupLighting() {
8680
- this.lightUniformBuffer = this.device.createBuffer({
8681
- label: "light uniforms",
8682
- size: 112 * 4, // ambient (4) + 4 lights x 2 vec4 (32) + irradiance SH 9 x vec4 (36) + fog 4 x vec4 (16) + cast shadow (4 + 16)
8991
+ prefilterSky(source, width, height, levels) {
8992
+ if (levels <= 1)
8993
+ return source;
8994
+ if (!this.skyPrefilterPipeline) {
8995
+ const module = this.device.createShaderModule({ label: "sky prefilter", code: SKY_PREFILTER_WGSL });
8996
+ this.skyPrefilterPipeline = this.device.createRenderPipeline({
8997
+ label: "sky prefilter",
8998
+ layout: "auto",
8999
+ vertex: { module, entryPoint: "vs_full" },
9000
+ fragment: { module, entryPoint: "fs_sky", targets: [{ format: "rgba16float" }] },
9001
+ primitive: { topology: "triangle-list" },
9002
+ });
9003
+ }
9004
+ const pipeline = this.skyPrefilterPipeline;
9005
+ const out = this.device.createTexture({
9006
+ label: "world equirect (HDR, GGX-prefiltered)",
9007
+ size: [width, height],
9008
+ format: "rgba16float",
9009
+ mipLevelCount: levels,
9010
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
9011
+ });
9012
+ const encoder = this.device.createCommandEncoder({ label: "sky prefilter" });
9013
+ encoder.copyTextureToTexture({ texture: source, mipLevel: 0 }, { texture: out, mipLevel: 0 }, [width, height]);
9014
+ const params = this.device.createBuffer({
9015
+ label: "sky prefilter steps",
9016
+ size: levels * 256,
8683
9017
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8684
9018
  });
8685
- this.lightData.fill(0);
8686
- this.lightCount = 0;
8687
- this.writeWorld();
8688
- this.writeSun(0);
9019
+ const data = new Float32Array(levels * 64);
9020
+ for (let m = 1; m < levels; m++)
9021
+ data.set([skyLevelRoughness(m, levels), width, height, 0], m * 64);
9022
+ this.device.queue.writeBuffer(params, 0, data);
9023
+ const srcView = source.createView();
9024
+ for (let m = 1; m < levels; m++) {
9025
+ const group = this.device.createBindGroup({
9026
+ label: `sky prefilter ${m}`,
9027
+ layout: pipeline.getBindGroupLayout(0),
9028
+ entries: [
9029
+ { binding: 0, resource: srcView },
9030
+ { binding: 1, resource: this.materialSampler },
9031
+ { binding: 2, resource: { buffer: params, offset: m * 256, size: 16 } },
9032
+ ],
9033
+ });
9034
+ const p = encoder.beginRenderPass({
9035
+ label: `sky prefilter ${m}`,
9036
+ colorAttachments: [
9037
+ { view: out.createView({ baseMipLevel: m, mipLevelCount: 1 }), loadOp: "clear", storeOp: "store" },
9038
+ ],
9039
+ });
9040
+ p.setPipeline(pipeline);
9041
+ p.setBindGroup(0, group);
9042
+ p.draw(3);
9043
+ p.end();
9044
+ }
9045
+ this.device.queue.submit([encoder.finish()]);
9046
+ // The box chain and the steps are done with once that work is: the source
9047
+ // retires with the skies (destroyed at the next frame's start).
9048
+ this.retiredSkies.push(source);
9049
+ void this.device.queue.onSubmittedWorkDone().then(() => params.destroy());
9050
+ return out;
8689
9051
  }
8690
9052
  /** Returns whether it actually rebuilt — see rebuildCompositeBindGroup. */
8691
9053
  rebuildPerFrameBindGroups() {
@@ -8704,7 +9066,7 @@ export class Engine {
8704
9066
  !this.shadowLightVPBuffer ||
8705
9067
  !this.materialSampler ||
8706
9068
  !this.shadowComparisonSampler ||
8707
- !this.brdfLutView ||
9069
+ !this.probeUniformBuffer ||
8708
9070
  !(this.worldEquirectView ?? this.fallbackEquirectView)) {
8709
9071
  return false;
8710
9072
  }
@@ -8726,8 +9088,31 @@ export class Engine {
8726
9088
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
8727
9089
  { binding: 6, resource: { buffer: this.lightsBuffer } },
8728
9090
  { binding: 8, resource: env },
8729
- { binding: 9, resource: this.brdfLutView },
9091
+ { binding: 9, resource: this.cookieView },
9092
+ { binding: 10, resource: this.castShadowView },
9093
+ { binding: 11, resource: this.probeView },
9094
+ { binding: 12, resource: { buffer: this.probeUniformBuffer } },
9095
+ ],
9096
+ });
9097
+ // The probe's own view of the scene: one cube face at a time through its
9098
+ // capture camera, and the probe itself switched off — a material inside
9099
+ // the capture reflects the sky, never the cube being filled.
9100
+ this.probeCaptureBindGroup = this.device.createBindGroup({
9101
+ label: "reflection probe capture per-frame bind group",
9102
+ layout: this.mainPerFrameBindGroupLayout,
9103
+ entries: [
9104
+ { binding: 0, resource: { buffer: this.probeCameraBuffer } },
9105
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
9106
+ { binding: 2, resource: this.materialSampler },
9107
+ { binding: 3, resource: this.shadowAtlasView },
9108
+ { binding: 4, resource: this.shadowComparisonSampler },
9109
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
9110
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
9111
+ { binding: 8, resource: env },
9112
+ { binding: 9, resource: this.cookieView },
8730
9113
  { binding: 10, resource: this.castShadowView },
9114
+ { binding: 11, resource: this.probeFallbackView },
9115
+ { binding: 12, resource: { buffer: this.probeOffBuffer } },
8731
9116
  ],
8732
9117
  });
8733
9118
  this.mirrorPerFrameBindGroup = this.device.createBindGroup({
@@ -8742,8 +9127,10 @@ export class Engine {
8742
9127
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
8743
9128
  { binding: 6, resource: { buffer: this.lightsBuffer } },
8744
9129
  { binding: 8, resource: env },
8745
- { binding: 9, resource: this.brdfLutView },
9130
+ { binding: 9, resource: this.cookieView },
8746
9131
  { binding: 10, resource: this.castShadowView },
9132
+ { binding: 11, resource: this.probeView },
9133
+ { binding: 12, resource: { buffer: this.probeUniformBuffer } },
8747
9134
  ],
8748
9135
  });
8749
9136
  return true;
@@ -8805,13 +9192,13 @@ export class Engine {
8805
9192
  this.lightData[base + 4] = this.sun.color.x;
8806
9193
  this.lightData[base + 5] = this.sun.color.y;
8807
9194
  this.lightData[base + 6] = this.sun.color.z;
9195
+ // Unity's light intensity: a white sun of 1 lights albedo 1:1 at N·L = 1.
8808
9196
  this.lightData[base + 7] = this.sun.strength;
8809
9197
  this.lightDataWords[DIR_LAYERS_AT + index] = this.sunLayers;
8810
9198
  if (index >= this.lightCount)
8811
9199
  this.lightCount = index + 1;
8812
9200
  this.updateLightBuffer();
8813
9201
  }
8814
- /** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
8815
9202
  /**
8816
9203
  * Distance fog on every lit surface, as a game on SimPipeline lays it: per
8817
9204
  * layer, t = saturate(f − (1 − f)·h) with f = saturate(depth·distance[0] +
@@ -8939,7 +9326,7 @@ export class Engine {
8939
9326
  }
8940
9327
  this.writeWorld();
8941
9328
  }
8942
- /** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
9329
+ /** Update the main light (Unity's directional Sun). Direction change marks shadow VP dirty. */
8943
9330
  setSun(options) {
8944
9331
  if (options.color)
8945
9332
  this.sun.color = options.color;
@@ -8990,6 +9377,7 @@ export class Engine {
8990
9377
  ...options,
8991
9378
  };
8992
9379
  this.groundY = opts.y;
9380
+ this.groundHalf = [opts.width / 2, opts.height / 2];
8993
9381
  this.createGroundGeometry(opts.width, opts.height, opts.y);
8994
9382
  this.createShadowGroundResources(opts);
8995
9383
  this.hasGround = true;
@@ -9040,6 +9428,7 @@ export class Engine {
9040
9428
  * params make: a scene document's JSON passes straight in, and so does a
9041
9429
  * literal typed into a console. Vec3 satisfies it either way. */
9042
9430
  lights) {
9431
+ this.sceneLights = lights;
9043
9432
  // The directional ones go to the light uniform's slots beside the sun's —
9044
9433
  // three of them, in order; the rest are records the grid indexes.
9045
9434
  const directional = (lights ?? []).filter((l) => l.kind === "directional");
@@ -9096,8 +9485,12 @@ export class Engine {
9096
9485
  this.lightsData[b + 12] = Math.max(inner, outer);
9097
9486
  // The layers it does NOT reach, as bits — inverted so a zero reaches all.
9098
9487
  this.lightsWords[b + 13] = ~(l.layers ?? ALL_LAYERS) >>> 0;
9099
- this.lightsData[b + 14] = 0;
9100
- this.lightsData[b + 15] = 0;
9488
+ // The bulb's radius (rzLightNear): 0 keeps the engine's default
9489
+ this.lightsData[b + 14] = l.near !== undefined && Number.isFinite(l.near) && l.near > 0 ? l.near : 0;
9490
+ // Its cookie's layer + 1, 0 for none or one not loaded yet (a load
9491
+ // rewrites the lamps when it lands).
9492
+ const cookie = l.cookie !== undefined ? this.cookieLayers.get(l.cookie) : undefined;
9493
+ this.lightsData[b + 15] = cookie !== undefined ? cookie + 1 : 0;
9101
9494
  }
9102
9495
  // The grid, from the records as STORED — normalised aim, clamped reach, the
9103
9496
  // cosines in f32 — so it indexes exactly what the shader will evaluate.
@@ -9121,6 +9514,62 @@ export class Engine {
9121
9514
  // count included — is written there.
9122
9515
  this.allocateLightSlots();
9123
9516
  }
9517
+ /**
9518
+ * A cookie, under `key`, for every spot lamp that names it (SceneLight.cookie):
9519
+ * the picture a stage light throws through its gobo, projected along the
9520
+ * cone — its RGB is the light let through, so black blocks, white passes and
9521
+ * a colour tints. Square, stretched to 512². Up to 16 at once; loading a key
9522
+ * again replaces its picture. False when every layer is taken.
9523
+ */
9524
+ async loadLightCookie(key, source) {
9525
+ if (!this.device)
9526
+ return false;
9527
+ let layer = this.cookieLayers.get(key);
9528
+ if (layer === undefined) {
9529
+ const used = new Set(this.cookieLayers.values());
9530
+ for (let k = 0; k < Engine.COOKIE_LAYERS && layer === undefined; k++)
9531
+ if (!used.has(k))
9532
+ layer = k;
9533
+ if (layer === undefined) {
9534
+ console.warn(`[reze] light cookie "${key}": all ${Engine.COOKIE_LAYERS} cookie layers are in use`);
9535
+ return false;
9536
+ }
9537
+ }
9538
+ const size = Engine.COOKIE_SIZE;
9539
+ const bmp = await createImageBitmap(source, {
9540
+ resizeWidth: size,
9541
+ resizeHeight: size,
9542
+ resizeQuality: "high",
9543
+ colorSpaceConversion: "none",
9544
+ premultiplyAlpha: "none",
9545
+ });
9546
+ // The real array on the first cookie, and every group that binds the
9547
+ // stand-in rebuilt against it (the stand-in itself is a 1x1 and stays).
9548
+ if (this.cookieTexture.width === 1) {
9549
+ this.cookieTexture = this.device.createTexture({
9550
+ label: "light cookies",
9551
+ size: [size, size, Engine.COOKIE_LAYERS],
9552
+ format: "rgba8unorm",
9553
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
9554
+ });
9555
+ this.cookieView = this.cookieTexture.createView({ dimension: "2d-array" });
9556
+ this.worldBindingsDirty = true;
9557
+ this.buildGroundBindGroup();
9558
+ }
9559
+ this.device.queue.copyExternalImageToTexture({ source: bmp }, { texture: this.cookieTexture, origin: [0, 0, layer] }, [size, size, 1]);
9560
+ bmp.close();
9561
+ this.cookieLayers.set(key, layer);
9562
+ if (this.sceneLights)
9563
+ this.setLights(this.sceneLights);
9564
+ return true;
9565
+ }
9566
+ /** Forget a cookie: lamps naming it light plainly again. */
9567
+ removeLightCookie(key) {
9568
+ if (!this.cookieLayers.delete(key))
9569
+ return;
9570
+ if (this.sceneLights)
9571
+ this.setLights(this.sceneLights);
9572
+ }
9124
9573
  /** How many positional lights the scene is carrying. */
9125
9574
  getLightCount() {
9126
9575
  return this.lightHeader[0];
@@ -9555,6 +10004,29 @@ export class Engine {
9555
10004
  this.device.queue.writeBuffer(inst.lightBuffer, 0, o);
9556
10005
  return true;
9557
10006
  }
10007
+ /**
10008
+ * Light one model with more than the world gives it: a FILL, linear RGB times
10009
+ * its surface colour, added after its material graph. Null takes it away again.
10010
+ *
10011
+ * After the graph, not in its ambient: an NPR ramp reads an ambient fill as
10012
+ * light, so sliding it moved her shadows and flipped whole regions across a
10013
+ * hard step. Added after, it brightens her evenly and every shadow stays put.
10014
+ *
10015
+ * A game lights its stage and its characters apart. Aether Gazer's rooms are
10016
+ * lit by their lamps and a near-black ambient, while its characters take a
10017
+ * flat base light of their own — so a stage whose World is right for the room
10018
+ * leaves a face turned from the lamps in the dark. This is that second light,
10019
+ * for the models the host counts as cast. A model drawn by the game's own
10020
+ * shaders (src/unity) lights itself and takes none of it.
10021
+ */
10022
+ setModelFill(name, fill) {
10023
+ const inst = this.modelInstances.get(name);
10024
+ if (!inst || !this.device)
10025
+ return false;
10026
+ inst.objectLight.set(fill ? [fill.x, fill.y, fill.z, 0] : [0, 0, 0, 0], 44);
10027
+ this.device.queue.writeBuffer(inst.lightBuffer, 0, inst.objectLight);
10028
+ return true;
10029
+ }
9558
10030
  /** The per-model group: its skinning matrices and its ObjectLight. */
9559
10031
  perInstanceBindGroup(name, skinMatrixBuffer, light) {
9560
10032
  return this.device.createBindGroup({
@@ -9573,6 +10045,9 @@ export class Engine {
9573
10045
  // Before the texture cache below frees it: a stale entry here would hand a
9574
10046
  // destroyed texture to the next setPlaneFrame.
9575
10047
  this.planeTextures.delete(name);
10048
+ // Its native look too: it skins from this model's buffers, which are
10049
+ // destroyed below, and would otherwise go on drawing from them.
10050
+ this.nativeLooks?.remove(name);
9576
10051
  inst.model.stop();
9577
10052
  for (const path of inst.textureCacheKeys) {
9578
10053
  const tex = this.textureCache.get(path);
@@ -11198,8 +11673,11 @@ export class Engine {
11198
11673
  if (d > most)
11199
11674
  most = d;
11200
11675
  }
11201
- const extent = Math.sqrt(most);
11202
- if (extent <= this.sceneExtent)
11676
+ this.raiseSceneExtent(Math.sqrt(most));
11677
+ }
11678
+ /** Raise the scene's reach (never lower it) and hand it to the camera. */
11679
+ raiseSceneExtent(extent) {
11680
+ if (!(extent > this.sceneExtent))
11203
11681
  return;
11204
11682
  this.sceneExtent = extent;
11205
11683
  this.camera?.setSceneExtent(extent);
@@ -11291,6 +11769,19 @@ export class Engine {
11291
11769
  maxZ = Math.max(maxZ, data[o + 18] + r);
11292
11770
  }
11293
11771
  }
11772
+ // The built-in floor, while it is drawn. It is no draw of the cull table,
11773
+ // so without this the box ended at the cast: the cascades stopped just
11774
+ // past her, and a low sun's long shadow ran off the last of them — the
11775
+ // floor beyond read as lit and her head and shoulders went missing.
11776
+ if (this.hasGround && !this.groundIsSuppressed()) {
11777
+ const [hx, hz] = this.groundHalf;
11778
+ minX = Math.min(minX, -hx);
11779
+ maxX = Math.max(maxX, hx);
11780
+ minY = Math.min(minY, this.groundY);
11781
+ maxY = Math.max(maxY, this.groundY);
11782
+ minZ = Math.min(minZ, -hz);
11783
+ maxZ = Math.max(maxZ, hz);
11784
+ }
11294
11785
  if (this.nativeStage) {
11295
11786
  const nb = this.nativeStage.bounds;
11296
11787
  minX = Math.min(minX, nb.min[0]);
@@ -11590,6 +12081,10 @@ export class Engine {
11590
12081
  */
11591
12082
  recordBundles() {
11592
12083
  this.bundlesDirty = false;
12084
+ // What the bundles record is what the probe would see: a stage arriving or
12085
+ // leaving, a look installed on it, a pipeline landing. Re-captured once it
12086
+ // settles (and switched off when the stage is gone).
12087
+ this.captureReflectionProbe();
11593
12088
  const scene = {
11594
12089
  colorFormats: sceneColorFormats(this.sceneFormats),
11595
12090
  depthStencilFormat: this.depthFormat,
@@ -11851,7 +12346,14 @@ export class Engine {
11851
12346
  * and any draw whose slot has not been assigned yet.
11852
12347
  */
11853
12348
  issueDraw(pass, draw, kind) {
11854
- const args = kind === "shadow" ? this.cullShadowArgs : kind === "mirror" ? this.cullMirrorArgs : this.cullCameraArgs;
12349
+ // The probe's cube faces have no cull of their own: drawn whole.
12350
+ const args = kind === "probe"
12351
+ ? null
12352
+ : kind === "shadow"
12353
+ ? this.cullShadowArgs
12354
+ : kind === "mirror"
12355
+ ? this.cullMirrorArgs
12356
+ : this.cullCameraArgs;
11855
12357
  if (args && draw.cullIndex >= 0) {
11856
12358
  pass.drawIndexedIndirect(args, draw.cullIndex * Engine.CULL_ARG_WORDS * 4);
11857
12359
  }
@@ -12095,8 +12597,9 @@ export class Engine {
12095
12597
  }));
12096
12598
  // Its ObjectLight: the world's ambient, and the layers its kind draws on —
12097
12599
  // the cast on the character layer as well as the default one. Words:
12098
- // layers [0..3], ambient flag [4..7], SH [8..43] — see materials/common.ts.
12099
- const objectLight = new Float32Array(44);
12600
+ // layers [0..3], ambient flag [4..7], SH [8..43], fill [44..47] — see
12601
+ // materials/common.ts.
12602
+ const objectLight = new Float32Array(48);
12100
12603
  new Uint32Array(objectLight.buffer)[0] =
12101
12604
  isStage || isPlane || isProp ? RENDERING_LAYER_DEFAULT : (RENDERING_LAYER_DEFAULT | RENDERING_LAYER_CHARACTER) >>> 0;
12102
12605
  const lightBuffer = this.device.createBuffer({
@@ -12111,7 +12614,8 @@ export class Engine {
12111
12614
  layout: this.pickPerInstanceBindGroupLayout,
12112
12615
  entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
12113
12616
  });
12114
- const gpuBuffers = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer];
12617
+ const attributeBuffer = this.createAttributeBuffer(name, model);
12618
+ const gpuBuffers = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer, attributeBuffer];
12115
12619
  const gpuMorph = this.createGpuMorph(name, model, vertexBuffer, gpuBuffers);
12116
12620
  // Cull bounds. The margin is the skinning reach plus the largest single
12117
12621
  // vertex-morph displacement — a face morph is millimetres against a reach of
@@ -12132,6 +12636,7 @@ export class Engine {
12132
12636
  indexBuffer,
12133
12637
  jointsBuffer,
12134
12638
  weightsBuffer,
12639
+ attributeBuffer,
12135
12640
  skinMatrixBuffer,
12136
12641
  wireEdges: new Map(),
12137
12642
  drawCalls: [],
@@ -12177,6 +12682,47 @@ export class Engine {
12177
12682
  this.bundlesDirty = true;
12178
12683
  this.updateOrderDirty = true;
12179
12684
  }
12685
+ /**
12686
+ * A model's attribute stream: vertex colour (RGBA) and a second UV set, six
12687
+ * floats a vertex, read by the material pipelines at slot 4.
12688
+ *
12689
+ * PMX has no vertex colour and one UV, but it has four ADDITIONAL UV
12690
+ * channels of four floats each, and that is where a model that needs more
12691
+ * keeps it: the first additional UV is the vertex colour, the second's xy
12692
+ * the second UV — the convention a converted game stage is written in (a
12693
+ * plant's occlusion rides in its vertex alpha, a light card's mask in its
12694
+ * second UV). A model without them reads white and its own UV, which is
12695
+ * what every graph that reads neither already assumed.
12696
+ */
12697
+ createAttributeBuffer(name, model) {
12698
+ const vertices = model.getVertices();
12699
+ const count = Math.floor(vertices.length / 8);
12700
+ const data = new Float32Array(count * Engine.ATTRIBUTE_FLOATS);
12701
+ const color = model.getAdditionalUv(1);
12702
+ const uv2 = model.getAdditionalUv(2);
12703
+ for (let i = 0; i < count; i++) {
12704
+ const o = i * Engine.ATTRIBUTE_FLOATS;
12705
+ if (color) {
12706
+ data[o] = color[i * 4];
12707
+ data[o + 1] = color[i * 4 + 1];
12708
+ data[o + 2] = color[i * 4 + 2];
12709
+ data[o + 3] = color[i * 4 + 3];
12710
+ }
12711
+ else {
12712
+ data[o] = data[o + 1] = data[o + 2] = data[o + 3] = 1;
12713
+ }
12714
+ data[o + 4] = uv2 ? uv2[i * 4] : vertices[i * 8 + 6];
12715
+ data[o + 5] = uv2 ? uv2[i * 4 + 1] : vertices[i * 8 + 7];
12716
+ }
12717
+ const buffer = this.device.createBuffer({
12718
+ label: `${name}: attribute buffer`,
12719
+ size: Math.max(data.byteLength, 4 * Engine.ATTRIBUTE_FLOATS),
12720
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
12721
+ });
12722
+ if (data.byteLength > 0)
12723
+ this.device.queue.writeBuffer(buffer, 0, data);
12724
+ return buffer;
12725
+ }
12180
12726
  // Build the per-model GPU vertex-morph state. Returns null (and leaves the model on the
12181
12727
  // CPU morph path) when the model has no vertex morphs. Created buffers are pushed into
12182
12728
  // gpuBuffers so they're released with the instance.
@@ -12303,7 +12849,10 @@ export class Engine {
12303
12849
  this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
12304
12850
  }
12305
12851
  buildGroundPipeline(soft, mirrored = false) {
12306
- return this.createRenderPipeline({
12852
+ return this.createRenderPipeline(this.groundPipelineConfig(soft, mirrored));
12853
+ }
12854
+ groundPipelineConfig(soft, mirrored = false) {
12855
+ return {
12307
12856
  ...this.groundShadowPipelineDesc,
12308
12857
  // A REFLECTION FLIPS WINDING — determinant -1, see reflection.ts. The
12309
12858
  // ground culls back faces, so drawn into the mirror pass with the ordinary
@@ -12313,11 +12862,8 @@ export class Engine {
12313
12862
  // the mirror, and the same answer the scene-pass pipelines already use.
12314
12863
  cullMode: mirrored ? "none" : this.groundShadowPipelineDesc.cullMode,
12315
12864
  label: `ground shadow pipeline${soft ? " (soft)" : ""}${mirrored ? " (mirror)" : ""}`,
12316
- shaderModule: this.device.createShaderModule({
12317
- label: soft ? "ground shadow (soft)" : "ground shadow",
12318
- code: groundShaderWgsl(soft),
12319
- }),
12320
- });
12865
+ shaderModule: this.cachedShaderModule(groundShaderWgsl(soft), soft ? "ground shadow (soft)" : "ground shadow"),
12866
+ };
12321
12867
  }
12322
12868
  /** The ground's pipeline for the mirror pass, built on the first frame a
12323
12869
  * mirror actually needs it. */
@@ -12452,6 +12998,7 @@ export class Engine {
12452
12998
  { binding: 12, resource: this.groundNoiseView },
12453
12999
  { binding: 13, resource: this.mirrorMaskView },
12454
13000
  { binding: 14, resource: { buffer: this.groundClipOffBuffer } },
13001
+ { binding: 15, resource: this.cookieView },
12455
13002
  ],
12456
13003
  });
12457
13004
  this.groundMirrorViewBindGroup = this.device.createBindGroup({
@@ -12474,6 +13021,7 @@ export class Engine {
12474
13021
  { binding: 12, resource: this.groundNoiseView },
12475
13022
  { binding: 13, resource: this.mirrorDummyColorView },
12476
13023
  { binding: 14, resource: { buffer: this.groundClipMirrorBuffer } },
13024
+ { binding: 15, resource: this.cookieView },
12477
13025
  ],
12478
13026
  });
12479
13027
  if (this.groundDrawCall)
@@ -12687,6 +13235,17 @@ export class Engine {
12687
13235
  ],
12688
13236
  });
12689
13237
  outline = { bindGroup: outlineBindGroup };
13238
+ if (!inst.outlineVertexBuffer) {
13239
+ const data = inst.model.getOutlineVertices();
13240
+ const buf = this.device.createBuffer({
13241
+ label: `${prefix}outline normals`,
13242
+ size: data.byteLength,
13243
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
13244
+ });
13245
+ this.device.queue.writeBuffer(buf, 0, data);
13246
+ inst.gpuBuffers.push(buf);
13247
+ inst.outlineVertexBuffer = buf;
13248
+ }
12690
13249
  }
12691
13250
  // Model-space AABB for the cull compute, grown by the three things that can
12692
13251
  // put geometry outside the vertices it was measured from: a vertex morph,
@@ -13620,39 +14179,6 @@ export class Engine {
13620
14179
  return new Vec3(x / w, y / w, z / w);
13621
14180
  }
13622
14181
  // World-space ray from camera through a canvas pixel. Uses WebGPU's NDC z ∈ [0,1].
13623
- /**
13624
- * Where a point on the canvas lands on a horizontal plane.
13625
- *
13626
- * `px,py` are canvas-relative pixels, top-left origin — what a pointer event
13627
- * gives you after subtracting the element's rect. Returns null when the ray
13628
- * cannot reach the plane: parallel to it, or pointing the other way, which is
13629
- * what a click on the sky above the horizon is.
13630
- *
13631
- * The one primitive a placement UI needs. Dragging a thing across the floor is
13632
- * otherwise three sliders in world units, which asks someone to guess numbers
13633
- * that have no visible relation to the picture they are looking at — and it
13634
- * throws away the property that makes pointing work at all: under perspective,
13635
- * moving something further away makes it smaller by exactly the right amount,
13636
- * so position and size stop being two controls to tune against each other.
13637
- */
13638
- groundPointAt(px, py, planeY = 0) {
13639
- const ray = this.buildMouseRay(px, py);
13640
- if (!ray)
13641
- return null;
13642
- // Parallel to the plane: no intersection, and a huge one is not an answer.
13643
- if (Math.abs(ray.dir.y) < 1e-6)
13644
- return null;
13645
- const t = (planeY - ray.origin.y) / ray.dir.y;
13646
- // Behind the camera — the plane is there, but not in this shot.
13647
- if (!(t > 0) || !isFinite(t))
13648
- return null;
13649
- return new Vec3(ray.origin.x + ray.dir.x * t, planeY, ray.origin.z + ray.dir.z * t);
13650
- }
13651
- /** Hand the pointer to something else — a placement drag, a gizmo, a host's own
13652
- * overlay — so the orbit does not also act on it. */
13653
- setCameraInputLocked(locked) {
13654
- this.camera?.setInputLocked(locked);
13655
- }
13656
14182
  buildMouseRay(px, py) {
13657
14183
  if (!this.camera)
13658
14184
  return null;
@@ -14052,6 +14578,10 @@ export class Engine {
14052
14578
  const idsRead = this.idDebug || this.effects.some((e) => e.readsIds || e.readsCastDistance);
14053
14579
  idAtt.storeOp = idsRead ? "store" : "discard";
14054
14580
  }
14581
+ // The stage's reflection probe, when one is due: its own submits, ahead of
14582
+ // this frame's, so every material below reads the fresh cube.
14583
+ if (hasModels)
14584
+ this.maybeCaptureProbe();
14055
14585
  const encoder = this.device.createCommandEncoder();
14056
14586
  // GPU vertex morphs: write morphed positions into vertex buffers before any pass reads
14057
14587
  // them. WebGPU inserts the storage→vertex barrier between this pass and the render passes.
@@ -14252,54 +14782,16 @@ export class Engine {
14252
14782
  // The field mounts, likewise: after the scene so foregrounds can read its
14253
14783
  // depth, before the composite that samples both layers.
14254
14784
  this.renderFieldPass(encoder);
14255
- // Bloom pyramid (EEVEE 3.6):
14256
- // 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
14257
- // 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
14258
- // 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
14259
- // then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
14260
- // Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
14261
- // bloomContributes() gates the whole pyramid, not just its intensity. The
14262
- // composite still SAMPLES bloomUp[0] unconditionally, which is safe and
14263
- // deliberate: it scales what it reads by the same effective intensity, so a
14264
- // stale or never-written pyramid is multiplied by zero. Skipping the build
14265
- // is therefore invisible in the frame and nine render passes cheaper.
14266
- if (this.bloomContributes() && this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
14267
- const bloomAtt = this.bloomPassDescriptor.colorAttachments;
14268
- // 1. Blit — opens the pyramid's timing span. See stampOpen: the nine
14269
- // passes below read as ONE component, which is the only useful grain.
14270
- bloomAtt[0].view = this.bloomDownMipViews[0];
14271
- this.bloomPassDescriptor.timestampWrites = this.stampOpen("bloom");
14272
- const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor);
14273
- pBlit.setPipeline(this.bloomBlitPipeline);
14274
- pBlit.setBindGroup(0, this.bloomBlitBindGroup);
14275
- pBlit.draw(3);
14276
- pBlit.end();
14277
- // 2. Downsample chain
14278
- this.bloomPassDescriptor.timestampWrites = undefined;
14279
- for (let i = 1; i < this.bloomMipCount; i++) {
14280
- bloomAtt[0].view = this.bloomDownMipViews[i];
14281
- const p = encoder.beginRenderPass(this.bloomPassDescriptor);
14282
- p.setPipeline(this.bloomDownsamplePipeline);
14283
- p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1]);
14284
- p.draw(3);
14285
- p.end();
14286
- }
14287
- // 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
14288
- const upSteps = this.bloomUpsampleBindGroups.length;
14289
- const topIdx = this.bloomMipCount - 2;
14290
- for (let k = 0; k < upSteps; k++) {
14291
- const levelIdx = topIdx - k; // writes bloomUp[levelIdx]
14292
- bloomAtt[0].view = this.bloomUpMipViews[levelIdx];
14293
- // The LAST upsample closes the span opened on the blit.
14294
- this.bloomPassDescriptor.timestampWrites = k === upSteps - 1 ? this.stampClose("bloom") : undefined;
14295
- const p = encoder.beginRenderPass(this.bloomPassDescriptor);
14296
- p.setPipeline(this.bloomUpsamplePipeline);
14297
- p.setBindGroup(0, this.bloomUpsampleBindGroups[k]);
14298
- p.draw(3);
14299
- p.end();
14300
- }
14301
- }
14302
- // Composite: HDR + bloom → Filmic tonemap → swapchain.
14785
+ // Bloom (renderBloom): prefilter, the Gaussian chain down, the scatter
14786
+ // blend back up; the composite adds up[0] × tint × intensity before the view
14787
+ // transform. bloomContributes() gates the whole chain, not just its
14788
+ // intensity. The composite still SAMPLES up[0] unconditionally, which is safe
14789
+ // and deliberate: it scales what it reads by the same effective intensity, so
14790
+ // a stale or never-written chain is multiplied by zero. Skipping the build is
14791
+ // therefore invisible in the frame and a couple of dozen render passes cheaper.
14792
+ if (this.bloomContributes() && this.bloomPrefilterBindGroup && this.compositeBindGroup && this.bloomLevels > 0)
14793
+ this.renderBloom(encoder);
14794
+ // Composite: HDR + bloom → view transform → swapchain.
14303
14795
  const swapchainView = this.context.getCurrentTexture().createView();
14304
14796
  const compositeAttachment = this.compositePassDescriptor.colorAttachments[0];
14305
14797
  compositeAttachment.view = swapchainView;
@@ -14444,11 +14936,12 @@ export class Engine {
14444
14936
  outgoing.push([id, install]);
14445
14937
  }
14446
14938
  }
14447
- const groupResults = [];
14448
- for (const g of groups) {
14449
- const r = await this.compileAndInstallGroup(inst, g);
14450
- groupResults.push({ groupId: g.id, diagnostics: r.diagnostics, ok: r.ok });
14451
- }
14939
+ // ALL GROUPS AT ONCE. Each install touches only its own id — its entry in
14940
+ // styleGroups, its uniform buffer, the draw calls already bound to it — so
14941
+ // the order they land in changes nothing, and each keeps its own staleness
14942
+ // guard (styleGroupGen). Awaiting them in turn made a model's styling the
14943
+ // sum of its compiles; the GPU process runs them side by side.
14944
+ const groupResults = (await Promise.all(groups.map((g) => this.compileAndInstallGroup(inst, g)))).map((r, i) => ({ groupId: groups[i].id, diagnostics: r.diagnostics, ok: r.ok }));
14452
14945
  // Repoints every draw call away from the outgoing installs (among
14453
14946
  // everything else) — only past this line is destroying them safe.
14454
14947
  this.assignDrawCallGroups(inst, claimed);
@@ -14463,6 +14956,38 @@ export class Engine {
14463
14956
  conflicts: [...conflicts],
14464
14957
  };
14465
14958
  }
14959
+ /**
14960
+ * Compile style groups' pipelines before any model needs them — a scene's
14961
+ * groups while its bundle is still downloading. Installs nothing: the
14962
+ * pipelines land in the shader cache, and applyStyleGroups later finds them
14963
+ * there (or still in flight). A group that does not compile is skipped
14964
+ * silently; its real install reports it.
14965
+ */
14966
+ async prewarmStyleGroups(groups) {
14967
+ if (!this.device)
14968
+ return;
14969
+ await Promise.all(groups.map(async (g) => {
14970
+ const renderClass = g.renderClass ?? "auto";
14971
+ const alphaMode = g.alphaMode ?? "opaque";
14972
+ const blend = g.blend ?? "over";
14973
+ let result;
14974
+ try {
14975
+ result = compileGraph(g.graph, { renderClass, alphaMode, blend });
14976
+ }
14977
+ catch {
14978
+ return;
14979
+ }
14980
+ if (!result.ok)
14981
+ return;
14982
+ const module = this.cachedShaderModule(result.wgsl, `style group: ${g.id} (${renderClass})`);
14983
+ await Promise.all([
14984
+ this.createRenderClassPipeline(renderClass, module, false, true, false, blend),
14985
+ this.createRenderClassPipeline(renderClass, module, false, false, false, blend),
14986
+ renderClass === "hair" ? this.createRenderClassPipeline(renderClass, module, true, true, false, blend) : null,
14987
+ renderClass === "eye" ? this.createRenderClassPipeline(renderClass, module, false, true, true, blend) : null,
14988
+ ]).catch(() => { });
14989
+ }));
14990
+ }
14466
14991
  /** Add or replace a single style group by id. `opts` may carry a `previewNode` for the
14467
14992
  * editor's node-output preview workflow. */
14468
14993
  async upsertStyleGroup(modelName, group, opts) {
@@ -14814,9 +15339,35 @@ export class Engine {
14814
15339
  const generation = (inst.styleGroupGen.get(group.id) ?? 0) + 1;
14815
15340
  inst.styleGroupGen.set(group.id, generation);
14816
15341
  this.device.pushErrorScope("validation");
14817
- const module = this.device.createShaderModule({ label: `style group: ${group.id} (${renderClass})`, code: result.wgsl });
14818
- const info = await module.getCompilationInfo();
14819
- const scopeError = await this.device.popErrorScope();
15342
+ const module = this.cachedShaderModule(result.wgsl, `style group: ${group.id} (${renderClass})`);
15343
+ const scopePromise = this.device.popErrorScope();
15344
+ // EVERY VARIANT AT ONCE, and the module's diagnostics read beside them
15345
+ // rather than first: the compiles run in parallel in the GPU process, and
15346
+ // awaiting each in turn (and the diagnostics before any) made a group cost
15347
+ // the sum of its pipelines instead of the longest.
15348
+ const blend = group.blend ?? "over";
15349
+ // The single-sided twins a draw of this group will ask for (sidedPipeline),
15350
+ // built with their pipelines rather than synchronously at the first draw.
15351
+ const singleSided = inst.drawCalls.some((dc) => !dc.doubleSided && group.materials.includes(dc.materialName));
15352
+ const variant = (overEyes, depthWrite, mirrored) => this.createRenderClassPipeline(renderClass, module, overEyes, depthWrite, mirrored, blend).then(async (p) => {
15353
+ if (singleSided && !mirrored)
15354
+ await this.buildSidedTwin(p, false);
15355
+ return p;
15356
+ });
15357
+ const variants = Promise.all([
15358
+ variant(false, true, false),
15359
+ // The depth-write-off twin: stage transparency draws with it (see
15360
+ // pipelineForDrawCall), and a future OIT path would too.
15361
+ variant(false, false, false),
15362
+ renderClass === "hair" ? variant(true, true, false) : undefined,
15363
+ // Only the eye needs one: every other class culls "none", which a flipped
15364
+ // winding leaves alone.
15365
+ renderClass === "eye" ? variant(false, true, true) : undefined,
15366
+ ]);
15367
+ // Handled below either way; this keeps a rejection off the console while
15368
+ // the diagnostics are read.
15369
+ variants.catch(() => { });
15370
+ const [info, scopeError] = await Promise.all([module.getCompilationInfo(), scopePromise]);
14820
15371
  const diagnostics = [...result.diagnostics];
14821
15372
  for (const msg of info.messages) {
14822
15373
  if (msg.type !== "error")
@@ -14826,6 +15377,7 @@ export class Engine {
14826
15377
  if (diagnostics.some((d) => d.severity === "error") || scopeError) {
14827
15378
  if (scopeError && !diagnostics.some((d) => d.severity === "error"))
14828
15379
  diagnostics.push({ severity: "error", message: `WGSL: ${scopeError.message}` });
15380
+ this.forgetShaderModule(result.wgsl);
14829
15381
  return { ok: false, diagnostics, slotMap: result.slotMap };
14830
15382
  }
14831
15383
  let pipeline;
@@ -14833,17 +15385,8 @@ export class Engine {
14833
15385
  let overEyesPipeline;
14834
15386
  let mirrorPipeline;
14835
15387
  try {
14836
- const blend = group.blend ?? "over";
14837
- pipeline = await this.createRenderClassPipeline(renderClass, module, false, true, false, blend);
14838
- // The depth-write-off twin: stage transparency draws with it (see
14839
- // pipelineForDrawCall), and a future OIT path would too.
14840
- pipelineNoDepthWrite = await this.createRenderClassPipeline(renderClass, module, false, false, false, blend);
14841
- if (renderClass === "hair")
14842
- overEyesPipeline = await this.createRenderClassPipeline(renderClass, module, true, true, false, blend);
14843
- // Only the eye needs one: every other class culls "none", which a flipped
14844
- // winding leaves alone.
14845
- if (renderClass === "eye")
14846
- mirrorPipeline = await this.createRenderClassPipeline(renderClass, module, false, true, true, blend);
15388
+ ;
15389
+ [pipeline, pipelineNoDepthWrite, overEyesPipeline, mirrorPipeline] = await variants;
14847
15390
  }
14848
15391
  catch (e) {
14849
15392
  diagnostics.push({ severity: "error", message: `pipeline creation failed: ${e.message}` });
@@ -15002,7 +15545,7 @@ export class Engine {
15002
15545
  const base = {
15003
15546
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
15004
15547
  layout: this.mainPipelineLayout,
15005
- vertex: { module, buffers: this.fullVertexBufferLayouts },
15548
+ vertex: { module, buffers: this.materialVertexBufferLayouts },
15006
15549
  // The eye front-culls — the see-through-hair look is draw order plus that
15007
15550
  // cull plus the stencil stamp, and it is the deployed appearance of every
15008
15551
  // published scene. In the MIRROR the winding is flipped (determinant -1,
@@ -15061,11 +15604,20 @@ export class Engine {
15061
15604
  }
15062
15605
  const desc = {
15063
15606
  ...base,
15064
- fragment: { module, constants, targets: blend === "additive" ? this.sceneTargetsAdditive : this.sceneTargets },
15607
+ fragment: {
15608
+ module,
15609
+ constants,
15610
+ targets: blend === "additive"
15611
+ ? this.sceneTargetsAdditive
15612
+ : blend === "premultiplied"
15613
+ ? this.sceneTargetsPremultiplied
15614
+ : this.sceneTargets,
15615
+ },
15065
15616
  depthStencil,
15066
15617
  };
15067
- return this.device.createRenderPipelineAsync(desc).then((pipeline) => {
15068
- this.pipelineDescs.set(pipeline, desc);
15618
+ return this.cachedRenderPipeline(desc).then((pipeline) => {
15619
+ if (!this.pipelineDescs.has(pipeline))
15620
+ this.pipelineDescs.set(pipeline, desc);
15069
15621
  return pipeline;
15070
15622
  });
15071
15623
  }
@@ -15076,6 +15628,151 @@ export class Engine {
15076
15628
  this.pipelineDescs.set(pipeline, desc);
15077
15629
  return pipeline;
15078
15630
  }
15631
+ static trimCache(cache, max) {
15632
+ while (cache.size > max)
15633
+ cache.delete(cache.keys().next().value);
15634
+ }
15635
+ /** A small stable id per GPU object, for cache keys. */
15636
+ gpuObjectId(o) {
15637
+ let id = this.gpuObjectIds.get(o);
15638
+ if (id === undefined) {
15639
+ id = this.gpuObjectNext++;
15640
+ this.gpuObjectIds.set(o, id);
15641
+ }
15642
+ return id;
15643
+ }
15644
+ /** One module per WGSL source. */
15645
+ cachedShaderModule(code, label) {
15646
+ let module = this.shaderModuleCache.get(code);
15647
+ if (module) {
15648
+ this.shaderModuleCache.delete(code);
15649
+ }
15650
+ else {
15651
+ module = this.device.createShaderModule({ label, code });
15652
+ }
15653
+ this.shaderModuleCache.set(code, module);
15654
+ Engine.trimCache(this.shaderModuleCache, Engine.SHADER_CACHE_MAX);
15655
+ return module;
15656
+ }
15657
+ /** Drop a source whose module did not validate, so a retry reports afresh. */
15658
+ forgetShaderModule(code) {
15659
+ this.shaderModuleCache.delete(code);
15660
+ }
15661
+ /** One bind group layout per entry list. */
15662
+ cachedBindGroupLayout(desc) {
15663
+ const key = JSON.stringify(desc.entries);
15664
+ let layout = this.bindGroupLayoutCache.get(key);
15665
+ if (!layout) {
15666
+ layout = this.device.createBindGroupLayout(desc);
15667
+ this.bindGroupLayoutCache.set(key, layout);
15668
+ }
15669
+ return layout;
15670
+ }
15671
+ /** One pipeline layout per bind group layout list. */
15672
+ cachedPipelineLayout(bindGroupLayouts) {
15673
+ const key = bindGroupLayouts.map((l) => this.gpuObjectId(l)).join(",");
15674
+ let layout = this.pipelineLayoutCache.get(key);
15675
+ if (!layout) {
15676
+ layout = this.device.createPipelineLayout({ bindGroupLayouts });
15677
+ this.pipelineLayoutCache.set(key, layout);
15678
+ }
15679
+ return layout;
15680
+ }
15681
+ pipelineKey(kind, desc) {
15682
+ return (kind +
15683
+ JSON.stringify(desc, (k, v) => k === "label" ? undefined : (k === "module" || k === "layout") && v && typeof v === "object" ? `#${this.gpuObjectId(v)}` : v));
15684
+ }
15685
+ cachedPipeline(key, build) {
15686
+ let p = this.pipelineCache.get(key);
15687
+ if (p) {
15688
+ this.pipelineCache.delete(key);
15689
+ }
15690
+ else {
15691
+ p = build().catch((e) => {
15692
+ this.pipelineCache.delete(key);
15693
+ throw e;
15694
+ });
15695
+ }
15696
+ this.pipelineCache.set(key, p);
15697
+ Engine.trimCache(this.pipelineCache, Engine.PIPELINE_CACHE_MAX);
15698
+ return p;
15699
+ }
15700
+ /** createRenderPipelineAsync, once per module + state. */
15701
+ cachedRenderPipeline(desc) {
15702
+ return this.cachedPipeline(this.pipelineKey("render", desc), () => this.device.createRenderPipelineAsync(desc));
15703
+ }
15704
+ /** createComputePipelineAsync, once per module + state. */
15705
+ cachedComputePipeline(desc) {
15706
+ return this.cachedPipeline(this.pipelineKey("compute", desc), () => this.device.createComputePipelineAsync(desc));
15707
+ }
15708
+ /**
15709
+ * A pipeline init builds: async, in parallel with the rest, and in place by
15710
+ * the time init returns (it awaits every job). Outside init — a pass built
15711
+ * on first use — it is the synchronous build it always was.
15712
+ */
15713
+ initRenderPipeline(desc, assign, twins = false) {
15714
+ if (!this.initPipelineJobs) {
15715
+ const pipeline = this.device.createRenderPipeline(desc);
15716
+ if (twins)
15717
+ this.pipelineDescs.set(pipeline, desc);
15718
+ assign(pipeline);
15719
+ return;
15720
+ }
15721
+ this.initPipelineJobs.push(this.cachedRenderPipeline(desc)
15722
+ // A rejected build is rebuilt the old way, which hands back the invalid
15723
+ // pipeline and reports through uncapturederror — a bad init pipeline
15724
+ // was never fatal to init, and is not now.
15725
+ .catch(() => this.device.createRenderPipeline(desc))
15726
+ .then((pipeline) => {
15727
+ assign(pipeline);
15728
+ if (!twins)
15729
+ return;
15730
+ this.pipelineDescs.set(pipeline, desc);
15731
+ // The mirror's twin too, but not waited for: only a scene with a
15732
+ // mirror draws it, and it is a cache hit on the module just built.
15733
+ void this.buildSidedTwin(pipeline, true);
15734
+ return this.buildSidedTwin(pipeline, false);
15735
+ }));
15736
+ }
15737
+ /** The compute twin of initRenderPipeline. */
15738
+ initComputePipeline(desc, assign) {
15739
+ if (!this.initPipelineJobs) {
15740
+ assign(this.device.createComputePipeline(desc));
15741
+ return;
15742
+ }
15743
+ this.initPipelineJobs.push(this.cachedComputePipeline(desc)
15744
+ .catch(() => this.device.createComputePipeline(desc))
15745
+ .then(assign));
15746
+ }
15747
+ /**
15748
+ * Build a model pipeline's single-sided twin off the draw path (see
15749
+ * sidedPipeline). Resolves once it is in place; never rejects — a twin that
15750
+ * fails leaves the draw on its double-sided pipeline, as before it existed.
15751
+ */
15752
+ buildSidedTwin(pipeline, mirrored) {
15753
+ const desc = this.pipelineDescs.get(pipeline);
15754
+ if (!desc || (desc.primitive?.cullMode ?? "none") !== "none")
15755
+ return Promise.resolve();
15756
+ const cache = mirrored ? this.singleSidedMirror : this.singleSided;
15757
+ const pending = mirrored ? this.sidedPendingMirror : this.sidedPending;
15758
+ if (cache.has(pipeline) || pending.has(pipeline))
15759
+ return Promise.resolve();
15760
+ pending.add(pipeline);
15761
+ return this.cachedRenderPipeline({
15762
+ ...desc,
15763
+ label: `${desc.label ?? "pipeline"} (single-sided${mirrored ? ", mirror" : ""})`,
15764
+ primitive: { ...desc.primitive, cullMode: mirrored ? "back" : "front" },
15765
+ }).then((twin) => {
15766
+ cache.set(pipeline, twin);
15767
+ pending.delete(pipeline);
15768
+ // A bundle recorded meanwhile holds the double-sided stand-in.
15769
+ this.bundlesDirty = true;
15770
+ }, () => {
15771
+ // Remembered as itself, so the draw stops asking.
15772
+ cache.set(pipeline, pipeline);
15773
+ pending.delete(pipeline);
15774
+ });
15775
+ }
15079
15776
  /**
15080
15777
  * The pipeline a draw should use once its double-sided flag is honoured.
15081
15778
  *
@@ -15095,17 +15792,15 @@ export class Engine {
15095
15792
  const desc = this.pipelineDescs.get(pipeline);
15096
15793
  if (!desc || (desc.primitive?.cullMode ?? "none") !== "none")
15097
15794
  return pipeline;
15098
- const cache = mirrored ? this.singleSidedMirror : this.singleSided;
15099
- let twin = cache.get(pipeline);
15100
- if (!twin) {
15101
- twin = this.device.createRenderPipeline({
15102
- ...desc,
15103
- label: `${desc.label ?? "pipeline"} (single-sided${mirrored ? ", mirror" : ""})`,
15104
- primitive: { ...desc.primitive, cullMode: mirrored ? "back" : "front" },
15105
- });
15106
- cache.set(pipeline, twin);
15107
- }
15108
- return twin;
15795
+ const twin = (mirrored ? this.singleSidedMirror : this.singleSided).get(pipeline);
15796
+ if (twin)
15797
+ return twin;
15798
+ // NEVER BUILT HERE SYNCHRONOUSLY: a sync build stalls the GPU process for
15799
+ // the whole compile, mid-frame. The camera's twins are built beside their
15800
+ // pipelines; one asked for first here (a mirror's) is started now and the
15801
+ // draw keeps its double-sided pipeline until it lands.
15802
+ void this.buildSidedTwin(pipeline, mirrored);
15803
+ return pipeline;
15109
15804
  }
15110
15805
  // Pipeline for a material draw call: its group's compiled pipeline when grouped, else
15111
15806
  // the neutral base (ungrouped materials render the default graph). Transparent-bucket
@@ -15136,6 +15831,42 @@ export class Engine {
15136
15831
  * makes outlines compose like MMD: every material drawn later in the author's
15137
15832
  * order covers earlier hulls, and each hull sits over everything drawn before it.
15138
15833
  */
15834
+ /**
15835
+ * A stage's draws, far to near from the eye: its transparent queue, in the
15836
+ * order a game sorts one. Each draw's distance is its material box's centre,
15837
+ * placed, against the camera — a pane or a water surface is one material,
15838
+ * which is the unit a game sorts too. The array is kept per instance and
15839
+ * re-sorted in place, so an unchanged order costs a comparison per draw.
15840
+ */
15841
+ backToFront(inst) {
15842
+ let list = this.sortedDraws.get(inst);
15843
+ if (!list || list.length !== inst.drawCalls.length || list.some((d) => !inst.drawCalls.includes(d))) {
15844
+ list = [...inst.drawCalls];
15845
+ this.sortedDraws.set(inst, list);
15846
+ }
15847
+ const m = inst.model;
15848
+ const ex = this.cameraMatrixData[32];
15849
+ const ey = this.cameraMatrixData[33];
15850
+ const ez = this.cameraMatrixData[34];
15851
+ const p = this.sortScratch;
15852
+ const dist = this.sortDistance;
15853
+ dist.clear();
15854
+ for (const d of list) {
15855
+ const b = d.bounds;
15856
+ if (!b || b.length < 6 || !(b[0] <= b[3])) {
15857
+ dist.set(d, 0);
15858
+ continue;
15859
+ }
15860
+ 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);
15861
+ Quat.rotateVecInto(m.rotation, p, p);
15862
+ const dx = p.x + m.position.x - ex;
15863
+ const dy = p.y + m.position.y - ey;
15864
+ const dz = p.z + m.position.z - ez;
15865
+ dist.set(d, dx * dx + dy * dy + dz * dz);
15866
+ }
15867
+ list.sort((a, b) => dist.get(b) - dist.get(a));
15868
+ return list;
15869
+ }
15139
15870
  /** Is this draw's compiled class "hair"? Ungrouped draws never are — the
15140
15871
  * neutral pipeline is the auto class. */
15141
15872
  isHairDraw(inst, dc) {
@@ -15151,11 +15882,20 @@ export class Engine {
15151
15882
  only) {
15152
15883
  let currentPipeline = null;
15153
15884
  let bound = false;
15154
- for (const draw of inst.drawCalls) {
15885
+ const outlined = [];
15886
+ // A stage's glass and water in the camera pass go far to near, as a
15887
+ // game's transparent queue does: author order put a far pane over a near
15888
+ // one wherever the author listed it last. Characters keep MMD's author
15889
+ // order — their layers are fabric, built around it.
15890
+ const draws = type === "transparent" && inst.isStage && view.args === "camera" ? this.backToFront(inst) : inst.drawCalls;
15891
+ for (const draw of draws) {
15155
15892
  if (draw.type !== type)
15156
15893
  continue;
15157
15894
  if (only && (only === "hair") !== this.isHairDraw(inst, draw))
15158
15895
  continue;
15896
+ // The probe draws directly, past the cull: honour the switches it holds.
15897
+ if (view.args === "probe" && !this.shouldRenderDrawCall(inst, draw))
15898
+ continue;
15159
15899
  if (!bound) {
15160
15900
  pass.setBindGroup(0, view.perFrame);
15161
15901
  pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
@@ -15169,31 +15909,43 @@ export class Engine {
15169
15909
  }
15170
15910
  pass.setBindGroup(2, draw.bindGroup);
15171
15911
  this.issueDraw(pass, draw, view.args);
15172
- if (draw.outline && this.outlineEnabled && view.outlines) {
15173
- // Same index range; own pipeline + groups 0/2. Group 1 (skinMats) is
15174
- // layout-identical between the main and outline pipelines and stays
15175
- // bound. Restore group 0 afterwards and force a pipeline re-set.
15176
- const mirrored = view.args === "mirror";
15177
- pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline);
15178
- pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup);
15912
+ if (draw.outline && this.outlineEnabled && view.outlines)
15913
+ outlined.push(draw);
15914
+ }
15915
+ // THE HULLS AFTER THE SURFACES, as MMD draws a model's edges. A hull held
15916
+ // at the minimum width is faded, and it still writes depth: drawn between
15917
+ // two materials it hid the second wherever it stood in front of it — a
15918
+ // shoe's sole's hull cut the upper behind it, and the floor showed through
15919
+ // as pale lines along the seam. After every surface of this phase it only
15920
+ // ever blends over finished ones. Same index ranges; own pipeline and
15921
+ // groups 0/2 — group 1 (skinMats) is layout-identical and stays bound.
15922
+ if (outlined.length) {
15923
+ const mirrored = view.args === "mirror";
15924
+ pass.setPipeline(mirrored ? this.outlineMirrorPipeline : this.outlinePipeline);
15925
+ pass.setBindGroup(0, mirrored ? this.outlineMirrorPerFrameBindGroup : this.outlinePerFrameBindGroup);
15926
+ // Slot 3: the hull's smoothed normals + edge scale. Only the outline
15927
+ // pipeline reads it; the others leave the slot alone.
15928
+ if (inst.outlineVertexBuffer)
15929
+ pass.setVertexBuffer(3, inst.outlineVertexBuffer);
15930
+ for (const draw of outlined) {
15179
15931
  pass.setBindGroup(2, draw.outline.bindGroup);
15180
15932
  this.issueDraw(pass, draw, view.args);
15181
- pass.setBindGroup(0, view.perFrame);
15182
- currentPipeline = null;
15183
15933
  }
15934
+ pass.setBindGroup(0, view.perFrame);
15184
15935
  }
15185
15936
  }
15186
15937
  /**
15187
15938
  * Main-pass render sequence for one model instance — babylon-mmd parity:
15188
15939
  * opaque bucket, the hair-over-eyes stencil pass, then alpha-blend materials
15189
- * in PMX author order with depth write ON (forceDepthWrite). Outlines are not
15190
- * a separate phase: drawMaterials draws each edge-flagged material's hull
15191
- * right after the material itself, like MMD's per-mesh outline stage.
15940
+ * in PMX author order with depth write ON (forceDepthWrite). Outlines close
15941
+ * each drawMaterials call: the edge-flagged materials' hulls after all of its
15942
+ * surfaces, as MMD draws a model's edges.
15192
15943
  */
15193
15944
  setModelDrawState(pass, inst) {
15194
15945
  pass.setVertexBuffer(0, inst.vertexBuffer);
15195
15946
  pass.setVertexBuffer(1, inst.jointsBuffer);
15196
15947
  pass.setVertexBuffer(2, inst.weightsBuffer);
15948
+ pass.setVertexBuffer(4, inst.attributeBuffer);
15197
15949
  pass.setIndexBuffer(inst.indexBuffer, "uint32");
15198
15950
  // The stencil reference used to be set here. It is pass state, not bundle
15199
15951
  // state — GPURenderBundleEncoder has no setStencilReference at all — so it
@@ -15527,7 +16279,7 @@ export class Engine {
15527
16279
  // still pattern welded to the picture. On the SCENE clock like everything
15528
16280
  // else here, so an export reproduces the editor exactly rather than
15529
16281
  // scattering differently at whatever rate the encoder ran.
15530
- u[3] = this.grain.animated ? Math.floor((this.sceneClock * 24) % 1024) : 0;
16282
+ u[3] = Math.floor((this.sceneClock * 24) % 1024);
15531
16283
  u[26] = this.canvas.width;
15532
16284
  u[27] = this.canvas.height;
15533
16285
  // Camera world position (viewU[10]) — the other half of bgWorldPos. It
@@ -15768,7 +16520,10 @@ export class Engine {
15768
16520
  }
15769
16521
  for (let s = 0; s < anchors.length; s++) {
15770
16522
  const a = CAST_SUBJECT_VEC4S * 4 + (s * MAX_EFFECT_SUBJECTS + n) * 12;
15771
- const pos = m.getBoneWorldPosition(anchors[s].bone);
16523
+ // `along`: the point that far down the bone's own axis, still model space
16524
+ // here, so the placement below turns and scales it like the joint.
16525
+ const along = anchors[s].along;
16526
+ const pos = along ? m.getBonePointAlong(anchors[s].bone, along) : m.getBoneWorldPosition(anchors[s].bone);
15772
16527
  if (!pos) {
15773
16528
  cd[a + 3] = 0;
15774
16529
  continue;
@@ -15791,7 +16546,7 @@ export class Engine {
15791
16546
  cd[a + 5] = vy;
15792
16547
  cd[a + 6] = vz;
15793
16548
  if (anchors[s].trail)
15794
- this.writeTrail(inst.name, s, n, pos, cd, a);
16549
+ this.writeTrail(inst.name, s, n, pos, cd, a, anchors[s].step);
15795
16550
  const fwd = m.getBoneWorldForward(anchors[s].bone);
15796
16551
  if (fwd) {
15797
16552
  Quat.rotateVecInto(m.rotation, fwd, fwd);
@@ -15831,7 +16586,7 @@ export class Engine {
15831
16586
  // effect's counts before the next upload replaces them.
15832
16587
  this.castData.fill(0, CAST_SUBJECT_VEC4S * 4);
15833
16588
  }
15834
- writeTrail(model, slot, n, pos, cd, anchorBase) {
16589
+ writeTrail(model, slot, n, pos, cd, anchorBase, step) {
15835
16590
  const key = `${model}\u0000${slot}`;
15836
16591
  let ring = this.anchorTrail.get(key);
15837
16592
  if (!ring) {
@@ -15852,9 +16607,47 @@ export class Engine {
15852
16607
  if (Math.hypot(dx, dy, dz) / dt > 50) {
15853
16608
  ring.pos.length = 0;
15854
16609
  ring.t.length = 0;
16610
+ ring.live = false;
16611
+ }
16612
+ }
16613
+ if (step && ring.pos.length > 0) {
16614
+ // `trail step d` — the path a game's trail keeps (Unity's
16615
+ // minVertexDistance). Point 0 is the LIVE head: it follows the bone every
16616
+ // frame. It is left behind as a kept point only on a tick, and only once
16617
+ // the bone is `step` from the last point kept, so no segment is shorter
16618
+ // than that and a slowing hand cannot leave a knot of jittery stubs for a
16619
+ // wide ribbon to fan across. Still at most one point per tick, so a stepped
16620
+ // ring never covers LESS time than a sampled one.
16621
+ const live = ring.live === true;
16622
+ const k = live ? 3 : 0;
16623
+ const far = Math.hypot(pos.x - ring.pos[k], pos.y - ring.pos[k + 1], pos.z - ring.pos[k + 2]) >= step;
16624
+ if (live && !(far && this.trailDue > 0)) {
16625
+ ring.pos[0] = pos.x;
16626
+ ring.pos[1] = pos.y;
16627
+ ring.pos[2] = pos.z;
16628
+ ring.t[0] = this.sceneClock;
16629
+ }
16630
+ else {
16631
+ // keep the head where it is as a point (live) or start a new head
16632
+ if (live) {
16633
+ ring.pos[0] = pos.x;
16634
+ ring.pos[1] = pos.y;
16635
+ ring.pos[2] = pos.z;
16636
+ ring.t[0] = this.sceneClock;
16637
+ ring.live = false;
16638
+ }
16639
+ else {
16640
+ ring.pos.unshift(pos.x, pos.y, pos.z);
16641
+ ring.t.unshift(this.sceneClock);
16642
+ ring.live = !(far && this.trailDue > 0);
16643
+ }
16644
+ if (ring.t.length > TRAIL_SAMPLES) {
16645
+ ring.t.length = TRAIL_SAMPLES;
16646
+ ring.pos.length = TRAIL_SAMPLES * 3;
16647
+ }
15855
16648
  }
15856
16649
  }
15857
- if (this.trailDue > 0 || ring.pos.length === 0) {
16650
+ else if (this.trailDue > 0 || ring.pos.length === 0) {
15858
16651
  // ONE sample per frame, never one per due tick. A frame that spanned
15859
16652
  // several 60Hz ticks only knows where the bone is NOW, and unshifting that
15860
16653
  // position once per tick fabricated duplicate samples — same point, same
@@ -15938,10 +16731,32 @@ export class Engine {
15938
16731
  headSkin: () => (head >= 0 ? inst.model.getSkinMatrices().subarray(head * 16, head * 16 + 16) : null),
15939
16732
  headRest,
15940
16733
  layers: new Uint32Array(inst.objectLight.buffer)[0],
16734
+ additionalUv: (channel) => inst.model.getAdditionalUv(channel),
16735
+ morphs: inst.model.getMorphing().morphs,
16736
+ morphWeights: () => inst.model.getEffectiveMorphWeights(),
15941
16737
  }, look);
15942
16738
  this.writeCullHidden(true);
15943
16739
  return true;
15944
16740
  }
16741
+ /** Resolves once a model's native look can draw: every pass's pipeline is
16742
+ * compiled (async, at setModelNativeLook). Until then its dressed materials
16743
+ * sit out — a host reveals the model after this to avoid that gap. */
16744
+ nativeLookReady(name) {
16745
+ return this.nativeLooks?.ready(name) ?? Promise.resolve();
16746
+ }
16747
+ /**
16748
+ * Set some of the values of one material a model's native look dresses —
16749
+ * its Unity material properties by name, colours linear, as the look's
16750
+ * `values` hold them — over the look's own, until set again. What a game
16751
+ * animates on a material over a take (its timeline's material curves)
16752
+ * reaches the shaders this way, one frame at a time: the caller samples its
16753
+ * keys on its own clock and sets what it got. Only the names the material's
16754
+ * look carries are taken, and a value equal to the one in place writes
16755
+ * nothing. False when the model wears no look dressing that material.
16756
+ */
16757
+ setModelNativeUniforms(name, material, values) {
16758
+ return this.nativeLooks?.setUniforms(name, material, values) ?? false;
16759
+ }
15945
16760
  /** The hosts for the game's shaders: the scene pass's and the shadow atlas's,
15946
16761
  * with the stand-ins an empty slot reads. Built on first use. */
15947
16762
  ensureNativeHosts() {
@@ -15990,6 +16805,18 @@ export class Engine {
15990
16805
  white,
15991
16806
  black,
15992
16807
  blackCube: solid("native: black cube", [0, 0, 0, 255], true),
16808
+ // an empty volume (a froxel buffer no pass filled): no in-scatter, no extinction
16809
+ emptyVolume: (() => {
16810
+ const t = d.createTexture({
16811
+ label: "native: empty volume",
16812
+ size: [1, 1, 1],
16813
+ dimension: "3d",
16814
+ format: "rgba8unorm",
16815
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
16816
+ });
16817
+ d.queue.writeTexture({ texture: t }, new Uint8Array([0, 0, 0, 0]), { bytesPerRow: 4, rowsPerImage: 1 }, [1, 1, 1]);
16818
+ return t.createView({ dimension: "3d" });
16819
+ })(),
15993
16820
  depth: this.nativeNoShadowView,
15994
16821
  comparison: this.shadowComparisonSampler,
15995
16822
  };
@@ -16018,6 +16845,8 @@ export class Engine {
16018
16845
  return;
16019
16846
  const scene = this.ensureNativeHosts();
16020
16847
  this.nativeStage = await NativeStage.load(this.device, pkg, read, { scene, shadow: this.nativeShadowHost }, { mipmaps: (t, levels) => this.generateMipmaps(t, levels), fallback: this.nativeFallback }, onProgress);
16848
+ // The stage's sky is in it: the far plane reaches as far as the stage does.
16849
+ this.raiseSceneExtent(this.nativeStage.extent);
16021
16850
  }
16022
16851
  /** What went wrong in the game's shaders, and the names nothing supplied. */
16023
16852
  nativeReport() {
@@ -16065,11 +16894,17 @@ export class Engine {
16065
16894
  let sunDirection = [dir.x, dir.y, dir.z];
16066
16895
  let sunColor = [sun.color.x * k, sun.color.y * k, sun.color.z * k];
16067
16896
  let sunShadow = this.sunShadow;
16897
+ let sunUnity = null;
16068
16898
  if (stage) {
16069
16899
  // Its lamps are the stage's own; its key is the scene's sun, which the
16070
- // host sets from the stage (and a person may then move).
16900
+ // host sets from the stage (and a person may then move) - along the
16901
+ // scene sun's direction, in the stage's own main-light colour. The scene
16902
+ // sun's colour is the cast's key, which the game keeps apart (a
16903
+ // character's light is _ProbeLightingBase, not the stage's main light).
16071
16904
  const L = stage.pkg.lights;
16072
16905
  const sc = stage.pkg.scale;
16906
+ sunColor = [L.main.color[0], L.main.color[1], L.main.color[2]];
16907
+ sunUnity = L.main.unity ?? null;
16073
16908
  lights.length = 0;
16074
16909
  for (const l of L.additional) {
16075
16910
  lights.push({
@@ -16099,6 +16934,7 @@ export class Engine {
16099
16934
  dt: 1 / 60,
16100
16935
  sunDirection,
16101
16936
  sunColor,
16937
+ sunUnity,
16102
16938
  sunShadow,
16103
16939
  lights,
16104
16940
  ambientSH: sh ? Array.from(sh, (v) => v * s) : null,
@@ -16209,6 +17045,8 @@ Engine.GIZMO_WORLD_SIZE = 1.5;
16209
17045
  Engine.GIZMO_THICKNESS_PX = 15.0;
16210
17046
  Engine.GIZMO_PICK_THRESHOLD_PX = 17.0;
16211
17047
  Engine.MULTISAMPLE_COUNT = 4;
17048
+ /** Floats per vertex in a model's attribute stream: colour RGBA, then UV2. */
17049
+ Engine.ATTRIBUTE_FLOATS = 6;
16212
17050
  /**
16213
17051
  * Shadow map depth format — 16-bit, deliberately.
16214
17052
  *
@@ -16376,13 +17214,24 @@ Engine.TIMED_PASSES = [
16376
17214
  // because two planes would need two of every attachment below. Which one is
16377
17215
  // in force is mirrorPlane, refreshed by updateMirrorCamera.
16378
17216
  Engine.GROUND_PLANE = [0, 1, 0, 0];
16379
- Engine.BLOOM_MAX_LEVELS = 5;
16380
- // Width of the baked Filmic tone LUT (composite.ts FILMIC_LUT_W must match).
16381
- Engine.FILMIC_LUT_WIDTH = 256;
17217
+ Engine.COOKIE_SIZE = 512;
17218
+ Engine.COOKIE_LAYERS = 16;
17219
+ Engine.PROBE_SETTLE_FRAMES = 20;
17220
+ /** The game's prefilter clamps at 6550.4 — a tenth of the half-float ceiling
17221
+ * (its BloomPass _Params.y of 100 never reaches the shader). */
17222
+ Engine.BLOOM_CLAMP = 6550.4;
17223
+ Engine.BLOOM_MAX_LEVELS = 16;
16382
17224
  // Smoothness metrics are computed over a ring buffer of true frame intervals
16383
17225
  // (vsync-to-vsync), recomputed at STATS_REFRESH_MS so the readout doesn't flicker.
16384
17226
  Engine.STATS_WINDOW = 120;
16385
17227
  Engine.STATS_REFRESH_MS = 500;
17228
+ /** viewU[6].y per transform — see viewTransform in composite.ts. */
17229
+ Engine.VIEW_TRANSFORM_ID = {
17230
+ soft: 0,
17231
+ none: 1,
17232
+ neutral: 2,
17233
+ aces: 3,
17234
+ };
16386
17235
  /**
16387
17236
  * The dials a `#mirror` effect is READ BY — the contract between an effect
16388
17237
  * file and the reflection.
@@ -16446,6 +17295,17 @@ Engine.CULL_MODEL_RIGID = 2;
16446
17295
  /** A cache key no material can collide with — a PMX name is never empty and
16447
17296
  * never contains a NUL. */
16448
17297
  Engine.SEAM_KEY = "\u0000seams";
17298
+ // ─── The shader cache ─────────────────────────────────────────────
17299
+ //
17300
+ // A compile is most of a second per distinct fragment shader on a cold
17301
+ // browser cache, and the same source used to be compiled once per CALLER: a
17302
+ // graph per model that carried it, the composite per effect install. These
17303
+ // key a module by its WGSL and a pipeline by its module and every descriptor
17304
+ // field but the label, so a second request for the same thing — in flight or
17305
+ // done — is the first one's promise. Bounded, oldest first, because an editor
17306
+ // session compiles a new source per keystroke.
17307
+ Engine.SHADER_CACHE_MAX = 256;
17308
+ Engine.PIPELINE_CACHE_MAX = 512;
16449
17309
  /**
16450
17310
  * This frame's Unity globals, keeping last frame's object for every value that
16451
17311
  * did not change: the native host refills a uniform member only when its value
@@ -16459,22 +17319,3 @@ function stableGlobals(prev, next) {
16459
17319
  }
16460
17320
  return next;
16461
17321
  }
16462
- function sameValue(a, b) {
16463
- if (typeof a === "number" || typeof b === "number")
16464
- return a === b;
16465
- if (a.length !== b.length)
16466
- return false;
16467
- if (a instanceof Uint32Array !== b instanceof Uint32Array)
16468
- return false;
16469
- for (let i = 0; i < a.length; i++) {
16470
- const x = a[i];
16471
- const y = b[i];
16472
- if (typeof x === "number" || typeof y === "number") {
16473
- if (x !== y)
16474
- return false;
16475
- }
16476
- else if (!sameValue(x, y))
16477
- return false;
16478
- }
16479
- return true;
16480
- }