Skip to main content

omp_tui/
shader.rs

1//! CPU fragment-shader toolkit: a GPU-style program trait and a rasterizer
2//! that packs per-pixel shading into half-block cells.
3//!
4//! The mental model is a fullscreen GPU pipeline at terminal resolution:
5//! implement [`Program`] — a per-frame [`advance`](Program::advance), a
6//! per-pixel [`fragment`](Program::fragment), and an optional point-sprite
7//! [`particles`](Program::particles) pass — or use a plain
8//! `Fn(f32, f32) -> (Vec3, f32)` closure for a still field. Then either
9//! mount it in a retained tree with [`crate::components::Shader`] or paint
10//! it into any cell grid with [`Surface::render`].
11//!
12//! One terminal cell is a 1×2 pixel column (`▀` foreground over
13//! background), so a `cols × rows` viewport is a `cols × rows·2` pixel
14//! target with square pixels on a typical 1:2 cell. Everything is a pure
15//! function of the caller's clock, so animated effects stay deterministic
16//! and testable.
17//!
18//! # Example
19//! ```
20//! use omp_tui::{
21//! 	scene::{Vec3, vec3},
22//! 	shader::Surface,
23//! };
24//!
25//! // A vertical dusk gradient: any `Fn(x, y) -> (color, coverage)` closure.
26//! let mut dusk = |_: f32, y: f32| (vec3(0.9, 0.4, 0.2).lerp(vec3(0.05, 0.05, 0.2), y / 16.0), 1.0);
27//! let mut lit = 0;
28//! Surface::new().render(&mut dusk, std::time::Duration::ZERO, 20, 8, |_, _, _, _, _| lit += 1);
29//! assert_eq!(lit, 20 * 8);
30//! ```
31
32mod eclipse;
33
34use std::time::Duration;
35
36pub use eclipse::Eclipse;
37
38use crate::{frame::Color, scene::Vec3};
39
40/// Coverage at which a pixel lights; both halves dark leaves the cell
41/// untouched, so whatever sits behind it shows through.
42const LIT_THRESHOLD: f32 = 0.04;
43
44/// A fullscreen effect: per-frame state plus a shader for every pixel.
45///
46/// [`fragment`](Self::fragment) returns a unit-range color and a coverage
47/// alpha. Coverage decides whether a pixel lights; color, scaled by
48/// coverage, is composited over black. [`particles`](Self::particles)
49/// splats point sprites over the shaded field — the CPU stand-in for an
50/// instanced sprite pass. Any `Fn(f32, f32) -> (Vec3, f32)` closure is a
51/// still, particle-free program.
52pub trait Program {
53	/// Advances animation state to `now` for a `width` × `height` pixel
54	/// target. Runs once per frame before any sampling.
55	fn advance(&mut self, now: Duration, width: f32, height: f32) {
56		let _ = (now, width, height);
57	}
58
59	/// Shades the pixel centered at `(x, y)`: `(color, coverage)`, both in
60	/// unit range.
61	fn fragment(&self, x: f32, y: f32) -> (Vec3, f32);
62
63	/// Emits point sprites as `emit(x, y, color, alpha)`; each blends over
64	/// the shaded field at its nearest pixel. Off-target sprites are
65	/// ignored.
66	fn particles(&self, emit: &mut dyn FnMut(f32, f32, Vec3, f32)) {
67		let _ = emit;
68	}
69}
70
71impl<F: Fn(f32, f32) -> (Vec3, f32)> Program for F {
72	fn fragment(&self, x: f32, y: f32) -> (Vec3, f32) {
73		self(x, y)
74	}
75}
76
77/// Avalanching integer hash (lowbias32) — the seed mixer stipple and dust
78/// shaders build on; identical to the WGSL `hash_u32` used on GPU ports.
79pub const fn hash(value: u32) -> u32 {
80	let mut mixed = value;
81	mixed = (mixed ^ (mixed >> 16)).wrapping_mul(0x7feb_352d);
82	mixed = (mixed ^ (mixed >> 15)).wrapping_mul(0x846c_a68b);
83	mixed ^ (mixed >> 16)
84}
85
86/// A uniform `[0, 1)` sample from a hash seed — stable across frames, so
87/// hash-derived geometry never flickers.
88pub fn rand01(value: u32) -> f32 {
89	(hash(value) >> 8) as f32 / 16_777_216.0
90}
91
92/// A reusable half-block render target.
93///
94/// Owns the pixel buffer between frames, so rendering allocates nothing
95/// once warm. One instance per viewport; sizes are per-call.
96#[derive(Default)]
97pub struct Surface {
98	/// Premultiplied linear color and accumulated coverage per pixel,
99	/// row-major at `cols × rows·2`. A `Vec` is the right shape here: one
100	/// large frame-sized buffer, reused across frames.
101	pixels: Vec<(Vec3, f32)>,
102}
103
104impl Surface {
105	/// Creates an empty target; the buffer grows on first render.
106	pub const fn new() -> Self {
107		Self { pixels: Vec::new() }
108	}
109
110	/// Renders one frame of `program` into a `cols` × `rows` grid of
111	/// half-block cells.
112	///
113	/// Advances the program to `now`, shades every pixel, splats particles,
114	/// then emits lit cells as `put(column, row, glyph, fg, bg)`: `▀` with
115	/// both colors when both halves lit, `▀`/`▄` with `bg = None` when only
116	/// one half is, nothing when neither.
117	pub fn render<P: Program + ?Sized>(
118		&mut self,
119		program: &mut P,
120		now: Duration,
121		cols: u16,
122		rows: u16,
123		mut put: impl FnMut(u16, u16, char, Color, Option<Color>),
124	) {
125		if cols == 0 || rows == 0 {
126			return;
127		}
128		let width = cols as usize;
129		let height = rows as usize * 2;
130		program.advance(now, width as f32, height as f32);
131
132		self.pixels.clear();
133		self.pixels.reserve(width * height);
134		for py in 0..height {
135			for px in 0..width {
136				let (color, alpha) = program.fragment(px as f32 + 0.5, py as f32 + 0.5);
137				let alpha = alpha.clamp(0.0, 1.0);
138				self.pixels.push((color * alpha, alpha));
139			}
140		}
141		program.particles(&mut |x: f32, y: f32, color: Vec3, alpha: f32| {
142			if x < 0.0 || y < 0.0 || x >= width as f32 || y >= height as f32 {
143				return;
144			}
145			let alpha = alpha.clamp(0.0, 1.0);
146			let pixel = &mut self.pixels[y as usize * width + x as usize];
147			pixel.0 = color * alpha + pixel.0 * (1.0 - alpha);
148			pixel.1 = pixel.1.mul_add(1.0 - alpha, alpha);
149		});
150
151		for row in 0..rows as usize {
152			for col in 0..width {
153				let (top, top_cover) = self.pixels[row * 2 * width + col];
154				let (bottom, bottom_cover) = self.pixels[(row * 2 + 1) * width + col];
155				let (glyph, fg, bg) = match (top_cover >= LIT_THRESHOLD, bottom_cover >= LIT_THRESHOLD)
156				{
157					(true, true) => ('▀', top, Some(bottom)),
158					(true, false) => ('▀', top, None),
159					(false, true) => ('▄', bottom, None),
160					(false, false) => continue,
161				};
162				put(col as u16, row as u16, glyph, Color::from(fg), bg.map(Color::from));
163			}
164		}
165	}
166}
167
168#[cfg(test)]
169mod tests {
170	use super::*;
171	use crate::scene::vec3;
172
173	fn cells<P: Program>(
174		cols: u16,
175		rows: u16,
176		program: &mut P,
177	) -> Vec<(u16, u16, char, Color, Option<Color>)> {
178		let mut out = Vec::new();
179		Surface::new().render(program, Duration::ZERO, cols, rows, |x, y, glyph, fg, bg| {
180			out.push((x, y, glyph, fg, bg));
181		});
182		out
183	}
184
185	#[test]
186	fn opaque_field_packs_pixel_pairs_into_upper_half_blocks() {
187		// Top pixel row red, bottom row blue.
188		let mut split = |_: f32, y: f32| {
189			if y < 1.0 {
190				(vec3(1.0, 0.0, 0.0), 1.0)
191			} else {
192				(vec3(0.0, 0.0, 1.0), 1.0)
193			}
194		};
195		let cells = cells(4, 1, &mut split);
196		assert_eq!(cells.len(), 4);
197		assert!(cells.iter().all(|&(.., glyph, fg, bg)| {
198			glyph == '▀' && fg == Color::Rgb(255, 0, 0) && bg == Some(Color::Rgb(0, 0, 255))
199		}));
200	}
201
202	#[test]
203	fn coverage_below_the_lit_threshold_stays_transparent() {
204		let mut haze = |_: f32, _: f32| (vec3(1.0, 1.0, 1.0), 0.02);
205		assert_eq!(cells(8, 4, &mut haze).len(), 0, "0.02 sits under the lit threshold");
206	}
207
208	#[test]
209	fn a_half_lit_cell_picks_the_matching_half_block() {
210		let mut top = |_: f32, y: f32| (vec3(1.0, 1.0, 1.0), if y < 1.0 { 1.0 } else { 0.0 });
211		assert_eq!(cells(1, 1, &mut top), vec![(0, 0, '▀', Color::Rgb(255, 255, 255), None)]);
212		let mut bottom = |_: f32, y: f32| (vec3(1.0, 1.0, 1.0), if y < 1.0 { 0.0 } else { 1.0 });
213		assert_eq!(cells(1, 1, &mut bottom), vec![(0, 0, '▄', Color::Rgb(255, 255, 255), None)]);
214	}
215
216	#[test]
217	fn particles_blend_over_the_field_and_ignore_off_target_splats() {
218		struct Dust;
219		impl Program for Dust {
220			fn fragment(&self, _: f32, _: f32) -> (Vec3, f32) {
221				(Vec3::ZERO, 1.0)
222			}
223
224			fn particles(&self, emit: &mut dyn FnMut(f32, f32, Vec3, f32)) {
225				emit(0.5, 0.5, vec3(1.0, 1.0, 1.0), 0.5);
226				emit(-3.0, 0.5, vec3(1.0, 1.0, 1.0), 1.0);
227				emit(0.5, 99.0, vec3(1.0, 1.0, 1.0), 1.0);
228			}
229		}
230		let cells = cells(2, 1, &mut Dust);
231		// Half-alpha white over black is linear 0.5, encoded as sRGB 188.
232		assert!(cells.contains(&(0, 0, '▀', Color::Rgb(188, 188, 188), Some(Color::Rgb(0, 0, 0)))));
233		assert!(cells.contains(&(1, 0, '▀', Color::Rgb(0, 0, 0), Some(Color::Rgb(0, 0, 0)))));
234	}
235
236	#[test]
237	fn rand01_is_deterministic_and_unit_range() {
238		for seed in 0..1000_u32 {
239			let sample = rand01(seed);
240			assert!((0.0..1.0).contains(&sample));
241			assert_eq!(sample, rand01(seed));
242		}
243	}
244
245	#[test]
246	fn advance_sees_the_pixel_resolution() {
247		struct Probe(f32, f32);
248		impl Program for Probe {
249			fn advance(&mut self, _: Duration, width: f32, height: f32) {
250				*self = Self(width, height);
251			}
252
253			fn fragment(&self, _: f32, _: f32) -> (Vec3, f32) {
254				(Vec3::ZERO, 0.0)
255			}
256		}
257		let mut probe = Probe(0.0, 0.0);
258		Surface::new().render(&mut probe, Duration::ZERO, 10, 4, |_, _, _, _, _| {});
259		assert_eq!((probe.0, probe.1), (10.0, 8.0), "rows double into pixel height");
260	}
261}