Skip to main content

omp_tui/shader/
eclipse.rs

1//! The stippled-eclipse effect: a CPU port of the WebGPU shader behind
2//! stencil.so's landing page, and the reference [`Program`] implementation.
3#![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
17/// Upper bound on dust sprites; the arc-area formula stays well under it.
18const MAX_PARTICLES: f32 = 4000.0;
19/// Reference viewport area: the web shader's 1920×1080 design pixels. The
20/// scale factor derived from it drives every distance in the effect, so
21/// the geometry survives the ~10× drop to terminal resolution.
22const DESIGN_AREA: f32 = 1920.0 * 1080.0;
23/// Full glimmer waves along the visible rim.
24const GLIMMER_WAVES: f32 = 2.2;
25
26/// Rim anchor caps, as viewport fractions. The legacy scale-based circle
27/// decides where the arc enters the top edge and exits the right edge, but
28/// each anchor is clamped: entry never past `RIM_TOP_X * width`, exit never
29/// below `RIM_RIGHT_Y * height` (legacy exits through the bottom edge count
30/// as below). The arc then bulges toward the lower-left by `RIM_BULGE *
31/// chord` (sagitta).
32const 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/// A stippled eclipse in the brand palette.
43///
44/// An analytic rim arc pinned to the viewport's top and right edges, a
45/// sky/violet/plum corona regenerating on a 90-frame cycle, rim glimmer,
46/// migrating silver dust, film grain, and a vignette.
47///
48/// Use it as a full-viewport backdrop — mount it with
49/// [`crate::components::Shader`], or drive it through
50/// [`Surface::render`](crate::shader::Surface::render) under hand-painted
51/// chrome. The field is opaque everywhere, so it always paints the whole
52/// target. Every distance derives from `sqrt(area / DESIGN_AREA)`, exactly
53/// as on the web, so the geometry adapts to any viewport. All state is
54/// recomputed in [`Program::advance`] — the CPU twin of the WGSL `Scene`
55/// uniform — making frames a pure function of the clock.
56///
57/// The GPU original draws stipple sites at 2×2 physical pixels and adds a
58/// barely-there monochrome grade pass; here every half-block pixel is a
59/// stipple site and the grade is dropped — it sits below one terminal
60/// color step.
61#[derive(Default)]
62pub struct Eclipse {
63	width:          f32,
64	height:         f32,
65	/// Pixel row pitch for stipple/grain cell indices.
66	pixel_width:    u32,
67	/// `sqrt(area / DESIGN_AREA)`: every falloff, speed, and depth scales
68	/// by it, exactly as on the web.
69	scale:          f32,
70	center:         (f32, f32),
71	radius:         f32,
72	/// Solid rim band width; the web's `5 * scale` floored at one pixel so
73	/// the cyan arc survives coarse terminal pixels.
74	rim_band:       f32,
75	theta_min:      f32,
76	theta_max:      f32,
77	glimmer_k:      f32,
78	dust_depth:     f32,
79	particle_count: u32,
80	/// Clock in web design frames (60 per second).
81	time_frames:    f32,
82	/// `floor(time_frames)`: the stipple regeneration counter.
83	frame:          u32,
84}
85
86impl Eclipse {
87	/// Radial brightness falloff toward the viewport corners.
88	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	/// Slow brightness wave traveling along the rim.
96	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		// The web clamps scale to 0.5..=2.0; a terminal target sits far
110		// below the design area, so only guard the degenerate low end.
111		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		// Legacy scale-based circle, used only to place the edge anchors.
116		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		// No right-edge crossing (arc would exit through the bottom) counts
122		// as "below the cap", so the cap takes over on wide viewports.
123		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		// Circle through the two capped edge anchors with a chord-relative
129		// bulge; the center sits on the plate side (down-left chord normal).
130		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		// Inside the rim sits the featureless plate.
164		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			// Corona stipple: three exponential shells, each pixel rolling
175			// against the summed density on a staggered 90-frame cycle.
176			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		// Dust is pure hash-derived instancing, exactly like the GPU vertex
208		// shader: each mote's orbit, speed, and wander come from its index.
209		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
235/// Angular span of the rim circle visible inside the viewport, padded a
236/// few hundredths of a radian so dust never pops at the edges.
237fn 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	/// Collects one rendered frame as `(x, y, fg)` cells.
262	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		// The stipple is sparse, so single corona cells may roll dark; the
285		// solid rim band always lights something.
286		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}