tawasuyu

tawasuyu/brahman

Rust 572TOML 217Markdown 68JSON 198326 Sutichaykuna

⎇ 52acaabcf42e81de31acf884f80c607fe8b829fe crates/modules/gioser/gioser-canvas-web/src/lib.rs Rust · 335 siq'ikuna · 11.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

gioser 52acaabcf · 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>`, pasa eventos de mouse,
4//! y llama `render(time_ms)` desde un `requestAnimationFrame`.
5//!
6//! ```ignore
7//! let mut r = Renderer::new(&canvas)?;
8//! r.resize(w, h);
9//! // por cada mousemove:
10//! r.set_mouse_px(dx, dy);
11//! // por cada frame:
12//! r.render(time_ms);
13//! ```
14//!
15//! El layout sigue el patrón de `yahweh_launcher::launch_app(title, size, factory)`:
16//! una sola línea para arrancar, escena auto-contenida. Cuando exista `yahweh-web`,
17//! este renderer es el "factory" que recibirá.
18
19use gioser_geom::ChacanaSpec;
20use gioser_palette::{cosmos, Rgb};
21use gioser_physics::SpringDamper2;
22use gioser_shaders::{
23    chacana_quad, FS_CHACANA, FS_COSMOS, FULLSCREEN_QUAD, VS_CHACANA, VS_FULLSCREEN,
24};
25use glam::{Mat4, Vec3, Vec4};
26use std::collections::HashMap;
27use wasm_bindgen::{JsCast, JsValue};
28use web_sys::{
29    HtmlCanvasElement, WebGl2RenderingContext as GL, WebGlProgram, WebGlShader,
30    WebGlUniformLocation, WebGlVertexArrayObject,
31};
32
33const RAD: f32 = core::f32::consts::PI / 180.0;
34const MAX_TILT_DEG: f32 = 22.0;
35
36/// Re-export para apps: identidad (id, color, label) de cada punta cardinal,
37/// en el orden `[N, E, S, W]` de `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        // Atributo `a_pos` siempre en location 0.
77        gl.bind_attrib_location(&program, 0, "a_pos");
78        gl.link_program(&program);
79        let linked = gl
80            .get_program_parameter(&program, GL::LINK_STATUS)
81            .as_bool()
82            .unwrap_or(false);
83        if !linked {
84            return Err(gl
85                .get_program_info_log(&program)
86                .unwrap_or_else(|| "link failed".into()));
87        }
88        let mut uniforms = HashMap::new();
89        for n in names {
90            if let Some(loc) = gl.get_uniform_location(&program, n) {
91                uniforms.insert(*n, loc);
92            }
93        }
94        Ok(Self { program, uniforms })
95    }
96
97    fn u(&self, name: &'static str) -> Option<&WebGlUniformLocation> {
98        self.uniforms.get(name)
99    }
100}
101
102fn compile_shader(gl: &GL, ty: u32, src: &str) -> Result<WebGlShader, String> {
103    let s = gl.create_shader(ty).ok_or("create_shader failed")?;
104    gl.shader_source(&s, src);
105    gl.compile_shader(&s);
106    let ok = gl
107        .get_shader_parameter(&s, GL::COMPILE_STATUS)
108        .as_bool()
109        .unwrap_or(false);
110    if !ok {
111        return Err(gl
112            .get_shader_info_log(&s)
113            .unwrap_or_else(|| "compile failed".into()));
114    }
115    Ok(s)
116}
117
118fn upload_quad(
119    gl: &GL,
120    verts: &[f32],
121    attr_loc: u32,
122) -> Result<(WebGlVertexArrayObject, i32), String> {
123    let vao = gl
124        .create_vertex_array()
125        .ok_or("create_vertex_array failed")?;
126    gl.bind_vertex_array(Some(&vao));
127    let buf = gl.create_buffer().ok_or("create_buffer failed")?;
128    gl.bind_buffer(GL::ARRAY_BUFFER, Some(&buf));
129    // SAFETY: `Float32Array::view` apunta directamente a la memoria del WASM linear,
130    // que no podemos mover durante este scope (sin allocs intermedias).
131    unsafe {
132        let view = js_sys::Float32Array::view(verts);
133        gl.buffer_data_with_array_buffer_view(GL::ARRAY_BUFFER, &view, GL::STATIC_DRAW);
134    }
135    gl.vertex_attrib_pointer_with_i32(attr_loc, 2, GL::FLOAT, false, 0, 0);
136    gl.enable_vertex_attrib_array(attr_loc);
137    gl.bind_vertex_array(None);
138    Ok((vao, (verts.len() / 2) as i32))
139}
140
141impl Renderer {
142    pub fn new(canvas: &HtmlCanvasElement) -> Result<Self, JsValue> {
143        let gl = canvas
144            .get_context("webgl2")?
145            .ok_or_else(|| JsValue::from_str("WebGL2 no soportado"))?
146            .dyn_into::<GL>()?;
147
148        let chacana = ChacanaSpec::CLASSIC;
149
150        let cosmos_prog = Program::new(
151            &gl,
152            VS_FULLSCREEN,
153            FS_COSMOS,
154            &[
155                "u_resolution",
156                "u_time",
157                "u_parallax",
158                "u_void",
159                "u_nebula_a",
160                "u_nebula_b",
161                "u_stardust",
162            ],
163        )
164        .map_err(JsValue::from)?;
165
166        let chacana_prog = Program::new(
167            &gl,
168            VS_CHACANA,
169            FS_CHACANA,
170            &[
171                "u_mvp",
172                "u_time",
173                "u_thickness",
174                "u_arm_extent",
175                "u_line_color",
176                "u_rim_color",
177                "u_sun_color",
178                "u_sun_pulse",
179            ],
180        )
181        .map_err(JsValue::from)?;
182
183        let (cosmos_vao, _) = upload_quad(&gl, &FULLSCREEN_QUAD, 0).map_err(JsValue::from)?;
184        let chacana_quad_verts = chacana_quad(chacana.arm_extent);
185        let (chacana_vao, chacana_quad_count) =
186            upload_quad(&gl, &chacana_quad_verts, 0).map_err(JsValue::from)?;
187
188        let tilt = SpringDamper2::new(2.2, 0.72);
189
190        Ok(Self {
191            gl,
192            cosmos_prog,
193            chacana_prog,
194            cosmos_vao,
195            chacana_vao,
196            chacana_quad_count,
197            chacana,
198            tilt,
199            sun_pulse: 0.0,
200            last_time_ms: 0.0,
201            viewport: (canvas.width().max(1), canvas.height().max(1)),
202            mouse: (0.0, 0.0),
203        })
204    }
205
206    pub fn resize(&mut self, w: u32, h: u32) {
207        self.viewport = (w.max(1), h.max(1));
208        self.gl
209            .viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
210    }
211
212    /// Recibe coords del mouse en pixeles, origen en el centro del canvas
213    /// (+x derecha, +y arriba). Setea el target de inclinación.
214    pub fn set_mouse_px(&mut self, x: f32, y: f32) {
215        let (w, h) = self.viewport;
216        if h == 0 {
217            return;
218        }
219        let aspect = w as f32 / h as f32;
220        let half_h = h as f32 * 0.5;
221        let mx = (x / half_h).clamp(-aspect, aspect);
222        let my = (y / half_h).clamp(-1.0, 1.0);
223        self.mouse = (mx, my);
224
225        let max_tilt = MAX_TILT_DEG * RAD;
226        // Pitch (rot X): mouse arriba → top inclina hacia el viewer (+rotX).
227        // Yaw  (rot Y): mouse derecha → right inclina hacia el viewer (-rotY).
228        let target = [my * max_tilt, -mx * max_tilt / aspect];
229        self.tilt.set_target(target);
230    }
231
232    /// Posición proyectada (NDC `[-1, 1]²`) de cada tip cardinal en el orden N/E/S/W.
233    /// El caller la usa para anclar el DOM.
234    pub fn tips_ndc(&self) -> [(f32, f32); 4] {
235        let mvp = self.build_mvp();
236        let mut out = [(0.0_f32, 0.0_f32); 4];
237        for (i, t) in self.chacana.tips().iter().enumerate() {
238            let p = mvp * Vec4::new(t.0, t.1, 0.0, 1.0);
239            let w = if p.w == 0.0 { 1.0 } else { p.w };
240            out[i] = (p.x / w, p.y / w);
241        }
242        out
243    }
244
245    pub fn chacana(&self) -> &ChacanaSpec {
246        &self.chacana
247    }
248
249    fn build_mvp(&self) -> Mat4 {
250        let (w, h) = self.viewport;
251        let aspect = w as f32 / h as f32;
252        let proj = Mat4::perspective_rh(45.0_f32.to_radians(), aspect, 0.1, 20.0);
253        let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 2.6), Vec3::ZERO, Vec3::Y);
254        let pitch = Mat4::from_rotation_x(self.tilt.position[0]);
255        let yaw = Mat4::from_rotation_y(self.tilt.position[1]);
256        let scale = Mat4::from_scale(Vec3::splat(0.92));
257        proj * view * yaw * pitch * scale
258    }
259
260    pub fn render(&mut self, time_ms: f64) {
261        let dt = if self.last_time_ms == 0.0 {
262            1.0 / 60.0
263        } else {
264            ((time_ms - self.last_time_ms) as f32 / 1000.0).clamp(0.0, 1.0 / 15.0)
265        };
266        self.last_time_ms = time_ms;
267
268        // Subdividimos la física para mantener estabilidad con dt grandes.
269        let sub = 4;
270        let sub_dt = dt / sub as f32;
271        for _ in 0..sub {
272            self.tilt.step(sub_dt);
273        }
274        let t = time_ms as f32 * 0.001;
275        self.sun_pulse = 0.5 + 0.5 * (t * 1.4).sin();
276
277        let gl = &self.gl;
278        gl.viewport(0, 0, self.viewport.0 as i32, self.viewport.1 as i32);
279        gl.disable(GL::DEPTH_TEST);
280        gl.enable(GL::BLEND);
281        gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA);
282        gl.clear_color(0.02, 0.015, 0.04, 1.0);
283        gl.clear(GL::COLOR_BUFFER_BIT);
284
285        // ---- Cosmos ----
286        gl.use_program(Some(&self.cosmos_prog.program));
287        if let Some(u) = self.cosmos_prog.u("u_resolution") {
288            gl.uniform2f(Some(u), self.viewport.0 as f32, self.viewport.1 as f32);
289        }
290        if let Some(u) = self.cosmos_prog.u("u_time") {
291            gl.uniform1f(Some(u), t);
292        }
293        if let Some(u) = self.cosmos_prog.u("u_parallax") {
294            gl.uniform2f(Some(u), self.mouse.0, self.mouse.1);
295        }
296        upload_rgb(gl, self.cosmos_prog.u("u_void"), cosmos::VOID);
297        upload_rgb(gl, self.cosmos_prog.u("u_nebula_a"), cosmos::NEBULA_A);
298        upload_rgb(gl, self.cosmos_prog.u("u_nebula_b"), cosmos::NEBULA_B);
299        upload_rgb(gl, self.cosmos_prog.u("u_stardust"), cosmos::STARDUST);
300        gl.bind_vertex_array(Some(&self.cosmos_vao));
301        gl.draw_arrays(GL::TRIANGLES, 0, 6);
302
303        // ---- Chacana (blend aditivo para que el glow sume luz) ----
304        gl.blend_func(GL::SRC_ALPHA, GL::ONE);
305        gl.use_program(Some(&self.chacana_prog.program));
306        let mvp = self.build_mvp();
307        if let Some(u) = self.chacana_prog.u("u_mvp") {
308            gl.uniform_matrix4fv_with_f32_array(Some(u), false, &mvp.to_cols_array());
309        }
310        if let Some(u) = self.chacana_prog.u("u_time") {
311            gl.uniform1f(Some(u), t);
312        }
313        if let Some(u) = self.chacana_prog.u("u_thickness") {
314            gl.uniform1f(Some(u), self.chacana.thickness);
315        }
316        if let Some(u) = self.chacana_prog.u("u_arm_extent") {
317            gl.uniform1f(Some(u), self.chacana.arm_extent);
318        }
319        upload_rgb(gl, self.chacana_prog.u("u_line_color"), cosmos::CHACANA_LINE);
320        upload_rgb(gl, self.chacana_prog.u("u_rim_color"), cosmos::CHACANA_RIM);
321        upload_rgb(gl, self.chacana_prog.u("u_sun_color"), cosmos::SUN_CORE);
322        if let Some(u) = self.chacana_prog.u("u_sun_pulse") {
323            gl.uniform1f(Some(u), self.sun_pulse);
324        }
325        gl.bind_vertex_array(Some(&self.chacana_vao));
326        gl.draw_arrays(GL::TRIANGLES, 0, self.chacana_quad_count);
327        gl.bind_vertex_array(None);
328    }
329}
330
331fn upload_rgb(gl: &GL, loc: Option<&WebGlUniformLocation>, c: Rgb) {
332    if let Some(u) = loc {
333        gl.uniform3f(Some(u), c.0, c.1, c.2);
334    }
335}