use bevy::{
camera::RenderTarget,
image::ImageSampler,
input::mouse::AccumulatedMouseMotion,
pbr::wireframe::{Wireframe, WireframeColor, WireframeConfig, WireframePlugin},
prelude::*,
render::{
RenderPlugin,
render_resource::{Extent3d, TextureFormat, WgpuFeatures},
settings::{RenderCreation, WgpuSettings},
},
window::{
CursorGrabMode, CursorOptions, MonitorSelection, PresentMode, PrimaryWindow,
VideoModeSelection, WindowMode, WindowResolution,
},
};
use bevy_spark::{SparkDiagnostics, SparkPlugin, SplatCloud, SplatCoordinateConvention, Splats};
const WINDOW_WIDTH: u32 = 1280;
const WINDOW_HEIGHT: u32 = 720;
fn main() {
if std::env::var_os("BEVY_SPARK_MAX_STDDEV").is_none() {
unsafe {
std::env::set_var("BEVY_SPARK_MAX_STDDEV", "2.0");
}
}
let mut args = std::env::args().skip(1);
let path = args
.next()
.unwrap_or_else(|| "kitchen_500k.spz".to_string());
let collider_path = args.next().or_else(|| inferred_collider_path(&path));
let present_mode = match std::env::var("BEVY_SPARK_PRESENT").ok().as_deref() {
Some("vsync") => PresentMode::AutoVsync,
Some("immediate") => PresentMode::AutoNoVsync,
Some("fifo") => PresentMode::Fifo,
Some("mailbox") => PresentMode::Mailbox,
Some("relaxed") => PresentMode::FifoRelaxed,
_ => PresentMode::Fifo,
};
let mut wgpu_settings = WgpuSettings::default();
wgpu_settings.features |= WgpuFeatures::POLYGON_MODE_LINE | WgpuFeatures::PUSH_CONSTANTS;
App::new()
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: match collider_path.as_deref() {
Some(collider) => format!("bevy_spark — {path} + {collider}"),
None => format!("bevy_spark — {path}"),
},
resolution: kitchen_window_resolution(),
mode: kitchen_window_mode(),
present_mode,
..default()
}),
primary_cursor_options: Some(CursorOptions {
visible: false,
grab_mode: CursorGrabMode::Locked,
..default()
}),
..default()
})
.set(RenderPlugin {
render_creation: RenderCreation::Automatic(wgpu_settings),
..default()
}),
)
.add_plugins((SparkPlugin, WireframePlugin::default()))
.insert_resource(WireframeConfig {
global: false,
default_color: Color::srgb(0.0, 0.9, 1.0),
})
.insert_resource(SceneArg {
splat_path: path,
collider_path,
})
.insert_resource(ClearColor(Color::srgb(0.05, 0.05, 0.05)))
.insert_resource(FpsState::default())
.add_systems(Startup, setup)
.add_systems(Update, (drive_camera, resize_upscale_target, update_fps))
.run();
}
fn kitchen_window_resolution() -> WindowResolution {
match std::env::var("BEVY_SPARK_WINDOW_SCALE").ok() {
Some(value) if matches!(value.as_str(), "native" | "auto" | "os") => {
WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT)
}
Some(value) => value
.parse::<f32>()
.ok()
.filter(|scale| scale.is_finite() && *scale > 0.0)
.map(|scale| {
WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT).with_scale_factor_override(scale)
})
.unwrap_or_else(|| {
WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT).with_scale_factor_override(1.0)
}),
None => WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT).with_scale_factor_override(1.0),
}
}
fn kitchen_window_mode() -> WindowMode {
match std::env::var("BEVY_SPARK_FULLSCREEN").ok().as_deref() {
Some("1") | Some("true") | Some("yes") | Some("on") | Some("borderless") => {
WindowMode::BorderlessFullscreen(MonitorSelection::Current)
}
Some("exclusive") => {
WindowMode::Fullscreen(MonitorSelection::Current, VideoModeSelection::Current)
}
_ => WindowMode::Windowed,
}
}
fn upscale_factor() -> Option<u32> {
std::env::var("BEVY_SPARK_UPSCALE")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.filter(|factor| *factor > 1)
}
fn inferred_collider_path(path: &str) -> Option<String> {
let file_name = path.rsplit('/').next().unwrap_or(path);
if file_name.starts_with("fireplace") {
Some("fireplace_collider.glb".to_string())
} else if file_name.starts_with("rustic_kitchen_with_natural_light") {
Some("rustic_kitchen_with_natural_light_collider.glb".to_string())
} else {
None
}
}
#[derive(Resource)]
struct SceneArg {
splat_path: String,
collider_path: Option<String>,
}
#[derive(Component)]
struct FlyCamera {
speed: f32,
sensitivity: Vec2,
}
#[derive(Component)]
struct FpsText;
#[derive(Resource, Default)]
struct FpsState {
frames: u32,
accum: f32,
min_dt: f32,
max_dt: f32,
}
#[derive(Resource)]
struct UpscaleTarget {
handle: Handle<Image>,
factor: u32,
}
fn scaled_extent(width: u32, height: u32, factor: u32) -> Extent3d {
Extent3d {
width: (width / factor).max(1),
height: (height / factor).max(1),
depth_or_array_layers: 1,
}
}
fn setup(
mut commands: Commands,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<StandardMaterial>>,
asset_server: Res<AssetServer>,
scene: Res<SceneArg>,
) {
let handle: Handle<Splats> = asset_server.load(scene.splat_path.clone());
commands.spawn((SplatCloud { handle }, SplatCoordinateConvention::YDown));
let collider_material = materials.add(StandardMaterial {
base_color: Color::srgba(0.0, 0.9, 1.0, 0.08),
alpha_mode: AlphaMode::Blend,
unlit: true,
..default()
});
if let Some(collider_path) = &scene.collider_path {
let collider_transform =
Transform::from_rotation(Quat::from_rotation_x(std::f32::consts::PI));
let collider_mesh: Handle<Mesh> = asset_server.load(
GltfAssetLabel::Primitive {
mesh: 0,
primitive: 0,
}
.from_asset(collider_path.clone()),
);
commands.spawn((
Mesh3d(collider_mesh),
MeshMaterial3d(collider_material),
collider_transform,
Wireframe,
WireframeColor {
color: Color::srgb(0.0, 0.95, 1.0),
},
Name::new("Collider wireframe overlay"),
));
}
let upscale = upscale_factor().map(|factor| {
let extent = scaled_extent(WINDOW_WIDTH, WINDOW_HEIGHT, factor);
let mut image = Image::new_target_texture(
extent.width,
extent.height,
TextureFormat::Rgba8UnormSrgb,
None,
);
image.sampler = ImageSampler::linear();
let handle = images.add(image);
commands.insert_resource(UpscaleTarget {
handle: handle.clone(),
factor,
});
commands.spawn((
ImageNode {
image: handle.clone(),
image_mode: NodeImageMode::Stretch,
..default()
},
Node {
position_type: PositionType::Absolute,
top: Val::Px(0.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
GlobalZIndex(-1),
));
handle
});
let mut camera = commands.spawn((
Camera3d::default(),
Camera::default(),
Msaa::Off,
bevy::core_pipeline::tonemapping::Tonemapping::None,
Transform::from_xyz(0.0, 0.0, 0.0).looking_at(Vec3::new(0.0, 0.0, -1.0), Vec3::Y),
Projection::Perspective(PerspectiveProjection {
fov: std::f32::consts::FRAC_PI_3,
..default()
}),
FlyCamera {
speed: 1.5,
sensitivity: Vec2::splat(0.002),
},
));
if let Some(handle) = upscale.clone() {
camera.insert(RenderTarget::from(handle));
commands.spawn((
Camera2d,
Camera {
order: 1,
..default()
},
IsDefaultUiCamera,
));
} else {
camera.insert(IsDefaultUiCamera);
}
commands.spawn((
Text::new("FPS: …"),
TextFont {
font_size: 22.0,
..default()
},
TextColor(Color::WHITE),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)),
Node {
position_type: PositionType::Absolute,
top: Val::Px(8.0),
left: Val::Px(8.0),
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
..default()
},
FpsText,
));
}
fn resize_upscale_target(
upscale: Option<Res<UpscaleTarget>>,
mut images: ResMut<Assets<Image>>,
windows: Query<&Window, With<PrimaryWindow>>,
) {
let Some(upscale) = upscale else {
return;
};
let Ok(window) = windows.single() else {
return;
};
let Some(image) = images.get_mut(&upscale.handle) else {
return;
};
let extent = scaled_extent(
window.physical_width(),
window.physical_height(),
upscale.factor,
);
if image.texture_descriptor.size != extent {
image.resize(extent);
}
}
fn update_fps(
time: Res<Time>,
mut state: ResMut<FpsState>,
mut q: Query<&mut Text, With<FpsText>>,
diagnostics: Res<SparkDiagnostics>,
windows: Query<&Window, With<PrimaryWindow>>,
upscale: Option<Res<UpscaleTarget>>,
) {
let dt = time.delta_secs();
if state.frames == 0 {
state.min_dt = dt;
state.max_dt = dt;
} else {
if dt < state.min_dt {
state.min_dt = dt;
}
if dt > state.max_dt {
state.max_dt = dt;
}
}
state.frames += 1;
state.accum += dt;
if state.accum >= 0.5 {
let fps = state.frames as f32 / state.accum;
let avg_ms = (state.accum / state.frames as f32) * 1000.0;
let min_ms = state.min_dt * 1000.0;
let max_ms = state.max_dt * 1000.0;
let snapshot = diagnostics.snapshot();
let window_text = windows
.single()
.map(|window| {
let physical_width = window.physical_width();
let physical_height = window.physical_height();
if let Some(upscale) = upscale.as_deref() {
format!(
"{}x{} -> {}x{} @ {:.2}x",
(physical_width / upscale.factor).max(1),
(physical_height / upscale.factor).max(1),
physical_width,
physical_height,
window.scale_factor()
)
} else {
format!(
"{}x{} @ {:.2}x",
physical_width,
physical_height,
window.scale_factor()
)
}
})
.unwrap_or_else(|_| "window ?".to_string());
for mut text in &mut q {
**text = format!(
"FPS {fps:5.1} ms avg/min/max {avg_ms:5.1} / {min_ms:5.1} / {max_ms:5.1}\n{window_text} splats {}/{} sort {:4.1} ms",
snapshot.visible_splats,
snapshot.total_splats,
snapshot.last_sort_time_secs * 1000.0,
);
}
state.frames = 0;
state.accum = 0.0;
state.min_dt = 0.0;
state.max_dt = 0.0;
}
}
fn drive_camera(
time: Res<Time>,
keys: Res<ButtonInput<KeyCode>>,
mouse_buttons: Res<ButtonInput<MouseButton>>,
mouse_motion: Res<AccumulatedMouseMotion>,
mut cursor_options: Single<&mut CursorOptions>,
mut q: Query<(&mut Transform, &FlyCamera)>,
) {
if keys.just_pressed(KeyCode::Escape) {
cursor_options.visible = true;
cursor_options.grab_mode = CursorGrabMode::None;
}
if mouse_buttons.just_pressed(MouseButton::Left) {
cursor_options.visible = false;
cursor_options.grab_mode = CursorGrabMode::Locked;
}
for (mut transform, cam) in &mut q {
let delta = mouse_motion.delta;
if delta != Vec2::ZERO && cursor_options.grab_mode != CursorGrabMode::None {
let delta_yaw = -delta.x * cam.sensitivity.x;
let delta_pitch = -delta.y * cam.sensitivity.y;
let (yaw, pitch, roll) = transform.rotation.to_euler(EulerRot::YXZ);
let pitch_limit = std::f32::consts::FRAC_PI_2 - 0.01;
transform.rotation = Quat::from_euler(
EulerRot::YXZ,
yaw + delta_yaw,
(pitch + delta_pitch).clamp(-pitch_limit, pitch_limit),
roll,
);
}
let mut direction = Vec3::ZERO;
let forward = transform.forward().as_vec3();
let right = transform.right().as_vec3();
if keys.pressed(KeyCode::KeyW) || keys.pressed(KeyCode::ArrowUp) {
direction += forward;
}
if keys.pressed(KeyCode::KeyS) || keys.pressed(KeyCode::ArrowDown) {
direction -= forward;
}
if keys.pressed(KeyCode::KeyD) || keys.pressed(KeyCode::ArrowRight) {
direction += right;
}
if keys.pressed(KeyCode::KeyA) || keys.pressed(KeyCode::ArrowLeft) {
direction -= right;
}
if keys.pressed(KeyCode::Space) || keys.pressed(KeyCode::KeyE) {
direction += Vec3::Y;
}
if keys.pressed(KeyCode::ControlLeft)
|| keys.pressed(KeyCode::ControlRight)
|| keys.pressed(KeyCode::KeyQ)
{
direction -= Vec3::Y;
}
if direction != Vec3::ZERO {
let boost = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
let speed = if boost { cam.speed * 4.0 } else { cam.speed };
transform.translation += direction.normalize() * speed * time.delta_secs();
}
}
}