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/dist/engine.js CHANGED
@@ -823,6 +823,13 @@ export class Engine {
823
823
  this.fieldBgViews = [null, null];
824
824
  this.fieldFgTextures = [null, null];
825
825
  this.fieldFgViews = [null, null];
826
+ /** The filter's page: a second full-res pair, so a filter can read the pair
827
+ * the other effects drew into while it draws. Held only while a filter is
828
+ * installed — see ensureFilterPage. */
829
+ this.fieldPageBgTexture = null;
830
+ this.fieldPageFgTexture = null;
831
+ this.fieldPageBgView = null;
832
+ this.fieldPageFgView = null;
826
833
  /** One per scale: the field shader reconstructs the full-res pixel it stands
827
834
  * in for, so each pass needs its own (w, h, fullW, fullH). */
828
835
  this.fieldUniformBuffers = [];
@@ -2169,6 +2176,56 @@ export class Engine {
2169
2176
  this.fieldFgViews[i] = this.fieldFgTextures[i].createView();
2170
2177
  this.device.queue.writeBuffer(this.fieldUniformBuffers[i], 0, new Float32Array([w, h, this.fieldFullW, this.fieldFullH]));
2171
2178
  }
2179
+ this.ensureFilterPage();
2180
+ }
2181
+ /**
2182
+ * The filter's page, held only while a filter is installed.
2183
+ *
2184
+ * A full-res rgba16f pair is real memory — two 16MB targets at 1080p, four
2185
+ * times that at 4K — and most scenes never install a filter. Created here
2186
+ * and on resize, released the moment the last filter goes. Sized off the
2187
+ * full pair, which is what a filter reads and what the composite reads back.
2188
+ */
2189
+ ensureFilterPage() {
2190
+ const wanted = !!this.device && this.fieldFullW > 0 && this.effects.some((e) => e.filter);
2191
+ const w = Math.max(1, this.fieldFullW);
2192
+ const h = Math.max(1, this.fieldFullH);
2193
+ if (wanted && this.fieldPageBgTexture?.width === w && this.fieldPageBgTexture.height === h)
2194
+ return;
2195
+ this.fieldPageBgTexture?.destroy();
2196
+ this.fieldPageFgTexture?.destroy();
2197
+ this.fieldPageBgTexture = null;
2198
+ this.fieldPageFgTexture = null;
2199
+ this.fieldPageBgView = null;
2200
+ this.fieldPageFgView = null;
2201
+ if (!wanted)
2202
+ return;
2203
+ const make = (label) => this.device.createTexture({
2204
+ label,
2205
+ size: [w, h],
2206
+ format: "rgba16float",
2207
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
2208
+ });
2209
+ this.fieldPageBgTexture = make("field layer page (background)");
2210
+ this.fieldPageFgTexture = make("field layer page (foreground)");
2211
+ this.fieldPageBgView = this.fieldPageBgTexture.createView();
2212
+ this.fieldPageFgView = this.fieldPageFgTexture.createView();
2213
+ }
2214
+ /** Filters drawn this frame — the predicate renderFieldPass runs them by,
2215
+ * shared with the composite uniform that says the half pair was consumed. */
2216
+ filtersDrawn() {
2217
+ if (!this.fieldPageBgView || !this.fieldPageFgView)
2218
+ return 0;
2219
+ let n = 0;
2220
+ for (const e of this.effects)
2221
+ if (e.filter && e.fieldPipeline && e.fieldBindGroups && e.weight > 0)
2222
+ n++;
2223
+ return n;
2224
+ }
2225
+ /** Which of a filter's eight bind groups: the page it reads (0 the plain
2226
+ * pair, 1 the filter page), whether it absorbs the half pair, grid parity. */
2227
+ static filterBindIndex(readPage, absorbHalf, gridParity) {
2228
+ return (readPage * 2 + (absorbHalf ? 1 : 0)) * 2 + gridParity;
2172
2229
  }
2173
2230
  /**
2174
2231
  * ONE PER GRID PARITY.
@@ -2184,7 +2241,7 @@ export class Engine {
2184
2241
  // Captured, so the null guards above survive into the closure.
2185
2242
  const depth = this.depthReadView;
2186
2243
  const bloom = this.compositeBloomView;
2187
- const build = (owner, grid) => this.device.createBindGroup({
2244
+ const build = (owner, grid, layers) => this.device.createBindGroup({
2188
2245
  label: "field layer bind group",
2189
2246
  layout: this.fieldBindGroupLayout,
2190
2247
  entries: [
@@ -2206,6 +2263,11 @@ export class Engine {
2206
2263
  { binding: 26, resource: this.castDistView ?? this.castDistFallbackView },
2207
2264
  { binding: 27, resource: this.hdrResolveTexture.createView() },
2208
2265
  { binding: 28, resource: this.simSampler },
2266
+ // The other effects' layers, for rzSceneFrame — see the loop below.
2267
+ { binding: 29, resource: layers[0] },
2268
+ { binding: 30, resource: layers[1] },
2269
+ { binding: 31, resource: layers[2] },
2270
+ { binding: 32, resource: layers[3] },
2209
2271
  { binding: 2, resource: this.bloomSampler },
2210
2272
  { binding: 5, resource: this.filmicLutView },
2211
2273
  { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
@@ -2215,10 +2277,28 @@ export class Engine {
2215
2277
  });
2216
2278
  // Per effect: the params buffer and the grid are both its own, so two
2217
2279
  // effects cannot share a bind group even when everything else matches.
2280
+ // What a filter reads, by variant. A plain effect draws into the pair it
2281
+ // would otherwise read, so it gets the transparent 1x1 at every layer slot
2282
+ // and rzSceneFrame answers the scene alone.
2283
+ const none = this.trailFallbackView;
2284
+ const pairA = [this.fieldBgViews[0] ?? none, this.fieldFgViews[0] ?? none];
2285
+ const page = [this.fieldPageBgView ?? none, this.fieldPageFgView ?? none];
2286
+ const half = [this.fieldBgViews[1] ?? none, this.fieldFgViews[1] ?? none];
2218
2287
  for (const e of this.effects) {
2219
- e.fieldBindGroups = e.grid
2220
- ? [build(e, e.grid.read[0]), build(e, e.grid.read[1])]
2221
- : [build(e, this.simFallbackView), build(e, this.simFallbackView)];
2288
+ const grids = e.grid ? [e.grid.read[0], e.grid.read[1]] : [this.simFallbackView, this.simFallbackView];
2289
+ if (!e.filter) {
2290
+ e.fieldBindGroups = grids.map((g) => build(e, g, [none, none, none, none]));
2291
+ continue;
2292
+ }
2293
+ // Eight, in filterBindIndex order: read page × absorbs half × grid parity.
2294
+ const groups = [];
2295
+ for (const read of [pairA, page]) {
2296
+ for (const h of [[none, none], half]) {
2297
+ for (const g of grids)
2298
+ groups.push(build(e, g, [read[0], read[1], h[0], h[1]]));
2299
+ }
2300
+ }
2301
+ e.fieldBindGroups = groups;
2222
2302
  }
2223
2303
  }
2224
2304
  /**
@@ -2615,7 +2695,13 @@ export class Engine {
2615
2695
  const gridSize = gridEntryPoint(wgsl) ? Math.min(d.grid || 256, GRID_MAX) : 0;
2616
2696
  // `alias` goes in: a field effect reads bones through _rzSlot exactly as a
2617
2697
  // particle one does, and it was the only module never handed the mapping.
2618
- const fieldEffect = hasBackground || hasForeground ? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length } : null;
2698
+ // A FILTER by what it calls, like every other mount property. rzSceneFrame
2699
+ // answers the frame with the other effects' layers in it, which is only
2700
+ // possible from a pass that runs after theirs — see renderFieldPass.
2701
+ const filter = (hasBackground || hasForeground) && /\brzSceneFrame\s*\(/.test(wgsl);
2702
+ const fieldEffect = hasBackground || hasForeground
2703
+ ? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length, filter, additiveLayer: d.additiveLayer }
2704
+ : null;
2619
2705
  const source = buildCompositeShader(fieldEffect);
2620
2706
  this.device.pushErrorScope("validation");
2621
2707
  const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source });
@@ -2658,10 +2744,15 @@ export class Engine {
2658
2744
  // the premultiplication happens here, and the composite reads it
2659
2745
  // back knowing that. With one effect over a cleared target the
2660
2746
  // result is identical to the replace it used to do.
2661
- targets: [
2662
- { format: "rgba16float", blend: layerBlend },
2663
- { format: "rgba16float", blend: layerBlend },
2664
- ],
2747
+ // A filter composes over the layers in its own shader and writes
2748
+ // the result outright: blending would layer it onto a page that
2749
+ // holds nothing.
2750
+ targets: filter
2751
+ ? [{ format: "rgba16float" }, { format: "rgba16float" }]
2752
+ : [
2753
+ { format: "rgba16float", blend: layerBlend },
2754
+ { format: "rgba16float", blend: layerBlend },
2755
+ ],
2665
2756
  },
2666
2757
  primitive: { topology: "triangle-list" },
2667
2758
  multisample: { count: 1 },
@@ -2842,7 +2933,10 @@ export class Engine {
2842
2933
  // against 4.5ms). It is the right call for a soft additive glow, which
2843
2934
  // upsamples invisibly — and it is now a claim an author makes about
2844
2935
  // their own effect rather than a fate that befalls one.
2845
- fieldLayer: d.fieldLayer,
2936
+ // A filter holds every other effect's pixels, so it holds them at full
2937
+ // resolution whatever it declared for itself.
2938
+ fieldLayer: filter ? 0 : d.fieldLayer,
2939
+ filter,
2846
2940
  fieldPipeline,
2847
2941
  fieldClock,
2848
2942
  // Filled by rebuildFieldBindGroup below, which needs the instance to
@@ -2881,8 +2975,13 @@ export class Engine {
2881
2975
  */
2882
2976
  async setEffects(list) {
2883
2977
  const noMounts = { background: false, foreground: false };
2884
- if (!this.device)
2978
+ // Clearing an engine that has nothing yet is already done; only an install
2979
+ // needs the device.
2980
+ if (!this.device) {
2981
+ if (!list || list.length === 0)
2982
+ return [];
2885
2983
  return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0 }];
2984
+ }
2886
2985
  const requested = list ?? [];
2887
2986
  if (requested.length === 0) {
2888
2987
  for (const e of this.effects) {
@@ -3001,8 +3100,14 @@ export class Engine {
3001
3100
  // fieldLayer when the instance is built, and both target pairs exist for
3002
3101
  // the life of the surface. What used to be a scene-wide decision made here
3003
3102
  // is now each effect's own.
3004
- this.rebuildFieldBindGroup();
3005
- this.rebuildCompositeBindGroup();
3103
+ this.ensureFilterPage();
3104
+ // EVERY shared buffer, not only the field and composite groups. The mounts
3105
+ // above were built across awaits, and audio, a score or lyrics landing in
3106
+ // one of those gaps replaced its buffer and destroyed the old one — which
3107
+ // the new particle, light, trail and grid groups had been built against,
3108
+ // so the next submit named a destroyed buffer. Rebinding at install closes
3109
+ // the gap from whichever side the asset arrived.
3110
+ this.rebindSharedBuffers();
3006
3111
  this.writeCompositeViewUniforms();
3007
3112
  return results;
3008
3113
  }
@@ -3602,18 +3707,31 @@ export class Engine {
3602
3707
  // The distance field, before anything reads it. Returns immediately when no
3603
3708
  // installed effect names rzCastDistance, which is the common case.
3604
3709
  this.encodeCastDistance(encoder);
3710
+ // FILTERS RUN LAST, each in a pass of its own, because each reads the pair
3711
+ // the others drew into — a texture no pass may sample while drawing into
3712
+ // it. So the full-res pair is two, the plain pair A and the filter's page,
3713
+ // and the passes alternate between them. The composite reads A and is not
3714
+ // rebuilt per frame, so the chain is arranged to END on A: with an odd
3715
+ // number of filters the plain effects draw into the page instead, and the
3716
+ // last filter lands where the composite looks. The half pair is read by
3717
+ // the first filter and consumed — viewU[5].w tells the composite so.
3718
+ const filters = drawn.filter((e) => e.filter);
3719
+ const pageA = [this.fieldBgViews[0], this.fieldFgViews[0]];
3720
+ const pageB = [this.fieldPageBgView, this.fieldPageFgView];
3721
+ const canFilter = filters.length > 0 && !!pageB[0] && !!pageB[1];
3722
+ const plainFull = canFilter && filters.length % 2 === 1 ? pageB : pageA;
3723
+ const attach = (views) => [
3724
+ { view: views[0], clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
3725
+ { view: views[1], clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
3726
+ ];
3605
3727
  let stamped = false;
3606
3728
  for (let i = 0; i < Engine.FIELD_SCALES.length; i++) {
3607
- const bg = this.fieldBgViews[i];
3608
- const fg = this.fieldFgViews[i];
3609
- if (!bg || !fg || !this.fieldPairUsed(i))
3729
+ const target = i === 0 ? plainFull : [this.fieldBgViews[i], this.fieldFgViews[i]];
3730
+ if (!target[0] || !target[1] || !this.fieldPairUsed(i))
3610
3731
  continue;
3611
3732
  const pass = encoder.beginRenderPass({
3612
3733
  label: `field layer (${Engine.FIELD_SCALES[i] === 1 ? "full" : "half"})`,
3613
- colorAttachments: [
3614
- { view: bg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
3615
- { view: fg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
3616
- ],
3734
+ colorAttachments: attach(target),
3617
3735
  // One query pair is reserved for "field", and it goes to the first pair
3618
3736
  // that actually runs — full res when something declared #fullres, half
3619
3737
  // otherwise. Pinning it to i === 0 would have measured a pass that, now
@@ -3622,7 +3740,7 @@ export class Engine {
3622
3740
  });
3623
3741
  stamped = true;
3624
3742
  for (const e of drawn) {
3625
- if (e.fieldLayer !== i)
3743
+ if (e.fieldLayer !== i || e.filter)
3626
3744
  continue;
3627
3745
  pass.setPipeline(e.fieldPipeline);
3628
3746
  // The grid this effect just wrote — after its parity flip, the one at
@@ -3632,6 +3750,21 @@ export class Engine {
3632
3750
  }
3633
3751
  pass.end();
3634
3752
  }
3753
+ if (!canFilter)
3754
+ return;
3755
+ // Each filter reads what the previous pass left and writes the other page.
3756
+ // The half pair goes to the first one, and only when something drew into
3757
+ // it this frame — an unused pair is never cleared and holds stale pixels.
3758
+ let read = plainFull;
3759
+ filters.forEach((e, k) => {
3760
+ const write = read === pageA ? pageB : pageA;
3761
+ const pass = encoder.beginRenderPass({ label: "field layer (filter)", colorAttachments: attach(write) });
3762
+ pass.setPipeline(e.fieldPipeline);
3763
+ pass.setBindGroup(0, e.fieldBindGroups[Engine.filterBindIndex(read === pageA ? 0 : 1, k === 0 && this.fieldPairUsed(1), e.grid?.parity ?? 0)]);
3764
+ pass.draw(3);
3765
+ pass.end();
3766
+ read = write;
3767
+ });
3635
3768
  }
3636
3769
  /** The trail bind group holds the depth view, which a resize recreates. */
3637
3770
  rebindTrails() {
@@ -4570,6 +4703,11 @@ export class Engine {
4570
4703
  // why it may not borrow the one at 18.
4571
4704
  { binding: 27, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4572
4705
  { binding: 28, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
4706
+ // The other effects' layers, for a filter's rzSceneFrame (scene-tap.ts).
4707
+ { binding: 29, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4708
+ { binding: 30, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4709
+ { binding: 31, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4710
+ { binding: 32, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
4573
4711
  // THE VIEW TRANSFORM'S OWN RESOURCES. viewTransform() lives in the
4574
4712
  // header both this module and the composite share, but its lookups did
4575
4713
  // not: an effect calling it compiled and then failed at pipeline
@@ -10175,8 +10313,10 @@ export class Engine {
10175
10313
  if (!inst)
10176
10314
  return;
10177
10315
  const target = this.selectedMaterial.materialName;
10178
- const draw = inst.drawCalls.find((d) => (d.type === "opaque" || d.type === "transparent") && d.materialName === target);
10179
- if (!draw || !this.shouldRenderDrawCall(inst, draw))
10316
+ // Every draw under the name — see materialIndexRanges for why there can be
10317
+ // more than one.
10318
+ const draws = inst.drawCalls.filter((d) => (d.type === "opaque" || d.type === "transparent") && d.materialName === target && this.shouldRenderDrawCall(inst, d));
10319
+ if (draws.length === 0)
10180
10320
  return;
10181
10321
  // Mask pass: fill the selected material's projected footprint with 1.0. Depth-always
10182
10322
  // (no depth attachment) so the outline traces complete boundaries even when the
@@ -10189,7 +10329,8 @@ export class Engine {
10189
10329
  mpass.setVertexBuffer(1, inst.jointsBuffer);
10190
10330
  mpass.setVertexBuffer(2, inst.weightsBuffer);
10191
10331
  mpass.setIndexBuffer(inst.indexBuffer, "uint32");
10192
- mpass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
10332
+ for (const draw of draws)
10333
+ mpass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
10193
10334
  mpass.end();
10194
10335
  // Edge pass: screen-space edge detect on the mask, alpha-blended over swapchain.
10195
10336
  const edgeAttachment = this.selectionEdgePassDescriptor.colorAttachments[0];
@@ -10203,21 +10344,25 @@ export class Engine {
10203
10344
  // Unique edges of the mesh, as a line-list index buffer. Each interior edge is
10204
10345
  // shared by two triangles, so deduplicating halves both the buffer and the
10205
10346
  // draw. Built once per model, on the first frame its wireframe is asked for.
10206
- /** The index run `material` owns, or the whole list when it is null. Materials
10207
- * are consecutive runs in declaration order, so the offset is a prefix sum —
10208
- * the same walk the draw list does. Returns null for a name the model does
10209
- * not have, which is what a stale selection looks like after a reload. */
10210
- materialIndexRange(inst, material) {
10347
+ /** The index runs `material` owns — EVERY run, because a PMX may carry
10348
+ * several materials under one name and a name is how the host asks; taking
10349
+ * the first left the rest of a hair or a coat out of its own wireframe —
10350
+ * or the whole list when it is null. Materials are consecutive runs in
10351
+ * declaration order, so each offset is a prefix sum, the same walk the draw
10352
+ * list does. Empty for a name the model does not have, which is what a
10353
+ * stale selection looks like after a reload. */
10354
+ materialIndexRanges(inst, material) {
10211
10355
  const indices = inst.model.getIndices();
10212
10356
  if (!material)
10213
- return [0, indices.length];
10357
+ return [[0, indices.length]];
10358
+ const runs = [];
10214
10359
  let offset = 0;
10215
10360
  for (const m of inst.model.getMaterials()) {
10216
10361
  if (m.name === material)
10217
- return [offset, offset + m.vertexCount];
10362
+ runs.push([offset, offset + m.vertexCount]);
10218
10363
  offset += m.vertexCount;
10219
10364
  }
10220
- return null;
10365
+ return runs;
10221
10366
  }
10222
10367
  /**
10223
10368
  * @param material one material's own edges, or null for the whole mesh
@@ -10263,10 +10408,9 @@ export class Engine {
10263
10408
  }
10264
10409
  }
10265
10410
  else {
10266
- const range = this.materialIndexRange(inst, material);
10267
- if (!range)
10411
+ const runs = this.materialIndexRanges(inst, material);
10412
+ if (runs.length === 0)
10268
10413
  return false;
10269
- const [start, end] = range;
10270
10414
  const seen = new Set();
10271
10415
  const add = (a, b) => {
10272
10416
  const lo = a < b ? a : b;
@@ -10277,10 +10421,12 @@ export class Engine {
10277
10421
  seen.add(k);
10278
10422
  edges.push(lo, hi);
10279
10423
  };
10280
- for (let i = start; i + 2 < end; i += 3) {
10281
- add(indices[i], indices[i + 1]);
10282
- add(indices[i + 1], indices[i + 2]);
10283
- add(indices[i + 2], indices[i]);
10424
+ for (const [start, end] of runs) {
10425
+ for (let i = start; i + 2 < end; i += 3) {
10426
+ add(indices[i], indices[i + 1]);
10427
+ add(indices[i + 1], indices[i + 2]);
10428
+ add(indices[i + 2], indices[i]);
10429
+ }
10284
10430
  }
10285
10431
  }
10286
10432
  if (edges.length === 0) {
@@ -10332,8 +10478,8 @@ export class Engine {
10332
10478
  // picked, since only the picked material draws at all then.
10333
10479
  const hm = this.hoverMaterial;
10334
10480
  const hoverName = material === null && hm?.modelName === inst.name ? hm.materialName : null;
10335
- const hoverRange = hoverName ? this.materialIndexRange(inst, hoverName) : null;
10336
- const hover = hoverRange && this.ensureEdgeBuffer(inst, hoverName) ? inst.wireEdges.get(hoverName) : null;
10481
+ const hoverRuns = hoverName ? this.materialIndexRanges(inst, hoverName) : [];
10482
+ const hover = hoverRuns.length > 0 && this.ensureEdgeBuffer(inst, hoverName) ? inst.wireEdges.get(hoverName) : null;
10337
10483
  this.wireframeColorData.set(DEFAULT_VERTEX_COLOR);
10338
10484
  this.wireframeColorData[4] = this.canvas.width;
10339
10485
  this.wireframeColorData[5] = this.canvas.height;
@@ -10371,9 +10517,10 @@ export class Engine {
10371
10517
  // before the base mesh below gets a chance to occlude it. The base mesh's
10372
10518
  // depth write further down repeats the SAME geometry for these faces
10373
10519
  // (identical z), so it neither disturbs this nor needs to skip them.
10374
- if (hover && hoverRange && !xray) {
10520
+ if (hover && !xray) {
10375
10521
  bindMesh();
10376
- pass.drawIndexed(hoverRange[1] - hoverRange[0], 1, hoverRange[0]);
10522
+ for (const [start, end] of hoverRuns)
10523
+ pass.drawIndexed(end - start, 1, start);
10377
10524
  pass.setBindGroup(0, this.wireframeHoverBindGroup);
10378
10525
  pass.setBindGroup(1, hover.bindGroup);
10379
10526
  pass.setPipeline(this.wireframePipeline);
@@ -10394,11 +10541,12 @@ export class Engine {
10394
10541
  // the coat hide it does not answer that. Its own depth still goes in, so its
10395
10542
  // back faces stay hidden and it reads as an object instead of a haze —
10396
10543
  // which is the difference between this and turning x-ray on.
10397
- const range = material !== null ? this.materialIndexRange(inst, material) : null;
10544
+ const runs = material !== null ? this.materialIndexRanges(inst, material) : null;
10398
10545
  if (!xray) {
10399
10546
  bindMesh();
10400
- if (range)
10401
- pass.drawIndexed(range[1] - range[0], 1, range[0]);
10547
+ if (runs)
10548
+ for (const [start, end] of runs)
10549
+ pass.drawIndexed(end - start, 1, start);
10402
10550
  else
10403
10551
  pass.drawIndexed(inst.model.getIndices().length);
10404
10552
  }
@@ -12235,7 +12383,9 @@ export class Engine {
12235
12383
  u[20] = v[2];
12236
12384
  u[21] = v[6];
12237
12385
  u[22] = v[10];
12238
- u[23] = 0;
12386
+ // A drawn filter consumed the half pair (renderFieldPass); the composite
12387
+ // skips it rather than laying it down a second time.
12388
+ u[23] = this.filtersDrawn() > 0 ? 1 : 0;
12239
12389
  // Effect clock + canvas size (viewU[6]) — written on the same refresh.
12240
12390
  // The effect clock, on the SCENE's time rather than the wall's.
12241
12391
  //
@@ -64,6 +64,11 @@ type CompositeEffectSource = {
64
64
  hasBackground: boolean;
65
65
  /** Defines `fn foreground(...)` — mount over the finished frame. */
66
66
  hasForeground: boolean;
67
+ /** Calls rzSceneFrame — a FILTER, which carries the layers it read forward
68
+ * (buildFieldShader) and so draws with blending off. */
69
+ filter?: boolean;
70
+ /** `#layer additive`: how a filter's own output joins the layers it carries. */
71
+ additiveLayer?: boolean;
67
72
  /** Grid resolution when the effect declared `#grid`, else 0. */
68
73
  gridSize: number;
69
74
  /** Whether the scene pass carries the id attachment, so the field module can
@@ -1 +1 @@
1
- {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AA2BA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BAgC+B;AAC/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,EAAE,CAIhG;AAID,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAEzG,KAAK,qBAAqB,GAAG;IAC3B,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;IACtB,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;IAChB;mFAC+E;IAC/E,GAAG,CAAC,EAAE,OAAO,CAAA;IACb;;;yEAGqE;IACrE,UAAU,EAAE,MAAM,CAAA;IAClB;;8EAE0E;IAC1E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB,CAAA;AA6LD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,yqaA+H5B,CAAA;AA6MD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAQlF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAiFtE;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
1
+ {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AA2BA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BAgC+B;AAC/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,EAAE,CAIhG;AAID,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAEzG,KAAK,qBAAqB,GAAG;IAC3B,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;IACtB;6DACyD;IACzD,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,gFAAgF;IAChF,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;IAChB;mFAC+E;IAC/E,GAAG,CAAC,EAAE,OAAO,CAAA;IACb;;;yEAGqE;IACrE,UAAU,EAAE,MAAM,CAAA;IAClB;;8EAE0E;IAC1E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB,CAAA;AA8LD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,yqaA+H5B,CAAA;AA8MD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAQlF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAqGtE;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
@@ -117,7 +117,8 @@ override APPLY_GAMMA: bool = true;
117
117
  // and no on/off uniform either: the pipeline is REBUILT per effect, so
118
118
  // the compiled variant IS the flag.
119
119
  // viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
120
- // viewU[5] = (camera forward, _) — refreshed per frame while skybox/effect active.
120
+ // viewU[5] = (camera forward, half pair consumed by a filter this frame) — refreshed
121
+ // per frame while skybox/effect active.
121
122
  // viewU[6] = (time seconds, view transform id, canvas width, canvas height).
122
123
  // viewU[7] = (grade offset.rgb, contrast); viewU[8] = (grade power.rgb, saturation);
123
124
  // viewU[9] = (grade slope.rgb, grade on/off) — see grade() below.
@@ -573,7 +574,8 @@ const BACKGROUND_CALL = /* wgsl */ `
573
574
  // straight form it replaced.
574
575
  let bgFx = rzFieldMerge(
575
576
  clamp(textureSampleLevel(fieldBgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)),
576
- clamp(textureSampleLevel(fieldBgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)));
577
+ // The half pair, unless a filter consumed it this frame (viewU[5].w).
578
+ clamp(textureSampleLevel(fieldBgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)) * (1.0 - viewU[5].w));
577
579
  bgPm = bgFx.rgb + bgPm * (1.0 - bgFx.a);
578
580
  bgA = bgFx.a + bgA * (1.0 - bgFx.a);
579
581
  `;
@@ -584,7 +586,7 @@ const FOREGROUND_CALL = /* wgsl */ `
584
586
  // Premultiplied, as the background layer above — same reason.
585
587
  let fgFx = rzFieldMerge(
586
588
  clamp(textureSampleLevel(fieldFgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)),
587
- clamp(textureSampleLevel(fieldFgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)));
589
+ clamp(textureSampleLevel(fieldFgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)) * (1.0 - viewU[5].w));
588
590
  outRgb = fgFx.rgb + outRgb * (1.0 - fgFx.a);
589
591
  outA = fgFx.a + outA * (1.0 - fgFx.a);
590
592
  `;
@@ -635,6 +637,16 @@ export function buildFieldShader(effect) {
635
637
  const fgLine = effect.hasForeground
636
638
  ? "out.fg = clamp(foreground(dir, uv, _rzFieldClock.x, linearDepth(vec2<i32>(min(fx, fullSz - 1.0)))), vec4f(0.0), vec4f(1.0));"
637
639
  : "";
640
+ // A FILTER read the other effects' layers through rzSceneFrame and now
641
+ // carries them: its own output goes over (or adds to) what they drew, and
642
+ // the pipeline writes the result with blending off. Weight has already
643
+ // scaled its alpha, so a filter faded to nothing passes the layers through
644
+ // untouched.
645
+ const carry = effect.filter
646
+ ? effect.additiveLayer
647
+ ? "out.bg = _rzCarryAdd(out.bg, _rzLayerBg(uv));\n out.fg = _rzCarryAdd(out.fg, _rzLayerFg(uv));"
648
+ : "out.bg = _rzCarryOver(out.bg, _rzLayerBg(uv));\n out.fg = _rzCarryOver(out.fg, _rzLayerFg(uv));"
649
+ : "";
638
650
  return (COMPOSITE_HEAD +
639
651
  EFFECT_SCENE_API +
640
652
  anchorAliasWgsl(effect.alias ?? []) +
@@ -656,7 +668,7 @@ export function buildFieldShader(effect) {
656
668
  // texel is CAST_FIELD_DIV of them and the accessor scales on the way out.
657
669
  // An author writes the width they mean and never learns how it is built.
658
670
  castDistanceApi(0, 26, 18, CAST_FIELD_DIV) +
659
- sceneTapApi(0, 27, 28) +
671
+ sceneTapApi(0, 27, 28, [29, 30, 31, 32]) +
660
672
  "\n// ── user effect (setEffect) ──\n" +
661
673
  effect.paramsDecl +
662
674
  "\n" +
@@ -675,6 +687,15 @@ export function buildFieldShader(effect) {
675
687
  */
676
688
  @group(0) @binding(22) var<uniform> _rzFieldClock: vec4f; // (time, weight, _, _)
677
689
 
690
+ /** A filter's own straight-alpha output over the premultiplied layers it read
691
+ * (the layer blend state, restated) and the additive form of the same. */
692
+ fn _rzCarryOver(own: vec4f, under: vec4f) -> vec4f {
693
+ return vec4f(own.rgb * own.a + under.rgb * (1.0 - own.a), own.a + under.a * (1.0 - own.a));
694
+ }
695
+ fn _rzCarryAdd(own: vec4f, under: vec4f) -> vec4f {
696
+ return vec4f(own.rgb * own.a + under.rgb, own.a + under.a);
697
+ }
698
+
678
699
  @vertex fn fieldVs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
679
700
  let x = f32((vi & 1u) << 2u) - 1.0;
680
701
  let y = f32((vi & 2u) << 1u) - 1.0;
@@ -704,6 +725,7 @@ struct FieldOut {
704
725
  // Scaling colour as well would fade as the square.
705
726
  out.bg.a *= _rzFieldClock.y;
706
727
  out.fg.a *= _rzFieldClock.y;
728
+ ${carry}
707
729
  return out;
708
730
  }
709
731
  `);
@@ -43,7 +43,7 @@
43
43
  * Clamp-to-edge is what stops a warped offset wrapping the far side of the
44
44
  * screen into the picture.
45
45
  */
46
- export declare function sceneTapApi(group: number, tex: number, samp: number): string;
46
+ export declare function sceneTapApi(group: number, tex: number, samp: number, layers: readonly [number, number, number, number]): string;
47
47
  /**
48
48
  * The same names, for the modules with no finished scene to give.
49
49
  *
@@ -1 +1 @@
1
- {"version":3,"file":"scene-tap.d.ts","sourceRoot":"","sources":["../../src/shaders/scene-tap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAoK5E;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc,kjBAW1B,CAAA"}
1
+ {"version":3,"file":"scene-tap.d.ts","sourceRoot":"","sources":["../../src/shaders/scene-tap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,GAChD,MAAM,CA6MR;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc,kjBAW1B,CAAA"}
@@ -43,10 +43,36 @@
43
43
  * Clamp-to-edge is what stops a warped offset wrapping the far side of the
44
44
  * screen into the picture.
45
45
  */
46
- export function sceneTapApi(group, tex, samp) {
46
+ export function sceneTapApi(group, tex, samp, layers) {
47
47
  return /* wgsl */ `
48
48
  @group(${group}) @binding(${tex}) var _rzSceneTex: texture_2d<f32>;
49
49
  @group(${group}) @binding(${samp}) var _rzSceneSamp: sampler;
50
+ // The field layers as the other effects left them — see rzSceneFrame. Bound to
51
+ // the pair the others drew into for a filter, and to a 1x1 transparent for
52
+ // everything else: an effect cannot sample the pair it is drawing into.
53
+ @group(${group}) @binding(${layers[0]}) var _rzLayerBgFull: texture_2d<f32>;
54
+ @group(${group}) @binding(${layers[1]}) var _rzLayerFgFull: texture_2d<f32>;
55
+ @group(${group}) @binding(${layers[2]}) var _rzLayerBgHalf: texture_2d<f32>;
56
+ @group(${group}) @binding(${layers[3]}) var _rzLayerFgHalf: texture_2d<f32>;
57
+
58
+ /** Premultiplied OVER, top in front — the composite's rzFieldMerge, restated so
59
+ * this file keeps importing nothing. A resolution boundary is a layer
60
+ * boundary: full res over half, exactly as the composite reads them. */
61
+ fn _rzLayerOver(top: vec4f, bot: vec4f) -> vec4f {
62
+ return vec4f(top.rgb + bot.rgb * (1.0 - top.a), top.a + bot.a * (1.0 - top.a));
63
+ }
64
+ fn _rzLayerBg(uv: vec2f) -> vec4f {
65
+ let p = _rzTapUv(uv);
66
+ return _rzLayerOver(
67
+ clamp(textureSampleLevel(_rzLayerBgFull, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)),
68
+ clamp(textureSampleLevel(_rzLayerBgHalf, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)));
69
+ }
70
+ fn _rzLayerFg(uv: vec2f) -> vec4f {
71
+ let p = _rzTapUv(uv);
72
+ return _rzLayerOver(
73
+ clamp(textureSampleLevel(_rzLayerFgFull, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)),
74
+ clamp(textureSampleLevel(_rzLayerFgHalf, _rzSceneSamp, p, 0.0), vec4f(0.0), vec4f(1.0)));
75
+ }
50
76
 
51
77
  /** uv as the mount gives it (y=0 at the BOTTOM, the shadertoy convention the
52
78
  * composite documents) turned into a texture coordinate (y=0 at the top).
@@ -175,25 +201,40 @@ fn rzSceneDisplay(uv: vec2f) -> vec3f {
175
201
  }
176
202
 
177
203
  /**
178
- * The finished pixel: the scene over its background, exactly as the composite
179
- * would lay it down. One call, and the answer already carries the ground's
180
- * fade, its opacity, and the background showing through both.
181
- *
182
- * This is what a FILTER wants — anything re-rendering the whole frame on a tube
183
- * or a low-resolution screen. Reaching for rzSceneDisplay and a depth test
184
- * instead rebuilds this by hand and gets it wrong in one specific way every
185
- * time: coverage is not a yes or no, and treating it as one turns every soft
186
- * edge in the scene into a hard one.
187
- *
188
- * NOT INCLUDED: depth of field, background effects, and an equirect background
189
- * (a 360 dome reads as its average rather than its texture). Foregrounds compose
190
- * after this in any case.
204
+ * The frame as it stands: the scene over its background, and both layers of
205
+ * every other field effect, laid down in the order the composite would lay
206
+ * them. One call, and the answer already carries the ground's fade, its
207
+ * opacity, the background showing through both, and whatever the other
208
+ * effects drew — a waveform, a caption, a glow.
209
+ *
210
+ * CALLING THIS MAKES THE EFFECT A FILTER. A filter runs after every other
211
+ * field effect, at full resolution, and what it returns replaces the layers it
212
+ * read: its own output is composed over them in its pass (buildFieldShader),
213
+ * so nothing drawn before it is lost and nothing is drawn twice. Two filters
214
+ * run in document order, each reading the one before. The layers are a
215
+ * pass-order fact — a filter reads the pair the others drew into while it
216
+ * draws into a page of its own (renderFieldPass).
217
+ *
218
+ * This is what a FILTER wants — anything re-rendering the whole frame on a
219
+ * tube, a low-resolution screen, or a card. Reaching for rzSceneDisplay and a
220
+ * depth test instead rebuilds this by hand and gets it wrong in one specific
221
+ * way every time: coverage is not a yes or no, and treating it as one turns
222
+ * every soft edge in the scene into a hard one.
223
+ *
224
+ * NOT INCLUDED: depth of field, and an equirect background (a 360 dome reads
225
+ * as its average rather than its texture).
191
226
  */
192
227
  fn rzSceneFrame(uv: vec2f) -> vec3f {
193
228
  let a = clamp(rzSceneAlpha(uv), 0.0, 1.0);
194
229
  let bg = viewU[2];
195
- let bgRgb = bg.rgb * select(0.0, 1.0, bg.w > 0.5);
196
- return rzSceneDisplay(uv) * a + bgRgb * (1.0 - a);
230
+ let bgA = select(0.0, 1.0, bg.w > 0.5);
231
+ // The background layer over the base, the scene over both, the foreground
232
+ // layer over the frame — the composite's own order.
233
+ let lb = _rzLayerBg(uv);
234
+ let under = lb.rgb + bg.rgb * bgA * (1.0 - lb.a);
235
+ let frame = rzSceneDisplay(uv) * a + under * (1.0 - a);
236
+ let lf = _rzLayerFg(uv);
237
+ return lf.rgb + frame * (1.0 - lf.a);
197
238
  }
198
239
 
199
240
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.55.9",
3
+ "version": "0.55.11",
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",