Skip to content

render: procedural materials, and untangle from atmospheric perspective - #13

Open
thannart wants to merge 10 commits into
dkogan:masterfrom
thannart:materials-and-atmo-refactor
Open

thannart wants to merge 10 commits into
dkogan:masterfrom
thannart:materials-and-atmo-refactor

Conversation

@thannart

Copy link
Copy Markdown

Adds --materials: a first, purely procedural land-cover approximation (no aerial imagery or real land-cover data) classifying each point by elevation (snow line) and slope steepness (bare rock), with forest below the tree line and alpine grass above it otherwise. Off by default (materials_scale uniform, the same 0.0/1.0 opt-in pattern already used for curvature and shading).

While testing --materials together with --shading, the near (foreground) terrain came out much too dark: the legacy near-color gray (a plain styling choice from the pre-materials grayscale look), the material's own color, and the slope-shading dimming were all multiplying together, crushing near, shaded forest to near black.

Restructured the near/far atmospheric blend to fix this: vertex.glsl now only computes the scalar atmo_t (0 at znear_color, 1 at zfar_color) and passes it through geometry.glsl to the fragment shader, instead of computing a mixed color early. fragment.glsl picks the near-color anchor itself: COLOR_NEAR_DEFAULT (the old gray) when materials are off, or material_color() when they're on — then blends towards the far haze color COLOR_FAR with atmo_t, so a real land-cover color is no longer also darkened by the legacy gray. The far-distance haze blend is unchanged: it's genuinely distance-dependent atmospheric scattering, so it applies the same regardless of materials.

Verified the materials=off/shading=off default path is unaffected: a pixel diff against the pre-refactor render shows only 6 out of 6.6M bytes differ, by at most 1/255 — floating-point reordering noise, not a visual regression.

Stacked on #12 (not yet merged) — this diff includes #12's commits until that lands.

🤖 Generated with Claude Code

thannart and others added 10 commits September 3, 2026 21:48
Lets you account for the fact that the viewer isn't standing at ground
level (e.g. an apartment floor), by reusing horizonator_move() to
reposition the camera at ground_elevation + viewer_height after the
usual auto-selected ground elevation is computed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…correction

The renderer worked entirely in the tangent plane at the viewer, ignoring
Earth's curvature. This is a good approximation at short range, but at
the 100-150km distances relevant for viewing the Alps from Lyon, a
distant peak's apparent elevation angle can be off by more than a
kilometer of apparent height.

Add the standard geodetic "curvature and refraction" correction:
  drop = (1-k) * distance^2 / (2*Rearth)
subtracted from each vertex's apparent height, where k is the
atmospheric refraction coefficient (k=0.13, the Gaussian refraction
coefficient used by udeuschle.de, is the default).

New horizonator_set_curvature() API, and --curvature / --refraction-k
flags on standalone. Off by default, so existing behavior is unchanged
unless --curvature is explicitly requested; this lets the flat-plane and
curved renders be compared directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n use

The renderer always built a mesh covering the full 360-degree circle of
loaded DEM data, even for a render that only ever looks at a narrow
azimuth wedge (e.g. a fixed 110-degree panorama). Most of that geometry
was never visible and cost triangle-generation/vertex-shader time every
frame for nothing.

horizonator_init() takes 3 new parameters: restrict_mesh_azimuth,
mesh_az_deg0, mesh_az_deg1. When set, only cells within that azimuth
range (plus a 5-degree margin, plus a small radius always meshed near
the viewer where per-cell azimuth changes too fast to test reliably)
are triangulated; the rest of the loaded square is skipped in the index
buffer. Vertex generation, DEM sampling and the shaders are untouched.

standalone (a single fixed-wedge render per invocation) enables this by
default, since the meshed wedge always matches the rendered wedge
exactly -- verified pixel-identical output on 3 different az/height
combinations, 36-44% faster wall-clock render time. Pass
--no-restrict-mesh-azimuth to fall back to the old full-circle mesh.

The interactive horizonator tool and the Python bindings pass
restrict_mesh_azimuth=false, since they let the viewer pan around after
the data is loaded, potentially outside whatever wedge was meshed at
init time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s the render

horizonator_project() (used by annotator.c to predict where a named POI
should land on screen) computed the apparent elevation from a raw
lat/lon/ele difference, with no Earth-curvature/refraction correction.
When a render was made with horizonator_set_curvature() enabled, the
actual terrain in that render sits lower than this flat-plane
projection predicts -- by over a kilometer of apparent height at
100+km. annotator.c's occlusion/matching search (a small pixel-radius,
range-difference fuzz match) can't bridge that gap, so with
--curvature, no POIs were ever found: every one looked occluded.

horizonator_project() now takes curvature_enabled/refraction_k and
applies the same drop = (1-k)*d^2/(2*Rearth) subtracted from the
apparent height, matching vertex.glsl. annotate() takes and forwards
the same two values, which must match whatever was passed to
horizonator_set_curvature() for the render being annotated.
horizonator_unproject() needs no equivalent change: it derives lat/lon
from the already-rendered (already curvature-corrected, if enabled)
range image, not from an independently-computed real-world height.

Also fixes query-peaks-from-osm.py, which the Overpass API was
rejecting (HTTP 406) or timing out on: switch to https, add an
identifying User-Agent and an explicit query timeout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…100km

MAX_MARKER_DIST was a #define fixed at 100000.0, independent of the
render's own --zfar. Any POI farther than 100km was silently never
labelled, even if it was actually rendered and clearly visible (e.g.
Mont Blanc, 158km from Lyon, with a --zfar of 150km+).

annotate() now takes max_marker_dist_m explicitly; standalone passes
zfar, since there's no point labelling something farther than what was
actually rendered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Switches the default look from the original blue-background/red-distance
color-coding to a udeuschle.de-style drawn panorama: a white background,
near terrain in dark gray fading to light gray/white far away, and the
crest of each visible ridge/mountain-range layer outlined in a dark gray
line.

- horizonator-lib.c: glClearColor() to white instead of blue
- vertex.glsl: rgb is now a grayscale value (GRAY_NEAR..GRAY_FAR by
  distance) on all 3 channels, instead of red-only
- standalone.c: new draw_ridge_outlines(), a CPU-side post-process on the
  already-available range image. Darkens a pixel to --ridge-line-gray
  wherever it sits at a depth discontinuity of more than
  --ridge-line-threshold meters from the pixel above it (terrain against
  the sky, or one surface abruptly replaced by a much nearer/farther one
  behind a gap). Deliberately uses an absolute meters threshold, not a
  fraction of distance: at the bottom of the frame (grazing, near-horizontal
  sightlines) a fixed threshold naturally produces more, finer ridgelets
  than near the horizon, matching what a real drawn panorama looks like up
  close. --no-ridge-lines disables this.

Also rewrites standalone --help and the corresponding README section into
a single, organized reference for every rendering option and how it
affects the output (field of view, curvature/refraction, color/ridge
lines, peak labels, viewer position, performance, data sources).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The peak-visibility-matching logic (project each POI, then scan the
rendered range image to confirm it isn't occluded) was inlined in
annotate(), entangled with the PDF/SVG drawing code -- there was no way
to get just the list of visible peaks without generating a full
annotated render.

Extracts that logic into find_visible_pois(), a new function in
annotator.c/.h that annotate() now calls internally (no behavior
change, no more duplicated code). standalone gets a new
--list-visible-peaks OUT.txt option, independent of --image, that
writes the visible peaks (name, lat, lon, ele_m, range_m) as a plain
tab-separated file.

Also fixes a latent NULL-dereference: strlen(filename_image) was called
unconditionally, before checking filename_image for NULL. This was
never reached with a NULL filename_image before (every prior path
through this code already required --image), but --list-visible-peaks
without --image hits it directly.

cluster-visible-peaks.py is a companion script: reads that output,
drops peaks with no letters in their name (OSM-nameless fallback
entries, e.g. "1583.0"), and groups the rest into geographic clusters
("massifs" -- OSM doesn't reliably tag this for this area, so these are
unnamed, distance-ordered groups, not real massif names).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds directional slope shading: terrain is darkened/lightened based on
how its surface faces relative to a light direction, giving the relief
a 3D appearance beyond the existing pure distance-based grayscale.

Normals are estimated per-vertex, on the CPU, by finite differences
over the 4 DEM neighbors of each grid point (horizonator-lib.c, in the
existing vertex-buffer-filling loop -- no extra DEM reads, the samples
are already being fetched there). They're uploaded as a second vertex
attribute (a new float VBO; the existing position VBO stays untouched,
compact GLshorts) and interpolated by the rasterizer across each
triangle. This means shading is continuous across the edge between two
triangles sharing a vertex, unlike a flat per-triangle normal, which
would show a visible facet at every triangle boundary.

Lighting itself (dot(normal,sun_dir), with a 0.5 ambient floor so a
slope facing away from the sun dims rather than goes black) happens in
fragment.glsl, using the interpolated per-fragment normal;
geometry.glsl just passes normals through unchanged, one per vertex,
alongside the existing rgb/tex passthrough.

horizonator_set_sun(ctx, shading_enabled, sun_az_deg, sun_el_deg) is a
new public function, following the same on/off + uniforms pattern as
horizonator_set_curvature(): shading is off by default (unchanged
legacy rendering) via a shading_scale uniform (0.0/1.0), same trick as
curvature_scale. standalone gets matching --shading/--sun-azimuth/
--sun-elevation flags.

Memory cost: +12 bytes/vertex (a float3 normal) for the *entire* loaded
grid (Nvertices = (2*radius_cells)^2, independent of the azimuth-wedge
mesh restriction, which only shrinks the triangle/index buffer, not the
vertex buffer) -- e.g. +~250MB at a 150km zfar. This is a one-time
allocation per render (standalone is single-shot: one init, one
render, exit), not a repeated one, so it doesn't compound the way the
known horizonator_deinit() leak does across repeated init/deinit
cycles in one process. Tested at the 150km/4000x550 reference crop
with no crash and stable memory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The distance-based color gradient (vertex.glsl) went from a dark gray
near the viewer to a light gray far away, converging on the white
background. Replaces GRAY_NEAR/GRAY_FAR with COLOR_NEAR/COLOR_FAR: near
terrain stays the same dark neutral gray, but far terrain now fades
towards a pale blue-gray instead of a neutral light gray, the way haze
and atmospheric scattering tint distant relief blue in a real photo.
Combines naturally with the slope-shading multiply in fragment.glsl
(unchanged there).

Updates the standalone --help COLOR section and the README to describe
the new blue-tinted far color instead of the old "light gray".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds --materials: a first, purely procedural land-cover approximation
(no aerial imagery or real land-cover data) classifying each point by
elevation (snow line) and slope steepness (bare rock), with forest
below the tree line and alpine grass above it otherwise. Off by
default (materials_scale uniform, 0.0/1.0 pattern already used for
curvature and shading).

While testing --materials together with --shading, the near
(foreground) terrain came out much too dark: the legacy near-color
gray (a plain styling choice from the pre-materials grayscale look),
the material's own color, and the slope-shading dimming were all
multiplying together, crushing near, shaded forest to near black.

Restructured the near/far atmospheric blend to fix this: vertex.glsl
now only computes the scalar atmo_t (0 at znear_color, 1 at
zfar_color) and passes it through geometry.glsl to the fragment
shader, instead of computing a mixed color early. fragment.glsl picks
the near-color anchor itself: COLOR_NEAR_DEFAULT (the old gray) when
materials are off, or material_color() when they're on -- then blends
towards the far haze color COLOR_FAR with atmo_t, so a real land-cover
color is no longer also darkened by the legacy gray. The far-distance
haze blend is unchanged: it's genuinely distance-dependent atmospheric
scattering, so it applies the same regardless of materials.

Verified the materials=off/shading=off default path is unaffected: a
pixel diff against the pre-refactor render shows only 6 out of
6.6M bytes differ, by at most 1/255 -- floating-point reordering
noise, not a visual regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant