Files
wsg/lib/src/app.rs
T
Jérôme Bousquié 3a424afe8c GPU culling
2026-09-22 15:48:15 +02:00

381 lines
19 KiB
Rust

//! # App Facade Module — High-Level Application Orchestration
//!
//! Defines the `App` facade type and `AppHandler` trait that provide the high-level user-facing API.
//! `App` encapsulates window lifecycle, event loop, frame acquisition, and rendering automation.
//! Users implement `AppHandler` to inject their game logic into the render loop without touching wgpu directly.
//! The `AppBuilder` provides a builder-style constructor for creating configured `App` instances.
//!
//! ## Interaction with Other Modules
//! - **core::context**: Consumes GPU hardware lifecycle via Context; acquires frames for rendering.
//! - **core::renderer**: Delegates draw call execution to Renderer per frame.
//! - **scene::scene**: Exposes Scene as mutable field so users can register resources and entities.
//! Since Step 7 the `PipelineCache` lives *inside* the Scene (via `Scene::init_gpu`), owned and
//! used for material building there.
//! - **utils::conf**: Provides default window title, dimensions, and embedded WGSL source.
//! - **handler**: Defines the AppHandler trait that users implement for custom logic.
//!
//! ## Architecture Note (winit 0.30)
//! winit 0.30 removed the synchronous window-creation API (`WindowBuilder`) and the closure-based
//! `EventLoop::run`, replacing them with the [`ApplicationHandler`] model driven by `EventLoop::run_app`.
//! Windows can only be created inside `ApplicationHandler::resumed()`. Consequently this module builds
//! the window and GPU context lazily inside `AppRunner`'s `resumed()` callback, and exposes the
//! user-facing `AppBuilder::build → App::run` flow over that model. `AppHandler::setup()` is invoked
//! once right after GPU initialization so users can register shaders/meshes/materials/entities.
use crate::AppHandler;
use crate::core::{Context, InputState, Renderer};
use crate::scene::Scene;
use crate::utils::WsgError;
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
use std::sync::Arc;
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowAttributes};
/// High-level application facade that orchestrates window lifecycle, event loop, and rendering automation.
/// Encapsulates all five WGPU objects (Instance, Surface, Adapter, Device, Queue) plus the render loop.
/// Users create an App via AppBuilder, then run it with their implementation of AppHandler.
///
/// The GPU-facing fields (`context`, `renderer`, `window`) are created lazily when the application is
/// resumed (see `AppRunner`); they are only populated after `App::run` has started. The `PipelineCache`
/// is not a field here: since Step 7 it lives in the `Scene`'s pipeline context (via `Scene::init_gpu`).
/// Access GPU resources through the `context()`, `renderer()` and `window()` accessors, which are
/// guaranteed to work inside `AppHandler::setup`, `update` and `render`.
pub struct App {
/// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`.
pub scene: Scene,
/// Unified input state (keyboard/mouse/scroll, DRAFT Step 15). Fed by the winit window events
/// and rotated each frame by `begin_frame`/`end_frame` around `AppHandler::update`. Read it in
/// `update` via `app.input` (e.g. `app.input.key_held(KeyCode::KeyW)`).
pub input: InputState,
/// Window title, read by the runner when the window is created in `resumed`.
pub(crate) title: String,
/// Window width, read by the runner when the window is created in `resumed`.
pub(crate) width: u32,
/// Window height, read by the runner when the window is created in `resumed`.
pub(crate) height: u32,
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
pub(crate) culling: bool,
/// Winit event loop for window management. Set to None after run() consumes it.
event_loop: Option<EventLoop<()>>, // On met en Option pour pouvoir faire .take() facilement
/// GPU hardware context — owns Instance, Surface, Adapter, Device, Queue lifecycle.
context: Option<Context>,
/// Executor layer — binds Materials and Meshes into RenderPasses during draw calls.
renderer: Option<Renderer>,
/// The OS-level window backing this application. Shared via Arc for multi-owner access.
window: Option<Arc<Window>>,
}
impl App {
/// Returns a reference to the GPU renderer.
/// Panics if called before `App::run` has created the renderer (i.e. before `resumed` fires).
pub fn renderer(&self) -> &Renderer {
self.renderer
.as_ref()
.expect("renderer not initialized yet — call app.run(handler) first")
}
/// Returns a mutable reference to the GPU renderer.
/// Panics if called before `App::run` has created the renderer (i.e. before `resumed` fires).
/// Callers can configure the renderer here, e.g. `app.renderer_mut().set_unlit(true)` in `setup`
/// to select flat 2D rendering (DRAFT Step 5).
pub fn renderer_mut(&mut self) -> &mut Renderer {
self.renderer
.as_mut()
.expect("renderer not initialized yet — call app.run(handler) first")
}
/// Returns a reference to the GPU hardware context.
/// Panics if called before `App::run` has created the context (i.e. before `resumed` fires).
pub fn context(&self) -> &Context {
self.context
.as_ref()
.expect("context not initialized yet — call app.run(handler) first")
}
/// Returns a reference to the window backing this application.
/// Panics if called before `App::run` has created the window (i.e. before `resumed` fires).
pub fn window(&self) -> &Window {
self.window
.as_ref()
.expect("window not initialized yet — call app.run(handler) first")
.as_ref()
}
/// Runs the application's main loop: processes events, updates logic per frame, renders, and presents.
/// Inputs: handler — user-provided AppHandler implementation containing game logic.
/// Returns Ok(()) on success or Err(WsgError::WindowSystem) if the event loop exits abnormally.
/// Called once at application entry point; runs until the window is closed or an error occurs.
/// Internal steps: 1) take EventLoop from Option → 2) build an `AppRunner` around the handler →
/// 3) on resumed: create window/context/renderer/cache and call handler.setup() →
/// 4) on about_to_wait: call handler.update() + request_redraw →
/// 5) on RedrawRequested: acquire frame → call handler.render() → present frame →
/// 6) on CloseRequested: exit the event loop.
pub fn run<H: AppHandler + 'static>(mut self, handler: H) -> Result<(), WsgError> {
// Extract the event_loop safely via Option
let event_loop = self.event_loop.take().ok_or(WsgError::WindowSystem)?; // error if already taken
let mut runner = AppRunner {
title: self.title.clone(),
width: self.width,
height: self.height,
culling: self.culling,
handler,
app: None,
};
event_loop
.run_app(&mut runner)
.map_err(|_| WsgError::WindowSystem)
}
/// Renders every entity in `self.scene` into the given color view in a single batched render pass.
/// Called automatically each frame by the default `AppHandler::render`, or manually by users
/// who override `render` to control drawing themselves.
/// Inputs: view — the frame's texture view acting as the color attachment target.
///
/// The viewport aspect ratio (needed for the active camera's perspective projection, Step 4.3)
/// is derived here from the window's current inner size, so the `Renderer` stays independent of
/// the windowing backend.
pub fn render_scene(&self, view: &wgpu::TextureView) {
let size = self.window().inner_size();
let aspect = size.width as f32 / size.height.max(1) as f32;
self.renderer().render_scene(view, &self.scene, aspect);
}
/// Resizes the surface and depth texture to a new window size (ROADMAP Phase 4.4).
/// Reconfigures the surface via `Context::configure` (which returns the chosen format) and
/// recreates the depth texture via `Renderer::resize_depth` so the color and depth attachments
/// stay the same size. If the surface format changes (rare, deterministic per window), the
/// Scene's GPU context is re-initialized to the new format; otherwise the swap alone suffices.
/// Inputs: width/height — the new surface dimensions in pixels.
/// Returns Ok(()) on success or a `WsgError` if the surface cannot be reconfigured.
pub fn resize(&mut self, width: u32, height: u32) -> Result<(), WsgError> {
let context = self.context.as_ref().ok_or(WsgError::SurfaceIncompatible)?;
let old_format = self.renderer().format();
let new_format = context.configure(&context.adapter, width, height)?;
self.renderer_mut().resize_depth(width, height);
self.renderer_mut().set_format(new_format);
if new_format != old_format {
// Surface format changed: re-wire the Scene's GPU context (device + queue + format)
// so its PipelineCache/pipelines match the new surface format.
let device = std::sync::Arc::new(self.renderer_mut().device().clone());
self.scene
.init_gpu(device, self.context().queue.clone(), new_format);
}
Ok(())
}
}
/// Builder for constructing a configured `App` instance with custom title and dimensions.
/// Provides a fluent API for setting window properties before building the full application context.
pub struct AppBuilder {
/// Window title displayed in the OS taskbar/window decorations.
title: String,
/// Window width in pixels.
width: u32,
/// Window height in pixels.
height: u32,
/// GPU frustum culling enabled (Step 15, D8). Defaults to false (non-regression).
culling: bool,
}
impl AppBuilder {
/// Creates an AppBuilder with default values: "WSG App" title, 800x600 resolution.
/// Called as the entry point of the builder pattern — always start here.
pub fn new() -> Self {
Self {
title: APP_DEFAULT_TITLE.to_string(),
width: APP_DEFAULT_WIDTH,
height: APP_DEFAULT_HEIGHT,
culling: false,
}
}
/// Sets the window title to display in the OS taskbar/window decorations.
/// Inputs: title (string reference). Returns Self for method chaining.
pub fn title(mut self, title: &str) -> Self {
self.title = title.to_string();
self
}
/// Sets the window dimensions in pixels.
/// Inputs: width (pixel count), height (pixel count). Returns Self for method chaining.
pub fn size(mut self, width: u32, height: u32) -> Self {
self.width = width;
self.height = height;
self
}
/// Enables GPU frustum culling (Step 15, D8). When true, entities whose bounding sphere is
/// fully outside the camera frustum are skipped (their indirect draw args are zeroed on the
/// GPU). Defaults to **off** (non-regression): the culling compute pass still runs but marks
/// every active entity visible, so the rendered image is identical to culling-off.
pub fn with_culling(mut self, enabled: bool) -> Self {
self.culling = enabled;
self
}
/// Builds the configured `App` instance: creates the event loop and stores the window
/// configuration. The GPU context, window and renderer are created later, when the event loop
/// is resumed (inside `App::run`), because winit 0.30 only allows window creation in that phase.
/// Returns Ok(App) on success or Err(WsgError) if the event loop cannot be created.
/// Called after setting desired properties via the builder pattern before `App::run`.
pub async fn build(self) -> Result<App, WsgError> {
let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?;
Ok(App {
scene: Scene::new(),
input: InputState::new(),
title: self.title,
width: self.width,
height: self.height,
culling: self.culling,
event_loop: Some(event_loop),
context: None,
renderer: None,
window: None,
})
}
}
/// Internal runner that adapts a user `AppHandler` to winit's 0.30 `ApplicationHandler` model.
/// It owns the window/GPU lifecycle: everything is created lazily inside `resumed()`, then the
/// user's `setup`, `update` and `render` hooks are driven from the corresponding winit events.
struct AppRunner<H: AppHandler> {
/// Window title, applied when the window is created in `resumed`.
title: String,
/// Window width in pixels, applied when the window is created in `resumed`.
width: u32,
/// Window height in pixels, applied when the window is created in `resumed`.
height: u32,
/// GPU frustum culling (Step 15, D8); applied to the renderer in `resumed`.
culling: bool,
/// The user-provided game logic.
handler: H,
/// The fully-built App facade, populated on the first `resumed` event.
app: Option<App>,
}
impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
/// Builds the window, GPU context, renderer and shader cache, then invokes the user's `setup`.
/// Guarded so redundant back-to-back `resumed` events do not re-initialize the GPU.
/// Inputs: event_loop — the active event loop used to create the window and control redrawing.
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.app.is_some() {
return;
}
event_loop.set_control_flow(ControlFlow::Poll);
let attrs = WindowAttributes::default()
.with_title(&self.title)
.with_inner_size(LogicalSize::new(self.width as f64, self.height as f64));
let window = Arc::new(
event_loop
.create_window(attrs)
.map_err(|_| WsgError::WindowSystem)
.expect("failed to create window"),
);
// GPU initialization (blocking, kept as simple as possible)
let context = pollster::block_on(Context::new(window.clone())).expect("GPU init failed");
let format = context
.configure(&context.adapter, self.width, self.height)
.expect("surface configuration failed");
let device = Arc::new(context.device.clone());
let renderer = Renderer::new(&context, format, self.width, self.height);
// Step 15, D8: apply the culling flag (off by default — non-regression).
renderer.set_culling(self.culling);
// Step 7 (DRAFT 7.1): the PipelineCache now lives in the Scene. We wire the GPU context
// (device + queue + format + cache) into the Scene before setup so it can build materials/meshes.
let mut scene = Scene::new();
scene.init_gpu(device, context.queue.clone(), format);
let mut app = App {
scene,
input: InputState::new(),
title: self.title.clone(),
width: self.width,
height: self.height,
culling: self.culling,
event_loop: None,
context: Some(context),
renderer: Some(renderer),
window: Some(window),
};
// Let the user register shaders/meshes/materials/entities once the GPU is ready.
self.handler.setup(&mut app);
self.app = Some(app);
}
/// Drives the user's per-frame update and requests a redraw so the window renders continuously.
/// Inputs: _event_loop — active event loop (unused here).
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
let Some(app) = self.app.as_mut() else {
return;
};
// Poll the device each frame: wgpu only fires async callbacks (queue.on_submitted_work_done,
// buffer/texture map_async) when the device is polled, and the event loop never does it on
// our behalf. `Wait` with no timeout = block until the most recent submission completes
// (i.e. once per frame on a live GPU, which is what we want for the windowed loop).
// A failed poll (e.g. a device-lost error) is logged, not fatal: the next frame's poll
// will retry, and wgpu surfaces the loss through the device's error handler anyway.
if let Err(e) = app.context().device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
}) {
eprintln!("WSG: device.poll() failed ({e:?})");
}
// Step 15 (input): start the input frame (rotate pressed/released + reset deltas),
// run the user logic, then close (clear the transient states).
app.input.begin_frame();
self.handler.update(app);
app.input.end_frame();
app.window().request_redraw();
}
/// Dispatches window events: RedrawRequested renders/presents a frame, CloseRequested exits.
/// Inputs: event_loop — active event loop, used to exit on close; event — the window event.
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: winit::window::WindowId,
event: WindowEvent,
) {
let Some(app) = self.app.as_mut() else {
return;
};
// Step 15 (input): feed the unified state from winit events (keyboard/mouse/wheel).
app.input.handle_window_event(&event);
match event {
WindowEvent::Resized(size) => {
// Guard (D3): minimizing the window sends Resized(0x0); never reconfigure at 0.
let w = size.width as u32;
let h = size.height as u32;
if w == 0 || h == 0 {
return;
}
// Step 11: reconfigure surface + depth to the new size, then re-render.
if let Err(e) = app.resize(w, h) {
eprintln!("WSG: resize error ({e:?})");
}
app.window().request_redraw();
}
WindowEvent::RedrawRequested => {
// Guard (D6): do not render on a zero-sized surface (minimized window).
let size = app.window().inner_size();
if size.width == 0 || size.height == 0 {
return;
}
// Rendering logic
let frame = app.context().get_next_frame();
// Call the user's render() (receives the current frame)
self.handler.render(app, &frame);
// Present automatically
app.renderer().present(frame);
}
WindowEvent::CloseRequested => {
event_loop.exit();
}
_ => {}
}
}
}