reze-engine 0.56.5 → 0.56.7

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/src/engine.ts CHANGED
@@ -30,7 +30,19 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut"
30
30
  import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow"
31
31
  import { ID_DEBUG_SHADER_WGSL } from "./shaders/passes/id-debug"
32
32
  import { paramChanged, sampleParamTrack, type ParamKey, type ParamValue } from "./param-track"
33
- import { effectState, type EffectWindow } from "./effect-schedule"
33
+ import {
34
+ advanceSim,
35
+ DISSOLVE_PARAMS,
36
+ dissolveConstants,
37
+ dissolveCycleOf,
38
+ effectState,
39
+ sampleDissolveCycle,
40
+ scheduledDissolve,
41
+ type DissolveCycle,
42
+ type DissolveTimings,
43
+ type EffectWindow,
44
+ type SimClock,
45
+ } from "./effect-schedule"
34
46
  import { parentKeySpan, type ModelParentKey } from "./parent-keys"
35
47
  import { SHADOW_CASCADES, buildShadowVP } from "./shadow-cascades"
36
48
  import { REFLECTION_DEBUG_WGSL, buildMirrorCamera, planeFromPointNormal } from "./reflection"
@@ -745,24 +757,8 @@ interface PickDrawCall {
745
757
  bindGroup: GPUBindGroup
746
758
  }
747
759
 
748
- /**
749
- * A repeating dissolve, in seconds within one cycle.
750
- *
751
- * Four moments rather than a duration and a delay: every one of them is a thing
752
- * you can see happen, and an author tuning this is watching for exactly those
753
- * four frames.
754
- */
755
- export interface DissolveCycle {
756
- period: number
757
- /** She starts to come apart. */
758
- breakAt: number
759
- /** Fully gone. */
760
- hiddenAt: number
761
- /** She starts to come back. */
762
- backAt: number
763
- /** Whole again. */
764
- doneAt: number
765
- }
760
+ /** Authored in effect-schedule, beside the evaluator that performs it. */
761
+ export type { DissolveCycle }
766
762
 
767
763
  interface ModelInstance {
768
764
  name: string
@@ -1533,6 +1529,19 @@ interface EffectInstance {
1533
1529
  * is what makes a scheduled effect cost nothing outside its window.
1534
1530
  */
1535
1531
  weight: number
1532
+ /** Where this effect's simulation stood last frame, while scheduled. See advanceSim. */
1533
+ sim: SimClock
1534
+ /** This frame's particle and grid step in seconds, from the transport. Read
1535
+ * only while the effect is scheduled; an unscheduled one steps by the render delta. */
1536
+ simStep: number
1537
+ /** Empty the particle pool and the grid before this frame's step. */
1538
+ simReset: boolean
1539
+ /**
1540
+ * The four durations this effect's `#dissolve` takes the cast apart over, as
1541
+ * its source wrote them. Null unless it declared one. A dial of the same name
1542
+ * wins over the constant — see dissolveTimingsOf.
1543
+ */
1544
+ dissolve: DissolveTimings | null
1536
1545
  /** This effect's OWN clock, as a uniform the field shader reads. Per effect
1537
1546
  * because the shared one (viewU[6].x) is measured from the first installed
1538
1547
  * effect's epoch, so everything later started mid-stream. Null when the
@@ -4547,6 +4556,10 @@ export class Engine {
4547
4556
  influence: 1,
4548
4557
  window: null,
4549
4558
  weight: 1,
4559
+ sim: { start: null, time: null },
4560
+ simStep: 0,
4561
+ simReset: false,
4562
+ dissolve: d.dissolve ? dissolveConstants(authored) : null,
4550
4563
  // Its OWN resolution, no longer the scene's: an effect that never asked
4551
4564
  // for full res is not promoted because a neighbour did.
4552
4565
  // FULL RESOLUTION UNLESS TOLD OTHERWISE.
@@ -4823,10 +4836,11 @@ export class Engine {
4823
4836
  const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render")
4824
4837
  if (Array.isArray(renderModule)) return { ok: false, diagnostics: renderModule }
4825
4838
 
4839
+ // COPY_DST so a scheduled effect can empty its pool when its window starts.
4826
4840
  const buffer = this.device.createBuffer({
4827
4841
  label: "particle pool",
4828
4842
  size: count * PARTICLE_STRIDE,
4829
- usage: GPUBufferUsage.STORAGE,
4843
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
4830
4844
  })
4831
4845
  const uniform = this.device.createBuffer({
4832
4846
  label: "particle uniforms",
@@ -5092,6 +5106,14 @@ export class Engine {
5092
5106
  for (const e of this.effects) {
5093
5107
  const p = e.particles
5094
5108
  if (!p) continue
5109
+ const scheduled = e.window !== null
5110
+ // A scheduled pool that has not moved holds still: a paused transport, or
5111
+ // a time outside every window. A dispatch at dt 0 would still run the
5112
+ // author's step, and not every step is a function of dt.
5113
+ if (scheduled && !e.simReset && e.simStep === 0) continue
5114
+ // Entering its window, or going back in it: the pool starts empty, and
5115
+ // every slot spawns on this dispatch from the effect's own local time.
5116
+ if (e.simReset) encoder.clearBuffer(p.buffer)
5095
5117
  p.data[0] = this.sceneClock - e.epochScene
5096
5118
  // The SIMULATION runs at every weight, 0 included — only the draw stops.
5097
5119
  // A scheduled effect that froze while faded out would resume from the
@@ -5100,7 +5122,9 @@ export class Engine {
5100
5122
  p.data[4] = e.weight
5101
5123
  // Clamped: a backgrounded tab returns with a delta of whole seconds, and an
5102
5124
  // unclamped step flings every particle out of the scene in one frame.
5103
- p.data[1] = Math.min(0.1, Math.max(0, deltaTime))
5125
+ // A scheduled effect steps by the transport instead, clamped the same way
5126
+ // in advanceSim, so a pause freezes it and an export steps it exactly.
5127
+ p.data[1] = scheduled ? e.simStep : Math.min(0.1, Math.max(0, deltaTime))
5104
5128
  p.counts[2] = p.count
5105
5129
  p.counts[3] = this.particleFrame++
5106
5130
  this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer as ArrayBuffer)
@@ -5554,7 +5578,8 @@ export class Engine {
5554
5578
  label: `grid grid ${n}`,
5555
5579
  size: [size, size],
5556
5580
  format: SIM_FORMAT,
5557
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING,
5581
+ // RENDER_ATTACHMENT so a scheduled effect can clear it when its window starts.
5582
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
5558
5583
  })
5559
5584
  const textures: [GPUTexture, GPUTexture] = [make(0), make(1)]
5560
5585
  const read: [GPUTextureView, GPUTextureView] = [textures[0].createView(), textures[1].createView()]
@@ -5624,11 +5649,28 @@ export class Engine {
5624
5649
  for (const e of this.effects) {
5625
5650
  const grid = e.grid
5626
5651
  if (!grid) continue
5652
+ const scheduled = e.window !== null
5653
+ // Held still like the particles, for the same reason.
5654
+ if (scheduled && !e.simReset && e.simStep === 0) continue
5655
+ if (e.simReset) {
5656
+ // Back to how install left it: both textures empty and frame 0 again,
5657
+ // the frame an effect seeds its grid on.
5658
+ for (const view of grid.read) {
5659
+ encoder
5660
+ .beginRenderPass({
5661
+ label: "grid reset",
5662
+ colorAttachments: [{ view, loadOp: "clear", storeOp: "store", clearValue: { r: 0, g: 0, b: 0, a: 0 } }],
5663
+ })
5664
+ .end()
5665
+ }
5666
+ grid.frame = 0
5667
+ grid.parity = 0
5668
+ }
5627
5669
  grid.data[0] = this.sceneClock - e.epochScene
5628
5670
  // Clamped like the particle step: a backgrounded tab returns with a delta of
5629
5671
  // whole seconds, and one unclamped step of an advection kernel throws the
5630
5672
  // whole grid off its own edge.
5631
- grid.data[1] = Math.min(0.1, Math.max(0, deltaTime))
5673
+ grid.data[1] = scheduled ? e.simStep : Math.min(0.1, Math.max(0, deltaTime))
5632
5674
  grid.data[2] = grid.size
5633
5675
  grid.data[3] = grid.frame++
5634
5676
  this.device.queue.writeBuffer(grid.uniform, 0, grid.data.buffer as ArrayBuffer)
@@ -5727,6 +5769,11 @@ export class Engine {
5727
5769
  const fx = this.effects[index]
5728
5770
  if (!fx) return
5729
5771
  fx.window = windows && windows.length ? windows : null
5772
+ // Unscheduled forgets where its simulation stood. A changed lane keeps it:
5773
+ // advanceSim restarts the pool only if the window the transport is in now
5774
+ // starts somewhere else, so trimming a block, or turning the influence
5775
+ // that is sent with every lane, does not restart a burst mid-flight.
5776
+ if (!fx.window) fx.sim = { start: null, time: null }
5730
5777
  }
5731
5778
 
5732
5779
  getEffectSchedule(index: number): readonly EffectWindow[] | null {
@@ -5745,12 +5792,20 @@ export class Engine {
5745
5792
  // Read ONCE: it walks the cast, and every effect wants the same answer.
5746
5793
  const transport = this.transportTime()
5747
5794
  for (const fx of this.effects) {
5795
+ fx.simReset = false
5748
5796
  if (!fx.window || fx.window.length === 0) {
5749
5797
  fx.weight = fx.influence
5750
5798
  continue
5751
5799
  }
5752
5800
  const at = effectState(fx.window, fx.influence, transport)
5753
5801
  fx.weight = at.weight
5802
+ // The particles and the grid carry state from frame to frame, so they get
5803
+ // more than a clock: a restart on entering a window or going back in one,
5804
+ // and a step measured on the transport. See advanceSim.
5805
+ const sim = advanceSim(fx.sim, fx.window, transport)
5806
+ fx.sim = sim.clock
5807
+ fx.simStep = sim.step
5808
+ fx.simReset = sim.reset
5754
5809
  // Its own clock, expressed the way the mounts read it. Every mount
5755
5810
  // derives time from the epoch against sceneClock, so this one write moves
5756
5811
  // the field, the particles, the ribbons, lightEmit and the grid together
@@ -14338,6 +14393,9 @@ export class Engine {
14338
14393
  if (pose) this.camera.setVmdPose(pose)
14339
14394
  }
14340
14395
 
14396
+ // Before the cast is written, which carries every subject's dissolve to the
14397
+ // effects reading it.
14398
+ this.evaluateDissolves()
14341
14399
  this.updateCameraUniforms()
14342
14400
  this.updateShadowLightVP()
14343
14401
 
@@ -14479,7 +14537,6 @@ export class Engine {
14479
14537
  // and a grid stepped after them is one frame stale in everything that used it.
14480
14538
  // Material parameters on the scene clock, before anything reads their
14481
14539
  // uniforms this frame.
14482
- this.evaluateDissolveCycles()
14483
14540
  this.evaluateParamTracks()
14484
14541
  // FIRST among the things that read an effect, because every one of them
14485
14542
  // reads what this writes: the sim's clock, the particle uniform's weight,
@@ -14960,22 +15017,6 @@ export class Engine {
14960
15017
  return true
14961
15018
  }
14962
15019
 
14963
- /**
14964
- * A repeating dissolve, on the scene clock.
14965
- *
14966
- * The alternative was a host calling setModelDissolve every frame, and it is
14967
- * the wrong shape twice: an exported take stepped at another rate would land
14968
- * on different values than the preview did, and the effect drawing the sparks
14969
- * would be reading a number some other clock wrote. Here the engine samples it
14970
- * where it samples everything else time-driven, so a take reproduces exactly
14971
- * and rzSubject().dissolve is the same value the material shell used on that
14972
- * very frame.
14973
- *
14974
- * The five numbers are seconds within one cycle: when she starts to go, when
14975
- * she is fully gone, when she starts to come back, and when she is whole. The
14976
- * gaps between them are the timing, and the hold between the middle two is how
14977
- * long she is away.
14978
- */
14979
15020
  /**
14980
15021
  * Eyes on the camera, per model — see Model.setEyeTracking. Live: solved
14981
15022
  * every frame against wherever the camera is, orbit or motion alike. Null
@@ -14988,6 +15029,20 @@ export class Engine {
14988
15029
  return true
14989
15030
  }
14990
15031
 
15032
+ /**
15033
+ * A repeating dissolve on one model, on the scene clock.
15034
+ *
15035
+ * The alternative was a host calling setModelDissolve every frame, and it is
15036
+ * the wrong shape twice: an exported take stepped at another rate would land
15037
+ * on different values than the preview did, and the effect drawing the sparks
15038
+ * would be reading a number some other clock wrote. Here the engine samples it
15039
+ * where it samples everything else time-driven, so a take reproduces exactly
15040
+ * and rzSubject().dissolve is the same value the material shell used on that
15041
+ * very frame.
15042
+ *
15043
+ * An effect that declares `#dissolve` needs none of this: it carries its own
15044
+ * cycle and follows its own clips — see evaluateDissolves.
15045
+ */
14991
15046
  setModelDissolveCycle(modelName: string, cycle: DissolveCycle | null): boolean {
14992
15047
  if (!this.modelInstances.has(modelName)) return false
14993
15048
  if (!cycle) {
@@ -14998,23 +15053,65 @@ export class Engine {
14998
15053
  return true
14999
15054
  }
15000
15055
 
15001
- /** Every dissolve cycle, at the current scene clock. Once per frame, before
15002
- * the cast is written and long before any effect reads it. */
15003
- private evaluateDissolveCycles(): void {
15004
- if (this.dissolveCycles.size === 0) return
15005
- for (const [name, c] of this.dissolveCycles) {
15006
- const period = Math.max(c.period, 1e-3)
15007
- const t = this.sceneClock - Math.floor(this.sceneClock / period) * period
15008
- let v = 1
15009
- if (t >= c.breakAt && t < c.hiddenAt) {
15010
- v = 1 - (t - c.breakAt) / Math.max(c.hiddenAt - c.breakAt, 1e-4)
15011
- } else if (t >= c.hiddenAt && t < c.backAt) {
15012
- v = 0
15013
- } else if (t >= c.backAt && t < c.doneAt) {
15014
- v = (t - c.backAt) / Math.max(c.doneAt - c.backAt, 1e-4)
15056
+ /** The model at one cast slot — the subject rzSubject(index) reads. */
15057
+ private castSubjectName(index: number): string | null {
15058
+ let n = 0
15059
+ let found: string | null = null
15060
+ this.forEachInstance((inst) => {
15061
+ if (found !== null || n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp) return
15062
+ if (n === index) found = inst.name
15063
+ n++
15064
+ })
15065
+ return found
15066
+ }
15067
+
15068
+ /** One effect's four durations: its dials where it declares them, the
15069
+ * constants it wrote where it does not. */
15070
+ private dissolveTimingsOf(fx: EffectInstance, wrote: DissolveTimings): DissolveTimings {
15071
+ const t = { ...wrote }
15072
+ for (const [key, name] of DISSOLVE_PARAMS) {
15073
+ const slot = fx.paramLayout.get(name)
15074
+ if (slot) t[key] = Math.max(0, fx.paramsData[slot.offset])
15075
+ }
15076
+ return t
15077
+ }
15078
+
15079
+ /** Models this wrote a dissolve onto last frame, so one nothing asks for any
15080
+ * more comes back whole instead of holding wherever it was left. */
15081
+ private dissolveTouched = new Set<string>()
15082
+
15083
+ /**
15084
+ * Every dissolve, once a frame, before the cast is written — so the value a
15085
+ * mote reads as rzSubject(i).dissolve is the one her materials wore on that
15086
+ * very frame, rather than the one they wore on the last.
15087
+ *
15088
+ * Two things ask for one. A cycle set on a model (setModelDissolveCycle) runs
15089
+ * on the scene clock and repeats, as it always has. An effect declaring
15090
+ * `#dissolve` takes subject 0 apart on ITS OWN terms: scheduled, the cycle on
15091
+ * each clip's clock with her whole between clips; unscheduled, the same
15092
+ * repeating cycle as before. Where both ask, the more dissolved wins — two
15093
+ * answers about one body, and half a body is not one of them.
15094
+ */
15095
+ private evaluateDissolves(): void {
15096
+ const want = new Map<string, number>()
15097
+ const put = (name: string, v: number) => want.set(name, Math.min(want.get(name) ?? 1, v))
15098
+ for (const [name, c] of this.dissolveCycles) put(name, sampleDissolveCycle(c, this.sceneClock))
15099
+ let transport: number | null = null
15100
+ for (const fx of this.effects) {
15101
+ if (!fx.dissolve) continue
15102
+ const cycle = dissolveCycleOf(this.dissolveTimingsOf(fx, fx.dissolve))
15103
+ const subject = this.castSubjectName(0)
15104
+ if (!cycle || !subject) continue
15105
+ if (fx.window && fx.window.length > 0) {
15106
+ transport ??= this.transportTime()
15107
+ put(subject, scheduledDissolve(fx.window, cycle, transport))
15108
+ } else {
15109
+ put(subject, sampleDissolveCycle(cycle, this.sceneClock))
15015
15110
  }
15016
- this.setModelDissolve(name, v)
15017
15111
  }
15112
+ for (const name of this.dissolveTouched) if (!want.has(name)) this.setModelDissolve(name, 1)
15113
+ this.dissolveTouched = new Set(want.keys())
15114
+ for (const [name, v] of want) this.setModelDissolve(name, v)
15018
15115
  }
15019
15116
 
15020
15117
  /** What setModelDissolve last set, or 1 for a model that has never dissolved. */
package/src/index.ts CHANGED
@@ -39,7 +39,15 @@ export { sampleParamTrack, type ParamKey, type ParamValue } from "./param-track"
39
39
  // The strip an effect is scheduled by, and the pure evaluator behind it —
40
40
  // exported so a caller can draw a lane against the same numbers the engine
41
41
  // renders from, rather than a second copy of the ramp maths.
42
- export { effectState, type EffectWindow, type EffectState } from "./effect-schedule"
42
+ export {
43
+ activeWindow,
44
+ advanceSim,
45
+ effectState,
46
+ SIM_MAX_STEP,
47
+ type EffectWindow,
48
+ type EffectState,
49
+ type SimClock,
50
+ } from "./effect-schedule"
43
51
  // Who a model hangs from over time, and the lookup behind it — exported so a
44
52
  // timeline draws its holds from the same rule the engine switches by.
45
53
  export { parentKeyIndex, type ModelParentKey } from "./parent-keys"