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.d.ts +30 -5
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +197 -47
- package/dist/shaders/passes/composite.d.ts +5 -0
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +26 -4
- package/dist/shaders/scene-tap.d.ts +1 -1
- package/dist/shaders/scene-tap.d.ts.map +1 -1
- package/dist/shaders/scene-tap.js +57 -16
- package/package.json +1 -1
- package/src/engine.ts +208 -50
- package/src/shaders/passes/composite.ts +31 -4
- package/src/shaders/scene-tap.ts +62 -16
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
|
|
1527
|
-
*
|
|
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
|
-
|
|
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 = (
|
|
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.
|
|
3292
|
-
|
|
3293
|
-
|
|
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
|
|
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
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4117
|
-
|
|
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
|
|
4742
|
-
|
|
4743
|
-
if (!
|
|
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
|
-
|
|
11901
|
-
|
|
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 (
|
|
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
|
|
11933
|
-
*
|
|
11934
|
-
* the
|
|
11935
|
-
*
|
|
11936
|
-
|
|
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)
|
|
12096
|
+
if (m.name === material) runs.push([offset, offset + m.vertexCount])
|
|
11942
12097
|
offset += m.vertexCount
|
|
11943
12098
|
}
|
|
11944
|
-
return
|
|
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
|
|
11994
|
-
if (
|
|
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 (
|
|
12006
|
-
|
|
12007
|
-
|
|
12008
|
-
|
|
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
|
|
12062
|
-
const hover =
|
|
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 &&
|
|
12259
|
+
if (hover && !xray) {
|
|
12104
12260
|
bindMesh()
|
|
12105
|
-
pass.drawIndexed(
|
|
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
|
|
12283
|
+
const runs = material !== null ? this.materialIndexRanges(inst, material) : null
|
|
12128
12284
|
if (!xray) {
|
|
12129
12285
|
bindMesh()
|
|
12130
|
-
if (
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
`
|
package/src/shaders/scene-tap.ts
CHANGED
|
@@ -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(
|
|
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
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
|
197
|
-
|
|
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
|
/**
|