308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""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()
|