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/engine.ts CHANGED
@@ -29,6 +29,7 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut"
29
29
  import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow"
30
30
  import { ID_DEBUG_SHADER_WGSL } from "./shaders/passes/id-debug"
31
31
  import { paramChanged, sampleParamTrack, type ParamKey, type ParamValue } from "./param-track"
32
+ import { effectState, type EffectWindow } from "./effect-schedule"
32
33
  import { SHADOW_CASCADES, buildShadowVP } from "./shadow-cascades"
33
34
  import { REFLECTION_DEBUG_WGSL, buildMirrorCamera } from "./reflection"
34
35
  import { packHalf, type HdrImage } from "./hdr"
@@ -49,7 +50,6 @@ import {
49
50
  MAX_LIGHTS,
50
51
  buildLightEmitShader,
51
52
  hasLightEmit,
52
- parseLightCount,
53
53
  } from "./shaders/lights"
54
54
  import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground"
55
55
  import { outlineShaderWgsl } from "./shaders/passes/outline"
@@ -66,7 +66,6 @@ import {
66
66
  buildCompositeShader,
67
67
  EFFECT_SCENE_API,
68
68
  buildFieldShader,
69
- parseEffectAnchors,
70
69
  EFFECT_ANCHORS,
71
70
  EFFECT_SUBJECTS,
72
71
  EFFECT_TRAIL_BASE,
@@ -75,9 +74,6 @@ import {
75
74
  import {
76
75
  buildParticleComputeShader,
77
76
  buildParticleRenderShader,
78
- parseParticleBlend,
79
- parseParticleBloom,
80
- parseParticleCount,
81
77
  particleEntryPoints,
82
78
  PARTICLE_STRIDE,
83
79
  } from "./shaders/passes/particles"
@@ -85,7 +81,6 @@ import {
85
81
  SIM_FORMAT,
86
82
  GRID_MAX,
87
83
  buildSimShader,
88
- parseGridSize,
89
84
  gridEntryPoint,
90
85
  } from "./shaders/passes/grid"
91
86
  import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails"
@@ -101,6 +96,7 @@ import type {
101
96
  StyleGroup,
102
97
  } from "./graph/style-group"
103
98
  import { DEFAULT_GRAPH } from "./graph/presets/default"
99
+ import { parseDirectives, stripDirectives, type EffectDirectives, type EffectParamDecl } from "./shaders/directives"
104
100
  import { UNLIT_GRAPH } from "./graph/presets/unlit"
105
101
  import { FACE_GRAPH } from "./graph/presets/face"
106
102
  import { HAIR_GRAPH } from "./graph/presets/hair"
@@ -379,6 +375,18 @@ export type EffectResult = {
379
375
  /** Which mounts the WGSL declared — `fn background` / `fn foreground`. Both
380
376
  * false only on a failed compile, since defining neither IS the failure. */
381
377
  mounts: { background: boolean; foreground: boolean }
378
+ /** The knobs this effect exposes, from its own `#param` lines — name, type,
379
+ * default and any range. A host builds controls from THIS rather than from
380
+ * a second parse of the source, so what the panel offers and what the shader
381
+ * reads cannot come apart. Empty when the effect declares none. */
382
+ params: EffectParamDecl[]
383
+ /** How long ONE firing lasts, seconds, from `#duration`. 0 = the effect
384
+ * declared none and is AMBIENT — a condition the scene is in rather than
385
+ * something that happens at a moment. A host places a hit at its own length
386
+ * and spans an ambient one, which is the same reason `params` is here: what
387
+ * the host does with an effect should come from the effect, not from a
388
+ * second parse that can drift from it. */
389
+ duration: number
382
390
  }
383
391
 
384
392
  type CameraOptions = {
@@ -1260,13 +1268,13 @@ const FIELD_LAYER_BLEND: GPUBlendState = {
1260
1268
  }
1261
1269
 
1262
1270
  /**
1263
- * `// @layer additive` — for LIGHT rather than matter.
1271
+ * `#layer additive` — for LIGHT rather than matter.
1264
1272
  *
1265
1273
  * Alpha-over is right for anything with mass: smoke, fog, a backdrop. It is
1266
1274
  * wrong for a glow, and visibly so the moment two of them cross — the later
1267
1275
  * bolt occludes the earlier one in proportion to its own brightness, when what
1268
1276
  * light does is get brighter. Unity and Unreal both ship exactly this split,
1269
- * and the particle path here already has it as `@blend additive`.
1277
+ * and the particle path here already has it as `#blend additive`.
1270
1278
  *
1271
1279
  * Colour still scales by the author's alpha, so alpha keeps meaning "how much
1272
1280
  * of this is here" and an effect fades out the way it always did. What changes
@@ -1295,6 +1303,12 @@ const FIELD_LAYER_BLEND_ADDITIVE: GPUBlendState = {
1295
1303
  */
1296
1304
  interface EffectInstance {
1297
1305
  wgsl: string
1306
+ /** What this instance's source declared — kept so setEffectParam can refuse a
1307
+ * name the effect never offered instead of writing nowhere. */
1308
+ paramDecls: EffectParamDecl[]
1309
+ /** One firing's length in seconds, from `#duration`. 0 = ambient. Reported
1310
+ * back at install so a host can place the effect at its own length. */
1311
+ duration: number
1298
1312
  paramLayout: Map<string, { offset: number; comps: 1 | 3 }>
1299
1313
  paramsBuffer: GPUBuffer | null
1300
1314
  paramsData: Float32Array<ArrayBuffer>
@@ -1321,6 +1335,26 @@ interface EffectInstance {
1321
1335
  anchors: { bone: string; trail: boolean }[]
1322
1336
  /** Where this effect's own clock started, in scene seconds. */
1323
1337
  epochScene: number
1338
+ /**
1339
+ * The level this effect reaches, 0..1 — Blender's `influence`, and its
1340
+ * meaning: a strip's blends ramp toward THIS rather than toward 1, so a
1341
+ * permanently half-strength effect and a scheduled one are the same dial.
1342
+ */
1343
+ influence: number
1344
+ /** Its strips, in scene seconds — a LANE, so one effect can fire more than
1345
+ * once. Null or empty = on for the whole scene, which is what applying an
1346
+ * effect does until someone places it. */
1347
+ window: readonly EffectWindow[] | null
1348
+ /**
1349
+ * What the mounts actually read this frame: `influence` shaped by the strip.
1350
+ *
1351
+ * Applied by ENGINE-GENERATED code at each mount's one output site, never by
1352
+ * the author's — an effect that had to honour its own weight would be an
1353
+ * effect that could forget to, and a scheduler cannot be built on a promise
1354
+ * every author has to keep. At 0 the mount's draw is skipped outright, which
1355
+ * is what makes a scheduled effect cost nothing outside its window.
1356
+ */
1357
+ weight: number
1324
1358
  /** This effect's OWN clock, as a uniform the field shader reads. Per effect
1325
1359
  * because the shared one (viewU[6].x) is measured from the first installed
1326
1360
  * effect's epoch, so everything later started mid-stream. Null when the
@@ -1328,7 +1362,7 @@ interface EffectInstance {
1328
1362
  fieldClock: GPUBuffer | null
1329
1363
  /** The lightEmit mount: a compute stage that writes this effect's own slots
1330
1364
  * in the shared lights buffer, once per light per frame. Null unless the
1331
- * source declares `// @lights n` AND defines fn lightEmit. */
1365
+ * source declares `#lights n` AND defines fn lightEmit. */
1332
1366
  lights: {
1333
1367
  pipeline: GPUComputePipeline
1334
1368
  bind: GPUBindGroup
@@ -1600,7 +1634,7 @@ export class Engine {
1600
1634
  * cost a degenerate quad the rasteriser rejects, which is cheaper than the
1601
1635
  * prefix sum and readback a compacted draw list would need every frame.
1602
1636
  */
1603
- /** Ceiling for `// @particles`. Past this an author is asking for a stall. */
1637
+ /** Ceiling for `#particles`. Past this an author is asking for a stall. */
1604
1638
  private static readonly MAX_PARTICLES = 65536
1605
1639
  private particleFrame = 0
1606
1640
  /**
@@ -1620,7 +1654,7 @@ export class Engine {
1620
1654
  * RESOLUTION. Index 0 is full, index 1 is half — coarsest last, so the
1621
1655
  * composite reads them full-over-half.
1622
1656
  *
1623
- * `@fullres` used to be a property of the shared targets: one effect
1657
+ * `#fullres` used to be a property of the shared targets: one effect
1624
1658
  * declaring it promoted the pass for every effect installed, so a starfield
1625
1659
  * that upsamples perfectly paid four times the pixels because a keyboard
1626
1660
  * beside it needed crisp edges. Measured, that was the largest avoidable cost
@@ -1742,7 +1776,7 @@ export class Engine {
1742
1776
  *
1743
1777
  * `field` earns its place now that a scene runs SEVERAL field effects at
1744
1778
  * once: it is one pass with N draws, its resolution is a property of the
1745
- * shared targets rather than of any one effect — so a single `@fullres`
1779
+ * shared targets rather than of any one effect — so a single `#fullres`
1746
1780
  * effect quadruples the pixel count for all of them — and it is the pass the
1747
1781
  * field restructure moves. Restructuring it while it was the only untimed
1748
1782
  * pass in the frame would have meant reasoning about the cost instead of
@@ -3010,34 +3044,10 @@ export class Engine {
3010
3044
  * is KEPT and diagnostics are returned with line numbers relative to the
3011
3045
  * user's WGSL. Pass null to remove the effect.
3012
3046
  */
3013
- /**
3014
- * Every directive an effect may declare — used ONLY to tell an author that
3015
- * the line they wrote is not doing what they think. See compileEffect.
3016
- *
3017
- * It has to be complete, including the ones this engine does not read itself:
3018
- * `@dissolve` is parsed by the host, and warning about it would be worse than
3019
- * the silence this replaced — a false alarm on a working line teaches authors
3020
- * to ignore the channel.
3021
- */
3022
- private static readonly KNOWN = new Set([
3023
- "@anchor",
3024
- "@layer",
3025
- "@blend",
3026
- "@bloom",
3027
- "@lights",
3028
- "@grid",
3029
- "@particles",
3030
- "@halfres",
3031
- // Accepted and inert: full resolution is the default now, and an effect
3032
- // that still says so is right about what it wants. Warning about it would
3033
- // be telling authors off for the thing that used to be necessary.
3034
- "@fullres",
3035
- // The HOST's, not this engine's — see the note above.
3036
- "@dissolve",
3037
- ])
3038
3047
 
3039
3048
  private async compileEffect(
3040
- wgsl: string,
3049
+ /** The author's file, directives included — parsed here and nowhere else. */
3050
+ authored: string,
3041
3051
  params: Record<string, EffectParamValue> | undefined,
3042
3052
  /** This effect's own declarations, already parsed by the caller — which had
3043
3053
  * to read them anyway to build the scene table. */
@@ -3046,7 +3056,22 @@ export class Engine {
3046
3056
  alias: number[],
3047
3057
  ): Promise<{ ok: true; instance: EffectInstance; warnings: string[] } | EffectResult> {
3048
3058
  const noMounts = { background: false, foreground: false }
3049
- if (!this.device) return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts }
3059
+ if (!this.device) return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts, params: [], duration: 0 }
3060
+
3061
+ // WHAT THE FILE DECLARES, read once. Everything below takes it from `d`
3062
+ // rather than running a regex of its own — eight parsers over one file was
3063
+ // eight chances to disagree about what it said, and they did.
3064
+ //
3065
+ // An unrecognised or malformed directive is an ERROR. `#` is not WGSL
3066
+ // syntax, so a line starting with one is unambiguously ours and there is
3067
+ // nothing to be lenient about; the old spelling lived in comments, where a
3068
+ // typo was indistinguishable from prose and could only ever be warned about.
3069
+ const parsed = parseDirectives(authored)
3070
+ if (parsed.errors.length) return { ok: false, diagnostics: parsed.errors, mounts: noMounts, params: [], duration: 0 }
3071
+ const d = parsed.directives
3072
+ // The compiler sees the file with its directive lines BLANKED, so every
3073
+ // diagnostic below still names the line the author is looking at.
3074
+ const wgsl = stripDirectives(authored)
3050
3075
 
3051
3076
  // ── Which mounts did the author ask for? A declaration, not a setting: the
3052
3077
  // entry points present in the source are the ones compiled in. Matching the
@@ -3063,14 +3088,10 @@ export class Engine {
3063
3088
  const te = trailEntryPoints(wgsl)
3064
3089
  const wantsTrails = te.width || te.shade
3065
3090
  if (wantsTrails && !(te.width && te.shade)) {
3066
- return {
3067
- ok: false,
3068
- diagnostics: [
3091
+ return { ok: false, diagnostics: [
3069
3092
  `a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
3070
3093
  `fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
3071
- ],
3072
- mounts: noMounts,
3073
- }
3094
+ ], mounts: noMounts, params: [], duration: 0 }
3074
3095
  }
3075
3096
  if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
3076
3097
  const missing = [
@@ -3078,7 +3099,7 @@ export class Engine {
3078
3099
  pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
3079
3100
  pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
3080
3101
  ].filter(Boolean)
3081
- return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts }
3102
+ return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts, params: [], duration: 0 }
3082
3103
  }
3083
3104
  // One file, one kind — for now.
3084
3105
  //
@@ -3093,36 +3114,28 @@ export class Engine {
3093
3114
  // says so plainly instead of failing with "unresolved type Particle" from a
3094
3115
  // pass they did not know they were compiling into.
3095
3116
  if ((wantsParticles || wantsTrails) && (hasBackground || hasForeground)) {
3096
- return {
3097
- ok: false,
3098
- diagnostics: [
3117
+ return { ok: false, diagnostics: [
3099
3118
  "an effect declares field mounts (background/foreground) or particles, not both — " +
3100
3119
  "split them into two effects",
3101
- ],
3102
- mounts: noMounts,
3103
- }
3120
+ ], mounts: noMounts, params: [], duration: 0 }
3104
3121
  }
3105
3122
  // lightEmit counts as a mount on its own: a pure lighting rig draws nothing
3106
3123
  // and is still an effect — it is how a scene gets stage lights without also
3107
3124
  // getting geometry it did not ask for.
3108
3125
  if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl)) {
3109
- return {
3110
- ok: false,
3111
- diagnostics: [
3126
+ return { ok: false, diagnostics: [
3112
3127
  "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
3113
3128
  "fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
3114
3129
  "the particle trio (particleInit/particleStep/particleShade), " +
3115
3130
  "the ribbon pair (trailWidth/trailShade), " +
3116
- "or fn lightEmit(i: u32) -> RzLight with // @lights <n>",
3117
- ],
3118
- mounts: noMounts,
3119
- }
3131
+ "or fn lightEmit(i: u32) -> RzLight with #lights <n>",
3132
+ ], mounts: noMounts, params: [], duration: 0 }
3120
3133
  }
3121
3134
  const mounts = { background: hasBackground, foreground: hasForeground }
3122
3135
 
3123
3136
  // ── Directives only some mounts honour ──
3124
3137
  //
3125
- // @bloom sets the aux mask, and only the particle and ribbon modules write
3138
+ // #bloom sets the aux mask, and only the particle and ribbon modules write
3126
3139
  // that mask: they draw inside the scene pass, in HDR, while the bloom
3127
3140
  // pyramid can still see them. A field effect composites in DISPLAY space
3128
3141
  // after tone mapping, so there is nothing left to pick it up and the
@@ -3135,32 +3148,9 @@ export class Engine {
3135
3148
  // pinning an effect that declares this has to keep installing; saying so is
3136
3149
  // all that was ever missing.
3137
3150
  const warnings: string[] = []
3138
- // A DIRECTIVE THAT PARSED AS NOTHING.
3139
- //
3140
- // Every pragma is matched with `\s*$` after it, so a line that carries a
3141
- // note as well — `// @fullres — glyph edges are sub-pixel detail` — matches
3142
- // none of them and is read as an ordinary comment. Nothing failed, nothing
3143
- // said anything, and the effect simply ran without the property it asked
3144
- // for: three shipped effects were silently half-res and a fourth silently
3145
- // stopped being additive, each one's first line explaining why it needed
3146
- // the thing it was not getting.
3147
- //
3148
- // Every directive this engine knows, so an unrecognised one is named rather
3149
- // than ignored. Cheap: it runs once per install, over a file a human wrote.
3150
- for (const m of wgsl.matchAll(/^[ \t]*\/\/[ \t]*(@[a-zA-Z]+)(.*)$/gm)) {
3151
- const [, tag, rest] = m
3152
- if (!Engine.KNOWN.has(tag)) {
3153
- warnings.push(`${tag} is not a directive this engine knows — it will be ignored.`)
3154
- } else if (rest.trim() && !/^\s*[\w.\-+]+(\s+[\w.\-+]+)*\s*$/.test(rest)) {
3155
- warnings.push(
3156
- `${tag} has a note on the same line, so it does not parse and is being IGNORED. ` +
3157
- `A directive must be alone on its line — put the note on the next one.`,
3158
- )
3159
- }
3160
- }
3161
- if (parseParticleBloom(wgsl) && !wantsParticles && !wantsTrails) {
3151
+ if (d.bloom && !wantsParticles && !wantsTrails) {
3162
3152
  warnings.push(
3163
- "// @bloom does nothing here. A field effect (background/foreground) composites after tone " +
3153
+ "#bloom does nothing here. A field effect (background/foreground) composites after tone " +
3164
3154
  "mapping, past the bloom pyramid — the directive applies to particles and ribbons, which draw " +
3165
3155
  "in HDR inside the scene pass. Make the effect's own falloff brighter instead.",
3166
3156
  )
@@ -3183,7 +3173,7 @@ export class Engine {
3183
3173
  let cursor = 0
3184
3174
  for (const [name, value] of entries) {
3185
3175
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
3186
- return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts }
3176
+ return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration }
3187
3177
  }
3188
3178
  const isVec = typeof value !== "number"
3189
3179
  const align = isVec ? 16 : 4
@@ -3212,7 +3202,7 @@ export class Engine {
3212
3202
  // module (buildFieldShader), so a bad effect can no longer produce errors at
3213
3203
  // line numbers in a shader the author never wrote — and installing one no
3214
3204
  // longer recompiles the composite's tone-mapping half at all.
3215
- const gridSize = gridEntryPoint(wgsl) ? parseGridSize(wgsl, GRID_MAX) || 256 : 0
3205
+ const gridSize = gridEntryPoint(wgsl) ? Math.min(d.grid || 256, GRID_MAX) : 0
3216
3206
  // `alias` goes in: a field effect reads bones through _rzSlot exactly as a
3217
3207
  // particle one does, and it was the only module never handed the mapping.
3218
3208
  const fieldEffect =
@@ -3221,11 +3211,11 @@ export class Engine {
3221
3211
  this.device.pushErrorScope("validation")
3222
3212
  const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
3223
3213
  const scopeErr = await this.device.popErrorScope()
3224
- if (scopeErr) return { ok: false, diagnostics: [scopeErr.message], mounts }
3214
+ if (scopeErr) return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration }
3225
3215
 
3226
3216
  // Declared like every other mount property: by what the source says, not by
3227
3217
  // a setting somewhere else that an author cannot see from the file.
3228
- const layerBlend = /^\s*\/\/\s*@layer\s+additive\s*$/m.test(wgsl)
3218
+ const layerBlend = d.additiveLayer
3229
3219
  ? FIELD_LAYER_BLEND_ADDITIVE
3230
3220
  : FIELD_LAYER_BLEND
3231
3221
  let fieldPipeline: GPURenderPipeline | null = null
@@ -3240,7 +3230,7 @@ export class Engine {
3240
3230
  .filter((m) => m.type === "error")
3241
3231
  .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
3242
3232
  if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
3243
- if (diagnostics.length > 0) return { ok: false, diagnostics, mounts }
3233
+ if (diagnostics.length > 0) return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration }
3244
3234
  try {
3245
3235
  fieldPipeline = await this.device.createRenderPipelineAsync({
3246
3236
  label: "field layer pipeline",
@@ -3266,7 +3256,7 @@ export class Engine {
3266
3256
  multisample: { count: 1 },
3267
3257
  })
3268
3258
  } catch (e) {
3269
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
3259
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration }
3270
3260
  }
3271
3261
  }
3272
3262
  let identity: GPURenderPipeline
@@ -3290,7 +3280,7 @@ export class Engine {
3290
3280
  make(true, "composite pipeline (effect, gamma!=1)"),
3291
3281
  ])
3292
3282
  } catch (e) {
3293
- return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
3283
+ return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration }
3294
3284
  }
3295
3285
 
3296
3286
  // Built BEFORE the swap: a particle stage that fails to compile has to leave
@@ -3306,17 +3296,17 @@ export class Engine {
3306
3296
  grid?.textures[1].destroy()
3307
3297
  grid?.uniform.destroy()
3308
3298
  trails?.uniform.destroy()
3309
- return { ok: false, diagnostics, mounts }
3299
+ return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration }
3310
3300
  }
3311
3301
  let particles: EffectParticles | null = null
3312
3302
  if (wantsParticles) {
3313
- const built = await this.buildParticles(wgsl, anchors, alias)
3303
+ const built = await this.buildParticles(wgsl, d, anchors, alias)
3314
3304
  if (!built.ok) return abandon(built.diagnostics)
3315
3305
  particles = built.state
3316
3306
  }
3317
3307
  let grid: EffectGrid | null = null
3318
3308
  if (gridEntryPoint(wgsl)) {
3319
- const built = await this.buildSim(wgsl, anchors, alias)
3309
+ const built = await this.buildSim(wgsl, d, anchors, alias)
3320
3310
  if (!built.ok) return abandon(built.diagnostics)
3321
3311
  grid = built.state
3322
3312
  }
@@ -3326,13 +3316,9 @@ export class Engine {
3326
3316
  // bone recorded without one would read zeroes and paint a line to the origin.
3327
3317
  const trailSlots = anchors.filter((a) => a.trail).length
3328
3318
  if (trailSlots === 0) {
3329
- return {
3330
- ok: false,
3331
- diagnostics: ["a ribbon effect needs at least one // @anchor <bone> trail"],
3332
- mounts,
3333
- }
3319
+ return { ok: false, diagnostics: ["a ribbon effect needs at least one #anchor <bone> trail"], mounts, params: d.params, duration: d.duration }
3334
3320
  }
3335
- const built = await this.buildTrails(wgsl, anchors, alias)
3321
+ const built = await this.buildTrails(wgsl, d, anchors, alias)
3336
3322
  if (!built.ok) return abandon(built.diagnostics)
3337
3323
  trails = built.state
3338
3324
  }
@@ -3345,13 +3331,13 @@ export class Engine {
3345
3331
  // count is a function nothing calls. Either alone is a silent blank, which
3346
3332
  // is the worst way for an effect to fail.
3347
3333
  let lights: EffectInstance["lights"] = null
3348
- const declaredLights = parseLightCount(wgsl, MAX_LIGHTS)
3334
+ const declaredLights = Math.min(d.lights, MAX_LIGHTS)
3349
3335
  const emits = hasLightEmit(wgsl)
3350
3336
  if (declaredLights > 0 !== emits) {
3351
3337
  return abandon([
3352
3338
  emits
3353
- ? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with // @lights <n>"
3354
- : "// @lights <n> needs fn lightEmit(i: u32) -> RzLight to fill those slots",
3339
+ ? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with #lights <n>"
3340
+ : "#lights <n> needs fn lightEmit(i: u32) -> RzLight to fill those slots",
3355
3341
  ])
3356
3342
  }
3357
3343
  if (declaredLights > 0) {
@@ -3380,6 +3366,8 @@ export class Engine {
3380
3366
  }
3381
3367
  const instance: EffectInstance = {
3382
3368
  wgsl,
3369
+ paramDecls: d.params,
3370
+ duration: d.duration,
3383
3371
  paramLayout: layout,
3384
3372
  paramsBuffer,
3385
3373
  paramsData,
@@ -3394,6 +3382,12 @@ export class Engine {
3394
3382
  // The effect's own clock starts now. Per effect so that one installed
3395
3383
  // later still gets a frame where rzGridFrame() is 0 and can seed.
3396
3384
  epochScene: this.sceneClock,
3385
+ // Fully on, unscheduled. An effect that is installed is showing;
3386
+ // scheduling it is something a caller does afterwards, and an install
3387
+ // that silently began at zero would look like a compile that failed.
3388
+ influence: 1,
3389
+ window: null,
3390
+ weight: 1,
3397
3391
  // Its OWN resolution, no longer the scene's: an effect that never asked
3398
3392
  // for full res is not promoted because a neighbour did.
3399
3393
  // FULL RESOLUTION UNLESS TOLD OTHERWISE.
@@ -3406,12 +3400,12 @@ export class Engine {
3406
3400
  // wearing a different hat: the safe answer has to be the one you get
3407
3401
  // for saying nothing.
3408
3402
  //
3409
- // The cost is real and is why the half layer stays: `@halfres` is worth
3403
+ // The cost is real and is why the half layer stays: `#halfres` is worth
3410
3404
  // about 3.7x on a full-screen effect (Footprints, measured, 1.2ms
3411
3405
  // against 4.5ms). It is the right call for a soft additive glow, which
3412
3406
  // upsamples invisibly — and it is now a claim an author makes about
3413
3407
  // their own effect rather than a fate that befalls one.
3414
- fieldLayer: /^\s*\/\/\s*@halfres\s*$/m.test(wgsl) ? 1 : 0,
3408
+ fieldLayer: d.fieldLayer,
3415
3409
  fieldPipeline,
3416
3410
  fieldClock,
3417
3411
  // Filled by rebuildFieldBindGroup below, which needs the instance to
@@ -3453,7 +3447,7 @@ export class Engine {
3453
3447
  list: { wgsl: string; params?: Record<string, EffectParamValue> }[] | null,
3454
3448
  ): Promise<EffectResult[]> {
3455
3449
  const noMounts = { background: false, foreground: false }
3456
- if (!this.device) return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts }]
3450
+ if (!this.device) return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0 }]
3457
3451
 
3458
3452
  const requested = list ?? []
3459
3453
  if (requested.length === 0) {
@@ -3479,7 +3473,14 @@ export class Engine {
3479
3473
 
3480
3474
  // One table for the whole scene, built before anything compiles: an effect's
3481
3475
  // alias is its row, and a bone two effects both name is allocated once.
3482
- const perEffectAnchors = requested.map((e) => parseEffectAnchors(e.wgsl, MAX_EFFECT_ANCHORS))
3476
+ // The table needs every effect's anchors before any of them compiles, so
3477
+ // this is the one place a source is read twice — compileEffect parses it
3478
+ // again for everything else. A malformed file yields no anchors here and
3479
+ // fails with its real diagnostics there, which is the right order: the
3480
+ // error names the line, not the table.
3481
+ const perEffectAnchors = requested.map((e) =>
3482
+ parseDirectives(e.wgsl).directives.anchors.slice(0, MAX_EFFECT_ANCHORS),
3483
+ )
3483
3484
  const table = buildAnchorTable(perEffectAnchors, MAX_EFFECT_ANCHORS)
3484
3485
 
3485
3486
  const results: EffectResult[] = []
@@ -3499,6 +3500,8 @@ export class Engine {
3499
3500
  instances.push(built.instance)
3500
3501
  results.push({
3501
3502
  ok: true,
3503
+ params: built.instance.paramDecls,
3504
+ duration: built.instance.duration,
3502
3505
  // Installed, and still with something to say — a directive that parsed
3503
3506
  // but will never fire. Same channel as the dropped-anchor note below.
3504
3507
  diagnostics: built.warnings,
@@ -3563,7 +3566,7 @@ export class Engine {
3563
3566
  this.compositePipelineIdentity = this.makeCompositePipeline(compositeModule, false, "composite pipeline (gamma=1)")
3564
3567
  this.compositePipelineGamma = this.makeCompositePipeline(compositeModule, true, "composite pipeline (gamma!=1)")
3565
3568
 
3566
- // Nothing to promote any more: `@fullres` is per effect, read into
3569
+ // Nothing to promote any more: `#fullres` is per effect, read into
3567
3570
  // fieldLayer when the instance is built, and both target pairs exist for
3568
3571
  // the life of the surface. What used to be a scene-wide decision made here
3569
3572
  // is now each effect's own.
@@ -3581,14 +3584,18 @@ export class Engine {
3581
3584
  const noMounts = { background: false, foreground: false }
3582
3585
  if (wgsl === null) {
3583
3586
  await this.setEffects(null)
3584
- return { ok: true, diagnostics: [], mounts: noMounts }
3587
+ return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0 }
3585
3588
  }
3586
3589
  const [result] = await this.setEffects([{ wgsl, params }])
3587
- return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts }
3590
+ return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts, params: [], duration: 0 }
3588
3591
  }
3589
3592
 
3590
3593
  private async buildParticles(
3594
+ /** Already stripped of directives — see compileEffect. */
3591
3595
  wgsl: string,
3596
+ /** What the file declared. Read here rather than re-parsed: the source no
3597
+ * longer carries the lines, and two readers is how they drift. */
3598
+ d: EffectDirectives,
3592
3599
  anchors: { bone: string; trail: boolean }[],
3593
3600
  /** This effect's local→scene slot map. Passed rather than read off the
3594
3601
  * engine: the builders run BEFORE the swap, so this.anchorTable still
@@ -3597,8 +3604,8 @@ export class Engine {
3597
3604
  ): Promise<{ ok: true; state: EffectParticles } | { ok: false; diagnostics: string[] }> {
3598
3605
  // No pragma means "some": an author who wrote the trio clearly wants
3599
3606
  // particles, and failing over a missing comment would be pedantry.
3600
- const count = parseParticleCount(wgsl, Engine.MAX_PARTICLES) || 1024
3601
- const src = { wgsl, count, blend: parseParticleBlend(wgsl), bloom: parseParticleBloom(wgsl) }
3607
+ const count = Math.min(d.particles || 1024, Engine.MAX_PARTICLES)
3608
+ const src = { wgsl, count, blend: d.particleBlend, bloom: d.bloom }
3602
3609
  // Sparks want to spawn where a trail is, so the particle stages see the same
3603
3610
  // cast buffer the trail draw reads.
3604
3611
  const cast = {
@@ -3636,10 +3643,13 @@ export class Engine {
3636
3643
  })
3637
3644
  const uniform = this.device.createBuffer({
3638
3645
  label: "particle uniforms",
3639
- size: 16,
3646
+ // Two vec4-sized rows: (time, dt, count, frame) and (weight, _, _, _).
3647
+ // The first was exactly full, and weight has to live in the same buffer
3648
+ // as the clock or a frame could draw one without the other.
3649
+ size: 32,
3640
3650
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3641
3651
  })
3642
- const uniformBytes = new ArrayBuffer(16)
3652
+ const uniformBytes = new ArrayBuffer(32)
3643
3653
  const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) }
3644
3654
 
3645
3655
  // Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
@@ -3856,12 +3866,18 @@ export class Engine {
3856
3866
  private emitLights(encoder: GPUCommandEncoder): void {
3857
3867
  for (const e of this.effects) {
3858
3868
  const l = e.lights
3869
+ // NOT skipped at weight 0, unlike every other mount. Each effect writes
3870
+ // its OWN slots in a shared buffer that is never cleared, so a skipped
3871
+ // dispatch leaves last frame's lights burning — the one place where not
3872
+ // running is the wrong answer. The shader zeroes them instead, and the
3873
+ // dispatch it costs is a single workgroup.
3859
3874
  if (!l || l.data[2] === 0) continue
3860
3875
  // The effect's OWN epoch — the same one its field, particle, ribbon and
3861
3876
  // grid halves now read. This was briefly conditional, to match a field
3862
3877
  // clock that was shared from the first installed effect; that clock is
3863
3878
  // per effect now, so every mount in one file agrees by construction.
3864
3879
  l.data[0] = this.sceneClock - e.epochScene
3880
+ l.data[3] = e.weight
3865
3881
  this.device.queue.writeBuffer(l.uniform, 0, l.data.buffer as ArrayBuffer)
3866
3882
  const cp = encoder.beginComputePass({ label: "light emit" })
3867
3883
  cp.setPipeline(l.pipeline)
@@ -3876,6 +3892,11 @@ export class Engine {
3876
3892
  const p = e.particles
3877
3893
  if (!p) continue
3878
3894
  p.data[0] = this.sceneClock - e.epochScene
3895
+ // The SIMULATION runs at every weight, 0 included — only the draw stops.
3896
+ // A scheduled effect that froze while faded out would resume from the
3897
+ // state it left rather than the one it would have reached, so fading one
3898
+ // back in would rewind it.
3899
+ p.data[4] = e.weight
3879
3900
  // Clamped: a backgrounded tab returns with a delta of whole seconds, and an
3880
3901
  // unclamped step flings every particle out of the scene in one frame.
3881
3902
  p.data[1] = Math.min(0.1, Math.max(0, deltaTime))
@@ -3894,7 +3915,7 @@ export class Engine {
3894
3915
  private renderParticles(pass: GPURenderPassEncoder, view: "camera" | "mirror"): void {
3895
3916
  for (const e of this.effects) {
3896
3917
  const p = e.particles
3897
- if (!p) continue
3918
+ if (!p || e.weight === 0) continue
3898
3919
  pass.setPipeline(p.render)
3899
3920
  pass.setBindGroup(0, view === "mirror" ? p.mirrorRenderBind : p.renderBind)
3900
3921
  pass.draw(6, p.count)
@@ -3909,7 +3930,11 @@ export class Engine {
3909
3930
  * frame on the CPU.
3910
3931
  */
3911
3932
  private async buildTrails(
3933
+ /** Already stripped of directives — see compileEffect. */
3912
3934
  wgsl: string,
3935
+ /** What the file declared. Read here rather than re-parsed: the source no
3936
+ * longer carries the lines, and two readers is how they drift. */
3937
+ d: EffectDirectives,
3913
3938
  anchors: { bone: string; trail: boolean }[],
3914
3939
  /** This effect's local→scene slot map. Passed rather than read off the
3915
3940
  * engine: the builders run BEFORE the swap, so this.anchorTable still
@@ -3925,7 +3950,7 @@ export class Engine {
3925
3950
  // nothing before: ribbon i was read as anchor slot i.
3926
3951
  const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0)
3927
3952
  const slots = ribbonSlots.length
3928
- const src = { wgsl, slots, ribbonSlots, blend: parseParticleBlend(wgsl), bloom: parseParticleBloom(wgsl) }
3953
+ const src = { wgsl, slots, ribbonSlots, blend: d.particleBlend, bloom: d.bloom }
3929
3954
  const code = buildTrailShader(src, {
3930
3955
  subjects: MAX_EFFECT_SUBJECTS,
3931
3956
  samples: TRAIL_SAMPLES,
@@ -4062,7 +4087,7 @@ export class Engine {
4062
4087
  * Takes the pass rather than opening one: that IS the change.
4063
4088
  */
4064
4089
  private drawTrails(pass: GPURenderPassEncoder, view: "camera" | "mirror"): void {
4065
- const drawn = this.effects.filter((e) => e.trails)
4090
+ const drawn = this.effects.filter((e) => e.trails && e.weight > 0)
4066
4091
  if (drawn.length === 0) return
4067
4092
  for (const e of drawn) {
4068
4093
  const t = e.trails!
@@ -4085,6 +4110,7 @@ export class Engine {
4085
4110
  if (view === "camera") {
4086
4111
  t.data[0] = this.sceneClock - e.epochScene
4087
4112
  t.data[1] = live
4113
+ t.data[2] = e.weight
4088
4114
  this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
4089
4115
  }
4090
4116
  pass.setPipeline(t.pipeline)
@@ -4097,14 +4123,31 @@ export class Engine {
4097
4123
  * upsample. Runs the whole quad — uniform control flow, so effects may use
4098
4124
  * derivatives freely, which the old inline path had to forbid. */
4099
4125
  private renderFieldPass(encoder: GPUCommandEncoder): void {
4100
- const drawn = this.effects.filter((e) => e.fieldPipeline && e.fieldBindGroups)
4101
- if (drawn.length === 0) return
4126
+ // TWO PREDICATES, deliberately, and they are not interchangeable.
4127
+ //
4128
+ // MOUNTED decides whether the pass runs, and it must agree exactly with
4129
+ // fieldPairUsed — that is what the composite's bind group was built against,
4130
+ // at install, and it is not rebuilt per frame. A pass skipped under a
4131
+ // binding that still points at its target leaves the last frame it drew
4132
+ // sitting there, so an effect faded to nothing would freeze on screen
4133
+ // instead of disappearing.
4134
+ //
4135
+ // DRAWN decides what is drawn into it, and this is where weight is worth
4136
+ // something: a field mount is a full-screen quad however little of the frame
4137
+ // it ends up touching, so an effect that is scheduled off would otherwise
4138
+ // shade every pixel to multiply it out to nothing. The pass still clears —
4139
+ // which is what makes the layer transparent rather than stale — and shades
4140
+ // nothing.
4141
+ const mounted = this.effects.filter((e) => e.fieldPipeline && e.fieldBindGroups)
4142
+ if (mounted.length === 0) return
4143
+ const drawn = mounted.filter((e) => e.weight > 0)
4102
4144
  // Each effect's own clock, before the pass that reads it. Seconds since
4103
4145
  // THIS effect was installed — so an effect added to a running scene starts
4104
4146
  // at zero and can seed, rather than joining whatever the first one is up to.
4105
4147
  for (const e of drawn) {
4106
4148
  if (!e.fieldClock) continue
4107
4149
  this.fieldClockScratch[0] = this.sceneClock - e.epochScene
4150
+ this.fieldClockScratch[1] = e.weight
4108
4151
  this.device.queue.writeBuffer(e.fieldClock, 0, this.fieldClockScratch.buffer as ArrayBuffer)
4109
4152
  }
4110
4153
  // ONE PASS PER RESOLUTION, N draws each, in document order — a pair is
@@ -4119,7 +4162,7 @@ export class Engine {
4119
4162
  // (fieldLayerView). Clearing and storing an empty full-res rgba16f pair is
4120
4163
  // two 16MB writes a frame to produce the transparent black the fallback
4121
4164
  // already is. Most scenes leave the full-res pair empty, since an effect only
4122
- // lands there by declaring @fullres.
4165
+ // lands there by declaring #fullres.
4123
4166
  let stamped = false
4124
4167
  for (let i = 0; i < Engine.FIELD_SCALES.length; i++) {
4125
4168
  const bg = this.fieldBgViews[i]
@@ -4132,7 +4175,7 @@ export class Engine {
4132
4175
  { view: fg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
4133
4176
  ],
4134
4177
  // One query pair is reserved for "field", and it goes to the first pair
4135
- // that actually runs — full res when something declared @fullres, half
4178
+ // that actually runs — full res when something declared #fullres, half
4136
4179
  // otherwise. Pinning it to i === 0 would have measured a pass that, now
4137
4180
  // that empty pairs are skipped, usually does not happen.
4138
4181
  timestampWrites: stamped ? undefined : this.stamps("field"),
@@ -4197,14 +4240,18 @@ export class Engine {
4197
4240
  * zero, so seeding is just "if frame is 0, return the initial state".
4198
4241
  */
4199
4242
  private async buildSim(
4243
+ /** Already stripped of directives — see compileEffect. */
4200
4244
  wgsl: string,
4245
+ /** What the file declared. Read here rather than re-parsed: the source no
4246
+ * longer carries the lines, and two readers is how they drift. */
4247
+ d: EffectDirectives,
4201
4248
  anchors: { bone: string; trail: boolean }[],
4202
4249
  /** This effect's local→scene slot map. Passed rather than read off the
4203
4250
  * engine: the builders run BEFORE the swap, so this.anchorTable still
4204
4251
  * describes the effect that is still on screen. */
4205
4252
  alias: number[],
4206
4253
  ): Promise<{ ok: true; state: EffectGrid } | { ok: false; diagnostics: string[] }> {
4207
- const size = parseGridSize(wgsl, GRID_MAX) || 256
4254
+ const size = Math.min(d.grid || 256, GRID_MAX)
4208
4255
  const cast = {
4209
4256
  subjects: MAX_EFFECT_SUBJECTS,
4210
4257
  samples: TRAIL_SAMPLES,
@@ -4343,10 +4390,19 @@ export class Engine {
4343
4390
  return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false }
4344
4391
  }
4345
4392
 
4346
- /** Write one effect param (declared at setEffect) — a uniform write, no
4347
- * recompile; the instant tier, like setStyleParam. */
4348
- setEffectParam(name: string, value: EffectParamValue): void {
4349
- const fx = this.effect
4393
+ /**
4394
+ * Set one parameter on one INSTANCE.
4395
+ *
4396
+ * By index, because the scene holds a list and the same effect may appear in
4397
+ * it twice with different values — which is the whole point of an instance
4398
+ * and was impossible while this addressed `this.effect`, a singular left over
4399
+ * from when a scene could wear exactly one.
4400
+ *
4401
+ * A write, not a recompile: parameters live in their own uniform buffer, so
4402
+ * dragging a slider costs a 16-byte upload rather than a shader build.
4403
+ */
4404
+ setEffectParam(index: number, name: string, value: EffectParamValue): void {
4405
+ const fx = this.effects[index]
4350
4406
  if (!fx || !fx.paramsBuffer) return
4351
4407
  const slot = fx.paramLayout.get(name)
4352
4408
  if (!slot) return
@@ -4359,6 +4415,119 @@ export class Engine {
4359
4415
  this.device.queue.writeBuffer(fx.paramsBuffer, 0, fx.paramsData)
4360
4416
  }
4361
4417
 
4418
+ /**
4419
+ * How much of one instance is showing, 0..1.
4420
+ *
4421
+ * The third of the three things an instance has — parameters, weight, time —
4422
+ * and the one a scheduler drives. Weight is not a parameter: a parameter is
4423
+ * whatever the author decided to expose and means only what their source
4424
+ * makes it mean, while weight means the same thing for every effect ever
4425
+ * written, including one whose author never heard of it. That is why it is
4426
+ * applied by engine-generated code at each mount's output rather than handed
4427
+ * to the source as a uniform to respect.
4428
+ *
4429
+ * At 0 nothing is drawn: no field quad, no particle draw, no ribbon, no light
4430
+ * dispatch. A scheduled effect outside its window costs its simulation and
4431
+ * nothing else — and a particle effect keeps simulating on purpose, so that
4432
+ * fading one back in continues rather than rewinds.
4433
+ *
4434
+ * Instant, and free: a float in a uniform every mount already uploads once a
4435
+ * frame. Nothing recompiles, so this is safe to drive per frame from a
4436
+ * timeline.
4437
+ */
4438
+ setEffectInfluence(index: number, influence: number): void {
4439
+ const fx = this.effects[index]
4440
+ if (!fx) return
4441
+ // Clamped rather than trusted: above 1 the field's own clamp would swallow
4442
+ // it while an additive particle would happily keep getting brighter, so the
4443
+ // same number would mean two things.
4444
+ fx.influence = Math.min(1, Math.max(0, influence))
4445
+ }
4446
+
4447
+ getEffectInfluence(index: number): number {
4448
+ return this.effects[index]?.influence ?? 0
4449
+ }
4450
+
4451
+ /**
4452
+ * Schedule one instance: when it is alive, and how it enters and leaves.
4453
+ *
4454
+ * Null is the unscheduled case — on for the whole scene, on the scene's own
4455
+ * clock — and is what an effect starts as.
4456
+ *
4457
+ * The engine evaluates this every frame rather than taking a weight from a
4458
+ * caller, because every loop that renders would otherwise have to remember to
4459
+ * drive it. The offline export loop already carries a scar about exactly that
4460
+ * shape of bug. Evaluating where the scene clock advances means playback and
4461
+ * export cannot disagree, and neither can forget.
4462
+ *
4463
+ * A caller that wants to drive an effect from something OTHER than the scene
4464
+ * clock — an animation's progress, a skill firing — leaves this null and
4465
+ * writes setEffectInfluence and setEffectTime itself, per frame. Both paths
4466
+ * exist on purpose; this one is what a timeline wants.
4467
+ */
4468
+ setEffectSchedule(index: number, windows: readonly EffectWindow[] | null): void {
4469
+ const fx = this.effects[index]
4470
+ if (!fx) return
4471
+ fx.window = windows && windows.length ? windows : null
4472
+ }
4473
+
4474
+ getEffectSchedule(index: number): readonly EffectWindow[] | null {
4475
+ return this.effects[index]?.window ?? null
4476
+ }
4477
+
4478
+ /**
4479
+ * Every scheduled effect, at the current scene clock.
4480
+ *
4481
+ * Called once a frame, BEFORE anything reads a weight or a clock. An effect
4482
+ * with no window keeps whatever a caller last set, which is what makes the
4483
+ * manual path above work — evaluating it would fight the caller for the field
4484
+ * every frame.
4485
+ */
4486
+ private evaluateEffectSchedules(): void {
4487
+ // Read ONCE: it walks the cast, and every effect wants the same answer.
4488
+ const transport = this.transportTime()
4489
+ for (const fx of this.effects) {
4490
+ if (!fx.window || fx.window.length === 0) {
4491
+ fx.weight = fx.influence
4492
+ continue
4493
+ }
4494
+ const at = effectState(fx.window, fx.influence, transport)
4495
+ fx.weight = at.weight
4496
+ // Its own clock, expressed the way the mounts read it. Every mount
4497
+ // derives time from the epoch against sceneClock, so this one write moves
4498
+ // the field, the particles, the ribbons, lightEmit and the grid together
4499
+ // — and hands them the STRIP's local time while they keep running on the
4500
+ // smooth monotonic clock a particle integrator needs.
4501
+ fx.epochScene = this.sceneClock - at.time
4502
+ }
4503
+ }
4504
+
4505
+ /**
4506
+ * Move one instance's own clock to a given second.
4507
+ *
4508
+ * Everything an effect can animate is derived from its epoch — the field
4509
+ * clock, the particle and ribbon clocks, lightEmit's time argument, the grid's
4510
+ * frame counter — so moving the epoch moves all of them together and there is
4511
+ * no mount that can be left reading last frame's time.
4512
+ *
4513
+ * This is what lets an effect be SCHEDULED rather than merely switched on: an
4514
+ * instance that enters at bar 33 is handed a time that starts at zero there,
4515
+ * so it plays its own opening instead of joining whatever the scene clock had
4516
+ * reached. Feeding it the transport's time instead gives the other reading —
4517
+ * an effect that runs in lockstep with the music — and both are one call.
4518
+ */
4519
+ setEffectTime(index: number, time: number): void {
4520
+ const fx = this.effects[index]
4521
+ if (!fx) return
4522
+ fx.epochScene = this.sceneClock - time
4523
+ }
4524
+
4525
+
4526
+ getEffectTime(index: number): number {
4527
+ const fx = this.effects[index]
4528
+ return fx ? this.sceneClock - fx.epochScene : 0
4529
+ }
4530
+
4362
4531
  /** Patch bloom; GPU uniforms update immediately if `init()` has run. */
4363
4532
  /** Camera depth of field (see DepthOfFieldOptions). Free while disabled —
4364
4533
  * the scene pass only stores its depth buffer on frames the gather reads. */
@@ -6020,7 +6189,7 @@ export class Engine {
6020
6189
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
6021
6190
  })
6022
6191
 
6023
- // The field layer — half resolution by default, full for @fullres effects.
6192
+ // The field layer — half resolution by default, full for #fullres effects.
6024
6193
  this.fieldFullW = width
6025
6194
  this.fieldFullH = height
6026
6195
  this.createFieldTargets()
@@ -6904,10 +7073,24 @@ export class Engine {
6904
7073
  this.camera.setVmdDriven(false)
6905
7074
  }
6906
7075
 
6907
- // Clock the camera VMD runs on: the first model with an active clip (playing or scrubbed),
6908
- // so a static stage in the scene never freezes the shot at frame 0. Falls back to the first
6909
- // model, then to 0 (empty scene).
6910
- private cameraClockTime(): number {
7076
+ /**
7077
+ * THE TRANSPORT'S CLOCK — where the scene is in its own playback.
7078
+ *
7079
+ * The first model with an active clip (playing or scrubbed), so a static stage
7080
+ * never freezes it at frame 0. Falls back to the first model with a clip, then
7081
+ * to 0 for an empty scene.
7082
+ *
7083
+ * NOT `sceneClock`, and the difference is the whole reason this has a name.
7084
+ * `sceneClock` only ever accumulates delta — it is how long the engine has
7085
+ * been running, it does not move when you scrub, and it does not stop when you
7086
+ * pause. Anything that should line up with what the transport shows has to
7087
+ * read THIS. An effect scheduled to frame 100 against sceneClock fires once,
7088
+ * a hundred frames after the page loaded, and never again.
7089
+ *
7090
+ * Deterministic offline: the export loop advances model animation by an exact
7091
+ * per-frame delta, so this reproduces frame for frame.
7092
+ */
7093
+ private transportTime(): number {
6911
7094
  let fallback: number | null = null
6912
7095
  for (const inst of this.modelInstances.values()) {
6913
7096
  // Stages are skipped outright. Scenery carries no motion, and it is added
@@ -10558,7 +10741,7 @@ export class Engine {
10558
10741
 
10559
10742
  // Drive the shot from the camera VMD (synced to the animated model's clock).
10560
10743
  if (this.camera.vmdDriven && this.cameraAnimation) {
10561
- const pose = this.cameraAnimation.sample(this.cameraClockTime())
10744
+ const pose = this.cameraAnimation.sample(this.transportTime())
10562
10745
  if (pose) this.camera.setVmdPose(pose)
10563
10746
  }
10564
10747
 
@@ -10693,6 +10876,12 @@ export class Engine {
10693
10876
  // uniforms this frame.
10694
10877
  this.evaluateDissolveCycles()
10695
10878
  this.evaluateParamTracks()
10879
+ // FIRST among the things that read an effect, because every one of them
10880
+ // reads what this writes: the sim's clock, the particle uniform's weight,
10881
+ // the light dispatch, the field draw. Evaluated here rather than by a
10882
+ // caller so that playback, the export loop and a warm-up pass cannot
10883
+ // disagree about when an effect is alive — none of them has to remember it.
10884
+ this.evaluateEffectSchedules()
10696
10885
  this.stepSim(encoder, deltaTime)
10697
10886
  this.stepParticles(encoder, deltaTime)
10698
10887
  // Before the scene pass, which READS the slots this writes. Same buffer,
@@ -10754,7 +10943,7 @@ export class Engine {
10754
10943
  this.forEachInstance((inst) => this.renderModelTransparentPhase(pass, inst, camView))
10755
10944
  // Last in the pass: depth-tested against everything drawn above, so a
10756
10945
  // particle behind the character is simply hidden, and still inside the HDR
10757
- // target so an `@bloom` effect reaches the pyramid below.
10946
+ // target so an `#bloom` effect reaches the pyramid below.
10758
10947
  this.renderParticles(pass, "camera")
10759
10948
  // Ribbons, in the same pass and after the particles: both are additive
10760
10949
  // light in HDR, and both reach the bloom pyramid because of it. This used