ModelTranslator: 高低模烘焙 Blender 主流程(NORMAL/AO + EMIT trick)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7c3b86a995
commit
eed388770b
@@ -38,3 +38,169 @@ def bbox_mismatch(high_dims, low_dims, tol=BBOX_TOL):
|
|||||||
if m > 1e-9 and abs(h - l) / m > tol:
|
if m > 1e-9 and abs(h - l) / m > tol:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 以下仅在 Blender 内执行 ----------------
|
||||||
|
|
||||||
|
|
||||||
|
def _import_fbx_joined(path, warnings):
|
||||||
|
"""导入 FBX 并把其中所有 mesh join 成单对象;无 mesh 返回 None。"""
|
||||||
|
import bpy
|
||||||
|
from bl_decimate import join_meshes
|
||||||
|
before = set(bpy.data.objects)
|
||||||
|
bpy.ops.import_scene.fbx(filepath=path)
|
||||||
|
new = [o for o in bpy.data.objects if o not in before]
|
||||||
|
meshes = [o for o in new if o.type == 'MESH']
|
||||||
|
skipped = [o.name for o in new if o.type != 'MESH']
|
||||||
|
if skipped:
|
||||||
|
warnings.append("%s:忽略非 mesh 对象 %s"
|
||||||
|
% (os.path.basename(path), ", ".join(skipped)))
|
||||||
|
if not meshes:
|
||||||
|
return None
|
||||||
|
return join_meshes(meshes)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_bake_target(low, stem):
|
||||||
|
"""低模换成单一烘焙材质,返回接收烘焙结果的 image 节点。"""
|
||||||
|
import bpy
|
||||||
|
mat = bpy.data.materials.new(stem)
|
||||||
|
mat.use_nodes = True
|
||||||
|
node = mat.node_tree.nodes.new('ShaderNodeTexImage')
|
||||||
|
mat.node_tree.nodes.active = node
|
||||||
|
low.data.materials.clear()
|
||||||
|
low.data.materials.append(mat)
|
||||||
|
return mat, node
|
||||||
|
|
||||||
|
|
||||||
|
def _high_materials(high):
|
||||||
|
return [s.material for s in high.material_slots if s.material]
|
||||||
|
|
||||||
|
|
||||||
|
def _rewire_emit(mat, input_name, warnings):
|
||||||
|
"""把 Principled 指定输入(连线或常量)改接 Emission 直连输出,供 EMIT 烘焙。"""
|
||||||
|
import bpy
|
||||||
|
if not mat.use_nodes:
|
||||||
|
mat.use_nodes = True
|
||||||
|
nt = mat.node_tree
|
||||||
|
bsdf = next((n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED'), None)
|
||||||
|
out = next((n for n in nt.nodes if n.type == 'OUTPUT_MATERIAL'), None)
|
||||||
|
if out is None:
|
||||||
|
out = nt.nodes.new('ShaderNodeOutputMaterial')
|
||||||
|
emit = nt.nodes.get("mt_emit")
|
||||||
|
if emit is None:
|
||||||
|
emit = nt.nodes.new('ShaderNodeEmission')
|
||||||
|
emit.name = "mt_emit"
|
||||||
|
for l in list(emit.inputs['Color'].links):
|
||||||
|
nt.links.remove(l)
|
||||||
|
if bsdf is None:
|
||||||
|
warnings.append("材质 %s 无 Principled BSDF,%s 用 0.5 灰常量"
|
||||||
|
% (mat.name, input_name))
|
||||||
|
emit.inputs['Color'].default_value = (0.5, 0.5, 0.5, 1.0)
|
||||||
|
else:
|
||||||
|
sock = bsdf.inputs[input_name]
|
||||||
|
if sock.is_linked:
|
||||||
|
nt.links.new(sock.links[0].from_socket, emit.inputs['Color'])
|
||||||
|
else:
|
||||||
|
v = sock.default_value
|
||||||
|
if isinstance(v, float):
|
||||||
|
emit.inputs['Color'].default_value = (v, v, v, 1.0)
|
||||||
|
else: # Base Color 是 4 分量
|
||||||
|
emit.inputs['Color'].default_value = (v[0], v[1], v[2], 1.0)
|
||||||
|
for l in list(out.inputs['Surface'].links):
|
||||||
|
nt.links.remove(l)
|
||||||
|
nt.links.new(emit.outputs['Emission'], out.inputs['Surface'])
|
||||||
|
|
||||||
|
|
||||||
|
def _bake_pass(low, high, target_node, name, bake_type, size, colorspace,
|
||||||
|
ray, samples, outdir, stem):
|
||||||
|
"""执行一个烘焙 pass 并保存 PNG,返回文件名。"""
|
||||||
|
import bpy
|
||||||
|
img = bpy.data.images.new("mt_bake_" + name, size, size, alpha=False)
|
||||||
|
img.colorspace_settings.name = colorspace
|
||||||
|
target_node.image = img
|
||||||
|
|
||||||
|
bpy.ops.object.select_all(action='DESELECT')
|
||||||
|
high.select_set(True)
|
||||||
|
low.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = low
|
||||||
|
bpy.context.scene.cycles.samples = samples
|
||||||
|
|
||||||
|
kwargs = dict(type=bake_type, use_selected_to_active=True,
|
||||||
|
cage_extrusion=ray, max_ray_distance=ray * 2.0,
|
||||||
|
margin=16, use_clear=True)
|
||||||
|
if bake_type == 'NORMAL':
|
||||||
|
kwargs["normal_space"] = 'TANGENT' # OpenGL +Y
|
||||||
|
bpy.ops.object.bake(**kwargs)
|
||||||
|
|
||||||
|
path = os.path.abspath(os.path.join(outdir, "%s_%s.png" % (stem, name)))
|
||||||
|
img.filepath_raw = path
|
||||||
|
img.file_format = 'PNG'
|
||||||
|
img.save()
|
||||||
|
bpy.data.images.remove(img)
|
||||||
|
return os.path.basename(path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import bpy
|
||||||
|
import json
|
||||||
|
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||||
|
high_fbx, low_fbx, outdir = argv[0], argv[1], argv[2]
|
||||||
|
size, ray_arg, ao_samples = int(argv[3]), argv[4], int(argv[5])
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
high = _import_fbx_joined(high_fbx, warnings)
|
||||||
|
low = _import_fbx_joined(low_fbx, warnings)
|
||||||
|
if high is None or low is None:
|
||||||
|
print("MT_SUMMARY " + json.dumps(
|
||||||
|
{"error": "高模或低模 FBX 中没有 mesh"}, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
if not low.data.uv_layers:
|
||||||
|
print("MT_SUMMARY " + json.dumps(
|
||||||
|
{"error": "低模没有 UV 层,请先用 model_decimate.py 生成"},
|
||||||
|
ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
high_dims, low_dims = tuple(high.dimensions), tuple(low.dimensions)
|
||||||
|
if bbox_mismatch(high_dims, low_dims):
|
||||||
|
warnings.append("高低模包围盒尺寸差异超 %d%%:高 %s 低 %s,确认是同一模型且坐标已对齐"
|
||||||
|
% (BBOX_TOL * 100,
|
||||||
|
[round(d, 3) for d in high_dims],
|
||||||
|
[round(d, 3) for d in low_dims]))
|
||||||
|
ray = estimate_ray_distance(low_dims) if ray_arg == "auto" else float(ray_arg)
|
||||||
|
|
||||||
|
stem = output_stem(os.path.splitext(os.path.basename(low_fbx))[0])
|
||||||
|
os.makedirs(outdir, exist_ok=True)
|
||||||
|
|
||||||
|
scene = bpy.context.scene
|
||||||
|
scene.render.engine = 'CYCLES'
|
||||||
|
scene.cycles.device = 'CPU'
|
||||||
|
bake_mat, target_node = _setup_bake_target(low, stem)
|
||||||
|
|
||||||
|
mats = _high_materials(high)
|
||||||
|
outputs = {}
|
||||||
|
for name, bake_type, emit_input, colorspace in BAKE_PASSES:
|
||||||
|
if emit_input is not None: # EMIT trick:先改写全部高模材质
|
||||||
|
for m in mats:
|
||||||
|
_rewire_emit(m, emit_input, warnings)
|
||||||
|
samples = ao_samples if bake_type == 'AO' else 1
|
||||||
|
outputs[name] = _bake_pass(low, high, target_node, name, bake_type,
|
||||||
|
size, colorspace, ray, samples, outdir, stem)
|
||||||
|
|
||||||
|
# 导出低模:剥掉烘焙 image 节点,材质名保留 stem(工具3 兜底识别用)
|
||||||
|
bake_mat.node_tree.nodes.remove(target_node)
|
||||||
|
out_fbx = os.path.join(outdir, stem + ".fbx")
|
||||||
|
bpy.ops.object.select_all(action='DESELECT')
|
||||||
|
low.select_set(True)
|
||||||
|
bpy.ops.export_scene.fbx(filepath=out_fbx, use_selection=True,
|
||||||
|
path_mode='STRIP', embed_textures=False)
|
||||||
|
|
||||||
|
print("MT_SUMMARY " + json.dumps(
|
||||||
|
{"stem": stem, "fbx": os.path.basename(out_fbx), "outputs": outputs,
|
||||||
|
"size": size, "ray_distance": round(ray, 6),
|
||||||
|
"high_tris": len(high.data.polygons), "low_tris": len(low.data.polygons),
|
||||||
|
"warnings": warnings}, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__" and "--" in sys.argv:
|
||||||
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user