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

212 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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()