Fixed normal seams
This commit is contained in:
@@ -413,10 +413,66 @@ 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
|
||||
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')
|
||||
|
||||
# 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.
|
||||
ogre_export_dir = os.path.dirname(mapping.gltf_path.replace(".glb", ".scene"))
|
||||
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}")
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user