# 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 编辑器执行,工具侧无需改动。