From e54140d0cf02bdb6fe182bfaa69bfb1c5ba95f6c Mon Sep 17 00:00:00 2001 From: Alex Reilly Date: Sun, 29 Dec 2024 18:50:33 -0500 Subject: [PATCH] Timeline (#157) Adds the ability to rewind time in the UI. --- frontend/app/src/simulation_window.rs | 41 ++++++ frontend/app/src/ui/mod.rs | 141 ++++++++++++++++----- frontend/app/style.css | 11 ++ frontend/renderer/src/particle_renderer.rs | 26 +++- frontend/renderer/src/trail_renderer.rs | 20 ++- frontend/simulation_worker/src/lib.rs | 3 +- shared/simulator/src/scenario/mod.rs | 7 + shared/simulator/src/snapshot.rs | 2 + 8 files changed, 210 insertions(+), 41 deletions(-) diff --git a/frontend/app/src/simulation_window.rs b/frontend/app/src/simulation_window.rs index 61d77038..7043bfa5 100644 --- a/frontend/app/src/simulation_window.rs +++ b/frontend/app/src/simulation_window.rs @@ -4,6 +4,8 @@ use oort_simulation_worker::SimAgent; use oort_simulator::{scenario, simulation::Code, snapshot::Snapshot}; use rand::Rng; use std::rc::Rc; +use wasm_bindgen::JsCast; +use web_sys::HtmlInputElement; use yew::html::Scope; use yew::prelude::*; use yew_agent::{Bridge, Bridged}; @@ -21,6 +23,7 @@ pub enum Msg { WheelEvent(web_sys::WheelEvent), PointerEvent(web_sys::PointerEvent), BlurEvent(web_sys::FocusEvent), + TimelineEvent(usize), RequestSnapshot, ReceivedSimAgentResponse(oort_simulation_worker::Response), } @@ -44,6 +47,7 @@ pub struct SimulationWindow { canvas_ref: NodeRef, status_ref: NodeRef, picked_ref: NodeRef, + timeline_ref: NodeRef, } impl Component for SimulationWindow { @@ -72,6 +76,7 @@ impl Component for SimulationWindow { canvas_ref: context.props().canvas_ref.clone(), status_ref: NodeRef::default(), picked_ref: NodeRef::default(), + timeline_ref: NodeRef::default(), } } @@ -107,6 +112,15 @@ impl Component for SimulationWindow { Msg::Render => { if let Some(ui) = self.ui.as_mut() { ui.render(); + + // Move timeline indicator + if let Some(timeline_ref) = self.timeline_ref.cast::() { + if ui.is_buffering() { + timeline_ref.set_value(ui.snapshot_count().to_string().as_str()); + } else { + timeline_ref.set_value_as_number(ui.snapshot_index() as f64); + } + } } self.check_status(context) } @@ -147,6 +161,19 @@ impl Component for SimulationWindow { }) => { if let Some(ui) = self.ui.as_mut() { ui.on_snapshot(snapshot); + + // Update timeline with new max value + if let Some(timeline_ref) = self.timeline_ref.cast::() { + timeline_ref.set_max(ui.snapshot_count().to_string().as_str()); + } + } + false + } + Msg::TimelineEvent(index) => { + // Timeline was set to a new index + // Display the relevant snapshot + if let Some(ui) = self.ui.as_mut() { + ui.set_snapshot_index(index); } false } @@ -172,6 +199,19 @@ impl Component for SimulationWindow { let pointer_event_cb = context.link().callback(Msg::PointerEvent); let blur_event_cb = context.link().callback(Msg::BlurEvent); + let timeline_event_cb = context.link().callback(|event: InputEvent| { + let target = event + .target() + .expect("Event should have a target when dispatched"); + + let slider_value = target + .unchecked_into::() + .value_as_number() + .round() as usize; + + Msg::TimelineEvent(slider_value) + }); + create_portal( html! { <> @@ -185,6 +225,7 @@ impl Component for SimulationWindow { onpointerup={pointer_event_cb.clone()} onpointerdown={pointer_event_cb} onblur={blur_event_cb} /> +

diff --git a/frontend/app/src/ui/mod.rs b/frontend/app/src/ui/mod.rs
index d8f6948d..1032e247 100644
--- a/frontend/app/src/ui/mod.rs
+++ b/frontend/app/src/ui/mod.rs
@@ -9,7 +9,7 @@ use oort_simulator::model;
 use oort_simulator::scenario::Status;
 use oort_simulator::simulation::{self, PHYSICS_TICK_LENGTH};
 use oort_simulator::snapshot::{self, ShipSnapshot, Snapshot};
-use std::collections::{HashMap, HashSet, VecDeque};
+use std::collections::{HashMap, HashSet};
 use std::time::Duration;
 use web_sys::{Element, HtmlCanvasElement};
 use yew::NodeRef;
@@ -28,7 +28,7 @@ pub struct UI {
     seed: u32,
     snapshot: Option,
     uninterpolated_snapshot: Option,
-    pending_snapshots: VecDeque,
+    snapshots: Vec,
     renderer: Renderer,
     canvas: HtmlCanvasElement,
     zoom: f32,
@@ -37,7 +37,8 @@ pub struct UI {
     frame_timer: frame_timer::FrameTimer,
     status: Status,
     quit: bool,
-    single_steps: i32,
+    steps_forward: i32,
+    steps_backward: i32,
     paused: bool,
     slowmo: bool,
     keys_down: std::collections::HashSet,
@@ -45,11 +46,13 @@ pub struct UI {
     frame: u64,
     start_time: instant::Instant,
     last_render_time: instant::Instant,
+    /// Time difference between the first and current frame
     physics_time: std::time::Duration,
     fps: fps::FPS,
     debug: bool,
     last_status_msg: String,
     snapshot_requests_in_flight: usize,
+    is_buffering: bool,
     nonce: u32,
     request_snapshot: yew::Callback<()>,
     picked_ship_id: Option,
@@ -90,7 +93,6 @@ impl UI {
         let camera_offset = vector![0.0, 0.0];
         renderer.set_view(zoom, camera_focus + camera_offset);
         let frame_timer: frame_timer::FrameTimer = Default::default();
-        let single_steps = 0;
 
         let keys_down = std::collections::HashSet::::new();
         let keys_pressed = std::collections::HashSet::::new();
@@ -105,7 +107,7 @@ impl UI {
             seed,
             snapshot: None,
             uninterpolated_snapshot: None,
-            pending_snapshots: VecDeque::new(),
+            snapshots: Vec::new(),
             renderer,
             canvas,
             zoom,
@@ -114,7 +116,8 @@ impl UI {
             frame_timer,
             status: Status::Running,
             quit: false,
-            single_steps,
+            steps_forward: 0,
+            steps_backward: 0,
             paused,
             slowmo: false,
             keys_down,
@@ -127,6 +130,7 @@ impl UI {
             debug,
             last_status_msg: "".to_owned(),
             snapshot_requests_in_flight: 0,
+            is_buffering: true,
             nonce,
             request_snapshot,
             picked_ship_id: None,
@@ -147,11 +151,7 @@ impl UI {
         self.needs_render = false;
 
         let now = instant::Instant::now();
-        let elapsed = now - self.last_render_time;
-        self.last_render_time = now;
-        if elapsed.as_millis() > 20 {
-            debug!("Late render: {:.1} ms", elapsed.as_millis());
-        }
+
         self.fps
             .start_frame((now - self.start_time).as_millis() as f64);
         self.frame_timer
@@ -182,11 +182,19 @@ impl UI {
         }
         if self.keys_pressed.contains("Space") {
             self.paused = !self.paused;
-            self.single_steps = 0;
+            self.steps_forward = 0;
+            self.steps_backward = 0;
+            self.is_buffering = false;
+            self.last_render_time = instant::Instant::now();
         }
         if self.keys_pressed.contains("KeyN") {
             self.paused = true;
-            self.single_steps += 1;
+            self.steps_forward += 1;
+            self.is_buffering = false;
+        } else if self.keys_pressed.contains("KeyP") {
+            self.paused = true;
+            self.steps_backward += 1;
+            self.is_buffering = false;
         }
 
         if is_mac() {
@@ -240,13 +248,20 @@ impl UI {
             }
         }
 
+        let elapsed = now - self.last_render_time;
+        self.last_render_time = now;
+        if elapsed.as_millis() > 20 {
+            debug!("Late render: {:.1} ms", elapsed.as_millis());
+        }
+
         if !self.paused && !self.slowmo {
             self.physics_time += elapsed;
         }
 
         if self.status == Status::Running
             && (!self.paused
-                || self.single_steps > 0
+                || self.steps_forward > 0
+                || self.steps_backward > 0
                 || fast_forward
                 || self.slowmo
                 || self.snapshot.is_none())
@@ -257,17 +272,27 @@ impl UI {
                     self.physics_time += dt;
                     self.update_snapshot(true);
                 }
-            } else if self.single_steps > 0 {
+            } else if self.steps_forward > 0 {
                 self.physics_time += dt;
                 self.update_snapshot(false);
+            } else if self.steps_backward > 0 {
+                // Avoiding overflow
+                self.physics_time = if self.physics_time < dt {
+                    Duration::from_secs(0)
+                } else {
+                    self.physics_time - dt
+                };
+
+                self.update_snapshot(false);
+                self.steps_backward -= 1;
             } else if self.slowmo {
                 self.physics_time += dt / 10;
                 self.update_snapshot(true);
             } else {
                 self.update_snapshot(true);
             }
-            if self.single_steps > 0 {
-                self.single_steps -= 1;
+            if self.steps_forward > 0 {
+                self.steps_forward -= 1;
             }
         } else if self.paused != was_paused || self.slowmo != was_slowmo {
             self.update_snapshot(false);
@@ -317,7 +342,7 @@ impl UI {
             _ => {}
         }
 
-        if self.pending_snapshots.len() <= 1 && !fast_forward {
+        if self.snapshots.len() <= 1 && !fast_forward {
             status_msgs.push("SLOW SIM".to_owned());
         }
 
@@ -341,7 +366,7 @@ impl UI {
                 if let Some(snapshot) = self.snapshot.as_ref() {
                     status_msgs.push(format!("SIM {:.1} ms", snapshot.timing.total() * 1e3));
                 }
-                status_msgs.push(format!("SNAP {}", self.pending_snapshots.len()));
+                status_msgs.push(format!("SNAP {}", self.snapshots.len()));
             }
             status_msgs.push(self.version.clone());
             let status_msg = status_msgs.join("; ");
@@ -374,7 +399,7 @@ impl UI {
             return;
         }
 
-        self.pending_snapshots.push_back(snapshot);
+        self.snapshots.push(snapshot);
         if self.snapshot_requests_in_flight > 0 {
             self.snapshot_requests_in_flight -= 1;
         }
@@ -382,34 +407,54 @@ impl UI {
         self.needs_render = true;
     }
 
+    /// Sets the displayed snapshot by index
+    ///
+    /// The time of the first snapshot is expected to be at 0 at
+    /// the time this was written. This will need to be updated if
+    /// that expectation changes.
+    ///
+    /// Snapshots are evenly spaced, so an index can easily be converted into a time
+    /// and vice versa
+    pub fn set_snapshot_index(&mut self, index: usize) {
+        self.physics_time = Duration::from_secs_f64((index as f64) * PHYSICS_TICK_LENGTH);
+        self.paused = true;
+        self.update_snapshot(false);
+        self.needs_render = true;
+        self.is_buffering = false;
+        let _ = self.canvas.focus();
+    }
+
+    pub fn is_buffering(&self) -> bool {
+        self.is_buffering
+    }
+
     pub fn update_snapshot(&mut self, interpolate: bool) {
-        while self.pending_snapshots.len() > SNAPSHOT_PRELOAD / 2
-            && std::time::Duration::from_secs_f64(self.pending_snapshots[1].time)
-                <= self.physics_time
-        {
-            self.pending_snapshots.pop_front();
-        }
+        let snapshot_index =
+            (self.physics_time.as_secs_f64() / PHYSICS_TICK_LENGTH).round() as usize;
 
-        if self.pending_snapshots.len() < SNAPSHOT_PRELOAD
+        // Reaching the end of the preloaded snapshots, need to preload more
+        if SNAPSHOT_PRELOAD + 1 + snapshot_index > self.snapshots.len()
             && self.snapshot_requests_in_flight < MAX_SNAPSHOT_REQUESTS_IN_FLIGHT
         {
             self.request_snapshot.emit(());
             self.request_snapshot.emit(());
             self.snapshot_requests_in_flight += 2;
+            self.is_buffering = true;
         }
 
-        if !self.pending_snapshots.is_empty()
-            && std::time::Duration::from_secs_f64(self.pending_snapshots[0].time)
+        if snapshot_index < self.snapshots.len()
+            && std::time::Duration::from_secs_f64(self.snapshots[snapshot_index].time)
                 <= self.physics_time
         {
             let first_snapshot = self.snapshot.is_none();
 
-            self.snapshot = self.pending_snapshots.pop_front();
+            self.snapshot = self.snapshots.get(snapshot_index).cloned();
             self.uninterpolated_snapshot = self.snapshot.clone();
+
             let snapshot = self.snapshot.as_mut().unwrap();
 
             if first_snapshot {
-                // Zoom out to show all ships.
+                // Set zoom to show all ships.
                 let mut points = snapshot
                     .ships
                     .iter()
@@ -449,17 +494,37 @@ impl UI {
         }
 
         if let Some(snapshot) = self.snapshot.as_mut() {
+            // Time of the snapshot
             let t = std::time::Duration::from_secs_f64(snapshot.time);
-            assert!(self.physics_time >= t);
-            let mut delta = (self.physics_time - t).min(Duration::from_millis(16));
+
+            // Find the difference between current time and snapshot time, with a max value of 16
+            // TODO: Why 16? The tick length is 16.66 miliseconds. Maybe that's it?
+            let mut delta = if t <= self.physics_time {
+                (self.physics_time - t).min(Duration::from_millis(16))
+            } else {
+                (t - self.physics_time).min(Duration::from_millis(16))
+            };
+
+            // TODO: Unsure what this is for
+            // TODO: More magic numbers
             if delta > Duration::from_millis(3) {
                 delta -= Duration::from_millis(1);
             }
-            self.physics_time = t + delta;
+
+            // Set the new current time to the time of the snapshot + the delta
+            // Could just be unchanged if the delta is less than 4
+            self.physics_time = if t <= self.physics_time {
+                t + delta
+            } else {
+                t - delta
+            };
 
             if interpolate {
+                // Alters snapshot to make it appear as it would at `physics_time`
                 snapshot::interpolate(snapshot, delta.as_secs_f64());
             } else if snapshot.time != self.uninterpolated_snapshot.as_ref().unwrap().time {
+                // TODO: Do we need to do this? Does this ever happen?
+                // uninterpolated_snapshot is a copy of snapshot
                 *snapshot = self.uninterpolated_snapshot.as_ref().unwrap().clone();
             }
 
@@ -613,6 +678,14 @@ impl UI {
         self.snapshot.clone()
     }
 
+    pub fn snapshot_count(&self) -> usize {
+        self.snapshots.len()
+    }
+
+    pub fn snapshot_index(&self) -> usize {
+        (self.snapshot.as_ref().map_or(0.0, |s| s.time) / PHYSICS_TICK_LENGTH).round() as usize
+    }
+
     pub fn update_picked(&mut self) {
         if let Some(ship) = self.picked_ship_id.and_then(|id| {
             self.snapshot
diff --git a/frontend/app/style.css b/frontend/app/style.css
index e91b5cf0..3f19fe7c 100644
--- a/frontend/app/style.css
+++ b/frontend/app/style.css
@@ -103,6 +103,17 @@ body {
   font-size: 24px;
 }
 
+.slider {
+  bottom: 50px;
+  right: 20px;
+  height: 24px;
+  left: 20px;
+
+  width: fill-available;
+  position: absolute;
+  color: #dddddd;
+}
+
 .picked {
   top: 20px;
   left: 20px;
diff --git a/frontend/renderer/src/particle_renderer.rs b/frontend/renderer/src/particle_renderer.rs
index 6f0d8c87..baafaf30 100644
--- a/frontend/renderer/src/particle_renderer.rs
+++ b/frontend/renderer/src/particle_renderer.rs
@@ -9,6 +9,7 @@ use web_sys::{WebGl2RenderingContext, WebGlProgram, WebGlUniformLocation, WebGlV
 use WebGl2RenderingContext as gl;
 
 const MAX_PARTICLES: usize = 1000;
+const EMPTY_PARTICLE_CREATION_TIME: f32 = -100.0;
 
 pub struct ParticleRenderer {
     context: WebGl2RenderingContext,
@@ -18,6 +19,8 @@ pub struct ParticleRenderer {
     scale_loc: WebGlUniformLocation,
     buffer_arena: buffer_arena::BufferArena,
     particles: Vec,
+    /// Used to avoid recreating particles when re-rendering
+    furthest_snapshot_seen: f32,
     next_particle_index: usize,
     max_particles_seen: usize,
     vao: WebGlVertexArrayObject,
@@ -43,6 +46,12 @@ pub struct DrawSet {
     current_time: f32,
 }
 
+impl DrawSet {
+    pub fn len(&self) -> usize {
+        self.num_instances
+    }
+}
+
 impl ParticleRenderer {
     pub fn new(context: WebGl2RenderingContext) -> Result {
         let vert_shader = glutil::compile_shader(
@@ -109,7 +118,7 @@ void main() {
                 velocity: vector![0.0, 0.0],
                 color: vector![0.0, 0.0, 0.0, 0.0],
                 lifetime: 1.0,
-                creation_time: -100.0,
+                creation_time: EMPTY_PARTICLE_CREATION_TIME,
             });
         }
 
@@ -126,6 +135,7 @@ void main() {
                 1024 * 1024,
             )?,
             particles,
+            furthest_snapshot_seen: -1.0,
             next_particle_index: 0,
             max_particles_seen: MAX_PARTICLES,
             vao,
@@ -141,6 +151,14 @@ void main() {
     }
 
     pub fn update(&mut self, snapshot: &Snapshot) {
+        // If we've already seen the snapshot, there's no need to recreate
+        // its particles
+        // We're using a String for the times because Rust's floats
+        // don't implement Hash or Eq
+        if snapshot.time as f32 <= self.furthest_snapshot_seen {
+            return;
+        }
+
         if snapshot.particles.len() > self.max_particles_seen {
             self.max_particles_seen = snapshot.particles.len();
             log::info!("Saw {} particles in snapshot", self.max_particles_seen);
@@ -154,6 +172,8 @@ void main() {
                 creation_time: snapshot.time as f32,
             });
         }
+
+        self.furthest_snapshot_seen = self.furthest_snapshot_seen.max(snapshot.time as f32);
     }
 
     pub fn upload(&mut self, projection_matrix: &Matrix4, snapshot: &Snapshot) -> DrawSet {
@@ -164,7 +184,9 @@ void main() {
         let attribs: Vec = self
             .particles
             .iter()
-            .filter(|x| x.creation_time >= current_time - 10.0)
+            .filter(|x| {
+                current_time >= x.creation_time && current_time <= x.creation_time + x.lifetime
+            })
             .cloned()
             .collect();
 
diff --git a/frontend/renderer/src/trail_renderer.rs b/frontend/renderer/src/trail_renderer.rs
index 7d790fee..dcc01ad5 100644
--- a/frontend/renderer/src/trail_renderer.rs
+++ b/frontend/renderer/src/trail_renderer.rs
@@ -3,7 +3,6 @@ use log::warn;
 use nalgebra::{vector, Matrix4, Point2, UnitComplex, Vector2};
 use oort_api::Ability;
 use oort_simulator::ship::ShipClass;
-use oort_simulator::simulation::PHYSICS_TICK_LENGTH;
 use oort_simulator::snapshot::Snapshot;
 use std::collections::HashMap;
 use wasm_bindgen::prelude::*;
@@ -24,6 +23,7 @@ pub struct TrailRenderer {
     buffer: WebGlBuffer,
     index: i32,
     last_positions: HashMap>,
+    prev_snapshot: Option,
 }
 
 impl TrailRenderer {
@@ -42,7 +42,7 @@ void main() {
     gl_Position = transform * vertex;
     varying_color = color;
     float lifetime = 2.0;
-    float age_frac = clamp((current_time - creation_time) / lifetime, 0.0, 1.0);
+    float age_frac = current_time >= creation_time ? clamp((current_time - creation_time) / lifetime, 0.0, 1.0) : 1.0;
     varying_color.a *= (1.0 - age_frac);
 }
     "#,
@@ -88,6 +88,7 @@ void main() {
             buffer,
             index: 0,
             last_positions: HashMap::new(),
+            prev_snapshot: None,
         })
     }
 
@@ -96,9 +97,13 @@ void main() {
     }
 
     pub fn update(&mut self, snapshot: &Snapshot) {
+        // Note: These snapshots may have been altered by interpolation, and you can't rely on even spacing.
+        // Using PHYSICS_TICK_LENGTH is not accurate.
         let mut data = Vec::with_capacity(snapshot.ships.len() * 2 * FLOATS_PER_VERTEX as usize);
         let mut n = 0;
+        let prev_creation_time = self.prev_snapshot.as_ref().map_or(0.0, |s| s.time as f32);
         let creation_time = snapshot.time as f32;
+
         for ship in snapshot.ships.iter() {
             if let ShipClass::Asteroid { .. } = ship.class {
                 continue;
@@ -108,7 +113,12 @@ void main() {
                     continue;
                 }
             }
-            let mut color = super::ShipRenderer::team_color(ship.team);
+            let mut color: nalgebra::Matrix<
+                f32,
+                nalgebra::Const<4>,
+                nalgebra::Const<1>,
+                nalgebra::ArrayStorage,
+            > = super::ShipRenderer::team_color(ship.team);
             color.w = match ship.class {
                 ShipClass::Missile => 0.10,
                 ShipClass::Torpedo => 0.15,
@@ -130,7 +140,7 @@ void main() {
                         data.push(color.y);
                         data.push(color.z);
                         data.push(color.w);
-                        data.push(creation_time - PHYSICS_TICK_LENGTH as f32);
+                        data.push(prev_creation_time);
                         data.push(0.0);
 
                         data.push(current_position.x);
@@ -151,6 +161,8 @@ void main() {
             }
         }
 
+        self.prev_snapshot = Some(snapshot.clone());
+
         assert_eq!(n % 2, 0);
 
         if n == 0 {
diff --git a/frontend/simulation_worker/src/lib.rs b/frontend/simulation_worker/src/lib.rs
index 637629a2..a1ad52cf 100644
--- a/frontend/simulation_worker/src/lib.rs
+++ b/frontend/simulation_worker/src/lib.rs
@@ -61,9 +61,10 @@ impl yew_agent::Worker for SimAgent {
                 self.link.respond(who, Response::Snapshot { snapshot });
             }
             Request::Snapshot { ticks, nonce } => {
-                if self.errored {
+                if self.errored || self.sim().status() != Status::Running {
                     return;
                 }
+
                 for _ in 0..ticks {
                     if self.sim().status() == Status::Running && self.sim().tick() < MAX_TICKS {
                         self.sim().step();
diff --git a/shared/simulator/src/scenario/mod.rs b/shared/simulator/src/scenario/mod.rs
index db68b5a1..e0d923d4 100644
--- a/shared/simulator/src/scenario/mod.rs
+++ b/shared/simulator/src/scenario/mod.rs
@@ -37,6 +37,7 @@ use nalgebra::{vector, Vector2};
 use rand::{seq::SliceRandom, Rng, RngCore};
 use serde::{Deserialize, Serialize};
 use std::collections::HashMap;
+use std::fmt;
 
 pub mod prelude {
     pub use super::Scenario;
@@ -71,6 +72,12 @@ pub enum Status {
     Draw,
 }
 
+impl fmt::Display for Status {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "{:?}", self)
+    }
+}
+
 pub trait Scenario {
     fn name(&self) -> String;
 
diff --git a/shared/simulator/src/snapshot.rs b/shared/simulator/src/snapshot.rs
index 8d0fa7f0..cb26f984 100644
--- a/shared/simulator/src/snapshot.rs
+++ b/shared/simulator/src/snapshot.rs
@@ -114,6 +114,8 @@ impl std::ops::Mul for Timing {
     }
 }
 
+/// Alters the snapshot, moving ships and bullets
+/// based on dt and their velocity
 pub fn interpolate(snapshot: &mut Snapshot, dt: f64) {
     snapshot.time += dt;