Source code for bruhanimate.bruheffect.terrain_effect

"""Perspective voxel-space flyover of a seeded, periodic height field."""

import numpy as np

from ..bruhutil import Buffer
from ._fields import Field, paint, sample
from ._resize import raster
from .base_effect import BaseEffect
from .settings import TerrainSettings, validated_settings


[docs] class TerrainEffect(BaseEffect): """Coherent hills projected into a moving perspective view. A bounded seeded noise tile supplies four octaves. Travel wraps through that tile; resize preserves the tile and camera position and repaints at once. """
[docs] def __init__( self, buffer: Buffer, background: str, settings: TerrainSettings | None = None ) -> None: s = validated_settings(settings, TerrainSettings) super().__init__(buffer, background, seed=s.seed) self._settings = TerrainSettings(**vars(s)) self._noise = self.np_rng.random((128, 128)) self._travel = 0.0 self._paint()
def _height(self, x: Field, z: Field) -> Field: result = np.zeros_like(x) for octave in range(4): frequency = self._settings.scale * 2**octave # Smoothstep interpolation removes angular ridges between grid cells. xx, zz = x * frequency, z * frequency ix, iz = np.floor(xx), np.floor(zz) fx, fz = xx - ix, zz - iz result += ( sample( self._noise, ix + fx * fx * (3 - 2 * fx), iz + fz * fz * (3 - 2 * fz), wrap=True, ) / 2**octave ) return np.clip((result / 1.875 - 0.5) * 2.2 + 0.5, 0, 1) def _paint(self) -> None: s = self._settings h, w = self.buffer.height(), self.buffer.width() surface = np.zeros((h, w), dtype=float) occlusion = np.full(w, h, dtype=int) rays = np.linspace(-1, 1, w) * max(0.5, w / max(1, h * 2)) depths = np.geomspace(2, 45, 72) world_x = rays[None, :] * depths[:, None] world_z = np.broadcast_to(self._travel + depths[:, None], (len(depths), w)) samples = self._height(world_x, world_z) # Directional light reveals slopes instead of drawing flat elevation bands. slope = self._height(world_x + 0.3, world_z + 0.3) - samples lighting = np.clip(0.65 - slope * 5, 0.2, 1) for depth, elevations, light in zip(depths, samples, lighting): projected = np.clip( ( h * s.horizon + (1.1 - elevations * s.roughness * 1.5) * h * 4 / depth ).astype(int), 0, h, ) shades = 0.15 + light * 0.7 if s.style == "contours": shades = np.where( (elevations * 30) % 1 < 0.25, 0.95, 0.25 + elevations * 0.35 ) shades *= 1 - depth / 100 for x in np.flatnonzero(projected < occlusion): surface[projected[x] : occlusion[x], x] = shades[x] occlusion = np.minimum(projected, occlusion) paint( self.buffer, surface, (22, 28, 64, 100, 136, 180, 230, 255), color=s.color, background=self.background, )
[docs] def update(self, dt: float) -> None: self._travel = (self._travel + self._settings.speed) % ( 128 / self._settings.scale ) self._paint()
[docs] def resize(self, size: tuple[int, int]) -> None: self.buffer = raster(self.buffer, size, self.background) self._paint() super().resize(size)