一些脚本

This commit is contained in:
2026-10-12 17:48:27 +08:00
parent fc89790363
commit 9d60807642
5 changed files with 995 additions and 0 deletions
View File
+96
View File
@@ -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%
+797
View File
@@ -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())
+86
View File
@@ -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 <input>\_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%
+16
View File
@@ -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