diff --git a/assets/脚本/.gdignore b/assets/脚本/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/assets/脚本/convert.bat b/assets/脚本/convert.bat new file mode 100644 index 0000000..6575064 --- /dev/null +++ b/assets/脚本/convert.bat @@ -0,0 +1,96 @@ +@echo off +chcp 65001 >nul +setlocal + +rem ========================================================================== +rem convert.bat - launcher for convert_models.py (ASCII-only on purpose) +rem +rem Usage: +rem double-click -> use default INPUT / OUTPUT below +rem drag a folder onto this bat -> folder becomes INPUT +rem convert.bat IN OUT -> explicit input / output dir +rem convert.bat IN OUT --draco --recursive -> extra flags are forwarded +rem ========================================================================== + +set "HERE=%~dp0" +set "SCRIPT=%HERE%convert_models.py" + +rem ---------------------------- defaults ------------------------------------ +set "DEF_IN=E:\Godot\agame\assets\FBX" +set "DEF_OUT=E:\Godot\agame\assets\GLB" + +rem ---------------------------- input dir ----------------------------------- +set "INPUT=%~1" +if "%INPUT%"=="" set "INPUT=%DEF_IN%" +if not exist "%INPUT%" set "INPUT=%DEF_IN%" + +rem if a file (not a folder) was dropped, use its parent folder +set "ATTR=%~a1" +if not "%ATTR%"=="" if not "%ATTR:~0,1%"=="d" for %%I in ("%~1") do set "INPUT=%%~dpI" + +rem strip trailing backslash +if "%INPUT:~-1%"=="\" set "INPUT=%INPUT:~0,-1%" + +rem ---------------------------- output dir ---------------------------------- +set "OUTPUT=%~2" +if "%OUTPUT%"=="" set "OUTPUT=%DEF_OUT%" +if "%OUTPUT:~-1%"=="\" set "OUTPUT=%OUTPUT:~0,-1%" + +rem ---------------------------- extra flags --------------------------------- +set "EXTRA=" +if not "%~3"=="" set "EXTRA=%~3" +if not "%~4"=="" set "EXTRA=%EXTRA% %~4" +if not "%~5"=="" set "EXTRA=%EXTRA% %~5" +if not "%~6"=="" set "EXTRA=%EXTRA% %~6" +if not "%~7"=="" set "EXTRA=%EXTRA% %~7" +if not "%~8"=="" set "EXTRA=%EXTRA% %~8" +if not "%~9"=="" set "EXTRA=%EXTRA% %~9" + +rem ---------------------------- find python --------------------------------- +set "PY=" + +where py.exe >nul 2>nul +if %errorlevel% equ 0 set "PY=py -3" + +if not defined PY ( + where python.exe >nul 2>nul + if not errorlevel 1 set "PY=python" +) + +if not defined PY if exist "D:\dev\python\python3.13.7\python.exe" set "PY=D:\dev\python\python3.13.7\python.exe" +if not defined PY if exist "C:\Users\%USERNAME%\.workbuddy\binaries\python\versions\3.13.12\python.exe" set "PY=C:\Users\%USERNAME%\.workbuddy\binaries\python\versions\3.13.12\python.exe" + +if not defined PY ( + echo [ERROR] Python not found. Install Python 3, or edit PY in this file. + echo You can also run Blender directly: + echo blender.exe -b --factory-startup --python "%SCRIPT%" -- --input "%INPUT%" --output "%OUTPUT%" + echo. + pause + exit /b 2 +) + +if not exist "%SCRIPT%" ( + echo [ERROR] convert_models.py not found next to this bat: + echo %SCRIPT% + echo. + pause + exit /b 2 +) + +echo ========================================================== +echo Input : %INPUT% +echo Output : %OUTPUT% +echo Python : %PY% +echo ========================================================== +echo. + +%PY% "%SCRIPT%" --input "%INPUT%" --output "%OUTPUT%" %EXTRA% +set "RC=%errorlevel%" + +echo. +echo ---------------------------------------------------------- +if "%RC%"=="0" echo DONE. +if not "%RC%"=="0" echo FINISHED WITH ERRORS (exit code %RC%). See _convert_report.txt +echo ---------------------------------------------------------- +pause +endlocal & exit /b %RC% diff --git a/assets/脚本/convert_models.py b/assets/脚本/convert_models.py new file mode 100644 index 0000000..07b0e24 --- /dev/null +++ b/assets/脚本/convert_models.py @@ -0,0 +1,797 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +convert_models.py —— 可复用·批量模型转换器(Blender 后端) +========================================================== + +支持把一整个目录的模型批量转成 GLB(也可输出 .gltf / 分离式 glTF)。 +输入格式:fbx / obj / gltf / glb / stl / ply / usd / usda / usdc / dae / blend +输出格式:glb(默认,单文件内嵌贴图)/ gltf(.gltf + .bin + 贴图分离) + +-------------------------------------------------------------------------- +★ GLB 重新导出(re-export) +-------------------------------------------------------------------------- +把已经存在的 .glb 重新走一遍 Blender 的导入 → 导出流程,用于: + + * 换导出参数重出(--draco 压缩、--tex-format jpeg 换贴图编码、 + 去掉相机/灯光/自定义属性、改 Y-up/Z-up …) + * 修好模型后用同一套参数刷新一批旧 GLB + * 统一把某个目录的 GLB 规格拉齐 + +用 --re-export 开启;它会自动把源格式设成 glb、输出格式设成 glb 并允许覆盖。 +默认**原地替换**,替换前会把原文件备份到 `_glb_backup/`(可用 --backup-dir 改位置)。 + + # 原地重导出(自动备份原始 GLB) + python convert_models.py --re-export --input "E:\\Godot\\agame\\assets\\GLB" + + # 把一批老 GLB 的贴图统一压成 JPEG(体积减小的稳妥做法,Godot 完全支持) + python convert_models.py --re-export --input "E:\\Godot\\agame\\assets\\GLB" \ + --tex-format jpeg --tex-quality 85 + +注意:这是**往返(round-trip)**过程,模型会经 Blender 重新解析一次。 +网格数、三角面数、材质数、贴图、动画、自定义属性都会保留;差异仅在: + * 顶点数可能因 glTF 规范在 UV/法线接缝处重新拆分而小幅增加(正常) + * 因此不压缩时体积通常持平,个别模型会 ±几个百分点 + * 若开启 --draco,坐标会被量化,尺寸会有 1e-4 级别的微小变化 + +-------------------------------------------------------------------------- +两种运行方式(脚本会自动判断) +-------------------------------------------------------------------------- +1) 普通终端里直接跑(脚本会自己找到 Blender 并接管): + + python convert_models.py --input "E:\\某个模型目录" --output "E:\\输出目录" + + * 可用 --blender "D:\\...\\blender.exe" 手动指定 Blender + * Windows 下也可以直接双击同目录的 convert.bat(支持拖文件夹进去) + +2) 在 Blender 里跑(把本文件丢进 Text Editor,改完 CONFIG 后 Run Script) + +-------------------------------------------------------------------------- +常用参数(全部可选,命令行会覆盖 CONFIG) +-------------------------------------------------------------------------- + -i, --input DIR 输入目录 + -o, --output DIR 输出目录;默认 __same__(输出到源文件旁边) + -e, --ext LIST 要处理的扩展名,逗号分隔,默认 fbx + -r, --recursive 递归扫描子目录(输出保持相对目录结构) + --overwrite 覆盖已存在的输出(默认跳过 → 可断点续跑) + --dry-run 只列出将要转换的文件,不实际执行 + --suffix S 输出文件名加后缀,如 --suffix _conv + --filter GLOB 只处理文件名匹配该通配符的文件,如 --filter "Door_*" + --exclude GLOB 排除文件名匹配该通配符的文件,如 --exclude "*_LOD*" + --limit N 只处理前 N 个(调试用) + + --re-export GLB 重新导出模式(见上方说明) + --in-place 输出直接替换源文件;替换前自动备份 + --backup-dir DIR 指定备份目录(默认 <源目录>/_glb_backup) + --no-backup 原地替换时不备份(危险,不建议) + + --scale FLOAT 导入时的全局缩放,默认 1.0(源文件按厘米建模时用 0.01) + --apply 导出前应用修改器 + --no-anim 不导出动画 + --no-texture 不导出贴图(仅保留材质基础色) + --tex-format auto|jpeg|webp 贴图编码;默认 auto 保持原编码,jpeg/webp 可显著减小体积 + --tex-quality N 贴图质量 0-100(配合 --tex-format 使用,默认 75) + --leaf-bones 保留自动生成的 leaf bone(默认剔除,游戏资产更干净) + --tangents 导出切线 + --no-extras 不导出自定义属性 + --draco 启用 Draco 网格压缩(体积可降 80%+,但需引擎支持解码) + ⚠ 本项目的 Godot(4.7)官方 glTF 导入器不支持 Draco, + 开启后 GLB 会导入失败。除非另装 GDDraco 之类的 + GDExtension 解码扩展,否则请保持默认关闭。 + --cameras / --lights 顺带导出相机 / 灯光(默认都不导出) + --z-up 输出 Z-up(默认按 glTF 标准输出 Y-up) + --out-format glb|gltf 输出格式,默认 glb + + -q, --quiet 只输出汇总 + -v, --verbose 失败时打印完整堆栈 + --csv 额外生成 _convert_report.csv + +-------------------------------------------------------------------------- +产物 +-------------------------------------------------------------------------- + <输出目录>/<同名模型>.glb 每个源文件一个 + <输出目录>/_convert_report.txt 转换报告(成功/跳过/失败、耗时、体积) + <输出目录>/_convert_report.csv 加 --csv 时才有 + 原地重导出时:原文件被替换,原始文件备份在 <源目录>/_glb_backup/ + +退出码:0 全部成功;1 有失败;2 参数或环境错误。 +""" + +from __future__ import annotations + +import fnmatch +import glob +import os +import shutil +import subprocess +import sys +import time +import traceback + +# =========================================================================== +# CONFIG —— 在 Blender 里 Run Script 时生效;命令行参数会覆盖这里的值 +# =========================================================================== +CONFIG = { + "input": r"E:\Godot\agame\assets\FBX", + "output": "__same__", # 或写成 r"E:\Godot\agame\assets\GLB" + "ext": "fbx", + "recursive": False, + "overwrite": False, # True = 覆盖已有文件;False = 跳过(续跑) + "apply_modifiers": False, + "animations": True, + "textures": True, + "keep_leaf_bones": False, + "scale": 1.0, + "limit": 0, + "yup": True, + "cameras": False, + "lights": False, + "extras": True, + "tangents": False, + "draco": False, + "out_format": "glb", + "suffix": "", + "filter": "", + "exclude": "", + "dry_run": False, + "quiet": False, + "verbose": False, + "csv": False, + # ---- GLB 重新导出 ---- + "re_export": False, # 重新导出模式:源=glb,输出=glb,允许覆盖 + "in_place": False, # 原地替换源文件(自动备份) + "backup_dir": "", # 空 = <源目录>/_glb_backup + "no_backup": False, # 原地替换时不备份(危险) + "tex_format": "auto", # auto / jpeg / webp + "tex_quality": 0, # 0 = 用 Blender 默认 +} + +BOOL_FLAGS = { + "--recursive": ("recursive", True), + "--overwrite": ("overwrite", True), + "--apply": ("apply_modifiers", True), + "--no-anim": ("animations", False), + "--no-texture": ("textures", False), + "--leaf-bones": ("keep_leaf_bones", True), + "--tangents": ("tangents", True), + "--no-extras": ("extras", False), + "--draco": ("draco", True), + "--cameras": ("cameras", True), + "--lights": ("lights", True), + "--z-up": ("yup", False), + "--dry-run": ("dry_run", True), + "-q": ("quiet", True), + "--quiet": ("quiet", True), + "-v": ("verbose", True), + "--verbose": ("verbose", True), + "--csv": ("csv", True), + # ---- GLB 重新导出 ---- + "--re-export": ("re_export", True), + "--reexport": ("re_export", True), + "--in-place": ("in_place", True), + "--no-backup": ("no_backup", True), +} + + +VALUE_FLAGS = { + "-i": "input", "--input": "input", + "-o": "output", "--output": "output", "--out": "output", + "-e": "ext", "--ext": "ext", + "--scale": "scale", + "--limit": "limit", + "--suffix": "suffix", + "--filter": "filter", + "--exclude": "exclude", + "--out-format": "out_format", + "--blender": "blender", # 只在外部启动模式下使用 + "--backup-dir": "backup_dir", + "--tex-format": "tex_format", + "--tex-quality": "tex_quality", +} + +HELP = __doc__ + + +# =========================================================================== +# 命令行解析 +# =========================================================================== +def parse_opts(argv): + """解析纯参数列表(不含 Blender 自身参数)。""" + opts = dict(CONFIG) + opts.setdefault("blender", "") + seen = set() + + i = 0 + while i < len(argv): + a = argv[i] + key, eq, inline = a.partition("=") + + if key in ("-h", "--help"): + print(HELP) + raise SystemExit(0) + + if key in VALUE_FLAGS: + val = inline if eq else (argv[i + 1] if i + 1 < len(argv) else None) + if not eq: + i += 1 + if val is None: + raise SystemExit("[ERROR] 参数 %s 缺少取值" % a) + name = VALUE_FLAGS[key] + if name == "scale": + opts[name] = float(val) + elif name in ("limit", "tex_quality"): + opts[name] = int(val) + elif name in ("out_format", "tex_format"): + opts[name] = val.lower() + else: + opts[name] = val + seen.add(name) + i += 1 + continue + + if key in BOOL_FLAGS: + name, value = BOOL_FLAGS[key] + opts[name] = value + seen.add(name) + i += 1 + continue + + i += 1 + + opts["_seen"] = seen + return apply_mode(opts) + + +def apply_mode(opts): + """把 --re-export / --in-place 归一化成具体行为,并做约束校验。""" + seen = opts.pop("_seen", set()) + + if opts["out_format"] not in ("glb", "gltf"): + raise SystemExit("[ERROR] --out-format 只能是 glb 或 gltf") + if opts["tex_format"] not in ("auto", "jpeg", "webp"): + raise SystemExit("[ERROR] --tex-format 只能是 auto / jpeg / webp") + if not 0 <= int(opts["tex_quality"]) <= 100: + raise SystemExit("[ERROR] --tex-quality 需要在 0-100 之间") + + if opts["re_export"]: + # 重新导出:源 glb -> 输出 glb,允许覆盖 + if "ext" not in seen: + opts["ext"] = "glb" + opts["out_format"] = "glb" + opts["overwrite"] = True + # 没指定输出目录时,默认原地替换(并自动备份) + if opts["output"] == "__same__" and "in_place" not in seen: + opts["in_place"] = True + + if opts["in_place"]: + if opts["out_format"] != "glb": + raise SystemExit("[ERROR] --in-place 只支持 glb 输出(多文件格式无法原地替换)") + exts = {e.strip().lower().lstrip(".") for e in str(opts["ext"]).split(",") if e.strip()} + if exts != {"glb"}: + raise SystemExit("[ERROR] --in-place 要求源文件为 .glb,当前 --ext=%s" % opts["ext"]) + opts["overwrite"] = True + + return opts + + +def blender_user_args(raw_argv): + """Blender 运行模式下,用户参数一律放在 '--' 之后。 + + 在 Blender 的 Text Editor 里直接 Run Script 时通常没有 '--', + 此时返回空列表 —— 即完全使用脚本顶部的 CONFIG。 + """ + if "--" in raw_argv: + return raw_argv[raw_argv.index("--") + 1:] + return [] + + + +# =========================================================================== +# 日志 +# =========================================================================== +def log(msg, quiet=False): + if quiet: + return + try: + print(msg, flush=True) + except UnicodeEncodeError: + print(msg.encode("ascii", "replace").decode("ascii"), flush=True) + except Exception: + pass + + +# =========================================================================== +# Blender 定位 + 外部启动(在普通 Python 里运行时走这里) +# =========================================================================== +def find_blender(): + """返回 blender 可执行文件路径,找不到返回 None。""" + env = os.environ.get("BLENDER_EXE") or os.environ.get("BLENDER") + if env and os.path.isfile(env): + return env + + exe = "blender.exe" if os.name == "nt" else "blender" + from shutil import which + w = which(exe) or which("blender") + if w: + return w + + patterns = [ + r"C:\Program Files\Blender Foundation\Blender*\blender.exe", + r"C:\Program Files (x86)\Blender Foundation\Blender*\blender.exe", + r"D:\Program Files\Blender Foundation\Blender*\blender.exe", + r"E:\Program Files\Blender Foundation\Blender*\blender.exe", + r"C:\Program Files\Steam\steamapps\common\Blender\blender.exe", + r"D:\Steam\steamapps\common\Blender\blender.exe", + "/Applications/Blender.app/Contents/MacOS/Blender", + "/usr/local/bin/blender", + "/usr/bin/blender", + "/snap/bin/blender", + "/opt/blender*/blender", + ] + found = [] + for p in patterns: + found.extend(glob.glob(p)) + + def ver_key(path): + digits = [] + cur = "" + for ch in os.path.basename(os.path.dirname(path)) + os.path.basename(path): + if ch.isdigit(): + cur += ch + elif cur: + digits.append(int(cur)); cur = "" + if cur: + digits.append(int(cur)) + return digits + + found = [f for f in found if os.path.isfile(f)] + if not found: + return None + try: + found.sort(key=ver_key) + except Exception: + pass + return found[-1] + + +def relaunch(opts, raw_argv): + blender = opts.get("blender") or find_blender() + if not blender: + print("[ERROR] 找不到 Blender。请用 --blender 指定,例如:") + print(' python convert_models.py --blender "D:\\Program Files\\Blender ' + 'Foundation\\Blender 5.1\\blender.exe" --input ... --output ...') + return 2 + + child_args = raw_argv[1:] + + cmd = [blender, "-b", "--factory-startup", "--python", os.path.abspath(__file__), "--"] + child_args + print("[INFO] Blender: %s" % blender, flush=True) + print("[INFO] 输出格式: %s -> %s" % (opts.get("ext", "?"), opts.get("out_format", "glb")), flush=True) + print(flush=True) + try: + return subprocess.call(cmd) + except OSError as exc: + print("[ERROR] 启动 Blender 失败: %s" % exc) + return 2 + + +# =========================================================================== +# 以下函数都在 Blender 内运行 +# =========================================================================== +def scene_reset(bpy): + """彻底清空场景,避免上一个模型的数据污染下一个。""" + for obj in list(bpy.data.objects): + bpy.data.objects.remove(obj, do_unlink=True) + for coll in list(bpy.data.collections): + bpy.data.collections.remove(coll) + for _ in range(3): + bpy.data.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) + + +def _try_op(op, **kwargs): + """尝试调用 Blender 算子;成功返回 True。""" + try: + op(**kwargs) + return True + except Exception: + return False + + +def import_model(bpy, path, opts): + """按扩展名导入模型,返回实际使用的导入器名称。""" + ext = os.path.splitext(path)[1].lower().lstrip(".") + scale = opts["scale"] + leaf = not opts["keep_leaf_bones"] + + if ext == "fbx": + # Blender 5.x 新版原生导入器,失败再退回 4.x 的旧算子 + if hasattr(bpy.ops.wm, "fbx_import") and _try_op( + bpy.ops.wm.fbx_import, filepath=path, global_scale=scale, + ignore_leaf_bones=leaf, use_anim=True, use_custom_normals=True): + return "wm.fbx_import" + scene_reset(bpy) + bpy.ops.import_scene.fbx( + filepath=path, global_scale=scale, ignore_leaf_bones=leaf, + automatic_bone_orientation=False, use_anim=True, use_custom_normals=True) + return "import_scene.fbx" + + if ext == "obj": + if _try_op(bpy.ops.wm.obj_import, filepath=path, global_scale=scale): + return "wm.obj_import" + bpy.ops.import_scene.obj(filepath=path, global_scale=scale) + return "import_scene.obj" + + if ext in ("gltf", "glb"): + bpy.ops.import_scene.gltf(filepath=path) + return "import_scene.gltf" + + if ext == "stl": + if _try_op(bpy.ops.wm.stl_import, filepath=path, global_scale=scale): + return "wm.stl_import" + bpy.ops.import_mesh.stl(filepath=path, global_scale=scale) + return "import_mesh.stl" + + if ext == "ply": + if _try_op(bpy.ops.wm.ply_import, filepath=path): + return "wm.ply_import" + bpy.ops.import_mesh.ply(filepath=path) + return "import_mesh.ply" + + if ext in ("usd", "usda", "usdc", "usdz"): + bpy.ops.wm.usd_import(filepath=path, global_scale=scale) + return "wm.usd_import" + + if ext in ("dae", "dae.gz"): + bpy.ops.wm.collada_import(filepath=path) + return "wm.collada_import" + + if ext == "blend": + bpy.ops.wm.open_mainfile(filepath=path) + return "wm.open_mainfile" + + raise RuntimeError("不支持导入的格式: .%s" % ext) + + +def export_model(bpy, dst, opts): + # 贴图编码:auto 保持原编码;jpeg/webp 会重编码,体积通常显著下降 + if not opts["textures"]: + img_fmt = "NONE" + else: + img_fmt = {"auto": "AUTO", "jpeg": "JPEG", "webp": "WEBP"}[opts["tex_format"]] + + kwargs = dict( + filepath=dst, + export_format="GLB" if opts["out_format"] == "glb" else "GLTF_SEPARATE", + use_selection=False, + export_apply=opts["apply_modifiers"], + export_yup=opts["yup"], + export_materials="EXPORT", + export_image_format=img_fmt, + export_animations=opts["animations"], + export_cameras=opts["cameras"], + export_lights=opts["lights"], + export_extras=opts["extras"], + export_texcoords=True, + export_normals=True, + export_tangents=opts["tangents"], + ) + if opts["textures"] and int(opts["tex_quality"]) > 0: + kwargs["export_image_quality"] = int(opts["tex_quality"]) + if opts["draco"]: + kwargs.update( + export_draco_mesh_compression_enable=True, + export_draco_mesh_compression_level=6, + ) + try: + bpy.ops.export_scene.gltf(**kwargs) + except TypeError: + # 老版本 Blender 参数名不兼容时,退化到最小参数集 + bpy.ops.export_scene.gltf( + filepath=dst, + export_format=kwargs["export_format"], + use_selection=False, + export_apply=opts["apply_modifiers"], + export_yup=opts["yup"], + export_animations=opts["animations"], + ) + + +# --------------------------------------------------------------------------- +# 文件收集 +# --------------------------------------------------------------------------- +def collect_files(root, exts, recursive, include, exclude): + """按扩展名/通配符筛选源文件,返回排序后的绝对路径列表。""" + if not os.path.isdir(root): + return [] + if recursive: + walker = ((d, fs) for d, _sub, fs in os.walk(root)) + else: + walker = [(root, os.listdir(root))] + + hits = [] + for dirpath, filenames in walker: + for fn in filenames: + if fn.startswith("."): + continue + if not os.path.isfile(os.path.join(dirpath, fn)): + continue + if os.path.splitext(fn)[1].lower().lstrip(".") not in exts: + continue + if include and not fnmatch.fnmatch(fn.lower(), include.lower()): + continue + if exclude and fnmatch.fnmatch(fn.lower(), exclude.lower()): + continue + hits.append(os.path.join(dirpath, fn)) + return sorted(hits) + + +def _fmt_size(num_bytes): + for unit in ("B", "KB", "MB", "GB"): + if num_bytes < 1024 or unit == "GB": + return "%.2f%s" % (num_bytes, unit) + num_bytes /= 1024.0 + + +def _pct(old, new): + """体积变化百分比,例如 -74%。""" + if not old: + return "" + return "%+.0f%%" % ((new - old) * 100.0 / old) + + +def backup_file(src, backup_root, src_root): + """把源文件复制进备份目录(保持相对目录结构)。 + + 已存在同名备份时不覆盖 —— 保证备份里始终是**最早那一版**原始文件, + 重复跑 --re-export 不会把中间结果覆盖掉原始文件。 + """ + dst = os.path.join(backup_root, os.path.relpath(src, src_root)) + if os.path.exists(dst): + return dst + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copy2(src, dst) + # 让 Godot 跳过备份目录 + gdignore = os.path.join(backup_root, ".gdignore") + if not os.path.exists(gdignore): + try: + open(gdignore, "w").close() + except OSError: + pass + return dst + + +# --------------------------------------------------------------------------- +# 主流程(Blender 内) +# --------------------------------------------------------------------------- +def run_inside_blender(raw_argv): + import bpy + + try: + opts = parse_opts(blender_user_args(raw_argv)) + except SystemExit as exc: + if exc.code: + print(exc.code) + return 0 if exc.code == 0 else 2 + + src_root = os.path.abspath(opts["input"]) if opts["input"] else "" + if not src_root or not os.path.isdir(src_root): + print("[ERROR] 输入目录不存在: %s" % (src_root or "(空)")) + print(" 用 --input \"目录\" 指定,或修改脚本顶部的 CONFIG。") + return 2 + + out_root = src_root if opts["output"] == "__same__" else os.path.abspath(opts["output"]) + + exts = {e.strip().lower().lstrip(".") for e in str(opts["ext"]).split(",") if e.strip()} + files = collect_files(src_root, exts, opts["recursive"], opts["filter"], opts["exclude"]) + if opts["limit"] > 0: + files = files[:int(opts["limit"])] + + # ---- 原地替换模式准备 ---- + in_place = bool(opts["in_place"]) + hint_same_dir = (not in_place and os.path.normcase(out_root) == os.path.normcase(src_root) + and opts["out_format"] == "glb" and exts == {"glb"}) + if hint_same_dir: + in_place = True + + backup_root = "" + if in_place: + if opts["no_backup"]: + backup_root = "" + else: + backup_root = os.path.abspath(opts["backup_dir"]) if opts["backup_dir"] \ + else os.path.join(src_root, "_glb_backup") + out_root = backup_root or src_root + + quiet = opts["quiet"] + log("=" * 72) + log("批量模型转换 -> %s%s" + % (opts["out_format"].upper(), " [原地重新导出]" if in_place else "")) + log("输入目录 : %s" % src_root) + if in_place: + log("输出 : 原地替换源文件") + log("备份 : %s" % (backup_root if backup_root else "(未启用备份)")) + else: + log("输出目录 : %s" % out_root) + log("格式 : %s 找到文件: %d" % (",".join(sorted(exts)), len(files))) + log("递归 : %-5s 覆盖: %-5s 缩放: %s%s" + % (opts["recursive"], opts["overwrite"], opts["scale"], + (" Draco: %s" % opts["draco"]) if opts["draco"] else "")) + if in_place and hint_same_dir and not opts["in_place"]: + log("提示 : 输出目录与源目录相同,已自动切换为原地替换(原文件会先备份)") + log("=" * 72) + + if not files: + log("[WARN] 没有匹配到任何源文件。") + return 0 + + if opts["dry_run"]: + for idx, src in enumerate(files, 1): + log("[%3d/%3d] 待转换 %s" % (idx, len(files), os.path.relpath(src, src_root))) + log("-" * 72) + log("[DRY-RUN] 共 %d 个文件,未实际执行。" % len(files)) + return 0 + + ok, skipped, failed = 0, 0, [] + rows = [] + t_all = time.time() + out_bytes = 0 + + for idx, src in enumerate(files, 1): + stem = os.path.splitext(os.path.basename(src))[0] + if in_place: + dst_dir = os.path.dirname(src) + dst = src + else: + if opts["recursive"]: + rel = os.path.relpath(os.path.dirname(src), src_root) + dst_dir = out_root if rel == "." else os.path.join(out_root, rel) + else: + dst_dir = out_root + dst = os.path.join(dst_dir, stem + opts["suffix"] + "." + opts["out_format"]) + + tag = "[%3d/%3d]" % (idx, len(files)) + if not in_place: + if os.path.abspath(dst) == os.path.abspath(src): + log("%s 跳过(输入输出同名) %s" % (tag, stem), quiet) + skipped += 1 + continue + if os.path.exists(dst) and not opts["overwrite"]: + log("%s 跳过(已存在) %s" % (tag, stem), quiet) + skipped += 1 + rows.append((src, dst, "skipped", "", "0", "")) + continue + + os.makedirs(dst_dir, exist_ok=True) + before = os.path.getsize(src) if os.path.isfile(src) else 0 + # 原地模式先写临时文件,成功后再原子替换,失败不会破坏原文件 + tmp = os.path.join(dst_dir, stem + "__reexport_tmp__." + opts["out_format"]) if in_place else dst + t0 = time.time() + try: + scene_reset(bpy) + importer = import_model(bpy, src, opts) + if not bpy.data.objects: + raise RuntimeError("导入后场景为空") + export_model(bpy, tmp, opts) + + if in_place: + if backup_root: + backup_file(src, backup_root, src_root) + os.replace(tmp, dst) + + size = os.path.getsize(dst) + out_bytes += size + dt = time.time() - t0 + note = ("%s -> %s (%s)" % (_fmt_size(before), _fmt_size(size), _pct(before, size)) + if in_place else _fmt_size(size)) + log("%s OK %-38s %-24s %5.1fs (%s)" + % (tag, stem, note, dt, importer), quiet) + rows.append((src, dst, "ok", importer, "%.2f" % dt, note)) + ok += 1 + except Exception as exc: + dt = time.time() - t0 + log("%s FAIL %-38s %s" % (tag, stem, exc), quiet) + if opts["verbose"]: + log(traceback.format_exc()) + rows.append((src, dst, "failed", "", "%.2f" % dt, str(exc))) + failed.append((src, str(exc), traceback.format_exc())) + try: + if os.path.exists(tmp): + os.remove(tmp) + if not in_place and os.path.exists(dst): + os.remove(dst) + except OSError: + pass + + total = time.time() - t_all + src_bytes = sum(os.path.getsize(f[0]) for f in rows if os.path.isfile(f[0])) if rows else 0 + + # ---- 报告 ---- + report = os.path.join(out_root, "_convert_report.txt") + csv_path = os.path.join(out_root, "_convert_report.csv") + header = "批量模型重新导出报告" if in_place else "批量模型转换报告" + try: + os.makedirs(out_root, exist_ok=True) + with open(report, "w", encoding="utf-8") as f: + f.write("%s\n" % header) + f.write("时间 : %s\n" % time.strftime("%Y-%m-%d %H:%M:%S")) + f.write("输入目录 : %s\n" % src_root) + if in_place: + f.write("输出 : 原地替换源文件\n") + f.write("备份目录 : %s\n" % (backup_root if backup_root else "(未启用备份)")) + else: + f.write("输出目录 : %s\n" % out_root) + f.write("输入格式 : %s\n输出格式 : %s\n" % (",".join(sorted(exts)), opts["out_format"])) + f.write("导出选项 : draco=%s apply=%s anim=%s texture=%s/%s extras=%s yup=%s\n" + % (opts["draco"], opts["apply_modifiers"], opts["animations"], + opts["textures"], opts["tex_format"], opts["extras"], opts["yup"])) + f.write("总计 %d 成功 %d 跳过 %d 失败 %d\n" % (len(files), ok, skipped, len(failed))) + f.write("耗时 : %.1fs\n" % total) + f.write("产物体积 : %s\n" % _fmt_size(out_bytes)) + f.write("\n%-10s %-46s %-8s %s\n" % ("状态", "源文件", "耗时s", "说明")) + f.write("-" * 100 + "\n") + for src, dst, status, imp, dt, note in rows: + f.write("%-10s %-46s %-8s %s\n" % (status, os.path.relpath(src, src_root), dt, note)) + if failed: + f.write("\n--- 失败清单 ---\n") + for src, err, _tb in failed: + f.write("\n%s\n %s\n" % (src, err)) + except OSError as exc: + report = "(写入失败: %s)" % exc + + if opts["csv"]: + try: + import csv as _csv + with open(csv_path, "w", encoding="utf-8-sig", newline="") as f: + w = _csv.writer(f) + w.writerow(["status", "source", "output", "importer", "seconds", "note"]) + for row in rows: + w.writerow([row[2], row[0], row[1], row[3], row[4], row[5]]) + except Exception as exc: + csv_path = "(写入失败: %s)" % exc + + log("-" * 72) + log("完成: 成功 %d / 跳过 %d / 失败 %d 耗时 %.1fs 产物 %s" + % (ok, skipped, len(failed), total, _fmt_size(out_bytes))) + if in_place: + log("原文件已原地替换;备份位置: %s" % (backup_root if backup_root else "(未备份)")) + if failed: + log("失败清单:") + for src, err, _tb in failed: + log(" - %s (%s)" % (os.path.basename(src), err)) + if in_place: + log(" (失败的文件没有被改动,原文件保持原样)") + log("报告: %s" % report) + if opts["csv"]: + log("CSV : %s" % csv_path) + + return 1 if failed else 0 + + +# =========================================================================== +# 入口:自动判断是在 Blender 内还是在普通 Python 里 +# =========================================================================== +def main(): + raw_argv = list(sys.argv) + inside = "bpy" in sys.modules + if not inside: + try: + import bpy # noqa: F401 + inside = True + except ImportError: + inside = False + + if inside: + return run_inside_blender(raw_argv) + + # 普通 Python:解析参数后转交给 Blender + try: + opts = parse_opts(raw_argv[1:]) + except SystemExit as exc: + if exc.code: + print(exc.code) + return 0 if exc.code == 0 else 2 + return relaunch(opts, raw_argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/assets/脚本/reexport_glb.bat b/assets/脚本/reexport_glb.bat new file mode 100644 index 0000000..daef923 --- /dev/null +++ b/assets/脚本/reexport_glb.bat @@ -0,0 +1,86 @@ +@echo off +chcp 65001 >nul +setlocal + +rem ========================================================================== +rem reexport_glb.bat - GLB re-export launcher (ASCII-only on purpose) +rem +rem Re-exports existing .glb files through Blender with the current export +rem settings. Originals are replaced IN PLACE and backed up first into +rem \_glb_backup . +rem +rem Usage: +rem double-click -> re-export DEFAULT_DIR +rem drag a folder onto this bat -> re-export that folder +rem reexport_glb.bat "DIR" -> re-export DIR +rem reexport_glb.bat "DIR" --tex-format jpeg --tex-quality 85 +rem +rem NOTE: do NOT add --draco here. Godot does not read Draco-compressed +rem glTF unless you install a decoder extension. +rem ========================================================================== + +set "HERE=%~dp0" +set "SCRIPT=%HERE%convert_models.py" + +rem ---------------------------- default folder ------------------------------ +set "DEF_DIR=E:\Godot\agame\assets\GLB" + +rem ---------------------------- target folder ------------------------------- +set "TARGET=%~1" +if "%TARGET%"=="" set "TARGET=%DEF_DIR%" +if not exist "%TARGET%" set "TARGET=%DEF_DIR%" +if "%TARGET:~-1%"=="\" set "TARGET=%TARGET:~0,-1%" + +rem ---------------------------- extra flags --------------------------------- +set "EXTRA=" +if not "%~2"=="" set "EXTRA=%~2" +if not "%~3"=="" set "EXTRA=%EXTRA% %~3" +if not "%~4"=="" set "EXTRA=%EXTRA% %~4" +if not "%~5"=="" set "EXTRA=%EXTRA% %~5" +if not "%~6"=="" set "EXTRA=%EXTRA% %~6" + +rem ---------------------------- find python --------------------------------- +set "PY=" + +where py.exe >nul 2>nul +if %errorlevel% equ 0 set "PY=py -3" + +if not defined PY ( + where python.exe >nul 2>nul + if not errorlevel 1 set "PY=python" +) + +if not defined PY if exist "D:\dev\python\python3.13.7\python.exe" set "PY=D:\dev\python\python3.13.7\python.exe" + +if not defined PY ( + echo [ERROR] Python not found. Edit PY in this file. + echo. + pause + exit /b 2 +) + +if not exist "%SCRIPT%" ( + echo [ERROR] convert_models.py not found next to this bat: + echo %SCRIPT% + echo. + pause + exit /b 2 +) + +echo ========================================================== +echo Re-export GLB (in place + backup) +echo Folder : %TARGET% +echo Backup : %TARGET%\_glb_backup +echo ========================================================== +echo. + +%PY% "%SCRIPT%" --re-export --input "%TARGET%" %EXTRA% +set "RC=%errorlevel%" + +echo. +echo ---------------------------------------------------------- +if "%RC%"=="0" echo DONE. +if not "%RC%"=="0" echo FINISHED WITH ERRORS (exit code %RC%). See _glb_backup\_convert_report.txt +echo ---------------------------------------------------------- +pause +endlocal & exit /b %RC% diff --git a/assets/脚本/use.txt b/assets/脚本/use.txt new file mode 100644 index 0000000..71ef5f7 --- /dev/null +++ b/assets/脚本/use.txt @@ -0,0 +1,16 @@ +# 方式一:普通终端,脚本自己找 Blender(已实测通过) +python convert_models.py --input "E:\Godot\agame\assets\FBX" --output "E:\Godot\agame\assets\GLB" --recursive --overwrite + +# 方式二:直接调 Blender +"D:\Program Files\Blender Foundation\Blender 5.1\blender.exe" -b --factory-startup \ + --python "E:\Godot\agame\assets\脚本\convert_models.py" -- --input ... --output ... + + +# 1) 原地重导出:直接替换原 GLB,但会先把原始文件备份 +python convert_models.py --re-export --input "E:\Godot\agame\assets\GLB" + +# 2) 重导出到新目录:原文件完全不动 +python convert_models.py --re-export --input "E:\旧目录" --output "E:\新目录" + +# 顺带把贴图统一压成 JPEG +python convert_models.py --re-export --input "..." --tex-format jpeg --tex-quality 85