diff --git a/crates/apps/gioser-web/README.md b/crates/apps/gioser-web/README.md
index 5f39900..9880686 100644
--- a/crates/apps/gioser-web/README.md
+++ b/crates/apps/gioser-web/README.md
@@ -35,32 +35,127 @@ se enchufa al runtime equivalente a `yahweh_launcher::launch_app`.
- **Botones:** DOM real (accesibles, navegables por teclado, deep-link
por hash) proyectados desde 3D al viewport cada frame.
-## Build
+---
-Requiere el target `wasm32-unknown-unknown` y `wasm-bindgen-cli`.
+## Requisitos
+
+- **Rust** con el target `wasm32-unknown-unknown` instalado.
+ - Con `rustup`: `rustup target add wasm32-unknown-unknown`.
+ - En Artix/Arch con Rust del sistema: el target suele venir incluido en
+ `/usr/lib/rustlib/wasm32-unknown-unknown` (verificá con
+ `ls /usr/lib/rustlib | grep wasm`). Si falta: `pacman -S rust-wasm`
+ (no existe — el target viene con el paquete `rust` base).
+- **wasm-bindgen-cli** (versión exacta 0.2.121, debe matchear la dep del
+ `Cargo.lock`). Verificá con
+ `grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | head` antes de instalar.
```sh
-# Una vez (si no los tenés):
-rustup target add wasm32-unknown-unknown
-cargo install wasm-bindgen-cli --version 0.2.99
-
-# Build:
-cargo build -p gioser-web --release --target wasm32-unknown-unknown
-
-# Generar bindings JS:
-wasm-bindgen \
- target/wasm32-unknown-unknown/release/gioser_web.wasm \
- --out-dir crates/apps/gioser-web/pkg \
- --target web
-
-# Servir (cualquier static server vale):
-python3 -m http.server -d crates/apps/gioser-web 8080
-# → http://localhost:8080/
+# Una sola vez:
+cargo install wasm-bindgen-cli --version 0.2.121 --locked
```
-Si usás `rust-toolchain.toml` con Rust del sistema (Artix/Arch), instalá
-el target con tu package manager (`pacman -S rust-wasm` o equivalente) o
-montá rustup en un perfil aparte.
+> La versión del CLI **debe** coincidir con la del crate `wasm-bindgen`
+> en `Cargo.lock`. Si no coincide, el output JS no carga el `.wasm`
+> generado. Si actualizás el workspace y wasm-bindgen sube de versión,
+> reinstalá el CLI con la nueva versión.
+
+- Un static server para probar local: `python3 -m http.server` alcanza.
+
+---
+
+## Flujo rápido (un comando)
+
+Hay un wrapper que hace cargo build + wasm-bindgen + copia salida:
+
+```sh
+# Dev — sin optimización, build rápido (~10 s).
+./scripts/build-gioser-web.sh dev
+
+# Release — opt-level=3, lto, strip, ~30 s pero binario pequeño.
+./scripts/build-gioser-web.sh release
+```
+
+El output queda en `crates/apps/gioser-web/pkg/`:
+```
+pkg/
+├── gioser_web.js ← bindings JS (referenciados por index.html)
+├── gioser_web_bg.wasm ← binario WASM
+└── gioser_web.d.ts ← typings (no se usan en runtime, son para IDE)
+```
+
+---
+
+## Probarlo local
+
+```sh
+./scripts/build-gioser-web.sh dev
+python3 -m http.server -d crates/apps/gioser-web 8080
+# Abrir http://localhost:8080/
+```
+
+Si cambiás Rust: re-ejecutar el script y refrescar el browser.
+Si cambiás `index.html` / `styles.css`: alcanza con refrescar.
+
+---
+
+## Build release y deploy
+
+```sh
+./scripts/build-gioser-web.sh release
+```
+
+El binario release optimiza:
+- `opt-level = 3`, `lto = "thin"`, `codegen-units = 1`,
+ `panic = "abort"`, `strip = "symbols"` (del perfil `[profile.release]`
+ del workspace).
+- WASM resultante típico: **~120 KB** (canvas + shaders + bindings) sin
+ comprimir, **~50 KB** gzippeado. wasm-bindgen pasa por
+ `wasm-opt` automáticamente si lo encontrás en `$PATH` (instalable via
+ `binaryen`).
+
+Para deploy, los **artefactos a subir al host estático** son sólo
+cuatro archivos:
+
+```
+crates/apps/gioser-web/
+├── index.html ← entry point
+├── styles.css ← estilos
+└── pkg/
+ ├── gioser_web.js
+ └── gioser_web_bg.wasm
+```
+
+Funciona en cualquier static host (Nginx, GitHub Pages, S3+CloudFront,
+Caddy, netlify, fly, Vercel static). **Importante:**
+
+- El server debe servir `.wasm` con `Content-Type: application/wasm`.
+ Nginx/Caddy lo hacen por default; algunos hosts muy viejos no — fijate.
+- `index.html` referencia `./pkg/gioser_web.js` con `type="module"`,
+ o sea que el browser usa el ES module loader. Eso requiere servir por
+ HTTP/HTTPS (no `file://`).
+- Fonts de Google: el `` apunta a `fonts.googleapis.com`. Para uso
+ offline o sin tracking, descargá las fuentes y servilas locales.
+
+### Comando deploy "tar + scp"
+
+```sh
+./scripts/build-gioser-web.sh release
+tar czf gioser-web-dist.tar.gz \
+ -C crates/apps/gioser-web \
+ index.html styles.css pkg/
+
+# Subir al server:
+scp gioser-web-dist.tar.gz user@host:/var/www/gioser/
+ssh user@host 'cd /var/www/gioser && tar xzf gioser-web-dist.tar.gz'
+```
+
+### GitHub Pages / gitea pages
+
+Configurá la branch de pages a apuntar a un directorio que contenga
+sólo los 4 archivos. Un workflow CI que corra el script y commitee el
+`pkg/` a la branch de deploy hace el trabajo.
+
+---
## Routing
@@ -68,10 +163,35 @@ Los `` apuntan a anchors locales.
Cuando definamos rutas reales (otras páginas, sub-apps, etc.), basta con
cambiar el `href` en `index.html` o interceptar el click desde JS.
-## Tests
+---
-Los crates agnósticos tienen tests unitarios:
+## Tests de los crates agnósticos
+
+Los cuatro crates sin gpui/web tienen tests unitarios estándar:
```sh
-cargo test -p gioser-geom -p gioser-physics -p gioser-palette
+cargo test -p gioser-geom -p gioser-physics -p gioser-palette -p gioser-shaders
```
+
+`gioser-canvas-web` no tiene tests (depende de WebGL2 que sólo existe
+en browser).
+
+---
+
+## Troubleshooting
+
+**Pantalla en blanco + error en consola "no link/binding found"**
+→ Versión de `wasm-bindgen-cli` no coincide con la del `Cargo.lock`.
+Reinstalá con `cargo install wasm-bindgen-cli --version `
+donde `` sale de `grep '^version' Cargo.lock` cerca de la entrada
+`wasm-bindgen`.
+
+**"WebGL2 not supported"** en algunos navegadores viejos
+→ No hay fallback. WebGL2 es soporte universal en navegadores modernos
+(Chrome/Edge/Firefox/Safari desde 2017). Para targets ancestrales habría
+que escribir un renderer WebGL1, no contemplado por ahora.
+
+**Build muy lento por las deps de web-sys**
+→ Las features de web-sys están minimizadas en el Cargo.toml; sólo se
+importan las que el renderer usa. El primer build sí es lento (~1 min),
+los incrementales son rápidos.
diff --git a/crates/modules/gioser/gioser-canvas-web/src/lib.rs b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
index 19af3a2..3b6656d 100644
--- a/crates/modules/gioser/gioser-canvas-web/src/lib.rs
+++ b/crates/modules/gioser/gioser-canvas-web/src/lib.rs
@@ -31,17 +31,22 @@ use web_sys::{
};
const RAD: f32 = core::f32::consts::PI / 180.0;
-const MAX_TILT_DEG: f32 = 22.0;
+/// Inclinación máxima en cada eje. 35° hace que las puntas se desplacen
+/// visiblemente en pantalla — los botones DOM cabalgan ese movimiento.
+const MAX_TILT_DEG: f32 = 35.0;
+/// Escala mundo→viewport: la chacana clásica con arm_extent ≈ 0.65 ocupa
+/// ~94% del eje vertical del viewport, dejando aire para aros y glow.
+const WORLD_SCALE: f32 = 1.45;
/// 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
+ ("aire", elements::AIRE, "AIRE"), // N
+ ("fuego", elements::FUEGO, "FUEGO"), // E
("tierra", elements::TIERRA, "TIERRA"), // S
- ("agua", elements::AGUA, "AGUA"), // W
+ ("agua", elements::AGUA, "AGUA"), // W
];
}
@@ -73,7 +78,6 @@ impl Program {
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
@@ -126,8 +130,8 @@ fn upload_quad(
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).
+ // SAFETY: `Float32Array::view` apunta a memoria WASM lineal; no la
+ // movemos durante este scope (no hay allocs intermedias).
unsafe {
let view = js_sys::Float32Array::view(verts);
gl.buffer_data_with_array_buffer_view(GL::ARRAY_BUFFER, &view, GL::STATIC_DRAW);
@@ -171,6 +175,7 @@ impl Renderer {
"u_mvp",
"u_time",
"u_thickness",
+ "u_center_half",
"u_arm_extent",
"u_line_color",
"u_rim_color",
@@ -181,11 +186,13 @@ impl Renderer {
.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_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);
+ // Spring sub-crítico, frecuencia baja: sensación de cuerpo pesado
+ // que se inclina hacia el cursor con overshoot orgánico (~10%).
+ let tilt = SpringDamper2::new(1.8, 0.62);
Ok(Self {
gl,
@@ -209,8 +216,7 @@ impl Renderer {
.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.
+ /// Mouse en pixeles desde el centro del canvas (+x derecha, +y arriba).
pub fn set_mouse_px(&mut self, x: f32, y: f32) {
let (w, h) = self.viewport;
if h == 0 {
@@ -223,13 +229,13 @@ impl Renderer {
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).
+ // Pitch (+rotX) cuando mouse arriba: el tope se acerca al viewer.
+ // Yaw (-rotY) cuando mouse derecha: el lado derecho se acerca.
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.
+ /// Posición proyectada (NDC `[-1, 1]²`) de cada tip cardinal en 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();
@@ -246,6 +252,12 @@ impl Renderer {
&self.chacana
}
+ /// Posición normalizada del mouse en clip-space; útil para que el caller
+ /// añada parallax sutil a elementos DOM independientes de la chacana.
+ pub fn mouse_clip(&self) -> (f32, f32) {
+ self.mouse
+ }
+
fn build_mvp(&self) -> Mat4 {
let (w, h) = self.viewport;
let aspect = w as f32 / h as f32;
@@ -253,7 +265,7 @@ impl Renderer {
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));
+ let scale = Mat4::from_scale(Vec3::splat(WORLD_SCALE));
proj * view * yaw * pitch * scale
}
@@ -265,7 +277,6 @@ impl Renderer {
};
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 {
@@ -313,8 +324,11 @@ impl Renderer {
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_center_half") {
+ gl.uniform1f(Some(u), self.chacana.center_half());
+ }
if let Some(u) = self.chacana_prog.u("u_arm_extent") {
- gl.uniform1f(Some(u), self.chacana.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);
diff --git a/crates/modules/gioser/gioser-geom/src/lib.rs b/crates/modules/gioser/gioser-geom/src/lib.rs
index 90d7889..f466464 100644
--- a/crates/modules/gioser/gioser-geom/src/lib.rs
+++ b/crates/modules/gioser/gioser-geom/src/lib.rs
@@ -1,85 +1,66 @@
-//! Geometría de la chacana andina (cruz cuadrada escalonada).
+//! Geometría de la chacana andina escalonada (cruz cuadrada de Tiwanaku).
//!
-//! 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`.
+//! Modelo paramétrico: un cuadrado central de lado `2 * center_half()`,
+//! del que sobresalen cuatro brazos cardinales formados por `steps`
+//! niveles. Cada nivel adelgaza al brazo en `thickness` por lado y lo
+//! prolonga en `thickness` hacia afuera.
//!
-//! 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.
+//! Para `steps = 2` (clásica mística):
+//! - Centro: cuadrado `6s × 6s` (donde `s = thickness`).
+//! - Nivel 1: rectángulo perpendicular `4s × s` adosado a cada cara del centro.
+//! - Nivel 2 (punta): rectángulo `2s × s` adosado al nivel 1.
+//!
+//! Resultado: bounding box `±5s` (cuadrado, no alargado como una cruz latina),
+//! 9 rectángulos disjuntos triangulables, 4 tips cardinales.
#[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`.
+ /// Unidad base de la geometría. Cada paso aporta `thickness` de ancho
+ /// y `thickness` de profundidad.
pub thickness: f32,
+ /// Cantidad de escalones por brazo (`>= 1`). La chacana mística clásica = `2`.
+ pub steps: u32,
}
impl ChacanaSpec {
- /// Configuración canónica: brazo 1.0, grosor 0.18 (proporciones del logo).
+ /// Configuración canónica del logo GioSer: 2 escalones, thickness 0.13
+ /// (bounding box ≈ 1.30 × 1.30 en unidades de mundo).
pub const CLASSIC: Self = Self {
- arm_extent: 1.0,
- thickness: 0.18,
+ thickness: 0.13,
+ steps: 2,
};
- pub const fn new(arm_extent: f32, thickness: f32) -> Self {
- Self {
- arm_extent,
- thickness,
- }
+ pub const fn new(thickness: f32, steps: u32) -> Self {
+ Self { thickness, steps }
}
- /// Las cuatro puntas cardinales en orden `[N, E, S, W]`.
- /// Coordenadas listas para anclar UI sobre la chacana.
+ /// Semi-lado del cuadrado central — la parte **más ancha** de la chacana.
+ pub fn center_half(&self) -> f32 {
+ (self.steps as f32 + 1.0) * self.thickness
+ }
+
+ /// Distancia desde el centro a la punta más externa.
+ pub fn arm_extent(&self) -> f32 {
+ self.center_half() + self.steps as f32 * self.thickness
+ }
+
+ /// Las cuatro puntas cardinales `[N, E, S, W]`.
pub fn tips(&self) -> [(f32, f32); 4] {
- let l = self.arm_extent;
+ 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;
+ 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`.
+ /// Triangulación: `1 + 4 * steps` rectángulos en `GL_TRIANGLES`.
+ /// Para `steps = 2`: 9 rects = 54 vértices.
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 c = self.center_half();
+ let mut tri = Vec::with_capacity(6 * (1 + 4 * self.steps as usize));
let mut rect = |x0: f32, y0: f32, x1: f32, y1: f32| {
tri.push((x0, y0));
tri.push((x1, y0));
@@ -88,23 +69,22 @@ impl ChacanaSpec {
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
+ rect(-c, -c, c, c);
+ for k in 1..=self.steps {
+ // El k-ésimo nivel (1 = más cerca del centro, steps = punta)
+ // adelgaza a (steps - k + 1) * thickness de semi-ancho.
+ let hw = (self.steps - k + 1) as f32 * s;
+ let inner = c + (k - 1) as f32 * s;
+ let outer = c + k as f32 * s;
+ rect(-hw, inner, hw, outer); // N
+ rect(-hw, -outer, hw, -inner); // S
+ rect(inner, -hw, outer, hw); // E
+ rect(-outer, -hw, -inner, hw); // W
+ }
tri
}
- /// Para un punto cualquiera, devuelve la punta más cercana y su distancia.
- /// Útil para snapping de interacción.
+ /// Para un punto cualquiera, devuelve la punta más cercana y la distancia.
pub fn closest_tip(&self, p: (f32, f32)) -> ((f32, f32), f32) {
let tips = self.tips();
let mut best = (tips[0], f32::INFINITY);
@@ -125,35 +105,55 @@ mod tests {
use super::*;
#[test]
- fn perimeter_has_20_vertices() {
- assert_eq!(ChacanaSpec::CLASSIC.perimeter().len(), 20);
+ fn classic_is_two_step_chacana() {
+ let c = ChacanaSpec::CLASSIC;
+ assert_eq!(c.steps, 2);
+ // center_half = 3 * 0.13 = 0.39; arm_extent = 0.65.
+ assert!((c.center_half() - 0.39).abs() < 1e-6);
+ assert!((c.arm_extent() - 0.65).abs() < 1e-6);
}
#[test]
- fn triangles_form_9_rectangles() {
- assert_eq!(ChacanaSpec::CLASSIC.triangles().len(), 9 * 6);
+ fn arm_extent_grows_with_steps() {
+ let c1 = ChacanaSpec::new(0.1, 1);
+ let c2 = ChacanaSpec::new(0.1, 2);
+ let c3 = ChacanaSpec::new(0.1, 3);
+ assert!(c1.arm_extent() < c2.arm_extent());
+ assert!(c2.arm_extent() < c3.arm_extent());
+ }
+
+ #[test]
+ fn triangles_one_rect_plus_four_per_step() {
+ let c1 = ChacanaSpec::new(0.1, 1);
+ assert_eq!(c1.triangles().len(), 6 * (1 + 4 * 1));
+ let c2 = ChacanaSpec::CLASSIC;
+ assert_eq!(c2.triangles().len(), 6 * (1 + 4 * 2));
+ let c3 = ChacanaSpec::new(0.1, 3);
+ assert_eq!(c3.triangles().len(), 6 * (1 + 4 * 3));
}
#[test]
fn tips_match_cardinals() {
- let c = ChacanaSpec::new(2.0, 0.3);
+ let c = ChacanaSpec::CLASSIC;
+ let l = c.arm_extent();
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
+ assert_eq!(tips[0], (0.0, l)); // N
+ assert_eq!(tips[1], (l, 0.0)); // E
+ assert_eq!(tips[2], (0.0, -l)); // S
+ assert_eq!(tips[3], (-l, 0.0)); // W
}
#[test]
- fn closest_tip_to_upper_left_is_north() {
+ fn closest_tip_to_upper_point_is_north() {
let c = ChacanaSpec::CLASSIC;
- let (tip, _d) = c.closest_tip((-0.1, 0.95));
- assert_eq!(tip, (0.0, 1.0));
+ let (tip, _d) = c.closest_tip((-0.1, 0.55));
+ assert_eq!(tip, (0.0, c.arm_extent()));
}
#[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)));
+ let c = ChacanaSpec::new(0.12, 2);
+ let l = c.arm_extent();
+ assert_eq!(c.aabb(), ((-l, -l), (l, l)));
}
}
diff --git a/crates/modules/gioser/gioser-shaders/src/lib.rs b/crates/modules/gioser/gioser-shaders/src/lib.rs
index d724be9..acf0994 100644
--- a/crates/modules/gioser/gioser-shaders/src/lib.rs
+++ b/crates/modules/gioser/gioser-shaders/src/lib.rs
@@ -20,9 +20,9 @@ void main() {
}
";
-/// 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`.
+/// Fragment del fondo cósmico: nubes FBM en 3 capas, 3 estratos de
+/// estrellas con titilación independiente, viñeta, y 4 meteoros
+/// procedurales que cruzan el cielo periódicamente.
pub const FS_COSMOS: &str = "#version 300 es
precision highp float;
in vec2 v_clip;
@@ -39,6 +39,9 @@ uniform vec3 u_stardust;
float hash21(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
+float hash11(float n) {
+ return fract(sin(n * 78.233) * 43758.5453);
+}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
@@ -54,45 +57,106 @@ float fbm(vec2 p) {
float a = 0.55;
for (int i = 0; i < 5; i++) {
v += a * vnoise(p);
- p *= 2.07;
+ p = p * 2.07 + vec2(11.3, 7.7);
a *= 0.55;
}
return v;
}
+
+// Meteoro procedural: trazo brillante con cola, vida 1.6s, respawnea solo.
+float meteor(vec2 uv, float seed) {
+ float period = 6.5 + 4.0 * hash11(seed * 17.0);
+ float t_seeded = u_time + seed * 19.0;
+ float phase = mod(t_seeded, period);
+ float life = 1.6;
+ if (phase > life) return 0.0;
+ float t = phase / life;
+
+ float epoch = floor(t_seeded / period);
+ vec2 origin = vec2(
+ hash21(vec2(seed, epoch)) * 2.6 - 1.3,
+ 0.55 + hash21(vec2(seed + 5.0, epoch)) * 0.55
+ );
+ vec2 dir = normalize(vec2(
+ hash21(vec2(seed + 1.0, epoch)) * 1.6 - 0.8,
+ -0.7 - hash21(vec2(seed + 2.0, epoch)) * 0.6
+ ));
+
+ vec2 head = origin + dir * t * 2.1;
+ vec2 tail = head - dir * 0.24;
+
+ vec2 pa = uv - tail;
+ vec2 ba = head - tail;
+ float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
+ float dist = length(pa - ba * h);
+
+ float perpGlow = exp(-dist * 420.0);
+ float trailFalloff = smoothstep(0.0, 1.0, h);
+ float headPulse = exp(-dist * 900.0);
+ float lifeFade = sin(t * 3.14159);
+
+ return (perpGlow * trailFalloff + headPulse * 1.4) * lifeFade;
+}
+
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;
+ // === NUBES (drift visible, 5× más rápido que la versión anterior) ===
+ vec2 d1 = vec2( u_time * 0.055, u_time * 0.022) + u_parallax * 0.10;
+ vec2 d2 = vec2(-u_time * 0.085, u_time * 0.058) + u_parallax * 0.22;
+ vec2 d3 = vec2( u_time * 0.130, -u_time * 0.095) + u_parallax * 0.40;
- float n1 = fbm(uv * 0.9 + d1);
- float n2 = fbm(uv * 2.1 + d2);
- float n3 = fbm(uv * 4.5 + d3);
+ float n1 = fbm(uv * 0.85 + d1);
+ float n2 = fbm(uv * 2.05 + d2);
+ float n3 = fbm(uv * 4.40 + 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;
+ color = mix(color, u_nebula_a, pow(n1, 1.5) * 0.80);
+ color = mix(color, u_nebula_b, pow(n2, 1.85) * 0.62);
+ color += u_nebula_a * pow(n3, 3.0) * 0.28;
+ // Viñeta radial.
float r = length(v_clip);
- color *= 1.0 - smoothstep(0.55, 1.35, r) * 0.85;
+ color *= 1.0 - smoothstep(0.55, 1.40, 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;
+ // === ESTRELLAS — 3 estratos con titilación distinta ===
+ // Brillantes, pocas, titilan rápido.
+ {
+ vec2 sgrid = uv * 75.0;
+ vec2 sid = floor(sgrid);
+ float sh = hash21(sid);
+ float tw = 0.45 + 0.55 * sin(u_time * 2.6 + sh * 41.0);
+ float mask = smoothstep(0.9935, 0.999, sh);
+ color += u_stardust * mask * tw * 1.15;
+ }
+ // Medianas, densas, titilan lento.
+ {
+ vec2 sgrid = uv * 135.0 + vec2(7.0, 11.0);
+ vec2 sid = floor(sgrid);
+ float sh = hash21(sid);
+ float tw = 0.55 + 0.45 * sin(u_time * 1.1 + sh * 28.0);
+ float mask = smoothstep(0.987, 0.994, sh);
+ color += u_stardust * mask * tw * 0.75;
+ }
+ // Polvo de fondo, muchas, casi sin twinkle.
+ {
+ vec2 sgrid = uv * 260.0 + vec2(13.0, 3.0);
+ vec2 sid = floor(sgrid);
+ float sh = hash21(sid);
+ float tw = 0.7 + 0.3 * sin(u_time * 0.5 + sh * 15.0);
+ float mask = smoothstep(0.982, 0.989, sh);
+ color += u_stardust * mask * tw * 0.40;
+ }
- // 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;
+ // === METEOROS (4 procedurales, respawn independiente) ===
+ float meteors = 0.0;
+ meteors += meteor(uv, 0.31);
+ meteors += meteor(uv, 1.73);
+ meteors += meteor(uv, 4.29);
+ meteors += meteor(uv, 7.11);
+ color += vec3(1.0, 0.94, 0.78) * meteors * 1.1;
fragColor = vec4(color, 1.0);
}
@@ -110,8 +174,9 @@ void main() {
}
";
-/// Fragment de la chacana: SDF de la cruz escalonada + glow + aro + sol pulsante.
-/// Uniforms: `u_time`, `u_thickness`, `u_arm_extent`,
+/// Fragment de la chacana mística: SDF de 2 escalones por brazo,
+/// líneas glow + aro + rayos zodiacales + sol central pulsante.
+/// Uniforms: `u_time`, `u_thickness` (s), `u_center_half` (c), `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;
@@ -119,6 +184,7 @@ in vec2 v_world;
out vec4 fragColor;
uniform float u_time;
uniform float u_thickness;
+uniform float u_center_half;
uniform float u_arm_extent;
uniform vec3 u_line_color;
uniform vec3 u_rim_color;
@@ -129,65 +195,90 @@ 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)));
+
+// Chacana de 2 escalones (mística clásica): centro 2c×2c + 4 brazos
+// con 2 niveles. Inner level half-width = 2s, outer (tip) = s.
+float sdChacana(vec2 p, float s, float c) {
+ float d = sdBox(p, vec2(c, c));
+ float hd = 0.5 * s;
+ // Nivel interno (más ancho, pegado al centro).
+ float mid1 = c + 0.5 * s;
+ float hw1 = 2.0 * s;
+ d = min(d, sdBox(p - vec2(0.0, mid1), vec2(hw1, hd))); // N
+ d = min(d, sdBox(p - vec2(0.0, -mid1), vec2(hw1, hd))); // S
+ d = min(d, sdBox(p - vec2( mid1, 0.0), vec2(hd, hw1))); // E
+ d = min(d, sdBox(p - vec2(-mid1, 0.0), vec2(hd, hw1))); // W
+ // Punta (más angosta, externa).
+ float mid2 = c + 1.5 * s;
+ float hw2 = 1.0 * s;
+ d = min(d, sdBox(p - vec2(0.0, mid2), vec2(hw2, hd)));
+ d = min(d, sdBox(p - vec2(0.0, -mid2), vec2(hw2, hd)));
+ d = min(d, sdBox(p - vec2( mid2, 0.0), vec2(hd, hw2)));
+ d = min(d, sdBox(p - vec2(-mid2, 0.0), vec2(hd, hw2)));
return d;
}
void main() {
vec2 p = v_world;
- float d = sdChacana(p, u_thickness, u_arm_extent);
+ float d = sdChacana(p, u_thickness, u_center_half);
+ float r = length(p);
- // Línea: gaussiana alrededor del borde.
- float lineW = 0.013;
+ // Línea principal: gaussiana sobre el borde de la chacana.
+ float lineW = 0.011;
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;
+ // Glow exterior cae suave hacia el infinito.
+ float glow = exp(-max(d, 0.0) * 8.0) * 0.55;
- // Fill interior tenue (ligera niebla cyan dentro).
+ // Fill interior, una niebla cyan muy tenue.
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;
+ // Aro circular que envuelve la chacana (rasgo del logo).
+ float ringR_outer = u_arm_extent * 1.32;
+ float ringD_outer = abs(r - ringR_outer);
+ float ring_outer = exp(-(ringD_outer * ringD_outer) / (2.0 * 0.008 * 0.008)) * 0.80;
- // Rayos sutiles (12 divisiones del círculo, como husillos del calendario).
+ // Aro interior fino (segundo orbital).
+ float ringR_inner = u_arm_extent * 1.18;
+ float ringD_inner = abs(r - ringR_inner);
+ float ring_inner = exp(-(ringD_inner * ringD_inner) / (2.0 * 0.0035 * 0.0035)) * 0.42;
+
+ // Ventana radial entre arm_extent y el aro exterior — para rayos y muescas.
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));
+ float band = smoothstep(u_arm_extent * 1.00, u_arm_extent * 1.10, r)
+ * (1.0 - smoothstep(ringR_outer * 0.92, ringR_outer * 1.00, r));
+
+ // Rayos: 12 divisiones (meses andinos / horas), modulados en el tiempo.
+ float rays = pow(abs(cos(ang * 6.0)), 24.0) * band
+ * (0.55 + 0.45 * sin(u_time * 0.7));
+
+ // Marcas cardinales (4 muescas finas) — exponente alto = picos angostos.
+ float card = pow(abs(cos(ang * 2.0)), 120.0) * band * 1.10;
// Sol central: gauss tight + corona suave + pulso.
- float sunR = u_thickness * 0.55;
- float sunDist = length(p);
+ float sunR = u_thickness * 0.50;
+ float sunDist = r;
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);
+ float corR = sunR * 5.0;
+ float corona = exp(-(sunDist * sunDist) / (2.0 * corR * corR)) * 0.50;
+ float sunMix = sun * (1.0 + 0.2 * u_sun_pulse) + corona * (0.7 + 0.3 * u_sun_pulse);
+
+ // Halo del centro: cuadrado oscuro detrás de la chacana para profundidad.
+ float coreShadow = smoothstep(u_center_half * 0.95, u_center_half * 0.3, max(abs(p.x), abs(p.y))) * 0.20;
vec3 col = vec3(0.0);
- col += u_line_color * line * 1.45;
+ col += u_line_color * line * 1.55;
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;
+ col += u_line_color * ring_outer * 1.00;
+ col += u_rim_color * ring_inner * 1.15;
+ col += u_rim_color * rays * 1.20;
+ col += u_line_color * card * 1.30;
+ col += u_sun_color * sunMix * 1.45;
+ col += vec3(0.05, 0.08, 0.14) * (fill + coreShadow) * 0.6;
- float alpha = clamp(line * 1.2 + glow + ring + rays + sunMix + fill * 0.5, 0.0, 1.0);
+ float alpha = clamp(
+ line * 1.2 + glow + ring_outer + ring_inner + rays + card + sunMix + fill * 0.5,
+ 0.0, 1.0);
fragColor = vec4(col, alpha);
}
";
@@ -197,8 +288,10 @@ 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.
+/// Quad ligeramente mayor que la chacana para no recortar aros ni glow.
+/// `arm_extent` es la distancia centro→punta; multiplicamos por un factor
+/// que cubre el aro exterior (1.32×) más halo.
pub fn chacana_quad(arm_extent: f32) -> [f32; 12] {
- let e = arm_extent * 1.45;
+ let e = arm_extent * 1.65;
[-e, -e, e, -e, e, e, -e, -e, e, e, -e, e]
}