Skip to content

Commit 35c972c

Browse files
authored
Add example of rendering a Bevy scene into Slint (slint-ui#8380)
1 parent 0a0e761 commit 35c972c

File tree

9 files changed

+696
-5
lines changed

9 files changed

+696
-5
lines changed

.github/workflows/ci.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ jobs:
5858
extra_args: "--exclude ffmpeg --exclude gstreamer-player"
5959
- os: ubuntu-22.04
6060
rust_version: "nightly"
61+
# Bevy doesn't compile with 1.82 but requires a newer version
62+
- rust_version: "1.82"
63+
maybe_exclude_bevy: "--exclude bevy-example"
6164
exclude:
6265
- os: macos-14
6366
rust_version: "1.82"
@@ -103,12 +106,12 @@ jobs:
103106
cargo update -p typed-index-collections --precise 3.2.3
104107
fi
105108
- name: Run tests (not qt)
106-
run: cargo test --verbose --all-features --workspace ${{ matrix.extra_args }} --exclude slint-node --exclude pyslint --exclude test-driver-node --exclude slint-node --exclude test-driver-nodejs --exclude test-driver-cpp --exclude mcu-board-support --exclude mcu-embassy --exclude printerdemo_mcu --exclude uefi-demo --exclude slint-cpp --exclude slint-python -- --skip=_qt::t
109+
run: cargo test --verbose --all-features --workspace ${{ matrix.extra_args }} ${{ matrix.maybe_exclude_bevy }} --exclude slint-node --exclude pyslint --exclude test-driver-node --exclude slint-node --exclude test-driver-nodejs --exclude test-driver-cpp --exclude mcu-board-support --exclude mcu-embassy --exclude printerdemo_mcu --exclude uefi-demo --exclude slint-cpp --exclude slint-python -- --skip=_qt::t
107110
env:
108111
SLINT_CREATE_SCREENSHOTS: 1
109112
shell: bash
110113
- name: Run tests (qt)
111-
run: cargo test --verbose --all-features --workspace ${{ matrix.extra_args }} --exclude slint-node --exclude pyslint --exclude test-driver-node --exclude slint-node --exclude test-driver-nodejs --exclude test-driver-cpp --exclude mcu-board-support --exclude mcu-embassy --exclude printerdemo_mcu --exclude uefi-demo --exclude slint-cpp --exclude slint-python --bin test-driver-rust -- _qt --test-threads=1
114+
run: cargo test --verbose --all-features --workspace ${{ matrix.extra_args }} ${{ matrix.maybe_exclude_bevy }} --exclude slint-node --exclude pyslint --exclude test-driver-node --exclude slint-node --exclude test-driver-nodejs --exclude test-driver-cpp --exclude mcu-board-support --exclude mcu-embassy --exclude printerdemo_mcu --exclude uefi-demo --exclude slint-cpp --exclude slint-python --bin test-driver-rust -- _qt --test-threads=1
112115
shell: bash
113116
- name: Archive screenshots after failed tests
114117
if: ${{ failure() }}

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ members = [
1111
'api/python',
1212
'api/wasm-interpreter',
1313
'examples/7guis',
14+
"examples/bevy",
1415
'examples/gallery',
1516
'examples/imagefilter/rust',
1617
'examples/maps',

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ These examples demonstrate the main features of Slint and how to use them in dif
2424
| [Maps![Maps demo image](https://github.com/slint-ui/slint/assets/959326/f5e8cca6-dee1-4681-83da-88fec27f9a45 "OpenGL Underlay demo image")](./maps/) | A rust example that load image tiles asynchronously from OpenStreetMap server and allow panning and zooming. <br/> [Project...](./maps/) | |
2525
| [Virtual Keyboard![Virtual Keyboard demo image](https://user-images.githubusercontent.com/6715107/231668373-23faedf8-b42a-401d-b3a2-845d5e61252b.png "Virtual Keyboard demo image")](./virtual_keyboard/) | A Rust and C++ example that shows how to implement a custom virtual keyboard in Slint. <br/> [Project...](./virtual_keyboard/) | |
2626
| [7GUIs![7 GUI's demo image](https://user-images.githubusercontent.com/22800467/169002497-5b90e63b-5717-4290-8ac7-c618d9e2a4f1.png "7 GUI's demo image")](./7guis/) | Our implementations of the ["7GUIs"](https://7guis.github.io/7guis/) Tasks. <br/> [Project...](./7guis/) | |
27+
| [Slint & Bevy![Bevy demo image](https://github.com/user-attachments/assets/69785864-b6ae-40e1-8f62-4f70677d930e "Bevy demo image")](./7guis/) | A demo that shows how to embed [Bevy](https://bevyengine.org) into Slint <br/> [Project...](./bevy/) | |
2728

2829
#### External examples
2930
| Thumbnail | Description |

examples/bevy/Cargo.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright © SixtyFPS GmbH <[email protected]>
2+
# SPDX-License-Identifier: MIT
3+
4+
[package]
5+
name = "bevy-example"
6+
version = "1.12.0"
7+
authors = ["Slint Developers <[email protected]>"]
8+
edition = "2021"
9+
publish = false
10+
license = "MIT"
11+
description = "Slint Bevy Integration Example"
12+
13+
[[bin]]
14+
name = "bevy_example"
15+
path = "main.rs"
16+
17+
[dependencies]
18+
slint = { path = "../../api/rs/slint", features = ["unstable-wgpu-24"] }
19+
spin_on = { version = "0.1" }
20+
bevy = { version = "0.16.0", default-features = false, features = ["bevy_core_pipeline", "bevy_pbr", "bevy_window", "bevy_scene", "bevy_gltf", "bevy_log", "jpeg", "png", "tonemapping_luts", "multi_threaded"] }
21+
smol = { version = "2.0.0" }
22+
async-compat = { version = "0.2.4" }
23+
reqwest = { version = "0.12", features = ["stream"] }

examples/bevy/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!-- Copyright © SixtyFPS GmbH <[email protected]> ; SPDX-License-Identifier: MIT -->
2+
3+
### `bevy`
4+
5+
This example shows how to integrate [Bevy](https://bevyengine.org) 3D rendering into Slint, using WGPU.
6+
7+
The example can be run on desktop platforms.
8+
9+
![Screenshot of the Bevy Demo](https://github.com/user-attachments/assets/69785864-b6ae-40e1-8f62-4f70677d930e)
10+
11+
12+
On a desktop system, run the demo with the following command:
13+
```sh
14+
cargo run -p bevy-example
15+
```

examples/bevy/main.rs

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// Copyright © SixtyFPS GmbH <[email protected]>
2+
// SPDX-License-Identifier: MIT
3+
4+
use bevy::prelude::*;
5+
use slint::{Model, SharedString};
6+
7+
mod slint_bevy_adapter;
8+
mod web_asset;
9+
10+
slint::slint! {
11+
import { Palette, Button, ComboBox, GroupBox, GridBox, Slider, HorizontalBox, VerticalBox, ProgressIndicator } from "std-widgets.slint";
12+
export component AppWindow inherits Window {
13+
in property <image> texture <=> i.source;
14+
out property <length> requested-texture-width: i.width;
15+
out property <length> requested-texture-height: i.height;
16+
17+
in property <bool> show-loading-screen: false;
18+
in property <string> download-url;
19+
in property <percent> download-progress;
20+
21+
in property <[string]> available-models;
22+
callback load-model(index: int);
23+
24+
title: @tr("Slint & Bevy");
25+
preferred-width: 500px;
26+
preferred-height: 600px;
27+
28+
VerticalBox {
29+
alignment: start;
30+
Rectangle {
31+
background: Palette.alternate-background;
32+
33+
VerticalBox {
34+
Text {
35+
text: "This text is rendered using Slint. The animation below is rendered using Bevy code.";
36+
wrap: word-wrap;
37+
}
38+
39+
HorizontalBox {
40+
Text {
41+
text: "Select Model:";
42+
vertical-alignment: center;
43+
}
44+
ComboBox {
45+
model: root.available-models;
46+
selected(current-value) => { root.load-model(self.current-index) }
47+
}
48+
}
49+
}
50+
}
51+
52+
i := Image {
53+
image-fit: fill;
54+
width: 100%;
55+
height: 100%;
56+
preferred-width: self.source.width * 1px;
57+
preferred-height: self.source.height * 1px;
58+
59+
if show-loading-screen: Rectangle {
60+
background: Palette.background;
61+
VerticalBox {
62+
alignment: start;
63+
Text {
64+
horizontal-alignment: center;
65+
text: "Downloading Assets";
66+
}
67+
Text {
68+
text: download-url;
69+
overflow: elide;
70+
}
71+
ProgressIndicator {
72+
indeterminate: download-url.is-empty;
73+
progress: root.download-progress;
74+
}
75+
}
76+
}
77+
}
78+
}
79+
}
80+
}
81+
82+
fn main() -> Result<(), Box<dyn std::error::Error>> {
83+
let (model_selector_sender, model_selector_receiver) = smol::channel::bounded::<GLTFModel>(1);
84+
85+
let (download_progress_sender, download_progress_receiver) =
86+
smol::channel::bounded::<(SharedString, f32)>(5);
87+
88+
let (new_texture_receiver, control_message_sender) =
89+
spin_on::spin_on(slint_bevy_adapter::run_bevy_app_with_slint(
90+
|app| {
91+
app.add_plugins(web_asset::WebAssetReaderPlugin(download_progress_sender));
92+
},
93+
|mut app| {
94+
app.insert_resource(CameraPos(Vec3::new(3., 4.0, 4.0)))
95+
// .insert_resource(ModelBasePath("".into()))
96+
.insert_resource(ModelBasePath(
97+
"https://github.com/KhronosGroup/glTF-Sample-Assets/raw/refs/heads/main/"
98+
.into(),
99+
))
100+
.add_systems(Startup, setup)
101+
.add_systems(Update, reload_model_from_channel(model_selector_receiver))
102+
.add_systems(Update, animate_camera)
103+
.run();
104+
},
105+
))?;
106+
107+
let app_window = AppWindow::new().unwrap();
108+
let app2 = app_window.as_weak();
109+
110+
app_window.window().set_rendering_notifier(move |state, _| {
111+
let slint::RenderingState::BeforeRendering = state else { return };
112+
let Some(app) = app2.upgrade() else { return };
113+
app.window().request_redraw();
114+
let Ok(new_texture) = new_texture_receiver.try_recv() else { return };
115+
if let Some(old_texture) = app.get_texture().to_wgpu_24_texture() {
116+
let control_message_sender = control_message_sender.clone();
117+
slint::spawn_local(async move {
118+
control_message_sender
119+
.send(slint_bevy_adapter::ControlMessage::ReleaseFrontBufferTexture {
120+
texture: old_texture,
121+
})
122+
.await
123+
.unwrap();
124+
})
125+
.unwrap();
126+
}
127+
128+
let requested_width = app.get_requested_texture_width().round() as u32;
129+
let requested_height = app.get_requested_texture_height().round() as u32;
130+
if requested_width > 0 && requested_height > 0 {
131+
let control_message_sender = control_message_sender.clone();
132+
slint::spawn_local(async move {
133+
control_message_sender
134+
.send(slint_bevy_adapter::ControlMessage::ResizeBuffers {
135+
width: requested_width,
136+
height: requested_height,
137+
})
138+
.await
139+
.unwrap();
140+
})
141+
.unwrap();
142+
}
143+
144+
if let Ok(image) = new_texture.try_into() {
145+
app.set_texture(image);
146+
}
147+
})?;
148+
149+
let app_weak = app_window.as_weak();
150+
151+
slint::spawn_local(async move {
152+
loop {
153+
let Ok((url, progress)) = download_progress_receiver.recv().await else {
154+
break;
155+
};
156+
let Some(app) = app_weak.upgrade() else { return };
157+
app.set_download_url(url);
158+
app.set_download_progress(progress * 100.);
159+
app.set_show_loading_screen(progress < 1.0);
160+
}
161+
})
162+
.unwrap();
163+
164+
let models = slint::VecModel::from_slice(&[
165+
GLTFModel {
166+
name: "Damaged Helmet".into(),
167+
path: "Models/DamagedHelmet/glTF-Binary/DamagedHelmet.glb".into(),
168+
center: Vec3::new(3.0, 4.0, 4.0),
169+
},
170+
GLTFModel {
171+
name: "Fish".into(),
172+
path: "Models/BarramundiFish/glTF-Binary/BarramundiFish.glb".into(),
173+
center: Vec3::new(3.0, 2.0, 1.0),
174+
},
175+
GLTFModel {
176+
name: "Box".into(),
177+
path: "Models/Box/glTF-Binary/Box.glb".into(),
178+
center: Vec3::new(3.0, 4.0, 4.0),
179+
},
180+
]);
181+
182+
app_window
183+
.set_available_models(slint::ModelRc::new(models.clone().map(|model| model.name.clone())));
184+
185+
model_selector_sender.send_blocking(models.row_data(0).unwrap()).unwrap();
186+
187+
app_window.on_load_model(move |index| {
188+
let model = models.row_data(index as usize).unwrap();
189+
let model_selector_sender = model_selector_sender.clone();
190+
slint::spawn_local(async move {
191+
model_selector_sender.send(model).await.ok();
192+
})
193+
.unwrap();
194+
});
195+
196+
app_window.run()?;
197+
198+
Ok(())
199+
}
200+
201+
#[derive(Clone)]
202+
struct GLTFModel {
203+
name: SharedString,
204+
path: SharedString,
205+
center: Vec3,
206+
}
207+
208+
#[derive(Resource)]
209+
struct CameraPos(Vec3);
210+
211+
#[derive(Resource)]
212+
struct ModelBasePath(String);
213+
214+
fn setup(mut commands: Commands, camera: Res<CameraPos>) {
215+
commands.spawn(DirectionalLight { illuminance: 100_000.0, ..default() });
216+
commands.spawn((
217+
Camera3d::default(),
218+
Transform::from_translation(camera.0).looking_at(Vec3::new(0.0, -0.5, 0.0), Vec3::Y),
219+
PointLight { color: Color::linear_rgb(0.5, 0., 0.), ..default() },
220+
));
221+
222+
/*
223+
commands.spawn(SceneRoot(
224+
//asset_server.load(GltfAssetLabel::Scene(0).from_asset("DamagedHelmet.glb")),
225+
asset_server.load(
226+
GltfAssetLabel::Scene(0)
227+
.from_asset("Models/DamagedHelmet/glTF-Binary/DamagedHelmet.glb"), // GltfAssetLabel::Scene(0)
228+
// .from_asset("https://github.com/KhronosGroup/glTF-Sample-Assets/raw/refs/heads/main/Models/DamagedHelmet/glTF-Binary/DamagedHelmet.glb"),
229+
),
230+
));
231+
*/
232+
}
233+
234+
fn reload_model_from_channel(
235+
receiver: smol::channel::Receiver<GLTFModel>,
236+
) -> impl FnMut(
237+
Commands,
238+
Res<AssetServer>,
239+
Query<Entity, With<SceneRoot>>,
240+
ResMut<CameraPos>,
241+
Res<ModelBasePath>,
242+
) {
243+
move |mut commands, asset_server, loaded_bundles, mut camera, base_path| {
244+
let Ok(new_model) = receiver.try_recv() else {
245+
return;
246+
};
247+
for loaded_bundle in loaded_bundles.iter() {
248+
commands.entity(loaded_bundle).despawn();
249+
}
250+
commands.spawn(SceneRoot(
251+
//asset_server.load(GltfAssetLabel::Scene(0).from_asset("DamagedHelmet.glb")),
252+
asset_server.load(
253+
GltfAssetLabel::Scene(0).from_asset(format!("{}{}", base_path.0, new_model.path)),
254+
),
255+
));
256+
camera.0 = new_model.center;
257+
}
258+
}
259+
260+
fn animate_camera(
261+
mut cameras: Query<&mut Transform, With<Camera3d>>,
262+
time: Res<Time>,
263+
camera: Res<CameraPos>,
264+
) {
265+
let now = time.elapsed_secs();
266+
for mut transform in cameras.iter_mut() {
267+
// transform.translation = vec3(ops::cos(now), 0.0, ops::sin(now)) * vec3(3.0, 4.0, 4.0);
268+
transform.translation = vec3(ops::cos(now), 0.0, ops::sin(now)) * camera.0;
269+
transform.look_at(Vec3::new(0.0, 0.0, 0.0), Vec3::Y);
270+
}
271+
}

0 commit comments

Comments
 (0)