Comment créer des jeux 3D avec PureScript Native et C ++

La dernière fois que j'ai écrit sur la liaison de PureScript à C ++, j'ai présenté l'animation du logo PureScript avec SFML. Depuis lors, PureScript Native (PSN) a remplacé Pure11 et les détails relatifs à l’utilisation de PSN ont changé.

Pour concrétiser ce constat, je vais expliquer ce qu’il a fallu pour créer Lambda Lantern - un jeu en 3D sur les modèles de programmation fonctionnels. À l'origine, Lambda Lantern a commencé comme une soumission GitHub Game Off. Comme il ne vous reste qu'une trentaine de jours à terminer, des raccourcis ont dû être faits. Je compte aborder les raccourcis et étoffer le jeu en l’utilisant comme moyen de piloter la liaison C ++ et l’écosystème qui l’entoure.

Si vous ne souhaitez pas créer de jeu, mais que vous souhaitez tout de même lier PureScript au code C ++ ou si vous cherchez une alternative à la liaison de Haskell à C ++ - pas de soucis - j'ai essayé de garder ce guide aussi général que possible. possible.

Mise en place du projet

Git clonez PSN et construisez-le avec stack comme tout autre projet Haskell Stack.

git clone https://github.com/andyarvanitis/purescript-native
cd purescript-native
installation en pile
CD

Une fois terminé, vous obtiendrez le compilateur PureScript vers C ++ appelé pscpp. Si votre pile est installée, elle devrait se trouver dans votre répertoire bin local. Dans tous les cas, assurez-vous que votre variable d'environnement de chemin d'accès contient le chemin d'accès à pscpp.

export PATH = "$ {PATH}: $ {HOME} /. local / bin"

Ensuite, vous aurez besoin de Node.js et NPM. J'aime gérer Node avec NVM

clone de git https://github.com/lettier/lambda-lantern.git
cd lambda-lantern /
nvm install `cat .nvmrc` && nvm use

mais n'hésitez pas à utiliser une méthode que vous appréciez. Installez purescript et psc-package comme ceci.

npm installer -g purescript psc-package-bin-simple

PSN peut générer un fichier Make pratique qui gère le processus de construction. Si le Makefile n'est pas déjà présent, allez-y et générez-le maintenant.

pscpp --makefile

Lambda Lantern a déjà un paquet défini pour psc-package. Je ne pouvais pas utiliser le plus commun car je devais inclure la fourchette du prélude Andy Arvanitis. La fourchette ne repose pas sur les comparaisons d’égalité non sécurisées de type implicite. Plus tard, ils ont été supprimés dans la version 4.1.1 du prélude. Cela devrait me permettre d'utiliser l'écosystème d'origine. Quoi qu'il en soit, vous pouvez toujours développer votre propre ensemble de paquets avec psc-package init et en pointant le fichier psc-package.json vers votre référentiel local ou distant contenant votre fichier packages.json.

Une fois que votre fichier psc-package.json est prêt, installez les dépendances PureScript à l'aide de la commande suivante.

Installation de psc-package

Si vous regardez sous .psc-package /, vous verrez tout le code PureScript de votre ensemble de paquets, mais il vous manque encore le C ++ FFI (interface de fonction étrangère).

Créez un répertoire ffi / dans le répertoire principal de votre projet. Le Makefile que vous avez généré précédemment est configuré pour y chercher. Vous pouvez bien sûr le changer mais assurez-vous de mettre à jour le Makefile.

Clonez le contenu de purescript-native-ffi dans le répertoire ffi /.

Pour Lambda Lantern, lors du clonage du référentiel FFI, veillez à ne pas écraser les fichiers existants car je devais apporter des modifications et des ajouts.

git clone \
  https://github.com/andyarvanitis/purescript-native-ffi.git
cp -nr purescript-native-ffi /. lambda-lanterne / ffi /

Votre répertoire de projet devrait ressembler à quelque chose comme ça.

/
  .psc-package /
  ffi /
  node_modules /
  src /
    Main.purs
  Makefile
  psc-package.json

À ce stade, vous souhaiterez installer le moteur de jeu ou le code C ++ auquel vous serez lié. Pour Lambda Lantern, vous devrez installer Panda3D. Panda3D a des installateurs pour Linux, macOS et Windows.

Choisir le moteur de jeu

Après avoir passé en revue plusieurs moteurs de jeu basés sur C ++, j'ai choisi Panda3D, car il disposait de l'API la plus simple à associer. Panda3D est livré avec la plupart des fonctionnalités que vous voyez ailleurs, comme les shaders, les effets de particules, la physique, les cibles de rendu multiples, l'audio 3D, etc. Néanmoins, si vous préférez un autre moteur, n'hésitez pas à l'utiliser. L'idée de base de ce guide est toujours applicable.

Un motif commun que j'ai remarqué dans de nombreux moteurs que j'ai examinés était la nécessité d'hériter d'une classe d'applications. Le moteur de jeu contrôle les commandes principales et appelle le style principal d'Hollywood de votre classe d'applications. Avoir le contrôle de moteur principal ferait un sandwich encombrant C ++ et PureScript - commençant et finissant en C ++ avec la logique de jeu PureScript au milieu. Je suis sûr que vous pourriez déconstruire la classe d’application et reproduire ce que fait le moteur, mais avec Panda3D disponible, je n’en ai pas vu la nécessité.

La documentation de Panda3D est volumineuse, mais je devais parfois plonger dans le code pour trouver telle ou telle classe qui n’apparaissait pas dans la référence C ++. Les forums sont également une excellente ressource avec plus d'une décennie de messages à parcourir. Parfois, vous rencontrerez des solutions obsolètes, je vous recommande donc de trier les données les plus récentes.

Création des exportations C ++ FFI

Voici un fichier FFI PSN C ++ sans fioritures.

#include "purescript.h"
FOREIGN_BEGIN (SomeModuleName)
FOREIGN_END

C'est entre le début et la fin étrangers que vous définissez les exportations appelées du côté de PureScript.

exports ["id"] = [] (const boxed & param_) -> boxed {
  retourne param_;
};

Pour contourner les paramètres et renvoyer les valeurs, PSN utilise la classe boxed. Cette classe encadrée repose sur shared_ptr.

Pour utiliser une valeur encadrée, vous devrez la décompresser, en indiquant son type.

auto param = unbox  (param_);

Si vous ne connaissez pas le type, vous êtes bloqué. C'est le problème des comparaisons d'égalité de type implicite puisque les types encadrés ont leurs types effacés.

Toutes les exportations doivent accepter les valeurs encadrées et renvoyer les valeurs encadrées. Certains types tels que string, char, double, int, long, etc. peuvent être retournés directement. Le compilateur construira une valeur boxed pour vous puisque la classe boxed est livrée avec quelques constructeurs pratiques.

exports ["echo"] = [] (const boxed & s_) -> boxed {
  const auto s = unbox  (s_);
  résultats;
};

Pour les autres types, vous devrez appeler la procédure box pour mettre votre type en boîte.

exports ["someFunction"] = [] (const boxed & param_) -> boxed {
  auto param = unbox  (param_);
  // ...
  boîte de retour  (param);
};

Vous devrez curry vos fonctions ou en d’autres termes, renvoyer une nouvelle fonction lambda pour chaque paramètre. Si votre fonction renvoie un effet, vous aurez besoin d’une fonction lambda supplémentaire ne prenant aucun paramètre.

En PureScript:

importation étrangère add1 :: Number -> Number
importation étrangère add1 ':: Number -> Number Effect

En C ++:

exports ["add1"] = [] (const boxed & n_) -> boxed {
  const auto n = unbox  (n_);
  retourne n + 1,0;
};
exports ["add1 '"] = [] (const boxed & n_) -> boxed {
  const auto n = unbox  (n_);
  return [=] () -> boxed {
    retourne n + 1,0;
  };
};

Marquer une importation avec Effect est à vous, mais si la fonction modifie quoi que ce soit en dehors de sa portée ou renvoie une sortie différente pour la même entrée, elle doit utiliser Effet.

Pour ceux qui ne connaissent pas la syntaxe ci-dessus, les exportations PSN utilisent des fonctions lambda C ++.

[] // signifie ne capturer aucune variable externe.
[=] // signifie capturer les variables externes par valeur.
[&] // signifie capturer les variables externes par référence.

Par valeur, vous devez en faire une copie afin que les modifications apportées à la copie n’affectent pas l’original et par référence, créez un alias de manière à ce que les modifications affectent l’original. Idéalement, vous voudrez utiliser [=] au lieu de [&] pour ne pas modifier les paramètres d'entrée.

Voici quelques modèles de fonction lambda C ++ typiques.

[& capture, = liste] (p, a, r, a, m, s) -> ReturnType {body; }
[& capture, = liste] (p, a, r, a, m, s) {corps; }
[& capture, = list] {body; }

Je pense que la meilleure pratique consiste à décompresser vos paramètres au fur et à mesure de leur réception et à utiliser [=] pour chaque fonction lambda renvoyée, comme ceci.

[] (const boxed & param_) -> boxed {
  const auto param = unbox  (param_);
  return [=] (const boxed & param1_) -> boxed {
    const auto param1 = unbox  (param1_);
    return [=] (const boxed & param2_) -> boxed {
      const auto param2 = unbox  (param2_);
        revenir ...
    };
  };
};

Cependant, pour certaines exportations, je devais déballer les paramètres de la dernière fonction lambda uniquement. Par exemple, comme ça. Unboxing dans le premier lambda puis une copie par référence comme celle-ci ont causé un segfault Et je ne pouvais pas décompresser dans le premier lambda, puis copier par valeur comme ceci, car le compilateur se plaindrait que j’essayais de modifier un NodePath constant avec set_scale. Il y avait la possibilité d'utiliser le mot clé mutable comme celui-ci, mais le déconditionnement de tous les paramètres de la dernière fonction lambda a bien fonctionné.

Une autre voie aurait été d’allouer dynamiquement des chemins de nœuds -

// ...
retourne [&] () -> boxed {
  // ...
  const auto nodePathPtr =
    std :: make_shared  (
      nodePath.find (requête)
    )
  return nodePathPtr;
};
// ...

renvoyer et accepter des pointeurs vers eux - mais cela aurait ajouté une surcharge inutile. Néanmoins, avec les pointeurs, vous pouvez les maintenir constants, décompressez-les dès que vous les recevez et copiez-les par valeur à l'aide de [=].

De manière générale, je suis resté proche de l'API Panda3D. Idéalement, vous souhaitez que vos exportations soient très petites et basiques - insérez toute logique supplémentaire du côté de PureScript pour une protection accrue. Cependant, dans certains cas, j'ai dévié lorsque les procédures étaient routinières et répétitives.

La liaison à une partie de l’API Panda3D n’était pas la seule FFI nécessaire. Il était également nécessaire de créer des exportations FFI pour les fonctionnalités courantes, comme maintenant, les fonctions centrées sur JS telles que setInterval, requestAnimationFrame, etc., et la recherche de variables d'environnement système - quelque chose de complètement étranger à JavaScript (du moins dans le navigateur).

La majeure partie de l'écosystème PureScript suppose un back-end JavaScript, ce qui est compréhensible. Espérons cependant que l’utilisation du PSN augmente, augmentant ainsi la couverture C ++ FFI. Pouvoir cibler facilement les plates-formes Web et natives avec PureScript est définitivement l'idéal.

Un appel particulier de FFI concernait la fenêtre du navigateur, ce qui n’était pas applicable. Je l’ai donc rempli avec un no-op.

exports ["window"] = [] () -> boxed {return boxed (); };

Notez que le temps d’exécution du PSN génère une erreur s’il ne parvient pas à trouver l’exportation FFI pour un appel FFI. Si cela se produit, vous verrez quelque chose comme ce qui suit.

terminer appelé après avoir lancé une instance de
'std :: runtime_error'
what (): clé de dictionnaire "setInterval" non trouvée

Évidemment, ce serait bien si cela était pris au moment de la compilation, mais cela pourrait ne pas être réalisable.

Je savais que je voulais utiliser la programmation réactive fonctionnelle (PRF), j'avais donc besoin de comportements purescript et d'événement purescript. Le premier utilise requestAnimationFrame tandis que le dernier utilise clearInterval et setInterval.

En comptant les jours jusqu'à la fin du jeu vidéo, j'ai décidé de choisir un modèle multi-thread simple pour émuler ces fonctions centrées sur JS en C ++. En traitant de multiples threads par rapport à la nature unique des threads de JavaScript, mes événements FRP présentaient des différences subtiles et moins subtiles.

En JavaScript cette

setInterval
  (function () {while (true) {console.log (1);}}
  , 1000
  )
setInterval
  (function () {while (true) {console.log (2);}}
  , 1000
  )

n’imprimerait que 1 à plusieurs reprises, sans jamais laisser une chance au deuxième intervalle. Mais en C ++ (avec mon FFI) quelque chose comme ça

setInterval
  ([] {while (true) {std :: cout << 1 << "\ n";}}
  , 1000
  )
setInterval
  ([] {while (true) {std :: cout << 2 << "\ n";}}
  , 1000
  )

serait imprimer 1 et 2, encore et encore simultanément.

Je prévois de revenir au modèle multithread et de le convertir en un modèle de planification de file d'attente prioritaire mono-threadé et non préemptif pour qu'il ressemble davantage à son homologue JavaScript.

Création des importations PureScript FFI

Le côté PureScript de la clôture FFI n’a pas été aussi mouvementé. Ayant plus de temps, j’aurais créé des classes de type pour émuler les hiérarchies de classes trouvées dans Panda3D. Maintenant que le jeu vidéo est terminé, je peux passer en revue et ranger les interfaces en faisant abstraction des points communs.

La création des appels PureScript FFI pour C ++ est identique à celle utilisée pour JavaScript.

Voici un exemple d’appel FFI pour créer une lumière ambiante.

données d'importation étrangère PandaNode :: Type
- ...
importation étrangère createAmbientLight
  :: Chaîne
  -> Nombre
  -> Nombre
  -> Nombre
  -> Effet PandaNode

Cette importation appellera son homologue d'exportation C ++ FFI.

En regardant le C ++ généré par pscpp, vous pouvez voir comment cela est finalement lié.

auto createAmbientLight () -> const boxé & {
  Const statique boxed _ =
    foreign (). at ("createAmbientLight");
  revenir _;
};
// ...
boxed v27 =
  Panda3d :: createAmbientLight
    ()
    ("lumière ambiante")
    (0,125)
    (0,122)
    (0,184)
    ();

Construire le projet

PSN utilise le compilateur purs (v0.12.0), il n’ya donc pas de différence - en termes de PureScript - entre les cibles C ++ et JavaScript.

Voici les grandes lignes du processus de construction.

  • Compilez le code PureScript en appelant purs et en produisant quelque chose appelé «CoreFn», qui est la représentation AST (abstract syntax tree).
  • Compilez le CoreFn en appelant pscpp et en affichant C ++.
  • Compilez et liez le C ++ en appelant quelque chose comme g ++ et en affichant le fichier exécutable final.

L'utilisation du Makefile généré facilite grandement le processus de construction. Il vous permet de passer des indicateurs de compilateur et d'éditeur de liens si nécessaire. Voici la commande make que j'utilise pour construire Lambda Lantern

faire \
  CXXFLAGS = "\
    -fmax-errors = 1 \
    -I / usr / include / python \
    -I / usr / include / panda3d \
    -I / usr / include / freetype2 "\
  LDFLAGS = "\
    -L / usr / lib / panda3d \
    -lp3framework \
    -lpanda \
    -lpandafx \
    -lpandaexpress \
    -lp3dtoolconfig \
    -lp3dtool \
    -lp3pystub \
    -lp3direct \
    -pthread \
    -lpthread "

La plupart des drapeaux doivent inclure les en-têtes Panda3D et les liens avec les bibliothèques Panda3D.

/ usr / include / python, / usr / include / panda3d, / usr / lib / panda3d, etc. peuvent ne pas être les chemins exacts de votre système. Vous pouvez également avoir besoin de drapeaux supplémentaires non listés ci-dessus. Par exemple, sur Ubuntu, je dois inclure -I / usr / include / eigen3.

Après avoir construit votre projet, l’exécutable sera situé dans ./output/bin/.

Distribuer le projet

PSN est multi-plateforme entre Linux, MacOS et Windows. Pour cette section, je vais décrire ce que j’ai fait pour distribuer Lambda Lantern for Linux.

Après avoir créé des instantanés, des paquets plats, des packages AUR et AppImages, je trouve qu'AppImage est la meilleure expérience à la fois pour le distributeur et pour l'utilisateur.

Pour commencer, vous voudrez savoir sur quelles bibliothèques repose votre binaire.

objdump -p sortie / bin / main

Recherchez la «section dynamique» dans la sortie.

Section dynamique:
  NÉCESSAIRE libp3framework.so.1.10
  Nécessaire libpanda.so.1.10
  ...

Vous aurez peut-être besoin de bibliothèques supplémentaires non répertoriées. Et même si vous avez réussi à inclure toutes les bibliothèques, votre AppImage peut ne pas s'exécuter sur des distributions plus anciennes, en fonction de la mise à jour de votre environnement de construction (généralement à cause de la libc). Je recommande d’utiliser Ubuntu 14.04 ou 16.04 comme environnement de construction, puis de tester votre AppImage sur autant d’installations récentes que possible. Si vous avez manqué des bibliothèques, vous recevrez une erreur indiquant la bibliothèque introuvable, comme ci-dessous.

erreur lors du chargement des bibliothèques partagées: libpanda.so.1.10: impossible d'ouvrir le fichier d'objet partagé: aucun fichier ou répertoire de ce type

La bonne chose à propos de AppImage est que vous pouvez inclure autant ou aussi peu de bibliothèques que vous le souhaitez. Dans l’idéal, vous devez inclure une bibliothèque qui ne se trouve généralement pas sur un système moyen. Cela donnera à votre AppImage la plus grande chance de travailler hors de la boîte.

Voici l’arborescence de répertoires de la Lambda Lantern AppImage.

/
  etc/
    Confauto.prc
    Config.prc
  usr /
    lib /
      panda3d /
    partager/
      applications/
        com.lettier.lambda-lantern.desktop
      icônes / hicolor / 256x256 /
        com.lettier.lambda-lantern.png
      lambda-lanterne /
        les atouts/
          des œufs/
          polices /
          la musique/
          des sons/
      licences /
      metainfo /
        com.lettier.lambda-lantern.appdata.xml
  AppRun

Les fichiers Config.prc et Confauto.prc sont requis pour que Panda3D puisse s'exécuter. Dans le fichier AppRun, j’ai fourni le chemin des fichiers prc et du répertoire des actifs de Lambda Lantern. Si vous vous en souvenez bien, je devais créer une exportation / importation FFI pour rechercher des variables d'environnement. Au début du programme, Lambda Lantern recherche une variable d'environnement pour trouver ses actifs.

exporter LAMBDA_LANTERN_ASSETS_PATH = \
  "$ {HERE} / usr / share / lambda-lantern / assets"
export PANDA_PRC_DIR = "$ {ICI} / etc"

Avec tous les fichiers en place et le fichier AppRun rempli, vous créez AppImage comme suit.

appimagetool-x86_64.AppImage lambda-lantern.AppDir

L'outil AppImage effectuera une validation, puis générera AppImage si tout est vérifié.

Si vous utilisez GitHub, vous pouvez télécharger AppImage vers une version à télécharger par les utilisateurs. Après le téléchargement, les utilisateurs peuvent marquer AppImage comme exécutable et commencer à jouer!

Dernières pensées

Lier Haskell à C est assez facile mais pas avec C ++. Ceci est regrettable car certaines des bibliothèques largement utilisées pour le développement de jeux sont généralement en C ++. Pendant un certain temps, j'ai essayé différentes méthodes pour lier Haskell à C ++ mais cela n'a jamais été aussi simple que de lier PureScript à C ++ en utilisant PSN. Si vous avez trouvé un excellent moyen de lier Haskell à des projets C ++ complexes, faites-le moi savoir. Toutefois, si vous avez essayé de lier Haskell à C ++ - sans chercher d’excellentes options - envisagez vivement le PSN.

La possibilité de lier PureScript au C ++ ouvre de nombreuses possibilités, car il existe de très bons projets basés sur C ++. Des moteurs de jeu de pointe aux bibliothèques d’apprentissage machine robustes. Choisir l'une d'entre elles et y coller une interface PureScript vous confère des avantages indéniables.

Je pense que choisir Panda3D est la bonne voie à suivre. Il n’essaie pas de contrôler le flux de travail - du concept à l’exécutable - ce qui le rend parfait pour moi, car j’aime marcher sur des chemins inexplorés, et voir ce que j’ai pu faire avec les projets Gifcurry et Movie Monad. À l’origine, je voulais utiliser Godot, mais j’avais besoin de quelque chose que je connecte au lieu d’un environnement que vous augmentez de l’intérieur. Si vous connaissez un jeu qui contrôle main et utilise Godot avec uniquement du C ++, veuillez me le faire savoir. Quoi qu’il en soit, Panda3D est un véritable bijou. Si vous en savez un peu, vous découvrirez de très bons projets qui l’utilisent comme celui ci-dessous.

Espérons que d’autres utiliseront de plus en plus le PSN. Je suis très enthousiaste à l'idée de transformer le concept de Lambda Lantern en un jeu complet, mais je suis encore plus enthousiaste à l'idée de l'utiliser pour passer le mot et créer l'écosystème autour du PSN. À mon avis, être capable de cibler facilement le Web (JavaScript) et le bureau (C ++) - en utilisant uniquement PureScript - est un rêve devenu réalité.