1#![allow(
4 clippy::suboptimal_flops,
5 clippy::imprecise_flops,
6 clippy::manual_midpoint,
7 reason = "kept literal to the WGSL original"
8)]
9
10use std::{f32::consts::TAU, time::Duration};
11
12use crate::{
13 scene::{Vec3, vec3},
14 shader::{Program, hash, rand01},
15};
16
17const MAX_PARTICLES: f32 = 4000.0;
19const DESIGN_AREA: f32 = 1920.0 * 1080.0;
23const GLIMMER_WAVES: f32 = 2.2;
25
26const RIM_TOP_X: f32 = 0.405;
33const RIM_RIGHT_Y: f32 = 0.585;
34const RIM_BULGE: f32 = 0.062;
35
36const SKY: Vec3 = Vec3::rgb(56, 189, 248);
37const VIOLET: Vec3 = Vec3::rgb(192, 132, 252);
38const PLUM: Vec3 = Vec3::rgb(70, 15, 85);
39const SILVER: Vec3 = Vec3::rgb(250, 250, 252);
40const BLACK: Vec3 = Vec3::rgb(9, 9, 11);
41
42#[derive(Default)]
62pub struct Eclipse {
63 width: f32,
64 height: f32,
65 pixel_width: u32,
67 scale: f32,
70 center: (f32, f32),
71 radius: f32,
72 rim_band: f32,
75 theta_min: f32,
76 theta_max: f32,
77 glimmer_k: f32,
78 dust_depth: f32,
79 particle_count: u32,
80 time_frames: f32,
82 frame: u32,
84}
85
86impl Eclipse {
87 fn vignette(&self, x: f32, y: f32) -> f32 {
89 let dx = x - self.width * 0.5;
90 let dy = y - self.height * 0.5;
91 let normalized = (dx * dx + dy * dy).sqrt() / (1600.0 * self.scale);
92 (1.0 - normalized.powi(3)).max(0.2)
93 }
94
95 fn glimmer(&self, theta: f32) -> f32 {
97 let phase = self.time_frames * (TAU * GLIMMER_WAVES / 720.0);
98 1.0 + 0.16 * (theta * self.glimmer_k - phase).sin()
99 }
100}
101
102impl Program for Eclipse {
103 fn advance(&mut self, now: Duration, width: f32, height: f32) {
104 self.time_frames = now.as_secs_f32() * 60.0;
105 self.frame = self.time_frames as u32;
106 self.width = width;
107 self.height = height;
108 self.pixel_width = width as u32;
109 let scale = ((width * height) / DESIGN_AREA).sqrt().max(0.01);
112 self.scale = scale;
113 self.rim_band = (5.0 * scale).max(1.0);
114
115 let legacy_cx = -724.0 * scale + (width - 1920.0 * scale) * 0.4;
117 let legacy_cy = 2000.0 * scale;
118 let legacy_r = 2500.0 * scale;
119 let legacy_top_x = legacy_cx + (legacy_r * legacy_r - legacy_cy * legacy_cy).sqrt();
120 let edge_dx = width - legacy_cx;
121 let legacy_right_y = if legacy_r > edge_dx {
124 legacy_cy - (legacy_r * legacy_r - edge_dx * edge_dx).sqrt()
125 } else {
126 f32::INFINITY
127 };
128 let (p1x, p1y) = (legacy_top_x.min(width * RIM_TOP_X), 0.0);
131 let (p2x, p2y) = (width, legacy_right_y.min(height * RIM_RIGHT_Y));
132 let (dx, dy) = (p2x - p1x, p2y - p1y);
133 let chord = (dx * dx + dy * dy).sqrt();
134 let sagitta = chord * RIM_BULGE;
135 let radius = chord * chord / (8.0 * sagitta) + sagitta * 0.5;
136 self.center = (
137 (p1x + p2x) * 0.5 - dy / chord * (radius - sagitta),
138 (p1y + p2y) * 0.5 + dx / chord * (radius - sagitta),
139 );
140 self.radius = radius;
141 self.dust_depth = 500.0 * scale;
142 let (theta_min, theta_max) = visible_arc(width, height, self.center.0, self.center.1);
143 self.theta_min = theta_min;
144 self.theta_max = theta_max;
145 self.glimmer_k = TAU * GLIMMER_WAVES / (theta_max - theta_min);
146 self.particle_count = (0.10
147 * 0.25 * (theta_max - theta_min)
148 * (radius - self.dust_depth * 0.5)
149 * self.dust_depth
150 * 0.25)
151 .round()
152 .clamp(0.0, MAX_PARTICLES) as u32;
153 }
154
155 fn fragment(&self, x: f32, y: f32) -> (Vec3, f32) {
156 let index = y as u32 * self.pixel_width + x as u32;
157 let grain = (rand01(index ^ 0x9e37_79b9) - 0.5) * Vec3::rgb(24, 24, 24).x;
158 let grain = vec3(grain, grain, grain);
159 let base = (BLACK * self.vignette(x, y) + grain).clamp01();
160 let (dx, dy) = (x - self.center.0, y - self.center.1);
161 let rim_distance = (dx * dx + dy * dy).sqrt() - self.radius;
162
163 if rim_distance < 0.0 {
165 return (base, 1.0);
166 }
167
168 let shade = self.vignette(x, y);
169 let theta = dy.atan2(dx);
170 let scale = self.scale;
171 let ink = if rim_distance < self.rim_band {
172 Some(SKY * (shade * self.glimmer(theta)))
173 } else {
174 let sky = (-rim_distance / (14.0 * scale)).exp();
177 let violet = (-rim_distance / (120.0 * scale)).exp()
178 * (rim_distance / (10.0 * scale)).min(1.0)
179 * 0.85;
180 let plum = (-rim_distance / (600.0 * scale)).exp()
181 * (rim_distance / (30.0 * scale)).min(1.0)
182 * 0.7;
183 let total = (sky + violet + plum).min(1.0);
184
185 if total > 0.003 {
186 let bucket = hash(index ^ 0x85eb_ca6b) % 90;
187 let generation = (self.frame + 89 - bucket) / 90;
188 let roll = rand01(index ^ generation.wrapping_mul(0xc2b2_ae35) ^ 0x2026_0712);
189 if roll < sky {
190 Some(SKY * (shade * self.glimmer(theta)))
191 } else if roll < sky + violet {
192 Some(VIOLET * shade)
193 } else if roll < total {
194 Some(PLUM * shade)
195 } else {
196 None
197 }
198 } else {
199 None
200 }
201 };
202
203 ink.map_or((base, 1.0), |ink| ((ink + grain).clamp01(), 1.0))
204 }
205
206 fn particles(&self, emit: &mut dyn FnMut(f32, f32, Vec3, f32)) {
207 for instance in 0..self.particle_count {
210 let seed = hash(instance ^ 0xa511_e9b3);
211 let mix = rand01(seed);
212 let theta_base = self.theta_min * (1.0 - mix) + self.theta_max * mix;
213 let sampled_depth = self.dust_depth * (1.0 - rand01(seed ^ 0x63d8_3595).cbrt());
214 let max_depth = sampled_depth.max(14.0 * self.scale);
215 let speed = (0.14 + rand01(seed ^ 0x9e37_79b9) * 0.22) * self.scale;
216 let offset = rand01(seed ^ 0xc2b2_ae35) * max_depth;
217 let travel = self.time_frames * speed + offset;
218 let depth = travel - (travel / max_depth).floor() * max_depth;
219 let radius = self.radius - depth;
220 let wave_frequency = 0.004 + rand01(seed ^ 0x27d4_eb2f) * 0.011;
221 let wave_phase = rand01(seed ^ 0x1656_67b1) * TAU;
222 let wander = -(0.06 * self.scale / wave_frequency)
223 * (self.time_frames * wave_frequency + wave_phase).cos()
224 / radius.max(1.0);
225 let theta = theta_base + wander;
226 let x = self.center.0 + radius * theta.cos();
227 let y = self.center.1 + radius * theta.sin();
228 let fade_in = (depth / 3.0).min(1.0);
229 let fade_out = (max_depth - depth) / (max_depth * 0.25);
230 emit(x, y, SILVER * self.vignette(x, y), fade_in.min(fade_out).clamp(0.0, 1.0));
231 }
232 }
233}
234
235fn visible_arc(width: f32, height: f32, center_x: f32, center_y: f32) -> (f32, f32) {
238 let mut theta_min = f32::INFINITY;
239 let mut theta_max = f32::NEG_INFINITY;
240 for step in 0..=32 {
241 let ratio = step as f32 / 32.0;
242 for (x, y) in [
243 (width * ratio, 0.0),
244 (width * ratio, height),
245 (0.0, height * ratio),
246 (width, height * ratio),
247 ] {
248 let theta = (y - center_y).atan2(x - center_x);
249 theta_min = theta_min.min(theta);
250 theta_max = theta_max.max(theta);
251 }
252 }
253 (theta_min - 0.04, theta_max + 0.04)
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::{frame::Color, shader::Surface};
260
261 fn frame_cells(cols: u16, rows: u16, at: Duration) -> Vec<(u16, u16, Color)> {
263 let mut cells = Vec::new();
264 let mut eclipse = Eclipse::default();
265 Surface::new().render(&mut eclipse, at, cols, rows, |x, y, _, fg, _| {
266 cells.push((x, y, fg));
267 });
268 cells
269 }
270
271 #[test]
272 fn the_field_is_opaque_and_splits_plate_from_corona() {
273 let cells = frame_cells(80, 24, Duration::ZERO);
274 assert_eq!(cells.len(), 80 * 24, "the eclipse paints every cell");
275 let bright = |color: &Color| match color {
276 Color::Rgb(r, g, b) => u16::from(*r) + u16::from(*g) + u16::from(*b) > 120,
277 _ => false,
278 };
279 let lit = |x: u16, y: u16| {
280 cells
281 .iter()
282 .any(|&(cx, cy, fg)| cx == x && cy == y && bright(&fg))
283 };
284 assert!(cells.iter().any(|(.., fg)| bright(fg)), "the rim band lights cells");
287 assert!(!lit(0, 23), "the plate keeps the bottom-left corner dark");
288 }
289
290 #[test]
291 fn the_stipple_regenerates_over_time() {
292 let start = frame_cells(60, 20, Duration::ZERO);
293 let later = frame_cells(60, 20, Duration::from_secs(2));
294 assert_ne!(start, later, "the 90-frame cycle re-rolls corona pixels");
295 }
296}