Blenders FBX-Schreiber trug den absoluten Pfad der geöffneten .blend-Datei in den Dateikopf ein (ApplicationNativeFile, z. B. /Users/martin/Documents/…). Die Bytes hingen damit vom Ort des Checkouts ab; nach dem Umzug nach ~/Developer galt die byteidentische Reproduzierbarkeit für die eingecheckten Dateien nicht mehr. Ab Version 11 steht dort der relative Quellpfad aus dem Manifest (Generatoren: leer). Geprüft an der Grenzwacht: zwei Läufe byteidentisch, kein absoluter Pfad, Prüfprotokoll gleich bis auf Version und Größe. Die eingecheckten FBX-Dateien bleiben Version 10 bis zum nächsten Gesamtexport (spätestens U5); Inhalt unverändert. Dokumentiert in U2-WORLD. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
564 lines
28 KiB
Python
564 lines
28 KiB
Python
"""Reproducible Blender -> FBX export for the Unity port (docs/port/U2-WORLD.md, U2A).
|
|
|
|
Runs headless on the Blender sources of the Godot reference, applies the per-asset export
|
|
rules of tools/blender/export_manifest.json, checks the result and writes, per asset:
|
|
|
|
Assets/Luneraria/Art/<group>/<id>.fbx the model for Unity
|
|
Assets/Luneraria/Art/<group>/<id>.export.json facts and checks (read by Unity's importer)
|
|
|
|
Checks: units and scale, up and forward axis, object transforms, mesh names, triangle counts,
|
|
material slots, UVs, normals, tangent prerequisites, vertex colours, root bone, bone names,
|
|
clips (names, lengths, loop flags, contact events), bounds. Expectations come from the Godot
|
|
manifests of the GLBs the reference game uses, so the FBX must describe the same model.
|
|
After writing, the FBX is imported into an empty scene and checked again (round trip).
|
|
|
|
Usage (from the Unity project root): sh tools/blender/export_fbx.sh [id,id,...]
|
|
(runs blender --background --factory-startup --python-use-system-env with PYTHONHASHSEED=0
|
|
--python tools/blender/lune_fbx_export.py -- --godot ../saumwacht-godot --ids <ids>)
|
|
Exit code 0 only if every check of every asset passed.
|
|
"""
|
|
import bpy
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from mathutils import Matrix, Vector
|
|
|
|
UNITY_ROOT = Path(__file__).resolve().parents[2]
|
|
EXPORTER_VERSION = 11
|
|
|
|
# Tangents are not written: Unity computes them with MikkTSpace like Blender (import rule
|
|
# CalculateMikk); per-corner tangents and binormals in double precision doubled the file size.
|
|
# One export setting for every asset. The file keeps Blender's Z-up axes; Unity's importer
|
|
# converts them with "Bake Axis Conversion" as Unity (x, y, z) = Blender (x, z, y), so no root
|
|
# carries a rotation and animation data is converted with the meshes (Blender's own "Apply
|
|
# Transform" is documented as broken for armatures). Before export every asset is turned by 180
|
|
# degrees about Blender's Z and the turn is applied (face_unity_forward): the front (Blender -Y,
|
|
# glTF/Godot +Z) then becomes Unity +Z, and Unity = (-x, y, z) of the Godot coordinates.
|
|
FBX_SETTINGS = dict(
|
|
use_selection=True, use_visible=False, use_active_collection=False,
|
|
global_scale=1.0, apply_unit_scale=True, apply_scale_options='FBX_SCALE_UNITS',
|
|
axis_forward='Y', axis_up='Z', bake_space_transform=False,
|
|
object_types={'EMPTY', 'MESH', 'ARMATURE'},
|
|
use_mesh_modifiers=True, use_mesh_modifiers_render=True,
|
|
mesh_smooth_type='OFF', use_subsurf=False, use_mesh_edges=False, use_tspace=False,
|
|
use_triangles=False, colors_type='LINEAR', prioritize_active_color=True,
|
|
use_custom_props=False,
|
|
add_leaf_bones=False, primary_bone_axis='Y', secondary_bone_axis='X',
|
|
use_armature_deform_only=True, armature_nodetype='NULL',
|
|
bake_anim=True, bake_anim_use_all_bones=True, bake_anim_use_nla_strips=False,
|
|
bake_anim_use_all_actions=True, bake_anim_force_startend_keying=True,
|
|
bake_anim_step=1.0, bake_anim_simplify_factor=0.0,
|
|
path_mode='STRIP', embed_textures=False, batch_mode='OFF',
|
|
)
|
|
|
|
|
|
def args():
|
|
argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
|
|
result = {'manifest': str(UNITY_ROOT / 'tools/blender/export_manifest.json'), 'godot': str(UNITY_ROOT.parent / 'saumwacht-godot'), 'ids': ''}
|
|
for i in range(0, len(argv) - 1, 2):
|
|
result[argv[i].lstrip('-')] = argv[i + 1]
|
|
return result
|
|
|
|
|
|
class Report:
|
|
def __init__(self, spec):
|
|
self.data = {'id': spec['id'], 'group': spec['group'], 'source': spec['source'], 'exporter_version': EXPORTER_VERSION,
|
|
'blender': bpy.app.version_string, 'fbx_settings': {k: (sorted(v) if isinstance(v, set) else v) for k, v in FBX_SETTINGS.items()},
|
|
'checks': []}
|
|
|
|
def check(self, name, ok, detail=''):
|
|
self.data['checks'].append({'check': name, 'ok': bool(ok), 'detail': str(detail)})
|
|
return ok
|
|
|
|
@property
|
|
def ok(self):
|
|
return all(c['ok'] for c in self.data['checks'])
|
|
|
|
|
|
def godot_expectations(spec, godot_root):
|
|
"""Facts of the GLB the reference uses, from its manifest (triangles, nodes, bounds, ...)."""
|
|
source = spec.get('godot_manifest')
|
|
if not source:
|
|
return {}
|
|
data = json.loads((godot_root / source['file']).read_text())
|
|
assets = data['assets'] if isinstance(data, dict) and 'assets' in data else data
|
|
if isinstance(assets, dict):
|
|
assets = [assets]
|
|
for entry in assets:
|
|
if source.get('id') is not None and entry.get('id') == source['id']:
|
|
return entry
|
|
if source.get('id') is None and entry.get('source') == spec['source']:
|
|
return entry
|
|
raise KeyError('no entry %s in %s' % (source.get('id'), source['file']))
|
|
|
|
|
|
def triangles(mesh):
|
|
return sum(len(p.vertices) - 2 for p in mesh.polygons)
|
|
|
|
|
|
def evaluated_mesh(obj, depsgraph):
|
|
evaluated = obj.evaluated_get(depsgraph)
|
|
return evaluated, evaluated.to_mesh()
|
|
|
|
|
|
def prepare(spec, report):
|
|
"""Applies the export rules of the manifest to the opened .blend (never saved)."""
|
|
rules = spec.get('rules', {})
|
|
skip = tuple(rules.get('skip_modifiers', ()))
|
|
removed = 0
|
|
if skip:
|
|
for obj in bpy.data.objects:
|
|
for modifier in list(obj.modifiers):
|
|
if modifier.name.startswith(skip):
|
|
obj.modifiers.remove(modifier)
|
|
removed += 1
|
|
report.data['removed_modifiers'] = removed
|
|
if rules.get('logical_names'):
|
|
logical_names(report)
|
|
join = rules.get('join')
|
|
if join:
|
|
parts = [o for o in bpy.context.scene.objects if o.type == 'MESH' and o.parent and o.parent.name == join['parent']]
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for obj in parts:
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = parts[0]
|
|
bpy.ops.object.join()
|
|
bpy.context.object.name = join['into']
|
|
bpy.context.object.data.name = join['into']
|
|
report.data['joined_parts'] = len(parts)
|
|
face_unity_forward(report)
|
|
chunk = rules.get('chunk')
|
|
if chunk:
|
|
chunk_objects(chunk, report)
|
|
rest_action = rules.get('rest_action')
|
|
for obj in bpy.context.scene.objects:
|
|
if obj.type == 'ARMATURE' and obj.animation_data:
|
|
# Export the bind pose, not the last evaluated frame of some action.
|
|
obj.animation_data.action = bpy.data.actions.get(rest_action) if rest_action else None
|
|
bpy.context.scene.frame_set(0)
|
|
|
|
|
|
def face_unity_forward(report):
|
|
"""Turns every root 180 degrees about Z and applies the turn to all objects (meshes, empties,
|
|
armatures with their rest pose). Bone animation is stored relative to the rest pose and
|
|
stays valid. Shared mesh data is made single-user first."""
|
|
turn = Matrix.Rotation(math.pi, 4, 'Z')
|
|
scene_objects = [o for o in bpy.context.scene.objects if o.type in ('MESH', 'ARMATURE', 'EMPTY')]
|
|
for obj in scene_objects:
|
|
if obj.parent is None:
|
|
obj.matrix_world = turn @ obj.matrix_world
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for obj in scene_objects:
|
|
obj.hide_set(False)
|
|
obj.hide_select = False
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = scene_objects[0]
|
|
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False, isolate_users=True)
|
|
turned = [o.name for o in scene_objects if o.matrix_world.to_euler().z != 0.0 and abs(o.matrix_world.to_euler().z) > 1e-5]
|
|
report.check('turn_applied', not turned, turned[:8])
|
|
|
|
|
|
def chunk_objects(size, report):
|
|
"""Runtime merge of the Verdant Rift (assets-source/phase10/build_sanctuary.py): mesh objects
|
|
joined by 16 m cell and first material, named Chunk_<x>_<y>_<material>."""
|
|
import math as m
|
|
groups = {}
|
|
for obj in [o for o in bpy.context.scene.objects if o.type == 'MESH']:
|
|
center = obj.matrix_world.translation
|
|
key = (int(m.floor(center.x / size)), int(m.floor(center.y / size)), obj.data.materials[0].name)
|
|
groups.setdefault(key, []).append(obj)
|
|
for key, parts in groups.items():
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for obj in parts:
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = parts[0]
|
|
if len(parts) > 1:
|
|
bpy.ops.object.join()
|
|
parts[0].name = 'Chunk_%d_%d_%s' % key
|
|
report.data['chunks'] = len(groups)
|
|
|
|
|
|
def logical_names(report):
|
|
"""Phase-4 sources keep their logical names in custom properties (part_name, material_name);
|
|
Blender may have suffixed the datablocks (.004). The Godot pipeline renamed the GLB nodes the
|
|
same way (assets-source/phase4/blender/pipeline.py, _canonical_glb_names). Materials that share
|
|
a logical name are one material and are merged."""
|
|
renamed = 0
|
|
for obj in list(bpy.data.objects):
|
|
if 'part_name' in obj.keys():
|
|
obj.name = '__lune_tmp_' + obj.name
|
|
for obj in list(bpy.data.objects):
|
|
if 'part_name' in obj.keys():
|
|
obj.name = obj['part_name']
|
|
renamed += int(obj.name == obj['part_name'])
|
|
groups = {}
|
|
for material in list(bpy.data.materials):
|
|
logical = material.get('material_name')
|
|
if logical:
|
|
groups.setdefault(logical, []).append(material)
|
|
merged = 0
|
|
for logical, materials in groups.items():
|
|
keep = materials[0]
|
|
for other in materials[1:]:
|
|
other.user_remap(keep)
|
|
merged += 1
|
|
for material in materials[1:]:
|
|
bpy.data.materials.remove(material)
|
|
keep.name = '__lune_tmp_' + logical
|
|
for logical in groups:
|
|
bpy.data.materials['__lune_tmp_' + logical].name = logical
|
|
collisions = [o.name for o in bpy.data.objects if 'part_name' in o.keys() and o.name != o['part_name']]
|
|
report.check('logical_names_unique', not collisions, collisions[:12])
|
|
report.data['logical_names'] = {'objects': renamed, 'materials': len(groups), 'merged_materials': merged}
|
|
|
|
|
|
def export_objects(spec):
|
|
include = spec.get('rules', {}).get('objects')
|
|
prefix = spec.get('rules', {}).get('objects_prefix')
|
|
objects = []
|
|
for obj in bpy.context.scene.objects:
|
|
if obj.type not in ('MESH', 'ARMATURE', 'EMPTY'):
|
|
continue
|
|
if include and obj.name not in include:
|
|
continue
|
|
if prefix and not obj.name.startswith(prefix):
|
|
continue
|
|
if obj.hide_render:
|
|
continue
|
|
objects.append(obj)
|
|
return objects
|
|
|
|
|
|
def to_unity(v):
|
|
"""Blender (x, y, z), after the turn of face_unity_forward -> Unity (x, y, z) = (x, z, y)."""
|
|
return Vector((v.x, v.z, v.y))
|
|
|
|
|
|
def facts(objects, depsgraph):
|
|
"""Scene facts in Unity coordinates: meshes, materials, bounds, bones, actions."""
|
|
meshes = {}
|
|
low = Vector((math.inf,) * 3)
|
|
high = Vector((-math.inf,) * 3)
|
|
for obj in objects:
|
|
if obj.type != 'MESH':
|
|
continue
|
|
evaluated, mesh = evaluated_mesh(obj, depsgraph)
|
|
world = evaluated.matrix_world
|
|
# Skinned meshes: bounds of the rest pose (armature modifier is evaluated in the bind pose).
|
|
mesh_low = Vector((math.inf,) * 3)
|
|
mesh_high = Vector((-math.inf,) * 3)
|
|
for vertex in mesh.vertices:
|
|
p = to_unity(world @ vertex.co)
|
|
mesh_low = Vector(map(min, mesh_low, p))
|
|
mesh_high = Vector(map(max, mesh_high, p))
|
|
low = Vector(map(min, low, mesh_low))
|
|
high = Vector(map(max, high, mesh_high))
|
|
invalid = {i for i, n in enumerate(mesh.corner_normals) if abs(n.vector.length - 1.0) >= 1e-3}
|
|
bad_normals = len(invalid)
|
|
mesh.calc_loop_triangles()
|
|
# Triangles Unity may drop on import: zero area, or a corner without a valid normal.
|
|
degenerate = sum(1 for t in mesh.loop_triangles if t.area < 1e-10 or any(l in invalid for l in t.loops))
|
|
normals_ok = bad_normals == 0
|
|
meshes[obj.name] = {
|
|
'mesh': obj.data.name, 'triangles': triangles(mesh), 'vertices': len(mesh.vertices),
|
|
'ngons': sum(1 for p in mesh.polygons if len(p.vertices) > 4),
|
|
'materials': [slot.material.name if slot.material else None for slot in obj.material_slots],
|
|
'uv_layers': [uv.name for uv in mesh.uv_layers],
|
|
'color_attributes': [c.name for c in mesh.color_attributes],
|
|
'active_color': mesh.color_attributes.active_color.name if mesh.color_attributes.active_color else None,
|
|
'custom_normals': obj.data.has_custom_normals, 'normals_unit_length': normals_ok, 'bad_normals': bad_normals,
|
|
'degenerate_triangles': degenerate,
|
|
'shape_keys': len(obj.data.shape_keys.key_blocks) - 1 if obj.data.shape_keys else 0,
|
|
'skinned': any(m.type == 'ARMATURE' for m in obj.modifiers),
|
|
'scale': [round(s, 6) for s in obj.matrix_world.to_scale()],
|
|
'bounds_unity': {'min': [round(c, 4) for c in mesh_low], 'max': [round(c, 4) for c in mesh_high]},
|
|
}
|
|
evaluated.to_mesh_clear()
|
|
armatures = {}
|
|
for obj in objects:
|
|
if obj.type == 'ARMATURE':
|
|
bones = [b for b in obj.data.bones if b.use_deform]
|
|
armatures[obj.name] = {'bones': [b.name for b in bones], 'roots': [b.name for b in bones if b.parent is None or not b.parent.use_deform]}
|
|
empties = {o.name: [round(c, 4) for c in to_unity(o.matrix_world.translation)] for o in objects if o.type == 'EMPTY'}
|
|
return {'meshes': meshes, 'armatures': armatures, 'empties': empties,
|
|
'bounds_unity': {'min': [round(c, 4) for c in low], 'max': [round(c, 4) for c in high]} if meshes else None}
|
|
|
|
|
|
def clip_facts(fps):
|
|
clips = []
|
|
for action in bpy.data.actions:
|
|
start, end = action.frame_range
|
|
clips.append({'name': action.name, 'frames': [round(start, 3), round(end, 3)], 'seconds': round((end - start) / fps, 4),
|
|
'loop': bool(action.get('loop', action.name in ('idle', 'walk'))),
|
|
'contact_seconds': float(action.get('contact_seconds', -1.0))})
|
|
return clips
|
|
|
|
|
|
def godot_to_unity_bounds(bounds):
|
|
"""Godot/glTF bounds (x, y, z) -> Unity (-x, y, z)."""
|
|
lo, hi = bounds['min'], bounds['max']
|
|
return {'min': [-hi[0], lo[1], lo[2]], 'max': [-lo[0], hi[1], hi[2]]}
|
|
|
|
|
|
def run_checks(spec, report, before, clips, expect):
|
|
scene = bpy.context.scene
|
|
report.check('units_metric', scene.unit_settings.system == 'METRIC', scene.unit_settings.system)
|
|
report.check('unit_scale_1', abs(scene.unit_settings.scale_length - 1.0) < 1e-9, scene.unit_settings.scale_length)
|
|
meshes = before['meshes']
|
|
bad_scale = {n: m['scale'] for n, m in meshes.items() if any(abs(s - 1.0) > 1e-4 for s in m['scale'])}
|
|
negative = {n: s for n, s in bad_scale.items() if any(c < 0 for c in s)}
|
|
report.check('no_negative_scale', not negative, negative)
|
|
report.check('object_scale_1', not bad_scale or spec.get('rules', {}).get('allow_object_scale', False), bad_scale)
|
|
names = [m['mesh'] for m in meshes.values()]
|
|
report.check('mesh_names_unique', len(set(meshes)) == len(meshes), sorted(meshes))
|
|
empty_slots = {n: m['materials'] for n, m in meshes.items() if None in m['materials'] or not m['materials']}
|
|
report.check('material_slots_filled', not empty_slots, empty_slots)
|
|
# Known source defects may be allowed per mesh with an exact count (allow_bad_normals).
|
|
allowed = spec.get('rules', {}).get('allow_bad_normals', {})
|
|
bad = {n: m['bad_normals'] for n, m in meshes.items() if m['bad_normals'] > allowed.get(n, 0)}
|
|
report.check('normals_unit_length', not bad, bad)
|
|
if allowed:
|
|
report.data['allowed_bad_normals'] = {n: meshes[n]['bad_normals'] for n in allowed if n in meshes}
|
|
# Tangents: Unity triangulates on import and computes them with MikkTSpace where UVs exist
|
|
# (checked on the Unity side). N-gons are therefore fine; they are recorded. Meshes without
|
|
# UVs are shaded triplanar or unlit and carry no tangents.
|
|
report.data['meshes_with_ngons'] = {n: m['ngons'] for n, m in meshes.items() if m['ngons']}
|
|
report.data['meshes_without_uv'] = sorted(n for n, m in meshes.items() if not m['uv_layers'])
|
|
# Blend shapes: none of the sources uses them; Unity imports them only when they exist.
|
|
shapes = {n: m['shape_keys'] for n, m in meshes.items() if m['shape_keys']}
|
|
report.check('no_blend_shapes', not shapes or spec.get('rules', {}).get('blend_shapes', False), shapes)
|
|
total = sum(m['triangles'] for m in meshes.values())
|
|
report.data['triangles'] = total
|
|
if 'triangles' in expect:
|
|
report.check('triangles_match_godot', total == expect['triangles'], '%d fbx / %d godot' % (total, expect['triangles']))
|
|
if 'nodes' in expect:
|
|
report.check('mesh_names_match_godot', sorted(meshes) == sorted(expect['nodes']), sorted(set(meshes) ^ set(expect['nodes'])))
|
|
if 'mesh_count' in expect:
|
|
report.check('mesh_count_match_godot', len(meshes) == expect['mesh_count'], '%d / %d' % (len(meshes), expect['mesh_count']))
|
|
if 'parts' in expect and spec.get('rules', {}).get('check_parts', True):
|
|
# Godot's part list holds the meshes and named empties (anchors), not the root or the marker.
|
|
marker = spec.get('rules', {}).get('forward_marker', 'Forward')
|
|
named = set(meshes) | {n for n in before['empties'] if not n.startswith('Model_') and not n.startswith(marker)}
|
|
report.check('part_names_match_godot', sorted(named) == sorted(expect['parts']), sorted(named ^ set(expect['parts']))[:12])
|
|
if 'materials' in expect:
|
|
used = sorted({name for m in meshes.values() for name in m['materials'] if name})
|
|
report.check('materials_match_godot', used == sorted(expect['materials']), sorted(set(used) ^ set(expect['materials'])))
|
|
if expect.get('bounds_godot') and before['bounds_unity']:
|
|
# The Godot manifests measure the intact model (towers: without damaged upper part,
|
|
# debris, ruin and cloth); bounds_exclude names those parts.
|
|
exclude = tuple(spec.get('rules', {}).get('bounds_exclude', ()))
|
|
chosen = [m['bounds_unity'] for n, m in meshes.items() if not (exclude and n.startswith(exclude))]
|
|
got = {'min': [min(b['min'][i] for b in chosen) for i in range(3)], 'max': [max(b['max'][i] for b in chosen) for i in range(3)]}
|
|
want = godot_to_unity_bounds(expect['bounds_godot'])
|
|
worst = max(abs(a - b) for key in ('min', 'max') for a, b in zip(got[key], want[key]))
|
|
report.check('bounds_match_godot', worst < 2e-3, 'largest difference %.5f m' % worst)
|
|
armatures = before['armatures']
|
|
if spec['kind'] == 'skinned':
|
|
report.check('one_armature', len(armatures) == 1, sorted(armatures))
|
|
for name, arm in armatures.items():
|
|
report.check('single_root_bone', len(arm['roots']) == 1, arm['roots'])
|
|
want_root = spec.get('rules', {}).get('root_bone', 'Root')
|
|
report.check('root_bone_name', arm['roots'] == [want_root], arm['roots'])
|
|
if 'bone_names' in expect:
|
|
report.check('bone_names_match_godot', sorted(arm['bones']) == sorted(expect['bone_names']), sorted(set(arm['bones']) ^ set(expect['bone_names'])))
|
|
if 'bones' in expect:
|
|
report.check('bone_count_match_godot', len(arm['bones']) == expect['bones'], '%d / %d' % (len(arm['bones']), expect['bones']))
|
|
bad = [b for b in arm['bones'] if not b.replace('.', '').replace('_', '').isalnum()]
|
|
report.check('bone_names_portable', not bad, bad)
|
|
report.check('skinned_meshes_present', any(m['skinned'] for m in meshes.values()), '')
|
|
names = sorted(c['name'] for c in clips)
|
|
if 'clips' in expect:
|
|
report.check('clips_match_godot', names == sorted(expect['clips']), sorted(set(names) ^ set(expect['clips'])))
|
|
required = spec.get('rules', {}).get('required_clips', [])
|
|
report.check('required_clips_present', all(c in names for c in required), [c for c in required if c not in names])
|
|
if 'clip_seconds' in expect:
|
|
worst = max(abs(c['seconds'] - expect['clip_seconds'][c['name']]) for c in clips if c['name'] in expect['clip_seconds'])
|
|
report.check('clip_lengths_match_godot', worst < 1e-3, 'largest difference %.4f s' % worst)
|
|
else:
|
|
report.check('no_armature', not armatures, sorted(armatures))
|
|
forward = spec.get('rules', {}).get('forward_marker')
|
|
if forward:
|
|
marker = next((p for n, p in before['empties'].items() if n.startswith(forward)), None)
|
|
report.check('forward_marker_unity_plus_z', marker is not None and marker[2] > 0.1 and abs(marker[0]) < 1e-3, marker)
|
|
|
|
|
|
# Source file named in the FBX header (ApplicationNativeFile): the manifest path relative to the
|
|
# reference repository, set per asset; generators have no saved file.
|
|
NATIVE_FILE = {'path': ''}
|
|
|
|
|
|
def reproducible_fbx():
|
|
"""Byte-identical FBX for identical sources: Blender's FBX writer stamps the current time into
|
|
the header, writes the absolute path of the open .blend file (ApplicationNativeFile) and derives
|
|
object ids from Python's string hash. The clock is fixed and the path is the manifest's relative
|
|
source path here (since exporter version 11; version 10 wrote the absolute path, so its files
|
|
depend on the checkout location); the hash seed comes from PYTHONHASHSEED=0 with
|
|
--python-use-system-env (tools/blender/export_fbx.sh)."""
|
|
import datetime
|
|
import io_scene_fbx.export_fbx_bin as writer
|
|
|
|
class FixedClock(datetime.datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return cls(2026, 1, 1, 0, 0, 0)
|
|
|
|
class Clock:
|
|
datetime = FixedClock
|
|
|
|
writer.datetime = Clock
|
|
|
|
class Data:
|
|
"""bpy.data with a neutral filepath; everything else is the real data of the open file."""
|
|
@property
|
|
def filepath(self):
|
|
return NATIVE_FILE['path']
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(bpy.data, name)
|
|
|
|
class Bpy:
|
|
data = Data()
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(bpy, name)
|
|
|
|
writer.bpy = Bpy()
|
|
|
|
|
|
def export_fbx(path, objects):
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for obj in objects:
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = objects[0]
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
bpy.ops.export_scene.fbx(filepath=str(path), **FBX_SETTINGS)
|
|
|
|
|
|
def round_trip(path, spec, report, before, clips):
|
|
"""Imports the written FBX into an empty file and compares names and counts."""
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
bpy.ops.import_scene.fbx(filepath=str(path), use_custom_normals=True, use_anim=True, automatic_bone_orientation=False)
|
|
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
imported = [o for o in bpy.context.scene.objects]
|
|
meshes = {o.name: o for o in imported if o.type == 'MESH'}
|
|
report.check('roundtrip_mesh_names', sorted(meshes) == sorted(before['meshes']), sorted(set(meshes) ^ set(before['meshes'])))
|
|
tri = 0
|
|
for obj in meshes.values():
|
|
evaluated, mesh = evaluated_mesh(obj, depsgraph)
|
|
tri += triangles(mesh)
|
|
evaluated.to_mesh_clear()
|
|
report.check('roundtrip_triangles', tri == report.data['triangles'], '%d / %d' % (tri, report.data['triangles']))
|
|
materials = sorted({s.material.name for o in meshes.values() for s in o.material_slots if s.material})
|
|
want = sorted({name for m in before['meshes'].values() for name in m['materials'] if name})
|
|
report.check('roundtrip_materials', materials == want, sorted(set(materials) ^ set(want)))
|
|
if spec['kind'] == 'skinned':
|
|
arm = next(o for o in imported if o.type == 'ARMATURE')
|
|
bones = sorted(b.name for b in arm.data.bones)
|
|
want_bones = sorted(next(iter(before['armatures'].values()))['bones'])
|
|
report.check('roundtrip_bones', bones == want_bones, sorted(set(bones) ^ set(want_bones)))
|
|
takes = sorted(a.name.split('|')[-1] for a in bpy.data.actions)
|
|
report.check('roundtrip_clips', takes == sorted(c['name'] for c in clips), takes)
|
|
|
|
|
|
def run_generator(spec, godot_root, source):
|
|
"""Runs a Blender generator (a source without a saved .blend) in a temporary copy of the
|
|
reference layout, so its own outputs (GLB, manifests) never touch the reference repository."""
|
|
import shutil
|
|
import tempfile
|
|
root = Path(tempfile.mkdtemp(prefix='lune_generator_'))
|
|
target = root / spec['source']
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source, target)
|
|
for extra in spec['generator'].get('inputs', []):
|
|
(root / extra).parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(godot_root / extra, root / extra)
|
|
code = compile(target.read_text(), str(target), 'exec')
|
|
exec(code, {'__file__': str(target), '__name__': '__lune_generator__'})
|
|
shutil.rmtree(root, ignore_errors=True)
|
|
|
|
|
|
def copy_textures(spec, godot_root, report):
|
|
"""Images the FBX materials reference by file name go to Assets/Luneraria/Art/Textures."""
|
|
import shutil
|
|
copied = []
|
|
for image in spec.get('rules', {}).get('textures', []):
|
|
target = UNITY_ROOT / 'Assets/Luneraria/Art/Textures' / Path(image).name
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if not target.exists() or target.read_bytes() != (godot_root / image).read_bytes():
|
|
shutil.copy2(godot_root / image, target)
|
|
copied.append(str(target.relative_to(UNITY_ROOT)))
|
|
if copied:
|
|
report.data['textures'] = copied
|
|
|
|
|
|
def export_asset(spec, godot_root):
|
|
report = Report(spec)
|
|
source = godot_root / spec['source']
|
|
if spec.get('generator'):
|
|
run_generator(spec, godot_root, source)
|
|
NATIVE_FILE['path'] = ''
|
|
else:
|
|
bpy.ops.wm.open_mainfile(filepath=str(source))
|
|
NATIVE_FILE['path'] = spec['source']
|
|
report.data['source_sha256'] = hashlib.sha256(source.read_bytes()).hexdigest()
|
|
copy_textures(spec, godot_root, report)
|
|
expect = godot_expectations(spec, godot_root)
|
|
expect.update(spec.get('expect', {}))
|
|
fps = bpy.context.scene.render.fps / bpy.context.scene.render.fps_base
|
|
prepare(spec, report)
|
|
objects = export_objects(spec)
|
|
# Facts of the bind pose (rest), like the Godot manifests; the animation bake needs POSE again.
|
|
armatures = [o for o in objects if o.type == 'ARMATURE']
|
|
for arm in armatures:
|
|
arm.data.pose_position = 'REST'
|
|
bpy.context.view_layer.update()
|
|
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
before = facts(objects, depsgraph)
|
|
for arm in armatures:
|
|
arm.data.pose_position = 'POSE'
|
|
bpy.context.view_layer.update()
|
|
clips = clip_facts(fps) if spec['kind'] == 'skinned' else []
|
|
report.data.update({'kind': spec['kind'], 'fps': fps, 'objects': len(objects), 'meshes': before['meshes'], 'armatures': before['armatures'],
|
|
'empties': before['empties'], 'bounds_unity': before['bounds_unity'], 'clips': clips,
|
|
'expected_from_godot': {k: expect[k] for k in ('triangles', 'bounds_godot', 'bones', 'clips', 'resource_path') if k in expect}})
|
|
run_checks(spec, report, before, clips, expect)
|
|
out = UNITY_ROOT / 'Assets/Luneraria/Art' / spec['group'] / (spec['id'] + '.fbx')
|
|
export_fbx(out, objects)
|
|
report.data['fbx'] = str(out.relative_to(UNITY_ROOT))
|
|
report.data['fbx_bytes'] = out.stat().st_size
|
|
round_trip(out, spec, report, before, clips)
|
|
report.data['ok'] = report.ok
|
|
(out.with_suffix('.export.json')).write_text(json.dumps(report.data, indent=1) + '\n')
|
|
failed = [c for c in report.data['checks'] if not c['ok']]
|
|
print('LUNE_EXPORT %s %s: %d checks, %d failed%s' % ('OK' if not failed else 'FAIL', spec['id'], len(report.data['checks']), len(failed),
|
|
''.join('\n FAIL %s: %s' % (c['check'], c['detail']) for c in failed)))
|
|
return report.ok
|
|
|
|
|
|
def main():
|
|
reproducible_fbx()
|
|
options = args()
|
|
manifest = json.loads(Path(options['manifest']).read_text())
|
|
godot_root = Path(options['godot']).resolve()
|
|
wanted = [i for i in options['ids'].split(',') if i]
|
|
specs = [s for s in manifest['assets'] if not wanted or s['id'] in wanted]
|
|
missing = set(wanted) - {s['id'] for s in specs}
|
|
ok = not missing
|
|
if missing:
|
|
print('LUNE_EXPORT FAIL unknown ids', sorted(missing))
|
|
for spec in specs:
|
|
try:
|
|
ok = export_asset(spec, godot_root) and ok
|
|
except Exception as error: # a broken asset must not stop the batch
|
|
import traceback
|
|
traceback.print_exc()
|
|
print('LUNE_EXPORT FAIL %s: %s' % (spec['id'], error))
|
|
ok = False
|
|
print('LUNE_EXPORT_DONE ok=%s assets=%d' % (ok, len(specs)))
|
|
sys.exit(0 if ok else 1)
|
|
|
|
|
|
main()
|