Unity - Génération procédurale de terrain

Débogage et optimisation

L'auteur

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Cette série explique comment générer de manière procédurale des continents dans Unity.
Vous pouvez retrouver les autres épisodes de cette série dans le sommaire dédié.

II. Vidéo


Unity - Génération procédurale de terrain - Débogage et optimisation


III. Résumé

Cet épisode couvre la correction de quelques problèmes et la mise en place de petites optimisations.

III-A. Corrections de bogues

III-A-1. Erreur PropertyToID

Si vous lancez le projet (mode « Play »), vous obtenez une erreur stipulant que la PropertyToID ne peut être appelée que dans le thread principal.

En regardant les détails de l'erreur, vous pourrez mieux comprendre son origine (et où, dans votre code, l'erreur se produit).

Pour résoudre l'erreur, il faut déplacer l'appel à la fonction UpdateMeshHeights() afin qu'elle soit exécutée dans le thread principal. Pour cela, placez l'appel dans la fonction DrawMapInEditor() et ajoutez un appel dans une nouvelle méthode Awake() dans la classe MapGenerator.cs :

MapGenerator.cs
Sélectionnez
void Awake() {
    textureData.UpdateMeshHeights (terrainMaterial, terrainData.minHeight, terrainData.maxHeight);
}

III-A-2. Correction de la construction de l'exécutable final

Maintenant, si vous essayez de construire le projet (afin de le distribuer) vous obtiendrez une nouvelle erreur stipulant que UnityEditor n'existe pas. En effet, le fichier UpdatableData.cs fait référence à la classe UnityEditor, qui elle, n'est pas présente dans l'exécutable final. Il est possible de conditionner le code, afin qu'il ne soit compilé que si l'on est dans l'éditeur :

UpdatableData.cs
Sélectionnez
#if UNITY_EDITOR

protected virtual void OnValidate() {
    if (autoUpdate) {
        UnityEditor.EditorApplication.update += NotifyOfUpdatedValues;
    }
}

public void NotifyOfUpdatedValues() {
    UnityEditor.EditorApplication.update -= NotifyOfUpdatedValues;
    if (OnValuesUpdated != null) {
        OnValuesUpdated ();
    }
}

#endif

Toutefois, une telle modification fait que la méthode NoiseData::OnValidate() n'est plus une surcharge, car la méthode UpdatableData::OnValidate() n'existe plus. Du coup, une protection est aussi ajoutée dans NoiseData.cs :

NoiseData.cs
Sélectionnez
#if UNITY_EDITOR

protected override void OnValidate() {
    if (lacunarity < 1) {
        lacunarity = 1;
    }
    if (octaves < 0) {
        octaves = 0;
    }

    base.OnValidate ();
}
#endif

III-A-3. Terrain noir

Même si maintenant vous pouvez construire le projet, vous obtiendrez un terrain noir. Il faut modifier la méthode MapGenerator::Awake() pour mettre à jour, au démarrage de l'application, le matériau à utiliser pour le terrain :

MapGenerator.cs
Sélectionnez
void Awake() {
    textureData.ApplyToMaterial (terrainMaterial);
    textureData.UpdateMeshHeights (terrainMaterial, terrainData.minHeight, terrainData.maxHeight);
}

III-B. Optimisations

Grâce au profileur, on peut remarquer que la génération des données de collision impacte énormément les performances. Pour améliorer cette situation, les données de collision ne seront plus générées que pour le morceau de terrain sur lequel le joueur se trouve.

Par ailleurs, on peut remarquer qu'il est possible d'activer la génération des données de collisions pour plusieurs niveaux de détails à la fois. Évidemment, cela ne devrait pas être le cas. Pour corriger cela, vous pouvez faire en sorte que l'utilisateur entre un nombre déterminant le niveau de détails à utiliser.

III-B-1. Implémentation

Le code du fichier EndlessTerrain.cs est fortement impacté par ces améliorations :

  • ajoutez un int colliderLODIndex ;
  • ajoutez un float constant colliderGenerationDistanceThreshold dont la valeur est 5 ;
  • passez la variable colliderLODIndex au constructeur de TerrainChunk ;
  • modifiez le constructeur pour prendre en compte cette nouvelle variable : elle est copiée dans une variable colliderLODIndex membre de la classe TerrainChunk. La variable collisionLODMesh n'est plus utile ;
  • ajoutez un booléen, membre de la classe TerrainChunk, appelé hasSetCollider ;
  • ajoutez une variable sqrVisibleDstThreshold avec un getter permettant de récupérer le carré de la variable visibleDstThreshold ;
  • ajoutez une nouvelle méthode, permettant de définir les données de collision suivant la distance du joueur, ainsi que de générer les données si nécessaire :

    EndlessTerrain.cs
    Sélectionnez
    public void UpdateCollisionMesh() {
        if (!hasSetCollider) {
            float sqrDstFromViewerToEdge = bounds.SqrDistance (viewerPosition);
    
            if (sqrDstFromViewerToEdge < detailLevels [colliderLODIndex].sqrVisibleDstThreshold) {
                if (!lodMeshes [colliderLODIndex].hasRequestedMesh) {
                    lodMeshes [colliderLODIndex].RequestMesh (mapData);
                }
            }
    
            if (sqrDstFromViewerToEdge < colliderGenerationDistanceThreshold * colliderGenerationDistanceThreshold) {
                if (lodMeshes [colliderLODIndex].hasMesh) {
                    meshCollider.sharedMesh = lodMeshes [colliderLODIndex].mesh;
                    hasSetCollider = true;
                }
            }
        }
    }
  • La fonction est appelée à chaque mise à jour du jeu, si le joueur a bougé, dans la méthode EndlessTerrain::Udpate() :
EndlessTerrain.cs
Sélectionnez
if (viewerPosition != viewerPositionOld) {
    foreach (TerrainChunk chunk in visibleTerrainChunks) {
        chunk.UpdateCollisionMesh ();
    }
}

Sachant que seules les positions X et Z sont utilisées pour savoir si le joueur bouge, au lancement du jeu, les données de collision ne sont pas générées : le joueur n'a as bougé (mis à part sur l'axe des Y).. Pour corriger ce cas, l'action du LODMesh est transformée en event pour permettre à deux fonctions d'être appelées au déclenchement de l'événement. L'ancien code lié à l'action est retiré. L'événement est configuré dans le constructeur de TerrainChunk lors de la création des LODMesh :

EndlessTerrain.cs
Sélectionnez
lodMeshes = new LODMesh[detailLevels.Length];
for (int i = 0; i < detailLevels.Length; i++) {
    lodMeshes[i] = new LODMesh(detailLevels[i].lod);
    lodMeshes[i].updateCallback += UpdateTerrainChunk;
    if (i == colliderLODIndex) {
        lodMeshes[i].updateCallback += UpdateCollisionMesh;
    }
}

III-B-2. Diminution de la taille des morceaux de terrain

Une seconde possibilité pour réduire le temps de génération est de réduire le nombre des données à générer en réduisant la taille des morceaux de terrain.
Toutefois, les valeurs utilisées jusqu'à présent sont soumises à deux contraintes :

  • ne pas être trop grandes, car le nombre de sommets d'un seul modèle est limité par Unity ;
  • être compatible avec l'implémentation du niveau de détail. Sachant que nous supportons jusqu'à sept niveaux de détail, les valeurs possibles pour la taille des morceaux sont très limitées. Si nous n'en supportions plus que cinq, un choix plus grand de valeurs serait possible.

Ajoutez un entier constant, membre de la classe MeshGenerator, déterminant le nombre de niveaux supportés :

MeshGenerator.cs
Sélectionnez
public const int numSupportedLODs = 5;

Grâce à cette nouvelle variable, restreignez les valeurs possibles pour la variable LODInfo::lod :

EndlessTerrain.cs
Sélectionnez
[Range(0,MeshGenerator.numSupportedLODs-1)]
public int lod;

Il est nécessaire de faire de même pour la variable MapGenerator::editorPreviewLOD :

MapGenerator.cs
Sélectionnez
[Range(0,MeshGenerator.numSupportedLODs-1)]
public int editorPreviewLOD;

Maintenant que nous avons plus de choix pour les tailles des morceaux, nous pouvons mettre en place un mécanisme permettant à l'utilisateur de choisir parmi les différentes valeurs valides :

MeshGenerator.cs
Sélectionnez
public const int numSupportedChunkSizes = 9;
public const int numSupportedFlatshadedChunkSizes = 3;
public static readonly int[] supportedChunkSizes = {48,72,96,120,144,168,192,216,240};
public static readonly int[] supportedFlatshadedChunkSizes = {48,72,96};

Les tableaux ne peuvent pas être définis constants. Par contre, on peut obtenir un comportement assez proche en combinant les qualificateurs static et readonly.

Ainsi, dans le fichier MapGenerator.cs, nous pouvons afficher le nouveau paramètre à l'utilisateur :

MapGenerator.cs
Sélectionnez
[Range(0,MeshGenerator.numSupportedChunkSizes-1)]
public int chunkSizeIndex; [Range(0,MeshGenerator.numSupportedFlatshadedChunkSizes-1)]
public int flatshadedChunkSizeIndex;

Et utiliser les variables dans le getter de mapChunkSize :

MapGenerator.cs
Sélectionnez
public int mapChunkSize {
    get {
        if (terrainData.useFlatShading) {
            return MeshGenerator.supportedFlatshadedChunkSizes [flatshadedChunkSizeIndex] -1;
        } else {
            return MeshGenerator.supportedChunkSizes [chunkSizeIndex] -1;
        }
    }
}

III-B-3. Amélioration de la gestion de la visibilité des morceaux

La gestion de la visibilité des morceaux de terrains peut être améliorée. Pour cela :

  • renommez la variable terrainChunksVisibleLastUpdate en visibleTerrainChunks ;
  • modifiez la méthode UpdateTerrainChunk() pour qu'elle gère d'elle-même la visibilité des morceaux :

    EndlessTerrain.cs
    Sélectionnez
    if (wasVisible != visible) {
        if (visible) {
            visibleTerrainChunks.Add (this);
        } else {
            visibleTerrainChunks.Remove (this);
        }
        SetVisible (visible);
    }
  • modifiez la méthode UpdateVisibleChunks() pour prendre en compte ces changements :
EndlessTerrain.cs
Sélectionnez
void UpdateVisibleChunks() {
    HashSet<Vector2> alreadyUpdatedChunkCoords = new HashSet<Vector2> ();
    for (int i = visibleTerrainChunks.Count-1; i >= 0; i--) {
        alreadyUpdatedChunkCoords.Add (visibleTerrainChunks [i].coord);
        visibleTerrainChunks [i].UpdateTerrainChunk ();
    }
        
    int currentChunkCoordX = Mathf.RoundToInt (viewerPosition.x / chunkSize);
    int currentChunkCoordY = Mathf.RoundToInt (viewerPosition.y / chunkSize);

    for (int yOffset = -chunksVisibleInViewDst; yOffset <= chunksVisibleInViewDst; yOffset++) {
        for (int xOffset = -chunksVisibleInViewDst; xOffset <= chunksVisibleInViewDst; xOffset++) {
            Vector2 viewedChunkCoord = new Vector2 (currentChunkCoordX + xOffset, currentChunkCoordY + yOffset);
            if (!alreadyUpdatedChunkCoords.Contains (viewedChunkCoord)) {
                if (terrainChunkDictionary.ContainsKey (viewedChunkCoord)) {
                    terrainChunkDictionary [viewedChunkCoord].UpdateTerrainChunk ();
                } else {
                    terrainChunkDictionary.Add (viewedChunkCoord, new TerrainChunk (viewedChunkCoord, chunkSize, detailLevels, colliderLODIndex, transform, mapMaterial));
                }
            }

        }
    }
}

Une trace des morceaux mis à jour est conservée, afin de ne pas mettre deux fois à jour les mêmes morceaux. La position des morceaux est utilisée pour identifier les morceaux mis à jour.

La boucle démarre de la fin de la liste, car la méthode appelée peut supprimer des éléments de cette liste. Sans ce parcours inversé, l'index utilisé serait hors des limites du tableau une fois ces éléments retirés.

IV. Commenter

Vous pouvez commenter et donner vos avis dans la discussion associée sur le forum.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Copyright © 2018 Unity Technologies. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.