1#![warn(clippy::all, clippy::pedantic)]
7#![cfg_attr(
11 all(target_arch = "wasm32", feature = "nightly"),
12 feature(thread_local)
13)]
14
15pub mod auth;
16pub mod shared_world;
17pub mod world_state;
18
19#[cfg(target_arch = "wasm32")]
20pub mod metrics;
21
22#[cfg(test)]
23#[cfg(target_arch = "wasm32")]
24pub mod smoke_test;
25
26pub mod auth_proto {
30 #![allow(clippy::must_use_candidate, clippy::doc_markdown)]
31 tonic::include_proto!("aetheris.auth.v1");
32}
33
34#[cfg(target_arch = "wasm32")]
35pub mod transport;
36
37#[cfg(target_arch = "wasm32")]
38pub mod render;
39
40#[cfg(target_arch = "wasm32")]
41pub mod render_primitives;
42
43#[cfg(target_arch = "wasm32")]
44#[cfg_attr(feature = "nightly", thread_local)]
45static _TLS_ANCHOR: u8 = 0;
46
47use std::sync::atomic::AtomicUsize;
48#[cfg(target_arch = "wasm32")]
49use std::sync::atomic::Ordering;
50
51#[allow(dead_code)]
52static NEXT_WORKER_ID: AtomicUsize = AtomicUsize::new(1);
53
54#[cfg(target_arch = "wasm32")]
55thread_local! {
56 static WORKER_ID: usize = NEXT_WORKER_ID.fetch_add(1, Ordering::Relaxed);
57}
58
59#[must_use]
61pub fn performance_now() -> f64 {
62 #[cfg(target_arch = "wasm32")]
63 {
64 use wasm_bindgen::JsCast;
65 let global = js_sys::global();
66
67 if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
69 return worker.performance().map(|p| p.now()).unwrap_or(0.0);
70 }
71
72 if let Ok(window) = global.dyn_into::<web_sys::Window>() {
74 return window.performance().map(|p| p.now()).unwrap_or(0.0);
75 }
76
77 js_sys::Date::now()
79 }
80 #[cfg(not(target_arch = "wasm32"))]
81 {
82 0.0
83 }
84}
85
86#[allow(dead_code)]
87pub(crate) fn get_worker_id() -> usize {
88 #[cfg(target_arch = "wasm32")]
89 {
90 WORKER_ID.with(|&id| id)
91 }
92 #[cfg(not(target_arch = "wasm32"))]
93 {
94 0
95 }
96}
97
98#[cfg(target_arch = "wasm32")]
99mod wasm_impl {
100 use crate::metrics::with_collector;
101 use crate::performance_now;
102 use crate::render::RenderState;
103 use crate::shared_world::{MAX_ENTITIES, SabSlot, SharedWorld};
104 use crate::transport::WebTransportBridge;
105 use crate::world_state::ClientWorld;
106 use aetheris_encoder_serde::SerdeEncoder;
107 use aetheris_protocol::events::NetworkEvent;
108 use aetheris_protocol::traits::GameTransport;
109 use aetheris_protocol::types::ClientId;
110 use wasm_bindgen::prelude::*;
111
112 #[wasm_bindgen]
113 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
114 pub enum ConnectionState {
115 Disconnected,
116 Connecting,
117 InGame,
118 Reconnecting,
119 Failed,
120 }
121
122 #[derive(Clone)]
124 pub struct SimulationSnapshot {
125 pub tick: u64,
126 pub entities: Vec<SabSlot>,
127 }
128
129 #[wasm_bindgen]
131 pub struct AetherisClient {
132 shared_world: SharedWorld,
133 world_state: ClientWorld,
134 render_state: Option<RenderState>,
135 transport: Option<WebTransportBridge>,
136 worker_id: usize,
137 session_token: Option<String>,
138
139 snapshots: std::collections::VecDeque<SimulationSnapshot>,
141
142 last_rtt_ms: f64,
144 ping_counter: u64,
145
146 reassembler: aetheris_protocol::Reassembler,
147 connection_state: ConnectionState,
148 reconnect_attempts: u32,
149 playground_rotation_enabled: bool,
150 playground_next_network_id: u64,
151 first_playground_tick: bool,
152
153 render_buffer: Vec<SabSlot>,
155 }
156
157 #[wasm_bindgen]
158 impl AetherisClient {
159 #[wasm_bindgen(constructor)]
162 pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
163 console_error_panic_hook::set_once();
164
165 use std::sync::atomic::{AtomicBool, Ordering};
168 static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
169 if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
170 let config = tracing_wasm::WASMLayerConfigBuilder::new()
171 .set_max_level(tracing::Level::INFO)
172 .build();
173 tracing_wasm::set_as_global_default_with_config(config);
174 }
175
176 let shared_world = if let Some(ptr_val) = shared_world_ptr {
177 let ptr = ptr_val as *mut u8;
178
179 if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
181 return Err(JsValue::from_str(
182 "Invalid shared_world_ptr: null or unaligned",
183 ));
184 }
185
186 unsafe { SharedWorld::from_ptr(ptr) }
191 } else {
192 SharedWorld::new()
193 };
194
195 tracing::info!(
196 "AetherisClient initialized on worker {}",
197 crate::get_worker_id()
198 );
199
200 with_collector(|c| {
202 c.push_event(
203 1,
204 "wasm_client",
205 "AetherisClient initialized",
206 "wasm_init",
207 None,
208 );
209 });
210
211 Ok(Self {
212 shared_world,
213 world_state: ClientWorld::new(),
214 render_state: None,
215 transport: None,
216 worker_id: crate::get_worker_id(),
217 session_token: None,
218 snapshots: std::collections::VecDeque::with_capacity(8),
219 last_rtt_ms: 0.0,
220 ping_counter: 0,
221 reassembler: aetheris_protocol::Reassembler::new(),
222 connection_state: ConnectionState::Disconnected,
223 reconnect_attempts: 0,
224 playground_rotation_enabled: false,
225 playground_next_network_id: 1,
226 first_playground_tick: true,
227 render_buffer: Vec::with_capacity(crate::shared_world::MAX_ENTITIES),
228 })
229 }
230
231 #[wasm_bindgen]
232 pub async fn request_otp(base_url: String, email: String) -> Result<String, String> {
233 crate::auth::request_otp(base_url, email).await
234 }
235
236 #[wasm_bindgen]
237 pub async fn login_with_otp(
238 base_url: String,
239 request_id: String,
240 code: String,
241 ) -> Result<String, String> {
242 crate::auth::login_with_otp(base_url, request_id, code).await
243 }
244
245 #[wasm_bindgen]
246 pub async fn logout(base_url: String, session_token: String) -> Result<(), String> {
247 crate::auth::logout(base_url, session_token).await
248 }
249
250 #[wasm_bindgen(getter)]
251 pub fn connection_state(&self) -> ConnectionState {
252 self.connection_state
253 }
254
255 fn check_worker(&self) {
256 debug_assert_eq!(
257 self.worker_id,
258 crate::get_worker_id(),
259 "AetherisClient accessed from wrong worker! It is pin-bound to its creating thread."
260 );
261 }
262
263 pub fn shared_world_ptr(&self) -> u32 {
265 self.shared_world.as_ptr() as u32
266 }
267
268 pub async fn connect(
269 &mut self,
270 url: String,
271 cert_hash: Option<Vec<u8>>,
272 ) -> Result<(), JsValue> {
273 self.check_worker();
274
275 if self.connection_state == ConnectionState::Connecting
276 || self.connection_state == ConnectionState::InGame
277 || self.connection_state == ConnectionState::Reconnecting
278 {
279 return Ok(());
280 }
281
282 if self.connection_state == ConnectionState::Failed && self.reconnect_attempts > 0 {
284 with_collector(|c| {
285 c.push_event(
286 2,
287 "transport",
288 "Triggering reconnection",
289 "reconnect_attempt",
290 None,
291 );
292 });
293 }
294
295 self.connection_state = ConnectionState::Connecting;
296 tracing::info!(url = %url, "Connecting to server...");
297
298 let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
299
300 match transport_result {
301 Ok(transport) => {
302 if let Some(token) = &self.session_token {
304 let encoder = SerdeEncoder::new();
305 let auth_event = NetworkEvent::Auth {
306 session_token: token.clone(),
307 };
308
309 match encoder.encode_event(&auth_event) {
310 Ok(data) => {
311 if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
312 self.connection_state = ConnectionState::Failed;
313 tracing::error!(error = ?e, "Handshake failed: could not send auth packet");
314 return Err(JsValue::from_str(&format!(
315 "Failed to send auth packet: {:?}",
316 e
317 )));
318 }
319 tracing::info!("Auth packet sent to server");
320 }
321 Err(e) => {
322 self.connection_state = ConnectionState::Failed;
323 tracing::error!(error = ?e, "Handshake failed: could not encode auth packet");
324 return Err(JsValue::from_str("Failed to encode auth packet"));
325 }
326 }
327 } else {
328 tracing::warn!(
329 "Connecting without session token! Server will likely discard data."
330 );
331 }
332
333 self.transport = Some(transport);
334 self.connection_state = ConnectionState::InGame;
335 self.reconnect_attempts = 0;
336 tracing::info!("WebTransport connection established");
337 with_collector(|c| {
339 c.push_event(
340 1,
341 "transport",
342 &format!("WebTransport connected: {url}"),
343 "connect_handshake",
344 None,
345 );
346 });
347 Ok(())
348 }
349 Err(e) => {
350 self.connection_state = ConnectionState::Failed;
351 tracing::error!(error = ?e, "Failed to establish WebTransport connection");
352 with_collector(|c| {
354 c.push_event(
355 3,
356 "transport",
357 &format!("WebTransport failed: {url} — {e:?}"),
358 "connect_handshake_failed",
359 None,
360 );
361 });
362 Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
363 }
364 }
365 }
366
367 pub fn set_session_token(&mut self, token: String) {
369 self.session_token = Some(token);
370 }
371
372 pub async fn init_renderer(&mut self, canvas: JsValue) -> Result<(), JsValue> {
375 self.check_worker();
376 use wasm_bindgen::JsCast;
377
378 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
379 backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
380 flags: wgpu::InstanceFlags::default(),
381 ..wgpu::InstanceDescriptor::new_without_display_handle()
382 });
383
384 let (surface_target, width, height) =
386 if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
387 let width = html_canvas.width();
388 let height = html_canvas.height();
389 tracing::info!(
390 "Initializing renderer on HTMLCanvasElement ({}x{})",
391 width,
392 height
393 );
394 (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
395 } else if let Ok(offscreen_canvas) =
396 canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
397 {
398 let width = offscreen_canvas.width();
399 let height = offscreen_canvas.height();
400 tracing::info!(
401 "Initializing renderer on OffscreenCanvas ({}x{})",
402 width,
403 height
404 );
405
406 let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
409 JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
410 })?;
411
412 (
413 wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
414 width,
415 height,
416 )
417 } else {
418 return Err(JsValue::from_str(
419 "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
420 ));
421 };
422
423 let surface = instance
424 .create_surface(surface_target)
425 .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
426
427 let render_state = RenderState::new(&instance, surface, width, height)
428 .await
429 .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
430
431 self.render_state = Some(render_state);
432
433 with_collector(|c| {
435 c.push_event(
436 1,
437 "render_worker",
438 &format!("Renderer initialized ({}x{})", width, height),
439 "render_pipeline_setup",
440 None,
441 );
442 });
443
444 Ok(())
445 }
446
447 #[wasm_bindgen]
448 pub fn resize(&mut self, width: u32, height: u32) {
449 if let Some(state) = &mut self.render_state {
450 state.resize(width, height);
451 }
452 }
453
454 #[cfg(debug_assertions)]
455 #[wasm_bindgen]
456 pub fn set_debug_mode(&mut self, mode: u32) {
457 self.check_worker();
458 if let Some(state) = &mut self.render_state {
459 state.set_debug_mode(match mode {
460 0 => crate::render::DebugRenderMode::Off,
461 1 => crate::render::DebugRenderMode::Wireframe,
462 2 => crate::render::DebugRenderMode::Components,
463 _ => crate::render::DebugRenderMode::Full,
464 });
465 }
466 }
467
468 #[wasm_bindgen]
469 pub fn set_theme_colors(&mut self, bg_base: &str, text_primary: &str) {
470 self.check_worker();
471 let clear = crate::render::parse_css_color(bg_base);
472 let label = crate::render::parse_css_color(text_primary);
473
474 tracing::info!(
475 "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
476 bg_base,
477 clear,
478 text_primary,
479 label
480 );
481
482 if let Some(state) = &mut self.render_state {
483 state.set_clear_color(clear);
484 #[cfg(debug_assertions)]
485 state.set_label_color([
486 label.r as f32,
487 label.g as f32,
488 label.b as f32,
489 label.a as f32,
490 ]);
491 }
492 }
493
494 #[cfg(debug_assertions)]
495 #[wasm_bindgen]
496 pub fn cycle_debug_mode(&mut self) {
497 if let Some(state) = &mut self.render_state {
498 state.cycle_debug_mode();
499 }
500 }
501
502 #[cfg(debug_assertions)]
503 #[wasm_bindgen]
504 pub fn toggle_grid(&mut self) {
505 if let Some(state) = &mut self.render_state {
506 state.toggle_grid();
507 }
508 }
509
510 pub async fn tick(&mut self) {
512 self.check_worker();
513 use aetheris_protocol::traits::{Encoder, GameTransport, WorldState};
514
515 let encoder = SerdeEncoder::new();
516
517 if let Some(_transport) = &self.transport {}
520
521 if let Some(transport) = &mut self.transport {
523 self.ping_counter = self.ping_counter.wrapping_add(1);
524 if self.ping_counter % 60 == 0 {
525 let now = performance_now();
527 let tick_u64 = now as u64;
528
529 if let Ok(data) = encoder.encode_event(&NetworkEvent::Ping {
530 client_id: ClientId(0), tick: tick_u64,
532 }) {
533 web_sys::console::debug_1(
534 &format!("[Aetheris] Sending Ping: tick={tick_u64}").into(),
535 );
536 let _ = transport.send_unreliable(ClientId(0), &data).await;
537 }
538 }
539 }
540
541 if let Some(transport) = &mut self.transport {
543 let events = match transport.poll_events().await {
544 Ok(e) => e,
545 Err(e) => {
546 tracing::error!("Transport poll failure: {:?}", e);
547 return;
548 }
549 };
550 let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
551 Vec::new();
552
553 for event in events {
554 match event {
555 NetworkEvent::UnreliableMessage { data, client_id }
556 | NetworkEvent::ReliableMessage { data, client_id } => {
557 if let Ok(update) = encoder.decode(&data) {
558 updates.push((client_id, update));
559 }
560 }
561 NetworkEvent::ClientConnected(id) => {
562 tracing::info!(?id, "Server connected");
563 }
564 NetworkEvent::ClientDisconnected(id) => {
565 tracing::warn!(?id, "Server disconnected");
566 }
567 NetworkEvent::Ping { client_id: _, tick } => {
568 let pong = NetworkEvent::Pong { tick };
570 if let Ok(data) = encoder.encode_event(&pong) {
571 let _ = transport.send_reliable(ClientId(0), &data).await;
572 }
573 }
574 NetworkEvent::Pong { tick } => {
575 let now = performance_now();
577 let rtt = now - (tick as f64);
578 self.last_rtt_ms = rtt;
579
580 with_collector(|c| {
581 c.update_rtt(rtt);
582 });
583
584 #[cfg(feature = "metrics")]
585 metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
586
587 web_sys::console::debug_1(
588 &format!("[Aetheris] Received Pong: tick={tick} RTT={rtt:.1}ms")
589 .into(),
590 );
591 tracing::debug!(rtt_ms = rtt, "RTT Update");
592 }
593 NetworkEvent::Auth { .. } => {
594 tracing::debug!("Received Auth event from server (unexpected)");
596 }
597 NetworkEvent::SessionClosed(id) => {
598 tracing::warn!(?id, "WebTransport session closed");
599 }
600 NetworkEvent::StreamReset(id) => {
601 tracing::error!(?id, "WebTransport stream reset");
602 }
603 NetworkEvent::Fragment {
604 client_id,
605 fragment,
606 } => {
607 if let Some(data) = self.reassembler.add(client_id, fragment) {
608 if let Ok(update) = encoder.decode(&data) {
609 updates.push((client_id, update));
610 }
611 }
612 }
613 NetworkEvent::StressTest { .. } => {
614 }
617 NetworkEvent::Spawn { .. } => {
618 }
620 NetworkEvent::ClearWorld { .. } => {
621 tracing::info!("Server initiated world clear");
622 self.world_state.entities.clear();
623 }
624 }
625 }
626
627 self.world_state.apply_updates(&updates);
629 }
630
631 if self.playground_rotation_enabled {
633 for slot in self.world_state.entities.values_mut() {
634 slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
635 }
636 }
637
638 self.world_state.latest_tick += 1;
641
642 self.flush_to_shared_world(self.world_state.latest_tick);
644 }
645
646 fn flush_to_shared_world(&mut self, tick: u64) {
647 let entities = &self.world_state.entities;
648 let write_buffer = self.shared_world.get_write_buffer();
649
650 let mut count = 0;
651 for (i, slot) in entities.values().enumerate() {
652 if i >= MAX_ENTITIES {
653 tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
654 break;
655 }
656 write_buffer[i] = *slot;
657 count += 1;
658 }
659
660 self.shared_world.commit_write(count as u32, tick);
661 }
662
663 #[wasm_bindgen]
664 pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
665 if self.world_state.entities.len() >= MAX_ENTITIES {
667 tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
668 return;
669 }
670
671 if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
673 self.playground_next_network_id = self
674 .world_state
675 .entities
676 .keys()
677 .map(|k| k.0)
678 .max()
679 .unwrap_or(0)
680 + 1;
681 }
682
683 let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
684 self.playground_next_network_id += 1;
685 let slot = SabSlot {
686 network_id: id.0,
687 x,
688 y,
689 z: 0.0,
690 rotation,
691 dx: 0.0,
692 dy: 0.0,
693 dz: 0.0,
694 hp: 100,
695 shield: 100,
696 entity_type,
697 flags: 0x01, padding: [0; 5],
699 };
700 self.world_state.entities.insert(id, slot);
701 }
702
703 #[wasm_bindgen]
704 pub async fn playground_spawn_net(
705 &mut self,
706 entity_type: u16,
707 x: f32,
708 y: f32,
709 rot: f32,
710 ) -> Result<(), JsValue> {
711 self.check_worker();
712
713 if let Some(transport) = &self.transport {
714 let encoder = SerdeEncoder::new();
715 let event = NetworkEvent::Spawn {
716 client_id: ClientId(0),
717 entity_type,
718 x,
719 y,
720 rot,
721 };
722
723 if let Ok(data) = encoder.encode_event(&event) {
724 transport
725 .send_reliable(ClientId(0), &data)
726 .await
727 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
728 tracing::info!(entity_type, x, y, "Sent Spawn command to server");
729 }
730 } else {
731 self.playground_spawn(entity_type, x, y, rot);
733 }
734 Ok(())
735 }
736
737 #[wasm_bindgen]
738 pub fn playground_clear(&mut self) {
739 self.world_state.entities.clear();
740 }
741
742 #[wasm_bindgen]
743 pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
744 self.check_worker();
745
746 if let Some(transport) = &self.transport {
747 let encoder = SerdeEncoder::new();
748 let event = NetworkEvent::ClearWorld {
749 client_id: ClientId(0),
750 };
751 if let Ok(data) = encoder.encode_event(&event) {
752 transport
753 .send_reliable(ClientId(0), &data)
754 .await
755 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
756 tracing::info!("Sent ClearWorld command to server");
757 self.world_state.entities.clear();
758 }
759 } else {
760 self.world_state.entities.clear();
762 }
763 Ok(())
764 }
765
766 #[wasm_bindgen]
767 pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
768 self.playground_rotation_enabled = enabled;
769 }
772
773 #[wasm_bindgen]
774 pub async fn playground_stress_test(
775 &mut self,
776 count: u16,
777 rotate: bool,
778 ) -> Result<(), JsValue> {
779 self.check_worker();
780
781 if let Some(transport) = &self.transport {
782 let encoder = SerdeEncoder::new();
783 let event = NetworkEvent::StressTest {
784 client_id: ClientId(0),
785 count,
786 rotate,
787 };
788
789 if let Ok(data) = encoder.encode_event(&event) {
790 transport
791 .send_reliable(ClientId(0), &data)
792 .await
793 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
794 tracing::info!(count, rotate, "Sent StressTest command to server");
795 }
796 self.playground_set_rotation_enabled(rotate);
797 } else {
798 self.playground_set_rotation_enabled(rotate);
800 self.playground_clear();
801 for _ in 0..count {
802 self.playground_spawn(1, 0.0, 0.0, 0.0); }
804 }
805
806 Ok(())
807 }
808
809 #[wasm_bindgen]
810 pub async fn tick_playground(&mut self) {
811 self.check_worker();
812
813 if self.first_playground_tick {
815 self.first_playground_tick = false;
816 with_collector(|c| {
817 c.push_event(
818 1,
819 "wasm_client",
820 "Playground simulation loop started",
821 "tick_playground_loop_start",
822 None,
823 );
824 });
825 }
826
827 let sim_start = crate::performance_now();
829 self.world_state.latest_tick += 1;
830
831 if self.playground_rotation_enabled {
832 for slot in self.world_state.entities.values_mut() {
833 slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
834 }
835 }
836
837 let count = self.world_state.entities.len() as u32;
839 self.flush_to_shared_world(self.world_state.latest_tick);
840
841 let sim_time_ms = crate::performance_now() - sim_start;
842 with_collector(|c| {
843 c.record_sim(sim_time_ms);
844 c.update_entity_count(count);
845 });
846 }
847
848 pub fn render(&mut self) -> f64 {
850 self.check_worker();
851
852 let tick = self.shared_world.tick();
853 let entities = self.shared_world.get_read_buffer();
854
855 thread_local! {
857 static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
858 }
859 FRAME_COUNT.with(|count| {
860 let current = count.get();
861 if current % 300 == 0 {
862 tracing::debug!(
863 "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
864 tick,
865 entities.len(),
866 self.snapshots.len(),
867 );
868 }
869 count.set(current + 1);
870 });
871
872 if tick == 0 {
873 let mut frame_time_ms = 0.0;
875 if let Some(state) = &mut self.render_state {
876 frame_time_ms = state.render_frame_with_compact_slots(&[]);
877 with_collector(|c| {
878 c.record_frame(frame_time_ms, 0.0);
881 });
882 }
883 return frame_time_ms;
884 }
885
886 if self.snapshots.is_empty()
888 || tick > self.snapshots.back().map(|s| s.tick).unwrap_or(0)
889 {
890 self.snapshots.push_back(SimulationSnapshot {
891 tick,
892 entities: entities.to_vec(),
893 });
894 }
895
896 let mut frame_time_ms = 0.0;
900 if !self.snapshots.is_empty() {
901 let latest_tick = self.snapshots.back().unwrap().tick as f32;
902 let target_tick = latest_tick - 2.0;
903 frame_time_ms = self.render_at_tick(target_tick);
904 }
905
906 let snap_count = self.snapshots.len() as u32;
908 with_collector(|c| {
909 c.record_frame(frame_time_ms, 0.0);
911 c.update_snapshot_count(snap_count);
912 });
913
914 frame_time_ms
915 }
916
917 fn render_at_tick(&mut self, target_tick: f32) -> f64 {
918 if self.snapshots.len() < 2 {
919 if let Some(state) = &mut self.render_state {
922 let entities = if !self.snapshots.is_empty() {
923 self.snapshots[0].entities.clone()
924 } else {
925 Vec::new()
926 };
927 return state.render_frame_with_compact_slots(&entities);
928 }
929 return 0.0;
930 }
931
932 let mut s1_idx = 0;
934 let mut found = false;
935
936 for i in 0..self.snapshots.len() - 1 {
937 if (self.snapshots[i].tick as f32) <= target_tick
938 && (self.snapshots[i + 1].tick as f32) > target_tick
939 {
940 s1_idx = i;
941 found = true;
942 break;
943 }
944 }
945
946 if !found {
947 if target_tick < self.snapshots[0].tick as f32 {
949 s1_idx = 0;
950 } else {
951 s1_idx = self.snapshots.len() - 2;
952 }
953 }
954
955 let s1 = self.snapshots.get(s1_idx).unwrap();
956 let s2 = self.snapshots.get(s1_idx + 1).unwrap();
957
958 let tick_range = (s2.tick - s1.tick) as f32;
959 let alpha = if tick_range > 0.0 {
960 (target_tick - s1.tick as f32) / tick_range
961 } else {
962 1.0
963 }
964 .clamp(0.0, 1.0);
965
966 self.render_buffer.clear();
968 self.render_buffer.extend_from_slice(&s2.entities);
969
970 let prev_map: std::collections::HashMap<u64, &SabSlot> =
972 s1.entities.iter().map(|e| (e.network_id, e)).collect();
973
974 for ent in &mut self.render_buffer {
975 if let Some(prev) = prev_map.get(&ent.network_id).copied() {
976 ent.x = lerp(prev.x, ent.x, alpha);
977 ent.y = lerp(prev.y, ent.y, alpha);
978 ent.z = lerp(prev.z, ent.z, alpha);
979 ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
980 }
981 }
982
983 let mut frame_time = 0.0;
984 if let Some(state) = &mut self.render_state {
985 frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
986 }
987
988 while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
992 self.snapshots.pop_front();
993 }
994
995 while self.snapshots.len() > 16 {
997 self.snapshots.pop_front();
998 }
999
1000 frame_time
1001 }
1002 }
1003
1004 fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1005 a + (b - a) * alpha
1006 }
1007
1008 fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1009 let mut diff = b - a;
1012 while diff < -std::f32::consts::PI {
1013 diff += std::f32::consts::TAU;
1014 }
1015 while diff > std::f32::consts::PI {
1016 diff -= std::f32::consts::TAU;
1017 }
1018 a + diff * alpha
1019 }
1020
1021 #[cfg(not(test))]
1023 #[wasm_bindgen(start)]
1024 pub fn main() {
1025 console_error_panic_hook::set_once();
1026 }
1027}