Skip to content

Commit c2da0dd

Browse files
committed
Fix glTF_Physics/samples/Robot_skinned scene: Add bone constraints (Copy Transforms / Child Of). Auto-wire skin joints to physics-driven ancestors.
- Per-bone constraint stack with inspector UI. Evaluated in the pose-sync loop against a new BonePoseWorld scratch buffer. - On glTF import, bones whose joint node has a physics-driven ancestor get a Child Of constraint with InverseMatrix baked to the rest offset, so skins follow rigid bodies. - Acceleration-mode drives with stiffness > 0 now convert to frequency/damping-ratio - EmptyShape fallback for motion-only bodies without colliders - skip BoneAttachment on physics-driven joint-node objects - Fork JoltPhysics with jrouwe/JoltPhysics#1977
1 parent 1146924 commit c2da0dd

9 files changed

Lines changed: 234 additions & 43 deletions

File tree

.gitmodules

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,4 @@
5151
url = git@github.com:BinomialLLC/basis_universal.git
5252
[submodule "lib/JoltPhysics"]
5353
path = lib/JoltPhysics
54-
url = git@github.com:jrouwe/JoltPhysics.git
54+
url = git@github.com:khiner/JoltPhysics.git

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,8 @@ Noteworthy dev bits:
4949

5050
This project supports generating an efficient physical audio model for any mesh using Linear Modal Analysis/Synthesis
5151

52-
The physical audio modeling components were implemented as a final project for PHYS-6260 - Computational Physics at Georgia Tech during my Master's, and this is the final report. [Here is the final report paper](paper/PAMofPassiveRigidBodies.pdf).
53-
54-
And here is a 36X48 poster:
52+
The physical audio modeling was originally implemented as a final project for PHYS-6260 - Computational Physics at Georgia Tech during my Master's.
53+
It's gotten a lot of work since then, but the details laid out in [the final report](paper/PAMofPassiveRigidBodies.pdf) are still the same, as well as this 36X48 poster:
5554

5655
![](paper/ProjectPoster36X48.png)
5756

src/Armature.cpp

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -329,31 +329,38 @@ void BoneMat3ToVecRoll(const mat3 &m, vec3 &direction, float &roll) {
329329
// co-located transforms and the spec ignores skinned mesh node transforms.
330330
void ComputeDeformMatrices(
331331
const Armature &data,
332-
std::span<const Transform> pose_deltas, std::span<const Transform> user_offsets, std::span<const mat4> inverse_bind_matrices, std::span<mat4> out_deform_matrices
332+
std::span<const mat4> bone_pose_world, std::span<const mat4> inverse_bind_matrices, std::span<mat4> out_deform_matrices
333333
) {
334334
if (!data.ImportedSkin || data.Bones.empty()) return;
335335

336-
// Compute posed world transforms in parent-before-child order (bones are already sorted this way)
337-
std::vector<mat4> pose_world(data.Bones.size());
338-
for (uint32_t i = 0; i < data.Bones.size(); ++i) {
339-
const auto combined = ComposeWithDelta(pose_deltas[i], user_offsets[i]);
340-
const auto local = ToMatrix(ComposeWithDelta(data.Bones[i].RestLocal, combined));
341-
const auto parent = data.Bones[i].ParentIndex;
342-
pose_world[i] = (parent == InvalidBoneIndex) ? local : pose_world[parent] * local;
343-
}
344-
345-
// For each joint in the skin's ordering, compute the deform matrix using the precomputed index mapping.
346336
for (uint32_t j = 0; j < data.JointOrderToBoneIndex.size() && j < out_deform_matrices.size(); ++j) {
347337
const auto bone_index = data.JointOrderToBoneIndex[j];
348-
if (bone_index == InvalidBoneIndex || bone_index >= pose_world.size()) {
338+
if (bone_index == InvalidBoneIndex || bone_index >= bone_pose_world.size()) {
349339
out_deform_matrices[j] = I4;
350340
continue;
351341
}
352342
const auto &ibm = (j < inverse_bind_matrices.size()) ? inverse_bind_matrices[j] : I4;
353-
out_deform_matrices[j] = pose_world[bone_index] * ibm;
343+
out_deform_matrices[j] = bone_pose_world[bone_index] * ibm;
354344
}
355345
}
356346

347+
Transform ApplyBoneConstraint(
348+
const BoneConstraint &c, const Transform &pre_local,
349+
const mat4 &parent_pose_world, const mat4 &armature_world_inv, const mat4 &target_world
350+
) {
351+
const mat4 effective_target = std::visit(
352+
[&]<typename T>(const T &d) -> mat4 {
353+
if constexpr (std::is_same_v<T, ChildOfData>) return target_world * d.InverseMatrix;
354+
else return target_world;
355+
},
356+
c.Data
357+
);
358+
const mat4 constrained_local = glm::inverse(parent_pose_world) * (armature_world_inv * effective_target);
359+
const Transform tl{vec3(constrained_local[3]), glm::normalize(glm::quat_cast(mat3(constrained_local))), pre_local.S};
360+
if (c.Influence >= 1.f) return tl;
361+
return {glm::mix(pre_local.P, tl.P, c.Influence), glm::slerp(pre_local.R, tl.R, c.Influence), pre_local.S};
362+
}
363+
357364
float ComputeBoneDisplayScale(const Armature &armature, uint32_t bone_index) {
358365
static constexpr float MinBoneLength = 0.004f;
359366
float min_child_dist = std::numeric_limits<float>::max();
@@ -379,6 +386,7 @@ void RebuildArmatureStructure(entt::registry &r, entt::entity arm_data_entity) {
379386
if (auto *ps = r.try_get<ArmaturePoseState>(arm_data_entity)) {
380387
ps->BonePoseDelta.assign(armature.Bones.size(), identity);
381388
ps->BoneUserOffset.assign(armature.Bones.size(), identity);
389+
ps->BonePoseWorld.assign(armature.Bones.size(), I4);
382390
}
383391
if (auto *anim = r.try_get<ArmatureAnimation>(arm_data_entity)) {
384392
for (auto &clip : anim->Clips) armature.ResolveAnimationIndices(clip);

src/Armature.h

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include <string>
1414
#include <string_view>
1515
#include <unordered_map>
16+
#include <variant>
1617
#include <vector>
1718

1819
inline constexpr uint32_t InvalidBoneIndex{std::numeric_limits<uint32_t>::max()};
@@ -134,10 +135,27 @@ struct BoneAttachment {
134135
BoneId Bone;
135136
};
136137

138+
// Pose constraint stack on bone entities.
139+
struct CopyTransformsData {};
140+
struct ChildOfData {
141+
mat4 InverseMatrix{I4}; // Stored "parent-inverse" like Blender's Child Of: inverse(target_world) * owner_world at bind time.
142+
};
143+
144+
struct BoneConstraint {
145+
entt::entity TargetEntity{null_entity};
146+
float Influence{1.f};
147+
std::variant<CopyTransformsData, ChildOfData> Data{CopyTransformsData{}};
148+
};
149+
150+
struct BoneConstraints {
151+
std::vector<BoneConstraint> Stack;
152+
};
153+
137154
// Component on armature data entities with imported skin data.
138155
struct ArmaturePoseState {
139156
std::vector<Transform> BonePoseDelta; // Animation delta from rest (identity = at rest). Persistent across frames.
140157
std::vector<Transform> BoneUserOffset; // Additive user offset per bone (identity = no offset). Applied on top of animation.
158+
std::vector<mat4> BonePoseWorld; // Per-bone pose world in armature-local space, post-constraint. Scratch, reused across frames.
141159
Range GpuDeformRange; // Allocation in shared ArmatureDeformBuffer arena. Count == 0 means not yet allocated.
142160
};
143161

@@ -203,9 +221,14 @@ Transform AbsoluteToDelta(const Transform &rest, const Transform &absolute);
203221
// keyframe value and converts to a rest-relative delta. Unkeyed components are left unchanged.
204222
void EvaluateAnimationDeltas(const AnimationClip &, float time, std::span<const ArmatureBone>, std::span<Transform> deltas);
205223

206-
// Compute final deform matrices from rest poses + pose deltas + user offsets + inverse bind matrices.
207-
// Effective delta per bone = ComposeWithDelta(pose_delta, user_offset). Writes directly into `out` (mapped GPU memory).
208-
void ComputeDeformMatrices(const Armature &, std::span<const Transform> pose_deltas, std::span<const Transform> user_offsets, std::span<const mat4> inverse_bind, std::span<mat4> out);
224+
// Gather per-joint deform matrices from pre-composed bone pose-world matrices + inverse binds.
225+
// Pose-sync owns FK (it needs per-bone world for constraint eval), so this is only the joint-order gather.
226+
// Writes directly into `out` (mapped GPU memory).
227+
void ComputeDeformMatrices(const Armature &, std::span<const mat4> bone_pose_world, std::span<const mat4> inverse_bind, std::span<mat4> out);
228+
229+
// Blend `pre_local` toward the transform implied by `target_world` at `c.Influence`.
230+
// Math is armature-local; `armature_world_inv` converts target from world. Scale is preserved from `pre_local`.
231+
Transform ApplyBoneConstraint(const BoneConstraint &c, const Transform &pre_local, const mat4 &parent_pose_world, const mat4 &armature_world_inv, const mat4 &target_world);
209232

210233
// Non-leaf: minimum distance to any child (ignoring near-zero). Leaf: inherit parent's scale, or 1.0.
211234
float ComputeBoneDisplayScale(const Armature &, uint32_t bone_index);

src/Scene.cpp

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -919,8 +919,11 @@ Scene::RenderRequest Scene::ProcessComponentEvents() {
919919
auto &pose_state = R.get<ArmaturePoseState>(arm_data_entity);
920920
const auto &armature = R.get<const Armature>(arm_data_entity);
921921
pose_state.GpuDeformRange = Buffers->ArmatureDeformBuffer.Allocate(armature.ImportedSkin->OrderedJointNodeIndices.size());
922+
for (uint32_t i = 0; i < armature.Bones.size() && i < pose_state.BonePoseWorld.size(); ++i) {
923+
pose_state.BonePoseWorld[i] = armature.Bones[i].RestWorld;
924+
}
922925
ComputeDeformMatrices(
923-
armature, pose_state.BonePoseDelta, pose_state.BoneUserOffset,
926+
armature, pose_state.BonePoseWorld,
924927
armature.ImportedSkin->InverseBindMatrices,
925928
Buffers->ArmatureDeformBuffer.GetMutable(pose_state.GpuDeformRange)
926929
);
@@ -1534,8 +1537,13 @@ Scene::RenderRequest Scene::ProcessComponentEvents() {
15341537
auto &armature = R.get<Armature>(arm_obj_comp.Entity);
15351538
if (!armature.ImportedSkin) continue;
15361539

1537-
// Skip armatures with no dirty bones unless a global refresh is needed.
1538-
if (!bones_need_refresh) {
1540+
// Constraints can depend on external targets (e.g. physics bodies), so we can't early-out on bone-dirty alone.
1541+
const bool has_any_constraint = std::any_of(
1542+
arm_obj_comp.BoneEntities.begin(), arm_obj_comp.BoneEntities.end(),
1543+
[&](auto e) { return R.all_of<BoneConstraints>(e); }
1544+
);
1545+
1546+
if (!bones_need_refresh && !has_any_constraint) {
15391547
bool has_dirty = false;
15401548
for (const auto b : arm_obj_comp.BoneEntities) {
15411549
if (local_changes.contains(b) || transform_end.contains(b)) {
@@ -1546,19 +1554,25 @@ Scene::RenderRequest Scene::ProcessComponentEvents() {
15461554
if (!has_dirty) continue;
15471555
}
15481556

1549-
bool need_sync{false}, rest_pose_edited{false};
1557+
const mat4 armature_world_inv = has_any_constraint ? glm::inverse(ToMatrix(R.get<const WorldTransform>(arm_obj_entity))) : I4;
1558+
1559+
bool need_sync = has_any_constraint;
1560+
bool rest_pose_edited = false;
15501561
for (uint32_t i = 0; i < arm_obj_comp.BoneEntities.size(); ++i) {
15511562
const auto b = arm_obj_comp.BoneEntities[i];
15521563
if (i >= pose_state->BonePoseDelta.size()) continue;
15531564
const auto &rest = armature.Bones[i].RestLocal;
1565+
const auto &bt = R.get<const Transform>(b);
1566+
Transform local{bt.P, bt.R, rest.S}; // Default; branches that recompute overwrite it.
1567+
bool should_patch = false;
15541568
if (is_edit_mode) {
15551569
if (mode_changed) {
15561570
// Entering Edit mode: snap to rest pose.
1557-
R.patch<Transform>(b, [&](auto &t) { t.P = rest.P; t.R = rest.R; });
1571+
local = {rest.P, rest.R, rest.S};
1572+
should_patch = true;
15581573
need_sync = true;
15591574
} else if (transform_end.contains(b) || (local_changes.contains(b) && !R.all_of<StartTransform>(b))) {
15601575
// Commit Edit mode transform (gizmo drag end or UI slider edit).
1561-
const auto &bt = R.get<const Transform>(b);
15621576
armature.Bones[i].RestLocal.P = bt.P;
15631577
armature.Bones[i].RestLocal.R = bt.R;
15641578
rest_pose_edited = true;
@@ -1575,32 +1589,50 @@ Scene::RenderRequest Scene::ProcessComponentEvents() {
15751589
.S = st->T.S / pd.S,
15761590
}
15771591
);
1578-
const auto &bt = R.get<const Transform>(b);
15791592
const Transform gizmo_local{bt.P, bt.R, rest.S};
15801593
pose_state->BoneUserOffset[i] = AbsoluteToDelta(grab_delta, AbsoluteToDelta(rest, gizmo_local));
1581-
const auto local = ComposeWithDelta(rest, ComposeWithDelta(pose_state->BonePoseDelta[i], pose_state->BoneUserOffset[i]));
1582-
R.patch<Transform>(b, [&](auto &t) { t.P = local.P; t.R = local.R; });
1594+
local = ComposeWithDelta(rest, ComposeWithDelta(pose_state->BonePoseDelta[i], pose_state->BoneUserOffset[i]));
1595+
should_patch = true;
15831596
need_sync = true;
15841597
} else if (transform_end.contains(b)) {
15851598
// Commit drag: bake current P/R into delta, clear offset.
1586-
const auto &cbt = R.get<const Transform>(b);
1587-
pose_state->BonePoseDelta[i] = AbsoluteToDelta(rest, {cbt.P, cbt.R, rest.S});
1599+
pose_state->BonePoseDelta[i] = AbsoluteToDelta(rest, {bt.P, bt.R, rest.S});
15881600
pose_state->BoneUserOffset[i] = {};
15891601
need_sync = true;
15901602
} else if (bones_need_refresh) {
15911603
// Animation advanced or leaving Edit mode: recompute entity P/R from deltas.
1592-
const auto local = ComposeWithDelta(rest, ComposeWithDelta(pose_state->BonePoseDelta[i], pose_state->BoneUserOffset[i]));
1593-
R.patch<Transform>(b, [&](auto &t) { t.P = local.P; t.R = local.R; });
1604+
local = ComposeWithDelta(rest, ComposeWithDelta(pose_state->BonePoseDelta[i], pose_state->BoneUserOffset[i]));
1605+
should_patch = true;
15941606
need_sync = true;
15951607
} else if (local_changes.contains(b)) {
15961608
// Manual transform: bake if position actually changed.
15971609
const auto expected = ComposeWithDelta(rest, ComposeWithDelta(pose_state->BonePoseDelta[i], pose_state->BoneUserOffset[i]));
1598-
const auto &bt = R.get<const Transform>(b);
1599-
if (bt.P == expected.P && bt.R == expected.R) continue;
1600-
pose_state->BonePoseDelta[i] = AbsoluteToDelta(rest, {bt.P, bt.R, rest.S});
1601-
pose_state->BoneUserOffset[i] = {};
1602-
need_sync = true;
1610+
if (bt.P != expected.P || bt.R != expected.R) {
1611+
pose_state->BonePoseDelta[i] = AbsoluteToDelta(rest, {bt.P, bt.R, rest.S});
1612+
pose_state->BoneUserOffset[i] = {};
1613+
need_sync = true;
1614+
}
1615+
}
1616+
1617+
const uint32_t parent_idx = armature.Bones[i].ParentIndex;
1618+
const mat4 parent_pose_world = (parent_idx == InvalidBoneIndex) ? I4 : pose_state->BonePoseWorld[parent_idx];
1619+
1620+
// Apply bone constraints. Skipped in edit mode (rest-pose edits bypass pose constraints).
1621+
if (!is_edit_mode) {
1622+
if (const auto *cs = R.try_get<const BoneConstraints>(b); cs && !cs->Stack.empty()) {
1623+
const auto before = local;
1624+
for (const auto &c : cs->Stack) {
1625+
if (c.TargetEntity == null_entity || !R.valid(c.TargetEntity)) continue;
1626+
const auto *twt = R.try_get<const WorldTransform>(c.TargetEntity);
1627+
if (!twt) continue;
1628+
local = ApplyBoneConstraint(c, local, parent_pose_world, armature_world_inv, ToMatrix(*twt));
1629+
}
1630+
if (local.P != before.P || local.R != before.R) should_patch = true;
1631+
}
16031632
}
1633+
1634+
if (should_patch) R.patch<Transform>(b, [&](auto &t) { t.P = local.P; t.R = local.R; });
1635+
pose_state->BonePoseWorld[i] = parent_pose_world * RestLocalToMatrix(local);
16041636
}
16051637
if (rest_pose_edited) {
16061638
// Recompute RestWorld in topological order, preserving world positions of untransformed bones.
@@ -1626,7 +1658,7 @@ Scene::RenderRequest Scene::ProcessComponentEvents() {
16261658
if (need_sync) {
16271659
if (!is_edit_mode) {
16281660
ComputeDeformMatrices(
1629-
armature, pose_state->BonePoseDelta, pose_state->BoneUserOffset,
1661+
armature, pose_state->BonePoseWorld,
16301662
armature.ImportedSkin->InverseBindMatrices,
16311663
Buffers->ArmatureDeformBuffer.GetMutable(pose_state->GpuDeformRange)
16321664
);

src/SceneGltf.cpp

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,7 @@ std::expected<std::pair<entt::entity, entt::entity>, std::string> Scene::AddGltf
503503
if (const auto object_it = object_entities_by_node.find(joint.JointNodeIndex);
504504
object_it != object_entities_by_node.end() &&
505505
R.all_of<Instance>(object_it->second) &&
506+
!R.all_of<PhysicsMotion>(object_it->second) &&
506507
!R.all_of<BoneAttachment>(object_it->second)) {
507508
R.emplace<BoneAttachment>(object_it->second, armature_data_entity, bone_id);
508509
}
@@ -555,9 +556,44 @@ std::expected<std::pair<entt::entity, entt::entity>, std::string> Scene::AddGltf
555556
const Transform identity_delta{vec3{0}, quat{1, 0, 0, 0}, vec3{1}};
556557
pose_state.BonePoseDelta.resize(armature.Bones.size(), identity_delta);
557558
pose_state.BoneUserOffset.resize(armature.Bones.size(), identity_delta);
559+
pose_state.BonePoseWorld.resize(armature.Bones.size(), I4);
558560
R.emplace<ArmaturePoseState>(armature_data_entity, std::move(pose_state));
559561
}
560562
CreateBoneInstances(armature_entity, armature_data_entity);
563+
564+
// Auto-wire Child Of on bones whose joint node has a physics-driven ancestor object,
565+
// so the skin follows simulated motion when an asset pairs rigid bodies with skinning via the scene graph.
566+
// Target is the nearest ancestor object with PhysicsMotion; InverseMatrix bakes the rest offset.
567+
{
568+
const auto find_physics_ancestor_entity = [&](uint32_t node_index) -> entt::entity {
569+
for (std::optional<uint32_t> cur = node_index; cur;) {
570+
if (const auto oit = object_entities_by_node.find(*cur);
571+
oit != object_entities_by_node.end() && R.all_of<PhysicsMotion>(oit->second)) return oit->second;
572+
const auto nit = scene_nodes_by_index.find(*cur);
573+
if (nit == scene_nodes_by_index.end()) break;
574+
cur = nit->second->ParentNodeIndex;
575+
}
576+
return entt::null;
577+
};
578+
const auto &arm_obj = R.get<const ArmatureObject>(armature_entity);
579+
const mat4 armature_world = ToMatrix(R.get<const WorldTransform>(armature_entity));
580+
for (uint32_t i = 0; i < armature.Bones.size(); ++i) {
581+
const auto &bone = armature.Bones[i];
582+
if (!bone.JointNodeIndex) continue;
583+
const auto target = find_physics_ancestor_entity(*bone.JointNodeIndex);
584+
if (target == entt::null) continue;
585+
R.emplace<BoneConstraints>(
586+
arm_obj.BoneEntities[i],
587+
BoneConstraints{
588+
.Stack = {BoneConstraint{
589+
.TargetEntity = target,
590+
.Influence = 1.f,
591+
.Data = ChildOfData{.InverseMatrix = glm::inverse(ToMatrix(R.get<const WorldTransform>(target))) * (armature_world * bone.RestWorld)},
592+
}}
593+
}
594+
);
595+
}
596+
}
561597
}
562598

563599
std::unordered_set<uint32_t> joint_node_indices;

0 commit comments

Comments
 (0)