👾 J'ai porté Quake III Arena sur PHP — Des fichiers PK3 à une carte jouable

작성자

카테고리:

← 피드로
DEV Community · Mathieu Ledru · 2026-08-14 개발(SW)

Quake III Arena n’est pas un simple prototype. C’est un jeu de tir en arène complet, doté d’un système de fichiers ZIP, d’un format de carte binaire, d’un langage de shaders autre que GLSL, d’un système de collisions différent du maillage dessiné et d’un code de déplacement qui, vingt-cinq ans plus tard, reste très particulier. Porter une partie de ce système en PHP ressemblait à un défi. C’en était un, en partie. C’était aussi une manière délibérée d’apprendre à maîtriser un moteur en réimplémentant ses mécanismes internes, et non en survolant un wiki.

Cet article décrit une expérience : un portage PHP progressif capable de lancer une fenêtre native, de charger des données PK3 (version commerciale ou démo), d’analyser une carte IBSP, de générer des lots de rendu, de gérer les déplacements du joueur à la Quake en tenant compte des collisions BSP, de tirer avec un arsenal réduit, d’ouvrir des portes et de rendre la carte maps/q3dm0.bsp suffisamment cohérente pour s’y déplacer, combattre et mourir. Il ne s’agit pas d’affirmer que Quake III a été entièrement réécrit en PHP. Il n’y a ni QVM, ni multijoueur, ni IA de bots, ni système de menus, et les textures du monde sont encore incomplètes. Ce qui existe, c’est une version jouable, avec la logique du moteur implémentée en PHP et l’interface graphique gérée par FFI.

Si vous souhaitez uniquement exécuter la commande, passez directement à la section Comment l’exécuter. Le reste décrit le chemin d’accès qui a permis de rendre cette commande utile.

Pourquoi porter Quake III sur PHP ?

PHP passe la majeure partie de son temps à répondre aux requêtes HTTP. Ce type de traitement est de courte durée : analyser une requête, interroger une base de données, générer du HTML ou du JSON, puis terminer. Les moteurs de jeu fonctionnent à l’inverse. Ils conservent l’état pendant plusieurs minutes, effectuent des dizaines d’itérations par seconde, décodent des formats binaires et font la distinction entre un maillage de rendu et un pinceau de découpage.

Je savais déjà que PHP pouvait faire bien plus que des pages web. La gamme Darkwood inclut des outils hors ligne complexes et des fenêtres natives compatibles avec FFI. Quake III représentait un défi plus ardu : il ne s’agissait pas simplement de « dessiner des triangles », mais de « respecter suffisamment le pipeline id Tech 3 pour que les réflexes acquis dans le jeu original restent intacts ».

Les objectifs étaient concrets :

  • Considérez le code source du moteur GPL comme une spécification, et non comme une source d’inspiration.
  • Conservez les données du jeu hors du dépôt et chargez-les comme le fait Quake : via des archives PK3.
  • Privilégier la fidélité aux détails qui donnent au jeu l’aspect de Quake (ordre du système de fichiers, blocs BSP, nombre de mouvements, règles d’apparition) plutôt qu’une architecture tape-à-l’œil mais inventée. Acceptez que PHP soit plus lent que C, et demandez-vous tout de même s’il peut être suffisamment correct pour être jouable.

Je ne cherchais pas à prouver que PHP devrait remplacer C pour le déploiement de logiciels de tir. Je cherchais plutôt à voir jusqu’où un environnement d’exécution PHP moderne peut aller lorsqu’on cesse de considérer chaque programme comme un gestionnaire de requêtes.

Qu’est-ce que id Software a réellement publié

En 2005, id Software a publié le code source du moteur Quake III Arena sous licence GPL. Cette publication constitue le fondement juridique et technique de ce portage. Elle inclut le code C du client, du serveur, du moteur de rendu, du système de collisions et des modules de jeu — la machinerie même.

Ce fichier n’inclut pas les ressources commerciales du jeu. Les cartes, textures, sons, modèles et packages PK3 qui donnent à Quake III son apparence et son ambiance sonore restent la propriété de Quake III. Le développement a utilisé des données locales ou des données de démonstration accessibles via une option en ligne de commande. Ces fichiers ne doivent en aucun cas être intégrés à l’arborescence Git.

Cette distinction est essentielle à chaque étape de cet article. Lorsque je dis « le port charge pak0.pk3 », je parle de votre fichier baseq3 légal, et non d’un jeu redistribué. Lorsque je dis « basé sur Quake III Arena », je fais référence aux algorithmes et à la structure des données issus des sources GPL, réimplémentés en PHP.

Récupérer les données du jeu dans PHP

Quake III n’utilise pas un répertoire de fichiers TGA comme modèle de contenu principal. Le contenu est stocké dans des fichiers PK3 : des archives ZIP respectant la convention de chemin d’accès de Quake. Un répertoire baseq3 typique contient pak0.pk3 jusqu’aux correctifs ultérieurs. Les correctifs ultérieurs remplacent les précédents lorsqu’ils partagent le même chemin d’accès interne.

Le portage PHP reprend ce principe dans src/Filesystem/FileSystem.php. L’option --basepath permet de spécifier le chemin d’accès à la racine d’installation (avec un sous-répertoire baseq3), au répertoire baseq3 lui-même, ou à un répertoire contenant des fichiers PK3. Le chargeur détecte les fichiers *.pk3, trie leurs noms comme le fait paksort dans Quake, et ajoute un préfixe à chaque archive pour que les paquets suivants soient prioritaires par ordre alphabétique. Une recherche parcourt ensuite cette liste et s’arrête au premier résultat (sémantique de FS_FOpenFileRead).

// FileSystem::addGameDirectory + resolve — src/Filesystem/FileSystem.php
usort($names, static fn (string $a, string $b): int => Path::compare($a, $b));
foreach ($names as $name) {
    $loaded[] = $this->addPak($real . DIRECTORY_SEPARATOR . $name); // prepend
}

public function resolve(string $qpath): ?ResolvedFile
{
    // ...
    foreach ($this->packs as $pack) {           // head = highest priority
        $entry = $pack->getEntry($qpath);
        if ($entry !== null) {
            return new ResolvedFile($entry, $pack);
        }
    }
    return null;
}

Enter fullscreen mode Exit fullscreen mode

C’est là la spécificité de Quake : il ne s’agit pas d’« ouvrir un seul fichier ZIP », mais de « présenter plusieurs archives comme un seul système de fichiers virtuel avec un ordre de priorité ». Le fait que pak1.pk3 l’emporte sur pak0.pk3 pour le même chemin d’accès (qpath) est le principe de fonctionnement des mises à jour mineures et des correctifs.

Pk3Archive indexe chaque fichier ZIP une seule fois. La fonction Path::normalize dans src/Filesystem/Path.php supprime les séparateurs initiaux, convertit la casse et rejette les caractères d’échappement .. afin que Maps/Q3DM0.BSP et maps/q3dm0.bsp soient résolus de la même manière. Il n’existe pas de chemin de recherche par répertoire libre dans ce portage ; cette restriction est intentionnelle.

Les outils en ligne de commande comme fs:which, pk3:list et data:inspect existent car les erreurs du système de fichiers peuvent être confondues avec des bogues du moteur de rendu. Si le mauvais paquet est sélectionné, on pourrait croire que l’analyseur de shaders est défectueux. Lorsque bsp:mesh ou le démarrage du jeu affiche la provenance d’un BSP, il indique le chemin d’accès (qpath) et l’archive sélectionnée ; cette simple ligne de commande nous a fait gagner un temps précieux de débogage, bien plus que n’importe quelle sortie de profileur.

Comprendre IBSP

Les cartes sont des fichiers BSP : arbres de partitionnement spatial binaire plus un ensemble de blocs associés. Quake III Arena utilise l’identité IBSP et la version 46, avec 17 blocs dans l’en-tête.

BspLoader (src/Bsp/BspLoader.php) ne transforme pas d’abord le fichier en un graphe d’objets complexe. Il traite les octets comme une structure qui doit correspondre à l’en-tête de Quake : identité, version, puis dix-sept paires {fileofs, filelen}. Cette approche est légèrement plus stricte que certaines anciennes méthodes C qui se fiaient uniquement à la version : les décalages incorrects sont rejetés avant même que quiconque n’accède à l’index du tampon.

// BspLoader::loadFromString — src/Bsp/BspLoader.php
$head = unpack('Vident/Vversion', substr($bytes, 0, 8));
$ident = $head['ident'];
$version = $head['version'];
if ($ident !== BspIdent::IDENT) { /* expect IBSP */ }
if ($version !== BspIdent::VERSION) { /* expect 46 */ }

$lumps = [];
$offset = 8;
for ($i = 0; $i < BspIdent::HEADER_LUMPS; $i++) {
    $chunk = unpack('Vfileofs/Vfilelen', substr($bytes, $offset, 8));
    // ... bounds-check fileofs/filelen against strlen($bytes) ...
    $lumps[] = new BspLump(
        index: $i,
        name: BspIdent::LUMP_NAMES[$i],
        fileofs: $chunk['fileofs'],
        filelen: $chunk['filelen'],
    );
    $offset += 8;
}

Enter fullscreen mode Exit fullscreen mode

unpack('V…') manipule des entiers non signés 32 bits en little-endian, soit la même largeur et le même ordre d’octets que les entiers stockés sur disque dans Quake sur les plateformes concernées. La modélisation PHP idiomatique intervient après cette fidélité. Une erreur de lecture de la longueur d’un bloc de quatre octets ne génère pas une exception de domaine élégante par la suite ; elle produit une carte illisible.

Ces éléments ne sont pas interchangeables. Le port utilise des éléments différents pour différentes tâches :

  • Dessiner la géométrie : shaders, drawverts, drawindexes, surfaces, lightmaps, lightgrid, visibility, fogs (partiellement)
  • Collision : plans, nœuds, feuilles, brosses à feuilles, brosses, côtés de brosses, shaders (pour les indicateurs de contenu)
  • Entités de jeu : le bloc d’entités — un bloc de texte contenant des dictionnaires de paires clé/valeur délimités par des accolades
  • Modèles / déménageurs : les modèles regroupent les indices que func_door et les fonctions associées référencent comme des modèles de brosse *N

Le rendu géométrique répond à la question « Que dois-je dessiner ? » La gestion des collisions répond à la question « Où peut aller une boîte ? » La gestion des entités répond à la question « Où commence le joueur et quels sont les objets ? » Confondre ces trois éléments est le moyen classique d’obtenir une belle carte à travers laquelle on tombe.

Des surfaces BSP à un format dessinable

Une fois la carte chargée en mémoire, WorldBatchBuilder parcourt les surfaces et crée des lots de rendu statiques indexés par le shader et la lightmap. Les faces planes et les structures triangulaires deviennent des maillages indexés. Les patches de Bézier sont tessellés sur le processeur. Les surfaces du ciel et des halos sont classées et souvent ignorées en tant que géométrie solide, puis traitées par des chemins dédiés.

Ce qui surprend un développeur PHP lambda, c’est le manque d’élégance du chemin des sommets. Dans Quake, drawVert_t est un enregistrement fixe de 44 octets. La fonction BspDrawVert::fromBytes du fichier src/Bsp/BspDrawVert.php le décompose champ par champ :

// BspDrawVert::fromBytes — src/Bsp/BspDrawVert.php (44-byte drawVert_t)
return new self(
    xyz: [
        BspBinary::float32($bytes, $offset),
        BspBinary::float32($bytes, $offset + 4),
        BspBinary::float32($bytes, $offset + 8),
    ],
    st: [
        BspBinary::float32($bytes, $offset + 12),
        BspBinary::float32($bytes, $offset + 16),
    ],
    lightmap: [
        BspBinary::float32($bytes, $offset + 20),
        BspBinary::float32($bytes, $offset + 24),
    ],
    normal: [
        BspBinary::float32($bytes, $offset + 28),
        BspBinary::float32($bytes, $offset + 32),
        BspBinary::float32($bytes, $offset + 36),
    ],
    color: [
        ord($bytes[$offset + 40]),
        ord($bytes[$offset + 41]),
        ord($bytes[$offset + 42]),
        ord($bytes[$offset + 43]),
    ],
);

Enter fullscreen mode Exit fullscreen mode

C’est là tout le principe : une chaîne d’octets BSP devient $xyz, $st, les coordonnées UV de la lightmap, une normale et quatre octets de couleur. Ensuite, les surfaces indexent ces sommets en triangles, puis WorldBatchBuilder les regroupe pour OpenGL. Sans ORM. Sans « VertexRepository ». Fidélité de la disposition binaire, encore une fois.

Au lancement de q3dm0, un résumé typique des erreurs standard (stderr) affiche des dizaines de milliers de triangles répartis sur une centaine de lots, plusieurs pages de lightmaps, les triangles de collision des patchs pour la face de découpage des surfaces courbes, et un nombre de clusters PVS se chiffrant en milliers. Ces chiffres ne constituent pas une performance de référence ; ils rappellent simplement que même une « petite » carte Quake représente un véritable jeu de données.

Le moteur de rendu ne diffuse pas le graphe de scène C original refEntity_t. PHP prépare les lots et l’état ; OpenGL (via FFI) soumet des rendus à fonction fixe. Il s’agit d’une limite pragmatique : PHP garantit l’exactitude de l’interprétation des ressources ; le GPU effectue toujours le traitement raster.

Les premières erreurs ont été instructives. Appliquer une inversion verticale inutile aux UV des lightmaps donnait un aspect légèrement incorrect à toutes les surfaces éclairées. Faire la moyenne des normales du plan du portail au lieu d’utiliser le plan du premier triangle a inversé l’orientation du portail. Mettre en cache le nom d’un shader comme « déjà chargé » avant la liaison des textures des étapes sœurs a ignoré les textures d’environnement et de fusion. Les surfaces de type « soupe », qui auraient dû utiliser les couleurs des sommets, ont été brièvement traitées comme des faces éclairées et sont devenues très lumineuses. Pris individuellement, aucun de ces bugs ne semble dramatique. C’est leur accumulation qui explique pourquoi un portage doit déboguer les valeurs intermédiaires, et pas seulement les pixels finaux.

Le système de visibilité (PVS) est chargé et utilisé pour limiter les lots pertinents pour un groupe de caméras. Sa cohérence est évaluée dans le journal d’état, ce qui signifie qu’il est suffisant pour jouer à q3dm0, même si tous les cas limites de l’organisation des groupes de caméras de Quake n’ont pas été vérifiés. Désactiver le PVS avec un paramètre reste utile si vous soupçonnez un problème de culling plutôt qu’un problème de matériaux.

Choix de la limite de rendu

Une question légitime se pose : pourquoi ne pas compiler le code C original avec Emscripten et le présenter comme un projet PHP ? Il s’agirait alors d’un simple empaquetage, et non d’un portage.

Ce projet conserve la logique de Quake en PHP :

  • Ordre de recherche PK3
  • Décodage BSP
  • Analyse des scripts de shaders
  • Traces de collision
  • Mouvements des joueurs
  • Règles de l’entité/du jeu pour la tranche prise en charge
  • Orchestration du rendu (quoi dessiner, avec quelles textures et mélanges)

La plateforme est légère : SdlGl ouvre une fenêtre, crée un contexte GL, interroge les entrées et échange les tampons. Gl est une grande bibliothèque d’assistance OpenGL à fonction fixe — monde, objets MD3, vue des armes, HUD, effets — toujours gérée par un état appartenant à PHP.

Il existe également une commande serve qui permet d’afficher un visualiseur d’orbites WebGL pour les dumps de maillage. Elle est utile pour les diagnostics. Ce n’est pas le jeu. Le chemin d’accès au jeu est natif :

PK3 archives
  ↓  FileSystem (override order)
BSP bytes
  ↓  BspLoader / WorldBatchBuilder / CollisionModel
world + collision + entities
  ↓  Application play loop
input → Pmove → MapGame → view
  ↓  Gl (FFI) + SdlGl::swap
native OpenGL window

Enter fullscreen mode Exit fullscreen mode

Ou, sous forme de chaîne d’appels :

php bin/quake3.php play → Application::cmdPlay
  → FileSystem + BspLoader + CollisionModel + WorldBatchBuilder
  → MapGame + Player + Pmove
  → loop: SdlGl input → pmove/game → Gl draw → swap

Enter fullscreen mode Exit fullscreen mode

PHP conserve l’état du jeu tant que la fenêtre est ouverte. Il ne se contente pas de traiter une seule requête HTTP avant de se fermer. Le chemin serve de WebGL sérialise un maillage statique pour la visualisation orbitale ; il n’exécute pas cette boucle.

Cette séparation correspond à un ancien modèle Darkwood : PHP comme cerveau durable, FFI comme porte métallique.

Textures, shaders et lightmaps

Les « shaders » de Quake III ne sont pas des programmes de shaders GPU modernes. Ce sont des scripts de matériaux situés dans scripts/*.shader : des étapes avec des textures, des modes de fusion, des tests alpha, des transformations tcMod, des animMaps, le mappage d’environnement et des clés de tri. Le GPU de 1999 exécutait un pipeline beaucoup plus simple ; le script indiquait comment l’alimenter.

Le port PHP analyse ces scripts (ShaderParser, Q3Shader, stages) et exécute un sous-ensemble partiel lors du rendu : modulation des lightmaps, nombreux modes de fusion, effets de défilement/rotation/turbulence, stages d’environnement sur certaines surfaces et MD3, skyboxes, portails/miroirs, flares. Le chargement des textures prend en charge les formats réellement utilisés par Quake pour ces données (chemins TGA/JPEG via ImageLoader).

Ce qui n’est pas garanti : la compatibilité totale des shaders. L’état d’avancement de l’exécution des shaders est partiel. Certains matériaux du monde sur q3dm0 apparaissent toujours noirs ou incorrects, même après la résolution des fichiers image. Il s’agit du principal obstacle visuel actuel : non pas des fichiers manquants, mais un comportement incomplet ou incorrect de l’interface. Des outils comme --debug-material= et --debug-surface-pick permettent à un utilisateur de pointer la caméra vers une surface défectueuse et d’identifier le shader concerné, au lieu de devoir deviner à partir d’une capture d’écran.

Les lightmaps sont chargées comme des pages et appliquées de manière classique dans Quake : une seconde étape de texturage module l’aspect diffus du monde. La réussite du chargement linéaire et de l’orientation UV était plus importante que n’importe quelle optimisation PHP.

Trouver le point d’apparition du joueur

Le bloc « entities » est du texte brut intégré au BSP ; Quake n’a pas besoin d’une base de données séparée pour savoir où se trouvent les joueurs et les objets. La fonction EntityParser::deathmatchSpawns dans src/Game/EntityParser.php parcourt les dictionnaires analysés et extrait les zones de deathmatch (avec info_player_start traité comme un alias, comme dans g_client.c).

// EntityParser::deathmatchSpawns — src/Game/EntityParser.php
if ($classname === 'info_player_start') {
    $classname = 'info_player_deathmatch';
}
if ($classname !== 'info_player_deathmatch') {
    continue;
}
$origin = $this->parseVec3($ent['origin'] ?? '0 0 0');
$angles = [0.0, 0.0, 0.0];
if (isset($ent['angles'])) {
    $angles = $this->parseVec3($ent['angles']);
} elseif (isset($ent['angle'])) {
    $angles[1] = (float) $ent['angle']; // F_ANGLEHACK → yaw only
}
$spawns[] = new SpawnPoint($classname, $origin, $angles, $i, (int) ($ent['spawnflags'] ?? 0));

Enter fullscreen mode Exit fullscreen mode

Cela ne vous donne qu’une liste. Le premier point d’apparition de Quake privilégie une zone avec spawnflags & 1. Sur q3dm0, il s’agit de la zone du hall d’entrée, et non de l’entité d’ordre zéro. Le portage reproduit SelectInitialSpawnPoint / SelectRandomFurthestSpawnPoint dans src/Game/SpawnPoint.php — il s’agit d’une règle fidèle pour la zone prise en charge, et non d’une simplification du type « choisir [0] ».

// SpawnPoint::selectInitial — src/Game/SpawnPoint.php
foreach ($spawns as $s) {
    if (($s->spawnflags & 1) !== 0) {
        return $s;
    }
}
return self::selectRandomFurthest($spawns, [0.0, 0.0, 0.0]);

Enter fullscreen mode Exit fullscreen mode

Les réapparitions sont triées par distance par rapport au point de mort et choisies aléatoirement parmi les deux plus éloignées. L’option --spawn=N reste prioritaire pour le débogage.

Le point d’apparition reçoit le classique +9 sur Z (playerOrigin()) avant que le joueur ne soit lâché au sol avec un effet de rendu visuel. Si vous oubliez l’ascenseur, vous apparaissez au sol. Si vous sautez la chute, vous apparaissez en l’air et les premières images sont étranges.

MapGame configure ensuite le reste des entités prises en charge pour cette carte : objets avec modèles MD3, téléporteur, portes (y compris les portes groupées qui doivent se déplacer ensemble), haut-parleurs d’ambiance, minuteurs, objets de carte rotatifs et surfaces de portail. Les dialogues d’introduction sont volontairement omis ; l’objectif était de créer une arène jouable, et non un personnage se contentant de visionner des cinématiques.

Créer une véritable boucle de jeu

Le script bin/quake3.php augmente les limites de mémoire et de temps, charge l’autoloader de Composer et transmet argv à Application. La commande play ne constitue pas un ensemble de plugins. Il s’agit d’une phase d’initialisation explicite suivie d’une boucle while ($running).

La fonction d’installation charge le BSP, crée les collisions et les lots, construit le monde du jeu, crée le joueur, initialise SDL/GL, charge les ressources GPU et affiche un bref inventaire des éléments trouvés. La boucle suivante s’exécute :

  1. Mesure le delta d’un cadre fixé
  2. Collecte les événements SDL dans un instantané d’entrée structuré
  3. Crée un Usercmd (avant/côté/haut, boutons, angles, souhaits d’armes)
  4. Exécute Pmove sur le modèle de collision (avec les solides de porte injectés comme boîtes d’entités).
  5. Mises à jour de MapGame (objets à ramasser, éléments mobiles, projectiles, minuteurs)
  6. Gère le tir et l’état de l’arme.
  7. Calcule les décalages de vue à la première personne (bob, land, damage kick, duck lerp)
  8. Affiche le monde, les entités, les effets, l’arme visible et l’interface.
  9. Échange les tampons

C’est courant pour un jeu. C’est inhabituel pour PHP uniquement parce que la culture PHP écrit rarement ce type de programme. La boucle réside dans Application::cmdPlay (src/Console/Application.php) en tant que processus de longue durée — et non comme un cycle de requêtes.

// Application::cmdPlay — frame head (src/Console/Application.php)
while ($running) {
    $now = $sdl->ticks();
    $dt = max(0.001, min(0.05, ($now - $last) / 1000.0));
    $last = $now;
    $frame++;
    $gameTime += $dt;

    $ev = $sdl->pollEvents();
    if ($ev['quit'] || $ev['escape']) {
        $running = false;
    }
    // ... zoom FOV, mouse look / +strafe, Usercmd, Pmove, MapGame, draw, swap ...
}

Enter fullscreen mode Exit fullscreen mode

Rien dans cet extrait ne relève d’un middleware fictif. Le temps delta est limité afin qu’un accroc ne téléporte pas le joueur à travers un mur. Les entrées sont enregistrées dans un instantané. Le reste de la trame — la création d’un objet Usercmd, l’exécution de Pmove, la mise à jour des entités, le rendu — correspond à la complexité induite par la boucle, et non à la boucle elle-même.

Regard à la souris et saisie au clavier

Les entrées sont gérées par SdlGl::pollEvents. Les touches sont associées à de courts noms (w, shift, pgdn, …). Les déplacements de la souris s’accumulent à chaque image. Les boutons permettent d’attaquer, de se déplacer latéralement et de zoomer.

Look utilise le comportement par défaut de Quake : m_yaw * sensitivity avec des valeurs par défaut correspondant à 0,022 × 5 degrés par déplacement de la souris. Les touches fléchées permettent de tourner à cl_yawspeed 140 (et les touches de tangage à cl_pitchspeed 140), la touche Maj appliquant le multiplicateur classique cl_anglespeedkey 1,5 pour la « vitesse ».

Maintenez le bouton droit de la souris ou Alt enfoncé pour activer le déplacement latéral : la souris vous fait glisser au lieu de vous faire tourner, et les flèches servent à effectuer un pas de côté. Maintenez le bouton central de la souris enfoncé pour activer le zoom : le champ de vision horizontal est ajusté par interpolation linéaire vers cg_zoomfov 22,5 sur 150 ms et la sensibilité de la caméra est multipliée par fov_y / 75 en mode zoom — les mêmes relations que celles utilisées par le jeu Quake.

Les touches d’armes suivent la numérotation « arme N » de Quake : 2 mitrailleuse, 3 fusil à pompe, 8 plasma. La molette et les crochets permettent de sélectionner les armes en votre possession disposant encore de munitions. Tirer s’effectue avec le clic gauche, Ctrl ou F. S’accroupir s’appuie sur C. Marcher s’effectue avec Maj. Aucune de ces actions ne nécessite le verrouillage du curseur du navigateur, car la fenêtre est native et utilise le mode souris relatif.

Mouvement de portage

Pmove est un sous-ensemble de bg_pmove.c / bg_slidemove.c de Quake. Il ne s’agit pas d’un CharacterController Unity avec des éléments cosmétiques de Quake. Les constantes sont celles de Quake : vitesse de saut 270, friction 6, accélération au sol 10, accélération dans les airs 1, vitesse d’arrêt 100, échelle de déplacement accroupi 0,25, taille du pas 18, vitesse de marche minimale 0,7.

Chaque trame de commande :

  • Applique une friction lorsqu’il est mis à la terre
  • Accélère la vitesse souhaitée lors des mouvements avant/latéraux en fonction de la vitesse de course ou de marche.
  • Intègre la gravité
  • Effectue des déplacements glissés avec découpage de plan
  • Monter les escaliers
  • Gère les événements de saut, d’accroupissement, d’atterrissage brutal et de balancement pour la vue et les bruits de pas.

Le contrôle aérien est similaire à l’accélération aérienne de Quake, ce qui explique pourquoi les sauts propulsés et les impacts de plasma donnent cette impression, d’autant plus que le recul des éclaboussures est également fidèle. Cette sensation ne provient pas de $pos += $vel * $dt, mais de la logique de vitesse souhaitée, inspirée de PM_Accelerate, implémentée dans Pmove::accelerate (src/Game/Pmove.php) : une adaptation fidèle de l’étape d’accélération de Quake pour les déplacements au sol et dans les airs (les déplacements dans l’eau et en spectateur restent différés).

// Pmove::accelerate — mirrors PM_Accelerate (src/Game/Pmove.php)
private function accelerate(array $wishdir, float $wishspeed, float $accel): void
{
    $currentspeed = $this->dot($this->ps->velocity, $wishdir);
    $addspeed = $wishspeed - $currentspeed;
    if ($addspeed <= 0) {
        return;
    }
    $accelspeed = $accel * $this->frametime * $wishspeed;
    if ($accelspeed > $addspeed) {
        $accelspeed = $addspeed;
    }
    $this->ps->velocity[0] += $accelspeed * $wishdir[0];
    $this->ps->velocity[1] += $accelspeed * $wishdir[1];
    $this->ps->velocity[2] += $accelspeed * $wishdir[2];
}

Enter fullscreen mode Exit fullscreen mode

La direction et la vitesse du mouvement sont définies par la commande utilisateur et l’échelle de marche/course ; la vitesse actuelle correspond à la projection de la vitesse sur le mouvement ; l’accélération est plafonnée pour éviter de dépasser le mouvement en une seule image. Modifier la constante d’accélération aérienne de 1 à une valeur plus « agréable » donne au jeu des sensations différentes de celles de Quake, même si la friction est toujours présente.

Les corps morts conservent leur vitesse et utilisent la friction des mouvements statiques au lieu de se figer. Les téléporteurs ajustent le temps de recul pour que l’impulsion ne soit pas absorbée par la friction du sol.

Il existe une commande de régression, regress:pmove, qui permet de se laisser tomber au sol, de marcher, de sauter et de frôler un mur. Il s’agit d’une étape préliminaire, et non d’un système physique complet. Lorsque le mouvement change, cette étape préliminaire doit toujours être franchie. Un test récent sur q3dm0 a confirmé une hauteur de chute au sol stable, une distance de marche, une vitesse de saut (velz) de 270 à l’échelle de Quake (257,2 après la première étape d’intégration dans le banc d’essai), et un delta de déplacement mural fini — suffisant pour détecter les erreurs de type « inversion de gravité » avant même que quiconque n’ouvre la fenêtre.

La présentation de l’interface se superpose à pmove, et non à l’intérieur. ViewOffset ajoute des effets de balancement, d’atterrissage, de lissage des marches, d’interpolation linéaire de la hauteur de la position accroupie et de recul directionnel. L’arme affichée ajoute ses propres décalages de balancement et d’atterrissage pour éviter qu’elle ne flotte comme un élément du HUD. Ces couches sont faciles à ignorer lorsqu’on se contente de vérifier que les touches WASD fonctionnent, et pourtant, ce sont précisément elles qui donnent à un portage un aspect bâclé ou, au contraire, un aspect négligé.

Le déplacement de l’eau et des spectateurs est différé. Pour q3dm0, cela est acceptable.

Collision avec le BSP de Quake

Il est impossible de provoquer une collision avec le maillage de rendu et d’appeler cela Quake. Les surfaces de rendu sont optimisées pour le dessin. Les données de découpage sont stockées dans des brosses rattachées aux feuilles de l’arbre BSP.

CollisionModel charge les plans, les nœuds, les feuilles, les brosses et leurs côtés, puis effectue un rendu de boîtes à la manière de CM_BoxTrace. Les mouvements du joueur sont masqués par un masque de contenu solide. Les balles et le plasma utilisent un masque de tir qui ignore les collisions avec le joueur. Les surfaces des patchs ajoutent des triangles de collision supplémentaires, construits lors de la génération par lots, car la géométrie des collisions courbes n’est pas toujours aussi simple à modéliser.

Les portes sont modélisées par des brosses. Leur volume n’est pas toujours présent dans les listes de brosses du monde comme les brosses statiques, c’est pourquoi la boucle de jeu injecte des AABB mobiles dans le modèle de collision à chaque image. C’est une solution pragmatique : le clipping complet des sous-modèles pourra être implémenté ultérieurement ; empêcher le joueur de franchir une porte fermée, en revanche, est impossible.

Les traces renvoient la position finale, la normale au plan, la fraction et les indicateurs de surface. Ces indicateurs déterminent la présence d’empreintes de pas sur du métal, l’absence d’impact de balles dans le ciel et les exemptions de dommages dus aux chutes, le cas échéant.

L’étape de clipping du brush est celle où le port cesse de ressembler à une démo de maillage. CollisionModel::traceThroughBrush (src/Collision/CollisionModel.php) parcourt les côtés du brush, calcule les distances entre les plans de départ et d’arrivée (avec le décalage AABB du joueur intégré à la distance du plan) et suit les fractions d’entrée et de sortie — le principe de CM_TraceThroughBrush.

// CollisionModel::traceThroughBrush — src/Collision/CollisionModel.php
for ($i = 0; $i < $brush['numsides']; $i++) {
    $side = $this->brushsides[$brush['firstSide'] + $i];
    $plane = $this->planes[$side['plane']];
    $n = $plane['normal'];
    $off = $tw['offsets'][$plane['signbits']];
    $dist = $plane['dist'] - ($off[0] * $n[0] + $off[1] * $n[1] + $off[2] * $n[2]);

    $d1 = $tw['start'][0] * $n[0] + $tw['start'][1] * $n[1] + $tw['start'][2] * $n[2] - $dist;
    $d2 = $tw['end'][0] * $n[0] + $tw['end'][1] * $n[1] + $tw['end'][2] * $n[2] - $dist;
    // ... miss / startout / getout bookkeeping ...
    if ($d1 > $d2) {
        $f = ($d1 - self::SURFACE_CLIP_EPSILON) / ($d1 - $d2);
        if ($f > $enterFrac) {
            $enterFrac = $f;
            $clipplane = $plane;
            $leadSurfaceFlags = $side['surfaceFlags'];
        }
    } else {
        $f = ($d1 + self::SURFACE_CLIP_EPSILON) / ($d1 - $d2);
        if ($f < $leaveFrac) {
            $leaveFrac = $f;
        }
    }
}
if ($enterFrac < $leaveFrac && $enterFrac < $tw['fraction']) {
    $tw['fraction'] = $enterFrac < 0 ? 0.0 : $enterFrac;
    // ... store planeNormal / surfaceFlags ...
}

Enter fullscreen mode Exit fullscreen mode

C’est pourquoi considérer les collisions avec les triangles visibles comme une abstraction erronée est une mauvaise approche : le BSP stocke des plans et des brosses destinés à ces requêtes spatiales. L’extrait ci-dessus représente une pile de collisions partielle (les entités déplacées utilisent toujours des AABB injectées ; certains cas de patch utilisent des triangles supplémentaires), mais le calcul de la fraction de brosse constitue le véritable algorithme de Quake, et non une solution de contournement temporaire pour simuler une collision sphère-maillage.

Le moment où il est devenu jouable

Le projet n’est pas devenu un jeu jouable du jour au lendemain. L’histoire utile se présente comme une succession de preuves :

  1. Lecture PK3 — lister et extraire les chemins ; prouver l’ordre de remplacement des paquets
  2. BSP reconnu — IBSP 46, dix-sept contrôles de type « lumps, bounds »
  3. Premiers triangles — décodage des faces en maillages CPU ; chargement ultérieur par lots
  4. Premier monde texturé — les images se lient ; les lightmaps se modulent ; le ciel apparaît
  5. Première apparition — analyse de l’entité, +9 Z, chute au sol, caméra au niveau des yeux
  6. Regard à la souris et WASD — initialement caméra libre, puis commandes utilisateur réelles
  7. Gravité et collisions — Déplacement contre les brosses ; fini les collisions avec le sol
  8. Objets, portes, téléporteur — la carte devient un lieu, et non un musée
  9. Armes et interface — mitrailleuse, fusil à pompe, plasma ; barre d’état ; douleur et mort
  10. Tranche cohérente — suffisamment de règles de Quake pour que jouer à q3dm0 semble intentionnel

Le langage utilisé pour l’état d’avancement est important. Le portage suit une étape clé comme Q3DM0_PLAYABLE_COHERENT et refuse de se promouvoir automatiquement comme « référence presque terminée » sans validation visuelle humaine. C’est une question de rigueur, pas de marketing. Une carte peut être jouable même si les éléments en étain et les portails ont encore un aspect incorrect.

Comment l’exécuter

Configuration requise : PHP 8.5+ avec ext-zip et ext-ffi, SDL2 installé et une copie légale de Quake III baseq3 (ou une racine d’installation) sur le disque.

composer install

php -d ffi.enable=true bin/quake3.php play \
  --basepath=/path/to/quake3-or-baseq3 \
  --map=maps/q3dm0.bsp

Enter fullscreen mode Exit fullscreen mode

--basepath peut être :

  • le répertoire d’installation du jeu qui contient baseq3/, ou
  • le répertoire baseq3 lui-même, ou
  • un répertoire contenant déjà des fichiers *.pk3

Cela ouvre une fenêtre SDL native. Cela n’ouvre pas un onglet de navigateur.

Commandes

Entrée Action WASD Déplacer Flèches Tourner (déplacement latéral en maintenant la touche +déplacement latéral) ; marcher en montée/descente Souris Regarder Clic droit / Alt + déplacement latéral Souris centrale +zoom Espace Saut C S’accroupir Changement Marche Clic / Ctrl / F Tir 2 / 3 / 8 Mitrailleuse / fusil à pompe / plasma Roue, [ ] Arme précédente / suivante Page suivante / Supprimer Rechercher vers le haut / vers le bas Fin Terrain central R Réapparition après le délai de mort Échap Quitter

Les options utiles incluent --freecam, --debug-lighting, --debug-surface-pick et --spawn=N.

Régression Pmove :

php bin/quake3.php regress:pmove \
  --basepath=/path/to/quake3-or-baseq3 \
  maps/q3dm0.bsp

Enter fullscreen mode Exit fullscreen mode

Visualiseur de maillage pour navigateur de diagnostic (pas le jeu) :

php bin/quake3.php serve \
  --basepath=/path/to/quake3-or-baseq3 \
  --map=maps/q3dm0.bsp

Enter fullscreen mode Exit fullscreen mode

À quoi ressemble la mise en œuvre aujourd’hui

Sur maps/q3dm0.bsp, le port prend en charge une session solo de type match à mort :

  • Déplacements à la première personne avec les chiffres de Quake : se baisser, marcher, sauter, faire un pas, glisser
  • Mitrailleuse, fusil à pompe et plasma avec gestion du temps de montée/descente, règles de munitions et effets d’impact
  • Ramassage d’objets (santé, armure, munitions, armes) avec des plateformes de récupération et des temps de réapparition à la Quake
  • Des portes (y compris pour les équipes appariées), un téléporteur, des haut-parleurs d’ambiance, des minuteurs
  • Barre d’état, réticule, sélection d’armes, noms des armes ramassées, affichage des dégâts
  • Visualisez les modèles d’armes avec l’effet de flamme à la bouche et la rotation du canon de la mitrailleuse.
  • Portails/miroirs, ciel, lueurs, ombre du joueur
  • Audio minimaliste via les lecteurs de la plateforme pour une sélection d’événements

D’un point de vue architectural, les types PHP importants sont ennuyeux, et c’est tant mieux :

Zone Types FS Système de fichiers, Archive Pk3, Chemin BSP BspLoader, BspMap, fonctions d’assistance groupées, VisData Préparation du rendu WorldBatchBuilder, ShaderParser, MaterialResolver, ImageLoader Plateforme SdlGl, Gl, ThinSound Déplacement/collision Pmove, Player, Usercmd, CollisionModel Jeu MapGame, EntityParser, SpawnPoint, MoverSystem, WeaponState Shell Application

L’application console est volumineuse car elle gère actuellement la boucle de jeu. C’est un fait. Un nettoyage ultérieur pourrait extraire une classe GameSession, sans modifier le flux de données.

Qu’est-ce qui manque encore ?

Soyez explicite.

Les matériaux du monde restent le principal point faible visuel. Certaines surfaces sont noires ou incorrectes malgré la résolution des textures. L’exécution complète des shaders Quake n’est pas encore terminée.

Le contenu correspond à une seule tranche q3dm0. D’autres cartes peuvent être lancées, mais elles ne constituent pas la cible de fidélité.

Aucune arme autre que la mitrailleuse, le fusil à pompe ou le plasma n’est présente. Le gantelet est numéroté mais inutilisé. Aucun chemin vers le canon électromagnétique, la foudre, le BFG ou la grenade à roquettes n’est prévu dans cette version.

Systèmes non inclus dans le périmètre : bots, réseau, bytecode QVM, interface utilisateur des menus/variables de configuration, sauvegardes, spatialisation sonore complète, infrastructure d’éclairage dynamique complète, parité complète des objets de carte, volumes d’eau.

La **collision pour les mouvements complexes est assistée par AABB plutôt que par un traçage complet du modèle de pinceau.

Le son est partiel : seuls les événements importants sont diffusés ; il ne s’agit pas d’un mixeur.

Le navigateur serve n’est pas un deuxième client de jeu.

Qualifier cela de « Quake III en PHP » sans ces précisions serait malhonnête. En revanche, le décrire comme « une portion jouable de la carte Quake III en PHP avec un véritable moteur de jeu » est exact.

Un autre point important : des projets de comparaison existent en JavaScript et dans d’autres langages pour afficher les cartes de Quake dans un navigateur. Ils constituent de précieuses références pour connaître le rendu visuel attendu. Ce portage PHP n’a pas rejoint ces projets. Il a délibérément conservé une fenêtre native et une boucle de jeu PHP. Lors de la comparaison de la fidélité du rendu avec un visualiseur WebGL, l’ordre de référence était toujours le suivant : d’abord les sources C de Quake, puis le visualiseur comme référence pour déterminer « à quoi devrait ressembler cette surface », et enfin le comportement PHP testé. Cet ordre empêche le portage de converger vers des approximations d’autres développeurs.

Performances et points faibles de PHP

Je ne vais pas inventer de nouvelles valeurs de temps d’affichage. La correction primait sur tout. Subjectivement, sur une machine moderne, la tranche q3dm0 est interactive ; elle ne correspond pas aux performances de 1999 sur du matériel de 1999, et elle ne prétend pas l’être.

Centres de coûts probables, qualifiés de jugement d’ingénierie plutôt que de mesures :

  • Les tableaux et objets PHP pour les sommets, les lots et les traces allouent plus de mémoire que les structures C compactes.
  • Le travail image par image dans la boucle de lecture (mises à jour d’entités, HUD, effets) est pratique à écrire et facile à rendre bavard.
  • La configuration de l’étape de shader sur le chemin à fonction fixe comporte plus de validations et de branchements qu’un matériau précalculé.
  • Les chargements d’images et de MD3 sont effectués en début de session ; c’est bien pour le jeu, mais cela entraîne une latence de démarrage importante. Les traces de collision sont algorithmiques ; les facteurs constants PHP sont importants lorsque de nombreuses traces sont exécutées par saut.

Rien de tout cela ne justifiait l’arrêt du développement. Au contraire, cela incitait à conserver une architecture simple : décoder une seule fois, traiter par lots statiquement lorsque c’était possible et éviter de réécrire les calculs de Quake sous une forme plus astucieuse, mais erronée.

Si ce projet a un jour besoin de performances exceptionnelles, les options honnêtes consistent à profiler, à réduire les allocations par image, à déplacer les traces critiques derrière une représentation plus compacte ou à accepter l’interface FFI pour certains noyaux — et non à prétendre que l’interpréteur PHP correspondra à vm_x86.c.

Débogage et observabilité

Les ports du moteur lâchent sans bruit. Les triangles sont presque corrects. Le point d’apparition est presque bon. Le tir traverse la zone du joueur et explose contre un mur invisible.

L’interface de ligne de commande (CLI) a donc développé des outils d’inspection :

  • data:inspect — quels paquets --basepath a-t-il réellement attachés ?
  • fs:which — quelle archive fournit un qpath ?
  • bsp:info, bsp:entities, bsp:spawns, bsp:surfaces, bsp:lightmaps
  • shader:missing, shader:audit, render:audit
  • trace:test — déposer une boîte de joueur sur le sol
  • regress:pmove — porte de mouvement
  • Jouez avec des options telles que --debug-surface-pick, --debug-lighting, --lightmap-only, --no-pvs

Le principe reste le même : examiner le monde intermédiaire. Les développeurs de Quake eux-mêmes avaient cette habitude. Un portage PHP en a d’autant plus besoin, car l’environnement d’exécution ne signalera pas systématiquement une erreur de segmentation à la moindre imprécision ; il peut arriver qu’il affiche simplement des caractères illisibles.

L’option --debug-surface-pick illustre parfaitement cette approche. Visez une surface, appuyez sur P, et la fonction dumpSurfacePick dans src/Console/Application.php effectue une analyse de collision depuis l’œil, puis parcourt les lots de rendu pour identifier le shader sous le réticule :

// Application::dumpSurfacePick — src/Console/Application.php
[$fwd] = QuakeMath::angleVectors($angles);
$end = [
    $eye[0] + $fwd[0] * 8192.0,
    $eye[1] + $fwd[1] * 8192.0,
    $eye[2] + $fwd[2] * 8192.0,
];
$tr = $cm->trace($eye, $end, [0, 0, 0], [0, 0, 0], CollisionModel::MASK_PLAYERSOLID);
fwrite(STDERR, sprintf(
    "screen hit world=(%.1f %.1f %.1f) fraction=%.4f ...\n",
    $tr->endPos[0], $tr->endPos[1], $tr->endPos[2], $tr->fraction,
));
// ... ray vs batches → shader name / lightmap / PVS cluster ...

Enter fullscreen mode Exit fullscreen mode

Si l’étain ne s’affiche toujours pas correctement, il vous faut le nom du shader, et non une autre capture d’écran. C’est aussi pourquoi le journal d’état indique toujours que les matériaux du monde sont soumis à une vérification visuelle humaine plutôt qu’à une tâche automatisée.

Ce que cette expérience m’a appris

La fidélité est une succession de raccords. Ordre des paquets, rôles des objets, indicateurs d’apparition, masques de contenu, numérotation des armes : chaque raccord est infime. Trois raccords manquants suffisent à donner une impression de « bizarre », perceptible immédiatement par les joueurs et difficilement compréhensible par les débogeurs.

Le maillage de rendu est trompeur. Une belle géométrie n’est pas une API de collision. Dès que les traces de pinceau des rayons ont été déplacées, la carte a cessé de ressembler à un décor de cinéma.

PHP peut héberger une boucle de jeu. Le langage n’est pas l’obstacle. L’obstacle réside dans la gestion des formats binaires et de l’état en temps réel sans le confort d’un framework.

La prise en charge partielle des shaders est encore loin d’être optimale. Même avec des textures « presque complètes », le rendu peut paraître défectueux, car les matériaux de Quake sont composés de plusieurs couches. Une couche d’environnement manquante ou un mélange incorrect ne provoque pas une dégradation progressive ; le métal se transforme instantanément en vide.

La documentation doit accompagner le code source, et non pas être enfouie indéfiniment dans l’arborescence d’exécution. Les journaux d’état et les audits de formatage sont précieux lors du portage, mais deviennent une source de distraction une fois que l’on souhaite simplement lancer le jeu. Les séparer permet de préserver l’intégrité du dépôt de code.

Ne faites pas l’autopromotion des étapes clés. PLAYABLE_COHERENT est une affirmation concernant une tranche. REFERENCE_CLOSE nécessite des regards sur l’étain et les portails. Les machines ne devraient pas valider le goût.

Conclusion

Le code source du moteur GPL de Quake III Arena est une véritable mine d’or : une spécification complète et explicite du fonctionnement d’un jeu de tir en arène de la fin des années 90. En porter une partie vers PHP, on a dû lever le voile sur toutes les hypothèses sous-jacentes : systèmes de fichiers, cartes, matériaux, mouvements et même l’interaction entre le langage et le GPU.

Le résultat n’est pas un produit qui remplace Quake III. C’est un laboratoire fonctionnel : php bin/quake3.php play contre un véritable baseq3, en parcourant q3dm0 avec la vitesse de saut de Quake toujours fixée à 270, le plasma demandant toujours l’autorisation de masquer le contenu du tir, et l’interface affichant toujours une faible santé clignotante sur une horloge de 256 ms car c’est ce que signifie (cg.time >> 8) & 1.

Si vous disposez de données de jeu légales, d’une chaîne d’outils PHP 8.5 et de SDL2, vous pouvez l’exécuter. Si les moteurs de jeu vous intéressent, vous pouvez aborder le code PHP comme on a abordé le C autrefois : non pas comme un tutoriel sur un framework, mais comme une cartographie des choix à faire.

C’était là le but. Non pas rendre PHP à la mode pour les jeux, mais prendre le moteur suffisamment au sérieux pour en retravailler les moindres recoins jusqu’à ce qu’une carte redevienne un lieu.

Code source : https://github.com/matyo91/quake-III-arena-php

원문에서 계속 ↗