//! Scene assembly: snapshot + camera + raycast + shade → frame buffer //! (docs/05 §5.2 steps 1–5, wiring-plan §3). //! //! The single render entry point is [`render`]: for each cell column-half it //! casts a primary ray ([`ray`](crate::ray)), shades the hit //! ([`shade`](crate::shade)), snaps depth discontinuities to edge-art, //! composites snapshot billboards brightest-first, and applies stratum fog //! as a POST-shade lerp ([`apply_fog`]). Output is a //! [`FrameBuffer`](crate::FrameBuffer); ANSI encoding is [`ansi`](crate::ansi). //! //! # Ownership map (reviewer MINOR-2 arbitration) //! Stratum fog is a post-lerp on `shade()`'s fg/bg toward the active //! [`Palette`](crate::palette) fog color at the fog-density step — NOT a //! pre-lerp of material bases (unimplementable via `shade()`'s signature, //! whose base lookup is internal). Bases stay canonical so screenshot hashes //! pin one table; fog varies per stratum without touching it. //! //! # Camera routing (reviewer MINOR-4) //! Only [`Camera::FirstPerson`] / [`Camera::ChaseOrbit`] route through //! [`ray::ray_dir`] (perspective). [`Camera::TopTactical`] is orthographic //! and renders via [`render_tactical`] (plan-view glyphs, no rays) — //! tactical-ortho raycasting is a tracked follow-up, not this function. use crate::camera::View; use crate::palette::Palette; pub use crate::production::{ ascii_scene, edge_join, eligibility_samples, entity_eligible, render_scene, sample_visible, semantic_ascii, semantic_color, EntityDescriptor, HalfSample, RenderScratch, SceneBounds, SemanticRole, }; use crate::ray::{self, Frustum}; use crate::shade::{self, actor_glyph}; use crate::{Camera, Cell, FrameBuffer}; /// Max ray distance per quality tier (docs/05 §5.2 step 2). pub const MAX_DIST: f32 = 48.0; /// Lerp one channel toward fog by `t` (0 = full color, 1 = full fog). fn lerp_ch(c: u8, f: u8, t: f32) -> u8 { let t = t.clamp(0.0, 1.0); (f32::from(c) + (f32::from(f) - f32::from(c)) * t).round() as u8 } /// Post-shade stratum fog: lerp fg/bg toward the palette fog color for the /// hit's stratum at the fog-density step (docs/05 §5.1 fog strata). /// Pure function — the same inputs always yield the same outputs. pub fn apply_fog(fg: [u8; 3], bg: [u8; 3], fog: [u8; 3], fog_frac: f32) -> ([u8; 3], [u8; 3]) { let t = fog_frac.clamp(0.0, 1.0); ( [ lerp_ch(fg[0], fog[0], t), lerp_ch(fg[1], fog[1], t), lerp_ch(fg[2], fog[2], t), ], [ lerp_ch(bg[0], fog[0], t), lerp_ch(bg[1], fog[1], t), lerp_ch(bg[2], fog[2], t), ], ) } /// Fog color for a world z against the palette (Shallows above −30, /// Works −30..−90, Deep below — provisional bands until worldgen strata /// volumes land in Phase 10; documented here so the hash rig pins it). pub fn fog_for_z(pal: &Palette, z: f32) -> [u8; 3] { if z < -90.0 { pal.fog_deep } else if z < -30.0 { pal.fog_works } else { pal.fog_shallows } } /// One shaded half-block sample plus its display depth (INFINITY = miss). struct Sample { cell: Cell, dist: f32, face: Option, } fn miss_sample() -> Sample { Sample { cell: Cell { glyph: ' ', fg: [0, 0, 0], bg: [0, 0, 0], }, dist: f32::INFINITY, face: None, } } /// Render one frame: perspective raycast + shade + edge-art + fog into `fb` /// (docs/05 §5.2 steps 2–5). `lights` maps world cells to 24-step levels; /// pass `&[(x, y, z, level)]` sampled from the sim light plane. /// `actors` are `(species, pos, stance, lit, phase)` billboards composited /// brightest-first after opaques (docs/05 §5.2 step 4). /// /// Two passes, both deterministic: pass 1 shades every half-block sample; /// pass 2 snaps horizontal depth discontinuities to edge-art via /// [`shade::edge_glyph`] (right-neighbor diff) and packs bright-over-dim /// into cells. pub fn render( grid: &hexwork_core::voxel::ChunkedGrid, view: &View, pal: &Palette, fb: &mut FrameBuffer, lights: &[(i32, i32, i32, u8)], actors: &[(&str, [f32; 3], hexwork_core::actor::Stance, bool, u8)], ) { assert!( !view.is_orthographic(), "perspective render only; use render_tactical for TopTactical (MINOR-4)" ); // View → Frustum: View clamps FOV (except NaN); Frustum::new asserts. // NaN FOV is a CLI-boundary error — reject here, never panic deep. assert!( view.fov_deg.is_finite(), "NaN FOV must be validated at the CLI boundary (MINOR-3)" ); if fb.w == 0 || fb.h == 0 { return; } let frustum = Frustum::new(fb.w, fb.h, view.fov_deg, view.yaw, view.pitch); // Pass 1: shade every half-block sample. let mut samples = Vec::with_capacity(fb.w * fb.h * 2); for y in 0..fb.h { for x in 0..fb.w { for half in 0..2 { let dir = ray::ray_dir(&frustum, x, y * 2 + half); match ray::cast(grid, view.eye, dir, MAX_DIST) { None => samples.push(miss_sample()), Some(hit) => { let fog_frac = (hit.dist / MAX_DIST).clamp(0.0, 1.0); let warm = hit.face == ray::Face::ZPos; // Solid voxels stop the light flood: their visible face // receives light from the adjacent air cell, not the rock. let (dx, dy, dz) = match hit.face { ray::Face::XPos => (1, 0, 0), ray::Face::XNeg => (-1, 0, 0), ray::Face::YPos => (0, 1, 0), ray::Face::YNeg => (0, -1, 0), ray::Face::ZPos => (0, 0, 1), ray::Face::ZNeg => (0, 0, -1), }; let lvl = light_at(lights, hit.x, hit.y, hit.z).max(light_at( lights, hit.x + dx, hit.y + dy, hit.z + dz, )); let (glyph, fg, bg) = shade::shade(&hit, lvl, fog_frac, warm); let (fg, bg) = apply_fog(fg, bg, fog_for_z(pal, hit.z as f32), fog_frac); samples.push(Sample { cell: Cell { glyph, fg, bg }, dist: hit.dist, face: Some(hit.face), }); } } } } } let at = |x: usize, y: usize, half: usize, w: usize| (y * w + x) * 2 + half; // Pass 2: edge-art snap (right-neighbor depth diff per half-row) + pack. let mut depths = vec![f32::INFINITY; fb.w * fb.h]; for y in 0..fb.h { for x in 0..fb.w { for half in 0..2 { let i = at(x, y, half, fb.w); if samples[i].face.is_none() { continue; } // Right-neighbor diff; at the right edge compare the two // halves of this cell instead (never out of bounds). let ndist = if x + 1 < fb.w { samples[at(x + 1, y, half, fb.w)].dist } else { samples[at(x, y, 1 - half, fb.w)].dist }; let diff = (samples[i].dist - ndist).abs(); if let Some(face) = samples[i].face { if let Some(edge) = shade::edge_glyph(face, diff) { samples[i].cell.glyph = edge; } } } // Brightest half-block wins the cell (simple, deterministic). let top = &samples[at(x, y, 0, fb.w)]; let bottom = &samples[at(x, y, 1, fb.w)]; let bright = if brightness(top.cell) >= brightness(bottom.cell) { top } else { bottom }; let i = y * fb.w + x; fb.cells[i] = Cell { glyph: bright.cell.glyph, fg: bright.cell.fg, // Density and line-art glyphs need their shaded, dark partner. // The other sample's foreground is only meaningful with an // actual oriented half-block glyph; using it here erases flat // surfaces and edges (fg ~= bg). bg: bright.cell.bg, }; depths[i] = bright.dist; } } // Billboards brightest-first: sort far→near so near overwrites far. // Deterministic by (dist desc, index asc). let mut order: Vec<(f32, usize)> = actors .iter() .enumerate() .map(|(i, a)| { let dx = a.1[0] - view.eye[0]; let dy = a.1[1] - view.eye[1]; let dz = a.1[2] - view.eye[2]; (dx * dx + dy * dy + dz * dz, i) }) .collect(); order.sort_by(|a, b| { b.0.partial_cmp(&a.0) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| a.1.cmp(&b.1)) }); for (_, i) in order { let (species, pos, stance, lit, phase) = &actors[i]; // Project: crude pinhole onto the cell grid (deterministic; the // full billboard projection lands with the threaded renderer). let dx = pos[0] - view.eye[0]; let dy = pos[1] - view.eye[1]; if dx <= 0.1 || fb.w == 0 { continue; } let col = ((dy / dx * fb.w as f32 * 0.5 + fb.w as f32 * 0.5) as usize).min(fb.w - 1); let row = (fb.h / 2).min(fb.h - 1); let idx = row * fb.w + col; let dist_sq = dx * dx + dy * dy + (pos[2] - view.eye[2]).powi(2); if dist_sq.sqrt() < depths[idx] { fb.cells[idx].glyph = actor_glyph(species, *stance, *lit, *phase); } } } /// Immutable active-location bounds (inclusive) and authoritative player cell. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TacticalMapContext { pub min: [i32; 3], pub max: [i32; 3], pub player_cell: [i32; 3], } /// Terrain-only orthographic map at the explicit selected body plane. /// Shares the bounded map classifier, without location bounds or a marker. pub fn render_tactical(grid: &hexwork_core::voxel::ChunkedGrid, view: &View, fb: &mut FrameBuffer) { render_tactical_inner(grid, view, None, fb); } /// Bounded selected-body-plane map: `#` body/head obstruction, `~` water, /// `:` gas, `.` occupied support, and space for unsupported air/outside bounds. /// Support is not a complete movement-legality claim. Only the player's own /// on-slice position is overlaid; no other entity or reveal policy is inferred. pub fn render_tactical_map( grid: &hexwork_core::voxel::ChunkedGrid, view: &View, context: &TacticalMapContext, fb: &mut FrameBuffer, ) { render_tactical_inner(grid, view, Some(context), fb); } fn tactical_contains(context: Option<&TacticalMapContext>, cell: [i32; 3]) -> bool { context.map_or(true, |c| { (0..3).all(|i| c.min[i] <= cell[i] && cell[i] <= c.max[i]) }) } fn tactical_center(value: f32) -> Option { let value = f64::from(value); (value.is_finite() && value >= f64::from(i32::MIN) && value <= f64::from(i32::MAX)) .then_some(value as i32) } fn tactical_terrain( grid: &hexwork_core::voxel::ChunkedGrid, context: Option<&TacticalMapContext>, cell: [i32; 3], ) -> char { if !tactical_contains(context, cell) { return ' '; } let [x, y, z] = cell; let body = grid.get_world(x, y, z); let occupied = |z| tactical_contains(context, [x, y, z]) && grid.get_world(x, y, z).occ != 0; if body.occ != 0 || z.checked_add(1).is_some_and(occupied) { '#' } else if body.fluid_kind == 1 && body.fluid_amt > 0 { '~' } else if body.gas_kind != 0 && body.gas_amt > 0 { ':' } else if z.checked_sub(1).is_some_and(occupied) { '.' } else { ' ' } } fn render_tactical_inner( grid: &hexwork_core::voxel::ChunkedGrid, view: &View, context: Option<&TacticalMapContext>, fb: &mut FrameBuffer, ) { assert!( view.is_orthographic(), "render_tactical needs a TopTactical view" ); let slice_z = view.tactical_slice_z.expect( "TopTactical view must carry explicit tactical_slice_z metadata; construct it with View::new or top_tactical", ); fb.cells.fill(Cell { glyph: ' ', fg: [200, 200, 200], bg: [0, 0, 0], }); if fb.w == 0 || fb.h == 0 { return; } let (Some(cx), Some(cy)) = (tactical_center(view.eye[0]), tactical_center(view.eye[1])) else { return; }; if context.is_some_and(|c| { (0..3).any(|i| c.min[i] > c.max[i]) || slice_z < c.min[2] || slice_z > c.max[2] }) { return; } // i128 accommodates usize framebuffer offsets plus any i32 world center. let left = i128::from(cx) - (fb.w / 2) as i128; let top = i128::from(cy) - (fb.h / 2) as i128; for y in 0..fb.h { for x in 0..fb.w { let (Ok(wx), Ok(wy)) = ( i32::try_from(left + x as i128), i32::try_from(top + y as i128), ) else { continue; }; fb.cells[y * fb.w + x].glyph = tactical_terrain(grid, context, [wx, wy, slice_z]); } } // Constant-time overlay, on precisely the same projection as terrain. if let Some(c) = context { if c.player_cell[2] == slice_z && tactical_contains(context, c.player_cell) { let x = i128::from(c.player_cell[0]) - left; let y = i128::from(c.player_cell[1]) - top; if x >= 0 && x < fb.w as i128 && y >= 0 && y < fb.h as i128 { fb.cells[y as usize * fb.w + x as usize].glyph = '@'; } } } } fn brightness(c: Cell) -> u16 { u16::from(c.fg[0]) + u16::from(c.fg[1]) + u16::from(c.fg[2]) } fn light_at(lights: &[(i32, i32, i32, u8)], x: i32, y: i32, z: i32) -> u8 { lights .iter() .find(|l| l.0 == x && l.1 == y && l.2 == z) .map(|l| l.3) .unwrap_or(0) } /// Silence the unused [`Camera`] import warning when docs link it. #[allow(dead_code)] fn _camera_doc_link() { let _ = Camera::FirstPerson; }