//! Renderer WebGL2 que compone geometría + física + paleta + shaders en pantalla. //! //! El loop externo (típicamente `requestAnimationFrame`) llama `render(time_ms)`. //! Los eventos input se propagan vía métodos: `set_mouse_px`, `release_tilt`, //! `impulse_click`. El cliente puede consultar dimensiones derivadas //! (`click_radius_css_px`, `tilt_degrees`, `cardinal_positions_ndc`) para //! sincronizar DOM (botones, título, taskbar). use gioser_geom::ChacanaSpec; use gioser_palette::{cosmos, Rgb}; use gioser_physics::{SpringDamper1, SpringDamper2}; use gioser_shaders::{ chacana_quad, FS_CHACANA, FS_COSMOS, FS_OVERLAY_CLOUDS, 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 DEG: f32 = 180.0 / core::f32::consts::PI; /// Inclinación máxima en cada eje. const MAX_TILT_DEG: f32 = 28.0; /// `cot(45°/2)` — factor de proyección. Lo necesitamos también para calcular /// el radio del círculo en pixels (hit-test del click). const COT_HALF_FOV: f32 = 2.414_213_5; /// Distancia del aro principal respecto al centro de la chacana — sincronizar /// con `FS_CHACANA::ringR_main` del shader. const RING_FACTOR: f32 = 1.45; /// Duración en segundos de cada "cuerpo central" (sol / luna / tierra) /// antes de empezar a transicionar al siguiente. ~45 s = ~10 pulsos del /// sol (`sin(t*1.4)` período ≈ 4.5 s). const BODY_PHASE_SECS: f32 = 45.0; /// Duración del cross-fade entre cuerpos. Más alto = transiciones más /// graduales. const BODY_TRANSITION_SECS: f32 = 4.0; /// Cantidad de cuerpos en el ciclo: sol(0), luna(1), tierra(2). const BODY_COUNT: i32 = 3; /// Identidad de cada cardinal (id, color de acento, label). Orden `[N, E, S, W]`. pub mod tips { use gioser_palette::{elements, Rgb}; pub const ORDER: [(&str, Rgb, &str); 4] = [ ("aire", elements::AIRE, "AIRE"), ("fuego", elements::FUEGO, "FUEGO"), ("tierra", elements::TIERRA, "TIERRA"), ("agua", elements::AGUA, "AGUA"), ]; } /// Colores zodiacales en orden Aries→Piscis. Sigue la asignación tradicional /// por triplicidad elemental: /// fuego: aries, leo, sagitario (rojo, dorado, púrpura) /// tierra: tauro, virgo, capricornio (verde, marrón, verde oscuro) /// aire: géminis, libra, acuario (amarillo, rosa, celeste) /// agua: cáncer, escorpio, piscis (plata, rojo profundo, verde mar) /// /// El shader los recibe como `uniform vec3 u_zodiac[12]` y los dibuja como /// trazos radiales muy sutiles entre la chacana y el aro exterior. pub const ZODIAC_COLORS: [[f32; 3]; 12] = [ [0.95, 0.30, 0.20], // 0 Aries — fuego rojo [0.35, 0.65, 0.30], // 1 Tauro — tierra verde [0.95, 0.85, 0.30], // 2 Géminis — aire amarillo [0.80, 0.88, 0.95], // 3 Cáncer — agua plata [0.98, 0.65, 0.20], // 4 Leo — fuego dorado [0.62, 0.50, 0.32], // 5 Virgo — tierra marrón [0.95, 0.65, 0.82], // 6 Libra — aire rosa [0.55, 0.15, 0.22], // 7 Escorpio — agua rojo profundo [0.60, 0.30, 0.85], // 8 Sagitario — fuego púrpura [0.22, 0.45, 0.28], // 9 Capricornio — tierra verde oscuro [0.48, 0.78, 0.95], // 10 Acuario — aire celeste [0.22, 0.72, 0.62], // 11 Piscis — agua verde mar ]; pub struct Renderer { gl: GL, cosmos_prog: Program, chacana_prog: Program, overlay_prog: Program, cosmos_vao: WebGlVertexArrayObject, chacana_vao: WebGlVertexArrayObject, chacana_quad_count: i32, chacana: ChacanaSpec, /// Spring del tilt 3D que sigue al mouse. Sub-crítico orgánico. tilt: SpringDamper2, /// Spring de "vibración" tras click: rotación Z bien underdamped que /// decae naturalmente. Independiente del tilt. shake: SpringDamper1, /// Contador para alternar sentido del shake en clicks sucesivos. click_count: u32, sun_pulse: f32, last_time_ms: f64, /// Dimensiones device-pixel del canvas (lo que GL viewport usa). viewport: (u32, u32), /// Dimensiones CSS-pixel del canvas (lo que ven los eventos DOM). client_size: (f32, f32), /// 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 { 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); 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 { 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 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); } 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)) } /// Devuelve el factor de escala mundo→viewport en función del aspect. /// Para portrait (aspect < 1), achicamos proporcionalmente para que la /// circunferencia exterior no se corte por los lados. fn world_scale_for_aspect(aspect: f32) -> f32 { let base = 1.05; if aspect >= 1.0 { base } else { // En portrait, el extent visible horizontal se reduce con `aspect`. // Bajamos la escala para mantener el aro entero dentro del viewport, // con piso 0.45 para que no quede ridículamente pequeña. (base * aspect.max(0.45)).min(base) } } impl Renderer { pub fn new(canvas: &HtmlCanvasElement) -> Result { let gl = canvas .get_context("webgl2")? .ok_or_else(|| JsValue::from_str("WebGL2 no soportado"))? .dyn_into::()?; 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_center_half", "u_arm_extent", "u_line_color", "u_rim_color", "u_sun_color", "u_dark_color", "u_aire_color", "u_fuego_color", "u_tierra_color", "u_agua_color", "u_zodiac[0]", "u_sun_pulse", "u_body_a", "u_body_b", "u_body_blend", ], ) .map_err(JsValue::from)?; let overlay_prog = Program::new( &gl, VS_FULLSCREEN, FS_OVERLAY_CLOUDS, &["u_resolution", "u_time", "u_parallax"], ) .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(1.7, 0.65); // Shake: alta frecuencia, muy underdamped → vibración fuerte que // muere en ~0.8 s con varios ciclos visibles. let shake = SpringDamper1::new(7.5, 0.13); Ok(Self { gl, cosmos_prog, chacana_prog, overlay_prog, cosmos_vao, chacana_vao, chacana_quad_count, chacana, tilt, shake, click_count: 0, sun_pulse: 0.0, last_time_ms: 0.0, viewport: (canvas.width().max(1), canvas.height().max(1)), client_size: ( canvas.client_width().max(1) as f32, canvas.client_height().max(1) as f32, ), 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); } /// Tamaño en CSS pixels (independiente del DPR). Lo usa el hit-test del /// click para que coincida con coordenadas DOM. pub fn set_client_size(&mut self, w: f32, h: f32) { self.client_size = (w.max(1.0), h.max(1.0)); } 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; let target = [my * max_tilt, -mx * max_tilt / aspect]; self.tilt.set_target(target); } /// Mouse fuera del canvas — la chacana vuelve al frente con rebote /// natural del spring sub-crítico. pub fn release_tilt(&mut self) { self.tilt.set_target([0.0, 0.0]); // mouse parallax (fondo) también vuelve al centro self.mouse = (0.0, 0.0); } /// Inyecta un impulso al spring shake — la chacana vibra fuerte y decae. /// Llamar en respuesta a un click/tap dentro del aro. pub fn impulse_click(&mut self) { self.click_count = self.click_count.wrapping_add(1); let dir = if self.click_count % 2 == 0 { 1.0 } else { -1.0 }; // Magnitud del impulso en rad/s. Con ω≈47, esto produce un pico // de ~5-7° en la rotación Z, decayendo en ~0.8 s. self.shake.velocity[0] += 6.5 * dir; } /// Radio del aro exterior, en CSS pixels desde el centro del canvas. /// El cliente lo usa para decidir si un click cae dentro del círculo. pub fn click_radius_css_px(&self) -> f32 { let (w, _h) = self.viewport; let aspect = w as f32 / self.viewport.1.max(1) as f32; let scale = world_scale_for_aspect(aspect); let ring_ndc = self.chacana.arm_extent() * RING_FACTOR * scale * COT_HALF_FOV / 2.6; ring_ndc * self.client_size.1 / 2.0 } /// Posición proyectada NDC de cada tip cardinal `[N, E, S, W]`. pub fn tips_ndc(&self) -> [(f32, f32); 4] { self.points_ndc(&self.chacana.tips()) } /// Posiciones NDC para anclar botones en los 4 cardinales a un radio /// específico (factor sobre `arm_extent`). pub fn cardinal_positions_ndc(&self, radius_factor: f32) -> [(f32, f32); 4] { let r = self.chacana.arm_extent() * radius_factor; self.points_ndc(&[(0.0, r), (r, 0.0), (0.0, -r), (-r, 0.0)]) } fn points_ndc(&self, pts: &[(f32, f32); 4]) -> [(f32, f32); 4] { let mvp = self.build_mvp(); let mut out = [(0.0_f32, 0.0_f32); 4]; for (i, t) in pts.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 } pub fn mouse_clip(&self) -> (f32, f32) { self.mouse } /// `(pitch_deg, yaw_deg, roll_deg)` actuales. Roll viene del shake spring. pub fn tilt_degrees(&self) -> (f32, f32, f32) { ( self.tilt.position[0] * DEG, self.tilt.position[1] * DEG, self.shake.position[0] * DEG, ) } fn build_mvp(&self) -> Mat4 { let (w, h) = self.viewport; let aspect = w as f32 / h as f32; let scale_val = world_scale_for_aspect(aspect); 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 roll = Mat4::from_rotation_z(self.shake.position[0]); let scale = Mat4::from_scale(Vec3::splat(scale_val)); proj * view * yaw * pitch * roll * 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; // Subdividir físico — el shake corre a alta frecuencia y necesita // dt < 1/freq para mantenerse estable (1/7.5 ≈ 133 ms; 8 sub-pasos a // 60fps dejan 2 ms por sub-paso). let sub = 8; let sub_dt = dt / sub as f32; for _ in 0..sub { self.tilt.step(sub_dt); self.shake.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) 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_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()); } 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); upload_rgb(gl, self.chacana_prog.u("u_dark_color"), cosmos::CHACANA_DARK); upload_rgb( gl, self.chacana_prog.u("u_aire_color"), gioser_palette::elements::AIRE, ); upload_rgb( gl, self.chacana_prog.u("u_fuego_color"), gioser_palette::elements::FUEGO, ); upload_rgb( gl, self.chacana_prog.u("u_tierra_color"), gioser_palette::elements::TIERRA, ); upload_rgb( gl, self.chacana_prog.u("u_agua_color"), gioser_palette::elements::AGUA, ); // Subir las 12 colores zodiacales como vec3[12]. Aplanamos a un único // slice de 36 floats; uniform3fv interpreta cada terna como vec3. if let Some(u) = self.chacana_prog.u("u_zodiac[0]") { let mut flat = [0.0f32; 36]; for (i, c) in ZODIAC_COLORS.iter().enumerate() { flat[i * 3] = c[0]; flat[i * 3 + 1] = c[1]; flat[i * 3 + 2] = c[2]; } gl.uniform3fv_with_f32_array(Some(u), &flat); } if let Some(u) = self.chacana_prog.u("u_sun_pulse") { gl.uniform1f(Some(u), self.sun_pulse); } let (body_a, body_b, blend) = body_state(t); if let Some(u) = self.chacana_prog.u("u_body_a") { gl.uniform1i(Some(u), body_a); } if let Some(u) = self.chacana_prog.u("u_body_b") { gl.uniform1i(Some(u), body_b); } if let Some(u) = self.chacana_prog.u("u_body_blend") { gl.uniform1f(Some(u), blend); } gl.bind_vertex_array(Some(&self.chacana_vao)); gl.draw_arrays(GL::TRIANGLES, 0, self.chacana_quad_count); // ---- Capa overlay de nubes (apenas visible, encima de todo) ---- // blend normal alpha — las nubes COMPONEN sobre la escena, no suman luz. gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA); gl.use_program(Some(&self.overlay_prog.program)); if let Some(u) = self.overlay_prog.u("u_resolution") { gl.uniform2f(Some(u), self.viewport.0 as f32, self.viewport.1 as f32); } if let Some(u) = self.overlay_prog.u("u_time") { gl.uniform1f(Some(u), t); } if let Some(u) = self.overlay_prog.u("u_parallax") { gl.uniform2f(Some(u), self.mouse.0, self.mouse.1); } gl.bind_vertex_array(Some(&self.cosmos_vao)); gl.draw_arrays(GL::TRIANGLES, 0, 6); 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); } } /// Devuelve `(body_a, body_b, blend)` para el tiempo `t` (segundos). /// El ciclo es sol → luna → tierra → sol → ... cada uno estable por /// `BODY_PHASE_SECS` y con `BODY_TRANSITION_SECS` de cross-fade al /// siguiente. fn body_state(t: f32) -> (i32, i32, f32) { let slot = BODY_PHASE_SECS + BODY_TRANSITION_SECS; let cycle = slot * BODY_COUNT as f32; let pt = t.rem_euclid(cycle).max(0.0); let mut acc = 0.0_f32; for i in 0..BODY_COUNT { let stable_end = acc + BODY_PHASE_SECS; if pt < stable_end { return (i, i, 0.0); } let trans_end = stable_end + BODY_TRANSITION_SECS; if pt < trans_end { let blend = ((pt - stable_end) / BODY_TRANSITION_SECS).clamp(0.0, 1.0); // Smoothstep para una curva más natural que linear lerp. let s = blend * blend * (3.0 - 2.0 * blend); return (i, (i + 1) % BODY_COUNT, s); } acc = trans_end; } (0, 0, 0.0) }