diff --git a/assets/blender/characters/CMakeLists.txt b/assets/blender/characters/CMakeLists.txt index aa30c85..2f7ea16 100644 --- a/assets/blender/characters/CMakeLists.txt +++ b/assets/blender/characters/CMakeLists.txt @@ -459,41 +459,24 @@ function(add_shape_key_propagation INPUT_BLEND OUTPUT_BLEND) ) endfunction() -add_shape_key_propagation( - ${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-male.blend - ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-shapes.blend -) -add_shape_key_propagation( - ${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-female.blend - ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-shapes.blend -) - -# male set(SLOT_LIST "bottom" "top" "feet" "hair") -set(SLOT_INPUT "edited-normal-male-shapes.blend") list(GET SLOT_LIST -1 LAST_SLOT) -foreach (SLOT ${SLOT_LIST}) - set(SLOT_OUTPUT "edited-normal-male-consolidated-${SLOT}.blend") - if (SLOT STREQUAL LAST_SLOT) - set(SLOT_OUTPUT "edited-normal-male-consolidated.blend") - endif() - add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}" - "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-${SLOT}_weighted.blend" - "${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}" +# male and female clothes pipeline +foreach (SEX ${SEX_LIST}) + add_shape_key_propagation( + ${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${SEX}.blend + ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-shapes.blend ) - set(SLOT_INPUT ${SLOT_OUTPUT}) -endforeach() - -# female -set(SLOT_INPUT "edited-normal-female-shapes.blend") -foreach (SLOT ${SLOT_LIST}) - set(SLOT_OUTPUT "edited-normal-female-consolidated-${SLOT}.blend") - if (SLOT STREQUAL LAST_SLOT) - set(SLOT_OUTPUT "edited-normal-female-consolidated.blend") - endif() - add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}" - "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-${SLOT}_weighted.blend" - "${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}" - ) - set(SLOT_INPUT ${SLOT_OUTPUT}) + set(SLOT_INPUT "edited-normal-${SEX}-shapes.blend") + foreach (SLOT ${SLOT_LIST}) + set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated-${SLOT}.blend") + if (SLOT STREQUAL LAST_SLOT) + set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated.blend") + endif() + add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}" + "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-${SLOT}_weighted.blend" + "${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}" + ) + set(SLOT_INPUT ${SLOT_OUTPUT}) + endforeach() endforeach() diff --git a/assets/blender/characters/clothes-male-hair.blend b/assets/blender/characters/clothes-male-hair.blend index e6941b1..7121792 100644 --- a/assets/blender/characters/clothes-male-hair.blend +++ b/assets/blender/characters/clothes-male-hair.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0402b6bc9a2749bc5eb6ce4d932f719b24c0843654edcb08824c302753784173 -size 865899 +oid sha256:2b267783d2f0dc991c7edd972b6c21b344abf3b5a46737c664912feae0ca9549 +size 1212326 diff --git a/assets/blender/characters/combine_clothes.py b/assets/blender/characters/combine_clothes.py index 91cf204..b8dcabc 100644 --- a/assets/blender/characters/combine_clothes.py +++ b/assets/blender/characters/combine_clothes.py @@ -151,16 +151,19 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy= print(f"Processing: {clothing_name_for_final} -> {target_name} (using copy)") - # NEW CODE: Store custom properties from clothing before they might be lost + # Store custom properties from clothing before they might be lost clothing_props = {} - for prop_name in ["ref_shapes", "ref_ray_length"]: + for prop_name in ["ref_shapes", "ref_ray_length", + "has_own_armature", "own_armature_name"]: if prop_name in clothing_obj: clothing_props[prop_name] = clothing_obj[prop_name] print(f" Stored property '{prop_name}' = {clothing_obj[prop_name]} from clothing") # Also store metadata tags for export pipeline clothing_meta = {} - for prop_name in ["ref_layer", "ref_part", "garment_id", "tags", "age", "sex", "slot"]: + for prop_name in ["ref_layer", "ref_part", "garment_id", "tags", + "age", "sex", "slot", + "has_own_armature", "own_armature_name"]: if prop_name in clothing_obj: clothing_meta[prop_name] = clothing_obj[prop_name] print(f" Stored meta '{prop_name}' = {clothing_obj[prop_name]} from clothing") @@ -181,18 +184,31 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy= whitelist.add(master_arm) # Handle clothing armature (if it's not a copy for layer 2) + has_own_arm = clothing_obj.get("has_own_armature", False) if not is_clothing_copy and clothing_obj.parent and clothing_obj.parent.type == 'ARMATURE': old_arm = clothing_obj.parent clothing_obj.matrix_world = clothing_obj.matrix_world.copy() clothing_obj.parent = None - if old_arm not in whitelist: - bpy.data.objects.remove(old_arm, do_unlink=True) + # Keep hair armatures (objects with own skeleton) + if not has_own_arm: + if old_arm not in whitelist: + bpy.data.objects.remove(old_arm, do_unlink=True) + else: + whitelist.add(old_arm) # preserve hair skeleton + elif not is_clothing_copy and has_own_arm: + # Hair has own skeleton but parent is None (or not an armature); + # find the armature by name and preserve it. + own_arm_name = clothing_obj.get("own_armature_name", "") + old_arm = bpy.data.objects.get(own_arm_name) + if old_arm and old_arm.type == 'ARMATURE' and old_arm not in whitelist: + whitelist.add(old_arm) + print(f" Preserved hair armature '{old_arm.name}' by name for {clothing_name_for_final}") # For layer 2 clothing copies, we don't need to handle armature separately # as they'll inherit from the target # Reparent to master armature if exists - if master_arm: + if master_arm and not clothing_obj.get("has_own_armature", False): clothing_obj.parent = master_arm for mod in clothing_obj.modifiers: if mod.type == 'ARMATURE': @@ -210,11 +226,6 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy= bpy.ops.object.join() # ---- Fix: ensure clothing faces keep the clothing's material ---- - # After join, Blender may have assigned clothing faces to a - # pre-existing slot with the same base name but older texture - # references (loaded from the body blend). Find the slot that - # actually holds the clothing's original material data block - # and reassign any faces that landed in a stale look-alike slot. if clothing_mat: clothing_slot = None stale_slot = None @@ -238,15 +249,12 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy= f"{stale_slot} -> clothing slot {clothing_slot}") # ---------------------------------------------------------------- - # NEW CODE: Copy stored custom properties to combined object - # After joining, new_target is the active object and contains the combined mesh + # Copy stored custom properties to combined object for prop_name, prop_value in clothing_props.items(): new_target[prop_name] = prop_value print(f" Copied property '{prop_name}' = {prop_value} to combined object") - # NEW CODE: Aggregate clothing metadata onto combined object - # Collect from all clothing objects processed for this target - # Use JSON string since Blender ID properties don't support Python lists/sets + # Aggregate clothing metadata onto combined object if "_combined_meta" in new_target: meta = json.loads(new_target["_combined_meta"]) else: @@ -297,17 +305,14 @@ def run_batch_combine(): all_objs = bpy.data.objects # Separate body objects (no ref_layer property) - # Filter out rig control shapes (cs_*), helper objects, and objects with no vertices body_objects = [] skipped_objects = [] for o in all_objs: if o.type != 'MESH' or "ref_layer" in o: continue - # Skip rig control shapes and helper objects if o.name.startswith("cs_") or o.name.startswith("WGT-") or o.name.startswith("MCH-") or o.name.startswith("ORG-"): skipped_objects.append(o.name) continue - # Skip objects with no vertices (empty meshes) if len(o.data.vertices) == 0: skipped_objects.append(o.name) continue @@ -316,7 +321,6 @@ def run_batch_combine(): if skipped_objects: print(f"Skipped {len(skipped_objects)} non-body mesh objects: {', '.join(skipped_objects[:10])}{'...' if len(skipped_objects) > 10 else ''}") - # Filter body objects: those missing age/sex/slot are treated as helpers/references required_body_props = {"age", "sex", "slot"} valid_body_objects = [] skipped_body_objects = [] @@ -335,7 +339,6 @@ def run_batch_combine(): body_objects = valid_body_objects - # Separate clothing by layer, filtering out objects missing required clothing properties required_clothing_props = {"ref_layer", "ref_part", "garment_id"} clothing_layer1 = [] clothing_layer2 = [] @@ -343,7 +346,6 @@ def run_batch_combine(): for o in all_objs: if o.type != 'MESH' or "ref_layer" not in o: continue - # Check if this is a valid clothing object (has required clothing properties) missing = [p for p in required_clothing_props if p not in o] if missing: print(f"WARNING: Mesh object '{o.name}' has ref_layer but is missing required clothing properties {missing}.") @@ -362,16 +364,11 @@ def run_batch_combine(): print(f"Found {len(clothing_layer1)} layer 1 clothing objects") print(f"Found {len(clothing_layer2)} layer 2 clothing objects") - # Create dictionary of body objects for quick lookup body_objects_dict = {obj.name: obj for obj in body_objects} - - # Track objects to keep whitelist = set() - - # List to store combined objects from layer 1 combined_objects = [] - # PROCESS LAYER 1: Combine with original body parts + # PROCESS LAYER 1 print("\n=== PROCESSING LAYER 1 CLOTHING ===") for clothing_obj in clothing_layer1: if "ref_part" not in clothing_obj: @@ -385,7 +382,6 @@ def run_batch_combine(): print(f"Warning: Target body '{target_name}' not found for clothing '{clothing_obj.name}', skipping") continue - # Process the pair result = process_clothing_pair(clothing_obj, original_body, whitelist) if result: combined_objects.append(result) @@ -399,27 +395,20 @@ def run_batch_combine(): for clothing_obj in clothing_layer2: print(f"\nProcessing layer 2 clothing: {clothing_obj.name}") - - # Store the original clothing name for later use original_clothing_name = clothing_obj.name - # For each combined object from layer 1 for combined_obj in combined_objects: - # Create a copy of the layer 2 clothing clothing_copy = clothing_obj.copy() clothing_copy.data = clothing_obj.data.copy() bpy.context.collection.objects.link(clothing_copy) - # Copy custom properties for key in clothing_obj.keys(): clothing_copy[key] = clothing_obj[key] - # Set the ref_part to point to the combined object clothing_copy["ref_part"] = combined_obj.name print(f" Combining with layer 1 result: {combined_obj.name}") - # Process the pair result = process_clothing_pair(clothing_copy, combined_obj, whitelist, is_clothing_copy=True, original_clothing_name=original_clothing_name) @@ -429,7 +418,7 @@ def run_batch_combine(): print(f"Layer 2 first pass complete. Created {len(layer2_results_over_l1)} combined objects") - # PROCESS LAYER 2 (Second Pass): Combine directly with body parts (like layer 1) + # PROCESS LAYER 2 (Second Pass): Combine directly with body parts print("\n=== PROCESSING LAYER 2 CLOTHING (Second Pass - Direct to Body) ===") layer2_results_direct = [] @@ -447,7 +436,6 @@ def run_batch_combine(): print(f"\nProcessing layer 2 clothing directly with body: {clothing_obj.name} -> {target_name}") - # Process directly with body part (no copy needed for the clothing itself) result = process_clothing_pair(clothing_obj, original_body, whitelist, is_clothing_copy=False) if result: layer2_results_direct.append(result) @@ -455,7 +443,6 @@ def run_batch_combine(): print(f"Layer 2 second pass complete. Created {len(layer2_results_direct)} combined objects") - # Add all results to combined objects list all_results = combined_objects + layer2_results_over_l1 + layer2_results_direct print(f"\n=== SUMMARY ===") @@ -464,7 +451,6 @@ def run_batch_combine(): print(f"Layer 2 direct to body results: {len(layer2_results_direct)}") print(f"Total combined objects: {len(all_results)}") - # Finalize aggregated metadata on all combined objects for obj in all_results: whitelist.add(obj) if "_combined_meta" in obj: @@ -490,16 +476,13 @@ def run_batch_combine(): print(f"Finalized metadata for {obj.name}: layer={obj['layer']}, garments={obj['garments']}, tags={obj['clothing_tags']}") - # Remove temporary meta property del obj["_combined_meta"] cleanup_unused_objects(whitelist) - # Save the result bpy.ops.wm.save_as_mainfile(filepath=output_path) print(f"\nSaved to: {output_path}") if __name__ == "__main__": run_batch_combine() - diff --git a/assets/blender/characters/consolidate.py b/assets/blender/characters/consolidate.py index 89f78e0..e230e3c 100644 --- a/assets/blender/characters/consolidate.py +++ b/assets/blender/characters/consolidate.py @@ -16,13 +16,10 @@ def process_append(source_files, output_path): print(f"Warning: Source file not found: {file_path}") continue - # ---- Pre-import materials from source blend ---- - # Blender silently reuses existing materials when appending - # objects, even if the incoming one has different properties - # (texture references). Force materials to be imported first - # so texture changes in the source .blend survive. + # ---- Pre-import materials and armatures from source blend ---- with bpy.data.libraries.load(file_path, link=False) as (data_from, data_to): data_to.materials = data_from.materials + data_to.objects = data_from.objects imported_materials = {} for mat in data_to.materials: if mat is None: @@ -33,15 +30,26 @@ def process_append(source_files, output_path): f"different data; incoming material will " f"replace it on appended objects.") imported_materials[mat.name] = mat - # ---------------------------------------------------- - with bpy.data.libraries.load(file_path) as (data_from, data_to): - data_to.objects = data_from.objects + # Also track imported armatures + imported_armatures = {} + for obj in data_to.objects: + if obj is None: + continue + if obj.type == 'ARMATURE': + imported_armatures[obj.name] = obj + # ---------------------------------------------------- for obj in data_to.objects: if obj is None: continue - # Check criteria + # Keep armatures from the source blend + if obj.type == 'ARMATURE': + bpy.context.collection.objects.link(obj) + print(f"Imported armature '{obj.name}' from source blend") + continue + + # Check criteria for body part meshes has_props = all(p in obj.keys() for p in required_props) if obj.type == 'MESH' and has_props: # 1. Link to the scene root temporarily @@ -61,17 +69,36 @@ def process_append(source_files, output_path): # 2. Synchronize Names obj.data.name = obj.name - # 3. Find Target Armature + # 3. Armature handling + has_own_arm = obj.get("has_own_armature", False) + if has_own_arm: + own_arm_name = obj.get("own_armature_name", "") + own_arm = bpy.data.objects.get(own_arm_name) + if not own_arm and own_arm_name in imported_armatures: + own_arm = imported_armatures[own_arm_name] + if own_arm: + # Keep hair's own armature; don't reparent + # to body armature. + # Fix the Armature modifier to point to the + # hair armature (combine_clothes may have + # changed it to the body rig). + arm_mod = next((m for m in obj.modifiers + if m.type == 'ARMATURE'), None) + if arm_mod: + arm_mod.object = own_arm + obj.parent = own_arm + print(f"Processed {obj.name}: keeping own " + f"armature '{own_arm_name}'") + continue + + # Normal body part: parent to body armature arm_name = obj.get("sex") arm_obj = bpy.data.objects.get(arm_name) if arm_obj and arm_obj.type == 'ARMATURE': - # A. Handle Collections: Move mesh to armature's collections - # Remove from all current collections first + # A. Handle Collections for col in obj.users_collection: col.objects.unlink(obj) - - # Link to every collection the armature belongs to for col in arm_obj.users_collection: col.objects.link(obj) @@ -82,28 +109,22 @@ def process_append(source_files, output_path): arm_mod = next((m for m in obj.modifiers if m.type == 'ARMATURE'), None) if not arm_mod: arm_mod = obj.modifiers.new(name="Armature", type='ARMATURE') - arm_mod.object = arm_obj print(f"Processed {obj.name}: Parented and Modset to {arm_name}") else: print(f"Warning: Armature '{arm_name}' not found for {obj.name}") elif obj.type == 'MESH': - # Object is a mesh but missing required properties - treat as helper/reference missing = [p for p in required_props if p not in obj.keys()] print(f"WARNING: Mesh object '{obj.name}' is missing required properties {missing}.") print(f" Treating as helper/reference object (not a body part).") print(f" Available properties: {[k for k in obj.keys() if not k.startswith('_')]}") - # Don't remove - keep helper/reference objects in the scene else: - # Clean up data not meeting criteria bpy.data.objects.remove(obj, do_unlink=True) # 4. Recursive Purge of all unlinked data (Materials, Textures, Meshes) bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) # 5. Fix seams across ALL body parts in the consolidated scene. - # This synchronizes shape key offsets and normals for matching vertices - # across base body parts and combined clothing variants. print("\nFixing seams across all consolidated body parts...") fix_seams_across_objects() fix_normals_across_objects() diff --git a/assets/blender/characters/process_clothes.py b/assets/blender/characters/process_clothes.py index 9b92305..1850882 100644 --- a/assets/blender/characters/process_clothes.py +++ b/assets/blender/characters/process_clothes.py @@ -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() - diff --git a/assets/blender/scripts/export_models2.py b/assets/blender/scripts/export_models2.py index fd69d82..5fbc58e 100644 --- a/assets/blender/scripts/export_models2.py +++ b/assets/blender/scripts/export_models2.py @@ -248,6 +248,27 @@ for mapping in[CommandLineMapping()]: discovered.append(ob.name) mapping.objs += discovered print(f"Auto-discovered {len(discovered)} body part objects: {discovered}") + + # ---- Separate hair with its own skeleton ---- + hair_own_skel = [] + hair_own_skel_objs = [] # save object references + for name in list(mapping.objs): + obj = bpy.data.objects.get(name) + if obj: + has_it = obj.get("has_own_armature", False) + own_arm = obj.get("own_armature_name", "") + print(f" Object '{name}': has_own_armature=" + f"{has_it}, own_armature_name='{own_arm}'") + if has_it: + hair_own_skel.append(name) + hair_own_skel_objs.append(obj) + mapping.objs.remove(name) + if hair_own_skel: + print(f"Hair with own skeleton (exported separately): " + f"{hair_own_skel}") + else: + print(f"No hair with own skeleton detected") + # --------------------------------------------- else: bpy.ops.wm.append( filepath=os.path.join(mapping.blend_path, mapping.inner_path), @@ -266,6 +287,8 @@ for mapping in[CommandLineMapping()]: bpy.data.objects.remove(ob) # Remove auto-discovered objects that weren't meant for export elif mapping.auto_discover and ob.type == 'MESH' and ob.name not in mapping.objs: + if ob.get("has_own_armature", False): + continue # exported separately, don't rename if not ob.name.endswith("-noimp"): ob.name = ob.name + "-noimp" @@ -294,11 +317,28 @@ for mapping in[CommandLineMapping()]: bpy.data.actions.remove(act) for obj in bpy.data.objects: if obj.type == 'MESH': + # Skip hair-with-own-skeleton objects — they're + # exported separately and must not be renamed + if obj.get("has_own_armature", False): + continue if not obj.name in mapping.objs and obj.parent is None: if not obj.name.endswith("-noimp"): obj.name = obj.name + "-noimp" bpy.ops.wm.save_as_mainfile(filepath=(basepath + "/assets/blender/scripts/" + mapping.outfile)) + # Hide hair-with-own-skeleton objects from the body glTF export. + # The glTF exporter crashes when sampling animations for hair armatures + # that are not part of the main rig, so we exclude them here and export + # hair separately via OGRE later. + for obj in hair_own_skel_objs: + obj.hide_viewport = True + obj.hide_render = True + arm_name = obj.get("own_armature_name", "") + hair_arm = bpy.data.objects.get(arm_name) + if hair_arm: + hair_arm.hide_viewport = True + hair_arm.hide_render = True + os.makedirs(os.path.dirname(mapping.gltf_path), exist_ok=True) bpy.ops.export_scene.gltf(filepath=mapping.gltf_path, use_selection=False, @@ -317,7 +357,7 @@ for mapping in[CommandLineMapping()]: use_mesh_edges=False, use_mesh_vertices=False, export_cameras=False, - use_visible=False, + use_visible=True, use_renderable=False, export_yup=True, export_animations=True, @@ -331,6 +371,16 @@ for mapping in[CommandLineMapping()]: export_lights=False, export_skins=True) print("exported to: " + mapping.gltf_path) + + # Unhide hair objects for OGRE export + for obj in hair_own_skel_objs: + obj.hide_viewport = False + obj.hide_render = False + arm_name = obj.get("own_armature_name", "") + hair_arm = bpy.data.objects.get(arm_name) + if hair_arm: + hair_arm.hide_viewport = False + hair_arm.hide_render = False obj_names = mapping.objs prefix = mapping.armature_name + "_" @@ -556,6 +606,115 @@ for mapping in[CommandLineMapping()]: else: print(f"WARNING: fix_ogre_mesh_seams.py not found at {fix_script}") + # ---- Export hair with own skeleton ---- + print(f"\n=== HAIR DEBUG: auto_discover={mapping.auto_discover} " + f"hair_own_skel={hair_own_skel}") + if mapping.auto_discover and hair_own_skel: + # Collect hair meshes grouped by their armature name + hair_by_arm = {} + for obj in hair_own_skel_objs: + arm_name = obj.get("own_armature_name", "") + print(f" HAIR DEBUG: obj '{obj.name}' " + f"own_armature_name='{arm_name}' " + f"has_own_armature={obj.get('has_own_armature', False)}") + if arm_name not in hair_by_arm: + hair_by_arm[arm_name] = [] + hair_by_arm[arm_name].append(obj) + print(f" HAIR DEBUG: hair_by_arm has {len(hair_by_arm)} groups") + + body_arm_name = mapping.armature_name # save "male"/"female" + body_prefix = body_arm_name + "_" + + # Debug: list all armatures in the scene + all_arms = [o.name for o in bpy.data.objects + if o.type == 'ARMATURE'] + print(f" HAIR DEBUG: armatures in scene: {all_arms}") + + for arm_name, hair_objs in hair_by_arm.items(): + hair_arm = bpy.data.objects.get(arm_name) + print(f" HAIR DEBUG: arm_name='{arm_name}' " + f"hair_arm_found={hair_arm is not None}") + if not hair_arm or hair_arm.type != 'ARMATURE': + print(f" WARNING: Armature '{arm_name}' not found " + f"for hair, skipping") + continue + + hair_obj_names = [o.name for o in hair_objs] + print(f" Exporting hair with skeleton '{arm_name}': " + f"{hair_obj_names}") + + # Sync armature data name + hair_arm.data.name = hair_arm.name + print(f" Armature data name: '{hair_arm.data.name}'") + + # Write body_part JSON for each hair mesh + json_dir = os.path.dirname(mapping.gltf_path) + for obj in hair_objs: + new_mesh_name = body_prefix + obj.name + obj.data.name = new_mesh_name + save_data = { + "age": obj.get("age", ""), + "sex": obj.get("sex", ""), + "slot": obj.get("slot", ""), + "mesh": obj.data.name + ".mesh", + "layer": int(obj.get("layer", 0)), + "garments": [], + "tags": [], + "shape_keys": [], + "own_skeleton": True, + "attach_to_bone": "mixamorig:Head" + } + garments = obj.get("garments", "") + if garments: + save_data["garments"] = [ + g.strip() + for g in str(garments).split(";") + if g.strip()] + clothing_tags = obj.get("clothing_tags", "") + if clothing_tags: + save_data["tags"] = [ + t.strip() + for t in str(clothing_tags).split(";") + if t.strip()] + save_file = (json_dir + "/body_part_" + + obj.data.name + ".json") + with open(save_file, 'w') as f: + json.dump(save_data, f, indent=2) + print(f" Wrote {save_file}") + + # OGRE export for hair with its own skeleton + + # OGRE export for hair with its own skeleton + hair_scene = mapping.gltf_path.replace( + ".glb", "_hair_" + arm_name + ".scene") + bpy.ops.ogre.export( + filepath=hair_scene, + EX_SELECTED_ONLY=False, + EX_SHARED_ARMATURE=True, + EX_LOD_GENERATION='0', + EX_LOD_DISTANCE=20, + EX_LOD_LEVELS=4, + EX_GENERATE_TANGENTS='4') + print(f" Exported hair scene: {hair_scene}") + + # Fix alpha_rejection in hair .material files + _mat_files = _glob.glob( + os.path.join(ogre_export_dir, "*.material")) + for _mf in _mat_files: + with open(_mf, 'r') as _f: + _content = _f.read() + if 'alpha_rejection' in _content: + _new = _re.sub( + r'alpha_rejection (\S+) (\d+\.?\d*)', + lambda m: 'alpha_rejection ' + + m.group(1) + ' ' + + str(int(float(m.group(2)))), + _content) + if _new != _content: + with open(_mf, 'w') as _f: + _f.write(_new) + # ----------------------------------------- + bpy.ops.wm.read_homefile(use_empty=True) time.sleep(2) bpy.ops.wm.quit_blender() diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 28137a9..02e7bf7 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -48,6 +48,7 @@ set(EDITSCENE_SOURCES systems/MarkovNameGenerator.cpp systems/PregnancySystem.cpp systems/AnimationTreeSystem.cpp + systems/HairPhysicsSystem.cpp systems/BehaviorTreeSystem.cpp systems/NavMeshSystem.cpp recast/TileCacheNavMesh.cpp @@ -232,6 +233,7 @@ set(EDITSCENE_HEADERS systems/CharacterRegistry.hpp systems/PregnancySystem.hpp systems/AnimationTreeSystem.hpp + systems/HairPhysicsSystem.hpp systems/BehaviorTreeSystem.hpp systems/NavMeshSystem.hpp recast/TileCacheNavMesh.hpp diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 302e791..dd5f11a 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -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( + 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(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 --- */ diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index faecde4..7341c4a 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -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 m_proceduralMeshSystem; std::unique_ptr m_characterSlotSystem; std::unique_ptr m_animationTreeSystem; + std::unique_ptr m_hairPhysicsSystem; std::unique_ptr m_behaviorTreeSystem; std::unique_ptr m_navMeshSystem; std::unique_ptr m_characterSystem; diff --git a/src/features/editScene/components/Character.hpp b/src/features/editScene/components/Character.hpp index 21bd1e9..025aabd 100644 --- a/src/features/editScene/components/Character.hpp +++ b/src/features/editScene/components/Character.hpp @@ -3,6 +3,8 @@ #pragma once #include +#include +#include /** * 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; diff --git a/src/features/editScene/components/CharacterSlots.hpp b/src/features/editScene/components/CharacterSlots.hpp index 1b6401c..7b22dfb 100644 --- a/src/features/editScene/components/CharacterSlots.hpp +++ b/src/features/editScene/components/CharacterSlots.hpp @@ -5,6 +5,8 @@ #include #include +#include "AnimationTree.hpp" + /** * Selection criteria for a single character slot. * Layer 0 (nude base) can be selected via combo box (e.g. hair styles). diff --git a/src/features/editScene/lua/LuaComponentApi.cpp b/src/features/editScene/lua/LuaComponentApi.cpp index 7a5c04c..aa999d9 100644 --- a/src/features/editScene/lua/LuaComponentApi.cpp +++ b/src/features/editScene/lua/LuaComponentApi.cpp @@ -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);); diff --git a/src/features/editScene/physics/physics.cpp b/src/features/editScene/physics/physics.cpp index 22d3659..e3d71ff 100644 --- a/src/features/editScene/physics/physics.cpp +++ b/src/features/editScene/physics/physics.cpp @@ -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 characterBodies; bool debugDraw; JPH::Vec3 gravity = JPH::Vec3(0.0f, -9.8f, 0.0f); + std::unordered_map > 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 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::msSingleton = 0; diff --git a/src/features/editScene/physics/physics.h b/src/features/editScene/physics/physics.h index bc08ade..04d7178 100644 --- a/src/features/editScene/physics/physics.h +++ b/src/features/editScene/physics/physics.h @@ -10,6 +10,7 @@ #include #include #include +#include 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 diff --git a/src/features/editScene/recastnavigation/CMakeLists.txt b/src/features/editScene/recastnavigation/CMakeLists.txt index 4de50fb..e6f9caa 100644 --- a/src/features/editScene/recastnavigation/CMakeLists.txt +++ b/src/features/editScene/recastnavigation/CMakeLists.txt @@ -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 "$" 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) diff --git a/src/features/editScene/systems/AnimationTreeSystem.cpp b/src/features/editScene/systems/AnimationTreeSystem.cpp index 8ceaa3f..0ab8f05 100644 --- a/src/features/editScene/systems/AnimationTreeSystem.cpp +++ b/src/features/editScene/systems/AnimationTreeSystem.cpp @@ -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; } + + diff --git a/src/features/editScene/systems/AnimationTreeSystem.hpp b/src/features/editScene/systems/AnimationTreeSystem.hpp index c3ae866..7e867a4 100644 --- a/src/features/editScene/systems/AnimationTreeSystem.hpp +++ b/src/features/editScene/systems/AnimationTreeSystem.hpp @@ -11,6 +11,7 @@ #include "../components/AnimationTree.hpp" #include "../components/AnimationTreeTemplate.hpp" + /** * System that evaluates an AnimationTreeComponent each frame. * diff --git a/src/features/editScene/systems/CharacterSlotSystem.cpp b/src/features/editScene/systems/CharacterSlotSystem.cpp index fa73dcc..b700c6f 100644 --- a/src/features/editScene/systems/CharacterSlotSystem.cpp +++ b/src/features/editScene/systems/CharacterSlotSystem.cpp @@ -504,13 +504,13 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e, } } - /* Populate default slots from catalog if still empty */ - if (cs.slotSelections.empty()) { - auto slots = getSlots(age, cs.sex); - for (const auto &slot : slots) { - SlotSelection sel; - cs.slotSelections[slot] = sel; - } + /* Make sure every catalog slot exists in the selection map. This + * guarantees a face/body master is available so that hair with its + * own skeleton can attach to a real head bone. */ + auto slots = getSlots(age, cs.sex); + for (const auto &slot : slots) { + if (cs.slotSelections.find(slot) == cs.slotSelections.end()) + cs.slotSelections[slot] = SlotSelection(); } if (!e.has()) @@ -602,9 +602,6 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e, if (mesh.empty()) continue; - Ogre::LogManager::getSingleton().logMessage( - "[CharacterSlotSystem] slot='" + slot + - try { ensureMeshPoseAnimation(mesh); Ogre::MeshPtr partMesh = @@ -612,19 +609,47 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e, mesh, "Characters"); Ogre::Entity *partEnt = m_sceneMgr->createEntity(partMesh); - partEnt->shareSkeletonInstanceWith(masterEnt); - transform.node->attachObject(partEnt); - m_entities[e.id()].parts[slot] = partEnt; + + /* Check if this mesh has its own skeleton + * (e.g. hair exported with a hair-specific + * armature). If so, attach to the master's + * Head bone instead of sharing skeletons. */ const nlohmann::json *entry = findCatalogEntry(age, cs.sex, slot, mesh); + bool ownSkeleton = false; + Ogre::String attachBone = "mixamorig:Head"; + if (entry) { + ownSkeleton = entry->value( + "own_skeleton", false); + attachBone = entry->value( + "attach_to_bone", attachBone); + } + + if (ownSkeleton) { + /* Hair with its own skeleton: attach + * to the master entity's Head bone + * so it follows head movement while + * being animated by its own skeleton. + */ + masterEnt->attachObjectToBone( + attachBone, partEnt); + Ogre::LogManager::getSingleton() + .logMessage( + "[CharacterSlotSystem] " + "Attached hair '" + + slot + + "' with own skeleton to " + "bone '" + + attachBone + "'"); + } else { + partEnt->shareSkeletonInstanceWith( + masterEnt); + transform.node->attachObject(partEnt); + } + m_entities[e.id()].parts[slot] = partEnt; + applyShapeKeys(e, partEnt, entry); - /* Re-prepare temp buffers after skeleton sharing and - * enabling vertex animation. shareSkeletonInstanceWith - * replaces the part's AnimationStateSet but does not - * recreate temp blend buffers, which are needed for - * proper per-entity pose animation. - */ prepareEntityTempBlendBuffers(partEnt); } catch (const Ogre::Exception &ex) { diff --git a/src/features/editScene/systems/CharacterSystem.cpp b/src/features/editScene/systems/CharacterSystem.cpp index 7071477..9cedd49 100644 --- a/src/features/editScene/systems/CharacterSystem.cpp +++ b/src/features/editScene/systems/CharacterSystem.cpp @@ -1,8 +1,49 @@ #include "CharacterSystem.hpp" +#include "../components/CharacterSlots.hpp" #include +#include +#include +#include #include +#include +#include #include +static Ogre::Entity *getMasterEntity(flecs::entity e) +{ + if (!e.is_alive() || !e.has()) + return nullptr; + const CharacterSlotsComponent &cs = e.get(); + 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(e.id()) | + 0x80000000; + JPH::CharacterBase *base = m_physics->createCharacter(transform.node, shape); if (!base) return; auto *ch = static_cast(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(state.character)); } + if (e.has()) { + auto &cc = e.get_mut(); + 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); + } }); } diff --git a/src/features/editScene/systems/CharacterSystem.hpp b/src/features/editScene/systems/CharacterSystem.hpp index 35764da..ff4692c 100644 --- a/src/features/editScene/systems/CharacterSystem.hpp +++ b/src/features/editScene/systems/CharacterSystem.hpp @@ -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; diff --git a/src/features/editScene/systems/HairPhysicsSystem.cpp b/src/features/editScene/systems/HairPhysicsSystem.cpp new file mode 100644 index 0000000..39b9bc4 --- /dev/null +++ b/src/features/editScene/systems/HairPhysicsSystem.cpp @@ -0,0 +1,587 @@ +#include "HairPhysicsSystem.hpp" +#include "CharacterSlotSystem.hpp" +#include "../components/CharacterSlots.hpp" +#include "../components/Character.hpp" +#include +#include +#include +#include + +#include + +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( + hairEnt->getParentNode()); + if (tagPoint) { + Ogre::Bone *parentBone = dynamic_cast( + 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 +#include +#include +#include + +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().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 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 orderedBones; + std::unordered_map boneToIndex; + std::queue 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(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 skeleton = new JPH::Skeleton; + std::vector bindWorldMatrices; + bindWorldMatrices.resize(orderedBones.size()); + + for (size_t i = 0; i < orderedBones.size(); ++i) { + Ogre::Bone *bone = orderedBones[i]; + Ogre::Bone *parent = static_cast(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 settings = new JPH::RagdollSettings; + settings->mSkeleton = skeleton; + settings->mParts.resize(orderedBones.size()); + + std::vector 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( + 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(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()) { + auto &cc = e.get_mut(); + if (cc.collisionGroupId == 0) + cc.collisionGroupId = + static_cast(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()) + settings->DisableParentChildCollisions(); + + settings->Stabilize(); + settings->CalculateConstraintPriorities(); + settings->CalculateBodyIndexToConstraintIndex(); + + JPH::PhysicsSystem *physSystem = m_physics->getPhysicsSystem(); + JPH::Ref 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()) + 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(); + 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(); + 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 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; +} diff --git a/src/features/editScene/systems/HairPhysicsSystem.hpp b/src/features/editScene/systems/HairPhysicsSystem.hpp new file mode 100644 index 0000000..0108e20 --- /dev/null +++ b/src/features/editScene/systems/HairPhysicsSystem.hpp @@ -0,0 +1,75 @@ +#ifndef EDITSCENE_HAIRPHYSICSSYSTEM_HPP +#define EDITSCENE_HAIRPHYSICSSYSTEM_HPP +#pragma once + +#include +#include +#include +#include + +#include "../physics/physics.h" + +#include +#include + +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 ragdoll; + flecs::entity_t entityId = 0; + Ogre::Entity *hairEntity = nullptr; + Ogre::Entity *masterEntity = nullptr; + Ogre::String slotName; + std::vector boneNames; + std::vector 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 > + m_states; + + static uint32_t s_nextCollisionGroup; +}; + +#endif // EDITSCENE_HAIRPHYSICSSYSTEM_HPP diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 1599381..9747941 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -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(), json["linearVelocity"][2].get()); } + 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(), + json["chestHalfExtents"][1].get(), + json["chestHalfExtents"][2].get()); + } 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(cc); } @@ -2195,6 +2220,11 @@ void SceneSerializer::deserializeCharacterIdentity(flecs::entity entity, entity.set(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(); @@ -2217,6 +2247,7 @@ nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity) } json["slotSelections"] = selections; + // Serialize front axis json["frontAxis"] = { cs.frontAxis.x, cs.frontAxis.y, cs.frontAxis.z }; @@ -2269,6 +2300,7 @@ void SceneSerializer::deserializeCharacterSlots(flecs::entity entity, cs.frontAxis.normalise(); } + cs.dirty = true; entity.set(cs); } diff --git a/src/features/editScene/ui/CharacterEditor.cpp b/src/features/editScene/ui/CharacterEditor.cpp index 3845ff6..93b253d 100644 --- a/src/features/editScene/ui/CharacterEditor.cpp +++ b/src/features/editScene/ui/CharacterEditor.cpp @@ -1,5 +1,6 @@ #include "CharacterEditor.hpp" #include +#include 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 }; diff --git a/src/features/editScene/ui/CharacterSlotsEditor.cpp b/src/features/editScene/ui/CharacterSlotsEditor.cpp index facc934..89379a8 100644 --- a/src/features/editScene/ui/CharacterSlotsEditor.cpp +++ b/src/features/editScene/ui/CharacterSlotsEditor.cpp @@ -221,61 +221,65 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity, } } else { /* Layer 0 combo (base meshes like hair) */ - std::vector layer0Meshes = - CharacterSlotSystem:: - getMeshesForLayer( - currentAge, - cs.sex, slot, - 0); - if (!layer0Meshes.empty()) { - Ogre::String l0Preview = "auto"; - if (!sel.layer0Mesh.empty() && - sel.layer0Mesh != "none") - l0Preview = CharacterSlotSystem:: - getMeshLabel( + /* Hair slot always uses auto stub; no base editing */ + if (slot != "hair") { + std::vector layer0Meshes = + CharacterSlotSystem:: + getMeshesForLayer( currentAge, - cs.sex, - slot, - sel.layer0Mesh); - if (ImGui::BeginCombo( - "Base (Layer 0)", - l0Preview.c_str())) { - if (ImGui::Selectable( - "auto", - sel.layer0Mesh.empty() || - sel.layer0Mesh == - "none")) { - sel.layer0Mesh = - "none"; - modified = true; - cs.dirty = true; - } - for (const auto &m : - layer0Meshes) { - Ogre::String label = CharacterSlotSystem:: + cs.sex, slot, + 0); + if (!layer0Meshes.empty()) { + Ogre::String l0Preview = "auto"; + if (!sel.layer0Mesh.empty() && + sel.layer0Mesh != "none") + l0Preview = CharacterSlotSystem:: getMeshLabel( currentAge, cs.sex, slot, - m); - bool isSelected = - (sel.layer0Mesh == - m); + sel.layer0Mesh); + if (ImGui::BeginCombo( + "Base (Layer 0)", + l0Preview.c_str())) { if (ImGui::Selectable( - label.c_str(), - isSelected)) { + "auto", + sel.layer0Mesh.empty() || + sel.layer0Mesh == + "none")) { sel.layer0Mesh = - m; - modified = - true; - cs.dirty = - true; + "none"; + modified = true; + cs.dirty = true; } + for (const auto &m : + layer0Meshes) { + Ogre::String label = CharacterSlotSystem:: + getMeshLabel( + currentAge, + cs.sex, + slot, + m); + bool isSelected = + (sel.layer0Mesh == + m); + if (ImGui::Selectable( + label.c_str(), + isSelected)) { + sel.layer0Mesh = + m; + modified = + true; + cs.dirty = + true; + } + } + ImGui::EndCombo(); } - ImGui::EndCombo(); } + } else { + ImGui::TextDisabled("Base: auto (stub hair)"); } - /* Layer 1 combo */ std::vector layer1Meshes = CharacterSlotSystem:: diff --git a/src/gamedata/GUIModule.cpp b/src/gamedata/GUIModule.cpp index 66049f5..3e85ceb 100644 --- a/src/gamedata/GUIModule.cpp +++ b/src/gamedata/GUIModule.cpp @@ -232,22 +232,34 @@ struct GUIListener : public Ogre::RenderTargetListener { float width = size.x; float height = size.y; Ogre::Camera *camera = ECS::get().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)