reze-engine 0.58.4 → 0.60.0
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/engine.d.ts +35 -4
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +157 -33
- package/dist/graph/slots.js +2 -2
- package/dist/shaders/lights.d.ts.map +1 -1
- package/dist/shaders/lights.js +22 -14
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +7 -3
- package/dist/shadow-cascades.d.ts +84 -34
- package/dist/shadow-cascades.d.ts.map +1 -1
- package/dist/shadow-cascades.js +101 -55
- package/package.json +1 -1
- package/src/engine.ts +151 -37
- package/src/graph/slots.ts +2 -2
- package/src/shaders/lights.ts +22 -14
- package/src/shaders/materials/common.ts +7 -3
- package/src/shadow-cascades.ts +159 -67
package/src/engine.ts
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
type SimClock,
|
|
45
45
|
} from "./effect-schedule"
|
|
46
46
|
import { parentKeySpan, type ModelParentKey } from "./parent-keys"
|
|
47
|
-
import { SHADOW_CASCADES,
|
|
47
|
+
import { SHADOW_CASCADES, buildShadowCascades, type ShadowBounds, type ShadowView } 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"
|
|
@@ -866,8 +866,10 @@ interface ModelInstance {
|
|
|
866
866
|
shadowDrawCalls: DrawCall[]
|
|
867
867
|
shadowBindGroups: GPUBindGroup[]
|
|
868
868
|
mainPerInstanceBindGroup: GPUBindGroup
|
|
869
|
-
/** Its own fill
|
|
870
|
-
|
|
869
|
+
/** Its own light — fill and sun — when setModelFill or setModelSun gave it
|
|
870
|
+
* one: a 32-byte ModelLight, and the CPU copy it is written from. */
|
|
871
|
+
lightBuffer: GPUBuffer | null
|
|
872
|
+
modelLight: Float32Array | null
|
|
871
873
|
pickPerInstanceBindGroup: GPUBindGroup
|
|
872
874
|
pickDrawCalls: PickDrawCall[]
|
|
873
875
|
/** Environment geometry added via addStage — no physics, no IK, and it
|
|
@@ -2459,7 +2461,7 @@ export class Engine {
|
|
|
2459
2461
|
private depthReadView: GPUTextureView | null = null
|
|
2460
2462
|
private compositeUniformBuffer!: GPUBuffer
|
|
2461
2463
|
// [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
|
|
2462
|
-
//
|
|
2464
|
+
// 15 × vec4f — see the viewU comment in composite.ts. The last one is the
|
|
2463
2465
|
// camera's world position, which is what lets a foreground effect turn the
|
|
2464
2466
|
// depth it is handed into a PLACE (bgWorldPos) rather than a distance.
|
|
2465
2467
|
private readonly compositeUniformData = new Float32Array(60)
|
|
@@ -7307,8 +7309,8 @@ export class Engine {
|
|
|
7307
7309
|
)
|
|
7308
7310
|
|
|
7309
7311
|
this.noFillBuffer = this.device.createBuffer({
|
|
7310
|
-
label: "model
|
|
7311
|
-
size:
|
|
7312
|
+
label: "model light (none)",
|
|
7313
|
+
size: 32,
|
|
7312
7314
|
usage: GPUBufferUsage.UNIFORM,
|
|
7313
7315
|
})
|
|
7314
7316
|
|
|
@@ -7422,7 +7424,7 @@ export class Engine {
|
|
|
7422
7424
|
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
|
7423
7425
|
buffer: { type: "read-only-storage" },
|
|
7424
7426
|
},
|
|
7425
|
-
// The model's own
|
|
7427
|
+
// The model's own light — see setModelFill and setModelSun.
|
|
7426
7428
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
7427
7429
|
],
|
|
7428
7430
|
})
|
|
@@ -8018,7 +8020,7 @@ export class Engine {
|
|
|
8018
8020
|
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
8019
8021
|
this.compositeUniformBuffer = this.device.createBuffer({
|
|
8020
8022
|
label: "composite view uniforms",
|
|
8021
|
-
//
|
|
8023
|
+
// 15 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
|
|
8022
8024
|
// (bg rgb, mode) · camera right/up/forward basis for the 360 skybox ray ·
|
|
8023
8025
|
// (time, _, canvas width, canvas height) for user effects · three grade
|
|
8024
8026
|
// vectors (CDL offset+contrast, power+saturation, slope+flag) · camera
|
|
@@ -10753,39 +10755,66 @@ export class Engine {
|
|
|
10753
10755
|
* for the models the host counts as cast.
|
|
10754
10756
|
*/
|
|
10755
10757
|
setModelFill(name: string, fill: Vec3 | null): boolean {
|
|
10758
|
+
return this.writeModelLight(name, 0, fill)
|
|
10759
|
+
}
|
|
10760
|
+
|
|
10761
|
+
/**
|
|
10762
|
+
* A model's own sun: the colour and strength the scene's sun has FOR THIS
|
|
10763
|
+
* MODEL, in place of the scene's. Null gives it the scene's sun back.
|
|
10764
|
+
*
|
|
10765
|
+
* The other half of lighting a stage and its cast apart. A game's stage is
|
|
10766
|
+
* lit by its own daylight — Aether Gazer's kitchen at twenty times white —
|
|
10767
|
+
* while its characters take a key light of their own that the stage never
|
|
10768
|
+
* states. So a stage carries the sun it was lit by, and the scene's sun
|
|
10769
|
+
* stays the cast's. Direction and shadow are still the scene's: one sun
|
|
10770
|
+
* casts, and it casts the same way on both.
|
|
10771
|
+
*/
|
|
10772
|
+
setModelSun(name: string, sun: Vec3 | null): boolean {
|
|
10773
|
+
return this.writeModelLight(name, 4, sun)
|
|
10774
|
+
}
|
|
10775
|
+
|
|
10776
|
+
/** One half of a model's light, at `at` (0 the fill, 4 the sun) in its
|
|
10777
|
+
* ModelLight; w says whether that half is set. Both null releases the
|
|
10778
|
+
* buffer and the model takes the shared zero stand-in again. */
|
|
10779
|
+
private writeModelLight(name: string, at: 0 | 4, value: Vec3 | null): boolean {
|
|
10756
10780
|
const inst = this.modelInstances.get(name)
|
|
10757
10781
|
if (!inst || !this.device) return false
|
|
10758
|
-
|
|
10759
|
-
|
|
10760
|
-
|
|
10761
|
-
|
|
10782
|
+
const light = inst.modelLight ?? new Float32Array(8)
|
|
10783
|
+
light.set(value ? [value.x, value.y, value.z, 1] : [0, 0, 0, 0], at)
|
|
10784
|
+
const any = light[3] > 0 || light[7] > 0
|
|
10785
|
+
if (!any) {
|
|
10786
|
+
if (!inst.lightBuffer) return true
|
|
10787
|
+
const retired = inst.lightBuffer
|
|
10788
|
+
inst.lightBuffer = null
|
|
10789
|
+
inst.modelLight = null
|
|
10762
10790
|
inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, null)
|
|
10763
10791
|
this.bundlesDirty = true
|
|
10764
10792
|
// Retired once the GPU is done with it: an encoded frame may still name it.
|
|
10765
10793
|
void this.device.queue.onSubmittedWorkDone().then(() => retired.destroy())
|
|
10766
10794
|
return true
|
|
10767
10795
|
}
|
|
10768
|
-
|
|
10769
|
-
|
|
10770
|
-
|
|
10771
|
-
|
|
10796
|
+
inst.modelLight = light
|
|
10797
|
+
if (!inst.lightBuffer) {
|
|
10798
|
+
inst.lightBuffer = this.device.createBuffer({
|
|
10799
|
+
label: `${name}: light`,
|
|
10800
|
+
size: 32,
|
|
10772
10801
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
10773
10802
|
})
|
|
10774
|
-
inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, inst.
|
|
10803
|
+
inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, inst.lightBuffer)
|
|
10775
10804
|
this.bundlesDirty = true
|
|
10776
10805
|
}
|
|
10777
|
-
this.device.queue.writeBuffer(inst.
|
|
10806
|
+
this.device.queue.writeBuffer(inst.lightBuffer, 0, light.buffer as ArrayBuffer)
|
|
10778
10807
|
return true
|
|
10779
10808
|
}
|
|
10780
10809
|
|
|
10781
|
-
/** The per-model group: its skinning matrices and its
|
|
10782
|
-
private perInstanceBindGroup(name: string, skinMatrixBuffer: GPUBuffer,
|
|
10810
|
+
/** The per-model group: its skinning matrices and its light, or the zero stand-in. */
|
|
10811
|
+
private perInstanceBindGroup(name: string, skinMatrixBuffer: GPUBuffer, light: GPUBuffer | null): GPUBindGroup {
|
|
10783
10812
|
return this.device.createBindGroup({
|
|
10784
10813
|
label: `${name}: main per-instance bind group`,
|
|
10785
10814
|
layout: this.mainPerInstanceBindGroupLayout,
|
|
10786
10815
|
entries: [
|
|
10787
10816
|
{ binding: 0, resource: { buffer: skinMatrixBuffer } },
|
|
10788
|
-
{ binding: 1, resource: { buffer:
|
|
10817
|
+
{ binding: 1, resource: { buffer: light ?? this.noFillBuffer } },
|
|
10789
10818
|
],
|
|
10790
10819
|
})
|
|
10791
10820
|
}
|
|
@@ -10819,7 +10848,7 @@ export class Engine {
|
|
|
10819
10848
|
for (const buf of inst.gpuBuffers) {
|
|
10820
10849
|
buf.destroy()
|
|
10821
10850
|
}
|
|
10822
|
-
inst.
|
|
10851
|
+
inst.lightBuffer?.destroy()
|
|
10823
10852
|
// Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
|
|
10824
10853
|
for (const install of inst.styleGroups.values()) this.destroyInstall(install)
|
|
10825
10854
|
this.modelInstances.delete(name)
|
|
@@ -12511,6 +12540,55 @@ export class Engine {
|
|
|
12511
12540
|
}
|
|
12512
12541
|
if (this.cullModelBuffer) this.device.queue.writeBuffer(this.cullModelBuffer, 0, data.buffer as ArrayBuffer)
|
|
12513
12542
|
this.updateCasterSphere(data)
|
|
12543
|
+
this.updateShadowSceneBounds(data, flags)
|
|
12544
|
+
}
|
|
12545
|
+
|
|
12546
|
+
/**
|
|
12547
|
+
* The box around everything visible, from the same numbers the cull reads:
|
|
12548
|
+
* a rigid model's per-draw boxes through its matrix, a posed model's sphere.
|
|
12549
|
+
* The shadow cascades reach along the light across it.
|
|
12550
|
+
*/
|
|
12551
|
+
private updateShadowSceneBounds(data: Float32Array, flags: Uint32Array): void {
|
|
12552
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity
|
|
12553
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity
|
|
12554
|
+
for (let i = 0; i < this.cullDraws.length; i++) {
|
|
12555
|
+
const f = i * 8
|
|
12556
|
+
const mi = this.cullMetaU32[f + 3]
|
|
12557
|
+
const o = mi * Engine.CULL_MODEL_FLOATS
|
|
12558
|
+
const mf = flags[o + 20]
|
|
12559
|
+
if ((mf & Engine.CULL_MODEL_VISIBLE) === 0) continue
|
|
12560
|
+
if ((mf & Engine.CULL_MODEL_RIGID) !== 0) {
|
|
12561
|
+
const cx = (this.cullMetaF32[f] + this.cullMetaF32[f + 4]) * 0.5
|
|
12562
|
+
const cy = (this.cullMetaF32[f + 1] + this.cullMetaF32[f + 5]) * 0.5
|
|
12563
|
+
const cz = (this.cullMetaF32[f + 2] + this.cullMetaF32[f + 6]) * 0.5
|
|
12564
|
+
const ex = (this.cullMetaF32[f + 4] - this.cullMetaF32[f]) * 0.5
|
|
12565
|
+
const ey = (this.cullMetaF32[f + 5] - this.cullMetaF32[f + 1]) * 0.5
|
|
12566
|
+
const ez = (this.cullMetaF32[f + 6] - this.cullMetaF32[f + 2]) * 0.5
|
|
12567
|
+
const wx = data[o] * cx + data[o + 4] * cy + data[o + 8] * cz + data[o + 12]
|
|
12568
|
+
const wy = data[o + 1] * cx + data[o + 5] * cy + data[o + 9] * cz + data[o + 13]
|
|
12569
|
+
const wz = data[o + 2] * cx + data[o + 6] * cy + data[o + 10] * cz + data[o + 14]
|
|
12570
|
+
const gx = Math.abs(data[o]) * ex + Math.abs(data[o + 4]) * ey + Math.abs(data[o + 8]) * ez
|
|
12571
|
+
const gy = Math.abs(data[o + 1]) * ex + Math.abs(data[o + 5]) * ey + Math.abs(data[o + 9]) * ez
|
|
12572
|
+
const gz = Math.abs(data[o + 2]) * ex + Math.abs(data[o + 6]) * ey + Math.abs(data[o + 10]) * ez
|
|
12573
|
+
minX = Math.min(minX, wx - gx); maxX = Math.max(maxX, wx + gx)
|
|
12574
|
+
minY = Math.min(minY, wy - gy); maxY = Math.max(maxY, wy + gy)
|
|
12575
|
+
minZ = Math.min(minZ, wz - gz); maxZ = Math.max(maxZ, wz + gz)
|
|
12576
|
+
} else {
|
|
12577
|
+
const r = data[o + 19]
|
|
12578
|
+
if (!(r > 0)) continue
|
|
12579
|
+
minX = Math.min(minX, data[o + 16] - r); maxX = Math.max(maxX, data[o + 16] + r)
|
|
12580
|
+
minY = Math.min(minY, data[o + 17] - r); maxY = Math.max(maxY, data[o + 17] + r)
|
|
12581
|
+
minZ = Math.min(minZ, data[o + 18] - r); maxZ = Math.max(maxZ, data[o + 18] + r)
|
|
12582
|
+
}
|
|
12583
|
+
}
|
|
12584
|
+
if (!Number.isFinite(minX)) {
|
|
12585
|
+
this.shadowSceneBounds = null
|
|
12586
|
+
return
|
|
12587
|
+
}
|
|
12588
|
+
const b = this.shadowSceneBounds ?? { min: [0, 0, 0], max: [0, 0, 0] }
|
|
12589
|
+
b.min[0] = minX; b.min[1] = minY; b.min[2] = minZ
|
|
12590
|
+
b.max[0] = maxX; b.max[1] = maxY; b.max[2] = maxZ
|
|
12591
|
+
this.shadowSceneBounds = b
|
|
12514
12592
|
}
|
|
12515
12593
|
|
|
12516
12594
|
/**
|
|
@@ -13364,7 +13442,8 @@ export class Engine {
|
|
|
13364
13442
|
shadowDrawCalls: [],
|
|
13365
13443
|
shadowBindGroups,
|
|
13366
13444
|
mainPerInstanceBindGroup,
|
|
13367
|
-
|
|
13445
|
+
lightBuffer: null,
|
|
13446
|
+
modelLight: null,
|
|
13368
13447
|
pickPerInstanceBindGroup,
|
|
13369
13448
|
pickDrawCalls: [],
|
|
13370
13449
|
isStage,
|
|
@@ -13790,25 +13869,60 @@ export class Engine {
|
|
|
13790
13869
|
/** How much shadow the sun casts — see SunOptions.shadow. Full until told. */
|
|
13791
13870
|
private sunShadow = 1
|
|
13792
13871
|
private shadowLightVPDirty = true
|
|
13793
|
-
|
|
13794
|
-
|
|
13872
|
+
/** The cascades as last uploaded, to skip the upload while nothing moved:
|
|
13873
|
+
* the fit snaps to the map's texels, so a still camera fits the same box. */
|
|
13874
|
+
private readonly shadowLightVPLast = new Float32Array(16 * SHADOW_CASCADES.length)
|
|
13875
|
+
/** Everything drawn, in world space, as writeCullModels last saw it. The
|
|
13876
|
+
* cascades reach along the light from its nearest face to its farthest, so
|
|
13877
|
+
* a window frame behind the camera casts onto the floor in front of it. */
|
|
13878
|
+
private shadowSceneBounds: ShadowBounds = null
|
|
13879
|
+
private readonly shadowView: ShadowView = {
|
|
13880
|
+
eye: { x: 0, y: 0, z: 0 },
|
|
13881
|
+
right: { x: 1, y: 0, z: 0 },
|
|
13882
|
+
up: { x: 0, y: 1, z: 0 },
|
|
13883
|
+
forward: { x: 0, y: 0, z: 1 },
|
|
13884
|
+
fov: 1,
|
|
13885
|
+
aspect: 1,
|
|
13886
|
+
near: 1,
|
|
13887
|
+
far: 100,
|
|
13888
|
+
focus: 10,
|
|
13889
|
+
}
|
|
13795
13890
|
|
|
13796
13891
|
private updateShadowLightVP() {
|
|
13797
|
-
//
|
|
13798
|
-
//
|
|
13799
|
-
//
|
|
13800
|
-
// GPU; this method
|
|
13892
|
+
// THE CASCADES FOLLOW THE CAMERA — fitted every frame to what it sees, the
|
|
13893
|
+
// way Blender's sun shadow is, with their depth fitted to the scene. The
|
|
13894
|
+
// fit MATH lives in shadow-cascades.ts, where it is testable without a
|
|
13895
|
+
// GPU; this method gathers the view and owns the upload.
|
|
13896
|
+
const v = this.shadowView
|
|
13897
|
+
const view = this.camera.getViewMatrix().values
|
|
13898
|
+
const eye = this.camera.getEyePosition()
|
|
13899
|
+
v.eye.x = eye.x
|
|
13900
|
+
v.eye.y = eye.y
|
|
13901
|
+
v.eye.z = eye.z
|
|
13902
|
+
// lookAt writes the basis as the view's rows: right, up, forward.
|
|
13903
|
+
v.right.x = view[0]
|
|
13904
|
+
v.right.y = view[4]
|
|
13905
|
+
v.right.z = view[8]
|
|
13906
|
+
v.up.x = view[1]
|
|
13907
|
+
v.up.y = view[5]
|
|
13908
|
+
v.up.z = view[9]
|
|
13909
|
+
v.forward.x = view[2]
|
|
13910
|
+
v.forward.y = view[6]
|
|
13911
|
+
v.forward.z = view[10]
|
|
13912
|
+
v.fov = this.camera.fov
|
|
13913
|
+
v.aspect = this.camera.aspect
|
|
13914
|
+
v.near = this.camera.near
|
|
13915
|
+
v.far = this.camera.far
|
|
13801
13916
|
const t = this.camera.target
|
|
13802
|
-
|
|
13803
|
-
Math.abs(t.x - this.shadowCenter.x) > 1e-3 ||
|
|
13804
|
-
Math.abs(t.y - this.shadowCenter.y) > 1e-3 ||
|
|
13805
|
-
Math.abs(t.z - this.shadowCenter.z) > 1e-3
|
|
13806
|
-
if (!this.shadowLightVPDirty && !moved) return
|
|
13807
|
-
this.shadowLightVPDirty = false
|
|
13808
|
-
this.shadowCenter.setXYZ(t.x, t.y, t.z)
|
|
13917
|
+
v.focus = Math.hypot(t.x - eye.x, t.y - eye.y, t.z - eye.z)
|
|
13809
13918
|
|
|
13919
|
+
buildShadowCascades(v, this.sun.direction, this.shadowSceneBounds, this.shadowLightVPMatrix)
|
|
13920
|
+
let same = !this.shadowLightVPDirty
|
|
13921
|
+
for (let i = 0; same && i < this.shadowLightVPMatrix.length; i++) same = this.shadowLightVPMatrix[i] === this.shadowLightVPLast[i]
|
|
13922
|
+
if (same) return
|
|
13923
|
+
this.shadowLightVPDirty = false
|
|
13924
|
+
this.shadowLightVPLast.set(this.shadowLightVPMatrix)
|
|
13810
13925
|
for (let i = 0; i < SHADOW_CASCADES.length; i++) {
|
|
13811
|
-
buildShadowVP(t, this.sun.direction, SHADOW_CASCADES[i], this.shadowLightVPMatrix, i * 16)
|
|
13812
13926
|
this.device.queue.writeBuffer(this.shadowCascadeVPBuffers[i], 0, this.shadowLightVPMatrix, i * 16, 16)
|
|
13813
13927
|
}
|
|
13814
13928
|
this.device.queue.writeBuffer(this.shadowLightVPBuffer, 0, this.shadowLightVPMatrix)
|
package/src/graph/slots.ts
CHANGED
|
@@ -130,10 +130,10 @@ ${discard}
|
|
|
130
130
|
let v = normalize(camera.viewPos - input.worldPos);${flip}
|
|
131
131
|
${gate}
|
|
132
132
|
let l = -light.lights[0].direction.xyz;
|
|
133
|
-
let sun = light.lights[0].color.xyz * light.lights[0].color.w;
|
|
133
|
+
let sun = select(light.lights[0].color.xyz * light.lights[0].color.w, modelLight.sun.rgb, modelLight.sun.w > 0.5);
|
|
134
134
|
// The world: flat colour, or the HDRI's irradiance at this normal — which
|
|
135
135
|
// is what makes a loaded sky actually light her instead of only backing her.
|
|
136
|
-
let amb = rzWorldAmbient(n) +
|
|
136
|
+
let amb = rzWorldAmbient(n) + modelLight.fill.rgb;
|
|
137
137
|
let shadow = sampleShadow(input.worldPos, n);
|
|
138
138
|
let tex_color = tex_s.rgb;
|
|
139
139
|
|
package/src/shaders/lights.ts
CHANGED
|
@@ -253,6 +253,11 @@ export function lightsApi(group: number, binding: number): string {
|
|
|
253
253
|
@group(${group}) @binding(${binding}) var<storage, read> _rzLights: array<vec4u>;
|
|
254
254
|
|
|
255
255
|
const RZ_MAX_LIGHTS: u32 = ${MAX_LIGHTS}u;
|
|
256
|
+
// A lamp's bulb, in world units: the inverse square is held flat inside it,
|
|
257
|
+
// so the spike beside the lamp is finite. Aether Gazer's lamps are
|
|
258
|
+
// 0.1 m across (their shapeRadius, capping 1/d² at 1/0.1), which at MMD scale
|
|
259
|
+
// is 2.5 units.
|
|
260
|
+
const RZ_LAMP_NEAR: f32 = 2.5;
|
|
256
261
|
|
|
257
262
|
/** How many positional lights the scene has. Zero is the ordinary case. */
|
|
258
263
|
fn rzLightCount() -> u32 { return min(u32(bitcast<f32>(_rzLights[0].x)), RZ_MAX_LIGHTS); }
|
|
@@ -294,19 +299,16 @@ fn _rzLightCellMask(p: vec3f) -> vec4u {
|
|
|
294
299
|
/**
|
|
295
300
|
* One light's contribution at a surface point.
|
|
296
301
|
*
|
|
297
|
-
*
|
|
302
|
+
* A LIGHT FALLS OFF AS THE INVERSE SQUARE, the curve Unity, Unreal, Blender and
|
|
303
|
+
* glTF all light with: intensity / max(d², RZ_LAMP_NEAR²), so its intensity is
|
|
304
|
+
* the brightness one unit away, windowed by (1 − (d/R)⁴)² so it is exactly zero
|
|
305
|
+
* at its radius and the bound the grid is built from is real.
|
|
298
306
|
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* as
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* So: intensity is the brightness AT the light, radius is where it reaches
|
|
306
|
-
* zero, and the curve between them is the same shape whatever the scene's
|
|
307
|
-
* scale. Both dials now mean what they say, which for a composer beats being
|
|
308
|
-
* right about photons. (1 - t²)² — smooth at both ends, exactly 0 at the
|
|
309
|
-
* radius, so the bound the grid is built from is real.
|
|
307
|
+
* THE UNITS ARE BLENDER'S. Intensity is radiant intensity, a point light's
|
|
308
|
+
* power over 4π, and a Lambertian surface returns albedo × irradiance / π —
|
|
309
|
+
* the π the sun term already carries. So a stage exported from Blender lights
|
|
310
|
+
* here as it lit there, and a lamp's intensity is what Blender's exporter
|
|
311
|
+
* writes in candela over 683.
|
|
310
312
|
*/
|
|
311
313
|
fn _rzLightOne(i: u32, p: vec3f, n: vec3f) -> vec3f {
|
|
312
314
|
let pr = _rzLightVec(i, 0u);
|
|
@@ -322,14 +324,16 @@ fn _rzLightOne(i: u32, p: vec3f, n: vec3f) -> vec3f {
|
|
|
322
324
|
let ndl = max(dot(n, toLight), 0.0);
|
|
323
325
|
if (ndl <= 0.0) { return vec3f(0.0); }
|
|
324
326
|
let t = clamp(dist / max(pr.w, 1e-4), 0.0, 1.0);
|
|
325
|
-
let
|
|
327
|
+
let t2 = t * t;
|
|
328
|
+
let window = 1.0 - t2 * t2;
|
|
329
|
+
let falloff = window * window / max(dist * dist, RZ_LAMP_NEAR * RZ_LAMP_NEAR);
|
|
326
330
|
// How far inside the cone this point sits: 1 within the inner angle, 0 past
|
|
327
331
|
// the outer one, squared for the same soft edge the falloff has. A point
|
|
328
332
|
// light's (-1, -1) divides by the floor and clamps to 1, so it pays one
|
|
329
333
|
// dot product and no branch.
|
|
330
334
|
let cone = rzLightCone(i);
|
|
331
335
|
let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);
|
|
332
|
-
return rzLightColor(i) * (ndl * falloff *
|
|
336
|
+
return rzLightColor(i) * (ndl * falloff * aim * aim);
|
|
333
337
|
}
|
|
334
338
|
|
|
335
339
|
/** The lamps named by one word of a cell's bits, lowest first. */
|
|
@@ -355,6 +359,10 @@ fn _rzLightWord(bits0: u32, base: u32, p: vec3f, n: vec3f) -> vec3f {
|
|
|
355
359
|
* every material must cost nothing until someone asks for a light.
|
|
356
360
|
*/
|
|
357
361
|
fn rzLightsDiffuse(p: vec3f, n: vec3f) -> vec3f {
|
|
362
|
+
return _rzLightsIrradiance(p, n) * (1.0 / 3.141592653589793);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
fn _rzLightsIrradiance(p: vec3f, n: vec3f) -> vec3f {
|
|
358
366
|
var acc = vec3f(0.0);
|
|
359
367
|
let count = rzLightCount();
|
|
360
368
|
let docs = _rzLightDocCount();
|
|
@@ -146,9 +146,13 @@ struct LightVP { viewProj: array<mat4x4f, ${SHADOW_CASCADES.length}>, };
|
|
|
146
146
|
@group(0) @binding(8) var worldEnvTexture: texture_2d<f32>;
|
|
147
147
|
// binding(9) brdfLut is declared inside NODES_WGSL (nodes.ts).
|
|
148
148
|
@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
149
|
+
// The light this model takes apart from the scene's — see Engine.setModelFill
|
|
150
|
+
// and Engine.setModelSun. fill is added to its ambient, zero for a model
|
|
151
|
+
// nobody gave one; sun replaces the scene's sun colour while its w is set,
|
|
152
|
+
// which is how a stage keeps the daylight its game lit it by while the cast
|
|
153
|
+
// keeps the key the scene set for them.
|
|
154
|
+
struct ModelLight { fill: vec4f, sun: vec4f }
|
|
155
|
+
@group(1) @binding(1) var<uniform> modelLight: ModelLight;
|
|
152
156
|
@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
|
|
153
157
|
@group(2) @binding(1) var<uniform> material: MaterialUniforms;
|
|
154
158
|
// Reserved for future sphere/toon graph nodes; graphs that don't read them get the
|
package/src/shadow-cascades.ts
CHANGED
|
@@ -1,68 +1,139 @@
|
|
|
1
|
-
// The shadow volumes, as data — a list the engine iterates rather than one
|
|
2
|
-
// hardcoded box, so a second cascade is a list entry and not a rewrite.
|
|
3
|
-
//
|
|
4
|
-
// Pure math, its own module for the same reason param-track.ts is: the engine
|
|
5
|
-
// class needs a GPU to construct, and the one thing that ever goes WRONG with a
|
|
6
|
-
// shadow volume is arithmetic — a snap that stops snapping, an eye that lands
|
|
7
|
-
// inside the near plane. Headless tests can hold this half to golden values.
|
|
8
|
-
//
|
|
9
|
-
// The arithmetic is the shipped single-volume code, operation for operation:
|
|
10
|
-
// float addition is not associative, so "the same formula, reordered" is not
|
|
11
|
-
// the same matrix, and cascade 0 must be BIT-IDENTICAL to the volume every
|
|
12
|
-
// published scene was lit by.
|
|
13
|
-
|
|
14
1
|
import { Mat4, Vec3 } from "./math"
|
|
15
2
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
3
|
+
/**
|
|
4
|
+
* THE SUN'S SHADOW FOLLOWS THE CAMERA, the way Blender's does.
|
|
5
|
+
*
|
|
6
|
+
* Each cascade is fitted every frame to a slice of the view frustum: the near
|
|
7
|
+
* one to the stretch around what the camera is looking at, the far one to the
|
|
8
|
+
* whole of what it sees. Whatever is in view is in a cascade, and whatever
|
|
9
|
+
* could throw a shadow onto it is in the cascade's depth range, which is fitted
|
|
10
|
+
* to the scene's bounds along the light. A room forty metres across with its
|
|
11
|
+
* window frames behind the camera shadows its floor as the game does; a lone
|
|
12
|
+
* dancer on an empty floor keeps a crisp near map.
|
|
13
|
+
*
|
|
14
|
+
* The earlier shape was two fixed boxes around the camera target, 64 and 256
|
|
15
|
+
* units across, and a stage reached past them: the floor near the windows lay
|
|
16
|
+
* outside every box and was drawn lit, the shadows stopping at the box's edge
|
|
17
|
+
* in a straight line.
|
|
18
|
+
*
|
|
19
|
+
* INVARIANT the sampler and the cull both lean on: the outer cascade CONTAINS
|
|
20
|
+
* the inner one. The sampler falls from cascade 0 to 1 at the box edge, which
|
|
21
|
+
* is only seamless if 1 covers where 0 ends, and the cull tests the OUTERMOST
|
|
22
|
+
* frustum alone. Both hold because the outer slice is the whole frustum, of
|
|
23
|
+
* which the inner slice is a part, and both take the same depth range.
|
|
24
|
+
* tests/shadow-cascades.test.mjs pins it.
|
|
25
|
+
*/
|
|
26
|
+
export type ShadowCascade = {
|
|
24
27
|
/** Texels per side of this cascade's map — sets the snap quantum. */
|
|
25
28
|
mapSize: number
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
export const SHADOW_CASCADES: readonly ShadowCascade[] = [{ mapSize: 4096 }, { mapSize: 2048 }]
|
|
32
|
+
|
|
33
|
+
/** How far past the camera's point of interest the near cascade reaches, in
|
|
34
|
+
* world units: the dancer and the floor around her, at the near map's texel. */
|
|
35
|
+
export const NEAR_REACH = 40
|
|
36
|
+
|
|
37
|
+
/** The view the cascades are fitted to: the camera's eye and basis (world
|
|
38
|
+
* space, left-handed, +Z forward as the projection is), its vertical field of
|
|
39
|
+
* view, aspect and clip planes, and how far away the thing it looks at is. */
|
|
40
|
+
export type ShadowView = {
|
|
41
|
+
eye: { x: number; y: number; z: number }
|
|
42
|
+
right: { x: number; y: number; z: number }
|
|
43
|
+
up: { x: number; y: number; z: number }
|
|
44
|
+
forward: { x: number; y: number; z: number }
|
|
45
|
+
fov: number
|
|
46
|
+
aspect: number
|
|
47
|
+
near: number
|
|
48
|
+
far: number
|
|
49
|
+
/** Distance from the eye to the camera target, along the view. */
|
|
50
|
+
focus: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** World-space box around everything drawn, or null for an empty scene. */
|
|
54
|
+
export type ShadowBounds = { min: [number, number, number]; max: [number, number, number] } | null
|
|
55
|
+
|
|
56
|
+
type XYZ = { x: number; y: number; z: number }
|
|
57
|
+
|
|
28
58
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* - the sampler falls from cascade i to i+1 at the box edge, which is only
|
|
34
|
-
* seamless if i+1 covers where i ends, and
|
|
35
|
-
* - the cull tests ONE frustum — the outermost — and the rasterizer clips
|
|
36
|
-
* each cascade to its own box. That is the same argument that made
|
|
37
|
-
* single-volume shadow culling exact: anything rejected was contributing
|
|
38
|
-
* nothing anywhere. Concentric containment is what keeps it true for a
|
|
39
|
-
* LIST. tests/shadow-cascades.test.mjs pins it.
|
|
59
|
+
* Where each cascade's slice of the view starts and ends, as distances along
|
|
60
|
+
* the view: [near, split] and [near, farFit]. The far end is where the scene
|
|
61
|
+
* ends, so an empty floor with a dancer on it keeps a short, sharp frustum
|
|
62
|
+
* rather than the camera's far plane.
|
|
40
63
|
*/
|
|
41
|
-
export
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
export function cascadeSlices(view: ShadowView, bounds: ShadowBounds): [number, number][] {
|
|
65
|
+
let farFit = view.near + 200
|
|
66
|
+
if (bounds) {
|
|
67
|
+
let deepest = 0
|
|
68
|
+
for (let i = 0; i < 8; i++) {
|
|
69
|
+
const cx = (i & 1 ? bounds.max : bounds.min)[0] - view.eye.x
|
|
70
|
+
const cy = (i & 2 ? bounds.max : bounds.min)[1] - view.eye.y
|
|
71
|
+
const cz = (i & 4 ? bounds.max : bounds.min)[2] - view.eye.z
|
|
72
|
+
deepest = Math.max(deepest, cx * view.forward.x + cy * view.forward.y + cz * view.forward.z)
|
|
73
|
+
}
|
|
74
|
+
farFit = deepest + 1
|
|
75
|
+
}
|
|
76
|
+
farFit = Math.min(view.far, Math.max(view.near + 1, farFit))
|
|
77
|
+
const split = Math.min(farFit, Math.max(view.near + 8, view.focus + NEAR_REACH))
|
|
78
|
+
return [
|
|
79
|
+
[view.near, split],
|
|
80
|
+
[view.near, farFit],
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The bounding sphere of a frustum slice: on the view axis, at the depth that
|
|
85
|
+
* balances the near and far rectangles' corners. */
|
|
86
|
+
function sliceSphere(view: ShadowView, n: number, f: number): { center: Vec3; radius: number } {
|
|
87
|
+
const t = Math.tan(view.fov / 2)
|
|
88
|
+
const k2 = t * t * (1 + view.aspect * view.aspect)
|
|
89
|
+
let depth: number
|
|
90
|
+
let radius: number
|
|
91
|
+
if (k2 >= (f - n) / (f + n)) {
|
|
92
|
+
depth = f
|
|
93
|
+
radius = f * Math.sqrt(k2)
|
|
94
|
+
} else {
|
|
95
|
+
depth = 0.5 * (f + n) * (1 + k2)
|
|
96
|
+
radius = 0.5 * Math.sqrt((f - n) * (f - n) + 2 * (f * f + n * n) * k2 + (f + n) * (f + n) * k2 * k2)
|
|
97
|
+
}
|
|
98
|
+
const center = new Vec3(
|
|
99
|
+
view.eye.x + view.forward.x * depth,
|
|
100
|
+
view.eye.y + view.forward.y * depth,
|
|
101
|
+
view.eye.z + view.forward.z * depth,
|
|
102
|
+
)
|
|
103
|
+
return { center, radius }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The eight corners of a slice, for tests and for the fit's own checks. */
|
|
107
|
+
export function sliceCorners(view: ShadowView, n: number, f: number): XYZ[] {
|
|
108
|
+
const out: XYZ[] = []
|
|
109
|
+
for (const d of [n, f]) {
|
|
110
|
+
const h = d * Math.tan(view.fov / 2)
|
|
111
|
+
const w = h * view.aspect
|
|
112
|
+
for (const sy of [-1, 1])
|
|
113
|
+
for (const sx of [-1, 1])
|
|
114
|
+
out.push({
|
|
115
|
+
x: view.eye.x + view.forward.x * d + view.right.x * w * sx + view.up.x * h * sy,
|
|
116
|
+
y: view.eye.y + view.forward.y * d + view.right.y * w * sx + view.up.y * h * sy,
|
|
117
|
+
z: view.eye.z + view.forward.z * d + view.right.z * w * sx + view.up.z * h * sy,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
53
122
|
|
|
54
123
|
/**
|
|
55
|
-
* One cascade's view-projection
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
124
|
+
* One cascade's view-projection: an orthographic box around the slice's
|
|
125
|
+
* sphere, snapped to the map's texel grid in the light's right/up plane so a
|
|
126
|
+
* moving camera doesn't shimmer its shadow edges, and reaching along the light
|
|
127
|
+
* from the nearest thing in the scene to the farthest, so everything that
|
|
128
|
+
* could cast into the slice does.
|
|
60
129
|
*
|
|
61
130
|
* Writes the 16 floats into `out` at `offset` and returns `out`.
|
|
62
131
|
*/
|
|
63
|
-
export function
|
|
64
|
-
|
|
65
|
-
|
|
132
|
+
export function fitShadowVP(
|
|
133
|
+
view: ShadowView,
|
|
134
|
+
slice: [number, number],
|
|
135
|
+
sunDirection: XYZ,
|
|
136
|
+
bounds: ShadowBounds,
|
|
66
137
|
cascade: ShadowCascade,
|
|
67
138
|
out: Float32Array,
|
|
68
139
|
offset: number,
|
|
@@ -70,28 +141,49 @@ export function buildShadowVP(
|
|
|
70
141
|
const dir = new Vec3(sunDirection.x, sunDirection.y, sunDirection.z)
|
|
71
142
|
dir.normalize()
|
|
72
143
|
const up = Math.abs(dir.y) > 0.99 ? new Vec3(0, 0, -1) : new Vec3(0, 1, 0)
|
|
73
|
-
|
|
74
|
-
const t = new Vec3(target.x, target.y, target.z)
|
|
75
144
|
const right = Vec3.crossInto(up, dir, new Vec3(0, 0, 0)).normalize()
|
|
76
145
|
const upv = Vec3.crossInto(dir, right, new Vec3(0, 0, 0))
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
146
|
+
|
|
147
|
+
const { center, radius } = sliceSphere(view, slice[0], slice[1])
|
|
148
|
+
// A texel of the map, in world units; the radius is rounded up onto the
|
|
149
|
+
// grid too, so the box's size does not drift with the fov by fractions.
|
|
150
|
+
const texel = Math.max((2 * radius) / cascade.mapSize, 1e-4)
|
|
151
|
+
const half = Math.ceil(radius / texel) * texel
|
|
152
|
+
const tr = Math.round(center.dot(right) / texel) * texel
|
|
153
|
+
const tu = Math.round(center.dot(upv) / texel) * texel
|
|
154
|
+
const td = center.dot(dir)
|
|
81
155
|
const snapped = new Vec3(
|
|
82
156
|
right.x * tr + upv.x * tu + dir.x * td,
|
|
83
157
|
right.y * tr + upv.y * tu + dir.y * td,
|
|
84
158
|
right.z * tr + upv.z * tu + dir.z * td,
|
|
85
159
|
)
|
|
86
160
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
161
|
+
// Along the light: from the scene's nearest point to its farthest, with a
|
|
162
|
+
// margin so a caster on the box's own face is not clipped. With nothing to
|
|
163
|
+
// fit, the sphere itself.
|
|
164
|
+
let zmin = td - half
|
|
165
|
+
let zmax = td + half
|
|
166
|
+
if (bounds) {
|
|
167
|
+
for (let i = 0; i < 8; i++) {
|
|
168
|
+
const z =
|
|
169
|
+
(i & 1 ? bounds.max : bounds.min)[0] * dir.x + (i & 2 ? bounds.max : bounds.min)[1] * dir.y + (i & 4 ? bounds.max : bounds.min)[2] * dir.z
|
|
170
|
+
zmin = Math.min(zmin, z)
|
|
171
|
+
zmax = Math.max(zmax, z)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const margin = 2 + 0.02 * (zmax - zmin)
|
|
175
|
+
const back = td - zmin + margin
|
|
176
|
+
const far = back + (zmax - td) + margin
|
|
177
|
+
const eye = new Vec3(snapped.x - dir.x * back, snapped.y - dir.y * back, snapped.z - dir.z * back)
|
|
178
|
+
const viewM = Mat4.lookAt(eye, snapped, up)
|
|
179
|
+
const proj = Mat4.orthographicLh(-half, half, -half, half, 1, far + 1)
|
|
180
|
+
out.set(proj.multiply(viewM).values, offset)
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Every cascade's view-projection in a row, inner to outer. */
|
|
185
|
+
export function buildShadowCascades(view: ShadowView, sunDirection: XYZ, bounds: ShadowBounds, out: Float32Array): Float32Array {
|
|
186
|
+
const slices = cascadeSlices(view, bounds)
|
|
187
|
+
for (let i = 0; i < SHADOW_CASCADES.length; i++) fitShadowVP(view, slices[i], sunDirection, bounds, SHADOW_CASCADES[i], out, i * 16)
|
|
96
188
|
return out
|
|
97
189
|
}
|