unlit3d/winit/presenter.rs
1//! The presentation path: the window surface, and how a frame reaches it.
2//!
3//! A scene presents one of two ways, chosen with [`PresentationPath`]. The plain
4//! one draws straight into the swap chain: one pass, no copy. The offscreen
5//! one draws the scene into a persistent target
6//! ([`offscreen::PersistentTarget`](crate::offscreen::PersistentTarget))
7//! and adds a blit pass after it that copies the target over the swap
8//! chain. The target survives present, so a screenshot reads the last
9//! frame back from it, and the swap chain's format or size changes never
10//! touch the scene's pipeline -- which is why an MCP-driven scene asks for
11//! it.
12//!
13//! [`build_presenter`] constructs the [`Presenter`] component once the GPU
14//! context exists, and [`setup_presenter`] spawns it. A single-world
15//! application drives the result with [`present_frames`],
16//! [`pump_presented`] and [`resize_presented`]; a host that keeps the
17//! render world in a sub-world -- multi-world runtimes -- spawns its own
18//! pump and calls [`Presenter::present`] itself.
19
20use std::cell::RefCell;
21use std::rc::Rc;
22use std::sync::Arc;
23use std::sync::Mutex;
24use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
25
26use unlit_ecs::World;
27use unlit_ecs::bundle;
28use unlit_ecs::sync;
29use unlit_ecs::{Command, Entity, EntityAccessError};
30
31use crate::attachments::FrameAttachments;
32use crate::blit::BlitSource;
33use crate::frame::{Frame, FrameBehaviourOrder, FrameSkip, OnFrame, dispatch_frame};
34use crate::offscreen::{PersistentTarget, TargetConfig};
35use crate::pass::{FramePass, FramePassOrder, Pass};
36use crate::renderer::Renderer;
37use crate::source::Source;
38use crate::winit::builtin::{Resumed, window};
39use crate::winit::event::{OnAboutToWait, OnWindowEvent};
40use crate::winit::surface::WindowSurface;
41use winit::event::WindowEvent;
42use winit::window::Window;
43
44/// Why the presentation path could not be built, or a frame could not be
45/// presented.
46#[derive(Debug, thiserror::Error)]
47#[non_exhaustive]
48pub enum PresenterError {
49 /// The host world carries no window to present to.
50 #[error("the scene setup found no window")]
51 NoWindow,
52 /// The window surface could not be built from the display handle.
53 #[error("failed to build the presentation surface: {0}")]
54 Surface(#[from] wgpu::CreateSurfaceError),
55 /// The render sub-world is not reachable from the host.
56 #[error("the scene setup found no render world: {0}")]
57 NoRenderWorld(EntityAccessError),
58 /// The render world carries no renderer at the named entity.
59 #[error("the scene setup found no renderer: {0}")]
60 NoRenderer(EntityAccessError),
61 /// The offscreen target could not be bound to the scene pass.
62 #[error("failed to bind the offscreen target: {0}")]
63 Target(EntityAccessError),
64 /// A resource the render world was expected to carry is gone at runtime.
65 #[error("the presentation path lost a render-world resource: {0}")]
66 MissingResource(#[from] EntityAccessError),
67}
68
69/// The order a scene's own pass records at, ahead of the copying pass.
70///
71/// A caller building the render world spawns its pass with this order, so
72/// the pass the offscreen path adds lands after it.
73pub const SCENE_PASS_ORDER: FramePassOrder = FramePassOrder(0);
74
75/// The order the offscreen path's copying pass records at: after the
76/// scene's own, so the target it samples holds this frame's picture.
77pub const BLIT_PASS_ORDER: FramePassOrder = FramePassOrder(1);
78
79/// The frame-behaviour order [`present_frames`] runs at: after the default
80/// band, so every behaviour that feeds the frame has run, and before
81/// behaviours that read its result state an order of their own.
82pub const PRESENT_ORDER: i32 = 10;
83
84/// How a scene reaches the window's swap chain.
85///
86/// This does not choose the final render destination: both paths present to
87/// the window. They differ only in whether the scene first draws into a
88/// persistent intermediate texture. Pure offscreen and empty-target rendering
89/// use [`PersistentTarget`] or an unattached pass directly, without a presenter.
90///
91/// [`PresentationPath::Surface`] draws straight into the swap chain.
92/// [`PresentationPath::Intermediate`] adds a persistent texture and a blit;
93/// that texture is readable after present, which makes it suitable for MCP
94/// screenshots, post-processing and scaled presentation.
95#[derive(Clone, Copy, Debug)]
96pub enum PresentationPath {
97 /// Draw the scene straight into the swap chain.
98 Surface {
99 /// The sample count the swap-chain frames resolve with.
100 samples: u32,
101 /// Whether swap-chain frames carry a depth-stencil attachment.
102 with_depth: bool,
103 },
104 /// Draw the scene into a persistent intermediate texture, then blit it
105 /// over the swap chain. The texture remains readable after presentation,
106 /// and its size can scale with the window or stay fixed.
107 Intermediate(TargetConfig),
108}
109
110impl Default for PresentationPath {
111 /// The plain window: a swap-chain frame at four samples with depth, the
112 /// presentation path's long-standing default.
113 fn default() -> Self {
114 Self::Surface {
115 samples: crate::offscreen::OFFSCREEN_SAMPLES,
116 with_depth: true,
117 }
118 }
119}
120
121/// Everything needed to render a frame and present it.
122///
123/// The presenter is a component of its own entity, built by
124/// [`build_presenter`]: everything it holds arrives after the GPU request
125/// resolves, and a component set fixed at spawn has no room for that.
126pub struct Presenter {
127 /// The render sub-world the frames are presented from; `None` when the
128 /// renderer, the passes and the presenter share the host world itself.
129 render: Option<Entity>,
130 /// The renderer inside the render world.
131 renderer: Entity,
132 /// The pass the scene's own sources draw into.
133 scene_pass: Entity,
134 /// The pass that copies the offscreen target over the surface; `None`
135 /// when the scene presents itself.
136 blit: Option<Blit>,
137 /// The swap chain wrapper.
138 surface: WindowSurface,
139 /// The presenting pass's attachments to the swap chain: the scene's own
140 /// sample count without the offscreen path, one sample and no depth
141 /// with it, since the blit covers the target with one triangle.
142 attachments: FrameAttachments,
143 /// The persistent target the scene draws into, when the scene's frames
144 /// land offscreen.
145 target: Option<PersistentTarget>,
146 /// The size used until the window reports its own.
147 pub fallback_size: (u32, u32),
148}
149
150/// The pass that copies the offscreen target into the swap chain.
151struct Blit {
152 /// The pass in the render world, ordered after every other pass.
153 pass: Entity,
154}
155
156impl Presenter {
157 /// The pass that draws into the swap chain.
158 ///
159 /// The offscreen target sits in front of it when the blit pass exists;
160 /// otherwise the scene's own pass draws straight into the surface.
161 fn presenting(&self) -> Entity {
162 self.blit.as_ref().map_or(self.scene_pass, |blit| blit.pass)
163 }
164
165 /// Draw the frame's result into the swap chain and present it.
166 ///
167 /// The presenting pass parks first and takes the frame's view only
168 /// after the swap chain image has been acquired. Between the two, and
169 /// after the image has been presented and destroyed, the pass states
170 /// no attachment -- so a frame rendered by anything other than this
171 /// loop, an MCP tool's screenshot of the scene, skips the pass rather
172 /// than recording into a dead swap-chain image.
173 ///
174 /// A surface released by [`Self::release`] is opened again here, so a host
175 /// that suspended only has to keep presenting.
176 ///
177 /// # Errors
178 ///
179 /// Returns an error when the render world's renderer or its resources
180 /// are gone, or the window can no longer back a surface. A render
181 /// sub-world that is gone presents nothing and returns `Ok`.
182 pub fn present(&mut self, host: &World) -> Result<(), PresenterError> {
183 let mut nested = match self.render {
184 Some(render) => match host.root_scope().query_one::<&World>(render) {
185 Ok(nested) => Some(nested),
186 Err(_) => return Ok(()),
187 },
188 None => None,
189 };
190 let world = match nested.as_mut() {
191 Some(nested) => nested.get(),
192 None => host,
193 };
194 let presenting = self.presenting();
195 if self.surface.is_released() {
196 let mut renderer_row = world.root_scope().query_one::<&Renderer>(self.renderer)?;
197 self.surface
198 .build(world, renderer_row.get())
199 .map_err(PresenterError::Surface)?;
200 }
201 let Some(swap_frame) =
202 self.surface
203 .acquire_for(world, self.renderer, presenting, Some(&self.attachments))
204 else {
205 return Ok(());
206 };
207 let mut renderer_row = world
208 .root_scope()
209 .query_one::<&mut Renderer>(self.renderer)?;
210 renderer_row.get().render(world);
211 let context = renderer_row.get().context();
212 let queue = context.queue(&world.root_scope())?;
213 swap_frame.present_and_park(world, &queue, presenting);
214 Ok(())
215 }
216
217 /// Match the surface, the swap-chain attachments and the offscreen
218 /// target to a new size.
219 ///
220 /// The surface resizes first and reports the size it ended up with; the
221 /// swap chain's attachments follow it, and the offscreen target is
222 /// rebuilt for that size. The target's texture and view are replaced in
223 /// place -- their resource nodes keep their ids -- so the blit reading
224 /// the view rebuilds its bind group through its own dependency, without
225 /// the presenter touching the source.
226 ///
227 /// # Errors
228 ///
229 /// Returns an error when the render world or its resources are gone.
230 pub fn resize(&mut self, host: &World, size: (u32, u32)) -> Result<(), PresenterError> {
231 let mut nested = match self.render {
232 Some(render) => Some(
233 host.root_scope()
234 .query_one::<&World>(render)
235 .map_err(PresenterError::MissingResource)?,
236 ),
237 None => None,
238 };
239 let world = match nested.as_mut() {
240 Some(nested) => nested.get(),
241 None => host,
242 };
243 let size = {
244 let mut renderer = world
245 .root_scope()
246 .query_one::<&mut Renderer>(self.renderer)?;
247 self.surface
248 .resize(world, &mut *renderer.get(), size.0, size.1);
249 self.surface.size()
250 };
251 self.attachments.resize(world, size);
252 let Some(target) = self.target.as_mut() else {
253 return Ok(());
254 };
255 let context = {
256 let mut renderer = world.root_scope().query_one::<&Renderer>(self.renderer)?;
257 renderer.get().context()
258 };
259 let device = context.device(&world.root_scope())?;
260 target
261 .resize(world, context, &device, size)
262 .map_err(PresenterError::MissingResource)?;
263 Ok(())
264 }
265
266 /// Drop the swap chain the platform is about to invalidate, keeping
267 /// everything the scene drew from.
268 ///
269 /// The presenting pass parks first: it still names a view of the swap
270 /// chain, and that id is what keeps the view alive -- parking it drops
271 /// the view, so the view goes with the surface this release collects.
272 /// The scene keeps its state and presents again through a surface built
273 /// from the same window.
274 ///
275 /// # Errors
276 ///
277 /// Returns an error when the render world or its renderer is gone.
278 pub fn release(&mut self, host: &World) -> Result<(), PresenterError> {
279 let mut nested = match self.render {
280 Some(render) => Some(
281 host.root_scope()
282 .query_one::<&World>(render)
283 .map_err(PresenterError::MissingResource)?,
284 ),
285 None => None,
286 };
287 let world = match nested.as_mut() {
288 Some(nested) => nested.get(),
289 None => host,
290 };
291 let presenting = self.presenting();
292 let _ = world
293 .root_scope()
294 .query_one::<&mut FramePass>(presenting)
295 .map(|mut pass| pass.get().park());
296 let mut renderer_row = world
297 .root_scope()
298 .query_one::<&mut Renderer>(self.renderer)?;
299 self.surface.release(world, &mut *renderer_row.get());
300 Ok(())
301 }
302}
303
304/// Build the presentation path for a live render world, as a
305/// [`Presenter`] component value.
306///
307/// `render` names the sub-world the renderer and the passes live in, or
308/// `None` when they share `world` itself; `renderer` and `scene_pass` name
309/// the renderer and the pass the scene's own sources draw into, all already
310/// filled in. The window comes from `world`; when it has not reported a
311/// size yet, `fallback_size` stands in.
312///
313/// `path` chooses direct presentation or an intermediate texture plus blit.
314///
315/// # Errors
316///
317/// Returns an error when `world` has no window, the surface cannot be
318/// built, or the render world, its renderer or its targets are gone.
319#[expect(
320 clippy::too_many_arguments,
321 reason = "one parameter per scene fixture the host already holds"
322)]
323pub fn build_presenter(
324 world: &mut World,
325 render: Option<Entity>,
326 renderer: Entity,
327 scene_pass: Entity,
328 surface: wgpu::Surface<'static>,
329 instance: &wgpu::Instance,
330 adapter: &wgpu::Adapter,
331 fallback_size: (u32, u32),
332 path: PresentationPath,
333) -> Result<Presenter, PresenterError> {
334 let window = window(world).ok_or(PresenterError::NoWindow)?;
335 let raw_size = window.inner_size();
336 let size = if raw_size.width == 0 || raw_size.height == 0 {
337 fallback_size
338 } else {
339 (raw_size.width, raw_size.height)
340 };
341 let (blit, surface, attachments, target) = match render {
342 Some(render) => {
343 let mut nested = world
344 .root_scope()
345 .query_one::<&mut World>(render)
346 .map_err(PresenterError::NoRenderWorld)?;
347 build_in(
348 nested.get(),
349 renderer,
350 scene_pass,
351 instance,
352 adapter,
353 window,
354 surface,
355 size,
356 path,
357 )?
358 }
359 None => build_in(
360 world, renderer, scene_pass, instance, adapter, window, surface, size, path,
361 )?,
362 };
363 Ok(Presenter {
364 render,
365 renderer,
366 scene_pass,
367 blit,
368 surface,
369 attachments,
370 target,
371 fallback_size,
372 })
373}
374
375/// Build the presentation path inside the render world itself.
376///
377/// Returns the blit pass when the scene draws offscreen, the opened window
378/// surface, the presenting pass's swap-chain attachments, and the bound
379/// offscreen target when there is one.
380#[expect(
381 clippy::too_many_arguments,
382 reason = "one parameter per scene fixture the host already holds"
383)]
384fn build_in(
385 world: &mut World,
386 renderer: Entity,
387 scene_pass: Entity,
388 instance: &wgpu::Instance,
389 adapter: &wgpu::Adapter,
390 window: Arc<Window>,
391 surface: wgpu::Surface<'static>,
392 size: (u32, u32),
393 path: PresentationPath,
394) -> Result<
395 (
396 Option<Blit>,
397 WindowSurface,
398 FrameAttachments,
399 Option<PersistentTarget>,
400 ),
401 PresenterError,
402> {
403 let context = world
404 .root_scope()
405 .query_one::<&Renderer>(renderer)
406 .map_err(PresenterError::NoRenderer)
407 .map(|mut row| row.get().context())?;
408 // The presenting pass's swap-chain attachments: without the offscreen
409 // path it is the scene's own pass, with the scene's sample count and
410 // depth; with it, the blit covers the target with one triangle -- one
411 // sample and no depth.
412 let (samples, with_depth) = match path {
413 PresentationPath::Surface {
414 samples,
415 with_depth,
416 } => (samples, with_depth),
417 PresentationPath::Intermediate(_) => (1, false),
418 };
419 let target = match path {
420 PresentationPath::Surface { .. } => None,
421 PresentationPath::Intermediate(config) => {
422 let device = context.device(&world.root_scope())?;
423 Some(
424 PersistentTarget::bind(
425 world,
426 context,
427 scene_pass,
428 &device,
429 config.size.resolve(size),
430 config,
431 )
432 .map_err(PresenterError::Target)?,
433 )
434 }
435 };
436 // The blit pass reads the target's view. A resize replaces that view in
437 // place, so the source is never patched: the bind group rebuilds
438 // through its own dependency on the view.
439 let blit = match target.as_ref() {
440 None => None,
441 Some(target) => {
442 let blit_pass = world.root_scope_mut().spawn(Pass::new(BLIT_PASS_ORDER));
443 world
444 .scope_mut(blit_pass)?
445 .spawn(bundle![Source::new(BlitSource::new(target.view.clone()))]);
446 Some(Blit { pass: blit_pass })
447 }
448 };
449 let (surface, attachments) = {
450 let mut renderer_row = world
451 .root_scope()
452 .query_one::<&mut Renderer>(renderer)
453 .map_err(PresenterError::NoRenderer)?;
454 let renderer = renderer_row.get();
455 {
456 let surface = WindowSurface::new(world, renderer, instance, adapter, window, surface);
457 let attachments = FrameAttachments::new(
458 world,
459 renderer.context(),
460 surface.color_format(),
461 surface.size(),
462 samples,
463 with_depth,
464 );
465 (surface, attachments)
466 }
467 };
468 Ok((blit, surface, attachments, target))
469}
470
471/// Build the presentation path and spawn its [`Presenter`], answering the
472/// entity it landed on.
473///
474/// See [`build_presenter`] for the arguments.
475///
476/// # Errors
477///
478/// Returns the same errors as [`build_presenter`].
479#[expect(
480 clippy::too_many_arguments,
481 reason = "one parameter per scene fixture the host already holds"
482)]
483pub fn setup_presenter(
484 world: &mut World,
485 render: Option<Entity>,
486 renderer: Entity,
487 scene_pass: Entity,
488 surface: wgpu::Surface<'static>,
489 instance: &wgpu::Instance,
490 adapter: &wgpu::Adapter,
491 fallback_size: (u32, u32),
492 path: PresentationPath,
493) -> Result<Entity, PresenterError> {
494 let presenter = build_presenter(
495 world,
496 render,
497 renderer,
498 scene_pass,
499 surface,
500 instance,
501 adapter,
502 fallback_size,
503 path,
504 )?;
505 let entity = world.root_scope_mut().spawn(bundle![presenter]);
506 Ok(entity)
507}
508
509/// One frame's dispatch, deferred to the command apply.
510///
511/// The frame behaviours need a mutable world, and they run between two
512/// event callbacks -- so the pump and the redraw request queue this command
513/// instead of drawing inline.
514#[derive(Clone)]
515struct DrawFrame {
516 /// The instant the previous frame dispatched, shared by both producers.
517 last: Arc<Mutex<Option<web_time::Instant>>>,
518 /// The index the next dispatched frame takes.
519 index: Arc<AtomicU32>,
520 /// Set while a draw is queued but not yet applied, so the pump and a
521 /// redraw request in the same turn coalesce into one frame.
522 pending: Arc<AtomicBool>,
523 /// Work the host does right before the dispatch, when it has any.
524 pre_dispatch: Option<SharedHostPump>,
525}
526
527/// Work the host does before a frame dispatches, for the world being dispatched.
528pub type HostPump = Box<dyn FnMut(&mut World)>;
529
530/// The same work, shared by the two producers that queue a frame.
531type SharedHostPump = Rc<RefCell<HostPump>>;
532
533impl Command for DrawFrame {
534 fn apply(self: Box<Self>, world: &mut World) -> Result<(), EntityAccessError> {
535 let now = web_time::Instant::now();
536 let delta_time = sync::lock(&self.last)
537 .replace(now)
538 .map_or(0.0, |last| (now - last).as_secs_f32().min(0.1));
539 let size = window(world)
540 .map(|window| {
541 let size = window.inner_size();
542 (size.width, size.height)
543 })
544 .or_else(|| {
545 world
546 .root_scope()
547 .query::<(Entity, &Presenter)>()
548 .iter()
549 .next()
550 .map(|(_, presenter)| presenter.fallback_size)
551 })
552 .unwrap_or((1, 1));
553 let mut frame = Frame {
554 delta_time,
555 index: self.index.fetch_add(1, Ordering::Relaxed),
556 size,
557 };
558 if let Some(pre_dispatch) = &self.pre_dispatch {
559 (pre_dispatch.borrow_mut())(world);
560 }
561 dispatch_frame(world, &mut frame);
562 // A frame has been dispatched, so the next producer may queue one.
563 self.pending.store(false, Ordering::Release);
564 Ok(())
565 }
566}
567
568/// The frame behaviour that presents every dispatched frame: draw the
569/// frame's result into the swap chain.
570///
571/// A single-world application spawns this once; the frame loop that
572/// dispatches frames is [`pump_presented`]. The renderer walks every pass
573/// of the world here, so the scene's own pass -- and the offscreen path's
574/// copying pass, when there is one -- both record on this one call.
575///
576/// A frame the host skipped -- because its scene or its presentation path is
577/// not up yet -- is not presented either, so the behaviour that decides
578/// states an earlier order. Neither is a frame the winit host reports the
579/// app suspended for; a world with no winit host counts as resumed. When a
580/// world carries more than one presenter, the first one the query yields
581/// presents.
582pub fn present_frames() -> OnFrame {
583 OnFrame::with_order(
584 FrameBehaviourOrder(PRESENT_ORDER),
585 move |world, _entity, _frame| {
586 // A suspended winit host has no surface worth presenting into.
587 let resumed = world
588 .root_scope()
589 .query::<&Resumed>()
590 .iter()
591 .next()
592 .is_none_or(|state| state.0);
593 if !resumed || FrameSkip::is_skipped(world) {
594 return;
595 }
596 let mut rows = world.root_scope().query::<(Entity, &mut Presenter)>();
597 let Some((_, presenter)) = rows.iter().next() else {
598 return;
599 };
600 if let Err(error) = presenter.present(world) {
601 log::error!("failed to present a frame: {error}");
602 }
603 },
604 )
605}
606
607/// The behaviours that keep frames coming: one per turn of the event loop,
608/// plus one per compositor redraw request.
609///
610/// Redraw requests alone go quiet whenever the window stops being visible
611/// to the compositor, and a simulation left waiting on them freezes with
612/// it; the pump keeps the scene advancing on its own.
613pub fn pump_presented() -> (OnAboutToWait, OnWindowEvent) {
614 pump_presented_with(None)
615}
616
617/// `pump_presented`, with host work that runs before every frame dispatch.
618///
619/// A runtime that keeps its scene in a sub-world polls that world's own tasks
620/// and drains its command queue first; `pre_dispatch` receives the host world
621/// so it can do that on the same turn, before the behaviours that read what
622/// the work landed are dispatched.
623pub fn pump_presented_with(pre_dispatch: Option<HostPump>) -> (OnAboutToWait, OnWindowEvent) {
624 let pre_dispatch: Option<SharedHostPump> = pre_dispatch.map(|pump| Rc::new(RefCell::new(pump)));
625 let last: Arc<Mutex<Option<web_time::Instant>>> = Arc::default();
626 let index: Arc<AtomicU32> = Arc::default();
627 let pending: Arc<AtomicBool> = Arc::default();
628 let pump = {
629 let (last, index, pending) = (Arc::clone(&last), Arc::clone(&index), Arc::clone(&pending));
630 let pre_dispatch = pre_dispatch.clone();
631 OnAboutToWait::new(move |world, _entity, ()| {
632 queue_draw(world, &last, &index, &pending, &pre_dispatch);
633 })
634 };
635 let redraw = {
636 let (last, index, pending) = (Arc::clone(&last), Arc::clone(&index), Arc::clone(&pending));
637 let pre_dispatch = pre_dispatch.clone();
638 OnWindowEvent::new(move |world, _entity, payload| {
639 if matches!(payload.event, WindowEvent::RedrawRequested) {
640 queue_draw(world, &last, &index, &pending, &pre_dispatch);
641 }
642 })
643 };
644 (pump, redraw)
645}
646
647/// Queue one frame unless a queued one has not been applied yet.
648///
649/// The pump and a redraw request can both fire in the same turn of the
650/// event loop; without the flag that turn would dispatch two frames and
651/// advance the simulation twice.
652fn queue_draw(
653 world: &World,
654 last: &Arc<Mutex<Option<web_time::Instant>>>,
655 index: &Arc<AtomicU32>,
656 pending: &Arc<AtomicBool>,
657 pre_dispatch: &Option<SharedHostPump>,
658) {
659 if pending.swap(true, Ordering::AcqRel) {
660 return;
661 }
662 world.root_scope().queue().push(DrawFrame {
663 last: Arc::clone(last),
664 index: Arc::clone(index),
665 pending: Arc::clone(pending),
666 pre_dispatch: pre_dispatch.clone(),
667 });
668}
669
670/// The resize behaviour: match the presenter to the window's new size,
671/// skipping the degenerate zero size windows report on minimize.
672pub fn resize_presented() -> OnWindowEvent {
673 OnWindowEvent::new(move |world, _entity, payload| {
674 let WindowEvent::Resized(size) = &payload.event else {
675 return;
676 };
677 if size.width == 0 || size.height == 0 {
678 return;
679 }
680 let mut rows = world.root_scope().query::<(Entity, &mut Presenter)>();
681 let Some((_, presenter)) = rows.iter().next() else {
682 return;
683 };
684 if let Err(error) = presenter.resize(world, (size.width, size.height)) {
685 log::error!("failed to resize the presentation path: {error}");
686 }
687 })
688}