161 lines
6.1 KiB
Python
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()
|