From a8faba8aad441e87f8a8b94c148c01f9c075d621 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 11 Oct 2026 13:12:50 +0400 Subject: feat: complete map viewer scene and static CTL pose preview Complete the interactive mission viewer with environment rendering, audio events, dynamic shadows, free-flight camera controls, and per-component CTL pose sampling. --- crates/fparkan-fx/src/sky.rs | 2079 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2079 insertions(+) create mode 100644 crates/fparkan-fx/src/sky.rs (limited to 'crates/fparkan-fx/src/sky.rs') diff --git a/crates/fparkan-fx/src/sky.rs b/crates/fparkan-fx/src/sky.rs new file mode 100644 index 0000000..f41e920 --- /dev/null +++ b/crates/fparkan-fx/src/sky.rs @@ -0,0 +1,2079 @@ +//! CPU-side sky dome, sky materials, fog, and sun output. +//! +//! The original `CSky` object is a finite dome rather than an equirectangular +//! skybox. This module keeps that geometry and the interpolated +//! `sky.ske`/`sky.wea` data independent of any graphics backend. A renderer +//! can upload [`SkyMesh`] once, update its vertex colors, and resolve the +//! material names in [`SkyMaterials`] through its normal resource path. + +use crate::atmosphere::{AtmosphereFrame, PackedColor, SkySample, SunBirth, SunSample}; +use std::fmt; + +/// Number of rows used by the original environment `sky.wea` table. +pub const SKY_MATERIAL_COUNT: usize = 9; + +/// Texture-coordinate scales used by the three original sky vertex stages. +/// +/// The values are the constants at PE `65E94`, `65E98`, and `65E9C` in the +/// original renderer. They are applied to `position.x / radius` and +/// `position.y / radius`. +pub const SKY_UV_SCALES: [f32; 3] = [1.0, 15.0, 3.0]; + +/// Material names observed in the AutoDemo `sky.wea` table. +/// +/// Missions are allowed to replace these names. Code should use the rows +/// returned by [`SkyMaterials::parse`] when resolving a real mission. +pub const OBSERVED_SKY_MATERIAL_NAMES: [&str; SKY_MATERIAL_COUNT] = [ + "ENV_NEBULA_0", + "ENV_STARS", + "ENV_CLOUDS", + "ENV_SUN_3", + "ENV_MOON", + "ENV_FLARE_00", + "ENV_FLARE_01", + "SNOWFLAKE", + "RAIN_DROP", +]; + +const TAU: f32 = std::f32::consts::TAU; +// These are the values written by Terrain's atmosphere-manager constructor: +// 0x6581C = 10000, and 0x6580C = 3.1415901184 is halved twice before the +// CSky constructor receives it. +const NATIVE_PI: f32 = 3.1415901184; +const DEFAULT_HEIGHT: f32 = 10_000.0; +const DEFAULT_THETA_MAX: f32 = NATIVE_PI * 0.25; +const DEFAULT_Z_SCALE: f32 = 1.0; +const DEFAULT_AZIMUTH_COUNT: usize = 16; +const DEFAULT_RINGS: usize = 5; +const FOG_DISTANCE_SCALE: f32 = 700.0; +const MAX_GEOMETRY_VERTICES: usize = 1_000_000; +// CSky::Render (481C5) uses these data constants when it converts the camera +// yaw to a four-color, ninety-degree horizon sector. +const NATIVE_GRADIENT_SEGMENT_DEGREES: i64 = 90; +const NATIVE_GRADIENT_FRACTION_SCALE: f32 = f32::from_bits(0x3C36_0B61); +const NATIVE_GRADIENT_ZERO: f32 = 0.0; +const NATIVE_GRADIENT_ONE: f32 = 1.0; +const NATIVE_GRADIENT_THREE: f32 = 3.0; +const NATIVE_GRADIENT_SHORT_SCALE: f32 = 0.8; +const NATIVE_GRADIENT_LONG_SCALE: f32 = 0.1; +const NATIVE_GRADIENT_LONG_OFFSET: f32 = 0.7; + +/// Configuration passed to the original `CSky` dome constructor. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SkyGeometryConfig { + /// Dome height before the z scale is applied. + pub height: f32, + /// Maximum polar angle in radians. + pub theta_max: f32, + /// Vertical scale applied to the generated positions. + pub z_scale: f32, + /// Number of azimuth sectors around the dome. + pub azimuth_count: usize, + /// Number of vertices in each azimuth sector after the zenith vertex. + pub rings: usize, +} + +impl SkyGeometryConfig { + /// Creates a geometry configuration. + #[must_use] + pub const fn new( + height: f32, + theta_max: f32, + z_scale: f32, + azimuth_count: usize, + rings: usize, + ) -> Self { + Self { + height, + theta_max, + z_scale, + azimuth_count, + rings, + } + } + + /// Returns the radius used by the original constructor. + #[must_use] + pub fn radius(self) -> f32 { + let half_theta = self.theta_max * 0.5; + self.height * 0.5 / half_theta.sin().powi(2) + } + + /// Builds a colored sky mesh and validates all counts and floating point + /// inputs. + pub fn mesh(self, sample: &SkySample) -> Result { + self.try_mesh(sample) + } + + /// Builds a colored sky mesh and validates all counts and floating point + /// inputs. + pub fn try_mesh(self, sample: &SkySample) -> Result { + let mut mesh = self.try_topology()?; + mesh.update_colors(sample); + Ok(mesh) + } + + /// Builds the immutable dome topology. + /// + /// Vertex colors are initialized to the packed zero value and are filled + /// by [`SkyMesh::update_colors`] when the first atmosphere sample arrives. + /// Keeping topology construction separate lets a renderer upload the + /// position/normal/UV/index buffers once. + pub fn try_topology(self) -> Result { + validate_geometry(self)?; + let vertex_count = self + .azimuth_count + .checked_mul(self.rings) + .and_then(|count| count.checked_add(1)) + .ok_or(SkyGeometryError::CountOverflow)?; + if vertex_count > MAX_GEOMETRY_VERTICES { + return Err(SkyGeometryError::TooManyVertices { vertex_count }); + } + let triangle_count = self + .azimuth_count + .checked_mul( + self.rings + .checked_mul(2) + .and_then(|count| count.checked_sub(1)) + .ok_or(SkyGeometryError::CountOverflow)?, + ) + .ok_or(SkyGeometryError::CountOverflow)?; + let index_count = triangle_count + .checked_mul(3) + .ok_or(SkyGeometryError::CountOverflow)?; + let radius = self.radius(); + if !radius.is_finite() || radius <= 0.0 { + return Err(SkyGeometryError::NonFiniteRadius); + } + + let mut vertices = Vec::with_capacity(vertex_count); + vertices.push(SkyVertex { + position: [0.0, 0.0, self.height * self.z_scale], + normal: [0, 0, 127], + uv: [[0.0, 0.0]; 3], + color: PackedColor::default(), + }); + + for azimuth in 0..self.azimuth_count { + let phi = azimuth as f32 / self.azimuth_count as f32 * TAU; + let sin_phi = phi.sin(); + let cos_phi = phi.cos(); + for ring in 0..self.rings { + let theta = (ring + 1) as f32 / self.rings as f32 * self.theta_max; + let sin_theta = theta.sin(); + let cos_theta = theta.cos(); + let position = [ + radius * sin_theta * sin_phi, + radius * sin_theta * cos_phi, + (radius * cos_theta + self.height - radius) * self.z_scale, + ]; + let normal = [ + normal_byte(sin_theta * sin_phi), + normal_byte(sin_theta * cos_phi), + normal_byte(cos_theta), + ]; + let uv = std::array::from_fn(|stage| { + [ + position[0] / radius * SKY_UV_SCALES[stage], + position[1] / radius * SKY_UV_SCALES[stage], + ] + }); + vertices.push(SkyVertex { + position, + normal, + uv, + color: PackedColor::default(), + }); + } + } + + let mut indices = Vec::with_capacity(index_count); + for azimuth in 0..self.azimuth_count { + let base = 1 + azimuth * self.rings; + let next = 1 + ((azimuth + 1) % self.azimuth_count) * self.rings; + indices.push(0); + indices.extend_from_slice(&[ + u32::try_from(base).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(next).map_err(|_| SkyGeometryError::IndexOverflow)?, + ]); + for ring in 0..self.rings - 1 { + let a = base + ring; + let b = base + ring + 1; + let c = next + ring; + let d = next + ring + 1; + indices.extend_from_slice(&[ + u32::try_from(a).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(b).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(c).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(c).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(b).map_err(|_| SkyGeometryError::IndexOverflow)?, + u32::try_from(d).map_err(|_| SkyGeometryError::IndexOverflow)?, + ]); + } + } + + Ok(SkyMesh { + config: self, + radius, + vertices, + indices, + }) + } +} + +impl Default for SkyGeometryConfig { + fn default() -> Self { + Self::new( + DEFAULT_HEIGHT, + DEFAULT_THETA_MAX, + DEFAULT_Z_SCALE, + DEFAULT_AZIMUTH_COUNT, + DEFAULT_RINGS, + ) + } +} + +fn validate_geometry(config: SkyGeometryConfig) -> Result<(), SkyGeometryError> { + if !config.height.is_finite() || config.height <= 0.0 { + return Err(SkyGeometryError::InvalidHeight(config.height)); + } + if !config.theta_max.is_finite() || config.theta_max <= 0.0 || config.theta_max >= TAU { + return Err(SkyGeometryError::InvalidTheta(config.theta_max)); + } + if !config.z_scale.is_finite() { + return Err(SkyGeometryError::InvalidZScale(config.z_scale)); + } + if config.azimuth_count == 0 || config.rings == 0 { + return Err(SkyGeometryError::ZeroCount); + } + Ok(()) +} + +fn normal_byte(component: f32) -> i8 { + // Terrain's native sky constructor writes signed normal components with + // x87 nearest-even rounding and a 127 scale. Keep the signed type here; + // a renderer that uploads the legacy byte stream can cast it to u8. + (component * 127.0).round_ties_even().clamp(-128.0, 127.0) as i8 +} + +fn color_for_vertex( + sample: &SkySample, + ring: usize, + azimuth: usize, + azimuth_count: usize, +) -> PackedColor { + match ring { + 0 => sample.colors[12], + 1 => belt_color(sample, 8, azimuth, azimuth_count), + 2 => belt_color(sample, 4, azimuth, azimuth_count), + _ => belt_color(sample, 0, azimuth, azimuth_count), + } +} + +fn belt_color( + sample: &SkySample, + offset: usize, + azimuth: usize, + azimuth_count: usize, +) -> PackedColor { + let group_width = azimuth_count.saturating_add(3) / 4; + if group_width == 0 { + return sample.colors[offset]; + } + let anchor = (azimuth / group_width).min(3); + let next = (anchor + 1) & 3; + let factor = (azimuth % group_width) as f32 / group_width as f32; + sample.colors[offset + anchor].lerp(sample.colors[offset + next], factor) +} + +/// One CPU vertex in the original sky buffer layout. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SkyVertex { + /// World-space position. + pub position: [f32; 3], + /// Signed 8-bit normal components written by the original constructor. + pub normal: [i8; 3], + /// Three texture-coordinate stages, in original stage order. + pub uv: [[f32; 2]; 3], + /// Current ARGB vertex color. + pub color: PackedColor, +} + +impl SkyVertex { + /// Returns the packed color as normalized RGBA. + #[must_use] + pub fn color_rgba(self) -> [f32; 4] { + self.color.rgba() + } + + /// Decodes the native signed normal bytes to normalized components. + #[must_use] + pub fn normal_vector(self) -> [f32; 3] { + self.normal.map(|component| f32::from(component) / 127.0) + } +} + +/// Generated sky dome geometry and indices. +#[derive(Clone, Debug, PartialEq)] +pub struct SkyMesh { + /// Configuration used to generate this mesh. + pub config: SkyGeometryConfig, + /// Radius from the original constructor formula. + pub radius: f32, + /// Zenith followed by azimuth-major ring vertices. + pub vertices: Vec, + /// Triangle-list indices: one fan triangle and `rings - 1` quads per + /// azimuth sector. + pub indices: Vec, +} + +impl SkyMesh { + /// Number of triangles in the mesh. + #[must_use] + pub fn triangle_count(&self) -> usize { + self.indices.len() / 3 + } + + /// Updates only the dynamic vertex colors for an atmosphere sample. + /// + /// The vertex order is fixed by [`SkyGeometryConfig::try_topology`], so + /// this operation never reallocates the topology or index buffer. The + /// renderer can upload the changed color fields in place. + pub fn update_colors(&mut self, sample: &SkySample) { + let Some(zenith) = self.vertices.first_mut() else { + return; + }; + zenith.color = sample.colors[12]; + for azimuth in 0..self.config.azimuth_count { + let base = 1 + azimuth * self.config.rings; + for ring in 0..self.config.rings { + let Some(vertex) = self.vertices.get_mut(base + ring) else { + return; + }; + vertex.color = color_for_vertex(sample, ring, azimuth, self.config.azimuth_count); + } + } + } +} + +/// Errors found while constructing a sky dome. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SkyGeometryError { + /// Height must be finite and positive. + InvalidHeight(f32), + /// Polar angle must be finite and in `(0, TAU)`. + InvalidTheta(f32), + /// Vertical scale must be finite. + InvalidZScale(f32), + /// A sector or ring count was zero. + ZeroCount, + /// A count calculation overflowed. + CountOverflow, + /// Mesh allocation would exceed the bounded CPU contract. + TooManyVertices { vertex_count: usize }, + /// The radius formula did not produce a finite positive value. + NonFiniteRadius, + /// An index could not fit in the renderer-facing u32 index type. + IndexOverflow, +} + +impl fmt::Display for SkyGeometryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidHeight(value) => { + write!(f, "sky height must be finite and positive: {value}") + } + Self::InvalidTheta(value) => write!(f, "sky theta_max must be in (0, TAU): {value}"), + Self::InvalidZScale(value) => write!(f, "sky z_scale must be finite: {value}"), + Self::ZeroCount => write!(f, "sky azimuth and ring counts must be non-zero"), + Self::CountOverflow => write!(f, "sky geometry count overflow"), + Self::TooManyVertices { vertex_count } => { + write!(f, "sky geometry has too many vertices: {vertex_count}") + } + Self::NonFiniteRadius => write!(f, "sky geometry radius is not finite and positive"), + Self::IndexOverflow => write!(f, "sky geometry index does not fit in u32"), + } + } +} + +impl std::error::Error for SkyGeometryError {} + +/// One row from a `sky.wea` material table. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkyMaterialRow { + /// Legacy numeric id preserved from the source row. + pub legacy_id: i32, + /// Material lookup name. + pub name: String, +} + +/// Parsed dynamic material names from `sky.wea`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkyMaterials { + /// Main material rows in file order. + pub rows: Vec, + /// Optional lightmap rows after the `LIGHTMAPS` section. + pub lightmaps: Vec, +} + +impl SkyMaterials { + /// Parses a text `sky.wea` payload. + pub fn parse(bytes: &[u8]) -> Result { + let text = String::from_utf8_lossy(bytes); + let mut lines = text.lines().enumerate(); + let (count_line, count_text) = lines + .find_map(|(line, text)| (!text.trim().is_empty()).then_some((line, text.trim()))) + .ok_or(SkyWeaError::Empty)?; + let count = count_text + .parse::() + .map_err(|_| SkyWeaError::InvalidCount { + line: count_line, + text: count_text.to_string(), + })?; + if count == 0 { + return Err(SkyWeaError::ZeroCount); + } + + let mut rows = Vec::with_capacity(count); + for index in 0..count { + let Some((line, text)) = lines.next() else { + return Err(SkyWeaError::MissingRow { index, count }); + }; + let text = text.trim(); + if text.is_empty() { + return Err(SkyWeaError::InvalidRow { + index, + line, + text: text.to_string(), + }); + } + rows.push(parse_material_row(index, line, text)?); + } + + let mut lightmaps = Vec::new(); + let mut rest = lines.filter_map(|(line, text)| { + let trimmed = text.trim(); + (!trimmed.is_empty()).then_some((line, trimmed)) + }); + if let Some((line, marker)) = rest.next() { + if !marker.eq_ignore_ascii_case("LIGHTMAPS") { + return Err(SkyWeaError::UnexpectedTrailingLine { + line, + text: marker.to_string(), + }); + } + let Some((count_line, count_text)) = rest.next() else { + return Err(SkyWeaError::InvalidLightmapCount { + line, + text: String::new(), + }); + }; + let lightmap_count = + count_text + .parse::() + .map_err(|_| SkyWeaError::InvalidLightmapCount { + line: count_line, + text: count_text.to_string(), + })?; + lightmaps.reserve(lightmap_count); + for index in 0..lightmap_count { + let Some((row_line, row_text)) = rest.next() else { + return Err(SkyWeaError::MissingLightmapRow { + index, + count: lightmap_count, + }); + }; + lightmaps.push(parse_material_row(index, row_line, row_text)?); + } + if let Some((extra_line, extra_text)) = rest.next() { + return Err(SkyWeaError::UnexpectedTrailingLine { + line: extra_line, + text: extra_text.to_string(), + }); + } + } + + Ok(Self { rows, lightmaps }) + } + + /// Creates a table from caller-provided names, assigning positional ids. + #[must_use] + pub fn from_names(names: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + rows: names + .into_iter() + .enumerate() + .map(|(index, name)| SkyMaterialRow { + legacy_id: index as i32, + name: name.into(), + }) + .collect(), + lightmaps: Vec::new(), + } + } + + /// Returns the positional material row. + #[must_use] + pub fn row(&self, index: usize) -> Option<&SkyMaterialRow> { + self.rows.get(index) + } + + /// Returns the material name at a positional row. + #[must_use] + pub fn material_name(&self, index: usize) -> Option<&str> { + self.row(index).map(|row| row.name.as_str()) + } + + /// Returns all main material names in file order. + #[must_use] + pub fn names(&self) -> Vec<&str> { + self.rows.iter().map(|row| row.name.as_str()).collect() + } +} + +impl From> for SkyMaterials { + fn from(names: Vec) -> Self { + Self::from_names(names) + } +} + +impl Default for SkyMaterials { + fn default() -> Self { + Self::from_names(OBSERVED_SKY_MATERIAL_NAMES) + } +} + +fn parse_material_row( + index: usize, + line: usize, + text: &str, +) -> Result { + let mut parts = text.splitn(2, char::is_whitespace); + let legacy_text = parts.next().unwrap_or_default(); + let name = parts.next().unwrap_or_default().trim(); + let legacy_id = legacy_text + .parse::() + .map_err(|_| SkyWeaError::InvalidRow { + index, + line, + text: text.to_string(), + })?; + if name.is_empty() { + return Err(SkyWeaError::InvalidRow { + index, + line, + text: text.to_string(), + }); + } + Ok(SkyMaterialRow { + legacy_id, + name: name.to_string(), + }) +} + +/// Errors returned while parsing `sky.wea`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SkyWeaError { + /// Payload has no non-empty lines. + Empty, + /// Main row count is malformed. + InvalidCount { line: usize, text: String }, + /// Main table declared no rows. + ZeroCount, + /// A main row is missing. + MissingRow { index: usize, count: usize }, + /// A main or lightmap row is malformed. + InvalidRow { + index: usize, + line: usize, + text: String, + }, + /// Lightmap count is malformed. + InvalidLightmapCount { line: usize, text: String }, + /// A lightmap row is missing. + MissingLightmapRow { index: usize, count: usize }, + /// Non-empty data followed the main rows without a known section marker. + UnexpectedTrailingLine { line: usize, text: String }, +} + +impl fmt::Display for SkyWeaError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "sky.wea payload is empty"), + Self::InvalidCount { line, text } => { + write!(f, "invalid sky.wea count at line {line}: {text}") + } + Self::ZeroCount => write!(f, "sky.wea material count must be positive"), + Self::MissingRow { index, count } => { + write!(f, "missing sky.wea row {index} of {count}") + } + Self::InvalidRow { index, line, text } => { + write!(f, "invalid sky.wea row {index} at line {line}: {text}") + } + Self::InvalidLightmapCount { line, text } => { + write!(f, "invalid sky.wea lightmap count at line {line}: {text}") + } + Self::MissingLightmapRow { index, count } => { + write!(f, "missing sky.wea lightmap row {index} of {count}") + } + Self::UnexpectedTrailingLine { line, text } => { + write!(f, "unexpected sky.wea line {line}: {text}") + } + } + } +} + +impl std::error::Error for SkyWeaError {} + +/// Parses a `sky.wea` payload without requiring callers to name the parser +/// type. +pub fn parse_sky_wea(bytes: &[u8]) -> Result { + SkyMaterials::parse(bytes) +} + +/// The nine positional material roles used by the original sky renderer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SkyLayerKind { + /// Nebula/background row zero. + Nebula, + /// Stars row one. + Stars, + /// Clouds row two. + Clouds, + /// Sun sprite row three. + Sun, + /// Moon sprite row four. + Moon, + /// First flare row five. + Flare0, + /// Second flare row six. + Flare1, + /// Snow row seven. + Snow, + /// Rain row eight. + Rain, + /// A mission-specific row after the known roles. + Other(usize), +} + +impl SkyLayerKind { + /// Maps a positional WEA row to its known role. + #[must_use] + pub const fn from_row(row: usize) -> Self { + match row { + 0 => Self::Nebula, + 1 => Self::Stars, + 2 => Self::Clouds, + 3 => Self::Sun, + 4 => Self::Moon, + 5 => Self::Flare0, + 6 => Self::Flare1, + 7 => Self::Snow, + 8 => Self::Rain, + other => Self::Other(other), + } + } +} + +/// Native draw order for the dome and its full-screen gradient. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SkyPassKind { + /// Untextured full-screen gradient quad. + ScreenGradient, + /// Row zero nebula with row one stars bound as its secondary texture. + NebulaStars, + /// Untextured colored gradient over the dome. + DomeGradient, + /// Row two cloud dome translated below the camera. + Clouds, +} + +/// Renderer contract for one native sky pass. +#[derive(Clone, Debug, PartialEq)] +pub struct SkyPassFrame<'a> { + /// Pass order/role. + pub kind: SkyPassKind, + /// Primary positional WEA material row, if textured. + pub material_row: Option, + /// Secondary positional WEA row for the stars texture. + pub secondary_material_row: Option, + /// Primary material name. + pub material: Option<&'a str>, + /// Secondary material name. + pub secondary_material: Option<&'a str>, + /// Primary vertex UV stage. + pub uv_stage: Option, + /// Secondary vertex UV stage. + pub secondary_uv_stage: Option, + /// World-space Z offset relative to camera translation. + pub translation_z: f32, + /// Native blend mode of the pass. + pub blend: SkyBlendMode, + /// Native clouds directional RGB, when the pass uses it. + pub material_color: Option<[f32; 3]>, +} + +/// Lighting-dependent colors produced by the native screen-gradient update. +/// +/// `color` is the packed ARGB value sent to the full-screen gradient and to +/// the last dome ring. `horizon_floor` is the independently computed RGB +/// floor from the light-manager glare delta; native CSky clamps every stored +/// dome palette color up to these three channels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SkyGradientFrame { + /// Packed ARGB screen-gradient and last-ring color. + pub color: PackedColor, + /// Lower bound applied to dome palette RGB channels, normalized to 0..1. + pub horizon_floor: [f32; 3], +} + +impl SkyGradientFrame { + /// Raises a packed dome color to the native lighting-dependent RGB floor. + #[must_use] + pub fn clamp_dome_color(self, color: PackedColor) -> PackedColor { + let rgba = color.rgba(); + PackedColor::from_rgba([ + rgba[0].max(self.horizon_floor[0]), + rgba[1].max(self.horizon_floor[1]), + rgba[2].max(self.horizon_floor[2]), + rgba[3], + ]) + } +} + +/// Per-row state supplied alongside [`SkyMesh`]. +#[derive(Clone, Debug, PartialEq)] +pub struct SkyLayerFrame<'a> { + /// Positional row in `sky.wea`. + pub row: usize, + /// Interpreted role for the row. + pub kind: SkyLayerKind, + /// Dynamic mission material name, if that row exists. + pub material: Option<&'a str>, + /// Whether the row is currently visible according to atmosphere state. + pub active: bool, + /// Vertex UV stage used by the native sky material pass. + pub uv_stage: usize, + /// Native UV scale for this layer's stage. + pub uv_scale: f32, + /// Normalized row intensity where the atmosphere supplies one. + pub intensity: f32, + /// Monotonic elapsed time for material UV animation. + pub phase_seconds: f32, +} + +/// Blend operation used by a directional sky sprite pass. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SkyBlendMode { + /// Use the sprite's alpha channel over the current sky. + Alpha, + /// Add the sprite contribution to the current sky. + Additive, +} + +/// The fixed four-corner UV order used by native sun, moon, and flare quads. +/// +/// `CSun` leaves a half-percent border around the source texture. The order +/// is left-top, left-bottom, right-bottom, right-top in the native quad. +pub const SKY_SPRITE_UV: [[f32; 2]; 4] = [ + [0.005, 0.005], + [0.005, 0.995], + [0.995, 0.995], + [0.995, 0.005], +]; + +/// Positional flare rows emitted by the original sun object. +pub const SKY_FLARE_ROWS: [usize; 12] = [5, 5, 6, 6, 5, 6, 5, 5, 6, 5, 5, 5]; + +const SKY_FLARE_SIZES: [f32; 12] = [0.2, 0.3, 0.2, 0.1, 0.1, 0.3, 0.3, 0.7, 0.4, 1.0, 0.3, 0.2]; +const SKY_FLARE_OFFSETS: [f32; 12] = [ + 1.2, 0.7, 0.5, 0.2, 0.0, -0.2, -0.3, -0.5, -0.6, -0.8, -1.0, -1.1, +]; +const SKY_FLARE_COLORS: [u32; 12] = [ + 0xFFB0_90A3, + 0xFF5A_58BB, + 0x9630_BE52, + 0x96C9_3432, + 0xFF30_BE52, + 0x9696_9664, + 0xFFB0_90A3, + 0xFF7C_6BC9, + 0x9630_6452, + 0xFF0B_17B9, + 0xFFB6_26B1, + 0xFF7C_C5C9, +]; + +const GLARE_COS_15: f32 = 0.965_925_8; +const GLARE_COS_30: f32 = 0.866_025_4; +const GLARE_COS_60: f32 = 0.5; +const SUN_SPRITE_DISTANCE: f32 = 0.325; + +/// Renderer-facing state for one directional sun, moon or flare sprite. +/// +/// The CPU side provides the native material row, direction, sampled RGB and +/// unit-quad UVs. A renderer projects the direction through its active camera +/// and applies the material's size/alpha state. Flare rows additionally need a +/// camera visibility or occlusion test before drawing. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SkySpriteFrame<'a> { + /// Positional role in `sky.wea`. + pub kind: SkyLayerKind, + /// Positional material row. + pub row: usize, + /// Dynamic mission material name. + pub material: Option<&'a str>, + /// Whether the source celestial interval is active. + pub active: bool, + /// Direction in world space. + pub direction: [f32; 3], + /// Native packed sprite RGB (`SunSample::packed[0]`). + pub color: [f32; 3], + /// Native packed sprite alpha (`SunSample::packed[0]`). + pub alpha: f32, + /// The two native half-size sample values used by sun/moon quads. + pub size_values: [f32; 2], + /// Sampled intensity. + pub intensity: f32, + /// Unit quad texture coordinates in native corner order. + pub uv: [[f32; 2]; 4], + /// Blend operation for the sprite pass. + pub blend: SkyBlendMode, + /// Flares require camera-space visibility testing. + pub requires_occlusion: bool, +} + +/// One native directional source submitted for a celestial object. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DirectionalLightFrame { + /// False means the light slot must be removed from the light manager. + pub active: bool, + /// Direction used by the native light manager. + pub direction: [f32; 3], + /// RGB value passed to the native light manager without another intensity + /// multiplication. + pub color: [f32; 3], +} + +impl DirectionalLightFrame { + const INACTIVE: Self = Self { + active: false, + direction: [0.0; 3], + color: [0.0; 3], + }; +} + +/// Camera data needed by the native screen-space sun and flare path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SunVisibility { + /// Projected sun center in viewport pixels. + pub projected_sun: [f32; 2], + /// Viewport width and height in pixels. + pub viewport: [f32; 2], + /// Camera forward vector in world space. + pub view_forward: [f32; 3], + /// Result of the renderer's world-ray visibility query. + pub unoccluded: bool, +} + +/// One screen-space flare generated from the native flare tables. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FlareQuad { + /// Native positional material row (5 or 6). + pub row: usize, + /// Whether this quad should be submitted this frame. + pub active: bool, + /// Final RGBA color after the packed sun alpha multiplication. + pub color: [f32; 4], + /// Center in viewport pixels. + pub center_pixels: [f32; 2], + /// Equal X/Y half-size in viewport pixels. + pub half_size_pixels: f32, + /// Native UV order for the quad. + pub uv: [[f32; 2]; 4], +} + +/// Result of the native sun glare/flare calculation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SunOptics { + /// RGB submitted to the primary directional light after the glare boost. + pub primary_color: [f32; 3], + /// Squared 15-degree cone amount before the horizon factor. + pub glare_amount: f32, + /// Twelve fixed-topology flare slots. + pub flares: [FlareQuad; 12], +} + +/// Errors returned when a camera projection cannot supply native sprite data. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SkyProjectionError { + /// Viewport dimensions must be finite and positive. + InvalidViewport([f32; 2]), + /// Horizontal FOV must be finite and in `(0, pi)`. + InvalidHorizontalFov(f32), + /// A camera forward vector could not be normalized. + InvalidViewForward([f32; 3]), +} + +impl fmt::Display for SkyProjectionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidViewport(value) => write!(f, "invalid sky viewport: {value:?}"), + Self::InvalidHorizontalFov(value) => { + write!(f, "invalid sky horizontal FOV: {value}") + } + Self::InvalidViewForward(value) => { + write!(f, "invalid sky view forward vector: {value:?}") + } + } + } +} + +impl std::error::Error for SkyProjectionError {} + +/// Errors returned while updating a persistent sky owner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SkyUpdateError { + /// An externally supplied atmosphere sample contains a non-finite value. + NonFiniteAtmosphere, + /// The persistent mesh no longer matches its immutable configuration. + TopologyChanged, +} + +impl fmt::Display for SkyUpdateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NonFiniteAtmosphere => { + write!(f, "sky atmosphere sample contains a non-finite value") + } + Self::TopologyChanged => write!(f, "sky topology changed after construction"), + } + } +} + +impl std::error::Error for SkyUpdateError {} + +/// Fog values sent by the original `CSky` update. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FogFrame { + /// Normalized RGB fog color from `SkySample::packed[1]`. + pub color: [f32; 3], + /// Native global lighting floor from the same sampled RGB state. + /// + /// `CShade` merges this vector with the material lighting accumulator by + /// taking the per-channel maximum before the shader receives the result. + pub lighting_floor: [f32; 3], + /// Fog start distance (`sky.values[0] * 700`). + pub start: f32, + /// Fog end distance (`sky.values[1] * 700`). + pub end: f32, +} + +impl FogFrame { + /// Converts an interpolated sky sample to fog parameters. + #[must_use] + pub fn from_sample(sample: &SkySample) -> Self { + let start = finite_or_zero(sample.values[0]) * FOG_DISTANCE_SCALE; + let end = finite_or_zero(sample.values[1]) * FOG_DISTANCE_SCALE; + let rgba = sample.packed[1].rgba(); + let color = [rgba[0], rgba[1], rgba[2]]; + Self { + color, + lighting_floor: color, + start, + end, + } + } +} + +/// Converts an interpolated sky sample to fog parameters. +#[must_use] +pub fn fog_frame(sample: &SkySample) -> FogFrame { + FogFrame::from_sample(sample) +} + +/// Birth and orbit settings for a sun object. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SunConfig { + /// Time at which the stateless sun sample starts, in seconds. + pub start_seconds: f32, + /// Lifetime used by the original `(now - start) / life` age calculation. + pub lifetime_seconds: f32, + /// Two integer birth angles from the sun birth payload, in degrees. + pub initial_angles_degrees: [f32; 2], +} + +impl SunConfig { + /// Creates sun settings. + #[must_use] + pub const fn new( + start_seconds: f32, + lifetime_seconds: f32, + initial_angles_degrees: [f32; 2], + ) -> Self { + Self { + start_seconds, + lifetime_seconds, + initial_angles_degrees, + } + } + + fn angles_radians(self) -> [f32; 2] { + [ + finite_or_zero(self.initial_angles_degrees[0]).to_radians(), + finite_or_zero(self.initial_angles_degrees[1]).to_radians(), + ] + } + + /// Converts the native birth payload emitted by the atmosphere sampler. + #[must_use] + pub const fn from_birth(birth: SunBirth) -> Self { + Self::new( + birth.start_seconds, + birth.lifetime_seconds, + birth.initial_angles_degrees, + ) + } +} + +impl Default for SunConfig { + fn default() -> Self { + Self::new(0.0, 1.0, [0.0; 2]) + } +} + +/// A renderer-facing sun sample. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SunFrame { + /// Whether the latest SunStart/SunStop interval is active. + pub active: bool, + /// Final world-space direction after the original birth-angle matrix. + pub direction: [f32; 3], + /// Intermediate orbit direction before the birth-angle matrix. + pub orbit_direction: [f32; 3], + /// RGB light color from `SunSample::rgb`. + pub color: [f32; 3], + /// RGBA color stored in the native sprite descriptor. + pub sprite_color: [f32; 4], + /// RGB color of the opposite directional light from packed parameter 1. + pub secondary_color: [f32; 3], + /// The two native screen-quad size values from the SKE tail. + pub size_values: [f32; 2], + /// Material row selected by the native birth dispatcher. + pub material_row: Option, + /// Atmosphere intensity from trailing key parameter two. + pub intensity: f32, + /// Clamped normalized lifecycle age. + pub age: f32, + /// Untransformed orbit angle in radians. + pub theta: f32, +} + +impl SunFrame { + /// Converts a sampled sun and stateless clock into a frame. + #[must_use] + pub fn from_sample(sample: &SunSample, now_seconds: f32, config: SunConfig) -> Self { + sun_frame(sample, now_seconds, config) + } + + /// Returns an inactive frame without inventing lifecycle timing. + #[must_use] + pub fn inactive(sample: &SunSample) -> Self { + let sprite_color = sample.packed[0].rgba(); + let secondary_color = sample.packed[1].rgba(); + Self { + active: false, + direction: [0.0; 3], + orbit_direction: [0.0; 3], + color: sample.rgb.map(finite_or_zero), + sprite_color, + secondary_color: [secondary_color[0], secondary_color[1], secondary_color[2]], + size_values: sample.values.map(finite_or_zero), + material_row: None, + intensity: finite_or_zero(sample.intensity).max(0.0), + age: 0.0, + theta: 0.0, + } + } + + /// Direction from the camera toward the native celestial geometry. + /// + /// The light manager stores the opposite vector, so the screen/world + /// sprite path negates [`Self::direction`]. + #[must_use] + pub fn geometry_direction(self) -> [f32; 3] { + self.direction.map(|component| -component) + } + + /// Places the native celestial object at half the camera far plane. + /// This is `camera_translation - light_direction * far / 2`, with the + /// geometry direction exposed by [`Self::geometry_direction`]. + #[must_use] + pub fn world_center(self, camera_translation: [f32; 3], camera_far: f32) -> [f32; 3] { + let distance = finite_or_zero(camera_far) * 0.5; + let direction = self.geometry_direction(); + std::array::from_fn(|index| camera_translation[index] + direction[index] * distance) + } + + /// Returns primary and opposite directional lights in native order. + #[must_use] + pub fn directional_lights(self) -> [DirectionalLightFrame; 2] { + if !self.active { + return [DirectionalLightFrame::INACTIVE; 2]; + } + [ + DirectionalLightFrame { + active: true, + direction: self.direction, + color: self.color, + }, + DirectionalLightFrame { + active: true, + direction: self.direction.map(|component| -component), + color: self.secondary_color, + }, + ] + } +} + +/// Converts an interpolated atmosphere sun sample to a renderer-facing frame. +/// +/// The original update computes `orbit_direction = [cos(theta), 0, +/// -sin(theta)]`, then applies the row-vector product of the two birth-angle +/// matrices. With `A` and `B` in radians, the resulting direction is: +/// +/// ```text +/// [x*cos(A), -x*sin(A)*cos(B) + z*sin(B), +/// x*sin(A)*sin(B) + z*cos(B)] +/// ``` +/// +/// where `x` and `z` are the two non-zero orbit components. +#[must_use] +pub fn sun_frame(sample: &SunSample, now_seconds: f32, config: SunConfig) -> SunFrame { + let color = sample.rgb.map(finite_or_zero); + let sprite_color = sample.packed[0].rgba(); + let secondary = sample.packed[1].rgba(); + let intensity = finite_or_zero(sample.intensity).max(0.0); + if !sample.active { + return SunFrame::inactive(sample); + } + + let now_seconds = finite_or_zero(now_seconds); + let start_seconds = finite_or_zero(config.start_seconds); + let lifetime = finite_or_zero(config.lifetime_seconds); + let age = if lifetime > 0.0 { + ((now_seconds - start_seconds) / lifetime).clamp(0.0, 1.0) + } else { + 1.0 + }; + let theta = (age * 1.2 - 0.1) * std::f32::consts::PI; + let orbit_direction = [theta.cos(), 0.0, -theta.sin()]; + let [angle_a, angle_b] = config.angles_radians(); + let (sin_a, cos_a) = angle_a.sin_cos(); + let (sin_b, cos_b) = angle_b.sin_cos(); + let x = orbit_direction[0]; + let z = orbit_direction[2]; + let direction = normalize3([ + x * cos_a, + -x * sin_a * cos_b + z * sin_b, + x * sin_a * sin_b + z * cos_b, + ]); + SunFrame { + active: true, + direction, + orbit_direction, + color, + sprite_color, + secondary_color: [secondary[0], secondary[1], secondary[2]], + size_values: sample.values.map(finite_or_zero), + material_row: sample.birth.map(|birth| birth.material_row), + intensity, + age, + theta, + } +} + +/// Converts a sampled celestial object using the birth payload generated by +/// the atmosphere dispatcher. +/// +/// The schedule sampler carries the absolute start time, lifetime, angles and +/// material row for the active interval. Keeping that payload in the sample +/// means a renderer does not need a stateful wrapper or a caller-provided +/// placeholder configuration. +#[must_use] +pub fn celestial_frame(sample: &SunSample, now_seconds: f32) -> SunFrame { + sample + .birth + .map(|birth| sun_frame(sample, now_seconds, SunConfig::from_birth(birth))) + .unwrap_or_else(|| SunFrame::inactive(sample)) +} + +/// Computes the native half-size of a sun or moon screen quad in pixels. +/// +/// The SKE tail values are the first two fields of `SunSample::values`; they +/// are deliberately not read from the material descriptor. The renderer +/// supplies the real viewport and horizontal FOV for the current swapchain. +pub fn sprite_half_size_pixels( + values: [f32; 2], + viewport: [f32; 2], + horizontal_fov: f32, +) -> Result<[f32; 2], SkyProjectionError> { + validate_viewport(viewport)?; + if !horizontal_fov.is_finite() + || horizontal_fov <= 0.0 + || horizontal_fov >= std::f32::consts::PI + { + return Err(SkyProjectionError::InvalidHorizontalFov(horizontal_fov)); + } + let scale = viewport[0] / horizontal_fov * SUN_SPRITE_DISTANCE * 0.5; + Ok([ + scale * finite_or_zero(values[0]), + scale * finite_or_zero(values[1]), + ]) +} + +/// Computes native glare and flare quads for the current camera. +pub fn sun_optics( + sun: SunFrame, + visibility: SunVisibility, +) -> Result { + validate_viewport(visibility.viewport)?; + let view_forward = normalize3_checked(visibility.view_forward)?; + let light_direction = sun.direction; + let to_sun = light_direction.map(|component| -component); + let cone = dot3(view_forward, to_sun); + let cone_amount = ((cone - GLARE_COS_15) / (1.0 - GLARE_COS_15)).max(0.0); + let glare_amount = cone_amount * cone_amount; + let projected_inside = visibility.projected_sun[0].is_finite() + && visibility.projected_sun[1].is_finite() + && visibility.projected_sun[0] >= 0.0 + && visibility.projected_sun[0] <= visibility.viewport[0] + && visibility.projected_sun[1] >= 0.0 + && visibility.projected_sun[1] <= visibility.viewport[1]; + let primary_length = length3(sun.color); + let visible = sun.active && projected_inside && visibility.unoccluded && primary_length > 1.1; + + let mut primary_color = sun.color; + if visible { + let height = + ((-light_direction[2] - GLARE_COS_60) / (GLARE_COS_30 - GLARE_COS_60)).clamp(0.0, 1.0); + let boost = 1.0 + 4.0 * sun.sprite_color[3].clamp(0.0, 1.0) * height * glare_amount; + primary_color = primary_color.map(|component| component * boost); + } + + let flare_active = visible && glare_amount <= 0.1; + let viewport_center = [visibility.viewport[0] * 0.5, visibility.viewport[1] * 0.5]; + let flares = std::array::from_fn(|index| { + let base = PackedColor(SKY_FLARE_COLORS[index]); + let rgba = base.rgba(); + let base_alpha = ((SKY_FLARE_COLORS[index] >> 24) & 0xFF) as f32; + let alpha = (base_alpha * sun.sprite_color[3].clamp(0.0, 1.0)).round_ties_even() / 255.0; + let offset = SKY_FLARE_OFFSETS[index]; + let center = [ + viewport_center[0] + (visibility.projected_sun[0] - viewport_center[0]) * offset, + viewport_center[1] + (visibility.projected_sun[1] - viewport_center[1]) * offset, + ]; + FlareQuad { + row: SKY_FLARE_ROWS[index], + active: flare_active, + color: [rgba[0], rgba[1], rgba[2], alpha], + center_pixels: center, + half_size_pixels: visibility.viewport[0] * SKY_FLARE_SIZES[index] / 8.0, + uv: SKY_SPRITE_UV, + } + }); + Ok(SunOptics { + primary_color, + glare_amount, + flares, + }) +} + +fn validate_viewport(viewport: [f32; 2]) -> Result<(), SkyProjectionError> { + if viewport[0].is_finite() && viewport[1].is_finite() && viewport[0] > 0.0 && viewport[1] > 0.0 + { + Ok(()) + } else { + Err(SkyProjectionError::InvalidViewport(viewport)) + } +} + +fn normalize3_checked(vector: [f32; 3]) -> Result<[f32; 3], SkyProjectionError> { + let length = length3(vector); + if length.is_finite() && length > 0.0 { + Ok(vector.map(|component| component / length)) + } else { + Err(SkyProjectionError::InvalidViewForward(vector)) + } +} + +fn length3(vector: [f32; 3]) -> f32 { + (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt() +} + +fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] +} + +/// Samples the native full-screen sky gradient and lighting-dependent horizon +/// floor for one view. +/// +/// The original `CSky::Render` code at `481C5` converts `(yaw + pi)` to +/// degrees with the x87 nearest-even conversion, chooses one of four +/// ninety-degree sectors, and interpolates the corresponding two entries of +/// `SkySample::colors[0..4]`. The later `483A3..485EE` block derives a +/// non-negative light-manager glare RGB delta and raises the stored dome +/// palette to its piecewise RGB floor. The renderer supplies +/// `glare_rgb_delta = max(current_light_rgb - base_light_rgb, 0)` componentwise; +/// it is not a camera or projection vector. +#[must_use] +pub fn screen_gradient_frame( + sample: &SkySample, + yaw_radians: f32, + glare_rgb_delta: [f32; 3], +) -> SkyGradientFrame { + let yaw_radians = finite_or_zero(yaw_radians); + let phase_degrees = ((yaw_radians + NATIVE_PI) * (180.0 / NATIVE_PI)).round_ties_even(); + let phase_degrees = phase_degrees as i64; + let segment = phase_degrees.div_euclid(NATIVE_GRADIENT_SEGMENT_DEGREES); + let remainder = phase_degrees.rem_euclid(NATIVE_GRADIENT_SEGMENT_DEGREES); + let palette_index = (segment - 2).rem_euclid(4) as usize; + let next_palette_index = (palette_index + 1) & 3; + let palette_fraction = remainder as f32 * NATIVE_GRADIENT_FRACTION_SCALE; + let color = interpolate_gradient_color( + sample.colors[palette_index], + sample.colors[next_palette_index], + palette_fraction, + ); + let glare_rgb_delta = glare_rgb_delta.map(|component| finite_or_zero(component).max(0.0)); + let horizon_floor = glare_rgb_delta.map(native_horizon_floor_component); + SkyGradientFrame { + color, + horizon_floor, + } +} + +fn interpolate_gradient_color( + first: PackedColor, + second: PackedColor, + fraction: f32, +) -> PackedColor { + let channel = |shift: u32| { + let a = ((first.raw() >> shift) & 0xFF) as f32; + let b = ((second.raw() >> shift) & 0xFF) as f32; + (a + (b - a) * fraction).clamp(0.0, 255.0).round_ties_even() as u32 + }; + PackedColor(0xFF00_0000 | (channel(16) << 16) | (channel(8) << 8) | channel(0)) +} + +fn native_horizon_floor_component(delta: f32) -> f32 { + let mapped = if delta <= NATIVE_GRADIENT_ONE { + delta * NATIVE_GRADIENT_SHORT_SCALE + } else if delta <= NATIVE_GRADIENT_THREE { + delta * NATIVE_GRADIENT_LONG_SCALE + NATIVE_GRADIENT_LONG_OFFSET + } else { + NATIVE_GRADIENT_ONE + }; + // Keep the normalized API bounded even if a caller supplies a malformed + // negative or non-finite value after the input sanitization above. + mapped.clamp(NATIVE_GRADIENT_ZERO, NATIVE_GRADIENT_ONE) +} + +/// Complete CPU output for one sky frame. +#[derive(Clone, Debug, PartialEq)] +pub struct SkyFrame<'a> { + /// Interpolated atmosphere sky payload retained for renderer extensions. + pub sky: SkySample, + /// Sun and light-manager state. + pub sun: SunFrame, + /// Independent moon object state from the moon SunStart/SunStop interval. + pub moon: SunFrame, + /// Primary and opposite light for the sun, followed by the moon pair. + pub directional_lights: [DirectionalLightFrame; 4], + /// Native directional sprite data for sun, moon and both flares. + pub sprites: [SkySpriteFrame<'a>; 4], + /// Native sky draw order. The renderer submits these four passes as a + /// stable topology and changes only sampled uniforms/material dynamics. + pub passes: [SkyPassFrame<'a>; 4], + /// Fog state. + pub fog: FogFrame, + /// Native global lighting floor copied from the sampled sky state. + pub lighting_floor: [f32; 3], + /// Per-row cloud/star/sprite state. + pub layers: Vec>, + /// Clock value supplied by the caller. + pub time_seconds: f32, +} + +impl<'a> SkyFrame<'a> { + /// Builds a frame from one sampled atmosphere snapshot. + #[must_use] + pub fn from_atmosphere( + atmosphere: &AtmosphereFrame, + materials: &'a SkyMaterials, + now_seconds: f32, + ) -> SkyFrame<'a> { + let sky = atmosphere.sky.clone(); + let sun = celestial_frame(&atmosphere.sun, now_seconds); + let moon = celestial_frame(&atmosphere.moon, now_seconds); + let time_seconds = finite_or_zero(now_seconds); + let fog = fog_frame(&sky); + let passes = build_passes(materials, &sky); + Self { + sky, + sun, + moon, + directional_lights: celestial_lights(&sun, &moon), + sprites: build_sprites(materials, &sun, &moon), + passes, + lighting_floor: fog.lighting_floor, + fog, + layers: build_layers(materials, &sun, atmosphere, time_seconds), + time_seconds, + } + } + + /// Computes camera-dependent sun glare and the fixed twelve flare slots. + pub fn sun_optics(&self, visibility: SunVisibility) -> Result { + sun_optics(self.sun, visibility) + } + + /// Computes the lighting-dependent native screen-gradient and horizon floor. + #[must_use] + pub fn screen_gradient(&self, yaw_radians: f32, glare_rgb_delta: [f32; 3]) -> SkyGradientFrame { + screen_gradient_frame(&self.sky, yaw_radians, glare_rgb_delta) + } +} + +/// Stateful CPU sky owner suitable for a renderer's per-frame update. +#[derive(Clone, Debug)] +pub struct SkySystem { + /// Dome geometry configuration. + pub geometry: SkyGeometryConfig, + /// Mission material rows. + pub materials: SkyMaterials, + /// Static dome topology with mutable per-frame colors. + mesh: SkyMesh, +} + +impl SkySystem { + /// Creates a sky system. + pub fn new( + geometry: SkyGeometryConfig, + materials: SkyMaterials, + ) -> Result { + Ok(Self { + geometry, + materials, + mesh: geometry.try_topology()?, + }) + } + + /// Returns the persistent dome mesh. + #[must_use] + pub const fn mesh(&self) -> &SkyMesh { + &self.mesh + } + + /// Updates only dynamic colors and frame state on the persistent topology. + pub fn update( + &mut self, + atmosphere: &AtmosphereFrame, + now_seconds: f32, + ) -> Result, SkyUpdateError> { + validate_atmosphere(atmosphere)?; + let expected_vertices = self + .geometry + .azimuth_count + .checked_mul(self.geometry.rings) + .and_then(|count| count.checked_add(1)); + let expected_indices = self + .geometry + .azimuth_count + .checked_mul(self.geometry.rings.saturating_mul(2).saturating_sub(1)) + .and_then(|count| count.checked_mul(3)); + if expected_vertices != Some(self.mesh.vertices.len()) + || expected_indices != Some(self.mesh.indices.len()) + { + return Err(SkyUpdateError::TopologyChanged); + } + let sky = atmosphere.sky.clone(); + self.mesh.update_colors(&sky); + let sun = celestial_frame(&atmosphere.sun, now_seconds); + let moon = celestial_frame(&atmosphere.moon, now_seconds); + let time_seconds = finite_or_zero(now_seconds); + let fog = fog_frame(&sky); + let passes = build_passes(&self.materials, &sky); + Ok(SkyFrame { + sky, + sun, + moon, + directional_lights: celestial_lights(&sun, &moon), + sprites: build_sprites(&self.materials, &sun, &moon), + passes, + lighting_floor: fog.lighting_floor, + fog, + layers: build_layers(&self.materials, &sun, atmosphere, time_seconds), + time_seconds, + }) + } +} + +fn build_sprites<'a>( + materials: &'a SkyMaterials, + sun: &SunFrame, + moon: &SunFrame, +) -> [SkySpriteFrame<'a>; 4] { + [ + sprite_frame( + materials, + SkyLayerKind::Sun, + 3, + sun, + false, + SkyBlendMode::Alpha, + ), + sprite_frame( + materials, + SkyLayerKind::Moon, + 4, + moon, + false, + SkyBlendMode::Alpha, + ), + sprite_frame( + materials, + SkyLayerKind::Flare0, + 5, + sun, + true, + SkyBlendMode::Additive, + ), + sprite_frame( + materials, + SkyLayerKind::Flare1, + 6, + sun, + true, + SkyBlendMode::Additive, + ), + ] +} + +fn build_passes<'a>(materials: &'a SkyMaterials, sky: &SkySample) -> [SkyPassFrame<'a>; 4] { + [ + SkyPassFrame { + kind: SkyPassKind::ScreenGradient, + material_row: None, + secondary_material_row: None, + material: None, + secondary_material: None, + uv_stage: None, + secondary_uv_stage: None, + translation_z: 0.0, + blend: SkyBlendMode::Alpha, + material_color: None, + }, + SkyPassFrame { + kind: SkyPassKind::NebulaStars, + material_row: Some(0), + secondary_material_row: Some(1), + material: materials.material_name(0), + secondary_material: materials.material_name(1), + uv_stage: Some(0), + secondary_uv_stage: Some(1), + translation_z: 0.0, + blend: SkyBlendMode::Alpha, + material_color: None, + }, + SkyPassFrame { + kind: SkyPassKind::DomeGradient, + material_row: None, + secondary_material_row: None, + material: None, + secondary_material: None, + uv_stage: None, + secondary_uv_stage: None, + translation_z: 0.0, + blend: SkyBlendMode::Alpha, + material_color: None, + }, + SkyPassFrame { + kind: SkyPassKind::Clouds, + material_row: Some(2), + secondary_material_row: None, + material: materials.material_name(2), + secondary_material: None, + uv_stage: Some(2), + secondary_uv_stage: None, + translation_z: -5000.0, + blend: SkyBlendMode::Alpha, + material_color: { + let rgba = sky.packed[0].rgba(); + Some([rgba[0], rgba[1], rgba[2]]) + }, + }, + ] +} + +fn sprite_frame<'a>( + materials: &'a SkyMaterials, + kind: SkyLayerKind, + row: usize, + source: &SunFrame, + requires_occlusion: bool, + blend: SkyBlendMode, +) -> SkySpriteFrame<'a> { + SkySpriteFrame { + kind, + row, + material: materials.material_name(row), + active: source.active, + direction: source.geometry_direction(), + color: [ + source.sprite_color[0], + source.sprite_color[1], + source.sprite_color[2], + ], + alpha: source.sprite_color[3], + size_values: source.size_values, + intensity: source.intensity, + uv: SKY_SPRITE_UV, + blend, + requires_occlusion, + } +} + +fn build_layers<'a>( + materials: &'a SkyMaterials, + sun: &SunFrame, + atmosphere: &AtmosphereFrame, + phase_seconds: f32, +) -> Vec> { + let count = materials.rows.len().max(SKY_MATERIAL_COUNT); + (0..count) + .map(|row| { + let kind = SkyLayerKind::from_row(row); + let (active, intensity, uv_stage) = match kind { + SkyLayerKind::Nebula => (true, 1.0, 0), + SkyLayerKind::Stars => (true, 1.0, 1), + SkyLayerKind::Clouds => (true, 1.0, 2), + SkyLayerKind::Sun | SkyLayerKind::Flare0 | SkyLayerKind::Flare1 => { + (sun.active, sun.intensity, 0) + } + SkyLayerKind::Moon => (atmosphere.moon.active, atmosphere.moon.intensity, 0), + SkyLayerKind::Snow => (atmosphere.snow.active, atmosphere.snow.intensity, 0), + SkyLayerKind::Rain => (atmosphere.rain.active, atmosphere.rain.intensity, 0), + SkyLayerKind::Other(_) => (true, 1.0, 0), + }; + SkyLayerFrame { + row, + kind, + material: materials.material_name(row), + active, + uv_stage, + uv_scale: SKY_UV_SCALES[uv_stage], + intensity: finite_or_zero(intensity).max(0.0), + phase_seconds, + } + }) + .collect() +} + +fn celestial_lights(sun: &SunFrame, moon: &SunFrame) -> [DirectionalLightFrame; 4] { + let sun_lights = sun.directional_lights(); + let moon_lights = moon.directional_lights(); + [sun_lights[0], sun_lights[1], moon_lights[0], moon_lights[1]] +} + +fn validate_atmosphere(atmosphere: &AtmosphereFrame) -> Result<(), SkyUpdateError> { + let finite = |values: &[f32]| values.iter().all(|value| value.is_finite()); + if finite(&atmosphere.sky.values) + && finite(&atmosphere.sun.rgb) + && finite(&atmosphere.sun.values) + && atmosphere.sun.intensity.is_finite() + && finite(&atmosphere.moon.rgb) + && finite(&atmosphere.moon.values) + && atmosphere.moon.intensity.is_finite() + && atmosphere.rain.intensity.is_finite() + && atmosphere.snow.intensity.is_finite() + && atmosphere.lightning.intensity.is_finite() + && atmosphere.real_seconds.is_finite() + && atmosphere.track_seconds.is_finite() + && atmosphere.day_seconds.is_finite() + { + Ok(()) + } else { + Err(SkyUpdateError::NonFiniteAtmosphere) + } +} + +fn finite_or_zero(value: f32) -> f32 { + if value.is_finite() { + value + } else { + 0.0 + } +} + +fn normalize3(vector: [f32; 3]) -> [f32; 3] { + let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt(); + if length.is_finite() && length > 0.0 { + vector.map(|component| component / length) + } else { + [0.0; 3] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atmosphere::{AtmosphereFrame, WeatherSample}; + + fn sample() -> SkySample { + let colors = std::array::from_fn(|index| PackedColor(0xFF00_0000 | index as u32)); + SkySample { + colors, + values: [1.0, 2.0], + packed: [PackedColor(0), PackedColor(0xFF11_2233)], + } + } + + fn sun(active: bool) -> SunSample { + SunSample { + active, + rgb: [0.5, 0.25, 0.125], + values: [0.0; 2], + packed: [PackedColor::default(); 2], + intensity: 0.5, + birth: None, + } + } + + fn atmosphere(active: bool) -> AtmosphereFrame { + AtmosphereFrame { + real_seconds: 0.0, + track_index: 0, + track_seconds: 0.0, + day_seconds: 0.0, + sky: sample(), + sun: sun(active), + moon: sun(false), + rain: WeatherSample { + active: false, + intensity: 0.0, + color: [0.0; 4], + raw_color: [0.0; 4], + resources: Vec::new(), + }, + snow: WeatherSample { + active: false, + intensity: 0.0, + color: [0.0; 4], + raw_color: [0.0; 4], + resources: Vec::new(), + }, + lightning: WeatherSample { + active: false, + intensity: 0.0, + color: [0.0; 4], + raw_color: [0.0; 4], + resources: Vec::new(), + }, + } + } + + #[test] + fn geometry_matches_original_counts_positions_uv_and_winding() { + let config = SkyGeometryConfig::new(10.0, std::f32::consts::FRAC_PI_2, 2.0, 4, 3); + let mesh = config.try_mesh(&sample()).expect("valid mesh"); + assert_eq!(mesh.vertices.len(), 13); + assert_eq!(mesh.indices.len(), 4 * (6 * 3 - 3)); + assert_eq!(mesh.indices[0..3], [0, 1, 4]); + assert_eq!(mesh.indices[3..9], [1, 2, 4, 4, 2, 5]); + assert_eq!(mesh.vertices[0].position, [0.0, 0.0, 20.0]); + assert!((mesh.vertices[1].position[0]).abs() < 1.0e-5); + assert!((mesh.vertices[1].position[1] - 5.0).abs() < 1.0e-5); + assert!((mesh.vertices[1].position[2] - 17.320_508).abs() < 1.0e-5); + assert!((mesh.vertices[1].uv[1][1] - 7.5).abs() < 1.0e-5); + assert_eq!(mesh.vertices[0].normal, [0, 0, 127]); + assert_eq!(mesh.vertices[0].normal_vector(), [0.0, 0.0, 1.0]); + } + + #[test] + fn normals_use_signed_127_nearest_even_encoding() { + assert_eq!(normal_byte(0.5), 64); + assert_eq!(normal_byte(-0.5), -64); + assert_eq!(normal_byte(-1.0), -127); + } + + #[test] + fn colors_use_zenith_and_three_interpolated_quadrant_belts() { + let mut sky = sample(); + sky.colors[12] = PackedColor(0xFFAA_BBCC); + sky.colors[8] = PackedColor(0xFF00_0000); + sky.colors[9] = PackedColor(0xFF00_FF00); + sky.colors[10] = PackedColor(0xFF00_00FF); + sky.colors[11] = PackedColor(0xFFFF_FFFF); + let config = SkyGeometryConfig::new(10.0, 1.0, 1.0, 12, 4); + let mesh = config.try_mesh(&sky).expect("valid mesh"); + assert_eq!(mesh.vertices[0].color.raw(), 0xFFAA_BBCC); + assert_eq!(mesh.vertices[1].color.raw(), 0xFFAA_BBCC); + // First belt, azimuth one: one third from colors 8 to 9. + assert_eq!(mesh.vertices[6].color.raw(), PackedColor(0xFF00_5500).raw()); + // The next belt starts at the fourth ring, and the last belt is used + // for every ring after the three original color bands. + let ring_two = 1 + 2; + let ring_three = 1 + 3; + assert_eq!(mesh.vertices[ring_two].color.raw(), sky.colors[4].raw()); + assert_eq!(mesh.vertices[ring_three].color.raw(), sky.colors[0].raw()); + } + + #[test] + fn wea_parser_keeps_dynamic_names_and_optional_lightmaps() { + let table = SkyMaterials::parse( + b"2\r\n7 custom_snow\r\n8 custom_rain\r\n\r\nLIGHTMAPS\r\n1\r\n0 lm\r\n", + ) + .expect("sky.wea"); + assert_eq!(table.material_name(0), Some("custom_snow")); + assert_eq!(table.rows[1].legacy_id, 8); + assert_eq!(table.lightmaps[0].name, "lm"); + } + + #[test] + fn fog_uses_original_scale_and_packed_rgb() { + let frame = fog_frame(&sample()); + assert_eq!(frame.start, 700.0); + assert_eq!(frame.end, 1_400.0); + assert_eq!( + frame.color, + [ + 0x11 as f32 / 255.0, + 0x22 as f32 / 255.0, + 0x33 as f32 / 255.0 + ] + ); + } + + #[test] + fn screen_gradient_uses_native_yaw_and_lighting_floor() { + let mut sky = sample(); + sky.colors[0] = PackedColor(0xFF00_0000); + sky.colors[1] = PackedColor(0xFF00_FF00); + sky.colors[2] = PackedColor(0xFF00_00FF); + sky.colors[3] = PackedColor(0xFFFF_0000); + + let at_sector_start = screen_gradient_frame(&sky, 0.0, [0.0; 3]); + assert_eq!(at_sector_start.color, sky.colors[0]); + + let halfway = screen_gradient_frame(&sky, std::f32::consts::FRAC_PI_4, [0.5, 2.0, 4.0]); + assert_eq!(halfway.color, PackedColor(0xFF00_8000)); + assert_eq!(halfway.horizon_floor, [0.4, 0.9, 1.0]); + assert_eq!( + halfway.clamp_dome_color(PackedColor(0xFF10_2030)), + PackedColor(0xFF66_E6_FF) + ); + } + + #[test] + fn lighting_floor_maps_native_boundaries() { + for (delta, expected) in [(0.5, 0.4), (1.0, 0.8), (2.0, 0.9), (3.0, 1.0), (4.0, 1.0)] { + let frame = screen_gradient_frame(&sample(), 0.0, [delta; 3]); + assert_eq!(frame.horizon_floor, [expected; 3], "delta={delta}"); + } + } + + #[test] + fn sun_orientation_applies_birth_matrix_after_orbit_vector() { + let sample = sun(true); + let at_start = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [90.0, 0.0])); + assert!((at_start.direction[0]).abs() < 1.0e-5); + assert!((at_start.direction[1] + at_start.orbit_direction[0]).abs() < 1.0e-5); + assert!((at_start.direction[2] - at_start.orbit_direction[2]).abs() < 1.0e-5); + + let side = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [0.0, 90.0])); + assert!((side.direction[0] - side.orbit_direction[0]).abs() < 1.0e-5); + assert!((side.direction[1] - side.orbit_direction[2]).abs() < 1.0e-5); + assert!(side.direction[2].abs() < 1.0e-5); + } + + #[test] + fn native_birth_keeps_the_real_start_time() { + assert!(!celestial_frame(&sun(false), 100.0).active); + let birth = SunBirth { + start_seconds: 90.0, + lifetime_seconds: 20.0, + initial_angles_degrees: [0.0; 2], + material_row: 3, + }; + let mut active = sun(true); + active.birth = Some(birth); + let first = celestial_frame(&active, 100.0); + let later = celestial_frame(&active, 105.0); + assert!((first.age - 0.5).abs() < 1.0e-5); + assert!((later.age - 0.75).abs() < 1.0e-5); + assert!(!celestial_frame(&sun(false), 106.0).active); + } + + #[test] + fn frame_exposes_dynamic_layers_and_moon_direction() { + let atmosphere = atmosphere(true); + let materials = SkyMaterials::from_names(["nebula", "stars", "clouds", "sun", "moon"]); + let frame = SkyFrame::from_atmosphere(&atmosphere, &materials, 0.0); + assert_eq!(frame.layers[3].material.as_deref(), Some("sun")); + assert_eq!(frame.layers[8].material, None); + assert_eq!(frame.moon.direction, [0.0; 3]); + assert_eq!(frame.sprites[0].material, Some("sun")); + } + + #[test] + fn system_reuses_topology_and_updates_only_colors() { + let materials = SkyMaterials::from_names(["nebula", "stars", "clouds"]); + let mut system = SkySystem::new(SkyGeometryConfig::default(), materials).expect("geometry"); + let vertex_ptr = system.mesh().vertices.as_ptr(); + let index_ptr = system.mesh().indices.as_ptr(); + let positions = system + .mesh() + .vertices + .iter() + .map(|vertex| vertex.position) + .collect::>(); + + let mut atmosphere = atmosphere(true); + atmosphere.sky.colors[12] = PackedColor(0xFF12_3456); + let (lighting_floor, fog_color, sprites_have_native_uv) = { + let frame = system.update(&atmosphere, 10.0).expect("sky update"); + ( + frame.lighting_floor, + frame.fog.color, + frame + .sprites + .iter() + .all(|sprite| sprite.uv == SKY_SPRITE_UV), + ) + }; + + assert_eq!(system.mesh().vertices.as_ptr(), vertex_ptr); + assert_eq!(system.mesh().indices.as_ptr(), index_ptr); + assert_eq!( + system + .mesh() + .vertices + .iter() + .map(|vertex| vertex.position) + .collect::>(), + positions + ); + assert_eq!(system.mesh().vertices[0].color, PackedColor(0xFF12_3456)); + assert_eq!(lighting_floor, fog_color); + assert!(sprites_have_native_uv); + } + + #[test] + fn native_sprite_and_light_contract_uses_packed_secondary_values() { + let mut source = sun(true); + source.packed = [PackedColor(0x8011_2233), PackedColor(0xFF44_5566)]; + source.values = [2.0, 3.0]; + let frame = sun_frame(&source, 0.5, SunConfig::new(0.0, 1.0, [0.0, 0.0])); + assert_eq!( + frame.sprite_color, + [ + 0x11 as f32 / 255.0, + 0x22 as f32 / 255.0, + 0x33 as f32 / 255.0, + 0x80 as f32 / 255.0, + ] + ); + let lights = frame.directional_lights(); + assert_eq!(lights[0].color, frame.color); + assert_eq!(lights[1].direction, frame.direction.map(|value| -value)); + assert_eq!( + lights[1].color, + [ + 0x44 as f32 / 255.0, + 0x55 as f32 / 255.0, + 0x66 as f32 / 255.0, + ] + ); + assert_eq!( + frame.geometry_direction(), + frame.direction.map(|value| -value) + ); + let center = frame.world_center([1.0, 2.0, 3.0], 100.0); + assert!((center[0] - 1.0).abs() < 1.0e-4); + assert!((center[1] - 2.0).abs() < 1.0e-4); + assert!((center[2] - 53.0).abs() < 1.0e-4); + assert_eq!( + sprite_half_size_pixels(frame.size_values, [1000.0, 500.0], 2.0).expect("projection"), + [162.5, 243.75] + ); + } + + #[test] + fn glare_boost_and_flares_follow_native_visibility_gates() { + let mut source = sun(true); + source.rgb = [1.0, 1.0, 1.0]; + source.packed[0] = PackedColor(0xFF11_2233); + let sun = sun_frame(&source, 0.5, SunConfig::new(0.0, 1.0, [0.0, 0.0])); + let visibility = SunVisibility { + projected_sun: [50.0, 50.0], + viewport: [100.0, 100.0], + view_forward: sun.geometry_direction(), + unoccluded: true, + }; + let glare = sun_optics(sun, visibility).expect("optics"); + assert_eq!(glare.glare_amount, 1.0); + assert!(glare.primary_color[0] > sun.color[0]); + assert!(glare.flares.iter().all(|flare| !flare.active)); + + let mut visibility = visibility; + visibility.view_forward = sun.direction; + let flare = sun_optics(sun, visibility).expect("optics"); + assert!(flare.glare_amount < 0.1); + assert!(flare.flares.iter().all(|quad| quad.active)); + assert_eq!(flare.flares[0].row, 5); + assert_eq!(flare.flares[0].uv, SKY_SPRITE_UV); + assert_eq!(flare.flares[0].center_pixels, [50.0, 50.0]); + } + + #[test] + fn native_passes_preserve_nebula_stars_gradient_cloud_order() { + let materials = SkyMaterials::from_names([ + "nebula", "stars", "clouds", "sun", "moon", "flare0", "flare1", "snow", "rain", + ]); + let sky = sample(); + let frame = SkyFrame::from_atmosphere(&atmosphere(false), &materials, 0.0); + assert_eq!(frame.passes.len(), 4); + assert_eq!(frame.passes[0].kind, SkyPassKind::ScreenGradient); + assert_eq!(frame.passes[1].kind, SkyPassKind::NebulaStars); + assert_eq!(frame.passes[1].material_row, Some(0)); + assert_eq!(frame.passes[1].secondary_material_row, Some(1)); + assert_eq!(frame.passes[1].uv_stage, Some(0)); + assert_eq!(frame.passes[1].secondary_uv_stage, Some(1)); + assert_eq!(frame.passes[2].kind, SkyPassKind::DomeGradient); + assert_eq!(frame.passes[3].kind, SkyPassKind::Clouds); + assert_eq!(frame.passes[3].uv_stage, Some(2)); + assert_eq!(frame.passes[3].translation_z, -5000.0); + let rgba = sky.packed[0].rgba(); + assert_eq!( + frame.passes[3].material_color, + Some([rgba[0], rgba[1], rgba[2]]) + ); + } + + #[test] + fn sky_update_rejects_non_finite_input() { + let mut system = SkySystem::new(SkyGeometryConfig::default(), SkyMaterials::default()) + .expect("geometry"); + let mut frame = atmosphere(false); + frame.sky.values[0] = f32::NAN; + assert_eq!( + system.update(&frame, 0.0), + Err(SkyUpdateError::NonFiniteAtmosphere) + ); + } +} -- cgit v1.2.3