1029 lines
33 KiB
Markdown
1029 lines
33 KiB
Markdown
# 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 -- <src.fbx> <outdir> <max_size>
|
||
"""
|
||
import numpy as np
|
||
|
||
|
||
def encode_normal_rg(n):
|
||
"""切线法线 (N,3) -> 球面编码 (N,2),范围 [0,1]。
|
||
shader 侧 XPbr_Base.hlsl DecodeSphereMap 先 *2-1 再解码。"""
|
||
n = n / np.maximum(np.linalg.norm(n, axis=-1, keepdims=True), 1e-8)
|
||
z = np.clip(n[..., 2], -0.99, 1.0)
|
||
denom = np.sqrt(2.0 * (1.0 + z))
|
||
enc = n[..., :2] / denom[..., None]
|
||
return np.clip(enc * 0.5 + 0.5, 0.0, 1.0)
|
||
|
||
|
||
def decode_sphere_map(enc01):
|
||
"""shader DecodeSphereMap 的 numpy 等价(含 *2-1),仅测试用。"""
|
||
e = enc01 * 2.0 - 1.0
|
||
l = 1.0 - e[..., 0] ** 2 - e[..., 1] ** 2
|
||
l = np.clip(l, 0.0, 1.0)
|
||
xy = e * np.sqrt(l)[..., None]
|
||
return np.concatenate([xy * 2.0, (l * 2.0 - 1.0)[..., None]], axis=-1)
|
||
```
|
||
|
||
- [ ] **Step 4: 运行确认通过**
|
||
|
||
Run: `cd Tools/ModelTranslator && "/d/tools/blender-5.0.0-windows-x64/blender.exe" -b --factory-startup --python tests/bl_test_encode.py`
|
||
Expected: 输出 `ENCODE_TEST_OK max_err=... deg`(<2)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add Tools/ModelTranslator/bl_convert.py Tools/ModelTranslator/tests/bl_test_encode.py
|
||
git -c user.name=ud18010 -c user.email=ud18010@xworld.ren commit -m "ModelTranslator: sphere-map normal encode with round-trip test"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: bl_convert.py 主流程
|
||
|
||
**Files:**
|
||
- Modify: `Tools/ModelTranslator/bl_convert.py`(追加主流程;bpy 导入放到 main 段,保证编码函数可独立测试)
|
||
|
||
- [ ] **Step 1: 追加主流程代码**
|
||
|
||
在 Task 3 的两个函数之后追加:
|
||
|
||
```python
|
||
# ---------------- 以下仅在 Blender 内执行 ----------------
|
||
|
||
AO_NAME_RE = None # 延迟编译,见 _looks_like_ao
|
||
|
||
|
||
def _looks_like_ao(name):
|
||
import re
|
||
global AO_NAME_RE
|
||
if AO_NAME_RE is None:
|
||
AO_NAME_RE = re.compile(r"(ao|occlusion|ambient)", re.I)
|
||
return bool(AO_NAME_RE.search(name))
|
||
|
||
|
||
def _image_pixels(img):
|
||
"""bpy Image -> float32 (h, w, 4),原始值(不做色彩变换)。"""
|
||
w, h = img.size
|
||
buf = np.empty(w * h * 4, dtype=np.float32)
|
||
img.pixels.foreach_get(buf)
|
||
return buf.reshape(h, w, 4)
|
||
|
||
|
||
def _scaled_pixels(img, tw, th):
|
||
"""取缩放到 (tw, th) 的像素;用副本 scale,不污染原图。"""
|
||
if tuple(img.size) == (tw, th):
|
||
return _image_pixels(img)
|
||
cp = img.copy()
|
||
try:
|
||
cp.scale(tw, th)
|
||
return _image_pixels(cp)
|
||
finally:
|
||
import bpy
|
||
bpy.data.images.remove(cp)
|
||
|
||
|
||
def _trace_image(socket, warnings, what):
|
||
"""沿输入连线找贴图。返回 (bpy.types.Image | None, channel | None)。
|
||
穿透 Normal Map / Separate Color 节点;channel 为 0..3 或 None(用 R/灰度)。"""
|
||
if not socket.is_linked:
|
||
return None, None
|
||
node = socket.links[0].from_node
|
||
out_name = socket.links[0].from_socket.name
|
||
if node.type == 'TEX_IMAGE':
|
||
return node.image, None
|
||
if node.type == 'NORMAL_MAP':
|
||
return _trace_image(node.inputs['Color'], warnings, what)
|
||
if node.type in ('SEPARATE_COLOR', 'SEPRGB'):
|
||
ch = {'Red': 0, 'Green': 1, 'Blue': 2, 'R': 0, 'G': 1, 'B': 2}.get(out_name, 0)
|
||
img, _ = _trace_image(node.inputs[0], warnings, what)
|
||
return img, ch
|
||
warnings.append("%s: 无法识别的节点 %s,改用常量" % (what, node.type))
|
||
return None, None
|
||
|
||
|
||
def _material_maps(mat, warnings):
|
||
"""从材质 Principled BSDF 提取贴图与常量。"""
|
||
maps = {"basecolor": None, "alpha": None, "normal": None,
|
||
"metallic": None, "roughness": None, "ao": None}
|
||
consts = {"basecolor": (0.5, 0.5, 0.5, 1.0), "metallic": 0.0, "roughness": 0.5}
|
||
if not mat.use_nodes:
|
||
warnings.append("材质未用节点,全部用默认常量")
|
||
return maps, consts
|
||
bsdf = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None)
|
||
if bsdf is None:
|
||
warnings.append("未找到 Principled BSDF,全部用默认常量")
|
||
return maps, consts
|
||
|
||
def grab(input_name, key):
|
||
sock = bsdf.inputs.get(input_name)
|
||
if sock is None:
|
||
return
|
||
img, ch = _trace_image(sock, warnings, key)
|
||
if img is not None:
|
||
maps[key] = (img, ch)
|
||
|
||
grab('Base Color', 'basecolor')
|
||
grab('Alpha', 'alpha')
|
||
grab('Normal', 'normal')
|
||
grab('Metallic', 'metallic')
|
||
grab('Roughness', 'roughness')
|
||
consts["basecolor"] = tuple(bsdf.inputs['Base Color'].default_value)
|
||
consts["metallic"] = float(bsdf.inputs['Metallic'].default_value)
|
||
consts["roughness"] = float(bsdf.inputs['Roughness'].default_value)
|
||
|
||
# AO:材质节点树内按名匹配(未连到 BSDF 也算)
|
||
used = {m[0] for m in maps.values() if m}
|
||
for n in mat.node_tree.nodes:
|
||
if n.type == 'TEX_IMAGE' and n.image and n.image not in used \
|
||
and _looks_like_ao(n.image.name):
|
||
maps["ao"] = (n.image, None)
|
||
break
|
||
return maps, consts
|
||
|
||
|
||
def _target_size(maps, max_size):
|
||
ws = [m[0].size[0] for m in maps.values() if m]
|
||
hs = [m[0].size[1] for m in maps.values() if m]
|
||
if not ws:
|
||
return 4, 4
|
||
return min(max(ws), max_size), min(max(hs), max_size)
|
||
|
||
|
||
def _channel(pixels, ch):
|
||
return pixels[..., ch if ch is not None else 0]
|
||
|
||
|
||
def _save_png(path, rgba, srgb_view=False):
|
||
"""float32 (h,w,4) -> PNG。写原始值(Non-Color,避免色彩管理改写数据)。"""
|
||
import bpy
|
||
h, w = rgba.shape[:2]
|
||
img = bpy.data.images.new("mt_tmp", width=w, height=h, alpha=True)
|
||
img.colorspace_settings.name = 'Non-Color'
|
||
img.pixels.foreach_set(np.ascontiguousarray(rgba, dtype=np.float32).ravel())
|
||
img.filepath_raw = path
|
||
img.file_format = 'PNG'
|
||
img.alpha_mode = 'CHANNEL_PACKED'
|
||
img.save()
|
||
bpy.data.images.remove(img)
|
||
|
||
|
||
def _convert_material(mat, model_name, outdir, max_size):
|
||
import os
|
||
warnings = []
|
||
maps, consts = _material_maps(mat, warnings)
|
||
tw, th = _target_size(maps, max_size)
|
||
|
||
# --- 基础贴图 ---
|
||
base = np.empty((th, tw, 4), dtype=np.float32)
|
||
if maps["basecolor"]:
|
||
base[..., :3] = _scaled_pixels(maps["basecolor"][0], tw, th)[..., :3]
|
||
else:
|
||
base[..., :3] = np.array(consts["basecolor"][:3], dtype=np.float32)
|
||
if maps["alpha"]:
|
||
img, ch = maps["alpha"]
|
||
base[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
|
||
alpha_mode = "transparency"
|
||
elif maps["basecolor"] and maps["basecolor"][0].channels == 4 and \
|
||
_image_has_alpha(maps["basecolor"][0]):
|
||
base[..., 3] = _scaled_pixels(maps["basecolor"][0], tw, th)[..., 3]
|
||
alpha_mode = "transparency"
|
||
elif maps["ao"]:
|
||
img, ch = maps["ao"]
|
||
base[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
|
||
alpha_mode = "ao"
|
||
else:
|
||
base[..., 3] = 1.0
|
||
alpha_mode = "white"
|
||
|
||
# --- 混合贴图 ---
|
||
mix = np.empty((th, tw, 4), dtype=np.float32)
|
||
if maps["normal"]:
|
||
npx = _scaled_pixels(maps["normal"][0], tw, th)[..., :3] * 2.0 - 1.0
|
||
mix[..., 0:2] = encode_normal_rg(npx)
|
||
else:
|
||
mix[..., 0:2] = 0.5
|
||
warnings.append("无法线贴图,RG 填平面法线")
|
||
if maps["metallic"]:
|
||
img, ch = maps["metallic"]
|
||
mix[..., 2] = _channel(_scaled_pixels(img, tw, th), ch)
|
||
else:
|
||
mix[..., 2] = consts["metallic"]
|
||
if maps["roughness"]:
|
||
img, ch = maps["roughness"]
|
||
mix[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
|
||
else:
|
||
mix[..., 3] = consts["roughness"]
|
||
|
||
import re
|
||
sname = re.sub(r"[^0-9A-Za-z_\-]", "_", mat.name).strip("_") or "mat"
|
||
base_png = os.path.join(outdir, "%s_%s_base.png" % (model_name, sname))
|
||
mix_png = os.path.join(outdir, "%s_%s_mix.png" % (model_name, sname))
|
||
_save_png(base_png, base)
|
||
_save_png(mix_png, mix)
|
||
|
||
return {
|
||
"name": mat.name, "safe_name": sname,
|
||
"base_png": os.path.basename(base_png), "mix_png": os.path.basename(mix_png),
|
||
"alpha_mode": alpha_mode, "size": [tw, th],
|
||
"sources": {k: (v[0].name if v else None) for k, v in maps.items()},
|
||
"warnings": warnings,
|
||
}
|
||
|
||
|
||
def _image_has_alpha(img):
|
||
"""采样判断 alpha 是否非全白(全白视为无透明信息)。"""
|
||
px = _image_pixels(img)
|
||
a = px[..., 3]
|
||
return bool((a < 0.995).any())
|
||
|
||
|
||
def _strip_textures():
|
||
import bpy
|
||
for mat in bpy.data.materials:
|
||
if not mat.use_nodes:
|
||
continue
|
||
for n in list(mat.node_tree.nodes):
|
||
if n.type in ('TEX_IMAGE', 'NORMAL_MAP', 'SEPARATE_COLOR', 'SEPRGB'):
|
||
mat.node_tree.nodes.remove(n)
|
||
|
||
|
||
def main():
|
||
import bpy, json, os, sys
|
||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||
src, outdir, max_size = argv[0], argv[1], int(argv[2])
|
||
os.makedirs(outdir, exist_ok=True)
|
||
model_name = os.path.splitext(os.path.basename(src))[0]
|
||
|
||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||
bpy.ops.import_scene.fbx(filepath=src)
|
||
|
||
used_mats = []
|
||
for obj in bpy.data.objects:
|
||
if obj.type != 'MESH':
|
||
continue
|
||
for slot in obj.material_slots:
|
||
if slot.material and slot.material not in used_mats:
|
||
used_mats.append(slot.material)
|
||
|
||
results = [_convert_material(m, model_name, outdir, max_size) for m in used_mats]
|
||
|
||
_strip_textures()
|
||
fbx_out = os.path.join(outdir, model_name + ".fbx")
|
||
bpy.ops.export_scene.fbx(filepath=fbx_out, path_mode='NONE', embed_textures=False)
|
||
|
||
print("MT_SUMMARY " + json.dumps(
|
||
{"model": model_name, "fbx": os.path.basename(fbx_out), "materials": results},
|
||
ensure_ascii=False))
|
||
|
||
|
||
if __name__ == "__main__" and "--" in __import__("sys").argv:
|
||
main()
|
||
```
|
||
|
||
- [ ] **Step 2: 冒烟运行(tong.fbx)**
|
||
|
||
Run:
|
||
```bash
|
||
cd Tools/ModelTranslator && "/d/tools/blender-5.0.0-windows-x64/blender.exe" -b --factory-startup --python bl_convert.py -- src/tong.fbx out/tong 2048 2>&1 | grep -E "MT_SUMMARY|Error|Traceback" | head -5
|
||
```
|
||
Expected: 一行 `MT_SUMMARY {...}`;`out/tong/` 下有 `tong.fbx` 与每材质 `_base.png`/`_mix.png`
|
||
|
||
- [ ] **Step 3: 检查输出尺寸 ≤2048**
|
||
|
||
Run: `cd Tools/ModelTranslator && python -c "import struct,glob;[print(f,struct.unpack('>II',open(f,'rb').read(24)[16:24])) for f in glob.glob('out/tong/*.png')]"`
|
||
Expected: 全部宽高 ≤2048
|
||
|
||
- [ ] **Step 4: 编码测试回归**
|
||
|
||
Run: `cd Tools/ModelTranslator && "/d/tools/blender-5.0.0-windows-x64/blender.exe" -b --factory-startup --python tests/bl_test_encode.py`
|
||
Expected: `ENCODE_TEST_OK`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add Tools/ModelTranslator/bl_convert.py
|
||
git -c user.name=ud18010 -c user.email=ud18010@xworld.ren commit -m "ModelTranslator: Blender conversion pipeline (import, classify, pack, export)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: model_translator.py CLI
|
||
|
||
**Files:**
|
||
- Create: `Tools/ModelTranslator/model_translator.py`
|
||
|
||
- [ ] **Step 1: 实现 CLI**
|
||
|
||
```python
|
||
"""ModelTranslator 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 编辑器执行,工具侧无需改动。
|