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/dist/engine.js
CHANGED
|
@@ -20,7 +20,9 @@ import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shader
|
|
|
20
20
|
import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
|
|
21
21
|
import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
|
|
22
22
|
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
|
|
23
|
-
import { buildCompositeShader, parseEffectAnchors, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
|
|
23
|
+
import { buildCompositeShader, buildFieldShader, parseEffectAnchors, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
|
|
24
|
+
import { buildParticleComputeShader, buildParticleRenderShader, parseParticleBlend, parseParticleBloom, parseParticleCount, particleEntryPoints, PARTICLE_STRIDE, } from "./shaders/passes/particles";
|
|
25
|
+
import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails";
|
|
24
26
|
import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
|
|
25
27
|
import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap";
|
|
26
28
|
import { compileGraph } from "./graph/compile";
|
|
@@ -345,6 +347,39 @@ export class Engine {
|
|
|
345
347
|
// the fragment shader and treats missing dst.a as 1, so the blend math is
|
|
346
348
|
// unchanged).
|
|
347
349
|
this.hdrFormat = "rgba16float";
|
|
350
|
+
/**
|
|
351
|
+
* The installed effect's particle system, or null when it declared none.
|
|
352
|
+
*
|
|
353
|
+
* A fixed pool: the count is chosen at install and the slots recycle, so there
|
|
354
|
+
* is no allocation and no spawn-rate bookkeeping in the hot path. Dead slots
|
|
355
|
+
* cost a degenerate quad the rasteriser rejects, which is cheaper than the
|
|
356
|
+
* prefix sum and readback a compacted draw list would need every frame.
|
|
357
|
+
*/
|
|
358
|
+
this.particles = null;
|
|
359
|
+
this.particleFrame = 0;
|
|
360
|
+
/**
|
|
361
|
+
* The installed effect's ribbons, or null when it declared none.
|
|
362
|
+
*
|
|
363
|
+
* No buffer of its own: it reads the very same path history the field-based
|
|
364
|
+
* ribbon read through rzTrail, so a trail costs one draw and nothing recorded.
|
|
365
|
+
*/
|
|
366
|
+
this.trails = null;
|
|
367
|
+
/** The ribbons' own offscreen target — max-blended, composited after tone map. */
|
|
368
|
+
this.trailLayerTexture = null;
|
|
369
|
+
this.trailLayerView = null;
|
|
370
|
+
/** The field layer: user background/foreground mounts at half resolution. */
|
|
371
|
+
this.fieldBgTexture = null;
|
|
372
|
+
this.fieldBgView = null;
|
|
373
|
+
this.fieldFgTexture = null;
|
|
374
|
+
this.fieldFgView = null;
|
|
375
|
+
/** 2 = half resolution (the default); 1 = full, for effects that declare
|
|
376
|
+
* `// @fullres` because they draw sub-pixel detail no upsample can carry. */
|
|
377
|
+
this.fieldScale = 2;
|
|
378
|
+
this.fieldFullW = 0;
|
|
379
|
+
this.fieldFullH = 0;
|
|
380
|
+
this.fieldPipeline = null;
|
|
381
|
+
this.fieldBindGroup = null;
|
|
382
|
+
this.audioTimeScratch = new Float32Array(2);
|
|
348
383
|
this.depthOfField = { ...DEFAULT_DEPTH_OF_FIELD_OPTIONS };
|
|
349
384
|
this.dofUniformData = new Float32Array(12);
|
|
350
385
|
this.dofFocusScratch = new Vec3(0, 0, 0);
|
|
@@ -887,6 +922,51 @@ export class Engine {
|
|
|
887
922
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
888
923
|
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
889
924
|
{ binding: 11, resource: { buffer: this.castBuffer } },
|
|
925
|
+
{ binding: 12, resource: this.trails && this.trailLayerView ? this.trailLayerView : this.trailFallbackView },
|
|
926
|
+
{ binding: 13, resource: { buffer: this.audioBuffer } },
|
|
927
|
+
{ binding: 15, resource: this.fieldBgView ?? this.trailFallbackView },
|
|
928
|
+
{ binding: 16, resource: this.fieldFgView ?? this.trailFallbackView },
|
|
929
|
+
],
|
|
930
|
+
});
|
|
931
|
+
this.rebuildFieldBindGroup();
|
|
932
|
+
}
|
|
933
|
+
createFieldTargets() {
|
|
934
|
+
if (!this.device || this.fieldFullW === 0)
|
|
935
|
+
return;
|
|
936
|
+
const w = Math.max(1, Math.ceil(this.fieldFullW / this.fieldScale));
|
|
937
|
+
const h = Math.max(1, Math.ceil(this.fieldFullH / this.fieldScale));
|
|
938
|
+
this.fieldBgTexture?.destroy();
|
|
939
|
+
this.fieldFgTexture?.destroy();
|
|
940
|
+
this.fieldBgTexture = this.device.createTexture({
|
|
941
|
+
label: "field layer (background)",
|
|
942
|
+
size: [w, h],
|
|
943
|
+
format: "rgba16float",
|
|
944
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
945
|
+
});
|
|
946
|
+
this.fieldFgTexture = this.device.createTexture({
|
|
947
|
+
label: "field layer (foreground)",
|
|
948
|
+
size: [w, h],
|
|
949
|
+
format: "rgba16float",
|
|
950
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
951
|
+
});
|
|
952
|
+
this.fieldBgView = this.fieldBgTexture.createView();
|
|
953
|
+
this.fieldFgView = this.fieldFgTexture.createView();
|
|
954
|
+
this.device.queue.writeBuffer(this.fieldUniformBuffer, 0, new Float32Array([w, h, this.fieldFullW, this.fieldFullH]));
|
|
955
|
+
}
|
|
956
|
+
rebuildFieldBindGroup() {
|
|
957
|
+
if (!this.device || !this.depthReadView || !this.fieldUniformBuffer)
|
|
958
|
+
return;
|
|
959
|
+
this.fieldBindGroup = this.device.createBindGroup({
|
|
960
|
+
label: "field layer bind group",
|
|
961
|
+
layout: this.fieldBindGroupLayout,
|
|
962
|
+
entries: [
|
|
963
|
+
{ binding: 3, resource: { buffer: this.compositeUniformBuffer } },
|
|
964
|
+
{ binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
|
|
965
|
+
{ binding: 8, resource: this.depthReadView },
|
|
966
|
+
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
967
|
+
{ binding: 11, resource: { buffer: this.castBuffer } },
|
|
968
|
+
{ binding: 13, resource: { buffer: this.audioBuffer } },
|
|
969
|
+
{ binding: 14, resource: { buffer: this.fieldUniformBuffer } },
|
|
890
970
|
],
|
|
891
971
|
});
|
|
892
972
|
}
|
|
@@ -990,6 +1070,9 @@ export class Engine {
|
|
|
990
1070
|
if (wgsl === null) {
|
|
991
1071
|
this.effect?.paramsBuffer?.destroy();
|
|
992
1072
|
this.effect = null;
|
|
1073
|
+
this.releaseParticles();
|
|
1074
|
+
this.releaseTrails();
|
|
1075
|
+
this.fieldPipeline = null;
|
|
993
1076
|
const module = this.device.createShaderModule({ label: "composite shader", code: buildCompositeShader(null) });
|
|
994
1077
|
this.compositePipelineIdentity = this.makeCompositePipeline(module, false, "composite pipeline (gamma=1)");
|
|
995
1078
|
this.compositePipelineGamma = this.makeCompositePipeline(module, true, "composite pipeline (gamma!=1)");
|
|
@@ -1003,12 +1086,62 @@ export class Engine {
|
|
|
1003
1086
|
// to one — those never follow `fn`.
|
|
1004
1087
|
const hasBackground = /\bfn\s+background\s*\(/.test(wgsl);
|
|
1005
1088
|
const hasForeground = /\bfn\s+foreground\s*\(/.test(wgsl);
|
|
1006
|
-
|
|
1089
|
+
// Particles are a THIRD mount, declared the same way — by the functions the
|
|
1090
|
+
// source defines. All three are required together: a pool with no shader to
|
|
1091
|
+
// draw it, or a draw with nothing spawning into it, is a silent blank rather
|
|
1092
|
+
// than an error, which is the worst way for an effect to fail.
|
|
1093
|
+
const pe = particleEntryPoints(wgsl);
|
|
1094
|
+
const wantsParticles = pe.init || pe.step || pe.shade;
|
|
1095
|
+
const te = trailEntryPoints(wgsl);
|
|
1096
|
+
const wantsTrails = te.width || te.shade;
|
|
1097
|
+
if (wantsTrails && !(te.width && te.shade)) {
|
|
1007
1098
|
return {
|
|
1008
1099
|
ok: false,
|
|
1009
1100
|
diagnostics: [
|
|
1010
|
-
|
|
1011
|
-
|
|
1101
|
+
`a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
|
|
1102
|
+
`fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
|
|
1103
|
+
],
|
|
1104
|
+
mounts: noMounts,
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
|
|
1108
|
+
const missing = [
|
|
1109
|
+
pe.init ? null : "fn particleInit(id: u32, seed: f32) -> Particle",
|
|
1110
|
+
pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
|
|
1111
|
+
pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
|
|
1112
|
+
].filter(Boolean);
|
|
1113
|
+
return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts };
|
|
1114
|
+
}
|
|
1115
|
+
// One file, one kind — for now.
|
|
1116
|
+
//
|
|
1117
|
+
// The two kinds compile into different modules: field functions belong to the
|
|
1118
|
+
// composite pass, particle functions to the particle pair. A file holding both
|
|
1119
|
+
// would have to be spliced into both, and each module would then need the
|
|
1120
|
+
// OTHER's scaffolding (the Particle struct in the composite; the composite's
|
|
1121
|
+
// uniforms in the particle stages) for the dead half to compile — several
|
|
1122
|
+
// declarations that exist only so unused code type-checks, and a handful of
|
|
1123
|
+
// accessors that would silently return zero on the wrong side. Splitting into
|
|
1124
|
+
// two effects costs the author nothing once a scene can hold a list, and this
|
|
1125
|
+
// says so plainly instead of failing with "unresolved type Particle" from a
|
|
1126
|
+
// pass they did not know they were compiling into.
|
|
1127
|
+
if ((wantsParticles || wantsTrails) && (hasBackground || hasForeground)) {
|
|
1128
|
+
return {
|
|
1129
|
+
ok: false,
|
|
1130
|
+
diagnostics: [
|
|
1131
|
+
"an effect declares field mounts (background/foreground) or particles, not both — " +
|
|
1132
|
+
"split them into two effects",
|
|
1133
|
+
],
|
|
1134
|
+
mounts: noMounts,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails) {
|
|
1138
|
+
return {
|
|
1139
|
+
ok: false,
|
|
1140
|
+
diagnostics: [
|
|
1141
|
+
"an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
|
|
1142
|
+
"fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
|
|
1143
|
+
"the particle trio (particleInit/particleStep/particleShade), " +
|
|
1144
|
+
"or the ribbon pair (trailWidth/trailShade)",
|
|
1012
1145
|
],
|
|
1013
1146
|
mounts: noMounts,
|
|
1014
1147
|
};
|
|
@@ -1056,19 +1189,50 @@ export class Engine {
|
|
|
1056
1189
|
: "";
|
|
1057
1190
|
// ── Compile with validation captured, not thrown at the console. Line
|
|
1058
1191
|
// numbers in diagnostics are rebased to the USER's source.
|
|
1059
|
-
|
|
1060
|
-
|
|
1192
|
+
// The composite is STATIC: user field code compiles in its own half-res
|
|
1193
|
+
// module (buildFieldShader), so a bad effect can no longer produce errors at
|
|
1194
|
+
// line numbers in a shader the author never wrote — and installing one no
|
|
1195
|
+
// longer recompiles the composite's tone-mapping half at all.
|
|
1196
|
+
const fieldEffect = hasBackground || hasForeground ? { wgsl, paramsDecl, hasBackground, hasForeground } : null;
|
|
1197
|
+
const source = buildCompositeShader(fieldEffect);
|
|
1061
1198
|
this.device.pushErrorScope("validation");
|
|
1062
1199
|
const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source });
|
|
1063
|
-
const info = await module.getCompilationInfo();
|
|
1064
1200
|
const scopeErr = await this.device.popErrorScope();
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
if (
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1201
|
+
if (scopeErr)
|
|
1202
|
+
return { ok: false, diagnostics: [scopeErr.message], mounts };
|
|
1203
|
+
let fieldPipeline = null;
|
|
1204
|
+
if (fieldEffect) {
|
|
1205
|
+
const fieldSource = buildFieldShader(fieldEffect);
|
|
1206
|
+
const userLineOffset = fieldSource.slice(0, fieldSource.indexOf(wgsl)).split("\n").length - 1;
|
|
1207
|
+
this.device.pushErrorScope("validation");
|
|
1208
|
+
const fieldModule = this.device.createShaderModule({ label: "field shader (effect)", code: fieldSource });
|
|
1209
|
+
const info = await fieldModule.getCompilationInfo();
|
|
1210
|
+
const fieldScopeErr = await this.device.popErrorScope();
|
|
1211
|
+
const diagnostics = info.messages
|
|
1212
|
+
.filter((m) => m.type === "error")
|
|
1213
|
+
.map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`);
|
|
1214
|
+
if (diagnostics.length === 0 && fieldScopeErr)
|
|
1215
|
+
diagnostics.push(fieldScopeErr.message);
|
|
1216
|
+
if (diagnostics.length > 0)
|
|
1217
|
+
return { ok: false, diagnostics, mounts };
|
|
1218
|
+
try {
|
|
1219
|
+
fieldPipeline = await this.device.createRenderPipelineAsync({
|
|
1220
|
+
label: "field layer pipeline",
|
|
1221
|
+
layout: this.fieldPipelineLayout,
|
|
1222
|
+
vertex: { module: fieldModule, entryPoint: "fieldVs" },
|
|
1223
|
+
fragment: {
|
|
1224
|
+
module: fieldModule,
|
|
1225
|
+
entryPoint: "fieldFs",
|
|
1226
|
+
targets: [{ format: "rgba16float" }, { format: "rgba16float" }],
|
|
1227
|
+
},
|
|
1228
|
+
primitive: { topology: "triangle-list" },
|
|
1229
|
+
multisample: { count: 1 },
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
catch (e) {
|
|
1233
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts };
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1072
1236
|
let identity;
|
|
1073
1237
|
let gamma;
|
|
1074
1238
|
try {
|
|
@@ -1092,8 +1256,47 @@ export class Engine {
|
|
|
1092
1256
|
catch (e) {
|
|
1093
1257
|
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts };
|
|
1094
1258
|
}
|
|
1259
|
+
// Built BEFORE the swap: a particle stage that fails to compile has to leave
|
|
1260
|
+
// the previously installed effect running, exactly as a bad composite does.
|
|
1261
|
+
let particles = null;
|
|
1262
|
+
if (wantsParticles) {
|
|
1263
|
+
const built = await this.buildParticles(wgsl, anchors.filter((a) => a.trail).length);
|
|
1264
|
+
if (!built.ok)
|
|
1265
|
+
return { ok: false, diagnostics: built.diagnostics, mounts };
|
|
1266
|
+
particles = built.state;
|
|
1267
|
+
}
|
|
1268
|
+
let trails = null;
|
|
1269
|
+
if (wantsTrails) {
|
|
1270
|
+
// Only anchors that asked for `trail` have a path to draw; a ribbon on a
|
|
1271
|
+
// bone recorded without one would read zeroes and paint a line to the origin.
|
|
1272
|
+
const trailSlots = anchors.filter((a) => a.trail).length;
|
|
1273
|
+
if (trailSlots === 0) {
|
|
1274
|
+
return {
|
|
1275
|
+
ok: false,
|
|
1276
|
+
diagnostics: ["a ribbon effect needs at least one // @anchor <bone> trail"],
|
|
1277
|
+
mounts,
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
const built = await this.buildTrails(wgsl, trailSlots);
|
|
1281
|
+
if (!built.ok)
|
|
1282
|
+
return { ok: false, diagnostics: built.diagnostics, mounts };
|
|
1283
|
+
trails = built.state;
|
|
1284
|
+
}
|
|
1095
1285
|
// ── Swap — only now does the old effect (and its params buffer) go away.
|
|
1096
1286
|
this.effect?.paramsBuffer?.destroy();
|
|
1287
|
+
this.releaseParticles();
|
|
1288
|
+
this.releaseTrails();
|
|
1289
|
+
this.particles = particles;
|
|
1290
|
+
this.trails = trails;
|
|
1291
|
+
this.fieldPipeline = fieldPipeline;
|
|
1292
|
+
// `// @fullres`: an effect that draws SUB-PIXEL detail — hairline curves,
|
|
1293
|
+
// scanlines — declares it and pays full price; everything soft stays at
|
|
1294
|
+
// half. The field shader reads its size from fieldU, so nothing else moves.
|
|
1295
|
+
const wantScale = /^\s*\/\/\s*@fullres\s*$/m.test(wgsl) ? 1 : 2;
|
|
1296
|
+
if (wantScale !== this.fieldScale) {
|
|
1297
|
+
this.fieldScale = wantScale;
|
|
1298
|
+
this.createFieldTargets();
|
|
1299
|
+
}
|
|
1097
1300
|
let paramsBuffer = null;
|
|
1098
1301
|
if (entries.length) {
|
|
1099
1302
|
paramsBuffer = this.device.createBuffer({
|
|
@@ -1117,6 +1320,366 @@ export class Engine {
|
|
|
1117
1320
|
this.writeCompositeViewUniforms();
|
|
1118
1321
|
return { ok: true, diagnostics: [], mounts };
|
|
1119
1322
|
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Compile an effect's particle stages and allocate its pool.
|
|
1325
|
+
*
|
|
1326
|
+
* Two modules, not one: the compute and render stages bind the same buffer
|
|
1327
|
+
* with different access (read_write vs read), and a single module would have
|
|
1328
|
+
* to pick one. Compiling them separately also means an author's helper names
|
|
1329
|
+
* live in their own compilation unit, which is what lets two effects both
|
|
1330
|
+
* define `hash21` without meeting.
|
|
1331
|
+
*/
|
|
1332
|
+
async buildParticles(wgsl, trailSlots) {
|
|
1333
|
+
// No pragma means "some": an author who wrote the trio clearly wants
|
|
1334
|
+
// particles, and failing over a missing comment would be pedantry.
|
|
1335
|
+
const count = parseParticleCount(wgsl, Engine.MAX_PARTICLES) || 1024;
|
|
1336
|
+
const src = { wgsl, count, blend: parseParticleBlend(wgsl), bloom: parseParticleBloom(wgsl) };
|
|
1337
|
+
// Sparks want to spawn where a trail is, so the particle stages see the same
|
|
1338
|
+
// cast buffer the trail draw reads.
|
|
1339
|
+
const cast = {
|
|
1340
|
+
subjects: MAX_EFFECT_SUBJECTS,
|
|
1341
|
+
samples: TRAIL_SAMPLES,
|
|
1342
|
+
base: MAX_EFFECT_SUBJECTS * 3,
|
|
1343
|
+
trailBase: CAST_TRAIL_BASE,
|
|
1344
|
+
slots: trailSlots,
|
|
1345
|
+
};
|
|
1346
|
+
const compile = async (code, label) => {
|
|
1347
|
+
const offset = code.slice(0, code.indexOf(wgsl)).split("\n").length - 1;
|
|
1348
|
+
this.device.pushErrorScope("validation");
|
|
1349
|
+
const module = this.device.createShaderModule({ label, code });
|
|
1350
|
+
const info = await module.getCompilationInfo();
|
|
1351
|
+
const scopeErr = await this.device.popErrorScope();
|
|
1352
|
+
const diagnostics = info.messages
|
|
1353
|
+
.filter((m) => m.type === "error")
|
|
1354
|
+
.map((m) => `${Math.max(0, m.lineNum - offset)}:${m.linePos} ${m.message}`);
|
|
1355
|
+
if (diagnostics.length === 0 && scopeErr)
|
|
1356
|
+
diagnostics.push(scopeErr.message);
|
|
1357
|
+
return diagnostics.length ? diagnostics : module;
|
|
1358
|
+
};
|
|
1359
|
+
const computeModule = await compile(buildParticleComputeShader(src, cast), "particle compute");
|
|
1360
|
+
if (Array.isArray(computeModule))
|
|
1361
|
+
return { ok: false, diagnostics: computeModule };
|
|
1362
|
+
const renderModule = await compile(buildParticleRenderShader(src, cast), "particle render");
|
|
1363
|
+
if (Array.isArray(renderModule))
|
|
1364
|
+
return { ok: false, diagnostics: renderModule };
|
|
1365
|
+
const buffer = this.device.createBuffer({
|
|
1366
|
+
label: "particle pool",
|
|
1367
|
+
size: count * PARTICLE_STRIDE,
|
|
1368
|
+
usage: GPUBufferUsage.STORAGE,
|
|
1369
|
+
});
|
|
1370
|
+
const uniform = this.device.createBuffer({
|
|
1371
|
+
label: "particle uniforms",
|
|
1372
|
+
size: 16,
|
|
1373
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1374
|
+
});
|
|
1375
|
+
const uniformBytes = new ArrayBuffer(16);
|
|
1376
|
+
const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) };
|
|
1377
|
+
// Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
|
|
1378
|
+
// visible to the vertex stage at all (WebGPU forbids it — a vertex shader
|
|
1379
|
+
// that could write memory has no defined ordering against the rasteriser).
|
|
1380
|
+
// Declaring one set of flags for both layouts is what made the pipeline
|
|
1381
|
+
// layout invalid, and the error surfaces later and unhelpfully as "invalid
|
|
1382
|
+
// due to a previous error".
|
|
1383
|
+
const layoutFor = (storage, visibility) => this.device.createBindGroupLayout({
|
|
1384
|
+
entries: [
|
|
1385
|
+
{ binding: 0, visibility, buffer: { type: storage } },
|
|
1386
|
+
{ binding: 1, visibility, buffer: { type: "uniform" } },
|
|
1387
|
+
{ binding: 2, visibility, buffer: { type: "uniform" } },
|
|
1388
|
+
{ binding: 3, visibility, buffer: { type: "read-only-storage" } },
|
|
1389
|
+
{ binding: 4, visibility, buffer: { type: "read-only-storage" } },
|
|
1390
|
+
],
|
|
1391
|
+
});
|
|
1392
|
+
const bindFor = (layout) => this.device.createBindGroup({
|
|
1393
|
+
layout,
|
|
1394
|
+
entries: [
|
|
1395
|
+
{ binding: 0, resource: { buffer } },
|
|
1396
|
+
{ binding: 1, resource: { buffer: uniform } },
|
|
1397
|
+
{ binding: 2, resource: { buffer: this.cameraUniformBuffer } },
|
|
1398
|
+
{ binding: 3, resource: { buffer: this.castBuffer } },
|
|
1399
|
+
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
1400
|
+
],
|
|
1401
|
+
});
|
|
1402
|
+
const computeLayout = layoutFor("storage", GPUShaderStage.COMPUTE);
|
|
1403
|
+
const renderLayout = layoutFor("read-only-storage", GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT);
|
|
1404
|
+
// Additive keeps the destination and adds to it; the alpha channel is left
|
|
1405
|
+
// alone (dst factor one, src zero) so a glow does not also claim coverage
|
|
1406
|
+
// it never occluded.
|
|
1407
|
+
// Additive effects need the MASK to sum like the colour does — see the
|
|
1408
|
+
// fragment shaders' mask comment. rg8unorm clamps the sum at 1, which is the
|
|
1409
|
+
// saturation alpha-over would reach anyway.
|
|
1410
|
+
const maskTarget = src.blend === "additive"
|
|
1411
|
+
? {
|
|
1412
|
+
format: Engine.BLOOM_MASK_FORMAT,
|
|
1413
|
+
blend: {
|
|
1414
|
+
color: { srcFactor: "one", dstFactor: "one", operation: "add" },
|
|
1415
|
+
alpha: { srcFactor: "one", dstFactor: "one", operation: "add" },
|
|
1416
|
+
},
|
|
1417
|
+
}
|
|
1418
|
+
: this.sceneTargets[1];
|
|
1419
|
+
const colorTarget = src.blend === "additive"
|
|
1420
|
+
? {
|
|
1421
|
+
format: this.hdrFormat,
|
|
1422
|
+
blend: {
|
|
1423
|
+
color: { srcFactor: "one", dstFactor: "one", operation: "add" },
|
|
1424
|
+
alpha: { srcFactor: "zero", dstFactor: "one", operation: "add" },
|
|
1425
|
+
},
|
|
1426
|
+
}
|
|
1427
|
+
: this.sceneTargets[0];
|
|
1428
|
+
this.device.pushErrorScope("validation");
|
|
1429
|
+
try {
|
|
1430
|
+
const compute = await this.device.createComputePipelineAsync({
|
|
1431
|
+
label: "particle compute pipeline",
|
|
1432
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
|
|
1433
|
+
compute: { module: computeModule, entryPoint: "main" },
|
|
1434
|
+
});
|
|
1435
|
+
const render = await this.device.createRenderPipelineAsync({
|
|
1436
|
+
label: "particle render pipeline",
|
|
1437
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [renderLayout] }),
|
|
1438
|
+
vertex: { module: renderModule, entryPoint: "vs" },
|
|
1439
|
+
fragment: { module: renderModule, entryPoint: "fs", targets: [colorTarget, maskTarget] },
|
|
1440
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
1441
|
+
// Tested but not WRITTEN: particles are transparent, so writing depth
|
|
1442
|
+
// would make whichever quad drew first occlude the ones behind it.
|
|
1443
|
+
depthStencil: { format: "depth24plus-stencil8", depthWriteEnabled: false, depthCompare: "less-equal" },
|
|
1444
|
+
multisample: { count: Engine.MULTISAMPLE_COUNT },
|
|
1445
|
+
});
|
|
1446
|
+
const scoped = await this.device.popErrorScope();
|
|
1447
|
+
if (scoped) {
|
|
1448
|
+
buffer.destroy();
|
|
1449
|
+
uniform.destroy();
|
|
1450
|
+
return { ok: false, diagnostics: [scoped.message] };
|
|
1451
|
+
}
|
|
1452
|
+
return {
|
|
1453
|
+
ok: true,
|
|
1454
|
+
state: {
|
|
1455
|
+
count,
|
|
1456
|
+
buffer,
|
|
1457
|
+
uniform,
|
|
1458
|
+
// One 16-byte block, two views: time/dt are floats and count/frame are
|
|
1459
|
+
// integers, and writing them through separate arrays would upload two
|
|
1460
|
+
// different buffers with the same name.
|
|
1461
|
+
data: uniformView.floats,
|
|
1462
|
+
counts: uniformView.uints,
|
|
1463
|
+
compute,
|
|
1464
|
+
computeLayout,
|
|
1465
|
+
computeBind: bindFor(computeLayout),
|
|
1466
|
+
render,
|
|
1467
|
+
renderLayout,
|
|
1468
|
+
renderBind: bindFor(renderLayout),
|
|
1469
|
+
rebind: () => ({ computeBind: bindFor(computeLayout), renderBind: bindFor(renderLayout) }),
|
|
1470
|
+
},
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
catch (e) {
|
|
1474
|
+
await this.device.popErrorScope();
|
|
1475
|
+
buffer.destroy();
|
|
1476
|
+
uniform.destroy();
|
|
1477
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] };
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Step the pool, before the scene pass.
|
|
1482
|
+
*
|
|
1483
|
+
* Outside the render pass because a compute dispatch cannot be encoded inside
|
|
1484
|
+
* one — and it has to precede the draw that reads the same buffer, or the
|
|
1485
|
+
* quads render last frame's positions.
|
|
1486
|
+
*/
|
|
1487
|
+
stepParticles(encoder, deltaTime) {
|
|
1488
|
+
const p = this.particles;
|
|
1489
|
+
if (!p)
|
|
1490
|
+
return;
|
|
1491
|
+
p.data[0] = this.sceneClock - this.effectEpochScene;
|
|
1492
|
+
// Clamped: a backgrounded tab returns with a delta of whole seconds, and an
|
|
1493
|
+
// unclamped step flings every particle out of the scene in one frame.
|
|
1494
|
+
p.data[1] = Math.min(0.1, Math.max(0, deltaTime));
|
|
1495
|
+
p.counts[2] = p.count;
|
|
1496
|
+
p.counts[3] = this.particleFrame++;
|
|
1497
|
+
this.device.queue.writeBuffer(p.uniform, 0, p.data.buffer);
|
|
1498
|
+
const cp = encoder.beginComputePass({ label: "particles" });
|
|
1499
|
+
cp.setPipeline(p.compute);
|
|
1500
|
+
cp.setBindGroup(0, p.computeBind);
|
|
1501
|
+
cp.dispatchWorkgroups(Math.ceil(p.count / 64));
|
|
1502
|
+
cp.end();
|
|
1503
|
+
}
|
|
1504
|
+
/** Draw the pool. Inside the scene pass, so it is depth-tested and pre-bloom. */
|
|
1505
|
+
renderParticles(pass) {
|
|
1506
|
+
const p = this.particles;
|
|
1507
|
+
if (!p)
|
|
1508
|
+
return;
|
|
1509
|
+
pass.setPipeline(p.render);
|
|
1510
|
+
pass.setBindGroup(0, p.renderBind);
|
|
1511
|
+
pass.draw(6, p.count);
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Compile an effect's ribbon stage.
|
|
1515
|
+
*
|
|
1516
|
+
* One instance per (slot, subject, segment), so a scene with several dancers
|
|
1517
|
+
* and several declared bones is still one draw and nothing is computed per
|
|
1518
|
+
* frame on the CPU.
|
|
1519
|
+
*/
|
|
1520
|
+
async buildTrails(wgsl, slots) {
|
|
1521
|
+
const src = { wgsl, slots, blend: parseParticleBlend(wgsl), bloom: parseParticleBloom(wgsl) };
|
|
1522
|
+
const code = buildTrailShader(src, {
|
|
1523
|
+
subjects: MAX_EFFECT_SUBJECTS,
|
|
1524
|
+
samples: TRAIL_SAMPLES,
|
|
1525
|
+
base: MAX_EFFECT_SUBJECTS * 3,
|
|
1526
|
+
trailBase: CAST_TRAIL_BASE,
|
|
1527
|
+
});
|
|
1528
|
+
const offset = code.slice(0, code.indexOf(wgsl)).split("\n").length - 1;
|
|
1529
|
+
this.device.pushErrorScope("validation");
|
|
1530
|
+
const module = this.device.createShaderModule({ label: "trail shader", code });
|
|
1531
|
+
const info = await module.getCompilationInfo();
|
|
1532
|
+
const scopeErr = await this.device.popErrorScope();
|
|
1533
|
+
const diagnostics = info.messages
|
|
1534
|
+
.filter((m) => m.type === "error")
|
|
1535
|
+
.map((m) => `${Math.max(0, m.lineNum - offset)}:${m.linePos} ${m.message}`);
|
|
1536
|
+
if (diagnostics.length === 0 && scopeErr)
|
|
1537
|
+
diagnostics.push(scopeErr.message);
|
|
1538
|
+
if (diagnostics.length)
|
|
1539
|
+
return { ok: false, diagnostics };
|
|
1540
|
+
const uniform = this.device.createBuffer({
|
|
1541
|
+
label: "trail uniforms",
|
|
1542
|
+
size: 16,
|
|
1543
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1544
|
+
});
|
|
1545
|
+
const layout = this.device.createBindGroupLayout({
|
|
1546
|
+
entries: [
|
|
1547
|
+
{
|
|
1548
|
+
binding: 0,
|
|
1549
|
+
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
|
1550
|
+
buffer: { type: "read-only-storage" },
|
|
1551
|
+
},
|
|
1552
|
+
{ binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
1553
|
+
{ binding: 2, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
1554
|
+
// The scene's depth, for the fragment's manual occlusion test.
|
|
1555
|
+
{
|
|
1556
|
+
binding: 3,
|
|
1557
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
1558
|
+
texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
|
|
1559
|
+
},
|
|
1560
|
+
// The audio analysis, for rzAudio* in width and shade alike.
|
|
1561
|
+
{ binding: 4, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
1562
|
+
],
|
|
1563
|
+
});
|
|
1564
|
+
// ONE target: the ribbons' own layer, blended with MAX in both channels.
|
|
1565
|
+
// Max is the original's core-takes-the-max rule as a blend mode — parallel
|
|
1566
|
+
// strands of a circling hand meet as max and cannot double into bright
|
|
1567
|
+
// dashes, which every additive variant of this pipeline drew. The layer is
|
|
1568
|
+
// composited over the frame after tone mapping (see composite.ts), which is
|
|
1569
|
+
// where the fullscreen ribbon always ran.
|
|
1570
|
+
const layerTarget = {
|
|
1571
|
+
format: "rgba16float",
|
|
1572
|
+
blend: {
|
|
1573
|
+
color: { srcFactor: "one", dstFactor: "one", operation: "max" },
|
|
1574
|
+
alpha: { srcFactor: "one", dstFactor: "one", operation: "max" },
|
|
1575
|
+
},
|
|
1576
|
+
};
|
|
1577
|
+
this.device.pushErrorScope("validation");
|
|
1578
|
+
try {
|
|
1579
|
+
const pipeline = await this.device.createRenderPipelineAsync({
|
|
1580
|
+
label: "trail pipeline",
|
|
1581
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
|
1582
|
+
vertex: { module, entryPoint: "vs" },
|
|
1583
|
+
fragment: { module, entryPoint: "fs", targets: [layerTarget] },
|
|
1584
|
+
primitive: { topology: "triangle-list", cullMode: "none" },
|
|
1585
|
+
// No depth attachment and no MSAA: the layer is a lone colour target,
|
|
1586
|
+
// and occlusion happens in the fragment against the scene's own depth.
|
|
1587
|
+
multisample: { count: 1 },
|
|
1588
|
+
});
|
|
1589
|
+
const scoped = await this.device.popErrorScope();
|
|
1590
|
+
if (scoped) {
|
|
1591
|
+
uniform.destroy();
|
|
1592
|
+
return { ok: false, diagnostics: [scoped.message] };
|
|
1593
|
+
}
|
|
1594
|
+
return {
|
|
1595
|
+
ok: true,
|
|
1596
|
+
state: {
|
|
1597
|
+
instances: slots * MAX_EFFECT_SUBJECTS * (TRAIL_SAMPLES - 1) * TRAIL_SUBDIVISIONS,
|
|
1598
|
+
uniform,
|
|
1599
|
+
data: new Float32Array(4),
|
|
1600
|
+
pipeline,
|
|
1601
|
+
layout,
|
|
1602
|
+
bind: this.device.createBindGroup({
|
|
1603
|
+
layout,
|
|
1604
|
+
entries: [
|
|
1605
|
+
{ binding: 0, resource: { buffer: this.castBuffer } },
|
|
1606
|
+
{ binding: 1, resource: { buffer: uniform } },
|
|
1607
|
+
{ binding: 2, resource: { buffer: this.cameraUniformBuffer } },
|
|
1608
|
+
{ binding: 3, resource: this.depthReadView },
|
|
1609
|
+
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
1610
|
+
],
|
|
1611
|
+
}),
|
|
1612
|
+
},
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
catch (e) {
|
|
1616
|
+
await this.device.popErrorScope();
|
|
1617
|
+
uniform.destroy();
|
|
1618
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)] };
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
releaseTrails() {
|
|
1622
|
+
this.trails?.uniform.destroy();
|
|
1623
|
+
this.trails = null;
|
|
1624
|
+
}
|
|
1625
|
+
/** Draw the ribbons into their own layer — cleared, max-blended, and
|
|
1626
|
+
* composited over the frame after tone mapping. */
|
|
1627
|
+
renderTrailLayer(encoder) {
|
|
1628
|
+
const t = this.trails;
|
|
1629
|
+
if (!t || !this.trailLayerView)
|
|
1630
|
+
return;
|
|
1631
|
+
t.data[0] = this.sceneClock - this.effectEpochScene;
|
|
1632
|
+
this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer);
|
|
1633
|
+
const pass = encoder.beginRenderPass({
|
|
1634
|
+
label: "trail layer",
|
|
1635
|
+
colorAttachments: [
|
|
1636
|
+
{ view: this.trailLayerView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
1637
|
+
],
|
|
1638
|
+
});
|
|
1639
|
+
pass.setPipeline(t.pipeline);
|
|
1640
|
+
pass.setBindGroup(0, t.bind);
|
|
1641
|
+
pass.draw(6, t.instances);
|
|
1642
|
+
pass.end();
|
|
1643
|
+
}
|
|
1644
|
+
/** The user's field mounts, drawn at half resolution for the composite to
|
|
1645
|
+
* upsample. Runs the whole quad — uniform control flow, so effects may use
|
|
1646
|
+
* derivatives freely, which the old inline path had to forbid. */
|
|
1647
|
+
renderFieldPass(encoder) {
|
|
1648
|
+
if (!this.fieldPipeline || !this.fieldBgView || !this.fieldFgView || !this.fieldBindGroup)
|
|
1649
|
+
return;
|
|
1650
|
+
const pass = encoder.beginRenderPass({
|
|
1651
|
+
label: "field layer",
|
|
1652
|
+
colorAttachments: [
|
|
1653
|
+
{ view: this.fieldBgView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
1654
|
+
{ view: this.fieldFgView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
1655
|
+
],
|
|
1656
|
+
});
|
|
1657
|
+
pass.setPipeline(this.fieldPipeline);
|
|
1658
|
+
pass.setBindGroup(0, this.fieldBindGroup);
|
|
1659
|
+
pass.draw(3);
|
|
1660
|
+
pass.end();
|
|
1661
|
+
}
|
|
1662
|
+
/** The trail bind group holds the depth view, which a resize recreates. */
|
|
1663
|
+
rebindTrails() {
|
|
1664
|
+
const t = this.trails;
|
|
1665
|
+
if (!t || !this.depthReadView)
|
|
1666
|
+
return;
|
|
1667
|
+
t.bind = this.device.createBindGroup({
|
|
1668
|
+
layout: t.layout,
|
|
1669
|
+
entries: [
|
|
1670
|
+
{ binding: 0, resource: { buffer: this.castBuffer } },
|
|
1671
|
+
{ binding: 1, resource: { buffer: t.uniform } },
|
|
1672
|
+
{ binding: 2, resource: { buffer: this.cameraUniformBuffer } },
|
|
1673
|
+
{ binding: 3, resource: this.depthReadView },
|
|
1674
|
+
{ binding: 4, resource: { buffer: this.audioBuffer } },
|
|
1675
|
+
],
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
releaseParticles() {
|
|
1679
|
+
this.particles?.buffer.destroy();
|
|
1680
|
+
this.particles?.uniform.destroy();
|
|
1681
|
+
this.particles = null;
|
|
1682
|
+
}
|
|
1120
1683
|
/** Which mounts the installed effect declared. Both false when none is set. */
|
|
1121
1684
|
getEffectMounts() {
|
|
1122
1685
|
return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false };
|
|
@@ -1517,6 +2080,48 @@ export class Engine {
|
|
|
1517
2080
|
addressModeU: "repeat",
|
|
1518
2081
|
addressModeV: "repeat",
|
|
1519
2082
|
});
|
|
2083
|
+
this.trailFallbackView = this.device
|
|
2084
|
+
.createTexture({
|
|
2085
|
+
label: "trail layer fallback (1x1 transparent)",
|
|
2086
|
+
size: [1, 1],
|
|
2087
|
+
format: "rgba16float",
|
|
2088
|
+
usage: GPUTextureUsage.TEXTURE_BINDING,
|
|
2089
|
+
})
|
|
2090
|
+
.createView();
|
|
2091
|
+
this.audioFallbackBuffer = this.device.createBuffer({
|
|
2092
|
+
label: "audio analysis fallback (silence)",
|
|
2093
|
+
size: 32,
|
|
2094
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
2095
|
+
});
|
|
2096
|
+
this.audioBuffer = this.audioFallbackBuffer;
|
|
2097
|
+
this.fieldUniformBuffer = this.device.createBuffer({
|
|
2098
|
+
label: "field layer uniforms (half size, full size)",
|
|
2099
|
+
size: 16,
|
|
2100
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2101
|
+
});
|
|
2102
|
+
// The field pass's own layout: the subset of the composite's bindings the
|
|
2103
|
+
// user's code can statically reach, WITHOUT the field textures themselves —
|
|
2104
|
+
// a pass may not sample its own attachments, and WebGPU counts every
|
|
2105
|
+
// resource in a bound group whether the shader reads it or not.
|
|
2106
|
+
this.fieldBindGroupLayout = this.device.createBindGroupLayout({
|
|
2107
|
+
label: "field layer bind layout",
|
|
2108
|
+
entries: [
|
|
2109
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
2110
|
+
{ binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
2111
|
+
{
|
|
2112
|
+
binding: 8,
|
|
2113
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
2114
|
+
texture: { sampleType: "depth", viewDimension: "2d", multisampled: true },
|
|
2115
|
+
},
|
|
2116
|
+
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
2117
|
+
{ binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
2118
|
+
{ binding: 13, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
2119
|
+
{ binding: 14, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
2120
|
+
],
|
|
2121
|
+
});
|
|
2122
|
+
this.fieldPipelineLayout = this.device.createPipelineLayout({
|
|
2123
|
+
bindGroupLayouts: [this.fieldBindGroupLayout],
|
|
2124
|
+
});
|
|
1520
2125
|
this.fallbackMaterialTexture = this.device.createTexture({
|
|
1521
2126
|
label: "fallback material texture (1x1 white)",
|
|
1522
2127
|
size: [1, 1],
|
|
@@ -2133,6 +2738,15 @@ export class Engine {
|
|
|
2133
2738
|
// The cast, for rzSubject/rzAnchor. Always bound so the base shader's
|
|
2134
2739
|
// layout matches; the base shader simply never reads it.
|
|
2135
2740
|
{ binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
2741
|
+
// The trail layer. Bound to a transparent 1×1 when no ribbon effect is
|
|
2742
|
+
// installed, so the base shader's layout always matches.
|
|
2743
|
+
{ binding: 12, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
2744
|
+
// The audio analysis, for rzAudio*. Silence fallback when the scene has
|
|
2745
|
+
// no track.
|
|
2746
|
+
{ binding: 13, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
2747
|
+
// The field layer's two halves. Fallback-bound when no field effect runs.
|
|
2748
|
+
{ binding: 15, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
2749
|
+
{ binding: 16, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
2136
2750
|
],
|
|
2137
2751
|
});
|
|
2138
2752
|
this.fallbackEquirectTexture = this.device.createTexture({
|
|
@@ -2294,6 +2908,21 @@ export class Engine {
|
|
|
2294
2908
|
format: this.hdrFormat,
|
|
2295
2909
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
2296
2910
|
});
|
|
2911
|
+
// rgba16float explicitly, NOT hdrFormat: the composite reads this layer's
|
|
2912
|
+
// ALPHA to composite it over the frame, and an rg11b10 hdr fallback has no
|
|
2913
|
+
// alpha channel to read.
|
|
2914
|
+
this.trailLayerTexture?.destroy();
|
|
2915
|
+
this.trailLayerTexture = this.device.createTexture({
|
|
2916
|
+
label: "trail layer",
|
|
2917
|
+
size: [width, height],
|
|
2918
|
+
format: "rgba16float",
|
|
2919
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
2920
|
+
});
|
|
2921
|
+
this.trailLayerView = this.trailLayerTexture.createView();
|
|
2922
|
+
// The field layer — half resolution by default, full for @fullres effects.
|
|
2923
|
+
this.fieldFullW = width;
|
|
2924
|
+
this.fieldFullH = height;
|
|
2925
|
+
this.createFieldTargets();
|
|
2297
2926
|
// Bloom-mask MRT attachments — same dims + MSAA as HDR so they share the render pass.
|
|
2298
2927
|
// MS buffer gets resolved into maskResolveTexture, which the bloom blit pass samples.
|
|
2299
2928
|
this.multisampleMaskTexture = this.device.createTexture({
|
|
@@ -2350,6 +2979,7 @@ export class Engine {
|
|
|
2350
2979
|
});
|
|
2351
2980
|
const depthTextureView = this.depthTexture.createView();
|
|
2352
2981
|
this.depthReadView = this.depthTexture.createView({ aspect: "depth-only" });
|
|
2982
|
+
this.rebindTrails();
|
|
2353
2983
|
// storeOp="discard" on MSAA views keeps per-sample data in Apple TBDR tile memory —
|
|
2354
2984
|
// only the resolveTarget (hdrResolveTexture / maskResolveView) gets written to RAM.
|
|
2355
2985
|
// With storeOp="store" Safari's Metal backend spills the full MS buffer every frame
|
|
@@ -2741,6 +3371,63 @@ export class Engine {
|
|
|
2741
3371
|
getCameraVmdDuration() {
|
|
2742
3372
|
return this.cameraAnimation?.duration ?? 0;
|
|
2743
3373
|
}
|
|
3374
|
+
/**
|
|
3375
|
+
* Install a track's precomputed analysis for the rzAudio* effect functions:
|
|
3376
|
+
* `data` is frames × (2 + bands) floats — loudness, bass onset, then the band
|
|
3377
|
+
* magnitudes, all 0..1 — sampled by the clock given to setAudioTime. Null
|
|
3378
|
+
* clears back to silence.
|
|
3379
|
+
*
|
|
3380
|
+
* Precomputed for the WHOLE track, never fed live from an analyser: an export
|
|
3381
|
+
* steps the engine frame by frame rather than playing in real time, so live
|
|
3382
|
+
* analysis would render silence into the exported video.
|
|
3383
|
+
*/
|
|
3384
|
+
setAudioData(data, bandsPerFrame, secondsPerFrame) {
|
|
3385
|
+
if (this.audioBuffer !== this.audioFallbackBuffer)
|
|
3386
|
+
this.audioBuffer.destroy();
|
|
3387
|
+
if (!data || data.length === 0) {
|
|
3388
|
+
this.audioBuffer = this.audioFallbackBuffer;
|
|
3389
|
+
}
|
|
3390
|
+
else {
|
|
3391
|
+
const frames = Math.floor(data.length / (bandsPerFrame + 2));
|
|
3392
|
+
const payload = new Float32Array(8 + data.length);
|
|
3393
|
+
payload[0] = frames;
|
|
3394
|
+
payload[1] = bandsPerFrame;
|
|
3395
|
+
payload[2] = secondsPerFrame;
|
|
3396
|
+
payload.set(data, 8);
|
|
3397
|
+
this.audioBuffer = this.device.createBuffer({
|
|
3398
|
+
label: "audio analysis",
|
|
3399
|
+
size: payload.byteLength,
|
|
3400
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
3401
|
+
});
|
|
3402
|
+
this.device.queue.writeBuffer(this.audioBuffer, 0, payload);
|
|
3403
|
+
}
|
|
3404
|
+
// Every consumer holds the buffer by reference in a bind group; all of them
|
|
3405
|
+
// re-bind so audio arriving after an effect (or before one) both work.
|
|
3406
|
+
this.rebuildCompositeBindGroup();
|
|
3407
|
+
this.rebindTrails();
|
|
3408
|
+
if (this.particles) {
|
|
3409
|
+
const b = this.particles.rebind();
|
|
3410
|
+
this.particles.computeBind = b.computeBind;
|
|
3411
|
+
this.particles.renderBind = b.renderBind;
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
/**
|
|
3415
|
+
* Where the track is NOW, in seconds — written by whoever owns playback: the
|
|
3416
|
+
* editor's audio clock, the viewer's, or the export loop with its exact
|
|
3417
|
+
* per-frame time. A 4-byte header write, cheap enough for every frame.
|
|
3418
|
+
*/
|
|
3419
|
+
setAudioTime(seconds, playing = true) {
|
|
3420
|
+
if (this.audioBuffer === this.audioFallbackBuffer)
|
|
3421
|
+
return;
|
|
3422
|
+
this.audioTimeScratch[0] = seconds;
|
|
3423
|
+
this.audioTimeScratch[1] = playing ? 1 : 0;
|
|
3424
|
+
this.device.queue.writeBuffer(this.audioBuffer, 12, this.audioTimeScratch);
|
|
3425
|
+
}
|
|
3426
|
+
/** Every camera keyframe's frame index — what a timeline draws as its cuts.
|
|
3427
|
+
* Empty when no camera VMD is loaded. */
|
|
3428
|
+
getCameraVmdKeyframes() {
|
|
3429
|
+
return this.cameraAnimation?.keyframeIndices() ?? [];
|
|
3430
|
+
}
|
|
2744
3431
|
/** Drop the loaded camera VMD and return to orbit control. */
|
|
2745
3432
|
clearCameraVmd() {
|
|
2746
3433
|
this.cameraAnimation = null;
|
|
@@ -4611,7 +5298,7 @@ export class Engine {
|
|
|
4611
5298
|
// write leaves dofU[0].x at 0 while DoF is off, so refreshing it does not
|
|
4612
5299
|
// switch the gather on.
|
|
4613
5300
|
const dofOn = this.depthOfField.enabled;
|
|
4614
|
-
const depthRead = dofOn || (this.effect?.hasForeground ?? false);
|
|
5301
|
+
const depthRead = dofOn || (this.effect?.hasForeground ?? false) || this.trails !== null;
|
|
4615
5302
|
this.renderPassDescriptor.depthStencilAttachment.depthStoreOp = depthRead ? "store" : "discard";
|
|
4616
5303
|
if (depthRead)
|
|
4617
5304
|
this.writeDepthOfFieldUniforms();
|
|
@@ -4643,6 +5330,7 @@ export class Engine {
|
|
|
4643
5330
|
sp.end();
|
|
4644
5331
|
this.shadowMapPopulated = hasModels;
|
|
4645
5332
|
}
|
|
5333
|
+
this.stepParticles(encoder, deltaTime);
|
|
4646
5334
|
const pass = encoder.beginRenderPass(this.renderPassDescriptor);
|
|
4647
5335
|
// Phase order: opaque models → ground → transparent fabric.
|
|
4648
5336
|
// The ground shader is the most expensive full-coverage draw in the frame
|
|
@@ -4663,7 +5351,17 @@ export class Engine {
|
|
|
4663
5351
|
if (inst.model.visible)
|
|
4664
5352
|
this.renderModelTransparentPhase(pass, inst);
|
|
4665
5353
|
});
|
|
5354
|
+
// Last in the pass: depth-tested against everything drawn above, so a
|
|
5355
|
+
// particle behind the character is simply hidden, and still inside the HDR
|
|
5356
|
+
// target so an `@bloom` effect reaches the pyramid below.
|
|
5357
|
+
this.renderParticles(pass);
|
|
4666
5358
|
pass.end();
|
|
5359
|
+
// Ribbons draw AFTER the scene pass ends, so its depth is resolved for
|
|
5360
|
+
// their manual occlusion test — and before the composite that reads them.
|
|
5361
|
+
this.renderTrailLayer(encoder);
|
|
5362
|
+
// The field mounts, likewise: after the scene so foregrounds can read its
|
|
5363
|
+
// depth, before the composite that samples both layers.
|
|
5364
|
+
this.renderFieldPass(encoder);
|
|
4667
5365
|
// Bloom pyramid (EEVEE 3.6):
|
|
4668
5366
|
// 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
|
|
4669
5367
|
// 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
|
|
@@ -5475,15 +6173,38 @@ export class Engine {
|
|
|
5475
6173
|
ring = { pos: [], t: [] };
|
|
5476
6174
|
this.anchorTrail.set(key, ring);
|
|
5477
6175
|
}
|
|
6176
|
+
// A TELEPORT is not motion. A model popping from the origin to its place at
|
|
6177
|
+
// load, a scrub, a scene swap — the bone genuinely moves many units in one
|
|
6178
|
+
// frame, and a recorder that faithfully keeps both ends hands every reader a
|
|
6179
|
+
// path across the world: the ribbon drew it as a streak and the sparks
|
|
6180
|
+
// seeded a burst along it. Fifty units per second is far beyond any dance
|
|
6181
|
+
// (a hard flick peaks around twenty); past it, the history restarts here.
|
|
6182
|
+
if (ring.pos.length > 0) {
|
|
6183
|
+
const dx = pos.x - ring.pos[0];
|
|
6184
|
+
const dy = pos.y - ring.pos[1];
|
|
6185
|
+
const dz = pos.z - ring.pos[2];
|
|
6186
|
+
const dt = Math.max(1 / 120, this.sceneClock - ring.t[0]);
|
|
6187
|
+
if (Math.hypot(dx, dy, dz) / dt > 50) {
|
|
6188
|
+
ring.pos.length = 0;
|
|
6189
|
+
ring.t.length = 0;
|
|
6190
|
+
}
|
|
6191
|
+
}
|
|
5478
6192
|
if (this.trailDue > 0 || ring.pos.length === 0) {
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
6193
|
+
// ONE sample per frame, never one per due tick. A frame that spanned
|
|
6194
|
+
// several 60Hz ticks only knows where the bone is NOW, and unshifting that
|
|
6195
|
+
// position once per tick fabricated duplicate samples — same point, same
|
|
6196
|
+
// timestamp, up to four copies — precisely when the scene ran heavy. Every
|
|
6197
|
+
// duplicate pair kinked the spline, and each kink drew as a bright bar
|
|
6198
|
+
// across the ribbon: banding that appeared under load, was spaced once per
|
|
6199
|
+
// frame, and survived every renderer fix because the renderer was
|
|
6200
|
+
// faithfully drawing corrupted history. Coarser spacing under load is
|
|
6201
|
+
// honest — each sample carries its true timestamp, and the spline and the
|
|
6202
|
+
// central-difference weight exist to handle uneven spacing.
|
|
6203
|
+
ring.pos.unshift(pos.x, pos.y, pos.z);
|
|
6204
|
+
ring.t.unshift(this.sceneClock);
|
|
6205
|
+
if (ring.t.length > TRAIL_SAMPLES) {
|
|
6206
|
+
ring.t.length = TRAIL_SAMPLES;
|
|
6207
|
+
ring.pos.length = TRAIL_SAMPLES * 3;
|
|
5487
6208
|
}
|
|
5488
6209
|
}
|
|
5489
6210
|
const count = ring.t.length;
|
|
@@ -5580,6 +6301,8 @@ Engine.STENCIL_EYE_VALUE = 1;
|
|
|
5580
6301
|
* cleared / edge-faded regions like before).
|
|
5581
6302
|
* rg8unorm at 4× MSAA is 8 bytes/texel — still fits Apple TBDR tile memory comfortably. */
|
|
5582
6303
|
Engine.BLOOM_MASK_FORMAT = "rg8unorm";
|
|
6304
|
+
/** Ceiling for `// @particles`. Past this an author is asking for a stall. */
|
|
6305
|
+
Engine.MAX_PARTICLES = 65536;
|
|
5583
6306
|
Engine.BLOOM_MAX_LEVELS = 5;
|
|
5584
6307
|
// Width of the baked Filmic tone LUT (composite.ts FILMIC_LUT_W must match).
|
|
5585
6308
|
Engine.FILMIC_LUT_WIDTH = 256;
|