Files
AIC-Project/docs/superpowers/plans/2026-07-15-model-translator.md
T
2026-07-15 10:50:05 +08:00

1029 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ModelTranslator 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 用 headless Blender 把带 PBR 贴图的 FBX 转为纯模型 FBX + XPbr 基础/混合贴图 + Unity 开箱即用的 .mat/.meta。
**Architecture:** 外层 CLI(系统 Python,纯 stdlib)负责找 Blender、调子进程、根据转换摘要生成 Unity 资产文件;Blender 内脚本(bpy + 自带 numpy)负责导入 FBX、按 Principled BSDF 连线识别贴图、通道打包出 PNG、导出纯 FBX,并向 stdout 打 JSON 摘要。Unity 资产模板独立成 `unity_assets.py` 纯函数模块(可用系统 Python unittest 测试)。
**Tech Stack:** Python 3.12stdlib)、Blender 5.0`bpy.ops.import_scene.fbx`/`export_scene.fbx`、numpy 1.26.4)、Unity 2022.3 资产 YAML。
**约定:**
- Blender 路径默认 `D:\tools\blender-5.0.0-windows-x64\blender.exe`
- XPbr shader GUID`cab897163f45445fa34263d6670bfc9e`
- 球面编码:`enc = n.xy / sqrt(2*(1+n.z)) * 0.5 + 0.5`;解码(shader 侧)先 `*2-1`
- git 提交身份:`git -c user.name=ud18010 -c user.email=ud18010@xworld.ren commit ...`
**文件结构:**
```
Tools/ModelTranslator/
model_translator.py # CLI:找 blender、跑转换、写 Unity 资产
unity_assets.py # Unity meta/mat 模板 + GUID 复用(纯 stdlib
bl_convert.py # Blender 内脚本(bpy+numpy
tests/test_unity_assets.py # unittest(系统 Python
tests/bl_test_encode.py # 法线编码 round-tripBlender 内运行)
README.md
.gitignore # 忽略 out/
```
---
### Task 1: XPbr shader 法线解码重映射
**Files:**
- Modify: `Client/Assets/Game/shader/XShader/XPbr_Base.hlsl``DecodeSphereMap` 函数)
- [ ] **Step 1: 修改 DecodeSphereMap**
把:
```hlsl
float3 DecodeSphereMap(float2 encoded)
{
float4 nn = float4(encoded, 1, -1);
```
改为:
```hlsl
float3 DecodeSphereMap(float2 encoded)
{
encoded = encoded * 2.0 - 1.0; //rg 无符号存储,重映射回有符号
float4 nn = float4(encoded, 1, -1);
```
同时把注释 `//解码 RG 两通道球面映射压缩法线(与 XTex 的 MixTex 资源格式一致)` 改为 `//解码 RG 两通道球面映射压缩法线(有符号,ModelTranslator 工具编码:enc = n.xy/sqrt(2*(1+n.z)) * 0.5 + 0.5`
- [ ] **Step 2: Commit**
```bash
git add Client/Assets/Game/shader/XShader/XPbr_Base.hlsl
git -c user.name=ud18010 -c user.email=ud18010@xworld.ren commit -m "XPbr: remap sphere-map normal decode to signed range for ModelTranslator"
```
---
### Task 2: unity_assets.pyTDD
**Files:**
- Create: `Tools/ModelTranslator/unity_assets.py`
- Test: `Tools/ModelTranslator/tests/test_unity_assets.py`
- [ ] **Step 1: 写失败测试**
`Tools/ModelTranslator/tests/test_unity_assets.py`
```python
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_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()
```
- [ ] **Step 2: 运行确认失败**
Run: `cd Tools/ModelTranslator && python -m unittest tests.test_unity_assets -v`
Expected: FAIL`ModuleNotFoundError: No module named 'unity_assets'`
- [ ] **Step 3: 实现 unity_assets.py**
```python
"""Unity 资产(.meta / .mat)文本模板与 GUID 管理。纯 stdlib,可独立测试。"""
import os
import re
import uuid
XPBR_SHADER_GUID = "cab897163f45445fa34263d6670bfc9e" # Client/Assets/Game/shader/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):
with open(meta, "r", encoding="utf-8") as f:
m = _GUID_RE.search(f.read())
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):
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: 0
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)
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: 1
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)
```
- [ ] **Step 4: 运行确认通过**
Run: `cd Tools/ModelTranslator && python -m unittest tests.test_unity_assets -v`
Expected: 全部 PASS7 tests OK
- [ ] **Step 5: Commit**
```bash
git add Tools/ModelTranslator/unity_assets.py Tools/ModelTranslator/tests/test_unity_assets.py
git -c user.name=ud18010 -c user.email=ud18010@xworld.ren commit -m "ModelTranslator: Unity asset templates (texture/material/model meta) with GUID reuse"
```
---
### Task 3: bl_convert.py 法线编码函数(Blender 内 TDD
**Files:**
- Create: `Tools/ModelTranslator/bl_convert.py`(先只写编码函数,主流程 Task 4)
- Test: `Tools/ModelTranslator/tests/bl_test_encode.py`
- [ ] **Step 1: 写失败测试**
`Tools/ModelTranslator/tests/bl_test_encode.py`(在 Blender 内运行):
```python
"""法线球面编码 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)
```
- [ ] **Step 2: 运行确认失败**
Run: `cd Tools/ModelTranslator && "/d/tools/blender-5.0.0-windows-x64/blender.exe" -b --factory-startup --python tests/bl_test_encode.py`
Expected: `ENCODE_TEST_FAIL: No module named 'bl_convert'`(退出码 1
- [ ] **Step 3: 实现编码函数(bl_convert.py 首个版本)**
```python
"""Blender 内运行:FBX -> 纯模型 FBX + XPbr 基础/混合贴图。
调用:blender -b --factory-startup --python bl_convert.py -- <src.fbx> <outdir> <max_size>
"""
import numpy as np
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)
```
- [ ] **Step 4: 运行确认通过**
Run: `cd Tools/ModelTranslator && "/d/tools/blender-5.0.0-windows-x64/blender.exe" -b --factory-startup --python tests/bl_test_encode.py`
Expected: 输出 `ENCODE_TEST_OK max_err=... deg`<2
- [ ] **Step 5: Commit**
```bash
git add Tools/ModelTranslator/bl_convert.py Tools/ModelTranslator/tests/bl_test_encode.py
git -c user.name=ud18010 -c user.email=ud18010@xworld.ren commit -m "ModelTranslator: sphere-map normal encode with round-trip test"
```
---
### Task 4: bl_convert.py 主流程
**Files:**
- Modify: `Tools/ModelTranslator/bl_convert.py`(追加主流程;bpy 导入放到 main 段,保证编码函数可独立测试)
- [ ] **Step 1: 追加主流程代码**
在 Task 3 的两个函数之后追加:
```python
# ---------------- 以下仅在 Blender 内执行 ----------------
AO_NAME_RE = None # 延迟编译,见 _looks_like_ao
def _looks_like_ao(name):
import re
global AO_NAME_RE
if AO_NAME_RE is None:
AO_NAME_RE = re.compile(r"(ao|occlusion|ambient)", re.I)
return bool(AO_NAME_RE.search(name))
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):
"""从材质 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:
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 _looks_like_ao(n.image.name):
maps["ao"] = (n.image, None)
break
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, srgb_view=False):
"""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):
import os
warnings = []
maps, consts = _material_maps(mat, warnings)
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]
else:
base[..., :3] = np.array(consts["basecolor"][:3], dtype=np.float32)
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"]
import re
sname = re.sub(r"[^0-9A-Za-z_\-]", "_", mat.name).strip("_") or "mat"
base_png = os.path.join(outdir, "%s_%s_base.png" % (model_name, sname))
mix_png = os.path.join(outdir, "%s_%s_mix.png" % (model_name, sname))
_save_png(base_png, base)
_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, os, sys
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)
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)
results = [_convert_material(m, model_name, outdir, max_size) 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='NONE', 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 __import__("sys").argv:
main()
```
- [ ] **Step 2: 冒烟运行(tong.fbx**
Run:
```bash
cd Tools/ModelTranslator && "/d/tools/blender-5.0.0-windows-x64/blender.exe" -b --factory-startup --python bl_convert.py -- src/tong.fbx out/tong 2048 2>&1 | grep -E "MT_SUMMARY|Error|Traceback" | head -5
```
Expected: 一行 `MT_SUMMARY {...}``out/tong/` 下有 `tong.fbx` 与每材质 `_base.png`/`_mix.png`
- [ ] **Step 3: 检查输出尺寸 ≤2048**
Run: `cd Tools/ModelTranslator && python -c "import struct,glob;[print(f,struct.unpack('>II',open(f,'rb').read(24)[16:24])) for f in glob.glob('out/tong/*.png')]"`
Expected: 全部宽高 ≤2048
- [ ] **Step 4: 编码测试回归**
Run: `cd Tools/ModelTranslator && "/d/tools/blender-5.0.0-windows-x64/blender.exe" -b --factory-startup --python tests/bl_test_encode.py`
Expected: `ENCODE_TEST_OK`
- [ ] **Step 5: Commit**
```bash
git add Tools/ModelTranslator/bl_convert.py
git -c user.name=ud18010 -c user.email=ud18010@xworld.ren commit -m "ModelTranslator: Blender conversion pipeline (import, classify, pack, export)"
```
---
### Task 5: model_translator.py CLI
**Files:**
- Create: `Tools/ModelTranslator/model_translator.py`
- [ ] **Step 1: 实现 CLI**
```python
"""ModelTranslator CLIFBX -> 纯模型 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 shutil
import subprocess
import sys
import unity_assets as ua
DEFAULT_BLENDER = r"D:\tools\blender-5.0.0-windows-x64\blender.exe"
HERE = os.path.dirname(os.path.abspath(__file__))
def find_blender(cli_arg):
for cand in (cli_arg, os.environ.get("BLENDER_EXE"), DEFAULT_BLENDER,
shutil.which("blender")):
if cand and os.path.isfile(cand):
return cand
sys.exit("找不到 blender.exe,请用 --blender 指定或设置环境变量 BLENDER_EXE")
def run_blender(blender, src, outdir, max_size):
cmd = [blender, "-b", "--factory-startup",
"--python", os.path.join(HERE, "bl_convert.py"),
"--", src, outdir, str(max_size)]
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
errors="replace")
summary = None
for line in (proc.stdout or "").splitlines():
if line.startswith("MT_SUMMARY "):
summary = json.loads(line[len("MT_SUMMARY "):])
if proc.returncode != 0 or summary is None:
sys.stderr.write(proc.stdout[-2000:] + "\n" + proc.stderr[-2000:] + "\n")
sys.exit("Blender 转换失败:%s" % src)
return summary
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)
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))
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 = "%s_%s" % (summary["model"], m["safe_name"])
mat_path = os.path.join(outdir, mat_name + ".mat")
mat_guid = ua.guid_for(mat_path)
alpha_test = m["alpha_mode"] == "transparency"
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"])
with open(fbx_path + ".meta", "w", encoding="utf-8", newline="\n") as f:
f.write(ua.model_meta(ua.guid_for(fbx_path), remaps))
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 main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("input", help="FBX 文件或目录")
ap.add_argument("-o", "--out", default=os.path.join(HERE, "out"))
ap.add_argument("--max-size", type=int, default=2048)
ap.add_argument("--blender", default=None)
args = ap.parse_args()
blender = find_blender(args.blender)
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 = run_blender(blender, src, outdir, args.max_size)
write_unity_assets(outdir, summary, args.max_size)
report(summary, outdir)
if __name__ == "__main__":
main()
```
- [ ] **Step 2: 单元测试回归**
Run: `cd Tools/ModelTranslator && python -m unittest tests.test_unity_assets -v`
Expected: PASS
- [ ] **Step 3: 集成运行 tong.fbx**
Run: `cd Tools/ModelTranslator && python model_translator.py src/tong.fbx`
Expected: 打印材质摘要;`out/tong/``tong.fbx(+.meta)`、每材质 `_base.png(+.meta)``_mix.png(+.meta)``.mat(+.meta)`
- [ ] **Step 4: 检查 GUID 复用**
Run: `cd Tools/ModelTranslator && python -c "
import re,glob
before={f:re.search(r'guid: (\w+)',open(f).read()).group(1) for f in glob.glob('out/tong/*.meta')}
import subprocess;subprocess.run(['python','model_translator.py','src/tong.fbx'],check=True)
after={f:re.search(r'guid: (\w+)',open(f).read()).group(1) for f in glob.glob('out/tong/*.meta')}
assert before==after, 'GUID 变了!'
print('GUID_REUSE_OK', len(before))"`
Expected: `GUID_REUSE_OK N`
- [ ] **Step 5: 集成运行 woodcar.fbx(批量入口顺带验证)**
Run: `cd Tools/ModelTranslator && python model_translator.py src/woodcar.fbx`
Expected: 同样输出齐全
- [ ] **Step 6: Commit**
```bash
git add Tools/ModelTranslator/model_translator.py
git -c user.name=ud18010 -c user.email=ud18010@xworld.ren commit -m "ModelTranslator: CLI runner with Unity asset generation"
```
---
### Task 6: README 与 .gitignore
**Files:**
- Create: `Tools/ModelTranslator/README.md`
- Create: `Tools/ModelTranslator/.gitignore`
- [ ] **Step 1: 写 README.md**
内容涵盖:用途、依赖(Blender 5.0 路径/环境变量)、用法示例(单文件/目录)、输出结构说明、A 通道自动判定规则(透明>AO>白)、混合贴图通道含义与 sRGB 要求、"拷入 Client/Assets 即可用"说明、局限(程序纹理不支持、法线绿通道按 OpenGL 约定、不做图集合并)。
- [ ] **Step 2: 写 .gitignore**
```
out/
```
- [ ] **Step 3: Commit**
```bash
git add Tools/ModelTranslator/README.md Tools/ModelTranslator/.gitignore
git -c user.name=ud18010 -c user.email=ud18010@xworld.ren commit -m "ModelTranslator: README and gitignore"
```
---
### Task 7: 人工验证(Unity
- [ ]`out/tong/` 拷入 `Client/Assets/`(任意位置),确认:模型直接带 XPbr 材质显示;混合贴图 sRGB 已关;透明/AO 判定正确。此步由用户在 Unity 编辑器执行,工具侧无需改动。