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:
+36
-28
@@ -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` 完整迁入作为可选增强。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user