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/README.md +126 -76
- package/dist/asset-reader.d.ts +16 -0
- package/dist/asset-reader.d.ts.map +1 -0
- package/dist/asset-reader.js +74 -0
- package/dist/engine.d.ts +9 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +60 -20
- package/dist/folder-upload.d.ts +24 -0
- package/dist/folder-upload.d.ts.map +1 -0
- package/dist/folder-upload.js +50 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/model.d.ts +9 -3
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +46 -8
- package/dist/pmx-loader.d.ts +3 -0
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +9 -2
- package/dist/vmd-writer.d.ts +5 -0
- package/dist/vmd-writer.d.ts.map +1 -0
- package/dist/vmd-writer.js +162 -0
- package/package.json +4 -3
- package/src/asset-reader.ts +79 -0
- package/src/engine.ts +84 -20
- package/src/folder-upload.ts +59 -0
- package/src/index.ts +3 -2
- package/src/model.ts +46 -9
- package/src/pmx-loader.ts +11 -2
- package/src/vmd-writer.ts +180 -0
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(
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
|
1514
|
-
return this.
|
|
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
|
|
1625
|
-
const
|
|
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
|
|
1632
|
-
|
|
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: ${
|
|
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(
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
|
892
|
+
return loadBuffer().then((buf) => {
|
|
893
|
+
const vmdKeyFrames = VMDLoader.loadFromBuffer(buf)
|
|
867
894
|
const clip = this.buildClipFromVmdKeyFrames(vmdKeyFrames)
|
|
868
|
-
this.animationState.loadAnimation(
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
+
}
|