e8f97b50cb
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>
566 lines
21 KiB
Rust
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)
|
|
}
|