Add a compositor for offscreen frames on one GPU device

Expose renderer frames and surface handoff, with a standalone two-view GPU smoke example. Part 1 of the splitscreen series; normal game rendering is unchanged.

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
martipopsandCodex committed 2026-10-29 09:34:38 -04:00
1 parent 9c15e18c6f
commit f9a97f264f
4 files changed
+385

No files matched your search

@@ -0,0 +1,151 @@
//! Two offscreen frames presented in one window, without game resources or player data.
//! `cargo run --profile fast -p horizon-client-render --example compositor -- --smoke`
use horizon_client_render::gfx::compositor::Compositor;
use std::sync::Arc;
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
struct Frames {
window: Arc<Window>,
compositor: Compositor,
textures: [wgpu::Texture; 2],
}
impl Frames {
fn new(event_loop: &ActiveEventLoop) -> horizon_client_render::Result<Self> {
let window = Arc::new(
event_loop.create_window(
Window::default_attributes()
.with_title("Compositor: red and blue views")
.with_inner_size(winit::dpi::PhysicalSize::new(640, 360)),
)?,
);
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
Box::new(window.clone()),
));
let surface = instance.create_surface(window.clone())?;
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
compatible_surface: Some(&surface),
..Default::default()
}))?;
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
let textures = [[0, 0, 255, 255], [255, 0, 0, 255]].map(|pixel| {
let size = wgpu::Extent3d {
width: 320,
height: 360,
depth_or_array_layers: 1,
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Offscreen view"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8Unorm,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
texture.as_image_copy(),
&pixel.repeat(320 * 360),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(320 * 4),
rows_per_image: Some(360),
},
size,
);
texture
});
let compositor = Compositor::new(
surface,
&adapter,
&device,
&queue,
window.inner_size().into(),
)?;
Ok(Self {
window,
compositor,
textures,
})
}
}
#[derive(Default)]
struct Demo {
frames: Option<Frames>,
error: Option<String>,
presented: usize,
smoke: bool,
}
impl ApplicationHandler for Demo {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.frames.is_none() {
match Frames::new(event_loop) {
Ok(frames) => self.frames = Some(frames),
Err(error) => {
self.error = Some(error.to_string());
event_loop.exit();
}
}
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
let Some(f) = &mut self.frames else { return };
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => f.compositor.resize(size.width, size.height),
// Uncovered pixels are black; resize smaller to exercise clipping.
WindowEvent::RedrawRequested
if f.compositor
.present(&[(&f.textures[0], [0, 0]), (&f.textures[1], [320, 0])]) =>
{
self.presented += 1;
if self.smoke && self.presented == 10 {
f.compositor.set_vsync(false);
f.compositor.resize(0, 0);
assert!(!f.compositor.present(&[]));
f.compositor.resize(u32::MAX, u32::MAX);
assert!(!f.compositor.present(&[]));
f.compositor.resize(400, 240);
let _ = f
.window
.request_inner_size(winit::dpi::PhysicalSize::new(400, 240));
}
if self.smoke && self.presented == 20 {
println!("compositor: 20 frames, resize, suspension and clipping passed");
event_loop.exit();
}
}
_ => {}
}
}
fn about_to_wait(&mut self, _: &ActiveEventLoop) {
if let Some(f) = &self.frames {
f.window.request_redraw();
}
}
}
fn main() -> horizon_client_render::Result<()> {
let mut demo = Demo {
smoke: std::env::args().any(|a| a == "--smoke"),
..Default::default()
};
EventLoop::new()?.run_app(&mut demo)?;
if let Some(error) = demo.error {
return Err(error.into());
}
if demo.smoke && demo.presented < 20 {
return Err("smoke test closed early".into());
}
Ok(())
}
+1
View File
@@ -69,6 +69,7 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
mod animate;
mod arena;
pub mod compositor;
mod cull;
mod end_sky;
mod flat;
@@ -0,0 +1,214 @@
//! Splitscreen: several players' renderers draw offscreen on one device, and the
//! compositor copies each frame into its place on a window's surface. A window holds views but
//! never owns them, so a view can later move to another window (another monitor, detached).
use super::Renderer;
use crate::Result;
use std::sync::Arc;
use winit::window::Window;
pub struct Compositor {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
present_modes: Vec<wgpu::PresentMode>,
suspended: bool,
}
impl Renderer {
/// A compositor for `window` on this renderer's device, presenting on the surface it gave up.
pub fn compositor(&self, window: Arc<Window>) -> Result<Compositor> {
// The surface this renderer set aside for its successor (`release_surface`), else a new
// one: a window can't keep two.
let spare = self
.spare_surface
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
let surface = match spare {
Some(surface) => surface,
None => self.instance.create_surface(window.clone())?,
};
Compositor::new(
surface,
&self.adapter,
&self.device,
&self.queue,
window.inner_size().into(),
)
}
/// The last offscreen frame (`--offscreen`), for the compositor.
pub fn offscreen_frame(&self) -> Option<&wgpu::Texture> {
self.offscreen.as_ref()
}
/// Splitscreen turned off: a compositor's surface, set aside for the renderer that draws on
/// the window again (it takes it, as after `release_surface`).
pub fn take_back(&self, compositor: Compositor) {
*self
.spare_surface
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(compositor.surface);
}
}
impl Compositor {
/// Present offscreen BGRA frames on a compatible surface using their existing device.
/// The surface must support COPY_DST and Bgra8Unorm; unsupported surfaces return an error.
pub fn new(
surface: wgpu::Surface<'static>,
adapter: &wgpu::Adapter,
device: &wgpu::Device,
queue: &wgpu::Queue,
size: [u32; 2],
) -> Result<Self> {
let caps = surface.get_capabilities(adapter);
// ponytail: a plain texture copy needs the offscreen frames' format; a blit pass would
// take any surface format (Rgba8Unorm on some Linux drivers).
if !caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
return Err("surface does not support copying offscreen frames".into());
}
let format = wgpu::TextureFormat::Bgra8Unorm;
if !caps.formats.contains(&format) {
return Err(format!(
"splitscreen needs a {format:?} surface; this one offers {:?}",
caps.formats
)
.into());
}
let max = device.limits().max_texture_dimension_2d;
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
format,
width: size[0].clamp(1, max),
height: size[1].clamp(1, max),
present_mode: wgpu::PresentMode::Fifo,
desired_maximum_frame_latency: 2,
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
view_formats: vec![],
color_space: wgpu::SurfaceColorSpace::default(),
};
surface.configure(device, &config);
Ok(Compositor {
surface,
device: device.clone(),
queue: queue.clone(),
config,
present_modes: caps.present_modes,
suspended: !super::drawable_size(size[0], size[1], max),
})
}
/// Vsync on: presents wait for the display. Off: they don't (Mailbox, else Immediate).
pub fn set_vsync(&mut self, vsync: bool) {
let mode = [wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate]
.into_iter()
.find(|m| !vsync && self.present_modes.contains(m))
.unwrap_or(wgpu::PresentMode::Fifo);
if mode != self.config.present_mode {
self.config.present_mode = mode;
self.surface.configure(&self.device, &self.config);
}
}
pub fn resize(&mut self, width: u32, height: u32) {
self.suspended =
!super::drawable_size(width, height, self.device.limits().max_texture_dimension_2d);
if !self.suspended {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
}
}
/// Copy each view's frame to its top-left corner on the window and present. Frames that
/// don't fit are clipped; parts of the window no view covers (a view before its first frame,
/// one still at its old size after a resize) are black. Nothing when the window has no frame
/// to give (occluded, resized, timed out). Returns true when a frame was submitted.
pub fn present(&mut self, views: &[(&wgpu::Texture, [u32; 2])]) -> bool {
if self.suspended {
return false;
}
let frame = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(f)
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
self.surface.configure(&self.device, &self.config);
return false;
}
_ => return false,
};
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Splitscreen compositor"),
});
let target = frame.texture.create_view(&Default::default());
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Splitscreen compositor clear"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
}));
for &(texture, [x, y]) in views {
let [width, height] = clipped_size(
[texture.width(), texture.height()],
[x, y],
[self.config.width, self.config.height],
);
if width == 0 || height == 0 {
continue;
}
encoder.copy_texture_to_texture(
texture.as_image_copy(),
wgpu::TexelCopyTextureInfo {
origin: wgpu::Origin3d { x, y, z: 0 },
..frame.texture.as_image_copy()
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
}
self.queue.submit([encoder.finish()]);
self.queue.present(frame);
true
}
}
/// Copies never reach past the surface, including while a resized view still has its old frame.
fn clipped_size(view: [u32; 2], at: [u32; 2], surface: [u32; 2]) -> [u32; 2] {
[
view[0].min(surface[0].saturating_sub(at[0])),
view[1].min(surface[1].saturating_sub(at[1])),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frames_are_clipped_to_the_window_during_resize() {
assert_eq!(clipped_size([640, 480], [0, 0], [1280, 720]), [640, 480]);
assert_eq!(
clipped_size([640, 480], [1000, 600], [1280, 720]),
[280, 120]
);
assert_eq!(clipped_size([640, 480], [1280, 720], [1280, 720]), [0, 0]);
assert_eq!(
clipped_size([640, 480], [u32::MAX, 0], [1280, 720]),
[0, 480]
);
}
}
+19
View File
@@ -0,0 +1,19 @@
# Offscreen frame compositor
Part 1 of the splitscreen series (#3541). The renderer exposes its offscreen frame and can hand
its window surface to a compositor on the same GPU device. The compositor clears uncovered
pixels to black and copies each BGRA view to its assigned position, clipping at the window edge.
Returning the surface through `Renderer::take_back` lets the next renderer draw on the window
again. The normal game path does not create a compositor.
A standalone example exercises this without game resources, profiles, audio or launcher data:
```sh
cargo run --profile fast -p horizon-client-render --example compositor
cargo run --profile fast -p horizon-client-render --example compositor -- --smoke
```
The window shows a red view on the left and a blue one on the right. Resize it to check clipping
and uncovered black regions. The smoke run presents 20 frames, switches vsync off, tests zero and
oversized dimensions, resizes the window, then exits. Unsupported surface copy formats return an
error. Unit coverage checks clipping while a view still has its old size during a resize.