optic_render/renderer.rs
1use optic_core::{log_info, ColorInfo, Cull, DrawMode, Gradient, PolyMode, RGBA, Size2D};
2
3use crate::context::RenderContext;
4use crate::glraw::GL;
5use crate::handles::{Canvas, Mesh2D, Mesh3D, RenderTarget, Shader, Texture2D};
6use crate::util::{Transform2D, Transform3D};
7use crate::{asset, Camera};
8
9/// The primary renderer — owns the GL context, fallback assets, and global
10/// pipeline state.
11///
12/// `GPU` is the central rendezvous point between CPU and GPU. It owns:
13///
14/// | Owner | Type | Purpose |
15/// |---|---|---|
16/// | GL context | [`RenderContext`] | EGL/GLX/WGL surface and function pointers |
17/// | Fallback shaders | [`Shader`] | Built-in 2D and 3D shaders (used when no custom shader is provided) |
18/// | Fallback texture | [`Texture2D`] | Checkerboard texture for untextured meshes |
19/// | Pipeline config | — | Polygon mode, culling, MSAA, clear colour |
20///
21/// # Lifecycle
22///
23/// A `GPU` is created once at startup and lives for the entire application
24/// session. It is **not** `Send` — OpenGL contexts are thread-bound on most
25/// platforms.
26///
27/// # Creating a GPU
28///
29/// | Constructor | Use case |
30/// |---|---|
31/// | [`GPU::new_headless`](Self::new_headless) | Off-screen / compute-only (no window) |
32/// | [`GPU::new_windowed`](Self::new_windowed) | On-screen rendering |
33///
34/// # Fallback assets
35///
36/// On construction, `GPU` loads built-in default shaders and a checkerboard
37/// fallback texture. [`ship_mesh3d`](Self::ship_mesh3d) and
38/// [`ship_mesh2d`](Self::ship_mesh2d) attach these automatically, so you can
39/// render something immediately without providing custom shaders.
40///
41/// # Example
42///
43/// ```ignore
44/// use optic_render::GPU;
45///
46/// let gpu = GPU::new_headless()?;
47/// gpu.log_backend_info();
48/// // → "BACKEND: 4.6.0 NVIDIA ... (GLSL 4.60)"
49/// ```
50pub struct GPU {
51 pub ctx: RenderContext,
52 pub poly_mode: PolyMode,
53 pub cull_face: Cull,
54 pub bg_color: RGBA,
55 pub msaa: bool,
56 pub msaa_samples: u32,
57 pub culling: bool,
58 pub fallback_shader2d: Shader,
59 pub fallback_shader3d: Shader,
60 pub fallback_texture: Texture2D,
61 pub canvas_size: Size2D,
62 pub(crate) current_target_size: Size2D,
63 pub(crate) max_color_attachments: i32,
64 pub(crate) max_draw_buffers: i32,
65 pub(crate) max_samples: i32,
66}
67
68impl GPU {
69 /// Creates a headless GPU context (no window).
70 ///
71 /// Useful for:
72 /// - Off-screen rendering (compute FBOs)
73 /// - Automated testing
74 /// - Server-side rendering
75 ///
76 /// The context uses an EGL `PBuffer` surface (or platform equivalent).
77 pub fn new_headless() -> optic_core::OpticResult<Self> {
78 let ctx = RenderContext::new_headless()?;
79 Ok(Self::from_ctx(ctx))
80 }
81
82 /// Creates a GPU context backed by an on-screen window.
83 ///
84 /// `raw_handle` and `display_handle` come from the windowing system
85 /// (e.g. `winit`). See [`optic_window`] for how to acquire them.
86 ///
87 /// # Errors
88 ///
89 /// Returns an error if the EGL/GLX surface cannot be created.
90 pub fn new_windowed(
91 raw_handle: raw_window_handle::RawWindowHandle,
92 display_handle: raw_window_handle::RawDisplayHandle,
93 size: Size2D,
94 ) -> optic_core::OpticResult<Self> {
95 let ctx = RenderContext::new_windowed(raw_handle, display_handle, size)?;
96 Ok(Self::from_ctx(ctx))
97 }
98
99 fn from_ctx(ctx: RenderContext) -> Self {
100 let bg_color = RGBA::grey(0.5);
101 GL::enable_depth(true);
102
103 let max_color_attachments = unsafe {
104 let mut v = 0i32;
105 gl::GetIntegerv(gl::MAX_COLOR_ATTACHMENTS, &mut v);
106 v
107 };
108 let max_draw_buffers = unsafe {
109 let mut v = 0i32;
110 gl::GetIntegerv(gl::MAX_DRAW_BUFFERS, &mut v);
111 v
112 };
113 let max_samples = unsafe {
114 let mut v = 0i32;
115 gl::GetIntegerv(gl::MAX_SAMPLES, &mut v);
116 v
117 };
118
119 let canvas_size = Size2D::from(1, 1);
120 let mut gpu = Self {
121 ctx,
122 bg_color,
123 msaa: true,
124 culling: true,
125 msaa_samples: 4,
126 cull_face: Cull::AntiClock,
127 poly_mode: PolyMode::Filled,
128 fallback_shader2d: Shader::new(0, false),
129 fallback_shader3d: Shader::new(0, false),
130 fallback_texture: Texture2D::new(0, Size2D::empty(), optic_core::ImgFormat::RGBA(8), optic_core::ImgFilter::Closest, optic_core::ImgWrap::Repeat),
131 canvas_size,
132 current_target_size: canvas_size,
133 max_color_attachments,
134 max_draw_buffers,
135 max_samples,
136 };
137
138 if let Ok(fallback_tex) = asset::TextureFile::fallback() {
139 let mut tex = fallback_tex;
140 tex.set_wrap(optic_core::ImgWrap::Repeat);
141 gpu.fallback_texture = gpu.ship_texture(&tex);
142 }
143 if let Ok(shader_asset) = asset::ShaderFile::default_3d() {
144 if let Some(shader) = gpu.ship_shader(&shader_asset) {
145 gpu.fallback_shader3d = shader;
146 let mut s = gpu.fallback_shader3d.clone();
147 s.attach_tex(&gpu.fallback_texture);
148 gpu.fallback_shader3d = s;
149 }
150 }
151 if let Ok(shader_asset) = asset::ShaderFile::default_2d() {
152 if let Some(shader) = gpu.ship_shader(&shader_asset) {
153 gpu.fallback_shader2d = shader;
154 let mut s = gpu.fallback_shader2d.clone();
155 s.attach_tex(&gpu.fallback_texture);
156 gpu.fallback_shader2d = s;
157 }
158 }
159
160 gpu.set_msaa(true);
161 gpu.set_culling(true);
162 gpu.set_wire_width(2.0);
163 gpu.set_bg_color(bg_color);
164 GL::enable_alpha(true);
165 gpu
166 }
167
168 // ── Backend info ─────────────────────────────────────────────────────
169
170 /// Returns the OpenGL version string, e.g. `"4.6.0 NVIDIA 545.84"`.
171 pub fn version(&self) -> &str { &self.ctx.gl_ver }
172
173 /// Returns the GLSL version string, e.g. `"4.60"`.
174 pub fn lang_version(&self) -> &str { &self.ctx.glsl_ver }
175
176 /// Returns the GPU device name, e.g. `"GeForce RTX 3080"`.
177 pub fn name(&self) -> &str { &self.ctx.device }
178
179 /// Logs the OpenGL and GLSL version at the `INFO` level.
180 pub fn log_backend_info(&self) {
181 log_info!("BACKEND: {} (GLSL {})", self.ctx.gl_ver, self.ctx.glsl_ver);
182 }
183
184 /// Logs the current pipeline configuration (polygon mode, culling, MSAA)
185 /// at the `INFO` level.
186 pub fn log_info(&self) {
187 log_info!("RENDERER");
188 log_info!(
189 "> mode: {}",
190 match self.poly_mode {
191 PolyMode::Points => "POINTS",
192 PolyMode::WireFrame => "WIREFRAME",
193 PolyMode::Filled => "RASTERIZE",
194 }
195 );
196 log_info!(
197 "> cull: {}",
198 if self.culling {
199 let face = match self.cull_face {
200 Cull::Clock => "clockwise",
201 Cull::AntiClock => "anti-clock",
202 };
203 format!("ON [{face}]")
204 } else {
205 "OFF".to_string()
206 }
207 );
208 log_info!(
209 "> msaa: {}",
210 if self.msaa {
211 format!("ON [{} samples]", self.msaa_samples)
212 } else {
213 "OFF".to_string()
214 }
215 );
216 }
217
218 // ── Clearing ─────────────────────────────────────────────────────────
219
220 /// Clears the current render target using the configured background colour.
221 ///
222 /// Equivalent to `clear_target(None, true)` — preserves `bg_color` and
223 /// clears depth.
224 pub fn clear(&self) {
225 self.ctx.clear();
226 }
227
228 /// Clears the currently-bound render target with explicit control.
229 ///
230 /// | Parameter | `Some` / `true` | `None` / `false` |
231 /// |---|---|---|
232 /// | `color` | Sets `bg_color` and clears colour buffer | Leaves colour buffer untouched |
233 /// | `depth` | Clears depth buffer | Leaves depth buffer untouched |
234 ///
235 /// ```ignore
236 /// // Clear colour to red, leave depth as-is
237 /// gpu.clear_target(Some(RED.into()), false);
238 ///
239 /// // Clear both with current bg_color
240 /// gpu.clear_target(None, true);
241 /// ```
242 pub fn clear_target(&mut self, color: Option<RGBA>, depth: bool) {
243 let mut mask = 0u32;
244 if let Some(c) = color {
245 self.ctx.set_clear_color(c);
246 self.bg_color = c;
247 mask |= gl::COLOR_BUFFER_BIT;
248 }
249 if depth {
250 mask |= gl::DEPTH_BUFFER_BIT;
251 }
252 if mask != 0 {
253 unsafe { gl::Clear(mask); }
254 }
255 }
256
257 // ── Pipeline state ───────────────────────────────────────────────────
258
259 /// Sets the background clear colour.
260 ///
261 /// Applied the next time [`clear`](Self::clear) or
262 /// [`clear_target`](Self::clear_target) is called.
263 pub fn set_bg_color(&mut self, color: RGBA) {
264 self.bg_color = color;
265 self.ctx.set_clear_color(color);
266 }
267
268 /// Sets the polygon rasterization mode.
269 ///
270 /// | Mode | Effect |
271 /// |---|---|
272 /// | [`PolyMode::Filled`] | Solid triangles (default) |
273 /// | [`PolyMode::WireFrame`] | Triangle outlines |
274 /// | [`PolyMode::Points`] | Vertex points |
275 pub fn set_poly_mode(&mut self, mode: PolyMode) {
276 self.poly_mode = mode;
277 GL::poly_mode(mode);
278 }
279
280 /// Toggles between filled and wireframe mode.
281 pub fn toggle_wireframe(&mut self) {
282 let mode = match self.poly_mode {
283 PolyMode::WireFrame => PolyMode::Filled,
284 _ => PolyMode::WireFrame,
285 };
286 self.set_poly_mode(mode);
287 }
288
289 /// Sets the line width used in wireframe mode.
290 ///
291 /// > **Note**: `glLineWidth` is deprecated in core OpenGL and may not
292 /// > work on all drivers. Prefer a geometry shader for thick lines.
293 pub fn set_wire_width(&mut self, width: f32) {
294 GL::set_wire_width(width);
295 }
296
297 /// Sets the point size used when [`PolyMode::Points`] is active.
298 pub fn set_point_size(&self, size: f32) {
299 GL::set_point_size(size);
300 }
301
302 // ── MSAA ─────────────────────────────────────────────────────────────
303
304 /// Enables or disables multisample anti-aliasing.
305 pub fn set_msaa(&mut self, enable: bool) {
306 self.msaa = enable;
307 GL::enable_msaa(enable);
308 }
309
310 /// Toggles MSAA on/off.
311 pub fn toggle_msaa(&mut self) {
312 self.msaa = !self.msaa;
313 GL::enable_msaa(self.msaa);
314 }
315
316 /// Sets the MSAA sample count for newly created canvases.
317 ///
318 /// Existing canvases are not affected. The driver's maximum sample count
319 /// is available at [`GPU::max_samples`] at runtime.
320 pub fn set_msaa_samples(&mut self, samples: u32) { self.msaa_samples = samples; }
321
322 // ── Culling ──────────────────────────────────────────────────────────
323
324 /// Enables or disables back-face culling.
325 pub fn set_culling(&mut self, enable: bool) {
326 self.culling = enable;
327 GL::enable_cull(enable);
328 }
329
330 /// Toggles back-face culling on/off.
331 pub fn toggle_culling(&mut self) {
332 self.culling = !self.culling;
333 GL::enable_cull(self.culling);
334 }
335
336 /// Sets which face is culled (clockwise or counter-clockwise).
337 ///
338 /// Vertices in counter-clockwise order (the default in OpenGL) are
339 /// front-facing. Set to [`Cull::Clock`] to cull the front face instead.
340 pub fn set_cull_face(&mut self, cull_face: Cull) {
341 self.cull_face = cull_face;
342 GL::set_cull_face(cull_face);
343 }
344
345 /// Flips the cull face between clockwise and counter-clockwise.
346 pub fn flip_cull_face(&mut self) {
347 self.cull_face = match self.cull_face {
348 Cull::Clock => Cull::AntiClock,
349 Cull::AntiClock => Cull::Clock,
350 };
351 GL::set_cull_face(self.cull_face);
352 }
353
354 // ─── Canvas / viewport ───────────────────────────────────────────────
355
356 /// Sets the logical canvas size (defines the 2D orthographic projection).
357 ///
358 /// This is the size used by [`render2d`](Self::render2d) to compute its
359 /// aspect-correct orthographic matrix.
360 pub fn set_canvas_size(&mut self, size: Size2D) {
361 self.canvas_size = size;
362 }
363
364 /// Returns the size of the currently-bound render target.
365 ///
366 /// Updated whenever [`set_render_target`](Self::set_render_target) is
367 /// called.
368 pub fn current_render_target_size(&self) -> Size2D {
369 self.current_target_size
370 }
371
372 // ── Asset shipping ───────────────────────────────────────────────────
373
374 /// Returns a clone of the 3D fallback shader (with the fallback texture
375 /// pre-bound).
376 ///
377 /// Useful when you want to render a mesh without writing a custom shader:
378 ///
379 /// ```ignore
380 /// let mut mesh = gpu.ship_mesh3d(&my_mesh_file);
381 /// mesh.shader = Some(gpu.fallback_shader3d());
382 /// ```
383 pub fn fallback_shader3d(&self) -> Shader {
384 self.fallback_shader3d.clone()
385 }
386
387 /// Returns a clone of the 2D fallback shader (with the fallback texture
388 /// pre-bound).
389 pub fn fallback_shader2d(&self) -> Shader {
390 self.fallback_shader2d.clone()
391 }
392
393 /// Uploads a [`Mesh3DFile`](asset::Mesh3DFile) to the GPU and returns a
394 /// [`Mesh3D`] with the fallback 3D shader attached.
395 ///
396 /// The returned mesh is immediately renderable:
397 ///
398 /// ```ignore
399 /// let cube = Mesh3DFile::cube(2.0);
400 /// let mesh = gpu.ship_mesh3d(&cube);
401 /// gpu.render3d(&mesh, &camera);
402 /// ```
403 pub fn ship_mesh3d(&self, file: &asset::Mesh3DFile) -> Mesh3D {
404 Mesh3D {
405 visibility: true,
406 handle: file.ship(),
407 shader: Some(self.fallback_shader3d()),
408 transform: Transform3D::default(),
409 draw_mode: DrawMode::Triangles,
410 }
411 }
412
413 /// Uploads a [`Mesh2DFile`](asset::Mesh2DFile) to the GPU and returns a
414 /// [`Mesh2D`] with the fallback 2D shader attached.
415 pub fn ship_mesh2d(&self, file: &asset::Mesh2DFile) -> Mesh2D {
416 Mesh2D {
417 visibility: true,
418 handle: file.ship(),
419 shader: Some(self.fallback_shader2d()),
420 transform: Transform2D::default(),
421 draw_mode: DrawMode::Triangles,
422 }
423 }
424
425 /// Compiles a [`ShaderFile`](asset::ShaderFile) into a usable [`Shader`].
426 ///
427 /// Returns `None` if compilation or linking fails (errors are logged
428 /// internally by the GLSL compiler). A returned shader is ready to bind
429 /// uniforms and attach textures.
430 pub fn ship_shader(&self, asset: &asset::ShaderFile) -> Option<Shader> {
431 asset.compile().ok()
432 }
433
434 /// Uploads a [`TextureFile`](asset::TextureFile) to the GPU.
435 ///
436 /// ```ignore
437 /// let tex_file = TextureFile::from_disk("assets/grass.png")?;
438 /// let tex: Texture2D = gpu.ship_texture(&tex_file);
439 /// ```
440 pub fn ship_texture(&self, image: &asset::TextureFile) -> Texture2D {
441 image.ship()
442 }
443
444 /// Bakes a [`Gradient`] into a 1-pixel-high 2D texture (colour ramp).
445 ///
446 /// `resolution` controls the width of the texture (number of samples).
447 /// Each sample is linearly interpolated from the gradient and stored as
448 /// `RGBA8`.
449 ///
450 /// ```ignore
451 /// use optic_core::Gradient;
452 ///
453 /// let grad = Gradient::rainbow();
454 /// let ramp: Texture2D = gpu.ship_gradient(&grad, 256);
455 /// // Use as a lookup texture in a shader
456 /// ```
457 pub fn ship_gradient(&self, gradient: &Gradient, resolution: u32) -> Texture2D {
458 let res = resolution.max(1);
459 let colors = gradient.sample_n(res as usize);
460 let mut bytes = Vec::with_capacity(res as usize * 4);
461 for c in &colors {
462 let (r, g, b, a) = c.to_bytes();
463 bytes.push(r);
464 bytes.push(g);
465 bytes.push(b);
466 bytes.push(a);
467 }
468 let size = Size2D::from(res, 1);
469 let id = crate::handles::texture::create_texture(
470 &bytes,
471 size,
472 &optic_core::ImgFormat::RGBA(8),
473 &optic_core::ImgFilter::Linear,
474 &optic_core::ImgWrap::Clip,
475 );
476 Texture2D::new(id, size, optic_core::ImgFormat::RGBA(8), optic_core::ImgFilter::Linear, optic_core::ImgWrap::Clip)
477 }
478
479 /// Creates a [`Canvas`] (FBO) with hardware capability validation.
480 ///
481 /// Checks the descriptor against the driver's maximum colour attachments,
482 /// draw buffers, and MSAA samples before creating the FBO.
483 ///
484 /// # Errors
485 ///
486 /// | Condition | Error |
487 /// |---|---|
488 /// | Too many colour attachments | `"exceeds GL_MAX_COLOR_ATTACHMENTS"` |
489 /// | Too many draw buffers | `"exceeds GL_MAX_DRAW_BUFFERS"` |
490 /// | Too many MSAA samples | `"exceeds GL_MAX_SAMPLES"` |
491 /// | FBO incomplete | Framebuffer status error |
492 pub fn ship_canvas(&mut self, desc: &crate::handles::CanvasDesc) -> optic_core::OpticResult<Canvas> {
493 if desc.color_formats.len() as i32 > self.max_color_attachments {
494 return Err(optic_core::OpticError::new(
495 optic_core::OpticErrorKind::Custom,
496 &format!(
497 "ship_canvas: {} color attachments exceeds GL_MAX_COLOR_ATTACHMENTS ({})",
498 desc.color_formats.len(), self.max_color_attachments,
499 ),
500 ));
501 }
502 if desc.color_formats.len() as i32 > self.max_draw_buffers {
503 return Err(optic_core::OpticError::new(
504 optic_core::OpticErrorKind::Custom,
505 &format!(
506 "ship_canvas: {} color attachments exceeds GL_MAX_DRAW_BUFFERS ({})",
507 desc.color_formats.len(), self.max_draw_buffers,
508 ),
509 ));
510 }
511 if desc.samples as i32 > self.max_samples {
512 return Err(optic_core::OpticError::new(
513 optic_core::OpticErrorKind::Custom,
514 &format!(
515 "ship_canvas: {} samples exceeds GL_MAX_SAMPLES ({})",
516 desc.samples, self.max_samples,
517 ),
518 ));
519 }
520 Canvas::new(desc)
521 }
522
523 // ── Render target ────────────────────────────────────────────────────
524
525 /// Binds a render target (screen or canvas) for subsequent draw calls.
526 ///
527 /// Updates the viewport to match the target's size and records the size
528 /// in [`current_target_size`](Self::current_target_size).
529 ///
530 /// ```ignore
531 /// // Render to a canvas
532 /// let canvas = gpu.ship_canvas(&my_desc)?;
533 /// gpu.set_render_target(&RenderTarget::Canvas(&canvas))?;
534 /// gpu.clear();
535 ///
536 /// // Switch back to screen
537 /// gpu.set_render_target(&RenderTarget::Screen)?;
538 /// canvas.blit_to_screen(window_size);
539 /// ```
540 pub fn set_render_target(&mut self, target: &RenderTarget) -> optic_core::OpticResult<()> {
541 match target {
542 RenderTarget::Screen => {
543 unsafe { gl::BindFramebuffer(gl::FRAMEBUFFER, 0); }
544 GL::resize(self.canvas_size);
545 self.current_target_size = self.canvas_size;
546 }
547 RenderTarget::Canvas(canvas) => {
548 let fb = canvas.fbo_id;
549 unsafe { gl::BindFramebuffer(gl::FRAMEBUFFER, fb); }
550 GL::resize(canvas.size);
551 self.current_target_size = canvas.size;
552 }
553 }
554 Ok(())
555 }
556
557 // ── Rendering ────────────────────────────────────────────────────────
558
559 /// Draws a 3D mesh through the given camera.
560 ///
561 /// Internally computes `MVP = proj × view × model` and sends it to the
562 /// shader before issuing the draw call.
563 ///
564 /// ```ignore
565 /// let cube_file = Mesh3DFile::cube(2.0);
566 /// let mesh = gpu.ship_mesh3d(&cube_file);
567 ///
568 /// gpu.render3d(&mesh, &camera);
569 /// ```
570 pub fn render3d(&self, mesh: &Mesh3D, camera: &Camera) {
571 mesh.render(&camera.transform.view_matrix(), &camera.transform.proj_matrix());
572 }
573
574 /// Draws a 2D mesh using an orthographic projection derived from the
575 /// canvas size.
576 ///
577 /// The projection maps `[-aspect, aspect]` on X and `[-1, 1]` on Y
578 /// where `aspect = canvas_width / canvas_height`.
579 ///
580 /// ```ignore
581 /// let quad_file = Mesh2DFile::quad(&(800, 600).into());
582 /// let mesh = gpu.ship_mesh2d(&quad_file);
583 ///
584 /// gpu.render2d(&mesh);
585 /// ```
586 pub fn render2d(&self, mesh: &Mesh2D) {
587 let aspect = if self.canvas_size.w > 0 && self.canvas_size.h > 0 {
588 self.canvas_size.w as f32 / self.canvas_size.h as f32
589 } else {
590 1.0
591 };
592 let proj = cgmath::ortho(-aspect, aspect, -1.0, 1.0, -1.0, 1.0);
593 mesh.render(&proj);
594 }
595}