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/src/model.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { Mat4, Quat, Vec3, bezierInterpolate } from "./math"
1
+ import { Mat4, Quat, Vec3 } from "./math"
2
2
  import { Rigidbody, Joint, Physics } from "./physics"
3
3
  import { IKSolverSystem } from "./ik-solver"
4
- import { VMDKeyFrame, VMDLoader, BoneFrame, MorphFrame } from "./vmd-loader"
4
+ import { VMDLoader } from "./vmd-loader"
5
+ import { AnimationData, BoneKeyframe, MorphKeyframe, BoneInterpolation, interpolateControlPoints, rawInterpolationToBoneInterpolation } from "./animation"
5
6
 
7
+ const VMD_FPS = 30
6
8
  const VERTEX_STRIDE = 8
7
9
 
8
10
  export interface Texture {
@@ -181,9 +183,10 @@ export class Model {
181
183
  private tweenTimeMs: number = 0 // Time tracking for tweens (milliseconds)
182
184
 
183
185
  // Animation runtime
184
- private animationData: VMDKeyFrame[] | null = null
185
- private boneTracks: Map<string, Array<{ boneFrame: BoneFrame; time: number }>> = new Map()
186
- private morphTracks: Map<string, Array<{ morphFrame: MorphFrame; time: number }>> = new Map()
186
+ private _hasAnimation: boolean = false
187
+ private _animationData: AnimationData | null = null
188
+ private boneTracks: Map<string, Array<{ boneName: string; frame: number; rotation: Quat; translation: Vec3; interpolation: BoneInterpolation; time: number }>> = new Map()
189
+ private morphTracks: Map<string, Array<{ morphName: string; frame: number; weight: number; time: number }>> = new Map()
187
190
  private animationDuration: number = 0
188
191
  private isPlaying: boolean = false
189
192
  private isPaused: boolean = false
@@ -723,57 +726,6 @@ export class Model {
723
726
  this.applyMorphs()
724
727
  }
725
728
 
726
- /**
727
- * Atomic pose setter for external animation editors.
728
- * Sets bone rotations, translations, and morph weights in a single pass,
729
- * identical to how getPoseAtTime applies VMD poses during playback.
730
- * Cancels any active tweens on affected bones/morphs.
731
- */
732
- setPose(
733
- rotations?: Record<string, Quat>,
734
- translations?: Record<string, Vec3>,
735
- morphs?: Record<string, number>
736
- ): void {
737
- const state = this.tweenState
738
-
739
- if (rotations) {
740
- for (const [name, quat] of Object.entries(rotations)) {
741
- const idx = this.runtimeSkeleton.nameIndex[name] ?? -1
742
- if (idx < 0 || idx >= this.skeleton.bones.length) continue
743
-
744
- this.runtimeSkeleton.localRotations[idx].set(quat.clone().normalize())
745
- state.rotActive[idx] = 0
746
- }
747
- }
748
-
749
- if (translations) {
750
- for (const [name, vec] of Object.entries(translations)) {
751
- const idx = this.runtimeSkeleton.nameIndex[name] ?? -1
752
- if (idx < 0 || idx >= this.skeleton.bones.length) continue
753
-
754
- const rotation = rotations?.[name]?.clone().normalize()
755
- const localTranslation = this.convertVMDTranslationToLocal(idx, vec, rotation)
756
- this.runtimeSkeleton.localTranslations[idx].set(localTranslation)
757
- state.transActive[idx] = 0
758
- }
759
- }
760
-
761
- if (morphs) {
762
- let morphChanged = false
763
- for (const [name, weight] of Object.entries(morphs)) {
764
- const idx = this.runtimeMorph.nameIndex[name] ?? -1
765
- if (idx < 0 || idx >= this.runtimeMorph.weights.length) continue
766
-
767
- this.runtimeMorph.weights[idx] = Math.max(0, Math.min(1, weight))
768
- state.morphActive[idx] = 0
769
- morphChanged = true
770
- }
771
- if (morphChanged) {
772
- this.applyMorphs()
773
- }
774
- }
775
- }
776
-
777
729
  private applyMorphs(): void {
778
730
  // Reset vertex data to base positions
779
731
  this.vertexData.set(this.baseVertexData)
@@ -843,11 +795,89 @@ export class Model {
843
795
  * Load VMD animation file
844
796
  */
845
797
  async loadVmd(vmdUrl: string): Promise<void> {
846
- this.animationData = await VMDLoader.load(vmdUrl)
798
+ const vmdKeyFrames = await VMDLoader.load(vmdUrl)
799
+
800
+ // Convert VMDKeyFrame[] to AnimationData
801
+ const boneTracks: Record<string, BoneKeyframe[]> = {}
802
+ const morphTracks: Record<string, MorphKeyframe[]> = {}
803
+
804
+ for (const keyFrame of vmdKeyFrames) {
805
+ for (const bf of keyFrame.boneFrames) {
806
+ if (!boneTracks[bf.boneName]) boneTracks[bf.boneName] = []
807
+ boneTracks[bf.boneName].push({
808
+ frame: bf.frame,
809
+ rotation: bf.rotation,
810
+ translation: bf.translation,
811
+ interpolation: rawInterpolationToBoneInterpolation(bf.interpolation),
812
+ })
813
+ }
814
+ for (const mf of keyFrame.morphFrames) {
815
+ if (!morphTracks[mf.morphName]) morphTracks[mf.morphName] = []
816
+ morphTracks[mf.morphName].push({
817
+ frame: mf.frame,
818
+ weight: mf.weight,
819
+ })
820
+ }
821
+ }
822
+
823
+ this.loadAnimationData({ boneTracks, morphTracks })
824
+ }
825
+
826
+ /**
827
+ * Load animation from structured keyframe data.
828
+ * This is the primary way to set animation data — loadVmd delegates to this.
829
+ */
830
+ loadAnimationData(data: AnimationData): void {
831
+ this._animationData = data
847
832
  this.resetAllBones()
848
833
  this.resetAllMorphs()
849
- this.processFrames()
850
- // Apply initial pose at time 0
834
+
835
+ this.boneTracks = new Map()
836
+ for (const name in data.boneTracks) {
837
+ const keyframes = data.boneTracks[name]
838
+ const sorted = [...keyframes].sort((a, b) => a.frame - b.frame)
839
+ this.boneTracks.set(
840
+ name,
841
+ sorted.map((kf) => ({
842
+ boneName: name,
843
+ frame: kf.frame,
844
+ rotation: kf.rotation,
845
+ translation: kf.translation,
846
+ interpolation: kf.interpolation,
847
+ time: kf.frame / VMD_FPS,
848
+ }))
849
+ )
850
+ }
851
+
852
+ this.morphTracks = new Map()
853
+ for (const name in data.morphTracks) {
854
+ const keyframes = data.morphTracks[name]
855
+ const sorted = [...keyframes].sort((a, b) => a.frame - b.frame)
856
+ this.morphTracks.set(
857
+ name,
858
+ sorted.map((kf) => ({
859
+ morphName: name,
860
+ frame: kf.frame,
861
+ weight: kf.weight,
862
+ time: kf.frame / VMD_FPS,
863
+ }))
864
+ )
865
+ }
866
+
867
+ this.boneTrackIndices.clear()
868
+ this.morphTrackIndices.clear()
869
+
870
+ // Calculate duration
871
+ let maxTime = 0
872
+ for (const frames of this.boneTracks.values()) {
873
+ if (frames.length > 0) maxTime = Math.max(maxTime, frames[frames.length - 1].time)
874
+ }
875
+ for (const frames of this.morphTracks.values()) {
876
+ if (frames.length > 0) maxTime = Math.max(maxTime, frames[frames.length - 1].time)
877
+ }
878
+ this.animationDuration = maxTime
879
+
880
+ this._hasAnimation = true
851
881
  this.animationTime = 0
852
882
  this.getPoseAtTime(0)
853
883
 
@@ -898,71 +928,8 @@ export class Model {
898
928
  this.physicsEnabled = enabled
899
929
  }
900
930
 
901
- /**
902
- * Process frames into tracks
903
- */
904
- private processFrames(): void {
905
- if (!this.animationData) return
906
-
907
- // Helper to group frames by name and sort by time
908
- const groupFrames = <T>(
909
- items: Array<{ item: T; name: string; time: number }>
910
- ): Map<string, Array<{ item: T; time: number }>> => {
911
- const tracks = new Map<string, Array<{ item: T; time: number }>>()
912
- for (const { item, name, time } of items) {
913
- if (!tracks.has(name)) tracks.set(name, [])
914
- tracks.get(name)!.push({ item, time })
915
- }
916
- for (const keyFrames of tracks.values()) {
917
- keyFrames.sort((a, b) => a.time - b.time)
918
- }
919
- return tracks
920
- }
921
-
922
- // Collect all bone and morph frames
923
- const boneItems: Array<{ item: BoneFrame; name: string; time: number }> = []
924
- const morphItems: Array<{ item: MorphFrame; name: string; time: number }> = []
925
-
926
- for (const keyFrame of this.animationData) {
927
- for (const boneFrame of keyFrame.boneFrames) {
928
- boneItems.push({ item: boneFrame, name: boneFrame.boneName, time: keyFrame.time })
929
- }
930
- for (const morphFrame of keyFrame.morphFrames) {
931
- morphItems.push({ item: morphFrame, name: morphFrame.morphName, time: keyFrame.time })
932
- }
933
- }
934
-
935
- // Transform to expected format
936
- this.boneTracks = new Map()
937
- for (const [name, frames] of groupFrames(boneItems).entries()) {
938
- this.boneTracks.set(
939
- name,
940
- frames.map((f) => ({ boneFrame: f.item, time: f.time }))
941
- )
942
- }
943
-
944
- this.morphTracks = new Map()
945
- for (const [name, frames] of groupFrames(morphItems).entries()) {
946
- this.morphTracks.set(
947
- name,
948
- frames.map((f) => ({ morphFrame: f.item, time: f.time }))
949
- )
950
- }
951
-
952
- // Reset cached indices when tracks change
953
- this.boneTrackIndices.clear()
954
- this.morphTrackIndices.clear()
955
-
956
- // Calculate duration from all tracks
957
- const allTracks = [...this.boneTracks.values(), ...this.morphTracks.values()]
958
- this.animationDuration = allTracks.reduce((max, keyFrames) => {
959
- const lastTime = keyFrames[keyFrames.length - 1]?.time ?? 0
960
- return Math.max(max, lastTime)
961
- }, 0)
962
- }
963
-
964
931
  playAnimation(): void {
965
- if (!this.animationData) return
932
+ if (!this._hasAnimation) return
966
933
 
967
934
  this.isPaused = false
968
935
  this.isPlaying = true
@@ -985,7 +952,7 @@ export class Model {
985
952
  }
986
953
 
987
954
  seekAnimation(time: number): void {
988
- if (!this.animationData) return
955
+ if (!this._hasAnimation) return
989
956
  const clampedTime = Math.max(0, Math.min(time, this.animationDuration))
990
957
  this.animationTime = clampedTime
991
958
  }
@@ -993,6 +960,10 @@ export class Model {
993
960
  /**
994
961
  * Get current animation progress
995
962
  */
963
+ getAnimationData(): AnimationData | null {
964
+ return this._animationData
965
+ }
966
+
996
967
  getAnimationProgress(): { current: number; duration: number; percentage: number } {
997
968
  const duration = this.animationDuration
998
969
  const percentage = duration > 0 ? (this.animationTime / duration) * 100 : 0
@@ -1045,9 +1016,7 @@ export class Model {
1045
1016
  * Optimized for per-frame performance
1046
1017
  */
1047
1018
  private getPoseAtTime(time: number): void {
1048
- if (!this.animationData) return
1049
-
1050
- const INV_127 = 1 / 127 // Pre-compute division constant
1019
+ if (!this._hasAnimation) return
1051
1020
 
1052
1021
  // Process bone tracks
1053
1022
  for (const [boneName, keyFrames] of this.boneTracks.entries()) {
@@ -1059,11 +1028,10 @@ export class Model {
1059
1028
 
1060
1029
  if (idx < 0) continue
1061
1030
 
1062
- // Update cache
1063
1031
  this.boneTrackIndices.set(boneName, idx)
1064
1032
 
1065
- const frameA = keyFrames[idx].boneFrame
1066
- const frameB = keyFrames[idx + 1]?.boneFrame
1033
+ const frameA = keyFrames[idx]
1034
+ const frameB = keyFrames[idx + 1]
1067
1035
 
1068
1036
  const boneIdx = this.runtimeSkeleton.nameIndex[boneName]
1069
1037
  if (boneIdx === undefined) continue
@@ -1072,61 +1040,30 @@ export class Model {
1072
1040
  const localTrans = this.runtimeSkeleton.localTranslations[boneIdx]
1073
1041
 
1074
1042
  if (!frameB) {
1075
- // No interpolation needed - direct assignment
1076
- // Use animation frame's rotation for translation conversion to ensure consistency
1077
- // This prevents conflicts when IK later modifies the rotation
1078
1043
  const frameRotation = frameA.rotation
1079
1044
  localRot.set(frameRotation)
1080
- // Convert VMD relative translation to local translation using animation rotation
1081
1045
  const localTranslation = this.convertVMDTranslationToLocal(boneIdx, frameA.translation, frameRotation)
1082
1046
  localTrans.set(localTranslation)
1083
1047
  } else {
1084
- const timeA = keyFrames[idx].time
1085
- const timeB = keyFrames[idx + 1].time
1086
- const timeDelta = timeB - timeA
1087
- const gradient = (clampedTime - timeA) / timeDelta
1048
+ const timeDelta = frameB.time - frameA.time
1049
+ const gradient = (clampedTime - frameA.time) / timeDelta
1088
1050
  const interp = frameB.interpolation
1089
1051
 
1090
- // Pre-normalize interpolation values (avoid division in bezierInterpolate)
1091
- const rotT = bezierInterpolate(
1092
- interp[0] * INV_127,
1093
- interp[1] * INV_127,
1094
- interp[2] * INV_127,
1095
- interp[3] * INV_127,
1096
- gradient
1097
- )
1098
-
1099
- // Use Quat.slerp to interpolate rotation
1052
+ const rotT = interpolateControlPoints(interp.rotation, gradient)
1100
1053
  const rotation = Quat.slerp(frameA.rotation, frameB.rotation, rotT)
1101
1054
 
1102
- // Interpolate VMD translation using bezier for each component
1103
- // Inline getWeight to avoid function call overhead
1104
- const getWeight = (offset: number) =>
1105
- bezierInterpolate(
1106
- interp[offset] * INV_127,
1107
- interp[offset + 8] * INV_127,
1108
- interp[offset + 4] * INV_127,
1109
- interp[offset + 12] * INV_127,
1110
- gradient
1111
- )
1112
-
1113
- const txWeight = getWeight(0)
1114
- const tyWeight = getWeight(16)
1115
- const tzWeight = getWeight(32)
1116
-
1117
- // Interpolate VMD relative translations (relative to bind pose world position)
1055
+ const txWeight = interpolateControlPoints(interp.translationX, gradient)
1056
+ const tyWeight = interpolateControlPoints(interp.translationY, gradient)
1057
+ const tzWeight = interpolateControlPoints(interp.translationZ, gradient)
1058
+
1118
1059
  const interpolatedVMDTranslation = new Vec3(
1119
1060
  frameA.translation.x + (frameB.translation.x - frameA.translation.x) * txWeight,
1120
1061
  frameA.translation.y + (frameB.translation.y - frameA.translation.y) * tyWeight,
1121
1062
  frameA.translation.z + (frameB.translation.z - frameA.translation.z) * tzWeight
1122
1063
  )
1123
1064
 
1124
- // Convert interpolated VMD translation to local translation using animation rotation
1125
- // This ensures translation is computed for the animation rotation, not the runtime rotation
1126
- // that will be modified by IK, preventing conflicts
1127
1065
  const localTranslation = this.convertVMDTranslationToLocal(boneIdx, interpolatedVMDTranslation, rotation)
1128
1066
 
1129
- // Direct property writes to avoid object allocation
1130
1067
  localRot.set(rotation)
1131
1068
  localTrans.set(localTranslation)
1132
1069
  }
@@ -1142,19 +1079,18 @@ export class Model {
1142
1079
 
1143
1080
  if (idx < 0) continue
1144
1081
 
1145
- // Update cache
1146
1082
  this.morphTrackIndices.set(morphName, idx)
1147
1083
 
1148
- const frameA = keyFrames[idx].morphFrame
1149
- const frameB = keyFrames[idx + 1]?.morphFrame
1084
+ const frameA = keyFrames[idx]
1085
+ const frameB = keyFrames[idx + 1]
1150
1086
 
1151
1087
  const morphIdx = this.runtimeMorph.nameIndex[morphName]
1152
1088
  if (morphIdx === undefined) continue
1153
1089
 
1154
1090
  const weight = frameB
1155
1091
  ? frameA.weight +
1156
- (frameB.weight - frameA.weight) *
1157
- ((clampedTime - keyFrames[idx].time) / (keyFrames[idx + 1].time - keyFrames[idx].time))
1092
+ (frameB.weight - frameA.weight) *
1093
+ ((clampedTime - keyFrames[idx].time) / (keyFrames[idx + 1].time - keyFrames[idx].time))
1158
1094
  : frameA.weight
1159
1095
 
1160
1096
  this.runtimeMorph.weights[morphIdx] = weight
@@ -1176,7 +1112,7 @@ export class Model {
1176
1112
  const tweensChangedMorphs = this.updateTweens()
1177
1113
 
1178
1114
  // Apply animation if playing or paused (always apply pose if animation data exists and we have a time set)
1179
- if (this.animationData) {
1115
+ if (this._hasAnimation) {
1180
1116
  if (this.isPlaying && !this.isPaused) {
1181
1117
  this.animationTime += deltaTime
1182
1118
 
@@ -1228,7 +1164,7 @@ export class Model {
1228
1164
  // Recompute ALL world matrices before each solver starts
1229
1165
  // This ensures each solver sees the effects of previous solvers on localRotations
1230
1166
  this.computeWorldMatrices()
1231
-
1167
+
1232
1168
  // Clear computed set for this solver's pass
1233
1169
  this.ikComputedSet.clear()
1234
1170
 
@@ -1293,9 +1229,9 @@ export class Model {
1293
1229
 
1294
1230
  // Handle append transformations (same logic as computeWorldMatrices)
1295
1231
  const appendParentIdx = b.appendParentIndex
1296
- const hasAppend = b.appendRotate &&
1297
- appendParentIdx !== undefined &&
1298
- appendParentIdx >= 0 &&
1232
+ const hasAppend = b.appendRotate &&
1233
+ appendParentIdx !== undefined &&
1234
+ appendParentIdx >= 0 &&
1299
1235
  appendParentIdx < bones.length
1300
1236
 
1301
1237
  if (hasAppend) {
@@ -1312,7 +1248,7 @@ export class Model {
1312
1248
  // Compute append parent's world matrix for dependency order, but use base rotation for append
1313
1249
  this.computeSingleBoneWorldMatrix(appendParentIdx, applyIK)
1314
1250
  }
1315
-
1251
+
1316
1252
  // Use append parent's base local rotation only (IK rotations are applied after solving)
1317
1253
  let appendRot = localRot[appendParentIdx]
1318
1254
 
@@ -1320,7 +1256,7 @@ export class Model {
1320
1256
  const aw = appendRot.w
1321
1257
  const absRatio = ratio < 0 ? -ratio : ratio
1322
1258
  if (ratio < 0) { ax = -ax; ay = -ay; az = -az }
1323
-
1259
+
1324
1260
  const appendQuat = new Quat(ax, ay, az, aw)
1325
1261
  const result = Quat.slerp(Quat.identity(), appendQuat, absRatio)
1326
1262
  rotateM = Mat4.fromQuat(result.x, result.y, result.z, result.w).multiply(rotateM)