This commit is contained in:
sergio
2026-05-12 18:55:29 +00:00
parent 6596c81271
commit 52acaabcf4
23 changed files with 1774 additions and 0 deletions
@@ -0,0 +1,32 @@
[package]
name = "gioser-canvas-web"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
gioser-geom = { path = "../gioser-geom" }
gioser-physics = { path = "../gioser-physics" }
gioser-palette = { path = "../gioser-palette" }
gioser-shaders = { path = "../gioser-shaders" }
wasm-bindgen.workspace = true
js-sys.workspace = true
glam.workspace = true
[dependencies.web-sys]
workspace = true
features = [
"Window",
"Document",
"HtmlCanvasElement",
"WebGl2RenderingContext",
"WebGlShader",
"WebGlProgram",
"WebGlBuffer",
"WebGlUniformLocation",
"WebGlVertexArrayObject",
"console",
]
@@ -0,0 +1,335 @@
//! Renderer WebGL2 que compone geometría + física + paleta + shaders en pantalla.
//!
//! Es agnóstico del DOM: el caller monta el `<canvas>`, pasa eventos de mouse,
//! y llama `render(time_ms)` desde un `requestAnimationFrame`.
//!
//! ```ignore
//! let mut r = Renderer::new(&canvas)?;
//! r.resize(w, h);
//! // por cada mousemove:
//! r.set_mouse_px(dx, dy);
//! // por cada frame:
//! r.render(time_ms);
//! ```
//!
//! El layout sigue el patrón de `yahweh_launcher::launch_app(title, size, factory)`:
//! una sola línea para arrancar, escena auto-contenida. Cuando exista `yahweh-web`,
//! este renderer es el "factory" que recibirá.
use gioser_geom::ChacanaSpec;
use gioser_palette::{cosmos, Rgb};
use gioser_physics::SpringDamper2;
use gioser_shaders::{
chacana_quad, FS_CHACANA, FS_COSMOS, FULLSCREEN_QUAD, VS_CHACANA, VS_FULLSCREEN,
};
use glam::{Mat4, Vec3, Vec4};
use std::collections::HashMap;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{
HtmlCanvasElement, WebGl2RenderingContext as GL, WebGlProgram, WebGlShader,
WebGlUniformLocation, WebGlVertexArrayObject,
};
const RAD: f32 = core::f32::consts::PI / 180.0;
const MAX_TILT_DEG: f32 = 22.0;
/// Re-export para apps: identidad (id, color, label) de cada punta cardinal,
/// en el orden `[N, E, S, W]` de `ChacanaSpec::tips()`.
pub mod tips {
use gioser_palette::{elements, Rgb};
pub const ORDER: [(&str, Rgb, &str); 4] = [
("aire", elements::AIRE, "AIRE"), // N
("fuego", elements::FUEGO, "FUEGO"), // E
("tierra", elements::TIERRA, "TIERRA"), // S
("agua", elements::AGUA, "AGUA"), // W
];
}
pub struct Renderer {
gl: GL,
cosmos_prog: Program,
chacana_prog: Program,
cosmos_vao: WebGlVertexArrayObject,
chacana_vao: WebGlVertexArrayObject,
chacana_quad_count: i32,
chacana: ChacanaSpec,
tilt: SpringDamper2,
sun_pulse: f32,
last_time_ms: f64,
viewport: (u32, u32),
/// Mouse en clip-space, x ∈ [-aspect, aspect], y ∈ [-1, 1].
mouse: (f32, f32),
}
struct Program {
program: WebGlProgram,
uniforms: HashMap<&'static str, WebGlUniformLocation>,
}
impl Program {
fn new(gl: &GL, vs: &str, fs: &str, names: &[&'static str]) -> Result<Self, String> {
let vs = compile_shader(gl, GL::VERTEX_SHADER, vs)?;
let fs = compile_shader(gl, GL::FRAGMENT_SHADER, fs)?;
let program = gl.create_program().ok_or("create_program failed")?;
gl.attach_shader(&program, &vs);
gl.attach_shader(&program, &fs);
// Atributo `a_pos` siempre en location 0.
gl.bind_attrib_location(&program, 0, "a_pos");
gl.link_program(&program);
let linked = gl
.get_program_parameter(&program, GL::LINK_STATUS)
.as_bool()
.unwrap_or(false);
if !linked {
return Err(gl
.get_program_info_log(&program)
.unwrap_or_else(|| "link failed".into()));
}
let mut uniforms = HashMap::new();
for n in names {
if let Some(loc) = gl.get_uniform_location(&program, n) {
uniforms.insert(*n, loc);
}
}
Ok(Self { program, uniforms })
}
fn u(&self, name: &'static str) -> Option<&WebGlUniformLocation> {
self.uniforms.get(name)
}
}
fn compile_shader(gl: &GL, ty: u32, src: &str) -> Result<WebGlShader, String> {
let s = gl.create_shader(ty).ok_or("create_shader failed")?;
gl.shader_source(&s, src);
gl.compile_shader(&s);
let ok = gl
.get_shader_parameter(&s, GL::COMPILE_STATUS)
.as_bool()
.unwrap_or(false);
if !ok {
return Err(gl
.get_shader_info_log(&s)
.unwrap_or_else(|| "compile failed".into()));
}
Ok(s)
}
fn upload_quad(
gl: &GL,
verts: &[f32],
attr_loc: u32,
) -> Result<(WebGlVertexArrayObject, i32), String> {
let vao = gl
.create_vertex_array()
.ok_or("create_vertex_array failed")?;
gl.bind_vertex_array(Some(&vao));
let buf = gl.create_buffer().ok_or("create_buffer failed")?;
gl.bind_buffer(GL::ARRAY_BUFFER, Some(&buf));
// SAFETY: `Float32Array::view` apunta directamente a la memoria del WASM linear,
// que no podemos mover durante este scope (sin allocs intermedias).
unsafe {
let view = js_sys::Float32Array::view(verts);
gl.buffer_data_with_array_buffer_view(GL::ARRAY_BUFFER, &view, GL::STATIC_DRAW);
}
gl.vertex_attrib_pointer_with_i32(attr_loc, 2, GL::FLOAT, false, 0, 0);
gl.enable_vertex_attrib_array(attr_loc);
gl.bind_vertex_array(None);
Ok((vao, (verts.len() / 2) as i32))
}
impl Renderer {
pub fn new(canvas: &HtmlCanvasElement) -> Result<Self, JsValue> {
let gl = canvas
.get_context("webgl2")?
.ok_or_else(|| JsValue::from_str("WebGL2 no soportado"))?
.dyn_into::<GL>()?;
let chacana = ChacanaSpec::CLASSIC;
let cosmos_prog = Program::new(
&gl,
VS_FULLSCREEN,
FS_COSMOS,
&[
"u_resolution",
"u_time",
"u_parallax",
"u_void",
"u_nebula_a",
"u_nebula_b",
"u_stardust",
],
)
.map_err(JsValue::from)?;
let chacana_prog = Program::new(
&gl,
VS_CHACANA,
FS_CHACANA,
&[
"u_mvp",
"u_time",
"u_thickness",
"u_arm_extent",
"u_line_color",
"u_rim_color",
"u_sun_color",
"u_sun_pulse",
],
)
.map_err(JsValue::from)?;
let (cosmos_vao, _) = upload_quad(&gl, &FULLSCREEN_QUAD, 0).map_err(JsValue::from)?;
let chacana_quad_verts = chacana_quad(chacana.arm_extent);
let (chacana_vao, chacana_quad_count) =
upload_quad(&gl, &chacana_quad_verts, 0).map_err(JsValue::from)?;
let tilt = SpringDamper2::new(2.2, 0.72);
Ok(Self {
gl,
cosmos_prog,
chacana_prog,
cosmos_vao,
chacana_vao,
chacana_quad_count,
chacana,
tilt,
sun_pulse: 0.0,
last_time_ms: 0.0,
viewport: (canvas.width().max(1), canvas.height().max(1)),
mouse: (0.0, 0.0),
})
}
pub fn resize(&mut self, w: u32, h: u32) {
self.viewport = (w.max(1), h.max(1));
self.gl
.viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
}
/// Recibe coords del mouse en pixeles, origen en el centro del canvas
/// (+x derecha, +y arriba). Setea el target de inclinación.
pub fn set_mouse_px(&mut self, x: f32, y: f32) {
let (w, h) = self.viewport;
if h == 0 {
return;
}
let aspect = w as f32 / h as f32;
let half_h = h as f32 * 0.5;
let mx = (x / half_h).clamp(-aspect, aspect);
let my = (y / half_h).clamp(-1.0, 1.0);
self.mouse = (mx, my);
let max_tilt = MAX_TILT_DEG * RAD;
// Pitch (rot X): mouse arriba → top inclina hacia el viewer (+rotX).
// Yaw (rot Y): mouse derecha → right inclina hacia el viewer (-rotY).
let target = [my * max_tilt, -mx * max_tilt / aspect];
self.tilt.set_target(target);
}
/// Posición proyectada (NDC `[-1, 1]²`) de cada tip cardinal en el orden N/E/S/W.
/// El caller la usa para anclar el DOM.
pub fn tips_ndc(&self) -> [(f32, f32); 4] {
let mvp = self.build_mvp();
let mut out = [(0.0_f32, 0.0_f32); 4];
for (i, t) in self.chacana.tips().iter().enumerate() {
let p = mvp * Vec4::new(t.0, t.1, 0.0, 1.0);
let w = if p.w == 0.0 { 1.0 } else { p.w };
out[i] = (p.x / w, p.y / w);
}
out
}
pub fn chacana(&self) -> &ChacanaSpec {
&self.chacana
}
fn build_mvp(&self) -> Mat4 {
let (w, h) = self.viewport;
let aspect = w as f32 / h as f32;
let proj = Mat4::perspective_rh(45.0_f32.to_radians(), aspect, 0.1, 20.0);
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 2.6), Vec3::ZERO, Vec3::Y);
let pitch = Mat4::from_rotation_x(self.tilt.position[0]);
let yaw = Mat4::from_rotation_y(self.tilt.position[1]);
let scale = Mat4::from_scale(Vec3::splat(0.92));
proj * view * yaw * pitch * scale
}
pub fn render(&mut self, time_ms: f64) {
let dt = if self.last_time_ms == 0.0 {
1.0 / 60.0
} else {
((time_ms - self.last_time_ms) as f32 / 1000.0).clamp(0.0, 1.0 / 15.0)
};
self.last_time_ms = time_ms;
// Subdividimos la física para mantener estabilidad con dt grandes.
let sub = 4;
let sub_dt = dt / sub as f32;
for _ in 0..sub {
self.tilt.step(sub_dt);
}
let t = time_ms as f32 * 0.001;
self.sun_pulse = 0.5 + 0.5 * (t * 1.4).sin();
let gl = &self.gl;
gl.viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
gl.disable(GL::DEPTH_TEST);
gl.enable(GL::BLEND);
gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA);
gl.clear_color(0.02, 0.015, 0.04, 1.0);
gl.clear(GL::COLOR_BUFFER_BIT);
// ---- Cosmos ----
gl.use_program(Some(&self.cosmos_prog.program));
if let Some(u) = self.cosmos_prog.u("u_resolution") {
gl.uniform2f(Some(u), self.viewport.0 as f32, self.viewport.1 as f32);
}
if let Some(u) = self.cosmos_prog.u("u_time") {
gl.uniform1f(Some(u), t);
}
if let Some(u) = self.cosmos_prog.u("u_parallax") {
gl.uniform2f(Some(u), self.mouse.0, self.mouse.1);
}
upload_rgb(gl, self.cosmos_prog.u("u_void"), cosmos::VOID);
upload_rgb(gl, self.cosmos_prog.u("u_nebula_a"), cosmos::NEBULA_A);
upload_rgb(gl, self.cosmos_prog.u("u_nebula_b"), cosmos::NEBULA_B);
upload_rgb(gl, self.cosmos_prog.u("u_stardust"), cosmos::STARDUST);
gl.bind_vertex_array(Some(&self.cosmos_vao));
gl.draw_arrays(GL::TRIANGLES, 0, 6);
// ---- Chacana (blend aditivo para que el glow sume luz) ----
gl.blend_func(GL::SRC_ALPHA, GL::ONE);
gl.use_program(Some(&self.chacana_prog.program));
let mvp = self.build_mvp();
if let Some(u) = self.chacana_prog.u("u_mvp") {
gl.uniform_matrix4fv_with_f32_array(Some(u), false, &mvp.to_cols_array());
}
if let Some(u) = self.chacana_prog.u("u_time") {
gl.uniform1f(Some(u), t);
}
if let Some(u) = self.chacana_prog.u("u_thickness") {
gl.uniform1f(Some(u), self.chacana.thickness);
}
if let Some(u) = self.chacana_prog.u("u_arm_extent") {
gl.uniform1f(Some(u), self.chacana.arm_extent);
}
upload_rgb(gl, self.chacana_prog.u("u_line_color"), cosmos::CHACANA_LINE);
upload_rgb(gl, self.chacana_prog.u("u_rim_color"), cosmos::CHACANA_RIM);
upload_rgb(gl, self.chacana_prog.u("u_sun_color"), cosmos::SUN_CORE);
if let Some(u) = self.chacana_prog.u("u_sun_pulse") {
gl.uniform1f(Some(u), self.sun_pulse);
}
gl.bind_vertex_array(Some(&self.chacana_vao));
gl.draw_arrays(GL::TRIANGLES, 0, self.chacana_quad_count);
gl.bind_vertex_array(None);
}
}
fn upload_rgb(gl: &GL, loc: Option<&WebGlUniformLocation>, c: Rgb) {
if let Some(u) = loc {
gl.uniform3f(Some(u), c.0, c.1, c.2);
}
}
@@ -0,0 +1,8 @@
[package]
name = "gioser-geom"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
@@ -0,0 +1,159 @@
//! Geometría de la chacana andina (cruz cuadrada escalonada).
//!
//! Genera un polígono cerrado de 20 vértices: un cuadrado central,
//! cuatro escalones (uno por brazo cardinal) y cuatro puntas que
//! extienden hasta `arm_extent`.
//!
//! Convención: plano XY, centro en `(0, 0)`, +Y hacia el norte,
//! +X hacia el este. Toda la API es pura: ningún I/O, ninguna asignación
//! global; apta para ejecutar dentro de un shader-host, en un test,
//! o en una integración nativa.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ChacanaSpec {
/// Distancia desde el centro hasta la punta del brazo.
pub arm_extent: f32,
/// Semi-grosor del brazo. El escalón mide `2 * thickness`.
pub thickness: f32,
}
impl ChacanaSpec {
/// Configuración canónica: brazo 1.0, grosor 0.18 (proporciones del logo).
pub const CLASSIC: Self = Self {
arm_extent: 1.0,
thickness: 0.18,
};
pub const fn new(arm_extent: f32, thickness: f32) -> Self {
Self {
arm_extent,
thickness,
}
}
/// Las cuatro puntas cardinales en orden `[N, E, S, W]`.
/// Coordenadas listas para anclar UI sobre la chacana.
pub fn tips(&self) -> [(f32, f32); 4] {
let l = self.arm_extent;
[(0.0, l), (l, 0.0), (0.0, -l), (-l, 0.0)]
}
/// Bounding box axis-aligned `(min, max)`.
pub fn aabb(&self) -> ((f32, f32), (f32, f32)) {
let l = self.arm_extent;
((-l, -l), (l, l))
}
/// Perímetro cerrado en orden horario: 20 vértices, listo para `LINE_LOOP`.
pub fn perimeter(&self) -> Vec<(f32, f32)> {
let s = self.thickness;
let l = self.arm_extent;
let s2 = s * 2.0;
vec![
(s, l),
(s, s2),
(s2, s2),
(s2, s),
(l, s),
(l, -s),
(s2, -s),
(s2, -s2),
(s, -s2),
(s, -l),
(-s, -l),
(-s, -s2),
(-s2, -s2),
(-s2, -s),
(-l, -s),
(-l, s),
(-s2, s),
(-s2, s2),
(-s, s2),
(-s, l),
]
}
/// Triangulación: 9 rectángulos (1 centro + 4 escalones + 4 puntas) = 54 vértices.
/// Listo para `GL_TRIANGLES`.
pub fn triangles(&self) -> Vec<(f32, f32)> {
let s = self.thickness;
let l = self.arm_extent;
let s2 = s * 2.0;
let mut tri = Vec::with_capacity(9 * 6);
let mut rect = |x0: f32, y0: f32, x1: f32, y1: f32| {
tri.push((x0, y0));
tri.push((x1, y0));
tri.push((x1, y1));
tri.push((x0, y0));
tri.push((x1, y1));
tri.push((x0, y1));
};
// Cuadrado central
rect(-s, -s, s, s);
// Escalones (un rect 4s × s por brazo)
rect(-s2, s, s2, s2); // N
rect(-s2, -s2, s2, -s); // S
rect(s, -s2, s2, s2); // E
rect(-s2, -s2, -s, s2); // W
// Puntas (un rect 2s × (l - 2s) por brazo)
rect(-s, s2, s, l); // N
rect(-s, -l, s, -s2); // S
rect(s2, -s, l, s); // E
rect(-l, -s, -s2, s); // W
tri
}
/// Para un punto cualquiera, devuelve la punta más cercana y su distancia.
/// Útil para snapping de interacción.
pub fn closest_tip(&self, p: (f32, f32)) -> ((f32, f32), f32) {
let tips = self.tips();
let mut best = (tips[0], f32::INFINITY);
for t in tips.iter() {
let dx = t.0 - p.0;
let dy = t.1 - p.1;
let d = (dx * dx + dy * dy).sqrt();
if d < best.1 {
best = (*t, d);
}
}
best
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn perimeter_has_20_vertices() {
assert_eq!(ChacanaSpec::CLASSIC.perimeter().len(), 20);
}
#[test]
fn triangles_form_9_rectangles() {
assert_eq!(ChacanaSpec::CLASSIC.triangles().len(), 9 * 6);
}
#[test]
fn tips_match_cardinals() {
let c = ChacanaSpec::new(2.0, 0.3);
let tips = c.tips();
assert_eq!(tips[0], (0.0, 2.0)); // N
assert_eq!(tips[1], (2.0, 0.0)); // E
assert_eq!(tips[2], (0.0, -2.0)); // S
assert_eq!(tips[3], (-2.0, 0.0)); // W
}
#[test]
fn closest_tip_to_upper_left_is_north() {
let c = ChacanaSpec::CLASSIC;
let (tip, _d) = c.closest_tip((-0.1, 0.95));
assert_eq!(tip, (0.0, 1.0));
}
#[test]
fn aabb_matches_extent() {
let c = ChacanaSpec::new(1.5, 0.2);
assert_eq!(c.aabb(), ((-1.5, -1.5), (1.5, 1.5)));
}
}
@@ -0,0 +1,8 @@
[package]
name = "gioser-palette"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
@@ -0,0 +1,130 @@
//! Paleta visual de GioSer: cuatro elementos + cosmos.
//!
//! Los colores se exponen en RGB lineal (rango `0..=1`). Para CSS,
//! convertir con `to_srgb_hex()`. Para shaders WebGL, pasar como `vec3`.
//! La separación lineal/sRGB es deliberada: el motor blending suma luz
//! en lineal y el ojo lee sRGB.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rgb(pub f32, pub f32, pub f32);
impl Rgb {
pub const fn new(r: f32, g: f32, b: f32) -> Self {
Self(r, g, b)
}
pub const fn array(self) -> [f32; 3] {
[self.0, self.1, self.2]
}
/// Hex string sRGB `#rrggbb` en bytes ASCII (7 chars).
/// Hex en bytes evita allocs al pasar a CSS desde WASM.
pub fn to_srgb_hex(self) -> [u8; 7] {
fn encode(c: f32) -> u8 {
let g = if c <= 0.003_130_8 {
12.92 * c
} else {
1.055 * c.clamp(0.0, 1.0).powf(1.0 / 2.4) - 0.055
};
(g.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
}
fn nib(x: u8) -> u8 {
if x < 10 {
b'0' + x
} else {
b'a' + (x - 10)
}
}
let r = encode(self.0);
let g = encode(self.1);
let b = encode(self.2);
[
b'#',
nib(r >> 4),
nib(r & 0x0f),
nib(g >> 4),
nib(g & 0x0f),
nib(b >> 4),
nib(b & 0x0f),
]
}
pub fn lerp(self, other: Rgb, t: f32) -> Rgb {
Rgb(
self.0 + (other.0 - self.0) * t,
self.1 + (other.1 - self.1) * t,
self.2 + (other.2 - self.2) * t,
)
}
}
/// Los cuatro elementos canónicos.
pub mod elements {
use super::Rgb;
/// Aire — azul-blanco luminoso. Software, IA, aspiración.
pub const AIRE: Rgb = Rgb(0.78, 0.86, 1.00);
/// Agua — cyan profundo. Espiritualidad aplicada.
pub const AGUA: Rgb = Rgb(0.28, 0.74, 0.95);
/// Fuego — ámbar/escarlata. Inspiración.
pub const FUEGO: Rgb = Rgb(0.98, 0.45, 0.18);
/// Tierra — ocre cálido. Cuerpo.
pub const TIERRA: Rgb = Rgb(0.82, 0.55, 0.28);
pub const ALL: [(&str, Rgb); 4] = [
("aire", AIRE),
("agua", AGUA),
("fuego", FUEGO),
("tierra", TIERRA),
];
}
/// Fondo cósmico + elementos arquitectónicos.
pub mod cosmos {
use super::Rgb;
/// Vacío profundo, casi negro con tinte violeta.
pub const VOID: Rgb = Rgb(0.030, 0.025, 0.060);
/// Nebulosa interior — violeta tenue.
pub const NEBULA_A: Rgb = Rgb(0.220, 0.130, 0.380);
/// Nebulosa exterior — azul profundo.
pub const NEBULA_B: Rgb = Rgb(0.080, 0.180, 0.320);
/// Núcleo solar central.
pub const SUN_CORE: Rgb = Rgb(1.000, 0.860, 0.520);
/// Líneas de la chacana — cyan helado.
pub const CHACANA_LINE: Rgb = Rgb(0.55, 0.92, 1.00);
/// Aro de fuego del logo — dorado-ámbar.
pub const CHACANA_RIM: Rgb = Rgb(0.95, 0.65, 0.32);
/// Polvo de estrellas.
pub const STARDUST: Rgb = Rgb(0.85, 0.88, 1.00);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_white() {
assert_eq!(&Rgb(1.0, 1.0, 1.0).to_srgb_hex(), b"#ffffff");
}
#[test]
fn hex_black() {
assert_eq!(&Rgb(0.0, 0.0, 0.0).to_srgb_hex(), b"#000000");
}
#[test]
fn lerp_midpoint() {
let m = Rgb(0.0, 0.0, 0.0).lerp(Rgb(1.0, 0.5, 0.0), 0.5);
assert_eq!(m, Rgb(0.5, 0.25, 0.0));
}
#[test]
fn linear_to_srgb_midgray_lifts_brightness() {
// 0.5 lineal codifica a sRGB ≈ 0.735 → byte ≈ 187 (0xbb..0xbc segun redondeo de powf).
// Bandgap [0xb8, 0xbf] permite drift de implementaciones de powf entre plataformas.
let h = Rgb(0.5, 0.5, 0.5).to_srgb_hex();
let lo = b"#b8b8b8";
let hi = b"#bfbfbf";
assert!(h.as_slice() >= lo.as_slice() && h.as_slice() <= hi.as_slice(),
"got {:?}", core::str::from_utf8(&h).unwrap_or(""));
}
}
@@ -0,0 +1,8 @@
[package]
name = "gioser-physics"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
@@ -0,0 +1,118 @@
//! Resorte-amortiguador genérico de N dimensiones.
//!
//! Sirve para animaciones orgánicas: el "tilt" de la chacana hacia el mouse,
//! el pulso del sol, las transiciones de hover. La integración es semi-implícita
//! Euler — estable para `dt < 1/freq_hz`. Si `dt` real puede exceder ese límite,
//! el caller subdivide.
//!
//! `damping_ratio`:
//! - `1.0` crítico: settle en tiempo mínimo, sin overshoot.
//! - `0.7` sub-crítico: overshoot suave (≈4.6 %), se siente vivo.
//! - `< 0.5` muy oscilante (no recomendado fuera de FX).
#![no_std]
#[derive(Clone, Copy, Debug)]
pub struct SpringDamper<const N: usize> {
pub position: [f32; N],
pub velocity: [f32; N],
pub target: [f32; N],
/// Frecuencia natural en Hz.
pub freq_hz: f32,
/// 1.0 = crítico, < 1.0 = oscila.
pub damping_ratio: f32,
}
impl<const N: usize> SpringDamper<N> {
pub const fn new(freq_hz: f32, damping_ratio: f32) -> Self {
Self {
position: [0.0; N],
velocity: [0.0; N],
target: [0.0; N],
freq_hz,
damping_ratio,
}
}
pub fn with_position(mut self, pos: [f32; N]) -> Self {
self.position = pos;
self.target = pos;
self
}
pub fn set_target(&mut self, t: [f32; N]) {
self.target = t;
}
/// Avanza la simulación. Caller suele pasarlo desde un `requestAnimationFrame`.
pub fn step(&mut self, dt: f32) {
// Tau = 2π. core::f32::consts::TAU está estable desde 1.47.
let omega = core::f32::consts::TAU * self.freq_hz;
let zeta = self.damping_ratio;
let k = omega * omega;
let c = 2.0 * zeta * omega;
let mut i = 0;
while i < N {
let dx = self.position[i] - self.target[i];
let a = -k * dx - c * self.velocity[i];
self.velocity[i] += a * dt;
self.position[i] += self.velocity[i] * dt;
i += 1;
}
}
/// `true` cuando el sistema está esencialmente parado en el target.
pub fn at_rest(&self, eps_pos: f32, eps_vel: f32) -> bool {
let mut i = 0;
while i < N {
if (self.position[i] - self.target[i]).abs() > eps_pos
|| self.velocity[i].abs() > eps_vel
{
return false;
}
i += 1;
}
true
}
}
pub type SpringDamper1 = SpringDamper<1>;
pub type SpringDamper2 = SpringDamper<2>;
pub type SpringDamper3 = SpringDamper<3>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settles_at_target_when_critically_damped() {
let mut s = SpringDamper2::new(3.0, 1.0);
s.set_target([1.0, -0.5]);
for _ in 0..300 {
s.step(1.0 / 120.0);
}
assert!((s.position[0] - 1.0).abs() < 1e-3);
assert!((s.position[1] + 0.5).abs() < 1e-3);
assert!(s.at_rest(1e-3, 1e-3));
}
#[test]
fn underdamped_overshoots() {
let mut s = SpringDamper1::new(3.0, 0.3);
s.set_target([1.0]);
let mut peak = 0.0f32;
for _ in 0..240 {
s.step(1.0 / 240.0);
if s.position[0] > peak {
peak = s.position[0];
}
}
assert!(peak > 1.0, "underdamped should overshoot, peak={}", peak);
}
#[test]
fn at_rest_initially() {
let s: SpringDamper2 = SpringDamper::new(2.0, 1.0);
assert!(s.at_rest(1e-6, 1e-6));
}
}
@@ -0,0 +1,8 @@
[package]
name = "gioser-shaders"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
@@ -0,0 +1,204 @@
//! Fuentes GLSL ES 3.00 para GioSer.
//!
//! Cada `const &str` es un shader completo, listo para pasar a
//! `gl.shaderSource()`. No dependemos de ningún backend; el cliente
//! decide cómo compilarlos. Convención: precision `highp float`,
//! atributo `a_pos`, varying `v_*`, uniforms `u_*`.
#![no_std]
/// Vertex shader para quads en clip-space `[-1, 1]²`.
pub const VS_FULLSCREEN: &str = "#version 300 es
precision highp float;
in vec2 a_pos;
out vec2 v_clip;
out vec2 v_uv;
void main() {
v_clip = a_pos;
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
";
/// Fragment del fondo cósmico: FBM en 3 capas + estrellas + viñeta.
/// Uniforms esperados: `u_resolution`, `u_time`, `u_parallax`,
/// `u_void`, `u_nebula_a`, `u_nebula_b`, `u_stardust`.
pub const FS_COSMOS: &str = "#version 300 es
precision highp float;
in vec2 v_clip;
in vec2 v_uv;
out vec4 fragColor;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_parallax;
uniform vec3 u_void;
uniform vec3 u_nebula_a;
uniform vec3 u_nebula_b;
uniform vec3 u_stardust;
float hash21(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.55;
for (int i = 0; i < 5; i++) {
v += a * vnoise(p);
p *= 2.07;
a *= 0.55;
}
return v;
}
void main() {
float aspect = u_resolution.x / max(u_resolution.y, 1.0);
vec2 uv = v_clip;
uv.x *= aspect;
vec2 d1 = vec2( u_time * 0.010, u_time * 0.004) + u_parallax * 0.08;
vec2 d2 = vec2(-u_time * 0.016, u_time * 0.011) + u_parallax * 0.18;
vec2 d3 = vec2( u_time * 0.024, -u_time * 0.019) + u_parallax * 0.34;
float n1 = fbm(uv * 0.9 + d1);
float n2 = fbm(uv * 2.1 + d2);
float n3 = fbm(uv * 4.5 + d3);
vec3 color = u_void;
color = mix(color, u_nebula_a, pow(n1, 1.6) * 0.70);
color = mix(color, u_nebula_b, pow(n2, 2.0) * 0.55);
color += u_nebula_a * pow(n3, 3.2) * 0.22;
float r = length(v_clip);
color *= 1.0 - smoothstep(0.55, 1.35, r) * 0.85;
// Estrellas brillantes (pocas, titilan).
vec2 sgrid = uv * 90.0;
vec2 sid = floor(sgrid);
float sh = hash21(sid);
float twinkle = 0.4 + 0.6 * sin(u_time * 1.7 + sh * 28.0);
float starMask = smoothstep(0.997, 0.9985, sh);
color += u_stardust * starMask * twinkle * 0.95;
// Polvo (muchas, débiles).
vec2 dgrid = uv * 220.0;
float dh = hash21(floor(dgrid));
float dustMask = smoothstep(0.985, 0.992, dh);
color += u_stardust * dustMask * 0.25;
fragColor = vec4(color, 1.0);
}
";
/// Vertex de la chacana: aplica MVP y pasa la posición de mundo al fragment.
pub const VS_CHACANA: &str = "#version 300 es
precision highp float;
in vec2 a_pos;
out vec2 v_world;
uniform mat4 u_mvp;
void main() {
v_world = a_pos;
gl_Position = u_mvp * vec4(a_pos, 0.0, 1.0);
}
";
/// Fragment de la chacana: SDF de la cruz escalonada + glow + aro + sol pulsante.
/// Uniforms: `u_time`, `u_thickness`, `u_arm_extent`,
/// `u_line_color`, `u_rim_color`, `u_sun_color`, `u_sun_pulse`.
pub const FS_CHACANA: &str = "#version 300 es
precision highp float;
in vec2 v_world;
out vec4 fragColor;
uniform float u_time;
uniform float u_thickness;
uniform float u_arm_extent;
uniform vec3 u_line_color;
uniform vec3 u_rim_color;
uniform vec3 u_sun_color;
uniform float u_sun_pulse;
float sdBox(vec2 p, vec2 b) {
vec2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
float sdChacana(vec2 p, float s, float L) {
float s2 = s * 2.0;
float halfArm = max((L - s2) * 0.5, 0.0);
float armOff = s2 + halfArm;
float d = sdBox(p, vec2(s, s));
d = min(d, sdBox(p - vec2(0.0, 1.5 * s), vec2(s2, 0.5 * s)));
d = min(d, sdBox(p - vec2(0.0, -1.5 * s), vec2(s2, 0.5 * s)));
d = min(d, sdBox(p - vec2( 1.5 * s, 0.0), vec2(0.5 * s, s2)));
d = min(d, sdBox(p - vec2(-1.5 * s, 0.0), vec2(0.5 * s, s2)));
d = min(d, sdBox(p - vec2(0.0, armOff), vec2(s, halfArm)));
d = min(d, sdBox(p - vec2(0.0, -armOff), vec2(s, halfArm)));
d = min(d, sdBox(p - vec2( armOff, 0.0), vec2(halfArm, s)));
d = min(d, sdBox(p - vec2(-armOff, 0.0), vec2(halfArm, s)));
return d;
}
void main() {
vec2 p = v_world;
float d = sdChacana(p, u_thickness, u_arm_extent);
// Línea: gaussiana alrededor del borde.
float lineW = 0.013;
float line = exp(-(d * d) / (2.0 * lineW * lineW));
// Glow exterior cae más suave.
float glow = exp(-max(d, 0.0) * 7.5) * 0.55;
// Fill interior tenue (ligera niebla cyan dentro).
float fill = smoothstep(0.0, -0.025, d);
// Aro exterior: gran círculo que envuelve la chacana.
float ringR = u_arm_extent * 1.18;
float ringD = abs(length(p) - ringR);
float ringW = 0.008;
float ring = exp(-(ringD * ringD) / (2.0 * ringW * ringW)) * 0.75;
// Rayos sutiles (12 divisiones del círculo, como husillos del calendario).
float ang = atan(p.y, p.x);
float rays = pow(abs(cos(ang * 6.0)), 80.0)
* smoothstep(u_arm_extent * 1.05, ringR * 0.97, length(p))
* (0.18 + 0.10 * sin(u_time * 0.6));
// Sol central: gauss tight + corona suave + pulso.
float sunR = u_thickness * 0.55;
float sunDist = length(p);
float sun = exp(-(sunDist * sunDist) / (2.0 * sunR * sunR));
float corR = sunR * 4.5;
float corona = exp(-(sunDist * sunDist) / (2.0 * corR * corR)) * 0.45;
float sunMix = sun + corona * (0.75 + 0.25 * u_sun_pulse);
vec3 col = vec3(0.0);
col += u_line_color * line * 1.45;
col += u_rim_color * glow * 1.05;
col += u_line_color * ring * 0.95;
col += u_rim_color * rays * 1.40;
col += u_sun_color * sunMix * 1.35;
col += vec3(0.04, 0.06, 0.12) * fill * 0.55;
float alpha = clamp(line * 1.2 + glow + ring + rays + sunMix + fill * 0.5, 0.0, 1.0);
fragColor = vec4(col, alpha);
}
";
/// Geometría del quad fullscreen: dos triángulos en clip-space.
pub const FULLSCREEN_QUAD: [f32; 12] = [
-1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0,
];
/// Quad ligeramente mayor que la chacana para no recortar el glow ni el aro.
pub fn chacana_quad(arm_extent: f32) -> [f32; 12] {
let e = arm_extent * 1.45;
[-e, -e, e, -e, e, e, -e, -e, e, e, -e, e]
}