tawasuyu

tawasuyu/brahman

Rust 225TOML 67JSON 14HTML 12842 Definitions

feat(gioser): chacana mística stepped, nubes/estrellas/meteoros, tilt 35°

- gioser-geom: ChacanaSpec paramétrica con `steps` (default 2). bounding box
  cuadrado (no cruz alargada), centro 6s×6s, brazos cortos de 2 niveles que
  adelgazan hacia la punta. arm_extent = 0.65 con thickness=0.13.
- gioser-shaders: nubes FBM 5× más rápidas, 3 estratos de estrellas con
  twinkle independiente, 4 meteoros procedurales con cola/cabeza y vida
  cíclica. Chacana SDF rediseñada para 2 escalones, aro doble (interior +
  exterior), 12 rayos angulares y 4 marcas cardinales animadas.
- gioser-canvas-web: MAX_TILT 22°→35°, WORLD_SCALE 0.92→1.45, spring
  1.8 Hz / ζ=0.62 (más languido). uniform `u_center_half` agregado.
  Las puntas DOM se desplazan visiblemente con el tilt.
- README: fix wasm-bindgen-cli 0.2.99 → 0.2.121 + `--locked`.

13 tests pasan (6 geom + 4 palette + 3 physics).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

e701df4a2bc2d97e13d7d0ec218e8032bc72702a · by sergio · (5 months ago) · parent d1ce4c897 · browse the tree at this commit

Semantic delta · 27

crates/modules/gioser/gioser-canvas-web/src/lib.rs

crates/modules/gioser/gioser-geom/src/lib.rs

crates/modules/gioser/gioser-shaders/src/lib.rs

4 files · +421 −194

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
+# Una sola vez:
+cargo install wasm-bindgen-cli --version 0.2.121 --locked
+```
+
+> 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.
 
-# Build:
-cargo build -p gioser-web --release --target wasm32-unknown-unknown
+- Un static server para probar local: `python3 -m http.server` alcanza.
 
-# 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):
+## 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
-# → http://localhost: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
 ```
 
-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.
+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 `<link>` 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 `<a href="#aire|fuego|tierra|agua">` 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 <X.Y.Z>`
+donde `<X.Y.Z>` 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 }
+    }
+
+    /// 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
     }
 
-    /// Las cuatro puntas cardinales en orden `[N, E, S, W]`.
-    /// Coordenadas listas para anclar UI sobre la chacana.
+    /// 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;
-
-    // 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;
+    color *= 1.0 - smoothstep(0.55, 1.40, r) * 0.85;
+
+    // === 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;
+    }
+
+    // === 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]
 }