reze-engine 0.50.4 → 0.50.6

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.
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { Vec3 } from "./math"
6
6
  import { bezierInterpolate } from "./animation"
7
- import type { CameraKeyframe } from "./vmd-loader"
7
+ import { DEFAULT_CAMERA_INTERPOLATION, type CameraKeyframe } from "./vmd-loader"
8
8
 
9
9
  const FPS = 30
10
10
  const DEG2RAD = Math.PI / 180
@@ -53,6 +53,19 @@ export class CameraAnimation {
53
53
  return this.frames.map((f) => f.frame)
54
54
  }
55
55
 
56
+ /**
57
+ * Every keyframe, in full — the track as an editable document.
58
+ *
59
+ * The counterpart to keyframeIndices(), which deliberately withholds the
60
+ * poses because a timeline only wants the rhythm. An EDITOR wants the poses:
61
+ * it has to show what the shot does at a cut, let it be changed, and write
62
+ * the result back out. Shallow copies, so a host mutating what it gets back
63
+ * cannot reach into the track this is sampling from mid-playback.
64
+ */
65
+ keyframes(): CameraKeyframe[] {
66
+ return this.frames.map((f) => ({ ...f }))
67
+ }
68
+
56
69
  /** Sample the camera pose at time `t` (seconds). Clamps to the track ends; null if empty. */
57
70
  sample(t: number): CameraPose | null {
58
71
  const frames = this.frames
@@ -76,7 +89,7 @@ export class CameraAnimation {
76
89
  const span = b.frame - a.frame
77
90
  const localT = span > 0 ? (frame - a.frame) / span : 0
78
91
  // The incoming interpolation curve is stored on the segment's end keyframe (b).
79
- const ip = b.interpolation
92
+ const ip = b.interpolation ?? DEFAULT_CAMERA_INTERPOLATION
80
93
 
81
94
  return {
82
95
  target: new Vec3(
package/src/engine.ts CHANGED
@@ -8,7 +8,8 @@ import { CULL_COMPUTE_WGSL } from "./shaders/passes/cull"
8
8
  import { buildAnchorTable, anchorAliasWgsl, EMPTY_ANCHOR_TABLE, type AnchorTable } from "./shaders/anchor-table"
9
9
  import { MIDI_HEADER, MIDI_KEYS, MIDI_NOTES, MIDI_STRIDE } from "./shaders/midi-api"
10
10
  import { decodeTga } from "./tga-loader"
11
- import { VMDLoader } from "./vmd-loader"
11
+ import { VMDLoader, type CameraKeyframe } from "./vmd-loader"
12
+ import { VMDWriter } from "./vmd-writer"
12
13
  import { CameraAnimation } from "./camera-animation"
13
14
  import { PmxLoader } from "./pmx-loader"
14
15
  import { RezePhysics } from "./physics"
@@ -1525,6 +1526,16 @@ export class Engine {
1525
1526
  * this line, and the accessors then answer 0 rather than failing to compile.
1526
1527
  */
1527
1528
  private static readonly MRT_IDS = true
1529
+ /**
1530
+ * What fraction of its authored damping a chest rig's body keeps.
1531
+ *
1532
+ * The whole tuning surface for how long those rigs swing: lower rings
1533
+ * longer, 1 restores the authored value exactly. It does NOT change where
1534
+ * they hang at rest — that is the property that made damping the right knob
1535
+ * (see RezePhysics.setJiggleDamping). Judge it against the models that
1536
+ * motivated it; it is a starting point, not a measurement.
1537
+ */
1538
+ private static readonly JIGGLE_DAMPING_SCALE = 0.5
1528
1539
  /** The id attachment. Multisampled with the pass and NEVER resolved: an
1529
1540
  * averaged id belongs to nothing, so consumers textureLoad sample 0. */
1530
1541
  private idTexture: GPUTexture | null = null
@@ -6500,6 +6511,39 @@ export class Engine {
6500
6511
  this.camera.setVmdDriven(this.cameraAnimation !== null)
6501
6512
  }
6502
6513
 
6514
+ /**
6515
+ * Drive the shot from camera keyframes built in JS — the camera's answer to
6516
+ * `Model.loadClip`.
6517
+ *
6518
+ * The two loadCameraVmd* methods take FILE BYTES, which is all a viewer ever
6519
+ * needs. An editor needs the other direction: hold the track as data, change
6520
+ * a keyframe, and see the result immediately. Going through the writer and
6521
+ * back through the parser for every edit would work and would be absurd.
6522
+ *
6523
+ * Empty (or an empty array) clears the track and returns the camera to orbit,
6524
+ * same as clearCameraVmd — a track with no keyframes cannot drive anything,
6525
+ * and silently keeping the previous one would be worse than saying so.
6526
+ */
6527
+ loadCameraClip(frames: CameraKeyframe[]): void {
6528
+ this.cameraAnimation = frames.length ? new CameraAnimation([...frames]) : null
6529
+ this.camera.setVmdDriven(this.cameraAnimation !== null)
6530
+ }
6531
+
6532
+ /** The loaded camera track as editable keyframes, or [] with none loaded.
6533
+ * Copies — mutating them does not reach the track being sampled. */
6534
+ getCameraClip(): CameraKeyframe[] {
6535
+ return this.cameraAnimation?.keyframes() ?? []
6536
+ }
6537
+
6538
+ /** The loaded camera track as camera-VMD bytes. Throws with none loaded:
6539
+ * writing an empty camera file is a mistake worth hearing about, not a
6540
+ * 30-byte header to hand someone as a download. */
6541
+ exportCameraVmd(): ArrayBuffer {
6542
+ const frames = this.cameraAnimation?.keyframes()
6543
+ if (!frames?.length) throw new Error("No camera track loaded")
6544
+ return new VMDWriter().writeCamera(frames)
6545
+ }
6546
+
6503
6547
  /** Turn the loaded camera VMD on/off (falls back to orbit when off). No-op if none loaded. */
6504
6548
  setCameraVmdEnabled(enabled: boolean): void {
6505
6549
  this.camera.setVmdDriven(enabled && this.cameraAnimation !== null)
@@ -7505,6 +7549,10 @@ export class Engine {
7505
7549
  if (inst.physics && this.physicsEnabled && inst.model.visible) {
7506
7550
  const tPhys = performance.now()
7507
7551
  inst.physics.step(deltaTime, inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices())
7552
+ // The step published new world matrices for the simulated bones; the
7553
+ // bones that INHERIT from them are still wearing the animated pose.
7554
+ // Returns immediately unless this rig actually has such a bone.
7555
+ inst.model.applyPhysicsAppend()
7508
7556
  physicsMs += performance.now() - tPhys
7509
7557
  }
7510
7558
  if (inst.vertexBufferNeedsUpdate) this.updateVertexBuffer(inst)
@@ -8522,6 +8570,20 @@ export class Engine {
8522
8570
  // solver for the heaviest mesh in the scene and dropping it afterwards was
8523
8571
  // both wasted work and an invariant maintained in the wrong place.
8524
8572
  const physics = !isStage && rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null
8573
+ // Which bones the simulation will overwrite, handed to the pose pipeline so
8574
+ // the append (付与) pass can consume the simulated result instead of the
8575
+ // animated one. Precomputed here, once, because the answer is topology —
8576
+ // see Model.setPhysicsDrivenBones for what it costs when a rig needs it and
8577
+ // why it costs nothing when none does.
8578
+ if (physics) {
8579
+ model.setPhysicsDrivenBones(physics.getPhysicsDrivenBones())
8580
+ // The bodies an inherited-from bone rides on are damped less than the
8581
+ // rest, so they swing longer WITHOUT hanging lower — see
8582
+ // RezePhysics.setJiggleDamping for why damping is the separable knob and
8583
+ // solver iterations are not.
8584
+ const appendSources = model.getAppendSourceBones()
8585
+ if (appendSources.length > 0) physics.setJiggleDamping(appendSources, Engine.JIGGLE_DAMPING_SCALE)
8586
+ }
8525
8587
  // Adopt the scene's air, or a model added mid-session would fall under
8526
8588
  // different gravity from the ones already on stage.
8527
8589
  if (physics) {
package/src/index.ts CHANGED
@@ -109,8 +109,8 @@ export {
109
109
  interpolateControlPoints,
110
110
  rawInterpolationToBoneInterpolation,
111
111
  } from "./animation"
112
- export { VMDLoader, type CameraKeyframe, type IkFrame } from "./vmd-loader"
113
- export { VMDWriter } from "./vmd-writer"
112
+ export { VMDLoader, DEFAULT_CAMERA_INTERPOLATION, type CameraKeyframe, type IkFrame } from "./vmd-loader"
113
+ export { VMDWriter, type VmdTrackSelection } from "./vmd-writer"
114
114
  export { PmxLoader } from "./pmx-loader"
115
115
  export { CameraAnimation, type CameraPose } from "./camera-animation"
116
116
  export { RezePhysics } from "./physics"
package/src/model.ts CHANGED
@@ -4,7 +4,7 @@ import { joinAssetPath, type AssetReader } from "./asset-reader"
4
4
  import { Rigidbody, Joint } from "./physics"
5
5
  import { IKSolverSystem } from "./ik-solver"
6
6
  import { VMDLoader, type VMDKeyFrame } from "./vmd-loader"
7
- import { VMDWriter } from "./vmd-writer"
7
+ import { VMDWriter, type VmdTrackSelection } from "./vmd-writer"
8
8
  import {
9
9
  AnimationClip,
10
10
  AnimationPlayOptions,
@@ -30,6 +30,10 @@ const _animSlerp = new Quat(0, 0, 0, 1)
30
30
  const _animInterpT = new Vec3(0, 0, 0)
31
31
  const _convOut = new Vec3(0, 0, 0)
32
32
  const _convMat = new Float32Array(16)
33
+ // Scratch for the post-physics append recovery — see applyPhysicsAppend.
34
+ const _appendBasisX = new Vec3(0, 0, 0)
35
+ const _appendBasisY = new Vec3(0, 0, 0)
36
+ const _appendBasisZ = new Vec3(0, 0, 0)
33
37
  // Blend-path scratch: per-entry sample target and the crossfade's two fixed entries.
34
38
  const _blendQ = new Quat(0, 0, 0, 1)
35
39
  const _blendT = new Vec3(0, 0, 0)
@@ -388,6 +392,15 @@ export class Model {
388
392
  // visited-array + closure. Order depends only on parentIndex (static), so this
389
393
  // reproduces the old recursion's finishing order exactly. See buildDeformOrder.
390
394
  private deformOrder!: Int32Array
395
+ /** 1 where the simulation overwrites the bone's world matrix. See setPhysicsDrivenBones. */
396
+ private physicsDriven: Uint8Array | null = null
397
+ /** Bones to recompute after a step, in deform order — null when no rig needs it. */
398
+ private physicsAppendOrder: Int32Array | null = null
399
+ /** Simulated bones something inherits from; their local rotation is recovered. */
400
+ private physicsAppendSources: Int32Array | null = null
401
+ /** Recovered post-step local rotations, kept OUT of localRotations on purpose. */
402
+ private appendRotOverride: Quat[] | null = null
403
+ private appendRotOverrideSet: Uint8Array | null = null
391
404
 
392
405
  // Bind-pose absolute (world) position per bone, xyz packed. Static (bindTranslation
393
406
  // accumulated down the hierarchy). Precomputed once so convertVMDTranslationToLocal
@@ -1707,10 +1720,20 @@ export class Model {
1707
1720
  return values[i - 1] + (values[i] - values[i - 1]) * t
1708
1721
  }
1709
1722
 
1710
- exportVmd(name: string): ArrayBuffer {
1723
+ /**
1724
+ * The clip called `name` as VMD bytes.
1725
+ *
1726
+ * `tracks` splits the file the same way `loadVmd`'s option splits what it
1727
+ * reads: "motion" is the dance (bones + IK), "morphs" is the expression file
1728
+ * to lay over one, "all" (the default) is both in one file as MMD exports it.
1729
+ * A clip missing the half you asked for still writes — an empty motion or an
1730
+ * empty expression file is a valid VMD, and is the honest answer to "export
1731
+ * the morphs" for a clip that has none.
1732
+ */
1733
+ exportVmd(name: string, options?: { tracks?: VmdTrackSelection }): ArrayBuffer {
1711
1734
  const clip = this.animationState.getAnimationClip(name)
1712
1735
  if (!clip) throw new Error(`Animation clip "${name}" not found`)
1713
- return new VMDWriter().write(clip)
1736
+ return new VMDWriter().write(clip, options)
1714
1737
  }
1715
1738
 
1716
1739
  play(): void
@@ -2612,7 +2635,141 @@ export class Model {
2612
2635
  }
2613
2636
  }
2614
2637
 
2615
- computeWorldMatrices(): void {
2638
+ /**
2639
+ * Which bones the physics simulation overwrites, and what that costs the
2640
+ * append (付与) pass — the 胸 problem.
2641
+ *
2642
+ * PMX lets a bone inherit a fraction of another bone's rotation (付与親 /
2643
+ * append parent). The pipeline computes that inheritance inside
2644
+ * computeWorldMatrices, which runs BEFORE physics — and it reads the
2645
+ * parent's LOCAL rotation, which physics never writes: the simulation
2646
+ * publishes world matrices only. So when a rig hangs a bone off a simulated
2647
+ * one, the inheritance saw the animated pose and nothing else, and the
2648
+ * dependent bone sat still no matter how much the parent swung. Rigs that
2649
+ * drive a chest this way — a simulated bone with the visible bones
2650
+ * appending from it — produced no motion at all.
2651
+ *
2652
+ * The engine calls this once, after building the simulation, and it
2653
+ * precomputes the whole answer: WHICH bones need revisiting after a step,
2654
+ * in deform order. Everything downstream is a walk over that list.
2655
+ */
2656
+ setPhysicsDrivenBones(boneIndices: number[]): void {
2657
+ const bones = this.skeleton.bones
2658
+ const n = bones.length
2659
+ this.physicsDriven = new Uint8Array(n)
2660
+ for (const b of boneIndices) if (b >= 0 && b < n) this.physicsDriven[b] = 1
2661
+
2662
+ // Bones to recompute after a step: anything that INHERITS from a simulated
2663
+ // bone, everything under it, and anything inheriting from those in turn.
2664
+ // Simulated bones themselves are deliberately excluded — their world matrix
2665
+ // IS the simulation's output, and recomputing it from a local pose the
2666
+ // simulation never wrote would throw the step away.
2667
+ const affected = new Uint8Array(n)
2668
+ let changed = true
2669
+ while (changed) {
2670
+ changed = false
2671
+ for (let k = 0; k < n; k++) {
2672
+ const i = this.deformOrder[k]
2673
+ if (affected[i] || this.physicsDriven[i]) continue
2674
+ const b = bones[i]
2675
+ const ap = b.appendParentIndex
2676
+ const inheritsAffected =
2677
+ (b.appendRotate || b.appendMove) && ap !== undefined && ap >= 0 && ap < n && (this.physicsDriven[ap] || affected[ap])
2678
+ const parentAffected = b.parentIndex >= 0 && affected[b.parentIndex]
2679
+ if (inheritsAffected || parentAffected) {
2680
+ affected[i] = 1
2681
+ changed = true
2682
+ }
2683
+ }
2684
+ }
2685
+
2686
+ const order: number[] = []
2687
+ for (let k = 0; k < n; k++) {
2688
+ const i = this.deformOrder[k]
2689
+ if (affected[i]) order.push(i)
2690
+ }
2691
+ this.physicsAppendOrder = order.length > 0 ? Int32Array.from(order) : null
2692
+ // The simulated bones something actually inherits from — the only ones
2693
+ // whose post-step local rotation has to be recovered below.
2694
+ const sources = new Set<number>()
2695
+ for (const i of order) {
2696
+ const ap = bones[i].appendParentIndex
2697
+ if (ap !== undefined && ap >= 0 && ap < n && this.physicsDriven[ap]) sources.add(ap)
2698
+ }
2699
+ this.physicsAppendSources = sources.size > 0 ? Int32Array.from(sources) : null
2700
+ if (this.appendRotOverride === null && this.physicsAppendOrder) {
2701
+ this.appendRotOverride = Array.from({ length: n }, () => Quat.identity())
2702
+ this.appendRotOverrideSet = new Uint8Array(n)
2703
+ }
2704
+ }
2705
+
2706
+ /** The simulated bones that visible bones INHERIT from — the chest rig, in
2707
+ * one list. Empty unless setPhysicsDrivenBones found such a relationship. */
2708
+ getAppendSourceBones(): number[] {
2709
+ return this.physicsAppendSources ? Array.from(this.physicsAppendSources) : []
2710
+ }
2711
+
2712
+ /**
2713
+ * Re-run the append pass against the simulation's result. Call after a step.
2714
+ *
2715
+ * Two halves. First the simulated bones an append parent list names get their
2716
+ * post-step LOCAL rotation recovered — physics published only world matrices,
2717
+ * and the append math speaks local. The recovery is the ordinary change of
2718
+ * basis: local = parentWorld⁻¹ · world, read back as a quaternion off the
2719
+ * relative basis, which is exact for the rigid transforms these are.
2720
+ *
2721
+ * Then the dependent bones recompute, in deform order, reading that recovered
2722
+ * rotation instead of the animated one. The recovered value lives in its OWN
2723
+ * array rather than in localRotations, deliberately: localRotations is what
2724
+ * next frame's pose blends against and what the simulation reads to build
2725
+ * kinematic targets, and writing a simulation result back into it would make
2726
+ * the animation chase its own tail.
2727
+ */
2728
+ applyPhysicsAppend(): void {
2729
+ const order = this.physicsAppendOrder
2730
+ const sources = this.physicsAppendSources
2731
+ if (!order || !sources || !this.appendRotOverride || !this.appendRotOverrideSet) return
2732
+ const bones = this.skeleton.bones
2733
+ const worldMats = this.runtimeSkeleton.worldMatrices
2734
+
2735
+ this.appendRotOverrideSet.fill(0)
2736
+ for (let s = 0; s < sources.length; s++) {
2737
+ const i = sources[s]
2738
+ const w = worldMats[i].values
2739
+ const p = bones[i].parentIndex
2740
+ // Columns of the bone's own basis, expressed in its parent's frame. With
2741
+ // no parent the world basis already IS the local one.
2742
+ let bx0 = w[0], bx1 = w[1], bx2 = w[2]
2743
+ let by0 = w[4], by1 = w[5], by2 = w[6]
2744
+ let bz0 = w[8], bz1 = w[9], bz2 = w[10]
2745
+ if (p >= 0) {
2746
+ const pm = worldMats[p].values
2747
+ // parentᵀ · child, the rotation half of parentWorld⁻¹ · world: the
2748
+ // parent basis is orthonormal, so its inverse is its transpose.
2749
+ const r0 = bx0, r1 = bx1, r2 = bx2
2750
+ bx0 = pm[0] * r0 + pm[1] * r1 + pm[2] * r2
2751
+ bx1 = pm[4] * r0 + pm[5] * r1 + pm[6] * r2
2752
+ bx2 = pm[8] * r0 + pm[9] * r1 + pm[10] * r2
2753
+ const g0 = by0, g1 = by1, g2 = by2
2754
+ by0 = pm[0] * g0 + pm[1] * g1 + pm[2] * g2
2755
+ by1 = pm[4] * g0 + pm[5] * g1 + pm[6] * g2
2756
+ by2 = pm[8] * g0 + pm[9] * g1 + pm[10] * g2
2757
+ const b0 = bz0, b1 = bz1, b2 = bz2
2758
+ bz0 = pm[0] * b0 + pm[1] * b1 + pm[2] * b2
2759
+ bz1 = pm[4] * b0 + pm[5] * b1 + pm[6] * b2
2760
+ bz2 = pm[8] * b0 + pm[9] * b1 + pm[10] * b2
2761
+ }
2762
+ _appendBasisX.setXYZ(bx0, bx1, bx2)
2763
+ _appendBasisY.setXYZ(by0, by1, by2)
2764
+ _appendBasisZ.setXYZ(bz0, bz1, bz2)
2765
+ Quat.fromBasisInto(_appendBasisX, _appendBasisY, _appendBasisZ, this.appendRotOverride[i])
2766
+ this.appendRotOverrideSet[i] = 1
2767
+ }
2768
+
2769
+ this.computeWorldMatrices(order)
2770
+ }
2771
+
2772
+ computeWorldMatrices(subset?: Int32Array): void {
2616
2773
  const bones = this.skeleton.bones
2617
2774
  const localRot = this.runtimeSkeleton.localRotations
2618
2775
  const localTrans = this.runtimeSkeleton.localTranslations
@@ -2624,8 +2781,15 @@ export class Model {
2624
2781
  // Flat traversal in precomputed order: every bone's parent is already done, so no
2625
2782
  // per-bone visited check, no recursion, and no per-call allocation. Same per-bone
2626
2783
  // math as before. Scratch slots are safe to reuse since there's no reentrancy now.
2627
- const order = this.deformOrder
2628
- for (let k = 0; k < boneCount; k++) {
2784
+ //
2785
+ // A SUBSET is the post-physics pass (applyPhysicsAppend): the same walk over
2786
+ // the bones that inherit from a simulated one, in the same relative order,
2787
+ // leaving every other bone — the simulated ones above all — untouched.
2788
+ const order = subset ?? this.deformOrder
2789
+ const count = subset ? subset.length : boneCount
2790
+ const override = this.appendRotOverride
2791
+ const overrideSet = this.appendRotOverrideSet
2792
+ for (let k = 0; k < count; k++) {
2629
2793
  const i = order[k]
2630
2794
  const b = bones[i]
2631
2795
 
@@ -2643,7 +2807,12 @@ export class Model {
2643
2807
 
2644
2808
  if (hasRatio) {
2645
2809
  if (b.appendRotate) {
2646
- const appendRot = localRot[appendParentIdx]
2810
+ // The simulated parent's RECOVERED rotation when there is one — the
2811
+ // whole point of the post-physics pass. localRotations still holds
2812
+ // the animated pose for that bone, which is exactly what must not
2813
+ // be inherited here.
2814
+ const appendRot =
2815
+ override && overrideSet && overrideSet[appendParentIdx] ? override[appendParentIdx] : localRot[appendParentIdx]
2647
2816
  let ax = appendRot.x, ay = appendRot.y, az = appendRot.z
2648
2817
  const aw = appendRot.w
2649
2818
  const absRatio = ratio < 0 ? -ratio : ratio
@@ -706,6 +706,69 @@ export class RezePhysics {
706
706
  // boneWorld = bodyWorld × bodyOffsetInverse.
707
707
  // The body pose is the render-interpolated pose between the previous and current
708
708
  // substep states (alpha = fraction into the next step), which removes fixed-step judder.
709
+ /**
710
+ * Let the bodies carrying these bones swing longer, by damping them less.
711
+ *
712
+ * DAMPING, and not solver iterations, and the difference is the whole point.
713
+ * Under-converging a joint does make it swing further — it also stops it ever
714
+ * reaching equilibrium, so the body hangs visibly low at rest. Sag and swing
715
+ * come as a pair there and no amount of tuning separates them. Damping does
716
+ * separate them: for m·x″ + c·x′ + k·x = mg the rest position is mg/k, which
717
+ * c does not appear in. Less damping is a longer, larger oscillation about
718
+ * exactly the same resting height.
719
+ *
720
+ * Scoped to the bones asked for, because it is a look and not a correction —
721
+ * rigs whose visible bones inherit from a simulated one (付与親) are authored
722
+ * against an MMD that lets them move more than a faithfully damped
723
+ * simulation does. Hair and skirt keep their authored damping.
724
+ *
725
+ * `scale` multiplies the AUTHORED damping: 1 restores it, 0.5 halves it,
726
+ * 0 leaves the body undamped and ringing. Idempotent — the authored values
727
+ * are snapshotted on first use, so repeated calls set rather than compound.
728
+ */
729
+ setJiggleDamping(boneIndices: number[], scale: number): void {
730
+ const wanted = new Set(boneIndices.filter((b) => b >= 0))
731
+ if (wanted.size === 0) return
732
+ if (!this.authoredLinDamp || !this.authoredAngDamp) {
733
+ this.authoredLinDamp = Float32Array.from(this.store.linearDamping)
734
+ this.authoredAngDamp = Float32Array.from(this.store.angularDamping)
735
+ }
736
+ const s = Math.max(0, Math.min(1, scale))
737
+ const boneOf = this.store.boneIndex
738
+ for (let i = 0; i < this.store.count; i++) {
739
+ const b = boneOf[i]
740
+ if (b < 0 || !wanted.has(b)) continue
741
+ this.store.linearDamping[i] = this.authoredLinDamp[i] * s
742
+ this.store.angularDamping[i] = this.authoredAngDamp[i] * s
743
+ }
744
+ // The factors are cached against dt, which has not changed.
745
+ this.world.invalidateDampingCache()
746
+ }
747
+
748
+ /** Authored damping, kept so setJiggleDamping sets rather than compounds. */
749
+ private authoredLinDamp: Float32Array | null = null
750
+ private authoredAngDamp: Float32Array | null = null
751
+
752
+ /**
753
+ * Bones whose world matrix this simulation OVERWRITES each step.
754
+ *
755
+ * The same test applyDynamicsToBones runs — a Dynamic body bound to a real
756
+ * bone — exposed because the pose pipeline has to know. PMX lets a bone
757
+ * inherit rotation from an 付与親 (append parent), and when that parent is
758
+ * simulated the inheritance has to consume the SIMULATED result, not the
759
+ * animated pose the frame started with. Nothing else can answer which bones
760
+ * those are: the mapping lives in this store.
761
+ */
762
+ getPhysicsDrivenBones(): number[] {
763
+ const out: number[] = []
764
+ for (let i = 0; i < this.store.count; i++) {
765
+ if (this.store.type[i] !== RigidbodyType.Dynamic) continue
766
+ const b = this.store.boneIndex[i]
767
+ if (b >= 0) out.push(b)
768
+ }
769
+ return out
770
+ }
771
+
709
772
  private applyDynamicsToBones(boneWorldMatrices: Mat4[], alpha: number): void {
710
773
  const N = this.store.count
711
774
  const inv = this.store.bodyOffsetInverse
@@ -45,6 +45,13 @@ export class World {
45
45
  // Per-body damping factors pow(1−damping, dt), cached because damping and
46
46
  // the fixed dt never change — two Math.pow per body per substep otherwise.
47
47
  private dampCacheDt = -1
48
+
49
+ /** Drop the cached damping factors. The cache is keyed on dt alone, because
50
+ * authored damping never changed — until a rig asked for softer jiggle (see
51
+ * RezePhysics.setJiggleDamping), which rewrites the store's values. */
52
+ invalidateDampingCache(): void {
53
+ this.dampCacheDt = -1
54
+ }
48
55
  private linDampFactor: Float32Array | null = null
49
56
  private angDampFactor: Float32Array | null = null
50
57
 
package/src/vmd-loader.ts CHANGED
@@ -29,9 +29,28 @@ export interface CameraKeyframe {
29
29
  target: Vec3
30
30
  rotation: Vec3 // euler radians
31
31
  fov: number // degrees
32
- interpolation: Uint8Array // 24 bytes
32
+ /** 24 bytes, contiguous per channel — see camera-animation.ts's `bez`.
33
+ * Optional so a hand-authored keyframe does not have to know the layout;
34
+ * both the sampler and the writer fall back to DEFAULT_CAMERA_INTERPOLATION.
35
+ * A parsed file always carries its own. */
36
+ interpolation?: Uint8Array
33
37
  }
34
38
 
39
+ /** Linear in, linear out on all six channels. 20/107 is MMD's own linear pair
40
+ * (its bezier bytes run 0-127), so a keyframe written with this reads back as
41
+ * a straight line in MMD rather than an ease nobody asked for. */
42
+ export const DEFAULT_CAMERA_INTERPOLATION: Uint8Array = (() => {
43
+ const ip = new Uint8Array(24)
44
+ for (let c = 0; c < 6; c++) {
45
+ const b = c * 4
46
+ ip[b] = 20 // x1
47
+ ip[b + 1] = 107 // x2
48
+ ip[b + 2] = 20 // y1
49
+ ip[b + 3] = 107 // y2
50
+ }
51
+ return ip
52
+ })()
53
+
35
54
  /** A VMD "IK/display" record: one moment at which chains are switched. */
36
55
  export interface IkFrame {
37
56
  frame: number
package/src/vmd-writer.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AnimationClip, BoneInterpolation, ControlPoint } from "./animation"
2
+ import { DEFAULT_CAMERA_INTERPOLATION, type CameraKeyframe } from "./vmd-loader"
2
3
 
3
4
  const VMD_HEADER = "Vocaloid Motion Data 0002"
4
5
  const HEADER_SIZE = 30
@@ -9,6 +10,11 @@ const BONE_FRAME_SIZE = BONE_NAME_SIZE + 4 + 12 + 16 + 64 // 111 bytes
9
10
  const MORPH_FRAME_SIZE = MORPH_NAME_SIZE + 4 + 4 // 23 bytes
10
11
  /** IK bone names get 20 bytes in the IK block, not the 15 bones get elsewhere. */
11
12
  const IK_NAME_SIZE = 20
13
+ /** frame + distance + target(3) + rotation(3) + interpolation(24) + fov + perspective. */
14
+ const CAMERA_FRAME_SIZE = 4 + 4 + 12 + 12 + 24 + 4 + 1 // 61 bytes
15
+ /** What MMD stamps in the model-name field of a camera VMD. Tools sniff it to
16
+ * tell a camera file from a motion at a glance, so write what they expect. */
17
+ const CAMERA_MODEL_NAME = "\u30ab\u30e1\u30e9\u30fb\u7167\u660e"
12
18
 
13
19
  // Build a Unicode-to-Shift-JIS lookup by inverting the TextDecoder mapping.
14
20
  let shiftJISTable: Map<string, number[]> | null = null
@@ -47,22 +53,41 @@ function encodeShiftJIS(str: string): Uint8Array {
47
53
  return new Uint8Array(bytes)
48
54
  }
49
55
 
56
+ /** Which half of a clip to write. Mirrors `Model.loadVmd`'s `tracks` option, so
57
+ * a file this writer splits out is one the loader can read straight back:
58
+ *
59
+ * "all" bone + morph (+ IK) — one file, what MMD itself exports
60
+ * "motion" bone (+ IK) only — the dance, no expressions
61
+ * "morphs" morph only — an expression file (\u8868\u60c5\u30e2\u30fc\u30b7\u30e7\u30f3) to lay over a motion
62
+ *
63
+ * IK rides with "motion" rather than "morphs" because it is bone state: which
64
+ * chains solve says nothing about a face. */
65
+ export type VmdTrackSelection = "all" | "motion" | "morphs"
66
+
50
67
  export class VMDWriter {
51
- write(clip: AnimationClip): ArrayBuffer {
68
+ write(clip: AnimationClip, options?: { tracks?: VmdTrackSelection }): ArrayBuffer {
69
+ const tracks = options?.tracks ?? "all"
70
+ const wantBones = tracks !== "morphs"
71
+ const wantMorphs = tracks !== "motion"
72
+
52
73
  let totalBoneFrames = 0
53
- for (const frames of clip.boneTracks.values()) {
54
- totalBoneFrames += frames.length
74
+ if (wantBones) {
75
+ for (const frames of clip.boneTracks.values()) {
76
+ totalBoneFrames += frames.length
77
+ }
55
78
  }
56
79
  let totalMorphFrames = 0
57
- for (const frames of clip.morphTracks.values()) {
58
- totalMorphFrames += frames.length
80
+ if (wantMorphs) {
81
+ for (const frames of clip.morphTracks.values()) {
82
+ totalMorphFrames += frames.length
83
+ }
59
84
  }
60
85
 
61
86
  // IK state is stored per MOMENT, not per bone: one record lists every chain
62
87
  // and its state at that frame. So the tracks are transposed back into the
63
88
  // frames they were flattened from.
64
89
  const ikByFrame = new Map<number, { boneName: string; enabled: boolean }[]>()
65
- for (const [boneName, keys] of clip.ikTracks ?? []) {
90
+ for (const [boneName, keys] of (wantBones ? clip.ikTracks : undefined) ?? []) {
66
91
  for (const key of keys) {
67
92
  const at = ikByFrame.get(key.frame)
68
93
  if (at) at.push({ boneName, enabled: key.enabled })
@@ -98,7 +123,7 @@ export class VMDWriter {
98
123
  offset += 4
99
124
 
100
125
  // Bone frames
101
- for (const frames of clip.boneTracks.values()) {
126
+ for (const frames of wantBones ? clip.boneTracks.values() : []) {
102
127
  for (const kf of frames) {
103
128
  // Bone name (15 bytes, Shift-JIS)
104
129
  offset = writeFixedShiftJIS(buffer, offset, kf.boneName, BONE_NAME_SIZE)
@@ -130,7 +155,7 @@ export class VMDWriter {
130
155
  offset += 4
131
156
 
132
157
  // Morph frames
133
- for (const frames of clip.morphTracks.values()) {
158
+ for (const frames of wantMorphs ? clip.morphTracks.values() : []) {
134
159
  for (const kf of frames) {
135
160
  // Morph name (15 bytes, Shift-JIS)
136
161
  offset = writeFixedShiftJIS(buffer, offset, kf.morphName, MORPH_NAME_SIZE)
@@ -168,8 +193,67 @@ export class VMDWriter {
168
193
 
169
194
  return buffer
170
195
  }
196
+
197
+ /**
198
+ * A camera VMD: the shot's own file, with no model motion in it.
199
+ *
200
+ * Bone and morph counts are written as zero rather than omitted — the camera
201
+ * block sits after them in the format, so a reader walking the file in order
202
+ * (including this package's own parseCamera) has to pass through both to
203
+ * reach it. Light, self-shadow and IK blocks are left off entirely; every
204
+ * reader bounds-checks past the camera block, and MMD is happy with a file
205
+ * that simply ends there.
206
+ *
207
+ * `frames` is sorted by frame on the way out: CameraAnimation binary-searches
208
+ * the track it loads, and an out-of-order file would sample wrong rather than
209
+ * fail loudly.
210
+ */
211
+ writeCamera(frames: CameraKeyframe[]): ArrayBuffer {
212
+ const sorted = [...frames].sort((a, b) => a.frame - b.frame)
213
+ const size = HEADER_SIZE + MODEL_NAME_SIZE + 4 + 4 + 4 + sorted.length * CAMERA_FRAME_SIZE
214
+ const buffer = new ArrayBuffer(size)
215
+ const view = new DataView(buffer)
216
+ let offset = 0
217
+
218
+ offset = writeFixedString(buffer, offset, VMD_HEADER, HEADER_SIZE)
219
+ offset = writeFixedShiftJIS(buffer, offset, CAMERA_MODEL_NAME, MODEL_NAME_SIZE)
220
+
221
+ view.setUint32(offset, 0, true) // bone frame count
222
+ offset += 4
223
+ view.setUint32(offset, 0, true) // morph frame count
224
+ offset += 4
225
+ view.setUint32(offset, sorted.length, true)
226
+ offset += 4
227
+
228
+ for (const kf of sorted) {
229
+ view.setUint32(offset, kf.frame, true); offset += 4
230
+ view.setFloat32(offset, kf.distance, true); offset += 4
231
+ view.setFloat32(offset, kf.target.x, true); offset += 4
232
+ view.setFloat32(offset, kf.target.y, true); offset += 4
233
+ view.setFloat32(offset, kf.target.z, true); offset += 4
234
+ // Euler radians, as the loader reads them.
235
+ view.setFloat32(offset, kf.rotation.x, true); offset += 4
236
+ view.setFloat32(offset, kf.rotation.y, true); offset += 4
237
+ view.setFloat32(offset, kf.rotation.z, true); offset += 4
238
+ // 24 bytes, contiguous per channel — see camera-animation.ts's `bez`.
239
+ // Short or missing tables are padded with a linear default rather than
240
+ // writing junk: a hand-built keyframe should not have to know the layout.
241
+ const ip = new Uint8Array(24)
242
+ ip.set(DEFAULT_CAMERA_INTERPOLATION)
243
+ if (kf.interpolation) ip.set(kf.interpolation.subarray(0, 24))
244
+ new Uint8Array(buffer, offset, 24).set(ip)
245
+ offset += 24
246
+ // fov is degrees, and an integer in the file — MMD's own field is u32.
247
+ view.setUint32(offset, Math.max(0, Math.round(kf.fov)), true); offset += 4
248
+ view.setUint8(offset, 0) // 0 = perspective
249
+ offset += 1
250
+ }
251
+
252
+ return buffer
253
+ }
171
254
  }
172
255
 
256
+
173
257
  function writeFixedString(buffer: ArrayBuffer, offset: number, str: string, maxBytes: number): number {
174
258
  const bytes = new Uint8Array(buffer, offset, maxBytes)
175
259
  bytes.fill(0)