Character hair physics implementation

This commit is contained in:
2026-06-16 01:22:20 +03:00
parent 765dffbed0
commit 0a5edacf8a
26 changed files with 1629 additions and 225 deletions
+17 -34
View File
@@ -459,41 +459,24 @@ function(add_shape_key_propagation INPUT_BLEND OUTPUT_BLEND)
)
endfunction()
add_shape_key_propagation(
${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-male.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-shapes.blend
)
add_shape_key_propagation(
${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-female.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-shapes.blend
)
# male
set(SLOT_LIST "bottom" "top" "feet" "hair")
set(SLOT_INPUT "edited-normal-male-shapes.blend")
list(GET SLOT_LIST -1 LAST_SLOT)
foreach (SLOT ${SLOT_LIST})
set(SLOT_OUTPUT "edited-normal-male-consolidated-${SLOT}.blend")
if (SLOT STREQUAL LAST_SLOT)
set(SLOT_OUTPUT "edited-normal-male-consolidated.blend")
endif()
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-${SLOT}_weighted.blend"
"${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}"
# male and female clothes pipeline
foreach (SEX ${SEX_LIST})
add_shape_key_propagation(
${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${SEX}.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-shapes.blend
)
set(SLOT_INPUT ${SLOT_OUTPUT})
endforeach()
# female
set(SLOT_INPUT "edited-normal-female-shapes.blend")
foreach (SLOT ${SLOT_LIST})
set(SLOT_OUTPUT "edited-normal-female-consolidated-${SLOT}.blend")
if (SLOT STREQUAL LAST_SLOT)
set(SLOT_OUTPUT "edited-normal-female-consolidated.blend")
endif()
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-${SLOT}_weighted.blend"
"${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}"
)
set(SLOT_INPUT ${SLOT_OUTPUT})
set(SLOT_INPUT "edited-normal-${SEX}-shapes.blend")
foreach (SLOT ${SLOT_LIST})
set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated-${SLOT}.blend")
if (SLOT STREQUAL LAST_SLOT)
set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated.blend")
endif()
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-${SLOT}_weighted.blend"
"${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}"
)
set(SLOT_INPUT ${SLOT_OUTPUT})
endforeach()
endforeach()
Binary file not shown.
+26 -43
View File
@@ -151,16 +151,19 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
print(f"Processing: {clothing_name_for_final} -> {target_name} (using copy)")
# NEW CODE: Store custom properties from clothing before they might be lost
# Store custom properties from clothing before they might be lost
clothing_props = {}
for prop_name in ["ref_shapes", "ref_ray_length"]:
for prop_name in ["ref_shapes", "ref_ray_length",
"has_own_armature", "own_armature_name"]:
if prop_name in clothing_obj:
clothing_props[prop_name] = clothing_obj[prop_name]
print(f" Stored property '{prop_name}' = {clothing_obj[prop_name]} from clothing")
# Also store metadata tags for export pipeline
clothing_meta = {}
for prop_name in ["ref_layer", "ref_part", "garment_id", "tags", "age", "sex", "slot"]:
for prop_name in ["ref_layer", "ref_part", "garment_id", "tags",
"age", "sex", "slot",
"has_own_armature", "own_armature_name"]:
if prop_name in clothing_obj:
clothing_meta[prop_name] = clothing_obj[prop_name]
print(f" Stored meta '{prop_name}' = {clothing_obj[prop_name]} from clothing")
@@ -181,18 +184,31 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
whitelist.add(master_arm)
# Handle clothing armature (if it's not a copy for layer 2)
has_own_arm = clothing_obj.get("has_own_armature", False)
if not is_clothing_copy and clothing_obj.parent and clothing_obj.parent.type == 'ARMATURE':
old_arm = clothing_obj.parent
clothing_obj.matrix_world = clothing_obj.matrix_world.copy()
clothing_obj.parent = None
if old_arm not in whitelist:
bpy.data.objects.remove(old_arm, do_unlink=True)
# Keep hair armatures (objects with own skeleton)
if not has_own_arm:
if old_arm not in whitelist:
bpy.data.objects.remove(old_arm, do_unlink=True)
else:
whitelist.add(old_arm) # preserve hair skeleton
elif not is_clothing_copy and has_own_arm:
# Hair has own skeleton but parent is None (or not an armature);
# find the armature by name and preserve it.
own_arm_name = clothing_obj.get("own_armature_name", "")
old_arm = bpy.data.objects.get(own_arm_name)
if old_arm and old_arm.type == 'ARMATURE' and old_arm not in whitelist:
whitelist.add(old_arm)
print(f" Preserved hair armature '{old_arm.name}' by name for {clothing_name_for_final}")
# For layer 2 clothing copies, we don't need to handle armature separately
# as they'll inherit from the target
# Reparent to master armature if exists
if master_arm:
if master_arm and not clothing_obj.get("has_own_armature", False):
clothing_obj.parent = master_arm
for mod in clothing_obj.modifiers:
if mod.type == 'ARMATURE':
@@ -210,11 +226,6 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
bpy.ops.object.join()
# ---- Fix: ensure clothing faces keep the clothing's material ----
# After join, Blender may have assigned clothing faces to a
# pre-existing slot with the same base name but older texture
# references (loaded from the body blend). Find the slot that
# actually holds the clothing's original material data block
# and reassign any faces that landed in a stale look-alike slot.
if clothing_mat:
clothing_slot = None
stale_slot = None
@@ -238,15 +249,12 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
f"{stale_slot} -> clothing slot {clothing_slot}")
# ----------------------------------------------------------------
# NEW CODE: Copy stored custom properties to combined object
# After joining, new_target is the active object and contains the combined mesh
# Copy stored custom properties to combined object
for prop_name, prop_value in clothing_props.items():
new_target[prop_name] = prop_value
print(f" Copied property '{prop_name}' = {prop_value} to combined object")
# NEW CODE: Aggregate clothing metadata onto combined object
# Collect from all clothing objects processed for this target
# Use JSON string since Blender ID properties don't support Python lists/sets
# Aggregate clothing metadata onto combined object
if "_combined_meta" in new_target:
meta = json.loads(new_target["_combined_meta"])
else:
@@ -297,17 +305,14 @@ def run_batch_combine():
all_objs = bpy.data.objects
# Separate body objects (no ref_layer property)
# Filter out rig control shapes (cs_*), helper objects, and objects with no vertices
body_objects = []
skipped_objects = []
for o in all_objs:
if o.type != 'MESH' or "ref_layer" in o:
continue
# Skip rig control shapes and helper objects
if o.name.startswith("cs_") or o.name.startswith("WGT-") or o.name.startswith("MCH-") or o.name.startswith("ORG-"):
skipped_objects.append(o.name)
continue
# Skip objects with no vertices (empty meshes)
if len(o.data.vertices) == 0:
skipped_objects.append(o.name)
continue
@@ -316,7 +321,6 @@ def run_batch_combine():
if skipped_objects:
print(f"Skipped {len(skipped_objects)} non-body mesh objects: {', '.join(skipped_objects[:10])}{'...' if len(skipped_objects) > 10 else ''}")
# Filter body objects: those missing age/sex/slot are treated as helpers/references
required_body_props = {"age", "sex", "slot"}
valid_body_objects = []
skipped_body_objects = []
@@ -335,7 +339,6 @@ def run_batch_combine():
body_objects = valid_body_objects
# Separate clothing by layer, filtering out objects missing required clothing properties
required_clothing_props = {"ref_layer", "ref_part", "garment_id"}
clothing_layer1 = []
clothing_layer2 = []
@@ -343,7 +346,6 @@ def run_batch_combine():
for o in all_objs:
if o.type != 'MESH' or "ref_layer" not in o:
continue
# Check if this is a valid clothing object (has required clothing properties)
missing = [p for p in required_clothing_props if p not in o]
if missing:
print(f"WARNING: Mesh object '{o.name}' has ref_layer but is missing required clothing properties {missing}.")
@@ -362,16 +364,11 @@ def run_batch_combine():
print(f"Found {len(clothing_layer1)} layer 1 clothing objects")
print(f"Found {len(clothing_layer2)} layer 2 clothing objects")
# Create dictionary of body objects for quick lookup
body_objects_dict = {obj.name: obj for obj in body_objects}
# Track objects to keep
whitelist = set()
# List to store combined objects from layer 1
combined_objects = []
# PROCESS LAYER 1: Combine with original body parts
# PROCESS LAYER 1
print("\n=== PROCESSING LAYER 1 CLOTHING ===")
for clothing_obj in clothing_layer1:
if "ref_part" not in clothing_obj:
@@ -385,7 +382,6 @@ def run_batch_combine():
print(f"Warning: Target body '{target_name}' not found for clothing '{clothing_obj.name}', skipping")
continue
# Process the pair
result = process_clothing_pair(clothing_obj, original_body, whitelist)
if result:
combined_objects.append(result)
@@ -399,27 +395,20 @@ def run_batch_combine():
for clothing_obj in clothing_layer2:
print(f"\nProcessing layer 2 clothing: {clothing_obj.name}")
# Store the original clothing name for later use
original_clothing_name = clothing_obj.name
# For each combined object from layer 1
for combined_obj in combined_objects:
# Create a copy of the layer 2 clothing
clothing_copy = clothing_obj.copy()
clothing_copy.data = clothing_obj.data.copy()
bpy.context.collection.objects.link(clothing_copy)
# Copy custom properties
for key in clothing_obj.keys():
clothing_copy[key] = clothing_obj[key]
# Set the ref_part to point to the combined object
clothing_copy["ref_part"] = combined_obj.name
print(f" Combining with layer 1 result: {combined_obj.name}")
# Process the pair
result = process_clothing_pair(clothing_copy, combined_obj, whitelist,
is_clothing_copy=True,
original_clothing_name=original_clothing_name)
@@ -429,7 +418,7 @@ def run_batch_combine():
print(f"Layer 2 first pass complete. Created {len(layer2_results_over_l1)} combined objects")
# PROCESS LAYER 2 (Second Pass): Combine directly with body parts (like layer 1)
# PROCESS LAYER 2 (Second Pass): Combine directly with body parts
print("\n=== PROCESSING LAYER 2 CLOTHING (Second Pass - Direct to Body) ===")
layer2_results_direct = []
@@ -447,7 +436,6 @@ def run_batch_combine():
print(f"\nProcessing layer 2 clothing directly with body: {clothing_obj.name} -> {target_name}")
# Process directly with body part (no copy needed for the clothing itself)
result = process_clothing_pair(clothing_obj, original_body, whitelist, is_clothing_copy=False)
if result:
layer2_results_direct.append(result)
@@ -455,7 +443,6 @@ def run_batch_combine():
print(f"Layer 2 second pass complete. Created {len(layer2_results_direct)} combined objects")
# Add all results to combined objects list
all_results = combined_objects + layer2_results_over_l1 + layer2_results_direct
print(f"\n=== SUMMARY ===")
@@ -464,7 +451,6 @@ def run_batch_combine():
print(f"Layer 2 direct to body results: {len(layer2_results_direct)}")
print(f"Total combined objects: {len(all_results)}")
# Finalize aggregated metadata on all combined objects
for obj in all_results:
whitelist.add(obj)
if "_combined_meta" in obj:
@@ -490,16 +476,13 @@ def run_batch_combine():
print(f"Finalized metadata for {obj.name}: layer={obj['layer']}, garments={obj['garments']}, tags={obj['clothing_tags']}")
# Remove temporary meta property
del obj["_combined_meta"]
cleanup_unused_objects(whitelist)
# Save the result
bpy.ops.wm.save_as_mainfile(filepath=output_path)
print(f"\nSaved to: {output_path}")
if __name__ == "__main__":
run_batch_combine()
+41 -20
View File
@@ -16,13 +16,10 @@ def process_append(source_files, output_path):
print(f"Warning: Source file not found: {file_path}")
continue
# ---- Pre-import materials from source blend ----
# Blender silently reuses existing materials when appending
# objects, even if the incoming one has different properties
# (texture references). Force materials to be imported first
# so texture changes in the source .blend survive.
# ---- Pre-import materials and armatures from source blend ----
with bpy.data.libraries.load(file_path, link=False) as (data_from, data_to):
data_to.materials = data_from.materials
data_to.objects = data_from.objects
imported_materials = {}
for mat in data_to.materials:
if mat is None:
@@ -33,15 +30,26 @@ def process_append(source_files, output_path):
f"different data; incoming material will "
f"replace it on appended objects.")
imported_materials[mat.name] = mat
# ----------------------------------------------------
with bpy.data.libraries.load(file_path) as (data_from, data_to):
data_to.objects = data_from.objects
# Also track imported armatures
imported_armatures = {}
for obj in data_to.objects:
if obj is None:
continue
if obj.type == 'ARMATURE':
imported_armatures[obj.name] = obj
# ----------------------------------------------------
for obj in data_to.objects:
if obj is None: continue
# Check criteria
# Keep armatures from the source blend
if obj.type == 'ARMATURE':
bpy.context.collection.objects.link(obj)
print(f"Imported armature '{obj.name}' from source blend")
continue
# Check criteria for body part meshes
has_props = all(p in obj.keys() for p in required_props)
if obj.type == 'MESH' and has_props:
# 1. Link to the scene root temporarily
@@ -61,17 +69,36 @@ def process_append(source_files, output_path):
# 2. Synchronize Names
obj.data.name = obj.name
# 3. Find Target Armature
# 3. Armature handling
has_own_arm = obj.get("has_own_armature", False)
if has_own_arm:
own_arm_name = obj.get("own_armature_name", "")
own_arm = bpy.data.objects.get(own_arm_name)
if not own_arm and own_arm_name in imported_armatures:
own_arm = imported_armatures[own_arm_name]
if own_arm:
# Keep hair's own armature; don't reparent
# to body armature.
# Fix the Armature modifier to point to the
# hair armature (combine_clothes may have
# changed it to the body rig).
arm_mod = next((m for m in obj.modifiers
if m.type == 'ARMATURE'), None)
if arm_mod:
arm_mod.object = own_arm
obj.parent = own_arm
print(f"Processed {obj.name}: keeping own "
f"armature '{own_arm_name}'")
continue
# Normal body part: parent to body armature
arm_name = obj.get("sex")
arm_obj = bpy.data.objects.get(arm_name)
if arm_obj and arm_obj.type == 'ARMATURE':
# A. Handle Collections: Move mesh to armature's collections
# Remove from all current collections first
# A. Handle Collections
for col in obj.users_collection:
col.objects.unlink(obj)
# Link to every collection the armature belongs to
for col in arm_obj.users_collection:
col.objects.link(obj)
@@ -82,28 +109,22 @@ def process_append(source_files, output_path):
arm_mod = next((m for m in obj.modifiers if m.type == 'ARMATURE'), None)
if not arm_mod:
arm_mod = obj.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = arm_obj
print(f"Processed {obj.name}: Parented and Modset to {arm_name}")
else:
print(f"Warning: Armature '{arm_name}' not found for {obj.name}")
elif obj.type == 'MESH':
# Object is a mesh but missing required properties - treat as helper/reference
missing = [p for p in required_props if p not in obj.keys()]
print(f"WARNING: Mesh object '{obj.name}' is missing required properties {missing}.")
print(f" Treating as helper/reference object (not a body part).")
print(f" Available properties: {[k for k in obj.keys() if not k.startswith('_')]}")
# Don't remove - keep helper/reference objects in the scene
else:
# Clean up data not meeting criteria
bpy.data.objects.remove(obj, do_unlink=True)
# 4. Recursive Purge of all unlinked data (Materials, Textures, Meshes)
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
# 5. Fix seams across ALL body parts in the consolidated scene.
# This synchronizes shape key offsets and normals for matching vertices
# across base body parts and combined clothing variants.
print("\nFixing seams across all consolidated body parts...")
fix_seams_across_objects()
fix_normals_across_objects()
+81 -21
View File
@@ -72,7 +72,7 @@ def process_batch():
# 3. Locate Reference Library
target_lib_name = f"normal_{age}_{sex}.blend"
target_lib_path = os.path.join(lib_directory, target_lib_name)
rig_name = str(sex)
rig_name = str(sex)
if not os.path.exists(target_lib_path):
if target_lib_name == "normal_adult_male.blend":
@@ -94,11 +94,48 @@ def process_batch():
source_mesh = bpy.data.objects.get(ref_mesh_name)
rig = bpy.data.objects.get(rig_name)
# ---- Detect hair with its own skeleton ----
has_own_arm = False
own_arm_name = None
own_arm = None
print(f" Checking {name} modifiers for own armature:")
for mod in clothing.modifiers:
print(f" type={mod.type} object={mod.object}")
if mod.type == 'ARMATURE':
arm = mod.object
if arm is None:
print(f" mod.object is None, trying by name...")
continue
print(f" arm.name='{arm.name}' rig_name='{rig_name}'")
if arm.name != rig_name:
has_own_arm = True
own_arm_name = arm.name
own_arm = arm
break
if has_own_arm and own_arm:
clothing["has_own_armature"] = True
clothing["own_armature_name"] = own_arm_name
# Ensure hair armature is linked to scene so parent/modifier
# references survive the save.
try:
bpy.context.collection.objects.link(own_arm)
except RuntimeError:
pass # Already linked
print(f" DETECTED own armature '{own_arm_name}' on {name}")
else:
print(f" No own armature on {name} (rig='{rig_name}')")
# -------------------------------------------
# 5. Prep Objects (Apply Scale & Clear Animation)
bpy.context.view_layer.objects.active = clothing
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
for item in [clothing, rig]:
# Don't clear animation on the hair armature if it has
# its own skeleton (we want to preserve hair animations)
items_to_clear = [clothing]
if not has_own_arm:
items_to_clear.append(rig)
for item in items_to_clear:
if item.animation_data: item.animation_data_clear()
if item.type == 'ARMATURE':
bpy.context.view_layer.objects.active = item
@@ -106,30 +143,54 @@ def process_batch():
bpy.ops.pose.transforms_clear()
bpy.ops.object.mode_set(mode='OBJECT')
# 6. Weight Transfer
clothing.vertex_groups.clear()
dt = clothing.modifiers.new(name="WT", type='DATA_TRANSFER')
dt.object = source_mesh
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.layers_vgroup_select_src = 'ALL'
dt.vert_mapping = 'POLYINTERP_NEAREST'
bpy.context.view_layer.objects.active = clothing
# "Generate Data Layers" step
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
remove_empty_vertex_groups(clothing, threshold=0.001)
# Also clear the body rig's animation (always)
if has_own_arm:
for item in [rig]:
if item.animation_data: item.animation_data_clear()
if item.type == 'ARMATURE':
bpy.context.view_layer.objects.active = item
bpy.ops.object.mode_set(mode='POSE')
bpy.ops.pose.transforms_clear()
bpy.ops.object.mode_set(mode='OBJECT')
# Set garment_id from object name if not already set
if "garment_id" not in clothing:
clothing["garment_id"] = clothing.name
# 6. Weight Transfer
if has_own_arm:
# Hair with its own skeleton already has correct vertex
# groups for its hair bones; body weight transfer would
# destroy them.
print(f"Skipped weight transfer for {name}: has own skeleton")
else:
clothing.vertex_groups.clear()
dt = clothing.modifiers.new(name="WT", type='DATA_TRANSFER')
dt.object = source_mesh
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.layers_vgroup_select_src = 'ALL'
dt.vert_mapping = 'POLYINTERP_NEAREST'
bpy.context.view_layer.objects.active = clothing
# "Generate Data Layers" step
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
remove_empty_vertex_groups(clothing, threshold=0.001)
# 7. Final Parenting
clothing.parent = rig
arm_mod = clothing.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = rig
if has_own_arm:
# Hair with its own skeleton: keep the existing Armature
# modifier (which points to the hair skeleton). Just
# parent to the hair armature; do NOT add a body
# Armature modifier.
clothing.parent = own_arm
print(f"Parented {name} to hair armature '{own_arm_name}'")
else:
clothing.parent = rig
arm_mod = clothing.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = rig
# 8. Cleanup Reference Mesh (keep the Rig!)
bpy.data.objects.remove(source_mesh, do_unlink=True)
@@ -147,4 +208,3 @@ def process_batch():
if __name__ == "__main__":
process_batch()
+160 -1
View File
@@ -248,6 +248,27 @@ for mapping in[CommandLineMapping()]:
discovered.append(ob.name)
mapping.objs += discovered
print(f"Auto-discovered {len(discovered)} body part objects: {discovered}")
# ---- Separate hair with its own skeleton ----
hair_own_skel = []
hair_own_skel_objs = [] # save object references
for name in list(mapping.objs):
obj = bpy.data.objects.get(name)
if obj:
has_it = obj.get("has_own_armature", False)
own_arm = obj.get("own_armature_name", "")
print(f" Object '{name}': has_own_armature="
f"{has_it}, own_armature_name='{own_arm}'")
if has_it:
hair_own_skel.append(name)
hair_own_skel_objs.append(obj)
mapping.objs.remove(name)
if hair_own_skel:
print(f"Hair with own skeleton (exported separately): "
f"{hair_own_skel}")
else:
print(f"No hair with own skeleton detected")
# ---------------------------------------------
else:
bpy.ops.wm.append(
filepath=os.path.join(mapping.blend_path, mapping.inner_path),
@@ -266,6 +287,8 @@ for mapping in[CommandLineMapping()]:
bpy.data.objects.remove(ob)
# Remove auto-discovered objects that weren't meant for export
elif mapping.auto_discover and ob.type == 'MESH' and ob.name not in mapping.objs:
if ob.get("has_own_armature", False):
continue # exported separately, don't rename
if not ob.name.endswith("-noimp"):
ob.name = ob.name + "-noimp"
@@ -294,11 +317,28 @@ for mapping in[CommandLineMapping()]:
bpy.data.actions.remove(act)
for obj in bpy.data.objects:
if obj.type == 'MESH':
# Skip hair-with-own-skeleton objects — they're
# exported separately and must not be renamed
if obj.get("has_own_armature", False):
continue
if not obj.name in mapping.objs and obj.parent is None:
if not obj.name.endswith("-noimp"):
obj.name = obj.name + "-noimp"
bpy.ops.wm.save_as_mainfile(filepath=(basepath + "/assets/blender/scripts/" + mapping.outfile))
# Hide hair-with-own-skeleton objects from the body glTF export.
# The glTF exporter crashes when sampling animations for hair armatures
# that are not part of the main rig, so we exclude them here and export
# hair separately via OGRE later.
for obj in hair_own_skel_objs:
obj.hide_viewport = True
obj.hide_render = True
arm_name = obj.get("own_armature_name", "")
hair_arm = bpy.data.objects.get(arm_name)
if hair_arm:
hair_arm.hide_viewport = True
hair_arm.hide_render = True
os.makedirs(os.path.dirname(mapping.gltf_path), exist_ok=True)
bpy.ops.export_scene.gltf(filepath=mapping.gltf_path,
use_selection=False,
@@ -317,7 +357,7 @@ for mapping in[CommandLineMapping()]:
use_mesh_edges=False,
use_mesh_vertices=False,
export_cameras=False,
use_visible=False,
use_visible=True,
use_renderable=False,
export_yup=True,
export_animations=True,
@@ -331,6 +371,16 @@ for mapping in[CommandLineMapping()]:
export_lights=False,
export_skins=True)
print("exported to: " + mapping.gltf_path)
# Unhide hair objects for OGRE export
for obj in hair_own_skel_objs:
obj.hide_viewport = False
obj.hide_render = False
arm_name = obj.get("own_armature_name", "")
hair_arm = bpy.data.objects.get(arm_name)
if hair_arm:
hair_arm.hide_viewport = False
hair_arm.hide_render = False
obj_names = mapping.objs
prefix = mapping.armature_name + "_"
@@ -556,6 +606,115 @@ for mapping in[CommandLineMapping()]:
else:
print(f"WARNING: fix_ogre_mesh_seams.py not found at {fix_script}")
# ---- Export hair with own skeleton ----
print(f"\n=== HAIR DEBUG: auto_discover={mapping.auto_discover} "
f"hair_own_skel={hair_own_skel}")
if mapping.auto_discover and hair_own_skel:
# Collect hair meshes grouped by their armature name
hair_by_arm = {}
for obj in hair_own_skel_objs:
arm_name = obj.get("own_armature_name", "")
print(f" HAIR DEBUG: obj '{obj.name}' "
f"own_armature_name='{arm_name}' "
f"has_own_armature={obj.get('has_own_armature', False)}")
if arm_name not in hair_by_arm:
hair_by_arm[arm_name] = []
hair_by_arm[arm_name].append(obj)
print(f" HAIR DEBUG: hair_by_arm has {len(hair_by_arm)} groups")
body_arm_name = mapping.armature_name # save "male"/"female"
body_prefix = body_arm_name + "_"
# Debug: list all armatures in the scene
all_arms = [o.name for o in bpy.data.objects
if o.type == 'ARMATURE']
print(f" HAIR DEBUG: armatures in scene: {all_arms}")
for arm_name, hair_objs in hair_by_arm.items():
hair_arm = bpy.data.objects.get(arm_name)
print(f" HAIR DEBUG: arm_name='{arm_name}' "
f"hair_arm_found={hair_arm is not None}")
if not hair_arm or hair_arm.type != 'ARMATURE':
print(f" WARNING: Armature '{arm_name}' not found "
f"for hair, skipping")
continue
hair_obj_names = [o.name for o in hair_objs]
print(f" Exporting hair with skeleton '{arm_name}': "
f"{hair_obj_names}")
# Sync armature data name
hair_arm.data.name = hair_arm.name
print(f" Armature data name: '{hair_arm.data.name}'")
# Write body_part JSON for each hair mesh
json_dir = os.path.dirname(mapping.gltf_path)
for obj in hair_objs:
new_mesh_name = body_prefix + obj.name
obj.data.name = new_mesh_name
save_data = {
"age": obj.get("age", ""),
"sex": obj.get("sex", ""),
"slot": obj.get("slot", ""),
"mesh": obj.data.name + ".mesh",
"layer": int(obj.get("layer", 0)),
"garments": [],
"tags": [],
"shape_keys": [],
"own_skeleton": True,
"attach_to_bone": "mixamorig:Head"
}
garments = obj.get("garments", "")
if garments:
save_data["garments"] = [
g.strip()
for g in str(garments).split(";")
if g.strip()]
clothing_tags = obj.get("clothing_tags", "")
if clothing_tags:
save_data["tags"] = [
t.strip()
for t in str(clothing_tags).split(";")
if t.strip()]
save_file = (json_dir + "/body_part_" +
obj.data.name + ".json")
with open(save_file, 'w') as f:
json.dump(save_data, f, indent=2)
print(f" Wrote {save_file}")
# OGRE export for hair with its own skeleton
# OGRE export for hair with its own skeleton
hair_scene = mapping.gltf_path.replace(
".glb", "_hair_" + arm_name + ".scene")
bpy.ops.ogre.export(
filepath=hair_scene,
EX_SELECTED_ONLY=False,
EX_SHARED_ARMATURE=True,
EX_LOD_GENERATION='0',
EX_LOD_DISTANCE=20,
EX_LOD_LEVELS=4,
EX_GENERATE_TANGENTS='4')
print(f" Exported hair scene: {hair_scene}")
# Fix alpha_rejection in hair .material files
_mat_files = _glob.glob(
os.path.join(ogre_export_dir, "*.material"))
for _mf in _mat_files:
with open(_mf, 'r') as _f:
_content = _f.read()
if 'alpha_rejection' in _content:
_new = _re.sub(
r'alpha_rejection (\S+) (\d+\.?\d*)',
lambda m: 'alpha_rejection ' +
m.group(1) + ' ' +
str(int(float(m.group(2)))),
_content)
if _new != _content:
with open(_mf, 'w') as _f:
_f.write(_new)
# -----------------------------------------
bpy.ops.wm.read_homefile(use_empty=True)
time.sleep(2)
bpy.ops.wm.quit_blender()
+2
View File
@@ -48,6 +48,7 @@ set(EDITSCENE_SOURCES
systems/MarkovNameGenerator.cpp
systems/PregnancySystem.cpp
systems/AnimationTreeSystem.cpp
systems/HairPhysicsSystem.cpp
systems/BehaviorTreeSystem.cpp
systems/NavMeshSystem.cpp
recast/TileCacheNavMesh.cpp
@@ -232,6 +233,7 @@ set(EDITSCENE_HEADERS
systems/CharacterRegistry.hpp
systems/PregnancySystem.hpp
systems/AnimationTreeSystem.hpp
systems/HairPhysicsSystem.hpp
systems/BehaviorTreeSystem.hpp
systems/NavMeshSystem.hpp
recast/TileCacheNavMesh.hpp
+18
View File
@@ -17,6 +17,7 @@
#include "systems/ProceduralMeshSystem.hpp"
#include "systems/CharacterSlotSystem.hpp"
#include "systems/AnimationTreeSystem.hpp"
#include "systems/HairPhysicsSystem.hpp"
#include "systems/BehaviorTreeSystem.hpp"
#include "systems/NavMeshSystem.hpp"
#include "systems/CharacterSystem.hpp"
@@ -398,6 +399,13 @@ void EditorApp::setup()
m_world, m_sceneMgr);
m_animationTreeSystem->initialize();
// Setup HairPhysics system
m_hairPhysicsSystem = std::make_unique<HairPhysicsSystem>(
m_world, m_sceneMgr,
m_physicsSystem->getPhysicsWrapper(),
m_characterSlotSystem.get());
m_hairPhysicsSystem->initialize();
// Setup Character physics system (needed by BehaviorTreeSystem)
m_characterSystem =
std::make_unique<CharacterSystem>(m_world, m_sceneMgr);
@@ -1493,10 +1501,20 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
m_buoyancySystem->update(evt.timeSinceLastFrame);
}
/* --- Hair physics root sync (before physics step) --- */
if (m_hairPhysicsSystem) {
m_hairPhysicsSystem->prePhysicsUpdate();
}
/* --- Main physics step --- */
if (m_physicsSystem) {
m_physicsSystem->update(evt.timeSinceLastFrame);
}
/* --- Hair physics pose read-back (after physics step) --- */
if (m_hairPhysicsSystem) {
m_hairPhysicsSystem->postPhysicsUpdate();
}
}
/* --- Rendering support systems --- */
+2
View File
@@ -24,6 +24,7 @@ class ProceduralMaterialSystem;
class ProceduralMeshSystem;
class CharacterSlotSystem;
class AnimationTreeSystem;
class HairPhysicsSystem;
class BehaviorTreeSystem;
class NavMeshSystem;
class CharacterSystem;
@@ -252,6 +253,7 @@ private:
std::unique_ptr<ProceduralMeshSystem> m_proceduralMeshSystem;
std::unique_ptr<CharacterSlotSystem> m_characterSlotSystem;
std::unique_ptr<AnimationTreeSystem> m_animationTreeSystem;
std::unique_ptr<HairPhysicsSystem> m_hairPhysicsSystem;
std::unique_ptr<BehaviorTreeSystem> m_behaviorTreeSystem;
std::unique_ptr<NavMeshSystem> m_navMeshSystem;
std::unique_ptr<CharacterSystem> m_characterSystem;
@@ -3,6 +3,8 @@
#pragma once
#include <Ogre.h>
#include <Jolt/Jolt.h>
#include <Jolt/Physics/Body/BodyID.h>
/**
* Character physics component
@@ -48,6 +50,39 @@ struct CharacterComponent {
float floorCheckDistance = 2.0f;
bool useGravity = true;
/* Per-character collision group. Body = subgroup 0, head = subgroup 1,
* hair joints = 2+. Runtime only — regenerated when character is rebuilt. */
uint32_t collisionGroupId = 0;
/* Separate head collider body for hair physics. Runtime only. */
JPH::BodyID headColliderBody;
/* Head sphere radius for hair physics collision. */
float headRadius = 0.13f;
/* Vertical offset from the character base to the head sphere centre.
* If zero, defaults to getTotalHeight() - headRadius. */
float headOffsetY = 0.0f;
/* Bone that drives the head collider. If empty, falls back to the
* static offset above. */
Ogre::String headBoneName = "mixamorig:Head";
/* Optional chest/upper-body collider to stop long hair from clipping
* through the torso. Runtime only. */
JPH::BodyID chestColliderBody;
/* Chest box half-extents. If any axis is zero, no chest collider is
* created. */
Ogre::Vector3 chestHalfExtents = Ogre::Vector3(0.2f, 0.12f, 0.12f);
/* Vertical offset along the world Y axis applied to the chest bone
* position when placing the chest collider. */
float chestOffsetY = 0.0f;
/* Bone that drives the chest collider. */
Ogre::String chestBoneName = "mixamorig:Spine2";
float getHalfHeight() const
{
return height * 0.5f;
@@ -5,6 +5,8 @@
#include <unordered_map>
#include <vector>
#include "AnimationTree.hpp"
/**
* Selection criteria for a single character slot.
* Layer 0 (nude base) can be selected via combo box (e.g. hair styles).
@@ -469,6 +469,18 @@ static void registerAllComponents()
lua_setfield(L, -2, "linearVelocity");
lua_pushboolean(L, c.enabled ? 1 : 0);
lua_setfield(L, -2, "enabled");
lua_pushnumber(L, c.headRadius);
lua_setfield(L, -2, "headRadius");
lua_pushnumber(L, c.headOffsetY);
lua_setfield(L, -2, "headOffsetY");
lua_pushstring(L, c.headBoneName.c_str());
lua_setfield(L, -2, "headBoneName");
pushVector3(L, c.chestHalfExtents);
lua_setfield(L, -2, "chestHalfExtents");
lua_pushnumber(L, c.chestOffsetY);
lua_setfield(L, -2, "chestOffsetY");
lua_pushstring(L, c.chestBoneName.c_str());
lua_setfield(L, -2, "chestBoneName");
lua_pushboolean(L, c.useGravity ? 1 : 0);
lua_setfield(L, -2, "useGravity");
, if (lua_getfield(L, idx, "radius"), lua_isnumber(L, -1))
@@ -486,6 +498,24 @@ static void registerAllComponents()
if (lua_getfield(L, idx, "enabled"), lua_isboolean(L, -1))
c.enabled = lua_toboolean(L, -1) != 0;
lua_pop(L, 1);
if (lua_getfield(L, idx, "headRadius"), lua_isnumber(L, -1))
c.headRadius = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "headOffsetY"), lua_isnumber(L, -1))
c.headOffsetY = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "headBoneName"), lua_isstring(L, -1))
c.headBoneName = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "chestHalfExtents"), lua_istable(L, -1))
c.chestHalfExtents = readVector3(L, lua_gettop(L));
lua_pop(L, 1);
if (lua_getfield(L, idx, "chestOffsetY"), lua_isnumber(L, -1))
c.chestOffsetY = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "chestBoneName"), lua_isstring(L, -1))
c.chestBoneName = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "useGravity"), lua_isboolean(L, -1))
c.useGravity = lua_toboolean(L, -1) != 0;
lua_pop(L, 1););
+73 -24
View File
@@ -86,13 +86,19 @@ public:
{
switch (inObject1) {
case Layers::NON_MOVING:
return inObject2 ==
Layers::MOVING; // Non moving only collides with moving
return inObject2 == Layers::MOVING ||
inObject2 == Layers::HAIR;
case Layers::MOVING:
return true; // Moving collides with everything
return inObject2 != Layers::HEAD;
case Layers::SENSORS:
return inObject2 ==
Layers::MOVING; // Non moving only collides with moving
return inObject2 == Layers::MOVING;
case Layers::HAIR:
return inObject2 == Layers::NON_MOVING ||
inObject2 == Layers::MOVING ||
inObject2 == Layers::HAIR ||
inObject2 == Layers::HEAD;
case Layers::HEAD:
return inObject2 == Layers::HAIR;
default:
JPH_ASSERT(false);
return false;
@@ -111,6 +117,8 @@ public:
BroadPhaseLayers::NON_MOVING;
mObjectToBroadPhase[Layers::MOVING] = BroadPhaseLayers::MOVING;
mObjectToBroadPhase[Layers::SENSORS] = BroadPhaseLayers::MOVING;
mObjectToBroadPhase[Layers::HAIR] = BroadPhaseLayers::MOVING;
mObjectToBroadPhase[Layers::HEAD] = BroadPhaseLayers::MOVING;
}
virtual uint GetNumBroadPhaseLayers() const override
@@ -157,6 +165,13 @@ public:
return inLayer2 == BroadPhaseLayers::MOVING;
case Layers::MOVING:
return true;
case Layers::SENSORS:
return inLayer2 == BroadPhaseLayers::MOVING;
case Layers::HAIR:
return inLayer2 == BroadPhaseLayers::NON_MOVING ||
inLayer2 == BroadPhaseLayers::MOVING;
case Layers::HEAD:
return inLayer2 == BroadPhaseLayers::MOVING;
default:
JPH_ASSERT(false);
return false;
@@ -281,31 +296,15 @@ public:
JPH::RVec3Arg inV3, JPH::ColorArg inColor,
ECastShadow inCastShadow = ECastShadow::Off) override
{
Ogre::Vector3 d = mCameraNode->_getDerivedOrientation() *
Ogre::Vector3(0, 0, -1);
JPH::Vec4 color = inColor.ToVec4();
Ogre::Vector3 p1 = JoltPhysics::convert(inV1);
Ogre::Vector3 p2 = JoltPhysics::convert(inV2);
Ogre::Vector3 p3 = JoltPhysics::convert(inV3);
Ogre::ColourValue cv(color[0], color[1], color[2], color[3]);
#if 0
float dproj1 = p1.dotProduct(d);
float dproj2 = p2.dotProduct(d);
float dproj3 = p3.dotProduct(d);
if (dproj1 < 0 && dproj2 < 0 && dproj3 < 0)
return;
if (dproj1 > 50 && dproj2 > 50 && dproj3 > 50)
return;
#endif
mLines.push_back({ p1, p2, cv });
#if 0
mTriangles.push_back({ { { inV1[0], inV1[1], inV1[2] },
{ inV2[0], inV2[1], inV2[2] },
{ inV3[0], inV3[1], inV3[2] } },
Ogre::ColourValue(color[0], color[1],
color[2], color[3]) });
#endif
mLines.push_back({ p2, p3, cv });
mLines.push_back({ p3, p1, cv });
}
#if 0
Batch CreateTriangleBatch(const Triangle *inTriangles,
@@ -339,6 +338,11 @@ public:
std::cout << "geometry\n";
}
#endif
void updateCameraPos()
{
SetCameraPos(JoltPhysics::convert(
mCameraNode->_getDerivedPosition()));
}
void finish()
{
Ogre::Vector3 d = mCameraNode->_getDerivedOrientation() *
@@ -561,6 +565,7 @@ class Physics {
std::set<JPH::BodyID> characterBodies;
bool debugDraw;
JPH::Vec3 gravity = JPH::Vec3(0.0f, -9.8f, 0.0f);
std::unordered_map<uint32_t, JPH::Ref<JPH::GroupFilterTable> > groupFilters;
public:
class ActivationListener : public JPH::BodyActivationListener {
@@ -570,6 +575,32 @@ public:
virtual void OnBodyDeactivated(const JPH::BodyID &inBodyID,
JPH::uint64 inBodyUserData) = 0;
};
JPH::PhysicsSystem *getPhysicsSystem()
{
return &physics_system;
}
JPH::GroupFilterTable *getOrCreateGroupFilter(uint32_t groupId,
uint32_t numSubGroups)
{
auto it = groupFilters.find(groupId);
if (it != groupFilters.end())
return it->second.GetPtr();
JPH::Ref<JPH::GroupFilterTable> filter =
new JPH::GroupFilterTable(numSubGroups);
groupFilters[groupId] = filter;
return filter.GetPtr();
}
JPH::GroupFilterTable *getGroupFilter(uint32_t groupId) const
{
auto it = groupFilters.find(groupId);
if (it != groupFilters.end())
return it->second.GetPtr();
return nullptr;
}
Physics(Ogre::SceneManager *scnMgr, Ogre::SceneNode *cameraNode,
ActivationListener *activationListener = nullptr,
JPH::ContactListener *contactListener = nullptr)
@@ -813,10 +844,12 @@ public:
ch->GetPosition()));
}
}
if (debugDraw)
if (debugDraw) {
mDebugRenderer->updateCameraPos();
physics_system.DrawBodies(
JPH::BodyManager::DrawSettings(),
mDebugRenderer);
}
mDebugRenderer->finish();
mDebugRenderer->NextFrame();
#if 0
@@ -2000,5 +2033,21 @@ void JoltPhysicsWrapper::setRootMotionCharacter(JPH::BodyID id, bool enabled)
phys->setRootMotionCharacter(id, enabled);
}
JPH::PhysicsSystem *JoltPhysicsWrapper::getPhysicsSystem() const
{
return phys->getPhysicsSystem();
}
JPH::GroupFilterTable *JoltPhysicsWrapper::getOrCreateGroupFilter(
uint32_t groupId, uint32_t numSubGroups)
{
return phys->getOrCreateGroupFilter(groupId, numSubGroups);
}
JPH::GroupFilterTable *JoltPhysicsWrapper::getGroupFilter(uint32_t groupId) const
{
return phys->getGroupFilter(groupId);
}
template <>
JoltPhysicsWrapper *Ogre::Singleton<JoltPhysicsWrapper>::msSingleton = 0;
+18 -1
View File
@@ -10,6 +10,7 @@
#include <Jolt/Physics/Collision/BroadPhase/BroadPhaseLayer.h>
#include <Jolt/Physics/Body/BodyCreationSettings.h>
#include <Jolt/Physics/EActivation.h>
#include <Jolt/Physics/Collision/GroupFilterTable.h>
void physics();
namespace JPH
{
@@ -18,6 +19,7 @@ class Character;
class ContactManifold;
class ContactSettings;
class SubShapeIDPair;
class PhysicsSystem;
}
// Layer that objects can be in, determines which other objects it can collide with
// Typically you at least want to have 1 layer for moving bodies and 1 layer for static bodies, but you can have more
@@ -28,7 +30,13 @@ namespace Layers
static constexpr JPH::ObjectLayer NON_MOVING = 0;
static constexpr JPH::ObjectLayer MOVING = 1;
static constexpr JPH::ObjectLayer SENSORS = 2;
static constexpr JPH::ObjectLayer NUM_LAYERS = 3;
static constexpr JPH::ObjectLayer HAIR = 3;
static constexpr JPH::ObjectLayer HEAD = 4;
static constexpr JPH::ObjectLayer NUM_LAYERS = 5;
/* Max subgroups per character collision group. Body = 0, head = 1,
* hair joints = 2..MAX_SUBGROUPS-1. */
static constexpr uint32_t MAX_CHARACTER_SUBGROUPS = 256;
};
// Each broadphase layer results in a separate bounding volume tree in the broad phase. You at least want to have
@@ -237,5 +245,14 @@ public:
* because the scene node position is driven by root motion
* from AnimationTreeSystem. */
void setRootMotionCharacter(JPH::BodyID id, bool enabled);
JPH::PhysicsSystem *getPhysicsSystem() const;
/* Shared group filters for per-character collision filtering.
* Body = subgroup 0, head = subgroup 1, chest = subgroup 2,
* hair joints = 3+. */
JPH::GroupFilterTable *getOrCreateGroupFilter(
uint32_t groupId,
uint32_t numSubGroups = Layers::MAX_CHARACTER_SUBGROUPS);
JPH::GroupFilterTable *getGroupFilter(uint32_t groupId) const;
};
#endif
@@ -16,10 +16,13 @@ set(RECASTNAVIGATION_DEMO OFF CACHE BOOL "" FORCE)
set(RECASTNAVIGATION_TESTS OFF CACHE BOOL "" FORCE)
set(RECASTNAVIGATION_EXAMPLES OFF CACHE BOOL "" FORCE)
set(RECASTNAVIGATION_ENABLE_ASSERTS "$<CONFIG:Debug>" CACHE STRING "" FORCE)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(CMAKE_POSITION_INDEPENDENT_CODE ON CACHE BOOL "" FORCE)
# Build the core libraries only
add_subdirectory(Recast)
add_subdirectory(Detour)
add_subdirectory(DetourTileCache)
add_subdirectory(DetourCrowd)
add_subdirectory(DebugUtils)
add_subdirectory(Recast EXCLUDE_FROM_ALL)
add_subdirectory(Detour EXCLUDE_FROM_ALL)
add_subdirectory(DetourTileCache EXCLUDE_FROM_ALL)
add_subdirectory(DetourCrowd EXCLUDE_FROM_ALL)
add_subdirectory(DebugUtils EXCLUDE_FROM_ALL)
@@ -549,6 +549,7 @@ void AnimationTreeSystem::update(float deltaTime)
/* Handle end-of-animation transitions */
checkEndTransitions(e, at, state, ctx);
});
}
void AnimationTreeSystem::evaluateNode(const AnimationTreeNode &node,
@@ -817,3 +818,5 @@ AnimationTreeSystem::findAnimationNode(const AnimationTreeNode &stateNode) const
}
return nullptr;
}
@@ -11,6 +11,7 @@
#include "../components/AnimationTree.hpp"
#include "../components/AnimationTreeTemplate.hpp"
/**
* System that evaluates an AnimationTreeComponent each frame.
*
@@ -504,13 +504,13 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
}
}
/* Populate default slots from catalog if still empty */
if (cs.slotSelections.empty()) {
auto slots = getSlots(age, cs.sex);
for (const auto &slot : slots) {
SlotSelection sel;
cs.slotSelections[slot] = sel;
}
/* Make sure every catalog slot exists in the selection map. This
* guarantees a face/body master is available so that hair with its
* own skeleton can attach to a real head bone. */
auto slots = getSlots(age, cs.sex);
for (const auto &slot : slots) {
if (cs.slotSelections.find(slot) == cs.slotSelections.end())
cs.slotSelections[slot] = SlotSelection();
}
if (!e.has<TransformComponent>())
@@ -602,9 +602,6 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
if (mesh.empty())
continue;
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] slot='" + slot +
try {
ensureMeshPoseAnimation(mesh);
Ogre::MeshPtr partMesh =
@@ -612,19 +609,47 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
mesh, "Characters");
Ogre::Entity *partEnt =
m_sceneMgr->createEntity(partMesh);
partEnt->shareSkeletonInstanceWith(masterEnt);
transform.node->attachObject(partEnt);
m_entities[e.id()].parts[slot] = partEnt;
/* Check if this mesh has its own skeleton
* (e.g. hair exported with a hair-specific
* armature). If so, attach to the master's
* Head bone instead of sharing skeletons. */
const nlohmann::json *entry =
findCatalogEntry(age, cs.sex, slot, mesh);
bool ownSkeleton = false;
Ogre::String attachBone = "mixamorig:Head";
if (entry) {
ownSkeleton = entry->value(
"own_skeleton", false);
attachBone = entry->value(
"attach_to_bone", attachBone);
}
if (ownSkeleton) {
/* Hair with its own skeleton: attach
* to the master entity's Head bone
* so it follows head movement while
* being animated by its own skeleton.
*/
masterEnt->attachObjectToBone(
attachBone, partEnt);
Ogre::LogManager::getSingleton()
.logMessage(
"[CharacterSlotSystem] "
"Attached hair '" +
slot +
"' with own skeleton to "
"bone '" +
attachBone + "'");
} else {
partEnt->shareSkeletonInstanceWith(
masterEnt);
transform.node->attachObject(partEnt);
}
m_entities[e.id()].parts[slot] = partEnt;
applyShapeKeys(e, partEnt, entry);
/* Re-prepare temp buffers after skeleton sharing and
* enabling vertex animation. shareSkeletonInstanceWith
* replaces the part's AnimationStateSet but does not
* recreate temp blend buffers, which are needed for
* proper per-entity pose animation.
*/
prepareEntityTempBlendBuffers(partEnt);
} catch (const Ogre::Exception &ex) {
@@ -1,8 +1,49 @@
#include "CharacterSystem.hpp"
#include "../components/CharacterSlots.hpp"
#include <Jolt/Physics/Character/Character.h>
#include <Jolt/Physics/Collision/GroupFilterTable.h>
#include <Jolt/Physics/Body/BodyInterface.h>
#include <Jolt/Physics/PhysicsSystem.h>
#include <OgreLogManager.h>
#include <OgreEntity.h>
#include <OgreSkeletonInstance.h>
#include <iostream>
static Ogre::Entity *getMasterEntity(flecs::entity e)
{
if (!e.is_alive() || !e.has<CharacterSlotsComponent>())
return nullptr;
const CharacterSlotsComponent &cs = e.get<CharacterSlotsComponent>();
return cs.masterEntity;
}
static bool getBoneWorldMatrix(Ogre::Entity *masterEnt,
const Ogre::String &boneName,
Ogre::Matrix4 &outMat)
{
if (!masterEnt || boneName.empty() || !masterEnt->hasSkeleton())
return false;
Ogre::SkeletonInstance *skel = masterEnt->getSkeleton();
if (!skel)
return false;
Ogre::Bone *bone = nullptr;
try {
bone = skel->getBone(boneName);
} catch (...) {
return false;
}
if (!bone)
return false;
outMat = bone->_getFullTransform();
Ogre::SceneNode *node = masterEnt->getParentSceneNode();
if (node)
outMat = node->_getFullTransform() * outMat;
return true;
}
CharacterSystem::CharacterSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr)
: m_world(world)
@@ -136,6 +177,116 @@ JPH::ShapeRefC CharacterSystem::buildCharacterShape(flecs::entity e,
rotations);
}
void CharacterSystem::createHeadCollider(flecs::entity e,
CharacterComponent &cc,
const TransformComponent &transform)
{
if (!m_physics)
return;
if (cc.headColliderBody.IsInvalid() == false) {
m_physics->removeBody(cc.headColliderBody);
m_physics->destroyBody(cc.headColliderBody);
cc.headColliderBody = JPH::BodyID();
}
if (cc.collisionGroupId == 0)
return;
float headRadius = cc.headRadius > 0.0f ? cc.headRadius : 0.13f;
Ogre::Matrix4 headWorld;
bool hasBone = getBoneWorldMatrix(getMasterEntity(e), cc.headBoneName,
headWorld);
if (hasBone) {
headWorld.setTrans(headWorld.getTrans() +
Ogre::Vector3(0.0f, cc.headOffsetY, 0.0f));
} else {
float headOffsetY = cc.headOffsetY > 0.0f ?
cc.headOffsetY :
cc.getTotalHeight() - headRadius;
Ogre::Vector3 headOffset(0.0f, headOffsetY, 0.0f);
headOffset += cc.offset;
Ogre::Vector3 headPos =
transform.node->_getDerivedPosition() +
transform.node->_getDerivedOrientation() *
Ogre::Vector3(cc.offset.x, 0.0f, cc.offset.z) +
Ogre::Vector3(0.0f, headOffsetY + cc.offset.y, 0.0f);
Ogre::Matrix4 local = Ogre::Matrix4::IDENTITY;
local.makeTransform(headPos, Ogre::Vector3::UNIT_SCALE,
Ogre::Quaternion::IDENTITY);
headWorld = local;
}
JPH::ShapeRefC sphere = m_physics->createSphereShape(headRadius);
cc.headColliderBody = m_physics->createBody(
sphere, 0.0f, headWorld.getTrans(),
Ogre::Quaternion::IDENTITY,
JPH::EMotionType::Kinematic, Layers::HEAD);
JPH::PhysicsSystem *physSystem = m_physics->getPhysicsSystem();
if (physSystem) {
JPH::BodyInterface &bi = physSystem->GetBodyInterface();
JPH::GroupFilterTable *filter =
m_physics->getOrCreateGroupFilter(
cc.collisionGroupId);
bi.SetCollisionGroup(cc.headColliderBody,
JPH::CollisionGroup(filter, cc.collisionGroupId,
1));
}
m_physics->addBody(cc.headColliderBody,
JPH::EActivation::Activate);
}
void CharacterSystem::createChestCollider(flecs::entity e,
CharacterComponent &cc)
{
if (!m_physics)
return;
if (cc.chestColliderBody.IsInvalid() == false) {
m_physics->removeBody(cc.chestColliderBody);
m_physics->destroyBody(cc.chestColliderBody);
cc.chestColliderBody = JPH::BodyID();
}
if (cc.collisionGroupId == 0 ||
cc.chestHalfExtents.x <= 0.0f ||
cc.chestHalfExtents.y <= 0.0f ||
cc.chestHalfExtents.z <= 0.0f)
return;
Ogre::Matrix4 chestWorld;
if (!getBoneWorldMatrix(getMasterEntity(e), cc.chestBoneName,
chestWorld))
return;
Ogre::Vector3 chestPos = chestWorld.getTrans() +
Ogre::Vector3(0.0f, cc.chestOffsetY, 0.0f);
Ogre::Matrix3 chestRotMat = chestWorld.linear().orthonormalised();
Ogre::Quaternion chestRot = Ogre::Quaternion(chestRotMat);
JPH::ShapeRefC box = m_physics->createBoxShape(cc.chestHalfExtents);
cc.chestColliderBody = m_physics->createBody(
box, 0.0f, chestPos, chestRot,
JPH::EMotionType::Kinematic, Layers::HEAD);
JPH::PhysicsSystem *physSystem = m_physics->getPhysicsSystem();
if (physSystem) {
JPH::BodyInterface &bi = physSystem->GetBodyInterface();
JPH::GroupFilterTable *filter =
m_physics->getOrCreateGroupFilter(
cc.collisionGroupId);
bi.SetCollisionGroup(cc.chestColliderBody,
JPH::CollisionGroup(filter, cc.collisionGroupId,
2));
}
m_physics->addBody(cc.chestColliderBody,
JPH::EActivation::Activate);
}
void CharacterSystem::setupEntity(flecs::entity e, CharacterComponent &cc)
{
if (!m_physics)
@@ -153,13 +304,37 @@ void CharacterSystem::setupEntity(flecs::entity e, CharacterComponent &cc)
if (!shape)
return;
/* Assign a non-zero collision group for per-character filtering
* (body = subgroup 0, head = subgroup 1, chest = subgroup 2,
* hair joints = 3+). */
if (cc.collisionGroupId == 0)
cc.collisionGroupId = static_cast<uint32_t>(e.id()) |
0x80000000;
JPH::CharacterBase *base =
m_physics->createCharacter(transform.node, shape);
if (!base)
return;
auto *ch = static_cast<JPH::Character *>(base);
/* Set collision group on character body so hair filtering works. */
JPH::PhysicsSystem *physSystem = m_physics->getPhysicsSystem();
if (physSystem) {
JPH::BodyInterface &bi = physSystem->GetBodyInterface();
JPH::GroupFilterTable *filter =
m_physics->getOrCreateGroupFilter(
cc.collisionGroupId);
bi.SetCollisionGroup(
ch->GetBodyID(),
JPH::CollisionGroup(filter, cc.collisionGroupId,
0));
}
ch->AddToPhysicsSystem();
createHeadCollider(e, cc, transform);
createChestCollider(e, cc);
// Don't modify gravity factor - let buoyancy handle floating/sinking
// m_physics->setGravityFactor(ch->GetBodyID(), 0.0f);
cc.hasFloor = false;
@@ -194,6 +369,20 @@ void CharacterSystem::teardownEntity(flecs::entity e)
std::shared_ptr<JPH::Character>(state.character));
}
if (e.has<CharacterComponent>()) {
auto &cc = e.get_mut<CharacterComponent>();
if (cc.headColliderBody.IsInvalid() == false) {
m_physics->removeBody(cc.headColliderBody);
m_physics->destroyBody(cc.headColliderBody);
cc.headColliderBody = JPH::BodyID();
}
if (cc.chestColliderBody.IsInvalid() == false) {
m_physics->removeBody(cc.chestColliderBody);
m_physics->destroyBody(cc.chestColliderBody);
cc.chestColliderBody = JPH::BodyID();
}
}
m_states.erase(it);
}
@@ -353,5 +542,58 @@ void CharacterSystem::update(float deltaTime)
/* Sync rotation from scene node */
state.character->SetRotation(JoltPhysics::convert(
state.sceneNode->_getDerivedOrientation()));
/* Sync head and chest colliders to animated bones. */
Ogre::Entity *masterEnt = getMasterEntity(e);
Ogre::Matrix4 boneWorld;
if (cc.headColliderBody.IsInvalid() == false &&
transform.node) {
float headRadius = cc.headRadius > 0.0f ?
cc.headRadius :
0.13f;
float headOffsetY = cc.headOffsetY > 0.0f ?
cc.headOffsetY :
cc.getTotalHeight() - headRadius;
Ogre::Vector3 headPos;
if (getBoneWorldMatrix(masterEnt, cc.headBoneName,
boneWorld)) {
headPos = boneWorld.getTrans() +
Ogre::Vector3(0.0f,
cc.headOffsetY,
0.0f);
} else {
headPos =
transform.node->_getDerivedPosition() +
transform.node->_getDerivedOrientation() *
Ogre::Vector3(cc.offset.x,
0.0f,
cc.offset.z) +
Ogre::Vector3(0.0f,
headOffsetY +
cc.offset.y,
0.0f);
}
m_physics->setPositionAndRotation(
cc.headColliderBody, headPos,
Ogre::Quaternion::IDENTITY, true);
}
if (cc.chestColliderBody.IsInvalid() == false &&
getBoneWorldMatrix(masterEnt, cc.chestBoneName,
boneWorld)) {
Ogre::Vector3 chestPos =
boneWorld.getTrans() +
Ogre::Vector3(0.0f, cc.chestOffsetY,
0.0f);
Ogre::Matrix3 chestRotMat =
boneWorld.linear().orthonormalised();
Ogre::Quaternion chestRot =
Ogre::Quaternion(chestRotMat);
m_physics->setPositionAndRotation(
cc.chestColliderBody, chestPos,
chestRot, true);
}
});
}
@@ -55,6 +55,9 @@ private:
JPH::ShapeRefC buildCharacterShape(flecs::entity e,
CharacterComponent &cc);
JPH::ShapeRefC createColliderShape(PhysicsColliderComponent &collider);
void createHeadCollider(flecs::entity e, CharacterComponent &cc,
const TransformComponent &transform);
void createChestCollider(flecs::entity e, CharacterComponent &cc);
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
@@ -0,0 +1,587 @@
#include "HairPhysicsSystem.hpp"
#include "CharacterSlotSystem.hpp"
#include "../components/CharacterSlots.hpp"
#include "../components/Character.hpp"
#include <OgreEntity.h>
#include <OgreLogManager.h>
#include <OgreSceneNode.h>
#include <OgreTagPoint.h>
#include <queue>
namespace {
/**
* Find the master bone the hair is attached to.
* Prefer the TagPoint's parent bone; fall back to the named head bone.
*/
Ogre::Bone *findAttachBone(Ogre::Entity *hairEnt,
Ogre::Entity *masterEnt)
{
Ogre::TagPoint *tagPoint = dynamic_cast<Ogre::TagPoint *>(
hairEnt->getParentNode());
if (tagPoint) {
Ogre::Bone *parentBone = dynamic_cast<Ogre::Bone *>(
tagPoint->getParent());
if (parentBone)
return parentBone;
}
if (masterEnt && masterEnt->hasSkeleton()) {
Ogre::SkeletonInstance *skel = masterEnt->getSkeleton();
if (skel) {
try {
return skel->getBone("mixamorig:Head");
} catch (...) {
}
}
}
return nullptr;
}
/**
* World transform of the attachment bone, including the master entity's
* scene-node transform.
*/
Ogre::Matrix4 getAttachBoneWorldMatrix(Ogre::Entity *masterEnt,
Ogre::Bone *attachBone)
{
if (!attachBone)
return Ogre::Matrix4::IDENTITY;
Ogre::Matrix4 boneWorld = attachBone->_getFullTransform();
if (masterEnt) {
Ogre::SceneNode *node = masterEnt->getParentSceneNode();
if (node)
boneWorld = node->_getFullTransform() * boneWorld;
}
return boneWorld;
}
} // namespace
#include <Jolt/Physics/Ragdoll/Ragdoll.h>
#include <Jolt/Physics/Collision/Shape/CapsuleShape.h>
#include <Jolt/Physics/Constraints/SwingTwistConstraint.h>
#include <Jolt/Skeleton/SkeletonPose.h>
uint32_t HairPhysicsSystem::s_nextCollisionGroup = 1;
HairPhysicsSystem::HairPhysicsSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr,
JoltPhysicsWrapper *physics,
CharacterSlotSystem *slotSystem)
: m_world(world)
, m_sceneMgr(sceneMgr)
, m_physics(physics)
, m_slotSystem(slotSystem)
{
}
HairPhysicsSystem::~HairPhysicsSystem()
{
for (auto &entityPair : m_states) {
for (auto &slotPair : entityPair.second) {
destroyRagdoll(slotPair.second);
}
}
m_states.clear();
}
void HairPhysicsSystem::initialize()
{
m_initialized = true;
}
void HairPhysicsSystem::prePhysicsUpdate()
{
if (!m_initialized || !m_physics)
return;
m_world.query<CharacterSlotsComponent>().each(
[this](flecs::entity e, CharacterSlotsComponent &cs) {
Ogre::Entity *masterEnt = cs.masterEntity;
for (const auto &pair : cs.slotSelections) {
const Ogre::String &slot = pair.first;
Ogre::Entity *partEnt =
m_slotSystem->getSlotEntity(e, slot);
if (!partEnt || !partEnt->hasSkeleton())
continue;
auto &slotMap = m_states[e.id()];
auto it = slotMap.find(slot);
if (it == slotMap.end() ||
it->second.hairEntity != partEnt) {
/* Need to create or recreate ragdoll */
if (it != slotMap.end())
destroyRagdoll(it->second);
createRagdoll(e, slot, partEnt,
masterEnt);
it = slotMap.find(slot);
}
if (it != slotMap.end())
syncRootToHead(it->second);
}
/* Clean up ragdolls for slots that no longer have
* skeleton parts. */
auto &slotMap = m_states[e.id()];
std::vector<Ogre::String> toRemove;
for (auto &pair : slotMap) {
Ogre::Entity *partEnt =
m_slotSystem->getSlotEntity(e,
pair.first);
if (!partEnt || !partEnt->hasSkeleton()) {
toRemove.push_back(pair.first);
continue;
}
}
for (const auto &slot : toRemove) {
destroyRagdoll(slotMap[slot]);
slotMap.erase(slot);
}
});
}
void HairPhysicsSystem::postPhysicsUpdate()
{
if (!m_initialized || !m_physics)
return;
for (auto &entityPair : m_states) {
for (auto &slotPair : entityPair.second) {
syncPhysicsToSkeleton(slotPair.second);
}
}
}
void HairPhysicsSystem::createRagdoll(flecs::entity e,
const Ogre::String &slot,
Ogre::Entity *hairEnt,
Ogre::Entity *masterEnt)
{
Ogre::SkeletonInstance *skel = hairEnt->getSkeleton();
if (!skel)
return;
/* Build ordered bone list via BFS so parents always precede children. */
std::vector<Ogre::Bone *> orderedBones;
std::unordered_map<Ogre::Bone *, size_t> boneToIndex;
std::queue<Ogre::Bone *> bfsQueue;
for (unsigned short i = 0; i < skel->getNumBones(); ++i) {
Ogre::Bone *b = skel->getBone(i);
if (!b->getParent()) {
bfsQueue.push(b);
}
}
while (!bfsQueue.empty()) {
Ogre::Bone *b = bfsQueue.front();
bfsQueue.pop();
boneToIndex[b] = orderedBones.size();
orderedBones.push_back(b);
for (unsigned short i = 0; i < b->numChildren(); ++i) {
Ogre::Bone *child = static_cast<Ogre::Bone *>(b->getChild(i));
if (child)
bfsQueue.push(child);
}
}
if (orderedBones.empty())
return;
/* Sanity check: if the skeleton has too many bones, it's probably
* a shared body skeleton rather than a hair-specific skeleton.
* Skip ragdoll creation to avoid physics cache overflow. */
constexpr size_t maxHairBones = 32;
if (orderedBones.size() > maxHairBones)
return;
/* Build JPH skeleton and bind-world matrices. */
JPH::Ref<JPH::Skeleton> skeleton = new JPH::Skeleton;
std::vector<JPH::Mat44> bindWorldMatrices;
bindWorldMatrices.resize(orderedBones.size());
for (size_t i = 0; i < orderedBones.size(); ++i) {
Ogre::Bone *bone = orderedBones[i];
Ogre::Bone *parent = static_cast<Ogre::Bone *>(bone->getParent());
int parentIndex = -1;
if (parent) {
auto it = boneToIndex.find(parent);
if (it != boneToIndex.end())
parentIndex = (int)it->second;
}
skeleton->AddJoint(bone->getName().c_str(), parentIndex);
/* Build bind-world matrix from initial pose. */
Ogre::Matrix4 localMat = Ogre::Matrix4::IDENTITY;
localMat.makeTransform(
bone->getInitialPosition(),
bone->getInitialScale(),
bone->getInitialOrientation());
if (parent) {
bindWorldMatrices[i] =
bindWorldMatrices[boneToIndex[parent]] *
ogreMatrixToJolt(localMat);
} else {
bindWorldMatrices[i] = ogreMatrixToJolt(localMat);
}
}
/* World transform of the attachment bone (head) on the master entity. */
Ogre::Bone *attachBone = findAttachBone(hairEnt, masterEnt);
if (!attachBone) {
Ogre::LogManager::getSingleton().logMessage(
"HairPhysicsSystem: cannot find attachment bone for " +
slot + ", skipping ragdoll");
return;
}
Ogre::Matrix4 attachBoneMat = getAttachBoneWorldMatrix(masterEnt,
attachBone);
JPH::Mat44 attachBoneJolt = ogreMatrixToJolt(attachBoneMat);
/* Build ragdoll settings. */
JPH::Ref<JPH::RagdollSettings> settings = new JPH::RagdollSettings;
settings->mSkeleton = skeleton;
settings->mParts.resize(orderedBones.size());
std::vector<int> parentIndices;
parentIndices.resize(orderedBones.size(), -1);
for (size_t i = 0; i < orderedBones.size(); ++i) {
Ogre::Bone *bone = orderedBones[i];
JPH::RagdollSettings::Part &part = settings->mParts[i];
/* Capsule size heuristic from average child distance. */
float halfHeight = 0.05f;
float radius = 0.02f;
if (bone->numChildren() > 0) {
float avgLen = 0.0f;
int count = 0;
for (unsigned short ci = 0; ci < bone->numChildren();
++ci) {
Ogre::Bone *child = static_cast<Ogre::Bone *>(
bone->getChild(ci));
if (!child)
continue;
Ogre::Vector3 diff =
child->getInitialPosition();
avgLen += diff.length();
++count;
}
if (count > 0) {
avgLen /= count;
halfHeight = avgLen * 0.5f;
radius = avgLen * 0.15f;
}
}
/* Clamp to reasonable bounds. */
if (halfHeight < 0.01f)
halfHeight = 0.01f;
if (radius < 0.005f)
radius = 0.005f;
if (radius > halfHeight * 0.9f)
radius = halfHeight * 0.9f;
part.SetShape(new JPH::CapsuleShape(halfHeight, radius));
/* World-space bind position. */
JPH::Mat44 worldMat = attachBoneJolt * bindWorldMatrices[i];
part.mPosition = JPH::RVec3(worldMat.GetTranslation());
part.mRotation = worldMat.GetRotationSafe().GetQuaternion().Normalized();
part.mMotionType = (i == 0) ?
JPH::EMotionType::Kinematic :
JPH::EMotionType::Dynamic;
part.mLinearDamping = 0.5f;
part.mAngularDamping = 0.5f;
part.mGravityFactor = 0.5f;
part.mMaxLinearVelocity = 5.0f;
part.mMaxAngularVelocity = JPH::DegreesToRadians(720.0f);
Ogre::Bone *parent = static_cast<Ogre::Bone *>(bone->getParent());
if (parent) {
auto it = boneToIndex.find(parent);
if (it != boneToIndex.end())
parentIndices[i] = (int)it->second;
}
if (i > 0 && parentIndices[i] >= 0) {
JPH::SwingTwistConstraintSettings *constraint =
new JPH::SwingTwistConstraintSettings;
constraint->mSpace = JPH::EConstraintSpace::LocalToBodyCOM;
/* Position in parent local space (bone's initial pos). */
constraint->mPosition1 = JPH::RVec3(
JoltPhysics::convert(
bone->getInitialPosition()));
constraint->mPosition2 = JPH::RVec3::sZero();
constraint->mTwistAxis2 = JPH::Vec3::sAxisY();
JPH::Quat childLocalRot = JoltPhysics::convert(
bone->getInitialOrientation());
constraint->mTwistAxis1 = childLocalRot *
constraint->mTwistAxis2;
JPH::Vec3 planeAxis1 = childLocalRot *
JPH::Vec3::sAxisX();
constraint->mPlaneAxis1 = planeAxis1;
constraint->mPlaneAxis2 = JPH::Vec3::sAxisX();
constraint->mTwistMinAngle = -JPH::DegreesToRadians(
45.0f);
constraint->mTwistMaxAngle = JPH::DegreesToRadians(
45.0f);
constraint->mNormalHalfConeAngle =
JPH::DegreesToRadians(30.0f);
constraint->mPlaneHalfConeAngle =
JPH::DegreesToRadians(30.0f);
part.mToParent = constraint;
}
}
/* Collision group setup.
* Subgroup 0 = character capsule, 1 = head sphere, 2 = chest sphere,
* 3+ = hair joints. */
uint32_t collisionGroupId;
constexpr uint32_t subgroupHairStart = 3;
JPH::GroupFilterTable *groupFilter = nullptr;
if (e.has<CharacterComponent>()) {
auto &cc = e.get_mut<CharacterComponent>();
if (cc.collisionGroupId == 0)
cc.collisionGroupId =
static_cast<uint32_t>(e.id()) |
0x80000000;
collisionGroupId = cc.collisionGroupId;
groupFilter = m_physics->getOrCreateGroupFilter(
collisionGroupId);
/* Disable body (subgroup 0), head (1) and chest (2) vs every
* hair joint. These kinematic spheres sit at the attachment
* points; overlap with the hair chain creates an explosive
* feedback loop that throws the joints across the map. */
for (size_t i = 0; i < orderedBones.size(); ++i) {
uint32_t hairSub = subgroupHairStart + (uint32_t)i;
groupFilter->DisableCollision(0, hairSub);
groupFilter->DisableCollision(1, hairSub);
groupFilter->DisableCollision(2, hairSub);
}
/* Disable all hair-joint vs hair-joint collisions. Siblings
* and parents/children start close together and can otherwise
* generate explosive contact impulses. */
for (size_t i = 0; i < orderedBones.size(); ++i) {
for (size_t j = i + 1; j < orderedBones.size(); ++j) {
groupFilter->DisableCollision(
subgroupHairStart + (uint32_t)i,
subgroupHairStart + (uint32_t)j);
}
}
} else {
collisionGroupId = s_nextCollisionGroup++;
}
for (size_t i = 0; i < orderedBones.size(); ++i) {
settings->mParts[i].mObjectLayer = Layers::HAIR;
if (groupFilter) {
settings->mParts[i].mCollisionGroup.SetGroupFilter(
groupFilter);
settings->mParts[i].mCollisionGroup.SetGroupID(
collisionGroupId);
settings->mParts[i].mCollisionGroup.SetSubGroupID(
subgroupHairStart + (uint32_t)i);
}
}
if (!e.has<CharacterComponent>())
settings->DisableParentChildCollisions();
settings->Stabilize();
settings->CalculateConstraintPriorities();
settings->CalculateBodyIndexToConstraintIndex();
JPH::PhysicsSystem *physSystem = m_physics->getPhysicsSystem();
JPH::Ref<JPH::Ragdoll> ragdoll = settings->CreateRagdoll(
collisionGroupId, 0, physSystem);
/* Store skeleton bones for later pose sync. */
HairRagdollState state;
state.ragdoll = ragdoll;
state.entityId = e.id();
state.hairEntity = hairEnt;
state.masterEntity = masterEnt;
state.slotName = slot;
{
Ogre::Bone *rootBone = orderedBones[0];
Ogre::Matrix4 rootLocal = Ogre::Matrix4::IDENTITY;
rootLocal.makeTransform(rootBone->getInitialPosition(),
rootBone->getInitialScale(),
rootBone->getInitialOrientation());
state.rootBindTransform = rootLocal;
}
state.boneNames.reserve(orderedBones.size());
for (Ogre::Bone *bone : orderedBones)
state.boneNames.push_back(bone->getName());
state.parentIndices = parentIndices;
/* Ensure all bones are manually controlled. */
for (Ogre::Bone *bone : orderedBones) {
bone->setManuallyControlled(true);
}
ragdoll->AddToPhysicsSystem(JPH::EActivation::Activate);
Ogre::LogManager::getSingleton().logMessage(
"HairPhysicsSystem: created ragdoll for " + slot +
" ('" + hairEnt->getMesh()->getName() + "', " +
std::to_string(orderedBones.size()) + " bones, " +
std::to_string(ragdoll->GetConstraintCount()) +
" constraints)");
m_states[e.id()][slot] = std::move(state);
}
void HairPhysicsSystem::destroyRagdoll(HairRagdollState &state)
{
if (state.ragdoll) {
state.ragdoll->RemoveFromPhysicsSystem();
state.ragdoll = nullptr;
}
state.boneNames.clear();
state.parentIndices.clear();
}
bool HairPhysicsSystem::isStateValid(const HairRagdollState &state) const
{
if (!state.hairEntity || state.entityId == 0 || !m_slotSystem)
return false;
flecs::entity e = m_world.entity(state.entityId);
if (!e.is_alive() || !e.has<CharacterSlotsComponent>())
return false;
Ogre::Entity *current = m_slotSystem->getSlotEntity(e, state.slotName);
return current == state.hairEntity && current->hasSkeleton();
}
void HairPhysicsSystem::syncRootToHead(HairRagdollState &state)
{
if (!state.ragdoll || !isStateValid(state))
return;
flecs::entity e = m_world.entity(state.entityId);
const CharacterSlotsComponent &cs = e.get<CharacterSlotsComponent>();
Ogre::Entity *masterEnt = cs.masterEntity;
if (!masterEnt)
return;
Ogre::Bone *attachBone = findAttachBone(state.hairEntity, masterEnt);
if (!attachBone)
return;
/* The root body was created at attachBone * rootBindTransform, so we
* must keep it there each frame. */
Ogre::Matrix4 attachBoneMat = getAttachBoneWorldMatrix(masterEnt,
attachBone);
Ogre::Matrix4 rootWorldMat = attachBoneMat * state.rootBindTransform;
Ogre::Vector3 pos = rootWorldMat.getTrans();
Ogre::Quaternion rot(rootWorldMat.linear());
rot.normalise();
JPH::BodyID rootBody = state.ragdoll->GetBodyID(0);
m_physics->setPositionAndRotation(rootBody, pos, rot, true);
}
void HairPhysicsSystem::syncPhysicsToSkeleton(HairRagdollState &state)
{
if (!state.ragdoll || state.boneNames.empty() ||
!isStateValid(state))
return;
Ogre::SkeletonInstance *skel = state.hairEntity->getSkeleton();
if (!skel)
return;
JPH::SkeletonPose pose;
pose.SetSkeleton(
state.ragdoll->GetRagdollSettings()->GetSkeleton());
state.ragdoll->GetPose(pose);
const JPH::SkeletonPose::Mat44Vector &jointMatrices =
pose.GetJointMatrices();
JPH::RVec3 rootOffset = pose.GetRootOffset();
Ogre::Matrix4 rootOffsetMat = Ogre::Matrix4::IDENTITY;
rootOffsetMat.makeTransform(JoltPhysics::convert(rootOffset),
Ogre::Vector3::UNIT_SCALE,
Ogre::Quaternion::IDENTITY);
/* Get the attachment transform so we can compute hair-entity-local
* transforms for the root bone. */
flecs::entity e = m_world.entity(state.entityId);
const CharacterSlotsComponent &cs = e.get<CharacterSlotsComponent>();
Ogre::Entity *masterEnt = cs.masterEntity;
Ogre::Bone *attachBone = findAttachBone(state.hairEntity, masterEnt);
Ogre::Matrix4 attachBoneInv = Ogre::Matrix4::IDENTITY;
if (attachBone) {
Ogre::Matrix4 attachBoneMat = getAttachBoneWorldMatrix(
masterEnt, attachBone);
attachBoneInv = attachBoneMat.inverse();
}
std::vector<Ogre::Matrix4> worldMats(state.boneNames.size());
for (size_t i = 0; i < state.boneNames.size(); ++i)
worldMats[i] = rootOffsetMat * joltMatrixToOgre(jointMatrices[i]);
for (size_t i = 0; i < state.boneNames.size(); ++i) {
Ogre::Bone *bone = skel->getBone(state.boneNames[i]);
if (!bone)
continue;
Ogre::Matrix4 localMat;
if (state.parentIndices[i] >= 0) {
/* Joint transforms from Jolt are in skeleton world space;
* Ogre bones expect parent-local space. */
localMat = worldMats[state.parentIndices[i]].inverse() *
worldMats[i];
} else {
/* Root bone is in hair-entity local space. */
localMat = attachBoneInv * worldMats[i];
}
Ogre::Vector3 pos, scl(Ogre::Vector3::UNIT_SCALE);
Ogre::Quaternion rot;
pos = localMat.getTrans();
rot = Ogre::Quaternion(localMat.linear());
rot.normalise();
/* Extract scale from basis vectors. */
scl.x = Ogre::Vector3(localMat[0][0], localMat[0][1],
localMat[0][2]).length();
scl.y = Ogre::Vector3(localMat[1][0], localMat[1][1],
localMat[1][2]).length();
scl.z = Ogre::Vector3(localMat[2][0], localMat[2][1],
localMat[2][2]).length();
bone->setManuallyControlled(true);
bone->setPosition(pos);
bone->setOrientation(rot);
bone->setScale(scl);
}
}
JPH::Mat44 HairPhysicsSystem::ogreMatrixToJolt(
const Ogre::Matrix4 &m) const
{
JPH::Mat44 jm;
for (int row = 0; row < 4; ++row) {
for (int col = 0; col < 4; ++col) {
jm(row, col) = m[row][col];
}
}
return jm;
}
Ogre::Matrix4 HairPhysicsSystem::joltMatrixToOgre(
const JPH::Mat44 &m) const
{
Ogre::Matrix4 om;
for (int row = 0; row < 4; ++row) {
for (int col = 0; col < 4; ++col) {
om[row][col] = m(row, col);
}
}
return om;
}
@@ -0,0 +1,75 @@
#ifndef EDITSCENE_HAIRPHYSICSSYSTEM_HPP
#define EDITSCENE_HAIRPHYSICSSYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <unordered_map>
#include <vector>
#include "../physics/physics.h"
#include <Jolt/Physics/Ragdoll/Ragdoll.h>
#include <Jolt/Skeleton/Skeleton.h>
class CharacterSlotSystem;
/**
* System that creates and updates Jolt Physics ragdolls for hair parts
* with their own skeleton. Replaces the previous animation-tree-based
* hair animation.
*/
class HairPhysicsSystem {
public:
HairPhysicsSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
JoltPhysicsWrapper *physics,
CharacterSlotSystem *slotSystem);
~HairPhysicsSystem();
void initialize();
/** Call before physics step to sync root bodies to head transforms. */
void prePhysicsUpdate();
/** Call after physics step to read poses back to Ogre skeletons. */
void postPhysicsUpdate();
private:
struct HairRagdollState {
JPH::Ref<JPH::Ragdoll> ragdoll;
flecs::entity_t entityId = 0;
Ogre::Entity *hairEntity = nullptr;
Ogre::Entity *masterEntity = nullptr;
Ogre::String slotName;
std::vector<Ogre::String> boneNames;
std::vector<int> parentIndices;
Ogre::Matrix4 rootBindTransform = Ogre::Matrix4::IDENTITY;
};
void createRagdoll(flecs::entity e, const Ogre::String &slot,
Ogre::Entity *hairEnt, Ogre::Entity *masterEnt);
void destroyRagdoll(HairRagdollState &state);
void syncRootToHead(HairRagdollState &state);
void syncPhysicsToSkeleton(HairRagdollState &state);
bool isStateValid(const HairRagdollState &state) const;
/* Helpers */
JPH::Mat44 ogreMatrixToJolt(const Ogre::Matrix4 &m) const;
Ogre::Matrix4 joltMatrixToOgre(const JPH::Mat44 &m) const;
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
JoltPhysicsWrapper *m_physics;
CharacterSlotSystem *m_slotSystem;
bool m_initialized = false;
/* Per-entity per-slot ragdoll states */
std::unordered_map<
flecs::entity_t,
std::unordered_map<Ogre::String, HairRagdollState> >
m_states;
static uint32_t s_nextCollisionGroup;
};
#endif // EDITSCENE_HAIRPHYSICSSYSTEM_HPP
@@ -2150,6 +2150,15 @@ nlohmann::json SceneSerializer::serializeCharacter(flecs::entity entity)
json["offset"] = { cc.offset.x, cc.offset.y, cc.offset.z };
json["linearVelocity"] = { cc.linearVelocity.x, cc.linearVelocity.y,
cc.linearVelocity.z };
json["headRadius"] = cc.headRadius;
json["headOffsetY"] = cc.headOffsetY;
json["headBoneName"] = cc.headBoneName;
json["chestHalfExtents"] = {
cc.chestHalfExtents.x, cc.chestHalfExtents.y,
cc.chestHalfExtents.z
};
json["chestOffsetY"] = cc.chestOffsetY;
json["chestBoneName"] = cc.chestBoneName;
return json;
}
@@ -2175,6 +2184,22 @@ void SceneSerializer::deserializeCharacter(flecs::entity entity,
json["linearVelocity"][1].get<float>(),
json["linearVelocity"][2].get<float>());
}
cc.headRadius = json.value("headRadius", 0.13f);
cc.headOffsetY = json.value("headOffsetY", 0.0f);
cc.headBoneName = json.value("headBoneName", Ogre::String("mixamorig:Head"));
if (json.contains("chestHalfExtents") &&
json["chestHalfExtents"].is_array() &&
json["chestHalfExtents"].size() >= 3) {
cc.chestHalfExtents = Ogre::Vector3(
json["chestHalfExtents"][0].get<float>(),
json["chestHalfExtents"][1].get<float>(),
json["chestHalfExtents"][2].get<float>());
} else if (json.contains("chestRadius")) {
float r = json.value("chestRadius", 0.25f);
cc.chestHalfExtents = Ogre::Vector3(r, r, r);
}
cc.chestOffsetY = json.value("chestOffsetY", 0.0f);
cc.chestBoneName = json.value("chestBoneName", Ogre::String("mixamorig:Spine2"));
cc.dirty = true;
entity.set<CharacterComponent>(cc);
}
@@ -2195,6 +2220,11 @@ void SceneSerializer::deserializeCharacterIdentity(flecs::entity entity,
entity.set<CharacterIdentityComponent>(ci);
}
/* Forward declarations for AnimationTreeNode serialization */
static nlohmann::json serializeAnimationTreeNode(const AnimationTreeNode &node);
static void deserializeAnimationTreeNode(AnimationTreeNode &node,
const nlohmann::json &json);
nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity)
{
auto &cs = entity.get<CharacterSlotsComponent>();
@@ -2217,6 +2247,7 @@ nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity)
}
json["slotSelections"] = selections;
// Serialize front axis
json["frontAxis"] = { cs.frontAxis.x, cs.frontAxis.y, cs.frontAxis.z };
@@ -2269,6 +2300,7 @@ void SceneSerializer::deserializeCharacterSlots(flecs::entity entity,
cs.frontAxis.normalise();
}
cs.dirty = true;
entity.set<CharacterSlotsComponent>(cs);
}
@@ -1,5 +1,6 @@
#include "CharacterEditor.hpp"
#include <imgui.h>
#include <cstring>
bool CharacterEditor::renderComponent(flecs::entity entity,
CharacterComponent &cc)
@@ -25,6 +26,61 @@ bool CharacterEditor::renderComponent(flecs::entity entity,
ImGui::TextDisabled("Total: %.2f m",
cc.getTotalHeight());
ImGui::Separator();
ImGui::Text("Head Collider");
if (ImGui::DragFloat("Head Radius##Character",
&cc.headRadius, 0.01f, 0.0f, 2.0f,
"%.2f")) {
modified = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("0 = default (0.13)");
if (ImGui::DragFloat("Head Offset Y##Character",
&cc.headOffsetY, 0.01f, 0.0f, 5.0f,
"%.2f")) {
modified = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("0 = default (totalHeight - headRadius)");
char headBoneBuf[64];
std::strncpy(headBoneBuf, cc.headBoneName.c_str(), sizeof(headBoneBuf));
headBoneBuf[sizeof(headBoneBuf) - 1] = '\0';
if (ImGui::InputText("Head Bone##Character", headBoneBuf,
sizeof(headBoneBuf))) {
cc.headBoneName = headBoneBuf;
modified = true;
}
ImGui::Separator();
ImGui::Text("Chest Collider");
float che[3] = { cc.chestHalfExtents.x, cc.chestHalfExtents.y,
cc.chestHalfExtents.z };
if (ImGui::InputFloat3("Chest Half Extents##Character", che,
"%.2f")) {
cc.chestHalfExtents = Ogre::Vector3(che[0], che[1], che[2]);
modified = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Set any axis to 0 to disable");
if (ImGui::DragFloat("Chest Offset Y##Character",
&cc.chestOffsetY, 0.01f, -2.0f, 2.0f,
"%.2f")) {
modified = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Vertical offset along world Y");
char chestBoneBuf[64];
std::strncpy(chestBoneBuf, cc.chestBoneName.c_str(),
sizeof(chestBoneBuf));
chestBoneBuf[sizeof(chestBoneBuf) - 1] = '\0';
if (ImGui::InputText("Chest Bone##Character", chestBoneBuf,
sizeof(chestBoneBuf))) {
cc.chestBoneName = chestBoneBuf;
modified = true;
}
ImGui::Separator();
ImGui::Text("Offset");
float off[3] = { cc.offset.x, cc.offset.y, cc.offset.z };
@@ -221,61 +221,65 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
}
} else {
/* Layer 0 combo (base meshes like hair) */
std::vector<Ogre::String> layer0Meshes =
CharacterSlotSystem::
getMeshesForLayer(
currentAge,
cs.sex, slot,
0);
if (!layer0Meshes.empty()) {
Ogre::String l0Preview = "auto";
if (!sel.layer0Mesh.empty() &&
sel.layer0Mesh != "none")
l0Preview = CharacterSlotSystem::
getMeshLabel(
/* Hair slot always uses auto stub; no base editing */
if (slot != "hair") {
std::vector<Ogre::String> layer0Meshes =
CharacterSlotSystem::
getMeshesForLayer(
currentAge,
cs.sex,
slot,
sel.layer0Mesh);
if (ImGui::BeginCombo(
"Base (Layer 0)",
l0Preview.c_str())) {
if (ImGui::Selectable(
"auto",
sel.layer0Mesh.empty() ||
sel.layer0Mesh ==
"none")) {
sel.layer0Mesh =
"none";
modified = true;
cs.dirty = true;
}
for (const auto &m :
layer0Meshes) {
Ogre::String label = CharacterSlotSystem::
cs.sex, slot,
0);
if (!layer0Meshes.empty()) {
Ogre::String l0Preview = "auto";
if (!sel.layer0Mesh.empty() &&
sel.layer0Mesh != "none")
l0Preview = CharacterSlotSystem::
getMeshLabel(
currentAge,
cs.sex,
slot,
m);
bool isSelected =
(sel.layer0Mesh ==
m);
sel.layer0Mesh);
if (ImGui::BeginCombo(
"Base (Layer 0)",
l0Preview.c_str())) {
if (ImGui::Selectable(
label.c_str(),
isSelected)) {
"auto",
sel.layer0Mesh.empty() ||
sel.layer0Mesh ==
"none")) {
sel.layer0Mesh =
m;
modified =
true;
cs.dirty =
true;
"none";
modified = true;
cs.dirty = true;
}
for (const auto &m :
layer0Meshes) {
Ogre::String label = CharacterSlotSystem::
getMeshLabel(
currentAge,
cs.sex,
slot,
m);
bool isSelected =
(sel.layer0Mesh ==
m);
if (ImGui::Selectable(
label.c_str(),
isSelected)) {
sel.layer0Mesh =
m;
modified =
true;
cs.dirty =
true;
}
}
ImGui::EndCombo();
}
ImGui::EndCombo();
}
} else {
ImGui::TextDisabled("Base: auto (stub hair)");
}
/* Layer 1 combo */
std::vector<Ogre::String> layer1Meshes =
CharacterSlotSystem::
+23 -11
View File
@@ -232,22 +232,34 @@ struct GUIListener : public Ogre::RenderTargetListener {
float width = size.x;
float height = size.y;
Ogre::Camera *camera = ECS::get<Camera>().mCamera;
// 1. Convert to camera space
// 1. Convert to camera space (OGRE camera looks down -Z)
Ogre::Vector3 eyeSpacePoint =
camera->getViewMatrix() * worldPoint;
// 2. Project to clip space
Ogre::Vector3 clipSpacePoint =
camera->getProjectionMatrix() * eyeSpacePoint;
if (clipSpacePoint.z < 0.0f)
if (eyeSpacePoint.z >= 0.0f)
return Ogre::Vector2(-1, -1);
// 3. Convert from clip space (-1 to 1) to screen space (0 to 1)
// Note: Y is usually flipped in API screen coordinates compared to projection
float screenX = (clipSpacePoint.x / 2.0f) + 0.5f;
float screenY = 1.0f - ((clipSpacePoint.y / 2.0f) + 0.5f);
// 2. Project to homogeneous clip space using Vector4 to preserve W
Ogre::Vector4 clipSpacePoint =
camera->getProjectionMatrix() *
Ogre::Vector4(eyeSpacePoint.x, eyeSpacePoint.y,
eyeSpacePoint.z, 1.0f);
if (clipSpacePoint.w <= 0.0f)
return Ogre::Vector2(-1, -1);
// 4. Map to actual pixel dimensions
// 3. Perspective divide to get NDC [-1, 1]
float ndcX = clipSpacePoint.x / clipSpacePoint.w;
float ndcY = clipSpacePoint.y / clipSpacePoint.w;
// 4. Convert NDC to screen space [0, 1], flipping Y for ImGui
float screenX = (ndcX * 0.5f) + 0.5f;
float screenY = 1.0f - ((ndcY * 0.5f) + 0.5f);
// 5. Reject if outside viewport bounds
if (screenX < 0.0f || screenX > 1.0f ||
screenY < 0.0f || screenY > 1.0f)
return Ogre::Vector2(-1, -1);
// 6. Map to actual pixel dimensions
return Ogre::Vector2(screenX * width, screenY * height);
}
void preview(const Ogre::RenderTargetViewportEvent &evt)