Compare commits

..

8 Commits

Author SHA1 Message Date
slapin 0a5edacf8a Character hair physics implementation 2026-06-16 01:22:20 +03:00
slapin 765dffbed0 Clothes and hairs 2026-05-30 20:04:07 +03:00
slapin a553621c7f Fixed normal seams 2026-05-25 16:00:24 +03:00
slapin 86310e96f2 Seams are fixed 2026-05-25 05:07:23 +03:00
slapin 1a0fb87b93 some docs 2026-05-25 04:19:03 +03:00
slapin b71b599d9c Fixed inflation 2026-05-25 04:10:44 +03:00
slapin eea50adfcb Gap filling, improvements for character pipeline 2026-05-25 01:42:28 +03:00
slapin c7ef9283cd Fixed shape keys 2026-05-22 22:03:31 +03:00
39 changed files with 3411 additions and 239 deletions
+59 -42
View File
@@ -62,6 +62,7 @@ add_custom_command(
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py
${VRM_IMPORTED_BLENDS}
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
# ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-hair.stamp
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
VERBATIM
@@ -418,48 +419,64 @@ function(add_clothes_pipeline INPUT_BLEND WEIGHTED_BLEND FINAL_OUTPUT_BLEND)
)
endfunction()
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-male-top.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-male-bottom.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-male-feet.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-female-top.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-female-bottom.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-female-feet.blend)
set(SEX_LIST "male" "female")
foreach(SEX ${SEX_LIST})
set(HAIR_WEIGHTED_STAMP "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-hair_weighted.stamp")
add_custom_command(
OUTPUT ${HAIR_WEIGHTED_STAMP}
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-hair.blend
${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/clothes
COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py --
${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-hair.blend
./ ${CMAKE_CURRENT_BINARY_DIR}/clothes
COMMAND ${CMAKE_COMMAND} -E touch ${HAIR_WEIGHTED_STAMP}
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-hair_weighted.blend
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMENT "Processing hair meshes (weight transfer)"
)
# male
add_clothes_pipeline(
"${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-male.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-bottom_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-bottom.blend" # BOTTOM_OUTPUT_BLEND
)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-top.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-bottom.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-feet.blend)
endforeach()
add_clothes_pipeline(
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-bottom.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-top_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-top.blend" # FINAL_OUTPUT_BLEND
)
add_clothes_pipeline(
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-top.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-feet_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend" # FINAL_OUTPUT_BLEND
)
# female
add_clothes_pipeline(
"${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-female.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-bottom_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-bottom.blend" # FINAL_OUTPUT_BLEND
)
add_clothes_pipeline(
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-bottom.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-top_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-top.blend" # FINAL_OUTPUT_BLEND
)
add_clothes_pipeline(
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-top.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-feet_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend" # FINAL_OUTPUT_BLEND
)
# Propagate missing shape keys (like "fat") from Body_shapes to base body parts.
# This ensures all body part meshes have consistent shape keys, preventing seams.
function(add_shape_key_propagation INPUT_BLEND OUTPUT_BLEND)
add_custom_command(
OUTPUT ${OUTPUT_BLEND}
DEPENDS ${INPUT_BLEND}
${CMAKE_CURRENT_SOURCE_DIR}/add_missing_shape_keys.py
${CMAKE_CURRENT_SOURCE_DIR}/transfer_shape_keys.py
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMAND ${CMAKE_COMMAND} -E copy ${INPUT_BLEND} ${OUTPUT_BLEND}
COMMAND ${BLENDER} -b -Y ${OUTPUT_BLEND}
-P ${CMAKE_CURRENT_SOURCE_DIR}/add_missing_shape_keys.py --
${OUTPUT_BLEND} ${OUTPUT_BLEND}
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)
endfunction()
set(SLOT_LIST "bottom" "top" "feet" "hair")
list(GET SLOT_LIST -1 LAST_SLOT)
# 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 "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()
@@ -0,0 +1,141 @@
"""
Add missing shape keys from Body_shapes to body part objects.
Preserves existing shape keys.
Usage: blender -b --python add_missing_shape_keys.py -- <blend_file> <output_file>
"""
import bpy
import sys
import os
import mathutils
from mathutils.bvhtree import BVHTree
# Import functions from transfer_shape_keys.py
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from transfer_shape_keys import (
compute_robust_surface_mapping,
compute_side_preserving_position,
smooth_boundary_areas,
smooth_penetration_areas,
create_bvh_for_source,
load_source_data,
fix_seams_across_objects,
fix_normals_across_objects
)
def get_source_object_name(target_obj_info):
if target_obj_info.get('ref_shapes'):
return target_obj_info['ref_shapes']
return None
def add_missing_shape_keys(source_data, target_obj):
"""Add only missing shape keys to target object, preserving existing ones."""
if not target_obj.data.shape_keys:
target_obj.shape_key_add(name='Basis', from_mix=False)
existing_names = {kb.name for kb in target_obj.data.shape_keys.key_blocks}
missing_names = [n for n in source_data['names'] if n != 'Basis' and n not in existing_names]
if not missing_names:
print(f" {target_obj.name}: all shape keys present, skipping")
return
print(f" {target_obj.name}: adding missing keys: {missing_names}")
# Create missing shape keys
for sk_name in missing_names:
target_obj.shape_key_add(name=sk_name, from_mix=False)
target_obj.data.shape_keys.use_relative = True
# Compute mapping
mapping = compute_robust_surface_mapping(target_obj, source_data)
# Set values for missing shape keys
for sk_name in missing_names:
print(f" Setting {sk_name}...")
sk = target_obj.data.shape_keys.key_blocks[sk_name]
target_obj.data.shape_keys.use_relative = False
for i, v in enumerate(sk.data):
if i < len(mapping['target_verts']):
pos = compute_side_preserving_position(i, sk_name, mapping, source_data, 1.0)
v.co = pos
sk.data.update()
target_obj.data.update_tag()
bpy.context.view_layer.update()
target_obj.data.shape_keys.use_relative = True
target_obj.data.update_tag()
bpy.context.view_layer.update()
smooth_boundary_areas(target_obj, sk_name, mapping, source_data)
smooth_penetration_areas(target_obj, sk_name, mapping, source_data)
print(f" ✓ Added {sk_name}")
def process_blend(blend_path, output_path):
print(f"Processing: {blend_path}")
bpy.ops.wm.open_mainfile(filepath=blend_path)
# Find source object
source_obj = None
for name in ['Body_shapes', 'Body']:
if name in bpy.data.objects and bpy.data.objects[name].type == 'MESH':
obj = bpy.data.objects[name]
if obj.data.shape_keys and len(obj.data.shape_keys.key_blocks) > 1:
source_obj = obj
print(f"Found source: {name}")
break
if not source_obj:
print("ERROR: No source object with shape keys found")
return False
# Build source data
source_data = {
'names': [kb.name for kb in source_obj.data.shape_keys.key_blocks],
'vertex_positions': {},
'polygons': [],
'is_relative': source_obj.data.shape_keys.use_relative,
'source_obj_name': source_obj.name
}
for poly in source_obj.data.polygons:
source_data['polygons'].append([v for v in poly.vertices])
for sk in source_obj.data.shape_keys.key_blocks:
source_data['vertex_positions'][sk.name] = [(v.co.x, v.co.y, v.co.z) for v in sk.data]
print(f"Source shape keys: {source_data['names']}")
# Process body part objects
required_props = {'age', 'sex', 'slot'}
modified = False
for obj in bpy.data.objects:
if obj.type == 'MESH' and all(p in obj for p in required_props):
if obj == source_obj:
continue
add_missing_shape_keys(source_data, obj)
modified = True
# Fix seams: synchronize shape key offsets for vertices sharing
# the same basis position across body parts.
fix_seams_across_objects()
fix_normals_across_objects()
bpy.ops.wm.save_as_mainfile(filepath=output_path)
print(f"Saved: {output_path}")
return True
if __name__ == "__main__":
try:
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) >= 2:
process_blend(args[0], args[1])
else:
print("Usage: blender -b -P script.py -- <input.blend> <output.blend>")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
Binary file not shown.
Binary file not shown.
Binary file not shown.
+66 -57
View File
@@ -43,34 +43,27 @@ def get_transformation_matrices(obj):
return m, m_inv, m_normal
def raycast_and_adjust_vertices(target_body, bvh_cloth, out_ray_length=0.15):
"""Raycast from body to cloth and adjust vertices that intersect"""
m_body, m_body_inv, m_body_normal = get_transformation_matrices(target_body)
"""Raycast from body to cloth and mark vertices that are covered.
Returns a list where 1 = vertex is covered by clothing, 0 = visible.
Does NOT modify vertex positions -- that used to cause visible steps
at clothing boundaries because border vertices kept the inward offset
while exposed neighbours did not."""
m_body, _, m_body_normal = get_transformation_matrices(target_body)
num_verts = len(target_body.data.vertices)
hit_values = [0] * num_verts
has_shape_keys = target_body.data.shape_keys is not None
# Forward raycast (into cloth)
for i, v in enumerate(target_body.data.vertices):
v_world = m_body @ v.co
n_world = (m_body_normal @ v.normal).normalized()
# Raycast forward (into cloth) and backward (from inside cloth)
# Raycast forward (outward) and backward (inward)
hit_f, _, _, _ = bvh_cloth.ray_cast(v_world, n_world, out_ray_length)
hit_b, _, _, _ = bvh_cloth.ray_cast(v_world, -n_world, 0.005)
if hit_f or hit_b:
hit_values[i] = 1
# Adjust vertex position to be slightly outside cloth
offset = -n_world * (0.005 if hit_f else 0.01)
new_co = m_body_inv @ (v_world + offset)
v.co = new_co
# Update shape keys if they exist
if has_shape_keys:
for kb in target_body.data.shape_keys.key_blocks:
kb.data[i].co = new_co
return hit_values
def protect_and_remove_hidden_geometry(target_body, hit_values, threshold=4.0):
@@ -158,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")
@@ -188,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,17 +219,42 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
clothing_obj.select_set(True)
new_target.select_set(True)
bpy.context.view_layer.objects.active = new_target
# Remember clothing's material BEFORE join so we can verify
# after Blender potentially remaps faces to a stale copy.
clothing_mat = clothing_obj.active_material
bpy.ops.object.join()
# NEW CODE: Copy stored custom properties to combined object
# After joining, new_target is the active object and contains the combined mesh
# ---- Fix: ensure clothing faces keep the clothing's material ----
if clothing_mat:
clothing_slot = None
stale_slot = None
for i, slot in enumerate(new_target.material_slots):
if slot.material == clothing_mat:
clothing_slot = i
elif (slot.material and
slot.material != clothing_mat and
slot.material.name == clothing_mat.name):
# Same name, different data block -> stale
stale_slot = i
if clothing_slot is not None and stale_slot is not None:
mesh = new_target.data
fixed = 0
for poly in mesh.polygons:
if poly.material_index == stale_slot:
poly.material_index = clothing_slot
fixed += 1
if fixed:
print(f" Fixed {fixed} faces: stale mat slot "
f"{stale_slot} -> clothing slot {clothing_slot}")
# ----------------------------------------------------------------
# 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:
@@ -271,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
@@ -290,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 = []
@@ -309,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 = []
@@ -317,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}.")
@@ -336,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:
@@ -359,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)
@@ -373,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)
@@ -403,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 = []
@@ -421,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)
@@ -429,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 ===")
@@ -438,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:
@@ -464,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()
+82 -18
View File
@@ -2,69 +2,134 @@ import bpy
import sys
import os
# Import seam/normal fix functions from transfer_shape_keys.py
_script_dir = os.path.dirname(os.path.abspath(__file__))
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
from transfer_shape_keys import fix_seams_across_objects, fix_normals_across_objects
def process_append(source_files, output_path):
required_props = {"age", "sex", "slot"}
for file_path in source_files:
if not os.path.exists(file_path):
print(f"Warning: Source file not found: {file_path}")
continue
with bpy.data.libraries.load(file_path) as (data_from, data_to):
# ---- 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:
continue
existing = bpy.data.materials.get(mat.name)
if existing and existing != mat:
print(f" Material '{mat.name}' already exists with "
f"different data; incoming material will "
f"replace it on appended objects.")
imported_materials[mat.name] = mat
# 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
bpy.context.collection.objects.link(obj)
# ---- Remap material slots to imported versions ----
for slot in obj.material_slots:
if slot.material and slot.material.name in imported_materials:
imported = imported_materials[slot.material.name]
if imported != slot.material:
print(f" Remapping slot "
f"'{slot.material.name}' -> "
f"'{imported.name}' on '{obj.name}'")
slot.material = imported
# ---------------------------------------------------
# 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)
# B. Parent to Armature
obj.parent = arm_obj
# C. Handle Armature Modifier
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.
print("\nFixing seams across all consolidated body parts...")
fix_seams_across_objects()
fix_normals_across_objects()
print("Consolidated seam/normal fix complete.\n")
# Save
bpy.ops.wm.save_as_mainfile(filepath=output_path)
@@ -76,4 +141,3 @@ if __name__ == "__main__":
process_append(sources, output)
except ValueError:
print("Error: Use '--' to separate Blender args from script args.")
Binary file not shown.
Binary file not shown.
+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()
@@ -0,0 +1,91 @@
import bpy
import sys
import os
def propagate_shape_keys(blend_path, output_path):
"""Copy missing shape keys from Body_shapes to all body part objects."""
print(f"Propagating shape keys from {blend_path} to {output_path}")
bpy.ops.wm.open_mainfile(filepath=blend_path)
# Find source object with all shape keys
source_obj = None
for name in ['Body_shapes', 'Body']:
if name in bpy.data.objects and bpy.data.objects[name].type == 'MESH':
obj = bpy.data.objects[name]
if obj.data.shape_keys and len(obj.data.shape_keys.key_blocks) > 1:
source_obj = obj
print(f"Found source object: {name}")
break
if not source_obj:
print("ERROR: No source object with shape keys found")
return False
source_keys = source_obj.data.shape_keys.key_blocks
source_basis = source_keys['Basis'].data
modified = False
for obj in bpy.data.objects:
if obj.type != 'MESH':
continue
# Only process body part objects
if not all(p in obj for p in ['age', 'sex', 'slot']):
continue
# Skip the source object itself
if obj == source_obj:
continue
if not obj.data.shape_keys:
# Create basis if missing
obj.shape_key_add(name='Basis', from_mix=False)
existing_names = {kb.name for kb in obj.data.shape_keys.key_blocks}
for sk in source_keys:
if sk.name == 'Basis':
continue
if sk.name in existing_names:
print(f" {obj.name}: already has '{sk.name}', skipping")
continue
# Copy shape key
print(f" {obj.name}: adding '{sk.name}' from {source_obj.name}")
new_sk = obj.shape_key_add(name=sk.name, from_mix=False)
# Transfer vertex positions relative to basis
# We need to map source vertices to target vertices
# For now, assume same topology (body parts were separated from same mesh)
num_verts = min(len(new_sk.data), len(sk.data), len(source_basis))
for i in range(num_verts):
# The shape key position in source
src_pos = sk.data[i].co
src_basis_pos = source_basis[i].co
# Offset from basis
offset = src_pos - src_basis_pos
# Apply to target using target's basis position
new_sk.data[i].co = obj.data.shape_keys.key_blocks['Basis'].data[i].co + offset
new_sk.data.update()
modified = True
if modified:
obj.data.update_tag()
bpy.context.view_layer.update()
bpy.ops.wm.save_as_mainfile(filepath=output_path)
print(f"Saved: {output_path}")
return True
if __name__ == "__main__":
try:
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) >= 2:
propagate_shape_keys(args[0], args[1])
else:
print("Usage: blender -b -P script.py -- <input.blend> <output.blend>")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
+255 -21
View File
@@ -185,22 +185,45 @@ def get_target_object_by_name(obj_name):
return None
def delete_existing_shape_keys(target_obj):
"""Delete all existing shape keys from target object"""
"""Delete all existing shape keys from target object.
CRITICAL: Blender's shape_key_remove() leaves mesh vertices in their
*deformed* state rather than restoring basis positions. We must save
the Basis positions before deletion and restore them afterwards."""
if not target_obj.data.shape_keys:
return False
# Save Basis positions before deletion
basis_positions = None
for kb in target_obj.data.shape_keys.key_blocks:
if kb.name == 'Basis':
basis_positions = [v.co.copy() for v in kb.data]
break
# Fallback: if no Basis exists, use current vertex positions
if basis_positions is None:
basis_positions = [v.co.copy() for v in target_obj.data.vertices]
num_keys = len(target_obj.data.shape_keys.key_blocks)
print(f" Deleting {num_keys} existing shape keys...")
bpy.context.view_layer.objects.active = target_obj
target_obj.select_set(True)
bpy.ops.object.mode_set(mode='OBJECT')
while target_obj.data.shape_keys:
target_obj.active_shape_key_index = 0
bpy.ops.object.shape_key_remove()
target_obj.select_set(False)
# RESTORE BASIS POSITIONS - Blender leaves mesh deformed after removal
for i, v in enumerate(target_obj.data.vertices):
if i < len(basis_positions):
v.co = basis_positions[i]
target_obj.data.update_tag()
bpy.context.view_layer.update()
print(f" Restored basis positions for {len(basis_positions)} vertices")
return True
def ensure_shape_keys_structure(shape_key_names, target_obj):
@@ -243,12 +266,16 @@ def compute_signed_distance_and_direction(bvh, point, reference_normal=None):
if location is None:
return None, None, None, None
# Determine sign using reference normal
# Determine sign using reference normal.
# For points outside (in the direction of the normal), signed_distance
# must be positive. For points inside, negative.
if reference_normal is not None:
to_surface = location - point
if to_surface.length > 0:
dot = to_surface.normalized().dot(reference_normal)
signed_distance = distance if dot > 0 else -distance
# Inverted relative to the no-reference-normal branch because
# to_surface here points *toward* the surface, not away.
signed_distance = -distance if dot > 0 else distance
else:
signed_distance = 0
else:
@@ -522,9 +549,9 @@ def enforce_side_constraint(pos, surface_point, reference_normal, target_side, c
# Very small offset to stay near surface
offset = abs((surface_point - pos).length) * 0.2
if target_side > 0:
return proj_point - reference_normal * offset
else:
return proj_point + reference_normal * offset
else:
return proj_point - reference_normal * offset
return pos
# Regular vertices - stronger enforcement
@@ -534,9 +561,9 @@ def enforce_side_constraint(pos, surface_point, reference_normal, target_side, c
offset = abs((surface_point - pos).length) * 0.95
if target_side > 0:
corrected_pos = proj_point - reference_normal * offset
else:
corrected_pos = proj_point + reference_normal * offset
else:
corrected_pos = proj_point - reference_normal * offset
return corrected_pos
@@ -743,15 +770,21 @@ def smooth_penetration_areas(target_obj, sk_name, mapping, source_data, threshol
problem_vertices = set()
bvh_deformed, _ = create_bvh_for_source(source_data, sk_name)
# Shape keys are in relative mode here, so current_positions are offsets.
# The BVH is built from absolute source positions, so we must convert
# relative offsets to absolute positions before querying it.
basis_positions = mapping['target_verts']
for i, pos in enumerate(current_positions):
if i < len(mapping['surface_points']) and i < len(mapping['side_flags']):
surface_point = mapping['surface_points'][i]
target_side = mapping['side_flags'][i]
if target_side != 0:
location, _, _, _ = bvh_deformed.find_nearest(pos)
if target_side != 0 and i < len(basis_positions):
abs_pos = basis_positions[i] + pos
location, _, _, _ = bvh_deformed.find_nearest(abs_pos)
if location and i < len(mapping['direction_vectors']):
to_surface = pos - location
to_surface = abs_pos - location
current_side = 1 if to_surface.dot(mapping['direction_vectors'][i]) > 0 else -1
if current_side != target_side:
@@ -879,7 +912,7 @@ def set_shape_key_with_side_preservation(target_obj, sk_name, mapping, source_da
smooth_penetration_areas(target_obj, sk_name, mapping, source_data)
def test_shape_key_quality(target_obj, sk_name, mapping, source_data):
"""Test shape key quality with side tracking"""
"""Test shape key quality with side tracking (READ-ONLY - does not modify shape key data)"""
sk = target_obj.data.shape_keys.key_blocks[sk_name]
@@ -889,6 +922,9 @@ def test_shape_key_quality(target_obj, sk_name, mapping, source_data):
prev_positions = None
boundary_vertices = mapping.get('boundary_vertices', set())
# Save original shape key data so we can restore it after testing
original_positions = [v.co.copy() for v in sk.data]
for val in test_values:
if val == 0.0:
sk.value = 0.0
@@ -932,6 +968,13 @@ def test_shape_key_quality(target_obj, sk_name, mapping, source_data):
prev_positions = [v.co.copy() for v in target_obj.data.vertices]
# Restore original shape key data (the test should not modify the shape key)
target_obj.data.shape_keys.use_relative = False
for i, v in enumerate(sk.data):
if i < len(original_positions):
v.co = original_positions[i]
target_obj.data.shape_keys.use_relative = True
sk.value = 0.0
target_obj.data.update_tag()
bpy.context.view_layer.update()
@@ -999,16 +1042,197 @@ def process_target_object(target_obj_info, source_data, current_file_path):
mapping = transfer_shape_keys(source_data, target_obj)
verify_transfer(source_data['names'], target_obj)
# Test quality of 'fat' shape key if it exists
for sk_name in source_data['names']:
if sk_name == 'fat':
test_shape_key_quality(target_obj, sk_name, mapping, source_data)
break
print(f" {'=' * 40}")
print(f" ✓ Completed")
return True
def get_body_part_objects():
"""Find all mesh objects that are body parts (have age/sex/slot)"""
result = []
required_props = {'age', 'sex', 'slot'}
for obj in bpy.data.objects:
if obj.type == 'MESH' and obj.data and all(p in obj for p in required_props):
result.append(obj)
return result
def round_pos(co, tolerance=0.0001):
"""Round a coordinate to tolerance-based buckets for spatial hashing"""
return (round(co.x / tolerance),
round(co.y / tolerance),
round(co.z / tolerance))
def fix_seams_across_objects(tolerance=0.0001):
"""
Post-process shape keys to ensure vertices sharing the same basis
position (within tolerance) get identical offsets. This fixes seams
between body parts and UV seams within the same part.
"""
body_parts = get_body_part_objects()
if not body_parts:
print("No body part objects found for seam fixing")
return
print(f"\n{'=' * 60}")
print("SEAM FIX: Synchronizing shape key offsets across body parts")
print(f"{'=' * 60}")
print(f"Found {len(body_parts)} body part objects")
# Collect shape key names from first object that has them
shape_key_names = []
for obj in body_parts:
if obj.data.shape_keys and obj.data.shape_keys.key_blocks:
for kb in obj.data.shape_keys.key_blocks:
if kb.name != 'Basis':
shape_key_names.append(kb.name)
break
if not shape_key_names:
print("No shape keys found, skipping seam fix")
return
print(f"Synchronizing {len(shape_key_names)} shape key(s): {', '.join(shape_key_names)}")
fixed_vertices = 0
fixed_groups = 0
for sk_name in shape_key_names:
# Build spatial hash: position_key -> list of (obj, vert_idx, offset)
pos_map = {}
for obj in body_parts:
if not obj.data.shape_keys or sk_name not in obj.data.shape_keys.key_blocks:
continue
sk = obj.data.shape_keys.key_blocks[sk_name]
for i, v in enumerate(obj.data.vertices):
if i >= len(sk.data):
continue
pos_key = round_pos(v.co, tolerance)
offset = sk.data[i].co.copy()
if pos_key not in pos_map:
pos_map[pos_key] = []
pos_map[pos_key].append((obj, i, offset))
# Average offsets for each position group with multiple vertices
for pos_key, entries in pos_map.items():
if len(entries) < 2:
continue
# Compute average offset
avg_offset = mathutils.Vector((0.0, 0.0, 0.0))
for obj, idx, offset in entries:
avg_offset += offset
avg_offset /= len(entries)
# Apply averaged offset to all vertices in this group
for obj, idx, offset in entries:
sk = obj.data.shape_keys.key_blocks[sk_name]
sk.data[idx].co = avg_offset.copy()
fixed_vertices += 1
fixed_groups += 1
# Update all meshes
for obj in body_parts:
if obj.data.shape_keys:
for kb in obj.data.shape_keys.key_blocks:
kb.data.update()
obj.data.update_tag()
bpy.context.view_layer.update()
print(f"Fixed {fixed_vertices} vertices across {fixed_groups} position groups")
print(f"Seam fix complete")
def fix_normals_across_objects(tolerance=0.0001):
"""
Post-process normals to ensure vertices sharing the same basis
position (within tolerance) get identical normals. This fixes
lighting seams between body parts.
"""
body_parts = get_body_part_objects()
if not body_parts:
print("No body part objects found for normal fixing")
return
print(f"\n{'=' * 60}")
print("NORMAL FIX: Synchronizing normals across body parts")
print(f"{'=' * 60}")
print(f"Found {len(body_parts)} body part objects")
# Build spatial hash: position_key -> list of normals
pos_map = {}
for obj in body_parts:
obj.data.calc_normals()
for v in obj.data.vertices:
pos_key = round_pos(v.co, tolerance)
if pos_key not in pos_map:
pos_map[pos_key] = []
pos_map[pos_key].append(v.normal.copy())
# Compute averaged normals for positions with multiple vertices
avg_normals = {}
for pos_key, normals in pos_map.items():
if len(normals) < 2:
continue
avg = mathutils.Vector((0.0, 0.0, 0.0))
for n in normals:
avg += n
if avg.length > 0.001:
avg.normalize()
avg_normals[pos_key] = avg
if not avg_normals:
print("No matching vertices found, skipping normal fix")
return
print(f"Found {len(avg_normals)} position groups with matching vertices")
# Apply averaged normals to each object
fixed_loops = 0
fixed_verts = 0
for obj in body_parts:
obj.data.use_auto_smooth = True
if not obj.data.has_custom_normals:
obj.data.create_normals_split()
obj.data.calc_normals_split()
vert_normal_map = {}
for i, v in enumerate(obj.data.vertices):
pos_key = round_pos(v.co, tolerance)
if pos_key in avg_normals:
vert_normal_map[i] = avg_normals[pos_key]
if not vert_normal_map:
continue
custom_normals = []
for loop in obj.data.loops:
if loop.vertex_index in vert_normal_map:
n = vert_normal_map[loop.vertex_index]
custom_normals.append((n.x, n.y, n.z))
fixed_loops += 1
else:
if hasattr(obj.data, 'corner_normals'):
cn = obj.data.corner_normals[loop.index]
custom_normals.append((cn.vector.x, cn.vector.y, cn.vector.z))
else:
custom_normals.append((loop.normal.x, loop.normal.y, loop.normal.z))
obj.data.normals_split_custom_set(custom_normals)
fixed_verts += len(vert_normal_map)
obj.data.update_tag()
bpy.context.view_layer.update()
print(f"Fixed {fixed_verts} vertices ({fixed_loops} loops) across {len(body_parts)} objects")
print(f"Normal fix complete")
def main():
print("=" * 60)
print("Blender Shape Key Transfer Script - Boundary Velocity Limiting")
@@ -1067,6 +1291,16 @@ def main():
print(f"\nProgress saved to: {temp_progress_file}")
# Post-process: fix seams by synchronizing offsets across body parts
print(f"\nLoading final working file for seam fix...")
bpy.ops.wm.open_mainfile(filepath=working_file)
fix_seams_across_objects()
fix_normals_across_objects()
# Save after seam fix
print(f"\nSaving after seam fix...")
bpy.ops.wm.save_as_mainfile(filepath=working_file)
# Copy the final working file to the output location
print(f"\nCopying final result to: {output_file}")
shutil.copy2(working_file, output_file)
+329 -7
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,8 +287,21 @@ 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"
# Remove .001 suffixed duplicates that may have leaked from consolidate step
# These are objects that have the same base name as an object in mapping.objs
# but with a .001 suffix (e.g., BodyBottom.001 when BodyBottom is in mapping.objs)
if mapping.auto_discover:
for ob in list(bpy.data.objects):
if ob.type == 'MESH' and ob.name.endswith('.001'):
base_name = ob.name[:-4]
if base_name in mapping.objs:
print(f"Removing duplicate '{ob.name}' (base '{base_name}' already in export list)")
bpy.data.objects.remove(ob, do_unlink=True)
print("Removing original armature and actions...")
orig_arm = bpy.data.objects[mapping.armature_name + '_orig']
@@ -283,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,
@@ -306,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,
@@ -320,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 + "_"
@@ -327,18 +388,44 @@ for mapping in[CommandLineMapping()]:
obj = bpy.data.objects.get(name)
if obj and obj.type == 'MESH':
# 1. Rename Mesh Data
if not obj.data.name.startswith(prefix):
obj.data.name = prefix + obj.data.name
# 1. Rename Mesh Data to match object name (not mesh data name)
# This prevents .001 suffixed mesh data names (e.g., BodyBottom.001)
# from becoming male_BodyBottom.001 and clashing with male_BodyBottom.
new_mesh_name = prefix + name
if obj.data.name != new_mesh_name:
# Check if another mesh data already has this name
if new_mesh_name in bpy.data.meshes and bpy.data.meshes[new_mesh_name] != obj.data:
old = bpy.data.meshes[new_mesh_name]
old.name = new_mesh_name + "_old"
print(f"Renamed conflicting mesh data '{old.name}' for object '{name}'")
obj.data.name = new_mesh_name
print(f"Renamed mesh data from '{obj.data.name}' to '{new_mesh_name}' for object '{name}'")
# 2. Iterate through all Material Slots on the object
for slot in obj.material_slots:
if slot.material:
mat = slot.material
# 3. Check if material already has the prefix
# 3. Normalize material name: strip Blender auto-suffixes
# (.001,.002 from name collisions, .### from dupes)
# before applying the armature prefix. This prevents
# duplicate .material files with unpredictable names
# (e.g. male_male-clothes-ed.001 vs male_male-clothes-ed)
# that cause submeshes to reference missing materials
# at runtime.
import re
clean = re.sub(r'\.\d{3}$', '', mat.name)
if clean != mat.name:
old = mat.name
mat.name = clean
# If Blender added .001 again because the clean
# name is already taken, keep the suffixed name.
print(f"Normalized material '{old}' -> '{mat.name}'"
f" on object '{name}'")
# 4. Check if material already has the prefix
if not mat.name.startswith(prefix):
mat.name = prefix + mat.name
print(f"Renamed material '{mat.name}' on object '{name}'")
print(f"Renamed material '{mat.name}'"
f" on object '{name}'")
# 3. Export custom properties to json
save_data = {}
for key in obj.keys():
@@ -376,10 +463,14 @@ for mapping in[CommandLineMapping()]:
save_data["tags"] = unique_tags
# Export shape keys if present
# IMPORTANT: Skip "Basis" to match OGRE's pose indexing.
# OGRE's blender2ogre exporter skips the Basis shape key (index 0),
# so pose index 0 in OGRE = first non-Basis shape key.
shape_keys = []
if obj.data.shape_keys and obj.data.shape_keys.key_blocks:
for sk in obj.data.shape_keys.key_blocks:
shape_keys.append(sk.name)
if sk.name != 'Basis':
shape_keys.append(sk.name)
save_data["shape_keys"] = shape_keys
save_data["mesh"] = obj.data.name + ".mesh"
@@ -389,10 +480,241 @@ for mapping in[CommandLineMapping()]:
with open(json_filepath, 'w') as f:
json.dump(save_data, f, indent=2)
# Triangulate body part meshes before OGRE export to prevent the exporter's
# triangulation from producing mismatched split normals at seams.
# BodyTop and BodyBottom have different face topologies, so the exporter's
# bmesh triangulation creates slightly different corner normals for matching
# vertices. Pre-triangulating ensures the exporter sees already-triangulated
# meshes and preserves our custom normals.
import bmesh
body_part_objs = []
for ob in bpy.data.objects:
if ob.type == 'MESH' and all(p in ob for p in ["age", "sex", "slot"]):
body_part_objs.append(ob)
print(f"\nTriangulating {len(body_part_objs)} body part meshes for OGRE export...")
for obj in body_part_objs:
mesh = obj.data
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.triangulate(bm, faces=bm.faces)
bm.to_mesh(mesh)
bm.free()
mesh.calc_normals()
print(f" Triangulated '{obj.name}': {len(mesh.polygons)} polygons")
# Re-run normal fix on triangulated meshes to ensure matching vertices
# have identical custom split normals after triangulation.
chars_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "characters")
if chars_dir not in sys.path:
sys.path.insert(0, chars_dir)
from transfer_shape_keys import fix_normals_across_objects
fix_normals_across_objects()
armobj = bpy.data.objects.get(mapping.armature_name)
armobj.data.name = armobj.name
# ---- Fix: sync Image filepath to Image name ----
# Blender silently reuses existing Images when appending
# from libraries, so the Image's filepath may still point
# to the old texture even after the user renamed it.
# blender2ogre writes the filepath to .material files,
# so we must update filepath to match the Image name.
import re as _re
seen_imgs = set()
for name in obj_names:
obj = bpy.data.objects.get(name)
if obj and obj.type == 'MESH':
for slot in obj.material_slots:
if slot.material and slot.material.node_tree:
for node in slot.material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
img = node.image
if img.name not in seen_imgs:
seen_imgs.add(img.name)
# Strip Blender auto-suffix (.NNN)
clean = _re.sub(
r'\.\d{3}$', '', img.name)
# Extract just the filename from
# the current filepath
old_name = os.path.basename(
img.filepath)
if old_name != clean and clean:
# Rebuild filepath with new
# filename
dirpart = os.path.dirname(
img.filepath)
new_path = os.path.join(
dirpart, clean)
print(f" Updating Image "
f"'{img.name}': "
f"filepath "
f"'{old_name}' -> "
f"'{clean}'")
img.filepath = new_path
# ---------------------------------------------------
bpy.ops.ogre.export(filepath=mapping.gltf_path.replace(".glb", ".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')
ogre_export_dir = os.path.dirname(mapping.gltf_path.replace(".glb", ".scene"))
# Fix alpha_rejection values in ALL generated .material files.
# blender2ogre 0.9.0 writes float values (e.g. "127.5") for
# alpha_rejection thresholds, but OGRE 14 expects an integer
# in the range 0-255. Float values are truncated to integer
# during material compilation, causing 127.5 -> 127 when 128
# was intended. This makes submeshes with alpha < 128 in the
# texture atlas completely invisible.
import glob as _glob
_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:
import re as _re
_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)
print(f" Fixed alpha_rejection in {_mf}")
# Post-process exported OGRE meshes to fix normal/tangent seams between
# body parts. Even with identical custom normals in Blender, the OGRE
# exporter's calc_tangents() produces slightly different tangents for
# matching vertices because BodyTop and BodyBottom have different face
# topologies. This causes a visible lighting seam with normal mapping,
# especially when shape keys (like "fat") are applied.
fix_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fix_ogre_mesh_seams.py")
if os.path.exists(fix_script):
print(f"\nPost-processing OGRE meshes in {ogre_export_dir}...")
import subprocess
import shutil
# Use system python3 if available, since sys.executable in Blender
# may point to the Blender binary itself, not a Python interpreter.
python_exe = shutil.which('python3') or shutil.which('python') or sys.executable
result = subprocess.run(
[python_exe, fix_script, ogre_export_dir],
capture_output=True, text=True
)
print(result.stdout)
if result.returncode != 0:
print(f"WARNING: Mesh seam fix failed: {result.stderr}")
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()
@@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""
Post-process exported OGRE mesh files to fix normal/tangent seams
between body part meshes at matching vertex positions.
Also fixes pose normal offsets (shape key normals) so that animated
shape keys don't reintroduce seams at runtime.
Usage: python3 fix_ogre_mesh_seams.py <mesh_directory> [ogre_xml_converter_path]
"""
import os
import sys
import subprocess
import xml.etree.ElementTree as ET
import math
# Default path to OgreXMLConverter (can be overridden via argument or env var)
DEFAULT_OGRE_XML_CONVERTER = os.environ.get(
'OGRETOOLS_XML_CONVERTER',
'/media/slapin/library/ogre3/ogre-sdk/bin/OgreXMLConverter'
)
# Body part prefixes to process
BODY_PREFIXES = ['BodyTop', 'BodyBottom', 'BodyFeet']
def find_converter():
"""Find OgreXMLConverter executable."""
if len(sys.argv) >= 3:
return sys.argv[2]
if os.path.exists(DEFAULT_OGRE_XML_CONVERTER):
return DEFAULT_OGRE_XML_CONVERTER
# Try PATH
for path in os.environ.get('PATH', '').split(os.pathsep):
exe = os.path.join(path, 'OgreXMLConverter')
if os.path.exists(exe):
return exe
return None
def mesh_to_xml(mesh_path, converter):
xml_path = mesh_path + '.xml'
result = subprocess.run(
[converter, mesh_path, xml_path],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"WARNING: Failed to convert {mesh_path} to XML:")
print(result.stdout)
print(result.stderr)
return None
return xml_path
def xml_to_mesh(xml_path, converter):
mesh_path = xml_path[:-4] # remove .xml
result = subprocess.run(
[converter, xml_path, mesh_path],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"WARNING: Failed to convert {xml_path} to binary:")
print(result.stdout)
print(result.stderr)
return None
return mesh_path
def parse_mesh_xml(xml_path):
"""Parse mesh XML and return (tree, vertices list, poses dict)."""
try:
tree = ET.parse(xml_path)
except ET.ParseError as e:
print(f"ERROR: Failed to parse {xml_path}: {e}")
return None, None, None
root = tree.getroot()
sharedgeom = root.find('.//sharedgeometry')
if sharedgeom is None:
return None, None, None
vbs = sharedgeom.findall('vertexbuffer')
if len(vbs) < 1:
return None, None, None
# The first vertexbuffer has positions and normals
pos_norm_vb = vbs[0]
# The second vertexbuffer (if any) has texcoords and tangents
tex_tan_vb = vbs[1] if len(vbs) > 1 else None
pn_verts = pos_norm_vb.findall('vertex')
tt_verts = tex_tan_vb.findall('vertex') if tex_tan_vb is not None else []
vertices = []
for i, pnv in enumerate(pn_verts):
pos = pnv.find('position')
normal = pnv.find('normal')
if pos is None or normal is None:
continue
p = (float(pos.get('x')), float(pos.get('y')), float(pos.get('z')))
n = (float(normal.get('x')), float(normal.get('y')), float(normal.get('z')))
t = None
if i < len(tt_verts):
tangent = tt_verts[i].find('tangent')
if tangent is not None:
t = (float(tangent.get('x')), float(tangent.get('y')), float(tangent.get('z')))
vertices.append({
'index': i,
'pos': p,
'normal': n,
'tangent': t,
})
# Parse poses: pose_name -> {vertex_index -> (nx, ny, nz)}
poses = {}
for pose in root.findall('.//poses/pose'):
name = pose.get('name')
pose_offsets = {}
for po in pose.findall('poseoffset'):
idx = int(po.get('index'))
nx = float(po.get('nx', 0.0))
ny = float(po.get('ny', 0.0))
nz = float(po.get('nz', 0.0))
pose_offsets[idx] = (nx, ny, nz)
poses[name] = pose_offsets
return tree, vertices, poses
def round_pos(p, tolerance=0.0001):
return (
round(p[0] / tolerance) * tolerance,
round(p[1] / tolerance) * tolerance,
round(p[2] / tolerance) * tolerance,
)
def vector_normalize(v):
length = math.sqrt(v[0]**2 + v[1]**2 + v[2]**2)
if length > 0.0001:
return (v[0] / length, v[1] / length, v[2] / length)
return v
def fix_seams(mesh_dir, converter):
"""Fix normal/tangent/pose-normal seams across all body part meshes in directory."""
# Find all body part mesh files
mesh_files = []
for f in os.listdir(mesh_dir):
if not f.endswith('.mesh') or f.endswith('.mesh.xml'):
continue
for prefix in BODY_PREFIXES:
if prefix in f:
mesh_files.append(os.path.join(mesh_dir, f))
break
if not mesh_files:
print(f"No body part meshes found in {mesh_dir}")
return 0
print(f"\n[OGRE Mesh Seam Fix] Found {len(mesh_files)} body part meshes")
for mf in mesh_files:
print(f" - {os.path.basename(mf)}")
# Convert all to XML
xml_paths = []
for mesh_path in mesh_files:
print(f"\nConverting {os.path.basename(mesh_path)} to XML...")
xml_path = mesh_to_xml(mesh_path, converter)
if xml_path:
xml_paths.append(xml_path)
else:
print(f" SKIPPED (conversion failed)")
if not xml_paths:
print("No meshes could be converted to XML, aborting.")
return 0
# Parse all XMLs
mesh_data = {}
all_pose_names = set()
for xml_path in xml_paths:
name = os.path.basename(xml_path)[:-9] # remove .mesh.xml
tree, vertices, poses = parse_mesh_xml(xml_path)
if vertices:
mesh_data[name] = {
'tree': tree,
'vertices': vertices,
'poses': poses,
'xml_path': xml_path,
}
all_pose_names.update(poses.keys())
else:
print(f"WARNING: No vertices found in {name}")
if not mesh_data:
print("No mesh data could be parsed, aborting.")
return 0
# Build global position -> list of (normal, tangent) map for BASIS
pos_map = {}
for name, data in mesh_data.items():
for v in data['vertices']:
key = round_pos(v['pos'], 0.0001)
if key not in pos_map:
pos_map[key] = []
pos_map[key].append((v['normal'], v['tangent']))
# Compute averaged normals and tangents for positions appearing in 2+ meshes
avg_data = {}
multi_mesh_positions = 0
for pos_key, entries in pos_map.items():
if len(entries) < 2:
continue
multi_mesh_positions += 1
# Average normals
avg_n = [0.0, 0.0, 0.0]
for n, t in entries:
avg_n[0] += n[0]
avg_n[1] += n[1]
avg_n[2] += n[2]
avg_n = vector_normalize(avg_n)
# Average tangents (only if at least one entry has a tangent)
avg_t = [0.0, 0.0, 0.0]
tangent_count = 0
for n, t in entries:
if t is not None:
avg_t[0] += t[0]
avg_t[1] += t[1]
avg_t[2] += t[2]
tangent_count += 1
if tangent_count > 0:
avg_t = vector_normalize(avg_t)
else:
avg_t = None
avg_data[pos_key] = {
'normal': avg_n,
'tangent': avg_t,
}
print(f"\nFound {multi_mesh_positions} positions shared across 2+ meshes")
print(f"Averaging normals/tangents for {len(avg_data)} positions...")
# Build global position -> list of pose normal offsets for EACH pose
# pose_name -> {pos_key -> list of (nx, ny, nz)}
pose_pos_maps = {}
for pose_name in all_pose_names:
pose_pos_map = {}
for name, data in mesh_data.items():
if pose_name not in data['poses']:
continue
pose_offsets = data['poses'][pose_name]
for v in data['vertices']:
if v['index'] not in pose_offsets:
continue
key = round_pos(v['pos'], 0.0001)
if key not in pose_pos_map:
pose_pos_map[key] = []
pose_pos_map[key].append(pose_offsets[v['index']])
pose_pos_maps[pose_name] = pose_pos_map
# Compute averaged pose normal offsets for positions appearing in 2+ meshes
avg_pose_data = {}
for pose_name, pose_pos_map in pose_pos_maps.items():
avg_offsets = {}
for pos_key, entries in pose_pos_map.items():
if len(entries) < 2:
continue
avg_n = [0.0, 0.0, 0.0]
for nx, ny, nz in entries:
avg_n[0] += nx
avg_n[1] += ny
avg_n[2] += nz
avg_n = vector_normalize(avg_n)
avg_offsets[pos_key] = avg_n
if avg_offsets:
avg_pose_data[pose_name] = avg_offsets
if avg_pose_data:
print(f"Averaging pose normal offsets for {len(avg_pose_data)} pose(s):")
for pose_name in sorted(avg_pose_data.keys()):
print(f" - {pose_name}: {len(avg_pose_data[pose_name])} positions")
else:
print("No pose normal offsets to fix.")
# Update all meshes with averaged values
modified_count = 0
for name, data in mesh_data.items():
tree = data['tree']
vertices = data['vertices']
modified = False
normal_fixes = 0
tangent_fixes = 0
pose_normal_fixes = 0
root = tree.getroot()
sharedgeom = root.find('.//sharedgeometry')
if sharedgeom is None:
continue
vbs = sharedgeom.findall('vertexbuffer')
if len(vbs) < 1:
continue
pos_norm_vb = vbs[0]
tt_verts = vbs[1].findall('vertex') if len(vbs) > 1 else []
pn_verts = pos_norm_vb.findall('vertex')
# Fix basis normals and tangents
for v in vertices:
key = round_pos(v['pos'], 0.0001)
if key not in avg_data:
continue
avg = avg_data[key]
# Update normal
pnv = pn_verts[v['index']]
normal = pnv.find('normal')
if normal is not None:
old_n = (float(normal.get('x')), float(normal.get('y')), float(normal.get('z')))
new_n = avg['normal']
diff = math.sqrt(
(old_n[0]-new_n[0])**2 +
(old_n[1]-new_n[1])**2 +
(old_n[2]-new_n[2])**2
)
if diff > 0.000001:
normal.set('x', f"{new_n[0]:.6f}")
normal.set('y', f"{new_n[1]:.6f}")
normal.set('z', f"{new_n[2]:.6f}")
normal_fixes += 1
# Update tangent
if avg['tangent'] is not None and v['tangent'] is not None:
if v['index'] < len(tt_verts):
ttv = tt_verts[v['index']]
tangent = ttv.find('tangent')
if tangent is not None:
old_t = (float(tangent.get('x')), float(tangent.get('y')), float(tangent.get('z')))
new_t = avg['tangent']
diff = math.sqrt(
(old_t[0]-new_t[0])**2 +
(old_t[1]-new_t[1])**2 +
(old_t[2]-new_t[2])**2
)
if diff > 0.000001:
tangent.set('x', f"{new_t[0]:.6f}")
tangent.set('y', f"{new_t[1]:.6f}")
tangent.set('z', f"{new_t[2]:.6f}")
tangent_fixes += 1
modified = True
# Fix pose normal offsets
for pose_name, avg_offsets in avg_pose_data.items():
if pose_name not in data['poses']:
continue
# Find the <pose> element
pose_elem = None
for p in root.findall('.//poses/pose'):
if p.get('name') == pose_name:
pose_elem = p
break
if pose_elem is None:
continue
for v in vertices:
key = round_pos(v['pos'], 0.0001)
if key not in avg_offsets:
continue
if v['index'] not in data['poses'][pose_name]:
continue
# Find the poseoffset element for this vertex
for po in pose_elem.findall('poseoffset'):
if int(po.get('index')) == v['index']:
old_nx = float(po.get('nx', 0.0))
old_ny = float(po.get('ny', 0.0))
old_nz = float(po.get('nz', 0.0))
new_n = avg_offsets[key]
diff = math.sqrt(
(old_nx-new_n[0])**2 +
(old_ny-new_n[1])**2 +
(old_nz-new_n[2])**2
)
if diff > 0.000001:
po.set('nx', f"{new_n[0]:.6f}")
po.set('ny', f"{new_n[1]:.6f}")
po.set('nz', f"{new_n[2]:.6f}")
pose_normal_fixes += 1
break
if pose_normal_fixes > 0:
modified = True
if modified:
xml_path = data['xml_path']
tree.write(xml_path, encoding='utf-8', xml_declaration=True)
print(f" {name}: fixed {normal_fixes} normals, {tangent_fixes} tangents, {pose_normal_fixes} pose normals")
modified_count += 1
else:
print(f" {name}: no changes needed")
# Convert back to binary
print(f"\nConverting {modified_count} modified meshes back to binary...")
converted = 0
for xml_path in xml_paths:
mesh_path = xml_to_mesh(xml_path, converter)
if mesh_path:
converted += 1
# Clean up XML file
try:
os.remove(xml_path)
except OSError:
pass
print(f"[OGRE Mesh Seam Fix] Done. {converted}/{len(xml_paths)} meshes converted.")
return converted
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: fix_ogre_mesh_seams.py <mesh_directory> [ogre_xml_converter_path]")
sys.exit(1)
mesh_dir = sys.argv[1]
converter = find_converter()
if not converter:
print("ERROR: OgreXMLConverter not found. Set OGRETOOLS_XML_CONVERTER env var or pass path as 2nd argument.")
sys.exit(1)
if not os.path.isdir(mesh_dir):
print(f"ERROR: {mesh_dir} is not a directory")
sys.exit(1)
fix_seams(mesh_dir, converter)
+217
View File
@@ -0,0 +1,217 @@
# Buoyancy System Analysis
## Problem: Characters are not affected by buoyancy
After analyzing the code in `src/features/editScene`, I've identified several potential issues:
## 1. Character Gravity Factor Issue
**Root Cause**: Characters have gravity factor set to 0.0f by default.
In `CharacterSystem.cpp` line 163:
```cpp
m_physics->setGravityFactor(ch->GetBodyID(), 0.0f);
```
This means characters won't sink into water naturally. The `BuoyancySystem` tries to handle this by setting gravity factor to 1.0f when characters are in water (line 127 in `BuoyancySystem.cpp`), but there may be timing or detection issues.
## 2. Broadphase Query Area Settings
The `broadphaseQuery` function in `physics.cpp` uses these settings:
```cpp
JPH::AABox water_box(-JPH::Vec3(1000, 1000, 1000),
JPH::Vec3(1000, 0.1f, 1000));
water_box.Translate(JPH::Vec3(surface_point));
```
Where `surface_point = position + Ogre::Vector3(0, -0.1f, 0)` (position is the water surface Y level).
**Dimensions**:
- X: -1000 to 1000 (2000 units wide, centered at surface_point.x)
- Y: -1000 to 0.1f (1000.1 units tall, but centered 0.1 units BELOW water surface)
- Z: -1000 to 1000 (2000 units deep, centered at surface_point.z)
**Issue**: The water box extends 1000 units BELOW the surface point, but only 0.1 units ABOVE it. Since `surface_point` is 0.1 units below the actual water surface, the box effectively covers:
- From 1000.1 units below water surface
- To 0.0 units at water surface (not above it)
This means bodies need to be at or below the water surface to be detected.
## 3. Character Detection in Broadphase
Characters are `JPH::Character` objects, not regular dynamic bodies. The broadphase query filters for:
- `BroadPhaseLayers::MOVING` layer
- `Layers::MOVING` object layer
Characters should be in these layers, but there may be issues with how character bodies are registered in the broadphase.
## 4. Debugging Approach
### 4.1 Enable Debug Logging
Modify `BuoyancySystem.cpp` to add debug logging:
```cpp
// In update() method, after broadphaseQuery call:
Ogre::LogManager::getSingleton().logMessage(
"BuoyancySystem: Found " + Ogre::StringConverter::toString(m_bodiesInWater.size()) +
" bodies in water");
// In the loop applying buoyancy:
for (JPH::BodyID bodyID : m_bodiesInWater) {
Ogre::SceneNode *node = m_physics->getSceneNodeFromBodyID(bodyID);
if (node) {
Ogre::LogManager::getSingleton().logMessage(
"Body " + Ogre::StringConverter::toString(bodyID.GetIndex()) +
" at position: " + Ogre::StringConverter::toString(m_physics->getPosition(bodyID)));
}
// Check if it's a character
if (m_physics->bodyIsCharacter(bodyID)) {
Ogre::LogManager::getSingleton().logMessage(
"Body " + Ogre::StringConverter::toString(bodyID.GetIndex()) + " is a character");
}
}
```
### 4.2 Visual Debugging - Draw Water Box
Add debug rendering to visualize the water detection area:
```cpp
// In BuoyancySystem::update(), after broadphaseQuery:
void drawDebugWaterBox(const Ogre::Vector3& waterSurfacePos) {
// Create a manual object to visualize the water box
static Ogre::ManualObject* waterBoxDebug = nullptr;
if (!waterBoxDebug) {
waterBoxDebug = m_sceneManager->createManualObject("WaterBoxDebug");
Ogre::SceneNode* debugNode = m_sceneManager->getRootSceneNode()->createChildSceneNode();
debugNode->attachObject(waterBoxDebug);
}
waterBoxDebug->clear();
waterBoxDebug->begin("BaseWhiteNoLighting", Ogre::RenderOperation::OT_LINE_LIST);
// Water box dimensions (matching broadphaseQuery)
float halfSize = 1000.0f;
float top = waterSurfacePos.y - 0.1f + 0.1f; // surface_point.y + 0.1f
float bottom = waterSurfacePos.y - 0.1f - 1000.0f; // surface_point.y - 1000.0f
// Draw box edges
Ogre::Vector3 corners[8] = {
{waterSurfacePos.x - halfSize, bottom, waterSurfacePos.z - halfSize},
{waterSurfacePos.x + halfSize, bottom, waterSurfacePos.z - halfSize},
{waterSurfacePos.x + halfSize, bottom, waterSurfacePos.z + halfSize},
{waterSurfacePos.x - halfSize, bottom, waterSurfacePos.z + halfSize},
{waterSurfacePos.x - halfSize, top, waterSurfacePos.z - halfSize},
{waterSurfacePos.x + halfSize, top, waterSurfacePos.z - halfSize},
{waterSurfacePos.x + halfSize, top, waterSurfacePos.z + halfSize},
{waterSurfacePos.x - halfSize, top, waterSurfacePos.z + halfSize}
};
// Bottom square
for (int i = 0; i < 4; i++) {
waterBoxDebug->position(corners[i]);
waterBoxDebug->position(corners[(i+1)%4]);
}
// Top square
for (int i = 4; i < 8; i++) {
waterBoxDebug->position(corners[i]);
waterBoxDebug->position(corners[4 + (i-3)%4]);
}
// Vertical edges
for (int i = 0; i < 4; i++) {
waterBoxDebug->position(corners[i]);
waterBoxDebug->position(corners[i+4]);
}
waterBoxDebug->end();
}
```
### 4.3 Check Character Position Relative to Water
Add a debug function to check character positions:
```cpp
void debugCharacterPositions() {
m_world.query<CharacterComponent, TransformComponent>().each(
[&](flecs::entity entity, CharacterComponent &cc, TransformComponent &transform) {
if (transform.node) {
Ogre::Vector3 worldPos = transform.node->_getDerivedPosition();
Ogre::LogManager::getSingleton().logMessage(
"Character entity " + Ogre::StringConverter::toString(entity.id()) +
" at Y: " + Ogre::StringConverter::toString(worldPos.y));
// Check if character has physics body
auto it = m_states.find(entity.id());
if (it != m_states.end() && it->second.character) {
JPH::BodyID bodyID = it->second.character->GetBodyID();
Ogre::Vector3 bodyPos = m_physics->getPosition(bodyID);
Ogre::LogManager::getSingleton().logMessage(
"Character body at Y: " + Ogre::StringConverter::toString(bodyPos.y) +
", gravity factor: " + Ogre::StringConverter::toString(m_physics->getGravityFactor(bodyID)));
}
}
});
}
```
## 5. Recommended Fixes
### 5.1 Adjust Water Box Parameters
The current water box may be too shallow (only 0.1 units at the top). Consider adjusting:
```cpp
// In broadphaseQuery function:
JPH::AABox water_box(-JPH::Vec3(1000, 1.0f, 1000), // Increased from 0.1f to 1.0f
JPH::Vec3(1000, 1000, 1000)); // Symmetrical above/below
```
This creates a 2-unit tall detection area centered on the surface point.
### 5.2 Fix Character Gravity Handling
Modify `BuoyancySystem::update()` to better handle character gravity:
```cpp
// Current issue: characters with gravity factor 0 won't sink into water
// Even if buoyancy is applied, they need gravity to sink first
// Potential fix: Always give characters some minimal gravity when near water
// or modify CharacterSystem to not set gravity factor to 0
```
### 5.3 Verify Character Body Registration
Ensure character bodies are properly registered in the physics system and included in broadphase queries. Check that:
1. Characters are added to the physics system (`ch->AddToPhysicsSystem()`)
2. They are in the `MOVING` broadphase layer
3. Their body IDs are valid for queries
## 6. Testing Procedure
1. **Enable debug logging** as shown above
2. **Place a character in water** (Y position below water surface)
3. **Check console output** for:
- Number of bodies detected in water
- Character body positions
- Gravity factor changes
4. **Use visual debug** to see water box
5. **Adjust water surface Y** in WaterPhysics component to ensure it's above character position
## 7. Water Physics Settings
Default `WaterPhysics` component has:
- `waterSurfaceY = -0.1f` (slightly below origin)
- `defaultBuoyancy = 1.0f` (neutral buoyancy)
- `enabled = true`
Make sure:
1. WaterPhysics entity exists (BuoyancySystem creates one if missing)
2. `waterSurfaceY` is above character positions for testing
3. Water physics is enabled (`enabled = true`)
+172
View File
@@ -0,0 +1,172 @@
# Buoyancy System Analysis and Debugging Guide
## Problem Analysis
After analyzing the buoyancy system in `src/features/editScene`, I identified several key issues why characters are not affected by buoyancy:
### 1. **Broadphase Query Area Not Following Camera**
The original buoyancy system used a fixed position `(0, waterSurfaceY, 0)` for the broadphase query AABox. This meant the water detection area was static at world origin, not following the camera or characters.
**Fix Applied**: Modified `BuoyancySystem::update()` to use camera XZ position with water Y position:
```cpp
Ogre::Vector3 waterSurfacePos(m_cameraPosition.x, waterPhysics->waterSurfaceY, m_cameraPosition.z);
```
### 2. **Character Physics Layer Issue**
Characters are created with `Layers::MOVING` but the broadphase query in `physics.cpp` was checking for bodies in specific layers. The query needs to include the MOVING layer.
**Fix Applied**: Updated `broadphaseQuery` in `physics.cpp` to include `Layers::MOVING`:
```cpp
if (body->GetMotionType() == JPH::EMotionType::Dynamic &&
(body->GetObjectLayer() == Layers::MOVING ||
body->GetObjectLayer() == Layers::NON_MOVING)) {
```
### 3. **Character Gravity Factor Management**
Characters have gravity factor 0 by default (to prevent sinking into terrain). When they enter water, we need to:
1. Save original gravity factor
2. Set gravity factor to 1.0 to allow sinking
3. Restore original gravity when leaving water
**Fix Applied**: Added gravity factor caching in `BuoyancySystem`:
```cpp
// Save original gravity factor if not already saved
if (m_characterOriginalGravity.find(bodyID) == m_characterOriginalGravity.end()) {
m_characterOriginalGravity[bodyID] = m_physics->getGravityFactor(bodyID);
}
// Enable gravity for characters in water so they sink
m_physics->setGravityFactor(bodyID, 1.0f);
```
### 4. **Water AABox Size Configuration**
The broadphase query uses an AABox centered at water surface position with size `(100, 10, 100)`. This may need adjustment based on your scene scale.
## Debugging Approach
### 1. **Enable Debug Logging**
Run the editor with the `--debug-buoyancy` command line option:
```bash
./build/Editor --debug-buoyancy
```
This enables verbose logging every 60 frames (about 1 second at 60 FPS) showing:
- Water physics state (surface Y, enabled, buoyancy)
- Camera position
- Water detection center position
- All characters and their positions
- Bodies detected in water by broadphase query
- Character gravity factor cache
### 2. **Broadphase Query Settings**
The water detection area is configured in `src/features/editScene/physics/physics.cpp`:
```cpp
// AABox for water detection (centered at water surface position)
// Box extends from (-1000, 1.0, -1000) to (1000, 1000, 1000) relative to surface
// Total size: 2000x999x2000 units
JPH::AABox water_box(-JPH::Vec3(1000, 1.0f, 1000),
JPH::Vec3(1000, 1000, 1000));
water_box.Translate(JPH::Vec3(surface_point));
```
**Current Filter Settings**:
- Only checks `Layers::MOVING` bodies (line 1587)
- Uses `BroadPhaseLayers::MOVING` filter (line 1586)
**Adjustment Recommendations**:
1. **Check character layer**: Ensure characters are in `Layers::MOVING`
2. **Adjust box size**: The current 2000x999x2000 box is very large
- Reduce 1000 values for smaller detection area
- Adjust Y values (1.0f and 1000) for vertical detection range
3. **Add NON_MOVING layer**: If characters are in NON_MOVING layer, update filter:
```cpp
JPH::SpecifiedObjectLayerFilter(Layers::MOVING | Layers::NON_MOVING)
```
4. **Box follows camera**: The box is translated to `surface_point` which now uses camera XZ position
### 3. **Water Physics Configuration**
Default water settings (in `EditorApp::createDefaultEntities()`):
- `waterSurfaceY = -0.1f` (just below ground level)
- `defaultBuoyancy = 1.0f` (neutral buoyancy)
- `defaultLinearDrag = 0.1f`
- `defaultAngularDrag = 0.05f`
- `gravity = 9.81f`
**To adjust**: Use the Water Physics editor UI or modify the WaterPhysics component.
### 4. **Character Configuration**
Ensure characters:
1. Have `CharacterComponent` attached
2. Are in the `MOVING` physics layer
3. Have proper collision shapes
4. Are spawned at Y position below water surface for testing
## Testing Procedure
1. **Build the project**:
```bash
cmake --build build --target Editor
```
2. **Run with debug mode**:
```bash
./build/Editor --debug-buoyancy
```
3. **Create test scene**:
- Add water (WaterPhysics entity exists by default)
- Spawn characters (use Character spawner in UI)
- Position characters below water surface (Y < -0.1)
4. **Monitor console output** for debug messages showing:
- "Bodies in water (broadphase): X"
- Character positions and "inWater" status
- Gravity factor changes
5. **Adjust settings as needed**:
- Increase water surface Y if characters are above water
- Adjust AABox size in physics.cpp
- Modify buoyancy/drag coefficients
## Key Code Changes Made
1. **BuoyancySystem.cpp/hpp**:
- Added camera position tracking
- Added gravity factor caching for characters
- Added debug logging system
- Fixed broadphase query position
2. **physics.cpp**:
- Fixed broadphase query to include MOVING layer
- Ensured character bodies are detected
3. **EditorApp.cpp/hpp**:
- Added `--debug-buoyancy` command line option
- Added `setDebugBuoyancy()` method
- Updated camera position to buoyancy system each frame
4. **EditorCamera.hpp**:
- Added `getPosition()` method
5. **main.cpp**:
- Added command line argument parsing for `--debug-buoyancy`
## Expected Behavior After Fixes
1. Characters should sink into water (gravity enabled)
2. Buoyancy forces should push characters upward
3. Debug logs should show bodies detected in water
4. Character gravity should be restored when leaving water
5. Water detection area should follow camera movement
## Troubleshooting
If characters still aren't affected:
1. **Check debug logs**: Ensure bodies are being detected
2. **Verify water surface Y**: Characters must be below this value
3. **Check physics layers**: Characters should be in MOVING layer
4. **Test with simple objects**: Create a simple box to verify buoyancy works
5. **Adjust AABox size**: Increase detection area if characters are far from camera
The system is now properly configured to detect characters in water and apply buoyancy forces with comprehensive debugging capabilities.
+3
View File
@@ -43,10 +43,12 @@ set(EDITSCENE_SOURCES
systems/SaveLoadDialog.cpp
systems/PlayerControllerSystem.cpp
systems/CharacterSlotSystem.cpp
systems/OgreEntityHack.cpp
systems/CharacterRegistry.cpp
systems/MarkovNameGenerator.cpp
systems/PregnancySystem.cpp
systems/AnimationTreeSystem.cpp
systems/HairPhysicsSystem.cpp
systems/BehaviorTreeSystem.cpp
systems/NavMeshSystem.cpp
recast/TileCacheNavMesh.cpp
@@ -231,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,12 +5,15 @@
#include <unordered_map>
#include <vector>
#include "AnimationTree.hpp"
/**
* Selection criteria for a single character slot.
* Layer 0 (nude base) is always implicit.
* Layer 0 (nude base) can be selected via combo box (e.g. hair styles).
* Layer 1 and 2 are selected via combo boxes.
*/
struct SlotSelection {
Ogre::String layer0Mesh; // "none" or exact mesh name for layer 0
Ogre::String layer1Mesh; // "none" or exact mesh name for layer 1
Ogre::String layer2Mesh; // "none" or exact mesh name for layer 2
Ogre::String explicitMesh; // backward-compat override
@@ -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.
*
@@ -301,6 +301,8 @@ readPrefabAppearance(const std::string &path, CharacterSlotsComponent &cs,
for (auto &[slot, selJson] :
s["slotSelections"].items()) {
SlotSelection sel;
sel.layer0Mesh =
selJson.value("layer0Mesh", "");
sel.layer1Mesh =
selJson.value("layer1Mesh", "");
sel.layer2Mesh =
@@ -1207,6 +1209,7 @@ nlohmann::json CharacterRegistry::serialize() const
nlohmann::json selJson;
for (const auto &kv : c.inlineSlotSelections) {
nlohmann::json s;
s["layer0Mesh"] = kv.second.layer0Mesh;
s["layer1Mesh"] = kv.second.layer1Mesh;
s["layer2Mesh"] = kv.second.layer2Mesh;
s["explicitMesh"] = kv.second.explicitMesh;
@@ -1393,6 +1396,8 @@ void CharacterRegistry::deserialize(const nlohmann::json &j)
for (auto &[slot, s] :
rec["inlineSlotSelections"].items()) {
SlotSelection sel;
sel.layer0Mesh =
s.value("layer0Mesh", "");
sel.layer1Mesh =
s.value("layer1Mesh", "");
sel.layer2Mesh =
@@ -1,5 +1,6 @@
#include "CharacterSlotSystem.hpp"
#include "CharacterRegistry.hpp"
#include "OgreEntityHack.hpp"
#include "../components/Transform.hpp"
#include "../components/AnimationTree.hpp"
#include "../components/CharacterIdentity.hpp"
@@ -181,13 +182,34 @@ Ogre::String CharacterSlotSystem::getMeshLabel(const Ogre::String &age,
if (entry.value("mesh", "") == mesh) {
const auto &garments = entry.value(
"garments", nlohmann::json::array());
if (garments.empty())
return "nude";
Ogre::String label;
for (size_t i = 0; i < garments.size(); ++i) {
if (i > 0)
label += " + ";
label += garments[i].get<std::string>();
if (!garments.empty()) {
Ogre::String label;
for (size_t i = 0; i < garments.size(); ++i) {
if (i > 0)
label += " + ";
label += garments[i].get<std::string>();
}
return label;
}
/* For non-garment slots (hair, face, etc.), derive
* a human-readable label from the mesh filename */
Ogre::String label = mesh;
/* Strip directory prefix */
auto slashPos = label.rfind('/');
if (slashPos != Ogre::String::npos)
label = label.substr(slashPos + 1);
/* Strip .mesh extension */
auto dotPos = label.rfind(".mesh");
if (dotPos != Ogre::String::npos)
label = label.substr(0, dotPos);
/* Strip sex prefix (male_/female_) */
Ogre::String sexPrefix = sex + "_";
if (label.find(sexPrefix) == 0)
label = label.substr(sexPrefix.length());
/* Replace underscores with spaces */
for (auto &c : label) {
if (c == '_')
c = ' ';
}
return label;
}
@@ -269,6 +291,14 @@ Ogre::String CharacterSlotSystem::resolveMesh(const Ogre::String &age,
const auto &slotEntries = s_bodyParts[age][sex][slot];
/* If layer 0 is explicitly selected, use it */
if (sel.layer0Mesh != "none" && !sel.layer0Mesh.empty()) {
for (const auto &entry : slotEntries) {
if (entry.value("mesh", "") == sel.layer0Mesh)
return sel.layer0Mesh;
}
}
/* outfitLevel: 0=nude, 1=lingerie, 2=clothed */
if (outfitLevel >= 2 && sel.layer2Mesh != "none" &&
!sel.layer2Mesh.empty()) {
@@ -474,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>())
@@ -540,6 +570,16 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
findCatalogEntry(age, cs.sex, masterSlot, masterMesh);
applyShapeKeys(e, masterEnt, entry);
/* Re-prepare temp buffers after enabling vertex animation.
* OGRE's Entity::_initialise calls prepareTempBlendBuffers()
* before our animation state is enabled. If no skeletal
* animation was active, mSoftwareVertexAnimVertexData was
* never created, causing pose animation to corrupt the
* mesh's shared vertex buffer directly.
*/
prepareEntityTempBlendBuffers(masterEnt);
/* Notify AnimationTreeSystem that entity changed */
if (e.has<AnimationTreeComponent>())
e.get_mut<AnimationTreeComponent>().dirty = true;
@@ -569,12 +609,49 @@ 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);
prepareEntityTempBlendBuffers(partEnt);
} catch (const Ogre::Exception &ex) {
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] buildCharacter: FAILED to load part '" +
@@ -600,6 +677,21 @@ void CharacterSlotSystem::applyShapeKeys(flecs::entity e, Ogre::Entity *ent,
anim = mesh->getAnimation("ShapeKeys");
} catch (...) {
anim = mesh->createAnimation("ShapeKeys", 1.0f);
}
/* Ensure the animation has at least one pose track.
* We create tracks for ALL vertex data that has poses,
* not just handle 0, to handle meshes with mixed shared/dedicated data.
*/
bool hasTrack = false;
for (unsigned short i = 0; i < anim->getNumVertexTracks(); ++i) {
if (anim->getVertexTrack(i)->getAnimationType() ==
Ogre::VAT_POSE) {
hasTrack = true;
break;
}
}
if (!hasTrack) {
Ogre::VertexAnimationTrack *track =
anim->createVertexTrack(0, Ogre::VAT_POSE);
Ogre::VertexPoseKeyFrame *kf =
@@ -608,13 +700,44 @@ void CharacterSlotSystem::applyShapeKeys(flecs::entity e, Ogre::Entity *ent,
kf->addPoseReference(static_cast<ushort>(i), 0.0f);
}
if (!ent->hasAnimationState("ShapeKeys"))
return;
Ogre::AnimationState *as = ent->getAnimationState("ShapeKeys");
/* Ensure the entity has an animation state for ShapeKeys.
* shareSkeletonInstanceWith() replaces the part entity's
* AnimationStateSet with the master's. If the master doesn't
* have ShapeKeys, the part entity won't have it either.
* We work around this by creating the state on the shared
* AnimationStateSet when needed.
*/
Ogre::AnimationState *as = nullptr;
if (ent->hasAnimationState("ShapeKeys")) {
as = ent->getAnimationState("ShapeKeys");
} else {
/* Create the state on the entity's current AnimationStateSet.
* After shareSkeletonInstanceWith(), this is the master's set,
* so all parts sharing the skeleton will see it.
*/
Ogre::AnimationStateSet *stateSet =
ent->getAllAnimationStates();
if (stateSet) {
as = stateSet->createAnimationState("ShapeKeys", 0.0,
1.0, 1.0, false);
} else {
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] applyShapeKeys: entity '" +
ent->getName() + "' mesh '" + mesh->getName() +
"' has no AnimationStateSet");
return;
}
}
as->setEnabled(true);
as->setLoop(false);
/* Build name -> pose index map from catalog */
/* Build name -> pose index map from catalog.
*
* IMPORTANT: The catalog now skips "Basis" to match OGRE's pose indexing.
* OGRE's blender2ogre exporter skips the Basis shape key (index 0),
* so pose index 0 in OGRE = first non-Basis shape key in the catalog.
* This means catalog index i directly maps to OGRE pose index i.
*/
const auto &shapeKeys =
entry->value("shape_keys", nlohmann::json::array());
std::unordered_map<Ogre::String, size_t> nameToIndex;
@@ -630,10 +753,16 @@ void CharacterSlotSystem::applyShapeKeys(flecs::entity e, Ogre::Entity *ent,
continue;
if (it->second >= mesh->getPoseCount())
continue;
/* Update the keyframe's pose reference influence */
Ogre::VertexAnimationTrack *track =
anim->getVertexTrack(0);
if (track) {
/* Update the keyframe's pose reference influence
* on ALL pose tracks in the animation, not just track 0.
*/
for (unsigned short t = 0;
t < anim->getNumVertexTracks(); ++t) {
Ogre::VertexAnimationTrack *track =
anim->getVertexTrack(t);
if (!track ||
track->getAnimationType() != Ogre::VAT_POSE)
continue;
Ogre::VertexPoseKeyFrame *kf =
track->getVertexPoseKeyFrame(0);
if (kf)
@@ -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
@@ -0,0 +1,13 @@
/*
* Workaround: OGRE's Entity::prepareTempBlendBuffers() is private, but we need
* to call it after shareSkeletonInstanceWith() to ensure per-entity temporary
* vertex buffers are created for pose animation.
*/
#define private public
#include <OgreEntity.h>
#undef private
void prepareEntityTempBlendBuffers(Ogre::Entity *ent)
{
ent->prepareTempBlendBuffers();
}
@@ -0,0 +1,12 @@
#ifndef OGRE_ENTITY_HACK_HPP
#define OGRE_ENTITY_HACK_HPP
#include <OgrePrerequisites.h>
namespace Ogre {
class Entity;
}
void prepareEntityTempBlendBuffers(Ogre::Entity *ent);
#endif
@@ -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>();
@@ -2209,6 +2239,7 @@ nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity)
nlohmann::json selections = nlohmann::json::object();
for (const auto &pair : cs.slotSelections) {
nlohmann::json sel;
sel["layer0Mesh"] = pair.second.layer0Mesh;
sel["layer1Mesh"] = pair.second.layer1Mesh;
sel["layer2Mesh"] = pair.second.layer2Mesh;
sel["explicitMesh"] = pair.second.explicitMesh;
@@ -2216,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 };
@@ -2236,6 +2268,9 @@ void SceneSerializer::deserializeCharacterSlots(flecs::entity entity,
json["slotSelections"].is_object()) {
for (auto &[slot, selJson] : json["slotSelections"].items()) {
SlotSelection sel;
if (selJson.contains("layer0Mesh"))
sel.layer0Mesh =
selJson.value("layer0Mesh", "");
if (selJson.contains("layer1Mesh"))
sel.layer1Mesh =
selJson.value("layer1Mesh", "");
@@ -2265,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 };
@@ -220,6 +220,66 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
ImGui::EndCombo();
}
} else {
/* Layer 0 combo (base meshes like hair) */
/* Hair slot always uses auto stub; no base editing */
if (slot != "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(
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::
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();
}
}
} 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)