Bake library AO and lift environment textures
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:
1 parent
d8cfddbf89
commit
d872034388
78 files changed
+390
-28
No files matched your search
@@ -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())
|
||||
Reference in new issue
Block a user