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/dist/engine.js CHANGED
@@ -19,13 +19,14 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
19
19
  import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow";
20
20
  import { ID_DEBUG_SHADER_WGSL } from "./shaders/passes/id-debug";
21
21
  import { paramChanged, sampleParamTrack } from "./param-track";
22
+ import { effectState } from "./effect-schedule";
22
23
  import { SHADOW_CASCADES, buildShadowVP } from "./shadow-cascades";
23
24
  import { REFLECTION_DEBUG_WGSL, buildMirrorCamera } from "./reflection";
24
25
  import { packHalf } from "./hdr";
25
26
  import { evalIrradianceSH, projectIrradianceSH } from "./ibl";
26
27
  import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, packLyrics } from "./shaders/lyrics-api";
27
28
  import { sceneTargets as sceneTargetsFor, sceneColorFormats, setMrtIds, mrtIdsEnabled, SCENE_ID_FORMAT, } from "./shaders/passes/scene-contract";
28
- import { LIGHT_HEADER, LIGHT_STRIDE, LIGHTS_FLOATS, MAX_LIGHTS, buildLightEmitShader, hasLightEmit, parseLightCount, } from "./shaders/lights";
29
+ import { LIGHT_HEADER, LIGHT_STRIDE, LIGHTS_FLOATS, MAX_LIGHTS, buildLightEmitShader, hasLightEmit, } from "./shaders/lights";
29
30
  import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground";
30
31
  import { outlineShaderWgsl } from "./shaders/passes/outline";
31
32
  import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass";
@@ -33,14 +34,15 @@ import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shader
33
34
  import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
34
35
  import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
35
36
  import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
36
- import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader, parseEffectAnchors, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
37
- import { buildParticleComputeShader, buildParticleRenderShader, parseParticleBlend, parseParticleBloom, parseParticleCount, particleEntryPoints, PARTICLE_STRIDE, } from "./shaders/passes/particles";
38
- import { SIM_FORMAT, GRID_MAX, buildSimShader, parseGridSize, gridEntryPoint, } from "./shaders/passes/grid";
37
+ import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
38
+ import { buildParticleComputeShader, buildParticleRenderShader, particleEntryPoints, PARTICLE_STRIDE, } from "./shaders/passes/particles";
39
+ import { SIM_FORMAT, GRID_MAX, buildSimShader, gridEntryPoint, } from "./shaders/passes/grid";
39
40
  import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails";
40
41
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
41
42
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap";
42
43
  import { compileGraph } from "./graph/compile";
43
44
  import { DEFAULT_GRAPH } from "./graph/presets/default";
45
+ import { parseDirectives, stripDirectives } from "./shaders/directives";
44
46
  import { UNLIT_GRAPH } from "./graph/presets/unlit";
45
47
  import { FACE_GRAPH } from "./graph/presets/face";
46
48
  import { HAIR_GRAPH } from "./graph/presets/hair";
@@ -597,13 +599,13 @@ const FIELD_LAYER_BLEND = {
597
599
  alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
598
600
  };
599
601
  /**
600
- * `// @layer additive` — for LIGHT rather than matter.
602
+ * `#layer additive` — for LIGHT rather than matter.
601
603
  *
602
604
  * Alpha-over is right for anything with mass: smoke, fog, a backdrop. It is
603
605
  * wrong for a glow, and visibly so the moment two of them cross — the later
604
606
  * bolt occludes the earlier one in proportion to its own brightness, when what
605
607
  * light does is get brighter. Unity and Unreal both ship exactly this split,
606
- * and the particle path here already has it as `@blend additive`.
608
+ * and the particle path here already has it as `#blend additive`.
607
609
  *
608
610
  * Colour still scales by the author's alpha, so alpha keeps meaning "how much
609
611
  * of this is here" and an effect fades out the way it always did. What changes
@@ -2043,7 +2045,44 @@ export class Engine {
2043
2045
  primitive: { topology: "triangle-list" },
2044
2046
  });
2045
2047
  }
2046
- async compileEffect(wgsl, params,
2048
+ /**
2049
+ * Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
2050
+ * composite pass. ONE effect per scene, and the code says where it mounts by
2051
+ * which of these it defines — either, or both in one file:
2052
+ *
2053
+ * fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
2054
+ * fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f
2055
+ *
2056
+ * `background` is a LAYER between the base background and the scene,
2057
+ * over-composited onto whichever base is active (solid color, 360 equirect, or
2058
+ * transparency) — its alpha lets the base show through, so a starfield is
2059
+ * stars over the user's background color. `foreground` composites over the
2060
+ * finished frame instead, which is where rain, snow, petals and fog live, and
2061
+ * is handed `depth`: the camera-space distance in metres of whatever the scene
2062
+ * drew at that pixel (the far plane where it drew nothing). Compare a
2063
+ * particle's own distance against it and the model occludes it; fog just reads
2064
+ * it, since fog's alpha IS a function of distance.
2065
+ *
2066
+ * `ray` is the pixel's normalized world-space view direction (LH, +Z forward —
2067
+ * what the skybox samples by), `uv` is 0..1 bottom-left origin, `time` is
2068
+ * seconds since apply, and `bgResolution()` gives the canvas size. Return sRGB
2069
+ * + alpha; alpha is the only "how much does this replace" control there is.
2070
+ * Declared `params` arrive as `params.<name>` (number → f32, Vec3 → vec3f),
2071
+ * shared by both mounts, and are later tweaked without recompiling via
2072
+ * setEffectParam.
2073
+ *
2074
+ * Both mounts are display-space: neither affects lighting, bloom or
2075
+ * tonemapping, and both are captured by offline export. A foreground makes the
2076
+ * scene pass STORE its depth buffer (it otherwise discards it into tile
2077
+ * memory) for as long as one is installed.
2078
+ *
2079
+ * Compiles off the hot path (async pipelines): on failure the previous effect
2080
+ * is KEPT and diagnostics are returned with line numbers relative to the
2081
+ * user's WGSL. Pass null to remove the effect.
2082
+ */
2083
+ async compileEffect(
2084
+ /** The author's file, directives included — parsed here and nowhere else. */
2085
+ authored, params,
2047
2086
  /** This effect's own declarations, already parsed by the caller — which had
2048
2087
  * to read them anyway to build the scene table. */
2049
2088
  anchors,
@@ -2051,7 +2090,22 @@ export class Engine {
2051
2090
  alias) {
2052
2091
  const noMounts = { background: false, foreground: false };
2053
2092
  if (!this.device)
2054
- return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts };
2093
+ return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts, params: [], duration: 0 };
2094
+ // WHAT THE FILE DECLARES, read once. Everything below takes it from `d`
2095
+ // rather than running a regex of its own — eight parsers over one file was
2096
+ // eight chances to disagree about what it said, and they did.
2097
+ //
2098
+ // An unrecognised or malformed directive is an ERROR. `#` is not WGSL
2099
+ // syntax, so a line starting with one is unambiguously ours and there is
2100
+ // nothing to be lenient about; the old spelling lived in comments, where a
2101
+ // typo was indistinguishable from prose and could only ever be warned about.
2102
+ const parsed = parseDirectives(authored);
2103
+ if (parsed.errors.length)
2104
+ return { ok: false, diagnostics: parsed.errors, mounts: noMounts, params: [], duration: 0 };
2105
+ const d = parsed.directives;
2106
+ // The compiler sees the file with its directive lines BLANKED, so every
2107
+ // diagnostic below still names the line the author is looking at.
2108
+ const wgsl = stripDirectives(authored);
2055
2109
  // ── Which mounts did the author ask for? A declaration, not a setting: the
2056
2110
  // entry points present in the source are the ones compiled in. Matching the
2057
2111
  // `fn` keyword is enough to be safe against a `foreground` LOCAL or a call
@@ -2067,14 +2121,10 @@ export class Engine {
2067
2121
  const te = trailEntryPoints(wgsl);
2068
2122
  const wantsTrails = te.width || te.shade;
2069
2123
  if (wantsTrails && !(te.width && te.shade)) {
2070
- return {
2071
- ok: false,
2072
- diagnostics: [
2124
+ return { ok: false, diagnostics: [
2073
2125
  `a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
2074
2126
  `fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
2075
- ],
2076
- mounts: noMounts,
2077
- };
2127
+ ], mounts: noMounts, params: [], duration: 0 };
2078
2128
  }
2079
2129
  if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
2080
2130
  const missing = [
@@ -2082,7 +2132,7 @@ export class Engine {
2082
2132
  pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
2083
2133
  pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
2084
2134
  ].filter(Boolean);
2085
- return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts };
2135
+ return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts, params: [], duration: 0 };
2086
2136
  }
2087
2137
  // One file, one kind — for now.
2088
2138
  //
@@ -2097,35 +2147,27 @@ export class Engine {
2097
2147
  // says so plainly instead of failing with "unresolved type Particle" from a
2098
2148
  // pass they did not know they were compiling into.
2099
2149
  if ((wantsParticles || wantsTrails) && (hasBackground || hasForeground)) {
2100
- return {
2101
- ok: false,
2102
- diagnostics: [
2150
+ return { ok: false, diagnostics: [
2103
2151
  "an effect declares field mounts (background/foreground) or particles, not both — " +
2104
2152
  "split them into two effects",
2105
- ],
2106
- mounts: noMounts,
2107
- };
2153
+ ], mounts: noMounts, params: [], duration: 0 };
2108
2154
  }
2109
2155
  // lightEmit counts as a mount on its own: a pure lighting rig draws nothing
2110
2156
  // and is still an effect — it is how a scene gets stage lights without also
2111
2157
  // getting geometry it did not ask for.
2112
2158
  if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl)) {
2113
- return {
2114
- ok: false,
2115
- diagnostics: [
2159
+ return { ok: false, diagnostics: [
2116
2160
  "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
2117
2161
  "fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
2118
2162
  "the particle trio (particleInit/particleStep/particleShade), " +
2119
2163
  "the ribbon pair (trailWidth/trailShade), " +
2120
- "or fn lightEmit(i: u32) -> RzLight with // @lights <n>",
2121
- ],
2122
- mounts: noMounts,
2123
- };
2164
+ "or fn lightEmit(i: u32) -> RzLight with #lights <n>",
2165
+ ], mounts: noMounts, params: [], duration: 0 };
2124
2166
  }
2125
2167
  const mounts = { background: hasBackground, foreground: hasForeground };
2126
2168
  // ── Directives only some mounts honour ──
2127
2169
  //
2128
- // @bloom sets the aux mask, and only the particle and ribbon modules write
2170
+ // #bloom sets the aux mask, and only the particle and ribbon modules write
2129
2171
  // that mask: they draw inside the scene pass, in HDR, while the bloom
2130
2172
  // pyramid can still see them. A field effect composites in DISPLAY space
2131
2173
  // after tone mapping, so there is nothing left to pick it up and the
@@ -2138,30 +2180,8 @@ export class Engine {
2138
2180
  // pinning an effect that declares this has to keep installing; saying so is
2139
2181
  // all that was ever missing.
2140
2182
  const warnings = [];
2141
- // A DIRECTIVE THAT PARSED AS NOTHING.
2142
- //
2143
- // Every pragma is matched with `\s*$` after it, so a line that carries a
2144
- // note as well — `// @fullres — glyph edges are sub-pixel detail` — matches
2145
- // none of them and is read as an ordinary comment. Nothing failed, nothing
2146
- // said anything, and the effect simply ran without the property it asked
2147
- // for: three shipped effects were silently half-res and a fourth silently
2148
- // stopped being additive, each one's first line explaining why it needed
2149
- // the thing it was not getting.
2150
- //
2151
- // Every directive this engine knows, so an unrecognised one is named rather
2152
- // than ignored. Cheap: it runs once per install, over a file a human wrote.
2153
- for (const m of wgsl.matchAll(/^[ \t]*\/\/[ \t]*(@[a-zA-Z]+)(.*)$/gm)) {
2154
- const [, tag, rest] = m;
2155
- if (!Engine.KNOWN.has(tag)) {
2156
- warnings.push(`${tag} is not a directive this engine knows — it will be ignored.`);
2157
- }
2158
- else if (rest.trim() && !/^\s*[\w.\-+]+(\s+[\w.\-+]+)*\s*$/.test(rest)) {
2159
- warnings.push(`${tag} has a note on the same line, so it does not parse and is being IGNORED. ` +
2160
- `A directive must be alone on its line — put the note on the next one.`);
2161
- }
2162
- }
2163
- if (parseParticleBloom(wgsl) && !wantsParticles && !wantsTrails) {
2164
- warnings.push("// @bloom does nothing here. A field effect (background/foreground) composites after tone " +
2183
+ if (d.bloom && !wantsParticles && !wantsTrails) {
2184
+ warnings.push("#bloom does nothing here. A field effect (background/foreground) composites after tone " +
2165
2185
  "mapping, past the bloom pyramid — the directive applies to particles and ribbons, which draw " +
2166
2186
  "in HDR inside the scene pass. Make the effect's own falloff brighter instead.");
2167
2187
  }
@@ -2181,7 +2201,7 @@ export class Engine {
2181
2201
  let cursor = 0;
2182
2202
  for (const [name, value] of entries) {
2183
2203
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
2184
- return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts };
2204
+ return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration };
2185
2205
  }
2186
2206
  const isVec = typeof value !== "number";
2187
2207
  const align = isVec ? 16 : 4;
@@ -2210,7 +2230,7 @@ export class Engine {
2210
2230
  // module (buildFieldShader), so a bad effect can no longer produce errors at
2211
2231
  // line numbers in a shader the author never wrote — and installing one no
2212
2232
  // longer recompiles the composite's tone-mapping half at all.
2213
- const gridSize = gridEntryPoint(wgsl) ? parseGridSize(wgsl, GRID_MAX) || 256 : 0;
2233
+ const gridSize = gridEntryPoint(wgsl) ? Math.min(d.grid || 256, GRID_MAX) : 0;
2214
2234
  // `alias` goes in: a field effect reads bones through _rzSlot exactly as a
2215
2235
  // particle one does, and it was the only module never handed the mapping.
2216
2236
  const fieldEffect = hasBackground || hasForeground ? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length } : null;
@@ -2219,10 +2239,10 @@ export class Engine {
2219
2239
  const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source });
2220
2240
  const scopeErr = await this.device.popErrorScope();
2221
2241
  if (scopeErr)
2222
- return { ok: false, diagnostics: [scopeErr.message], mounts };
2242
+ return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration };
2223
2243
  // Declared like every other mount property: by what the source says, not by
2224
2244
  // a setting somewhere else that an author cannot see from the file.
2225
- const layerBlend = /^\s*\/\/\s*@layer\s+additive\s*$/m.test(wgsl)
2245
+ const layerBlend = d.additiveLayer
2226
2246
  ? FIELD_LAYER_BLEND_ADDITIVE
2227
2247
  : FIELD_LAYER_BLEND;
2228
2248
  let fieldPipeline = null;
@@ -2239,7 +2259,7 @@ export class Engine {
2239
2259
  if (diagnostics.length === 0 && fieldScopeErr)
2240
2260
  diagnostics.push(fieldScopeErr.message);
2241
2261
  if (diagnostics.length > 0)
2242
- return { ok: false, diagnostics, mounts };
2262
+ return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration };
2243
2263
  try {
2244
2264
  fieldPipeline = await this.device.createRenderPipelineAsync({
2245
2265
  label: "field layer pipeline",
@@ -2266,7 +2286,7 @@ export class Engine {
2266
2286
  });
2267
2287
  }
2268
2288
  catch (e) {
2269
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts };
2289
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration };
2270
2290
  }
2271
2291
  }
2272
2292
  let identity;
@@ -2290,7 +2310,7 @@ export class Engine {
2290
2310
  ]);
2291
2311
  }
2292
2312
  catch (e) {
2293
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts };
2313
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration };
2294
2314
  }
2295
2315
  // Built BEFORE the swap: a particle stage that fails to compile has to leave
2296
2316
  // the previously installed effect running, exactly as a bad composite does.
@@ -2305,18 +2325,18 @@ export class Engine {
2305
2325
  grid?.textures[1].destroy();
2306
2326
  grid?.uniform.destroy();
2307
2327
  trails?.uniform.destroy();
2308
- return { ok: false, diagnostics, mounts };
2328
+ return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration };
2309
2329
  };
2310
2330
  let particles = null;
2311
2331
  if (wantsParticles) {
2312
- const built = await this.buildParticles(wgsl, anchors, alias);
2332
+ const built = await this.buildParticles(wgsl, d, anchors, alias);
2313
2333
  if (!built.ok)
2314
2334
  return abandon(built.diagnostics);
2315
2335
  particles = built.state;
2316
2336
  }
2317
2337
  let grid = null;
2318
2338
  if (gridEntryPoint(wgsl)) {
2319
- const built = await this.buildSim(wgsl, anchors, alias);
2339
+ const built = await this.buildSim(wgsl, d, anchors, alias);
2320
2340
  if (!built.ok)
2321
2341
  return abandon(built.diagnostics);
2322
2342
  grid = built.state;
@@ -2327,13 +2347,9 @@ export class Engine {
2327
2347
  // bone recorded without one would read zeroes and paint a line to the origin.
2328
2348
  const trailSlots = anchors.filter((a) => a.trail).length;
2329
2349
  if (trailSlots === 0) {
2330
- return {
2331
- ok: false,
2332
- diagnostics: ["a ribbon effect needs at least one // @anchor <bone> trail"],
2333
- mounts,
2334
- };
2350
+ return { ok: false, diagnostics: ["a ribbon effect needs at least one #anchor <bone> trail"], mounts, params: d.params, duration: d.duration };
2335
2351
  }
2336
- const built = await this.buildTrails(wgsl, anchors, alias);
2352
+ const built = await this.buildTrails(wgsl, d, anchors, alias);
2337
2353
  if (!built.ok)
2338
2354
  return abandon(built.diagnostics);
2339
2355
  trails = built.state;
@@ -2346,13 +2362,13 @@ export class Engine {
2346
2362
  // count is a function nothing calls. Either alone is a silent blank, which
2347
2363
  // is the worst way for an effect to fail.
2348
2364
  let lights = null;
2349
- const declaredLights = parseLightCount(wgsl, MAX_LIGHTS);
2365
+ const declaredLights = Math.min(d.lights, MAX_LIGHTS);
2350
2366
  const emits = hasLightEmit(wgsl);
2351
2367
  if (declaredLights > 0 !== emits) {
2352
2368
  return abandon([
2353
2369
  emits
2354
- ? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with // @lights <n>"
2355
- : "// @lights <n> needs fn lightEmit(i: u32) -> RzLight to fill those slots",
2370
+ ? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with #lights <n>"
2371
+ : "#lights <n> needs fn lightEmit(i: u32) -> RzLight to fill those slots",
2356
2372
  ]);
2357
2373
  }
2358
2374
  if (declaredLights > 0) {
@@ -2380,6 +2396,8 @@ export class Engine {
2380
2396
  }
2381
2397
  const instance = {
2382
2398
  wgsl,
2399
+ paramDecls: d.params,
2400
+ duration: d.duration,
2383
2401
  paramLayout: layout,
2384
2402
  paramsBuffer,
2385
2403
  paramsData,
@@ -2394,6 +2412,12 @@ export class Engine {
2394
2412
  // The effect's own clock starts now. Per effect so that one installed
2395
2413
  // later still gets a frame where rzGridFrame() is 0 and can seed.
2396
2414
  epochScene: this.sceneClock,
2415
+ // Fully on, unscheduled. An effect that is installed is showing;
2416
+ // scheduling it is something a caller does afterwards, and an install
2417
+ // that silently began at zero would look like a compile that failed.
2418
+ influence: 1,
2419
+ window: null,
2420
+ weight: 1,
2397
2421
  // Its OWN resolution, no longer the scene's: an effect that never asked
2398
2422
  // for full res is not promoted because a neighbour did.
2399
2423
  // FULL RESOLUTION UNLESS TOLD OTHERWISE.
@@ -2406,12 +2430,12 @@ export class Engine {
2406
2430
  // wearing a different hat: the safe answer has to be the one you get
2407
2431
  // for saying nothing.
2408
2432
  //
2409
- // The cost is real and is why the half layer stays: `@halfres` is worth
2433
+ // The cost is real and is why the half layer stays: `#halfres` is worth
2410
2434
  // about 3.7x on a full-screen effect (Footprints, measured, 1.2ms
2411
2435
  // against 4.5ms). It is the right call for a soft additive glow, which
2412
2436
  // upsamples invisibly — and it is now a claim an author makes about
2413
2437
  // their own effect rather than a fate that befalls one.
2414
- fieldLayer: /^\s*\/\/\s*@halfres\s*$/m.test(wgsl) ? 1 : 0,
2438
+ fieldLayer: d.fieldLayer,
2415
2439
  fieldPipeline,
2416
2440
  fieldClock,
2417
2441
  // Filled by rebuildFieldBindGroup below, which needs the instance to
@@ -2451,7 +2475,7 @@ export class Engine {
2451
2475
  async setEffects(list) {
2452
2476
  const noMounts = { background: false, foreground: false };
2453
2477
  if (!this.device)
2454
- return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts }];
2478
+ return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0 }];
2455
2479
  const requested = list ?? [];
2456
2480
  if (requested.length === 0) {
2457
2481
  for (const e of this.effects) {
@@ -2475,7 +2499,12 @@ export class Engine {
2475
2499
  }
2476
2500
  // One table for the whole scene, built before anything compiles: an effect's
2477
2501
  // alias is its row, and a bone two effects both name is allocated once.
2478
- const perEffectAnchors = requested.map((e) => parseEffectAnchors(e.wgsl, MAX_EFFECT_ANCHORS));
2502
+ // The table needs every effect's anchors before any of them compiles, so
2503
+ // this is the one place a source is read twice — compileEffect parses it
2504
+ // again for everything else. A malformed file yields no anchors here and
2505
+ // fails with its real diagnostics there, which is the right order: the
2506
+ // error names the line, not the table.
2507
+ const perEffectAnchors = requested.map((e) => parseDirectives(e.wgsl).directives.anchors.slice(0, MAX_EFFECT_ANCHORS));
2479
2508
  const table = buildAnchorTable(perEffectAnchors, MAX_EFFECT_ANCHORS);
2480
2509
  const results = [];
2481
2510
  const instances = [];
@@ -2489,6 +2518,8 @@ export class Engine {
2489
2518
  instances.push(built.instance);
2490
2519
  results.push({
2491
2520
  ok: true,
2521
+ params: built.instance.paramDecls,
2522
+ duration: built.instance.duration,
2492
2523
  // Installed, and still with something to say — a directive that parsed
2493
2524
  // but will never fire. Same channel as the dropped-anchor note below.
2494
2525
  diagnostics: built.warnings,
@@ -2548,7 +2579,7 @@ export class Engine {
2548
2579
  });
2549
2580
  this.compositePipelineIdentity = this.makeCompositePipeline(compositeModule, false, "composite pipeline (gamma=1)");
2550
2581
  this.compositePipelineGamma = this.makeCompositePipeline(compositeModule, true, "composite pipeline (gamma!=1)");
2551
- // Nothing to promote any more: `@fullres` is per effect, read into
2582
+ // Nothing to promote any more: `#fullres` is per effect, read into
2552
2583
  // fieldLayer when the instance is built, and both target pairs exist for
2553
2584
  // the life of the surface. What used to be a scene-wide decision made here
2554
2585
  // is now each effect's own.
@@ -2565,20 +2596,25 @@ export class Engine {
2565
2596
  const noMounts = { background: false, foreground: false };
2566
2597
  if (wgsl === null) {
2567
2598
  await this.setEffects(null);
2568
- return { ok: true, diagnostics: [], mounts: noMounts };
2599
+ return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0 };
2569
2600
  }
2570
2601
  const [result] = await this.setEffects([{ wgsl, params }]);
2571
- return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts };
2572
- }
2573
- async buildParticles(wgsl, anchors,
2602
+ return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts, params: [], duration: 0 };
2603
+ }
2604
+ async buildParticles(
2605
+ /** Already stripped of directives — see compileEffect. */
2606
+ wgsl,
2607
+ /** What the file declared. Read here rather than re-parsed: the source no
2608
+ * longer carries the lines, and two readers is how they drift. */
2609
+ d, anchors,
2574
2610
  /** This effect's local→scene slot map. Passed rather than read off the
2575
2611
  * engine: the builders run BEFORE the swap, so this.anchorTable still
2576
2612
  * describes the effect that is still on screen. */
2577
2613
  alias) {
2578
2614
  // No pragma means "some": an author who wrote the trio clearly wants
2579
2615
  // particles, and failing over a missing comment would be pedantry.
2580
- const count = parseParticleCount(wgsl, Engine.MAX_PARTICLES) || 1024;
2581
- const src = { wgsl, count, blend: parseParticleBlend(wgsl), bloom: parseParticleBloom(wgsl) };
2616
+ const count = Math.min(d.particles || 1024, Engine.MAX_PARTICLES);
2617
+ const src = { wgsl, count, blend: d.particleBlend, bloom: d.bloom };
2582
2618
  // Sparks want to spawn where a trail is, so the particle stages see the same
2583
2619
  // cast buffer the trail draw reads.
2584
2620
  const cast = {
@@ -2616,10 +2652,13 @@ export class Engine {
2616
2652
  });
2617
2653
  const uniform = this.device.createBuffer({
2618
2654
  label: "particle uniforms",
2619
- size: 16,
2655
+ // Two vec4-sized rows: (time, dt, count, frame) and (weight, _, _, _).
2656
+ // The first was exactly full, and weight has to live in the same buffer
2657
+ // as the clock or a frame could draw one without the other.
2658
+ size: 32,
2620
2659
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2621
2660
  });
2622
- const uniformBytes = new ArrayBuffer(16);
2661
+ const uniformBytes = new ArrayBuffer(32);
2623
2662
  const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) };
2624
2663
  // Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
2625
2664
  // visible to the vertex stage at all (WebGPU forbids it — a vertex shader
@@ -2825,6 +2864,11 @@ export class Engine {
2825
2864
  emitLights(encoder) {
2826
2865
  for (const e of this.effects) {
2827
2866
  const l = e.lights;
2867
+ // NOT skipped at weight 0, unlike every other mount. Each effect writes
2868
+ // its OWN slots in a shared buffer that is never cleared, so a skipped
2869
+ // dispatch leaves last frame's lights burning — the one place where not
2870
+ // running is the wrong answer. The shader zeroes them instead, and the
2871
+ // dispatch it costs is a single workgroup.
2828
2872
  if (!l || l.data[2] === 0)
2829
2873
  continue;
2830
2874
  // The effect's OWN epoch — the same one its field, particle, ribbon and
@@ -2832,6 +2876,7 @@ export class Engine {
2832
2876
  // clock that was shared from the first installed effect; that clock is
2833
2877
  // per effect now, so every mount in one file agrees by construction.
2834
2878
  l.data[0] = this.sceneClock - e.epochScene;
2879
+ l.data[3] = e.weight;
2835
2880
  this.device.queue.writeBuffer(l.uniform, 0, l.data.buffer);
2836
2881
  const cp = encoder.beginComputePass({ label: "light emit" });
2837
2882
  cp.setPipeline(l.pipeline);
@@ -2846,6 +2891,11 @@ export class Engine {
2846
2891
  if (!p)
2847
2892
  continue;
2848
2893
  p.data[0] = this.sceneClock - e.epochScene;
2894
+ // The SIMULATION runs at every weight, 0 included — only the draw stops.
2895
+ // A scheduled effect that froze while faded out would resume from the
2896
+ // state it left rather than the one it would have reached, so fading one
2897
+ // back in would rewind it.
2898
+ p.data[4] = e.weight;
2849
2899
  // Clamped: a backgrounded tab returns with a delta of whole seconds, and an
2850
2900
  // unclamped step flings every particle out of the scene in one frame.
2851
2901
  p.data[1] = Math.min(0.1, Math.max(0, deltaTime));
@@ -2863,7 +2913,7 @@ export class Engine {
2863
2913
  renderParticles(pass, view) {
2864
2914
  for (const e of this.effects) {
2865
2915
  const p = e.particles;
2866
- if (!p)
2916
+ if (!p || e.weight === 0)
2867
2917
  continue;
2868
2918
  pass.setPipeline(p.render);
2869
2919
  pass.setBindGroup(0, view === "mirror" ? p.mirrorRenderBind : p.renderBind);
@@ -2877,7 +2927,12 @@ export class Engine {
2877
2927
  * and several declared bones is still one draw and nothing is computed per
2878
2928
  * frame on the CPU.
2879
2929
  */
2880
- async buildTrails(wgsl, anchors,
2930
+ async buildTrails(
2931
+ /** Already stripped of directives — see compileEffect. */
2932
+ wgsl,
2933
+ /** What the file declared. Read here rather than re-parsed: the source no
2934
+ * longer carries the lines, and two readers is how they drift. */
2935
+ d, anchors,
2881
2936
  /** This effect's local→scene slot map. Passed rather than read off the
2882
2937
  * engine: the builders run BEFORE the swap, so this.anchorTable still
2883
2938
  * describes the effect that is still on screen. */
@@ -2891,7 +2946,7 @@ export class Engine {
2891
2946
  // nothing before: ribbon i was read as anchor slot i.
2892
2947
  const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0);
2893
2948
  const slots = ribbonSlots.length;
2894
- const src = { wgsl, slots, ribbonSlots, blend: parseParticleBlend(wgsl), bloom: parseParticleBloom(wgsl) };
2949
+ const src = { wgsl, slots, ribbonSlots, blend: d.particleBlend, bloom: d.bloom };
2895
2950
  const code = buildTrailShader(src, {
2896
2951
  subjects: MAX_EFFECT_SUBJECTS,
2897
2952
  samples: TRAIL_SAMPLES,
@@ -3028,7 +3083,7 @@ export class Engine {
3028
3083
  * Takes the pass rather than opening one: that IS the change.
3029
3084
  */
3030
3085
  drawTrails(pass, view) {
3031
- const drawn = this.effects.filter((e) => e.trails);
3086
+ const drawn = this.effects.filter((e) => e.trails && e.weight > 0);
3032
3087
  if (drawn.length === 0)
3033
3088
  return;
3034
3089
  for (const e of drawn) {
@@ -3052,6 +3107,7 @@ export class Engine {
3052
3107
  if (view === "camera") {
3053
3108
  t.data[0] = this.sceneClock - e.epochScene;
3054
3109
  t.data[1] = live;
3110
+ t.data[2] = e.weight;
3055
3111
  this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer);
3056
3112
  }
3057
3113
  pass.setPipeline(t.pipeline);
@@ -3063,9 +3119,25 @@ export class Engine {
3063
3119
  * upsample. Runs the whole quad — uniform control flow, so effects may use
3064
3120
  * derivatives freely, which the old inline path had to forbid. */
3065
3121
  renderFieldPass(encoder) {
3066
- const drawn = this.effects.filter((e) => e.fieldPipeline && e.fieldBindGroups);
3067
- if (drawn.length === 0)
3122
+ // TWO PREDICATES, deliberately, and they are not interchangeable.
3123
+ //
3124
+ // MOUNTED decides whether the pass runs, and it must agree exactly with
3125
+ // fieldPairUsed — that is what the composite's bind group was built against,
3126
+ // at install, and it is not rebuilt per frame. A pass skipped under a
3127
+ // binding that still points at its target leaves the last frame it drew
3128
+ // sitting there, so an effect faded to nothing would freeze on screen
3129
+ // instead of disappearing.
3130
+ //
3131
+ // DRAWN decides what is drawn into it, and this is where weight is worth
3132
+ // something: a field mount is a full-screen quad however little of the frame
3133
+ // it ends up touching, so an effect that is scheduled off would otherwise
3134
+ // shade every pixel to multiply it out to nothing. The pass still clears —
3135
+ // which is what makes the layer transparent rather than stale — and shades
3136
+ // nothing.
3137
+ const mounted = this.effects.filter((e) => e.fieldPipeline && e.fieldBindGroups);
3138
+ if (mounted.length === 0)
3068
3139
  return;
3140
+ const drawn = mounted.filter((e) => e.weight > 0);
3069
3141
  // Each effect's own clock, before the pass that reads it. Seconds since
3070
3142
  // THIS effect was installed — so an effect added to a running scene starts
3071
3143
  // at zero and can seed, rather than joining whatever the first one is up to.
@@ -3073,6 +3145,7 @@ export class Engine {
3073
3145
  if (!e.fieldClock)
3074
3146
  continue;
3075
3147
  this.fieldClockScratch[0] = this.sceneClock - e.epochScene;
3148
+ this.fieldClockScratch[1] = e.weight;
3076
3149
  this.device.queue.writeBuffer(e.fieldClock, 0, this.fieldClockScratch.buffer);
3077
3150
  }
3078
3151
  // ONE PASS PER RESOLUTION, N draws each, in document order — a pair is
@@ -3087,7 +3160,7 @@ export class Engine {
3087
3160
  // (fieldLayerView). Clearing and storing an empty full-res rgba16f pair is
3088
3161
  // two 16MB writes a frame to produce the transparent black the fallback
3089
3162
  // already is. Most scenes leave the full-res pair empty, since an effect only
3090
- // lands there by declaring @fullres.
3163
+ // lands there by declaring #fullres.
3091
3164
  let stamped = false;
3092
3165
  for (let i = 0; i < Engine.FIELD_SCALES.length; i++) {
3093
3166
  const bg = this.fieldBgViews[i];
@@ -3101,7 +3174,7 @@ export class Engine {
3101
3174
  { view: fg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
3102
3175
  ],
3103
3176
  // One query pair is reserved for "field", and it goes to the first pair
3104
- // that actually runs — full res when something declared @fullres, half
3177
+ // that actually runs — full res when something declared #fullres, half
3105
3178
  // otherwise. Pinning it to i === 0 would have measured a pass that, now
3106
3179
  // that empty pairs are skipped, usually does not happen.
3107
3180
  timestampWrites: stamped ? undefined : this.stamps("field"),
@@ -3165,12 +3238,17 @@ export class Engine {
3165
3238
  * against: rzGridFrame() is 0 on the first step and every value it reads is
3166
3239
  * zero, so seeding is just "if frame is 0, return the initial state".
3167
3240
  */
3168
- async buildSim(wgsl, anchors,
3241
+ async buildSim(
3242
+ /** Already stripped of directives — see compileEffect. */
3243
+ wgsl,
3244
+ /** What the file declared. Read here rather than re-parsed: the source no
3245
+ * longer carries the lines, and two readers is how they drift. */
3246
+ d, anchors,
3169
3247
  /** This effect's local→scene slot map. Passed rather than read off the
3170
3248
  * engine: the builders run BEFORE the swap, so this.anchorTable still
3171
3249
  * describes the effect that is still on screen. */
3172
3250
  alias) {
3173
- const size = parseGridSize(wgsl, GRID_MAX) || 256;
3251
+ const size = Math.min(d.grid || 256, GRID_MAX);
3174
3252
  const cast = {
3175
3253
  subjects: MAX_EFFECT_SUBJECTS,
3176
3254
  samples: TRAIL_SAMPLES,
@@ -3305,10 +3383,19 @@ export class Engine {
3305
3383
  getEffectMounts() {
3306
3384
  return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false };
3307
3385
  }
3308
- /** Write one effect param (declared at setEffect) — a uniform write, no
3309
- * recompile; the instant tier, like setStyleParam. */
3310
- setEffectParam(name, value) {
3311
- const fx = this.effect;
3386
+ /**
3387
+ * Set one parameter on one INSTANCE.
3388
+ *
3389
+ * By index, because the scene holds a list and the same effect may appear in
3390
+ * it twice with different values — which is the whole point of an instance
3391
+ * and was impossible while this addressed `this.effect`, a singular left over
3392
+ * from when a scene could wear exactly one.
3393
+ *
3394
+ * A write, not a recompile: parameters live in their own uniform buffer, so
3395
+ * dragging a slider costs a 16-byte upload rather than a shader build.
3396
+ */
3397
+ setEffectParam(index, name, value) {
3398
+ const fx = this.effects[index];
3312
3399
  if (!fx || !fx.paramsBuffer)
3313
3400
  return;
3314
3401
  const slot = fx.paramLayout.get(name);
@@ -3323,6 +3410,114 @@ export class Engine {
3323
3410
  }
3324
3411
  this.device.queue.writeBuffer(fx.paramsBuffer, 0, fx.paramsData);
3325
3412
  }
3413
+ /**
3414
+ * How much of one instance is showing, 0..1.
3415
+ *
3416
+ * The third of the three things an instance has — parameters, weight, time —
3417
+ * and the one a scheduler drives. Weight is not a parameter: a parameter is
3418
+ * whatever the author decided to expose and means only what their source
3419
+ * makes it mean, while weight means the same thing for every effect ever
3420
+ * written, including one whose author never heard of it. That is why it is
3421
+ * applied by engine-generated code at each mount's output rather than handed
3422
+ * to the source as a uniform to respect.
3423
+ *
3424
+ * At 0 nothing is drawn: no field quad, no particle draw, no ribbon, no light
3425
+ * dispatch. A scheduled effect outside its window costs its simulation and
3426
+ * nothing else — and a particle effect keeps simulating on purpose, so that
3427
+ * fading one back in continues rather than rewinds.
3428
+ *
3429
+ * Instant, and free: a float in a uniform every mount already uploads once a
3430
+ * frame. Nothing recompiles, so this is safe to drive per frame from a
3431
+ * timeline.
3432
+ */
3433
+ setEffectInfluence(index, influence) {
3434
+ const fx = this.effects[index];
3435
+ if (!fx)
3436
+ return;
3437
+ // Clamped rather than trusted: above 1 the field's own clamp would swallow
3438
+ // it while an additive particle would happily keep getting brighter, so the
3439
+ // same number would mean two things.
3440
+ fx.influence = Math.min(1, Math.max(0, influence));
3441
+ }
3442
+ getEffectInfluence(index) {
3443
+ return this.effects[index]?.influence ?? 0;
3444
+ }
3445
+ /**
3446
+ * Schedule one instance: when it is alive, and how it enters and leaves.
3447
+ *
3448
+ * Null is the unscheduled case — on for the whole scene, on the scene's own
3449
+ * clock — and is what an effect starts as.
3450
+ *
3451
+ * The engine evaluates this every frame rather than taking a weight from a
3452
+ * caller, because every loop that renders would otherwise have to remember to
3453
+ * drive it. The offline export loop already carries a scar about exactly that
3454
+ * shape of bug. Evaluating where the scene clock advances means playback and
3455
+ * export cannot disagree, and neither can forget.
3456
+ *
3457
+ * A caller that wants to drive an effect from something OTHER than the scene
3458
+ * clock — an animation's progress, a skill firing — leaves this null and
3459
+ * writes setEffectInfluence and setEffectTime itself, per frame. Both paths
3460
+ * exist on purpose; this one is what a timeline wants.
3461
+ */
3462
+ setEffectSchedule(index, windows) {
3463
+ const fx = this.effects[index];
3464
+ if (!fx)
3465
+ return;
3466
+ fx.window = windows && windows.length ? windows : null;
3467
+ }
3468
+ getEffectSchedule(index) {
3469
+ return this.effects[index]?.window ?? null;
3470
+ }
3471
+ /**
3472
+ * Every scheduled effect, at the current scene clock.
3473
+ *
3474
+ * Called once a frame, BEFORE anything reads a weight or a clock. An effect
3475
+ * with no window keeps whatever a caller last set, which is what makes the
3476
+ * manual path above work — evaluating it would fight the caller for the field
3477
+ * every frame.
3478
+ */
3479
+ evaluateEffectSchedules() {
3480
+ // Read ONCE: it walks the cast, and every effect wants the same answer.
3481
+ const transport = this.transportTime();
3482
+ for (const fx of this.effects) {
3483
+ if (!fx.window || fx.window.length === 0) {
3484
+ fx.weight = fx.influence;
3485
+ continue;
3486
+ }
3487
+ const at = effectState(fx.window, fx.influence, transport);
3488
+ fx.weight = at.weight;
3489
+ // Its own clock, expressed the way the mounts read it. Every mount
3490
+ // derives time from the epoch against sceneClock, so this one write moves
3491
+ // the field, the particles, the ribbons, lightEmit and the grid together
3492
+ // — and hands them the STRIP's local time while they keep running on the
3493
+ // smooth monotonic clock a particle integrator needs.
3494
+ fx.epochScene = this.sceneClock - at.time;
3495
+ }
3496
+ }
3497
+ /**
3498
+ * Move one instance's own clock to a given second.
3499
+ *
3500
+ * Everything an effect can animate is derived from its epoch — the field
3501
+ * clock, the particle and ribbon clocks, lightEmit's time argument, the grid's
3502
+ * frame counter — so moving the epoch moves all of them together and there is
3503
+ * no mount that can be left reading last frame's time.
3504
+ *
3505
+ * This is what lets an effect be SCHEDULED rather than merely switched on: an
3506
+ * instance that enters at bar 33 is handed a time that starts at zero there,
3507
+ * so it plays its own opening instead of joining whatever the scene clock had
3508
+ * reached. Feeding it the transport's time instead gives the other reading —
3509
+ * an effect that runs in lockstep with the music — and both are one call.
3510
+ */
3511
+ setEffectTime(index, time) {
3512
+ const fx = this.effects[index];
3513
+ if (!fx)
3514
+ return;
3515
+ fx.epochScene = this.sceneClock - time;
3516
+ }
3517
+ getEffectTime(index) {
3518
+ const fx = this.effects[index];
3519
+ return fx ? this.sceneClock - fx.epochScene : 0;
3520
+ }
3326
3521
  /** Patch bloom; GPU uniforms update immediately if `init()` has run. */
3327
3522
  /** Camera depth of field (see DepthOfFieldOptions). Free while disabled —
3328
3523
  * the scene pass only stores its depth buffer on frames the gather reads. */
@@ -4873,7 +5068,7 @@ export class Engine {
4873
5068
  format: this.hdrFormat,
4874
5069
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
4875
5070
  });
4876
- // The field layer — half resolution by default, full for @fullres effects.
5071
+ // The field layer — half resolution by default, full for #fullres effects.
4877
5072
  this.fieldFullW = width;
4878
5073
  this.fieldFullH = height;
4879
5074
  this.createFieldTargets();
@@ -5708,10 +5903,24 @@ export class Engine {
5708
5903
  this.cameraAnimation = null;
5709
5904
  this.camera.setVmdDriven(false);
5710
5905
  }
5711
- // Clock the camera VMD runs on: the first model with an active clip (playing or scrubbed),
5712
- // so a static stage in the scene never freezes the shot at frame 0. Falls back to the first
5713
- // model, then to 0 (empty scene).
5714
- cameraClockTime() {
5906
+ /**
5907
+ * THE TRANSPORT'S CLOCK where the scene is in its own playback.
5908
+ *
5909
+ * The first model with an active clip (playing or scrubbed), so a static stage
5910
+ * never freezes it at frame 0. Falls back to the first model with a clip, then
5911
+ * to 0 for an empty scene.
5912
+ *
5913
+ * NOT `sceneClock`, and the difference is the whole reason this has a name.
5914
+ * `sceneClock` only ever accumulates delta — it is how long the engine has
5915
+ * been running, it does not move when you scrub, and it does not stop when you
5916
+ * pause. Anything that should line up with what the transport shows has to
5917
+ * read THIS. An effect scheduled to frame 100 against sceneClock fires once,
5918
+ * a hundred frames after the page loaded, and never again.
5919
+ *
5920
+ * Deterministic offline: the export loop advances model animation by an exact
5921
+ * per-frame delta, so this reproduces frame for frame.
5922
+ */
5923
+ transportTime() {
5715
5924
  let fallback = null;
5716
5925
  for (const inst of this.modelInstances.values()) {
5717
5926
  // Stages are skipped outright. Scenery carries no motion, and it is added
@@ -8812,7 +9021,7 @@ export class Engine {
8812
9021
  }
8813
9022
  // Drive the shot from the camera VMD (synced to the animated model's clock).
8814
9023
  if (this.camera.vmdDriven && this.cameraAnimation) {
8815
- const pose = this.cameraAnimation.sample(this.cameraClockTime());
9024
+ const pose = this.cameraAnimation.sample(this.transportTime());
8816
9025
  if (pose)
8817
9026
  this.camera.setVmdPose(pose);
8818
9027
  }
@@ -8946,6 +9155,12 @@ export class Engine {
8946
9155
  // uniforms this frame.
8947
9156
  this.evaluateDissolveCycles();
8948
9157
  this.evaluateParamTracks();
9158
+ // FIRST among the things that read an effect, because every one of them
9159
+ // reads what this writes: the sim's clock, the particle uniform's weight,
9160
+ // the light dispatch, the field draw. Evaluated here rather than by a
9161
+ // caller so that playback, the export loop and a warm-up pass cannot
9162
+ // disagree about when an effect is alive — none of them has to remember it.
9163
+ this.evaluateEffectSchedules();
8949
9164
  this.stepSim(encoder, deltaTime);
8950
9165
  this.stepParticles(encoder, deltaTime);
8951
9166
  // Before the scene pass, which READS the slots this writes. Same buffer,
@@ -9008,7 +9223,7 @@ export class Engine {
9008
9223
  this.forEachInstance((inst) => this.renderModelTransparentPhase(pass, inst, camView));
9009
9224
  // Last in the pass: depth-tested against everything drawn above, so a
9010
9225
  // particle behind the character is simply hidden, and still inside the HDR
9011
- // target so an `@bloom` effect reaches the pyramid below.
9226
+ // target so an `#bloom` effect reaches the pyramid below.
9012
9227
  this.renderParticles(pass, "camera");
9013
9228
  // Ribbons, in the same pass and after the particles: both are additive
9014
9229
  // light in HDR, and both reach the bloom pyramid because of it. This used
@@ -10450,14 +10665,14 @@ Engine.JIGGLE_DAMPING_SCALE = 0.5;
10450
10665
  * cost a degenerate quad the rasteriser rejects, which is cheaper than the
10451
10666
  * prefix sum and readback a compacted draw list would need every frame.
10452
10667
  */
10453
- /** Ceiling for `// @particles`. Past this an author is asking for a stall. */
10668
+ /** Ceiling for `#particles`. Past this an author is asking for a stall. */
10454
10669
  Engine.MAX_PARTICLES = 65536;
10455
10670
  /**
10456
10671
  * The field layer: user background/foreground mounts, ONE TARGET PAIR PER
10457
10672
  * RESOLUTION. Index 0 is full, index 1 is half — coarsest last, so the
10458
10673
  * composite reads them full-over-half.
10459
10674
  *
10460
- * `@fullres` used to be a property of the shared targets: one effect
10675
+ * `#fullres` used to be a property of the shared targets: one effect
10461
10676
  * declaring it promoted the pass for every effect installed, so a starfield
10462
10677
  * that upsamples perfectly paid four times the pixels because a keyboard
10463
10678
  * beside it needed crisp edges. Measured, that was the largest avoidable cost
@@ -10481,7 +10696,7 @@ Engine.FIELD_SCALES = [1, 2];
10481
10696
  *
10482
10697
  * `field` earns its place now that a scene runs SEVERAL field effects at
10483
10698
  * once: it is one pass with N draws, its resolution is a property of the
10484
- * shared targets rather than of any one effect — so a single `@fullres`
10699
+ * shared targets rather than of any one effect — so a single `#fullres`
10485
10700
  * effect quadruples the pixel count for all of them — and it is the pass the
10486
10701
  * field restructure moves. Restructuring it while it was the only untimed
10487
10702
  * pass in the frame would have meant reasoning about the cost instead of
@@ -10526,66 +10741,6 @@ Engine.FILMIC_LUT_WIDTH = 256;
10526
10741
  // (vsync-to-vsync), recomputed at STATS_REFRESH_MS so the readout doesn't flicker.
10527
10742
  Engine.STATS_WINDOW = 120;
10528
10743
  Engine.STATS_REFRESH_MS = 500;
10529
- /**
10530
- * Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
10531
- * composite pass. ONE effect per scene, and the code says where it mounts by
10532
- * which of these it defines — either, or both in one file:
10533
- *
10534
- * fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
10535
- * fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f
10536
- *
10537
- * `background` is a LAYER between the base background and the scene,
10538
- * over-composited onto whichever base is active (solid color, 360 equirect, or
10539
- * transparency) — its alpha lets the base show through, so a starfield is
10540
- * stars over the user's background color. `foreground` composites over the
10541
- * finished frame instead, which is where rain, snow, petals and fog live, and
10542
- * is handed `depth`: the camera-space distance in metres of whatever the scene
10543
- * drew at that pixel (the far plane where it drew nothing). Compare a
10544
- * particle's own distance against it and the model occludes it; fog just reads
10545
- * it, since fog's alpha IS a function of distance.
10546
- *
10547
- * `ray` is the pixel's normalized world-space view direction (LH, +Z forward —
10548
- * what the skybox samples by), `uv` is 0..1 bottom-left origin, `time` is
10549
- * seconds since apply, and `bgResolution()` gives the canvas size. Return sRGB
10550
- * + alpha; alpha is the only "how much does this replace" control there is.
10551
- * Declared `params` arrive as `params.<name>` (number → f32, Vec3 → vec3f),
10552
- * shared by both mounts, and are later tweaked without recompiling via
10553
- * setEffectParam.
10554
- *
10555
- * Both mounts are display-space: neither affects lighting, bloom or
10556
- * tonemapping, and both are captured by offline export. A foreground makes the
10557
- * scene pass STORE its depth buffer (it otherwise discards it into tile
10558
- * memory) for as long as one is installed.
10559
- *
10560
- * Compiles off the hot path (async pipelines): on failure the previous effect
10561
- * is KEPT and diagnostics are returned with line numbers relative to the
10562
- * user's WGSL. Pass null to remove the effect.
10563
- */
10564
- /**
10565
- * Every directive an effect may declare — used ONLY to tell an author that
10566
- * the line they wrote is not doing what they think. See compileEffect.
10567
- *
10568
- * It has to be complete, including the ones this engine does not read itself:
10569
- * `@dissolve` is parsed by the host, and warning about it would be worse than
10570
- * the silence this replaced — a false alarm on a working line teaches authors
10571
- * to ignore the channel.
10572
- */
10573
- Engine.KNOWN = new Set([
10574
- "@anchor",
10575
- "@layer",
10576
- "@blend",
10577
- "@bloom",
10578
- "@lights",
10579
- "@grid",
10580
- "@particles",
10581
- "@halfres",
10582
- // Accepted and inert: full resolution is the default now, and an effect
10583
- // that still says so is right about what it wants. Warning about it would
10584
- // be telling authors off for the thing that used to be necessary.
10585
- "@fullres",
10586
- // The HOST's, not this engine's — see the note above.
10587
- "@dissolve",
10588
- ]);
10589
10744
  // ── GPU frustum cull ────────────────────────────────────────────────────────
10590
10745
  //
10591
10746
  // Sizes, once, so the arithmetic below is readable: a DrawMeta is 32 bytes