"""Run in a separate Blender background process; never saves the input .blend. Exports the scene timeline, with optional per-frame PBR texture atlas baking. """ import argparse import fnmatch import json import math import os from pathlib import Path import struct import sys import tempfile import bpy import numpy as np def deselect_all(): # Blender's operator skips hidden objects that can retain a saved selection. for obj in bpy.context.view_layer.objects: obj.select_set(False) def arguments(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', required=True) parser.add_argument('--scene') parser.add_argument('--objects', action='append', help='Object name glob; repeat to select a subset') parser.add_argument('--bake-materials', action='store_true') parser.add_argument('--resolution', type=int, default=128) parser.add_argument('--samples', type=int, default=8) parser.add_argument('--lighting', choices=['compat', 'spec'], default='compat', help='Unitless lighting for Forma, or glTF physical light units') parser.add_argument('--start-frame', type=int) parser.add_argument('--end-frame', type=int) parser.add_argument('--frame-step', type=int, default=1) parser.add_argument('--allow-lossy', action='store_true', help='Permit features identified in the export report as unsupported') return parser.parse_args(sys.argv[sys.argv.index('--') + 1:]) def tree_nodes(tree, seen=None): seen = set() if seen is None else seen if not tree or tree.as_pointer() in seen: return [] seen.add(tree.as_pointer()) result = list(tree.nodes) for node in tree.nodes: if node.type == 'GROUP': result += tree_nodes(node.node_tree, seen) return result def animated(material): for data in [material, material.node_tree] + [n.node_tree for n in tree_nodes(material.node_tree) if n.type == 'GROUP']: anim = getattr(data, 'animation_data', None) if anim and (anim.action or anim.drivers or anim.nla_tracks): return True return any(n.type == 'TEX_IMAGE' and n.image and n.image.source in {'MOVIE', 'SEQUENCE'} for n in tree_nodes(material.node_tree)) def report_scene(objects, bake): warnings = [] needs_bake = set() unsupported = [] direct = {'ShaderNodeBsdfPrincipled', 'ShaderNodeOutputMaterial', 'ShaderNodeTexImage', 'ShaderNodeNormalMap', 'ShaderNodeUVMap', 'ShaderNodeMapping', 'ShaderNodeSeparateColor', 'ShaderNodeCombineColor', 'ShaderNodeRGB', 'ShaderNodeValue'} for obj in objects: if obj.type not in {'MESH', 'CURVE', 'FONT', 'SURFACE', 'EMPTY', 'ARMATURE', 'CAMERA', 'LIGHT'}: unsupported.append(f'{obj.name}: object type {obj.type} needs conversion/render baking') if obj.type == 'LIGHT' and obj.data.type == 'AREA': unsupported.append(f'{obj.name}: area light is approximated by a point light') for modifier in obj.modifiers: if modifier.type in {'FLUID', 'CLOTH', 'SOFT_BODY', 'PARTICLE_SYSTEM', 'DYNAMIC_PAINT', 'NODES'}: unsupported.append(f'{obj.name}: {modifier.type} is exported as evaluated geometry at the starting frame, not a simulation') if obj.type == 'MESH' and obj.data.shape_keys and modifier.type != 'ARMATURE': unsupported.append(f'{obj.name}: {modifier.type} cannot be applied while preserving shape keys; modifier is omitted') for data in (obj, obj.data): animation = getattr(data, 'animation_data', None) if animation and animation.drivers: warnings.append(f'{obj.name}: drivers are sampled with automatic Python execution disabled; inspect complex driver expressions') for slot in obj.material_slots: material = slot.material if not material: continue nodes = tree_nodes(material.node_tree) types = {n.bl_idname for n in nodes} if types - direct or animated(material): needs_bake.add(material.name) if any('Volume' in t for t in types): unsupported.append(f'{material.name}: volumetric shading cannot be represented by a glTF surface; use engine fog or render baking') if types & {'ShaderNodeLayerWeight', 'ShaderNodeFresnel', 'ShaderNodeLightPath', 'ShaderNodeCameraData'}: unsupported.append(f'{material.name}: view-dependent nodes are frozen by UV baking; inspect the result from different angles') if any(n.type == 'OUTPUT_MATERIAL' and n.inputs['Displacement'].is_linked for n in nodes): unsupported.append(f'{material.name}: displacement needs evaluated geometry; the surface bake does not displace vertices') if not bake and material.name in needs_bake: warnings.append(f'{material.name}: procedural/animated inputs may need --bake-materials; only glTF-compatible property animation transfers directly') if bake: for node in nodes: if node.type == 'BSDF_PRINCIPLED': for name in ('Transmission Weight', 'Coat Weight', 'Sheen Weight', 'Subsurface Weight', 'Thin Film Thickness', 'Anisotropic IOR Level'): socket = node.inputs.get(name) if socket and (socket.is_linked or socket.default_value != 0): unsupported.append(f'{material.name}: baked PBR does not preserve {name}; use native glTF for compatible inputs') return sorted(set(warnings)), sorted(set(unsupported)), sorted(needs_bake) def socket_source(material): tree = material.node_tree output = next((n for n in tree.nodes if n.type == 'OUTPUT_MATERIAL' and n.is_active_output), None) if not output or not output.inputs['Surface'].is_linked: raise RuntimeError(f'{material.name}: no surface shader to bake') surface = output.inputs['Surface'].links[0].from_node alpha = None if surface.type == 'MIX_SHADER': links = [surface.inputs[i].links[0].from_node if surface.inputs[i].is_linked else None for i in (1, 2)] transparent = [i for i, n in enumerate(links) if n and n.type == 'BSDF_TRANSPARENT'] if len(transparent) == 1: transparent_index = transparent[0] alpha = surface.inputs[0] if transparent_index == 1: inverse = tree.nodes.new('ShaderNodeMath') inverse.operation = 'SUBTRACT' inverse.inputs[0].default_value = 1 copy_socket(tree, alpha, inverse.inputs[1]) alpha = inverse.outputs[0] surface = links[1 - transparent_index] if surface and surface.type == 'BSDF_PRINCIPLED': return output, {'color': surface.inputs['Base Color'], 'alpha': alpha or surface.inputs['Alpha'], 'roughness': surface.inputs['Roughness'], 'metallic': surface.inputs['Metallic'], 'emission': surface.inputs['Emission Color'], 'strength': surface.inputs['Emission Strength']}, False if surface and surface.type == 'EMISSION': return output, {'color': surface.inputs['Color'], 'alpha': alpha, 'roughness': None, 'metallic': None, 'emission': surface.inputs['Color'], 'strength': surface.inputs['Strength']}, True raise RuntimeError(f'{material.name}: bake requires a Principled or Emission surface (optionally mixed with Transparent)') def copy_socket(tree, source, target, default=0): if source is None: value = default elif source.is_output: tree.links.new(source, target) return elif source.is_linked: tree.links.new(source.links[0].from_socket, target) return else: value = source.default_value try: target.default_value = value except (TypeError, ValueError): if isinstance(value, (int, float)): target.default_value = (value, value, value, 1) else: target.default_value = float(value[0]) def image_pixels(image, resolution): pixels = np.empty(resolution * resolution * 4, dtype=np.float32) image.pixels.foreach_get(pixels) return pixels.reshape((resolution, resolution, 4)) def bake_object(obj, scene, frames, options, temporary, warnings): # Each object gets its own material because UV/object coordinates can differ. original_slots = [slot.material for slot in obj.material_slots] if not original_slots: return None obj.data = obj.data.copy() for i, material in enumerate(original_slots): if material: obj.material_slots[i].material = material.copy() if not obj.data.uv_layers: deselect_all() obj.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.smart_project(island_margin=.035) bpy.ops.object.mode_set(mode='OBJECT') states = [] for material in [slot.material for slot in obj.material_slots if slot.material]: if not material.use_nodes: material.use_nodes = True output, sockets, unlit = socket_source(material) tree = material.node_tree original = output.inputs['Surface'].links[0].from_socket emission = tree.nodes.new('ShaderNodeEmission') image_node = tree.nodes.new('ShaderNodeTexImage') tree.nodes.active = image_node states.append((tree, output, original, emission, image_node, sockets, unlit)) if not states: return None sample_frames = frames if any(animated(m) for m in original_slots if m) else frames[:1] size = options.resolution pad = 2 cell = size + 2 * pad columns = math.ceil(math.sqrt(len(sample_frames))) rows = math.ceil(len(sample_frames) / columns) width, height = columns * cell, rows * cell if max(width, height) > 8192: raise RuntimeError('Texture atlas exceeds 8192px; increase --frame-step or reduce --resolution') atlases = {key: np.zeros((height, width, 4), dtype=np.float32) for key in ('color', 'orm', 'normal', 'emission')} image = bpy.data.images.new('FormaBakeTarget', width=size, height=size, alpha=True, float_buffer=True) image.colorspace_settings.name = 'Non-Color' for state in states: state[4].image = image deselect_all() obj.select_set(True) bpy.context.view_layer.objects.active = obj try: for index, frame in enumerate(sample_frames): scene.frame_set(frame) for tree, *_ in states: if tree.animation_data: for curve in tree.animation_data.drivers: if not curve.driver.is_valid: raise RuntimeError(f'{obj.name}: invalid/disabled driver {curve.data_path}; prepare its animation in Blender before exporting') channels = {} for channel in ('color', 'alpha', 'roughness', 'metallic', 'emission', 'normal'): for tree, output, original, emission, image_node, sockets, unlit in states: tree.nodes.active = image_node for link in list(output.inputs['Surface'].links): tree.links.remove(link) if channel == 'normal': tree.links.new(original, output.inputs['Surface']) else: for input_socket in emission.inputs: for link in list(input_socket.links): tree.links.remove(link) defaults = {'alpha': 1, 'roughness': .5, 'metallic': 0, 'emission': 0, 'color': .8} copy_socket(tree, sockets.get(channel), emission.inputs['Color'], defaults[channel]) if unlit and channel == 'color': for link in list(emission.inputs['Color'].links): tree.links.remove(link) emission.inputs['Color'].default_value = (0, 0, 0, 1) if channel == 'emission': copy_socket(tree, sockets.get('strength'), emission.inputs['Strength'], 1) else: emission.inputs['Strength'].default_value = 1 tree.links.new(emission.outputs[0], output.inputs['Surface']) bpy.context.view_layer.update() bpy.ops.object.bake(type='NORMAL' if channel == 'normal' else 'EMIT', use_clear=True, margin=8, normal_space='TANGENT') channels[channel] = image_pixels(image, size).copy() channels['color'][:, :, 3] = np.clip(channels['alpha'][:, :, 0], 0, 1) orm = np.ones((size, size, 4), dtype=np.float32) orm[:, :, 1] = channels['roughness'][:, :, 0] orm[:, :, 2] = channels['metallic'][:, :, 0] channels['orm'] = orm y, x = (index // columns) * cell, (index % columns) * cell for channel, atlas in atlases.items(): atlas[y:y+cell, x:x+cell, :] = np.pad(channels[channel], ((pad, pad), (pad, pad), (0, 0)), mode='edge') print(f'FORMA_BAKE {obj.name} {index+1}/{len(sample_frames)}', flush=True) finally: for tree, output, original, emission, image_node, sockets, unlit in states: for link in list(output.inputs['Surface'].links): tree.links.remove(link) tree.links.new(original, output.inputs['Surface']) bpy.data.images.remove(image) strength = max(1.0, float(np.nanmax(atlases['emission'][:, :, :3]))) atlases['emission'][:, :, :3] /= strength material = bpy.data.materials.new('FormaBake_' + obj.name) material.use_nodes = True tree = material.node_tree principled = tree.nodes.get('Principled BSDF') if all(state[6] for state in states): principled.inputs['Specular IOR Level'].default_value = 0 texture_nodes = {} for channel, pixels in atlases.items(): pixels = np.nan_to_num(np.clip(pixels, 0, 1), nan=0, posinf=1, neginf=0) atlas_image = bpy.data.images.new(material.name + '_' + channel, width=width, height=height, alpha=True) atlas_image.colorspace_settings.name = 'sRGB' if channel in {'color', 'emission'} else 'Non-Color' atlas_image.pixels.foreach_set(pixels.ravel()) atlas_image.filepath_raw = str(Path(temporary) / (str(atlas_image.as_pointer()) + '.png')) atlas_image.file_format = 'PNG' atlas_image.save() atlas_image.pack() tex = tree.nodes.new('ShaderNodeTexImage') tex.image = atlas_image texture_nodes[channel] = tex tree.links.new(texture_nodes['color'].outputs['Color'], principled.inputs['Base Color']) tree.links.new(texture_nodes['color'].outputs['Alpha'], principled.inputs['Alpha']) separate = tree.nodes.new('ShaderNodeSeparateColor') tree.links.new(texture_nodes['orm'].outputs['Color'], separate.inputs[0]) tree.links.new(separate.outputs['Green'], principled.inputs['Roughness']) tree.links.new(separate.outputs['Blue'], principled.inputs['Metallic']) normal = tree.nodes.new('ShaderNodeNormalMap') tree.links.new(texture_nodes['normal'].outputs['Color'], normal.inputs['Color']) tree.links.new(normal.outputs['Normal'], principled.inputs['Normal']) tree.links.new(texture_nodes['emission'].outputs['Color'], principled.inputs['Emission Color']) principled.inputs['Emission Strength'].default_value = strength obj.data.materials.clear() obj.data.materials.append(material) for polygon in obj.data.polygons: polygon.material_index = 0 return {'material': material.name, 'frames': sample_frames, 'scale': [size / width, size / height], 'offsets': [[((i % columns) * cell + pad) / width, ((rows - 1 - i // columns) * cell + pad) / height] for i in range(len(sample_frames))], 'alpha': any(np.min(atlases['color'][(i // columns)*cell:(i // columns+1)*cell, (i % columns)*cell:(i % columns+1)*cell, 3]) < .999 for i in range(len(sample_frames))), 'emissionStrength': strength} def read_glb(filename): data = Path(filename).read_bytes() length = struct.unpack_from(' 1024 or options.frame_step < 1 or not 1 <= options.samples <= 256: raise RuntimeError('Resolution must be 16–1024, frame step >= 1, samples 1–256') scene = bpy.data.scenes.get(options.scene) if options.scene else bpy.context.scene if scene is None: raise RuntimeError('Scene not found') bpy.context.window.scene = scene scene.frame_start = options.start_frame if options.start_frame is not None else scene.frame_start scene.frame_end = options.end_frame if options.end_frame is not None else scene.frame_end if scene.frame_end < scene.frame_start: raise RuntimeError('End frame precedes start frame') frames = list(range(scene.frame_start, scene.frame_end + 1, options.frame_step)) if options.bake_materials and len(frames) > 256: raise RuntimeError('At most 256 baked frames; increase --frame-step') scene.frame_set(scene.frame_start) objects = [o for o in scene.objects if not o.hide_render and (not options.objects or any(fnmatch.fnmatchcase(o.name, pattern) for pattern in options.objects))] if not objects: raise RuntimeError('No objects matched the export selection') warnings, unsupported, needs_bake = report_scene(objects, options.bake_materials) report = {'version': 1, 'blender': bpy.app.version_string, 'scene': scene.name, 'objects': len(objects), 'bakedMaterials': [], 'warnings': warnings + unsupported, 'frames': [scene.frame_start, scene.frame_end], 'frameStep': options.frame_step, 'lighting': options.lighting} output = Path(options.output).resolve() output.parent.mkdir(parents=True, exist_ok=True) report_path = output.with_suffix('.report.json') report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2)) if unsupported and not options.allow_lossy: raise RuntimeError('Unsupported features found; inspect ' + str(report_path) + '. Use --allow-lossy only to accept the listed approximations.') for obj in objects: if obj.type == 'LIGHT' and obj.data.type == 'AREA': obj.data = obj.data.copy() obj.data.type = 'POINT' deselect_all() for obj in objects: obj.hide_set(False) obj.select_set(True) bpy.context.view_layer.objects.active = objects[0] object_names = [obj.name for obj in objects] curves = [obj for obj in objects if obj.type in {'CURVE', 'FONT', 'SURFACE'}] for obj in objects: obj.select_set(obj in curves) if curves: bpy.context.view_layer.objects.active = curves[0] bpy.ops.object.convert(target='MESH') # Conversion normally preserves object identity and names. objects = [bpy.data.objects.get(name) for name in object_names] objects = [obj for obj in objects if obj] for obj in objects: if obj.type != 'MESH' or obj.data.shape_keys: continue deselect_all() obj.select_set(True) bpy.context.view_layer.objects.active = obj obj.data = obj.data.copy() for modifier in list(obj.modifiers): if modifier.type == 'ARMATURE': continue if not modifier.show_render: obj.modifiers.remove(modifier) continue modifier.show_viewport = True result = bpy.ops.object.modifier_apply(modifier=modifier.name) if 'FINISHED' not in result: raise RuntimeError(f'{obj.name}: could not apply modifier {modifier.name}') if options.bake_materials: modifier = obj.modifiers.new('Forma triangulation', 'TRIANGULATE') bpy.ops.object.modifier_apply(modifier=modifier.name) with tempfile.TemporaryDirectory(prefix='forma-bake-') as temporary: bakes = [] if options.bake_materials: scene.render.engine = 'CYCLES' scene.cycles.device = 'CPU' scene.cycles.samples = options.samples for obj in objects: if obj.type != 'MESH' or not any(s.material and s.material.name in needs_bake for s in obj.material_slots): continue if any('Volume' in n.bl_idname for slot in obj.material_slots if slot.material for n in tree_nodes(slot.material.node_tree)): continue baked = bake_object(obj, scene, frames, options, temporary, warnings) if baked: bakes.append(baked) scene.frame_set(scene.frame_start) deselect_all() for obj in objects: obj.select_set(True) bpy.context.view_layer.objects.active = objects[0] properties = bpy.ops.export_scene.gltf.get_rna_type().properties required = ['export_pointer_animation', 'export_animation_mode'] if any(name not in properties for name in required): raise RuntimeError('Install a Blender version with glTF Animation Pointer export support') settings = dict(filepath=str(output), export_format='GLB', use_selection=True, use_active_scene=True, export_animations=True, export_animation_mode='SCENE', export_pointer_animation=True, export_force_sampling=True, export_frame_range=True, export_frame_step=options.frame_step, export_skins=True, export_morph=True, export_morph_animation=True, export_cameras=True, export_lights=True, export_extras=True, export_texcoords=True, export_normals=True, export_tangents=True, export_anim_scene_split_object=False, export_convert_animation_pointer=True, export_bake_animation=True, export_gn_mesh=True, export_optimize_animation_size=False) settings['export_import_convert_lighting_mode'] = options.lighting.upper() bpy.ops.export_scene.gltf(**{key: value for key, value in settings.items() if key in properties}) doc, binary = read_glb(output) attach_atlas_animations(doc, binary, bakes, scene) report['bakedMaterials'] = [{'material': b['material'], 'frames': len(b['frames'])} for b in bakes] report['warnings'] = sorted(set(report['warnings'] + warnings)) active_camera = next((doc['cameras'][node['camera']].get('name') for node in doc.get('nodes', []) if scene.camera and node.get('name') == scene.camera.name and 'camera' in node), None) doc.setdefault('extras', {})['forma'] = {'version': 1, 'warnings': report['warnings'], 'source': 'Blender', 'activeCamera': active_camera, 'fps': scene.render.fps / scene.render.fps_base} write_glb(output, doc, binary) report['bytes'] = output.stat().st_size report['clips'] = [animation.get('name') for animation in doc.get('animations', [])] report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2)) print('FORMA_EXPORT ' + json.dumps(report, ensure_ascii=False), flush=True) if __name__ == '__main__': main()