reze-engine 0.55.2 → 0.55.4

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
@@ -380,6 +380,24 @@ export type ModelTransform = {
380
380
  visible: boolean
381
381
  }
382
382
 
383
+ /** How a model rides another — MMD's 外部親 (outside parent). See setModelParent. */
384
+ export type ModelAttachment = {
385
+ /** Model key of the parent. */
386
+ model: string
387
+ /** Bone on the parent. A name the parent's rig lacks rides the parent's root. */
388
+ bone: string
389
+ }
390
+
391
+ /** The attachment as the engine keeps it: the record plus the two matrices the
392
+ * per-frame placement needs, allocated once per attach rather than per frame. */
393
+ type Attachment = ModelAttachment & {
394
+ /** Where the child's origin sits in the bone's space (position · rotation). */
395
+ offsetMatrix: Float32Array
396
+ /** The root the child is posed under this frame. Handed to Model.setRootParent
397
+ * BY REFERENCE and refilled every frame; see placeAttached. */
398
+ rootMatrix: Float32Array
399
+ }
400
+
383
401
  type SunOptions = {
384
402
  /** Linear color of the sun lamp (Blender: Light > Color). */
385
403
  color?: Vec3
@@ -778,6 +796,18 @@ interface ModelInstance {
778
796
  * plane into isStage would have made adding a title graphic delete the ground.
779
797
  */
780
798
  isPlane: boolean
799
+ /**
800
+ * A PROP: a PMX object a character holds or wears — a microphone, a fan, a
801
+ * sword. The third answer beside stage and plane. It keeps what a cast member
802
+ * has that scenery does not (physics, outlines, its own clip) and drops what
803
+ * makes one a performer: no effect subject id, no seeding of the scene clock,
804
+ * no bone picking. Like a card it leaves the floor alone. See addProp.
805
+ */
806
+ isProp: boolean
807
+ /** Who this model hangs from, or null. Any model can: a prop by design, a
808
+ * card for a sign in her hand, a second character for a mascot on her
809
+ * shoulder. See setModelParent. */
810
+ parent: Attachment | null
781
811
  /** This card's texture is rewritten every frame, so it is allocated with no
782
812
  * mip chain — rebuilding one per frame is a pass per level per card, and is
783
813
  * what a moving card was mostly costing. See setPlaneFrame. */
@@ -4970,7 +5000,7 @@ export class Engine {
4970
5000
  for (const inst of this.modelInstances.values()) {
4971
5001
  // Neither a stage nor a plane is a performer, so neither is a subject an
4972
5002
  // effect can follow.
4973
- if (!inst.model.visible || inst.isStage || inst.isPlane) continue
5003
+ if (!inst.model.visible || inst.isStage || inst.isPlane || inst.isProp) continue
4974
5004
  const model = inst.model
4975
5005
  const matrices = model.getWorldMatrices()
4976
5006
  if (matrices.length === 0) continue
@@ -8008,7 +8038,7 @@ export class Engine {
8008
8038
  // is first in insertion order and was seeding this clock with its own
8009
8039
  // permanent zero. In a scene with a stage, a camera VMD therefore sampled
8010
8040
  // frame 0 forever and the shot never moved.
8011
- if (inst.isStage || inst.isPlane) continue
8041
+ if (inst.isStage || inst.isPlane || inst.isProp) continue
8012
8042
  const p = inst.model.getAnimationProgress()
8013
8043
  if (p.playing || p.paused) return p.current
8014
8044
  // Otherwise the first cast member that actually HAS a clip: one still at
@@ -8460,6 +8490,16 @@ export class Engine {
8460
8490
  return model
8461
8491
  }
8462
8492
 
8493
+ /** loadModel's folder/zip path for a prop. See addProp. */
8494
+ async loadProp(
8495
+ name: string,
8496
+ options: LoadModelFromFilesOptions & { transform?: Partial<ModelTransform> },
8497
+ ): Promise<Model> {
8498
+ const { model, pmxKey, reader } = await this.openPmxFromFiles(name, options)
8499
+ await this.addProp(model, pmxKey, { name, transform: options.transform, assetReader: reader })
8500
+ return model
8501
+ }
8502
+
8463
8503
  /** Read a PMX out of a picked folder / expanded zip. Shared by loadModel and
8464
8504
  * loadStage so the file-map and path handling exist in exactly one place. */
8465
8505
  private async openPmxFromFiles(
@@ -8485,7 +8525,7 @@ export class Engine {
8485
8525
  pmxPath: string,
8486
8526
  name?: string,
8487
8527
  assetReader?: AssetReader,
8488
- options?: { stage?: boolean; plane?: boolean; dynamic?: boolean },
8528
+ options?: { stage?: boolean; plane?: boolean; dynamic?: boolean; prop?: boolean },
8489
8529
  ): Promise<string> {
8490
8530
  const requested = name ?? model.name
8491
8531
  let key = requested
@@ -8504,6 +8544,7 @@ export class Engine {
8504
8544
  options?.stage ?? false,
8505
8545
  options?.plane ?? false,
8506
8546
  options?.dynamic ?? false,
8547
+ options?.prop ?? false,
8507
8548
  )
8508
8549
  return key
8509
8550
  }
@@ -8537,6 +8578,28 @@ export class Engine {
8537
8578
  return key
8538
8579
  }
8539
8580
 
8581
+ /**
8582
+ * Add a PMX as a PROP: an object a character holds or wears rather than a
8583
+ * performer or the environment. A microphone, a fan, a sword, an umbrella.
8584
+ *
8585
+ * It keeps what makes a held thing look right — physics (the charm on a phone
8586
+ * strap swings), toon outlines, its own clip if it has one — and drops what
8587
+ * makes a model a cast member: no effect subject id, so a silhouette effect
8588
+ * still outlines HER and not the mic; no seeding of the scene clock; no bone
8589
+ * picking in the pose editor. Like a card it leaves the built-in ground
8590
+ * alone, which is the one thing a stage does that a prop must not. Usually
8591
+ * hung from a bone with setModelParent, though it can stand on its own.
8592
+ */
8593
+ async addProp(
8594
+ model: Model,
8595
+ pmxPath: string,
8596
+ options?: { name?: string; transform?: Partial<ModelTransform>; assetReader?: AssetReader },
8597
+ ): Promise<string> {
8598
+ const key = await this.addModel(model, pmxPath, options?.name, options?.assetReader, { prop: true })
8599
+ if (options?.transform) this.setModelTransform(key, options.transform)
8600
+ return key
8601
+ }
8602
+
8540
8603
  /**
8541
8604
  * Put a picture in the scene as a flat card.
8542
8605
  *
@@ -8792,8 +8855,14 @@ export class Engine {
8792
8855
  // Per-group StyleUniforms buffers aren't in gpuBuffers (allocated post-load).
8793
8856
  for (const install of inst.styleGroups.values()) this.destroyInstall(install)
8794
8857
  this.modelInstances.delete(name)
8858
+ // Whatever hung from it stands on its own now, at identity — the same
8859
+ // place a detach leaves a model.
8860
+ for (const other of this.modelInstances.values()) {
8861
+ if (other.parent?.model === name) this.setModelParent(other.name, null)
8862
+ }
8795
8863
  this.cullListDirty = true
8796
8864
  this.bundlesDirty = true
8865
+ this.updateOrderDirty = true
8797
8866
  }
8798
8867
 
8799
8868
  getModelNames(): string[] {
@@ -8804,6 +8873,136 @@ export class Engine {
8804
8873
  return this.modelInstances.get(name)?.model ?? null
8805
8874
  }
8806
8875
 
8876
+ /**
8877
+ * Hang a model from a bone of another — MMD's 外部親 (outside parent).
8878
+ *
8879
+ * Every frame, after the parent has been posed and simulated, the child's
8880
+ * root bones are placed at that bone with `offset` composed on top, and only
8881
+ * then is the child posed itself. The placement enters through the child's
8882
+ * BONES rather than its model transform (Model.setRootParent): physics runs
8883
+ * in model space, so a root moved by the transform would have a charm on a
8884
+ * phone strap feel gravity swing with the hand, while a root moved by the
8885
+ * skeleton keeps down down. It also puts the child's own clip on top of the
8886
+ * ride, as MMD does — an umbrella that spins keeps spinning in the hand.
8887
+ *
8888
+ * While attached the child's position and rotation are held at identity and
8889
+ * setModelTransform ignores them; scale still applies, and is folded into
8890
+ * the placement so the offset stays in the parent's units. Detaching leaves
8891
+ * the model at identity until the host places it again.
8892
+ *
8893
+ * A bone the parent lacks rides the parent's root, which is what camera
8894
+ * follow does with an unknown name. Returns false for an unknown model, a
8895
+ * missing parent, or a model asked to ride itself.
8896
+ */
8897
+ setModelParent(
8898
+ name: string,
8899
+ parent: string | null,
8900
+ bone = "全ての親",
8901
+ offset?: { position?: Vec3; rotation?: Quat },
8902
+ ): boolean {
8903
+ const inst = this.modelInstances.get(name)
8904
+ if (!inst) return false
8905
+ if (parent === null) {
8906
+ if (inst.parent) {
8907
+ inst.parent = null
8908
+ inst.model.setRootParent(null)
8909
+ inst.skinMatricesDirty = true
8910
+ this.updateOrderDirty = true
8911
+ }
8912
+ return true
8913
+ }
8914
+ if (parent === name || !this.modelInstances.has(parent)) return false
8915
+ const p = offset?.position ?? new Vec3(0, 0, 0)
8916
+ const r = offset?.rotation ?? Quat.identity()
8917
+ const offsetMatrix = inst.parent?.offsetMatrix ?? new Float32Array(16)
8918
+ Mat4.fromPositionRotationScaleInto(p.x, p.y, p.z, r.x, r.y, r.z, r.w, 1, offsetMatrix)
8919
+ // Identity until the first frame fills it: a physics reset between now and
8920
+ // then re-poses the model, and a zero matrix would fold it to a point.
8921
+ const rootMatrix = inst.parent?.rootMatrix ?? new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])
8922
+ inst.parent = { model: parent, bone, offsetMatrix, rootMatrix }
8923
+ inst.model.setPosition(new Vec3(0, 0, 0))
8924
+ inst.model.setRotation(Quat.identity())
8925
+ inst.model.setRootParent(rootMatrix)
8926
+ inst.skinMatricesDirty = true
8927
+ this.updateOrderDirty = true
8928
+ return true
8929
+ }
8930
+
8931
+ /** What a model hangs from, or null. */
8932
+ getModelParent(name: string): ModelAttachment | null {
8933
+ const att = this.modelInstances.get(name)?.parent
8934
+ return att ? { model: att.model, bone: att.bone } : null
8935
+ }
8936
+
8937
+ /**
8938
+ * The root an attached model is posed under this frame: the parent's
8939
+ * placement, its bone as posed and simulated, then the offset.
8940
+ *
8941
+ * The translation is divided by the child's own scale. The skin bake
8942
+ * multiplies the child's scale back on outside the skeleton, and a uniform
8943
+ * scale commutes with the rotation, so this is exactly what lands the child
8944
+ * at the bone in world units while its mesh still comes out scaled.
8945
+ */
8946
+ private placeAttached(inst: ModelInstance): void {
8947
+ const att = inst.parent!
8948
+ const parent = this.modelInstances.get(att.model)
8949
+ if (!parent) {
8950
+ this.setModelParent(inst.name, null)
8951
+ return
8952
+ }
8953
+ const out = att.rootMatrix
8954
+ const tmp = this.attachScratch
8955
+ const root = parent.model.getRootMatrix()
8956
+ const bone = parent.model.getBoneWorldMatrix(att.bone)
8957
+ if (bone) {
8958
+ Mat4.multiplyArrays(root, 0, bone, 0, tmp, 0)
8959
+ Mat4.multiplyArrays(tmp, 0, att.offsetMatrix, 0, out, 0)
8960
+ } else {
8961
+ Mat4.multiplyArrays(root, 0, att.offsetMatrix, 0, out, 0)
8962
+ }
8963
+ const s = inst.model.scale
8964
+ if (s > 0 && s !== 1) {
8965
+ const k = 1 / s
8966
+ out[12] *= k
8967
+ out[13] *= k
8968
+ out[14] *= k
8969
+ }
8970
+ }
8971
+ private readonly attachScratch = new Float32Array(16)
8972
+
8973
+ /** Instances in pose order: a parent before every model hanging from it, so
8974
+ * a child reads the bone as posed and simulated THIS frame. Insertion order
8975
+ * otherwise. Rebuilt when a model is added, removed or re-parented. */
8976
+ private updateOrder: ModelInstance[] = []
8977
+ private updateOrderDirty = true
8978
+ private instancesInUpdateOrder(): ModelInstance[] {
8979
+ if (!this.updateOrderDirty) return this.updateOrder
8980
+ const placed = new Set<string>()
8981
+ const order: ModelInstance[] = []
8982
+ let pending = Array.from(this.modelInstances.values())
8983
+ while (pending.length > 0) {
8984
+ const rest: ModelInstance[] = []
8985
+ for (const inst of pending) {
8986
+ const p = inst.parent?.model
8987
+ if (p === undefined || placed.has(p) || !this.modelInstances.has(p)) {
8988
+ order.push(inst)
8989
+ placed.add(inst.name)
8990
+ } else rest.push(inst)
8991
+ }
8992
+ if (rest.length === pending.length) {
8993
+ // A cycle: nothing left can go first. They pose in insertion order and
8994
+ // each reads the other's previous frame, which is the best a cycle gets.
8995
+ console.warn(`[reze] attachment cycle: ${rest.map((r) => r.name).join(" → ")}`)
8996
+ order.push(...rest)
8997
+ break
8998
+ }
8999
+ pending = rest
9000
+ }
9001
+ this.updateOrder = order
9002
+ this.updateOrderDirty = false
9003
+ return order
9004
+ }
9005
+
8807
9006
  /**
8808
9007
  * Place a model in the scene — position, rotation, uniform scale, visibility. The
8809
9008
  * transform is a root offset baked into skinning (moves the whole rig), so it composes
@@ -8815,8 +9014,11 @@ export class Engine {
8815
9014
  const inst = this.modelInstances.get(name)
8816
9015
  const model = inst?.model
8817
9016
  if (!inst || !model) return
8818
- if (transform.position) model.setPosition(transform.position)
8819
- if (transform.rotation) model.setRotation(transform.rotation)
9017
+ // An attached model is placed by its parent's bone; its own position and
9018
+ // rotation are held at identity so the ride is the whole placement (see
9019
+ // setModelParent). Scale and visibility are still its own.
9020
+ if (transform.position && !inst.parent) model.setPosition(transform.position)
9021
+ if (transform.rotation && !inst.parent) model.setRotation(transform.rotation)
8820
9022
  if (transform.scale !== undefined) model.setScale(transform.scale)
8821
9023
  if (transform.visible !== undefined) model.setVisible(transform.visible)
8822
9024
  // The root transform is baked into the skin matrices, so moving a model is a
@@ -8937,7 +9139,7 @@ export class Engine {
8937
9139
 
8938
9140
  for (const inst of this.modelInstances.values()) {
8939
9141
  if (options.modelName !== undefined && inst.name !== options.modelName) continue
8940
- if (inst.isStage || inst.isPlane) continue
9142
+ if (inst.isStage || inst.isPlane || inst.isProp) continue
8941
9143
  const bones = inst.model.getSkeleton().bones
8942
9144
  this.bonePickScratch = boneMarkerPositions(inst.model, this.bonePickScratch)
8943
9145
  const pos = this.bonePickScratch
@@ -9004,7 +9206,7 @@ export class Engine {
9004
9206
 
9005
9207
  for (const inst of this.modelInstances.values()) {
9006
9208
  if (options.modelName !== undefined && inst.name !== options.modelName) continue
9007
- if (inst.isStage || inst.isPlane) continue
9209
+ if (inst.isStage || inst.isPlane || inst.isProp) continue
9008
9210
  const model = inst.model
9009
9211
  const { positions } = model.getGeometry()
9010
9212
  const count = positions.length / 3
@@ -9321,12 +9523,18 @@ export class Engine {
9321
9523
  private updateInstances(deltaTime: number): void {
9322
9524
  let animMs = 0
9323
9525
  let physicsMs = 0
9324
- this.forEachInstance((inst) => {
9526
+ for (const inst of this.instancesInUpdateOrder()) {
9325
9527
  const tAnim = performance.now()
9528
+ // An attached model is placed from its parent's bone as posed and
9529
+ // simulated THIS frame — the order guarantees the parent came first —
9530
+ // and only then posed itself, so its clip and physics ride the placement.
9531
+ const attached = inst.parent !== null
9532
+ if (attached) this.placeAttached(inst)
9326
9533
  // A stage never solves IK — nothing drives its chains — and skips the pose
9327
9534
  // pass entirely while it is idle. Morph changes still come through, since
9328
- // that is the one thing a stage's controls do move.
9329
- const stageIdle = (inst.isStage || inst.isPlane) && inst.model.isIdle()
9535
+ // that is the one thing a stage's controls do move. A prop idles the same
9536
+ // way while it stands on its own; hung from a hand it moves every frame.
9537
+ const stageIdle = (inst.isStage || inst.isPlane || inst.isProp) && !attached && inst.model.isIdle()
9330
9538
  let verticesChanged = false
9331
9539
  if (!stageIdle) {
9332
9540
  verticesChanged = inst.model.update(deltaTime, inst.isStage || inst.isPlane ? false : this.ikEnabled)
@@ -9370,7 +9578,7 @@ export class Engine {
9370
9578
  physicsMs += performance.now() - tPhys
9371
9579
  }
9372
9580
  if (inst.vertexBufferNeedsUpdate) this.updateVertexBuffer(inst)
9373
- })
9581
+ }
9374
9582
  this.frameAnimMsRaw = animMs
9375
9583
  this.framePhysicsMsRaw = physicsMs
9376
9584
  const EMA = 0.1
@@ -10327,6 +10535,7 @@ export class Engine {
10327
10535
  isStage = false,
10328
10536
  isPlane = false,
10329
10537
  dynamicTexture = false,
10538
+ isProp = false,
10330
10539
  ): Promise<void> {
10331
10540
  const vertices = model.getVertices()
10332
10541
  const skinning = model.getSkinning()
@@ -10472,6 +10681,8 @@ export class Engine {
10472
10681
  pickDrawCalls: [],
10473
10682
  isStage,
10474
10683
  isPlane,
10684
+ isProp,
10685
+ parent: null,
10475
10686
  dynamicTexture,
10476
10687
  // Seeded true: the bind pose has to reach the GPU once before any frame.
10477
10688
  skinMatricesDirty: true,
@@ -10500,6 +10711,7 @@ export class Engine {
10500
10711
  this.modelInstances.set(name, inst)
10501
10712
  this.cullListDirty = true
10502
10713
  this.bundlesDirty = true
10714
+ this.updateOrderDirty = true
10503
10715
  }
10504
10716
 
10505
10717
  // Build the per-model GPU vertex-morph state. Returns null (and leaves the model on the
@@ -13882,7 +14094,7 @@ export class Engine {
13882
14094
  // serves.
13883
14095
  let n = 0
13884
14096
  this.forEachInstance((inst) => {
13885
- if (n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane) return
14097
+ if (n >= MAX_EFFECT_SUBJECTS || inst.isStage || inst.isPlane || inst.isProp) return
13886
14098
  const m = inst.model
13887
14099
  // The model transform is only where the model was PLACED. A motion moves
13888
14100
  // the character by animating bones, so an effect anchored to the
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  type MaterialPreset,
15
15
  type MaterialPresetMap,
16
16
  type ModelTransform,
17
+ type ModelAttachment,
17
18
  type GizmoDragEvent,
18
19
  type GizmoDragCallback,
19
20
  type GizmoDragKind,
package/src/model.ts CHANGED
@@ -315,6 +315,38 @@ export class Model {
315
315
  this._visible = visible
316
316
  }
317
317
 
318
+ /** Hang the rig's root bones from `matrix` (model space, column-major 16
319
+ * floats), or from nothing. The engine drives this every frame for an
320
+ * attached model; the matrix is read at the next world pass, not copied. */
321
+ setRootParent(matrix: Float32Array | null): void {
322
+ this.rootParent = matrix
323
+ if (matrix) {
324
+ const root = this.skeleton.bones.find((b) => b.parentIndex < 0)
325
+ this.primaryRootBind = root ? [root.bindTranslation[0], root.bindTranslation[1], root.bindTranslation[2]] : [0, 0, 0]
326
+ }
327
+ }
328
+
329
+ getRootParent(): Float32Array | null {
330
+ return this.rootParent
331
+ }
332
+
333
+ /** The placement matrix (position · rotation · scale) the skin bake composes
334
+ * onto every bone. Rebuilt lazily, the way getSkinMatrices does it. */
335
+ getRootMatrix(): Float32Array {
336
+ this.refreshRootMatrix()
337
+ return this.rootMatrixValues
338
+ }
339
+
340
+ private refreshRootMatrix(): void {
341
+ if (!this.rootMatrixDirty) return
342
+ const p = this._position, r = this._rotation, s = this._scale
343
+ Mat4.fromPositionRotationScaleInto(p.x, p.y, p.z, r.x, r.y, r.z, r.w, s, this.rootMatrixValues)
344
+ this.rootIsIdentity =
345
+ p.x === 0 && p.y === 0 && p.z === 0 &&
346
+ r.x === 0 && r.y === 0 && r.z === 0 && r.w === 1 && s === 1
347
+ this.rootMatrixDirty = false
348
+ }
349
+
318
350
  private vertexData: Float32Array<ArrayBuffer>
319
351
  private baseVertexData: Float32Array<ArrayBuffer> // Original vertex data before morphing
320
352
  private vertexCount: number
@@ -383,6 +415,21 @@ export class Model {
383
415
  private rootMatrixValues: Float32Array = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1])
384
416
  private rootMatrixDirty: boolean = false
385
417
  private rootIsIdentity: boolean = true
418
+ /** What every parentless bone hangs from — MMD's 外部親 (outside parent).
419
+ * Model space, so the pose pipeline, IK and physics all see it: a prop bound
420
+ * to a hand is moved by its BONES, and gravity keeps pointing down while the
421
+ * hand tilts. Null is the ordinary rig, rooted at the model's own origin.
422
+ * Written per frame by the engine from the parent's posed bone; see
423
+ * Engine.setModelParent. */
424
+ private rootParent: Float32Array | null = null
425
+ /** The bind position of the PRIMARY root — the first parentless bone, 全ての親
426
+ * by convention. Under a root parent that bone sits exactly ON the parent
427
+ * bone, as MMD's 外部親 does, so its bind position is taken off every root's
428
+ * local matrix: the primary lands at the parent, the other roots keep their
429
+ * layout relative to it. Without this the MODEL ORIGIN went to the parent
430
+ * bone, and a prop rigged with its one bone at the mesh's centre hung that
431
+ * far away from the hand. */
432
+ private primaryRootBind: [number, number, number] = [0, 0, 0]
386
433
 
387
434
  // Cached skin matrices array to avoid allocations in getSkinMatrices
388
435
  private skinMatricesArray?: Float32Array
@@ -984,6 +1031,14 @@ export class Model {
984
1031
  return this.clipApplySuspended
985
1032
  }
986
1033
 
1034
+ /** A bone's posed matrix — model space, column-major, the live array rather
1035
+ * than a copy. Null for a name this rig does not have. */
1036
+ getBoneWorldMatrix(boneName: string): Float32Array | null {
1037
+ const idx = this.runtimeSkeleton.nameIndex[boneName]
1038
+ if (idx === undefined || idx < 0) return null
1039
+ return this.runtimeSkeleton.worldMatrices[idx].values
1040
+ }
1041
+
987
1042
  // World bone origin (world matrix col3); unknown name → null
988
1043
  getBoneWorldPosition(boneName: string): Vec3 | null {
989
1044
  const idx = this.runtimeSkeleton.nameIndex[boneName]
@@ -1238,14 +1293,7 @@ export class Model {
1238
1293
  const skinMatrices = this.skinMatricesArray
1239
1294
 
1240
1295
  // Rebuild root matrix + cache identity-shortcut flag only when pos/rot changed.
1241
- if (this.rootMatrixDirty) {
1242
- const p = this._position, r = this._rotation, s = this._scale
1243
- Mat4.fromPositionRotationScaleInto(p.x, p.y, p.z, r.x, r.y, r.z, r.w, s, this.rootMatrixValues)
1244
- this.rootIsIdentity =
1245
- p.x === 0 && p.y === 0 && p.z === 0 &&
1246
- r.x === 0 && r.y === 0 && r.z === 0 && r.w === 1 && s === 1
1247
- this.rootMatrixDirty = false
1248
- }
1296
+ this.refreshRootMatrix()
1249
1297
 
1250
1298
  if (this.rootIsIdentity) {
1251
1299
  // skinMatrix = worldMatrix × inverseBindMatrix
@@ -2644,6 +2692,12 @@ export class Model {
2644
2692
  if (b.parentIndex >= 0) {
2645
2693
  const parentMat = worldMats[b.parentIndex]
2646
2694
  Mat4.multiplyArrays(parentMat.values, 0, localMVals, 0, worldMat.values, 0)
2695
+ } else if (this.rootParent) {
2696
+ const pr = this.primaryRootBind
2697
+ localMVals[12] -= pr[0]
2698
+ localMVals[13] -= pr[1]
2699
+ localMVals[14] -= pr[2]
2700
+ Mat4.multiplyArrays(this.rootParent, 0, localMVals, 0, worldMat.values, 0)
2647
2701
  } else {
2648
2702
  worldMat.values.set(localMVals)
2649
2703
  }
@@ -2801,6 +2855,8 @@ export class Model {
2801
2855
  // leaving every other bone — the simulated ones above all — untouched.
2802
2856
  const order = subset ?? this.deformOrder
2803
2857
  const count = subset ? subset.length : boneCount
2858
+ const rootParent = this.rootParent
2859
+ const primaryRootBind = this.primaryRootBind
2804
2860
  const override = this.appendRotOverride
2805
2861
  const overrideSet = this.appendRotOverrideSet
2806
2862
  for (let k = 0; k < count; k++) {
@@ -2875,6 +2931,13 @@ export class Model {
2875
2931
  if (b.parentIndex >= 0) {
2876
2932
  const parentMat = worldMats[b.parentIndex]
2877
2933
  Mat4.multiplyArrays(parentMat.values, 0, localMVals, 0, worldMat.values, 0)
2934
+ } else if (rootParent) {
2935
+ // The primary root's bind position comes off every root, so the primary
2936
+ // sits ON the parent bone. See primaryRootBind.
2937
+ localMVals[12] -= primaryRootBind[0]
2938
+ localMVals[13] -= primaryRootBind[1]
2939
+ localMVals[14] -= primaryRootBind[2]
2940
+ Mat4.multiplyArrays(rootParent, 0, localMVals, 0, worldMat.values, 0)
2878
2941
  } else {
2879
2942
  worldMat.values.set(localMVals)
2880
2943
  }
@@ -56,10 +56,13 @@ export type LyricRect = [number, number, number, number]
56
56
  /**
57
57
  * Parse an .lrc file: `[mm:ss.xx]` tags (several per line share the text),
58
58
  * an optional `[offset:±ms]` tag, blank-text tags kept as instrumental gaps'
59
- * end markers. Lines come out sorted; each line's end is the next line's
60
- * start, and the last line gets a ten-second hold. The offset follows the
61
- * LRC convention: positive shows lines EARLIER the knob to turn when the
62
- * words feel late against this particular rip.
59
+ * end markers. Lines come out sorted; each line's end is the next LATER
60
+ * stamp's start, and the last line gets a ten-second hold. Several lines on
61
+ * one stamp an original and its translation, the bilingual .lrc idiom
62
+ * stay in file order and share the one window, so an effect finds a
63
+ * translation as the consecutive lines whose start equals the live line's.
64
+ * The offset follows the LRC convention: positive shows lines EARLIER — the
65
+ * knob to turn when the words feel late against this particular rip.
63
66
  */
64
67
  export function parseLRC(source: string): LyricLine[] {
65
68
  let offset = 0
@@ -84,7 +87,13 @@ export function parseLRC(source: string): LyricLine[] {
84
87
  // An empty-text stamp is an .lrc idiom for "the previous line ends here";
85
88
  // it closes its predecessor and is not a line of its own.
86
89
  if (stamped[i].text === "") continue
87
- const next = stamped[i + 1]
90
+ // The window closes at the next stamp that is actually LATER. A stamp
91
+ // shared by an original and its translation is one moment; closing the
92
+ // first line at the second's start gave it a zero-length window no clock
93
+ // ever fell inside, and only the translation was ever drawn.
94
+ let j = i + 1
95
+ while (j < stamped.length && stamped[j].start <= stamped[i].start) j++
96
+ const next = stamped[j]
88
97
  lines.push({
89
98
  start: stamped[i].start,
90
99
  end: next ? next.start : stamped[i].start + 10,