Project Version : Unity 2020.3.22f1
Ce projet Unity permet la génération procédurale d'une ville, dont le placements des différents éléments constituants une ville. Vous trouverez dans le markdown le travail de recherche.
- Generation procédurale de monde, Adrien Peytavie, 2013
- Map Generator : Create procedural American-style cities, Keir "ProbableTrain", 2020
La première intuition a été de baser la création de la ville sur une texture 2D représentant des routes. En utilisant l'algorithme de remplissage par diffusion, chaque quartier séparé par une route peut être nommé d'un type de zone.
Une autre intuition, plus simple, est basée sur une grille représentant une ville, dont on a rempli chaque case par la dénomination d'une zone (centre-ville, résidentiel, industriel...). Puis, en utilisant l'algorithme Marching square (utilisation de l'algorithme Marching square dans Unity), on peut délimiter ces zones non plus selon la grille mais selon les coordonnées de la carte.
Selon nos connaissances et nos observations, plusieurs règles de générations ont été décidées pour le remplissage de la grille :
- Une ville est de forme ronde.
- La zone "Centre-ville" est unique et elle est au centre de la ville.
- Autour du centre-ville, la zone "résidentiel" s'étend sur tout le reste de la ville.
- Une zone "industrielle" est forcément en dehors du centre-ville.
- Une zone de "bureau" est forcément en centre-ville.
Voici les paramètres des zones :
- Centre ville
- Au centre de la ville
- Radius modulable
- Quartier résidentiel
- S'étend autour du centre-ville
- Zone industriel
- Au abord de la ville, dans la périphérie
- Généré dans les quartiers résidentiels
- Zone travail (entreprise)
- Non implémentée
- Espace vert
- Tous les quartiers qui n'ont pas de bâtiments
- Aléatoire, + grand en s'éloignant du centre ville (non implémenté)
- Zone d'eau
- Cours d'eau passant dans la ville
- Dans les espaces verts (non implémenté)
De plus, les bâtiments présents dans une ville diffèrents selon leur emplacement. Ainsi, un batiment en centre ville sera un immeuble haut, et plus on s'écarte du centre ville, moins les bâtiments sont hauts. De la même façon, il y a plus de bâtiment au centre de la ville, et plus on s'éloigne moins la ville est dense.
Cette problématique nous a fait penser à une distribution gaussienne. Une fonction gaussienne a dont été utilisée afin d'instancier les bâtiments de la ville (plus dense au centre, plus étendu sur les bords).
La génération de la ville (fonction CreateEpicentres) se fait en créant des cercles successifs jusqu'à la taille du rayon de la ville. Pour chacun des points de ce cercle, on le place dans la grille en lui donnant un type de zone (centre-ville si il est au centre, résidentiel sinon). Puis, selon la fonction gaussienne, la position a une certaine probabilité qu'elle devienne occupée, et qu'il y aura un bâtiment sur cette case.
Jouer sur les différents paramètres de la classe de la ville (CityClass) permet de modifier les paramètres de la fonction gaussienne, et ainsi modifier la densité ou l'étendue de la ville. Partant de cette façon de construire la ville, on peut instancier plusieurs ville dans le rendu.
On peut interpréter deux zones occupées collées de plusieurs façons :
- C'est un bâtiment plus grand : les deux zones occupées fusionnent ;
- C'est une zone dense, les bâtiments sont plus hauts.
Intuitivement, on compte le nombre de bâtiments dans la zone d'un certain rayon autour d'un bâtiment ; on supprime ces bâtiments et on agrandit proportionnellement le bâtiment central. Cette méthode, bien que particulièrement pertinente, s'est révélé lourde à implémenter.
Lors de la séparation de la carte en quartiers, nous avons appliqué une autre méthode (CreateBuildings), plus simple mais moins précise :
- Si une zone est complètement occupé, c'est qu'elle est très dense, les immeubles seront plus hauts ;
- Si la zone est remplie aux 2/3 ou plus, ce sont des immeubles moyens,
- Si la zone est remplie à 1/3 ou plus, ce sont de petits immeubles,
- Si la zone contient moins de 1/3 de zones occupées, ce sont des maisons,
- Sinon c'est un parc.
Afin de structurer la ville, nous avons généré une grille de routes entre les quartiers de n*n (fonction CreateRoads) (taille et nombre des quartiers modifiables dans les paramètres du component CityBuild). Puisque le projet se focalise principalement sur le placement des différents éléments d'une ville, la grille des routes est très simple et sert de découpage de la grille en quartiers carrés.
Plus tard, ce découpage en quartiers nous a permit de choisir les types de bâtiments selon le nombre de zones occupées dans le quartier.
Les parcs sont de grands espaces sans bâtiments. Ainsi, les quartiers qui n'ont pas de bâtiments deviennent des parcs.
Souvent, une rivière traverse une ville. Ainsi, crée dans la fonction CreateRiver, la rivière suit une courbe de bézier, le premier point étant une position aléatoire sur l'un des côté de la carte, les points au milieu sont les positions des épicentres des villes, et le dernier points est une position aléatoire de l'autre côté de la carte.
Les zones industrielles ne peuvent pas être placées dans le centre-ville.
Pendant la création de la zone résidentielle, la zone attribuée à la case de la grille a une très faible chance de se transformer en quartier industriel. Puis, on génère autour de ce point une zone industrielle avec un très faible radius (fonction CreateIndustrialZone) puis dans la génération de batiments on place des usines dans les zones.
Pour afficher une grille avec le plus de mesh possible plusieurs options étaient disponible :
- Instanciation de prefab unity (pool d'objet)
- Utilisation des gizmos pour afficher des primitives simples
- Utilisation du GPU instancing de mesh
La dernière option à été choisie. Un exemple de cette solution, trouvée dans cette article, explique le principe de base d'utilisation ainsi qu'une demonstration d'un code déjà fonctionnel. Cependant pour rester le plus performant possible, nous sommes restés sur une solution avec des cubes en lieu et place de mesh avec plusieurs subMeshes.
Le principe de la méthode DrawMeshInstanceInrect est de charger le mesh sur le GPU afin de l'instancier N fois dans un seul drawCall au lieu de faire appel à l'instanciation d'objet d'Unity (ne fusionne pas les drawcalls d'un même objet et plus lourd) pour seulement faire de l'affichage. Cependant, nous n'avons pas de physique sur ces objets. Cette méthode est un wrapper qui permet de ne pas toucher directement au GPU.
Cela passe par la création d'un shader qui va lire nos données envoyées (MeshProperties) afin d'afficher dans le moteur les meshs a leurs transformation et couleur respectives. Ce shader est utilisé via un material passé à la classe de rendu.
public struct MeshProperties
{
public Matrix4x4 mat;
public Vector4 color;
// usefull to get to total size of the struct
public static int Size()
{
return sizeof(float) * 4 * 4 + sizeof(float) * 4;
}
}
Ici un exemple d'initialisation des différents buffer pour chacun des bâtiments.
public void InitializeBuffers()
{
uint[] args = new uint[5] {0, 0, 0, 0, 0};
// 0 = count of triangles indices
// 1 = number of mesh to draw
// 2 = submesh starting index
// 3 = submesh base offset index
foreach (var building in buildingMeshes)
{
for (int i = 0; i < building.Value.subMeshCount; i++)
{
args[0] = (uint)building.Value.GetIndexCount(i);
args[1] = (uint)(_builder.DimensionSize * _builder.DimensionSize);
args[2] = (uint)building.Value.GetIndexStart(i);
args[3] = (uint)building.Value.GetBaseVertex(i);
_argsBuffers[building.Key].Add(new ComputeBuffer(1,
args.Length * sizeof(uint), ComputeBufferType.IndirectArguments));
_argsBuffers[building.Key][i].SetData(args);
}
}
// Init buffer with grid
MeshProperties[] properties = new MeshProperties[_builder.DimensionSize * _builder.DimensionSize];
for (uint z = 0; z < _builder.DimensionSize; ++z)
{
for (uint x = 0; x < _builder.DimensionSize; ++x)
{
var cityCase = _builder.MapCase[z * _builder.DimensionSize + x];
MeshProperties props = new MeshProperties();
Vector3 scale = CityCase.GetBuildingSize(cityCase);
Vector3 position = new Vector3(x, scale.y / 2, z);
Quaternion rotation = Quaternion.identity;
props.mat = Matrix4x4.TRS(position, rotation, scale);
props.color = cityCase.occupied == true
? _builder.buildingColor[cityCase.building] : Color.clear;
properties[z * _builder.DimensionSize + x] = props;
}
}
_meshPropertiesBuffer = new ComputeBuffer(
(int)(_builder.DimensionSize * _builder.DimensionSize),
MeshProperties.Size());
// give all the properties generated before to the struct in the shader
_meshPropertiesBuffer.SetData(properties);
material.SetBuffer("_Properties", _meshPropertiesBuffer);
}
Ici la fonction d'affichage appelé pour chaque type de bâtiments.
foreach (var mesh in buildingMeshes)
{
for (int i = 0; i < mesh.Value.subMeshCount; i++)
{
Graphics.DrawMeshInstancedIndirect(mesh.Value, i,
material, _bounds, _argsBuffers[mesh.Key][i]);
}
}