reze-engine 0.41.3 → 0.42.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.41.3",
3
+ "version": "0.42.0",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/animation.ts CHANGED
@@ -355,24 +355,34 @@ export function bezierInterpolate(x1: number, x2: number, y1: number, y2: number
355
355
 
356
356
  const INV_127 = 1 / 127
357
357
 
358
+ /**
359
+ * The 64 interpolation bytes of a VMD bone frame, as four bezier curves.
360
+ *
361
+ * MMD interleaves the channels rather than storing them one after another: byte
362
+ * `i` is channel i's x1, `i + 4` its y1, `i + 8` its x2, `i + 12` its y2, where
363
+ * the channels are X = 0, Y = 1, Z = 2 and ROTATION = 3. The remaining 48 bytes
364
+ * are the same record written three more times, each shifted a byte left — a
365
+ * legacy quirk, and not one to read from: real files in this repo disagree with
366
+ * their own shifted copies, so the first block is the only trustworthy one.
367
+ *
368
+ * Rotation used to read `raw[0..3]`, which is not rotation's curve at all — it
369
+ * is the x1 byte of all four channels in a row. On an ordinary keyframe that
370
+ * evaluates to a bezier with both control points at y = 0: the curve holds near
371
+ * zero for most of the interval and then snaps to 1 at its end. Applied to every
372
+ * bone's rotation on every keyframe interval, which is essentially all of an MMD
373
+ * motion, it reads as a dance that stutters between poses instead of flowing
374
+ * through them.
375
+ */
358
376
  export function rawInterpolationToBoneInterpolation(raw: Uint8Array): BoneInterpolation {
377
+ const channel = (i: number): ControlPoint[] => [
378
+ { x: raw[i], y: raw[i + 4] },
379
+ { x: raw[i + 8], y: raw[i + 12] },
380
+ ]
359
381
  return {
360
- rotation: [
361
- { x: raw[0], y: raw[2] },
362
- { x: raw[1], y: raw[3] },
363
- ],
364
- translationX: [
365
- { x: raw[0], y: raw[4] },
366
- { x: raw[8], y: raw[12] },
367
- ],
368
- translationY: [
369
- { x: raw[16], y: raw[20] },
370
- { x: raw[24], y: raw[28] },
371
- ],
372
- translationZ: [
373
- { x: raw[32], y: raw[36] },
374
- { x: raw[40], y: raw[44] },
375
- ],
382
+ translationX: channel(0),
383
+ translationY: channel(1),
384
+ translationZ: channel(2),
385
+ rotation: channel(3),
376
386
  }
377
387
  }
378
388
 
package/src/engine.ts CHANGED
@@ -34,7 +34,14 @@ import {
34
34
  BLOOM_UPSAMPLE_SHADER_WGSL,
35
35
  } from "./shaders/passes/bloom"
36
36
  import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut"
37
- import { buildCompositeShader } from "./shaders/passes/composite"
37
+ import {
38
+ buildCompositeShader,
39
+ parseEffectAnchors,
40
+ EFFECT_ANCHORS,
41
+ EFFECT_SUBJECTS,
42
+ EFFECT_TRAIL_BASE,
43
+ EFFECT_TRAIL_SAMPLES,
44
+ } from "./shaders/passes/composite"
38
45
  import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
39
46
  import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
40
47
  import { compileGraph, type CompileOptions, type StyleSlot } from "./graph/compile"
@@ -234,14 +241,43 @@ export type WorldOptions = {
234
241
 
235
242
  /** A model's scene placement — root offset baked into skinning + visibility. Serializable
236
243
  * into a scene descriptor via getModelTransform. */
237
- /** How many character positions an effect can read (viewU[11..14]). */
238
- const MAX_EFFECT_SUBJECTS = 4
244
+ /** How many character positions an effect can read (viewU[11..14]). Defined
245
+ * beside the shader that reads them — the layout arithmetic has to agree. */
246
+ const MAX_EFFECT_SUBJECTS = EFFECT_SUBJECTS
239
247
  /** Where a character IS, for an effect that follows them. センター carries a
240
248
  * motion's root movement — walking, jumping — where the model transform only
241
249
  * carries where the model was placed; 全ての親 is the fallback for a model that
242
250
  * animates the true root instead. */
243
251
  const SUBJECT_BONES = ["センター", "全ての親"]
244
252
 
253
+ /** How many bones one effect may name. Eight is already a lot for one file, and
254
+ * this is a MINIMUM: raising it breaks nothing, because effects read through
255
+ * rzAnchor() rather than indexing the buffer. Lowering it would. */
256
+ const MAX_EFFECT_ANCHORS = EFFECT_ANCHORS
257
+
258
+ /** Only for the bounding sphere's height. */
259
+ const HEAD_BONE = "頭"
260
+
261
+ /** Path samples kept per trailed anchor. ~2.1s at the sampling rate below, which
262
+ * is a long ribbon — a dancer's arm draws most of a circle in that time.
263
+ *
264
+ * A MINIMUM, like every cap here, and raising it is why that matters: effects
265
+ * read through rzTrail and loop to rzTrailCount, so this went 64 → 128 without
266
+ * touching a single published effect. Lowering it is the direction that breaks. */
267
+ const TRAIL_SAMPLES = EFFECT_TRAIL_SAMPLES
268
+ /** Sampled on the SCENE clock at a fixed rate, so a path is identical in the
269
+ * editor, in an export and in a re-export, and its spacing does not change with
270
+ * the display's refresh. */
271
+ const TRAIL_HZ = 60
272
+ const TRAIL_DT = 1 / TRAIL_HZ
273
+
274
+ /** vec4 slots: four subjects × 3, then anchors × four subjects × 3, then the
275
+ * trails — slot-major, four subjects each, TRAIL_SAMPLES apiece. */
276
+ const CAST_SUBJECT_VEC4S = MAX_EFFECT_SUBJECTS * 3
277
+ const CAST_ANCHOR_VEC4S = MAX_EFFECT_ANCHORS * MAX_EFFECT_SUBJECTS * 3
278
+ const CAST_TRAIL_BASE = EFFECT_TRAIL_BASE
279
+ const CAST_VEC4S = CAST_TRAIL_BASE + MAX_EFFECT_ANCHORS * MAX_EFFECT_SUBJECTS * TRAIL_SAMPLES
280
+
245
281
  export type ModelTransform = {
246
282
  position: Vec3
247
283
  rotation: Quat
@@ -648,6 +684,9 @@ function materialAlphaStats(
648
684
  return { avg: sum / n / 255, translucentFrac: translucent / n }
649
685
  }
650
686
 
687
+ /** Tried in order when a PMX names a texture without an extension. */
688
+ const TEXTURE_EXTENSION_GUESSES = [".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds", ".spa", ".sph"]
689
+
651
690
  export class Engine {
652
691
  private static instance: Engine | null = null
653
692
 
@@ -837,7 +876,29 @@ export class Engine {
837
876
  /** Mounted over the finished frame — and the reason the scene pass has to
838
877
  * STORE its depth, which it otherwise discards into tile memory. */
839
878
  hasForeground: boolean
879
+ /** Bones the source asked for, in declaration order — rzAnchor's slots. Only
880
+ * these are resolved and uploaded, so a file that names none costs nothing. */
881
+ anchors: { bone: string; trail: boolean }[]
840
882
  } | null = null
883
+ /** The cast, as the effect API sees it. Written per frame while an effect is
884
+ * installed, and only up to what that effect actually declared. */
885
+ private castBuffer!: GPUBuffer
886
+ private castData!: Float32Array<ArrayBuffer>
887
+ /** Last frame's anchor world positions, for velocity. Keyed model id → slot. */
888
+ private anchorPrev = new Map<string, Float32Array>()
889
+ private castLastMs = 0
890
+ /** Recent path per trailed anchor, keyed "model\0slot". Newest first, so the
891
+ * shader's index 0 is now — written by unshifting rather than by tracking a
892
+ * head, because 64 is short and the alternative is an index the GPU side
893
+ * would also have to know about. */
894
+ private anchorTrail = new Map<string, { pos: number[]; t: number[] }>()
895
+ /** Scene seconds, advanced by the frame delta — NOT wall time, so an offline
896
+ * export samples the same path the editor showed. */
897
+ private sceneClock = 0
898
+ private trailAccum = 0
899
+ /** Trail samples owed this frame, computed once so every trail on every
900
+ * character samples in lockstep and their paths stay comparable. */
901
+ private trailDue = 0
841
902
  private agxLutTexture: GPUTexture | null = null
842
903
  private agxFallbackTexture!: GPUTexture
843
904
  /** Bound at composite binding 7 when no effect (or a param-less one) is set. */
@@ -1188,6 +1249,7 @@ export class Engine {
1188
1249
 
1189
1250
  private rebuildCompositeBindGroup(): void {
1190
1251
  if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
1252
+ if (!this.castBuffer) return
1191
1253
  this.compositeBindGroup = this.device.createBindGroup({
1192
1254
  label: "composite bind group",
1193
1255
  layout: this.compositeBindGroupLayout,
@@ -1203,6 +1265,7 @@ export class Engine {
1203
1265
  { binding: 8, resource: this.depthReadView },
1204
1266
  { binding: 9, resource: { buffer: this.dofUniformBuffer } },
1205
1267
  { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
1268
+ { binding: 11, resource: { buffer: this.castBuffer } },
1206
1269
  ],
1207
1270
  })
1208
1271
  }
@@ -1334,6 +1397,14 @@ export class Engine {
1334
1397
  }
1335
1398
  const mounts = { background: hasBackground, foreground: hasForeground }
1336
1399
 
1400
+ // ── Which bones did the author ask for? Same idea as the mounts above: a
1401
+ // declaration in the source, not a setting somewhere else. Only what is
1402
+ // named here gets resolved and uploaded, so naming none costs nothing and
1403
+ // naming eight costs eight — rather than every rig's 500 bones costing
1404
+ // everybody. Past the cap the extras are dropped rather than silently
1405
+ // shifting every slot after them.
1406
+ const anchors = parseEffectAnchors(wgsl, MAX_EFFECT_ANCHORS)
1407
+
1337
1408
  // ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
1338
1409
  // Fields are emitted in declaration order; offsets follow WGSL's natural
1339
1410
  // uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
@@ -1415,7 +1486,13 @@ export class Engine {
1415
1486
  })
1416
1487
  this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
1417
1488
  }
1418
- this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground }
1489
+ this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground, anchors }
1490
+ // Velocities and paths restart from rest rather than continuing from
1491
+ // whatever the last effect's slot 0 happened to be — the slots mean
1492
+ // something different now, and a trail would otherwise draw a line from the
1493
+ // old bone to the new one across the whole scene.
1494
+ this.anchorPrev.clear()
1495
+ this.anchorTrail.clear()
1419
1496
  this.compositePipelineIdentity = identity
1420
1497
  this.compositePipelineGamma = gamma
1421
1498
  this.effectEpochMs = performance.now()
@@ -2454,6 +2531,15 @@ export class Engine {
2454
2531
  size: 16,
2455
2532
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2456
2533
  })
2534
+ // Allocated at full size once rather than grown: it is ~7KB, the bind group
2535
+ // would otherwise be rebuilt whenever an effect declared a different number
2536
+ // of bones, and only the declared prefix is ever written.
2537
+ this.castData = new Float32Array(CAST_VEC4S * 4)
2538
+ this.castBuffer = this.device.createBuffer({
2539
+ label: "effect cast data",
2540
+ size: this.castData.byteLength,
2541
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
2542
+ })
2457
2543
  this.compositeBindGroupLayout = this.device.createBindGroupLayout({
2458
2544
  label: "composite bind group layout",
2459
2545
  entries: [
@@ -2483,6 +2569,9 @@ export class Engine {
2483
2569
  // AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
2484
2570
  // 1×1×1 stand-in keeps the bind group valid until it arrives.
2485
2571
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
2572
+ // The cast, for rzSubject/rzAnchor. Always bound so the base shader's
2573
+ // layout matches; the base shader simply never reads it.
2574
+ { binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
2486
2575
  ],
2487
2576
  })
2488
2577
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -3174,13 +3263,21 @@ export class Engine {
3174
3263
  // so a static stage in the scene never freezes the shot at frame 0. Falls back to the first
3175
3264
  // model, then to 0 (empty scene).
3176
3265
  private cameraClockTime(): number {
3177
- let first: number | null = null
3266
+ let fallback: number | null = null
3178
3267
  for (const inst of this.modelInstances.values()) {
3268
+ // Stages are skipped outright. Scenery carries no motion, and it is added
3269
+ // BEFORE the cast — it paints while the models stream in behind it — so it
3270
+ // is first in insertion order and was seeding this clock with its own
3271
+ // permanent zero. In a scene with a stage, a camera VMD therefore sampled
3272
+ // frame 0 forever and the shot never moved.
3273
+ if (inst.isStage) continue
3179
3274
  const p = inst.model.getAnimationProgress()
3180
- if (first === null) first = p.current
3181
3275
  if (p.playing || p.paused) return p.current
3276
+ // Otherwise the first cast member that actually HAS a clip: one still at
3277
+ // bind pose must not claim the clock from one holding the motion.
3278
+ if (fallback === null && p.duration > 0) fallback = p.current
3182
3279
  }
3183
- return first ?? 0
3280
+ return fallback ?? 0
3184
3281
  }
3185
3282
 
3186
3283
  /** Current orbit eye position (spherical coords resolved to a point). */
@@ -4627,12 +4724,38 @@ export class Engine {
4627
4724
  return cached
4628
4725
  }
4629
4726
 
4630
- let buffer: ArrayBuffer
4727
+ // PMX texture tables are hand-maintained, and they routinely carry entries
4728
+ // that are not files. Two kinds show up constantly: a bare directory
4729
+ // ("Textures", "spa\\"), which is a leftover placeholder pointing at nothing,
4730
+ // and a name whose extension was dropped — where the texture is sitting right
4731
+ // there on disk one suffix longer, and the material renders white for want of
4732
+ // it. The first is answered by staying quiet, the second by trying.
4733
+ let buffer: ArrayBuffer | null = null
4734
+ let readError: unknown = null
4631
4735
  try {
4632
4736
  buffer = await inst.assetReader.readBinary(logicalPath)
4633
4737
  } catch (e) {
4634
- console.warn(`[reze] texture read failed: ${logicalPath}`, e instanceof Error ? e.message : e)
4635
- return null
4738
+ readError = e
4739
+ }
4740
+ if (!buffer) {
4741
+ const base = logicalPath.split(/[\\/]/).pop() ?? ""
4742
+ // No basename at all: the entry named a directory. Nothing was ever meant
4743
+ // to load, so this is not a failure worth a line in anyone's console.
4744
+ if (!base) return null
4745
+ if (!base.includes(".")) {
4746
+ for (const ext of TEXTURE_EXTENSION_GUESSES) {
4747
+ try {
4748
+ buffer = await inst.assetReader.readBinary(`${logicalPath}${ext}`)
4749
+ break
4750
+ } catch {
4751
+ // keep trying — the list is short and only runs for a broken entry
4752
+ }
4753
+ }
4754
+ }
4755
+ if (!buffer) {
4756
+ console.warn(`[reze] texture read failed: ${logicalPath}`, readError instanceof Error ? readError.message : readError)
4757
+ return null
4758
+ }
4636
4759
  }
4637
4760
 
4638
4761
  // Decode to either an ImageBitmap (web-native formats) or raw RGBA (TGA, DDS, PSD).
@@ -5356,6 +5479,13 @@ export class Engine {
5356
5479
  }
5357
5480
 
5358
5481
  private renderWithDelta(deltaTime: number) {
5482
+ // The scene clock, and the only clock a trail may sample on: renderFrame()
5483
+ // drives offline export with an exact per-frame delta, so a path recorded
5484
+ // against this is reproducible where one recorded against wall time is not.
5485
+ this.sceneClock += deltaTime
5486
+ this.trailAccum += deltaTime
5487
+ this.trailDue = Math.floor(this.trailAccum / TRAIL_DT)
5488
+ this.trailAccum -= this.trailDue * TRAIL_DT
5359
5489
  const tFrame = performance.now()
5360
5490
  this.frameAnimMsRaw = 0
5361
5491
  this.framePhysicsMsRaw = 0
@@ -6181,11 +6311,172 @@ export class Engine {
6181
6311
  u[44 + n * 4] = px
6182
6312
  u[45 + n * 4] = py
6183
6313
  u[46 + n * 4] = pz
6314
+ this.writeCastEntry(inst, n, px, py, pz)
6184
6315
  n++
6185
6316
  })
6186
6317
  u[43] = n
6187
6318
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
6319
+ // Only what an effect declared, and only while one is installed. A scene
6320
+ // with no effect writes nothing here at all.
6321
+ if (this.effect) {
6322
+ // Up to the last trailed slot, not the whole buffer: an effect with no
6323
+ // trails never uploads the 32KB it would otherwise pay for every frame.
6324
+ let lastTrail = -1
6325
+ for (let i = 0; i < this.effect.anchors.length; i++) if (this.effect.anchors[i].trail) lastTrail = i
6326
+ const used =
6327
+ lastTrail >= 0
6328
+ ? CAST_TRAIL_BASE + (lastTrail * MAX_EFFECT_SUBJECTS + MAX_EFFECT_SUBJECTS) * TRAIL_SAMPLES
6329
+ : CAST_SUBJECT_VEC4S + this.effect.anchors.length * MAX_EFFECT_SUBJECTS * 3
6330
+ this.device.queue.writeBuffer(this.castBuffer, 0, this.castData, 0, used * 4)
6331
+ this.castLastMs = performance.now()
6332
+ }
6333
+ }
6334
+ }
6335
+
6336
+ /**
6337
+ * One character's slice of the effect API's view of the cast.
6338
+ *
6339
+ * `px/py/pz` is the hip point the caller just composed — passed in rather than
6340
+ * recomputed, since it is the same two bone lookups.
6341
+ *
6342
+ * Bone positions are model-space, so each is scaled, rotated and translated by
6343
+ * the model transform exactly as the hip point above was. Getting that wrong
6344
+ * does not look wrong on a model standing at the origin, which is precisely
6345
+ * how it would ship.
6346
+ */
6347
+ private writeCastEntry(inst: ModelInstance, n: number, px: number, py: number, pz: number): void {
6348
+ const effect = this.effect
6349
+ if (!effect) return
6350
+ const m = inst.model
6351
+ const cd = this.castData
6352
+ const toWorld = (v: Vec3): Vec3 => {
6353
+ v.setXYZ(v.x * m.scale, v.y * m.scale, v.z * m.scale)
6354
+ Quat.rotateVecInto(m.rotation, v, v)
6355
+ v.setXYZ(v.x + m.position.x, v.y + m.position.y, v.z + m.position.z)
6356
+ return v
6357
+ }
6358
+
6359
+ // The floor under this character: where the model was PLACED.
6360
+ //
6361
+ // A foot bone was the obvious answer and the wrong one. 足IK sits at the
6362
+ // ANKLE, not on the sole — an ankle above the ground even standing still,
6363
+ // and further still in heels — so a floor derived from it lands a hand's
6364
+ // width up the leg, which is exactly where the first version of Footfalls
6365
+ // drew its marks. A PMX's origin is between the feet on the floor by
6366
+ // convention, and placing a character on a stage moves that origin with
6367
+ // them, so the placement already answers "what is the ground here".
6368
+ //
6369
+ // Deliberately NOT the animated height: a jump lifts the character, not the
6370
+ // floor, and a floor that follows a jump is not a floor.
6371
+ const floorY = m.position.y
6372
+ // Generous on purpose: this is for culling, and a sphere that is too small
6373
+ // clips the effect it was meant to bound. Height is hip-to-head doubled;
6374
+ // arm span is about height on a human, so half of it is the radius, and the
6375
+ // rest is margin for a motion that reaches.
6376
+ const head = m.getBoneWorldPosition(HEAD_BONE)
6377
+ const height = head ? Math.max(0.01, toWorld(head).y - floorY) : Math.max(0.01, (py - floorY) * 2)
6378
+ const b = n * 12
6379
+ cd[b] = px
6380
+ cd[b + 1] = floorY
6381
+ cd[b + 2] = pz
6382
+ cd[b + 3] = 1
6383
+ cd[b + 4] = px
6384
+ cd[b + 5] = py
6385
+ cd[b + 6] = pz
6386
+ cd[b + 8] = px
6387
+ cd[b + 9] = floorY + height * 0.5
6388
+ cd[b + 10] = pz
6389
+ cd[b + 11] = height * 0.75
6390
+
6391
+ // Declared bones. Velocity is per model AND per slot, so two characters
6392
+ // wearing the same effect never inherit each other's motion.
6393
+ const anchors = effect.anchors
6394
+ if (anchors.length === 0) return
6395
+ let prev = this.anchorPrev.get(inst.name)
6396
+ const dtMs = Math.max(1, performance.now() - this.castLastMs)
6397
+ const invDt = 1000 / dtMs
6398
+ if (!prev || prev.length !== anchors.length * 3) {
6399
+ prev = new Float32Array(anchors.length * 3).fill(NaN)
6400
+ this.anchorPrev.set(inst.name, prev)
6401
+ }
6402
+ for (let s = 0; s < anchors.length; s++) {
6403
+ const a = CAST_SUBJECT_VEC4S * 4 + (s * MAX_EFFECT_SUBJECTS + n) * 12
6404
+ const pos = m.getBoneWorldPosition(anchors[s].bone)
6405
+ if (!pos) {
6406
+ cd[a + 3] = 0
6407
+ continue
6408
+ }
6409
+ toWorld(pos)
6410
+ const p = s * 3
6411
+ // NaN on the first frame a slot exists — a velocity out of nothing would
6412
+ // be a spike, and a trail or a spark reading it would fire on load.
6413
+ const vx = Number.isNaN(prev[p]) ? 0 : (pos.x - prev[p]) * invDt
6414
+ const vy = Number.isNaN(prev[p]) ? 0 : (pos.y - prev[p + 1]) * invDt
6415
+ const vz = Number.isNaN(prev[p]) ? 0 : (pos.z - prev[p + 2]) * invDt
6416
+ prev[p] = pos.x
6417
+ prev[p + 1] = pos.y
6418
+ prev[p + 2] = pos.z
6419
+ cd[a] = pos.x
6420
+ cd[a + 1] = pos.y
6421
+ cd[a + 2] = pos.z
6422
+ cd[a + 3] = 1
6423
+ cd[a + 4] = vx
6424
+ cd[a + 5] = vy
6425
+ cd[a + 6] = vz
6426
+ if (anchors[s].trail) this.writeTrail(inst.name, s, n, pos, cd, a)
6427
+ const fwd = m.getBoneWorldForward(anchors[s].bone)
6428
+ if (fwd) {
6429
+ Quat.rotateVecInto(m.rotation, fwd, fwd)
6430
+ cd[a + 8] = fwd.x
6431
+ cd[a + 9] = fwd.y
6432
+ cd[a + 10] = fwd.z
6433
+ }
6434
+ }
6435
+ }
6436
+
6437
+ /**
6438
+ * One trailed anchor's recent path, sampled on the scene clock and written
6439
+ * newest-first.
6440
+ *
6441
+ * Newest-first is what lets a ribbon be drawn by walking the index upward and
6442
+ * fading on age, and it means the shader never needs to know where the ring's
6443
+ * head is. Sixty-four entries is short enough that unshifting beats the
6444
+ * bookkeeping an actual ring buffer would push onto the GPU side too.
6445
+ *
6446
+ * Sampling is gated on TRAIL_DT of SCENE time, so a 120Hz display and a 30fps
6447
+ * export record the same path at the same spacing. A frame that covers several
6448
+ * intervals emits several samples rather than one, or a fast hand would tear.
6449
+ */
6450
+ private writeTrail(model: string, slot: number, n: number, pos: Vec3, cd: Float32Array, anchorBase: number): void {
6451
+ const key = `${model}\u0000${slot}`
6452
+ let ring = this.anchorTrail.get(key)
6453
+ if (!ring) {
6454
+ ring = { pos: [], t: [] }
6455
+ this.anchorTrail.set(key, ring)
6456
+ }
6457
+ if (this.trailDue > 0 || ring.pos.length === 0) {
6458
+ const steps = Math.min(this.trailDue, 4)
6459
+ for (let k = 0; k < Math.max(1, steps); k++) {
6460
+ ring.pos.unshift(pos.x, pos.y, pos.z)
6461
+ ring.t.unshift(this.sceneClock)
6462
+ if (ring.t.length > TRAIL_SAMPLES) {
6463
+ ring.t.length = TRAIL_SAMPLES
6464
+ ring.pos.length = TRAIL_SAMPLES * 3
6465
+ }
6466
+ }
6188
6467
  }
6468
+ const count = ring.t.length
6469
+ // Age rather than a timestamp: the shader would otherwise need the scene
6470
+ // clock too, and there is only one place that has to know what time it is.
6471
+ const base = (CAST_TRAIL_BASE + (slot * MAX_EFFECT_SUBJECTS + n) * TRAIL_SAMPLES) * 4
6472
+ for (let i = 0; i < count; i++) {
6473
+ cd[base + i * 4] = ring.pos[i * 3]
6474
+ cd[base + i * 4 + 1] = ring.pos[i * 3 + 1]
6475
+ cd[base + i * 4 + 2] = ring.pos[i * 3 + 2]
6476
+ cd[base + i * 4 + 3] = this.sceneClock - ring.t[i]
6477
+ }
6478
+ // The count rides in the anchor's spare lane, so rzTrailCount is one read.
6479
+ cd[anchorBase + 11] = count
6189
6480
  }
6190
6481
 
6191
6482
  private updateSkinMatrices() {