From 3c6a149f012005e6f4e044d9267e71a6f3ba994a Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Fri, 4 Sep 2026 20:47:19 +0300 Subject: [PATCH 01/50] fix: present the canvas opaque so a browser does not composite the page through it A WebGPU canvas reports premultiplied alpha first, so `alpha_modes[0]` picks it and the browser blends every pixel against the page. Nothing downstream sets alpha: the post-processing pass clears to transparent to mark empty background, so the whole frame arrives with a = 0 and the UI appears to bleed into the scene. A window on macOS or Windows lists Opaque first and never noticed, and the offscreen path has no surface at all -- which is why screenshots looked right while the browser did not. Prefer Opaque where the surface offers it. --- src/window/wgpu_canvas.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index 9be615a20..3e4882b92 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -590,7 +590,20 @@ impl WgpuCanvas { width, height, present_mode, - alpha_mode: surface_caps.alpha_modes[0], + // Opaque wherever the surface offers it. A window on macOS or + // Windows lists it first anyway, but a WebGPU canvas lists + // premultiplied first, and then the browser composites the page + // through every pixel whose alpha the renderer never set — which + // is all of them, since the post-processing pass marks empty + // background with a = 0 and nothing downstream restores it. + alpha_mode: if surface_caps + .alpha_modes + .contains(&wgpu::CompositeAlphaMode::Opaque) + { + wgpu::CompositeAlphaMode::Opaque + } else { + surface_caps.alpha_modes[0] + }, view_formats: vec![], desired_maximum_frame_latency: 2, }; From a556d84d9d939ee0d0ec0a8f86d26ee04a06e2f4 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 5 Sep 2026 08:01:42 +0300 Subject: [PATCH 02/50] add screen buffer. Add ime events. Add safe area support --- Cargo.toml | 2 +- src/event/ime.rs | 39 ++++++++++ src/event/mod.rs | 2 + src/post_processing/hdr.rs | 6 +- src/resource/material.rs | 15 ++++ src/scene/scene_node2d.rs | 99 +++++++++++++++++++++++++ src/window/canvas.rs | 15 ++++ src/window/events.rs | 26 ++++++- src/window/ios.rs | 20 +++++ src/window/rendering.rs | 146 +++++++++++++++++++++++++++++++------ src/window/wgpu_canvas.rs | 58 +++++++++++++++ src/window/window.rs | 40 ++++++++++ 12 files changed, 444 insertions(+), 24 deletions(-) create mode 100644 src/event/ime.rs diff --git a/Cargo.toml b/Cargo.toml index 64d430122..b2f657019 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,7 +84,7 @@ android-activity = "0.6.1" [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" objc2-foundation = "0.3" -objc2-ui-kit = { version = "0.3", features = ["UIResponder", "UIView", "UITextInput"] } +objc2-ui-kit = { version = "0.3", features = ["UIGeometry", "UIResponder", "UIView", "UITextInput"] } [target.wasm32-unknown-unknown.dependencies] oneshot = { version = "0.2.0", features = ["async"] } diff --git a/src/event/ime.rs b/src/event/ime.rs new file mode 100644 index 000000000..9fb69c95f --- /dev/null +++ b/src/event/ime.rs @@ -0,0 +1,39 @@ +//! Composed text input: what an input method editor reports while a word is +//! being assembled, and what it commits. +//! +//! Delivered beside the event stream rather than inside it, as dropped +//! files are: [`WindowEvent`](super::WindowEvent) is `Copy`, and composed +//! text is not. Nothing arrives until +//! [`Window::set_ime_allowed`](crate::window::Window::set_ime_allowed) has +//! been called with `true`. + +/// One report from the platform's input method editor. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ImeEvent { + /// The editor took the keyboard: composition may follow. + Enabled, + /// The text being composed so far, with the caret range in bytes of that + /// text when the editor places one. Shown in place of nothing; the next + /// `Preedit` or a `Commit` replaces it. + Preedit { + text: String, + cursor: Option<(usize, usize)>, + }, + /// Composed text the editor settled on: what a text field appends. + Commit(String), + /// The editor let go; any preedit shown is withdrawn. + Disabled, +} + +impl ImeEvent { + /// From winit's own report. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn from_winit(ime: winit::event::Ime) -> Self { + match ime { + winit::event::Ime::Enabled => Self::Enabled, + winit::event::Ime::Preedit(text, cursor) => Self::Preedit { text, cursor }, + winit::event::Ime::Commit(text) => Self::Commit(text), + winit::event::Ime::Disabled => Self::Disabled, + } + } +} diff --git a/src/event/mod.rs b/src/event/mod.rs index 2bc7caf66..ee47e333d 100644 --- a/src/event/mod.rs +++ b/src/event/mod.rs @@ -1,7 +1,9 @@ //! Window event handling. pub use self::event_manager::{Event, EventManager, Events}; +pub use self::ime::ImeEvent; pub use self::window_event::{Action, Key, Modifiers, MouseButton, TouchAction, WindowEvent}; mod event_manager; +mod ime; mod window_event; diff --git a/src/post_processing/hdr.rs b/src/post_processing/hdr.rs index cbd80f253..b59260999 100644 --- a/src/post_processing/hdr.rs +++ b/src/post_processing/hdr.rs @@ -1005,7 +1005,11 @@ impl HdrPipeline { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HDR_FORMAT, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + // COPY_SRC: a 2D material reading the screen takes a copy of the + // film mid-frame; without MSAA that copy is a plain texture copy. + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let scene_view = scene_texture.create_view(&wgpu::TextureViewDescriptor::default()); diff --git a/src/resource/material.rs b/src/resource/material.rs index eccb7f8c3..48df8c1cc 100644 --- a/src/resource/material.rs +++ b/src/resource/material.rs @@ -297,6 +297,14 @@ pub struct RenderContext2d { pub viewport_width: u32, /// The viewport height in pixels. pub viewport_height: u32, + /// The frame drawn so far, as a single-sample copy of the HDR film in + /// linear light, for a material whose [`Material2d::reads_screen`] says + /// so. Refreshed just before each such object draws; `None` when nothing + /// in the scene reads it. + pub screen: Option, + /// Counts replacements of `screen` (a resize makes a new texture), so a + /// bind group built over it knows when to rebuild. + pub screen_generation: u64, } /// Context for 2D renderers that need to create their own render passes. @@ -330,6 +338,13 @@ pub trait Material2d { /// Creates per-object GPU data for this material. fn create_gpu_data(&self) -> Box; + /// Whether this material samples [`RenderContext2d::screen`]. The 2D pass + /// is split around each object that says so, and the film copied before + /// it draws, so the copy holds everything drawn beneath it this frame. + fn reads_screen(&self) -> bool { + false + } + /// Called at the start of each frame before any objects are prepared. /// /// This allows materials to reset per-frame state, such as clearing diff --git a/src/scene/scene_node2d.rs b/src/scene/scene_node2d.rs index 5bc00e6f7..0bf53fdbc 100644 --- a/src/scene/scene_node2d.rs +++ b/src/scene/scene_node2d.rs @@ -172,6 +172,105 @@ impl SceneNodeData2d { } } + /// Whether any visible object under this node draws with a material + /// that reads the screen, which is what decides whether the frame's 2D + /// pass has to be split. + pub fn has_screen_reader(&self) -> bool { + if !self.visible { + return false; + } + if let Some(o) = &self.object { + if o.material().borrow().reads_screen() { + return true; + } + } + self.children.iter().any(|c| c.data().has_screen_reader()) + } + + /// Render the scene graph rooted by this node, splitting the pass around + /// every object whose material reads the screen: `begin_pass` opens a + /// pass that loads what is there, `copy_screen` refreshes the copy such + /// a material samples, and both run between the closed pass and the + /// next. Objects before the first reader draw exactly as [`Self::render`] + /// draws them. + pub fn render_with_screen( + &mut self, + camera: &mut dyn Camera2d, + encoder: &mut wgpu::CommandEncoder, + context: &RenderContext2d, + begin_pass: &mut dyn FnMut(&mut wgpu::CommandEncoder) -> wgpu::RenderPass<'static>, + copy_screen: &mut dyn FnMut(&mut wgpu::CommandEncoder), + ) { + if !self.visible { + return; + } + let mut pass = Some(begin_pass(encoder)); + self.do_render_with_screen( + Pose2::IDENTITY, + Vec2::ONE, + camera, + encoder, + &mut pass, + context, + begin_pass, + copy_screen, + ); + drop(pass); + } + + #[allow(clippy::too_many_arguments)] + fn do_render_with_screen( + &mut self, + transform: Pose2, + scale: Vec2, + camera: &mut dyn Camera2d, + encoder: &mut wgpu::CommandEncoder, + pass: &mut Option>, + context: &RenderContext2d, + begin_pass: &mut dyn FnMut(&mut wgpu::CommandEncoder) -> wgpu::RenderPass<'static>, + copy_screen: &mut dyn FnMut(&mut wgpu::CommandEncoder), + ) { + if !self.up_to_date { + self.up_to_date = true; + self.world_transform = transform * self.local_transform; + self.world_scale = scale * self.local_scale; + } + + if let Some(ref mut o) = self.object { + if o.material().borrow().reads_screen() { + // Close the pass so the film holds everything drawn so far, + // copy it, and open the next pass over the same film. + drop(pass.take()); + copy_screen(encoder); + *pass = Some(begin_pass(encoder)); + } + let render_pass = pass.as_mut().expect("a pass is open between splits"); + o.render( + self.world_transform, + self.world_scale, + camera, + render_pass, + context, + ) + } + + for c in self.children.iter_mut() { + let mut bc = c.data_mut(); + if bc.visible { + bc.do_render_with_screen( + self.world_transform, + self.world_scale, + camera, + encoder, + pass, + context, + begin_pass, + copy_screen, + ) + } + } + } + /// A reference to the object possibly contained by this node. #[inline] pub fn object(&self) -> Option<&Object2d> { diff --git a/src/window/canvas.rs b/src/window/canvas.rs index 2b50edc9a..b4f25587f 100644 --- a/src/window/canvas.rs +++ b/src/window/canvas.rs @@ -204,6 +204,21 @@ impl Canvas { self.canvas.take_dropped_files() } + /// Composed text the input method reported since the last call. + pub fn take_ime_events(&self) -> Vec { + self.canvas.take_ime_events() + } + + /// Let the platform compose text through its input method. + pub fn set_ime_allowed(&self, allowed: bool) { + self.canvas.set_ime_allowed(allowed); + } + + /// The display's insets in pixels as `[left, top, right, bottom]`. + pub fn safe_area(&self) -> [f32; 4] { + self.canvas.safe_area() + } + /// Set the cursor position. pub fn set_cursor_position(&self, x: f64, y: f64) { self.canvas.set_cursor_position(x, y); diff --git a/src/window/events.rs b/src/window/events.rs index 310aff921..0af4aebf2 100644 --- a/src/window/events.rs +++ b/src/window/events.rs @@ -2,7 +2,7 @@ use crate::camera::Camera2d; use crate::camera::Camera3d; -use crate::event::{Action, EventManager, Key, MouseButton, WindowEvent}; +use crate::event::{Action, EventManager, ImeEvent, Key, MouseButton, WindowEvent}; use super::Window; @@ -94,6 +94,30 @@ impl Window { unhandled_events.borrow_mut().clear(); self.canvas.poll_events(); + // Composed text rides beside the stream: kept for `ime_events`, and + // fed to egui, whose text fields compose the same way. + let ime = self.canvas.take_ime_events(); + #[cfg(feature = "egui")] + for event in &ime { + // egui counts the caret in characters where winit counts bytes; + // enabling and disabling it no longer hears. + let event = match event { + ImeEvent::Preedit { text, cursor } => egui::ImeEvent::Preedit { + text: text.clone(), + active_range_chars: cursor.map(|(start, end)| { + let chars = |byte: usize| text[..byte.min(text.len())].chars().count(); + chars(start)..chars(end) + }), + }, + ImeEvent::Commit(text) => egui::ImeEvent::Commit(text.clone()), + ImeEvent::Enabled | ImeEvent::Disabled => continue, + }; + self.egui_context + .raw_input + .events + .push(egui::Event::Ime(event)); + } + *self.ime_events.borrow_mut() = ime; } pub(crate) fn handle_event( diff --git a/src/window/ios.rs b/src/window/ios.rs index 6d237f0eb..f6cacc5b7 100644 --- a/src/window/ios.rs +++ b/src/window/ios.rs @@ -209,6 +209,26 @@ define_class!( } ); +/// The insets a notch, the status bar and the home indicator take, in +/// points as `[left, top, right, bottom]`; zeros before the view is laid out. +pub(crate) fn safe_area(window: &Window) -> [f64; 4] { + use wgpu::rwh::{HasWindowHandle, RawWindowHandle}; + + if MainThreadMarker::new().is_none() { + return [0.0; 4]; + } + let Ok(handle) = window.window_handle() else { + return [0.0; 4]; + }; + let RawWindowHandle::UiKit(ui_kit) = handle.as_raw() else { + return [0.0; 4]; + }; + // Valid while `window` is alive, which the borrow guarantees. + let view: &UIView = unsafe { ui_kit.ui_view.cast().as_ref() }; + let insets = view.safeAreaInsets(); + [insets.left, insets.top, insets.right, insets.bottom] +} + /// Show or hide the system keyboard for `window`. pub(crate) fn set_keyboard_visible(window: &Window, visible: bool) { use wgpu::rwh::{HasWindowHandle, RawWindowHandle}; diff --git a/src/window/rendering.rs b/src/window/rendering.rs index fbbed1859..6107c9b5a 100644 --- a/src/window/rendering.rs +++ b/src/window/rendering.rs @@ -29,6 +29,42 @@ const STARTUP_SURFACE_TIMEOUT: std::time::Duration = std::time::Duration::from_s const SURFACE_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(16); impl Window { + /// The frame-so-far copy a screen-reading 2D material samples, at the + /// film's size; a new texture, and a new generation, when that changes. + fn screen_copy_2d(&mut self, width: u32, height: u32) -> &super::window::ScreenCopy2d { + let stale = self + .screen_2d + .as_ref() + .is_none_or(|copy| copy.width != width || copy.height != height); + if stale { + let generation = self.screen_2d.as_ref().map_or(1, |copy| copy.generation + 1); + let texture = Context::get().create_texture(&wgpu::TextureDescriptor { + label: Some("2d_screen_copy"), + size: wgpu::Extent3d { + width: width.max(1), + height: height.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: crate::post_processing::HDR_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + self.screen_2d = Some(super::window::ScreenCopy2d { + view, + width, + height, + generation, + }); + } + self.screen_2d.as_ref().expect("the copy was just made") + } + /// Renders one frame of a 3D scene. /// /// This is the main rendering function that should be called in your render loop. @@ -903,11 +939,25 @@ impl Window { // Render the 2D planar scene (into the HDR film, like the 3D scene). { + // A material that reads the screen samples a copy of the film + // taken just before its object draws, so the pass below is split + // around each such object. The copy is made only once asked. + let reads_screen = scene_2d + .as_deref() + .is_some_and(|scene| scene.data().has_screen_reader()); + let (screen_view, screen_generation) = if reads_screen { + let copy = self.screen_copy_2d(w, h); + (Some(copy.view.clone()), copy.generation) + } else { + (None, 0) + }; let context_2d = RenderContext2d { surface_format: Context::render_format(), sample_count, viewport_width: w, viewport_height: h, + screen: screen_view.clone(), + screen_generation, }; // Clear material buffers for the new frame @@ -927,27 +977,81 @@ impl Window { // bandwidth — and skipping it leaves the film's contents untouched, // exactly as a no-op Load/Store pass would. if let Some(scene_2d) = scene_2d { - let scene2d_ts = self.gpu_timer.render_scope("2d"); - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("2d_scene_render_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &color_view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - depth_stencil_attachment: None, - timestamp_writes: scene2d_ts, - occlusion_query_set: None, - multiview_mask: None, - }); - - scene_2d - .data_mut() - .render(camera_2d, &mut render_pass, &context_2d); + let mut scene2d_ts = self.gpu_timer.render_scope("2d"); + // One pass over the film, opened again after every screen + // copy; only the first carries the timestamps. + let mut begin_pass = |encoder: &mut wgpu::CommandEncoder| { + encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("2d_scene_render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &color_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: scene2d_ts.take(), + occlusion_query_set: None, + multiview_mask: None, + }) + .forget_lifetime() + }; + match screen_view { + None => { + let mut render_pass = begin_pass(&mut encoder); + scene_2d + .data_mut() + .render(camera_2d, &mut render_pass, &context_2d); + } + Some(screen_view) => { + // Under MSAA a pass with no draws resolves the film + // into the copy; without it the film copies as is. + let mut copy_screen = |encoder: &mut wgpu::CommandEncoder| { + if resolve_view.is_some() { + let _resolve = + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("2d_screen_copy_resolve"), + color_attachments: &[Some( + wgpu::RenderPassColorAttachment { + view: &color_view, + resolve_target: Some(&screen_view), + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + }, + )], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + } else { + encoder.copy_texture_to_texture( + color_view.texture().as_image_copy(), + screen_view.texture().as_image_copy(), + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + } + }; + scene_2d.data_mut().render_with_screen( + camera_2d, + &mut encoder, + &context_2d, + &mut begin_pass, + &mut copy_screen, + ); + } + } } // Polylines and points render on top of surfaces (into the HDR film). diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index 3e4882b92..01266fcd7 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -97,6 +97,10 @@ thread_local! { // is Copy and a PathBuf is not. Drained by `take_dropped_files`. static DROPPED_FILES: RefCell> = const { RefCell::new(Vec::new()) }; + /// Composed text the input method reported, drained per window each + /// frame by `take_ime_events`, as dropped files are. + static IME_EVENTS: RefCell> = + const { RefCell::new(Vec::new()) }; } // Android lifecycle plumbing. winit can only build an EventLoop from the @@ -229,6 +233,11 @@ pub(crate) fn collect_window_event(window_id: winit::window::WindowId, event: Wi DROPPED_FILES.with(|dropped| dropped.borrow_mut().push((window_id, path))); vec![] } + WinitWindowEvent::Ime(ime) => { + let event = crate::event::ImeEvent::from_winit(ime); + IME_EVENTS.with(|events| events.borrow_mut().push((window_id, event))); + vec![] + } _ => vec![], }; @@ -1871,6 +1880,55 @@ impl WgpuCanvas { Vec::new() } + /// Composed text the input method reported since the last call, in + /// order. Always empty until `set_ime_allowed(true)`. + pub fn take_ime_events(&self) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + { + let Some(window) = &self.window else { + return Vec::new(); + }; + let id = window.id(); + IME_EVENTS.with(|events| { + let mut events = events.borrow_mut(); + let (mine, others): (Vec<_>, Vec<_>) = + events.drain(..).partition(|(window, _)| *window == id); + *events = others; + mine.into_iter().map(|(_, event)| event).collect() + }) + } + #[cfg(target_arch = "wasm32")] + { + Vec::new() + } + } + + /// Ask the platform to compose text through its input method: what a + /// CJK keyboard needs before it sends anything. Off, keys arrive as plain + /// characters only. + pub fn set_ime_allowed(&self, allowed: bool) { + if let Some(window) = &self.window { + window.set_ime_allowed(allowed); + } + } + + /// The insets a notch, a status bar or a home indicator take, in pixels + /// as `[left, top, right, bottom]`. Zero everywhere but iOS. + pub fn safe_area(&self) -> [f32; 4] { + #[cfg(target_os = "ios")] + { + let Some(window) = &self.window else { + return [0.0; 4]; + }; + let scale = window.scale_factor(); + super::ios::safe_area(window).map(|points| (points * scale) as f32) + } + #[cfg(not(target_os = "ios"))] + { + [0.0; 4] + } + } + /// Set the cursor grabbing behaviour. pub fn set_cursor_grab(&self, grab: bool) { use winit::window::CursorGrabMode; diff --git a/src/window/window.rs b/src/window/window.rs index b7e5d36fd..1f772beda 100644 --- a/src/window/window.rs +++ b/src/window/window.rs @@ -48,9 +48,25 @@ pub(super) static DEFAULT_SHADOW_RESOLUTION: u32 = 2048u32; /// Structure representing a window and a 3D scene. /// /// This is the main interface with the 3d engine. +/// The single-sample copy of the HDR film that a 2D material reading the +/// screen samples, with the generation a bind group over it compares. +pub(super) struct ScreenCopy2d { + /// The view keeps its texture alive; a copy reaches it through the view. + pub(super) view: wgpu::TextureView, + pub(super) width: u32, + pub(super) height: u32, + pub(super) generation: u64, +} + pub struct Window { pub(super) events: Rc>, pub(super) unhandled_events: Rc>>, + /// This frame's composed-text reports, drained from the canvas once per + /// frame and kept for `ime_events`. + pub(super) ime_events: Rc>>, + /// The frame-so-far copy a screen-reading 2D material samples: made on + /// the first frame something asks, remade when the film changes size. + pub(super) screen_2d: Option, pub(super) ambient_intensity: f32, pub(super) ambient_color: Color, pub(super) fog: crate::light::Fog, @@ -360,6 +376,26 @@ impl Window { self.canvas.take_dropped_files() } + /// Composed text the input method reported this frame, in order: the + /// preedit as it changes and the text it commits. Empty until + /// [`Self::set_ime_allowed`] has been called with `true`. egui text + /// fields hear the same events on their own. + pub fn ime_events(&self) -> Vec { + self.ime_events.borrow().clone() + } + + /// Let the platform compose text through its input method, so a CJK + /// keyboard reaches text fields; a hardware keyboard is unaffected. + pub fn set_ime_allowed(&self, allowed: bool) { + self.canvas.set_ime_allowed(allowed); + } + + /// The insets a notch, a status bar or a home indicator take, in pixels + /// as `[left, top, right, bottom]`. Zero everywhere but iOS. + pub fn safe_area(&self) -> [f32; 4] { + self.canvas.safe_area() + } + /// Sets the cursor position in window coordinates. /// /// # Arguments @@ -1030,6 +1066,8 @@ impl Window { canvas, events: Rc::new(event_receive), unhandled_events: Rc::new(RefCell::new(Vec::new())), + ime_events: Rc::new(RefCell::new(Vec::new())), + screen_2d: None, ambient_intensity: 0.2, ambient_color: crate::color::WHITE, fog: crate::light::Fog::default(), @@ -1116,6 +1154,8 @@ impl Window { canvas, events: Rc::new(event_receive), unhandled_events: Rc::new(RefCell::new(Vec::new())), + ime_events: Rc::new(RefCell::new(Vec::new())), + screen_2d: None, ambient_intensity: 0.2, ambient_color: crate::color::WHITE, fog: crate::light::Fog::default(), From 25602cdff50fc37edff2f618e5591c8585af5e0f Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 5 Sep 2026 08:12:50 +0300 Subject: [PATCH 03/50] remove/simplify comments --- Cargo.toml | 1 + src/event/ime.rs | 23 +++------ src/post_processing/hdr.rs | 3 +- src/resource/material.rs | 14 ++--- src/scene/scene_node2d.rs | 14 ++--- src/window/egui_integration.rs | 65 ++++++++++++----------- src/window/events.rs | 5 +- src/window/ios.rs | 3 +- src/window/rendering.rs | 17 +++--- src/window/wgpu_canvas.rs | 94 +++++++++++++++++++++++----------- src/window/window.rs | 21 +++----- 11 files changed, 131 insertions(+), 129 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b2f657019..f15d19be3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,7 @@ web-sys = { version = "0.3", features = [ "TouchList", "HtmlCanvasElement", "HtmlElement", + "Navigator", "Window", "UiEvent", "Event", diff --git a/src/event/ime.rs b/src/event/ime.rs index 9fb69c95f..9dd159c4a 100644 --- a/src/event/ime.rs +++ b/src/event/ime.rs @@ -1,32 +1,23 @@ -//! Composed text input: what an input method editor reports while a word is -//! being assembled, and what it commits. -//! -//! Delivered beside the event stream rather than inside it, as dropped -//! files are: [`WindowEvent`](super::WindowEvent) is `Copy`, and composed -//! text is not. Nothing arrives until -//! [`Window::set_ime_allowed`](crate::window::Window::set_ime_allowed) has -//! been called with `true`. +//! Composed text from the platform's input method, delivered beside the +//! `Copy` event stream as dropped files are. -/// One report from the platform's input method editor. +/// One report from the input method; nothing arrives until +/// [`Window::set_ime_allowed`](crate::window::Window::set_ime_allowed). #[derive(Clone, Debug, PartialEq, Eq)] pub enum ImeEvent { - /// The editor took the keyboard: composition may follow. Enabled, - /// The text being composed so far, with the caret range in bytes of that - /// text when the editor places one. Shown in place of nothing; the next - /// `Preedit` or a `Commit` replaces it. + /// The text composed so far, replacing the last preedit; `cursor` is a + /// byte range of it. Preedit { text: String, cursor: Option<(usize, usize)>, }, - /// Composed text the editor settled on: what a text field appends. + /// Settled text, to be appended; the preedit is gone. Commit(String), - /// The editor let go; any preedit shown is withdrawn. Disabled, } impl ImeEvent { - /// From winit's own report. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn from_winit(ime: winit::event::Ime) -> Self { match ime { diff --git a/src/post_processing/hdr.rs b/src/post_processing/hdr.rs index b59260999..b7595f225 100644 --- a/src/post_processing/hdr.rs +++ b/src/post_processing/hdr.rs @@ -1005,8 +1005,7 @@ impl HdrPipeline { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: HDR_FORMAT, - // COPY_SRC: a 2D material reading the screen takes a copy of the - // film mid-frame; without MSAA that copy is a plain texture copy. + // COPY_SRC: a screen-reading 2D material copies the film mid-frame. usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, diff --git a/src/resource/material.rs b/src/resource/material.rs index 48df8c1cc..a22e00e1d 100644 --- a/src/resource/material.rs +++ b/src/resource/material.rs @@ -297,13 +297,10 @@ pub struct RenderContext2d { pub viewport_width: u32, /// The viewport height in pixels. pub viewport_height: u32, - /// The frame drawn so far, as a single-sample copy of the HDR film in - /// linear light, for a material whose [`Material2d::reads_screen`] says - /// so. Refreshed just before each such object draws; `None` when nothing - /// in the scene reads it. + /// The frame so far, a single-sample copy of the HDR film refreshed just + /// before each [`Material2d::reads_screen`] object draws; else `None`. pub screen: Option, - /// Counts replacements of `screen` (a resize makes a new texture), so a - /// bind group built over it knows when to rebuild. + /// Bumped when `screen` is a new texture, so bind groups over it rebuild. pub screen_generation: u64, } @@ -338,9 +335,8 @@ pub trait Material2d { /// Creates per-object GPU data for this material. fn create_gpu_data(&self) -> Box; - /// Whether this material samples [`RenderContext2d::screen`]. The 2D pass - /// is split around each object that says so, and the film copied before - /// it draws, so the copy holds everything drawn beneath it this frame. + /// Whether this material samples [`RenderContext2d::screen`]; the 2D pass + /// is split around each object that does, the film copied before it. fn reads_screen(&self) -> bool { false } diff --git a/src/scene/scene_node2d.rs b/src/scene/scene_node2d.rs index 0bf53fdbc..4f5dee1a2 100644 --- a/src/scene/scene_node2d.rs +++ b/src/scene/scene_node2d.rs @@ -172,9 +172,7 @@ impl SceneNodeData2d { } } - /// Whether any visible object under this node draws with a material - /// that reads the screen, which is what decides whether the frame's 2D - /// pass has to be split. + /// Whether any visible object under this node reads the screen. pub fn has_screen_reader(&self) -> bool { if !self.visible { return false; @@ -187,12 +185,8 @@ impl SceneNodeData2d { self.children.iter().any(|c| c.data().has_screen_reader()) } - /// Render the scene graph rooted by this node, splitting the pass around - /// every object whose material reads the screen: `begin_pass` opens a - /// pass that loads what is there, `copy_screen` refreshes the copy such - /// a material samples, and both run between the closed pass and the - /// next. Objects before the first reader draw exactly as [`Self::render`] - /// draws them. + /// [`Self::render`], with the pass closed, `copy_screen` run and a pass + /// from `begin_pass` opened before every object whose material reads the screen. pub fn render_with_screen( &mut self, camera: &mut dyn Camera2d, @@ -238,8 +232,6 @@ impl SceneNodeData2d { if let Some(ref mut o) = self.object { if o.material().borrow().reads_screen() { - // Close the pass so the film holds everything drawn so far, - // copy it, and open the next pass over the same film. drop(pass.take()); copy_screen(encoder); *pass = Some(begin_pass(encoder)); diff --git a/src/window/egui_integration.rs b/src/window/egui_integration.rs index 34de44463..4cfd92ff8 100644 --- a/src/window/egui_integration.rs +++ b/src/window/egui_integration.rs @@ -2,7 +2,7 @@ use egui::RawInput; -use crate::event::{Action, Key, WindowEvent}; +use crate::event::{Action, Modifiers, WindowEvent}; use crate::renderer::EguiRenderer; use super::Window; @@ -37,6 +37,34 @@ impl EguiContext { } } +/// Whether ⌘ (Super) is the platform's command modifier: on macOS, and on +/// any Apple platform reached through a browser. Everywhere else it is Ctrl. +fn command_is_super() -> bool { + #[cfg(target_arch = "wasm32")] + { + super::wgpu_canvas::apple_platform() + } + #[cfg(not(target_arch = "wasm32"))] + { + cfg!(target_os = "macos") + } +} + +/// egui's view of the modifiers an event arrived with. Read from the event +/// rather than from the key states: those are polled once a frame, so a chord +/// tapped within one frame has already released its modifier by then. +fn egui_modifiers(modifiers: Modifiers) -> egui::Modifiers { + let ctrl = modifiers.contains(Modifiers::Control); + let mac_cmd = command_is_super() && modifiers.contains(Modifiers::Super); + egui::Modifiers { + alt: modifiers.contains(Modifiers::Alt), + ctrl, + shift: modifiers.contains(Modifiers::Shift), + mac_cmd, + command: if command_is_super() { mac_cmd } else { ctrl }, + } +} + impl Window { /// Retrieves a mutable reference to the egui context. /// @@ -134,7 +162,7 @@ impl Window { .events .push(egui::Event::PointerMoved(pos)); } - WindowEvent::MouseButton(button, action, _) => { + WindowEvent::MouseButton(button, action, modifiers) => { let button = match button { crate::event::MouseButton::Button1 => egui::PointerButton::Primary, crate::event::MouseButton::Button2 => egui::PointerButton::Secondary, @@ -157,11 +185,11 @@ impl Window { pos, button, pressed, - modifiers: self.get_egui_modifiers(), + modifiers: egui_modifiers(modifiers), }); } } - WindowEvent::Scroll(x, y, _) => { + WindowEvent::Scroll(x, y, modifiers) => { // Use Point unit since kiss3d's scroll values are already scaled // (native multiplies LineDelta by 10, WASM applies various scales) self.egui_context @@ -171,7 +199,7 @@ impl Window { unit: egui::MouseWheelUnit::Point, delta: egui::Vec2::new(x as f32, y as f32), phase: egui::TouchPhase::Move, - modifiers: self.get_egui_modifiers(), + modifiers: egui_modifiers(modifiers), }); } WindowEvent::Touch(id, x, y, action, _) => { @@ -243,14 +271,14 @@ impl Window { .events .push(egui::Event::Text(ch.to_string())); } - WindowEvent::Key(key, action, _modifiers) => { + WindowEvent::Key(key, action, modifiers) => { if let Some(egui_key) = self.translate_key_to_egui(key) { self.egui_context.raw_input.events.push(egui::Event::Key { key: egui_key, physical_key: None, pressed: action == Action::Press, repeat: false, - modifiers: self.get_egui_modifiers(), + modifiers: egui_modifiers(modifiers), }); } } @@ -258,29 +286,6 @@ impl Window { } } - pub(crate) fn get_egui_modifiers(&self) -> egui::Modifiers { - let ctrl = self.get_key(Key::LControl) == Action::Press - || self.get_key(Key::RControl) == Action::Press; - // On macOS the platform command modifier is the ⌘ (Super) key, which - // arrives as LWin/RWin; everywhere else it is Ctrl. - let super_down = - self.get_key(Key::LWin) == Action::Press || self.get_key(Key::RWin) == Action::Press; - let mac_cmd = cfg!(target_os = "macos") && super_down; - egui::Modifiers { - alt: self.get_key(Key::LAlt) == Action::Press - || self.get_key(Key::RAlt) == Action::Press, - ctrl, - shift: self.get_key(Key::LShift) == Action::Press - || self.get_key(Key::RShift) == Action::Press, - mac_cmd, - command: if cfg!(target_os = "macos") { - mac_cmd - } else { - ctrl - }, - } - } - pub(crate) fn translate_key_to_egui(&self, key: Key) -> Option { Some(match key { Key::A => egui::Key::A, diff --git a/src/window/events.rs b/src/window/events.rs index 0af4aebf2..caf1c8c5c 100644 --- a/src/window/events.rs +++ b/src/window/events.rs @@ -94,13 +94,10 @@ impl Window { unhandled_events.borrow_mut().clear(); self.canvas.poll_events(); - // Composed text rides beside the stream: kept for `ime_events`, and - // fed to egui, whose text fields compose the same way. let ime = self.canvas.take_ime_events(); #[cfg(feature = "egui")] for event in &ime { - // egui counts the caret in characters where winit counts bytes; - // enabling and disabling it no longer hears. + // egui counts the caret in characters where winit counts bytes. let event = match event { ImeEvent::Preedit { text, cursor } => egui::ImeEvent::Preedit { text: text.clone(), diff --git a/src/window/ios.rs b/src/window/ios.rs index f6cacc5b7..5fbbbc0ce 100644 --- a/src/window/ios.rs +++ b/src/window/ios.rs @@ -209,8 +209,7 @@ define_class!( } ); -/// The insets a notch, the status bar and the home indicator take, in -/// points as `[left, top, right, bottom]`; zeros before the view is laid out. +/// `[left, top, right, bottom]` in points; zeros before the view is laid out. pub(crate) fn safe_area(window: &Window) -> [f64; 4] { use wgpu::rwh::{HasWindowHandle, RawWindowHandle}; diff --git a/src/window/rendering.rs b/src/window/rendering.rs index 6107c9b5a..87103ad9c 100644 --- a/src/window/rendering.rs +++ b/src/window/rendering.rs @@ -29,15 +29,17 @@ const STARTUP_SURFACE_TIMEOUT: std::time::Duration = std::time::Duration::from_s const SURFACE_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(16); impl Window { - /// The frame-so-far copy a screen-reading 2D material samples, at the - /// film's size; a new texture, and a new generation, when that changes. + /// The frame-so-far copy at the film's size; a new generation on resize. fn screen_copy_2d(&mut self, width: u32, height: u32) -> &super::window::ScreenCopy2d { let stale = self .screen_2d .as_ref() .is_none_or(|copy| copy.width != width || copy.height != height); if stale { - let generation = self.screen_2d.as_ref().map_or(1, |copy| copy.generation + 1); + let generation = self + .screen_2d + .as_ref() + .map_or(1, |copy| copy.generation + 1); let texture = Context::get().create_texture(&wgpu::TextureDescriptor { label: Some("2d_screen_copy"), size: wgpu::Extent3d { @@ -939,9 +941,6 @@ impl Window { // Render the 2D planar scene (into the HDR film, like the 3D scene). { - // A material that reads the screen samples a copy of the film - // taken just before its object draws, so the pass below is split - // around each such object. The copy is made only once asked. let reads_screen = scene_2d .as_deref() .is_some_and(|scene| scene.data().has_screen_reader()); @@ -978,8 +977,7 @@ impl Window { // exactly as a no-op Load/Store pass would. if let Some(scene_2d) = scene_2d { let mut scene2d_ts = self.gpu_timer.render_scope("2d"); - // One pass over the film, opened again after every screen - // copy; only the first carries the timestamps. + // Only the first pass over the film carries the timestamps. let mut begin_pass = |encoder: &mut wgpu::CommandEncoder| { encoder .begin_render_pass(&wgpu::RenderPassDescriptor { @@ -1008,8 +1006,7 @@ impl Window { .render(camera_2d, &mut render_pass, &context_2d); } Some(screen_view) => { - // Under MSAA a pass with no draws resolves the film - // into the copy; without it the film copies as is. + // MSAA: an empty pass resolves the film into the copy. let mut copy_screen = |encoder: &mut wgpu::CommandEncoder| { if resolve_view.is_some() { let _resolve = diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index 01266fcd7..5ea1dd432 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -97,8 +97,7 @@ thread_local! { // is Copy and a PathBuf is not. Drained by `take_dropped_files`. static DROPPED_FILES: RefCell> = const { RefCell::new(Vec::new()) }; - /// Composed text the input method reported, drained per window each - /// frame by `take_ime_events`, as dropped files are. + /// Composed text per window, drained by `take_ime_events` like dropped files. static IME_EVENTS: RefCell> = const { RefCell::new(Vec::new()) }; } @@ -396,10 +395,11 @@ impl WgpuCanvas { let document = web_window.document().expect("Failed to get document"); // Try to find an existing canvas with the configured id, or create one - let canvas = document + let page_canvas = document .get_element_by_id(&canvas_setup.canvas_id) - .and_then(|elem| elem.dyn_into::().ok()) - .unwrap_or_else(|| { + .and_then(|elem| elem.dyn_into::().ok()); + let created = page_canvas.is_none(); + let canvas = page_canvas.unwrap_or_else(|| { // Create a new canvas element let canvas = document .create_element("canvas") @@ -417,24 +417,28 @@ impl WgpuCanvas { canvas }); - // Style html and body to fill 100% - if let Some(html) = document.document_element() { - if let Some(html) = html.dyn_ref::() { - let style = html.style(); + // A canvas kiss3d had to create is the whole page: size the + // document around it. One the page supplied sits in a layout the + // page owns, which keeps its margins and its scrolling. + if created { + if let Some(html) = document.document_element() { + if let Some(html) = html.dyn_ref::() { + let style = html.style(); + let _ = style.set_property("margin", "0"); + let _ = style.set_property("padding", "0"); + let _ = style.set_property("width", "100%"); + let _ = style.set_property("height", "100%"); + } + } + if let Some(body) = document.body() { + let style = body.style(); let _ = style.set_property("margin", "0"); let _ = style.set_property("padding", "0"); let _ = style.set_property("width", "100%"); let _ = style.set_property("height", "100%"); + let _ = style.set_property("overflow", "hidden"); } } - if let Some(body) = document.body() { - let style = body.style(); - let _ = style.set_property("margin", "0"); - let _ = style.set_property("padding", "0"); - let _ = style.set_property("width", "100%"); - let _ = style.set_property("height", "100%"); - let _ = style.set_property("overflow", "hidden"); - } let window_attrs = window_attrs.with_canvas(Some(canvas)); @@ -599,12 +603,8 @@ impl WgpuCanvas { width, height, present_mode, - // Opaque wherever the surface offers it. A window on macOS or - // Windows lists it first anyway, but a WebGPU canvas lists - // premultiplied first, and then the browser composites the page - // through every pixel whose alpha the renderer never set — which - // is all of them, since the post-processing pass marks empty - // background with a = 0 and nothing downstream restores it. + // Opaque where offered: a WebGPU canvas lists premultiplied first, + // and the page would show through every pixel left at alpha 0. alpha_mode: if surface_caps .alpha_modes .contains(&wgpu::CompositeAlphaMode::Opaque) @@ -884,6 +884,16 @@ impl WgpuCanvas { pending .borrow_mut() .push(WindowEvent::Key(key, Action::Press, modifiers)); + // macOS sends no keyup for a key released while ⌘ is held, + // which would leave it pressed for good: release it now. + if modifiers.contains(Modifiers::Super) + && apple_platform() + && !is_modifier_key(key) + { + pending + .borrow_mut() + .push(WindowEvent::Key(key, Action::Release, modifiers)); + } // Emit a Char event for single-character (printable) keys so // egui text fields receive text input. Skip when a command // modifier is held so shortcuts (e.g. Ctrl+A) don't insert text, @@ -1880,8 +1890,7 @@ impl WgpuCanvas { Vec::new() } - /// Composed text the input method reported since the last call, in - /// order. Always empty until `set_ime_allowed(true)`. + /// Composed text since the last call; empty until `set_ime_allowed(true)`. pub fn take_ime_events(&self) -> Vec { #[cfg(not(target_arch = "wasm32"))] { @@ -1903,17 +1912,14 @@ impl WgpuCanvas { } } - /// Ask the platform to compose text through its input method: what a - /// CJK keyboard needs before it sends anything. Off, keys arrive as plain - /// characters only. + /// Let the platform compose text through its input method. pub fn set_ime_allowed(&self, allowed: bool) { if let Some(window) = &self.window { window.set_ime_allowed(allowed); } } - /// The insets a notch, a status bar or a home indicator take, in pixels - /// as `[left, top, right, bottom]`. Zero everywhere but iOS. + /// `[left, top, right, bottom]` insets in pixels; zero everywhere but iOS. pub fn safe_area(&self) -> [f32; 4] { #[cfg(target_os = "ios")] { @@ -2276,6 +2282,34 @@ fn translate_web_mouse_button(button: i16) -> MouseButton { } } +/// Whether the browser runs on an Apple platform, where ⌘ is the command +/// key. A page can only tell from the user agent; iPadOS reports itself as a +/// Mac, which is right, since its keyboards carry ⌘ too. +#[cfg(target_arch = "wasm32")] +pub(crate) fn apple_platform() -> bool { + thread_local! { + static APPLE: bool = web_sys::window() + .and_then(|w| w.navigator().user_agent().ok()) + .is_some_and(|ua| ua.contains("Mac")); + } + APPLE.with(|apple| *apple) +} + +#[cfg(target_arch = "wasm32")] +fn is_modifier_key(key: Key) -> bool { + matches!( + key, + Key::LShift + | Key::RShift + | Key::LControl + | Key::RControl + | Key::LAlt + | Key::RAlt + | Key::LWin + | Key::RWin + ) +} + #[cfg(target_arch = "wasm32")] fn translate_web_key(code: &str) -> Key { match code { diff --git a/src/window/window.rs b/src/window/window.rs index 1f772beda..98bc80fbb 100644 --- a/src/window/window.rs +++ b/src/window/window.rs @@ -48,10 +48,8 @@ pub(super) static DEFAULT_SHADOW_RESOLUTION: u32 = 2048u32; /// Structure representing a window and a 3D scene. /// /// This is the main interface with the 3d engine. -/// The single-sample copy of the HDR film that a 2D material reading the -/// screen samples, with the generation a bind group over it compares. +/// The single-sample film copy a screen-reading 2D material samples. pub(super) struct ScreenCopy2d { - /// The view keeps its texture alive; a copy reaches it through the view. pub(super) view: wgpu::TextureView, pub(super) width: u32, pub(super) height: u32, @@ -61,11 +59,8 @@ pub(super) struct ScreenCopy2d { pub struct Window { pub(super) events: Rc>, pub(super) unhandled_events: Rc>>, - /// This frame's composed-text reports, drained from the canvas once per - /// frame and kept for `ime_events`. pub(super) ime_events: Rc>>, - /// The frame-so-far copy a screen-reading 2D material samples: made on - /// the first frame something asks, remade when the film changes size. + /// Made on the first frame a material asks; remade when the film resizes. pub(super) screen_2d: Option, pub(super) ambient_intensity: f32, pub(super) ambient_color: Color, @@ -376,22 +371,18 @@ impl Window { self.canvas.take_dropped_files() } - /// Composed text the input method reported this frame, in order: the - /// preedit as it changes and the text it commits. Empty until - /// [`Self::set_ime_allowed`] has been called with `true`. egui text - /// fields hear the same events on their own. + /// This frame's composed text, preedits and commits in order; empty until + /// [`Self::set_ime_allowed`]. egui's fields hear the same events on their own. pub fn ime_events(&self) -> Vec { self.ime_events.borrow().clone() } - /// Let the platform compose text through its input method, so a CJK - /// keyboard reaches text fields; a hardware keyboard is unaffected. + /// Let the platform compose text through its input method. pub fn set_ime_allowed(&self, allowed: bool) { self.canvas.set_ime_allowed(allowed); } - /// The insets a notch, a status bar or a home indicator take, in pixels - /// as `[left, top, right, bottom]`. Zero everywhere but iOS. + /// `[left, top, right, bottom]` insets in pixels; zero everywhere but iOS. pub fn safe_area(&self) -> [f32; 4] { self.canvas.safe_area() } From 2b0a9697477b139c2ba8b118fbdf8e7d135da13c Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 5 Sep 2026 08:16:09 +0300 Subject: [PATCH 04/50] =?UTF-8?q?web:=20a=20supplied=20canvas=20keeps=20it?= =?UTF-8?q?s=20page's=20scrolling;=20=E2=8C=98=20chords=20and=20keyup=20on?= =?UTF-8?q?=20Apple=20browsers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page that supplies the canvas owns its layout: kiss3d no longer forces html/body to 100% with overflow hidden there, which had killed the page's scrolling. egui modifiers now come from the event that carried them rather than from key states polled once a frame, so a chord tapped inside one frame still matches; ⌘ is the command key in a browser on an Apple platform, and a key released while ⌘ is held gets the keyup macOS never sends. Most of this landed mid-edit in 25602cdf; this restores the Key import it lost and formats. --- src/window/egui_integration.rs | 2 +- src/window/wgpu_canvas.rs | 38 ++++++++++++++++++---------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/window/egui_integration.rs b/src/window/egui_integration.rs index 4cfd92ff8..1bd687d50 100644 --- a/src/window/egui_integration.rs +++ b/src/window/egui_integration.rs @@ -2,7 +2,7 @@ use egui::RawInput; -use crate::event::{Action, Modifiers, WindowEvent}; +use crate::event::{Action, Key, Modifiers, WindowEvent}; use crate::renderer::EguiRenderer; use super::Window; diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index 5ea1dd432..be48749bc 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -400,22 +400,22 @@ impl WgpuCanvas { .and_then(|elem| elem.dyn_into::().ok()); let created = page_canvas.is_none(); let canvas = page_canvas.unwrap_or_else(|| { - // Create a new canvas element - let canvas = document - .create_element("canvas") - .expect("Failed to create canvas element") - .dyn_into::() - .expect("Failed to cast to HtmlCanvasElement"); - canvas.set_id(&canvas_setup.canvas_id); - - // Append to body - if let Some(body) = document.body() { - body.append_child(&canvas) - .expect("Failed to append canvas to body"); - } + // Create a new canvas element + let canvas = document + .create_element("canvas") + .expect("Failed to create canvas element") + .dyn_into::() + .expect("Failed to cast to HtmlCanvasElement"); + canvas.set_id(&canvas_setup.canvas_id); + + // Append to body + if let Some(body) = document.body() { + body.append_child(&canvas) + .expect("Failed to append canvas to body"); + } - canvas - }); + canvas + }); // A canvas kiss3d had to create is the whole page: size the // document around it. One the page supplied sits in a layout the @@ -890,9 +890,11 @@ impl WgpuCanvas { && apple_platform() && !is_modifier_key(key) { - pending - .borrow_mut() - .push(WindowEvent::Key(key, Action::Release, modifiers)); + pending.borrow_mut().push(WindowEvent::Key( + key, + Action::Release, + modifiers, + )); } // Emit a Char event for single-character (printable) keys so // egui text fields receive text input. Skip when a command From db8ad10291e1ae547adf4dc9e63a13b432792cb6 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 5 Sep 2026 08:48:19 +0300 Subject: [PATCH 05/50] Update events.rs --- src/window/events.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/window/events.rs b/src/window/events.rs index caf1c8c5c..67d8b1ea2 100644 --- a/src/window/events.rs +++ b/src/window/events.rs @@ -2,7 +2,7 @@ use crate::camera::Camera2d; use crate::camera::Camera3d; -use crate::event::{Action, EventManager, ImeEvent, Key, MouseButton, WindowEvent}; +use crate::event::{Action, EventManager, Key, MouseButton, WindowEvent}; use super::Window; @@ -97,6 +97,7 @@ impl Window { let ime = self.canvas.take_ime_events(); #[cfg(feature = "egui")] for event in &ime { + use crate::event::ImeEvent; // egui counts the caret in characters where winit counts bytes. let event = match event { ImeEvent::Preedit { text, cursor } => egui::ImeEvent::Preedit { From 890aaab3321ad56e4f10a9202388d50174ce7e9e Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 5 Sep 2026 09:17:54 +0300 Subject: [PATCH 06/50] update egui renderer --- src/renderer/egui_renderer.rs | 15 ++++++++++++++ src/window/egui_integration.rs | 37 ++++++++++++++++++++++++++++++---- src/window/inspector/mod.rs | 8 ++++---- src/window/offscreen.rs | 2 +- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/renderer/egui_renderer.rs b/src/renderer/egui_renderer.rs index 0781c9a93..ab5d67422 100644 --- a/src/renderer/egui_renderer.rs +++ b/src/renderer/egui_renderer.rs @@ -112,6 +112,21 @@ impl EguiRenderer { /// [`egui::TextureId`] that `ui.image((id, size))` can draw — no CPU copy /// involved. The texture stays registered until /// [`Self::unregister_native_texture`]. + /// Throw the open pass away and begin it again with `raw_input`, as + /// `Context::run` does when a pass asks to be discarded: its shapes are + /// dropped, its texture uploads kept (the font atlas may have grown), and + /// its pass count carried over so egui knows how many it has run. + pub fn rerun_frame(&mut self, raw_input: RawInput) { + let output = self.egui_ctx.end_pass(); + self.textures_delta.append(output.textures_delta); + let passes = output.platform_output.num_completed_passes; + // `end_pass` took the viewport's output; `will_discard` reads the + // count back off the fresh one. + self.egui_ctx + .output_mut(|output| output.num_completed_passes = passes); + self.egui_ctx.begin_pass(raw_input); + } + pub fn register_native_texture( &mut self, view: &wgpu::TextureView, diff --git a/src/window/egui_integration.rs b/src/window/egui_integration.rs index 1bd687d50..d40b6fd87 100644 --- a/src/window/egui_integration.rs +++ b/src/window/egui_integration.rs @@ -20,6 +20,9 @@ pub(crate) struct EguiContext { /// cursor, so the first finger down plays that role until it lifts; /// other fingers are ignored rather than fighting over the pointer. pub(crate) pointer_touch_id: Option, + /// What a rerun of the open pass begins with: the pass's input less its + /// events, as `Context::run` hands the later passes of a frame. + pub(crate) rerun_input: RawInput, #[cfg(not(target_arch = "wasm32"))] pub(crate) start_time: std::time::Instant, } @@ -31,6 +34,7 @@ impl EguiContext { raw_input: RawInput::default(), pass_active: false, pointer_touch_id: None, + rerun_input: RawInput::default(), #[cfg(not(target_arch = "wasm32"))] start_time: std::time::Instant::now(), } @@ -369,9 +373,9 @@ impl Window { /// /// # Note /// Only available when the `egui` feature is enabled. - pub fn draw_ui(&mut self, ui_fn: F) + pub fn draw_ui(&mut self, mut ui_fn: F) where - F: FnOnce(&egui::Context), + F: FnMut(&egui::Context), { // Open the egui pass lazily so that several `draw_ui` (and // `draw_inspector`) calls in the same frame all run their widgets into @@ -379,11 +383,32 @@ impl Window { // `finish_egui_pass`. Beginning a fresh pass per call would have the // second call's `end_frame` overwrite the first call's shapes (and the // `std::mem::take` below would starve it of input). - if !self.egui_context.pass_active { + let opened = !self.egui_context.pass_active; + if opened { self.begin_egui_pass(); } ui_fn(self.egui_context.renderer.context()); + + // A pass that only learned a size — a new `Area`, which draws none of + // itself while it is measured, a `Grid`, a `Resize` — asks to be + // discarded and run again before the frame is shown. `Context::run` + // does that for its callers; with the pass open here, this does, or + // the frame shows the gap. Only the call that opened the pass reruns: + // a rerun replays one closure, and an earlier call's shapes are gone. + let max_passes = self + .egui_context + .renderer + .context() + .options(|options| options.max_passes.get()); + let mut passes = 1; + while opened && passes < max_passes && self.egui_context.renderer.context().will_discard() { + self.egui_context + .renderer + .rerun_frame(self.egui_context.rerun_input.clone()); + ui_fn(self.egui_context.renderer.context()); + passes += 1; + } } /// Begins a new egui pass, feeding it the events accumulated since the last @@ -420,7 +445,11 @@ impl Window { raw_input.time = time; raw_input.predicted_dt = 1.0 / 60.0; - self.egui_context.renderer.begin_frame(raw_input); + // `take` leaves what `Context::run` hands a later pass of the same + // frame: the time, and none of the events. + let first = raw_input.take(); + self.egui_context.rerun_input = raw_input; + self.egui_context.renderer.begin_frame(first); self.egui_context.pass_active = true; } diff --git a/src/window/inspector/mod.rs b/src/window/inspector/mod.rs index 907c9e958..7edb7e4ef 100644 --- a/src/window/inspector/mod.rs +++ b/src/window/inspector/mod.rs @@ -446,8 +446,8 @@ impl Window { pub fn draw_inspector( &mut self, inspector: &mut Inspector, - scene: Option<&mut SceneNode3d>, - scene_2d: Option<&mut SceneNode2d>, + mut scene: Option<&mut SceneNode3d>, + mut scene_2d: Option<&mut SceneNode2d>, mut raytracer: Option<&mut RayTracer>, ) { // One-time seeding of UI state from the live window. @@ -481,8 +481,8 @@ impl Window { self.draw_ui(|ctx| { inspector.ui( ctx, - scene, - scene_2d, + scene.as_deref_mut(), + scene_2d.as_deref_mut(), &mut settings, raytracer.as_deref_mut(), timings.as_ref(), diff --git a/src/window/offscreen.rs b/src/window/offscreen.rs index 2bd92c8df..3ea7c5abc 100644 --- a/src/window/offscreen.rs +++ b/src/window/offscreen.rs @@ -372,7 +372,7 @@ impl OffscreenSurface { #[cfg(feature = "egui")] pub fn draw_ui(&mut self, ui_fn: F) where - F: FnOnce(&egui::Context), + F: FnMut(&egui::Context), { self.window.draw_ui(ui_fn); } From f4f389bbd2990ec35eecfd98146c04c58c7cb770 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 5 Sep 2026 21:50:33 +0300 Subject: [PATCH 07/50] egui: a frame that draws no UI shows the last pass again The renderer cleared its shapes right after drawing them, so a host that skipped its pass painted nothing. Drop them when the next pass begins instead: identical for a host that draws every frame, and it lets one redraw its UI only when something changed. --- src/renderer/egui_renderer.rs | 5 ++++- src/window/egui_integration.rs | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/renderer/egui_renderer.rs b/src/renderer/egui_renderer.rs index ab5d67422..d2c3936d4 100644 --- a/src/renderer/egui_renderer.rs +++ b/src/renderer/egui_renderer.rs @@ -95,6 +95,10 @@ impl EguiRenderer { /// Begin a new frame with the given raw input. pub fn begin_frame(&mut self, raw_input: RawInput) { + // The pass about to run replaces last pass's shapes, so this is where + // they are dropped. Not after rendering them: a host that skips a pass + // shows the last one again rather than nothing (`Window::draw_ui`). + self.shapes.clear(); self.egui_ctx.begin_pass(raw_input); } @@ -240,7 +244,6 @@ impl EguiRenderer { } self.textures_delta.clear(); - self.shapes.clear(); } } diff --git a/src/window/egui_integration.rs b/src/window/egui_integration.rs index d40b6fd87..9d204d53b 100644 --- a/src/window/egui_integration.rs +++ b/src/window/egui_integration.rs @@ -377,6 +377,11 @@ impl Window { where F: FnMut(&egui::Context), { + // A frame that calls this none of the times shows the last pass's + // shapes again, which is what lets a host redraw its UI only when + // something changed; the shapes are dropped by the next pass, not by + // rendering them (`EguiRenderer::begin_frame`). + // // Open the egui pass lazily so that several `draw_ui` (and // `draw_inspector`) calls in the same frame all run their widgets into // the *same* pass. The pass is closed at render time by From 2fac4f359457038482a32f14a92c047b9b2c4841 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 6 Sep 2026 07:59:06 +0300 Subject: [PATCH 08/50] mesh3d: carry per-vertex colors A mesh had positions, normals and texture coordinates and nowhere to put the colors a glTF's COLOR_0 carries, so a loader had to drop them and a material had nothing to bind. Optional, like the skin and the morph deltas beside it: a mesh without colors allocates no buffer and draws exactly as before. set_colors pads a short list with opaque white, and colors_buffer uploads on demand for a material that binds its own vertex layout. --- src/resource/mesh3d.rs | 45 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/resource/mesh3d.rs b/src/resource/mesh3d.rs index 4b1973cd8..a4ec17233 100644 --- a/src/resource/mesh3d.rs +++ b/src/resource/mesh3d.rs @@ -23,6 +23,11 @@ pub struct GpuMesh3d { normals: Arc>>, uvs: Arc>>, edges: Option>>>, + /// Optional per-vertex colors (glTF `COLOR_0`), present only on meshes + /// that carry them. A material that reads them multiplies its albedo by + /// this; one that does not is unaffected, and a mesh without them costs + /// no buffer at all. + colors: Option>>>, /// Optional per-vertex skinning attributes (glTF `JOINTS_0`/`WEIGHTS_0`), /// present only on skinned meshes. Drives GPU vertex skinning. skin_vertices: Option, @@ -268,6 +273,7 @@ impl GpuMesh3d { normals, uvs, edges: None, + colors: None, skin_vertices: None, morph: None, } @@ -284,6 +290,45 @@ impl GpuMesh3d { self.skin_vertices.is_some() } + /// Attaches per-vertex colors to this mesh. Used by a loader for glTF's + /// `COLOR_0`, and by any caller that tints a mesh vertex by vertex. + /// + /// One color per vertex, in the same order as `coords`; a shorter list is + /// padded with opaque white so a partly-colored mesh still draws. + pub fn set_colors(&mut self, colors: Vec<[f32; 4]>) { + let vertices = self + .coords + .read() + .ok() + .and_then(|coords| coords.data().as_ref().map(Vec::len)) + .unwrap_or(colors.len()); + let mut colors = colors; + colors.resize(vertices, [1.0; 4]); + self.colors = Some(Arc::new(RwLock::new(GPUVec::new( + colors, + BufferType::Array, + AllocationType::StaticDraw, + )))); + } + + /// Whether this mesh carries per-vertex colors. + pub fn has_colors(&self) -> bool { + self.colors.is_some() + } + + /// The per-vertex colors, for a material that binds them itself. + pub fn colors(&self) -> Option<&Arc>>> { + self.colors.as_ref() + } + + /// Returns the per-vertex color buffer, uploading it first. `None` when + /// the mesh carries no colors. + pub fn colors_buffer(&self) -> Option { + let colors = self.colors.as_ref()?; + colors.write().ok()?.load_to_gpu(); + colors.read().ok()?.buffer().cloned() + } + /// Attaches morph-target deltas to this mesh, marking it as morphable. Used by /// the glTF loader. pub fn set_morph_targets(&mut self, morph: MorphTargets) { From f714dc8895aec0a4350a9057956fdc522a096cac Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 6 Sep 2026 15:14:52 +0300 Subject: [PATCH 09/50] Update wgpu_canvas.rs --- src/window/wgpu_canvas.rs | 59 ++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index be48749bc..b37de68b4 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -281,8 +281,11 @@ pub struct WgpuCanvas { msaa_view: Option, /// Number of samples for MSAA sample_count: u32, - /// Texture for reading back pixels (for screenshots) - readback_texture: wgpu::Texture, + /// Texture for reading back pixels (for screenshots), made on the first + /// capture. It is as large as the surface, and a run that never takes a + /// shot never pays for it; a resize drops it rather than rebuilding one + /// nothing has asked for. + readback_texture: std::cell::RefCell>, /// Staging buffer reused across `read_pixels` calls, grown on demand, so /// per-frame capture doesn't allocate (and free) a GPU buffer every call. screenshot_staging: RefCell>, @@ -637,9 +640,8 @@ impl WgpuCanvas { (None, None) }; - // Create readback texture for screenshots - let readback_texture = - Self::create_readback_texture(&ctxt.device, width, height, surface_format); + // The readback texture is built on the first capture, not here. + let readback_texture = std::cell::RefCell::new(None); // Set up WASM event listeners #[cfg(target_arch = "wasm32")] @@ -1045,8 +1047,7 @@ impl WgpuCanvas { } else { (None, None) }; - let readback_texture = - Self::create_readback_texture(&ctxt.device, width, height, surface_format); + let readback_texture = std::cell::RefCell::new(None); WgpuCanvas { window: None, @@ -1135,8 +1136,8 @@ impl WgpuCanvas { self.msaa_view = Some(msaa_view); } - self.readback_texture = - Self::create_readback_texture(&ctxt.device, width, height, self.surface_config.format); + // Dropped, not rebuilt: the next capture makes one at the new size. + self.readback_texture.replace(None); } /// Changes the MSAA sample count, recreating the size-dependent attachments @@ -1235,6 +1236,22 @@ impl WgpuCanvas { (texture, view) } + /// The readback texture, built at the surface's current size the first + /// time a capture asks for it. Cloning is cheap: a `wgpu::Texture` is a + /// handle. + fn readback_texture(&self) -> wgpu::Texture { + let mut slot = self.readback_texture.borrow_mut(); + if slot.is_none() { + *slot = Some(Self::create_readback_texture( + &Context::get().device, + self.surface_config.width, + self.surface_config.height, + self.surface_config.format, + )); + } + slot.as_ref().expect("just built").clone() + } + fn create_readback_texture( device: &wgpu::Device, width: u32, @@ -1434,13 +1451,9 @@ impl WgpuCanvas { self.msaa_view = Some(new_msaa_view); } - // Recreate readback texture - self.readback_texture = Self::create_readback_texture( - &ctxt.device, - width, - height, - self.surface_config.format, - ); + // Dropped, not rebuilt: the next capture makes one + // at the new size. + self.readback_texture.replace(None); } } } @@ -1487,13 +1500,9 @@ impl WgpuCanvas { self.msaa_view = Some(new_msaa_view); } - // Recreate readback texture - self.readback_texture = Self::create_readback_texture( - &ctxt.device, - current_size.width, - current_size.height, - self.surface_config.format, - ); + // Dropped, not rebuilt: the next capture makes one at the + // new size. + self.readback_texture.replace(None); let _ = self.out_events.send(WindowEvent::FramebufferSize( current_size.width, @@ -1576,7 +1585,7 @@ impl WgpuCanvas { aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyTextureInfo { - texture: &self.readback_texture, + texture: &self.readback_texture(), mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, @@ -1647,7 +1656,7 @@ impl WgpuCanvas { encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { - texture: &self.readback_texture, + texture: &self.readback_texture(), mip_level: 0, origin: wgpu::Origin3d { x: x as u32, From b64bd0a286b96edf71a41cc1e41d924465a34c6b Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Mon, 7 Sep 2026 14:21:38 +0300 Subject: [PATCH 10/50] Add launch url support --- Cargo.toml | 2 +- src/window/ios.rs | 86 +++++++++++++++++++++++++++++++++++++++++++++-- src/window/mod.rs | 2 +- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f15d19be3..3fb756826 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,7 +84,7 @@ android-activity = "0.6.1" [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" objc2-foundation = "0.3" -objc2-ui-kit = { version = "0.3", features = ["UIGeometry", "UIResponder", "UIView", "UITextInput"] } +objc2-ui-kit = { version = "0.3", features = ["UIApplication", "UIGeometry", "UIResponder", "UIView", "UITextInput"] } [target.wasm32-unknown-unknown.dependencies] oneshot = { version = "0.2.0", features = ["async"] } diff --git a/src/window/ios.rs b/src/window/ios.rs index 5fbbbc0ce..5697d53d7 100644 --- a/src/window/ios.rs +++ b/src/window/ios.rs @@ -17,8 +17,10 @@ use std::task::{Context as TaskContext, Poll, Waker}; use objc2::rc::Retained; use objc2::{define_class, msg_send, MainThreadMarker, MainThreadOnly}; -use objc2_foundation::{NSObjectProtocol, NSString}; -use objc2_ui_kit::{UIKeyInput, UITextInputTraits, UIView}; +use objc2_foundation::{NSDictionary, NSNotification, NSNotificationCenter, NSObject, NSObjectProtocol, NSString, NSURL}; +use objc2_ui_kit::{ + UIApplicationDidFinishLaunchingNotification, UIKeyInput, UITextInputTraits, UIView, +}; use winit::application::ApplicationHandler; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::window::{Window, WindowAttributes, WindowId}; @@ -134,6 +136,9 @@ impl ApplicationHandler for IosApp { /// `UIApplicationMain` owns the process. pub fn run_ios(fut: impl Future + 'static) { let event_loop = EventLoop::new().expect("Failed to create event loop"); + if let Some(mtm) = MainThreadMarker::new() { + observe_launch_url(mtm); + } let mut app = IosApp { fut: Some(Box::pin(fut)), started: false, @@ -209,6 +214,83 @@ define_class!( } ); +thread_local! { + /// The URL the app was launched with, if it was launched from one. + static LAUNCH_URL: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; + /// The observer is kept alive for the life of the process: the + /// notification centre does not retain it, and a dropped observer is a + /// dangling pointer the next notification would follow. + static LAUNCH_OBSERVER: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +// UIKit hands the launch URL to the application delegate, and winit owns the +// delegate. Rather than take it over, this listens for the notification the +// same launch posts: its `userInfo` carries the very dictionary the delegate +// is given, so the URL is read without owning anything. +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "Kiss3dLaunchObserver"] + struct LaunchObserver; + + unsafe impl NSObjectProtocol for LaunchObserver {} + + impl LaunchObserver { + #[unsafe(method(didFinishLaunching:))] + #[expect( + deprecated, + reason = "the scene lifecycle it defers to is not one a kiss3d app adopts" + )] + fn did_finish_launching(&self, notification: &NSNotification) { + let Some(info): Option> = notification.userInfo() else { + return; + }; + let key: &NSString = unsafe { objc2_ui_kit::UIApplicationLaunchOptionsURLKey }; + let Some(object) = info.objectForKey(key) else { + return; + }; + // The value under that key is documented as an NSURL. + let url: &NSURL = unsafe { &*ptr::from_ref(&*object).cast::() }; + if let Some(text) = unsafe { url.absoluteString() } { + LAUNCH_URL.with(|cell| *cell.borrow_mut() = Some(text.to_string())); + } + } + } +); + +/// Start listening for the launch URL. Called before `UIApplicationMain` +/// takes the thread, which is the only moment early enough to hear the +/// notification it posts. +/// +/// `UIApplicationLaunchOptionsURLKey` is deprecated in favour of the UIScene +/// lifecycle, and deliberately used anyway: the key is empty only for an app +/// that declares a `UIApplicationSceneManifest`, and a kiss3d app declares +/// none. Adopting scenes to read a URL would mean taking over the window +/// lifecycle winit owns. +fn observe_launch_url(mtm: MainThreadMarker) { + let observer: Retained = + unsafe { msg_send![LaunchObserver::alloc(mtm), init] }; + unsafe { + NSNotificationCenter::defaultCenter().addObserver_selector_name_object( + &observer, + objc2::sel!(didFinishLaunching:), + Some(UIApplicationDidFinishLaunchingNotification), + None, + ); + } + LAUNCH_OBSERVER.with(|cell| *cell.borrow_mut() = Some(observer)); +} + +/// The URL this app was launched with, taken once. +/// +/// Empty unless the app was opened from a link. It is taken rather than read +/// so a game that asks twice does not act on the same launch twice. +pub fn take_launch_url() -> Option { + LAUNCH_URL.with(|cell| cell.borrow_mut().take()) +} + /// `[left, top, right, bottom]` in points; zeros before the view is laid out. pub(crate) fn safe_area(window: &Window) -> [f64; 4] { use wgpu::rwh::{HasWindowHandle, RawWindowHandle}; diff --git a/src/window/mod.rs b/src/window/mod.rs index 97a012640..eaae3e03b 100644 --- a/src/window/mod.rs +++ b/src/window/mod.rs @@ -23,7 +23,7 @@ pub use canvas::{Canvas, CanvasSetup, NumSamples}; #[cfg(feature = "egui")] pub use inspector::{Inspector, InspectorTab}; #[cfg(target_os = "ios")] -pub use ios::run_ios; +pub use ios::{run_ios, take_launch_url}; pub use offscreen::OffscreenSurface; #[cfg(feature = "recording")] pub use recording::RecordingConfig; From bbee2f50c1d092f67877fa430fac0c285df9e7b4 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Mon, 7 Sep 2026 15:00:44 +0300 Subject: [PATCH 11/50] postprocessing rustfmt the film-chain block a render target remade when its format changes, not only its size The film-stage post chain reuses the ping-pong pair at Rgba16Float. `resize` compared only the extent, so the pair stayed at the surface format and the seeding copy failed validation: source and destination were not copy-compatible. --- src/post_processing/hdr.rs | 34 +++++-- src/resource/framebuffer_manager.rs | 11 ++- src/window/rendering.rs | 132 +++++++++++++++++++++++++++- src/window/window.rs | 9 ++ 4 files changed, 174 insertions(+), 12 deletions(-) diff --git a/src/post_processing/hdr.rs b/src/post_processing/hdr.rs index b7595f225..d6c9a0a34 100644 --- a/src/post_processing/hdr.rs +++ b/src/post_processing/hdr.rs @@ -235,7 +235,7 @@ pub struct HdrPipeline { // and `scene` is its single-sample resolve destination; otherwise only // `scene` exists and is rendered into directly. // Single-sample HDR scene texture, kept alive alongside its view. - _scene_texture: wgpu::Texture, + scene_texture: wgpu::Texture, scene_view: wgpu::TextureView, // MSAA HDR attachment, kept alive alongside its view. _scene_msaa_texture: Option, @@ -818,7 +818,7 @@ impl HdrPipeline { width, height, sample_count, - _scene_texture: targets.scene_texture, + scene_texture: targets.scene_texture, scene_view: targets.scene_view, _scene_msaa_texture: targets.scene_msaa_texture, scene_msaa_view: targets.scene_msaa_view, @@ -1142,7 +1142,7 @@ impl HdrPipeline { Self::create_oit_composite_pipeline(&self.oit_layout, sample_count); } let targets = Self::create_targets(width, height, sample_count); - self._scene_texture = targets.scene_texture; + self.scene_texture = targets.scene_texture; self.scene_view = targets.scene_view; self._scene_msaa_texture = targets.scene_msaa_texture; self.scene_msaa_view = targets.scene_msaa_view; @@ -1173,6 +1173,12 @@ impl HdrPipeline { &self.scene_view } + /// The texture behind [`scene_resolved_view`](Self::scene_resolved_view), for a + /// film-stage post chain that has to copy it before it may write over it. + pub fn scene_texture(&self) -> &wgpu::Texture { + &self.scene_texture + } + /// The MSAA resolve target (the single-sample HDR texture), or `None` when /// MSAA is disabled. pub fn scene_resolve_view(&self) -> Option<&wgpu::TextureView> { @@ -1319,13 +1325,14 @@ impl HdrPipeline { fn run_bloom( &self, encoder: &mut wgpu::CommandEncoder, + input: &wgpu::TextureView, gpu: &mut crate::renderer::timings::GpuTimer, ) { let ctxt = Context::get(); // Prefilter the full-res scene into the first (half-res) mip. { - let bg = self.bloom_bind_group(&ctxt, &self.scene_view, self.width, self.height); + let bg = self.bloom_bind_group(&ctxt, input, self.width, self.height); let bloom_ts = gpu.render_scope("bloom"); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("hdr_bloom_prefilter_pass"), @@ -1547,12 +1554,27 @@ impl HdrPipeline { output_view: &wgpu::TextureView, force_opaque: bool, gpu: &mut crate::renderer::timings::GpuTimer, + ) { + let film = self.scene_view.clone(); + self.resolve_from(encoder, &film, output_view, force_opaque, gpu); + } + + /// [`resolve`](Self::resolve) reading `input` rather than the film itself, so a + /// pass that ran over the film feeds bloom and the tonemap instead of being + /// thrown away by them. + pub(crate) fn resolve_from( + &mut self, + encoder: &mut wgpu::CommandEncoder, + input: &wgpu::TextureView, + output_view: &wgpu::TextureView, + force_opaque: bool, + gpu: &mut crate::renderer::timings::GpuTimer, ) { let ctxt = Context::get(); let bloom_enabled = self.settings.bloom_enabled && self.settings.bloom_intensity > 0.0; if bloom_enabled { - self.run_bloom(encoder, gpu); + self.run_bloom(encoder, input, gpu); } // Auto-exposure: meter + adapt before tonemapping. The resulting 1x1 @@ -1600,7 +1622,7 @@ impl HdrPipeline { entries: &[ wgpu::BindGroupEntry { binding: 0, - resource: wgpu::BindingResource::TextureView(&self.scene_view), + resource: wgpu::BindingResource::TextureView(input), }, wgpu::BindGroupEntry { binding: 1, diff --git a/src/resource/framebuffer_manager.rs b/src/resource/framebuffer_manager.rs index d71858d4d..6ffcc28d7 100644 --- a/src/resource/framebuffer_manager.rs +++ b/src/resource/framebuffer_manager.rs @@ -26,6 +26,9 @@ pub struct OffscreenBuffers { pub width: u32, /// Height of the render target. pub height: u32, + /// The colour format the textures were made with. A target reused at + /// another format has to be remade, not just resized. + pub format: wgpu::TextureFormat, } impl RenderTarget { @@ -76,7 +79,7 @@ impl RenderTarget { // Screen resizing is handled by the canvas/surface } RenderTarget::Offscreen(o) => { - if o.width != width || o.height != height { + if o.width != width || o.height != height || o.format != surface_format { // Recreate textures with new size **o = OffscreenBuffers::new(width, height, surface_format, true); } @@ -111,9 +114,12 @@ impl OffscreenBuffers { sample_count: 1, dimension: wgpu::TextureDimension::D2, format, + // COPY_DST: a film-stage post chain is seeded with a copy of the + // HDR film, which is a texture this one never renders into. usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_SRC, + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); @@ -162,6 +168,7 @@ impl OffscreenBuffers { sampler, width, height, + format, } } } diff --git a/src/window/rendering.rs b/src/window/rendering.rs index 87103ad9c..ca010cd4d 100644 --- a/src/window/rendering.rs +++ b/src/window/rendering.rs @@ -219,11 +219,63 @@ impl Window { camera, camera_2d, renderer, + &mut [], post_processing, ) .await } + /// [`render_chain`](Self::render_chain) with a second chain that runs on the + /// HDR film, before bloom and the tonemap. + /// + /// A `film` pass works in linear light and what it writes is what blooms and + /// what is tonemapped; a `post` pass works on the LDR image after them. That is + /// the whole difference, and it is why an effect belongs in one or the other: + /// a fog or a colour operation wants the film, a scanline or a vignette wants + /// the finished picture. + pub async fn render_chains( + &mut self, + scene: Option<&mut SceneNode3d>, + scene_2d: Option<&mut SceneNode2d>, + camera: Option<&mut dyn Camera3d>, + camera_2d: Option<&mut dyn Camera2d>, + renderer: Option<&mut dyn Renderer3d>, + film: &mut [&mut dyn PostProcessingEffect], + post: &mut [&mut dyn PostProcessingEffect], + ) -> bool { + let mut default_cam2 = FixedView2d::default(); + let mut default_cam = FixedView3d::default(); + let camera = camera.unwrap_or(&mut default_cam); + let camera_2d = camera_2d.unwrap_or(&mut default_cam2); + self.handle_events(camera, camera_2d); + self.render_single_frame(scene, scene_2d, camera, camera_2d, renderer, film, post) + .await + } + + /// Seed a film-stage chain: the HDR film into the target the first effect + /// reads. An effect takes a `RenderTarget` and the film is not one, so this is + /// the one copy the stage costs, and only when a chain is passed. + fn seed_film( + encoder: &mut wgpu::CommandEncoder, + film: &wgpu::Texture, + into: &RenderTarget, + w: u32, + h: u32, + ) { + let RenderTarget::Offscreen(o) = into else { + return; + }; + encoder.copy_texture_to_texture( + film.as_image_copy(), + o.color_texture.as_image_copy(), + wgpu::Extent3d { + width: w.min(o.width).max(1), + height: h.min(o.height).max(1), + depth_or_array_layers: 1, + }, + ); + } + async fn render_single_frame( &mut self, mut scene: Option<&mut SceneNode3d>, @@ -231,6 +283,7 @@ impl Window { camera: &mut dyn Camera3d, camera_2d: &mut dyn Camera2d, mut renderer: Option<&mut dyn Renderer3d>, + film_processing: &mut [&mut dyn PostProcessingEffect], post_processing: &mut [&mut dyn PostProcessingEffect], ) -> bool { // Frame timing: CPU wall-clock for the whole frame (and submit/present @@ -300,6 +353,12 @@ impl Window { .resize(w, h, self.canvas.surface_format()); self.post_process_render_target_b .resize(w, h, self.canvas.surface_format()); + if !film_processing.is_empty() { + self.film_render_target + .resize(w, h, crate::post_processing::HDR_FORMAT); + self.film_render_target_b + .resize(w, h, crate::post_processing::HDR_FORMAT); + } if offscreen { if self.offscreen_output_target.is_none() { self.offscreen_output_target = @@ -1282,17 +1341,82 @@ impl Window { // page, so force an opaque alpha there; a hidden/offscreen target keeps the // scene alpha for snapshots and host-app embedding. let force_opaque = !offscreen; + + // The film-stage chain, before bloom and the tonemap: a pass listed there + // works in linear light, and what it writes is what blooms. The film is + // copied into A because an effect reads a `RenderTarget` and the film is + // not one; from there the pair ping-pongs as the LDR chain does. + let film_view = if film_processing.is_empty() { + None + } else { + let film = self.hdr.scene_resolved_view().clone(); + Self::seed_film( + &mut encoder, + self.hdr.scene_texture(), + &self.film_render_target, + w, + h, + ); + let mut input_is_a = true; + for pp in film_processing.iter_mut() { + let (input, output) = if input_is_a { + (&self.film_render_target, &self.film_render_target_b) + } else { + (&self.film_render_target_b, &self.film_render_target) + }; + let output_view = match output { + RenderTarget::Offscreen(o) => &o.color_view, + RenderTarget::Screen => &frame_view, + }; + pp.update(0.016, w as f32, h as f32, znear, zfar); + let mut pp_context = PostProcessingContext { + encoder: &mut encoder, + output_view, + }; + pp.draw(input, &mut pp_context); + input_is_a = !input_is_a; + } + // Whichever half the last effect wrote is what the tonemap reads. + let landed = if input_is_a { + &self.film_render_target + } else { + &self.film_render_target_b + }; + match landed { + RenderTarget::Offscreen(o) => Some(o.color_view.clone()), + RenderTarget::Screen => Some(film), + } + }; + let resolve_input = film_view; + let resolve = |hdr: &mut crate::post_processing::HdrPipeline, + encoder: &mut wgpu::CommandEncoder, + out: &wgpu::TextureView, + gpu: &mut crate::renderer::timings::GpuTimer| { + match &resolve_input { + Some(view) => hdr.resolve_from(encoder, view, out, force_opaque, gpu), + None => hdr.resolve(encoder, out, force_opaque, gpu), + } + }; + if post_processing.is_empty() { - self.hdr - .resolve(&mut encoder, &frame_view, force_opaque, &mut self.gpu_timer); + resolve( + &mut self.hdr, + &mut encoder, + &frame_view, + &mut self.gpu_timer, + ); } else { // Tonemap into the first ping-pong target (A). let first_view = match &self.post_process_render_target { RenderTarget::Offscreen(o) => o.color_view.clone(), RenderTarget::Screen => frame_view.clone(), }; - self.hdr - .resolve(&mut encoder, &first_view, force_opaque, &mut self.gpu_timer); + resolve( + &mut self.hdr, + &mut encoder, + &first_view, + &mut self.gpu_timer, + ); let n = post_processing.len(); for (i, pp) in post_processing.iter_mut().enumerate() { diff --git a/src/window/window.rs b/src/window/window.rs index 98bc80fbb..b3d5a667f 100644 --- a/src/window/window.rs +++ b/src/window/window.rs @@ -118,6 +118,11 @@ pub struct Window { /// buffers when chaining more than one post-processing effect: each effect reads /// one and writes the other, and the last writes the final frame. pub(super) post_process_render_target_b: RenderTarget, + /// The same pair at [`HDR_FORMAT`](crate::post_processing::HDR_FORMAT), for a + /// chain that runs on the film before bloom and the tonemap rather than on the + /// LDR image after them. Only resized when such a chain is passed. + pub(super) film_render_target: RenderTarget, + pub(super) film_render_target_b: RenderTarget, /// Offscreen render target used when the window is hidden, so `snap` and /// recording work without a presentable surface. Created on first use. pub(super) offscreen_output_target: Option, @@ -1089,6 +1094,8 @@ impl Window { post_process_render_target: framebuffer_manager.new_render_target(width, height, true), post_process_render_target_b: framebuffer_manager .new_render_target(width, height, false), + film_render_target: framebuffer_manager.new_render_target(width, height, false), + film_render_target_b: framebuffer_manager.new_render_target(width, height, false), offscreen_output_target: None, aov_renderer: None, hidden: hide, @@ -1178,6 +1185,8 @@ impl Window { post_process_render_target: framebuffer_manager.new_render_target(width, height, true), post_process_render_target_b: framebuffer_manager .new_render_target(width, height, false), + film_render_target: framebuffer_manager.new_render_target(width, height, false), + film_render_target_b: framebuffer_manager.new_render_target(width, height, false), offscreen_output_target: None, aov_renderer: None, // A headless window has no surface; always render off-screen. From 02fa7bda5b7c968489313e7f9efa468a5707c3b3 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Mon, 7 Sep 2026 16:04:11 +0300 Subject: [PATCH 12/50] rustfmt tidies the launch-url imports and binding --- src/window/ios.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/window/ios.rs b/src/window/ios.rs index 5697d53d7..e56c57cc7 100644 --- a/src/window/ios.rs +++ b/src/window/ios.rs @@ -17,7 +17,9 @@ use std::task::{Context as TaskContext, Poll, Waker}; use objc2::rc::Retained; use objc2::{define_class, msg_send, MainThreadMarker, MainThreadOnly}; -use objc2_foundation::{NSDictionary, NSNotification, NSNotificationCenter, NSObject, NSObjectProtocol, NSString, NSURL}; +use objc2_foundation::{ + NSDictionary, NSNotification, NSNotificationCenter, NSObject, NSObjectProtocol, NSString, NSURL, +}; use objc2_ui_kit::{ UIApplicationDidFinishLaunchingNotification, UIKeyInput, UITextInputTraits, UIView, }; @@ -270,8 +272,7 @@ define_class!( /// none. Adopting scenes to read a URL would mean taking over the window /// lifecycle winit owns. fn observe_launch_url(mtm: MainThreadMarker) { - let observer: Retained = - unsafe { msg_send![LaunchObserver::alloc(mtm), init] }; + let observer: Retained = unsafe { msg_send![LaunchObserver::alloc(mtm), init] }; unsafe { NSNotificationCenter::defaultCenter().addObserver_selector_name_object( &observer, From fb942b8c5d6c82ffe14c28af0b3646dea5aeff45 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Thu, 10 Sep 2026 08:59:22 +0300 Subject: [PATCH 13/50] Set the pointer to the shape egui asked for A pass reports a cursor in `platform_output`, and nothing read it: the resize shape over a panel seam, the caret over a text field and the pointing hand over a link were all requested and dropped. `end_frame` keeps the icon, `finish_egui_pass` applies it through a new `Canvas::set_cursor_icon`, and only a change is set, because setting one is a hop to the main thread. --- src/renderer/egui_renderer.rs | 9 ++++++ src/window/canvas.rs | 5 +++ src/window/egui_integration.rs | 57 ++++++++++++++++++++++++++++++++++ src/window/wgpu_canvas.rs | 7 +++++ 4 files changed, 78 insertions(+) diff --git a/src/renderer/egui_renderer.rs b/src/renderer/egui_renderer.rs index d2c3936d4..4b64e3dff 100644 --- a/src/renderer/egui_renderer.rs +++ b/src/renderer/egui_renderer.rs @@ -10,6 +10,7 @@ pub struct EguiRenderer { renderer: egui_wgpu::Renderer, shapes: Vec, textures_delta: egui::TexturesDelta, + cursor: egui::CursorIcon, } impl EguiRenderer { @@ -80,6 +81,7 @@ impl EguiRenderer { renderer, shapes: Vec::new(), textures_delta: Default::default(), + cursor: egui::CursorIcon::Default, } } @@ -102,9 +104,16 @@ impl EguiRenderer { self.egui_ctx.begin_pass(raw_input); } + /// What the last pass asked the pointer to look like over the widget it + /// was on. `Default` whenever nothing under it asked for anything else. + pub fn cursor(&self) -> egui::CursorIcon { + self.cursor + } + /// End the current frame and prepare for rendering. pub fn end_frame(&mut self) { let output = self.egui_ctx.end_pass(); + self.cursor = output.platform_output.cursor_icon; self.shapes = output.shapes; // Append rather than replace: if a previous frame's render was skipped // (e.g. failed to acquire surface texture), we must not lose its texture diff --git a/src/window/canvas.rs b/src/window/canvas.rs index b4f25587f..ebd5f5fb0 100644 --- a/src/window/canvas.rs +++ b/src/window/canvas.rs @@ -229,6 +229,11 @@ impl Canvas { self.canvas.hide_cursor(hide); } + /// Set the shape the pointer takes over this window. + pub fn set_cursor_icon(&self, icon: winit::window::CursorIcon) { + self.canvas.set_cursor_icon(icon); + } + /// Hide the window. pub fn hide(&mut self) { self.canvas.hide() diff --git a/src/window/egui_integration.rs b/src/window/egui_integration.rs index 9d204d53b..90753bb61 100644 --- a/src/window/egui_integration.rs +++ b/src/window/egui_integration.rs @@ -23,6 +23,9 @@ pub(crate) struct EguiContext { /// What a rerun of the open pass begins with: the pass's input less its /// events, as `Context::run` hands the later passes of a frame. pub(crate) rerun_input: RawInput, + /// The shape the pointer was last set to. Setting one is a hop to the + /// main thread, so only a change is worth making. + pub(crate) cursor: egui::CursorIcon, #[cfg(not(target_arch = "wasm32"))] pub(crate) start_time: std::time::Instant, } @@ -35,6 +38,7 @@ impl EguiContext { pass_active: false, pointer_touch_id: None, rerun_input: RawInput::default(), + cursor: egui::CursorIcon::Default, #[cfg(not(target_arch = "wasm32"))] start_time: std::time::Instant::now(), } @@ -465,6 +469,13 @@ impl Window { if self.egui_context.pass_active { self.egui_context.renderer.end_frame(); self.egui_context.pass_active = false; + // What the pass asked for, applied where the window is: egui says + // which shape a widget wants and only the host can set one. + let want = self.egui_context.renderer.cursor(); + if want != self.egui_context.cursor { + self.egui_context.cursor = want; + self.canvas.set_cursor_icon(winit_cursor(want)); + } } // Note: `raw_input` is *not* reset here. It is drained by // `begin_egui_pass` (via `std::mem::take`) when the next pass opens, and @@ -473,3 +484,49 @@ impl Window { // stop responding to input. } } + +/// egui's cursor names as winit's. The two lists are the same set under +/// different names, and neither crate knows about the other. +fn winit_cursor(icon: egui::CursorIcon) -> winit::window::CursorIcon { + use egui::CursorIcon as E; + use winit::window::CursorIcon as W; + match icon { + E::Default => W::Default, + // Hiding the pointer is `hide_cursor`, not a shape; nothing in a + // widget's own paint should take it off the screen. + E::None => W::Default, + E::ContextMenu => W::ContextMenu, + E::Help => W::Help, + E::PointingHand => W::Pointer, + E::Progress => W::Progress, + E::Wait => W::Wait, + E::Cell => W::Cell, + E::Crosshair => W::Crosshair, + E::Text => W::Text, + E::VerticalText => W::VerticalText, + E::Alias => W::Alias, + E::Copy => W::Copy, + E::Move => W::Move, + E::NoDrop => W::NoDrop, + E::NotAllowed => W::NotAllowed, + E::Grab => W::Grab, + E::Grabbing => W::Grabbing, + E::AllScroll => W::AllScroll, + E::ResizeHorizontal => W::EwResize, + E::ResizeNeSw => W::NeswResize, + E::ResizeNwSe => W::NwseResize, + E::ResizeVertical => W::NsResize, + E::ResizeEast => W::EResize, + E::ResizeSouthEast => W::SeResize, + E::ResizeSouth => W::SResize, + E::ResizeSouthWest => W::SwResize, + E::ResizeWest => W::WResize, + E::ResizeNorthWest => W::NwResize, + E::ResizeNorth => W::NResize, + E::ResizeNorthEast => W::NeResize, + E::ResizeColumn => W::ColResize, + E::ResizeRow => W::RowResize, + E::ZoomIn => W::ZoomIn, + E::ZoomOut => W::ZoomOut, + } +} diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index b37de68b4..e0e0e661f 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -1973,6 +1973,13 @@ impl WgpuCanvas { } } + /// Set the shape the pointer takes over this window. + pub fn set_cursor_icon(&self, icon: winit::window::CursorIcon) { + if let Some(window) = &self.window { + window.set_cursor(icon); + } + } + /// Hide the window. pub fn hide(&mut self) { if let Some(window) = &self.window { From 1af51c7fc3d2ffa0bfbad22e9632fa30b8a2dbea Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Thu, 10 Sep 2026 09:24:36 +0300 Subject: [PATCH 14/50] Let a caller state a texture's whole sampler add_image, add_image_pixelated and add_image_with_color_space are a handful of combinations, and a caller reading its settings out of a file cannot pick one without giving the rest up. TextureSampling carries wrapping per axis, a filter per magnification, minification and mip level, anisotropy, the colour space and premultiplied alpha, and add_image_sampled takes it. Anisotropy is clamped rather than passed on: wgpu aborts the process on a sampler whose filters do not match it. Premultiplication scales an sRGB texture in linear light, which is where the fringe it removes lives. Update mod.rs --- src/resource/mod.rs | 4 +- src/resource/texture_manager.rs | 311 +++++++++++++++++++++++++++++--- 2 files changed, 288 insertions(+), 27 deletions(-) diff --git a/src/resource/mod.rs b/src/resource/mod.rs index 795741437..3eecdb748 100644 --- a/src/resource/mod.rs +++ b/src/resource/mod.rs @@ -16,7 +16,9 @@ pub use crate::resource::mesh3d::{GpuMesh3d, MorphTargets, SkinVertexData}; pub use crate::resource::mesh_manager2d::MeshManager2d; pub use crate::resource::mesh_manager3d::MeshManager3d; pub use crate::resource::pipeline_cache::{multisample_state, PipelineCache}; -pub use crate::resource::texture_manager::{Texture, TextureManager, TextureWrapping}; +pub use crate::resource::texture_manager::{ + Texture, TextureManager, TextureSampling, TextureWrapping, +}; mod dynamic_buffer; mod framebuffer_manager; diff --git a/src/resource/texture_manager.rs b/src/resource/texture_manager.rs index c802c155e..4cd927bdb 100644 --- a/src/resource/texture_manager.rs +++ b/src/resource/texture_manager.rs @@ -31,6 +31,113 @@ impl From for wgpu::AddressMode { } } +/// The whole of how one texture is sampled, and how its pixels are prepared +/// before they reach the GPU. +/// +/// One struct rather than a call per combination: a caller that reads its +/// settings out of a file cannot pick from `add_image`, `add_image_pixelated` +/// and `add_image_with_color_space` without giving some of them up. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TextureSampling { + /// Wrapping across the horizontal axis. + pub wrap_u: TextureWrapping, + /// Wrapping across the vertical axis. + pub wrap_v: TextureWrapping, + /// Between texels when the texture is magnified. + pub mag_filter: wgpu::FilterMode, + /// Between texels when it is minified. + pub min_filter: wgpu::FilterMode, + /// Between mip levels, read only when `mipmaps` is set. + pub mipmap_filter: wgpu::MipmapFilterMode, + /// Build the mip chain on upload, box-filtered in linear light. + pub mipmaps: bool, + /// Samples per fetch, 1 to 16. wgpu refuses anything above 1 unless all + /// three filters are linear, so [`TextureSampling::sane`] lowers it. + pub anisotropy: u16, + /// Colour authored in sRGB, which the sampler decodes; data — a normal + /// map, a mask — sets this false and is returned raw. + pub srgb: bool, + /// Multiply RGB by alpha before upload, which stops a filtered edge + /// bleeding the transparent texels' colour into what is drawn. + /// + /// A premultiplied texture must be drawn with + /// [`Blend2d::PremultipliedAlpha`](crate::scene::Blend2d::PremultipliedAlpha); + /// under ordinary alpha blending it is multiplied a second time and comes + /// out dark. + pub premultiply: bool, +} + +impl Default for TextureSampling { + /// What [`TextureManager::add_image`] has always done: clamped, bilinear, + /// no mip chain, sRGB colour. + fn default() -> Self { + TextureSampling { + wrap_u: TextureWrapping::ClampToEdge, + wrap_v: TextureWrapping::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Linear, + mipmaps: false, + anisotropy: 1, + srgb: true, + premultiply: false, + } + } +} + +impl TextureSampling { + /// This sampling with whatever the device would refuse taken back out. + /// + /// wgpu validates anisotropy against the three filters and aborts the + /// process on a mismatch; a texture asked for more than the driver's + /// sixteen is a settings file, not a bug worth a crash. + pub fn sane(mut self) -> Self { + let linear = self.mag_filter == wgpu::FilterMode::Linear + && self.min_filter == wgpu::FilterMode::Linear + && self.mipmap_filter == wgpu::MipmapFilterMode::Linear; + self.anisotropy = if linear { + self.anisotropy.clamp(1, 16) + } else { + 1 + }; + self + } + + /// The pixel format this sampling reads its texels through. + fn format(self) -> wgpu::TextureFormat { + if self.srgb { + wgpu::TextureFormat::Rgba8UnormSrgb + } else { + wgpu::TextureFormat::Rgba8Unorm + } + } +} + +/// sRGB transfer function (IEC 61966-2-1), decoding one stored byte. +/// +/// Mip generation and premultiplication both average or scale colour, and +/// both have to do it in linear light or the result comes out dark. +fn srgb_to_linear(u: u8) -> f32 { + let c = u as f32 / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +/// The same transfer function the other way, back to a stored byte. +fn linear_to_srgb(c: f32) -> u8 { + let c = c.clamp(0.0, 1.0); + let s = if c <= 0.0031308 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + }; + (s * 255.0 + 0.5) as u8 +} + /// A GPU texture with its view and sampler. pub struct Texture { /// The underlying wgpu texture. @@ -41,10 +148,16 @@ pub struct Texture { pub sampler: wgpu::Sampler, /// Texture dimensions (width, height). pub size: (u32, u32), + /// Whether the uploaded RGB was multiplied by alpha, so a caller can pick + /// the blend mode that matches rather than guessing. + pub premultiplied: bool, } impl Texture { - /// Creates a new texture with the given data. + /// Creates a new texture with the given data, sampled the one way this + /// call has always offered: the same wrapping and filter on every axis. + /// + /// [`Texture::sampled`] takes the rest of the sampler. pub fn new( width: u32, height: u32, @@ -54,6 +167,61 @@ impl Texture { filter: wgpu::FilterMode, generate_mipmaps: bool, ) -> Arc { + let wrapping = match address_mode { + wgpu::AddressMode::MirrorRepeat => TextureWrapping::MirroredRepeat, + wgpu::AddressMode::Repeat => TextureWrapping::Repeat, + _ => TextureWrapping::ClampToEdge, + }; + Self::build( + width, + height, + data, + format, + TextureSampling { + wrap_u: wrapping, + wrap_v: wrapping, + mag_filter: filter, + min_filter: filter, + mipmap_filter: if generate_mipmaps { + wgpu::MipmapFilterMode::Linear + } else { + wgpu::MipmapFilterMode::Nearest + }, + mipmaps: generate_mipmaps, + srgb: format == wgpu::TextureFormat::Rgba8UnormSrgb, + ..Default::default() + }, + ) + } + + /// Creates a new RGBA8 texture sampled exactly as `sampling` asks. + /// + /// `data` is straight (non-premultiplied) RGBA8; `sampling.premultiply` + /// is applied here, in linear light for an sRGB texture, so the sampler + /// returns colour already scaled by its alpha. + pub fn sampled( + width: u32, + height: u32, + data: &[u8], + sampling: TextureSampling, + ) -> Arc { + let sampling = sampling.sane(); + let format = sampling.format(); + if sampling.premultiply { + let premultiplied = Self::premultiply_rgba(data, sampling.srgb); + return Self::build(width, height, &premultiplied, format, sampling); + } + Self::build(width, height, data, format, sampling) + } + + fn build( + width: u32, + height: u32, + data: &[u8], + format: wgpu::TextureFormat, + sampling: TextureSampling, + ) -> Arc { + let generate_mipmaps = sampling.mipmaps; let ctxt = Context::get(); let mip_level_count = if generate_mipmaps { @@ -149,16 +317,19 @@ impl Texture { let sampler = ctxt.create_sampler(&wgpu::SamplerDescriptor { label: Some("texture_sampler"), - address_mode_u: address_mode, - address_mode_v: address_mode, - address_mode_w: address_mode, - mag_filter: filter, - min_filter: filter, + address_mode_u: sampling.wrap_u.into(), + address_mode_v: sampling.wrap_v.into(), + // Nothing here is a 3D texture, so w follows u rather than + // offering a key that samples nothing. + address_mode_w: sampling.wrap_u.into(), + mag_filter: sampling.mag_filter, + min_filter: sampling.min_filter, mipmap_filter: if generate_mipmaps { - wgpu::MipmapFilterMode::Linear + sampling.mipmap_filter } else { wgpu::MipmapFilterMode::Nearest }, + anisotropy_clamp: sampling.anisotropy, ..Default::default() }); @@ -167,9 +338,30 @@ impl Texture { view, sampler, size: (width, height), + premultiplied: sampling.premultiply, }) } + /// RGB scaled by alpha, which is what a premultiplied blend expects. + /// + /// An sRGB texture is scaled in linear light: the sampler decodes the + /// stored value before it is blended, so scaling the encoded byte would + /// leave the fringe it is here to remove. + fn premultiply_rgba(data: &[u8], srgb: bool) -> Vec { + let mut out = data.to_vec(); + for texel in out.chunks_exact_mut(4) { + let alpha = texel[3] as f32 / 255.0; + for channel in &mut texel[..3] { + *channel = if srgb { + linear_to_srgb(srgb_to_linear(*channel) * alpha) + } else { + ((*channel as f32) * alpha + 0.5) as u8 + }; + } + } + out + } + /// Downsamples an RGBA image by half using box filtering. /// /// When `srgb` is set, RGB channels are decoded to linear light before @@ -177,25 +369,6 @@ impl Texture { /// mip chains don't darken — the gamma-correct behavior. Data textures pass /// `srgb = false` and are averaged directly. fn downsample_rgba(data: &[u8], width: u32, height: u32, srgb: bool) -> Vec { - // sRGB transfer-function helpers (IEC 61966-2-1). - fn srgb_to_linear(u: u8) -> f32 { - let c = u as f32 / 255.0; - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } - } - fn linear_to_srgb(c: f32) -> u8 { - let c = c.clamp(0.0, 1.0); - let s = if c <= 0.0031308 { - c * 12.92 - } else { - 1.055 * c.powf(1.0 / 2.4) - 0.055 - }; - (s * 255.0 + 0.5) as u8 - } - let new_width = (width / 2).max(1); let new_height = (height / 2).max(1); let mut new_data = vec![0u8; (new_width * new_height * 4) as usize]; @@ -430,6 +603,30 @@ impl TextureManager { .clone() } + /// Registers a texture sampled exactly as `sampling` asks: wrapping per + /// axis, a filter per magnification, minification and mip level, + /// anisotropy, the colour space and premultiplied alpha. + /// + /// The named calls above are the handful of combinations that came before + /// it; this is the whole sampler, for a caller reading its settings out of + /// a file. If a texture with the same name exists it is returned as it is, + /// so a caller changing a setting has to change the name too. + pub fn add_image_sampled( + &mut self, + image: DynamicImage, + name: &str, + sampling: TextureSampling, + ) -> Arc { + self.textures + .entry(name.to_string()) + .or_insert_with(|| { + let (width, height) = image.dimensions(); + let rgba = image.to_rgba8(); + Texture::sampled(width, height, rgba.as_raw(), sampling) + }) + .clone() + } + /// Allocates a new texture and tries to decode it from bytes array /// Panics if unable to do so /// If a texture with same name exists, nothing is created and the old texture is returned. @@ -555,3 +752,65 @@ impl TextureManager { self.generate_mipmaps = enabled; } } + +#[cfg(test)] +mod tests { + use super::{Texture, TextureSampling, TextureWrapping}; + + /// wgpu aborts the process on a sampler it considers invalid, so a + /// settings file asking for anisotropy on a pixel-art texture has to lose + /// the anisotropy rather than the run. + #[test] + fn anisotropy_needs_every_filter_linear() { + let pixelated = TextureSampling { + mag_filter: wgpu::FilterMode::Nearest, + anisotropy: 16, + ..Default::default() + }; + assert_eq!(pixelated.sane().anisotropy, 1); + + let smooth = TextureSampling { + anisotropy: 16, + ..Default::default() + }; + assert_eq!(smooth.sane().anisotropy, 16); + assert_eq!( + TextureSampling { + anisotropy: 64, + ..Default::default() + } + .sane() + .anisotropy, + 16, + "more than the device offers is clamped, not refused" + ); + } + + #[test] + fn the_default_sampling_is_what_add_image_always_did() { + let sampling = TextureSampling::default(); + assert_eq!(sampling.wrap_u, TextureWrapping::ClampToEdge); + assert_eq!(sampling.mag_filter, wgpu::FilterMode::Linear); + assert!(sampling.srgb && !sampling.mipmaps && !sampling.premultiply); + } + + /// A transparent texel keeps no colour to bleed into its neighbours, and + /// an opaque one is left exactly as it was authored. + #[test] + fn premultiplying_scales_colour_by_alpha() { + let straight = [255u8, 128, 0, 0, 255, 128, 0, 255]; + let linear = Texture::premultiply_rgba(&straight, false); + assert_eq!(&linear[..4], &[0, 0, 0, 0]); + assert_eq!(&linear[4..], &[255, 128, 0, 255]); + } + + /// Halved alpha in linear light is not a halved byte: scaling the stored + /// value instead would leave the fringe premultiplication removes. + #[test] + fn an_srgb_texture_is_scaled_in_linear_light() { + let straight = [255u8, 255, 255, 128]; + let encoded = Texture::premultiply_rgba(&straight, true); + assert_eq!(&encoded[..3], &[188, 188, 188]); + assert_eq!(encoded[3], 128, "alpha is not itself scaled"); + } +} From 1f7d425c0a120eb19f3e588d9836cbfe1ca51b9d Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Fri, 11 Sep 2026 09:21:10 +0300 Subject: [PATCH 15/50] get keyboard height for android and ios. --- Cargo.toml | 5 ++- src/window/canvas.rs | 5 +++ src/window/ios.rs | 85 ++++++++++++++++++++++++++++++++++++++- src/window/wgpu_canvas.rs | 59 +++++++++++++++++++++++++++ src/window/window.rs | 6 +++ 5 files changed, 156 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3fb756826..c3e04c0c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,8 +83,9 @@ android-activity = "0.6.1" # summons UIKit's keyboard itself through a hidden UIKeyInput view (window/ios.rs). [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" -objc2-foundation = "0.3" -objc2-ui-kit = { version = "0.3", features = ["UIApplication", "UIGeometry", "UIResponder", "UIView", "UITextInput"] } +objc2-core-foundation = { version = "0.3", features = ["CFCGTypes"] } +objc2-foundation = { version = "0.3", features = ["NSValue"] } +objc2-ui-kit = { version = "0.3", features = ["UIApplication", "UIGeometry", "UIResponder", "UIView", "UITextInput", "UIWindow", "objc2-core-foundation"] } [target.wasm32-unknown-unknown.dependencies] oneshot = { version = "0.2.0", features = ["async"] } diff --git a/src/window/canvas.rs b/src/window/canvas.rs index ebd5f5fb0..508c06b22 100644 --- a/src/window/canvas.rs +++ b/src/window/canvas.rs @@ -219,6 +219,11 @@ impl Canvas { self.canvas.safe_area() } + /// Pixels of the window the on-screen keyboard covers, from the bottom. + pub fn keyboard_height(&self) -> f32 { + self.canvas.keyboard_height() + } + /// Set the cursor position. pub fn set_cursor_position(&self, x: f64, y: f64) { self.canvas.set_cursor_position(x, y); diff --git a/src/window/ios.rs b/src/window/ios.rs index e56c57cc7..56602821b 100644 --- a/src/window/ios.rs +++ b/src/window/ios.rs @@ -17,11 +17,15 @@ use std::task::{Context as TaskContext, Poll, Waker}; use objc2::rc::Retained; use objc2::{define_class, msg_send, MainThreadMarker, MainThreadOnly}; +use objc2_core_foundation::CGRect; use objc2_foundation::{ - NSDictionary, NSNotification, NSNotificationCenter, NSObject, NSObjectProtocol, NSString, NSURL, + NSDictionary, NSNotification, NSNotificationCenter, NSObject, NSObjectProtocol, NSString, + NSValue, NSURL, }; use objc2_ui_kit::{ - UIApplicationDidFinishLaunchingNotification, UIKeyInput, UITextInputTraits, UIView, + NSValueUIGeometryExtensions, UIApplicationDidFinishLaunchingNotification, UIKeyInput, + UIKeyboardFrameEndUserInfoKey, UIKeyboardWillChangeFrameNotification, UITextInputTraits, + UIView, }; use winit::application::ApplicationHandler; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; @@ -140,6 +144,7 @@ pub fn run_ios(fut: impl Future + 'static) { let event_loop = EventLoop::new().expect("Failed to create event loop"); if let Some(mtm) = MainThreadMarker::new() { observe_launch_url(mtm); + observe_keyboard(mtm); } let mut app = IosApp { fut: Some(Box::pin(fut)), @@ -225,6 +230,12 @@ thread_local! { /// dangling pointer the next notification would follow. static LAUNCH_OBSERVER: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// Where the keyboard will end up, in screen coordinates, as UIKit last + /// announced it; `None` before it has ever moved. + static KEYBOARD_FRAME: Cell> = const { Cell::new(None) }; + /// Kept alive for the reason `LAUNCH_OBSERVER` is. + static KEYBOARD_OBSERVER: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; } // UIKit hands the launch URL to the application delegate, and winit owns the @@ -262,6 +273,76 @@ define_class!( } ); +// Every keyboard move posts `UIKeyboardWillChangeFrameNotification` with the +// frame it is heading for, hides included: a hidden keyboard's frame sits +// below the screen, so one handler covers show, hide, rotate and resize. +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "Kiss3dKeyboardObserver"] + struct KeyboardObserver; + + unsafe impl NSObjectProtocol for KeyboardObserver {} + + impl KeyboardObserver { + #[unsafe(method(keyboardWillChangeFrame:))] + fn will_change_frame(&self, notification: &NSNotification) { + let Some(info): Option> = notification.userInfo() else { + return; + }; + let key: &NSString = unsafe { UIKeyboardFrameEndUserInfoKey }; + let Some(object) = info.objectForKey(key) else { + return; + }; + // Documented as an NSValue wrapping a CGRect. + let value: &NSValue = unsafe { &*ptr::from_ref(&*object).cast::() }; + let frame = unsafe { value.CGRectValue() }; + KEYBOARD_FRAME.with(|cell| cell.set(Some(frame))); + } + } +); + +fn observe_keyboard(mtm: MainThreadMarker) { + let observer: Retained = + unsafe { msg_send![KeyboardObserver::alloc(mtm), init] }; + unsafe { + NSNotificationCenter::defaultCenter().addObserver_selector_name_object( + &observer, + objc2::sel!(keyboardWillChangeFrame:), + Some(UIKeyboardWillChangeFrameNotification), + None, + ); + } + KEYBOARD_OBSERVER.with(|cell| *cell.borrow_mut() = Some(observer)); +} + +/// How far up `window` the keyboard reaches, in points; zero while it is down. +/// +/// The frame is converted into the view's own coordinates, as Apple asks: a +/// screen-space frame is wrong under rotation and in a window smaller than +/// the screen. +pub(crate) fn keyboard_height(window: &Window) -> f64 { + use wgpu::rwh::{HasWindowHandle, RawWindowHandle}; + + if MainThreadMarker::new().is_none() { + return 0.0; + } + let Some(frame) = KEYBOARD_FRAME.with(Cell::get) else { + return 0.0; + }; + let Ok(handle) = window.window_handle() else { + return 0.0; + }; + let RawWindowHandle::UiKit(ui_kit) = handle.as_raw() else { + return 0.0; + }; + // Valid while `window` is alive, which the borrow guarantees. + let view: &UIView = unsafe { ui_kit.ui_view.cast().as_ref() }; + let local = view.convertRect_fromView(frame, None); + let bottom = view.bounds().size.height; + (bottom - local.origin.y).clamp(0.0, local.size.height.max(0.0)) +} + /// Start listening for the launch URL. Called before `UIApplicationMain` /// takes the thread, which is the only moment early enough to hear the /// notification it posts. diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index e0e0e661f..9c8f14e4e 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -1,5 +1,7 @@ //! Unified wgpu-based canvas for both native and web platforms. +#[cfg(target_os = "android")] +use std::cell::Cell; use std::cell::RefCell; use std::sync::mpsc::Sender; use std::sync::Arc; @@ -122,6 +124,34 @@ enum LifecycleEvent { Suspended, } +#[cfg(target_os = "android")] +thread_local! { + // The least of the window NativeActivity's content rect has ever left + // uncovered at this window height: the navigation bar, where the system + // draws one over the window. Reset when the height changes, since a + // rotation moves the bar. + static ANDROID_RESTING: Cell<(i32, i32)> = const { Cell::new((0, i32::MAX)) }; +} + +/// The keyboard's share of what the content rect leaves uncovered. +/// +/// NativeActivity reports one rect for everything the system covers, keyboard +/// and navigation bar alike. The bar's share is the smallest cover seen at +/// this height, so what is left above it is the keyboard. +#[cfg(target_os = "android")] +fn android_keyboard(height: i32, covered: i32) -> f32 { + ANDROID_RESTING.with(|resting| { + let (at, least) = resting.get(); + let least = if at == height { + least.min(covered) + } else { + covered + }; + resting.set((height, least)); + (covered - least).max(0) as f32 + }) +} + /// Stores the `AndroidApp` handle that `android_main` received, so opening a /// window can build winit's event loop from it (a bare `EventLoop::new` /// panics on Android by design). Must run on the `android_main` thread before @@ -1930,6 +1960,35 @@ impl WgpuCanvas { } } + /// How many pixels of the window the on-screen keyboard covers, from the + /// bottom; zero with it down, and zero everywhere but Android and iOS. + pub fn keyboard_height(&self) -> f32 { + #[cfg(target_os = "android")] + { + let Some(window) = &self.window else { + return 0.0; + }; + let height = window.inner_size().height as i32; + let Some(content) = + ANDROID_APP.with(|app| app.borrow().as_ref().map(|a| a.content_rect())) + else { + return 0.0; + }; + android_keyboard(height, height - content.bottom) + } + #[cfg(target_os = "ios")] + { + let Some(window) = &self.window else { + return 0.0; + }; + (super::ios::keyboard_height(window) * window.scale_factor()) as f32 + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + 0.0 + } + } + /// `[left, top, right, bottom]` insets in pixels; zero everywhere but iOS. pub fn safe_area(&self) -> [f32; 4] { #[cfg(target_os = "ios")] diff --git a/src/window/window.rs b/src/window/window.rs index b3d5a667f..9fb475091 100644 --- a/src/window/window.rs +++ b/src/window/window.rs @@ -392,6 +392,12 @@ impl Window { self.canvas.safe_area() } + /// How many pixels of the window the on-screen keyboard covers, from the + /// bottom; zero with it down, and zero everywhere but Android and iOS. + pub fn keyboard_height(&self) -> f32 { + self.canvas.keyboard_height() + } + /// Sets the cursor position in window coordinates. /// /// # Arguments From e90b8d8b924f61f2a1a0af4e67641d6ccb8a7ca0 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Fri, 11 Sep 2026 09:41:26 +0300 Subject: [PATCH 16/50] Update texture_manager.rs --- src/resource/texture_manager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resource/texture_manager.rs b/src/resource/texture_manager.rs index 4cd927bdb..413a593f7 100644 --- a/src/resource/texture_manager.rs +++ b/src/resource/texture_manager.rs @@ -349,7 +349,7 @@ impl Texture { /// leave the fringe it is here to remove. fn premultiply_rgba(data: &[u8], srgb: bool) -> Vec { let mut out = data.to_vec(); - for texel in out.chunks_exact_mut(4) { + for texel in out.as_chunks_mut::<4>().0 { let alpha = texel[3] as f32 / 255.0; for channel in &mut texel[..3] { *channel = if srgb { From dce5aec7f0d0485924b456d821c43947bdf1a436 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Fri, 11 Sep 2026 10:07:09 +0300 Subject: [PATCH 17/50] add support for exclusive full screen and maximized mode. --- src/window/canvas.rs | 10 ++++++++++ src/window/wgpu_canvas.rs | 33 +++++++++++++++++++++++++++++++++ src/window/window.rs | 15 +++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/src/window/canvas.rs b/src/window/canvas.rs index 508c06b22..ecd5704be 100644 --- a/src/window/canvas.rs +++ b/src/window/canvas.rs @@ -189,6 +189,16 @@ impl Canvas { self.canvas.set_fullscreen(fullscreen); } + /// Enter or leave exclusive fullscreen on the current monitor. + pub fn set_exclusive_fullscreen(&self, exclusive: bool) { + self.canvas.set_exclusive_fullscreen(exclusive); + } + + /// Maximize the window, or restore it. + pub fn set_maximized(&self, maximized: bool) { + self.canvas.set_maximized(maximized); + } + /// Show or hide the platform's on-screen keyboard (mobile; no-op elsewhere). pub fn set_keyboard_visible(&self, visible: bool) { self.canvas.set_keyboard_visible(visible); diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index 9c8f14e4e..1cbbf2a12 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -1897,6 +1897,39 @@ impl WgpuCanvas { } } + /// Enter exclusive fullscreen on the current monitor, in its largest + /// video mode at the highest refresh rate, or leave it. Falls back to + /// borderless where the platform offers no video modes, as the web does. + pub fn set_exclusive_fullscreen(&self, exclusive: bool) { + let Some(window) = &self.window else { + return; + }; + if !exclusive { + window.set_fullscreen(None); + return; + } + let mode = window.current_monitor().and_then(|monitor| { + monitor.video_modes().max_by_key(|mode| { + let size = mode.size(); + ( + u64::from(size.width) * u64::from(size.height), + mode.refresh_rate_millihertz(), + ) + }) + }); + window.set_fullscreen(Some(match mode { + Some(mode) => winit::window::Fullscreen::Exclusive(mode), + None => winit::window::Fullscreen::Borderless(None), + })); + } + + /// Maximize the window, or restore it. + pub fn set_maximized(&self, maximized: bool) { + if let Some(window) = &self.window { + window.set_maximized(maximized); + } + } + /// Whether the window is currently fullscreen. pub fn is_fullscreen(&self) -> bool { self.window diff --git a/src/window/window.rs b/src/window/window.rs index 9fb475091..7c1d28802 100644 --- a/src/window/window.rs +++ b/src/window/window.rs @@ -353,6 +353,21 @@ impl Window { self.canvas.set_fullscreen(fullscreen); } + /// Enters or leaves exclusive fullscreen: the current monitor's largest + /// video mode, at its highest refresh rate. + /// + /// # Platform-specific + /// Where the platform offers no video modes, as on the web, this is + /// borderless fullscreen. + pub fn set_exclusive_fullscreen(&self, exclusive: bool) { + self.canvas.set_exclusive_fullscreen(exclusive); + } + + /// Maximizes the window, or restores it. + pub fn set_maximized(&self, maximized: bool) { + self.canvas.set_maximized(maximized); + } + /// Shows or hides the platform's on-screen keyboard. /// /// # Platform-specific From cc0bd8c919e55587b371e02234bdc12e5688b2b4 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 12 Sep 2026 21:29:42 +0300 Subject: [PATCH 18/50] Re-export wgpu so an implementor cannot drift to another version --- src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 17ca16306..531fb9c96 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -107,6 +107,10 @@ pub extern crate egui; pub use glamx; +// The material traits are written in wgpu types, so an implementor must name +// them: re-exported so it uses this crate's wgpu and cannot drift to another. +pub use wgpu; + // Re-export the procedural macro and its runtime dependencies pub use kiss3d_macro::main; From 775b688fc4c4cfee829d4764826c2c998bbe1158 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 13 Sep 2026 08:04:36 +0300 Subject: [PATCH 19/50] fix egui hotkeys --- src/window/egui_integration.rs | 213 +++++++++++++++++++++++++-------- src/window/wgpu_canvas.rs | 31 ++++- 2 files changed, 191 insertions(+), 53 deletions(-) diff --git a/src/window/egui_integration.rs b/src/window/egui_integration.rs index 90753bb61..b400bc4c6 100644 --- a/src/window/egui_integration.rs +++ b/src/window/egui_integration.rs @@ -63,16 +63,120 @@ fn command_is_super() -> bool { /// tapped within one frame has already released its modifier by then. fn egui_modifiers(modifiers: Modifiers) -> egui::Modifiers { let ctrl = modifiers.contains(Modifiers::Control); - let mac_cmd = command_is_super() && modifiers.contains(Modifiers::Super); + let super_key = modifiers.contains(Modifiers::Super); egui::Modifiers { alt: modifiers.contains(Modifiers::Alt), ctrl, shift: modifiers.contains(Modifiers::Shift), - mac_cmd, - command: if command_is_super() { mac_cmd } else { ctrl }, + // Only where the platform has a ⌘: egui's mac-only paths read this. + mac_cmd: command_is_super() && super_key, + // Either key drives a `COMMAND` chord, so ⌃S saves on a Mac and ⌘S + // saves on a Mac keyboard plugged into anything else. + command: ctrl || super_key, } } +/// egui's key for one of ours. Keys arrive as physical codes on both +/// backends, so a shifted `\` is still `Backslash` and one arm covers every +/// layout. Anything egui has no key for is dropped. +fn translate_key_to_egui(key: Key) -> Option { + Some(match key { + Key::A => egui::Key::A, + Key::B => egui::Key::B, + Key::C => egui::Key::C, + Key::D => egui::Key::D, + Key::E => egui::Key::E, + Key::F => egui::Key::F, + Key::G => egui::Key::G, + Key::H => egui::Key::H, + Key::I => egui::Key::I, + Key::J => egui::Key::J, + Key::K => egui::Key::K, + Key::L => egui::Key::L, + Key::M => egui::Key::M, + Key::N => egui::Key::N, + Key::O => egui::Key::O, + Key::P => egui::Key::P, + Key::Q => egui::Key::Q, + Key::R => egui::Key::R, + Key::S => egui::Key::S, + Key::T => egui::Key::T, + Key::U => egui::Key::U, + Key::V => egui::Key::V, + Key::W => egui::Key::W, + Key::X => egui::Key::X, + Key::Y => egui::Key::Y, + Key::Z => egui::Key::Z, + Key::Key0 | Key::Numpad0 => egui::Key::Num0, + Key::Key1 | Key::Numpad1 => egui::Key::Num1, + Key::Key2 | Key::Numpad2 => egui::Key::Num2, + Key::Key3 | Key::Numpad3 => egui::Key::Num3, + Key::Key4 | Key::Numpad4 => egui::Key::Num4, + Key::Key5 | Key::Numpad5 => egui::Key::Num5, + Key::Key6 | Key::Numpad6 => egui::Key::Num6, + Key::Key7 | Key::Numpad7 => egui::Key::Num7, + Key::Key8 | Key::Numpad8 => egui::Key::Num8, + Key::Key9 | Key::Numpad9 => egui::Key::Num9, + Key::F1 => egui::Key::F1, + Key::F2 => egui::Key::F2, + Key::F3 => egui::Key::F3, + Key::F4 => egui::Key::F4, + Key::F5 => egui::Key::F5, + Key::F6 => egui::Key::F6, + Key::F7 => egui::Key::F7, + Key::F8 => egui::Key::F8, + Key::F9 => egui::Key::F9, + Key::F10 => egui::Key::F10, + Key::F11 => egui::Key::F11, + Key::F12 => egui::Key::F12, + Key::F13 => egui::Key::F13, + Key::F14 => egui::Key::F14, + Key::F15 => egui::Key::F15, + Key::F16 => egui::Key::F16, + Key::F17 => egui::Key::F17, + Key::F18 => egui::Key::F18, + Key::F19 => egui::Key::F19, + Key::F20 => egui::Key::F20, + Key::F21 => egui::Key::F21, + Key::F22 => egui::Key::F22, + Key::F23 => egui::Key::F23, + Key::F24 => egui::Key::F24, + Key::Backslash => egui::Key::Backslash, + Key::Slash | Key::Divide => egui::Key::Slash, + Key::Comma | Key::NumpadComma => egui::Key::Comma, + Key::Period | Key::Decimal => egui::Key::Period, + Key::Minus | Key::Subtract => egui::Key::Minus, + Key::Add => egui::Key::Plus, + Key::Equals | Key::NumpadEquals => egui::Key::Equals, + Key::Semicolon => egui::Key::Semicolon, + Key::Colon => egui::Key::Colon, + Key::Apostrophe => egui::Key::Quote, + Key::Grave => egui::Key::Backtick, + Key::LBracket => egui::Key::OpenBracket, + Key::RBracket => egui::Key::CloseBracket, + Key::Escape => egui::Key::Escape, + Key::Tab => egui::Key::Tab, + Key::Back => egui::Key::Backspace, + Key::Return | Key::NumpadEnter => egui::Key::Enter, + Key::Space => egui::Key::Space, + Key::Insert => egui::Key::Insert, + Key::Delete => egui::Key::Delete, + Key::Home => egui::Key::Home, + Key::End => egui::Key::End, + Key::PageUp => egui::Key::PageUp, + Key::PageDown => egui::Key::PageDown, + Key::Left => egui::Key::ArrowLeft, + Key::Up => egui::Key::ArrowUp, + Key::Right => egui::Key::ArrowRight, + Key::Down => egui::Key::ArrowDown, + Key::Copy => egui::Key::Copy, + Key::Cut => egui::Key::Cut, + Key::Paste => egui::Key::Paste, + Key::NavigateBackward => egui::Key::BrowserBack, + _ => return None, + }) +} + impl Window { /// Retrieves a mutable reference to the egui context. /// @@ -280,7 +384,7 @@ impl Window { .push(egui::Event::Text(ch.to_string())); } WindowEvent::Key(key, action, modifiers) => { - if let Some(egui_key) = self.translate_key_to_egui(key) { + if let Some(egui_key) = translate_key_to_egui(key) { self.egui_context.raw_input.events.push(egui::Event::Key { key: egui_key, physical_key: None, @@ -294,53 +398,6 @@ impl Window { } } - pub(crate) fn translate_key_to_egui(&self, key: Key) -> Option { - Some(match key { - Key::A => egui::Key::A, - Key::B => egui::Key::B, - Key::C => egui::Key::C, - Key::D => egui::Key::D, - Key::E => egui::Key::E, - Key::F => egui::Key::F, - Key::G => egui::Key::G, - Key::H => egui::Key::H, - Key::I => egui::Key::I, - Key::J => egui::Key::J, - Key::K => egui::Key::K, - Key::L => egui::Key::L, - Key::M => egui::Key::M, - Key::N => egui::Key::N, - Key::O => egui::Key::O, - Key::P => egui::Key::P, - Key::Q => egui::Key::Q, - Key::R => egui::Key::R, - Key::S => egui::Key::S, - Key::T => egui::Key::T, - Key::U => egui::Key::U, - Key::V => egui::Key::V, - Key::W => egui::Key::W, - Key::X => egui::Key::X, - Key::Y => egui::Key::Y, - Key::Z => egui::Key::Z, - Key::Escape => egui::Key::Escape, - Key::Tab => egui::Key::Tab, - Key::Back => egui::Key::Backspace, - Key::Return => egui::Key::Enter, - Key::Space => egui::Key::Space, - Key::Insert => egui::Key::Insert, - Key::Delete => egui::Key::Delete, - Key::Home => egui::Key::Home, - Key::End => egui::Key::End, - Key::PageUp => egui::Key::PageUp, - Key::PageDown => egui::Key::PageDown, - Key::Left => egui::Key::ArrowLeft, - Key::Up => egui::Key::ArrowUp, - Key::Right => egui::Key::ArrowRight, - Key::Down => egui::Key::ArrowDown, - _ => return None, - }) - } - /// Draws an immediate mode UI using egui. /// /// Call this method from your render loop to create and display UI elements. @@ -530,3 +587,57 @@ fn winit_cursor(icon: egui::CursorIcon) -> winit::window::CursorIcon { E::ZoomOut => W::ZoomOut, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The chords the editor binds are punctuation, digits and function keys, + /// none of which egui ever saw while the map held letters only. + #[test] + fn punctuation_digits_and_function_keys_reach_egui() { + for (key, want) in [ + (Key::Backslash, egui::Key::Backslash), + (Key::Slash, egui::Key::Slash), + (Key::Comma, egui::Key::Comma), + (Key::Equals, egui::Key::Equals), + (Key::Minus, egui::Key::Minus), + (Key::Key1, egui::Key::Num1), + (Key::Numpad1, egui::Key::Num1), + (Key::F5, egui::Key::F5), + (Key::F11, egui::Key::F11), + (Key::A, egui::Key::A), + ] { + assert_eq!(translate_key_to_egui(key), Some(want), "{:?}", key); + } + assert_eq!(translate_key_to_egui(Key::Unknown), None); + } + + /// Both keys drive a `COMMAND` chord, whichever platform is running, so a + /// ⌃ chord works on a Mac and a ⌘ one on the keyboards that have it. + #[test] + fn control_and_super_both_read_as_command() { + for held in [Modifiers::Control, Modifiers::Super] { + let mods = egui_modifiers(held | Modifiers::Shift); + assert!(mods.command, "{:?} did not read as command", held); + assert!(mods.shift); + assert!( + mods.matches_logically(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT), + "{:?} did not match ⇧⌘", + held + ); + } + } + + /// ⌘ is a Mac's alone: egui's mac-only paths read `mac_cmd`, and a chord + /// asking for no modifier still has to see none. + #[test] + fn mac_cmd_follows_the_platform_and_a_bare_key_stays_bare() { + let sup = egui_modifiers(Modifiers::Super); + assert_eq!(sup.mac_cmd, cfg!(target_os = "macos")); + let none = egui_modifiers(Modifiers::empty()); + assert!(!none.command && !none.ctrl); + assert!(none.matches_logically(egui::Modifiers::NONE)); + assert!(!egui_modifiers(Modifiers::Control).matches_logically(egui::Modifiers::NONE)); + } +} diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index 1cbbf2a12..037351aae 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -2333,7 +2333,7 @@ trait WebModifiers { /// Builds a mask from a `getModifierState`-style query. /// /// See . -#[cfg(target_arch = "wasm32")] +#[cfg(any(target_arch = "wasm32", test))] fn web_modifiers(state: impl Fn(&str) -> bool) -> Modifiers { let mut res = Modifiers::empty(); if state("Shift") { @@ -2420,7 +2420,7 @@ fn is_modifier_key(key: Key) -> bool { ) } -#[cfg(target_arch = "wasm32")] +#[cfg(any(target_arch = "wasm32", test))] fn translate_web_key(code: &str) -> Key { match code { "Digit1" => Key::Key1, @@ -2541,6 +2541,33 @@ mod tests { .collect() } + /// The editor's chords are punctuation and function keys, and both + /// backends read the physical code, so a shifted `\` is still `Backslash`. + #[test] + fn a_chord_key_arrives_as_its_physical_code() { + for (code, web, want) in [ + (KeyCode::Backslash, "Backslash", Key::Backslash), + (KeyCode::Comma, "Comma", Key::Comma), + (KeyCode::Equal, "Equal", Key::Equals), + (KeyCode::Slash, "Slash", Key::Slash), + (KeyCode::F5, "F5", Key::F5), + ] { + assert_eq!(translate_key(PhysicalKey::Code(code)), want); + assert_eq!(translate_web_key(web), want); + } + } + + /// Both keys reach the event as themselves. Folding them into one command + /// modifier is the egui layer's job, in `egui_modifiers`. + #[test] + fn control_and_super_both_reach_the_event() { + let held = translate_modifiers(ModifiersState::SUPER | ModifiersState::SHIFT); + assert!(held.contains(Modifiers::Super) && held.contains(Modifiers::Shift)); + assert!(translate_modifiers(ModifiersState::CONTROL).contains(Modifiers::Control)); + let web = web_modifiers(|key| key == "Meta" || key == "Shift"); + assert!(web.contains(Modifiers::Super) && web.contains(Modifiers::Shift)); + } + // Regression test for https://github.com/dimforge/kiss3d/issues/380: // a single key press/release cycle must only produce a single Char event, // otherwise egui textboxes (and other text consumers) insert each typed From b80fa339c19b691935c69095cbd313fa05bb4bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 10 Sep 2026 18:13:43 +0200 Subject: [PATCH 20/50] fix(build): clippy chunk lint and the serde feature over wgpu's types --- .github/workflows/build.yml | 4 ++++ Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1693737d9..5f75fdb54 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,6 +39,10 @@ jobs: run: cargo build --verbose -p kiss3d; - name: Build kiss3d (+ egui) run: cargo build --verbose -p kiss3d --features egui; + # `serde` derives over types the graphics crates own, so it only builds + # while their own serde features are pulled in with it. + - name: Build kiss3d (+ serde) + run: cargo build --verbose -p kiss3d --features serde; - name: Build examples (+ egui) run: cargo build --verbose --examples --features egui; - name: Run tests diff --git a/Cargo.toml b/Cargo.toml index c3e04c0c3..dc67afa22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ path = "src/lib.rs" # falls back to a path text field). egui = ["dep:egui", "dep:egui-wgpu", "dep:rfd"] recording = ["dep:ffmpeg-the-third"] -serde = ["dep:serde", "glamx/serde", "bitflags/serde", "rgb/serde"] +serde = ["dep:serde", "glamx/serde", "bitflags/serde", "rgb/serde", "wgpu/serde"] # If enabled, switching between the ray-tracer and the rasterizer is possible while kipping # only the `.render_3d` call (instead of having to manually switch between `.render_3d` and # `.raytrace_3d`. This is mainly for debugging and experiments comparing the rasterizer and From 179489cb4717800cf4a83fd59227403ab859836f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 10 Sep 2026 18:15:39 +0200 Subject: [PATCH 21/50] fix(egui): make retained UI shapes opt-in rather than the default --- src/renderer/egui_renderer.rs | 40 +++++++++++++++++++++++++++++----- src/window/egui_integration.rs | 39 +++++++++++++++++++++++++++------ src/window/offscreen.rs | 19 ++++++++++++++++ 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/renderer/egui_renderer.rs b/src/renderer/egui_renderer.rs index 4b64e3dff..122ff86dc 100644 --- a/src/renderer/egui_renderer.rs +++ b/src/renderer/egui_renderer.rs @@ -11,6 +11,7 @@ pub struct EguiRenderer { shapes: Vec, textures_delta: egui::TexturesDelta, cursor: egui::CursorIcon, + retain_shapes: bool, } impl EguiRenderer { @@ -82,6 +83,7 @@ impl EguiRenderer { shapes: Vec::new(), textures_delta: Default::default(), cursor: egui::CursorIcon::Default, + retain_shapes: false, } } @@ -98,12 +100,35 @@ impl EguiRenderer { /// Begin a new frame with the given raw input. pub fn begin_frame(&mut self, raw_input: RawInput) { // The pass about to run replaces last pass's shapes, so this is where - // they are dropped. Not after rendering them: a host that skips a pass - // shows the last one again rather than nothing (`Window::draw_ui`). + // they are dropped once they have been rendered too (see + // `set_retain_shapes`). self.shapes.clear(); self.egui_ctx.begin_pass(raw_input); } + /// Whether a pass's shapes survive being rendered, so a frame that runs no + /// pass at all draws the last one again. Off by default. + /// + /// Turning it on lets a host run its UI only on the frames something + /// changed. It also means a UI stops being drawn only when the next pass + /// draws something else: a caller that shows its UI conditionally wants + /// this off, or has to call [`Self::clear_shapes`] when it hides it. + pub fn set_retain_shapes(&mut self, retain: bool) { + self.retain_shapes = retain; + } + + /// Whether rendered shapes are kept for the frames that run no pass. + pub fn retains_shapes(&self) -> bool { + self.retain_shapes + } + + /// Drops the shapes of the last pass, so a retaining host stops drawing + /// the UI it last built. A no-op unless [`Self::set_retain_shapes`] is on, + /// since otherwise rendering them already dropped them. + pub fn clear_shapes(&mut self) { + self.shapes.clear(); + } + /// What the last pass asked the pointer to look like over the widget it /// was on. `Default` whenever nothing under it asked for anything else. pub fn cursor(&self) -> egui::CursorIcon { @@ -121,10 +146,6 @@ impl EguiRenderer { self.textures_delta.append(output.textures_delta); } - /// Registers a native wgpu texture view with egui, returning a - /// [`egui::TextureId`] that `ui.image((id, size))` can draw — no CPU copy - /// involved. The texture stays registered until - /// [`Self::unregister_native_texture`]. /// Throw the open pass away and begin it again with `raw_input`, as /// `Context::run` does when a pass asks to be discarded: its shapes are /// dropped, its texture uploads kept (the font atlas may have grown), and @@ -140,6 +161,10 @@ impl EguiRenderer { self.egui_ctx.begin_pass(raw_input); } + /// Registers a native wgpu texture view with egui, returning a + /// [`egui::TextureId`] that `ui.image((id, size))` can draw — no CPU copy + /// involved. The texture stays registered until + /// [`Self::unregister_native_texture`]. pub fn register_native_texture( &mut self, view: &wgpu::TextureView, @@ -253,6 +278,9 @@ impl EguiRenderer { } self.textures_delta.clear(); + if !self.retain_shapes { + self.shapes.clear(); + } } } diff --git a/src/window/egui_integration.rs b/src/window/egui_integration.rs index b400bc4c6..7d5b8368e 100644 --- a/src/window/egui_integration.rs +++ b/src/window/egui_integration.rs @@ -398,6 +398,28 @@ impl Window { } } + /// Whether the UI built by one frame's [`Self::draw_ui`] calls is drawn + /// again on the frames that make none. Off by default. + /// + /// Turning it on lets an app run its widgets only when something changed + /// and keep the UI on screen in between. An app that shows its UI + /// conditionally (`if show_hud { window.draw_ui(..) }`) wants it off, or + /// has to call [`Self::clear_ui`] when it hides the UI. + pub fn set_ui_retained(&mut self, retained: bool) { + self.egui_context.renderer.set_retain_shapes(retained); + } + + /// Whether the last frame's UI is redrawn on a frame that builds none. + pub fn ui_retained(&self) -> bool { + self.egui_context.renderer.retains_shapes() + } + + /// Drops the UI built by the last [`Self::draw_ui`], so a retaining app + /// stops drawing it. A no-op unless [`Self::set_ui_retained`] is on. + pub fn clear_ui(&mut self) { + self.egui_context.renderer.clear_shapes(); + } + /// Draws an immediate mode UI using egui. /// /// Call this method from your render loop to create and display UI elements. @@ -434,15 +456,18 @@ impl Window { /// /// # Note /// Only available when the `egui` feature is enabled. + /// # Passes + /// + /// `ui_fn` may run more than once per frame. A pass that only measured + /// something (a new `Area`, a `Grid`, a `Resize`) asks egui to discard it + /// and run it again before the frame is shown, so a closure that mutates + /// state has to tolerate being replayed. Only the closure that opened the + /// pass is replayed: when a second `draw_ui` of the same frame is the one + /// that asks, its measurement lands on the next frame instead. pub fn draw_ui(&mut self, mut ui_fn: F) where F: FnMut(&egui::Context), { - // A frame that calls this none of the times shows the last pass's - // shapes again, which is what lets a host redraw its UI only when - // something changed; the shapes are dropped by the next pass, not by - // rendering them (`EguiRenderer::begin_frame`). - // // Open the egui pass lazily so that several `draw_ui` (and // `draw_inspector`) calls in the same frame all run their widgets into // the *same* pass. The pass is closed at render time by @@ -456,8 +481,8 @@ impl Window { ui_fn(self.egui_context.renderer.context()); - // A pass that only learned a size — a new `Area`, which draws none of - // itself while it is measured, a `Grid`, a `Resize` — asks to be + // A pass that only learned a size (a new `Area`, which draws none of + // itself while it is measured, a `Grid`, a `Resize`) asks to be // discarded and run again before the frame is shown. `Context::run` // does that for its callers; with the pass open here, this does, or // the frame shows the gap. Only the call that opened the pass reruns: diff --git a/src/window/offscreen.rs b/src/window/offscreen.rs index 3ea7c5abc..fe34a95d1 100644 --- a/src/window/offscreen.rs +++ b/src/window/offscreen.rs @@ -376,4 +376,23 @@ impl OffscreenSurface { { self.window.draw_ui(ui_fn); } + + /// Redraws the last UI on the frames that build none. See + /// [`Window::set_ui_retained`]. + #[cfg(feature = "egui")] + pub fn set_ui_retained(&mut self, retained: bool) { + self.window.set_ui_retained(retained); + } + + /// Whether the last UI is redrawn on a frame that builds none. + #[cfg(feature = "egui")] + pub fn ui_retained(&self) -> bool { + self.window.ui_retained() + } + + /// Drops the UI built by the last `draw_ui`. See [`Window::clear_ui`]. + #[cfg(feature = "egui")] + pub fn clear_ui(&mut self) { + self.window.clear_ui(); + } } From d85164ae958a212af7157af1a2612f6d6636c7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 10 Sep 2026 18:21:02 +0200 Subject: [PATCH 22/50] feat(post-processing): build effect pipelines per output format so both chains take any effect --- src/post_processing/cas.rs | 84 +++++----- src/post_processing/crt.rs | 84 +++++----- src/post_processing/fxaa.rs | 84 +++++----- src/post_processing/gi2d.rs | 156 ++++++++++-------- src/post_processing/grayscales.rs | 84 +++++----- src/post_processing/hdr.rs | 9 +- src/post_processing/loupe.rs | 121 ++++++++------ src/post_processing/mod.rs | 2 +- src/post_processing/oculus_stereo.rs | 84 +++++----- src/post_processing/post_processing_effect.rs | 39 +++++ src/post_processing/sobel_edge_highlight.rs | 84 +++++----- src/post_processing/waves.rs | 84 +++++----- src/window/rendering.rs | 103 ++++++------ src/window/window.rs | 12 +- 14 files changed, 586 insertions(+), 444 deletions(-) diff --git a/src/post_processing/cas.rs b/src/post_processing/cas.rs index 8c63ac3ce..3e948a78d 100644 --- a/src/post_processing/cas.rs +++ b/src/post_processing/cas.rs @@ -6,7 +6,9 @@ //! recover the detail those passes soften. use crate::context::Context; -use crate::post_processing::post_processing_effect::{PostProcessingContext, PostProcessingEffect}; +use crate::post_processing::post_processing_effect::{ + FormatPipelines, PostProcessingContext, PostProcessingEffect, +}; use crate::resource::RenderTarget; use bytemuck::{Pod, Zeroable}; @@ -26,7 +28,7 @@ struct CasUniforms { /// Contrast Adaptive Sharpening post-processing effect. pub struct Cas { - pipeline: wgpu::RenderPipeline, + pipeline: FormatPipelines, bind_group_layout: wgpu::BindGroupLayout, vertex_buffer: wgpu::Buffer, uniform_buffer: wgpu::Buffer, @@ -100,42 +102,45 @@ impl Cas { }], }; - let pipeline = ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("cas_pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[Some(vertex_buffer_layout)], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: ctxt.surface_format, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleStrip, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, + let pipeline = FormatPipelines::new(move |format| { + let ctxt = Context::get(); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("cas_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[Some(vertex_buffer_layout.clone())], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) }); let vertices = [ @@ -190,6 +195,7 @@ impl PostProcessingEffect for Cas { } fn draw(&mut self, target: &RenderTarget, context: &mut PostProcessingContext) { + let pipeline = self.pipeline.get(context.output_format); let ctxt = Context::get(); let (color_view, sampler) = match target { @@ -237,7 +243,7 @@ impl PostProcessingEffect for Cas { multiview_mask: None, }); - render_pass.set_pipeline(&self.pipeline); + render_pass.set_pipeline(&pipeline); render_pass.set_bind_group(0, &bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); render_pass.draw(0..4, 0..1); diff --git a/src/post_processing/crt.rs b/src/post_processing/crt.rs index ebda0a3a9..34a9aecf9 100644 --- a/src/post_processing/crt.rs +++ b/src/post_processing/crt.rs @@ -2,7 +2,9 @@ //! scanlines and a vignette — a classic retro look for 2D scenes. use crate::context::Context; -use crate::post_processing::post_processing_effect::{PostProcessingContext, PostProcessingEffect}; +use crate::post_processing::post_processing_effect::{ + FormatPipelines, PostProcessingContext, PostProcessingEffect, +}; use crate::resource::RenderTarget; use bytemuck::{Pod, Zeroable}; @@ -39,7 +41,7 @@ struct CrtUniforms { /// crt.set_scanlines(0.3, 480.0); /// ``` pub struct Crt { - pipeline: wgpu::RenderPipeline, + pipeline: FormatPipelines, texture_bind_group_layout: wgpu::BindGroupLayout, uniform_buffer: wgpu::Buffer, uniform_bind_group: wgpu::BindGroup, @@ -123,42 +125,45 @@ impl Crt { }], }; - let pipeline = ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("crt_pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[Some(vertex_buffer_layout)], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: ctxt.surface_format, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleStrip, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, + let pipeline = FormatPipelines::new(move |format| { + let ctxt = Context::get(); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("crt_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[Some(vertex_buffer_layout.clone())], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) }); let vertices = [ @@ -239,6 +244,7 @@ impl PostProcessingEffect for Crt { fn update(&mut self, _dt: f32, _w: f32, _h: f32, _znear: f32, _zfar: f32) {} fn draw(&mut self, target: &RenderTarget, context: &mut PostProcessingContext) { + let pipeline = self.pipeline.get(context.output_format); let ctxt = Context::get(); let (color_view, sampler) = match target { @@ -282,7 +288,7 @@ impl PostProcessingEffect for Crt { multiview_mask: None, }); - render_pass.set_pipeline(&self.pipeline); + render_pass.set_pipeline(&pipeline); render_pass.set_bind_group(0, &texture_bind_group, &[]); render_pass.set_bind_group(1, &self.uniform_bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); diff --git a/src/post_processing/fxaa.rs b/src/post_processing/fxaa.rs index 9a0b3a0d7..6e69ba477 100644 --- a/src/post_processing/fxaa.rs +++ b/src/post_processing/fxaa.rs @@ -6,7 +6,9 @@ //! rendering) — at the cost of some softening of fine detail. use crate::context::Context; -use crate::post_processing::post_processing_effect::{PostProcessingContext, PostProcessingEffect}; +use crate::post_processing::post_processing_effect::{ + FormatPipelines, PostProcessingContext, PostProcessingEffect, +}; use crate::resource::RenderTarget; use bytemuck::{Pod, Zeroable}; @@ -42,7 +44,7 @@ struct FxaaUniforms { /// # } /// ``` pub struct Fxaa { - pipeline: wgpu::RenderPipeline, + pipeline: FormatPipelines, bind_group_layout: wgpu::BindGroupLayout, vertex_buffer: wgpu::Buffer, uniform_buffer: wgpu::Buffer, @@ -116,42 +118,45 @@ impl Fxaa { }], }; - let pipeline = ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("fxaa_pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[Some(vertex_buffer_layout)], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: ctxt.surface_format, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleStrip, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, + let pipeline = FormatPipelines::new(move |format| { + let ctxt = Context::get(); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("fxaa_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[Some(vertex_buffer_layout.clone())], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) }); let vertices = [ @@ -211,6 +216,7 @@ impl PostProcessingEffect for Fxaa { } fn draw(&mut self, target: &RenderTarget, context: &mut PostProcessingContext) { + let pipeline = self.pipeline.get(context.output_format); let ctxt = Context::get(); let (color_view, sampler) = match target { @@ -258,7 +264,7 @@ impl PostProcessingEffect for Fxaa { multiview_mask: None, }); - render_pass.set_pipeline(&self.pipeline); + render_pass.set_pipeline(&pipeline); render_pass.set_bind_group(0, &bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); render_pass.draw(0..4, 0..1); diff --git a/src/post_processing/gi2d.rs b/src/post_processing/gi2d.rs index 34c5d6125..d2ae1ef4b 100644 --- a/src/post_processing/gi2d.rs +++ b/src/post_processing/gi2d.rs @@ -29,7 +29,9 @@ use crate::camera::Camera2d; use crate::color::Color; use crate::context::Context; -use crate::post_processing::post_processing_effect::{PostProcessingContext, PostProcessingEffect}; +use crate::post_processing::post_processing_effect::{ + FormatPipelines, PostProcessingContext, PostProcessingEffect, +}; use crate::post_processing::HDR_FORMAT; use crate::resource::RenderTarget; use bytemuck::{Pod, Zeroable}; @@ -188,7 +190,7 @@ impl GiTexture { /// Screen-space 2D global-illumination post-processing effect (see the [module docs](crate::post_processing)). pub struct Gi2d { field_pipeline: wgpu::RenderPipeline, - composite_pipeline: wgpu::RenderPipeline, + composite_pipeline: FormatPipelines, jfa_seed_pipeline: wgpu::RenderPipeline, jfa_step_pipeline: wgpu::RenderPipeline, jfa_resolve_pipeline: wgpu::RenderPipeline, @@ -220,7 +222,7 @@ pub struct Gi2d { // Radiance-cascade resources. cascade_pipeline: wgpu::RenderPipeline, - cascade_composite_pipeline: wgpu::RenderPipeline, + cascade_composite_pipeline: FormatPipelines, cascade: [GiTexture; 2], /// Current allocated size of the (decoupled) cascade textures. cascade_tex_size: (u32, u32), @@ -379,58 +381,12 @@ impl Gi2d { ), ); - let vertex_buffer_layout = Some(wgpu::VertexBufferLayout { - array_stride: std::mem::size_of::() as wgpu::BufferAddress, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &[wgpu::VertexAttribute { - offset: 0, - shader_location: 0, - format: wgpu::VertexFormat::Float32x2, - }], - }); - let make_pipeline = |label: &str, layout: &wgpu::PipelineLayout, shader: &wgpu::ShaderModule, fs_entry: &str, format: wgpu::TextureFormat| { - ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(label), - layout: Some(layout), - vertex: wgpu::VertexState { - module: shader, - entry_point: Some("vs_main"), - buffers: std::slice::from_ref(&vertex_buffer_layout), - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: shader, - entry_point: Some(fs_entry), - targets: &[Some(wgpu::ColorTargetState { - format, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleStrip, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, - }) + make_gi2d_pipeline(label, layout, shader, fs_entry, format) }; let field_pipeline = make_pipeline( @@ -440,13 +396,17 @@ impl Gi2d { "fs_main", HDR_FORMAT, ); - let composite_pipeline = make_pipeline( - "gi2d_composite_pipeline", - &composite_layout, - &composite_shader, - "fs_main", - ctxt.surface_format, - ); + // Written into whatever chain the effect is handed to: the LDR one + // takes the surface format, the film one takes `HDR_FORMAT`. + let composite_pipeline = FormatPipelines::new(move |format| { + make_gi2d_pipeline( + "gi2d_composite_pipeline", + &composite_layout, + &composite_shader, + "fs_main", + format, + ) + }); let jfa_seed_pipeline = make_pipeline( "gi2d_jfa_seed_pipeline", &jfa_seed_layout, @@ -511,13 +471,15 @@ impl Gi2d { "fs_main", HDR_FORMAT, ); - let cascade_composite_pipeline = make_pipeline( - "gi2d_cascade_composite_pipeline", - &cascade_composite_layout, - &cascade_composite_shader, - "fs_main", - ctxt.surface_format, - ); + let cascade_composite_pipeline = FormatPipelines::new(move |format| { + make_gi2d_pipeline( + "gi2d_cascade_composite_pipeline", + &cascade_composite_layout, + &cascade_composite_shader, + "fs_main", + format, + ) + }); // Per-level cascade uniform pool (filled each frame for the levels in use). let mut cascade_param_buffers = Vec::with_capacity(MAX_CASCADES); @@ -1006,6 +968,7 @@ impl Gi2d { &mut self, encoder: &mut wgpu::CommandEncoder, output_view: &wgpu::TextureView, + output_format: wgpu::TextureFormat, scene_view: &wgpu::TextureView, scene_sampler: &wgpu::Sampler, ) { @@ -1119,11 +1082,12 @@ impl Gi2d { ], }); let c0_bg = self.tex_only_bind_group(&self.cascade[0].view); + let composite = self.cascade_composite_pipeline.get(output_format); self.fullscreen_pass( encoder, "gi2d_cascade_composite_pass", output_view, - &self.cascade_composite_pipeline, + &composite, &[&scene_bg, &c0_bg, &self.cascade_composite_bind_group], ); } @@ -1158,6 +1122,7 @@ impl PostProcessingEffect for Gi2d { self.render_cascades( context.encoder, context.output_view, + context.output_format, scene_view, scene_sampler, ); @@ -1209,11 +1174,12 @@ impl PostProcessingEffect for Gi2d { ], }); let gi_bind_group = self.tex_bind_group(&self.history[next].view, "gi2d_field_bg"); + let composite = self.composite_pipeline.get(context.output_format); self.fullscreen_pass( context.encoder, "gi2d_composite_pass", context.output_view, - &self.composite_pipeline, + &composite, &[ &scene_bind_group, &gi_bind_group, @@ -1228,3 +1194,61 @@ impl PostProcessingEffect for Gi2d { self.frame_index = self.frame_index.wrapping_add(1); } } + +/// One of the fixed-function full-screen pipelines this effect runs, drawing +/// `fs_entry` into an attachment of `format`. +fn make_gi2d_pipeline( + label: &str, + layout: &wgpu::PipelineLayout, + shader: &wgpu::ShaderModule, + fs_entry: &str, + format: wgpu::TextureFormat, +) -> wgpu::RenderPipeline { + let ctxt = Context::get(); + let vertex_buffer_layout = Some(wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }], + }); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(layout), + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs_main"), + buffers: std::slice::from_ref(&vertex_buffer_layout), + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: shader, + entry_point: Some(fs_entry), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) +} diff --git a/src/post_processing/grayscales.rs b/src/post_processing/grayscales.rs index 0081960a4..4d523d082 100644 --- a/src/post_processing/grayscales.rs +++ b/src/post_processing/grayscales.rs @@ -1,7 +1,9 @@ //! Post-processing effect to draw everything in grey-levels. use crate::context::Context; -use crate::post_processing::post_processing_effect::{PostProcessingContext, PostProcessingEffect}; +use crate::post_processing::post_processing_effect::{ + FormatPipelines, PostProcessingContext, PostProcessingEffect, +}; use crate::resource::RenderTarget; use bytemuck::{Pod, Zeroable}; @@ -14,7 +16,7 @@ struct QuadVertex { /// Post processing effect which turns everything to gray scales. pub struct Grayscales { - pipeline: wgpu::RenderPipeline, + pipeline: FormatPipelines, bind_group_layout: wgpu::BindGroupLayout, vertex_buffer: wgpu::Buffer, } @@ -79,42 +81,45 @@ impl Grayscales { }], }; - let pipeline = ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("grayscales_pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[Some(vertex_buffer_layout)], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: ctxt.surface_format, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleStrip, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, + let pipeline = FormatPipelines::new(move |format| { + let ctxt = Context::get(); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("grayscales_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[Some(vertex_buffer_layout.clone())], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) }); // Create full-screen quad vertices @@ -151,6 +156,7 @@ impl PostProcessingEffect for Grayscales { fn update(&mut self, _: f32, _: f32, _: f32, _: f32, _: f32) {} fn draw(&mut self, target: &RenderTarget, context: &mut PostProcessingContext) { + let pipeline = self.pipeline.get(context.output_format); let ctxt = Context::get(); // Get the source texture and sampler from the render target @@ -196,7 +202,7 @@ impl PostProcessingEffect for Grayscales { multiview_mask: None, }); - render_pass.set_pipeline(&self.pipeline); + render_pass.set_pipeline(&pipeline); render_pass.set_bind_group(0, &bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); render_pass.draw(0..4, 0..1); diff --git a/src/post_processing/hdr.rs b/src/post_processing/hdr.rs index d6c9a0a34..06d7cc87d 100644 --- a/src/post_processing/hdr.rs +++ b/src/post_processing/hdr.rs @@ -1426,6 +1426,7 @@ impl HdrPipeline { fn run_auto_exposure( &mut self, encoder: &mut wgpu::CommandEncoder, + input: &wgpu::TextureView, gpu: &mut crate::renderer::timings::GpuTimer, ) -> usize { let ctxt = Context::get(); @@ -1453,14 +1454,16 @@ impl HdrPipeline { }), ); - // Metering pass: scene -> 1x1 average luminance. + // Metering pass: scene -> 1x1 average luminance. Meters `input`, not the + // film itself, so a film-stage chain that changes brightness is what the + // exposure adapts to rather than something the tonemap never sees. let meter_bg = ctxt.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("hdr_autoexposure_meter_bg"), layout: &self.meter_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, - resource: wgpu::BindingResource::TextureView(&self.scene_view), + resource: wgpu::BindingResource::TextureView(input), }, wgpu::BindGroupEntry { binding: 1, @@ -1581,7 +1584,7 @@ impl HdrPipeline { // exposure texture is sampled by the tonemap pass (binding 5). let auto = self.settings.auto_exposure; let exposure_index = if auto { - self.run_auto_exposure(encoder, gpu) + self.run_auto_exposure(encoder, input, gpu) } else { self.exposure_index }; diff --git a/src/post_processing/loupe.rs b/src/post_processing/loupe.rs index 88c86a946..0e5591700 100644 --- a/src/post_processing/loupe.rs +++ b/src/post_processing/loupe.rs @@ -29,7 +29,9 @@ //! ``` use crate::context::Context; -use crate::post_processing::post_processing_effect::{PostProcessingContext, PostProcessingEffect}; +use crate::post_processing::post_processing_effect::{ + FormatPipelines, PostProcessingContext, PostProcessingEffect, +}; use crate::resource::RenderTarget; use bytemuck::{Pod, Zeroable}; use glamx::Vec2; @@ -59,18 +61,20 @@ struct LoupeUniforms { border_color: [f32; 4], } -/// An intermediate surface-format target the inner effect renders into, so the -/// loupe can magnify the post-processed result. Allocated lazily, only when an -/// inner effect is set. +/// An intermediate target the inner effect renders into, so the loupe can +/// magnify the post-processed result. Allocated lazily, only when an inner +/// effect is set, and at the format the loupe itself was asked to write: the +/// inner effect draws into this the way it would draw into the real output. struct MidTarget { view: wgpu::TextureView, sampler: wgpu::Sampler, width: u32, height: u32, + format: wgpu::TextureFormat, } impl MidTarget { - fn new(width: u32, height: u32) -> Self { + fn new(width: u32, height: u32, format: wgpu::TextureFormat) -> Self { let ctxt = Context::get(); let texture = ctxt.create_texture(&wgpu::TextureDescriptor { label: Some("loupe_mid_texture"), @@ -82,7 +86,7 @@ impl MidTarget { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: ctxt.surface_format, + format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); @@ -96,13 +100,14 @@ impl MidTarget { sampler, width, height, + format, } } } /// Magnifier loupe post-processing effect. pub struct Loupe { - pipeline: wgpu::RenderPipeline, + pipeline: FormatPipelines, bind_group_layout: wgpu::BindGroupLayout, vertex_buffer: wgpu::Buffer, uniform_buffer: wgpu::Buffer, @@ -114,6 +119,9 @@ pub struct Loupe { border_color: [f32; 3], inner: Option>, mid: Option, + /// The viewport size the intermediate target is built at, learned in + /// `update` and used by `draw`. + mid_size: (u32, u32), } impl Default for Loupe { @@ -184,42 +192,45 @@ impl Loupe { }], }; - let pipeline = ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("loupe_pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[Some(vertex_buffer_layout)], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: ctxt.surface_format, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleStrip, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, + let pipeline = FormatPipelines::new(move |format| { + let ctxt = Context::get(); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("loupe_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[Some(vertex_buffer_layout.clone())], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) }); let vertices: [[f32; 2]; 4] = [[-1.0, -1.0], [1.0, -1.0], [-1.0, 1.0], [1.0, 1.0]]; @@ -256,6 +267,7 @@ impl Loupe { border_color: [1.0, 0.9, 0.2], inner: None, mid: None, + mid_size: (1, 1), } } @@ -317,10 +329,9 @@ impl PostProcessingEffect for Loupe { fn update(&mut self, dt: f32, w: f32, h: f32, znear: f32, zfar: f32) { if let Some(inner) = &mut self.inner { inner.update(dt, w, h, znear, zfar); - let (iw, ih) = (w.max(1.0) as u32, h.max(1.0) as u32); - if self.mid.as_ref().map(|m| (m.width, m.height)) != Some((iw, ih)) { - self.mid = Some(MidTarget::new(iw, ih)); - } + // The intermediate target is built in `draw`, which is where the + // format it has to match is known. + self.mid_size = (w.max(1.0) as u32, h.max(1.0) as u32); } else { self.mid = None; } @@ -353,9 +364,22 @@ impl PostProcessingEffect for Loupe { } fn draw(&mut self, target: &RenderTarget, context: &mut PostProcessingContext) { + let pipeline = self.pipeline.get(context.output_format); let ctxt = Context::get(); ctxt.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&self.uniforms)); + // The inner effect writes the same format the loupe was asked for. + if self.inner.is_some() { + let (iw, ih) = self.mid_size; + let stale = self + .mid + .as_ref() + .is_none_or(|m| (m.width, m.height, m.format) != (iw, ih, context.output_format)); + if stale { + self.mid = Some(MidTarget::new(iw, ih, context.output_format)); + } + } + // Source for the loupe: the inner effect's output when wrapping one (rendered // into our intermediate target first), otherwise the raw resolved scene. let (src_view, src_sampler) = match (&mut self.inner, &self.mid) { @@ -363,6 +387,7 @@ impl PostProcessingEffect for Loupe { let mut inner_ctx = PostProcessingContext { encoder: context.encoder, output_view: &mid.view, + output_format: mid.format, }; inner.draw(target, &mut inner_ctx); (&mid.view, &mid.sampler) @@ -411,7 +436,7 @@ impl PostProcessingEffect for Loupe { multiview_mask: None, }); - render_pass.set_pipeline(&self.pipeline); + render_pass.set_pipeline(&pipeline); render_pass.set_bind_group(0, &bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); render_pass.draw(0..4, 0..1); diff --git a/src/post_processing/mod.rs b/src/post_processing/mod.rs index 729090065..cfeef117e 100644 --- a/src/post_processing/mod.rs +++ b/src/post_processing/mod.rs @@ -14,7 +14,7 @@ pub use crate::post_processing::hdr::{ pub use crate::post_processing::loupe::{Loupe, LoupeCorner}; pub use crate::post_processing::oculus_stereo::OculusStereo; pub use crate::post_processing::post_processing_effect::{ - PostProcessingContext, PostProcessingEffect, + FormatPipelines, PostProcessingContext, PostProcessingEffect, }; #[cfg(not(target_arch = "wasm32"))] pub use crate::post_processing::sobel_edge_highlight::SobelEdgeHighlight; diff --git a/src/post_processing/oculus_stereo.rs b/src/post_processing/oculus_stereo.rs index 219c198eb..814138bcb 100644 --- a/src/post_processing/oculus_stereo.rs +++ b/src/post_processing/oculus_stereo.rs @@ -1,7 +1,9 @@ //! Post processing effect to support the Oculus Rift. use crate::context::Context; -use crate::post_processing::post_processing_effect::{PostProcessingContext, PostProcessingEffect}; +use crate::post_processing::post_processing_effect::{ + FormatPipelines, PostProcessingContext, PostProcessingEffect, +}; use crate::resource::RenderTarget; use bytemuck::{Pod, Zeroable}; @@ -26,7 +28,7 @@ struct OculusUniforms { /// An post-processing effect to support the oculus rift. pub struct OculusStereo { - pipeline: wgpu::RenderPipeline, + pipeline: FormatPipelines, texture_bind_group_layout: wgpu::BindGroupLayout, #[allow(dead_code)] uniform_bind_group_layout: wgpu::BindGroupLayout, @@ -117,42 +119,45 @@ impl OculusStereo { }], }; - let pipeline = ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("oculus_pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[Some(vertex_buffer_layout)], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: ctxt.surface_format, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleStrip, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, + let pipeline = FormatPipelines::new(move |format| { + let ctxt = Context::get(); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("oculus_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[Some(vertex_buffer_layout.clone())], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) }); // Create full-screen quad vertices @@ -214,6 +219,7 @@ impl PostProcessingEffect for OculusStereo { } fn draw(&mut self, target: &RenderTarget, context: &mut PostProcessingContext) { + let pipeline = self.pipeline.get(context.output_format); let ctxt = Context::get(); // Get the source texture and sampler from the render target @@ -276,7 +282,7 @@ impl PostProcessingEffect for OculusStereo { multiview_mask: None, }); - render_pass.set_pipeline(&self.pipeline); + render_pass.set_pipeline(&pipeline); render_pass.set_bind_group(0, &texture_bind_group, &[]); render_pass.set_bind_group(1, &self.uniform_bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); diff --git a/src/post_processing/post_processing_effect.rs b/src/post_processing/post_processing_effect.rs index 1ef78d96a..9a9aba189 100644 --- a/src/post_processing/post_processing_effect.rs +++ b/src/post_processing/post_processing_effect.rs @@ -1,6 +1,7 @@ //! Trait implemented by every post-processing effect. use crate::resource::RenderTarget; +use std::collections::HashMap; /// Context passed to post-processing effects during draw. pub struct PostProcessingContext<'a> { @@ -8,6 +9,44 @@ pub struct PostProcessingContext<'a> { pub encoder: &'a mut wgpu::CommandEncoder, /// The output color view to render to. pub output_view: &'a wgpu::TextureView, + /// The colour format of `output_view`. A pipeline is tied to the format of + /// the attachment it writes, so an effect that runs on both the LDR chain + /// (the surface format) and the film chain + /// ([`HDR_FORMAT`](crate::post_processing::HDR_FORMAT)) needs one per + /// format; [`FormatPipelines`] keeps them. + pub output_format: wgpu::TextureFormat, +} + +/// An effect's render pipeline, built once per colour format it is asked to +/// write into. +/// +/// The built-in effects all keep theirs here rather than building one pipeline +/// up front, so the same effect can be handed to either post-processing chain. +pub struct FormatPipelines { + build: Box wgpu::RenderPipeline>, + built: HashMap, +} + +impl FormatPipelines { + /// Defers pipeline creation to `build`, which is called once per format. + pub fn new(build: impl Fn(wgpu::TextureFormat) -> wgpu::RenderPipeline + 'static) -> Self { + FormatPipelines { + build: Box::new(build), + built: HashMap::new(), + } + } + + /// The pipeline writing `format`, built the first time it is asked for. + /// The returned pipeline is a handle, so cloning it out costs nothing and + /// leaves the caller free to borrow the rest of its effect. + pub fn get(&mut self, format: wgpu::TextureFormat) -> wgpu::RenderPipeline { + if let Some(pipeline) = self.built.get(&format) { + return pipeline.clone(); + } + let pipeline = (self.build)(format); + self.built.insert(format, pipeline.clone()); + pipeline + } } /// Trait for implementing custom post-processing effects. diff --git a/src/post_processing/sobel_edge_highlight.rs b/src/post_processing/sobel_edge_highlight.rs index 0a73bd566..c9f657007 100644 --- a/src/post_processing/sobel_edge_highlight.rs +++ b/src/post_processing/sobel_edge_highlight.rs @@ -1,7 +1,9 @@ //! A post-processing effect to highlight edges. use crate::context::Context; -use crate::post_processing::post_processing_effect::{PostProcessingContext, PostProcessingEffect}; +use crate::post_processing::post_processing_effect::{ + FormatPipelines, PostProcessingContext, PostProcessingEffect, +}; use crate::resource::RenderTarget; use bytemuck::{Pod, Zeroable}; @@ -26,7 +28,7 @@ struct SobelUniforms { /// Post processing effect which draws detected edges on top of the original buffer. pub struct SobelEdgeHighlight { - pipeline: wgpu::RenderPipeline, + pipeline: FormatPipelines, color_bind_group_layout: wgpu::BindGroupLayout, depth_bind_group_layout: wgpu::BindGroupLayout, #[allow(dead_code)] @@ -141,42 +143,45 @@ impl SobelEdgeHighlight { }], }; - let pipeline = ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("sobel_pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[Some(vertex_buffer_layout)], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: ctxt.surface_format, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleStrip, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, + let pipeline = FormatPipelines::new(move |format| { + let ctxt = Context::get(); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sobel_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[Some(vertex_buffer_layout.clone())], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) }); // Create full-screen quad vertices @@ -258,6 +263,7 @@ impl PostProcessingEffect for SobelEdgeHighlight { } fn draw(&mut self, target: &RenderTarget, context: &mut PostProcessingContext) { + let pipeline = self.pipeline.get(context.output_format); let ctxt = Context::get(); // Get the source textures and sampler from the render target @@ -330,7 +336,7 @@ impl PostProcessingEffect for SobelEdgeHighlight { multiview_mask: None, }); - render_pass.set_pipeline(&self.pipeline); + render_pass.set_pipeline(&pipeline); render_pass.set_bind_group(0, &color_bind_group, &[]); render_pass.set_bind_group(1, &depth_bind_group, &[]); render_pass.set_bind_group(2, &self.uniform_bind_group, &[]); diff --git a/src/post_processing/waves.rs b/src/post_processing/waves.rs index 07799d68b..3b7d6edfa 100644 --- a/src/post_processing/waves.rs +++ b/src/post_processing/waves.rs @@ -7,7 +7,9 @@ use std::f32; use crate::context::Context; -use crate::post_processing::post_processing_effect::{PostProcessingContext, PostProcessingEffect}; +use crate::post_processing::post_processing_effect::{ + FormatPipelines, PostProcessingContext, PostProcessingEffect, +}; use crate::resource::RenderTarget; use bytemuck::{Pod, Zeroable}; @@ -30,7 +32,7 @@ struct WavesUniforms { /// /// It deforms the displayed scene with a wave effect. pub struct Waves { - pipeline: wgpu::RenderPipeline, + pipeline: FormatPipelines, texture_bind_group_layout: wgpu::BindGroupLayout, #[allow(dead_code)] uniform_bind_group_layout: wgpu::BindGroupLayout, @@ -120,42 +122,45 @@ impl Waves { }], }; - let pipeline = ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("waves_pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[Some(vertex_buffer_layout)], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: ctxt.surface_format, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleStrip, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, + let pipeline = FormatPipelines::new(move |format| { + let ctxt = Context::get(); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("waves_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[Some(vertex_buffer_layout.clone())], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) }); // Create full-screen quad vertices @@ -215,6 +220,7 @@ impl PostProcessingEffect for Waves { } fn draw(&mut self, target: &RenderTarget, context: &mut PostProcessingContext) { + let pipeline = self.pipeline.get(context.output_format); let ctxt = Context::get(); // Get the source texture and sampler from the render target @@ -268,7 +274,7 @@ impl PostProcessingEffect for Waves { multiview_mask: None, }); - render_pass.set_pipeline(&self.pipeline); + render_pass.set_pipeline(&pipeline); render_pass.set_bind_group(0, &texture_bind_group, &[]); render_pass.set_bind_group(1, &self.uniform_bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); diff --git a/src/window/rendering.rs b/src/window/rendering.rs index ca010cd4d..2b4e5a123 100644 --- a/src/window/rendering.rs +++ b/src/window/rendering.rs @@ -11,8 +11,8 @@ use crate::prelude::FixedView2d; use crate::renderer::timings::{CpuTimer, RenderTimings}; use crate::renderer::{RayTracer, Renderer3d}; use crate::resource::{ - MaterialManager2d, MaterialManager3d, RenderContext, RenderContext2d, RenderContext2dEncoder, - RenderPhase, RenderTarget, + MaterialManager2d, MaterialManager3d, OffscreenBuffers, RenderContext, RenderContext2d, + RenderContext2dEncoder, RenderPhase, RenderTarget, }; use crate::scene::{SceneNode2d, SceneNode3d}; @@ -67,6 +67,27 @@ impl Window { self.screen_2d.as_ref().expect("the copy was just made") } + /// The film-stage ping-pong pair at [`HDR_FORMAT`](crate::post_processing::HDR_FORMAT), + /// made the first frame a film chain is passed and resized with the window + /// after that. A run that never passes one allocates neither. + fn ensure_film_targets(&mut self, width: u32, height: u32) { + let format = crate::post_processing::HDR_FORMAT; + match &mut self.film_render_targets { + Some((a, b)) => { + a.resize(width, height, format); + b.resize(width, height, format); + } + None => { + let make = || { + RenderTarget::Offscreen(Box::new(OffscreenBuffers::new( + width, height, format, false, + ))) + }; + self.film_render_targets = Some((make(), make())); + } + } + } + /// Renders one frame of a 3D scene. /// /// This is the main rendering function that should be called in your render loop. @@ -354,10 +375,7 @@ impl Window { self.post_process_render_target_b .resize(w, h, self.canvas.surface_format()); if !film_processing.is_empty() { - self.film_render_target - .resize(w, h, crate::post_processing::HDR_FORMAT); - self.film_render_target_b - .resize(w, h, crate::post_processing::HDR_FORMAT); + self.ensure_film_targets(w, h); } if offscreen { if self.offscreen_output_target.is_none() { @@ -1345,47 +1363,37 @@ impl Window { // The film-stage chain, before bloom and the tonemap: a pass listed there // works in linear light, and what it writes is what blooms. The film is // copied into A because an effect reads a `RenderTarget` and the film is - // not one; from there the pair ping-pongs as the LDR chain does. - let film_view = if film_processing.is_empty() { - None - } else { - let film = self.hdr.scene_resolved_view().clone(); - Self::seed_film( - &mut encoder, - self.hdr.scene_texture(), - &self.film_render_target, - w, - h, - ); - let mut input_is_a = true; - for pp in film_processing.iter_mut() { - let (input, output) = if input_is_a { - (&self.film_render_target, &self.film_render_target_b) - } else { - (&self.film_render_target_b, &self.film_render_target) - }; - let output_view = match output { - RenderTarget::Offscreen(o) => &o.color_view, - RenderTarget::Screen => &frame_view, - }; - pp.update(0.016, w as f32, h as f32, znear, zfar); - let mut pp_context = PostProcessingContext { - encoder: &mut encoder, - output_view, - }; - pp.draw(input, &mut pp_context); - input_is_a = !input_is_a; - } - // Whichever half the last effect wrote is what the tonemap reads. - let landed = if input_is_a { - &self.film_render_target - } else { - &self.film_render_target_b - }; - match landed { - RenderTarget::Offscreen(o) => Some(o.color_view.clone()), - RenderTarget::Screen => Some(film), + // not one; from there the pair ping-pongs as the LDR chain does. Every + // effect here writes `HDR_FORMAT`, which is what its `output_format` + // says. + let film_format = crate::post_processing::HDR_FORMAT; + let film_view = match self.film_render_targets.as_ref() { + Some((a, b)) if !film_processing.is_empty() => { + Self::seed_film(&mut encoder, self.hdr.scene_texture(), a, w, h); + let mut input_is_a = true; + for pp in film_processing.iter_mut() { + let (input, output) = if input_is_a { (a, b) } else { (b, a) }; + let output_view = match output { + RenderTarget::Offscreen(o) => &o.color_view, + RenderTarget::Screen => &frame_view, + }; + pp.update(0.016, w as f32, h as f32, znear, zfar); + let mut pp_context = PostProcessingContext { + encoder: &mut encoder, + output_view, + output_format: film_format, + }; + pp.draw(input, &mut pp_context); + input_is_a = !input_is_a; + } + // Whichever half the last effect wrote is what the tonemap reads. + let landed = if input_is_a { a } else { b }; + match landed { + RenderTarget::Offscreen(o) => Some(o.color_view.clone()), + RenderTarget::Screen => None, + } } + _ => None, }; let resolve_input = film_view; let resolve = |hdr: &mut crate::post_processing::HdrPipeline, @@ -1447,6 +1455,9 @@ impl Window { let mut pp_context = PostProcessingContext { encoder: &mut encoder, output_view, + // Both the ping-pong pair and the frame carry the surface + // format, so every effect of this chain writes that. + output_format: self.canvas.surface_format(), }; pp.draw(input, &mut pp_context); } diff --git a/src/window/window.rs b/src/window/window.rs index 7c1d28802..d318e5abf 100644 --- a/src/window/window.rs +++ b/src/window/window.rs @@ -120,9 +120,9 @@ pub struct Window { pub(super) post_process_render_target_b: RenderTarget, /// The same pair at [`HDR_FORMAT`](crate::post_processing::HDR_FORMAT), for a /// chain that runs on the film before bloom and the tonemap rather than on the - /// LDR image after them. Only resized when such a chain is passed. - pub(super) film_render_target: RenderTarget, - pub(super) film_render_target_b: RenderTarget, + /// LDR image after them. Made the first frame such a chain is passed, so a run + /// that never passes one allocates neither. + pub(super) film_render_targets: Option<(RenderTarget, RenderTarget)>, /// Offscreen render target used when the window is hidden, so `snap` and /// recording work without a presentable surface. Created on first use. pub(super) offscreen_output_target: Option, @@ -1115,8 +1115,7 @@ impl Window { post_process_render_target: framebuffer_manager.new_render_target(width, height, true), post_process_render_target_b: framebuffer_manager .new_render_target(width, height, false), - film_render_target: framebuffer_manager.new_render_target(width, height, false), - film_render_target_b: framebuffer_manager.new_render_target(width, height, false), + film_render_targets: None, offscreen_output_target: None, aov_renderer: None, hidden: hide, @@ -1206,8 +1205,7 @@ impl Window { post_process_render_target: framebuffer_manager.new_render_target(width, height, true), post_process_render_target_b: framebuffer_manager .new_render_target(width, height, false), - film_render_target: framebuffer_manager.new_render_target(width, height, false), - film_render_target_b: framebuffer_manager.new_render_target(width, height, false), + film_render_targets: None, offscreen_output_target: None, aov_renderer: None, // A headless window has no surface; always render off-screen. From 423c961a6c96ebf271ad929c4a27baa9c0fce265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 10 Sep 2026 18:22:10 +0200 Subject: [PATCH 23/50] refactor(scene2d): one traversal behind both 2D render paths --- src/scene/scene_node2d.rs | 111 ++++++++++---------------------------- 1 file changed, 28 insertions(+), 83 deletions(-) diff --git a/src/scene/scene_node2d.rs b/src/scene/scene_node2d.rs index 4f5dee1a2..77a0f6c44 100644 --- a/src/scene/scene_node2d.rs +++ b/src/scene/scene_node2d.rs @@ -129,45 +129,38 @@ impl SceneNodeData2d { render_pass: &mut wgpu::RenderPass<'_>, context: &RenderContext2d, ) { - if self.visible { - self.do_render(Pose2::IDENTITY, Vec2::ONE, camera, render_pass, context) + if !self.visible { + return; } + self.traverse( + Pose2::IDENTITY, + Vec2::ONE, + &mut |object, transform, scale| { + object.render(transform, scale, camera, render_pass, context) + }, + ); } - fn do_render( - &mut self, - transform: Pose2, - scale: Vec2, - camera: &mut dyn Camera2d, - render_pass: &mut wgpu::RenderPass<'_>, - context: &RenderContext2d, - ) { + /// Walks this subtree in draw order, propagating world transforms and + /// handing every visible object it holds to `draw`. + fn traverse(&mut self, transform: Pose2, scale: Vec2, draw: &mut F) + where + F: FnMut(&mut Object2d, Pose2, Vec2), + { if !self.up_to_date { self.up_to_date = true; self.world_transform = transform * self.local_transform; self.world_scale = scale * self.local_scale; } - if let Some(ref mut o) = self.object { - o.render( - self.world_transform, - self.world_scale, - camera, - render_pass, - context, - ) + if let Some(object) = &mut self.object { + draw(object, self.world_transform, self.world_scale); } for c in self.children.iter_mut() { let mut bc = c.data_mut(); if bc.visible { - bc.do_render( - self.world_transform, - self.world_scale, - camera, - render_pass, - context, - ) + bc.traverse(self.world_transform, self.world_scale, draw); } } } @@ -199,70 +192,22 @@ impl SceneNodeData2d { return; } let mut pass = Some(begin_pass(encoder)); - self.do_render_with_screen( + self.traverse( Pose2::IDENTITY, Vec2::ONE, - camera, - encoder, - &mut pass, - context, - begin_pass, - copy_screen, + &mut |object, transform, scale| { + if object.material().borrow().reads_screen() { + drop(pass.take()); + copy_screen(encoder); + pass = Some(begin_pass(encoder)); + } + let render_pass = pass.as_mut().expect("a pass is open between splits"); + object.render(transform, scale, camera, render_pass, context) + }, ); drop(pass); } - #[allow(clippy::too_many_arguments)] - fn do_render_with_screen( - &mut self, - transform: Pose2, - scale: Vec2, - camera: &mut dyn Camera2d, - encoder: &mut wgpu::CommandEncoder, - pass: &mut Option>, - context: &RenderContext2d, - begin_pass: &mut dyn FnMut(&mut wgpu::CommandEncoder) -> wgpu::RenderPass<'static>, - copy_screen: &mut dyn FnMut(&mut wgpu::CommandEncoder), - ) { - if !self.up_to_date { - self.up_to_date = true; - self.world_transform = transform * self.local_transform; - self.world_scale = scale * self.local_scale; - } - - if let Some(ref mut o) = self.object { - if o.material().borrow().reads_screen() { - drop(pass.take()); - copy_screen(encoder); - *pass = Some(begin_pass(encoder)); - } - let render_pass = pass.as_mut().expect("a pass is open between splits"); - o.render( - self.world_transform, - self.world_scale, - camera, - render_pass, - context, - ) - } - - for c in self.children.iter_mut() { - let mut bc = c.data_mut(); - if bc.visible { - bc.do_render_with_screen( - self.world_transform, - self.world_scale, - camera, - encoder, - pass, - context, - begin_pass, - copy_screen, - ) - } - } - } - /// A reference to the object possibly contained by this node. #[inline] pub fn object(&self) -> Option<&Object2d> { From 13ef752b23f8c34ceb2f44a9c377fa867a1e1395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 10 Sep 2026 18:23:18 +0200 Subject: [PATCH 24/50] fix(input): keep a cmd-held key pressed on web and take IME cursors off char boundaries --- src/window/events.rs | 7 ++++++- src/window/wgpu_canvas.rs | 39 ++++++++++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/window/events.rs b/src/window/events.rs index 67d8b1ea2..05505f414 100644 --- a/src/window/events.rs +++ b/src/window/events.rs @@ -103,7 +103,12 @@ impl Window { ImeEvent::Preedit { text, cursor } => egui::ImeEvent::Preedit { text: text.clone(), active_range_chars: cursor.map(|(start, end)| { - let chars = |byte: usize| text[..byte.min(text.len())].chars().count(); + // Counting the chars that start before the byte index + // rather than slicing at it: an index that is not a + // char boundary rounds down instead of panicking. + let chars = |byte: usize| { + text.char_indices().take_while(|(at, _)| *at < byte).count() + }; chars(start)..chars(end) }), }, diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index 037351aae..f25f1c05b 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -917,16 +917,19 @@ impl WgpuCanvas { .borrow_mut() .push(WindowEvent::Key(key, Action::Press, modifiers)); // macOS sends no keyup for a key released while ⌘ is held, - // which would leave it pressed for good: release it now. + // which would leave that key pressed for good. Remember it + // and release it when ⌘ itself goes up, so it still reads + // as held for as long as it really is. if modifiers.contains(Modifiers::Super) && apple_platform() && !is_modifier_key(key) { - pending.borrow_mut().push(WindowEvent::Key( - key, - Action::Release, - modifiers, - )); + SUPER_HELD_KEYS.with(|held| { + let mut held = held.borrow_mut(); + if !held.contains(&key) { + held.push(key); + } + }); } // Emit a Char event for single-character (printable) keys so // egui text fields receive text input. Skip when a command @@ -949,11 +952,18 @@ impl WgpuCanvas { let pending = pending_events.clone(); let closure = Closure::::new(move |event: web_sys::KeyboardEvent| { let key = translate_web_key(&event.code()); - pending.borrow_mut().push(WindowEvent::Key( - key, - Action::Release, - event.modifiers(), - )); + let modifiers = event.modifiers(); + pending + .borrow_mut() + .push(WindowEvent::Key(key, Action::Release, modifiers)); + // ⌘ is up, so the keys it swallowed the keyup of are too. + if !modifiers.contains(Modifiers::Super) { + let swallowed = SUPER_HELD_KEYS.with(|held| held.take()); + let mut pending = pending.borrow_mut(); + for key in swallowed { + pending.push(WindowEvent::Key(key, Action::Release, modifiers)); + } + } }); let _ = web_window .add_event_listener_with_callback("keyup", closure.as_ref().unchecked_ref()); @@ -2405,6 +2415,13 @@ pub(crate) fn apple_platform() -> bool { APPLE.with(|apple| *apple) } +thread_local! { + /// Keys pressed while ⌘ was held on an Apple platform. macOS never sends + /// their keyup, so their release is synthesized when ⌘ goes up. + #[cfg(target_arch = "wasm32")] + static SUPER_HELD_KEYS: RefCell> = const { RefCell::new(Vec::new()) }; +} + #[cfg(target_arch = "wasm32")] fn is_modifier_key(key: Key) -> bool { matches!( From 284ea24f3954d787ade89c34e81e054ad8a277d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 10 Sep 2026 18:32:17 +0200 Subject: [PATCH 25/50] feat(mesh3d): render per-vertex colors through the default material and the glTF loader --- .run/vertex_colors (web).run.xml | 19 +++++++ .run/vertex_colors.run.xml | 19 +++++++ examples/vertex_colors.rs | 67 ++++++++++++++++++++++ run_all_examples.sh | 1 + src/builtin/default.wgsl | 13 ++++- src/builtin/object_material.rs | 95 ++++++++++++++++++++++---------- src/loader/gltf.rs | 7 +++ 7 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 .run/vertex_colors (web).run.xml create mode 100644 .run/vertex_colors.run.xml create mode 100644 examples/vertex_colors.rs diff --git a/.run/vertex_colors (web).run.xml b/.run/vertex_colors (web).run.xml new file mode 100644 index 000000000..bc80059f1 --- /dev/null +++ b/.run/vertex_colors (web).run.xml @@ -0,0 +1,19 @@ + + + + diff --git a/.run/vertex_colors.run.xml b/.run/vertex_colors.run.xml new file mode 100644 index 000000000..b69f47923 --- /dev/null +++ b/.run/vertex_colors.run.xml @@ -0,0 +1,19 @@ + + + + diff --git a/examples/vertex_colors.rs b/examples/vertex_colors.rs new file mode 100644 index 000000000..ee161772c --- /dev/null +++ b/examples/vertex_colors.rs @@ -0,0 +1,67 @@ +//! Per-vertex colours: a mesh that carries one colour per vertex, which the +//! default material multiplies into its base colour. +//! +//! This is the attribute the glTF loader fills in from `COLOR_0`, so a model +//! authored with painted vertices arrives already tinted. A mesh that carries +//! none is unaffected and costs no extra buffer. + +use kiss3d::prelude::*; + +/// A flat quad of half-extent `half`, wound counter-clockwise. +fn quad(half: f32) -> (Vec, Vec<[u32; 3]>) { + ( + vec![ + Vec3::new(-half, -half, 0.0), + Vec3::new(half, -half, 0.0), + Vec3::new(half, half, 0.0), + Vec3::new(-half, half, 0.0), + ], + vec![[0, 1, 2], [0, 2, 3]], + ) +} + +#[kiss3d::main] +async fn main() { + let mut window = Window::new("Kiss3d: vertex_colors").await; + let mut camera = OrbitCamera3d::default(); + let mut scene = SceneNode3d::empty(); + scene + .add_light(Light::point(120.0)) + .set_position(Vec3::new(-2.0, 4.0, -6.0)); + + // One colour per corner; the gradient across each triangle is the + // rasterizer interpolating between them. + let (vertices, indices) = quad(0.7); + let mut colored = GpuMesh3d::new(vertices, indices, None, None, false); + colored.set_colors(vec![ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + [1.0, 1.0, 0.0, 1.0], + ]); + let mut painted = scene + .add_mesh(Rc::new(RefCell::new(colored)), Vec3::ONE) + .enable_backface_culling(false); + painted.set_position(Vec3::new(-0.9, 0.0, 0.0)); + + // The same quad without colours, for comparison: `set_color` alone still + // decides the whole surface. + let (vertices, indices) = quad(0.5); + let mut plain = scene + .add_mesh( + Rc::new(RefCell::new(GpuMesh3d::new( + vertices, indices, None, None, false, + ))), + Vec3::ONE, + ) + .set_color(ORANGE) + .enable_backface_culling(false); + plain.set_position(Vec3::new(0.9, 0.0, 0.0)); + + let rot = Quat::from_axis_angle(Vec3::Y, 0.008); + + while window.render_3d(&mut scene, &mut camera).await { + painted.rotate(rot); + plain.rotate(rot); + } +} diff --git a/run_all_examples.sh b/run_all_examples.sh index 977a7c0f9..45daa5bbe 100755 --- a/run_all_examples.sh +++ b/run_all_examples.sh @@ -35,6 +35,7 @@ EXAMPLES=( mouse_events custom_mesh custom_mesh_shared + vertex_colors custom_material procedural quad diff --git a/src/builtin/default.wgsl b/src/builtin/default.wgsl index a8608a21b..71dcabcf8 100644 --- a/src/builtin/default.wgsl +++ b/src/builtin/default.wgsl @@ -627,6 +627,9 @@ struct VertexInput { @location(0) position: vec3, @location(1) tex_coord: vec2, @location(2) normal: vec3, + // Only on a mesh that carries them (glTF COLOR_0, `GpuMesh3d::set_colors`); + // the vertex layout leaves the slot out otherwise. + @if(vertex_colors) @location(8) color: vec4, } // === GPU vertex deformation: skinning + morph targets (deform variant only) === @@ -808,7 +811,10 @@ fn vs_main(vertex: VertexInput, instance: InstanceInput) -> VertexOutput { out.view_pos = view_pos.xyz / view_pos.w; out.tex_coord = vertex.tex_coord; - out.vert_color = instance.inst_color; + // Per-vertex colour tints the instance colour, which the fragment stage + // already multiplies into the base colour. + @if(vertex_colors) out.vert_color = instance.inst_color * vertex.color; + @if(!vertex_colors) out.vert_color = instance.inst_color; return out; } @@ -873,7 +879,10 @@ fn vs_main(vertex: VertexInput, instance: InstanceInput, @builtin(vertex_index) } out.tex_coord = vertex.tex_coord; - out.vert_color = instance.inst_color; + // Per-vertex colour tints the instance colour, which the fragment stage + // already multiplies into the base colour. + @if(vertex_colors) out.vert_color = instance.inst_color * vertex.color; + @if(!vertex_colors) out.vert_color = instance.inst_color; return out; } diff --git a/src/builtin/object_material.rs b/src/builtin/object_material.rs index 71cc6a85d..ca6bf2cf6 100644 --- a/src/builtin/object_material.rs +++ b/src/builtin/object_material.rs @@ -475,11 +475,11 @@ pub struct ObjectMaterial { /// backend has no spare bind group (web / WebGL2) — deform then falls back to the /// plain path. deform_pipeline_layout: Option, - /// Opaque-surface pipeline builder: `(layout, module, _skinned, cull, label, samples)`. + /// Opaque-surface pipeline builder: `(layout, module, vertex_colors, cull, label, samples)`. build_opaque: SurfacePipelineBuilder, /// Weighted-blended OIT pipeline builder (same signature as `build_opaque`). build_oit: SurfacePipelineBuilder, - /// Depth + view-position prepass pipeline builder: `(layout, module, _skinned, samples)`. + /// Depth + view-position prepass pipeline builder: `(layout, module, vertex_colors, samples)`. build_prepass: PrepassPipelineBuilder, /// WESL-compiled shader modules, keyed by feature mask (lazily compiled, cached). shader_modules: RefCell>>, @@ -607,8 +607,9 @@ pub struct ObjectMaterial { } /// Builds an opaque-surface or OIT pipeline from a compiled module: -/// `(pipeline_layout, shader_module, _skinned, cull_mode, label, sample_count)`. -/// Captures nothing; the deform variant differs only in the module + layout passed. +/// `(pipeline_layout, shader_module, vertex_colors, cull_mode, label, sample_count)`. +/// Captures nothing; the deform variant differs only in the module + layout passed, +/// while `vertex_colors` adds the per-vertex colour buffer to the vertex layout. type SurfacePipelineBuilder = Rc< dyn Fn( &wgpu::PipelineLayout, @@ -621,7 +622,7 @@ type SurfacePipelineBuilder = Rc< >; /// Builds the depth + view-position prepass pipeline: -/// `(pipeline_layout, shader_module, _skinned, sample_count)`. +/// `(pipeline_layout, shader_module, vertex_colors, sample_count)`. type PrepassPipelineBuilder = Rc wgpu::RenderPipeline>; @@ -665,10 +666,12 @@ impl ShaderFeatures { const ANISOTROPY: u32 = 1 << 13; const TRANSMISSION: u32 = 1 << 14; const REFLECTOR: u32 = 1 << 15; + // Per-mesh geometry. + const VERTEX_COLORS: u32 = 1 << 16; /// `(WESL feature name, bit)` — names MUST match the `@if(...)` flags in /// `default.wgsl`. - const TABLE: [(&'static str, u32); 16] = [ + const TABLE: [(&'static str, u32); 17] = [ ("deform", Self::DEFORM), ("clustered", Self::CLUSTERED), ("shadows", Self::SHADOWS), @@ -685,6 +688,7 @@ impl ShaderFeatures { ("anisotropy", Self::ANISOTROPY), ("transmission", Self::TRANSMISSION), ("reflector", Self::REFLECTOR), + ("vertex_colors", Self::VERTEX_COLORS), ]; #[inline] @@ -750,7 +754,9 @@ enum PipelineKind { /// /// We use separate buffers for instance data (positions, colors, deformations) /// instead of interleaving them, to avoid per-frame data conversion overhead. -fn surface_vertex_buffer_layouts() -> [Option>; 6] { +fn surface_vertex_buffer_layouts( + vertex_colors: bool, +) -> [Option>; 7] { // Buffer 0: Vertex positions const POSITIONS: [wgpu::VertexAttribute; 1] = [wgpu::VertexAttribute { offset: 0, @@ -781,6 +787,13 @@ fn surface_vertex_buffer_layouts() -> [Option> shader_location: 4, format: wgpu::VertexFormat::Float32x4, }]; + // Buffer 6: Per-vertex colors, only on a mesh that carries them; the slot is + // left empty otherwise rather than costing every mesh a white buffer. + const COLORS: [wgpu::VertexAttribute; 1] = [wgpu::VertexAttribute { + offset: 0, + shader_location: 8, + format: wgpu::VertexFormat::Float32x4, + }]; // Buffer 5: Instance deformations (3x Vector3 = 3 columns of a 3x3 matrix), // stored as 3 consecutive vec3s per instance. const INST_DEF: [wgpu::VertexAttribute; 3] = [ @@ -832,6 +845,11 @@ fn surface_vertex_buffer_layouts() -> [Option> step_mode: wgpu::VertexStepMode::Instance, attributes: &INST_DEF, }), + vertex_colors.then_some(wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::<[f32; 4]>() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &COLORS, + }), ] } @@ -1234,24 +1252,24 @@ impl ObjectMaterial { }); // Shared opaque-surface pipeline builder, parameterized by the pipeline - // layout and the (WESL-specialized) shader module. The `skinned` flag is - // vestigial — deform data is read from group-4 storage by index, so the - // vertex layout is identical; the deform variant differs only in the module + - // layout passed. Stored on the material and invoked lazily per + // layout and the (WESL-specialized) shader module. Deform data is read from + // group-4 storage by index, so the deform variant differs only in the module + // + layout passed; `vertex_colors` is the one flag that changes the vertex + // layout. Stored on the material and invoked lazily per // `(features, sample_count)` by `surface_pipeline`. let build_opaque = std::rc::Rc::new( |layout: &wgpu::PipelineLayout, shader: &wgpu::ShaderModule, - skinned: bool, + vertex_colors: bool, cull_mode: Option, label: &'static str, sample_count: u32| { let ctxt = Context::get(); // The deformed pipelines share the plain vertex layout: skin // joints/weights and morph deltas come from group-4 storage buffers, - // not vertex attributes. `skinned` only selects the shader + layout. - let _ = skinned; - let plain_layouts = surface_vertex_buffer_layouts(); + // not vertex attributes, so the deform variant only selects the + // shader + layout. Per-vertex colours do add a buffer. + let plain_layouts = surface_vertex_buffer_layouts(vertex_colors); let buffers: &[Option] = &plain_layouts; ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), @@ -1304,16 +1322,16 @@ impl ObjectMaterial { let build_oit = std::rc::Rc::new( |layout: &wgpu::PipelineLayout, shader: &wgpu::ShaderModule, - skinned: bool, + vertex_colors: bool, cull_mode: Option, label: &'static str, sample_count: u32| { let ctxt = Context::get(); // The deformed pipelines share the plain vertex layout: skin // joints/weights and morph deltas come from group-4 storage buffers, - // not vertex attributes. `skinned` only selects the shader + layout. - let _ = skinned; - let plain_layouts = surface_vertex_buffer_layouts(); + // not vertex attributes, so the deform variant only selects the + // shader + layout. Per-vertex colours do add a buffer. + let plain_layouts = surface_vertex_buffer_layouts(vertex_colors); let buffers: &[Option] = &plain_layouts; ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), @@ -1393,14 +1411,14 @@ impl ObjectMaterial { let build_prepass = std::rc::Rc::new( |layout: &wgpu::PipelineLayout, shader: &wgpu::ShaderModule, - skinned: bool, + vertex_colors: bool, sample_count: u32| { let ctxt = Context::get(); // The deformed pipelines share the plain vertex layout: skin // joints/weights and morph deltas come from group-4 storage buffers, - // not vertex attributes. `skinned` only selects the shader + layout. - let _ = skinned; - let plain_layouts = surface_vertex_buffer_layouts(); + // not vertex attributes, so the deform variant only selects the + // shader + layout. Per-vertex colours do add a buffer. + let plain_layouts = surface_vertex_buffer_layouts(vertex_colors); let buffers: &[Option] = &plain_layouts; ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("object_material_prepass_pipeline"), @@ -2173,11 +2191,14 @@ impl ObjectMaterial { } else { &self.pipeline_layout }; + // The prepass reaches here with its features already collapsed by + // `prepass_key`, so it never asks for the colour slot. + let vertex_colors = features.has(ShaderFeatures::VERTEX_COLORS); let pipeline = match kind { PipelineKind::OpaqueCull => (self.build_opaque)( layout, &module, - false, + vertex_colors, Some(wgpu::Face::Back), "object_material_pipeline_cull", sample_count, @@ -2185,7 +2206,7 @@ impl ObjectMaterial { PipelineKind::OpaqueNoCull => (self.build_opaque)( layout, &module, - false, + vertex_colors, None, "object_material_pipeline_no_cull", sample_count, @@ -2193,7 +2214,7 @@ impl ObjectMaterial { PipelineKind::OitCull => (self.build_oit)( layout, &module, - false, + vertex_colors, Some(wgpu::Face::Back), "object_material_oit_pipeline_cull", sample_count, @@ -2201,12 +2222,14 @@ impl ObjectMaterial { PipelineKind::OitNoCull => (self.build_oit)( layout, &module, - false, + vertex_colors, None, "object_material_oit_pipeline_no_cull", sample_count, ), - PipelineKind::Prepass => (self.build_prepass)(layout, &module, false, sample_count), + PipelineKind::Prepass => { + (self.build_prepass)(layout, &module, vertex_colors, sample_count) + } }; let pipeline = Rc::new(pipeline); self.surface_pipelines @@ -2254,6 +2277,7 @@ impl ObjectMaterial { data: &ObjectData3d, use_deform: bool, shadows_active: bool, + vertex_colors: bool, ) -> ShaderFeatures { let f = ShaderFeatures::default() // Structural / capability. @@ -2278,6 +2302,7 @@ impl ObjectMaterial { .with(ShaderFeatures::ANISOTROPY, data.anisotropy() != 0.0) .with(ShaderFeatures::TRANSMISSION, data.transmission() > 0.0) .with(ShaderFeatures::REFLECTOR, data.reflector().is_some()) + .with(ShaderFeatures::VERTEX_COLORS, vertex_colors) } /// Builds the combined material-texture bind group (group 2): albedo at @@ -3140,6 +3165,10 @@ impl Material3d for ObjectMaterial { mesh.normals().write().unwrap().load_to_gpu(); mesh.faces().write().unwrap().load_to_gpu(); + // Per-vertex colours, on the meshes that carry them; the shader variant + // and the vertex layout follow whether this is `Some`. + let colors_buf = mesh.colors_buffer(); + let coords_buffer = mesh.coords().read().unwrap(); let uvs_buffer = mesh.uvs().read().unwrap(); let normals_buffer = mesh.normals().read().unwrap(); @@ -3270,7 +3299,8 @@ impl Material3d for ObjectMaterial { (crate::resource::RenderPhase::Transmission, false) | (crate::resource::RenderPhase::Opaque, false) => PipelineKind::OpaqueNoCull, }; - let mut features = self.object_features(data, use_deform, shadows_active); + let mut features = + self.object_features(data, use_deform, shadows_active, colors_buf.is_some()); // The prepass ignores all shading features; collapse to the structural key // so it stays a single module per deform-ness. if kind == PipelineKind::Prepass { @@ -3306,6 +3336,13 @@ impl Material3d for ObjectMaterial { render_pass.set_vertex_buffer(4, inst_colors_buf.slice(..)); render_pass.set_vertex_buffer(5, inst_deformations_buf.slice(..)); + // Slot 6 exists only on the variant built for a mesh that carries + // per-vertex colours; the prepass collapses that feature away and + // ignores the slot. + if let Some(colors_buf) = &colors_buf { + render_pass.set_vertex_buffer(6, colors_buf.slice(..)); + } + render_pass.set_index_buffer(faces_buf.slice(..), VERTEX_INDEX_FORMAT); render_pass.draw_indexed(0..mesh.num_indices(), 0, 0..num_instances as u32); diff --git a/src/loader/gltf.rs b/src/loader/gltf.rs index 65bb011c4..97bc29e8c 100644 --- a/src/loader/gltf.rs +++ b/src/loader/gltf.rs @@ -205,6 +205,13 @@ fn build_primitive(prim: &gltf::Primitive, buffers: &[gltf::buffer::Data]) -> Op let mut mesh = GpuMesh3d::new(positions, faces, normals, uvs, false); + // COLOR_0: an optional per-vertex tint the material multiplies into its base + // colour. glTF stores it as RGB or RGBA, in u8, u16 or f32; `into_rgba_f32` + // normalizes all of that, opaque alpha included. + if let Some(colors) = reader.read_colors(0) { + mesh.set_colors(colors.into_rgba_f32().collect()); + } + // Skinning attributes: present together on skinned primitives. JOINTS_0 is // widened from u8/u16 to u32 so a single vertex format covers every mesh. if let (Some(joints), Some(weights)) = (reader.read_joints(0), reader.read_weights(0)) { From 3e0e94fb2031e5326b310a62beb4f64d0f1c0493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 10 Sep 2026 18:34:53 +0200 Subject: [PATCH 26/50] docs(examples): a 2D material that refracts the screen copy it reads --- .run/screen_read2d (web).run.xml | 19 ++ .run/screen_read2d.run.xml | 19 ++ examples/screen_read2d.rs | 377 +++++++++++++++++++++++++++++++ run_all_examples.sh | 1 + 4 files changed, 416 insertions(+) create mode 100644 .run/screen_read2d (web).run.xml create mode 100644 .run/screen_read2d.run.xml create mode 100644 examples/screen_read2d.rs diff --git a/.run/screen_read2d (web).run.xml b/.run/screen_read2d (web).run.xml new file mode 100644 index 000000000..f38e297c4 --- /dev/null +++ b/.run/screen_read2d (web).run.xml @@ -0,0 +1,19 @@ + + + + diff --git a/.run/screen_read2d.run.xml b/.run/screen_read2d.run.xml new file mode 100644 index 000000000..ec3f2c5c6 --- /dev/null +++ b/.run/screen_read2d.run.xml @@ -0,0 +1,19 @@ + + + + diff --git a/examples/screen_read2d.rs b/examples/screen_read2d.rs new file mode 100644 index 000000000..2b88820e4 --- /dev/null +++ b/examples/screen_read2d.rs @@ -0,0 +1,377 @@ +//! A 2D material that samples the frame so far. +//! +//! A material whose `reads_screen` is true splits the 2D pass: the frame drawn +//! up to that object is copied into [`RenderContext2d::screen`], and the object +//! draws with that copy bound. This one refracts what is behind it, the way a +//! rippled pane of glass would. +//! +//! The copy is remade whenever the film resizes, and `screen_generation` says +//! when: a bind group built over the old texture has to be rebuilt. + +use kiss3d::prelude::vertex_index::VERTEX_INDEX_FORMAT; +use kiss3d::prelude::*; +use kiss3d::resource::RenderContext2d; +use std::any::Any; + +#[kiss3d::main] +async fn main() { + let mut window = Window::new("Kiss3d: screen_read2d").await; + window.set_background_color(Color::new(0.02, 0.03, 0.08, 1.0)); + let mut camera = FixedView2d::default(); + let mut scene = SceneNode2d::empty(); + + // The scene behind the glass: a row of rectangles the ripple has something + // to bend. + let colors = [RED, ORANGE, YELLOW, GREEN, CYAN, BLUE, MAGENTA]; + for (i, color) in colors.iter().enumerate() { + let x = (i as f32 - 3.0) * 90.0; + scene + .add_rectangle(70.0, 320.0) + .set_color(*color) + .set_position(Vec2::new(x, 0.0)); + } + + // The pane itself. Its material reads the screen, so the pass is split + // before it and everything above was already drawn into the copy. + let glass = Rc::new(RefCell::new( + Box::new(RippleMaterial2d::new()) as Box + )); + let mut pane = scene.add_rectangle(420.0, 220.0).set_material(glass); + pane.set_position(Vec2::new(0.0, 0.0)); + + while window.render_2d(&mut scene, &mut camera).await {} +} + +/// Everything the shader needs, in one uniform block. `mat3x3` is stored as +/// three `vec4` columns, the alignment WGSL asks for. +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +struct RippleUniforms { + view: [[f32; 4]; 3], + proj: [[f32; 4]; 3], + model: [[f32; 4]; 3], + scale: [[f32; 4]; 2], + /// `(viewport width, viewport height, time, strength)`. + params: [f32; 4], +} + +/// This material keeps no per-object state, but the trait still hands each +/// object a box of its own. +struct NoGpuData; + +impl GpuData for NoGpuData { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +/// Refracts the frame behind it through a moving ripple. +struct RippleMaterial2d { + pipeline: PipelineCache, + bind_group_layout: wgpu::BindGroupLayout, + uniform_buffer: wgpu::Buffer, + sampler: wgpu::Sampler, + /// Built over `RenderContext2d::screen`, and rebuilt when the generation + /// beside it says that texture is a new one. + bind_group: Option, + bound_generation: u64, + start: web_time::Instant, +} + +impl RippleMaterial2d { + fn new() -> Self { + let ctxt = Context::get(); + + let bind_group_layout = ctxt.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("ripple2d_bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + 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: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let pipeline_layout = ctxt.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("ripple2d_pipeline_layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + + let shader = ctxt.create_shader_module(Some("ripple2d_shader"), SHADER); + + let vertex_buffer_layouts = [ + // Mesh positions. + Some(wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }], + }), + ]; + + let pipeline = PipelineCache::new(move |sample_count| { + let ctxt = Context::get(); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("ripple2d_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &vertex_buffer_layouts, + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + // The 2D scene draws into the linear HDR film, like the + // 3D one; the tonemap resolves it to the surface. + format: Context::render_format(), + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: multisample_state(sample_count), + multiview_mask: None, + cache: None, + }) + }); + + let uniform_buffer = ctxt.create_buffer_simple( + Some("ripple2d_uniform_buffer"), + std::mem::size_of::() as u64, + wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + ); + + let sampler = ctxt.create_sampler(&wgpu::SamplerDescriptor { + label: Some("ripple2d_sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + + RippleMaterial2d { + pipeline, + bind_group_layout, + uniform_buffer, + sampler, + bind_group: None, + bound_generation: 0, + start: web_time::Instant::now(), + } + } + + /// The bind group over this frame's screen copy, rebuilt when the copy is a + /// new texture (a resize) and reused otherwise. + fn screen_bind_group(&mut self, context: &RenderContext2d) -> Option<&wgpu::BindGroup> { + let screen = context.screen.as_ref()?; + if self.bind_group.is_none() || self.bound_generation != context.screen_generation { + let ctxt = Context::get(); + self.bind_group = Some(ctxt.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("ripple2d_bind_group"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(screen), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + })); + self.bound_generation = context.screen_generation; + } + self.bind_group.as_ref() + } +} + +/// `Mat3` as three `vec4` columns. +fn padded_mat3(m: &Mat3) -> [[f32; 4]; 3] { + let c = m.to_cols_array_2d(); + [ + [c[0][0], c[0][1], c[0][2], 0.0], + [c[1][0], c[1][1], c[1][2], 0.0], + [c[2][0], c[2][1], c[2][2], 0.0], + ] +} + +impl Material2d for RippleMaterial2d { + fn create_gpu_data(&self) -> Box { + Box::new(NoGpuData) + } + + /// What splits the 2D pass around this object and fills + /// `RenderContext2d::screen` before it draws. + fn reads_screen(&self) -> bool { + true + } + + #[allow(clippy::too_many_arguments)] + fn render( + &mut self, + transform: Pose2, + scale: Vec2, + camera: &mut dyn Camera2d, + _data: &ObjectData2d, + mesh: &mut GpuMesh2d, + _instances: &mut InstancesBuffer2d, + _gpu_data: &mut dyn GpuData, + render_pass: &mut wgpu::RenderPass<'_>, + context: &RenderContext2d, + ) { + let ctxt = Context::get(); + mesh.load_to_gpu(); + + let coords = mesh.coords().read().unwrap(); + let faces = mesh.faces().read().unwrap(); + let (Some(coords_buf), Some(faces_buf)) = (coords.buffer(), faces.buffer()) else { + return; + }; + + let (view, proj) = camera.view_transform_pair(); + let model = transform.to_mat3(); + let scale = Mat2::from_diagonal(scale); + let uniforms = RippleUniforms { + view: padded_mat3(&view), + proj: padded_mat3(&proj), + model: padded_mat3(&model), + scale: [ + [scale.x_axis.x, scale.x_axis.y, 0.0, 0.0], + [scale.y_axis.x, scale.y_axis.y, 0.0, 0.0], + ], + params: [ + context.viewport_width as f32, + context.viewport_height as f32, + self.start.elapsed().as_secs_f32(), + 0.012, + ], + }; + ctxt.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); + + let pipeline = self.pipeline.get(context.sample_count); + let Some(bind_group) = self.screen_bind_group(context) else { + return; + }; + + render_pass.set_pipeline(&pipeline); + render_pass.set_bind_group(0, bind_group, &[]); + render_pass.set_vertex_buffer(0, coords_buf.slice(..)); + render_pass.set_index_buffer(faces_buf.slice(..), VERTEX_INDEX_FORMAT); + render_pass.draw_indexed(0..mesh.num_indices(), 0, 0..1); + } +} + +const SHADER: &str = r#" +struct Uniforms { + view_0: vec4, + view_1: vec4, + view_2: vec4, + proj_0: vec4, + proj_1: vec4, + proj_2: vec4, + model_0: vec4, + model_1: vec4, + model_2: vec4, + scale_0: vec4, + scale_1: vec4, + params: vec4, +} + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var screen_tex: texture_2d; +@group(0) @binding(2) var screen_sampler: sampler; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) local: vec2, +} + +@vertex +fn vs_main(@location(0) position: vec2) -> VertexOutput { + let view = mat3x3(u.view_0.xyz, u.view_1.xyz, u.view_2.xyz); + let proj = mat3x3(u.proj_0.xyz, u.proj_1.xyz, u.proj_2.xyz); + let model = mat3x3(u.model_0.xyz, u.model_1.xyz, u.model_2.xyz); + let scale = mat2x2(u.scale_0.xy, u.scale_1.xy); + + let model_pos = model * vec3(scale * position, 1.0); + var projected = proj * view * model_pos; + projected.z = 0.0; + + var out: VertexOutput; + out.clip_position = vec4(projected, 1.0); + out.local = position; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let viewport = u.params.xy; + let time = u.params.z; + let strength = u.params.w; + + // Where this fragment sits in the copy of the frame so far. + let uv = in.clip_position.xy / viewport; + + // A pair of travelling waves, one per axis, bends the lookup. + let offset = vec2( + sin(uv.y * 38.0 + time * 2.3), + cos(uv.x * 26.0 + time * 1.7), + ) * strength; + let refracted = textureSample(screen_tex, screen_sampler, clamp(uv + offset, vec2(0.0), vec2(1.0))); + + // A cool tint and a bright edge, so the pane reads as glass rather than as + // a shimmering hole. + let edge = smoothstep(0.46, 0.5, max(abs(in.local.x), abs(in.local.y))); + let tinted = refracted.rgb * vec3(0.82, 0.9, 1.05) + vec3(0.02); + return vec4(mix(tinted, vec3(0.7, 0.85, 1.0), edge * 0.5), 1.0); +} +"#; diff --git a/run_all_examples.sh b/run_all_examples.sh index 45daa5bbe..1201a5cae 100755 --- a/run_all_examples.sh +++ b/run_all_examples.sh @@ -15,6 +15,7 @@ EXAMPLES=( blend_modes2d sprites2d post_processing2d + screen_read2d lighting2d global_illumination2d effect_chain2d From 9aac618fbf4f34948beecf355b345eace036b8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 10 Sep 2026 18:37:10 +0200 Subject: [PATCH 27/50] docs(examples): a film-stage post-processing effect, and OffscreenSurface::render_chains --- .run/film_chain (web).run.xml | 19 +++ .run/film_chain.run.xml | 19 +++ examples/film_chain.rs | 308 ++++++++++++++++++++++++++++++++++ run_all_examples.sh | 1 + src/window/offscreen.rs | 20 +++ 5 files changed, 367 insertions(+) create mode 100644 .run/film_chain (web).run.xml create mode 100644 .run/film_chain.run.xml create mode 100644 examples/film_chain.rs diff --git a/.run/film_chain (web).run.xml b/.run/film_chain (web).run.xml new file mode 100644 index 000000000..ae6b5b45a --- /dev/null +++ b/.run/film_chain (web).run.xml @@ -0,0 +1,19 @@ + + + + diff --git a/.run/film_chain.run.xml b/.run/film_chain.run.xml new file mode 100644 index 000000000..32754c936 --- /dev/null +++ b/.run/film_chain.run.xml @@ -0,0 +1,19 @@ + + + + diff --git a/examples/film_chain.rs b/examples/film_chain.rs new file mode 100644 index 000000000..56708562a --- /dev/null +++ b/examples/film_chain.rs @@ -0,0 +1,308 @@ +//! The film-stage post-processing chain: effects that run on the HDR film, +//! before bloom and the tonemap, instead of on the finished LDR image. +//! +//! Both chains are handed to [`Window::render_chains`]. The same effect is +//! moved between them with the space bar, and the difference is what linear +//! light buys: a haze added to the film is real light, so it blows the +//! highlights out and blooms. The same haze added after the tonemap can only +//! wash the picture towards white. + +use kiss3d::post_processing::{FormatPipelines, PostProcessingContext, PostProcessingEffect}; +use kiss3d::prelude::*; +use kiss3d::resource::RenderTarget; + +#[kiss3d::main] +async fn main() { + let mut window = Window::new("Kiss3d: film_chain").await; + window.set_background_color(Color::new(0.01, 0.012, 0.02, 1.0)); + window.set_bloom_enabled(true); + window.set_bloom(1.0, 0.6); + + let mut camera = OrbitCamera3d::new(Vec3::new(0.0, 1.5, 6.0), Vec3::ZERO); + let mut scene = SceneNode3d::empty(); + scene + .add_light(Light::point(60.0)) + .set_position(Vec3::new(3.0, 5.0, 4.0)); + + let mut sphere = scene.add_sphere(1.0); + sphere.set_color(Color::new(0.15, 0.16, 0.2, 1.0)); + let mut lamp = scene.add_sphere(0.35); + lamp.set_color(WHITE); + lamp.set_emissive(Color::new(6.0, 4.4, 2.2, 1.0)); + lamp.set_position(Vec3::new(-2.0, 1.2, 1.0)); + + let mut haze = Haze::new(); + let mut on_film = true; + println!("Space: move the haze between the film chain and the post chain."); + + loop { + for event in window.events().iter() { + if let WindowEvent::Key(Key::Space, Action::Press, _) = event.value { + on_film = !on_film; + println!( + "haze on the {} chain", + if on_film { "film" } else { "post" } + ); + } + } + + let running = if on_film { + window + .render_chains( + Some(&mut scene), + None, + Some(&mut camera), + None, + None, + &mut [&mut haze], + &mut [], + ) + .await + } else { + window + .render_chain( + Some(&mut scene), + None, + Some(&mut camera), + None, + None, + &mut [&mut haze], + ) + .await + }; + if !running { + break; + } + } +} + +/// Adds a soft radial glow to whatever it is given. +/// +/// On the film chain the glow is added in linear light, so it lifts values past +/// 1.0 and both blooms and tonemaps. On the post chain it is added to an image +/// that is already tonemapped, where the same numbers can only approach white. +struct Haze { + pipeline: FormatPipelines, + bind_group_layout: wgpu::BindGroupLayout, + uniform_buffer: wgpu::Buffer, + vertex_buffer: wgpu::Buffer, + uniforms: HazeUniforms, +} + +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +struct HazeUniforms { + /// `(viewport width, viewport height, intensity, unused)`. + params: [f32; 4], +} + +impl Haze { + fn new() -> Self { + let ctxt = Context::get(); + + let bind_group_layout = ctxt.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("haze_bind_group_layout"), + 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, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + let pipeline_layout = ctxt.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("haze_pipeline_layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + let shader = ctxt.create_shader_module(Some("haze_shader"), SHADER); + + // One pipeline per format it is asked to write: the film chain's HDR + // targets, the post chain's surface-format ones. + let pipeline = FormatPipelines::new(move |format| { + let ctxt = Context::get(); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("haze_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }], + })], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) + }); + + let vertices: [[f32; 2]; 4] = [[-1.0, -1.0], [1.0, -1.0], [-1.0, 1.0], [1.0, 1.0]]; + let vertex_buffer = ctxt.create_buffer_init( + Some("haze_vertex_buffer"), + bytemuck::cast_slice(&vertices), + wgpu::BufferUsages::VERTEX, + ); + let uniform_buffer = ctxt.create_buffer_simple( + Some("haze_uniform_buffer"), + std::mem::size_of::() as u64, + wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + ); + + Haze { + pipeline, + bind_group_layout, + uniform_buffer, + vertex_buffer, + uniforms: HazeUniforms { + params: [1.0, 1.0, 1.6, 0.0], + }, + } + } +} + +impl PostProcessingEffect for Haze { + fn update(&mut self, _dt: f32, w: f32, h: f32, _znear: f32, _zfar: f32) { + self.uniforms.params[0] = w; + self.uniforms.params[1] = h; + } + + fn draw(&mut self, target: &RenderTarget, context: &mut PostProcessingContext) { + let ctxt = Context::get(); + let RenderTarget::Offscreen(source) = target else { + return; + }; + + ctxt.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&self.uniforms)); + let pipeline = self.pipeline.get(context.output_format); + let bind_group = ctxt.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("haze_bind_group"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&source.color_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&source.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.uniform_buffer.as_entire_binding(), + }, + ], + }); + + let mut pass = context + .encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("haze_render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: context.output_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + pass.draw(0..4, 0..1); + } +} + +const SHADER: &str = r#" +struct Uniforms { + params: vec4, +} + +@group(0) @binding(0) var src_tex: texture_2d; +@group(0) @binding(1) var src_sampler: sampler; +@group(0) @binding(2) var u: Uniforms; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(@location(0) position: vec2) -> VertexOutput { + var out: VertexOutput; + out.clip_position = vec4(position, 0.0, 1.0); + out.uv = vec2(position.x * 0.5 + 0.5, 0.5 - position.y * 0.5); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let src = textureSample(src_tex, src_sampler, in.uv); + + // A warm blob left of centre, falling off smoothly. + let aspect = u.params.x / max(u.params.y, 1.0); + let d = distance(vec2(in.uv.x * aspect, in.uv.y), vec2(0.34 * aspect, 0.46)); + let glow = pow(clamp(1.0 - d * 2.6, 0.0, 1.0), 3.0) * u.params.z; + + return vec4(src.rgb + glow * vec3(1.0, 0.72, 0.38), src.a); +} +"#; diff --git a/run_all_examples.sh b/run_all_examples.sh index 1201a5cae..b7488ccb5 100755 --- a/run_all_examples.sh +++ b/run_all_examples.sh @@ -16,6 +16,7 @@ EXAMPLES=( sprites2d post_processing2d screen_read2d + film_chain lighting2d global_illumination2d effect_chain2d diff --git a/src/window/offscreen.rs b/src/window/offscreen.rs index fe34a95d1..d2f2e55a0 100644 --- a/src/window/offscreen.rs +++ b/src/window/offscreen.rs @@ -118,6 +118,26 @@ impl OffscreenSurface { .await; } + /// Renders one frame through both post-processing chains: `film` on the HDR + /// film before bloom and the tonemap, `post` on the LDR image after them. + /// See [`Window::render_chains`]. + #[allow(clippy::too_many_arguments)] + pub async fn render_chains( + &mut self, + scene: Option<&mut SceneNode3d>, + scene_2d: Option<&mut SceneNode2d>, + camera: Option<&mut dyn Camera3d>, + camera_2d: Option<&mut dyn Camera2d>, + renderer: Option<&mut dyn Renderer3d>, + film: &mut [&mut dyn PostProcessingEffect], + post: &mut [&mut dyn PostProcessingEffect], + ) { + let _ = self + .window + .render_chains(scene, scene_2d, camera, camera_2d, renderer, film, post) + .await; + } + /// Renders one path-traced frame into the off-screen texture. /// /// Call repeatedly with the same [`RayTracer`] to accumulate samples (the From 62f77dfffd819473cbc514309ff11e1c651683de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 10 Sep 2026 18:38:22 +0200 Subject: [PATCH 28/50] docs(changelog): record everything on this branch --- CHANGELOG.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a425fa8b..6bab9f370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,69 @@ +# Unreleased + +## Breaking Changes + +- `Window::draw_ui` (and `OffscreenSurface::draw_ui`) takes an `FnMut` rather than an `FnOnce`, and + may run it more than once per frame: egui discards and re-runs a pass that only measured + something, so a closure that mutates state has to tolerate being replayed. +- `PostProcessingContext` gained an `output_format` field, and the built-in effects now build their + pipelines per format. A custom effect that constructs the context itself, or that built one + pipeline against the surface format, has to follow; `FormatPipelines` does the caching. +- `RenderContext2d` gained `screen` and `screen_generation` fields, and `OffscreenBuffers` a + `format` field. +- The `serde` feature now also enables `wgpu/serde`, which `TextureSampling` needs to derive over + wgpu's own types. + +## New Features + +- `Window::render_chains` (and `OffscreenSurface::render_chains`) runs a second post-processing + chain on the HDR film, before bloom and the tonemap, so an effect works in linear light and what + it writes is what blooms. `PostProcessingContext::output_format` says which format an effect + draws into, and `FormatPipelines` keeps one pipeline per format, so the same effect can be handed + to either chain. Added the `film_chain` example. +- `Material2d::reads_screen`: a 2D material can sample the frame drawn so far. The 2D pass is split + around each object that asks, with the film copied into `RenderContext2d::screen` first; + `screen_generation` changes when that copy is a new texture, so a bind group over it is rebuilt. + Added the `screen_read2d` example. +- Per-vertex colours: `GpuMesh3d::set_colors` / `colors` / `has_colors`, which the default material + multiplies into its base colour and the glTF loader fills in from `COLOR_0`. A mesh that carries + none is unaffected and allocates no buffer. Added the `vertex_colors` example. +- `TextureSampling`, with `TextureManager::add_image_sampled` and `Texture::sampled`, states a + texture's whole sampler at once: wrapping per axis, the magnification, minification and mip + filters, anisotropy, the colour space and premultiplied alpha. `TextureSampling::sane` drops what + the device would refuse rather than aborting the process, and `Texture::premultiplied` says which + blend mode the result wants. +- Input-method text: `ImeEvent`, `Window::ime_events` and `Window::set_ime_allowed`. egui's text + fields consume the same events on their own. +- `Window::safe_area` reports the display's insets in pixels, non-zero on iOS. +- `kiss3d::window::take_launch_url` (iOS) returns the URL the app was opened from, once. +- `Window::set_ui_retained` / `ui_retained` / `clear_ui` redraw the last egui pass on the frames + that build none, so a host can run its widgets only when something changed. Off by default: an + app that draws its UI conditionally expects a frame that draws none to show none. +- egui's pointer shape is applied to the window, and a pass egui asks to discard (a new `Area`, a + `Grid`, a `Resize`) is re-run before the frame is shown rather than showing the gap. +- `Canvas::set_cursor_icon` and `HdrPipeline::scene_texture`. + +## Bug Fixes + +- On the web, the surface is presented opaque wherever the platform offers it: a WebGPU canvas + lists premultiplied alpha first, and the page showed through every pixel left at alpha 0. +- A canvas the page supplies keeps the layout and the scrolling the page gave it; only a canvas + kiss3d had to create still sizes the document around itself. +- egui reads an event's own modifiers rather than the polled key states, so a chord tapped and + released inside one frame is no longer seen unmodified. ⌘ is the command modifier on an Apple + platform reached through a browser too, not only on native macOS. +- On the web on macOS, a key pressed while ⌘ is held is released when ⌘ is, rather than staying + pressed for good: macOS sends no keyup of its own for it. +- A render target reused at another colour format is remade rather than only resized. +- Auto-exposure meters what the film-stage chain wrote, not the film it started from. + +## Performance + +- The screenshot read-back texture is built on the first capture instead of with every window, and + a resize drops it rather than rebuilding one nothing has asked for. +- The film-stage render targets are allocated on the first frame a film chain is passed, so a run + that never passes one costs nothing. + # v0.46.0 ## Breaking Changes From 253d5565bc3ececb5176b521f9aaad9c73d67bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Sun, 13 Sep 2026 09:15:22 +0200 Subject: [PATCH 29/50] fix(mobile,window): one UIKit view lookup, a pinned Android keyboard heuristic, is_maximized --- src/window/canvas.rs | 5 ++++ src/window/ios.rs | 54 ++++++++++++++------------------------- src/window/wgpu_canvas.rs | 34 ++++++++++++++++++++---- src/window/window.rs | 10 ++++++-- 4 files changed, 61 insertions(+), 42 deletions(-) diff --git a/src/window/canvas.rs b/src/window/canvas.rs index ecd5704be..bcc98e558 100644 --- a/src/window/canvas.rs +++ b/src/window/canvas.rs @@ -199,6 +199,11 @@ impl Canvas { self.canvas.set_maximized(maximized); } + /// Whether the window is currently maximized. + pub fn is_maximized(&self) -> bool { + self.canvas.is_maximized() + } + /// Show or hide the platform's on-screen keyboard (mobile; no-op elsewhere). pub fn set_keyboard_visible(&self, visible: bool) { self.canvas.set_keyboard_visible(visible); diff --git a/src/window/ios.rs b/src/window/ios.rs index 56602821b..7f587bf40 100644 --- a/src/window/ios.rs +++ b/src/window/ios.rs @@ -321,23 +321,30 @@ fn observe_keyboard(mtm: MainThreadMarker) { /// The frame is converted into the view's own coordinates, as Apple asks: a /// screen-space frame is wrong under rotation and in a window smaller than /// the screen. -pub(crate) fn keyboard_height(window: &Window) -> f64 { +/// The `UIView` winit put this window's content in, with the marker proving +/// the thread UIKit may be touched from. +/// +/// `None` off the main thread, which winit callbacks (and so the app future) +/// never leave, or on a window that is not a UIKit one. +fn ui_view(window: &Window) -> Option<(MainThreadMarker, &UIView)> { use wgpu::rwh::{HasWindowHandle, RawWindowHandle}; - if MainThreadMarker::new().is_none() { - return 0.0; - } - let Some(frame) = KEYBOARD_FRAME.with(Cell::get) else { - return 0.0; + let mtm = MainThreadMarker::new()?; + let handle = window.window_handle().ok()?; + let RawWindowHandle::UiKit(ui_kit) = handle.as_raw() else { + return None; }; - let Ok(handle) = window.window_handle() else { + // Valid while `window` is alive, which the borrow guarantees. + Some((mtm, unsafe { ui_kit.ui_view.cast().as_ref() })) +} + +pub(crate) fn keyboard_height(window: &Window) -> f64 { + let Some(frame) = KEYBOARD_FRAME.with(Cell::get) else { return 0.0; }; - let RawWindowHandle::UiKit(ui_kit) = handle.as_raw() else { + let Some((_, view)) = ui_view(window) else { return 0.0; }; - // Valid while `window` is alive, which the borrow guarantees. - let view: &UIView = unsafe { ui_kit.ui_view.cast().as_ref() }; let local = view.convertRect_fromView(frame, None); let bottom = view.bounds().size.height; (bottom - local.origin.y).clamp(0.0, local.size.height.max(0.0)) @@ -375,41 +382,18 @@ pub fn take_launch_url() -> Option { /// `[left, top, right, bottom]` in points; zeros before the view is laid out. pub(crate) fn safe_area(window: &Window) -> [f64; 4] { - use wgpu::rwh::{HasWindowHandle, RawWindowHandle}; - - if MainThreadMarker::new().is_none() { - return [0.0; 4]; - } - let Ok(handle) = window.window_handle() else { - return [0.0; 4]; - }; - let RawWindowHandle::UiKit(ui_kit) = handle.as_raw() else { + let Some((_, view)) = ui_view(window) else { return [0.0; 4]; }; - // Valid while `window` is alive, which the borrow guarantees. - let view: &UIView = unsafe { ui_kit.ui_view.cast().as_ref() }; let insets = view.safeAreaInsets(); [insets.left, insets.top, insets.right, insets.bottom] } /// Show or hide the system keyboard for `window`. pub(crate) fn set_keyboard_visible(window: &Window, visible: bool) { - use wgpu::rwh::{HasWindowHandle, RawWindowHandle}; - - // Everything here is UIKit: reachable only from the main thread, which - // is the only thread winit callbacks (and therefore the app future) run - // on. The guard is belt and braces, not a code path. - let Some(mtm) = MainThreadMarker::new() else { - return; - }; - let Ok(handle) = window.window_handle() else { + let Some((mtm, parent)) = ui_view(window) else { return; }; - let RawWindowHandle::UiKit(ui_kit) = handle.as_raw() else { - return; - }; - // Valid while `window` is alive, which the borrow guarantees. - let parent: &UIView = unsafe { ui_kit.ui_view.cast().as_ref() }; KEY_VIEW.with(|cell| { let mut cell = cell.borrow_mut(); diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index f25f1c05b..1f0b0c801 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -1,6 +1,6 @@ //! Unified wgpu-based canvas for both native and web platforms. -#[cfg(target_os = "android")] +#[cfg(any(target_os = "android", test))] use std::cell::Cell; use std::cell::RefCell; use std::sync::mpsc::Sender; @@ -124,7 +124,7 @@ enum LifecycleEvent { Suspended, } -#[cfg(target_os = "android")] +#[cfg(any(target_os = "android", test))] thread_local! { // The least of the window NativeActivity's content rect has ever left // uncovered at this window height: the navigation bar, where the system @@ -138,7 +138,7 @@ thread_local! { /// NativeActivity reports one rect for everything the system covers, keyboard /// and navigation bar alike. The bar's share is the smallest cover seen at /// this height, so what is left above it is the keyboard. -#[cfg(target_os = "android")] +#[cfg(any(target_os = "android", test))] fn android_keyboard(height: i32, covered: i32) -> f32 { ANDROID_RESTING.with(|resting| { let (at, least) = resting.get(); @@ -1908,8 +1908,9 @@ impl WgpuCanvas { } /// Enter exclusive fullscreen on the current monitor, in its largest - /// video mode at the highest refresh rate, or leave it. Falls back to - /// borderless where the platform offers no video modes, as the web does. + /// video mode at the highest refresh rate, or leave fullscreen entirely. + /// Falls back to borderless where the platform offers no video modes, as + /// the web does. pub fn set_exclusive_fullscreen(&self, exclusive: bool) { let Some(window) = &self.window else { return; @@ -1940,6 +1941,13 @@ impl WgpuCanvas { } } + /// Whether the window is currently maximized. + pub fn is_maximized(&self) -> bool { + self.window + .as_ref() + .is_some_and(|window| window.is_maximized()) + } + /// Whether the window is currently fullscreen. pub fn is_fullscreen(&self) -> bool { self.window @@ -2558,6 +2566,22 @@ mod tests { .collect() } + /// The navigation bar covers the window whether or not the keyboard is up, + /// so the first cover seen at a height is taken as the bar alone and only + /// what a later one adds is the keyboard. A rotation changes the height, + /// which starts the measurement again. + #[test] + fn the_android_keyboard_is_what_covers_more_than_the_navigation_bar() { + // 1080-tall window whose navigation bar takes the bottom 60. + assert_eq!(android_keyboard(1080, 60), 0.0, "the bar alone is not it"); + assert_eq!(android_keyboard(1080, 660), 600.0, "the keyboard is up"); + assert_eq!(android_keyboard(1080, 60), 0.0, "and down again"); + // A rotation: the new height is measured from scratch, so its first + // cover is the bar again rather than 600 pixels of phantom keyboard. + assert_eq!(android_keyboard(1920, 40), 0.0); + assert_eq!(android_keyboard(1920, 440), 400.0); + } + /// The editor's chords are punctuation and function keys, and both /// backends read the physical code, so a shifted `\` is still `Backslash`. #[test] diff --git a/src/window/window.rs b/src/window/window.rs index d318e5abf..2b87de875 100644 --- a/src/window/window.rs +++ b/src/window/window.rs @@ -353,8 +353,9 @@ impl Window { self.canvas.set_fullscreen(fullscreen); } - /// Enters or leaves exclusive fullscreen: the current monitor's largest - /// video mode, at its highest refresh rate. + /// Enters exclusive fullscreen, the current monitor's largest video mode + /// at its highest refresh rate, or leaves fullscreen entirely: passing + /// `false` also leaves the borderless fullscreen of [`Self::set_fullscreen`]. /// /// # Platform-specific /// Where the platform offers no video modes, as on the web, this is @@ -368,6 +369,11 @@ impl Window { self.canvas.set_maximized(maximized); } + /// Whether the window is currently maximized. + pub fn is_maximized(&self) -> bool { + self.canvas.is_maximized() + } + /// Shows or hides the platform's on-screen keyboard. /// /// # Platform-specific From 1f0cc01bc3d1158c6942e6c26c4689ccf57c035f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Sun, 13 Sep 2026 09:15:22 +0200 Subject: [PATCH 30/50] ci: run the tests that only exist with the egui feature --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f75fdb54..b76e7d6f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,6 +47,10 @@ jobs: run: cargo build --verbose --examples --features egui; - name: Run tests run: cargo test + # The egui integration (key translation, the command modifier) is behind + # the feature, so its tests only exist in a build that enables it. + - name: Run tests (+ egui) + run: cargo test --features egui build-wasm: runs-on: ubuntu-latest env: From 30d3deb26a160d70ca8dc6e472f23422beac8d1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Sun, 13 Sep 2026 09:15:22 +0200 Subject: [PATCH 31/50] docs(changelog): the keyboard height, fullscreen and egui hotkey changes --- .run/screen_read2d.run.xml | 5 +++-- CHANGELOG.md | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.run/screen_read2d.run.xml b/.run/screen_read2d.run.xml index ec3f2c5c6..77fd8524d 100644 --- a/.run/screen_read2d.run.xml +++ b/.run/screen_read2d.run.xml @@ -1,7 +1,9 @@ + - + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bab9f370..9c462e534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,17 @@ - egui's pointer shape is applied to the window, and a pass egui asks to discard (a new `Area`, a `Grid`, a `Resize`) is re-run before the frame is shown rather than showing the gap. - `Canvas::set_cursor_icon` and `HdrPipeline::scene_texture`. +- `Window::keyboard_height` reports how many pixels of the window the on-screen keyboard covers, + from the bottom. Non-zero on Android and iOS, so a mobile app can lift what the keyboard would + bury. +- `Window::set_exclusive_fullscreen` takes the current monitor's largest video mode at its highest + refresh rate, and `set_maximized` / `is_maximized` join the existing `set_fullscreen` / + `is_fullscreen`. +- `kiss3d::wgpu` re-exports the wgpu the crate itself was built against. The material traits are + written in wgpu types, so an implementor naming them through this cannot drift to another + version. +- egui now hears punctuation, digits, the numpad, the function keys and the clipboard keys, not + only the letters and the navigation keys, so an app can bind a chord to any of them. ## Bug Fixes @@ -56,6 +67,10 @@ pressed for good: macOS sends no keyup of its own for it. - A render target reused at another colour format is remade rather than only resized. - Auto-exposure meters what the film-stage chain wrote, not the film it started from. +- An egui shortcut now fires from either Ctrl or ⌘, on every platform, rather than from whichever + one the build's platform calls the command key. This deliberately departs from egui's own rule + (⌘ on macOS, Ctrl everywhere else), so that a Mac keyboard drives the same chords wherever it is + plugged in; `mac_cmd` still follows the platform, so egui's mac-only paths are unaffected. ## Performance From 061d95f5f387c2bc9489ecd3efb735ffbb534763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Sun, 13 Sep 2026 09:30:49 +0200 Subject: [PATCH 32/50] fix(egui): follow egui's own rule for the command modifier --- CHANGELOG.md | 4 --- src/window/egui_integration.rs | 51 +++++++++++++++++++++------------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c462e534..6bd8edd40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,10 +67,6 @@ pressed for good: macOS sends no keyup of its own for it. - A render target reused at another colour format is remade rather than only resized. - Auto-exposure meters what the film-stage chain wrote, not the film it started from. -- An egui shortcut now fires from either Ctrl or ⌘, on every platform, rather than from whichever - one the build's platform calls the command key. This deliberately departs from egui's own rule - (⌘ on macOS, Ctrl everywhere else), so that a Mac keyboard drives the same chords wherever it is - plugged in; `mac_cmd` still follows the platform, so egui's mac-only paths are unaffected. ## Performance diff --git a/src/window/egui_integration.rs b/src/window/egui_integration.rs index 7d5b8368e..0852b13c2 100644 --- a/src/window/egui_integration.rs +++ b/src/window/egui_integration.rs @@ -63,16 +63,18 @@ fn command_is_super() -> bool { /// tapped within one frame has already released its modifier by then. fn egui_modifiers(modifiers: Modifiers) -> egui::Modifiers { let ctrl = modifiers.contains(Modifiers::Control); - let super_key = modifiers.contains(Modifiers::Super); + // Only where the platform's command key is ⌘: egui's mac-only paths read + // this, and a browser on an Apple platform counts. + let mac_cmd = command_is_super() && modifiers.contains(Modifiers::Super); egui::Modifiers { alt: modifiers.contains(Modifiers::Alt), ctrl, shift: modifiers.contains(Modifiers::Shift), - // Only where the platform has a ⌘: egui's mac-only paths read this. - mac_cmd: command_is_super() && super_key, - // Either key drives a `COMMAND` chord, so ⌃S saves on a Mac and ⌘S - // saves on a Mac keyboard plugged into anything else. - command: ctrl || super_key, + mac_cmd, + // egui's own rule: a `COMMAND` chord is ⌘ where the platform has one + // and Ctrl everywhere else, so a shortcut reads the way the rest of + // the platform's apps read it. + command: if command_is_super() { mac_cmd } else { ctrl }, } } @@ -638,20 +640,31 @@ mod tests { assert_eq!(translate_key_to_egui(Key::Unknown), None); } - /// Both keys drive a `COMMAND` chord, whichever platform is running, so a - /// ⌃ chord works on a Mac and a ⌘ one on the keyboards that have it. + /// A `COMMAND` chord is the key the platform calls command, as egui has it: + /// ⌘ where there is one, Ctrl everywhere else. The other key is not it. #[test] - fn control_and_super_both_read_as_command() { - for held in [Modifiers::Control, Modifiers::Super] { - let mods = egui_modifiers(held | Modifiers::Shift); - assert!(mods.command, "{:?} did not read as command", held); - assert!(mods.shift); - assert!( - mods.matches_logically(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT), - "{:?} did not match ⇧⌘", - held - ); - } + fn command_is_the_key_the_platform_calls_command() { + let (command, other) = if command_is_super() { + (Modifiers::Super, Modifiers::Control) + } else { + (Modifiers::Control, Modifiers::Super) + }; + + let held = egui_modifiers(command | Modifiers::Shift); + assert!(held.command, "{:?} did not read as command", command); + assert!(held.shift); + assert!( + held.matches_logically(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT), + "{:?} did not match ⇧⌘", + command + ); + + assert!( + !egui_modifiers(other).command, + "{:?} read as command on a platform that calls {:?} that", + other, + command + ); } /// ⌘ is a Mac's alone: egui's mac-only paths read `mac_cmd`, and a chord From 945d7459c41d81c87b1a13dbe9c705afe7c74020 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 13 Sep 2026 15:42:53 +0300 Subject: [PATCH 33/50] change rendering to use instead pixel per point. --- src/window/egui_integration.rs | 37 +++++++++++++++++++++++----------- src/window/rendering.rs | 24 +++++++++------------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/window/egui_integration.rs b/src/window/egui_integration.rs index 0852b13c2..360569225 100644 --- a/src/window/egui_integration.rs +++ b/src/window/egui_integration.rs @@ -206,6 +206,18 @@ impl Window { self.egui_context.renderer.context() } + /// Physical pixels per egui point: the display's own scale factor times + /// the UI zoom held by [`egui::Context::set_zoom_factor`]. Every position + /// egui is fed and the screen it is told about are divided by this, which + /// is what `egui-winit` does. + /// + /// A host that wants the UI larger sets the zoom factor and reads this to + /// learn what a point became; it must not set `pixels_per_point` itself, + /// since egui derives that from the zoom and the native scale below. + pub fn egui_pixels_per_point(&self) -> f32 { + self.egui_context.renderer.context().zoom_factor() * self.scale_factor() as f32 + } + /// Registers a native wgpu texture view with this window's egui renderer, /// returning a [`egui::TextureId`] that can be drawn with /// `ui.image((id, size))` inside [`Window::draw_ui`] — entirely on the @@ -265,7 +277,7 @@ impl Window { /// Feed a window event to egui for processing. pub(crate) fn feed_egui_event(&mut self, event: &WindowEvent) { - let scale_factor = self.scale_factor() as f32; + let scale_factor = self.egui_pixels_per_point(); match *event { WindowEvent::CursorPos(x, y, _) => { @@ -518,22 +530,23 @@ impl Window { Some(start.elapsed().as_secs_f64()) }; - let scale_factor = self.canvas.scale_factor() as f32; - - // Set pixels_per_point on the context to match our DPI scale - self.egui_context - .renderer - .context() - .set_pixels_per_point(scale_factor); + let native = self.canvas.scale_factor() as f32; + let ppp = self.egui_pixels_per_point(); // Build raw input with accumulated events let mut raw_input = std::mem::take(&mut self.egui_context.raw_input); + // The display's own scale, which egui multiplies by the zoom factor to + // reach `pixels_per_point`. Setting that directly instead would pin the + // zoom at 1, since `set_pixels_per_point` is a zoom setter in disguise. + let viewport = raw_input.viewport_id; + raw_input + .viewports + .entry(viewport) + .or_default() + .native_pixels_per_point = Some(native); raw_input.screen_rect = Some(egui::Rect::from_min_size( egui::Pos2::ZERO, - egui::vec2( - self.width() as f32 / scale_factor, - self.height() as f32 / scale_factor, - ), + egui::vec2(self.width() as f32 / ppp, self.height() as f32 / ppp), )); raw_input.time = time; raw_input.predicted_dt = 1.0 / 60.0; diff --git a/src/window/rendering.rs b/src/window/rendering.rs index 2b4e5a123..e895d6013 100644 --- a/src/window/rendering.rs +++ b/src/window/rendering.rs @@ -1491,13 +1491,12 @@ impl Window { // Close the pass opened by any draw_ui/draw_inspector calls this // frame so all their shapes are tessellated together. self.finish_egui_pass(); - self.egui_context.renderer.render( - &frame_view, - &depth_view, - w, - h, - self.canvas.scale_factor() as f32, - ); + // What the pass laid out in, zoom included, not the display's own + // scale: tessellation and the screen descriptor both want points. + let ppp = self.egui_pixels_per_point(); + self.egui_context + .renderer + .render(&frame_view, &depth_view, w, h, ppp); } // Copy the rendered image into the readback texture so `snap`, @@ -1739,13 +1738,10 @@ impl Window { // Close the pass opened by any draw_ui/draw_inspector calls this // frame so all their shapes are tessellated together. self.finish_egui_pass(); - self.egui_context.renderer.render( - &frame_view, - &frame_view, - w, - h, - self.canvas.scale_factor() as f32, - ); + let ppp = self.egui_pixels_per_point(); + self.egui_context + .renderer + .render(&frame_view, &frame_view, w, h, ppp); } match &frame { From cab7b4305cf4b8ef8cd0382f3deb6923b176f859 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 13 Sep 2026 16:04:18 +0300 Subject: [PATCH 34/50] make zoom configurable --- src/window/egui_integration.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/window/egui_integration.rs b/src/window/egui_integration.rs index 360569225..d103e5ed5 100644 --- a/src/window/egui_integration.rs +++ b/src/window/egui_integration.rs @@ -26,6 +26,11 @@ pub(crate) struct EguiContext { /// The shape the pointer was last set to. Setting one is a hop to the /// main thread, so only a change is worth making. pub(crate) cursor: egui::CursorIcon, + /// The UI zoom the host asked for, in points per design pixel. Held here + /// rather than read back from the context because `set_zoom_factor` only + /// lands at the next `begin_pass`, so the context answers with the old + /// one until then and every size computed from it would be a frame late. + pub(crate) zoom: f32, #[cfg(not(target_arch = "wasm32"))] pub(crate) start_time: std::time::Instant, } @@ -39,6 +44,7 @@ impl EguiContext { pointer_touch_id: None, rerun_input: RawInput::default(), cursor: egui::CursorIcon::Default, + zoom: 1.0, #[cfg(not(target_arch = "wasm32"))] start_time: std::time::Instant::now(), } @@ -215,7 +221,20 @@ impl Window { /// learn what a point became; it must not set `pixels_per_point` itself, /// since egui derives that from the zoom and the native scale below. pub fn egui_pixels_per_point(&self) -> f32 { - self.egui_context.renderer.context().zoom_factor() * self.scale_factor() as f32 + self.egui_context.zoom * self.scale_factor() as f32 + } + + /// Draw the UI this many points per design pixel, on top of whatever the + /// display's own scale is. Applied to egui's zoom factor at the start of + /// the next pass, which is what makes every widget and every font grow + /// together, egui's own controls included. + pub fn set_ui_zoom(&mut self, zoom: f32) { + self.egui_context.zoom = zoom.max(f32::EPSILON); + } + + /// What [`Window::set_ui_zoom`] was last given. + pub fn ui_zoom(&self) -> f32 { + self.egui_context.zoom } /// Registers a native wgpu texture view with this window's egui renderer, @@ -532,6 +551,12 @@ impl Window { let native = self.canvas.scale_factor() as f32; let ppp = self.egui_pixels_per_point(); + // Before the pass opens: egui holds a new zoom until its next + // `begin_pass`, so setting it here is what makes this pass use it. + self.egui_context + .renderer + .context() + .set_zoom_factor(self.egui_context.zoom); // Build raw input with accumulated events let mut raw_input = std::mem::take(&mut self.egui_context.raw_input); From 4a45b95429f8659d757441761db29ddeeaaf8a22 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 13 Sep 2026 21:20:39 +0300 Subject: [PATCH 35/50] text scale --- src/window/canvas.rs | 5 +++++ src/window/ios.rs | 25 ++++++++++++++++++++++--- src/window/wgpu_canvas.rs | 13 +++++++++++++ src/window/window.rs | 6 ++++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/window/canvas.rs b/src/window/canvas.rs index bcc98e558..d445fa527 100644 --- a/src/window/canvas.rs +++ b/src/window/canvas.rs @@ -234,6 +234,11 @@ impl Canvas { self.canvas.safe_area() } + /// The reader's own preferred text size, as a multiplier of standard. + pub fn text_scale(&self) -> f32 { + self.canvas.text_scale() + } + /// Pixels of the window the on-screen keyboard covers, from the bottom. pub fn keyboard_height(&self) -> f32 { self.canvas.keyboard_height() diff --git a/src/window/ios.rs b/src/window/ios.rs index 7f587bf40..6be6c25ad 100644 --- a/src/window/ios.rs +++ b/src/window/ios.rs @@ -23,9 +23,9 @@ use objc2_foundation::{ NSValue, NSURL, }; use objc2_ui_kit::{ - NSValueUIGeometryExtensions, UIApplicationDidFinishLaunchingNotification, UIKeyInput, - UIKeyboardFrameEndUserInfoKey, UIKeyboardWillChangeFrameNotification, UITextInputTraits, - UIView, + NSValueUIGeometryExtensions, UIApplicationDidFinishLaunchingNotification, UIFont, UIKeyInput, + UIKeyboardFrameEndUserInfoKey, UIKeyboardWillChangeFrameNotification, UIFontTextStyleBody, + UITextInputTraits, UIView, }; use winit::application::ApplicationHandler; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; @@ -389,6 +389,25 @@ pub(crate) fn safe_area(window: &Window) -> [f64; 4] { [insets.left, insets.top, insets.right, insets.bottom] } +/// How much larger than standard the reader asked their text to be. +/// +/// Dynamic Type, as the ratio of the body style's point size to the 17 points +/// it is at the default setting. 1.0 where UIKit cannot be asked. +pub(crate) fn text_scale() -> f32 { + /// The body style's size at the default Dynamic Type setting. + const STANDARD_BODY: f64 = 17.0; + let Some(mtm) = MainThreadMarker::new() else { + return 1.0; + }; + let font = unsafe { UIFont::preferredFontForTextStyle(UIFontTextStyleBody, mtm) }; + let size = font.pointSize(); + if size > 0.0 { + (size / STANDARD_BODY) as f32 + } else { + 1.0 + } +} + /// Show or hide the system keyboard for `window`. pub(crate) fn set_keyboard_visible(window: &Window, visible: bool) { let Some((mtm, parent)) = ui_view(window) else { diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index 1f0b0c801..b7e96a140 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -2056,6 +2056,19 @@ impl WgpuCanvas { } } + /// How much larger than standard the reader asked their text to be: iOS + /// Dynamic Type today, and 1.0 on every platform not yet asked. + pub fn text_scale(&self) -> f32 { + #[cfg(target_os = "ios")] + { + super::ios::text_scale() + } + #[cfg(not(target_os = "ios"))] + { + 1.0 + } + } + /// Set the cursor grabbing behaviour. pub fn set_cursor_grab(&self, grab: bool) { use winit::window::CursorGrabMode; diff --git a/src/window/window.rs b/src/window/window.rs index 2b87de875..a46c5a92d 100644 --- a/src/window/window.rs +++ b/src/window/window.rs @@ -419,6 +419,12 @@ impl Window { self.canvas.keyboard_height() } + /// How much larger than standard the reader asked their text to be: iOS + /// Dynamic Type today, and 1.0 where the platform has not been asked. + pub fn text_scale(&self) -> f32 { + self.canvas.text_scale() + } + /// Sets the cursor position in window coordinates. /// /// # Arguments From 16bf4bbf810e335f8ada58d10f8912f029775bd7 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 13 Sep 2026 21:43:09 +0300 Subject: [PATCH 36/50] ios: fix text_scale for objc2-ui-kit 0.3 and rustfmt import order --- src/window/ios.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/window/ios.rs b/src/window/ios.rs index 6be6c25ad..ec6ed710e 100644 --- a/src/window/ios.rs +++ b/src/window/ios.rs @@ -23,9 +23,9 @@ use objc2_foundation::{ NSValue, NSURL, }; use objc2_ui_kit::{ - NSValueUIGeometryExtensions, UIApplicationDidFinishLaunchingNotification, UIFont, UIKeyInput, - UIKeyboardFrameEndUserInfoKey, UIKeyboardWillChangeFrameNotification, UIFontTextStyleBody, - UITextInputTraits, UIView, + NSValueUIGeometryExtensions, UIApplicationDidFinishLaunchingNotification, UIFont, + UIFontTextStyleBody, UIKeyInput, UIKeyboardFrameEndUserInfoKey, + UIKeyboardWillChangeFrameNotification, UITextInputTraits, UIView, }; use winit::application::ApplicationHandler; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; @@ -396,11 +396,11 @@ pub(crate) fn safe_area(window: &Window) -> [f64; 4] { pub(crate) fn text_scale() -> f32 { /// The body style's size at the default Dynamic Type setting. const STANDARD_BODY: f64 = 17.0; - let Some(mtm) = MainThreadMarker::new() else { + if MainThreadMarker::new().is_none() { return 1.0; - }; - let font = unsafe { UIFont::preferredFontForTextStyle(UIFontTextStyleBody, mtm) }; - let size = font.pointSize(); + } + let font = unsafe { UIFont::preferredFontForTextStyle(UIFontTextStyleBody) }; + let size = unsafe { font.pointSize() }; if size > 0.0 { (size / STANDARD_BODY) as f32 } else { From 5fb037adcc5dbb539c827973e71f10d13669c3d8 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Tue, 15 Sep 2026 13:07:26 +0300 Subject: [PATCH 37/50] fixes to ssao --- src/builtin/ssao.wgsl | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/builtin/ssao.wgsl b/src/builtin/ssao.wgsl index 6161c204f..42fc68e13 100644 --- a/src/builtin/ssao.wgsl +++ b/src/builtin/ssao.wgsl @@ -65,7 +65,13 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let px = textureSampleLevel(t_viewpos, s_pt, in.uv + vec2(u.inv_resolution.x, 0.0), 0.0).xyz; let py = textureSampleLevel(t_viewpos, s_pt, in.uv + vec2(0.0, u.inv_resolution.y), 0.0).xyz; var n = normalize(cross(px - p, py - p)); - if n.z < 0.0 { + // Face the normal at the eye by the direction to the point, not by the z + // axis. `p` runs from the eye to the surface, so a front face has + // dot(n, p) < 0. Testing n.z instead decides nothing on a surface seen at a + // glancing angle -- ground under a standing camera -- where n.z is zero to + // within rounding, and half the pixels get a hemisphere pointing into the + // ground, which then occludes itself. + if dot(n, p) > 0.0 { n = -n; } @@ -77,6 +83,10 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let tbn = mat3x3(t, b, n); var occlusion = 0.0; + // Samples that land off the screen are unknown, not unoccluded, so they + // leave the average as well as the sum. Counting them as clear lightens the + // AO towards every edge of the frame, by more the wider the radius reaches. + var taken = 0.0; for (var i = 0; i < 16; i = i + 1) { let sample_pos = p + (tbn * kernel(i)) * u.radius; let clip = u.proj * vec4(sample_pos, 1.0); @@ -88,7 +98,10 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { if suv.x < 0.0 || suv.x > 1.0 || suv.y < 0.0 || suv.y > 1.0 { continue; } + taken = taken + 1.0; let sampled = textureSampleLevel(t_viewpos, s_pt, suv, 0.0); + // Sky at the sample's pixel: nothing there to occlude it, which is a + // measurement, so it stays in the average. if sampled.a < 0.5 { continue; } @@ -100,6 +113,6 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { } } - let ao = clamp(1.0 - (occlusion / 16.0) * u.intensity, 0.0, 1.0); + let ao = clamp(1.0 - (occlusion / max(taken, 1.0)) * u.intensity, 0.0, 1.0); return vec4(pow(ao, u.power)); } From bb3591f24f2b5770fa217820633b11aa4d3b3eb0 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 19 Sep 2026 19:18:03 +0300 Subject: [PATCH 38/50] Move to wesl 0.5: VirtualResolver lives in wesl::resolver, Compiler replaces Wesl --- Cargo.toml | 2 +- src/builtin/mod.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dc67afa22..43db7ca9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,7 @@ rusttype = { version = "0.9", features = ["gpu_cache"] } serde = { version = "1", features = ["derive"], optional = true } wgpu = "30" winit = "0.30" -wesl = "0.4" +wesl = "0.5" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] pollster = "1" diff --git a/src/builtin/mod.rs b/src/builtin/mod.rs index 501df4701..0a3dccc5e 100644 --- a/src/builtin/mod.rs +++ b/src/builtin/mod.rs @@ -45,24 +45,24 @@ pub(crate) fn compile_wesl( root: &str, features: &[(&str, bool)], ) -> String { - let mut resolver = wesl::VirtualResolver::new(); + let mut resolver = wesl::resolver::VirtualResolver::new(); for (path, src) in modules { resolver.add_module( path.parse().expect("invalid WESL module path"), (*src).into(), ); } - let mut comp = wesl::Wesl::new("").set_custom_resolver(resolver); - comp.set_options(wesl::CompileOptions { + let mut options = wesl::CompileOptions { // wesl's own validation needs the `eval` crate feature (off); naga validates // at `create_shader_module` regardless. validate: false, ..Default::default() - }); + }; for (name, on) in features { - comp.set_feature(name, *on); + options.features.set(name, *on); } - comp.compile(&root.parse().expect("invalid WESL root module path")) + wesl::Compiler::new_with_resolver(options, resolver) + .compile_module(&root.parse().expect("invalid WESL root module path")) .unwrap_or_else(|e| panic!("WESL compilation of {} failed: {}", root, e)) .to_string() } From c1ee9da19123390a7d3e3feed32386a2de3c19fd Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 20 Sep 2026 13:04:00 +0300 Subject: [PATCH 39/50] make bloom and other stuff optional --- src/post_processing/hdr.rs | 348 +++++++++++++++++++++---------------- 1 file changed, 198 insertions(+), 150 deletions(-) diff --git a/src/post_processing/hdr.rs b/src/post_processing/hdr.rs index 06d7cc87d..707dfbf28 100644 --- a/src/post_processing/hdr.rs +++ b/src/post_processing/hdr.rs @@ -221,6 +221,180 @@ struct HdrTargets { oit_reveal_msaa_view: Option, } +/// Compile the bloom chain. Called the first time a frame draws bloom. +fn build_bloom(bloom_layout: &wgpu::BindGroupLayout) -> BloomPipelines { + let ctxt = Context::get(); + let vertex_layout = Some(wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }], + }); + + let bloom_shader = ctxt.create_shader_module( + Some("hdr_bloom_shader"), + &crate::builtin::compile_shader_with_common( + "package::hdr_bloom", + include_str!("../builtin/hdr_bloom.wgsl"), + ), + ); + + let bloom_pipeline_layout = ctxt.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("hdr_bloom_pipeline_layout"), + bind_group_layouts: &[Some(&bloom_layout)], + immediate_size: 0, + }); + + let make_bloom_pipeline = |label: &str, fs_entry: &str, blend: Option| { + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&bloom_pipeline_layout), + vertex: wgpu::VertexState { + module: &bloom_shader, + entry_point: Some("vs_main"), + buffers: std::slice::from_ref(&vertex_layout), + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &bloom_shader, + entry_point: Some(fs_entry), + targets: &[Some(wgpu::ColorTargetState { + format: HDR_FORMAT, + blend, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) + }; + + let prefilter = make_bloom_pipeline("hdr_bloom_prefilter", "fs_prefilter", None); + let downsample = make_bloom_pipeline("hdr_bloom_downsample", "fs_downsample", None); + // The upsample pass additively blends into the larger mip. + let upsample = make_bloom_pipeline( + "hdr_bloom_upsample", + "fs_upsample", + Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent::REPLACE, + }), + ); + + BloomPipelines { + prefilter, + downsample, + upsample, + } +} + +/// The bloom chain's pipelines, built together the first time bloom draws. +struct BloomPipelines { + prefilter: wgpu::RenderPipeline, + downsample: wgpu::RenderPipeline, + upsample: wgpu::RenderPipeline, +} + +/// Auto-exposure's two 1x1 pipelines, built the first time it runs. +struct ExposurePipelines { + meter: wgpu::RenderPipeline, + adapt: wgpu::RenderPipeline, +} + +/// Compile auto-exposure's two passes. Called the first time one runs. +fn build_exposure( + meter_layout: &wgpu::BindGroupLayout, + adapt_layout: &wgpu::BindGroupLayout, +) -> ExposurePipelines { + let ctxt = Context::get(); + let meter_shader = ctxt.create_shader_module( + Some("hdr_autoexposure_meter"), + &crate::builtin::compile_shader_with_common( + "package::auto_exposure_meter", + include_str!("../builtin/auto_exposure_meter.wgsl"), + ), + ); + let make_1x1_pipeline = + |label: &str, layout: &wgpu::BindGroupLayout, shader: &wgpu::ShaderModule| { + let pl = ctxt.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(label), + bind_group_layouts: &[Some(layout)], + immediate_size: 0, + }); + ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&pl), + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::R16Float, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) + }; + let meter = make_1x1_pipeline("hdr_autoexposure_meter", meter_layout, &meter_shader); + + let adapt_shader = ctxt.create_shader_module( + Some("hdr_autoexposure_adapt"), + &crate::builtin::compile_shader_with_common( + "package::auto_exposure_adapt", + include_str!("../builtin/auto_exposure_adapt.wgsl"), + ), + ); + let adapt = make_1x1_pipeline("hdr_autoexposure_adapt", adapt_layout, &adapt_shader); + ExposurePipelines { meter, adapt } +} + /// Owns the HDR scene target, bloom chain and resolve pipelines for the /// rasterizer. One instance lives on each [`Window`](crate::window::Window). pub struct HdrPipeline { @@ -265,9 +439,10 @@ pub struct HdrPipeline { // Bloom pipelines (prefilter / downsample / upsample) and tonemap pipeline. bloom_layout: wgpu::BindGroupLayout, - prefilter_pipeline: wgpu::RenderPipeline, - downsample_pipeline: wgpu::RenderPipeline, - upsample_pipeline: wgpu::RenderPipeline, + // Built the first frame bloom is drawn. Bloom is off by default, so three + // pipelines and a shader module were compiled at start-up for a pass most + // projects never run. + bloom: std::sync::OnceLock, tonemap_layout: wgpu::BindGroupLayout, tonemap_pipeline: wgpu::RenderPipeline, @@ -290,9 +465,9 @@ pub struct HdrPipeline { // Index of the texture written this frame (the other holds the previous value). exposure_index: usize, meter_layout: wgpu::BindGroupLayout, - meter_pipeline: wgpu::RenderPipeline, adapt_layout: wgpu::BindGroupLayout, - adapt_pipeline: wgpu::RenderPipeline, + // Built the first frame auto-exposure runs, for the same reason as `bloom`. + exposure: std::sync::OnceLock, adapt_uniform: wgpu::Buffer, // Wall-clock of the previous adaptation, for the dt-based smoothing. last_adapt_time: Option, @@ -353,14 +528,6 @@ impl HdrPipeline { ], }); - let bloom_shader = ctxt.create_shader_module( - Some("hdr_bloom_shader"), - &crate::builtin::compile_shader_with_common( - "package::hdr_bloom", - include_str!("../builtin/hdr_bloom.wgsl"), - ), - ); - let vertex_layout = Some(wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as wgpu::BufferAddress, step_mode: wgpu::VertexStepMode::Vertex, @@ -371,69 +538,6 @@ impl HdrPipeline { }], }); - let bloom_pipeline_layout = ctxt.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("hdr_bloom_pipeline_layout"), - bind_group_layouts: &[Some(&bloom_layout)], - immediate_size: 0, - }); - - let make_bloom_pipeline = |label: &str, fs_entry: &str, blend: Option| { - ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(label), - layout: Some(&bloom_pipeline_layout), - vertex: wgpu::VertexState { - module: &bloom_shader, - entry_point: Some("vs_main"), - buffers: std::slice::from_ref(&vertex_layout), - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &bloom_shader, - entry_point: Some(fs_entry), - targets: &[Some(wgpu::ColorTargetState { - format: HDR_FORMAT, - blend, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleStrip, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, - }) - }; - - let prefilter_pipeline = make_bloom_pipeline("hdr_bloom_prefilter", "fs_prefilter", None); - let downsample_pipeline = - make_bloom_pipeline("hdr_bloom_downsample", "fs_downsample", None); - // The upsample pass additively blends into the larger mip. - let upsample_pipeline = make_bloom_pipeline( - "hdr_bloom_upsample", - "fs_upsample", - Some(wgpu::BlendState { - color: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::One, - dst_factor: wgpu::BlendFactor::One, - operation: wgpu::BlendOperation::Add, - }, - alpha: wgpu::BlendComponent::REPLACE, - }), - ); - // Tonemap bind group: scene texture + sampler, bloom texture + sampler, uniforms. let tonemap_layout = ctxt.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("hdr_tonemap_layout"), @@ -699,61 +803,6 @@ impl HdrPipeline { }, ], }); - let meter_shader = ctxt.create_shader_module( - Some("hdr_autoexposure_meter"), - &crate::builtin::compile_shader_with_common( - "package::auto_exposure_meter", - include_str!("../builtin/auto_exposure_meter.wgsl"), - ), - ); - let make_1x1_pipeline = - |label: &str, layout: &wgpu::BindGroupLayout, shader: &wgpu::ShaderModule| { - let pl = ctxt.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some(label), - bind_group_layouts: &[Some(layout)], - immediate_size: 0, - }); - ctxt.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(label), - layout: Some(&pl), - vertex: wgpu::VertexState { - module: shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::R16Float, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, - }) - }; - let meter_pipeline = - make_1x1_pipeline("hdr_autoexposure_meter", &meter_layout, &meter_shader); - // Adaptation pipeline: meter + prev exposure + sampler + uniforms -> new exposure. let adapt_layout = ctxt.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("hdr_autoexposure_adapt_layout"), @@ -796,15 +845,6 @@ impl HdrPipeline { }, ], }); - let adapt_shader = ctxt.create_shader_module( - Some("hdr_autoexposure_adapt"), - &crate::builtin::compile_shader_with_common( - "package::auto_exposure_adapt", - include_str!("../builtin/auto_exposure_adapt.wgsl"), - ), - ); - let adapt_pipeline = - make_1x1_pipeline("hdr_autoexposure_adapt", &adapt_layout, &adapt_shader); let adapt_uniform = ctxt.create_buffer_simple( Some("hdr_autoexposure_adapt_uniform"), std::mem::size_of::() as u64, @@ -835,9 +875,7 @@ impl HdrPipeline { oit_composite_pipeline, sampler, bloom_layout, - prefilter_pipeline, - downsample_pipeline, - upsample_pipeline, + bloom: std::sync::OnceLock::new(), tonemap_layout, tonemap_pipeline, _tony_lut_texture: tony_lut, @@ -852,9 +890,8 @@ impl HdrPipeline { exposure_views: [exposure_view0, exposure_view1], exposure_index: 0, meter_layout, - meter_pipeline, adapt_layout, - adapt_pipeline, + exposure: std::sync::OnceLock::new(), adapt_uniform, last_adapt_time: None, } @@ -1322,6 +1359,17 @@ impl HdrPipeline { /// Runs the bloom prefilter + downsample + upsample chain. The final blurred /// result lands in `bloom_mips[0]` (half resolution), which the tonemap pass /// samples. + /// The bloom chain, compiled on the first frame that draws it. + fn bloom(&self) -> &BloomPipelines { + self.bloom.get_or_init(|| build_bloom(&self.bloom_layout)) + } + + /// Auto-exposure's passes, compiled on the first frame that runs them. + fn exposure(&self) -> &ExposurePipelines { + self.exposure + .get_or_init(|| build_exposure(&self.meter_layout, &self.adapt_layout)) + } + fn run_bloom( &self, encoder: &mut wgpu::CommandEncoder, @@ -1350,7 +1398,7 @@ impl HdrPipeline { occlusion_query_set: None, multiview_mask: None, }); - pass.set_pipeline(&self.prefilter_pipeline); + pass.set_pipeline(&self.bloom().prefilter); pass.set_bind_group(0, &bg, &[]); pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); pass.draw(0..4, 0..1); @@ -1378,7 +1426,7 @@ impl HdrPipeline { occlusion_query_set: None, multiview_mask: None, }); - pass.set_pipeline(&self.downsample_pipeline); + pass.set_pipeline(&self.bloom().downsample); pass.set_bind_group(0, &bg, &[]); pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); pass.draw(0..4, 0..1); @@ -1407,7 +1455,7 @@ impl HdrPipeline { occlusion_query_set: None, multiview_mask: None, }); - pass.set_pipeline(&self.upsample_pipeline); + pass.set_pipeline(&self.bloom().upsample); pass.set_bind_group(0, &bg, &[]); pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); pass.draw(0..4, 0..1); @@ -1489,7 +1537,7 @@ impl HdrPipeline { occlusion_query_set: None, multiview_mask: None, }); - pass.set_pipeline(&self.meter_pipeline); + pass.set_pipeline(&self.exposure().meter); pass.set_bind_group(0, &meter_bg, &[]); pass.draw(0..3, 0..1); } @@ -1535,7 +1583,7 @@ impl HdrPipeline { occlusion_query_set: None, multiview_mask: None, }); - pass.set_pipeline(&self.adapt_pipeline); + pass.set_pipeline(&self.exposure().adapt); pass.set_bind_group(0, &adapt_bg, &[]); pass.draw(0..3, 0..1); } From 9ab3d5ff73b4595d1b88ba8d61e5d7165a429b6c Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 20 Sep 2026 13:08:43 +0300 Subject: [PATCH 40/50] add prepare to pre-enable hdr if needed and post processing --- src/post_processing/hdr.rs | 16 ++++++++++++++++ src/window/window.rs | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/src/post_processing/hdr.rs b/src/post_processing/hdr.rs index 707dfbf28..a517cf5ee 100644 --- a/src/post_processing/hdr.rs +++ b/src/post_processing/hdr.rs @@ -1359,6 +1359,22 @@ impl HdrPipeline { /// Runs the bloom prefilter + downsample + upsample chain. The final blurred /// result lands in `bloom_mips[0]` (half resolution), which the tonemap pass /// samples. + /// Compile whatever the current settings will draw with, so the frame that + /// first draws it is not the frame that waits for a shader compiler. + /// + /// Called when the settings change rather than when a pass runs: a project + /// that ships with bloom on pays at start-up, as it did when these were + /// built unconditionally, and one that turns it on mid-game pays on the + /// frame it asked rather than inside a render pass. + pub fn prepare(&self) { + if self.settings.bloom_enabled { + let _ = self.bloom(); + } + if self.settings.auto_exposure { + let _ = self.exposure(); + } + } + /// The bloom chain, compiled on the first frame that draws it. fn bloom(&self) -> &BloomPipelines { self.bloom.get_or_init(|| build_bloom(&self.bloom_layout)) diff --git a/src/window/window.rs b/src/window/window.rs index a46c5a92d..bf5316a34 100644 --- a/src/window/window.rs +++ b/src/window/window.rs @@ -906,6 +906,15 @@ impl Window { self.hdr.settings_mut().bloom_enabled = enabled; } + /// Compile the finishing passes the current settings will draw with. + /// + /// Bloom and auto-exposure are built on demand, so call this after writing + /// the settings: a frame that waits for a shader compiler is a frame + /// somebody sees, and this puts that wait where the settings changed. + pub fn prepare_post(&self) { + self.hdr.prepare(); + } + /// Sets the bloom brightness threshold and additive intensity. pub fn set_bloom(&mut self, threshold: f32, intensity: f32) { let s = self.hdr.settings_mut(); From ec7acb7c325ef15032cbe6e385612eec774be64e Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 20 Sep 2026 13:17:42 +0300 Subject: [PATCH 41/50] Update hdr.rs --- src/post_processing/hdr.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/post_processing/hdr.rs b/src/post_processing/hdr.rs index a517cf5ee..20176a8a4 100644 --- a/src/post_processing/hdr.rs +++ b/src/post_processing/hdr.rs @@ -244,7 +244,7 @@ fn build_bloom(bloom_layout: &wgpu::BindGroupLayout) -> BloomPipelines { let bloom_pipeline_layout = ctxt.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("hdr_bloom_pipeline_layout"), - bind_group_layouts: &[Some(&bloom_layout)], + bind_group_layouts: &[Some(bloom_layout)], immediate_size: 0, }); From 8d2df95a9d44c48d7a5df55bb0b503fe868ce4bd Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 20 Sep 2026 13:27:05 +0300 Subject: [PATCH 42/50] fix clippy: the bloom layout is already a reference, and the lazy pipelines want a cell rather than a lock --- src/post_processing/hdr.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/post_processing/hdr.rs b/src/post_processing/hdr.rs index 20176a8a4..4b3ed3a1d 100644 --- a/src/post_processing/hdr.rs +++ b/src/post_processing/hdr.rs @@ -442,7 +442,7 @@ pub struct HdrPipeline { // Built the first frame bloom is drawn. Bloom is off by default, so three // pipelines and a shader module were compiled at start-up for a pass most // projects never run. - bloom: std::sync::OnceLock, + bloom: std::cell::OnceCell, tonemap_layout: wgpu::BindGroupLayout, tonemap_pipeline: wgpu::RenderPipeline, @@ -467,7 +467,7 @@ pub struct HdrPipeline { meter_layout: wgpu::BindGroupLayout, adapt_layout: wgpu::BindGroupLayout, // Built the first frame auto-exposure runs, for the same reason as `bloom`. - exposure: std::sync::OnceLock, + exposure: std::cell::OnceCell, adapt_uniform: wgpu::Buffer, // Wall-clock of the previous adaptation, for the dt-based smoothing. last_adapt_time: Option, @@ -875,7 +875,7 @@ impl HdrPipeline { oit_composite_pipeline, sampler, bloom_layout, - bloom: std::sync::OnceLock::new(), + bloom: std::cell::OnceCell::new(), tonemap_layout, tonemap_pipeline, _tony_lut_texture: tony_lut, @@ -891,7 +891,7 @@ impl HdrPipeline { exposure_index: 0, meter_layout, adapt_layout, - exposure: std::sync::OnceLock::new(), + exposure: std::cell::OnceCell::new(), adapt_uniform, last_adapt_time: None, } From c2446d53b45bdfa81eb4d9033ff4da85437f88b0 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sun, 20 Sep 2026 16:16:27 +0300 Subject: [PATCH 43/50] a surface-less render takes a software adapter where there is no driver --- src/window/wgpu_canvas.rs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index b7e96a140..838d86a30 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -1024,15 +1024,26 @@ impl WgpuCanvas { ..wgpu::InstanceDescriptor::new_without_display_handle() }); - let adapter = instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::default(), - compatible_surface: None, - force_fallback_adapter: false, - apply_limit_buckets: false, - }) - .await - .expect("Failed to find an appropriate adapter"); + let ask = async |fallback: bool| { + instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::default(), + compatible_surface: None, + force_fallback_adapter: fallback, + apply_limit_buckets: false, + }) + .await + .ok() + }; + // A machine with no GPU driver still has a software one: WARP on + // Windows, lavapipe where Mesa is installed. Slow, and the only + // way a surface-less render runs on a CI box at all. + let adapter = match ask(false).await { + Some(adapter) => adapter, + None => ask(true) + .await + .expect("Failed to find an appropriate adapter, software one included"), + }; let required_features = device_features(&adapter, canvas_setup.required_features); let (device, queue) = adapter From 0acb496c0f446236a63ff502053427ddba0f7948 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Mon, 21 Sep 2026 08:55:48 +0200 Subject: [PATCH 44/50] add sprite batching --- src/builtin/object2d.wgsl | 3 ++- src/builtin/object_material2d.rs | 16 ++++++++++++++++ src/scene/object2d.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/builtin/object2d.wgsl b/src/builtin/object2d.wgsl index a755c56fb..2525a9c98 100644 --- a/src/builtin/object2d.wgsl +++ b/src/builtin/object2d.wgsl @@ -51,6 +51,7 @@ struct InstanceInput { @location(3) inst_color: vec4, @location(4) inst_def_0: vec2, // deformation matrix column 0 @location(5) inst_def_1: vec2, // deformation matrix column 1 + @location(6) inst_uv: vec4, // texture rectangle: min.xy, max.xy } // Vertex output / Fragment input @@ -91,7 +92,7 @@ fn vs_main(vertex: VertexInput, instance: InstanceInput) -> VertexOutput { projected_pos.z = 0.0; out.clip_position = vec4(projected_pos, 1.0); - out.tex_coord = vertex.tex_coord; + out.tex_coord = mix(instance.inst_uv.xy, instance.inst_uv.zw, vertex.tex_coord); out.vert_color = instance.inst_color; return out; diff --git a/src/builtin/object_material2d.rs b/src/builtin/object_material2d.rs index 9b0c75875..75add19df 100644 --- a/src/builtin/object_material2d.rs +++ b/src/builtin/object_material2d.rs @@ -948,6 +948,16 @@ impl ObjectMaterial2d { }, ], }), + // Buffer 5: Instance texture rectangles ([f32; 4]) + Some(wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::<[f32; 4]>() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[wgpu::VertexAttribute { + offset: 0, + shader_location: 6, // inst_uv + format: wgpu::VertexFormat::Float32x4, + }], + }), ]; let pipeline = Rc::new( @@ -1466,6 +1476,7 @@ impl Material2d for ObjectMaterial2d { instances.lines_widths.load_to_gpu(); instances.points_colors.load_to_gpu(); instances.points_sizes.load_to_gpu(); + instances.uvs.load_to_gpu(); // Ensure mesh buffers are on GPU mesh.load_to_gpu(); @@ -1495,6 +1506,10 @@ impl Material2d for ObjectMaterial2d { Some(b) => b, None => return, }; + let inst_uvs_buf = match instances.uvs.buffer() { + Some(b) => b, + None => return, + }; let inst_points_sizes_buf = match instances.points_sizes.buffer() { Some(b) => b, None => return, @@ -1549,6 +1564,7 @@ impl Material2d for ObjectMaterial2d { render_pass.set_vertex_buffer(2, inst_positions_buf.slice(..)); render_pass.set_vertex_buffer(3, inst_colors_buf.slice(..)); render_pass.set_vertex_buffer(4, inst_deformations_buf.slice(..)); + render_pass.set_vertex_buffer(5, inst_uvs_buf.slice(..)); render_pass.set_index_buffer(faces_buf.slice(..), VERTEX_INDEX_FORMAT); diff --git a/src/scene/object2d.rs b/src/scene/object2d.rs index 1f9274cf3..0b9cf6754 100644 --- a/src/scene/object2d.rs +++ b/src/scene/object2d.rs @@ -224,6 +224,10 @@ pub struct InstanceData2d { pub points_color: Option<[f32; 4]>, /// The point size in pixels for this instance. None = use object's point size. pub points_size: Option, + /// The rectangle of the texture this instance draws, as `[min_x, min_y, + /// max_x, max_y]` of the mesh's own UVs. `[0, 0, 1, 1]` draws them + /// unchanged, and a flip is a swapped pair. + pub uv: [f32; 4], } impl Default for InstanceData2d { @@ -236,10 +240,14 @@ impl Default for InstanceData2d { lines_width: None, // Use object's wireframe width points_color: None, // Use object's point color points_size: None, // Use object's point size + uv: UV_WHOLE_2D, } } } +/// The instance UV rectangle that draws the mesh's own coordinates. +pub const UV_WHOLE_2D: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + /// Sentinel value for lines_width indicating "use object's value". pub const LINES_WIDTH_USE_OBJECT_2D: f32 = -1.0; /// Sentinel value for lines_color indicating "use object's value" (alpha = 0). @@ -283,6 +291,10 @@ pub struct InstancesBuffer2d { pub points_colors: GPUVec<[f32; 4]>, /// GPU buffer of instance point sizes. Negative means use object's size. pub points_sizes: GPUVec, + /// GPU buffer of instance texture rectangles, `[min_x, min_y, max_x, + /// max_y]`. Lets one mesh serve a whole sheet: every instance picks its + /// own frame, so sprites on one image draw in one call. + pub uvs: GPUVec<[f32; 4]>, } impl Default for InstancesBuffer2d { @@ -323,6 +335,11 @@ impl Default for InstancesBuffer2d { BufferType::Array, AllocationType::StreamDraw, ), + uvs: GPUVec::new( + vec![UV_WHOLE_2D], + BufferType::Array, + AllocationType::StreamDraw, + ), } } } @@ -498,6 +515,13 @@ impl Object2d { .data_mut() .take() .unwrap_or_default(); + let mut uv_data: Vec<_> = self + .instances + .borrow_mut() + .uvs + .data_mut() + .take() + .unwrap_or_default(); pos_data.clear(); col_data.clear(); @@ -506,6 +530,7 @@ impl Object2d { lines_width_data.clear(); points_col_data.clear(); points_size_data.clear(); + uv_data.clear(); pos_data.extend(instances.iter().map(|i| i.position)); col_data.extend(instances.iter().map(|i| i.color)); @@ -541,7 +566,10 @@ impl Object2d { *self.instances.borrow_mut().lines_colors.data_mut() = Some(lines_col_data); *self.instances.borrow_mut().lines_widths.data_mut() = Some(lines_width_data); *self.instances.borrow_mut().points_colors.data_mut() = Some(points_col_data); + uv_data.extend(instances.iter().map(|i| i.uv)); + *self.instances.borrow_mut().points_sizes.data_mut() = Some(points_size_data); + *self.instances.borrow_mut().uvs.data_mut() = Some(uv_data); } /// Prepares this object's per-instance buffers to be written directly by a From d5a1104a9351789b8b7e39000f42b0c044baf358 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Mon, 21 Sep 2026 08:58:11 +0200 Subject: [PATCH 45/50] The whole-image rectangle is public beside the instance that takes it --- src/scene/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 5a45847b0..4627aca00 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3,7 +3,7 @@ pub use self::animation::{AnimationChannel, AnimationClip, AnimationPlayer, Interpolation}; pub use self::object2d::{ Blend2d, InstanceComputeBuffers2d, InstanceData2d, InstancesBuffer2d, Object2d, ObjectData2d, - LINES_COLOR_USE_OBJECT_2D, LINES_WIDTH_USE_OBJECT_2D, POINTS_COLOR_USE_OBJECT_2D, + LINES_COLOR_USE_OBJECT_2D, LINES_WIDTH_USE_OBJECT_2D, POINTS_COLOR_USE_OBJECT_2D, UV_WHOLE_2D, POINTS_SIZE_USE_OBJECT_2D, }; pub use self::object3d::{ From 4f0eb8f49550b67b0b0641b7824a72ca6bcba001 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Mon, 21 Sep 2026 09:15:20 +0200 Subject: [PATCH 46/50] The instance example and the re-export catch up with the UV rectangle --- src/scene/mod.rs | 4 ++-- src/scene/object2d.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 4627aca00..61b06e561 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3,8 +3,8 @@ pub use self::animation::{AnimationChannel, AnimationClip, AnimationPlayer, Interpolation}; pub use self::object2d::{ Blend2d, InstanceComputeBuffers2d, InstanceData2d, InstancesBuffer2d, Object2d, ObjectData2d, - LINES_COLOR_USE_OBJECT_2D, LINES_WIDTH_USE_OBJECT_2D, POINTS_COLOR_USE_OBJECT_2D, UV_WHOLE_2D, - POINTS_SIZE_USE_OBJECT_2D, + LINES_COLOR_USE_OBJECT_2D, LINES_WIDTH_USE_OBJECT_2D, POINTS_COLOR_USE_OBJECT_2D, + POINTS_SIZE_USE_OBJECT_2D, UV_WHOLE_2D, }; pub use self::object3d::{ AlphaMode, Bsdf, InstanceComputeBuffers, InstanceData3d, InstancesBuffer3d, Object3d, diff --git a/src/scene/object2d.rs b/src/scene/object2d.rs index 0b9cf6754..8fe2b8de6 100644 --- a/src/scene/object2d.rs +++ b/src/scene/object2d.rs @@ -207,6 +207,7 @@ impl ObjectData2d { /// lines_width: Some(2.0), // 2px wireframe /// points_color: Some([1.0, 1.0, 0.0, 1.0]), // Yellow points /// points_size: Some(5.0), // 5px points +/// uv: [0.0, 0.0, 1.0, 1.0], // the whole image /// }; /// ``` pub struct InstanceData2d { From ca5cecfbe569fda91691247c42a40d37eab6ef36 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 26 Sep 2026 08:25:18 +0200 Subject: [PATCH 47/50] Every key with a W3C code reaches the app, and Back and Forward are their own buttons The desktop table missed CapsLock, IntlRo, ContextMenu and NumpadEqual, and the web table missed F13-F24, the locks, the international keys, the media, browser and launch keys, and the IME keys. Back and Forward folded into one button on desktop; they are Button4 and Button5, as on the web. --- src/window/wgpu_canvas.rs | 53 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index 838d86a30..d79d59e7c 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -2207,7 +2207,9 @@ fn translate_mouse_button(button: winit::event::MouseButton) -> MouseButton { winit::event::MouseButton::Left => MouseButton::Button1, winit::event::MouseButton::Right => MouseButton::Button2, winit::event::MouseButton::Middle => MouseButton::Button3, - _ => MouseButton::Button4, + winit::event::MouseButton::Back => MouseButton::Button4, + winit::event::MouseButton::Forward => MouseButton::Button5, + winit::event::MouseButton::Other(_) => MouseButton::Button6, } } @@ -2356,6 +2358,10 @@ fn translate_key(physical_key: PhysicalKey) -> Key { KeyCode::Copy => Key::Copy, KeyCode::Paste => Key::Paste, KeyCode::Cut => Key::Cut, + KeyCode::CapsLock => Key::Capital, + KeyCode::IntlRo => Key::AbntC1, + KeyCode::ContextMenu => Key::Apps, + KeyCode::NumpadEqual => Key::NumpadEquals, _ => Key::Unknown, } } else { @@ -2571,6 +2577,51 @@ fn translate_web_key(code: &str) -> Key { "Semicolon" => Key::Semicolon, "Slash" => Key::Slash, "Tab" => Key::Tab, + "F13" => Key::F13, + "F14" => Key::F14, + "F15" => Key::F15, + "F16" => Key::F16, + "F17" => Key::F17, + "F18" => Key::F18, + "F19" => Key::F19, + "F20" => Key::F20, + "F21" => Key::F21, + "F22" => Key::F22, + "F23" => Key::F23, + "F24" => Key::F24, + "CapsLock" => Key::Capital, + "ScrollLock" => Key::Scroll, + "PrintScreen" => Key::Snapshot, + "Pause" => Key::Pause, + "IntlBackslash" => Key::OEM102, + "IntlYen" => Key::Yen, + "IntlRo" => Key::AbntC1, + "ContextMenu" => Key::Apps, + "NumpadEqual" => Key::NumpadEquals, + "NumpadComma" => Key::NumpadComma, + "MediaPlayPause" => Key::PlayPause, + "MediaStop" => Key::MediaStop, + "MediaTrackNext" => Key::NextTrack, + "MediaTrackPrevious" => Key::PrevTrack, + "MediaSelect" => Key::MediaSelect, + "AudioVolumeMute" => Key::Mute, + "AudioVolumeDown" => Key::VolumeDown, + "AudioVolumeUp" => Key::VolumeUp, + "BrowserBack" => Key::NavigateBackward, + "BrowserForward" => Key::NavigateForward, + "BrowserHome" => Key::WebHome, + "BrowserRefresh" => Key::WebRefresh, + "BrowserSearch" => Key::WebSearch, + "LaunchMail" => Key::Mail, + "Power" => Key::Power, + "Sleep" => Key::Sleep, + "WakeUp" => Key::Wake, + "KanaMode" => Key::Kana, + "Convert" => Key::Convert, + "NonConvert" => Key::NoConvert, + "Copy" => Key::Copy, + "Paste" => Key::Paste, + "Cut" => Key::Cut, _ => Key::Unknown, } } From 035cac63750af1e217d78952f8f6f1188d489ddf Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 26 Sep 2026 16:49:06 +0200 Subject: [PATCH 48/50] A window can sleep until an event, and another thread can wake it Window::wait_events(timeout) pumps the window system with a timeout instead of polling it, queueing what arrived for events() without drawing, and answers whether anything came. Window::waker() hands out a Waker whose wake() ends that wait from any thread, through the event loop's proxy. The web and iOS own their loops, so there it polls. --- src/window/canvas.rs | 11 +++++++ src/window/events.rs | 16 ++++++++++ src/window/mod.rs | 2 +- src/window/wgpu_canvas.rs | 61 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/window/canvas.rs b/src/window/canvas.rs index d445fa527..f87fb45bb 100644 --- a/src/window/canvas.rs +++ b/src/window/canvas.rs @@ -100,6 +100,17 @@ impl Canvas { self.canvas.poll_events() } + /// Wait for an event, a [`super::Waker`] or `timeout`, then poll; see + /// [`WgpuCanvas::wait_events`]. + pub fn wait_events(&mut self, timeout: Option) -> bool { + self.canvas.wait_events(timeout) + } + + /// What ends a [`Self::wait_events`] from another thread. + pub fn waker(&self) -> Option { + self.canvas.waker() + } + /// Resizes the canvas render targets. pub fn resize(&mut self, width: u32, height: u32) { self.canvas.resize(width, height) diff --git a/src/window/events.rs b/src/window/events.rs index 05505f414..77be6f58d 100644 --- a/src/window/events.rs +++ b/src/window/events.rs @@ -42,6 +42,22 @@ impl Window { EventManager::new(self.events.clone(), self.unhandled_events.clone()) } + /// Sleeps until the window system has an event, a [`Waker`] from + /// [`Self::waker`] fires, or `timeout` passes, and queues what arrived for + /// [`Self::events`] without drawing. `None` waits for as long as it takes. + /// Answers whether anything arrived. The web and iOS cannot block here. + /// + /// [`Waker`]: crate::window::Waker + pub fn wait_events(&mut self, timeout: Option) -> bool { + self.canvas.wait_events(timeout) + } + + /// What ends a [`Self::wait_events`] from another thread; `None` where + /// the platform owns the loop. + pub fn waker(&self) -> Option { + self.canvas.waker() + } + /// Gets the current state of a keyboard key. /// /// # Arguments diff --git a/src/window/mod.rs b/src/window/mod.rs index eaae3e03b..f07141597 100644 --- a/src/window/mod.rs +++ b/src/window/mod.rs @@ -29,6 +29,6 @@ pub use offscreen::OffscreenSurface; pub use recording::RecordingConfig; #[cfg(target_os = "android")] pub use wgpu_canvas::init_android; -pub use wgpu_canvas::WgpuCanvas; +pub use wgpu_canvas::{Waker, WgpuCanvas}; pub use window::Window; pub(crate) use window_cache::WINDOW_CACHE; diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index d79d59e7c..1645c66a8 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -89,6 +89,21 @@ thread_local! { static EVENT_LOOP: RefCell>> = const { RefCell::new(None) }; } +/// Ends a [`WgpuCanvas::wait_events`] from any thread. +#[derive(Clone, Debug)] +pub struct Waker( + #[cfg(not(any(target_arch = "wasm32", target_os = "ios")))] + winit::event_loop::EventLoopProxy<()>, +); + +impl Waker { + /// Wake the waiting loop; harmless once it has gone. + pub fn wake(&self) { + #[cfg(not(any(target_arch = "wasm32", target_os = "ios")))] + let _ = self.0.send_event(()); + } +} + #[cfg(not(target_arch = "wasm32"))] thread_local! { // Shared event storage for multi-window support. Events are stored per window_id @@ -1330,10 +1345,38 @@ impl WgpuCanvas { /// Polls events from the window system. pub fn poll_events(&mut self) { + self.pump_events(Some(std::time::Duration::ZERO)); + } + + /// Blocks until the window system has an event, a [`Waker`] wakes it, or + /// `timeout` passes, then polls as [`Self::poll_events`] does; `None` + /// waits for as long as it takes. Answers whether anything arrived. The + /// web and iOS own their loops and cannot block here, so there it polls. + pub fn wait_events(&mut self, timeout: Option) -> bool { + self.pump_events(timeout) + } + + /// Something that ends a [`Self::wait_events`] from another thread. + /// `None` where the loop is not kiss3d's to pump. + pub fn waker(&self) -> Option { + #[cfg(not(any(target_arch = "wasm32", target_os = "ios")))] + { + EVENT_LOOP.with(|cell| cell.borrow().as_ref().map(|el| Waker(el.create_proxy()))) + } + #[cfg(any(target_arch = "wasm32", target_os = "ios"))] + { + None + } + } + + fn pump_events(&mut self, timeout: Option) -> bool { // A headless canvas has no window and no event loop; nothing to poll. if self.window.is_none() { - return; + return false; } + let mut arrived = false; + #[cfg(any(target_arch = "wasm32", target_os = "ios"))] + let _ = timeout; #[cfg(not(target_arch = "wasm32"))] { @@ -1345,7 +1388,9 @@ impl WgpuCanvas { { use winit::platform::pump_events::EventLoopExtPumpEvents; - struct EventCollector; + struct EventCollector { + arrived: bool, + } impl ApplicationHandler for EventCollector { fn resumed(&mut self, _event_loop: &ActiveEventLoop) { @@ -1371,15 +1416,20 @@ impl WgpuCanvas { window_id: winit::window::WindowId, event: WinitWindowEvent, ) { + self.arrived = true; collect_window_event(window_id, event); } + + fn user_event(&mut self, _event_loop: &ActiveEventLoop, _event: ()) { + self.arrived = true; + } } - let timeout = Some(std::time::Duration::ZERO); EVENT_LOOP.with(|event_loop_cell| { if let Some(ref mut event_loop) = *event_loop_cell.borrow_mut() { - let mut collector = EventCollector; + let mut collector = EventCollector { arrived: false }; let _ = event_loop.pump_app_events(timeout, &mut collector); + arrived = collector.arrived; } }); } @@ -1447,6 +1497,7 @@ impl WgpuCanvas { .remove(&self.window_id.unwrap()) .unwrap_or_default() }); + arrived |= !events.is_empty(); for event in events { match event { @@ -1563,6 +1614,7 @@ impl WgpuCanvas { // Process pending events from web callbacks let events: Vec = self.pending_events.borrow_mut().drain(..).collect(); + arrived |= !events.is_empty(); for event in events { match &event { WindowEvent::CursorPos(x, y, _) => { @@ -1579,6 +1631,7 @@ impl WgpuCanvas { let _ = self.out_events.send(event); } } + arrived } /// Gets the current surface texture for rendering. From 3fcc9a8eba26c63e13dec100bc599432fad2099f Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 26 Sep 2026 17:31:17 +0200 Subject: [PATCH 49/50] A window says when it gains or loses focus and when it goes out of sight winit's Focused becomes WindowEvent::Focus, which was declared and never sent. Occluded, and an Android activity suspending or resuming, become WindowEvent::Iconify. --- src/window/wgpu_canvas.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index 1645c66a8..ae60ef91b 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -273,6 +273,14 @@ pub(crate) fn collect_window_event(window_id: winit::window::WindowId, event: Wi WinitWindowEvent::ModifiersChanged(new_modifiers) => { vec![PendingEvent::Modifiers(new_modifiers.state())] } + WinitWindowEvent::Focused(focused) => { + vec![PendingEvent::WindowEvent(WindowEvent::Focus(focused))] + } + // Minimised, covered or on another space: out of sight, as a + // backgrounded app is. + WinitWindowEvent::Occluded(hidden) => { + vec![PendingEvent::WindowEvent(WindowEvent::Iconify(hidden))] + } WinitWindowEvent::DroppedFile(path) => { DROPPED_FILES.with(|dropped| dropped.borrow_mut().push((window_id, path))); vec![] @@ -1442,6 +1450,10 @@ impl WgpuCanvas { let lifecycle: Vec = LIFECYCLE_EVENTS.with(|events| events.borrow_mut().drain(..).collect()); for event in lifecycle { + // Told to the app too: an activity in the background is + // an iconified window. + let backgrounded = matches!(event, LifecycleEvent::Suspended); + let _ = self.out_events.send(WindowEvent::Iconify(backgrounded)); match event { LifecycleEvent::Suspended => { self.surface = None; From 5498f39667827efad0117e350f2ba1688f6a2b93 Mon Sep 17 00:00:00 2001 From: Dragos Daian Date: Sat, 26 Sep 2026 18:03:47 +0200 Subject: [PATCH 50/50] An app sent back, brought back or short of memory says so WindowEvent::LowMemory is new: winit's memory_warning on iOS and Android. iOS now passes suspended and resumed on as Iconify, as Android does, through one lifecycle queue poll_events drains on every native target. --- src/event/window_event.rs | 3 +++ src/window/ios.rs | 12 +++++++++++ src/window/wgpu_canvas.rs | 42 +++++++++++++++++++++++++++++---------- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/event/window_event.rs b/src/event/window_event.rs index 354577093..be35cc80e 100644 --- a/src/event/window_event.rs +++ b/src/event/window_event.rs @@ -35,6 +35,9 @@ pub enum WindowEvent { CharModifiers(char, Modifiers), /// A touch event occurred (id, x, y, action, modifiers). Touch(u64, f64, f64, TouchAction, Modifiers), + /// The system is short of memory: what can be rebuilt is worth freeing. + /// Sent on iOS and Android; a desktop pages instead. + LowMemory, } use WindowEvent::*; diff --git a/src/window/ios.rs b/src/window/ios.rs index ec6ed710e..f43d44ede 100644 --- a/src/window/ios.rs +++ b/src/window/ios.rs @@ -9,6 +9,7 @@ //! `while window.render().await` loop yield back to UIKit between frames — //! the iOS analogue of the wasm path awaiting `requestAnimationFrame`. +use super::wgpu_canvas::{push_lifecycle, LifecycleEvent}; use std::cell::Cell; use std::future::Future; use std::pin::Pin; @@ -115,10 +116,21 @@ impl IosApp { impl ApplicationHandler for IosApp { fn resumed(&mut self, event_loop: &ActiveEventLoop) { event_loop.set_control_flow(ControlFlow::Poll); + if self.started { + push_lifecycle(LifecycleEvent::Resumed); + } self.started = true; self.poll(event_loop); } + fn suspended(&mut self, _event_loop: &ActiveEventLoop) { + push_lifecycle(LifecycleEvent::Suspended); + } + + fn memory_warning(&mut self, _event_loop: &ActiveEventLoop) { + push_lifecycle(LifecycleEvent::LowMemory); + } + fn window_event( &mut self, _event_loop: &ActiveEventLoop, diff --git a/src/window/wgpu_canvas.rs b/src/window/wgpu_canvas.rs index ae60ef91b..c3b8a71d8 100644 --- a/src/window/wgpu_canvas.rs +++ b/src/window/wgpu_canvas.rs @@ -129,14 +129,28 @@ thread_local! { thread_local! { static ANDROID_APP: RefCell> = const { RefCell::new(None) }; +} + +#[cfg(not(target_arch = "wasm32"))] +thread_local! { static LIFECYCLE_EVENTS: RefCell> = const { RefCell::new(Vec::new()) }; } -#[cfg(target_os = "android")] +/// What the application, rather than a window, was told, kept until +/// `poll_events` hands it on. +#[cfg(not(target_arch = "wasm32"))] +#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))] #[derive(Clone, Copy, Debug)] -enum LifecycleEvent { +pub(crate) enum LifecycleEvent { Resumed, Suspended, + LowMemory, +} + +/// Queue an application event for the next `poll_events`. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn push_lifecycle(event: LifecycleEvent) { + LIFECYCLE_EVENTS.with(|events| events.borrow_mut().push(event)); } #[cfg(any(target_os = "android", test))] @@ -1418,6 +1432,10 @@ impl WgpuCanvas { }); } + fn memory_warning(&mut self, _event_loop: &ActiveEventLoop) { + push_lifecycle(LifecycleEvent::LowMemory); + } + fn window_event( &mut self, _event_loop: &ActiveEventLoop, @@ -1442,19 +1460,23 @@ impl WgpuCanvas { }); } - // Android: the native window lives only between Resumed and - // Suspended, so the surface must die and be reborn with it. While - // it is gone `get_current_texture` returns None and frames skip. - #[cfg(target_os = "android")] + // Told to the app as window events: an app in the background is + // an iconified window. Android: the native window lives only + // between Resumed and Suspended, so the surface must die and be + // reborn with it; while it is gone frames skip. + #[cfg(not(target_arch = "wasm32"))] { let lifecycle: Vec = LIFECYCLE_EVENTS.with(|events| events.borrow_mut().drain(..).collect()); for event in lifecycle { - // Told to the app too: an activity in the background is - // an iconified window. - let backgrounded = matches!(event, LifecycleEvent::Suspended); - let _ = self.out_events.send(WindowEvent::Iconify(backgrounded)); + let _ = self.out_events.send(match event { + LifecycleEvent::LowMemory => WindowEvent::LowMemory, + LifecycleEvent::Suspended => WindowEvent::Iconify(true), + LifecycleEvent::Resumed => WindowEvent::Iconify(false), + }); + #[cfg(target_os = "android")] match event { + LifecycleEvent::LowMemory => {} LifecycleEvent::Suspended => { self.surface = None; }