⎇ fce630c8d0a50843b6d447531efb106a627fd1c7
crates/modules/gioser/gioser-canvas-web/src/lib.rs
History
Who touched what
raw
feat(gioser): sol detrás, título central, drawers MD + pluma agnóstico
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}