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/dist/camera.d.ts +13 -1
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +25 -2
- package/dist/engine.d.ts +70 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +242 -53
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +20 -0
- package/dist/ibl.d.ts +28 -0
- package/dist/ibl.d.ts.map +1 -1
- package/dist/ibl.js +21 -0
- package/dist/shaders/lights.d.ts +16 -6
- package/dist/shaders/lights.d.ts.map +1 -1
- package/dist/shaders/lights.js +16 -6
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +7 -0
- package/dist/shaders/materials/nodes.d.ts +1 -1
- package/dist/shaders/materials/nodes.d.ts.map +1 -1
- package/dist/shaders/materials/nodes.js +41 -0
- package/package.json +1 -1
- package/src/camera.ts +26 -2
- package/src/engine.ts +262 -60
- package/src/graph/registry.ts +21 -0
- package/src/ibl.ts +41 -0
- package/src/shaders/lights.ts +16 -6
- package/src/shaders/materials/common.ts +7 -0
- package/src/shaders/materials/nodes.ts +41 -0
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
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
9236
|
-
this.lightData[b] =
|
|
9237
|
-
this.lightData[b + 1] =
|
|
9238
|
-
this.lightData[b + 2] =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/graph/registry.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/shaders/lights.ts
CHANGED
|
@@ -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
|
|
48
|
-
* this bounds the worst case rather than the ordinary one
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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 =
|
|
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); }
|