reze-engine 0.2.18 → 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
@@ -116,6 +110,7 @@ export class Engine {
116
110
  private physics: Physics | null = null
117
111
  private materialSampler!: GPUSampler
118
112
  private textureCache = new Map<string, GPUTexture>()
113
+ private vertexBufferNeedsUpdate = false
119
114
  // Draw lists
120
115
  private opaqueDraws: DrawCall[] = []
121
116
  private eyeDraws: DrawCall[] = []
@@ -143,8 +138,10 @@ export class Engine {
143
138
  private animationTimeouts: number[] = []
144
139
  private hasAnimation = false // Set to true when loadAnimation is called
145
140
  private playingAnimation = false // Set to true when playAnimation is called
146
- private breathingTimeout: number | null = null
147
- 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()
148
145
 
149
146
  constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
150
147
  this.canvas = canvas
@@ -1282,75 +1279,100 @@ export class Engine {
1282
1279
  this.hasAnimation = true
1283
1280
  }
1284
1281
 
1285
- public playAnimation(options?: {
1286
- breathBones?: string[] | Record<string, number> // Array of bone names or map of bone name -> rotation range
1287
- breathDuration?: number // Breathing cycle duration in milliseconds
1288
- }) {
1282
+ public playAnimation() {
1289
1283
  if (this.animationFrames.length === 0) return
1290
1284
 
1291
1285
  this.stopAnimation()
1292
- this.stopBreathing()
1286
+
1293
1287
  this.playingAnimation = true
1294
1288
 
1295
- // Enable breathing if breathBones is provided
1296
- const enableBreath = options?.breathBones !== undefined && options.breathBones !== null
1297
- let breathBones: string[] = []
1298
- 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
+ }
1299
1299
 
1300
- if (enableBreath && options.breathBones) {
1301
- if (Array.isArray(options.breathBones)) {
1302
- breathBones = options.breathBones
1303
- } else {
1304
- breathBones = Object.keys(options.breathBones)
1305
- 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, [])
1306
1304
  }
1305
+ boneKeyFramesByBone.get(boneFrame.boneName)!.push({ boneFrame, time })
1307
1306
  }
1308
1307
 
1309
- const breathDuration = options?.breathDuration ?? 4000
1308
+ for (const keyFrames of boneKeyFramesByBone.values()) {
1309
+ keyFrames.sort((a, b) => a.time - b.time)
1310
+ }
1310
1311
 
1311
- const allBoneKeyFrames: BoneKeyFrame[] = []
1312
+ // Process morph frames
1313
+ const allMorphKeyFrames: Array<{ morphFrame: MorphFrame; time: number }> = []
1312
1314
  for (const keyFrame of this.animationFrames) {
1313
- for (const boneFrame of keyFrame.boneFrames) {
1314
- allBoneKeyFrames.push({
1315
- boneName: boneFrame.boneName,
1315
+ for (const morphFrame of keyFrame.morphFrames) {
1316
+ allMorphKeyFrames.push({
1317
+ morphFrame,
1316
1318
  time: keyFrame.time,
1317
- rotation: boneFrame.rotation,
1318
1319
  })
1319
1320
  }
1320
1321
  }
1321
1322
 
1322
- const boneKeyFramesByBone = new Map<string, BoneKeyFrame[]>()
1323
- for (const boneKeyFrame of allBoneKeyFrames) {
1324
- if (!boneKeyFramesByBone.has(boneKeyFrame.boneName)) {
1325
- 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, [])
1326
1327
  }
1327
- boneKeyFramesByBone.get(boneKeyFrame.boneName)!.push(boneKeyFrame)
1328
+ morphKeyFramesByMorph.get(morphFrame.morphName)!.push({ morphFrame, time })
1328
1329
  }
1329
1330
 
1330
- for (const keyFrames of boneKeyFramesByBone.values()) {
1331
+ for (const keyFrames of morphKeyFramesByMorph.values()) {
1331
1332
  keyFrames.sort((a, b) => a.time - b.time)
1332
1333
  }
1333
1334
 
1334
- const time0Rotations: Array<{ boneName: string; rotation: Quat }> = []
1335
- const bonesWithTime0 = new Set<string>()
1336
- for (const [boneName, keyFrames] of boneKeyFramesByBone.entries()) {
1337
- if (keyFrames.length > 0 && keyFrames[0].time === 0) {
1338
- time0Rotations.push({
1339
- boneName: boneName,
1340
- rotation: keyFrames[0].rotation,
1341
- })
1342
- 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
+ }
1343
1355
  }
1344
1356
  }
1357
+ this.animationDuration = maxFrameTime > 0 ? maxFrameTime : 0
1358
+ this.animationStartTime = performance.now()
1345
1359
 
1360
+ // Initialize bones and morphs to time 0 pose
1346
1361
  if (this.currentModel) {
1347
- if (time0Rotations.length > 0) {
1348
- const boneNames = time0Rotations.map((r) => r.boneName)
1349
- const rotations = time0Rotations.map((r) => r.rotation)
1350
- 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
+ }
1351
1373
  }
1352
1374
 
1353
- const skeleton = this.currentModel.getSkeleton()
1375
+ // Reset bones without time 0 keyframes
1354
1376
  const bonesToReset: string[] = []
1355
1377
  for (const bone of skeleton.bones) {
1356
1378
  if (!bonesWithTime0.has(bone.name)) {
@@ -1364,6 +1386,14 @@ export class Engine {
1364
1386
  this.rotateBones(bonesToReset, identityQuats, 0)
1365
1387
  }
1366
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
+
1367
1397
  // Reset physics immediately and upload matrices to prevent A-pose flash
1368
1398
  if (this.physics) {
1369
1399
  this.currentModel.evaluatePose()
@@ -1384,70 +1414,6 @@ export class Engine {
1384
1414
  this.device.queue.submit([encoder.finish()])
1385
1415
  }
1386
1416
  }
1387
- for (const [_, keyFrames] of boneKeyFramesByBone.entries()) {
1388
- for (let i = 0; i < keyFrames.length; i++) {
1389
- const boneKeyFrame = keyFrames[i]
1390
- const previousBoneKeyFrame = i > 0 ? keyFrames[i - 1] : null
1391
-
1392
- if (boneKeyFrame.time === 0) continue
1393
-
1394
- let durationMs = 0
1395
- if (i === 0) {
1396
- durationMs = boneKeyFrame.time * 1000
1397
- } else if (previousBoneKeyFrame) {
1398
- durationMs = (boneKeyFrame.time - previousBoneKeyFrame.time) * 1000
1399
- }
1400
-
1401
- const scheduleTime = i > 0 && previousBoneKeyFrame ? previousBoneKeyFrame.time : 0
1402
- const delayMs = scheduleTime * 1000
1403
-
1404
- if (delayMs <= 0) {
1405
- this.rotateBones([boneKeyFrame.boneName], [boneKeyFrame.rotation], durationMs)
1406
- } else {
1407
- const timeoutId = window.setTimeout(() => {
1408
- this.rotateBones([boneKeyFrame.boneName], [boneKeyFrame.rotation], durationMs)
1409
- }, delayMs)
1410
- this.animationTimeouts.push(timeoutId)
1411
- }
1412
- }
1413
- }
1414
-
1415
- // Setup breathing animation if enabled
1416
- if (enableBreath && this.currentModel) {
1417
- // Find the last frame time
1418
- let maxTime = 0
1419
- for (const keyFrame of this.animationFrames) {
1420
- if (keyFrame.time > maxTime) {
1421
- maxTime = keyFrame.time
1422
- }
1423
- }
1424
-
1425
- // Get last frame rotations directly from animation data for breathing bones
1426
- const lastFrameRotations = new Map<string, Quat>()
1427
- for (const bone of breathBones) {
1428
- const keyFrames = boneKeyFramesByBone.get(bone)
1429
- if (keyFrames && keyFrames.length > 0) {
1430
- // Find the rotation at the last frame time (closest keyframe <= maxTime)
1431
- let lastRotation: Quat | null = null
1432
- for (let i = keyFrames.length - 1; i >= 0; i--) {
1433
- if (keyFrames[i].time <= maxTime) {
1434
- lastRotation = keyFrames[i].rotation
1435
- break
1436
- }
1437
- }
1438
- if (lastRotation) {
1439
- lastFrameRotations.set(bone, lastRotation)
1440
- }
1441
- }
1442
- }
1443
-
1444
- // Start breathing after animation completes
1445
- // Use the last frame rotations directly from animation data (no need to capture from model)
1446
- const animationEndTime = maxTime * 1000 + 200 // Small buffer for final tweens to complete
1447
- this.breathingTimeout = window.setTimeout(() => {
1448
- this.startBreathing(breathBones, lastFrameRotations, breathRotationRanges, breathDuration)
1449
- }, animationEndTime)
1450
- }
1451
1417
  }
1452
1418
 
1453
1419
  public stopAnimation() {
@@ -1456,69 +1422,195 @@ export class Engine {
1456
1422
  }
1457
1423
  this.animationTimeouts = []
1458
1424
  this.playingAnimation = false
1425
+ this.boneTracks.clear()
1426
+ this.morphTracks.clear()
1459
1427
  }
1460
1428
 
1461
- private stopBreathing() {
1462
- if (this.breathingTimeout !== null) {
1463
- clearTimeout(this.breathingTimeout)
1464
- this.breathingTimeout = null
1465
- }
1466
- this.breathingBaseRotations.clear()
1467
- }
1468
-
1469
- private startBreathing(
1470
- bones: string[],
1471
- baseRotations: Map<string, Quat>,
1472
- rotationRanges?: Record<string, number>,
1473
- durationMs: number = 4000
1474
- ) {
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 {
1475
1433
  if (!this.currentModel) return
1476
1434
 
1477
- // Store base rotations directly from last frame of animation data
1478
- // These are the exact rotations from the animation - use them as-is
1479
- for (const bone of bones) {
1480
- const baseRot = baseRotations.get(bone)
1481
- if (baseRot) {
1482
- 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
+ }
1483
1446
  }
1447
+ return left
1484
1448
  }
1485
1449
 
1486
- const halfCycleMs = durationMs / 2
1487
- 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))
1488
1465
 
1489
- // Start breathing cycle - oscillate around exact base rotation (final pose)
1490
- // Each bone can have its own rotation range, or use default
1491
- const animate = (isInhale: boolean) => {
1492
- if (!this.currentModel) return
1466
+ const upperBoundIndex = upperBoundFrameIndex(clampedFrameTime, keyFrames)
1467
+ const upperBoundIndexMinusOne = upperBoundIndex - 1
1493
1468
 
1494
- const breathingBoneNames: string[] = []
1495
- const breathingQuats: Quat[] = []
1469
+ if (upperBoundIndexMinusOne < 0) continue
1496
1470
 
1497
- for (const bone of bones) {
1498
- const baseRot = this.breathingBaseRotations.get(bone)
1499
- if (!baseRot) continue
1471
+ const timeB = keyFrames[upperBoundIndex]?.time
1472
+ const boneFrameA = keyFrames[upperBoundIndexMinusOne].boneFrame
1500
1473
 
1501
- // Get rotation range for this bone (per-bone or default)
1502
- 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
+ )
1503
1540
 
1504
- // Oscillate around base rotation with the bone's rotation range
1505
- // isInhale: base * rotation, exhale: base * (-rotation)
1506
- const oscillationRot = Quat.fromEuler(isInhale ? rotation : -rotation, 0, 0)
1507
- 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
+ )
1508
1546
 
1509
- breathingBoneNames.push(bone)
1510
- breathingQuats.push(finalRot)
1547
+ boneNamesToRotate.push(boneName)
1548
+ rotationsToApply.push(interpolatedRotation)
1549
+ boneNamesToMove.push(boneName)
1550
+ translationsToApply.push(interpolatedTranslation)
1511
1551
  }
1552
+ }
1512
1553
 
1513
- if (breathingBoneNames.length > 0) {
1514
- 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
+ }
1515
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))
1516
1577
 
1517
- 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
+ }
1518
1600
  }
1519
1601
 
1520
- // Start breathing from exhale position (closer to base) to minimize initial movement
1521
- 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
+ }
1522
1614
  }
1523
1615
 
1524
1616
  public getStats(): EngineStats {
@@ -1552,7 +1644,6 @@ export class Engine {
1552
1644
  public dispose() {
1553
1645
  this.stopRenderLoop()
1554
1646
  this.stopAnimation()
1555
- this.stopBreathing()
1556
1647
  if (this.camera) this.camera.detachControl()
1557
1648
  if (this.resizeObserver) {
1558
1649
  this.resizeObserver.disconnect()
@@ -1577,6 +1668,26 @@ export class Engine {
1577
1668
  this.currentModel?.rotateBones(bones, rotations, durationMs)
1578
1669
  }
1579
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
+
1676
+ public setMorphWeight(name: string, weight: number, durationMs?: number): void {
1677
+ if (!this.currentModel) return
1678
+ this.currentModel.setMorphWeight(name, weight, durationMs)
1679
+ if (!durationMs || durationMs === 0) {
1680
+ this.vertexBufferNeedsUpdate = true
1681
+ }
1682
+ }
1683
+
1684
+ private updateVertexBuffer(): void {
1685
+ if (!this.currentModel || !this.vertexBuffer) return
1686
+ const vertices = this.currentModel.getVertices()
1687
+ if (!vertices || vertices.length === 0) return
1688
+ this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices)
1689
+ }
1690
+
1580
1691
  // Step 7: Create vertex, index, and joint buffers
1581
1692
  private async setupModelBuffers(model: Model) {
1582
1693
  this.currentModel = model
@@ -1992,14 +2103,45 @@ export class Engine {
1992
2103
  this.updateCameraUniforms()
1993
2104
  this.updateRenderTarget()
1994
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
+
2121
+ // Update model pose first (this may update morph weights via tweens)
2122
+ // We need to do this before creating the encoder to ensure vertex buffer is ready
2123
+ if (this.currentModel) {
2124
+ const hasActiveMorphTweens = this.currentModel.evaluatePose()
2125
+ if (hasActiveMorphTweens) {
2126
+ this.vertexBufferNeedsUpdate = true
2127
+ }
2128
+ }
2129
+
2130
+ // Update vertex buffer if morphs changed
2131
+ if (this.vertexBufferNeedsUpdate) {
2132
+ this.updateVertexBuffer()
2133
+ this.vertexBufferNeedsUpdate = false
2134
+ }
2135
+
1995
2136
  // Use single encoder for both compute and render (reduces sync points)
1996
2137
  const encoder = this.device.createCommandEncoder()
1997
2138
 
1998
2139
  this.updateModelPose(deltaTime, encoder)
1999
2140
 
2000
- // Hide model if animation is loaded but not playing yet (prevents A-pose flash)
2001
- // Still update physics and poses, just don't render visually
2002
- 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) {
2003
2145
  // Submit encoder to ensure matrices are uploaded and physics initializes
2004
2146
  this.device.queue.submit([encoder.finish()])
2005
2147
  return
@@ -2169,7 +2311,8 @@ export class Engine {
2169
2311
  }
2170
2312
 
2171
2313
  private updateModelPose(deltaTime: number, encoder: GPUCommandEncoder) {
2172
- this.currentModel!.evaluatePose()
2314
+ // Note: evaluatePose is called earlier in render() to update vertex buffer before encoder creation
2315
+ // Here we just get the matrices and update physics/compute
2173
2316
  const worldMats = this.currentModel!.getBoneWorldMatrices()
2174
2317
 
2175
2318
  if (this.physics) {