reze-engine 0.56.11 → 0.56.13

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
  /**
@@ -657,6 +667,11 @@ type DrawCallType = "opaque" | "transparent" | "ground" | "opaque-outline" | "tr
657
667
 
658
668
  interface DrawCall {
659
669
  type: DrawCallType
670
+ /** The phase this material's own alpha put it in, before any style group had
671
+ * a say. A `hashed` group moves its materials to the opaque phase, and this
672
+ * is what they go back to when that group is removed — recomputing it would
673
+ * mean re-sampling the texture's alpha over the geometry again. */
674
+ baseType: DrawCallType
660
675
  count: number
661
676
  firstIndex: number
662
677
  bindGroup: GPUBindGroup
@@ -2301,6 +2316,9 @@ export class Engine {
2301
2316
  private worldEquirectTexture: GPUTexture | null = null
2302
2317
  private worldEquirectView: GPUTextureView | null = null
2303
2318
  private worldStrength = 1
2319
+ /** A three-colour sky's irradiance SH, when the world is a gradient rather
2320
+ * than a picture. An HDRI outranks it — see writeWorld. */
2321
+ private worldGradientSH: Float32Array | null = null
2304
2322
  /** The installed HDRI's folded irradiance SH (27 floats), or null. */
2305
2323
  private worldSH: Float32Array | null = null
2306
2324
  private fallbackEquirectTexture!: GPUTexture
@@ -3593,9 +3611,12 @@ export class Engine {
3593
3611
  }
3594
3612
  }
3595
3613
 
3596
- private rebuildCompositeBindGroup(): void {
3597
- if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
3598
- if (!this.castBuffer) return
3614
+ /** Returns whether it actually rebuilt: a caller retrying after a resource
3615
+ * swap has to know, because a bail leaves the previous group — and the
3616
+ * previous group names the texture the swap replaced. */
3617
+ private rebuildCompositeBindGroup(): boolean {
3618
+ if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return false
3619
+ if (!this.castBuffer) return false
3599
3620
  // BEFORE the entries below, not after: they ask fieldPairUsed which effects
3600
3621
  // draw, and that answer includes whether an effect has its field bind group
3601
3622
  // yet. Building the composite first and the field groups second would bind
@@ -3630,6 +3651,7 @@ export class Engine {
3630
3651
  { binding: 21, resource: this.fieldLayerView(this.fieldFgViews[1], 1) },
3631
3652
  ],
3632
3653
  })
3654
+ return true
3633
3655
  }
3634
3656
 
3635
3657
  /**
@@ -4092,7 +4114,13 @@ export class Engine {
4092
4114
  * coefficients, so what lights her is what you see.
4093
4115
  */
4094
4116
  setWorldEquirect(source: HdrImage | null, options?: { strength?: number }): void {
4095
- this.worldEquirectTexture?.destroy()
4117
+ // RETIRED, NOT DESTROYED. A frame already encoded against the old sky can
4118
+ // still be in flight — the bind groups that name it are rebuilt below, but
4119
+ // the command buffer holding the previous ones is submitted at the end of
4120
+ // the frame this call landed in the middle of. Destroying here is what
4121
+ // produces "Destroyed texture used in a submit"; the next frame's start is
4122
+ // the first moment nothing can be pointing at it.
4123
+ if (this.worldEquirectTexture) this.retiredSkies.push(this.worldEquirectTexture)
4096
4124
  this.worldEquirectTexture = null
4097
4125
  this.worldEquirectView = null
4098
4126
  this.worldStrength = Math.max(options?.strength ?? 1, 0)
@@ -4102,18 +4130,54 @@ export class Engine {
4102
4130
  // Scene-linear radiance in rgba16float. The composite treats it as light
4103
4131
  // rather than wallpaper (mode 3) — a sun in it rolls off like a sun,
4104
4132
  // through the same exposure and view transform as the scene.
4133
+ // MIPPED, and the chain is the prefilter a reflection reads: level n is a
4134
+ // box average of the sky over twice the solid angle of n-1, which is what
4135
+ // lets rzWorldSpecular answer a rough surface without a blur pass per
4136
+ // level. Built on the CPU because the source is already float data in
4137
+ // memory and the alternative is a render pipeline per mip.
4138
+ const levels = Math.floor(Math.log2(Math.max(source.width, source.height))) + 1
4105
4139
  const tex = this.device.createTexture({
4106
4140
  label: "world equirect (HDR)",
4107
4141
  size: [source.width, source.height],
4108
4142
  format: "rgba16float",
4143
+ mipLevelCount: levels,
4109
4144
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
4110
4145
  })
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
- )
4146
+ let data = source.data
4147
+ let w = source.width
4148
+ let h = source.height
4149
+ for (let level = 0; level < levels; level++) {
4150
+ if (level > 0) {
4151
+ const nw = Math.max(1, w >> 1)
4152
+ const nh = Math.max(1, h >> 1)
4153
+ const down = new Float32Array(nw * nh * 4)
4154
+ for (let y = 0; y < nh; y++) {
4155
+ for (let x = 0; x < nw; x++) {
4156
+ const x0 = Math.min(x * 2, w - 1)
4157
+ const x1 = Math.min(x * 2 + 1, w - 1)
4158
+ const y0 = Math.min(y * 2, h - 1)
4159
+ const y1 = Math.min(y * 2 + 1, h - 1)
4160
+ for (let c = 0; c < 4; c++) {
4161
+ down[(y * nw + x) * 4 + c] =
4162
+ (data[(y0 * w + x0) * 4 + c] +
4163
+ data[(y0 * w + x1) * 4 + c] +
4164
+ data[(y1 * w + x0) * 4 + c] +
4165
+ data[(y1 * w + x1) * 4 + c]) *
4166
+ 0.25
4167
+ }
4168
+ }
4169
+ }
4170
+ data = down
4171
+ w = nw
4172
+ h = nh
4173
+ }
4174
+ this.device.queue.writeTexture(
4175
+ { texture: tex, mipLevel: level },
4176
+ packHalf(data),
4177
+ { bytesPerRow: w * 8, rowsPerImage: h },
4178
+ [w, h],
4179
+ )
4180
+ }
4117
4181
  this.worldEquirectTexture = tex
4118
4182
  this.worldEquirectView = tex.createView()
4119
4183
  // The sky lights the scene, not only backs it. The sun keeps the toon
@@ -4125,6 +4189,12 @@ export class Engine {
4125
4189
  }
4126
4190
  }
4127
4191
  if (this.worldSH || hadSH) this.writeWorld()
4192
+ // The MATERIAL groups too, not only the composite's: a surface that
4193
+ // reflects the sky samples this very texture, so a world swapped after
4194
+ // init would otherwise keep reflecting the one it replaced. Marked as well,
4195
+ // because either rebuild may bail on resources that are not up yet.
4196
+ this.worldBindingsDirty = true
4197
+ this.rebuildPerFrameBindGroups()
4128
4198
  this.rebuildCompositeBindGroup()
4129
4199
  if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
4130
4200
  }
@@ -5147,6 +5217,12 @@ export class Engine {
5147
5217
  * uniform, so nothing recompiles.
5148
5218
  */
5149
5219
  private allocateLightSlots(): void {
5220
+ // BEFORE INIT IS A REAL CALLER. A host sets a scene's lamps from an effect
5221
+ // that runs as soon as its state exists, which on a hot reload is before
5222
+ // the engine has a device — and every public setter here has to survive
5223
+ // that, the way setWorldEquirect does. The buffers are written from the
5224
+ // engine's own state at init, so nothing is lost by returning.
5225
+ if (!this.device) return
5150
5226
  let next = this.docLightCount
5151
5227
  for (const e of this.effects) {
5152
5228
  if (!e.lights) continue
@@ -6743,8 +6819,11 @@ export class Engine {
6743
6819
  // The positional lights. Always bound, empty or not, so every material
6744
6820
  // pipeline shares one layout whether or not the scene has any.
6745
6821
  { 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.
6822
+ // The far cascade's shadow map, then the world's sky (8) and the BRDF
6823
+ // LUT (9). The sky is always bound — the 1x1 fallback when the scene
6824
+ // has none — so every material pipeline keeps sharing one layout.
6747
6825
  { binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
6826
+ { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6748
6827
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
6749
6828
  ],
6750
6829
  })
@@ -6988,6 +7067,19 @@ export class Engine {
6988
7067
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
6989
7068
  })
6990
7069
  this.device.queue.writeBuffer(this.lightsBuffer, 0, this.lightsData)
7070
+ // BEFORE the per-frame groups, which bind it. The composite needs this 1x1
7071
+ // stand-in too, and it used to be created with the composite's own
7072
+ // resources further down — far enough down that a material group built up
7073
+ // here had nothing to put on its sky binding, and every draw went out with
7074
+ // no bind group at index 0.
7075
+ this.fallbackEquirectTexture = this.device.createTexture({
7076
+ label: "equirect fallback",
7077
+ size: [1, 1],
7078
+ format: "rgba8unorm",
7079
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
7080
+ })
7081
+ this.fallbackEquirectView = this.fallbackEquirectTexture.createView()
7082
+
6991
7083
  // Zero-filled = zero lines, which every accessor answers gracefully.
6992
7084
  this.lyricsBuffer = this.device.createBuffer({
6993
7085
  label: "lyrics",
@@ -7005,39 +7097,7 @@ export class Engine {
7005
7097
  this.lyricsTextureView = this.lyricsTexture.createView()
7006
7098
 
7007
7099
  // 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
- })
7100
+ this.rebuildPerFrameBindGroups()
7041
7101
 
7042
7102
  this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
7043
7103
  label: "ground shadow layout",
@@ -7441,14 +7501,6 @@ export class Engine {
7441
7501
  { binding: 21, visibility: GPUShaderStage.FRAGMENT, texture: {} },
7442
7502
  ],
7443
7503
  })
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
7504
  this.compositePipelineLayout = this.device.createPipelineLayout({
7453
7505
  bindGroupLayouts: [this.compositeBindGroupLayout],
7454
7506
  })
@@ -9215,6 +9267,103 @@ export class Engine {
9215
9267
  this.writeSun(0)
9216
9268
  }
9217
9269
 
9270
+ /**
9271
+ * The per-frame bind groups, the camera's and the mirror's.
9272
+ *
9273
+ * A METHOD rather than two literals at init, because one of their resources
9274
+ * changes after it: the world's sky texture is swapped whenever an HDRI is
9275
+ * installed or cleared, and a bind group holds the view it was built with.
9276
+ * The mirror's is identical but for the camera — a reflection is the same
9277
+ * scene lit the same way, seen from a reflected eye.
9278
+ */
9279
+ /**
9280
+ * Skies this engine has replaced, kept alive rather than destroyed.
9281
+ *
9282
+ * NOT DESTROYED AT ALL, after three attempts that each deferred it further:
9283
+ * one frame, then three, then "once both bind groups have rebuilt". Every one
9284
+ * still produced "destroyed texture used in a submit" somewhere — a command
9285
+ * buffer encoded before the swap, a rebuild that bailed on half-built
9286
+ * resources, an engine torn down by a hot reload whose last frame is still in
9287
+ * flight. The engine cannot see all of those, and each attempt to enumerate
9288
+ * them found another.
9289
+ *
9290
+ * So the trade is taken explicitly: a replaced sky is a megabyte or two held
9291
+ * until the page goes away, against a validation error every frame. A scene
9292
+ * swaps its world a handful of times in a session, and the GPU frees all of
9293
+ * it when the device does.
9294
+ */
9295
+ private retiredSkies: GPUTexture[] = []
9296
+
9297
+ /** A world was installed or cleared and the bind groups that name its texture
9298
+ * may not have been rebuilt yet — both rebuilds bail when their own
9299
+ * resources are half-built, which is exactly the moment a host swaps a sky.
9300
+ * Retried at the top of a frame until one of them takes. */
9301
+ private worldBindingsDirty = false
9302
+
9303
+ /** Returns whether it actually rebuilt — see rebuildCompositeBindGroup. */
9304
+ private rebuildPerFrameBindGroups(): boolean {
9305
+ // EVERY resource, not just the layout. This runs twice: once at init, where
9306
+ // the order is known, and again whenever a world is installed — which the
9307
+ // host may do before init has finished, and a bind group built around an
9308
+ // undefined buffer is a validation error at creation rather than a missing
9309
+ // picture later.
9310
+ if (
9311
+ !this.device ||
9312
+ !this.mainPerFrameBindGroupLayout ||
9313
+ !this.shadowMapDepthViews?.length ||
9314
+ !this.cameraUniformBuffer ||
9315
+ !this.mirrorCameraBuffer ||
9316
+ !this.lightUniformBuffer ||
9317
+ !this.lightsBuffer ||
9318
+ !this.shadowLightVPBuffer ||
9319
+ !this.materialSampler ||
9320
+ !this.shadowComparisonSampler ||
9321
+ !this.brdfLutView ||
9322
+ !(this.worldEquirectView ?? this.fallbackEquirectView)
9323
+ ) {
9324
+ return false
9325
+ }
9326
+ // BOTH ENTRY LISTS ARE WRITTEN OUT, rather than shared through a helper.
9327
+ // tests/bindings.test.mjs reads this file and checks statically that every
9328
+ // bind group covers exactly its layout — it cannot see through a function,
9329
+ // and a missing binding is a validation error at draw time rather than a
9330
+ // compile error here. The duplication is the price of that check.
9331
+ const env = this.worldEquirectView ?? this.fallbackEquirectView
9332
+ this.perFrameBindGroup = this.device.createBindGroup({
9333
+ label: "main per-frame bind group",
9334
+ layout: this.mainPerFrameBindGroupLayout,
9335
+ entries: [
9336
+ { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
9337
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
9338
+ { binding: 2, resource: this.materialSampler },
9339
+ { binding: 3, resource: this.shadowMapDepthViews[0] },
9340
+ { binding: 4, resource: this.shadowComparisonSampler },
9341
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
9342
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
9343
+ { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
9344
+ { binding: 8, resource: env },
9345
+ { binding: 9, resource: this.brdfLutView },
9346
+ ],
9347
+ })
9348
+ this.mirrorPerFrameBindGroup = this.device.createBindGroup({
9349
+ label: "mirror per-frame bind group",
9350
+ layout: this.mainPerFrameBindGroupLayout,
9351
+ entries: [
9352
+ { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
9353
+ { binding: 1, resource: { buffer: this.lightUniformBuffer } },
9354
+ { binding: 2, resource: this.materialSampler },
9355
+ { binding: 3, resource: this.shadowMapDepthViews[0] },
9356
+ { binding: 4, resource: this.shadowComparisonSampler },
9357
+ { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
9358
+ { binding: 6, resource: { buffer: this.lightsBuffer } },
9359
+ { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
9360
+ { binding: 8, resource: env },
9361
+ { binding: 9, resource: this.brdfLutView },
9362
+ ],
9363
+ })
9364
+ return true
9365
+ }
9366
+
9218
9367
  /**
9219
9368
  * Write world ambient. For a uniform-radiance world, hemispherical irradiance
9220
9369
  * is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
@@ -9225,17 +9374,22 @@ export class Engine {
9225
9374
  this.lightData[0] = this.world.color.x * s
9226
9375
  this.lightData[1] = this.world.color.y * s
9227
9376
  this.lightData[2] = this.world.color.z * s
9228
- this.lightData[3] = 0
9377
+ // The spare in the ambient vec4, which rzWorldSpecular multiplies its sky
9378
+ // sample by: the SH carries strength already, the texture cannot.
9379
+ this.lightData[3] = this.world.strength
9229
9380
  // The sky's irradiance, when an HDRI world is installed — the world
9230
9381
  // STRENGTH dial keeps its meaning by scaling it, and the world COLOUR is
9231
9382
  // simply unread while the flag is up (Blender's own semantics: the image
9232
9383
  // replaces the colour, strength applies to either).
9384
+ // An HDRI outranks a gradient outranks the flat colour, which is the order
9385
+ // of how much each one knows about the sky.
9386
+ const sh = this.worldSH ?? this.worldGradientSH
9233
9387
  for (let i = 0; i < 9; i++) {
9234
9388
  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
9389
+ if (sh) {
9390
+ this.lightData[b] = sh[i * 3] * s
9391
+ this.lightData[b + 1] = sh[i * 3 + 1] * s
9392
+ this.lightData[b + 2] = sh[i * 3 + 2] * s
9239
9393
  } else {
9240
9394
  this.lightData[b] = 0
9241
9395
  this.lightData[b + 1] = 0
@@ -9243,7 +9397,11 @@ export class Engine {
9243
9397
  }
9244
9398
  this.lightData[b + 3] = 0
9245
9399
  }
9246
- this.lightData[39] = this.worldSH ? 1 : 0
9400
+ // WHICH KIND of sky, not merely whether there is one: 2 is a picture a
9401
+ // reflection can be sampled from, 1 is a gradient it can only be fitted to,
9402
+ // 0 is a flat colour. rzWorldSpecular reads this to decide whether to touch
9403
+ // the equirect texture at all.
9404
+ this.lightData[39] = this.worldSH ? 2 : this.worldGradientSH ? 1 : 0
9247
9405
  this.updateLightBuffer()
9248
9406
  }
9249
9407
 
@@ -9268,6 +9426,9 @@ export class Engine {
9268
9426
  setWorld(options: WorldOptions): void {
9269
9427
  if (options.color) this.world.color = options.color
9270
9428
  if (options.strength !== undefined) this.world.strength = options.strength
9429
+ if (options.gradient !== undefined) {
9430
+ this.worldGradientSH = options.gradient ? gradientIrradianceSH(options.gradient) : null
9431
+ }
9271
9432
  this.writeWorld()
9272
9433
  }
9273
9434
 
@@ -9362,6 +9523,7 @@ export class Engine {
9362
9523
  this.hasGround = true
9363
9524
  this.groundDrawCall = {
9364
9525
  type: "ground",
9526
+ baseType: "ground",
9365
9527
  count: 6,
9366
9528
  firstIndex: 0,
9367
9529
  bindGroup: this.groundShadowBindGroup!,
@@ -9451,7 +9613,9 @@ export class Engine {
9451
9613
  this.lightsData[b + 14] = 0
9452
9614
  this.lightsData[b + 15] = 0
9453
9615
  }
9454
- if (list.length) {
9616
+ // The CPU copy is written either way; only the upload needs a device. A
9617
+ // scene whose lamps arrive before init keeps them, and init uploads them.
9618
+ if (list.length && this.device && this.lightsBuffer) {
9455
9619
  this.device.queue.writeBuffer(
9456
9620
  this.lightsBuffer,
9457
9621
  LIGHT_HEADER * 4,
@@ -11531,6 +11695,38 @@ export class Engine {
11531
11695
  this.cullReadback = null
11532
11696
  }
11533
11697
 
11698
+ /** How far the loaded scene reaches from the origin, in world units — the
11699
+ * largest any model has ever needed. */
11700
+ private sceneExtent = 0
11701
+
11702
+ /**
11703
+ * Tell the camera how big the scene is, so its far plane can cover it.
11704
+ *
11705
+ * MEASURED ON LOAD, from the model's own vertices, and only ever raised. A
11706
+ * stage is the case that matters: framed from ten units away, a sky dome a
11707
+ * thousand units out sits well beyond a far plane derived from the orbit, and
11708
+ * the part of it past that plane is simply not drawn — which looks like a
11709
+ * polygon of background colour that follows the camera, not like clipping.
11710
+ *
11711
+ * Never lowered when a model leaves: the cost of a far plane that is too
11712
+ * generous is depth precision, and the cost of one that is too near is
11713
+ * geometry that vanishes. One of those is a bug report.
11714
+ */
11715
+ private noteSceneExtent(model: Model): void {
11716
+ const { positions } = model.getGeometry()
11717
+ let most = 0
11718
+ // Every 16th vertex: an extent is a bound, not a measurement, and a stage
11719
+ // has a quarter of a million of them.
11720
+ for (let i = 0; i + 2 < positions.length; i += 48) {
11721
+ const d = positions[i] * positions[i] + positions[i + 1] * positions[i + 1] + positions[i + 2] * positions[i + 2]
11722
+ if (d > most) most = d
11723
+ }
11724
+ const extent = Math.sqrt(most)
11725
+ if (extent <= this.sceneExtent) return
11726
+ this.sceneExtent = extent
11727
+ this.camera?.setSceneExtent(extent)
11728
+ }
11729
+
11534
11730
  /**
11535
11731
  * One record per model: the rigid transform its per-material boxes live under,
11536
11732
  * or the world sphere that bounds it in any pose. Written every frame, because
@@ -12222,6 +12418,7 @@ export class Engine {
12222
12418
  const vertices = model.getVertices()
12223
12419
  const skinning = model.getSkinning()
12224
12420
  const skeleton = model.getSkeleton()
12421
+ this.noteSceneExtent(model)
12225
12422
  const boneCount = skeleton.bones.length
12226
12423
  const matrixSize = boneCount * 16 * 4
12227
12424
 
@@ -13013,6 +13210,7 @@ export class Engine {
13013
13210
  const type: DrawCallType = inst.isPlane ? "opaque" : isTransparent ? "transparent" : "opaque"
13014
13211
  inst.drawCalls.push({
13015
13212
  type,
13213
+ baseType: type,
13016
13214
  count: indexCount,
13017
13215
  firstIndex: currentIndexOffset,
13018
13216
  bindGroup,
@@ -14476,6 +14674,17 @@ export class Engine {
14476
14674
  }
14477
14675
 
14478
14676
  private renderWithDelta(deltaTime: number) {
14677
+ // The bind groups first, then the textures they used to name. A retired
14678
+ // texture is freed only once a frame has been rebuilt without it — freeing
14679
+ // it while a group still points at it is what "destroyed texture used in a
14680
+ // submit" means, and it repeats every frame because the stale group stays.
14681
+ if (this.worldBindingsDirty) {
14682
+ // Both have to take: either one bailing leaves a group naming the sky
14683
+ // that was replaced, which is a frame drawn against the wrong one.
14684
+ const perFrame = this.rebuildPerFrameBindGroups()
14685
+ const composite = this.rebuildCompositeBindGroup()
14686
+ this.worldBindingsDirty = !(perFrame && composite)
14687
+ }
14479
14688
  // The scene clock, and the only clock a trail may sample on: renderFrame()
14480
14689
  // drives offline export with an exact per-frame delta, so a path recorded
14481
14690
  // against this is reproducible where one recorded against wall time is not.
@@ -15418,6 +15627,18 @@ export class Engine {
15418
15627
  const install = wantId ? inst.styleGroups.get(wantId) : undefined
15419
15628
  const groupId = install ? wantId! : null
15420
15629
  if (groupId) inst.materialToGroup.set(dc.materialName, groupId)
15630
+ // A HASHED GROUP DRAWS IN THE OPAQUE PHASE. Alpha-to-coverage is the
15631
+ // transparency technique built for that phase — it is why a card already
15632
+ // takes this route — and it is what a cutout wants: crisp edges, depth
15633
+ // written, correct occlusion between layers.
15634
+ //
15635
+ // Foliage is the case that needs it. A leaf card's antialiased edges put
15636
+ // its translucentFrac over the 2% bar, so the whole shrub lands in the
15637
+ // alpha-blend bucket and is drawn in author order: soft haloed leaves
15638
+ // that do not occlude each other, and haze wherever cards overlap. The
15639
+ // game it came from alpha-tests exactly these materials.
15640
+ const type = install?.alphaMode === "hashed" ? "opaque" : dc.baseType
15641
+ if (dc.type !== type) dc.type = type
15421
15642
  if (dc.groupId === groupId) continue
15422
15643
  dc.groupId = groupId
15423
15644
  dc.bindGroup = this.createMaterialBindGroup(
@@ -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>;