477 lines
27 KiB
Python
477 lines
27 KiB
Python
"""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('<I', data, 12)[0]
|
||
doc = json.loads(data[20:20+length])
|
||
binary = bytearray()
|
||
offset = 20 + length
|
||
while offset + 8 <= len(data):
|
||
size, kind = struct.unpack_from('<II', data, offset)
|
||
if kind == 0x004E4942:
|
||
binary.extend(data[offset+8:offset+8+size])
|
||
offset += 8 + size
|
||
return doc, binary
|
||
|
||
|
||
def write_glb(filename, doc, binary):
|
||
while len(binary) % 4:
|
||
binary.append(0)
|
||
doc['buffers'] = [{'byteLength': len(binary)}] if binary else []
|
||
data = json.dumps(doc, ensure_ascii=False, separators=(',', ':')).encode()
|
||
data += b' ' * (-len(data) % 4)
|
||
chunks = struct.pack('<II', len(data), 0x4E4F534A) + data
|
||
if binary:
|
||
chunks += struct.pack('<II', len(binary), 0x004E4942) + binary
|
||
Path(filename).write_bytes(struct.pack('<III', 0x46546C67, 2, 12 + len(chunks)) + chunks)
|
||
|
||
|
||
def accessor(doc, binary, values, components):
|
||
while len(binary) % 4:
|
||
binary.append(0)
|
||
offset = len(binary)
|
||
flat = [x for value in values for x in (value if isinstance(value, list) else [value])]
|
||
binary.extend(struct.pack('<' + 'f' * len(flat), *flat))
|
||
views = doc.setdefault('bufferViews', [])
|
||
views.append({'buffer': 0, 'byteOffset': offset, 'byteLength': len(flat) * 4})
|
||
accessors = doc.setdefault('accessors', [])
|
||
entry = {'bufferView': len(views)-1, 'componentType': 5126, 'count': len(values), 'type': 'SCALAR' if components == 1 else 'VEC' + str(components)}
|
||
if components == 1:
|
||
entry.update(min=[min(flat)], max=[max(flat)])
|
||
accessors.append(entry)
|
||
return len(accessors)-1
|
||
|
||
|
||
def attach_atlas_animations(doc, binary, bakes, scene):
|
||
fps = scene.render.fps / scene.render.fps_base
|
||
for bake in bakes:
|
||
index = next(i for i, m in enumerate(doc.get('materials', [])) if m.get('name') == bake['material'])
|
||
material = doc['materials'][index]
|
||
material['alphaMode'] = 'BLEND' if bake['alpha'] else 'OPAQUE'
|
||
paths = [('pbrMetallicRoughness/baseColorTexture', material.get('pbrMetallicRoughness', {}).get('baseColorTexture')), ('pbrMetallicRoughness/metallicRoughnessTexture', material.get('pbrMetallicRoughness', {}).get('metallicRoughnessTexture')), ('normalTexture', material.get('normalTexture')), ('emissiveTexture', material.get('emissiveTexture'))]
|
||
for property_path, info in paths:
|
||
if not info:
|
||
continue
|
||
info.setdefault('extensions', {})['KHR_texture_transform'] = {'scale': bake['scale'], 'offset': bake['offsets'][0]}
|
||
samplers = doc.setdefault('samplers', [])
|
||
samplers.append({'magFilter': 9729, 'minFilter': 9729, 'wrapS': 33071, 'wrapT': 33071})
|
||
doc['textures'][info['index']]['sampler'] = len(samplers)-1
|
||
used = doc.setdefault('extensionsUsed', [])
|
||
if 'KHR_texture_transform' not in used:
|
||
used.append('KHR_texture_transform')
|
||
if len(bake['frames']) <= 1:
|
||
continue
|
||
if 'KHR_animation_pointer' not in used:
|
||
used.append('KHR_animation_pointer')
|
||
times = [(frame - scene.frame_start) / fps for frame in bake['frames']]
|
||
times.append((scene.frame_end - scene.frame_start + 1) / fps)
|
||
offsets = bake['offsets'] + [bake['offsets'][0]]
|
||
animations = doc.setdefault('animations', [])
|
||
if not animations:
|
||
animations.append({'name': 'BlenderTimeline', 'channels': [], 'samplers': []})
|
||
animation = animations[0]
|
||
sampler = {'input': accessor(doc, binary, times, 1), 'output': accessor(doc, binary, offsets, 2), 'interpolation': 'STEP'}
|
||
animation['samplers'].append(sampler)
|
||
animation['channels'].append({'sampler': len(animation['samplers'])-1, 'target': {'path': 'pointer', 'extensions': {'KHR_animation_pointer': {'pointer': f'/materials/{index}/{property_path}/extensions/KHR_texture_transform/offset'}}}})
|
||
|
||
|
||
def main():
|
||
if not bpy.app.background:
|
||
raise RuntimeError('Use a separate Blender --background process to preserve the open authoring session')
|
||
options = arguments()
|
||
if options.resolution < 16 or options.resolution > 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()
|