use crate::anim::Easing;
use crate::base::{Rgba, Size};
use crate::render::{Cell, Style, Surface};
use crate::theme::Theme;
use crate::three::extract::MeshData;
use crate::three::load::{MaterialData, MeshInstance, Model};
use crate::three::math::{Mat4, Vec3};
use crate::three::primitives::cuboid;
use crate::three::raster::Framebuffer;
use crate::three::scene::{Camera, Light, Scene, SceneRenderer};
use crate::three::texture::srgb8_to_linear;
const PLANE_W: f32 = 1.0;
const PLANE_H: f32 = 0.62;
const PLANE_T: f32 = 0.04;
const PLANE_DZ: f32 = 0.22;
const PLANE_DY: f32 = 0.18;
const PLANE_PITCH_DEG: f32 = 12.0;
const CROSSBAR_SCALE: f32 = 0.7;
const ARRIVAL_OFFSET: Vec3 = Vec3::new(2.6, -2.2, 1.2);
const ARRIVAL_FRACTION: f32 = 0.94;
#[derive(Clone, Debug, PartialEq)]
pub struct BrandmarkParams {
pub align_start_ms: u32,
pub reveal_start_ms: u32,
pub hold_start_ms: u32,
pub plane_stagger_ms: u32,
pub plane_arrival_ms: u32,
pub burst_at_ms: u32,
pub burst_particles: u32,
pub burst_lifetime_ms: u32,
pub afterglow_decay_per_100ms: f32,
pub ease_arrival: [f32; 4],
pub ease_settle: [f32; 4],
pub ease_tracking: [f32; 4],
pub ease_fade: [f32; 4],
pub camera_yaw_deg: (f32, f32),
pub camera_pitch_deg: f32,
pub camera_dolly: (f32, f32),
pub ramp: [Rgba; 5],
pub field: Rgba,
pub wordmark: &'static str,
pub tagline: &'static str,
pub skip_hint: &'static str,
pub wordmark_tracking: (u16, u16),
}
#[cfg(test)]
impl BrandmarkParams {
pub(crate) fn reference() -> BrandmarkParams {
BrandmarkParams {
align_start_ms: 900,
reveal_start_ms: 1400,
hold_start_ms: 1850,
plane_stagger_ms: 120,
plane_arrival_ms: 780,
burst_at_ms: 900,
burst_particles: 12,
burst_lifetime_ms: 450,
afterglow_decay_per_100ms: 0.72,
ease_arrival: [0.16, 1.0, 0.30, 1.0],
ease_settle: [0.34, 1.56, 0.64, 1.0],
ease_tracking: [0.83, 0.0, 0.17, 1.0],
ease_fade: [0.33, 1.0, 0.68, 1.0],
camera_yaw_deg: (-35.0, -6.0),
camera_pitch_deg: 8.0,
camera_dolly: (5.2, 4.4),
ramp: [
Rgba::rgb(0xe9, 0x45, 0x60),
Rgba::rgb(0xc9, 0x53, 0x8f),
Rgba::rgb(0x9d, 0x6b, 0xc9),
Rgba::rgb(0x7a, 0x86, 0xe8),
Rgba::rgb(0x60, 0xa5, 0xfa),
],
field: Rgba::rgb(0x0f, 0x34, 0x60),
wordmark: "AbstractTUI",
tagline: "the terminal, composed",
skip_hint: "press any key to skip",
wordmark_tracking: (4, 1),
}
}
}
fn ramp_color(ramp: &[Rgba; 5], t: f32) -> Rgba {
let t = t.clamp(0.0, 1.0) * (ramp.len() - 1) as f32;
let i = (t.floor() as usize).min(ramp.len() - 2);
ramp[i].lerp(ramp[i + 1], t - i as f32)
}
pub struct BrandmarkRenderer {
params: BrandmarkParams,
scene_renderer: SceneRenderer,
fb: Framebuffer,
trail: crate::gfx::Bitmap,
mosaic: crate::gfx::MosaicRenderer,
surface: Surface,
last_t: f32,
}
impl BrandmarkRenderer {
pub fn with_params(params: BrandmarkParams) -> BrandmarkRenderer {
BrandmarkRenderer {
params,
scene_renderer: SceneRenderer::new(),
fb: Framebuffer::new(0, 0),
trail: crate::gfx::Bitmap::default(),
mosaic: crate::gfx::MosaicRenderer::new(),
surface: Surface::new(Size::new(0, 0), Cell::EMPTY),
last_t: 0.0,
}
}
pub fn render(&mut self, t: f32, size: Size, theme: &Theme) -> &Surface {
let w = size.w.max(0) as u32;
let h = size.h.max(0) as u32;
let (pw, ph) = (w, h * 2);
if self.fb.width() != pw || self.fb.height() != ph {
self.fb = Framebuffer::new(pw, ph);
self.trail = crate::gfx::Bitmap::new(pw, ph, Rgba::TRANSPARENT);
}
if self.surface.size() != size {
self.surface = Surface::new(size, Cell::EMPTY);
}
if size.w <= 0 || size.h <= 0 {
return &self.surface;
}
let bg = theme.tokens.bg;
let p = self.params.clone();
let model = build_planes(t, &p);
let ms = t * 1000.0;
let cam_k = ease(
p.ease_tracking,
(ms / p.reveal_start_ms as f32).clamp(0.0, 1.0),
);
let yaw_deg = lerp(p.camera_yaw_deg.0, p.camera_yaw_deg.1, cam_k);
let dolly = lerp(p.camera_dolly.0, p.camera_dolly.1, cam_k);
let camera = Camera::orbit(
Vec3::new(0.0, PLANE_DY, 0.0), dolly,
yaw_deg.to_radians(),
p.camera_pitch_deg.to_radians(),
);
let mut scene = Scene::new(&model, camera);
scene.light = Light {
direction: Vec3::new(-0.3, -0.6, -0.75),
ambient: 0.72,
diffuse: 0.38,
};
scene.background = Rgba::TRANSPARENT;
scene.double_sided = true;
self.scene_renderer.render(&scene, &mut self.fb);
self.fb.depth_fog(bg, 0.45); draw_burst(&mut self.fb, ms, &p);
merge_afterglow(
&mut self.trail,
&mut self.fb,
t,
self.last_t,
p.afterglow_decay_per_100ms,
);
self.last_t = t;
let grid = self
.mosaic
.render(self.fb.bitmap(), w, h, crate::gfx::MosaicMode::HalfBlock);
let (cx, cy) = ((size.w as f32 - 1.0) * 0.5, (size.h as f32 - 1.0) * 0.5);
let max_r = (cx * cx + cy * cy).sqrt().max(1.0);
for row in 0..h {
for col in 0..w {
let cell = grid
.get(col, row)
.copied()
.unwrap_or(crate::gfx::MosaicCell::EMPTY);
let empty = cell.fg.is_transparent() && cell.bg.is_transparent();
let (ch, fg, bgc) = if empty {
let dx = col as f32 - cx;
let dy = row as f32 - cy;
let k = (dx * dx + dy * dy).sqrt() / max_r * 0.12;
let ground = mix(bg, p.field, k);
(' ', ground, ground)
} else {
(cell.ch, cell.fg.over(bg), cell.bg.over(bg))
};
let mut buf = [0u8; 4];
let s: &str = ch.encode_utf8(&mut buf);
self.surface
.draw_text(col as i32, row as i32, s, Style::new().fg(fg).bg(bgc));
}
}
self.draw_wordmark(ms, size, theme);
self.draw_hints(ms, size, theme);
&self.surface
}
fn draw_wordmark(&mut self, ms: f32, size: Size, theme: &Theme) {
let p = self.params.clone();
let reveal = p.reveal_start_ms as f32;
if ms < reveal {
return;
}
let k = ((ms - reveal) / (p.hold_start_ms as f32 - reveal)).clamp(0.0, 1.0);
let track_k = ease(p.ease_tracking, k);
let tracking = lerp(
p.wordmark_tracking.0 as f32,
p.wordmark_tracking.1 as f32,
track_k,
);
let letters: Vec<char> = p.wordmark.chars().collect();
let n = letters.len() as f32;
let width = n + (n - 1.0) * (tracking - 1.0).max(0.0);
let x0 = (size.w as f32 - width) * 0.5;
let y = size.h - 3;
let bg = theme.tokens.bg;
for (i, chl) in letters.iter().enumerate() {
let fade = ((ms - reveal - i as f32 * 30.0) / 200.0).clamp(0.0, 1.0);
if fade <= 0.0 {
continue;
}
let fg = mix(bg, theme.tokens.text, ease(p.ease_fade, fade));
let x = (x0 + i as f32 * tracking.max(1.0)).round() as i32;
let mut buf = [0u8; 4];
self.surface
.draw_text(x, y, chl.encode_utf8(&mut buf), Style::new().fg(fg));
}
let sweep = ease(p.ease_fade, k);
let full = width.round() as i32;
let lit = (full as f32 * sweep).round() as i32;
for x in 0..lit {
self.surface.draw_text(
x0.round() as i32 + x,
y + 1,
"─",
Style::new().fg(ramp_color(&p.ramp, x as f32 / full.max(1) as f32)),
);
}
let tag_fade = ((k - 0.35) / 0.5).clamp(0.0, 1.0);
if tag_fade > 0.0 {
let tag = p.tagline;
let tx = (size.w - tag.chars().count() as i32) / 2;
let fg = mix(bg, theme.tokens.text_muted, ease(p.ease_fade, tag_fade));
self.surface.draw_text(tx, y + 2, tag, Style::new().fg(fg));
}
}
fn draw_hints(&mut self, ms: f32, size: Size, theme: &Theme) {
if ms < 300.0 {
return;
}
let fade = ((ms - 300.0) / 250.0).clamp(0.0, 1.0);
let hint = self.params.skip_hint;
let x = size.w - hint.chars().count() as i32 - 1;
let fg = mix(
theme.tokens.bg,
theme.tokens.text_faint,
ease(self.params.ease_fade, fade),
);
self.surface
.draw_text(x, size.h - 1, hint, Style::new().fg(fg));
}
}
fn build_planes(t: f32, p: &BrandmarkParams) -> Model {
let ms = t * 1000.0;
let mut instances = Vec::with_capacity(3);
for i in 0..3u32 {
let start = (i * p.plane_stagger_ms) as f32;
let a = ((ms - start) / p.plane_arrival_ms as f32).clamp(0.0, 1.0);
let arrive = ease(p.ease_arrival, a) * ARRIVAL_FRACTION;
let s = ((ms - p.align_start_ms as f32) / (p.reveal_start_ms - p.align_start_ms) as f32)
.clamp(0.0, 1.0);
let progress = arrive + ease(p.ease_settle, s) * (1.0 - ARRIVAL_FRACTION);
let fi = i as f32 - 1.0; let target = Vec3::new(0.0, (fi + 1.0) * PLANE_DY, fi * PLANE_DZ);
let pos = target + ARRIVAL_OFFSET * (1.0 - progress);
let scale = if i == 2 { CROSSBAR_SCALE } else { 1.0 };
let mut mesh = cuboid(PLANE_W * scale, PLANE_H, PLANE_T);
paint_ramp(&mut mesh, i, &p.ramp);
mesh.material = Some(0);
let world = Mat4::translate(pos)
.mul(&Mat4::rotate_x(PLANE_PITCH_DEG.to_radians()))
.mul(&Mat4::scale(Vec3::new(1.0, 1.0, 1.0)));
instances.push(MeshInstance {
data: mesh,
world,
source_node: None,
});
}
Model {
instances,
materials: vec![MaterialData::default()],
rig: None,
warnings: Vec::new(),
}
}
fn paint_ramp(mesh: &mut MeshData, plane: u32, ramp: &[Rgba; 5]) {
let half_w = PLANE_W * 0.5;
let colors = mesh
.positions
.iter()
.map(|p| {
let x_norm = ((p[0] + half_w) / PLANE_W).clamp(0.0, 1.0);
let ramp_t = (plane as f32 + x_norm) / 3.0;
let c = ramp_color(ramp, ramp_t);
[
srgb8_to_linear(c.r),
srgb8_to_linear(c.g),
srgb8_to_linear(c.b),
1.0,
]
})
.collect();
mesh.colors = Some(colors);
}
fn draw_burst(fb: &mut Framebuffer, ms: f32, p: &BrandmarkParams) {
let age = ms - p.burst_at_ms as f32;
if !(0.0..=p.burst_lifetime_ms as f32).contains(&age) {
return;
}
let life = age / p.burst_lifetime_ms as f32;
let (w, h) = (fb.width() as f32, fb.height() as f32);
let (ox, oy) = (w * 0.5, h * 0.42);
let n = p.burst_particles;
for i in 0..n {
let ang = i as f32 * 2.399_963 + 0.7;
let speed = 0.12 + 0.08 * ((i * 37 % 11) as f32 / 10.0);
let dist = life.sqrt() * speed * w;
let x = ox + ang.cos() * dist;
let y = oy + ang.sin() * dist * 0.6; let c = ramp_color(&p.ramp, i as f32 / (n - 1).max(1) as f32);
let fade = 1.0 - life;
let px = fb.bitmap_mut();
let (xi, yi) = (x as i64, y as i64);
if xi >= 0 && yi >= 0 && (xi as u32) < px.width() && (yi as u32) < px.height() {
let prev = px.get(xi as u32, yi as u32).unwrap_or(Rgba::TRANSPARENT);
px.set(xi as u32, yi as u32, mix(prev.over(Rgba::BLACK), c, fade));
}
}
}
fn merge_afterglow(
trail: &mut crate::gfx::Bitmap,
fb: &mut Framebuffer,
t: f32,
last_t: f32,
decay_per_100ms: f32,
) {
let dt_ms = ((t - last_t).max(0.0) * 1000.0).min(500.0);
let decay = decay_per_100ms.powf(dt_ms / 100.0);
let cur = fb.bitmap_mut();
for (tp, cp) in trail
.pixels_mut()
.iter_mut()
.zip(cur.pixels_mut().iter_mut())
{
let faded = Rgba::new(
(tp.r as f32 * decay) as u8,
(tp.g as f32 * decay) as u8,
(tp.b as f32 * decay) as u8,
(tp.a as f32 * decay) as u8,
);
let merged = Rgba::new(
faded.r.max(cp.r),
faded.g.max(cp.g),
faded.b.max(cp.b),
faded.a.max(cp.a),
);
*tp = merged;
*cp = merged;
}
}
#[inline]
fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
#[inline]
fn ease(bezier: [f32; 4], t: f32) -> f32 {
Easing::CubicBezier(bezier[0], bezier[1], bezier[2], bezier[3]).eval(t.clamp(0.0, 1.0))
}
#[inline]
fn mix(a: Rgba, b: Rgba, k: f32) -> Rgba {
a.lerp(b, k)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::default_theme;
#[test]
fn identity_drift_pin() {
use crate::boot::identity as id;
let p = BrandmarkParams::reference();
assert_eq!(p.align_start_ms, id::PHASE_ALIGN_START_MS);
assert_eq!(p.reveal_start_ms, id::PHASE_REVEAL_START_MS);
assert_eq!(p.hold_start_ms, id::PHASE_HOLD_START_MS);
assert_eq!(p.plane_stagger_ms, id::PLANE_STAGGER_MS);
assert_eq!(p.plane_arrival_ms, id::PLANE_ARRIVAL_MS);
assert_eq!(p.burst_at_ms, id::BURST_AT_MS);
assert_eq!(p.burst_particles, id::BURST_PARTICLES);
assert_eq!(p.burst_lifetime_ms, id::BURST_LIFETIME_MS);
assert_eq!(p.afterglow_decay_per_100ms, id::AFTERGLOW_DECAY_PER_100MS);
assert_eq!(p.ease_arrival, id::EASE_ARRIVAL);
assert_eq!(p.ease_settle, id::EASE_SETTLE);
assert_eq!(p.ease_tracking, id::EASE_TRACKING);
assert_eq!(p.ease_fade, id::EASE_FADE);
assert_eq!(p.camera_yaw_deg, id::CAMERA_YAW_DEG);
assert_eq!(p.camera_pitch_deg, id::CAMERA_PITCH_DEG);
assert_eq!(p.camera_dolly, id::CAMERA_DOLLY);
assert_eq!(p.ramp, id::BRAND_RAMP);
assert_eq!(p.field, id::BRAND_FIELD);
assert_eq!(p.wordmark, id::WORDMARK);
assert_eq!(p.tagline, id::TAGLINE);
assert_eq!(p.skip_hint, id::SKIP_HINT);
assert_eq!(p.wordmark_tracking, id::WORDMARK_TRACKING);
for k in [0.0f32, 0.2, 0.5, 0.77, 1.0] {
assert_eq!(ramp_color(&p.ramp, k), id::brand_ramp(k), "ramp at {k}");
}
}
fn coverage(surface: &Surface) -> usize {
let mut n = 0;
for y in 0..surface.size().h {
for x in 0..surface.size().w {
if let Some(c) = surface.get(x, y) {
if !c.bg.is_transparent() || !c.fg.is_transparent() {
n += 1;
}
}
}
}
n
}
#[test]
fn honors_requested_size_and_resizes() {
let theme = default_theme();
let mut r = BrandmarkRenderer::with_params(BrandmarkParams::reference());
let s = r.render(0.5, Size::new(100, 30), theme);
assert_eq!(s.size(), Size::new(100, 30));
let s = r.render(0.6, Size::new(80, 24), theme);
assert_eq!(s.size(), Size::new(80, 24));
let s = r.render(0.7, Size::new(0, 0), theme);
assert_eq!(s.size(), Size::new(0, 0)); }
#[test]
fn storyboard_beats_appear() {
let theme = default_theme();
let mut r = BrandmarkRenderer::with_params(BrandmarkParams::reference());
let s = r.render(0.0, Size::new(100, 30), theme);
assert!(coverage(s) > 0, "vignette paints the ground");
let mut r2 = BrandmarkRenderer::with_params(BrandmarkParams::reference());
let s = r2.render(1.0, Size::new(100, 30), theme);
let mark_cells = mark_cell_count(s, theme);
assert!(
mark_cells > 30,
"mark visible at t=1.0 ({mark_cells} cells)"
);
let mut r3 = BrandmarkRenderer::with_params(BrandmarkParams::reference());
let s = r3.render(1.9, Size::new(100, 30), theme);
let row: String = row_text(s, s.size().h - 3);
assert!(row.contains('A'), "wordmark visible: {row:?}");
let hint_row = row_text(s, s.size().h - 1);
assert!(hint_row.contains("skip"), "skip hint: {hint_row:?}");
}
#[test]
fn deterministic_frames_fresh_renderers() {
let theme = default_theme();
let mut a = BrandmarkRenderer::with_params(BrandmarkParams::reference());
let mut b = BrandmarkRenderer::with_params(BrandmarkParams::reference());
let (sa, sb) = (
frame_dump(a.render(1.2, Size::new(60, 20), theme)),
frame_dump(b.render(1.2, Size::new(60, 20), theme)),
);
assert_eq!(sa, sb);
}
#[test]
fn camera_sweep_changes_the_frame() {
let theme = default_theme();
let mut a = BrandmarkRenderer::with_params(BrandmarkParams::reference());
let mut b = BrandmarkRenderer::with_params(BrandmarkParams::reference());
let early = frame_dump(a.render(0.5, Size::new(60, 20), theme));
let late = frame_dump(b.render(1.3, Size::new(60, 20), theme));
assert_ne!(early, late);
}
#[test]
#[ignore = "perf budget; run explicitly in release"]
fn perf_brandmark_100x30() {
let theme = default_theme();
let mut r = BrandmarkRenderer::with_params(BrandmarkParams::reference());
let m = crate::testing::bench::time_median("brandmark_100x30", 3, 5, 20, |i| {
let t = (i % 60) as f32 / 30.0;
let s = r.render(t, Size::new(100, 30), theme);
crate::testing::bench::sink(s.size());
});
eprintln!("{}", m.report());
m.assert_under(std::time::Duration::from_millis(8));
}
fn row_text(s: &Surface, y: i32) -> String {
(0..s.size().w)
.map(|x| {
s.get(x, y)
.and_then(|c| s.glyph_str(c).chars().next())
.unwrap_or(' ')
})
.collect()
}
fn mark_cell_count(s: &Surface, theme: &Theme) -> usize {
let bg = theme.tokens.bg;
let mut n = 0;
for y in 0..s.size().h {
for x in 0..s.size().w {
if let Some(c) = s.get(x, y) {
let d = (c.bg.r as i32 - bg.r as i32).abs()
+ (c.bg.g as i32 - bg.g as i32).abs()
+ (c.bg.b as i32 - bg.b as i32).abs();
if d > 120 {
n += 1;
}
}
}
}
n
}
fn frame_dump(s: &Surface) -> Vec<(String, Rgba, Rgba)> {
let mut out = Vec::new();
for y in 0..s.size().h {
for x in 0..s.size().w {
let c = s.get(x, y).unwrap();
out.push((s.glyph_str(c).to_string(), c.fg, c.bg));
}
}
out
}
}