33 KiB
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.12(stdlib)、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-trip(Blender 内运行)
README.md
.gitignore # 忽略 out/
Task 1: XPbr shader 法线解码重映射
Files:
-
Modify:
Client/Assets/Game/shader/XShader/XPbr_Base.hlsl(DecodeSphereMap函数) -
Step 1: 修改 DecodeSphereMap
把:
float3 DecodeSphereMap(float2 encoded)
{
float4 nn = float4(encoded, 1, -1);
改为:
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
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.py(TDD)
Files:
-
Create:
Tools/ModelTranslator/unity_assets.py -
Test:
Tools/ModelTranslator/tests/test_unity_assets.py -
Step 1: 写失败测试
Tools/ModelTranslator/tests/test_unity_assets.py:
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
"""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: 全部 PASS(7 tests OK)
- Step 5: Commit
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 内运行):
"""法线球面编码 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 首个版本)
"""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
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 的两个函数之后追加:
# ---------------- 以下仅在 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:
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
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
"""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 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
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
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 编辑器执行,工具侧无需改动。