Poisson Engine — Comment je rends 100 000 poissons en 5 niveaux de détail simultanés
Afficher 100 000 entités à 60 FPS dans un navigateur n'est pas qu'une question de WebGPU. C'est aussi de savoir que 99 d'entre elles n'ont pas besoin d'être rendues comme la centième. Le système LOD qui fait que Poisson tourne sur un MacBook M1 sans souffler.
Cet article décortique le projetPoissonLe piège du "100 000 entités à 60 FPS"
Quand j'ai annoncé Poisson Engine — un moteur WebGPU qui simule 100 000 poissons à 60 FPS dans un navigateur — j'ai reçu en moyenne une question toutes les deux semaines :
"C'est pas un peu inutile ? Tu vois pas 100 000 poissons à l'œil nu sur un écran 1920×1080. Tu en vois… 2 000, max."
La personne avait raison.
Et c'est précisément ce que mon système exploite.
La règle d'or des moteurs 3D : LOD
Tous les jeux AAA depuis 20 ans utilisent LOD — Level Of Detail. L'idée est simple : un objet à 10 mètres de la caméra mérite 10 000 polygones. Le même objet à 100 mètres en mérite 1 000. À 1 000 mètres, 100. À 10 000 mètres, un pixel.
Rendre tous les objets au max de détails est un gâchis. Rendre chaque objet au juste niveau de détail nécessaire est l'optimisation #1 en rendu 3D.
Poisson applique exactement ça, mais à plat (2D), et sur GPU pour 100 000 entités simultanément. Voici les 5 niveaux :
const LOD_LEVELS = [
{ dist: 0, maxDist: 50, render: "full-sprite-animated-textured" }, // L0
{ dist: 50, maxDist: 150, render: "sprite-static-textured" }, // L1
{ dist: 150, maxDist: 400, render: "colored-pixel-with-direction" }, // L2
{ dist: 400, maxDist: 1000, render: "single-pixel" }, // L3
{ dist:1000, maxDist:Infinity, render: "density-heatmap" }, // L4
];
| Niveau | Distance | Représentation | Coût GPU par entité |
|---|---|---|---|
| L0 | 0–50 px | Sprite animé texturé (mouvement de nageoires) | ~0.18 µs |
| L1 | 50–150 | Sprite statique texturé (orienté direction) | ~0.08 µs |
| L2 | 150–400 | Pixel coloré + indicateur direction (2 px) | ~0.012 µs |
| L3 | 400–1000 | Pixel unique | ~0.004 µs |
| L4 | >1000 | Pas rendu — agrégé dans une heatmap de densité | ~0 |
La décision LOD est sur le GPU
Le piège habituel : faire le LOD côté CPU. "Pour chaque entité, calculer la distance, choisir le niveau, envoyer au GPU."
Pour 100 000 entités, c'est 100 000 calculs de distance par frame. Sur CPU, ça consomme 4-6 ms à lui seul. Avant même qu'on dessine quoi que ce soit.
Sur Poisson, le LOD est calculé dans un compute shader WebGPU, en parallèle, en moins d'1 µs total. Le shader regarde chaque entité, calcule sa distance à la caméra, et écrit son niveau LOD dans un buffer.
@compute @workgroup_size(64)
fn assign_lod(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
if (i >= arrayLength(&boids)) { return; }
let pos = boids[i].position;
let dist_sq = dot(pos - camera_pos, pos - camera_pos);
// Branch-less LOD assignment (les branches GPU c'est mal)
let l0 = f32(dist_sq < 2500.0); // <50²
let l1 = f32(dist_sq < 22500.0); // <150²
let l2 = f32(dist_sq < 160000.0); // <400²
let l3 = f32(dist_sq < 1000000.0); // <1000²
// Niveau = nombre de seuils dépassés
lods[i] = u32(4.0 - (l0 + l1 + l2 + l3));
}
Pas de if. Pas de branche. Quatre comparaisons booléennes converties en floats,
sommées, soustraites de 4. La même série d'instructions s'exécute pour les
65 536 threads en parallèle. C'est ce que le GPU adore.
Le bin par LOD : compactage GPU
Une fois le LOD assigné, on a 100 000 entités classées en 5 catégories. Si je les rends dans l'ordre du tableau original, je vais constamment changer de pipeline (rendre un sprite, puis un pixel, puis un sprite, puis un pixel…). Chaque switch de pipeline GPU coûte cher.
Solution : trier les entités par LOD avant le rendu. Tous les L0 ensemble, puis tous les L1, etc.
Sur CPU avec 100 000 entités, ça prendrait 2-3 ms de tri. Sur GPU avec un algorithme parallel prefix sum, ça prend 0,6 ms.
Le pipeline final de rendu :
1. compute LOD assignment (1 pass, 0.1 ms)
2. parallel prefix sum sort (1 pass, 0.6 ms)
3. render L0 (sprites) (1 pass, ~80% du temps)
4. render L1 (sprites static) (1 pass, ~10%)
5. render L2 (colored pixels) (1 pass, ~5%)
6. render L3 (raw pixels) (1 pass, ~3%)
7. heatmap L4 (1 pass, ~2%)
7 passes. Une seule submit au GPU. 8.2 ms par frame au total.
La beauté de l'illusion
Voici ce qui rend ce système si efficace : l'œil humain ne voit pas la différence.
Quand on regarde un poisson à 5 mètres dans une simulation, on voit sa forme, ses nageoires animées, sa couleur. C'est le L0.
Quand on regarde un autre poisson à 100 mètres dans la même image, on voit un truc orange qui bouge. Pas ses nageoires. C'est le L2. Mais on ne s'en rend pas compte — parce qu'à cette distance, l'œil n'a pas la résolution de toute façon.
À 500 mètres : on voit un pixel coloré. C'est le L3.
À 2 000 mètres : on voit une zone plus dense de pixels. C'est le L4 — une heatmap qui agrège tous les poissons trop loin pour mériter un rendu individuel.
Le résultat visuel est identique à un rendu où chaque poisson serait rendu en L0. L'œil ne fait pas la différence.
Et le GPU économise 60-80 % de son temps.
Le bonus inattendu : ça scale au-delà
Le système marche pour 100 000 entités. Mais le vrai intérêt, c'est qu'il scale.
Avec un rendu uniforme L0, doubler les entités double le temps GPU. À 200 000, on serait à 16 ms — déjà sous les 60 FPS.
Avec le LOD à 5 niveaux, doubler les entités n'ajoute presque rien à L0 (elles sont déjà toutes proches, le L0 est saturé). Les nouvelles entités tombent surtout en L3-L4, où le coût marginal est ridicule.
J'ai testé : à 500 000 entités, Poisson tourne à 40 FPS. À 1 million, 27 FPS. Sans LOD ce serait littéralement diapo.
La leçon
L'optimisation, ce n'est pas écrire du code plus rapide. C'est arrêter de faire du travail qui ne sert à rien.
Rendre 100 000 entités à plein détail quand 99 % d'entre elles font moins d'un pixel à l'écran : travail inutile. La supprimer rapporte plus que tout le reste de l'optimisation combiné.
Le LOD est l'exemple parfait. L'optimisation #1 d'un moteur de rendu est de ne pas rendre. C'est pareil pour les bases de données, les réseaux, les calculs ML. Le code le plus rapide est celui qui n'est jamais exécuté.
Cette intuition — "qu'est-ce que je peux ne pas faire ?" — me sert dans chaque optimisation que je fais aujourd'hui, pas seulement sur le GPU.
Stack & code
- WebGPU + WGSL — shaders compute parallèles
- 5 niveaux LOD dans
js/engine/renderer/layer-registry.js - Compute shader
assign_lod— branch-less, ~0.1 ms pour 100K entités - Parallel prefix sum pour le tri par LOD (≈ même algo que sort GPU classique)
- 7 pipelines de rendu dispatchés en un seul
commandEncoder.submit() - Heatmap L4 rendue en compute → texture, projeté en quad fullscreen
- Test de stress : 1 million d'entités à 27 FPS sur MacBook M1
- Publié sur npm comme
@poisson/engine
L'idée du LOD a 30 ans. L'implémenter au bon endroit (compute shader + tri GPU
- heatmap pour les très loins) est ce qui transforme une optimisation théorique en gain réel mesurable.
Florian Sola
Lead Technique · Haute performance temps réel · 9 ans d'expérience