Files
brahman/crates/modules/gioser/gioser-canvas-web/src/lib.rs
T
sergio e8f97b50cb feat(gioser): luna con textura rica + terminador curvo, auras anchas, overlay nubes
Luna (FS_CHACANA::render_moon):
- Normales esféricas reales: nx=p.x/R, ny=p.y/R, nz=sqrt(1-nx²-ny²).
- Terminador CURVO: dot(normal, sun_dir) donde sun_dir gira en el plano
  X-Z según la fase. Resultado: la frontera luz/sombra es una elipse
  proyectada en pantalla, como en la luna real (no una vertical recta).
- Fase lineal: phi = fract(t/40) * 2π cicla new→first-q→full→last-q→new
  en ~40s.
- Limb darkening realista: pow(nz, 0.45) — bordes más oscuros que el
  centro (el regolito lunar dispersa).
- 6 capas de textura:
    maria_n     (escala 4.5) → mares oscuros (smoothstep 0.42..0.60)
    craters_mid (escala 13)  → cráteres grandes
    craters_small (escala 28) → cráteres chicos
    fine (escala 55) → granularidad del terreno
    micro (escala 110) → polvo
    ring_mid + ring_small → crests via pow(abs(n-0.5)*2, k) → bordes
                            elevados de cráteres
  Albedo final: 0.80 + craters±0.32 + small±0.22 + fine±0.20 + micro±0.10
  + rings (+0.22, +0.16) - maria 0.50, clamp [0.10, 1.15].

Auras elementales (FS_CHACANA::element_cloud):
- sigma_along  0.42 → 0.62 (más reach hacia afuera)
- sigma_perp   0.34 → 0.62 (mucho más ancho perpendicular)
- cloud_center offset 0.22 → 0.28 (más lejos del centro)
- multiplier 0.28 → 0.26 (compensa intensidad por la mayor cobertura)
- Resultado: las nubes elementales se solapan en las esquinas NE/NW/SE/SW
  y mezclan colores. El cuadrante entero respira el color del cardinal.

Overlay clouds (FS_OVERLAY_CLOUDS — nuevo shader):
- Tercer pase tras chacana, fullscreen quad.
- blend = SRC_ALPHA / ONE_MINUS_SRC_ALPHA (compositing normal, no aditivo)
  → las nubes COMPONEN sobre la escena en lugar de sumar luz.
- Dos capas FBM (escalas 0.55 y 1.30) con parallax inverso del mouse
  (-0.05 y -0.09) — se sienten "delante" del cosmos.
- Drift más lento que las nubes del cosmos (0.020 vs 0.055), para que se
  perciban como otra capa atmosférica.
- smoothstep(0.55..0.88, 0.50..0.82) → sólo crestas se vuelven nube;
  mucho del viewport queda transparente.
- Alpha máximo 0.10 — "apenas visible" como pidió el diseño.
- Color mix gris→blanco-azul según densidad local.

Renderer (gioser-canvas-web):
- Nuevo Program overlay_prog con uniforms u_resolution/u_time/u_parallax.
- render() ahora hace 3 pases: cosmos → chacana → overlay clouds.

Workspace verde.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 04:53:05 +00:00

566 lines
21 KiB
Rust

//! 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<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);
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 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<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_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)
}