Files
PythonInUnreal/archive/legacy_python/generate_terrain_from_heightmap.py
T
2026-10-31 17:03:41 +01:00

161 lines
6.1 KiB
Python

"""Run inside Unreal (Python console / Execute Python Script).
Same pipeline as generate_procedural_terrain.py, but the height field comes
from an actual image (e.g. DemoAssets/poland_shaded.png) instead of procedural
noise. Reads heightmap_config.json (next to this script).
By default darker pixels = higher elevation and brighter pixels = lower, to
match shaded-relief style maps (set "invert": false in the config for the
opposite, standard-heightmap convention where white = high).
If the image has an alpha channel, pixels with alpha below "alpha_threshold"
(0-1, default 0.5) are cut out of the terrain mesh entirely (real holes, not
just low ground) and are excluded from foliage scatter/placement - useful for
a heightmap shaped like a country/island silhouette with a transparent
background.
Run import_environment_assets.py first so the mesh paths referenced in the
config actually exist. Set the HEIGHTMAP_CONFIG env var to point at a
different config file.
"""
import os, json, unreal
import terrain_common as tc
from png_reader import decode_png
CONFIG_PATH = os.environ.get(
"HEIGHTMAP_CONFIG",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "heightmap_config.json"),
)
class HeightmapField:
def __init__(self, cfg):
image_path = cfg["image_path"]
print(f"Decoding heightmap image: {image_path}")
width, height, channels, pixels = decode_png(image_path)
print(f" {width}x{height}, {channels} channel(s)")
self.width = width
self.height_px = height
self.invert = cfg.get("invert", True) # darker = higher, by default (shaded-relief convention)
self.height_scale = cfg["height_scale"]
self.size_x = cfg["size_x"]
self.size_y = cfg["size_y"]
self.alpha_threshold = cfg.get("alpha_threshold", 0.5)
self.exclude_white = cfg.get("exclude_white", True)
self.white_threshold = cfg.get("white_threshold", 0.98)
self._lum = self._build_luminance(pixels, width, height, channels)
self._alpha = self._build_alpha(pixels, width, height, channels)
self._min_channel = self._build_min_channel(pixels, width, height, channels)
@staticmethod
def _build_luminance(pixels, width, height, channels):
count = width * height
lum = [0.0] * count
if channels <= 2:
for i in range(count):
lum[i] = pixels[i * channels] / 255.0
else:
for i in range(count):
o = i * channels
lum[i] = (0.299 * pixels[o] + 0.587 * pixels[o + 1] + 0.114 * pixels[o + 2]) / 255.0
return lum
@staticmethod
def _build_min_channel(pixels, width, height, channels):
"""Per-pixel min(R,G,B) (or the sole channel, if grayscale) in [0,1] -
used to detect "fully white" fill pixels regardless of luminance
formula quirks (e.g. pure yellow has high luminance but isn't white)."""
count = width * height
arr = [0.0] * count
if channels <= 2:
for i in range(count):
arr[i] = pixels[i * channels] / 255.0
else:
for i in range(count):
o = i * channels
arr[i] = min(pixels[o], pixels[o + 1], pixels[o + 2]) / 255.0
return arr
@staticmethod
def _build_alpha(pixels, width, height, channels):
# Only grayscale+alpha (2) and RGBA (4) carry an alpha channel; anything
# else is fully opaque, so there is nothing to mask out.
if channels not in (2, 4):
return None
count = width * height
alpha = [0.0] * count
last = channels - 1
for i in range(count):
alpha[i] = pixels[i * channels + last] / 255.0
return alpha
def _world_to_image(self, x, y):
# Map world (x, y) in [-size/2, size/2] to continuous image space, with
# +Y world mapped toward row 0 (image top), matching a north-up map.
u = (x + self.size_x / 2.0) / self.size_x
v = (y + self.size_y / 2.0) / self.size_y
u = min(max(u, 0.0), 1.0)
v = min(max(v, 0.0), 1.0)
return u * (self.width - 1), (1.0 - v) * (self.height_px - 1)
def _sample_bilinear(self, channel_array, x, y):
px, py = self._world_to_image(x, y)
x0, y0 = int(px), int(py)
x1, y1 = min(x0 + 1, self.width - 1), min(y0 + 1, self.height_px - 1)
fx, fy = px - x0, py - y0
def S(xx, yy):
return channel_array[yy * self.width + xx]
s00, s10, s01, s11 = S(x0, y0), S(x1, y0), S(x0, y1), S(x1, y1)
s0 = s00 + (s10 - s00) * fx
s1 = s01 + (s11 - s01) * fx
return s0 + (s1 - s0) * fy
def height(self, x, y):
lum = self._sample_bilinear(self._lum, x, y)
if self.invert:
lum = 1.0 - lum
return lum * self.height_scale
def normal(self, x, y, step=200.0):
return tc.finite_difference_normal(self.height, x, y, step)
def is_masked(self, x, y):
if self._alpha is not None and self._sample_bilinear(self._alpha, x, y) < self.alpha_threshold:
return True
if self.exclude_white and self._sample_bilinear(self._min_channel, x, y) >= self.white_threshold:
return True
return False
def main():
with open(CONFIG_PATH, "r") as f:
config = json.load(f)
terrain_cfg = config["terrain"]
field = HeightmapField(terrain_cfg)
base_folder = "HeightmapTerrain"
print("Building terrain mesh from heightmap...")
static_mesh, bounds = tc.build_terrain_mesh(terrain_cfg, field)
loc = terrain_cfg["location"]
origin = unreal.Vector(loc[0], loc[1], loc[2])
tc.spawn_terrain_actor(static_mesh, origin, base_folder, "HeightmapTerrain")
print("Scattering foliage layers...")
for layer_cfg in config.get("scatter_layers", []):
tc.scatter_layer(layer_cfg, field, bounds, origin, base_folder)
explicit_entries = config.get("explicit_placements", [])
if explicit_entries:
print("Placing explicit manifest entries...")
tc.place_explicit(explicit_entries, field, origin, base_folder)
print("Done.")
main()