reze-engine 0.59.0 → 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.
@@ -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) + modelFill.rgb;
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
 
@@ -302,8 +302,13 @@ fn _rzLightCellMask(p: vec3f) -> vec4u {
302
302
  * A LIGHT FALLS OFF AS THE INVERSE SQUARE, the curve Unity, Unreal, Blender and
303
303
  * glTF all light with: intensity / max(d², RZ_LAMP_NEAR²), so its intensity is
304
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. A lamp imported
306
- * from a game carries the game's own numbers.
305
+ * at its radius and the bound the grid is built from is real.
306
+ *
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.
307
312
  */
308
313
  fn _rzLightOne(i: u32, p: vec3f, n: vec3f) -> vec3f {
309
314
  let pr = _rzLightVec(i, 0u);
@@ -354,6 +359,10 @@ fn _rzLightWord(bits0: u32, base: u32, p: vec3f, n: vec3f) -> vec3f {
354
359
  * every material must cost nothing until someone asks for a light.
355
360
  */
356
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 {
357
366
  var acc = vec3f(0.0);
358
367
  let count = rzLightCount();
359
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
- // Light this model receives beyond the world's — see Engine.setModelFill. Zero
150
- // for a model nobody gave one, which leaves its ambient exactly the world's.
151
- @group(1) @binding(1) var<uniform> modelFill: vec4f;
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
@@ -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
- type ShadowCascade = {
17
- /** World units across the ortho box, both axes. */
18
- span: number
19
- /** How far behind the target the light's eye sits, along -sunDir. */
20
- back: number
21
- /** Ortho near/far, in world units from that eye. */
22
- near: number
23
- far: number
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
- * The list, inner to outer. INVARIANT the sampler and the cull both lean on:
30
- * each cascade's box must CONTAIN the previous one (same snapped target, wider
31
- * span, deeper reach), because
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 const SHADOW_CASCADES: readonly ShadowCascade[] = [
42
- // The shipped volume: 64 units at 4096² ≈ 64 texels/unit — crisp contact
43
- // shadows on the ground catcher (2048 read visibly blurry).
44
- { span: 64, back: 72, near: 1, far: 140, mapSize: 4096 },
45
- // The stage volume: 4× the span on each side, so a set piece 100 units out
46
- // still throws and receives shade instead of popping lit at the near box's
47
- // edge. 2048² over 256 units ≈ 8 texels/unit — soft, and read at distances
48
- // where soft is what a shadow looks like anyway. 16 MB where the near map
49
- // is 64. Depth reach scales with the span (same eye direction, deeper box),
50
- // keeping the containment invariant checkable from the specs alone.
51
- { span: 256, back: 288, near: 1, far: 560, mapSize: 2048 },
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, following the camera target.
56
- *
57
- * The target is snapped to this cascade's OWN texel quantum in the light's
58
- * right/up plane, so a moving volume doesn't shimmer its shadow edges while
59
- * running — each cascade snaps to its own grid, coarser maps snapping coarser.
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 buildShadowVP(
64
- target: { x: number; y: number; z: number },
65
- sunDirection: { x: number; y: number; z: number },
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
- const texel = cascade.span / cascade.mapSize
78
- const tr = Math.round(t.dot(right) / texel) * texel
79
- const tu = Math.round(t.dot(upv) / texel) * texel
80
- const td = t.dot(dir)
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
- const eye = new Vec3(snapped.x - dir.x * cascade.back, snapped.y - dir.y * cascade.back, snapped.z - dir.z * cascade.back)
88
- const view = Mat4.lookAt(eye, snapped, up)
89
- const half = cascade.span / 2
90
- // The shadow map keeps the NON-reversed convention (orthographicLh maps z to
91
- // [0,1] front-to-back) — reversing it buys nothing for an ortho box and the
92
- // +2 pipeline depth bias is signed against this direction.
93
- const proj = Mat4.orthographicLh(-half, half, -half, half, cascade.near, cascade.far)
94
- const vp = proj.multiply(view)
95
- out.set(vp.values, offset)
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
  }