OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / dc_erosion_color.gdshader

470 lines · gdshader ref:
shader_type spatial;

// THE SAME FIELD THE TERRAIN IS BUILT FROM, evaluated per fragment.
//
// Generated from proc-nodes/src/shared/erosion_filter.inc by
// tools/gen_terrain_noise_shaderinc.py -- not a copy, a build product. The
// material cannot CALL the density function (that runs in a compute pipeline
// on dc-terrain's own RenderingDevice; this is a spatial shader on the main
// device, and there is no runtime path between them), so the field reaches it
// as source instead.
#include "res://shaders/terrain_density.gdshaderinc"

// ── DETAIL NORMAL ───────────────────────────────────────────────────────────
//
// This is NOT new noise. The terrain surface is a function h(x,z) defined at
// every scale; the LOD-8 mesh is a coarse SAMPLING of it, not a different
// surface. Evaluating the same functions with the same constants and the same
// seed recovers the normal the geometry would have had at LOD 0 -- the same
// noise, sampled at pixel rate instead of vertex rate.
//
// ⚠ ONLY THE BAND THE MESH HAS LOST. The geometry already carries every octave
// coarser than its voxel; re-adding those would DOUBLE them. This reproduces
// the DETAIL layer specifically -- base 100 m, four octaves to ~15 m -- which
// is what disappears first, at roughly 2.8 km where the voxel passes 50 m.
// The mountain layer's finest octave is 7.4 km and the mesh never loses it.
//
// ⚠ STRENGTH IS A UNIFORM, NOT DERIVED. The chain from det.x through
// detail_frac, y_mapped and inv_yscale to world metres is exactly the kind of
// multi-stage scaling I have already got wrong once this session (the
// waterline, off by 3.4x). The FREQUENCIES here are exact -- they must be, or
// it stops being the same field -- and the amplitude is tuned by eye.
group_uniforms DetailNormal;
// ⚠ THE FIRST VERSION MULTIPLIED BY detail_freq AND WAS INVISIBLE.
// dn.yz is d(value)/d(u) with u = world*freq, i.e. ALREADY in noise units.
// Multiplying by freq again gave ~0.008 -- a half-degree tilt. And it was the
// wrong correction anyway: what is missing is the noise->METRES amplitude, not
// another frequency. One knob, absorbing all of it, tuned by eye.
uniform float density_normal_blend : hint_range(0.0, 1.0) = 1.0;
// Floor on the central-difference step, in metres. surface_normals.glsl
// documents why this cannot be tiny: evaluate_density reaches the surface
// through a large offset and a small scale, so a probe below ~1 m asks for
// a difference under one float32 ULP and the horizontal components come
// back EXACTLY 0.0 -- normals collapse to straight up on SMOOTH ground,
// where the true gradient is smallest. surface_normals.glsl measured HALF A
// VOXEL as the point where float32 still resolves it, and the voxel is 1 m,
// so 0.5 is that floor. The fade starts at a 1 m footprint, so it binds only
// where dn_amt is already near zero.
uniform float density_normal_min_eps = 0.5;
// Maps erosion_filter's ridge value (-1 creases .. +1 ridges) onto the
// material's 0..1 curvature. 0.5 uses the full range; lower flattens toward
// neutral. Watch it with detail_normal_debug = 2.
uniform float density_curvature_gain : hint_range(0.0, 2.0) = 0.5;
// 1 = show the detail normal directly as colour. Distinguishes "too weak" from
// "not running at all" -- a distinction I guessed at once already.
uniform int detail_normal_debug = 0;
// Fades IN with distance: near, the geometry already carries this detail and
// adding it would double it; far, the geometry has lost it and this replaces
// it. Same field either side, so the handover has nothing to pop.
// COARSENESS = voxel size / pixel footprint, so these are ratios, not metres.
// 1.0 is the break-even point -- one voxel per pixel. Fully on by 2.5, i.e.
// once a voxel spans a couple of pixels and the facets would start to show.
// Angle one pixel subtends, radians. 0.0011 ~ 70 deg vertical FOV at 1080p.
// A uniform rather than derived from PROJECTION_MATRIX -- see fragment().
uniform float density_normal_px_angle = 0.0011;
uniform float density_normal_fp_lo = 1.0;
uniform float density_normal_fp_hi = 2.5;
// ⚠ STOPGAP (hyper-games #108 perf, 2026-10-07): OFF WHERE THE UNDERSEA TERM IS ON.
// terrain_density.gdshaderinc was generated before the Geiss undersea term existed,
// so below the coast band it evaluates the erosion field + the OLD submarine fBm --
// a field the seabed geometry no longer follows -- four times per pixel. That was
// both the dominant cost underwater (seabed in view: ~60 ms GPU at 1080p) and the
// wrong detail. Fades out with the generator's own weight
//   ud_w = smoothstep(coast_start, coast_full, water_y - y)
// (dc_terrain_generator_procnodes.cpp _undersea_term), so it is off exactly where
// the geometry stops being the field this include evaluates. Set by the map only
// when the undersea term installed. Remove once the detail normal comes from the
// mesher's real density (per-chunk bake).
uniform bool undersea_detail_off = false;
uniform vec3 undersea_band = vec3(700.0, 30.0, 50.0);   // water_y, coast_start, coast_full (m)
// DETAIL_SCALE / TERRAIN_HEIGHT from map_dc_erosion.gd (600 / 60000). Set from
// the map so the two cannot drift.

// CURVATURE arrives per-vertex in CUSTOM1.x — qef_solve's horizontal Laplacian
// of the density grid, in metres. This is the same quantity terrain-lookdev
// computes (`lap = h[i-1]+h[i+1]+h[i-n]+h[i+n] - 4*h[i]`), not the erosion
// filter's ErosionResult.ridge: for a heightfield-derived density the
// horizontal Laplacian of d IS the Laplacian of h.
//
// It replaces the screen-space dFdx/dFdy approximation, which was per-2x2-quad
// on a per-face-normal mesh and so produced facets and saturated creases.
varying float v_curv;
varying float v_vox;

// Altitude + slope coloring for DC terrain.
//
// No varyings, no vertex() — NVIDIA driver 590.48 workaround.
// World position via INV_VIEW_MATRIX * VERTEX.
// World normal via INV_VIEW_MATRIX * NORMAL.
// Works identically under rasterization and pathtracing.

uniform vec3 terrain_params = vec3(0.0, 10.0, 0.465);

// LOOKDEV MODES — 0 = the game material, 1 = the lookdev's 3D material,
//                 2 = the lookdev's 2D hypsometric map ramp.
//
// Exists so the engine and terrain-lookdev can be compared BY EYE. Every gate
// here reports a number, and a number has not once said whether the terrain
// looks right.
//
// ⚠ MODE 1 IS THE ONE THAT MATCHES THE 3D PANE. A first version of this ported
// server.py's LAND_STOPS/SEA_STOPS — the 2D MAP tint — which is a different
// scheme entirely and rendered near-white. Mode 2 keeps it, because a top-down
// hypsometric comparison is still worth having; it is just not the 3D view.
uniform int color_mode = 0;
// The FIELD's range in metres, for the altitude attribute. The lookdev
// normalises to each TILE's min/max; in engine that would make the material
// change as you walk, so this is the field's GLOBAL range, measured over all
// 841 cached tiles: -4829 .. 5110 m.
// THE ALTITUDE RANGE the material is normalised over.
//
// ⚠ THE LOOKDEV USES THE DISPLAYED TILE'S OWN min/max, and that is most of why
// the colours did not match. Against the global field range (-4829..5110 over
// all 841 cached tiles) terrain at 0-1200 m lands in t = 0.49..0.60 — a thin
// slice of the ramp — so everything reads as one shade. The lookdev spreads
// that same terrain across the whole ramp.
//
// So this is the BLOCK's range, not the field's: -4226..2680, which the engine
// prints on load as "custom data uploaded ... range -4226..2680". Constant
// across the block, so no seam within it.
//
// ⚠ When the single block becomes many, per-block ranges WOULD seam — adjacent
// blocks of different relief would tint differently at the join. The answer
// then is one range for the whole field, and the material accepting that it
// uses less of the ramp than the lookdev does.
uniform vec2 field_range = vec2(-4230.0, 2782.0);
// Screen-space curvature is resolution dependent; this scales it into 0..1.
// CURVATURE GAIN — 0 DISABLES the curvature attribute entirely.
//
// Default 0, because screen-space curvature is the wrong instrument here and
// it dominated everything when it was on. dFdx/dFdy are evaluated per 2x2
// pixel quad, and a dual-contoured mesh has PER-FACE normals: the derivative
// is zero inside a facet and spikes at every edge. The result is visibly
// faceted, and because the spikes drive k to 0 in creases — where the material
// applies BOTH of its darkening terms, `wet` and `ao` — gullies came out
// nearly black.
//
// At 0 the material is altitude + slope only. terrain-lookdev says that is
// "monotone", and it is right, but monotone is closer to the reference than
// saturated noise is.
//
// THE REAL FIX is the erosion filter's own ErosionResult.ridge — "-1 in
// creases, +1 on ridges. Free; drives materials." The generator already
// computes it per sample and discards it; plumbing it to the mesh as a vertex
// attribute is what makes this attribute correct rather than approximated.
uniform float curvature_gain = 0.25;

// SLOPE IS A LOD-DEPENDENT SIGNAL, AND THIS IS WHERE THAT STOPS MATTERING.
//
// The mesh normal is a central difference over HALF A VOXEL
// (edge_intersect.glsl: `float eps = voxel_size * 0.5;`), and that step is not
// a choice -- the file carries a measured float32 table showing n.x collapses
// to EXACTLY 0.0 at eps = 0.01 voxel. So the normal is a low-pass of the
// surface at the LOD's own scale.
//
// map_dc_erosion.gd records voxel size ~ distance / 56, so the normal is
// averaged over ~distance/112 metres: 0.9 m at 100 m, 89 m at 10 km. Slope
// therefore reads systematically FLATTER with distance and every LOD boundary
// shifts the colour bands -- the "colouring depends on the LOD" artifact.
//
// Fading the slope term out with distance is not a workaround: the signal is
// genuinely meaningless out there, and using a meaningless signal is the
// defect. Altitude (world Y) is exact at every LOD, so distant terrain is
// tinted by altitude alone and stops banding.
//
// ⚠ THIS IS A MITIGATION, NOT THE FIX. The durable answer is a LOD-INVARIANT
// slope carried per-vertex from the generator's own analytic gradient (which
// is scale-free and already computed). That needs edge_data widened from 8 to
// 9 floats -- slot 7 is taken by hit_brush|hit_mat -- so it is a buffer-layout
// change, not a shader one.
// ⚠ THE FIRST VALUES WERE 1500/12000 AND THAT WAS A REGRESSION, NOT A FIX.
// The far plane is 200 km and you routinely see 100+ km of this world, so a
// fade completing at 12 km pinned essentially the WHOLE view at the constant
// slope -- one flat olive colour edge to edge. Trading LOD banding for no
// variation at all is a worse picture, and it is the picture the screenshot
// showed.
//
// Sized from the LOD instead of guessed. map_dc_erosion.gd records voxel
// size ~ distance / 56, and the normal averages over voxel/2, so the scale
// the normal represents is ~distance/112 m. Slope stays trustworthy while
// that is small against the features being shaded -- the mountain layer's
// finest octave is 7.4 km at MOUNTAIN_SCALE 0.1, so the normal is still
// resolving real structure out to tens of km.
//
// 25 km -> 150 km: full detail across everything you can walk to, fading only
// where the voxel is >200 m and the normal genuinely is an average of a
// mountainside.
uniform float slope_lod_fade_start = 25000.0;   // m: slope fully trusted below
uniform float slope_lod_fade_end   = 150000.0;  // m: slope fully ignored above

// The JS sstep, which unlike GLSL smoothstep accepts a > b (used for `wet`).
#include "res://shaders/terrain_material.gdshaderinc"

const vec3 CLIFF_COLOR = vec3(0.22, 0.2, 0.2);
const vec3 DIRT_COLOR = vec3(0.55, 0.45, 0.35);
const vec3 GRASS_COLOR1 = vec3(0.15, 0.3, 0.1);
const vec3 GRASS_COLOR2 = vec3(0.4, 0.5, 0.2);
const vec3 SAND_COLOR = vec3(0.76, 0.68, 0.56);
const vec3 WATER_COLOR = vec3(0.0, 0.15, 0.3);
const vec3 SNOW_COLOR = vec3(0.95, 0.97, 1.0);
const vec3 SHORE_COLOR = vec3(0.0, 0.25, 0.25);

void vertex() {
	v_curv = CUSTOM1.x;
	// .y is this chunk's voxel size, written unconditionally by
	// DCTerrain::_upload_cell. It is how the material learns how coarse the
	// geometry beneath it is.
	v_vox = CUSTOM1.y;
}

void fragment() {
	float floor_y = terrain_params.x;
	float ceiling_y = terrain_params.y;
	float water_height = terrain_params.z;

	vec3 world_pos = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
	vec3 world_normal = normalize((INV_VIEW_MATRIX * vec4(NORMAL, 0.0)).xyz);

	float h = (world_pos.y - floor_y) / max(ceiling_y - floor_y, 0.01);
	float ny = world_normal.y;

	// ⚠ NO `return` IN fragment(). Godot's shading language forbids it:
	//
	//   SHADER ERROR: Using 'return' in the 'fragment' processor function is
	//   incorrect.
	//
	// and the whole shader then fails to compile, so the terrain renders with
	// the DEFAULT material — flat white. That is what "the lookdev colours are
	// just white" was, twice, and neither the ramp nor the tonemapping had
	// anything to do with it.
	//
	// It is invisible to `godot --headless --import`, which does not compile
	// shaders. The only check that can fail here is running the map.
	if (color_mode == 1) {
		// THE 3D PANE'S MATERIAL, as ALBEDO under the scene's own sun —
		// terrain-lookdev's 3D view is lit with a sun and cast shadows, so this
		// is an albedo, not emission.
		//
		// ⚠ CURVATURE IS SCREEN-SPACE and so view dependent. The lookdev takes
		// a Laplacian over the heightfield and normalises by its 95th
		// percentile; a fragment shader has no neighbours, so this reads the
		// world normal's screen derivatives. Same SIGN — the crease/ridge split
		// the material turns on — but not the same magnitude, and the balance
		// shifts with distance. The exact route is the erosion filter's own
		// ErosionResult.ridge, which the generator computes and discards.
		float t = clamp((world_pos.y - field_range.x)
		                / max(field_range.y - field_range.x, 0.01), 0.0, 1.0);
		float ny_c = clamp(abs(world_normal.y), 0.001, 1.0);
		float slope = sqrt(max(1.0 - ny_c * ny_c, 0.0)) / ny_c;   // tan = m/m
		// length(VERTEX) is view-space distance: VERTEX is in view space here.
		float lod_trust = 1.0 - smoothstep(slope_lod_fade_start,
		                                   slope_lod_fade_end, length(VERTEX));
		// Fade toward a REPRESENTATIVE slope, not toward zero: zero would make
		// everything distant read as flat meadow, which is a different lie.
		// 0.32 is the map's own measured RMS slope at 100 m (MOUNTAIN_GAIN
		// note in map_dc_erosion.gd).
		slope = mix(0.32, slope, lod_trust);
		// k = 0.5 is "neither crease nor ridge", which switches the attribute
		// off cleanly rather than leaving it wrong.
		// k: 0 = crease, 1 = ridge, per the lookdev's own mapping
		//     k = clamp01(curv / (p95 * 2) + 0.5)
		// It normalises by the 95th percentile of |lap| over the displayed
		// tile; that is not available per chunk, so curvature_gain stands in
		// for 1/(2*p95) and is tunable while looking at the result.
		//
		// ⚠ SIGN. The lookdev's COMMENTS say creases are wet and shadowed, but
		// its ARITHMETIC gives a valley a POSITIVE Laplacian and therefore a
		// HIGH k — which is no wet and full AO, i.e. bright valleys. This
		// follows the arithmetic, because that is what produced the image being
		// matched. Negate curvature_gain to test the other reading.
		// Curvature is the density-grid Laplacian, so it is scale-dependent
		// for the same reason. 0.5 is its neutral value (neither crease nor
		// ridge), which is the honest thing to show where it cannot be known.
		float k = clamp(0.5 + curvature_gain * v_curv * lod_trust, 0.0, 1.0);

		// ---- DENSITY NORMAL, faded IN with distance ------------------
		// THE REAL DENSITY FUNCTION, not a stand-in for it. terrain_density
		// .gdshaderinc is generated from
		// DCTerrainGeneratorProcNodes::get_compiled_glsl() -- the same text the
		// GPU mesher compiles -- so the material cannot drift from the geometry.
		//
		// The previous version reproduced the DETAIL layer, which is uniform
		// 100 m fBm, and looked like noise because that is what it is. The
		// structure worth seeing at distance lives in the erosion filter, and
		// the erosion filter needs the mountain layer's height AND gradient as
		// input -- i.e. essentially the whole density stack. So evaluate it.
		//
		// FOUR TAPS, NOT SIX. proc_density returns pos.y - query_y, so the
		// field is exactly H(x,z) - y and dd/dy is analytically -1 everywhere.
		// The two vertical taps were spent rediscovering a constant. Measured
		// fullscreen at 1080p on a 2070: 16.4 ms at six taps, 10.0 ms at four.
		//
		// NOT ANALYTIC. erosion_node_eval_grad returns a slope for free and
		// pn_out_grad carries it here, but it is approximate by construction --
		// erosion_filter drops d(combi_mask)/dp and d(fade_target)/dp, both of
		// which it REPLACES every octave. Measured against central differences:
		// 29.2 deg mean error, rising 5.5 -> 29.2 as octaves go 1 -> 7. See
		// dc-terrain/tests/test_analytic_gradient.cpp.
		vec3 dbg_n = vec3(0.0);
		bool dbg_on = false;
		// ON WHEREVER THE MESH IS COARSER THAN A PIXEL. Every LOD but the
		// finest, in practice -- which is the whole of it.
		//
		// Two earlier criteria were both proxies and both wrong. DISTANCE (1500
		// -> 9000 m) breaks the moment the camera climbs: flying high, ground 2 km
		// away is drawn from coarse chunks but sits below the fade start, so it
		// kept the faceted per-vertex curvature while the horizon looked right.
		// PIXEL FOOTPRINT alone is better but still infers the chunk's resolution
		// from camera distance, which is the thing that just stopped agreeing.
		//
		// v_vox is the chunk's actual voxel size, so the ratio is the question
		// itself: at 1 a voxel covers one pixel and the mesh carries everything
		// the screen can show; past that it is under-detailed and the field wins.
		// Adapts to FOV and resolution for free, because both are already in fp.
		// ⚠ NOT fwidth(). fwidth measures the screen-space footprint INCLUDING
		// foreshortening, so on ground viewed at a grazing angle one pixel spans
		// tens of metres along the view direction and the ratio below collapses --
		// switching the effect off exactly where you are looking ACROSS the
		// terrain, which is most of the near field. Detail perpendicular to the
		// grazing direction is still perfectly resolvable; length(fwidth()) cannot
		// see that, and foreshortening should not decide detail level anyway.
		//
		// ⚠ DERIVED px_angle FROM PROJECTION_MATRIX ONCE AND IT KILLED THE WHOLE
		// EFFECT. 2.0 / (PROJECTION_MATRIX[1][1] * VIEWPORT_SIZE.y) is the right
		// arithmetic, but the guard on it read near zero in this pass, so px_angle
		// came out ~20000, fp was enormous, coarseness collapsed and the field
		// never engaged anywhere. A built-in whose value in THIS pass has not been
		// verified is not worth the FOV-tracking it buys; the angle is a uniform.
		//
		// MIN OF TWO ESTIMATES. fwidth is honest about resolution but includes
		// foreshortening, so grazing ground reports tens of metres; the angular
		// estimate ignores orientation entirely. The resolvable scale is the
		// SMALLER -- across a grazing surface detail is still resolvable even
		// though along it, it is not.
		float fp = max(min(length(fwidth(world_pos)),
		                   length(VERTEX) * density_normal_px_angle), 1e-4);
		float coarseness = v_vox / fp;
		float dn_amt = smoothstep(density_normal_fp_lo, density_normal_fp_hi, coarseness)
		             * density_normal_blend;
		if (undersea_detail_off) {
			dn_amt *= 1.0 - smoothstep(undersea_band.y, undersea_band.z, undersea_band.x - world_pos.y);
		}
		if (dn_amt > 0.001) {
			// eps AND the octave limit are both the pixel footprint, so the
			// field is sampled at exactly the rate the screen can show: no
			// aliasing, and nyquist_octaves stops adding octaves that would
			// land below a pixel. Cost FALLS with distance rather than rising.
			float vs = max(fp, density_normal_min_eps);
			float ridge = 0.0;
			vec2 grad = vec2(0.0);
			float dxp = evaluate_density(world_pos.x + vs, world_pos.y, world_pos.z, vs, ridge, grad);
			float dxm = evaluate_density(world_pos.x - vs, world_pos.y, world_pos.z, vs, ridge, grad);
			float dzp = evaluate_density(world_pos.x, world_pos.y, world_pos.z + vs, vs, ridge, grad);
			float dzm = evaluate_density(world_pos.x, world_pos.y, world_pos.z - vs, vs, ridge, grad);
			float dx = dxp - dxm;
			float dz = dzp - dzm;
			vec3 wn = normalize(vec3(-dx, 2.0 * vs, -dz));
			// Blend, not replace: near the fade start the mesh normal is still
			// the better one, and a hard switch would show as a ring.
			wn = normalize(mix(world_normal, wn, dn_amt));
			world_normal = wn;
			NORMAL = normalize((VIEW_MATRIX * vec4(wn, 0.0)).xyz);
			// Recompute slope from the perturbed normal so the COLOUR follows
			// the detail too -- that is the whole "colour derived from the
			// normal map" property, and it costs two lines.
			float ny2 = clamp(abs(wn.y), 0.001, 1.0);
			slope = sqrt(max(1.0 - ny2 * ny2, 0.0)) / ny2;

			// CURVATURE TOO, OR THE COLOUR STAYS FACETED.
			//
			// lookdev_material takes three inputs -- altitude, slope, curvature --
			// and overriding only the first two left k reading v_curv, which is
			// CUSTOM1.x, a per-VERTEX mesh attribute. On a coarse chunk one
			// triangle spans kilometres, so k is near constant across it and the
			// colour came out in flat polygonal patches with triangle edges, over
			// normals that were correctly detailed. Fixing the normal could not fix
			// that, because the geometry was never in the normal.
			//
			// ⚠ NOT A LAPLACIAN. The obvious move is a second difference of the
			// four taps, and it does not work: second differences amplify high
			// frequencies, so the 100 m detail layer swamps the erosion structure
			// and k comes out as salt-and-pepper everywhere det_mask is not zero.
			// Rendered and looked at before shipping it -- it was unusable.
			//
			// erosion_filter already publishes the thing itself: r.ridge, "-1 in
			// creases, +1 on ridges. Free; drives materials." It is structural
			// rather than numerical, it costs nothing, and pn_out_grad's egress was
			// already carrying it out. `ridge` here holds the value from the last
			// tap, half a pixel footprint away -- below the scale anything here
			// resolves.
			float k_field = clamp(0.5 + density_curvature_gain * ridge, 0.0, 1.0);
			k = clamp(mix(k, k_field, dn_amt), 0.0, 1.0);

			dbg_n = wn;
			dbg_on = true;
		}

		// Debug AFTER the material, or lookdev_material overwrites it -- which
		// is exactly what the first version of this did.
		if (detail_normal_debug == 3) {
			// WHERE IS IT ON? black = mesh normal, white = density field. Answers
			// "is the gate firing where I think" without another round of guessing.
			ALBEDO = vec3(dn_amt);
			ROUGHNESS = 1.0;
		} else if (detail_normal_debug == 4) {
			// Chunk voxel size, 1 m black .. 256 m white. If this is flat black the
			// CUSTOM1.y channel is not arriving and the gate can never fire.
			ALBEDO = vec3(log2(max(v_vox, 1.0)) / 8.0);
			ROUGHNESS = 1.0;
		} else if (detail_normal_debug == 2 && dbg_on) {
			// Curvature alone: black creases, white ridges. If THIS is faceted the
			// colour will be, whatever the normal looks like.
			ALBEDO = vec3(k);
			ROUGHNESS = 1.0;
		} else if (detail_normal_debug == 1 && dbg_on) {
			ALBEDO = dbg_n * 0.5 + 0.5;
			ROUGHNESS = 1.0;
		} else {
			ALBEDO = lookdev_material(t, slope, k);
		}
		ROUGHNESS = 0.95;
	} else if (color_mode == 2) {
		// The 2D hypsometric MAP tint, ramp and hillshade, effectively unlit.
		// A different question from mode 1: top-down legibility, not what the
		// terrain looks like in the world.
		float sh = clamp(0.45 + 0.75 * ((world_normal.x + world_normal.z) * 0.5 + 0.5),
		                 0.0, 1.35);
		ALBEDO = vec3(0.0);
		EMISSION = hypso_ramp(world_pos.y) * (0.30 + 0.70 * sh);
		ROUGHNESS = 1.0;
	} else {
		float steepness = 1.0 - smoothstep(0.5, 0.85, ny);
		vec3 color = CLIFF_COLOR;

		float dirt_mask = smoothstep(0.3, 0.7, steepness) * (1.0 - smoothstep(0.85, 1.0, steepness));
		color = mix(color, DIRT_COLOR, dirt_mask);

		float above_water = smoothstep(water_height, water_height + 0.03, h);
		float below_snow = 1.0 - smoothstep(0.7, 0.82, h);
		float flat_enough = smoothstep(0.7, 0.9, ny);
		vec3 grass = mix(GRASS_COLOR1, GRASS_COLOR2, smoothstep(0.4, 0.65, h));
		color = mix(color, grass, above_water * below_snow * flat_enough);

		float snow_altitude = smoothstep(0.7, 0.85, h);
		float snow_slope = smoothstep(0.55, 0.8, ny);
		color = mix(color, SNOW_COLOR, snow_altitude * snow_slope);

		float shore_band = smoothstep(water_height + 0.02, water_height, h)
		                  * smoothstep(water_height - 0.02, water_height, h);
		color = mix(color, SAND_COLOR, shore_band * flat_enough);

		if (h < water_height - 0.005) {
			float depth = water_height - h;
			float shore = exp(-depth * 40.0);
			color = mix(WATER_COLOR, SHORE_COLOR, shore);
		}
		ALBEDO = color;
		ROUGHNESS = mix(0.9, 0.6, steepness);
	}
}