From 005cc8f6998503467a17a624f26d7bc354c5de87 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Wed, 15 Jul 2026 10:50:05 +0800 Subject: [PATCH] Add ModelTranslator implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-15-model-translator.md | 1028 +++++++++++++++++ 1 file changed, 1028 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-model-translator.md diff --git a/docs/superpowers/plans/2026-07-15-model-translator.md b/docs/superpowers/plans/2026-07-15-model-translator.md new file mode 100644 index 00000000..80f5dc06 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-model-translator.md @@ -0,0 +1,1028 @@ +# 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** + +把: +```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.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`: +```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: 全部 PASS(7 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 -- +""" +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 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** + +```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 编辑器执行,工具侧无需改动。