reze-engine 0.6.7 → 0.8.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,9 +1,19 @@
1
- import { Mat4, Quat, Vec3, bezierInterpolate } from "./math";
1
+ import { Mat4, Quat, Vec3 } from "./math";
2
+ import { Engine } from "./engine";
3
+ import { PmxLoader } from "./pmx-loader";
2
4
  import { Physics } from "./physics";
3
5
  import { IKSolverSystem } from "./ik-solver";
4
6
  import { VMDLoader } from "./vmd-loader";
7
+ import { interpolateControlPoints, rawInterpolationToBoneInterpolation } from "./animation";
8
+ const VMD_FPS = 30;
5
9
  const VERTEX_STRIDE = 8;
6
10
  export class Model {
11
+ // loadPmx: fetch PMX + register with Engine.getInstance() (init engine first)
12
+ static async loadPmx(path) {
13
+ const model = await PmxLoader.load(path);
14
+ await Engine.getInstance().registerModel(model, path);
15
+ return model;
16
+ }
7
17
  constructor(vertexData, indexData, textures, materials, skeleton, skinning, morphing, rigidbodies = [], joints = []) {
8
18
  this.textures = [];
9
19
  this.materials = [];
@@ -16,7 +26,7 @@ export class Model {
16
26
  this.cachedIdentityMat2 = Mat4.identity();
17
27
  this.tweenTimeMs = 0; // Time tracking for tweens (milliseconds)
18
28
  // Animation runtime
19
- this.animationData = null;
29
+ this._hasAnimation = false;
20
30
  this.boneTracks = new Map();
21
31
  this.morphTracks = new Map();
22
32
  this.animationDuration = 0;
@@ -239,6 +249,13 @@ export class Model {
239
249
  getSkeleton() {
240
250
  return this.skeleton;
241
251
  }
252
+ // World bone origin (world matrix col3); unknown name → null
253
+ getBoneWorldPosition(boneName) {
254
+ const idx = this.runtimeSkeleton.nameIndex[boneName];
255
+ if (idx === undefined || idx < 0)
256
+ return null;
257
+ return this.runtimeSkeleton.worldMatrices[idx].getPosition();
258
+ }
242
259
  getSkinning() {
243
260
  return this.skinning;
244
261
  }
@@ -345,14 +362,7 @@ export class Model {
345
362
  state.transActive[idx] = 1;
346
363
  }
347
364
  }
348
- /**
349
- * Convert VMD-style relative translation (relative to bind pose world position) to local translation
350
- * This helper is used by both moveBones and getPoseAtTime to ensure consistent translation handling
351
- * @param boneIdx - Bone index
352
- * @param vmdRelativeTranslation - VMD relative translation
353
- * @param rotation - Optional rotation to use for conversion. If not provided, uses current localRotation.
354
- * Use animation rotation (from frame) to avoid conflicts when IK modifies rotation.
355
- */
365
+ // VMD translation (world delta from bind pose) → bone local space; optional rotation for animation vs IK
356
366
  convertVMDTranslationToLocal(boneIdx, vmdRelativeTranslation, rotation) {
357
367
  const skeleton = this.skeleton;
358
368
  const bones = skeleton.bones;
@@ -441,6 +451,12 @@ export class Model {
441
451
  this.runtimeMorph.weights[idx] = clampedWeight;
442
452
  this.tweenState.morphActive[idx] = 0;
443
453
  this.applyMorphs();
454
+ try {
455
+ Engine.getInstance().markVertexBufferDirty();
456
+ }
457
+ catch {
458
+ /* not registered yet */
459
+ }
444
460
  return;
445
461
  }
446
462
  // Animated change
@@ -464,49 +480,6 @@ export class Model {
464
480
  this.runtimeMorph.weights[idx] = startWeight;
465
481
  this.applyMorphs();
466
482
  }
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
483
  applyMorphs() {
511
484
  // Reset vertex data to base positions
512
485
  this.vertexData.set(this.baseVertexData);
@@ -564,15 +537,71 @@ export class Model {
564
537
  }
565
538
  }
566
539
  }
567
- /**
568
- * Load VMD animation file
569
- */
570
540
  async loadVmd(vmdUrl) {
571
- this.animationData = await VMDLoader.load(vmdUrl);
541
+ const vmdKeyFrames = await VMDLoader.load(vmdUrl);
572
542
  this.resetAllBones();
573
543
  this.resetAllMorphs();
574
- this.processFrames();
575
- // Apply initial pose at time 0
544
+ // Build bone tracks: Map<boneName, Array<{boneName, frame, rotation, translation, interpolation, time}>>
545
+ const boneTracksByBone = {};
546
+ for (const keyFrame of vmdKeyFrames) {
547
+ for (const bf of keyFrame.boneFrames) {
548
+ if (!boneTracksByBone[bf.boneName])
549
+ boneTracksByBone[bf.boneName] = [];
550
+ boneTracksByBone[bf.boneName].push({
551
+ frame: bf.frame,
552
+ rotation: bf.rotation,
553
+ translation: bf.translation,
554
+ interpolation: rawInterpolationToBoneInterpolation(bf.interpolation),
555
+ });
556
+ }
557
+ }
558
+ this.boneTracks = new Map();
559
+ for (const name in boneTracksByBone) {
560
+ const keyframes = boneTracksByBone[name];
561
+ const sorted = [...keyframes].sort((a, b) => a.frame - b.frame);
562
+ this.boneTracks.set(name, sorted.map((kf) => ({
563
+ boneName: name,
564
+ frame: kf.frame,
565
+ rotation: kf.rotation,
566
+ translation: kf.translation,
567
+ interpolation: kf.interpolation,
568
+ time: kf.frame / VMD_FPS,
569
+ })));
570
+ }
571
+ // Build morph tracks: Map<morphName, Array<{morphName, frame, weight, time}>>
572
+ const morphTracksByMorph = {};
573
+ for (const keyFrame of vmdKeyFrames) {
574
+ for (const mf of keyFrame.morphFrames) {
575
+ if (!morphTracksByMorph[mf.morphName])
576
+ morphTracksByMorph[mf.morphName] = [];
577
+ morphTracksByMorph[mf.morphName].push({ frame: mf.frame, weight: mf.weight });
578
+ }
579
+ }
580
+ this.morphTracks = new Map();
581
+ for (const name in morphTracksByMorph) {
582
+ const keyframes = morphTracksByMorph[name];
583
+ const sorted = [...keyframes].sort((a, b) => a.frame - b.frame);
584
+ this.morphTracks.set(name, sorted.map((kf) => ({
585
+ morphName: name,
586
+ frame: kf.frame,
587
+ weight: kf.weight,
588
+ time: kf.frame / VMD_FPS,
589
+ })));
590
+ }
591
+ this.boneTrackIndices.clear();
592
+ this.morphTrackIndices.clear();
593
+ // Calculate duration
594
+ let maxTime = 0;
595
+ for (const frames of this.boneTracks.values()) {
596
+ if (frames.length > 0)
597
+ maxTime = Math.max(maxTime, frames[frames.length - 1].time);
598
+ }
599
+ for (const frames of this.morphTracks.values()) {
600
+ if (frames.length > 0)
601
+ maxTime = Math.max(maxTime, frames[frames.length - 1].time);
602
+ }
603
+ this.animationDuration = maxTime;
604
+ this._hasAnimation = true;
576
605
  this.animationTime = 0;
577
606
  this.getPoseAtTime(0);
578
607
  if (this.physics) {
@@ -580,9 +609,6 @@ export class Model {
580
609
  this.physics.reset(this.runtimeSkeleton.worldMatrices, this.skeleton.inverseBindMatrices);
581
610
  }
582
611
  }
583
- /**
584
- * Reset all bones to their default pose
585
- */
586
612
  resetAllBones() {
587
613
  for (let boneIdx = 0; boneIdx < this.skeleton.bones.length; boneIdx++) {
588
614
  const localRot = this.runtimeSkeleton.localRotations[boneIdx];
@@ -604,69 +630,14 @@ export class Model {
604
630
  this.morphsDirty = true;
605
631
  this.applyMorphs();
606
632
  }
607
- /**
608
- * Enable or disable IK solving
609
- */
610
633
  setIKEnabled(enabled) {
611
634
  this.ikEnabled = enabled;
612
635
  }
613
- /**
614
- * Enable or disable physics simulation
615
- */
616
636
  setPhysicsEnabled(enabled) {
617
637
  this.physicsEnabled = enabled;
618
638
  }
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
639
  playAnimation() {
669
- if (!this.animationData)
640
+ if (!this._hasAnimation)
670
641
  return;
671
642
  this.isPaused = false;
672
643
  this.isPlaying = true;
@@ -686,14 +657,11 @@ export class Model {
686
657
  this.animationTime = 0;
687
658
  }
688
659
  seekAnimation(time) {
689
- if (!this.animationData)
660
+ if (!this._hasAnimation)
690
661
  return;
691
662
  const clampedTime = Math.max(0, Math.min(time, this.animationDuration));
692
663
  this.animationTime = clampedTime;
693
664
  }
694
- /**
695
- * Get current animation progress
696
- */
697
665
  getAnimationProgress() {
698
666
  const duration = this.animationDuration;
699
667
  const percentage = duration > 0 ? (this.animationTime / duration) * 100 : 0;
@@ -703,9 +671,6 @@ export class Model {
703
671
  percentage,
704
672
  };
705
673
  }
706
- /**
707
- * Binary search upper bound helper (static to avoid recreation)
708
- */
709
674
  static upperBound(time, keyFrames, startIdx = 0) {
710
675
  let left = startIdx, right = keyFrames.length;
711
676
  while (left < right) {
@@ -717,10 +682,6 @@ export class Model {
717
682
  }
718
683
  return left;
719
684
  }
720
- /**
721
- * Find keyframe index with caching optimization
722
- * Uses cached index as starting point for faster lookup when time is close
723
- */
724
685
  findKeyframeIndex(time, keyFrames, cachedIdx) {
725
686
  if (keyFrames.length === 0)
726
687
  return -1;
@@ -737,14 +698,9 @@ export class Model {
737
698
  const idx = Model.upperBound(time, keyFrames, 0) - 1;
738
699
  return idx;
739
700
  }
740
- /**
741
- * Get pose at specific time (internal helper)
742
- * Optimized for per-frame performance
743
- */
744
701
  getPoseAtTime(time) {
745
- if (!this.animationData)
702
+ if (!this._hasAnimation)
746
703
  return;
747
- const INV_127 = 1 / 127; // Pre-compute division constant
748
704
  // Process bone tracks
749
705
  for (const [boneName, keyFrames] of this.boneTracks.entries()) {
750
706
  if (keyFrames.length === 0)
@@ -754,48 +710,31 @@ export class Model {
754
710
  const idx = this.findKeyframeIndex(clampedTime, keyFrames, cachedIdx);
755
711
  if (idx < 0)
756
712
  continue;
757
- // Update cache
758
713
  this.boneTrackIndices.set(boneName, idx);
759
- const frameA = keyFrames[idx].boneFrame;
760
- const frameB = keyFrames[idx + 1]?.boneFrame;
714
+ const frameA = keyFrames[idx];
715
+ const frameB = keyFrames[idx + 1];
761
716
  const boneIdx = this.runtimeSkeleton.nameIndex[boneName];
762
717
  if (boneIdx === undefined)
763
718
  continue;
764
719
  const localRot = this.runtimeSkeleton.localRotations[boneIdx];
765
720
  const localTrans = this.runtimeSkeleton.localTranslations[boneIdx];
766
721
  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
722
  const frameRotation = frameA.rotation;
771
723
  localRot.set(frameRotation);
772
- // Convert VMD relative translation to local translation using animation rotation
773
724
  const localTranslation = this.convertVMDTranslationToLocal(boneIdx, frameA.translation, frameRotation);
774
725
  localTrans.set(localTranslation);
775
726
  }
776
727
  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;
728
+ const timeDelta = frameB.time - frameA.time;
729
+ const gradient = (clampedTime - frameA.time) / timeDelta;
781
730
  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
731
+ const rotT = interpolateControlPoints(interp.rotation, gradient);
785
732
  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)
733
+ const txWeight = interpolateControlPoints(interp.translationX, gradient);
734
+ const tyWeight = interpolateControlPoints(interp.translationY, gradient);
735
+ const tzWeight = interpolateControlPoints(interp.translationZ, gradient);
793
736
  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
737
  const localTranslation = this.convertVMDTranslationToLocal(boneIdx, interpolatedVMDTranslation, rotation);
798
- // Direct property writes to avoid object allocation
799
738
  localRot.set(rotation);
800
739
  localTrans.set(localTranslation);
801
740
  }
@@ -809,10 +748,9 @@ export class Model {
809
748
  const idx = this.findKeyframeIndex(clampedTime, keyFrames, cachedIdx);
810
749
  if (idx < 0)
811
750
  continue;
812
- // Update cache
813
751
  this.morphTrackIndices.set(morphName, idx);
814
- const frameA = keyFrames[idx].morphFrame;
815
- const frameB = keyFrames[idx + 1]?.morphFrame;
752
+ const frameA = keyFrames[idx];
753
+ const frameB = keyFrames[idx + 1];
816
754
  const morphIdx = this.runtimeMorph.nameIndex[morphName];
817
755
  if (morphIdx === undefined)
818
756
  continue;
@@ -825,19 +763,14 @@ export class Model {
825
763
  this.morphsDirty = true; // Mark as dirty when animation sets morph weights
826
764
  }
827
765
  }
828
- /**
829
- * Updates the model pose by recomputing all matrices.
830
- * If animation is playing, applies animation pose first.
831
- * deltaTime: Time elapsed since last update in seconds
832
- * Returns true if vertices were modified (morphs changed)
833
- */
766
+ // Returns true when morphs changed (vertex buffer may need upload)
834
767
  update(deltaTime) {
835
768
  // Update tween time (in milliseconds)
836
769
  this.tweenTimeMs += deltaTime * 1000;
837
770
  // Update all active tweens (rotations, translations, morphs)
838
771
  const tweensChangedMorphs = this.updateTweens();
839
772
  // Apply animation if playing or paused (always apply pose if animation data exists and we have a time set)
840
- if (this.animationData) {
773
+ if (this._hasAnimation) {
841
774
  if (this.isPlaying && !this.isPaused) {
842
775
  this.animationTime += deltaTime;
843
776
  if (this.animationTime >= this.animationDuration) {
@@ -218,7 +218,7 @@ export class PmxLoader {
218
218
  this.getFloat32(),
219
219
  ];
220
220
  // edgeSize float
221
- const edgeSize = this.getFloat32();
221
+ const edgeSize = this.getFloat32() * 2; // double the size for better visibility
222
222
  const textureIndex = this.getNonVertexIndex(this.textureIndexSize);
223
223
  const sphereTextureIndex = this.getNonVertexIndex(this.textureIndexSize);
224
224
  const sphereTextureMode = this.getUint8();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.6.7",
3
+ "version": "0.8.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,75 @@
1
+ export interface ControlPoint {
2
+ x: number
3
+ y: number
4
+ }
5
+
6
+ export interface BoneInterpolation {
7
+ rotation: ControlPoint[]
8
+ translationX: ControlPoint[]
9
+ translationY: ControlPoint[]
10
+ translationZ: ControlPoint[]
11
+ }
12
+
13
+ // Cubic bezier in normalized 0–1 space (binary search on x)
14
+ export function bezierInterpolate(x1: number, x2: number, y1: number, y2: number, t: number): number {
15
+ t = Math.max(0, Math.min(1, t))
16
+
17
+ let start = 0
18
+ let end = 1
19
+ let mid = 0.5
20
+
21
+ for (let i = 0; i < 15; i++) {
22
+ const x = 3 * (1 - mid) * (1 - mid) * mid * x1 + 3 * (1 - mid) * mid * mid * x2 + mid * mid * mid
23
+
24
+ if (Math.abs(x - t) < 0.0001) {
25
+ break
26
+ }
27
+
28
+ if (x < t) {
29
+ start = mid
30
+ } else {
31
+ end = mid
32
+ }
33
+
34
+ mid = (start + end) / 2
35
+ }
36
+
37
+ const y = 3 * (1 - mid) * (1 - mid) * mid * y1 + 3 * (1 - mid) * mid * mid * y2 + mid * mid * mid
38
+
39
+ return y
40
+ }
41
+
42
+ const INV_127 = 1 / 127
43
+
44
+ // VMD 64-byte interpolation blob → BoneInterpolation
45
+ export function rawInterpolationToBoneInterpolation(raw: Uint8Array): BoneInterpolation {
46
+ return {
47
+ rotation: [
48
+ { x: raw[0], y: raw[2] },
49
+ { x: raw[1], y: raw[3] },
50
+ ],
51
+ translationX: [
52
+ { x: raw[0], y: raw[4] },
53
+ { x: raw[8], y: raw[12] },
54
+ ],
55
+ translationY: [
56
+ { x: raw[16], y: raw[20] },
57
+ { x: raw[24], y: raw[28] },
58
+ ],
59
+ translationZ: [
60
+ { x: raw[32], y: raw[36] },
61
+ { x: raw[40], y: raw[44] },
62
+ ],
63
+ }
64
+ }
65
+
66
+ // Control points are 0–127 VMD bytes
67
+ export function interpolateControlPoints(cp: ControlPoint[], t: number): number {
68
+ return bezierInterpolate(
69
+ cp[0].x * INV_127,
70
+ cp[1].x * INV_127,
71
+ cp[0].y * INV_127,
72
+ cp[1].y * INV_127,
73
+ t
74
+ )
75
+ }
package/src/camera.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Mat4, Vec3 } from "./math"
2
2
 
3
- const FAR = 1000
3
+ const FAR = 2000
4
4
 
5
5
  export class Camera {
6
6
  alpha: number
@@ -29,7 +29,7 @@ export class Camera {
29
29
  panSensitivity: number = 0.0002 // Sensitivity for right-click panning
30
30
  wheelPrecision: number = 0.01
31
31
  pinchPrecision: number = 0.05
32
- minZ: number = 0.1
32
+ minZ: number = 0.05
33
33
  maxZ: number = FAR
34
34
  lowerBetaLimit: number = 0.001
35
35
  upperBetaLimit: number = Math.PI - 0.001