champions-of-luneraria/tools/blender/lune_fbx_export.py
Martin Stickelbroeck 3ca38a8443 U2 Batch Asset Import
Alle 86 Modelle der Godot-Referenz laufen durch die Blender→FBX-Pipeline
(nach bestandenem Vertical Function Test); jede Einheit zeigt ihr eigenes
Modell.

- Exportmanifest aus den Godot-Manifesten (tools/blender/make_manifest.py):
  Units, Enemies, Props, Characters, Peoples, Buildings, Environment; die
  drei Vergleichs-GLBs als Aliase. Neu in der Pipeline: Tangenten berechnet
  Unity mit MikkTSpace, Generatoren laufen in einer temporären Kopie, die
  Grüne Kluft wird wie das GLB zusammengefasst, Texturen kommen mit.
  1.616 Blender- und 1.247 Unity-Prüfungen grün, zwei Exportläufe
  byteidentisch; Kontaktbögen je Gruppe.
- Einheitenbibliothek nach Godots AssetLibrary: 70 Einträge der
  Einheitengruppen, Figuren mit Skelett ersetzen das starre Modell ihrer ID.
  Starre Modelle werden zur Laufzeit je Material zusammengefasst; Controller
  werden an Ort und Stelle aktualisiert.
- Tests mit allen Modellen: EditMode 11/11, PlayMode 2/2. Vertical Function
  Test Seed 2 DCE95C96556F3A1A und Seed 9 ab Runde 21 7B8E43AEFC0D60C5,
  beide gleich dem Kern (Nachweise mit LUNE_EVIDENCE=u2a-batch; die Belege
  von U2C und U2D bleiben unverändert).
- macOS-Player mit allen Modellen: 158-160 fps in der Übersicht
  (116 Einheiten), 205-207 fps auf der Mid-Lane; Haupt- und Render-Thread
  etwa doppelt so lang wie mit dem Platzhalter, Present-Ausreißer kürzer.
- Befund iCloud Drive: Konfliktkopien ("Name 2.ext") im Build-Cache ließen
  den Build die Einheitenbibliothek mit altem Feldlayout schreiben; der
  Player meldete sharedassets0.assets als beschädigt. LunePlayerBuild
  entfernt solche Kopien vor dem Build und bricht ab, wenn während des
  Builds neue entstehen. Bereits committete Kopien: Controller-Duplikate und
  World/Generated 2.meta entfernt, drei U2D-Benchmarkläufe als -run1 bis
  -run3 benannt.
- Dokumentation: U2-WORLD (Batch-Import), PORT-STATUS (Entscheidungen:
  Modelle direkt in Git, Projektort außerhalb von iCloud Drive), PORT-QA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:10:35 +02:00

537 lines
27 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 = 10
# 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)
def reproducible_fbx():
"""Byte-identical FBX for identical sources: Blender's FBX writer stamps the current time into
the header and derives object ids from Python's string hash. The clock is fixed here; 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
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)
else:
bpy.ops.wm.open_mainfile(filepath=str(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()