reze-engine 0.3.0 → 0.3.1

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,10 +1,9 @@
1
- import { bezierInterpolate } from "./bezier-interpolate"
2
1
  import { Camera } from "./camera"
3
- import { Mat4, Quat, Vec3 } from "./math"
2
+ import { Quat, Vec3 } from "./math"
4
3
  import { Model } from "./model"
5
4
  import { PmxLoader } from "./pmx-loader"
6
5
  import { Physics } from "./physics"
7
- import { BoneFrame, MorphFrame, VMDKeyFrame, VMDLoader } from "./vmd-loader"
6
+ import { AnimationPose, Player } from "./player"
8
7
 
9
8
  export type EngineOptions = {
10
9
  ambientColor?: Vec3
@@ -134,14 +133,9 @@ export class Engine {
134
133
  private animationFrameId: number | null = null
135
134
  private renderLoopCallback: (() => void) | null = null
136
135
 
137
- private animationFrames: VMDKeyFrame[] = []
138
- private animationTimeouts: number[] = []
136
+ private player: Player = new Player()
139
137
  private hasAnimation = false // Set to true when loadAnimation is called
140
- private playingAnimation = false // Set to true when playAnimation is called
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()
138
+ private animationStartTime: number = 0 // Track when animation first started (for A-pose prevention)
145
139
 
146
140
  constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
147
141
  this.canvas = canvas
@@ -1273,109 +1267,71 @@ export class Engine {
1273
1267
  this.lightData[3] = 0.0 // Padding for vec3f alignment
1274
1268
  }
1275
1269
 
1276
- public async loadAnimation(url: string) {
1277
- const frames = await VMDLoader.load(url)
1278
- this.animationFrames = frames
1270
+ public async loadAnimation(url: string, audioUrl?: string) {
1271
+ await this.player.loadVmd(url, audioUrl)
1279
1272
  this.hasAnimation = true
1280
- }
1281
-
1282
- public playAnimation() {
1283
- if (this.animationFrames.length === 0) return
1284
1273
 
1285
- this.stopAnimation()
1286
-
1287
- this.playingAnimation = true
1274
+ // Show first frame (time 0) immediately
1275
+ if (this.currentModel) {
1276
+ const initialPose = this.player.getPoseAtTime(0)
1277
+ this.applyPose(initialPose)
1288
1278
 
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
- })
1279
+ // Reset bones without time 0 keyframes
1280
+ const skeleton = this.currentModel.getSkeleton()
1281
+ const bonesWithPose = new Set(initialPose.boneRotations.keys())
1282
+ const bonesToReset: string[] = []
1283
+ for (const bone of skeleton.bones) {
1284
+ if (!bonesWithPose.has(bone.name)) {
1285
+ bonesToReset.push(bone.name)
1286
+ }
1297
1287
  }
1298
- }
1299
1288
 
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, [])
1289
+ if (bonesToReset.length > 0) {
1290
+ const identityQuat = new Quat(0, 0, 0, 1)
1291
+ const identityQuats = new Array(bonesToReset.length).fill(identityQuat)
1292
+ this.rotateBones(bonesToReset, identityQuats, 0)
1304
1293
  }
1305
- boneKeyFramesByBone.get(boneFrame.boneName)!.push({ boneFrame, time })
1306
- }
1307
1294
 
1308
- for (const keyFrames of boneKeyFramesByBone.values()) {
1309
- keyFrames.sort((a, b) => a.time - b.time)
1310
- }
1295
+ // Update model pose and physics
1296
+ this.currentModel.evaluatePose()
1311
1297
 
1312
- // Process morph frames
1313
- const allMorphKeyFrames: Array<{ morphFrame: MorphFrame; time: number }> = []
1314
- for (const keyFrame of this.animationFrames) {
1315
- for (const morphFrame of keyFrame.morphFrames) {
1316
- allMorphKeyFrames.push({
1317
- morphFrame,
1318
- time: keyFrame.time,
1319
- })
1320
- }
1321
- }
1298
+ if (this.physics) {
1299
+ const worldMats = this.currentModel.getBoneWorldMatrices()
1300
+ this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices())
1322
1301
 
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, [])
1302
+ // Upload matrices immediately
1303
+ this.device.queue.writeBuffer(
1304
+ this.worldMatrixBuffer!,
1305
+ 0,
1306
+ worldMats.buffer,
1307
+ worldMats.byteOffset,
1308
+ worldMats.byteLength
1309
+ )
1310
+ const encoder = this.device.createCommandEncoder()
1311
+ this.computeSkinMatrices(encoder)
1312
+ this.device.queue.submit([encoder.finish()])
1327
1313
  }
1328
- morphKeyFramesByMorph.get(morphFrame.morphName)!.push({ morphFrame, time })
1329
1314
  }
1315
+ }
1330
1316
 
1331
- for (const keyFrames of morphKeyFramesByMorph.values()) {
1332
- keyFrames.sort((a, b) => a.time - b.time)
1333
- }
1317
+ public playAnimation() {
1318
+ if (!this.hasAnimation || !this.currentModel) return
1334
1319
 
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
- }
1355
- }
1356
- }
1357
- this.animationDuration = maxFrameTime > 0 ? maxFrameTime : 0
1358
- this.animationStartTime = performance.now()
1320
+ const wasPaused = this.player.isPausedState()
1321
+ const wasPlaying = this.player.isPlayingState()
1359
1322
 
1360
- // Initialize bones and morphs to time 0 pose
1361
- if (this.currentModel) {
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
- }
1373
- }
1323
+ // Only reset pose and physics if starting from beginning (not resuming)
1324
+ if (!wasPlaying && !wasPaused) {
1325
+ // Get initial pose at time 0
1326
+ const initialPose = this.player.getPoseAtTime(0)
1327
+ this.applyPose(initialPose)
1374
1328
 
1375
1329
  // Reset bones without time 0 keyframes
1330
+ const skeleton = this.currentModel.getSkeleton()
1331
+ const bonesWithPose = new Set(initialPose.boneRotations.keys())
1376
1332
  const bonesToReset: string[] = []
1377
1333
  for (const bone of skeleton.bones) {
1378
- if (!bonesWithTime0.has(bone.name)) {
1334
+ if (!bonesWithPose.has(bone.name)) {
1379
1335
  bonesToReset.push(bone.name)
1380
1336
  }
1381
1337
  }
@@ -1386,14 +1342,6 @@ export class Engine {
1386
1342
  this.rotateBones(bonesToReset, identityQuats, 0)
1387
1343
  }
1388
1344
 
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
-
1397
1345
  // Reset physics immediately and upload matrices to prevent A-pose flash
1398
1346
  if (this.physics) {
1399
1347
  this.currentModel.evaluatePose()
@@ -1414,202 +1362,79 @@ export class Engine {
1414
1362
  this.device.queue.submit([encoder.finish()])
1415
1363
  }
1416
1364
  }
1417
- }
1418
1365
 
1419
- public stopAnimation() {
1420
- for (const timeoutId of this.animationTimeouts) {
1421
- clearTimeout(timeoutId)
1366
+ // Start playback (or resume if paused)
1367
+ this.player.play()
1368
+ if (this.animationStartTime === 0) {
1369
+ this.animationStartTime = performance.now()
1422
1370
  }
1423
- this.animationTimeouts = []
1424
- this.playingAnimation = false
1425
- this.boneTracks.clear()
1426
- this.morphTracks.clear()
1427
1371
  }
1428
1372
 
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 {
1433
- if (!this.currentModel) return
1434
-
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
- }
1446
- }
1447
- return left
1448
- }
1449
-
1450
- const boneNamesToRotate: string[] = []
1451
- const rotationsToApply: Quat[] = []
1452
- const boneNamesToMove: string[] = []
1453
- const translationsToApply: Vec3[] = []
1454
- const morphNamesToSet: string[] = []
1455
- const morphWeightsToSet: number[] = []
1373
+ public stopAnimation() {
1374
+ this.player.stop()
1375
+ }
1456
1376
 
1457
- // Process each bone track
1458
- for (const [boneName, keyFrames] of this.boneTracks.entries()) {
1459
- if (keyFrames.length === 0) continue
1377
+ public pauseAnimation() {
1378
+ this.player.pause()
1379
+ }
1460
1380
 
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))
1381
+ public seekAnimation(time: number) {
1382
+ if (!this.currentModel || !this.hasAnimation) return
1465
1383
 
1466
- const upperBoundIndex = upperBoundFrameIndex(clampedFrameTime, keyFrames)
1467
- const upperBoundIndexMinusOne = upperBoundIndex - 1
1384
+ this.player.seek(time)
1468
1385
 
1469
- if (upperBoundIndexMinusOne < 0) continue
1386
+ // Immediately apply pose at seeked time
1387
+ const pose = this.player.getPoseAtTime(time)
1388
+ this.applyPose(pose)
1470
1389
 
1471
- const timeB = keyFrames[upperBoundIndex]?.time
1472
- const boneFrameA = keyFrames[upperBoundIndexMinusOne].boneFrame
1390
+ // Update model pose and physics
1391
+ this.currentModel.evaluatePose()
1473
1392
 
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
- )
1540
-
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
- )
1546
-
1547
- boneNamesToRotate.push(boneName)
1548
- rotationsToApply.push(interpolatedRotation)
1549
- boneNamesToMove.push(boneName)
1550
- translationsToApply.push(interpolatedTranslation)
1551
- }
1552
- }
1553
-
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
- }
1565
- }
1566
- return left
1393
+ if (this.physics) {
1394
+ const worldMats = this.currentModel.getBoneWorldMatrices()
1395
+ this.physics.reset(worldMats, this.currentModel.getBoneInverseBindMatrices())
1396
+
1397
+ // Upload matrices immediately
1398
+ this.device.queue.writeBuffer(
1399
+ this.worldMatrixBuffer!,
1400
+ 0,
1401
+ worldMats.buffer,
1402
+ worldMats.byteOffset,
1403
+ worldMats.byteLength
1404
+ )
1405
+ const encoder = this.device.createCommandEncoder()
1406
+ this.computeSkinMatrices(encoder)
1407
+ this.device.queue.submit([encoder.finish()])
1567
1408
  }
1409
+ }
1568
1410
 
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))
1577
-
1578
- const upperBoundIndex = upperBoundMorphIndex(clampedFrameTime, keyFrames)
1579
- const upperBoundIndexMinusOne = upperBoundIndex - 1
1580
-
1581
- if (upperBoundIndexMinusOne < 0) continue
1411
+ public getAnimationProgress() {
1412
+ return this.player.getProgress()
1413
+ }
1582
1414
 
1583
- const timeB = keyFrames[upperBoundIndex]?.time
1584
- const morphFrameA = keyFrames[upperBoundIndexMinusOne].morphFrame
1415
+ /**
1416
+ * Apply animation pose to model
1417
+ */
1418
+ private applyPose(pose: AnimationPose): void {
1419
+ if (!this.currentModel) return
1585
1420
 
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
- }
1421
+ // Apply bone rotations
1422
+ if (pose.boneRotations.size > 0) {
1423
+ const boneNames = Array.from(pose.boneRotations.keys())
1424
+ const rotations = Array.from(pose.boneRotations.values())
1425
+ this.rotateBones(boneNames, rotations, 0)
1600
1426
  }
1601
1427
 
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)
1428
+ // Apply bone translations
1429
+ if (pose.boneTranslations.size > 0) {
1430
+ const boneNames = Array.from(pose.boneTranslations.keys())
1431
+ const translations = Array.from(pose.boneTranslations.values())
1432
+ this.moveBones(boneNames, translations, 0)
1608
1433
  }
1609
- if (morphNamesToSet.length > 0) {
1610
- for (let i = 0; i < morphNamesToSet.length; i++) {
1611
- this.setMorphWeight(morphNamesToSet[i], morphWeightsToSet[i], 0)
1612
- }
1434
+
1435
+ // Apply morph weights
1436
+ for (const [morphName, weight] of pose.morphWeights.entries()) {
1437
+ this.setMorphWeight(morphName, weight, 0)
1613
1438
  }
1614
1439
  }
1615
1440
 
@@ -2104,18 +1929,11 @@ export class Engine {
2104
1929
  this.updateRenderTarget()
2105
1930
 
2106
1931
  // 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)
1932
+ if (this.hasAnimation && this.currentModel) {
1933
+ const pose = this.player.update(currentTime)
1934
+ if (pose) {
1935
+ this.applyPose(pose)
2115
1936
  }
2116
- } else if (this.playingAnimation && this.animationDuration <= 0) {
2117
- // Animation has no duration or invalid, stop it immediately
2118
- this.stopAnimation()
2119
1937
  }
2120
1938
 
2121
1939
  // Update model pose first (this may update morph weights via tweens)
@@ -2141,7 +1959,7 @@ export class Engine {
2141
1959
  // Hide model if animation is loaded but hasn't started playing yet (prevents A-pose flash)
2142
1960
  // Once animation has played (even if it stopped), continue rendering normally
2143
1961
  // Still update physics and poses, just don't render visually before first play
2144
- if (this.hasAnimation && !this.playingAnimation && this.animationStartTime === 0) {
1962
+ if (this.hasAnimation && !this.player.isPlayingState() && this.animationStartTime === 0) {
2145
1963
  // Submit encoder to ensure matrices are uploaded and physics initializes
2146
1964
  this.device.queue.submit([encoder.finish()])
2147
1965
  return