reze-engine 0.57.6 → 0.58.1
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 +31 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +231 -82
- 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/lights.d.ts +2 -10
- package/dist/shaders/lights.d.ts.map +1 -1
- package/dist/shaders/lights.js +4 -24
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +3 -17
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +2 -0
- package/dist/shaders/passes/grid.d.ts.map +1 -1
- package/dist/shaders/passes/grid.js +2 -0
- package/dist/shaders/passes/particles.d.ts +20 -2
- package/dist/shaders/passes/particles.d.ts.map +1 -1
- package/dist/shaders/passes/particles.js +77 -5
- 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 +7 -1
- package/dist/shaders/scene-light-api.d.ts +42 -0
- package/dist/shaders/scene-light-api.d.ts.map +1 -0
- package/dist/shaders/scene-light-api.js +144 -0
- package/dist/shaders/shadow-api.d.ts +29 -0
- package/dist/shaders/shadow-api.d.ts.map +1 -0
- package/dist/shaders/shadow-api.js +112 -0
- package/package.json +1 -1
- package/src/engine.ts +272 -96
- package/src/shaders/directives.ts +37 -4
- package/src/shaders/lights.ts +4 -24
- package/src/shaders/materials/common.ts +3 -17
- package/src/shaders/passes/composite.ts +2 -0
- package/src/shaders/passes/grid.ts +2 -0
- package/src/shaders/passes/particles.ts +93 -6
- package/src/shaders/passes/trails.ts +10 -1
- package/src/shaders/scene-light-api.ts +148 -0
package/src/engine.ts
CHANGED
|
@@ -113,6 +113,7 @@ import {
|
|
|
113
113
|
import {
|
|
114
114
|
buildParticleComputeShader,
|
|
115
115
|
buildParticleRenderShader,
|
|
116
|
+
PARTICLE_LIGHT_BINDING,
|
|
116
117
|
particleEntryPoints,
|
|
117
118
|
PARTICLE_STRIDE,
|
|
118
119
|
} from "./shaders/passes/particles"
|
|
@@ -1428,15 +1429,24 @@ interface EffectParticles {
|
|
|
1428
1429
|
counts: Uint32Array
|
|
1429
1430
|
compute: GPUComputePipeline
|
|
1430
1431
|
computeLayout: GPUBindGroupLayout
|
|
1431
|
-
|
|
1432
|
+
/** Indexed by the effect's GRID PARITY, so the pool samples the grid the grid
|
|
1433
|
+
* pass wrote this frame. Both entries are identical without a grid. */
|
|
1434
|
+
computeBinds: [GPUBindGroup, GPUBindGroup]
|
|
1435
|
+
/** The cutout's depth prepass, when the effect defined particleCover. Drawn
|
|
1436
|
+
* first, with the same bind group; `render` then tests equal. */
|
|
1437
|
+
depth: GPURenderPipeline | null
|
|
1432
1438
|
render: GPURenderPipeline
|
|
1433
1439
|
renderLayout: GPUBindGroupLayout
|
|
1434
|
-
|
|
1440
|
+
renderBinds: [GPUBindGroup, GPUBindGroup]
|
|
1435
1441
|
/** Same draw, the MIRRORED camera: how particles join the floor mirror. The
|
|
1436
1442
|
* billboards face whichever eye is bound, so one extra bind group is the
|
|
1437
1443
|
* whole cost. */
|
|
1438
|
-
|
|
1439
|
-
rebind: () => {
|
|
1444
|
+
mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
|
|
1445
|
+
rebind: () => {
|
|
1446
|
+
computeBinds: [GPUBindGroup, GPUBindGroup]
|
|
1447
|
+
renderBinds: [GPUBindGroup, GPUBindGroup]
|
|
1448
|
+
mirrorRenderBinds: [GPUBindGroup, GPUBindGroup]
|
|
1449
|
+
}
|
|
1440
1450
|
}
|
|
1441
1451
|
|
|
1442
1452
|
/**
|
|
@@ -1501,9 +1511,10 @@ interface EffectTrails {
|
|
|
1501
1511
|
data: Float32Array
|
|
1502
1512
|
pipeline: GPURenderPipeline
|
|
1503
1513
|
layout: GPUBindGroupLayout
|
|
1504
|
-
|
|
1514
|
+
/** Indexed by the effect's GRID PARITY, like the particle pool's. */
|
|
1515
|
+
binds: [GPUBindGroup, GPUBindGroup]
|
|
1505
1516
|
/** The mirrored camera's view of the same ribbons — the floor mirror's. */
|
|
1506
|
-
|
|
1517
|
+
mirrorBinds: [GPUBindGroup, GPUBindGroup]
|
|
1507
1518
|
/** The effect's declared parameters, or null when it declares none.
|
|
1508
1519
|
*
|
|
1509
1520
|
* Kept for the same reason `layout` is: both bind groups are REBUILT on every
|
|
@@ -1675,6 +1686,8 @@ interface EffectInstance {
|
|
|
1675
1686
|
* wins over the constant — see dissolveTimingsOf.
|
|
1676
1687
|
*/
|
|
1677
1688
|
dissolve: DissolveTimings | null
|
|
1689
|
+
/** `#ground`: the floor this effect asks for — colour, linear, and grain. */
|
|
1690
|
+
ground: { color: Vec3; noise: number } | null
|
|
1678
1691
|
/** This effect's OWN clock, as a uniform the field shader reads. Per effect
|
|
1679
1692
|
* because the shared one (viewU[6].x) is measured from the first installed
|
|
1680
1693
|
* effect's epoch, so everything later started mid-stream. Null when the
|
|
@@ -2063,7 +2076,12 @@ export class Engine {
|
|
|
2063
2076
|
* prefix sum and readback a compacted draw list would need every frame.
|
|
2064
2077
|
*/
|
|
2065
2078
|
/** Ceiling for `#particles`. Past this an author is asking for a stall. */
|
|
2066
|
-
|
|
2079
|
+
// Raised for the lawn: 65536 blades at real density is a 40-unit patch and
|
|
2080
|
+
// an 80-unit one wants four hundred thousand. A cutout pool's cost is
|
|
2081
|
+
// bounded by the pixels it covers, not by the count — the step is one
|
|
2082
|
+
// compute invocation per slot, the pool buffer is 48 bytes each (24 MB at
|
|
2083
|
+
// the cap), and a culled blade is a degenerate quad the rasteriser drops.
|
|
2084
|
+
private static readonly MAX_PARTICLES = 524288
|
|
2067
2085
|
private particleFrame = 0
|
|
2068
2086
|
/**
|
|
2069
2087
|
* The installed effect's persistent grid, or null when it declared none.
|
|
@@ -4306,9 +4324,9 @@ export class Engine {
|
|
|
4306
4324
|
for (const e of this.effects) {
|
|
4307
4325
|
if (e.particles) {
|
|
4308
4326
|
const b = e.particles.rebind()
|
|
4309
|
-
e.particles.
|
|
4310
|
-
e.particles.
|
|
4311
|
-
e.particles.
|
|
4327
|
+
e.particles.computeBinds = b.computeBinds
|
|
4328
|
+
e.particles.renderBinds = b.renderBinds
|
|
4329
|
+
e.particles.mirrorRenderBinds = b.mirrorRenderBinds
|
|
4312
4330
|
}
|
|
4313
4331
|
if (e.grid) e.grid.binds = [this.gridBindGroup(e.grid, 0), this.gridBindGroup(e.grid, 1)]
|
|
4314
4332
|
if (e.lights) e.lights.bind = this.lightEmitBindGroup(e.lights.layout, e.lights.uniform, e.lights.params)
|
|
@@ -4852,16 +4870,19 @@ export class Engine {
|
|
|
4852
4870
|
trails?.uniform.destroy()
|
|
4853
4871
|
return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration, readsCast: false }
|
|
4854
4872
|
}
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
particles = built.state
|
|
4859
|
-
}
|
|
4873
|
+
// THE GRID FIRST. Particles and ribbons READ it — a blade of grass reads the
|
|
4874
|
+
// bend a foot left there — so the textures have to exist before their bind
|
|
4875
|
+
// groups are built. Nothing reads them back, so the grid depends on neither.
|
|
4860
4876
|
if (gridEntryPoint(wgsl)) {
|
|
4861
4877
|
const built = await this.buildSim(wgsl, d, anchors, alias, paramsFor)
|
|
4862
4878
|
if (!built.ok) return abandon(built.diagnostics)
|
|
4863
4879
|
grid = built.state
|
|
4864
4880
|
}
|
|
4881
|
+
if (wantsParticles) {
|
|
4882
|
+
const built = await this.buildParticles(wgsl, d, anchors, alias, paramsFor, grid)
|
|
4883
|
+
if (!built.ok) return abandon(built.diagnostics)
|
|
4884
|
+
particles = built.state
|
|
4885
|
+
}
|
|
4865
4886
|
if (wantsTrails) {
|
|
4866
4887
|
// Only anchors that asked for `trail` have a path to draw; a ribbon on a
|
|
4867
4888
|
// bone recorded without one would read zeroes and paint a line to the origin.
|
|
@@ -4869,7 +4890,7 @@ export class Engine {
|
|
|
4869
4890
|
if (trailSlots === 0) {
|
|
4870
4891
|
return abandon(["a ribbon effect needs at least one #anchor <bone> trail"])
|
|
4871
4892
|
}
|
|
4872
|
-
const built = await this.buildTrails(wgsl, d, anchors, alias, paramsFor)
|
|
4893
|
+
const built = await this.buildTrails(wgsl, d, anchors, alias, paramsFor, grid)
|
|
4873
4894
|
if (!built.ok) return abandon(built.diagnostics)
|
|
4874
4895
|
trails = built.state
|
|
4875
4896
|
}
|
|
@@ -4949,6 +4970,7 @@ export class Engine {
|
|
|
4949
4970
|
simStep: 0,
|
|
4950
4971
|
simReset: false,
|
|
4951
4972
|
dissolve: d.dissolve ? dissolveConstants(authored) : null,
|
|
4973
|
+
ground: d.ground ? { color: new Vec3(...d.ground.color), noise: d.ground.noise } : null,
|
|
4952
4974
|
// Its OWN resolution, no longer the scene's: an effect that never asked
|
|
4953
4975
|
// for full res is not promoted because a neighbour did.
|
|
4954
4976
|
// FULL RESOLUTION UNLESS TOLD OTHERWISE.
|
|
@@ -5209,11 +5231,23 @@ export class Engine {
|
|
|
5209
5231
|
/** The effect's declared dials. Spliced into both stages and bound at 7,
|
|
5210
5232
|
* the same binding the composite gives them. */
|
|
5211
5233
|
params: EffectParamsBinding,
|
|
5234
|
+
/** This effect's own grid, already built, or null when it declared none.
|
|
5235
|
+
* Read-only here: the pool samples it, the grid pass owns it. */
|
|
5236
|
+
grid: EffectGrid | null,
|
|
5212
5237
|
): Promise<{ ok: true; state: EffectParticles } | { ok: false; diagnostics: string[] }> {
|
|
5213
5238
|
// No pragma means "some": an author who wrote the trio clearly wants
|
|
5214
5239
|
// particles, and failing over a missing comment would be pedantry.
|
|
5215
5240
|
const count = Math.min(d.particles || 1024, Engine.MAX_PARTICLES)
|
|
5216
|
-
const src = {
|
|
5241
|
+
const src = {
|
|
5242
|
+
wgsl,
|
|
5243
|
+
count,
|
|
5244
|
+
blend: d.particleBlend,
|
|
5245
|
+
bloom: d.bloom,
|
|
5246
|
+
paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
|
|
5247
|
+
gridSize: grid?.size ?? 0,
|
|
5248
|
+
cover: particleEntryPoints(wgsl).cover,
|
|
5249
|
+
}
|
|
5250
|
+
const prepass = src.blend === "cutout" && src.cover
|
|
5217
5251
|
// Sparks want to spawn where a trail is, so the particle stages see the same
|
|
5218
5252
|
// cast buffer the trail draw reads.
|
|
5219
5253
|
const cast = {
|
|
@@ -5267,7 +5301,7 @@ export class Engine {
|
|
|
5267
5301
|
// Declaring one set of flags for both layouts is what made the pipeline
|
|
5268
5302
|
// layout invalid, and the error surfaces later and unhelpfully as "invalid
|
|
5269
5303
|
// due to a previous error".
|
|
5270
|
-
const layoutFor = (storage: GPUBufferBindingType, visibility: number) =>
|
|
5304
|
+
const layoutFor = (storage: GPUBufferBindingType, visibility: number, shadow: boolean) =>
|
|
5271
5305
|
this.device.createBindGroupLayout({
|
|
5272
5306
|
entries: [
|
|
5273
5307
|
{ binding: 0, visibility, buffer: { type: storage } },
|
|
@@ -5282,31 +5316,70 @@ export class Engine {
|
|
|
5282
5316
|
// Only when the effect declared any: WGSL has no empty struct, so a
|
|
5283
5317
|
// param-less effect must not carry the decl OR the binding.
|
|
5284
5318
|
...(params.buffer ? [{ binding: 7, visibility, buffer: { type: "uniform" as const } }] : []),
|
|
5319
|
+
// The grid, ALWAYS — the accessor is compiled in unconditionally, and a
|
|
5320
|
+
// binding the shader declares must exist in the layout whether the
|
|
5321
|
+
// author calls it or not. With no grid it is the 1x1 of zeroes.
|
|
5322
|
+
{ binding: 8, visibility, texture: { sampleType: "float" as const } },
|
|
5323
|
+
{ binding: 9, visibility, sampler: { type: "filtering" as const } },
|
|
5324
|
+
// The scene's light, for rzShadow and rzWorldAmbient — the shading
|
|
5325
|
+
// stage only; the compute module compiles the stubs. See
|
|
5326
|
+
// scene-light-api.ts.
|
|
5327
|
+
...(shadow
|
|
5328
|
+
? [
|
|
5329
|
+
{ binding: PARTICLE_LIGHT_BINDING, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
|
|
5330
|
+
{ binding: PARTICLE_LIGHT_BINDING + 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
|
|
5331
|
+
{ binding: PARTICLE_LIGHT_BINDING + 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" as const } },
|
|
5332
|
+
{ binding: PARTICLE_LIGHT_BINDING + 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" as const } },
|
|
5333
|
+
{ binding: PARTICLE_LIGHT_BINDING + 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" as const } },
|
|
5334
|
+
]
|
|
5335
|
+
: []),
|
|
5285
5336
|
],
|
|
5286
5337
|
})
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5338
|
+
/** One per GRID PARITY, for the reason rebuildFieldBindGroup gives: the grid
|
|
5339
|
+
* alternates which texture is current, and rebuilding a single group every
|
|
5340
|
+
* frame is waste for a change that only ever toggles between two known
|
|
5341
|
+
* states. Both entries are the same view when there is no grid. */
|
|
5342
|
+
const bindFor = (layout: GPUBindGroupLayout, camera: GPUBuffer, shadow: boolean) =>
|
|
5343
|
+
[0, 1].map((parity) =>
|
|
5344
|
+
this.device.createBindGroup({
|
|
5345
|
+
layout,
|
|
5346
|
+
entries: [
|
|
5347
|
+
{ binding: 0, resource: { buffer } },
|
|
5348
|
+
{ binding: 1, resource: { buffer: uniform } },
|
|
5349
|
+
{ binding: 2, resource: { buffer: camera } },
|
|
5350
|
+
{ binding: 3, resource: { buffer: this.castBuffer } },
|
|
5351
|
+
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
5352
|
+
{ binding: 5, resource: { buffer: this.midiBuffer } },
|
|
5353
|
+
{ binding: 6, resource: { buffer: this.lyricsBuffer } },
|
|
5354
|
+
...(params.buffer ? [{ binding: 7, resource: { buffer: params.buffer } }] : []),
|
|
5355
|
+
{ binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
|
|
5356
|
+
{ binding: 9, resource: this.simSampler },
|
|
5357
|
+
...(shadow
|
|
5358
|
+
? [
|
|
5359
|
+
{ binding: PARTICLE_LIGHT_BINDING, resource: { buffer: this.lightUniformBuffer } },
|
|
5360
|
+
{ binding: PARTICLE_LIGHT_BINDING + 1, resource: { buffer: this.shadowLightVPBuffer } },
|
|
5361
|
+
{ binding: PARTICLE_LIGHT_BINDING + 2, resource: this.shadowMapDepthViews[0] },
|
|
5362
|
+
{ binding: PARTICLE_LIGHT_BINDING + 3, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
|
|
5363
|
+
{ binding: PARTICLE_LIGHT_BINDING + 4, resource: this.shadowComparisonSampler },
|
|
5364
|
+
]
|
|
5365
|
+
: []),
|
|
5366
|
+
],
|
|
5367
|
+
}),
|
|
5368
|
+
) as [GPUBindGroup, GPUBindGroup]
|
|
5301
5369
|
|
|
5302
|
-
const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE)
|
|
5303
|
-
const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT)
|
|
5370
|
+
const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE, false)
|
|
5371
|
+
const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, true)
|
|
5304
5372
|
|
|
5305
5373
|
// Additive keeps the destination and adds to it, and leaves alpha alone, so
|
|
5306
5374
|
// a glow does not claim coverage it never occluded. The MASK sums with it —
|
|
5307
5375
|
// otherwise an additive effect could never reach the bloom gate. Both live
|
|
5308
5376
|
// in scene-contract as the "particle-additive" class.
|
|
5377
|
+
//
|
|
5378
|
+
// Cutout blends exactly like alpha and differs only in depth and coverage,
|
|
5379
|
+
// below — so it shares alpha's targets rather than adding a class whose
|
|
5380
|
+
// blends would be a copy.
|
|
5309
5381
|
const targets = sceneTargetsFor(src.blend === "additive" ? "particle-additive" : "particle", this.sceneFormats)
|
|
5382
|
+
const cutout = src.blend === "cutout"
|
|
5310
5383
|
|
|
5311
5384
|
this.device.pushErrorScope("validation")
|
|
5312
5385
|
try {
|
|
@@ -5315,15 +5388,41 @@ export class Engine {
|
|
|
5315
5388
|
layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
|
|
5316
5389
|
compute: { module: computeModule, entryPoint: "main" },
|
|
5317
5390
|
})
|
|
5391
|
+
const renderLayoutObj = this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] })
|
|
5392
|
+
// The prepass, when the effect gave the cutout a cheap shape: coverage to
|
|
5393
|
+
// depth, colour targets at writeMask 0 (the depth-prepass class), and
|
|
5394
|
+
// the shading pass below then tests EQUAL and writes no depth.
|
|
5395
|
+
const depth = prepass
|
|
5396
|
+
? await this.device.createRenderPipelineAsync({
|
|
5397
|
+
label: "particle depth prepass pipeline",
|
|
5398
|
+
layout: renderLayoutObj,
|
|
5399
|
+
vertex: { module: renderModule, entryPoint: "vs" },
|
|
5400
|
+
fragment: { module: renderModule, entryPoint: "fsDepth", targets: sceneTargetsFor("depth-prepass", this.sceneFormats) },
|
|
5401
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
5402
|
+
depthStencil: { format: this.depthFormat, depthWriteEnabled: true, depthCompare: this.depthAhead },
|
|
5403
|
+
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
5404
|
+
})
|
|
5405
|
+
: null
|
|
5318
5406
|
const render = await this.device.createRenderPipelineAsync({
|
|
5319
5407
|
label: "particle render pipeline",
|
|
5320
|
-
layout:
|
|
5408
|
+
layout: renderLayoutObj,
|
|
5321
5409
|
vertex: { module: renderModule, entryPoint: "vs" },
|
|
5322
5410
|
fragment: { module: renderModule, entryPoint: "fs", targets },
|
|
5323
5411
|
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
5324
5412
|
// Tested but not WRITTEN: particles are transparent, so writing depth
|
|
5325
5413
|
// would make whichever quad drew first occlude the ones behind it.
|
|
5326
|
-
|
|
5414
|
+
//
|
|
5415
|
+
// Except a CUTOUT, which is opaque where it is drawn at all. Its alpha
|
|
5416
|
+
// becomes per-sample coverage (in the shader — see FSOut.samples, the
|
|
5417
|
+
// HDR target has no alpha for the hardware path), so its edges stay
|
|
5418
|
+
// antialiased under MSAA while its depth is real — and a blade behind
|
|
5419
|
+
// another blade is then rejected at the depth test rather than shaded
|
|
5420
|
+
// and blended under it.
|
|
5421
|
+
// For a lawn stacked ten blades deep that is the difference between
|
|
5422
|
+
// shading ten layers and shading about three.
|
|
5423
|
+
depthStencil: prepass
|
|
5424
|
+
? { format: this.depthFormat, depthWriteEnabled: false, depthCompare: "equal" }
|
|
5425
|
+
: { format: this.depthFormat, depthWriteEnabled: cutout, depthCompare: this.depthAhead },
|
|
5327
5426
|
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
5328
5427
|
})
|
|
5329
5428
|
const scoped = await this.device.popErrorScope()
|
|
@@ -5345,15 +5444,16 @@ export class Engine {
|
|
|
5345
5444
|
counts: uniformView.uints,
|
|
5346
5445
|
compute,
|
|
5347
5446
|
computeLayout,
|
|
5348
|
-
|
|
5447
|
+
computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
|
|
5448
|
+
depth,
|
|
5349
5449
|
render,
|
|
5350
5450
|
renderLayout,
|
|
5351
|
-
|
|
5352
|
-
|
|
5451
|
+
renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
|
|
5452
|
+
mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
|
|
5353
5453
|
rebind: () => ({
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5454
|
+
computeBinds: bindFor(computeLayout, this.cameraUniformBuffer, false),
|
|
5455
|
+
renderBinds: bindFor(renderLayout, this.cameraUniformBuffer, true),
|
|
5456
|
+
mirrorRenderBinds: bindFor(renderLayout, this.mirrorCameraBuffer, true),
|
|
5357
5457
|
}),
|
|
5358
5458
|
},
|
|
5359
5459
|
}
|
|
@@ -5553,7 +5653,7 @@ export class Engine {
|
|
|
5553
5653
|
this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer as ArrayBuffer)
|
|
5554
5654
|
const cp = encoder.beginComputePass({ label: "particles" })
|
|
5555
5655
|
cp.setPipeline(p.compute)
|
|
5556
|
-
cp.setBindGroup(0, p.
|
|
5656
|
+
cp.setBindGroup(0, p.computeBinds[e.grid?.parity ?? 0])
|
|
5557
5657
|
cp.dispatchWorkgroups(Math.ceil(p.count / 64))
|
|
5558
5658
|
cp.end()
|
|
5559
5659
|
}
|
|
@@ -5564,8 +5664,15 @@ export class Engine {
|
|
|
5564
5664
|
for (const e of this.effects) {
|
|
5565
5665
|
const p = e.particles
|
|
5566
5666
|
if (!p || e.weight === 0) continue
|
|
5667
|
+
const parity = e.grid?.parity ?? 0
|
|
5668
|
+
const bind = view === "mirror" ? p.mirrorRenderBinds[parity] : p.renderBinds[parity]
|
|
5669
|
+
if (p.depth) {
|
|
5670
|
+
pass.setPipeline(p.depth)
|
|
5671
|
+
pass.setBindGroup(0, bind)
|
|
5672
|
+
pass.draw(6, p.count)
|
|
5673
|
+
}
|
|
5567
5674
|
pass.setPipeline(p.render)
|
|
5568
|
-
pass.setBindGroup(0,
|
|
5675
|
+
pass.setBindGroup(0, bind)
|
|
5569
5676
|
pass.draw(6, p.count)
|
|
5570
5677
|
}
|
|
5571
5678
|
}
|
|
@@ -5590,6 +5697,9 @@ export class Engine {
|
|
|
5590
5697
|
alias: number[],
|
|
5591
5698
|
/** The effect's declared dials, spliced and bound at 7 as everywhere else. */
|
|
5592
5699
|
params: EffectParamsBinding,
|
|
5700
|
+
/** This effect's own grid, already built, or null. Ribbons read it; the grid
|
|
5701
|
+
* pass owns it. */
|
|
5702
|
+
grid: EffectGrid | null,
|
|
5593
5703
|
): Promise<{ ok: true; state: EffectTrails } | { ok: false; diagnostics: string[] }> {
|
|
5594
5704
|
// `slots` here is how many RIBBONS to draw — one per trailed anchor — which
|
|
5595
5705
|
// is a different number from the anchor ADDRESS SPACE the accessors index
|
|
@@ -5600,7 +5710,15 @@ export class Engine {
|
|
|
5600
5710
|
// nothing before: ribbon i was read as anchor slot i.
|
|
5601
5711
|
const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0)
|
|
5602
5712
|
const slots = ribbonSlots.length
|
|
5603
|
-
const src = {
|
|
5713
|
+
const src = {
|
|
5714
|
+
wgsl,
|
|
5715
|
+
slots,
|
|
5716
|
+
ribbonSlots,
|
|
5717
|
+
blend: (d.particleBlend === "additive" ? "additive" : "alpha") as "alpha" | "additive",
|
|
5718
|
+
bloom: d.bloom,
|
|
5719
|
+
paramsDecl: params.wgsl(EFFECT_PARAMS_BINDING),
|
|
5720
|
+
gridSize: grid?.size ?? 0,
|
|
5721
|
+
}
|
|
5604
5722
|
const code = buildTrailShader(src, {
|
|
5605
5723
|
subjects: MAX_EFFECT_SUBJECTS,
|
|
5606
5724
|
samples: TRAIL_SAMPLES,
|
|
@@ -5647,6 +5765,13 @@ export class Engine {
|
|
|
5647
5765
|
...(params.buffer
|
|
5648
5766
|
? [{ binding: 7, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } }]
|
|
5649
5767
|
: []),
|
|
5768
|
+
// The grid, always — compiled in whether the author calls it or not.
|
|
5769
|
+
{
|
|
5770
|
+
binding: 8,
|
|
5771
|
+
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
|
5772
|
+
texture: { sampleType: "float" as const },
|
|
5773
|
+
},
|
|
5774
|
+
{ binding: 9, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, sampler: { type: "filtering" as const } },
|
|
5650
5775
|
],
|
|
5651
5776
|
})
|
|
5652
5777
|
// TWO targets, the scene pass's own: HDR colour and the aux (bloom mask,
|
|
@@ -5692,30 +5817,8 @@ export class Engine {
|
|
|
5692
5817
|
data: new Float32Array(4),
|
|
5693
5818
|
pipeline,
|
|
5694
5819
|
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
|
-
}),
|
|
5820
|
+
binds: this.trailBindGroups(layout, uniform, params.buffer, this.cameraUniformBuffer, grid),
|
|
5821
|
+
mirrorBinds: this.trailBindGroups(layout, uniform, params.buffer, this.mirrorCameraBuffer, grid),
|
|
5719
5822
|
},
|
|
5720
5823
|
}
|
|
5721
5824
|
} catch (e) {
|
|
@@ -5776,7 +5879,8 @@ export class Engine {
|
|
|
5776
5879
|
this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
|
|
5777
5880
|
}
|
|
5778
5881
|
pass.setPipeline(t.pipeline)
|
|
5779
|
-
|
|
5882
|
+
const parity = e.grid?.parity ?? 0
|
|
5883
|
+
pass.setBindGroup(0, view === "mirror" ? t.mirrorBinds[parity] : t.binds[parity])
|
|
5780
5884
|
pass.draw(6, t.slots * live * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS)
|
|
5781
5885
|
}
|
|
5782
5886
|
}
|
|
@@ -5904,34 +6008,45 @@ export class Engine {
|
|
|
5904
6008
|
// one, so it has to be part of every group built against that layout —
|
|
5905
6009
|
// including this one. Omitting it here is not a missing uniform, it is a
|
|
5906
6010
|
// 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
|
-
})
|
|
6011
|
+
t.binds = this.trailBindGroups(t.layout, t.uniform, t.params, this.cameraUniformBuffer, e.grid)
|
|
6012
|
+
t.mirrorBinds = this.trailBindGroups(t.layout, t.uniform, t.params, this.mirrorCameraBuffer, e.grid)
|
|
5932
6013
|
}
|
|
5933
6014
|
}
|
|
5934
6015
|
|
|
6016
|
+
/**
|
|
6017
|
+
* A ribbon's bind group, ONE PER GRID PARITY.
|
|
6018
|
+
*
|
|
6019
|
+
* One author for both the build and the rebind, because they drifted once
|
|
6020
|
+
* already: the params binding is part of the LAYOUT whenever the effect
|
|
6021
|
+
* declared one, so it has to be part of every group built against that
|
|
6022
|
+
* layout, and a group missing it is not a missing uniform but one WebGPU
|
|
6023
|
+
* refuses outright, taking the whole frame down.
|
|
6024
|
+
*/
|
|
6025
|
+
private trailBindGroups(
|
|
6026
|
+
layout: GPUBindGroupLayout,
|
|
6027
|
+
uniform: GPUBuffer,
|
|
6028
|
+
params: GPUBuffer | null,
|
|
6029
|
+
camera: GPUBuffer,
|
|
6030
|
+
grid: EffectGrid | null,
|
|
6031
|
+
): [GPUBindGroup, GPUBindGroup] {
|
|
6032
|
+
return [0, 1].map((parity) =>
|
|
6033
|
+
this.device.createBindGroup({
|
|
6034
|
+
layout,
|
|
6035
|
+
entries: [
|
|
6036
|
+
{ binding: 0, resource: { buffer: this.castBuffer } },
|
|
6037
|
+
{ binding: 1, resource: { buffer: uniform } },
|
|
6038
|
+
{ binding: 2, resource: { buffer: camera } },
|
|
6039
|
+
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
6040
|
+
{ binding: 5, resource: { buffer: this.midiBuffer } },
|
|
6041
|
+
{ binding: 6, resource: { buffer: this.lyricsBuffer } },
|
|
6042
|
+
...(params ? [{ binding: EFFECT_PARAMS_BINDING, resource: { buffer: params } }] : []),
|
|
6043
|
+
{ binding: 8, resource: grid ? grid.read[parity] : this.simFallbackView },
|
|
6044
|
+
{ binding: 9, resource: this.simSampler },
|
|
6045
|
+
],
|
|
6046
|
+
}),
|
|
6047
|
+
) as [GPUBindGroup, GPUBindGroup]
|
|
6048
|
+
}
|
|
6049
|
+
|
|
5935
6050
|
private releaseParticles(): void {
|
|
5936
6051
|
for (const e of this.effects) {
|
|
5937
6052
|
e.particles?.buffer.destroy()
|
|
@@ -7294,8 +7409,10 @@ export class Engine {
|
|
|
7294
7409
|
},
|
|
7295
7410
|
})
|
|
7296
7411
|
|
|
7412
|
+
// The matrices, then one vec4: the caster sphere, which effects' rzShadow
|
|
7413
|
+
// tests before any tap. The materials and the ground bind the matrices only.
|
|
7297
7414
|
this.shadowLightVPBuffer = this.device.createBuffer({
|
|
7298
|
-
size: 64 * SHADOW_CASCADES.length,
|
|
7415
|
+
size: 64 * SHADOW_CASCADES.length + 16,
|
|
7299
7416
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
7300
7417
|
})
|
|
7301
7418
|
this.shadowCascadeVPBuffers = SHADOW_CASCADES.map((_, i) =>
|
|
@@ -12247,6 +12364,61 @@ export class Engine {
|
|
|
12247
12364
|
/** The ground's uniform block, kept so the caster sphere can be refreshed in
|
|
12248
12365
|
* it every frame rather than rebuilding the buffer (addGround allocates). */
|
|
12249
12366
|
private groundMaterialData: Float32Array | null = null
|
|
12367
|
+
/** What addGround was given, so an effect's `#ground` can be lifted again
|
|
12368
|
+
* without rebuilding the floor. */
|
|
12369
|
+
private groundBaseColor = new Vec3(1, 1, 1)
|
|
12370
|
+
private groundBaseNoise = 0
|
|
12371
|
+
|
|
12372
|
+
/**
|
|
12373
|
+
* Dress the floor in whatever the installed effects ask for.
|
|
12374
|
+
*
|
|
12375
|
+
* The LAST effect declaring `#ground` wins, faded by its own weight so a
|
|
12376
|
+
* scheduled lawn brings its soil in with it and takes it away again. Three
|
|
12377
|
+
* floats at the top of the ground block, written only when they change —
|
|
12378
|
+
* the same bargain writeGroundCasterSphere strikes, for the same reason.
|
|
12379
|
+
*/
|
|
12380
|
+
private writeGroundDress(): void {
|
|
12381
|
+
const gb = this.groundMaterialData
|
|
12382
|
+
if (!gb || !this.groundShadowMaterialBuffer) return
|
|
12383
|
+
let want = this.groundBaseColor
|
|
12384
|
+
let noise = this.groundBaseNoise
|
|
12385
|
+
for (const e of this.effects) {
|
|
12386
|
+
if (!e.ground || e.weight <= 0) continue
|
|
12387
|
+
const w = e.weight
|
|
12388
|
+
want = new Vec3(
|
|
12389
|
+
want.x + (e.ground.color.x - want.x) * w,
|
|
12390
|
+
want.y + (e.ground.color.y - want.y) * w,
|
|
12391
|
+
want.z + (e.ground.color.z - want.z) * w,
|
|
12392
|
+
)
|
|
12393
|
+
noise += (e.ground.noise - noise) * w
|
|
12394
|
+
}
|
|
12395
|
+
if (gb[0] === want.x && gb[1] === want.y && gb[2] === want.z && gb[10] === noise) return
|
|
12396
|
+
gb[0] = want.x
|
|
12397
|
+
gb[1] = want.y
|
|
12398
|
+
gb[2] = want.z
|
|
12399
|
+
gb[10] = noise
|
|
12400
|
+
// Two writes rather than one over the span: the seven floats between hold
|
|
12401
|
+
// the fades and the shadow strength, and this is not the place that owns
|
|
12402
|
+
// them.
|
|
12403
|
+
this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb.subarray(0, 3) as Float32Array<ArrayBuffer>)
|
|
12404
|
+
this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 40, gb.subarray(10, 11) as Float32Array<ArrayBuffer>)
|
|
12405
|
+
}
|
|
12406
|
+
|
|
12407
|
+
/** The caster sphere as last written after the cascade matrices; NaN so the
|
|
12408
|
+
* first frame always writes. */
|
|
12409
|
+
private shadowCastersWritten = new Float32Array([NaN, NaN, NaN, NaN])
|
|
12410
|
+
|
|
12411
|
+
/**
|
|
12412
|
+
* The same sphere, into the cascade block after its matrices — rzShadow's
|
|
12413
|
+
* copy. There whether or not a ground exists: a lawn is often the only floor.
|
|
12414
|
+
*/
|
|
12415
|
+
private writeShadowCasters(): void {
|
|
12416
|
+
const w = this.shadowCastersWritten
|
|
12417
|
+
const c = this.casterSphere
|
|
12418
|
+
if (w[0] === c[0] && w[1] === c[1] && w[2] === c[2] && w[3] === c[3]) return
|
|
12419
|
+
w.set(c)
|
|
12420
|
+
this.device.queue.writeBuffer(this.shadowLightVPBuffer, 64 * SHADOW_CASCADES.length, c as Float32Array<ArrayBuffer>)
|
|
12421
|
+
}
|
|
12250
12422
|
|
|
12251
12423
|
/**
|
|
12252
12424
|
* Push this frame's caster sphere into the ground's uniform.
|
|
@@ -13221,6 +13393,8 @@ export class Engine {
|
|
|
13221
13393
|
// keeping the uniform vec4-aligned.
|
|
13222
13394
|
const gb = new Float32Array(24)
|
|
13223
13395
|
this.groundMaterialData = gb
|
|
13396
|
+
this.groundBaseColor = new Vec3(diffuseColor.x, diffuseColor.y, diffuseColor.z)
|
|
13397
|
+
this.groundBaseNoise = noiseStrength
|
|
13224
13398
|
gb[0] = diffuseColor.x
|
|
13225
13399
|
gb[1] = diffuseColor.y
|
|
13226
13400
|
gb[2] = diffuseColor.z
|
|
@@ -15242,6 +15416,8 @@ export class Engine {
|
|
|
15242
15416
|
if (hasModels) this.dispatchCull(encoder)
|
|
15243
15417
|
// After the cull, which is what recomputes the spheres it unions.
|
|
15244
15418
|
this.writeGroundCasterSphere()
|
|
15419
|
+
this.writeShadowCasters()
|
|
15420
|
+
this.writeGroundDress()
|
|
15245
15421
|
|
|
15246
15422
|
// After the cull, because a rebuild there can reallocate the argument
|
|
15247
15423
|
// 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
|