Greboca  

Suport technique et veille technologique

Aujourd’hui, les grandes entreprises et administrations publiques hésitent entre continuer à utiliser des logiciels propriétaires ou basculer vers les Logiciels Libres. Pourtant, la plupart des logiciels libres sont capables de bien traiter les données issues des logiciels propriétaire, et parfois avec une meilleur compatibilité.

C’est alors la barrière de la prise en main qui fait peur, et pourtant...

Les logiciels libres

L’aspect « Logiciel Libre » permet une évolution rapide et une plus grande participation des utilisateurs. Les aides et tutoriels foisonnent sur Internet ou sont directement inclus dans le logiciel lui-même.

Enfin, les concepteurs sont plus proches des utilisateurs, ce qui rend les logiciels libres plus agréable à utiliser et conviviaux.

Grâce à la disponibilité des logiciels libres, vous trouverez facilement des services de support techniques et la licence n’est plus un frein à l’utilisation de ces logiciels par votre personnel.

Notre support technique concerne essentiellement les logiciels libres, que ce soit sous forme de services ponctuels ou de tutoriels.

LinuxFr.org : les journaux  -  Écrire un jeu en Rust presque de zéro

 -  Juin 2022 - 

Sommaire

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. 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 pour ça, et même pixels qui facilite un peu le tout.

A cela, on rajoute le Entity Component System (voir cet article) legion qui provient du moteur de jeu 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 :

[dependencies]
winit = "0.26"
winit_input_helper = "0.12"
pixels = "0.9"
legion = "0.4"

NB : La crate 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 :

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 :

use winit::{
  event::{Event, WindowEvent},
  event_loop::{ControlFlow, EventLoop},
  window::{Window, WindowBuilder},
  dpi::PhysicalSize,
};

type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

struct Application {
  event_loop: EventLoop<()>,
  window: Window,
}

impl Application {
  pub fn new(width: f64, height: f64) -> Result<Self> {
    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 :

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 :

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 :

use winit_input_helper::WinitInputHelper;

struct Application {
  // ...
  input_state: WinitInputHelper,
};

impl Application {
  pub fn new(/* ... */) -> Result<Self> {
    // ...
    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 :

use pixels::Pixels;

pub struct DrawCommandBuffer {
  commands: Vec<Box<dyn Fn(&mut Pixels) -> ()>>,
}

impl DrawCommandBuffer {
  pub fn new() -> Self {
    Self { commands: vec![] }
  }

  pub fn push(&mut self, func: Box<dyn Fn(&mut Pixels) -> ()>) {
    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 :

use pixels::{Pixels, SurfaceTexture};

struct Renderer {
  application_surface: Pixels,
}

impl Renderer {
  pub fn new(window: &Window) -> Result<Self> {
    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 :

struct Application {
  // ...
  renderer: Renderer,
}

impl Application {
  pub fn new(/* ... */) -> Result<Self> {
    // ...
    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 :

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 :

use legion::*;

struct Application {
  // ...
  world: World,
}

impl Application {
  pub fn (/* ... */) -> Result<Self> {
    // ...

    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::<DrawCommandBuffer>().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 :

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é :

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::<DrawCommandBuffer>().unwrap();

    draw_command_buffer.push(Box::new(move |application_surface| {
      // `color` a été déplacée dans le scope de la closure

par David Delassus

LinuxFr.org : les journaux

LinuxFr.org : Journaux

La version 2.0 de WhosWho est sortie

 -  15 mai - 

Bonjour Nal,Je viens de publier la version 2.0 de WhosWho, mon logiciel pour faire des trombinoscopes, dont j'avais parlé il y a longtemps dans (...)


décrire une une image avec une iA locale

 -  8 mai - 

Aujourd'hui c'est fourien™, petit tuto sans prétention!Pour décrire des images en utilisant une iA localement j'utilise LLaVA qui fait partie de (...)


antistress adventure in Flatpak land

 -  30 avril - 

Hello nal, ça faisait un bail !Certain (il se reconnaîtra) m'a demandé de le tenir au courant lorsque j'aurai basculé sur un usage de Firefox (...)


Téléphone sous Linux ?

 -  25 avril - 

Aujourd'hui, avoir un téléphone avec un Android libéré, c'est possible, on pense en particulier à Murena.Avoir un téléphone sous GNU/Linux, c'est (...)


Quand votre voiture vous espionne… et vous le fait payer

 -  23 avril - 

Ceci se passe aux États-Unis, pour l’instant, aucune preuve qu’une telle fuite existe en Europe. Mais… si votre assurance augmente brutalement, (...)