Add AIServer, character art pipeline, and Art package boot integration.

Bring in MaleExplorer assets/shaders, model tooling and Hunyuan updates, plus boot/YooAsset Art package support.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 17:06:59 +08:00
co-authored by Cursor
parent b7a0bbcb89
commit 6550b8df4b
121 changed files with 313260 additions and 113 deletions
+3
View File
@@ -2,6 +2,7 @@
from .auth import ensure_login, is_logged_in, logout, start_wechat_login_blocking, status
from .client import FACE_LABELS, generate_from_image
from .errors import HunyuanQuotaExceeded, HunyuanReloginNeeded
__all__ = [
"ensure_login",
@@ -11,4 +12,6 @@ __all__ = [
"status",
"FACE_LABELS",
"generate_from_image",
"HunyuanQuotaExceeded",
"HunyuanReloginNeeded",
]
+98 -13
View File
@@ -1,6 +1,7 @@
"""WeChat login session for https://3d.hunyuan.tencent.com via Playwright.
CLI 友好:同步阻塞扫码登录(非后台线程)。
未登录 / 凭证失效 / 额度满换号 时弹出有头 Edge 供用户扫码。
"""
from __future__ import annotations
@@ -11,7 +12,7 @@ from pathlib import Path
from typing import Any, Callable, Optional
HUNYUAN_URL = "https://3d.hunyuan.tencent.com/"
LOGIN_TIMEOUT_SEC = 180
LOGIN_TIMEOUT_SEC = 300
UpdateFn = Callable[..., None]
@@ -140,32 +141,87 @@ def _looks_logged_in(page) -> bool:
has_generate = bool(page.get_by_text("立即生成").count())
except Exception:
pass
return bool((interesting or storage_hit) and has_generate)
# 有生成入口,且 cookie/storage 任一命中,或页面上已无「登录」
if has_generate and (interesting or storage_hit):
return True
if has_generate and not _page_shows_login_cta(page):
return True
return bool(interesting and storage_hit)
def _page_shows_login_cta(page) -> bool:
try:
if page.get_by_text("登录", exact=True).count():
return page.get_by_text("登录", exact=True).first.is_visible(timeout=300)
except Exception:
pass
return False
def _try_site_logout(page) -> None:
"""尽量在官网侧退出,方便额度满后换号扫码。"""
for label in ("退出登录", "退出", "登出", "注销"):
if _click_first(page, (label,), exact=False):
page.wait_for_timeout(800)
break
# 常见头像/用户菜单再点一次退出
try:
avatar = page.locator(
'[class*="avatar"], [class*="user-info"], [class*="UserInfo"]'
).first
if avatar.count() and avatar.is_visible(timeout=500):
avatar.click(timeout=2000)
page.wait_for_timeout(400)
_click_first(page, ("退出登录", "退出", "登出"), exact=False)
except Exception:
pass
def start_wechat_login_blocking(
timeout_sec: int = LOGIN_TIMEOUT_SEC,
*,
update: Optional[UpdateFn] = None,
reason: str = "auth",
clear_existing: bool = False,
) -> dict:
"""打开有头浏览器供微信扫码,阻塞直到成功 / 超时 / 失败。"""
"""打开有头浏览器供微信扫码,阻塞直到成功 / 超时 / 失败。
reason:
- auth: 未登录或凭证失效
- quota: 当前账号额度满,需换号
clear_existing: 先清本地会话;quota 场景会再尝试官网退出。
"""
from playwright.sync_api import sync_playwright
def _emit(event: str, **payload: Any) -> None:
if update:
update(event=event, **payload)
if clear_existing or reason == "quota":
logout()
if reason == "quota":
message = (
"当前混元账号额度/次数已满,请在弹出的浏览器中退出旧号并用微信扫码登录其他账号"
)
else:
message = "请在弹出的浏览器窗口中用微信扫码登录混元3D"
_write_status(
logged_in=False,
state="waiting_scan",
message="请在弹出的浏览器窗口中用微信扫码登录",
message=message,
reason=reason,
)
_emit(
"login_waiting",
stage="请在弹出的浏览器窗口中用微信扫码登录",
stage=message,
progress=5,
timeout_sec=timeout_sec,
reason=reason,
)
# 同步打到 stderr,方便 Agent/终端注意弹窗
print(f"[hunyuan] {message}(最长等待 {timeout_sec}s)", flush=True)
try:
with sync_playwright() as playwright:
@@ -177,8 +233,15 @@ def start_wechat_login_blocking(
)
page = context.new_page()
page.goto(HUNYUAN_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(2500)
page.wait_for_timeout(2000)
if reason == "quota":
_try_site_logout(page)
page.goto(HUNYUAN_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(1500)
_click_first(page, ("登录", "立即登录"), exact=True)
page.wait_for_timeout(800)
_click_first(page, ("微信", "微信登录", "微信扫码"), exact=False)
deadline = time.monotonic() + timeout_sec
@@ -187,15 +250,22 @@ def start_wechat_login_blocking(
session_dir().mkdir(parents=True, exist_ok=True)
context.storage_state(path=str(storage_state_path()))
result = _write_status(
logged_in=True, state="ready", message="微信登录成功"
logged_in=True,
state="ready",
message="微信登录成功",
reason=reason,
)
browser.close()
_emit("login_ok", stage="微信登录成功", progress=15, **result)
print("[hunyuan] 微信登录成功,会话已保存", flush=True)
return result
page.wait_for_timeout(1500)
result = _write_status(
logged_in=False, state="timeout", message="微信登录超时,请重试"
logged_in=False,
state="timeout",
message="微信登录超时,请重试",
reason=reason,
)
browser.close()
_emit("login_failed", stage="微信登录超时", progress=0, **result)
@@ -204,7 +274,7 @@ def start_wechat_login_blocking(
raise
except Exception as exc:
result = _write_status(
logged_in=False, state="error", message=f"登录失败:{exc}"
logged_in=False, state="error", message=f"登录失败:{exc}", reason=reason
)
_emit("login_failed", stage=str(exc), progress=0, **result)
raise RuntimeError(f"登录失败:{exc}") from exc
@@ -215,9 +285,14 @@ def ensure_login(
interactive: bool = True,
timeout_sec: int = LOGIN_TIMEOUT_SEC,
update: Optional[UpdateFn] = None,
force: bool = False,
reason: str = "auth",
) -> dict:
"""若无会话则弹微信登录;interactive=False 且未登录则抛错。"""
if is_logged_in():
"""若无会话则弹微信登录;interactive=False 且未登录则抛错。
force=True 时忽略本地凭证,强制打开登录页(换号 / 重登)。
"""
if is_logged_in() and not force:
current = status()
if update:
update(
@@ -234,7 +309,17 @@ def ensure_login(
if update:
update(
event="login_required",
stage="需要微信登录混元3D",
stage=(
"当前账号额度已满,需要换号登录"
if reason == "quota"
else "需要微信登录混元3D"
),
progress=2,
reason=reason,
)
return start_wechat_login_blocking(timeout_sec=timeout_sec, update=update)
return start_wechat_login_blocking(
timeout_sec=timeout_sec,
update=update,
reason=reason,
clear_existing=force or reason == "quota",
)
+201 -7
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from typing import Callable, Optional
from .auth import HUNYUAN_URL, is_logged_in, storage_state_path
from .errors import HunyuanQuotaExceeded, HunyuanReloginNeeded
FACE_LABEL = "50k"
FACE_LABELS = ("50k", "500k", "1m")
@@ -17,6 +18,28 @@ POLL_INTERVAL_SEC = 5
API_LIST = "https://3d.hunyuan.tencent.com/api/3d/creations/list"
API_DETAIL = "https://3d.hunyuan.tencent.com/api/3d/creations/detail"
# 额度/次数用尽常见文案(页面 toast / 弹窗 / API message)
_QUOTA_PATTERNS = (
r"额度不足",
r"额度已用完",
r"额度已满",
r"额度已经用完",
r"次数不足",
r"次数已用完",
r"次数已达上限",
r"今日生成次数",
r"今日次数已用完",
r"已达上限",
r"积分不足",
r"免费次数.*?用完",
r"体验次数.*?用完",
r"生成次数不足",
r"没有剩余.*?次数",
r"剩余次数\s*[::]?\s*0",
r"insufficient\s*(quota|credit|balance)",
)
_QUOTA_RE = re.compile("|".join(_QUOTA_PATTERNS), re.I)
UpdateFn = Callable[..., None]
@@ -47,6 +70,123 @@ def _click_visible_text(page, labels, exact=False, timeout=5000):
return False
def _text_indicates_quota(text: str) -> bool:
if not text:
return False
return bool(_QUOTA_RE.search(text))
def _payload_indicates_quota(payload) -> Optional[str]:
"""从 generations / 其它 API JSON 中提取额度相关错误文案。"""
if payload is None:
return None
if isinstance(payload, str):
return payload if _text_indicates_quota(payload) else None
if isinstance(payload, dict):
for key in (
"message",
"msg",
"error",
"errorMsg",
"errorMessage",
"errMsg",
"reason",
"detail",
"tips",
"tip",
):
value = payload.get(key)
if isinstance(value, str) and _text_indicates_quota(value):
return value
if isinstance(value, (dict, list)):
found = _payload_indicates_quota(value)
if found:
return found
# code 类字段旁带 message
code = payload.get("code") or payload.get("ret") or payload.get("errorCode")
msg = payload.get("message") or payload.get("msg") or ""
if code not in (None, 0, "0", 200, "200", "Success", "success") and _text_indicates_quota(
str(msg)
):
return str(msg)
for key in ("data", "result", "error", "errors"):
found = _payload_indicates_quota(payload.get(key))
if found:
return found
if isinstance(payload, list):
for item in payload:
found = _payload_indicates_quota(item)
if found:
return found
return None
def _page_quota_message(page) -> Optional[str]:
"""优先从弹窗/toast 取额度提示,避免整页营销文案误判。"""
selectors = (
".t-dialog",
".t-message",
".t-notification",
"[class*='dialog']",
"[class*='toast']",
"[class*='message']",
"[role='dialog']",
"[role='alert']",
)
chunks: list[str] = []
for sel in selectors:
try:
locator = page.locator(sel)
total = min(locator.count(), 8)
except Exception:
continue
for index in range(total):
try:
node = locator.nth(index)
if not node.is_visible(timeout=200):
continue
text = (node.inner_text(timeout=500) or "").strip()
if text:
chunks.append(text)
except Exception:
continue
for text in chunks:
if _text_indicates_quota(text):
for line in text.splitlines():
line = line.strip()
if line and _text_indicates_quota(line):
return line[:200]
return text[:200]
try:
body = page.inner_text("body")
except Exception:
return None
# 整页仅在出现非常明确的「用完/不足」短句时判定
for line in body.splitlines():
line = line.strip()
if 4 <= len(line) <= 40 and _text_indicates_quota(line):
return line[:200]
return None
def _raise_if_quota(page=None, payloads=None, fallback: Optional[str] = None):
if payloads:
for payload in payloads:
found = _payload_indicates_quota(payload)
if found:
raise HunyuanQuotaExceeded(
f"混元账号额度不足:{found}。请换号登录后重试。"
)
if page is not None:
found = _page_quota_message(page)
if found:
raise HunyuanQuotaExceeded(
f"混元账号额度不足:{found}。请换号登录后重试。"
)
if fallback and _text_indicates_quota(fallback):
raise HunyuanQuotaExceeded(fallback)
def _dismiss_popups(page, log=None):
"""Close common confirm/agreement toasts that block generate submit."""
for label in ("我知道了", "知道了", "同意并继续", "同意", "确认", "继续", "关闭"):
@@ -54,6 +194,12 @@ def _dismiss_popups(page, log=None):
if log:
log.write(f"dismissed popup via 「{label}」\n")
page.wait_for_timeout(400)
# 协议点完后若仍是额度弹窗,再抛换号
quota_msg = _page_quota_message(page)
if quota_msg:
raise HunyuanQuotaExceeded(
f"混元账号额度不足:{quota_msg}。请换号登录后重试。"
)
def _click_generate(page, log=None):
@@ -202,8 +348,17 @@ def _dump_debug(page, directory: Path, log, network_hits):
def _api_json(request_context, method, url, **kwargs):
response = getattr(request_context, method)(url, **kwargs)
if response.status >= 400:
body_text = ""
try:
body_text = response.text()
except Exception:
pass
if response.status in (401, 403):
raise PermissionError(f"混元接口鉴权失败 {response.status}: {url}")
raise HunyuanReloginNeeded(f"混元接口鉴权失败 {response.status}: {url}")
if _text_indicates_quota(body_text):
raise HunyuanQuotaExceeded(
f"混元账号额度不足(HTTP {response.status}):{body_text[:200]}"
)
raise RuntimeError(f"混元接口失败 {response.status}: {url}")
try:
return response.json()
@@ -379,7 +534,7 @@ def _poll_until_ready(
waited = int(GENERATION_TIMEOUT_SEC - (deadline - time.monotonic()))
try:
detail = _detail_creation(request_context, creations_id)
except PermissionError:
except HunyuanReloginNeeded:
raise
except Exception:
listing = _list_creations(request_context)
@@ -432,10 +587,15 @@ def generate_from_image(
directory: Path,
update: UpdateFn,
face_label: str = FACE_LABEL,
*,
headed: bool = False,
) -> Path:
"""图生3D,返回 directory/generated.glb。鉴权失败抛 PermissionError。"""
"""图生3D,返回 directory/generated.glb。
鉴权失败抛 HunyuanReloginNeeded;额度满抛 HunyuanQuotaExceeded。
"""
if not is_logged_in():
raise PermissionError("尚未微信登录混元3D,请先登录。")
raise HunyuanReloginNeeded("尚未微信登录混元3D,请先登录。")
if face_label not in FACE_LABELS:
raise ValueError(f"面数须为 {FACE_LABELS} 之一,收到: {face_label}")
@@ -483,13 +643,16 @@ def generate_from_image(
if found:
creations_id = found
entry["creationsId"] = found
quota = _payload_indicates_quota(body)
if quota:
entry["quota"] = quota
except Exception:
pass
update(event="stage", stage="打开混元3D 网页", progress=20)
with log_path.open("w", encoding="utf-8") as log, sync_playwright() as playwright:
browser = playwright.chromium.launch(
channel=_browser_channel(), headless=True
channel=_browser_channel(), headless=not headed
)
context = browser.new_context(
storage_state=str(state_path),
@@ -505,7 +668,7 @@ def generate_from_image(
log.write("opened hunyuan home\n")
if _page_looks_logged_out(page):
raise PermissionError("混元登录凭证已失效,需要重新微信登录。")
raise HunyuanReloginNeeded("混元登录凭证已失效,需要重新微信登录。")
update(event="stage", stage="进入图/文生3D", progress=26)
_enter_studio(page, log)
@@ -552,6 +715,7 @@ def generate_from_image(
submitted = False
for attempt in range(1, 4):
if not _click_generate(page, log=log):
_raise_if_quota(page=page, payloads=generations_bodies)
_dump_debug(page, directory, log, network_hits)
raise RuntimeError("未找到「立即生成」按钮,页面结构可能已变化。")
log.write(f"generate attempt {attempt}/3\n")
@@ -565,15 +729,21 @@ def generate_from_image(
submitted = True
log.write("generations API observed\n")
break
# 提交无响应时优先识别额度弹窗
_raise_if_quota(page=page, payloads=generations_bodies)
log.write("no /creations/generations yet; will retry click\n")
page.wait_for_timeout(1200)
if not submitted:
_raise_if_quota(page=page, payloads=generations_bodies)
_dump_debug(page, directory, log, network_hits)
raise RuntimeError(
"点击「立即生成」后官网未发起生成请求(无 /creations/generations)。"
"请打开混元官网确认额度/协议弹窗后重试。"
"可能是额度不足或协议弹窗未处理;脚本将尝试打开登录页换号。"
)
# generations 返回了但可能是额度错误
_raise_if_quota(page=page, payloads=generations_bodies)
(directory / "hunyuan_generations.json").write_text(
json.dumps(generations_bodies, ensure_ascii=False, indent=2)[:200000],
encoding="utf-8",
@@ -635,12 +805,36 @@ def generate_from_image(
)
context.storage_state(path=str(state_path))
return mesh
except HunyuanQuotaExceeded:
log.write("ERROR: quota exceeded\n")
try:
_dump_debug(page, directory, log, network_hits)
except Exception:
pass
raise
except HunyuanReloginNeeded:
log.write("ERROR: re-login needed\n")
try:
_dump_debug(page, directory, log, network_hits)
except Exception:
pass
raise
except Exception as exc:
log.write(f"ERROR: {exc}\n")
try:
_dump_debug(page, directory, log, network_hits)
except Exception:
pass
# 提交失败且页面有额度提示时,升级为换号登录
if "未发起生成请求" in str(exc) or "立即生成" in str(exc):
try:
_raise_if_quota(page=page, payloads=generations_bodies)
except HunyuanQuotaExceeded:
raise
# 无法确认额度时也当作需换号,方便用户打开登录页处理
raise HunyuanQuotaExceeded(
f"{exc}(已按额度/账号问题处理:将打开登录页换号)"
) from exc
raise
finally:
browser.close()
+14
View File
@@ -0,0 +1,14 @@
"""混元 3D 流程异常:触发交互式重新登录 / 换号。"""
from __future__ import annotations
class HunyuanReloginNeeded(PermissionError):
"""凭证失效或未登录,需要打开登录页重新微信扫码。"""
reason = "auth"
class HunyuanQuotaExceeded(HunyuanReloginNeeded):
"""当前账号额度/次数用尽,需要换号登录后再生成。"""
reason = "quota"
+40 -9
View File
@@ -33,6 +33,7 @@ from hunyuan_3d.auth import ( # noqa: E402
status,
)
from hunyuan_3d.client import FACE_LABEL, FACE_LABELS, generate_from_image # noqa: E402
from hunyuan_3d.errors import HunyuanQuotaExceeded, HunyuanReloginNeeded # noqa: E402
from hunyuan_3d.events import CallbackHub # noqa: E402
@@ -83,10 +84,11 @@ def run_generate(
output: str,
faces: str = FACE_LABEL,
interactive_login: bool = True,
login_timeout: int = 180,
login_timeout: int = 300,
callback_url: Optional[str] = None,
callback_file: Optional[str] = None,
quiet: bool = False,
max_login_retries: int = 3,
) -> Dict[str, Any]:
callbacks = CallbackHub(
callback_url=callback_url,
@@ -134,23 +136,43 @@ def run_generate(
attempts += 1
try:
mesh = generate_from_image(
image_path, work_dir, update, face_label=face_label
image_path,
work_dir,
update,
face_label=face_label,
headed=False,
)
break
except PermissionError as exc:
if not interactive_login or attempts >= 2:
except HunyuanReloginNeeded as exc:
reason = getattr(exc, "reason", "auth") or "auth"
if isinstance(exc, HunyuanQuotaExceeded):
reason = "quota"
if not interactive_login or attempts > max_login_retries:
raise GenerateError(str(exc)) from exc
update(
event="login_required",
stage="凭证失效,重新微信登录",
stage=(
"当前账号额度已满,打开登录页换号"
if reason == "quota"
else "凭证失效,重新微信登录"
),
progress=3,
error=str(exc),
reason=reason,
attempt=attempts,
)
print(
f"[hunyuan] {exc}\n"
f"[hunyuan] 即将打开浏览器登录页(原因={reason},第 {attempts}/{max_login_retries} 次)",
flush=True,
)
# 清掉失效会话再扫码
if is_logged_in():
logout()
start_wechat_login_blocking(
timeout_sec=login_timeout, update=update
timeout_sec=login_timeout,
update=update,
reason=reason,
clear_existing=True,
)
shutil.copy2(mesh, output_path)
@@ -203,8 +225,13 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument(
"--login-timeout",
type=int,
default=180,
help="微信扫码等待秒数,默认 180",
default=300,
help="微信扫码等待秒数,默认 300",
)
p.add_argument(
"--force-login",
action="store_true",
help="忽略本地凭证,强制弹出登录页(换号)",
)
p.add_argument("--callback-url", default=None, help="流程回调 URL:每个事件 POST JSON")
p.add_argument(
@@ -252,10 +279,14 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
quiet=quiet_events,
)
update = _make_update(callbacks)
force = bool(args.force_login or payload.get("force_login"))
reason = str(payload.get("reason") or ("quota" if force else "auth"))
try:
result = start_wechat_login_blocking(
timeout_sec=int(payload.get("login_timeout") or args.login_timeout),
update=update,
reason=reason,
clear_existing=force or reason == "quota",
)
except Exception as exc: # noqa: BLE001
err = {"ok": False, "error": str(exc)}
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""将角色 XPbr / 烘焙产物安装到 AIClient/Assets/Bundles/Characters/{ActorName}/。
优先识别 XPbr 产物(推荐):
{ActorName}.fbx
{ActorName}_base.png # RGB albedo, A=AO/透明
{ActorName}_mix.png # RG sphere-normal, B=metallic, A=roughness
{ActorName}.mat # Test/XPbr
concept.png # 可选
{ActorName}.prefab # Unity 菜单生成
兼容旧五通道烘焙目录(_color/_normal/_ao/_metallic/_roughness)。
用法:
python Tools/install_character_bundles.py -a MaleExplorer -b Tools/output/actor_game/actor_xpbr -c ref.png
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from pathlib import Path
def _repo_root() -> Path:
return Path(__file__).resolve().parent.parent
def _pick_first(files: list[Path], predicate) -> Path | None:
for path in files:
if path.is_file() and predicate(path.name.lower()):
return path
return None
def install(
*,
actor: str,
bake_dir: Path,
concept: Path | None = None,
dry_run: bool = False,
) -> dict:
actor = actor.strip()
if not actor or any(ch in actor for ch in r'\/:*?"<>|'):
raise ValueError(f"非法 ActorName: {actor!r}")
bake_dir = bake_dir.expanduser().resolve()
if not bake_dir.is_dir():
raise FileNotFoundError(f"资源目录不存在: {bake_dir}")
dest = _repo_root() / "AIClient" / "Assets" / "Bundles" / "Characters" / actor
files = list(bake_dir.iterdir())
sources: dict[str, Path] = {}
fbx = _pick_first(files, lambda n: n.endswith(".fbx"))
if fbx:
sources["fbx"] = fbx
# XPbr 优先
base = _pick_first(files, lambda n: n.endswith("_base.png") or n.endswith("_basemap.png"))
mix = _pick_first(files, lambda n: n.endswith("_mix.png") or n.endswith("_mixmap.png"))
mat = _pick_first(files, lambda n: n.endswith(".mat"))
if base:
sources["base"] = base
if mix:
sources["mix"] = mix
if mat:
sources["mat"] = mat
# 旧五通道兜底
if "base" not in sources:
color = _pick_first(files, lambda n: "_color" in n and n.endswith(".png"))
if color:
sources["color"] = color
for key, token in (
("normal", "_normal"),
("ao", "_ao"),
("metallic", "_metallic"),
("roughness", "_roughness"),
):
hit = _pick_first(files, lambda n, t=token: t in n and n.endswith(".png"))
if hit:
sources[key] = hit
if "fbx" not in sources:
raise FileNotFoundError(f"目录缺少 FBX: {bake_dir}")
if "base" not in sources and "color" not in sources:
raise FileNotFoundError(
f"目录缺少 XPbr _base.png 或旧版 _color.png: {bake_dir}"
)
name_map = {
"fbx": f"{actor}.fbx",
"base": f"{actor}_base.png",
"mix": f"{actor}_mix.png",
"mat": f"{actor}.mat",
"color": f"{actor}_color.png",
"normal": f"{actor}_normal.png",
"ao": f"{actor}_ao.png",
"metallic": f"{actor}_metallic.png",
"roughness": f"{actor}_roughness.png",
}
copied: list[str] = []
if not dry_run:
dest.mkdir(parents=True, exist_ok=True)
for key, src in sources.items():
target = dest / name_map[key]
if not dry_run:
shutil.copy2(src, target)
# 同步 .meta(GUID 复用,避免破坏引用)
meta = Path(str(src) + ".meta")
if meta.is_file():
shutil.copy2(meta, Path(str(target) + ".meta"))
copied.append(str(target.relative_to(_repo_root())).replace("\\", "/"))
concept_rel = None
if concept is not None:
concept = concept.expanduser().resolve()
if not concept.is_file():
raise FileNotFoundError(f"概念图不存在: {concept}")
target = dest / "concept.png"
if not dry_run:
shutil.copy2(concept, target)
concept_rel = str(target.relative_to(_repo_root())).replace("\\", "/")
copied.append(concept_rel)
mode = "xpbr" if "base" in sources else "legacy_pbr"
return {
"ok": True,
"actor": actor,
"mode": mode,
"dest": str(dest.relative_to(_repo_root())).replace("\\", "/"),
"files": copied,
"hint": (
f"在 Unity 执行菜单 AIGame/M3/Setup Character Prefab + Art Collector "
f"生成挂 XPbr 的 {actor}.prefab;并登记 Doc/ResDesc_Model.md"
),
}
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="安装角色资源到 Bundles/Characters/{Actor}")
p.add_argument("--actor", "-a", required=True, help="角色目录名 / 寻址名,如 MaleExplorer")
p.add_argument(
"--bake-dir",
"-b",
required=True,
help="model_lowpoly_bake 的 *_xpbr 目录,或旧五通道 bake 子目录",
)
p.add_argument("--concept", "-c", default=None, help="可选概念图,复制为 concept.png")
p.add_argument("--dry-run", action="store_true")
args = p.parse_args(argv)
try:
result = install(
actor=args.actor,
bake_dir=Path(args.bake_dir),
concept=Path(args.concept) if args.concept else None,
dry_run=args.dry_run,
)
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), flush=True)
return 1
print(json.dumps(result, ensure_ascii=False, indent=2), flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
+36 -28
View File
@@ -1,48 +1,56 @@
# 高模减面与贴图烘焙
# 高模减面与贴图烘焙 + XPbr 打包
复刻自 `ClientTrunk/Tools/ModelTools`(`model_decimate` + `model_bake`)。
复刻自 ClientTrunk ModelTools,并接入 AIC#Project ModelTranslator 的 **XPbr** 收尾。
```text
高模(FBX / GLB / glTF)
-> model_decimate.py:合并 Mesh、清理、三角化、减面、重展 UV
-> <名称>_low.fbx
-> model_bake.py:Cycles selected-to-active 烘焙
-> 低模 FBX + color / normal / ao / metallic / roughness PNG
-> model_decimate:减面 + 重展 UV -> <名>_low.fbx
-> model_bake:Cycles 烘焙 -> 五通道 PNG + 低模 FBX
-> model_translator:打包为 XPbr 两张图 + .mat/.meta
_base.png RGB=Albedo A=AO/透明
_mix.png RG=球面法线 B=Metallic A=Roughness
```
## 环境
- 安装 Blender(推荐 5.0),并满足其一:
- `blender` 在 PATH
- 环境变量 `BLENDER_EXE` 或 `BLENDER_PATH`
- 运行时 `--blender "D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe"`
- 本机默认路径已写在 `mt_run.py`:`D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe`
- UV 观察图需要 Pillow:`pip install Pillow`
- UVPackmaster 4 可选(已装则自动用于 UV 排布)
- Blender 5.0:默认 `D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe`
- 或 `BLENDER_EXE` / `BLENDER_PATH` / `--blender`
- Pillow(UV 观察图):`pip install Pillow`
- UVPackmaster 4 可选
## 分步命令
## 一键(推荐)
```powershell
python Tools/model_tools/model_decimate.py D:\Assets\house.glb --tris 5000 -o D:\Assets\house_low
python Tools/model_tools/model_bake.py D:\Assets\house.glb D:\Assets\house_low\house_low.fbx -o D:\Assets\house_bake --size 2048
python Tools/model_tools/model_lowpoly_bake.py prop.glb -o Tools/output/prop_game --tris 5000 --size 2048
# 默认会继续生成 Tools/output/prop_game/prop_xpbr/(base+mix+.mat)
# 跳过 XPbr:加 --no-xpbr
```
## 一键流水线(推荐 Agent 使用)
## 分步
```powershell
python Tools/model_tools/model_lowpoly_bake.py D:\Assets\house.glb -o Tools/output/house --tris 5000 --size 2048
python Tools/model_tools/model_decimate.py prop.glb --tris 5000 -o Tools/output/prop_low
python Tools/model_tools/model_bake.py prop.glb Tools/output/prop_low/prop_low.fbx -o Tools/output/prop_bake --size 2048
python Tools/model_tools/model_translator.py Tools/output/prop_bake/prop/prop.fbx -o Tools/output/prop_xpbr
```
输出示例:
## 装入 Unity + 预设
```text
Tools/output/house/house/
house.fbx
house_color.png
house_normal.png
house_ao.png
house_metallic.png
house_roughness.png
```powershell
python Tools/install_character_bundles.py -a MaleExplorer -b Tools/output/prop_game/prop_xpbr -c ref.png
# Unity 菜单:AIGame/M3/Setup Character Prefab + Art Collector
# 会挂 Test/XPbr,生成 Characters/MaleExplorer/MaleExplorer.prefab
```
生成完成后请在 `Doc/ResDesc_Model.md` 登记资源。
## 与 AIC#Project/ModelTranslator 减面烘焙对比
| 能力 | 本仓库 `Tools/model_tools`(现) | AIC#Project ModelTranslator |
| --- | --- | --- |
| 高模格式 | FBX/GLB/glTF | 主路径偏 FBX(减面 CLI 文案) |
| 减面后端 | Decimate collapse | **默认 Quad Remesher**,失败回退 collapse |
| UV 质量门 | 翻转/重叠 + **最大单岛占比**(已对齐) | 翻转/重叠/最大单岛 + 更深 seam 扫描 |
| 烘焙射线 | **自适应分离距离**(已对齐) | 自适应分离距离 |
| 收尾 | **XPbr base/mix + .mat**(已接入) | 同左(源头) |
| QR 引擎 | 未捆绑(无 xremesh 依赖) | 需本机 Quad Remesher 引擎 |
**结论**:AIC#Project 在「有机角色拓扑」上更优(Quad Remesher + 更深 UV 门);本仓库已并入其自适应射线、大岛门限与 XPbr 打包,并保留 GLB 输入。若本机已装 QR 引擎,可再把 `qr_bridge` / `--reducer quad` 完整迁入作为可选增强。
+72 -2
View File
@@ -8,7 +8,10 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
DEFAULT_RAY_PCT = 0.02 # 自动射线距离 = 低模包围盒对角线 * 2%
DEFAULT_RAY_PCT = 0.02 # 测量失败时回退:低模包围盒对角线 * 2%
RAY_SAFETY = 1.5 # 自适应射线安全系数(分离 P99 × 此值)
RAY_LOWER_PCT = 0.0005 # 射线下限 = 包围盒对角线 * 0.05%
RAY_UPPER_PCT = 0.01 # 射线上限 = 包围盒对角线 * 1%
BBOX_TOL = 0.10 # 高低模包围盒尺寸相对差警告阈值
# (输出名, bake type, EMIT 来源的 Principled 输入, 目标图颜色空间)
@@ -26,6 +29,33 @@ def estimate_ray_distance(bbox_dims, pct=DEFAULT_RAY_PCT):
return math.sqrt(sum(d * d for d in bbox_dims)) * pct
def percentile(values, q):
"""插值分位数。空列表或 q∉[0,1] 抛 ValueError。"""
if not values:
raise ValueError("percentile 需要至少一个值")
if not 0.0 <= q <= 1.0:
raise ValueError("分位数 q 必须在 [0,1]")
ordered = sorted(float(v) for v in values)
pos = (len(ordered) - 1) * q
lo = int(math.floor(pos))
hi = int(math.ceil(pos))
if lo == hi:
return ordered[lo]
return ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo)
def adaptive_ray_distance(distances, bbox_dims):
"""按低↔高分离距离定射线:P99×RAY_SAFETY,夹在对角线 [0.05%,1%]。"""
diagonal = math.sqrt(sum(float(d) * float(d) for d in bbox_dims))
if diagonal <= 0.0:
raise ValueError("低模包围盒对角线必须为正")
p99 = percentile(distances, 0.99)
lower = diagonal * RAY_LOWER_PCT
upper = diagonal * RAY_UPPER_PCT
raw = max(p99 * RAY_SAFETY, lower)
return {"distance_p99": p99, "value": min(raw, upper), "capped": raw > upper}
def output_stem(low_name):
"""低模名去掉 _low 后缀作为输出前缀(对齐工具3纯网格贴图命名约定)。"""
return low_name[:-4] if low_name.endswith("_low") else low_name
@@ -65,6 +95,29 @@ def _import_model_joined(path, warnings):
return obj
def _measure_separation(low, high, limit=10000):
"""低模面心到高模表面最近距离数组(供自适应射线)。"""
from mathutils.bvhtree import BVHTree
hv = [high.matrix_world @ v.co for v in high.data.vertices]
hp = [tuple(p.vertices) for p in high.data.polygons]
bvh = BVHTree.FromPolygons(hv, hp, all_triangles=False)
polys = low.data.polygons
n = len(polys)
if n == 0:
return []
if n <= limit:
idxs = range(n)
else:
idxs = [round(i * (n - 1) / float(limit - 1)) for i in range(limit)]
mw = low.matrix_world
dists = []
for i in idxs:
hit = bvh.find_nearest(mw @ polys[i].center)
if hit[0] is not None and hit[3] is not None:
dists.append(hit[3])
return dists
def _setup_bake_target(low, stem):
"""低模换成单一烘焙材质,返回接收烘焙结果的 image 节点。"""
import bpy
@@ -177,7 +230,24 @@ def main():
% (BBOX_TOL * 100,
[round(d, 3) for d in high_dims],
[round(d, 3) for d in low_dims]))
ray = estimate_ray_distance(low_dims) if ray_arg == "auto" else float(ray_arg)
if ray_arg == "auto":
dists = _measure_separation(low, high)
if dists:
try:
adapt = adaptive_ray_distance(dists, low_dims)
ray = adapt["value"]
warnings.append(
"自适应射线距离=%.6f(P99=%.6f%s)"
% (ray, adapt["distance_p99"],
",已触顶" if adapt["capped"] else ""))
except ValueError as exc:
ray = estimate_ray_distance(low_dims)
warnings.append("自适应射线失败(%s),回退对角线 2%%" % exc)
else:
ray = estimate_ray_distance(low_dims)
warnings.append("分离距离采样为空,回退对角线 2%% 射线")
else:
ray = float(ray_arg)
stem = output_stem(os.path.splitext(os.path.basename(low_fbx))[0])
os.makedirs(outdir, exist_ok=True)
+409
View File
@@ -0,0 +1,409 @@
"""Blender 内运行:FBX -> 纯模型 FBX + XPbr 基础/混合贴图。
调用:blender -b --factory-startup --python bl_convert.py -- <src.fbx> <outdir> <max_size>
"""
import os
import re
import sys
import numpy as np
# blender --python 不会把脚本目录加入 sys.path,手动加入以复用 unity_assets(纯 stdlib)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from unity_assets import safe_name
AO_NAME_RE = re.compile(r"(ao|occlusion|ambient)", re.I)
def encode_normal_rg(n):
"""切线法线 (N,3) -> 球面编码 (N,2),范围 [0,1]。
shader 侧 XPbr_Base.hlsl DecodeSphereMap 先 *2-1 再解码。"""
n = n / np.maximum(np.linalg.norm(n, axis=-1, keepdims=True), 1e-8)
z = np.clip(n[..., 2], -0.99, 1.0)
denom = np.sqrt(2.0 * (1.0 + z))
enc = n[..., :2] / denom[..., None]
return np.clip(enc * 0.5 + 0.5, 0.0, 1.0)
def decode_sphere_map(enc01):
"""shader DecodeSphereMap 的 numpy 等价(含 *2-1),仅测试用。"""
e = enc01 * 2.0 - 1.0
l = 1.0 - e[..., 0] ** 2 - e[..., 1] ** 2
l = np.clip(l, 0.0, 1.0)
xy = e * np.sqrt(l)[..., None]
return np.concatenate([xy * 2.0, (l * 2.0 - 1.0)[..., None]], axis=-1)
def linear_to_srgb(c):
c = np.clip(c, 0.0, 1.0)
return np.where(c <= 0.0031308, c * 12.92,
1.055 * np.power(c, 1.0 / 2.4) - 0.055)
def encode_base_texture_for_png(rgba, rgb_is_linear):
"""Base RGB is stored for Unity sRGB import; alpha keeps AO/opacity data.
Image.pixels 对 8-bit 图返回原始字节值(已是 sRGB 编码,实测 blender 5.0),
仅线性来源(float 图 / Principled 常量色)需要 linear->sRGB,否则双重 gamma 变亮。"""
out = np.clip(np.array(rgba, dtype=np.float32, copy=True), 0.0, 1.0)
if rgb_is_linear:
out[..., :3] = linear_to_srgb(out[..., :3])
return out
# ---------------- 以下仅在 Blender 内执行 ----------------
IMG_EXTS = ('.png', '.jpg', '.jpeg', '.tga', '.exr', '.bmp', '.tif', '.tiff')
# 磁盘贴图命名约定:文件名末段 token -> 贴图槽位
DISK_MAP_TOKENS = {
"color": "basecolor", "basecolor": "basecolor", "albedo": "basecolor",
"diffuse": "basecolor",
"normal": "normal", "nrm": "normal",
"metallic": "metallic", "metalness": "metallic", "metal": "metallic",
"roughness": "roughness", "rough": "roughness",
"ao": "ao", "occlusion": "ao", "ambient": "ao",
"alpha": "alpha", "opacity": "alpha",
}
def _looks_like_ao(name):
return bool(AO_NAME_RE.search(name))
def _image_broken(img):
"""外置贴图文件丢失/加载失败时,Blender 留下 0 尺寸空图。"""
return img.size[0] == 0 or img.size[1] == 0
def _image_stem(img):
"""贴图文件名(不含扩展名);外置贴图优先用 filepath,比 img.name 可靠。"""
path = img.filepath if img.filepath else img.name
return os.path.splitext(os.path.basename(path))[0]
def _load_disk_image(src_dir, fname, warnings):
"""加载 FBX 同目录的贴图文件,失败返回 None。"""
import bpy
try:
img = bpy.data.images.load(os.path.join(src_dir, fname), check_existing=True)
except RuntimeError as e:
warnings.append("贴图 %s 加载失败:%s" % (fname, e))
return None
if _image_broken(img):
warnings.append("贴图 %s 加载失败(0 尺寸)" % fname)
return None
return img
def _find_maps_on_disk(src_dir, model_name, maps, warnings):
"""材质无任何贴图(如纯网格 FBX)时,按命名约定从 FBX 同目录识别整套贴图。
文件按 <前缀>_<token>.<ext> 归组;多套前缀时取与模型同名的一组。"""
try:
files = sorted(os.listdir(src_dir))
except OSError:
return
groups = {} # 前缀 -> {槽位: 文件名}
for f in files:
stem, ext = os.path.splitext(f)
if ext.lower() not in IMG_EXTS or "_" not in stem:
continue
prefix, token = stem.rsplit("_", 1)
key = DISK_MAP_TOKENS.get(token.lower())
if key:
groups.setdefault(prefix, {}).setdefault(key, f)
if not groups:
return
if model_name in groups:
chosen = groups[model_name]
elif len(groups) == 1:
chosen = next(iter(groups.values()))
else:
warnings.append("同目录有多套命名约定贴图(前缀:%s),无法确定归属,忽略"
% ", ".join(sorted(groups)))
return
for key, fname in chosen.items():
if maps.get(key):
continue
img = _load_disk_image(src_dir, fname, warnings)
if img is not None:
maps[key] = (img, None)
def _find_ao_on_disk(src_dir, maps, warnings):
"""材质未引用 AO 时,从 FBX 同目录按文件名兜底搜索(烘焙工具常只把 _ao.png 放旁边)。
多候选时优先与 basecolor 贴图同前缀(xxx_color.png -> xxx_ao.png)。"""
import bpy
try:
files = sorted(os.listdir(src_dir))
except OSError:
return None
cands = [f for f in files if f.lower().endswith(IMG_EXTS)
and _looks_like_ao(os.path.splitext(f)[0])]
if not cands:
return None
pick = None
if maps["basecolor"] and len(cands) > 1:
prefix = _image_stem(maps["basecolor"][0]).rsplit("_", 1)[0]
matched = [f for f in cands if f.startswith(prefix)]
if matched:
pick = matched[0]
if pick is None:
if len(cands) == 1:
pick = cands[0]
else:
warnings.append("同目录有多张疑似 AO 贴图无法确定归属,忽略:%s" % ", ".join(cands))
return None
return _load_disk_image(src_dir, pick, warnings)
def _image_pixels(img):
"""bpy Image -> float32 (h, w, 4),原始值(不做色彩变换)。"""
w, h = img.size
buf = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(buf)
return buf.reshape(h, w, 4)
def _scaled_pixels(img, tw, th):
"""取缩放到 (tw, th) 的像素;用副本 scale,不污染原图。"""
if tuple(img.size) == (tw, th):
return _image_pixels(img)
cp = img.copy()
try:
cp.scale(tw, th)
return _image_pixels(cp)
finally:
import bpy
bpy.data.images.remove(cp)
def _trace_image(socket, warnings, what):
"""沿输入连线找贴图。返回 (bpy.types.Image | None, channel | None)。
穿透 Normal Map / Separate Color 节点;channel 为 0..3 或 None(用 R/灰度)。"""
if not socket.is_linked:
return None, None
node = socket.links[0].from_node
out_name = socket.links[0].from_socket.name
if node.type == 'TEX_IMAGE':
return node.image, None
if node.type == 'NORMAL_MAP':
return _trace_image(node.inputs['Color'], warnings, what)
if node.type in ('SEPARATE_COLOR', 'SEPRGB'):
ch = {'Red': 0, 'Green': 1, 'Blue': 2, 'R': 0, 'G': 1, 'B': 2}.get(out_name, 0)
img, _ = _trace_image(node.inputs[0], warnings, what)
return img, ch
warnings.append("%s: 无法识别的节点 %s,改用常量" % (what, node.type))
return None, None
def _material_maps(mat, warnings, src_dir, model_name):
"""从材质 Principled BSDF 提取贴图与常量。"""
maps = {"basecolor": None, "alpha": None, "normal": None,
"metallic": None, "roughness": None, "ao": None}
consts = {"basecolor": (0.5, 0.5, 0.5, 1.0), "metallic": 0.0, "roughness": 0.5}
if not mat.use_nodes:
warnings.append("材质未用节点,全部用默认常量")
return maps, consts
bsdf = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None)
if bsdf is None:
warnings.append("未找到 Principled BSDF,全部用默认常量")
return maps, consts
def grab(input_name, key):
sock = bsdf.inputs.get(input_name)
if sock is None:
return
img, ch = _trace_image(sock, warnings, key)
if img is not None:
if _image_broken(img):
warnings.append("%s 贴图 %s 加载失败(外置文件缺失?),改用常量"
% (key, img.name))
else:
maps[key] = (img, ch)
grab('Base Color', 'basecolor')
grab('Alpha', 'alpha')
grab('Normal', 'normal')
grab('Metallic', 'metallic')
grab('Roughness', 'roughness')
consts["basecolor"] = tuple(bsdf.inputs['Base Color'].default_value)
consts["metallic"] = float(bsdf.inputs['Metallic'].default_value)
consts["roughness"] = float(bsdf.inputs['Roughness'].default_value)
# AO:材质节点树内按名匹配(未连到 BSDF 也算)
used = {m[0] for m in maps.values() if m}
for n in mat.node_tree.nodes:
if n.type == 'TEX_IMAGE' and n.image and n.image not in used \
and not _image_broken(n.image) and _looks_like_ao(n.image.name):
maps["ao"] = (n.image, None)
break
# 纯网格 FBX:材质一张贴图都没有时,按命名约定从同目录识别整套
if not any(maps.values()):
_find_maps_on_disk(src_dir, model_name, maps, warnings)
if maps["ao"] is None:
img = _find_ao_on_disk(src_dir, maps, warnings)
if img is not None:
maps["ao"] = (img, None)
return maps, consts
def _target_size(maps, max_size):
ws = [m[0].size[0] for m in maps.values() if m]
hs = [m[0].size[1] for m in maps.values() if m]
if not ws:
return 4, 4
return min(max(ws), max_size), min(max(hs), max_size)
def _channel(pixels, ch):
return pixels[..., ch if ch is not None else 0]
def _save_png(path, rgba):
"""float32 (h,w,4) -> PNG。写原始值(Non-Color,避免色彩管理改写数据)。"""
import bpy
h, w = rgba.shape[:2]
img = bpy.data.images.new("mt_tmp", width=w, height=h, alpha=True)
img.colorspace_settings.name = 'Non-Color'
img.pixels.foreach_set(np.ascontiguousarray(rgba, dtype=np.float32).ravel())
img.filepath_raw = path
img.file_format = 'PNG'
img.alpha_mode = 'CHANNEL_PACKED'
img.save()
bpy.data.images.remove(img)
def _convert_material(mat, model_name, outdir, max_size, used_snames, src_dir):
warnings = []
maps, consts = _material_maps(mat, warnings, src_dir, model_name)
tw, th = _target_size(maps, max_size)
# --- 基础贴图 ---
base = np.empty((th, tw, 4), dtype=np.float32)
if maps["basecolor"]:
base[..., :3] = _scaled_pixels(maps["basecolor"][0], tw, th)[..., :3]
# 8-bit 图 pixels 已是 sRGB 编码值;float 图(EXR 等)才是线性
rgb_is_linear = bool(maps["basecolor"][0].is_float)
else:
base[..., :3] = np.array(consts["basecolor"][:3], dtype=np.float32)
rgb_is_linear = True # Principled 常量色是线性值
if maps["alpha"]:
img, ch = maps["alpha"]
base[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
alpha_mode = "transparency"
elif maps["basecolor"] and maps["basecolor"][0].channels == 4 and \
_image_has_alpha(maps["basecolor"][0]):
base[..., 3] = _scaled_pixels(maps["basecolor"][0], tw, th)[..., 3]
alpha_mode = "transparency"
elif maps["ao"]:
img, ch = maps["ao"]
base[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
alpha_mode = "ao"
else:
base[..., 3] = 1.0
alpha_mode = "white"
# --- 混合贴图 ---
mix = np.empty((th, tw, 4), dtype=np.float32)
if maps["normal"]:
npx = _scaled_pixels(maps["normal"][0], tw, th)[..., :3] * 2.0 - 1.0
mix[..., 0:2] = encode_normal_rg(npx)
else:
mix[..., 0:2] = 0.5
warnings.append("无法线贴图,RG 填平面法线")
if maps["metallic"]:
img, ch = maps["metallic"]
mix[..., 2] = _channel(_scaled_pixels(img, tw, th), ch)
else:
mix[..., 2] = consts["metallic"]
if maps["roughness"]:
img, ch = maps["roughness"]
mix[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
else:
mix[..., 3] = consts["roughness"]
# 中文等非 ASCII 材质名会同样退化(如都变 "mat"),加序号防止互相覆盖
sname = base_sname = safe_name(mat.name)
n = 1
while sname in used_snames:
n += 1
sname = "%s_%d" % (base_sname, n)
used_snames.add(sname)
if sname != base_sname:
warnings.append("材质名 %s 清洗后与其它材质重名,输出改用 %s" % (mat.name, sname))
# 材质名与模型名相同(如纯网格 FBX 的兜底材质)时不重复拼接
stem = model_name if sname == model_name else "%s_%s" % (model_name, sname)
base_png = os.path.abspath(os.path.join(outdir, "%s_base.png" % stem))
mix_png = os.path.abspath(os.path.join(outdir, "%s_mix.png" % stem))
_save_png(base_png, encode_base_texture_for_png(base, rgb_is_linear))
_save_png(mix_png, mix)
return {
"name": mat.name, "safe_name": sname,
"base_png": os.path.basename(base_png), "mix_png": os.path.basename(mix_png),
"alpha_mode": alpha_mode, "size": [tw, th],
"sources": {k: (v[0].name if v else None) for k, v in maps.items()},
"warnings": warnings,
}
def _image_has_alpha(img):
"""采样判断 alpha 是否非全白(全白视为无透明信息)。"""
px = _image_pixels(img)
a = px[..., 3]
return bool((a < 0.995).any())
def _strip_textures():
import bpy
for mat in bpy.data.materials:
if not mat.use_nodes:
continue
for n in list(mat.node_tree.nodes):
if n.type in ('TEX_IMAGE', 'NORMAL_MAP', 'SEPARATE_COLOR', 'SEPRGB'):
mat.node_tree.nodes.remove(n)
def main():
import bpy, json
argv = sys.argv[sys.argv.index("--") + 1:]
src, outdir, max_size = argv[0], argv[1], int(argv[2])
os.makedirs(outdir, exist_ok=True)
model_name = os.path.splitext(os.path.basename(src))[0]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=src)
# 纯网格 FBX(无材质槽):以模型名新建材质挂上,否则 Unity 侧没有重映射挂点
fallback_mat = None
for obj in bpy.data.objects:
if obj.type == 'MESH' and not any(s.material for s in obj.material_slots):
if fallback_mat is None:
fallback_mat = bpy.data.materials.new(model_name)
fallback_mat.use_nodes = True
obj.data.materials.append(fallback_mat)
used_mats = []
for obj in bpy.data.objects:
if obj.type != 'MESH':
continue
for slot in obj.material_slots:
if slot.material and slot.material not in used_mats:
used_mats.append(slot.material)
src_dir = os.path.dirname(os.path.abspath(src))
used_snames = set()
results = [_convert_material(m, model_name, outdir, max_size, used_snames, src_dir)
for m in used_mats]
_strip_textures()
fbx_out = os.path.join(outdir, model_name + ".fbx")
bpy.ops.export_scene.fbx(filepath=fbx_out, path_mode='STRIP', embed_textures=False)
print("MT_SUMMARY " + json.dumps(
{"model": model_name, "fbx": os.path.basename(fbx_out), "materials": results},
ensure_ascii=False))
if __name__ == "__main__" and "--" in sys.argv:
main()
+43 -17
View File
@@ -52,12 +52,15 @@ def within_tolerance(target, actual, tol=TOLERANCE):
UV_FLIP_TOL = 0.02 # 质量门:UV 翻转面占比超此值回退 smart_project
UV_OVERLAP_TOL = 0.08 # 质量门:UV 重叠面占比阈值(手工 UV 同口径约 5%,灾难性失败 >20%)
UV_MAX_ISLAND_FRAC = 0.25 # 最大单岛 UV 面积占比超此值判不合格(挡光滑面巨型畸变岛)
def uv_gate_ok(flipped, overlap, overlap_tol=UV_OVERLAP_TOL):
"""UV 质量门:翻转按 UV_FLIP_TOL 固定,重叠按 overlap_tol(默认 UV_OVERLAP_TOL)。
overlap 为 None(op 不可用)按 0 处理。"""
return flipped <= UV_FLIP_TOL and (overlap or 0.0) <= overlap_tol
def uv_gate_ok(flipped, overlap, overlap_tol=UV_OVERLAP_TOL,
max_island_frac=0.0, island_frac_tol=UV_MAX_ISLAND_FRAC):
"""UV 质量门:翻转 / 重叠 / 最大单岛占比。overlap/max_island_frac 为 None 按 0。"""
return (flipped <= UV_FLIP_TOL
and (overlap or 0.0) <= overlap_tol
and (max_island_frac or 0.0) <= island_frac_tol)
def uvpm_mode_label(base, applied):
@@ -66,17 +69,17 @@ def uvpm_mode_label(base, applied):
def pick_best_candidate(candidates, overlap_tol=UV_OVERLAP_TOL):
"""从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。
overlap_tol 覆盖重叠容忍。每个 candidate 至少含 flipped/overlap/islands。"""
"""从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。"""
passing = [c for c in candidates
if uv_gate_ok(c["flipped"], c["overlap"], overlap_tol)]
if uv_gate_ok(c["flipped"], c["overlap"], overlap_tol,
c.get("max_island_frac", 0.0))]
if not passing:
return None
return min(passing, key=lambda c: c["islands"])
def count_islands(n_faces, adjacent_pairs):
"""union-find 数 UV 岛:n_faces 个面,adjacent_pairs 为 UV 连续的面索引对。"""
def island_labels(n_faces, adjacent_pairs):
"""union-find:返回每个面所属岛根标签列表。"""
parent = list(range(n_faces))
def find(i):
@@ -89,7 +92,25 @@ def count_islands(n_faces, adjacent_pairs):
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
return len({find(i) for i in range(n_faces)})
return [find(i) for i in range(n_faces)]
def count_islands(n_faces, adjacent_pairs):
"""union-find 数 UV 岛:n_faces 个面,adjacent_pairs 为 UV 连续的面索引对。"""
return len(set(island_labels(n_faces, adjacent_pairs)))
def max_island_area_fraction(labels, abs_areas):
"""最大单岛 UV 面积占比。"""
if not labels or not abs_areas:
return 0.0
totals = {}
for lab, area in zip(labels, abs_areas):
totals[lab] = totals.get(lab, 0.0) + abs(area)
total = sum(totals.values())
if total <= 0.0:
return 0.0
return max(totals.values()) / total
def flipped_fraction(signed_areas):
@@ -168,7 +189,7 @@ UVPM_EXT = "bl_ext.user_default.uvpackmaster4" # UVPackmaster 4 扩展模块
UVPM_PIXEL_MARGIN = 4 # UVPM 岛间距(像素),与 PACK_MARGIN * UVPM_TEX_SIZE 同口径
UVPM_TEX_SIZE = 2048
UVPM_ROTATION_STEP = 15 # UVPM 旋转步进(度);比默认 90 更细,利于薄斜条对齐(启发式因引擎崩溃已弃用)
SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0) # 首选 SEAM_ANGLE_DEG 不过门时依次下探的 seam 角度
SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0, 30.0, 25.0, 20.0, 15.0) # 首选不过门时下探
STRETCH_ITERS = 30 # minimize_stretch 松弛迭代次数
REL_WELD = 1e-4 # 焊接距离占局部包围盒对角线比例(避免 cm/m 单位差异导致绝对阈值失准)
@@ -258,11 +279,14 @@ def _collect_uv_metrics(obj):
area += a.x * b.y - b.x * a.y
signed.append(area * 0.5)
n = len(bm.faces)
labels = island_labels(n, pairs)
abs_areas = [abs(a) for a in signed]
bm.free()
return {"islands": count_islands(n, pairs),
"fill": round(fill_ratio([abs(a) for a in signed]), 4),
return {"islands": len(set(labels)) if n else 0,
"fill": round(fill_ratio(abs_areas), 4),
"flipped": round(flipped_fraction(signed), 4),
"overlap": _overlap_fraction(obj)}
"overlap": _overlap_fraction(obj),
"max_island_frac": round(max_island_area_fraction(labels, abs_areas), 4)}
def _overlap_fraction(obj):
@@ -371,7 +395,8 @@ def _do_unwrap(obj, mode, warnings, overlap_tol=UV_OVERLAP_TOL):
m = _collect_uv_metrics(obj)
m["angle"] = ang
candidates.append(m)
if uv_gate_ok(m["flipped"], m["overlap"], overlap_tol):
if uv_gate_ok(m["flipped"], m["overlap"], overlap_tol,
m.get("max_island_frac", 0.0)):
break # 该档已过门;更低角度只会更碎,无需再试
best = pick_best_candidate(candidates, overlap_tol)
if best is not None:
@@ -383,9 +408,10 @@ def _do_unwrap(obj, mode, warnings, overlap_tol=UV_OVERLAP_TOL):
m["mode"] = uvpm_mode_label("seam@%d" % int(best["angle"]), uvpm)
return m
detail = "、".join(
"%d°(翻转%.1f%% 重叠%s)" % (
"%d°(翻转%.1f%% 重叠%s 大岛%.1f%%)" % (
int(c["angle"]), c["flipped"] * 100,
"%.1f%%" % (c["overlap"] * 100) if c["overlap"] is not None else "未知")
"%.1f%%" % (c["overlap"] * 100) if c["overlap"] is not None else "未知",
c.get("max_island_frac", 0.0) * 100)
for c in candidates)
warnings.append("所有 seam 角度均未过质量门(%s),回退 smart_project" % detail)
mode = "smart_fallback"
+55 -1
View File
@@ -117,6 +117,7 @@ def run_pipeline(
ray_distance: Optional[float] = None,
blender: Optional[str] = None,
keep_low: bool = True,
xpbr: bool = True,
callback_url: Optional[str] = None,
callback_file: Optional[str] = None,
quiet: bool = False,
@@ -211,6 +212,26 @@ def run_pipeline(
blender_args=("-t", "1"),
)
xpbr_dir = None
xpbr_summary = None
if xpbr:
callbacks.emit("stage", stage="打包 XPbr base/mix 贴图与材质", progress=88)
from model_translator import translate_fbx
bake_fbx = bake_dir / bake["fbx"]
xpbr_dir = out_dir / f"{stem}_xpbr"
xpbr_summary = translate_fbx(
str(bake_fbx), str(xpbr_dir), max_size=size, blender=blender_exe
)
callbacks.emit(
"xpbr",
stage="XPbr 资源已生成",
progress=95,
outdir=str(xpbr_dir),
fbx=xpbr_summary.get("fbx"),
materials=[m.get("name") for m in xpbr_summary.get("materials") or []],
)
result = {
"ok": True,
"status": "succeeded",
@@ -220,13 +241,28 @@ def run_pipeline(
"bake_dir": str(bake_dir),
"fbx": str(bake_dir / bake["fbx"]),
"maps": {k: str(bake_dir / v) for k, v in bake["outputs"].items()},
"xpbr_dir": str(xpbr_dir) if xpbr_dir else None,
"xpbr": (
{
"fbx": str(xpbr_dir / xpbr_summary["fbx"]),
"materials": xpbr_summary.get("materials"),
}
if xpbr_summary and xpbr_dir
else None
),
"tris_before": dec["tris_before"],
"tris_after": bake["low_tris"],
"high_tris": bake["high_tris"],
"size": size,
"ray_distance": bake["ray_distance"],
"warnings": list(dec.get("warnings") or []) + list(bake.get("warnings") or []),
"hint": "生成完成后请在 Doc/ResDesc_Model.md 登记该资源",
"hint": (
"XPbr 目录可拷进 AIClient/Assets;"
"预设用 Unity 菜单 AIGame/M3/Setup Character Prefab + Art Collector;"
"并登记 Doc/ResDesc_Model.md"
if xpbr
else "生成完成后请在 Doc/ResDesc_Model.md 登记该资源"
),
}
callbacks.emit("succeeded", **result)
return result
@@ -251,6 +287,19 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--max-overlap", type=float, default=None, help="UV 重叠容忍 (0,1]")
p.add_argument("--ray-distance", type=float, default=None, help="烘焙射线距离")
p.add_argument("--blender", default=None, help="blender.exe 路径")
p.add_argument(
"--xpbr",
dest="xpbr",
action="store_true",
default=True,
help="烘焙后打包 XPbr base/mix + .mat(默认开启)",
)
p.add_argument(
"--no-xpbr",
dest="xpbr",
action="store_false",
help="跳过 XPbr 打包,只保留五通道烘焙图",
)
p.add_argument(
"--callback-url", default=None, help="流程回调 URL:每个事件 POST JSON"
)
@@ -300,6 +349,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
else args.ray_distance
),
blender=payload.get("blender") or args.blender,
xpbr=bool(
payload["xpbr"]
if "xpbr" in payload
else args.xpbr
),
callback_url=payload.get("callback_url") or args.callback_url,
callback_file=payload.get("callback_file") or args.callback_file,
quiet=quiet,
+113
View File
@@ -0,0 +1,113 @@
"""ModelTranslator CLI:FBX -> 纯模型 FBX + XPbr 贴图 + Unity .mat/.meta。
用法:python model_translator.py src/tong.fbx [-o out] [--max-size 2048] [--blender exe]
"""
import argparse
import json
import os
import sys
import unity_assets as ua
from mt_run import HERE, find_blender, run_blender_script
def write_unity_assets(outdir, summary, max_size):
remaps = []
for m in summary["materials"]:
base_png = os.path.join(outdir, m["base_png"])
mix_png = os.path.join(outdir, m["mix_png"])
base_guid = ua.guid_for(base_png)
mix_guid = ua.guid_for(mix_png)
alpha_test = m["alpha_mode"] == "transparency"
with open(base_png + ".meta", "w", encoding="utf-8", newline="\n") as f:
f.write(
ua.texture_meta(
base_guid,
srgb=True,
max_size=max_size,
alpha_is_transparency=alpha_test,
)
)
with open(mix_png + ".meta", "w", encoding="utf-8", newline="\n") as f:
f.write(ua.texture_meta(mix_guid, srgb=False, max_size=max_size))
mat_name = (
summary["model"]
if m["safe_name"] == summary["model"]
else "%s_%s" % (summary["model"], m["safe_name"])
)
mat_path = os.path.join(outdir, mat_name + ".mat")
mat_guid = ua.guid_for(mat_path)
with open(mat_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
ua.material_yaml(
mat_name, ua.XPBR_SHADER_GUID, base_guid, mix_guid, alpha_test
)
)
with open(mat_path + ".meta", "w", encoding="utf-8", newline="\n") as f:
f.write(ua.material_meta(mat_guid))
remaps.append((m["name"], mat_guid))
fbx_path = os.path.join(outdir, summary["fbx"])
fbx_guid = ua.guid_for(fbx_path)
with open(fbx_path + ".meta", "w", encoding="utf-8", newline="\n") as f:
f.write(ua.model_meta(fbx_guid, remaps))
return {
"fbx": fbx_path,
"fbx_guid": fbx_guid,
"materials": remaps,
"outdir": outdir,
}
def report(summary, outdir):
print("== %s -> %s" % (summary["model"], outdir))
for m in summary["materials"]:
print(
" 材质 %-24s A通道=%s 尺寸=%dx%d"
% (m["name"], m["alpha_mode"], m["size"][0], m["size"][1])
)
for k, v in m["sources"].items():
print(" %-10s %s" % (k, v or "(常量)"))
for w in m["warnings"]:
print(" [警告] %s" % w)
def translate_fbx(src, outdir, max_size=2048, blender=None):
"""Convert bake-output FBX (+ sibling PBR maps) into XPbr assets. Returns summary dict."""
blender_exe = find_blender(blender)
os.makedirs(outdir, exist_ok=True)
summary = run_blender_script(
blender_exe, "bl_convert.py", [src, outdir, str(max_size)]
)
unity = write_unity_assets(outdir, summary, max_size)
return {**summary, "unity": unity}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("input", help="FBX 文件或目录")
ap.add_argument("-o", "--out", default=os.path.join(HERE, "out_xpbr"))
ap.add_argument("--max-size", type=int, default=2048)
ap.add_argument("--blender", default=None)
args = ap.parse_args()
if os.path.isdir(args.input):
srcs = [
os.path.join(args.input, f)
for f in sorted(os.listdir(args.input))
if f.lower().endswith(".fbx")
]
if not srcs:
sys.exit("目录中没有 FBX:%s" % args.input)
else:
srcs = [args.input]
for src in srcs:
name = os.path.splitext(os.path.basename(src))[0]
outdir = os.path.join(args.out, name)
summary = translate_fbx(src, outdir, args.max_size, args.blender)
report(summary, outdir)
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
"""法线球面编码 round-trip 测试。运行:
blender -b --factory-startup --python tests/bl_test_encode.py
成功打印 ENCODE_TEST_OK,失败非零退出。"""
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from bl_convert import encode_normal_rg, decode_sphere_map
def main():
rng = np.random.default_rng(7)
# 随机切线空间法线(z>=0 半球),含边缘方向
n = rng.normal(size=(4096, 3))
n[:, 2] = np.abs(n[:, 2])
n /= np.linalg.norm(n, axis=1, keepdims=True)
n = np.vstack([n, [[0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, -1, 0], [0.707, -0.707, 0]]])
enc = encode_normal_rg(n) # (N,2) in [0,1]
assert enc.min() >= 0.0 and enc.max() <= 1.0, "encode out of [0,1]"
enc8 = np.round(enc * 255.0) / 255.0 # 模拟 8bit PNG 存储
dec = decode_sphere_map(enc8) # shader 同款公式
dec /= np.linalg.norm(dec, axis=1, keepdims=True)
cos = np.clip(np.sum(n * dec, axis=1), -1.0, 1.0)
max_err = np.degrees(np.arccos(cos)).max()
assert max_err < 2.0, "max angle error %.3f deg >= 2" % max_err
# 平面法线编码值应为 (0.5, 0.5)
flat = encode_normal_rg(np.array([[0.0, 0.0, 1.0]]))
assert np.allclose(flat, 0.5, atol=1e-6), "flat normal must encode to 0.5"
print("ENCODE_TEST_OK max_err=%.4f deg" % max_err)
try:
main()
except Exception as e:
print("ENCODE_TEST_FAIL:", e)
sys.exit(1)
@@ -0,0 +1,73 @@
import os
import re
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import unity_assets as ua
class TestGuid(unittest.TestCase):
def test_new_guid_is_32_hex(self):
g = ua.new_guid()
self.assertTrue(re.fullmatch(r"[0-9a-f]{32}", g))
def test_guid_for_reuses_existing_meta(self):
with tempfile.TemporaryDirectory() as d:
asset = os.path.join(d, "a.png")
with open(asset + ".meta", "w") as f:
f.write("fileFormatVersion: 2\nguid: 0123456789abcdef0123456789abcdef\n")
self.assertEqual(ua.guid_for(asset), "0123456789abcdef0123456789abcdef")
def test_guid_for_generates_when_no_meta(self):
with tempfile.TemporaryDirectory() as d:
g = ua.guid_for(os.path.join(d, "b.png"))
self.assertTrue(re.fullmatch(r"[0-9a-f]{32}", g))
class TestTemplates(unittest.TestCase):
def test_texture_meta_srgb_on_off(self):
on = ua.texture_meta("a" * 32, srgb=True, max_size=2048)
off = ua.texture_meta("b" * 32, srgb=False, max_size=2048)
self.assertIn("sRGBTexture: 1", on)
self.assertIn("sRGBTexture: 0", off)
self.assertIn("maxTextureSize: 2048", on)
self.assertIn("guid: " + "a" * 32, on)
def test_texture_meta_alpha_is_transparency(self):
default = ua.texture_meta("a" * 32, srgb=True, max_size=2048)
ait = ua.texture_meta("a" * 32, srgb=True, max_size=2048,
alpha_is_transparency=True)
self.assertIn("alphaIsTransparency: 0", default)
self.assertIn("alphaIsTransparency: 1", ait)
def test_material_yaml_refs_and_keyword(self):
y = ua.material_yaml("tong_wood", "s" * 32, "c" * 32, "d" * 32, alpha_test=True)
self.assertIn("m_Name: tong_wood", y)
self.assertIn("guid: " + "s" * 32, y) # shader
self.assertIn("guid: " + "c" * 32, y) # base tex
self.assertIn("guid: " + "d" * 32, y) # mix tex
self.assertIn("_ALPHATEST_ON", y)
self.assertIn("_AlphaTest: 1", y)
def test_material_yaml_no_keyword_when_opaque(self):
y = ua.material_yaml("m", "s" * 32, "c" * 32, "d" * 32, alpha_test=False)
self.assertNotIn("_ALPHATEST_ON", y)
self.assertIn("_AlphaTest: 0", y)
def test_model_meta_external_objects(self):
m = ua.model_meta("e" * 32, [("wood", "f" * 32), ("iron", "1" * 32)])
self.assertIn("name: wood", m)
self.assertIn("guid: " + "f" * 32, m)
self.assertIn("name: iron", m)
self.assertIn("guid: " + "e" * 32, m)
class TestSanitize(unittest.TestCase):
def test_safe_name(self):
self.assertEqual(ua.safe_name("Mat 01.a/b"), "Mat_01_a_b")
if __name__ == "__main__":
unittest.main()
+323
View File
@@ -0,0 +1,323 @@
"""Unity 资产(.meta / .mat)文本模板与 GUID 管理。纯 stdlib,可独立测试。
模板采自 Unity 2022.3(TextureImporter serializedVersion 12 / Material 8 /
ModelImporter 22200);升级 Unity 后如导入异常,在编辑器导出同类资产 diff 更新模板。"""
import os
import re
import uuid
XPBR_SHADER_GUID = "cab897163f45445fa34263d6670bfc9e" # AIClient/Assets/Bundles/Shaders/XShader/XPbr.shader.meta
_GUID_RE = re.compile(r"^guid: ([0-9a-f]{32})", re.M)
def new_guid():
return uuid.uuid4().hex
def guid_for(asset_path):
"""资产已有 .meta 则复用其中 guid(重复转换不破坏 Unity 引用),否则新分配。"""
meta = asset_path + ".meta"
if os.path.isfile(meta):
try:
with open(meta, "r", encoding="utf-8", errors="replace") as f:
m = _GUID_RE.search(f.read())
except OSError:
m = None # meta 损坏/不可读时按无 meta 处理,重新分配
if m:
return m.group(1)
return new_guid()
def safe_name(name):
return re.sub(r"[^0-9A-Za-z_\-]", "_", name).strip("_") or "mat"
def texture_meta(guid, srgb, max_size, alpha_is_transparency=False):
return """fileFormatVersion: 2
guid: {guid}
TextureImporter:
internalIDToNameTable: []
externalObjects: {{}}
serializedVersion: 12
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: {srgb}
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: {max_size}
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {{x: 0.5, y: 0.5}}
spritePixelsToUnits: 100
spriteBorder: {{x: 0, y: 0, z: 0, w: 0}}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: {ait}
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: {max_size}
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {{}}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
""".format(guid=guid, srgb=1 if srgb else 0, max_size=max_size,
ait=1 if alpha_is_transparency else 0)
def material_yaml(name, shader_guid, base_tex_guid, mix_tex_guid, alpha_test):
keywords = "\n - _ALPHATEST_ON" if alpha_test else " []"
return """%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {{fileID: 0}}
m_PrefabInstance: {{fileID: 0}}
m_PrefabAsset: {{fileID: 0}}
m_Name: {name}
m_Shader: {{fileID: 4800000, guid: {shader_guid}, type: 3}}
m_Parent: {{fileID: 0}}
m_ModifiedSerializedProperties: 0
m_ValidKeywords:{keywords}
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {{}}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {{fileID: 2800000, guid: {base_tex_guid}, type: 3}}
m_Scale: {{x: 1, y: 1}}
m_Offset: {{x: 0, y: 0}}
- _MixTex:
m_Texture: {{fileID: 2800000, guid: {mix_tex_guid}, type: 3}}
m_Scale: {{x: 1, y: 1}}
m_Offset: {{x: 0, y: 0}}
m_Ints: []
m_Floats:
- _AO: 1
- _AlphaTest: {alpha_test}
- _Cutoff: 0.5
- _Metallic: 1
- _Roughness: 0.8
m_Colors:
- _BaseColor: {{r: 1, g: 1, b: 1, a: 1}}
m_BuildTextureStacks: []
""".format(name=name, shader_guid=shader_guid, base_tex_guid=base_tex_guid,
mix_tex_guid=mix_tex_guid, keywords=keywords,
alpha_test=1 if alpha_test else 0)
def material_meta(guid):
return """fileFormatVersion: 2
guid: {guid}
NativeFormatImporter:
externalObjects: {{}}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
""".format(guid=guid)
def model_meta(guid, material_remaps):
"""material_remaps: [(fbx 内材质名, mat 文件 guid), ...]"""
if material_remaps:
ext = "externalObjects:\n"
for mat_name, mat_guid in material_remaps:
ext += (" - first:\n"
" type: UnityEngine:Material\n"
" assembly: UnityEngine.CoreModule\n"
" name: {n}\n"
" second: {{fileID: 2100000, guid: {g}, type: 2}}\n"
).format(n=mat_name, g=mat_guid)
else:
ext = "externalObjects: {}\n"
return """fileFormatVersion: 2
guid: {guid}
ModelImporter:
serializedVersion: 22200
internalIDToNameTable: []
{ext} materials:
materialImportMode: 2
materialName: 0
materialSearch: 1
materialLocation: 1
animations:
legacyGenerateAnimations: 4
bakeSimulation: 0
resampleCurves: 1
optimizeGameObjects: 0
removeConstantScaleCurves: 0
motionNodeName:
animationImportErrors:
animationImportWarnings:
animationRetargetingWarnings:
animationDoRetargetingWarnings: 0
importAnimatedCustomProperties: 0
importConstraints: 0
animationCompression: 1
animationRotationError: 0.5
animationPositionError: 0.5
animationScaleError: 0.5
animationWrapMode: 0
extraExposedTransformPaths: []
extraUserProperties: []
clipAnimations: []
isReadable: 0
meshes:
lODScreenPercentages: []
globalScale: 1
meshCompression: 0
addColliders: 0
useSRGBMaterialColor: 1
sortHierarchyByName: 1
importPhysicalCameras: 1
importVisibility: 1
importBlendShapes: 1
importCameras: 1
importLights: 1
nodeNameCollisionStrategy: 1
fileIdsGeneration: 2
swapUVChannels: 0
generateSecondaryUV: 0
useFileUnits: 1
keepQuads: 0
weldVertices: 1
bakeAxisConversion: 0
preserveHierarchy: 0
skinWeightsMode: 0
maxBonesPerVertex: 4
minBoneWeight: 0.001
optimizeBones: 1
meshOptimizationFlags: -1
indexFormat: 0
secondaryUVAngleDistortion: 8
secondaryUVAreaDistortion: 15.000001
secondaryUVHardAngle: 88
secondaryUVMarginMethod: 1
secondaryUVMinLightmapResolution: 40
secondaryUVMinObjectScale: 1
secondaryUVPackMargin: 4
useFileScale: 1
strictVertexDataChecks: 0
tangentSpace:
normalSmoothAngle: 60
normalImportMode: 0
tangentImportMode: 3
normalCalculationMode: 4
legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0
blendShapeNormalImportMode: 1
normalSmoothingSource: 0
referencedClips: []
importAnimation: 1
humanDescription:
serializedVersion: 3
human: []
skeleton: []
armTwist: 0.5
foreArmTwist: 0.5
upperLegTwist: 0.5
legTwist: 0.5
armStretch: 0.05
legStretch: 0.05
feetSpacing: 0
globalScale: 1
rootMotionBoneName:
hasTranslationDoF: 0
hasExtraRoot: 0
skeletonHasParents: 1
lastHumanDescriptionAvatarSource: {{instanceID: 0}}
autoGenerateAvatarMappingIfUnspecified: 1
animationType: 2
humanoidOversampling: 1
avatarSetup: 0
addHumanoidExtraRootOnlyWhenUsingAvatar: 1
importBlendShapeDeformPercent: 1
remapMaterialsIfMaterialImportModeIsNone: 0
additionalBone: 0
userData:
assetBundleName:
assetBundleVariant:
""".format(guid=guid, ext=ext)
@@ -0,0 +1,345 @@
{
"latest": {
"event": "succeeded",
"ts": "2026-09-21T08:40:09Z",
"ok": true,
"job_id": "3774809aca8d49cfb32d719aa5cde496",
"status": "succeeded",
"progress": 100,
"faces": "50k",
"image": "D:\\UD\\AI\\AIGame\\Tools\\output\\male_explorer_test.png",
"output": "D:\\UD\\AI\\AIGame\\Tools\\output\\male_explorer_3d\\male_explorer.glb",
"work_dir": "D:\\UD\\AI\\AIGame\\Tools\\data\\hunyuan\\jobs\\3774809aca8d49cfb32d719aa5cde496",
"bytes": 40204236,
"hint": "生成完成后请在 Doc/ResDesc_Model.md 登记该资源"
},
"history": [
{
"event": "started",
"ts": "2026-09-21T08:35:15Z",
"stage": "开始混元图生3D",
"progress": 1,
"job_id": "3774809aca8d49cfb32d719aa5cde496",
"image": "D:\\UD\\AI\\AIGame\\Tools\\output\\male_explorer_test.png",
"output": "D:\\UD\\AI\\AIGame\\Tools\\output\\male_explorer_3d\\male_explorer.glb",
"faces": "50k"
},
{
"event": "login_required",
"ts": "2026-09-21T08:35:15Z",
"stage": "需要微信登录混元3D",
"progress": 2,
"reason": "auth"
},
{
"event": "login_waiting",
"ts": "2026-09-21T08:35:15Z",
"stage": "请在弹出的浏览器窗口中用微信扫码登录混元3D",
"progress": 5,
"timeout_sec": 300,
"reason": "auth"
},
{
"event": "login_ok",
"ts": "2026-09-21T08:35:37Z",
"stage": "微信登录成功",
"progress": 15,
"logged_in": true,
"state": "ready",
"message": "微信登录成功",
"session_dir": "D:\\UD\\AI\\AIGame\\Tools\\data\\hunyuan",
"reason": "auth",
"updated_at": 1789979737.4780936
},
{
"event": "stage",
"ts": "2026-09-21T08:35:37Z",
"stage": "打开混元3D 网页",
"progress": 20
},
{
"event": "stage",
"ts": "2026-09-21T08:35:41Z",
"stage": "进入图/文生3D",
"progress": 26
},
{
"event": "stage",
"ts": "2026-09-21T08:35:45Z",
"stage": "切换到图生3D",
"progress": 30
},
{
"event": "stage",
"ts": "2026-09-21T08:35:46Z",
"stage": "上传参考图片",
"progress": 36
},
{
"event": "stage",
"ts": "2026-09-21T08:35:51Z",
"stage": "选择 50k 原始面数",
"progress": 44
},
{
"event": "stage",
"ts": "2026-09-21T08:35:51Z",
"stage": "提交混元生成",
"progress": 52
},
{
"event": "stage",
"ts": "2026-09-21T08:35:53Z",
"stage": "打开资产页并等待完成",
"progress": 55
},
{
"event": "stage",
"ts": "2026-09-21T08:36:00Z",
"stage": "资产页 · 几何生成中 4%(已等待 0s / 最长 1200s)",
"progress": 55
},
{
"event": "stage",
"ts": "2026-09-21T08:36:05Z",
"stage": "资产页 · 几何生成中 8%(已等待 5s / 最长 1200s)",
"progress": 56
},
{
"event": "stage",
"ts": "2026-09-21T08:36:10Z",
"stage": "资产页 · 几何生成中 10%(已等待 10s / 最长 1200s)",
"progress": 57
},
{
"event": "stage",
"ts": "2026-09-21T08:36:15Z",
"stage": "资产页 · 几何生成中 18%(已等待 15s / 最长 1200s)",
"progress": 59
},
{
"event": "stage",
"ts": "2026-09-21T08:36:20Z",
"stage": "资产页 · 几何生成中 22%(已等待 20s / 最长 1200s)",
"progress": 60
},
{
"event": "stage",
"ts": "2026-09-21T08:36:25Z",
"stage": "资产页 · 几何生成中 24%(已等待 25s / 最长 1200s)",
"progress": 60
},
{
"event": "stage",
"ts": "2026-09-21T08:36:30Z",
"stage": "资产页 · 几何生成中 27%(已等待 30s / 最长 1200s)",
"progress": 61
},
{
"event": "stage",
"ts": "2026-09-21T08:36:35Z",
"stage": "资产页 · 几何生成中 35%(已等待 35s / 最长 1200s)",
"progress": 63
},
{
"event": "stage",
"ts": "2026-09-21T08:36:40Z",
"stage": "资产页 · 几何生成中 39%(已等待 40s / 最长 1200s)",
"progress": 64
},
{
"event": "stage",
"ts": "2026-09-21T08:36:45Z",
"stage": "资产页 · 几何生成中 41%(已等待 45s / 最长 1200s)",
"progress": 64
},
{
"event": "stage",
"ts": "2026-09-21T08:36:50Z",
"stage": "资产页 · 几何生成中 45%(已等待 50s / 最长 1200s)",
"progress": 65
},
{
"event": "stage",
"ts": "2026-09-21T08:36:55Z",
"stage": "资产页 · 几何生成中 51%(已等待 55s / 最长 1200s)",
"progress": 67
},
{
"event": "stage",
"ts": "2026-09-21T08:37:00Z",
"stage": "资产页 · 几何生成中 55%(已等待 60s / 最长 1200s)",
"progress": 68
},
{
"event": "stage",
"ts": "2026-09-21T08:37:05Z",
"stage": "资产页 · 几何生成中 58%(已等待 65s / 最长 1200s)",
"progress": 68
},
{
"event": "stage",
"ts": "2026-09-21T08:37:11Z",
"stage": "资产页 · 几何生成中 62%(已等待 70s / 最长 1200s)",
"progress": 69
},
{
"event": "stage",
"ts": "2026-09-21T08:37:16Z",
"stage": "资产页 · 几何生成中 68%(已等待 75s / 最长 1200s)",
"progress": 71
},
{
"event": "stage",
"ts": "2026-09-21T08:37:21Z",
"stage": "资产页 · 几何生成中 71%(已等待 80s / 最长 1200s)",
"progress": 72
},
{
"event": "stage",
"ts": "2026-09-21T08:37:26Z",
"stage": "资产页 · 几何生成中 75%(已等待 85s / 最长 1200s)",
"progress": 73
},
{
"event": "stage",
"ts": "2026-09-21T08:37:31Z",
"stage": "资产页 · 几何生成中 78%(已等待 91s / 最长 1200s)",
"progress": 73
},
{
"event": "stage",
"ts": "2026-09-21T08:37:36Z",
"stage": "资产页 · 几何生成中 82%(已等待 96s / 最长 1200s)",
"progress": 74
},
{
"event": "stage",
"ts": "2026-09-21T08:37:41Z",
"stage": "资产页 · 几何生成中 90%(已等待 101s / 最长 1200s)",
"progress": 76
},
{
"event": "stage",
"ts": "2026-09-21T08:37:46Z",
"stage": "资产页 · 几何生成中 93%(已等待 106s / 最长 1200s)",
"progress": 77
},
{
"event": "stage",
"ts": "2026-09-21T08:37:51Z",
"stage": "资产页 · 几何生成中 95%(已等待 111s / 最长 1200s)",
"progress": 77
},
{
"event": "stage",
"ts": "2026-09-21T08:37:56Z",
"stage": "资产页 · 几何生成中 99%(已等待 116s / 最长 1200s)",
"progress": 78
},
{
"event": "stage",
"ts": "2026-09-21T08:38:01Z",
"stage": "资产页 · 几何生成中 99%(已等待 121s / 最长 1200s)",
"progress": 78
},
{
"event": "stage",
"ts": "2026-09-21T08:38:06Z",
"stage": "资产页 · 几何生成中 99%(已等待 126s / 最长 1200s)",
"progress": 78
},
{
"event": "stage",
"ts": "2026-09-21T08:38:11Z",
"stage": "资产页 · 几何生成中 99%(已等待 131s / 最长 1200s)",
"progress": 78
},
{
"event": "stage",
"ts": "2026-09-21T08:38:16Z",
"stage": "资产页 · 几何生成中 99%(已等待 136s / 最长 1200s)",
"progress": 78
},
{
"event": "stage",
"ts": "2026-09-21T08:38:21Z",
"stage": "资产页 · 几何生成中 99%(已等待 141s / 最长 1200s)",
"progress": 78
},
{
"event": "stage",
"ts": "2026-09-21T08:38:26Z",
"stage": "资产页 · 几何生成中 99%(已等待 146s / 最长 1200s)",
"progress": 78
},
{
"event": "stage",
"ts": "2026-09-21T08:38:31Z",
"stage": "资产页 · 几何生成中 99%(已等待 151s / 最长 1200s)",
"progress": 78
},
{
"event": "stage",
"ts": "2026-09-21T08:38:36Z",
"stage": "资产页 · 几何生成中 99%(已等待 156s / 最长 1200s)",
"progress": 78
},
{
"event": "stage",
"ts": "2026-09-21T08:38:42Z",
"stage": "资产页 · 纹理生成中 14%(已等待 161s / 最长 1200s)",
"progress": 81
},
{
"event": "stage",
"ts": "2026-09-21T08:38:57Z",
"stage": "资产页 · 纹理生成中 32%(已等待 177s / 最长 1200s)",
"progress": 84
},
{
"event": "stage",
"ts": "2026-09-21T08:39:13Z",
"stage": "资产页 · 纹理生成中 54%(已等待 193s / 最长 1200s)",
"progress": 87
},
{
"event": "stage",
"ts": "2026-09-21T08:39:28Z",
"stage": "资产页 · 纹理生成中 77%(已等待 208s / 最长 1200s)",
"progress": 90
},
{
"event": "stage",
"ts": "2026-09-21T08:39:44Z",
"stage": "资产页 · 纹理生成中 99%(已等待 224s / 最长 1200s)",
"progress": 93
},
{
"event": "stage",
"ts": "2026-09-21T08:40:00Z",
"stage": "混元生成完成(已等待 240s / 最长 1200s)",
"progress": 95
},
{
"event": "stage",
"ts": "2026-09-21T08:40:00Z",
"stage": "下载混元 GLB",
"progress": 96
},
{
"event": "succeeded",
"ts": "2026-09-21T08:40:09Z",
"ok": true,
"job_id": "3774809aca8d49cfb32d719aa5cde496",
"status": "succeeded",
"progress": 100,
"faces": "50k",
"image": "D:\\UD\\AI\\AIGame\\Tools\\output\\male_explorer_test.png",
"output": "D:\\UD\\AI\\AIGame\\Tools\\output\\male_explorer_3d\\male_explorer.glb",
"work_dir": "D:\\UD\\AI\\AIGame\\Tools\\data\\hunyuan\\jobs\\3774809aca8d49cfb32d719aa5cde496",
"bytes": 40204236,
"hint": "生成完成后请在 Doc/ResDesc_Model.md 登记该资源"
}
]
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB