reze-engine 0.41.4 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -840,14 +876,38 @@ export class Engine {
840
876
  /** Mounted over the finished frame — and the reason the scene pass has to
841
877
  * STORE its depth, which it otherwise discards into tile memory. */
842
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 }[]
843
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
844
902
  private agxLutTexture: GPUTexture | null = null
845
903
  private agxFallbackTexture!: GPUTexture
846
904
  /** Bound at composite binding 7 when no effect (or a param-less one) is set. */
847
905
  private bgParamsDummyBuffer!: GPUBuffer
848
906
  private compositePipelineLayout!: GPUPipelineLayout
849
907
  /** time=0 origin for the active effect — reset each setEffect. */
850
- private effectEpochMs = 0
908
+ /** Scene-clock reading when the current effect was installed. The effect's
909
+ * `time` is measured from here — see where it is written. */
910
+ private effectEpochScene = 0
851
911
  private compositeBloomView: GPUTextureView | null = null
852
912
 
853
913
  // EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
@@ -1191,6 +1251,7 @@ export class Engine {
1191
1251
 
1192
1252
  private rebuildCompositeBindGroup(): void {
1193
1253
  if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
1254
+ if (!this.castBuffer) return
1194
1255
  this.compositeBindGroup = this.device.createBindGroup({
1195
1256
  label: "composite bind group",
1196
1257
  layout: this.compositeBindGroupLayout,
@@ -1206,6 +1267,7 @@ export class Engine {
1206
1267
  { binding: 8, resource: this.depthReadView },
1207
1268
  { binding: 9, resource: { buffer: this.dofUniformBuffer } },
1208
1269
  { binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
1270
+ { binding: 11, resource: { buffer: this.castBuffer } },
1209
1271
  ],
1210
1272
  })
1211
1273
  }
@@ -1337,6 +1399,14 @@ export class Engine {
1337
1399
  }
1338
1400
  const mounts = { background: hasBackground, foreground: hasForeground }
1339
1401
 
1402
+ // ── Which bones did the author ask for? Same idea as the mounts above: a
1403
+ // declaration in the source, not a setting somewhere else. Only what is
1404
+ // named here gets resolved and uploaded, so naming none costs nothing and
1405
+ // naming eight costs eight — rather than every rig's 500 bones costing
1406
+ // everybody. Past the cap the extras are dropped rather than silently
1407
+ // shifting every slot after them.
1408
+ const anchors = parseEffectAnchors(wgsl, MAX_EFFECT_ANCHORS)
1409
+
1340
1410
  // ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
1341
1411
  // Fields are emitted in declaration order; offsets follow WGSL's natural
1342
1412
  // uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
@@ -1418,10 +1488,16 @@ export class Engine {
1418
1488
  })
1419
1489
  this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
1420
1490
  }
1421
- this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground }
1491
+ this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground, anchors }
1492
+ // Velocities and paths restart from rest rather than continuing from
1493
+ // whatever the last effect's slot 0 happened to be — the slots mean
1494
+ // something different now, and a trail would otherwise draw a line from the
1495
+ // old bone to the new one across the whole scene.
1496
+ this.anchorPrev.clear()
1497
+ this.anchorTrail.clear()
1422
1498
  this.compositePipelineIdentity = identity
1423
1499
  this.compositePipelineGamma = gamma
1424
- this.effectEpochMs = performance.now()
1500
+ this.effectEpochScene = this.sceneClock
1425
1501
  this.rebuildCompositeBindGroup()
1426
1502
  this.writeCompositeViewUniforms()
1427
1503
  return { ok: true, diagnostics: [], mounts }
@@ -2457,6 +2533,15 @@ export class Engine {
2457
2533
  size: 16,
2458
2534
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2459
2535
  })
2536
+ // Allocated at full size once rather than grown: it is ~7KB, the bind group
2537
+ // would otherwise be rebuilt whenever an effect declared a different number
2538
+ // of bones, and only the declared prefix is ever written.
2539
+ this.castData = new Float32Array(CAST_VEC4S * 4)
2540
+ this.castBuffer = this.device.createBuffer({
2541
+ label: "effect cast data",
2542
+ size: this.castData.byteLength,
2543
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
2544
+ })
2460
2545
  this.compositeBindGroupLayout = this.device.createBindGroupLayout({
2461
2546
  label: "composite bind group layout",
2462
2547
  entries: [
@@ -2486,6 +2571,9 @@ export class Engine {
2486
2571
  // AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
2487
2572
  // 1×1×1 stand-in keeps the bind group valid until it arrives.
2488
2573
  { binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
2574
+ // The cast, for rzSubject/rzAnchor. Always bound so the base shader's
2575
+ // layout matches; the base shader simply never reads it.
2576
+ { binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
2489
2577
  ],
2490
2578
  })
2491
2579
  this.fallbackEquirectTexture = this.device.createTexture({
@@ -5393,6 +5481,13 @@ export class Engine {
5393
5481
  }
5394
5482
 
5395
5483
  private renderWithDelta(deltaTime: number) {
5484
+ // The scene clock, and the only clock a trail may sample on: renderFrame()
5485
+ // drives offline export with an exact per-frame delta, so a path recorded
5486
+ // against this is reproducible where one recorded against wall time is not.
5487
+ this.sceneClock += deltaTime
5488
+ this.trailAccum += deltaTime
5489
+ this.trailDue = Math.floor(this.trailAccum / TRAIL_DT)
5490
+ this.trailAccum -= this.trailDue * TRAIL_DT
5396
5491
  const tFrame = performance.now()
5397
5492
  this.frameAnimMsRaw = 0
5398
5493
  this.framePhysicsMsRaw = 0
@@ -6178,7 +6273,16 @@ export class Engine {
6178
6273
  u[22] = v[10]
6179
6274
  u[23] = 0
6180
6275
  // Effect clock + canvas size (viewU[6]) — written on the same refresh.
6181
- u[24] = (performance.now() - this.effectEpochMs) / 1000
6276
+ // The effect clock, on the SCENE's time rather than the wall's.
6277
+ //
6278
+ // renderFrame() drives offline export as fast as the encoder will take
6279
+ // frames, so wall time races ahead of the video's own time — a rain effect
6280
+ // fell at the wrong rate in the export, a twinkle blinked at the wrong
6281
+ // speed, and none of it matched what the editor had shown. Measured
6282
+ // against the accumulated frame delta, an effect animates identically in
6283
+ // the editor, in an export, and in a re-export, which is the same rule the
6284
+ // trails already followed.
6285
+ u[24] = this.sceneClock - this.effectEpochScene
6182
6286
  u[26] = this.canvas.width
6183
6287
  u[27] = this.canvas.height
6184
6288
  // Camera world position (viewU[10]) — the other half of bgWorldPos. It
@@ -6218,11 +6322,172 @@ export class Engine {
6218
6322
  u[44 + n * 4] = px
6219
6323
  u[45 + n * 4] = py
6220
6324
  u[46 + n * 4] = pz
6325
+ this.writeCastEntry(inst, n, px, py, pz)
6221
6326
  n++
6222
6327
  })
6223
6328
  u[43] = n
6224
6329
  this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
6330
+ // Only what an effect declared, and only while one is installed. A scene
6331
+ // with no effect writes nothing here at all.
6332
+ if (this.effect) {
6333
+ // Up to the last trailed slot, not the whole buffer: an effect with no
6334
+ // trails never uploads the 32KB it would otherwise pay for every frame.
6335
+ let lastTrail = -1
6336
+ for (let i = 0; i < this.effect.anchors.length; i++) if (this.effect.anchors[i].trail) lastTrail = i
6337
+ const used =
6338
+ lastTrail >= 0
6339
+ ? CAST_TRAIL_BASE + (lastTrail * MAX_EFFECT_SUBJECTS + MAX_EFFECT_SUBJECTS) * TRAIL_SAMPLES
6340
+ : CAST_SUBJECT_VEC4S + this.effect.anchors.length * MAX_EFFECT_SUBJECTS * 3
6341
+ this.device.queue.writeBuffer(this.castBuffer, 0, this.castData, 0, used * 4)
6342
+ this.castLastMs = performance.now()
6343
+ }
6344
+ }
6345
+ }
6346
+
6347
+ /**
6348
+ * One character's slice of the effect API's view of the cast.
6349
+ *
6350
+ * `px/py/pz` is the hip point the caller just composed — passed in rather than
6351
+ * recomputed, since it is the same two bone lookups.
6352
+ *
6353
+ * Bone positions are model-space, so each is scaled, rotated and translated by
6354
+ * the model transform exactly as the hip point above was. Getting that wrong
6355
+ * does not look wrong on a model standing at the origin, which is precisely
6356
+ * how it would ship.
6357
+ */
6358
+ private writeCastEntry(inst: ModelInstance, n: number, px: number, py: number, pz: number): void {
6359
+ const effect = this.effect
6360
+ if (!effect) return
6361
+ const m = inst.model
6362
+ const cd = this.castData
6363
+ const toWorld = (v: Vec3): Vec3 => {
6364
+ v.setXYZ(v.x * m.scale, v.y * m.scale, v.z * m.scale)
6365
+ Quat.rotateVecInto(m.rotation, v, v)
6366
+ v.setXYZ(v.x + m.position.x, v.y + m.position.y, v.z + m.position.z)
6367
+ return v
6368
+ }
6369
+
6370
+ // The floor under this character: where the model was PLACED.
6371
+ //
6372
+ // A foot bone was the obvious answer and the wrong one. 足IK sits at the
6373
+ // ANKLE, not on the sole — an ankle above the ground even standing still,
6374
+ // and further still in heels — so a floor derived from it lands a hand's
6375
+ // width up the leg, which is exactly where the first version of Footfalls
6376
+ // drew its marks. A PMX's origin is between the feet on the floor by
6377
+ // convention, and placing a character on a stage moves that origin with
6378
+ // them, so the placement already answers "what is the ground here".
6379
+ //
6380
+ // Deliberately NOT the animated height: a jump lifts the character, not the
6381
+ // floor, and a floor that follows a jump is not a floor.
6382
+ const floorY = m.position.y
6383
+ // Generous on purpose: this is for culling, and a sphere that is too small
6384
+ // clips the effect it was meant to bound. Height is hip-to-head doubled;
6385
+ // arm span is about height on a human, so half of it is the radius, and the
6386
+ // rest is margin for a motion that reaches.
6387
+ const head = m.getBoneWorldPosition(HEAD_BONE)
6388
+ const height = head ? Math.max(0.01, toWorld(head).y - floorY) : Math.max(0.01, (py - floorY) * 2)
6389
+ const b = n * 12
6390
+ cd[b] = px
6391
+ cd[b + 1] = floorY
6392
+ cd[b + 2] = pz
6393
+ cd[b + 3] = 1
6394
+ cd[b + 4] = px
6395
+ cd[b + 5] = py
6396
+ cd[b + 6] = pz
6397
+ cd[b + 8] = px
6398
+ cd[b + 9] = floorY + height * 0.5
6399
+ cd[b + 10] = pz
6400
+ cd[b + 11] = height * 0.75
6401
+
6402
+ // Declared bones. Velocity is per model AND per slot, so two characters
6403
+ // wearing the same effect never inherit each other's motion.
6404
+ const anchors = effect.anchors
6405
+ if (anchors.length === 0) return
6406
+ let prev = this.anchorPrev.get(inst.name)
6407
+ const dtMs = Math.max(1, performance.now() - this.castLastMs)
6408
+ const invDt = 1000 / dtMs
6409
+ if (!prev || prev.length !== anchors.length * 3) {
6410
+ prev = new Float32Array(anchors.length * 3).fill(NaN)
6411
+ this.anchorPrev.set(inst.name, prev)
6412
+ }
6413
+ for (let s = 0; s < anchors.length; s++) {
6414
+ const a = CAST_SUBJECT_VEC4S * 4 + (s * MAX_EFFECT_SUBJECTS + n) * 12
6415
+ const pos = m.getBoneWorldPosition(anchors[s].bone)
6416
+ if (!pos) {
6417
+ cd[a + 3] = 0
6418
+ continue
6419
+ }
6420
+ toWorld(pos)
6421
+ const p = s * 3
6422
+ // NaN on the first frame a slot exists — a velocity out of nothing would
6423
+ // be a spike, and a trail or a spark reading it would fire on load.
6424
+ const vx = Number.isNaN(prev[p]) ? 0 : (pos.x - prev[p]) * invDt
6425
+ const vy = Number.isNaN(prev[p]) ? 0 : (pos.y - prev[p + 1]) * invDt
6426
+ const vz = Number.isNaN(prev[p]) ? 0 : (pos.z - prev[p + 2]) * invDt
6427
+ prev[p] = pos.x
6428
+ prev[p + 1] = pos.y
6429
+ prev[p + 2] = pos.z
6430
+ cd[a] = pos.x
6431
+ cd[a + 1] = pos.y
6432
+ cd[a + 2] = pos.z
6433
+ cd[a + 3] = 1
6434
+ cd[a + 4] = vx
6435
+ cd[a + 5] = vy
6436
+ cd[a + 6] = vz
6437
+ if (anchors[s].trail) this.writeTrail(inst.name, s, n, pos, cd, a)
6438
+ const fwd = m.getBoneWorldForward(anchors[s].bone)
6439
+ if (fwd) {
6440
+ Quat.rotateVecInto(m.rotation, fwd, fwd)
6441
+ cd[a + 8] = fwd.x
6442
+ cd[a + 9] = fwd.y
6443
+ cd[a + 10] = fwd.z
6444
+ }
6445
+ }
6446
+ }
6447
+
6448
+ /**
6449
+ * One trailed anchor's recent path, sampled on the scene clock and written
6450
+ * newest-first.
6451
+ *
6452
+ * Newest-first is what lets a ribbon be drawn by walking the index upward and
6453
+ * fading on age, and it means the shader never needs to know where the ring's
6454
+ * head is. Sixty-four entries is short enough that unshifting beats the
6455
+ * bookkeeping an actual ring buffer would push onto the GPU side too.
6456
+ *
6457
+ * Sampling is gated on TRAIL_DT of SCENE time, so a 120Hz display and a 30fps
6458
+ * export record the same path at the same spacing. A frame that covers several
6459
+ * intervals emits several samples rather than one, or a fast hand would tear.
6460
+ */
6461
+ private writeTrail(model: string, slot: number, n: number, pos: Vec3, cd: Float32Array, anchorBase: number): void {
6462
+ const key = `${model}\u0000${slot}`
6463
+ let ring = this.anchorTrail.get(key)
6464
+ if (!ring) {
6465
+ ring = { pos: [], t: [] }
6466
+ this.anchorTrail.set(key, ring)
6467
+ }
6468
+ if (this.trailDue > 0 || ring.pos.length === 0) {
6469
+ const steps = Math.min(this.trailDue, 4)
6470
+ for (let k = 0; k < Math.max(1, steps); k++) {
6471
+ ring.pos.unshift(pos.x, pos.y, pos.z)
6472
+ ring.t.unshift(this.sceneClock)
6473
+ if (ring.t.length > TRAIL_SAMPLES) {
6474
+ ring.t.length = TRAIL_SAMPLES
6475
+ ring.pos.length = TRAIL_SAMPLES * 3
6476
+ }
6477
+ }
6225
6478
  }
6479
+ const count = ring.t.length
6480
+ // Age rather than a timestamp: the shader would otherwise need the scene
6481
+ // clock too, and there is only one place that has to know what time it is.
6482
+ const base = (CAST_TRAIL_BASE + (slot * MAX_EFFECT_SUBJECTS + n) * TRAIL_SAMPLES) * 4
6483
+ for (let i = 0; i < count; i++) {
6484
+ cd[base + i * 4] = ring.pos[i * 3]
6485
+ cd[base + i * 4 + 1] = ring.pos[i * 3 + 1]
6486
+ cd[base + i * 4 + 2] = ring.pos[i * 3 + 2]
6487
+ cd[base + i * 4 + 3] = this.sceneClock - ring.t[i]
6488
+ }
6489
+ // The count rides in the anchor's spare lane, so rzTrailCount is one read.
6490
+ cd[anchorBase + 11] = count
6226
6491
  }
6227
6492
 
6228
6493
  private updateSkinMatrices() {
package/src/model.ts CHANGED
@@ -968,6 +968,23 @@ export class Model {
968
968
  return this.runtimeSkeleton.worldMatrices[idx].getPosition()
969
969
  }
970
970
 
971
+ /**
972
+ * A bone's forward axis, normalised — which way a foot points, where a head
973
+ * looks. Model space, like getBoneWorldPosition: the caller composes the model
974
+ * transform on top if it wants world space.
975
+ *
976
+ * Column 2 of the world matrix. Null for a name this rig does not have, which
977
+ * is the ordinary case across rigs that spell bones differently.
978
+ */
979
+ getBoneWorldForward(boneName: string): Vec3 | null {
980
+ const idx = this.runtimeSkeleton.nameIndex[boneName]
981
+ if (idx === undefined || idx < 0) return null
982
+ const m = this.runtimeSkeleton.worldMatrices[idx].values
983
+ const len = Math.hypot(m[8], m[9], m[10])
984
+ if (len < 1e-8) return null
985
+ return new Vec3(m[8] / len, m[9] / len, m[10] / len)
986
+ }
987
+
971
988
  getSkinning(): Skinning {
972
989
  return this.skinning
973
990
  }
@@ -28,8 +28,28 @@ export class RezePhysics {
28
28
  private readonly maxSubSteps = 6
29
29
  /** EMA of one world.step's CPU cost; drives catch-up load shedding. */
30
30
  private stepCostEmaMs = 0.5
31
- /** Max ms a single frame may spend catching up physics before shedding. */
32
- private readonly stepBudgetMs = 8
31
+ /**
32
+ * Share of the frame a single frame may spend catching physics up before it
33
+ * sheds the backlog instead.
34
+ *
35
+ * A FRACTION, not a fixed millisecond count, and that distinction is the whole
36
+ * point. It used to be a flat 8ms — about half a frame at 60Hz, which is what
37
+ * it was tuned against. The moment anything else made frames longer (a heavy
38
+ * effect, a weak GPU) the frame grew and the budget did not, so at 30Hz
39
+ * physics was allowed a QUARTER of the frame while needing twice the
40
+ * substeps. It shed work it could easily afford, and shedding discards time —
41
+ * so physics ran fractionally slow-motion against a character moving at full
42
+ * speed, which is exactly what hair and skirts lagging behind the body looks
43
+ * like.
44
+ *
45
+ * At 60Hz this reproduces the old 8ms almost exactly, so nothing that was
46
+ * tuned against it moves.
47
+ */
48
+ private readonly stepBudgetFraction = 0.5
49
+ /** Floor and ceiling on that share: a very short frame still gets enough to
50
+ * make progress, and a very long one must not spend all of itself here. */
51
+ private readonly stepBudgetMinMs = 8
52
+ private readonly stepBudgetMaxMs = 24
33
53
 
34
54
  // Fixed-timestep render interpolation ("Fix Your Timestep"): the dynamic body pose is
35
55
  // rendered as lerp(prev, curr, alpha) between the last two completed substeps, where
@@ -260,7 +280,11 @@ export class RezePhysics {
260
280
  // state (6× step cost every frame; iOS "starts smooth, then stays slow").
261
281
  // Catching up must never cost more than a frame-budget slice: shed the backlog
262
282
  // instead (physics runs fractionally slow-motion for a beat; framerate holds).
263
- const affordable = Math.max(1, Math.floor(this.stepBudgetMs / Math.max(0.05, this.stepCostEmaMs)))
283
+ const budgetMs = Math.min(
284
+ this.stepBudgetMaxMs,
285
+ Math.max(this.stepBudgetMinMs, dt * 1000 * this.stepBudgetFraction),
286
+ )
287
+ const affordable = Math.max(1, Math.floor(budgetMs / Math.max(0.05, this.stepCostEmaMs)))
264
288
  if (nSub > affordable) nSub = affordable
265
289
  for (let k = 0; k < nSub; k++) {
266
290
  this.savePrevState()