reze-engine 0.55.9 → 0.55.11

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
@@ -1523,10 +1523,16 @@ interface EffectInstance {
1523
1523
  * for the same reason `layout` is: `bind` is rebuilt on a buffer swap. */
1524
1524
  params: GPUBuffer | null
1525
1525
  } | null
1526
- /** The field mount's pipeline and its two parity bind groups, or null when the
1527
- * effect declares neither background nor foreground. */
1526
+ /** The field mount's pipeline and its bind groups, or null when the effect
1527
+ * declares neither background nor foreground. */
1528
1528
  fieldPipeline: GPURenderPipeline | null
1529
- fieldBindGroups: [GPUBindGroup, GPUBindGroup] | null
1529
+ /** Two for a plain effect, one per grid parity. Eight for a filter — which
1530
+ * page it reads, whether it absorbs the half pair, grid parity — in
1531
+ * Engine.filterBindIndex order. */
1532
+ fieldBindGroups: GPUBindGroup[] | null
1533
+ /** Calls rzSceneFrame: runs after every other field effect, at full
1534
+ * resolution, reading their layers and carrying them forward. */
1535
+ filter: boolean
1530
1536
  /** Which resolution pair this effect draws into: 0 full, 1 half. Its own
1531
1537
  * declaration, not the scene's — see Engine.FIELD_SCALES. */
1532
1538
  fieldLayer: number
@@ -1932,6 +1938,13 @@ export class Engine {
1932
1938
  private fieldBgViews: (GPUTextureView | null)[] = [null, null]
1933
1939
  private fieldFgTextures: (GPUTexture | null)[] = [null, null]
1934
1940
  private fieldFgViews: (GPUTextureView | null)[] = [null, null]
1941
+ /** The filter's page: a second full-res pair, so a filter can read the pair
1942
+ * the other effects drew into while it draws. Held only while a filter is
1943
+ * installed — see ensureFilterPage. */
1944
+ private fieldPageBgTexture: GPUTexture | null = null
1945
+ private fieldPageFgTexture: GPUTexture | null = null
1946
+ private fieldPageBgView: GPUTextureView | null = null
1947
+ private fieldPageFgView: GPUTextureView | null = null
1935
1948
  /** One per scale: the field shader reconstructs the full-res pixel it stands
1936
1949
  * in for, so each pass needs its own (w, h, fullW, fullH). */
1937
1950
  private fieldUniformBuffers: GPUBuffer[] = []
@@ -3240,6 +3253,55 @@ export class Engine {
3240
3253
  new Float32Array([w, h, this.fieldFullW, this.fieldFullH]),
3241
3254
  )
3242
3255
  }
3256
+ this.ensureFilterPage()
3257
+ }
3258
+
3259
+ /**
3260
+ * The filter's page, held only while a filter is installed.
3261
+ *
3262
+ * A full-res rgba16f pair is real memory — two 16MB targets at 1080p, four
3263
+ * times that at 4K — and most scenes never install a filter. Created here
3264
+ * and on resize, released the moment the last filter goes. Sized off the
3265
+ * full pair, which is what a filter reads and what the composite reads back.
3266
+ */
3267
+ private ensureFilterPage(): void {
3268
+ const wanted = !!this.device && this.fieldFullW > 0 && this.effects.some((e) => e.filter)
3269
+ const w = Math.max(1, this.fieldFullW)
3270
+ const h = Math.max(1, this.fieldFullH)
3271
+ if (wanted && this.fieldPageBgTexture?.width === w && this.fieldPageBgTexture.height === h) return
3272
+ this.fieldPageBgTexture?.destroy()
3273
+ this.fieldPageFgTexture?.destroy()
3274
+ this.fieldPageBgTexture = null
3275
+ this.fieldPageFgTexture = null
3276
+ this.fieldPageBgView = null
3277
+ this.fieldPageFgView = null
3278
+ if (!wanted) return
3279
+ const make = (label: string) =>
3280
+ this.device.createTexture({
3281
+ label,
3282
+ size: [w, h],
3283
+ format: "rgba16float",
3284
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
3285
+ })
3286
+ this.fieldPageBgTexture = make("field layer page (background)")
3287
+ this.fieldPageFgTexture = make("field layer page (foreground)")
3288
+ this.fieldPageBgView = this.fieldPageBgTexture.createView()
3289
+ this.fieldPageFgView = this.fieldPageFgTexture.createView()
3290
+ }
3291
+
3292
+ /** Filters drawn this frame — the predicate renderFieldPass runs them by,
3293
+ * shared with the composite uniform that says the half pair was consumed. */
3294
+ private filtersDrawn(): number {
3295
+ if (!this.fieldPageBgView || !this.fieldPageFgView) return 0
3296
+ let n = 0
3297
+ for (const e of this.effects) if (e.filter && e.fieldPipeline && e.fieldBindGroups && e.weight > 0) n++
3298
+ return n
3299
+ }
3300
+
3301
+ /** Which of a filter's eight bind groups: the page it reads (0 the plain
3302
+ * pair, 1 the filter page), whether it absorbs the half pair, grid parity. */
3303
+ private static filterBindIndex(readPage: 0 | 1, absorbHalf: boolean, gridParity: number): number {
3304
+ return (readPage * 2 + (absorbHalf ? 1 : 0)) * 2 + gridParity
3243
3305
  }
3244
3306
 
3245
3307
  /**
@@ -3255,7 +3317,11 @@ export class Engine {
3255
3317
  // Captured, so the null guards above survive into the closure.
3256
3318
  const depth = this.depthReadView
3257
3319
  const bloom = this.compositeBloomView
3258
- const build = (owner: EffectInstance, grid: GPUTextureView) =>
3320
+ const build = (
3321
+ owner: EffectInstance,
3322
+ grid: GPUTextureView,
3323
+ layers: readonly [GPUTextureView, GPUTextureView, GPUTextureView, GPUTextureView],
3324
+ ) =>
3259
3325
  this.device.createBindGroup({
3260
3326
  label: "field layer bind group",
3261
3327
  layout: this.fieldBindGroupLayout,
@@ -3278,6 +3344,11 @@ export class Engine {
3278
3344
  { binding: 26, resource: this.castDistView ?? this.castDistFallbackView! },
3279
3345
  { binding: 27, resource: this.hdrResolveTexture.createView() },
3280
3346
  { binding: 28, resource: this.simSampler },
3347
+ // The other effects' layers, for rzSceneFrame — see the loop below.
3348
+ { binding: 29, resource: layers[0] },
3349
+ { binding: 30, resource: layers[1] },
3350
+ { binding: 31, resource: layers[2] },
3351
+ { binding: 32, resource: layers[3] },
3281
3352
  { binding: 2, resource: this.bloomSampler },
3282
3353
  { binding: 5, resource: this.filmicLutView },
3283
3354
  { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
@@ -3287,10 +3358,27 @@ export class Engine {
3287
3358
  })
3288
3359
  // Per effect: the params buffer and the grid are both its own, so two
3289
3360
  // effects cannot share a bind group even when everything else matches.
3361
+ // What a filter reads, by variant. A plain effect draws into the pair it
3362
+ // would otherwise read, so it gets the transparent 1x1 at every layer slot
3363
+ // and rzSceneFrame answers the scene alone.
3364
+ const none = this.trailFallbackView
3365
+ const pairA: [GPUTextureView, GPUTextureView] = [this.fieldBgViews[0] ?? none, this.fieldFgViews[0] ?? none]
3366
+ const page: [GPUTextureView, GPUTextureView] = [this.fieldPageBgView ?? none, this.fieldPageFgView ?? none]
3367
+ const half: [GPUTextureView, GPUTextureView] = [this.fieldBgViews[1] ?? none, this.fieldFgViews[1] ?? none]
3290
3368
  for (const e of this.effects) {
3291
- e.fieldBindGroups = e.grid
3292
- ? [build(e, e.grid.read[0]), build(e, e.grid.read[1])]
3293
- : [build(e, this.simFallbackView), build(e, this.simFallbackView)]
3369
+ const grids = e.grid ? [e.grid.read[0], e.grid.read[1]] : [this.simFallbackView, this.simFallbackView]
3370
+ if (!e.filter) {
3371
+ e.fieldBindGroups = grids.map((g) => build(e, g, [none, none, none, none]))
3372
+ continue
3373
+ }
3374
+ // Eight, in filterBindIndex order: read page × absorbs half × grid parity.
3375
+ const groups: GPUBindGroup[] = []
3376
+ for (const read of [pairA, page]) {
3377
+ for (const h of [[none, none], half] as [GPUTextureView, GPUTextureView][]) {
3378
+ for (const g of grids) groups.push(build(e, g, [read[0], read[1], h[0], h[1]]))
3379
+ }
3380
+ }
3381
+ e.fieldBindGroups = groups
3294
3382
  }
3295
3383
  }
3296
3384
 
@@ -3710,8 +3798,14 @@ export class Engine {
3710
3798
  const gridSize = gridEntryPoint(wgsl) ? Math.min(d.grid || 256, GRID_MAX) : 0
3711
3799
  // `alias` goes in: a field effect reads bones through _rzSlot exactly as a
3712
3800
  // particle one does, and it was the only module never handed the mapping.
3801
+ // A FILTER by what it calls, like every other mount property. rzSceneFrame
3802
+ // answers the frame with the other effects' layers in it, which is only
3803
+ // possible from a pass that runs after theirs — see renderFieldPass.
3804
+ const filter = (hasBackground || hasForeground) && /\brzSceneFrame\s*\(/.test(wgsl)
3713
3805
  const fieldEffect =
3714
- hasBackground || hasForeground ? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length } : null
3806
+ hasBackground || hasForeground
3807
+ ? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length, filter, additiveLayer: d.additiveLayer }
3808
+ : null
3715
3809
  const source = buildCompositeShader(fieldEffect)
3716
3810
  this.device.pushErrorScope("validation")
3717
3811
  const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
@@ -3752,10 +3846,15 @@ export class Engine {
3752
3846
  // the premultiplication happens here, and the composite reads it
3753
3847
  // back knowing that. With one effect over a cleared target the
3754
3848
  // result is identical to the replace it used to do.
3755
- targets: [
3756
- { format: "rgba16float", blend: layerBlend },
3757
- { format: "rgba16float", blend: layerBlend },
3758
- ],
3849
+ // A filter composes over the layers in its own shader and writes
3850
+ // the result outright: blending would layer it onto a page that
3851
+ // holds nothing.
3852
+ targets: filter
3853
+ ? [{ format: "rgba16float" }, { format: "rgba16float" }]
3854
+ : [
3855
+ { format: "rgba16float", blend: layerBlend },
3856
+ { format: "rgba16float", blend: layerBlend },
3857
+ ],
3759
3858
  },
3760
3859
  primitive: { topology: "triangle-list" },
3761
3860
  multisample: { count: 1 },
@@ -3937,7 +4036,10 @@ export class Engine {
3937
4036
  // against 4.5ms). It is the right call for a soft additive glow, which
3938
4037
  // upsamples invisibly — and it is now a claim an author makes about
3939
4038
  // their own effect rather than a fate that befalls one.
3940
- fieldLayer: d.fieldLayer,
4039
+ // A filter holds every other effect's pixels, so it holds them at full
4040
+ // resolution whatever it declared for itself.
4041
+ fieldLayer: filter ? 0 : d.fieldLayer,
4042
+ filter,
3941
4043
  fieldPipeline,
3942
4044
  fieldClock,
3943
4045
  // Filled by rebuildFieldBindGroup below, which needs the instance to
@@ -3979,7 +4081,12 @@ export class Engine {
3979
4081
  list: { wgsl: string; params?: Record<string, EffectParamValue> }[] | null,
3980
4082
  ): Promise<EffectResult[]> {
3981
4083
  const noMounts = { background: false, foreground: false }
3982
- if (!this.device) return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0 }]
4084
+ // Clearing an engine that has nothing yet is already done; only an install
4085
+ // needs the device.
4086
+ if (!this.device) {
4087
+ if (!list || list.length === 0) return []
4088
+ return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0 }]
4089
+ }
3983
4090
 
3984
4091
  const requested = list ?? []
3985
4092
  if (requested.length === 0) {
@@ -4113,8 +4220,14 @@ export class Engine {
4113
4220
  // fieldLayer when the instance is built, and both target pairs exist for
4114
4221
  // the life of the surface. What used to be a scene-wide decision made here
4115
4222
  // is now each effect's own.
4116
- this.rebuildFieldBindGroup()
4117
- this.rebuildCompositeBindGroup()
4223
+ this.ensureFilterPage()
4224
+ // EVERY shared buffer, not only the field and composite groups. The mounts
4225
+ // above were built across awaits, and audio, a score or lyrics landing in
4226
+ // one of those gaps replaced its buffer and destroyed the old one — which
4227
+ // the new particle, light, trail and grid groups had been built against,
4228
+ // so the next submit named a destroyed buffer. Rebinding at install closes
4229
+ // the gap from whichever side the asset arrived.
4230
+ this.rebindSharedBuffers()
4118
4231
  this.writeCompositeViewUniforms()
4119
4232
  return results
4120
4233
  }
@@ -4736,17 +4849,31 @@ export class Engine {
4736
4849
  // installed effect names rzCastDistance, which is the common case.
4737
4850
  this.encodeCastDistance(encoder)
4738
4851
 
4852
+ // FILTERS RUN LAST, each in a pass of its own, because each reads the pair
4853
+ // the others drew into — a texture no pass may sample while drawing into
4854
+ // it. So the full-res pair is two, the plain pair A and the filter's page,
4855
+ // and the passes alternate between them. The composite reads A and is not
4856
+ // rebuilt per frame, so the chain is arranged to END on A: with an odd
4857
+ // number of filters the plain effects draw into the page instead, and the
4858
+ // last filter lands where the composite looks. The half pair is read by
4859
+ // the first filter and consumed — viewU[5].w tells the composite so.
4860
+ const filters = drawn.filter((e) => e.filter)
4861
+ const pageA: [GPUTextureView | null, GPUTextureView | null] = [this.fieldBgViews[0], this.fieldFgViews[0]]
4862
+ const pageB: [GPUTextureView | null, GPUTextureView | null] = [this.fieldPageBgView, this.fieldPageFgView]
4863
+ const canFilter = filters.length > 0 && !!pageB[0] && !!pageB[1]
4864
+ const plainFull = canFilter && filters.length % 2 === 1 ? pageB : pageA
4865
+ const attach = (views: [GPUTextureView | null, GPUTextureView | null]): GPURenderPassColorAttachment[] => [
4866
+ { view: views[0]!, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
4867
+ { view: views[1]!, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
4868
+ ]
4739
4869
  let stamped = false
4740
4870
  for (let i = 0; i < Engine.FIELD_SCALES.length; i++) {
4741
- const bg = this.fieldBgViews[i]
4742
- const fg = this.fieldFgViews[i]
4743
- if (!bg || !fg || !this.fieldPairUsed(i)) continue
4871
+ const target: [GPUTextureView | null, GPUTextureView | null] =
4872
+ i === 0 ? plainFull : [this.fieldBgViews[i], this.fieldFgViews[i]]
4873
+ if (!target[0] || !target[1] || !this.fieldPairUsed(i)) continue
4744
4874
  const pass = encoder.beginRenderPass({
4745
4875
  label: `field layer (${Engine.FIELD_SCALES[i] === 1 ? "full" : "half"})`,
4746
- colorAttachments: [
4747
- { view: bg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
4748
- { view: fg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
4749
- ],
4876
+ colorAttachments: attach(target),
4750
4877
  // One query pair is reserved for "field", and it goes to the first pair
4751
4878
  // that actually runs — full res when something declared #fullres, half
4752
4879
  // otherwise. Pinning it to i === 0 would have measured a pass that, now
@@ -4755,7 +4882,7 @@ export class Engine {
4755
4882
  })
4756
4883
  stamped = true
4757
4884
  for (const e of drawn) {
4758
- if (e.fieldLayer !== i) continue
4885
+ if (e.fieldLayer !== i || e.filter) continue
4759
4886
  pass.setPipeline(e.fieldPipeline!)
4760
4887
  // The grid this effect just wrote — after its parity flip, the one at
4761
4888
  // `parity`. Per effect, so two grids never read each other's frame.
@@ -4764,6 +4891,23 @@ export class Engine {
4764
4891
  }
4765
4892
  pass.end()
4766
4893
  }
4894
+ if (!canFilter) return
4895
+ // Each filter reads what the previous pass left and writes the other page.
4896
+ // The half pair goes to the first one, and only when something drew into
4897
+ // it this frame — an unused pair is never cleared and holds stale pixels.
4898
+ let read = plainFull
4899
+ filters.forEach((e, k) => {
4900
+ const write = read === pageA ? pageB : pageA
4901
+ const pass = encoder.beginRenderPass({ label: "field layer (filter)", colorAttachments: attach(write) })
4902
+ pass.setPipeline(e.fieldPipeline!)
4903
+ pass.setBindGroup(
4904
+ 0,
4905
+ e.fieldBindGroups![Engine.filterBindIndex(read === pageA ? 0 : 1, k === 0 && this.fieldPairUsed(1), e.grid?.parity ?? 0)],
4906
+ )
4907
+ pass.draw(3)
4908
+ pass.end()
4909
+ read = write
4910
+ })
4767
4911
  }
4768
4912
 
4769
4913
  /** The trail bind group holds the depth view, which a resize recreates. */
@@ -5751,6 +5895,11 @@ export class Engine {
5751
5895
  // why it may not borrow the one at 18.
5752
5896
  { binding: 27, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5753
5897
  { binding: 28, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
5898
+ // The other effects' layers, for a filter's rzSceneFrame (scene-tap.ts).
5899
+ { binding: 29, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5900
+ { binding: 30, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5901
+ { binding: 31, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5902
+ { binding: 32, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
5754
5903
  // THE VIEW TRANSFORM'S OWN RESOURCES. viewTransform() lives in the
5755
5904
  // header both this module and the composite share, but its lookups did
5756
5905
  // not: an effect calling it compiled and then failed at pipeline
@@ -11897,10 +12046,12 @@ export class Engine {
11897
12046
  const inst = this.modelInstances.get(this.selectedMaterial.modelName)
11898
12047
  if (!inst) return
11899
12048
  const target = this.selectedMaterial.materialName
11900
- const draw = inst.drawCalls.find(
11901
- (d) => (d.type === "opaque" || d.type === "transparent") && d.materialName === target,
12049
+ // Every draw under the name — see materialIndexRanges for why there can be
12050
+ // more than one.
12051
+ const draws = inst.drawCalls.filter(
12052
+ (d) => (d.type === "opaque" || d.type === "transparent") && d.materialName === target && this.shouldRenderDrawCall(inst, d),
11902
12053
  )
11903
- if (!draw || !this.shouldRenderDrawCall(inst, draw)) return
12054
+ if (draws.length === 0) return
11904
12055
 
11905
12056
  // Mask pass: fill the selected material's projected footprint with 1.0. Depth-always
11906
12057
  // (no depth attachment) so the outline traces complete boundaries even when the
@@ -11913,7 +12064,7 @@ export class Engine {
11913
12064
  mpass.setVertexBuffer(1, inst.jointsBuffer)
11914
12065
  mpass.setVertexBuffer(2, inst.weightsBuffer)
11915
12066
  mpass.setIndexBuffer(inst.indexBuffer, "uint32")
11916
- mpass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
12067
+ for (const draw of draws) mpass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
11917
12068
  mpass.end()
11918
12069
 
11919
12070
  // Edge pass: screen-space edge detect on the mask, alpha-blended over swapchain.
@@ -11929,19 +12080,23 @@ export class Engine {
11929
12080
  // Unique edges of the mesh, as a line-list index buffer. Each interior edge is
11930
12081
  // shared by two triangles, so deduplicating halves both the buffer and the
11931
12082
  // draw. Built once per model, on the first frame its wireframe is asked for.
11932
- /** The index run `material` owns, or the whole list when it is null. Materials
11933
- * are consecutive runs in declaration order, so the offset is a prefix sum —
11934
- * the same walk the draw list does. Returns null for a name the model does
11935
- * not have, which is what a stale selection looks like after a reload. */
11936
- private materialIndexRange(inst: ModelInstance, material: string | null): [number, number] | null {
12083
+ /** The index runs `material` owns — EVERY run, because a PMX may carry
12084
+ * several materials under one name and a name is how the host asks; taking
12085
+ * the first left the rest of a hair or a coat out of its own wireframe —
12086
+ * or the whole list when it is null. Materials are consecutive runs in
12087
+ * declaration order, so each offset is a prefix sum, the same walk the draw
12088
+ * list does. Empty for a name the model does not have, which is what a
12089
+ * stale selection looks like after a reload. */
12090
+ private materialIndexRanges(inst: ModelInstance, material: string | null): [number, number][] {
11937
12091
  const indices = inst.model.getIndices()
11938
- if (!material) return [0, indices.length]
12092
+ if (!material) return [[0, indices.length]]
12093
+ const runs: [number, number][] = []
11939
12094
  let offset = 0
11940
12095
  for (const m of inst.model.getMaterials()) {
11941
- if (m.name === material) return [offset, offset + m.vertexCount]
12096
+ if (m.name === material) runs.push([offset, offset + m.vertexCount])
11942
12097
  offset += m.vertexCount
11943
12098
  }
11944
- return null
12099
+ return runs
11945
12100
  }
11946
12101
 
11947
12102
  /** A cache key no material can collide with — a PMX name is never empty and
@@ -11990,9 +12145,8 @@ export class Engine {
11990
12145
  offset = end
11991
12146
  }
11992
12147
  } else {
11993
- const range = this.materialIndexRange(inst, material)
11994
- if (!range) return false
11995
- const [start, end] = range
12148
+ const runs = this.materialIndexRanges(inst, material)
12149
+ if (runs.length === 0) return false
11996
12150
  const seen = new Set<number>()
11997
12151
  const add = (a: number, b: number) => {
11998
12152
  const lo = a < b ? a : b
@@ -12002,10 +12156,12 @@ export class Engine {
12002
12156
  seen.add(k)
12003
12157
  edges.push(lo, hi)
12004
12158
  }
12005
- for (let i = start; i + 2 < end; i += 3) {
12006
- add(indices[i], indices[i + 1])
12007
- add(indices[i + 1], indices[i + 2])
12008
- add(indices[i + 2], indices[i])
12159
+ for (const [start, end] of runs) {
12160
+ for (let i = start; i + 2 < end; i += 3) {
12161
+ add(indices[i], indices[i + 1])
12162
+ add(indices[i + 1], indices[i + 2])
12163
+ add(indices[i + 2], indices[i])
12164
+ }
12009
12165
  }
12010
12166
  }
12011
12167
  if (edges.length === 0) {
@@ -12058,8 +12214,8 @@ export class Engine {
12058
12214
  // picked, since only the picked material draws at all then.
12059
12215
  const hm = this.hoverMaterial
12060
12216
  const hoverName = material === null && hm?.modelName === inst.name ? hm.materialName : null
12061
- const hoverRange = hoverName ? this.materialIndexRange(inst, hoverName) : null
12062
- const hover = hoverRange && this.ensureEdgeBuffer(inst, hoverName) ? inst.wireEdges.get(hoverName!) : null
12217
+ const hoverRuns = hoverName ? this.materialIndexRanges(inst, hoverName) : []
12218
+ const hover = hoverRuns.length > 0 && this.ensureEdgeBuffer(inst, hoverName) ? inst.wireEdges.get(hoverName!) : null
12063
12219
 
12064
12220
  this.wireframeColorData.set(DEFAULT_VERTEX_COLOR)
12065
12221
  this.wireframeColorData[4] = this.canvas.width
@@ -12100,9 +12256,9 @@ export class Engine {
12100
12256
  // before the base mesh below gets a chance to occlude it. The base mesh's
12101
12257
  // depth write further down repeats the SAME geometry for these faces
12102
12258
  // (identical z), so it neither disturbs this nor needs to skip them.
12103
- if (hover && hoverRange && !xray) {
12259
+ if (hover && !xray) {
12104
12260
  bindMesh()
12105
- pass.drawIndexed(hoverRange[1] - hoverRange[0], 1, hoverRange[0])
12261
+ for (const [start, end] of hoverRuns) pass.drawIndexed(end - start, 1, start)
12106
12262
  pass.setBindGroup(0, this.wireframeHoverBindGroup)
12107
12263
  pass.setBindGroup(1, hover.bindGroup)
12108
12264
  pass.setPipeline(this.wireframePipeline)
@@ -12124,10 +12280,10 @@ export class Engine {
12124
12280
  // the coat hide it does not answer that. Its own depth still goes in, so its
12125
12281
  // back faces stay hidden and it reads as an object instead of a haze —
12126
12282
  // which is the difference between this and turning x-ray on.
12127
- const range = material !== null ? this.materialIndexRange(inst, material) : null
12283
+ const runs = material !== null ? this.materialIndexRanges(inst, material) : null
12128
12284
  if (!xray) {
12129
12285
  bindMesh()
12130
- if (range) pass.drawIndexed(range[1] - range[0], 1, range[0])
12286
+ if (runs) for (const [start, end] of runs) pass.drawIndexed(end - start, 1, start)
12131
12287
  else pass.drawIndexed(inst.model.getIndices().length)
12132
12288
  }
12133
12289
 
@@ -14251,7 +14407,9 @@ export class Engine {
14251
14407
  u[20] = v[2]
14252
14408
  u[21] = v[6]
14253
14409
  u[22] = v[10]
14254
- u[23] = 0
14410
+ // A drawn filter consumed the half pair (renderFieldPass); the composite
14411
+ // skips it rather than laying it down a second time.
14412
+ u[23] = this.filtersDrawn() > 0 ? 1 : 0
14255
14413
  // Effect clock + canvas size (viewU[6]) — written on the same refresh.
14256
14414
  // The effect clock, on the SCENE's time rather than the wall's.
14257
14415
  //
@@ -96,6 +96,11 @@ type CompositeEffectSource = {
96
96
  hasBackground: boolean
97
97
  /** Defines `fn foreground(...)` — mount over the finished frame. */
98
98
  hasForeground: boolean
99
+ /** Calls rzSceneFrame — a FILTER, which carries the layers it read forward
100
+ * (buildFieldShader) and so draws with blending off. */
101
+ filter?: boolean
102
+ /** `#layer additive`: how a filter's own output joins the layers it carries. */
103
+ additiveLayer?: boolean
99
104
  /** Grid resolution when the effect declared `#grid`, else 0. */
100
105
  gridSize: number
101
106
  /** Whether the scene pass carries the id attachment, so the field module can
@@ -146,7 +151,8 @@ override APPLY_GAMMA: bool = true;
146
151
  // and no on/off uniform either: the pipeline is REBUILT per effect, so
147
152
  // the compiled variant IS the flag.
148
153
  // viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
149
- // viewU[5] = (camera forward, _) — refreshed per frame while skybox/effect active.
154
+ // viewU[5] = (camera forward, half pair consumed by a filter this frame) — refreshed
155
+ // per frame while skybox/effect active.
150
156
  // viewU[6] = (time seconds, view transform id, canvas width, canvas height).
151
157
  // viewU[7] = (grade offset.rgb, contrast); viewU[8] = (grade power.rgb, saturation);
152
158
  // viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
@@ -605,7 +611,8 @@ const BACKGROUND_CALL = /* wgsl */ `
605
611
  // straight form it replaced.
606
612
  let bgFx = rzFieldMerge(
607
613
  clamp(textureSampleLevel(fieldBgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)),
608
- clamp(textureSampleLevel(fieldBgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)));
614
+ // The half pair, unless a filter consumed it this frame (viewU[5].w).
615
+ clamp(textureSampleLevel(fieldBgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)) * (1.0 - viewU[5].w));
609
616
  bgPm = bgFx.rgb + bgPm * (1.0 - bgFx.a);
610
617
  bgA = bgFx.a + bgA * (1.0 - bgFx.a);
611
618
  `
@@ -617,7 +624,7 @@ const FOREGROUND_CALL = /* wgsl */ `
617
624
  // Premultiplied, as the background layer above — same reason.
618
625
  let fgFx = rzFieldMerge(
619
626
  clamp(textureSampleLevel(fieldFgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)),
620
- clamp(textureSampleLevel(fieldFgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)));
627
+ clamp(textureSampleLevel(fieldFgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)) * (1.0 - viewU[5].w));
621
628
  outRgb = fgFx.rgb + outRgb * (1.0 - fgFx.a);
622
629
  outA = fgFx.a + outA * (1.0 - fgFx.a);
623
630
  `
@@ -670,6 +677,16 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
670
677
  const fgLine = effect.hasForeground
671
678
  ? "out.fg = clamp(foreground(dir, uv, _rzFieldClock.x, linearDepth(vec2<i32>(min(fx, fullSz - 1.0)))), vec4f(0.0), vec4f(1.0));"
672
679
  : ""
680
+ // A FILTER read the other effects' layers through rzSceneFrame and now
681
+ // carries them: its own output goes over (or adds to) what they drew, and
682
+ // the pipeline writes the result with blending off. Weight has already
683
+ // scaled its alpha, so a filter faded to nothing passes the layers through
684
+ // untouched.
685
+ const carry = effect.filter
686
+ ? effect.additiveLayer
687
+ ? "out.bg = _rzCarryAdd(out.bg, _rzLayerBg(uv));\n out.fg = _rzCarryAdd(out.fg, _rzLayerFg(uv));"
688
+ : "out.bg = _rzCarryOver(out.bg, _rzLayerBg(uv));\n out.fg = _rzCarryOver(out.fg, _rzLayerFg(uv));"
689
+ : ""
673
690
  return (
674
691
  COMPOSITE_HEAD +
675
692
  EFFECT_SCENE_API +
@@ -692,7 +709,7 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
692
709
  // texel is CAST_FIELD_DIV of them and the accessor scales on the way out.
693
710
  // An author writes the width they mean and never learns how it is built.
694
711
  castDistanceApi(0, 26, 18, CAST_FIELD_DIV) +
695
- sceneTapApi(0, 27, 28) +
712
+ sceneTapApi(0, 27, 28, [29, 30, 31, 32]) +
696
713
  "\n// ── user effect (setEffect) ──\n" +
697
714
  effect.paramsDecl +
698
715
  "\n" +
@@ -711,6 +728,15 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
711
728
  */
712
729
  @group(0) @binding(22) var<uniform> _rzFieldClock: vec4f; // (time, weight, _, _)
713
730
 
731
+ /** A filter's own straight-alpha output over the premultiplied layers it read
732
+ * (the layer blend state, restated) and the additive form of the same. */
733
+ fn _rzCarryOver(own: vec4f, under: vec4f) -> vec4f {
734
+ return vec4f(own.rgb * own.a + under.rgb * (1.0 - own.a), own.a + under.a * (1.0 - own.a));
735
+ }
736
+ fn _rzCarryAdd(own: vec4f, under: vec4f) -> vec4f {
737
+ return vec4f(own.rgb * own.a + under.rgb, own.a + under.a);
738
+ }
739
+
714
740
  @vertex fn fieldVs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
715
741
  let x = f32((vi & 1u) << 2u) - 1.0;
716
742
  let y = f32((vi & 2u) << 1u) - 1.0;
@@ -740,6 +766,7 @@ struct FieldOut {
740
766
  // Scaling colour as well would fade as the square.
741
767
  out.bg.a *= _rzFieldClock.y;
742
768
  out.fg.a *= _rzFieldClock.y;
769
+ ${carry}
743
770
  return out;
744
771
  }
745
772
  `
@@ -44,10 +44,41 @@
44
44
  * Clamp-to-edge is what stops a warped offset wrapping the far side of the
45
45
  * screen into the picture.
46
46
  */
47
- export function sceneTapApi(group: number, tex: number, samp: number): string {
47
+ export function sceneTapApi(
48
+ group: number,
49
+ tex: number,
50
+ samp: number,
51
+ layers: readonly [number, number, number, number],
52
+ ): string {
48
53
  return /* wgsl */ `
49
54
  @group(${group}) @binding(${tex}) var _rzSceneTex: texture_2d<f32>;
50
55
  @group(${group}) @binding(${samp}) var _rzSceneSamp: sampler;
56
+ // The field layers as the other effects left them — see rzSceneFrame. Bound to
57
+ // the pair the others drew into for a filter, and to a 1x1 transparent for
58
+ // everything else: an effect cannot sample the pair it is drawing into.
59
+ @group(${group}) @binding(${layers[0]}) var _rzLayerBgFull: texture_2d<f32>;
60
+ @group(${group}) @binding(${layers[1]}) var _rzLayerFgFull: texture_2d<f32>;
61
+ @group(${group}) @binding(${layers[2]}) var _rzLayerBgHalf: texture_2d<f32>;
62
+ @group(${group}) @binding(${layers[3]}) var _rzLayerFgHalf: texture_2d<f32>;
63
+
64
+ /** Premultiplied OVER, top in front — the composite's rzFieldMerge, restated so
65
+ * this file keeps importing nothing. A resolution boundary is a layer
66
+ * boundary: full res over half, exactly as the composite reads them. */
67
+ fn _rzLayerOver(top: vec4f, bot: vec4f) -> vec4f {
68
+ return vec4f(top.rgb + bot.rgb * (1.0 - top.a), top.a + bot.a * (1.0 - top.a));
69
+ }
70
+ fn _rzLayerBg(uv: vec2f) -> vec4f {
71
+ let p = _rzTapUv(uv);
72
+ return _rzLayerOver(
73
+ clamp(textureSampleLevel(_rzLayerBgFull, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)),
74
+ clamp(textureSampleLevel(_rzLayerBgHalf, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)));
75
+ }
76
+ fn _rzLayerFg(uv: vec2f) -> vec4f {
77
+ let p = _rzTapUv(uv);
78
+ return _rzLayerOver(
79
+ clamp(textureSampleLevel(_rzLayerFgFull, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)),
80
+ clamp(textureSampleLevel(_rzLayerFgHalf, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)));
81
+ }
51
82
 
52
83
  /** uv as the mount gives it (y=0 at the BOTTOM, the shadertoy convention the
53
84
  * composite documents) turned into a texture coordinate (y=0 at the top).
@@ -176,25 +207,40 @@ fn rzSceneDisplay(uv: vec2f) -> vec3f {
176
207
  }
177
208
 
178
209
  /**
179
- * The finished pixel: the scene over its background, exactly as the composite
180
- * would lay it down. One call, and the answer already carries the ground's
181
- * fade, its opacity, and the background showing through both.
182
- *
183
- * This is what a FILTER wants — anything re-rendering the whole frame on a tube
184
- * or a low-resolution screen. Reaching for rzSceneDisplay and a depth test
185
- * instead rebuilds this by hand and gets it wrong in one specific way every
186
- * time: coverage is not a yes or no, and treating it as one turns every soft
187
- * edge in the scene into a hard one.
188
- *
189
- * NOT INCLUDED: depth of field, background effects, and an equirect background
190
- * (a 360 dome reads as its average rather than its texture). Foregrounds compose
191
- * after this in any case.
210
+ * The frame as it stands: the scene over its background, and both layers of
211
+ * every other field effect, laid down in the order the composite would lay
212
+ * them. One call, and the answer already carries the ground's fade, its
213
+ * opacity, the background showing through both, and whatever the other
214
+ * effects drew — a waveform, a caption, a glow.
215
+ *
216
+ * CALLING THIS MAKES THE EFFECT A FILTER. A filter runs after every other
217
+ * field effect, at full resolution, and what it returns replaces the layers it
218
+ * read: its own output is composed over them in its pass (buildFieldShader),
219
+ * so nothing drawn before it is lost and nothing is drawn twice. Two filters
220
+ * run in document order, each reading the one before. The layers are a
221
+ * pass-order fact — a filter reads the pair the others drew into while it
222
+ * draws into a page of its own (renderFieldPass).
223
+ *
224
+ * This is what a FILTER wants — anything re-rendering the whole frame on a
225
+ * tube, a low-resolution screen, or a card. Reaching for rzSceneDisplay and a
226
+ * depth test instead rebuilds this by hand and gets it wrong in one specific
227
+ * way every time: coverage is not a yes or no, and treating it as one turns
228
+ * every soft edge in the scene into a hard one.
229
+ *
230
+ * NOT INCLUDED: depth of field, and an equirect background (a 360 dome reads
231
+ * as its average rather than its texture).
192
232
  */
193
233
  fn rzSceneFrame(uv: vec2f) -> vec3f {
194
234
  let a = clamp(rzSceneAlpha(uv), 0.0, 1.0);
195
235
  let bg = viewU[2];
196
- let bgRgb = bg.rgb * select(0.0, 1.0, bg.w > 0.5);
197
- return rzSceneDisplay(uv) * a + bgRgb * (1.0 - a);
236
+ let bgA = select(0.0, 1.0, bg.w > 0.5);
237
+ // The background layer over the base, the scene over both, the foreground
238
+ // layer over the frame — the composite's own order.
239
+ let lb = _rzLayerBg(uv);
240
+ let under = lb.rgb + bg.rgb * bgA * (1.0 - lb.a);
241
+ let frame = rzSceneDisplay(uv) * a + under * (1.0 - a);
242
+ let lf = _rzLayerFg(uv);
243
+ return lf.rgb + frame * (1.0 - lf.a);
198
244
  }
199
245
 
200
246
  /**