From 7c46c061b14438650de2246a25efa33829352dfa Mon Sep 17 00:00:00 2001 From: ud18010 Date: Wed, 15 Jul 2026 10:58:15 +0800 Subject: [PATCH] ModelTranslator: Unity asset templates (texture/material/model meta) with GUID reuse --- .../tests/test_unity_assets.py | 66 ++++ Tools/ModelTranslator/unity_assets.py | 316 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 Tools/ModelTranslator/tests/test_unity_assets.py create mode 100644 Tools/ModelTranslator/unity_assets.py diff --git a/Tools/ModelTranslator/tests/test_unity_assets.py b/Tools/ModelTranslator/tests/test_unity_assets.py new file mode 100644 index 00000000..505abc77 --- /dev/null +++ b/Tools/ModelTranslator/tests/test_unity_assets.py @@ -0,0 +1,66 @@ +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() diff --git a/Tools/ModelTranslator/unity_assets.py b/Tools/ModelTranslator/unity_assets.py new file mode 100644 index 00000000..a35d1927 --- /dev/null +++ b/Tools/ModelTranslator/unity_assets.py @@ -0,0 +1,316 @@ +"""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)