209 lines
6.6 KiB
Python
209 lines
6.6 KiB
Python
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"
|
||
)
|