reze-engine 0.51.0 → 0.52.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 (44) hide show
  1. package/dist/effect-schedule.d.ts +67 -0
  2. package/dist/effect-schedule.d.ts.map +1 -0
  3. package/dist/effect-schedule.js +96 -0
  4. package/dist/engine.d.ts +113 -17
  5. package/dist/engine.d.ts.map +1 -1
  6. package/dist/engine.js +324 -169
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +8 -0
  10. package/dist/shaders/anchor-table.d.ts +1 -1
  11. package/dist/shaders/anchor-table.js +3 -3
  12. package/dist/shaders/cast-api.d.ts +1 -1
  13. package/dist/shaders/cast-api.js +2 -2
  14. package/dist/shaders/directives.d.ts +73 -0
  15. package/dist/shaders/directives.d.ts.map +1 -0
  16. package/dist/shaders/directives.js +238 -0
  17. package/dist/shaders/lights.d.ts +0 -10
  18. package/dist/shaders/lights.d.ts.map +1 -1
  19. package/dist/shaders/lights.js +11 -19
  20. package/dist/shaders/passes/composite.d.ts +5 -5
  21. package/dist/shaders/passes/composite.d.ts.map +1 -1
  22. package/dist/shaders/passes/composite.js +12 -5
  23. package/dist/shaders/passes/grid.d.ts +0 -2
  24. package/dist/shaders/passes/grid.d.ts.map +1 -1
  25. package/dist/shaders/passes/grid.js +0 -9
  26. package/dist/shaders/passes/particles.d.ts +0 -6
  27. package/dist/shaders/passes/particles.d.ts.map +1 -1
  28. package/dist/shaders/passes/particles.js +15 -19
  29. package/dist/shaders/passes/scene-contract.d.ts +1 -1
  30. package/dist/shaders/passes/trails.d.ts.map +1 -1
  31. package/dist/shaders/passes/trails.js +9 -4
  32. package/package.json +2 -2
  33. package/src/effect-schedule.ts +120 -0
  34. package/src/engine.ts +323 -134
  35. package/src/index.ts +15 -0
  36. package/src/shaders/anchor-table.ts +3 -3
  37. package/src/shaders/cast-api.ts +2 -2
  38. package/src/shaders/directives.ts +289 -0
  39. package/src/shaders/lights.ts +11 -19
  40. package/src/shaders/passes/composite.ts +13 -6
  41. package/src/shaders/passes/grid.ts +0 -9
  42. package/src/shaders/passes/particles.ts +15 -21
  43. package/src/shaders/passes/scene-contract.ts +1 -1
  44. package/src/shaders/passes/trails.ts +9 -4
package/src/index.ts CHANGED
@@ -34,6 +34,10 @@ export { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, parseLRC, type LyricLine, type Ly
34
34
  // wants to draw a track, or scrub one, should read the same curve the engine
35
35
  // plays rather than reimplementing it a second time.
36
36
  export { sampleParamTrack, type ParamKey, type ParamValue } from "./param-track"
37
+ // The strip an effect is scheduled by, and the pure evaluator behind it —
38
+ // exported so a caller can draw a lane against the same numbers the engine
39
+ // renders from, rather than a second copy of the ramp maths.
40
+ export { effectState, type EffectWindow, type EffectState } from "./effect-schedule"
37
41
  export {
38
42
  compileGraph,
39
43
  validateGraph,
@@ -70,6 +74,17 @@ export { STOCKINGS_GRAPH } from "./graph/presets/stockings"
70
74
  export { EYE_GRAPH } from "./graph/presets/eye"
71
75
  export { FACE_GRAPH } from "./graph/presets/face"
72
76
  export { UNLIT_GRAPH } from "./graph/presets/unlit"
77
+ // What an effect declares, and the source with those lines blanked. Exported
78
+ // because a host builds parameter controls from the declarations and needs the
79
+ // same answer the engine got — two parsers is how they disagree.
80
+ export {
81
+ parseDirectives,
82
+ stripDirectives,
83
+ DIRECTIVE_LINE,
84
+ DIRECTIVE_NOTE,
85
+ type EffectDirectives,
86
+ type EffectParamDecl,
87
+ } from "./shaders/directives"
73
88
  export {
74
89
  Model,
75
90
  MATERIAL_MORPH_MULTIPLY,
@@ -91,7 +91,7 @@ export function buildAnchorTable(requests: AnchorRequest[][], max: number): Anch
91
91
  * TRAILED anchor, so its instance index counts 0,1,2… over trailed anchors only
92
92
  * — while the cast buffer is addressed by DECLARATION slot. Those coincide
93
93
  * exactly when every anchor is trailed, which is true of all 14 library effects
94
- * and is why this stayed latent: declare `@anchor 頭` then `@anchor 左手首 trail`
94
+ * and is why this stayed latent: declare `#anchor 頭` then `#anchor 左手首 trail`
95
95
  * and ribbon 0 asked for the trail of 頭, which has none, so the ribbon silently
96
96
  * did not draw.
97
97
  *
@@ -127,7 +127,7 @@ ${cases}
127
127
  * folds away.
128
128
  */
129
129
  export function anchorAliasWgsl(alias: number[]): string {
130
- // NO @anchor AT ALL. Every slot is unmapped, and rzAnchor() must report
130
+ // NO #anchor AT ALL. Every slot is unmapped, and rzAnchor() must report
131
131
  // .valid false for all of them.
132
132
  //
133
133
  // This used to fall through to the identity branch below — `[].every()` is
@@ -139,7 +139,7 @@ export function anchorAliasWgsl(alias: number[]): string {
139
139
  // reading.
140
140
  if (alias.length === 0) {
141
141
  return `
142
- /** No @anchor in this effect: every slot is unmapped, so rzAnchor() is invalid
142
+ /** No #anchor in this effect: every slot is unmapped, so rzAnchor() is invalid
143
143
  * rather than reading whichever bone another effect declared first. */
144
144
  fn _rzSlot(local: i32) -> i32 { return -1; }
145
145
  `
@@ -92,7 +92,7 @@ fn rzSubject(i: i32) -> RzSubject {
92
92
  /**
93
93
  * Where a named bone is, this frame.
94
94
  *
95
- * The slot is the author's own: the Nth @anchor in their file, in the order
95
+ * The slot is the author's own: the Nth #anchor in their file, in the order
96
96
  * they wrote them. _rzSlot turns that into the scene's address, which is what
97
97
  * keeps two effects that both anchor to a wrist from reading each other's.
98
98
  */
@@ -115,7 +115,7 @@ fn rzAnchor(subject: i32, slot: i32) -> RzAnchor {
115
115
  *
116
116
  * Bounded by the anchor cap, NOT by how many anchors asked for a trail. Those
117
117
  * are different index spaces: storage is addressed by anchor slot, so an
118
- * untrailed @anchor followed by a trailed one put the trail at index 1 with a
118
+ * untrailed #anchor followed by a trailed one put the trail at index 1 with a
119
119
  * bound of 1 and rzTrail returned zero — a ribbon that silently did not draw.
120
120
  */
121
121
  fn rzTrailCount(subject: i32, slot: i32) -> i32 {
@@ -0,0 +1,289 @@
1
+ // What an effect DECLARES about itself — parsed once, in one place.
2
+ //
3
+ // An effect file is WGSL plus a handful of lines that configure how the engine
4
+ // mounts it: which resolution it draws at, how it blends, which bones it
5
+ // follows, what knobs it exposes. Those lines are not comments. They decide
6
+ // whether the file gets what it asks for, and the failure they used to have was
7
+ // the worst kind available.
8
+ //
9
+ // WHY `#` AND NOT `// @`. The old spelling put directives inside comments,
10
+ // which meant two things. Authors read them as comments and wrote notes on the
11
+ // same line — every parser was anchored to end-of-line, so the note silently
12
+ // unmade the directive, and three shipped effects ran at half the resolution
13
+ // their own first line said they needed while a fourth quietly stopped being
14
+ // additive. Nothing failed and nothing was reported, because nothing had been
15
+ // declared to fail. And it could not be fixed by being strict: a comment
16
+ // beginning with `@` is a legitimate thing to write, so an unrecognised one can
17
+ // only ever be a warning.
18
+ //
19
+ // WGSL has no `#` syntax of its own. A line starting with `#` is therefore
20
+ // unambiguously ours, an unknown one is an ERROR rather than a guess, and the
21
+ // sigil is the one every shader author already reads as "directive" from
22
+ // `#pragma`. The cost usually quoted for this — that the file stops being valid
23
+ // WGSL — is not a cost we pay: an effect calls rzSubject, rzTrail and reads
24
+ // `params`, none of which exist until the engine splices them in, so these
25
+ // files have never compiled anywhere else.
26
+ //
27
+ // STRIPPED BY BLANKING, not by deleting: the compiler sees an empty line where
28
+ // each directive was, so every diagnostic's line number still points at the
29
+ // line the author is looking at.
30
+
31
+ /** A knob an effect exposes, for a host to build a control from. */
32
+ export type EffectParamDecl = {
33
+ name: string
34
+ kind: "float" | "color" | "vec3"
35
+ /** Numbers for float/vec3; `#rrggbb` for a colour, which the host converts. */
36
+ value: number | [number, number, number] | string
37
+ /** float only, and only when the author gave a range. */
38
+ min?: number
39
+ max?: number
40
+ }
41
+
42
+ export type EffectDirectives = {
43
+ /** Bones this effect follows, in declaration order — slot 0 is the first. */
44
+ anchors: { bone: string; trail: boolean }[]
45
+ params: EffectParamDecl[]
46
+ /** Field layer: 0 full, 1 half. Full unless `#halfres` says otherwise. */
47
+ fieldLayer: 0 | 1
48
+ /** The field layer composites additively rather than over. */
49
+ additiveLayer: boolean
50
+ /** Particle blend, which is a different axis from the field layer's. */
51
+ particleBlend: "alpha" | "additive"
52
+ particles: number
53
+ lights: number
54
+ grid: number
55
+ bloom: boolean
56
+ /** This effect takes the cast apart — the host reads the timing. */
57
+ dissolve: boolean
58
+ /**
59
+ * How long ONE firing of this effect lasts, in seconds. 0 = undeclared.
60
+ *
61
+ * An effect is one of two things, and only its author knows which. A HIT has
62
+ * an arc — a circle flares, peaks and is gone — and its length is a fact
63
+ * about it, the way a video clip's length is a fact about the file. An
64
+ * AMBIENT effect (stars, fog, rain) has no length at all; it is a condition
65
+ * the scene is in.
66
+ *
67
+ * Declaring it is what lets a host place the effect instead of making someone
68
+ * construct it: dropping a hit on the timeline gives a strip already the
69
+ * right size, and an effect that declares nothing spans the scene. Every
70
+ * timeline works this way — a clip arrives at its own duration.
71
+ */
72
+ duration: number
73
+ }
74
+
75
+ /** Every directive, with how many words follow it. `rest` means free-form. */
76
+ const SPEC = {
77
+ anchor: "rest",
78
+ param: "rest",
79
+ halfres: 0,
80
+ layer: 1,
81
+ blend: 1,
82
+ particles: 1,
83
+ lights: 1,
84
+ grid: 1,
85
+ bloom: 0,
86
+ dissolve: 0,
87
+ duration: 1,
88
+ } as const
89
+
90
+ /** A line that declares something, and what it declares. Exported because an
91
+ * editor highlights by the same rule the parser reads by — a highlighter with
92
+ * its own idea of what counts is one that paints a line as configuration that
93
+ * the engine then ignores. */
94
+ export const DIRECTIVE_LINE = /^[ \t]*#([a-zA-Z]+)[ \t]*(.*)$/
95
+ const LINE = DIRECTIVE_LINE
96
+
97
+ /**
98
+ * Split a directive's arguments from a trailing note.
99
+ *
100
+ * `#anchor 左手首 trail — her sword hand` is one line doing two jobs, and
101
+ * refusing it is what made the old spelling dangerous: the note is the natural
102
+ * thing to write, so it has to be the accepted thing to write.
103
+ */
104
+ export const DIRECTIVE_NOTE = /(?:^|\s)(?:—|--|\/\/|#)\s/
105
+
106
+ function argsOf(rest: string): string[] {
107
+ // `^` as well as `\s`: the tag's own trailing space is eaten by LINE, so a
108
+ // note can begin at the very first character — which is what
109
+ // `#halfres — glyph edges` looks like by the time it reaches here.
110
+ const note = rest.search(DIRECTIVE_NOTE)
111
+ return (note >= 0 ? rest.slice(0, note) : rest).trim().split(/\s+/).filter(Boolean)
112
+ }
113
+
114
+ /** A number, or null. Empty is NULL, not zero: `Number("")` is 0, so a missing
115
+ * default silently became a real one — an author who wrote `#param float D`
116
+ * and meant to finish the line got a knob quietly pinned at zero. */
117
+ const num = (s: string | undefined): number | null => {
118
+ if (!s || !s.trim()) return null
119
+ const v = Number(s)
120
+ return Number.isFinite(v) ? v : null
121
+ }
122
+
123
+ export type DirectiveResult = { directives: EffectDirectives; errors: string[] }
124
+
125
+ /** Read every declaration in a source. Errors name the line, one-based. */
126
+ export function parseDirectives(wgsl: string): DirectiveResult {
127
+ const d: EffectDirectives = {
128
+ anchors: [],
129
+ params: [],
130
+ // FULL unless asked otherwise — the default is what an author gets for
131
+ // saying nothing, so it has to be the answer that cannot silently ruin an
132
+ // effect. `#halfres` is a claim about being cheap, which is a claim only
133
+ // the author can make.
134
+ fieldLayer: 0,
135
+ additiveLayer: false,
136
+ particleBlend: "alpha",
137
+ particles: 0,
138
+ lights: 0,
139
+ grid: 0,
140
+ bloom: false,
141
+ dissolve: false,
142
+ duration: 0,
143
+ }
144
+ const errors: string[] = []
145
+ const lines = wgsl.split("\n")
146
+
147
+ lines.forEach((line, i) => {
148
+ const m = LINE.exec(line)
149
+ if (!m) return
150
+ const at = `line ${i + 1}`
151
+ const tag = m[1].toLowerCase()
152
+ if (!(tag in SPEC)) {
153
+ errors.push(`${at}: #${m[1]} is not a directive. Known: ${Object.keys(SPEC).map((k) => `#${k}`).join(", ")}`)
154
+ return
155
+ }
156
+ const args = argsOf(m[2])
157
+ const want = SPEC[tag as keyof typeof SPEC]
158
+ if (want !== "rest" && args.length !== want) {
159
+ errors.push(`${at}: #${tag} takes ${want} argument${want === 1 ? "" : "s"}, got ${args.length}`)
160
+ return
161
+ }
162
+
163
+ switch (tag) {
164
+ case "anchor": {
165
+ if (args.length < 1 || args.length > 2 || (args[1] && args[1] !== "trail")) {
166
+ errors.push(`${at}: #anchor takes a bone name and optionally the word "trail"`)
167
+ return
168
+ }
169
+ d.anchors.push({ bone: args[0], trail: args[1] === "trail" })
170
+ return
171
+ }
172
+ case "param": {
173
+ const [kind, name, ...rest] = args
174
+ if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
175
+ errors.push(`${at}: #param needs a WGSL identifier for a name`)
176
+ return
177
+ }
178
+ if (kind === "color") {
179
+ if (!/^#[0-9a-fA-F]{6}$/.test(rest[0] ?? "")) {
180
+ errors.push(`${at}: #param color ${name} needs a default like #3b82f6`)
181
+ return
182
+ }
183
+ d.params.push({ name, kind: "color", value: rest[0] })
184
+ return
185
+ }
186
+ if (kind === "vec3") {
187
+ const v = rest.slice(0, 3).map(num)
188
+ if (v.length !== 3 || v.some((x) => x === null)) {
189
+ errors.push(`${at}: #param vec3 ${name} needs three numbers`)
190
+ return
191
+ }
192
+ d.params.push({ name, kind: "vec3", value: v as [number, number, number] })
193
+ return
194
+ }
195
+ if (kind === "float") {
196
+ const v = num(rest[0])
197
+ if (v === null) {
198
+ errors.push(`${at}: #param float ${name} needs a default`)
199
+ return
200
+ }
201
+ const lo = num(rest[1])
202
+ const hi = num(rest[2])
203
+ // A range is optional and all-or-nothing: half of one is a slider
204
+ // with an end nobody chose.
205
+ if ((rest[1] !== undefined) !== (rest[2] !== undefined) || (rest[1] !== undefined && (lo === null || hi === null))) {
206
+ errors.push(`${at}: #param float ${name} takes both a min and a max, or neither`)
207
+ return
208
+ }
209
+ d.params.push({ name, kind: "float", value: v, ...(lo !== null && hi !== null ? { min: lo, max: hi } : {}) })
210
+ return
211
+ }
212
+ errors.push(`${at}: #param kind must be float, color or vec3`)
213
+ return
214
+ }
215
+ case "halfres":
216
+ d.fieldLayer = 1
217
+ return
218
+ case "layer":
219
+ if (args[0] !== "additive") {
220
+ errors.push(`${at}: #layer takes "additive" — over is the default`)
221
+ return
222
+ }
223
+ d.additiveLayer = true
224
+ return
225
+ case "blend":
226
+ if (args[0] !== "additive") {
227
+ errors.push(`${at}: #blend takes "additive" — alpha is the default`)
228
+ return
229
+ }
230
+ d.particleBlend = "additive"
231
+ return
232
+ case "bloom":
233
+ d.bloom = true
234
+ return
235
+ case "dissolve":
236
+ d.dissolve = true
237
+ return
238
+ case "duration": {
239
+ // SECONDS, like every other time a directive states (see #dissolve).
240
+ // The document above works in frames and converts once; an author
241
+ // writing a shader is thinking about how long a flare takes, not about
242
+ // MMD's frame rate.
243
+ const n = num(args[0])
244
+ if (n === null || n <= 0) {
245
+ errors.push(`${at}: #duration takes a length in seconds`)
246
+ return
247
+ }
248
+ d.duration = n
249
+ return
250
+ }
251
+ default: {
252
+ // The three that take a count.
253
+ const n = num(args[0])
254
+ if (n === null || n < 0) {
255
+ errors.push(`${at}: #${tag} takes a number`)
256
+ return
257
+ }
258
+ if (tag === "particles") d.particles = n
259
+ else if (tag === "lights") d.lights = n
260
+ else if (tag === "grid") d.grid = n
261
+ return
262
+ }
263
+ }
264
+ })
265
+
266
+ // Duplicates are an author editing one line and forgetting another; last-wins
267
+ // is a coin toss they never see resolved.
268
+ const names = new Set<string>()
269
+ for (const p of d.params) {
270
+ if (names.has(p.name)) errors.push(`#param ${p.name} is declared twice`)
271
+ names.add(p.name)
272
+ }
273
+
274
+ return { directives: d, errors }
275
+ }
276
+
277
+ /**
278
+ * The source as the compiler should see it: every directive line blanked.
279
+ *
280
+ * Blanked rather than removed so line numbers survive — a diagnostic points at
281
+ * the line the author is looking at, which is the whole reason the engine
282
+ * rebases them in the first place.
283
+ */
284
+ export function stripDirectives(wgsl: string): string {
285
+ return wgsl
286
+ .split("\n")
287
+ .map((line) => (LINE.test(line) ? "" : line))
288
+ .join("\n")
289
+ }
@@ -45,21 +45,6 @@ export const MAX_LIGHTS = 16
45
45
  /** Floats in the whole buffer. */
46
46
  export const LIGHTS_FLOATS = LIGHT_HEADER + MAX_LIGHTS * LIGHT_STRIDE
47
47
 
48
- /**
49
- * `// @lights 3` — how many lights this effect emits.
50
- *
51
- * Declared, like every other mount: what the file says is what gets allocated,
52
- * so an effect that emits none costs no slots and nobody pays for a cap they
53
- * did not ask for. Clamped rather than rejected, the same choice `@particles`
54
- * makes — an author asking for a hundred gets the most the engine will give and
55
- * a scene that still runs.
56
- */
57
- export function parseLightCount(wgsl: string, max: number): number {
58
- const m = /^\s*\/\/\s*@lights\s+(\d+)\s*$/m.exec(wgsl)
59
- if (!m) return 0
60
- return Math.max(1, Math.min(max, parseInt(m[1], 10)))
61
- }
62
-
63
48
  /**
64
49
  * The RzLight struct, declared in EVERY module a user's source is spliced into.
65
50
  *
@@ -144,8 +129,8 @@ export function buildLightEmitShader(
144
129
  // read_write HERE and read-only in the material shaders. Different passes, so
145
130
  // the two never coexist: this compute runs before the scene pass that reads it.
146
131
  @group(0) @binding(0) var<storage, read_write> _rzLightsOut: array<f32>;
147
- // (time, base slot, count, _) — see buildLightEmitShader on why the base is
148
- // here and not in the text.
132
+ // (time, base slot, count, weight) — see buildLightEmitShader on why the base
133
+ // is here and not in the text.
149
134
  @group(0) @binding(1) var<uniform> _rzLightU: vec4f;
150
135
  // The camera block and the cast — the two buffers the scene API reads. Same
151
136
  // contents the field and grid modules bind, so an effect's lightEmit sees the
@@ -195,12 +180,19 @@ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
195
180
  let finite = l.pos.x == l.pos.x && l.pos.y == l.pos.y && l.pos.z == l.pos.z &&
196
181
  l.radius == l.radius && l.intensity == l.intensity &&
197
182
  l.color.x == l.color.x && l.color.y == l.color.y && l.color.z == l.color.z;
198
- let c = select(vec3f(0.0), max(l.color * l.intensity, vec3f(0.0)), finite);
183
+ // Weight rides along with the sanitisation, which is already the one place
184
+ // this buffer is written: a lamp at half weight is half as bright, and one
185
+ // at zero never reaches here because the dispatch is skipped.
186
+ let c = select(vec3f(0.0), max(l.color * l.intensity, vec3f(0.0)), finite) * _rzLightU.w;
199
187
  let b = ${LIGHT_HEADER}u + (u32(_rzLightU.y) + i) * ${LIGHT_STRIDE}u;
200
188
  _rzLightsOut[b] = select(0.0, l.pos.x, finite);
201
189
  _rzLightsOut[b + 1u] = select(0.0, l.pos.y, finite);
202
190
  _rzLightsOut[b + 2u] = select(0.0, l.pos.z, finite);
203
- _rzLightsOut[b + 3u] = select(0.0, max(l.radius, 0.0), finite);
191
+ // Radius goes to zero when the effect is OFF, and is left alone at every
192
+ // weight above it. Scaling it with the fade would shrink a dimming lamp's
193
+ // reach, which is a different thing than dimming it; zeroing it at nothing
194
+ // is what lets a material's distance cull drop the slot entirely.
195
+ _rzLightsOut[b + 3u] = select(0.0, max(l.radius, 0.0), finite && _rzLightU.w > 0.0);
204
196
  // Colour carries intensity, exactly as the CPU writer stores it — one product,
205
197
  // one place, so the two producers cannot disagree about what a slot means.
206
198
  _rzLightsOut[b + 4u] = c.x;
@@ -58,13 +58,13 @@ import { gridReadApi } from "./grid"
58
58
  /**
59
59
  * The bones an effect asked for, in declaration order — the slots rzAnchor reads.
60
60
  *
61
- * // @anchor 左手首 trail
62
- * // @anchor 頭
61
+ * #anchor 左手首 trail
62
+ * #anchor 頭
63
63
  *
64
64
  * A declaration in the source, like the mounts: what a file names is what gets
65
65
  * resolved and uploaded, so naming none costs nothing and nobody pays for a
66
66
  * rig's other five hundred bones. Anchored to the start of a line so that
67
- * writing the word @anchor in ordinary prose does not silently add a slot —
67
+ * writing the word #anchor in ordinary prose does not silently add a slot —
68
68
  * which would shift every slot after it.
69
69
  *
70
70
  * `trail` additionally keeps that bone's recent PATH, for rzTrail. Opt-in
@@ -75,7 +75,7 @@ import { gridReadApi } from "./grid"
75
75
  * not have simply reports invalid.
76
76
  */
77
77
  export function parseEffectAnchors(wgsl: string, max: number): { bone: string; trail: boolean }[] {
78
- return [...wgsl.matchAll(/^[ \t]*\/\/[ \t]*@anchor[ \t]+(\S+)([ \t]+trail)?[ \t]*$/gm)]
78
+ return [...wgsl.matchAll(/^[ \t]*\/\/[ \t]*#anchor[ \t]+(\S+)([ \t]+trail)?[ \t]*$/gm)]
79
79
  .map((m) => ({ bone: m[1], trail: m[2] !== undefined }))
80
80
  .slice(0, max)
81
81
  }
@@ -93,7 +93,7 @@ type CompositeEffectSource = {
93
93
  hasBackground: boolean
94
94
  /** Defines `fn foreground(...)` — mount over the finished frame. */
95
95
  hasForeground: boolean
96
- /** Grid resolution when the effect declared `// @grid`, else 0. */
96
+ /** Grid resolution when the effect declared `#grid`, else 0. */
97
97
  gridSize: number
98
98
  /** Whether the scene pass carries the id attachment, so the field module can
99
99
  * bind it. False emits accessors that answer 0 rather than nothing at all. */
@@ -728,7 +728,7 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
728
728
  * whose lightEmit read its own epoch disagreed with its own background()
729
729
  * about what time it was. One buffer per effect, one answer.
730
730
  */
731
- @group(0) @binding(22) var<uniform> _rzFieldClock: vec4f;
731
+ @group(0) @binding(22) var<uniform> _rzFieldClock: vec4f; // (time, weight, _, _)
732
732
 
733
733
  @vertex fn fieldVs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
734
734
  let x = f32((vi & 1u) << 2u) - 1.0;
@@ -752,6 +752,13 @@ struct FieldOut {
752
752
  out.fg = vec4f(0.0);
753
753
  ${bgLine}
754
754
  ${fgLine}
755
+ // WEIGHT, applied where the author cannot decline it.
756
+ //
757
+ // Alpha only: both field blends multiply the fragment's colour by src-alpha,
758
+ // so this is the fade for the alpha-over layer and the additive one alike.
759
+ // Scaling colour as well would fade as the square.
760
+ out.bg.a *= _rzFieldClock.y;
761
+ out.fg.a *= _rzFieldClock.y;
755
762
  return out;
756
763
  }
757
764
  `
@@ -40,15 +40,6 @@ export const GRID_MAX = 1024
40
40
  /** rgba16float, ping-ponged. Two of these is the whole memory cost. */
41
41
  export const SIM_FORMAT: GPUTextureFormat = "rgba16float"
42
42
 
43
- /** `// @grid 256` — the grid's resolution, in texels per side. */
44
- export function parseGridSize(wgsl: string, max: number): number {
45
- const m = /^\s*\/\/\s*@grid\s+(\d+)\s*$/m.exec(wgsl)
46
- if (!m) return 0
47
- // Clamped rather than rejected, as the particle count is: an author asking for
48
- // 4096 gets the most the engine will give and a scene that still runs.
49
- return Math.max(8, Math.min(max, parseInt(m[1], 10)))
50
- }
51
-
52
43
  /** Whether this effect drives a grid at all. */
53
44
  export function gridEntryPoint(wgsl: string): boolean {
54
45
  return /\bfn\s+gridStep\s*\(/.test(wgsl)
@@ -102,6 +102,11 @@ struct ParticleU {
102
102
  dt: f32,
103
103
  count: u32,
104
104
  frame: u32,
105
+ /** The effect's evaluated influence, applied at the one output site below. */
106
+ weight: f32,
107
+ _pad0: f32,
108
+ _pad1: f32,
109
+ _pad2: f32,
105
110
  }
106
111
  `
107
112
 
@@ -134,26 +139,6 @@ fn rzProject(p: vec3f) -> vec3f {
134
139
  fn rzCamPos() -> vec3f { return cam.camPos; }
135
140
  `
136
141
 
137
- /** `// @particles 4096` — how many live at once. */
138
- export function parseParticleCount(wgsl: string, max: number): number {
139
- const m = /^\s*\/\/\s*@particles\s+(\d+)\s*$/m.exec(wgsl)
140
- if (!m) return 0
141
- // Clamped rather than rejected: an author asking for a million gets the most
142
- // the engine will give and a scene that still runs, which is a better failure
143
- // than a compile error naming a number they had no way to know.
144
- return Math.max(1, Math.min(max, parseInt(m[1], 10)))
145
- }
146
-
147
- /** `// @bloom` — opt in to the bloom pyramid. Sparks want it; rain does not. */
148
- export function parseParticleBloom(wgsl: string): boolean {
149
- return /^\s*\/\/\s*@bloom\s*$/m.test(wgsl)
150
- }
151
-
152
- /** `// @blend additive` — default is straight alpha. */
153
- export function parseParticleBlend(wgsl: string): ParticleBlend {
154
- return /^\s*\/\/\s*@blend\s+additive\s*$/m.test(wgsl) ? "additive" : "alpha"
155
- }
156
-
157
142
  /** Does the source define the particle contract? All three are required together. */
158
143
  export function particleEntryPoints(wgsl: string): { init: boolean; step: boolean; shade: boolean } {
159
144
  return {
@@ -316,7 +301,16 @@ ${sceneIdFieldWgsl()}}
316
301
  @fragment
317
302
  fn fs(in: VSOut) -> FSOut {
318
303
  let p = particles[in.id];
319
- let c = particleShade(p, in.uv);
304
+ // WEIGHT IS APPLIED HERE, on the author's result, before anything reads it.
305
+ //
306
+ // Alpha alone, and that is not a shortcut: this fragment is premultiplied
307
+ // two lines down, so scaling alpha scales the colour with it — and where the
308
+ // target blends additively the premultiply is what carries the fade. One
309
+ // multiply is the correct fade for both particle targets.
310
+ var c = particleShade(p, in.uv);
311
+ c.a *= pu.weight;
312
+ // Weight 0 therefore discards every fragment, so an effect faded out costs
313
+ // nothing past the vertex stage even on the frame the draw is still issued.
320
314
  if (c.a <= 0.0) { discard; }
321
315
  var out: FSOut;
322
316
  // PREMULTIPLIED: the scene's colour target blends with srcFactor \"one\", so a
@@ -86,7 +86,7 @@ type SceneRenderClass =
86
86
  | "outline"
87
87
  /** Particles and ribbons in their default, non-additive mode. */
88
88
  | "particle"
89
- /** Particles declaring `// @blend additive` — LIGHT rather than matter, so
89
+ /** Particles declaring `#blend additive` — LIGHT rather than matter, so
90
90
  * colour sums and alpha is left alone: a glow must not claim coverage it
91
91
  * never occluded. The aux target sums with it, which is what lets an
92
92
  * additive effect reach the bloom gate at all. */
@@ -132,7 +132,8 @@ struct TrailU {
132
132
  // model is added or removed, and recompiling every trail shader for that
133
133
  // would be absurd.
134
134
  subjects: f32,
135
- _pad1: f32,
135
+ /** The effect's evaluated influence, applied at the one output site below. */
136
+ weight: f32,
136
137
  _pad2: f32,
137
138
  }
138
139
  @group(0) @binding(0) var<storage, read> _rzCast: array<vec4f>;
@@ -402,15 +403,19 @@ fn fs(in: VSOut) -> TrailFSOut {
402
403
  // hardware does it, correctly and for free, and the reversed-Z trap that
403
404
  // needed its own regression test goes with it.
404
405
  let c = trailShade(in.uv.x, in.uv.y, in.age, in.weight, i32(in.slot));
405
- if (c.a <= 0.0) { discard; }
406
+ // THE EFFECT'S weight, not the ribbon's — in.weight above is the strand's
407
+ // own taper and belongs to the author. This one is the scheduler's, and it
408
+ // scales alpha because the target below multiplies colour by src-alpha.
409
+ let a = c.a * tu.weight;
410
+ if (a <= 0.0) { discard; }
406
411
  var o: TrailFSOut;
407
412
  // STRAIGHT colour into an ADDITIVE target, which reverses the old MAX rule
408
413
  // deliberately: max existed so parallel strands could not double into bright
409
414
  // dashes on a layer composited after tone mapping. In HDR before bloom,
410
415
  // overlapping light SHOULD sum — that is what neon does — and the tone
411
416
  // mapper is what keeps the sum from clipping.
412
- o.color = vec4f(c.rgb, c.a);
413
- o.aux = vec4f(${src.bloom ? "1.0" : "0.0"}, 1.0, 0.0, c.a);
417
+ o.color = vec4f(c.rgb, a);
418
+ o.aux = vec4f(${src.bloom ? "1.0" : "0.0"}, 1.0, 0.0, a);
414
419
  ${sceneIdPadWgsl("o")} return o;
415
420
  }
416
421
  `