Resolves Q-101 — how a coarse Layer-1 cell + seed becomes coherent ~1m voxel geometry across the scale jumps. The tile-derivation-contract workshop output: - three-carrier refinement chain RegionProfile -> ChunkContext -> VoxelColumn, pure deterministic, no authoring at the derivation layers - district-temperature climate primitive (2x2 km, C, nullable) + separate moisture; everything climatic derives from temperature(+moisture) - scattered, transient freeze/snow model (freshwater +5..-10, sea ice own band, snow moisture-gated; forms cold phase / melts warm phase) - stateless f64-to-voxel domain warp (anti-squaring) - 8 morphology families over a frozen 17-zone vocabulary, gated decision tree - seams prevented at source (gate ordering + build-time matrix); valid geomorphic seams kept sharp + warped Includes workshop brief, round-1 positions, and workshop-outcomes.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
16 KiB
title, workshop, participant, round, date
| title | workshop | participant | round | date |
|---|---|---|---|---|
| Tyre Round 1 — RegionHint API, anti-squaring, determinism/perf | tile-derivation-contract | Tyre | 1 | 2026-06-07 |
Tyre Round 1: The Derivation API Contract
Framing the problem correctly
The 78 km → 1 km → 64 m → 1 m span is not a single derivation problem — it is
three distinct problems with different failure modes, and conflating them is how
you get squaring. My position up front: each scale boundary needs a different
coherence mechanism, and the RegionHint I sketched earlier needs to be split
into three typed carriers, one per boundary. The overall structure is:
Layer 1 cell (78 km) → RegionProfile (region-scale morphology zone + parameters)
RegionProfile → ChunkContext (64 m, resolved from heightmap position)
ChunkContext + seed → VoxelColumn (1 m, the actual derive call)
The RegionHint name survives but becomes specifically the RegionProfile — the
thing the region-level pass fills once and hands to all chunks within it.
Scale boundary 1: 78 km cell → ~1 km region (RegionProfile)
This boundary is classification, not interpolation. A region does not gradually become a fjord coast — it is or it isn't, based on contextual inputs. The squaring risk here is not visual grid artifacts; it is abrupt zone transitions at region edges.
What the 78 km cell hands down
The Layer 1 outputs already available per D-208 / D-209 / D-227:
elevation(f32 heightmap, but read as integer-scaled u16 per D-010)flow_accumulation(D8 integer count)flow_direction(D8 integer 0–7)sub_biome(D-210 / D-228 — climate + vegetation zone)river_networkmembership (bool)
What the region pass adds (runs once per region, stored as RegionProfile):
pub struct RegionProfile {
// Classification inputs — all integer or enum
pub morphology_zone: MorphologyZone, // fjord / meander / delta / etc.
pub lithology: Lithology, // resolved from body parameters + elevation strata
pub glaciation: GlaciationGrade, // 0..4 integer grade
pub slope_class: SlopeClass, // Flat / Gentle / Steep / Cliff (computed from 3×3 elev kernel)
pub drainage_class: DrainageClass, // None / Ephemeral / Perennial / Major
pub sea_level_margin: i32, // elevation - sea_level, integer metres
// Morphology algorithm selector — derived from all above
pub morphology_family: MorphologyFamily,
// Parameters handed to ChunkContext
pub relief_amplitude: u16, // max voxel-height variation within region, integer metres
pub meander_intensity: u8, // 0–255 integer
pub erosion_grade: u8, // 0–255 integer
pub dune_orientation: u8, // compass octant (0–7), prevailing wind
}
MorphologyZone is the D-228 "region-level morphology zone" field, resolved here.
MorphologyFamily is the algorithm selector — that is Gestalt's domain to enumerate;
I just need to know what I am selecting between.
Lithology: resolving the ocean/lake mask gap
Q-101 flags the D-223 ocean-polygon removal as an "unowned dependency". My resolution: sea-level threshold + flood-fill from heightmap edge, all integer.
fn classify_water_body(
body_params: &BodyParams, // has sea_level, radius
elevation_grid: &Grid<u16>, // integer-scaled metres
pos: (u16, u16),
) -> WaterBodyClass {
// cells below sea_level threshold that are connected to the heightmap
// edge (flood-fill) → ocean; isolated depressions below sea_level → lake
}
This is a single flood-fill pass — O(n) over the Layer-1 grid (512×256 = 131k cells),
produces a bitmask. Deterministic, integer-only, no authored polygon dependency.
Lithology strata are body-class parameters (tectonic activity, age) → integer lookup
table per (body_class, elevation_band). No f32 in classification.
Region boundary blending (anti-squaring at this boundary)
The morphology zone must not flip discretely at region edges. Mechanism: dual
classification with a blending weight, resolved at the ChunkContext level.
Each region classifies to a primary and a secondary MorphologyFamily, with an
integer weight primary_weight: u8 (255 = fully primary, 128 = equal mix). Chunks
near region boundaries inherit both families and blend their parameter tables.
The boundary width is one chunk (64 m) on each side — chosen to be invisible at
normal view distances and narrow enough not to corrupt chunk interiors.
Scale boundary 2: ~1 km region → 64 m chunk (ChunkContext)
This is the critical boundary for squaring. Adjacent chunks from different regions must tile seamlessly. The failure mode here is visible grid lines where chunk borders coincide with region zone changes.
ChunkContext struct
pub struct ChunkContext {
// Position identity
pub chunk_pos: ChunkPos, // integer chunk grid coordinates
pub chunk_seed: SeedChain, // SeedChain::derive(Block, chunk_id)
// Inherited from RegionProfile (primary)
pub primary: RegionProfile,
// Boundary blending — present only for boundary chunks
pub secondary: Option<RegionProfile>,
pub blend_weight: u8, // 255 = fully primary (no blend)
// Heightmap slice — integer, resolved at chunk resolution
pub elevation_min: u16,
pub elevation_max: u16,
pub elevation_grid: [u16; 64*64], // 4096 u16 values, bilinear from Layer 1 → chunk res
// Flow data at chunk resolution
pub flow_direction: [u8; 64*64], // D8 octant per chunk-scale cell
pub river_cells: u64, // bitmask of 8×8 macro-cells with river presence
}
ChunkContext is computed once per chunk and cached per D-227 (same eviction
tier as the derived tile data itself). Computing it requires reading the four
surrounding RegionProfile records plus the heightmap tile. Cost: integer arithmetic
over 64×64 arrays — well under 1 ms.
Anti-squaring: domain warping at the chunk scale
The principal technique for seam-free transitions at chunk boundaries is position-domain warping using a global continuous noise field — matching the D-228 "global, position-keyed continuous noise field — never per-chunk" directive for the cohesion matrix at the material layer.
The same principle applies at the morphology level: the warping offsets are derived from a global field keyed on (body_id, world_seed), evaluated at any (x, y) position. Any chunk can independently compute the warp offset for any position within or adjacent to itself — producing identical values for the shared boundary. This is the structural guarantee of seam-freedom: warp values are global functions, not per-chunk state.
Concretely:
fn warp_offset(seed: u64, body_id: u64, x: f64, y: f64) -> (f64, f64) {
// Two independent value-noise fields (no coherent noise dependency)
// Using integer-grid hash + bilinear interpolation to keep it f64-confined
// Returns sub-voxel displacement in (dx, dy), magnitude capped at ~8m
}
The warp magnitude (~8 m maximum) is chosen to be larger than one tile (1 m) but smaller than a chunk (64 m). This means:
- Chunk-grid lines are visibly displaced by up to 8 m in both directions
- No straight-line grid artifact can persist for more than 8 m
- Adjacent chunks compute the warp for their shared edge independently and get identical values (pure function of world coordinates + seed)
The warp is applied in world-space coordinates before the morphology algorithm runs — the algorithm sees warped positions and produces terrain that has no knowledge of the chunk grid. The chunk boundary is invisible because neither algorithm has it.
Relation to the D-227 chunk cache
The domain-warp field does not need to be cached separately. Each chunk computes warp offsets only for its own tiles (the 64×64 grid) plus a 16-tile overlap margin on each side (for the blend zone). This overlap is cheap — 96×96 evaluations vs 64×64. The warp function itself is O(1) per position; the cost scales with chunk area, not with neighbor count.
Scale boundary 3: 64 m chunk → 1 m voxel (the derive call)
This is where D-227's subtile(x,y,z) = derive(seed, atlas, position) lives.
Refined function signature
pub fn derive_voxel(
ctx: &ChunkContext, // pre-computed chunk context (cached)
pos: VoxelPos, // integer (x, y, z) — 1 m resolution
) -> TileAxes {
// Returns the D-228 composite:
// TerrainMaterial, FloorMaterial, Vegetation, Water, elevation
}
pub struct TileAxes {
pub terrain_material: TerrainMaterial, // u8 enum
pub floor_material: FloorMaterial, // u8 enum
pub vegetation: VegetationKind, // u8 enum
pub water: WaterDepth, // u8 enum
pub elevation: u16, // integer metres
}
The pos parameter carries world-space coordinates, not chunk-local coordinates.
The algorithm:
- Apply
warp_offset(ctx.chunk_seed.seed(), body_id, pos.x, pos.y)— warp the position. - If
ctx.blend_weight < 255: resolveTileAxestwice (primary + secondaryRegionProfile), then integer-blend the parameters before selecting materials. The blend uses the same warp offset to prevent the blend seam from aligning with the chunk edge. - Call the
MorphologyFamily-specific derivation function with theChunkContextparameters. - Derive
TerrainMaterialfromlithology + elevation + slope. - Derive
Vegetationfromsub_biome + terrain_material + elevation. - Derive
Waterfrom regional water-height vselevation(the D-228 cheap seasonal/tidal water model). - Return
TileAxes.
Step 3 is Gestalt's domain (the morphology algorithm implementations). The contract
from my side: each family receives ChunkContext + warped position and returns an
ElevationDelta: i16 (delta from the base heightmap elevation, signed, integer metres).
That delta feeds into step 4 onward.
Structural decisions (D-010 integer discipline)
All structural decisions — which material, which morphology zone, which elevation band — are integer-only. f64 is confined to the warp offset computation and any within-voxel interpolation. This is not ceremonial: D-227 makes it save-critical. A single f32 comparison that flips on a different hardware FPU would desync every mutator reference in a save file.
Specific constraints I am placing:
ElevationDeltaisi16(integer metres). No sub-voxel elevation in the structural layer. Sub-voxel geometry is a render concern (shape-from-material, D-228: "sand slumps to angle of repose, rock breaks to vertical face").- Material selection is a series of integer comparisons / lookup table reads.
- The warp offset is f64 during computation but is immediately truncated to an
i16(integer metre displacement) before any structural decision uses it. The sub-metre remainder is discarded — only the rendered visual uses sub-voxel precision, and that is a client concern.
Performance contract
Target: <5 ms per chunk, per D-227.
Budget breakdown (64×64 = 4,096 tiles per chunk):
| Work item | Cost estimate | Basis |
|---|---|---|
ChunkContext construction (once per chunk) |
~0.2 ms | 4×4 RegionProfile lookups + 64×64 integer array ops |
| Warp offset computation (4,096 + margin positions) | ~0.5 ms | ~5,000 hash + bilinear evaluations |
| Morphology algorithm (4,096 tiles, varies by family) | ~1–3 ms | family dependent; Gestalt/Troblum to validate |
| Material + vegetation + water derivation (4,096 tiles) | ~0.5 ms | integer table lookups |
| Total | ~2.2–4.2 ms | headroom for complex families |
The chunk cache means this cost is paid once per chunk per play session (or per Atlas view request). Adjacent chunks share no mutable state; they are embarrassingly parallel under Rayon (D-208 precedent).
The ChunkContext is the critical cache object — it amortizes the RegionProfile
lookups across all 4,096 tile derivations in the chunk. Evicting a chunk from cache
means recomputing its ChunkContext plus all tile derivations; the RegionProfile
cache above it is longer-lived (one per ~1 km region vs one per 64 m chunk).
What I need from teammates
From Gestalt:
- The ≥6 morphology family names and their context selectors, so I can populate
MorphologyFamilyas a typed enum with explicit#[repr(u8)]discriminants (stability requirement — same asSeedDomain). - The
ElevationDeltarange each family can produce. I need to know if any family wants sub-voxel precision (answer: no — see D-010 argument above) or more than ±255 m variation within a chunk (answer: physically implausible for a 64 m chunk). - Any context inputs the family selector needs beyond what
RegionProfilealready carries. My position:RegionProfileshould be sufficient; if a family needs a per-tile input not inRegionProfile, that is a signal the classification belongs at theChunkContextlevel, not the voxel level.
From Troblum:
- Stress-test the warp budget specifically. 5,000 hash + bilinear evaluations in
0.5 ms is ~90 ns/evaluation — plausible on modern hardware but worth verifying.
The warp can be precomputed into a lookup grid within the
ChunkContextif that is faster, at the cost of ~12 KB of additional cache memory per chunk. - Validate the
ChunkContextconstruction cost. The 64×64 integer bilinear interpolation from Layer-1 resolution to chunk resolution is ~8× upsampling; that should be fast but Troblum should confirm.
From Miri:
- Which morphology zones need sub-region variation within a single 64 m chunk? For example: a meander reach with an oxbow lake needs the lake to appear within a chunk that the region classifies as "meander reach". Does that require a secondary zone classification at the chunk level, or does the morphology algorithm handle it internally?
- The D-228 "named features matter for the wiki/atlas" list — which ones require
the
RegionProfile.morphology_zoneto be queryable at Atlas-generation time? That determines whetherRegionProfileneeds to be persisted to the layer cache or can be recomputed on Atlas queries.
Open issues I am flagging for Round 2
-
River flow direction at the voxel level. Q-101 notes "river flow direction derives from the D8 network at query time." The D8 network is Layer-1 resolution (512×256). At 1 m voxel resolution, a river cell needs flow direction at meter scale. The meander algorithm (Gestalt's domain) presumably drives local course variation — but does the D8 direction act as a basin-scale constraint or a cell-level input? This affects whether
flow_directioninChunkContextis one value per chunk or one value per 8×8 macro-cell within the chunk. -
RegionProfilepersistence vs recompute. The Atlas viewer (D-225) currently cachesLayer1Output(rivers, basins, attractors).RegionProfileis a finer grain. For Phase 4 Atlas use, we may want to computeRegionProfilelazily on Atlas request rather than precomputing for all regions — the number of regions per body is on the order of 78km/1km ≈ ~6,000 per body. That is cheap to recompute on demand but expensive to precompute at atlas-load time for all bodies. -
MorphologyFamilytransition zones and pathological adjacencies. What happens when a fjord coast is adjacent to a meander delta? The blend mechanism handles it at the chunk level but theRegionProfileclassifier needs to produce a sensiblesecondarymorphology zone for the boundary chunks. This requires the morphology-zone vocabulary (Gestalt's Round 1 output) before Round 2 can finalize. -
Body-class modulation of
RIVER_THRESHOLD. Q-101 flags this. My position:RIVER_THRESHOLDshould be a body parameter computed from(hydrosphere, tectonic_activity, precipitation_class)using an integer lookup table. This gives Nigel the handle without touching the D8 algorithm itself. The lookup table lives inbody_params.rsalongside other per-body scalar parameters.