Files

308 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Blender 内运行:高低模 Cycles 烘焙,低模 UV 上出 normal/ao/color/metallic/roughness。
调用:blender -b --factory-startup --python bl_bake.py -- \
<high.fbx> <low.fbx> <outdir> <size> <ray_distance|auto> <samples>
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
DEFAULT_RAY_PCT = 0.02 # 自动射线距离 = 低模包围盒对角线 * 2%
RAY_SAFETY = 1.5 # 自适应射线安全系数(分离 P99 × 此值)
RAY_LOWER_PCT = 0.0005 # 射线下限 = 包围盒对角线 * 0.05%
RAY_UPPER_PCT = 0.01 # 射线上限 = 包围盒对角线 * 1%
BBOX_TOL = 0.10 # 高低模包围盒尺寸相对差警告阈值
def _progress(msg):
"""打印阶段进度;mt_run 会把 MT_PROGRESS 行实时转发给上层 UI。"""
print("MT_PROGRESS " + msg, flush=True)
# (输出名, bake type, EMIT 来源的 Principled 输入, 目标图颜色空间)
BAKE_PASSES = [
("normal", 'NORMAL', None, 'Non-Color'),
("ao", 'AO', None, 'Non-Color'),
("color", 'EMIT', 'Base Color', 'sRGB'),
("metallic", 'EMIT', 'Metallic', 'Non-Color'),
("roughness", 'EMIT', 'Roughness', 'Non-Color'),
]
def estimate_ray_distance(bbox_dims, pct=DEFAULT_RAY_PCT):
"""低模包围盒尺寸 (dx,dy,dz) -> 射线距离 = 对角线长 * pct。"""
return math.sqrt(sum(d * d for d in bbox_dims)) * pct
def percentile(values, q):
"""插值分位数。空列表或 q∉[0,1] 抛 ValueError。"""
if not values:
raise ValueError("percentile 需要至少一个值")
if not 0.0 <= q <= 1.0:
raise ValueError("分位数 q 必须在 [0,1]")
ordered = sorted(float(v) for v in values)
pos = (len(ordered) - 1) * q
lo = int(math.floor(pos))
hi = int(math.ceil(pos))
if lo == hi:
return ordered[lo]
return ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo)
def adaptive_ray_distance(distances, bbox_dims):
"""按低↔高分离距离定射线:P99×RAY_SAFETY,夹在对角线 [0.05%,1%]。
返回 {distance_p99, value, capped};包围盒退化抛 ValueError。"""
diagonal = math.sqrt(sum(float(d) * float(d) for d in bbox_dims))
if diagonal <= 0.0:
raise ValueError("低模包围盒对角线必须为正")
p99 = percentile(distances, 0.99)
lower = diagonal * RAY_LOWER_PCT
upper = diagonal * RAY_UPPER_PCT
raw = max(p99 * RAY_SAFETY, lower)
return {"distance_p99": p99, "value": min(raw, upper), "capped": raw > upper}
def output_stem(low_name):
"""低模名去掉 _low 后缀作为输出前缀(对齐工具3纯网格贴图命名约定)。"""
return low_name[:-4] if low_name.endswith("_low") else low_name
def bbox_mismatch(high_dims, low_dims, tol=BBOX_TOL):
"""任一轴尺寸相对差超 tol 返回 True;接近 0 的轴忽略(平面模型)。"""
for h, l in zip(high_dims, low_dims):
m = max(abs(h), abs(l))
if m > 1e-9 and abs(h - l) / m > tol:
return True
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
obj = join_meshes(meshes)
# 应用物体变换(世界坐标不变):cage_extrusion/max_ray_distance 按低模局部空间解释,
# 未应用的缩放(如 cm 单位 FBX 的 scale=0.01)会把射线距离缩到近零导致大面积打空(脏色)
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
return obj
def _measure_separation(low, high, limit=10000):
"""低模面心到高模表面最近距离数组(供自适应射线)。超 limit 面则均匀采样。
高模 BVH 无命中的面丢弃;返回距离列表(可能为空)。"""
from mathutils.bvhtree import BVHTree
hv = [high.matrix_world @ v.co for v in high.data.vertices]
hp = [tuple(p.vertices) for p in high.data.polygons]
bvh = BVHTree.FromPolygons(hv, hp, all_triangles=False)
polys = low.data.polygons
n = len(polys)
if n == 0:
return []
if n <= limit:
idxs = range(n)
else:
idxs = [round(i * (n - 1) / float(limit - 1)) for i in range(limit)]
mw = low.matrix_world
dists = []
for i in idxs:
hit = bvh.find_nearest(mw @ polys[i].center)
if hit[0] is not None and hit[3] is not None:
dists.append(hit[3])
return dists
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):
# 该通道无贴图,把常量值直接烘入
warnings.append("%s.%s 无贴图,(常量) %.3f 烘入" % (mat.name, input_name, v))
emit.inputs['Color'].default_value = (v, v, v, 1.0)
else: # Base Color 是 4 分量
# 该通道无贴图,把常量色直接烘入
warnings.append("%s.%s 无贴图,(常量色 %.3f/%.3f/%.3f) 烘入"
% (mat.name, input_name, v[0], v[1], v[2]))
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)
_progress("导入高模 FBX…")
high = _import_fbx_joined(high_fbx, warnings)
_progress("导入低模 FBX…")
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]))
if ray_arg != "auto":
ray = float(ray_arg)
ray_info = {"source": "explicit", "distance_p99": None,
"value": ray, "capped": False}
else:
_progress("测量自适应射线距离…")
dists = _measure_separation(low, high)
try:
if not dists:
raise ValueError("低模面心到高模无有效最近距离")
ray_info = adaptive_ray_distance(dists, low_dims)
ray_info["source"] = "adaptive"
ray = ray_info["value"]
if ray_info["capped"]:
warnings.append("自适应射线达包围盒对角线 1% 上限,请检查高低模对应关系")
except ValueError as e:
ray = estimate_ray_distance(low_dims)
ray_info = {"source": "fixed_fallback", "distance_p99": None,
"value": ray, "capped": False}
warnings.append("自适应射线测量失败(%s),回退固定射线" % e)
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 = {}
total = len(BAKE_PASSES)
for i, (name, bake_type, emit_input, colorspace) in enumerate(BAKE_PASSES):
_progress("烘焙 %s (%d/%d)…" % (name, i + 1, total))
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)
_progress("导出低模 FBX…")
# 导出低模:剥掉烘焙 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)
ray_info = dict(ray_info)
ray_info["distance_p99"] = (None if ray_info["distance_p99"] is None
else round(ray_info["distance_p99"], 6))
ray_info["value"] = round(ray_info["value"], 6)
print("MT_SUMMARY " + json.dumps(
{"stem": stem, "fbx": os.path.basename(out_fbx), "outputs": outputs,
"size": size, "ray_distance": round(ray, 6), "ray": ray_info,
"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()