Loading
Temps fixe, graines déterministes, monde toroïdal et hachage spatial : ce qui permet à deux mille particules de rester une expérience.
Lancer la simulation
0303 / 09À l'écran, Particle Life est un nuage de points colorés.
Dans le moteur, c'est une suite de promesses.
La même durée simulée doit produire le même résultat physique, quel que soit le taux de rafraîchissement. La même graine et la même matrice doivent reproduire la même ouverture. Une particule près du bord droit doit interagir correctement avec une autre près du bord gauche. Deux mille particules ne devraient pas exiger quatre millions de calculs de distance coûteux à chaque étape.
Aucune de ces promesses ne crée l'émergence. Elles créent la scène stable sur laquelle un résultat émergent peut avoir un sens.
Sans elles, un beau motif pourrait être un défaut de fréquence d'image, un accident aléatoire, un artefact de bord ou une différence propre à la machine. Le moteur élimine ces explications concurrentes.
L’application limite d’abord l’écart d’une image à 100 ms, puis le système garde au plus quatre pas de 1/60 s. Le temps abandonné n’entre jamais dans l’horloge simulée.
La trame d'animation du navigateur pilote une SimulationLoop. À chaque tick, la boucle accomplit quatre tâches séparées :
temps réel écoulé
↓
étapes physiques fixes → analyse → rendu → statistiques de l'interfaceLe ParticleSystem possède positions, vitesses, types, matrice et pas physique. Analysis lit cet état et calcule amas, vitesse et stabilité. Le renderer consomme une vue étroite des buffers. React contrôle la présentation mais ne pénètre jamais dans la boucle de force.
Ces frontières permettent de tester la physique sans React et de perdre le rendu sans effacer le monde.
Cette séparation permet d'exécuter la même physique sans interface dans les tests et la recherche Discover. Le renderer peut passer de WebGPU à WebGL2 sans modifier une trajectoire. L'interface agit par une petite API au lieu de muter les tableaux.
Règle d'architecture : L'état de la simulation n'a qu'un propriétaire. Tout le reste l'observe ou lui demande une opération explicite.
Un navigateur ne garantit pas soixante images par seconde. Un écran 60 Hz, un écran 144 Hz, un CPU occupé ou un onglet en arrière-plan fournissent des intervalles différents.
Si le moteur injectait directement chaque durée réelle dans les équations, ces machines ne réaliseraient pas la même expérience. Les grandes images feraient de grands sauts d'intégration. Les petites seraient plus douces. Même les motifs qualitatifs pourraient diverger.
Particle Life accumule le temps réel puis le consomme par pas fixes :
h = 1 / 60 secondeÀ 120 images d'affichage par seconde, certaines images ne contiennent aucune étape physique. Après une image lente, l'accumulateur peut exécuter plusieurs étapes avant le rendu. Les équations reçoivent toujours le même h.
Le contrôle de vitesse respecte le même contrat. À 2×, il ne double pas h ; il fournit deux fois plus de temps simulé. Soixante images à 2× suivent donc exactement la trajectoire de cent vingt images à 1×. Un test le vérifie valeur flottante par valeur flottante.
Une limite est volontaire : quatre étapes fixes au maximum par tick. Lorsqu'un onglet revient après dix secondes, le surplus est abandonné plutôt que de provoquer une spirale de rattrapage. L'horloge affiche uniquement les étapes réellement calculées : le temps simulé vrai, pas une estimation murale.
Chaque réinitialisation disperse les positions et attribue les types. Avec Math.random(), une matrice partagée décrirait les règles, mais pas le monde initial.
Le moteur utilise le générateur déterministe Mulberry32. Une graine de 32 bits produit toujours la même séquence, consommée dans un ordre fixe :
particule 0 : x, y, type
particule 1 : x, y, type
particule 2 : x, y, type
...Cet ordre fait partie du format. Le changer modifierait silencieusement tous les liens existants, même avec la même graine.
Le déterminisme unifie plusieurs fonctions : les tests reproduisent une trajectoire ; Discover compare les candidats dans les mêmes conditions ; Reset reconstruit une ouverture ; une URL transmet graine, matrice, préréglage et nombre ; une découverte sauvegardée rejoue le monde au lieu d'en conserver une image.
Le partage actuel reproduit exactement l'ouverture, pas les positions et vitesses d'un instant ultérieur. Un lien signifie « recommencer la même expérience », pas « revenir à l'image que je regardais ».
Un bord rigide ajouterait une règle étrangère à la matrice. Les particules s'accumuleraient sur les parois ou rebondiraient, rendant les structures proches du bord difficiles à interpréter.
Particle Life utilise un monde toroïdal : sortir à droite fait rentrer à gauche, sortir en bas fait rentrer en haut. Le rectangle se comporte comme la surface d'un tore, même s'il reste affiché à plat.
Le monde se replie sur lui-même. La physique utilise la distance d'image minimale : deux particules sur des bords opposés peuvent rester voisines.
Replier les positions ne suffit pas. La force doit aussi utiliser le trajet replié le plus court, selon la convention de l'image minimale :
let dx = neighborX - particleX;
if (dx > width / 2) dx -= width;
else if (dx < -width / 2) dx += width;Une particule en x = 2 et une autre en x = width - 3 sont séparées de cinq pixels à travers la jointure, pas de presque toute la largeur. La physique locale devient cohérente partout, sans centre privilégié ni bord matériel.
Un moteur naïf compare chaque particule à toutes les autres. Avec N = 2 000 :
N × (N - 1) = 2 000 × 1 999 = 3 998 000 vérifications par étapeÀ soixante étapes par seconde simulée, l'immense majorité est inutile, car la force est nulle au-delà de R = 100 px.
Le hachage spatial divise le monde en grille uniforme. Chaque cellule est au moins aussi large que le rayon. Les particules sont insérées dans des listes plates par cellule. Pour trouver les voisines possibles, une particule inspecte seulement sa cellule et les huit cellules adjacentes, puis applique le test de rayon exact.
Avec 2 000 particules, toutes les paires commencent près de quatre millions de vérifications dirigées par étape.
Une cellule est au moins aussi large que le rayon d'interaction. La cellule actuelle et ses huit voisines contiennent donc tous les candidats possibles.
La charge typique passe de « comparer avec le monde » à « comparer avec la population locale ». Dans la distribution prévue, le comportement est proche du linéaire avec le nombre de particules. Ce n'est pas une garantie universelle : si toutes les particules occupent une cellule, l'ensemble candidat redevient quadratique.
Les détails comptent. Une grille basée sur ceil créait autrefois une dernière cellule trop étroite au bord, laissant échapper de vraies voisines hors de la recherche 3×3. Les indices doivent aussi tolérer l'arrondi Float32 sur la frontière, dédupliquer les cellules repliées dans les très petits mondes et conserver un ordre de requête déterministe. Chaque cas possède un test de régression.
Au lieu d'un tableau d'objets particules, le système utilise une structure de tableaux :
x = Float32Array
y = Float32Array
vx = Float32Array
vy = Float32Array
type = Uint8ArrayLa boucle lit un stockage numérique contigu sans créer d'objets temporaires. Le renderer envoie les mêmes positions et types vers les buffers GPU. Les tableaux ne grandissent que pendant Reset ou Spawn, jamais dans le chemin chaud.
Le hachage suit le même principe avec des Int32Array pour les têtes de cellule et les pointeurs suivants. Les résultats de voisinage réutilisent des buffers temporaires. « Zéro allocation » décrit ici le régime stable, pas toute l'application : une action utilisateur peut allouer. L'objectif est d'empêcher le ramasse-miettes de devenir une force invisible sur la cadence.
Après le calcul des forces, chaque particule suit le même ordre : limiter la vitesse, appliquer le frottement, intégrer la position avec le pas fixe, puis replier la position.
Changer cet ordre change le modèle. Appliquer le frottement avant la limite produit un autre résultat sous une matrice violente. Dans une simulation, un « refactoring » arithmétique est souvent une modification physique déguisée.
Les tests couvrent donc des invariants : vitesses finies sous des interactions ±0.9, respect de la limite, trajectoires identiques malgré des séquences d'images irrégulières, et positions valides après redimensionnement.
Lors de la navigation, la boucle annule son animation, déconnecte l'observateur de taille et détruit les ressources GPU ou WebGL. Une initialisation tardive après démontage est détruite dès qu'elle se termine.
La perte d'un périphérique GPU impose une contrainte étrange : un canvas configuré WebGPU ne peut pas simplement devenir WebGL2. La récupération monte un canvas neuf et y rattache la SimulationLoop existante. Particules, vitesses, temps et matrice survivent ; seul le renderer change.
C'est pourquoi un échec de rendu n'efface pas l'expérience. Le moteur possède le monde. Le canvas n'en est qu'une vue.
Ouvrez la simulation, appuyez sur Partager, puis ouvrez le lien dans un second onglet. Les deux ouvertures doivent correspondre : mêmes règles, même nombre, même graine. Leurs cadences pourront ensuite diverger, mais elles commencent comme la même expérience.
La prochaine partie suivra l'état dans l'autre direction : des tableaux typés vers les buffers WebGPU, le bloom, les traînées, les lignes de connexion et le repli WebGL2.
Plus à lire
La matrice n'est ni une palette ni un panneau de commande. C'est un langage directionnel pour l'attraction, la répulsion, la poursuite et la structure.
Le rendu n'est pas décoratif. Il rend lisibles distance, mouvement, relations et défaillances sans modifier la physique.
La première version ne produisait que deux choses : des amas singuliers et de la poussière en expansion. Une zone de répulsion a tout changé.