Complete the interactive mission viewer with environment rendering, audio events, dynamic shadows, free-flight camera controls, and per-component CTL pose sampling.
2080 lines
72 KiB
Rust
2080 lines
72 KiB
Rust
//! 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<SkyMesh, SkyGeometryError> {
|
|
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<SkyMesh, SkyGeometryError> {
|
|
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<SkyMesh, SkyGeometryError> {
|
|
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<SkyVertex>,
|
|
/// Triangle-list indices: one fan triangle and `rings - 1` quads per
|
|
/// azimuth sector.
|
|
pub indices: Vec<u32>,
|
|
}
|
|
|
|
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<SkyMaterialRow>,
|
|
/// Optional lightmap rows after the `LIGHTMAPS` section.
|
|
pub lightmaps: Vec<SkyMaterialRow>,
|
|
}
|
|
|
|
impl SkyMaterials {
|
|
/// Parses a text `sky.wea` payload.
|
|
pub fn parse(bytes: &[u8]) -> Result<Self, SkyWeaError> {
|
|
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::<usize>()
|
|
.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::<usize>()
|
|
.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<I, S>(names: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: Into<String>,
|
|
{
|
|
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<Vec<String>> for SkyMaterials {
|
|
fn from(names: Vec<String>) -> 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<SkyMaterialRow, SkyWeaError> {
|
|
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::<i32>()
|
|
.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, SkyWeaError> {
|
|
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<usize>,
|
|
/// Secondary positional WEA row for the stars texture.
|
|
pub secondary_material_row: Option<usize>,
|
|
/// 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<usize>,
|
|
/// Secondary vertex UV stage.
|
|
pub secondary_uv_stage: Option<usize>,
|
|
/// 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<usize>,
|
|
/// 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<SunOptics, SkyProjectionError> {
|
|
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<SkyLayerFrame<'a>>,
|
|
/// 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<SunOptics, SkyProjectionError> {
|
|
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<Self, SkyGeometryError> {
|
|
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<SkyFrame<'_>, 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<SkyLayerFrame<'a>> {
|
|
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::<Vec<_>>();
|
|
|
|
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::<Vec<_>>(),
|
|
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)
|
|
);
|
|
}
|
|
}
|