381 lines
19 KiB
Rust
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();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|