reze-engine 0.2.19 → 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
@@ -3,7 +3,7 @@ import { Quat, Vec3 } from "./math"
3
3
  import { Model } from "./model"
4
4
  import { PmxLoader } from "./pmx-loader"
5
5
  import { Physics } from "./physics"
6
- import { VMDKeyFrame, VMDLoader } from "./vmd-loader"
6
+ import { AnimationPose, Player } from "./player"
7
7
 
8
8
  export type EngineOptions = {
9
9
  ambientColor?: Vec3
@@ -24,12 +24,6 @@ interface DrawCall {
24
24
  bindGroup: GPUBindGroup
25
25
  }
26
26
 
27
- type BoneKeyFrame = {
28
- boneName: string
29
- time: number
30
- rotation: Quat
31
- }
32
-
33
27
  export class Engine {
34
28
  private canvas: HTMLCanvasElement
35
29
  private device!: GPUDevice
@@ -79,7 +73,6 @@ export class Engine {
79
73
  private static readonly DEFAULT_RIM_LIGHT_INTENSITY = 0.45
80
74
  private static readonly DEFAULT_CAMERA_DISTANCE = 26.6
81
75
  private static readonly DEFAULT_CAMERA_TARGET = new Vec3(0, 12.5, 0)
82
- private static readonly HAIR_OVER_EYES_ALPHA = 0.5
83
76
  private static readonly TRANSPARENCY_EPSILON = 0.001
84
77
  private static readonly STATS_FPS_UPDATE_INTERVAL_MS = 1000
85
78
  private static readonly STATS_FRAME_TIME_ROUNDING = 100
@@ -140,12 +133,9 @@ export class Engine {
140
133
  private animationFrameId: number | null = null
141
134
  private renderLoopCallback: (() => void) | null = null
142
135
 
143
- private animationFrames: VMDKeyFrame[] = []
144
- private animationTimeouts: number[] = []
136
+ private player: Player = new Player()
145
137
  private hasAnimation = false // Set to true when loadAnimation is called
146
- private playingAnimation = false // Set to true when playAnimation is called
147
- private breathingTimeout: number | null = null
148
- private breathingBaseRotations: Map<string, Quat> = new Map()
138
+ private animationStartTime: number = 0 // Track when animation first started (for A-pose prevention)
149
139
 
150
140
  constructor(canvas: HTMLCanvasElement, options?: EngineOptions) {
151
141
  this.canvas = canvas
@@ -1277,84 +1267,71 @@ export class Engine {
1277
1267
  this.lightData[3] = 0.0 // Padding for vec3f alignment
1278
1268
  }
1279
1269
 
1280
- public async loadAnimation(url: string) {
1281
- const frames = await VMDLoader.load(url)
1282
- this.animationFrames = frames
1270
+ public async loadAnimation(url: string, audioUrl?: string) {
1271
+ await this.player.loadVmd(url, audioUrl)
1283
1272
  this.hasAnimation = true
1284
- }
1285
1273
 
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
- }) {
1290
- if (this.animationFrames.length === 0) return
1291
-
1292
- this.stopAnimation()
1293
- this.stopBreathing()
1294
- 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)
1295
1278
 
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
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
+ }
1287
+ }
1300
1288
 
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
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)
1307
1293
  }
1308
- }
1309
1294
 
1310
- const breathDuration = options?.breathDuration ?? 4000
1295
+ // Update model pose and physics
1296
+ this.currentModel.evaluatePose()
1311
1297
 
1312
- const allBoneKeyFrames: BoneKeyFrame[] = []
1313
- for (const keyFrame of this.animationFrames) {
1314
- for (const boneFrame of keyFrame.boneFrames) {
1315
- allBoneKeyFrames.push({
1316
- boneName: boneFrame.boneName,
1317
- time: keyFrame.time,
1318
- rotation: boneFrame.rotation,
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 boneKeyFramesByBone = new Map<string, BoneKeyFrame[]>()
1324
- for (const boneKeyFrame of allBoneKeyFrames) {
1325
- if (!boneKeyFramesByBone.has(boneKeyFrame.boneName)) {
1326
- boneKeyFramesByBone.set(boneKeyFrame.boneName, [])
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
- boneKeyFramesByBone.get(boneKeyFrame.boneName)!.push(boneKeyFrame)
1329
1314
  }
1315
+ }
1330
1316
 
1331
- for (const keyFrames of boneKeyFramesByBone.values()) {
1332
- keyFrames.sort((a, b) => a.time - b.time)
1333
- }
1317
+ public playAnimation() {
1318
+ if (!this.hasAnimation || !this.currentModel) return
1334
1319
 
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)
1344
- }
1345
- }
1320
+ const wasPaused = this.player.isPausedState()
1321
+ const wasPlaying = this.player.isPlayingState()
1346
1322
 
1347
- 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)
1352
- }
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)
1353
1328
 
1329
+ // Reset bones without time 0 keyframes
1354
1330
  const skeleton = this.currentModel.getSkeleton()
1331
+ const bonesWithPose = new Set(initialPose.boneRotations.keys())
1355
1332
  const bonesToReset: string[] = []
1356
1333
  for (const bone of skeleton.bones) {
1357
- if (!bonesWithTime0.has(bone.name)) {
1334
+ if (!bonesWithPose.has(bone.name)) {
1358
1335
  bonesToReset.push(bone.name)
1359
1336
  }
1360
1337
  }
@@ -1385,141 +1362,80 @@ export class Engine {
1385
1362
  this.device.queue.submit([encoder.finish()])
1386
1363
  }
1387
1364
  }
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
1365
 
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)
1366
+ // Start playback (or resume if paused)
1367
+ this.player.play()
1368
+ if (this.animationStartTime === 0) {
1369
+ this.animationStartTime = performance.now()
1451
1370
  }
1452
1371
  }
1453
1372
 
1454
1373
  public stopAnimation() {
1455
- for (const timeoutId of this.animationTimeouts) {
1456
- clearTimeout(timeoutId)
1457
- }
1458
- this.animationTimeouts = []
1459
- this.playingAnimation = false
1374
+ this.player.stop()
1460
1375
  }
1461
1376
 
1462
- private stopBreathing() {
1463
- if (this.breathingTimeout !== null) {
1464
- clearTimeout(this.breathingTimeout)
1465
- this.breathingTimeout = null
1466
- }
1467
- this.breathingBaseRotations.clear()
1377
+ public pauseAnimation() {
1378
+ this.player.pause()
1468
1379
  }
1469
1380
 
1470
- private startBreathing(
1471
- bones: string[],
1472
- baseRotations: Map<string, Quat>,
1473
- rotationRanges?: Record<string, number>,
1474
- durationMs: number = 4000
1475
- ) {
1476
- if (!this.currentModel) return
1381
+ public seekAnimation(time: number) {
1382
+ if (!this.currentModel || !this.hasAnimation) return
1477
1383
 
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)
1484
- }
1485
- }
1486
-
1487
- const halfCycleMs = durationMs / 2
1488
- const defaultRotation = 0.02 // Default rotation range if not specified per bone
1489
-
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
1384
+ this.player.seek(time)
1494
1385
 
1495
- const breathingBoneNames: string[] = []
1496
- const breathingQuats: Quat[] = []
1386
+ // Immediately apply pose at seeked time
1387
+ const pose = this.player.getPoseAtTime(time)
1388
+ this.applyPose(pose)
1497
1389
 
1498
- for (const bone of bones) {
1499
- const baseRot = this.breathingBaseRotations.get(bone)
1500
- if (!baseRot) continue
1390
+ // Update model pose and physics
1391
+ this.currentModel.evaluatePose()
1501
1392
 
1502
- // Get rotation range for this bone (per-bone or default)
1503
- const rotation = rotationRanges?.[bone] ?? defaultRotation
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()])
1408
+ }
1409
+ }
1504
1410
 
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)
1411
+ public getAnimationProgress() {
1412
+ return this.player.getProgress()
1413
+ }
1509
1414
 
1510
- breathingBoneNames.push(bone)
1511
- breathingQuats.push(finalRot)
1512
- }
1415
+ /**
1416
+ * Apply animation pose to model
1417
+ */
1418
+ private applyPose(pose: AnimationPose): void {
1419
+ if (!this.currentModel) return
1513
1420
 
1514
- if (breathingBoneNames.length > 0) {
1515
- this.rotateBones(breathingBoneNames, breathingQuats, halfCycleMs)
1516
- }
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)
1426
+ }
1517
1427
 
1518
- this.breathingTimeout = window.setTimeout(() => animate(!isInhale), halfCycleMs)
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)
1519
1433
  }
1520
1434
 
1521
- // Start breathing from exhale position (closer to base) to minimize initial movement
1522
- animate(false)
1435
+ // Apply morph weights
1436
+ for (const [morphName, weight] of pose.morphWeights.entries()) {
1437
+ this.setMorphWeight(morphName, weight, 0)
1438
+ }
1523
1439
  }
1524
1440
 
1525
1441
  public getStats(): EngineStats {
@@ -1553,7 +1469,6 @@ export class Engine {
1553
1469
  public dispose() {
1554
1470
  this.stopRenderLoop()
1555
1471
  this.stopAnimation()
1556
- this.stopBreathing()
1557
1472
  if (this.camera) this.camera.detachControl()
1558
1473
  if (this.resizeObserver) {
1559
1474
  this.resizeObserver.disconnect()
@@ -1578,6 +1493,11 @@ export class Engine {
1578
1493
  this.currentModel?.rotateBones(bones, rotations, durationMs)
1579
1494
  }
1580
1495
 
1496
+ // moveBones now takes relative translations (VMD-style) by default
1497
+ public moveBones(bones: string[], relativeTranslations: Vec3[], durationMs?: number) {
1498
+ this.currentModel?.moveBones(bones, relativeTranslations, durationMs)
1499
+ }
1500
+
1581
1501
  public setMorphWeight(name: string, weight: number, durationMs?: number): void {
1582
1502
  if (!this.currentModel) return
1583
1503
  this.currentModel.setMorphWeight(name, weight, durationMs)
@@ -2008,6 +1928,14 @@ export class Engine {
2008
1928
  this.updateCameraUniforms()
2009
1929
  this.updateRenderTarget()
2010
1930
 
1931
+ // Animate VMD animation if playing
1932
+ if (this.hasAnimation && this.currentModel) {
1933
+ const pose = this.player.update(currentTime)
1934
+ if (pose) {
1935
+ this.applyPose(pose)
1936
+ }
1937
+ }
1938
+
2011
1939
  // Update model pose first (this may update morph weights via tweens)
2012
1940
  // We need to do this before creating the encoder to ensure vertex buffer is ready
2013
1941
  if (this.currentModel) {
@@ -2028,9 +1956,10 @@ export class Engine {
2028
1956
 
2029
1957
  this.updateModelPose(deltaTime, encoder)
2030
1958
 
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) {
1959
+ // Hide model if animation is loaded but hasn't started playing yet (prevents A-pose flash)
1960
+ // Once animation has played (even if it stopped), continue rendering normally
1961
+ // Still update physics and poses, just don't render visually before first play
1962
+ if (this.hasAnimation && !this.player.isPlayingState() && this.animationStartTime === 0) {
2034
1963
  // Submit encoder to ensure matrices are uploaded and physics initializes
2035
1964
  this.device.queue.submit([encoder.finish()])
2036
1965
  return