Procedural Grid

Fatih Altuntaş
9 min readJan 21, 2021

--

1- Render

Unity’de bir şeyi görselleştirmek istiyorsak, bir mesh kullanırız. Başka bir programdan(blender vs.) dışa aktarılan bir 3B model, prosedural olarak oluşturulmuş bir mesh, sprite, UI element, veya particle system de olabilir. Hatta efektler bile bir mesh ile oluşturulur.

Öyleyse mesh nedir? Mesh, grafik donanımı tarafından karmaşık şeyler çizmek için kullanılan bir yapıdır. 3B uzayda noktaları tanımlayan en az bir köşe noktaları (vertices) kümesi ve bu noktaları birbirine bağlayan bir dizi üçgen (triangle) içerir. Üçgenler, mesh’in temsil ettiği şeyin yüzeyini (surface) oluşturur.

Üçgenler düz bir yüze sahip ve kenarları da düz olduğundan, düz yüzeyli ve düz kenarlı şeyleri mükemmel şekilde görselleştirmek için kullanılabilirler (bir küpün yüzleri gibi). Eğri veya yuvarlak yüzeyler ise birçok küçük üçgen kullanılarak oluşturulabilir. Üçgenler yeterince küçük görünüyorsa — tek bir pikselden büyük değilse — o zaman yaklaşımı fark etmezsiniz. Realtime performans için bu mümkün değildir, bu nedenle yüzeyler her zaman bir dereceye kadar pürüzlü görünecektir.

Unity'nin varsayılan kapsülü, küpü ve küresi, shaded ve wireframe.

Bir gameobject’in 3B modelinin görüntülenmesini istiyorsak, iki component’e sahip olmalıyız: İlki, mesh filter. Bu component, göstermek istediğimiz mesh için bir referans tutar. İkincisi, bir mesh renderer. Bu component ise, mesh’in nasıl oluşturulacığını ayarlar. Hangi material kullanılmalı, gölge cast shadow mu yoksa receive shadow mu gibi ayarlamalar içerir.

Unity'nin varsayılan küp nesnesi.

Neden material bir arraydir?

Bir ağ oluşturucu birden çok malzemeye sahip olabilir. Bu, çoğunlukla alt ağlar olarak bilinen birden çok ayrı üçgen kümesine sahip ağları oluşturmak için kullanılır. Bunlar çoğunlukla içe aktarılan 3B modellerle kullanılır

Material’ini ayarlayarak bir mesh’in görünümünü tamamen değiştirebiliriz. Unity’nin varsayılan material’i tamamen beyazdır. Assets / Create / Material aracılığıyla yeni bir material oluşturarak ve onu gameobjet’imize sürükleyerek kendimizinkiyle değiştirebiliriz. Yeni material’imiz varsayılan olarak Unity’nin Standart Shader’ını kullanır, bu da bize yüzeyimizin görsel olarak nasıl davrandığını ayarlamanız için bir dizi kontrol sağlar.

Mesh’imize çok fazla ayrıntı eklemenin hızlı bir yolu, bir albedo sağlamaktır. Bu, bir material’in temel rengini temsil eden bir texture’dır. Elbette bu material’i mesh’in üçgenlerine nasıl yansıtacağımızı bilmemiz gerekiyor. Bu, köşe noktalarına 2D texture koordinatları eklenerek yapılır. Texture uzayının iki boyutu U ve V olarak adlandırılır, bu yüzden UV koordinatları olarak bilinirler. Bu koordinatlar tüm texture’ı kaplayan (0, 0) ve (1, 1) arasındadır. Bu aralığın dışındaki koordinatlar, texture ayarlarına bağlı olarak ya calmp’lenir ya da tiling’e neden olur.

Unity'nin mesh'ine uygulanan bir UV texture dokusu.

2- Grid Köşe Noktaları Oluşturma

Peki kendi mesh’imizi nasıl yaparız? Basit bir dikdörtgen grid oluşturarak öğrenelim. Grid, birim uzunluktaki kare tile’lardan(dörtlü) oluşacaktır. Yeni bir C # script oluşturalım ve onu yatay ve dikey boyuta sahip bir grid componentine dönüştürelim.

using System.Collections;
using UnityEngine;
public class Grid : MonoBehaviour
{
public int xSize, ySize;
}

Bu component’i bir gameobject’e eklediğimizde, ona bir mesh filter ve mesh renderer da vermemiz gerekir. Unity’nin bizim için otomatik olarak eklemesini sağlamak için sınıfımıza bir attribute ekleyebiliriz.

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class Grid : MonoBehaviour
{
public int xSize, ySize;
}

Artık yeni bir boş gameobject oluşturabilir, Grid componen’ini buna ekleyebilir ve diğer iki component’ede sahip oluruz. renderer material’ini ayarlayalım ve filter’ın mesh’ini tanımsız bırakalım. Grid’in boyutunu 10'a 5 olarak ayarladım.

Grid object

Önce köşe noktası konumlarına odaklanalım ve üçgeni sonraya bırakalım. Noktaları saklamak için bir array 3B vektör tutmamız gerekir. Köşe noktasısayısı grid’in boyutuna bağlıdır. Her dörtlünün köşelerinde bir köşe noktasına ihtiyacımız var, ancak bitişik dörtlüler aynı köşe noktasını paylaşabilir. Bu nedenle, her boyutta tile’ımız olduğundan bir tane daha köşe noktasına ihtiyacımız var.

4'e 2 grid için köşe noktaları ve dörtlü indeksler.
private Vector3[] vertices;private void Generate()
{
vertices = new Vector3[(xSize + 1) * (ySize + 1)];
}

Köşe noktalarını görselleştirelim, böylece onları doğru konumlandırıp konumlandırmadığımızı kontrol etmiş oluruz. Bunu, bir OnDrawGizmos metodu ekleyerek ve her köşe noktası için sahne görünümünde küçük bir siyah küre çizerek yapabiliriz.

private void OnDrawGizmos()
{
Gizmos.color = Color.black;
for (int i = 0; i < vertices.Length; i++)
{
Gizmos.DrawSphere(vertices[i], 0.1f);
}
}

Gizmos Nedir?

Gizmos, editörde kullanabileceğimiz görsel ipuçlarıdır. Varsayılan olarak, oyun görünümünde değil sahne görünümünde görünürler, kendi toolbar’ları ile ayarlanabilir. Simgeler, çizgiler ve diğer bazı şeyleri çizmenize olanak tanır.

Bu, gameplay modunda olmadığımızda hatalar üretecektir, çünkü OnDrawGizmos metodları, herhangi bir köşe noktası olmadığında Unity edit modundayken de çağrılır.

private void OnDrawGizmos()
{
if (vertices == null)
{
return;
}
...
}

Play modundayken, başlangıçta yalnızca tek bir küre görüyoruz. Bunun nedeni, henüz köşe noktalarını konumlandırmamış olmamız, dolayısıyla hepsinin bu konumda çakışmasıdır. Çift döngü kullanarak tüm pozisyonları yinelemeliyiz.

Köşe noktalarından oluşan bir grid.

Köşe noktalarının nasıl konumlandırıldığını görmek için Coroutine kullanıp süreci biraz yavaşlatalım.

private IEnumerator Generate()
{
vertices = new Vector3[(xSize + 1) * (ySize + 1)];
for (int i = 0, y = 0; y <= ySize; y++)
{
for (int x = 0; x <= xSize; x++, i++)
{
vertices[i] = new Vector3(x, y, 0);
yield return new WaitForSeconds(0.1f);
}
}
}

3- Mesh Oluşturma

Artık köşe noktalarının doğru konumlandırıldığını bildiğimize göre, gerçek mesh oluşturabilirz. Kendi component’imizde bir referans tutmanın yanı sıra, onumesh filter’a da atamalıyız. Sonra köşe noktalarını bir kez ele aldığımızda, onları mesh’imize verebiliriz.

private IEnumerator Generate()
{
...
GetComponent<MeshFilter>().mesh = mesh = new Mesh();
mesh.name = "Procedural Grid";
for (int i = 0, y = 0; y <= ySize; y++)
{
for (int x = 0; x <= xSize; x++, i++)
{
...
mesh.vertices = vertices;
...
}
}
}
Mesh, oynatma modunda görünür.

Artık paly modunda bir mesh’imiz var, ancak henüz görünmüyor çünkü ona hiç üçgen vermedik. Üçgenler, bir array köşe noktası indeksiyle tanımlanır. Her üçgenin üç noktası olduğundan, birbirini izleyen üç indis bir üçgen tanımlar. Tek bir üçgen ile başlayalım.

private IEnumerator Generate () 
{

int[] triangles = new int[3];
triangles[0] = 0;
triangles[1] = 1;
triangles[2] = 2;
mesh.triangles = triangles;
}

Şimdi bir üçgene sahibiz, ancak kullandığımız üç nokta düz bir çizgide yatıyor. Bu görünmeyen dejenere bir üçgen oluşturur. İlk iki köşe noktası iyi, ama sonra bir sonraki satırın ilk köşe noktasına atlamalıyız.

triangles[0] = 0;
triangles[1] = 1;
triangles[2] = xSize + 1;

Bu bize bir üçgen verir, ancak yalnızca tek bir yönden görülebilir. Bu durumda, yalnızca Z ekseninin ters yönüne bakıldığında görünür. Dolayısıyla, görmek için görünümü döndürmemiz gerekebilir.

Bir üçgenin hangi taraftan görüneceği, köşe noktası indekslerinin yönüne göre belirlenir. Varsayılan olarak, saat yönünde düzenlenmişlerse, üçgenin ileriye dönük ve görünür olduğu kabul edilir. Saat yönünün tersine üçgenler atılır.

Üçhenin iki tarafı.

Bu nedenle, Z eksenine baktığımızda üçgenin görünmesini sağlamak için, köşelerinin geçildiği sırayı değiştirmeliyiz. Bunu son iki indeksi değiştirerek yapabiliriz.

triangles[0] = 0;
triangles[1] = xSize + 1;
triangles[2] = 1;

Şimdi grid’imizin ilk tile’ının yarısını kaplayan bir üçgenimiz var. Tüm grid’i kaplamak için tek ihtiyacımız olan ikinci bir üçgen.

triangles[0] = 0;
triangles[1] = xSize + 1;
triangles[2] = 1;
triangles[3] = 1;
triangles[4] = xSize + 1;
triangles[5] = xSize + 2;
İki üçgenden oluşan bir dörtgen.

Bu üçgenler iki köşeyi paylaştığından, her köşe indeksinden yalnızca bir kez bahsederek, bunu dört kod satırına indirebiliriz.

triangles[0] = 0;
triangles[3] = triangles[2] = 1;
triangles[4] = triangles[1] = xSize + 1;
triangles[5] = xSize + 2;
İlk kare.

Bunu bir döngüye dönüştürerek ilk tile sırasının tamamını oluşturabiliriz. Hem köşe hem de üçgen indeksleri üzerinde yineleme yaptığımız için, ikisini de takip etmeliyiz. Ayrıca yield’ı bu döngüye taşıyalım, böylece artık köşelerin görünmesini beklemek zorunda kalmayız.

int[] triangles = new int[xSize * ySize * 6];     for (int tris = 0, vert = 0, y = 0; y < ySize; y++, vert++)
{
for (int x = 0; x < xSize; x++, tris += 6, vert++)
{
triangles[tris + 0] = vert;
triangles[tris + 3] = triangles[tris + 2] = vert + 1;
triangles[tris + 4] = triangles[tris + 1] = vert + xSize + 1;
triangles[tris + 5] = vert + xSize + 2;

mesh.triangles = triangles;

yield return new WaitForSeconds(0.01f);
}
}
Tüm grid dolduruldu.

Gördüğümüz gibi, grid’in tamamı artık her seferinde bir sıra olmak üzere üçgenlerle dolu. Bunu hallettiğimize göre, mesh’in gecikmeden oluşturulabilmesi için tüm coroutine kodunu kaldırabiliriz.

private void Start()
{
Generate();
}
private void Generate()
{
vertices = new Vector3[(xSize + 1) * (ySize + 1)];

GetComponent<MeshFilter>().mesh = mesh = new Mesh();
mesh.name = "Procedural Grid";
for (int i = 0, y = 0; y <= ySize; y++)
{
for (int x = 0; x <= xSize; x++, i++)
{
vertices[i] = new Vector3(x, y, 0);
mesh.vertices = vertices;
}
}

int[] triangles = new int[xSize * ySize * 6];
for (int tris = 0, vert = 0, y = 0; y < ySize; y++, vert++)
{
for (int x = 0; x < xSize; x++, tris += 6, vert++)
{
triangles[tris + 0] = vert;
triangles[tris + 3] = triangles[tris + 2] = vert + 1;
triangles[tris + 4] = triangles[tris + 1] = vert + xSize + 1;
triangles[tris + 5] = vert + xSize + 2;

mesh.triangles = triangles;
}
}
}

4- Additonal Vertex Data Oluşturma

Grid’imiz şu anda tuhaf bir şekilde ayfınlanıyor. Bunun nedeni, mesh’e henüz normaller vermedik. Varsayılan normal yön, ihtiyacımız olanın tam tersi olan (0, 0, 1) dir.

Normaller nasıl çalışır?

Normal, bir yüzeye dik olan vektördür. Her zaman birim uzunlukta normaller kullanırız ve bunlar yüzeylerinin içini değil, dışını gösterir.

Normaller, bir ışık ışınının bir yüzeye çarptığı açıyı belirlemek için kullanılabilir. Nasıl kullanıldığının ayrıntıları shader’a bağlıdır.

Üçgen her zaman düz olduğundan, normaller hakkında ayrı bilgi vermeye gerek olmamalıdır. Ancak, bunu yaparak hile yapabiliriz. Gerçekte, köşelerin normalleri yoktur, üçgenleri vardır. Özel normalleri köşelere ekleyerek ve aralarında üçgenler arasında enterpolasyon yaparak, bir grup düz üçgen yerine pürüzsüz bir şekilde eğimli bir yüzeye sahip olduğumuzu düşünebiliriz. Mesh’in keskin yüzeyine dikkat etmediğiniz sürece bu illüzyon ikna edicidir.

Normaller köşe noktası başına tanımlanır, bu nedenle başka bir vektör array’i doldurmamız gerekir. Alternatif olarak, mesh’den normalleri üçgenlerine dayanarak bulmasını isteyebiliriz.

private void Generate () 
{

mesh.triangles = triangles;
mesh.RecalculateNormals();
}

Sıradaki UV koordinatları. Albedo dokusuna sahip bir material kullanmasına rağmen grid’in şu anda tek tip bir renge sahip olduğunu fark edebiliriz. Bu mantıklı çünkü UV koordinatlarını kendimiz sağlamazsak hepsi sıfırdır. Textur’ı grid’in tamamına uyacak şekilde yapmak için, köşe noktasının konumunu grid boyutlarına bölmeniz yeterlidir.

Vector2[] uv = new Vector2[vertices.Length];        for (int i = 0, y = 0; y <= ySize; y++)
{
for (int x = 0; x <= xSize; x++, i++)
{
vertices[i] = new Vector3(x, y, 0);
uv[i] = new Vector2((float)x / xSize, (float)y / ySize);
}
}

mesh.vertices = vertices;
mesh.uv = uv;

Bir yüzeye daha belirgin ayrıntı eklemenin başka bir yolu da normal map kullanmaktır. Bu map’ler renk olarak kodlanmış normal vektörleri içerir. Bunları bir yüzeye uygulamak, tek başına köşe noktası normalleri ile oluşturulabileceğinden çok daha ayrıntılı ışık efektleri ile sonuçlanacaktır.

Bu material’i grid’e uygulamak tümseklere neden olur, ancak bunlar yanlıştır. Onları doğru şekilde yönlendirmek için mesh’e tanjant vektörler eklememiz gerekir.

vertices = new Vector3[(xSize + 1) * (ySize + 1)];
Vector2[] uv = new Vector2[vertices.Length];
Vector4[] tangents = new Vector4[vertices.Length];
Vector4 tangent = new Vector4(1f, 0f, 0f, -1f);
GetComponent<MeshFilter>().mesh = mesh = new Mesh();
mesh.name = "Procedural Grid";
for (int i = 0, y = 0; y <= ySize; y++)
{
for (int x = 0; x <= xSize; x++, i++)
{
vertices[i] = new Vector3(x, y, 0);
uv[i] = new Vector2((float)x / xSize, (float)y / ySize);
tangents[i] = tangent;
}
}
Engebeli gibi görünen düz bir yüzey.

Kaynakça: https://catlikecoding.com/unity/tutorials/procedural-grid/

--

--