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