ThreeKingdomsSlingshot/Tools/wl_vfx_import.py

375 lines
16 KiB
Python

"""WL 프로젝트 이펙트(VFX) → 이 게임으로 옮기기 (파일 작업만 · GUID 유지 · 원본 무수정).
PD 2026-11-09: 「이 게임에 활용 가능한 리소스를 WL 프로젝트에서 체크해보고 이 프로젝트에도 그대로 쓸 수 있도록 옮기고」.
WL = URP · 이 게임 = 빌트인 렌더 파이프라인 → 재질 셰이더가 빌트인에서 도는 프리팹만 옮긴다.
판정(재질 1장씩 · 프리팹 = 의존 재질이 전부 통과해야 통과):
통과 = 빌트인 셰이더 · 셰이더 그래프에 Built-In 타깃이 있음(이 게임에 com.unity.shadergraph 필요) ·
.shader 가 URP 인클루드 없이 CG/빌트인 경로를 가짐
탈락 = URP 패키지 셰이더·재질 · URP 전용 .shader · URP 타깃만 있는 셰이더 그래프 · 셰이더 파일 없음 ·
프리팹이 쓰는 스크립트가 이 게임에 없는 네임스페이스(URP · HDRP · Input System · DOTween …)를 씀
복사 = 프리팹 + 의존 파일(guid 참조 · 셰이더 #include · .meta 의 외부 재질 참조) 을 .meta 째 복사(GUID 그대로 → 참조 유지).
경로 = WL Assets/<rel> → 이 게임 <dest>/<rel>(앞의 ResWork/VFX/ · ResWork/ 는 뗌) · 같은 GUID 가 이미 있으면 건너뜀.
실행(어느 python 이든 · 표준 라이브러리만):
python -I -B -X utf8 Tools/wl_vfx_import.py scan <WL Assets 기준 폴더|프리팹> ... # 판정 + 크기 표(복사 없음)
python -I -B -X utf8 Tools/wl_vfx_import.py copy <WL Assets 기준 폴더|프리팹> ... # 통과 프리팹만 복사
옵션: --wl <WL 루트>(없으면 환경 변수 WL_PROJECT_ROOT) · --dest Assets/VFX · --list(프리팹별 판정 줄) · --report <csv>
복사 뒤 = Unity 에디터 Assets 새로고침(자동) → 메뉴 TKS/VFX/Check Imported VFX (분홍 재질 검사).
다른 Unity 프로젝트도 같은 방식(2026-11-10 PD 「IB(쿠로네코) 이펙트를 그대로 써서 확인」):
python -I -B -X utf8 Tools/wl_vfx_import.py copy AssetResources/FX/ef_booster.prefab … --wl E:/NerdNavis/Kuroneko/KuronekoClient --dest Assets/VFX/IB --skip-scripts
--skip-scripts = 프리팹이 붙인 그 게임 전용 스크립트(.cs)는 판정·복사에서 뺀다(사본 Resources/Fx/TKS_Fx_* 에서 빠진 스크립트 부품을 지움).
"""
import argparse
import collections
import csv
import os
import re
import shutil
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
TKS_ROOT = os.path.dirname(HERE)
GUID_YAML = re.compile(r"guid:\s*([0-9a-f]{32})")
HEX32 = re.compile(r"(?<![0-9a-f])([0-9a-f]{32})(?![0-9a-f])")
SHADER_LINE = re.compile(r"m_Shader:\s*\{fileID:\s*(-?\d+)(?:,\s*guid:\s*([0-9a-f]{32}))?")
INCLUDE = re.compile(r'#include\s+"([^"]+)"')
USING = re.compile(r"^\s*using\s+(?:static\s+)?([A-Za-z_][\w\.]*)\s*;", re.M)
BUILTIN_GUIDS = {"0000000000000000f000000000000000", "0000000000000000e000000000000000", "0000000000000000d000000000000000"}
YAML_EXT = {".prefab", ".mat", ".asset", ".controller", ".anim", ".overridecontroller", ".mask", ".physicmaterial",
".physicsmaterial2d", ".rendertexture", ".flare", ".playable", ".signal", ".spriteatlas", ".unity", ".lighting"}
GRAPH_EXT = {".shadergraph", ".shadersubgraph", ".vfx", ".vfxoperator", ".vfxblock"}
SHADER_EXT = {".shader", ".cginc", ".hlsl", ".compute", ".glslinc"}
OK_CLASSES = {"builtin", "sg_builtin", "shader_builtin", "shader_multi", "port"}
# port = 이 게임 Assets/VFX/_BuiltinPorts/ 에 같은 GUID 의 빌트인 이식판이 이미 있는 URP 셰이더(재질 무수정으로 그대로 씀 · 2026-11-09)
# 이 게임에서 컴파일되는 네임스페이스(그 밖 = 스크립트 위험 → 그 프리팹 탈락)
SAFE_NS = ("System", "UnityEngine", "UnityEditor", "TMPro")
BAD_NS = ("UnityEngine.Rendering.Universal", "UnityEngine.Rendering.HighDefinition", "UnityEditor.Rendering.Universal",
"UnityEditor.Rendering.HighDefinition", "UnityEngine.InputSystem", "UnityEngine.Experimental.Rendering.Universal",
"Unity.Mathematics", "UnityEngine.Rendering.PostProcessing", "Cinemachine", "Unity.Cinemachine")
def strip_rel(rel):
for pre in ("ResWork/VFX/", "ResWork/"):
if rel.startswith(pre):
rel = rel[len(pre):]
break
# 팩·WL 사본은 Resources 밖에 둔다(빌드 포함 · Resources.Load 경로 충돌 방지) — 게임이 읽는 사본은 Resources/Fx/TKS_Fx_* 로 따로 만든다
return "/".join(p for p in rel.split("/") if p != "Resources")
class Index:
def __init__(self, root, with_pkg):
self.root = root
self.g2p = {}
self._walk(os.path.join(root, "Assets"))
if with_pkg:
pc = os.path.join(root, "Library", "PackageCache")
if os.path.isdir(pc):
self._walk(pc, only=(".shader.meta", ".shadergraph.meta", ".mat.meta", ".shadersubgraph.meta", ".hlsl.meta", ".asset.meta"))
def _walk(self, base, only=None):
for dp, dn, fn in os.walk(base):
for f in fn:
if not f.endswith(".meta") or (only and not f.endswith(only)):
continue
p = os.path.join(dp, f)
try:
with open(p, "r", encoding="utf-8", errors="ignore") as fh:
m = GUID_YAML.search(fh.read(400))
except OSError:
continue
if m:
self.g2p[m.group(1)] = os.path.normpath(p[:-5])
def read_text(p, n=None):
try:
with open(p, "r", encoding="utf-8", errors="ignore") as fh:
return fh.read(n) if n else fh.read()
except OSError:
return ""
class Scanner:
def __init__(self, wl_root):
self.wl = wl_root
self.assets = os.path.normpath(os.path.join(wl_root, "Assets"))
print("WL guid 색인 중…", flush=True)
self.idx = Index(wl_root, True)
print(" guid %d" % len(self.idx.g2p), flush=True)
self.tks = Index(TKS_ROOT, False)
self.deps_cache = {}
self.shader_cache = {}
self.script_cache = {}
self.skip_scripts = False
def in_pkg(self, p):
return "PackageCache" in p
def direct_deps(self, p):
if p in self.deps_cache:
return self.deps_cache[p]
ext = os.path.splitext(p)[1].lower()
out = set()
guids = set()
if ext in YAML_EXT:
guids.update(GUID_YAML.findall(read_text(p)))
elif ext in GRAPH_EXT:
guids.update(HEX32.findall(read_text(p)))
elif ext in SHADER_EXT:
d = os.path.dirname(p)
for inc in INCLUDE.findall(read_text(p)):
if inc.startswith("Packages/"):
continue
cand = os.path.normpath(os.path.join(self.wl, inc)) if inc.startswith("Assets/") else os.path.normpath(os.path.join(d, inc))
if os.path.isfile(cand):
out.add(cand)
meta = p + ".meta"
if os.path.isfile(meta):
for g in GUID_YAML.findall(read_text(meta))[1:]: # 첫 줄 = 자기 guid
guids.add(g)
for g in guids:
if g in BUILTIN_GUIDS:
continue
q = self.idx.g2p.get(g)
if q and os.path.normpath(q) != os.path.normpath(p):
out.add(q)
self.deps_cache[p] = out
return out
def closure(self, start):
seen = set()
stack = [os.path.normpath(start)]
while stack:
p = stack.pop()
if p in seen:
continue
seen.add(p)
if self.in_pkg(p):
continue # 패키지 안은 따라가지 않음(판정만)
for q in self.direct_deps(p):
if q not in seen:
stack.append(q)
return seen
def classify_shader(self, fileid, guid):
if guid is None or guid in BUILTIN_GUIDS:
return "builtin"
if guid in self.shader_cache:
return self.shader_cache[guid]
tp = self.tks.g2p.get(guid)
if tp and tp.endswith(".shader") and "com.unity.render-pipelines.universal" not in read_text(tp):
self.shader_cache[guid] = "port"
return "port"
path = self.idx.g2p.get(guid)
if not path:
res = "missing"
elif self.in_pkg(path):
res = "urp_pkg" if "render-pipelines.universal" in path else "srp_pkg"
elif path.endswith((".shadergraph", ".shadersubgraph")):
t = read_text(path)
res = "sg_builtin" if "BuiltInTarget" in t else ("sg_urp" if "UniversalTarget" in t else "sg_other")
elif path.endswith(".shader"):
t = read_text(path)
urp = "com.unity.render-pipelines.universal" in t
cg = ("UnityCG.cginc" in t) or ("CGPROGRAM" in t)
subs = t.count("SubShader")
tagged = len(re.findall(r'"RenderPipeline"\s*=\s*"UniversalPipeline"', t))
if urp and not cg:
res = "shader_urp"
elif urp and cg:
res = "shader_multi"
elif tagged and tagged >= subs:
res = "shader_urp_tag"
else:
res = "shader_builtin"
else:
res = "other"
self.shader_cache[guid] = res
return res
def script_risk(self, p):
if p in self.script_cache:
return self.script_cache[p]
bad = []
for ns in USING.findall(read_text(p)):
if ns.startswith(BAD_NS) or not ns.startswith(SAFE_NS):
bad.append(ns)
self.script_cache[p] = bad
return bad
def judge(self, prefab):
files = self.closure(prefab)
why = []
for f in files:
low = f.lower()
if self.in_pkg(f):
why.append("패키지 의존 " + os.path.basename(f))
continue
if low.endswith(".mat"):
m = SHADER_LINE.search(read_text(f, 6000))
cls = "noshader" if not m else self.classify_shader(m.group(1), m.group(2))
if cls not in OK_CLASSES:
why.append("%s:%s" % (cls, os.path.basename(f)))
elif low.endswith(".cs"):
if self.skip_scripts:
continue
bad = self.script_risk(f)
if bad:
why.append("스크립트 %s(%s)" % (os.path.basename(f), ",".join(sorted(set(bad)))))
size = 0
for f in files:
if not self.in_pkg(f):
try:
size += os.path.getsize(f)
except OSError:
pass
return files, why, size
def collect_prefabs(sc, targets):
out = []
for t in targets:
p = os.path.normpath(os.path.join(sc.assets, t))
if os.path.isfile(p) and p.lower().endswith(".prefab"):
out.append(p)
elif os.path.isdir(p):
for dp, dn, fn in os.walk(p):
# 데모 · 씬 폴더 프리팹은 제외(데모 연출용 · 카메라 · 조명)
if re.search(r"(^|[\\/])(Demo|Demos|Scene|Scenes|Showcase)([\\/]|$)", os.path.relpath(dp, p), re.I):
continue
for f in fn:
if f.lower().endswith(".prefab"):
out.append(os.path.join(dp, f))
else:
print(" 없음:", t)
return out
def unpack(pkg_path, dest, skip_scenes):
"""빌트인용 .unitypackage(WL 폴더 안 · 예: GAP UniqueToonProjectiles BIRP)를 GUID 째 <dest>/<경로>(앞 Assets/ 뗌)로 풀기.
같은 GUID 가 이미 있으면 건너뜀 · 경로는 Assets/ 로 시작하고 .. 가 없을 때만."""
import tarfile
tks = Index(TKS_ROOT, False)
entries = {}
with tarfile.open(pkg_path, "r:gz") as t:
for m in t.getmembers():
if not m.isfile():
continue
g, _, kind = m.name.partition("/")
entries.setdefault(g, {})[kind] = m
done = skip = 0
for g, e in entries.items():
if "pathname" not in e or "asset" not in e:
continue
path = t.extractfile(e["pathname"]).read().decode("utf-8").splitlines()[0].strip()
if not path.startswith("Assets/") or ".." in path.split("/"):
continue
if skip_scenes and path.lower().endswith(".unity"):
continue
if g in tks.g2p:
skip += 1
continue
dst = os.path.normpath(os.path.join(TKS_ROOT, dest, path[len("Assets/"):]))
os.makedirs(os.path.dirname(dst), exist_ok=True)
with open(dst, "wb") as fh:
fh.write(t.extractfile(e["asset"]).read())
if "asset.meta" in e:
with open(dst + ".meta", "wb") as fh:
fh.write(t.extractfile(e["asset.meta"]).read())
done += 1
print("풀기 %d · 이미 있음 %d → %s" % (done, skip, os.path.join(TKS_ROOT, dest)))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("cmd", choices=["scan", "copy", "unpack"])
ap.add_argument("targets", nargs="+")
ap.add_argument("--wl", default=os.environ.get("WL_PROJECT_ROOT", ""))
ap.add_argument("--dest", default="Assets/VFX")
ap.add_argument("--list", action="store_true")
ap.add_argument("--report", default="")
ap.add_argument("--with-scenes", action="store_true", help="unpack: 데모 씬도 풀기")
ap.add_argument("--skip-scripts", action="store_true", help="프리팹이 쓰는 .cs 스크립트는 판정·복사에서 제외")
a = ap.parse_args()
if not a.wl or not os.path.isdir(os.path.join(a.wl, "Assets")):
sys.exit("WL 루트를 --wl 또는 환경 변수 WL_PROJECT_ROOT 로 주세요")
if a.cmd == "unpack":
for t in a.targets:
unpack(os.path.join(a.wl, "Assets", t), a.dest, not a.with_scenes)
return
sc = Scanner(a.wl)
sc.skip_scripts = a.skip_scripts
prefabs = collect_prefabs(sc, a.targets)
print("프리팹 %d" % len(prefabs), flush=True)
ok_files = set()
rows = []
per_pack = collections.OrderedDict()
for pf in prefabs:
files, why, size = sc.judge(pf)
rel = os.path.relpath(pf, sc.assets).replace("\\", "/")
pack = next((t for t in a.targets if rel.startswith(t.replace("\\", "/"))), "?")
st = per_pack.setdefault(pack, {"ok": 0, "ng": 0, "reasons": collections.Counter()})
if why:
st["ng"] += 1
for w in why:
st["reasons"][w.split(":")[0].split("(")[0]] += 1
else:
st["ok"] += 1
ok_files.update(f for f in files if not sc.in_pkg(f))
rows.append((rel, "OK" if not why else "NG", round(size / 1048576, 2), "; ".join(sorted(set(why)))[:300]))
if a.list:
print(" %s %s %.1fMB %s" % ("OK" if not why else "NG", rel, size / 1048576, "; ".join(sorted(set(why)))[:160]))
total = 0
for f in ok_files:
try:
total += os.path.getsize(f)
except OSError:
pass
print("\n팩별 판정")
for pk, st in per_pack.items():
print(" %-70s 통과 %4d · 탈락 %4d %s" % (pk, st["ok"], st["ng"], dict(st["reasons"].most_common(4))))
print("통과 프리팹 의존 파일 %d · %.1f MB" % (len(ok_files), total / 1048576))
if a.report:
os.makedirs(os.path.dirname(os.path.abspath(a.report)), exist_ok=True)
with open(a.report, "w", encoding="utf-8-sig", newline="") as fh:
w = csv.writer(fh)
w.writerow(["s_prefab", "e_result", "f_mb", "Memo"])
w.writerows(rows)
print("판정 표 →", a.report)
if a.cmd != "copy":
return
tks = Index(TKS_ROOT, False)
copied = skipped = 0
for src in sorted(ok_files):
rel = os.path.relpath(src, sc.assets).replace("\\", "/")
if rel.startswith("..") or (a.skip_scripts and src.lower().endswith((".cs", ".dll"))):
continue
dst = os.path.normpath(os.path.join(TKS_ROOT, a.dest, strip_rel(rel)))
meta = src + ".meta"
g = GUID_YAML.search(read_text(meta, 400))
g = g.group(1) if g else None
if g and g in tks.g2p:
skipped += 1
continue
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
if os.path.isfile(meta):
shutil.copy2(meta, dst + ".meta")
copied += 1
print("복사 %d · 이미 있음 %d → %s" % (copied, skipped, os.path.join(TKS_ROOT, a.dest)))
if __name__ == "__main__":
main()