Bake library AO and lift environment textures
Full build / Full desktop build + optional release (push) Successful in 3m43s
Full build / Import + headless tests (push) Successful in 29s

Phase 4 of the v0.6.0 graphics plan:

- tools/bake_ao.py bakes 256² AO maps for every Blender character/gear/prop
  (Cycles, existing UVs) next to each GLB; Materials.apply_ao() wires the red
  channel into character slots, gear, and props. Compatibility has no SSAO.
- Regenerated brick/concrete/asphalt at 1024² with OpenRouter, derived tangent
  normal maps via tools/make_normal_maps.py, and wired them through
  SurfaceMaterials.textured() without changing UV scales.
- Evaluated Forward+: boots on desktop Vulkan, but the renderer is project-wide
  and Web must stay GL Compatibility; v0.6.0 keeps Compatibility (decision in
  MEMORY/plan). A desktop-only override is deferred.
- Recaptured screenshots; compare 11 good / 0 bad. Fixed marquee text alignment.
- Tests: baked AO maps exist for all 46 assets; prop/character AO material
  checks; headless 222/222, Python 69/69, all smokes pass.
- Version 0.6.0; ADR-0013 accepted; README/architecture/blender-assets/plan and
  TODO/DONE updated.
This commit is contained in:
zwitschi committed 2026-11-01 15:07:27 +01:00
1 parent d8cfddbf89
commit d872034388
78 files changed
+390 -28

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Bake per-asset ambient occlusion maps for the Blender library.
The GL Compatibility renderer has no SSAO, so the assets carry a baked AO map
instead. This tool opens each generated `.blend` source, bakes AO into a single
image using the asset's existing UVs, and writes it next to the GLB as
`<name>_ao.png`. Godot wires the map into the material's AO channel.
Run inside Blender (flatpak; absolute paths required):
flatpak run org.blender.Blender --background \
--python "$PWD/tools/bake_ao.py" -- --only bollard
flatpak run org.blender.Blender --background \
--python "$PWD/tools/bake_ao.py"
The tool is a developer step; the committed PNGs mean CI never needs Blender.
"""
import argparse
import json
import sys
from pathlib import Path
try:
import bpy # type: ignore
except ImportError: # pragma: no cover
bpy = None
ROOT = Path(__file__).resolve().parent.parent
LIBRARY = ROOT / "assets" / "models" / "blender"
SRC = ROOT / "assets" / "models" / "blender_src"
MANIFEST = LIBRARY / "manifest.json"
SIZE = 256
SAMPLES = 16
def bake_asset(name: str, category: str) -> bool:
source = SRC / f"{name}.blend"
if not source.is_file():
print(f"skip {name}: missing {source}")
return False
bpy.ops.wm.open_mainfile(filepath=str(source))
# Some character variants keep tiny rigid parts (shoes/soles) without UVs;
# bake the UV-mapped body/head/limbs and leave those parts untextured.
meshes = [o for o in bpy.context.scene.objects
if o.type == "MESH" and o.data.uv_layers]
if not meshes:
print(f"skip {name}: no UV-mapped mesh")
return False
image = bpy.data.images.new(f"{name}_ao", width=SIZE, height=SIZE,
alpha=False)
image.generated_color = (1.0, 1.0, 1.0, 1.0)
for mesh in meshes:
for material in mesh.data.materials:
if material is None or not material.use_nodes:
continue
nodes = material.node_tree.nodes
texture = nodes.new("ShaderNodeTexImage")
texture.image = image
for node in nodes:
node.select = False
texture.select = True
nodes.active = texture
for obj in bpy.context.selected_objects:
obj.select_set(False)
for mesh in meshes:
mesh.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.samples = SAMPLES
scene.cycles.use_denoising = False
scene.render.bake.margin = 4
scene.render.bake.use_clear = True
bpy.ops.object.bake(type="AO", use_clear=True)
out = LIBRARY / category / f"{name}_ao.png"
out.parent.mkdir(parents=True, exist_ok=True)
image.filepath_raw = str(out)
image.file_format = "PNG"
image.save()
print(f"baked {category}/{name} -> {out.relative_to(ROOT)}")
return True
def main(argv=None) -> int:
raw = list(argv if argv is not None else sys.argv[1:])
if "--" in raw:
raw = raw[raw.index("--") + 1:]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--only", default="", help="comma-separated asset names")
args = parser.parse_args(raw)
if bpy is None:
print("bake_ao.py must run inside Blender (flatpak)", file=sys.stderr)
return 2
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
only = {name for name in args.only.split(",") if name}
baked = 0
for name, entry in sorted(manifest["assets"].items()):
if only and name not in only:
continue
if bake_asset(name, entry["category"]):
baked += 1
print(f"baked {baked} AO maps")
return 0 if baked else 1
if __name__ == "__main__":
sys.exit(main())
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Derive tangent-space normal maps from albedo textures (Pillow only).
python3 tools/make_normal_maps.py assets/textures/ai/game_ready/brick_facade.png ...
Writes <stem>_normal.png next to each input. The height signal is the
luminance, so mortar/aggregate recesses catch the light; edges wrap for tiling.
"""
import sys
from pathlib import Path
from PIL import Image
STRENGTH = 2.0
def normal_map(source: Path) -> Path:
image = Image.open(source).convert("L")
width, height = image.size
pixels = list(image.getdata())
out = bytearray(width * height * 3)
def lum(x: int, y: int) -> float:
return pixels[(y % height) * width + (x % width)]
for y in range(height):
row = y * width
for x in range(width):
dx = (lum(x + 1, y) - lum(x - 1, y)) / 255.0
dy = (lum(x, y + 1) - lum(x, y - 1)) / 255.0
nx = -dx * STRENGTH
ny = -dy * STRENGTH
nz = 1.0
length = (nx * nx + ny * ny + nz * nz) ** 0.5
offset = (row + x) * 3
out[offset] = int((nx / length * 0.5 + 0.5) * 255)
out[offset + 1] = int((ny / length * 0.5 + 0.5) * 255)
out[offset + 2] = int((nz / length * 0.5 + 0.5) * 255)
normal = Image.frombytes("RGB", (width, height), bytes(out))
target = source.with_name(source.stem + "_normal.png")
normal.save(target)
print(f"wrote {target}")
return target
def main(argv=None) -> int:
paths = [Path(p) for p in (argv if argv is not None else sys.argv[1:])]
if not paths:
print(__doc__, file=sys.stderr)
return 2
for path in paths:
normal_map(path)
return 0
if __name__ == "__main__":
sys.exit(main())
+9
View File
@@ -54,6 +54,15 @@ class BuiltAssetTests(unittest.TestCase):
if entry["category"] == "npc_gear":
self.assertIn(entry["attach"], ba.GEAR_ATTACH, name)
def test_baked_ao_maps_exist_for_every_asset(self):
from PIL import Image
for name, entry in json.loads(
ba.MANIFEST.read_text(encoding="utf-8"))["assets"].items():
path = ba.OUT_DIR / entry["category"] / f"{name}_ao.png"
self.assertTrue(path.is_file(), path)
with Image.open(path) as image:
self.assertEqual(image.size, (256, 256), path)
def test_sources_are_hidden_from_godot(self):
self.assertTrue((ba.SRC_DIR / ".gdignore").is_file())