212 lines
12 KiB
Python
212 lines
12 KiB
Python
# -*- 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()
|