Add AIServer, character art pipeline, and Art package boot integration.

Bring in MaleExplorer assets/shaders, model tooling and Hunyuan updates, plus boot/YooAsset Art package support.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 17:06:59 +08:00
co-authored by Cursor
parent b7a0bbcb89
commit 6550b8df4b
121 changed files with 313260 additions and 113 deletions
+36 -28
View File
@@ -1,48 +1,56 @@
# 高模减面与贴图烘焙
# 高模减面与贴图烘焙 + XPbr 打包
复刻自 `ClientTrunk/Tools/ModelTools`(`model_decimate` + `model_bake`)。
复刻自 ClientTrunk ModelTools,并接入 AIC#Project ModelTranslator 的 **XPbr** 收尾。
```text
高模(FBX / GLB / glTF)
-> model_decimate.py:合并 Mesh、清理、三角化、减面、重展 UV
-> <名称>_low.fbx
-> model_bake.py:Cycles selected-to-active 烘焙
-> 低模 FBX + color / normal / ao / metallic / roughness PNG
-> model_decimate:减面 + 重展 UV -> <名>_low.fbx
-> model_bake:Cycles 烘焙 -> 五通道 PNG + 低模 FBX
-> model_translator:打包为 XPbr 两张图 + .mat/.meta
_base.png RGB=Albedo A=AO/透明
_mix.png RG=球面法线 B=Metallic A=Roughness
```
## 环境
- 安装 Blender(推荐 5.0),并满足其一:
- `blender` 在 PATH
- 环境变量 `BLENDER_EXE` 或 `BLENDER_PATH`
- 运行时 `--blender "D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe"`
- 本机默认路径已写在 `mt_run.py`:`D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe`
- UV 观察图需要 Pillow:`pip install Pillow`
- UVPackmaster 4 可选(已装则自动用于 UV 排布)
- Blender 5.0:默认 `D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe`
- 或 `BLENDER_EXE` / `BLENDER_PATH` / `--blender`
- Pillow(UV 观察图):`pip install Pillow`
- UVPackmaster 4 可选
## 分步命令
## 一键(推荐)
```powershell
python Tools/model_tools/model_decimate.py D:\Assets\house.glb --tris 5000 -o D:\Assets\house_low
python Tools/model_tools/model_bake.py D:\Assets\house.glb D:\Assets\house_low\house_low.fbx -o D:\Assets\house_bake --size 2048
python Tools/model_tools/model_lowpoly_bake.py prop.glb -o Tools/output/prop_game --tris 5000 --size 2048
# 默认会继续生成 Tools/output/prop_game/prop_xpbr/(base+mix+.mat)
# 跳过 XPbr:加 --no-xpbr
```
## 一键流水线(推荐 Agent 使用)
## 分步
```powershell
python Tools/model_tools/model_lowpoly_bake.py D:\Assets\house.glb -o Tools/output/house --tris 5000 --size 2048
python Tools/model_tools/model_decimate.py prop.glb --tris 5000 -o Tools/output/prop_low
python Tools/model_tools/model_bake.py prop.glb Tools/output/prop_low/prop_low.fbx -o Tools/output/prop_bake --size 2048
python Tools/model_tools/model_translator.py Tools/output/prop_bake/prop/prop.fbx -o Tools/output/prop_xpbr
```
输出示例:
## 装入 Unity + 预设
```text
Tools/output/house/house/
house.fbx
house_color.png
house_normal.png
house_ao.png
house_metallic.png
house_roughness.png
```powershell
python Tools/install_character_bundles.py -a MaleExplorer -b Tools/output/prop_game/prop_xpbr -c ref.png
# Unity 菜单:AIGame/M3/Setup Character Prefab + Art Collector
# 会挂 Test/XPbr,生成 Characters/MaleExplorer/MaleExplorer.prefab
```
生成完成后请在 `Doc/ResDesc_Model.md` 登记资源。
## 与 AIC#Project/ModelTranslator 减面烘焙对比
| 能力 | 本仓库 `Tools/model_tools`(现) | AIC#Project ModelTranslator |
| --- | --- | --- |
| 高模格式 | FBX/GLB/glTF | 主路径偏 FBX(减面 CLI 文案) |
| 减面后端 | Decimate collapse | **默认 Quad Remesher**,失败回退 collapse |
| UV 质量门 | 翻转/重叠 + **最大单岛占比**(已对齐) | 翻转/重叠/最大单岛 + 更深 seam 扫描 |
| 烘焙射线 | **自适应分离距离**(已对齐) | 自适应分离距离 |
| 收尾 | **XPbr base/mix + .mat**(已接入) | 同左(源头) |
| QR 引擎 | 未捆绑(无 xremesh 依赖) | 需本机 Quad Remesher 引擎 |
**结论**:AIC#Project 在「有机角色拓扑」上更优(Quad Remesher + 更深 UV 门);本仓库已并入其自适应射线、大岛门限与 XPbr 打包,并保留 GLB 输入。若本机已装 QR 引擎,可再把 `qr_bridge` / `--reducer quad` 完整迁入作为可选增强。
+72 -2
View File
@@ -8,7 +8,10 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
DEFAULT_RAY_PCT = 0.02 # 自动射线距离 = 低模包围盒对角线 * 2%
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 # 高低模包围盒尺寸相对差警告阈值
# (输出名, bake type, EMIT 来源的 Principled 输入, 目标图颜色空间)
@@ -26,6 +29,33 @@ def estimate_ray_distance(bbox_dims, pct=DEFAULT_RAY_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%]。"""
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
@@ -65,6 +95,29 @@ def _import_model_joined(path, warnings):
return obj
def _measure_separation(low, high, limit=10000):
"""低模面心到高模表面最近距离数组(供自适应射线)。"""
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
@@ -177,7 +230,24 @@ def main():
% (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)
if ray_arg == "auto":
dists = _measure_separation(low, high)
if dists:
try:
adapt = adaptive_ray_distance(dists, low_dims)
ray = adapt["value"]
warnings.append(
"自适应射线距离=%.6f(P99=%.6f%s)"
% (ray, adapt["distance_p99"],
",已触顶" if adapt["capped"] else ""))
except ValueError as exc:
ray = estimate_ray_distance(low_dims)
warnings.append("自适应射线失败(%s),回退对角线 2%%" % exc)
else:
ray = estimate_ray_distance(low_dims)
warnings.append("分离距离采样为空,回退对角线 2%% 射线")
else:
ray = float(ray_arg)
stem = output_stem(os.path.splitext(os.path.basename(low_fbx))[0])
os.makedirs(outdir, exist_ok=True)
+409
View File
@@ -0,0 +1,409 @@
"""Blender 内运行:FBX -> 纯模型 FBX + XPbr 基础/混合贴图。
调用:blender -b --factory-startup --python bl_convert.py -- <src.fbx> <outdir> <max_size>
"""
import os
import re
import sys
import numpy as np
# blender --python 不会把脚本目录加入 sys.path,手动加入以复用 unity_assets(纯 stdlib)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from unity_assets import safe_name
AO_NAME_RE = re.compile(r"(ao|occlusion|ambient)", re.I)
def encode_normal_rg(n):
"""切线法线 (N,3) -> 球面编码 (N,2),范围 [0,1]。
shader 侧 XPbr_Base.hlsl DecodeSphereMap 先 *2-1 再解码。"""
n = n / np.maximum(np.linalg.norm(n, axis=-1, keepdims=True), 1e-8)
z = np.clip(n[..., 2], -0.99, 1.0)
denom = np.sqrt(2.0 * (1.0 + z))
enc = n[..., :2] / denom[..., None]
return np.clip(enc * 0.5 + 0.5, 0.0, 1.0)
def decode_sphere_map(enc01):
"""shader DecodeSphereMap 的 numpy 等价(含 *2-1),仅测试用。"""
e = enc01 * 2.0 - 1.0
l = 1.0 - e[..., 0] ** 2 - e[..., 1] ** 2
l = np.clip(l, 0.0, 1.0)
xy = e * np.sqrt(l)[..., None]
return np.concatenate([xy * 2.0, (l * 2.0 - 1.0)[..., None]], axis=-1)
def linear_to_srgb(c):
c = np.clip(c, 0.0, 1.0)
return np.where(c <= 0.0031308, c * 12.92,
1.055 * np.power(c, 1.0 / 2.4) - 0.055)
def encode_base_texture_for_png(rgba, rgb_is_linear):
"""Base RGB is stored for Unity sRGB import; alpha keeps AO/opacity data.
Image.pixels 对 8-bit 图返回原始字节值(已是 sRGB 编码,实测 blender 5.0),
仅线性来源(float 图 / Principled 常量色)需要 linear->sRGB,否则双重 gamma 变亮。"""
out = np.clip(np.array(rgba, dtype=np.float32, copy=True), 0.0, 1.0)
if rgb_is_linear:
out[..., :3] = linear_to_srgb(out[..., :3])
return out
# ---------------- 以下仅在 Blender 内执行 ----------------
IMG_EXTS = ('.png', '.jpg', '.jpeg', '.tga', '.exr', '.bmp', '.tif', '.tiff')
# 磁盘贴图命名约定:文件名末段 token -> 贴图槽位
DISK_MAP_TOKENS = {
"color": "basecolor", "basecolor": "basecolor", "albedo": "basecolor",
"diffuse": "basecolor",
"normal": "normal", "nrm": "normal",
"metallic": "metallic", "metalness": "metallic", "metal": "metallic",
"roughness": "roughness", "rough": "roughness",
"ao": "ao", "occlusion": "ao", "ambient": "ao",
"alpha": "alpha", "opacity": "alpha",
}
def _looks_like_ao(name):
return bool(AO_NAME_RE.search(name))
def _image_broken(img):
"""外置贴图文件丢失/加载失败时,Blender 留下 0 尺寸空图。"""
return img.size[0] == 0 or img.size[1] == 0
def _image_stem(img):
"""贴图文件名(不含扩展名);外置贴图优先用 filepath,比 img.name 可靠。"""
path = img.filepath if img.filepath else img.name
return os.path.splitext(os.path.basename(path))[0]
def _load_disk_image(src_dir, fname, warnings):
"""加载 FBX 同目录的贴图文件,失败返回 None。"""
import bpy
try:
img = bpy.data.images.load(os.path.join(src_dir, fname), check_existing=True)
except RuntimeError as e:
warnings.append("贴图 %s 加载失败:%s" % (fname, e))
return None
if _image_broken(img):
warnings.append("贴图 %s 加载失败(0 尺寸)" % fname)
return None
return img
def _find_maps_on_disk(src_dir, model_name, maps, warnings):
"""材质无任何贴图(如纯网格 FBX)时,按命名约定从 FBX 同目录识别整套贴图。
文件按 <前缀>_<token>.<ext> 归组;多套前缀时取与模型同名的一组。"""
try:
files = sorted(os.listdir(src_dir))
except OSError:
return
groups = {} # 前缀 -> {槽位: 文件名}
for f in files:
stem, ext = os.path.splitext(f)
if ext.lower() not in IMG_EXTS or "_" not in stem:
continue
prefix, token = stem.rsplit("_", 1)
key = DISK_MAP_TOKENS.get(token.lower())
if key:
groups.setdefault(prefix, {}).setdefault(key, f)
if not groups:
return
if model_name in groups:
chosen = groups[model_name]
elif len(groups) == 1:
chosen = next(iter(groups.values()))
else:
warnings.append("同目录有多套命名约定贴图(前缀:%s),无法确定归属,忽略"
% ", ".join(sorted(groups)))
return
for key, fname in chosen.items():
if maps.get(key):
continue
img = _load_disk_image(src_dir, fname, warnings)
if img is not None:
maps[key] = (img, None)
def _find_ao_on_disk(src_dir, maps, warnings):
"""材质未引用 AO 时,从 FBX 同目录按文件名兜底搜索(烘焙工具常只把 _ao.png 放旁边)。
多候选时优先与 basecolor 贴图同前缀(xxx_color.png -> xxx_ao.png)。"""
import bpy
try:
files = sorted(os.listdir(src_dir))
except OSError:
return None
cands = [f for f in files if f.lower().endswith(IMG_EXTS)
and _looks_like_ao(os.path.splitext(f)[0])]
if not cands:
return None
pick = None
if maps["basecolor"] and len(cands) > 1:
prefix = _image_stem(maps["basecolor"][0]).rsplit("_", 1)[0]
matched = [f for f in cands if f.startswith(prefix)]
if matched:
pick = matched[0]
if pick is None:
if len(cands) == 1:
pick = cands[0]
else:
warnings.append("同目录有多张疑似 AO 贴图无法确定归属,忽略:%s" % ", ".join(cands))
return None
return _load_disk_image(src_dir, pick, warnings)
def _image_pixels(img):
"""bpy Image -> float32 (h, w, 4),原始值(不做色彩变换)。"""
w, h = img.size
buf = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(buf)
return buf.reshape(h, w, 4)
def _scaled_pixels(img, tw, th):
"""取缩放到 (tw, th) 的像素;用副本 scale,不污染原图。"""
if tuple(img.size) == (tw, th):
return _image_pixels(img)
cp = img.copy()
try:
cp.scale(tw, th)
return _image_pixels(cp)
finally:
import bpy
bpy.data.images.remove(cp)
def _trace_image(socket, warnings, what):
"""沿输入连线找贴图。返回 (bpy.types.Image | None, channel | None)。
穿透 Normal Map / Separate Color 节点;channel 为 0..3 或 None(用 R/灰度)。"""
if not socket.is_linked:
return None, None
node = socket.links[0].from_node
out_name = socket.links[0].from_socket.name
if node.type == 'TEX_IMAGE':
return node.image, None
if node.type == 'NORMAL_MAP':
return _trace_image(node.inputs['Color'], warnings, what)
if node.type in ('SEPARATE_COLOR', 'SEPRGB'):
ch = {'Red': 0, 'Green': 1, 'Blue': 2, 'R': 0, 'G': 1, 'B': 2}.get(out_name, 0)
img, _ = _trace_image(node.inputs[0], warnings, what)
return img, ch
warnings.append("%s: 无法识别的节点 %s,改用常量" % (what, node.type))
return None, None
def _material_maps(mat, warnings, src_dir, model_name):
"""从材质 Principled BSDF 提取贴图与常量。"""
maps = {"basecolor": None, "alpha": None, "normal": None,
"metallic": None, "roughness": None, "ao": None}
consts = {"basecolor": (0.5, 0.5, 0.5, 1.0), "metallic": 0.0, "roughness": 0.5}
if not mat.use_nodes:
warnings.append("材质未用节点,全部用默认常量")
return maps, consts
bsdf = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None)
if bsdf is None:
warnings.append("未找到 Principled BSDF,全部用默认常量")
return maps, consts
def grab(input_name, key):
sock = bsdf.inputs.get(input_name)
if sock is None:
return
img, ch = _trace_image(sock, warnings, key)
if img is not None:
if _image_broken(img):
warnings.append("%s 贴图 %s 加载失败(外置文件缺失?),改用常量"
% (key, img.name))
else:
maps[key] = (img, ch)
grab('Base Color', 'basecolor')
grab('Alpha', 'alpha')
grab('Normal', 'normal')
grab('Metallic', 'metallic')
grab('Roughness', 'roughness')
consts["basecolor"] = tuple(bsdf.inputs['Base Color'].default_value)
consts["metallic"] = float(bsdf.inputs['Metallic'].default_value)
consts["roughness"] = float(bsdf.inputs['Roughness'].default_value)
# AO:材质节点树内按名匹配(未连到 BSDF 也算)
used = {m[0] for m in maps.values() if m}
for n in mat.node_tree.nodes:
if n.type == 'TEX_IMAGE' and n.image and n.image not in used \
and not _image_broken(n.image) and _looks_like_ao(n.image.name):
maps["ao"] = (n.image, None)
break
# 纯网格 FBX:材质一张贴图都没有时,按命名约定从同目录识别整套
if not any(maps.values()):
_find_maps_on_disk(src_dir, model_name, maps, warnings)
if maps["ao"] is None:
img = _find_ao_on_disk(src_dir, maps, warnings)
if img is not None:
maps["ao"] = (img, None)
return maps, consts
def _target_size(maps, max_size):
ws = [m[0].size[0] for m in maps.values() if m]
hs = [m[0].size[1] for m in maps.values() if m]
if not ws:
return 4, 4
return min(max(ws), max_size), min(max(hs), max_size)
def _channel(pixels, ch):
return pixels[..., ch if ch is not None else 0]
def _save_png(path, rgba):
"""float32 (h,w,4) -> PNG。写原始值(Non-Color,避免色彩管理改写数据)。"""
import bpy
h, w = rgba.shape[:2]
img = bpy.data.images.new("mt_tmp", width=w, height=h, alpha=True)
img.colorspace_settings.name = 'Non-Color'
img.pixels.foreach_set(np.ascontiguousarray(rgba, dtype=np.float32).ravel())
img.filepath_raw = path
img.file_format = 'PNG'
img.alpha_mode = 'CHANNEL_PACKED'
img.save()
bpy.data.images.remove(img)
def _convert_material(mat, model_name, outdir, max_size, used_snames, src_dir):
warnings = []
maps, consts = _material_maps(mat, warnings, src_dir, model_name)
tw, th = _target_size(maps, max_size)
# --- 基础贴图 ---
base = np.empty((th, tw, 4), dtype=np.float32)
if maps["basecolor"]:
base[..., :3] = _scaled_pixels(maps["basecolor"][0], tw, th)[..., :3]
# 8-bit 图 pixels 已是 sRGB 编码值;float 图(EXR 等)才是线性
rgb_is_linear = bool(maps["basecolor"][0].is_float)
else:
base[..., :3] = np.array(consts["basecolor"][:3], dtype=np.float32)
rgb_is_linear = True # Principled 常量色是线性值
if maps["alpha"]:
img, ch = maps["alpha"]
base[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
alpha_mode = "transparency"
elif maps["basecolor"] and maps["basecolor"][0].channels == 4 and \
_image_has_alpha(maps["basecolor"][0]):
base[..., 3] = _scaled_pixels(maps["basecolor"][0], tw, th)[..., 3]
alpha_mode = "transparency"
elif maps["ao"]:
img, ch = maps["ao"]
base[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
alpha_mode = "ao"
else:
base[..., 3] = 1.0
alpha_mode = "white"
# --- 混合贴图 ---
mix = np.empty((th, tw, 4), dtype=np.float32)
if maps["normal"]:
npx = _scaled_pixels(maps["normal"][0], tw, th)[..., :3] * 2.0 - 1.0
mix[..., 0:2] = encode_normal_rg(npx)
else:
mix[..., 0:2] = 0.5
warnings.append("无法线贴图,RG 填平面法线")
if maps["metallic"]:
img, ch = maps["metallic"]
mix[..., 2] = _channel(_scaled_pixels(img, tw, th), ch)
else:
mix[..., 2] = consts["metallic"]
if maps["roughness"]:
img, ch = maps["roughness"]
mix[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
else:
mix[..., 3] = consts["roughness"]
# 中文等非 ASCII 材质名会同样退化(如都变 "mat"),加序号防止互相覆盖
sname = base_sname = safe_name(mat.name)
n = 1
while sname in used_snames:
n += 1
sname = "%s_%d" % (base_sname, n)
used_snames.add(sname)
if sname != base_sname:
warnings.append("材质名 %s 清洗后与其它材质重名,输出改用 %s" % (mat.name, sname))
# 材质名与模型名相同(如纯网格 FBX 的兜底材质)时不重复拼接
stem = model_name if sname == model_name else "%s_%s" % (model_name, sname)
base_png = os.path.abspath(os.path.join(outdir, "%s_base.png" % stem))
mix_png = os.path.abspath(os.path.join(outdir, "%s_mix.png" % stem))
_save_png(base_png, encode_base_texture_for_png(base, rgb_is_linear))
_save_png(mix_png, mix)
return {
"name": mat.name, "safe_name": sname,
"base_png": os.path.basename(base_png), "mix_png": os.path.basename(mix_png),
"alpha_mode": alpha_mode, "size": [tw, th],
"sources": {k: (v[0].name if v else None) for k, v in maps.items()},
"warnings": warnings,
}
def _image_has_alpha(img):
"""采样判断 alpha 是否非全白(全白视为无透明信息)。"""
px = _image_pixels(img)
a = px[..., 3]
return bool((a < 0.995).any())
def _strip_textures():
import bpy
for mat in bpy.data.materials:
if not mat.use_nodes:
continue
for n in list(mat.node_tree.nodes):
if n.type in ('TEX_IMAGE', 'NORMAL_MAP', 'SEPARATE_COLOR', 'SEPRGB'):
mat.node_tree.nodes.remove(n)
def main():
import bpy, json
argv = sys.argv[sys.argv.index("--") + 1:]
src, outdir, max_size = argv[0], argv[1], int(argv[2])
os.makedirs(outdir, exist_ok=True)
model_name = os.path.splitext(os.path.basename(src))[0]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=src)
# 纯网格 FBX(无材质槽):以模型名新建材质挂上,否则 Unity 侧没有重映射挂点
fallback_mat = None
for obj in bpy.data.objects:
if obj.type == 'MESH' and not any(s.material for s in obj.material_slots):
if fallback_mat is None:
fallback_mat = bpy.data.materials.new(model_name)
fallback_mat.use_nodes = True
obj.data.materials.append(fallback_mat)
used_mats = []
for obj in bpy.data.objects:
if obj.type != 'MESH':
continue
for slot in obj.material_slots:
if slot.material and slot.material not in used_mats:
used_mats.append(slot.material)
src_dir = os.path.dirname(os.path.abspath(src))
used_snames = set()
results = [_convert_material(m, model_name, outdir, max_size, used_snames, src_dir)
for m in used_mats]
_strip_textures()
fbx_out = os.path.join(outdir, model_name + ".fbx")
bpy.ops.export_scene.fbx(filepath=fbx_out, path_mode='STRIP', embed_textures=False)
print("MT_SUMMARY " + json.dumps(
{"model": model_name, "fbx": os.path.basename(fbx_out), "materials": results},
ensure_ascii=False))
if __name__ == "__main__" and "--" in sys.argv:
main()
+43 -17
View File
@@ -52,12 +52,15 @@ def within_tolerance(target, actual, tol=TOLERANCE):
UV_FLIP_TOL = 0.02 # 质量门:UV 翻转面占比超此值回退 smart_project
UV_OVERLAP_TOL = 0.08 # 质量门:UV 重叠面占比阈值(手工 UV 同口径约 5%,灾难性失败 >20%)
UV_MAX_ISLAND_FRAC = 0.25 # 最大单岛 UV 面积占比超此值判不合格(挡光滑面巨型畸变岛)
def uv_gate_ok(flipped, overlap, overlap_tol=UV_OVERLAP_TOL):
"""UV 质量门:翻转按 UV_FLIP_TOL 固定,重叠按 overlap_tol(默认 UV_OVERLAP_TOL)。
overlap 为 None(op 不可用)按 0 处理。"""
return flipped <= UV_FLIP_TOL and (overlap or 0.0) <= overlap_tol
def uv_gate_ok(flipped, overlap, overlap_tol=UV_OVERLAP_TOL,
max_island_frac=0.0, island_frac_tol=UV_MAX_ISLAND_FRAC):
"""UV 质量门:翻转 / 重叠 / 最大单岛占比。overlap/max_island_frac 为 None 按 0。"""
return (flipped <= UV_FLIP_TOL
and (overlap or 0.0) <= overlap_tol
and (max_island_frac or 0.0) <= island_frac_tol)
def uvpm_mode_label(base, applied):
@@ -66,17 +69,17 @@ def uvpm_mode_label(base, applied):
def pick_best_candidate(candidates, overlap_tol=UV_OVERLAP_TOL):
"""从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。
overlap_tol 覆盖重叠容忍。每个 candidate 至少含 flipped/overlap/islands。"""
"""从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。"""
passing = [c for c in candidates
if uv_gate_ok(c["flipped"], c["overlap"], overlap_tol)]
if uv_gate_ok(c["flipped"], c["overlap"], overlap_tol,
c.get("max_island_frac", 0.0))]
if not passing:
return None
return min(passing, key=lambda c: c["islands"])
def count_islands(n_faces, adjacent_pairs):
"""union-find 数 UV 岛:n_faces 个面,adjacent_pairs 为 UV 连续的面索引对。"""
def island_labels(n_faces, adjacent_pairs):
"""union-find:返回每个面所属岛根标签列表。"""
parent = list(range(n_faces))
def find(i):
@@ -89,7 +92,25 @@ def count_islands(n_faces, adjacent_pairs):
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
return len({find(i) for i in range(n_faces)})
return [find(i) for i in range(n_faces)]
def count_islands(n_faces, adjacent_pairs):
"""union-find 数 UV 岛:n_faces 个面,adjacent_pairs 为 UV 连续的面索引对。"""
return len(set(island_labels(n_faces, adjacent_pairs)))
def max_island_area_fraction(labels, abs_areas):
"""最大单岛 UV 面积占比。"""
if not labels or not abs_areas:
return 0.0
totals = {}
for lab, area in zip(labels, abs_areas):
totals[lab] = totals.get(lab, 0.0) + abs(area)
total = sum(totals.values())
if total <= 0.0:
return 0.0
return max(totals.values()) / total
def flipped_fraction(signed_areas):
@@ -168,7 +189,7 @@ UVPM_EXT = "bl_ext.user_default.uvpackmaster4" # UVPackmaster 4 扩展模块
UVPM_PIXEL_MARGIN = 4 # UVPM 岛间距(像素),与 PACK_MARGIN * UVPM_TEX_SIZE 同口径
UVPM_TEX_SIZE = 2048
UVPM_ROTATION_STEP = 15 # UVPM 旋转步进(度);比默认 90 更细,利于薄斜条对齐(启发式因引擎崩溃已弃用)
SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0) # 首选 SEAM_ANGLE_DEG 不过门时依次下探的 seam 角度
SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0, 30.0, 25.0, 20.0, 15.0) # 首选不过门时下探
STRETCH_ITERS = 30 # minimize_stretch 松弛迭代次数
REL_WELD = 1e-4 # 焊接距离占局部包围盒对角线比例(避免 cm/m 单位差异导致绝对阈值失准)
@@ -258,11 +279,14 @@ def _collect_uv_metrics(obj):
area += a.x * b.y - b.x * a.y
signed.append(area * 0.5)
n = len(bm.faces)
labels = island_labels(n, pairs)
abs_areas = [abs(a) for a in signed]
bm.free()
return {"islands": count_islands(n, pairs),
"fill": round(fill_ratio([abs(a) for a in signed]), 4),
return {"islands": len(set(labels)) if n else 0,
"fill": round(fill_ratio(abs_areas), 4),
"flipped": round(flipped_fraction(signed), 4),
"overlap": _overlap_fraction(obj)}
"overlap": _overlap_fraction(obj),
"max_island_frac": round(max_island_area_fraction(labels, abs_areas), 4)}
def _overlap_fraction(obj):
@@ -371,7 +395,8 @@ def _do_unwrap(obj, mode, warnings, overlap_tol=UV_OVERLAP_TOL):
m = _collect_uv_metrics(obj)
m["angle"] = ang
candidates.append(m)
if uv_gate_ok(m["flipped"], m["overlap"], overlap_tol):
if uv_gate_ok(m["flipped"], m["overlap"], overlap_tol,
m.get("max_island_frac", 0.0)):
break # 该档已过门;更低角度只会更碎,无需再试
best = pick_best_candidate(candidates, overlap_tol)
if best is not None:
@@ -383,9 +408,10 @@ def _do_unwrap(obj, mode, warnings, overlap_tol=UV_OVERLAP_TOL):
m["mode"] = uvpm_mode_label("seam@%d" % int(best["angle"]), uvpm)
return m
detail = "、".join(
"%d°(翻转%.1f%% 重叠%s)" % (
"%d°(翻转%.1f%% 重叠%s 大岛%.1f%%)" % (
int(c["angle"]), c["flipped"] * 100,
"%.1f%%" % (c["overlap"] * 100) if c["overlap"] is not None else "未知")
"%.1f%%" % (c["overlap"] * 100) if c["overlap"] is not None else "未知",
c.get("max_island_frac", 0.0) * 100)
for c in candidates)
warnings.append("所有 seam 角度均未过质量门(%s),回退 smart_project" % detail)
mode = "smart_fallback"
+55 -1
View File
@@ -117,6 +117,7 @@ def run_pipeline(
ray_distance: Optional[float] = None,
blender: Optional[str] = None,
keep_low: bool = True,
xpbr: bool = True,
callback_url: Optional[str] = None,
callback_file: Optional[str] = None,
quiet: bool = False,
@@ -211,6 +212,26 @@ def run_pipeline(
blender_args=("-t", "1"),
)
xpbr_dir = None
xpbr_summary = None
if xpbr:
callbacks.emit("stage", stage="打包 XPbr base/mix 贴图与材质", progress=88)
from model_translator import translate_fbx
bake_fbx = bake_dir / bake["fbx"]
xpbr_dir = out_dir / f"{stem}_xpbr"
xpbr_summary = translate_fbx(
str(bake_fbx), str(xpbr_dir), max_size=size, blender=blender_exe
)
callbacks.emit(
"xpbr",
stage="XPbr 资源已生成",
progress=95,
outdir=str(xpbr_dir),
fbx=xpbr_summary.get("fbx"),
materials=[m.get("name") for m in xpbr_summary.get("materials") or []],
)
result = {
"ok": True,
"status": "succeeded",
@@ -220,13 +241,28 @@ def run_pipeline(
"bake_dir": str(bake_dir),
"fbx": str(bake_dir / bake["fbx"]),
"maps": {k: str(bake_dir / v) for k, v in bake["outputs"].items()},
"xpbr_dir": str(xpbr_dir) if xpbr_dir else None,
"xpbr": (
{
"fbx": str(xpbr_dir / xpbr_summary["fbx"]),
"materials": xpbr_summary.get("materials"),
}
if xpbr_summary and xpbr_dir
else None
),
"tris_before": dec["tris_before"],
"tris_after": bake["low_tris"],
"high_tris": bake["high_tris"],
"size": size,
"ray_distance": bake["ray_distance"],
"warnings": list(dec.get("warnings") or []) + list(bake.get("warnings") or []),
"hint": "生成完成后请在 Doc/ResDesc_Model.md 登记该资源",
"hint": (
"XPbr 目录可拷进 AIClient/Assets;"
"预设用 Unity 菜单 AIGame/M3/Setup Character Prefab + Art Collector;"
"并登记 Doc/ResDesc_Model.md"
if xpbr
else "生成完成后请在 Doc/ResDesc_Model.md 登记该资源"
),
}
callbacks.emit("succeeded", **result)
return result
@@ -251,6 +287,19 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--max-overlap", type=float, default=None, help="UV 重叠容忍 (0,1]")
p.add_argument("--ray-distance", type=float, default=None, help="烘焙射线距离")
p.add_argument("--blender", default=None, help="blender.exe 路径")
p.add_argument(
"--xpbr",
dest="xpbr",
action="store_true",
default=True,
help="烘焙后打包 XPbr base/mix + .mat(默认开启)",
)
p.add_argument(
"--no-xpbr",
dest="xpbr",
action="store_false",
help="跳过 XPbr 打包,只保留五通道烘焙图",
)
p.add_argument(
"--callback-url", default=None, help="流程回调 URL:每个事件 POST JSON"
)
@@ -300,6 +349,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
else args.ray_distance
),
blender=payload.get("blender") or args.blender,
xpbr=bool(
payload["xpbr"]
if "xpbr" in payload
else args.xpbr
),
callback_url=payload.get("callback_url") or args.callback_url,
callback_file=payload.get("callback_file") or args.callback_file,
quiet=quiet,
+113
View File
@@ -0,0 +1,113 @@
"""ModelTranslator CLI:FBX -> 纯模型 FBX + XPbr 贴图 + Unity .mat/.meta。
用法:python model_translator.py src/tong.fbx [-o out] [--max-size 2048] [--blender exe]
"""
import argparse
import json
import os
import sys
import unity_assets as ua
from mt_run import HERE, find_blender, run_blender_script
def write_unity_assets(outdir, summary, max_size):
remaps = []
for m in summary["materials"]:
base_png = os.path.join(outdir, m["base_png"])
mix_png = os.path.join(outdir, m["mix_png"])
base_guid = ua.guid_for(base_png)
mix_guid = ua.guid_for(mix_png)
alpha_test = m["alpha_mode"] == "transparency"
with open(base_png + ".meta", "w", encoding="utf-8", newline="\n") as f:
f.write(
ua.texture_meta(
base_guid,
srgb=True,
max_size=max_size,
alpha_is_transparency=alpha_test,
)
)
with open(mix_png + ".meta", "w", encoding="utf-8", newline="\n") as f:
f.write(ua.texture_meta(mix_guid, srgb=False, max_size=max_size))
mat_name = (
summary["model"]
if m["safe_name"] == summary["model"]
else "%s_%s" % (summary["model"], m["safe_name"])
)
mat_path = os.path.join(outdir, mat_name + ".mat")
mat_guid = ua.guid_for(mat_path)
with open(mat_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
ua.material_yaml(
mat_name, ua.XPBR_SHADER_GUID, base_guid, mix_guid, alpha_test
)
)
with open(mat_path + ".meta", "w", encoding="utf-8", newline="\n") as f:
f.write(ua.material_meta(mat_guid))
remaps.append((m["name"], mat_guid))
fbx_path = os.path.join(outdir, summary["fbx"])
fbx_guid = ua.guid_for(fbx_path)
with open(fbx_path + ".meta", "w", encoding="utf-8", newline="\n") as f:
f.write(ua.model_meta(fbx_guid, remaps))
return {
"fbx": fbx_path,
"fbx_guid": fbx_guid,
"materials": remaps,
"outdir": outdir,
}
def report(summary, outdir):
print("== %s -> %s" % (summary["model"], outdir))
for m in summary["materials"]:
print(
" 材质 %-24s A通道=%s 尺寸=%dx%d"
% (m["name"], m["alpha_mode"], m["size"][0], m["size"][1])
)
for k, v in m["sources"].items():
print(" %-10s %s" % (k, v or "(常量)"))
for w in m["warnings"]:
print(" [警告] %s" % w)
def translate_fbx(src, outdir, max_size=2048, blender=None):
"""Convert bake-output FBX (+ sibling PBR maps) into XPbr assets. Returns summary dict."""
blender_exe = find_blender(blender)
os.makedirs(outdir, exist_ok=True)
summary = run_blender_script(
blender_exe, "bl_convert.py", [src, outdir, str(max_size)]
)
unity = write_unity_assets(outdir, summary, max_size)
return {**summary, "unity": unity}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("input", help="FBX 文件或目录")
ap.add_argument("-o", "--out", default=os.path.join(HERE, "out_xpbr"))
ap.add_argument("--max-size", type=int, default=2048)
ap.add_argument("--blender", default=None)
args = ap.parse_args()
if os.path.isdir(args.input):
srcs = [
os.path.join(args.input, f)
for f in sorted(os.listdir(args.input))
if f.lower().endswith(".fbx")
]
if not srcs:
sys.exit("目录中没有 FBX:%s" % args.input)
else:
srcs = [args.input]
for src in srcs:
name = os.path.splitext(os.path.basename(src))[0]
outdir = os.path.join(args.out, name)
summary = translate_fbx(src, outdir, args.max_size, args.blender)
report(summary, outdir)
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
"""法线球面编码 round-trip 测试。运行:
blender -b --factory-startup --python tests/bl_test_encode.py
成功打印 ENCODE_TEST_OK,失败非零退出。"""
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from bl_convert import encode_normal_rg, decode_sphere_map
def main():
rng = np.random.default_rng(7)
# 随机切线空间法线(z>=0 半球),含边缘方向
n = rng.normal(size=(4096, 3))
n[:, 2] = np.abs(n[:, 2])
n /= np.linalg.norm(n, axis=1, keepdims=True)
n = np.vstack([n, [[0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, -1, 0], [0.707, -0.707, 0]]])
enc = encode_normal_rg(n) # (N,2) in [0,1]
assert enc.min() >= 0.0 and enc.max() <= 1.0, "encode out of [0,1]"
enc8 = np.round(enc * 255.0) / 255.0 # 模拟 8bit PNG 存储
dec = decode_sphere_map(enc8) # shader 同款公式
dec /= np.linalg.norm(dec, axis=1, keepdims=True)
cos = np.clip(np.sum(n * dec, axis=1), -1.0, 1.0)
max_err = np.degrees(np.arccos(cos)).max()
assert max_err < 2.0, "max angle error %.3f deg >= 2" % max_err
# 平面法线编码值应为 (0.5, 0.5)
flat = encode_normal_rg(np.array([[0.0, 0.0, 1.0]]))
assert np.allclose(flat, 0.5, atol=1e-6), "flat normal must encode to 0.5"
print("ENCODE_TEST_OK max_err=%.4f deg" % max_err)
try:
main()
except Exception as e:
print("ENCODE_TEST_FAIL:", e)
sys.exit(1)
@@ -0,0 +1,73 @@
import os
import re
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import unity_assets as ua
class TestGuid(unittest.TestCase):
def test_new_guid_is_32_hex(self):
g = ua.new_guid()
self.assertTrue(re.fullmatch(r"[0-9a-f]{32}", g))
def test_guid_for_reuses_existing_meta(self):
with tempfile.TemporaryDirectory() as d:
asset = os.path.join(d, "a.png")
with open(asset + ".meta", "w") as f:
f.write("fileFormatVersion: 2\nguid: 0123456789abcdef0123456789abcdef\n")
self.assertEqual(ua.guid_for(asset), "0123456789abcdef0123456789abcdef")
def test_guid_for_generates_when_no_meta(self):
with tempfile.TemporaryDirectory() as d:
g = ua.guid_for(os.path.join(d, "b.png"))
self.assertTrue(re.fullmatch(r"[0-9a-f]{32}", g))
class TestTemplates(unittest.TestCase):
def test_texture_meta_srgb_on_off(self):
on = ua.texture_meta("a" * 32, srgb=True, max_size=2048)
off = ua.texture_meta("b" * 32, srgb=False, max_size=2048)
self.assertIn("sRGBTexture: 1", on)
self.assertIn("sRGBTexture: 0", off)
self.assertIn("maxTextureSize: 2048", on)
self.assertIn("guid: " + "a" * 32, on)
def test_texture_meta_alpha_is_transparency(self):
default = ua.texture_meta("a" * 32, srgb=True, max_size=2048)
ait = ua.texture_meta("a" * 32, srgb=True, max_size=2048,
alpha_is_transparency=True)
self.assertIn("alphaIsTransparency: 0", default)
self.assertIn("alphaIsTransparency: 1", ait)
def test_material_yaml_refs_and_keyword(self):
y = ua.material_yaml("tong_wood", "s" * 32, "c" * 32, "d" * 32, alpha_test=True)
self.assertIn("m_Name: tong_wood", y)
self.assertIn("guid: " + "s" * 32, y) # shader
self.assertIn("guid: " + "c" * 32, y) # base tex
self.assertIn("guid: " + "d" * 32, y) # mix tex
self.assertIn("_ALPHATEST_ON", y)
self.assertIn("_AlphaTest: 1", y)
def test_material_yaml_no_keyword_when_opaque(self):
y = ua.material_yaml("m", "s" * 32, "c" * 32, "d" * 32, alpha_test=False)
self.assertNotIn("_ALPHATEST_ON", y)
self.assertIn("_AlphaTest: 0", y)
def test_model_meta_external_objects(self):
m = ua.model_meta("e" * 32, [("wood", "f" * 32), ("iron", "1" * 32)])
self.assertIn("name: wood", m)
self.assertIn("guid: " + "f" * 32, m)
self.assertIn("name: iron", m)
self.assertIn("guid: " + "e" * 32, m)
class TestSanitize(unittest.TestCase):
def test_safe_name(self):
self.assertEqual(ua.safe_name("Mat 01.a/b"), "Mat_01_a_b")
if __name__ == "__main__":
unittest.main()
+323
View File
@@ -0,0 +1,323 @@
"""Unity 资产(.meta / .mat)文本模板与 GUID 管理。纯 stdlib,可独立测试。
模板采自 Unity 2022.3(TextureImporter serializedVersion 12 / Material 8 /
ModelImporter 22200);升级 Unity 后如导入异常,在编辑器导出同类资产 diff 更新模板。"""
import os
import re
import uuid
XPBR_SHADER_GUID = "cab897163f45445fa34263d6670bfc9e" # AIClient/Assets/Bundles/Shaders/XShader/XPbr.shader.meta
_GUID_RE = re.compile(r"^guid: ([0-9a-f]{32})", re.M)
def new_guid():
return uuid.uuid4().hex
def guid_for(asset_path):
"""资产已有 .meta 则复用其中 guid(重复转换不破坏 Unity 引用),否则新分配。"""
meta = asset_path + ".meta"
if os.path.isfile(meta):
try:
with open(meta, "r", encoding="utf-8", errors="replace") as f:
m = _GUID_RE.search(f.read())
except OSError:
m = None # meta 损坏/不可读时按无 meta 处理,重新分配
if m:
return m.group(1)
return new_guid()
def safe_name(name):
return re.sub(r"[^0-9A-Za-z_\-]", "_", name).strip("_") or "mat"
def texture_meta(guid, srgb, max_size, alpha_is_transparency=False):
return """fileFormatVersion: 2
guid: {guid}
TextureImporter:
internalIDToNameTable: []
externalObjects: {{}}
serializedVersion: 12
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: {srgb}
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: {max_size}
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {{x: 0.5, y: 0.5}}
spritePixelsToUnits: 100
spriteBorder: {{x: 0, y: 0, z: 0, w: 0}}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: {ait}
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: {max_size}
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {{}}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
""".format(guid=guid, srgb=1 if srgb else 0, max_size=max_size,
ait=1 if alpha_is_transparency else 0)
def material_yaml(name, shader_guid, base_tex_guid, mix_tex_guid, alpha_test):
keywords = "\n - _ALPHATEST_ON" if alpha_test else " []"
return """%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {{fileID: 0}}
m_PrefabInstance: {{fileID: 0}}
m_PrefabAsset: {{fileID: 0}}
m_Name: {name}
m_Shader: {{fileID: 4800000, guid: {shader_guid}, type: 3}}
m_Parent: {{fileID: 0}}
m_ModifiedSerializedProperties: 0
m_ValidKeywords:{keywords}
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {{}}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {{fileID: 2800000, guid: {base_tex_guid}, type: 3}}
m_Scale: {{x: 1, y: 1}}
m_Offset: {{x: 0, y: 0}}
- _MixTex:
m_Texture: {{fileID: 2800000, guid: {mix_tex_guid}, type: 3}}
m_Scale: {{x: 1, y: 1}}
m_Offset: {{x: 0, y: 0}}
m_Ints: []
m_Floats:
- _AO: 1
- _AlphaTest: {alpha_test}
- _Cutoff: 0.5
- _Metallic: 1
- _Roughness: 0.8
m_Colors:
- _BaseColor: {{r: 1, g: 1, b: 1, a: 1}}
m_BuildTextureStacks: []
""".format(name=name, shader_guid=shader_guid, base_tex_guid=base_tex_guid,
mix_tex_guid=mix_tex_guid, keywords=keywords,
alpha_test=1 if alpha_test else 0)
def material_meta(guid):
return """fileFormatVersion: 2
guid: {guid}
NativeFormatImporter:
externalObjects: {{}}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
""".format(guid=guid)
def model_meta(guid, material_remaps):
"""material_remaps: [(fbx 内材质名, mat 文件 guid), ...]"""
if material_remaps:
ext = "externalObjects:\n"
for mat_name, mat_guid in material_remaps:
ext += (" - first:\n"
" type: UnityEngine:Material\n"
" assembly: UnityEngine.CoreModule\n"
" name: {n}\n"
" second: {{fileID: 2100000, guid: {g}, type: 2}}\n"
).format(n=mat_name, g=mat_guid)
else:
ext = "externalObjects: {}\n"
return """fileFormatVersion: 2
guid: {guid}
ModelImporter:
serializedVersion: 22200
internalIDToNameTable: []
{ext} materials:
materialImportMode: 2
materialName: 0
materialSearch: 1
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
resampleCurves: 1
optimizeGameObjects: 0
removeConstantScaleCurves: 0
motionNodeName:
animationImportErrors:
animationImportWarnings:
animationRetargetingWarnings:
animationDoRetargetingWarnings: 0
importAnimatedCustomProperties: 0
importConstraints: 0
animationCompression: 1
animationRotationError: 0.5
animationPositionError: 0.5
animationScaleError: 0.5
animationWrapMode: 0
extraExposedTransformPaths: []
extraUserProperties: []
clipAnimations: []
isReadable: 0
meshes:
lODScreenPercentages: []
globalScale: 1
meshCompression: 0
addColliders: 0
useSRGBMaterialColor: 1
sortHierarchyByName: 1
importPhysicalCameras: 1
importVisibility: 1
importBlendShapes: 1
importCameras: 1
importLights: 1
nodeNameCollisionStrategy: 1
fileIdsGeneration: 2
swapUVChannels: 0
generateSecondaryUV: 0
useFileUnits: 1
keepQuads: 0
weldVertices: 1
bakeAxisConversion: 0
preserveHierarchy: 0
skinWeightsMode: 0
maxBonesPerVertex: 4
minBoneWeight: 0.001
optimizeBones: 1
meshOptimizationFlags: -1
indexFormat: 0
secondaryUVAngleDistortion: 8
secondaryUVAreaDistortion: 15.000001
secondaryUVHardAngle: 88
secondaryUVMarginMethod: 1
secondaryUVMinLightmapResolution: 40
secondaryUVMinObjectScale: 1
secondaryUVPackMargin: 4
useFileScale: 1
strictVertexDataChecks: 0
tangentSpace:
normalSmoothAngle: 60
normalImportMode: 0
tangentImportMode: 3
normalCalculationMode: 4
legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0
blendShapeNormalImportMode: 1
normalSmoothingSource: 0
referencedClips: []
importAnimation: 1
humanDescription:
serializedVersion: 3
human: []
skeleton: []
armTwist: 0.5
foreArmTwist: 0.5
upperLegTwist: 0.5
legTwist: 0.5
armStretch: 0.05
legStretch: 0.05
feetSpacing: 0
globalScale: 1
rootMotionBoneName:
hasTranslationDoF: 0
hasExtraRoot: 0
skeletonHasParents: 1
lastHumanDescriptionAvatarSource: {{instanceID: 0}}
autoGenerateAvatarMappingIfUnspecified: 1
animationType: 2
humanoidOversampling: 1
avatarSetup: 0
addHumanoidExtraRootOnlyWhenUsingAvatar: 1
importBlendShapeDeformPercent: 1
remapMaterialsIfMaterialImportModeIsNone: 0
additionalBone: 0
userData:
assetBundleName:
assetBundleVariant:
""".format(guid=guid, ext=ext)