reze-engine 0.6.6 → 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;
@@ -525,11 +528,78 @@ export class Model {
525
528
  * Load VMD animation file
526
529
  */
527
530
  async loadVmd(vmdUrl) {
528
- 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;
529
563
  this.resetAllBones();
530
564
  this.resetAllMorphs();
531
- this.processFrames();
532
- // 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;
533
603
  this.animationTime = 0;
534
604
  this.getPoseAtTime(0);
535
605
  if (this.physics) {
@@ -573,57 +643,8 @@ export class Model {
573
643
  setPhysicsEnabled(enabled) {
574
644
  this.physicsEnabled = enabled;
575
645
  }
576
- /**
577
- * Process frames into tracks
578
- */
579
- processFrames() {
580
- if (!this.animationData)
581
- return;
582
- // Helper to group frames by name and sort by time
583
- const groupFrames = (items) => {
584
- const tracks = new Map();
585
- for (const { item, name, time } of items) {
586
- if (!tracks.has(name))
587
- tracks.set(name, []);
588
- tracks.get(name).push({ item, time });
589
- }
590
- for (const keyFrames of tracks.values()) {
591
- keyFrames.sort((a, b) => a.time - b.time);
592
- }
593
- return tracks;
594
- };
595
- // Collect all bone and morph frames
596
- const boneItems = [];
597
- const morphItems = [];
598
- for (const keyFrame of this.animationData) {
599
- for (const boneFrame of keyFrame.boneFrames) {
600
- boneItems.push({ item: boneFrame, name: boneFrame.boneName, time: keyFrame.time });
601
- }
602
- for (const morphFrame of keyFrame.morphFrames) {
603
- morphItems.push({ item: morphFrame, name: morphFrame.morphName, time: keyFrame.time });
604
- }
605
- }
606
- // Transform to expected format
607
- this.boneTracks = new Map();
608
- for (const [name, frames] of groupFrames(boneItems).entries()) {
609
- this.boneTracks.set(name, frames.map((f) => ({ boneFrame: f.item, time: f.time })));
610
- }
611
- this.morphTracks = new Map();
612
- for (const [name, frames] of groupFrames(morphItems).entries()) {
613
- this.morphTracks.set(name, frames.map((f) => ({ morphFrame: f.item, time: f.time })));
614
- }
615
- // Reset cached indices when tracks change
616
- this.boneTrackIndices.clear();
617
- this.morphTrackIndices.clear();
618
- // Calculate duration from all tracks
619
- const allTracks = [...this.boneTracks.values(), ...this.morphTracks.values()];
620
- this.animationDuration = allTracks.reduce((max, keyFrames) => {
621
- const lastTime = keyFrames[keyFrames.length - 1]?.time ?? 0;
622
- return Math.max(max, lastTime);
623
- }, 0);
624
- }
625
646
  playAnimation() {
626
- if (!this.animationData)
647
+ if (!this._hasAnimation)
627
648
  return;
628
649
  this.isPaused = false;
629
650
  this.isPlaying = true;
@@ -643,7 +664,7 @@ export class Model {
643
664
  this.animationTime = 0;
644
665
  }
645
666
  seekAnimation(time) {
646
- if (!this.animationData)
667
+ if (!this._hasAnimation)
647
668
  return;
648
669
  const clampedTime = Math.max(0, Math.min(time, this.animationDuration));
649
670
  this.animationTime = clampedTime;
@@ -651,6 +672,9 @@ export class Model {
651
672
  /**
652
673
  * Get current animation progress
653
674
  */
675
+ getAnimationData() {
676
+ return this._animationData;
677
+ }
654
678
  getAnimationProgress() {
655
679
  const duration = this.animationDuration;
656
680
  const percentage = duration > 0 ? (this.animationTime / duration) * 100 : 0;
@@ -699,9 +723,8 @@ export class Model {
699
723
  * Optimized for per-frame performance
700
724
  */
701
725
  getPoseAtTime(time) {
702
- if (!this.animationData)
726
+ if (!this._hasAnimation)
703
727
  return;
704
- const INV_127 = 1 / 127; // Pre-compute division constant
705
728
  // Process bone tracks
706
729
  for (const [boneName, keyFrames] of this.boneTracks.entries()) {
707
730
  if (keyFrames.length === 0)
@@ -711,48 +734,31 @@ export class Model {
711
734
  const idx = this.findKeyframeIndex(clampedTime, keyFrames, cachedIdx);
712
735
  if (idx < 0)
713
736
  continue;
714
- // Update cache
715
737
  this.boneTrackIndices.set(boneName, idx);
716
- const frameA = keyFrames[idx].boneFrame;
717
- const frameB = keyFrames[idx + 1]?.boneFrame;
738
+ const frameA = keyFrames[idx];
739
+ const frameB = keyFrames[idx + 1];
718
740
  const boneIdx = this.runtimeSkeleton.nameIndex[boneName];
719
741
  if (boneIdx === undefined)
720
742
  continue;
721
743
  const localRot = this.runtimeSkeleton.localRotations[boneIdx];
722
744
  const localTrans = this.runtimeSkeleton.localTranslations[boneIdx];
723
745
  if (!frameB) {
724
- // No interpolation needed - direct assignment
725
- // Use animation frame's rotation for translation conversion to ensure consistency
726
- // This prevents conflicts when IK later modifies the rotation
727
746
  const frameRotation = frameA.rotation;
728
747
  localRot.set(frameRotation);
729
- // Convert VMD relative translation to local translation using animation rotation
730
748
  const localTranslation = this.convertVMDTranslationToLocal(boneIdx, frameA.translation, frameRotation);
731
749
  localTrans.set(localTranslation);
732
750
  }
733
751
  else {
734
- const timeA = keyFrames[idx].time;
735
- const timeB = keyFrames[idx + 1].time;
736
- const timeDelta = timeB - timeA;
737
- const gradient = (clampedTime - timeA) / timeDelta;
752
+ const timeDelta = frameB.time - frameA.time;
753
+ const gradient = (clampedTime - frameA.time) / timeDelta;
738
754
  const interp = frameB.interpolation;
739
- // Pre-normalize interpolation values (avoid division in bezierInterpolate)
740
- const rotT = bezierInterpolate(interp[0] * INV_127, interp[1] * INV_127, interp[2] * INV_127, interp[3] * INV_127, gradient);
741
- // Use Quat.slerp to interpolate rotation
755
+ const rotT = interpolateControlPoints(interp.rotation, gradient);
742
756
  const rotation = Quat.slerp(frameA.rotation, frameB.rotation, rotT);
743
- // Interpolate VMD translation using bezier for each component
744
- // Inline getWeight to avoid function call overhead
745
- const getWeight = (offset) => bezierInterpolate(interp[offset] * INV_127, interp[offset + 8] * INV_127, interp[offset + 4] * INV_127, interp[offset + 12] * INV_127, gradient);
746
- const txWeight = getWeight(0);
747
- const tyWeight = getWeight(16);
748
- const tzWeight = getWeight(32);
749
- // 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);
750
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);
751
- // Convert interpolated VMD translation to local translation using animation rotation
752
- // This ensures translation is computed for the animation rotation, not the runtime rotation
753
- // that will be modified by IK, preventing conflicts
754
761
  const localTranslation = this.convertVMDTranslationToLocal(boneIdx, interpolatedVMDTranslation, rotation);
755
- // Direct property writes to avoid object allocation
756
762
  localRot.set(rotation);
757
763
  localTrans.set(localTranslation);
758
764
  }
@@ -766,10 +772,9 @@ export class Model {
766
772
  const idx = this.findKeyframeIndex(clampedTime, keyFrames, cachedIdx);
767
773
  if (idx < 0)
768
774
  continue;
769
- // Update cache
770
775
  this.morphTrackIndices.set(morphName, idx);
771
- const frameA = keyFrames[idx].morphFrame;
772
- const frameB = keyFrames[idx + 1]?.morphFrame;
776
+ const frameA = keyFrames[idx];
777
+ const frameB = keyFrames[idx + 1];
773
778
  const morphIdx = this.runtimeMorph.nameIndex[morphName];
774
779
  if (morphIdx === undefined)
775
780
  continue;
@@ -794,7 +799,7 @@ export class Model {
794
799
  // Update all active tweens (rotations, translations, morphs)
795
800
  const tweensChangedMorphs = this.updateTweens();
796
801
  // Apply animation if playing or paused (always apply pose if animation data exists and we have a time set)
797
- if (this.animationData) {
802
+ if (this._hasAnimation) {
798
803
  if (this.isPlaying && !this.isPaused) {
799
804
  this.animationTime += deltaTime;
800
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.6",
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,6 +1164,7 @@ export class Engine {
1155
1164
  this.currentModel?.moveBones(boneTranslations, durationMs)
1156
1165
  }
1157
1166
 
1167
+
1158
1168
  public resetAllBones() {
1159
1169
  this.currentModel?.resetAllBones()
1160
1170
  }
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
- }