Skip to content

Commit fed93a0

Browse files
committed
Optionally resize Window canvas element to fit parent element (#4726)
Currently Bevy's web canvases are "fixed size". They are manually set to specific dimensions. This might be fine for some games and website layouts, but for sites with flexible layouts, or games that want to "fill" the browser window, Bevy doesn't provide the tools needed to make this easy out of the box. There are third party plugins like [bevy-web-resizer](https://github.com/frewsxcv/bevy-web-resizer/) that listen for window resizes, take the new dimensions, and resize the winit window accordingly. However this only covers a subset of cases and this is common enough functionality that it should be baked into Bevy. A significant motivating use case here is the [Bevy WASM Examples page](https://bevyengine.org/examples/). This scales the canvas to fit smaller windows (such as mobile). But this approach both breaks winit's mouse events and removes pixel-perfect rendering (which means we might be rendering too many or too few pixels). bevyengine/bevy-website#371 In an ideal world, winit would support this behavior out of the box. But unfortunately that seems blocked for now: rust-windowing/winit#2074. And it builds on the ResizeObserver api, which isn't supported in all browsers yet (and is only supported in very new versions of the popular browsers). While we wait for a complete winit solution, I've added a `fit_canvas_to_parent` option to WindowDescriptor / Window, which when enabled will listen for window resizes and resize the Bevy canvas/window to fit its parent element. This enables users to scale bevy canvases using arbitrary CSS, by "inheriting" their parents' size. Note that the wrapper element _is_ required because winit overrides the canvas sizing with absolute values on each resize. There is one limitation worth calling out here: while the majority of canvas resizes will be triggered by window resizes, modifying element layout at runtime (css animations, javascript-driven element changes, dev-tool-injected changes, etc) will not be detected here. I'm not aware of a good / efficient event-driven way to do this outside of the ResizeObserver api. In practice, window-resize-driven canvas resizing should cover the majority of use cases. Users that want to actively poll for element resizes can just do that (or we can build another feature and let people choose based on their specific needs). I also took the chance to make a couple of minor tweaks: * Made the `canvas` window setting available on all platforms. Users shouldn't need to deal with cargo feature selection to support web scenarios. We can just ignore the value on non-web platforms. I added documentation that explains this. * Removed the redundant "initial create windows" handler. With the addition of the code in this pr, the code duplication was untenable. This enables a number of patterns: ## Easy "fullscreen window" mode for the default canvas The "parent element" defaults to the `<body>` element. ```rust app .insert_resource(WindowDescriptor { fit_canvas_to_parent: true, ..default() }) ``` And CSS: ```css html, body { margin: 0; height: 100%; } ``` ## Fit custom canvas to "wrapper" parent element ```rust app .insert_resource(WindowDescriptor { fit_canvas_to_parent: true, canvas: Some("#bevy".to_string()), ..default() }) ``` And the HTML: ```html <div style="width: 50%; height: 100%"> <canvas id="bevy"></canvas> </div> ```
1 parent b6eeded commit fed93a0

4 files changed

Lines changed: 154 additions & 26 deletions

File tree

crates/bevy_window/src/window.rs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ pub struct Window {
165165
raw_window_handle: RawWindowHandleWrapper,
166166
focused: bool,
167167
mode: WindowMode,
168-
#[cfg(target_arch = "wasm32")]
169-
pub canvas: Option<String>,
168+
canvas: Option<String>,
169+
fit_canvas_to_parent: bool,
170170
command_queue: Vec<WindowCommand>,
171171
}
172172

@@ -267,8 +267,8 @@ impl Window {
267267
raw_window_handle: RawWindowHandleWrapper::new(raw_window_handle),
268268
focused: true,
269269
mode: window_descriptor.mode,
270-
#[cfg(target_arch = "wasm32")]
271270
canvas: window_descriptor.canvas.clone(),
271+
fit_canvas_to_parent: window_descriptor.fit_canvas_to_parent,
272272
command_queue: Vec::new(),
273273
}
274274
}
@@ -600,6 +600,28 @@ impl Window {
600600
pub fn raw_window_handle(&self) -> RawWindowHandleWrapper {
601601
self.raw_window_handle.clone()
602602
}
603+
604+
/// The "html canvas" element selector. If set, this selector will be used to find a matching html canvas element,
605+
/// rather than creating a new one.
606+
/// Uses the [CSS selector format](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector).
607+
///
608+
/// This value has no effect on non-web platforms.
609+
#[inline]
610+
pub fn canvas(&self) -> Option<&str> {
611+
self.canvas.as_deref()
612+
}
613+
614+
/// Whether or not to fit the canvas element's size to its parent element's size.
615+
///
616+
/// **Warning**: this will not behave as expected for parents that set their size according to the size of their
617+
/// children. This creates a "feedback loop" that will result in the canvas growing on each resize. When using this
618+
/// feature, ensure the parent's size is not affected by its children.
619+
///
620+
/// This value has no effect on non-web platforms.
621+
#[inline]
622+
pub fn fit_canvas_to_parent(&self) -> bool {
623+
self.fit_canvas_to_parent
624+
}
603625
}
604626

605627
#[derive(Debug, Clone)]
@@ -625,8 +647,20 @@ pub struct WindowDescriptor {
625647
/// macOS X transparent works with winit out of the box, so this issue might be related to: <https://github.com/gfx-rs/wgpu/issues/687>
626648
/// Windows 11 is related to <https://github.com/rust-windowing/winit/issues/2082>
627649
pub transparent: bool,
628-
#[cfg(target_arch = "wasm32")]
650+
/// The "html canvas" element selector. If set, this selector will be used to find a matching html canvas element,
651+
/// rather than creating a new one.
652+
/// Uses the [CSS selector format](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector).
653+
///
654+
/// This value has no effect on non-web platforms.
629655
pub canvas: Option<String>,
656+
/// Whether or not to fit the canvas element's size to its parent element's size.
657+
///
658+
/// **Warning**: this will not behave as expected for parents that set their size according to the size of their
659+
/// children. This creates a "feedback loop" that will result in the canvas growing on each resize. When using this
660+
/// feature, ensure the parent's size is not affected by its children.
661+
///
662+
/// This value has no effect on non-web platforms.
663+
pub fit_canvas_to_parent: bool,
630664
}
631665

632666
impl Default for WindowDescriptor {
@@ -645,8 +679,8 @@ impl Default for WindowDescriptor {
645679
cursor_visible: true,
646680
mode: WindowMode::Windowed,
647681
transparent: false,
648-
#[cfg(target_arch = "wasm32")]
649682
canvas: None,
683+
fit_canvas_to_parent: false,
650684
}
651685
}
652686
}

crates/bevy_winit/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ raw-window-handle = "0.4.2"
3030
winit = { version = "0.26.0", default-features = false }
3131
wasm-bindgen = { version = "0.2" }
3232
web-sys = "0.3"
33+
crossbeam-channel = "0.5"
3334

3435
[package.metadata.docs.rs]
3536
features = ["x11"]

crates/bevy_winit/src/lib.rs

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
mod converters;
2+
#[cfg(target_arch = "wasm32")]
3+
mod web_resize;
24
mod winit_config;
35
mod winit_windows;
46

@@ -43,9 +45,15 @@ impl Plugin for WinitPlugin {
4345
.init_resource::<WinitSettings>()
4446
.set_runner(winit_runner)
4547
.add_system_to_stage(CoreStage::PostUpdate, change_window.label(ModifiesWindows));
48+
#[cfg(target_arch = "wasm32")]
49+
app.add_plugin(web_resize::CanvasParentResizePlugin);
4650
let event_loop = EventLoop::new();
47-
handle_initial_window_events(&mut app.world, &event_loop);
48-
app.insert_non_send_resource(event_loop);
51+
let mut create_window_reader = WinitCreateWindowReader::default();
52+
// Note that we create a window here "early" because WASM/WebGL requires the window to exist prior to initializing
53+
// the renderer.
54+
handle_create_window_events(&mut app.world, &event_loop, &mut create_window_reader.0);
55+
app.insert_resource(create_window_reader)
56+
.insert_non_send_resource(event_loop);
4957
}
5058
}
5159

@@ -271,19 +279,27 @@ impl Default for WinitPersistentState {
271279
}
272280
}
273281

282+
#[derive(Default)]
283+
struct WinitCreateWindowReader(ManualEventReader<CreateWindow>);
284+
274285
pub fn winit_runner_with(mut app: App) {
275286
let mut event_loop = app
276287
.world
277288
.remove_non_send_resource::<EventLoop<()>>()
278289
.unwrap();
279-
let mut create_window_event_reader = ManualEventReader::<CreateWindow>::default();
290+
let mut create_window_event_reader = app
291+
.world
292+
.remove_resource::<WinitCreateWindowReader>()
293+
.unwrap()
294+
.0;
280295
let mut app_exit_event_reader = ManualEventReader::<AppExit>::default();
281296
let mut redraw_event_reader = ManualEventReader::<RequestRedraw>::default();
282297
let mut winit_state = WinitPersistentState::default();
283298
app.world
284299
.insert_non_send_resource(event_loop.create_proxy());
285300

286301
let return_from_run = app.world.resource::<WinitSettings>().return_from_run;
302+
287303
trace!("Entering winit event loop");
288304

289305
let event_handler = move |event: Event<()>,
@@ -627,24 +643,18 @@ fn handle_create_window_events(
627643
window_created_events.send(WindowCreated {
628644
id: create_window_event.id,
629645
});
630-
}
631-
}
632646

633-
fn handle_initial_window_events(world: &mut World, event_loop: &EventLoop<()>) {
634-
let world = world.cell();
635-
let mut winit_windows = world.non_send_resource_mut::<WinitWindows>();
636-
let mut windows = world.resource_mut::<Windows>();
637-
let mut create_window_events = world.resource_mut::<Events<CreateWindow>>();
638-
let mut window_created_events = world.resource_mut::<Events<WindowCreated>>();
639-
for create_window_event in create_window_events.drain() {
640-
let window = winit_windows.create_window(
641-
event_loop,
642-
create_window_event.id,
643-
&create_window_event.descriptor,
644-
);
645-
windows.add(window);
646-
window_created_events.send(WindowCreated {
647-
id: create_window_event.id,
648-
});
647+
#[cfg(target_arch = "wasm32")]
648+
{
649+
let channel = world.resource_mut::<web_resize::CanvasParentResizeEventChannel>();
650+
if create_window_event.descriptor.fit_canvas_to_parent {
651+
let selector = if let Some(selector) = &create_window_event.descriptor.canvas {
652+
selector
653+
} else {
654+
web_resize::WINIT_CANVAS_SELECTOR
655+
};
656+
channel.listen_to_selector(create_window_event.id, selector);
657+
}
658+
}
649659
}
650660
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
use crate::WinitWindows;
2+
use bevy_app::{App, Plugin};
3+
use bevy_ecs::prelude::*;
4+
use bevy_window::WindowId;
5+
use crossbeam_channel::{Receiver, Sender};
6+
use wasm_bindgen::JsCast;
7+
use winit::dpi::LogicalSize;
8+
9+
pub(crate) struct CanvasParentResizePlugin;
10+
11+
impl Plugin for CanvasParentResizePlugin {
12+
fn build(&self, app: &mut App) {
13+
app.init_resource::<CanvasParentResizeEventChannel>()
14+
.add_system(canvas_parent_resize_event_handler);
15+
}
16+
}
17+
18+
struct ResizeEvent {
19+
size: LogicalSize<f32>,
20+
window_id: WindowId,
21+
}
22+
23+
pub(crate) struct CanvasParentResizeEventChannel {
24+
sender: Sender<ResizeEvent>,
25+
receiver: Receiver<ResizeEvent>,
26+
}
27+
28+
fn canvas_parent_resize_event_handler(
29+
winit_windows: Res<WinitWindows>,
30+
resize_events: Res<CanvasParentResizeEventChannel>,
31+
) {
32+
for event in resize_events.receiver.try_iter() {
33+
if let Some(window) = winit_windows.get_window(event.window_id) {
34+
window.set_inner_size(event.size);
35+
}
36+
}
37+
}
38+
39+
fn get_size(selector: &str) -> Option<LogicalSize<f32>> {
40+
let win = web_sys::window().unwrap();
41+
let doc = win.document().unwrap();
42+
let element = doc.query_selector(selector).ok()??;
43+
let parent_element = element.parent_element()?;
44+
let rect = parent_element.get_bounding_client_rect();
45+
return Some(winit::dpi::LogicalSize::new(
46+
rect.width() as f32,
47+
rect.height() as f32,
48+
));
49+
}
50+
51+
pub(crate) const WINIT_CANVAS_SELECTOR: &str = "canvas[data-raw-handle]";
52+
53+
impl Default for CanvasParentResizeEventChannel {
54+
fn default() -> Self {
55+
let (sender, receiver) = crossbeam_channel::unbounded();
56+
return Self { sender, receiver };
57+
}
58+
}
59+
60+
impl CanvasParentResizeEventChannel {
61+
pub(crate) fn listen_to_selector(&self, window_id: WindowId, selector: &str) {
62+
let sender = self.sender.clone();
63+
let owned_selector = selector.to_string();
64+
let resize = move || {
65+
if let Some(size) = get_size(&owned_selector) {
66+
sender.send(ResizeEvent { size, window_id }).unwrap();
67+
}
68+
};
69+
70+
// ensure resize happens on startup
71+
resize();
72+
73+
let closure = wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::Event| {
74+
resize();
75+
}) as Box<dyn FnMut(_)>);
76+
let window = web_sys::window().unwrap();
77+
78+
window
79+
.add_event_listener_with_callback("resize", closure.as_ref().unchecked_ref())
80+
.unwrap();
81+
closure.forget();
82+
}
83+
}

0 commit comments

Comments
 (0)