reze-engine 0.1.3 → 0.1.5

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,1031 +1,1031 @@
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
- this.materials.push({
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
- } catch (error) {
283
- console.error("Error parsing materials:", error)
284
- this.materials = []
285
- }
286
- }
287
-
288
- private parseBones() {
289
- try {
290
- const count = this.getInt32()
291
- const bones: Bone[] = []
292
- // Collect absolute positions, then convert to parent-relative offsets
293
- type AbsBone = {
294
- name: string
295
- parent: number
296
- x: number
297
- y: number
298
- z: number
299
- appendParent?: number
300
- appendRatio?: number
301
- appendRotate?: boolean
302
- appendMove?: boolean
303
- }
304
- const abs: AbsBone[] = new Array(count)
305
- // PMX 2.x bone flags (best-effort common masks)
306
- const FLAG_TAIL_IS_BONE = 0x0001
307
- const FLAG_IK = 0x0020
308
- const FLAG_APPEND_ROTATE = 0x0100
309
- const FLAG_APPEND_MOVE = 0x0200
310
- const FLAG_AXIS_LIMIT = 0x0400
311
- const FLAG_LOCAL_AXIS = 0x0800
312
- const FLAG_EXTERNAL_PARENT = 0x2000
313
- for (let i = 0; i < count; i++) {
314
- const name = this.getText()
315
- this.getText() // englishName (skip)
316
- // PMX is left-handed, engine is now left-handed - no conversion needed
317
- const x = this.getFloat32()
318
- const y = this.getFloat32()
319
- const z = this.getFloat32()
320
- const parentIndex = this.getNonVertexIndex(this.boneIndexSize)
321
- this.getInt32() // transform order (skip)
322
- const flags = this.getUint16()
323
-
324
- // Tail: bone index or offset vector3
325
- if ((flags & FLAG_TAIL_IS_BONE) !== 0) {
326
- this.getNonVertexIndex(this.boneIndexSize)
327
- } else {
328
- // tail offset vec3
329
- this.getFloat32()
330
- this.getFloat32()
331
- this.getFloat32()
332
- }
333
-
334
- // Append transform (inherit/ratio)
335
- let appendParent: number | undefined = undefined
336
- let appendRatio: number | undefined = undefined
337
- let appendRotate = false
338
- let appendMove = false
339
- if ((flags & (FLAG_APPEND_ROTATE | FLAG_APPEND_MOVE)) !== 0) {
340
- appendParent = this.getNonVertexIndex(this.boneIndexSize) // append parent
341
- appendRatio = this.getFloat32() // ratio
342
- appendRotate = (flags & FLAG_APPEND_ROTATE) !== 0
343
- appendMove = (flags & FLAG_APPEND_MOVE) !== 0
344
- }
345
-
346
- // Axis limit
347
- if ((flags & FLAG_AXIS_LIMIT) !== 0) {
348
- this.getFloat32()
349
- this.getFloat32()
350
- this.getFloat32()
351
- }
352
-
353
- // Local axis (two vectors x and z)
354
- if ((flags & FLAG_LOCAL_AXIS) !== 0) {
355
- // local axis X
356
- this.getFloat32()
357
- this.getFloat32()
358
- this.getFloat32()
359
- // local axis Z
360
- this.getFloat32()
361
- this.getFloat32()
362
- this.getFloat32()
363
- }
364
-
365
- // External parent transform
366
- if ((flags & FLAG_EXTERNAL_PARENT) !== 0) {
367
- this.getInt32()
368
- }
369
-
370
- // IK block
371
- if ((flags & FLAG_IK) !== 0) {
372
- this.getNonVertexIndex(this.boneIndexSize) // target
373
- this.getInt32() // iteration
374
- this.getFloat32() // rotationConstraint
375
- const linksCount = this.getInt32()
376
- for (let li = 0; li < linksCount; li++) {
377
- this.getNonVertexIndex(this.boneIndexSize) // link target
378
- const hasLimit = this.getUint8() === 1
379
- if (hasLimit) {
380
- // min and max angles (vec3 each)
381
- this.getFloat32()
382
- this.getFloat32()
383
- this.getFloat32()
384
- this.getFloat32()
385
- this.getFloat32()
386
- this.getFloat32()
387
- }
388
- }
389
- }
390
- // Stash minimal bone info; append data will be merged later
391
- abs[i] = { name, parent: parentIndex, x, y, z, appendParent, appendRatio, appendRotate, appendMove }
392
- }
393
- for (let i = 0; i < count; i++) {
394
- const a = abs[i]
395
- if (a.parent >= 0 && a.parent < count) {
396
- const p = abs[a.parent]
397
- bones.push({
398
- name: a.name,
399
- parentIndex: a.parent,
400
- bindTranslation: [a.x - p.x, a.y - p.y, a.z - p.z],
401
- children: [], // Will be populated later when building skeleton
402
- appendParentIndex: a.appendParent,
403
- appendRatio: a.appendRatio,
404
- appendRotate: a.appendRotate,
405
- appendMove: a.appendMove,
406
- })
407
- } else {
408
- bones.push({
409
- name: a.name,
410
- parentIndex: a.parent,
411
- bindTranslation: [a.x, a.y, a.z],
412
- children: [], // Will be populated later when building skeleton
413
- appendParentIndex: a.appendParent,
414
- appendRatio: a.appendRatio,
415
- appendRotate: a.appendRotate,
416
- appendMove: a.appendMove,
417
- })
418
- }
419
- }
420
- this.bones = bones
421
- } catch (e) {
422
- console.warn("Error parsing bones:", e)
423
- this.bones = []
424
- }
425
- }
426
-
427
- private skipMorphs(): boolean {
428
- try {
429
- // Check if we have enough bytes to read the count
430
- if (this.offset + 4 > this.view.buffer.byteLength) {
431
- return false
432
- }
433
- const count = this.getInt32()
434
- if (count < 0 || count > 100000) {
435
- // Suspicious count, likely corrupted - restore offset
436
- this.offset -= 4
437
- return false
438
- }
439
- for (let i = 0; i < count; i++) {
440
- // Check bounds before reading each morph
441
- if (this.offset >= this.view.buffer.byteLength) {
442
- return false
443
- }
444
- try {
445
- this.getText() // name
446
- this.getText() // englishName
447
- this.getUint8() // panelType
448
- const morphType = this.getUint8()
449
- const offsetCount = this.getInt32()
450
-
451
- // Skip offsets based on morph type
452
- for (let j = 0; j < offsetCount; j++) {
453
- if (this.offset >= this.view.buffer.byteLength) {
454
- return false
455
- }
456
- if (morphType === 0) {
457
- // Group morph
458
- this.getNonVertexIndex(this.morphIndexSize) // morphIndex
459
- this.getFloat32() // ratio
460
- } else if (morphType === 1) {
461
- // Vertex morph
462
- this.getIndex(this.vertexIndexSize) // vertexIndex
463
- this.getFloat32() // x
464
- this.getFloat32() // y
465
- this.getFloat32() // z
466
- } else if (morphType === 2) {
467
- // Bone morph
468
- this.getNonVertexIndex(this.boneIndexSize) // boneIndex
469
- this.getFloat32() // x
470
- this.getFloat32() // y
471
- this.getFloat32() // z
472
- this.getFloat32() // rx
473
- this.getFloat32() // ry
474
- this.getFloat32() // rz
475
- } else if (morphType === 3) {
476
- // UV morph
477
- this.getIndex(this.vertexIndexSize) // vertexIndex
478
- this.getFloat32() // u
479
- this.getFloat32() // v
480
- } else if (morphType === 4 || morphType === 5 || morphType === 6 || morphType === 7) {
481
- // UV morph types 4-7 (additional UV channels)
482
- this.getIndex(this.vertexIndexSize) // vertexIndex
483
- this.getFloat32() // u
484
- this.getFloat32() // v
485
- } else if (morphType === 8) {
486
- // Material morph
487
- this.getNonVertexIndex(this.materialIndexSize) // materialIndex
488
- this.getUint8() // offsetType
489
- this.getFloat32() // diffuse r
490
- this.getFloat32() // diffuse g
491
- this.getFloat32() // diffuse b
492
- this.getFloat32() // diffuse a
493
- this.getFloat32() // specular r
494
- this.getFloat32() // specular g
495
- this.getFloat32() // specular b
496
- this.getFloat32() // specular power
497
- this.getFloat32() // ambient r
498
- this.getFloat32() // ambient g
499
- this.getFloat32() // ambient b
500
- this.getFloat32() // edgeColor r
501
- this.getFloat32() // edgeColor g
502
- this.getFloat32() // edgeColor b
503
- this.getFloat32() // edgeColor a
504
- this.getFloat32() // edgeSize
505
- this.getFloat32() // textureCoeff r
506
- this.getFloat32() // textureCoeff g
507
- this.getFloat32() // textureCoeff b
508
- this.getFloat32() // textureCoeff a
509
- this.getFloat32() // sphereCoeff r
510
- this.getFloat32() // sphereCoeff g
511
- this.getFloat32() // sphereCoeff b
512
- this.getFloat32() // sphereCoeff a
513
- this.getFloat32() // toonCoeff r
514
- this.getFloat32() // toonCoeff g
515
- this.getFloat32() // toonCoeff b
516
- this.getFloat32() // toonCoeff a
517
- }
518
- }
519
- } catch (e) {
520
- // If we fail to read a morph, stop skipping
521
- console.warn(`Error reading morph ${i}:`, e)
522
- return false
523
- }
524
- }
525
- return true
526
- } catch (e) {
527
- console.warn("Error skipping morphs:", e)
528
- return false
529
- }
530
- }
531
-
532
- private skipDisplayFrames(): boolean {
533
- try {
534
- // Check if we have enough bytes to read the count
535
- if (this.offset + 4 > this.view.buffer.byteLength) {
536
- return false
537
- }
538
- const count = this.getInt32()
539
- if (count < 0 || count > 100000) {
540
- // Suspicious count, likely corrupted - restore offset
541
- this.offset -= 4
542
- return false
543
- }
544
- for (let i = 0; i < count; i++) {
545
- // Check bounds before reading each frame
546
- if (this.offset >= this.view.buffer.byteLength) {
547
- return false
548
- }
549
- try {
550
- this.getText() // name
551
- this.getText() // englishName
552
- this.getUint8() // flag
553
- const elementCount = this.getInt32()
554
- for (let j = 0; j < elementCount; j++) {
555
- if (this.offset >= this.view.buffer.byteLength) {
556
- return false
557
- }
558
- const elementType = this.getUint8()
559
- if (elementType === 0) {
560
- // Bone frame
561
- this.getNonVertexIndex(this.boneIndexSize)
562
- } else if (elementType === 1) {
563
- // Morph frame
564
- this.getNonVertexIndex(this.morphIndexSize)
565
- }
566
- }
567
- } catch (e) {
568
- // If we fail to read a frame, stop skipping
569
- console.warn(`Error reading display frame ${i}:`, e)
570
- return false
571
- }
572
- }
573
- return true
574
- } catch (e) {
575
- console.warn("Error skipping display frames:", e)
576
- return false
577
- }
578
- }
579
-
580
- private parseRigidbodies(): void {
581
- try {
582
- // Note: morphs and display frames are already skipped in parse() before calling this
583
- // Check bounds before reading rigidbody count
584
- if (this.offset + 4 > this.view.buffer.byteLength) {
585
- console.warn("Not enough bytes for rigidbody count")
586
- this.rigidbodies = []
587
- return
588
- }
589
-
590
- const count = this.getInt32()
591
- if (count < 0 || count > 10000) {
592
- // Suspicious count
593
- console.warn(`Suspicious rigidbody count: ${count}`)
594
- this.rigidbodies = []
595
- return
596
- }
597
-
598
- this.rigidbodies = []
599
-
600
- for (let i = 0; i < count; i++) {
601
- try {
602
- // Check bounds before reading each rigidbody
603
- if (this.offset >= this.view.buffer.byteLength) {
604
- console.warn(`Reached end of buffer while reading rigidbody ${i} of ${count}`)
605
- break
606
- }
607
-
608
- const name = this.getText()
609
- const englishName = this.getText()
610
- const boneIndex = this.getNonVertexIndex(this.boneIndexSize)
611
- const group = this.getUint8()
612
- const collisionMask = this.getUint16()
613
-
614
- const shape = this.getUint8() // 0=sphere, 1=box, 2=capsule
615
-
616
- // Size parameters (depends on shape)
617
- const sizeX = this.getFloat32()
618
- const sizeY = this.getFloat32()
619
- const sizeZ = this.getFloat32()
620
-
621
- const posX = this.getFloat32()
622
- const posY = this.getFloat32()
623
- const posZ = this.getFloat32()
624
-
625
- // Rotation (Euler angles in RADIANS in PMX file - PMX Editor displays them as degrees for convenience)
626
- // ZXY order (left-handed system)
627
- const rotX = this.getFloat32()
628
- const rotY = this.getFloat32()
629
- const rotZ = this.getFloat32()
630
-
631
- // Physical properties
632
- const mass = this.getFloat32()
633
- const linearDamping = this.getFloat32()
634
- const angularDamping = this.getFloat32()
635
- const restitution = this.getFloat32()
636
- const friction = this.getFloat32()
637
- const type = this.getUint8() // 0=static, 1=dynamic, 2=kinematic
638
-
639
- this.rigidbodies.push({
640
- name,
641
- englishName,
642
- boneIndex,
643
- group,
644
- collisionMask,
645
- shape: shape as RigidbodyShape,
646
- size: new Vec3(sizeX, sizeY, sizeZ),
647
- shapePosition: new Vec3(posX, posY, posZ),
648
- shapeRotation: new Vec3(rotX, rotY, rotZ),
649
- mass,
650
- linearDamping,
651
- angularDamping,
652
- restitution,
653
- friction,
654
- type: type as RigidbodyType,
655
- bodyOffsetMatrixInverse: Mat4.identity(),
656
- })
657
- } catch (e) {
658
- console.warn(`Error reading rigidbody ${i} of ${count}:`, e)
659
- // Stop parsing if we encounter an error
660
- break
661
- }
662
- }
663
- } catch (e) {
664
- console.warn("Error parsing rigidbodies:", e)
665
- this.rigidbodies = []
666
- }
667
- }
668
-
669
- private parseJoints(): void {
670
- try {
671
- // Check bounds before reading joint count
672
- if (this.offset + 4 > this.view.buffer.byteLength) {
673
- console.warn("Not enough bytes for joint count")
674
- this.joints = []
675
- return
676
- }
677
-
678
- const count = this.getInt32()
679
- if (count < 0 || count > 10000) {
680
- console.warn(`Suspicious joint count: ${count}`)
681
- this.joints = []
682
- return
683
- }
684
-
685
- this.joints = []
686
-
687
- for (let i = 0; i < count; i++) {
688
- try {
689
- // Check bounds before reading each joint
690
- if (this.offset >= this.view.buffer.byteLength) {
691
- console.warn(`Reached end of buffer while reading joint ${i} of ${count}`)
692
- break
693
- }
694
-
695
- const name = this.getText()
696
- const englishName = this.getText()
697
- const type = this.getUint8()
698
-
699
- // Rigidbody indices use rigidBodyIndexSize (not boneIndexSize)
700
- const rigidbodyIndexA = this.getNonVertexIndex(this.rigidBodyIndexSize)
701
- const rigidbodyIndexB = this.getNonVertexIndex(this.rigidBodyIndexSize)
702
-
703
- const posX = this.getFloat32()
704
- const posY = this.getFloat32()
705
- const posZ = this.getFloat32()
706
-
707
- // Rotation (Euler angles in RADIANS in PMX file - PMX Editor displays them as degrees for convenience)
708
- // ZXY order (left-handed system)
709
- const rotX = this.getFloat32()
710
- const rotY = this.getFloat32()
711
- const rotZ = this.getFloat32()
712
-
713
- const posMinX = this.getFloat32()
714
- const posMinY = this.getFloat32()
715
- const posMinZ = this.getFloat32()
716
-
717
- const posMaxX = this.getFloat32()
718
- const posMaxY = this.getFloat32()
719
- const posMaxZ = this.getFloat32()
720
-
721
- // Rotation constraints (in RADIANS in PMX file)
722
- const rotMinX = this.getFloat32()
723
- const rotMinY = this.getFloat32()
724
- const rotMinZ = this.getFloat32()
725
-
726
- const rotMaxX = this.getFloat32()
727
- const rotMaxY = this.getFloat32()
728
- const rotMaxZ = this.getFloat32()
729
-
730
- // Spring parameters
731
- const springPosX = this.getFloat32()
732
- const springPosY = this.getFloat32()
733
- const springPosZ = this.getFloat32()
734
-
735
- // Spring rotation (stiffness values in PMX file)
736
- const springRotX = this.getFloat32()
737
- const springRotY = this.getFloat32()
738
- const springRotZ = this.getFloat32()
739
-
740
- // Store rotations and springs as Vec3 (Euler angles), not Quat
741
- this.joints.push({
742
- name,
743
- englishName,
744
- type,
745
- rigidbodyIndexA,
746
- rigidbodyIndexB,
747
- position: new Vec3(posX, posY, posZ),
748
- rotation: new Vec3(rotX, rotY, rotZ), // Store as Vec3 (Euler angles)
749
- positionMin: new Vec3(posMinX, posMinY, posMinZ),
750
- positionMax: new Vec3(posMaxX, posMaxY, posMaxZ),
751
- rotationMin: new Vec3(rotMinX, rotMinY, rotMinZ), // Store as Vec3 (Euler angles)
752
- rotationMax: new Vec3(rotMaxX, rotMaxY, rotMaxZ), // Store as Vec3 (Euler angles)
753
- springPosition: new Vec3(springPosX, springPosY, springPosZ),
754
- springRotation: new Vec3(springRotX, springRotY, springRotZ), // Store as Vec3 (stiffness values)
755
- })
756
- } catch (e) {
757
- console.warn(`Error reading joint ${i} of ${count}:`, e)
758
- // Stop parsing if we encounter an error
759
- break
760
- }
761
- }
762
- } catch (e) {
763
- console.warn("Error parsing joints:", e)
764
- this.joints = []
765
- }
766
- }
767
-
768
- private computeInverseBind() {
769
- if (!this.bones || this.bones.length === 0) {
770
- this.inverseBindMatrices = new Float32Array(0)
771
- return
772
- }
773
- const n = this.bones.length
774
- const world = new Array<Mat4 | null>(n).fill(null)
775
- const inv = new Float32Array(n * 16)
776
-
777
- const computeWorld = (i: number): Mat4 => {
778
- if (world[i]) return world[i] as Mat4
779
- const bone = this.bones[i]
780
- const local = Mat4.identity().translateInPlace(
781
- bone.bindTranslation[0],
782
- bone.bindTranslation[1],
783
- bone.bindTranslation[2]
784
- )
785
- let w: Mat4
786
- if (bone.parentIndex >= 0 && bone.parentIndex < n) {
787
- w = computeWorld(bone.parentIndex).multiply(local)
788
- } else {
789
- w = local
790
- }
791
- world[i] = w
792
- return w
793
- }
794
-
795
- for (let i = 0; i < n; i++) {
796
- const w = computeWorld(i)
797
- const invm = Mat4.identity().translateInPlace(-w.values[12], -w.values[13], -w.values[14])
798
- inv.set(invm.values, i * 16)
799
- }
800
- this.inverseBindMatrices = inv
801
- }
802
-
803
- private toModel(positions: number[], normals: number[], uvs: number[], indices: number[]): Model {
804
- // Create indexed vertex buffer
805
- const vertexCount = positions.length / 3
806
- const vertexData = new Float32Array(vertexCount * 8)
807
-
808
- for (let i = 0; i < vertexCount; i++) {
809
- const pi = i * 3
810
- const ui = i * 2
811
- const vi = i * 8
812
-
813
- vertexData[vi] = positions[pi]
814
- vertexData[vi + 1] = positions[pi + 1]
815
- vertexData[vi + 2] = positions[pi + 2]
816
- vertexData[vi + 3] = normals[pi]
817
- vertexData[vi + 4] = normals[pi + 1]
818
- vertexData[vi + 5] = normals[pi + 2]
819
- vertexData[vi + 6] = uvs[ui]
820
- vertexData[vi + 7] = uvs[ui + 1]
821
- }
822
-
823
- // Create index buffer
824
- const indexData = new Uint32Array(indices)
825
-
826
- // Create skeleton (required)
827
- const skeleton: Skeleton = {
828
- bones: this.bones.length > 0 ? this.bones : [],
829
- inverseBindMatrices: this.inverseBindMatrices || new Float32Array(0),
830
- }
831
-
832
- let skinning: Skinning
833
- if (this.joints0 && this.weights0) {
834
- // Clamp joints to valid range now that we know bone count, and renormalize weights
835
- const boneCount = this.bones.length
836
- const joints = this.joints0
837
- const weights = this.weights0
838
- for (let i = 0; i < joints.length; i += 4) {
839
- // First pass: identify and zero out invalid joints, collect valid weight sum
840
- let validWeightSum = 0
841
- let validCount = 0
842
- for (let k = 0; k < 4; k++) {
843
- const j = joints[i + k]
844
- if (j < 0 || j >= boneCount) {
845
- // Invalid joint: zero the weight but keep joint index for debugging
846
- weights[i + k] = 0
847
- // Optionally clamp to valid range
848
- if (j < 0) {
849
- joints[i + k] = 0
850
- } else {
851
- joints[i + k] = boneCount > 0 ? boneCount - 1 : 0
852
- }
853
- } else {
854
- validWeightSum += weights[i + k]
855
- validCount++
856
- }
857
- }
858
-
859
- // If no valid weights, assign all weight to first bone (bone 0)
860
- if (validWeightSum === 0 || validCount === 0) {
861
- weights[i] = 255
862
- weights[i + 1] = 0
863
- weights[i + 2] = 0
864
- weights[i + 3] = 0
865
- joints[i] = boneCount > 0 ? 0 : 0
866
- joints[i + 1] = 0
867
- joints[i + 2] = 0
868
- joints[i + 3] = 0
869
- } else if (validWeightSum !== 255) {
870
- // Normalize valid weights to sum to exactly 255
871
- const scale = 255 / validWeightSum
872
- let accum = 0
873
- for (let k = 0; k < 3; k++) {
874
- if (joints[i + k] >= 0 && joints[i + k] < boneCount) {
875
- const v = Math.max(0, Math.min(255, Math.round(weights[i + k] * scale)))
876
- weights[i + k] = v
877
- accum += v
878
- } else {
879
- weights[i + k] = 0
880
- }
881
- }
882
- // Handle the 4th weight
883
- if (joints[i + 3] >= 0 && joints[i + 3] < boneCount) {
884
- weights[i + 3] = Math.max(0, Math.min(255, 255 - accum))
885
- } else {
886
- weights[i + 3] = 0
887
- // Redistribute the remainder to the last valid weight
888
- if (accum < 255) {
889
- for (let k = 2; k >= 0; k--) {
890
- if (joints[i + k] >= 0 && joints[i + k] < boneCount && weights[i + k] > 0) {
891
- weights[i + k] = Math.min(255, weights[i + k] + (255 - accum))
892
- break
893
- }
894
- }
895
- }
896
- }
897
-
898
- // Final safety check: ensure sum is exactly 255
899
- const finalSum = weights[i] + weights[i + 1] + weights[i + 2] + weights[i + 3]
900
- if (finalSum !== 255) {
901
- const diff = 255 - finalSum
902
- // Add/subtract difference to/from the largest valid weight
903
- let maxIdx = 0
904
- let maxWeight = weights[i]
905
- for (let k = 1; k < 4; k++) {
906
- if (weights[i + k] > maxWeight && joints[i + k] >= 0 && joints[i + k] < boneCount) {
907
- maxWeight = weights[i + k]
908
- maxIdx = k
909
- }
910
- }
911
- if (joints[i + maxIdx] >= 0 && joints[i + maxIdx] < boneCount) {
912
- weights[i + maxIdx] = Math.max(0, Math.min(255, weights[i + maxIdx] + diff))
913
- }
914
- }
915
- }
916
- }
917
- skinning = { joints, weights }
918
- } else {
919
- // Create default skinning (single bone per vertex)
920
- const vertexCount = positions.length / 3
921
- const joints = new Uint16Array(vertexCount * 4)
922
- const weights = new Uint8Array(vertexCount * 4)
923
- for (let i = 0; i < vertexCount; i++) {
924
- joints[i * 4] = 0
925
- weights[i * 4] = 255
926
- }
927
- skinning = { joints, weights }
928
- }
929
-
930
- return new Model(
931
- vertexData,
932
- indexData,
933
- this.textures,
934
- this.materials,
935
- skeleton,
936
- skinning,
937
- this.rigidbodies,
938
- this.joints
939
- )
940
- }
941
-
942
- private getUint8() {
943
- if (this.offset >= this.view.buffer.byteLength) {
944
- throw new RangeError(`Offset ${this.offset} exceeds buffer bounds ${this.view.buffer.byteLength}`)
945
- }
946
- return this.view.getUint8(this.offset++)
947
- }
948
-
949
- private getUint16() {
950
- if (this.offset + 2 > this.view.buffer.byteLength) {
951
- throw new RangeError(`Offset ${this.offset} + 2 exceeds buffer bounds ${this.view.buffer.byteLength}`)
952
- }
953
- const v = this.view.getUint16(this.offset, true)
954
- this.offset += 2
955
- return v
956
- }
957
-
958
- // Vertex index: 1->uint8, 2->uint16, 4->int32
959
- private getVertexIndex(size: number) {
960
- if (size === 1) return this.getUint8()
961
- if (size === 2) {
962
- const v = this.view.getUint16(this.offset, true)
963
- this.offset += 2
964
- return v
965
- }
966
- return this.getInt32()
967
- }
968
-
969
- // Non-vertex indices (texture/material/bone/morph/rigid): 1->int8, 2->int16, 4->int32
970
- private getNonVertexIndex(size: number) {
971
- if (size === 1) {
972
- const v = this.view.getInt8(this.offset)
973
- this.offset += 1
974
- return v
975
- }
976
- if (size === 2) {
977
- const v = this.view.getInt16(this.offset, true)
978
- this.offset += 2
979
- return v
980
- }
981
- return this.getInt32()
982
- }
983
-
984
- private getInt32() {
985
- if (this.offset + 4 > this.view.buffer.byteLength) {
986
- throw new RangeError(`Offset ${this.offset} + 4 exceeds buffer bounds ${this.view.buffer.byteLength}`)
987
- }
988
- const v = this.view.getInt32(this.offset, true)
989
- this.offset += 4
990
- return v
991
- }
992
-
993
- private getFloat32() {
994
- if (this.offset + 4 > this.view.buffer.byteLength) {
995
- throw new RangeError(`Offset ${this.offset} + 4 exceeds buffer bounds ${this.view.buffer.byteLength}`)
996
- }
997
- const v = this.view.getFloat32(this.offset, true)
998
- this.offset += 4
999
- return v
1000
- }
1001
-
1002
- private getString(len: number) {
1003
- const bytes = new Uint8Array(this.view.buffer, this.offset, len)
1004
- this.offset += len
1005
- return String.fromCharCode(...bytes)
1006
- }
1007
-
1008
- private getText() {
1009
- const len = this.getInt32()
1010
- if (len <= 0) return ""
1011
-
1012
- // Debug: log problematic string lengths
1013
- if (len > 1000 || len < -1000) {
1014
- throw new RangeError(`Suspicious string length: ${len} at offset ${this.offset - 4}`)
1015
- }
1016
-
1017
- // Ensure we don't read beyond buffer bounds
1018
- if (this.offset + len > this.view.buffer.byteLength) {
1019
- throw new RangeError(`String length ${len} exceeds buffer bounds at offset ${this.offset - 4}`)
1020
- }
1021
-
1022
- const bytes = new Uint8Array(this.view.buffer, this.offset, len)
1023
- this.offset += len
1024
- return this.decoder.decode(bytes)
1025
- }
1026
-
1027
- private getIndex(size: number) {
1028
- // Backward-compat helper: defaults to vertex index behavior
1029
- return this.getVertexIndex(size)
1030
- }
1031
- }
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
+ this.materials.push({
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
+ } catch (error) {
283
+ console.error("Error parsing materials:", error)
284
+ this.materials = []
285
+ }
286
+ }
287
+
288
+ private parseBones() {
289
+ try {
290
+ const count = this.getInt32()
291
+ const bones: Bone[] = []
292
+ // Collect absolute positions, then convert to parent-relative offsets
293
+ type AbsBone = {
294
+ name: string
295
+ parent: number
296
+ x: number
297
+ y: number
298
+ z: number
299
+ appendParent?: number
300
+ appendRatio?: number
301
+ appendRotate?: boolean
302
+ appendMove?: boolean
303
+ }
304
+ const abs: AbsBone[] = new Array(count)
305
+ // PMX 2.x bone flags (best-effort common masks)
306
+ const FLAG_TAIL_IS_BONE = 0x0001
307
+ const FLAG_IK = 0x0020
308
+ const FLAG_APPEND_ROTATE = 0x0100
309
+ const FLAG_APPEND_MOVE = 0x0200
310
+ const FLAG_AXIS_LIMIT = 0x0400
311
+ const FLAG_LOCAL_AXIS = 0x0800
312
+ const FLAG_EXTERNAL_PARENT = 0x2000
313
+ for (let i = 0; i < count; i++) {
314
+ const name = this.getText()
315
+ this.getText() // englishName (skip)
316
+ // PMX is left-handed, engine is now left-handed - no conversion needed
317
+ const x = this.getFloat32()
318
+ const y = this.getFloat32()
319
+ const z = this.getFloat32()
320
+ const parentIndex = this.getNonVertexIndex(this.boneIndexSize)
321
+ this.getInt32() // transform order (skip)
322
+ const flags = this.getUint16()
323
+
324
+ // Tail: bone index or offset vector3
325
+ if ((flags & FLAG_TAIL_IS_BONE) !== 0) {
326
+ this.getNonVertexIndex(this.boneIndexSize)
327
+ } else {
328
+ // tail offset vec3
329
+ this.getFloat32()
330
+ this.getFloat32()
331
+ this.getFloat32()
332
+ }
333
+
334
+ // Append transform (inherit/ratio)
335
+ let appendParent: number | undefined = undefined
336
+ let appendRatio: number | undefined = undefined
337
+ let appendRotate = false
338
+ let appendMove = false
339
+ if ((flags & (FLAG_APPEND_ROTATE | FLAG_APPEND_MOVE)) !== 0) {
340
+ appendParent = this.getNonVertexIndex(this.boneIndexSize) // append parent
341
+ appendRatio = this.getFloat32() // ratio
342
+ appendRotate = (flags & FLAG_APPEND_ROTATE) !== 0
343
+ appendMove = (flags & FLAG_APPEND_MOVE) !== 0
344
+ }
345
+
346
+ // Axis limit
347
+ if ((flags & FLAG_AXIS_LIMIT) !== 0) {
348
+ this.getFloat32()
349
+ this.getFloat32()
350
+ this.getFloat32()
351
+ }
352
+
353
+ // Local axis (two vectors x and z)
354
+ if ((flags & FLAG_LOCAL_AXIS) !== 0) {
355
+ // local axis X
356
+ this.getFloat32()
357
+ this.getFloat32()
358
+ this.getFloat32()
359
+ // local axis Z
360
+ this.getFloat32()
361
+ this.getFloat32()
362
+ this.getFloat32()
363
+ }
364
+
365
+ // External parent transform
366
+ if ((flags & FLAG_EXTERNAL_PARENT) !== 0) {
367
+ this.getInt32()
368
+ }
369
+
370
+ // IK block
371
+ if ((flags & FLAG_IK) !== 0) {
372
+ this.getNonVertexIndex(this.boneIndexSize) // target
373
+ this.getInt32() // iteration
374
+ this.getFloat32() // rotationConstraint
375
+ const linksCount = this.getInt32()
376
+ for (let li = 0; li < linksCount; li++) {
377
+ this.getNonVertexIndex(this.boneIndexSize) // link target
378
+ const hasLimit = this.getUint8() === 1
379
+ if (hasLimit) {
380
+ // min and max angles (vec3 each)
381
+ this.getFloat32()
382
+ this.getFloat32()
383
+ this.getFloat32()
384
+ this.getFloat32()
385
+ this.getFloat32()
386
+ this.getFloat32()
387
+ }
388
+ }
389
+ }
390
+ // Stash minimal bone info; append data will be merged later
391
+ abs[i] = { name, parent: parentIndex, x, y, z, appendParent, appendRatio, appendRotate, appendMove }
392
+ }
393
+ for (let i = 0; i < count; i++) {
394
+ const a = abs[i]
395
+ if (a.parent >= 0 && a.parent < count) {
396
+ const p = abs[a.parent]
397
+ bones.push({
398
+ name: a.name,
399
+ parentIndex: a.parent,
400
+ bindTranslation: [a.x - p.x, a.y - p.y, a.z - p.z],
401
+ children: [], // Will be populated later when building skeleton
402
+ appendParentIndex: a.appendParent,
403
+ appendRatio: a.appendRatio,
404
+ appendRotate: a.appendRotate,
405
+ appendMove: a.appendMove,
406
+ })
407
+ } else {
408
+ bones.push({
409
+ name: a.name,
410
+ parentIndex: a.parent,
411
+ bindTranslation: [a.x, a.y, a.z],
412
+ children: [], // Will be populated later when building skeleton
413
+ appendParentIndex: a.appendParent,
414
+ appendRatio: a.appendRatio,
415
+ appendRotate: a.appendRotate,
416
+ appendMove: a.appendMove,
417
+ })
418
+ }
419
+ }
420
+ this.bones = bones
421
+ } catch (e) {
422
+ console.warn("Error parsing bones:", e)
423
+ this.bones = []
424
+ }
425
+ }
426
+
427
+ private skipMorphs(): boolean {
428
+ try {
429
+ // Check if we have enough bytes to read the count
430
+ if (this.offset + 4 > this.view.buffer.byteLength) {
431
+ return false
432
+ }
433
+ const count = this.getInt32()
434
+ if (count < 0 || count > 100000) {
435
+ // Suspicious count, likely corrupted - restore offset
436
+ this.offset -= 4
437
+ return false
438
+ }
439
+ for (let i = 0; i < count; i++) {
440
+ // Check bounds before reading each morph
441
+ if (this.offset >= this.view.buffer.byteLength) {
442
+ return false
443
+ }
444
+ try {
445
+ this.getText() // name
446
+ this.getText() // englishName
447
+ this.getUint8() // panelType
448
+ const morphType = this.getUint8()
449
+ const offsetCount = this.getInt32()
450
+
451
+ // Skip offsets based on morph type
452
+ for (let j = 0; j < offsetCount; j++) {
453
+ if (this.offset >= this.view.buffer.byteLength) {
454
+ return false
455
+ }
456
+ if (morphType === 0) {
457
+ // Group morph
458
+ this.getNonVertexIndex(this.morphIndexSize) // morphIndex
459
+ this.getFloat32() // ratio
460
+ } else if (morphType === 1) {
461
+ // Vertex morph
462
+ this.getIndex(this.vertexIndexSize) // vertexIndex
463
+ this.getFloat32() // x
464
+ this.getFloat32() // y
465
+ this.getFloat32() // z
466
+ } else if (morphType === 2) {
467
+ // Bone morph
468
+ this.getNonVertexIndex(this.boneIndexSize) // boneIndex
469
+ this.getFloat32() // x
470
+ this.getFloat32() // y
471
+ this.getFloat32() // z
472
+ this.getFloat32() // rx
473
+ this.getFloat32() // ry
474
+ this.getFloat32() // rz
475
+ } else if (morphType === 3) {
476
+ // UV morph
477
+ this.getIndex(this.vertexIndexSize) // vertexIndex
478
+ this.getFloat32() // u
479
+ this.getFloat32() // v
480
+ } else if (morphType === 4 || morphType === 5 || morphType === 6 || morphType === 7) {
481
+ // UV morph types 4-7 (additional UV channels)
482
+ this.getIndex(this.vertexIndexSize) // vertexIndex
483
+ this.getFloat32() // u
484
+ this.getFloat32() // v
485
+ } else if (morphType === 8) {
486
+ // Material morph
487
+ this.getNonVertexIndex(this.materialIndexSize) // materialIndex
488
+ this.getUint8() // offsetType
489
+ this.getFloat32() // diffuse r
490
+ this.getFloat32() // diffuse g
491
+ this.getFloat32() // diffuse b
492
+ this.getFloat32() // diffuse a
493
+ this.getFloat32() // specular r
494
+ this.getFloat32() // specular g
495
+ this.getFloat32() // specular b
496
+ this.getFloat32() // specular power
497
+ this.getFloat32() // ambient r
498
+ this.getFloat32() // ambient g
499
+ this.getFloat32() // ambient b
500
+ this.getFloat32() // edgeColor r
501
+ this.getFloat32() // edgeColor g
502
+ this.getFloat32() // edgeColor b
503
+ this.getFloat32() // edgeColor a
504
+ this.getFloat32() // edgeSize
505
+ this.getFloat32() // textureCoeff r
506
+ this.getFloat32() // textureCoeff g
507
+ this.getFloat32() // textureCoeff b
508
+ this.getFloat32() // textureCoeff a
509
+ this.getFloat32() // sphereCoeff r
510
+ this.getFloat32() // sphereCoeff g
511
+ this.getFloat32() // sphereCoeff b
512
+ this.getFloat32() // sphereCoeff a
513
+ this.getFloat32() // toonCoeff r
514
+ this.getFloat32() // toonCoeff g
515
+ this.getFloat32() // toonCoeff b
516
+ this.getFloat32() // toonCoeff a
517
+ }
518
+ }
519
+ } catch (e) {
520
+ // If we fail to read a morph, stop skipping
521
+ console.warn(`Error reading morph ${i}:`, e)
522
+ return false
523
+ }
524
+ }
525
+ return true
526
+ } catch (e) {
527
+ console.warn("Error skipping morphs:", e)
528
+ return false
529
+ }
530
+ }
531
+
532
+ private skipDisplayFrames(): boolean {
533
+ try {
534
+ // Check if we have enough bytes to read the count
535
+ if (this.offset + 4 > this.view.buffer.byteLength) {
536
+ return false
537
+ }
538
+ const count = this.getInt32()
539
+ if (count < 0 || count > 100000) {
540
+ // Suspicious count, likely corrupted - restore offset
541
+ this.offset -= 4
542
+ return false
543
+ }
544
+ for (let i = 0; i < count; i++) {
545
+ // Check bounds before reading each frame
546
+ if (this.offset >= this.view.buffer.byteLength) {
547
+ return false
548
+ }
549
+ try {
550
+ this.getText() // name
551
+ this.getText() // englishName
552
+ this.getUint8() // flag
553
+ const elementCount = this.getInt32()
554
+ for (let j = 0; j < elementCount; j++) {
555
+ if (this.offset >= this.view.buffer.byteLength) {
556
+ return false
557
+ }
558
+ const elementType = this.getUint8()
559
+ if (elementType === 0) {
560
+ // Bone frame
561
+ this.getNonVertexIndex(this.boneIndexSize)
562
+ } else if (elementType === 1) {
563
+ // Morph frame
564
+ this.getNonVertexIndex(this.morphIndexSize)
565
+ }
566
+ }
567
+ } catch (e) {
568
+ // If we fail to read a frame, stop skipping
569
+ console.warn(`Error reading display frame ${i}:`, e)
570
+ return false
571
+ }
572
+ }
573
+ return true
574
+ } catch (e) {
575
+ console.warn("Error skipping display frames:", e)
576
+ return false
577
+ }
578
+ }
579
+
580
+ private parseRigidbodies(): void {
581
+ try {
582
+ // Note: morphs and display frames are already skipped in parse() before calling this
583
+ // Check bounds before reading rigidbody count
584
+ if (this.offset + 4 > this.view.buffer.byteLength) {
585
+ console.warn("Not enough bytes for rigidbody count")
586
+ this.rigidbodies = []
587
+ return
588
+ }
589
+
590
+ const count = this.getInt32()
591
+ if (count < 0 || count > 10000) {
592
+ // Suspicious count
593
+ console.warn(`Suspicious rigidbody count: ${count}`)
594
+ this.rigidbodies = []
595
+ return
596
+ }
597
+
598
+ this.rigidbodies = []
599
+
600
+ for (let i = 0; i < count; i++) {
601
+ try {
602
+ // Check bounds before reading each rigidbody
603
+ if (this.offset >= this.view.buffer.byteLength) {
604
+ console.warn(`Reached end of buffer while reading rigidbody ${i} of ${count}`)
605
+ break
606
+ }
607
+
608
+ const name = this.getText()
609
+ const englishName = this.getText()
610
+ const boneIndex = this.getNonVertexIndex(this.boneIndexSize)
611
+ const group = this.getUint8()
612
+ const collisionMask = this.getUint16()
613
+
614
+ const shape = this.getUint8() // 0=sphere, 1=box, 2=capsule
615
+
616
+ // Size parameters (depends on shape)
617
+ const sizeX = this.getFloat32()
618
+ const sizeY = this.getFloat32()
619
+ const sizeZ = this.getFloat32()
620
+
621
+ const posX = this.getFloat32()
622
+ const posY = this.getFloat32()
623
+ const posZ = this.getFloat32()
624
+
625
+ // Rotation (Euler angles in RADIANS in PMX file - PMX Editor displays them as degrees for convenience)
626
+ // ZXY order (left-handed system)
627
+ const rotX = this.getFloat32()
628
+ const rotY = this.getFloat32()
629
+ const rotZ = this.getFloat32()
630
+
631
+ // Physical properties
632
+ const mass = this.getFloat32()
633
+ const linearDamping = this.getFloat32()
634
+ const angularDamping = this.getFloat32()
635
+ const restitution = this.getFloat32()
636
+ const friction = this.getFloat32()
637
+ const type = this.getUint8() // 0=static, 1=dynamic, 2=kinematic
638
+
639
+ this.rigidbodies.push({
640
+ name,
641
+ englishName,
642
+ boneIndex,
643
+ group,
644
+ collisionMask,
645
+ shape: shape as RigidbodyShape,
646
+ size: new Vec3(sizeX, sizeY, sizeZ),
647
+ shapePosition: new Vec3(posX, posY, posZ),
648
+ shapeRotation: new Vec3(rotX, rotY, rotZ),
649
+ mass,
650
+ linearDamping,
651
+ angularDamping,
652
+ restitution,
653
+ friction,
654
+ type: type as RigidbodyType,
655
+ bodyOffsetMatrixInverse: Mat4.identity(),
656
+ })
657
+ } catch (e) {
658
+ console.warn(`Error reading rigidbody ${i} of ${count}:`, e)
659
+ // Stop parsing if we encounter an error
660
+ break
661
+ }
662
+ }
663
+ } catch (e) {
664
+ console.warn("Error parsing rigidbodies:", e)
665
+ this.rigidbodies = []
666
+ }
667
+ }
668
+
669
+ private parseJoints(): void {
670
+ try {
671
+ // Check bounds before reading joint count
672
+ if (this.offset + 4 > this.view.buffer.byteLength) {
673
+ console.warn("Not enough bytes for joint count")
674
+ this.joints = []
675
+ return
676
+ }
677
+
678
+ const count = this.getInt32()
679
+ if (count < 0 || count > 10000) {
680
+ console.warn(`Suspicious joint count: ${count}`)
681
+ this.joints = []
682
+ return
683
+ }
684
+
685
+ this.joints = []
686
+
687
+ for (let i = 0; i < count; i++) {
688
+ try {
689
+ // Check bounds before reading each joint
690
+ if (this.offset >= this.view.buffer.byteLength) {
691
+ console.warn(`Reached end of buffer while reading joint ${i} of ${count}`)
692
+ break
693
+ }
694
+
695
+ const name = this.getText()
696
+ const englishName = this.getText()
697
+ const type = this.getUint8()
698
+
699
+ // Rigidbody indices use rigidBodyIndexSize (not boneIndexSize)
700
+ const rigidbodyIndexA = this.getNonVertexIndex(this.rigidBodyIndexSize)
701
+ const rigidbodyIndexB = this.getNonVertexIndex(this.rigidBodyIndexSize)
702
+
703
+ const posX = this.getFloat32()
704
+ const posY = this.getFloat32()
705
+ const posZ = this.getFloat32()
706
+
707
+ // Rotation (Euler angles in RADIANS in PMX file - PMX Editor displays them as degrees for convenience)
708
+ // ZXY order (left-handed system)
709
+ const rotX = this.getFloat32()
710
+ const rotY = this.getFloat32()
711
+ const rotZ = this.getFloat32()
712
+
713
+ const posMinX = this.getFloat32()
714
+ const posMinY = this.getFloat32()
715
+ const posMinZ = this.getFloat32()
716
+
717
+ const posMaxX = this.getFloat32()
718
+ const posMaxY = this.getFloat32()
719
+ const posMaxZ = this.getFloat32()
720
+
721
+ // Rotation constraints (in RADIANS in PMX file)
722
+ const rotMinX = this.getFloat32()
723
+ const rotMinY = this.getFloat32()
724
+ const rotMinZ = this.getFloat32()
725
+
726
+ const rotMaxX = this.getFloat32()
727
+ const rotMaxY = this.getFloat32()
728
+ const rotMaxZ = this.getFloat32()
729
+
730
+ // Spring parameters
731
+ const springPosX = this.getFloat32()
732
+ const springPosY = this.getFloat32()
733
+ const springPosZ = this.getFloat32()
734
+
735
+ // Spring rotation (stiffness values in PMX file)
736
+ const springRotX = this.getFloat32()
737
+ const springRotY = this.getFloat32()
738
+ const springRotZ = this.getFloat32()
739
+
740
+ // Store rotations and springs as Vec3 (Euler angles), not Quat
741
+ this.joints.push({
742
+ name,
743
+ englishName,
744
+ type,
745
+ rigidbodyIndexA,
746
+ rigidbodyIndexB,
747
+ position: new Vec3(posX, posY, posZ),
748
+ rotation: new Vec3(rotX, rotY, rotZ), // Store as Vec3 (Euler angles)
749
+ positionMin: new Vec3(posMinX, posMinY, posMinZ),
750
+ positionMax: new Vec3(posMaxX, posMaxY, posMaxZ),
751
+ rotationMin: new Vec3(rotMinX, rotMinY, rotMinZ), // Store as Vec3 (Euler angles)
752
+ rotationMax: new Vec3(rotMaxX, rotMaxY, rotMaxZ), // Store as Vec3 (Euler angles)
753
+ springPosition: new Vec3(springPosX, springPosY, springPosZ),
754
+ springRotation: new Vec3(springRotX, springRotY, springRotZ), // Store as Vec3 (stiffness values)
755
+ })
756
+ } catch (e) {
757
+ console.warn(`Error reading joint ${i} of ${count}:`, e)
758
+ // Stop parsing if we encounter an error
759
+ break
760
+ }
761
+ }
762
+ } catch (e) {
763
+ console.warn("Error parsing joints:", e)
764
+ this.joints = []
765
+ }
766
+ }
767
+
768
+ private computeInverseBind() {
769
+ if (!this.bones || this.bones.length === 0) {
770
+ this.inverseBindMatrices = new Float32Array(0)
771
+ return
772
+ }
773
+ const n = this.bones.length
774
+ const world = new Array<Mat4 | null>(n).fill(null)
775
+ const inv = new Float32Array(n * 16)
776
+
777
+ const computeWorld = (i: number): Mat4 => {
778
+ if (world[i]) return world[i] as Mat4
779
+ const bone = this.bones[i]
780
+ const local = Mat4.identity().translateInPlace(
781
+ bone.bindTranslation[0],
782
+ bone.bindTranslation[1],
783
+ bone.bindTranslation[2]
784
+ )
785
+ let w: Mat4
786
+ if (bone.parentIndex >= 0 && bone.parentIndex < n) {
787
+ w = computeWorld(bone.parentIndex).multiply(local)
788
+ } else {
789
+ w = local
790
+ }
791
+ world[i] = w
792
+ return w
793
+ }
794
+
795
+ for (let i = 0; i < n; i++) {
796
+ const w = computeWorld(i)
797
+ const invm = Mat4.identity().translateInPlace(-w.values[12], -w.values[13], -w.values[14])
798
+ inv.set(invm.values, i * 16)
799
+ }
800
+ this.inverseBindMatrices = inv
801
+ }
802
+
803
+ private toModel(positions: number[], normals: number[], uvs: number[], indices: number[]): Model {
804
+ // Create indexed vertex buffer
805
+ const vertexCount = positions.length / 3
806
+ const vertexData = new Float32Array(vertexCount * 8)
807
+
808
+ for (let i = 0; i < vertexCount; i++) {
809
+ const pi = i * 3
810
+ const ui = i * 2
811
+ const vi = i * 8
812
+
813
+ vertexData[vi] = positions[pi]
814
+ vertexData[vi + 1] = positions[pi + 1]
815
+ vertexData[vi + 2] = positions[pi + 2]
816
+ vertexData[vi + 3] = normals[pi]
817
+ vertexData[vi + 4] = normals[pi + 1]
818
+ vertexData[vi + 5] = normals[pi + 2]
819
+ vertexData[vi + 6] = uvs[ui]
820
+ vertexData[vi + 7] = uvs[ui + 1]
821
+ }
822
+
823
+ // Create index buffer
824
+ const indexData = new Uint32Array(indices)
825
+
826
+ // Create skeleton (required)
827
+ const skeleton: Skeleton = {
828
+ bones: this.bones.length > 0 ? this.bones : [],
829
+ inverseBindMatrices: this.inverseBindMatrices || new Float32Array(0),
830
+ }
831
+
832
+ let skinning: Skinning
833
+ if (this.joints0 && this.weights0) {
834
+ // Clamp joints to valid range now that we know bone count, and renormalize weights
835
+ const boneCount = this.bones.length
836
+ const joints = this.joints0
837
+ const weights = this.weights0
838
+ for (let i = 0; i < joints.length; i += 4) {
839
+ // First pass: identify and zero out invalid joints, collect valid weight sum
840
+ let validWeightSum = 0
841
+ let validCount = 0
842
+ for (let k = 0; k < 4; k++) {
843
+ const j = joints[i + k]
844
+ if (j < 0 || j >= boneCount) {
845
+ // Invalid joint: zero the weight but keep joint index for debugging
846
+ weights[i + k] = 0
847
+ // Optionally clamp to valid range
848
+ if (j < 0) {
849
+ joints[i + k] = 0
850
+ } else {
851
+ joints[i + k] = boneCount > 0 ? boneCount - 1 : 0
852
+ }
853
+ } else {
854
+ validWeightSum += weights[i + k]
855
+ validCount++
856
+ }
857
+ }
858
+
859
+ // If no valid weights, assign all weight to first bone (bone 0)
860
+ if (validWeightSum === 0 || validCount === 0) {
861
+ weights[i] = 255
862
+ weights[i + 1] = 0
863
+ weights[i + 2] = 0
864
+ weights[i + 3] = 0
865
+ joints[i] = boneCount > 0 ? 0 : 0
866
+ joints[i + 1] = 0
867
+ joints[i + 2] = 0
868
+ joints[i + 3] = 0
869
+ } else if (validWeightSum !== 255) {
870
+ // Normalize valid weights to sum to exactly 255
871
+ const scale = 255 / validWeightSum
872
+ let accum = 0
873
+ for (let k = 0; k < 3; k++) {
874
+ if (joints[i + k] >= 0 && joints[i + k] < boneCount) {
875
+ const v = Math.max(0, Math.min(255, Math.round(weights[i + k] * scale)))
876
+ weights[i + k] = v
877
+ accum += v
878
+ } else {
879
+ weights[i + k] = 0
880
+ }
881
+ }
882
+ // Handle the 4th weight
883
+ if (joints[i + 3] >= 0 && joints[i + 3] < boneCount) {
884
+ weights[i + 3] = Math.max(0, Math.min(255, 255 - accum))
885
+ } else {
886
+ weights[i + 3] = 0
887
+ // Redistribute the remainder to the last valid weight
888
+ if (accum < 255) {
889
+ for (let k = 2; k >= 0; k--) {
890
+ if (joints[i + k] >= 0 && joints[i + k] < boneCount && weights[i + k] > 0) {
891
+ weights[i + k] = Math.min(255, weights[i + k] + (255 - accum))
892
+ break
893
+ }
894
+ }
895
+ }
896
+ }
897
+
898
+ // Final safety check: ensure sum is exactly 255
899
+ const finalSum = weights[i] + weights[i + 1] + weights[i + 2] + weights[i + 3]
900
+ if (finalSum !== 255) {
901
+ const diff = 255 - finalSum
902
+ // Add/subtract difference to/from the largest valid weight
903
+ let maxIdx = 0
904
+ let maxWeight = weights[i]
905
+ for (let k = 1; k < 4; k++) {
906
+ if (weights[i + k] > maxWeight && joints[i + k] >= 0 && joints[i + k] < boneCount) {
907
+ maxWeight = weights[i + k]
908
+ maxIdx = k
909
+ }
910
+ }
911
+ if (joints[i + maxIdx] >= 0 && joints[i + maxIdx] < boneCount) {
912
+ weights[i + maxIdx] = Math.max(0, Math.min(255, weights[i + maxIdx] + diff))
913
+ }
914
+ }
915
+ }
916
+ }
917
+ skinning = { joints, weights }
918
+ } else {
919
+ // Create default skinning (single bone per vertex)
920
+ const vertexCount = positions.length / 3
921
+ const joints = new Uint16Array(vertexCount * 4)
922
+ const weights = new Uint8Array(vertexCount * 4)
923
+ for (let i = 0; i < vertexCount; i++) {
924
+ joints[i * 4] = 0
925
+ weights[i * 4] = 255
926
+ }
927
+ skinning = { joints, weights }
928
+ }
929
+
930
+ return new Model(
931
+ vertexData,
932
+ indexData,
933
+ this.textures,
934
+ this.materials,
935
+ skeleton,
936
+ skinning,
937
+ this.rigidbodies,
938
+ this.joints
939
+ )
940
+ }
941
+
942
+ private getUint8() {
943
+ if (this.offset >= this.view.buffer.byteLength) {
944
+ throw new RangeError(`Offset ${this.offset} exceeds buffer bounds ${this.view.buffer.byteLength}`)
945
+ }
946
+ return this.view.getUint8(this.offset++)
947
+ }
948
+
949
+ private getUint16() {
950
+ if (this.offset + 2 > this.view.buffer.byteLength) {
951
+ throw new RangeError(`Offset ${this.offset} + 2 exceeds buffer bounds ${this.view.buffer.byteLength}`)
952
+ }
953
+ const v = this.view.getUint16(this.offset, true)
954
+ this.offset += 2
955
+ return v
956
+ }
957
+
958
+ // Vertex index: 1->uint8, 2->uint16, 4->int32
959
+ private getVertexIndex(size: number) {
960
+ if (size === 1) return this.getUint8()
961
+ if (size === 2) {
962
+ const v = this.view.getUint16(this.offset, true)
963
+ this.offset += 2
964
+ return v
965
+ }
966
+ return this.getInt32()
967
+ }
968
+
969
+ // Non-vertex indices (texture/material/bone/morph/rigid): 1->int8, 2->int16, 4->int32
970
+ private getNonVertexIndex(size: number) {
971
+ if (size === 1) {
972
+ const v = this.view.getInt8(this.offset)
973
+ this.offset += 1
974
+ return v
975
+ }
976
+ if (size === 2) {
977
+ const v = this.view.getInt16(this.offset, true)
978
+ this.offset += 2
979
+ return v
980
+ }
981
+ return this.getInt32()
982
+ }
983
+
984
+ private getInt32() {
985
+ if (this.offset + 4 > this.view.buffer.byteLength) {
986
+ throw new RangeError(`Offset ${this.offset} + 4 exceeds buffer bounds ${this.view.buffer.byteLength}`)
987
+ }
988
+ const v = this.view.getInt32(this.offset, true)
989
+ this.offset += 4
990
+ return v
991
+ }
992
+
993
+ private getFloat32() {
994
+ if (this.offset + 4 > this.view.buffer.byteLength) {
995
+ throw new RangeError(`Offset ${this.offset} + 4 exceeds buffer bounds ${this.view.buffer.byteLength}`)
996
+ }
997
+ const v = this.view.getFloat32(this.offset, true)
998
+ this.offset += 4
999
+ return v
1000
+ }
1001
+
1002
+ private getString(len: number) {
1003
+ const bytes = new Uint8Array(this.view.buffer, this.offset, len)
1004
+ this.offset += len
1005
+ return String.fromCharCode(...bytes)
1006
+ }
1007
+
1008
+ private getText() {
1009
+ const len = this.getInt32()
1010
+ if (len <= 0) return ""
1011
+
1012
+ // Debug: log problematic string lengths
1013
+ if (len > 1000 || len < -1000) {
1014
+ throw new RangeError(`Suspicious string length: ${len} at offset ${this.offset - 4}`)
1015
+ }
1016
+
1017
+ // Ensure we don't read beyond buffer bounds
1018
+ if (this.offset + len > this.view.buffer.byteLength) {
1019
+ throw new RangeError(`String length ${len} exceeds buffer bounds at offset ${this.offset - 4}`)
1020
+ }
1021
+
1022
+ const bytes = new Uint8Array(this.view.buffer, this.offset, len)
1023
+ this.offset += len
1024
+ return this.decoder.decode(bytes)
1025
+ }
1026
+
1027
+ private getIndex(size: number) {
1028
+ // Backward-compat helper: defaults to vertex index behavior
1029
+ return this.getVertexIndex(size)
1030
+ }
1031
+ }