reze-engine 0.42.3 → 0.50.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 (140) hide show
  1. package/README.md +40 -410
  2. package/dist/camera.d.ts +3 -0
  3. package/dist/camera.d.ts.map +1 -1
  4. package/dist/camera.js +33 -8
  5. package/dist/engine.d.ts +924 -29
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +4043 -278
  8. package/dist/graph/registry.d.ts +2 -2
  9. package/dist/graph/registry.d.ts.map +1 -1
  10. package/dist/graph/registry.js +1 -1
  11. package/dist/graph/slots.d.ts +0 -1
  12. package/dist/graph/slots.d.ts.map +1 -1
  13. package/dist/graph/slots.js +37 -9
  14. package/dist/hdr.d.ts +18 -0
  15. package/dist/hdr.d.ts.map +1 -0
  16. package/dist/hdr.js +162 -0
  17. package/dist/ibl.d.ts +19 -0
  18. package/dist/ibl.d.ts.map +1 -0
  19. package/dist/ibl.js +113 -0
  20. package/dist/ik-solver.d.ts +2 -1
  21. package/dist/ik-solver.d.ts.map +1 -1
  22. package/dist/index.d.ts +5 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +10 -0
  25. package/dist/math.d.ts +20 -1
  26. package/dist/math.d.ts.map +1 -1
  27. package/dist/math.js +23 -16
  28. package/dist/midi-loader.d.ts +10 -0
  29. package/dist/midi-loader.d.ts.map +1 -0
  30. package/dist/midi-loader.js +247 -0
  31. package/dist/model.d.ts +2 -13
  32. package/dist/model.d.ts.map +1 -1
  33. package/dist/model.js +29 -0
  34. package/dist/param-track.d.ts +48 -0
  35. package/dist/param-track.d.ts.map +1 -0
  36. package/dist/param-track.js +80 -0
  37. package/dist/physics/types.d.ts.map +1 -1
  38. package/dist/physics/types.js +3 -0
  39. package/dist/reflection.d.ts +27 -0
  40. package/dist/reflection.d.ts.map +1 -0
  41. package/dist/reflection.js +93 -0
  42. package/dist/shaders/anchor-table.d.ts +56 -0
  43. package/dist/shaders/anchor-table.d.ts.map +1 -0
  44. package/dist/shaders/anchor-table.js +128 -0
  45. package/dist/shaders/audio-api.d.ts +3 -0
  46. package/dist/shaders/audio-api.d.ts.map +1 -0
  47. package/dist/shaders/audio-api.js +81 -0
  48. package/dist/shaders/cast-api.d.ts +2 -0
  49. package/dist/shaders/cast-api.d.ts.map +1 -0
  50. package/dist/shaders/cast-api.js +121 -0
  51. package/dist/shaders/cast-layout.d.ts +21 -0
  52. package/dist/shaders/cast-layout.d.ts.map +1 -0
  53. package/dist/shaders/cast-layout.js +20 -0
  54. package/dist/shaders/lights.d.ts +79 -0
  55. package/dist/shaders/lights.d.ts.map +1 -0
  56. package/dist/shaders/lights.js +269 -0
  57. package/dist/shaders/lyrics-api.d.ts +39 -0
  58. package/dist/shaders/lyrics-api.d.ts.map +1 -0
  59. package/dist/shaders/lyrics-api.js +187 -0
  60. package/dist/shaders/materials/common.d.ts +2 -4
  61. package/dist/shaders/materials/common.d.ts.map +1 -1
  62. package/dist/shaders/materials/common.js +87 -35
  63. package/dist/shaders/midi-api.d.ts +10 -0
  64. package/dist/shaders/midi-api.d.ts.map +1 -0
  65. package/dist/shaders/midi-api.js +114 -0
  66. package/dist/shaders/passes/composite.d.ts +31 -15
  67. package/dist/shaders/passes/composite.d.ts.map +1 -1
  68. package/dist/shaders/passes/composite.js +225 -135
  69. package/dist/shaders/passes/cull.d.ts +2 -0
  70. package/dist/shaders/passes/cull.d.ts.map +1 -0
  71. package/dist/shaders/passes/cull.js +138 -0
  72. package/dist/shaders/passes/field-blit.d.ts +26 -0
  73. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  74. package/dist/shaders/passes/field-blit.js +65 -0
  75. package/dist/shaders/passes/grid.d.ts +31 -0
  76. package/dist/shaders/passes/grid.d.ts.map +1 -0
  77. package/dist/shaders/passes/grid.js +169 -0
  78. package/dist/shaders/passes/ground.d.ts +13 -1
  79. package/dist/shaders/passes/ground.d.ts.map +1 -1
  80. package/dist/shaders/passes/ground.js +170 -25
  81. package/dist/shaders/passes/hosted-api.d.ts +57 -0
  82. package/dist/shaders/passes/hosted-api.d.ts.map +1 -0
  83. package/dist/shaders/passes/hosted-api.js +166 -0
  84. package/dist/shaders/passes/id-debug.d.ts +28 -0
  85. package/dist/shaders/passes/id-debug.d.ts.map +1 -0
  86. package/dist/shaders/passes/id-debug.js +74 -0
  87. package/dist/shaders/passes/particles.d.ts +66 -0
  88. package/dist/shaders/passes/particles.d.ts.map +1 -0
  89. package/dist/shaders/passes/particles.js +279 -0
  90. package/dist/shaders/passes/scene-contract.d.ts +128 -0
  91. package/dist/shaders/passes/scene-contract.d.ts.map +1 -0
  92. package/dist/shaders/passes/scene-contract.js +207 -0
  93. package/dist/shaders/passes/sim.d.ts +34 -0
  94. package/dist/shaders/passes/sim.d.ts.map +1 -0
  95. package/dist/shaders/passes/sim.js +169 -0
  96. package/dist/shaders/passes/trails.d.ts +59 -0
  97. package/dist/shaders/passes/trails.d.ts.map +1 -0
  98. package/dist/shaders/passes/trails.js +340 -0
  99. package/dist/shaders/score-api.d.ts +10 -0
  100. package/dist/shaders/score-api.d.ts.map +1 -0
  101. package/dist/shaders/score-api.js +114 -0
  102. package/dist/shadow-cascades.d.ts +45 -0
  103. package/dist/shadow-cascades.d.ts.map +1 -0
  104. package/dist/shadow-cascades.js +70 -0
  105. package/dist/vmd-loader.d.ts +3 -2
  106. package/dist/vmd-loader.d.ts.map +1 -1
  107. package/package.json +1 -1
  108. package/src/camera.ts +31 -8
  109. package/src/engine.ts +4535 -296
  110. package/src/graph/registry.ts +2 -2
  111. package/src/graph/slots.ts +37 -9
  112. package/src/hdr.ts +156 -0
  113. package/src/ibl.ts +115 -0
  114. package/src/ik-solver.ts +1 -1
  115. package/src/index.ts +12 -0
  116. package/src/math.ts +23 -17
  117. package/src/midi-loader.ts +246 -0
  118. package/src/model.ts +31 -3
  119. package/src/param-track.ts +83 -0
  120. package/src/physics/types.ts +4 -1
  121. package/src/reflection.ts +94 -0
  122. package/src/shaders/anchor-table.ts +147 -0
  123. package/src/shaders/audio-api.ts +82 -0
  124. package/src/shaders/cast-api.ts +123 -0
  125. package/src/shaders/cast-layout.ts +20 -0
  126. package/src/shaders/lights.ts +280 -0
  127. package/src/shaders/lyrics-api.ts +202 -0
  128. package/src/shaders/materials/common.ts +89 -35
  129. package/src/shaders/midi-api.ts +116 -0
  130. package/src/shaders/passes/composite.ts +244 -136
  131. package/src/shaders/passes/cull.ts +139 -0
  132. package/src/shaders/passes/grid.ts +178 -0
  133. package/src/shaders/passes/ground.ts +172 -25
  134. package/src/shaders/passes/hosted-api.ts +171 -0
  135. package/src/shaders/passes/id-debug.ts +75 -0
  136. package/src/shaders/passes/particles.ts +340 -0
  137. package/src/shaders/passes/scene-contract.ts +266 -0
  138. package/src/shaders/passes/trails.ts +390 -0
  139. package/src/shadow-cascades.ts +97 -0
  140. package/src/vmd-loader.ts +2 -2
@@ -0,0 +1,166 @@
1
+ // The half of the effect API whose VALUES are module-local.
2
+ //
3
+ // An effect file is spliced WHOLE into every module it has a mount in. A file
4
+ // with a trail and a lightEmit compiles its trail code inside the light module,
5
+ // where it never runs but still has to resolve — so `rzTime()` has to exist
6
+ // there even though a compute pass that writes lamp positions has no obvious
7
+ // clock, and `rzFalloff()` has to exist there even though nothing calls it.
8
+ //
9
+ // EFFECT_SCENE_API (composite.ts) is the other half: names that mean the same
10
+ // thing everywhere, written once. These are the ones that cannot be, because
11
+ // each module keeps its clock and its viewport in a different uniform. So they
12
+ // are written once HERE and the expression is the parameter — which is the
13
+ // same trick the grid pass already used for one of them, generalised to all.
14
+ //
15
+ // The rule this file exists to make keepable: a hosting module includes every
16
+ // block below. Not the ones its own mount happens to need — every one, because
17
+ // what lands in it is the author's whole file.
18
+ //
19
+ // EXCEPT that EFFECT_SCENE_API already carries EFFECT_MATH_API and the Particle
20
+ // struct, so the three modules built on it take those two by inheritance and
21
+ // must not add them again. tests/hosted-api.test.mjs fails on a name declared
22
+ // twice for exactly this reason — it is the mistake sharing blocks invites.
23
+ /**
24
+ * The particle record, laid out by hand — declared in EVERY hosting module.
25
+ *
26
+ * Not only in the particle ones: `fn particleStep(p: Particle)` is part of the
27
+ * author's file, so the trail, field and lightEmit modules compile that
28
+ * signature as dead code and a missing struct is a compile error on a function
29
+ * the author was right to write. The trail module used to carry a hand-copied
30
+ * duplicate for exactly this reason.
31
+ *
32
+ * `age` and `life` sit in the padding that vec3f alignment would waste anyway
33
+ * (a vec3f occupies 12 bytes but aligns the next field to 16), so the struct is
34
+ * 48 bytes rather than the 64 a naive ordering costs. At 4096 particles that is
35
+ * 192KB instead of 256KB, and it is read every frame by both stages.
36
+ *
37
+ * `life <= 0` means "not alive" and is what the pool checks to recycle a slot,
38
+ * so a freshly zeroed buffer is entirely dead and every particle is born on the
39
+ * first step rather than needing a separate seeding pass.
40
+ *
41
+ * RzLight is the same kind of thing and lives in lights.ts, because it is that
42
+ * module's own type; every hosting module splices it for this same reason.
43
+ */
44
+ export const PARTICLE_STRUCT_WGSL = /* wgsl */ `
45
+ struct Particle {
46
+ pos: vec3f,
47
+ age: f32,
48
+ vel: vec3f,
49
+ life: f32,
50
+ size: f32,
51
+ rot: f32,
52
+ seed: f32,
53
+ // Aspect along the direction of travel. 1 or less is a square billboard; a
54
+ // raindrop is 10 or 20. Zero-initialised, so an effect that never sets it gets
55
+ // the square it expects.
56
+ stretch: f32,
57
+ }
58
+ `;
59
+ /**
60
+ * `rzTime`/`rzDt`, from whatever the module calls its clock.
61
+ *
62
+ * A module with no clock of its own passes a literal. Zero dt is honest there:
63
+ * nothing spliced into it is being stepped, so a kernel that integrates would
64
+ * integrate by nothing rather than by a plausible-looking wrong number.
65
+ */
66
+ export function clockApi(timeExpr, dtExpr) {
67
+ return /* wgsl */ `
68
+ fn rzTime() -> f32 { return ${timeExpr}; }
69
+ fn rzDt() -> f32 { return ${dtExpr}; }
70
+ `;
71
+ }
72
+ /** `rzViewportHeight` — the render target's height in pixels, which is what a
73
+ * point size in world units has to be divided by to become a pixel radius. */
74
+ export function viewportApi(heightExpr) {
75
+ return /* wgsl */ `
76
+ fn rzViewportHeight() -> f32 { return ${heightExpr}; }
77
+ `;
78
+ }
79
+ /**
80
+ * The one cast number that is PER EFFECT rather than per engine.
81
+ *
82
+ * Author-visible, and published effects loop over it, so its meaning is pinned:
83
+ * how many of THIS effect's anchors asked for a trail. Deliberately not the
84
+ * anchor address space — that is RZ_MAX_ANCHORS in CAST_API, and the two being
85
+ * one number was the old trail bug. Everything else about the cast's shape is a
86
+ * constant and lives there.
87
+ */
88
+ export function trailSlotsApi(trailCount) {
89
+ return /* wgsl */ `
90
+ const RZ_TRAIL_SLOTS: i32 = ${trailCount};
91
+ `;
92
+ }
93
+ /**
94
+ * Pure math — no bindings, no uniforms, identical in every module.
95
+ *
96
+ * These were duplicated in the particle and trail modules, byte for byte in the
97
+ * case of rzFalloff and to a `+ vec3f(0.0)` in the case of rzValueNoise. Two
98
+ * copies of a noise function is one bad merge away from an effect that looks
99
+ * different depending on which mount drew it, which is not a bug anyone would
100
+ * think to look for.
101
+ *
102
+ * EVERY hosting module includes this and none defines any of these itself, so
103
+ * "which module am I in" can never change what a helper returns.
104
+ */
105
+ export const EFFECT_MATH_API = /* wgsl */ `
106
+ fn rzHash11(x: f32) -> f32 {
107
+ var p = fract(x * 0.1031);
108
+ p = p * (p + 33.33);
109
+ return fract(p * (p + p));
110
+ }
111
+ fn rzHash21(p: vec2f) -> f32 {
112
+ var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
113
+ p3 = p3 + dot(p3, p3.yzx + 33.33);
114
+ return fract((p3.x + p3.y) * p3.z);
115
+ }
116
+ /** Three independent randoms from one seed — the usual need when spawning. */
117
+ fn rzHash13(x: f32) -> vec3f {
118
+ return vec3f(rzHash11(x), rzHash11(x + 17.13), rzHash11(x + 41.71));
119
+ }
120
+ /**
121
+ * Compact-support falloff: 1 at the centre, exactly 0 at r, smooth between.
122
+ *
123
+ * It reaches exactly zero rather than merely getting small, because a glow that
124
+ * never quite ends has to be culled somewhere, and culling it wherever it looks
125
+ * close enough is what put a visible hard edge on the first halo effect.
126
+ */
127
+ fn rzFalloff(d: f32, r: f32) -> f32 {
128
+ let x = clamp(d / max(r, 1e-6), 0.0, 1.0);
129
+ let f = 1.0 - x;
130
+ return f * f * f;
131
+ }
132
+ fn rzHash31(p: vec3f) -> f32 {
133
+ var p3 = fract(p * 0.1031);
134
+ p3 = p3 + dot(p3, p3.zyx + 31.32);
135
+ return fract((p3.x + p3.y) * p3.z);
136
+ }
137
+ fn rzValueNoise(p: vec3f) -> f32 {
138
+ let i = floor(p);
139
+ let f = fract(p);
140
+ let u = f * f * (3.0 - 2.0 * f);
141
+ let n000 = rzHash31(i);
142
+ let n100 = rzHash31(i + vec3f(1.0, 0.0, 0.0));
143
+ let n010 = rzHash31(i + vec3f(0.0, 1.0, 0.0));
144
+ let n110 = rzHash31(i + vec3f(1.0, 1.0, 0.0));
145
+ let n001 = rzHash31(i + vec3f(0.0, 0.0, 1.0));
146
+ let n101 = rzHash31(i + vec3f(1.0, 0.0, 1.0));
147
+ let n011 = rzHash31(i + vec3f(0.0, 1.0, 1.0));
148
+ let n111 = rzHash31(i + vec3f(1.0, 1.0, 1.0));
149
+ let x00 = mix(n000, n100, u.x);
150
+ let x10 = mix(n010, n110, u.x);
151
+ let x01 = mix(n001, n101, u.x);
152
+ let x11 = mix(n011, n111, u.x);
153
+ return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);
154
+ }
155
+ /** Divergence-free flow — the field a wisp of smoke follows without a solver. */
156
+ fn rzCurlNoise(p: vec3f) -> vec3f {
157
+ let e = 0.1;
158
+ let dx = vec3f(e, 0.0, 0.0);
159
+ let dy = vec3f(0.0, e, 0.0);
160
+ let dz = vec3f(0.0, 0.0, e);
161
+ let x0 = rzValueNoise(p - dx); let x1 = rzValueNoise(p + dx);
162
+ let y0 = rzValueNoise(p - dy); let y1 = rzValueNoise(p + dy);
163
+ let z0 = rzValueNoise(p - dz); let z1 = rzValueNoise(p + dz);
164
+ return normalize(vec3f((y1 - y0) - (z1 - z0), (z1 - z0) - (x1 - x0), (x1 - x0) - (y1 - y0)) + vec3f(1e-6));
165
+ }
166
+ `;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The id attachment, drawn so a person can look at it.
3
+ *
4
+ * The id buffer is correct or incorrect in ways nothing else in the frame can
5
+ * show: with no consumer, a perfect id buffer and a completely wrong one
6
+ * produce exactly the same picture. This pass exists so that "it works" is
7
+ * something seen rather than inferred.
8
+ *
9
+ * WHAT CORRECT LOOKS LIKE, and what each failure looks like instead:
10
+ *
11
+ * - Every material is ONE FLAT COLOUR, with hard edges. Ids are names, not
12
+ * quantities: a gradient anywhere, or fringing along an edge, means
13
+ * something is interpolating or resolving them — the two failures the
14
+ * no-resolve/no-blend rules exist to prevent.
15
+ * - Parts differ from each other. A whole model in one colour means the
16
+ * material index never made it into the uniform.
17
+ * - The FLOOR is a fixed light grey, the reserved id, and never shares a
18
+ * colour with a body part.
19
+ * - Where nothing was drawn is BLACK — id 0, the reserved nothing, which is
20
+ * also what the attachment clears to. Black *over geometry* means that draw
21
+ * wrote no id; colour where the sky should be means the clear is wrong.
22
+ *
23
+ * Colours come from a hash of the pair, so neighbouring materials land on
24
+ * unrelated hues rather than adjacent shades of one — the point is telling them
25
+ * apart, not ordering them.
26
+ */
27
+ export declare const ID_DEBUG_SHADER_WGSL = "\n// Multisampled and NEVER resolved, so it is read the way it is written: one\n// sample, by texel. Sample 0 is what every consumer of this attachment reads.\n@group(0) @binding(0) var idTex: texture_multisampled_2d<u32>;\n\n@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {\n let x = f32((vi & 1u) << 2u) - 1.0;\n let y = f32((vi & 2u) << 1u) - 1.0;\n return vec4f(x, y, 0.0, 1.0);\n}\n\n/** Integer hash \u2192 0..1. Distinct inputs land on unrelated outputs, which is the\n * whole requirement: adjacent ids must not read as adjacent colours. */\nfn hashId(x: u32) -> f32 {\n var h = x * 747796405u + 2891336453u;\n h = ((h >> ((h >> 28u) + 4u)) ^ h) * 277803737u;\n return f32((h >> 22u) ^ h) / 4294967295.0;\n}\n\n@fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {\n let ids = textureLoad(idTex, vec2<i32>(fragCoord.xy), 0);\n let materialId = ids.x;\n let objectId = ids.y;\n\n // Nothing drawn here. Black, and it must cover exactly the empty space.\n if (materialId == 0u && objectId == 0u) {\n return vec4f(0.0, 0.0, 0.0, 1.0);\n }\n // The floor, at the top of the range. Flat light grey so it is unmistakable\n // and cannot be confused with a hashed colour.\n if (materialId == 65535u) {\n return vec4f(0.72, 0.72, 0.75, 1.0);\n }\n // Hue from the material, brightness from the object, so two models wearing\n // the same material index still read apart.\n let h = hashId(materialId * 1973u + 9277u);\n let shade = 0.55 + 0.45 * hashId(objectId * 6151u + 1u);\n // Cheap hue ramp: three offset cosines. Saturated on purpose \u2014 this is a\n // diagnostic, not a look.\n let rgb = vec3f(\n 0.5 + 0.5 * cos(6.28318 * (h + 0.00)),\n 0.5 + 0.5 * cos(6.28318 * (h + 0.33)),\n 0.5 + 0.5 * cos(6.28318 * (h + 0.67)),\n );\n return vec4f(rgb * shade, 1.0);\n}\n";
28
+ //# sourceMappingURL=id-debug.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"id-debug.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/id-debug.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,oBAAoB,20DA8ChC,CAAA"}
@@ -0,0 +1,74 @@
1
+ import { GROUND_MATERIAL_ID } from "./ground";
2
+ /**
3
+ * The id attachment, drawn so a person can look at it.
4
+ *
5
+ * The id buffer is correct or incorrect in ways nothing else in the frame can
6
+ * show: with no consumer, a perfect id buffer and a completely wrong one
7
+ * produce exactly the same picture. This pass exists so that "it works" is
8
+ * something seen rather than inferred.
9
+ *
10
+ * WHAT CORRECT LOOKS LIKE, and what each failure looks like instead:
11
+ *
12
+ * - Every material is ONE FLAT COLOUR, with hard edges. Ids are names, not
13
+ * quantities: a gradient anywhere, or fringing along an edge, means
14
+ * something is interpolating or resolving them — the two failures the
15
+ * no-resolve/no-blend rules exist to prevent.
16
+ * - Parts differ from each other. A whole model in one colour means the
17
+ * material index never made it into the uniform.
18
+ * - The FLOOR is a fixed light grey, the reserved id, and never shares a
19
+ * colour with a body part.
20
+ * - Where nothing was drawn is BLACK — id 0, the reserved nothing, which is
21
+ * also what the attachment clears to. Black *over geometry* means that draw
22
+ * wrote no id; colour where the sky should be means the clear is wrong.
23
+ *
24
+ * Colours come from a hash of the pair, so neighbouring materials land on
25
+ * unrelated hues rather than adjacent shades of one — the point is telling them
26
+ * apart, not ordering them.
27
+ */
28
+ export const ID_DEBUG_SHADER_WGSL = /* wgsl */ `
29
+ // Multisampled and NEVER resolved, so it is read the way it is written: one
30
+ // sample, by texel. Sample 0 is what every consumer of this attachment reads.
31
+ @group(0) @binding(0) var idTex: texture_multisampled_2d<u32>;
32
+
33
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
34
+ let x = f32((vi & 1u) << 2u) - 1.0;
35
+ let y = f32((vi & 2u) << 1u) - 1.0;
36
+ return vec4f(x, y, 0.0, 1.0);
37
+ }
38
+
39
+ /** Integer hash → 0..1. Distinct inputs land on unrelated outputs, which is the
40
+ * whole requirement: adjacent ids must not read as adjacent colours. */
41
+ fn hashId(x: u32) -> f32 {
42
+ var h = x * 747796405u + 2891336453u;
43
+ h = ((h >> ((h >> 28u) + 4u)) ^ h) * 277803737u;
44
+ return f32((h >> 22u) ^ h) / 4294967295.0;
45
+ }
46
+
47
+ @fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
48
+ let ids = textureLoad(idTex, vec2<i32>(fragCoord.xy), 0);
49
+ let materialId = ids.x;
50
+ let objectId = ids.y;
51
+
52
+ // Nothing drawn here. Black, and it must cover exactly the empty space.
53
+ if (materialId == 0u && objectId == 0u) {
54
+ return vec4f(0.0, 0.0, 0.0, 1.0);
55
+ }
56
+ // The floor, at the top of the range. Flat light grey so it is unmistakable
57
+ // and cannot be confused with a hashed colour.
58
+ if (materialId == ${GROUND_MATERIAL_ID}u) {
59
+ return vec4f(0.72, 0.72, 0.75, 1.0);
60
+ }
61
+ // Hue from the material, brightness from the object, so two models wearing
62
+ // the same material index still read apart.
63
+ let h = hashId(materialId * 1973u + 9277u);
64
+ let shade = 0.55 + 0.45 * hashId(objectId * 6151u + 1u);
65
+ // Cheap hue ramp: three offset cosines. Saturated on purpose — this is a
66
+ // diagnostic, not a look.
67
+ let rgb = vec3f(
68
+ 0.5 + 0.5 * cos(6.28318 * (h + 0.00)),
69
+ 0.5 + 0.5 * cos(6.28318 * (h + 0.33)),
70
+ 0.5 + 0.5 * cos(6.28318 * (h + 0.67)),
71
+ );
72
+ return vec4f(rgb * shade, 1.0);
73
+ }
74
+ `;
@@ -0,0 +1,66 @@
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
+ /** The scene's anchor CAP — the address space, not how many asked for trails. */
8
+ slots: number;
9
+ /** How many of this effect's anchors asked for a trail. AUTHOR-VISIBLE as
10
+ * RZ_TRAIL_SLOTS: published effects loop over it, so its meaning is pinned
11
+ * even though the address space it used to share is now a separate number. */
12
+ trailCount: number;
13
+ /** This effect's local slot → scene slot. Identity while one effect exists. */
14
+ alias: number[];
15
+ };
16
+ /** How the author's quads combine with the scene. */
17
+ type ParticleBlend = "alpha" | "additive";
18
+ type ParticleSource = {
19
+ /** The author's WGSL verbatim. */
20
+ wgsl: string;
21
+ /** Simultaneous particles. Fixed at install; the pool recycles rather than grows. */
22
+ count: number;
23
+ blend: ParticleBlend;
24
+ /** Feed the bloom pyramid. Off by default — rain should not glow. */
25
+ bloom: boolean;
26
+ };
27
+ /** Bytes per particle. Explicitly padded — see the struct below. */
28
+ export declare const PARTICLE_STRIDE = 48;
29
+ /** `// @particles 4096` — how many live at once. */
30
+ export declare function parseParticleCount(wgsl: string, max: number): number;
31
+ /** `// @bloom` — opt in to the bloom pyramid. Sparks want it; rain does not. */
32
+ export declare function parseParticleBloom(wgsl: string): boolean;
33
+ /** `// @blend additive` — default is straight alpha. */
34
+ export declare function parseParticleBlend(wgsl: string): ParticleBlend;
35
+ /** Does the source define the particle contract? All three are required together. */
36
+ export declare function particleEntryPoints(wgsl: string): {
37
+ init: boolean;
38
+ step: boolean;
39
+ shade: boolean;
40
+ };
41
+ /**
42
+ * Spawn, age, recycle.
43
+ *
44
+ * One kernel for both birth and update, because a dead slot and a new particle
45
+ * are the same write — a pool that recycles has no allocation and therefore no
46
+ * spawn-rate bookkeeping to get wrong. The cost is that lifetimes are staggered
47
+ * only by whatever the author randomises in `particleInit`, which for rain and
48
+ * snow is exactly right, and for a burst is what the age offset is for.
49
+ */
50
+ export declare function buildParticleComputeShader(src: ParticleSource, cast: CastLayout): string;
51
+ /**
52
+ * One camera-facing quad per live particle.
53
+ *
54
+ * Six vertices, no index or vertex buffer: the corners are derived from
55
+ * `vertex_index` and the particle is read from storage by `instance_index`, so a
56
+ * draw is `draw(6, count)` and there is nothing to upload per frame. The billboard
57
+ * basis comes from the VIEW matrix's rows rather than from a look-at, which keeps
58
+ * the quad square on screen no matter where the camera rolls.
59
+ *
60
+ * A dead particle collapses to a degenerate quad instead of being culled on the
61
+ * CPU — the alternative is a compacted draw list, which costs a prefix sum and a
62
+ * readback every frame to save vertices the rasteriser was going to reject anyway.
63
+ */
64
+ export declare function buildParticleRenderShader(src: ParticleSource, cast: CastLayout): string;
65
+ export {};
66
+ //# sourceMappingURL=particles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"particles.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/particles.ts"],"names":[],"mappings":"AAwBA,sEAAsE;AACtE,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAA;IACb;;mFAE+E;IAC/E,UAAU,EAAE,MAAM,CAAA;IAClB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,EAAE,CAAA;CAChB,CAAA;AAiCD,qDAAqD;AACrD,KAAK,aAAa,GAAG,OAAO,GAAG,UAAU,CAAA;AAEzC,KAAK,cAAc,GAAG;IACpB,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;AAkDjC,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,CA0CxF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CA6GvF"}
@@ -0,0 +1,279 @@
1
+ import { RZ_LIGHT_STRUCT_WGSL } from "../lights";
2
+ import { audioApi } from "../audio-api";
3
+ import { lyricsApi } from "../lyrics-api";
4
+ import { anchorAliasWgsl } from "../anchor-table";
5
+ import { midiApi } from "../midi-api";
6
+ import { CAST_API } from "../cast-api";
7
+ import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api";
8
+ /**
9
+ * The trail accessors, in the PARTICLE module.
10
+ *
11
+ * Sparks are the reason: the original hand ribbon shed sparks along its path,
12
+ * and as real particles they need to SPAWN on that path — which means
13
+ * particleInit reading the same recorded history the trail draws from. One
14
+ * effect file, two mounts, one buffer.
15
+ */
16
+ /** Shared with the grid pass, which reads the same buffer for the same reason:
17
+ * a kernel that displaces fog has to know where the dancer's feet are. */
18
+ function castApi(cast) {
19
+ return (
20
+ // rzSubjectCount FIRST, because CAST_API is written against it and this
21
+ // module has no view uniform to read the engine's count out of. Scanning
22
+ // for a subject whose bounding sphere has a radius is the same answer by a
23
+ // different route — the seam is named at the top of cast-api.ts.
24
+ `
25
+ fn rzSubjectCount() -> i32 {
26
+ var n = 0;
27
+ for (var i = 0; i < RZ_SUBJECTS; i++) {
28
+ if (_rzCast[i * 3 + 2].w > 0.0) { n = i + 1; }
29
+ }
30
+ return n;
31
+ }
32
+ ` +
33
+ CAST_API +
34
+ trailSlotsApi(cast.trailCount) +
35
+ anchorAliasWgsl(cast.alias));
36
+ }
37
+ /** Bytes per particle. Explicitly padded — see the struct below. */
38
+ export const PARTICLE_STRIDE = 48;
39
+ const CAMERA_STRUCT = /* wgsl */ `
40
+ struct CameraU {
41
+ view: mat4x4f,
42
+ proj: mat4x4f,
43
+ camPos: vec3f,
44
+ targetHeight: f32,
45
+ }
46
+ `;
47
+ const PARTICLE_UNIFORMS = /* wgsl */ `
48
+ struct ParticleU {
49
+ time: f32,
50
+ dt: f32,
51
+ count: u32,
52
+ frame: u32,
53
+ }
54
+ `;
55
+ /**
56
+ * This module's half of the prelude: the camera, from its own uniform.
57
+ *
58
+ * The rest — hashes, noise, falloff, clock, viewport — is shared with every
59
+ * other module that hosts an effect's source, because an author's helper must
60
+ * mean the same thing in whichever one it lands in.
61
+ */
62
+ const PRELUDE =
63
+ // The hashes, the noise and rzFalloff, identical in every module that hosts an
64
+ // effect's source; then the clock and the viewport, which cannot be, because
65
+ // this module keeps both in its own uniforms.
66
+ EFFECT_MATH_API +
67
+ clockApi("pu.time", "pu.dt") +
68
+ viewportApi("cam.targetHeight") +
69
+ /* wgsl */ `
70
+ ${RZ_LIGHT_STRUCT_WGSL}
71
+ fn rzCameraPos() -> vec3f { return cam.camPos; }
72
+ fn rzCameraRight() -> vec3f { return vec3f(cam.view[0][0], cam.view[1][0], cam.view[2][0]); }
73
+ fn rzCameraUp() -> vec3f { return vec3f(cam.view[0][1], cam.view[1][1], cam.view[2][1]); }
74
+ fn rzCameraForward() -> vec3f { return vec3f(cam.view[0][2], cam.view[1][2], cam.view[2][2]); }
75
+ /** World point → (uv, view distance), same contract as the field mounts' rzProject. */
76
+ fn rzProject(p: vec3f) -> vec3f {
77
+ let clip = cam.proj * cam.view * vec4f(p, 1.0);
78
+ let w = max(clip.w, 1e-4);
79
+ return vec3f(clip.xy / w * 0.5 + 0.5, clip.w);
80
+ }
81
+ fn rzCamPos() -> vec3f { return cam.camPos; }
82
+ `;
83
+ /** `// @particles 4096` — how many live at once. */
84
+ export function parseParticleCount(wgsl, max) {
85
+ const m = /^\s*\/\/\s*@particles\s+(\d+)\s*$/m.exec(wgsl);
86
+ if (!m)
87
+ return 0;
88
+ // Clamped rather than rejected: an author asking for a million gets the most
89
+ // the engine will give and a scene that still runs, which is a better failure
90
+ // than a compile error naming a number they had no way to know.
91
+ return Math.max(1, Math.min(max, parseInt(m[1], 10)));
92
+ }
93
+ /** `// @bloom` — opt in to the bloom pyramid. Sparks want it; rain does not. */
94
+ export function parseParticleBloom(wgsl) {
95
+ return /^\s*\/\/\s*@bloom\s*$/m.test(wgsl);
96
+ }
97
+ /** `// @blend additive` — default is straight alpha. */
98
+ export function parseParticleBlend(wgsl) {
99
+ return /^\s*\/\/\s*@blend\s+additive\s*$/m.test(wgsl) ? "additive" : "alpha";
100
+ }
101
+ /** Does the source define the particle contract? All three are required together. */
102
+ export function particleEntryPoints(wgsl) {
103
+ return {
104
+ init: /\bfn\s+particleInit\s*\(/.test(wgsl),
105
+ step: /\bfn\s+particleStep\s*\(/.test(wgsl),
106
+ shade: /\bfn\s+particleShade\s*\(/.test(wgsl),
107
+ };
108
+ }
109
+ /**
110
+ * Spawn, age, recycle.
111
+ *
112
+ * One kernel for both birth and update, because a dead slot and a new particle
113
+ * are the same write — a pool that recycles has no allocation and therefore no
114
+ * spawn-rate bookkeeping to get wrong. The cost is that lifetimes are staggered
115
+ * only by whatever the author randomises in `particleInit`, which for rain and
116
+ * snow is exactly right, and for a burst is what the age offset is for.
117
+ */
118
+ export function buildParticleComputeShader(src, cast) {
119
+ return (PARTICLE_STRUCT_WGSL +
120
+ CAMERA_STRUCT +
121
+ PARTICLE_UNIFORMS +
122
+ `
123
+ @group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
124
+ @group(0) @binding(1) var<uniform> pu: ParticleU;
125
+ @group(0) @binding(2) var<uniform> cam: CameraU;
126
+ @group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
127
+ ` +
128
+ castApi(cast) +
129
+ audioApi(0, 4) +
130
+ midiApi(0, 5) +
131
+ lyricsApi(0, 6) +
132
+ PRELUDE +
133
+ "\n// ── user effect ──\n" +
134
+ src.wgsl +
135
+ /* wgsl */ `
136
+ @compute @workgroup_size(64)
137
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
138
+ let i = gid.x;
139
+ if (i >= pu.count) { return; }
140
+ var p = particles[i];
141
+ if (p.life <= 0.0 || p.age >= p.life) {
142
+ // The seed is stable per SLOT and per generation, so a particle looks the
143
+ // same every time the scene is replayed at the same moment — which is what
144
+ // keeps an exported video identical to the preview.
145
+ let generation = floor(pu.time * 0.37) + f32(i) * 0.618;
146
+ p = particleInit(i, rzHash11(generation));
147
+ // age is NOT reset here. WGSL zero-initialises a var, so an author who
148
+ // ignores it starts at zero anyway — while one who sets it to a fraction of
149
+ // its life staggers the pool, which is the difference between snow and a
150
+ // pulse of snow arriving all at once every few seconds.
151
+ } else {
152
+ p = particleStep(p, pu.dt);
153
+ p.age = p.age + pu.dt;
154
+ }
155
+ particles[i] = p;
156
+ }
157
+ `);
158
+ }
159
+ /**
160
+ * One camera-facing quad per live particle.
161
+ *
162
+ * Six vertices, no index or vertex buffer: the corners are derived from
163
+ * `vertex_index` and the particle is read from storage by `instance_index`, so a
164
+ * draw is `draw(6, count)` and there is nothing to upload per frame. The billboard
165
+ * basis comes from the VIEW matrix's rows rather than from a look-at, which keeps
166
+ * the quad square on screen no matter where the camera rolls.
167
+ *
168
+ * A dead particle collapses to a degenerate quad instead of being culled on the
169
+ * CPU — the alternative is a compacted draw list, which costs a prefix sum and a
170
+ * readback every frame to save vertices the rasteriser was going to reject anyway.
171
+ */
172
+ export function buildParticleRenderShader(src, cast) {
173
+ return (`override BLOOM: bool = ${src.bloom ? "true" : "false"};
174
+ override ADDITIVE: bool = ${src.blend === "additive" ? "true" : "false"};\n` +
175
+ PARTICLE_STRUCT_WGSL +
176
+ CAMERA_STRUCT +
177
+ PARTICLE_UNIFORMS +
178
+ `
179
+ @group(0) @binding(0) var<storage, read> particles: array<Particle>;
180
+ @group(0) @binding(1) var<uniform> pu: ParticleU;
181
+ @group(0) @binding(2) var<uniform> cam: CameraU;
182
+ @group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
183
+ ` +
184
+ castApi(cast) +
185
+ audioApi(0, 4) +
186
+ midiApi(0, 5) +
187
+ lyricsApi(0, 6) +
188
+ PRELUDE +
189
+ "\n// ── user effect ──\n" +
190
+ src.wgsl +
191
+ /* wgsl */ `
192
+ struct VSOut {
193
+ @builtin(position) clip: vec4f,
194
+ @location(0) uv: vec2f,
195
+ @location(1) @interpolate(flat) id: u32,
196
+ }
197
+
198
+ @vertex
199
+ fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
200
+ var out: VSOut;
201
+ out.id = ii;
202
+ let p = particles[ii];
203
+ // Two triangles, corners in the order 0,1,2, 2,1,3.
204
+ let quad = array<vec2f, 6>(
205
+ vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
206
+ vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0),
207
+ );
208
+ let c = quad[vi];
209
+ out.uv = c * 0.5 + 0.5;
210
+ if (p.life <= 0.0) {
211
+ // Degenerate: off the near plane, rasterises nothing.
212
+ out.clip = vec4f(0.0, 0.0, -2.0, 1.0);
213
+ return out;
214
+ }
215
+ let right = vec3f(cam.view[0][0], cam.view[1][0], cam.view[2][0]);
216
+ let up = vec3f(cam.view[0][1], cam.view[1][1], cam.view[2][1]);
217
+ let s = sin(p.rot);
218
+ let k = cos(p.rot);
219
+ var r = vec2f(c.x * k - c.y * s, c.x * s + c.y * k);
220
+ // Stretched along the direction of travel ON SCREEN — which is not the world
221
+ // direction once the camera is off-axis. Rain falling straight down is nearly
222
+ // a point when viewed from above and a long streak from the side, and taking
223
+ // the velocity's components in the camera's own basis is what gets both right.
224
+ // Rotation is ignored while stretched: the velocity IS the orientation.
225
+ if (p.stretch > 1.0) {
226
+ let vr = dot(p.vel, right);
227
+ let vu = dot(p.vel, up);
228
+ let vlen = length(vec2f(vr, vu));
229
+ if (vlen > 1e-5) {
230
+ let d = vec2f(vr, vu) / vlen;
231
+ r = vec2f(d.y, -d.x) * c.x + d * (c.y * p.stretch);
232
+ }
233
+ }
234
+ let world = p.pos + (right * r.x + up * r.y) * p.size;
235
+ out.clip = cam.proj * cam.view * vec4f(world, 1.0);
236
+ return out;
237
+ }
238
+
239
+ struct FSOut {
240
+ @location(0) color: vec4f,
241
+ // The scene's aux target: (bloom mask, coverage). Materials write it, so a
242
+ // particle that skipped it would punch a hole in the mask of whatever it drew
243
+ // over.
244
+ //
245
+ // vec4f even though the target is rg8unorm and only .rg land: that target's
246
+ // blend factors reference SrcAlpha, and a fragment with no alpha channel is
247
+ // rejected outright — "reading alpha but it is missing from fragment output".
248
+ // The material shaders declare vec4f here for the same reason.
249
+ @location(1) mask: vec4f,
250
+ }
251
+
252
+ @fragment
253
+ fn fs(in: VSOut) -> FSOut {
254
+ let p = particles[in.id];
255
+ let c = particleShade(p, in.uv);
256
+ if (c.a <= 0.0) { discard; }
257
+ var out: FSOut;
258
+ // PREMULTIPLIED: the scene's colour target blends with srcFactor \"one\", so a
259
+ // straight-alpha fragment would come out over-bright wherever it is
260
+ // translucent — which is most of a soft particle.
261
+ out.color = vec4f(c.rgb * c.a, c.a);
262
+ // The mask's OPERATOR must match the colour's, or the composite invents bands.
263
+ //
264
+ // The composite divides the HDR colour by this coverage to un-premultiply
265
+ // before tone mapping. An ALPHA effect writes (gate, 1.0) and lets the
266
+ // src-alpha blend make alpha-over coverage, exactly as the materials do. An
267
+ // ADDITIVE effect's pipeline blends this target with factor ONE instead, and
268
+ // writes its values directly — coverage SUMS like the colour does, so
269
+ // Σ(rgb·a)/Σa returns the true colour even where the effect overlaps itself.
270
+ // With summed colour over alpha-over coverage, every self-overlap divided into
271
+ // a bright white bar — visible only over the background, because the model's
272
+ // own coverage is already 1 there and the divide is a no-op. That mismatch,
273
+ // not geometry, was the banding that survived every geometry fix.
274
+ let mg = select(vec2f(select(0.0, 1.0, BLOOM), 1.0), vec2f(select(0.0, c.a, BLOOM), c.a), ADDITIVE);
275
+ out.mask = vec4f(mg.x, mg.y, 0.0, c.a);
276
+ return out;
277
+ }
278
+ `);
279
+ }