stylized 1-bit rendering

This commit is contained in:
Jonas H
2026-01-21 11:04:55 +01:00
parent 5d2eca0393
commit 2422106725
40 changed files with 2859 additions and 366 deletions

View File

@@ -0,0 +1,208 @@
import bpy
import bmesh
import numpy as np
from pathlib import Path
from mathutils import Vector
def simple_blur(data, iterations=1):
"""
Simple 3x3 box blur for vector fields.
"""
result = data.copy()
h, w, c = data.shape
for _ in range(iterations):
blurred = np.zeros_like(result)
for y in range(h):
for x in range(w):
count = 0
total = np.zeros(c)
for dy in [-1, 0, 1]:
for dx in [-1, 0, 1]:
ny, nx = y + dy, x + dx
if 0 <= ny < h and 0 <= nx < w:
total += result[ny, nx]
count += 1
blurred[y, x] = total / count if count > 0 else result[y, x]
result = blurred
return result
def generate_normal_map_from_terrain(terrain_obj, resolution=1024, blur_iterations=2):
"""
Bake terrain surface normals to a texture for neighbor sampling.
Args:
terrain_obj: Blender mesh object (the terrain plane)
resolution: Output texture resolution (square)
blur_iterations: Number of smoothing passes
"""
print(f"Generating normal map from terrain mesh: {terrain_obj.name}")
print(f"Output resolution: {resolution}×{resolution}")
mesh = terrain_obj.data
bm = bmesh.new()
bm.from_mesh(mesh)
bm.verts.ensure_lookup_table()
bm.faces.ensure_lookup_table()
world_matrix = terrain_obj.matrix_world
normal_matrix = world_matrix.to_3x3().inverted().transposed()
verts_world = [world_matrix @ v.co for v in bm.verts]
normals_world = [normal_matrix @ v.normal for v in bm.verts]
if len(verts_world) == 0:
raise ValueError("Terrain mesh has no vertices")
xs = [v.x for v in verts_world]
ys = [v.y for v in verts_world]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
print(f"Terrain bounds: X=[{min_x:.2f}, {max_x:.2f}], Y=[{min_y:.2f}, {max_y:.2f}]")
normal_map = np.zeros((resolution, resolution, 3), dtype=np.float32)
sample_counts = np.zeros((resolution, resolution), dtype=np.int32)
print("Sampling vertex normals to grid...")
for v_pos, v_normal in zip(verts_world, normals_world):
u = (v_pos.x - min_x) / (max_x - min_x) if max_x > min_x else 0.5
v_coord = (v_pos.y - min_y) / (max_y - min_y) if max_y > min_y else 0.5
u = np.clip(u, 0.0, 0.9999)
v_coord = np.clip(v_coord, 0.0, 0.9999)
grid_x = int(u * resolution)
grid_y = int(v_coord * resolution)
normal_map[grid_y, grid_x, 0] += v_normal.x
normal_map[grid_y, grid_x, 1] += v_normal.y
normal_map[grid_y, grid_x, 2] += v_normal.z
sample_counts[grid_y, grid_x] += 1
mask = sample_counts > 0
for c in range(3):
normal_map[:, :, c][mask] /= sample_counts[mask]
print("Interpolating missing values...")
if not mask.all():
filled = normal_map.copy()
for _ in range(10):
smoothed = simple_blur(filled, iterations=2)
for c in range(3):
filled[:, :, c] = np.where(mask, filled[:, :, c], smoothed[:, :, c])
normal_map = filled
print("Normalizing normal vectors...")
magnitudes = np.sqrt(np.sum(normal_map**2, axis=2))
magnitudes = np.maximum(magnitudes, 1e-8)
for c in range(3):
normal_map[:, :, c] /= magnitudes
if blur_iterations > 0:
print(f"Smoothing normal field ({blur_iterations} iterations)...")
normal_map = simple_blur(normal_map, iterations=blur_iterations)
magnitudes = np.sqrt(np.sum(normal_map**2, axis=2))
magnitudes = np.maximum(magnitudes, 1e-8)
for c in range(3):
normal_map[:, :, c] /= magnitudes
print("Encoding to RGB texture (world space normals)...")
normal_encoded = normal_map * 0.5 + 0.5
normal_rgba = np.zeros((resolution, resolution, 4), dtype=np.float32)
normal_rgba[:, :, 0] = normal_encoded[:, :, 0]
normal_rgba[:, :, 1] = normal_encoded[:, :, 1]
normal_rgba[:, :, 2] = normal_encoded[:, :, 2]
normal_rgba[:, :, 3] = 1.0
normal_flat = normal_rgba.flatten()
print(f"Creating Blender image...")
output_img = bpy.data.images.new(
name="Terrain_Normal_Map",
width=resolution,
height=resolution,
alpha=True,
float_buffer=True
)
output_img.pixels[:] = normal_flat
bm.free()
return output_img
def save_normal_map(output_path, resolution=1024, blur_iterations=2, terrain_name="TerrainPlane"):
"""
Main function to generate and save normal map from terrain in current Blender file.
Args:
output_path: Path to save PNG normal map
resolution: Output texture resolution
blur_iterations: Smoothing iterations
terrain_name: Name of terrain object
"""
terrain_obj = bpy.data.objects.get(terrain_name)
if not terrain_obj:
print(f"Object '{terrain_name}' not found. Searching for terrain-like objects...")
for obj in bpy.data.objects:
if obj.type == 'MESH':
print(f" Found mesh: {obj.name}")
if 'terrain' in obj.name.lower() or 'plane' in obj.name.lower():
terrain_obj = obj
print(f" -> Using: {obj.name}")
break
if not terrain_obj:
raise ValueError(f"Object '{terrain_name}' not found and no terrain mesh detected. Available objects: {[obj.name for obj in bpy.data.objects if obj.type == 'MESH']}")
print(f"Using terrain object: {terrain_obj.name}")
if terrain_obj.type != 'MESH':
raise ValueError(f"Object '{terrain_obj.name}' is not a mesh")
normal_img = generate_normal_map_from_terrain(
terrain_obj,
resolution=resolution,
blur_iterations=blur_iterations
)
normal_img.filepath_raw = str(output_path)
normal_img.file_format = 'PNG'
normal_img.save()
bpy.data.images.remove(normal_img)
print(f"\n{'='*60}")
print(f"Normal map generation complete!")
print(f"Output: {output_path}")
print(f"Resolution: {resolution}×{resolution}")
print(f"{'='*60}")
print("\nNormal encoding:")
print(" RGB channels: World-space normal (0.5, 0.5, 0.5) = (0, 0, 0)")
print(" Decode in shader: normal = texture.rgb * 2.0 - 1.0;")
if __name__ == "__main__":
project_root = Path(bpy.data.filepath).parent.parent
output_path = project_root / "textures" / "terrain_normals.png"
output_path.parent.mkdir(parents=True, exist_ok=True)
save_normal_map(
output_path=output_path,
resolution=1024,
blur_iterations=2,
terrain_name="TerrainPlane"
)