reze-engine 0.42.3 → 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/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
- if (!hasBackground && !hasForeground) {
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
- "an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f " +
1011
- "or fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f (or both)",
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
- const source = buildCompositeShader({ wgsl, paramsDecl, hasBackground, hasForeground });
1060
- const userLineOffset = source.slice(0, source.indexOf(wgsl)).split("\n").length - 1;
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
- const diagnostics = info.messages
1066
- .filter((m) => m.type === "error")
1067
- .map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`);
1068
- if (diagnostics.length === 0 && scopeErr)
1069
- diagnostics.push(scopeErr.message);
1070
- if (diagnostics.length > 0)
1071
- return { ok: false, diagnostics, mounts };
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,58 @@ 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
+ }
2744
3426
  /** Every camera keyframe's frame index — what a timeline draws as its cuts.
2745
3427
  * Empty when no camera VMD is loaded. */
2746
3428
  getCameraVmdKeyframes() {
@@ -4616,7 +5298,7 @@ export class Engine {
4616
5298
  // write leaves dofU[0].x at 0 while DoF is off, so refreshing it does not
4617
5299
  // switch the gather on.
4618
5300
  const dofOn = this.depthOfField.enabled;
4619
- const depthRead = dofOn || (this.effect?.hasForeground ?? false);
5301
+ const depthRead = dofOn || (this.effect?.hasForeground ?? false) || this.trails !== null;
4620
5302
  this.renderPassDescriptor.depthStencilAttachment.depthStoreOp = depthRead ? "store" : "discard";
4621
5303
  if (depthRead)
4622
5304
  this.writeDepthOfFieldUniforms();
@@ -4648,6 +5330,7 @@ export class Engine {
4648
5330
  sp.end();
4649
5331
  this.shadowMapPopulated = hasModels;
4650
5332
  }
5333
+ this.stepParticles(encoder, deltaTime);
4651
5334
  const pass = encoder.beginRenderPass(this.renderPassDescriptor);
4652
5335
  // Phase order: opaque models → ground → transparent fabric.
4653
5336
  // The ground shader is the most expensive full-coverage draw in the frame
@@ -4668,7 +5351,17 @@ export class Engine {
4668
5351
  if (inst.model.visible)
4669
5352
  this.renderModelTransparentPhase(pass, inst);
4670
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);
4671
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);
4672
5365
  // Bloom pyramid (EEVEE 3.6):
4673
5366
  // 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
4674
5367
  // 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
@@ -5480,15 +6173,38 @@ export class Engine {
5480
6173
  ring = { pos: [], t: [] };
5481
6174
  this.anchorTrail.set(key, ring);
5482
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
+ }
5483
6192
  if (this.trailDue > 0 || ring.pos.length === 0) {
5484
- const steps = Math.min(this.trailDue, 4);
5485
- for (let k = 0; k < Math.max(1, steps); k++) {
5486
- ring.pos.unshift(pos.x, pos.y, pos.z);
5487
- ring.t.unshift(this.sceneClock);
5488
- if (ring.t.length > TRAIL_SAMPLES) {
5489
- ring.t.length = TRAIL_SAMPLES;
5490
- ring.pos.length = TRAIL_SAMPLES * 3;
5491
- }
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;
5492
6208
  }
5493
6209
  }
5494
6210
  const count = ring.t.length;
@@ -5585,6 +6301,8 @@ Engine.STENCIL_EYE_VALUE = 1;
5585
6301
  * cleared / edge-faded regions like before).
5586
6302
  * rg8unorm at 4× MSAA is 8 bytes/texel — still fits Apple TBDR tile memory comfortably. */
5587
6303
  Engine.BLOOM_MASK_FORMAT = "rg8unorm";
6304
+ /** Ceiling for `// @particles`. Past this an author is asking for a stall. */
6305
+ Engine.MAX_PARTICLES = 65536;
5588
6306
  Engine.BLOOM_MAX_LEVELS = 5;
5589
6307
  // Width of the baked Filmic tone LUT (composite.ts FILMIC_LUT_W must match).
5590
6308
  Engine.FILMIC_LUT_WIDTH = 256;