reze-engine 0.36.1 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/model.ts CHANGED
@@ -32,6 +32,10 @@ const _convMat = new Float32Array(16)
32
32
  // Blend-path scratch: per-entry sample target and the crossfade's two fixed entries.
33
33
  const _blendQ = new Quat(0, 0, 0, 1)
34
34
  const _blendT = new Vec3(0, 0, 0)
35
+
36
+ // Bone-morph scratch: the weighted rotation, slerped out of identity.
37
+ const _boneMorphQ = new Quat(0, 0, 0, 1)
38
+ const _boneMorphIdentity = new Quat(0, 0, 0, 1)
35
39
  export interface ClipEventInfo {
36
40
  clip: string
37
41
  /** The registered event time, seconds. */
@@ -131,12 +135,56 @@ export interface GroupMorphReference {
131
135
  ratio: number
132
136
  }
133
137
 
138
+ // Bone morph offset data (type 2). A stage's doors, platforms and hatches are
139
+ // posed with these — there is no VMD to drive them, so the weight IS the pose.
140
+ export interface BoneMorphOffset {
141
+ boneIndex: number
142
+ translation: [number, number, number]
143
+ /** Rotation quaternion (x, y, z, w). */
144
+ rotation: [number, number, number, number]
145
+ }
146
+
147
+ /** PMX material-morph blend mode. Multiply lerps toward base*morph; add offsets from base. */
148
+ export const MATERIAL_MORPH_MULTIPLY = 0
149
+ export const MATERIAL_MORPH_ADD = 1
150
+
151
+ // Material morph offset data (type 8). Stage artists ship colour and on/off
152
+ // switches this way: alpha to 0 hides a part, a diffuse tint recolours a set.
153
+ export interface MaterialMorphOffset {
154
+ /** -1 targets EVERY material in the model, per the PMX spec. */
155
+ materialIndex: number
156
+ /** MATERIAL_MORPH_MULTIPLY | MATERIAL_MORPH_ADD */
157
+ offsetType: number
158
+ diffuse: [number, number, number, number]
159
+ specular: [number, number, number]
160
+ shininess: number
161
+ ambient: [number, number, number]
162
+ edgeColor: [number, number, number, number]
163
+ edgeSize: number
164
+ textureCoeff: [number, number, number, number]
165
+ sphereCoeff: [number, number, number, number]
166
+ toonCoeff: [number, number, number, number]
167
+ }
168
+
169
+ // UV morph offset data (types 3–7). Type 3 is the base UV channel; 4–7 are the
170
+ // additional UV channels, which this engine does not carry — kept so the panel
171
+ // can tell "unsupported" from "absent".
172
+ export interface UvMorphOffset {
173
+ vertexIndex: number
174
+ /** (u, v, z, w) — only u/v apply to the base channel. */
175
+ offset: [number, number, number, number]
176
+ }
177
+
134
178
  // Morph definition
135
179
  export interface Morph {
136
180
  name: string
137
- type: number // 0=group, 1=vertex, 2=bone, 3=UV, 8=material
181
+ /** 0=group, 1=vertex, 2=bone, 3–7=UV, 8=material, 9=flip, 10=impulse. */
182
+ type: number
138
183
  vertexOffsets: VertexMorphOffset[] // Only for type 1 (vertex morph)
139
184
  groupReferences?: GroupMorphReference[] // Only for type 0 (group morph)
185
+ boneOffsets?: BoneMorphOffset[] // Only for type 2
186
+ materialOffsets?: MaterialMorphOffset[] // Only for type 8
187
+ uvOffsets?: UvMorphOffset[] // Only for types 3–7
140
188
  }
141
189
 
142
190
  export interface Morphing {
@@ -286,6 +334,24 @@ export class Model {
286
334
  private runtimeMorph!: MorphRuntime
287
335
  private morphsDirty: boolean = false // Flag indicating if morphs need to be applied
288
336
 
337
+ // Bone morphs (type 2), flattened once at load so the per-frame pass is a
338
+ // straight walk with no allocation. Empty for models without them, which is
339
+ // most characters — a stage's doors and platforms are the common case.
340
+ private boneMorphPlan: { morphIndex: number; boneIndex: number; translation: Vec3; rotation: Quat }[] = []
341
+ // Bones any bone morph touches, plus their locals as they were BEFORE the last
342
+ // application. A pose source rewrites the locals it owns every frame, but a
343
+ // stage usually has no clip at all — nothing would reset these, and the offset
344
+ // would compound frame after frame into a slow drift.
345
+ private boneMorphBones: number[] = []
346
+ private boneMorphRestoreR: Quat[] = []
347
+ private boneMorphRestoreT: Vec3[] = []
348
+ private boneMorphApplied = false
349
+ /** A full pose pass has run, so the world matrices are real. See isIdle. */
350
+ private posedOnce = false
351
+ // Set whenever effective weights change, for material/UV consumers that live
352
+ // outside this class. Vertex morphs use morphWeightsDirty (GPU) instead.
353
+ private auxMorphDirty = true
354
+
289
355
  // Root transform — model's placement in world space, independent of bones.
290
356
  // Folded into skin matrices (see getSkinMatrices) so every pass (main VS,
291
357
  // shadow VS, any future skinned pass) sees it without per-shader plumbing.
@@ -567,6 +633,119 @@ export class Model {
567
633
  }, {} as Record<string, number>),
568
634
  weights: new Float32Array(morphCount),
569
635
  }
636
+ const boneCount = this.skeleton.bones.length
637
+ this.boneMorphPlan = []
638
+ for (let i = 0; i < morphCount; i++) {
639
+ const morph = this.morphing.morphs[i]
640
+ if (morph.type !== 2 || !morph.boneOffsets) continue
641
+ for (const off of morph.boneOffsets) {
642
+ if (off.boneIndex < 0 || off.boneIndex >= boneCount) continue
643
+ this.boneMorphPlan.push({
644
+ morphIndex: i,
645
+ boneIndex: off.boneIndex,
646
+ translation: new Vec3(off.translation[0], off.translation[1], off.translation[2]),
647
+ rotation: new Quat(off.rotation[0], off.rotation[1], off.rotation[2], off.rotation[3]),
648
+ })
649
+ }
650
+ }
651
+ const touched = new Set(this.boneMorphPlan.map((e) => e.boneIndex))
652
+ this.boneMorphBones = [...touched]
653
+ this.boneMorphRestoreR = this.boneMorphBones.map(() => Quat.identity())
654
+ this.boneMorphRestoreT = this.boneMorphBones.map(() => Vec3.zeros())
655
+ this.boneMorphApplied = false
656
+ }
657
+
658
+ /**
659
+ * Bone morphs (type 2) compose over whatever the pose sources produced, the
660
+ * same way boneRotationOffsets do — they are an offset on the animated local
661
+ * transform, not a replacement for it. Re-applied every frame because each
662
+ * pose source rewrites the locals it touches.
663
+ *
664
+ * Stages are the reason this exists: a door or a lift is rigged as a bone
665
+ * morph and there is no VMD anywhere that drives it.
666
+ */
667
+ private applyBoneMorphs(): void {
668
+ if (this.boneMorphPlan.length === 0) return
669
+
670
+ // Undo the previous application first. With a clip running this is a no-op
671
+ // in effect — the pose source already overwrote these bones — but a stage
672
+ // has no clip, so without it the same offset would be re-added every frame.
673
+ if (this.boneMorphApplied) {
674
+ for (let i = 0; i < this.boneMorphBones.length; i++) {
675
+ const b = this.boneMorphBones[i]
676
+ this.runtimeSkeleton.localRotations[b].set(this.boneMorphRestoreR[i])
677
+ this.runtimeSkeleton.localTranslations[b].set(this.boneMorphRestoreT[i])
678
+ }
679
+ }
680
+ for (let i = 0; i < this.boneMorphBones.length; i++) {
681
+ const b = this.boneMorphBones[i]
682
+ this.boneMorphRestoreR[i].set(this.runtimeSkeleton.localRotations[b])
683
+ this.boneMorphRestoreT[i].set(this.runtimeSkeleton.localTranslations[b])
684
+ }
685
+ this.boneMorphApplied = true
686
+
687
+ const weights = this.getEffectiveMorphWeights()
688
+ for (const entry of this.boneMorphPlan) {
689
+ const w = weights[entry.morphIndex]
690
+ if (w < 0.0001) continue
691
+ const t = this.runtimeSkeleton.localTranslations[entry.boneIndex]
692
+ t.x += entry.translation.x * w
693
+ t.y += entry.translation.y * w
694
+ t.z += entry.translation.z * w
695
+ // Scale the rotation by weight the way MMD does — slerp out of identity,
696
+ // then compose. Multiplying components would not stay a unit quaternion.
697
+ const r = this.runtimeSkeleton.localRotations[entry.boneIndex]
698
+ Quat.slerpInto(_boneMorphIdentity, entry.rotation, w, _boneMorphQ)
699
+ Quat.multiplyInto(r, _boneMorphQ, r)
700
+ }
701
+ }
702
+
703
+ /** True (once) when effective morph weights changed — the engine re-derives
704
+ * material-morph uniforms from it. Separate from the GPU vertex path's flag
705
+ * so both can consume the same change. */
706
+ consumeAuxMorphDirty(): boolean {
707
+ const d = this.auxMorphDirty
708
+ this.auxMorphDirty = false
709
+ return d
710
+ }
711
+
712
+ /**
713
+ * Morph indices this model can actually act on, so a UI never offers a control
714
+ * that moves nothing.
715
+ *
716
+ * Driven directly: vertex (1), bone (2), material (8). Excluded: UV (3–7),
717
+ * which are parsed and kept but not yet applied; flip (9) and impulse (10),
718
+ * which are PMX 2.1 and would need the rigidbody solver.
719
+ *
720
+ * A group morph (0) is only as alive as what it points at — one referencing
721
+ * nothing but UV morphs is just as dead as the UV morphs themselves, so it is
722
+ * resolved rather than assumed.
723
+ */
724
+ getSupportedMorphIndices(): number[] {
725
+ const morphs = this.morphing.morphs
726
+ const drivable = (t: number) => t === 1 || t === 2 || t === 8
727
+ // Groups can reference groups, so walk with a seen-set rather than recursing.
728
+ const resolves = (start: number): boolean => {
729
+ const seen = new Set<number>()
730
+ const stack = [start]
731
+ while (stack.length > 0) {
732
+ const i = stack.pop()!
733
+ if (seen.has(i) || i < 0 || i >= morphs.length) continue
734
+ seen.add(i)
735
+ const m = morphs[i]
736
+ if (drivable(m.type)) return true
737
+ if (m.type === 0 && m.groupReferences) {
738
+ for (const ref of m.groupReferences) stack.push(ref.morphIndex)
739
+ }
740
+ }
741
+ return false
742
+ }
743
+ const out: number[] = []
744
+ for (let i = 0; i < morphs.length; i++) {
745
+ const t = morphs[i].type
746
+ if (drivable(t) || (t === 0 && resolves(i))) out.push(i)
747
+ }
748
+ return out
570
749
  }
571
750
 
572
751
  // Tween update - processes all tweens together with a single time reference
@@ -983,6 +1162,11 @@ export class Model {
983
1162
  this.runtimeMorph.weights[idx] = clampedWeight
984
1163
  this.tweenState.morphActive[idx] = 0
985
1164
  this.applyMorphs()
1165
+ // Vertex and material morphs are done by applyMorphs alone, but a bone
1166
+ // morph lands in the pose pass — and a model with no clip (every stage)
1167
+ // reports idle, so without this the pass never runs and the switch does
1168
+ // nothing. Costs one redundant applyMorphs on the next frame.
1169
+ if (this.boneMorphPlan.length > 0) this.morphsDirty = true
986
1170
  try {
987
1171
  Engine.getInstance().markVertexBufferDirty(this)
988
1172
  } catch {
@@ -1047,6 +1231,11 @@ export class Model {
1047
1231
  effectiveWeights[i] = Math.max(0, Math.min(1, effectiveWeights[i]))
1048
1232
  }
1049
1233
 
1234
+ // Bone morphs read these every frame, but material/UV consumers live outside
1235
+ // this class and need telling. Set on BOTH paths — a model whose only morphs
1236
+ // are material morphs never enables the GPU vertex path at all.
1237
+ this.auxMorphDirty = true
1238
+
1050
1239
  // GPU path: the compute pass applies the vertex offsets from these weights.
1051
1240
  if (this.gpuMorphEnabled) {
1052
1241
  this.morphWeightsDirty = true
@@ -1913,6 +2102,42 @@ export class Model {
1913
2102
  // within that. A host driving bones directly — motion capture writing FK
1914
2103
  // rotations every frame with no clip playing — turns it off wholesale, because
1915
2104
  // there is no motion present to carry the per-chain answer.
2105
+ /**
2106
+ * Nothing can have moved this frame: no clip, no blend, no live tween, no
2107
+ * morph weight change.
2108
+ *
2109
+ * Environment geometry is in this state almost every frame, so the engine
2110
+ * skips the whole pose pass — sampling, world matrices, and the skin-matrix
2111
+ * upload — for a stage that reports idle. A stage is usually the heaviest mesh
2112
+ * in the scene and the one that never moves; paying a full pose pass for it
2113
+ * every frame is the thing worth not doing.
2114
+ */
2115
+ isIdle(): boolean {
2116
+ // Never idle before the first pose pass. The constructor leaves the world
2117
+ // matrices identity, so skin = world × inverseBind collapses every vertex
2118
+ // into bone-local space — the mesh piles up at the origin. A cast member is
2119
+ // saved by running update() on frame 1 regardless; a stage that reported
2120
+ // idle immediately would render as a heap and never recover.
2121
+ if (!this.posedOnce) return false
2122
+ return (
2123
+ !this.morphsDirty &&
2124
+ this.oneShot === null &&
2125
+ this.crossfade === null &&
2126
+ (this.blendEntries === null || this.blendEntries.length === 0) &&
2127
+ this.animationState.getCurrentClip() === null &&
2128
+ !this.hasActiveTweens()
2129
+ )
2130
+ }
2131
+
2132
+ /** Any live rotation / translation / morph tween. */
2133
+ private hasActiveTweens(): boolean {
2134
+ const s = this.tweenState
2135
+ for (let i = 0; i < s.rotActive.length; i++) if (s.rotActive[i] === 1) return true
2136
+ for (let i = 0; i < s.transActive.length; i++) if (s.transActive[i] === 1) return true
2137
+ for (let i = 0; i < s.morphActive.length; i++) if (s.morphActive[i] === 1) return true
2138
+ return false
2139
+ }
2140
+
1916
2141
  update(deltaTime: number, ikEnabled = true): boolean {
1917
2142
  // Update tween time (in milliseconds)
1918
2143
  this.tweenTimeMs += deltaTime * 1000
@@ -1955,8 +2180,13 @@ export class Model {
1955
2180
  this.morphsDirty = false
1956
2181
  }
1957
2182
 
2183
+ // After the pose sources and the constant offsets, before the world pass —
2184
+ // bone morphs must survive into the world matrices IK then reads.
2185
+ this.applyBoneMorphs()
2186
+
1958
2187
  // Compute world matrices (needed for IK solving to read bone positions)
1959
2188
  this.computeWorldMatrices()
2189
+ this.posedOnce = true
1960
2190
 
1961
2191
  // Solve IK chains (modifies localRotations with final IK rotations). Chains
1962
2192
  // the clip switched off are skipped inside.
package/src/pmx-loader.ts CHANGED
@@ -9,6 +9,9 @@ import {
9
9
  Morphing,
10
10
  VertexMorphOffset,
11
11
  GroupMorphReference,
12
+ BoneMorphOffset,
13
+ MaterialMorphOffset,
14
+ UvMorphOffset,
12
15
  IKLink,
13
16
  } from "./model"
14
17
  import { Mat4, Vec3 } from "./math"
@@ -533,6 +536,11 @@ export class PmxLoader {
533
536
  vertexOffsets: [],
534
537
  groupReferences: [],
535
538
  }
539
+ // Allocated only for the type that uses them, so `morph.boneOffsets`
540
+ // being undefined means "not that kind of morph" rather than "empty".
541
+ if (morphType === 2) morph.boneOffsets = []
542
+ else if (morphType >= 3 && morphType <= 7) morph.uvOffsets = []
543
+ else if (morphType === 8) morph.materialOffsets = []
536
544
 
537
545
  // Parse vertex morphs (type 1)
538
546
  if (morphType === 1) {
@@ -569,60 +577,92 @@ export class PmxLoader {
569
577
  }
570
578
  }
571
579
  } else {
572
- // Skip other morph types for now
580
+ // Bone, UV and material morphs are kept; flip and impulse (PMX 2.1)
581
+ // are still read past — nothing in the wild uses them, and impulse
582
+ // would need to drive rigidbodies.
573
583
  for (let j = 0; j < offsetCount; j++) {
574
584
  if (this.offset >= this.view.buffer.byteLength) {
575
585
  break
576
586
  }
577
587
  if (morphType === 2) {
578
588
  // Bone morph
579
- this.getNonVertexIndex(this.boneIndexSize) // boneIndex
580
- this.getFloat32() // x
581
- this.getFloat32() // y
582
- this.getFloat32() // z
583
- this.getFloat32() // rotation quaternion x
584
- this.getFloat32() // rotation quaternion y
585
- this.getFloat32() // rotation quaternion z
586
- this.getFloat32() // rotation quaternion w
589
+ const boneIndex = this.getNonVertexIndex(this.boneIndexSize)
590
+ const tx = this.getFloat32()
591
+ const ty = this.getFloat32()
592
+ const tz = this.getFloat32()
593
+ const rx = this.getFloat32()
594
+ const ry = this.getFloat32()
595
+ const rz = this.getFloat32()
596
+ const rw = this.getFloat32()
597
+ if (boneIndex >= 0) {
598
+ const offset: BoneMorphOffset = {
599
+ boneIndex,
600
+ translation: [tx, ty, tz],
601
+ rotation: [rx, ry, rz, rw],
602
+ }
603
+ morph.boneOffsets!.push(offset)
604
+ }
587
605
  } else if (morphType >= 3 && morphType <= 7) {
588
606
  // UV morph + additional UV channels 1-4; offset is always a Float4 per spec
589
- this.getIndex(this.vertexIndexSize) // vertexIndex
590
- this.getFloat32() // x
591
- this.getFloat32() // y
592
- this.getFloat32() // z
593
- this.getFloat32() // w
607
+ const vertexIndex = this.getIndex(this.vertexIndexSize)
608
+ const x = this.getFloat32()
609
+ const y = this.getFloat32()
610
+ const z = this.getFloat32()
611
+ const w = this.getFloat32()
612
+ if (vertexIndex >= 0 && vertexIndex < this.vertexCount) {
613
+ const offset: UvMorphOffset = { vertexIndex, offset: [x, y, z, w] }
614
+ morph.uvOffsets!.push(offset)
615
+ }
594
616
  } else if (morphType === 8) {
595
617
  // Material morph
596
- this.getNonVertexIndex(this.materialIndexSize) // materialIndex
597
- this.getUint8() // offsetType
598
- this.getFloat32() // diffuse r
599
- this.getFloat32() // diffuse g
600
- this.getFloat32() // diffuse b
601
- this.getFloat32() // diffuse a
602
- this.getFloat32() // specular r
603
- this.getFloat32() // specular g
604
- this.getFloat32() // specular b
605
- this.getFloat32() // specular power
606
- this.getFloat32() // ambient r
607
- this.getFloat32() // ambient g
608
- this.getFloat32() // ambient b
609
- this.getFloat32() // edgeColor r
610
- this.getFloat32() // edgeColor g
611
- this.getFloat32() // edgeColor b
612
- this.getFloat32() // edgeColor a
613
- this.getFloat32() // edgeSize
614
- this.getFloat32() // textureCoeff r
615
- this.getFloat32() // textureCoeff g
616
- this.getFloat32() // textureCoeff b
617
- this.getFloat32() // textureCoeff a
618
- this.getFloat32() // sphereCoeff r
619
- this.getFloat32() // sphereCoeff g
620
- this.getFloat32() // sphereCoeff b
621
- this.getFloat32() // sphereCoeff a
622
- this.getFloat32() // toonCoeff r
623
- this.getFloat32() // toonCoeff g
624
- this.getFloat32() // toonCoeff b
625
- this.getFloat32() // toonCoeff a
618
+ const materialIndex = this.getNonVertexIndex(this.materialIndexSize)
619
+ const offsetType = this.getUint8()
620
+ const diffuseR = this.getFloat32()
621
+ const diffuseG = this.getFloat32()
622
+ const diffuseB = this.getFloat32()
623
+ const diffuseA = this.getFloat32()
624
+ const specularR = this.getFloat32()
625
+ const specularG = this.getFloat32()
626
+ const specularB = this.getFloat32()
627
+ const shininess = this.getFloat32()
628
+ const ambientR = this.getFloat32()
629
+ const ambientG = this.getFloat32()
630
+ const ambientB = this.getFloat32()
631
+ const edgeR = this.getFloat32()
632
+ const edgeG = this.getFloat32()
633
+ const edgeB = this.getFloat32()
634
+ const edgeA = this.getFloat32()
635
+ const edgeSize = this.getFloat32()
636
+ const texR = this.getFloat32()
637
+ const texG = this.getFloat32()
638
+ const texB = this.getFloat32()
639
+ const texA = this.getFloat32()
640
+ const sphR = this.getFloat32()
641
+ const sphG = this.getFloat32()
642
+ const sphB = this.getFloat32()
643
+ const sphA = this.getFloat32()
644
+ const toonR = this.getFloat32()
645
+ const toonG = this.getFloat32()
646
+ const toonB = this.getFloat32()
647
+ const toonA = this.getFloat32()
648
+ // -1 is legal and means "every material" — kept as-is so the
649
+ // applier can fan it out without a second convention.
650
+ if (materialIndex >= -1) {
651
+ const offset: MaterialMorphOffset = {
652
+ materialIndex,
653
+ offsetType,
654
+ diffuse: [diffuseR, diffuseG, diffuseB, diffuseA],
655
+ specular: [specularR, specularG, specularB],
656
+ shininess,
657
+ ambient: [ambientR, ambientG, ambientB],
658
+ edgeColor: [edgeR, edgeG, edgeB, edgeA],
659
+ edgeSize,
660
+ textureCoeff: [texR, texG, texB, texA],
661
+ sphereCoeff: [sphR, sphG, sphB, sphA],
662
+ toonCoeff: [toonR, toonG, toonB, toonA],
663
+ }
664
+ morph.materialOffsets!.push(offset)
665
+ }
626
666
  } else if (morphType === 9) {
627
667
  // Flip morph (PMX 2.1): same layout as group morph
628
668
  this.getNonVertexIndex(this.morphIndexSize) // morphIndex