reze-engine 0.42.2 → 0.43.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/README.md +29 -1
- package/dist/camera-animation.d.ts +8 -0
- package/dist/camera-animation.d.ts.map +1 -1
- package/dist/camera-animation.js +10 -0
- package/dist/engine.d.ts +110 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +746 -23
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +29 -0
- package/dist/physics/autofit.d.ts +147 -0
- package/dist/physics/autofit.d.ts.map +1 -0
- package/dist/physics/autofit.js +501 -0
- package/dist/shaders/audio-api.d.ts +3 -0
- package/dist/shaders/audio-api.d.ts.map +1 -0
- package/dist/shaders/audio-api.js +81 -0
- package/dist/shaders/passes/composite.d.ts +10 -0
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +75 -12
- package/dist/shaders/passes/ground-noise.d.ts +7 -0
- package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
- package/dist/shaders/passes/ground-noise.js +88 -0
- package/dist/shaders/passes/particles.d.ts +58 -0
- package/dist/shaders/passes/particles.d.ts.map +1 -0
- package/dist/shaders/passes/particles.js +351 -0
- package/dist/shaders/passes/trails.d.ts +47 -0
- package/dist/shaders/passes/trails.d.ts.map +1 -0
- package/dist/shaders/passes/trails.js +375 -0
- package/package.json +2 -2
- package/src/camera-animation.ts +11 -0
- package/src/engine.ts +806 -20
- package/src/model.ts +28 -0
- package/src/shaders/audio-api.ts +82 -0
- package/src/shaders/passes/composite.ts +76 -11
- package/src/shaders/passes/particles.ts +398 -0
- package/src/shaders/passes/trails.ts +406 -0
- package/dist/physics-debug.d.ts +0 -30
- package/dist/physics-debug.d.ts.map +0 -1
- package/dist/physics-debug.js +0 -526
- package/dist/shaders/materials/body.d.ts +0 -2
- package/dist/shaders/materials/body.d.ts.map +0 -1
- package/dist/shaders/materials/body.js +0 -95
- package/dist/shaders/materials/cloth_rough.d.ts +0 -2
- package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
- package/dist/shaders/materials/cloth_rough.js +0 -69
- package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
- package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
- package/dist/shaders/materials/cloth_smooth.js +0 -61
- package/dist/shaders/materials/default.d.ts +0 -2
- package/dist/shaders/materials/default.d.ts.map +0 -1
- package/dist/shaders/materials/default.js +0 -43
- package/dist/shaders/materials/eye.d.ts +0 -2
- package/dist/shaders/materials/eye.d.ts.map +0 -1
- package/dist/shaders/materials/eye.js +0 -60
- package/dist/shaders/materials/face.d.ts +0 -2
- package/dist/shaders/materials/face.d.ts.map +0 -1
- package/dist/shaders/materials/face.js +0 -95
- package/dist/shaders/materials/hair.d.ts +0 -2
- package/dist/shaders/materials/hair.d.ts.map +0 -1
- package/dist/shaders/materials/hair.js +0 -90
- package/dist/shaders/materials/metal.d.ts +0 -2
- package/dist/shaders/materials/metal.d.ts.map +0 -1
- package/dist/shaders/materials/metal.js +0 -77
- package/dist/shaders/materials/mmd_classic.d.ts +0 -2
- package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
- package/dist/shaders/materials/mmd_classic.js +0 -66
- package/dist/shaders/materials/stockings.d.ts +0 -2
- package/dist/shaders/materials/stockings.d.ts.map +0 -1
- package/dist/shaders/materials/stockings.js +0 -122
- package/dist/shaders/passes/physics-debug.d.ts +0 -2
- package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
- package/dist/shaders/passes/physics-debug.js +0 -69
package/src/engine.ts
CHANGED
|
@@ -36,12 +36,23 @@ import {
|
|
|
36
36
|
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut"
|
|
37
37
|
import {
|
|
38
38
|
buildCompositeShader,
|
|
39
|
+
buildFieldShader,
|
|
39
40
|
parseEffectAnchors,
|
|
40
41
|
EFFECT_ANCHORS,
|
|
41
42
|
EFFECT_SUBJECTS,
|
|
42
43
|
EFFECT_TRAIL_BASE,
|
|
43
44
|
EFFECT_TRAIL_SAMPLES,
|
|
44
45
|
} from "./shaders/passes/composite"
|
|
46
|
+
import {
|
|
47
|
+
buildParticleComputeShader,
|
|
48
|
+
buildParticleRenderShader,
|
|
49
|
+
parseParticleBlend,
|
|
50
|
+
parseParticleBloom,
|
|
51
|
+
parseParticleCount,
|
|
52
|
+
particleEntryPoints,
|
|
53
|
+
PARTICLE_STRIDE,
|
|
54
|
+
} from "./shaders/passes/particles"
|
|
55
|
+
import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails"
|
|
45
56
|
import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
|
|
46
57
|
import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
|
|
47
58
|
import { compileGraph, type CompileOptions, type StyleSlot } from "./graph/compile"
|
|
@@ -831,6 +842,75 @@ export class Engine {
|
|
|
831
842
|
private multisampleMaskTexture!: GPUTexture
|
|
832
843
|
private maskResolveTexture!: GPUTexture
|
|
833
844
|
private maskResolveView!: GPUTextureView
|
|
845
|
+
/**
|
|
846
|
+
* The installed effect's particle system, or null when it declared none.
|
|
847
|
+
*
|
|
848
|
+
* A fixed pool: the count is chosen at install and the slots recycle, so there
|
|
849
|
+
* is no allocation and no spawn-rate bookkeeping in the hot path. Dead slots
|
|
850
|
+
* cost a degenerate quad the rasteriser rejects, which is cheaper than the
|
|
851
|
+
* prefix sum and readback a compacted draw list would need every frame.
|
|
852
|
+
*/
|
|
853
|
+
private particles: {
|
|
854
|
+
count: number
|
|
855
|
+
buffer: GPUBuffer
|
|
856
|
+
uniform: GPUBuffer
|
|
857
|
+
data: Float32Array
|
|
858
|
+
counts: Uint32Array
|
|
859
|
+
compute: GPUComputePipeline
|
|
860
|
+
computeLayout: GPUBindGroupLayout
|
|
861
|
+
computeBind: GPUBindGroup
|
|
862
|
+
render: GPURenderPipeline
|
|
863
|
+
renderLayout: GPUBindGroupLayout
|
|
864
|
+
renderBind: GPUBindGroup
|
|
865
|
+
rebind: () => { computeBind: GPUBindGroup; renderBind: GPUBindGroup }
|
|
866
|
+
} | null = null
|
|
867
|
+
/** Ceiling for `// @particles`. Past this an author is asking for a stall. */
|
|
868
|
+
private static readonly MAX_PARTICLES = 65536
|
|
869
|
+
private particleFrame = 0
|
|
870
|
+
/**
|
|
871
|
+
* The installed effect's ribbons, or null when it declared none.
|
|
872
|
+
*
|
|
873
|
+
* No buffer of its own: it reads the very same path history the field-based
|
|
874
|
+
* ribbon read through rzTrail, so a trail costs one draw and nothing recorded.
|
|
875
|
+
*/
|
|
876
|
+
private trails: {
|
|
877
|
+
instances: number
|
|
878
|
+
uniform: GPUBuffer
|
|
879
|
+
data: Float32Array
|
|
880
|
+
pipeline: GPURenderPipeline
|
|
881
|
+
layout: GPUBindGroupLayout
|
|
882
|
+
bind: GPUBindGroup
|
|
883
|
+
} | null = null
|
|
884
|
+
/** The ribbons' own offscreen target — max-blended, composited after tone map. */
|
|
885
|
+
private trailLayerTexture: GPUTexture | null = null
|
|
886
|
+
private trailLayerView: GPUTextureView | null = null
|
|
887
|
+
/** 1×1 transparent stand-in so the composite layout binds with no trails installed. */
|
|
888
|
+
private trailFallbackView!: GPUTextureView
|
|
889
|
+
/** The field layer: user background/foreground mounts at half resolution. */
|
|
890
|
+
private fieldBgTexture: GPUTexture | null = null
|
|
891
|
+
private fieldBgView: GPUTextureView | null = null
|
|
892
|
+
private fieldFgTexture: GPUTexture | null = null
|
|
893
|
+
private fieldFgView: GPUTextureView | null = null
|
|
894
|
+
private fieldUniformBuffer!: GPUBuffer
|
|
895
|
+
/** 2 = half resolution (the default); 1 = full, for effects that declare
|
|
896
|
+
* `// @fullres` because they draw sub-pixel detail no upsample can carry. */
|
|
897
|
+
private fieldScale = 2
|
|
898
|
+
private fieldFullW = 0
|
|
899
|
+
private fieldFullH = 0
|
|
900
|
+
private fieldPipeline: GPURenderPipeline | null = null
|
|
901
|
+
private fieldBindGroupLayout!: GPUBindGroupLayout
|
|
902
|
+
private fieldPipelineLayout!: GPUPipelineLayout
|
|
903
|
+
private fieldBindGroup: GPUBindGroup | null = null
|
|
904
|
+
/**
|
|
905
|
+
* The audio analysis buffer every effect module binds: header
|
|
906
|
+
* [frames, bands, secondsPerFrame, audioTime], then [level, band0..bandN-1]
|
|
907
|
+
* per frame. Precomputed by the host for the whole track — never a live
|
|
908
|
+
* analyser, which would render silence during an export. Falls back to four
|
|
909
|
+
* zeroes (frames = 0) so layouts always bind.
|
|
910
|
+
*/
|
|
911
|
+
private audioBuffer!: GPUBuffer
|
|
912
|
+
private audioFallbackBuffer!: GPUBuffer
|
|
913
|
+
private audioTimeScratch = new Float32Array(2)
|
|
834
914
|
private renderPassDescriptor!: GPURenderPassDescriptor
|
|
835
915
|
private compositePassDescriptor!: GPURenderPassDescriptor
|
|
836
916
|
// Two specialized composite pipelines via WGSL pipeline-override constants.
|
|
@@ -1268,6 +1348,51 @@ export class Engine {
|
|
|
1268
1348
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
1269
1349
|
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
1270
1350
|
{ binding: 11, resource: { buffer: this.castBuffer } },
|
|
1351
|
+
{ binding: 12, resource: this.trails && this.trailLayerView ? this.trailLayerView : this.trailFallbackView },
|
|
1352
|
+
{ binding: 13, resource: { buffer: this.audioBuffer } },
|
|
1353
|
+
{ binding: 15, resource: this.fieldBgView ?? this.trailFallbackView },
|
|
1354
|
+
{ binding: 16, resource: this.fieldFgView ?? this.trailFallbackView },
|
|
1355
|
+
],
|
|
1356
|
+
})
|
|
1357
|
+
this.rebuildFieldBindGroup()
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
private createFieldTargets(): void {
|
|
1361
|
+
if (!this.device || this.fieldFullW === 0) return
|
|
1362
|
+
const w = Math.max(1, Math.ceil(this.fieldFullW / this.fieldScale))
|
|
1363
|
+
const h = Math.max(1, Math.ceil(this.fieldFullH / this.fieldScale))
|
|
1364
|
+
this.fieldBgTexture?.destroy()
|
|
1365
|
+
this.fieldFgTexture?.destroy()
|
|
1366
|
+
this.fieldBgTexture = this.device.createTexture({
|
|
1367
|
+
label: "field layer (background)",
|
|
1368
|
+
size: [w, h],
|
|
1369
|
+
format: "rgba16float",
|
|
1370
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1371
|
+
})
|
|
1372
|
+
this.fieldFgTexture = this.device.createTexture({
|
|
1373
|
+
label: "field layer (foreground)",
|
|
1374
|
+
size: [w, h],
|
|
1375
|
+
format: "rgba16float",
|
|
1376
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1377
|
+
})
|
|
1378
|
+
this.fieldBgView = this.fieldBgTexture.createView()
|
|
1379
|
+
this.fieldFgView = this.fieldFgTexture.createView()
|
|
1380
|
+
this.device.queue.writeBuffer(this.fieldUniformBuffer, 0, new Float32Array([w, h, this.fieldFullW, this.fieldFullH]))
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
private rebuildFieldBindGroup(): void {
|
|
1384
|
+
if (!this.device || !this.depthReadView || !this.fieldUniformBuffer) return
|
|
1385
|
+
this.fieldBindGroup = this.device.createBindGroup({
|
|
1386
|
+
label: "field layer bind group",
|
|
1387
|
+
layout: this.fieldBindGroupLayout,
|
|
1388
|
+
entries: [
|
|
1389
|
+
{ binding: 3, resource: { buffer: this.compositeUniformBuffer } },
|
|
1390
|
+
{ binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
|
|
1391
|
+
{ binding: 8, resource: this.depthReadView },
|
|
1392
|
+
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
1393
|
+
{ binding: 11, resource: { buffer: this.castBuffer } },
|
|
1394
|
+
{ binding: 13, resource: { buffer: this.audioBuffer } },
|
|
1395
|
+
{ binding: 14, resource: { buffer: this.fieldUniformBuffer } },
|
|
1271
1396
|
],
|
|
1272
1397
|
})
|
|
1273
1398
|
}
|
|
@@ -1373,6 +1498,9 @@ export class Engine {
|
|
|
1373
1498
|
if (wgsl === null) {
|
|
1374
1499
|
this.effect?.paramsBuffer?.destroy()
|
|
1375
1500
|
this.effect = null
|
|
1501
|
+
this.releaseParticles()
|
|
1502
|
+
this.releaseTrails()
|
|
1503
|
+
this.fieldPipeline = null
|
|
1376
1504
|
const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) })
|
|
1377
1505
|
this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)")
|
|
1378
1506
|
this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)")
|
|
@@ -1387,12 +1515,62 @@ export class Engine {
|
|
|
1387
1515
|
// to one — those never follow `fn`.
|
|
1388
1516
|
const hasBackground = /\bfn\s+background\s*\(/.test(wgsl)
|
|
1389
1517
|
const hasForeground = /\bfn\s+foreground\s*\(/.test(wgsl)
|
|
1390
|
-
|
|
1518
|
+
// Particles are a THIRD mount, declared the same way — by the functions the
|
|
1519
|
+
// source defines. All three are required together: a pool with no shader to
|
|
1520
|
+
// draw it, or a draw with nothing spawning into it, is a silent blank rather
|
|
1521
|
+
// than an error, which is the worst way for an effect to fail.
|
|
1522
|
+
const pe = particleEntryPoints(wgsl)
|
|
1523
|
+
const wantsParticles = pe.init || pe.step || pe.shade
|
|
1524
|
+
const te = trailEntryPoints(wgsl)
|
|
1525
|
+
const wantsTrails = te.width || te.shade
|
|
1526
|
+
if (wantsTrails && !(te.width && te.shade)) {
|
|
1527
|
+
return {
|
|
1528
|
+
ok: false,
|
|
1529
|
+
diagnostics: [
|
|
1530
|
+
`a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
|
|
1531
|
+
`fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
|
|
1532
|
+
],
|
|
1533
|
+
mounts: noMounts,
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
|
|
1537
|
+
const missing = [
|
|
1538
|
+
pe.init ? null : "fn particleInit(id: u32, seed: f32) -> Particle",
|
|
1539
|
+
pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
|
|
1540
|
+
pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
|
|
1541
|
+
].filter(Boolean)
|
|
1542
|
+
return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts }
|
|
1543
|
+
}
|
|
1544
|
+
// One file, one kind — for now.
|
|
1545
|
+
//
|
|
1546
|
+
// The two kinds compile into different modules: field functions belong to the
|
|
1547
|
+
// composite pass, particle functions to the particle pair. A file holding both
|
|
1548
|
+
// would have to be spliced into both, and each module would then need the
|
|
1549
|
+
// OTHER's scaffolding (the Particle struct in the composite; the composite's
|
|
1550
|
+
// uniforms in the particle stages) for the dead half to compile — several
|
|
1551
|
+
// declarations that exist only so unused code type-checks, and a handful of
|
|
1552
|
+
// accessors that would silently return zero on the wrong side. Splitting into
|
|
1553
|
+
// two effects costs the author nothing once a scene can hold a list, and this
|
|
1554
|
+
// says so plainly instead of failing with "unresolved type Particle" from a
|
|
1555
|
+
// pass they did not know they were compiling into.
|
|
1556
|
+
if ((wantsParticles || wantsTrails) && (hasBackground || hasForeground)) {
|
|
1557
|
+
return {
|
|
1558
|
+
ok: false,
|
|
1559
|
+
diagnostics: [
|
|
1560
|
+
"an effect declares field mounts (background/foreground) or particles, not both — " +
|
|
1561
|
+
"split them into two effects",
|
|
1562
|
+
],
|
|
1563
|
+
mounts: noMounts,
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails) {
|
|
1391
1567
|
return {
|
|
1392
1568
|
ok: false,
|
|
1393
1569
|
diagnostics: [
|
|
1394
|
-
"an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f " +
|
|
1395
|
-
"
|
|
1570
|
+
"an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
|
|
1571
|
+
"fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
|
|
1572
|
+
"the particle trio (particleInit/particleStep/particleShade), " +
|
|
1573
|
+
"or the ribbon pair (trailWidth/trailShade)",
|
|
1396
1574
|
],
|
|
1397
1575
|
mounts: noMounts,
|
|
1398
1576
|
}
|
|
@@ -1442,17 +1620,47 @@ export class Engine {
|
|
|
1442
1620
|
|
|
1443
1621
|
// ── Compile with validation captured, not thrown at the console. Line
|
|
1444
1622
|
// numbers in diagnostics are rebased to the USER's source.
|
|
1445
|
-
|
|
1446
|
-
|
|
1623
|
+
// The composite is STATIC: user field code compiles in its own half-res
|
|
1624
|
+
// module (buildFieldShader), so a bad effect can no longer produce errors at
|
|
1625
|
+
// line numbers in a shader the author never wrote — and installing one no
|
|
1626
|
+
// longer recompiles the composite's tone-mapping half at all.
|
|
1627
|
+
const fieldEffect = hasBackground || hasForeground ? { wgsl, paramsDecl, hasBackground, hasForeground } : null
|
|
1628
|
+
const source = buildCompositeShader(fieldEffect)
|
|
1447
1629
|
this.device.pushErrorScope("validation")
|
|
1448
1630
|
const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
|
|
1449
|
-
const info = await module.getCompilationInfo()
|
|
1450
1631
|
const scopeErr = await this.device.popErrorScope()
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
if (
|
|
1455
|
-
|
|
1632
|
+
if (scopeErr) return { ok: false, diagnostics: [scopeErr.message], mounts }
|
|
1633
|
+
|
|
1634
|
+
let fieldPipeline: GPURenderPipeline | null = null
|
|
1635
|
+
if (fieldEffect) {
|
|
1636
|
+
const fieldSource = buildFieldShader(fieldEffect)
|
|
1637
|
+
const userLineOffset = fieldSource.slice(0, fieldSource.indexOf(wgsl)).split("\n").length - 1
|
|
1638
|
+
this.device.pushErrorScope("validation")
|
|
1639
|
+
const fieldModule = this.device.createShaderModule({ label: "field shader (effect)", code: fieldSource })
|
|
1640
|
+
const info = await fieldModule.getCompilationInfo()
|
|
1641
|
+
const fieldScopeErr = await this.device.popErrorScope()
|
|
1642
|
+
const diagnostics = info.messages
|
|
1643
|
+
.filter((m) => m.type === "error")
|
|
1644
|
+
.map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
|
|
1645
|
+
if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
|
|
1646
|
+
if (diagnostics.length > 0) return { ok: false, diagnostics, mounts }
|
|
1647
|
+
try {
|
|
1648
|
+
fieldPipeline = await this.device.createRenderPipelineAsync({
|
|
1649
|
+
label: "field layer pipeline",
|
|
1650
|
+
layout: this.fieldPipelineLayout,
|
|
1651
|
+
vertex: { module: fieldModule, entryPoint: "fieldVs" },
|
|
1652
|
+
fragment: {
|
|
1653
|
+
module: fieldModule,
|
|
1654
|
+
entryPoint: "fieldFs",
|
|
1655
|
+
targets: [{ format: "rgba16float" }, { format: "rgba16float" }],
|
|
1656
|
+
},
|
|
1657
|
+
primitive: { topology: "triangle-list" },
|
|
1658
|
+
multisample: { count: 1 },
|
|
1659
|
+
})
|
|
1660
|
+
} catch (e) {
|
|
1661
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1456
1664
|
let identity: GPURenderPipeline
|
|
1457
1665
|
let gamma: GPURenderPipeline
|
|
1458
1666
|
try {
|
|
@@ -1477,8 +1685,46 @@ export class Engine {
|
|
|
1477
1685
|
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
|
|
1478
1686
|
}
|
|
1479
1687
|
|
|
1688
|
+
// Built BEFORE the swap: a particle stage that fails to compile has to leave
|
|
1689
|
+
// the previously installed effect running, exactly as a bad composite does.
|
|
1690
|
+
let particles: NonNullable<Engine["particles"]> | null = null
|
|
1691
|
+
if (wantsParticles) {
|
|
1692
|
+
const built = await this.buildParticles(wgsl, anchors.filter((a) => a.trail).length)
|
|
1693
|
+
if (!built.ok) return { ok: false, diagnostics: built.diagnostics, mounts }
|
|
1694
|
+
particles = built.state
|
|
1695
|
+
}
|
|
1696
|
+
let trails: NonNullable<Engine["trails"]> | null = null
|
|
1697
|
+
if (wantsTrails) {
|
|
1698
|
+
// Only anchors that asked for `trail` have a path to draw; a ribbon on a
|
|
1699
|
+
// bone recorded without one would read zeroes and paint a line to the origin.
|
|
1700
|
+
const trailSlots = anchors.filter((a) => a.trail).length
|
|
1701
|
+
if (trailSlots === 0) {
|
|
1702
|
+
return {
|
|
1703
|
+
ok: false,
|
|
1704
|
+
diagnostics: ["a ribbon effect needs at least one // @anchor <bone> trail"],
|
|
1705
|
+
mounts,
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
const built = await this.buildTrails(wgsl, trailSlots)
|
|
1709
|
+
if (!built.ok) return { ok: false, diagnostics: built.diagnostics, mounts }
|
|
1710
|
+
trails = built.state
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1480
1713
|
// ── Swap — only now does the old effect (and its params buffer) go away.
|
|
1481
1714
|
this.effect?.paramsBuffer?.destroy()
|
|
1715
|
+
this.releaseParticles()
|
|
1716
|
+
this.releaseTrails()
|
|
1717
|
+
this.particles = particles
|
|
1718
|
+
this.trails = trails
|
|
1719
|
+
this.fieldPipeline = fieldPipeline
|
|
1720
|
+
// `// @fullres`: an effect that draws SUB-PIXEL detail — hairline curves,
|
|
1721
|
+
// scanlines — declares it and pays full price; everything soft stays at
|
|
1722
|
+
// half. The field shader reads its size from fieldU, so nothing else moves.
|
|
1723
|
+
const wantScale = /^\s*\/\/\s*@fullres\s*$/m.test(wgsl) ? 1 : 2
|
|
1724
|
+
if (wantScale !== this.fieldScale) {
|
|
1725
|
+
this.fieldScale = wantScale
|
|
1726
|
+
this.createFieldTargets()
|
|
1727
|
+
}
|
|
1482
1728
|
let paramsBuffer: GPUBuffer | null = null
|
|
1483
1729
|
if (entries.length) {
|
|
1484
1730
|
paramsBuffer = this.device.createBuffer({
|
|
@@ -1503,6 +1749,381 @@ export class Engine {
|
|
|
1503
1749
|
return { ok: true, diagnostics: [], mounts }
|
|
1504
1750
|
}
|
|
1505
1751
|
|
|
1752
|
+
/**
|
|
1753
|
+
* Compile an effect's particle stages and allocate its pool.
|
|
1754
|
+
*
|
|
1755
|
+
* Two modules, not one: the compute and render stages bind the same buffer
|
|
1756
|
+
* with different access (read_write vs read), and a single module would have
|
|
1757
|
+
* to pick one. Compiling them separately also means an author's helper names
|
|
1758
|
+
* live in their own compilation unit, which is what lets two effects both
|
|
1759
|
+
* define `hash21` without meeting.
|
|
1760
|
+
*/
|
|
1761
|
+
private async buildParticles(
|
|
1762
|
+
wgsl: string,
|
|
1763
|
+
trailSlots: number,
|
|
1764
|
+
): Promise<{ ok: true; state: NonNullable<Engine["particles"]> } | { ok: false; diagnostics: string[] }> {
|
|
1765
|
+
// No pragma means "some": an author who wrote the trio clearly wants
|
|
1766
|
+
// particles, and failing over a missing comment would be pedantry.
|
|
1767
|
+
const count = parseParticleCount(wgsl, Engine.MAX_PARTICLES) || 1024
|
|
1768
|
+
const src = { wgsl, count, blend: parseParticleBlend(wgsl), bloom: parseParticleBloom(wgsl) }
|
|
1769
|
+
// Sparks want to spawn where a trail is, so the particle stages see the same
|
|
1770
|
+
// cast buffer the trail draw reads.
|
|
1771
|
+
const cast = {
|
|
1772
|
+
subjects: MAX_EFFECT_SUBJECTS,
|
|
1773
|
+
samples: TRAIL_SAMPLES,
|
|
1774
|
+
base: MAX_EFFECT_SUBJECTS * 3,
|
|
1775
|
+
trailBase: CAST_TRAIL_BASE,
|
|
1776
|
+
slots: trailSlots,
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
const compile = async (code: string, label: string): Promise<GPUShaderModule | string[]> => {
|
|
1780
|
+
const offset = code.slice(0, code.indexOf(wgsl)).split("\n").length - 1
|
|
1781
|
+
this.device.pushErrorScope("validation")
|
|
1782
|
+
const module = this.device.createShaderModule({ label, code })
|
|
1783
|
+
const info = await module.getCompilationInfo()
|
|
1784
|
+
const scopeErr = await this.device.popErrorScope()
|
|
1785
|
+
const diagnostics = info.messages
|
|
1786
|
+
.filter((m) => m.type === "error")
|
|
1787
|
+
.map((m) => `${Math.max(0, m.lineNum - offset)}:${m.linePos} ${m.message}`)
|
|
1788
|
+
if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
|
|
1789
|
+
return diagnostics.length ? diagnostics : module
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
const computeModule = await compile(buildParticleComputeShader(src, cast), "particle compute")
|
|
1793
|
+
if (Array.isArray(computeModule)) return { ok: false, diagnostics: computeModule }
|
|
1794
|
+
const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render")
|
|
1795
|
+
if (Array.isArray(renderModule)) return { ok: false, diagnostics: renderModule }
|
|
1796
|
+
|
|
1797
|
+
const buffer = this.device.createBuffer({
|
|
1798
|
+
label: "particle pool",
|
|
1799
|
+
size: count * PARTICLE_STRIDE,
|
|
1800
|
+
usage: GPUBufferUsage.STORAGE,
|
|
1801
|
+
})
|
|
1802
|
+
const uniform = this.device.createBuffer({
|
|
1803
|
+
label: "particle uniforms",
|
|
1804
|
+
size: 16,
|
|
1805
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1806
|
+
})
|
|
1807
|
+
const uniformBytes = new ArrayBuffer(16)
|
|
1808
|
+
const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) }
|
|
1809
|
+
|
|
1810
|
+
// Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
|
|
1811
|
+
// visible to the vertex stage at all (WebGPU forbids it — a vertex shader
|
|
1812
|
+
// that could write memory has no defined ordering against the rasteriser).
|
|
1813
|
+
// Declaring one set of flags for both layouts is what made the pipeline
|
|
1814
|
+
// layout invalid, and the error surfaces later and unhelpfully as "invalid
|
|
1815
|
+
// due to a previous error".
|
|
1816
|
+
const layoutFor = (storage: GPUBufferBindingType, visibility: number) =>
|
|
1817
|
+
this.device.createBindGroupLayout({
|
|
1818
|
+
entries: [
|
|
1819
|
+
{ binding: 0, visibility, buffer: { type: storage } },
|
|
1820
|
+
{ binding: 1, visibility, buffer: { type: "uniform" } },
|
|
1821
|
+
{ binding: 2, visibility, buffer: { type: "uniform" } },
|
|
1822
|
+
{ binding: 3, visibility, buffer: { type: "read-only-storage" } },
|
|
1823
|
+
{ binding: 4, visibility, buffer: { type: "read-only-storage" } },
|
|
1824
|
+
],
|
|
1825
|
+
})
|
|
1826
|
+
const bindFor = (layout: GPUBindGroupLayout) =>
|
|
1827
|
+
this.device.createBindGroup({
|
|
1828
|
+
layout,
|
|
1829
|
+
entries: [
|
|
1830
|
+
{ binding: 0, resource: { buffer } },
|
|
1831
|
+
{ binding: 1, resource: { buffer: uniform } },
|
|
1832
|
+
{ binding: 2, resource: { buffer: this.cameraUniformBuffer } },
|
|
1833
|
+
{ binding: 3, resource: { buffer: this.castBuffer } },
|
|
1834
|
+
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
1835
|
+
],
|
|
1836
|
+
})
|
|
1837
|
+
|
|
1838
|
+
const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE)
|
|
1839
|
+
const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT)
|
|
1840
|
+
|
|
1841
|
+
// Additive keeps the destination and adds to it; the alpha channel is left
|
|
1842
|
+
// alone (dst factor one, src zero) so a glow does not also claim coverage
|
|
1843
|
+
// it never occluded.
|
|
1844
|
+
// Additive effects need the MASK to sum like the colour does — see the
|
|
1845
|
+
// fragment shaders' mask comment. rg8unorm clamps the sum at 1, which is the
|
|
1846
|
+
// saturation alpha-over would reach anyway.
|
|
1847
|
+
const maskTarget: GPUColorTargetState =
|
|
1848
|
+
src.blend === "additive"
|
|
1849
|
+
? {
|
|
1850
|
+
format: Engine.BLOOM_MASK_FORMAT,
|
|
1851
|
+
blend: {
|
|
1852
|
+
color: { srcFactor: "one", dstFactor: "one", operation: "add" },
|
|
1853
|
+
alpha: { srcFactor: "one", dstFactor: "one", operation: "add" },
|
|
1854
|
+
},
|
|
1855
|
+
}
|
|
1856
|
+
: this.sceneTargets[1]
|
|
1857
|
+
const colorTarget: GPUColorTargetState =
|
|
1858
|
+
src.blend === "additive"
|
|
1859
|
+
? {
|
|
1860
|
+
format: this.hdrFormat,
|
|
1861
|
+
blend: {
|
|
1862
|
+
color: { srcFactor: "one", dstFactor: "one", operation: "add" },
|
|
1863
|
+
alpha: { srcFactor: "zero", dstFactor: "one", operation: "add" },
|
|
1864
|
+
},
|
|
1865
|
+
}
|
|
1866
|
+
: this.sceneTargets[0]
|
|
1867
|
+
|
|
1868
|
+
this.device.pushErrorScope("validation")
|
|
1869
|
+
try {
|
|
1870
|
+
const compute = await this.device.createComputePipelineAsync({
|
|
1871
|
+
label: "particle compute pipeline",
|
|
1872
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
|
|
1873
|
+
compute: { module: computeModule, entryPoint: "main" },
|
|
1874
|
+
})
|
|
1875
|
+
const render = await this.device.createRenderPipelineAsync({
|
|
1876
|
+
label: "particle render pipeline",
|
|
1877
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] }),
|
|
1878
|
+
vertex: { module: renderModule, entryPoint: "vs" },
|
|
1879
|
+
fragment: { module: renderModule, entryPoint: "fs", targets: [colorTarget, maskTarget] },
|
|
1880
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
1881
|
+
// Tested but not WRITTEN: particles are transparent, so writing depth
|
|
1882
|
+
// would make whichever quad drew first occlude the ones behind it.
|
|
1883
|
+
depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: false, depthCompare: "less-equal" },
|
|
1884
|
+
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
1885
|
+
})
|
|
1886
|
+
const scoped = await this.device.popErrorScope()
|
|
1887
|
+
if (scoped) {
|
|
1888
|
+
buffer.destroy()
|
|
1889
|
+
uniform.destroy()
|
|
1890
|
+
return { ok: false, diagnostics: [scoped.message] }
|
|
1891
|
+
}
|
|
1892
|
+
return {
|
|
1893
|
+
ok: true,
|
|
1894
|
+
state: {
|
|
1895
|
+
count,
|
|
1896
|
+
buffer,
|
|
1897
|
+
uniform,
|
|
1898
|
+
// One 16-byte block, two views: time/dt are floats and count/frame are
|
|
1899
|
+
// integers, and writing them through separate arrays would upload two
|
|
1900
|
+
// different buffers with the same name.
|
|
1901
|
+
data: uniformView.floats,
|
|
1902
|
+
counts: uniformView.uints,
|
|
1903
|
+
compute,
|
|
1904
|
+
computeLayout,
|
|
1905
|
+
computeBind: bindFor(computeLayout),
|
|
1906
|
+
render,
|
|
1907
|
+
renderLayout,
|
|
1908
|
+
renderBind: bindFor(renderLayout),
|
|
1909
|
+
rebind: () => ({ computeBind: bindFor(computeLayout), renderBind: bindFor(renderLayout) }),
|
|
1910
|
+
},
|
|
1911
|
+
}
|
|
1912
|
+
} catch (e) {
|
|
1913
|
+
await this.device.popErrorScope()
|
|
1914
|
+
buffer.destroy()
|
|
1915
|
+
uniform.destroy()
|
|
1916
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* Step the pool, before the scene pass.
|
|
1922
|
+
*
|
|
1923
|
+
* Outside the render pass because a compute dispatch cannot be encoded inside
|
|
1924
|
+
* one — and it has to precede the draw that reads the same buffer, or the
|
|
1925
|
+
* quads render last frame's positions.
|
|
1926
|
+
*/
|
|
1927
|
+
private stepParticles(encoder: GPUCommandEncoder, deltaTime: number): void {
|
|
1928
|
+
const p = this.particles
|
|
1929
|
+
if (!p) return
|
|
1930
|
+
p.data[0] = this.sceneClock - this.effectEpochScene
|
|
1931
|
+
// Clamped: a backgrounded tab returns with a delta of whole seconds, and an
|
|
1932
|
+
// unclamped step flings every particle out of the scene in one frame.
|
|
1933
|
+
p.data[1] = Math.min(0.1, Math.max(0, deltaTime))
|
|
1934
|
+
p.counts[2] = p.count
|
|
1935
|
+
p.counts[3] = this.particleFrame++
|
|
1936
|
+
this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer as ArrayBuffer)
|
|
1937
|
+
const cp = encoder.beginComputePass({ label: "particles" })
|
|
1938
|
+
cp.setPipeline(p.compute)
|
|
1939
|
+
cp.setBindGroup(0, p.computeBind)
|
|
1940
|
+
cp.dispatchWorkgroups(Math.ceil(p.count / 64))
|
|
1941
|
+
cp.end()
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/** Draw the pool. Inside the scene pass, so it is depth-tested and pre-bloom. */
|
|
1945
|
+
private renderParticles(pass: GPURenderPassEncoder): void {
|
|
1946
|
+
const p = this.particles
|
|
1947
|
+
if (!p) return
|
|
1948
|
+
pass.setPipeline(p.render)
|
|
1949
|
+
pass.setBindGroup(0, p.renderBind)
|
|
1950
|
+
pass.draw(6, p.count)
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/**
|
|
1954
|
+
* Compile an effect's ribbon stage.
|
|
1955
|
+
*
|
|
1956
|
+
* One instance per (slot, subject, segment), so a scene with several dancers
|
|
1957
|
+
* and several declared bones is still one draw and nothing is computed per
|
|
1958
|
+
* frame on the CPU.
|
|
1959
|
+
*/
|
|
1960
|
+
private async buildTrails(
|
|
1961
|
+
wgsl: string,
|
|
1962
|
+
slots: number,
|
|
1963
|
+
): Promise<{ ok: true; state: NonNullable<Engine["trails"]> } | { ok: false; diagnostics: string[] }> {
|
|
1964
|
+
const src = { wgsl, slots, blend: parseParticleBlend(wgsl), bloom: parseParticleBloom(wgsl) }
|
|
1965
|
+
const code = buildTrailShader(src, {
|
|
1966
|
+
subjects: MAX_EFFECT_SUBJECTS,
|
|
1967
|
+
samples: TRAIL_SAMPLES,
|
|
1968
|
+
base: MAX_EFFECT_SUBJECTS * 3,
|
|
1969
|
+
trailBase: CAST_TRAIL_BASE,
|
|
1970
|
+
})
|
|
1971
|
+
const offset = code.slice(0, code.indexOf(wgsl)).split("\n").length - 1
|
|
1972
|
+
this.device.pushErrorScope("validation")
|
|
1973
|
+
const module = this.device.createShaderModule({ label: "trail shader", code })
|
|
1974
|
+
const info = await module.getCompilationInfo()
|
|
1975
|
+
const scopeErr = await this.device.popErrorScope()
|
|
1976
|
+
const diagnostics = info.messages
|
|
1977
|
+
.filter((m) => m.type === "error")
|
|
1978
|
+
.map((m) => `${Math.max(0, m.lineNum - offset)}:${m.linePos} ${m.message}`)
|
|
1979
|
+
if (diagnostics.length === 0 && scopeErr) diagnostics.push(scopeErr.message)
|
|
1980
|
+
if (diagnostics.length) return { ok: false, diagnostics }
|
|
1981
|
+
|
|
1982
|
+
const uniform = this.device.createBuffer({
|
|
1983
|
+
label: "trail uniforms",
|
|
1984
|
+
size: 16,
|
|
1985
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1986
|
+
})
|
|
1987
|
+
const layout = this.device.createBindGroupLayout({
|
|
1988
|
+
entries: [
|
|
1989
|
+
{
|
|
1990
|
+
binding: 0,
|
|
1991
|
+
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
|
1992
|
+
buffer: { type: "read-only-storage" },
|
|
1993
|
+
},
|
|
1994
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
1995
|
+
{ binding: 2, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
1996
|
+
// The scene's depth, for the fragment's manual occlusion test.
|
|
1997
|
+
{
|
|
1998
|
+
binding: 3,
|
|
1999
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
2000
|
+
texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
|
|
2001
|
+
},
|
|
2002
|
+
// The audio analysis, for rzAudio* in width and shade alike.
|
|
2003
|
+
{ binding: 4, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
2004
|
+
],
|
|
2005
|
+
})
|
|
2006
|
+
// ONE target: the ribbons' own layer, blended with MAX in both channels.
|
|
2007
|
+
// Max is the original's core-takes-the-max rule as a blend mode — parallel
|
|
2008
|
+
// strands of a circling hand meet as max and cannot double into bright
|
|
2009
|
+
// dashes, which every additive variant of this pipeline drew. The layer is
|
|
2010
|
+
// composited over the frame after tone mapping (see composite.ts), which is
|
|
2011
|
+
// where the fullscreen ribbon always ran.
|
|
2012
|
+
const layerTarget: GPUColorTargetState = {
|
|
2013
|
+
format: "rgba16float",
|
|
2014
|
+
blend: {
|
|
2015
|
+
color: { srcFactor: "one", dstFactor: "one", operation: "max" },
|
|
2016
|
+
alpha: { srcFactor: "one", dstFactor: "one", operation: "max" },
|
|
2017
|
+
},
|
|
2018
|
+
}
|
|
2019
|
+
this.device.pushErrorScope("validation")
|
|
2020
|
+
try {
|
|
2021
|
+
const pipeline = await this.device.createRenderPipelineAsync({
|
|
2022
|
+
label: "trail pipeline",
|
|
2023
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
|
2024
|
+
vertex: { module, entryPoint: "vs" },
|
|
2025
|
+
fragment: { module, entryPoint: "fs", targets: [layerTarget] },
|
|
2026
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
2027
|
+
// No depth attachment and no MSAA: the layer is a lone colour target,
|
|
2028
|
+
// and occlusion happens in the fragment against the scene's own depth.
|
|
2029
|
+
multisample: { count: 1 },
|
|
2030
|
+
})
|
|
2031
|
+
const scoped = await this.device.popErrorScope()
|
|
2032
|
+
if (scoped) {
|
|
2033
|
+
uniform.destroy()
|
|
2034
|
+
return { ok: false, diagnostics: [scoped.message] }
|
|
2035
|
+
}
|
|
2036
|
+
return {
|
|
2037
|
+
ok: true,
|
|
2038
|
+
state: {
|
|
2039
|
+
instances: slots * MAX_EFFECT_SUBJECTS * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS,
|
|
2040
|
+
uniform,
|
|
2041
|
+
data: new Float32Array(4),
|
|
2042
|
+
pipeline,
|
|
2043
|
+
layout,
|
|
2044
|
+
bind: this.device.createBindGroup({
|
|
2045
|
+
layout,
|
|
2046
|
+
entries: [
|
|
2047
|
+
{ binding: 0, resource: { buffer: this.castBuffer } },
|
|
2048
|
+
{ binding: 1, resource: { buffer: uniform } },
|
|
2049
|
+
{ binding: 2, resource: { buffer: this.cameraUniformBuffer } },
|
|
2050
|
+
{ binding: 3, resource: this.depthReadView! },
|
|
2051
|
+
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
2052
|
+
],
|
|
2053
|
+
}),
|
|
2054
|
+
},
|
|
2055
|
+
}
|
|
2056
|
+
} catch (e) {
|
|
2057
|
+
await this.device.popErrorScope()
|
|
2058
|
+
uniform.destroy()
|
|
2059
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] }
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
private releaseTrails(): void {
|
|
2064
|
+
this.trails?.uniform.destroy()
|
|
2065
|
+
this.trails = null
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/** Draw the ribbons into their own layer — cleared, max-blended, and
|
|
2069
|
+
* composited over the frame after tone mapping. */
|
|
2070
|
+
private renderTrailLayer(encoder: GPUCommandEncoder): void {
|
|
2071
|
+
const t = this.trails
|
|
2072
|
+
if (!t || !this.trailLayerView) return
|
|
2073
|
+
t.data[0] = this.sceneClock - this.effectEpochScene
|
|
2074
|
+
this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
|
|
2075
|
+
const pass = encoder.beginRenderPass({
|
|
2076
|
+
label: "trail layer",
|
|
2077
|
+
colorAttachments: [
|
|
2078
|
+
{ view: this.trailLayerView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
2079
|
+
],
|
|
2080
|
+
})
|
|
2081
|
+
pass.setPipeline(t.pipeline)
|
|
2082
|
+
pass.setBindGroup(0, t.bind)
|
|
2083
|
+
pass.draw(6, t.instances)
|
|
2084
|
+
pass.end()
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/** The user's field mounts, drawn at half resolution for the composite to
|
|
2088
|
+
* upsample. Runs the whole quad — uniform control flow, so effects may use
|
|
2089
|
+
* derivatives freely, which the old inline path had to forbid. */
|
|
2090
|
+
private renderFieldPass(encoder: GPUCommandEncoder): void {
|
|
2091
|
+
if (!this.fieldPipeline || !this.fieldBgView || !this.fieldFgView || !this.fieldBindGroup) return
|
|
2092
|
+
const pass = encoder.beginRenderPass({
|
|
2093
|
+
label: "field layer",
|
|
2094
|
+
colorAttachments: [
|
|
2095
|
+
{ view: this.fieldBgView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
2096
|
+
{ view: this.fieldFgView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
2097
|
+
],
|
|
2098
|
+
})
|
|
2099
|
+
pass.setPipeline(this.fieldPipeline)
|
|
2100
|
+
pass.setBindGroup(0, this.fieldBindGroup)
|
|
2101
|
+
pass.draw(3)
|
|
2102
|
+
pass.end()
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/** The trail bind group holds the depth view, which a resize recreates. */
|
|
2106
|
+
private rebindTrails(): void {
|
|
2107
|
+
const t = this.trails
|
|
2108
|
+
if (!t || !this.depthReadView) return
|
|
2109
|
+
t.bind = this.device.createBindGroup({
|
|
2110
|
+
layout: t.layout,
|
|
2111
|
+
entries: [
|
|
2112
|
+
{ binding: 0, resource: { buffer: this.castBuffer } },
|
|
2113
|
+
{ binding: 1, resource: { buffer: t.uniform } },
|
|
2114
|
+
{ binding: 2, resource: { buffer: this.cameraUniformBuffer } },
|
|
2115
|
+
{ binding: 3, resource: this.depthReadView },
|
|
2116
|
+
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
2117
|
+
],
|
|
2118
|
+
})
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
private releaseParticles(): void {
|
|
2122
|
+
this.particles?.buffer.destroy()
|
|
2123
|
+
this.particles?.uniform.destroy()
|
|
2124
|
+
this.particles = null
|
|
2125
|
+
}
|
|
2126
|
+
|
|
1506
2127
|
/** Which mounts the installed effect declared. Both false when none is set. */
|
|
1507
2128
|
getEffectMounts(): { background: boolean; foreground: boolean } {
|
|
1508
2129
|
return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false }
|
|
@@ -1924,6 +2545,51 @@ export class Engine {
|
|
|
1924
2545
|
addressModeV: "repeat",
|
|
1925
2546
|
})
|
|
1926
2547
|
|
|
2548
|
+
this.trailFallbackView = this.device
|
|
2549
|
+
.createTexture({
|
|
2550
|
+
label: "trail layer fallback (1x1 transparent)",
|
|
2551
|
+
size: [1, 1],
|
|
2552
|
+
format: "rgba16float",
|
|
2553
|
+
usage: GPUTextureUsage.TEXTURE_BINDING,
|
|
2554
|
+
})
|
|
2555
|
+
.createView()
|
|
2556
|
+
|
|
2557
|
+
this.audioFallbackBuffer = this.device.createBuffer({
|
|
2558
|
+
label: "audio analysis fallback (silence)",
|
|
2559
|
+
size: 32,
|
|
2560
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
2561
|
+
})
|
|
2562
|
+
this.audioBuffer = this.audioFallbackBuffer
|
|
2563
|
+
|
|
2564
|
+
this.fieldUniformBuffer = this.device.createBuffer({
|
|
2565
|
+
label: "field layer uniforms (half size, full size)",
|
|
2566
|
+
size: 16,
|
|
2567
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2568
|
+
})
|
|
2569
|
+
// The field pass's own layout: the subset of the composite's bindings the
|
|
2570
|
+
// user's code can statically reach, WITHOUT the field textures themselves —
|
|
2571
|
+
// a pass may not sample its own attachments, and WebGPU counts every
|
|
2572
|
+
// resource in a bound group whether the shader reads it or not.
|
|
2573
|
+
this.fieldBindGroupLayout = this.device.createBindGroupLayout({
|
|
2574
|
+
label: "field layer bind layout",
|
|
2575
|
+
entries: [
|
|
2576
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
2577
|
+
{ binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
2578
|
+
{
|
|
2579
|
+
binding: 8,
|
|
2580
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
2581
|
+
texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
|
|
2582
|
+
},
|
|
2583
|
+
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
2584
|
+
{ binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
2585
|
+
{ binding: 13, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
2586
|
+
{ binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
2587
|
+
],
|
|
2588
|
+
})
|
|
2589
|
+
this.fieldPipelineLayout = this.device.createPipelineLayout({
|
|
2590
|
+
bindGroupLayouts: [this.fieldBindGroupLayout],
|
|
2591
|
+
})
|
|
2592
|
+
|
|
1927
2593
|
this.fallbackMaterialTexture = this.device.createTexture({
|
|
1928
2594
|
label: "fallback material texture (1x1 white)",
|
|
1929
2595
|
size: [1, 1],
|
|
@@ -2574,6 +3240,15 @@ export class Engine {
|
|
|
2574
3240
|
// The cast, for rzSubject/rzAnchor. Always bound so the base shader's
|
|
2575
3241
|
// layout matches; the base shader simply never reads it.
|
|
2576
3242
|
{ binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
3243
|
+
// The trail layer. Bound to a transparent 1×1 when no ribbon effect is
|
|
3244
|
+
// installed, so the base shader's layout always matches.
|
|
3245
|
+
{ binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
3246
|
+
// The audio analysis, for rzAudio*. Silence fallback when the scene has
|
|
3247
|
+
// no track.
|
|
3248
|
+
{ binding: 13, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
3249
|
+
// The field layer's two halves. Fallback-bound when no field effect runs.
|
|
3250
|
+
{ binding: 15, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
3251
|
+
{ binding: 16, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
2577
3252
|
],
|
|
2578
3253
|
})
|
|
2579
3254
|
this.fallbackEquirectTexture = this.device.createTexture({
|
|
@@ -2767,6 +3442,23 @@ export class Engine {
|
|
|
2767
3442
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
2768
3443
|
})
|
|
2769
3444
|
|
|
3445
|
+
// rgba16float explicitly, NOT hdrFormat: the composite reads this layer's
|
|
3446
|
+
// ALPHA to composite it over the frame, and an rg11b10 hdr fallback has no
|
|
3447
|
+
// alpha channel to read.
|
|
3448
|
+
this.trailLayerTexture?.destroy()
|
|
3449
|
+
this.trailLayerTexture = this.device.createTexture({
|
|
3450
|
+
label: "trail layer",
|
|
3451
|
+
size: [width, height],
|
|
3452
|
+
format: "rgba16float",
|
|
3453
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
3454
|
+
})
|
|
3455
|
+
this.trailLayerView = this.trailLayerTexture.createView()
|
|
3456
|
+
|
|
3457
|
+
// The field layer — half resolution by default, full for @fullres effects.
|
|
3458
|
+
this.fieldFullW = width
|
|
3459
|
+
this.fieldFullH = height
|
|
3460
|
+
this.createFieldTargets()
|
|
3461
|
+
|
|
2770
3462
|
// Bloom-mask MRT attachments — same dims + MSAA as HDR so they share the render pass.
|
|
2771
3463
|
// MS buffer gets resolved into maskResolveTexture, which the bloom blit pass samples.
|
|
2772
3464
|
this.multisampleMaskTexture = this.device.createTexture({
|
|
@@ -2826,6 +3518,7 @@ export class Engine {
|
|
|
2826
3518
|
|
|
2827
3519
|
const depthTextureView = this.depthTexture.createView()
|
|
2828
3520
|
this.depthReadView = this.depthTexture.createView({ aspect: "depth-only" })
|
|
3521
|
+
this.rebindTrails()
|
|
2829
3522
|
|
|
2830
3523
|
// storeOp="discard" on MSAA views keeps per-sample data in Apple TBDR tile memory —
|
|
2831
3524
|
// only the resolveTarget (hdrResolveTexture / maskResolveView) gets written to RAM.
|
|
@@ -3255,6 +3948,63 @@ export class Engine {
|
|
|
3255
3948
|
return this.cameraAnimation?.duration ?? 0
|
|
3256
3949
|
}
|
|
3257
3950
|
|
|
3951
|
+
/**
|
|
3952
|
+
* Install a track's precomputed analysis for the rzAudio* effect functions:
|
|
3953
|
+
* `data` is frames × (2 + bands) floats — loudness, bass onset, then the band
|
|
3954
|
+
* magnitudes, all 0..1 — sampled by the clock given to setAudioTime. Null
|
|
3955
|
+
* clears back to silence.
|
|
3956
|
+
*
|
|
3957
|
+
* Precomputed for the WHOLE track, never fed live from an analyser: an export
|
|
3958
|
+
* steps the engine frame by frame rather than playing in real time, so live
|
|
3959
|
+
* analysis would render silence into the exported video.
|
|
3960
|
+
*/
|
|
3961
|
+
setAudioData(data: Float32Array | null, bandsPerFrame: number, secondsPerFrame: number): void {
|
|
3962
|
+
if (this.audioBuffer !== this.audioFallbackBuffer) this.audioBuffer.destroy()
|
|
3963
|
+
if (!data || data.length === 0) {
|
|
3964
|
+
this.audioBuffer = this.audioFallbackBuffer
|
|
3965
|
+
} else {
|
|
3966
|
+
const frames = Math.floor(data.length / (bandsPerFrame + 2))
|
|
3967
|
+
const payload = new Float32Array(8 + data.length)
|
|
3968
|
+
payload[0] = frames
|
|
3969
|
+
payload[1] = bandsPerFrame
|
|
3970
|
+
payload[2] = secondsPerFrame
|
|
3971
|
+
payload.set(data, 8)
|
|
3972
|
+
this.audioBuffer = this.device.createBuffer({
|
|
3973
|
+
label: "audio analysis",
|
|
3974
|
+
size: payload.byteLength,
|
|
3975
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
3976
|
+
})
|
|
3977
|
+
this.device.queue.writeBuffer(this.audioBuffer, 0, payload)
|
|
3978
|
+
}
|
|
3979
|
+
// Every consumer holds the buffer by reference in a bind group; all of them
|
|
3980
|
+
// re-bind so audio arriving after an effect (or before one) both work.
|
|
3981
|
+
this.rebuildCompositeBindGroup()
|
|
3982
|
+
this.rebindTrails()
|
|
3983
|
+
if (this.particles) {
|
|
3984
|
+
const b = this.particles.rebind()
|
|
3985
|
+
this.particles.computeBind = b.computeBind
|
|
3986
|
+
this.particles.renderBind = b.renderBind
|
|
3987
|
+
}
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
/**
|
|
3991
|
+
* Where the track is NOW, in seconds — written by whoever owns playback: the
|
|
3992
|
+
* editor's audio clock, the viewer's, or the export loop with its exact
|
|
3993
|
+
* per-frame time. A 4-byte header write, cheap enough for every frame.
|
|
3994
|
+
*/
|
|
3995
|
+
setAudioTime(seconds: number, playing = true): void {
|
|
3996
|
+
if (this.audioBuffer === this.audioFallbackBuffer) return
|
|
3997
|
+
this.audioTimeScratch[0] = seconds
|
|
3998
|
+
this.audioTimeScratch[1] = playing ? 1 : 0
|
|
3999
|
+
this.device.queue.writeBuffer(this.audioBuffer, 12, this.audioTimeScratch)
|
|
4000
|
+
}
|
|
4001
|
+
|
|
4002
|
+
/** Every camera keyframe's frame index — what a timeline draws as its cuts.
|
|
4003
|
+
* Empty when no camera VMD is loaded. */
|
|
4004
|
+
getCameraVmdKeyframes(): number[] {
|
|
4005
|
+
return this.cameraAnimation?.keyframeIndices() ?? []
|
|
4006
|
+
}
|
|
4007
|
+
|
|
3258
4008
|
/** Drop the loaded camera VMD and return to orbit control. */
|
|
3259
4009
|
clearCameraVmd(): void {
|
|
3260
4010
|
this.cameraAnimation = null
|
|
@@ -5564,7 +6314,7 @@ export class Engine {
|
|
|
5564
6314
|
// write leaves dofU[0].x at 0 while DoF is off, so refreshing it does not
|
|
5565
6315
|
// switch the gather on.
|
|
5566
6316
|
const dofOn = this.depthOfField.enabled
|
|
5567
|
-
const depthRead = dofOn || (this.effect?.hasForeground ?? false)
|
|
6317
|
+
const depthRead = dofOn || (this.effect?.hasForeground ?? false) || this.trails !== null
|
|
5568
6318
|
this.renderPassDescriptor.depthStencilAttachment!.depthStoreOp = depthRead ? "store" : "discard"
|
|
5569
6319
|
if (depthRead) this.writeDepthOfFieldUniforms()
|
|
5570
6320
|
|
|
@@ -5597,6 +6347,8 @@ export class Engine {
|
|
|
5597
6347
|
this.shadowMapPopulated = hasModels
|
|
5598
6348
|
}
|
|
5599
6349
|
|
|
6350
|
+
this.stepParticles(encoder, deltaTime)
|
|
6351
|
+
|
|
5600
6352
|
const pass = encoder.beginRenderPass(this.renderPassDescriptor)
|
|
5601
6353
|
// Phase order: opaque models → ground → transparent fabric.
|
|
5602
6354
|
// The ground shader is the most expensive full-coverage draw in the frame
|
|
@@ -5614,8 +6366,19 @@ export class Engine {
|
|
|
5614
6366
|
this.forEachInstance((inst) => {
|
|
5615
6367
|
if (inst.model.visible) this.renderModelTransparentPhase(pass, inst)
|
|
5616
6368
|
})
|
|
6369
|
+
// Last in the pass: depth-tested against everything drawn above, so a
|
|
6370
|
+
// particle behind the character is simply hidden, and still inside the HDR
|
|
6371
|
+
// target so an `@bloom` effect reaches the pyramid below.
|
|
6372
|
+
this.renderParticles(pass)
|
|
5617
6373
|
pass.end()
|
|
5618
6374
|
|
|
6375
|
+
// Ribbons draw AFTER the scene pass ends, so its depth is resolved for
|
|
6376
|
+
// their manual occlusion test — and before the composite that reads them.
|
|
6377
|
+
this.renderTrailLayer(encoder)
|
|
6378
|
+
// The field mounts, likewise: after the scene so foregrounds can read its
|
|
6379
|
+
// depth, before the composite that samples both layers.
|
|
6380
|
+
this.renderFieldPass(encoder)
|
|
6381
|
+
|
|
5619
6382
|
// Bloom pyramid (EEVEE 3.6):
|
|
5620
6383
|
// 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
|
|
5621
6384
|
// 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
|
|
@@ -6465,15 +7228,38 @@ export class Engine {
|
|
|
6465
7228
|
ring = { pos: [], t: [] }
|
|
6466
7229
|
this.anchorTrail.set(key, ring)
|
|
6467
7230
|
}
|
|
7231
|
+
// A TELEPORT is not motion. A model popping from the origin to its place at
|
|
7232
|
+
// load, a scrub, a scene swap — the bone genuinely moves many units in one
|
|
7233
|
+
// frame, and a recorder that faithfully keeps both ends hands every reader a
|
|
7234
|
+
// path across the world: the ribbon drew it as a streak and the sparks
|
|
7235
|
+
// seeded a burst along it. Fifty units per second is far beyond any dance
|
|
7236
|
+
// (a hard flick peaks around twenty); past it, the history restarts here.
|
|
7237
|
+
if (ring.pos.length > 0) {
|
|
7238
|
+
const dx = pos.x - ring.pos[0]
|
|
7239
|
+
const dy = pos.y - ring.pos[1]
|
|
7240
|
+
const dz = pos.z - ring.pos[2]
|
|
7241
|
+
const dt = Math.max(1 / 120, this.sceneClock - ring.t[0])
|
|
7242
|
+
if (Math.hypot(dx, dy, dz) / dt > 50) {
|
|
7243
|
+
ring.pos.length = 0
|
|
7244
|
+
ring.t.length = 0
|
|
7245
|
+
}
|
|
7246
|
+
}
|
|
6468
7247
|
if (this.trailDue > 0 || ring.pos.length === 0) {
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
7248
|
+
// ONE sample per frame, never one per due tick. A frame that spanned
|
|
7249
|
+
// several 60Hz ticks only knows where the bone is NOW, and unshifting that
|
|
7250
|
+
// position once per tick fabricated duplicate samples — same point, same
|
|
7251
|
+
// timestamp, up to four copies — precisely when the scene ran heavy. Every
|
|
7252
|
+
// duplicate pair kinked the spline, and each kink drew as a bright bar
|
|
7253
|
+
// across the ribbon: banding that appeared under load, was spaced once per
|
|
7254
|
+
// frame, and survived every renderer fix because the renderer was
|
|
7255
|
+
// faithfully drawing corrupted history. Coarser spacing under load is
|
|
7256
|
+
// honest — each sample carries its true timestamp, and the spline and the
|
|
7257
|
+
// central-difference weight exist to handle uneven spacing.
|
|
7258
|
+
ring.pos.unshift(pos.x, pos.y, pos.z)
|
|
7259
|
+
ring.t.unshift(this.sceneClock)
|
|
7260
|
+
if (ring.t.length > TRAIL_SAMPLES) {
|
|
7261
|
+
ring.t.length = TRAIL_SAMPLES
|
|
7262
|
+
ring.pos.length = TRAIL_SAMPLES * 3
|
|
6477
7263
|
}
|
|
6478
7264
|
}
|
|
6479
7265
|
const count = ring.t.length
|