//! Production scene path; compatibility scene::render remains unchanged. use crate::{ camera::View, palette::Palette, projection::Projection, ray, scene, shade, Camera, Cell, FrameBuffer, }; use hexwork_core::voxel::ChunkedGrid; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SemanticRole { Player, Actor, Threat, Npc, Item, Connector, Interaction, } /// Anchor is centered feet/access cell; eligibility uses role-defined samples. #[derive(Clone, Copy, Debug)] pub struct EntityDescriptor { pub id: u64, pub anchor: [f32; 3], pub height: f32, pub glyph: char, pub role: SemanticRole, pub local_light: u8, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SceneBounds { pub min: [i32; 3], pub max: [i32; 3], } impl SceneBounds { pub fn contains(&self, cell: [i32; 3]) -> bool { (0..3).all(|i| self.min[i] <= cell[i] && cell[i] <= self.max[i]) } pub fn contains_point(&self, p: [f32; 3]) -> bool { p.iter().all(|v| v.is_finite()) && self.contains(p.map(|v| v.floor() as i32)) } } #[derive(Clone, Copy, Debug)] pub struct HalfSample { pub hit: Option, pub world: [f32; 3], pub ray: [f32; 3], pub distance: f32, pub color: [u8; 3], pub role: Option, pub entity_id: Option, pub glyph: char, } impl Default for HalfSample { fn default() -> Self { Self { hit: None, world: [0.0; 3], ray: [0.0; 3], distance: f32::INFINITY, color: [0; 3], role: None, entity_id: None, glyph: ' ', } } } #[derive(Default, Debug)] pub struct RenderScratch { samples: Vec, lights: Vec<(i32, i32, i32, u8)>, roles: Vec>, brute_billboards: bool, } impl RenderScratch { /// Reference-validation hook: disable only billboard coarse bounds. #[doc(hidden)] pub fn set_brute_billboards_for_testing(&mut self, brute: bool) { self.brute_billboards = brute; } pub fn half_samples(&self) -> &[HalfSample] { &self.samples } pub fn cell_roles(&self) -> &[Option] { &self.roles } fn light(&self, p: [i32; 3]) -> u8 { self.lights .binary_search_by_key(&(p[0], p[1], p[2]), |v| (v.0, v.1, v.2)) .ok() .map_or(0, |i| self.lights[i].3) } } /// Role-defined eligibility points: actor torso/head clamped to body height, /// ground item +0.15m, connector/interaction access center +0.5m. pub fn eligibility_samples(e: &EntityDescriptor) -> [[f32; 3]; 2] { let offsets = match e.role { SemanticRole::Player | SemanticRole::Actor | SemanticRole::Threat | SemanticRole::Npc => [ (e.height * 0.5).min(0.9), (e.height - 0.1).max(0.0).min(1.6), ], SemanticRole::Item => [0.15; 2], _ => [0.5; 2], }; offsets.map(|z| [e.anchor[0], e.anchor[1], e.anchor[2] + z]) } /// Exact single-sample LOS/range/bounds predicate, independent of camera. /// Pair this with positive light at `point` before accepting the sample. pub fn sample_visible( grid: &ChunkedGrid, eye: [f32; 3], point: [f32; 3], bounds: &SceneBounds, ) -> bool { if !bounds.contains_point(eye) || !bounds.contains_point(point) { return false; } let delta = std::array::from_fn(|i| point[i] - eye[i]); let distance = delta.iter().map(|v| v * v).sum::().sqrt(); distance.is_finite() && distance <= scene::MAX_DIST && (distance < 0.01 || ray::cast(grid, eye, delta, distance).is_none_or(|hit| hit.dist + 0.01 >= distance)) } /// Camera-independent eligibility. local_light is the positive local level /// supplied for the entity; callers needing per-sample light must test those /// sample levels before calling. Active location membership is caller-owned. pub fn entity_eligible( grid: &ChunkedGrid, player_eye: [f32; 3], entity: &EntityDescriptor, bounds: &SceneBounds, ) -> bool { if !entity.height.is_finite() || entity.height <= 0.0 || entity.local_light == 0 || !bounds.contains_point(entity.anchor) || !bounds.contains_point(player_eye) { return false; } eligibility_samples(entity).into_iter().any(|p| { if !bounds.contains_point(p) { return false; } let delta = std::array::from_fn(|i| p[i] - player_eye[i]); let distance = delta.iter().map(|v| v * v).sum::().sqrt(); distance.is_finite() && distance <= scene::MAX_DIST && (distance < 0.01 || ray::cast(grid, player_eye, delta, distance) .is_none_or(|hit| hit.dist + 0.01 >= distance)) }) } /// Shared semantic reflectance and local-light transfer for perspective/tactical. /// Zero light stays black; only colorblind palettes substitute threat yellow. pub fn semantic_color(role: SemanticRole, local_light: u8, palette: &Palette) -> [u8; 3] { let colorblind = matches!(palette.name, "protan" | "deutan"); let base: [u8; 3] = match role { SemanticRole::Player => [255, 180, 32], SemanticRole::Threat if colorblind => [255, 220, 80], SemanticRole::Threat if palette.name == "tritan" => [255, 90, 140], SemanticRole::Npc if colorblind => [90, 150, 255], SemanticRole::Npc if palette.name == "tritan" => [80, 240, 220], SemanticRole::Threat => [255, 70, 45], SemanticRole::Npc => [100, 190, 255], SemanticRole::Item => [255, 220, 100], SemanticRole::Connector => [180, 255, 220], SemanticRole::Interaction => [220, 180, 255], SemanticRole::Actor => [230; 3], }; base.map(|c| crate::transfer::encode(u16::from(c) * u16::from(local_light.min(24)))) } /// ASCII twin of a semantic glyph. Printable ASCII remains authoritative; /// Unicode arrows preserve direction, otherwise the semantic role supplies a cue. pub fn semantic_ascii(role: SemanticRole, glyph: char) -> char { if glyph.is_ascii_graphic() { return glyph; } match glyph { '↑' | '⇧' | '▲' => '^', '↓' | '⇩' | '▼' => 'v', '←' | '◀' => '<', '→' | '▶' => '>', '⚠' => '!', '†' | '✝' => '+', _ => match role { SemanticRole::Player => '@', SemanticRole::Threat => '!', SemanticRole::Actor | SemanticRole::Npc => 'n', SemanticRole::Item => '*', SemanticRole::Connector => '>', SemanticRole::Interaction => '&', }, } } /// ASCII conversion preserves typed entities (including punctuation glyphs). pub fn ascii_scene(fb: &mut FrameBuffer, scratch: &RenderScratch) { for (i, c) in fb.cells.iter_mut().enumerate() { if let Some(role) = scratch.roles.get(i).copied().flatten() { c.glyph = semantic_ascii(role, c.glyph); continue; } if matches!(c.glyph, '▀' | '▄' | '█' | '▓' | '▒' | '░') { let brightness = |rgb: [u8; 3]| { (u32::from(rgb[0]) * 3 + u32::from(rgb[1]) * 6 + u32::from(rgb[2])) / 10 }; let level = (brightness(c.fg) + brightness(c.bg)) / 2; const DENSITY: [char; 10] = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@']; c.glyph = DENSITY[(level * 9 / 255) as usize]; // Preserve either half's strong illumination for no-color bold. c.fg = std::array::from_fn(|j| c.fg[j].max(c.bg[j])); c.bg = [0; 3]; continue; } c.glyph = match c.glyph { '│' => '|', '─' => '-', '┼' | '┌' | '┐' | '└' | '┘' | '├' | '┤' | '┬' | '┴' => '+', g if g.is_ascii() => g, _ => '.', }; } } // Boundary bits: north=1, east=2, south=4, west=8. Screen borders // are not implicit geometry boundaries. Compare face planes, not ray lengths: // ray length changes across an oblique flat surface are not silhouettes. fn boundary(a: HalfSample, b: HalfSample) -> bool { let Some(ah) = a.hit else { return false; }; let Some(bh) = b.hit else { return true; }; if ah.face != bh.face { return true; } let plane = |h: ray::RayHit| match h.face { ray::Face::XNeg | ray::Face::XPos => h.x, ray::Face::YNeg | ray::Face::YPos => h.y, ray::Face::ZNeg | ray::Face::ZPos => h.z, }; // Integer plane depth avoids infinity subtraction and fixed-point hit noise. plane(ah) != plane(bh) } fn boundary_mask(samples: &[HalfSample], w: usize, h2: usize, x: usize, y: usize) -> u8 { let s = samples[y * w + x]; let mut mask = 0; for (dx, dy, bit) in [(0, -1, 1), (1, 0, 2), (0, 1, 4), (-1, 0, 8)] { let nx = x as isize + dx; let ny = y as isize + dy; if nx >= 0 && ny >= 0 && nx < w as isize && ny < h2 as isize && boundary(s, samples[ny as usize * w + nx as usize]) { mask |= bit; } } mask } /// Full-cell boundary art, including all four corners and tee/cross joins. /// Bits describe outward boundary sides: north=1,east=2,south=4,west=8. pub fn edge_join(mask: u8) -> char { match mask & 15 { 0 => '▀', 1 | 4 | 5 => '─', 2 | 8 | 10 => '│', 3 => '┐', 6 => '┘', 9 => '┌', 12 => '└', 7 => '┤', 11 => '┬', 13 => '├', 14 => '┴', 15 => '┼', _ => unreachable!(), } } // Clip to the inclusive voxel AABB before DDA, so remote loaded geometry // between an outside chase eye and this location cannot shadow the location. fn bounded_cast( grid: &ChunkedGrid, eye: [f32; 3], dir: [f32; 3], bounds: &SceneBounds, ) -> Option { let mut enter = 0.0f32; let mut exit = scene::MAX_DIST; for j in 0..3 { let lo = bounds.min[j] as f32; let hi = bounds.max[j] as f32 + 1.0; if lo >= hi { return None; } if dir[j].abs() < 1e-8 { if eye[j] < lo || eye[j] >= hi { return None; } continue; } let a = (lo - eye[j]) / dir[j]; let b = (hi - eye[j]) / dir[j]; enter = enter.max(a.min(b)); exit = exit.min(a.max(b)); } if enter >= exit { return None; } // Step one fixed-point unit inside when entering from outside. let start = if enter > 0.0 { enter + 0.002 } else { 0.0 }; if start >= exit { return None; } let origin = std::array::from_fn(|j| eye[j] + dir[j] * start); let mut hit = ray::cast(grid, origin, dir, exit - start)?; if !bounds.contains([hit.x, hit.y, hit.z]) { return None; } hit.dist += start; Some(hit) } // Conservative half-sample rectangle for a camera-facing billboard. Derive // screen slopes from shared Projection rays, not a second FOV/aspect formula. // All four corners have the center's forward depth because offsets are along // projection.right/up. Near-plane numerical ambiguity uses full scan; the // existing exact plane-depth predicate remains authoritative. Never use the // clipped project() result to infer that an offscreen corner means invisibility. fn billboard_rect( projection: &Projection, delta: [f32; 3], depth: f32, half_width: f32, half_height: f32, w: usize, h2: usize, ) -> [usize; 4] { let full = [0, w, 0, h2]; if depth <= crate::projection::NEAR * 2.0 || w < 2 || h2 < 2 { return full; } let dot = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).map(|(x, y)| x * y).sum::(); let first = projection.ray(0, 0); let last = projection.ray(w - 1, h2 - 1); let slope = |ray: [f32; 3], axis: [f32; 3]| dot(ray, axis) / dot(ray, projection.forward); let left = slope(first, projection.right); let right = slope(last, projection.right); let top = slope(first, projection.up); let bottom = slope(last, projection.up); let center_x = dot(delta, projection.right); let center_y = dot(delta, projection.up); let x0 = ((center_x - half_width) / depth - left) / (right - left) * (w - 1) as f32; let x1 = ((center_x + half_width) / depth - left) / (right - left) * (w - 1) as f32; let y0 = ((center_y + half_height) / depth - top) / (bottom - top) * (h2 - 1) as f32; let y1 = ((center_y - half_height) / depth - top) / (bottom - top) * (h2 - 1) as f32; if ![x0, x1, y0, y1].iter().all(|v| v.is_finite()) { return full; } // Two-sample outward padding covers floating point projection/intersection // disagreement at edges; exact ray/rectangle tests reject extra candidates. let low = |v: f32, n: usize| (v.floor() - 2.0).clamp(0.0, n as f32) as usize; let high = |v: f32, n: usize| (v.ceil() + 3.0).clamp(0.0, n as f32) as usize; [ low(x0.min(x1), w), high(x0.max(x1), w), low(y0.min(y1), h2), high(y0.max(y1), h2), ] } /// Bounded production perspective. Eligibility is separate and must be applied /// by the caller with entity_eligible (display camera is not the player eye). pub fn render_scene( grid: &ChunkedGrid, view: &View, palette: &Palette, framebuffer: &mut FrameBuffer, lights: &[(i32, i32, i32, u8)], entities: &[EntityDescriptor], bounds: &SceneBounds, scratch: &mut RenderScratch, ) { let fb = framebuffer; fb.clear(); let n = fb.w.saturating_mul(fb.h); scratch.roles.resize(n, None); scratch.roles.fill(None); scratch.samples.clear(); let Some(projection) = Projection::new(view, fb.w, fb.h) else { return; }; if fb.cells.len() < n { return; } scratch.lights.clear(); scratch.lights.extend_from_slice(lights); scratch .lights .sort_unstable_by_key(|v| (v.0, v.1, v.2, std::cmp::Reverse(v.3))); scratch.lights.dedup_by_key(|v| (v.0, v.1, v.2)); scratch.samples.resize(n * 2, HalfSample::default()); for y in 0..fb.h * 2 { for x in 0..fb.w { let direction = projection.ray(x, y); let i = y * fb.w + x; let mut sample = HalfSample { ray: direction, ..HalfSample::default() }; if let Some(hit) = bounded_cast(grid, view.eye, direction, bounds) { let normal = match hit.face { ray::Face::XNeg => [-1, 0, 0], ray::Face::XPos => [1, 0, 0], ray::Face::YNeg => [0, -1, 0], ray::Face::YPos => [0, 1, 0], ray::Face::ZNeg => [0, 0, -1], ray::Face::ZPos => [0, 0, 1], }; let p = [hit.x, hit.y, hit.z]; let air = std::array::from_fn(|j| p[j] + normal[j]); let level = scratch.light(p).max(if bounds.contains(air) { scratch.light(air) } else { 0 }); let fog = hit.dist / scene::MAX_DIST; let (_, fg, bg) = shade::shade(&hit, level, fog, hit.face == ray::Face::ZPos); // Fog must not illuminate a completely unlit surface. let color = if level == 0 { [0; 3] } else { scene::apply_fog(fg, bg, scene::fog_for_z(palette, hit.z as f32), fog).0 }; sample.hit = Some(hit); sample.world = std::array::from_fn(|j| view.eye[j] + direction[j] * hit.dist); sample.distance = hit.dist; sample.color = color; } scratch.samples[i] = sample; } } for e in entities { if (view.mode == Camera::FirstPerson && e.role == SemanticRole::Player) || e.local_light == 0 || !e.height.is_finite() || e.height <= 0.0 || !bounds.contains_point(e.anchor) { continue; } // Camera-facing rectangle rasterized at the SAME center rays as terrain. // Width is a bounded visual footprint; anchor is feet and height is vertical // along camera up. This also provides coverage between former point samples. let center = [e.anchor[0], e.anchor[1], e.anchor[2] + e.height * 0.5]; let delta: [f32; 3] = std::array::from_fn(|j| center[j] - view.eye[j]); let dot = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).map(|(x, y)| x * y).sum::(); let plane_depth = dot(delta, projection.forward); if plane_depth < crate::projection::NEAR { continue; } let half_width = (e.height * 0.45).clamp(0.25, 0.9) * 0.5; let [x0, x1, y0, y1] = if scratch.brute_billboards { [0, fb.w, 0, fb.h * 2] } else { billboard_rect( &projection, delta, plane_depth, half_width, e.height * 0.5, fb.w, fb.h * 2, ) }; if x0 >= x1 || y0 >= y1 { continue; } let color = semantic_color(e.role, e.local_light, palette); for y in y0..y1 { for s in &mut scratch.samples[y * fb.w + x0..y * fb.w + x1] { let denominator = dot(s.ray, projection.forward); if denominator <= 0.0 { continue; } let distance = plane_depth / denominator; if !distance.is_finite() || distance > scene::MAX_DIST { continue; } let p = std::array::from_fn(|j| view.eye[j] + s.ray[j] * distance); let relative = std::array::from_fn(|j| p[j] - center[j]); if dot(relative, projection.right).abs() > half_width || dot(relative, projection.up).abs() > e.height * 0.5 || !bounds.contains_point(p) { continue; } if distance < s.distance - 0.001 || ((distance - s.distance).abs() <= 0.001 && s.entity_id.is_some_and(|id| e.id < id)) { s.distance = distance; s.world = p; s.role = Some(e.role); s.entity_id = Some(e.id); s.glyph = e.glyph; s.color = color; } } } } for y in 0..fb.h { for x in 0..fb.w { let a = scratch.samples[(2 * y) * fb.w + x]; let b = scratch.samples[(2 * y + 1) * fb.w + x]; let i = y * fb.w + x; if a.color == [0; 3] && b.color == [0; 3] && a.role.is_none() && b.role.is_none() { fb.cells[i] = Cell::default(); continue; } // Each semantic sample already won occlusion on its own center ray. // The full glyph is an anchor symbol for that defined visible sample, // NOT an assertion that its rectangle covers the partner terrain. // Choose lower stable ID, then upper sample on ties; never compare t // across different half rays. Keep all half depths/data unchanged. let entity = match (a.role, b.role) { (Some(_), Some(_)) => Some(if a.entity_id <= b.entity_id { (a, b) } else { (b, a) }), (Some(_), None) => Some((a, b)), (None, Some(_)) => Some((b, a)), _ => None, }; if let Some((e, partner)) = entity { scratch.roles[i] = e.role; fb.cells[i] = Cell { glyph: e.glyph, fg: e.color, // When clipped, fg=semantic and bg=partner terrain, including // nearer ledges. ASCII may collapse terrain but MUST keep role. bg: if partner.role.is_none() { partner.color } else { [0; 3] }, }; continue; } let cell_mask = |cx: usize, cy: usize| { boundary_mask(&scratch.samples, fb.w, fb.h * 2, cx, 2 * cy) | boundary_mask(&scratch.samples, fb.w, fb.h * 2, cx, 2 * cy + 1) }; let mut mask = cell_mask(x, y); // Concave joins sit on the solid diagonal of an opening: connect // neighboring horizontal/vertical runs using four-neighbor masks. if mask == 0 && a.hit.is_some() && b.hit.is_some() { let n = if y > 0 { cell_mask(x, y - 1) } else { 0 }; let s = if y + 1 < fb.h { cell_mask(x, y + 1) } else { 0 }; let w = if x > 0 { cell_mask(x - 1, y) } else { 0 }; let e = if x + 1 < fb.w { cell_mask(x + 1, y) } else { 0 }; if e & 4 != 0 && s & 2 != 0 { mask = 6; } else if w & 4 != 0 && s & 8 != 0 { mask = 12; } else if e & 1 != 0 && n & 2 != 0 { mask = 3; } else if w & 1 != 0 && n & 8 != 0 { mask = 9; } } // Full-cell edge policy; original half hit/depth stays in scratch. let glyph = edge_join(mask); fb.cells[i] = if mask != 0 { Cell { glyph, fg: if a.distance <= b.distance { a.color } else { b.color }, bg: [0; 3], } } else { Cell { glyph, fg: a.color, bg: b.color, } }; } } }