Die Mid-Lane steht auf Zielqualität (stilisierter Realismus). Die übrige Karte bleibt auf dem U2-Stand, bis über den Weg entschieden ist. - U5A: tools/blender/bake_pbr.py backt sechs nahtlose PBR-Sätze (limestone, soil, bark, leaf, rock, water) mit Albedo, Normal aus dem Höhenfeld (Sobel) und ORM (R AO, G Rauigkeit, B Metall), je 2k. Torus-Mapping für Kantenfluss wie phase10/bake_bark.py. - U5B: Luneraria/Surface trägt _AlbedoMap, _NormalMap, _OrmMap triplanar über den Godot-Weltfarben; ORM steuert Rauigkeit, Metall und AO. LuneMaterialFactory bindet je Oberfläche den passenden Satz, LN_Rock den Felsensatz. - U5C: Mid-Lane-Licht — Nebel (ExponentialSquared, Dichte 0,0085), scharfe Schatten (Bias 0,02), Weißabgleich und Vignette, ACES und Bloom leicht angehoben. - U5D: Hit- und Angriffs-Flash über MaterialPropertyBlock (warm beim Angriff, weißglühend beim Treffer), Turmblitze als dreipunktiger Funke mit Breitenkurve und Farbverlauf. CaptureMidLane schreibt fünf Nachweisbilder nach docs/port/screenshots/u5/. - Leistung (macOS-Player, 116 Einheiten): 132 fps Übersicht, 182 fps Mid nah — 20–25 % unter U3 durch PBR, Normals und Nebel. Mid-Lane bleibt über 120 fps. - Tests: EditMode 29/29, PlayMode 13/13, Kerntests 457/457. Keine Regeländerung; Golden 51.221 Werte unverändert. - Grenze eingehalten: keine prozeduralen Gesichter, Haare oder Anatomie. Sculpts bleiben vorbehalten (Freigabe und Lizenzprüfung). - Dokumentation: U5-VISUAL (Umsetzung, Nachweis, Entscheidungsvorlage für die ganze Karte), PORT-QA (U5), PORT-STATUS, KNOWN-BEHAVIOR- DIFFERENCES. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
367 lines
15 KiB
Python
367 lines
15 KiB
Python
"""Seamless PBR texture bakes for Champions of Luneraria (U5A, docs/port/U5-VISUAL.md).
|
|
|
|
Procedural material bakes in Blender (Cycles, 1 sample, emission of the map), not bitmaps
|
|
scaled up. Every set is seamless on all four edges (torus mapping like phase10/bake_bark.py).
|
|
Writes 8-bit PNGs to Assets/Luneraria/Art/Textures/:
|
|
|
|
<name>-albedo-2k.png base colour
|
|
<name>-normal-2k.png tangent-space normal from the height field (Sobel)
|
|
<name>-orm-2k.png R occlusion, G roughness, B metallic (Unity-style ORM)
|
|
|
|
Sets: limestone (Kluft stone), soil (packed earth), bark (oak ridges), leaf (foliage),
|
|
rock (eroded strata), water (wave normals).
|
|
|
|
Usage (from the Unity project root):
|
|
sh tools/blender/bake_pbr.sh
|
|
sh tools/blender/bake_pbr.sh limestone,soil
|
|
Exit code 0 only if every set passed.
|
|
"""
|
|
import bpy
|
|
import math
|
|
import struct
|
|
import sys
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
UNITY_ROOT = Path(__file__).resolve().parents[2]
|
|
OUT = UNITY_ROOT / "Assets" / "Luneraria" / "Art" / "Textures"
|
|
SIZE = 2048
|
|
|
|
|
|
def args():
|
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
result = {"sets": "all"}
|
|
for i in range(0, len(argv) - 1, 2):
|
|
result[argv[i].lstrip("-")] = argv[i + 1]
|
|
return result
|
|
|
|
|
|
def linear(hexcolor):
|
|
rgb = [int(hexcolor[i:i + 2], 16) / 255 for i in (0, 2, 4)]
|
|
return tuple((c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4) for c in rgb) + (1.0,)
|
|
|
|
|
|
def math_node(nodes, links, op, a, b=None):
|
|
obj = nodes.new("ShaderNodeMath")
|
|
obj.operation = op
|
|
if isinstance(a, (float, int)):
|
|
obj.inputs[0].default_value = float(a)
|
|
else:
|
|
links.new(a, obj.inputs[0])
|
|
if b is not None:
|
|
if isinstance(b, (float, int)):
|
|
obj.inputs[1].default_value = float(b)
|
|
else:
|
|
links.new(b, obj.inputs[1])
|
|
return obj.outputs[0]
|
|
|
|
|
|
def torus_uv(nodes, links, scale_u, scale_v):
|
|
"""UV -> a circle in (cos u, sin u, cos v) so noise wraps on every edge."""
|
|
uv = nodes.new("ShaderNodeTexCoord")
|
|
split = nodes.new("ShaderNodeSeparateXYZ")
|
|
links.new(uv.outputs["UV"], split.inputs[0])
|
|
u = math_node(nodes, links, "MULTIPLY", split.outputs["X"], math.tau)
|
|
v = math_node(nodes, links, "MULTIPLY", split.outputs["Y"], math.tau)
|
|
co = nodes.new("ShaderNodeCombineXYZ")
|
|
links.new(math_node(nodes, links, "MULTIPLY", math_node(nodes, links, "COSINE", u), scale_u), co.inputs["X"])
|
|
links.new(math_node(nodes, links, "MULTIPLY", math_node(nodes, links, "SINE", u), scale_u), co.inputs["Y"])
|
|
links.new(math_node(nodes, links, "MULTIPLY", math_node(nodes, links, "COSINE", v), scale_v), co.inputs["Z"])
|
|
w = math_node(nodes, links, "MULTIPLY", math_node(nodes, links, "SINE", v), scale_v)
|
|
return co.outputs[0], w
|
|
|
|
|
|
def noise(nodes, links, co, w, scale, detail, roughness):
|
|
tex = nodes.new("ShaderNodeTexNoise")
|
|
tex.noise_dimensions = "4D"
|
|
links.new(co, tex.inputs["Vector"])
|
|
links.new(w, tex.inputs["W"])
|
|
tex.inputs["Scale"].default_value = scale
|
|
tex.inputs["Detail"].default_value = detail
|
|
tex.inputs["Roughness"].default_value = roughness
|
|
return tex.outputs["Fac"]
|
|
|
|
|
|
def ramp(nodes, links, source, stops):
|
|
"""ValToRGB with (position, hex) stops in sRGB; output linear colours."""
|
|
node_ramp = nodes.new("ShaderNodeValToRGB")
|
|
elements = node_ramp.color_ramp.elements
|
|
while len(elements) > 2:
|
|
elements.remove(elements[-1])
|
|
elements[0].position = stops[0][0]
|
|
elements[0].color = linear(stops[0][1])
|
|
elements[1].position = stops[-1][0]
|
|
elements[1].color = linear(stops[-1][1])
|
|
for position, hexcolor in stops[1:-1]:
|
|
elements.new(position).color = linear(hexcolor)
|
|
links.new(source, node_ramp.inputs[0])
|
|
return node_ramp.outputs[0]
|
|
|
|
|
|
def new_scene(name):
|
|
scene = bpy.data.scenes.new(name)
|
|
bpy.context.window.scene = scene
|
|
scene.render.engine = "CYCLES"
|
|
scene.cycles.samples = 1
|
|
scene.render.resolution_x = SIZE
|
|
scene.render.resolution_y = SIZE
|
|
scene.render.resolution_percentage = 100
|
|
scene.render.image_settings.file_format = "PNG"
|
|
scene.render.image_settings.color_mode = "RGB"
|
|
scene.render.image_settings.color_depth = "8"
|
|
scene.render.image_settings.compression = 90
|
|
scene.render.dither_intensity = 0
|
|
scene.view_settings.view_transform = "Standard"
|
|
scene.view_settings.look = "None"
|
|
scene.render.film_transparent = False
|
|
bpy.ops.mesh.primitive_plane_add(size=2)
|
|
plane = bpy.context.object
|
|
plane.name = name + "_surface"
|
|
mat = bpy.data.materials.new(name)
|
|
mat.use_nodes = True
|
|
plane.data.materials.append(mat)
|
|
nodes = mat.node_tree.nodes
|
|
nodes.clear()
|
|
links = mat.node_tree.links
|
|
emit = nodes.new("ShaderNodeEmission")
|
|
output = nodes.new("ShaderNodeOutputMaterial")
|
|
links.new(emit.outputs[0], output.inputs["Surface"])
|
|
bpy.ops.object.camera_add(location=(0, 0, 3))
|
|
camera = bpy.context.object
|
|
camera.data.type = "ORTHO"
|
|
camera.data.ortho_scale = 2
|
|
scene.camera = camera
|
|
return scene, nodes, links, emit
|
|
|
|
|
|
def render(scene, emit, source, path):
|
|
mat = bpy.data.materials.get(scene.name)
|
|
links = mat.node_tree.links
|
|
for link in list(emit.inputs["Color"].links):
|
|
links.remove(link)
|
|
if isinstance(source, (float, int)):
|
|
emit.inputs["Color"].default_value = (float(source), float(source), float(source), 1.0)
|
|
else:
|
|
links.new(source, emit.inputs["Color"])
|
|
scene.render.filepath = str(path)
|
|
bpy.ops.render.render(write_still=True)
|
|
print("PBR_WRITE", path.name)
|
|
|
|
|
|
# ---------------------------------------------------------------- PNG helpers (no numpy)
|
|
def read_png_gray(path):
|
|
"""Returns (width, height, list of 0..255 luminance samples, row-major)."""
|
|
image = bpy.data.images.load(str(path))
|
|
width, height = image.size
|
|
pixels = list(image.pixels)
|
|
bpy.data.images.remove(image)
|
|
values = [0] * (width * height)
|
|
for i in range(width * height):
|
|
r = pixels[i * 4]
|
|
g = pixels[i * 4 + 1]
|
|
b = pixels[i * 4 + 2]
|
|
values[i] = int(round((0.2126 * r + 0.7152 * g + 0.0722 * b) * 255))
|
|
return width, height, values
|
|
|
|
|
|
def write_png_rgb(path, width, height, pixels_rgb):
|
|
"""pixels_rgb: bytes-like length width*height*3, top-left origin."""
|
|
raw = bytearray()
|
|
stride = width * 3
|
|
for y in range(height):
|
|
raw.append(0) # filter none
|
|
raw.extend(pixels_rgb[y * stride:(y + 1) * stride])
|
|
compressed = zlib.compress(bytes(raw), 9)
|
|
|
|
def chunk(tag, data):
|
|
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
|
|
|
png = b"\x89PNG\r\n\x1a\n"
|
|
png += chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
|
|
png += chunk(b"IDAT", compressed)
|
|
png += chunk(b"IEND", b"")
|
|
path.write_bytes(png)
|
|
print("PBR_WRITE", path.name)
|
|
|
|
|
|
def height_to_normal(path_height, path_normal):
|
|
width, height, values = read_png_gray(path_height)
|
|
|
|
def sample(x, y):
|
|
return values[(y % height) * width + (x % width)]
|
|
|
|
strength = 2.0
|
|
out = bytearray(width * height * 3)
|
|
for y in range(height):
|
|
for x in range(width):
|
|
left = sample(x - 1, y)
|
|
right = sample(x + 1, y)
|
|
up = sample(x, y - 1)
|
|
down = sample(x, y + 1)
|
|
dx = (left - right) * strength / 255.0
|
|
dy = (up - down) * strength / 255.0
|
|
# Tangent-space normal, OpenGL convention (Y up).
|
|
nx, ny, nz = dx, dy, 1.0
|
|
length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
|
|
nx, ny, nz = nx / length, ny / length, nz / length
|
|
i = (y * width + x) * 3
|
|
out[i] = int(round((nx * 0.5 + 0.5) * 255))
|
|
out[i + 1] = int(round((ny * 0.5 + 0.5) * 255))
|
|
out[i + 2] = int(round((nz * 0.5 + 0.5) * 255))
|
|
write_png_rgb(path_normal, width, height, out)
|
|
|
|
|
|
def pack_orm(path_ao, path_rough, path_metal, path_orm):
|
|
_, _, ao = read_png_gray(path_ao)
|
|
width, height, rough = read_png_gray(path_rough)
|
|
_, _, metal = read_png_gray(path_metal)
|
|
out = bytearray(width * height * 3)
|
|
for i in range(width * height):
|
|
out[i * 3] = ao[i]
|
|
out[i * 3 + 1] = rough[i]
|
|
out[i * 3 + 2] = metal[i]
|
|
write_png_rgb(path_orm, width, height, out)
|
|
|
|
|
|
def save_set(name, builder):
|
|
scene, nodes, links, emit = new_scene("PBR_" + name)
|
|
height, albedo, roughness, metallic, ao = builder(nodes, links)
|
|
albedo_path = OUT / (name + "-albedo-2k.png")
|
|
height_path = OUT / (name + "-height-2k.png")
|
|
rough_path = OUT / (name + "-roughness-2k.png")
|
|
metal_path = OUT / (name + "-metallic-2k.png")
|
|
ao_path = OUT / (name + "-ao-2k.png")
|
|
render(scene, emit, albedo, albedo_path)
|
|
render(scene, emit, height, height_path)
|
|
render(scene, emit, roughness, rough_path)
|
|
render(scene, emit, metallic if not isinstance(metallic, (float, int)) else float(metallic), metal_path)
|
|
render(scene, emit, ao if not isinstance(ao, (float, int)) else float(ao), ao_path)
|
|
height_to_normal(height_path, OUT / (name + "-normal-2k.png"))
|
|
pack_orm(ao_path, rough_path, metal_path, OUT / (name + "-orm-2k.png"))
|
|
height_path.unlink()
|
|
rough_path.unlink()
|
|
metal_path.unlink()
|
|
ao_path.unlink()
|
|
print("PBR_BAKE_PASS", name)
|
|
|
|
|
|
# ---------------------------------------------------------------- sets
|
|
def build_limestone(nodes, links):
|
|
co, w = torus_uv(nodes, links, 9.0, 0.8)
|
|
broad = noise(nodes, links, co, w, 2.2, 6, 0.7)
|
|
grain = noise(nodes, links, co, w, 11.0, 5, 0.62)
|
|
cracks = noise(nodes, links, co, w, 5.5, 8, 0.8)
|
|
height = math_node(nodes, links, "ADD",
|
|
math_node(nodes, links, "MULTIPLY", broad, 0.55),
|
|
math_node(nodes, links, "MULTIPLY", grain, 0.35))
|
|
height = math_node(nodes, links, "SUBTRACT", height, math_node(nodes, links, "MULTIPLY", cracks, 0.2))
|
|
albedo = ramp(nodes, links, height, [
|
|
(0.22, "6E675C"), (0.4, "8C8476"), (0.55, "A39A88"), (0.72, "BDB3A0"), (0.88, "D2C8B4")])
|
|
roughness = ramp(nodes, links, height, [(0.2, "F0F0F0"), (0.5, "D0D0D0"), (0.8, "B8B8B8")])
|
|
ao = ramp(nodes, links, height, [(0.15, "707070"), (0.45, "D0D0D0"), (0.8, "FFFFFF")])
|
|
return height, albedo, roughness, 0.0, ao
|
|
|
|
|
|
def build_soil(nodes, links):
|
|
co, w = torus_uv(nodes, links, 7.0, 0.7)
|
|
broad = noise(nodes, links, co, w, 1.6, 5, 0.7)
|
|
grain = noise(nodes, links, co, w, 14.0, 4, 0.55)
|
|
pebbles = noise(nodes, links, co, w, 6.5, 6, 0.75)
|
|
height = math_node(nodes, links, "ADD",
|
|
math_node(nodes, links, "MULTIPLY", broad, 0.5),
|
|
math_node(nodes, links, "MULTIPLY", grain, 0.3))
|
|
height = math_node(nodes, links, "ADD", height, math_node(nodes, links, "MULTIPLY", pebbles, 0.2))
|
|
albedo = ramp(nodes, links, height, [
|
|
(0.2, "2A241C"), (0.4, "3C3428"), (0.58, "4A4030"), (0.75, "5C503C"), (0.9, "6E6048")])
|
|
roughness = ramp(nodes, links, height, [(0.2, "FAFAFA"), (0.6, "E8E8E8"), (0.9, "D8D8D8")])
|
|
ao = ramp(nodes, links, height, [(0.15, "808080"), (0.5, "D8D8D8"), (0.85, "FFFFFF")])
|
|
return height, albedo, roughness, 0.0, ao
|
|
|
|
|
|
def build_bark(nodes, links):
|
|
co, w = torus_uv(nodes, links, 10.0, 0.7)
|
|
grain = noise(nodes, links, co, w, 2.6, 7, 0.73)
|
|
ridges = noise(nodes, links, co, w, 7.5, 5, 0.8)
|
|
height = ramp(nodes, links, grain, [
|
|
(0.27, "121212"), (0.43, "242424"), (0.51, "8C8C8C"), (0.67, "CCCCCC")])
|
|
bump = math_node(nodes, links, "ADD", height, math_node(nodes, links, "MULTIPLY", ridges, 0.25))
|
|
albedo = ramp(nodes, links, height, [
|
|
(0.0, "302B23"), (0.18, "4B3C2A"), (0.51, "766041"), (0.74, "94855D"), (1.0, "B09A6D")])
|
|
roughness = ramp(nodes, links, ridges, [(0.2, "F5F5F5"), (0.7, "E0E0E0")])
|
|
ao = ramp(nodes, links, height, [(0.1, "606060"), (0.5, "C8C8C8"), (0.85, "FFFFFF")])
|
|
return bump, albedo, roughness, 0.0, ao
|
|
|
|
|
|
def build_leaf(nodes, links):
|
|
co, w = torus_uv(nodes, links, 8.0, 0.8)
|
|
broad = noise(nodes, links, co, w, 1.8, 4, 0.65)
|
|
vein = noise(nodes, links, co, w, 9.0, 5, 0.7)
|
|
height = math_node(nodes, links, "ADD",
|
|
math_node(nodes, links, "MULTIPLY", broad, 0.6),
|
|
math_node(nodes, links, "MULTIPLY", vein, 0.4))
|
|
albedo = ramp(nodes, links, height, [
|
|
(0.2, "1E3224"), (0.4, "2F4A32"), (0.58, "456640"), (0.75, "5F8554"), (0.9, "7FA66A")])
|
|
roughness = ramp(nodes, links, height, [(0.2, "D8D8D8"), (0.7, "C0C0C0")])
|
|
ao = ramp(nodes, links, height, [(0.15, "707070"), (0.55, "D0D0D0"), (0.85, "FFFFFF")])
|
|
return height, albedo, roughness, 0.0, ao
|
|
|
|
|
|
def build_rock(nodes, links):
|
|
co, w = torus_uv(nodes, links, 6.0, 0.6)
|
|
strata = noise(nodes, links, co, w, 3.5, 7, 0.78)
|
|
grain = noise(nodes, links, co, w, 16.0, 3, 0.5)
|
|
height = math_node(nodes, links, "ADD",
|
|
math_node(nodes, links, "MULTIPLY", strata, 0.7),
|
|
math_node(nodes, links, "MULTIPLY", grain, 0.3))
|
|
albedo = ramp(nodes, links, height, [
|
|
(0.2, "3A3C40"), (0.4, "4A4C50"), (0.58, "5A5C60"), (0.75, "6A6C70"), (0.9, "7A7C80")])
|
|
roughness = ramp(nodes, links, height, [(0.2, "FAFAFA"), (0.7, "ECECEC")])
|
|
ao = ramp(nodes, links, height, [(0.12, "606060"), (0.5, "D0D0D0"), (0.85, "FFFFFF")])
|
|
return height, albedo, roughness, 0.0, ao
|
|
|
|
|
|
def build_water(nodes, links):
|
|
co, w = torus_uv(nodes, links, 5.0, 0.5)
|
|
waves = noise(nodes, links, co, w, 3.2, 6, 0.62)
|
|
chop = noise(nodes, links, co, w, 9.5, 4, 0.55)
|
|
height = math_node(nodes, links, "ADD",
|
|
math_node(nodes, links, "MULTIPLY", waves, 0.65),
|
|
math_node(nodes, links, "MULTIPLY", chop, 0.35))
|
|
albedo = ramp(nodes, links, height, [(0.25, "0A2830"), (0.55, "123A42"), (0.8, "1A4A52")])
|
|
roughness = ramp(nodes, links, height, [(0.2, "3A3A3A"), (0.7, "555555")])
|
|
metallic = ramp(nodes, links, height, [(0.2, "202020"), (0.7, "303030")])
|
|
return height, albedo, roughness, metallic, 1.0
|
|
|
|
|
|
SETS = {
|
|
"limestone": build_limestone,
|
|
"soil": build_soil,
|
|
"bark": build_bark,
|
|
"leaf": build_leaf,
|
|
"rock": build_rock,
|
|
"water": build_water,
|
|
}
|
|
|
|
|
|
def main():
|
|
options = args()
|
|
wanted = list(SETS) if options["sets"] == "all" else [s.strip() for s in options["sets"].split(",") if s.strip()]
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
failed = []
|
|
for name in wanted:
|
|
if name not in SETS:
|
|
failed.append(name + ": unknown set")
|
|
continue
|
|
try:
|
|
save_set(name, SETS[name])
|
|
except Exception as error:
|
|
failed.append(name + ": " + repr(error))
|
|
if failed:
|
|
for line in failed:
|
|
print("PBR_BAKE_FAIL", line)
|
|
raise SystemExit(1)
|
|
print("PBR_BAKE_PASS all", ",".join(wanted))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|