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/pmx-loader.ts CHANGED
@@ -1,1054 +1,1173 @@
1
- import { Model, Texture, Material, Bone, Skeleton, Skinning } from "./model"
2
- import { Mat4, Vec3 } from "./math"
3
- import { Rigidbody, Joint, RigidbodyShape, RigidbodyType } from "./physics"
4
-
5
- export class PmxLoader {
6
- private view: DataView
7
- private offset = 0
8
- private decoder!: TextDecoder
9
- private encoding = 0
10
- private additionalVec4Count = 0
11
- private vertexIndexSize = 0
12
- private textureIndexSize = 0
13
- private materialIndexSize = 0
14
- private boneIndexSize = 0
15
- private morphIndexSize = 0
16
- private rigidBodyIndexSize = 0
17
- private textures: Texture[] = []
18
- private materials: Material[] = []
19
- private bones: Bone[] = []
20
- private inverseBindMatrices: Float32Array | null = null
21
- private joints0: Uint16Array | null = null
22
- private weights0: Uint8Array | null = null
23
- private rigidbodies: Rigidbody[] = []
24
- private joints: Joint[] = []
25
-
26
- private constructor(buffer: ArrayBuffer) {
27
- this.view = new DataView(buffer)
28
- }
29
-
30
- static async load(url: string): Promise<Model> {
31
- const loader = new PmxLoader(await fetch(url).then((r) => r.arrayBuffer()))
32
- return loader.parse()
33
- }
34
-
35
- private parse(): Model {
36
- this.parseHeader()
37
- const { positions, normals, uvs } = this.parseVertices()
38
- const indices = this.parseIndices()
39
- this.parseTextures()
40
- this.parseMaterials()
41
- this.parseBones()
42
- // Skip morphs and display frames before parsing rigidbodies
43
- this.skipMorphs()
44
- this.skipDisplayFrames()
45
- this.parseRigidbodies()
46
- this.parseJoints()
47
- this.computeInverseBind()
48
- return this.toModel(positions, normals, uvs, indices)
49
- }
50
-
51
- private parseHeader() {
52
- if (this.getString(3) !== "PMX") throw new Error("Not a PMX file")
53
-
54
- // PMX: 1-byte alignment after signature
55
- this.getUint8()
56
-
57
- // PMX: version (float32)
58
- const version = this.getFloat32()
59
- if (version < 2.0 || version > 2.2) {
60
- // Continue, but warn for unexpected version
61
- console.warn(`PMX version ${version} may not be fully supported`)
62
- }
63
-
64
- // PMX: globals count (uint8) followed by that many bytes describing encoding and index sizes
65
- const globalsCount = this.getUint8()
66
-
67
- // Validate globalsCount (must be at least 8 for PMX 2.x)
68
- if (globalsCount < 8) {
69
- throw new Error(`Invalid globalsCount: ${globalsCount}, expected at least 8`)
70
- }
71
-
72
- // Read globals (first 8 bytes are standard for PMX 2.x)
73
- this.encoding = this.getUint8() // 0:utf16le, 1:utf8
74
- this.additionalVec4Count = this.getUint8()
75
- this.vertexIndexSize = this.getUint8()
76
- this.textureIndexSize = this.getUint8()
77
- this.materialIndexSize = this.getUint8()
78
- this.boneIndexSize = this.getUint8()
79
- this.morphIndexSize = this.getUint8()
80
- this.rigidBodyIndexSize = this.getUint8()
81
-
82
- // Skip any extra globals beyond the first 8 (for future format extensions)
83
- if (globalsCount > 8) {
84
- for (let i = 8; i < globalsCount; i++) {
85
- this.getUint8()
86
- }
87
- }
88
-
89
- this.decoder = new TextDecoder(this.encoding === 0 ? "utf-16le" : "utf-8")
90
-
91
- // Skip model info (4 text fields)
92
- this.getText()
93
- this.getText()
94
- this.getText()
95
- this.getText()
96
- }
97
-
98
- private parseVertices() {
99
- const count = this.getInt32()
100
- const positions: number[] = []
101
- const normals: number[] = []
102
- const uvs: number[] = []
103
- // Prepare skinning arrays (4 influences per vertex)
104
- const joints = new Uint16Array(count * 4)
105
- const weights = new Uint8Array(count * 4) // UNORM8, will be normalized to 255
106
-
107
- for (let i = 0; i < count; i++) {
108
- const px = this.getFloat32()
109
- const py = this.getFloat32()
110
- const pz = this.getFloat32()
111
- positions.push(px, py, pz)
112
-
113
- const nx = this.getFloat32()
114
- const ny = this.getFloat32()
115
- const nz = this.getFloat32()
116
- normals.push(nx, ny, nz)
117
-
118
- const u = this.getFloat32()
119
- const v = this.getFloat32()
120
- // PMX UVs are in the same orientation as WebGPU sampling; no flip
121
- uvs.push(u, v)
122
-
123
- this.offset += this.additionalVec4Count * 16
124
- const type = this.getUint8()
125
- const base = i * 4
126
- // Initialize defaults
127
- joints[base] = 0
128
- joints[base + 1] = 0
129
- joints[base + 2] = 0
130
- joints[base + 3] = 0
131
- weights[base] = 255
132
- weights[base + 1] = 0
133
- weights[base + 2] = 0
134
- weights[base + 3] = 0
135
-
136
- if (type === 0) {
137
- // BDEF1
138
- const j0 = this.getNonVertexIndex(this.boneIndexSize)
139
- joints[base] = j0 >= 0 ? j0 : 0
140
- // weight stays [255,0,0,0]
141
- } else if (type === 1 || type === 3) {
142
- // BDEF2 or SDEF (treated as BDEF2)
143
- const j0 = this.getNonVertexIndex(this.boneIndexSize)
144
- const j1 = this.getNonVertexIndex(this.boneIndexSize)
145
- const w0f = this.getFloat32()
146
- const w0 = Math.max(0, Math.min(255, Math.round(w0f * 255)))
147
- const w1 = Math.max(0, Math.min(255, 255 - w0))
148
- joints[base] = j0 >= 0 ? j0 : 0
149
- joints[base + 1] = j1 >= 0 ? j1 : 0
150
- weights[base] = w0
151
- weights[base + 1] = w1
152
- // SDEF has extra 3 vec3 (C, R0, R1)
153
- if (type === 3) {
154
- this.offset += 36 // 9 floats * 4 bytes
155
- }
156
- } else if (type === 2 || type === 4) {
157
- // BDEF4 or QDEF (treat as LBS4)
158
- let sum = 0
159
- for (let k = 0; k < 4; k++) {
160
- const j = this.getNonVertexIndex(this.boneIndexSize)
161
- joints[base + k] = j >= 0 ? j : 0
162
- }
163
- const wf = [this.getFloat32(), this.getFloat32(), this.getFloat32(), this.getFloat32()]
164
- const ws = wf.map((x) => Math.max(0, Math.min(1, x)))
165
- const w8 = ws.map((x) => Math.round(x * 255))
166
- sum = w8[0] + w8[1] + w8[2] + w8[3]
167
- if (sum === 0) {
168
- weights[base] = 255
169
- } else {
170
- // Normalize to 255
171
- const scale = 255 / sum
172
- let accum = 0
173
- for (let k = 0; k < 3; k++) {
174
- const v = Math.max(0, Math.min(255, Math.round(w8[k] * scale)))
175
- weights[base + k] = v
176
- accum += v
177
- }
178
- weights[base + 3] = Math.max(0, Math.min(255, 255 - accum))
179
- }
180
- } else {
181
- throw new Error(`Invalid bone weight type: ${type}`)
182
- }
183
- this.offset += 4 // edge scale
184
- }
185
-
186
- this.joints0 = joints
187
- this.weights0 = weights
188
- return { positions, normals, uvs }
189
- }
190
-
191
- // (removed) skipBoneWeight – replaced by inline parsing
192
-
193
- private parseIndices() {
194
- const count = this.getInt32()
195
- const indices: number[] = []
196
-
197
- for (let i = 0; i < count; i++) {
198
- indices.push(this.getIndex(this.vertexIndexSize))
199
- }
200
-
201
- return indices
202
- }
203
-
204
- private parseTextures() {
205
- try {
206
- const count = this.getInt32()
207
- this.textures = []
208
-
209
- for (let i = 0; i < count; i++) {
210
- const textureName = this.getText()
211
- this.textures.push({
212
- path: textureName,
213
- name: textureName.split("/").pop() || textureName, // Extract filename
214
- })
215
- }
216
- } catch (error) {
217
- console.error("Error parsing textures:", error)
218
- this.textures = []
219
- }
220
- }
221
-
222
- private parseMaterials() {
223
- try {
224
- const count = this.getInt32()
225
- this.materials = []
226
-
227
- for (let i = 0; i < count; i++) {
228
- const name = this.getText()
229
- this.getText() // englishName (skip)
230
-
231
- const diffuse = [this.getFloat32(), this.getFloat32(), this.getFloat32(), this.getFloat32()] as [
232
- number,
233
- number,
234
- number,
235
- number
236
- ]
237
- const specular = [this.getFloat32(), this.getFloat32(), this.getFloat32()] as [number, number, number]
238
- const shininess = this.getFloat32()
239
- const ambient = [this.getFloat32(), this.getFloat32(), this.getFloat32()] as [number, number, number]
240
-
241
- const flag = this.getUint8()
242
- // edgeColor vec4
243
- const edgeColor: [number, number, number, number] = [
244
- this.getFloat32(),
245
- this.getFloat32(),
246
- this.getFloat32(),
247
- this.getFloat32(),
248
- ]
249
- // edgeSize float
250
- const edgeSize = this.getFloat32()
251
-
252
- const textureIndex = this.getNonVertexIndex(this.textureIndexSize)
253
- const sphereTextureIndex = this.getNonVertexIndex(this.textureIndexSize)
254
- const sphereTextureMode = this.getUint8()
255
-
256
- const isSharedToonTexture = this.getUint8() === 1
257
- const toonTextureIndex = isSharedToonTexture ? this.getUint8() : this.getNonVertexIndex(this.textureIndexSize)
258
-
259
- this.getText() // comment (skip)
260
- const vertexCount = this.getInt32()
261
-
262
- // PMX material flag bits:
263
- // Bit 0 (0x01): Double-sided rendering
264
- // Bit 4 (0x10): Edge drawing (outline)
265
- const mat: Material = {
266
- name,
267
- diffuse,
268
- specular,
269
- ambient,
270
- shininess,
271
- diffuseTextureIndex: textureIndex,
272
- normalTextureIndex: -1, // Not used in basic PMX
273
- sphereTextureIndex,
274
- sphereMode: sphereTextureMode,
275
- toonTextureIndex,
276
- edgeFlag: flag,
277
- edgeColor,
278
- edgeSize,
279
- vertexCount,
280
- }
281
-
282
- // Classify materials based on name
283
- const materialName = name.toLowerCase()
284
-
285
- // Classify eye materials
286
- mat.isEye =
287
- materialName.includes("目") || // Japanese "eye"
288
- materialName.includes("瞳") || // Japanese "pupil"
289
- materialName.includes("eye") ||
290
- materialName.includes("pupil") ||
291
- materialName.includes("iris") ||
292
- materialName.includes("目白") ||
293
- materialName.includes("眼") ||
294
- materialName.includes("睛") ||
295
- materialName.includes("眉")
296
-
297
- // Classify face materials
298
- mat.isFace = materialName.includes("face") || materialName.includes("脸")
299
-
300
- // Classify hair materials
301
- mat.isHair = materialName.includes("hair_f")
302
-
303
- this.materials.push(mat)
304
- }
305
- } catch (error) {
306
- console.error("Error parsing materials:", error)
307
- this.materials = []
308
- }
309
- }
310
-
311
- private parseBones() {
312
- try {
313
- const count = this.getInt32()
314
- const bones: Bone[] = []
315
- // Collect absolute positions, then convert to parent-relative offsets
316
- type AbsBone = {
317
- name: string
318
- parent: number
319
- x: number
320
- y: number
321
- z: number
322
- appendParent?: number
323
- appendRatio?: number
324
- appendRotate?: boolean
325
- appendMove?: boolean
326
- }
327
- const abs: AbsBone[] = new Array(count)
328
- // PMX 2.x bone flags (best-effort common masks)
329
- const FLAG_TAIL_IS_BONE = 0x0001
330
- const FLAG_IK = 0x0020
331
- const FLAG_APPEND_ROTATE = 0x0100
332
- const FLAG_APPEND_MOVE = 0x0200
333
- const FLAG_AXIS_LIMIT = 0x0400
334
- const FLAG_LOCAL_AXIS = 0x0800
335
- const FLAG_EXTERNAL_PARENT = 0x2000
336
- for (let i = 0; i < count; i++) {
337
- const name = this.getText()
338
- this.getText() // englishName (skip)
339
- // PMX is left-handed, engine is now left-handed - no conversion needed
340
- const x = this.getFloat32()
341
- const y = this.getFloat32()
342
- const z = this.getFloat32()
343
- const parentIndex = this.getNonVertexIndex(this.boneIndexSize)
344
- this.getInt32() // transform order (skip)
345
- const flags = this.getUint16()
346
-
347
- // Tail: bone index or offset vector3
348
- if ((flags & FLAG_TAIL_IS_BONE) !== 0) {
349
- this.getNonVertexIndex(this.boneIndexSize)
350
- } else {
351
- // tail offset vec3
352
- this.getFloat32()
353
- this.getFloat32()
354
- this.getFloat32()
355
- }
356
-
357
- // Append transform (inherit/ratio)
358
- let appendParent: number | undefined = undefined
359
- let appendRatio: number | undefined = undefined
360
- let appendRotate = false
361
- let appendMove = false
362
- if ((flags & (FLAG_APPEND_ROTATE | FLAG_APPEND_MOVE)) !== 0) {
363
- appendParent = this.getNonVertexIndex(this.boneIndexSize) // append parent
364
- appendRatio = this.getFloat32() // ratio
365
- appendRotate = (flags & FLAG_APPEND_ROTATE) !== 0
366
- appendMove = (flags & FLAG_APPEND_MOVE) !== 0
367
- }
368
-
369
- // Axis limit
370
- if ((flags & FLAG_AXIS_LIMIT) !== 0) {
371
- this.getFloat32()
372
- this.getFloat32()
373
- this.getFloat32()
374
- }
375
-
376
- // Local axis (two vectors x and z)
377
- if ((flags & FLAG_LOCAL_AXIS) !== 0) {
378
- // local axis X
379
- this.getFloat32()
380
- this.getFloat32()
381
- this.getFloat32()
382
- // local axis Z
383
- this.getFloat32()
384
- this.getFloat32()
385
- this.getFloat32()
386
- }
387
-
388
- // External parent transform
389
- if ((flags & FLAG_EXTERNAL_PARENT) !== 0) {
390
- this.getInt32()
391
- }
392
-
393
- // IK block
394
- if ((flags & FLAG_IK) !== 0) {
395
- this.getNonVertexIndex(this.boneIndexSize) // target
396
- this.getInt32() // iteration
397
- this.getFloat32() // rotationConstraint
398
- const linksCount = this.getInt32()
399
- for (let li = 0; li < linksCount; li++) {
400
- this.getNonVertexIndex(this.boneIndexSize) // link target
401
- const hasLimit = this.getUint8() === 1
402
- if (hasLimit) {
403
- // min and max angles (vec3 each)
404
- this.getFloat32()
405
- this.getFloat32()
406
- this.getFloat32()
407
- this.getFloat32()
408
- this.getFloat32()
409
- this.getFloat32()
410
- }
411
- }
412
- }
413
- // Stash minimal bone info; append data will be merged later
414
- abs[i] = { name, parent: parentIndex, x, y, z, appendParent, appendRatio, appendRotate, appendMove }
415
- }
416
- for (let i = 0; i < count; i++) {
417
- const a = abs[i]
418
- if (a.parent >= 0 && a.parent < count) {
419
- const p = abs[a.parent]
420
- bones.push({
421
- name: a.name,
422
- parentIndex: a.parent,
423
- bindTranslation: [a.x - p.x, a.y - p.y, a.z - p.z],
424
- children: [], // Will be populated later when building skeleton
425
- appendParentIndex: a.appendParent,
426
- appendRatio: a.appendRatio,
427
- appendRotate: a.appendRotate,
428
- appendMove: a.appendMove,
429
- })
430
- } else {
431
- bones.push({
432
- name: a.name,
433
- parentIndex: a.parent,
434
- bindTranslation: [a.x, a.y, a.z],
435
- children: [], // Will be populated later when building skeleton
436
- appendParentIndex: a.appendParent,
437
- appendRatio: a.appendRatio,
438
- appendRotate: a.appendRotate,
439
- appendMove: a.appendMove,
440
- })
441
- }
442
- }
443
- this.bones = bones
444
- } catch (e) {
445
- console.warn("Error parsing bones:", e)
446
- this.bones = []
447
- }
448
- }
449
-
450
- private skipMorphs(): boolean {
451
- try {
452
- // Check if we have enough bytes to read the count
453
- if (this.offset + 4 > this.view.buffer.byteLength) {
454
- return false
455
- }
456
- const count = this.getInt32()
457
- if (count < 0 || count > 100000) {
458
- // Suspicious count, likely corrupted - restore offset
459
- this.offset -= 4
460
- return false
461
- }
462
- for (let i = 0; i < count; i++) {
463
- // Check bounds before reading each morph
464
- if (this.offset >= this.view.buffer.byteLength) {
465
- return false
466
- }
467
- try {
468
- this.getText() // name
469
- this.getText() // englishName
470
- this.getUint8() // panelType
471
- const morphType = this.getUint8()
472
- const offsetCount = this.getInt32()
473
-
474
- // Skip offsets based on morph type
475
- for (let j = 0; j < offsetCount; j++) {
476
- if (this.offset >= this.view.buffer.byteLength) {
477
- return false
478
- }
479
- if (morphType === 0) {
480
- // Group morph
481
- this.getNonVertexIndex(this.morphIndexSize) // morphIndex
482
- this.getFloat32() // ratio
483
- } else if (morphType === 1) {
484
- // Vertex morph
485
- this.getIndex(this.vertexIndexSize) // vertexIndex
486
- this.getFloat32() // x
487
- this.getFloat32() // y
488
- this.getFloat32() // z
489
- } else if (morphType === 2) {
490
- // Bone morph
491
- this.getNonVertexIndex(this.boneIndexSize) // boneIndex
492
- this.getFloat32() // x
493
- this.getFloat32() // y
494
- this.getFloat32() // z
495
- this.getFloat32() // rx
496
- this.getFloat32() // ry
497
- this.getFloat32() // rz
498
- } else if (morphType === 3) {
499
- // UV morph
500
- this.getIndex(this.vertexIndexSize) // vertexIndex
501
- this.getFloat32() // u
502
- this.getFloat32() // v
503
- } else if (morphType === 4 || morphType === 5 || morphType === 6 || morphType === 7) {
504
- // UV morph types 4-7 (additional UV channels)
505
- this.getIndex(this.vertexIndexSize) // vertexIndex
506
- this.getFloat32() // u
507
- this.getFloat32() // v
508
- } else if (morphType === 8) {
509
- // Material morph
510
- this.getNonVertexIndex(this.materialIndexSize) // materialIndex
511
- this.getUint8() // offsetType
512
- this.getFloat32() // diffuse r
513
- this.getFloat32() // diffuse g
514
- this.getFloat32() // diffuse b
515
- this.getFloat32() // diffuse a
516
- this.getFloat32() // specular r
517
- this.getFloat32() // specular g
518
- this.getFloat32() // specular b
519
- this.getFloat32() // specular power
520
- this.getFloat32() // ambient r
521
- this.getFloat32() // ambient g
522
- this.getFloat32() // ambient b
523
- this.getFloat32() // edgeColor r
524
- this.getFloat32() // edgeColor g
525
- this.getFloat32() // edgeColor b
526
- this.getFloat32() // edgeColor a
527
- this.getFloat32() // edgeSize
528
- this.getFloat32() // textureCoeff r
529
- this.getFloat32() // textureCoeff g
530
- this.getFloat32() // textureCoeff b
531
- this.getFloat32() // textureCoeff a
532
- this.getFloat32() // sphereCoeff r
533
- this.getFloat32() // sphereCoeff g
534
- this.getFloat32() // sphereCoeff b
535
- this.getFloat32() // sphereCoeff a
536
- this.getFloat32() // toonCoeff r
537
- this.getFloat32() // toonCoeff g
538
- this.getFloat32() // toonCoeff b
539
- this.getFloat32() // toonCoeff a
540
- }
541
- }
542
- } catch (e) {
543
- // If we fail to read a morph, stop skipping
544
- console.warn(`Error reading morph ${i}:`, e)
545
- return false
546
- }
547
- }
548
- return true
549
- } catch (e) {
550
- console.warn("Error skipping morphs:", e)
551
- return false
552
- }
553
- }
554
-
555
- private skipDisplayFrames(): boolean {
556
- try {
557
- // Check if we have enough bytes to read the count
558
- if (this.offset + 4 > this.view.buffer.byteLength) {
559
- return false
560
- }
561
- const count = this.getInt32()
562
- if (count < 0 || count > 100000) {
563
- // Suspicious count, likely corrupted - restore offset
564
- this.offset -= 4
565
- return false
566
- }
567
- for (let i = 0; i < count; i++) {
568
- // Check bounds before reading each frame
569
- if (this.offset >= this.view.buffer.byteLength) {
570
- return false
571
- }
572
- try {
573
- this.getText() // name
574
- this.getText() // englishName
575
- this.getUint8() // flag
576
- const elementCount = this.getInt32()
577
- for (let j = 0; j < elementCount; j++) {
578
- if (this.offset >= this.view.buffer.byteLength) {
579
- return false
580
- }
581
- const elementType = this.getUint8()
582
- if (elementType === 0) {
583
- // Bone frame
584
- this.getNonVertexIndex(this.boneIndexSize)
585
- } else if (elementType === 1) {
586
- // Morph frame
587
- this.getNonVertexIndex(this.morphIndexSize)
588
- }
589
- }
590
- } catch (e) {
591
- // If we fail to read a frame, stop skipping
592
- console.warn(`Error reading display frame ${i}:`, e)
593
- return false
594
- }
595
- }
596
- return true
597
- } catch (e) {
598
- console.warn("Error skipping display frames:", e)
599
- return false
600
- }
601
- }
602
-
603
- private parseRigidbodies(): void {
604
- try {
605
- // Note: morphs and display frames are already skipped in parse() before calling this
606
- // Check bounds before reading rigidbody count
607
- if (this.offset + 4 > this.view.buffer.byteLength) {
608
- console.warn("Not enough bytes for rigidbody count")
609
- this.rigidbodies = []
610
- return
611
- }
612
-
613
- const count = this.getInt32()
614
- if (count < 0 || count > 10000) {
615
- // Suspicious count
616
- console.warn(`Suspicious rigidbody count: ${count}`)
617
- this.rigidbodies = []
618
- return
619
- }
620
-
621
- this.rigidbodies = []
622
-
623
- for (let i = 0; i < count; i++) {
624
- try {
625
- // Check bounds before reading each rigidbody
626
- if (this.offset >= this.view.buffer.byteLength) {
627
- console.warn(`Reached end of buffer while reading rigidbody ${i} of ${count}`)
628
- break
629
- }
630
-
631
- const name = this.getText()
632
- const englishName = this.getText()
633
- const boneIndex = this.getNonVertexIndex(this.boneIndexSize)
634
- const group = this.getUint8()
635
- const collisionMask = this.getUint16()
636
-
637
- const shape = this.getUint8() // 0=sphere, 1=box, 2=capsule
638
-
639
- // Size parameters (depends on shape)
640
- const sizeX = this.getFloat32()
641
- const sizeY = this.getFloat32()
642
- const sizeZ = this.getFloat32()
643
-
644
- const posX = this.getFloat32()
645
- const posY = this.getFloat32()
646
- const posZ = this.getFloat32()
647
-
648
- // Rotation (Euler angles in RADIANS in PMX file - PMX Editor displays them as degrees for convenience)
649
- // ZXY order (left-handed system)
650
- const rotX = this.getFloat32()
651
- const rotY = this.getFloat32()
652
- const rotZ = this.getFloat32()
653
-
654
- // Physical properties
655
- const mass = this.getFloat32()
656
- const linearDamping = this.getFloat32()
657
- const angularDamping = this.getFloat32()
658
- const restitution = this.getFloat32()
659
- const friction = this.getFloat32()
660
- const type = this.getUint8() // 0=static, 1=dynamic, 2=kinematic
661
-
662
- this.rigidbodies.push({
663
- name,
664
- englishName,
665
- boneIndex,
666
- group,
667
- collisionMask,
668
- shape: shape as RigidbodyShape,
669
- size: new Vec3(sizeX, sizeY, sizeZ),
670
- shapePosition: new Vec3(posX, posY, posZ),
671
- shapeRotation: new Vec3(rotX, rotY, rotZ),
672
- mass,
673
- linearDamping,
674
- angularDamping,
675
- restitution,
676
- friction,
677
- type: type as RigidbodyType,
678
- bodyOffsetMatrixInverse: Mat4.identity(),
679
- })
680
- } catch (e) {
681
- console.warn(`Error reading rigidbody ${i} of ${count}:`, e)
682
- // Stop parsing if we encounter an error
683
- break
684
- }
685
- }
686
- } catch (e) {
687
- console.warn("Error parsing rigidbodies:", e)
688
- this.rigidbodies = []
689
- }
690
- }
691
-
692
- private parseJoints(): void {
693
- try {
694
- // Check bounds before reading joint count
695
- if (this.offset + 4 > this.view.buffer.byteLength) {
696
- console.warn("Not enough bytes for joint count")
697
- this.joints = []
698
- return
699
- }
700
-
701
- const count = this.getInt32()
702
- if (count < 0 || count > 10000) {
703
- console.warn(`Suspicious joint count: ${count}`)
704
- this.joints = []
705
- return
706
- }
707
-
708
- this.joints = []
709
-
710
- for (let i = 0; i < count; i++) {
711
- try {
712
- // Check bounds before reading each joint
713
- if (this.offset >= this.view.buffer.byteLength) {
714
- console.warn(`Reached end of buffer while reading joint ${i} of ${count}`)
715
- break
716
- }
717
-
718
- const name = this.getText()
719
- const englishName = this.getText()
720
- const type = this.getUint8()
721
-
722
- // Rigidbody indices use rigidBodyIndexSize (not boneIndexSize)
723
- const rigidbodyIndexA = this.getNonVertexIndex(this.rigidBodyIndexSize)
724
- const rigidbodyIndexB = this.getNonVertexIndex(this.rigidBodyIndexSize)
725
-
726
- const posX = this.getFloat32()
727
- const posY = this.getFloat32()
728
- const posZ = this.getFloat32()
729
-
730
- // Rotation (Euler angles in RADIANS in PMX file - PMX Editor displays them as degrees for convenience)
731
- // ZXY order (left-handed system)
732
- const rotX = this.getFloat32()
733
- const rotY = this.getFloat32()
734
- const rotZ = this.getFloat32()
735
-
736
- const posMinX = this.getFloat32()
737
- const posMinY = this.getFloat32()
738
- const posMinZ = this.getFloat32()
739
-
740
- const posMaxX = this.getFloat32()
741
- const posMaxY = this.getFloat32()
742
- const posMaxZ = this.getFloat32()
743
-
744
- // Rotation constraints (in RADIANS in PMX file)
745
- const rotMinX = this.getFloat32()
746
- const rotMinY = this.getFloat32()
747
- const rotMinZ = this.getFloat32()
748
-
749
- const rotMaxX = this.getFloat32()
750
- const rotMaxY = this.getFloat32()
751
- const rotMaxZ = this.getFloat32()
752
-
753
- // Spring parameters
754
- const springPosX = this.getFloat32()
755
- const springPosY = this.getFloat32()
756
- const springPosZ = this.getFloat32()
757
-
758
- // Spring rotation (stiffness values in PMX file)
759
- const springRotX = this.getFloat32()
760
- const springRotY = this.getFloat32()
761
- const springRotZ = this.getFloat32()
762
-
763
- // Store rotations and springs as Vec3 (Euler angles), not Quat
764
- this.joints.push({
765
- name,
766
- englishName,
767
- type,
768
- rigidbodyIndexA,
769
- rigidbodyIndexB,
770
- position: new Vec3(posX, posY, posZ),
771
- rotation: new Vec3(rotX, rotY, rotZ), // Store as Vec3 (Euler angles)
772
- positionMin: new Vec3(posMinX, posMinY, posMinZ),
773
- positionMax: new Vec3(posMaxX, posMaxY, posMaxZ),
774
- rotationMin: new Vec3(rotMinX, rotMinY, rotMinZ), // Store as Vec3 (Euler angles)
775
- rotationMax: new Vec3(rotMaxX, rotMaxY, rotMaxZ), // Store as Vec3 (Euler angles)
776
- springPosition: new Vec3(springPosX, springPosY, springPosZ),
777
- springRotation: new Vec3(springRotX, springRotY, springRotZ), // Store as Vec3 (stiffness values)
778
- })
779
- } catch (e) {
780
- console.warn(`Error reading joint ${i} of ${count}:`, e)
781
- // Stop parsing if we encounter an error
782
- break
783
- }
784
- }
785
- } catch (e) {
786
- console.warn("Error parsing joints:", e)
787
- this.joints = []
788
- }
789
- }
790
-
791
- private computeInverseBind() {
792
- if (!this.bones || this.bones.length === 0) {
793
- this.inverseBindMatrices = new Float32Array(0)
794
- return
795
- }
796
- const n = this.bones.length
797
- const world = new Array<Mat4 | null>(n).fill(null)
798
- const inv = new Float32Array(n * 16)
799
-
800
- const computeWorld = (i: number): Mat4 => {
801
- if (world[i]) return world[i] as Mat4
802
- const bone = this.bones[i]
803
- const local = Mat4.identity().translateInPlace(
804
- bone.bindTranslation[0],
805
- bone.bindTranslation[1],
806
- bone.bindTranslation[2]
807
- )
808
- let w: Mat4
809
- if (bone.parentIndex >= 0 && bone.parentIndex < n) {
810
- w = computeWorld(bone.parentIndex).multiply(local)
811
- } else {
812
- w = local
813
- }
814
- world[i] = w
815
- return w
816
- }
817
-
818
- for (let i = 0; i < n; i++) {
819
- const w = computeWorld(i)
820
- const invm = Mat4.identity().translateInPlace(-w.values[12], -w.values[13], -w.values[14])
821
- inv.set(invm.values, i * 16)
822
- }
823
- this.inverseBindMatrices = inv
824
- }
825
-
826
- private toModel(positions: number[], normals: number[], uvs: number[], indices: number[]): Model {
827
- // Create indexed vertex buffer
828
- const vertexCount = positions.length / 3
829
- const vertexData = new Float32Array(vertexCount * 8)
830
-
831
- for (let i = 0; i < vertexCount; i++) {
832
- const pi = i * 3
833
- const ui = i * 2
834
- const vi = i * 8
835
-
836
- vertexData[vi] = positions[pi]
837
- vertexData[vi + 1] = positions[pi + 1]
838
- vertexData[vi + 2] = positions[pi + 2]
839
- vertexData[vi + 3] = normals[pi]
840
- vertexData[vi + 4] = normals[pi + 1]
841
- vertexData[vi + 5] = normals[pi + 2]
842
- vertexData[vi + 6] = uvs[ui]
843
- vertexData[vi + 7] = uvs[ui + 1]
844
- }
845
-
846
- // Create index buffer
847
- const indexData = new Uint32Array(indices)
848
-
849
- // Create skeleton (required)
850
- const skeleton: Skeleton = {
851
- bones: this.bones.length > 0 ? this.bones : [],
852
- inverseBindMatrices: this.inverseBindMatrices || new Float32Array(0),
853
- }
854
-
855
- let skinning: Skinning
856
- if (this.joints0 && this.weights0) {
857
- // Clamp joints to valid range now that we know bone count, and renormalize weights
858
- const boneCount = this.bones.length
859
- const joints = this.joints0
860
- const weights = this.weights0
861
- for (let i = 0; i < joints.length; i += 4) {
862
- // First pass: identify and zero out invalid joints, collect valid weight sum
863
- let validWeightSum = 0
864
- let validCount = 0
865
- for (let k = 0; k < 4; k++) {
866
- const j = joints[i + k]
867
- if (j < 0 || j >= boneCount) {
868
- // Invalid joint: zero the weight but keep joint index for debugging
869
- weights[i + k] = 0
870
- // Optionally clamp to valid range
871
- if (j < 0) {
872
- joints[i + k] = 0
873
- } else {
874
- joints[i + k] = boneCount > 0 ? boneCount - 1 : 0
875
- }
876
- } else {
877
- validWeightSum += weights[i + k]
878
- validCount++
879
- }
880
- }
881
-
882
- // If no valid weights, assign all weight to first bone (bone 0)
883
- if (validWeightSum === 0 || validCount === 0) {
884
- weights[i] = 255
885
- weights[i + 1] = 0
886
- weights[i + 2] = 0
887
- weights[i + 3] = 0
888
- joints[i] = boneCount > 0 ? 0 : 0
889
- joints[i + 1] = 0
890
- joints[i + 2] = 0
891
- joints[i + 3] = 0
892
- } else if (validWeightSum !== 255) {
893
- // Normalize valid weights to sum to exactly 255
894
- const scale = 255 / validWeightSum
895
- let accum = 0
896
- for (let k = 0; k < 3; k++) {
897
- if (joints[i + k] >= 0 && joints[i + k] < boneCount) {
898
- const v = Math.max(0, Math.min(255, Math.round(weights[i + k] * scale)))
899
- weights[i + k] = v
900
- accum += v
901
- } else {
902
- weights[i + k] = 0
903
- }
904
- }
905
- // Handle the 4th weight
906
- if (joints[i + 3] >= 0 && joints[i + 3] < boneCount) {
907
- weights[i + 3] = Math.max(0, Math.min(255, 255 - accum))
908
- } else {
909
- weights[i + 3] = 0
910
- // Redistribute the remainder to the last valid weight
911
- if (accum < 255) {
912
- for (let k = 2; k >= 0; k--) {
913
- if (joints[i + k] >= 0 && joints[i + k] < boneCount && weights[i + k] > 0) {
914
- weights[i + k] = Math.min(255, weights[i + k] + (255 - accum))
915
- break
916
- }
917
- }
918
- }
919
- }
920
-
921
- // Final safety check: ensure sum is exactly 255
922
- const finalSum = weights[i] + weights[i + 1] + weights[i + 2] + weights[i + 3]
923
- if (finalSum !== 255) {
924
- const diff = 255 - finalSum
925
- // Add/subtract difference to/from the largest valid weight
926
- let maxIdx = 0
927
- let maxWeight = weights[i]
928
- for (let k = 1; k < 4; k++) {
929
- if (weights[i + k] > maxWeight && joints[i + k] >= 0 && joints[i + k] < boneCount) {
930
- maxWeight = weights[i + k]
931
- maxIdx = k
932
- }
933
- }
934
- if (joints[i + maxIdx] >= 0 && joints[i + maxIdx] < boneCount) {
935
- weights[i + maxIdx] = Math.max(0, Math.min(255, weights[i + maxIdx] + diff))
936
- }
937
- }
938
- }
939
- }
940
- skinning = { joints, weights }
941
- } else {
942
- // Create default skinning (single bone per vertex)
943
- const vertexCount = positions.length / 3
944
- const joints = new Uint16Array(vertexCount * 4)
945
- const weights = new Uint8Array(vertexCount * 4)
946
- for (let i = 0; i < vertexCount; i++) {
947
- joints[i * 4] = 0
948
- weights[i * 4] = 255
949
- }
950
- skinning = { joints, weights }
951
- }
952
-
953
- return new Model(
954
- vertexData,
955
- indexData,
956
- this.textures,
957
- this.materials,
958
- skeleton,
959
- skinning,
960
- this.rigidbodies,
961
- this.joints
962
- )
963
- }
964
-
965
- private getUint8() {
966
- if (this.offset >= this.view.buffer.byteLength) {
967
- throw new RangeError(`Offset ${this.offset} exceeds buffer bounds ${this.view.buffer.byteLength}`)
968
- }
969
- return this.view.getUint8(this.offset++)
970
- }
971
-
972
- private getUint16() {
973
- if (this.offset + 2 > this.view.buffer.byteLength) {
974
- throw new RangeError(`Offset ${this.offset} + 2 exceeds buffer bounds ${this.view.buffer.byteLength}`)
975
- }
976
- const v = this.view.getUint16(this.offset, true)
977
- this.offset += 2
978
- return v
979
- }
980
-
981
- // Vertex index: 1->uint8, 2->uint16, 4->int32
982
- private getVertexIndex(size: number) {
983
- if (size === 1) return this.getUint8()
984
- if (size === 2) {
985
- const v = this.view.getUint16(this.offset, true)
986
- this.offset += 2
987
- return v
988
- }
989
- return this.getInt32()
990
- }
991
-
992
- // Non-vertex indices (texture/material/bone/morph/rigid): 1->int8, 2->int16, 4->int32
993
- private getNonVertexIndex(size: number) {
994
- if (size === 1) {
995
- const v = this.view.getInt8(this.offset)
996
- this.offset += 1
997
- return v
998
- }
999
- if (size === 2) {
1000
- const v = this.view.getInt16(this.offset, true)
1001
- this.offset += 2
1002
- return v
1003
- }
1004
- return this.getInt32()
1005
- }
1006
-
1007
- private getInt32() {
1008
- if (this.offset + 4 > this.view.buffer.byteLength) {
1009
- throw new RangeError(`Offset ${this.offset} + 4 exceeds buffer bounds ${this.view.buffer.byteLength}`)
1010
- }
1011
- const v = this.view.getInt32(this.offset, true)
1012
- this.offset += 4
1013
- return v
1014
- }
1015
-
1016
- private getFloat32() {
1017
- if (this.offset + 4 > this.view.buffer.byteLength) {
1018
- throw new RangeError(`Offset ${this.offset} + 4 exceeds buffer bounds ${this.view.buffer.byteLength}`)
1019
- }
1020
- const v = this.view.getFloat32(this.offset, true)
1021
- this.offset += 4
1022
- return v
1023
- }
1024
-
1025
- private getString(len: number) {
1026
- const bytes = new Uint8Array(this.view.buffer, this.offset, len)
1027
- this.offset += len
1028
- return String.fromCharCode(...bytes)
1029
- }
1030
-
1031
- private getText() {
1032
- const len = this.getInt32()
1033
- if (len <= 0) return ""
1034
-
1035
- // Debug: log problematic string lengths
1036
- if (len > 1000 || len < -1000) {
1037
- throw new RangeError(`Suspicious string length: ${len} at offset ${this.offset - 4}`)
1038
- }
1039
-
1040
- // Ensure we don't read beyond buffer bounds
1041
- if (this.offset + len > this.view.buffer.byteLength) {
1042
- throw new RangeError(`String length ${len} exceeds buffer bounds at offset ${this.offset - 4}`)
1043
- }
1044
-
1045
- const bytes = new Uint8Array(this.view.buffer, this.offset, len)
1046
- this.offset += len
1047
- return this.decoder.decode(bytes)
1048
- }
1049
-
1050
- private getIndex(size: number) {
1051
- // Backward-compat helper: defaults to vertex index behavior
1052
- return this.getVertexIndex(size)
1053
- }
1054
- }
1
+ import {
2
+ Model,
3
+ Texture,
4
+ Material,
5
+ Bone,
6
+ Skeleton,
7
+ Skinning,
8
+ Morph,
9
+ Morphing,
10
+ VertexMorphOffset,
11
+ GroupMorphReference,
12
+ IKLink,
13
+ } from "./model"
14
+ import { Mat4, Vec3 } from "./math"
15
+ import { Rigidbody, Joint, RigidbodyShape, RigidbodyType } from "./physics"
16
+
17
+ export class PmxLoader {
18
+ private view: DataView
19
+ private offset = 0
20
+ private decoder!: TextDecoder
21
+ private encoding = 0
22
+ private additionalVec4Count = 0
23
+ private vertexIndexSize = 0
24
+ private textureIndexSize = 0
25
+ private materialIndexSize = 0
26
+ private boneIndexSize = 0
27
+ private morphIndexSize = 0
28
+ private rigidBodyIndexSize = 0
29
+ private textures: Texture[] = []
30
+ private materials: Material[] = []
31
+ private bones: Bone[] = []
32
+ private inverseBindMatrices: Float32Array | null = null
33
+ private joints0: Uint16Array | null = null
34
+ private weights0: Uint8Array | null = null
35
+ private morphs: Morph[] = []
36
+ private vertexCount: number = 0
37
+ private rigidbodies: Rigidbody[] = []
38
+ private joints: Joint[] = []
39
+
40
+ private constructor(buffer: ArrayBuffer) {
41
+ this.view = new DataView(buffer)
42
+ }
43
+
44
+ static async load(url: string): Promise<Model> {
45
+ const loader = new PmxLoader(await fetch(url).then((r) => r.arrayBuffer()))
46
+ return loader.parse()
47
+ }
48
+
49
+ private parse(): Model {
50
+ this.parseHeader()
51
+ const { positions, normals, uvs } = this.parseVertices()
52
+ this.vertexCount = positions.length / 3
53
+ const indices = this.parseIndices()
54
+ this.parseTextures()
55
+ this.parseMaterials()
56
+ this.parseBones()
57
+ // Parse morphs and display frames before parsing rigidbodies
58
+ this.parseMorphs()
59
+ this.skipDisplayFrames()
60
+ this.parseRigidbodies()
61
+ this.parseJoints()
62
+ this.computeInverseBind()
63
+ return this.toModel(positions, normals, uvs, indices)
64
+ }
65
+
66
+ private parseHeader() {
67
+ if (this.getString(3) !== "PMX") throw new Error("Not a PMX file")
68
+
69
+ // PMX: 1-byte alignment after signature
70
+ this.getUint8()
71
+
72
+ // PMX: version (float32)
73
+ const version = this.getFloat32()
74
+ if (version < 2.0 || version > 2.2) {
75
+ // Continue, but warn for unexpected version
76
+ console.warn(`PMX version ${version} may not be fully supported`)
77
+ }
78
+
79
+ // PMX: globals count (uint8) followed by that many bytes describing encoding and index sizes
80
+ const globalsCount = this.getUint8()
81
+
82
+ // Validate globalsCount (must be at least 8 for PMX 2.x)
83
+ if (globalsCount < 8) {
84
+ throw new Error(`Invalid globalsCount: ${globalsCount}, expected at least 8`)
85
+ }
86
+
87
+ // Read globals (first 8 bytes are standard for PMX 2.x)
88
+ this.encoding = this.getUint8() // 0:utf16le, 1:utf8
89
+ this.additionalVec4Count = this.getUint8()
90
+ this.vertexIndexSize = this.getUint8()
91
+ this.textureIndexSize = this.getUint8()
92
+ this.materialIndexSize = this.getUint8()
93
+ this.boneIndexSize = this.getUint8()
94
+ this.morphIndexSize = this.getUint8()
95
+ this.rigidBodyIndexSize = this.getUint8()
96
+
97
+ // Skip any extra globals beyond the first 8 (for future format extensions)
98
+ if (globalsCount > 8) {
99
+ for (let i = 8; i < globalsCount; i++) {
100
+ this.getUint8()
101
+ }
102
+ }
103
+
104
+ this.decoder = new TextDecoder(this.encoding === 0 ? "utf-16le" : "utf-8")
105
+
106
+ // Skip model info (4 text fields)
107
+ this.getText()
108
+ this.getText()
109
+ this.getText()
110
+ this.getText()
111
+ }
112
+
113
+ private parseVertices() {
114
+ const count = this.getInt32()
115
+ const positions: number[] = []
116
+ const normals: number[] = []
117
+ const uvs: number[] = []
118
+ // Prepare skinning arrays (4 influences per vertex)
119
+ const joints = new Uint16Array(count * 4)
120
+ const weights = new Uint8Array(count * 4) // UNORM8, will be normalized to 255
121
+
122
+ for (let i = 0; i < count; i++) {
123
+ const px = this.getFloat32()
124
+ const py = this.getFloat32()
125
+ const pz = this.getFloat32()
126
+ positions.push(px, py, pz)
127
+
128
+ const nx = this.getFloat32()
129
+ const ny = this.getFloat32()
130
+ const nz = this.getFloat32()
131
+ normals.push(nx, ny, nz)
132
+
133
+ const u = this.getFloat32()
134
+ const v = this.getFloat32()
135
+ // PMX UVs are in the same orientation as WebGPU sampling; no flip
136
+ uvs.push(u, v)
137
+
138
+ this.offset += this.additionalVec4Count * 16
139
+ const type = this.getUint8()
140
+ const base = i * 4
141
+ // Initialize defaults
142
+ joints[base] = 0
143
+ joints[base + 1] = 0
144
+ joints[base + 2] = 0
145
+ joints[base + 3] = 0
146
+ weights[base] = 255
147
+ weights[base + 1] = 0
148
+ weights[base + 2] = 0
149
+ weights[base + 3] = 0
150
+
151
+ if (type === 0) {
152
+ // BDEF1
153
+ const j0 = this.getNonVertexIndex(this.boneIndexSize)
154
+ joints[base] = j0 >= 0 ? j0 : 0
155
+ // weight stays [255,0,0,0]
156
+ } else if (type === 1 || type === 3) {
157
+ // BDEF2 or SDEF (treated as BDEF2)
158
+ const j0 = this.getNonVertexIndex(this.boneIndexSize)
159
+ const j1 = this.getNonVertexIndex(this.boneIndexSize)
160
+ const w0f = this.getFloat32()
161
+ const w0 = Math.max(0, Math.min(255, Math.round(w0f * 255)))
162
+ const w1 = Math.max(0, Math.min(255, 255 - w0))
163
+ joints[base] = j0 >= 0 ? j0 : 0
164
+ joints[base + 1] = j1 >= 0 ? j1 : 0
165
+ weights[base] = w0
166
+ weights[base + 1] = w1
167
+ // SDEF has extra 3 vec3 (C, R0, R1)
168
+ if (type === 3) {
169
+ this.offset += 36 // 9 floats * 4 bytes
170
+ }
171
+ } else if (type === 2 || type === 4) {
172
+ // BDEF4 or QDEF (treat as LBS4)
173
+ let sum = 0
174
+ for (let k = 0; k < 4; k++) {
175
+ const j = this.getNonVertexIndex(this.boneIndexSize)
176
+ joints[base + k] = j >= 0 ? j : 0
177
+ }
178
+ const wf = [this.getFloat32(), this.getFloat32(), this.getFloat32(), this.getFloat32()]
179
+ const ws = wf.map((x) => Math.max(0, Math.min(1, x)))
180
+ const w8 = ws.map((x) => Math.round(x * 255))
181
+ sum = w8[0] + w8[1] + w8[2] + w8[3]
182
+ if (sum === 0) {
183
+ weights[base] = 255
184
+ } else {
185
+ // Normalize to 255
186
+ const scale = 255 / sum
187
+ let accum = 0
188
+ for (let k = 0; k < 3; k++) {
189
+ const v = Math.max(0, Math.min(255, Math.round(w8[k] * scale)))
190
+ weights[base + k] = v
191
+ accum += v
192
+ }
193
+ weights[base + 3] = Math.max(0, Math.min(255, 255 - accum))
194
+ }
195
+ } else {
196
+ throw new Error(`Invalid bone weight type: ${type}`)
197
+ }
198
+ this.offset += 4 // edge scale
199
+ }
200
+
201
+ this.joints0 = joints
202
+ this.weights0 = weights
203
+ return { positions, normals, uvs }
204
+ }
205
+
206
+ // (removed) skipBoneWeight – replaced by inline parsing
207
+
208
+ private parseIndices() {
209
+ const count = this.getInt32()
210
+ const indices: number[] = []
211
+
212
+ for (let i = 0; i < count; i++) {
213
+ indices.push(this.getIndex(this.vertexIndexSize))
214
+ }
215
+
216
+ return indices
217
+ }
218
+
219
+ private parseTextures() {
220
+ try {
221
+ const count = this.getInt32()
222
+ this.textures = []
223
+
224
+ for (let i = 0; i < count; i++) {
225
+ const textureName = this.getText()
226
+ this.textures.push({
227
+ path: textureName,
228
+ name: textureName.split("/").pop() || textureName, // Extract filename
229
+ })
230
+ }
231
+ } catch (error) {
232
+ console.error("Error parsing textures:", error)
233
+ this.textures = []
234
+ }
235
+ }
236
+
237
+ private parseMaterials() {
238
+ try {
239
+ const count = this.getInt32()
240
+ this.materials = []
241
+
242
+ for (let i = 0; i < count; i++) {
243
+ const name = this.getText()
244
+ this.getText() // englishName (skip)
245
+
246
+ const diffuse = [this.getFloat32(), this.getFloat32(), this.getFloat32(), this.getFloat32()] as [
247
+ number,
248
+ number,
249
+ number,
250
+ number
251
+ ]
252
+ const specular = [this.getFloat32(), this.getFloat32(), this.getFloat32()] as [number, number, number]
253
+ const shininess = this.getFloat32()
254
+ const ambient = [this.getFloat32(), this.getFloat32(), this.getFloat32()] as [number, number, number]
255
+
256
+ const flag = this.getUint8()
257
+ // edgeColor vec4
258
+ const edgeColor: [number, number, number, number] = [
259
+ this.getFloat32(),
260
+ this.getFloat32(),
261
+ this.getFloat32(),
262
+ this.getFloat32(),
263
+ ]
264
+ // edgeSize float
265
+ const edgeSize = this.getFloat32()
266
+
267
+ const textureIndex = this.getNonVertexIndex(this.textureIndexSize)
268
+ const sphereTextureIndex = this.getNonVertexIndex(this.textureIndexSize)
269
+ const sphereTextureMode = this.getUint8()
270
+
271
+ const isSharedToonTexture = this.getUint8() === 1
272
+ const toonTextureIndex = isSharedToonTexture ? this.getUint8() : this.getNonVertexIndex(this.textureIndexSize)
273
+
274
+ this.getText() // comment (skip)
275
+ const vertexCount = this.getInt32()
276
+
277
+ // PMX material flag bits:
278
+ // Bit 0 (0x01): Double-sided rendering
279
+ // Bit 4 (0x10): Edge drawing (outline)
280
+ const mat: Material = {
281
+ name,
282
+ diffuse,
283
+ specular,
284
+ ambient,
285
+ shininess,
286
+ diffuseTextureIndex: textureIndex,
287
+ normalTextureIndex: -1, // Not used in basic PMX
288
+ sphereTextureIndex,
289
+ sphereMode: sphereTextureMode,
290
+ toonTextureIndex,
291
+ edgeFlag: flag,
292
+ edgeColor,
293
+ edgeSize,
294
+ vertexCount,
295
+ }
296
+
297
+ // Classify materials based on name
298
+ const materialName = name.toLowerCase()
299
+
300
+ // Classify eye materials
301
+ mat.isEye =
302
+ materialName.includes("目") || // Japanese "eye"
303
+ materialName.includes("瞳") || // Japanese "pupil"
304
+ materialName.includes("eye") ||
305
+ materialName.includes("pupil") ||
306
+ materialName.includes("iris") ||
307
+ materialName.includes("目白") ||
308
+ materialName.includes("眼") ||
309
+ materialName.includes("睛") ||
310
+ materialName.includes("眉")
311
+
312
+ // Classify face materials
313
+ mat.isFace = materialName.includes("face") || materialName.includes("脸")
314
+
315
+ // Classify hair materials
316
+ mat.isHair = materialName.includes("hair_f")
317
+
318
+ this.materials.push(mat)
319
+ }
320
+ } catch (error) {
321
+ console.error("Error parsing materials:", error)
322
+ this.materials = []
323
+ }
324
+ }
325
+
326
+ private parseBones() {
327
+ try {
328
+ const count = this.getInt32()
329
+ const bones: Bone[] = []
330
+ // Collect absolute positions, then convert to parent-relative offsets
331
+ type AbsBone = {
332
+ name: string
333
+ parent: number
334
+ x: number
335
+ y: number
336
+ z: number
337
+ appendParent?: number
338
+ appendRatio?: number
339
+ appendRotate?: boolean
340
+ appendMove?: boolean
341
+ ikTargetIndex?: number
342
+ ikIteration?: number
343
+ ikLimitAngle?: number
344
+ ikLinks?: IKLink[]
345
+ }
346
+ const abs: AbsBone[] = new Array(count)
347
+ // PMX 2.x bone flags (best-effort common masks)
348
+ const FLAG_TAIL_IS_BONE = 0x0001
349
+ const FLAG_IK = 0x0020
350
+ const FLAG_APPEND_ROTATE = 0x0100
351
+ const FLAG_APPEND_MOVE = 0x0200
352
+ const FLAG_AXIS_LIMIT = 0x0400
353
+ const FLAG_LOCAL_AXIS = 0x0800
354
+ const FLAG_EXTERNAL_PARENT = 0x2000
355
+ for (let i = 0; i < count; i++) {
356
+ const name = this.getText()
357
+ this.getText() // englishName (skip)
358
+ // PMX is left-handed, engine is now left-handed - no conversion needed
359
+ const x = this.getFloat32()
360
+ const y = this.getFloat32()
361
+ const z = this.getFloat32()
362
+ const parentIndex = this.getNonVertexIndex(this.boneIndexSize)
363
+ this.getInt32() // transform order (skip)
364
+ const flags = this.getUint16()
365
+
366
+ // Tail: bone index or offset vector3
367
+ if ((flags & FLAG_TAIL_IS_BONE) !== 0) {
368
+ this.getNonVertexIndex(this.boneIndexSize)
369
+ } else {
370
+ // tail offset vec3
371
+ this.getFloat32()
372
+ this.getFloat32()
373
+ this.getFloat32()
374
+ }
375
+
376
+ // Append transform (inherit/ratio)
377
+ let appendParent: number | undefined = undefined
378
+ let appendRatio: number | undefined = undefined
379
+ let appendRotate = false
380
+ let appendMove = false
381
+ if ((flags & (FLAG_APPEND_ROTATE | FLAG_APPEND_MOVE)) !== 0) {
382
+ appendParent = this.getNonVertexIndex(this.boneIndexSize) // append parent
383
+ appendRatio = this.getFloat32() // ratio
384
+ appendRotate = (flags & FLAG_APPEND_ROTATE) !== 0
385
+ appendMove = (flags & FLAG_APPEND_MOVE) !== 0
386
+ }
387
+
388
+ // Axis limit
389
+ if ((flags & FLAG_AXIS_LIMIT) !== 0) {
390
+ this.getFloat32()
391
+ this.getFloat32()
392
+ this.getFloat32()
393
+ }
394
+
395
+ // Local axis (two vectors x and z)
396
+ if ((flags & FLAG_LOCAL_AXIS) !== 0) {
397
+ // local axis X
398
+ this.getFloat32()
399
+ this.getFloat32()
400
+ this.getFloat32()
401
+ // local axis Z
402
+ this.getFloat32()
403
+ this.getFloat32()
404
+ this.getFloat32()
405
+ }
406
+
407
+ // External parent transform
408
+ if ((flags & FLAG_EXTERNAL_PARENT) !== 0) {
409
+ this.getInt32()
410
+ }
411
+
412
+ // IK block
413
+ let ikTargetIndex: number | undefined = undefined
414
+ let ikIteration: number | undefined = undefined
415
+ let ikLimitAngle: number | undefined = undefined
416
+ let ikLinks: IKLink[] | undefined = undefined
417
+ if ((flags & FLAG_IK) !== 0) {
418
+ ikTargetIndex = this.getNonVertexIndex(this.boneIndexSize) // target
419
+ ikIteration = this.getInt32() // iteration
420
+ ikLimitAngle = this.getFloat32() // rotationConstraint
421
+ const linksCount = this.getInt32()
422
+ ikLinks = []
423
+ for (let li = 0; li < linksCount; li++) {
424
+ const linkBoneIndex = this.getNonVertexIndex(this.boneIndexSize) // link target
425
+ const hasLimit = this.getUint8() === 1
426
+ let minAngle: Vec3 | undefined = undefined
427
+ let maxAngle: Vec3 | undefined = undefined
428
+ if (hasLimit) {
429
+ // min and max angles (vec3 each)
430
+ const minX = this.getFloat32()
431
+ const minY = this.getFloat32()
432
+ const minZ = this.getFloat32()
433
+ const maxX = this.getFloat32()
434
+ const maxY = this.getFloat32()
435
+ const maxZ = this.getFloat32()
436
+ minAngle = new Vec3(minX, minY, minZ)
437
+ maxAngle = new Vec3(maxX, maxY, maxZ)
438
+ }
439
+ ikLinks.push({
440
+ boneIndex: linkBoneIndex,
441
+ hasLimit,
442
+ minAngle,
443
+ maxAngle,
444
+ })
445
+ }
446
+ }
447
+ // Stash minimal bone info; append data will be merged later
448
+ abs[i] = {
449
+ name,
450
+ parent: parentIndex,
451
+ x,
452
+ y,
453
+ z,
454
+ appendParent,
455
+ appendRatio,
456
+ appendRotate,
457
+ appendMove,
458
+ ikTargetIndex,
459
+ ikIteration,
460
+ ikLimitAngle,
461
+ ikLinks,
462
+ }
463
+ }
464
+ for (let i = 0; i < count; i++) {
465
+ const a = abs[i]
466
+ const boneData: Bone = {
467
+ name: a.name,
468
+ parentIndex: a.parent,
469
+ bindTranslation:
470
+ a.parent >= 0 && a.parent < count
471
+ ? [a.x - abs[a.parent].x, a.y - abs[a.parent].y, a.z - abs[a.parent].z]
472
+ : [a.x, a.y, a.z],
473
+ children: [], // Will be populated later when building skeleton
474
+ appendParentIndex: a.appendParent,
475
+ appendRatio: a.appendRatio,
476
+ appendRotate: a.appendRotate,
477
+ appendMove: a.appendMove,
478
+ ikTargetIndex: a.ikTargetIndex,
479
+ ikIteration: a.ikIteration,
480
+ ikLimitAngle: a.ikLimitAngle,
481
+ ikLinks: a.ikLinks,
482
+ }
483
+ bones.push(boneData)
484
+ }
485
+ this.bones = bones
486
+ } catch (e) {
487
+ console.warn("Error parsing bones:", e)
488
+ this.bones = []
489
+ }
490
+ }
491
+
492
+ private parseMorphs(): void {
493
+ try {
494
+ // Check if we have enough bytes to read the count
495
+ if (this.offset + 4 > this.view.buffer.byteLength) {
496
+ this.morphs = []
497
+ return
498
+ }
499
+ const count = this.getInt32()
500
+ if (count < 0 || count > 100000) {
501
+ // Suspicious count, likely corrupted
502
+ console.warn(`Suspicious morph count: ${count}`)
503
+ this.morphs = []
504
+ return
505
+ }
506
+
507
+ this.morphs = []
508
+
509
+ for (let i = 0; i < count; i++) {
510
+ // Check bounds before reading each morph
511
+ if (this.offset >= this.view.buffer.byteLength) {
512
+ break
513
+ }
514
+ try {
515
+ const name = this.getText()
516
+ this.getText() // englishName (skip)
517
+ this.getUint8() // panelType (skip)
518
+ const morphType = this.getUint8()
519
+ const offsetCount = this.getInt32()
520
+
521
+ const morph: Morph = {
522
+ name,
523
+ type: morphType,
524
+ vertexOffsets: [],
525
+ groupReferences: [],
526
+ }
527
+
528
+ // Parse vertex morphs (type 1)
529
+ if (morphType === 1) {
530
+ for (let j = 0; j < offsetCount; j++) {
531
+ if (this.offset >= this.view.buffer.byteLength) {
532
+ break
533
+ }
534
+ const vertexIndex = this.getIndex(this.vertexIndexSize)
535
+ const x = this.getFloat32()
536
+ const y = this.getFloat32()
537
+ const z = this.getFloat32()
538
+
539
+ if (vertexIndex >= 0 && vertexIndex < this.vertexCount) {
540
+ morph.vertexOffsets.push({
541
+ vertexIndex,
542
+ positionOffset: [x, y, z],
543
+ })
544
+ }
545
+ }
546
+ } else if (morphType === 0) {
547
+ // Parse group morphs
548
+ for (let j = 0; j < offsetCount; j++) {
549
+ if (this.offset >= this.view.buffer.byteLength) {
550
+ break
551
+ }
552
+ const morphIndex = this.getNonVertexIndex(this.morphIndexSize)
553
+ const ratio = this.getFloat32()
554
+
555
+ if (morphIndex >= 0) {
556
+ morph.groupReferences!.push({
557
+ morphIndex,
558
+ ratio,
559
+ })
560
+ }
561
+ }
562
+ } else {
563
+ // Skip other morph types for now
564
+ for (let j = 0; j < offsetCount; j++) {
565
+ if (this.offset >= this.view.buffer.byteLength) {
566
+ break
567
+ }
568
+ if (morphType === 2) {
569
+ // Bone morph
570
+ this.getNonVertexIndex(this.boneIndexSize) // boneIndex
571
+ this.getFloat32() // x
572
+ this.getFloat32() // y
573
+ this.getFloat32() // z
574
+ this.getFloat32() // rx
575
+ this.getFloat32() // ry
576
+ this.getFloat32() // rz
577
+ } else if (morphType === 3) {
578
+ // UV morph
579
+ this.getIndex(this.vertexIndexSize) // vertexIndex
580
+ this.getFloat32() // u
581
+ this.getFloat32() // v
582
+ } else if (morphType === 4 || morphType === 5 || morphType === 6 || morphType === 7) {
583
+ // UV morph types 4-7 (additional UV channels)
584
+ this.getIndex(this.vertexIndexSize) // vertexIndex
585
+ this.getFloat32() // u
586
+ this.getFloat32() // v
587
+ } else if (morphType === 8) {
588
+ // Material morph
589
+ this.getNonVertexIndex(this.materialIndexSize) // materialIndex
590
+ this.getUint8() // offsetType
591
+ this.getFloat32() // diffuse r
592
+ this.getFloat32() // diffuse g
593
+ this.getFloat32() // diffuse b
594
+ this.getFloat32() // diffuse a
595
+ this.getFloat32() // specular r
596
+ this.getFloat32() // specular g
597
+ this.getFloat32() // specular b
598
+ this.getFloat32() // specular power
599
+ this.getFloat32() // ambient r
600
+ this.getFloat32() // ambient g
601
+ this.getFloat32() // ambient b
602
+ this.getFloat32() // edgeColor r
603
+ this.getFloat32() // edgeColor g
604
+ this.getFloat32() // edgeColor b
605
+ this.getFloat32() // edgeColor a
606
+ this.getFloat32() // edgeSize
607
+ this.getFloat32() // textureCoeff r
608
+ this.getFloat32() // textureCoeff g
609
+ this.getFloat32() // textureCoeff b
610
+ this.getFloat32() // textureCoeff a
611
+ this.getFloat32() // sphereCoeff r
612
+ this.getFloat32() // sphereCoeff g
613
+ this.getFloat32() // sphereCoeff b
614
+ this.getFloat32() // sphereCoeff a
615
+ this.getFloat32() // toonCoeff r
616
+ this.getFloat32() // toonCoeff g
617
+ this.getFloat32() // toonCoeff b
618
+ this.getFloat32() // toonCoeff a
619
+ }
620
+ }
621
+ }
622
+
623
+ this.morphs.push(morph)
624
+ } catch (e) {
625
+ // If we fail to read a morph, skip it
626
+ console.warn(`Error reading morph ${i}:`, e)
627
+ }
628
+ }
629
+ } catch (e) {
630
+ console.warn("Error parsing morphs:", e)
631
+ this.morphs = []
632
+ }
633
+ }
634
+
635
+ private skipDisplayFrames(): boolean {
636
+ try {
637
+ // Check if we have enough bytes to read the count
638
+ if (this.offset + 4 > this.view.buffer.byteLength) {
639
+ return false
640
+ }
641
+ const count = this.getInt32()
642
+ if (count < 0 || count > 100000) {
643
+ // Suspicious count, likely corrupted - restore offset
644
+ this.offset -= 4
645
+ return false
646
+ }
647
+ for (let i = 0; i < count; i++) {
648
+ // Check bounds before reading each frame
649
+ if (this.offset >= this.view.buffer.byteLength) {
650
+ return false
651
+ }
652
+ try {
653
+ this.getText() // name
654
+ this.getText() // englishName
655
+ this.getUint8() // flag
656
+ const elementCount = this.getInt32()
657
+ for (let j = 0; j < elementCount; j++) {
658
+ if (this.offset >= this.view.buffer.byteLength) {
659
+ return false
660
+ }
661
+ const elementType = this.getUint8()
662
+ if (elementType === 0) {
663
+ // Bone frame
664
+ this.getNonVertexIndex(this.boneIndexSize)
665
+ } else if (elementType === 1) {
666
+ // Morph frame
667
+ this.getNonVertexIndex(this.morphIndexSize)
668
+ }
669
+ }
670
+ } catch (e) {
671
+ // If we fail to read a frame, stop skipping
672
+ console.warn(`Error reading display frame ${i}:`, e)
673
+ return false
674
+ }
675
+ }
676
+ return true
677
+ } catch (e) {
678
+ console.warn("Error skipping display frames:", e)
679
+ return false
680
+ }
681
+ }
682
+
683
+ private parseRigidbodies(): void {
684
+ try {
685
+ // Note: morphs and display frames are already skipped in parse() before calling this
686
+ // Check bounds before reading rigidbody count
687
+ if (this.offset + 4 > this.view.buffer.byteLength) {
688
+ console.warn("Not enough bytes for rigidbody count")
689
+ this.rigidbodies = []
690
+ return
691
+ }
692
+
693
+ const count = this.getInt32()
694
+ if (count < 0 || count > 10000) {
695
+ // Suspicious count
696
+ console.warn(`Suspicious rigidbody count: ${count}`)
697
+ this.rigidbodies = []
698
+ return
699
+ }
700
+
701
+ this.rigidbodies = []
702
+
703
+ for (let i = 0; i < count; i++) {
704
+ try {
705
+ // Check bounds before reading each rigidbody
706
+ if (this.offset >= this.view.buffer.byteLength) {
707
+ console.warn(`Reached end of buffer while reading rigidbody ${i} of ${count}`)
708
+ break
709
+ }
710
+
711
+ const name = this.getText()
712
+ const englishName = this.getText()
713
+ const boneIndex = this.getNonVertexIndex(this.boneIndexSize)
714
+ const group = this.getUint8()
715
+ const collisionMask = this.getUint16()
716
+
717
+ const shape = this.getUint8() // 0=sphere, 1=box, 2=capsule
718
+
719
+ // Size parameters (depends on shape)
720
+ const sizeX = this.getFloat32()
721
+ const sizeY = this.getFloat32()
722
+ const sizeZ = this.getFloat32()
723
+
724
+ const posX = this.getFloat32()
725
+ const posY = this.getFloat32()
726
+ const posZ = this.getFloat32()
727
+
728
+ // Rotation (Euler angles in RADIANS in PMX file - PMX Editor displays them as degrees for convenience)
729
+ // ZXY order (left-handed system)
730
+ const rotX = this.getFloat32()
731
+ const rotY = this.getFloat32()
732
+ const rotZ = this.getFloat32()
733
+
734
+ // Physical properties
735
+ const mass = this.getFloat32()
736
+ const linearDamping = this.getFloat32()
737
+ const angularDamping = this.getFloat32()
738
+ const restitution = this.getFloat32()
739
+ const friction = this.getFloat32()
740
+ const type = this.getUint8() // 0=static, 1=dynamic, 2=kinematic
741
+
742
+ this.rigidbodies.push({
743
+ name,
744
+ englishName,
745
+ boneIndex,
746
+ group,
747
+ collisionMask,
748
+ shape: shape as RigidbodyShape,
749
+ size: new Vec3(sizeX, sizeY, sizeZ),
750
+ shapePosition: new Vec3(posX, posY, posZ),
751
+ shapeRotation: new Vec3(rotX, rotY, rotZ),
752
+ mass,
753
+ linearDamping,
754
+ angularDamping,
755
+ restitution,
756
+ friction,
757
+ type: type as RigidbodyType,
758
+ bodyOffsetMatrixInverse: Mat4.identity(),
759
+ })
760
+ } catch (e) {
761
+ console.warn(`Error reading rigidbody ${i} of ${count}:`, e)
762
+ // Stop parsing if we encounter an error
763
+ break
764
+ }
765
+ }
766
+ } catch (e) {
767
+ console.warn("Error parsing rigidbodies:", e)
768
+ this.rigidbodies = []
769
+ }
770
+ }
771
+
772
+ private parseJoints(): void {
773
+ try {
774
+ // Check bounds before reading joint count
775
+ if (this.offset + 4 > this.view.buffer.byteLength) {
776
+ console.warn("Not enough bytes for joint count")
777
+ this.joints = []
778
+ return
779
+ }
780
+
781
+ const count = this.getInt32()
782
+ if (count < 0 || count > 10000) {
783
+ console.warn(`Suspicious joint count: ${count}`)
784
+ this.joints = []
785
+ return
786
+ }
787
+
788
+ this.joints = []
789
+
790
+ for (let i = 0; i < count; i++) {
791
+ try {
792
+ // Check bounds before reading each joint
793
+ if (this.offset >= this.view.buffer.byteLength) {
794
+ console.warn(`Reached end of buffer while reading joint ${i} of ${count}`)
795
+ break
796
+ }
797
+
798
+ const name = this.getText()
799
+ const englishName = this.getText()
800
+ const type = this.getUint8()
801
+
802
+ // Rigidbody indices use rigidBodyIndexSize (not boneIndexSize)
803
+ const rigidbodyIndexA = this.getNonVertexIndex(this.rigidBodyIndexSize)
804
+ const rigidbodyIndexB = this.getNonVertexIndex(this.rigidBodyIndexSize)
805
+
806
+ const posX = this.getFloat32()
807
+ const posY = this.getFloat32()
808
+ const posZ = this.getFloat32()
809
+
810
+ // Rotation (Euler angles in RADIANS in PMX file - PMX Editor displays them as degrees for convenience)
811
+ // ZXY order (left-handed system)
812
+ const rotX = this.getFloat32()
813
+ const rotY = this.getFloat32()
814
+ const rotZ = this.getFloat32()
815
+
816
+ const posMinX = this.getFloat32()
817
+ const posMinY = this.getFloat32()
818
+ const posMinZ = this.getFloat32()
819
+
820
+ const posMaxX = this.getFloat32()
821
+ const posMaxY = this.getFloat32()
822
+ const posMaxZ = this.getFloat32()
823
+
824
+ // Rotation constraints (in RADIANS in PMX file)
825
+ const rotMinX = this.getFloat32()
826
+ const rotMinY = this.getFloat32()
827
+ const rotMinZ = this.getFloat32()
828
+
829
+ const rotMaxX = this.getFloat32()
830
+ const rotMaxY = this.getFloat32()
831
+ const rotMaxZ = this.getFloat32()
832
+
833
+ // Spring parameters
834
+ const springPosX = this.getFloat32()
835
+ const springPosY = this.getFloat32()
836
+ const springPosZ = this.getFloat32()
837
+
838
+ // Spring rotation (stiffness values in PMX file)
839
+ const springRotX = this.getFloat32()
840
+ const springRotY = this.getFloat32()
841
+ const springRotZ = this.getFloat32()
842
+
843
+ // Store rotations and springs as Vec3 (Euler angles), not Quat
844
+ this.joints.push({
845
+ name,
846
+ englishName,
847
+ type,
848
+ rigidbodyIndexA,
849
+ rigidbodyIndexB,
850
+ position: new Vec3(posX, posY, posZ),
851
+ rotation: new Vec3(rotX, rotY, rotZ), // Store as Vec3 (Euler angles)
852
+ positionMin: new Vec3(posMinX, posMinY, posMinZ),
853
+ positionMax: new Vec3(posMaxX, posMaxY, posMaxZ),
854
+ rotationMin: new Vec3(rotMinX, rotMinY, rotMinZ), // Store as Vec3 (Euler angles)
855
+ rotationMax: new Vec3(rotMaxX, rotMaxY, rotMaxZ), // Store as Vec3 (Euler angles)
856
+ springPosition: new Vec3(springPosX, springPosY, springPosZ),
857
+ springRotation: new Vec3(springRotX, springRotY, springRotZ), // Store as Vec3 (stiffness values)
858
+ })
859
+ } catch (e) {
860
+ console.warn(`Error reading joint ${i} of ${count}:`, e)
861
+ // Stop parsing if we encounter an error
862
+ break
863
+ }
864
+ }
865
+ } catch (e) {
866
+ console.warn("Error parsing joints:", e)
867
+ this.joints = []
868
+ }
869
+ }
870
+
871
+ private computeInverseBind() {
872
+ if (!this.bones || this.bones.length === 0) {
873
+ this.inverseBindMatrices = new Float32Array(0)
874
+ return
875
+ }
876
+ const n = this.bones.length
877
+ const world = new Array<Mat4 | null>(n).fill(null)
878
+ const inv = new Float32Array(n * 16)
879
+
880
+ const computeWorld = (i: number): Mat4 => {
881
+ if (world[i]) return world[i] as Mat4
882
+ const bone = this.bones[i]
883
+ const local = Mat4.identity().translateInPlace(
884
+ bone.bindTranslation[0],
885
+ bone.bindTranslation[1],
886
+ bone.bindTranslation[2]
887
+ )
888
+ let w: Mat4
889
+ if (bone.parentIndex >= 0 && bone.parentIndex < n) {
890
+ w = computeWorld(bone.parentIndex).multiply(local)
891
+ } else {
892
+ w = local
893
+ }
894
+ world[i] = w
895
+ return w
896
+ }
897
+
898
+ for (let i = 0; i < n; i++) {
899
+ const w = computeWorld(i)
900
+ const invm = Mat4.identity().translateInPlace(-w.values[12], -w.values[13], -w.values[14])
901
+ inv.set(invm.values, i * 16)
902
+ }
903
+ this.inverseBindMatrices = inv
904
+ }
905
+
906
+ private toModel(positions: number[], normals: number[], uvs: number[], indices: number[]): Model {
907
+ // Create indexed vertex buffer
908
+ // Format: [x,y,z, nx,ny,nz, u,v, x,y,z, ...]
909
+ const vertexData = new Float32Array(this.vertexCount * 8)
910
+
911
+ for (let i = 0; i < this.vertexCount; i++) {
912
+ const pi = i * 3
913
+ const ui = i * 2
914
+ const vi = i * 8
915
+
916
+ vertexData[vi] = positions[pi]
917
+ vertexData[vi + 1] = positions[pi + 1]
918
+ vertexData[vi + 2] = positions[pi + 2]
919
+ vertexData[vi + 3] = normals[pi]
920
+ vertexData[vi + 4] = normals[pi + 1]
921
+ vertexData[vi + 5] = normals[pi + 2]
922
+ vertexData[vi + 6] = uvs[ui]
923
+ vertexData[vi + 7] = uvs[ui + 1]
924
+ }
925
+
926
+ // Create index buffer
927
+ const indexData = new Uint32Array(indices)
928
+
929
+ // Create skeleton (required)
930
+ const skeleton: Skeleton = {
931
+ bones: this.bones.length > 0 ? this.bones : [],
932
+ inverseBindMatrices: this.inverseBindMatrices || new Float32Array(0),
933
+ }
934
+
935
+ let skinning: Skinning
936
+ if (this.joints0 && this.weights0) {
937
+ // Clamp joints to valid range now that we know bone count, and renormalize weights
938
+ const boneCount = this.bones.length
939
+ const joints = this.joints0
940
+ const weights = this.weights0
941
+ for (let i = 0; i < joints.length; i += 4) {
942
+ // First pass: identify and zero out invalid joints, collect valid weight sum
943
+ let validWeightSum = 0
944
+ let validCount = 0
945
+ for (let k = 0; k < 4; k++) {
946
+ const j = joints[i + k]
947
+ if (j < 0 || j >= boneCount) {
948
+ // Invalid joint: zero the weight but keep joint index for debugging
949
+ weights[i + k] = 0
950
+ // Optionally clamp to valid range
951
+ if (j < 0) {
952
+ joints[i + k] = 0
953
+ } else {
954
+ joints[i + k] = boneCount > 0 ? boneCount - 1 : 0
955
+ }
956
+ } else {
957
+ validWeightSum += weights[i + k]
958
+ validCount++
959
+ }
960
+ }
961
+
962
+ // If no valid weights, assign all weight to first bone (bone 0)
963
+ if (validWeightSum === 0 || validCount === 0) {
964
+ weights[i] = 255
965
+ weights[i + 1] = 0
966
+ weights[i + 2] = 0
967
+ weights[i + 3] = 0
968
+ joints[i] = boneCount > 0 ? 0 : 0
969
+ joints[i + 1] = 0
970
+ joints[i + 2] = 0
971
+ joints[i + 3] = 0
972
+ } else if (validWeightSum !== 255) {
973
+ // Normalize valid weights to sum to exactly 255
974
+ const scale = 255 / validWeightSum
975
+ let accum = 0
976
+ for (let k = 0; k < 3; k++) {
977
+ if (joints[i + k] >= 0 && joints[i + k] < boneCount) {
978
+ const v = Math.max(0, Math.min(255, Math.round(weights[i + k] * scale)))
979
+ weights[i + k] = v
980
+ accum += v
981
+ } else {
982
+ weights[i + k] = 0
983
+ }
984
+ }
985
+ // Handle the 4th weight
986
+ if (joints[i + 3] >= 0 && joints[i + 3] < boneCount) {
987
+ weights[i + 3] = Math.max(0, Math.min(255, 255 - accum))
988
+ } else {
989
+ weights[i + 3] = 0
990
+ // Redistribute the remainder to the last valid weight
991
+ if (accum < 255) {
992
+ for (let k = 2; k >= 0; k--) {
993
+ if (joints[i + k] >= 0 && joints[i + k] < boneCount && weights[i + k] > 0) {
994
+ weights[i + k] = Math.min(255, weights[i + k] + (255 - accum))
995
+ break
996
+ }
997
+ }
998
+ }
999
+ }
1000
+
1001
+ // Final safety check: ensure sum is exactly 255
1002
+ const finalSum = weights[i] + weights[i + 1] + weights[i + 2] + weights[i + 3]
1003
+ if (finalSum !== 255) {
1004
+ const diff = 255 - finalSum
1005
+ // Add/subtract difference to/from the largest valid weight
1006
+ let maxIdx = 0
1007
+ let maxWeight = weights[i]
1008
+ for (let k = 1; k < 4; k++) {
1009
+ if (weights[i + k] > maxWeight && joints[i + k] >= 0 && joints[i + k] < boneCount) {
1010
+ maxWeight = weights[i + k]
1011
+ maxIdx = k
1012
+ }
1013
+ }
1014
+ if (joints[i + maxIdx] >= 0 && joints[i + maxIdx] < boneCount) {
1015
+ weights[i + maxIdx] = Math.max(0, Math.min(255, weights[i + maxIdx] + diff))
1016
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+ skinning = { joints, weights }
1021
+ } else {
1022
+ // Create default skinning (single bone per vertex)
1023
+ const joints = new Uint16Array(this.vertexCount * 4)
1024
+ const weights = new Uint8Array(this.vertexCount * 4)
1025
+ for (let i = 0; i < this.vertexCount; i++) {
1026
+ joints[i * 4] = 0
1027
+ weights[i * 4] = 255
1028
+ }
1029
+ skinning = { joints, weights }
1030
+ }
1031
+
1032
+ // Create morphing data structure
1033
+ const morphCount = this.morphs.length
1034
+ // Dense buffer: morphCount * vertexCount * 3 floats (one vec3 per morph per vertex)
1035
+ const offsetsBuffer = new Float32Array(morphCount * this.vertexCount * 3)
1036
+
1037
+ // Initialize all offsets to zero
1038
+ offsetsBuffer.fill(0)
1039
+
1040
+ // Fill in actual offsets for vertex morphs
1041
+ for (let morphIdx = 0; morphIdx < morphCount; morphIdx++) {
1042
+ const morph = this.morphs[morphIdx]
1043
+ if (morph.type === 1) {
1044
+ // Vertex morph
1045
+ // Store offsets in dense buffer: [morph0_v0, morph0_v1, ..., morph1_v0, ...]
1046
+ // Each vec3 is 3 consecutive floats
1047
+ for (const offset of morph.vertexOffsets) {
1048
+ // Calculate index in the dense buffer
1049
+ // Layout: morphIdx * (vertexCount * 3) + vertexIndex * 3
1050
+ // This gives us the starting float index for this morph's vertex offset
1051
+ const bufferIdx = morphIdx * this.vertexCount * 3 + offset.vertexIndex * 3
1052
+ if (
1053
+ bufferIdx >= 0 &&
1054
+ bufferIdx + 2 < offsetsBuffer.length &&
1055
+ offset.vertexIndex >= 0 &&
1056
+ offset.vertexIndex < this.vertexCount
1057
+ ) {
1058
+ offsetsBuffer[bufferIdx] = offset.positionOffset[0]
1059
+ offsetsBuffer[bufferIdx + 1] = offset.positionOffset[1]
1060
+ offsetsBuffer[bufferIdx + 2] = offset.positionOffset[2]
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ const morphing: Morphing = {
1067
+ morphs: this.morphs,
1068
+ offsetsBuffer,
1069
+ }
1070
+
1071
+ return new Model(
1072
+ vertexData,
1073
+ indexData,
1074
+ this.textures,
1075
+ this.materials,
1076
+ skeleton,
1077
+ skinning,
1078
+ morphing,
1079
+ this.rigidbodies,
1080
+ this.joints
1081
+ )
1082
+ }
1083
+
1084
+ private getUint8() {
1085
+ if (this.offset >= this.view.buffer.byteLength) {
1086
+ throw new RangeError(`Offset ${this.offset} exceeds buffer bounds ${this.view.buffer.byteLength}`)
1087
+ }
1088
+ return this.view.getUint8(this.offset++)
1089
+ }
1090
+
1091
+ private getUint16() {
1092
+ if (this.offset + 2 > this.view.buffer.byteLength) {
1093
+ throw new RangeError(`Offset ${this.offset} + 2 exceeds buffer bounds ${this.view.buffer.byteLength}`)
1094
+ }
1095
+ const v = this.view.getUint16(this.offset, true)
1096
+ this.offset += 2
1097
+ return v
1098
+ }
1099
+
1100
+ // Vertex index: 1->uint8, 2->uint16, 4->int32
1101
+ private getVertexIndex(size: number) {
1102
+ if (size === 1) return this.getUint8()
1103
+ if (size === 2) {
1104
+ const v = this.view.getUint16(this.offset, true)
1105
+ this.offset += 2
1106
+ return v
1107
+ }
1108
+ return this.getInt32()
1109
+ }
1110
+
1111
+ // Non-vertex indices (texture/material/bone/morph/rigid): 1->int8, 2->int16, 4->int32
1112
+ private getNonVertexIndex(size: number) {
1113
+ if (size === 1) {
1114
+ const v = this.view.getInt8(this.offset)
1115
+ this.offset += 1
1116
+ return v
1117
+ }
1118
+ if (size === 2) {
1119
+ const v = this.view.getInt16(this.offset, true)
1120
+ this.offset += 2
1121
+ return v
1122
+ }
1123
+ return this.getInt32()
1124
+ }
1125
+
1126
+ private getInt32() {
1127
+ if (this.offset + 4 > this.view.buffer.byteLength) {
1128
+ throw new RangeError(`Offset ${this.offset} + 4 exceeds buffer bounds ${this.view.buffer.byteLength}`)
1129
+ }
1130
+ const v = this.view.getInt32(this.offset, true)
1131
+ this.offset += 4
1132
+ return v
1133
+ }
1134
+
1135
+ private getFloat32() {
1136
+ if (this.offset + 4 > this.view.buffer.byteLength) {
1137
+ throw new RangeError(`Offset ${this.offset} + 4 exceeds buffer bounds ${this.view.buffer.byteLength}`)
1138
+ }
1139
+ const v = this.view.getFloat32(this.offset, true)
1140
+ this.offset += 4
1141
+ return v
1142
+ }
1143
+
1144
+ private getString(len: number) {
1145
+ const bytes = new Uint8Array(this.view.buffer, this.offset, len)
1146
+ this.offset += len
1147
+ return String.fromCharCode(...bytes)
1148
+ }
1149
+
1150
+ private getText() {
1151
+ const len = this.getInt32()
1152
+ if (len <= 0) return ""
1153
+
1154
+ // Debug: log problematic string lengths
1155
+ if (len > 1000 || len < -1000) {
1156
+ throw new RangeError(`Suspicious string length: ${len} at offset ${this.offset - 4}`)
1157
+ }
1158
+
1159
+ // Ensure we don't read beyond buffer bounds
1160
+ if (this.offset + len > this.view.buffer.byteLength) {
1161
+ throw new RangeError(`String length ${len} exceeds buffer bounds at offset ${this.offset - 4}`)
1162
+ }
1163
+
1164
+ const bytes = new Uint8Array(this.view.buffer, this.offset, len)
1165
+ this.offset += len
1166
+ return this.decoder.decode(bytes)
1167
+ }
1168
+
1169
+ private getIndex(size: number) {
1170
+ // Backward-compat helper: defaults to vertex index behavior
1171
+ return this.getVertexIndex(size)
1172
+ }
1173
+ }