reze-engine 0.36.0 → 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/README.md +4 -3
- package/dist/camera.d.ts +17 -0
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +25 -0
- package/dist/engine.d.ts +94 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +250 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/model.d.ts +81 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +192 -0
- package/dist/physics/autofit.d.ts +147 -0
- package/dist/physics/autofit.d.ts.map +1 -0
- package/dist/physics/autofit.js +501 -0
- package/dist/physics/contact.d.ts +12 -3
- package/dist/physics/contact.d.ts.map +1 -1
- package/dist/physics/contact.js +5 -2
- package/dist/physics/manifold.d.ts +32 -11
- package/dist/physics/manifold.d.ts.map +1 -1
- package/dist/physics/manifold.js +137 -57
- package/dist/physics/solver.d.ts +7 -1
- package/dist/physics/solver.d.ts.map +1 -1
- package/dist/physics/solver.js +536 -182
- package/dist/physics/world.d.ts +2 -0
- package/dist/physics/world.d.ts.map +1 -1
- package/dist/physics/world.js +16 -43
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +84 -44
- package/package.json +1 -1
- package/src/camera.ts +26 -0
- package/src/engine.ts +305 -18
- package/src/index.ts +11 -1
- package/src/model.ts +231 -1
- package/src/physics/contact.ts +17 -7
- package/src/physics/manifold.ts +151 -0
- package/src/physics/solver.ts +520 -184
- package/src/physics/world.ts +17 -43
- package/src/pmx-loader.ts +84 -44
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
|
-
|
|
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/physics/contact.ts
CHANGED
|
@@ -51,11 +51,18 @@ export interface Contact {
|
|
|
51
51
|
cBxN: number; cByN: number; cBzN: number // rB × n
|
|
52
52
|
jacInvN: number
|
|
53
53
|
bounceVel: number // restitution reference, captured at setup from initial relVelN
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
/** Substeps this contact point has persisted (from the manifold cache).
|
|
55
|
+
* Bullet kills restitution past m_restingContactRestitutionThreshold = 2. */
|
|
56
|
+
age: number
|
|
57
|
+
/** Penetration term routed to the SPLIT (pseudo-velocity) channel instead of
|
|
58
|
+
* the real one, when the contact is deeper than the split threshold. */
|
|
59
|
+
rhsPenetration: number
|
|
60
|
+
/** Accumulated impulse on the split channel — Bullet's m_appliedPushImpulse. */
|
|
61
|
+
appliedPushImpulse: number
|
|
62
|
+
/** Baumgarte bias, depth·ERP/dt — Bullet 2.75's positionalError. Positive
|
|
63
|
+
* when penetrating (pushes apart), negative when separated (allows the
|
|
64
|
+
* approach that closes the gap). See CONTACT_ERP. */
|
|
65
|
+
biasVel: number
|
|
59
66
|
// Friction tangent 1:
|
|
60
67
|
t1x: number; t1y: number; t1z: number
|
|
61
68
|
cAxT1: number; cAyT1: number; cAzT1: number
|
|
@@ -84,8 +91,10 @@ function makeContact(): Contact {
|
|
|
84
91
|
cBxN: 0, cByN: 0, cBzN: 0,
|
|
85
92
|
jacInvN: 0,
|
|
86
93
|
bounceVel: 0,
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
age: 0,
|
|
95
|
+
rhsPenetration: 0,
|
|
96
|
+
appliedPushImpulse: 0,
|
|
97
|
+
biasVel: 0,
|
|
89
98
|
t1x: 0, t1y: 0, t1z: 0,
|
|
90
99
|
cAxT1: 0, cAyT1: 0, cAzT1: 0,
|
|
91
100
|
cBxT1: 0, cByT1: 0, cBzT1: 0,
|
|
@@ -108,6 +117,7 @@ export class ContactPool {
|
|
|
108
117
|
c.appliedNormalImpulse = 0
|
|
109
118
|
c.appliedFrictionImpulse1 = 0
|
|
110
119
|
c.appliedFrictionImpulse2 = 0
|
|
120
|
+
c.appliedPushImpulse = 0
|
|
111
121
|
this.count++
|
|
112
122
|
return c
|
|
113
123
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Persistent contact manifolds — the cache that makes warm starting possible.
|
|
2
|
+
//
|
|
3
|
+
// Bullet 2.75 keeps a btPersistentManifold per body pair holding up to 4 points,
|
|
4
|
+
// each carrying the impulse it converged to last step. At setup the solver seeds
|
|
5
|
+
// each row with `cp.m_appliedImpulse * m_warmstartingFactor` (0.85) and applies
|
|
6
|
+
// it immediately, so a resting stack starts the substep already holding roughly
|
|
7
|
+
// the load it needs instead of rediscovering it from zero every time.
|
|
8
|
+
//
|
|
9
|
+
// That is not a nicety here: penetration recovery rides in the contact velocity
|
|
10
|
+
// row as a Baumgarte term, and a row rebuilt from zero each substep overshoots
|
|
11
|
+
// it. Warm starting on its own was measured WORSE on this engine — but that was
|
|
12
|
+
// against a solver with no bias term and a position-correction pass, which is a
|
|
13
|
+
// different system. The two are one design in Bullet and are ported as one.
|
|
14
|
+
//
|
|
15
|
+
// Points are matched by proximity in each body's own local frame, which is what
|
|
16
|
+
// btPersistentManifold does — a world-space match would drift with the body.
|
|
17
|
+
|
|
18
|
+
const MAX_POINTS = 4
|
|
19
|
+
/** Match radius, in model units. Bullet's gContactBreakingThreshold is 0.02;
|
|
20
|
+
* ours is the contact margin, so a point that merely slid along a face is
|
|
21
|
+
* still recognised as the same point rather than dropped and rebuilt. */
|
|
22
|
+
const MATCH_DIST_SQ = 0.04 * 0.04
|
|
23
|
+
|
|
24
|
+
interface Point {
|
|
25
|
+
/** Contact point in each body's local frame (lever arms are world-space and
|
|
26
|
+
* rotate with the body, so they cannot be compared across substeps). */
|
|
27
|
+
lax: number; lay: number; laz: number
|
|
28
|
+
lbx: number; lby: number; lbz: number
|
|
29
|
+
normalImpulse: number
|
|
30
|
+
frictionImpulse1: number
|
|
31
|
+
frictionImpulse2: number
|
|
32
|
+
/** Substeps this point has survived — Bullet disables restitution past
|
|
33
|
+
* m_restingContactRestitutionThreshold (2) so resting contacts stop bouncing. */
|
|
34
|
+
age: number
|
|
35
|
+
/** Marks points seen this substep; unseen ones are dropped. */
|
|
36
|
+
seen: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ManifoldCache {
|
|
40
|
+
private pairs = new Map<number, Point[]>()
|
|
41
|
+
private touched = new Set<number>()
|
|
42
|
+
|
|
43
|
+
private static key(a: number, b: number): number {
|
|
44
|
+
return a < b ? a * 65536 + b : b * 65536 + a
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Look up what this contact point converged to last substep. Returns null
|
|
48
|
+
* when it is new. */
|
|
49
|
+
find(a: number, b: number, lax: number, lay: number, laz: number): Point | null {
|
|
50
|
+
const list = this.pairs.get(ManifoldCache.key(a, b))
|
|
51
|
+
if (list === undefined) return null
|
|
52
|
+
let best: Point | null = null
|
|
53
|
+
let bestD = MATCH_DIST_SQ
|
|
54
|
+
for (let i = 0; i < list.length; i++) {
|
|
55
|
+
const p = list[i]
|
|
56
|
+
const dx = p.lax - lax, dy = p.lay - lay, dz = p.laz - laz
|
|
57
|
+
const d = dx * dx + dy * dy + dz * dz
|
|
58
|
+
if (d < bestD) { bestD = d; best = p }
|
|
59
|
+
}
|
|
60
|
+
return best
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Record what this point converged to, for the next substep to start from. */
|
|
64
|
+
store(
|
|
65
|
+
a: number, b: number,
|
|
66
|
+
lax: number, lay: number, laz: number,
|
|
67
|
+
lbx: number, lby: number, lbz: number,
|
|
68
|
+
normalImpulse: number, frictionImpulse1: number, frictionImpulse2: number,
|
|
69
|
+
age: number,
|
|
70
|
+
): void {
|
|
71
|
+
const k = ManifoldCache.key(a, b)
|
|
72
|
+
this.touched.add(k)
|
|
73
|
+
let list = this.pairs.get(k)
|
|
74
|
+
if (list === undefined) { list = []; this.pairs.set(k, list) }
|
|
75
|
+
// Replace the nearest existing point, else append; past MAX_POINTS drop the
|
|
76
|
+
// shallowest-held one so the manifold keeps the load-bearing corners.
|
|
77
|
+
let best = -1
|
|
78
|
+
let bestD = MATCH_DIST_SQ
|
|
79
|
+
for (let i = 0; i < list.length; i++) {
|
|
80
|
+
const p = list[i]
|
|
81
|
+
const dx = p.lax - lax, dy = p.lay - lay, dz = p.laz - laz
|
|
82
|
+
const d = dx * dx + dy * dy + dz * dz
|
|
83
|
+
if (d < bestD) { bestD = d; best = i }
|
|
84
|
+
}
|
|
85
|
+
if (best < 0) {
|
|
86
|
+
if (list.length < MAX_POINTS) {
|
|
87
|
+
list.push({ lax, lay, laz, lbx, lby, lbz, normalImpulse, frictionImpulse1, frictionImpulse2, age, seen: true })
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
// btPersistentManifold::sortCachedPoints — when a 5th point arrives, drop
|
|
91
|
+
// whichever of the 5 leaves the largest quadrilateral. Area is what keeps
|
|
92
|
+
// a resting box from pivoting; dropping the shallowest instead can leave
|
|
93
|
+
// four nearly-collinear points that pin position but not orientation.
|
|
94
|
+
best = worstAreaIndex(list, lax, lay, laz)
|
|
95
|
+
}
|
|
96
|
+
const p = list[best]
|
|
97
|
+
p.lax = lax; p.lay = lay; p.laz = laz
|
|
98
|
+
p.lbx = lbx; p.lby = lby; p.lbz = lbz
|
|
99
|
+
p.normalImpulse = normalImpulse
|
|
100
|
+
p.frictionImpulse1 = frictionImpulse1
|
|
101
|
+
p.frictionImpulse2 = frictionImpulse2
|
|
102
|
+
p.age = age
|
|
103
|
+
p.seen = true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Drop every point not re-seen this substep, and every pair left empty.
|
|
107
|
+
* Without this a separated pair keeps handing back a stale impulse. */
|
|
108
|
+
endStep(): void {
|
|
109
|
+
for (const [k, list] of this.pairs) {
|
|
110
|
+
if (!this.touched.has(k)) { this.pairs.delete(k); continue }
|
|
111
|
+
let w = 0
|
|
112
|
+
for (let i = 0; i < list.length; i++) {
|
|
113
|
+
const p = list[i]
|
|
114
|
+
if (!p.seen) continue
|
|
115
|
+
p.seen = false
|
|
116
|
+
list[w++] = p
|
|
117
|
+
}
|
|
118
|
+
list.length = w
|
|
119
|
+
if (w === 0) this.pairs.delete(k)
|
|
120
|
+
}
|
|
121
|
+
this.touched.clear()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
clear(): void {
|
|
125
|
+
this.pairs.clear()
|
|
126
|
+
this.touched.clear()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
/** Which of the 4 cached points to replace so the surviving quad keeps the most
|
|
132
|
+
* area once the new point joins it. */
|
|
133
|
+
function worstAreaIndex(list: Point[], nx: number, ny: number, nz: number): number {
|
|
134
|
+
let bestIdx = 0
|
|
135
|
+
let bestArea = -1
|
|
136
|
+
for (let drop = 0; drop < list.length; drop++) {
|
|
137
|
+
// The quad is: the new point plus the three survivors.
|
|
138
|
+
const pts: number[][] = [[nx, ny, nz]]
|
|
139
|
+
for (let i = 0; i < list.length; i++) if (i !== drop) pts.push([list[i].lax, list[i].lay, list[i].laz])
|
|
140
|
+
if (pts.length < 4) continue
|
|
141
|
+
// |d0 × d1| over the diagonals — Bullet's area proxy.
|
|
142
|
+
const d0x = pts[0][0] - pts[2][0], d0y = pts[0][1] - pts[2][1], d0z = pts[0][2] - pts[2][2]
|
|
143
|
+
const d1x = pts[1][0] - pts[3][0], d1y = pts[1][1] - pts[3][1], d1z = pts[1][2] - pts[3][2]
|
|
144
|
+
const cx = d0y * d1z - d0z * d1y
|
|
145
|
+
const cy = d0z * d1x - d0x * d1z
|
|
146
|
+
const cz = d0x * d1y - d0y * d1x
|
|
147
|
+
const area = cx * cx + cy * cy + cz * cz
|
|
148
|
+
if (area > bestArea) { bestArea = area; bestIdx = drop }
|
|
149
|
+
}
|
|
150
|
+
return bestIdx
|
|
151
|
+
}
|