Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 14:21:07 +08:00
co-authored by Cursor
commit f3f66ab063
931 changed files with 112366 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
"""Blender 内运行:高低模 Cycles 烘焙,低模 UV 上出 normal/ao/color/metallic/roughness。
调用:blender -b --factory-startup --python bl_bake.py -- \
<high.fbx|high.glb|high.gltf> <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%
BBOX_TOL = 0.10 # 高低模包围盒尺寸相对差警告阈值
# (输出名, 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 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_model_joined(path, warnings):
"""导入 FBX/GLB/glTF 并把其中所有 mesh join 成单对象;无 mesh 返回 None。"""
import bpy
from bl_decimate import import_model_objects, join_meshes
new = import_model_objects(path)
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 _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_model, 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_model_joined(high_model, warnings)
low = _import_model_joined(low_fbx, warnings)
if high is None or low is None:
print("MT_SUMMARY " + json.dumps(
{"error": "高模或低模中没有 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()