BurningTimesAi/공유/기획/FirstRising/맵/tools/texderive_dungeon.py

212 lines
12 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
# 2026-09-28 BT38 — 오염된 던전(PlagueCatacomb) 바닥을 '던전 타일'로 다시 짠다.
# PD "오염된 던전의 바닥 텍스쳐가 너무 크고 퀄리티가 다른 맵에 비해 떨어지는 느낌 … 좀 더 자연스러운 던전 타일처럼"
# 원인(실측): 팩 텍스처가 PC 에서 512 픽셀로 들어오고(1 m 에 64~109 픽셀 — 다른 맵의 주 바닥은 223~256),
# 바탕 층은 무늬 없는 바위를 8 m 로, 공터 층은 깨진 타일을 4.7 m 로 깔아 돌 한 장이 1~2 m 였다.
# 방법: 같은 팩의 던전 타일 세 가지(FloorA 온전 · FloorA3 얼룩 · FloorA2 깨짐)는 줄눈 배치가 같다(높이 맵 상관 0.74~0.82).
# → 네 층을 모두 같은 배치 · 같은 크기로 깔면 층이 바뀌어도 줄눈이 이어져 '한 바닥이 닳고 더러워진' 모습이 된다.
# 게임 에셋의 들여오기 설정은 건드리지 않고, 1024 픽셀 사본을 맵 폴더에 만든다(빌더가 1024 · 고화질 압축으로 들여온다).
# 만드는 것(맵 폴더 Textures): FRMap_dun_tile(온전) · FRMap_dun_tile_worn(얼룩) · FRMap_dun_tile_slime(점액) · FRMap_dun_dirt(흙 · 자갈) + 노멀맵(_N)
# 1차 화면 확인: 타일 세 가지는 줄눈 배치가 같아, 덧칠해도 4 m 마다 같은 무늬가 격자처럼 보였다(넓은 보스 자리에서 뚜렷)
# → 깨진 타일 대신 무늬가 다른 흙 층을 넣어 얼룩으로 덮고, 타일 안의 밝기 차(눈에 띄는 밝은 돌)를 더 편다
# 실행: python texderive_dungeon.py [--project] (--project 를 주면 FR 프로젝트 맵 폴더로 복사한다)
import os, sys, io, json, shutil
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
import numpy as np
from PIL import Image, ImageFilter
Image.MAX_IMAGE_PIXELS = None
PROJECT = r"C:\BurningTimes\FirstRising"
PACK = os.path.join(PROJECT, "Assets", "LevelAssets", "Toby Fredson", "Modular Dungeon Catacombs - Mobile", "Textures", "Textures_Terrain")
OUT = r"C:\BurningTimes\FR_maps_staging\_work\dungeon_floor"
DST = os.path.join(PROJECT, "Assets", "BurningTimes", "Maps", "PlagueCatacomb", "Textures")
SIZE = 1024
def load(name, mode="RGB"):
return Image.open(os.path.join(PACK, name)).convert(mode)
def down(img):
"""2048 → 1024. 줄인 뒤 조금 또렷하게(줄눈 · 돌결이 뭉개지지 않게)."""
small = img.resize((SIZE, SIZE), Image.LANCZOS)
return small.filter(ImageFilter.UnsharpMask(radius=1.2, percent=70, threshold=2))
def down_normal(img):
"""노멀맵은 줄인 뒤 길이를 1 로 다시 맞춘다."""
a = np.asarray(img.resize((SIZE, SIZE), Image.LANCZOS)).astype(np.float64) / 255.0 * 2.0 - 1.0
n = np.linalg.norm(a, axis=2, keepdims=True)
a = a / np.maximum(n, 1e-6)
return a
def save_normal(a, path):
n = np.linalg.norm(a, axis=2, keepdims=True)
a = a / np.maximum(n, 1e-6)
Image.fromarray(np.clip((a * 0.5 + 0.5) * 255.0 + 0.5, 0, 255).astype(np.uint8)).save(path)
def wrap_blur(x, radius):
"""가장자리가 이어지는 흐림 — 둘레를 되풀이해 붙인 뒤 흐리고 가운데만 잘라 낸다."""
pad = int(radius * 3) + 2
p = np.pad(x, pad, mode="wrap")
im = Image.fromarray(np.clip(p * 255.0, 0, 255).astype(np.uint8))
b = np.asarray(im.filter(ImageFilter.GaussianBlur(radius))).astype(np.float64) / 255.0
return b[pad:-pad, pad:-pad]
def pnoise(size, scale, octaves=4, seed=0):
"""주기 노이즈(0~1) — 가장자리가 정확히 이어진다."""
r = np.random.default_rng(seed)
out = np.zeros((size, size), np.float64)
amp = 1.0
tot = 0.0
fy = np.fft.fftfreq(size)[:, None] * size
fx = np.fft.fftfreq(size)[None, :] * size
f = np.hypot(fx, fy)
for o in range(octaves):
w = r.standard_normal((size, size))
k = scale * (2 ** o)
n = np.real(np.fft.ifft2(np.fft.fft2(w) * np.exp(-(f / k) ** 2)))
n = (n - n.min()) / (n.max() - n.min() + 1e-9)
out += n * amp
tot += amp
amp *= 0.5
out /= tot
return (out - out.min()) / (out.max() - out.min() + 1e-9)
def smooth(x, a, b):
t = np.clip((x - a) / (b - a + 1e-9), 0, 1)
return t * t * (3 - 2 * t)
def seam(img):
a = np.asarray(img).astype(np.float32)
inner = (np.abs(a[:, 1:] - a[:, :-1]).mean() + np.abs(a[1:, :] - a[:-1, :]).mean()) * 0.5
s = (np.abs(a[:, 0] - a[:, -1]).mean() + np.abs(a[0, :] - a[-1, :]).mean()) * 0.5
return float(s / max(1e-3, inner))
def flatten(img, radius=64.0, amount=0.65):
"""큰 밝기 얼룩을 편다 — 텍스처 안의 어두운 돌 무리 · 밝은 자리가 4 m 마다 되풀이되어 눈에 띄는 것을 줄인다(돌결 · 줄눈은 그대로)."""
a = np.asarray(img).astype(np.float64) / 255.0
l = (a * np.array([0.2126, 0.7152, 0.0722])).sum(axis=2)
low = wrap_blur(l, radius)
k = (low.mean() / np.maximum(low, 1e-3)) ** amount
a = np.clip(a * k[..., None], 0, 1)
return Image.fromarray((a * 255.0 + 0.5).astype(np.uint8))
def grade(img, gain=1.0, gamma=1.0, sat=1.0):
"""밝기 · 감마 · 채도를 맞춘다(세 타일의 밝기 차가 너무 크면 층 경계가 얼룩처럼 보인다)."""
a = np.asarray(img).astype(np.float64) / 255.0
a = np.clip(a * gain, 0, 1) ** gamma
l = (a * np.array([0.2126, 0.7152, 0.0722])).sum(axis=2, keepdims=True)
a = np.clip(l + (a - l) * sat, 0, 1)
return Image.fromarray((a * 255.0 + 0.5).astype(np.uint8))
def main():
os.makedirs(OUT, exist_ok=True)
rec = []
# ---------- 온전한 타일 · 얼룩진 타일 · 깨진 타일
src = {
"tile": ("CatacombsFloorA_Albedo.png", "CatacombsFloorA_Normal.png", dict(gain=1.00, gamma=1.0, sat=0.95)),
"tile_worn": ("CatacombsFloorA3_Albedo.png", "CatacombsFloorA3_Normal.png", dict(gain=1.22, gamma=0.96, sat=0.95)),
}
made = {}
normals = {}
for key, (alb, nrm, g) in src.items():
img = grade(flatten(flatten(down(load(alb)), 64.0, 0.8), 18.0, 0.45), **g) # 큰 얼룩(64) + 돌 한 장 크기(18)의 밝기 차를 편다
n = down_normal(load(nrm))
img.save(os.path.join(OUT, "FRMap_dun_%s.png" % key))
save_normal(n, os.path.join(OUT, "FRMap_dun_%s_N.png" % key))
made[key] = img
normals[key] = n
mean = np.asarray(img).reshape(-1, 3).mean(axis=0)
nmean = np.asarray(load(nrm).resize((64, 64))).reshape(-1, 3).mean(axis=0)
rec.append({"name": key, "from": alb, "mean": [round(float(v), 1) for v in mean], "seam": round(seam(img), 2), "normal_mean": [round(float(v)) for v in nmean]})
# ---------- 흙 · 자갈(무늬가 다른 층 — 타일 위를 얼룩으로 덮어 되풀이를 깬다)
dirt = grade(flatten(down(load("PebblesCATA_Albedo.png")), 64.0, 0.7), gain=1.05, gamma=1.0, sat=0.9)
dirt.save(os.path.join(OUT, "FRMap_dun_dirt.png"))
save_normal(down_normal(load("PebblesCATA_Normal.png")), os.path.join(OUT, "FRMap_dun_dirt_N.png"))
rec.append({"name": "dirt", "from": "PebblesCATA_Albedo.png", "mean": [round(float(v), 1) for v in np.asarray(dirt).reshape(-1, 3).mean(axis=0)], "seam": round(seam(dirt), 2)})
# ---------- 점액이 고인 타일(얼룩진 타일 + 줄눈에 고인 녹색 점액 + 드문 웅덩이)
base = np.asarray(made["tile_worn"]).astype(np.float64) / 255.0
h = np.asarray(load("CatacombsFloorA3_Height.png", "L").resize((SIZE, SIZE), Image.LANCZOS)).astype(np.float64) / 255.0
low = wrap_blur(h, 7.0) - h # 둘레보다 낮은 곳(줄눈 · 패인 곳)
joints = smooth(low, 0.012, 0.05)
joints = np.clip(wrap_blur(joints, 1.6) * 1.5, 0, 1) # 줄눈에서 조금 번지게
patch = smooth(pnoise(SIZE, 3.0, 4, 381), 0.42, 0.66) # 점액이 도는 구역(큰 얼룩)
pool = smooth(pnoise(SIZE, 5.0, 4, 977), 0.70, 0.82) * smooth(pnoise(SIZE, 2.0, 3, 55), 0.40, 0.65) # 드문 웅덩이
slime = np.clip(joints * patch * 0.95 + pool * 0.9, 0, 1)
fine = pnoise(SIZE, 24.0, 3, 412)
lum = (base * np.array([0.2126, 0.7152, 0.0722])).sum(axis=2)
green = np.stack([0.20 + 0.08 * fine, 0.34 + 0.12 * fine, 0.09 + 0.04 * fine], axis=2) # 탁한 올리브빛(선명한 녹색은 페인트처럼 보였다)
body = green * (0.55 + 0.9 * lum[..., None])
rim = smooth(slime, 0.15, 0.5) - smooth(slime, 0.5, 0.95) # 점액 가장자리는 조금 밝게(젖은 테)
out = base * (1 - slime[..., None] * 0.78) + body * (slime[..., None] * 0.78)
out = np.clip(out + rim[..., None] * np.array([0.02, 0.045, 0.008]), 0, 1)
img = Image.fromarray((out * 255.0 + 0.5).astype(np.uint8))
img.save(os.path.join(OUT, "FRMap_dun_tile_slime.png"))
n = normals["tile_worn"].copy()
flat = np.zeros_like(n)
flat[..., 2] = 1.0
k = (pool * 0.85 + joints * patch * 0.4)[..., None]
k = np.clip(k, 0, 0.9)
n = n * (1 - k) + flat * k # 웅덩이는 매끈하게
save_normal(n, os.path.join(OUT, "FRMap_dun_tile_slime_N.png"))
made["tile_slime"] = img
rec.append({"name": "tile_slime", "from": "tile_worn + 점액", "mean": [round(float(v), 1) for v in np.asarray(img).reshape(-1, 3).mean(axis=0)],
"seam": round(seam(img), 2), "slime_share": round(float((slime > 0.5).mean()), 3)})
# ---------- 미리 보기: 16 m × 12 m 바닥을 1 m = 64 픽셀로(타일 한 장 = TILE m) — 사람 키(1.8 m) 막대와 함께
TILE = float(os.environ.get("BT_TILE", "4.0"))
ppm = 64
W, H = 16, 12
rep = int(round(TILE * ppm))
def tiled(key):
t = np.asarray(made[key].resize((rep, rep), Image.LANCZOS)).astype(np.float64)
ny, nx = H * ppm // rep + 2, W * ppm // rep + 2
return np.tile(t, (ny, nx, 1))[:H * ppm, :W * ppm]
yy, xx = np.mgrid[0:H * ppm, 0:W * ppm]
r = np.random.default_rng(7)
def blot(scale, seed):
n = pnoise(1024, scale, 4, seed)
return np.tile(n, (2, 2))[:H * ppm, :W * ppm]
canvas = tiled("tile_worn") # 방 = 얼룩진 타일
path = smooth(np.abs(yy / ppm - 6.0), 3.4, 2.2) # 가운데 가로 통로 = 온전한 타일
canvas = canvas * (1 - path[..., None]) + tiled("tile") * path[..., None]
dt = np.asarray(dirt.resize((int(round(5.0 * ppm)), int(round(5.0 * ppm))), Image.LANCZOS)).astype(np.float64)
dt = np.tile(dt, (H * ppm // dt.shape[0] + 2, W * ppm // dt.shape[1] + 2, 1))[:H * ppm, :W * ppm]
m = smooth(blot(2.0, 3), 0.55, 0.66)[..., None] * 0.92 # 흙 얼룩(5 m 로 깐다)
canvas = canvas * (1 - m) + dt * m
m = smooth(blot(3.0, 9), 0.62, 0.74)[..., None] * 0.9 # 점액 얼룩
canvas = canvas * (1 - m) + tiled("tile_slime") * m
prev = Image.fromarray(np.clip(canvas, 0, 255).astype(np.uint8))
from PIL import ImageDraw
d = ImageDraw.Draw(prev)
d.rectangle([40, 40, 40 + int(0.6 * ppm), 40 + int(1.8 * ppm)], outline=(255, 60, 60), width=3) # 사람(0.6 × 1.8 m)
d.line([40, 20, 40 + ppm, 20], fill=(255, 255, 0), width=4) # 1 m
prev.save(os.path.join(OUT, "preview_floor_%sm.png" % ("%.1f" % TILE).replace(".", "_")))
for x in rec:
print(json.dumps(x, ensure_ascii=False))
if "--project" in sys.argv:
os.makedirs(DST, exist_ok=True)
n = 0
for f in sorted(os.listdir(OUT)):
# 1차에 만들었던 깨진 타일(tile_broken)은 쓰지 않는다 — 폴더에 남아 있어도 옮기지 않는다
if (f.startswith("FRMap_dun_tile") or f.startswith("FRMap_dun_dirt")) and f.endswith(".png") and "broken" not in f:
shutil.copy2(os.path.join(OUT, f), os.path.join(DST, f))
n += 1
print("프로젝트로 복사", n, "→", DST)
main()