reze-engine 0.2.19 → 0.3.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/engine.ts CHANGED
@@ -1,9 +1,10 @@
1
+ import { bezierInterpolate } from "./bezier-interpolate"
1
2
  import { Camera } from "./camera"
2
- import { Quat, Vec3 } from "./math"
3
+ import { Mat4, Quat, Vec3 } from "./math"
3
4
  import { Model } from "./model"
4
5
  import { PmxLoader } from "./pmx-loader"
5
6
  import { Physics } from "./physics"
6
- import { VMDKeyFrame, VMDLoader } from "./vmd-loader"
7
+ import { BoneFrame, MorphFrame, VMDKeyFrame, VMDLoader } from "./vmd-loader"
7
8
 
8
9
  export type EngineOptions = {
9
10
  ambientColor?: Vec3
@@ -24,12 +25,6 @@ interface DrawCall {
24
25
  bindGroup: GPUBindGroup
25
26
  }
26
27
 
27
- type BoneKeyFrame = {
28
- boneName: string
29
- time: number
30
- rotation: Quat
31
- }
32
-
33
28
  export class Engine {
34
29
  private canvas: HTMLCanvasElement
35
30
  private device!: GPUDevice
@@ -79,7 +74,6 @@ export class Engine {
79
74
  private static readonly DEFAULT_RIM_LIGHT_INTENSITY = 0.45
80
75
  private static readonly DEFAULT_CAMERA_DISTANCE = 26.6
81
76
  private static readonly DEFAULT_CAMERA_TARGET = new Vec3(0, 12.5, 0)
82
- private static readonly HAIR_OVER_EYES_ALPHA = 0.5
83
77
  private static readonly TRANSPARENCY_EPSILON = 0.001
84
78
  private static readonly STATS_FPS_UPDATE_INTERVAL_MS = 1000
85
79
  private static readonly STATS_FRAME_TIME_ROUNDING = 100
@@ -144,8 +138,10 @@ export class Engine {
144
138
  private animationTimeouts: number[] = []
145
139
  private hasAnimation = false // Set to true when loadAnimation is called
146
140
  private playingAnimation = false // Set to true when playAnimation is called
147
- private breathingTimeout: number | null = null
148
- private breathingBaseRotations: Map<string, Quat> = new Map()
141
+ private animationStartTime: number = 0 // When animation started playing
142
+ private animationDuration: number = 0 // Total animation duration in seconds
143
+ private boneTracks: Map<string, Array<{ boneFrame: BoneFrame; time: number }>> = new Map()
144
+ private morphTracks: Map<string, Array<{ morphFrame: MorphFrame; time: number }>> = new Map()
149
145
 
150
146
  constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
151
147
  this.canvas = canvas
@@ -1283,75 +1279,100 @@ export class Engine {
1283
1279
  this.hasAnimation = true
1284
1280
  }
1285
1281
 
1286
- public playAnimation(options?: {
1287
- breathBones?: string[] | Record<string, number> // Array of bone names or map of bone name -> rotation range
1288
- breathDuration?: number // Breathing cycle duration in milliseconds
1289
- }) {
1282
+ public playAnimation() {
1290
1283
  if (this.animationFrames.length === 0) return
1291
1284
 
1292
1285
  this.stopAnimation()
1293
- this.stopBreathing()
1286
+
1294
1287
  this.playingAnimation = true
1295
1288
 
1296
- // Enable breathing if breathBones is provided
1297
- const enableBreath = options?.breathBones !== undefined && options.breathBones !== null
1298
- let breathBones: string[] = []
1299
- let breathRotationRanges: Record<string, number> | undefined = undefined
1289
+ // Process bone frames
1290
+ const allBoneKeyFrames: Array<{ boneFrame: BoneFrame; time: number }> = []
1291
+ for (const keyFrame of this.animationFrames) {
1292
+ for (const boneFrame of keyFrame.boneFrames) {
1293
+ allBoneKeyFrames.push({
1294
+ boneFrame,
1295
+ time: keyFrame.time,
1296
+ })
1297
+ }
1298
+ }
1300
1299
 
1301
- if (enableBreath && options.breathBones) {
1302
- if (Array.isArray(options.breathBones)) {
1303
- breathBones = options.breathBones
1304
- } else {
1305
- breathBones = Object.keys(options.breathBones)
1306
- breathRotationRanges = options.breathBones
1300
+ const boneKeyFramesByBone = new Map<string, Array<{ boneFrame: BoneFrame; time: number }>>()
1301
+ for (const { boneFrame, time } of allBoneKeyFrames) {
1302
+ if (!boneKeyFramesByBone.has(boneFrame.boneName)) {
1303
+ boneKeyFramesByBone.set(boneFrame.boneName, [])
1307
1304
  }
1305
+ boneKeyFramesByBone.get(boneFrame.boneName)!.push({ boneFrame, time })
1308
1306
  }
1309
1307
 
1310
- const breathDuration = options?.breathDuration ?? 4000
1308
+ for (const keyFrames of boneKeyFramesByBone.values()) {
1309
+ keyFrames.sort((a, b) => a.time - b.time)
1310
+ }
1311
1311
 
1312
- const allBoneKeyFrames: BoneKeyFrame[] = []
1312
+ // Process morph frames
1313
+ const allMorphKeyFrames: Array<{ morphFrame: MorphFrame; time: number }> = []
1313
1314
  for (const keyFrame of this.animationFrames) {
1314
- for (const boneFrame of keyFrame.boneFrames) {
1315
- allBoneKeyFrames.push({
1316
- boneName: boneFrame.boneName,
1315
+ for (const morphFrame of keyFrame.morphFrames) {
1316
+ allMorphKeyFrames.push({
1317
+ morphFrame,
1317
1318
  time: keyFrame.time,
1318
- rotation: boneFrame.rotation,
1319
1319
  })
1320
1320
  }
1321
1321
  }
1322
1322
 
1323
- const boneKeyFramesByBone = new Map<string, BoneKeyFrame[]>()
1324
- for (const boneKeyFrame of allBoneKeyFrames) {
1325
- if (!boneKeyFramesByBone.has(boneKeyFrame.boneName)) {
1326
- boneKeyFramesByBone.set(boneKeyFrame.boneName, [])
1323
+ const morphKeyFramesByMorph = new Map<string, Array<{ morphFrame: MorphFrame; time: number }>>()
1324
+ for (const { morphFrame, time } of allMorphKeyFrames) {
1325
+ if (!morphKeyFramesByMorph.has(morphFrame.morphName)) {
1326
+ morphKeyFramesByMorph.set(morphFrame.morphName, [])
1327
1327
  }
1328
- boneKeyFramesByBone.get(boneKeyFrame.boneName)!.push(boneKeyFrame)
1328
+ morphKeyFramesByMorph.get(morphFrame.morphName)!.push({ morphFrame, time })
1329
1329
  }
1330
1330
 
1331
- for (const keyFrames of boneKeyFramesByBone.values()) {
1331
+ for (const keyFrames of morphKeyFramesByMorph.values()) {
1332
1332
  keyFrames.sort((a, b) => a.time - b.time)
1333
1333
  }
1334
1334
 
1335
- const time0Rotations: Array<{ boneName: string; rotation: Quat }> = []
1336
- const bonesWithTime0 = new Set<string>()
1337
- for (const [boneName, keyFrames] of boneKeyFramesByBone.entries()) {
1338
- if (keyFrames.length > 0 && keyFrames[0].time === 0) {
1339
- time0Rotations.push({
1340
- boneName: boneName,
1341
- rotation: keyFrames[0].rotation,
1342
- })
1343
- bonesWithTime0.add(boneName)
1335
+ // Store tracks for frame-based animation
1336
+ this.boneTracks = boneKeyFramesByBone
1337
+ this.morphTracks = morphKeyFramesByMorph
1338
+
1339
+ // Calculate animation duration from max frame time (already in seconds)
1340
+ let maxFrameTime = 0
1341
+ for (const keyFrames of this.boneTracks.values()) {
1342
+ if (keyFrames.length > 0) {
1343
+ const lastTime = keyFrames[keyFrames.length - 1].time
1344
+ if (lastTime > maxFrameTime) {
1345
+ maxFrameTime = lastTime
1346
+ }
1347
+ }
1348
+ }
1349
+ for (const keyFrames of this.morphTracks.values()) {
1350
+ if (keyFrames.length > 0) {
1351
+ const lastTime = keyFrames[keyFrames.length - 1].time
1352
+ if (lastTime > maxFrameTime) {
1353
+ maxFrameTime = lastTime
1354
+ }
1344
1355
  }
1345
1356
  }
1357
+ this.animationDuration = maxFrameTime > 0 ? maxFrameTime : 0
1358
+ this.animationStartTime = performance.now()
1346
1359
 
1360
+ // Initialize bones and morphs to time 0 pose
1347
1361
  if (this.currentModel) {
1348
- if (time0Rotations.length > 0) {
1349
- const boneNames = time0Rotations.map((r) => r.boneName)
1350
- const rotations = time0Rotations.map((r) => r.rotation)
1351
- this.rotateBones(boneNames, rotations, 0)
1362
+ const skeleton = this.currentModel.getSkeleton()
1363
+ const bonesWithTime0 = new Set<string>()
1364
+
1365
+ // Apply time 0 bone keyframes
1366
+ for (const [boneName, keyFrames] of this.boneTracks.entries()) {
1367
+ if (keyFrames.length > 0 && keyFrames[0].time === 0) {
1368
+ const boneFrame = keyFrames[0].boneFrame
1369
+ this.rotateBones([boneName], [boneFrame.rotation], 0)
1370
+ this.moveBones([boneName], [boneFrame.translation], 0)
1371
+ bonesWithTime0.add(boneName)
1372
+ }
1352
1373
  }
1353
1374
 
1354
- const skeleton = this.currentModel.getSkeleton()
1375
+ // Reset bones without time 0 keyframes
1355
1376
  const bonesToReset: string[] = []
1356
1377
  for (const bone of skeleton.bones) {
1357
1378
  if (!bonesWithTime0.has(bone.name)) {
@@ -1365,6 +1386,14 @@ export class Engine {
1365
1386
  this.rotateBones(bonesToReset, identityQuats, 0)
1366
1387
  }
1367
1388
 
1389
+ // Apply time 0 morph keyframes
1390
+ for (const [morphName, keyFrames] of this.morphTracks.entries()) {
1391
+ if (keyFrames.length > 0 && keyFrames[0].time === 0) {
1392
+ const morphFrame = keyFrames[0].morphFrame
1393
+ this.setMorphWeight(morphName, morphFrame.weight, 0)
1394
+ }
1395
+ }
1396
+
1368
1397
  // Reset physics immediately and upload matrices to prevent A-pose flash
1369
1398
  if (this.physics) {
1370
1399
  this.currentModel.evaluatePose()
@@ -1385,70 +1414,6 @@ export class Engine {
1385
1414
  this.device.queue.submit([encoder.finish()])
1386
1415
  }
1387
1416
  }
1388
- for (const [_, keyFrames] of boneKeyFramesByBone.entries()) {
1389
- for (let i = 0; i < keyFrames.length; i++) {
1390
- const boneKeyFrame = keyFrames[i]
1391
- const previousBoneKeyFrame = i > 0 ? keyFrames[i - 1] : null
1392
-
1393
- if (boneKeyFrame.time === 0) continue
1394
-
1395
- let durationMs = 0
1396
- if (i === 0) {
1397
- durationMs = boneKeyFrame.time * 1000
1398
- } else if (previousBoneKeyFrame) {
1399
- durationMs = (boneKeyFrame.time - previousBoneKeyFrame.time) * 1000
1400
- }
1401
-
1402
- const scheduleTime = i > 0 && previousBoneKeyFrame ? previousBoneKeyFrame.time : 0
1403
- const delayMs = scheduleTime * 1000
1404
-
1405
- if (delayMs <= 0) {
1406
- this.rotateBones([boneKeyFrame.boneName], [boneKeyFrame.rotation], durationMs)
1407
- } else {
1408
- const timeoutId = window.setTimeout(() => {
1409
- this.rotateBones([boneKeyFrame.boneName], [boneKeyFrame.rotation], durationMs)
1410
- }, delayMs)
1411
- this.animationTimeouts.push(timeoutId)
1412
- }
1413
- }
1414
- }
1415
-
1416
- // Setup breathing animation if enabled
1417
- if (enableBreath && this.currentModel) {
1418
- // Find the last frame time
1419
- let maxTime = 0
1420
- for (const keyFrame of this.animationFrames) {
1421
- if (keyFrame.time > maxTime) {
1422
- maxTime = keyFrame.time
1423
- }
1424
- }
1425
-
1426
- // Get last frame rotations directly from animation data for breathing bones
1427
- const lastFrameRotations = new Map<string, Quat>()
1428
- for (const bone of breathBones) {
1429
- const keyFrames = boneKeyFramesByBone.get(bone)
1430
- if (keyFrames && keyFrames.length > 0) {
1431
- // Find the rotation at the last frame time (closest keyframe <= maxTime)
1432
- let lastRotation: Quat | null = null
1433
- for (let i = keyFrames.length - 1; i >= 0; i--) {
1434
- if (keyFrames[i].time <= maxTime) {
1435
- lastRotation = keyFrames[i].rotation
1436
- break
1437
- }
1438
- }
1439
- if (lastRotation) {
1440
- lastFrameRotations.set(bone, lastRotation)
1441
- }
1442
- }
1443
- }
1444
-
1445
- // Start breathing after animation completes
1446
- // Use the last frame rotations directly from animation data (no need to capture from model)
1447
- const animationEndTime = maxTime * 1000 + 200 // Small buffer for final tweens to complete
1448
- this.breathingTimeout = window.setTimeout(() => {
1449
- this.startBreathing(breathBones, lastFrameRotations, breathRotationRanges, breathDuration)
1450
- }, animationEndTime)
1451
- }
1452
1417
  }
1453
1418
 
1454
1419
  public stopAnimation() {
@@ -1457,69 +1422,195 @@ export class Engine {
1457
1422
  }
1458
1423
  this.animationTimeouts = []
1459
1424
  this.playingAnimation = false
1425
+ this.boneTracks.clear()
1426
+ this.morphTracks.clear()
1460
1427
  }
1461
1428
 
1462
- private stopBreathing() {
1463
- if (this.breathingTimeout !== null) {
1464
- clearTimeout(this.breathingTimeout)
1465
- this.breathingTimeout = null
1466
- }
1467
- this.breathingBaseRotations.clear()
1468
- }
1469
-
1470
- private startBreathing(
1471
- bones: string[],
1472
- baseRotations: Map<string, Quat>,
1473
- rotationRanges?: Record<string, number>,
1474
- durationMs: number = 4000
1475
- ) {
1429
+ // Frame-based animation update (called every frame)
1430
+ // Similar to reference: MmdRuntimeModelAnimation.animate(frameTime)
1431
+ // frameTime is in seconds (already converted from VMD frame numbers in loader)
1432
+ private animate(frameTime: number): void {
1476
1433
  if (!this.currentModel) return
1477
1434
 
1478
- // Store base rotations directly from last frame of animation data
1479
- // These are the exact rotations from the animation - use them as-is
1480
- for (const bone of bones) {
1481
- const baseRot = baseRotations.get(bone)
1482
- if (baseRot) {
1483
- this.breathingBaseRotations.set(bone, baseRot)
1435
+ // Helper to find upper bound index (binary search)
1436
+ const upperBoundFrameIndex = (time: number, keyFrames: Array<{ boneFrame: BoneFrame; time: number }>): number => {
1437
+ let left = 0
1438
+ let right = keyFrames.length
1439
+ while (left < right) {
1440
+ const mid = Math.floor((left + right) / 2)
1441
+ if (keyFrames[mid].time <= time) {
1442
+ left = mid + 1
1443
+ } else {
1444
+ right = mid
1445
+ }
1484
1446
  }
1447
+ return left
1485
1448
  }
1486
1449
 
1487
- const halfCycleMs = durationMs / 2
1488
- const defaultRotation = 0.02 // Default rotation range if not specified per bone
1450
+ const boneNamesToRotate: string[] = []
1451
+ const rotationsToApply: Quat[] = []
1452
+ const boneNamesToMove: string[] = []
1453
+ const translationsToApply: Vec3[] = []
1454
+ const morphNamesToSet: string[] = []
1455
+ const morphWeightsToSet: number[] = []
1456
+
1457
+ // Process each bone track
1458
+ for (const [boneName, keyFrames] of this.boneTracks.entries()) {
1459
+ if (keyFrames.length === 0) continue
1460
+
1461
+ // Clamp frame time to track range (all times are in seconds)
1462
+ const startTime = keyFrames[0].time
1463
+ const endTime = keyFrames[keyFrames.length - 1].time
1464
+ const clampedFrameTime = Math.max(startTime, Math.min(endTime, frameTime))
1489
1465
 
1490
- // Start breathing cycle - oscillate around exact base rotation (final pose)
1491
- // Each bone can have its own rotation range, or use default
1492
- const animate = (isInhale: boolean) => {
1493
- if (!this.currentModel) return
1466
+ const upperBoundIndex = upperBoundFrameIndex(clampedFrameTime, keyFrames)
1467
+ const upperBoundIndexMinusOne = upperBoundIndex - 1
1494
1468
 
1495
- const breathingBoneNames: string[] = []
1496
- const breathingQuats: Quat[] = []
1469
+ if (upperBoundIndexMinusOne < 0) continue
1497
1470
 
1498
- for (const bone of bones) {
1499
- const baseRot = this.breathingBaseRotations.get(bone)
1500
- if (!baseRot) continue
1471
+ const timeB = keyFrames[upperBoundIndex]?.time
1472
+ const boneFrameA = keyFrames[upperBoundIndexMinusOne].boneFrame
1501
1473
 
1502
- // Get rotation range for this bone (per-bone or default)
1503
- const rotation = rotationRanges?.[bone] ?? defaultRotation
1474
+ if (timeB === undefined) {
1475
+ // Last keyframe or beyond - use the last keyframe value
1476
+ boneNamesToRotate.push(boneName)
1477
+ rotationsToApply.push(boneFrameA.rotation)
1478
+ boneNamesToMove.push(boneName)
1479
+ translationsToApply.push(boneFrameA.translation)
1480
+ } else {
1481
+ // Interpolate between two keyframes
1482
+ const timeA = keyFrames[upperBoundIndexMinusOne].time
1483
+ const boneFrameB = keyFrames[upperBoundIndex].boneFrame
1484
+ const gradient = (clampedFrameTime - timeA) / (timeB - timeA)
1485
+
1486
+ // Interpolate rotation using Bezier
1487
+ const interp = boneFrameB.interpolation
1488
+ const rotWeight = bezierInterpolate(
1489
+ interp[0] / 127, // x1
1490
+ interp[1] / 127, // x2
1491
+ interp[2] / 127, // y1
1492
+ interp[3] / 127, // y2
1493
+ gradient
1494
+ )
1495
+ const interpolatedRotation = Quat.slerp(boneFrameA.rotation, boneFrameB.rotation, rotWeight)
1496
+
1497
+ // Interpolate translation using Bezier (separate curves for X, Y, Z)
1498
+ // VMD interpolation layout (from reference, 4x4 grid, row-major):
1499
+ // Row 0: X_x1, Y_x1, phy1, phy2,
1500
+ // Row 1: X_y1, Y_y1, Z_y1, R_y1,
1501
+ // Row 2: X_x2, Y_x2, Z_x2, R_x2,
1502
+ // Row 3: X_y2, Y_y2, Z_y2, R_y2,
1503
+ // Row 4: Y_x1, Z_x1, R_x1, X_y1,
1504
+ // Row 5: Y_y1, Z_y1, R_y1, X_x2,
1505
+ // Row 6: Y_x2, Z_x2, R_x2, X_y2,
1506
+ // Row 7: Y_y2, Z_y2, R_y2, 00,
1507
+ // Row 8: Z_x1, R_x1, X_y1, Y_y1,
1508
+ // Row 9: Z_y1, R_y1, X_x2, Y_x2,
1509
+ // Row 10: Z_x2, R_x2, X_y2, Y_y2,
1510
+ // Row 11: Z_y2, R_y2, 00, 00,
1511
+ // Row 12: R_x1, X_y1, Y_y1, Z_y1,
1512
+ // Row 13: R_y1, X_x2, Y_x2, Z_x2,
1513
+ // Row 14: R_x2, X_y2, Y_y2, Z_y2,
1514
+ // Row 15: R_y2, 00, 00, 00
1515
+ // For rotation: R_x1=16, R_y1=20, R_x2=24, R_y2=28
1516
+ // For position X: X_x1=0, X_y1=4, X_x2=8, X_y2=12
1517
+ // For position Y: Y_x1=16, Y_y1=20, Y_x2=24, Y_y2=28
1518
+ // For position Z: Z_x1=32, Z_y1=36, Z_x2=40, Z_y2=44
1519
+ const xWeight = bezierInterpolate(
1520
+ interp[0] / 127, // X_x1
1521
+ interp[8] / 127, // X_x2
1522
+ interp[4] / 127, // X_y1
1523
+ interp[12] / 127, // X_y2
1524
+ gradient
1525
+ )
1526
+ const yWeight = bezierInterpolate(
1527
+ interp[16] / 127, // Y_x1
1528
+ interp[24] / 127, // Y_x2
1529
+ interp[20] / 127, // Y_y1
1530
+ interp[28] / 127, // Y_y2
1531
+ gradient
1532
+ )
1533
+ const zWeight = bezierInterpolate(
1534
+ interp[32] / 127, // Z_x1
1535
+ interp[40] / 127, // Z_x2
1536
+ interp[36] / 127, // Z_y1
1537
+ interp[44] / 127, // Z_y2
1538
+ gradient
1539
+ )
1504
1540
 
1505
- // Oscillate around base rotation with the bone's rotation range
1506
- // isInhale: base * rotation, exhale: base * (-rotation)
1507
- const oscillationRot = Quat.fromEuler(isInhale ? rotation : -rotation, 0, 0)
1508
- const finalRot = baseRot.multiply(oscillationRot)
1541
+ const interpolatedTranslation = new Vec3(
1542
+ boneFrameA.translation.x + (boneFrameB.translation.x - boneFrameA.translation.x) * xWeight,
1543
+ boneFrameA.translation.y + (boneFrameB.translation.y - boneFrameA.translation.y) * yWeight,
1544
+ boneFrameA.translation.z + (boneFrameB.translation.z - boneFrameA.translation.z) * zWeight
1545
+ )
1509
1546
 
1510
- breathingBoneNames.push(bone)
1511
- breathingQuats.push(finalRot)
1547
+ boneNamesToRotate.push(boneName)
1548
+ rotationsToApply.push(interpolatedRotation)
1549
+ boneNamesToMove.push(boneName)
1550
+ translationsToApply.push(interpolatedTranslation)
1512
1551
  }
1552
+ }
1513
1553
 
1514
- if (breathingBoneNames.length > 0) {
1515
- this.rotateBones(breathingBoneNames, breathingQuats, halfCycleMs)
1554
+ // Helper to find upper bound index for morph frames
1555
+ const upperBoundMorphIndex = (time: number, keyFrames: Array<{ morphFrame: MorphFrame; time: number }>): number => {
1556
+ let left = 0
1557
+ let right = keyFrames.length
1558
+ while (left < right) {
1559
+ const mid = Math.floor((left + right) / 2)
1560
+ if (keyFrames[mid].time <= time) {
1561
+ left = mid + 1
1562
+ } else {
1563
+ right = mid
1564
+ }
1516
1565
  }
1566
+ return left
1567
+ }
1568
+
1569
+ // Process each morph track
1570
+ for (const [morphName, keyFrames] of this.morphTracks.entries()) {
1571
+ if (keyFrames.length === 0) continue
1572
+
1573
+ // Clamp frame time to track range
1574
+ const startTime = keyFrames[0].time
1575
+ const endTime = keyFrames[keyFrames.length - 1].time
1576
+ const clampedFrameTime = Math.max(startTime, Math.min(endTime, frameTime))
1517
1577
 
1518
- this.breathingTimeout = window.setTimeout(() => animate(!isInhale), halfCycleMs)
1578
+ const upperBoundIndex = upperBoundMorphIndex(clampedFrameTime, keyFrames)
1579
+ const upperBoundIndexMinusOne = upperBoundIndex - 1
1580
+
1581
+ if (upperBoundIndexMinusOne < 0) continue
1582
+
1583
+ const timeB = keyFrames[upperBoundIndex]?.time
1584
+ const morphFrameA = keyFrames[upperBoundIndexMinusOne].morphFrame
1585
+
1586
+ if (timeB === undefined) {
1587
+ // Last keyframe or beyond - use the last keyframe value
1588
+ morphNamesToSet.push(morphName)
1589
+ morphWeightsToSet.push(morphFrameA.weight)
1590
+ } else {
1591
+ // Linear interpolation between two keyframes
1592
+ const timeA = keyFrames[upperBoundIndexMinusOne].time
1593
+ const morphFrameB = keyFrames[upperBoundIndex].morphFrame
1594
+ const gradient = (clampedFrameTime - timeA) / (timeB - timeA)
1595
+ const interpolatedWeight = morphFrameA.weight + (morphFrameB.weight - morphFrameA.weight) * gradient
1596
+
1597
+ morphNamesToSet.push(morphName)
1598
+ morphWeightsToSet.push(interpolatedWeight)
1599
+ }
1519
1600
  }
1520
1601
 
1521
- // Start breathing from exhale position (closer to base) to minimize initial movement
1522
- animate(false)
1602
+ // Apply all rotations, translations, and morphs at once (no tweening - direct application)
1603
+ if (boneNamesToRotate.length > 0) {
1604
+ this.rotateBones(boneNamesToRotate, rotationsToApply, 0)
1605
+ }
1606
+ if (boneNamesToMove.length > 0) {
1607
+ this.moveBones(boneNamesToMove, translationsToApply, 0)
1608
+ }
1609
+ if (morphNamesToSet.length > 0) {
1610
+ for (let i = 0; i < morphNamesToSet.length; i++) {
1611
+ this.setMorphWeight(morphNamesToSet[i], morphWeightsToSet[i], 0)
1612
+ }
1613
+ }
1523
1614
  }
1524
1615
 
1525
1616
  public getStats(): EngineStats {
@@ -1553,7 +1644,6 @@ export class Engine {
1553
1644
  public dispose() {
1554
1645
  this.stopRenderLoop()
1555
1646
  this.stopAnimation()
1556
- this.stopBreathing()
1557
1647
  if (this.camera) this.camera.detachControl()
1558
1648
  if (this.resizeObserver) {
1559
1649
  this.resizeObserver.disconnect()
@@ -1578,6 +1668,11 @@ export class Engine {
1578
1668
  this.currentModel?.rotateBones(bones, rotations, durationMs)
1579
1669
  }
1580
1670
 
1671
+ // moveBones now takes relative translations (VMD-style) by default
1672
+ public moveBones(bones: string[], relativeTranslations: Vec3[], durationMs?: number) {
1673
+ this.currentModel?.moveBones(bones, relativeTranslations, durationMs)
1674
+ }
1675
+
1581
1676
  public setMorphWeight(name: string, weight: number, durationMs?: number): void {
1582
1677
  if (!this.currentModel) return
1583
1678
  this.currentModel.setMorphWeight(name, weight, durationMs)
@@ -2008,6 +2103,21 @@ export class Engine {
2008
2103
  this.updateCameraUniforms()
2009
2104
  this.updateRenderTarget()
2010
2105
 
2106
+ // Animate VMD animation if playing
2107
+ if (this.playingAnimation && this.currentModel && this.animationDuration > 0) {
2108
+ const elapsedSeconds = (currentTime - this.animationStartTime) / 1000
2109
+ if (elapsedSeconds >= this.animationDuration) {
2110
+ // Animation has ended, stop it
2111
+ this.stopAnimation()
2112
+ } else {
2113
+ const frameTime = elapsedSeconds
2114
+ this.animate(frameTime)
2115
+ }
2116
+ } else if (this.playingAnimation && this.animationDuration <= 0) {
2117
+ // Animation has no duration or invalid, stop it immediately
2118
+ this.stopAnimation()
2119
+ }
2120
+
2011
2121
  // Update model pose first (this may update morph weights via tweens)
2012
2122
  // We need to do this before creating the encoder to ensure vertex buffer is ready
2013
2123
  if (this.currentModel) {
@@ -2028,9 +2138,10 @@ export class Engine {
2028
2138
 
2029
2139
  this.updateModelPose(deltaTime, encoder)
2030
2140
 
2031
- // Hide model if animation is loaded but not playing yet (prevents A-pose flash)
2032
- // Still update physics and poses, just don't render visually
2033
- if (this.hasAnimation && !this.playingAnimation) {
2141
+ // Hide model if animation is loaded but hasn't started playing yet (prevents A-pose flash)
2142
+ // Once animation has played (even if it stopped), continue rendering normally
2143
+ // Still update physics and poses, just don't render visually before first play
2144
+ if (this.hasAnimation && !this.playingAnimation && this.animationStartTime === 0) {
2034
2145
  // Submit encoder to ensure matrices are uploaded and physics initializes
2035
2146
  this.device.queue.submit([encoder.finish()])
2036
2147
  return