tawasuyu

tawasuyu/brahman

Rust 320TOML 122JSON 14Markdown 114164 Sutichaykuna

⎇ e8f97b50cb24848ab7b1634a78c0f84ec468a762 crates/modules/gioser/gioser-canvas-web/src/lib.rs Rust · 565 siq'ikuna · 20.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(gioser): luna con textura rica + terminador curvo, auras anchas, overlay nubes e8f97b50c · sergio ·

1//! Renderer WebGL2 que compone geometría + física + paleta + shaders en pantalla.
2//!
3//! El loop externo (típicamente `requestAnimationFrame`) llama `render(time_ms)`.
4//! Los eventos input se propagan vía métodos: `set_mouse_px`, `release_tilt`,
5//! `impulse_click`. El cliente puede consultar dimensiones derivadas
6//! (`click_radius_css_px`, `tilt_degrees`, `cardinal_positions_ndc`) para
7//! sincronizar DOM (botones, título, taskbar).
8
9use gioser_geom::ChacanaSpec;
10use gioser_palette::{cosmos, Rgb};
11use gioser_physics::{SpringDamper1, SpringDamper2};
12use gioser_shaders::{
13    chacana_quad, FS_CHACANA, FS_COSMOS, FS_OVERLAY_CLOUDS, FULLSCREEN_QUAD, VS_CHACANA,
14    VS_FULLSCREEN,
15};
16use glam::{Mat4, Vec3, Vec4};
17use std::collections::HashMap;
18use wasm_bindgen::{JsCast, JsValue};
19use web_sys::{
20    HtmlCanvasElement, WebGl2RenderingContext as GL, WebGlProgram, WebGlShader,
21    WebGlUniformLocation, WebGlVertexArrayObject,
22};
23
24const RAD: f32 = core::f32::consts::PI / 180.0;
25const DEG: f32 = 180.0 / core::f32::consts::PI;
26/// Inclinación máxima en cada eje.
27const MAX_TILT_DEG: f32 = 28.0;
28/// `cot(45°/2)` — factor de proyección. Lo necesitamos también para calcular
29/// el radio del círculo en pixels (hit-test del click).
30const COT_HALF_FOV: f32 = 2.414_213_5;
31/// Distancia del aro principal respecto al centro de la chacana — sincronizar
32/// con `FS_CHACANA::ringR_main` del shader.
33const RING_FACTOR: f32 = 1.45;
34
35/// Duración en segundos de cada "cuerpo central" (sol / luna / tierra)
36/// antes de empezar a transicionar al siguiente. ~45 s = ~10 pulsos del
37/// sol (`sin(t*1.4)` período ≈ 4.5 s).
38const BODY_PHASE_SECS: f32 = 45.0;
39/// Duración del cross-fade entre cuerpos. Más alto = transiciones más
40/// graduales.
41const BODY_TRANSITION_SECS: f32 = 4.0;
42/// Cantidad de cuerpos en el ciclo: sol(0), luna(1), tierra(2).
43const BODY_COUNT: i32 = 3;
44
45/// Identidad de cada cardinal (id, color de acento, label). Orden `[N, E, S, W]`.
46pub mod tips {
47    use gioser_palette::{elements, Rgb};
48    pub const ORDER: [(&str, Rgb, &str); 4] = [
49        ("aire", elements::AIRE, "AIRE"),
50        ("fuego", elements::FUEGO, "FUEGO"),
51        ("tierra", elements::TIERRA, "TIERRA"),
52        ("agua", elements::AGUA, "AGUA"),
53    ];
54}
55
56/// Colores zodiacales en orden Aries→Piscis. Sigue la asignación tradicional
57/// por triplicidad elemental:
58///   fuego: aries, leo, sagitario     (rojo, dorado, púrpura)
59///   tierra: tauro, virgo, capricornio (verde, marrón, verde oscuro)
60///   aire: géminis, libra, acuario     (amarillo, rosa, celeste)
61///   agua: cáncer, escorpio, piscis    (plata, rojo profundo, verde mar)
62///
63/// El shader los recibe como `uniform vec3 u_zodiac[12]` y los dibuja como
64/// trazos radiales muy sutiles entre la chacana y el aro exterior.
65pub const ZODIAC_COLORS: [[f32; 3]; 12] = [
66    [0.95, 0.30, 0.20], // 0 Aries — fuego rojo
67    [0.35, 0.65, 0.30], // 1 Tauro — tierra verde
68    [0.95, 0.85, 0.30], // 2 Géminis — aire amarillo
69    [0.80, 0.88, 0.95], // 3 Cáncer — agua plata
70    [0.98, 0.65, 0.20], // 4 Leo — fuego dorado
71    [0.62, 0.50, 0.32], // 5 Virgo — tierra marrón
72    [0.95, 0.65, 0.82], // 6 Libra — aire rosa
73    [0.55, 0.15, 0.22], // 7 Escorpio — agua rojo profundo
74    [0.60, 0.30, 0.85], // 8 Sagitario — fuego púrpura
75    [0.22, 0.45, 0.28], // 9 Capricornio — tierra verde oscuro
76    [0.48, 0.78, 0.95], // 10 Acuario — aire celeste
77    [0.22, 0.72, 0.62], // 11 Piscis — agua verde mar
78];
79
80pub struct Renderer {
81    gl: GL,
82    cosmos_prog: Program,
83    chacana_prog: Program,
84    overlay_prog: Program,
85    cosmos_vao: WebGlVertexArrayObject,
86    chacana_vao: WebGlVertexArrayObject,
87    chacana_quad_count: i32,
88    chacana: ChacanaSpec,
89    /// Spring del tilt 3D que sigue al mouse. Sub-crítico orgánico.
90    tilt: SpringDamper2,
91    /// Spring de "vibración" tras click: rotación Z bien underdamped que
92    /// decae naturalmente. Independiente del tilt.
93    shake: SpringDamper1,
94    /// Contador para alternar sentido del shake en clicks sucesivos.
95    click_count: u32,
96    sun_pulse: f32,
97    last_time_ms: f64,
98    /// Dimensiones device-pixel del canvas (lo que GL viewport usa).
99    viewport: (u32, u32),
100    /// Dimensiones CSS-pixel del canvas (lo que ven los eventos DOM).
101    client_size: (f32, f32),
102    /// Mouse en clip-space, x ∈ [-aspect, aspect], y ∈ [-1, 1].
103    mouse: (f32, f32),
104}
105
106struct Program {
107    program: WebGlProgram,
108    uniforms: HashMap<&'static str, WebGlUniformLocation>,
109}
110
111impl Program {
112    fn new(gl: &GL, vs: &str, fs: &str, names: &[&'static str]) -> Result<Self, String> {
113        let vs = compile_shader(gl, GL::VERTEX_SHADER, vs)?;
114        let fs = compile_shader(gl, GL::FRAGMENT_SHADER, fs)?;
115        let program = gl.create_program().ok_or("create_program failed")?;
116        gl.attach_shader(&program, &vs);
117        gl.attach_shader(&program, &fs);
118        gl.bind_attrib_location(&program, 0, "a_pos");
119        gl.link_program(&program);
120        let linked = gl
121            .get_program_parameter(&program, GL::LINK_STATUS)
122            .as_bool()
123            .unwrap_or(false);
124        if !linked {
125            return Err(gl
126                .get_program_info_log(&program)
127                .unwrap_or_else(|| "link failed".into()));
128        }
129        let mut uniforms = HashMap::new();
130        for n in names {
131            if let Some(loc) = gl.get_uniform_location(&program, n) {
132                uniforms.insert(*n, loc);
133            }
134        }
135        Ok(Self { program, uniforms })
136    }
137
138    fn u(&self, name: &'static str) -> Option<&WebGlUniformLocation> {
139        self.uniforms.get(name)
140    }
141}
142
143fn compile_shader(gl: &GL, ty: u32, src: &str) -> Result<WebGlShader, String> {
144    let s = gl.create_shader(ty).ok_or("create_shader failed")?;
145    gl.shader_source(&s, src);
146    gl.compile_shader(&s);
147    let ok = gl
148        .get_shader_parameter(&s, GL::COMPILE_STATUS)
149        .as_bool()
150        .unwrap_or(false);
151    if !ok {
152        return Err(gl
153            .get_shader_info_log(&s)
154            .unwrap_or_else(|| "compile failed".into()));
155    }
156    Ok(s)
157}
158
159fn upload_quad(
160    gl: &GL,
161    verts: &[f32],
162    attr_loc: u32,
163) -> Result<(WebGlVertexArrayObject, i32), String> {
164    let vao = gl
165        .create_vertex_array()
166        .ok_or("create_vertex_array failed")?;
167    gl.bind_vertex_array(Some(&vao));
168    let buf = gl.create_buffer().ok_or("create_buffer failed")?;
169    gl.bind_buffer(GL::ARRAY_BUFFER, Some(&buf));
170    // SAFETY: `Float32Array::view` apunta a memoria WASM lineal; no la
171    // movemos durante este scope (no hay allocs intermedias).
172    unsafe {
173        let view = js_sys::Float32Array::view(verts);
174        gl.buffer_data_with_array_buffer_view(GL::ARRAY_BUFFER, &view, GL::STATIC_DRAW);
175    }
176    gl.vertex_attrib_pointer_with_i32(attr_loc, 2, GL::FLOAT, false, 0, 0);
177    gl.enable_vertex_attrib_array(attr_loc);
178    gl.bind_vertex_array(None);
179    Ok((vao, (verts.len() / 2) as i32))
180}
181
182/// Devuelve el factor de escala mundo→viewport en función del aspect.
183/// Para portrait (aspect < 1), achicamos proporcionalmente para que la
184/// circunferencia exterior no se corte por los lados.
185fn world_scale_for_aspect(aspect: f32) -> f32 {
186    let base = 1.05;
187    if aspect >= 1.0 {
188        base
189    } else {
190        // En portrait, el extent visible horizontal se reduce con `aspect`.
191        // Bajamos la escala para mantener el aro entero dentro del viewport,
192        // con piso 0.45 para que no quede ridículamente pequeña.
193        (base * aspect.max(0.45)).min(base)
194    }
195}
196
197impl Renderer {
198    pub fn new(canvas: &HtmlCanvasElement) -> Result<Self, JsValue> {
199        let gl = canvas
200            .get_context("webgl2")?
201            .ok_or_else(|| JsValue::from_str("WebGL2 no soportado"))?
202            .dyn_into::<GL>()?;
203
204        let chacana = ChacanaSpec::CLASSIC;
205
206        let cosmos_prog = Program::new(
207            &gl,
208            VS_FULLSCREEN,
209            FS_COSMOS,
210            &[
211                "u_resolution",
212                "u_time",
213                "u_parallax",
214                "u_void",
215                "u_nebula_a",
216                "u_nebula_b",
217                "u_stardust",
218            ],
219        )
220        .map_err(JsValue::from)?;
221
222        let chacana_prog = Program::new(
223            &gl,
224            VS_CHACANA,
225            FS_CHACANA,
226            &[
227                "u_mvp",
228                "u_time",
229                "u_thickness",
230                "u_center_half",
231                "u_arm_extent",
232                "u_line_color",
233                "u_rim_color",
234                "u_sun_color",
235                "u_dark_color",
236                "u_aire_color",
237                "u_fuego_color",
238                "u_tierra_color",
239                "u_agua_color",
240                "u_zodiac[0]",
241                "u_sun_pulse",
242                "u_body_a",
243                "u_body_b",
244                "u_body_blend",
245            ],
246        )
247        .map_err(JsValue::from)?;
248
249        let overlay_prog = Program::new(
250            &gl,
251            VS_FULLSCREEN,
252            FS_OVERLAY_CLOUDS,
253            &["u_resolution", "u_time", "u_parallax"],
254        )
255        .map_err(JsValue::from)?;
256
257        let (cosmos_vao, _) = upload_quad(&gl, &FULLSCREEN_QUAD, 0).map_err(JsValue::from)?;
258        let chacana_quad_verts = chacana_quad(chacana.arm_extent());
259        let (chacana_vao, chacana_quad_count) =
260            upload_quad(&gl, &chacana_quad_verts, 0).map_err(JsValue::from)?;
261
262        let tilt = SpringDamper2::new(1.7, 0.65);
263        // Shake: alta frecuencia, muy underdamped → vibración fuerte que
264        // muere en ~0.8 s con varios ciclos visibles.
265        let shake = SpringDamper1::new(7.5, 0.13);
266
267        Ok(Self {
268            gl,
269            cosmos_prog,
270            chacana_prog,
271            overlay_prog,
272            cosmos_vao,
273            chacana_vao,
274            chacana_quad_count,
275            chacana,
276            tilt,
277            shake,
278            click_count: 0,
279            sun_pulse: 0.0,
280            last_time_ms: 0.0,
281            viewport: (canvas.width().max(1), canvas.height().max(1)),
282            client_size: (
283                canvas.client_width().max(1) as f32,
284                canvas.client_height().max(1) as f32,
285            ),
286            mouse: (0.0, 0.0),
287        })
288    }
289
290    pub fn resize(&mut self, w: u32, h: u32) {
291        self.viewport = (w.max(1), h.max(1));
292        self.gl
293            .viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
294    }
295
296    /// Tamaño en CSS pixels (independiente del DPR). Lo usa el hit-test del
297    /// click para que coincida con coordenadas DOM.
298    pub fn set_client_size(&mut self, w: f32, h: f32) {
299        self.client_size = (w.max(1.0), h.max(1.0));
300    }
301
302    pub fn set_mouse_px(&mut self, x: f32, y: f32) {
303        let (w, h) = self.viewport;
304        if h == 0 {
305            return;
306        }
307        let aspect = w as f32 / h as f32;
308        let half_h = h as f32 * 0.5;
309        let mx = (x / half_h).clamp(-aspect, aspect);
310        let my = (y / half_h).clamp(-1.0, 1.0);
311        self.mouse = (mx, my);
312        let max_tilt = MAX_TILT_DEG * RAD;
313        let target = [my * max_tilt, -mx * max_tilt / aspect];
314        self.tilt.set_target(target);
315    }
316
317    /// Mouse fuera del canvas — la chacana vuelve al frente con rebote
318    /// natural del spring sub-crítico.
319    pub fn release_tilt(&mut self) {
320        self.tilt.set_target([0.0, 0.0]);
321        // mouse parallax (fondo) también vuelve al centro
322        self.mouse = (0.0, 0.0);
323    }
324
325    /// Inyecta un impulso al spring shake — la chacana vibra fuerte y decae.
326    /// Llamar en respuesta a un click/tap dentro del aro.
327    pub fn impulse_click(&mut self) {
328        self.click_count = self.click_count.wrapping_add(1);
329        let dir = if self.click_count % 2 == 0 { 1.0 } else { -1.0 };
330        // Magnitud del impulso en rad/s. Con ω≈47, esto produce un pico
331        // de ~5-7° en la rotación Z, decayendo en ~0.8 s.
332        self.shake.velocity[0] += 6.5 * dir;
333    }
334
335    /// Radio del aro exterior, en CSS pixels desde el centro del canvas.
336    /// El cliente lo usa para decidir si un click cae dentro del círculo.
337    pub fn click_radius_css_px(&self) -> f32 {
338        let (w, _h) = self.viewport;
339        let aspect = w as f32 / self.viewport.1.max(1) as f32;
340        let scale = world_scale_for_aspect(aspect);
341        let ring_ndc = self.chacana.arm_extent() * RING_FACTOR * scale * COT_HALF_FOV / 2.6;
342        ring_ndc * self.client_size.1 / 2.0
343    }
344
345    /// Posición proyectada NDC de cada tip cardinal `[N, E, S, W]`.
346    pub fn tips_ndc(&self) -> [(f32, f32); 4] {
347        self.points_ndc(&self.chacana.tips())
348    }
349
350    /// Posiciones NDC para anclar botones en los 4 cardinales a un radio
351    /// específico (factor sobre `arm_extent`).
352    pub fn cardinal_positions_ndc(&self, radius_factor: f32) -> [(f32, f32); 4] {
353        let r = self.chacana.arm_extent() * radius_factor;
354        self.points_ndc(&[(0.0, r), (r, 0.0), (0.0, -r), (-r, 0.0)])
355    }
356
357    fn points_ndc(&self, pts: &[(f32, f32); 4]) -> [(f32, f32); 4] {
358        let mvp = self.build_mvp();
359        let mut out = [(0.0_f32, 0.0_f32); 4];
360        for (i, t) in pts.iter().enumerate() {
361            let p = mvp * Vec4::new(t.0, t.1, 0.0, 1.0);
362            let w = if p.w == 0.0 { 1.0 } else { p.w };
363            out[i] = (p.x / w, p.y / w);
364        }
365        out
366    }
367
368    pub fn chacana(&self) -> &ChacanaSpec {
369        &self.chacana
370    }
371
372    pub fn mouse_clip(&self) -> (f32, f32) {
373        self.mouse
374    }
375
376    /// `(pitch_deg, yaw_deg, roll_deg)` actuales. Roll viene del shake spring.
377    pub fn tilt_degrees(&self) -> (f32, f32, f32) {
378        (
379            self.tilt.position[0] * DEG,
380            self.tilt.position[1] * DEG,
381            self.shake.position[0] * DEG,
382        )
383    }
384
385    fn build_mvp(&self) -> Mat4 {
386        let (w, h) = self.viewport;
387        let aspect = w as f32 / h as f32;
388        let scale_val = world_scale_for_aspect(aspect);
389        let proj = Mat4::perspective_rh(45.0_f32.to_radians(), aspect, 0.1, 20.0);
390        let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 2.6), Vec3::ZERO, Vec3::Y);
391        let pitch = Mat4::from_rotation_x(self.tilt.position[0]);
392        let yaw = Mat4::from_rotation_y(self.tilt.position[1]);
393        let roll = Mat4::from_rotation_z(self.shake.position[0]);
394        let scale = Mat4::from_scale(Vec3::splat(scale_val));
395        proj * view * yaw * pitch * roll * scale
396    }
397
398    pub fn render(&mut self, time_ms: f64) {
399        let dt = if self.last_time_ms == 0.0 {
400            1.0 / 60.0
401        } else {
402            ((time_ms - self.last_time_ms) as f32 / 1000.0).clamp(0.0, 1.0 / 15.0)
403        };
404        self.last_time_ms = time_ms;
405
406        // Subdividir físico — el shake corre a alta frecuencia y necesita
407        // dt < 1/freq para mantenerse estable (1/7.5 ≈ 133 ms; 8 sub-pasos a
408        // 60fps dejan 2 ms por sub-paso).
409        let sub = 8;
410        let sub_dt = dt / sub as f32;
411        for _ in 0..sub {
412            self.tilt.step(sub_dt);
413            self.shake.step(sub_dt);
414        }
415
416        let t = time_ms as f32 * 0.001;
417        self.sun_pulse = 0.5 + 0.5 * (t * 1.4).sin();
418
419        let gl = &self.gl;
420        gl.viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
421        gl.disable(GL::DEPTH_TEST);
422        gl.enable(GL::BLEND);
423        gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA);
424        gl.clear_color(0.02, 0.015, 0.04, 1.0);
425        gl.clear(GL::COLOR_BUFFER_BIT);
426
427        // Cosmos
428        gl.use_program(Some(&self.cosmos_prog.program));
429        if let Some(u) = self.cosmos_prog.u("u_resolution") {
430            gl.uniform2f(Some(u), self.viewport.0 as f32, self.viewport.1 as f32);
431        }
432        if let Some(u) = self.cosmos_prog.u("u_time") {
433            gl.uniform1f(Some(u), t);
434        }
435        if let Some(u) = self.cosmos_prog.u("u_parallax") {
436            gl.uniform2f(Some(u), self.mouse.0, self.mouse.1);
437        }
438        upload_rgb(gl, self.cosmos_prog.u("u_void"), cosmos::VOID);
439        upload_rgb(gl, self.cosmos_prog.u("u_nebula_a"), cosmos::NEBULA_A);
440        upload_rgb(gl, self.cosmos_prog.u("u_nebula_b"), cosmos::NEBULA_B);
441        upload_rgb(gl, self.cosmos_prog.u("u_stardust"), cosmos::STARDUST);
442        gl.bind_vertex_array(Some(&self.cosmos_vao));
443        gl.draw_arrays(GL::TRIANGLES, 0, 6);
444
445        // Chacana (blend aditivo)
446        gl.blend_func(GL::SRC_ALPHA, GL::ONE);
447        gl.use_program(Some(&self.chacana_prog.program));
448        let mvp = self.build_mvp();
449        if let Some(u) = self.chacana_prog.u("u_mvp") {
450            gl.uniform_matrix4fv_with_f32_array(Some(u), false, &mvp.to_cols_array());
451        }
452        if let Some(u) = self.chacana_prog.u("u_time") {
453            gl.uniform1f(Some(u), t);
454        }
455        if let Some(u) = self.chacana_prog.u("u_thickness") {
456            gl.uniform1f(Some(u), self.chacana.thickness);
457        }
458        if let Some(u) = self.chacana_prog.u("u_center_half") {
459            gl.uniform1f(Some(u), self.chacana.center_half());
460        }
461        if let Some(u) = self.chacana_prog.u("u_arm_extent") {
462            gl.uniform1f(Some(u), self.chacana.arm_extent());
463        }
464        upload_rgb(gl, self.chacana_prog.u("u_line_color"), cosmos::CHACANA_LINE);
465        upload_rgb(gl, self.chacana_prog.u("u_rim_color"), cosmos::CHACANA_RIM);
466        upload_rgb(gl, self.chacana_prog.u("u_sun_color"), cosmos::SUN_CORE);
467        upload_rgb(gl, self.chacana_prog.u("u_dark_color"), cosmos::CHACANA_DARK);
468        upload_rgb(
469            gl,
470            self.chacana_prog.u("u_aire_color"),
471            gioser_palette::elements::AIRE,
472        );
473        upload_rgb(
474            gl,
475            self.chacana_prog.u("u_fuego_color"),
476            gioser_palette::elements::FUEGO,
477        );
478        upload_rgb(
479            gl,
480            self.chacana_prog.u("u_tierra_color"),
481            gioser_palette::elements::TIERRA,
482        );
483        upload_rgb(
484            gl,
485            self.chacana_prog.u("u_agua_color"),
486            gioser_palette::elements::AGUA,
487        );
488        // Subir las 12 colores zodiacales como vec3[12]. Aplanamos a un único
489        // slice de 36 floats; uniform3fv interpreta cada terna como vec3.
490        if let Some(u) = self.chacana_prog.u("u_zodiac[0]") {
491            let mut flat = [0.0f32; 36];
492            for (i, c) in ZODIAC_COLORS.iter().enumerate() {
493                flat[i * 3] = c[0];
494                flat[i * 3 + 1] = c[1];
495                flat[i * 3 + 2] = c[2];
496            }
497            gl.uniform3fv_with_f32_array(Some(u), &flat);
498        }
499        if let Some(u) = self.chacana_prog.u("u_sun_pulse") {
500            gl.uniform1f(Some(u), self.sun_pulse);
501        }
502        let (body_a, body_b, blend) = body_state(t);
503        if let Some(u) = self.chacana_prog.u("u_body_a") {
504            gl.uniform1i(Some(u), body_a);
505        }
506        if let Some(u) = self.chacana_prog.u("u_body_b") {
507            gl.uniform1i(Some(u), body_b);
508        }
509        if let Some(u) = self.chacana_prog.u("u_body_blend") {
510            gl.uniform1f(Some(u), blend);
511        }
512        gl.bind_vertex_array(Some(&self.chacana_vao));
513        gl.draw_arrays(GL::TRIANGLES, 0, self.chacana_quad_count);
514
515        // ---- Capa overlay de nubes (apenas visible, encima de todo) ----
516        // blend normal alpha — las nubes COMPONEN sobre la escena, no suman luz.
517        gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA);
518        gl.use_program(Some(&self.overlay_prog.program));
519        if let Some(u) = self.overlay_prog.u("u_resolution") {
520            gl.uniform2f(Some(u), self.viewport.0 as f32, self.viewport.1 as f32);
521        }
522        if let Some(u) = self.overlay_prog.u("u_time") {
523            gl.uniform1f(Some(u), t);
524        }
525        if let Some(u) = self.overlay_prog.u("u_parallax") {
526            gl.uniform2f(Some(u), self.mouse.0, self.mouse.1);
527        }
528        gl.bind_vertex_array(Some(&self.cosmos_vao));
529        gl.draw_arrays(GL::TRIANGLES, 0, 6);
530
531        gl.bind_vertex_array(None);
532    }
533}
534
535fn upload_rgb(gl: &GL, loc: Option<&WebGlUniformLocation>, c: Rgb) {
536    if let Some(u) = loc {
537        gl.uniform3f(Some(u), c.0, c.1, c.2);
538    }
539}
540
541/// Devuelve `(body_a, body_b, blend)` para el tiempo `t` (segundos).
542/// El ciclo es sol → luna → tierra → sol → ... cada uno estable por
543/// `BODY_PHASE_SECS` y con `BODY_TRANSITION_SECS` de cross-fade al
544/// siguiente.
545fn body_state(t: f32) -> (i32, i32, f32) {
546    let slot = BODY_PHASE_SECS + BODY_TRANSITION_SECS;
547    let cycle = slot * BODY_COUNT as f32;
548    let pt = t.rem_euclid(cycle).max(0.0);
549    let mut acc = 0.0_f32;
550    for i in 0..BODY_COUNT {
551        let stable_end = acc + BODY_PHASE_SECS;
552        if pt < stable_end {
553            return (i, i, 0.0);
554        }
555        let trans_end = stable_end + BODY_TRANSITION_SECS;
556        if pt < trans_end {
557            let blend = ((pt - stable_end) / BODY_TRANSITION_SECS).clamp(0.0, 1.0);
558            // Smoothstep para una curva más natural que linear lerp.
559            let s = blend * blend * (3.0 - 2.0 * blend);
560            return (i, (i + 1) % BODY_COUNT, s);
561        }
562        acc = trans_end;
563    }
564    (0, 0, 0.0)
565}