facett-core/src/render/gpu/offscreen.rs
//! **Shared offscreen colour+depth target + blit-into-egui helper** (feature
//! `wgpu`) — the CONS-CORE Phase C extraction of the bespoke offscreen plumbing
//! that lived in `facett-map3d`'s `Map3dRenderer` (`ensure_targets` + the blit
//! pipeline + the widget scissor).
//!
//! Why a skin needs this: egui's own render pass is **colour-only** (no depth
//! attachment), so a depth-tested 3D pass cannot draw straight into it — it must
//! render into an **owned offscreen colour + depth32 target**, then **sample (blit)**
//! that colour into egui's pass. This helper owns:
//!
//! - the offscreen colour texture (+ view) and the depth texture (+ view),
//! (re)created when the pane size changes ([`OffscreenColorDepth::ensure`]);
//! - the blit pipeline + sampler + bind group that sample the colour into egui's
//! pass ([`OffscreenColorDepth::blit`]);
//! - the **widget scissor** math ([`gpu_scissor_px`] / [`scissor_intersection`] /
//! [`ScissorPx`]) — the GPU twin of the CPU painter's rect clip, so nothing the 3D
//! pass paints can escape the widget rect (the clip bug oracle on the GPU lane).
//!
//! The skin keeps its own **mesh pipeline + shader + uniforms** and records its
//! depth-tested pass into [`OffscreenColorDepth::color_view`] /
//! [`OffscreenColorDepth::depth_view`]; this helper provides only the targets + the
//! blit. The depth+colour **formats are fixed** ([`DEPTH_FORMAT`] /
//! [`OFFSCREEN_FORMAT`]) so a skin's mesh pipeline matches the targets it draws into.
use wgpu::TextureFormat;
/// The offscreen depth attachment format — `Depth32Float`, the format the skins'
/// mesh pipelines declare for the `Less` depth test.
pub const DEPTH_FORMAT: TextureFormat = TextureFormat::Depth32Float;
/// The offscreen colour attachment format — `Rgba16Float`, an **HDR** target so a
/// skin can write linear radiance **above 1.0** (emissive windows, sun glints) that
/// a post stack (bright-pass → bloom → ACES tonemap, see `facett-map3d`'s post
/// passes) reads before it is tonemapped down to the 8-bit egui surface. The blit
/// (or a skin's tonemap pass) samples it as a filterable float texture.
pub const OFFSCREEN_FORMAT: TextureFormat = TextureFormat::Rgba16Float;
/// The blit shader (fullscreen triangle sampling the offscreen colour), byte-for-byte
/// the `blit_vs`/`blit_fs` map3d used. Self-contained so the helper owns its blit.
pub const BLIT_WGSL: &str = r#"
@group(0) @binding(0) var blit_tex: texture_2d<f32>;
@group(0) @binding(1) var blit_smp: sampler;
struct BlitOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn blit_vs(@builtin(vertex_index) vi: u32) -> BlitOut {
var p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
var out: BlitOut;
let xy = p[vi];
out.clip = vec4<f32>(xy, 0.0, 1.0);
out.uv = vec2<f32>(xy.x * 0.5 + 0.5, 1.0 - (xy.y * 0.5 + 0.5));
return out;
}
@fragment
fn blit_fs(in: BlitOut) -> @location(0) vec4<f32> {
return textureSample(blit_tex, blit_smp, in.uv);
}
"#;
/// The shared offscreen colour+depth target + blit pipeline. Construct once
/// ([`OffscreenColorDepth::new`], from the host's `target_format`); each frame call
/// [`ensure`](OffscreenColorDepth::ensure) for the pane size, record the skin's
/// depth-tested pass into the [`color_view`](OffscreenColorDepth::color_view) /
/// [`depth_view`](OffscreenColorDepth::depth_view), then [`blit`](OffscreenColorDepth::blit)
/// into egui's pass.
pub struct OffscreenColorDepth {
blit_pipeline: wgpu::RenderPipeline,
blit_bgl: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
color_tex: Option<wgpu::Texture>,
color_view: Option<wgpu::TextureView>,
depth_view: Option<wgpu::TextureView>,
/// A SECOND view of the depth texture, for SAMPLING it (the water/SSR/depth-fog
/// passes) — separate from the attachment view so a pass can read the scene
/// depth a previous pass wrote.
depth_sample_view: Option<wgpu::TextureView>,
blit_bind: Option<wgpu::BindGroup>,
size: (u32, u32),
}
impl OffscreenColorDepth {
/// Build the blit pipeline + sampler for the host's `target_format` (egui's
/// colour pass format), compositing the offscreen with straight
/// [`BlendState::ALPHA_BLENDING`](wgpu::BlendState::ALPHA_BLENDING) — right for a
/// skin whose pass writes an **opaque** scene (`alpha = 1`), which is every 3D
/// skin. No targets are allocated until [`ensure`](Self::ensure).
///
/// A pass that writes **premultiplied** colour (an additive glow overlay, where
/// `alpha` is coverage rather than 1) must not go through this one: alpha-blending
/// premultiplied colour multiplies by alpha a second time, so the falloff comes
/// out as `a²` and a faint particle halo vanishes. Use
/// [`new_with_blend`](Self::new_with_blend) with
/// `PREMULTIPLIED_ALPHA_BLENDING` for those.
pub fn new(device: &wgpu::Device, target_format: TextureFormat) -> Self {
Self::new_with_blend(device, target_format, wgpu::BlendState::ALPHA_BLENDING)
}
/// [`new`](Self::new) with an explicit blit blend mode — the ONE pipeline
/// construction, parameterised rather than copied (LAW 5). See `new`'s note for
/// which mode a pass needs.
pub fn new_with_blend(device: &wgpu::Device, target_format: TextureFormat, blend: wgpu::BlendState) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("l0_offscreen_blit"),
source: wgpu::ShaderSource::Wgsl(BLIT_WGSL.into()),
});
let blit_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("l0_offscreen_blit_bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("l0_offscreen_blit_pipeline"),
layout: Some(&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("l0_offscreen_blit_pll"),
bind_group_layouts: &[Some(&blit_bgl)],
immediate_size: 0,
})),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("blit_vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: None,
multisample: crate::render::gpu::msaa_state(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("blit_fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: target_format,
blend: Some(blend),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("l0_offscreen_blit_sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
blit_pipeline,
blit_bgl,
sampler,
color_tex: None,
color_view: None,
depth_view: None,
depth_sample_view: None,
blit_bind: None,
size: (0, 0),
}
}
/// (Re)create the offscreen colour + depth targets for a pane of `w×h` physical
/// pixels. Idempotent within a size — only reallocates when the pane resizes.
/// Extracted verbatim from map3d's `ensure_targets`.
///
/// Each side is clamped to the device's `max_texture_dimension_2d`
/// ([`super::texture_extent`]): a pane wider than the limit is rendered into the
/// clamped target and the blit stretches it over the pane — never a panic.
pub fn ensure(&mut self, device: &wgpu::Device, w: u32, h: u32) {
let (w, h) = super::texture_extent(device, w, h);
if self.size == (w, h) && self.color_view.is_some() {
return;
}
let color = device.create_texture(&wgpu::TextureDescriptor {
label: Some("l0_offscreen_color"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: crate::render::gpu::NO_MSAA_SAMPLES,
dimension: wgpu::TextureDimension::D2,
format: OFFSCREEN_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: Some("l0_offscreen_depth"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: crate::render::gpu::NO_MSAA_SAMPLES,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
// TEXTURE_BINDING so a skin's water/SSR/depth-fog pass can SAMPLE the
// scene depth (the reflective-water depth-tint reads the bottom's depth
// under each water fragment). RENDER_ATTACHMENT for the depth test.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let depth_sample_view = depth.create_view(&Default::default());
let blit_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("l0_offscreen_blit_bind"),
layout: &self.blit_bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&color_view) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) },
],
});
self.color_tex = Some(color);
self.color_view = Some(color_view);
self.depth_view = Some(depth_view);
self.depth_sample_view = Some(depth_sample_view);
self.blit_bind = Some(blit_bind);
self.size = (w, h);
}
/// A sampleable view of the offscreen **depth** texture (`Depth32Float`,
/// `texture_depth_2d` in WGSL) — for a skin's water / SSR / depth-fog pass to
/// read the scene depth a previous pass wrote. `None` before the first
/// [`ensure`](Self::ensure).
pub fn depth_sample_view(&self) -> Option<&wgpu::TextureView> {
self.depth_sample_view.as_ref()
}
/// The offscreen colour view to use as a skin's render-pass colour attachment.
/// `None` before the first [`ensure`](Self::ensure).
pub fn color_view(&self) -> Option<&wgpu::TextureView> {
self.color_view.as_ref()
}
/// The offscreen depth view to use as a skin's depth-stencil attachment.
pub fn depth_view(&self) -> Option<&wgpu::TextureView> {
self.depth_view.as_ref()
}
/// Whether targets are allocated (an `ensure` has run with a non-zero size).
pub fn ready(&self) -> bool {
self.blit_bind.is_some()
}
/// The current target size in physical pixels.
pub fn size(&self) -> (u32, u32) {
self.size
}
/// Blit the offscreen colour into the host's (egui) `render_pass`, **scissored to
/// the widget rect** (the GPU twin of the CPU painter's `clip_poly_to_rect`).
/// `info` is the paint callback's `PaintCallbackInfo`; the scissor is the
/// intersection of its viewport and clip rect, so nothing lands outside the
/// component. A no-op when the widget is fully clipped away or targets aren't
/// ready. Returns the scissor it applied (zero-area ⇒ nothing painted) so a test
/// can assert the clip on data.
pub fn blit(&self, info: &egui::PaintCallbackInfo, render_pass: &mut wgpu::RenderPass<'static>) -> ScissorPx {
let Some(blit_bind) = &self.blit_bind else { return ScissorPx { x: 0, y: 0, w: 0, h: 0 } };
let s = gpu_scissor_px(info);
if s.w == 0 || s.h == 0 {
return s; // fully clipped — paint nothing
}
render_pass.set_scissor_rect(s.x, s.y, s.w, s.h);
render_pass.set_pipeline(&self.blit_pipeline);
render_pass.set_bind_group(0, blit_bind, &[]);
render_pass.draw(0..3, 0..1);
s
}
}
/// A physical-pixel scissor rectangle (x, y top-left + w×h), the wgpu
/// `set_scissor_rect` argument. Moved from `facett-map3d::gpu` so the GPU rect-clip
/// math is shared + unit-testable without a device.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ScissorPx {
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
}
/// Compute the GPU pass scissor (in physical pixels) for a paint callback: the
/// **intersection of the widget viewport and the clip rect**, clamped to the
/// framebuffer — the GPU twin of the CPU painter's `clip_poly_to_rect`. Pins the 3D
/// pass to the widget's own rectangle so nothing it draws can land outside the
/// component, regardless of the surrounding egui pass's clip rect.
pub fn gpu_scissor_px(info: &egui::PaintCallbackInfo) -> ScissorPx {
let vp = info.viewport_in_pixels();
let cl = info.clip_rect_in_pixels();
scissor_intersection(
(vp.left_px, vp.top_px, vp.width_px, vp.height_px),
(cl.left_px, cl.top_px, cl.width_px, cl.height_px),
info.screen_size_px,
)
}
/// Intersect two `(left, top, width, height)` pixel rects and clamp to the
/// framebuffer `screen` size. Plain ints (no egui types) so the rect math is testable
/// with zero setup. Returns a zero-area rect when the two do not overlap.
pub fn scissor_intersection(
viewport: (i32, i32, i32, i32),
clip: (i32, i32, i32, i32),
screen: [u32; 2],
) -> ScissorPx {
let (vx, vy, vw, vh) = viewport;
let (cx, cy, cw, ch) = clip;
let left = vx.max(cx).max(0);
let top = vy.max(cy).max(0);
let right = (vx + vw).min(cx + cw).min(screen[0] as i32);
let bottom = (vy + vh).min(cy + ch).min(screen[1] as i32);
let w = (right - left).max(0) as u32;
let h = (bottom - top).max(0) as u32;
ScissorPx { x: left.max(0) as u32, y: top.max(0) as u32, w, h }
}
#[cfg(test)]
mod tests {
use super::*;
/// INJECT-ASSERT (clip oracle, moved math): a widget fully inside the screen with
/// a clip equal to it scissors to exactly that rect.
/// **A pane wider than the device's texture limit never panics** (the loki crash,
/// 2026-10-16: 10 604 px > 8 192): the targets are clamped to the limit, the blit
/// scales them over the pane. RED before the clamp — wgpu's validation panic.
#[test]
fn a_pane_wider_than_the_texture_limit_is_clamped_not_a_panic() {
assert_eq!(super::super::clamp_extent(8192, 10_604, 900), (8192, 900));
assert_eq!(super::super::clamp_extent(8192, 0, 0), (1, 1));
use crate::render::gputurn::probe;
let Some((device, _queue, _turn)) = probe::open("l0-offscreen-limit", probe::OnSoftware::Refuse, probe::downlevel).map(|p| p.split()) else {
eprintln!("[offscreen] no GPU adapter — skipping the device leg");
return;
};
let max = device.limits().max_texture_dimension_2d;
let mut off = OffscreenColorDepth::new(&device, TextureFormat::Bgra8Unorm);
off.ensure(&device, max + 2412, 600);
assert_eq!(off.size(), (max, 600), "clamped to the device limit");
assert!(off.ready() && off.color_view().is_some());
off.ensure(&device, 300, max * 2);
assert_eq!(off.size(), (300, max));
}
#[test]
fn scissor_equals_widget_when_clip_covers_it() {
let s = scissor_intersection((50, 40, 200, 150), (50, 40, 200, 150), [640, 480]);
assert_eq!(s, ScissorPx { x: 50, y: 40, w: 200, h: 150 });
}
/// INJECT-ASSERT (the bug oracle): when the surrounding clip is the WHOLE PANEL,
/// the scissor is still pinned to the widget viewport — no ink outside the rect.
#[test]
fn scissor_pinned_to_widget_not_panel_clip() {
let s = scissor_intersection((300, 200, 120, 90), (0, 0, 640, 480), [640, 480]);
assert_eq!(s, ScissorPx { x: 300, y: 200, w: 120, h: 90 });
assert!(s.x + s.w <= 420 && s.y + s.h <= 290, "no ink outside the widget rect");
}
/// INJECT-ASSERT: a partial overlap scissors to the intersection, clamped to the
/// framebuffer.
#[test]
fn scissor_intersection_clamped() {
let s = scissor_intersection((500, 400, 300, 300), (550, 420, 300, 300), [640, 480]);
assert_eq!(s, ScissorPx { x: 550, y: 420, w: 90, h: 60 });
assert!(s.x + s.w <= 640 && s.y + s.h <= 480);
}
/// INJECT-ASSERT: disjoint clip → zero-area scissor (paint nothing).
#[test]
fn scissor_zero_area_when_disjoint() {
let s = scissor_intersection((0, 0, 100, 100), (300, 300, 50, 50), [640, 480]);
assert_eq!(s.w, 0);
}
/// INJECT-ASSERT: `gpu_scissor_px` reads a real `PaintCallbackInfo` (panel-wide
/// clip) and confines to the widget viewport — the exact value `blit` feeds
/// `set_scissor_rect`.
#[test]
fn gpu_scissor_px_from_callback_info() {
let info = egui::PaintCallbackInfo {
viewport: egui::Rect::from_min_size(egui::pos2(40.0, 30.0), egui::vec2(200.0, 150.0)),
clip_rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)),
pixels_per_point: 1.0,
screen_size_px: [800, 600],
};
let s = gpu_scissor_px(&info);
assert_eq!(s, ScissorPx { x: 40, y: 30, w: 200, h: 150 });
}
/// INJECT-ASSERT: the offscreen colour+depth formats match what a depth-tested
/// skin pipeline declares (so the skin's mesh pass attaches to these targets).
#[test]
fn offscreen_formats_are_the_depth_tested_pair() {
assert_eq!(DEPTH_FORMAT, TextureFormat::Depth32Float);
assert_eq!(OFFSCREEN_FORMAT, TextureFormat::Rgba16Float);
assert!(BLIT_WGSL.contains("blit_vs") && BLIT_WGSL.contains("blit_fs"));
}
/// INJECT-ASSERT (water prereq): the depth texture is created with BOTH
/// RENDER_ATTACHMENT (the depth test) and TEXTURE_BINDING (so the water /
/// depth-fog pass can SAMPLE the scene depth). A unit check on the usage flags
/// the GPU-less matrix can read — the actual sampling is proven in the wgpu
/// water blob oracle.
#[test]
fn depth_target_is_render_attachment_and_sampleable() {
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
assert!(usage.contains(wgpu::TextureUsages::RENDER_ATTACHMENT), "depth is a render target");
assert!(usage.contains(wgpu::TextureUsages::TEXTURE_BINDING), "depth is sampleable for water");
}
}