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/src/engine.ts CHANGED
@@ -48,7 +48,7 @@ import { SHADOW_CASCADES, buildShadowVP } from "./shadow-cascades"
48
48
  import { REFLECTION_DEBUG_WGSL, buildMirrorCamera, planeFromPointNormal } from "./reflection"
49
49
  import { MIRROR_MASK_DOWNSAMPLE_WGSL, MIRROR_MAT_BYTES, mirrorShaderWgsl, mirrorShadowWgsl } from "./shaders/passes/mirror"
50
50
  import { packHalf, type HdrImage } from "./hdr"
51
- import { evalIrradianceSH, projectIrradianceSH } from "./ibl"
51
+ import { evalIrradianceSH, projectIrradianceSH, gradientIrradianceSH } from "./ibl"
52
52
  import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, lyricsApi, packLyrics, type LyricLine, type LyricRect } from "./shaders/lyrics-api"
53
53
  import {
54
54
  sceneTargets as sceneTargetsFor,
@@ -335,6 +335,16 @@ type WorldOptions = {
335
335
  color?: Vec3
336
336
  /** Multiplier on world color (Blender: World > Surface > Strength). */
337
337
  strength?: number
338
+ /**
339
+ * A sky that changes with the direction you look: one colour overhead, one at
340
+ * the horizon, one below. Game engines light a whole outdoor stage with this
341
+ * and nothing else, and one flat colour cannot stand in for it — the fill
342
+ * under a roof and the fill on a wall facing the sky are different light.
343
+ *
344
+ * Fitted to the SAME spherical harmonics an HDRI is, so it costs the shader
345
+ * nothing and an HDRI simply outranks it. Null clears it back to the colour.
346
+ */
347
+ gradient?: { sky: Vec3; equator: Vec3; ground: Vec3 } | null
338
348
  }
339
349
 
340
350
  /**
@@ -1594,6 +1604,23 @@ interface EffectInstance {
1594
1604
  trails: EffectTrails | null
1595
1605
  }
1596
1606
 
1607
+ /**
1608
+ * A light the DOCUMENT places, as setLights takes it.
1609
+ *
1610
+ * `aim` is what separates the two kinds: with one the light is a spot pointing
1611
+ * that way, without one it is a point light. Angles are the whole cone in
1612
+ * degrees, the way a fixture and every DCC states them.
1613
+ */
1614
+ export type SceneLight = {
1615
+ position: XYZ
1616
+ color: XYZ
1617
+ intensity?: number
1618
+ radius?: number
1619
+ aim?: XYZ
1620
+ angle?: number
1621
+ innerAngle?: number
1622
+ }
1623
+
1597
1624
  export class Engine {
1598
1625
  private static instance: Engine | null = null
1599
1626
 
@@ -1612,7 +1639,7 @@ export class Engine {
1612
1639
  // `!` field here is genuinely absent until init() — this one no longer is.
1613
1640
  private camera: Camera
1614
1641
  private cameraUniformBuffer!: GPUBuffer
1615
- private cameraMatrixData = new Float32Array(36)
1642
+ private cameraMatrixData = new Float32Array(40)
1616
1643
  // Blender-style scene config groups (resolved from EngineOptions)
1617
1644
  private world!: { color: Vec3; strength: number }
1618
1645
  private sun!: { color: Vec3; strength: number; direction: Vec3 }
@@ -2284,6 +2311,9 @@ export class Engine {
2284
2311
  private worldEquirectTexture: GPUTexture | null = null
2285
2312
  private worldEquirectView: GPUTextureView | null = null
2286
2313
  private worldStrength = 1
2314
+ /** A three-colour sky's irradiance SH, when the world is a gradient rather
2315
+ * than a picture. An HDRI outranks it — see writeWorld. */
2316
+ private worldGradientSH: Float32Array | null = null
2287
2317
  /** The installed HDRI's folded irradiance SH (27 floats), or null. */
2288
2318
  private worldSH: Float32Array | null = null
2289
2319
  private fallbackEquirectTexture!: GPUTexture
@@ -3576,9 +3606,12 @@ export class Engine {
3576
3606
  }
3577
3607
  }
3578
3608
 
3579
- private rebuildCompositeBindGroup(): void {
3580
- if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
3581
- if (!this.castBuffer) return
3609
+ /** Returns whether it actually rebuilt: a caller retrying after a resource
3610
+ * swap has to know, because a bail leaves the previous group — and the
3611
+ * previous group names the texture the swap replaced. */
3612
+ private rebuildCompositeBindGroup(): boolean {
3613
+ if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return false
3614
+ if (!this.castBuffer) return false
3582
3615
  // BEFORE the entries below, not after: they ask fieldPairUsed which effects
3583
3616
  // draw, and that answer includes whether an effect has its field bind group
3584
3617
  // yet. Building the composite first and the field groups second would bind
@@ -3613,6 +3646,7 @@ export class Engine {
3613
3646
  { binding: 21, resource: this.fieldLayerView(this.fieldFgViews[1], 1) },
3614
3647
  ],
3615
3648
  })
3649
+ return true
3616
3650
  }
3617
3651
 
3618
3652
  /**
@@ -4075,7 +4109,13 @@ export class Engine {
4075
4109
  * coefficients, so what lights her is what you see.
4076
4110
  */
4077
4111
  setWorldEquirect(source: HdrImage | null, options?: { strength?: number }): void {
4078
- this.worldEquirectTexture?.destroy()
4112
+ // RETIRED, NOT DESTROYED. A frame already encoded against the old sky can
4113
+ // still be in flight — the bind groups that name it are rebuilt below, but
4114
+ // the command buffer holding the previous ones is submitted at the end of
4115
+ // the frame this call landed in the middle of. Destroying here is what
4116
+ // produces "Destroyed texture used in a submit"; the next frame's start is
4117
+ // the first moment nothing can be pointing at it.
4118
+ if (this.worldEquirectTexture) this.retiredSkies.push(this.worldEquirectTexture)
4079
4119
  this.worldEquirectTexture = null
4080
4120
  this.worldEquirectView = null
4081
4121
  this.worldStrength = Math.max(options?.strength ?? 1, 0)
@@ -4085,18 +4125,54 @@ export class Engine {
4085
4125
  // Scene-linear radiance in rgba16float. The composite treats it as light
4086
4126
  // rather than wallpaper (mode 3) — a sun in it rolls off like a sun,
4087
4127
  // through the same exposure and view transform as the scene.
4128
+ // MIPPED, and the chain is the prefilter a reflection reads: level n is a
4129
+ // box average of the sky over twice the solid angle of n-1, which is what
4130
+ // lets rzWorldSpecular answer a rough surface without a blur pass per
4131
+ // level. Built on the CPU because the source is already float data in
4132
+ // memory and the alternative is a render pipeline per mip.
4133
+ const levels = Math.floor(Math.log2(Math.max(source.width, source.height))) + 1
4088
4134
  const tex = this.device.createTexture({
4089
4135
  label: "world equirect (HDR)",
4090
4136
  size: [source.width, source.height],
4091
4137
  format: "rgba16float",
4138
+ mipLevelCount: levels,
4092
4139
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
4093
4140
  })
4094
- this.device.queue.writeTexture(
4095
- { texture: tex },
4096
- packHalf(source.data),
4097
- { bytesPerRow: source.width * 8, rowsPerImage: source.height },
4098
- [source.width, source.height],
4099
- )
4141
+ let data = source.data
4142
+ let w = source.width
4143
+ let h = source.height
4144
+ for (let level = 0; level < levels; level++) {
4145
+ if (level > 0) {
4146
+ const nw = Math.max(1, w >> 1)
4147
+ const nh = Math.max(1, h >> 1)
4148
+ const down = new Float32Array(nw * nh * 4)
4149
+ for (let y = 0; y < nh; y++) {
4150
+ for (let x = 0; x < nw; x++) {
4151
+ const x0 = Math.min(x * 2, w - 1)
4152
+ const x1 = Math.min(x * 2 + 1, w - 1)
4153
+ const y0 = Math.min(y * 2, h - 1)
4154
+ const y1 = Math.min(y * 2 + 1, h - 1)
4155
+ for (let c = 0; c < 4; c++) {
4156
+ down[(y * nw + x) * 4 + c] =
4157
+ (data[(y0 * w + x0) * 4 + c] +
4158
+ data[(y0 * w + x1) * 4 + c] +
4159
+ data[(y1 * w + x0) * 4 + c] +
4160
+ data[(y1 * w + x1) * 4 + c]) *
4161
+ 0.25
4162
+ }
4163
+ }
4164
+ }
4165
+ data = down
4166
+ w = nw
4167
+ h = nh
4168
+ }
4169
+ this.device.queue.writeTexture(
4170
+ { texture: tex, mipLevel: level },
4171
+ packHalf(data),
4172
+ { bytesPerRow: w * 8, rowsPerImage: h },
4173
+ [w, h],
4174
+ )
4175
+ }
4100
4176
  this.worldEquirectTexture = tex
4101
4177
  this.worldEquirectView = tex.createView()
4102
4178
  // The sky lights the scene, not only backs it. The sun keeps the toon
@@ -4108,6 +4184,12 @@ export class Engine {
4108
4184
  }
4109
4185
  }
4110
4186
  if (this.worldSH || hadSH) this.writeWorld()
4187
+ // The MATERIAL groups too, not only the composite's: a surface that
4188
+ // reflects the sky samples this very texture, so a world swapped after
4189
+ // init would otherwise keep reflecting the one it replaced. Marked as well,
4190
+ // because either rebuild may bail on resources that are not up yet.
4191
+ this.worldBindingsDirty = true
4192
+ this.rebuildPerFrameBindGroups()
4111
4193
  this.rebuildCompositeBindGroup()
4112
4194
  if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
4113
4195
  }
@@ -5130,6 +5212,12 @@ export class Engine {
5130
5212
  * uniform, so nothing recompiles.
5131
5213
  */
5132
5214
  private allocateLightSlots(): void {
5215
+ // BEFORE INIT IS A REAL CALLER. A host sets a scene's lamps from an effect
5216
+ // that runs as soon as its state exists, which on a hot reload is before
5217
+ // the engine has a device — and every public setter here has to survive
5218
+ // that, the way setWorldEquirect does. The buffers are written from the
5219
+ // engine's own state at init, so nothing is lost by returning.
5220
+ if (!this.device) return
5133
5221
  let next = this.docLightCount
5134
5222
  for (const e of this.effects) {
5135
5223
  if (!e.lights) continue
@@ -6726,8 +6814,11 @@ export class Engine {
6726
6814
  // The positional lights. Always bound, empty or not, so every material
6727
6815
  // pipeline shares one layout whether or not the scene has any.
6728
6816
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
6729
- // The far cascade's shadow map. 8 stays free; 9 is the BRDF LUT.
6817
+ // The far cascade's shadow map, then the world's sky (8) and the BRDF
6818
+ // LUT (9). The sky is always bound — the 1x1 fallback when the scene
6819
+ // has none — so every material pipeline keeps sharing one layout.
6730
6820
  { binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
6821
+ { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6731
6822
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6732
6823
  ],
6733
6824
  })
@@ -6971,6 +7062,19 @@ export class Engine {
6971
7062
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
6972
7063
  })
6973
7064
  this.device.queue.writeBuffer(this.lightsBuffer, 0, this.lightsData)
7065
+ // BEFORE the per-frame groups, which bind it. The composite needs this 1x1
7066
+ // stand-in too, and it used to be created with the composite's own
7067
+ // resources further down — far enough down that a material group built up
7068
+ // here had nothing to put on its sky binding, and every draw went out with
7069
+ // no bind group at index 0.
7070
+ this.fallbackEquirectTexture = this.device.createTexture({
7071
+ label: "equirect fallback",
7072
+ size: [1, 1],
7073
+ format: "rgba8unorm",
7074
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
7075
+ })
7076
+ this.fallbackEquirectView = this.fallbackEquirectTexture.createView()
7077
+
6974
7078
  // Zero-filled = zero lines, which every accessor answers gracefully.
6975
7079
  this.lyricsBuffer = this.device.createBuffer({
6976
7080
  label: "lyrics",
@@ -6988,39 +7092,7 @@ export class Engine {
6988
7092
  this.lyricsTextureView = this.lyricsTexture.createView()
6989
7093
 
6990
7094
  // Now that shadow resources exist, create the main per-frame bind group
6991
- this.perFrameBindGroup = this.device.createBindGroup({
6992
- label: "main per-frame bind group",
6993
- layout: this.mainPerFrameBindGroupLayout,
6994
- entries: [
6995
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
6996
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
6997
- { binding: 2, resource: this.materialSampler },
6998
- { binding: 3, resource: this.shadowMapDepthViews[0] },
6999
- { binding: 4, resource: this.shadowComparisonSampler },
7000
- { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
7001
- { binding: 6, resource: { buffer: this.lightsBuffer } },
7002
- { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
7003
- { binding: 9, resource: this.brdfLutView },
7004
- ],
7005
- })
7006
- // The mirror's, identical but for the camera — same lights, same shadow
7007
- // maps, because a reflection is the same scene lit the same way, seen from
7008
- // a reflected eye.
7009
- this.mirrorPerFrameBindGroup = this.device.createBindGroup({
7010
- label: "mirror per-frame bind group",
7011
- layout: this.mainPerFrameBindGroupLayout,
7012
- entries: [
7013
- { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
7014
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
7015
- { binding: 2, resource: this.materialSampler },
7016
- { binding: 3, resource: this.shadowMapDepthViews[0] },
7017
- { binding: 4, resource: this.shadowComparisonSampler },
7018
- { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
7019
- { binding: 6, resource: { buffer: this.lightsBuffer } },
7020
- { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
7021
- { binding: 9, resource: this.brdfLutView },
7022
- ],
7023
- })
7095
+ this.rebuildPerFrameBindGroups()
7024
7096
 
7025
7097
  this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
7026
7098
  label: "ground shadow layout",
@@ -7424,14 +7496,6 @@ export class Engine {
7424
7496
  { binding: 21, visibility: GPUShaderStage.FRAGMENT, texture: {} },
7425
7497
  ],
7426
7498
  })
7427
- this.fallbackEquirectTexture = this.device.createTexture({
7428
- label: "equirect fallback",
7429
- size: [1, 1],
7430
- format: "rgba8unorm",
7431
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
7432
- })
7433
- this.fallbackEquirectView = this.fallbackEquirectTexture.createView()
7434
-
7435
7499
  this.compositePipelineLayout = this.device.createPipelineLayout({
7436
7500
  bindGroupLayouts: [this.compositeBindGroupLayout],
7437
7501
  })
@@ -9198,6 +9262,103 @@ export class Engine {
9198
9262
  this.writeSun(0)
9199
9263
  }
9200
9264
 
9265
+ /**
9266
+ * The per-frame bind groups, the camera's and the mirror's.
9267
+ *
9268
+ * A METHOD rather than two literals at init, because one of their resources
9269
+ * changes after it: the world's sky texture is swapped whenever an HDRI is
9270
+ * installed or cleared, and a bind group holds the view it was built with.
9271
+ * The mirror's is identical but for the camera — a reflection is the same
9272
+ * scene lit the same way, seen from a reflected eye.
9273
+ */
9274
+ /**
9275
+ * Skies this engine has replaced, kept alive rather than destroyed.
9276
+ *
9277
+ * NOT DESTROYED AT ALL, after three attempts that each deferred it further:
9278
+ * one frame, then three, then "once both bind groups have rebuilt". Every one
9279
+ * still produced "destroyed texture used in a submit" somewhere — a command
9280
+ * buffer encoded before the swap, a rebuild that bailed on half-built
9281
+ * resources, an engine torn down by a hot reload whose last frame is still in
9282
+ * flight. The engine cannot see all of those, and each attempt to enumerate
9283
+ * them found another.
9284
+ *
9285
+ * So the trade is taken explicitly: a replaced sky is a megabyte or two held
9286
+ * until the page goes away, against a validation error every frame. A scene
9287
+ * swaps its world a handful of times in a session, and the GPU frees all of
9288
+ * it when the device does.
9289
+ */
9290
+ private retiredSkies: GPUTexture[] = []
9291
+
9292
+ /** A world was installed or cleared and the bind groups that name its texture
9293
+ * may not have been rebuilt yet — both rebuilds bail when their own
9294
+ * resources are half-built, which is exactly the moment a host swaps a sky.
9295
+ * Retried at the top of a frame until one of them takes. */
9296
+ private worldBindingsDirty = false
9297
+
9298
+ /** Returns whether it actually rebuilt — see rebuildCompositeBindGroup. */
9299
+ private rebuildPerFrameBindGroups(): boolean {
9300
+ // EVERY resource, not just the layout. This runs twice: once at init, where
9301
+ // the order is known, and again whenever a world is installed — which the
9302
+ // host may do before init has finished, and a bind group built around an
9303
+ // undefined buffer is a validation error at creation rather than a missing
9304
+ // picture later.
9305
+ if (
9306
+ !this.device ||
9307
+ !this.mainPerFrameBindGroupLayout ||
9308
+ !this.shadowMapDepthViews?.length ||
9309
+ !this.cameraUniformBuffer ||
9310
+ !this.mirrorCameraBuffer ||
9311
+ !this.lightUniformBuffer ||
9312
+ !this.lightsBuffer ||
9313
+ !this.shadowLightVPBuffer ||
9314
+ !this.materialSampler ||
9315
+ !this.shadowComparisonSampler ||
9316
+ !this.brdfLutView ||
9317
+ !(this.worldEquirectView ?? this.fallbackEquirectView)
9318
+ ) {
9319
+ return false
9320
+ }
9321
+ // BOTH ENTRY LISTS ARE WRITTEN OUT, rather than shared through a helper.
9322
+ // tests/bindings.test.mjs reads this file and checks statically that every
9323
+ // bind group covers exactly its layout — it cannot see through a function,
9324
+ // and a missing binding is a validation error at draw time rather than a
9325
+ // compile error here. The duplication is the price of that check.
9326
+ const env = this.worldEquirectView ?? this.fallbackEquirectView
9327
+ this.perFrameBindGroup = this.device.createBindGroup({
9328
+ label: "main per-frame bind group",
9329
+ layout: this.mainPerFrameBindGroupLayout,
9330
+ entries: [
9331
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
9332
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
9333
+ { binding: 2, resource: this.materialSampler },
9334
+ { binding: 3, resource: this.shadowMapDepthViews[0] },
9335
+ { binding: 4, resource: this.shadowComparisonSampler },
9336
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
9337
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
9338
+ { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
9339
+ { binding: 8, resource: env },
9340
+ { binding: 9, resource: this.brdfLutView },
9341
+ ],
9342
+ })
9343
+ this.mirrorPerFrameBindGroup = this.device.createBindGroup({
9344
+ label: "mirror per-frame bind group",
9345
+ layout: this.mainPerFrameBindGroupLayout,
9346
+ entries: [
9347
+ { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
9348
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
9349
+ { binding: 2, resource: this.materialSampler },
9350
+ { binding: 3, resource: this.shadowMapDepthViews[0] },
9351
+ { binding: 4, resource: this.shadowComparisonSampler },
9352
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
9353
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
9354
+ { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
9355
+ { binding: 8, resource: env },
9356
+ { binding: 9, resource: this.brdfLutView },
9357
+ ],
9358
+ })
9359
+ return true
9360
+ }
9361
+
9201
9362
  /**
9202
9363
  * Write world ambient. For a uniform-radiance world, hemispherical irradiance
9203
9364
  * is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
@@ -9208,17 +9369,22 @@ export class Engine {
9208
9369
  this.lightData[0] = this.world.color.x * s
9209
9370
  this.lightData[1] = this.world.color.y * s
9210
9371
  this.lightData[2] = this.world.color.z * s
9211
- this.lightData[3] = 0
9372
+ // The spare in the ambient vec4, which rzWorldSpecular multiplies its sky
9373
+ // sample by: the SH carries strength already, the texture cannot.
9374
+ this.lightData[3] = this.world.strength
9212
9375
  // The sky's irradiance, when an HDRI world is installed — the world
9213
9376
  // STRENGTH dial keeps its meaning by scaling it, and the world COLOUR is
9214
9377
  // simply unread while the flag is up (Blender's own semantics: the image
9215
9378
  // replaces the colour, strength applies to either).
9379
+ // An HDRI outranks a gradient outranks the flat colour, which is the order
9380
+ // of how much each one knows about the sky.
9381
+ const sh = this.worldSH ?? this.worldGradientSH
9216
9382
  for (let i = 0; i < 9; i++) {
9217
9383
  const b = 36 + i * 4
9218
- if (this.worldSH) {
9219
- this.lightData[b] = this.worldSH[i * 3] * s
9220
- this.lightData[b + 1] = this.worldSH[i * 3 + 1] * s
9221
- this.lightData[b + 2] = this.worldSH[i * 3 + 2] * s
9384
+ if (sh) {
9385
+ this.lightData[b] = sh[i * 3] * s
9386
+ this.lightData[b + 1] = sh[i * 3 + 1] * s
9387
+ this.lightData[b + 2] = sh[i * 3 + 2] * s
9222
9388
  } else {
9223
9389
  this.lightData[b] = 0
9224
9390
  this.lightData[b + 1] = 0
@@ -9226,7 +9392,11 @@ export class Engine {
9226
9392
  }
9227
9393
  this.lightData[b + 3] = 0
9228
9394
  }
9229
- this.lightData[39] = this.worldSH ? 1 : 0
9395
+ // WHICH KIND of sky, not merely whether there is one: 2 is a picture a
9396
+ // reflection can be sampled from, 1 is a gradient it can only be fitted to,
9397
+ // 0 is a flat colour. rzWorldSpecular reads this to decide whether to touch
9398
+ // the equirect texture at all.
9399
+ this.lightData[39] = this.worldSH ? 2 : this.worldGradientSH ? 1 : 0
9230
9400
  this.updateLightBuffer()
9231
9401
  }
9232
9402
 
@@ -9251,6 +9421,9 @@ export class Engine {
9251
9421
  setWorld(options: WorldOptions): void {
9252
9422
  if (options.color) this.world.color = options.color
9253
9423
  if (options.strength !== undefined) this.world.strength = options.strength
9424
+ if (options.gradient !== undefined) {
9425
+ this.worldGradientSH = options.gradient ? gradientIrradianceSH(options.gradient) : null
9426
+ }
9254
9427
  this.writeWorld()
9255
9428
  }
9256
9429
 
@@ -9377,12 +9550,17 @@ export class Engine {
9377
9550
  * keep the meaning the caller gave them, which is the same rule the anchor
9378
9551
  * table follows. Passing none (or an empty list) turns the layer off and the
9379
9552
  * scene renders exactly as it did before lights existed.
9553
+ *
9554
+ * A light with an `aim` is a SPOT: it reaches only inside `angle`, full
9555
+ * inside `innerAngle`, and the two are degrees from the axis the way every
9556
+ * tool states a cone. Without one it is a point light, which the shading
9557
+ * spells as a cone that covers everything rather than as a second case.
9380
9558
  */
9381
9559
  setLights(
9382
9560
  /** Structural {x,y,z} rather than the Vec3 class, the same choice effect
9383
9561
  * params make: a scene document's JSON passes straight in, and so does a
9384
9562
  * literal typed into a console. Vec3 satisfies it either way. */
9385
- lights: { position: XYZ; color: XYZ; intensity?: number; radius?: number }[] | null,
9563
+ lights: SceneLight[] | null,
9386
9564
  ): void {
9387
9565
  const list = (lights ?? []).slice(0, MAX_LIGHTS)
9388
9566
  this.docLightCount = list.length
@@ -9409,9 +9587,29 @@ export class Engine {
9409
9587
  this.lightsData[b + 4] = Math.max(l.color.x * k, 0)
9410
9588
  this.lightsData[b + 5] = Math.max(l.color.y * k, 0)
9411
9589
  this.lightsData[b + 6] = Math.max(l.color.z * k, 0)
9412
- // [b + 7] is `type`, reserved: every light is a point light today.
9413
- }
9414
- if (list.length) {
9590
+ // A spot only where an aim was given AND it points somewhere: a zero
9591
+ // vector cannot be normalised, and a cone around nothing would light
9592
+ // nothing at all — a light that vanishes because its aim was left at the
9593
+ // default is worse than one that shines everywhere.
9594
+ const len = l.aim ? Math.hypot(l.aim.x, l.aim.y, l.aim.z) : 0
9595
+ this.lightsData[b + 7] = len > 0 ? 1 : 0
9596
+ this.lightsData[b + 8] = len > 0 ? l.aim!.x / len : 0
9597
+ this.lightsData[b + 9] = len > 0 ? l.aim!.y / len : 0
9598
+ this.lightsData[b + 10] = len > 0 ? l.aim!.z / len : 0
9599
+ // Half-angles in degrees, as cosines. Ordered so the outer edge is never
9600
+ // inside the inner one — a cone written the other way round would divide
9601
+ // by a negative span and light its own rim instead of its middle.
9602
+ const outer = len > 0 ? Math.cos((Math.min(Math.max(l.angle ?? 45, 0), 179) / 2) * (Math.PI / 180)) : -1
9603
+ const inner = len > 0 ? Math.cos((Math.min(Math.max(l.innerAngle ?? (l.angle ?? 45) * 0.8, 0), 179) / 2) * (Math.PI / 180)) : -1
9604
+ this.lightsData[b + 11] = outer
9605
+ this.lightsData[b + 12] = Math.max(inner, outer)
9606
+ this.lightsData[b + 13] = 0
9607
+ this.lightsData[b + 14] = 0
9608
+ this.lightsData[b + 15] = 0
9609
+ }
9610
+ // The CPU copy is written either way; only the upload needs a device. A
9611
+ // scene whose lamps arrive before init keeps them, and init uploads them.
9612
+ if (list.length && this.device && this.lightsBuffer) {
9415
9613
  this.device.queue.writeBuffer(
9416
9614
  this.lightsBuffer,
9417
9615
  LIGHT_HEADER * 4,
@@ -11491,6 +11689,38 @@ export class Engine {
11491
11689
  this.cullReadback = null
11492
11690
  }
11493
11691
 
11692
+ /** How far the loaded scene reaches from the origin, in world units — the
11693
+ * largest any model has ever needed. */
11694
+ private sceneExtent = 0
11695
+
11696
+ /**
11697
+ * Tell the camera how big the scene is, so its far plane can cover it.
11698
+ *
11699
+ * MEASURED ON LOAD, from the model's own vertices, and only ever raised. A
11700
+ * stage is the case that matters: framed from ten units away, a sky dome a
11701
+ * thousand units out sits well beyond a far plane derived from the orbit, and
11702
+ * the part of it past that plane is simply not drawn — which looks like a
11703
+ * polygon of background colour that follows the camera, not like clipping.
11704
+ *
11705
+ * Never lowered when a model leaves: the cost of a far plane that is too
11706
+ * generous is depth precision, and the cost of one that is too near is
11707
+ * geometry that vanishes. One of those is a bug report.
11708
+ */
11709
+ private noteSceneExtent(model: Model): void {
11710
+ const { positions } = model.getGeometry()
11711
+ let most = 0
11712
+ // Every 16th vertex: an extent is a bound, not a measurement, and a stage
11713
+ // has a quarter of a million of them.
11714
+ for (let i = 0; i + 2 < positions.length; i += 48) {
11715
+ const d = positions[i] * positions[i] + positions[i + 1] * positions[i + 1] + positions[i + 2] * positions[i + 2]
11716
+ if (d > most) most = d
11717
+ }
11718
+ const extent = Math.sqrt(most)
11719
+ if (extent <= this.sceneExtent) return
11720
+ this.sceneExtent = extent
11721
+ this.camera?.setSceneExtent(extent)
11722
+ }
11723
+
11494
11724
  /**
11495
11725
  * One record per model: the rigid transform its per-material boxes live under,
11496
11726
  * or the world sphere that bounds it in any pose. Written every frame, because
@@ -12182,6 +12412,7 @@ export class Engine {
12182
12412
  const vertices = model.getVertices()
12183
12413
  const skinning = model.getSkinning()
12184
12414
  const skeleton = model.getSkeleton()
12415
+ this.noteSceneExtent(model)
12185
12416
  const boneCount = skeleton.bones.length
12186
12417
  const matrixSize = boneCount * 16 * 4
12187
12418
 
@@ -14436,6 +14667,17 @@ export class Engine {
14436
14667
  }
14437
14668
 
14438
14669
  private renderWithDelta(deltaTime: number) {
14670
+ // The bind groups first, then the textures they used to name. A retired
14671
+ // texture is freed only once a frame has been rebuilt without it — freeing
14672
+ // it while a group still points at it is what "destroyed texture used in a
14673
+ // submit" means, and it repeats every frame because the stale group stays.
14674
+ if (this.worldBindingsDirty) {
14675
+ // Both have to take: either one bailing leaves a group naming the sky
14676
+ // that was replaced, which is a frame drawn against the wrong one.
14677
+ const perFrame = this.rebuildPerFrameBindGroups()
14678
+ const composite = this.rebuildCompositeBindGroup()
14679
+ this.worldBindingsDirty = !(perFrame && composite)
14680
+ }
14439
14681
  // The scene clock, and the only clock a trail may sample on: renderFrame()
14440
14682
  // drives offline export with an exact per-frame delta, so a path recorded
14441
14683
  // against this is reproducible where one recorded against wall time is not.
@@ -15865,6 +16107,11 @@ export class Engine {
15865
16107
  // shader derives the full viewport (width via projection aspect) for its
15866
16108
  // babylon-mmd constant-pixel edge extrusion.
15867
16109
  this.cameraMatrixData[35] = this.canvas.height
16110
+ // The SCENE clock, which is what a material may animate on: it pauses with
16111
+ // the transport and an export steps it exactly, so a ripple is in the same
16112
+ // place in the file as it was on screen. Wall time would drift between the
16113
+ // two. The buffer was already allocated at 40 floats; this is one of them.
16114
+ this.cameraMatrixData[36] = this.sceneClock
15868
16115
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
15869
16116
 
15870
16117
  // 360 backdrop: the composite reconstructs each pixel's view ray from the
@@ -132,6 +132,16 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
132
132
  outputs: { color: "color", alpha: "float" },
133
133
  contextOutputs: { color: "tex_color", alpha: "tex_s.a" },
134
134
  },
135
+ // The scene clock, in seconds. A material is otherwise a function of position
136
+ // alone — this is what lets one SCROLL: ripples on water, a conveyor, a sign.
137
+ // It is the same clock the effects read, so a paused scene holds still and an
138
+ // export steps it frame by frame.
139
+ time: {
140
+ inputs: {},
141
+ outputs: { value: "float" },
142
+ contextOutputs: { value: "camera.time" },
143
+ },
144
+
135
145
  geometry: {
136
146
  inputs: {},
137
147
  outputs: {
@@ -630,6 +640,27 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
630
640
  emit: (a) => `layer_weight_facing(${a.blend}, n, v)`,
631
641
  },
632
642
 
643
+ /**
644
+ * The world's radiance from a direction — Blender's Environment Texture, read
645
+ * off the scene's own sky rather than off an image a material carries.
646
+ *
647
+ * ITS DEFAULT VECTOR IS THE REFLECTION, which is the whole point: wire
648
+ * nothing and it answers what this surface reflects. The roughness input
649
+ * picks how blurred the answer is; at 0 it is a mirror.
650
+ *
651
+ * A scene with no HDRI has no sky to reflect, and this returns the flat world
652
+ * colour — the same value the ambient already carries, so such a scene looks
653
+ * exactly as it did.
654
+ */
655
+ environment: {
656
+ inputs: {
657
+ vector: { type: "vector", contextDefault: "reflect(-v, n)" },
658
+ roughness: F(0),
659
+ },
660
+ outputs: { color: "color" },
661
+ emit: (a) => `rzWorldSpecular(${a.vector}, ${a.roughness})`,
662
+ },
663
+
633
664
  // ── Lighting capture ──
634
665
  /**
635
666
  * Shader → RGB on a white diffuse closure, reduced to a scalar.
package/src/ibl.ts CHANGED
@@ -113,3 +113,44 @@ export function evalIrradianceSH(sh: Float32Array, n: { x: number; y: number; z:
113
113
  }
114
114
  return out
115
115
  }
116
+
117
+ /**
118
+ * A three-colour sky as irradiance SH — the same nine coefficients an HDRI
119
+ * gives, so nothing downstream has to know which kind of world it is looking at.
120
+ *
121
+ * The gradient is Unity's trilight and every other engine's "ambient gradient":
122
+ * one colour straight up, one at the horizon, one straight down, interpolated
123
+ * by the direction's height. It is what lights a game stage that has no baked
124
+ * lighting, and it is not a decoration — the fill on a wall facing the sky and
125
+ * the fill under a roof differ by the whole span between sky and ground.
126
+ *
127
+ * Built by SAMPLING IT INTO A SMALL EQUIRECT and projecting that, rather than by
128
+ * integrating the basis functions in closed form. The analytic route is a page
129
+ * of coefficients that has to be rederived if the interpolation ever changes;
130
+ * this is six lines that reuse the projector the HDRI path is already trusted
131
+ * to get right, and at 64x32 the fit is exact to well under a percent because
132
+ * the function has no detail finer than the harmonics can hold anyway.
133
+ */
134
+ type Rgb = { x: number; y: number; z: number }
135
+
136
+ export function gradientIrradianceSH(g: { sky: Rgb; equator: Rgb; ground: Rgb }): Float32Array {
137
+ const w = 64
138
+ const h = 32
139
+ const data = new Float32Array(w * h * 4)
140
+ for (let y = 0; y < h; y++) {
141
+ // The same parameterisation projectIrradianceSH reads: v runs top to bottom
142
+ // and the direction's height is cos(pi v).
143
+ const ny = Math.cos(Math.PI * ((y + 0.5) / h))
144
+ const t = Math.abs(ny)
145
+ const a = ny >= 0 ? g.sky : g.ground
146
+ 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]
147
+ for (let x = 0; x < w; x++) {
148
+ const i = (y * w + x) * 4
149
+ data[i] = c[0]
150
+ data[i + 1] = c[1]
151
+ data[i + 2] = c[2]
152
+ data[i + 3] = 1
153
+ }
154
+ }
155
+ return projectIrradianceSH({ width: w, height: h, data }, 1)
156
+ }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  type MaterialPreset,
15
15
  type MaterialPresetMap,
16
16
  type ModelTransform,
17
+ type SceneLight,
17
18
  type ModelAttachment,
18
19
  type GizmoDragEvent,
19
20
  type GizmoDragCallback,
package/src/reflection.ts CHANGED
@@ -156,5 +156,8 @@ export function buildMirrorCamera(
156
156
  out[33] = camera[33] - t * ny
157
157
  out[34] = camera[34] - t * nz
158
158
  out[35] = camera[35]
159
+ // The clock, unfolded: a mirrored draw of an animated material must be at the
160
+ // same instant as the one it reflects, or the ripples disagree across the pane.
161
+ out[36] = camera[36]
159
162
  return out
160
163
  }