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:
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""混元 3D 流程异常:触发交互式重新登录 / 换号。"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class HunyuanReloginNeeded(PermissionError):
|
||||
"""凭证失效或未登录,需要打开登录页重新微信扫码。"""
|
||||
|
||||
reason = "auth"
|
||||
|
||||
|
||||
class HunyuanQuotaExceeded(HunyuanReloginNeeded):
|
||||
"""当前账号额度/次数用尽,需要换号登录后再生成。"""
|
||||
|
||||
reason = "quota"
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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` 完整迁入作为可选增强。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.
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.
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Reference in New Issue
Block a user