reze-engine 0.56.6 → 0.56.8

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.
package/dist/engine.js CHANGED
@@ -20,7 +20,7 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
20
20
  import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow";
21
21
  import { ID_DEBUG_SHADER_WGSL } from "./shaders/passes/id-debug";
22
22
  import { paramChanged, sampleParamTrack } from "./param-track";
23
- import { effectState, advanceSim } from "./effect-schedule";
23
+ import { advanceSim, DISSOLVE_PARAMS, dissolveConstants, dissolveCycleOf, effectState, sampleDissolveCycle, scheduledDissolve, } from "./effect-schedule";
24
24
  import { parentKeySpan } from "./parent-keys";
25
25
  import { SHADOW_CASCADES, buildShadowVP } from "./shadow-cascades";
26
26
  import { REFLECTION_DEBUG_WGSL, buildMirrorCamera, planeFromPointNormal } from "./reflection";
@@ -896,6 +896,9 @@ export class Engine {
896
896
  this.cullCameraArgs = null;
897
897
  this.cullShadowArgs = null;
898
898
  this.cullMirrorArgs = null;
899
+ /** Where addGround put the floor. The mirror reflects across it, so a raised
900
+ * floor that kept y = 0 would reflect the scene into the wrong plane. */
901
+ this.groundY = 0;
899
902
  this.mirrorPlane = new Float32Array([0, 1, 0, 0]);
900
903
  this.mirrorCameraData = new Float32Array(40);
901
904
  // proj x mirrorView for the ground's projective sample and the cull planes,
@@ -1428,6 +1431,9 @@ export class Engine {
1428
1431
  this.gizmoDrag = null;
1429
1432
  this.camera?.setInputLocked(false);
1430
1433
  };
1434
+ /** Models this wrote a dissolve onto last frame, so one nothing asks for any
1435
+ * more comes back whole instead of holding wherever it was left. */
1436
+ this.dissolveTouched = new Set();
1431
1437
  this.canvas = canvas;
1432
1438
  const d = DEFAULT_ENGINE_OPTIONS;
1433
1439
  this.world = {
@@ -2147,7 +2153,9 @@ export class Engine {
2147
2153
  planeFromPointNormal(m[12], m[13], m[14], m[8], m[9], m[10], this.mirrorPlane);
2148
2154
  }
2149
2155
  else {
2156
+ // The floor's own plane: up, offset to wherever addGround put it.
2150
2157
  this.mirrorPlane.set(Engine.GROUND_PLANE);
2158
+ this.mirrorPlane[3] = -this.groundY;
2151
2159
  }
2152
2160
  // The ground inside the mirror pass clips against this same plane — a
2153
2161
  // mirror shows nothing behind itself, and the floor is what is behind one.
@@ -3365,6 +3373,7 @@ export class Engine {
3365
3373
  sim: { start: null, time: null },
3366
3374
  simStep: 0,
3367
3375
  simReset: false,
3376
+ dissolve: d.dissolve ? dissolveConstants(authored) : null,
3368
3377
  // Its OWN resolution, no longer the scene's: an effect that never asked
3369
3378
  // for full res is not promoted because a neighbour did.
3370
3379
  // FULL RESOLUTION UNLESS TOLD OTHERWISE.
@@ -7782,6 +7791,7 @@ export class Engine {
7782
7791
  const opts = {
7783
7792
  width: 160,
7784
7793
  height: 160,
7794
+ y: 0,
7785
7795
  diffuseColor: new Vec3(0.9, 0.1, 1.0),
7786
7796
  fadeStart: 10.0,
7787
7797
  fadeEnd: 80.0,
@@ -7797,7 +7807,8 @@ export class Engine {
7797
7807
  shadowSoftness: 0,
7798
7808
  ...options,
7799
7809
  };
7800
- this.createGroundGeometry(opts.width, opts.height);
7810
+ this.groundY = opts.y;
7811
+ this.createGroundGeometry(opts.width, opts.height, opts.y);
7801
7812
  this.createShadowGroundResources(opts);
7802
7813
  this.hasGround = true;
7803
7814
  this.groundDrawCall = {
@@ -7810,7 +7821,14 @@ export class Engine {
7810
7821
  // The ground belongs to no model instance, so it is not in the cull list —
7811
7822
  // cullIndex -1 leaves renderGround unconditional. Its box is filled in
7812
7823
  // anyway rather than left a lie for whoever reads this next.
7813
- bounds: new Float32Array([-opts.width / 2, 0, -opts.height / 2, opts.width / 2, 0, opts.height / 2]),
7824
+ bounds: new Float32Array([
7825
+ -opts.width / 2,
7826
+ opts.y,
7827
+ -opts.height / 2,
7828
+ opts.width / 2,
7829
+ opts.y,
7830
+ opts.height / 2,
7831
+ ]),
7814
7832
  cullIndex: -1,
7815
7833
  };
7816
7834
  }
@@ -10592,13 +10610,13 @@ export class Engine {
10592
10610
  baseBuf,
10593
10611
  };
10594
10612
  }
10595
- createGroundGeometry(width = 100, height = 100) {
10613
+ createGroundGeometry(width = 100, height = 100, y = 0) {
10596
10614
  const halfWidth = width / 2;
10597
10615
  const halfHeight = height / 2;
10598
10616
  const vertices = new Float32Array([
10599
10617
  // Bottom-left
10600
10618
  -halfWidth,
10601
- 0,
10619
+ y,
10602
10620
  -halfHeight, // position
10603
10621
  0,
10604
10622
  1,
@@ -10607,7 +10625,7 @@ export class Engine {
10607
10625
  0, // uv
10608
10626
  // Bottom-right
10609
10627
  halfWidth,
10610
- 0,
10628
+ y,
10611
10629
  -halfHeight, // position
10612
10630
  0,
10613
10631
  1,
@@ -10616,7 +10634,7 @@ export class Engine {
10616
10634
  0, // uv
10617
10635
  // Top-right
10618
10636
  halfWidth,
10619
- 0,
10637
+ y,
10620
10638
  halfHeight, // position
10621
10639
  0,
10622
10640
  1,
@@ -10625,7 +10643,7 @@ export class Engine {
10625
10643
  1, // uv
10626
10644
  // Top-left
10627
10645
  -halfWidth,
10628
- 0,
10646
+ y,
10629
10647
  halfHeight, // position
10630
10648
  0,
10631
10649
  1,
@@ -10693,7 +10711,7 @@ export class Engine {
10693
10711
  return this.groundShadowSoftPipeline;
10694
10712
  }
10695
10713
  createShadowGroundResources(opts) {
10696
- const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, opacity, mirror, mirrorBlur, shadowSoftness, } = opts;
10714
+ const { diffuseColor, y, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, opacity, mirror, mirrorBlur, shadowSoftness, } = opts;
10697
10715
  // Shadow map is already created in setupPipelines()
10698
10716
  // 20 floats: 16 for the original block, then (mirrorBlur, pad, pad, pad)
10699
10717
  // keeping the uniform vec4-aligned.
@@ -10721,6 +10739,9 @@ export class Engine {
10721
10739
  // gb[18] — shadow edge softness. Was padding; the shader reads it as the
10722
10740
  // Vogel disk's radius, and 0 takes the sharp nine-tap path unchanged.
10723
10741
  gb[18] = Math.min(Math.max(shadowSoftness, 0), 1);
10742
+ // gb[19] — the floor's height. Was padding; the mirror branch reflects the
10743
+ // eye across it, and a hardcoded 0 slid the reflection off a raised floor.
10744
+ gb[19] = y;
10724
10745
  // Which variant the draw picks. Zero is the sharp shader, which is the one
10725
10746
  // that existed before softness did.
10726
10747
  this.groundSoft = gb[18] > 0;
@@ -12304,6 +12325,9 @@ export class Engine {
12304
12325
  if (pose)
12305
12326
  this.camera.setVmdPose(pose);
12306
12327
  }
12328
+ // Before the cast is written, which carries every subject's dissolve to the
12329
+ // effects reading it.
12330
+ this.evaluateDissolves();
12307
12331
  this.updateCameraUniforms();
12308
12332
  this.updateShadowLightVP();
12309
12333
  // Depth of field's entire disabled cost is this branch: depth stays in
@@ -12444,7 +12468,6 @@ export class Engine {
12444
12468
  // and a grid stepped after them is one frame stale in everything that used it.
12445
12469
  // Material parameters on the scene clock, before anything reads their
12446
12470
  // uniforms this frame.
12447
- this.evaluateDissolveCycles();
12448
12471
  this.evaluateParamTracks();
12449
12472
  // FIRST among the things that read an effect, because every one of them
12450
12473
  // reads what this writes: the sim's clock, the particle uniform's weight,
@@ -12913,22 +12936,6 @@ export class Engine {
12913
12936
  }
12914
12937
  return true;
12915
12938
  }
12916
- /**
12917
- * A repeating dissolve, on the scene clock.
12918
- *
12919
- * The alternative was a host calling setModelDissolve every frame, and it is
12920
- * the wrong shape twice: an exported take stepped at another rate would land
12921
- * on different values than the preview did, and the effect drawing the sparks
12922
- * would be reading a number some other clock wrote. Here the engine samples it
12923
- * where it samples everything else time-driven, so a take reproduces exactly
12924
- * and rzSubject().dissolve is the same value the material shell used on that
12925
- * very frame.
12926
- *
12927
- * The five numbers are seconds within one cycle: when she starts to go, when
12928
- * she is fully gone, when she starts to come back, and when she is whole. The
12929
- * gaps between them are the timing, and the hold between the middle two is how
12930
- * long she is away.
12931
- */
12932
12939
  /**
12933
12940
  * Eyes on the camera, per model — see Model.setEyeTracking. Live: solved
12934
12941
  * every frame against wherever the camera is, orbit or motion alike. Null
@@ -12941,6 +12948,20 @@ export class Engine {
12941
12948
  inst.model.setEyeTracking(options);
12942
12949
  return true;
12943
12950
  }
12951
+ /**
12952
+ * A repeating dissolve on one model, on the scene clock.
12953
+ *
12954
+ * The alternative was a host calling setModelDissolve every frame, and it is
12955
+ * the wrong shape twice: an exported take stepped at another rate would land
12956
+ * on different values than the preview did, and the effect drawing the sparks
12957
+ * would be reading a number some other clock wrote. Here the engine samples it
12958
+ * where it samples everything else time-driven, so a take reproduces exactly
12959
+ * and rzSubject().dissolve is the same value the material shell used on that
12960
+ * very frame.
12961
+ *
12962
+ * An effect that declares `#dissolve` needs none of this: it carries its own
12963
+ * cycle and follows its own clips — see evaluateDissolves.
12964
+ */
12944
12965
  setModelDissolveCycle(modelName, cycle) {
12945
12966
  if (!this.modelInstances.has(modelName))
12946
12967
  return false;
@@ -12952,26 +12973,69 @@ export class Engine {
12952
12973
  this.dissolveCycles.set(modelName, cycle);
12953
12974
  return true;
12954
12975
  }
12955
- /** Every dissolve cycle, at the current scene clock. Once per frame, before
12956
- * the cast is written and long before any effect reads it. */
12957
- evaluateDissolveCycles() {
12958
- if (this.dissolveCycles.size === 0)
12959
- return;
12960
- for (const [name, c] of this.dissolveCycles) {
12961
- const period = Math.max(c.period, 1e-3);
12962
- const t = this.sceneClock - Math.floor(this.sceneClock / period) * period;
12963
- let v = 1;
12964
- if (t >= c.breakAt && t < c.hiddenAt) {
12965
- v = 1 - (t - c.breakAt) / Math.max(c.hiddenAt - c.breakAt, 1e-4);
12966
- }
12967
- else if (t >= c.hiddenAt && t < c.backAt) {
12968
- v = 0;
12976
+ /** The model at one cast slot — the subject rzSubject(index) reads. */
12977
+ castSubjectName(index) {
12978
+ let n = 0;
12979
+ let found = null;
12980
+ this.forEachInstance((inst) => {
12981
+ if (found !== null || n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp)
12982
+ return;
12983
+ if (n === index)
12984
+ found = inst.name;
12985
+ n++;
12986
+ });
12987
+ return found;
12988
+ }
12989
+ /** One effect's four durations: its dials where it declares them, the
12990
+ * constants it wrote where it does not. */
12991
+ dissolveTimingsOf(fx, wrote) {
12992
+ const t = { ...wrote };
12993
+ for (const [key, name] of DISSOLVE_PARAMS) {
12994
+ const slot = fx.paramLayout.get(name);
12995
+ if (slot)
12996
+ t[key] = Math.max(0, fx.paramsData[slot.offset]);
12997
+ }
12998
+ return t;
12999
+ }
13000
+ /**
13001
+ * Every dissolve, once a frame, before the cast is written — so the value a
13002
+ * mote reads as rzSubject(i).dissolve is the one her materials wore on that
13003
+ * very frame, rather than the one they wore on the last.
13004
+ *
13005
+ * Two things ask for one. A cycle set on a model (setModelDissolveCycle) runs
13006
+ * on the scene clock and repeats, as it always has. An effect declaring
13007
+ * `#dissolve` takes subject 0 apart on ITS OWN terms: scheduled, the cycle on
13008
+ * each clip's clock with her whole between clips; unscheduled, the same
13009
+ * repeating cycle as before. Where both ask, the more dissolved wins — two
13010
+ * answers about one body, and half a body is not one of them.
13011
+ */
13012
+ evaluateDissolves() {
13013
+ const want = new Map();
13014
+ const put = (name, v) => want.set(name, Math.min(want.get(name) ?? 1, v));
13015
+ for (const [name, c] of this.dissolveCycles)
13016
+ put(name, sampleDissolveCycle(c, this.sceneClock));
13017
+ let transport = null;
13018
+ for (const fx of this.effects) {
13019
+ if (!fx.dissolve)
13020
+ continue;
13021
+ const cycle = dissolveCycleOf(this.dissolveTimingsOf(fx, fx.dissolve));
13022
+ const subject = this.castSubjectName(0);
13023
+ if (!cycle || !subject)
13024
+ continue;
13025
+ if (fx.window && fx.window.length > 0) {
13026
+ transport ?? (transport = this.transportTime());
13027
+ put(subject, scheduledDissolve(fx.window, cycle, transport));
12969
13028
  }
12970
- else if (t >= c.backAt && t < c.doneAt) {
12971
- v = (t - c.backAt) / Math.max(c.doneAt - c.backAt, 1e-4);
13029
+ else {
13030
+ put(subject, sampleDissolveCycle(cycle, this.sceneClock));
12972
13031
  }
12973
- this.setModelDissolve(name, v);
12974
13032
  }
13033
+ for (const name of this.dissolveTouched)
13034
+ if (!want.has(name))
13035
+ this.setModelDissolve(name, 1);
13036
+ this.dissolveTouched = new Set(want.keys());
13037
+ for (const [name, v] of want)
13038
+ this.setModelDissolve(name, v);
12975
13039
  }
12976
13040
  /** What setModelDissolve last set, or 1 for a model that has never dissolved. */
12977
13041
  getModelDissolve(modelName) {
@@ -1 +1 @@
1
- {"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"AAgFA;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,UAAQ,GAAG,MAAM,CAmWrD;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,QAAS,CAAA;AACxC,eAAO,MAAM,gBAAgB,QAAS,CAAA;AAEtC;;;oEAGoE;AACpE,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,+oCAiClC,CAAA"}
1
+ {"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"AAgFA;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,UAAQ,GAAG,MAAM,CAoWrD;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,QAAS,CAAA;AACxC,eAAO,MAAM,gBAAgB,QAAS,CAAA;AAEtC;;;oEAGoE;AACpE,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,+oCAiClC,CAAA"}
@@ -90,7 +90,7 @@ struct GroundShadowMat {
90
90
  gridLineColor: vec3f, mirror: f32,
91
91
  // farCascade: 1 while a stage is loaded, 0 otherwise. See the branch below —
92
92
  // with no stage the far map is never drawn into, so its taps are known.
93
- mirrorBlur: f32, farCascade: f32, shadowSoftness: f32, _mb2: f32,
93
+ mirrorBlur: f32, farCascade: f32, shadowSoftness: f32, groundY: f32,
94
94
  // Every shadow caster in one sphere, refreshed per frame. w = radius; 0 means
95
95
  // nothing casts, negative means "do not use this" (a rigid caster has no
96
96
  // sphere, so a scene with a stage keeps the taps). See rzShadowPossible.
@@ -356,20 +356,21 @@ ${pcfWgsl("shadowMap", "suv_c", "material.pcfTexel", "compareZ", "acc", " ",
356
356
  // shared projection's pair (viewZ = projB / (z - projA); the formula
357
357
  // inverts both depth conventions, see the composite's linearDepth), and
358
358
  // reconstruct the reflected image point along the ray from the MIRRORED
359
- // eye through this fragment. The plane is y = 0, so mirroring the eye
360
- // and the camera forward is a sign flip on y, and the image point's
361
- // depth below the plane IS the reflected object's height above it.
359
+ // eye through this fragment. Mirroring across the floor's own plane is a
360
+ // sign flip on y about that height (the forward vector is a direction, so
361
+ // it flips alone), and the image point's depth below the plane IS the
362
+ // reflected object's height above it.
362
363
  let dims = vec2f(textureDimensions(mirrorDepth));
363
364
  let texel = clamp(vec2i(muv * dims), vec2i(0), vec2i(dims) - vec2i(1));
364
365
  let z = textureLoad(mirrorDepth, texel, 0);
365
366
  let viewZ = clamp(mirrorVP.params.y / (z - mirrorVP.params.x), 0.05, 100000.0);
366
- let eyeM = vec3f(camera.viewPos.x, -camera.viewPos.y, camera.viewPos.z);
367
+ let eyeM = vec3f(camera.viewPos.x, 2.0 * material.groundY - camera.viewPos.y, camera.viewPos.z);
367
368
  let fwd = vec3f(camera.view[0][2], camera.view[1][2], camera.view[2][2]);
368
369
  let fwdM = vec3f(fwd.x, -fwd.y, fwd.z);
369
370
  let toFrag = i.worldPos - eyeM;
370
371
  let dir = toFrag * inverseSqrt(max(dot(toFrag, toFrag), 1e-8));
371
372
  let t = viewZ / max(dot(dir, fwdM), 1e-4);
372
- let height = max(-(eyeM.y + dir.y * t), 0.0);
373
+ let height = max(material.groundY - (eyeM.y + dir.y * t), 0.0);
373
374
  // Full softness by BLUR_SPAN units above the floor — about a character's
374
375
  // height. The hardware clamps the level, so the nominal 5.0 needs no
375
376
  // knowledge of the real chain length, and blur 0 reads exactly level 0.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.56.6",
3
+ "version": "0.56.8",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -166,3 +166,99 @@ export function advanceSim(
166
166
  if (prev.start !== window.start || prev.time === null || time < prev.time) return { reset: true, step: 0, clock }
167
167
  return { reset: false, step: Math.min(SIM_MAX_STEP, time - prev.time), clock }
168
168
  }
169
+
170
+ /**
171
+ * A repeating dissolve, in seconds within one cycle.
172
+ *
173
+ * Four moments rather than a duration and a delay: every one of them is a thing
174
+ * you can see happen, and an author tuning this is watching for exactly those
175
+ * four frames.
176
+ */
177
+ export interface DissolveCycle {
178
+ period: number
179
+ /** She starts to come apart. */
180
+ breakAt: number
181
+ /** Fully gone. */
182
+ hiddenAt: number
183
+ /** She starts to come back. */
184
+ backAt: number
185
+ /** Whole again. */
186
+ doneAt: number
187
+ }
188
+
189
+ /** The four durations an effect declares, in seconds. */
190
+ export type DissolveTimings = { apart: number; gone: number; back: number; whole: number }
191
+
192
+ /** Each duration and the name an effect gives it, as a dial or as a constant. */
193
+ export const DISSOLVE_PARAMS = [
194
+ ["apart", "DISSOLVE_APART"],
195
+ ["gone", "DISSOLVE_GONE"],
196
+ ["back", "DISSOLVE_BACK"],
197
+ ["whole", "DISSOLVE_WHOLE"],
198
+ ] as const satisfies readonly (readonly [keyof DissolveTimings, string])[]
199
+
200
+ /**
201
+ * The four durations an effect wrote as constants.
202
+ *
203
+ * Where an effect declares them as `#param` dials instead, these are absent and
204
+ * the live dial stands in — which is what lets an author retime a teleport while
205
+ * watching it.
206
+ */
207
+ export function dissolveConstants(wgsl: string): DissolveTimings {
208
+ const one = (name: string): number => {
209
+ const m = new RegExp(`^\\s*const\\s+${name}\\s*(?::\\s*f32\\s*)?=\\s*(-?[\\d.]+)`, "m").exec(wgsl)
210
+ const v = m ? Number(m[1]) : 0
211
+ return Number.isFinite(v) && v > 0 ? v : 0
212
+ }
213
+ return { apart: one("DISSOLVE_APART"), gone: one("DISSOLVE_GONE"), back: one("DISSOLVE_BACK"), whole: one("DISSOLVE_WHOLE") }
214
+ }
215
+
216
+ /**
217
+ * Four durations as the four moments the engine performs, or null when they add
218
+ * up to nothing — a period of zero is not a dissolve that never fires, it is a
219
+ * number everything downstream divides by.
220
+ *
221
+ * The cycle STARTS WHOLE: the wait comes first, so it is the gap you see between
222
+ * teleports rather than a tail nobody can find the start of.
223
+ */
224
+ export function dissolveCycleOf(t: DissolveTimings): DissolveCycle | null {
225
+ const apart = Math.max(0, t.apart)
226
+ const gone = Math.max(0, t.gone)
227
+ const back = Math.max(0, t.back)
228
+ const whole = Math.max(0, t.whole)
229
+ if (apart + gone + back + whole <= 0.01) return null
230
+ const breakAt = whole
231
+ const hiddenAt = breakAt + apart
232
+ const backAt = hiddenAt + gone
233
+ const doneAt = backAt + back
234
+ return { period: doneAt, breakAt, hiddenAt, backAt, doneAt }
235
+ }
236
+
237
+ /** How much of her is there at one moment of a cycle, 1 whole and 0 gone. */
238
+ export function sampleDissolveCycle(c: DissolveCycle, time: number): number {
239
+ const period = Math.max(c.period, 1e-3)
240
+ const t = time - Math.floor(time / period) * period
241
+ if (t >= c.breakAt && t < c.hiddenAt) return 1 - (t - c.breakAt) / Math.max(c.hiddenAt - c.breakAt, 1e-4)
242
+ if (t >= c.hiddenAt && t < c.backAt) return 0
243
+ if (t >= c.backAt && t < c.doneAt) return (t - c.backAt) / Math.max(c.doneAt - c.backAt, 1e-4)
244
+ return 1
245
+ }
246
+
247
+ /**
248
+ * A scheduled effect's dissolve: whole outside every window, and inside one the
249
+ * cycle on that window's own clock.
250
+ *
251
+ * THE WINDOW OPENS ON THE DEPARTURE. The cycle itself starts whole, which is
252
+ * right for an effect that simply repeats, and wrong for a clip someone placed
253
+ * on a beat: they put it where the teleport happens, not three seconds before
254
+ * it. So the clip enters at `breakAt`, and the wait becomes the tail the motes
255
+ * finish falling in.
256
+ */
257
+ export function scheduledDissolve(
258
+ windows: readonly EffectWindow[],
259
+ c: DissolveCycle,
260
+ transport: number,
261
+ ): number {
262
+ const w = activeWindow(windows, transport)
263
+ return w ? sampleDissolveCycle(c, transport - w.start + c.breakAt) : 1
264
+ }