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/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 +75 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +257 -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 +281 -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
|
/**
|
|
@@ -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
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
9236
|
-
this.lightData[b] =
|
|
9237
|
-
this.lightData[b + 1] =
|
|
9238
|
-
this.lightData[b + 2] =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
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>;
|