Compare commits
8 Commits
3a3edf785c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a5edacf8a | |||
| 765dffbed0 | |||
| a553621c7f | |||
| 86310e96f2 | |||
| 1a0fb87b93 | |||
| b71b599d9c | |||
| eea50adfcb | |||
| c7ef9283cd |
@@ -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.
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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`)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 --- */
|
||||
|
||||
@@ -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););
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user