Module 10 · Leçon 4 sur 5
Réduire le lag
Optimise scripts, physique, réseau et rendu de ton obby avec des exemples avant/après, et apprends à choisir les optimisations qui comptent vraiment.
- ⏱️ 35 min
- 📝 Quiz de 7 questions
- 🔄 Vérifié le 24 octobre 2026
Sur cette page
- Prioriser : corriger ce que la mesure désigne
- Les scripts
- Les événements « à chaque image »
- Avant / après n° 1 : le compteur de pièces
- Avant / après n° 2 : un chronomètre par joueur
- La mémoire : déconnecter et faire le ménage
- La physique
- Ancrer tout ce qui ne bouge pas
- CanCollide, CanTouch et CanQuery
- CollisionFidelity des MeshParts
- Le rendu
- Le réseau
- Avant / après n° 3 : les pièces qui tournent
- Quiz de fin de leçon
🎯 Dans cette leçon, tu vas apprendre à…
- Remplacer les boucles et calculs à chaque image par du code piloté par les événements
- Éviter les fuites de mémoire en déconnectant les événements et en vidant les tables
- Alléger la physique avec Anchored, CanCollide, CanTouch, CanQuery et CollisionFidelity
- Réduire le coût du rendu et du réseau, puis prioriser les corrections
Tu sais maintenant mesurer. Place à la correction ! Dans cette leçon, on passe en revue les grandes sources de lag (ralentissement) d’un jeu Roblox : scripts, mémoire, physique, rendu et réseau. Pour chaque famille, tu verras ce que recommande la documentation officielle, avec des exemples avant/après tirés de l’Obby des Nuages. Et surtout : comment choisir par quoi commencer.
Prioriser : corriger ce que la mesure désigne
Avant de toucher au code, reviens à tes mesures de la leçon 2 :
- FPS bas avec des barres orange dans le MicroProfiler : regarde d’abord les scripts, la physique et les animations.
- FPS bas avec des barres bleues ou rouges : regarde d’abord le rendu (nombre d’objets, lumières, ombres, transparence, particules).
- Mémoire élevée ou qui grimpe : streaming, textures, sons et fuites dans les scripts.
- Ping élevé, actions en retard : le réseau (RemoteEvents, objets créés ou animés côté serveur).
Corrige d’abord le problème le plus visible pour les joueurs, une chose à la fois, et mesure après chaque changement.
Les scripts
À 60 FPS, chaque image dispose d’environ 16,67 ms au total. Tout code exécuté à chaque image grignote ce budget.
Les événements « à chaque image »
RunService propose des événements qui se déclenchent à chaque image : Heartbeat, PreRender, PreSimulation, PostSimulation, PreAnimation. La documentation recommande de les utiliser avec parcimonie, seulement quand c’est indispensable (une caméra, une animation fluide). Pour le reste :
- réagis à des événements (
Changed,Touched,PlayerAdded…) au lieu de vérifier en boucle ; - découpe les gros calculs avec
task.wait()pour les étaler sur plusieurs images ; - mets en cache les références : appelle
GetServiceouWaitForChildune fois, garde le résultat dans une variable.
Avant / après n° 1 : le compteur de pièces
Au module 5, ton compteur de pièces est déjà bien écrit. Mais on voit souvent des débutants écrire ce genre de version, qui relit tout environ 60 fois par seconde, même quand rien ne change.
Avant (version maladroite, à ne pas copier) :
-- AVANT
-- LocalScript dans StarterGui > InterfaceObby
-- (CompteurPieces est un Frame qui contient le TextLabel « Texte », comme au module 5)
local RunService = game:GetService("RunService")
local interface = script.Parent
local etiquette = interface:WaitForChild("CompteurPieces"):WaitForChild("Texte") :: TextLabel
RunService.Heartbeat:Connect(function()
-- À chaque image : on récupère le service, on cherche le dossier, puis la valeur…
local joueur = game:GetService("Players").LocalPlayer
local leaderstats = joueur:FindFirstChild("leaderstats")
if leaderstats then
local pieces = leaderstats:FindFirstChild("Pieces")
if pieces and pieces:IsA("IntValue") then
etiquette.Text = "Pièces : " .. pieces.Value
end
end
end)
Après : on récupère les références une fois, et on ne met à jour le texte que quand la valeur change. C’est exactement ce que fait la partie « compteur » du LocalScript ScriptInterface du module 5 : si ton compteur ressemble déjà à ça, tu n’as rien à changer (n’ajoute pas un second script qui ferait le même travail).
-- APRÈS (même principe que la partie « compteur » de ScriptInterface, module 5)
-- LocalScript dans StarterGui > InterfaceObby
local Players = game:GetService("Players")
local joueur = Players.LocalPlayer
local interface = script.Parent
local etiquette = interface:WaitForChild("CompteurPieces"):WaitForChild("Texte") :: TextLabel
-- Références mises en cache une seule fois, au démarrage
local pieces = joueur:WaitForChild("leaderstats"):WaitForChild("Pieces") :: IntValue
local function afficherPieces(valeur: number)
etiquette.Text = "Pièces : " .. valeur
end
afficherPieces(pieces.Value) -- affichage initial
pieces.Changed:Connect(afficherPieces) -- puis uniquement quand la valeur change
Le résultat à l’écran est identique. Mais au lieu de 60 mises à jour par seconde, il n’y en a qu’une par pièce ramassée. Pour un seul petit script, le gain est modeste ; c’est l’accumulation de ces petits travaux « à chaque image » qui finit par coûter cher.
Avant / après n° 2 : un chronomètre par joueur
Tu veux chronométrer la course de chaque joueur. Avant :
-- AVANT
-- Script placé dans ServerScriptService
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local chronos = {}
Players.PlayerAdded:Connect(function(joueur)
chronos[joueur] = 0
-- Une connexion PAR joueur, à un événement qui ne s'arrête jamais…
RunService.Heartbeat:Connect(function(deltaTime)
chronos[joueur] += deltaTime
-- … et un attribut modifié (donc répliqué) à chaque image
joueur:SetAttribute("TempsDeCourse", chronos[joueur])
end)
end)
Ce code cumule trois défauts : du calcul à chaque image pour chaque joueur, du trafic réseau inutile (l’attribut change 60 fois par seconde), et une fuite : quand le joueur part, la connexion continue de tourner pour toujours et la table chronos garde sa trace.
Après : on n’a pas besoin de compter chaque image. Il suffit de noter l’heure de départ et de faire une soustraction quand on veut le temps.
-- APRÈS
-- ModuleScript nommé « Chrono », placé dans ServerScriptService
-- Le script des checkpoints appelle Chrono.demarrer(joueur) au départ,
-- puis Chrono.lire(joueur) quand le joueur atteint la dernière étape.
local Players = game:GetService("Players")
local Chrono = {}
local heuresDeDepart: { [Player]: number } = {}
function Chrono.demarrer(joueur: Player)
heuresDeDepart[joueur] = os.clock()
end
function Chrono.lire(joueur: Player): number?
local depart = heuresDeDepart[joueur]
if depart == nil then
return nil
end
return os.clock() - depart
end
-- On fait le ménage quand le joueur part : pas de fuite de mémoire
Players.PlayerRemoving:Connect(function(joueur)
heuresDeDepart[joueur] = nil
end)
return Chrono
Plus aucun travail à chaque image, plus de trafic réseau, et la table est vidée au départ du joueur. Si l’interface doit afficher un chrono qui défile, c’est le client qui s’en charge, pour lui seul.
La mémoire : déconnecter et faire le ménage
La documentation Roblox cite deux grandes causes de fuites de mémoire dans les scripts.
- Les connexions oubliées. Une connexion active garde en mémoire sa fonction et tout ce qu’elle utilise. Les connexions d’un objet sont coupées quand cet objet est détruit. Piège classique : par défaut, l’objet
Playeret son personnage ne sont pas détruits automatiquement quand le joueur part. Les connexions faites sur eux (CharacterAdded, les valeurs deleaderstats…) peuvent donc rester actives. - Les tables qui grossissent. Ajouter une entrée par joueur sans jamais la retirer fait grossir la table à chaque arrivée.
Pour t’en protéger, déconnecte explicitement avec :Disconnect() et vide les tables au départ du joueur :
-- Script placé dans ServerScriptService
-- Modèle à réutiliser : on range les connexions de chaque joueur pour pouvoir les couper.
local Players = game:GetService("Players")
local connexionsParJoueur: { [Player]: { RBXScriptConnection } } = {}
Players.PlayerAdded:Connect(function(joueur)
local connexions = {}
connexionsParJoueur[joueur] = connexions
table.insert(connexions, joueur.CharacterAdded:Connect(function()
print(joueur.Name .. " réapparaît")
end))
end)
Players.PlayerRemoving:Connect(function(joueur)
local connexions = connexionsParJoueur[joueur]
if connexions then
for _, connexion in connexions do
connexion:Disconnect() -- on coupe chaque connexion
end
connexionsParJoueur[joueur] = nil -- et on vide l'entrée de la table
end
end)
Pour vérifier, surveille LuaHeap et InstanceCount dans l’outil Memory de la Developer Console : s’ils grimpent sans jamais redescendre pendant une longue session, cherche une fuite.
La physique
Ancrer tout ce qui ne bouge pas
Une Part non ancrée est simulée par le moteur physique. Les plateformes, checkpoints, kill bricks et décors de l’obby ne doivent pas tomber : Anchored = true pour tous. Même les plateformes mobiles animées par TweenService (module 5) sont ancrées : le tween les déplace, pas la gravité.
CanCollide, CanTouch et CanQuery
Ces trois propriétés de BasePart décident ce que le moteur calcule pour une Part :
CanCollide: la Part bloque-t-elle physiquement les autres ? Un décor qu’on ne doit pas heurter peut être àfalse.CanTouch: la Part déclenche-t-elle les événementsTouchedetTouchEnded? Sifalse, ces événements ne peuvent plus fonctionner. La documentation recommande de la désactiver sur les objets qui n’ont pas besoin de détecter de contact.CanQuery: la Part est-elle prise en compte par les requêtes spatiales (Raycast,GetPartBoundsInBox…) ? Cette propriété n’a d’effet que siCanCollideest àfalse.
Selon la référence API, une Part qui a à la fois CanTouch et CanCollide à false apporte un petit gain de performance, car le moteur n’a plus à calculer ses contacts avec les autres Parts.
CollisionFidelity des MeshParts
Pour une MeshPart, la propriété CollisionFidelity choisit la forme utilisée pour les collisions. Elle se règle dans Studio, pas par script pendant la partie.
Box: une simple boîte autour du maillage. La plus légère en mémoire.Hull: l’enveloppe convexe du maillage (comme un emballage tendu autour).Default: une décomposition en plusieurs enveloppes (à base de voxels, de petits cubes), plus fidèle queHullmais imprécise sur les trous et les creux ; elle consomme nettement plus de mémoire queBoxetHull.PreciseConvexDecomposition: la plus fidèle pour les objets complexes, mais coûteuse en calcul et en mémoire.
Les conseils de la documentation : Box pour les petits objets ou ceux avec lesquels on n’interagit pas ; Box ou Hull pour les objets moyens ; pour un objet ancré et petit, Box est en général sans risque. Et pour un objet sans collision, choisis quand même Box ou Hull : la géométrie de collision reste stockée en mémoire. Pour repérer les maillages trop précis, tape le filtre CollisionFidelity=PreciseConvexDecomposition dans la barre de recherche de l’Explorer, ou active Collision fidelity dans les options de visualisation, en haut à droite de la vue 3D.
Le rendu
Le rendu ne concerne que le client : c’est lui qui dessine l’image.
- Moins d’objets visibles en même temps. Plus une zone est dense en objets, plus il faut d’appels de dessin (draw calls). Si les FPS chutent quand tu regardes une zone précise, elle est sans doute trop chargée.
- Réutiliser les mêmes maillages. Roblox regroupe en un seul appel de dessin les MeshParts qui partagent le même maillage et la même apparence (instancing). Importe un maillage une fois, puis duplique-le dans Studio : ne réimporte pas dix copies identiques avec dix identifiants différents.
RenderFidelity. Sa valeur par défaut,Automatic, réduit les détails avec la distance : détail maximal sous 250 studs, moyen entre 250 et 500, minimal au-delà.Preciseforce le détail maximal partout : à éviter sur beaucoup de maillages.Performanceprivilégie la vitesse.- La transparence partielle. Des objets semi-transparents superposés obligent à dessiner plusieurs fois les mêmes pixels. La documentation conseille d’éviter les transparences autres que 0 (visible) et 1 (invisible). Pour l’Obby des Nuages, c’est un vrai sujet : n’empile pas des dizaines de nuages semi-transparents.
- Les ombres et lumières. Les ombres coûtent cher. Désactive
Light.Shadowssur les lumières qui n’ont pas besoin d’ombre, limite leur portée et leur nombre.BasePart.CastShadowpeut être désactivé sur de petites Parts dont l’ombre ne se voit presque pas, mais la référence API prévient que ça peut créer des défauts visuels et conseille de le laisser activé dans la plupart des cas : à faire avec parcimonie. Roblox réduit d’ailleurs lui-même la qualité des ombres quand la qualité graphique baisse, jusqu’à les désactiver sous le niveau 4 de qualité graphique (le réglage Graphics Quality du menu Roblox). - Les particules et décalques. Les
ParticleEmitter, décalques et textures se regroupent mal et ajoutent des appels de dessin. Garde des taux d’émission raisonnables, et évite de modifier les propriétés des émetteurs en continu. - Les textures. La mémoire graphique dépend du nombre de pixels : une image de 1024 × 1024 consomme quatre fois plus qu’une de 512 × 512. Selon la documentation, la plupart des images n’ont pas besoin de plus de 512 × 512, et les petites de plus de 256 × 256. Les matériaux intégrés de Roblox consomment bien moins de mémoire que des textures personnalisées.
Le réseau
Chaque information envoyée entre le serveur et les clients coûte du temps de calcul et de la bande passante.
- Envoie moins, moins souvent. Ne déclenche pas un RemoteEvent à chaque image ou à chaque entrée du joueur sans limite. Envoie un état quand il change. Après un achat, envoie l’objet acheté, pas tout l’inventaire.
- Les effets visuels côté client. Le serveur n’a besoin que du résultat (le joueur a ramassé la pièce) ; l’effet d’étincelles peut être créé par le client.
- Les animations décoratives côté client. Selon la documentation, une propriété animée par TweenService côté serveur est répliquée à chaque client à chaque image (avec, en prime, une animation saccadée quand la latence varie). C’est la même chose pour une boucle serveur qui modifie une propriété à chaque image : chaque changement est répliqué. Les plateformes mobiles du module 5 sont un cas à part : ce sont des objets du gameplay, que tous les joueurs doivent voir au même endroit. Pour un débutant, garde-les côté serveur comme au module 5, et réserve l’animation côté client aux effets purement décoratifs.
- Ne mets pas tout dans ReplicatedStorage. Tout son contenu est envoyé à chaque joueur avant qu’il puisse jouer. Ce dont seul le serveur a besoin va dans
ServerStorage.
Avant / après n° 3 : les pièces qui tournent
À la leçon 2, tu as étiqueté le Script serveur PiecesTournantes du module 3, qui fait tourner toutes les pièces environ 60 fois par seconde. Son vrai coût : chaque changement de CFrame fait côté serveur doit être répliqué à tous les joueurs chez qui la pièce est chargée, pour chaque pièce, environ 60 fois par seconde. La rotation est purement décorative : faisons-la côté client, et seulement pour les pièces chargées chez le joueur.
-- APRÈS
-- LocalScript « PiecesTournantesClient » placé dans StarterPlayerScripts
-- (Supprime le Script serveur « PiecesTournantes » et les éventuels scripts
-- de rotation restés dans chaque pièce.)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
-- Config est dans ReplicatedStorage.Modules : le client peut le lire
local Config = require(ReplicatedStorage:WaitForChild("Modules"):WaitForChild("Config"))
local dossierPieces = Workspace:WaitForChild("Pieces")
local VITESSE_ROTATION = math.rad(Config.VITESSE_ROTATION_PIECES) -- en radians par seconde
-- Les pièces actuellement chargées chez CE joueur (le streaming en ajoute et en retire)
local piecesChargees: { [BasePart]: boolean } = {}
local function ajouterPiece(objet: Instance)
if objet:IsA("BasePart") then
piecesChargees[objet] = true
end
end
local function retirerPiece(objet: Instance)
if objet:IsA("BasePart") then
piecesChargees[objet] = nil
end
end
-- Les pièces déjà là, puis celles qui arrivent ou repartent
for _, objet in dossierPieces:GetChildren() do
ajouterPiece(objet)
end
dossierPieces.ChildAdded:Connect(ajouterPiece)
dossierPieces.ChildRemoved:Connect(retirerPiece)
-- Ici, une mise à jour à chaque image est justifiée : c'est une animation visible.
RunService.PreRender:Connect(function(deltaTime: number)
local rotation = CFrame.Angles(0, VITESSE_ROTATION * deltaTime, 0)
for piece in piecesChargees do
piece.CFrame = piece.CFrame * rotation
end
end)
Ce qui change : zéro trafic réseau pour la rotation, et chaque appareil ne fait tourner que les pièces proches de son joueur. Le ramassage reste géré par le Script serveur Pieces du module 4, sans aucune modification : la pièce tourne sur elle-même, sa position ne change pas. Quand elle est ramassée, le serveur la rend invisible ; elle continue de tourner chez le client, ce qui ne se voit pas.
Ce qu’il faut retenir
- Corrige d’abord ce que la mesure désigne : barres orange → scripts et physique ; bleues ou rouges → rendu ; mémoire qui grimpe → fuites, textures, streaming.
- Préfère les événements aux boucles, mets en cache tes références, réserve les événements « à chaque image » aux animations visibles.
- Déconnecte tes connexions et vide tes tables quand un joueur part, ou active
PlayerCharacterDestroyBehavior. - Ancre tout ce qui ne bouge pas ; désactive
CanCollide,CanTouchetCanQuerysur les décors, jamais sur les objets du gameplay ; choisisBoxouHullpour laCollisionFidelitydes décors. - Limite objets, transparences, ombres, particules et grandes textures ; anime le décoratif côté client et envoie moins de données sur le réseau.
Sources et pour aller plus loin
- Improve performance — documentation Roblox
- Design for performance — documentation Roblox
- Collisions (CanCollide, CanTouch, collision fidelity) — documentation Roblox
- BasePart (CanTouch, CanQuery, CastShadow) — référence API
- Enum CollisionFidelity — référence API
- Enum RenderFidelity — référence API
- RunService (événements à chaque image) — référence API