RFL cubemap terrain — C99 prototype
One planet field supplies the space view, 256×256 surface chunks, biome/material maps, and multiplayer chunk addresses. The core uses C99 and libm. The optional bounded streaming cache uses POSIX threads. No C++ or external constraint solver is required.
This is a working generator and streaming module, with a command-line tool and tests. It has not been wired into or deployed with the live RFL game. The old surface renderer’s wrapped-map addressing needs to be replaced for continuous travel through these chunks; changing its height callback alone is insufficient.
Build and try it
make
make test
make sweep
./terrain --preset balanced --out out \
--set seed=42 --set surface_lod=6 \
--chunk +Z 6 22 52 --chunk +Z 6 23 52
./stream_demo out/world.ctw
./terrain --knobs
# Regional rules inspired by the paired region/height reference images:
./terrain --preset regional --out regional --diagnostics \
--set walk_coherence=0.85 --set broken_roughness=1.8 \
--chunk +Z 6 22 52
The default build uses -std=c99 -O2 -Wall -Wextra -Werror -pedantic.
The core can also be compiled into a C++ application through its extern "C"
header. Emscripten can use the core without the pthread cache; schedule chunk
generation in a Web Worker in that environment.
samples/ includes a built world and sixteen adjoining chunks on face +Z,
LOD 6, x=22..29 and y=52..53. Each owns exactly 256×256 samples/cells. The
additional positive edge row and column in .ctc files support interpolation.
Each .u16le file is exactly 131,072 bytes, containing only the 256×256 core.
previews/correspondence.png shows the planet, the chosen face, the actual chunk
outputs, and a height transect. previews/presets.png compares the six presets.
previews/regions.png compares region labels, elevation, and surface colors.
previews/walk_coherence.png compares IID and coherent increments with the
same mask and seed. Recreate these with python3 examples/reference_preview.py.
The preview scripts need Pillow and NumPy; the generator needs neither.
Generation model
The broad terrain is solved at world load, over one connected graph covering all six cube faces. Edge and corner vertices are shared. Chunk requests never run a separate coastline, lake, or slope solve.
- Evaluate smoothly blended region weights and a warped 3D fBm field on sphere
directions to obtain a signed land mask. Optional fine coastline noise is
stronger in rough regions. A
ct_shape_fncan supply a signed mask instead. - Find connected water bodies across face boundaries. The largest is ocean; sufficiently small other components are lakes. This size test uses vertex count, not exact equal-area surface measurements.
- Temporarily fill lakes and solve signed additive walks from coastal nodes. Each coast vertex has its own provenance. A candidate collision must have opposing arrival directions and sufficiently separated source locations.
- Set each lake’s provisional level to the minimum first-pass height over its footprint. If slope constraints are enabled, lower levels as necessary to make the combined water/shore constraints feasible.
- Solve again with sea and lake shores seeded simultaneously. Collision values are SUMs of distinct incoming, uncombined heights relative to sea level. Collisions are terminal: their summed values are never walking parents.
- Mix in the requested fractal/ridged relief away from pinned water boundaries.
- Project the macro heights onto the graph slope constraints, preserving the fitted shore elevations. Then freeze the macro field.
- Generate chunks by sampling that field plus globally addressed fine noise. Assign water, sand, grass, forest, exposed rock, and snow from the same height/mask/moisture fields used by the space view.
This is a Schmidt-inspired reconstruction, not Loren Schmidt’s source code or
a port of the unavailable undulate2.c. The primary reference is her
Inverse Terrain Solver talk, Roguelike Celebration 2024,
especially 15:59–18:07 and 28:51–31:50. The source provenance identity, collision
predicate, exact lake minimum, relative-height arithmetic for different lake
baselines, and raster junction closure remain our explicit implementation choices.
The default steps reproduce the handoff’s ranges at walk_coherence=0:
land [0,700) metres per accepted hop, sea [-280,40) metres per hop. The sea
walk has negative drift, not strictly negative increments. These are metre-scale
parameters in this package; height_quantum_m controls the uint16 encoding.
At higher coherence, increments mix IID samples with a spatially coherent noise field freshly seeded at each causal depth. This keeps accumulation stochastic between depths while neighboring cells share structure. Mixing changes the increment variance; it does not preserve the IID marginal distribution.
Whole-loop IDs would miss an island’s inward self-collision. Independent seed
IDs alone would collide almost immediately at the coast. The source-separation
and opposing-direction tests bridge these cases, but are a heuristic. A rare
enclosed raster pocket is resolved as one terminal junction from deduplicated
incoming contributions, without another walk step. junction_cells counts
those cells across both passes so this extension is visible.
The prototype uses four-neighbor macro graph edges. Coherent increments smooth
local variation but do not remove that graph’s directional bias.
The input callback is a filled signed field, not a directed line drawing. For contour art, first classify its faces with an explicit fill rule; the generator cannot recover path orientation or closure of open spurs from pixels.
Knobs
All settings are in ct_config; every numeric knob is listed by --knobs and
in include/ct_knobs.def. CLI configuration files use name=value. Presets reset
all settings; put overrides after --preset.
| Controls | Effect |
|---|---|
seed, planet_id |
Repeatable procedural world and its body identity |
continent_frequency, continent_octaves |
Number/scale of landmasses and coastline detail |
persistence, lacunarity |
Fine-octave strength and frequency spacing |
sea_threshold |
Higher values give less land |
warp_strength, warp_frequency |
Bend and complicate continent outlines |
noise_mix |
0 = contour walks; 1 = noise relief; intermediate = hybrid |
land_step_m, land_jitter_m |
Average uphill increment and its variation |
sea_step_m, sea_jitter_m |
Mean offshore increment and its variation |
walk_coherence, walk_noise_frequency |
Spatial agreement of walk increments |
front_delay_passes |
Deterministic 0..N scheduling delay per cell; changes terrain as well as animation |
meet_separation_cells |
How far apart source locations must be to count as an opposing-front meeting |
land_relief_m, ocean_depth_m |
Noise-mode/hybrid relief scale |
ridge_amplitude_m, ridge_frequency, ridge_sharpness |
Strength, size, and sharpness of ridged noise |
lake_max_fraction |
Largest small water component to treat as an elevated lake; 0 disables lakes |
max_macro_slope, slope_constraints |
Graph height-difference bound in metres/metre; on/off |
detail_amplitude_m, detail_frequency, detail_octaves |
Local relief superimposed consistently on the macro field |
humidity, moisture_frequency, forest_threshold, desert_threshold |
Wet/dry regional character and vegetation classification |
treeline_m, snowline_m, polar_cooling |
Exposed alpine terrain and snow thresholds, reduced toward the poles |
beach_height_m, shallow_depth_m |
Coast and shallow-water bands |
macro_resolution |
8..256 intervals per face edge for the global solve |
surface_lod, radius_m |
Physical chunk scale, independent of the global solve resolution |
height_quantum_m |
Metres per uint16 increment; controls range versus precision |
region_strength |
0 = global rules; 1 = full regional modulation |
region_frequency, region_blend |
Region patch scale; higher blend makes broader transitions |
coast_detail_amplitude, coast_detail_frequency |
Extra signed-mask detail, allowing small islands and holes |
plains_*, hills_*, ridges_*, broken_* |
Four editable terrain profiles, described below |
walk turns off the hybrid noise and local detail to expose the contour process.
noise bypasses both walk passes and elevated lakes. balanced, archipelago,
alpine, and dry exercise the hybrid controls.
regional enables four profiles and extra coast detail, at macro resolution 128.
Regional controls
The cyan/green/yellow/red reference image appears to show regional rule assignments. That interpretation is an inference; our labels and values are design choices, not recovered original parameters. Region IDs are separate from biome/material IDs. All weights are calculated at shared planet vertices and stored in the world snapshot. Each location blends four profiles continuously; the dominant color is only a diagnostic label.
| Profile prefix | height_scale |
roughness |
ridge_scale |
delay_passes |
|---|---|---|---|---|
plains_ |
0.35 | 0.20 | 0.05 | 0 |
hills_ |
0.80 | 0.60 | 0.45 | 1 |
ridges_ |
1.60 | 0.85 | 2.00 | 0 |
broken_ |
1.10 | 1.60 | 1.20 | 3 |
For example, ridges_height_scale=2 doubles the base uphill step and analytic
land relief where the ridges profile dominates. roughness scales walk jitter,
fine relief, and the optional coast perturbation; uphill jitter is capped at
the mean to preserve nonnegative land increments. The sea mean remains the
global sea_step_m. ridge_scale multiplies ridged noise in hybrid/noise modes.
Weighted delay_passes is rounded and added to front_delay_passes as the
maximum deterministic per-cell delay. It changes propagation and collision
geometry, not just animation speed. Strength zero neutralizes all profile effects.
region_frequency sets the size of broad rule patches. region_blend changes
the softness of their transitions. These fields share the continent domain warp.
Very sharp boundaries are still limited by macro resolution. The added coast
noise changes the mask before lake identification and both solves; fine
surface noise never invents a different coastline inside a chunk.
ct_config cfg;
ct_config_preset(&cfg, "regional");
cfg.seed = 42;
cfg.walk_coherence = 0.85;
cfg.broken_roughness = 1.8;
cfg.ridges_height_scale = 2.0;
char error[256];
ct_world *world = ct_world_build(&cfg, NULL, NULL, error, sizeof error);
/* Check world != NULL, then use ct_world_sample / ct_chunk_generate. */
ct_world_region() returns the weights and effective settings at a direction.
--diagnostics writes regions_FACE.ppm (dominant profile, water dark) and
height_FACE.ppm (the full uint16 purple-to-red scale). The reference preview
uses a separately labelled metre range and relief shading to make smaller
height differences visible. All of its terrain data comes from the C generator.
See REFERENCE_NOTES.md for the distinctions between evidence and reconstruction.
For a clean comparison, use the same seed and mask settings. Change one family
of knobs at a time. Increasing macro_resolution changes the discretized mask
and number of walk hops; scale per-hop increments down if you want similar
relief. High frequencies beyond that grid’s resolution alias. Fine noise also
needs a suitable frequency for the chosen finest sample spacing.
C constraint solving
For each macro graph edge (a,b) with spherical edge length d:
abs(height[a] - height[b]) <= max_macro_slope * d
The implementation computes min-plus distance envelopes with an indexed binary heap. This uses O(V) auxiliary storage and O(E log V) work per envelope. Feasible lake levels are established first, including the shoreline pins. For terrain, lower and upper envelopes from pinned heights bound the result. Clamping to those bounds and applying a final lower envelope satisfies the edge inequalities.
This is a graph height constraint projection, not volume-conserving erosion.
It can raise values to satisfy pinned lower bounds and lower overpiled ridges.
max_macro_slope_excess_m measures the actual remaining edge violation. The
constraint is over macro graph edges; interpolation and added fine
noise are not covered by a guarantee on the continuous final slope. The
current code does not simulate rivers, erosion, vegetation meshes, or weather.
Cubemap and scale
Face UV spans [-1,1], with v increasing down the image. These are exactly the
transforms inspected in RFL’s atlas_tool.c:
| Face | Unnormalized direction |
|---|---|
| +X | (1, -v, -u) |
| -X | (-1, -v, u) |
| +Y / north | (u, 1, v) |
| -Y / south | (u, -1, -v) |
| +Z / front | (u, -v, 1) |
| -Z / back | (-u, -v, -1) |
Normalize the direction before sampling. The cubemap is storage/addressing; the direction identifies the location on the physical body. The face projection uses dominant-axis ownership, with X winning exact ties, then Y, then Z.
A LOD L face has 2^L × 2^L chunks. Every chunk has a 256×256 core. Default L=6
therefore represents 6 × 64² = 24,576 finest chunks and 1,610,612,736 core cells
without allocating those cells at world load. The default macro graph has only
24,578 unique vertices.
At the center of a face, local sample spacing is approximately:
2 * radius_m / (256 * 2^surface_lod)
For a 6,371 km radius and L=6 this is about 778 m/sample and 199 km/chunk near
face center. Spacing varies across each cube face; cube parameter squares are
not equal-area physical squares. ct_key_metric() returns local metre-scale
tangent vectors. Use those instead of assuming the current RFL 1 km cell unit
is valid everywhere. L=7 approximately halves the spacing. macro_resolution
does not determine the number of surface chunks.
The extra row/column is the positive interpolation border. Adjacent chunks evaluate identical global coordinates at their shared border, including across cube edges. Coarser LOD vertices are subsets of finer vertices. The library provides samples; mixed-LOD mesh stitching, skirts, morphing, and view filtering remain renderer responsibilities.
Sampling is bilinear inland. Shore cells with a lone land/water corner use a triangle split through the shoreline pins. Saddles between different lake components split along their land diagonal to keep the water bodies separate. These splits preserve the shared cell edges. Land uses an interpolated shore reference, avoiding abrupt lake-height changes at cube boundaries.
Shared space/surface sampling
ct_sample s;
ct_world_sample(world, body_fixed_direction, &s);
/* Space: choose palette[s.palette_index] and apply view lighting.
* Surface: use s.height_m, s.material, s.biome, and the same palette.
* Water: s.water_m is a separate surface, not the terrain bed height. */
ct_key key;
double local_x, local_y;
ct_key_at(world, body_fixed_direction,
ct_world_config(world)->surface_lod,
&key, &local_x, &local_y);
Apply the inverse body rotation before both calls. Camera rotation, day/night lighting, local render origins, and player order must not enter generation seeds. The low-resolution space image is a sampled view of the same field, so small objects will disappear at distance. The supplied previews are point sampled; production space textures should be filtered for their pixel footprint.
The supplied 40-color palette has five shades for each of eight biomes. Edit
palette.txt as exactly 40 hexadecimal RRGGBB values, then use --palette.
Palette changes do not change heights, masks, or chunk generation IDs. Share
the palette separately when multiplayer clients need identical appearance.
Existing planet_tool BMP rows can populate the corresponding grass, shore, sea,
and cloud-independent biome colors through a small importer; no BMP parsing is
needed in the runtime sampler.
Streaming
/* Loading/worker setup: world must remain immutable and outlive cache. */
ct_stream *cache = ct_stream_create(world, 9);
/* Frame update: request nearby/current and predicted route keys. */
if (ct_stream_request(cache, key) == 1) {
const ct_chunk *chunk = ct_stream_acquire(cache, key);
if (chunk) {
/* Upload/use chunk, keeping it pinned as long as you retain it. */
ct_stream_release(cache, chunk);
}
/* Otherwise retain the current valid terrain/LOD and poll next frame. */
}
Creation preallocates all cache storage. Request/acquire/release take a short
mutex, with no file reads, allocation, or terrain generation on that path.
A worker generates outside the mutex. Pinned slots cannot be evicted. A full
cache returns busy; the caller retries later. ct_stream_wait() is only for
the command-line demo, tests, or loading screens.
A serialized chunk is 396,354 bytes, including heights, water levels, biome and palette indices with border samples. Nine in-memory slots take about 3.4 MiB. The 64-resolution macro graph and mapping take about 3.5 MiB, plus temporary world-build work arrays. This first cache has one worker and no disk cache or priority scheduler; the chunk files can be loaded/saved by a separate IO worker.
For a 3×3 neighborhood near a cube edge, obtain directions using
ct_key_direction(key, local_x + 256*dx, local_y + 256*dy, ...), then project
those directions with ct_key_at() and deduplicate keys. Beyond-face UV is
supported. Native neighbor axes can rotate across edges; render from body
directions or a local tangent basis rather than concatenating face arrays.
RFL wiring
The inspected GitHub snapshot still has WORLDW = WORLDH = 259, a wrapped
lon/lat map, and SURFACE_MODE_UNIT_METERS = 1000. This package deliberately
targets the user’s requested 256×256 chunk core. It does not assume that snapshot
is identical to the already-deployed browser build.
examples/rfl_adapter.h provides the existing callback signature and an explicit
axis conversion. The old source direction is Z-up; this cubemap is Y-up:
world = {legacy.x, legacy.z, legacy.y}. It also maps biome IDs to RFL’s
existing sea/shore/grass/forest/rock/snow material values.
Actual streaming integration needs these coordinated changes:
- Keep a body-fixed direction and altitude as the authoritative surface pose.
Convert that pose to a chunk key and local offset; replace whole-world
wrapi/wrapdbehavior with chunk lookup and cross-face reprojection. - Bind the same frozen
ct_worldto the space surface sampler and terrain chunk producer. Replace the separate planet_tool terrain function for worlds using this generator; keep its palette and lighting controls. - Let the surface renderer, collision queries, minimap, shadows, and XPM placement read the same active chunk neighborhood. Keep old valid data until the requested neighborhood is ready. Never block on generation inside an active frame.
- Use the local metric/tangent basis to keep close terrain flat around the player while retaining its place on the sphere. Convert metre heights at the renderer boundary, instead of rescaling the authoritative world data.
- Carry each body’s descriptor/generation and the player’s canonical chunk address in multiplayer state. Transform heading/tangent axes on face changes.
The old callback path clamps negative elevations to zero and recolors terrain
later in create_world(). It cannot by itself display the exported sea floor,
elevated lake surface, or complete shared palette. Both field and material access
need to be connected before claiming full space/surface correspondence in-game.
Multiplayer and file contracts
Logical chunk address:
planet_id / generation / face / lod / chunk_x / chunk_y
For a location callout, use ct_key_at() at surface_lod, even while rendering
a coarser tile. LOD 6 +Z 22,52 always denotes the same finest chunk for that
world generation. Different bodies and parameter revisions have different keys.
generation hashes the algorithm version, seed, all configuration fields, and
the actual solved macro field. digest hashes the chunk key and payload.
These are noncryptographic FNV-1a checksums for cache identity and mismatch
detection, not security checks or proof of authenticity. Transfer an explicit
world descriptor/snapshot, not only a seed.
Generation is repeatable for the same binary/IEEE environment, independent of chunk request order and worker scheduling. Integer RNG is stable, but floating noise, math-library functions, and threshold decisions are not claimed bit-exact across every compiler/CPU. Disable fast-math. Compare world and chunk checksums; use authoritative server snapshots/tiles when clients disagree. Even a shared macro snapshot does not eliminate possible fine-noise differences across CPUs. Near an exact border, the server’s key/local coordinates should be authoritative.
File formats are explicitly little endian and do not serialize native structs:
| File | Contents |
|---|---|
world.ctw |
Magic/version, complete config, generation checksum, macro heights/mask/water and region weights |
chunk_*.ctc |
Magic, complete key, quantum, checksum, clipping count, 257² records |
chunk_*.u16le |
Exactly 256² uint16 ground values, positive border omitted |
chunk_*.ppm |
Exactly 256² palette colors for quick inspection |
config.cfg |
Human-editable complete settings |
A .ctc record is (height:u16, water:u16, biome:u8, palette:u8), six bytes.
The header is 60 bytes. height = (encoded - 32768) * height_quantum_m.
Sea level encodes as 32768. Default quantum 1 m spans -32,768..32,767 m.
Clipping is counted explicitly; increase the quantum for taller/deeper worlds.
World snapshots preserve terrain, not the transient convergence log; generation
pass counters are available on freshly built worlds.
This revision uses algorithm version 2 and CTWORLD2 snapshots. Version 1
snapshots must be regenerated from their config; they are deliberately rejected.
The chunk record format is unchanged, but its generation ID changes. Regenerate
cached chunks alongside the world rather than mixing revisions.
Increment the algorithm version whenever published generation semantics change. Do not mix chunks from different generation IDs in one neighborhood.
Verification
make test exercises input rejection, exact height encoding, all face transforms,
same-loop SUM ridges, elevated lakes, feasible slope constraints, both sides of
all cube edges, identical chunk borders, LOD subsets, direct space/chunk sample
agreement, request-order and thread reproducibility, snapshot round trips,
corruption rejection, and pinned-cache behavior under capacity pressure.
make sweep also covers 27 worlds with varied seeds, front delays, coherence,
strict slopes, regional extremes, and macro resolutions through 256. make thread-sanitize runs
the main suite with ThreadSanitizer.
make sanitize builds AddressSanitizer + UndefinedBehaviorSanitizer. On hosts
where ptrace restrictions prevent LeakSanitizer from inspecting threads, run
make sanitize ASAN_OPTIONS=detect_leaks=0. See
VALIDATION.txt for the actual results from this delivery environment.


