reze-engine 0.56.10 → 0.56.12

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.
package/dist/engine.js CHANGED
@@ -26,7 +26,7 @@ import { SHADOW_CASCADES, buildShadowVP } from "./shadow-cascades";
26
26
  import { REFLECTION_DEBUG_WGSL, buildMirrorCamera, planeFromPointNormal } from "./reflection";
27
27
  import { MIRROR_MASK_DOWNSAMPLE_WGSL, MIRROR_MAT_BYTES, mirrorShaderWgsl, mirrorShadowWgsl } from "./shaders/passes/mirror";
28
28
  import { packHalf } from "./hdr";
29
- import { evalIrradianceSH, projectIrradianceSH } from "./ibl";
29
+ import { evalIrradianceSH, projectIrradianceSH, gradientIrradianceSH } from "./ibl";
30
30
  import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, packLyrics } from "./shaders/lyrics-api";
31
31
  import { sceneTargets as sceneTargetsFor, sceneColorFormats, setMrtIds, mrtIdsEnabled, SCENE_ID_FORMAT, } from "./shaders/passes/scene-contract";
32
32
  import { LIGHT_HEADER, LIGHT_STRIDE, LIGHTS_FLOATS, MAX_LIGHTS, buildLightEmitShader, hasLightEmit, } from "./shaders/lights";
@@ -703,7 +703,7 @@ export class Engine {
703
703
  return this.effects[0] ?? null;
704
704
  }
705
705
  constructor(canvas, options) {
706
- this.cameraMatrixData = new Float32Array(36);
706
+ this.cameraMatrixData = new Float32Array(40);
707
707
  // ambient vec4 (4) + 4 lights x 2 vec4 (32) + 9 irradiance-SH vec4s (36),
708
708
  // padded to 80. sh[0].w is the IBL flag: 0 = flat world colour, 1 = the sky.
709
709
  this.lightData = new Float32Array(80);
@@ -1011,6 +1011,9 @@ export class Engine {
1011
1011
  this.worldEquirectTexture = null;
1012
1012
  this.worldEquirectView = null;
1013
1013
  this.worldStrength = 1;
1014
+ /** A three-colour sky's irradiance SH, when the world is a gradient rather
1015
+ * than a picture. An HDRI outranks it — see writeWorld. */
1016
+ this.worldGradientSH = null;
1014
1017
  /** The installed HDRI's folded irradiance SH (27 floats), or null. */
1015
1018
  this.worldSH = null;
1016
1019
  // The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
@@ -1175,6 +1178,37 @@ export class Engine {
1175
1178
  /** A pose pushed in from outside — see setCameraPose. Reapplied every frame,
1176
1179
  * so it outranks the orbit AND a loaded track for as long as it is set. */
1177
1180
  this.cameraPoseOverride = null;
1181
+ /**
1182
+ * The per-frame bind groups, the camera's and the mirror's.
1183
+ *
1184
+ * A METHOD rather than two literals at init, because one of their resources
1185
+ * changes after it: the world's sky texture is swapped whenever an HDRI is
1186
+ * installed or cleared, and a bind group holds the view it was built with.
1187
+ * The mirror's is identical but for the camera — a reflection is the same
1188
+ * scene lit the same way, seen from a reflected eye.
1189
+ */
1190
+ /**
1191
+ * Skies this engine has replaced, kept alive rather than destroyed.
1192
+ *
1193
+ * NOT DESTROYED AT ALL, after three attempts that each deferred it further:
1194
+ * one frame, then three, then "once both bind groups have rebuilt". Every one
1195
+ * still produced "destroyed texture used in a submit" somewhere — a command
1196
+ * buffer encoded before the swap, a rebuild that bailed on half-built
1197
+ * resources, an engine torn down by a hot reload whose last frame is still in
1198
+ * flight. The engine cannot see all of those, and each attempt to enumerate
1199
+ * them found another.
1200
+ *
1201
+ * So the trade is taken explicitly: a replaced sky is a megabyte or two held
1202
+ * until the page goes away, against a validation error every frame. A scene
1203
+ * swaps its world a handful of times in a session, and the GPU frees all of
1204
+ * it when the device does.
1205
+ */
1206
+ this.retiredSkies = [];
1207
+ /** A world was installed or cleared and the bind groups that name its texture
1208
+ * may not have been rebuilt yet — both rebuilds bail when their own
1209
+ * resources are half-built, which is exactly the moment a host swaps a sky.
1210
+ * Retried at the top of a frame until one of them takes. */
1211
+ this.worldBindingsDirty = false;
1178
1212
  /** Per cascade: does its map currently hold nothing but the cleared far plane?
1179
1213
  * Set by the cascade loop, which skips a cascade that is unwanted and already
1180
1214
  * cleared rather than re-clearing it every frame. */
@@ -1215,6 +1249,9 @@ export class Engine {
1215
1249
  this.cpuRenderMs = 0;
1216
1250
  this.frameAnimMsRaw = 0;
1217
1251
  this.framePhysicsMsRaw = 0;
1252
+ /** How far the loaded scene reaches from the origin, in world units — the
1253
+ * largest any model has ever needed. */
1254
+ this.sceneExtent = 0;
1218
1255
  /** Every shadow caster in one sphere: (x, y, z, radius). radius 0 = nothing
1219
1256
  * casts, -1 = do not use (a rigid caster has no sphere). See updateCasterSphere. */
1220
1257
  /** A card's own texture, by model key — see setPlaneFrame. */
@@ -2406,11 +2443,14 @@ export class Engine {
2406
2443
  errors: [...this.gpuErrors].map(([message, count]) => ({ message, count })),
2407
2444
  };
2408
2445
  }
2446
+ /** Returns whether it actually rebuilt: a caller retrying after a resource
2447
+ * swap has to know, because a bail leaves the previous group — and the
2448
+ * previous group names the texture the swap replaced. */
2409
2449
  rebuildCompositeBindGroup() {
2410
2450
  if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView)
2411
- return;
2451
+ return false;
2412
2452
  if (!this.castBuffer)
2413
- return;
2453
+ return false;
2414
2454
  // BEFORE the entries below, not after: they ask fieldPairUsed which effects
2415
2455
  // draw, and that answer includes whether an effect has its field bind group
2416
2456
  // yet. Building the composite first and the field groups second would bind
@@ -2445,6 +2485,7 @@ export class Engine {
2445
2485
  { binding: 21, resource: this.fieldLayerView(this.fieldFgViews[1], 1) },
2446
2486
  ],
2447
2487
  });
2488
+ return true;
2448
2489
  }
2449
2490
  /**
2450
2491
  * Does any installed effect draw into field pair `layer`?
@@ -2887,7 +2928,14 @@ export class Engine {
2887
2928
  * coefficients, so what lights her is what you see.
2888
2929
  */
2889
2930
  setWorldEquirect(source, options) {
2890
- this.worldEquirectTexture?.destroy();
2931
+ // RETIRED, NOT DESTROYED. A frame already encoded against the old sky can
2932
+ // still be in flight — the bind groups that name it are rebuilt below, but
2933
+ // the command buffer holding the previous ones is submitted at the end of
2934
+ // the frame this call landed in the middle of. Destroying here is what
2935
+ // produces "Destroyed texture used in a submit"; the next frame's start is
2936
+ // the first moment nothing can be pointing at it.
2937
+ if (this.worldEquirectTexture)
2938
+ this.retiredSkies.push(this.worldEquirectTexture);
2891
2939
  this.worldEquirectTexture = null;
2892
2940
  this.worldEquirectView = null;
2893
2941
  this.worldStrength = Math.max(options?.strength ?? 1, 0);
@@ -2897,13 +2945,49 @@ export class Engine {
2897
2945
  // Scene-linear radiance in rgba16float. The composite treats it as light
2898
2946
  // rather than wallpaper (mode 3) — a sun in it rolls off like a sun,
2899
2947
  // through the same exposure and view transform as the scene.
2948
+ // MIPPED, and the chain is the prefilter a reflection reads: level n is a
2949
+ // box average of the sky over twice the solid angle of n-1, which is what
2950
+ // lets rzWorldSpecular answer a rough surface without a blur pass per
2951
+ // level. Built on the CPU because the source is already float data in
2952
+ // memory and the alternative is a render pipeline per mip.
2953
+ const levels = Math.floor(Math.log2(Math.max(source.width, source.height))) + 1;
2900
2954
  const tex = this.device.createTexture({
2901
2955
  label: "world equirect (HDR)",
2902
2956
  size: [source.width, source.height],
2903
2957
  format: "rgba16float",
2958
+ mipLevelCount: levels,
2904
2959
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
2905
2960
  });
2906
- this.device.queue.writeTexture({ texture: tex }, packHalf(source.data), { bytesPerRow: source.width * 8, rowsPerImage: source.height }, [source.width, source.height]);
2961
+ let data = source.data;
2962
+ let w = source.width;
2963
+ let h = source.height;
2964
+ for (let level = 0; level < levels; level++) {
2965
+ if (level > 0) {
2966
+ const nw = Math.max(1, w >> 1);
2967
+ const nh = Math.max(1, h >> 1);
2968
+ const down = new Float32Array(nw * nh * 4);
2969
+ for (let y = 0; y < nh; y++) {
2970
+ for (let x = 0; x < nw; x++) {
2971
+ const x0 = Math.min(x * 2, w - 1);
2972
+ const x1 = Math.min(x * 2 + 1, w - 1);
2973
+ const y0 = Math.min(y * 2, h - 1);
2974
+ const y1 = Math.min(y * 2 + 1, h - 1);
2975
+ for (let c = 0; c < 4; c++) {
2976
+ down[(y * nw + x) * 4 + c] =
2977
+ (data[(y0 * w + x0) * 4 + c] +
2978
+ data[(y0 * w + x1) * 4 + c] +
2979
+ data[(y1 * w + x0) * 4 + c] +
2980
+ data[(y1 * w + x1) * 4 + c]) *
2981
+ 0.25;
2982
+ }
2983
+ }
2984
+ }
2985
+ data = down;
2986
+ w = nw;
2987
+ h = nh;
2988
+ }
2989
+ this.device.queue.writeTexture({ texture: tex, mipLevel: level }, packHalf(data), { bytesPerRow: w * 8, rowsPerImage: h }, [w, h]);
2990
+ }
2907
2991
  this.worldEquirectTexture = tex;
2908
2992
  this.worldEquirectView = tex.createView();
2909
2993
  // The sky lights the scene, not only backs it. The sun keeps the toon
@@ -2917,6 +3001,12 @@ export class Engine {
2917
3001
  }
2918
3002
  if (this.worldSH || hadSH)
2919
3003
  this.writeWorld();
3004
+ // The MATERIAL groups too, not only the composite's: a surface that
3005
+ // reflects the sky samples this very texture, so a world swapped after
3006
+ // init would otherwise keep reflecting the one it replaced. Marked as well,
3007
+ // because either rebuild may bail on resources that are not up yet.
3008
+ this.worldBindingsDirty = true;
3009
+ this.rebuildPerFrameBindGroups();
2920
3010
  this.rebuildCompositeBindGroup();
2921
3011
  if (this.device && this.compositeUniformBuffer)
2922
3012
  this.writeCompositeViewUniforms();
@@ -3890,6 +3980,13 @@ export class Engine {
3890
3980
  * uniform, so nothing recompiles.
3891
3981
  */
3892
3982
  allocateLightSlots() {
3983
+ // BEFORE INIT IS A REAL CALLER. A host sets a scene's lamps from an effect
3984
+ // that runs as soon as its state exists, which on a hot reload is before
3985
+ // the engine has a device — and every public setter here has to survive
3986
+ // that, the way setWorldEquirect does. The buffers are written from the
3987
+ // engine's own state at init, so nothing is lost by returning.
3988
+ if (!this.device)
3989
+ return;
3893
3990
  let next = this.docLightCount;
3894
3991
  for (const e of this.effects) {
3895
3992
  if (!e.lights)
@@ -5423,8 +5520,11 @@ export class Engine {
5423
5520
  // The positional lights. Always bound, empty or not, so every material
5424
5521
  // pipeline shares one layout whether or not the scene has any.
5425
5522
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
5426
- // The far cascade's shadow map. 8 stays free; 9 is the BRDF LUT.
5523
+ // The far cascade's shadow map, then the world's sky (8) and the BRDF
5524
+ // LUT (9). The sky is always bound — the 1x1 fallback when the scene
5525
+ // has none — so every material pipeline keeps sharing one layout.
5427
5526
  { binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
5527
+ { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5428
5528
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5429
5529
  ],
5430
5530
  });
@@ -5658,6 +5758,18 @@ export class Engine {
5658
5758
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
5659
5759
  });
5660
5760
  this.device.queue.writeBuffer(this.lightsBuffer, 0, this.lightsData);
5761
+ // BEFORE the per-frame groups, which bind it. The composite needs this 1x1
5762
+ // stand-in too, and it used to be created with the composite's own
5763
+ // resources further down — far enough down that a material group built up
5764
+ // here had nothing to put on its sky binding, and every draw went out with
5765
+ // no bind group at index 0.
5766
+ this.fallbackEquirectTexture = this.device.createTexture({
5767
+ label: "equirect fallback",
5768
+ size: [1, 1],
5769
+ format: "rgba8unorm",
5770
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
5771
+ });
5772
+ this.fallbackEquirectView = this.fallbackEquirectTexture.createView();
5661
5773
  // Zero-filled = zero lines, which every accessor answers gracefully.
5662
5774
  this.lyricsBuffer = this.device.createBuffer({
5663
5775
  label: "lyrics",
@@ -5674,39 +5786,7 @@ export class Engine {
5674
5786
  });
5675
5787
  this.lyricsTextureView = this.lyricsTexture.createView();
5676
5788
  // Now that shadow resources exist, create the main per-frame bind group
5677
- this.perFrameBindGroup = this.device.createBindGroup({
5678
- label: "main per-frame bind group",
5679
- layout: this.mainPerFrameBindGroupLayout,
5680
- entries: [
5681
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
5682
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
5683
- { binding: 2, resource: this.materialSampler },
5684
- { binding: 3, resource: this.shadowMapDepthViews[0] },
5685
- { binding: 4, resource: this.shadowComparisonSampler },
5686
- { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
5687
- { binding: 6, resource: { buffer: this.lightsBuffer } },
5688
- { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
5689
- { binding: 9, resource: this.brdfLutView },
5690
- ],
5691
- });
5692
- // The mirror's, identical but for the camera — same lights, same shadow
5693
- // maps, because a reflection is the same scene lit the same way, seen from
5694
- // a reflected eye.
5695
- this.mirrorPerFrameBindGroup = this.device.createBindGroup({
5696
- label: "mirror per-frame bind group",
5697
- layout: this.mainPerFrameBindGroupLayout,
5698
- entries: [
5699
- { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
5700
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
5701
- { binding: 2, resource: this.materialSampler },
5702
- { binding: 3, resource: this.shadowMapDepthViews[0] },
5703
- { binding: 4, resource: this.shadowComparisonSampler },
5704
- { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
5705
- { binding: 6, resource: { buffer: this.lightsBuffer } },
5706
- { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
5707
- { binding: 9, resource: this.brdfLutView },
5708
- ],
5709
- });
5789
+ this.rebuildPerFrameBindGroups();
5710
5790
  this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
5711
5791
  label: "ground shadow layout",
5712
5792
  entries: [
@@ -6091,13 +6171,6 @@ export class Engine {
6091
6171
  { binding: 21, visibility: GPUShaderStage.FRAGMENT, texture: {} },
6092
6172
  ],
6093
6173
  });
6094
- this.fallbackEquirectTexture = this.device.createTexture({
6095
- label: "equirect fallback",
6096
- size: [1, 1],
6097
- format: "rgba8unorm",
6098
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
6099
- });
6100
- this.fallbackEquirectView = this.fallbackEquirectTexture.createView();
6101
6174
  this.compositePipelineLayout = this.device.createPipelineLayout({
6102
6175
  bindGroupLayouts: [this.compositeBindGroupLayout],
6103
6176
  });
@@ -7756,6 +7829,67 @@ export class Engine {
7756
7829
  this.writeWorld();
7757
7830
  this.writeSun(0);
7758
7831
  }
7832
+ /** Returns whether it actually rebuilt — see rebuildCompositeBindGroup. */
7833
+ rebuildPerFrameBindGroups() {
7834
+ // EVERY resource, not just the layout. This runs twice: once at init, where
7835
+ // the order is known, and again whenever a world is installed — which the
7836
+ // host may do before init has finished, and a bind group built around an
7837
+ // undefined buffer is a validation error at creation rather than a missing
7838
+ // picture later.
7839
+ if (!this.device ||
7840
+ !this.mainPerFrameBindGroupLayout ||
7841
+ !this.shadowMapDepthViews?.length ||
7842
+ !this.cameraUniformBuffer ||
7843
+ !this.mirrorCameraBuffer ||
7844
+ !this.lightUniformBuffer ||
7845
+ !this.lightsBuffer ||
7846
+ !this.shadowLightVPBuffer ||
7847
+ !this.materialSampler ||
7848
+ !this.shadowComparisonSampler ||
7849
+ !this.brdfLutView ||
7850
+ !(this.worldEquirectView ?? this.fallbackEquirectView)) {
7851
+ return false;
7852
+ }
7853
+ // BOTH ENTRY LISTS ARE WRITTEN OUT, rather than shared through a helper.
7854
+ // tests/bindings.test.mjs reads this file and checks statically that every
7855
+ // bind group covers exactly its layout — it cannot see through a function,
7856
+ // and a missing binding is a validation error at draw time rather than a
7857
+ // compile error here. The duplication is the price of that check.
7858
+ const env = this.worldEquirectView ?? this.fallbackEquirectView;
7859
+ this.perFrameBindGroup = this.device.createBindGroup({
7860
+ label: "main per-frame bind group",
7861
+ layout: this.mainPerFrameBindGroupLayout,
7862
+ entries: [
7863
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
7864
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
7865
+ { binding: 2, resource: this.materialSampler },
7866
+ { binding: 3, resource: this.shadowMapDepthViews[0] },
7867
+ { binding: 4, resource: this.shadowComparisonSampler },
7868
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
7869
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
7870
+ { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
7871
+ { binding: 8, resource: env },
7872
+ { binding: 9, resource: this.brdfLutView },
7873
+ ],
7874
+ });
7875
+ this.mirrorPerFrameBindGroup = this.device.createBindGroup({
7876
+ label: "mirror per-frame bind group",
7877
+ layout: this.mainPerFrameBindGroupLayout,
7878
+ entries: [
7879
+ { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
7880
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
7881
+ { binding: 2, resource: this.materialSampler },
7882
+ { binding: 3, resource: this.shadowMapDepthViews[0] },
7883
+ { binding: 4, resource: this.shadowComparisonSampler },
7884
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
7885
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
7886
+ { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
7887
+ { binding: 8, resource: env },
7888
+ { binding: 9, resource: this.brdfLutView },
7889
+ ],
7890
+ });
7891
+ return true;
7892
+ }
7759
7893
  /**
7760
7894
  * Write world ambient. For a uniform-radiance world, hemispherical irradiance
7761
7895
  * is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
@@ -7766,17 +7900,22 @@ export class Engine {
7766
7900
  this.lightData[0] = this.world.color.x * s;
7767
7901
  this.lightData[1] = this.world.color.y * s;
7768
7902
  this.lightData[2] = this.world.color.z * s;
7769
- this.lightData[3] = 0;
7903
+ // The spare in the ambient vec4, which rzWorldSpecular multiplies its sky
7904
+ // sample by: the SH carries strength already, the texture cannot.
7905
+ this.lightData[3] = this.world.strength;
7770
7906
  // The sky's irradiance, when an HDRI world is installed — the world
7771
7907
  // STRENGTH dial keeps its meaning by scaling it, and the world COLOUR is
7772
7908
  // simply unread while the flag is up (Blender's own semantics: the image
7773
7909
  // replaces the colour, strength applies to either).
7910
+ // An HDRI outranks a gradient outranks the flat colour, which is the order
7911
+ // of how much each one knows about the sky.
7912
+ const sh = this.worldSH ?? this.worldGradientSH;
7774
7913
  for (let i = 0; i < 9; i++) {
7775
7914
  const b = 36 + i * 4;
7776
- if (this.worldSH) {
7777
- this.lightData[b] = this.worldSH[i * 3] * s;
7778
- this.lightData[b + 1] = this.worldSH[i * 3 + 1] * s;
7779
- this.lightData[b + 2] = this.worldSH[i * 3 + 2] * s;
7915
+ if (sh) {
7916
+ this.lightData[b] = sh[i * 3] * s;
7917
+ this.lightData[b + 1] = sh[i * 3 + 1] * s;
7918
+ this.lightData[b + 2] = sh[i * 3 + 2] * s;
7780
7919
  }
7781
7920
  else {
7782
7921
  this.lightData[b] = 0;
@@ -7785,7 +7924,11 @@ export class Engine {
7785
7924
  }
7786
7925
  this.lightData[b + 3] = 0;
7787
7926
  }
7788
- this.lightData[39] = this.worldSH ? 1 : 0;
7927
+ // WHICH KIND of sky, not merely whether there is one: 2 is a picture a
7928
+ // reflection can be sampled from, 1 is a gradient it can only be fitted to,
7929
+ // 0 is a flat colour. rzWorldSpecular reads this to decide whether to touch
7930
+ // the equirect texture at all.
7931
+ this.lightData[39] = this.worldSH ? 2 : this.worldGradientSH ? 1 : 0;
7789
7932
  this.updateLightBuffer();
7790
7933
  }
7791
7934
  /** Write sun lamp into light slot `index` (0..3). Layout mirrors the WGSL struct. */
@@ -7812,6 +7955,9 @@ export class Engine {
7812
7955
  this.world.color = options.color;
7813
7956
  if (options.strength !== undefined)
7814
7957
  this.world.strength = options.strength;
7958
+ if (options.gradient !== undefined) {
7959
+ this.worldGradientSH = options.gradient ? gradientIrradianceSH(options.gradient) : null;
7960
+ }
7815
7961
  this.writeWorld();
7816
7962
  }
7817
7963
  /** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
@@ -7897,6 +8043,11 @@ export class Engine {
7897
8043
  * keep the meaning the caller gave them, which is the same rule the anchor
7898
8044
  * table follows. Passing none (or an empty list) turns the layer off and the
7899
8045
  * scene renders exactly as it did before lights existed.
8046
+ *
8047
+ * A light with an `aim` is a SPOT: it reaches only inside `angle`, full
8048
+ * inside `innerAngle`, and the two are degrees from the axis the way every
8049
+ * tool states a cone. Without one it is a point light, which the shading
8050
+ * spells as a cone that covers everything rather than as a second case.
7900
8051
  */
7901
8052
  setLights(
7902
8053
  /** Structural {x,y,z} rather than the Vec3 class, the same choice effect
@@ -7928,9 +8079,29 @@ export class Engine {
7928
8079
  this.lightsData[b + 4] = Math.max(l.color.x * k, 0);
7929
8080
  this.lightsData[b + 5] = Math.max(l.color.y * k, 0);
7930
8081
  this.lightsData[b + 6] = Math.max(l.color.z * k, 0);
7931
- // [b + 7] is `type`, reserved: every light is a point light today.
7932
- }
7933
- if (list.length) {
8082
+ // A spot only where an aim was given AND it points somewhere: a zero
8083
+ // vector cannot be normalised, and a cone around nothing would light
8084
+ // nothing at all — a light that vanishes because its aim was left at the
8085
+ // default is worse than one that shines everywhere.
8086
+ const len = l.aim ? Math.hypot(l.aim.x, l.aim.y, l.aim.z) : 0;
8087
+ this.lightsData[b + 7] = len > 0 ? 1 : 0;
8088
+ this.lightsData[b + 8] = len > 0 ? l.aim.x / len : 0;
8089
+ this.lightsData[b + 9] = len > 0 ? l.aim.y / len : 0;
8090
+ this.lightsData[b + 10] = len > 0 ? l.aim.z / len : 0;
8091
+ // Half-angles in degrees, as cosines. Ordered so the outer edge is never
8092
+ // inside the inner one — a cone written the other way round would divide
8093
+ // by a negative span and light its own rim instead of its middle.
8094
+ const outer = len > 0 ? Math.cos((Math.min(Math.max(l.angle ?? 45, 0), 179) / 2) * (Math.PI / 180)) : -1;
8095
+ const inner = len > 0 ? Math.cos((Math.min(Math.max(l.innerAngle ?? (l.angle ?? 45) * 0.8, 0), 179) / 2) * (Math.PI / 180)) : -1;
8096
+ this.lightsData[b + 11] = outer;
8097
+ this.lightsData[b + 12] = Math.max(inner, outer);
8098
+ this.lightsData[b + 13] = 0;
8099
+ this.lightsData[b + 14] = 0;
8100
+ this.lightsData[b + 15] = 0;
8101
+ }
8102
+ // The CPU copy is written either way; only the upload needs a device. A
8103
+ // scene whose lamps arrive before init keeps them, and init uploads them.
8104
+ if (list.length && this.device && this.lightsBuffer) {
7934
8105
  this.device.queue.writeBuffer(this.lightsBuffer, LIGHT_HEADER * 4, this.lightsData.buffer, LIGHT_HEADER * 4, list.length * LIGHT_STRIDE * 4);
7935
8106
  }
7936
8107
  // The effects' bases move when the document's count does; the header —
@@ -9812,6 +9983,35 @@ export class Engine {
9812
9983
  this.cullReadback?.shadow.destroy();
9813
9984
  this.cullReadback = null;
9814
9985
  }
9986
+ /**
9987
+ * Tell the camera how big the scene is, so its far plane can cover it.
9988
+ *
9989
+ * MEASURED ON LOAD, from the model's own vertices, and only ever raised. A
9990
+ * stage is the case that matters: framed from ten units away, a sky dome a
9991
+ * thousand units out sits well beyond a far plane derived from the orbit, and
9992
+ * the part of it past that plane is simply not drawn — which looks like a
9993
+ * polygon of background colour that follows the camera, not like clipping.
9994
+ *
9995
+ * Never lowered when a model leaves: the cost of a far plane that is too
9996
+ * generous is depth precision, and the cost of one that is too near is
9997
+ * geometry that vanishes. One of those is a bug report.
9998
+ */
9999
+ noteSceneExtent(model) {
10000
+ const { positions } = model.getGeometry();
10001
+ let most = 0;
10002
+ // Every 16th vertex: an extent is a bound, not a measurement, and a stage
10003
+ // has a quarter of a million of them.
10004
+ for (let i = 0; i + 2 < positions.length; i += 48) {
10005
+ const d = positions[i] * positions[i] + positions[i + 1] * positions[i + 1] + positions[i + 2] * positions[i + 2];
10006
+ if (d > most)
10007
+ most = d;
10008
+ }
10009
+ const extent = Math.sqrt(most);
10010
+ if (extent <= this.sceneExtent)
10011
+ return;
10012
+ this.sceneExtent = extent;
10013
+ this.camera?.setSceneExtent(extent);
10014
+ }
9815
10015
  /**
9816
10016
  * One record per model: the rigid transform its per-material boxes live under,
9817
10017
  * or the world sphere that bounds it in any pose. Written every frame, because
@@ -10477,6 +10677,7 @@ export class Engine {
10477
10677
  const vertices = model.getVertices();
10478
10678
  const skinning = model.getSkinning();
10479
10679
  const skeleton = model.getSkeleton();
10680
+ this.noteSceneExtent(model);
10480
10681
  const boneCount = skeleton.bones.length;
10481
10682
  const matrixSize = boneCount * 16 * 4;
10482
10683
  const vertexBuffer = this.device.createBuffer({
@@ -12330,6 +12531,17 @@ export class Engine {
12330
12531
  this.renderWithDelta(deltaSeconds);
12331
12532
  }
12332
12533
  renderWithDelta(deltaTime) {
12534
+ // The bind groups first, then the textures they used to name. A retired
12535
+ // texture is freed only once a frame has been rebuilt without it — freeing
12536
+ // it while a group still points at it is what "destroyed texture used in a
12537
+ // submit" means, and it repeats every frame because the stale group stays.
12538
+ if (this.worldBindingsDirty) {
12539
+ // Both have to take: either one bailing leaves a group naming the sky
12540
+ // that was replaced, which is a frame drawn against the wrong one.
12541
+ const perFrame = this.rebuildPerFrameBindGroups();
12542
+ const composite = this.rebuildCompositeBindGroup();
12543
+ this.worldBindingsDirty = !(perFrame && composite);
12544
+ }
12333
12545
  // The scene clock, and the only clock a trail may sample on: renderFrame()
12334
12546
  // drives offline export with an exact per-frame delta, so a path recorded
12335
12547
  // against this is reproducible where one recorded against wall time is not.
@@ -13690,6 +13902,11 @@ export class Engine {
13690
13902
  // shader derives the full viewport (width via projection aspect) for its
13691
13903
  // babylon-mmd constant-pixel edge extrusion.
13692
13904
  this.cameraMatrixData[35] = this.canvas.height;
13905
+ // The SCENE clock, which is what a material may animate on: it pauses with
13906
+ // the transport and an export steps it exactly, so a ripple is in the same
13907
+ // place in the file as it was on screen. Wall time would drift between the
13908
+ // two. The buffer was already allocated at 40 floats; this is one of them.
13909
+ this.cameraMatrixData[36] = this.sceneClock;
13693
13910
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData);
13694
13911
  // 360 backdrop: the composite reconstructs each pixel's view ray from the
13695
13912
  // camera basis — refresh it every frame the skybox is active. The view matrix
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/graph/registry.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAE3C,MAAM,MAAM,KAAK,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAA;AAEzD,KAAK,SAAS,GAAG;IACf,IAAI,EAAE,KAAK,CAAA;IACX,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,4FAA4F;IAC5F,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,yFAAyF;IACzF,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,QAAQ,GAAG;IACrB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;IAC9B,2FAA2F;IAC3F,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAA;IAC/C,qEAAqE;IACrE,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACvC,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACtC,CAAA;AAYD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,GAAG,MAAM,CAoBhE;AAED,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,GAAG,OAAO,CAIpE;AAMD,wBAAgB,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,GAAG,OAAO,CAM1D;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAKpE;AAyCD,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CA+nBlD,CAAA"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/graph/registry.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAE3C,MAAM,MAAM,KAAK,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAA;AAEzD,KAAK,SAAS,GAAG;IACf,IAAI,EAAE,KAAK,CAAA;IACX,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,4FAA4F;IAC5F,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,yFAAyF;IACzF,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,QAAQ,GAAG;IACrB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;IAC9B,2FAA2F;IAC3F,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAA;IAC/C,qEAAqE;IACrE,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACvC,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACtC,CAAA;AAYD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,GAAG,MAAM,CAoBhE;AAED,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,GAAG,OAAO,CAIpE;AAMD,wBAAgB,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,GAAG,OAAO,CAM1D;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAKpE;AAyCD,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CA8pBlD,CAAA"}
@@ -113,6 +113,15 @@ export const NODE_REGISTRY = {
113
113
  outputs: { color: "color", alpha: "float" },
114
114
  contextOutputs: { color: "tex_color", alpha: "tex_s.a" },
115
115
  },
116
+ // The scene clock, in seconds. A material is otherwise a function of position
117
+ // alone — this is what lets one SCROLL: ripples on water, a conveyor, a sign.
118
+ // It is the same clock the effects read, so a paused scene holds still and an
119
+ // export steps it frame by frame.
120
+ time: {
121
+ inputs: {},
122
+ outputs: { value: "float" },
123
+ contextOutputs: { value: "camera.time" },
124
+ },
116
125
  geometry: {
117
126
  inputs: {},
118
127
  outputs: {
@@ -592,6 +601,26 @@ export const NODE_REGISTRY = {
592
601
  outputs: { value: "float" },
593
602
  emit: (a) => `layer_weight_facing(${a.blend}, n, v)`,
594
603
  },
604
+ /**
605
+ * The world's radiance from a direction — Blender's Environment Texture, read
606
+ * off the scene's own sky rather than off an image a material carries.
607
+ *
608
+ * ITS DEFAULT VECTOR IS THE REFLECTION, which is the whole point: wire
609
+ * nothing and it answers what this surface reflects. The roughness input
610
+ * picks how blurred the answer is; at 0 it is a mirror.
611
+ *
612
+ * A scene with no HDRI has no sky to reflect, and this returns the flat world
613
+ * colour — the same value the ambient already carries, so such a scene looks
614
+ * exactly as it did.
615
+ */
616
+ environment: {
617
+ inputs: {
618
+ vector: { type: "vector", contextDefault: "reflect(-v, n)" },
619
+ roughness: F(0),
620
+ },
621
+ outputs: { color: "color" },
622
+ emit: (a) => `rzWorldSpecular(${a.vector}, ${a.roughness})`,
623
+ },
595
624
  // ── Lighting capture ──
596
625
  /**
597
626
  * Shader → RGB on a white diffuse closure, reduced to a scalar.
package/dist/ibl.d.ts CHANGED
@@ -16,4 +16,32 @@ export declare function evalIrradianceSH(sh: Float32Array, n: {
16
16
  y: number;
17
17
  z: number;
18
18
  }): [number, number, number];
19
+ /**
20
+ * A three-colour sky as irradiance SH — the same nine coefficients an HDRI
21
+ * gives, so nothing downstream has to know which kind of world it is looking at.
22
+ *
23
+ * The gradient is Unity's trilight and every other engine's "ambient gradient":
24
+ * one colour straight up, one at the horizon, one straight down, interpolated
25
+ * by the direction's height. It is what lights a game stage that has no baked
26
+ * lighting, and it is not a decoration — the fill on a wall facing the sky and
27
+ * the fill under a roof differ by the whole span between sky and ground.
28
+ *
29
+ * Built by SAMPLING IT INTO A SMALL EQUIRECT and projecting that, rather than by
30
+ * integrating the basis functions in closed form. The analytic route is a page
31
+ * of coefficients that has to be rederived if the interpolation ever changes;
32
+ * this is six lines that reuse the projector the HDRI path is already trusted
33
+ * to get right, and at 64x32 the fit is exact to well under a percent because
34
+ * the function has no detail finer than the harmonics can hold anyway.
35
+ */
36
+ type Rgb = {
37
+ x: number;
38
+ y: number;
39
+ z: number;
40
+ };
41
+ export declare function gradientIrradianceSH(g: {
42
+ sky: Rgb;
43
+ equator: Rgb;
44
+ ground: Rgb;
45
+ }): Float32Array;
46
+ export {};
19
47
  //# sourceMappingURL=ibl.d.ts.map
package/dist/ibl.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"ibl.d.ts","sourceRoot":"","sources":["../src/ibl.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAErC;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,QAAQ,EAAE,MAAM,SAAI,GAAG,YAAY,CA0E3E;AAED;qEACqE;AACrE,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,YAAY,EAAE,CAAC,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAQnH"}
1
+ {"version":3,"file":"ibl.d.ts","sourceRoot":"","sources":["../src/ibl.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAErC;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,QAAQ,EAAE,MAAM,SAAI,GAAG,YAAY,CA0E3E;AAED;qEACqE;AACrE,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,YAAY,EAAE,CAAC,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAQnH;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,KAAK,GAAG,GAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAE9C,wBAAgB,oBAAoB,CAAC,CAAC,EAAE;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,OAAO,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,GAAG,YAAY,CAoB7F"}
package/dist/ibl.js CHANGED
@@ -111,3 +111,24 @@ export function evalIrradianceSH(sh, n) {
111
111
  }
112
112
  return out;
113
113
  }
114
+ export function gradientIrradianceSH(g) {
115
+ const w = 64;
116
+ const h = 32;
117
+ const data = new Float32Array(w * h * 4);
118
+ for (let y = 0; y < h; y++) {
119
+ // The same parameterisation projectIrradianceSH reads: v runs top to bottom
120
+ // and the direction's height is cos(pi v).
121
+ const ny = Math.cos(Math.PI * ((y + 0.5) / h));
122
+ const t = Math.abs(ny);
123
+ const a = ny >= 0 ? g.sky : g.ground;
124
+ const c = [g.equator.x + (a.x - g.equator.x) * t, g.equator.y + (a.y - g.equator.y) * t, g.equator.z + (a.z - g.equator.z) * t];
125
+ for (let x = 0; x < w; x++) {
126
+ const i = (y * w + x) * 4;
127
+ data[i] = c[0];
128
+ data[i + 1] = c[1];
129
+ data[i + 2] = c[2];
130
+ data[i + 3] = 1;
131
+ }
132
+ }
133
+ return projectIrradianceSH({ width: w, height: h, data }, 1);
134
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { Engine, DEFAULT_BLOOM_OPTIONS, DEFAULT_DEPTH_OF_FIELD_OPTIONS, DEFAULT_VIEW_TRANSFORM, DEFAULT_COLOR_GRADING, type ColorGradingOptions, type EngineStats, type EngineOptions, type BloomOptions, type DepthOfFieldOptions, type ViewTransformOptions, type LoadModelFromFilesOptions, type MaterialPreset, type MaterialPresetMap, type ModelTransform, type ModelAttachment, type GizmoDragEvent, type GizmoDragCallback, type GizmoDragKind, type DissolveCycle, type EffectParamValue, type EffectResult, type CullDiagnostics, type MidiNote, } from "./engine";
1
+ export { Engine, DEFAULT_BLOOM_OPTIONS, DEFAULT_DEPTH_OF_FIELD_OPTIONS, DEFAULT_VIEW_TRANSFORM, DEFAULT_COLOR_GRADING, type ColorGradingOptions, type EngineStats, type EngineOptions, type BloomOptions, type DepthOfFieldOptions, type ViewTransformOptions, type LoadModelFromFilesOptions, type MaterialPreset, type MaterialPresetMap, type ModelTransform, type SceneLight, type ModelAttachment, type GizmoDragEvent, type GizmoDragCallback, type GizmoDragKind, type DissolveCycle, type EffectParamValue, type EffectResult, type CullDiagnostics, type MidiNote, } from "./engine";
2
2
  export { parsePmxFolderInput, pmxFileAtRelativePath, type PmxFolderInputResult } from "./folder-upload";
3
3
  export { parseMidi } from "./midi-loader";
4
4
  export { parseHDR, type HdrImage } from "./hdr";