reze-engine 0.42.2 → 0.43.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.
Files changed (71) hide show
  1. package/README.md +29 -1
  2. package/dist/camera-animation.d.ts +8 -0
  3. package/dist/camera-animation.d.ts.map +1 -1
  4. package/dist/camera-animation.js +10 -0
  5. package/dist/engine.d.ts +110 -0
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +746 -23
  8. package/dist/model.d.ts.map +1 -1
  9. package/dist/model.js +29 -0
  10. package/dist/physics/autofit.d.ts +147 -0
  11. package/dist/physics/autofit.d.ts.map +1 -0
  12. package/dist/physics/autofit.js +501 -0
  13. package/dist/shaders/audio-api.d.ts +3 -0
  14. package/dist/shaders/audio-api.d.ts.map +1 -0
  15. package/dist/shaders/audio-api.js +81 -0
  16. package/dist/shaders/passes/composite.d.ts +10 -0
  17. package/dist/shaders/passes/composite.d.ts.map +1 -1
  18. package/dist/shaders/passes/composite.js +75 -12
  19. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  20. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  21. package/dist/shaders/passes/ground-noise.js +88 -0
  22. package/dist/shaders/passes/particles.d.ts +58 -0
  23. package/dist/shaders/passes/particles.d.ts.map +1 -0
  24. package/dist/shaders/passes/particles.js +351 -0
  25. package/dist/shaders/passes/trails.d.ts +47 -0
  26. package/dist/shaders/passes/trails.d.ts.map +1 -0
  27. package/dist/shaders/passes/trails.js +375 -0
  28. package/package.json +2 -2
  29. package/src/camera-animation.ts +11 -0
  30. package/src/engine.ts +806 -20
  31. package/src/model.ts +28 -0
  32. package/src/shaders/audio-api.ts +82 -0
  33. package/src/shaders/passes/composite.ts +76 -11
  34. package/src/shaders/passes/particles.ts +398 -0
  35. package/src/shaders/passes/trails.ts +406 -0
  36. package/dist/physics-debug.d.ts +0 -30
  37. package/dist/physics-debug.d.ts.map +0 -1
  38. package/dist/physics-debug.js +0 -526
  39. package/dist/shaders/materials/body.d.ts +0 -2
  40. package/dist/shaders/materials/body.d.ts.map +0 -1
  41. package/dist/shaders/materials/body.js +0 -95
  42. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  43. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  44. package/dist/shaders/materials/cloth_rough.js +0 -69
  45. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  46. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  47. package/dist/shaders/materials/cloth_smooth.js +0 -61
  48. package/dist/shaders/materials/default.d.ts +0 -2
  49. package/dist/shaders/materials/default.d.ts.map +0 -1
  50. package/dist/shaders/materials/default.js +0 -43
  51. package/dist/shaders/materials/eye.d.ts +0 -2
  52. package/dist/shaders/materials/eye.d.ts.map +0 -1
  53. package/dist/shaders/materials/eye.js +0 -60
  54. package/dist/shaders/materials/face.d.ts +0 -2
  55. package/dist/shaders/materials/face.d.ts.map +0 -1
  56. package/dist/shaders/materials/face.js +0 -95
  57. package/dist/shaders/materials/hair.d.ts +0 -2
  58. package/dist/shaders/materials/hair.d.ts.map +0 -1
  59. package/dist/shaders/materials/hair.js +0 -90
  60. package/dist/shaders/materials/metal.d.ts +0 -2
  61. package/dist/shaders/materials/metal.d.ts.map +0 -1
  62. package/dist/shaders/materials/metal.js +0 -77
  63. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  64. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  65. package/dist/shaders/materials/mmd_classic.js +0 -66
  66. package/dist/shaders/materials/stockings.d.ts +0 -2
  67. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  68. package/dist/shaders/materials/stockings.js +0 -122
  69. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  70. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  71. package/dist/shaders/passes/physics-debug.js +0 -69
@@ -1,3 +1,4 @@
1
+ import { audioApi } from "../audio-api";
1
2
  // Composite: HDR scene + bloom pyramid → Filmic tone map → gamma → swapchain.
2
3
  // Bloom tint/intensity applied at combine (EEVEE treats them as combine-stage params, not prefilter).
3
4
  //
@@ -148,6 +149,16 @@ override APPLY_GAMMA: bool = true;
148
149
  // vec4 slots: [0 .. 11] four subjects, three each (root+valid, hip, bounds);
149
150
  // then MAX_ANCHORS × four subjects, three each (pos+valid, vel, fwd).
150
151
  @group(0) @binding(11) var<storage, read> _rzCast: array<vec4f>;
152
+ // The FIELD LAYER: the user's background/foreground mounts, rendered at half
153
+ // resolution in their own pass (see buildFieldShader) and sampled here. Field
154
+ // effects are glows, fog, shafts, gradients — low-frequency by nature — and
155
+ // running them per quarter-pixel was the single largest per-frame cost an
156
+ // effect could add. Bilinear upsampling is invisible at that frequency.
157
+ @group(0) @binding(15) var fieldBgTex: texture_2d<f32>;
158
+ @group(0) @binding(16) var fieldFgTex: texture_2d<f32>;
159
+ // Geometry ribbons, max-blended in their own layer (trails.ts). Composited in
160
+ // display space below, where the fullscreen ribbon effects always ran.
161
+ @group(0) @binding(12) var trailTex: texture_2d<f32>;
151
162
 
152
163
  // Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
153
164
  const FILMIC_LUT_W: f32 = 256.0;
@@ -288,6 +299,11 @@ fn rzProject(p: vec3f) -> vec3f {
288
299
  return vec3f(ndc * 0.5 + 0.5, z);
289
300
  }
290
301
 
302
+ fn rzCamPos() -> vec3f { return rzCameraPos(); }
303
+ fn rzCameraRight() -> vec3f { return viewU[3].xyz; }
304
+ fn rzCameraUp() -> vec3f { return viewU[4].xyz; }
305
+ fn rzCameraForward() -> vec3f { return viewU[5].xyz; }
306
+
291
307
  /** A character, as much of one as a shader needs. */
292
308
  struct RzSubject {
293
309
  /** On the FLOOR, under the body — where a ring or a magic circle belongs. */
@@ -562,6 +578,11 @@ const COMPOSITE_BODY = /* wgsl */ `
562
578
  // expression, because the foreground mount composites onto it.
563
579
  var outRgb = disp * sceneAlpha + bgPm * (1.0 - sceneAlpha);
564
580
  var outA = sceneAlpha + bgA * (1.0 - sceneAlpha);
581
+ // The trail layer, straight-alpha over the tone-mapped frame — the author's
582
+ // colours verbatim, never through AgX.
583
+ let trailFx = textureLoad(trailTex, vec2i(fragCoord.xy), 0);
584
+ outRgb = trailFx.rgb * trailFx.a + outRgb * (1.0 - trailFx.a);
585
+ outA = trailFx.a + outA * (1.0 - trailFx.a);
565
586
  FOREGROUND_CALL
566
587
  return vec4f(outRgb, outA);
567
588
  }
@@ -571,8 +592,7 @@ const COMPOSITE_BODY = /* wgsl */ `
571
592
  // OVER onto the base layer. No `if` around it: the pipeline is rebuilt per
572
593
  // effect, so this text only exists in variants whose WGSL defines background().
573
594
  const BACKGROUND_CALL = /* wgsl */ `
574
- let bgUv = vec2f(fragCoord.x / fullSz.x, 1.0 - fragCoord.y / fullSz.y);
575
- let bgFx = clamp(background(dir, bgUv, viewU[6].x), vec4f(0.0), vec4f(1.0));
595
+ let bgFx = clamp(textureSampleLevel(fieldBgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0));
576
596
  bgPm = bgFx.rgb * bgFx.a + bgPm * (1.0 - bgFx.a);
577
597
  bgA = bgFx.a + bgA * (1.0 - bgFx.a);
578
598
  `;
@@ -580,12 +600,7 @@ const BACKGROUND_CALL = /* wgsl */ `
580
600
  // base. Ungated by design: a foreground runs at every pixel, including the ones
581
601
  // the model covers, because covering them is the point.
582
602
  const FOREGROUND_CALL = /* wgsl */ `
583
- let fgUv = vec2f(fragCoord.x / fullSz.x, 1.0 - fragCoord.y / fullSz.y);
584
- // The scene's own depth, so the effect can tell what is in front of it: a
585
- // petal compares its distance against this and lets the model take the pixel,
586
- // and fog's alpha is nothing but a function of it. Pixels the scene never drew
587
- // read the far plane, so distance fog closes over the backdrop too.
588
- let fgFx = clamp(foreground(dir, fgUv, viewU[6].x, linearDepth(coord)), vec4f(0.0), vec4f(1.0));
603
+ let fgFx = clamp(textureSampleLevel(fieldFgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0));
589
604
  outRgb = fgFx.rgb * fgFx.a + outRgb * (1.0 - fgFx.a);
590
605
  outA = fgFx.a + outA * (1.0 - fgFx.a);
591
606
  `;
@@ -610,24 +625,72 @@ const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/;
610
625
  function backgroundCondition(effect) {
611
626
  // sceneAlpha, not alpha: the bokeh gather spreads coverage, so a pixel the
612
627
  // sharp scene fully covered can end up needing background behind its blur.
628
+ // (The old derivative carve-out is gone with the inline user code: the field
629
+ // pass runs the whole quad, which is uniform control flow by construction.)
613
630
  const coverage = "sceneAlpha < 0.999";
614
631
  if (!effect?.hasBackground)
615
632
  return `bg.w > 1.5 && ${coverage}`;
616
- return USES_DERIVATIVES.test(effect.wgsl) ? "true" : coverage;
633
+ return coverage;
617
634
  }
618
635
  export function buildCompositeShader(effect) {
619
636
  const body = COMPOSITE_BODY.replace("BACKGROUND_COND", backgroundCondition(effect))
620
637
  .replace("BACKGROUND_CALL", effect?.hasBackground ? BACKGROUND_CALL.trim() : "")
621
638
  .replace("FOREGROUND_CALL", effect?.hasForeground ? FOREGROUND_CALL.trim() : "");
622
- if (!effect)
623
- return COMPOSITE_HEAD + body;
639
+ // The composite is STATIC either way now: the user's code compiles in the
640
+ // field module alone, and the composite only decides whether to sample it.
641
+ return COMPOSITE_HEAD + audioApi(0, 13) + body;
642
+ }
643
+ /**
644
+ * The field pass: the user's background/foreground mounts at half resolution,
645
+ * into two rgba16f targets the composite bilinearly upsamples.
646
+ *
647
+ * The fragment reconstructs the FULL-resolution pixel it stands in for and runs
648
+ * the original derivation verbatim — same ndc, same ray, same uv, same depth
649
+ * read — so an effect cannot tell it moved; it is simply asked half as often
650
+ * in each direction.
651
+ */
652
+ export function buildFieldShader(effect) {
653
+ const bgLine = effect.hasBackground
654
+ ? "out.bg = clamp(background(dir, uv, viewU[6].x), vec4f(0.0), vec4f(1.0));"
655
+ : "";
656
+ const fgLine = effect.hasForeground
657
+ ? "out.fg = clamp(foreground(dir, uv, viewU[6].x, linearDepth(vec2<i32>(min(fx, fullSz - 1.0)))), vec4f(0.0), vec4f(1.0));"
658
+ : "";
624
659
  return (COMPOSITE_HEAD +
660
+ audioApi(0, 13) +
625
661
  "\n// ── user effect (setEffect) ──\n" +
626
662
  effect.paramsDecl +
627
663
  "\n" +
628
664
  effect.wgsl +
629
665
  "\n" +
630
- body);
666
+ /* wgsl */ `
667
+ @group(0) @binding(14) var<uniform> fieldU: vec4f;
668
+
669
+ @vertex fn fieldVs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
670
+ let x = f32((vi & 1u) << 2u) - 1.0;
671
+ let y = f32((vi & 2u) << 1u) - 1.0;
672
+ return vec4f(x, y, 0.0, 1.0);
673
+ }
674
+
675
+ struct FieldOut {
676
+ @location(0) bg: vec4f,
677
+ @location(1) fg: vec4f,
678
+ }
679
+
680
+ @fragment fn fieldFs(@builtin(position) fragCoord: vec4f) -> FieldOut {
681
+ let fullSz = fieldU.zw;
682
+ let fx = fragCoord.xy * (fullSz / max(fieldU.xy, vec2f(1.0)));
683
+ let ndc = vec2f(fx.x / fullSz.x * 2.0 - 1.0, 1.0 - fx.y / fullSz.y * 2.0);
684
+ let dir = normalize(viewU[5].xyz + ndc.x * viewU[3].w * viewU[3].xyz + ndc.y * viewU[4].w * viewU[4].xyz);
685
+ let uv = vec2f(fx.x / fullSz.x, 1.0 - fx.y / fullSz.y);
686
+ var out: FieldOut;
687
+ out.bg = vec4f(0.0);
688
+ out.fg = vec4f(0.0);
689
+ ${bgLine}
690
+ ${fgLine}
691
+ return out;
692
+ }
693
+ `);
631
694
  }
632
695
  /** Kept for compatibility with existing imports (the base, no-effect shader). */
633
696
  export const COMPOSITE_SHADER_WGSL = buildCompositeShader(null);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The tile as 8-bit luminance, plus its mip chain (box-filtered, which is exact
3
+ * for a repeating texture). Mips are the point as much as the speed: procedural
4
+ * noise has no mip chain, so at grazing angles it shimmered.
5
+ */
6
+ export declare function buildGroundNoiseMips(): Uint8Array[];
7
+ //# sourceMappingURL=ground-noise.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ground-noise.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground-noise.ts"],"names":[],"mappings":"AAkDA;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,UAAU,EAAE,CAsCnD"}
@@ -0,0 +1,88 @@
1
+ // Bakes the ground's frosted micro-texture into a seamless, mipmapped tile.
2
+ //
3
+ // The shader used to evaluate four octaves of value noise per pixel — sixteen
4
+ // hash rounds — and the ground covers the entire screen as soon as the camera
5
+ // tilts toward the horizon. This produces the same character of pattern once, at
6
+ // load, so the fragment shader pays one filtered fetch instead.
7
+ //
8
+ // SEAMLESS BY CONSTRUCTION. The original hash was not periodic, so a finite
9
+ // crop of it could never tile. Here every octave's lattice wraps at a whole
10
+ // number of cells across the tile, which makes the result repeat exactly at the
11
+ // edges. The pattern is therefore not pixel-identical to the analytic version —
12
+ // it is the same kind of noise, not the same noise. At the default strength of
13
+ // 0.05 it modulates brightness by ±2.5%, where the repeat is not findable by eye.
14
+ import { GROUND_NOISE_RES, GROUND_NOISE_TILE_WORLD } from "./ground";
15
+ /** Lattice cells across the tile at octave 0. The analytic version sampled fbm
16
+ * at worldXZ × 3, so a tile spanning 8 world units covers 24 base cells — the
17
+ * same spatial frequency the surface had before. */
18
+ const BASE_CELLS = GROUND_NOISE_TILE_WORLD * 3;
19
+ const OCTAVES = 4;
20
+ /** Deterministic lattice hash. Integer coordinates are wrapped by the caller,
21
+ * which is what makes the tile seamless. */
22
+ function hash(ix, iy) {
23
+ let h = Math.imul(ix, 374761393) + Math.imul(iy, 668265263);
24
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
25
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
26
+ }
27
+ /** Value noise on a lattice that repeats every `period` cells. */
28
+ function periodicValueNoise(x, y, period) {
29
+ const ix = Math.floor(x);
30
+ const iy = Math.floor(y);
31
+ const fx = x - ix;
32
+ const fy = y - iy;
33
+ // Smoothstep weights — same curve the WGSL used.
34
+ const ux = fx * fx * (3 - 2 * fx);
35
+ const uy = fy * fy * (3 - 2 * fy);
36
+ const x0 = ((ix % period) + period) % period;
37
+ const y0 = ((iy % period) + period) % period;
38
+ const x1 = (x0 + 1) % period;
39
+ const y1 = (y0 + 1) % period;
40
+ const a = hash(x0, y0);
41
+ const b = hash(x1, y0);
42
+ const c = hash(x0, y1);
43
+ const d = hash(x1, y1);
44
+ return (a + (b - a) * ux) * (1 - uy) + (c + (d - c) * ux) * uy;
45
+ }
46
+ /**
47
+ * The tile as 8-bit luminance, plus its mip chain (box-filtered, which is exact
48
+ * for a repeating texture). Mips are the point as much as the speed: procedural
49
+ * noise has no mip chain, so at grazing angles it shimmered.
50
+ */
51
+ export function buildGroundNoiseMips() {
52
+ const levels = [];
53
+ const res = GROUND_NOISE_RES;
54
+ const base = new Uint8Array(res * res);
55
+ for (let py = 0; py < res; py++) {
56
+ for (let px = 0; px < res; px++) {
57
+ // Texel centre, in units of octave-0 lattice cells.
58
+ const x = ((px + 0.5) / res) * BASE_CELLS;
59
+ const y = ((py + 0.5) / res) * BASE_CELLS;
60
+ let v = 0;
61
+ let amp = 0.5;
62
+ let freq = 1;
63
+ for (let o = 0; o < OCTAVES; o++) {
64
+ v += amp * periodicValueNoise(x * freq, y * freq, BASE_CELLS * freq);
65
+ freq *= 2;
66
+ amp *= 0.5;
67
+ }
68
+ base[py * res + px] = Math.max(0, Math.min(255, Math.round(v * 255)));
69
+ }
70
+ }
71
+ levels.push(base);
72
+ let src = base;
73
+ let size = res;
74
+ while (size > 1) {
75
+ const half = size >> 1;
76
+ const dst = new Uint8Array(half * half);
77
+ for (let y = 0; y < half; y++) {
78
+ for (let x = 0; x < half; x++) {
79
+ const s = y * 2 * size + x * 2;
80
+ dst[y * half + x] = (src[s] + src[s + 1] + src[s + size] + src[s + size + 1] + 2) >> 2;
81
+ }
82
+ }
83
+ levels.push(dst);
84
+ src = dst;
85
+ size = half;
86
+ }
87
+ return levels;
88
+ }
@@ -0,0 +1,58 @@
1
+ /** Where the cast/trail history sits in the shared storage buffer. */
2
+ export type CastLayout = {
3
+ subjects: number;
4
+ samples: number;
5
+ base: number;
6
+ trailBase: number;
7
+ slots: number;
8
+ };
9
+ /** How the author's quads combine with the scene. */
10
+ export type ParticleBlend = "alpha" | "additive";
11
+ export type ParticleSource = {
12
+ /** The author's WGSL verbatim. */
13
+ wgsl: string;
14
+ /** Simultaneous particles. Fixed at install; the pool recycles rather than grows. */
15
+ count: number;
16
+ blend: ParticleBlend;
17
+ /** Feed the bloom pyramid. Off by default — rain should not glow. */
18
+ bloom: boolean;
19
+ };
20
+ /** Bytes per particle. Explicitly padded — see the struct below. */
21
+ export declare const PARTICLE_STRIDE = 48;
22
+ /** `// @particles 4096` — how many live at once. */
23
+ export declare function parseParticleCount(wgsl: string, max: number): number;
24
+ /** `// @bloom` — opt in to the bloom pyramid. Sparks want it; rain does not. */
25
+ export declare function parseParticleBloom(wgsl: string): boolean;
26
+ /** `// @blend additive` — default is straight alpha. */
27
+ export declare function parseParticleBlend(wgsl: string): ParticleBlend;
28
+ /** Does the source define the particle contract? All three are required together. */
29
+ export declare function particleEntryPoints(wgsl: string): {
30
+ init: boolean;
31
+ step: boolean;
32
+ shade: boolean;
33
+ };
34
+ /**
35
+ * Spawn, age, recycle.
36
+ *
37
+ * One kernel for both birth and update, because a dead slot and a new particle
38
+ * are the same write — a pool that recycles has no allocation and therefore no
39
+ * spawn-rate bookkeeping to get wrong. The cost is that lifetimes are staggered
40
+ * only by whatever the author randomises in `particleInit`, which for rain and
41
+ * snow is exactly right, and for a burst is what the age offset is for.
42
+ */
43
+ export declare function buildParticleComputeShader(src: ParticleSource, cast: CastLayout): string;
44
+ /**
45
+ * One camera-facing quad per live particle.
46
+ *
47
+ * Six vertices, no index or vertex buffer: the corners are derived from
48
+ * `vertex_index` and the particle is read from storage by `instance_index`, so a
49
+ * draw is `draw(6, count)` and there is nothing to upload per frame. The billboard
50
+ * basis comes from the VIEW matrix's rows rather than from a look-at, which keeps
51
+ * the quad square on screen no matter where the camera rolls.
52
+ *
53
+ * A dead particle collapses to a degenerate quad instead of being culled on the
54
+ * CPU — the alternative is a compacted draw list, which costs a prefix sum and a
55
+ * readback every frame to save vertices the rasteriser was going to reject anyway.
56
+ */
57
+ export declare function buildParticleRenderShader(src: ParticleSource, cast: CastLayout): string;
58
+ //# sourceMappingURL=particles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"particles.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/particles.ts"],"names":[],"mappings":"AAkBA,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAmC9G,qDAAqD;AACrD,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,UAAU,CAAA;AAEhD,MAAM,MAAM,cAAc,GAAG;IAC3B,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,aAAa,CAAA;IACpB,qEAAqE;IACrE,KAAK,EAAE,OAAO,CAAA;CACf,CAAA;AAED,oEAAoE;AACpE,eAAO,MAAM,eAAe,KAAK,CAAA;AAiIjC,oDAAoD;AACpD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAOpE;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,wDAAwD;AACxD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAE9D;AAED,qFAAqF;AACrF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAMlG;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAwCxF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CA2GvF"}
@@ -0,0 +1,351 @@
1
+ import { audioApi } from "../audio-api";
2
+ /**
3
+ * The trail accessors, in the PARTICLE module.
4
+ *
5
+ * Sparks are the reason: the original hand ribbon shed sparks along its path,
6
+ * and as real particles they need to SPAWN on that path — which means
7
+ * particleInit reading the same recorded history the trail draws from. One
8
+ * effect file, two mounts, one buffer.
9
+ */
10
+ function castApi(cast) {
11
+ return `
12
+ const RZ_SUBJECTS: i32 = ${cast.subjects};
13
+ const RZ_SAMPLES: i32 = ${cast.samples};
14
+ const RZ_TRAIL_SLOTS: i32 = ${cast.slots};
15
+ fn rzSubjectCount() -> i32 {
16
+ var n = 0;
17
+ for (var i = 0; i < RZ_SUBJECTS; i++) {
18
+ if (_rzCast[i * 3 + 2].w > 0.0) { n = i + 1; }
19
+ }
20
+ return n;
21
+ }
22
+ fn rzTrailCount(subject: i32, slot: i32) -> i32 {
23
+ if (subject < 0 || subject >= RZ_SUBJECTS || slot < 0 || slot >= RZ_TRAIL_SLOTS) { return 0; }
24
+ return i32(_rzCast[${cast.base} + (slot * RZ_SUBJECTS + subject) * 3 + 2].w);
25
+ }
26
+ /** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */
27
+ fn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {
28
+ let n = rzTrailCount(subject, slot);
29
+ if (i < 0 || i >= n) { return vec4f(0.0); }
30
+ return _rzCast[${cast.trailBase} + (slot * RZ_SUBJECTS + subject) * RZ_SAMPLES + i];
31
+ }
32
+ `;
33
+ }
34
+ /** Bytes per particle. Explicitly padded — see the struct below. */
35
+ export const PARTICLE_STRIDE = 48;
36
+ /**
37
+ * The particle record, laid out by hand.
38
+ *
39
+ * `age` and `life` sit in the padding that vec3f alignment would waste anyway
40
+ * (a vec3f occupies 12 bytes but aligns the next field to 16), so the struct is
41
+ * 48 bytes rather than the 64 a naive ordering costs. At 4096 particles that is
42
+ * 192KB instead of 256KB, and it is read every frame by both stages.
43
+ *
44
+ * `life <= 0` means "not alive" and is what the pool checks to recycle a slot,
45
+ * so a freshly zeroed buffer is entirely dead and every particle is born on the
46
+ * first step rather than needing a separate seeding pass.
47
+ */
48
+ const PARTICLE_STRUCT = /* wgsl */ `
49
+ struct Particle {
50
+ pos: vec3f,
51
+ age: f32,
52
+ vel: vec3f,
53
+ life: f32,
54
+ size: f32,
55
+ rot: f32,
56
+ seed: f32,
57
+ // Aspect along the direction of travel. 1 or less is a square billboard; a
58
+ // raindrop is 10 or 20. Zero-initialised, so an effect that never sets it gets
59
+ // the square it expects.
60
+ stretch: f32,
61
+ }
62
+ `;
63
+ const CAMERA_STRUCT = /* wgsl */ `
64
+ struct CameraU {
65
+ view: mat4x4f,
66
+ proj: mat4x4f,
67
+ camPos: vec3f,
68
+ targetHeight: f32,
69
+ }
70
+ `;
71
+ const PARTICLE_UNIFORMS = /* wgsl */ `
72
+ struct ParticleU {
73
+ time: f32,
74
+ dt: f32,
75
+ count: u32,
76
+ frame: u32,
77
+ }
78
+ `;
79
+ /**
80
+ * The shared prelude, everything `rz`-prefixed.
81
+ *
82
+ * Not convenience — correctness. Every effect written against the old contract
83
+ * re-derived its own hash and its own falloff, which is duplicated code and
84
+ * duplicated bugs; `rzFalloff` in particular has COMPACT SUPPORT (it reaches
85
+ * exactly zero at r), because an exponential glow that never quite reaches zero
86
+ * has to be culled somewhere, and culling it wherever it "looks close enough"
87
+ * is what put a visible hard edge on the first halo effect.
88
+ */
89
+ const PRELUDE = /* wgsl */ `
90
+ fn rzHash11(x: f32) -> f32 {
91
+ var p = fract(x * 0.1031);
92
+ p = p * (p + 33.33);
93
+ return fract(p * (p + p));
94
+ }
95
+ fn rzHash21(p: vec2f) -> f32 {
96
+ var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
97
+ p3 = p3 + dot(p3, p3.yzx + 33.33);
98
+ return fract((p3.x + p3.y) * p3.z);
99
+ }
100
+ fn rzHash31(p: vec3f) -> f32 {
101
+ var p3 = fract(p * 0.1031);
102
+ p3 = p3 + dot(p3, p3.zyx + 31.32);
103
+ return fract((p3.x + p3.y) * p3.z);
104
+ }
105
+ /** Three independent randoms from one seed — the usual need when spawning. */
106
+ fn rzHash13(x: f32) -> vec3f {
107
+ return vec3f(rzHash11(x), rzHash11(x + 17.13), rzHash11(x + 41.71));
108
+ }
109
+ fn rzValueNoise(p: vec3f) -> f32 {
110
+ let i = floor(p);
111
+ let f = fract(p);
112
+ let u = f * f * (3.0 - 2.0 * f);
113
+ let n000 = rzHash31(i + vec3f(0.0, 0.0, 0.0));
114
+ let n100 = rzHash31(i + vec3f(1.0, 0.0, 0.0));
115
+ let n010 = rzHash31(i + vec3f(0.0, 1.0, 0.0));
116
+ let n110 = rzHash31(i + vec3f(1.0, 1.0, 0.0));
117
+ let n001 = rzHash31(i + vec3f(0.0, 0.0, 1.0));
118
+ let n101 = rzHash31(i + vec3f(1.0, 0.0, 1.0));
119
+ let n011 = rzHash31(i + vec3f(0.0, 1.0, 1.0));
120
+ let n111 = rzHash31(i + vec3f(1.0, 1.0, 1.0));
121
+ let x00 = mix(n000, n100, u.x);
122
+ let x10 = mix(n010, n110, u.x);
123
+ let x01 = mix(n001, n101, u.x);
124
+ let x11 = mix(n011, n111, u.x);
125
+ return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);
126
+ }
127
+ /** Divergence-free flow — the standard drifting-air force. Snow and mist want this. */
128
+ fn rzCurlNoise(p: vec3f) -> vec3f {
129
+ let e = 0.1;
130
+ let dx = vec3f(e, 0.0, 0.0);
131
+ let dy = vec3f(0.0, e, 0.0);
132
+ let dz = vec3f(0.0, 0.0, e);
133
+ let x0 = rzValueNoise(p - dx); let x1 = rzValueNoise(p + dx);
134
+ let y0 = rzValueNoise(p - dy); let y1 = rzValueNoise(p + dy);
135
+ let z0 = rzValueNoise(p - dz); let z1 = rzValueNoise(p + dz);
136
+ return normalize(vec3f((y1 - y0) - (z1 - z0), (z1 - z0) - (x1 - x0), (x1 - x0) - (y1 - y0)) + vec3f(1e-6));
137
+ }
138
+ /** Compact-support falloff: 1 at the centre, exactly 0 at r, smooth between. */
139
+ fn rzFalloff(d: f32, r: f32) -> f32 {
140
+ let x = clamp(d / max(r, 1e-6), 0.0, 1.0);
141
+ let f = 1.0 - x;
142
+ return f * f * f;
143
+ }
144
+ fn rzTime() -> f32 { return pu.time; }
145
+ fn rzViewportHeight() -> f32 { return cam.targetHeight; }
146
+ fn rzCameraPos() -> vec3f { return cam.camPos; }
147
+ fn rzCameraRight() -> vec3f { return vec3f(cam.view[0][0], cam.view[1][0], cam.view[2][0]); }
148
+ fn rzCameraUp() -> vec3f { return vec3f(cam.view[0][1], cam.view[1][1], cam.view[2][1]); }
149
+ fn rzCameraForward() -> vec3f { return vec3f(cam.view[0][2], cam.view[1][2], cam.view[2][2]); }
150
+ /** World point → (uv, view distance), same contract as the field mounts' rzProject. */
151
+ fn rzProject(p: vec3f) -> vec3f {
152
+ let clip = cam.proj * cam.view * vec4f(p, 1.0);
153
+ let w = max(clip.w, 1e-4);
154
+ return vec3f(clip.xy / w * 0.5 + 0.5, clip.w);
155
+ }
156
+ fn rzDt() -> f32 { return pu.dt; }
157
+ fn rzCamPos() -> vec3f { return cam.camPos; }
158
+ `;
159
+ /** `// @particles 4096` — how many live at once. */
160
+ export function parseParticleCount(wgsl, max) {
161
+ const m = /^\s*\/\/\s*@particles\s+(\d+)\s*$/m.exec(wgsl);
162
+ if (!m)
163
+ return 0;
164
+ // Clamped rather than rejected: an author asking for a million gets the most
165
+ // the engine will give and a scene that still runs, which is a better failure
166
+ // than a compile error naming a number they had no way to know.
167
+ return Math.max(1, Math.min(max, parseInt(m[1], 10)));
168
+ }
169
+ /** `// @bloom` — opt in to the bloom pyramid. Sparks want it; rain does not. */
170
+ export function parseParticleBloom(wgsl) {
171
+ return /^\s*\/\/\s*@bloom\s*$/m.test(wgsl);
172
+ }
173
+ /** `// @blend additive` — default is straight alpha. */
174
+ export function parseParticleBlend(wgsl) {
175
+ return /^\s*\/\/\s*@blend\s+additive\s*$/m.test(wgsl) ? "additive" : "alpha";
176
+ }
177
+ /** Does the source define the particle contract? All three are required together. */
178
+ export function particleEntryPoints(wgsl) {
179
+ return {
180
+ init: /\bfn\s+particleInit\s*\(/.test(wgsl),
181
+ step: /\bfn\s+particleStep\s*\(/.test(wgsl),
182
+ shade: /\bfn\s+particleShade\s*\(/.test(wgsl),
183
+ };
184
+ }
185
+ /**
186
+ * Spawn, age, recycle.
187
+ *
188
+ * One kernel for both birth and update, because a dead slot and a new particle
189
+ * are the same write — a pool that recycles has no allocation and therefore no
190
+ * spawn-rate bookkeeping to get wrong. The cost is that lifetimes are staggered
191
+ * only by whatever the author randomises in `particleInit`, which for rain and
192
+ * snow is exactly right, and for a burst is what the age offset is for.
193
+ */
194
+ export function buildParticleComputeShader(src, cast) {
195
+ return (PARTICLE_STRUCT +
196
+ CAMERA_STRUCT +
197
+ PARTICLE_UNIFORMS +
198
+ `
199
+ @group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
200
+ @group(0) @binding(1) var<uniform> pu: ParticleU;
201
+ @group(0) @binding(2) var<uniform> cam: CameraU;
202
+ @group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
203
+ ` +
204
+ castApi(cast) +
205
+ audioApi(0, 4) +
206
+ PRELUDE +
207
+ "\n// ── user effect ──\n" +
208
+ src.wgsl +
209
+ /* wgsl */ `
210
+ @compute @workgroup_size(64)
211
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
212
+ let i = gid.x;
213
+ if (i >= pu.count) { return; }
214
+ var p = particles[i];
215
+ if (p.life <= 0.0 || p.age >= p.life) {
216
+ // The seed is stable per SLOT and per generation, so a particle looks the
217
+ // same every time the scene is replayed at the same moment — which is what
218
+ // keeps an exported video identical to the preview.
219
+ let generation = floor(pu.time * 0.37) + f32(i) * 0.618;
220
+ p = particleInit(i, rzHash11(generation));
221
+ // age is NOT reset here. WGSL zero-initialises a var, so an author who
222
+ // ignores it starts at zero anyway — while one who sets it to a fraction of
223
+ // its life staggers the pool, which is the difference between snow and a
224
+ // pulse of snow arriving all at once every few seconds.
225
+ } else {
226
+ p = particleStep(p, pu.dt);
227
+ p.age = p.age + pu.dt;
228
+ }
229
+ particles[i] = p;
230
+ }
231
+ `);
232
+ }
233
+ /**
234
+ * One camera-facing quad per live particle.
235
+ *
236
+ * Six vertices, no index or vertex buffer: the corners are derived from
237
+ * `vertex_index` and the particle is read from storage by `instance_index`, so a
238
+ * draw is `draw(6, count)` and there is nothing to upload per frame. The billboard
239
+ * basis comes from the VIEW matrix's rows rather than from a look-at, which keeps
240
+ * the quad square on screen no matter where the camera rolls.
241
+ *
242
+ * A dead particle collapses to a degenerate quad instead of being culled on the
243
+ * CPU — the alternative is a compacted draw list, which costs a prefix sum and a
244
+ * readback every frame to save vertices the rasteriser was going to reject anyway.
245
+ */
246
+ export function buildParticleRenderShader(src, cast) {
247
+ return (`override BLOOM: bool = ${src.bloom ? "true" : "false"};
248
+ override ADDITIVE: bool = ${src.blend === "additive" ? "true" : "false"};\n` +
249
+ PARTICLE_STRUCT +
250
+ CAMERA_STRUCT +
251
+ PARTICLE_UNIFORMS +
252
+ `
253
+ @group(0) @binding(0) var<storage, read> particles: array<Particle>;
254
+ @group(0) @binding(1) var<uniform> pu: ParticleU;
255
+ @group(0) @binding(2) var<uniform> cam: CameraU;
256
+ @group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
257
+ ` +
258
+ castApi(cast) +
259
+ audioApi(0, 4) +
260
+ PRELUDE +
261
+ "\n// ── user effect ──\n" +
262
+ src.wgsl +
263
+ /* wgsl */ `
264
+ struct VSOut {
265
+ @builtin(position) clip: vec4f,
266
+ @location(0) uv: vec2f,
267
+ @location(1) @interpolate(flat) id: u32,
268
+ }
269
+
270
+ @vertex
271
+ fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
272
+ var out: VSOut;
273
+ out.id = ii;
274
+ let p = particles[ii];
275
+ // Two triangles, corners in the order 0,1,2, 2,1,3.
276
+ let quad = array<vec2f, 6>(
277
+ vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
278
+ vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0),
279
+ );
280
+ let c = quad[vi];
281
+ out.uv = c * 0.5 + 0.5;
282
+ if (p.life <= 0.0) {
283
+ // Degenerate: off the near plane, rasterises nothing.
284
+ out.clip = vec4f(0.0, 0.0, -2.0, 1.0);
285
+ return out;
286
+ }
287
+ let right = vec3f(cam.view[0][0], cam.view[1][0], cam.view[2][0]);
288
+ let up = vec3f(cam.view[0][1], cam.view[1][1], cam.view[2][1]);
289
+ let s = sin(p.rot);
290
+ let k = cos(p.rot);
291
+ var r = vec2f(c.x * k - c.y * s, c.x * s + c.y * k);
292
+ // Stretched along the direction of travel ON SCREEN — which is not the world
293
+ // direction once the camera is off-axis. Rain falling straight down is nearly
294
+ // a point when viewed from above and a long streak from the side, and taking
295
+ // the velocity's components in the camera's own basis is what gets both right.
296
+ // Rotation is ignored while stretched: the velocity IS the orientation.
297
+ if (p.stretch > 1.0) {
298
+ let vr = dot(p.vel, right);
299
+ let vu = dot(p.vel, up);
300
+ let vlen = length(vec2f(vr, vu));
301
+ if (vlen > 1e-5) {
302
+ let d = vec2f(vr, vu) / vlen;
303
+ r = vec2f(d.y, -d.x) * c.x + d * (c.y * p.stretch);
304
+ }
305
+ }
306
+ let world = p.pos + (right * r.x + up * r.y) * p.size;
307
+ out.clip = cam.proj * cam.view * vec4f(world, 1.0);
308
+ return out;
309
+ }
310
+
311
+ struct FSOut {
312
+ @location(0) color: vec4f,
313
+ // The scene's aux target: (bloom mask, coverage). Materials write it, so a
314
+ // particle that skipped it would punch a hole in the mask of whatever it drew
315
+ // over.
316
+ //
317
+ // vec4f even though the target is rg8unorm and only .rg land: that target's
318
+ // blend factors reference SrcAlpha, and a fragment with no alpha channel is
319
+ // rejected outright — "reading alpha but it is missing from fragment output".
320
+ // The material shaders declare vec4f here for the same reason.
321
+ @location(1) mask: vec4f,
322
+ }
323
+
324
+ @fragment
325
+ fn fs(in: VSOut) -> FSOut {
326
+ let p = particles[in.id];
327
+ let c = particleShade(p, in.uv);
328
+ if (c.a <= 0.0) { discard; }
329
+ var out: FSOut;
330
+ // PREMULTIPLIED: the scene's colour target blends with srcFactor \"one\", so a
331
+ // straight-alpha fragment would come out over-bright wherever it is
332
+ // translucent — which is most of a soft particle.
333
+ out.color = vec4f(c.rgb * c.a, c.a);
334
+ // The mask's OPERATOR must match the colour's, or the composite invents bands.
335
+ //
336
+ // The composite divides the HDR colour by this coverage to un-premultiply
337
+ // before tone mapping. An ALPHA effect writes (gate, 1.0) and lets the
338
+ // src-alpha blend make alpha-over coverage, exactly as the materials do. An
339
+ // ADDITIVE effect's pipeline blends this target with factor ONE instead, and
340
+ // writes its values directly — coverage SUMS like the colour does, so
341
+ // Σ(rgb·a)/Σa returns the true colour even where the effect overlaps itself.
342
+ // With summed colour over alpha-over coverage, every self-overlap divided into
343
+ // a bright white bar — visible only over the background, because the model's
344
+ // own coverage is already 1 there and the divide is a no-op. That mismatch,
345
+ // not geometry, was the banding that survived every geometry fix.
346
+ let mg = select(vec2f(select(0.0, 1.0, BLOOM), 1.0), vec2f(select(0.0, c.a, BLOOM), c.a), ADDITIVE);
347
+ out.mask = vec4f(mg.x, mg.y, 0.0, c.a);
348
+ return out;
349
+ }
350
+ `);
351
+ }