#![allow(
clippy::suboptimal_flops,
clippy::imprecise_flops,
clippy::manual_midpoint,
reason = "kept literal to the WGSL original"
)]
use std::{f32::consts::TAU, time::Duration};
use crate::{
scene::{Vec3, vec3},
shader::{Program, hash, rand01},
};
const MAX_PARTICLES: f32 = 4000.0;
const DESIGN_AREA: f32 = 1920.0 * 1080.0;
const GLIMMER_WAVES: f32 = 2.2;
const RIM_TOP_X: f32 = 0.405;
const RIM_RIGHT_Y: f32 = 0.585;
const RIM_BULGE: f32 = 0.062;
const SKY: Vec3 = Vec3::rgb(56, 189, 248);
const VIOLET: Vec3 = Vec3::rgb(192, 132, 252);
const PLUM: Vec3 = Vec3::rgb(70, 15, 85);
const SILVER: Vec3 = Vec3::rgb(250, 250, 252);
const BLACK: Vec3 = Vec3::rgb(9, 9, 11);
#[derive(Default)]
pub struct Eclipse {
width: f32,
height: f32,
pixel_width: u32,
scale: f32,
center: (f32, f32),
radius: f32,
rim_band: f32,
theta_min: f32,
theta_max: f32,
glimmer_k: f32,
dust_depth: f32,
particle_count: u32,
time_frames: f32,
frame: u32,
}
impl Eclipse {
fn vignette(&self, x: f32, y: f32) -> f32 {
let dx = x - self.width * 0.5;
let dy = y - self.height * 0.5;
let normalized = (dx * dx + dy * dy).sqrt() / (1600.0 * self.scale);
(1.0 - normalized.powi(3)).max(0.2)
}
fn glimmer(&self, theta: f32) -> f32 {
let phase = self.time_frames * (TAU * GLIMMER_WAVES / 720.0);
1.0 + 0.16 * (theta * self.glimmer_k - phase).sin()
}
}
impl Program for Eclipse {
fn advance(&mut self, now: Duration, width: f32, height: f32) {
self.time_frames = now.as_secs_f32() * 60.0;
self.frame = self.time_frames as u32;
self.width = width;
self.height = height;
self.pixel_width = width as u32;
let scale = ((width * height) / DESIGN_AREA).sqrt().max(0.01);
self.scale = scale;
self.rim_band = (5.0 * scale).max(1.0);
let legacy_cx = -724.0 * scale + (width - 1920.0 * scale) * 0.4;
let legacy_cy = 2000.0 * scale;
let legacy_r = 2500.0 * scale;
let legacy_top_x = legacy_cx + (legacy_r * legacy_r - legacy_cy * legacy_cy).sqrt();
let edge_dx = width - legacy_cx;
let legacy_right_y = if legacy_r > edge_dx {
legacy_cy - (legacy_r * legacy_r - edge_dx * edge_dx).sqrt()
} else {
f32::INFINITY
};
let (p1x, p1y) = (legacy_top_x.min(width * RIM_TOP_X), 0.0);
let (p2x, p2y) = (width, legacy_right_y.min(height * RIM_RIGHT_Y));
let (dx, dy) = (p2x - p1x, p2y - p1y);
let chord = (dx * dx + dy * dy).sqrt();
let sagitta = chord * RIM_BULGE;
let radius = chord * chord / (8.0 * sagitta) + sagitta * 0.5;
self.center = (
(p1x + p2x) * 0.5 - dy / chord * (radius - sagitta),
(p1y + p2y) * 0.5 + dx / chord * (radius - sagitta),
);
self.radius = radius;
self.dust_depth = 500.0 * scale;
let (theta_min, theta_max) = visible_arc(width, height, self.center.0, self.center.1);
self.theta_min = theta_min;
self.theta_max = theta_max;
self.glimmer_k = TAU * GLIMMER_WAVES / (theta_max - theta_min);
self.particle_count = (0.10
* 0.25 * (theta_max - theta_min)
* (radius - self.dust_depth * 0.5)
* self.dust_depth
* 0.25)
.round()
.clamp(0.0, MAX_PARTICLES) as u32;
}
fn fragment(&self, x: f32, y: f32) -> (Vec3, f32) {
let index = y as u32 * self.pixel_width + x as u32;
let grain = (rand01(index ^ 0x9e37_79b9) - 0.5) * Vec3::rgb(24, 24, 24).x;
let grain = vec3(grain, grain, grain);
let base = (BLACK * self.vignette(x, y) + grain).clamp01();
let (dx, dy) = (x - self.center.0, y - self.center.1);
let rim_distance = (dx * dx + dy * dy).sqrt() - self.radius;
if rim_distance < 0.0 {
return (base, 1.0);
}
let shade = self.vignette(x, y);
let theta = dy.atan2(dx);
let scale = self.scale;
let ink = if rim_distance < self.rim_band {
Some(SKY * (shade * self.glimmer(theta)))
} else {
let sky = (-rim_distance / (14.0 * scale)).exp();
let violet = (-rim_distance / (120.0 * scale)).exp()
* (rim_distance / (10.0 * scale)).min(1.0)
* 0.85;
let plum = (-rim_distance / (600.0 * scale)).exp()
* (rim_distance / (30.0 * scale)).min(1.0)
* 0.7;
let total = (sky + violet + plum).min(1.0);
if total > 0.003 {
let bucket = hash(index ^ 0x85eb_ca6b) % 90;
let generation = (self.frame + 89 - bucket) / 90;
let roll = rand01(index ^ generation.wrapping_mul(0xc2b2_ae35) ^ 0x2026_0712);
if roll < sky {
Some(SKY * (shade * self.glimmer(theta)))
} else if roll < sky + violet {
Some(VIOLET * shade)
} else if roll < total {
Some(PLUM * shade)
} else {
None
}
} else {
None
}
};
ink.map_or((base, 1.0), |ink| ((ink + grain).clamp01(), 1.0))
}
fn particles(&self, emit: &mut dyn FnMut(f32, f32, Vec3, f32)) {
for instance in 0..self.particle_count {
let seed = hash(instance ^ 0xa511_e9b3);
let mix = rand01(seed);
let theta_base = self.theta_min * (1.0 - mix) + self.theta_max * mix;
let sampled_depth = self.dust_depth * (1.0 - rand01(seed ^ 0x63d8_3595).cbrt());
let max_depth = sampled_depth.max(14.0 * self.scale);
let speed = (0.14 + rand01(seed ^ 0x9e37_79b9) * 0.22) * self.scale;
let offset = rand01(seed ^ 0xc2b2_ae35) * max_depth;
let travel = self.time_frames * speed + offset;
let depth = travel - (travel / max_depth).floor() * max_depth;
let radius = self.radius - depth;
let wave_frequency = 0.004 + rand01(seed ^ 0x27d4_eb2f) * 0.011;
let wave_phase = rand01(seed ^ 0x1656_67b1) * TAU;
let wander = -(0.06 * self.scale / wave_frequency)
* (self.time_frames * wave_frequency + wave_phase).cos()
/ radius.max(1.0);
let theta = theta_base + wander;
let x = self.center.0 + radius * theta.cos();
let y = self.center.1 + radius * theta.sin();
let fade_in = (depth / 3.0).min(1.0);
let fade_out = (max_depth - depth) / (max_depth * 0.25);
emit(x, y, SILVER * self.vignette(x, y), fade_in.min(fade_out).clamp(0.0, 1.0));
}
}
}
fn visible_arc(width: f32, height: f32, center_x: f32, center_y: f32) -> (f32, f32) {
let mut theta_min = f32::INFINITY;
let mut theta_max = f32::NEG_INFINITY;
for step in 0..=32 {
let ratio = step as f32 / 32.0;
for (x, y) in [
(width * ratio, 0.0),
(width * ratio, height),
(0.0, height * ratio),
(width, height * ratio),
] {
let theta = (y - center_y).atan2(x - center_x);
theta_min = theta_min.min(theta);
theta_max = theta_max.max(theta);
}
}
(theta_min - 0.04, theta_max + 0.04)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{frame::Color, shader::Surface};
fn frame_cells(cols: u16, rows: u16, at: Duration) -> Vec<(u16, u16, Color)> {
let mut cells = Vec::new();
let mut eclipse = Eclipse::default();
Surface::new().render(&mut eclipse, at, cols, rows, |x, y, _, fg, _| {
cells.push((x, y, fg));
});
cells
}
#[test]
fn the_field_is_opaque_and_splits_plate_from_corona() {
let cells = frame_cells(80, 24, Duration::ZERO);
assert_eq!(cells.len(), 80 * 24, "the eclipse paints every cell");
let bright = |color: &Color| match color {
Color::Rgb(r, g, b) => u16::from(*r) + u16::from(*g) + u16::from(*b) > 120,
_ => false,
};
let lit = |x: u16, y: u16| {
cells
.iter()
.any(|&(cx, cy, fg)| cx == x && cy == y && bright(&fg))
};
assert!(cells.iter().any(|(.., fg)| bright(fg)), "the rim band lights cells");
assert!(!lit(0, 23), "the plate keeps the bottom-left corner dark");
}
#[test]
fn the_stipple_regenerates_over_time() {
let start = frame_cells(60, 20, Duration::ZERO);
let later = frame_cells(60, 20, Duration::from_secs(2));
assert_ne!(start, later, "the 90-frame cycle re-rolls corona pixels");
}
}