Files
AIGame/Tools/studio_lowpoly_trial.py

89 lines
5.5 KiB
Python

"""Studio low-poly trial for any character (generalized from greathorn_studio_trial.py), steps 02-05 of the official flow:
02 Hunyuan 3D Studio low-poly topology -> 03 Studio UV unwrapping -> align to the exact source high -> 04 Blender bake
(diffuse/normal/AO/metallic/roughness) onto the Studio UVs -> 05 UV-preserving decimation only when over target x1.3.
The original prefab, controller, clips and rig are hash-snapshotted and must stay unchanged; the Studio candidate is
packaged and rebound to the original skeleton by studio_lowpoly_finish.py.
Run with the 3D workbench environment (it provides the Studio integration):
D:/UD/AI/XModel/LLM3DGraphics/.venv/Scripts/python.exe Tools/studio_lowpoly_trial.py --actor RockBall --kind Animal \
--source Tools/output/rockball_3d/rockball.glb --rig Tools/output/rockball_dgx/high_rig/rigged.glb --prefab RockBall_Rigged.prefab
"""
import sys
sys.dont_write_bytecode = True
import argparse
import hashlib
import json
import shutil
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STUDIO = Path('D:/UD/AI/XModel/LLM3DGraphics')
BLENDER = 'D:/Tools/Blender/blender-5.0.0-windows-x64/blender.exe'
sys.path.insert(0, str(ROOT / 'Tools'))
from hunyuan_3d import auth
sys.path.insert(0, str(STUDIO))
from backend import hunyuan_studio as studio
from backend.retopology import _run_blender_step, compress_bake_maps, limit_studio_faces
def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest()
def update(**values): print(json.dumps(values, ensure_ascii=True), flush=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--actor', required=True)
ap.add_argument('--kind', default='Animal', choices=('Animal', 'Enemy'))
ap.add_argument('--source', type=Path, required=True, help='the exact high model the original rig was made from')
ap.add_argument('--rig', type=Path, required=True)
ap.add_argument('--prefab', required=True, help='original animated prefab file name inside the actor folder')
ap.add_argument('--tris', type=int, default=2000)
ap.add_argument('--size', type=int, default=1024)
a = ap.parse_args()
out = ROOT / 'Tools/output/studio_trial' / a.actor
source = (ROOT / a.source).resolve()
out.mkdir(parents=True, exist_ok=True)
receipt = out / 'trial.json'
current = json.loads(receipt.read_text(encoding='utf-8')) if receipt.exists() else {}
digest = sha(source)
if current and current.get('sourceSha256') != digest: raise ValueError('Trial source changed')
original = ROOT / f'AIClient/Assets/Bundles/Characters/{a.kind}/{a.actor}'
originals = [original / a.prefab, original / f'{a.actor}_Animator.controller', *[original / 'Clips' / f'{n}.anim' for n in ('Idle', 'Walk', 'Attack')], ROOT / a.rig]
snapshot = {str(p.relative_to(ROOT)): sha(p) for p in originals}
if current.get('originals') and current['originals'] != snapshot: raise ValueError('Original skeleton/animations changed during trial')
current.update(actor=a.actor, kind=a.kind, prefab=a.prefab, rig=str(a.rig).replace('\\', '/'), source=str(source.relative_to(ROOT)).replace('\\', '/'),
sourceSha256=digest, targetTriangles=a.tris, textureSize=a.size, originals=snapshot, status='studio')
receipt.write_text(json.dumps(current, indent=2), encoding='utf-8')
shutil.copy2(source, out / 'model.glb')
# Reuse this project's authorised Hunyuan session through the installed Studio integration; credentials stay local.
studio.is_logged_in = lambda client_id=None: auth.is_logged_in()
studio.load_storage_state = lambda client_id=None: (json.loads(auth.storage_state_path().read_text(encoding='utf-8')), None)
uv = out / 'model_lowpoly_uv.glb'
if uv.exists(): studio.validate_uv_glb(uv)
else:
meta = json.loads((out / 'hunyuan_studio.json').read_text(encoding='utf-8')) if (out / 'hunyuan_studio.json').exists() else {}
studio.process_existing(source, out, update, target_faces=a.tris, resume_low_id=meta.get('lowpoly_works_id'), resume_uv_id=meta.get('uv_works_id'))
current.update(status='baking', studio=json.loads((out / 'hunyuan_studio.json').read_text(encoding='utf-8')))
receipt.write_text(json.dumps(current, indent=2), encoding='utf-8')
aligned = out / 'model_lowpoly_uv_aligned.glb'
if not aligned.exists():
# Studio centres/normalises its mesh; align it to the source high before any ray projection.
with (out / 'alignment.log').open('w', encoding='utf-8') as log:
subprocess.run([BLENDER, '-b', '--factory-startup', '--python', str(ROOT / 'Tools/model_tools/bl_align_studio_uv.py'), '--', str(out)], stdout=log, stderr=subprocess.STDOUT, check=True, timeout=120)
if not current.get('bakeAligned'):
_run_blender_step(out, BLENDER, 'blender_lowpoly_bake.py', (out / 'model.glb', out / 'model_baked.glb', 1, a.size, aligned), 'bake.log', marker='Lowpoly bake saved to')
compress_bake_maps(out)
current['bakeAligned'] = True
shutil.copy2(out / 'model_baked.glb', out / 'model_ex.glb')
mesh, decimation = limit_studio_faces(out, a.tris, BLENDER, update)
current.update(status='mesh_ready', triangles=len(mesh.faces), vertices=len(mesh.vertices), decimation=decimation,
bakeAlignment=json.loads((out / 'bake_alignment.json').read_text(encoding='utf-8')), finalSha256=sha(out / 'model_ex.glb'))
receipt.write_text(json.dumps(current, indent=2), encoding='utf-8')
update(status='mesh_ready', triangles=len(mesh.faces), decimation=decimation)
if __name__ == '__main__': main()