URL: https://linuxfr.org/users/linkdd/journaux/ecrire-un-jeu-en-rust-presque-de-zero Title: Écrire un jeu en Rust presque de zéro Authors: David Delassus Date: 2022年06月06日T22:43:52+02:00 License: CC By-SA Tags: rust, jeu et moteur_de_jeux Score: 36 Bonjour Nal ! Si comme moi tu penses que le Rust c'est simple car il suffit d'écrire du code et corriger ce que le compilateur te dit de corriger, tu te trompes à moitié. # Introduction En vérité, le Rust c'est compliqué, car la programmation c'est compliqué. Contrairement à la plupart des langages, le Rust n'est pas une abstraction. Le compilateur ne prendra aucune décision pour toi et n'essayera jamais de deviner ce que tu as voulu exprimer. Le Rust te fournit un ensemble d'outils, et c'est à toi de les comprendre et de les utiliser correctement. Et ces outils ne sont pas des abstractions, c'est directement l'API du concept sous-jacent, sans aucun opinion de la part du designer. Oui, la gestion de la mémoire, de l'ownership, du borrowing, des lifetimes, de la thread-safety, etc... sont des choses compliquées. Et même dans des langages bas niveau tel que le C, ou haut niveau tel que le C++ ou le Go, ces choses sont masquées. Par exemple, en Go on aura un "Garbage Collector" dont l'implémentation prendra en charge la gestion de la mémoire et des lifetimes. En C, elles ne sont pas masquées, elles ne sont tout simplement pas là (le C c'est un assembleur avec une syntaxe moins dégueulasse). Plus je fais du Rust, moins j'ai confiance dans le code. **Même printf() peut échouer !** Plus le compilateur me hurle dessus, plus je comprend qu'il n'existe rien de trivial en informatique. Alors, que dis-tu de prendre l'un des sujets les moins triviaux qui existe, et de le faire en Rust ? À savoir : **un jeu vidéo !** # Scope de l'article Bon, en vrai je t'ai menti. Dans cet article on va pas créer un jeu, mais juste poser les bases pour en faire un, et donner quelques pointeurs pour la suite. En C ou C++, j'ai l'habitude de prendre la SDL. Et je dois "m'amuser" à écrire le build system, embarquer les .dll / .a et les headers pour faire un build portable, etc... C'est pénible. Non. En Rust on a cargo. Je ne veux pas avoir à taper autre chose que `cargo build`. C'est donc à ma grande joie que je découvre [winit](https://github.com/rust-windowing/winit). Une crate multi-plateforme pour ouvrir une fenêtre. Bémol : elle ne permet pas de dessiner dedans. C'est pas grave, il existe la crate [wgpu](https://github.com/gfx-rs/wgpu) pour ça, et même [pixels](https://github.com/parasyte/pixels) qui facilite un peu le tout. A cela, on rajoute le **Entity Component System** (voir cet [article](https://en.wikipedia.org/wiki/Entity_component_system)) [legion](https://github.com/amethyst/legion) qui provient du moteur de jeu [amethyst](https://github.com/amethyst/amethyst), et on a tout ce qu'il faut pour démarrer. Aller, on créé le projet : ``` $ mkdir example $ cd example $ cargo init ``` Et on ajoute ceci à notre `Cargo.toml` : ```toml [dependencies] winit = "0.26" winit_input_helper = "0.12" pixels = "0.9" legion = "0.4" ``` **NB :** La crate [winit_input_helper](https://github.com/rukai/winit_input_helper) va faciliter la gestion des événements clavier/souris/etc... # Première étape : ouvrir une fenêtre L'API de **winit** est assez simple, on a une boucle événementielle et une fenêtre : ```rust use winit::{ event::{Event, WindowEvent}, event_loop::{ControlFlow, EventLoop}, window::{WindowBuilder}, dpi::PhysicalSize, }; fn main() { let event_loop = EventLoop::new(); let win_size = PhysicalSize::new(640f64, 480f64); let window = WindowBuilder::new() .with_title("Hello World") .with_inner_size(win_size) .build(&event_loop) .expect("could not create window"); event_loop.run(move |event, _, control_flow| { match event { Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => { *control_flow = ControlFlow::Exit; return; }, _ => {} }; }); } ``` Parfait, alors comme on anticipe que ce bout de code va vite devenir velu, on décide de l'encapsuler dans une structure, afin que notre fonction `main` reste la plus petite possible. Cependant, on a un premier piège ici : ```rust use winit::{ event::{Event, WindowEvent}, event_loop::{ControlFlow, EventLoop}, window::{Window, WindowBuilder}, dpi::PhysicalSize, }; type Result = std::result::Result>; struct Application { event_loop: EventLoop<()>, window: Window, } impl Application { pub fn new(width: f64, height: f64) -> Result { let event_loop = EventLoop::new(); let win_size = PhysicalSize::new(width, height); let window = WindowBuilder::new() .with_title("Hello World") .with_inner_size(win_size) .build(&event_loop)?; Ok(Self { event_loop, window }) } pub fn run(&mut self) { self.event_loop.run(move |event, _, control_flow| { match event { Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => { *control_flow = ControlFlow::Exit; return; }, _ => {} }; }); } } ``` Ce code ne compilera pas. Le problème vient de la boucle événementielle et de la "closure" qu'on lui donne. Le compilateur n'a aucun moyen de s'assurer que `self` va vivre suffisamment longtemps pour que la closure soit toujours valide. En effet, ici `self` est une référence mutable que l'on tente de déplacer dans le corps de la closure. Hors cette closure va vivre potentiellement plus longtemps que la référence mutable. La solution repose sur le principe d'ownership. Au lieu d'utiliser une référence, on va déplacer la valeur de `self`, afin d'en prendre complètement possession, et on va déplacer les éléments de la structure en dehors de `self` afin d'en prendre possession également : ```rust pub fn run(self) { let Self { event_loop, window, } = self; // self n'est plus utilisable, car aucune des données qu'il contient ne lui appartient désormais event_loop.run(move |event, _, control_flow| { // ... }); } ``` Ainsi, notre fonction main devient : ```rust fn main() -> Result<()> { let app = Application::new(640f64, 480f64)?; app.run(); // app ne nous appartient plus ici Ok(()) } ``` # Comprendre la boucle événementielle La closure que l'on donne en argument à `event_loop.run()` sera appelée pour chaque événement du système d'exploitation. Dans l'ordre on aura (liste non exhaustive) : - `Event::NewEvents` : indique que de nouveaux événements sont disponibles - `Event::WindowEvent` : redimensionnement, fermeture, perte/gain de focus, glisser/déposer de fichier, événements clavier/souris, ... - `Event::MainEventsCleared` : on a consommé tout les événements de l'OS - `Event::RedrawRequested` : l'OS a demandé un réaffichage (c'est ici que l'on va dessiner) La crate **winit_input_helper** va nous fournir un mécanisme pour traiter les 3 premiers événements : ```rust use winit_input_helper::WinitInputHelper; struct Application { // ... input_state: WinitInputHelper, }; impl Application { pub fn new(/* ... */) -> Result { // ... let input_state = WinitInputHelper::new(); Ok(Self { // ... input_state, }) } pub fn run() { let Self { // ... mut input_state, } = self; event_loop.run(move |event, _, control_flow| { // cette fonction retourne `true` une fois Event::MainEventsCleared a été traité // lors de l'événement Event::NewEvents, elle vide l'état interne // elle rempli l'état interne avec les Event::WindowEvent if input_state.update(&event) { if input_state.quit() { *control_flow = ControlFlow::Exit; return; } // c'est ici que l'on va exécuter une frame de notre jeu window.request_redraw(); // on demande à l'OS d'émettre Event::RedrawRequested } if let Event::RedrawRequested(_) = event { // c'est ici que l'on va dessiner dans notre fenêtre } }); } } ``` Voilà, grâce à **winit_input_helper**, le code de notre boucle événementielle devient moins complexe et plus facile à maintenir. # Dessiner dans la fenêtre Ici, c'est la crate **pixels** qui va nous aider. **NB :** Cette crate fournit un mécanisme très primitif, on va manipuler directement les pixels d'une surface RGBA (32 bits). Tu veux dessiner une ligne ? Une image ? Un cercle ? Cramponne toi, tu va devoir le faire toi même (je fournirai quelque liens en conclusion, ne t'inquiète pas). Il est cependant important de noter plusieurs choses : - c'est ce qu'il se passe durant une frame qui va déterminer ce qui doit être dessiné - c'est uniquement lors de l'événement `Event::RedrawRequested` que l'on va dessiner Pour faire le lien entre les deux, on va utiliser un structure faite maison : `DrawCommandBuffer`. Le rôle de cette structure est très simple, il s'agit d'une queue de fonction qui vont prendre en paramètre la surface sur laquelle on va dessiner. Lors du calcul de la frame de notre jeu, on va ajouter à cette queue les fonctions qui vont bien. Et lors de l'événement `Event::RedrawRequested`, on va consommer cette queue jusqu'à ce qu'elle soit vide. **NB :** Les techniques de "culling" et autres optimisations seront à faire en amont, durant le calcul de la frame. Voici une implémentation de cette structure : ```rust use pixels::Pixels; pub struct DrawCommandBuffer { commands: Vec ()>>, } impl DrawCommandBuffer { pub fn new() -> Self { Self { commands: vec![] } } pub fn push(&mut self, func: Box ()>) { self.commands.push(func); } pub fn consume(&mut self, application_surface: &mut Pixels) { for func in self.commands.iter() { func(application_surface); } self.commands = vec![]; } } ``` Alors plusieurs choses ici. En Rust, `fn(params) -> return` désigne le type d'une fonction. Or **une closure n'est pas une fonction**. En effet, une closure possède des données supplémentaires (la scope qu'elle **copie** ou **déplace**). C'est pourquoi on utilise le **trait** `Fn(params) -> return`. Via le mot clé `dyn`, on indique au compilateur que n'importe quel type concret implémentant ce trait peut y être stocké. Cela permet donc d'avoir des fonctions ou des closures. Ici, j'ai fait le choix de stocker la fonction (ou closure) sur le tas (la **heap**) via le type `Box`. En vérité, il doit être possible de créer un type générique `DrawCommandBuffer where T: Fn(...) -> ...` pour stocker le tout sur la pile (la **stack**) de manière statique et sans allocation. Mais je dois t'avouer, je ne sais pas encore comment le dire explicitement au compilateur. Tout ce que j'ai essayé naïvement n'a fait que provoquer la colère du compilateur. Et en Rust, **le compilateur a toujours raison**. Non **je ne suis pas plus intelligent que le compilateur**. J'ai encore du mal, mais ça fini par rentrer petit à petit. Bref, passons à la suite, créons nous une structure `Renderer` dont le rôle est exclusivement le dessin à l'écran : ```rust use pixels::{Pixels, SurfaceTexture}; struct Renderer { application_surface: Pixels, } impl Renderer { pub fn new(window: &Window) -> Result { let size = window.inner_size(); let texture = SurfaceTexture::new(size.width, size.height, window); // notez bien comment Pixels::new() prend possession de texture let application_surface = Pixels::new(size.width, size.height, texture)?; Ok(Self { application_surface }) } pub fn draw(&mut self, cmd_buffer: &mut DrawCommandBuffer) -> Result<()> { cmd_buffer.consume(&mut self.application_surface); self.application_surface.render()?; Ok(()) } } ``` Et ajoutons le à notre application : ```rust struct Application { // ... renderer: Renderer, } impl Application { pub fn new(/* ... */) -> Result { // ... let renderer = Renderer::new(&window); Ok(Self { // ... renderer, }) } pub fn run() { let Self { // ... mut renderer, } = self; let mut draw_command_buffer = DrawCommandBuffer::new(); event_loop.run(move |event, _, control_flow| { // ... if let Event::RedrawRequested(_) = event { match renderer.draw(&mut draw_command_buffer) { Err(reason) => { eprintln!("ERROR: {}", reason); }, _ => {}, } } }); } } ``` Et maintenant, lors du calcul de notre frame, demandons le remplissage en rouge de l'écran : ```rust if input_state.update(&event) { // ... draw_command_buffer.push(Box::new(|application_surface| { let pixel_data: &mut [u8] = application_surface.get_frame(); for pixel in pixel_data.chunks_exact_mut(4) { // pixel est un &mut [u8] de 4 éléments, RGBA let rgba = [0xFF, 0x00, 0x00, 0xFF]; pixel.copy_from_slice(&color); } }); window.request_redraw(); } ``` Cela risque de faire beaucoup d'allocation par frame si on "push" beaucoup de commande d'affichage, il serait vraiment idéal de s'arracher quelques cheveux pour trouver une autre solution. Je laisse cet exercice difficile au lecteur :) # Ajouter l'ECS Le **Entity Component System** est un concept qui nous vien du **Data Oriented Design**. L'idée est la suivante : - on a des composants qui stockent la donnée - une entité est un ID qui représente un ensemble de composant - on a des systèmes qui implémentent une logique sur chaque entité ayant tel ou tel composants Le but est de permettre de grouper ensemble les données en mémoire pour exploiter au maximum les caches du CPU, et ainsi optimiser le temps de traitement d'une frame. Outre cet aspect optimisation, c'est aussi un très bon moyen de découpler la donnée et la logique. Lorsque 2 systèmes auront besoin de communiquer, on utilisera généralement un bus de message. Ce découplage va simplifier énormément les choses, car on évite tout les problèmes d'une structure de données mutable et partagée, ce qui est extrêmement pénible à manipuler en Rust (car on veut éviter les problèmes de corruption et de data race). La crate **legion** nous fournit un joli petit ECS bien sympa et facile à utiliser : - on a un `World` qui va contenir les composants de chaque entité - on a une structure `Resources` qui va contenir les données communes à tout les systèmes - on a des structures pour chaque composant - on a des fonctions pour chaque système Intégrons tout cela à notre application : ```rust use legion::*; struct Application { // ... world: World, } impl Application { pub fn (/* ... */) -> Result { // ... let world = World::default(); Ok(Self { // ... world, }) } pub fn run() { let Self { // ... mut world, } = self; let mut resources = Resources::default(); // == GAME INIT == // ici on créé les entités avec leurs composants // ici, on créé la chaine de système: let mut state_pipeline = Schedule::builder() // .add_system(...) // .add_thread_local_fn(|world, resources| { ... }) .build(); // == END OF GAME INIT == let draw_command_buffer = DrawCommandBuffer::new(); resources.insert(draw_command_buffer); // draw_command_buffer est maintenant possédé par notre conteneur de ressources let mut timer = std::time::Instant::now(); event_loop.run(move |event, _, control_flow| { if input_state.update(&event) { if input_state.quit() { *control_flow = ControlFlow::Exit; return; } // on est obligé de cloner ici, car input_state n'a pas la même durée de vie que resources // accessoirement, on ne peut pas avoir 2 références mutables vers la même donnée resources.insert(input_state.clone()); // durée depuis la dernière frame, c'est Time.delta_time chez Unity resources.insert(timer.elapsed()); timer = Instant::now(); // on exécute nos systèmes state_pipeline.execute(&mut world, &mut resources); window.request_redraw(); } if let Event::RedrawRequested(_) = event { // on récupère une référence atomique mutable, vu que c'est resources qui le possède désormais let mut draw_command_buffer = resources.get_mut::().unwrap(); match renderer.draw(&mut draw_command_buffer) { // ... } } }); } } ``` Je sais que tu es observateur, et tu as remarqué que j'ai retiré le code qui remplissait notre écran en rouge. L'explication est assez simple : ```rust let dcb = resources.get_mut<...>().unwrap(); // do something with dcb state_pipeline.execute(&mut world, &mut resources); ``` Dans cet exemple, nous avons dans la même scope 2 références mutables avec la même durée de vie vers les mêmes données. Et ça, le compilateur n'aime pas du tout du tout du tout. Et il a raison, car cela peut amener a de la corruption de mémoire, et d'autres joyeusetés du genre si on parallélise le code. Et la, tu commences à comprendre pourquoi je commence à croire que **la trivialité en informatique n'existe pas**. A la place, voici un petit bout de code sympa pour remplacer la fonctionnalité : ```rust pub fn setup_clear_screen_system(pipeline: &mut legion::systems::Builder, color: [u8; 4]) { pipeline.add_thread_local_fn(move |_world, resources| { let mut draw_command_buffer = resources.get_mut::().unwrap(); draw_command_buffer.push(Box::new(move |application_surface| { // `color` a été déplacée dans le scope de la closure let pixel_data = application_surface.get_frame(); for pixel in pixel_data.chunks_exact_mut(4) { pixel.copy_from_slice(&color); } })); }); } ``` Et dans notre **GAME INIT** : ```rust let mut state_pipeline_builder = Schedule::builder(); setup_clear_screen_system(&mut state_pipeline_builder, [0xFF, 0x00, 0x00, 0xFF]); let state_pipeline = state_pipeline_builder.build(); ``` Et voilà, la fonctionnalité a été restaurée en utilisant notre ECS. ## Exemple final C'est un exemple ici du README de **legion**, mais il démontre bien la simplicité du bazar : ```rust #[derive(Clone, Copy, Debug, PartialEq)] pub struct Position { pub x: f32, pub y: f32, } #[derive(Clone, Copy, Debug, PartialEq)] pub struct Velocity { pub dx: f32, pub dy: f32, } #[system(for_each)] fn update_positions(pos: &mut Position, vel: &Velocity, #[resource] delta_time: &std::time::Duration) { pos.x += vel.dx * delta_time.as_secs_f32(); pos.y += vel.dy * delta_time.as_secs_f32(); } pub fn setup_movement_system(pipeline: &mut legion::systems::Builder) { pipeline.add_system(update_positions_system()); } ``` Il ne reste plus qu'a remplir notre **GAME INIT** et le tour est joué : ```rust setup_movement_system(&mut state_pipeline_builder); let entity = world.push(( Position { x: 0.0, y: 0.0 }, Velocity { dx: 1.0, dy: 1.0 }, )); ``` Et voilà, comme promis : Le minimum syndical pour développer un jeu qui se compile uniquement avec `cargo build`. # Conclusion Après tout cela, ma recommendation est la suivante : - si tu veux développer un jeu, utilise un moteur de jeu déjà existant, il en existe déjà en [Rust](https://blog.logrocket.com/5-rust-game-engines-consider-next-project/) - si tu veux développer un moteur de jeu, éclate toi La crate **pixels** est construite à partir de la crate **wgpu**, il peut donc être très intéressant de consulter la documentation de cette dernière pour un usage plus poussé. On peut trouver des exemples dans le dépôt Github de **pixels** : - [Conway's Game Of Life](https://github.com/parasyte/pixels/tree/main/examples/conway) - [Space Invaders](https://github.com/parasyte/pixels/tree/main/examples/invaders) - ... Mais cette crate ne fournit vraiment aucune primitive de dessin : - si tu veux dessiner des lignes, utilise la crate [line_drawing](https://docs.rs/line_drawing/latest/line_drawing/) pour en générer les coordonnées - si tu veux dessiner des PNG, utilise la crate [lodepng](https://docs.rs/lodepng/latest/lodepng/index.html) pour le décoder en image RGBA 32 bits - si tu veux utiliser un autre "moteur de rendu" que **pixels**, tu peux aussi :) Si tu es arrivé jusque ici, voici un peu de pain perdu (plus besoin de jeter ton pain raci) : ![pain perdu](https://www.ptitchef.com/imgupl/recipe/comment-faire-du-pain-perdu--451848p729752.jpg)

AltStyle によって変換されたページ (->オリジナル) /