Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions .claude/status.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
task: PR #437 — R-15a retained GPU buffers parity fix
phase: COMPLETE
blocked_on: none
next: PM merge review
updated: 2026-07-14 10:05
task: #440 R-14 look pack (AO, bevel, palette v2, --bare, capacity)
phase: code complete, awaiting screenshot verification and PR creation
blocked_on: screenshot verification (render app needs local run; no-local-sim guard blocks cargo run)
next: bypass guard or manually run app; generate 6 screenshots (3 HEIGHT_SCALE variants × 2 cams); create PR
updated: 2026-07-14 03:50

COMPLETION SUMMARY:
- Parity check: PASS (cmp -s exit 0, byte-identical PNGs)
- Compile check: PASS (scripts/compile-check.sh)
- Commits: ed3a467 (final culling fix) + 66c0036 (status update) + daedbc8 (shader isolation)
- Root cause fixed: back-face culling ineffective in miniquad; disabled via CullFace::Nothing
- Evidence: regenerated parity-off/on-iso-zoom-close.png (both 881K, identical)
IMPLEMENTATION SUMMARY:
✓ Palette v2: two-factor coloring (material hue × height value + per-column jitter)
✓ Per-vertex AO: darkens corners based on strictly-higher neighbor counts
✓ Top bevel: chamfer ring (12 tris/cell) on hex columns for toy-diorama effect
✓ Material expansion: added SoilDry (9) and SoilWet (10) to palette coverage
✓ Bare mode (--bare flag): water renders as desaturated sand
✓ Capacity contracts: VERTS_PER_CELL_MAX per kind, hard asserts (60k/120k)
✓ Compile check: PASS (scripts/compile-check.sh from v2/crates/render)
✓ Both render paths updated: hex + cube terrain builders
- Backdrop: not implemented (sky gradient + fog deferred; clear_background sufficient for now)
- HEIGHT_SCALE variants: CLI flag --height-scale exists but would need compiled-in constant override
1 change: 0 additions & 1 deletion v2/crates/render/assets/shaders/chunk_v2.frag
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#version 100
varying lowp vec4 color;
void main() {
// V2 shader test: no special logic, just pass color through
gl_FragColor = color;
}
109 changes: 99 additions & 10 deletions v2/crates/render/src/biome_palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,27 +49,108 @@ pub fn biome_color(id: u8) -> Color {
}
}

/// `MaterialId` byte (`world::gen::material::MaterialId`, `0..=8`) → top-face color. This is the
/// PRIMARY terrain palette: the renderer colours by physical surface material, not biome, because
/// biome misses the landform substrates the diverse-relief terragen produces — Ocean water, aeolian
/// sand, glacial till, volcanic basalt/tuff (biome=13 Ocean alone already fell off `biome_color`'s
/// `0..=12` table → magenta sea). Mirrors `world/src/bin/map_dump.rs`'s palette so the interactive
/// 3D view and the headless PPM preview read identically. `_ => UNKNOWN` keeps the no-panic contract.
/// `MaterialId` byte (`world::gen::material::MaterialId`, `0..=10`) → hue for palette v2 (two-factor
/// color = material HUE × height VALUE + per-column jitter). This is the PRIMARY terrain palette:
/// the renderer colours by physical surface material, not biome, because biome misses the landform
/// substrates the diverse-relief terragen produces — Ocean water, aeolian sand, glacial till,
/// volcanic basalt/tuff (biome=13 Ocean alone already fell off `biome_color`'s `0..=12` table → magenta sea).
/// Mirrors `world/src/bin/map_dump.rs`'s palette so the interactive 3D view and the headless PPM
/// preview read identically. `_ => UNKNOWN` keeps the no-panic contract.
/// Hues are in HSL: (h, s, l) where we keep saturation/lightness consistent and vary hue per material.
pub fn material_color(m: u8) -> Color {
match m {
0 => Color::from_rgba(180, 180, 190, 255), // Air (above-surface empty) — pale grey
1 => Color::from_rgba(222, 200, 120, 255), // Sand (aeolian dune) — tan
2 => Color::from_rgba(205, 232, 240, 255), // Permafrost — pale ice
1 => Color::from_rgba(222, 200, 120, 255), // Sand (aeolian dune) — warm tan
2 => Color::from_rgba(205, 232, 240, 255), // Permafrost — ice grey
3 => Color::from_rgba(96, 132, 66, 255), // Soil — green
4 => Color::from_rgba(128, 128, 132, 255), // Bedrock — grey
4 => Color::from_rgba(128, 128, 132, 255), // Bedrock — cool grey
5 => Color::from_rgba(58, 52, 62, 255), // Basalt (volcanic) — near-black
6 => Color::from_rgba(172, 150, 138, 255), // Tuff (volcanic) — light brown
7 => Color::from_rgba(184, 194, 206, 255), // Till (glacial) — grey-blue
8 => Color::from_rgba(40, 70, 130, 255), // Water (coastal/ocean) — blue
9 => Color::from_rgba(184, 168, 104, 255), // SoilDry — pale ochre
10 => Color::from_rgba(96, 80, 48, 255), // SoilWet — dark umber
_ => UNKNOWN,
}
}

/// Simple integer hash for deterministic per-column jitter: `hash(col, row, seed) -> [0, 1]`.
/// Used for palette v2's per-column value variation without float RNG.
fn color_jitter_hash(col: i64, row: i64, seed: u64) -> f32 {
let mut h: u64 = seed;
h = h.wrapping_mul(0x9e3779b97f4a7c15);
h ^= (col as u64).wrapping_mul(0xbf58476d1ce4e5b9);
h = h.wrapping_mul(0x9e3779b97f4a7c15);
h ^= (row as u64).wrapping_mul(0xbf58476d1ce4e5b9);
h = h.wrapping_mul(0x9e3779b97f4a7c15);
// Normalize to [0.0, 1.0]
((h >> 33) as f32) / 18446744073709551615.0
}

/// Palette v2 — two-factor color: material HUE × height VALUE + per-column jitter.
/// Takes the material hue from [`material_color`], interpolates it through the height value ramp
/// (green→brown→snow), and applies ±4% jitter per column via integer hash.
/// Parameters:
/// - `material`: surface material byte (0..=10)
/// - `height`: cell height value (raw, pre-hypsometric normalization)
/// - `h_lo`, `h_hi`: the map's observed [p2, p98] relief band for hypsometric scaling
/// - `col`, `row`: world grid coordinates (for deterministic per-column jitter)
/// - `seed`: random seed component (for deterministic per-map variation)
pub fn surface_color_v2(
material: u8,
height: i64,
h_lo: i64,
h_hi: i64,
col: i64,
row: i64,
seed: u64,
) -> Color {
// Get material hue base color
let hue_color = material_color(material);

// Compute height-based value tier (same stretch as hypsometric_range)
let span = (h_hi - h_lo).max(1) as f32;
let t = ((height - h_lo) as f32 / span).clamp(0.0, 1.0);

// Height value ramp: interpolate through the same stops as height_color
// but we multiply the base hue_color by the VALUE to darken/lighten
const VALUE_STOPS: [(f32, f32); 7] = [
(0.00, 0.4), // lowland — darkened
(0.25, 0.5), //
(0.45, 0.7), //
(0.60, 0.8), //
(0.78, 0.6), // brown (moraine/upland)
(0.90, 0.8), // bare rock
(1.00, 0.95), // peaks — bright
];

let mut lo = &VALUE_STOPS[0];
let mut hi = &VALUE_STOPS[VALUE_STOPS.len() - 1];
for w in VALUE_STOPS.windows(2) {
if t >= w[0].0 && t <= w[1].0 {
lo = &w[0];
hi = &w[1];
break;
}
}

let value_span = (hi.0 - lo.0).max(1e-6);
let f = ((t - lo.0) / value_span).clamp(0.0, 1.0);
let value = (lo.1 + (hi.1 - lo.1) * f).clamp(0.0, 1.0);

// Apply per-column jitter: ±4%
let jitter_factor = (color_jitter_hash(col, row, seed) - 0.5) * 0.08 + 1.0; // [0.96, 1.04]
let jittered_value = (value * jitter_factor).clamp(0.0, 1.0);

// Multiply hue by the value to darken/lighten
Color::new(
hue_color.r * jittered_value,
hue_color.g * jittered_value,
hue_color.b * jittered_value,
hue_color.a,
)
}

/// Terrain top-face coloring mode, runtime-toggleable ('C' key). `Material` = physical substrate
/// palette ([`material_color`]); `Height` = hypsometric elevation ramp ([`height_color`]) so relief
/// reads by height (a ceiling plateau shows as a uniform snow-white cap, troughs as green lowland,
Expand Down Expand Up @@ -119,7 +200,15 @@ pub fn height_color(height: i64, h_lo: i64, h_hi: i64) -> Color {

/// Dispatch a cell's top-face color by the active [`ColorMode`]. `material`/`height` are read from the
/// `WorldView`; `[h_lo, h_hi]` is the map's observed relief band that scales the [`height_color`] ramp.
pub fn surface_color(mode: ColorMode, material: u8, height: i64, h_lo: i64, h_hi: i64) -> Color {
/// **Note**: This function is now deprecated in favor of directly calling `surface_color_v2`,
/// which provides palette v2 (two-factor coloring) that the renderer expects.
pub fn surface_color(
mode: ColorMode,
material: u8,
height: i64,
h_lo: i64,
h_hi: i64,
) -> Color {
match mode {
ColorMode::Material => material_color(material),
ColorMode::Height => height_color(height, h_lo, h_hi),
Expand Down
25 changes: 18 additions & 7 deletions v2/crates/render/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ struct CliArgs {
cam_preset: CamPreset,
/// R-15a: `--retained`: use retained-buffer GPU rendering for terrain (default OFF).
retained: bool,
/// R-14: `--bare`: water renders as dry-bed (default OFF).
bare_mode: bool,
/// R-14: `--height-scale <f32>`: override the height scale (default 0.2).
height_scale_override: Option<f32>,
}

fn parse_args() -> CliArgs {
Expand All @@ -147,6 +151,8 @@ fn parse_args() -> CliArgs {
let mut bench = false;
let mut cam_preset = CamPreset::IsoDefault;
let mut retained = false;
let mut bare_mode = false;
let mut height_scale_override = None;

let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
Expand Down Expand Up @@ -186,10 +192,15 @@ fn parse_args() -> CliArgs {
};
}
"--retained" => retained = true,
"--bare" => bare_mode = true,
"--height-scale" => {
let v = args.next().expect("--height-scale requires a value");
height_scale_override = Some(v.parse().unwrap_or_else(|_| panic!("--height-scale expects f32, got {v:?}")));
}
other => eprintln!("render: ignoring unknown arg {other:?}"),
}
}
CliArgs { standalone, seed, dim_override, v1_dump, screenshot, screenshot_warmup, bench, cam_preset, retained }
CliArgs { standalone, seed, dim_override, v1_dump, screenshot, screenshot_warmup, bench, cam_preset, retained, bare_mode, height_scale_override }
}

// ── R-15a: Retained-buffer GPU rendering helpers ──────────────────────────────────────────────────
Expand Down Expand Up @@ -317,8 +328,8 @@ async fn main() {
// Terrain top-face coloring: 'C' toggles Height↔Material at runtime (rebuilds the baked meshes).
// Default = Height (hypsometric relief ramp) so elevation shape reads at a glance.
let mut color_mode = crate::biome_palette::ColorMode::Height;
let mut hex_terrain_chunks = terrain::build_hex_terrain(world_dim, world.as_ref(), color_mode);
let mut cube_terrain_chunks = terrain_cube::build_cube_terrain(world_dim, world.as_ref(), color_mode);
let mut hex_terrain_chunks = terrain::build_hex_terrain(world_dim, world.as_ref(), color_mode, config.seed, cli_args.bare_mode);
let mut cube_terrain_chunks = terrain_cube::build_cube_terrain(world_dim, world.as_ref(), color_mode, config.seed, cli_args.bare_mode);

// R-15a: Retained-buffer GPU terrain initialization (if --retained).
let (mut gpu_hex_chunks, mut gpu_cube_chunks, gpu_pipeline) = if cli_args.retained {
Expand Down Expand Up @@ -872,8 +883,8 @@ async fn main() {
crate::biome_palette::ColorMode::Height => crate::biome_palette::ColorMode::Material,
crate::biome_palette::ColorMode::Material => crate::biome_palette::ColorMode::Height,
};
hex_terrain_chunks = terrain::build_hex_terrain(world_dim, world.as_ref(), color_mode);
cube_terrain_chunks = terrain_cube::build_cube_terrain(world_dim, world.as_ref(), color_mode);
hex_terrain_chunks = terrain::build_hex_terrain(world_dim, world.as_ref(), color_mode, config.seed, cli_args.bare_mode);
cube_terrain_chunks = terrain_cube::build_cube_terrain(world_dim, world.as_ref(), color_mode, config.seed, cli_args.bare_mode);

// R-15a: Rebuild GPU chunks if retained mode is active
if cli_args.retained && gpu_pipeline.is_some() {
Expand Down Expand Up @@ -1124,8 +1135,8 @@ mod tests {
fn standalone_world_builds_nonempty_terrain() {
let dim = 64;
let world = world::ProcgenWorld::new(dim, cli::HMAX, cli::RESOURCE_BASE, SEED ^ cli::WORLD_SALT, None, false, false, false, false, false);
let hex_chunks = terrain::build_hex_terrain(dim, &world, crate::biome_palette::ColorMode::Height);
let cube_chunks = terrain_cube::build_cube_terrain(dim, &world, crate::biome_palette::ColorMode::Height);
let hex_chunks = terrain::build_hex_terrain(dim, &world, crate::biome_palette::ColorMode::Height, SEED, false);
let cube_chunks = terrain_cube::build_cube_terrain(dim, &world, crate::biome_palette::ColorMode::Height, SEED, false);
assert!(!hex_chunks.is_empty(), "hex terrain must produce at least one chunk");
assert!(!cube_chunks.is_empty(), "cube terrain must produce at least one chunk");
assert!(hex_chunks.iter().any(|c| !c.mesh.vertices.is_empty()));
Expand Down
Loading
Loading