reze-engine 0.68.8 → 0.68.9

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.
Files changed (59) hide show
  1. package/dist/engine.d.ts +2 -6
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +39 -41
  4. package/dist/graph/presets/pool_floor.d.ts +3 -0
  5. package/dist/graph/presets/pool_floor.d.ts.map +1 -0
  6. package/dist/graph/presets/pool_floor.js +42 -0
  7. package/dist/graph/presets/water.d.ts +3 -0
  8. package/dist/graph/presets/water.d.ts.map +1 -0
  9. package/dist/graph/presets/water.js +31 -0
  10. package/dist/graph/slots.d.ts.map +1 -1
  11. package/dist/graph/slots.js +5 -4
  12. package/dist/jelly-eyes.d.ts.map +1 -1
  13. package/dist/jelly-eyes.js +43 -17
  14. package/dist/physics/autofit.d.ts +147 -0
  15. package/dist/physics/autofit.d.ts.map +1 -0
  16. package/dist/physics/autofit.js +501 -0
  17. package/dist/shaders/agx-lut.d.ts +11 -0
  18. package/dist/shaders/agx-lut.d.ts.map +1 -0
  19. package/dist/shaders/agx-lut.js +29 -0
  20. package/dist/shaders/dfg_lut.d.ts +3 -0
  21. package/dist/shaders/dfg_lut.d.ts.map +1 -0
  22. package/dist/shaders/dfg_lut.js +129 -0
  23. package/dist/shaders/ltc_mag_lut.d.ts +3 -0
  24. package/dist/shaders/ltc_mag_lut.d.ts.map +1 -0
  25. package/dist/shaders/ltc_mag_lut.js +1033 -0
  26. package/dist/shaders/materials/common.d.ts +6 -0
  27. package/dist/shaders/materials/common.d.ts.map +1 -1
  28. package/dist/shaders/materials/common.js +67 -60
  29. package/dist/shaders/passes/depth-prepass.d.ts.map +1 -1
  30. package/dist/shaders/passes/depth-prepass.js +11 -3
  31. package/dist/shaders/passes/field-blit.d.ts +26 -0
  32. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  33. package/dist/shaders/passes/field-blit.js +65 -0
  34. package/dist/shaders/passes/ghosts.d.ts +22 -0
  35. package/dist/shaders/passes/ghosts.d.ts.map +1 -0
  36. package/dist/shaders/passes/ghosts.js +75 -0
  37. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  38. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  39. package/dist/shaders/passes/ground-noise.js +88 -0
  40. package/dist/shaders/passes/shadow.d.ts +1 -1
  41. package/dist/shaders/passes/shadow.d.ts.map +1 -1
  42. package/dist/shaders/passes/shadow.js +11 -3
  43. package/dist/shaders/passes/sim.d.ts +34 -0
  44. package/dist/shaders/passes/sim.d.ts.map +1 -0
  45. package/dist/shaders/passes/sim.js +169 -0
  46. package/dist/shaders/score-api.d.ts +10 -0
  47. package/dist/shaders/score-api.d.ts.map +1 -0
  48. package/dist/shaders/score-api.js +114 -0
  49. package/dist/shaders/shadow-api.d.ts +29 -0
  50. package/dist/shaders/shadow-api.d.ts.map +1 -0
  51. package/dist/shaders/shadow-api.js +112 -0
  52. package/package.json +1 -1
  53. package/src/engine.ts +39 -47
  54. package/src/graph/slots.ts +5 -4
  55. package/src/jelly-eyes.ts +41 -17
  56. package/src/shaders/materials/common.ts +68 -60
  57. package/src/shaders/passes/depth-prepass.ts +11 -3
  58. package/src/shaders/passes/shadow.ts +11 -3
  59. package/dist/_dbg-loader.mjs +0 -1100
@@ -0,0 +1,169 @@
1
+ import { audioApi } from "../audio-api";
2
+ import { scoreApi } from "../score-api";
3
+ import { EFFECT_SCENE_API } from "./composite";
4
+ // A persistent grid an effect can step and read: the one thing an effect could
5
+ // not have before, which is MEMORY.
6
+ //
7
+ // Every other mount is a pure function of position and time. A field shader
8
+ // recomputes its noise from scratch every frame; a trail reads history the
9
+ // engine happens to keep. Neither can build on what it produced last frame, and
10
+ // a whole class of effect is nothing but that:
11
+ //
12
+ // Fog that a dancer walks through does not "look displaced near her" — it IS
13
+ // displaced, and stays displaced, and creeps back over seconds. Water does not
14
+ // look rippled where a foot landed; a ripple leaves and propagates and
15
+ // reflects off the far edge long after the foot is gone. Both are the same
16
+ // shape of thing — a grid, stepped by its own previous value.
17
+ //
18
+ // It is also the only way to get the LOOK of a fluid, not merely its motion.
19
+ // Advection compounds: each frame's field is the last one pushed sideways, and a
20
+ // few hundred frames of that stretches and folds a smooth field into filaments
21
+ // and vortex sheets finer than the grid storing them. No noise function
22
+ // produces that, at any octave count, because the structure is not a function of
23
+ // position at all — it is a function of history.
24
+ //
25
+ // The engine owns the ping-pong, the dispatch and the sampling. The author owns
26
+ // the KERNEL, which is the part that differs: advection for smoke, the wave
27
+ // equation for water, a decaying stamp for footprints. That split is the same
28
+ // one the particle mounts make, and for the same reason — the loop is identical
29
+ // in every such effect and the physics never is.
30
+ // A megatexel of rgba16float is 8MB, and two of those is the whole cost. What
31
+ // actually bounds this is the STEP: one invocation per texel per frame, each
32
+ // taking a dozen samples, so doubling the side quadruples the work.
33
+ export const SIM_MAX = 1024;
34
+ /** rgba16float, ping-ponged. Two of these is the whole memory cost. */
35
+ export const SIM_FORMAT = "rgba16float";
36
+ /** `// @sim 256` — the grid's resolution, in texels per side. */
37
+ export function parseSimSize(wgsl, max) {
38
+ const m = /^\s*\/\/\s*@sim\s+(\d+)\s*$/m.exec(wgsl);
39
+ if (!m)
40
+ return 0;
41
+ // Clamped rather than rejected, as the particle count is: an author asking for
42
+ // 4096 gets the most the engine will give and a scene that still runs.
43
+ return Math.max(8, Math.min(max, parseInt(m[1], 10)));
44
+ }
45
+ /** Whether this effect drives a grid at all. */
46
+ export function simEntryPoint(wgsl) {
47
+ return /\bfn\s+simStep\s*\(/.test(wgsl);
48
+ }
49
+ /**
50
+ * `rzSim(uv)`, for a shader that READS the grid rather than steps it.
51
+ *
52
+ * Always compiled in, even for an effect with no grid — it then samples a 1×1
53
+ * of zeroes. An accessor that exists only sometimes is one an author has to
54
+ * guard, and a missing function is a compile error rather than a blank result.
55
+ */
56
+ export function simReadApi(group, tex, samp, size) {
57
+ return /* wgsl */ `
58
+ const RZ_SIM_SIZE: f32 = ${size > 0 ? size : 1}.0;
59
+ @group(${group}) @binding(${tex}) var _rzSimTex: texture_2d<f32>;
60
+ @group(${group}) @binding(${samp}) var _rzSimSamp: sampler;
61
+
62
+ /**
63
+ * The grid, bilinearly sampled. uv is 0..1 across it, and what that MEANS in
64
+ * the world is the effect's own business — the engine deliberately does not
65
+ * impose a mapping, because a fog laid over a stage and a ripple field around a
66
+ * character want different ones and both are two lines of arithmetic.
67
+ */
68
+ fn rzSim(uv: vec2f) -> vec4f {
69
+ return textureSampleLevel(_rzSimTex, _rzSimSamp, clamp(uv, vec2f(0.0), vec2f(1.0)), 0.0);
70
+ }
71
+ fn rzSimSize() -> f32 { return RZ_SIM_SIZE; }
72
+ fn rzSimTexel() -> f32 { return 1.0 / RZ_SIM_SIZE; }
73
+
74
+ // ── The step-only half, defined here too and never called here ──
75
+ //
76
+ // One effect file is spliced into EVERY module it has a mount in, so a file
77
+ // with a grid compiles its simStep inside the field and particle shaders as
78
+ // well — where it is dead code that nothing calls, but still code that has to
79
+ // resolve. Leaving these out is a compile error on a function the author was
80
+ // right to write. rzSimPrev reads the current grid rather than a previous one,
81
+ // which is the honest answer outside the step: there is no previous frame here.
82
+ fn rzSimPrev(uv: vec2f) -> vec4f { return rzSim(uv); }
83
+ fn rzSimFrame() -> i32 { return 1; }
84
+ `;
85
+ }
86
+ /** `rzTime`/`rzDt`, for a module that has no clock of its own — the field pass.
87
+ * Same reason as above: a spliced simStep refers to them. */
88
+ export function simClockApi(timeExpr) {
89
+ return /* wgsl */ `
90
+ fn rzTime() -> f32 { return ${timeExpr}; }
91
+ fn rzDt() -> f32 { return 0.0; }
92
+ `;
93
+ }
94
+ const SIM_UNIFORMS = /* wgsl */ `
95
+ struct SimU {
96
+ time: f32,
97
+ dt: f32,
98
+ size: f32,
99
+ frame: f32,
100
+ }
101
+ `;
102
+ /**
103
+ * The step shader: one invocation per texel, once per frame.
104
+ *
105
+ * The previous grid is bound as a SAMPLED texture and the next as a storage
106
+ * texture, which is what makes the two different resources and the whole thing
107
+ * legal — a shader cannot read and write one texture coherently, and the
108
+ * ping-pong is not an optimisation but the only correct way to do this.
109
+ *
110
+ * Sampled rather than only fetched, because advection needs to read BETWEEN
111
+ * texels: a semi-Lagrangian step asks "what was at the place this parcel came
112
+ * from", and that place is almost never a texel centre. Point-sampling it is
113
+ * what turns smoke into a staircase.
114
+ */
115
+ export function buildSimShader(wgsl, size, _cast) {
116
+ return (SIM_UNIFORMS +
117
+ /* wgsl */ `
118
+ @group(0) @binding(0) var<uniform> su: SimU;
119
+ @group(0) @binding(1) var _rzSimPrevTex: texture_2d<f32>;
120
+ @group(0) @binding(2) var _rzSimSamp: sampler;
121
+ @group(0) @binding(3) var _rzSimOut: texture_storage_2d<${SIM_FORMAT}, write>;
122
+ @group(0) @binding(4) var<storage, read> _rzCast: array<vec4f>;
123
+ // The same view uniform the composite reads, so the scene API below is the real
124
+ // thing here rather than a stub — a kernel can ask where a bone is, and a
125
+ // foreground spliced into this module resolves its camera calls.
126
+ @group(0) @binding(6) var<uniform> viewU: array<vec4<f32>, 15>;
127
+
128
+ fn rzTime() -> f32 { return su.time; }
129
+ fn rzDt() -> f32 { return su.dt; }
130
+ /** Texels per side. */
131
+ fn rzSimSize() -> f32 { return su.size; }
132
+ /** One texel, in uv — the step a neighbour lookup takes. */
133
+ fn rzSimTexel() -> f32 { return 1.0 / su.size; }
134
+ /**
135
+ * Steps since this effect was installed. Zero on the very first one, which is
136
+ * the only chance to seed a grid: everything after it builds on what is there.
137
+ */
138
+ fn rzSimFrame() -> i32 { return i32(su.frame); }
139
+
140
+ /** The grid as it was LAST frame, bilinear. The only input a kernel really has. */
141
+ fn rzSimPrev(uv: vec2f) -> vec4f {
142
+ return textureSampleLevel(_rzSimPrevTex, _rzSimSamp, clamp(uv, vec2f(0.0), vec2f(1.0)), 0.0);
143
+ }
144
+ ` +
145
+ EFFECT_SCENE_API +
146
+ audioApi(0, 5) +
147
+ scoreApi(0, 7) +
148
+ // rzSim itself, so a kernel may read the grid it is writing — through the
149
+ // PREVIOUS frame's texture, which is the only version of it that exists
150
+ // while the current one is still being written.
151
+ /* wgsl */ `
152
+ fn rzSim(uv: vec2f) -> vec4f { return rzSimPrev(uv); }
153
+ ` +
154
+ "\n// ── user effect (setEffect) ──\n" +
155
+ wgsl +
156
+ /* wgsl */ `
157
+
158
+ @compute @workgroup_size(8, 8)
159
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
160
+ let n = u32(${size});
161
+ if (gid.x >= n || gid.y >= n) { return; }
162
+ let xy = vec2i(i32(gid.x), i32(gid.y));
163
+ // The texel CENTRE, so a kernel that reads its own uv back through rzSimPrev
164
+ // lands on itself rather than a quarter-texel off.
165
+ let uv = (vec2f(f32(gid.x), f32(gid.y)) + vec2f(0.5)) / su.size;
166
+ textureStore(_rzSimOut, xy, simStep(uv, textureLoad(_rzSimPrevTex, xy, 0), su.dt));
167
+ }
168
+ `);
169
+ }
@@ -0,0 +1,10 @@
1
+ /** Floats before the key map: count, pitch range, clock, duration, release. */
2
+ export declare const SCORE_HEADER = 8;
3
+ /** One energy slot per MIDI pitch. 128 is the MIDI range, not a cap we chose. */
4
+ export declare const SCORE_KEYS = 128;
5
+ /** Where the note records begin. */
6
+ export declare const SCORE_NOTES: number;
7
+ /** Floats per note: start, duration, pitch, velocity. */
8
+ export declare const SCORE_STRIDE = 4;
9
+ /** The rzNote*/ export declare function scoreApi(group: number, binding: number): string;
10
+ //# sourceMappingURL=score-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"score-api.d.ts","sourceRoot":"","sources":["../../src/shaders/score-api.ts"],"names":[],"mappings":"AA8BA,+EAA+E;AAC/E,eAAO,MAAM,YAAY,IAAI,CAAA;AAC7B,iFAAiF;AACjF,eAAO,MAAM,UAAU,MAAM,CAAA;AAC7B,oCAAoC;AACpC,eAAO,MAAM,WAAW,QAA4B,CAAA;AACpD,yDAAyD;AACzD,eAAO,MAAM,YAAY,IAAI,CAAA;AAE7B,gBAAgB,CAChB,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CA2E/D"}
@@ -0,0 +1,114 @@
1
+ // The score data interface, as WGSL — shared verbatim by every effect module.
2
+ //
3
+ // The sibling of the audio interface, and precomputed for the same reason: an
4
+ // export steps the engine frame by frame rather than playing in real time, so
5
+ // anything read live from a player would render empty into the exported video.
6
+ // A note list is inherently precomputed, so it cannot even be tempted.
7
+ //
8
+ // WHY THIS EXISTS BESIDE rzAudio* RATHER THAN INSTEAD OF IT. The audio interface
9
+ // answers "how loud is the bass right now" — a spectrum, smeared across
10
+ // frequency and time. That is the right shape for a reactive backdrop and the
11
+ // wrong shape for a note: you cannot recover a discrete onset, a pitch and a
12
+ // duration from band energy, and the crispness of a falling note is the whole
13
+ // point of the look. The two are complementary — a scene can pulse its fog on
14
+ // rzAudioOnset while its notes fall on rzNoteAge.
15
+ //
16
+ // LAYOUT. An 8-float header, then a 128-float live key map, then the notes at 4
17
+ // floats each (start, duration, pitch, velocity). `time` and `playing` are
18
+ // rewritten every frame by whoever owns playback, exactly as the audio header
19
+ // is, so "now" is always the clock actually running.
20
+ //
21
+ // THE KEY MAP IS THE POINT OF THE DESIGN. Falling notes index the note list
22
+ // directly — one particle per note, which is what makes the geometry trivial.
23
+ // But a keyboard glow asks the opposite question, per pixel: is anything
24
+ // sounding at THIS pitch? Answering that by scanning the note list would be
25
+ // thousands of iterations per fragment. So the engine keeps a 128-entry
26
+ // per-pitch energy map, updated once per frame on the CPU, and the glow becomes
27
+ // one lookup — the same trade the audio interface makes by precomputing onset
28
+ // so "a beat-triggered effect is one comparison instead of a per-pixel history
29
+ // scan".
30
+ /** Floats before the key map: count, pitch range, clock, duration, release. */
31
+ export const SCORE_HEADER = 8;
32
+ /** One energy slot per MIDI pitch. 128 is the MIDI range, not a cap we chose. */
33
+ export const SCORE_KEYS = 128;
34
+ /** Where the note records begin. */
35
+ export const SCORE_NOTES = SCORE_HEADER + SCORE_KEYS;
36
+ /** Floats per note: start, duration, pitch, velocity. */
37
+ export const SCORE_STRIDE = 4;
38
+ /** The rzNote*/ /* rzKey* accessors, with the buffer declared at the given binding. */
39
+ export function scoreApi(group, binding) {
40
+ return /* wgsl */ `
41
+ @group(${group}) @binding(${binding}) var<storage, read> _rzScore: array<f32>;
42
+
43
+ /** Notes in the score; 0 when none is loaded, which every accessor below
44
+ * tolerates by returning zero rather than reading past the end. */
45
+ fn rzNoteCount() -> i32 { return i32(_rzScore[0]); }
46
+ /** Where the score is NOW, in seconds — the clock every age below hangs from. */
47
+ fn rzScoreTime() -> f32 { return _rzScore[3]; }
48
+ /** 1 while the score is advancing, 0 paused or absent. */
49
+ fn rzScorePlaying() -> f32 { return select(0.0, _rzScore[4], i32(_rzScore[0]) > 0); }
50
+ /** Last note-off, in seconds — the length of the piece. */
51
+ fn rzScoreDuration() -> f32 { return _rzScore[5]; }
52
+ /** Lowest and highest pitch the score actually uses. Lay a keyboard out against
53
+ * these rather than against 0..127 and a piano piece fills the screen instead
54
+ * of occupying its middle third. */
55
+ fn rzPitchLow() -> f32 { return _rzScore[1]; }
56
+ fn rzPitchHigh() -> f32 { return _rzScore[2]; }
57
+
58
+ fn _rzNoteAt(i: i32, field: i32) -> f32 {
59
+ if (i < 0 || i >= i32(_rzScore[0])) { return 0.0; }
60
+ return _rzScore[${SCORE_NOTES} + i * ${SCORE_STRIDE} + field];
61
+ }
62
+
63
+ /** When note i begins, in seconds. */
64
+ fn rzNoteStart(i: i32) -> f32 { return _rzNoteAt(i, 0); }
65
+ /** How long note i sounds, in seconds. */
66
+ fn rzNoteLength(i: i32) -> f32 { return _rzNoteAt(i, 1); }
67
+ /** MIDI pitch of note i — 60 is middle C. */
68
+ fn rzNotePitch(i: i32) -> f32 { return _rzNoteAt(i, 2); }
69
+ /** How hard note i was struck, 0..1. */
70
+ fn rzNoteVelocity(i: i32) -> f32 { return _rzNoteAt(i, 3); }
71
+
72
+ /**
73
+ * Seconds since note i began — NEGATIVE before it does.
74
+ *
75
+ * The falling-note function, and the sign is the whole of it: a note's height is
76
+ * -rzNoteAge(i) * speed, so a note not yet played sits above the keyboard at a
77
+ * distance proportional to how long is left, crosses zero exactly as it sounds,
78
+ * and keeps going. One expression, no state, no spawning — which is why one
79
+ * particle per note is the natural mapping and the pool index IS the note index.
80
+ */
81
+ fn rzNoteAge(i: i32) -> f32 { return _rzScore[3] - rzNoteStart(i); }
82
+
83
+ /** 1 while note i is sounding, 0 either side of it. */
84
+ fn rzNoteHeld(i: i32) -> f32 {
85
+ let age = rzNoteAge(i);
86
+ return select(0.0, 1.0, age >= 0.0 && age < rzNoteLength(i));
87
+ }
88
+
89
+ /**
90
+ * Energy at a MIDI pitch right now: 1 the instant it is struck, holding while
91
+ * sounding, then falling away over the release set at install time.
92
+ *
93
+ * Per PITCH rather than per note, because that is the question a keyboard asks
94
+ * and a note list cannot answer cheaply — see the header. Fractional pitches
95
+ * read the nearest key, so a caller can hand this a continuous x across the
96
+ * keyboard without rounding first.
97
+ */
98
+ fn rzKeyEnergy(pitch: f32) -> f32 {
99
+ let k = i32(round(pitch));
100
+ if (k < 0 || k >= ${SCORE_KEYS}) { return 0.0; }
101
+ return _rzScore[${SCORE_HEADER} + k];
102
+ }
103
+
104
+ /**
105
+ * A pitch's place across the score's own range, 0..1 — the x of a falling note
106
+ * and of the key it lands on, which have to agree or the effect is nonsense.
107
+ */
108
+ fn rzPitchX(pitch: f32) -> f32 {
109
+ let lo = _rzScore[1];
110
+ let hi = _rzScore[2];
111
+ return select(0.5, (pitch - lo) / max(hi - lo, 1.0), hi > lo);
112
+ }
113
+ `;
114
+ }
@@ -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.68.8",
3
+ "version": "0.68.9",
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
@@ -354,11 +354,6 @@ type GroupInstall = {
354
354
  pipelineNoDepthWrite: GPURenderPipeline
355
355
  /** hair render-class only: the stencil-matched IS_OVER_EYES=true variant. */
356
356
  overEyesPipeline?: GPURenderPipeline
357
- /** eye render-class only: the same pipeline with its cull flipped, for the
358
- * mirror pass. The eye is the ONE material class that culls a face, and a
359
- * reflection flips winding, so in the mirror the ordinary pipeline keeps
360
- * exactly the faces it exists to discard. */
361
- mirrorPipeline?: GPURenderPipeline
362
357
  uniformBuffer: GPUBuffer
363
358
  /** The group's own image maps, uploaded once per apply and owned here — the
364
359
  * install destroys them when it is replaced, so a re-apply cannot leak. */
@@ -8512,6 +8507,8 @@ export class Engine {
8512
8507
  { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
8513
8508
  { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
8514
8509
  { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
8510
+ // The model's ObjectLight, for its jelly eyes.
8511
+ { binding: 3, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
8515
8512
  ],
8516
8513
  })
8517
8514
  const shadowShader = this.device.createShaderModule({
@@ -15180,6 +15177,20 @@ export class Engine {
15180
15177
  physics.setFloor(this.physicsFloor)
15181
15178
  }
15182
15179
 
15180
+ // Its ObjectLight: the world's ambient, and the layers its kind draws on —
15181
+ // the cast on the character layer as well as the default one. Words:
15182
+ // layers [0..3], ambient flag [4..7], SH [8..43], fill [44..47], jelly
15183
+ // eyes [48..71] — see materials/common.ts.
15184
+ const objectLight = new Float32Array(96)
15185
+ new Uint32Array(objectLight.buffer)[0] =
15186
+ isStage || isPlane || isProp ? RENDERING_LAYER_DEFAULT : (RENDERING_LAYER_DEFAULT | RENDERING_LAYER_CHARACTER) >>> 0
15187
+ const lightBuffer = this.device.createBuffer({
15188
+ label: `${name}: object light`,
15189
+ size: objectLight.byteLength,
15190
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
15191
+ })
15192
+ this.device.queue.writeBuffer(lightBuffer, 0, objectLight)
15193
+
15183
15194
  // One per cascade: the shadow VERTEX shader reads a single matrix, and
15184
15195
  // which one is the only thing that differs between the cascade passes.
15185
15196
  const shadowBindGroups = SHADOW_CASCADES.map((_, ci) =>
@@ -15190,6 +15201,7 @@ export class Engine {
15190
15201
  { binding: 0, resource: { buffer: this.shadowCascadeVPBuffers[ci] } },
15191
15202
  { binding: 1, resource: { buffer: skinMatrixBuffer } },
15192
15203
  { binding: 2, resource: this.materialSampler },
15204
+ { binding: 3, resource: { buffer: lightBuffer } },
15193
15205
  ],
15194
15206
  }),
15195
15207
  )
@@ -15202,23 +15214,10 @@ export class Engine {
15202
15214
  { binding: 0, resource: { buffer: this.castShadowVPBuffer } },
15203
15215
  { binding: 1, resource: { buffer: skinMatrixBuffer } },
15204
15216
  { binding: 2, resource: this.materialSampler },
15217
+ { binding: 3, resource: { buffer: lightBuffer } },
15205
15218
  ],
15206
15219
  }),
15207
15220
  )
15208
-
15209
- // Its ObjectLight: the world's ambient, and the layers its kind draws on —
15210
- // the cast on the character layer as well as the default one. Words:
15211
- // layers [0..3], ambient flag [4..7], SH [8..43], fill [44..47], jelly
15212
- // eyes [48..71] — see materials/common.ts.
15213
- const objectLight = new Float32Array(96)
15214
- new Uint32Array(objectLight.buffer)[0] =
15215
- isStage || isPlane || isProp ? RENDERING_LAYER_DEFAULT : (RENDERING_LAYER_DEFAULT | RENDERING_LAYER_CHARACTER) >>> 0
15216
- const lightBuffer = this.device.createBuffer({
15217
- label: `${name}: object light`,
15218
- size: objectLight.byteLength,
15219
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
15220
- })
15221
- this.device.queue.writeBuffer(lightBuffer, 0, objectLight)
15222
15221
  const mainPerInstanceBindGroup = this.perInstanceBindGroup(name, skinMatrixBuffer, lightBuffer)
15223
15222
 
15224
15223
  const pickPerInstanceBindGroup = this.device.createBindGroup({
@@ -16189,6 +16188,10 @@ export class Engine {
16189
16188
  // Alpha is the switch; clamp the rest so a stacked multiply cannot send a
16190
16189
  // colour negative and light the material from the inside.
16191
16190
  for (let k = 0; k < 11; k++) if (d[k] < 0) d[k] = 0
16191
+ // Alpha also stops at 1, as in MMD. Models ship additive +1 alpha morphs
16192
+ // (照れ on 拾星梦语) that MMD's clamp makes a no-op; let through, alpha 2
16193
+ // blends as src*2 - dst and burns every material white.
16194
+ if (d[3] > 1) d[3] = 1
16192
16195
  if (d[3] < 0.0001) inst.morphHiddenMaterials.add(target.materialName)
16193
16196
  let changed = false
16194
16197
  for (let k = 0; k < 11; k++) {
@@ -18021,10 +18024,9 @@ export class Engine {
18021
18024
  if (!result.ok) return
18022
18025
  const module = this.cachedShaderModule(result.wgsl, `style group: ${g.id} (${renderClass})`)
18023
18026
  await Promise.all([
18024
- this.createRenderClassPipeline(renderClass, module, false, true, false, blend),
18025
- this.createRenderClassPipeline(renderClass, module, false, false, false, blend),
18026
- renderClass === "hair" ? this.createRenderClassPipeline(renderClass, module, true, true, false, blend) : null,
18027
- renderClass === "eye" ? this.createRenderClassPipeline(renderClass, module, false, true, true, blend) : null,
18027
+ this.createRenderClassPipeline(renderClass, module, false, true, blend),
18028
+ this.createRenderClassPipeline(renderClass, module, false, false, blend),
18029
+ renderClass === "hair" ? this.createRenderClassPipeline(renderClass, module, true, true, blend) : null,
18028
18030
  ]).catch(() => {})
18029
18031
  }),
18030
18032
  )
@@ -18401,20 +18403,17 @@ export class Engine {
18401
18403
  // The single-sided twins a draw of this group will ask for (sidedPipeline),
18402
18404
  // built with their pipelines rather than synchronously at the first draw.
18403
18405
  const singleSided = inst.drawCalls.some((dc) => !dc.doubleSided && group.materials.includes(dc.materialName))
18404
- const variant = (overEyes: boolean, depthWrite: boolean, mirrored: boolean) =>
18405
- this.createRenderClassPipeline(renderClass, module, overEyes, depthWrite, mirrored, blend).then(async (p) => {
18406
- if (singleSided && !mirrored) await this.buildSidedTwin(p, false)
18406
+ const variant = (overEyes: boolean, depthWrite: boolean) =>
18407
+ this.createRenderClassPipeline(renderClass, module, overEyes, depthWrite, blend).then(async (p) => {
18408
+ if (singleSided) await this.buildSidedTwin(p, false)
18407
18409
  return p
18408
18410
  })
18409
18411
  const variants = Promise.all([
18410
- variant(false, true, false),
18412
+ variant(false, true),
18411
18413
  // The depth-write-off twin: stage transparency draws with it (see
18412
18414
  // pipelineForDrawCall), and a future OIT path would too.
18413
- variant(false, false, false),
18414
- renderClass === "hair" ? variant(true, true, false) : undefined,
18415
- // Only the eye needs one: every other class culls "none", which a flipped
18416
- // winding leaves alone.
18417
- renderClass === "eye" ? variant(false, true, true) : undefined,
18415
+ variant(false, false),
18416
+ renderClass === "hair" ? variant(true, true) : undefined,
18418
18417
  ])
18419
18418
  // Handled below either way; this keeps a rejection off the console while
18420
18419
  // the diagnostics are read.
@@ -18435,9 +18434,8 @@ export class Engine {
18435
18434
  let pipeline: GPURenderPipeline
18436
18435
  let pipelineNoDepthWrite: GPURenderPipeline
18437
18436
  let overEyesPipeline: GPURenderPipeline | undefined
18438
- let mirrorPipeline: GPURenderPipeline | undefined
18439
18437
  try {
18440
- ;[pipeline, pipelineNoDepthWrite, overEyesPipeline, mirrorPipeline] = await variants
18438
+ ;[pipeline, pipelineNoDepthWrite, overEyesPipeline] = await variants
18441
18439
  } catch (e) {
18442
18440
  diagnostics.push({ severity: "error", message: `pipeline creation failed: ${(e as Error).message}` })
18443
18441
  return { ok: false, diagnostics, slotMap: result.slotMap }
@@ -18467,7 +18465,6 @@ export class Engine {
18467
18465
  pipeline,
18468
18466
  pipelineNoDepthWrite,
18469
18467
  overEyesPipeline,
18470
- mirrorPipeline,
18471
18468
  uniformBuffer,
18472
18469
  images: this.uploadGroupImages(group),
18473
18470
  imagesByMaterial: group.imagesByMaterial
@@ -18643,29 +18640,25 @@ export class Engine {
18643
18640
  /**
18644
18641
  * Render-class pipeline state. A group's compiled graph swaps the fragment shading; the
18645
18642
  * render-class owns pass integration (stencil interplay, depth bias, cull). auto = plain;
18646
- * eye = stamp + front cull + bias; hair = stencil-test (+ the over-eyes variant).
18643
+ * eye = stencil stamp; hair = stencil-test (+ the over-eyes variant). Cull comes from
18644
+ * the material's double-sided flag (sidedPipeline).
18647
18645
  */
18648
18646
  private createRenderClassPipeline(
18649
18647
  renderClass: RenderClass,
18650
18648
  module: GPUShaderModule,
18651
18649
  overEyes: boolean,
18652
18650
  depthWrite = true,
18653
- mirrored = false,
18654
18651
  blend: StyleBlend = "over",
18655
18652
  ): Promise<GPURenderPipeline> {
18656
18653
  const base = {
18657
18654
  label: `style ${renderClass}${overEyes ? " (over eyes)" : ""}`,
18658
18655
  layout: this.mainPipelineLayout,
18659
18656
  vertex: { module, buffers: this.materialVertexBufferLayouts },
18660
- // The eye front-culls — the see-through-hair look is draw order plus that
18661
- // cull plus the stencil stamp, and it is the deployed appearance of every
18662
- // published scene. In the MIRROR the winding is flipped (determinant -1,
18663
- // reflection.ts), so the same cull keeps the back of the eyeball and
18664
- // discards its front. `mirrored` flips it back rather than changing what
18665
- // the camera sees.
18666
- primitive: {
18667
- cullMode: (renderClass === "eye" ? (mirrored ? "back" : "front") : "none") as GPUCullMode,
18668
- },
18657
+ // Every class draws both sides here and takes the PMX double-sided flag
18658
+ // from sidedPipeline, whose single-sided twin culls front (back in the
18659
+ // mirror's flipped winding) — the eye's old fixed cull. A double-sided
18660
+ // iris sheet keeps the faces a turn of the eye swings away.
18661
+ primitive: { cullMode: "none" as GPUCullMode },
18669
18662
  multisample: { count: Engine.MULTISAMPLE_COUNT },
18670
18663
  }
18671
18664
  const plainDepth: GPUDepthStencilState = {
@@ -18956,7 +18949,6 @@ export class Engine {
18956
18949
  if (dc.groupId) {
18957
18950
  const install = inst.styleGroups.get(dc.groupId)
18958
18951
  if (install) {
18959
- if (mirrored && install.mirrorPipeline) return install.mirrorPipeline
18960
18952
  return stageGlass ? install.pipelineNoDepthWrite : install.pipeline
18961
18953
  }
18962
18954
  }