Skip to main content

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}