reze-engine 0.10.0 → 0.10.2

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,9 +3,25 @@ import { Mat4, Vec3 } from "./math"
3
3
  import { Model } from "./model"
4
4
  import { PmxLoader } from "./pmx-loader"
5
5
  import { Physics, type PhysicsOptions } from "./physics"
6
+ import {
7
+ createFetchAssetReader,
8
+ createFileMapAssetReader,
9
+ deriveBasePathFromPmxPath,
10
+ fileListToMap,
11
+ findFirstPmxFileInList,
12
+ joinAssetPath,
13
+ normalizeAssetPath,
14
+ type AssetReader,
15
+ } from "./asset-reader"
6
16
 
7
17
  export type RaycastCallback = (modelName: string, material: string | null, screenX: number, screenY: number) => void
8
18
 
19
+ /** Select a folder (webkitdirectory) and pass FileList or File[]; pmxFile picks which .pmx when several exist. */
20
+ export type LoadModelFromFilesOptions = {
21
+ files: FileList | File[]
22
+ pmxFile?: File
23
+ }
24
+
9
25
  export type EngineOptions = {
10
26
  ambientColor?: Vec3
11
27
  directionalLightIntensity?: number
@@ -62,6 +78,9 @@ interface ModelInstance {
62
78
  name: string
63
79
  model: Model
64
80
  basePath: string
81
+ assetReader: AssetReader
82
+ gpuBuffers: GPUBuffer[]
83
+ textureCacheKeys: string[]
65
84
  vertexBuffer: GPUBuffer
66
85
  indexBuffer: GPUBuffer
67
86
  jointsBuffer: GPUBuffer
@@ -1151,30 +1170,62 @@ export class Engine {
1151
1170
 
1152
1171
  async loadModel(path: string): Promise<Model>
1153
1172
  async loadModel(name: string, path: string): Promise<Model>
1154
- async loadModel(nameOrPath: string, path?: string): Promise<Model> {
1155
- const pmxPath = path === undefined ? nameOrPath : path
1156
- const name = path === undefined ? "model_" + (this._nextDefaultModelId++) : nameOrPath
1173
+ async loadModel(name: string, options: LoadModelFromFilesOptions): Promise<Model>
1174
+ async loadModel(
1175
+ nameOrPath: string,
1176
+ pathOrOptions?: string | LoadModelFromFilesOptions
1177
+ ): Promise<Model> {
1178
+ if (pathOrOptions !== undefined && typeof pathOrOptions === "object" && "files" in pathOrOptions) {
1179
+ const name = nameOrPath
1180
+ const pmxFile = pathOrOptions.pmxFile ?? findFirstPmxFileInList(pathOrOptions.files)
1181
+ if (!pmxFile) throw new Error("No .pmx file found in the selected folder")
1182
+ const map = fileListToMap(pathOrOptions.files)
1183
+ const pmxKey = normalizeAssetPath(
1184
+ (pmxFile as File & { webkitRelativePath?: string }).webkitRelativePath ?? pmxFile.name
1185
+ )
1186
+ const reader = createFileMapAssetReader(map)
1187
+ const model = await PmxLoader.loadFromReader(reader, pmxKey)
1188
+ model.setName(name)
1189
+ await this.addModel(model, pmxKey, name, reader)
1190
+ return model
1191
+ }
1192
+
1193
+ const pmxPath = pathOrOptions === undefined ? nameOrPath : pathOrOptions
1194
+ const name = pathOrOptions === undefined ? "model_" + this._nextDefaultModelId++ : nameOrPath
1157
1195
  const model = await PmxLoader.load(pmxPath)
1158
1196
  model.setName(name)
1159
1197
  await this.addModel(model, pmxPath, name)
1160
1198
  return model
1161
1199
  }
1162
1200
 
1163
- async addModel(model: Model, pmxPath: string, name?: string): Promise<string> {
1201
+ async addModel(model: Model, pmxPath: string, name?: string, assetReader?: AssetReader): Promise<string> {
1164
1202
  const requested = name ?? model.name
1165
1203
  let key = requested
1166
1204
  let n = 1
1167
1205
  while (this.modelInstances.has(key)) {
1168
1206
  key = `${requested}_${n++}`
1169
1207
  }
1170
- const pathParts = pmxPath.split("/")
1171
- pathParts.pop()
1172
- const basePath = pathParts.join("/") + "/"
1173
- await this.setupModelInstance(key, model, basePath)
1208
+ const reader = assetReader ?? createFetchAssetReader()
1209
+ const basePath = deriveBasePathFromPmxPath(pmxPath)
1210
+ model.setAssetContext(reader, basePath)
1211
+ await this.setupModelInstance(key, model, basePath, reader)
1174
1212
  return key
1175
1213
  }
1176
1214
 
1177
1215
  removeModel(name: string): void {
1216
+ const inst = this.modelInstances.get(name)
1217
+ if (!inst) return
1218
+ inst.model.stopAnimation()
1219
+ for (const path of inst.textureCacheKeys) {
1220
+ const tex = this.textureCache.get(path)
1221
+ if (tex) {
1222
+ tex.destroy()
1223
+ this.textureCache.delete(path)
1224
+ }
1225
+ }
1226
+ for (const buf of inst.gpuBuffers) {
1227
+ buf.destroy()
1228
+ }
1178
1229
  this.modelInstances.delete(name)
1179
1230
  }
1180
1231
 
@@ -1262,7 +1313,7 @@ export class Engine {
1262
1313
  inst.vertexBufferNeedsUpdate = false
1263
1314
  }
1264
1315
 
1265
- private async setupModelInstance(name: string, model: Model, basePath: string): Promise<void> {
1316
+ private async setupModelInstance(name: string, model: Model, basePath: string, assetReader: AssetReader): Promise<void> {
1266
1317
  const vertices = model.getVertices()
1267
1318
  const skinning = model.getSkinning()
1268
1319
  const skeleton = model.getSkeleton()
@@ -1345,10 +1396,21 @@ export class Engine {
1345
1396
  ],
1346
1397
  })
1347
1398
 
1399
+ const gpuBuffers: GPUBuffer[] = [
1400
+ vertexBuffer,
1401
+ indexBuffer,
1402
+ jointsBuffer,
1403
+ weightsBuffer,
1404
+ skinMatrixBuffer,
1405
+ ]
1406
+
1348
1407
  const inst: ModelInstance = {
1349
1408
  name,
1350
1409
  model,
1351
1410
  basePath,
1411
+ assetReader,
1412
+ gpuBuffers,
1413
+ textureCacheKeys: [],
1352
1414
  vertexBuffer,
1353
1415
  indexBuffer,
1354
1416
  jointsBuffer,
@@ -1510,8 +1572,8 @@ export class Engine {
1510
1572
 
1511
1573
  const loadTextureByIndex = async (texIndex: number): Promise<GPUTexture | null> => {
1512
1574
  if (texIndex < 0 || texIndex >= textures.length) return null
1513
- const path = inst.basePath + textures[texIndex].path
1514
- return this.createTextureFromPath(path)
1575
+ const logicalPath = joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path))
1576
+ return this.createTextureFromLogicalPath(inst, logicalPath)
1515
1577
  }
1516
1578
 
1517
1579
  let currentIndexOffset = 0
@@ -1535,6 +1597,7 @@ export class Engine {
1535
1597
  mat.specular,
1536
1598
  mat.shininess
1537
1599
  )
1600
+ inst.gpuBuffers.push(materialUniformBuffer)
1538
1601
 
1539
1602
  const textureView = diffuseTexture.createView()
1540
1603
  const bindGroup = this.device.createBindGroup({
@@ -1555,6 +1618,7 @@ export class Engine {
1555
1618
  mat.edgeSize, 0, 0, 0,
1556
1619
  ])
1557
1620
  const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData)
1621
+ inst.gpuBuffers.push(outlineUniformBuffer)
1558
1622
  const outlineBindGroup = this.device.createBindGroup({
1559
1623
  label: `${prefix}outline: ${mat.name}`,
1560
1624
  layout: this.outlinePerMaterialBindGroupLayout,
@@ -1569,6 +1633,7 @@ export class Engine {
1569
1633
  if (this.onRaycast) {
1570
1634
  const pickIdData = new Float32Array([modelId, materialId, 0, 0])
1571
1635
  const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData)
1636
+ inst.gpuBuffers.push(pickIdBuffer)
1572
1637
  const pickBindGroup = this.device.createBindGroup({
1573
1638
  label: `${prefix}pick: ${mat.name}`,
1574
1639
  layout: this.pickPerMaterialBindGroupLayout,
@@ -1621,24 +1686,22 @@ export class Engine {
1621
1686
  return !inst.hiddenMaterials.has(drawCall.materialName)
1622
1687
  }
1623
1688
 
1624
- private async createTextureFromPath(path: string): Promise<GPUTexture | null> {
1625
- const cached = this.textureCache.get(path)
1689
+ private async createTextureFromLogicalPath(inst: ModelInstance, logicalPath: string): Promise<GPUTexture | null> {
1690
+ const cacheKey = logicalPath
1691
+ const cached = this.textureCache.get(cacheKey)
1626
1692
  if (cached) {
1627
1693
  return cached
1628
1694
  }
1629
1695
 
1630
1696
  try {
1631
- const response = await fetch(path)
1632
- if (!response.ok) {
1633
- throw new Error(`HTTP ${response.status}: ${response.statusText}`)
1634
- }
1635
- const imageBitmap = await createImageBitmap(await response.blob(), {
1697
+ const buffer = await inst.assetReader.readBinary(logicalPath)
1698
+ const imageBitmap = await createImageBitmap(new Blob([buffer]), {
1636
1699
  premultiplyAlpha: "none",
1637
1700
  colorSpaceConversion: "none",
1638
1701
  })
1639
1702
 
1640
1703
  const texture = this.device.createTexture({
1641
- label: `texture: ${path}`,
1704
+ label: `texture: ${cacheKey}`,
1642
1705
  size: [imageBitmap.width, imageBitmap.height],
1643
1706
  format: "rgba8unorm",
1644
1707
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
@@ -1648,7 +1711,8 @@ export class Engine {
1648
1711
  imageBitmap.height,
1649
1712
  ])
1650
1713
 
1651
- this.textureCache.set(path, texture)
1714
+ this.textureCache.set(cacheKey, texture)
1715
+ inst.textureCacheKeys.push(cacheKey)
1652
1716
  return texture
1653
1717
  } catch {
1654
1718
  return null
@@ -0,0 +1,59 @@
1
+ import { normalizeAssetPath } from "./asset-reader"
2
+
3
+ /**
4
+ * Call on `<input type="file" webkitdirectory>` `change` **before** `input.value = ""`.
5
+ * `FileList` is live — clearing the input empties it; this copies to a stable `File[]`.
6
+ */
7
+ function prepareLocalFolderFiles(fileList: FileList | null | undefined): {
8
+ files: File[]
9
+ pmxRelativePaths: string[]
10
+ } {
11
+ const files = fileList?.length ? Array.from(fileList) : []
12
+ const pmxRelativePaths: string[] = []
13
+ for (const f of files) {
14
+ const wr = (f as File & { webkitRelativePath?: string }).webkitRelativePath
15
+ if (!wr || !wr.toLowerCase().endsWith(".pmx")) continue
16
+ pmxRelativePaths.push(normalizeAssetPath(wr))
17
+ }
18
+ pmxRelativePaths.sort((a, b) => a.localeCompare(b))
19
+ return { files, pmxRelativePaths }
20
+ }
21
+
22
+ function isDirectoryUpload(files: File[]): boolean {
23
+ return files.length > 0 && files.every((f) => !!(f as File & { webkitRelativePath?: string }).webkitRelativePath)
24
+ }
25
+
26
+ /** After choosing a path from `multiple`, get the `File` for `loadModel(..., { files, pmxFile })`. */
27
+ export function pmxFileAtRelativePath(files: File[], relativePath: string): File | undefined {
28
+ const norm = normalizeAssetPath(relativePath)
29
+ for (const f of files) {
30
+ const wr = (f as File & { webkitRelativePath?: string }).webkitRelativePath
31
+ if (wr && normalizeAssetPath(wr) === norm) return f
32
+ }
33
+ return undefined
34
+ }
35
+
36
+ /** Result of reading a folder input — switch on `status` in your UI. */
37
+ export type PmxFolderInputResult =
38
+ | { status: "empty" }
39
+ | { status: "not_directory" }
40
+ | { status: "no_pmx" }
41
+ | { status: "single"; files: File[]; pmxFile: File }
42
+ | { status: "multiple"; files: File[]; pmxRelativePaths: string[] }
43
+
44
+ /**
45
+ * One call from `onChange`: snapshots files, validates folder pick, resolves a single PMX or asks you to pick among several.
46
+ * Reset the input after: `e.target.value = ""`.
47
+ */
48
+ export function parsePmxFolderInput(fileList: FileList | null | undefined): PmxFolderInputResult {
49
+ const { files, pmxRelativePaths } = prepareLocalFolderFiles(fileList)
50
+ if (files.length === 0) return { status: "empty" }
51
+ if (!isDirectoryUpload(files)) return { status: "not_directory" }
52
+ if (pmxRelativePaths.length === 0) return { status: "no_pmx" }
53
+ if (pmxRelativePaths.length === 1) {
54
+ const pmxFile = pmxFileAtRelativePath(files, pmxRelativePaths[0]!)
55
+ if (!pmxFile) return { status: "no_pmx" }
56
+ return { status: "single", files, pmxFile }
57
+ }
58
+ return { status: "multiple", files, pmxRelativePaths }
59
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { Engine, type EngineStats } from "./engine"
1
+ export { Engine, type EngineStats, type LoadModelFromFilesOptions } from "./engine"
2
+ export { parsePmxFolderInput, pmxFileAtRelativePath, type PmxFolderInputResult } from "./folder-upload"
2
3
  export { Model } from "./model"
3
4
  export { Vec3, Quat, Mat4 } from "./math"
4
5
  export type {
@@ -11,4 +12,4 @@ export type {
11
12
  ControlPoint,
12
13
  } from "./animation"
13
14
  export { FPS } from "./animation"
14
- export { Physics, type PhysicsOptions } from "./physics"
15
+ export { Physics, type PhysicsOptions } from "./physics"
package/src/model.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { Mat4, Quat, Vec3 } from "./math"
2
2
  import { Engine } from "./engine"
3
+ import { joinAssetPath, type AssetReader } from "./asset-reader"
3
4
  import { Rigidbody, Joint } from "./physics"
4
5
  import { IKSolverSystem } from "./ik-solver"
5
6
  import { VMDLoader, type VMDKeyFrame } from "./vmd-loader"
7
+ import { VMDWriter } from "./vmd-writer"
6
8
  import {
7
9
  AnimationClip,
8
10
  AnimationPlayOptions,
@@ -205,6 +207,14 @@ export class Model {
205
207
  private morphTrackIndices: Map<string, number> = new Map()
206
208
  private lastAppliedClip: AnimationClip | null = null
207
209
 
210
+ private assetReader: AssetReader | null = null
211
+ private assetBasePath = ""
212
+
213
+ /** Called by Engine when registering the model; enables loadVmd to resolve relative paths for folder uploads. */
214
+ setAssetContext(reader: AssetReader, basePath: string): void {
215
+ this.assetReader = reader
216
+ this.assetBasePath = basePath
217
+ }
208
218
 
209
219
  constructor(
210
220
  vertexData: Float32Array<ArrayBuffer>,
@@ -856,19 +866,40 @@ export class Model {
856
866
  return { boneTracks, morphTracks, frameCount: maxFrame }
857
867
  }
858
868
 
859
- loadAnimation(animationName: string, source: string): Promise<void>
860
- loadAnimation(animationName: string, source: AnimationClip): void
861
- loadAnimation(animationName: string, source: string | AnimationClip): Promise<void> | void {
862
- if (typeof source !== "string") {
863
- this.animationState.loadAnimation(animationName, source)
864
- return
869
+ loadVmd(name: string, urlOrRelative: string): Promise<void> {
870
+ const loadBuffer = (): Promise<ArrayBuffer> => {
871
+ const u = urlOrRelative.trim()
872
+ const useSiteFetch =
873
+ u.startsWith("http://") ||
874
+ u.startsWith("https://") ||
875
+ u.startsWith("/") ||
876
+ u.startsWith("blob:") ||
877
+ u.startsWith("data:")
878
+ if (useSiteFetch) {
879
+ return fetch(u).then((r) => {
880
+ if (!r.ok) throw new Error(`Failed to fetch VMD ${u}: ${r.status}`)
881
+ return r.arrayBuffer()
882
+ })
883
+ }
884
+ if (this.assetReader) {
885
+ return this.assetReader.readBinary(joinAssetPath(this.assetBasePath, u))
886
+ }
887
+ return fetch(u).then((r) => {
888
+ if (!r.ok) throw new Error(`Failed to fetch VMD ${u}: ${r.status}`)
889
+ return r.arrayBuffer()
890
+ })
865
891
  }
866
- return VMDLoader.load(source).then((vmdKeyFrames) => {
892
+ return loadBuffer().then((buf) => {
893
+ const vmdKeyFrames = VMDLoader.loadFromBuffer(buf)
867
894
  const clip = this.buildClipFromVmdKeyFrames(vmdKeyFrames)
868
- this.animationState.loadAnimation(animationName, clip)
895
+ this.animationState.loadAnimation(name, clip)
869
896
  })
870
897
  }
871
898
 
899
+ loadClip(name: string, clip: AnimationClip): void {
900
+ this.animationState.loadAnimation(name, clip)
901
+ }
902
+
872
903
  resetAllBones(): void {
873
904
  for (let boneIdx = 0; boneIdx < this.skeleton.bones.length; boneIdx++) {
874
905
  const localRot = this.runtimeSkeleton.localRotations[boneIdx]
@@ -889,10 +920,16 @@ export class Model {
889
920
  this.applyMorphs()
890
921
  }
891
922
 
892
- getAnimationClip(name: string): AnimationClip | null {
923
+ getClip(name: string): AnimationClip | null {
893
924
  return this.animationState.getAnimationClip(name)
894
925
  }
895
926
 
927
+ exportVmd(name: string): ArrayBuffer {
928
+ const clip = this.animationState.getAnimationClip(name)
929
+ if (!clip) throw new Error(`Animation clip "${name}" not found`)
930
+ return new VMDWriter().write(clip)
931
+ }
932
+
896
933
  play(): void
897
934
  play(name: string): boolean
898
935
  play(name: string, options?: AnimationPlayOptions): boolean
package/src/pmx-loader.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from "./model"
14
14
  import { Mat4, Vec3 } from "./math"
15
15
  import { Rigidbody, Joint, RigidbodyShape, RigidbodyType } from "./physics"
16
+ import { createFetchAssetReader, type AssetReader } from "./asset-reader"
16
17
 
17
18
  export class PmxLoader {
18
19
  private view: DataView
@@ -42,8 +43,16 @@ export class PmxLoader {
42
43
  }
43
44
 
44
45
  static async load(url: string): Promise<Model> {
45
- const loader = new PmxLoader(await fetch(url).then((r) => r.arrayBuffer()))
46
- return loader.parse()
46
+ return PmxLoader.loadFromReader(createFetchAssetReader(), url)
47
+ }
48
+
49
+ static loadFromBuffer(buffer: ArrayBuffer): Model {
50
+ return new PmxLoader(buffer).parse()
51
+ }
52
+
53
+ static async loadFromReader(reader: AssetReader, pmxLogicalPath: string): Promise<Model> {
54
+ const buffer = await reader.readBinary(pmxLogicalPath)
55
+ return PmxLoader.loadFromBuffer(buffer)
47
56
  }
48
57
 
49
58
  private parse(): Model {
@@ -0,0 +1,180 @@
1
+ import { AnimationClip, BoneInterpolation } from "./animation"
2
+
3
+ const VMD_HEADER = "Vocaloid Motion Data 0002"
4
+ const HEADER_SIZE = 30
5
+ const MODEL_NAME_SIZE = 20
6
+ const BONE_NAME_SIZE = 15
7
+ const MORPH_NAME_SIZE = 15
8
+ const BONE_FRAME_SIZE = BONE_NAME_SIZE + 4 + 12 + 16 + 64 // 111 bytes
9
+ const MORPH_FRAME_SIZE = MORPH_NAME_SIZE + 4 + 4 // 23 bytes
10
+
11
+ // Build a Unicode-to-Shift-JIS lookup by inverting the TextDecoder mapping.
12
+ let shiftJISTable: Map<string, number[]> | null = null
13
+
14
+ function getShiftJISTable(): Map<string, number[]> {
15
+ if (shiftJISTable) return shiftJISTable
16
+ const decoder = new TextDecoder("shift-jis")
17
+ const map = new Map<string, number[]>()
18
+ // Single-byte range
19
+ for (let i = 0; i < 256; i++) {
20
+ const char = decoder.decode(new Uint8Array([i]))
21
+ if (char !== "\ufffd") map.set(char, [i])
22
+ }
23
+ // Two-byte range (JIS X 0208)
24
+ for (let hi = 0x81; hi <= 0xfc; hi++) {
25
+ if (hi >= 0xa0 && hi <= 0xdf) continue
26
+ for (let lo = 0x40; lo <= 0xfc; lo++) {
27
+ if (lo === 0x7f) continue
28
+ const char = decoder.decode(new Uint8Array([hi, lo]))
29
+ if (char !== "\ufffd" && !map.has(char)) {
30
+ map.set(char, [hi, lo])
31
+ }
32
+ }
33
+ }
34
+ shiftJISTable = map
35
+ return map
36
+ }
37
+
38
+ function encodeShiftJIS(str: string): Uint8Array {
39
+ const table = getShiftJISTable()
40
+ const bytes: number[] = []
41
+ for (const char of str) {
42
+ const b = table.get(char)
43
+ if (b) bytes.push(...b)
44
+ }
45
+ return new Uint8Array(bytes)
46
+ }
47
+
48
+ export class VMDWriter {
49
+ write(clip: AnimationClip): ArrayBuffer {
50
+ let totalBoneFrames = 0
51
+ for (const frames of clip.boneTracks.values()) {
52
+ totalBoneFrames += frames.length
53
+ }
54
+ let totalMorphFrames = 0
55
+ for (const frames of clip.morphTracks.values()) {
56
+ totalMorphFrames += frames.length
57
+ }
58
+
59
+ const size =
60
+ HEADER_SIZE +
61
+ MODEL_NAME_SIZE +
62
+ 4 + totalBoneFrames * BONE_FRAME_SIZE +
63
+ 4 + totalMorphFrames * MORPH_FRAME_SIZE
64
+
65
+ const buffer = new ArrayBuffer(size)
66
+ const view = new DataView(buffer)
67
+ let offset = 0
68
+
69
+ // Header (30 bytes, ASCII)
70
+ offset = writeFixedString(buffer, offset, VMD_HEADER, HEADER_SIZE)
71
+
72
+ // Model name (20 bytes, zeroed)
73
+ offset += MODEL_NAME_SIZE
74
+
75
+ // Bone frame count
76
+ view.setUint32(offset, totalBoneFrames, true)
77
+ offset += 4
78
+
79
+ // Bone frames
80
+ for (const frames of clip.boneTracks.values()) {
81
+ for (const kf of frames) {
82
+ // Bone name (15 bytes, Shift-JIS)
83
+ offset = writeFixedShiftJIS(buffer, offset, kf.boneName, BONE_NAME_SIZE)
84
+
85
+ // Frame number (u32 LE)
86
+ view.setUint32(offset, kf.frame, true)
87
+ offset += 4
88
+
89
+ // Translation (3 x f32 LE)
90
+ view.setFloat32(offset, kf.translation.x, true); offset += 4
91
+ view.setFloat32(offset, kf.translation.y, true); offset += 4
92
+ view.setFloat32(offset, kf.translation.z, true); offset += 4
93
+
94
+ // Rotation quaternion (4 x f32 LE)
95
+ view.setFloat32(offset, kf.rotation.x, true); offset += 4
96
+ view.setFloat32(offset, kf.rotation.y, true); offset += 4
97
+ view.setFloat32(offset, kf.rotation.z, true); offset += 4
98
+ view.setFloat32(offset, kf.rotation.w, true); offset += 4
99
+
100
+ // Interpolation (64 bytes)
101
+ const raw = boneInterpolationToRaw(kf.interpolation)
102
+ new Uint8Array(buffer, offset, 64).set(raw)
103
+ offset += 64
104
+ }
105
+ }
106
+
107
+ // Morph frame count
108
+ view.setUint32(offset, totalMorphFrames, true)
109
+ offset += 4
110
+
111
+ // Morph frames
112
+ for (const frames of clip.morphTracks.values()) {
113
+ for (const kf of frames) {
114
+ // Morph name (15 bytes, Shift-JIS)
115
+ offset = writeFixedShiftJIS(buffer, offset, kf.morphName, MORPH_NAME_SIZE)
116
+
117
+ // Frame number (u32 LE)
118
+ view.setUint32(offset, kf.frame, true)
119
+ offset += 4
120
+
121
+ // Weight (f32 LE)
122
+ view.setFloat32(offset, kf.weight, true)
123
+ offset += 4
124
+ }
125
+ }
126
+
127
+ return buffer
128
+ }
129
+ }
130
+
131
+ function writeFixedString(buffer: ArrayBuffer, offset: number, str: string, maxBytes: number): number {
132
+ const bytes = new Uint8Array(buffer, offset, maxBytes)
133
+ bytes.fill(0)
134
+ for (let i = 0; i < str.length && i < maxBytes; i++) {
135
+ bytes[i] = str.charCodeAt(i) & 0xff
136
+ }
137
+ return offset + maxBytes
138
+ }
139
+
140
+ function writeFixedShiftJIS(buffer: ArrayBuffer, offset: number, str: string, maxBytes: number): number {
141
+ const target = new Uint8Array(buffer, offset, maxBytes)
142
+ target.fill(0)
143
+ const encoded = encodeShiftJIS(str)
144
+ target.set(encoded.subarray(0, maxBytes))
145
+ return offset + maxBytes
146
+ }
147
+
148
+ /**
149
+ * Convert BoneInterpolation back to the 64-byte raw VMD interpolation table.
150
+ * Exact inverse of rawInterpolationToBoneInterpolation in animation.ts.
151
+ */
152
+ function boneInterpolationToRaw(interp: BoneInterpolation): Uint8Array {
153
+ const raw = new Uint8Array(64)
154
+
155
+ // Rotation: [{x: raw[0], y: raw[2]}, {x: raw[1], y: raw[3]}]
156
+ raw[0] = interp.rotation[0].x
157
+ raw[1] = interp.rotation[1].x
158
+ raw[2] = interp.rotation[0].y
159
+ raw[3] = interp.rotation[1].y
160
+
161
+ // TranslationX: [{x: raw[0], y: raw[4]}, {x: raw[8], y: raw[12]}]
162
+ // raw[0] already set by rotation (shared byte)
163
+ raw[4] = interp.translationX[0].y
164
+ raw[8] = interp.translationX[1].x
165
+ raw[12] = interp.translationX[1].y
166
+
167
+ // TranslationY: [{x: raw[16], y: raw[20]}, {x: raw[24], y: raw[28]}]
168
+ raw[16] = interp.translationY[0].x
169
+ raw[20] = interp.translationY[0].y
170
+ raw[24] = interp.translationY[1].x
171
+ raw[28] = interp.translationY[1].y
172
+
173
+ // TranslationZ: [{x: raw[32], y: raw[36]}, {x: raw[40], y: raw[44]}]
174
+ raw[32] = interp.translationZ[0].x
175
+ raw[36] = interp.translationZ[0].y
176
+ raw[40] = interp.translationZ[1].x
177
+ raw[44] = interp.translationZ[1].y
178
+
179
+ return raw
180
+ }