reze-engine 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/model.js CHANGED
@@ -1,7 +1,9 @@
1
- import { Mat4, Quat, Vec3, bezierInterpolate } from "./math";
1
+ import { Mat4, Quat, Vec3 } from "./math";
2
2
  import { Physics } from "./physics";
3
3
  import { IKSolverSystem } from "./ik-solver";
4
4
  import { VMDLoader } from "./vmd-loader";
5
+ import { interpolateControlPoints, rawInterpolationToBoneInterpolation } from "./animation";
6
+ const VMD_FPS = 30;
5
7
  const VERTEX_STRIDE = 8;
6
8
  export class Model {
7
9
  constructor(vertexData, indexData, textures, materials, skeleton, skinning, morphing, rigidbodies = [], joints = []) {
@@ -16,7 +18,8 @@ export class Model {
16
18
  this.cachedIdentityMat2 = Mat4.identity();
17
19
  this.tweenTimeMs = 0; // Time tracking for tweens (milliseconds)
18
20
  // Animation runtime
19
- this.animationData = null;
21
+ this._hasAnimation = false;
22
+ this._animationData = null;
20
23
  this.boneTracks = new Map();
21
24
  this.morphTracks = new Map();
22
25
  this.animationDuration = 0;
@@ -464,49 +467,6 @@ export class Model {
464
467
  this.runtimeMorph.weights[idx] = startWeight;
465
468
  this.applyMorphs();
466
469
  }
467
- /**
468
- * Atomic pose setter for external animation editors.
469
- * Sets bone rotations, translations, and morph weights in a single pass,
470
- * identical to how getPoseAtTime applies VMD poses during playback.
471
- * Cancels any active tweens on affected bones/morphs.
472
- */
473
- setPose(rotations, translations, morphs) {
474
- const state = this.tweenState;
475
- if (rotations) {
476
- for (const [name, quat] of Object.entries(rotations)) {
477
- const idx = this.runtimeSkeleton.nameIndex[name] ?? -1;
478
- if (idx < 0 || idx >= this.skeleton.bones.length)
479
- continue;
480
- this.runtimeSkeleton.localRotations[idx].set(quat.clone().normalize());
481
- state.rotActive[idx] = 0;
482
- }
483
- }
484
- if (translations) {
485
- for (const [name, vec] of Object.entries(translations)) {
486
- const idx = this.runtimeSkeleton.nameIndex[name] ?? -1;
487
- if (idx < 0 || idx >= this.skeleton.bones.length)
488
- continue;
489
- const rotation = rotations?.[name]?.clone().normalize();
490
- const localTranslation = this.convertVMDTranslationToLocal(idx, vec, rotation);
491
- this.runtimeSkeleton.localTranslations[idx].set(localTranslation);
492
- state.transActive[idx] = 0;
493
- }
494
- }
495
- if (morphs) {
496
- let morphChanged = false;
497
- for (const [name, weight] of Object.entries(morphs)) {
498
- const idx = this.runtimeMorph.nameIndex[name] ?? -1;
499
- if (idx < 0 || idx >= this.runtimeMorph.weights.length)
500
- continue;
501
- this.runtimeMorph.weights[idx] = Math.max(0, Math.min(1, weight));
502
- state.morphActive[idx] = 0;
503
- morphChanged = true;
504
- }
505
- if (morphChanged) {
506
- this.applyMorphs();
507
- }
508
- }
509
- }
510
470
  applyMorphs() {
511
471
  // Reset vertex data to base positions
512
472
  this.vertexData.set(this.baseVertexData);
@@ -568,11 +528,78 @@ export class Model {
568
528
  * Load VMD animation file
569
529
  */
570
530
  async loadVmd(vmdUrl) {
571
- this.animationData = await VMDLoader.load(vmdUrl);
531
+ const vmdKeyFrames = await VMDLoader.load(vmdUrl);
532
+ // Convert VMDKeyFrame[] to AnimationData
533
+ const boneTracks = {};
534
+ const morphTracks = {};
535
+ for (const keyFrame of vmdKeyFrames) {
536
+ for (const bf of keyFrame.boneFrames) {
537
+ if (!boneTracks[bf.boneName])
538
+ boneTracks[bf.boneName] = [];
539
+ boneTracks[bf.boneName].push({
540
+ frame: bf.frame,
541
+ rotation: bf.rotation,
542
+ translation: bf.translation,
543
+ interpolation: rawInterpolationToBoneInterpolation(bf.interpolation),
544
+ });
545
+ }
546
+ for (const mf of keyFrame.morphFrames) {
547
+ if (!morphTracks[mf.morphName])
548
+ morphTracks[mf.morphName] = [];
549
+ morphTracks[mf.morphName].push({
550
+ frame: mf.frame,
551
+ weight: mf.weight,
552
+ });
553
+ }
554
+ }
555
+ this.loadAnimationData({ boneTracks, morphTracks });
556
+ }
557
+ /**
558
+ * Load animation from structured keyframe data.
559
+ * This is the primary way to set animation data — loadVmd delegates to this.
560
+ */
561
+ loadAnimationData(data) {
562
+ this._animationData = data;
572
563
  this.resetAllBones();
573
564
  this.resetAllMorphs();
574
- this.processFrames();
575
- // Apply initial pose at time 0
565
+ this.boneTracks = new Map();
566
+ for (const name in data.boneTracks) {
567
+ const keyframes = data.boneTracks[name];
568
+ const sorted = [...keyframes].sort((a, b) => a.frame - b.frame);
569
+ this.boneTracks.set(name, sorted.map((kf) => ({
570
+ boneName: name,
571
+ frame: kf.frame,
572
+ rotation: kf.rotation,
573
+ translation: kf.translation,
574
+ interpolation: kf.interpolation,
575
+ time: kf.frame / VMD_FPS,
576
+ })));
577
+ }
578
+ this.morphTracks = new Map();
579
+ for (const name in data.morphTracks) {
580
+ const keyframes = data.morphTracks[name];
581
+ const sorted = [...keyframes].sort((a, b) => a.frame - b.frame);
582
+ this.morphTracks.set(name, sorted.map((kf) => ({
583
+ morphName: name,
584
+ frame: kf.frame,
585
+ weight: kf.weight,
586
+ time: kf.frame / VMD_FPS,
587
+ })));
588
+ }
589
+ this.boneTrackIndices.clear();
590
+ this.morphTrackIndices.clear();
591
+ // Calculate duration
592
+ let maxTime = 0;
593
+ for (const frames of this.boneTracks.values()) {
594
+ if (frames.length > 0)
595
+ maxTime = Math.max(maxTime, frames[frames.length - 1].time);
596
+ }
597
+ for (const frames of this.morphTracks.values()) {
598
+ if (frames.length > 0)
599
+ maxTime = Math.max(maxTime, frames[frames.length - 1].time);
600
+ }
601
+ this.animationDuration = maxTime;
602
+ this._hasAnimation = true;
576
603
  this.animationTime = 0;
577
604
  this.getPoseAtTime(0);
578
605
  if (this.physics) {
@@ -616,57 +643,8 @@ export class Model {
616
643
  setPhysicsEnabled(enabled) {
617
644
  this.physicsEnabled = enabled;
618
645
  }
619
- /**
620
- * Process frames into tracks
621
- */
622
- processFrames() {
623
- if (!this.animationData)
624
- return;
625
- // Helper to group frames by name and sort by time
626
- const groupFrames = (items) => {
627
- const tracks = new Map();
628
- for (const { item, name, time } of items) {
629
- if (!tracks.has(name))
630
- tracks.set(name, []);
631
- tracks.get(name).push({ item, time });
632
- }
633
- for (const keyFrames of tracks.values()) {
634
- keyFrames.sort((a, b) => a.time - b.time);
635
- }
636
- return tracks;
637
- };
638
- // Collect all bone and morph frames
639
- const boneItems = [];
640
- const morphItems = [];
641
- for (const keyFrame of this.animationData) {
642
- for (const boneFrame of keyFrame.boneFrames) {
643
- boneItems.push({ item: boneFrame, name: boneFrame.boneName, time: keyFrame.time });
644
- }
645
- for (const morphFrame of keyFrame.morphFrames) {
646
- morphItems.push({ item: morphFrame, name: morphFrame.morphName, time: keyFrame.time });
647
- }
648
- }
649
- // Transform to expected format
650
- this.boneTracks = new Map();
651
- for (const [name, frames] of groupFrames(boneItems).entries()) {
652
- this.boneTracks.set(name, frames.map((f) => ({ boneFrame: f.item, time: f.time })));
653
- }
654
- this.morphTracks = new Map();
655
- for (const [name, frames] of groupFrames(morphItems).entries()) {
656
- this.morphTracks.set(name, frames.map((f) => ({ morphFrame: f.item, time: f.time })));
657
- }
658
- // Reset cached indices when tracks change
659
- this.boneTrackIndices.clear();
660
- this.morphTrackIndices.clear();
661
- // Calculate duration from all tracks
662
- const allTracks = [...this.boneTracks.values(), ...this.morphTracks.values()];
663
- this.animationDuration = allTracks.reduce((max, keyFrames) => {
664
- const lastTime = keyFrames[keyFrames.length - 1]?.time ?? 0;
665
- return Math.max(max, lastTime);
666
- }, 0);
667
- }
668
646
  playAnimation() {
669
- if (!this.animationData)
647
+ if (!this._hasAnimation)
670
648
  return;
671
649
  this.isPaused = false;
672
650
  this.isPlaying = true;
@@ -686,7 +664,7 @@ export class Model {
686
664
  this.animationTime = 0;
687
665
  }
688
666
  seekAnimation(time) {
689
- if (!this.animationData)
667
+ if (!this._hasAnimation)
690
668
  return;
691
669
  const clampedTime = Math.max(0, Math.min(time, this.animationDuration));
692
670
  this.animationTime = clampedTime;
@@ -694,6 +672,9 @@ export class Model {
694
672
  /**
695
673
  * Get current animation progress
696
674
  */
675
+ getAnimationData() {
676
+ return this._animationData;
677
+ }
697
678
  getAnimationProgress() {
698
679
  const duration = this.animationDuration;
699
680
  const percentage = duration > 0 ? (this.animationTime / duration) * 100 : 0;
@@ -742,9 +723,8 @@ export class Model {
742
723
  * Optimized for per-frame performance
743
724
  */
744
725
  getPoseAtTime(time) {
745
- if (!this.animationData)
726
+ if (!this._hasAnimation)
746
727
  return;
747
- const INV_127 = 1 / 127; // Pre-compute division constant
748
728
  // Process bone tracks
749
729
  for (const [boneName, keyFrames] of this.boneTracks.entries()) {
750
730
  if (keyFrames.length === 0)
@@ -754,48 +734,31 @@ export class Model {
754
734
  const idx = this.findKeyframeIndex(clampedTime, keyFrames, cachedIdx);
755
735
  if (idx < 0)
756
736
  continue;
757
- // Update cache
758
737
  this.boneTrackIndices.set(boneName, idx);
759
- const frameA = keyFrames[idx].boneFrame;
760
- const frameB = keyFrames[idx + 1]?.boneFrame;
738
+ const frameA = keyFrames[idx];
739
+ const frameB = keyFrames[idx + 1];
761
740
  const boneIdx = this.runtimeSkeleton.nameIndex[boneName];
762
741
  if (boneIdx === undefined)
763
742
  continue;
764
743
  const localRot = this.runtimeSkeleton.localRotations[boneIdx];
765
744
  const localTrans = this.runtimeSkeleton.localTranslations[boneIdx];
766
745
  if (!frameB) {
767
- // No interpolation needed - direct assignment
768
- // Use animation frame's rotation for translation conversion to ensure consistency
769
- // This prevents conflicts when IK later modifies the rotation
770
746
  const frameRotation = frameA.rotation;
771
747
  localRot.set(frameRotation);
772
- // Convert VMD relative translation to local translation using animation rotation
773
748
  const localTranslation = this.convertVMDTranslationToLocal(boneIdx, frameA.translation, frameRotation);
774
749
  localTrans.set(localTranslation);
775
750
  }
776
751
  else {
777
- const timeA = keyFrames[idx].time;
778
- const timeB = keyFrames[idx + 1].time;
779
- const timeDelta = timeB - timeA;
780
- const gradient = (clampedTime - timeA) / timeDelta;
752
+ const timeDelta = frameB.time - frameA.time;
753
+ const gradient = (clampedTime - frameA.time) / timeDelta;
781
754
  const interp = frameB.interpolation;
782
- // Pre-normalize interpolation values (avoid division in bezierInterpolate)
783
- const rotT = bezierInterpolate(interp[0] * INV_127, interp[1] * INV_127, interp[2] * INV_127, interp[3] * INV_127, gradient);
784
- // Use Quat.slerp to interpolate rotation
755
+ const rotT = interpolateControlPoints(interp.rotation, gradient);
785
756
  const rotation = Quat.slerp(frameA.rotation, frameB.rotation, rotT);
786
- // Interpolate VMD translation using bezier for each component
787
- // Inline getWeight to avoid function call overhead
788
- const getWeight = (offset) => bezierInterpolate(interp[offset] * INV_127, interp[offset + 8] * INV_127, interp[offset + 4] * INV_127, interp[offset + 12] * INV_127, gradient);
789
- const txWeight = getWeight(0);
790
- const tyWeight = getWeight(16);
791
- const tzWeight = getWeight(32);
792
- // Interpolate VMD relative translations (relative to bind pose world position)
757
+ const txWeight = interpolateControlPoints(interp.translationX, gradient);
758
+ const tyWeight = interpolateControlPoints(interp.translationY, gradient);
759
+ const tzWeight = interpolateControlPoints(interp.translationZ, gradient);
793
760
  const interpolatedVMDTranslation = new Vec3(frameA.translation.x + (frameB.translation.x - frameA.translation.x) * txWeight, frameA.translation.y + (frameB.translation.y - frameA.translation.y) * tyWeight, frameA.translation.z + (frameB.translation.z - frameA.translation.z) * tzWeight);
794
- // Convert interpolated VMD translation to local translation using animation rotation
795
- // This ensures translation is computed for the animation rotation, not the runtime rotation
796
- // that will be modified by IK, preventing conflicts
797
761
  const localTranslation = this.convertVMDTranslationToLocal(boneIdx, interpolatedVMDTranslation, rotation);
798
- // Direct property writes to avoid object allocation
799
762
  localRot.set(rotation);
800
763
  localTrans.set(localTranslation);
801
764
  }
@@ -809,10 +772,9 @@ export class Model {
809
772
  const idx = this.findKeyframeIndex(clampedTime, keyFrames, cachedIdx);
810
773
  if (idx < 0)
811
774
  continue;
812
- // Update cache
813
775
  this.morphTrackIndices.set(morphName, idx);
814
- const frameA = keyFrames[idx].morphFrame;
815
- const frameB = keyFrames[idx + 1]?.morphFrame;
776
+ const frameA = keyFrames[idx];
777
+ const frameB = keyFrames[idx + 1];
816
778
  const morphIdx = this.runtimeMorph.nameIndex[morphName];
817
779
  if (morphIdx === undefined)
818
780
  continue;
@@ -837,7 +799,7 @@ export class Model {
837
799
  // Update all active tweens (rotations, translations, morphs)
838
800
  const tweensChangedMorphs = this.updateTweens();
839
801
  // Apply animation if playing or paused (always apply pose if animation data exists and we have a time set)
840
- if (this.animationData) {
802
+ if (this._hasAnimation) {
841
803
  if (this.isPlaying && !this.isPaused) {
842
804
  this.animationTime += deltaTime;
843
805
  if (this.animationTime >= this.animationDuration) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.6.7",
3
+ "version": "0.7.0",
4
4
  "description": "A WebGPU-based MMD model renderer",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -0,0 +1,109 @@
1
+ import { Quat, Vec3 } from "./math"
2
+
3
+ export interface ControlPoint {
4
+ x: number
5
+ y: number
6
+ }
7
+
8
+ export interface BoneInterpolation {
9
+ rotation: ControlPoint[]
10
+ translationX: ControlPoint[]
11
+ translationY: ControlPoint[]
12
+ translationZ: ControlPoint[]
13
+ }
14
+
15
+ export const LINEAR_INTERPOLATION: BoneInterpolation = {
16
+ rotation: [{ x: 20, y: 20 }, { x: 107, y: 107 }],
17
+ translationX: [{ x: 20, y: 20 }, { x: 107, y: 107 }],
18
+ translationY: [{ x: 20, y: 20 }, { x: 107, y: 107 }],
19
+ translationZ: [{ x: 20, y: 20 }, { x: 107, y: 107 }],
20
+ }
21
+
22
+ export interface BoneKeyframe {
23
+ frame: number
24
+ rotation: Quat
25
+ translation: Vec3
26
+ interpolation: BoneInterpolation
27
+ }
28
+
29
+ export interface MorphKeyframe {
30
+ frame: number
31
+ weight: number
32
+ }
33
+
34
+ export interface AnimationData {
35
+ boneTracks: Record<string, BoneKeyframe[]>
36
+ morphTracks: Record<string, MorphKeyframe[]>
37
+ }
38
+
39
+ /**
40
+ * Cubic bezier interpolation using binary search.
41
+ * Control points define the curve shape in 0-1 normalized space.
42
+ */
43
+ export function bezierInterpolate(x1: number, x2: number, y1: number, y2: number, t: number): number {
44
+ t = Math.max(0, Math.min(1, t))
45
+
46
+ let start = 0
47
+ let end = 1
48
+ let mid = 0.5
49
+
50
+ for (let i = 0; i < 15; i++) {
51
+ const x = 3 * (1 - mid) * (1 - mid) * mid * x1 + 3 * (1 - mid) * mid * mid * x2 + mid * mid * mid
52
+
53
+ if (Math.abs(x - t) < 0.0001) {
54
+ break
55
+ }
56
+
57
+ if (x < t) {
58
+ start = mid
59
+ } else {
60
+ end = mid
61
+ }
62
+
63
+ mid = (start + end) / 2
64
+ }
65
+
66
+ const y = 3 * (1 - mid) * (1 - mid) * mid * y1 + 3 * (1 - mid) * mid * mid * y2 + mid * mid * mid
67
+
68
+ return y
69
+ }
70
+
71
+ const INV_127 = 1 / 127
72
+
73
+ /**
74
+ * Convert raw VMD interpolation bytes (64-byte Uint8Array) to structured BoneInterpolation.
75
+ */
76
+ export function rawInterpolationToBoneInterpolation(raw: Uint8Array): BoneInterpolation {
77
+ return {
78
+ rotation: [
79
+ { x: raw[0], y: raw[2] },
80
+ { x: raw[1], y: raw[3] },
81
+ ],
82
+ translationX: [
83
+ { x: raw[0], y: raw[4] },
84
+ { x: raw[8], y: raw[12] },
85
+ ],
86
+ translationY: [
87
+ { x: raw[16], y: raw[20] },
88
+ { x: raw[24], y: raw[28] },
89
+ ],
90
+ translationZ: [
91
+ { x: raw[32], y: raw[36] },
92
+ { x: raw[40], y: raw[44] },
93
+ ],
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Compute bezier-interpolated weight for a pair of control points.
99
+ * Control point values are in 0-127 range.
100
+ */
101
+ export function interpolateControlPoints(cp: ControlPoint[], t: number): number {
102
+ return bezierInterpolate(
103
+ cp[0].x * INV_127,
104
+ cp[1].x * INV_127,
105
+ cp[0].y * INV_127,
106
+ cp[1].y * INV_127,
107
+ t
108
+ )
109
+ }
package/src/engine.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Camera } from "./camera"
2
2
  import { Mat4, Quat, Vec3 } from "./math"
3
3
  import { Model } from "./model"
4
+ import type { AnimationData } from "./animation"
4
5
  import { PmxLoader } from "./pmx-loader"
5
6
 
6
7
  export type RaycastCallback = (material: string | null, screenX: number, screenY: number) => void
@@ -1065,6 +1066,14 @@ export class Engine {
1065
1066
  await this.currentModel.loadVmd(url)
1066
1067
  }
1067
1068
 
1069
+ public loadAnimationData(data: AnimationData) {
1070
+ this.currentModel?.loadAnimationData(data)
1071
+ }
1072
+
1073
+ public getAnimationData(): AnimationData | null {
1074
+ return this.currentModel?.getAnimationData() ?? null
1075
+ }
1076
+
1068
1077
  public playAnimation() {
1069
1078
  this.currentModel?.playAnimation()
1070
1079
  }
@@ -1155,13 +1164,6 @@ export class Engine {
1155
1164
  this.currentModel?.moveBones(boneTranslations, durationMs)
1156
1165
  }
1157
1166
 
1158
- public setPose(
1159
- rotations?: Record<string, Quat>,
1160
- translations?: Record<string, Vec3>,
1161
- morphs?: Record<string, number>
1162
- ): void {
1163
- this.currentModel?.setPose(rotations, translations, morphs)
1164
- }
1165
1167
 
1166
1168
  public resetAllBones() {
1167
1169
  this.currentModel?.resetAllBones()
package/src/index.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export { Engine, type EngineStats } from "./engine"
2
2
  export { Vec3, Quat, Mat4 } from "./math"
3
+ export type { AnimationData, BoneKeyframe, MorphKeyframe, BoneInterpolation, ControlPoint } from "./animation"
4
+ export { bezierInterpolate, interpolateControlPoints, rawInterpolationToBoneInterpolation, LINEAR_INTERPOLATION } from "./animation"
package/src/math.ts CHANGED
@@ -540,45 +540,3 @@ export class Mat4 {
540
540
  }
541
541
  }
542
542
 
543
- /**
544
- * Bezier interpolation function
545
- * @param x1 First control point X (0-127, normalized to 0-1)
546
- * @param x2 Second control point X (0-127, normalized to 0-1)
547
- * @param y1 First control point Y (0-127, normalized to 0-1)
548
- * @param y2 Second control point Y (0-127, normalized to 0-1)
549
- * @param t Interpolation parameter (0-1)
550
- * @returns Interpolated value (0-1)
551
- */
552
- export function bezierInterpolate(x1: number, x2: number, y1: number, y2: number, t: number): number {
553
- // Clamp t to [0, 1]
554
- t = Math.max(0, Math.min(1, t))
555
-
556
- // Binary search for the t value that gives us the desired x
557
- // We're solving for t in the Bezier curve: x(t) = 3*(1-t)^2*t*x1 + 3*(1-t)*t^2*x2 + t^3
558
- let start = 0
559
- let end = 1
560
- let mid = 0.5
561
-
562
- // Iterate until we find the t value that gives us the desired x
563
- for (let i = 0; i < 15; i++) {
564
- // Evaluate Bezier curve at mid point
565
- const x = 3 * (1 - mid) * (1 - mid) * mid * x1 + 3 * (1 - mid) * mid * mid * x2 + mid * mid * mid
566
-
567
- if (Math.abs(x - t) < 0.0001) {
568
- break
569
- }
570
-
571
- if (x < t) {
572
- start = mid
573
- } else {
574
- end = mid
575
- }
576
-
577
- mid = (start + end) / 2
578
- }
579
-
580
- // Now evaluate the y value at this t
581
- const y = 3 * (1 - mid) * (1 - mid) * mid * y1 + 3 * (1 - mid) * mid * mid * y2 + mid * mid * mid
582
-
583
- return y
584
- }