reze-engine 0.55.4 → 0.55.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
@@ -1271,6 +1271,26 @@ function vertexMorphReach(model: Model): number {
1271
1271
  const TEXTURE_EXTENSION_GUESSES = [".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds", ".spa", ".sph"]
1272
1272
 
1273
1273
  /** One effect's GPU particle pool. Per effect: each declares its own count. */
1274
+ /**
1275
+ * The binding number an effect's params take in each pass.
1276
+ *
1277
+ * 7 in the composite, the particle stages and the ribbon pass, which all stop
1278
+ * below it. The GRID reaches 8 on its own, so it takes the next one up rather
1279
+ * than everything else moving to accommodate one mount.
1280
+ */
1281
+ const EFFECT_PARAMS_BINDING = 7
1282
+ const EFFECT_PARAMS_BINDING_GRID = 9
1283
+
1284
+ /** How a mount receives the effect's declared dials: a generator for the struct
1285
+ * at the binding that mount has free, and the buffer behind it. Null buffer
1286
+ * means the effect declared none, and then neither the decl nor the binding is
1287
+ * emitted — WGSL has no empty struct, and a bound buffer nothing reads is a
1288
+ * layout mismatch. */
1289
+ type EffectParamsBinding = {
1290
+ wgsl: (binding: number) => string
1291
+ buffer: GPUBuffer | null
1292
+ }
1293
+
1274
1294
  interface EffectParticles {
1275
1295
  count: number
1276
1296
  buffer: GPUBuffer
@@ -1313,6 +1333,9 @@ interface EffectGrid {
1313
1333
  data: Float32Array
1314
1334
  parity: number
1315
1335
  frame: number
1336
+ /** The effect's declared dials, or null. Held HERE as well as on the instance
1337
+ * because the rebind path rebuilds this bind group from the grid alone. */
1338
+ params: GPUBuffer | null
1316
1339
  }
1317
1340
 
1318
1341
  /**
@@ -1470,6 +1493,9 @@ interface EffectInstance {
1470
1493
  /** How many slots it asked for. Its base is assigned by the engine and can
1471
1494
  * move, which is why it travels in the uniform rather than the shader. */
1472
1495
  count: number
1496
+ /** The effect's declared parameters, or null when it declares none. Kept
1497
+ * for the same reason `layout` is: `bind` is rebuilt on a buffer swap. */
1498
+ params: GPUBuffer | null
1473
1499
  } | null
1474
1500
  /** The field mount's pipeline and its two parity bind groups, or null when the
1475
1501
  * effect declares neither background nor foreground. */
@@ -3253,7 +3279,13 @@ export class Engine {
3253
3279
  * fail where it was written, it fails at the next submit.
3254
3280
  */
3255
3281
  private gridBindGroup(
3256
- g: { layout: GPUBindGroupLayout; uniform: GPUBuffer; read: [GPUTextureView, GPUTextureView]; textures: [GPUTexture, GPUTexture] },
3282
+ g: {
3283
+ layout: GPUBindGroupLayout
3284
+ uniform: GPUBuffer
3285
+ read: [GPUTextureView, GPUTextureView]
3286
+ textures: [GPUTexture, GPUTexture]
3287
+ params: GPUBuffer | null
3288
+ },
3257
3289
  i: number,
3258
3290
  ): GPUBindGroup {
3259
3291
  return this.device.createBindGroup({
@@ -3268,6 +3300,9 @@ export class Engine {
3268
3300
  { binding: 6, resource: { buffer: this.compositeUniformBuffer } },
3269
3301
  { binding: 7, resource: { buffer: this.midiBuffer } },
3270
3302
  { binding: 8, resource: { buffer: this.lyricsBuffer } },
3303
+ // The grid is the one pass whose own bindings reach 8, so its params sit
3304
+ // above them rather than everything else shifting for one mount.
3305
+ ...(g.params ? [{ binding: EFFECT_PARAMS_BINDING_GRID, resource: { buffer: g.params } }] : []),
3271
3306
  ],
3272
3307
  })
3273
3308
  }
@@ -3304,7 +3339,7 @@ export class Engine {
3304
3339
  e.particles.mirrorRenderBind = b.mirrorRenderBind
3305
3340
  }
3306
3341
  if (e.grid) e.grid.binds = [this.gridBindGroup(e.grid, 0), this.gridBindGroup(e.grid, 1)]
3307
- if (e.lights) e.lights.bind = this.lightEmitBindGroup(e.lights.layout, e.lights.uniform)
3342
+ if (e.lights) e.lights.bind = this.lightEmitBindGroup(e.lights.layout, e.lights.uniform, e.lights.params)
3308
3343
  }
3309
3344
  }
3310
3345
 
@@ -3610,9 +3645,19 @@ export class Engine {
3610
3645
  paramsData[slot.offset + 2] = value.z
3611
3646
  }
3612
3647
  }
3613
- const paramsDecl = entries.length
3614
- ? `struct EffectParams {\n${fields.join("\n")}\n}\n@group(0) @binding(7) var<uniform> params: EffectParams;\n`
3615
- : ""
3648
+ /**
3649
+ * The params block, at whatever binding the pass asking has free.
3650
+ *
3651
+ * A single hardcoded slot cannot serve every mount — the grid's layout
3652
+ * already reaches binding 8 — so the struct is generated per pass and each
3653
+ * one states the number it can spare. Empty when nothing is declared: WGSL
3654
+ * has no empty struct, and a binding nothing reads is a layout mismatch.
3655
+ */
3656
+ const paramsWgsl = (binding: number) =>
3657
+ entries.length
3658
+ ? `struct EffectParams {\n${fields.join("\n")}\n}\n@group(0) @binding(${binding}) var<uniform> params: EffectParams;\n`
3659
+ : ""
3660
+ const paramsDecl = paramsWgsl(EFFECT_PARAMS_BINDING)
3616
3661
 
3617
3662
  // ── Compile with validation captured, not thrown at the console. Line
3618
3663
  // numbers in diagnostics are rebased to the USER's source.
@@ -3720,7 +3765,32 @@ export class Engine {
3720
3765
  let grid: EffectGrid | null = null
3721
3766
  let trails: EffectTrails | null = null
3722
3767
 
3768
+ // PARAMS ARE NOT A FIELD-MOUNT FEATURE, and used to be one by accident.
3769
+ //
3770
+ // `paramsDecl` was spliced into the composite alone, so `#param` worked for
3771
+ // background/foreground and produced "unresolved value 'params'" for every
3772
+ // particle, grid and ribbon effect — which is most of the ones anyone wants
3773
+ // a dial on. Rain's fall speed is the whole example.
3774
+ //
3775
+ // The buffer is created HERE rather than beside the instance it ends up on,
3776
+ // because the mount builders below need to bind it and they run first. That
3777
+ // makes `abandon` its owner: a mount that fails to compile must not leak it.
3778
+ let paramsBuffer: GPUBuffer | null = null
3779
+ if (entries.length) {
3780
+ paramsBuffer = this.device.createBuffer({
3781
+ label: "effect params",
3782
+ size: paramsData.byteLength,
3783
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3784
+ })
3785
+ this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
3786
+ }
3787
+ /** What every mount splices and binds: a generator for the struct decl at
3788
+ * the binding that mount has free, and the buffer behind it. The buffer is
3789
+ * null when nothing is declared, and then no binding is added at all. */
3790
+ const paramsFor: EffectParamsBinding = { wgsl: paramsWgsl, buffer: paramsBuffer }
3791
+
3723
3792
  const abandon = (diagnostics: string[]): EffectResult => {
3793
+ paramsBuffer?.destroy()
3724
3794
  particles?.buffer.destroy()
3725
3795
  particles?.uniform.destroy()
3726
3796
  grid?.textures[0].destroy()
@@ -3730,12 +3800,12 @@ export class Engine {
3730
3800
  return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration }
3731
3801
  }
3732
3802
  if (wantsParticles) {
3733
- const built = await this.buildParticles(wgsl, d, anchors, alias)
3803
+ const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor)
3734
3804
  if (!built.ok) return abandon(built.diagnostics)
3735
3805
  particles = built.state
3736
3806
  }
3737
3807
  if (gridEntryPoint(wgsl)) {
3738
- const built = await this.buildSim(wgsl, d, anchors, alias)
3808
+ const built = await this.buildSim(wgsl, d, anchors, alias, paramsFor)
3739
3809
  if (!built.ok) return abandon(built.diagnostics)
3740
3810
  grid = built.state
3741
3811
  }
@@ -3744,9 +3814,9 @@ export class Engine {
3744
3814
  // bone recorded without one would read zeroes and paint a line to the origin.
3745
3815
  const trailSlots = anchors.filter((a) => a.trail).length
3746
3816
  if (trailSlots === 0) {
3747
- return { ok: false, diagnostics: ["a ribbon effect needs at least one #anchor <bone> trail"], mounts, params: d.params, duration: d.duration }
3817
+ return abandon(["a ribbon effect needs at least one #anchor <bone> trail"])
3748
3818
  }
3749
- const built = await this.buildTrails(wgsl, d, anchors, alias)
3819
+ const built = await this.buildTrails(wgsl, d, anchors, alias, paramsFor)
3750
3820
  if (!built.ok) return abandon(built.diagnostics)
3751
3821
  trails = built.state
3752
3822
  }
@@ -3769,7 +3839,7 @@ export class Engine {
3769
3839
  ])
3770
3840
  }
3771
3841
  if (declaredLights > 0) {
3772
- const built = await this.buildLightEmit(wgsl, declaredLights, alias, anchors)
3842
+ const built = await this.buildLightEmit(wgsl, declaredLights, alias, anchors, paramsFor)
3773
3843
  if (!built.ok) return abandon(built.diagnostics)
3774
3844
  lights = built.state
3775
3845
  }
@@ -3783,15 +3853,6 @@ export class Engine {
3783
3853
  })
3784
3854
  : null
3785
3855
 
3786
- let paramsBuffer: GPUBuffer | null = null
3787
- if (entries.length) {
3788
- paramsBuffer = this.device.createBuffer({
3789
- label: "effect params",
3790
- size: paramsData.byteLength,
3791
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3792
- })
3793
- this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
3794
- }
3795
3856
  const instance: EffectInstance = {
3796
3857
  wgsl,
3797
3858
  paramDecls: d.params,
@@ -4041,11 +4102,14 @@ export class Engine {
4041
4102
  * engine: the builders run BEFORE the swap, so this.anchorTable still
4042
4103
  * describes the effect that is still on screen. */
4043
4104
  alias: number[],
4105
+ /** The effect's declared dials. Spliced into both stages and bound at 7,
4106
+ * the same binding the composite gives them. */
4107
+ params: EffectParamsBinding,
4044
4108
  ): Promise<{ ok: true; state: EffectParticles } | { ok: false; diagnostics: string[] }> {
4045
4109
  // No pragma means "some": an author who wrote the trio clearly wants
4046
4110
  // particles, and failing over a missing comment would be pedantry.
4047
4111
  const count = Math.min(d.particles || 1024, Engine.MAX_PARTICLES)
4048
- const src = { wgsl, count, blend: d.particleBlend, bloom: d.bloom }
4112
+ const src = { wgsl, count, blend: d.particleBlend, bloom: d.bloom, paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING) }
4049
4113
  // Sparks want to spawn where a trail is, so the particle stages see the same
4050
4114
  // cast buffer the trail draw reads.
4051
4115
  const cast = {
@@ -4110,6 +4174,9 @@ export class Engine {
4110
4174
  // rule and a background read the same instant.
4111
4175
  { binding: 5, visibility, buffer: { type: "read-only-storage" } },
4112
4176
  { binding: 6, visibility, buffer: { type: "read-only-storage" } },
4177
+ // Only when the effect declared any: WGSL has no empty struct, so a
4178
+ // param-less effect must not carry the decl OR the binding.
4179
+ ...(params.buffer ? [{ binding: 7, visibility, buffer: { type: "uniform" as const } }] : []),
4113
4180
  ],
4114
4181
  })
4115
4182
  const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer) =>
@@ -4123,6 +4190,7 @@ export class Engine {
4123
4190
  { binding: 4, resource: { buffer: this.audioBuffer } },
4124
4191
  { binding: 5, resource: { buffer: this.midiBuffer } },
4125
4192
  { binding: 6, resource: { buffer: this.lyricsBuffer } },
4193
+ ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
4126
4194
  ],
4127
4195
  })
4128
4196
 
@@ -4212,6 +4280,7 @@ export class Engine {
4212
4280
  count: number,
4213
4281
  alias: number[],
4214
4282
  anchors: { bone: string; trail: boolean }[],
4283
+ params: EffectParamsBinding,
4215
4284
  ): Promise<{ ok: true; state: NonNullable<EffectInstance["lights"]> } | { ok: false; diagnostics: string[] }> {
4216
4285
  const layout = this.device.createBindGroupLayout({
4217
4286
  label: "light emit layout",
@@ -4223,13 +4292,21 @@ export class Engine {
4223
4292
  { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
4224
4293
  { binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
4225
4294
  { binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
4295
+ ...(params.buffer
4296
+ ? [{ binding: EFFECT_PARAMS_BINDING, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" as const } }]
4297
+ : []),
4226
4298
  ],
4227
4299
  })
4228
4300
  const module = this.device.createShaderModule({
4229
4301
  label: "light emit",
4230
4302
  // The same scene API and the same per-effect anchor alias its drawing
4231
4303
  // half gets, so a lamp reads the cast exactly as the beam that paints it.
4232
- code: buildLightEmitShader(wgsl, EFFECT_SCENE_API + anchorAliasWgsl(alias), { trailCount: anchors.filter((a) => a.trail).length }),
4304
+ code: buildLightEmitShader(
4305
+ wgsl,
4306
+ EFFECT_SCENE_API + anchorAliasWgsl(alias),
4307
+ { trailCount: anchors.filter((a) => a.trail).length },
4308
+ params.wgsl(EFFECT_PARAMS_BINDING),
4309
+ ),
4233
4310
  })
4234
4311
  const info = await module.getCompilationInfo()
4235
4312
  const diagnostics = info.messages.filter((m) => m.type === "error").map((m) => `${m.lineNum}:${m.linePos} ${m.message}`)
@@ -4251,15 +4328,15 @@ export class Engine {
4251
4328
  uniform.destroy()
4252
4329
  return { ok: false, diagnostics: [scoped.message] }
4253
4330
  }
4254
- const bind = this.lightEmitBindGroup(layout, uniform)
4331
+ const bind = this.lightEmitBindGroup(layout, uniform, params.buffer)
4255
4332
  // The layout travels with the state so the emitter can be rebound when a
4256
4333
  // shared buffer under it is replaced — see rebindSharedBuffers.
4257
- return { ok: true, state: { pipeline, bind, layout, uniform, data, count } }
4334
+ return { ok: true, state: { pipeline, bind, layout, uniform, data, count, params: params.buffer } }
4258
4335
  }
4259
4336
 
4260
4337
  /** The light emitter's bind group. One author, for the reason gridBindGroup
4261
4338
  * gives: it is built once and rebuilt on every shared-buffer swap. */
4262
- private lightEmitBindGroup(layout: GPUBindGroupLayout, uniform: GPUBuffer): GPUBindGroup {
4339
+ private lightEmitBindGroup(layout: GPUBindGroupLayout, uniform: GPUBuffer, params: GPUBuffer | null): GPUBindGroup {
4263
4340
  return this.device.createBindGroup({
4264
4341
  label: "light emit bind",
4265
4342
  layout,
@@ -4271,6 +4348,7 @@ export class Engine {
4271
4348
  { binding: 4, resource: { buffer: this.audioBuffer } },
4272
4349
  { binding: 5, resource: { buffer: this.midiBuffer } },
4273
4350
  { binding: 6, resource: { buffer: this.lyricsBuffer } },
4351
+ ...(params ? [{ binding: EFFECT_PARAMS_BINDING, resource: { buffer: params } }] : []),
4274
4352
  ],
4275
4353
  })
4276
4354
  }
@@ -4380,6 +4458,8 @@ export class Engine {
4380
4458
  * engine: the builders run BEFORE the swap, so this.anchorTable still
4381
4459
  * describes the effect that is still on screen. */
4382
4460
  alias: number[],
4461
+ /** The effect's declared dials, spliced and bound at 7 as everywhere else. */
4462
+ params: EffectParamsBinding,
4383
4463
  ): Promise<{ ok: true; state: EffectTrails } | { ok: false; diagnostics: string[] }> {
4384
4464
  // `slots` here is how many RIBBONS to draw — one per trailed anchor — which
4385
4465
  // is a different number from the anchor ADDRESS SPACE the accessors index
@@ -4390,7 +4470,7 @@ export class Engine {
4390
4470
  // nothing before: ribbon i was read as anchor slot i.
4391
4471
  const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0)
4392
4472
  const slots = ribbonSlots.length
4393
- const src = { wgsl, slots, ribbonSlots, blend: d.particleBlend, bloom: d.bloom }
4473
+ const src = { wgsl, slots, ribbonSlots, blend: d.particleBlend, bloom: d.bloom, paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING) }
4394
4474
  const code = buildTrailShader(src, {
4395
4475
  subjects: MAX_EFFECT_SUBJECTS,
4396
4476
  samples: TRAIL_SAMPLES,
@@ -4434,6 +4514,9 @@ export class Engine {
4434
4514
  { binding: 5, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
4435
4515
  // The lyrics, for rzLyric*.
4436
4516
  { binding: 6, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
4517
+ ...(params.buffer
4518
+ ? [{ binding: 7, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } }]
4519
+ : []),
4437
4520
  ],
4438
4521
  })
4439
4522
  // TWO targets, the scene pass's own: HDR colour and the aux (bloom mask,
@@ -4486,6 +4569,7 @@ export class Engine {
4486
4569
  { binding: 4, resource: { buffer: this.audioBuffer } },
4487
4570
  { binding: 5, resource: { buffer: this.midiBuffer } },
4488
4571
  { binding: 6, resource: { buffer: this.lyricsBuffer } },
4572
+ ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
4489
4573
  ],
4490
4574
  }),
4491
4575
  mirrorBind: this.device.createBindGroup({
@@ -4497,6 +4581,7 @@ export class Engine {
4497
4581
  { binding: 4, resource: { buffer: this.audioBuffer } },
4498
4582
  { binding: 5, resource: { buffer: this.midiBuffer } },
4499
4583
  { binding: 6, resource: { buffer: this.lyricsBuffer } },
4584
+ ...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
4500
4585
  ],
4501
4586
  }),
4502
4587
  },
@@ -4694,6 +4779,8 @@ export class Engine {
4694
4779
  * engine: the builders run BEFORE the swap, so this.anchorTable still
4695
4780
  * describes the effect that is still on screen. */
4696
4781
  alias: number[],
4782
+ /** The effect's declared dials, spliced and bound above the grid's own. */
4783
+ params: EffectParamsBinding,
4697
4784
  ): Promise<{ ok: true; state: EffectGrid } | { ok: false; diagnostics: string[] }> {
4698
4785
  const size = Math.min(d.grid || 256, GRID_MAX)
4699
4786
  const cast = {
@@ -4705,7 +4792,7 @@ export class Engine {
4705
4792
  trailCount: anchors.filter((x) => x.trail).length,
4706
4793
  alias,
4707
4794
  }
4708
- const code = buildSimShader(wgsl, size, cast)
4795
+ const code = buildSimShader(wgsl, size, cast, params.wgsl(EFFECT_PARAMS_BINDING_GRID))
4709
4796
  const offset = code.slice(0, code.indexOf(wgsl)).split("\n").length - 1
4710
4797
  this.device.pushErrorScope("validation")
4711
4798
  const module = this.device.createShaderModule({ label: "grid step", code })
@@ -4733,6 +4820,9 @@ export class Engine {
4733
4820
  { binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
4734
4821
  { binding: 7, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
4735
4822
  { binding: 8, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
4823
+ ...(params.buffer
4824
+ ? [{ binding: EFFECT_PARAMS_BINDING_GRID, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" as const } }]
4825
+ : []),
4736
4826
  ],
4737
4827
  })
4738
4828
 
@@ -4766,7 +4856,7 @@ export class Engine {
4766
4856
  return { ok: false, diagnostics: [scoped.message] }
4767
4857
  }
4768
4858
  // One per parity: binds[i] READS textures[i] and WRITES the other.
4769
- const bindFor = (i: number) => this.gridBindGroup({ layout, uniform, read, textures }, i)
4859
+ const bindFor = (i: number) => this.gridBindGroup({ layout, uniform, read, textures, params: params.buffer }, i)
4770
4860
  return {
4771
4861
  ok: true,
4772
4862
  state: {
@@ -4780,6 +4870,7 @@ export class Engine {
4780
4870
  data: new Float32Array(4),
4781
4871
  parity: 0,
4782
4872
  frame: 0,
4873
+ params: params.buffer,
4783
4874
  },
4784
4875
  }
4785
4876
  } catch (e) {
@@ -7604,17 +7695,23 @@ export class Engine {
7604
7695
  /** Bind camera orbit center to a model's bone (Souls-style follow cam). Pass null to unbind. */
7605
7696
  setCameraTarget(model: Model | null, boneName: string, offset?: Vec3): void
7606
7697
  setCameraTarget(modelOrVec: Model | Vec3 | null, boneName?: string, offset?: Vec3): void {
7698
+ // panLocked tracks the BINDING, on every path that changes it — a target
7699
+ // set to a fixed point is pannable again, and forgetting it here is how the
7700
+ // pan stays dead after the follow was turned off. See Camera.panLocked.
7607
7701
  if (modelOrVec === null) {
7702
+ this.camera.panLocked = false
7608
7703
  this.cameraTargetModel = null
7609
7704
  return
7610
7705
  }
7611
7706
  if ("x" in modelOrVec && "y" in modelOrVec && "z" in modelOrVec) {
7707
+ this.camera.panLocked = false
7612
7708
  this.cameraTargetModel = null
7613
7709
  this.camera.target.x = modelOrVec.x
7614
7710
  this.camera.target.y = modelOrVec.y
7615
7711
  this.camera.target.z = modelOrVec.z
7616
7712
  return
7617
7713
  }
7714
+ this.camera.panLocked = true
7618
7715
  this.cameraTargetModel = modelOrVec
7619
7716
  this.cameraTargetBoneName = boneName ?? ""
7620
7717
  this.cameraTargetOffset.x = offset?.x ?? 0
@@ -7624,6 +7721,9 @@ export class Engine {
7624
7721
 
7625
7722
  /** Souls-style follow cam: orbit center tracks a model bone each frame. Shorthand for setCameraTarget(model, boneName, offset). */
7626
7723
  setCameraFollow(model: Model | null, boneName?: string, offset?: Vec3, smoothing?: number): void {
7724
+ // Panning sets the very thing the follow overwrites each frame, so it is
7725
+ // refused for as long as the shot is riding a bone. See Camera.panLocked.
7726
+ this.camera.panLocked = model !== null
7627
7727
  if (model === null) {
7628
7728
  this.cameraTargetModel = null
7629
7729
  return
@@ -9397,6 +9497,59 @@ export class Engine {
9397
9497
  else inst.hiddenMaterials.add(materialName)
9398
9498
  }
9399
9499
 
9500
+ /**
9501
+ * Push a colour/shading edit straight into a material's own uniform buffer —
9502
+ * the same block createMaterialUniformBuffer wrote at load, offset for
9503
+ * offset. A single write per call, on the fields that actually moved, so
9504
+ * dragging one slider does not touch the other eleven.
9505
+ *
9506
+ * UNGROUPED materials only. A grouped material renders through its style
9507
+ * group's own compiled graph (setupPipelines' neutral/DEFAULT_GRAPH path is
9508
+ * what reads this buffer, and a grouped material never runs it) — the call
9509
+ * still writes the bytes, they are simply never sampled, which would look
9510
+ * like the edit silently failing. Callers check groupsByModel first and this
9511
+ * quietly no-ops rather than assume that check was made, since a document
9512
+ * edit landing to the WRONG channel (the group's own colour input) would be
9513
+ * a worse failure than one that does nothing.
9514
+ *
9515
+ * Structural fields — anything that changes which draw bucket a material is
9516
+ * in, or which texture it binds — are NOT here: alpha crossing the 1.0
9517
+ * opaque/transparent line, edge on/off, a texture swap, all need the draw
9518
+ * list or the bind group rebuilt, not a uniform write. Those get their own
9519
+ * call when something needs them.
9520
+ */
9521
+ setMaterialUniforms(
9522
+ modelName: string,
9523
+ materialName: string,
9524
+ patch: {
9525
+ diffuse?: readonly [number, number, number, number]
9526
+ specular?: readonly [number, number, number]
9527
+ specularPower?: number
9528
+ ambient?: readonly [number, number, number]
9529
+ },
9530
+ ): boolean {
9531
+ const inst = this.modelInstances.get(modelName)
9532
+ if (!inst) return false
9533
+ const materials = inst.model.getMaterials()
9534
+ const index = materials.findIndex((m) => m.name === materialName)
9535
+ if (index < 0) return false
9536
+ const buffer = inst.materialUniformBuffers[index]
9537
+ if (!buffer) return false
9538
+ if (patch.diffuse) {
9539
+ this.device.queue.writeBuffer(buffer, 0, new Float32Array(patch.diffuse))
9540
+ }
9541
+ if (patch.ambient) {
9542
+ this.device.queue.writeBuffer(buffer, 16, new Float32Array(patch.ambient))
9543
+ }
9544
+ if (patch.specularPower !== undefined) {
9545
+ this.device.queue.writeBuffer(buffer, 28, new Float32Array([patch.specularPower]))
9546
+ }
9547
+ if (patch.specular) {
9548
+ this.device.queue.writeBuffer(buffer, 32, new Float32Array(patch.specular))
9549
+ }
9550
+ return true
9551
+ }
9552
+
9400
9553
  isMaterialVisible(modelName: string, materialName: string): boolean {
9401
9554
  const inst = this.modelInstances.get(modelName)
9402
9555
  return inst ? !inst.hiddenMaterials.has(materialName) : false
package/src/index.ts CHANGED
@@ -184,3 +184,17 @@ export {
184
184
  type RGBA,
185
185
  type Vec3Tuple,
186
186
  } from "./overlay"
187
+
188
+ /**
189
+ * The WGSL an effect's own source is compiled against — the hashes, the noise,
190
+ * the falloff, and the Particle struct the particle trio is written in terms of.
191
+ *
192
+ * Exported because reze-design renders LIBRARY PREVIEWS by hosting an effect's
193
+ * source itself, and a preview that reimplements this drifts from the renderer
194
+ * it is previewing. `rzHash13` meaning one thing on a card and another in the
195
+ * scene is worse than no card at all — and a preview missing them entirely just
196
+ * fails to compile, which is a blank card with nothing said anywhere.
197
+ *
198
+ * Pure strings, no GPU: importing them costs a host nothing.
199
+ */
200
+ export { EFFECT_MATH_API, PARTICLE_STRUCT_WGSL } from "./shaders/passes/hosted-api"
@@ -127,6 +127,7 @@ export function buildLightEmitShader(
127
127
  wgsl: string,
128
128
  sceneApi: string,
129
129
  cast: { trailCount: number },
130
+ paramsDecl = "",
130
131
  ): string {
131
132
  return /* wgsl */ `
132
133
  // read_write HERE and read-only in the material shaders. Different passes, so
@@ -164,6 +165,10 @@ ${trailSlotsApi(cast.trailCount)}
164
165
  // The id accessors, stubbed: this module cannot read an attachment the
165
166
  // scene pass writes. See id-api.ts — the author's whole file compiles here.
166
167
  ${idApi(false, 0, 0) + castDistanceStub()}
168
+ // The dials the author declared, if any. A lamp is exactly the thing someone
169
+ // retunes — its colour and its reach — so an emitter reads params like every
170
+ // other mount rather than being the one place a #param resolves to nothing.
171
+ ${paramsDecl}
167
172
  ${wgsl}
168
173
 
169
174
  @compute @workgroup_size(64)
@@ -107,9 +107,12 @@ struct SimU {
107
107
  * from", and that place is almost never a texel centre. Point-sampling it is
108
108
  * what turns smoke into a staircase.
109
109
  */
110
- export function buildSimShader(wgsl: string, size: number, cast: CastLayout): string {
110
+ export function buildSimShader(wgsl: string, size: number, cast: CastLayout, paramsDecl = ""): string {
111
111
  return (
112
112
  SIM_UNIFORMS +
113
+ // The effect's declared dials, ahead of everything that reads them. Empty
114
+ // when it declared none.
115
+ paramsDecl +
113
116
  /* wgsl */ `
114
117
  @group(0) @binding(0) var<uniform> su: SimU;
115
118
  @group(0) @binding(1) var _rzGridPrevTex: texture_2d<f32>;
@@ -83,6 +83,10 @@ type ParticleSource = {
83
83
  blend: ParticleBlend
84
84
  /** Feed the bloom pyramid. Off by default — rain should not glow. */
85
85
  bloom: boolean
86
+ /** The effect's `#param` block, or empty. Spliced into BOTH stages: a spawn
87
+ * rule reads the same dial the shading does, and rain's fall speed is used
88
+ * in the compute half. */
89
+ paramsDecl: string
86
90
  }
87
91
 
88
92
  /** Bytes per particle. Explicitly padded — see the struct below. */
@@ -165,6 +169,7 @@ export function buildParticleComputeShader(src: ParticleSource, cast: CastLayout
165
169
  CAMERA_STRUCT +
166
170
  PARTICLE_UNIFORMS +
167
171
  `
172
+ ${src.paramsDecl}
168
173
  @group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
169
174
  @group(0) @binding(1) var<uniform> pu: ParticleU;
170
175
  @group(0) @binding(2) var<uniform> cam: CameraU;
@@ -227,6 +232,7 @@ override ADDITIVE: bool = ${src.blend === "additive" ? "true" : "false"};\n` +
227
232
  CAMERA_STRUCT +
228
233
  PARTICLE_UNIFORMS +
229
234
  `
235
+ ${src.paramsDecl}
230
236
  @group(0) @binding(0) var<storage, read> particles: array<Particle>;
231
237
  @group(0) @binding(1) var<uniform> pu: ParticleU;
232
238
  @group(0) @binding(2) var<uniform> cam: CameraU;
@@ -33,6 +33,8 @@ type TrailSource = {
33
33
  /** For each ribbon, the LOCAL anchor slot it belongs to. Identity when every
34
34
  * anchor is trailed; otherwise it skips the untrailed ones. */
35
35
  ribbonSlots: number[]
36
+ /** The effect's `#param` block, or empty. */
37
+ paramsDecl: string
36
38
  /** Additive, like most glowing ribbons, or straight alpha. */
37
39
  blend: "alpha" | "additive"
38
40
  bloom: boolean
@@ -138,6 +140,7 @@ struct TrailU {
138
140
  weight: f32,
139
141
  _pad2: f32,
140
142
  }
143
+ ${src.paramsDecl}
141
144
  @group(0) @binding(0) var<storage, read> _rzCast: array<vec4f>;
142
145
  @group(0) @binding(1) var<uniform> tu: TrailU;
143
146
  @group(0) @binding(2) var<uniform> cam: CameraU;