champions-of-luneraria/tools/blender/bake_pbr.py
Martin Stickelbroeck 2c1686b837 U5 Vertical Slice: PBR-Sätze, Surface-Shader, Mid-Lane, Kampf-VFX
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>
2026-10-02 17:10:10 +02:00

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