reze-engine 0.57.5 → 0.58.0
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 +23 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +187 -77
- package/dist/shaders/directives.d.ts +18 -1
- package/dist/shaders/directives.d.ts.map +1 -1
- package/dist/shaders/directives.js +22 -3
- package/dist/shaders/materials/common.js +4 -4
- package/dist/shaders/passes/particles.d.ts +18 -2
- package/dist/shaders/passes/particles.d.ts.map +1 -1
- package/dist/shaders/passes/particles.js +70 -4
- package/dist/shaders/passes/trails.d.ts +3 -0
- package/dist/shaders/passes/trails.d.ts.map +1 -1
- package/dist/shaders/passes/trails.js +5 -0
- package/package.json +1 -1
- package/src/engine.ts +226 -91
- package/src/shaders/directives.ts +37 -4
- package/src/shaders/materials/common.ts +4 -4
- package/src/shaders/passes/particles.ts +85 -5
- package/src/shaders/passes/trails.ts +8 -0
package/src/engine.ts
CHANGED
|
@@ -1428,15 +1428,24 @@ interface EffectParticles {
|
|
|
1428
1428
|
counts: Uint32Array
|
|
1429
1429
|
compute: GPUComputePipeline
|
|
1430
1430
|
computeLayout: GPUBindGroupLayout
|
|
1431
|
-
|
|
1431
|
+
/** Indexed by the effect's GRID PARITY, so the pool samples the grid the grid
|
|
1432
|
+
* pass wrote this frame. Both entries are identical without a grid. */
|
|
1433
|
+
computeBinds: [GPUBindGroup, GPUBindGroup]
|
|
1434
|
+
/** The cutout's depth prepass, when the effect defined particleCover. Drawn
|
|
1435
|
+
* first, with the same bind group; `render` then tests equal. */
|
|
1436
|
+
depth: GPURenderPipeline | null
|
|
1432
1437
|
render: GPURenderPipeline
|
|
1433
1438
|
renderLayout: GPUBindGroupLayout
|
|
1434
|
-
|
|
1439
|
+
renderBinds: [GPUBindGroup, GPUBindGroup]
|
|
1435
1440
|
/** Same draw, the MIRRORED camera: how particles join the floor mirror. The
|
|
1436
1441
|
* billboards face whichever eye is bound, so one extra bind group is the
|
|
1437
1442
|
* whole cost. */
|
|
1438
|
-
|
|
1439
|
-
rebind: () => {
|
|
1443
|
+
mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
|
|
1444
|
+
rebind: () => {
|
|
1445
|
+
computeBinds: [GPUBindGroup, GPUBindGroup]
|
|
1446
|
+
renderBinds: [GPUBindGroup, GPUBindGroup]
|
|
1447
|
+
mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
|
|
1448
|
+
}
|
|
1440
1449
|
}
|
|
1441
1450
|
|
|
1442
1451
|
/**
|
|
@@ -1501,9 +1510,10 @@ interface EffectTrails {
|
|
|
1501
1510
|
data: Float32Array
|
|
1502
1511
|
pipeline: GPURenderPipeline
|
|
1503
1512
|
layout: GPUBindGroupLayout
|
|
1504
|
-
|
|
1513
|
+
/** Indexed by the effect's GRID PARITY, like the particle pool's. */
|
|
1514
|
+
binds: [GPUBindGroup, GPUBindGroup]
|
|
1505
1515
|
/** The mirrored camera's view of the same ribbons — the floor mirror's. */
|
|
1506
|
-
|
|
1516
|
+
mirrorBinds: [GPUBindGroup, GPUBindGroup]
|
|
1507
1517
|
/** The effect's declared parameters, or null when it declares none.
|
|
1508
1518
|
*
|
|
1509
1519
|
* Kept for the same reason `layout` is: both bind groups are REBUILT on every
|
|
@@ -1675,6 +1685,8 @@ interface EffectInstance {
|
|
|
1675
1685
|
* wins over the constant — see dissolveTimingsOf.
|
|
1676
1686
|
*/
|
|
1677
1687
|
dissolve: DissolveTimings | null
|
|
1688
|
+
/** `#ground`: the floor this effect asks for — colour, linear, and grain. */
|
|
1689
|
+
ground: { color: Vec3; noise: number } | null
|
|
1678
1690
|
/** This effect's OWN clock, as a uniform the field shader reads. Per effect
|
|
1679
1691
|
* because the shared one (viewU[6].x) is measured from the first installed
|
|
1680
1692
|
* effect's epoch, so everything later started mid-stream. Null when the
|
|
@@ -2063,7 +2075,12 @@ export class Engine {
|
|
|
2063
2075
|
* prefix sum and readback a compacted draw list would need every frame.
|
|
2064
2076
|
*/
|
|
2065
2077
|
/** Ceiling for `#particles`. Past this an author is asking for a stall. */
|
|
2066
|
-
|
|
2078
|
+
// Raised for the lawn: 65536 blades at real density is a 40-unit patch and
|
|
2079
|
+
// an 80-unit one wants four hundred thousand. A cutout pool's cost is
|
|
2080
|
+
// bounded by the pixels it covers, not by the count — the step is one
|
|
2081
|
+
// compute invocation per slot, the pool buffer is 48 bytes each (24 MB at
|
|
2082
|
+
// the cap), and a culled blade is a degenerate quad the rasteriser drops.
|
|
2083
|
+
private static readonly MAX_PARTICLES = 524288
|
|
2067
2084
|
private particleFrame = 0
|
|
2068
2085
|
/**
|
|
2069
2086
|
* The installed effect's persistent grid, or null when it declared none.
|
|
@@ -4306,9 +4323,9 @@ export class Engine {
|
|
|
4306
4323
|
for (const e of this.effects) {
|
|
4307
4324
|
if (e.particles) {
|
|
4308
4325
|
const b = e.particles.rebind()
|
|
4309
|
-
e.particles.
|
|
4310
|
-
e.particles.
|
|
4311
|
-
e.particles.
|
|
4326
|
+
e.particles.computeBinds = b.computeBinds
|
|
4327
|
+
e.particles.renderBinds = b.renderBinds
|
|
4328
|
+
e.particles.mirrorRenderBinds = b.mirrorRenderBinds
|
|
4312
4329
|
}
|
|
4313
4330
|
if (e.grid) e.grid.binds = [this.gridBindGroup(e.grid, 0), this.gridBindGroup(e.grid, 1)]
|
|
4314
4331
|
if (e.lights) e.lights.bind = this.lightEmitBindGroup(e.lights.layout, e.lights.uniform, e.lights.params)
|
|
@@ -4852,16 +4869,19 @@ export class Engine {
|
|
|
4852
4869
|
trails?.uniform.destroy()
|
|
4853
4870
|
return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
4854
4871
|
}
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
particles = built.state
|
|
4859
|
-
}
|
|
4872
|
+
// THE GRID FIRST. Particles and ribbons READ it — a blade of grass reads the
|
|
4873
|
+
// bend a foot left there — so the textures have to exist before their bind
|
|
4874
|
+
// groups are built. Nothing reads them back, so the grid depends on neither.
|
|
4860
4875
|
if (gridEntryPoint(wgsl)) {
|
|
4861
4876
|
const built = await this.buildSim(wgsl, d, anchors, alias, paramsFor)
|
|
4862
4877
|
if (!built.ok) return abandon(built.diagnostics)
|
|
4863
4878
|
grid = built.state
|
|
4864
4879
|
}
|
|
4880
|
+
if (wantsParticles) {
|
|
4881
|
+
const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor, grid)
|
|
4882
|
+
if (!built.ok) return abandon(built.diagnostics)
|
|
4883
|
+
particles = built.state
|
|
4884
|
+
}
|
|
4865
4885
|
if (wantsTrails) {
|
|
4866
4886
|
// Only anchors that asked for `trail` have a path to draw; a ribbon on a
|
|
4867
4887
|
// bone recorded without one would read zeroes and paint a line to the origin.
|
|
@@ -4869,7 +4889,7 @@ export class Engine {
|
|
|
4869
4889
|
if (trailSlots === 0) {
|
|
4870
4890
|
return abandon(["a ribbon effect needs at least one #anchor <bone> trail"])
|
|
4871
4891
|
}
|
|
4872
|
-
const built = await this.buildTrails(wgsl, d, anchors, alias, paramsFor)
|
|
4892
|
+
const built = await this.buildTrails(wgsl, d, anchors, alias, paramsFor, grid)
|
|
4873
4893
|
if (!built.ok) return abandon(built.diagnostics)
|
|
4874
4894
|
trails = built.state
|
|
4875
4895
|
}
|
|
@@ -4949,6 +4969,7 @@ export class Engine {
|
|
|
4949
4969
|
simStep: 0,
|
|
4950
4970
|
simReset: false,
|
|
4951
4971
|
dissolve: d.dissolve ? dissolveConstants(authored) : null,
|
|
4972
|
+
ground: d.ground ? { color: new Vec3(...d.ground.color), noise: d.ground.noise } : null,
|
|
4952
4973
|
// Its OWN resolution, no longer the scene's: an effect that never asked
|
|
4953
4974
|
// for full res is not promoted because a neighbour did.
|
|
4954
4975
|
// FULL RESOLUTION UNLESS TOLD OTHERWISE.
|
|
@@ -5209,11 +5230,23 @@ export class Engine {
|
|
|
5209
5230
|
/** The effect's declared dials. Spliced into both stages and bound at 7,
|
|
5210
5231
|
* the same binding the composite gives them. */
|
|
5211
5232
|
params: EffectParamsBinding,
|
|
5233
|
+
/** This effect's own grid, already built, or null when it declared none.
|
|
5234
|
+
* Read-only here: the pool samples it, the grid pass owns it. */
|
|
5235
|
+
grid: EffectGrid | null,
|
|
5212
5236
|
): Promise<{ ok: true; state: EffectParticles } | { ok: false; diagnostics: string[] }> {
|
|
5213
5237
|
// No pragma means "some": an author who wrote the trio clearly wants
|
|
5214
5238
|
// particles, and failing over a missing comment would be pedantry.
|
|
5215
5239
|
const count = Math.min(d.particles || 1024, Engine.MAX_PARTICLES)
|
|
5216
|
-
const src = {
|
|
5240
|
+
const src = {
|
|
5241
|
+
wgsl,
|
|
5242
|
+
count,
|
|
5243
|
+
blend: d.particleBlend,
|
|
5244
|
+
bloom: d.bloom,
|
|
5245
|
+
paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
|
|
5246
|
+
gridSize: grid?.size ?? 0,
|
|
5247
|
+
cover: particleEntryPoints(wgsl).cover,
|
|
5248
|
+
}
|
|
5249
|
+
const prepass = src.blend === "cutout" && src.cover
|
|
5217
5250
|
// Sparks want to spawn where a trail is, so the particle stages see the same
|
|
5218
5251
|
// cast buffer the trail draw reads.
|
|
5219
5252
|
const cast = {
|
|
@@ -5282,22 +5315,35 @@ export class Engine {
|
|
|
5282
5315
|
// Only when the effect declared any: WGSL has no empty struct, so a
|
|
5283
5316
|
// param-less effect must not carry the decl OR the binding.
|
|
5284
5317
|
...(params.buffer ? [{ binding: 7, visibility, buffer: { type: "uniform" as const } }] : []),
|
|
5318
|
+
// The grid, ALWAYS — the accessor is compiled in unconditionally, and a
|
|
5319
|
+
// binding the shader declares must exist in the layout whether the
|
|
5320
|
+
// author calls it or not. With no grid it is the 1x1 of zeroes.
|
|
5321
|
+
{ binding: 8, visibility, texture: { sampleType: "float" as const } },
|
|
5322
|
+
{ binding: 9, visibility, sampler: { type: "filtering" as const } },
|
|
5285
5323
|
],
|
|
5286
5324
|
})
|
|
5325
|
+
/** One per GRID PARITY, for the reason rebuildFieldBindGroup gives: the grid
|
|
5326
|
+
* alternates which texture is current, and rebuilding a single group every
|
|
5327
|
+
* frame is waste for a change that only ever toggles between two known
|
|
5328
|
+
* states. Both entries are the same view when there is no grid. */
|
|
5287
5329
|
const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer) =>
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5330
|
+
[0, 1].map((parity) =>
|
|
5331
|
+
this.device.createBindGroup({
|
|
5332
|
+
layout,
|
|
5333
|
+
entries: [
|
|
5334
|
+
{ binding: 0, resource: { buffer } },
|
|
5335
|
+
{ binding: 1, resource: { buffer: uniform } },
|
|
5336
|
+
{ binding: 2, resource: { buffer: camera } },
|
|
5337
|
+
{ binding: 3, resource: { buffer: this.castBuffer } },
|
|
5338
|
+
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
5339
|
+
{ binding: 5, resource: { buffer: this.midiBuffer } },
|
|
5340
|
+
{ binding: 6, resource: { buffer: this.lyricsBuffer } },
|
|
5341
|
+
...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
|
|
5342
|
+
{ binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
|
|
5343
|
+
{ binding: 9, resource: this.simSampler },
|
|
5344
|
+
],
|
|
5345
|
+
}),
|
|
5346
|
+
) as [GPUBindGroup, GPUBindGroup]
|
|
5301
5347
|
|
|
5302
5348
|
const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE)
|
|
5303
5349
|
const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT)
|
|
@@ -5306,7 +5352,12 @@ export class Engine {
|
|
|
5306
5352
|
// a glow does not claim coverage it never occluded. The MASK sums with it —
|
|
5307
5353
|
// otherwise an additive effect could never reach the bloom gate. Both live
|
|
5308
5354
|
// in scene-contract as the "particle-additive" class.
|
|
5355
|
+
//
|
|
5356
|
+
// Cutout blends exactly like alpha and differs only in depth and coverage,
|
|
5357
|
+
// below — so it shares alpha's targets rather than adding a class whose
|
|
5358
|
+
// blends would be a copy.
|
|
5309
5359
|
const targets = sceneTargetsFor(src.blend === "additive" ? "particle-additive" : "particle", this.sceneFormats)
|
|
5360
|
+
const cutout = src.blend === "cutout"
|
|
5310
5361
|
|
|
5311
5362
|
this.device.pushErrorScope("validation")
|
|
5312
5363
|
try {
|
|
@@ -5315,15 +5366,41 @@ export class Engine {
|
|
|
5315
5366
|
layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
|
|
5316
5367
|
compute: { module: computeModule, entryPoint: "main" },
|
|
5317
5368
|
})
|
|
5369
|
+
const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] })
|
|
5370
|
+
// The prepass, when the effect gave the cutout a cheap shape: coverage to
|
|
5371
|
+
// depth, colour targets at writeMask 0 (the depth-prepass class), and
|
|
5372
|
+
// the shading pass below then tests EQUAL and writes no depth.
|
|
5373
|
+
const depth = prepass
|
|
5374
|
+
? await this.device.createRenderPipelineAsync({
|
|
5375
|
+
label: "particle depth prepass pipeline",
|
|
5376
|
+
layout: renderLayoutObj,
|
|
5377
|
+
vertex: { module: renderModule, entryPoint: "vs" },
|
|
5378
|
+
fragment: { module: renderModule, entryPoint: "fsDepth", targets: sceneTargetsFor("depth-prepass", this.sceneFormats) },
|
|
5379
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
5380
|
+
depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
|
|
5381
|
+
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
5382
|
+
})
|
|
5383
|
+
: null
|
|
5318
5384
|
const render = await this.device.createRenderPipelineAsync({
|
|
5319
5385
|
label: "particle render pipeline",
|
|
5320
|
-
layout:
|
|
5386
|
+
layout: renderLayoutObj,
|
|
5321
5387
|
vertex: { module: renderModule, entryPoint: "vs" },
|
|
5322
5388
|
fragment: { module: renderModule, entryPoint: "fs", targets },
|
|
5323
5389
|
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
5324
5390
|
// Tested but not WRITTEN: particles are transparent, so writing depth
|
|
5325
5391
|
// would make whichever quad drew first occlude the ones behind it.
|
|
5326
|
-
|
|
5392
|
+
//
|
|
5393
|
+
// Except a CUTOUT, which is opaque where it is drawn at all. Its alpha
|
|
5394
|
+
// becomes per-sample coverage (in the shader — see FSOut.samples, the
|
|
5395
|
+
// HDR target has no alpha for the hardware path), so its edges stay
|
|
5396
|
+
// antialiased under MSAA while its depth is real — and a blade behind
|
|
5397
|
+
// another blade is then rejected at the depth test rather than shaded
|
|
5398
|
+
// and blended under it.
|
|
5399
|
+
// For a lawn stacked ten blades deep that is the difference between
|
|
5400
|
+
// shading ten layers and shading about three.
|
|
5401
|
+
depthStencil: prepass
|
|
5402
|
+
? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
|
|
5403
|
+
: { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
|
|
5327
5404
|
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
5328
5405
|
})
|
|
5329
5406
|
const scoped = await this.device.popErrorScope()
|
|
@@ -5345,15 +5422,16 @@ export class Engine {
|
|
|
5345
5422
|
counts: uniformView.uints,
|
|
5346
5423
|
compute,
|
|
5347
5424
|
computeLayout,
|
|
5348
|
-
|
|
5425
|
+
computeBinds: bindFor(computeLayout, this.cameraUniformBuffer),
|
|
5426
|
+
depth,
|
|
5349
5427
|
render,
|
|
5350
5428
|
renderLayout,
|
|
5351
|
-
|
|
5352
|
-
|
|
5429
|
+
renderBinds: bindFor(renderLayout, this.cameraUniformBuffer),
|
|
5430
|
+
mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer),
|
|
5353
5431
|
rebind: () => ({
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5432
|
+
computeBinds: bindFor(computeLayout, this.cameraUniformBuffer),
|
|
5433
|
+
renderBinds: bindFor(renderLayout, this.cameraUniformBuffer),
|
|
5434
|
+
mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer),
|
|
5357
5435
|
}),
|
|
5358
5436
|
},
|
|
5359
5437
|
}
|
|
@@ -5553,7 +5631,7 @@ export class Engine {
|
|
|
5553
5631
|
this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer as ArrayBuffer)
|
|
5554
5632
|
const cp = encoder.beginComputePass({ label: "particles" })
|
|
5555
5633
|
cp.setPipeline(p.compute)
|
|
5556
|
-
cp.setBindGroup(0, p.
|
|
5634
|
+
cp.setBindGroup(0, p.computeBinds[e.grid?.parity ?? 0])
|
|
5557
5635
|
cp.dispatchWorkgroups(Math.ceil(p.count / 64))
|
|
5558
5636
|
cp.end()
|
|
5559
5637
|
}
|
|
@@ -5564,8 +5642,15 @@ export class Engine {
|
|
|
5564
5642
|
for (const e of this.effects) {
|
|
5565
5643
|
const p = e.particles
|
|
5566
5644
|
if (!p || e.weight === 0) continue
|
|
5645
|
+
const parity = e.grid?.parity ?? 0
|
|
5646
|
+
const bind = view === "mirror" ? p.mirrorRenderBinds[parity] : p.renderBinds[parity]
|
|
5647
|
+
if (p.depth) {
|
|
5648
|
+
pass.setPipeline(p.depth)
|
|
5649
|
+
pass.setBindGroup(0, bind)
|
|
5650
|
+
pass.draw(6, p.count)
|
|
5651
|
+
}
|
|
5567
5652
|
pass.setPipeline(p.render)
|
|
5568
|
-
pass.setBindGroup(0,
|
|
5653
|
+
pass.setBindGroup(0, bind)
|
|
5569
5654
|
pass.draw(6, p.count)
|
|
5570
5655
|
}
|
|
5571
5656
|
}
|
|
@@ -5590,6 +5675,9 @@ export class Engine {
|
|
|
5590
5675
|
alias: number[],
|
|
5591
5676
|
/** The effect's declared dials, spliced and bound at 7 as everywhere else. */
|
|
5592
5677
|
params: EffectParamsBinding,
|
|
5678
|
+
/** This effect's own grid, already built, or null. Ribbons read it; the grid
|
|
5679
|
+
* pass owns it. */
|
|
5680
|
+
grid: EffectGrid | null,
|
|
5593
5681
|
): Promise<{ ok: true; state: EffectTrails } | { ok: false; diagnostics: string[] }> {
|
|
5594
5682
|
// `slots` here is how many RIBBONS to draw — one per trailed anchor — which
|
|
5595
5683
|
// is a different number from the anchor ADDRESS SPACE the accessors index
|
|
@@ -5600,7 +5688,15 @@ export class Engine {
|
|
|
5600
5688
|
// nothing before: ribbon i was read as anchor slot i.
|
|
5601
5689
|
const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0)
|
|
5602
5690
|
const slots = ribbonSlots.length
|
|
5603
|
-
const src = {
|
|
5691
|
+
const src = {
|
|
5692
|
+
wgsl,
|
|
5693
|
+
slots,
|
|
5694
|
+
ribbonSlots,
|
|
5695
|
+
blend: (d.particleBlend === "additive" ? "additive" : "alpha") as "alpha" | "additive",
|
|
5696
|
+
bloom: d.bloom,
|
|
5697
|
+
paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
|
|
5698
|
+
gridSize: grid?.size ?? 0,
|
|
5699
|
+
}
|
|
5604
5700
|
const code = buildTrailShader(src, {
|
|
5605
5701
|
subjects: MAX_EFFECT_SUBJECTS,
|
|
5606
5702
|
samples: TRAIL_SAMPLES,
|
|
@@ -5647,6 +5743,13 @@ export class Engine {
|
|
|
5647
5743
|
...(params.buffer
|
|
5648
5744
|
? [{ binding: 7, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } }]
|
|
5649
5745
|
: []),
|
|
5746
|
+
// The grid, always — compiled in whether the author calls it or not.
|
|
5747
|
+
{
|
|
5748
|
+
binding: 8,
|
|
5749
|
+
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
|
5750
|
+
texture: { sampleType: "float" as const },
|
|
5751
|
+
},
|
|
5752
|
+
{ binding: 9, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, sampler: { type: "filtering" as const } },
|
|
5650
5753
|
],
|
|
5651
5754
|
})
|
|
5652
5755
|
// TWO targets, the scene pass's own: HDR colour and the aux (bloom mask,
|
|
@@ -5692,30 +5795,8 @@ export class Engine {
|
|
|
5692
5795
|
data: new Float32Array(4),
|
|
5693
5796
|
pipeline,
|
|
5694
5797
|
layout,
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
entries: [
|
|
5698
|
-
{ binding: 0, resource: { buffer: this.castBuffer } },
|
|
5699
|
-
{ binding: 1, resource: { buffer: uniform } },
|
|
5700
|
-
{ binding: 2, resource: { buffer: this.cameraUniformBuffer } },
|
|
5701
|
-
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
5702
|
-
{ binding: 5, resource: { buffer: this.midiBuffer } },
|
|
5703
|
-
{ binding: 6, resource: { buffer: this.lyricsBuffer } },
|
|
5704
|
-
...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
|
|
5705
|
-
],
|
|
5706
|
-
}),
|
|
5707
|
-
mirrorBind: this.device.createBindGroup({
|
|
5708
|
-
layout,
|
|
5709
|
-
entries: [
|
|
5710
|
-
{ binding: 0, resource: { buffer: this.castBuffer } },
|
|
5711
|
-
{ binding: 1, resource: { buffer: uniform } },
|
|
5712
|
-
{ binding: 2, resource: { buffer: this.mirrorCameraBuffer } },
|
|
5713
|
-
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
5714
|
-
{ binding: 5, resource: { buffer: this.midiBuffer } },
|
|
5715
|
-
{ binding: 6, resource: { buffer: this.lyricsBuffer } },
|
|
5716
|
-
...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
|
|
5717
|
-
],
|
|
5718
|
-
}),
|
|
5798
|
+
binds: this.trailBindGroups(layout, uniform, params.buffer, this.cameraUniformBuffer, grid),
|
|
5799
|
+
mirrorBinds: this.trailBindGroups(layout, uniform, params.buffer, this.mirrorCameraBuffer, grid),
|
|
5719
5800
|
},
|
|
5720
5801
|
}
|
|
5721
5802
|
} catch (e) {
|
|
@@ -5776,7 +5857,8 @@ export class Engine {
|
|
|
5776
5857
|
this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
|
|
5777
5858
|
}
|
|
5778
5859
|
pass.setPipeline(t.pipeline)
|
|
5779
|
-
|
|
5860
|
+
const parity = e.grid?.parity ?? 0
|
|
5861
|
+
pass.setBindGroup(0, view === "mirror" ? t.mirrorBinds[parity] : t.binds[parity])
|
|
5780
5862
|
pass.draw(6, t.slots * live * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS)
|
|
5781
5863
|
}
|
|
5782
5864
|
}
|
|
@@ -5904,34 +5986,45 @@ export class Engine {
|
|
|
5904
5986
|
// one, so it has to be part of every group built against that layout —
|
|
5905
5987
|
// including this one. Omitting it here is not a missing uniform, it is a
|
|
5906
5988
|
// group WebGPU refuses outright, which takes the whole frame down.
|
|
5907
|
-
|
|
5908
|
-
t.
|
|
5909
|
-
layout: t.layout,
|
|
5910
|
-
entries: [
|
|
5911
|
-
{ binding: 0, resource: { buffer: this.castBuffer } },
|
|
5912
|
-
{ binding: 1, resource: { buffer: t.uniform } },
|
|
5913
|
-
{ binding: 2, resource: { buffer: this.cameraUniformBuffer } },
|
|
5914
|
-
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
5915
|
-
{ binding: 5, resource: { buffer: this.midiBuffer } },
|
|
5916
|
-
{ binding: 6, resource: { buffer: this.lyricsBuffer } },
|
|
5917
|
-
...paramsEntry,
|
|
5918
|
-
],
|
|
5919
|
-
})
|
|
5920
|
-
t.mirrorBind = this.device.createBindGroup({
|
|
5921
|
-
layout: t.layout,
|
|
5922
|
-
entries: [
|
|
5923
|
-
{ binding: 0, resource: { buffer: this.castBuffer } },
|
|
5924
|
-
{ binding: 1, resource: { buffer: t.uniform } },
|
|
5925
|
-
{ binding: 2, resource: { buffer: this.mirrorCameraBuffer } },
|
|
5926
|
-
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
5927
|
-
{ binding: 5, resource: { buffer: this.midiBuffer } },
|
|
5928
|
-
{ binding: 6, resource: { buffer: this.lyricsBuffer } },
|
|
5929
|
-
...paramsEntry,
|
|
5930
|
-
],
|
|
5931
|
-
})
|
|
5989
|
+
t.binds = this.trailBindGroups(t.layout, t.uniform, t.params, this.cameraUniformBuffer, e.grid)
|
|
5990
|
+
t.mirrorBinds = this.trailBindGroups(t.layout, t.uniform, t.params, this.mirrorCameraBuffer, e.grid)
|
|
5932
5991
|
}
|
|
5933
5992
|
}
|
|
5934
5993
|
|
|
5994
|
+
/**
|
|
5995
|
+
* A ribbon's bind group, ONE PER GRID PARITY.
|
|
5996
|
+
*
|
|
5997
|
+
* One author for both the build and the rebind, because they drifted once
|
|
5998
|
+
* already: the params binding is part of the LAYOUT whenever the effect
|
|
5999
|
+
* declared one, so it has to be part of every group built against that
|
|
6000
|
+
* layout, and a group missing it is not a missing uniform but one WebGPU
|
|
6001
|
+
* refuses outright, taking the whole frame down.
|
|
6002
|
+
*/
|
|
6003
|
+
private trailBindGroups(
|
|
6004
|
+
layout: GPUBindGroupLayout,
|
|
6005
|
+
uniform: GPUBuffer,
|
|
6006
|
+
params: GPUBuffer | null,
|
|
6007
|
+
camera: GPUBuffer,
|
|
6008
|
+
grid: EffectGrid | null,
|
|
6009
|
+
): [GPUBindGroup, GPUBindGroup] {
|
|
6010
|
+
return [0, 1].map((parity) =>
|
|
6011
|
+
this.device.createBindGroup({
|
|
6012
|
+
layout,
|
|
6013
|
+
entries: [
|
|
6014
|
+
{ binding: 0, resource: { buffer: this.castBuffer } },
|
|
6015
|
+
{ binding: 1, resource: { buffer: uniform } },
|
|
6016
|
+
{ binding: 2, resource: { buffer: camera } },
|
|
6017
|
+
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
6018
|
+
{ binding: 5, resource: { buffer: this.midiBuffer } },
|
|
6019
|
+
{ binding: 6, resource: { buffer: this.lyricsBuffer } },
|
|
6020
|
+
...(params ? [{ binding: EFFECT_PARAMS_BINDING, resource: { buffer: params } }] : []),
|
|
6021
|
+
{ binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
|
|
6022
|
+
{ binding: 9, resource: this.simSampler },
|
|
6023
|
+
],
|
|
6024
|
+
}),
|
|
6025
|
+
) as [GPUBindGroup, GPUBindGroup]
|
|
6026
|
+
}
|
|
6027
|
+
|
|
5935
6028
|
private releaseParticles(): void {
|
|
5936
6029
|
for (const e of this.effects) {
|
|
5937
6030
|
e.particles?.buffer.destroy()
|
|
@@ -12247,6 +12340,45 @@ export class Engine {
|
|
|
12247
12340
|
/** The ground's uniform block, kept so the caster sphere can be refreshed in
|
|
12248
12341
|
* it every frame rather than rebuilding the buffer (addGround allocates). */
|
|
12249
12342
|
private groundMaterialData: Float32Array | null = null
|
|
12343
|
+
/** What addGround was given, so an effect's `#ground` can be lifted again
|
|
12344
|
+
* without rebuilding the floor. */
|
|
12345
|
+
private groundBaseColor = new Vec3(1, 1, 1)
|
|
12346
|
+
private groundBaseNoise = 0
|
|
12347
|
+
|
|
12348
|
+
/**
|
|
12349
|
+
* Dress the floor in whatever the installed effects ask for.
|
|
12350
|
+
*
|
|
12351
|
+
* The LAST effect declaring `#ground` wins, faded by its own weight so a
|
|
12352
|
+
* scheduled lawn brings its soil in with it and takes it away again. Three
|
|
12353
|
+
* floats at the top of the ground block, written only when they change —
|
|
12354
|
+
* the same bargain writeGroundCasterSphere strikes, for the same reason.
|
|
12355
|
+
*/
|
|
12356
|
+
private writeGroundDress(): void {
|
|
12357
|
+
const gb = this.groundMaterialData
|
|
12358
|
+
if (!gb || !this.groundShadowMaterialBuffer) return
|
|
12359
|
+
let want = this.groundBaseColor
|
|
12360
|
+
let noise = this.groundBaseNoise
|
|
12361
|
+
for (const e of this.effects) {
|
|
12362
|
+
if (!e.ground || e.weight <= 0) continue
|
|
12363
|
+
const w = e.weight
|
|
12364
|
+
want = new Vec3(
|
|
12365
|
+
want.x + (e.ground.color.x - want.x) * w,
|
|
12366
|
+
want.y + (e.ground.color.y - want.y) * w,
|
|
12367
|
+
want.z + (e.ground.color.z - want.z) * w,
|
|
12368
|
+
)
|
|
12369
|
+
noise += (e.ground.noise - noise) * w
|
|
12370
|
+
}
|
|
12371
|
+
if (gb[0] === want.x && gb[1] === want.y && gb[2] === want.z && gb[10] === noise) return
|
|
12372
|
+
gb[0] = want.x
|
|
12373
|
+
gb[1] = want.y
|
|
12374
|
+
gb[2] = want.z
|
|
12375
|
+
gb[10] = noise
|
|
12376
|
+
// Two writes rather than one over the span: the seven floats between hold
|
|
12377
|
+
// the fades and the shadow strength, and this is not the place that owns
|
|
12378
|
+
// them.
|
|
12379
|
+
this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb.subarray(0, 3) as Float32Array<ArrayBuffer>)
|
|
12380
|
+
this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 40, gb.subarray(10, 11) as Float32Array<ArrayBuffer>)
|
|
12381
|
+
}
|
|
12250
12382
|
|
|
12251
12383
|
/**
|
|
12252
12384
|
* Push this frame's caster sphere into the ground's uniform.
|
|
@@ -13221,6 +13353,8 @@ export class Engine {
|
|
|
13221
13353
|
// keeping the uniform vec4-aligned.
|
|
13222
13354
|
const gb = new Float32Array(24)
|
|
13223
13355
|
this.groundMaterialData = gb
|
|
13356
|
+
this.groundBaseColor = new Vec3(diffuseColor.x, diffuseColor.y, diffuseColor.z)
|
|
13357
|
+
this.groundBaseNoise = noiseStrength
|
|
13224
13358
|
gb[0] = diffuseColor.x
|
|
13225
13359
|
gb[1] = diffuseColor.y
|
|
13226
13360
|
gb[2] = diffuseColor.z
|
|
@@ -15242,6 +15376,7 @@ export class Engine {
|
|
|
15242
15376
|
if (hasModels) this.dispatchCull(encoder)
|
|
15243
15377
|
// After the cull, which is what recomputes the spheres it unions.
|
|
15244
15378
|
this.writeGroundCasterSphere()
|
|
15379
|
+
this.writeGroundDress()
|
|
15245
15380
|
|
|
15246
15381
|
// After the cull, because a rebuild there can reallocate the argument
|
|
15247
15382
|
// buffers and a bundle captures the buffer it recorded against.
|
|
@@ -48,13 +48,27 @@ export type EffectDirectives = {
|
|
|
48
48
|
/** The field layer composites additively rather than over. */
|
|
49
49
|
additiveLayer: boolean
|
|
50
50
|
/** Particle blend, which is a different axis from the field layer's. */
|
|
51
|
-
|
|
51
|
+
/** `cutout` is alpha WITH DEPTH: the pipeline writes depth and turns the
|
|
52
|
+
* fragment's alpha into MSAA sample coverage, so a blade of grass behind
|
|
53
|
+
* another is rejected by the depth test instead of blended under it. What
|
|
54
|
+
* every real grass renderer does, and the only thing that bounds a dense
|
|
55
|
+
* lawn's cost by the pixels it covers rather than by how deep it stacks. */
|
|
56
|
+
particleBlend: "alpha" | "additive" | "cutout"
|
|
52
57
|
particles: number
|
|
53
58
|
lights: number
|
|
54
59
|
grid: number
|
|
55
60
|
bloom: boolean
|
|
56
61
|
/** This effect takes the cast apart — the host reads the timing. */
|
|
57
62
|
dissolve: boolean
|
|
63
|
+
/** `#ground #rrggbb [noise]`: how this effect DRESSES THE FLOOR while it is
|
|
64
|
+
* installed — a colour as linear RGB, and the strength of the ground's own
|
|
65
|
+
* grain (0 flat, 1 strong), which is what keeps soil from being a swatch.
|
|
66
|
+
* A lawn wants earth under it, and the earth has to be the engine's own
|
|
67
|
+
* ground rather than a layer painted over the frame: the ground is geometry
|
|
68
|
+
* in the scene pass, so a shadow lands on it and it tone-maps with
|
|
69
|
+
* everything else. The scene's own floor returns when the effect goes.
|
|
70
|
+
* Null when the file declares none. */
|
|
71
|
+
ground: { color: [number, number, number]; noise: number } | null
|
|
58
72
|
/**
|
|
59
73
|
* This effect IS a mirror: a plane in the scene showing a true reflection of
|
|
60
74
|
* it, folded and re-rendered by the engine.
|
|
@@ -101,6 +115,7 @@ const SPEC = {
|
|
|
101
115
|
dissolve: 0,
|
|
102
116
|
mirror: 0,
|
|
103
117
|
duration: 1,
|
|
118
|
+
ground: "rest",
|
|
104
119
|
} as const
|
|
105
120
|
|
|
106
121
|
/** A line that declares something, and what it declares. Exported because an
|
|
@@ -155,6 +170,7 @@ export function parseDirectives(wgsl: string): DirectiveResult {
|
|
|
155
170
|
grid: 0,
|
|
156
171
|
bloom: false,
|
|
157
172
|
dissolve: false,
|
|
173
|
+
ground: null,
|
|
158
174
|
mirror: false,
|
|
159
175
|
duration: 0,
|
|
160
176
|
}
|
|
@@ -240,15 +256,32 @@ export function parseDirectives(wgsl: string): DirectiveResult {
|
|
|
240
256
|
d.additiveLayer = true
|
|
241
257
|
return
|
|
242
258
|
case "blend":
|
|
243
|
-
if (args[0] !== "additive") {
|
|
244
|
-
errors.push(`${at}: #blend takes "additive" — alpha is the default`)
|
|
259
|
+
if (args[0] !== "additive" && args[0] !== "cutout") {
|
|
260
|
+
errors.push(`${at}: #blend takes "additive" or "cutout" — alpha is the default`)
|
|
245
261
|
return
|
|
246
262
|
}
|
|
247
|
-
d.particleBlend =
|
|
263
|
+
d.particleBlend = args[0]
|
|
248
264
|
return
|
|
249
265
|
case "bloom":
|
|
250
266
|
d.bloom = true
|
|
251
267
|
return
|
|
268
|
+
case "ground": {
|
|
269
|
+
const m = /^#([0-9a-fA-F]{6})$/.exec(args[0] ?? "")
|
|
270
|
+
const noise = args[1] === undefined ? 0 : num(args[1])
|
|
271
|
+
if (!m || args.length > 2 || noise === null) {
|
|
272
|
+
errors.push(`${at}: #ground takes a colour like #4b4026 and optionally a grain strength 0..1`)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
const v = parseInt(m[1], 16)
|
|
276
|
+
// sRGB in the file, linear in the uniform — the same transfer the
|
|
277
|
+
// ground's own colour goes through on the way in.
|
|
278
|
+
const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))
|
|
279
|
+
d.ground = {
|
|
280
|
+
color: [lin(((v >> 16) & 255) / 255), lin(((v >> 8) & 255) / 255), lin((v & 255) / 255)],
|
|
281
|
+
noise: Math.min(Math.max(noise, 0), 1),
|
|
282
|
+
}
|
|
283
|
+
return
|
|
284
|
+
}
|
|
252
285
|
case "dissolve":
|
|
253
286
|
d.dissolve = true
|
|
254
287
|
return
|
|
@@ -225,19 +225,19 @@ fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
|
|
|
225
225
|
//
|
|
226
226
|
// Lit is the answer when it is off. Returning 0 would be "fully shadowed",
|
|
227
227
|
// which is the opposite of what a disabled shadow means.
|
|
228
|
-
let
|
|
229
|
-
if (
|
|
228
|
+
let castAmt = light.lights[0].direction.w;
|
|
229
|
+
if (castAmt <= 0.0) { return 1.0; }
|
|
230
230
|
if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
|
|
231
231
|
let biasedPos = worldPos + n * 0.08;
|
|
232
232
|
let c0 = lightVP.viewProj[0] * vec4f(biasedPos, 1.0);
|
|
233
233
|
let n0 = c0.xyz / max(c0.w, 1e-6);
|
|
234
234
|
if (all(abs(n0.xy) < vec2f(0.98)) && n0.z > 0.0 && n0.z < 1.0) {
|
|
235
|
-
return mix(1.0, sampleShadowNear(n0),
|
|
235
|
+
return mix(1.0, sampleShadowNear(n0), castAmt);
|
|
236
236
|
}
|
|
237
237
|
let c1 = lightVP.viewProj[1] * vec4f(biasedPos, 1.0);
|
|
238
238
|
let n1 = c1.xyz / max(c1.w, 1e-6);
|
|
239
239
|
if (all(abs(n1.xy) < vec2f(0.98)) && n1.z > 0.0 && n1.z < 1.0) {
|
|
240
|
-
return mix(1.0, sampleShadowFar(n1),
|
|
240
|
+
return mix(1.0, sampleShadowFar(n1), castAmt);
|
|
241
241
|
}
|
|
242
242
|
// Outside every cascade there is no occlusion information; lit is the only
|
|
243
243
|
// honest answer.
|