⎇ e8f97b50cb24848ab7b1634a78c0f84ec468a762
crates/modules/gioser/gioser-canvas-web/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(gioser): luna con textura rica + terminador curvo, auras anchas, overlay nubes
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}