Journal Écrire un jeu en Rust presque de zéro

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
36
6
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<T>. En vérité, il doit être possible de créer un type générique DrawCommandBuffer<T> 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

      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 :

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 :

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

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

Mais cette crate ne fournit vraiment aucune primitive de dessin :

  • si tu veux dessiner des lignes, utilise la crate line_drawing pour en générer les coordonnées
  • si tu veux dessiner des PNG, utilise la crate lodepng 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

  • # C++, haut niveau ?

    Posté par  (site web personnel, Mastodon) . Évalué à 5. Dernière modification le 07 juin 2022 à 09:31.

    haut niveau tel que le C++

    Pour moi le C++ n'a jamais été un langage de haut niveau. Ce n'est que du C amélioré à la sauce objet. La gestion de la mémoire etc est toujours fait par le développeur, même si il y a eu des améliorations comme les pointeurs "intelligents" etc…

    Maintenant, ça fait quelques années que je ne fait plus de C++. J'ai peut-être raté quelque chose. En quoi c'est un langage de haut niveau aujourd'hui ? (ou alors, définir "langage de haut niveau")

    • [^] # Re: C++, haut niveau ?

      Posté par  (site web personnel) . Évalué à 1.

      À partir du moment où un langage te permet de créer un module noyau, ou être utiliser dans un firmware, c'est du bas niveau. Cela dit, le C++ dispose aussi de bibliothèques de haut niveau, tel Qt, qui ajoute au C++ les signaux /slots par exemple (mais est-ce du C++ ?).

      Un langage de haut niveau, en plus d'être peu recommandable pour le développement de firmware, devrait te permettre limiter l'accès aux ressources systèmes, définir le degré l'isolation des bibliothèques nativement, gérer ses dépendances comme un grand, qu'il soit "sécurisable" directement par l'administrateur ou l'utilisateur simplement, et pas par le développeur. Il y en a bien un, ça commence par J et ça fini par a, mais c'est plus une plateforme qu'un langage.

      • [^] # Re: C++, haut niveau ?

        Posté par  (site web personnel) . Évalué à 7.

        C'est pas complètement vrai, si on en croit la définition de Wikipedia, dont les limites sont floues, cf https://fr.wikipedia.org/wiki/Langage_de_programmation_de_haut_niveau, on peut tout à fait considérer le langage C comme haut-niveau.

        Tout dépend de l'utilisation que l'on en fait. Les bibliothèques standard en C te permettent de l'utiliser comme un langage haut niveau, mais les compilateurs te permettre de descendre bas niveau, mais par défaut, le C en lui même n'est pas un assembleur, et abstrait déjà complètement l'architecture matérielle. D'ailleurs il a été conçu pour être portable, ce qui en faisait au moment de sa conception un langage haut niveau.

        Le C++ est probablement beaucoup plus haut niveau que le C, car il est déconseillé en théorie de gérer ta mémoire par toi même, la "surcouche objet" qui n'est pas une surcouche mais bien tout ou partie du langage, te permets de complètement bannir malloc ou free de ton code. Il suffit de faire un objdump sur des pattern objets un peu avancés avec du code C++ non optimisé par le compilateur pour voir qu'en réalité, le code dumpé n'a plus grand chose à voir avec ce que tu as écrit, car le compilateur émet, selon l'architecture cible, des vrais morceaux de runtime dignes d'une VM.

    • [^] # Re: C++, haut niveau ?

      Posté par  . Évalué à 7.

      Le C++ peut faire les deux, en fait (bas niveau, haut niveau). Après, gcc eux-mêmes utilisaient un garbage collector avant (j'avais vu un article sur lwn.net, qui expliquait qu'ils allaient intégrer du C++ pour réduire ça et donc améliorer les perfs, secoués par le succès alors montant de clang), et pourtant, c'est (c'était?) du C, donc bon…

      On peut utiliser le C++ sans la STL, et dans ce cas on doit faire la gestion de la mémoire à la main (encore que, dans ce cas, on code soit-même ses wrappers…).
      Parfois même, pas le choix, par exemple si on veut utiliser mmap (qui renvoie -1 et pas 0 en cas d'échec «On error, the value MAP_FAILED (that is, (void *) -1) is returned, and errno is set to indicate the cause of the error.») ou, plus parlant compte tenu du journal: dans le cas d'openGL ou dans de vulkan (ça retourne des handles, donc std::unique_ptr peut pas gérer sans intermédiaire).

      Mais bon, je suis d'accord que:

      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.

      Est juste une affirmation fausse: en C, si, il y a une gestion de la mémoire: malloc/calloc/realloc/free, c'est plus simple à utiliser que maintenir soit-même le tas et la pile du programme.

      Toujours à ma connaissance en C, il n'y a pas besoin d'empiler manuellement avant d'appeler une fonction, ni de faire l'inverse en en sortant.
      Ce qu'il manque au C (selon moi) pour alléger considérablement la charge des développeurs, c'est un mécanisme à la defer (go, je crois?).
      Toujours en parlant de jeu vidéo, l'auteur devrais s'amuser un peu avec le langage de script de mindustry, la, il verrait ce qu'est une absence de gestion de la mémoire.

      Et toujours à ma connaissance, en C++ les automatismes de gestion de la mémoire, ça se limite à la STL (mais je doute que Rust n'ait pas une lib pour tableaux dynamiques, listes chaînées, dictionnaires, … hein) ainsi qu'à la RAII (idem, je doute que Rust ne permette pas ce genre de choses, d'une façon ou d'une autre).
      Comme je l'ai dit plus haut, la STL elle-même n'est pas parfaite, mais c'est bien assez bon dans 99% des cas.

      Je me demande a quoi sert cette phrase en fait. Pourquoi dénigrer les autres langages (surtout en intro, ça ne donne pas envie)? Rust n'a-t-il donc pas assez de qualités?

      • [^] # Re: C++, haut niveau ?

        Posté par  (site web personnel) . Évalué à 2.

        Ce qu'il manque au C (selon moi) pour alléger considérablement
        la charge des développeurs, c'est un mécanisme à la defer (go,
        je crois?).

        Je vais peut être dire une connerie, mais tu veux dire quoi par "un mécanisme à la defer" pour toi ?

        Il y a l'attribut cleanup dans gcc et llvm (donc c'est pas le C), mais ça fait pas ce que tu voudrais plus ou moins ?

        • [^] # Re: C++, haut niveau ?

          Posté par  . Évalué à 2.

          Je vais peut être dire une connerie, mais tu veux dire quoi par "un mécanisme à la defer" pour toi ?

          De permettre d'enregistrer des actions qui seront effectuées à la sortie de la fonction, typiquement pour éviter la dose de "goto erreur niveauX: fclose( file );"
          Ce que la RAII permets, quoi, mais en moins évolué et sans exceptions (qui ne sont pas appréciées par tout le monde).

      • [^] # Re: C++, haut niveau ?

        Posté par  . Évalué à 3.

        Et toujours à ma connaissance, en C++ les automatismes de gestion de la mémoire, ça se limite à la STL (mais je doute que Rust n'ait pas une lib pour tableaux dynamiques, listes chaînées, dictionnaires, … hein) ainsi qu'à la RAII (idem, je doute que Rust ne permette pas ce genre de choses, d'une façon ou d'une autre).

        Le C++ permet relativement simplement d’utiliser des mécanismes de gestion mémoire personnalisé, ce qui permet d’utiliser par exemple des gc sans trop d’efforts : https://v8.dev/blog/high-performance-cpp-gc

      • [^] # Re: C++, haut niveau ?

        Posté par  (site web personnel) . Évalué à 3.

        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.

        Est juste une affirmation fausse: en C, si, il y a une gestion de la mémoire: malloc/calloc/realloc/free, c'est plus simple à utiliser que maintenir soit-même le tas et la pile du programme.

        malloc/calloc/realloc/free (et alloca ne l'oublions pas), ce n'est pas de la gestion de la mémoire.

        C'est à toi de calculer la taille suffisante pour stocker ta donnée au préalable. C'est à toi de t'assurer que le pointeur retourné par realloc est toujours le même qu'avant. C'est à toi de t'assurer de ne pas appeler free 2 fois sur le même pointeur.

        L'implémentation des malloc qui appelle pour toi les syscall sbrk et brk et organisent la heap pour s'y retrouver reste quelque chose de très bas niveau, et peu différent de ce que tu ferais en Assembleur.

        Maintenant, concernant le C++ et le Go, c'est mécanisme de gestion de mémoire sont bien masqués à l'utilisateur. En Go ce n'est pas toi qui appelle le Garbage Collector, ou qui alloue la mémoire pour tes objets. En C++ ce n'est pas toi qui appelle tel ou tel constructeur, ni les destructeurs. Ces choses sont présentes, mais pas exposés à l'utilisateur.

        Je persiste et signe donc, C, C++, Go, et bien d'autres langages : ne sont pas explicite sur les concepts sous-jacents.

        https://link-society.com - https://kubirds.com

    • [^] # Re: C++, haut niveau ?

      Posté par  (site web personnel) . Évalué à 3.

      Ce qui suit n'est que mon avis.

      Pour moi, en C++ tu n'appelle pas manuellement les constructeurs (d'assignation, de copie, …) ou les destructeurs. Donc la durée de vie d'une variable au sein d'une scope est gérée pour toi. En C tu devras appeler ces fonctions à la main.

      La STL fournit nombre d'abstractions qu'il est désormais recommandé d'utiliser, sauf si tu as un besoin spécifique bien sûr.

      L'héritage, déjà rien que ça. Je recommande la lecture de ooc.pdf pour comprendre comment l'implémenter en C, et ainsi comprendre comment le C++ masque un autre concept bas niveau (organisation de la mémoire, édition des liens, etc…).

      Ce qui fait qu'un langage peut être considéré comme haut niveau selon moi, c'est le nombre d'abstraction présente. Et C++ possède beaucoup d'abstraction, la ou le C n'en possède quasiment aucune.

      https://link-society.com - https://kubirds.com

      • [^] # Re: C++, haut niveau ?

        Posté par  . Évalué à 4.

        Vrai, mais C++ permets aussi de faire à la main.
        C'est parfois utile, quand par exemple on veut éviter les réallocations et garder la mémoire "en bloc" (fragmentation mémoire). La STL ne fournit par exemple pas de ring buffer.

        On pourrait aussi parler, niveau utilité, de la programmation bare metal (pas d'OS pour gérer la ram).

        Certains programmes vont aussi implémenter plusieurs "tas", en fonction de la taille des allocations nécessaires ou de l'espérance de vie, pour des raisons de performances.

        On est bien d'accord: ce sont des cas exceptionnels. Mais ils existent, sont pour moi uniquement possibles dans des langages bas-niveau, et le sont en C++. Malgré qu'il fournisse aussi des fonctionnalités de haut niveau (RAII, héritage, exceptions, RTTI. La programmation générique me paraît ni haut, ni bas niveau par contre?).

        La STL fournit nombre d'abstractions qu'il est désormais recommandé d'utiliser, sauf si tu as un besoin spécifique bien sûr.

        Typiquement, la STL est incapable de gérer le besoin spécifique qui est de gérer des handles, tant que c'est pas des pointeurs.
        Descripteurs de fichiers UNIX, ID mémoire opengl, ID de fenêtres WIN32… je pense que certaines API que BDD utilisent aussi des IDs.

        Elle est pourtant à mon avis un bon example de fonctionnalités génériques, de haut niveau, puisque le contrôle sur la mémoire est au final assez faible quand on l'utilise, au moindre problème ça balance une exception, et il n'existe à ma connaissance aucun outil capable de vérifier que toutes les exceptions ont été traitées, en C++.
        Par contre, le langage lui-même est capable de mieux, notamment pour le debug (non, debug la STL c'est pas génial) ou les systèmes ou les exceptions ne sont pas les bienvenues (pour diverses raisons). Cf eastl.

  • # libretro core?

    Posté par  (site web personnel) . Évalué à 5.

    Pour éviter de se taper la gestion de fenêtre (et les changements de résolutions, et les mappings avec claviers/manettes…), pourquoi ne pas écrire le jeu sous forme de core libretro ?

    Ainsi le jeu est une DLL à charger via une application (comme l'excellent Retroarch) qui se charge de tout.

    https://docs.libretro.com/development/libretro-overview/
    https://docs.libretro.com/development/cores/developing-cores/
    https://github.com/max-m/rust-libretro/tree/f8cb496b49b2debfbb5d7dd66893eb4dac35d145/rust-libretro-example-core

    Le post ci-dessus est une grosse connerie, ne le lisez pas sérieusement.

    • [^] # Re: libretro core?

      Posté par  (site web personnel) . Évalué à 3.

      pourquoi ne pas écrire le jeu sous forme de core libretro ?

      Parce que c'était pas le sujet du "challenge".

      Je suis parti de "je voudrais bien ouvrir une fenêtre de manière cross-platforme sans embarquer une DLL" -> winit

      Pour continuer vers "j'aimerais bien une primitive sympa pour dessiner dedans" --> pixels

      Ensuite, je me suis hurté au compilateur Rust, ce qui m'a motivé à écrire cet article, comme un rappel personnel que ce que j'ai appris depuis 20 ans est faux : la programmation c'est compliqué, les langages nous mentent en cachant cela.

      https://link-society.com - https://kubirds.com

      • [^] # Re: libretro core?

        Posté par  (site web personnel) . Évalué à 10.

        la programmation c'est compliqué, les langages nous mentent en cachant cela.

        Surtout en Rust !

        Plus j'en apprends sur ce langage, moins j'ai envie d'en faire:

        • la syntaxe est moche ;
        • les temps de compilation tendent vers l'infini ;
        • sous prétexte d'être plus "safe", on doit se prendre la tête avec 42 concepts qui polluent le code ;
        • c'est safe, mais dès l'exemple de base de wgpu, on se retrouve avec un bloc "unsafe".

        J'y viendrais certainement un jour pour contribuer à un projet existant, mais à reculons façon moonwalk…

        Le post ci-dessus est une grosse connerie, ne le lisez pas sérieusement.

        • [^] # Re: libretro core?

          Posté par  (site web personnel) . Évalué à 7.

          Surtout en Rust !

          Bah non justement, en Rust les concepts ne sont pas masqués :

          • allocation dynamique sur la heap ? Box<T>
          • allocation dynamique avec refcounting pour pouvoir partager une même ressource ? Rc<T>
          • je veux partager la même data en lecture mais pas en écriture (copy on write) ? Cow<T>
          • je veux protéger l'accès concurrentiel à une donnée ? Mutex<T> souvent avec Arc<Mutex<T>>
          • je veux créer une structure qui est thread-safe ? Implémentation de Send et/ou Sync

          Il n'y a rien de plus explicite, ces informations apparaissent dans le système de type qui va permettre au compilateur de s'assurer que les règles qui gouvernent ces concepts sont bien respectées.

          la syntaxe est moche

          C'est subjectif. Je trouve aussi que la syntaxe est lourde, mais c'est pas plus moche que des templates C++ qu'on observe en masse dans les librairies "header only" (beurk).

          les temps de compilation tendent vers l'infini ;

          Exagération obsolète.

          1. Ca compile plus vite que du C++ (de mon expérience personnelle et anecdotique)
          2. https://fleet.rs/

          sous prétexte d'être plus "safe", on doit se prendre la tête avec 42 concepts qui polluent le code ;

          C'est pas sous prétexte d'être plus safe. C'est sous prétexte de prendre aucune décision pour le développeur. On le laisse gérer lui même pour qu'il puisse faire des applications performantes, on le laisse choisir lui même les trade-offs.

          Le Go est un langage safe aussi, mais les trade-offs ont été choisi par les développeurs du langage/compilateur. Et toi, pauvre petit développeur, tu dois faire avec.

          c'est safe, mais dès l'exemple de base de wgpu, on se retrouve avec un bloc "unsafe".

          Ca j'ai mis pas mal de temps à le comprendre. Mais en gros, le choix qui a été fait par les développeurs de Rust est le suivant :

          Il vaut mieux rejeter un programme valide qu'accepter un programme potentiellement invalide.

          Le unsafe permet au développeur d'indiquer au compilateur que, même si le compilateur ne sait pas le déterminer, le bloc de code est safe. C'est à utiliser avec parcimonie, mais le côté "non safe" (qui est plutôt "n'a pas réussi a déterminer la safety") ne fuit pas vers le reste du programme.

          De plus, le hardware sur lequel tourne le code Rust (ton PC) est par nature "unsafe". Il y a donc naturellement des choses que l'on ne peut pas déterminer à l'avance comme étant "safe".

          Au final, utiliser "unsafe" c'est déléguer à l'OS la garantie que l'opération est safe. Tout comme en Haskell on délèguerait la gestion d'une IO (impure) au runtime.

          Plus j'en apprends sur ce langage, moins j'ai envie d'en faire

          Et c'est dommage. Ce langage n'est pas parfait, loin de là. Beaucoup de fonctionnalités ne sont pas encore stabilisées (hello générateurs/code asynchrone/…). La syntaxe est dure à comprendre.

          Mais plus j'apprend ce langage, plus mes hypothèses et opinions se cassent la figure, pour laisser place à la réalité.

          Grâce à Rust, je me pose des questions sur mon code, mes données, que je ne me posais pas avant, et ce même dans d'autres langages.

          Si j'ose dire, apprendre le Rust fait de moi un meilleur développeur C / C++.

          https://link-society.com - https://kubirds.com

          • [^] # Re: libretro core?

            Posté par  . Évalué à 5.

            - la programmation c'est compliqué, les langages nous mentent en cachant cela.
            - Surtout en Rust
            - Bah non justement, en Rust les concepts ne sont pas masqués
            

            je suppose que le "surtout en Rust" de devnewton, s'appliquait à "la programmation c'est compliqué" et pas à "les langages nous mentent"

            • [^] # Re: libretro core?

              Posté par  (site web personnel) . Évalué à 3.

              Tout à fait :-)

              Ce que je trouve dommage avec Rust, c'est qu'il me semble difficile de faire simple.

              Faire du C++ simple, je sais faire. Les templates de la mort, les bidouilles de pointeurs et autres gruikeries, ça existe, mais rien ne m'oblige à les utiliser.

              Le post ci-dessus est une grosse connerie, ne le lisez pas sérieusement.

              • [^] # Re: libretro core?

                Posté par  (site web personnel) . Évalué à 2. Dernière modification le 08 juin 2022 à 14:00.

                Ah j'avais mal compris pardon :p

                avec Rust, c'est qu'il me semble difficile de faire simple.

                C'est parce qu'en vérité, rien n'est simple :p

                https://link-society.com - https://kubirds.com

  • # Qu'est-ce qu'une abstraction ?

    Posté par  . Évalué à 8.

    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.

    J'ai l'impression qu'il y a un quiproquo autours de la notion d'abstraction (peu différente de la notion de "concept sous jacent", la seule chose concrète dans la programmation c'est des électrons dans du silicium, tout le reste n'est qu'abstraction.

    Tous les langages ne sont qu'abstraction, jusqu'à l'assembleur qui propose une abstraction de ce qu'est une instruction machine, quelques types, etc. C'est normal que les langages de programmation ne soient qu'abstractions : c'est leur raison d'exister, personne n'a envie de programmer la machine directement.

    Rust est un langage qui manipule des abstractions de très haut niveau, des fonctions, des pointeurs, avec une notion complexe de "propriété", des "traits", de l'ordre supérieur, etc.
    Le passage de la moindre instruction rust au code machine généré par le compilateur est vertigineux (ou pas généré d'ailleurs, si le système de type se met en travers, une certaine abstraction de ce qu'est un programme "raisonnable").

    Quel niveau d'abstraction est le bon ? Trop bas il ne sert à rien, trop haut il devient restrictif…

  • # J'ai appris plein de trucs et suis tenté par ECS

    Posté par  (site web personnel, Mastodon) . Évalué à 3.

    Merci pour cet article, je développe des jeux en Rust depuis quelque années. Dernièrement avec ggez et macroquad. Eh bien tu m'as appris plein de choses.

    Jusqu'à présent, j'étais frileux d'utiliser de l'ECS. Préférant garder le "contrôle" sur mes structures, listes, enchaînement des étapes de la logique de mon jeu, etc. Je commence à me demander si ça ne vaudrait pas le coup que je m'y intéresse de plus près. Quels bénéfices apporte ECS par rapport à gérer sois-même ses données et enchainements ?

    🦀🐍 http://github.com/buxx 🖥 https://algoo.fr 📋 https://tracim.fr

    • [^] # Re: J'ai appris plein de trucs et suis tenté par ECS

      Posté par  (site web personnel) . Évalué à 3.

      Eh bien tu m'as appris plein de choses

      Ca fait plaisir de faire des choses utiles :) Merci.

      Quels bénéfices apporte ECS par rapport à gérer sois-même ses données et enchainements ?

      • facilement parallélisable
      • certains "moteurs ECS" ont des optimisations faites sur le stockage des données en mémoire
      • découplage donnée/logique
      • contrôle précis de l'orde d'exécution des systèmes

      Et surement plein d'autres encore.

      https://link-society.com - https://kubirds.com

  • # Livre?

    Posté par  . Évalué à 3.

    Hey, ce serait une super idée d'écrire un livre sur l'écriture d'un moteur de jeu!
    Tellement bonne qu'elle serait même déjà prise ?-)
    (Multiplayer Game Development in Rust, by Stephan Dilly and Lyon Beckers, Manning, not-yet-published)

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.