// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! FBO / framebuffer management for [`GlesCanvas`]: sub-canvas blit, //! main-FBO ⇄ default-framebuffer present, lazy auxiliary FBO for //! snapshot-based effects (Overlay blend inset shadow), and the //! externally-exposed borrowed-texture view. //! //! See `primitives.rs` module doc for the canvas-wide `unsafe` contract //! shared by every block in this file. Per-block notes below only call //! out what is specific to the operation. use glow::HasContext; use crate::types::Rect; use super::helpers::{ alloc_fbo_tex, native_framebuffer_id, native_texture_id, ortho_rect }; use super::raii::{ FboBinding, ProgramBinding }; use super::{ BorrowedGlesTexture, GlesCanvas }; impl GlesCanvas { pub fn blit( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32 ) { self.blit_fade_bottom( src, dest_x, dest_y, 0.0 ); } /// Blit `src` into this canvas at `( dest_x, dest_y )`, optionally feathering /// the last `fade_bottom_px` source rows so the bottom edge dissolves into /// transparency instead of cutting off cleanly. Used by viewports whose /// bottom edge is the leading edge of a slide-down animation, where a hard /// cut against the underlying layer reads as a knife. With `fade_bottom_px /// == 0.0` this matches [`Self::blit`] exactly. pub fn blit_fade_bottom( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32, fade_bottom_px: f32 ) { self.activate_target(); let dest = Rect { x: dest_x as f32, y: dest_y as f32, width: src.width as f32, height: src.height as f32, }; let mvp = ortho_rect( self.width, self.height, dest ); let alpha = self.global_alpha; let height_px = src.height as f32; let fade_clamp = fade_bottom_px.max( 0.0 ).min( height_px ); // SAFETY: `src.fbo_tex` is owned by `src` (a `&GlesCanvas` argument) // and outlives the call. `src` and `self` share the same `Arc` // — verified by construction (sub-canvases are built via `sub_canvas`, // which clones `Arc::clone(&self.gl)`) — so sampling `src`'s texture // from `self`'s FBO is well-defined. unsafe { // Both the main canvas and the sub-canvas FBO hold premultiplied // colour, and the global blend is `(ONE, ONE_MINUS_SRC_ALPHA)` — // the premul over-composite formula this blit needs. No temporary // blend switch necessary. self.gl.use_program( Some( self.sub_blit_program ) ); self.gl.uniform_matrix_4_f32_slice( Some( &self.u_subblit_mvp ), false, &mvp ); self.gl.uniform_1_f32( Some( &self.u_subblit_opacity ), alpha ); self.gl.uniform_1_f32( Some( &self.u_subblit_fade_bottom ), fade_clamp ); self.gl.uniform_1_f32( Some( &self.u_subblit_height_px ), height_px ); self.gl.active_texture( glow::TEXTURE0 ); self.gl.bind_texture( glow::TEXTURE_2D, Some( src.fbo_tex ) ); self.gl.uniform_1_i32( Some( &self.u_subblit_sampler ), 0 ); self.gl.bind_vertex_array( Some( self.quad_vao ) ); self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); self.gl.bind_vertex_array( None ); self.gl.bind_texture( glow::TEXTURE_2D, None ); } } /// Re-bind our FBO + viewport + scissor as the active GL state. Cheap and /// idempotent, called at the top of every draw / clear / clip method so /// that switching between canvases (e.g. main → sub-canvas → main) leaves /// each one's state correct without explicit "make active" calls. /// /// Why this exists: GL state (FBO binding, scissor box, viewport) is /// global — there is no implicit per-canvas state. When rendering /// switches between targets, every method on the active canvas must /// reassert its own FBO + viewport, plus re-enable its own scissor (or /// disable scissor when the canvas has no clip). pub( super ) fn activate_target( &self ) { // While a path clip is active, draws are redirected to the offscreen clip // layer (unclipped); the anti-aliased mask is applied when the layer is // composited back on clip end. Otherwise draws go to `fbo` with the // active scissor. // SAFETY: rebinds a canvas-owned FBO + viewport + scissor. All values // (`self.fbo`, `self.clip_layer`, `self.width/height`, `self.clip_scissor`) // live as long as `&self`, and the bind is idempotent. let target = match ( self.clip_layer_active, self.clip_layer ) { ( true, Some( ( fbo, _ ) ) ) => fbo, _ => self.fbo, }; let to_layer = self.clip_layer_active && self.clip_layer.is_some(); unsafe { self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( target ) ); self.gl.viewport( 0, 0, self.width as i32, self.height as i32 ); match self.clip_scissor { Some( r ) if !to_layer => { let ( x, y, w, h ) = self.scissor_pixels( r ); self.gl.enable( glow::SCISSOR_TEST ); self.gl.scissor( x, y, w, h ); } _ => { self.gl.disable( glow::SCISSOR_TEST ); } } } } /// Return a borrowed descriptor for the FBO color texture /// containing the latest rendered pixels. /// /// `y_inverted` is `true`: the FBO uses GL's native lower-left /// origin, so row 0 in texture memory is the bottom of the /// rendered image. Consumers that follow the same convention flip /// during sampling when this flag is set, producing a correctly- /// oriented result. The CPU-side counterpart /// [`Self::read_rgba_pixels`] does the same flip inline so the /// byte buffer is top-down. pub fn borrowed_texture( &self ) -> BorrowedGlesTexture { BorrowedGlesTexture { texture_id: native_texture_id( self.fbo_tex ), framebuffer_id: native_framebuffer_id( self.fbo ), texture: self.fbo_tex, framebuffer: self.fbo, width: self.width, height: self.height, premultiplied: true, y_inverted: true, } } /// Read the FBO color attachment into `out` as tightly packed RGBA8, /// top-left row first. /// /// This is a compatibility escape hatch. It forces a GPU→CPU sync and /// should not be used in steady-state hot paths. pub fn read_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String> { let needed = self.width as usize * self.height as usize * 4; if out.len() < needed { return Err( format!( "read_rgba_pixels needs {needed} bytes, got {}", out.len(), ) ); } let mut raw = vec![ 0_u8; needed ]; // SAFETY: `raw.len() == needed == width * height * 4` and PACK_ALIGNMENT // is set to 1, so `read_pixels` writes exactly `needed` bytes into a // buffer of exactly that size. `RGBA + UNSIGNED_BYTE` is the only // guaranteed-readable format on every GLES2/3 driver. unsafe { self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) ); self.gl.pixel_store_i32( glow::PACK_ALIGNMENT, 1 ); self.gl.read_pixels( 0, 0, self.width as i32, self.height as i32, glow::RGBA, glow::UNSIGNED_BYTE, glow::PixelPackData::Slice( Some( &mut raw ) ), ); } let stride = self.width as usize * 4; for y in 0..self.height as usize { let src = ( self.height as usize - 1 - y ) * stride; let dst = y * stride; out[ dst..dst + stride ].copy_from_slice( &raw[ src..src + stride ] ); } Ok( () ) } /// Lazily allocate the auxiliary FBO+texture pair used as a snapshot of /// `fbo` for framebuffer-fetch-style effects (Overlay blend, /// backdrop-blur source). The pair is sized to match the canvas so /// `gl_FragCoord.xy / canvas_size` samples the right texel. /// /// Returns the texture handle of `aux_a`. The FBO is only needed for /// the backdrop blur passes that write into `aux_b`; Overlay only /// reads from `aux_a`, so this helper keeps the blur-only `aux_b` /// allocation deferred until it is actually needed. fn ensure_aux_a( &mut self ) -> glow::Texture { if self.aux_a.is_none() { // SAFETY: `alloc_fbo_tex` is `unsafe fn`; its requirement (current // GL context) holds. Same FBO build / completeness assertion as // `setup.rs::new`. We deliberately leave `aux_a`'s FBO as the live // binding — the next draw goes through `activate_target` which // rebinds `self.fbo`. unsafe { let fbo = self.gl.create_framebuffer().expect( "aux_a FBO" ); let tex = alloc_fbo_tex( &self.gl, self.version, self.width, self.height ); self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) ); self.gl.framebuffer_texture_2d ( glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, glow::TEXTURE_2D, Some( tex ), 0, ); let status = self.gl.check_framebuffer_status( glow::FRAMEBUFFER ); assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "aux_a FBO incomplete: 0x{status:x}" ); self.aux_a = Some( ( fbo, tex ) ); } } self.aux_a.expect( "just allocated" ).1 } /// Snapshot variant that additionally clamps `region` to the active /// scissor. Safe only for shaders that sample the snapshot at /// exactly one point per fragment. pub( super ) fn snapshot_fbo_region_tight( &mut self, region: Rect ) { self.snapshot_fbo_region_impl( region, true ) } fn snapshot_fbo_region_impl( &mut self, region: Rect, intersect_scissor: bool ) { let aux_tex = self.ensure_aux_a(); // Clamp `region` to canvas bounds. `copy_tex_sub_image_2d` would // generate `INVALID_VALUE` (or undefined behaviour on some // drivers) if the source rect extends outside the framebuffer. let cw = self.width as f32; let ch = self.height as f32; let mut x0 = region.x.max( 0.0 ); let mut y0_top = region.y.max( 0.0 ); let mut x1 = ( region.x + region.width ).min( cw ); let mut y1_top = ( region.y + region.height ).min( ch ); if intersect_scissor { if let Some( clip ) = self.clip_scissor { x0 = x0.max( clip.x ); y0_top = y0_top.max( clip.y ); x1 = x1.min( clip.x + clip.width ); y1_top = y1_top.min( clip.y + clip.height ); } } let w = ( x1 - x0 ).floor() as i32; let h = ( y1_top - y0_top ).floor() as i32; if w <= 0 || h <= 0 { return; } // GL framebuffer origin is bottom-left; our rect is top-left. let src_x = x0.floor() as i32; let src_y = self.height as i32 - y0_top.floor() as i32 - h; // SAFETY: the four bounds checks above guarantee `(src_x, src_y, w, h)` // lies fully inside `self.fbo`'s colour attachment, so // `copy_tex_sub_image_2d` will not raise INVALID_VALUE. `aux_tex` was // allocated through `ensure_aux_a` to canvas dimensions, so the // destination region is also in-bounds. unsafe { self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) ); self.gl.bind_texture( glow::TEXTURE_2D, Some( aux_tex ) ); self.gl.copy_tex_sub_image_2d ( glow::TEXTURE_2D, 0, src_x, src_y, src_x, src_y, w, h, ); self.gl.bind_texture( glow::TEXTURE_2D, None ); } } /// Drop both auxiliary FBO+texture pairs if allocated. Called from /// [`Self::resize`] so the next effect re-allocates at the new size. pub( super ) fn invalidate_aux( &mut self ) { // SAFETY: each (fbo, tex) pair was created through `self.gl` in // `ensure_aux_a` / `ensure_aux_b`, so deleting through the same // context is well-defined. `take()` ensures we never double-delete. unsafe { if let Some( ( fbo, tex ) ) = self.aux_a.take() { self.gl.delete_framebuffer( fbo ); self.gl.delete_texture( tex ); } if let Some( ( fbo, tex ) ) = self.aux_b.take() { self.gl.delete_framebuffer( fbo ); self.gl.delete_texture( tex ); } } } /// Blit the FBO color attachment onto the default framebuffer (the EGL /// window). Caller is responsible for the `eglSwapBuffers` that /// publishes the result. After present, the FBO is rebound so the next /// frame's draws keep accumulating into the shadow canvas. /// /// The blit always covers the full surface — partial-redraw still saves /// work upstream (only changed widget pixels are repainted into the /// FBO), but the FBO→FB0 transfer itself is a single cheap fullscreen /// op. pub fn present( &mut self ) { // Scoped guards: bind the default framebuffer (id 0) and the // blit program for the duration of this fn. On Drop they restore // `self.fbo` and "no program", so any future early-return / panic // in the blit body cannot leave the canvas pointing at the wrong // FBO or program. The viewport, blend and scissor are restored // inline below — they are non-resource state that does not need // the guard treatment because `activate_target` rewrites them on // every subsequent draw. // // SAFETY: GL context is current (canvas invariant). `self.fbo` is // the canvas-owned FBO from `setup.rs::new`. `self.blit_program` // was linked in `setup.rs::new`. Restoring "no program active" // (`None`) is always sound. let _fbo = unsafe { FboBinding::scoped( &self.gl, None, Some( self.fbo ) ) }; let _prog = unsafe { ProgramBinding::scoped( &self.gl, Some( self.blit_program ), None ) }; // SAFETY: see above. The block sets up the blit pipeline state, // draws a fullscreen quad sampling `fbo_tex`, then restores blend // and viewport so the next frame's draws inherit the canvas-wide // defaults. FBO + program are restored by the guards on scope exit. unsafe { self.gl.viewport( 0, 0, self.width as i32, self.height as i32 ); self.gl.disable( glow::SCISSOR_TEST ); self.gl.disable( glow::BLEND ); self.gl.active_texture( glow::TEXTURE0 ); self.gl.bind_texture( glow::TEXTURE_2D, Some( self.fbo_tex ) ); self.gl.uniform_1_i32( Some( &self.u_blit_sampler ), 0 ); self.gl.bind_vertex_array( Some( self.quad_vao ) ); self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); self.gl.bind_vertex_array( None ); self.gl.bind_texture( glow::TEXTURE_2D, None ); self.gl.enable( glow::BLEND ); self.gl.viewport( 0, 0, self.width as i32, self.height as i32 ); } // Scissor was disabled above; reflect that in our cached state. self.clip_scissor = None; // Guards drop here: FBO → self.fbo, program → None. } }