tawasuyu

tawasuyu/brahman

Rust 506TOML 198Markdown 56JSON 197212 Definitions

⎇ fce630c8d0a50843b6d447531efb106a627fd1c7 crates/modules/gioser/gioser-canvas-web/src/lib.rs Rust · 355 lines · 12.3 KB History Who touched what raw

feat(gioser): sol detrás, título central, drawers MD + pluma agnóstico fce630c8d · sergio ·

1//! Renderer WebGL2 que compone geometría + física + paleta + shaders en pantalla.
2//!
3//! Es agnóstico del DOM: el caller monta el `<canvas>`, le pasa eventos
4//! de mouse y llama `render(time_ms)` desde un `requestAnimationFrame`.
5//!
6//! ```ignore
7//! let mut r = Renderer::new(&canvas)?;
8//! r.resize(w, h);
9//! r.set_mouse_px(dx, dy);
10//! r.render(time_ms);
11//! ```
12
13use gioser_geom::ChacanaSpec;
14use gioser_palette::{cosmos, Rgb};
15use gioser_physics::SpringDamper2;
16use gioser_shaders::{
17    chacana_quad, FS_CHACANA, FS_COSMOS, FULLSCREEN_QUAD, VS_CHACANA, VS_FULLSCREEN,
18};
19use glam::{Mat4, Vec3, Vec4};
20use std::collections::HashMap;
21use wasm_bindgen::{JsCast, JsValue};
22use web_sys::{
23    HtmlCanvasElement, WebGl2RenderingContext as GL, WebGlProgram, WebGlShader,
24    WebGlUniformLocation, WebGlVertexArrayObject,
25};
26
27const RAD: f32 = core::f32::consts::PI / 180.0;
28const DEG: f32 = 180.0 / core::f32::consts::PI;
29/// Inclinación máxima en cada eje. 28° = movimiento bien legible pero
30/// no caricaturesco; la chacana se siente "pesada y noble".
31const MAX_TILT_DEG: f32 = 28.0;
32/// Escala mundo→viewport: con arm_extent=0.65 + aro a 1.45×, la chacana
33/// + aro entran cómodos con margen para botones DOM más allá del aro.
34const WORLD_SCALE: f32 = 1.05;
35
36/// Identidad de cada cardinal (id, color de acento, label visible).
37/// Orden `[N, E, S, W]` coincide con `ChacanaSpec::tips()`.
38pub mod tips {
39    use gioser_palette::{elements, Rgb};
40    pub const ORDER: [(&str, Rgb, &str); 4] = [
41        ("aire", elements::AIRE, "AIRE"),       // N
42        ("fuego", elements::FUEGO, "FUEGO"),    // E
43        ("tierra", elements::TIERRA, "TIERRA"), // S
44        ("agua", elements::AGUA, "AGUA"),       // W
45    ];
46}
47
48pub struct Renderer {
49    gl: GL,
50    cosmos_prog: Program,
51    chacana_prog: Program,
52    cosmos_vao: WebGlVertexArrayObject,
53    chacana_vao: WebGlVertexArrayObject,
54    chacana_quad_count: i32,
55    chacana: ChacanaSpec,
56    tilt: SpringDamper2,
57    sun_pulse: f32,
58    last_time_ms: f64,
59    viewport: (u32, u32),
60    /// Mouse en clip-space, x ∈ [-aspect, aspect], y ∈ [-1, 1].
61    mouse: (f32, f32),
62}
63
64struct Program {
65    program: WebGlProgram,
66    uniforms: HashMap<&'static str, WebGlUniformLocation>,
67}
68
69impl Program {
70    fn new(gl: &GL, vs: &str, fs: &str, names: &[&'static str]) -> Result<Self, String> {
71        let vs = compile_shader(gl, GL::VERTEX_SHADER, vs)?;
72        let fs = compile_shader(gl, GL::FRAGMENT_SHADER, fs)?;
73        let program = gl.create_program().ok_or("create_program failed")?;
74        gl.attach_shader(&program, &vs);
75        gl.attach_shader(&program, &fs);
76        gl.bind_attrib_location(&program, 0, "a_pos");
77        gl.link_program(&program);
78        let linked = gl
79            .get_program_parameter(&program, GL::LINK_STATUS)
80            .as_bool()
81            .unwrap_or(false);
82        if !linked {
83            return Err(gl
84                .get_program_info_log(&program)
85                .unwrap_or_else(|| "link failed".into()));
86        }
87        let mut uniforms = HashMap::new();
88        for n in names {
89            if let Some(loc) = gl.get_uniform_location(&program, n) {
90                uniforms.insert(*n, loc);
91            }
92        }
93        Ok(Self { program, uniforms })
94    }
95
96    fn u(&self, name: &'static str) -> Option<&WebGlUniformLocation> {
97        self.uniforms.get(name)
98    }
99}
100
101fn compile_shader(gl: &GL, ty: u32, src: &str) -> Result<WebGlShader, String> {
102    let s = gl.create_shader(ty).ok_or("create_shader failed")?;
103    gl.shader_source(&s, src);
104    gl.compile_shader(&s);
105    let ok = gl
106        .get_shader_parameter(&s, GL::COMPILE_STATUS)
107        .as_bool()
108        .unwrap_or(false);
109    if !ok {
110        return Err(gl
111            .get_shader_info_log(&s)
112            .unwrap_or_else(|| "compile failed".into()));
113    }
114    Ok(s)
115}
116
117fn upload_quad(
118    gl: &GL,
119    verts: &[f32],
120    attr_loc: u32,
121) -> Result<(WebGlVertexArrayObject, i32), String> {
122    let vao = gl
123        .create_vertex_array()
124        .ok_or("create_vertex_array failed")?;
125    gl.bind_vertex_array(Some(&vao));
126    let buf = gl.create_buffer().ok_or("create_buffer failed")?;
127    gl.bind_buffer(GL::ARRAY_BUFFER, Some(&buf));
128    // SAFETY: `Float32Array::view` apunta a memoria WASM lineal; no la
129    // movemos durante este scope (no hay allocs intermedias).
130    unsafe {
131        let view = js_sys::Float32Array::view(verts);
132        gl.buffer_data_with_array_buffer_view(GL::ARRAY_BUFFER, &view, GL::STATIC_DRAW);
133    }
134    gl.vertex_attrib_pointer_with_i32(attr_loc, 2, GL::FLOAT, false, 0, 0);
135    gl.enable_vertex_attrib_array(attr_loc);
136    gl.bind_vertex_array(None);
137    Ok((vao, (verts.len() / 2) as i32))
138}
139
140impl Renderer {
141    pub fn new(canvas: &HtmlCanvasElement) -> Result<Self, JsValue> {
142        let gl = canvas
143            .get_context("webgl2")?
144            .ok_or_else(|| JsValue::from_str("WebGL2 no soportado"))?
145            .dyn_into::<GL>()?;
146
147        let chacana = ChacanaSpec::CLASSIC;
148
149        let cosmos_prog = Program::new(
150            &gl,
151            VS_FULLSCREEN,
152            FS_COSMOS,
153            &[
154                "u_resolution",
155                "u_time",
156                "u_parallax",
157                "u_void",
158                "u_nebula_a",
159                "u_nebula_b",
160                "u_stardust",
161            ],
162        )
163        .map_err(JsValue::from)?;
164
165        let chacana_prog = Program::new(
166            &gl,
167            VS_CHACANA,
168            FS_CHACANA,
169            &[
170                "u_mvp",
171                "u_time",
172                "u_thickness",
173                "u_center_half",
174                "u_arm_extent",
175                "u_line_color",
176                "u_rim_color",
177                "u_sun_color",
178                "u_dark_color",
179                "u_sun_pulse",
180            ],
181        )
182        .map_err(JsValue::from)?;
183
184        let (cosmos_vao, _) = upload_quad(&gl, &FULLSCREEN_QUAD, 0).map_err(JsValue::from)?;
185        let chacana_quad_verts = chacana_quad(chacana.arm_extent());
186        let (chacana_vao, chacana_quad_count) =
187            upload_quad(&gl, &chacana_quad_verts, 0).map_err(JsValue::from)?;
188
189        let tilt = SpringDamper2::new(1.7, 0.65);
190
191        Ok(Self {
192            gl,
193            cosmos_prog,
194            chacana_prog,
195            cosmos_vao,
196            chacana_vao,
197            chacana_quad_count,
198            chacana,
199            tilt,
200            sun_pulse: 0.0,
201            last_time_ms: 0.0,
202            viewport: (canvas.width().max(1), canvas.height().max(1)),
203            mouse: (0.0, 0.0),
204        })
205    }
206
207    pub fn resize(&mut self, w: u32, h: u32) {
208        self.viewport = (w.max(1), h.max(1));
209        self.gl
210            .viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
211    }
212
213    pub fn set_mouse_px(&mut self, x: f32, y: f32) {
214        let (w, h) = self.viewport;
215        if h == 0 {
216            return;
217        }
218        let aspect = w as f32 / h as f32;
219        let half_h = h as f32 * 0.5;
220        let mx = (x / half_h).clamp(-aspect, aspect);
221        let my = (y / half_h).clamp(-1.0, 1.0);
222        self.mouse = (mx, my);
223        let max_tilt = MAX_TILT_DEG * RAD;
224        let target = [my * max_tilt, -mx * max_tilt / aspect];
225        self.tilt.set_target(target);
226    }
227
228    /// Posición proyectada NDC de cada tip cardinal `[N, E, S, W]`.
229    pub fn tips_ndc(&self) -> [(f32, f32); 4] {
230        self.points_ndc(&self.chacana.tips())
231    }
232
233    /// Posición NDC de un punto en cualquier radio cardinal (factor sobre
234    /// `arm_extent`). Útil para anclar los botones DOM más allá de la chacana
235    /// pero dentro del aro.
236    pub fn cardinal_positions_ndc(&self, radius_factor: f32) -> [(f32, f32); 4] {
237        let r = self.chacana.arm_extent() * radius_factor;
238        self.points_ndc(&[(0.0, r), (r, 0.0), (0.0, -r), (-r, 0.0)])
239    }
240
241    fn points_ndc(&self, pts: &[(f32, f32); 4]) -> [(f32, f32); 4] {
242        let mvp = self.build_mvp();
243        let mut out = [(0.0_f32, 0.0_f32); 4];
244        for (i, t) in pts.iter().enumerate() {
245            let p = mvp * Vec4::new(t.0, t.1, 0.0, 1.0);
246            let w = if p.w == 0.0 { 1.0 } else { p.w };
247            out[i] = (p.x / w, p.y / w);
248        }
249        out
250    }
251
252    pub fn chacana(&self) -> &ChacanaSpec {
253        &self.chacana
254    }
255
256    pub fn mouse_clip(&self) -> (f32, f32) {
257        self.mouse
258    }
259
260    /// Devuelve `(pitch_deg, yaw_deg)` actuales del spring de tilt.
261    /// El caller los inyecta como CSS vars en el contenedor del título para
262    /// que el HTML se tumbe junto con la chacana renderizada en GL.
263    pub fn tilt_degrees(&self) -> (f32, f32) {
264        (self.tilt.position[0] * DEG, self.tilt.position[1] * DEG)
265    }
266
267    fn build_mvp(&self) -> Mat4 {
268        let (w, h) = self.viewport;
269        let aspect = w as f32 / h as f32;
270        let proj = Mat4::perspective_rh(45.0_f32.to_radians(), aspect, 0.1, 20.0);
271        let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 2.6), Vec3::ZERO, Vec3::Y);
272        let pitch = Mat4::from_rotation_x(self.tilt.position[0]);
273        let yaw = Mat4::from_rotation_y(self.tilt.position[1]);
274        let scale = Mat4::from_scale(Vec3::splat(WORLD_SCALE));
275        proj * view * yaw * pitch * scale
276    }
277
278    pub fn render(&mut self, time_ms: f64) {
279        let dt = if self.last_time_ms == 0.0 {
280            1.0 / 60.0
281        } else {
282            ((time_ms - self.last_time_ms) as f32 / 1000.0).clamp(0.0, 1.0 / 15.0)
283        };
284        self.last_time_ms = time_ms;
285        let sub = 4;
286        let sub_dt = dt / sub as f32;
287        for _ in 0..sub {
288            self.tilt.step(sub_dt);
289        }
290        let t = time_ms as f32 * 0.001;
291        self.sun_pulse = 0.5 + 0.5 * (t * 1.4).sin();
292
293        let gl = &self.gl;
294        gl.viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
295        gl.disable(GL::DEPTH_TEST);
296        gl.enable(GL::BLEND);
297        gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA);
298        gl.clear_color(0.02, 0.015, 0.04, 1.0);
299        gl.clear(GL::COLOR_BUFFER_BIT);
300
301        // Cosmos
302        gl.use_program(Some(&self.cosmos_prog.program));
303        if let Some(u) = self.cosmos_prog.u("u_resolution") {
304            gl.uniform2f(Some(u), self.viewport.0 as f32, self.viewport.1 as f32);
305        }
306        if let Some(u) = self.cosmos_prog.u("u_time") {
307            gl.uniform1f(Some(u), t);
308        }
309        if let Some(u) = self.cosmos_prog.u("u_parallax") {
310            gl.uniform2f(Some(u), self.mouse.0, self.mouse.1);
311        }
312        upload_rgb(gl, self.cosmos_prog.u("u_void"), cosmos::VOID);
313        upload_rgb(gl, self.cosmos_prog.u("u_nebula_a"), cosmos::NEBULA_A);
314        upload_rgb(gl, self.cosmos_prog.u("u_nebula_b"), cosmos::NEBULA_B);
315        upload_rgb(gl, self.cosmos_prog.u("u_stardust"), cosmos::STARDUST);
316        gl.bind_vertex_array(Some(&self.cosmos_vao));
317        gl.draw_arrays(GL::TRIANGLES, 0, 6);
318
319        // Chacana (blend aditivo para que dorado y sol sumen luz al cosmos)
320        gl.blend_func(GL::SRC_ALPHA, GL::ONE);
321        gl.use_program(Some(&self.chacana_prog.program));
322        let mvp = self.build_mvp();
323        if let Some(u) = self.chacana_prog.u("u_mvp") {
324            gl.uniform_matrix4fv_with_f32_array(Some(u), false, &mvp.to_cols_array());
325        }
326        if let Some(u) = self.chacana_prog.u("u_time") {
327            gl.uniform1f(Some(u), t);
328        }
329        if let Some(u) = self.chacana_prog.u("u_thickness") {
330            gl.uniform1f(Some(u), self.chacana.thickness);
331        }
332        if let Some(u) = self.chacana_prog.u("u_center_half") {
333            gl.uniform1f(Some(u), self.chacana.center_half());
334        }
335        if let Some(u) = self.chacana_prog.u("u_arm_extent") {
336            gl.uniform1f(Some(u), self.chacana.arm_extent());
337        }
338        upload_rgb(gl, self.chacana_prog.u("u_line_color"), cosmos::CHACANA_LINE);
339        upload_rgb(gl, self.chacana_prog.u("u_rim_color"), cosmos::CHACANA_RIM);
340        upload_rgb(gl, self.chacana_prog.u("u_sun_color"), cosmos::SUN_CORE);
341        upload_rgb(gl, self.chacana_prog.u("u_dark_color"), cosmos::CHACANA_DARK);
342        if let Some(u) = self.chacana_prog.u("u_sun_pulse") {
343            gl.uniform1f(Some(u), self.sun_pulse);
344        }
345        gl.bind_vertex_array(Some(&self.chacana_vao));
346        gl.draw_arrays(GL::TRIANGLES, 0, self.chacana_quad_count);
347        gl.bind_vertex_array(None);
348    }
349}
350
351fn upload_rgb(gl: &GL, loc: Option<&WebGlUniformLocation>, c: Rgb) {
352    if let Some(u) = loc {
353        gl.uniform3f(Some(u), c.0, c.1, c.2);
354    }
355}