reze-engine 0.2.19 → 0.3.1

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