Aller au contenu
Forma Roblox

Rechercher dans la formation

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

🎯 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 GetService ou WaitForChild une 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.

  1. 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 Player et son personnage ne sont pas détruits automatiquement quand le joueur part. Les connexions faites sur eux (CharacterAdded, les valeurs de leaderstats…) peuvent donc rester actives.
  2. 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énements Touched et TouchEnded ? Si false, 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 si CanCollide est à 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 que Hull mais imprécise sur les trous et les creux ; elle consomme nettement plus de mémoire que Box et Hull.
  • 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à. Precise force le détail maximal partout : à éviter sur beaucoup de maillages. Performance privilé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.Shadows sur les lumières qui n’ont pas besoin d’ombre, limite leur portée et leur nombre. BasePart.CastShadow peut ê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, CanTouch et CanQuery sur les décors, jamais sur les objets du gameplay ; choisis Box ou Hull pour la CollisionFidelity des 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

Quiz : vérifie tes acquis

7 questions · réussite à partir de 70 % · valide chaque réponse pour voir l’explication

  1. Question 1Trouve le bugPour « optimiser », ce Script serveur modifie les kill bricks. Depuis, plus personne ne meurt en les touchant, et l’Output affiche une erreur. Où est le bug ?
    local Workspace = game:GetService("Workspace")
    local killBricks = Workspace:WaitForChild("KillBricks")
    
    for _, brique in killBricks:GetChildren() do
    	if brique:IsA("BasePart") then
    		brique.Anchored = true
    		brique.CanTouch = false
    		brique.Touched:Connect(function(autrePartie)
    			local modele = autrePartie.Parent
    			local humanoid = modele and modele:FindFirstChildOfClass("Humanoid")
    			if humanoid then
    				humanoid.Health = 0
    			end
    		end)
    	end
    end
  2. Question 2QCMUn Script serveur connecte RunService.Heartbeat à l’intérieur de PlayerAdded, pour chaque joueur, sans jamais déconnecter. Que se passe-t-il quand des joueurs partent ?
  3. Question 3Vrai ou fauxFaire tourner un objet avec TweenService côté serveur envoie sa nouvelle position à tous les joueurs à chaque image.
  4. Question 4QCMUn décor en MeshPart (un nuage) ne sert à rien d’autre qu’à être beau. Quels réglages réduisent son coût ? (plusieurs réponses)

    Plusieurs réponses possibles : coche toutes les bonnes.

  5. Question 5QCMTon MicroProfiler montre surtout des barres orange, et l’étiquette la plus large vient d’un de tes scripts. Par quoi commencer ?
  6. Question 6Vrai ou fauxUne texture de 1024 × 1024 pixels utilise environ quatre fois plus de mémoire graphique qu’une texture de 512 × 512, même si son fichier est bien compressé.
  7. Question 7Améliore ce promptTon MicroProfiler (côté serveur) désigne l’étiquette ObbyRotationPieces. Quelle version du prompt donnera à Claude Code la meilleure correction ?
    Le prompt de départ
    réduis le lag de mon obby

    Choisis la meilleure version de ce prompt.