reze-engine 0.56.11 → 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
  /**
@@ -2301,6 +2311,9 @@ export class Engine {
2301
2311
  private worldEquirectTexture: GPUTexture | null = null
2302
2312
  private worldEquirectView: GPUTextureView | null = null
2303
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
2304
2317
  /** The installed HDRI's folded irradiance SH (27 floats), or null. */
2305
2318
  private worldSH: Float32Array | null = null
2306
2319
  private fallbackEquirectTexture!: GPUTexture
@@ -3593,9 +3606,12 @@ export class Engine {
3593
3606
  }
3594
3607
  }
3595
3608
 
3596
- private rebuildCompositeBindGroup(): void {
3597
- if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
3598
- 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
3599
3615
  // BEFORE the entries below, not after: they ask fieldPairUsed which effects
3600
3616
  // draw, and that answer includes whether an effect has its field bind group
3601
3617
  // yet. Building the composite first and the field groups second would bind
@@ -3630,6 +3646,7 @@ export class Engine {
3630
3646
  { binding: 21, resource: this.fieldLayerView(this.fieldFgViews[1], 1) },
3631
3647
  ],
3632
3648
  })
3649
+ return true
3633
3650
  }
3634
3651
 
3635
3652
  /**
@@ -4092,7 +4109,13 @@ export class Engine {
4092
4109
  * coefficients, so what lights her is what you see.
4093
4110
  */
4094
4111
  setWorldEquirect(source: HdrImage | null, options?: { strength?: number }): void {
4095
- 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)
4096
4119
  this.worldEquirectTexture = null
4097
4120
  this.worldEquirectView = null
4098
4121
  this.worldStrength = Math.max(options?.strength ?? 1, 0)
@@ -4102,18 +4125,54 @@ export class Engine {
4102
4125
  // Scene-linear radiance in rgba16float. The composite treats it as light
4103
4126
  // rather than wallpaper (mode 3) — a sun in it rolls off like a sun,
4104
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
4105
4134
  const tex = this.device.createTexture({
4106
4135
  label: "world equirect (HDR)",
4107
4136
  size: [source.width, source.height],
4108
4137
  format: "rgba16float",
4138
+ mipLevelCount: levels,
4109
4139
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
4110
4140
  })
4111
- this.device.queue.writeTexture(
4112
- { texture: tex },
4113
- packHalf(source.data),
4114
- { bytesPerRow: source.width * 8, rowsPerImage: source.height },
4115
- [source.width, source.height],
4116
- )
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
+ }
4117
4176
  this.worldEquirectTexture = tex
4118
4177
  this.worldEquirectView = tex.createView()
4119
4178
  // The sky lights the scene, not only backs it. The sun keeps the toon
@@ -4125,6 +4184,12 @@ export class Engine {
4125
4184
  }
4126
4185
  }
4127
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()
4128
4193
  this.rebuildCompositeBindGroup()
4129
4194
  if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
4130
4195
  }
@@ -5147,6 +5212,12 @@ export class Engine {
5147
5212
  * uniform, so nothing recompiles.
5148
5213
  */
5149
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
5150
5221
  let next = this.docLightCount
5151
5222
  for (const e of this.effects) {
5152
5223
  if (!e.lights) continue
@@ -6743,8 +6814,11 @@ export class Engine {
6743
6814
  // The positional lights. Always bound, empty or not, so every material
6744
6815
  // pipeline shares one layout whether or not the scene has any.
6745
6816
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
6746
- // 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.
6747
6820
  { binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
6821
+ { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6748
6822
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6749
6823
  ],
6750
6824
  })
@@ -6988,6 +7062,19 @@ export class Engine {
6988
7062
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
6989
7063
  })
6990
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
+
6991
7078
  // Zero-filled = zero lines, which every accessor answers gracefully.
6992
7079
  this.lyricsBuffer = this.device.createBuffer({
6993
7080
  label: "lyrics",
@@ -7005,39 +7092,7 @@ export class Engine {
7005
7092
  this.lyricsTextureView = this.lyricsTexture.createView()
7006
7093
 
7007
7094
  // Now that shadow resources exist, create the main per-frame bind group
7008
- this.perFrameBindGroup = this.device.createBindGroup({
7009
- label: "main per-frame bind group",
7010
- layout: this.mainPerFrameBindGroupLayout,
7011
- entries: [
7012
- { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
7013
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
7014
- { binding: 2, resource: this.materialSampler },
7015
- { binding: 3, resource: this.shadowMapDepthViews[0] },
7016
- { binding: 4, resource: this.shadowComparisonSampler },
7017
- { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
7018
- { binding: 6, resource: { buffer: this.lightsBuffer } },
7019
- { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
7020
- { binding: 9, resource: this.brdfLutView },
7021
- ],
7022
- })
7023
- // The mirror's, identical but for the camera — same lights, same shadow
7024
- // maps, because a reflection is the same scene lit the same way, seen from
7025
- // a reflected eye.
7026
- this.mirrorPerFrameBindGroup = this.device.createBindGroup({
7027
- label: "mirror per-frame bind group",
7028
- layout: this.mainPerFrameBindGroupLayout,
7029
- entries: [
7030
- { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
7031
- { binding: 1, resource: { buffer: this.lightUniformBuffer } },
7032
- { binding: 2, resource: this.materialSampler },
7033
- { binding: 3, resource: this.shadowMapDepthViews[0] },
7034
- { binding: 4, resource: this.shadowComparisonSampler },
7035
- { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
7036
- { binding: 6, resource: { buffer: this.lightsBuffer } },
7037
- { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
7038
- { binding: 9, resource: this.brdfLutView },
7039
- ],
7040
- })
7095
+ this.rebuildPerFrameBindGroups()
7041
7096
 
7042
7097
  this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
7043
7098
  label: "ground shadow layout",
@@ -7441,14 +7496,6 @@ export class Engine {
7441
7496
  { binding: 21, visibility: GPUShaderStage.FRAGMENT, texture: {} },
7442
7497
  ],
7443
7498
  })
7444
- this.fallbackEquirectTexture = this.device.createTexture({
7445
- label: "equirect fallback",
7446
- size: [1, 1],
7447
- format: "rgba8unorm",
7448
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
7449
- })
7450
- this.fallbackEquirectView = this.fallbackEquirectTexture.createView()
7451
-
7452
7499
  this.compositePipelineLayout = this.device.createPipelineLayout({
7453
7500
  bindGroupLayouts: [this.compositeBindGroupLayout],
7454
7501
  })
@@ -9215,6 +9262,103 @@ export class Engine {
9215
9262
  this.writeSun(0)
9216
9263
  }
9217
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
+
9218
9362
  /**
9219
9363
  * Write world ambient. For a uniform-radiance world, hemispherical irradiance
9220
9364
  * is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
@@ -9225,17 +9369,22 @@ export class Engine {
9225
9369
  this.lightData[0] = this.world.color.x * s
9226
9370
  this.lightData[1] = this.world.color.y * s
9227
9371
  this.lightData[2] = this.world.color.z * s
9228
- 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
9229
9375
  // The sky's irradiance, when an HDRI world is installed — the world
9230
9376
  // STRENGTH dial keeps its meaning by scaling it, and the world COLOUR is
9231
9377
  // simply unread while the flag is up (Blender's own semantics: the image
9232
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
9233
9382
  for (let i = 0; i < 9; i++) {
9234
9383
  const b = 36 + i * 4
9235
- if (this.worldSH) {
9236
- this.lightData[b] = this.worldSH[i * 3] * s
9237
- this.lightData[b + 1] = this.worldSH[i * 3 + 1] * s
9238
- 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
9239
9388
  } else {
9240
9389
  this.lightData[b] = 0
9241
9390
  this.lightData[b + 1] = 0
@@ -9243,7 +9392,11 @@ export class Engine {
9243
9392
  }
9244
9393
  this.lightData[b + 3] = 0
9245
9394
  }
9246
- 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
9247
9400
  this.updateLightBuffer()
9248
9401
  }
9249
9402
 
@@ -9268,6 +9421,9 @@ export class Engine {
9268
9421
  setWorld(options: WorldOptions): void {
9269
9422
  if (options.color) this.world.color = options.color
9270
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
+ }
9271
9427
  this.writeWorld()
9272
9428
  }
9273
9429
 
@@ -9451,7 +9607,9 @@ export class Engine {
9451
9607
  this.lightsData[b + 14] = 0
9452
9608
  this.lightsData[b + 15] = 0
9453
9609
  }
9454
- if (list.length) {
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) {
9455
9613
  this.device.queue.writeBuffer(
9456
9614
  this.lightsBuffer,
9457
9615
  LIGHT_HEADER * 4,
@@ -11531,6 +11689,38 @@ export class Engine {
11531
11689
  this.cullReadback = null
11532
11690
  }
11533
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
+
11534
11724
  /**
11535
11725
  * One record per model: the rigid transform its per-material boxes live under,
11536
11726
  * or the world sphere that bounds it in any pose. Written every frame, because
@@ -12222,6 +12412,7 @@ export class Engine {
12222
12412
  const vertices = model.getVertices()
12223
12413
  const skinning = model.getSkinning()
12224
12414
  const skeleton = model.getSkeleton()
12415
+ this.noteSceneExtent(model)
12225
12416
  const boneCount = skeleton.bones.length
12226
12417
  const matrixSize = boneCount * 16 * 4
12227
12418
 
@@ -14476,6 +14667,17 @@ export class Engine {
14476
14667
  }
14477
14668
 
14478
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
+ }
14479
14681
  // The scene clock, and the only clock a trail may sample on: renderFrame()
14480
14682
  // drives offline export with an exact per-frame delta, so a path recorded
14481
14683
  // against this is reproducible where one recorded against wall time is not.
@@ -640,6 +640,27 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
640
640
  emit: (a) => `layer_weight_facing(${a.blend}, n, v)`,
641
641
  },
642
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
+
643
664
  // ── Lighting capture ──
644
665
  /**
645
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
+ }
@@ -44,13 +44,23 @@ export const LIGHT_HEADER = 4
44
44
  /** Floats per light — see the layout above. */
45
45
  export const LIGHT_STRIDE = 16
46
46
  /**
47
- * The cap. The loop below runs per fragment over the lights a scene has, so
48
- * this bounds the worst case rather than the ordinary one — and it has to clear
49
- * a game stage: a Unity rip of one arrives with thirty-odd point lights, which
50
- * the old ceiling of sixteen cut in half. Past this the extras are dropped.
51
- * Clustering is what replaces it when a scene wants hundreds.
47
+ * The cap. The loop below runs per fragment over the lights a scene HAS, so
48
+ * this bounds the worst case rather than the ordinary one, and the buffer is
49
+ * storage rather than uniform — 128 records is 8 KiB, which costs nothing to
50
+ * declare and is paid for only by the scenes that fill it.
51
+ *
52
+ * Sized against the work, not against a round number: a Unity stage arrives
53
+ * with a lamp per fixture. One rip of a resort brought thirty-three, a lit
54
+ * interior brings a hundred or more, and the ceilings of sixteen and then
55
+ * forty-eight each cut a real scene in half. Past this the extras are dropped.
56
+ *
57
+ * What it does NOT buy: a scene that genuinely lights a fragment from a hundred
58
+ * lamps pays for a hundred iterations of the loop. The distance test below
59
+ * rejects most of them in a few instructions, which is what makes a rig of this
60
+ * size affordable at all; clustering is what replaces the linear walk when a
61
+ * scene wants every one of them close enough to matter.
52
62
  */
53
- export const MAX_LIGHTS = 48
63
+ export const MAX_LIGHTS = 128
54
64
  /** Floats in the whole buffer. */
55
65
  export const LIGHTS_FLOATS = LIGHT_HEADER + MAX_LIGHTS * LIGHT_STRIDE
56
66
 
@@ -136,6 +136,13 @@ struct LightVP { viewProj: array<mat4x4f, ${SHADOW_CASCADES.length}>, };
136
136
  // keeps its shadows when the crisp near volume ends. Binding 7: 6 is the
137
137
  // positional lights, 9 the BRDF LUT.
138
138
  @group(0) @binding(7) var shadowMapFar: texture_depth_2d;
139
+ // The world's own sky, for a surface that REFLECTS it rather than merely being
140
+ // lit by it. Equirect, in the same layout and the same linear radiance the
141
+ // composite draws the dome from, and mipped — a rough surface reads a coarser
142
+ // level, which is what a prefiltered environment is for. A scene with no HDRI
143
+ // has the 1x1 fallback bound here; rzWorldSpecular checks the SH flag and
144
+ // answers with the flat ambient instead, so nothing samples the placeholder.
145
+ @group(0) @binding(8) var worldEnvTexture: texture_2d<f32>;
139
146
  // binding(9) brdfLut is declared inside NODES_WGSL (nodes.ts).
140
147
  @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
141
148
  @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
@@ -290,6 +290,47 @@ fn point_world_to_camera(p: vec3f) -> vec3f { return (camera.view * vec4f(p, 1.0
290
290
  // One function per slot because WGSL has no texture arrays-of-bindings here; the
291
291
  // slot is topology, exactly like a Math node's operation.
292
292
 
293
+ /**
294
+ * The world's radiance arriving from a direction — what a reflection sees.
295
+ *
296
+ * The engine's ambient is irradiance: the SH fit of the sky integrated over a
297
+ * hemisphere, evaluated along the NORMAL. That is the right answer for diffuse
298
+ * light and the wrong one for a mirror, which needs radiance along the
299
+ * REFLECTION vector and needs it sharp. A stage ripped from a game asks for
300
+ * this on nearly every surface — it is what a Unity material's DEFAULT_REFLECTION
301
+ * keyword means —
302
+ * and so does every metal the app has ever drawn.
303
+ *
304
+ * ROUGHNESS PICKS A MIP, which is the cheap prefilter: the chain is built by
305
+ * successive halving at upload, so a level is a box average of the sky over a
306
+ * widening solid angle. A cosine-lobe convolution would be more correct; the
307
+ * visible difference is confined to the middle of the roughness range, and the
308
+ * chain costs one downsample per level instead of a render pass per level.
309
+ *
310
+ * Without an HDRI there is nothing to reflect but the flat world colour, which
311
+ * is what the ambient already is — so the two agree and a scene without a sky
312
+ * texture sees no change at all.
313
+ */
314
+ fn rzWorldSpecular(dir: vec3f, roughness: f32) -> vec3f {
315
+ // A SKY TEXTURE, or the best the scene has. The flag is 2 when an HDRI is
316
+ // installed, 1 when the world is a gradient fitted to the same harmonics,
317
+ // and 0 when it is one flat colour — and in the last two cases the ambient
318
+ // already answers by direction, which is a blurred sky and exactly what a
319
+ // world without a picture can honestly reflect.
320
+ if (light.sh[0].w < 1.5) { return rzWorldAmbient(normalize(dir)); }
321
+ let d = normalize(dir);
322
+ // The composite's own convention, restated: LH world with +Z forward, which
323
+ // is what makes a reflection line up with the dome the viewer can see.
324
+ let u = 0.5 + atan2(d.x, d.z) * 0.15915494309;
325
+ let v = 0.5 - asin(clamp(d.y, -1.0, 1.0)) * 0.31830988618;
326
+ let levels = f32(textureNumLevels(worldEnvTexture) - 1u);
327
+ // sqrt, not linear: mip n covers roughly twice the angle of n-1, so a linear
328
+ // ramp spends most of its travel in the blurry end and a mirror-to-satin
329
+ // sweep happens entirely in the first tenth of the dial.
330
+ let lod = clamp(sqrt(clamp(roughness, 0.0, 1.0)) * levels, 0.0, levels);
331
+ return textureSampleLevel(worldEnvTexture, diffuseSampler, vec2f(u, v), lod).rgb * light.ambientColor.w;
332
+ }
333
+
293
334
  fn group_tex0(uv: vec2f) -> vec4f { return textureSample(groupTexture0, diffuseSampler, uv); }
294
335
  fn group_tex1(uv: vec2f) -> vec4f { return textureSample(groupTexture1, diffuseSampler, uv); }
295
336
  fn group_tex2(uv: vec2f) -> vec4f { return textureSample(groupTexture2, diffuseSampler, uv); }