一些脚本
This commit is contained in:
@@ -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%
|
||||
@@ -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())
|
||||
@@ -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%
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user