375 lines
16 KiB
Python
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()
|