1use std::collections::HashMap;
2
3use egui::{
4 epaint::{PathShape, RectShape},
5 pos2, remap, vec2, Color32, Rect, Rgba, RichText, Rounding, Sense, Shape, Stroke, TextStyle, Vec2, WidgetText,
6};
7
8use renet::{ClientId, NetworkInfo, RenetServer};
9
10use circular_buffer::CircularBuffer;
11
12mod circular_buffer;
13
14#[cfg_attr(feature = "bevy", derive(bevy_ecs::system::Resource))]
20pub struct RenetClientVisualizer<const N: usize> {
21 rtt: CircularBuffer<N, f32>,
22 sent_bandwidth_kbps: CircularBuffer<N, f32>,
23 received_bandwidth_kbps: CircularBuffer<N, f32>,
24 packet_loss: CircularBuffer<N, f32>,
25 style: RenetVisualizerStyle,
26}
27
28#[cfg_attr(feature = "bevy", derive(bevy_ecs::system::Resource))]
34pub struct RenetServerVisualizer<const N: usize> {
35 show_all_clients: bool,
36 selected_client: Option<ClientId>,
37 clients: HashMap<ClientId, RenetClientVisualizer<N>>,
38 style: RenetVisualizerStyle,
39}
40
41#[derive(Debug, Clone)]
43pub struct RenetVisualizerStyle {
44 pub width: f32,
45 pub height: f32,
46 pub text_color: Color32,
47 pub rectangle_stroke: Stroke,
48 pub line_stroke: Stroke,
49}
50
51enum TopValue {
52 SuggestedValues([f32; 5]),
53 MaxValue { multiplicated: f32 },
54}
55
56enum TextFormat {
57 Percentage,
58 Normal,
59}
60
61impl Default for RenetVisualizerStyle {
62 fn default() -> Self {
63 Self {
64 width: 200.,
65 height: 100.,
66 text_color: Color32::WHITE,
67 rectangle_stroke: Stroke::new(1., Color32::WHITE),
68 line_stroke: Stroke::new(1., Color32::WHITE),
69 }
70 }
71}
72
73impl<const N: usize> Default for RenetClientVisualizer<N> {
74 fn default() -> Self {
75 RenetClientVisualizer::new(RenetVisualizerStyle::default())
76 }
77}
78
79impl<const N: usize> Default for RenetServerVisualizer<N> {
80 fn default() -> Self {
81 RenetServerVisualizer::new(RenetVisualizerStyle::default())
82 }
83}
84
85impl<const N: usize> RenetClientVisualizer<N> {
86 pub fn new(style: RenetVisualizerStyle) -> Self {
87 Self {
88 rtt: CircularBuffer::default(),
89 sent_bandwidth_kbps: CircularBuffer::default(),
90 received_bandwidth_kbps: CircularBuffer::default(),
91 packet_loss: CircularBuffer::default(),
92 style,
93 }
94 }
95
96 pub fn add_network_info(&mut self, network_info: NetworkInfo) {
110 self.rtt.push((network_info.rtt * 1000.) as f32);
111 self.sent_bandwidth_kbps
112 .push((network_info.bytes_sent_per_second * 8. / 1000.) as f32);
113 self.received_bandwidth_kbps
114 .push((network_info.bytes_received_per_second * 8. / 1000.) as f32);
115 self.packet_loss.push(network_info.packet_loss as f32);
116 }
117
118 pub fn show_window(&self, ctx: &egui::Context) {
120 egui::Window::new("Client Network Info")
121 .resizable(false)
122 .collapsible(true)
123 .show(ctx, |ui| {
124 ui.horizontal(|ui| {
125 self.draw_all(ui);
126 });
127 });
128 }
129
130 pub fn draw_received_kbps(&self, ui: &mut egui::Ui) {
132 show_graph(
133 ui,
134 &self.style,
135 "Received Kbitps",
136 TextFormat::Normal,
137 TopValue::MaxValue { multiplicated: 1.5 },
138 self.received_bandwidth_kbps.as_vec(),
139 );
140 }
141
142 pub fn draw_sent_kbps(&self, ui: &mut egui::Ui) {
144 show_graph(
145 ui,
146 &self.style,
147 "Sent Kbitps",
148 TextFormat::Normal,
149 TopValue::MaxValue { multiplicated: 1.5 },
150 self.sent_bandwidth_kbps.as_vec(),
151 );
152 }
153
154 pub fn draw_packet_loss(&self, ui: &mut egui::Ui) {
156 show_graph(
157 ui,
158 &self.style,
159 "Packet Loss",
160 TextFormat::Percentage,
161 TopValue::SuggestedValues([0.05, 0.1, 0.25, 0.5, 1.]),
162 self.packet_loss.as_vec(),
163 );
164 }
165
166 pub fn draw_rtt(&self, ui: &mut egui::Ui) {
168 show_graph(
169 ui,
170 &self.style,
171 "Round Time Trip (ms)",
172 TextFormat::Normal,
173 TopValue::SuggestedValues([32., 64., 128., 256., 512.]),
174 self.rtt.as_vec(),
175 );
176 }
177
178 pub fn draw_all(&self, ui: &mut egui::Ui) {
180 self.draw_received_kbps(ui);
181 self.draw_sent_kbps(ui);
182 self.draw_rtt(ui);
183 self.draw_packet_loss(ui);
184 }
185}
186
187impl<const N: usize> RenetServerVisualizer<N> {
188 pub fn new(style: RenetVisualizerStyle) -> Self {
189 Self {
190 show_all_clients: false,
191 selected_client: None,
192 clients: HashMap::new(),
193 style,
194 }
195 }
196
197 pub fn add_client(&mut self, client_id: ClientId) {
217 self.clients.insert(client_id, RenetClientVisualizer::new(self.style.clone()));
218 }
219
220 pub fn remove_client(&mut self, client_id: ClientId) {
240 self.clients.remove(&client_id);
241 }
242
243 fn add_network_info(&mut self, client_id: ClientId, network_info: NetworkInfo) {
244 if let Some(client) = self.clients.get_mut(&client_id) {
245 client.add_network_info(network_info);
246 }
247 }
248
249 pub fn update(&mut self, server: &RenetServer) {
263 for client_id in server.clients_id_iter() {
264 if let Ok(network_info) = server.network_info(client_id) {
265 self.add_network_info(client_id, network_info);
266 }
267 }
268 }
269
270 pub fn draw_client_metrics(&self, client_id: ClientId, ui: &mut egui::Ui) {
272 if let Some(client) = self.clients.get(&client_id) {
273 client.draw_all(ui);
274 }
275 }
276
277 pub fn show_window(&mut self, ctx: &egui::Context) {
280 egui::Window::new("Server Network Info")
281 .resizable(false)
282 .collapsible(true)
283 .show(ctx, |ui| {
284 ui.horizontal(|ui| {
285 ui.checkbox(&mut self.show_all_clients, "Show all clients");
286 ui.add_enabled_ui(!self.show_all_clients, |ui| {
287 let selected_text = match self.selected_client {
288 Some(client_id) => format!("{}", client_id),
289 None => "------".to_string(),
290 };
291 egui::ComboBox::from_label("Select client")
292 .selected_text(selected_text)
293 .show_ui(ui, |ui| {
294 for client_id in self.clients.keys() {
295 ui.selectable_value(&mut self.selected_client, Some(*client_id), format!("{}", client_id));
296 }
297 })
298 });
299 });
300 ui.vertical(|ui| {
301 if self.show_all_clients {
302 for (client_id, client) in self.clients.iter() {
303 ui.vertical(|ui| {
304 ui.heading(format!("Client {}", client_id));
305 ui.horizontal(|ui| {
306 client.draw_all(ui);
307 });
308 });
309 }
310 } else if let Some(selected_client) = self.selected_client {
311 if let Some(client) = self.clients.get(&selected_client) {
312 ui.horizontal(|ui| {
313 client.draw_all(ui);
314 });
315 }
316 }
317 });
318 });
319 }
320}
321
322fn show_graph(
323 ui: &mut egui::Ui,
324 style: &RenetVisualizerStyle,
325 label: &str,
326 text_format: TextFormat,
327 top_value: TopValue,
328 values: Vec<f32>,
329) {
330 if values.is_empty() {
331 return;
332 }
333
334 ui.vertical(|ui| {
335 ui.label(RichText::new(label).heading().color(style.text_color));
336
337 let last_value = values.last().unwrap();
338
339 let min = 0.0;
340 let mut max = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
341 match top_value {
342 TopValue::MaxValue { multiplicated } => {
343 max *= multiplicated;
344 }
345 TopValue::SuggestedValues(suggested_values) => {
346 for value in suggested_values.into_iter() {
347 if max < value {
348 max = value;
349 break;
350 }
351 }
352 }
353 }
354
355 let spacing_x = ui.spacing().item_spacing.x;
356
357 let last_text: WidgetText = match text_format {
358 TextFormat::Normal => format!("{:.2}", last_value).into(),
359 TextFormat::Percentage => format!("{:.1}%", last_value * 100.).into(),
360 };
361 let galley = last_text.into_galley(ui, Some(egui::TextWrapMode::Wrap), f32::INFINITY, TextStyle::Button);
362 let (outer_rect, _) = ui.allocate_exact_size(Vec2::new(style.width + galley.size().x + spacing_x, style.height), Sense::hover());
363 let rect = Rect::from_min_size(outer_rect.left_top(), vec2(style.width, style.height));
364 let text_pos = rect.right_center() + vec2(spacing_x / 2.0, -galley.size().y / 2.);
365 ui.painter().with_clip_rect(outer_rect).galley(text_pos, galley, style.text_color);
366
367 let body = Shape::Rect(RectShape {
368 rect,
369 rounding: Rounding::ZERO,
370 fill: Rgba::TRANSPARENT.into(),
371 stroke: style.rectangle_stroke,
372 uv: Rect::ZERO,
373 fill_texture_id: egui::TextureId::Managed(0),
374 blur_width: 0.0,
375 });
376 ui.painter().add(body);
377 let init_point = rect.left_bottom();
378
379 let size = values.len();
380 let points = values
381 .iter()
382 .enumerate()
383 .map(|(i, value)| {
384 let x = remap(i as f32, 0.0..=size as f32, 0.0..=style.width);
385 let y = remap(*value, min..=max, 0.0..=style.height);
386
387 pos2(x + init_point.x, init_point.y - y)
388 })
389 .collect();
390
391 let path = PathShape::line(points, style.line_stroke);
392 ui.painter().add(path);
393
394 {
395 let text: WidgetText = match text_format {
396 TextFormat::Normal => format!("{:.0}", max).into(),
397 TextFormat::Percentage => format!("{:.0}%", max * 100.).into(),
398 };
399 let galley = text.into_galley(ui, Some(egui::TextWrapMode::Wrap), f32::INFINITY, TextStyle::Button);
400 let text_pos = rect.left_top() + Vec2::new(0.0, galley.size().y / 2.) + vec2(spacing_x, 0.0);
401 ui.painter().with_clip_rect(outer_rect).galley(text_pos, galley, style.text_color);
402 }
403 {
404 let text: WidgetText = match text_format {
405 TextFormat::Normal => format!("{:.0}", min).into(),
406 TextFormat::Percentage => format!("{:.0}%", min * 100.).into(),
407 };
408 let galley = text.into_galley(ui, Some(egui::TextWrapMode::Wrap), f32::INFINITY, TextStyle::Button);
409 let text_pos = rect.left_bottom() - Vec2::new(0.0, galley.size().y * 1.5) + vec2(spacing_x, 0.0);
410 ui.painter().with_clip_rect(outer_rect).galley(text_pos, galley, style.text_color);
411 }
412 });
413}