renet_visualizer/
lib.rs

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/// Egui visualizer for the renet client. Draws graphs with metrics:
15/// RTT, Packet Loss, Kbitps Sent/Received.
16///
17/// N: determines how many values are shown in the graph.
18/// 200 is a good value, if updated at 60 fps the graphs would hold 3 seconds of data.
19#[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/// Egui visualizer for the renet server. Draws graphs for each connected client with metrics:
29/// RTT, Packet Loss, Kbitps Sent/Received.
30///
31/// N: determines how many values are shown in the graph.
32/// 200 is a good value, if updated at 60 fps the graphs would hold 3 seconds of data.
33#[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/// Style configuration for the visualizer. Customize size, color and line width.
42#[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    /// Add the network information from the client. Should be called every time the client
97    /// updates.
98    ///
99    /// # Usage
100    /// ```
101    /// # use renet::{RenetClient, ConnectionConfig};
102    /// # use renet_visualizer::RenetClientVisualizer;
103    /// # let mut client = RenetClient::new(ConnectionConfig::default());
104    /// # let delta = std::time::Duration::ZERO;
105    /// # let mut visualizer = RenetClientVisualizer::<5>::new(Default::default());
106    /// client.update(delta);
107    /// visualizer.add_network_info(client.network_info());
108    /// ```
109    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    /// Renders a new window with all the graphs metrics drawn.
119    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    /// Draws only the Received Kilobits Per Second metric.
131    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    /// Draws only the Sent Kilobits Per Second metric.
143    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    /// Draws only the Packet Loss metric.
155    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    /// Draws only the Round Time Trip metric.
167    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    /// Draw all metrics without a window or layout.
179    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    /// Add a new client to keep track off. Should be called whenever a new client
198    /// connected event is received.
199    ///
200    /// # Usage
201    /// ```
202    /// # use renet::{RenetServer, ServerEvent, ConnectionConfig};
203    /// # use renet_visualizer::RenetServerVisualizer;
204    /// # let mut renet_server = RenetServer::new(ConnectionConfig::default());
205    /// # let mut visualizer = RenetServerVisualizer::<5>::new(Default::default());
206    /// while let Some(event) = renet_server.get_event() {
207    ///     match event {
208    ///         ServerEvent::ClientConnected { client_id } => {
209    ///             visualizer.add_client(client_id);
210    ///             // ...
211    ///         }
212    ///         _ => {}
213    ///     }
214    /// }
215    /// ```
216    pub fn add_client(&mut self, client_id: ClientId) {
217        self.clients.insert(client_id, RenetClientVisualizer::new(self.style.clone()));
218    }
219
220    /// Remove a client from the visualizer. Should be called whenever a client
221    /// disconnected event is received.
222    ///
223    /// # Usage
224    /// ```
225    /// # use renet::{RenetServer, ServerEvent, ConnectionConfig};
226    /// # use renet_visualizer::RenetServerVisualizer;
227    /// # let mut renet_server = RenetServer::new(ConnectionConfig::default());
228    /// # let mut visualizer = RenetServerVisualizer::<5>::new(Default::default());
229    /// while let Some(event) = renet_server.get_event() {
230    ///     match event {
231    ///         ServerEvent::ClientDisconnected { client_id , reason } => {
232    ///             visualizer.remove_client(client_id);
233    ///             // ...
234    ///         }
235    ///         _ => {}
236    ///     }
237    /// }
238    /// ```
239    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    /// Update the metrics for all connected clients. Should be called every time the server
250    /// updates.
251    ///
252    /// # Usage
253    /// ```
254    /// # use renet::{RenetServer, ConnectionConfig};
255    /// # use renet_visualizer::RenetServerVisualizer;
256    /// # let mut renet_server = RenetServer::new(ConnectionConfig::default());
257    /// # let mut visualizer = RenetServerVisualizer::<5>::new(Default::default());
258    /// # let delta = std::time::Duration::ZERO;
259    /// renet_server.update(delta);
260    /// visualizer.update(&renet_server);
261    /// ```
262    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    /// Draw all metrics without a window or layout for the specified client.
271    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    /// Renders a new window with all the graphs metrics drawn. You can choose to show metrics for
278    /// all connected clients or for only one chosen by a dropdown.
279    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}