mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
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(())
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user