feat(lapaloma-phosphor): trail CRT con alpha decay + glow
- lapaloma-phosphor: feature `gpui` (default). LapalomaPhosphorElement divide el RingBuffer en N segmentos (default 16, configurable) y pinta cada uno como una stroke_polyline con alpha = (k+1)/N. El segmento más nuevo va con alpha 1.0, el más viejo casi transparente — efecto fósforo persistente. - Cada segmento incluye el primer punto del siguiente para evitar gaps visibles entre tramos. - Wraparound se parte en dos sub-polilíneas (no concatenadas) para no introducir la línea horizontal "del slot cap-1 al slot 0". - Glow opcional: pasada adicional con width × glow_width_mult y alpha × glow_alpha — efecto halo CRT. - crates/apps/lapaloma-phosphor-demo: misma señal sintética que stream-demo, paleta verde Tektronix (#9bff8c sobre #050805), trail 24 segs + glow 4× α 0.18. Limitación v0.1: el doc canónico usa triangle strip con per-vertex color (sección 4.3); GPUI 0.2 no expone esa API directa. La impl actual es funcionalmente equivalente con N draw calls en lugar de 1. Cuando wgpu directo esté disponible, swap inmediato sin tocar las API públicas. 46 tests verdes (sin cambios; phosphor se valida via demo). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "lapaloma-phosphor-demo"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
description = "Lapaloma — demo del trail CRT (phosphor) sobre un RingBuffer streaming a 60Hz. Compará con lapaloma-stream-demo para ver el contraste."
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
yahweh-launcher = { path = "../../modules/ui_engine/libs/launcher" }
|
||||
yahweh-theme = { path = "../../modules/ui_engine/libs/theme" }
|
||||
lapaloma-core = { path = "../../modules/ui_engine/libs/lapaloma-core" }
|
||||
lapaloma-render = { path = "../../modules/ui_engine/widgets/lapaloma-render", features = ["gpui"] }
|
||||
lapaloma-phosphor = { path = "../../modules/ui_engine/widgets/lapaloma-phosphor" }
|
||||
@@ -0,0 +1,116 @@
|
||||
//! `lapaloma-phosphor-demo` — osciloscopio con trail CRT.
|
||||
//!
|
||||
//! Igual setup que `lapaloma-stream-demo` (RingBuffer 512 +
|
||||
//! timer 60 Hz) pero el render usa `LapalomaPhosphorElement`:
|
||||
//! el trail decae en alpha del cursor hacia atrás y arrastra un
|
||||
//! halo (glow). Visualmente queda como un osciloscopio analógico
|
||||
//! con fósforo persistente.
|
||||
//!
|
||||
//! Sliders para `trail_segments` y `glow` se dejan para más
|
||||
//! adelante; este demo usa los defaults.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{div, prelude::*, px, Context, IntoElement, Render, Window};
|
||||
|
||||
use lapaloma_core::ring::RingBuffer;
|
||||
use lapaloma_phosphor::lapaloma_phosphor;
|
||||
use lapaloma_render::{Color, StrokeStyle};
|
||||
use yahweh_launcher::launch_app;
|
||||
use yahweh_theme::Theme;
|
||||
|
||||
const RING_CAPACITY: usize = 512;
|
||||
const SAMPLE_PERIOD: Duration = Duration::from_millis(16);
|
||||
|
||||
fn main() {
|
||||
launch_app(
|
||||
"Lapaloma — phosphor trail (CRT 60 Hz)",
|
||||
(900., 480.),
|
||||
PhosphorDemo::new,
|
||||
);
|
||||
}
|
||||
|
||||
struct PhosphorDemo {
|
||||
buffer: RingBuffer,
|
||||
t: u64,
|
||||
}
|
||||
|
||||
impl PhosphorDemo {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let timer = cx.background_executor().clone();
|
||||
loop {
|
||||
timer.timer(SAMPLE_PERIOD).await;
|
||||
let r = this.update(cx, |me, cx| {
|
||||
me.tick();
|
||||
cx.notify();
|
||||
});
|
||||
if r.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
buffer: RingBuffer::new(RING_CAPACITY),
|
||||
t: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&mut self) {
|
||||
let v = synthesize(self.t);
|
||||
self.buffer.push(v);
|
||||
self.t = self.t.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn synthesize(t: u64) -> f32 {
|
||||
let phase = t as f32;
|
||||
let signal = (phase * 0.07).sin() * 0.75 + (phase * 0.19).sin() * 0.22;
|
||||
let jitter = ((phase * 37.0).sin() * 1000.0).fract() * 0.04;
|
||||
signal + jitter
|
||||
}
|
||||
|
||||
impl Render for PhosphorDemo {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
|
||||
// Verde fósforo CRT clásico (Tektronix vibes).
|
||||
let plot_bg = Color::rgba(0.03, 0.05, 0.04, 1.0);
|
||||
let trace = StrokeStyle::new(1.6, Color::from_hex(0x9bff8c));
|
||||
|
||||
let phosphor = lapaloma_phosphor(self.buffer.clone(), trace)
|
||||
.background(plot_bg)
|
||||
.y_range(-1.2, 1.2)
|
||||
.trail_segments(24)
|
||||
.glow(4.0, 0.18);
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.bg(theme.bg_app.clone())
|
||||
.p(px(16.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(10.))
|
||||
.child(
|
||||
div()
|
||||
.text_color(theme.fg_text)
|
||||
.text_size(px(18.))
|
||||
.child("Lapaloma — phosphor"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap(px(16.))
|
||||
.text_size(px(11.))
|
||||
.text_color(theme.fg_muted)
|
||||
.child(format!("cap = {}", RING_CAPACITY))
|
||||
.child(format!("head = {}", self.buffer.head()))
|
||||
.child("trail = 24 segs")
|
||||
.child("glow = 4× / α 0.18")
|
||||
.child(format!("t = {}", self.t)),
|
||||
)
|
||||
.child(div().w_full().flex_grow().child(phosphor))
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,8 @@ description = "Lapaloma — decoración CRT sobre lapaloma-stream: trail con alp
|
||||
[dependencies]
|
||||
lapaloma-core = { path = "../../libs/lapaloma-core" }
|
||||
lapaloma-render = { path = "../lapaloma-render" }
|
||||
lapaloma-stream = { path = "../lapaloma-stream" }
|
||||
gpui = { workspace = true }
|
||||
gpui = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["gpui"]
|
||||
gpui = ["dep:gpui", "lapaloma-render/gpui"]
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! `LapalomaPhosphorElement` — Element GPUI con trail CRT.
|
||||
//!
|
||||
//! El render pinta el RingBuffer como N segmentos polilíneas con
|
||||
//! alpha decreciente del más nuevo al más viejo. Wraparound se
|
||||
//! parte en dos sub-polilíneas para no introducir la línea
|
||||
//! horizontal "del slot cap-1 al slot 0".
|
||||
|
||||
use std::panic;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
|
||||
Pixels, Style, Window,
|
||||
};
|
||||
|
||||
use lapaloma_core::ring::RingBuffer;
|
||||
use lapaloma_render::{Canvas, Color, Rect, StrokeStyle, WindowCanvas};
|
||||
|
||||
/// Cantidad de tramos del trail. Más tramos = gradiente más suave,
|
||||
/// más draw calls. 16 cubre la mayoría de los casos sin ser caro.
|
||||
const DEFAULT_TRAIL_SEGMENTS: usize = 16;
|
||||
|
||||
pub struct LapalomaPhosphorElement {
|
||||
pub buffer: RingBuffer,
|
||||
/// Color y ancho base. El alpha se modula por tramo;
|
||||
/// `base_stroke.color.a` es el alpha máximo (cabeza del trail).
|
||||
pub base_stroke: StrokeStyle,
|
||||
pub background: Option<Color>,
|
||||
pub y_min: f32,
|
||||
pub y_max: f32,
|
||||
pub padding: f32,
|
||||
pub trail_segments: usize,
|
||||
/// Si > 0, se aplica una pasada adicional con `width × glow_width_mult`
|
||||
/// y `alpha × glow_alpha` debajo del trazo principal — efecto halo CRT.
|
||||
pub glow_width_mult: f32,
|
||||
pub glow_alpha: f32,
|
||||
scratch: Vec<f32>,
|
||||
}
|
||||
|
||||
impl LapalomaPhosphorElement {
|
||||
pub fn new(buffer: RingBuffer, base_stroke: StrokeStyle) -> Self {
|
||||
Self {
|
||||
buffer,
|
||||
base_stroke,
|
||||
background: None,
|
||||
y_min: -1.0,
|
||||
y_max: 1.0,
|
||||
padding: 8.0,
|
||||
trail_segments: DEFAULT_TRAIL_SEGMENTS,
|
||||
glow_width_mult: 3.0,
|
||||
glow_alpha: 0.25,
|
||||
scratch: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn background(mut self, color: Color) -> Self {
|
||||
self.background = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn y_range(mut self, min: f32, max: f32) -> Self {
|
||||
debug_assert!(max > min);
|
||||
self.y_min = min;
|
||||
self.y_max = max;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn trail_segments(mut self, n: usize) -> Self {
|
||||
self.trail_segments = n.max(2);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn glow(mut self, width_mult: f32, alpha: f32) -> Self {
|
||||
self.glow_width_mult = width_mult;
|
||||
self.glow_alpha = alpha;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn no_glow(mut self) -> Self {
|
||||
self.glow_width_mult = 0.0;
|
||||
self.glow_alpha = 0.0;
|
||||
self
|
||||
}
|
||||
|
||||
fn plot_rect(&self, bounds: Rect) -> Rect {
|
||||
Rect::new(
|
||||
bounds.x + self.padding,
|
||||
bounds.y + self.padding,
|
||||
(bounds.w - self.padding * 2.0).max(1.0),
|
||||
(bounds.h - self.padding * 2.0).max(1.0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for LapalomaPhosphorElement {
|
||||
type Element = Self;
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for LapalomaPhosphorElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
fn source_location(&self) -> Option<&'static panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
|
||||
style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
|
||||
let id = window.request_layout(style, [], cx);
|
||||
(id, ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) {
|
||||
let ox: f32 = bounds.origin.x.into();
|
||||
let oy: f32 = bounds.origin.y.into();
|
||||
let w: f32 = bounds.size.width.into();
|
||||
let h: f32 = bounds.size.height.into();
|
||||
let outer = Rect::new(ox, oy, w, h);
|
||||
let plot = self.plot_rect(outer);
|
||||
|
||||
let mut canvas = WindowCanvas::new(window);
|
||||
|
||||
if let Some(bg) = self.background {
|
||||
canvas.fill_rect(outer, bg);
|
||||
}
|
||||
|
||||
let filled = self.buffer.filled_len();
|
||||
if filled < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let cap = self.buffer.capacity();
|
||||
let head = self.buffer.head();
|
||||
let coords = self.buffer.coords();
|
||||
|
||||
// El slot del sample temporalmente más viejo:
|
||||
// - is_full → `head` (el siguiente a sobrescribirse).
|
||||
// - !is_full → 0.
|
||||
let start_slot = if self.buffer.is_full() { head } else { 0 };
|
||||
|
||||
let n_segs = self.trail_segments.min(filled / 2).max(2);
|
||||
let base_per_seg = filled / n_segs;
|
||||
let glow_enabled = self.glow_alpha > 0.0 && self.glow_width_mult > 1.0;
|
||||
|
||||
for k in 0..n_segs {
|
||||
// Rango temporal del segmento. El segmento `k` cubre los
|
||||
// samples [k*base_per_seg, (k+1)*base_per_seg). El último
|
||||
// incluye el remainder.
|
||||
let t_lo = k * base_per_seg;
|
||||
let t_hi = if k == n_segs - 1 {
|
||||
filled
|
||||
} else {
|
||||
// +1 incluye el primer sample del siguiente segmento
|
||||
// para que las polilíneas se "toquen" sin gap visual.
|
||||
((k + 1) * base_per_seg) + 1
|
||||
};
|
||||
if t_hi <= t_lo + 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Alpha decrece linealmente del más nuevo al más viejo.
|
||||
// k = n_segs - 1 → 1.0; k = 0 → 1/n_segs.
|
||||
let life = (k as f32 + 1.0) / n_segs as f32;
|
||||
let alpha = self.base_stroke.color.a * life;
|
||||
let mut color = self.base_stroke.color;
|
||||
color.a = alpha;
|
||||
let stroke = StrokeStyle::new(self.base_stroke.width, color);
|
||||
|
||||
// Glow underneath, mismo path con más ancho y menos alpha.
|
||||
let glow_stroke = if glow_enabled {
|
||||
let mut gc = color;
|
||||
gc.a *= self.glow_alpha;
|
||||
Some(StrokeStyle::new(
|
||||
self.base_stroke.width * self.glow_width_mult,
|
||||
gc,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Proyectar el rango temporal a slots físicos, partiendo
|
||||
// si cruzamos el final del buffer.
|
||||
let seg_len = t_hi - t_lo;
|
||||
let abs_start = (start_slot + t_lo) % cap;
|
||||
let contiguous_len = cap - abs_start;
|
||||
|
||||
if seg_len <= contiguous_len {
|
||||
let slice = &coords[abs_start * 2..(abs_start + seg_len) * 2];
|
||||
self.scratch.clear();
|
||||
project_segment(slice, plot, self.y_min, self.y_max, &mut self.scratch);
|
||||
if self.scratch.len() >= 4 {
|
||||
if let Some(gs) = glow_stroke {
|
||||
canvas.stroke_polyline(&self.scratch, gs);
|
||||
}
|
||||
canvas.stroke_polyline(&self.scratch, stroke);
|
||||
}
|
||||
} else {
|
||||
// Wraparound: dos sub-polilíneas separadas.
|
||||
let slice_a = &coords[abs_start * 2..];
|
||||
self.scratch.clear();
|
||||
project_segment(slice_a, plot, self.y_min, self.y_max, &mut self.scratch);
|
||||
if self.scratch.len() >= 4 {
|
||||
if let Some(gs) = glow_stroke {
|
||||
canvas.stroke_polyline(&self.scratch, gs);
|
||||
}
|
||||
canvas.stroke_polyline(&self.scratch, stroke);
|
||||
}
|
||||
|
||||
let remaining = seg_len - contiguous_len;
|
||||
let slice_b = &coords[..remaining * 2];
|
||||
self.scratch.clear();
|
||||
project_segment(slice_b, plot, self.y_min, self.y_max, &mut self.scratch);
|
||||
if self.scratch.len() >= 4 {
|
||||
if let Some(gs) = glow_stroke {
|
||||
canvas.stroke_polyline(&self.scratch, gs);
|
||||
}
|
||||
canvas.stroke_polyline(&self.scratch, stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper builder.
|
||||
pub fn lapaloma_phosphor(
|
||||
buffer: RingBuffer,
|
||||
base_stroke: StrokeStyle,
|
||||
) -> LapalomaPhosphorElement {
|
||||
LapalomaPhosphorElement::new(buffer, base_stroke)
|
||||
}
|
||||
|
||||
/// Proyecta `[x_norm, y_value, …]` del ring a píxeles del plot.
|
||||
fn project_segment(segment: &[f32], plot: Rect, y_min: f32, y_max: f32, out: &mut Vec<f32>) {
|
||||
let y_span = y_max - y_min;
|
||||
if y_span.abs() < 1e-9 {
|
||||
return;
|
||||
}
|
||||
let inv = 1.0 / y_span;
|
||||
for chunk in segment.chunks_exact(2) {
|
||||
let xn = chunk[0];
|
||||
let yv = chunk[1];
|
||||
let py_norm = (yv - y_min) * inv;
|
||||
out.push(plot.x + xn * plot.w);
|
||||
out.push(plot.bottom() - py_norm * plot.h);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,33 @@
|
||||
//! `lapaloma-phosphor` — decoración CRT sobre `lapaloma-stream`.
|
||||
//! `lapaloma-phosphor` — decoración CRT sobre `lapaloma_core::RingBuffer`.
|
||||
//!
|
||||
//! Three pieces (sección 4.3):
|
||||
//! - **`trail`** — cada sample como 2 vértices ±half_width, triangle
|
||||
//! strip con color por vértice; alpha = 1 - age/trail_samples.
|
||||
//! - **`ghost`** — render con offset/blur del trail anterior.
|
||||
//! - **`magnetic_anchor`** — anotaciones ancladas a sample index
|
||||
//! absoluto, no a screen pos (sección 5.5).
|
||||
//! El "real" oscilloscope-trail effect de la sección 4.3 del
|
||||
//! ARCHITECTURE.md renderea cada sample como **2 vértices**
|
||||
//! (top y bottom, offset ±half_width) atados a un triangle strip
|
||||
//! con per-vertex color. GPUI 0.2 no expone triangle strips con
|
||||
//! atributos de vértice de forma directa.
|
||||
//!
|
||||
//! v0.1 implementa el efecto con un approach distinto pero
|
||||
//! visualmente similar: el trail se divide en N **segmentos**
|
||||
//! consecutivos del ring, cada uno se pinta como una `stroke_polyline`
|
||||
//! con alpha decreciente del más nuevo (1.0) al más viejo (≈ 0).
|
||||
//! Cada segmento incluye el primer sample del siguiente para no
|
||||
//! dejar gaps visibles entre tramos.
|
||||
//!
|
||||
//! Coste: N draw calls por frame en lugar de 2 del stream simple.
|
||||
//! Para N = 16 y ring cap = 512 son sub-millisecond en cualquier
|
||||
//! laptop moderna.
|
||||
//!
|
||||
//! Cuando GPUI/wgpu expongan triangle strip + per-vertex color,
|
||||
//! la siguiente fase reemplaza esta impl por la canónica.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod trail {}
|
||||
pub mod ghost {}
|
||||
pub mod magnetic_anchor {}
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub mod element;
|
||||
|
||||
#[cfg(feature = "gpui")]
|
||||
pub use element::{lapaloma_phosphor, LapalomaPhosphorElement};
|
||||
|
||||
Reference in New Issue
Block a user