diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8ceb28c..d0b9f4e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,11 +30,35 @@ jobs: with: toolchain: ${{ matrix.rust-toolchain }} + - name: Install ONNX Runtime on Windows + if: matrix.os == 'windows-latest' + run: | + Invoke-WebRequest -Uri "https://github.com/microsoft/onnxruntime/releases/download/v1.17.1/onnxruntime-win-x64-1.17.1.zip" -OutFile "onnxruntime.zip" + Expand-Archive -Path "onnxruntime.zip" -DestinationPath "$env:RUNNER_TEMP" + echo "ONNXRUNTIME_DIR=$env:RUNNER_TEMP\onnxruntime-win-x64-1.17.1" | Out-File -Append -Encoding ascii $env:GITHUB_ENV + + - name: Install ONNX Runtime on macOS + if: matrix.os == 'macos-latest' + run: | + curl -L "https://github.com/microsoft/onnxruntime/releases/download/v1.17.1/onnxruntime-osx-x86_64-1.17.1.tgz" -o "onnxruntime.tgz" + mkdir -p $HOME/onnxruntime + tar -xzf onnxruntime.tgz -C $HOME/onnxruntime + echo "ONNXRUNTIME_DIR=$HOME/onnxruntime/onnxruntime-osx-x86_64-1.17.1" >> $GITHUB_ENV + + + - name: Set ONNX Runtime library path for macOS + if: matrix.os == 'macos-latest' + run: echo "ORT_DYLIB_PATH=$ONNXRUNTIME_DIR/libonnxruntime.dylib" >> $GITHUB_ENV + + - name: Set ONNX Runtime library path for Windows + if: matrix.os == 'windows-latest' + run: echo "ORT_DYLIB_PATH=$ONNXRUNTIME_DIR/onnxruntime.dll" >> $GITHUB_ENV + + - name: lint run: cargo clippy -- -Dwarnings - name: build - run: cargo build - - # - name: build (web) - # run: cargo build --example=minimal --target wasm32-unknown-unknown --release + run: cargo build --features "ort/load-dynamic" + env: + ORT_DYLIB_PATH: ${{ env.ORT_DYLIB_PATH }} diff --git a/.gitignore b/.gitignore index eaec9ea..28fc69c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ www/assets/ *.mp4 mediamtx/ +onnxruntime/ diff --git a/Cargo.toml b/Cargo.toml index 331e8d0..542e828 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bevy_light_field" description = "rust bevy light field array tooling" -version = "0.3.0" +version = "0.5.0" edition = "2021" authors = ["mosure "] license = "MIT" @@ -25,16 +25,27 @@ exclude = [ default-run = "viewer" +[features] +default = [ + "person_matting", +] -# TODO: add feature for ffmpeg output +person_matting = ["bevy_ort", "ort", "ndarray"] [dependencies] anyhow = "1.0" async-compat = "0.2" -bytes = "1.5.0" +bevy_args = "1.3" +bevy_ort = { version = "0.6", optional = true } +bytes = "1.5" +clap = { version = "4.4", features = ["derive"] } futures = "0.3" +ndarray = { version = "0.15", optional = true } openh264 = "0.5" +serde = "1.0" +serde_json = "1.0" +serde_qs = "0.12" retina = "0.4" tokio = { version = "1.36", features = ["full"] } url = "2.5" @@ -53,6 +64,18 @@ features = [ ] +[dependencies.ort] +version = "2.0.0-alpha.4" +optional = true +default-features = false +features = [ + "cuda", + "load-dynamic", + "ndarray", + "openvino", +] + + [profile.dev.package."*"] opt-level = 3 diff --git a/README.md b/README.md index e56a22f..ef752ff 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,12 @@ rust bevy light field camera array tooling - [X] grid view of light field camera array - [X] stream to files with recording controls +- [X] person segmentation post-process (batch across streams) +- [X] async segmentation model inference +- [X] foreground extraction post-process and visualization mode +- [ ] camera array calibration (extrinsics, intrinsics, color) +- [ ] camera position visualization - [ ] playback nersemble recordings with annotations -- [ ] person segmentation post-process (batch across streams) -- [ ] camera array calibration - [ ] 3d reconstruction dataset preparation - [ ] real-time 3d reconstruction viewer @@ -27,6 +30,11 @@ rust bevy light field camera array tooling the viewer opens a window and displays the light field camera array, with post-process options +> see execution provider [bevy_ort documentation](https://github.com/mosure/bevy_ort?tab=readme-ov-file#run-the-example-person-segmentation-model-modnet) for better performance + +- windows: `cargo run --release --features "ort/cuda"` + + ### controls - `r` to start recording @@ -156,5 +164,7 @@ it is useful to test the light field viewer with emulated camera streams ## credits - [bevy_video](https://github.com/PortalCloudInc/bevy_video) - [gaussian_avatars](https://github.com/ShenhanQian/GaussianAvatars) +- [modnet](https://github.com/ZHKKKe/MODNet) - [nersemble](https://github.com/tobias-kirschstein/nersemble) - [paddle_seg_matting](https://github.com/PaddlePaddle/PaddleSeg/blob/release/2.9/Matting/docs/quick_start_en.md) +- [ray diffusion](https://github.com/jasonyzhang/RayDiffusion) diff --git a/assets/fonts/Caveat-Bold.ttf b/assets/fonts/Caveat-Bold.ttf new file mode 100644 index 0000000..5b05296 Binary files /dev/null and b/assets/fonts/Caveat-Bold.ttf differ diff --git a/assets/fonts/Caveat-Medium.ttf b/assets/fonts/Caveat-Medium.ttf new file mode 100644 index 0000000..ec96174 Binary files /dev/null and b/assets/fonts/Caveat-Medium.ttf differ diff --git a/assets/fonts/Caveat-Regular.ttf b/assets/fonts/Caveat-Regular.ttf new file mode 100644 index 0000000..9654095 Binary files /dev/null and b/assets/fonts/Caveat-Regular.ttf differ diff --git a/assets/fonts/Caveat-SemiBold.ttf b/assets/fonts/Caveat-SemiBold.ttf new file mode 100644 index 0000000..113d70d Binary files /dev/null and b/assets/fonts/Caveat-SemiBold.ttf differ diff --git a/assets/modnet_photographic_portrait_matting.onnx b/assets/modnet_photographic_portrait_matting.onnx new file mode 100644 index 0000000..929e054 Binary files /dev/null and b/assets/modnet_photographic_portrait_matting.onnx differ diff --git a/assets/streams.json b/assets/streams.json new file mode 100644 index 0000000..6b8838a --- /dev/null +++ b/assets/streams.json @@ -0,0 +1,10 @@ +[ + "rtsp://192.168.1.23/user=admin&password=admin123&channel=1&stream=0.sdp?", + "rtsp://192.168.1.24/user=admin&password=admin123&channel=1&stream=0.sdp?", + "rtsp://192.168.1.25/user=admin&password=admin123&channel=1&stream=0.sdp?", + "rtsp://192.168.1.26/user=admin&password=admin123&channel=1&stream=0.sdp?", + "rtsp://192.168.1.27/user=admin&password=admin123&channel=1&stream=0.sdp?", + "rtsp://192.168.1.28/user=admin&password=admin123&channel=1&stream=0.sdp?", + "rtsp://192.168.1.29/user=admin&password=admin123&channel=1&stream=0.sdp?", + "rtsp://192.168.1.30/user=admin&password=admin123&channel=1&stream=0.sdp?" +] diff --git a/src/lib.rs b/src/lib.rs index 6e77d68..aeec5a2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,22 @@ +use bevy::prelude::*; + +#[cfg(feature = "person_matting")] +pub mod matting; + +pub mod materials; pub mod mp4; pub mod stream; + + +pub struct LightFieldPlugin { + pub stream_config: String, +} + +impl Plugin for LightFieldPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(materials::StreamMaterialsPlugin); + app.add_plugins(stream::RtspStreamPlugin { + stream_config: self.stream_config.clone(), + }); + } +} diff --git a/src/materials/foreground.rs b/src/materials/foreground.rs new file mode 100644 index 0000000..854f9cf --- /dev/null +++ b/src/materials/foreground.rs @@ -0,0 +1,40 @@ +use bevy::{ + prelude::*, + asset::load_internal_asset, + render::render_resource::*, +}; + + +const FOREGROUND_SHADER_HANDLE: Handle = Handle::weak_from_u128(5231534123); + +pub struct ForegroundPlugin; +impl Plugin for ForegroundPlugin { + fn build(&self, app: &mut App) { + load_internal_asset!( + app, + FOREGROUND_SHADER_HANDLE, + "foreground.wgsl", + Shader::from_wgsl + ); + + app.add_plugins(UiMaterialPlugin::::default()); + } +} + + +#[derive(AsBindGroup, Asset, TypePath, Debug, Clone)] +pub struct ForegroundMaterial { + #[texture(0)] + #[sampler(1)] + pub input: Handle, + + #[texture(2)] + #[sampler(3)] + pub mask: Handle, +} + +impl UiMaterial for ForegroundMaterial { + fn fragment_shader() -> ShaderRef { + ShaderRef::Handle(FOREGROUND_SHADER_HANDLE) + } +} diff --git a/src/materials/foreground.wgsl b/src/materials/foreground.wgsl new file mode 100644 index 0000000..6fe85a1 --- /dev/null +++ b/src/materials/foreground.wgsl @@ -0,0 +1,22 @@ +#import bevy_ui::ui_vertex_output::UiVertexOutput + + +@group(1) @binding(0) var foreground_texture: texture_2d; +@group(1) @binding(1) var foreground_sampler: sampler; + +@group(1) @binding(2) var mask_texture: texture_2d; +@group(1) @binding(3) var mask_sampler: sampler; + + +@fragment +fn fragment(in: UiVertexOutput) -> @location(0) vec4 { + return textureSample( + foreground_texture, + foreground_sampler, + in.uv, + ) * textureSample( + mask_texture, + mask_sampler, + in.uv, + ).x; +} diff --git a/src/materials/mod.rs b/src/materials/mod.rs new file mode 100644 index 0000000..4cabb0a --- /dev/null +++ b/src/materials/mod.rs @@ -0,0 +1,11 @@ +use bevy::prelude::*; + +pub mod foreground; + + +pub struct StreamMaterialsPlugin; +impl Plugin for StreamMaterialsPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(foreground::ForegroundPlugin); + } +} diff --git a/src/matting.rs b/src/matting.rs new file mode 100644 index 0000000..a7d769f --- /dev/null +++ b/src/matting.rs @@ -0,0 +1,170 @@ +use bevy::{ + prelude::*, + ecs::system::CommandQueue, + tasks::{block_on, futures_lite::future, AsyncComputeTaskPool, Task}, +}; +use bevy_ort::{ + BevyOrtPlugin, + inputs, + models::modnet::{ + images_to_modnet_input, + modnet_output_to_luma_images, + }, + Onnx, +}; + +use crate::{ + materials::foreground::ForegroundMaterial, + stream::StreamId, +}; + + +#[derive(Component, Clone, Debug, Reflect)] +pub struct MattedStream { + pub stream_id: StreamId, + pub input: Handle, + pub output: Handle, + pub material: Handle, +} + + +#[derive(Resource, Default, Clone)] +pub struct InferenceSize(pub (u32, u32)); + +pub struct MattingPlugin { + pub max_inference_size: InferenceSize, +} + +impl MattingPlugin { + pub fn new(max_inference_size: (u32, u32)) -> Self { + MattingPlugin { + max_inference_size: InferenceSize(max_inference_size), + } + } +} + +impl Plugin for MattingPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(BevyOrtPlugin); + app.register_type::(); + app.init_resource::(); + app.insert_resource(self.max_inference_size.clone()); + app.add_systems(Startup, load_modnet); + app.add_systems(Update, matting_inference); + } +} + + +#[derive(Resource, Default)] +pub struct Modnet { + pub onnx: Handle, +} + + +fn load_modnet( + asset_server: Res, + mut modnet: ResMut, +) { + let modnet_handle: Handle = asset_server.load("modnet_photographic_portrait_matting.onnx"); + modnet.onnx = modnet_handle; +} + + +#[derive(Default)] +struct ModnetComputePipeline(Option>); + + +fn matting_inference( + mut commands: Commands, + images: Res>, + modnet: Res, + matted_streams: Query< + ( + Entity, + &MattedStream, + ) + >, + onnx_assets: Res>, + mut pipeline_local: Local, + inference_size: Res, +) { + if let Some(pipeline) = pipeline_local.0.as_mut() { + if let Some(mut commands_queue) = block_on(future::poll_once(pipeline)) { + commands.append(&mut commands_queue); + pipeline_local.0 = None; + } + + return; + } + + if matted_streams.is_empty() { + return; + } + + let thread_pool = AsyncComputeTaskPool::get(); + + let (inputs, outputs): (Vec<_>, Vec<_>) = matted_streams.iter() + .map(|(_, matted_stream)| { + let input = images.get(matted_stream.input.clone()).unwrap(); + let output = (matted_stream.output.clone(), matted_stream.material.clone()); + (input, output) + }) + .unzip(); + + let uninitialized = inputs.iter().any(|image| image.size() == (32, 32).into()); + if uninitialized { + return; + } + + let input = images_to_modnet_input( + inputs.as_slice(), + inference_size.0.into(), + ); + + if onnx_assets.get(&modnet.onnx).is_none() { + return; + } + + let onnx = onnx_assets.get(&modnet.onnx).unwrap(); + let session_arc = onnx.session.clone(); + + let task = thread_pool.spawn(async move { + let mask_images: Result, String> = (|| { + let session_lock = session_arc.lock().map_err(|e| e.to_string())?; + let session = session_lock.as_ref().ok_or("failed to get session from ONNX asset")?; + + let input_values = inputs!["input" => input.view()].map_err(|e| e.to_string())?; + let outputs = session.run(input_values).map_err(|e| e.to_string()); + + let binding = outputs.ok().unwrap(); + let output_value: &ort::Value = binding.get("output").unwrap(); + + Ok(modnet_output_to_luma_images(output_value)) + })(); + + match mask_images { + Ok(mask_images) => { + let mut command_queue = CommandQueue::default(); + + command_queue.push(move |world: &mut World| { + world.resource_scope(|world, mut images: Mut>| { + world.resource_scope(|_world, mut foreground_materials: Mut>| { + outputs.into_iter().zip(mask_images).for_each(|((output, material), mask_image)| { + images.insert(output, mask_image); + foreground_materials.get_mut(&material).unwrap(); + }); + }); + }); + }); + + command_queue + }, + Err(error) => { + eprintln!("inference failed: {}", error); + CommandQueue::default() + } + } + }); + + *pipeline_local = ModnetComputePipeline(Some(task)); +} diff --git a/src/mp4.rs b/src/mp4.rs index a76c945..fc420f2 100644 --- a/src/mp4.rs +++ b/src/mp4.rs @@ -4,7 +4,6 @@ use anyhow::{anyhow, bail, Error}; use bytes::{Buf, BufMut, BytesMut}; use retina::codec::{AudioParameters, ParametersRef, VideoParameters}; -use std::convert::TryFrom; use std::io::SeekFrom; use tokio::io::{AsyncSeek, AsyncSeekExt, AsyncWrite, AsyncWriteExt}; diff --git a/src/stream.rs b/src/stream.rs index bc20f64..bc1588d 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -24,6 +24,7 @@ use retina::{ }, codec::VideoFrame, }; +use serde::{Deserialize, Serialize}; use tokio::{ fs::File, runtime::{ @@ -37,10 +38,17 @@ use url::Url; use crate::mp4::Mp4Writer; -pub struct RtspStreamPlugin; +pub struct RtspStreamPlugin { + pub stream_config: String, +} + impl Plugin for RtspStreamPlugin { fn build(&self, app: &mut App) { + let config = std::fs::File::open(&self.stream_config).unwrap(); + let stream_uris = serde_json::from_reader::<_, StreamUris>(config).unwrap(); + app + .insert_resource(stream_uris) .init_resource::() .add_systems(Update, create_streams_from_descriptors) .add_systems(Update, apply_decode); @@ -426,3 +434,8 @@ fn convert_h264(data: &mut [u8]) -> Result<(), Error> { Ok(()) } + + + +#[derive(Resource, Clone, Debug, Default, Reflect, Serialize, Deserialize)] +pub struct StreamUris(pub Vec); diff --git a/tools/viewer.rs b/tools/viewer.rs index d135f99..b77ce81 100644 --- a/tools/viewer.rs +++ b/tools/viewer.rs @@ -1,6 +1,10 @@ use bevy::{ prelude::*, app::AppExit, + diagnostic::{ + DiagnosticsStore, + FrameTimeDiagnosticsPlugin, + }, render::{ render_asset::RenderAssetUsages, render_resource::{ @@ -13,37 +17,103 @@ use bevy::{ }, window::PrimaryWindow, }; +use bevy_args::{ + parse_args, + BevyArgsPlugin, + Deserialize, + Parser, + Serialize, +}; -use bevy_light_field::stream::{ - RtspStreamDescriptor, RtspStreamManager, RtspStreamPlugin, StreamId +use bevy_light_field::{ + LightFieldPlugin, + materials::foreground::ForegroundMaterial, + stream::{ + RtspStreamDescriptor, + RtspStreamManager, + StreamId, + StreamUris, + }, }; +#[cfg(feature = "person_matting")] +use bevy_light_field::matting::{ + MattedStream, + MattingPlugin, +}; + + +#[derive( + Default, + Debug, + Resource, + Serialize, + Deserialize, + Parser, +)] +#[command(about = "bevy_light_field viewer", version)] +pub struct LightFieldViewer { + #[arg(long, default_value = "assets/streams.json")] + pub config: String, + + #[arg(long, default_value = "false")] + pub show_fps: bool, + + #[arg(long, default_value = "false")] + pub fullscreen: bool, + + #[arg(long, default_value = "1920.0")] + pub width: f32, + #[arg(long, default_value = "1080.0")] + pub height: f32, + + #[arg(long, default_value = "512")] + pub max_matting_width: u32, + #[arg(long, default_value = "512")] + pub max_matting_height: u32, + + #[arg(long, default_value = "false")] + pub extract_foreground: bool, +} -const RTSP_URIS: [&str; 2] = [ - "rtsp://192.168.1.23/user=admin&password=admin123&channel=1&stream=0.sdp?", - "rtsp://192.168.1.24/user=admin&password=admin123&channel=1&stream=0.sdp?", -]; -// TODO: add bevy_args fn main() { + let args = parse_args::(); + + let mode = if args.fullscreen { + bevy::window::WindowMode::BorderlessFullscreen + } else { + bevy::window::WindowMode::Windowed + }; + let primary_window = Some(Window { - mode: bevy::window::WindowMode::Windowed, + mode, prevent_default_event_handling: false, - resolution: (1920.0, 1080.0).into(), + resolution: (args.width, args.height).into(), title: "bevy_light_field - rtsp viewer".to_string(), present_mode: bevy::window::PresentMode::AutoVsync, ..default() }); - App::new() + let mut app = App::new(); + app + .add_plugins(BevyArgsPlugin::::default()) .add_plugins(( DefaultPlugins .set(WindowPlugin { primary_window, ..default() }), - RtspStreamPlugin, + LightFieldPlugin { + stream_config: args.config.clone(), + }, + + #[cfg(feature = "person_matting")] + MattingPlugin::new(( + args.max_matting_width, + args.max_matting_height, + )), )) .add_systems(Startup, create_streams) .add_systems(Startup, setup_camera) @@ -54,8 +124,15 @@ fn main() { press_r_start_recording, press_s_stop_recording ) - ) - .run(); + ); + + if args.show_fps { + app.add_plugins(FrameTimeDiagnosticsPlugin); + app.add_systems(Startup, fps_display_setup.after(create_streams)); + app.add_systems(Update, fps_update_system); + } + + app.run(); } @@ -63,34 +140,39 @@ fn create_streams( mut commands: Commands, mut images: ResMut>, primary_window: Query<&Window, With>, + args: Res, + mut foreground_materials: ResMut>, + stream_uris: Res, ) { let window = primary_window.single(); + let elements = stream_uris.0.len(); + let ( columns, rows, - sprite_width, - sprite_height, + _sprite_width, + _sprite_height, ) = calculate_grid_dimensions( window.width(), window.height(), - RTSP_URIS.len() + elements, ); - let images: Vec> = RTSP_URIS.iter() + let size = Extent3d { + width: 32, + height: 32, + ..default() + }; + + let input_images: Vec> = stream_uris.0.iter() .enumerate() - .map(|(index, &url)| { + .map(|(index, url)| { let entity = commands.spawn_empty().id(); - let size = Extent3d { - width: 32, - height: 32, - ..default() - }; - let mut image = Image { asset_usage: RenderAssetUsages::all(), texture_descriptor: TextureDescriptor { - label: Some(url), + label: None, size, dimension: TextureDimension::D2, format: TextureFormat::Rgba8UnormSrgb, @@ -120,6 +202,51 @@ fn create_streams( }) .collect(); + let mask_images = input_images.iter() + .enumerate() + .map(|(index, image)| { + let mut mask_images = Image { + asset_usage: RenderAssetUsages::all(), + texture_descriptor: TextureDescriptor { + label: None, + size, + dimension: TextureDimension::D2, + format: TextureFormat::Rgba8UnormSrgb, // TODO: use R8 format + mip_level_count: 1, + sample_count: 1, + usage: TextureUsages::COPY_DST + | TextureUsages::TEXTURE_BINDING + | TextureUsages::RENDER_ATTACHMENT, + view_formats: &[TextureFormat::Rgba8UnormSrgb], + }, + ..default() + }; + mask_images.resize(size); + let mask_image = images.add(mask_images); + + let mut material = None; + + #[cfg(feature = "person_matting")] + if args.extract_foreground { + let foreground_mat = foreground_materials.add(ForegroundMaterial { + input: image.clone(), + mask: mask_image.clone(), + }); + + commands.spawn(MattedStream { + stream_id: StreamId(index), + input: image.clone(), + output: mask_image.clone(), + material: foreground_mat.clone(), + }); + + material = foreground_mat.into(); + } + + (mask_image, material) + }) + .collect::>(); + commands.spawn(NodeBundle { style: Style { display: Display::Grid, @@ -129,21 +256,34 @@ fn create_streams( grid_template_rows: RepeatedGridTrack::flex(rows as u16, 1.0), ..default() }, - background_color: BackgroundColor(Color::DARK_GRAY), + background_color: BackgroundColor(Color::BLACK), ..default() }) .with_children(|builder| { - images.iter() - .for_each(|image| { - builder.spawn(ImageBundle { - style: Style { - width: Val::Px(sprite_width), - height: Val::Px(sprite_height), + input_images.iter() + .zip(mask_images.iter()) + .for_each(|(input, (_mask, material))| { + if args.extract_foreground { + builder.spawn(MaterialNodeBundle { + style: Style { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + ..default() + }, + material: material.clone().unwrap(), ..default() - }, - image: UiImage::new(image.clone()), - ..default() - }); + }); + } else { + builder.spawn(ImageBundle { + style: Style { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + ..default() + }, + image: UiImage::new(input.clone()), + ..default() + }); + } }); }); } @@ -173,20 +313,16 @@ fn press_r_start_recording( stream_manager: Res ) { if keys.just_pressed(KeyCode::KeyR) { - let output_directory = "capture"; - std::fs::create_dir_all(output_directory).unwrap(); - let base_prefix = "bevy_light_field_"; + let output_directory = "capture"; + let session_id = get_next_session_id(output_directory); + let output_directory = format!("{}/{}", output_directory, session_id); - let prefix = format!( - "{}{:03}", - base_prefix, - get_next_session_id(output_directory, base_prefix) - ); + std::fs::create_dir_all(&output_directory).unwrap(); stream_manager.start_recording( - output_directory, - &prefix, + &output_directory, + "bevy_light_field", ); } } @@ -234,24 +370,65 @@ fn calculate_grid_dimensions(window_width: f32, window_height: f32, num_streams: } -fn get_next_session_id(output_directory: &str, base_prefix: &str) -> i32 { - let mut highest_count = -1i32; - if let Ok(entries) = std::fs::read_dir(output_directory) { - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { - if stem.starts_with(base_prefix) { - let suffix = stem.trim_start_matches(base_prefix); - let numeric_part = suffix.split('_').next().unwrap_or(""); - if let Ok(num) = numeric_part.parse::() { - highest_count = highest_count.max(num); - } else { - println!("failed to parse session ID '{}' for file '{}'", numeric_part, stem); - } +fn get_next_session_id(output_directory: &str) -> i32 { + match std::fs::read_dir(output_directory) { + Ok(entries) => entries.filter_map(|entry| { + let entry = entry.ok()?; + if entry.path().is_dir() { + entry.file_name().to_string_lossy().parse::().ok() + } else { + None } + }) + .max() + .map_or(0, |max_id| max_id + 1), + Err(_) => 0, + } +} + + +fn fps_display_setup( + mut commands: Commands, + asset_server: Res, +) { + commands.spawn(( + TextBundle::from_sections([ + TextSection::new( + "fps: ", + TextStyle { + font: asset_server.load("fonts/Caveat-Bold.ttf"), + font_size: 60.0, + color: Color::WHITE, + }, + ), + TextSection::from_style(TextStyle { + font: asset_server.load("fonts/Caveat-Medium.ttf"), + font_size: 60.0, + color: Color::GOLD, + }), + ]).with_style(Style { + position_type: PositionType::Absolute, + width: Val::Px(200.0), + bottom: Val::Px(5.0), + right: Val::Px(15.0), + ..default() + }), + FpsText, + )); +} + +#[derive(Component)] +struct FpsText; + +fn fps_update_system( + diagnostics: Res, + mut query: Query<&mut Text, With>, +) { + for mut text in &mut query { + if let Some(fps) = diagnostics.get(&FrameTimeDiagnosticsPlugin::FPS) { + if let Some(value) = fps.smoothed() { + text.sections[1].value = format!("{:.2}", value); } } } - - highest_count + 1 }