1use glam::{DVec2, Mat3A, Mat4, UVec2, Vec3, Vec3A};
2use instant::Instant;
3use pico_args::Arguments;
4use rend3::{
5 types::{
6 Backend, Camera, CameraProjection, DirectionalLight, DirectionalLightHandle, SampleCount, Texture,
7 TextureFormat,
8 },
9 util::typedefs::FastHashMap,
10 Renderer, RendererProfile,
11};
12use rend3_framework::{lock, AssetPath, Mutex};
13use rend3_gltf::GltfSceneInstance;
14use rend3_routine::{base::BaseRenderGraph, pbr::NormalTextureYDirection, skybox::SkyboxRoutine};
15use std::{collections::HashMap, future::Future, hash::BuildHasher, path::Path, sync::Arc, time::Duration};
16use wgpu_profiler::GpuTimerScopeResult;
17use winit::{
18 event::{DeviceEvent, ElementState, Event, KeyboardInput, MouseButton, WindowEvent},
19 window::{Fullscreen, WindowBuilder},
20};
21
22mod platform;
23
24async fn load_skybox_image(loader: &rend3_framework::AssetLoader, data: &mut Vec<u8>, path: &str) {
25 let decoded = image::load_from_memory(
26 &loader
27 .get_asset(AssetPath::Internal(path))
28 .await
29 .unwrap_or_else(|e| panic!("Error {}: {}", path, e)),
30 )
31 .unwrap()
32 .into_rgba8();
33
34 data.extend_from_slice(decoded.as_raw());
35}
36
37async fn load_skybox(
38 renderer: &Renderer,
39 loader: &rend3_framework::AssetLoader,
40 skybox_routine: &Mutex<SkyboxRoutine>,
41) -> Result<(), Box<dyn std::error::Error>> {
42 let mut data = Vec::new();
43 load_skybox_image(loader, &mut data, "skybox/right.jpg").await;
44 load_skybox_image(loader, &mut data, "skybox/left.jpg").await;
45 load_skybox_image(loader, &mut data, "skybox/top.jpg").await;
46 load_skybox_image(loader, &mut data, "skybox/bottom.jpg").await;
47 load_skybox_image(loader, &mut data, "skybox/front.jpg").await;
48 load_skybox_image(loader, &mut data, "skybox/back.jpg").await;
49
50 let handle = renderer.add_texture_cube(Texture {
51 format: TextureFormat::Rgba8UnormSrgb,
52 size: UVec2::new(2048, 2048),
53 data,
54 label: Some("background".into()),
55 mip_count: rend3::types::MipmapCount::ONE,
56 mip_source: rend3::types::MipmapSource::Uploaded,
57 });
58 lock(skybox_routine).set_background_texture(Some(handle));
59 Ok(())
60}
61
62async fn load_gltf(
63 renderer: &Renderer,
64 loader: &rend3_framework::AssetLoader,
65 settings: &rend3_gltf::GltfLoadSettings,
66 location: AssetPath<'_>,
67) -> Option<(rend3_gltf::LoadedGltfScene, GltfSceneInstance)> {
68 let gltf_start = Instant::now();
70 let is_default_scene = matches!(location, AssetPath::Internal(_));
71 let path = loader.get_asset_path(location);
72 let path = Path::new(&*path);
73 let parent = path.parent().unwrap();
74
75 let parent_str = parent.to_string_lossy();
76 let path_str = path.as_os_str().to_string_lossy();
77 log::info!("Reading gltf file: {}", path_str);
78 let gltf_data_result = loader.get_asset(AssetPath::External(&path_str)).await;
79
80 let gltf_data = match gltf_data_result {
81 Ok(d) => d,
82 Err(_) if is_default_scene => {
83 let suffix = if cfg!(target_os = "windows") { ".exe" } else { "" };
84
85 indoc::eprintdoc!("
86 *** WARNING ***
87
88 It appears you are running scene-viewer with no file to display.
89
90 The default scene is no longer bundled into the repository. If you are running on git, use the following commands
91 to download and unzip it into the right place. If you're running it through not-git, pass a custom folder to the -C argument
92 to tar, then run scene-viewer path/to/scene.gltf.
93
94 curl{0} https://cdn.cwfitz.com/scenes/rend3-default-scene.tar -o ./examples/scene-viewer/resources/rend3-default-scene.tar
95 tar{0} xf ./examples/scene-viewer/resources/rend3-default-scene.tar -C ./examples/scene-viewer/resources
96
97 ***************
98 ", suffix);
99
100 return None;
101 }
102 e => e.unwrap(),
103 };
104
105 let gltf_elapsed = gltf_start.elapsed();
106 let resources_start = Instant::now();
107 let (scene, instance) = rend3_gltf::load_gltf(renderer, &gltf_data, settings, |uri| async {
108 log::info!("Loading resource {}", uri);
109 let uri = uri;
110 let full_uri = parent_str.clone() + "/" + uri.as_str();
111 loader.get_asset(AssetPath::External(&full_uri)).await
112 })
113 .await
114 .unwrap();
115
116 log::info!(
117 "Loaded gltf in {:.3?}, resources loaded in {:.3?}",
118 gltf_elapsed,
119 resources_start.elapsed()
120 );
121 Some((scene, instance))
122}
123
124fn button_pressed<Hash: BuildHasher>(map: &HashMap<u32, bool, Hash>, key: u32) -> bool {
125 map.get(&key).map_or(false, |b| *b)
126}
127
128fn extract_backend(value: &str) -> Result<Backend, &'static str> {
129 Ok(match value.to_lowercase().as_str() {
130 "vulkan" | "vk" => Backend::Vulkan,
131 "dx12" | "12" => Backend::Dx12,
132 "dx11" | "11" => Backend::Dx11,
133 "metal" | "mtl" => Backend::Metal,
134 "opengl" | "gl" => Backend::Gl,
135 _ => return Err("unknown backend"),
136 })
137}
138
139fn extract_mode(value: &str) -> Result<rend3::RendererProfile, &'static str> {
140 Ok(match value.to_lowercase().as_str() {
141 "legacy" | "c" | "cpu" => rend3::RendererProfile::CpuDriven,
142 "modern" | "g" | "gpu" => rend3::RendererProfile::GpuDriven,
143 _ => return Err("unknown rendermode"),
144 })
145}
146
147fn extract_msaa(value: &str) -> Result<SampleCount, &'static str> {
148 Ok(match value {
149 "1" => SampleCount::One,
150 "4" => SampleCount::Four,
151 _ => return Err("invalid msaa count"),
152 })
153}
154
155fn extract_vec3(value: &str) -> Result<Vec3, &'static str> {
156 let mut res = [0.0_f32, 0.0, 0.0];
157 let split: Vec<_> = value.split(',').enumerate().collect();
158
159 if split.len() != 3 {
160 return Err("Directional lights are defined with 3 values");
161 }
162
163 for (idx, inner) in split {
164 let inner = inner.trim();
165
166 res[idx] = inner.parse().map_err(|_| "Cannot parse direction number")?;
167 }
168 Ok(Vec3::from(res))
169}
170
171fn option_arg<T>(result: Result<Option<T>, pico_args::Error>) -> Option<T> {
172 match result {
173 Ok(o) => o,
174 Err(pico_args::Error::Utf8ArgumentParsingFailed { value, cause }) => {
175 eprintln!("{}: '{}'\n\n{}", cause, value, HELP);
176 std::process::exit(1);
177 }
178 Err(pico_args::Error::OptionWithoutAValue(value)) => {
179 eprintln!("{} flag needs an argument", value);
180 std::process::exit(1);
181 }
182 Err(e) => {
183 eprintln!("{:?}", e);
184 std::process::exit(1);
185 }
186 }
187}
188
189#[cfg(not(target_arch = "wasm32"))]
190pub fn spawn<Fut>(fut: Fut)
191where
192 Fut: Future + Send + 'static,
193 Fut::Output: Send + 'static,
194{
195 std::thread::spawn(|| pollster::block_on(fut));
196}
197
198#[cfg(target_arch = "wasm32")]
199pub fn spawn<Fut>(fut: Fut)
200where
201 Fut: Future + 'static,
202 Fut::Output: 'static,
203{
204 wasm_bindgen_futures::spawn_local(async move {
205 fut.await;
206 });
207}
208
209const HELP: &str = "\
210scene-viewer
211
212gltf and glb scene viewer powered by the rend3 rendering library.
213
214usage: scene-viewer --options ./path/to/gltf/file.gltf
215
216Meta:
217 --help This menu.
218
219Rendering:
220 -b --backend Choose backend to run on ('vk', 'dx12', 'dx11', 'metal', 'gl').
221 -d --device Choose device to run on (case insensitive device substring).
222 -p --profile Choose rendering profile to use ('cpu', 'gpu').
223 --msaa <level> Level of antialiasing (either 1 or 4). Default 1.
224
225Windowing:
226 --absolute-mouse Interpret the relative mouse coordinates as absolute. Useful when using things like VNC.
227 --fullscreen Open the window in borderless fullscreen.
228
229Assets:
230 --normal-y-down Interpret all normals as having the DirectX convention of Y down. Defaults to Y up.
231 --directional-light <x,y,z> Create a directional light pointing towards the given coordinates.
232 --directional-light-intensity <value> All lights created by the above flag have this intensity. Defaults to 4.
233 --gltf-disable-directional-lights Disable all directional lights in the gltf
234 --ambient <value> Set the value of the minimum ambient light. This will be treated as white light of this intensity. Defaults to 0.1.
235 --scale <scale> Scale all objects loaded by this factor. Defaults to 1.0.
236 --shadow-distance <value> Distance from the camera there will be directional shadows. Lower values means higher quality shadows. Defaults to 100.
237
238Controls:
239 --walk <speed> Walk speed (speed without holding shift) in units/second (typically meters). Default 10.
240 --run <speed> Run speed (speed while holding shift) in units/second (typically meters). Default 50.
241";
242
243struct SceneViewer {
244 absolute_mouse: bool,
245 desired_backend: Option<Backend>,
246 desired_device_name: Option<String>,
247 desired_profile: Option<RendererProfile>,
248 file_to_load: Option<String>,
249 walk_speed: f32,
250 run_speed: f32,
251 gltf_settings: rend3_gltf::GltfLoadSettings,
252 directional_light_direction: Option<Vec3>,
253 directional_light_intensity: f32,
254 directional_light: Option<DirectionalLightHandle>,
255 ambient_light_level: f32,
256 samples: SampleCount,
257
258 fullscreen: bool,
259
260 scancode_status: FastHashMap<u32, bool>,
261 camera_pitch: f32,
262 camera_yaw: f32,
263 camera_location: Vec3A,
264 previous_profiling_stats: Option<Vec<GpuTimerScopeResult>>,
265 timestamp_last_second: Instant,
266 timestamp_last_frame: Instant,
267 frame_times: histogram::Histogram,
268 last_mouse_delta: Option<DVec2>,
269
270 grabber: Option<rend3_framework::Grabber>,
271}
272impl SceneViewer {
273 pub fn new() -> Self {
274 let mut args = Arguments::from_vec(std::env::args_os().skip(1).collect());
275
276 let help = args.contains(["-h", "--help"]);
278
279 let desired_backend = option_arg(args.opt_value_from_fn(["-b", "--backend"], extract_backend));
281 let desired_device_name: Option<String> =
282 option_arg(args.opt_value_from_str(["-d", "--device"])).map(|s: String| s.to_lowercase());
283 let desired_mode = option_arg(args.opt_value_from_fn(["-p", "--profile"], extract_mode));
284 let samples = option_arg(args.opt_value_from_fn("--msaa", extract_msaa)).unwrap_or(SampleCount::One);
285
286 let absolute_mouse: bool = args.contains("--absolute-mouse");
288 let fullscreen = args.contains("--fullscreen");
289
290 let normal_direction = match args.contains("--normal-y-down") {
292 true => NormalTextureYDirection::Down,
293 false => NormalTextureYDirection::Up,
294 };
295 let directional_light_direction = option_arg(args.opt_value_from_fn("--directional-light", extract_vec3));
296 let directional_light_intensity: f32 =
297 option_arg(args.opt_value_from_str("--directional-light-intensity")).unwrap_or(4.0);
298 let ambient_light_level: f32 = option_arg(args.opt_value_from_str("--ambient")).unwrap_or(0.10);
299 let scale: Option<f32> = option_arg(args.opt_value_from_str("--scale"));
300 let shadow_distance: Option<f32> = option_arg(args.opt_value_from_str("--shadow-distance"));
301 let gltf_disable_directional_light: bool = args.contains("--gltf-disable-directional-lights");
302
303 let walk_speed = args.value_from_str("--walk").unwrap_or(10.0_f32);
305 let run_speed = args.value_from_str("--run").unwrap_or(50.0_f32);
306
307 let file_to_load: Option<String> = args.free_from_str().ok();
309
310 let remaining = args.finish();
311
312 if !remaining.is_empty() {
313 eprint!("Unknown arguments:");
314 for flag in remaining {
315 eprint!(" '{}'", flag.to_string_lossy());
316 }
317 eprintln!("\n");
318
319 eprintln!("{}", HELP);
320 std::process::exit(1);
321 }
322
323 if help {
324 eprintln!("{}", HELP);
325 std::process::exit(1);
326 }
327
328 let mut gltf_settings = rend3_gltf::GltfLoadSettings {
329 normal_direction,
330 enable_directional: !gltf_disable_directional_light,
331 ..Default::default()
332 };
333 if let Some(scale) = scale {
334 gltf_settings.scale = scale
335 }
336 if let Some(shadow_distance) = shadow_distance {
337 gltf_settings.directional_light_shadow_distance = shadow_distance;
338 }
339
340 Self {
341 absolute_mouse,
342 desired_backend,
343 desired_device_name,
344 desired_profile: desired_mode,
345 file_to_load,
346 walk_speed,
347 run_speed,
348 gltf_settings,
349 directional_light_direction,
350 directional_light_intensity,
351 directional_light: None,
352 ambient_light_level,
353 samples,
354
355 fullscreen,
356
357 scancode_status: FastHashMap::default(),
358 camera_pitch: -std::f32::consts::FRAC_PI_8,
359 camera_yaw: std::f32::consts::FRAC_PI_4,
360 camera_location: Vec3A::new(3.0, 3.0, 3.0),
361 previous_profiling_stats: None,
362 timestamp_last_second: Instant::now(),
363 timestamp_last_frame: Instant::now(),
364 frame_times: histogram::Histogram::new(),
365 last_mouse_delta: None,
366
367 grabber: None,
368 }
369 }
370}
371impl rend3_framework::App for SceneViewer {
372 const HANDEDNESS: rend3::types::Handedness = rend3::types::Handedness::Right;
373
374 fn create_iad<'a>(
375 &'a mut self,
376 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<rend3::InstanceAdapterDevice>> + 'a>> {
377 Box::pin(async move {
378 Ok(rend3::create_iad(
379 self.desired_backend,
380 self.desired_device_name.clone(),
381 self.desired_profile,
382 None,
383 )
384 .await?)
385 })
386 }
387
388 fn sample_count(&self) -> SampleCount {
389 self.samples
390 }
391
392 fn scale_factor(&self) -> f32 {
393 cfg_if::cfg_if! {
396 if #[cfg(target_os = "android")] {
397 0.5
398 } else {
399 1.0
400 }
401 }
402 }
403
404 fn setup<'a>(
405 &'a mut self,
406 window: &'a winit::window::Window,
407 renderer: &'a Arc<Renderer>,
408 routines: &'a Arc<rend3_framework::DefaultRoutines>,
409 _surface_format: rend3::types::TextureFormat,
410 ) {
411 self.grabber = Some(rend3_framework::Grabber::new(window));
412
413 if let Some(direction) = self.directional_light_direction {
414 self.directional_light = Some(renderer.add_directional_light(DirectionalLight {
415 color: Vec3::splat(1.0),
416 intensity: self.directional_light_intensity,
417 direction,
418 distance: self.gltf_settings.directional_light_shadow_distance,
419 }));
420 }
421
422 let gltf_settings = self.gltf_settings;
423 let file_to_load = self.file_to_load.take();
424 let renderer = Arc::clone(renderer);
425 let routines = Arc::clone(routines);
426 spawn(async move {
427 let loader = rend3_framework::AssetLoader::new_local(
428 concat!(env!("CARGO_MANIFEST_DIR"), "/resources/"),
429 "",
430 "http://localhost:8000/resources/",
431 );
432 if let Err(e) = load_skybox(&renderer, &loader, &routines.skybox).await {
433 println!("Failed to load skybox {}", e)
434 };
435 Box::leak(Box::new(
436 load_gltf(
437 &renderer,
438 &loader,
439 &gltf_settings,
440 file_to_load
441 .as_deref()
442 .map_or_else(|| AssetPath::Internal("default-scene/scene.gltf"), AssetPath::External),
443 )
444 .await,
445 ));
446 });
447 }
448
449 fn handle_event(
450 &mut self,
451 window: &winit::window::Window,
452 renderer: &Arc<rend3::Renderer>,
453 routines: &Arc<rend3_framework::DefaultRoutines>,
454 base_rendergraph: &BaseRenderGraph,
455 surface: Option<&Arc<rend3::types::Surface>>,
456 resolution: UVec2,
457 event: rend3_framework::Event<'_, ()>,
458 control_flow: impl FnOnce(winit::event_loop::ControlFlow),
459 ) {
460 match event {
461 Event::MainEventsCleared => {
462 profiling::scope!("MainEventsCleared");
463 let now = Instant::now();
464
465 let delta_time = now - self.timestamp_last_frame;
466 self.frame_times.increment(delta_time.as_micros() as u64).unwrap();
467
468 let elapsed_since_second = now - self.timestamp_last_second;
469 if elapsed_since_second > Duration::from_secs(1) {
470 let count = self.frame_times.entries();
471 println!(
472 "{:0>5} frames over {:0>5.2}s. \
473 Min: {:0>5.2}ms; \
474 Average: {:0>5.2}ms; \
475 95%: {:0>5.2}ms; \
476 99%: {:0>5.2}ms; \
477 Max: {:0>5.2}ms; \
478 StdDev: {:0>5.2}ms",
479 count,
480 elapsed_since_second.as_secs_f32(),
481 self.frame_times.minimum().unwrap() as f32 / 1_000.0,
482 self.frame_times.mean().unwrap() as f32 / 1_000.0,
483 self.frame_times.percentile(95.0).unwrap() as f32 / 1_000.0,
484 self.frame_times.percentile(99.0).unwrap() as f32 / 1_000.0,
485 self.frame_times.maximum().unwrap() as f32 / 1_000.0,
486 self.frame_times.stddev().unwrap() as f32 / 1_000.0,
487 );
488 self.timestamp_last_second = now;
489 self.frame_times.clear();
490 }
491
492 self.timestamp_last_frame = now;
493
494 let rotation =
495 Mat3A::from_euler(glam::EulerRot::XYZ, -self.camera_pitch, -self.camera_yaw, 0.0).transpose();
496 let forward = -rotation.z_axis;
497 let up = rotation.y_axis;
498 let side = -rotation.x_axis;
499 let velocity = if button_pressed(&self.scancode_status, platform::Scancodes::SHIFT) {
500 self.run_speed
501 } else {
502 self.walk_speed
503 };
504 if button_pressed(&self.scancode_status, platform::Scancodes::W) {
505 self.camera_location += forward * velocity * delta_time.as_secs_f32();
506 }
507 if button_pressed(&self.scancode_status, platform::Scancodes::S) {
508 self.camera_location -= forward * velocity * delta_time.as_secs_f32();
509 }
510 if button_pressed(&self.scancode_status, platform::Scancodes::A) {
511 self.camera_location += side * velocity * delta_time.as_secs_f32();
512 }
513 if button_pressed(&self.scancode_status, platform::Scancodes::D) {
514 self.camera_location -= side * velocity * delta_time.as_secs_f32();
515 }
516 if button_pressed(&self.scancode_status, platform::Scancodes::Q) {
517 self.camera_location += up * velocity * delta_time.as_secs_f32();
518 }
519 if button_pressed(&self.scancode_status, platform::Scancodes::Z) {
520 self.camera_location -= up * velocity * delta_time.as_secs_f32();
521 }
522
523 if button_pressed(&self.scancode_status, platform::Scancodes::ESCAPE) {
524 self.grabber.as_mut().unwrap().request_ungrab(window);
525 }
526
527 if button_pressed(&self.scancode_status, platform::Scancodes::P) {
528 if let Some(ref stats) = self.previous_profiling_stats {
530 println!("Outputing gpu timing chrome trace to profile.json");
531 wgpu_profiler::chrometrace::write_chrometrace(Path::new("profile.json"), stats).unwrap();
532 } else {
533 println!("No gpu timing trace available, either timestamp queries are unsupported or not enough frames have elapsed yet!");
534 }
535 }
536
537 window.request_redraw()
538 }
539 Event::RedrawRequested(_) => {
540 let view = Mat4::from_euler(glam::EulerRot::XYZ, -self.camera_pitch, -self.camera_yaw, 0.0);
541 let view = view * Mat4::from_translation((-self.camera_location).into());
542
543 renderer.set_camera_data(Camera {
544 projection: CameraProjection::Perspective { vfov: 60.0, near: 0.1 },
545 view,
546 });
547
548 let frame = rend3::util::output::OutputFrame::Surface {
550 surface: Arc::clone(surface.unwrap()),
551 };
552 let pbr_routine = lock(&routines.pbr);
554 let mut skybox_routine = lock(&routines.skybox);
555 let tonemapping_routine = lock(&routines.tonemapping);
556
557 let (cmd_bufs, ready) = renderer.ready();
559 skybox_routine.ready(renderer);
561
562 let mut graph = rend3::graph::RenderGraph::new();
564
565 base_rendergraph.add_to_graph(
567 &mut graph,
568 &ready,
569 &pbr_routine,
570 Some(&skybox_routine),
571 &tonemapping_routine,
572 resolution,
573 self.samples,
574 Vec3::splat(self.ambient_light_level).extend(1.0),
575 );
576
577 self.previous_profiling_stats = graph.execute(renderer, frame, cmd_bufs, &ready);
579 profiling::finish_frame!();
581 }
582 Event::WindowEvent {
583 event: WindowEvent::Focused(focus),
584 ..
585 } => {
586 if !focus {
587 self.grabber.as_mut().unwrap().request_ungrab(window);
588 }
589 }
590 Event::WindowEvent {
591 event:
592 WindowEvent::KeyboardInput {
593 input: KeyboardInput { scancode, state, .. },
594 ..
595 },
596 ..
597 } => {
598 log::info!("WE scancode {:x}", scancode);
599 self.scancode_status.insert(
600 scancode,
601 match state {
602 ElementState::Pressed => true,
603 ElementState::Released => false,
604 },
605 );
606 }
607 Event::WindowEvent {
608 event:
609 WindowEvent::MouseInput {
610 button: MouseButton::Left,
611 state: ElementState::Pressed,
612 ..
613 },
614 ..
615 } => {
616 let grabber = self.grabber.as_mut().unwrap();
617
618 if !grabber.grabbed() {
619 grabber.request_grab(window);
620 }
621 }
622 Event::DeviceEvent {
623 event:
624 DeviceEvent::MouseMotion {
625 delta: (delta_x, delta_y),
626 ..
627 },
628 ..
629 } => {
630 if !self.grabber.as_ref().unwrap().grabbed() {
631 return;
632 }
633
634 const TAU: f32 = std::f32::consts::PI * 2.0;
635
636 let mouse_delta = if self.absolute_mouse {
637 let prev = self.last_mouse_delta.replace(DVec2::new(delta_x, delta_y));
638 if let Some(prev) = prev {
639 (DVec2::new(delta_x, delta_y) - prev) / 4.0
640 } else {
641 return;
642 }
643 } else {
644 DVec2::new(delta_x, delta_y)
645 };
646
647 self.camera_yaw -= (mouse_delta.x / 1000.0) as f32;
648 self.camera_pitch -= (mouse_delta.y / 1000.0) as f32;
649 if self.camera_yaw < 0.0 {
650 self.camera_yaw += TAU;
651 } else if self.camera_yaw >= TAU {
652 self.camera_yaw -= TAU;
653 }
654 self.camera_pitch = self
655 .camera_pitch
656 .max(-std::f32::consts::FRAC_PI_2 + 0.0001)
657 .min(std::f32::consts::FRAC_PI_2 - 0.0001);
658 }
659 Event::WindowEvent {
660 event: WindowEvent::CloseRequested,
661 ..
662 } => {
663 control_flow(winit::event_loop::ControlFlow::Exit);
664 }
665 _ => {}
666 }
667 }
668}
669
670#[cfg_attr(target_os = "android", ndk_glue::main(backtrace = "on", logger(level = "debug")))]
671pub fn main() {
672 let app = SceneViewer::new();
673
674 let mut builder = WindowBuilder::new().with_title("scene-viewer").with_maximized(true);
675 if app.fullscreen {
676 builder = builder.with_fullscreen(Some(Fullscreen::Borderless(None)));
677 }
678
679 rend3_framework::start(app, builder);
680}