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