reze-engine 0.58.0 → 0.58.1

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.
@@ -10,6 +10,7 @@ import { EFFECT_SUBJECT_VEC4S } from "../cast-layout";
10
10
  import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api";
11
11
  import { sceneIdFieldWgsl, sceneIdPadWgsl } from "./scene-contract";
12
12
  import { idApi } from "../id-api";
13
+ import { sceneLightApi } from "../scene-light-api";
13
14
  /**
14
15
  * Sub-segments drawn between each pair of recorded samples.
15
16
  *
@@ -247,7 +248,7 @@ fn rzTurnRadius(a: vec3f, b: vec3f, c: vec3f) -> f32 {
247
248
  gridReadApi(0, 8, 9, src.gridSize) +
248
249
  // Stubbed: this module cannot read an attachment the scene pass writes — see
249
250
  // id-api.ts. The author's whole file compiles here, so the names must exist.
250
- idApi(false, 0, 0) + castDistanceStub() +
251
+ idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) +
251
252
  "\n// ── user effect ──\n" +
252
253
  src.wgsl +
253
254
  /* wgsl */ `
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The world's light at a surface facing n — the flat colour, or the installed
3
+ * HDRI's irradiance (sh[0].w = 1), evaluated from folded SH coefficients (see
4
+ * ibl.ts for the folding; the shader is a plain polynomial in the normal).
5
+ *
6
+ * Written against whichever name the module gives the light uniform, so the
7
+ * materials, the ground and an effect all evaluate one polynomial.
8
+ */
9
+ export declare const worldAmbientWgsl: (u: string) => string;
10
+ /**
11
+ * 3×3 PCF over one cascade's map, for a point already in that cascade's NDC.
12
+ *
13
+ * Shared with the materials, so an effect's shadow and her own shadow soften
14
+ * alike. The sampler is linear-filtered, which makes each tap a 2×2 bilinear
15
+ * compare. Unrolled — Safari's Metal backend doesn't unroll nested shadow
16
+ * loops reliably. The body expects `ndc` in scope and returns.
17
+ */
18
+ export declare const pcf9: (map: string, ts: string, sampler?: string) => string;
19
+ /** Bindings the real accessors take, from `binding` upward. */
20
+ export declare const SCENE_LIGHT_API_BINDINGS = 5;
21
+ /**
22
+ * `rzShadow(p)`: how much of the sun reaches world point `p` — 1 lit, 0 in a
23
+ * caster's shadow, soft in between.
24
+ *
25
+ * `rzWorldAmbient(n)`: the world's light arriving at a surface facing `n` —
26
+ * the materials' own ambient, so an effect's shade can match hers.
27
+ *
28
+ * With `on`, declares five bindings from `binding`:
29
+ * +0 the light uniform (ambient, the sun — direction, and in .w the shadow
30
+ * switch — and the HDRI's irradiance)
31
+ * +1 the cascade view-projections, then the caster sphere
32
+ * +2 the near cascade's map +3 the far cascade's map
33
+ * +4 the comparison sampler
34
+ *
35
+ * It takes a POINT and no normal. The materials' sampleShadow also rejects a
36
+ * surface facing away from the sun, which is right for skin and wrong for a
37
+ * blade of grass or a petal: those are thin, lit through, and shaded by their
38
+ * own N·L. The point is nudged toward the sun instead, which lifts a blade's
39
+ * root off a stage floor that casts into the same map.
40
+ */
41
+ export declare function sceneLightApi(on: boolean, group: number, binding: number): string;
42
+ //# sourceMappingURL=scene-light-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scene-light-api.d.ts","sourceRoot":"","sources":["../../src/shaders/scene-light-api.ts"],"names":[],"mappings":"AAkBA;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,GAAI,GAAG,MAAM,WAazC,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,IAAI,GAAI,KAAK,MAAM,EAAE,IAAI,MAAM,EAAE,gBAAyB,WActE,CAAA;AAED,+DAA+D;AAC/D,eAAO,MAAM,wBAAwB,IAAI,CAAA;AAEzC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CA2DjF"}
@@ -0,0 +1,144 @@
1
+ import { SHADOW_CASCADES } from "../shadow-cascades";
2
+ // The scene's own light, as an effect author sees it: the sun's shadow and the
3
+ // world's ambient.
4
+ //
5
+ // A lawn standing in a character's shadow has to darken where the floor under
6
+ // it does, or the figure floats: the ground catcher draws her shadow, and the
7
+ // blades drawn over it hide it. And the shade has to be the SCENE'S shade — her
8
+ // shaded side takes the world's colour, and a lawn shaded by its own guess
9
+ // reads as a patch of different flowers beside her. Both are already computed
10
+ // for the materials every frame; this hands the same lookups to effects.
11
+ //
12
+ // Bound for real in the particle SHADING stage only. That is where geometry an
13
+ // effect draws inside the scene pass is shaded, and the shadow pass has already
14
+ // run by then. Every other module that compiles the author's file gets stubs:
15
+ // rzShadow answers 1, lit, which is what a point with no occlusion information
16
+ // is — the same answer sampleShadow gives outside every cascade — and
17
+ // rzWorldAmbient answers black, no light.
18
+ /**
19
+ * The world's light at a surface facing n — the flat colour, or the installed
20
+ * HDRI's irradiance (sh[0].w = 1), evaluated from folded SH coefficients (see
21
+ * ibl.ts for the folding; the shader is a plain polynomial in the normal).
22
+ *
23
+ * Written against whichever name the module gives the light uniform, so the
24
+ * materials, the ground and an effect all evaluate one polynomial.
25
+ */
26
+ export const worldAmbientWgsl = (u) => /* wgsl */ `
27
+ fn rzWorldAmbient(n: vec3f) -> vec3f {
28
+ if (${u}.sh[0].w < 0.5) { return ${u}.ambientColor.xyz; }
29
+ let x = n.x;
30
+ let y = n.y;
31
+ let z = n.z;
32
+ let c = ${u}.sh[0].xyz
33
+ + ${u}.sh[1].xyz * y + ${u}.sh[2].xyz * z + ${u}.sh[3].xyz * x
34
+ + ${u}.sh[4].xyz * (x * y) + ${u}.sh[5].xyz * (y * z)
35
+ + ${u}.sh[6].xyz * (3.0 * z * z - 1.0) + ${u}.sh[7].xyz * (x * z)
36
+ + ${u}.sh[8].xyz * (x * x - y * y);
37
+ return max(c, vec3f(0.0));
38
+ }
39
+ `;
40
+ /**
41
+ * 3×3 PCF over one cascade's map, for a point already in that cascade's NDC.
42
+ *
43
+ * Shared with the materials, so an effect's shadow and her own shadow soften
44
+ * alike. The sampler is linear-filtered, which makes each tap a 2×2 bilinear
45
+ * compare. Unrolled — Safari's Metal backend doesn't unroll nested shadow
46
+ * loops reliably. The body expects `ndc` in scope and returns.
47
+ */
48
+ export const pcf9 = (map, ts, sampler = "shadowSampler") => /* wgsl */ `
49
+ let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
50
+ let cmpZ = ndc.z - 0.001;
51
+ let ts = ${ts};
52
+ let s00 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(-ts, -ts), cmpZ);
53
+ let s10 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(0.0, -ts), cmpZ);
54
+ let s20 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f( ts, -ts), cmpZ);
55
+ let s01 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(-ts, 0.0), cmpZ);
56
+ let s11 = textureSampleCompareLevel(${map}, ${sampler}, suv, cmpZ);
57
+ let s21 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f( ts, 0.0), cmpZ);
58
+ let s02 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(-ts, ts), cmpZ);
59
+ let s12 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(0.0, ts), cmpZ);
60
+ let s22 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f( ts, ts), cmpZ);
61
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
62
+ `;
63
+ /** Bindings the real accessors take, from `binding` upward. */
64
+ export const SCENE_LIGHT_API_BINDINGS = 5;
65
+ /**
66
+ * `rzShadow(p)`: how much of the sun reaches world point `p` — 1 lit, 0 in a
67
+ * caster's shadow, soft in between.
68
+ *
69
+ * `rzWorldAmbient(n)`: the world's light arriving at a surface facing `n` —
70
+ * the materials' own ambient, so an effect's shade can match hers.
71
+ *
72
+ * With `on`, declares five bindings from `binding`:
73
+ * +0 the light uniform (ambient, the sun — direction, and in .w the shadow
74
+ * switch — and the HDRI's irradiance)
75
+ * +1 the cascade view-projections, then the caster sphere
76
+ * +2 the near cascade's map +3 the far cascade's map
77
+ * +4 the comparison sampler
78
+ *
79
+ * It takes a POINT and no normal. The materials' sampleShadow also rejects a
80
+ * surface facing away from the sun, which is right for skin and wrong for a
81
+ * blade of grass or a petal: those are thin, lit through, and shaded by their
82
+ * own N·L. The point is nudged toward the sun instead, which lifts a blade's
83
+ * root off a stage floor that casts into the same map.
84
+ */
85
+ export function sceneLightApi(on, group, binding) {
86
+ if (!on) {
87
+ return /* wgsl */ `
88
+ fn rzShadow(p: vec3f) -> f32 { return 1.0; }
89
+ fn rzWorldAmbient(n: vec3f) -> vec3f { return vec3f(0.0); }
90
+ `;
91
+ }
92
+ const n = SHADOW_CASCADES.length;
93
+ return /* wgsl */ `
94
+ // The light uniform, as the materials lay it out; lights[0] is the sun.
95
+ struct _RzSceneLight { direction: vec4f, color: vec4f, }
96
+ struct _RzSceneLightU { ambientColor: vec4f, lights: array<_RzSceneLight, 4>, sh: array<vec4f, 9>, }
97
+ // The cascades' matrices, inner to outer, then every caster in one sphere
98
+ // (x, y, z, radius): radius 0 = nothing casts, negative = do not test.
99
+ struct _RzShadowBlock { viewProj: array<mat4x4f, ${n}>, casters: vec4f, }
100
+ @group(${group}) @binding(${binding}) var<uniform> _rzLight: _RzSceneLightU;
101
+ @group(${group}) @binding(${binding + 1}) var<uniform> _rzShadowVP: _RzShadowBlock;
102
+ @group(${group}) @binding(${binding + 2}) var _rzShadowNear: texture_depth_2d;
103
+ @group(${group}) @binding(${binding + 3}) var _rzShadowFar: texture_depth_2d;
104
+ @group(${group}) @binding(${binding + 4}) var _rzShadowCmp: sampler_comparison;
105
+
106
+ fn _rzShadowNearTaps(ndc: vec3f) -> f32 {
107
+ ${pcf9("_rzShadowNear", `1.0 / ${SHADOW_CASCADES[0].mapSize}.0`, "_rzShadowCmp")}
108
+ }
109
+
110
+ fn _rzShadowFarTaps(ndc: vec3f) -> f32 {
111
+ ${pcf9("_rzShadowFar", `1.0 / ${SHADOW_CASCADES[n - 1].mapSize}.0`, "_rzShadowCmp")}
112
+ }
113
+
114
+ fn rzShadow(p: vec3f) -> f32 {
115
+ // The scene's one shadow switch, on the sun — see sampleShadow.
116
+ let castAmt = _rzLight.lights[0].direction.w;
117
+ if (castAmt <= 0.0) { return 1.0; }
118
+ let toSun = normalize(-_rzLight.lights[0].direction.xyz);
119
+ // Can anything cast onto this point at all? The ground's test, for the same
120
+ // two reasons: most of a lawn is nowhere near her, and a scene with no
121
+ // models never renders the map, whose untouched zeroes compare as shadowed.
122
+ let cs = _rzShadowVP.casters;
123
+ if (cs.w == 0.0) { return 1.0; }
124
+ if (cs.w > 0.0) {
125
+ let toCaster = cs.xyz - p;
126
+ let along = dot(toCaster, toSun);
127
+ let perp = length(toCaster - toSun * along);
128
+ if (along <= -cs.w || perp > cs.w) { return 1.0; }
129
+ }
130
+ let q = p + toSun * 0.08;
131
+ let c0 = _rzShadowVP.viewProj[0] * vec4f(q, 1.0);
132
+ let n0 = c0.xyz / max(c0.w, 1e-6);
133
+ if (all(abs(n0.xy) < vec2f(0.98)) && n0.z > 0.0 && n0.z < 1.0) {
134
+ return mix(1.0, _rzShadowNearTaps(n0), castAmt);
135
+ }
136
+ let c1 = _rzShadowVP.viewProj[${n - 1}] * vec4f(q, 1.0);
137
+ let n1 = c1.xyz / max(c1.w, 1e-6);
138
+ if (all(abs(n1.xy) < vec2f(0.98)) && n1.z > 0.0 && n1.z < 1.0) {
139
+ return mix(1.0, _rzShadowFarTaps(n1), castAmt);
140
+ }
141
+ return 1.0;
142
+ }
143
+ ${worldAmbientWgsl("_rzLight")}`;
144
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 3×3 PCF over one cascade's map, for a point already in that cascade's NDC.
3
+ *
4
+ * Shared with the materials, so an effect's shadow and her own shadow soften
5
+ * alike. The sampler is linear-filtered, which makes each tap a 2×2 bilinear
6
+ * compare. Unrolled — Safari's Metal backend doesn't unroll nested shadow
7
+ * loops reliably. The body expects `ndc` in scope and returns.
8
+ */
9
+ export declare const pcf9: (map: string, ts: string, sampler?: string) => string;
10
+ /** Bindings the real accessor takes, from `binding` upward. */
11
+ export declare const SHADOW_API_BINDINGS = 5;
12
+ /**
13
+ * `rzShadow(p)`: how much of the sun reaches world point `p` — 1 lit, 0 in a
14
+ * caster's shadow, soft in between.
15
+ *
16
+ * With `on`, declares five bindings from `binding`:
17
+ * +0 the light uniform (the sun's direction, and in .w the shadow switch)
18
+ * +1 the cascade view-projections, then the caster sphere
19
+ * +2 the near cascade's map +3 the far cascade's map
20
+ * +4 the comparison sampler
21
+ *
22
+ * It takes a POINT and no normal. The materials' sampleShadow also rejects a
23
+ * surface facing away from the sun, which is right for skin and wrong for a
24
+ * blade of grass or a petal: those are thin, lit through, and shaded by their
25
+ * own N·L. The point is nudged toward the sun instead, which lifts a blade's
26
+ * root off a stage floor that casts into the same map.
27
+ */
28
+ export declare function shadowApi(on: boolean, group: number, binding: number): string;
29
+ //# sourceMappingURL=shadow-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shadow-api.d.ts","sourceRoot":"","sources":["../../src/shaders/shadow-api.ts"],"names":[],"mappings":"AAcA;;;;;;;GAOG;AACH,eAAO,MAAM,IAAI,GAAI,KAAK,MAAM,EAAE,IAAI,MAAM,EAAE,gBAAyB,WActE,CAAA;AAED,+DAA+D;AAC/D,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAyD7E"}
@@ -0,0 +1,112 @@
1
+ import { SHADOW_CASCADES } from "../shadow-cascades";
2
+ // The sun's shadow, as an effect author sees it.
3
+ //
4
+ // A lawn standing in a character's shadow has to darken where the floor under
5
+ // it does, or the figure floats: the ground catcher draws her shadow, and the
6
+ // blades drawn over it hide it. The map is already there — the materials and
7
+ // the ground read it every frame — so this hands the same lookup to effects.
8
+ //
9
+ // Bound for real in the particle SHADING stage only. That is where geometry an
10
+ // effect draws inside the scene pass is shaded, and the shadow pass has already
11
+ // run by then. Every other module that compiles the author's file gets the
12
+ // stub, which answers 1: lit is what a point with no occlusion information is,
13
+ // the same answer sampleShadow gives outside every cascade.
14
+ /**
15
+ * 3×3 PCF over one cascade's map, for a point already in that cascade's NDC.
16
+ *
17
+ * Shared with the materials, so an effect's shadow and her own shadow soften
18
+ * alike. The sampler is linear-filtered, which makes each tap a 2×2 bilinear
19
+ * compare. Unrolled — Safari's Metal backend doesn't unroll nested shadow
20
+ * loops reliably. The body expects `ndc` in scope and returns.
21
+ */
22
+ export const pcf9 = (map, ts, sampler = "shadowSampler") => /* wgsl */ `
23
+ let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
24
+ let cmpZ = ndc.z - 0.001;
25
+ let ts = ${ts};
26
+ let s00 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(-ts, -ts), cmpZ);
27
+ let s10 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(0.0, -ts), cmpZ);
28
+ let s20 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f( ts, -ts), cmpZ);
29
+ let s01 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(-ts, 0.0), cmpZ);
30
+ let s11 = textureSampleCompareLevel(${map}, ${sampler}, suv, cmpZ);
31
+ let s21 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f( ts, 0.0), cmpZ);
32
+ let s02 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(-ts, ts), cmpZ);
33
+ let s12 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(0.0, ts), cmpZ);
34
+ let s22 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f( ts, ts), cmpZ);
35
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
36
+ `;
37
+ /** Bindings the real accessor takes, from `binding` upward. */
38
+ export const SHADOW_API_BINDINGS = 5;
39
+ /**
40
+ * `rzShadow(p)`: how much of the sun reaches world point `p` — 1 lit, 0 in a
41
+ * caster's shadow, soft in between.
42
+ *
43
+ * With `on`, declares five bindings from `binding`:
44
+ * +0 the light uniform (the sun's direction, and in .w the shadow switch)
45
+ * +1 the cascade view-projections, then the caster sphere
46
+ * +2 the near cascade's map +3 the far cascade's map
47
+ * +4 the comparison sampler
48
+ *
49
+ * It takes a POINT and no normal. The materials' sampleShadow also rejects a
50
+ * surface facing away from the sun, which is right for skin and wrong for a
51
+ * blade of grass or a petal: those are thin, lit through, and shaded by their
52
+ * own N·L. The point is nudged toward the sun instead, which lifts a blade's
53
+ * root off a stage floor that casts into the same map.
54
+ */
55
+ export function shadowApi(on, group, binding) {
56
+ if (!on) {
57
+ return /* wgsl */ `
58
+ fn rzShadow(p: vec3f) -> f32 { return 1.0; }
59
+ `;
60
+ }
61
+ const n = SHADOW_CASCADES.length;
62
+ return /* wgsl */ `
63
+ // The first two vec4s of the light uniform: world ambient, then the sun.
64
+ struct _RzShadowSun { ambient: vec4f, direction: vec4f, }
65
+ // The cascades' matrices, inner to outer, then every caster in one sphere
66
+ // (x, y, z, radius): radius 0 = nothing casts, negative = do not test.
67
+ struct _RzShadowBlock { viewProj: array<mat4x4f, ${n}>, casters: vec4f, }
68
+ @group(${group}) @binding(${binding}) var<uniform> _rzShadowSun: _RzShadowSun;
69
+ @group(${group}) @binding(${binding + 1}) var<uniform> _rzShadowVP: _RzShadowBlock;
70
+ @group(${group}) @binding(${binding + 2}) var _rzShadowNear: texture_depth_2d;
71
+ @group(${group}) @binding(${binding + 3}) var _rzShadowFar: texture_depth_2d;
72
+ @group(${group}) @binding(${binding + 4}) var _rzShadowCmp: sampler_comparison;
73
+
74
+ fn _rzShadowNearTaps(ndc: vec3f) -> f32 {
75
+ ${pcf9("_rzShadowNear", `1.0 / ${SHADOW_CASCADES[0].mapSize}.0`, "_rzShadowCmp")}
76
+ }
77
+
78
+ fn _rzShadowFarTaps(ndc: vec3f) -> f32 {
79
+ ${pcf9("_rzShadowFar", `1.0 / ${SHADOW_CASCADES[n - 1].mapSize}.0`, "_rzShadowCmp")}
80
+ }
81
+
82
+ fn rzShadow(p: vec3f) -> f32 {
83
+ // The scene's one shadow switch, on the sun — see sampleShadow.
84
+ let castAmt = _rzShadowSun.direction.w;
85
+ if (castAmt <= 0.0) { return 1.0; }
86
+ let toSun = normalize(-_rzShadowSun.direction.xyz);
87
+ // Can anything cast onto this point at all? The ground's test, for the same
88
+ // two reasons: most of a lawn is nowhere near her, and a scene with no
89
+ // models never renders the map, whose untouched zeroes compare as shadowed.
90
+ let cs = _rzShadowVP.casters;
91
+ if (cs.w == 0.0) { return 1.0; }
92
+ if (cs.w > 0.0) {
93
+ let toCaster = cs.xyz - p;
94
+ let along = dot(toCaster, toSun);
95
+ let perp = length(toCaster - toSun * along);
96
+ if (along <= -cs.w || perp > cs.w) { return 1.0; }
97
+ }
98
+ let q = p + toSun * 0.08;
99
+ let c0 = _rzShadowVP.viewProj[0] * vec4f(q, 1.0);
100
+ let n0 = c0.xyz / max(c0.w, 1e-6);
101
+ if (all(abs(n0.xy) < vec2f(0.98)) && n0.z > 0.0 && n0.z < 1.0) {
102
+ return mix(1.0, _rzShadowNearTaps(n0), castAmt);
103
+ }
104
+ let c1 = _rzShadowVP.viewProj[${n - 1}] * vec4f(q, 1.0);
105
+ let n1 = c1.xyz / max(c1.w, 1e-6);
106
+ if (all(abs(n1.xy) < vec2f(0.98)) && n1.z > 0.0 && n1.z < 1.0) {
107
+ return mix(1.0, _rzShadowFarTaps(n1), castAmt);
108
+ }
109
+ return 1.0;
110
+ }
111
+ `;
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.58.0",
3
+ "version": "0.58.1",
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
@@ -113,6 +113,7 @@ import {
113
113
  import {
114
114
  buildParticleComputeShader,
115
115
  buildParticleRenderShader,
116
+ PARTICLE_LIGHT_BINDING,
116
117
  particleEntryPoints,
117
118
  PARTICLE_STRIDE,
118
119
  } from "./shaders/passes/particles"
@@ -5300,7 +5301,7 @@ export class Engine {
5300
5301
  // Declaring one set of flags for both layouts is what made the pipeline
5301
5302
  // layout invalid, and the error surfaces later and unhelpfully as "invalid
5302
5303
  // due to a previous error".
5303
- const layoutFor = (storage: GPUBufferBindingType, visibility: number) =>
5304
+ const layoutFor = (storage: GPUBufferBindingType, visibility: number, shadow: boolean) =>
5304
5305
  this.device.createBindGroupLayout({
5305
5306
  entries: [
5306
5307
  { binding: 0, visibility, buffer: { type: storage } },
@@ -5320,13 +5321,25 @@ export class Engine {
5320
5321
  // author calls it or not. With no grid it is the 1x1 of zeroes.
5321
5322
  { binding: 8, visibility, texture: { sampleType: "float" as const } },
5322
5323
  { binding: 9, visibility, sampler: { type: "filtering" as const } },
5324
+ // The scene's light, for rzShadow and rzWorldAmbient — the shading
5325
+ // stage only; the compute module compiles the stubs. See
5326
+ // scene-light-api.ts.
5327
+ ...(shadow
5328
+ ? [
5329
+ { binding: PARTICLE_LIGHT_BINDING, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
5330
+ { binding: PARTICLE_LIGHT_BINDING + 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
5331
+ { binding: PARTICLE_LIGHT_BINDING + 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" as const } },
5332
+ { binding: PARTICLE_LIGHT_BINDING + 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" as const } },
5333
+ { binding: PARTICLE_LIGHT_BINDING + 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" as const } },
5334
+ ]
5335
+ : []),
5323
5336
  ],
5324
5337
  })
5325
5338
  /** One per GRID PARITY, for the reason rebuildFieldBindGroup gives: the grid
5326
5339
  * alternates which texture is current, and rebuilding a single group every
5327
5340
  * frame is waste for a change that only ever toggles between two known
5328
5341
  * states. Both entries are the same view when there is no grid. */
5329
- const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer) =>
5342
+ const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer, shadow: boolean) =>
5330
5343
  [0, 1].map((parity) =>
5331
5344
  this.device.createBindGroup({
5332
5345
  layout,
@@ -5341,12 +5354,21 @@ export class Engine {
5341
5354
  ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
5342
5355
  { binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
5343
5356
  { binding: 9, resource: this.simSampler },
5357
+ ...(shadow
5358
+ ? [
5359
+ { binding: PARTICLE_LIGHT_BINDING, resource: { buffer: this.lightUniformBuffer } },
5360
+ { binding: PARTICLE_LIGHT_BINDING + 1, resource: { buffer: this.shadowLightVPBuffer } },
5361
+ { binding: PARTICLE_LIGHT_BINDING + 2, resource: this.shadowMapDepthViews[0] },
5362
+ { binding: PARTICLE_LIGHT_BINDING + 3, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
5363
+ { binding: PARTICLE_LIGHT_BINDING + 4, resource: this.shadowComparisonSampler },
5364
+ ]
5365
+ : []),
5344
5366
  ],
5345
5367
  }),
5346
5368
  ) as [GPUBindGroup, GPUBindGroup]
5347
5369
 
5348
- const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE)
5349
- const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT)
5370
+ const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false)
5371
+ const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true)
5350
5372
 
5351
5373
  // Additive keeps the destination and adds to it, and leaves alpha alone, so
5352
5374
  // a glow does not claim coverage it never occluded. The MASK sums with it —
@@ -5422,16 +5444,16 @@ export class Engine {
5422
5444
  counts: uniformView.uints,
5423
5445
  compute,
5424
5446
  computeLayout,
5425
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer),
5447
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
5426
5448
  depth,
5427
5449
  render,
5428
5450
  renderLayout,
5429
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer),
5430
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer),
5451
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
5452
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
5431
5453
  rebind: () => ({
5432
- computeBinds: bindFor(computeLayout, this.cameraUniformBuffer),
5433
- renderBinds: bindFor(renderLayout, this.cameraUniformBuffer),
5434
- mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer),
5454
+ computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
5455
+ renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
5456
+ mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
5435
5457
  }),
5436
5458
  },
5437
5459
  }
@@ -7387,8 +7409,10 @@ export class Engine {
7387
7409
  },
7388
7410
  })
7389
7411
 
7412
+ // The matrices, then one vec4: the caster sphere, which effects' rzShadow
7413
+ // tests before any tap. The materials and the ground bind the matrices only.
7390
7414
  this.shadowLightVPBuffer = this.device.createBuffer({
7391
- size: 64 * SHADOW_CASCADES.length,
7415
+ size: 64 * SHADOW_CASCADES.length + 16,
7392
7416
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7393
7417
  })
7394
7418
  this.shadowCascadeVPBuffers = SHADOW_CASCADES.map((_, i) =>
@@ -12380,6 +12404,22 @@ export class Engine {
12380
12404
  this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 40, gb.subarray(10, 11) as Float32Array<ArrayBuffer>)
12381
12405
  }
12382
12406
 
12407
+ /** The caster sphere as last written after the cascade matrices; NaN so the
12408
+ * first frame always writes. */
12409
+ private shadowCastersWritten = new Float32Array([NaN, NaN, NaN, NaN])
12410
+
12411
+ /**
12412
+ * The same sphere, into the cascade block after its matrices — rzShadow's
12413
+ * copy. There whether or not a ground exists: a lawn is often the only floor.
12414
+ */
12415
+ private writeShadowCasters(): void {
12416
+ const w = this.shadowCastersWritten
12417
+ const c = this.casterSphere
12418
+ if (w[0] === c[0] && w[1] === c[1] && w[2] === c[2] && w[3] === c[3]) return
12419
+ w.set(c)
12420
+ this.device.queue.writeBuffer(this.shadowLightVPBuffer, 64 * SHADOW_CASCADES.length, c as Float32Array<ArrayBuffer>)
12421
+ }
12422
+
12383
12423
  /**
12384
12424
  * Push this frame's caster sphere into the ground's uniform.
12385
12425
  *
@@ -15376,6 +15416,7 @@ export class Engine {
15376
15416
  if (hasModels) this.dispatchCull(encoder)
15377
15417
  // After the cull, which is what recomputes the spheres it unions.
15378
15418
  this.writeGroundCasterSphere()
15419
+ this.writeShadowCasters()
15379
15420
  this.writeGroundDress()
15380
15421
 
15381
15422
  // After the cull, because a rebuild there can reallocate the argument
@@ -38,6 +38,7 @@ import { midiApi } from "./midi-api"
38
38
  import { lyricsApi } from "./lyrics-api"
39
39
  import { clockApi, trailSlotsApi, viewportApi } from "./passes/hosted-api"
40
40
  import { idApi } from "./id-api"
41
+ import { sceneLightApi, worldAmbientWgsl } from "./scene-light-api"
41
42
 
42
43
  /** Floats before the first record. One is the count; the rest keep the records
43
44
  * vec4-aligned, which is what lets a future pass read them as vec4s. */
@@ -84,29 +85,8 @@ struct RzLight {
84
85
  }
85
86
  `
86
87
 
87
- /**
88
- * The world's light at a surface facing n — the flat colour, or the installed
89
- * HDRI's irradiance (sh[0].w = 1), evaluated from folded SH coefficients (see
90
- * ibl.ts for the folding; the shader is a plain polynomial in the normal).
91
- *
92
- * One string included by every module that declares LightUniforms with the sh
93
- * block — the hosted-api lesson: a helper defined in some modules and not
94
- * others is a compile error waiting for the first file that crosses them.
95
- */
96
- export const WORLD_AMBIENT_WGSL = /* wgsl */ `
97
- fn rzWorldAmbient(n: vec3f) -> vec3f {
98
- if (light.sh[0].w < 0.5) { return light.ambientColor.xyz; }
99
- let x = n.x;
100
- let y = n.y;
101
- let z = n.z;
102
- let c = light.sh[0].xyz
103
- + light.sh[1].xyz * y + light.sh[2].xyz * z + light.sh[3].xyz * x
104
- + light.sh[4].xyz * (x * y) + light.sh[5].xyz * (y * z)
105
- + light.sh[6].xyz * (3.0 * z * z - 1.0) + light.sh[7].xyz * (x * z)
106
- + light.sh[8].xyz * (x * x - y * y);
107
- return max(c, vec3f(0.0));
108
- }
109
- `
88
+ /** rzWorldAmbient against the materials' and the ground's `light` uniform. */
89
+ export const WORLD_AMBIENT_WGSL = worldAmbientWgsl("light")
110
90
 
111
91
  /** Does this source define the emit mount? */
112
92
  export function hasLightEmit(wgsl: string): boolean {
@@ -184,7 +164,7 @@ ${viewportApi("viewU[6].w")}
184
164
  ${trailSlotsApi(cast.trailCount)}
185
165
  // The id accessors, stubbed: this module cannot read an attachment the
186
166
  // scene pass writes. See id-api.ts — the author's whole file compiles here.
187
- ${idApi(false, 0, 0) + castDistanceStub()}
167
+ ${idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0)}
188
168
  // The dials the author declared, if any. A lamp is exactly the thing someone
189
169
  // retunes — its colour and its reach — so an emitter reads params like every
190
170
  // other mount rather than being the one place a #param resolves to nothing.
@@ -1,6 +1,7 @@
1
1
  import { sceneFsOutWgsl } from "../passes/scene-contract"
2
2
  import { lightsApi } from "../lights"
3
3
  import { SHADOW_CASCADES } from "../../shadow-cascades"
4
+ import { pcf9 } from "../scene-light-api"
4
5
  import { WORLD_AMBIENT_WGSL } from "../lights"
5
6
 
6
7
  // Shared WGSL blocks concatenated by every material shader.
@@ -175,8 +176,8 @@ fn safe_normal(nIn: vec3f) -> vec3f {
175
176
  `;
176
177
 
177
178
  // ─── Shadow sampler (3×3 PCF, cascade-selected) ─────────────────────
178
- // Normal-bias 0.08, depth-bias 0.001 NDC. Unrolled — Safari's Metal backend
179
- // doesn't unroll nested shadow loops reliably. Texel sizes are interpolated
179
+ // Normal-bias 0.08, depth-bias 0.001 NDC; the kernel is pcf9 in scene-light-api.ts,
180
+ // shared with effects. Texel sizes are interpolated
180
181
  // from SHADOW_CASCADES so they cannot go stale the way the hardcoded 1/2048
181
182
  // once did (PCF taps landed TWO texels apart after the map grew to 4096,
182
183
  // quantizing self-shadow edges into crawling gray squares).
@@ -188,21 +189,6 @@ fn safe_normal(nIn: vec3f) -> vec3f {
188
189
  // far plane the comparison failed against every stored depth, silently
189
190
  // shadowing any stage deeper than the box.
190
191
 
191
- const pcf9 = (map: string, ts: string) => /* wgsl */ `
192
- let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
193
- let cmpZ = ndc.z - 0.001;
194
- let ts = ${ts};
195
- let s00 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
196
- let s10 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
197
- let s20 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
198
- let s01 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
199
- let s11 = textureSampleCompareLevel(${map}, shadowSampler, suv, cmpZ);
200
- let s21 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
201
- let s02 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
202
- let s12 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
203
- let s22 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f( ts, ts), cmpZ);
204
- return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
205
- `
206
192
 
207
193
  const SAMPLE_SHADOW_WGSL = /* wgsl */ `
208
194
 
@@ -6,6 +6,7 @@ import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewpor
6
6
  import { EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES } from "../cast-layout"
7
7
  import { audioApi } from "../audio-api"
8
8
  import { idApi } from "../id-api"
9
+ import { sceneLightApi } from "../scene-light-api"
9
10
  import { castDistanceApi, CAST_FIELD_DIV } from "./cast-distance"
10
11
  import { lyricsApi, lyricsTextApi } from "../lyrics-api"
11
12
  import { midiApi } from "../midi-api"
@@ -722,6 +723,7 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
722
723
  viewportApi("viewU[6].w") +
723
724
  trailSlotsApi(effect.trailCount) +
724
725
  idApi(effect.ids === true, 0, 23) +
726
+ sceneLightApi(false, 0, 0) +
725
727
  // Distance to the cast, in SCREEN pixels: the field is half-res, so a field
726
728
  // texel is CAST_FIELD_DIV of them and the accessor scales on the way out.
727
729
  // An author writes the width they mean and never learns how it is built.
@@ -8,6 +8,7 @@ import { SCENE_TAP_STUB } from "../scene-tap"
8
8
  import { type CastLayout } from "./particles"
9
9
  import { clockApi, trailSlotsApi, viewportApi } from "./hosted-api"
10
10
  import { idApi } from "../id-api"
11
+ import { sceneLightApi } from "../scene-light-api"
11
12
 
12
13
  // A persistent grid an effect can step and read: the one thing an effect could
13
14
  // not have before, which is MEMORY.
@@ -168,6 +169,7 @@ fn rzGrid(uv: vec2f) -> vec4f { return rzGridPrev(uv); }
168
169
  // Stubbed: this module cannot read an attachment the scene pass writes — see
169
170
  // id-api.ts. The author's whole file compiles here, so the names must exist.
170
171
  idApi(false, 0, 0) +
172
+ sceneLightApi(false, 0, 0) +
171
173
  // Inlined rather than imported from cast-distance, and NOT for tidiness.
172
174
  //
173
175
  // This module and composite.ts import each other — grid takes
@@ -10,6 +10,7 @@ import { EFFECT_SUBJECT_VEC4S } from "../cast-layout"
10
10
  import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api"
11
11
  import { sceneIdFieldWgsl, sceneIdPadWgsl } from "./scene-contract"
12
12
  import { idApi } from "../id-api"
13
+ import { sceneLightApi } from "../scene-light-api"
13
14
  // GPU particles for user effects: a compute step and an instanced quad draw.
14
15
  //
15
16
  // Its own shader MODULE rather than more source spliced into composite.ts, for
@@ -106,6 +107,9 @@ type ParticleSource = {
106
107
  gridSize: number
107
108
  }
108
109
 
110
+ /** Where the shading stage's five scene-light bindings start — see scene-light-api.ts. */
111
+ export const PARTICLE_LIGHT_BINDING = 10
112
+
109
113
  /** Bytes per particle. Explicitly padded — see the struct below. */
110
114
  export const PARTICLE_STRIDE = 48
111
115
 
@@ -207,7 +211,7 @@ ${src.paramsDecl}
207
211
  PRELUDE +
208
212
  // Stubbed: this module cannot read an attachment the scene pass writes — see
209
213
  // id-api.ts. The author's whole file compiles here, so the names must exist.
210
- idApi(false, 0, 0) + castDistanceStub() +
214
+ idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) +
211
215
  "\n// ── user effect ──\n" +
212
216
  src.wgsl +
213
217
  /* wgsl */ `
@@ -282,6 +286,9 @@ ${src.paramsDecl}
282
286
  // Stubbed: this module cannot read an attachment the scene pass writes — see
283
287
  // id-api.ts. The author's whole file compiles here, so the names must exist.
284
288
  idApi(false, 0, 0) + castDistanceStub() +
289
+ // The sun's shadow and the world's ambient, for real: the shadow pass has
290
+ // run by the time this draws, inside the scene pass. See scene-light-api.ts.
291
+ sceneLightApi(true, 0, PARTICLE_LIGHT_BINDING) +
285
292
  "\n// ── user effect ──\n" +
286
293
  src.wgsl +
287
294
  /* wgsl */ `