champions-of-luneraria/tools/blender/lune_fbx_export.py
Martin Stickelbroeck c386206876 U5.5-pilot Figuren: Meshy-Kohlenbeißer, Lava-Glut, GPU-Skinning
Pilot für eine generierte Figur: der Kohlenbeißer g01 kommt als Meshy-Ergebnis
in den Port und ersetzt dort das Phase-4-Modell. Eine Figur genügt, um Pipeline,
Bild und Leistung zu bewerten; Anatomie, Gesichter und Haare bleiben
Künstler-Sculpts oder lizenzierte Packs (Vorgabe vom 1. Oktober).

- Meshy-Pipeline unter tools/meshy/: API-Client (meshy.py, Ledger, Schlüssel
  nie geloggt) und Blender-Generator (build_figure.py) aus den unveränderten
  Downloads in assets-source/meshy/g01/. Export über den Manifest-Eintrag
  kohlenbeisser mit "replaces": "g01"; LuneUnitLibraryBuilder hängt die Figur
  zusätzlich unter der Godot-ID ein.
- UV-Material AI_Kohlenbeisser (Familie authored): die Karten der Figur selbst,
  Lava-Glut aus der Albedo-Alpha (Meshy 7.1 ohne Emission-Map) mit _Glow 1,25.
- Schatten ohne Normalen-Versatz (LuneShadowBias, _ShadowNormalScale 0): die
  UV-Naht-Kopien tragen geknickte Normalen; URP ApplyShadowBias zog helle
  Nähte durch den Schatten. Die Figur wirft nur mit Tiefen-Bias.
- GPU-Batched-Skinning (PlayerSettings.meshDeformation, LuneProjectSetup):
  35 Meshy-Kohlenbeißer kosteten mit CPU-Skinning rund 1 ms Haupt- und
  1,7 ms Render-Thread je Bild.
- LuneBenchmark: -luneBenchmarkRound und -luneBenchmarkArmy (Figuren-Stress).
  LuneAssetPreview: LUNE_ONLY für Vorher/Nachher, Trefferframe aus der
  Kontaktzeit des Manifests. FigureViewsTests (Explicit) für die vier
  Spielmomente der Figur.
- Tests: EditMode 29/29, PlayMode 14/14 (6 Explicit), Verify 718/0,
  Player-Selbsttest 30/30, Kerntests 457/457, Fingerabdrücke unverändert
  (DCE95C96556F3A1A, 7B8E43AEFC0D60C5).
- Leistung (A/B im selben Fenster, 35x g01): Meshy -21 % Mid (109,7 zu 138,8)
  und -13 % Karte (91,7 zu 105,6) gegenüber Phase-4. GPU-Skinning hebt Mid
  um 7-27 % und entlastet Haupt- und Render-Thread. Standard-Partie ab Runde 21
  ohne g01 im Bild: drei Varianten bis 6 %.
- Doku: U5-VISUAL (U5.5), PORT-QA, PORT-STATUS, KNOWN-BEHAVIOR-DIFFERENCES;
  Bilder screenshots/u5.5/ und u5.5-gpu/, Rohdaten logs/u5.5/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:14:59 +02:00

575 lines
29 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, source_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.
Generators of the Unity project itself (source_root "unity", the Meshy figures of U5.5) get
their arguments and the project root (LUNE_ARGS, LUNE_UNITY_ROOT) and may write textures there."""
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(source_root / extra, root / extra)
code = compile(target.read_text(), str(target), 'exec')
exec(code, {'__file__': str(target), '__name__': '__lune_generator__',
'LUNE_ARGS': spec['generator'].get('args', []), 'LUNE_UNITY_ROOT': str(UNITY_ROOT)})
shutil.rmtree(root, ignore_errors=True)
# What the generator reports about its build (the Meshy figures: clips, textures) goes to the report.
return bpy.app.driver_namespace.pop('lune_generator', None)
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)
# Sources live in the Godot reference, except the Unity project's own (source_root "unity").
source_root = UNITY_ROOT if spec.get('source_root') == 'unity' else godot_root
source = source_root / spec['source']
if spec.get('generator'):
generated = run_generator(spec, source_root, source)
if generated:
report.data['generator'] = generated
NATIVE_FILE['path'] = ''
report.data['inputs_sha256'] = {extra: hashlib.sha256((source_root / extra).read_bytes()).hexdigest()
for extra in spec['generator'].get('inputs', [])}
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()