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

33 KiB
Raw Permalink Blame History

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.0bpy.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 GUIDcab897163f45445fa34263d6670bfc9e
  • 球面编码: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.hlslDecodeSphereMap 函数)

  • 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.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

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: FAILModuleNotFoundError: 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: 全部 PASS7 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 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
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 编辑器执行,工具侧无需改动。