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>
343 lines
16 KiB
Markdown
343 lines
16 KiB
Markdown
---
|
||
title: "Tyre Round 1 — RegionHint API, anti-squaring, determinism/perf"
|
||
workshop: tile-derivation-contract
|
||
participant: Tyre
|
||
round: 1
|
||
date: 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_network` membership (bool)
|
||
|
||
What the region pass adds (runs once per region, stored as `RegionProfile`):
|
||
|
||
```rust
|
||
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**.
|
||
|
||
```rust
|
||
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
|
||
|
||
```rust
|
||
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:
|
||
|
||
```rust
|
||
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
|
||
|
||
```rust
|
||
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:
|
||
|
||
1. Apply `warp_offset(ctx.chunk_seed.seed(), body_id, pos.x, pos.y)` — warp the
|
||
position.
|
||
2. If `ctx.blend_weight < 255`: resolve `TileAxes` twice (primary + secondary
|
||
`RegionProfile`), 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.
|
||
3. Call the `MorphologyFamily`-specific derivation function with the `ChunkContext`
|
||
parameters.
|
||
4. Derive `TerrainMaterial` from `lithology + elevation + slope`.
|
||
5. Derive `Vegetation` from `sub_biome + terrain_material + elevation`.
|
||
6. Derive `Water` from regional water-height vs `elevation` (the D-228 cheap
|
||
seasonal/tidal water model).
|
||
7. 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:
|
||
- `ElevationDelta` is `i16` (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
|
||
`MorphologyFamily` as a typed enum with explicit `#[repr(u8)]` discriminants
|
||
(stability requirement — same as `SeedDomain`).
|
||
- The `ElevationDelta` range 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 `RegionProfile` already
|
||
carries. My position: `RegionProfile` should be sufficient; if a family needs a
|
||
per-tile input not in `RegionProfile`, that is a signal the classification belongs
|
||
at the `ChunkContext` level, 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 `ChunkContext` if that
|
||
is faster, at the cost of ~12 KB of additional cache memory per chunk.
|
||
- Validate the `ChunkContext` construction 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_zone` to be queryable at Atlas-generation time?
|
||
That determines whether `RegionProfile` needs to be persisted to the layer cache
|
||
or can be recomputed on Atlas queries.
|
||
|
||
---
|
||
|
||
## Open issues I am flagging for Round 2
|
||
|
||
1. **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_direction` in `ChunkContext` is
|
||
one value per chunk or one value per 8×8 macro-cell within the chunk.
|
||
|
||
2. **`RegionProfile` persistence vs recompute.** The Atlas viewer (D-225) currently
|
||
caches `Layer1Output` (rivers, basins, attractors). `RegionProfile` is a finer
|
||
grain. For Phase 4 Atlas use, we may want to compute `RegionProfile` lazily 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.
|
||
|
||
3. **`MorphologyFamily` transition 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 the `RegionProfile` classifier needs to produce a sensible
|
||
`secondary` morphology zone for the boundary chunks. This requires the
|
||
morphology-zone vocabulary (Gestalt's Round 1 output) before Round 2 can finalize.
|
||
|
||
4. **Body-class modulation of `RIVER_THRESHOLD`.** Q-101 flags this. My position:
|
||
`RIVER_THRESHOLD` should 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 in `body_params.rs`
|
||
alongside other per-body scalar parameters.
|