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.
@@ -1 +1 @@
1
- {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AA4BA,eAAO,MAAM,oBAAoB,QAsJhC,CAAC;AA8HF,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAGD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,aAAa,orMAgHzB,CAAA;AAQD,eAAO,MAAM,4BAA4B,QAC0D,CAAA"}
1
+ {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AA4BA,eAAO,MAAM,oBAAoB,QA0JhC,CAAC;AA8HF,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAGD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,aAAa,orMAgHzB,CAAA;AAQD,eAAO,MAAM,4BAA4B,QAC0D,CAAA"}
@@ -143,9 +143,13 @@ struct LightVP { viewProj: array<mat4x4f, ${SHADOW_CASCADES.length}>, };
143
143
  @group(0) @binding(8) var worldEnvTexture: texture_2d<f32>;
144
144
  // binding(9) brdfLut is declared inside NODES_WGSL (nodes.ts).
145
145
  @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
146
- // Light this model receives beyond the world's — see Engine.setModelFill. Zero
147
- // for a model nobody gave one, which leaves its ambient exactly the world's.
148
- @group(1) @binding(1) var<uniform> modelFill: vec4f;
146
+ // The light this model takes apart from the scene's — see Engine.setModelFill
147
+ // and Engine.setModelSun. fill is added to its ambient, zero for a model
148
+ // nobody gave one; sun replaces the scene's sun colour while its w is set,
149
+ // which is how a stage keeps the daylight its game lit it by while the cast
150
+ // keeps the key the scene set for them.
151
+ struct ModelLight { fill: vec4f, sun: vec4f }
152
+ @group(1) @binding(1) var<uniform> modelLight: ModelLight;
149
153
  @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
150
154
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
151
155
  // Reserved for future sphere/toon graph nodes; graphs that don't read them get the
@@ -1,45 +1,95 @@
1
- type ShadowCascade = {
2
- /** World units across the ortho box, both axes. */
3
- span: number;
4
- /** How far behind the target the light's eye sits, along -sunDir. */
5
- back: number;
6
- /** Ortho near/far, in world units from that eye. */
7
- near: number;
8
- far: number;
1
+ /**
2
+ * THE SUN'S SHADOW FOLLOWS THE CAMERA, the way Blender's does.
3
+ *
4
+ * Each cascade is fitted every frame to a slice of the view frustum: the near
5
+ * one to the stretch around what the camera is looking at, the far one to the
6
+ * whole of what it sees. Whatever is in view is in a cascade, and whatever
7
+ * could throw a shadow onto it is in the cascade's depth range, which is fitted
8
+ * to the scene's bounds along the light. A room forty metres across with its
9
+ * window frames behind the camera shadows its floor as the game does; a lone
10
+ * dancer on an empty floor keeps a crisp near map.
11
+ *
12
+ * The earlier shape was two fixed boxes around the camera target, 64 and 256
13
+ * units across, and a stage reached past them: the floor near the windows lay
14
+ * outside every box and was drawn lit, the shadows stopping at the box's edge
15
+ * in a straight line.
16
+ *
17
+ * INVARIANT the sampler and the cull both lean on: the outer cascade CONTAINS
18
+ * the inner one. The sampler falls from cascade 0 to 1 at the box edge, which
19
+ * is only seamless if 1 covers where 0 ends, and the cull tests the OUTERMOST
20
+ * frustum alone. Both hold because the outer slice is the whole frustum, of
21
+ * which the inner slice is a part, and both take the same depth range.
22
+ * tests/shadow-cascades.test.mjs pins it.
23
+ */
24
+ export type ShadowCascade = {
9
25
  /** Texels per side of this cascade's map — sets the snap quantum. */
10
26
  mapSize: number;
11
27
  };
28
+ export declare const SHADOW_CASCADES: readonly ShadowCascade[];
29
+ /** How far past the camera's point of interest the near cascade reaches, in
30
+ * world units: the dancer and the floor around her, at the near map's texel. */
31
+ export declare const NEAR_REACH = 40;
32
+ /** The view the cascades are fitted to: the camera's eye and basis (world
33
+ * space, left-handed, +Z forward as the projection is), its vertical field of
34
+ * view, aspect and clip planes, and how far away the thing it looks at is. */
35
+ export type ShadowView = {
36
+ eye: {
37
+ x: number;
38
+ y: number;
39
+ z: number;
40
+ };
41
+ right: {
42
+ x: number;
43
+ y: number;
44
+ z: number;
45
+ };
46
+ up: {
47
+ x: number;
48
+ y: number;
49
+ z: number;
50
+ };
51
+ forward: {
52
+ x: number;
53
+ y: number;
54
+ z: number;
55
+ };
56
+ fov: number;
57
+ aspect: number;
58
+ near: number;
59
+ far: number;
60
+ /** Distance from the eye to the camera target, along the view. */
61
+ focus: number;
62
+ };
63
+ /** World-space box around everything drawn, or null for an empty scene. */
64
+ export type ShadowBounds = {
65
+ min: [number, number, number];
66
+ max: [number, number, number];
67
+ } | null;
68
+ type XYZ = {
69
+ x: number;
70
+ y: number;
71
+ z: number;
72
+ };
12
73
  /**
13
- * The list, inner to outer. INVARIANT the sampler and the cull both lean on:
14
- * each cascade's box must CONTAIN the previous one (same snapped target, wider
15
- * span, deeper reach), because
16
- *
17
- * - the sampler falls from cascade i to i+1 at the box edge, which is only
18
- * seamless if i+1 covers where i ends, and
19
- * - the cull tests ONE frustum — the outermost — and the rasterizer clips
20
- * each cascade to its own box. That is the same argument that made
21
- * single-volume shadow culling exact: anything rejected was contributing
22
- * nothing anywhere. Concentric containment is what keeps it true for a
23
- * LIST. tests/shadow-cascades.test.mjs pins it.
74
+ * Where each cascade's slice of the view starts and ends, as distances along
75
+ * the view: [near, split] and [near, farFit]. The far end is where the scene
76
+ * ends, so an empty floor with a dancer on it keeps a short, sharp frustum
77
+ * rather than the camera's far plane.
24
78
  */
25
- export declare const SHADOW_CASCADES: readonly ShadowCascade[];
79
+ export declare function cascadeSlices(view: ShadowView, bounds: ShadowBounds): [number, number][];
80
+ /** The eight corners of a slice, for tests and for the fit's own checks. */
81
+ export declare function sliceCorners(view: ShadowView, n: number, f: number): XYZ[];
26
82
  /**
27
- * One cascade's view-projection, following the camera target.
28
- *
29
- * The target is snapped to this cascade's OWN texel quantum in the light's
30
- * right/up plane, so a moving volume doesn't shimmer its shadow edges while
31
- * running — each cascade snaps to its own grid, coarser maps snapping coarser.
83
+ * One cascade's view-projection: an orthographic box around the slice's
84
+ * sphere, snapped to the map's texel grid in the light's right/up plane so a
85
+ * moving camera doesn't shimmer its shadow edges, and reaching along the light
86
+ * from the nearest thing in the scene to the farthest, so everything that
87
+ * could cast into the slice does.
32
88
  *
33
89
  * Writes the 16 floats into `out` at `offset` and returns `out`.
34
90
  */
35
- export declare function buildShadowVP(target: {
36
- x: number;
37
- y: number;
38
- z: number;
39
- }, sunDirection: {
40
- x: number;
41
- y: number;
42
- z: number;
43
- }, cascade: ShadowCascade, out: Float32Array, offset: number): Float32Array;
91
+ export declare function fitShadowVP(view: ShadowView, slice: [number, number], sunDirection: XYZ, bounds: ShadowBounds, cascade: ShadowCascade, out: Float32Array, offset: number): Float32Array;
92
+ /** Every cascade's view-projection in a row, inner to outer. */
93
+ export declare function buildShadowCascades(view: ShadowView, sunDirection: XYZ, bounds: ShadowBounds, out: Float32Array): Float32Array;
44
94
  export {};
45
95
  //# sourceMappingURL=shadow-cascades.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"shadow-cascades.d.ts","sourceRoot":"","sources":["../src/shadow-cascades.ts"],"names":[],"mappings":"AAeA,KAAK,aAAa,GAAG;IACnB,mDAAmD;IACnD,IAAI,EAAE,MAAM,CAAA;IACZ,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAA;IACZ,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe,EAAE,SAAS,aAAa,EAWnD,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,EAC3C,YAAY,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,EACjD,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,YAAY,EACjB,MAAM,EAAE,MAAM,GACb,YAAY,CA4Bd"}
1
+ {"version":3,"file":"shadow-cascades.d.ts","sourceRoot":"","sources":["../src/shadow-cascades.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,eAAO,MAAM,eAAe,EAAE,SAAS,aAAa,EAA2C,CAAA;AAE/F;iFACiF;AACjF,eAAO,MAAM,UAAU,KAAK,CAAA;AAE5B;;+EAE+E;AAC/E,MAAM,MAAM,UAAU,GAAG;IACvB,GAAG,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACxC,KAAK,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC1C,EAAE,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACvC,OAAO,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC5C,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,2EAA2E;AAC3E,MAAM,MAAM,YAAY,GAAG;IAAE,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG,IAAI,CAAA;AAElG,KAAK,GAAG,GAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAE9C;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,YAAY,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAkBxF;AAwBD,4EAA4E;AAC5E,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,GAAG,EAAE,CAc1E;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,UAAU,EAChB,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EACvB,YAAY,EAAE,GAAG,EACjB,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,YAAY,EACjB,MAAM,EAAE,MAAM,GACb,YAAY,CA0Cd;AAED,gEAAgE;AAChE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,UAAU,EAAE,YAAY,EAAE,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,GAAG,EAAE,YAAY,GAAG,YAAY,CAI9H"}
@@ -1,70 +1,116 @@
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
1
  import { Mat4, Vec3 } from "./math";
2
+ export const SHADOW_CASCADES = [{ mapSize: 4096 }, { mapSize: 2048 }];
3
+ /** How far past the camera's point of interest the near cascade reaches, in
4
+ * world units: the dancer and the floor around her, at the near map's texel. */
5
+ export const NEAR_REACH = 40;
14
6
  /**
15
- * The list, inner to outer. INVARIANT the sampler and the cull both lean on:
16
- * each cascade's box must CONTAIN the previous one (same snapped target, wider
17
- * span, deeper reach), because
18
- *
19
- * - the sampler falls from cascade i to i+1 at the box edge, which is only
20
- * seamless if i+1 covers where i ends, and
21
- * - the cull tests ONE frustum — the outermost — and the rasterizer clips
22
- * each cascade to its own box. That is the same argument that made
23
- * single-volume shadow culling exact: anything rejected was contributing
24
- * nothing anywhere. Concentric containment is what keeps it true for a
25
- * LIST. tests/shadow-cascades.test.mjs pins it.
7
+ * Where each cascade's slice of the view starts and ends, as distances along
8
+ * the view: [near, split] and [near, farFit]. The far end is where the scene
9
+ * ends, so an empty floor with a dancer on it keeps a short, sharp frustum
10
+ * rather than the camera's far plane.
26
11
  */
27
- export const SHADOW_CASCADES = [
28
- // The shipped volume: 64 units at 4096² ≈ 64 texels/unit — crisp contact
29
- // shadows on the ground catcher (2048 read visibly blurry).
30
- { span: 64, back: 72, near: 1, far: 140, mapSize: 4096 },
31
- // The stage volume: 4× the span on each side, so a set piece 100 units out
32
- // still throws and receives shade instead of popping lit at the near box's
33
- // edge. 2048² over 256 units ≈ 8 texels/unit — soft, and read at distances
34
- // where soft is what a shadow looks like anyway. 16 MB where the near map
35
- // is 64. Depth reach scales with the span (same eye direction, deeper box),
36
- // keeping the containment invariant checkable from the specs alone.
37
- { span: 256, back: 288, near: 1, far: 560, mapSize: 2048 },
38
- ];
12
+ export function cascadeSlices(view, bounds) {
13
+ let farFit = view.near + 200;
14
+ if (bounds) {
15
+ let deepest = 0;
16
+ for (let i = 0; i < 8; i++) {
17
+ const cx = (i & 1 ? bounds.max : bounds.min)[0] - view.eye.x;
18
+ const cy = (i & 2 ? bounds.max : bounds.min)[1] - view.eye.y;
19
+ const cz = (i & 4 ? bounds.max : bounds.min)[2] - view.eye.z;
20
+ deepest = Math.max(deepest, cx * view.forward.x + cy * view.forward.y + cz * view.forward.z);
21
+ }
22
+ farFit = deepest + 1;
23
+ }
24
+ farFit = Math.min(view.far, Math.max(view.near + 1, farFit));
25
+ const split = Math.min(farFit, Math.max(view.near + 8, view.focus + NEAR_REACH));
26
+ return [
27
+ [view.near, split],
28
+ [view.near, farFit],
29
+ ];
30
+ }
31
+ /** The bounding sphere of a frustum slice: on the view axis, at the depth that
32
+ * balances the near and far rectangles' corners. */
33
+ function sliceSphere(view, n, f) {
34
+ const t = Math.tan(view.fov / 2);
35
+ const k2 = t * t * (1 + view.aspect * view.aspect);
36
+ let depth;
37
+ let radius;
38
+ if (k2 >= (f - n) / (f + n)) {
39
+ depth = f;
40
+ radius = f * Math.sqrt(k2);
41
+ }
42
+ else {
43
+ depth = 0.5 * (f + n) * (1 + k2);
44
+ radius = 0.5 * Math.sqrt((f - n) * (f - n) + 2 * (f * f + n * n) * k2 + (f + n) * (f + n) * k2 * k2);
45
+ }
46
+ const center = new Vec3(view.eye.x + view.forward.x * depth, view.eye.y + view.forward.y * depth, view.eye.z + view.forward.z * depth);
47
+ return { center, radius };
48
+ }
49
+ /** The eight corners of a slice, for tests and for the fit's own checks. */
50
+ export function sliceCorners(view, n, f) {
51
+ const out = [];
52
+ for (const d of [n, f]) {
53
+ const h = d * Math.tan(view.fov / 2);
54
+ const w = h * view.aspect;
55
+ for (const sy of [-1, 1])
56
+ for (const sx of [-1, 1])
57
+ out.push({
58
+ x: view.eye.x + view.forward.x * d + view.right.x * w * sx + view.up.x * h * sy,
59
+ y: view.eye.y + view.forward.y * d + view.right.y * w * sx + view.up.y * h * sy,
60
+ z: view.eye.z + view.forward.z * d + view.right.z * w * sx + view.up.z * h * sy,
61
+ });
62
+ }
63
+ return out;
64
+ }
39
65
  /**
40
- * One cascade's view-projection, following the camera target.
41
- *
42
- * The target is snapped to this cascade's OWN texel quantum in the light's
43
- * right/up plane, so a moving volume doesn't shimmer its shadow edges while
44
- * running — each cascade snaps to its own grid, coarser maps snapping coarser.
66
+ * One cascade's view-projection: an orthographic box around the slice's
67
+ * sphere, snapped to the map's texel grid in the light's right/up plane so a
68
+ * moving camera doesn't shimmer its shadow edges, and reaching along the light
69
+ * from the nearest thing in the scene to the farthest, so everything that
70
+ * could cast into the slice does.
45
71
  *
46
72
  * Writes the 16 floats into `out` at `offset` and returns `out`.
47
73
  */
48
- export function buildShadowVP(target, sunDirection, cascade, out, offset) {
74
+ export function fitShadowVP(view, slice, sunDirection, bounds, cascade, out, offset) {
49
75
  const dir = new Vec3(sunDirection.x, sunDirection.y, sunDirection.z);
50
76
  dir.normalize();
51
77
  const up = Math.abs(dir.y) > 0.99 ? new Vec3(0, 0, -1) : new Vec3(0, 1, 0);
52
- const t = new Vec3(target.x, target.y, target.z);
53
78
  const right = Vec3.crossInto(up, dir, new Vec3(0, 0, 0)).normalize();
54
79
  const upv = Vec3.crossInto(dir, right, new Vec3(0, 0, 0));
55
- const texel = cascade.span / cascade.mapSize;
56
- const tr = Math.round(t.dot(right) / texel) * texel;
57
- const tu = Math.round(t.dot(upv) / texel) * texel;
58
- const td = t.dot(dir);
80
+ const { center, radius } = sliceSphere(view, slice[0], slice[1]);
81
+ // A texel of the map, in world units; the radius is rounded up onto the
82
+ // grid too, so the box's size does not drift with the fov by fractions.
83
+ const texel = Math.max((2 * radius) / cascade.mapSize, 1e-4);
84
+ const half = Math.ceil(radius / texel) * texel;
85
+ const tr = Math.round(center.dot(right) / texel) * texel;
86
+ const tu = Math.round(center.dot(upv) / texel) * texel;
87
+ const td = center.dot(dir);
59
88
  const snapped = new Vec3(right.x * tr + upv.x * tu + dir.x * td, right.y * tr + upv.y * tu + dir.y * td, right.z * tr + upv.z * tu + dir.z * td);
60
- const eye = new Vec3(snapped.x - dir.x * cascade.back, snapped.y - dir.y * cascade.back, snapped.z - dir.z * cascade.back);
61
- const view = Mat4.lookAt(eye, snapped, up);
62
- const half = cascade.span / 2;
63
- // The shadow map keeps the NON-reversed convention (orthographicLh maps z to
64
- // [0,1] front-to-back) — reversing it buys nothing for an ortho box and the
65
- // +2 pipeline depth bias is signed against this direction.
66
- const proj = Mat4.orthographicLh(-half, half, -half, half, cascade.near, cascade.far);
67
- const vp = proj.multiply(view);
68
- out.set(vp.values, offset);
89
+ // Along the light: from the scene's nearest point to its farthest, with a
90
+ // margin so a caster on the box's own face is not clipped. With nothing to
91
+ // fit, the sphere itself.
92
+ let zmin = td - half;
93
+ let zmax = td + half;
94
+ if (bounds) {
95
+ for (let i = 0; i < 8; i++) {
96
+ const z = (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;
97
+ zmin = Math.min(zmin, z);
98
+ zmax = Math.max(zmax, z);
99
+ }
100
+ }
101
+ const margin = 2 + 0.02 * (zmax - zmin);
102
+ const back = td - zmin + margin;
103
+ const far = back + (zmax - td) + margin;
104
+ const eye = new Vec3(snapped.x - dir.x * back, snapped.y - dir.y * back, snapped.z - dir.z * back);
105
+ const viewM = Mat4.lookAt(eye, snapped, up);
106
+ const proj = Mat4.orthographicLh(-half, half, -half, half, 1, far + 1);
107
+ out.set(proj.multiply(viewM).values, offset);
108
+ return out;
109
+ }
110
+ /** Every cascade's view-projection in a row, inner to outer. */
111
+ export function buildShadowCascades(view, sunDirection, bounds, out) {
112
+ const slices = cascadeSlices(view, bounds);
113
+ for (let i = 0; i < SHADOW_CASCADES.length; i++)
114
+ fitShadowVP(view, slices[i], sunDirection, bounds, SHADOW_CASCADES[i], out, i * 16);
69
115
  return out;
70
116
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
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, buildShadowVP } from "./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, when setModelFill gave it one. */
870
- fillBuffer: GPUBuffer | null
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
- // 11 × vec4f — see the viewU comment in composite.ts. The last one is the
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 fill (none)",
7311
- size: 16,
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 fill — see setModelFill.
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
- // 11 × vec4f: (exposure, invGamma, _, _) · (bloom tint, intensity) ·
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
- if (!fill) {
10759
- if (!inst.fillBuffer) return true
10760
- const retired = inst.fillBuffer
10761
- inst.fillBuffer = null
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
- if (!inst.fillBuffer) {
10769
- inst.fillBuffer = this.device.createBuffer({
10770
- label: `${name}: fill`,
10771
- size: 16,
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.fillBuffer)
10803
+ inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, inst.lightBuffer)
10775
10804
  this.bundlesDirty = true
10776
10805
  }
10777
- this.device.queue.writeBuffer(inst.fillBuffer, 0, new Float32Array([fill.x, fill.y, fill.z, 0]))
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 fill, or the zero stand-in. */
10782
- private perInstanceBindGroup(name: string, skinMatrixBuffer: GPUBuffer, fill: GPUBuffer | null): GPUBindGroup {
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: fill ?? this.noFillBuffer } },
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.fillBuffer?.destroy()
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
- fillBuffer: null,
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
- // Last shadow-volume center, to skip recomputes while nothing moves.
13794
- private readonly shadowCenter = new Vec3(0, 11, 0)
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
- // The volumes follow the camera target so a character carried far from the
13798
- // origin by code-driven root motion stays inside the lit frustum. The
13799
- // volume MATH lives in shadow-cascades.ts, where it is testable without a
13800
- // GPU; this method owns only the dirty-tracking and the upload.
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
- const moved =
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)