Dynamiczne rozmieszczenie traw

Ustawienie gęstości detali

W dalszej części będziemy zajmować się rozwojem trawy z użyciem równań różniczkowych. Dlatego zalecam zmniejszyć liczbę detali w ustawieniach terenu. Wybierz ustawienia terenu i ustaw Detail Resolution per Patch na 16 i Detail Resolution na 256 lub 128. details

W trakcie tych zajęć skupimy się na dynamicznym modelu rozwoju trawy opartym od równaniach różniczkowych, który pozwoli na stworzenie rozmaitych wzorców wegetacji. Zanim przejdziemy do samego modelu, zapoznamy się z interfacem pozwalającym modyfikować trawę.

API detali

Trawa jest rozmieszczana na terenia na podstawie map detali. Są to dwuwymiarowe tablice typu int, której wartości określają zagęszczenie danego detalu w kolejnych polach polu. Tablicę ustawia się za pomocą funkcji terrain.terrainData.SetDetailLayer(int xBase, int yBase, int layer, int[,] details) gdzie xBase i yBase są początkowymi współrzędnymi (domyślnie 0,0), layer jest indeksem detalu a details to tablica opisująca wystąpienia detalu. Ostatnia może mieć maksymalnie wymiar terrain.terrainData.detailWidth-xBase na terrain.terrainData.detailHeight-yBase.

Zadanie

Uzupełnij funkcję reset o instrukcje, które ustawią trawę w krzyżyk idący po przekątnej terenu.

Model rozwoju trawy

Do rozwoju trawy wykorzystamy model reakcji-dyfuzji wzorując się na pracy. Wykorzystamy układ równań różniczkowych

\[ \frac{\partial w}{\partial t} = D_w\Delta w-w^2r^2+\text{feed}(0.5+w)\] \[ \frac{\partial r}{\partial t} = D_r\Delta r+r(wr-\text{kill})\]

gdzie \(r\) i \(w\) oznaczają odpowiednio wegetację (ilość roślin) i zawartość wody. Natomiast parametry \(D_w\), \(D_r\), kill i feed są parametrami, które będą kontrolować interakcję.

Analitycznie \(r\) i \(w\) są funkcjami od współrzędnych terenu \(x\) i \(y\) oraz czasu \(t\). Nas nie interesuje rozwiązanie przybliżone, więc współrzędne \(x\) i \(y\) będą realizowane jako tablica dwuwymiarowa, natomiast krok czasowy będzie dyskretny. Przy takich założeniach możemy zapisać następująco:

\[ \text{newWater}[i,j] = {oldWater}[i,j]+dt*(D_w\Delta(\text{oldWater},i,j)-\text{oldWater}[i,j]^2*\text{oldVegetation}[i,j]^2+\text{feed}(0.5+\text{oldWater}[i,j]))\] \[ \text{newVegetation}[i,j] = \text{oldVegetation}[i,j]+dt*(D_r\Delta(\text{oldVegetation},i,j)+\text{oldVegetation}[i,j]*(\text{oldWater}[i,j]*\text{oldVegetation}[i,j]-\text{kill}) )\]

gdzie \(\text{newWater}[i,j]\) oznacza poziom wody w następnym kroku we współrzędnych i,j, \(\text{oldWater}[i,j]\) oznacza poziom wody w poprzednim kroku we współrzędnych i,j, analogicznie \(\text{newVegetation}[i,j]\) i \(\text{oldVegetation}[i,j]\). Natomiast \(dt\) to krok czasowy, jakaś mała wartość około 0.05.

Pozostaje symbol \(\Delta\) oznacza on operator Laplace i oznacza on sumę drugich pochodnych po współrzędnych, czyli w naszym przypadku: \[\Delta = \frac{\partial^2}{\partial x^2}+\frac{\partial^2}{\partial y^2}.\]

Opisuje on różnicę między wartością funkcji w danym punkcie a jego otoczeniem. Ponieważ nasza przestrzeń jest dyskretna musimy skorzystać z dyskretnego laplaciana, który wygląda następująco

\[ T[i,j] = 0.2*(T[i+1,j]+T[i-1,j]+T[i,j+1]+T[i,j-1])+\\0.05*(T[i+1,j+1]+T[i+1,j-1]+T[i-1,j-1]+T[i-1,j+1])-T[i,j].\]

Można je zobrazować za pomocą ilusracji: aa

Implementacja

Do implementacji wykorzystamy compute shader. Compute shader to program wykonywany na karcie graficznej. Służy do wykonywania dużej liczby obliczeń, które można wykonywać współbieżnie. Nasz przypadek nadaje się idealnie.

Utwórz nowy compute shader, nazwij go na przykład GrassCalculation. Otwórz go w edytorze, będzie on wyglądać tak

// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel CSMain

// Create a RenderTexture with enableRandomWrite flag and set it
// with cs.SetTexture
///// Miejsce na parametry przesyłane 
RWTexture2D<float4> Result;


//Kernel czyli funkcja wywoływana przy aktywacji shadera
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
    // TODO: insert actual code here!

    Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0);
}

Ten kod zawiera definicję jednej zmiennej i funkcji, która jest kernelem. Kernel przyjmuje jeden argument typu uint3, który zawiera identyfikator wywołania. Shader uruchamiany jednocześnie w wielu instancjach z różnymi wartościami tego argumentu. Można je indeksować po 3 wymiarach. W naszym przypadku będziemy indeksować za ich pomocą po tablicach.

Zmienne definiowane globalnie, jak RWTexture2D<float4> Result, są parametrami, które przesyła się ze strony CPU. Typy, których nazwy zaczynają się od RW, mogą być modyfikowane przez shader. Dzięki temu mogą być wykorzystywane do zapisywania wyników.

Zaczniemy od napisania prostego shadera shadera, który będzie stopniowo obniżał wegetację o 0.01 całym na terenie. Zacznij od usunięcia definicji zmiennej Result i ciała funkcji CSMain. Tablicę z wegetacja prześlemy jako RWStructuredBuffer<float>, zakładamy, że może ona osiągnąć wartości od 0 do 1. Zdefiniuj globalną zmienną vegetation typu RWStructuredBuffer<float>.

Shadery nie obsługują 2-wymiarowych tablic, więc ją zastąpić tablicą 1-wymiarową o rozmiarze \(x*y\) i ręcznie indeksować. Do tego potrzebujemy znać wymiary tablicy, dodaj zmienne int sizeX i int sizeY, których do tego użyjemy. Tablicę będziemy zapisywać wierszami, jak na obrazku.

index

Wartość o współrzędnych x,y znajduje się pod indeksem \(x+y*\text{sizeY}\). Napisz funkcję int index(int x,int y), która będzie konwertować indeksy z jednego zapisu do drugiego.

Uzupełnij funkcję CSMain o instrukcje, które będzie obniżać wegetację we współrzędnych id.x i id.y wartość obetnij od dołu przez zero a przez jeden od góry z użyciem funkcji clamp. Całość powinna wyglądać tak:

// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel CSMain

// Create a RenderTexture with enableRandomWrite flag and set it
// with cs.SetTexture
///// Miejsce na parametry przesyłane 
RWStructuredBuffer<float> vegetation;
int sizeX;
int sizeY;

int index(int x, int y){
  return x+y*sizeX;
}

//Kernel czyli funkcja wywoływana przy aktywacji shadera
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
    vegetation[index(id.x, id.y)] = clamp(vegetation[index(id.x, id.y)] - 0.01,0,1);
}

Obsługa shadera

Mamy napisany shader, teraz pozostaje przesłać dane,uruchomić go i odczytać wyniki. Skrypt GrassController Zawiera szkielet kodu potrzebnego do obsługi shadera. Zanim zaczniemy podepnij GrassComputation w inspektorze pod zmienną computeShader.

By zainicjalizować zwykłe zmienne wykorzystuje się metody SetInt lub SetFloat w zależności od typu. Pierwszym jej argumentem jest id danej zmiennej, pozyskuje się ją za pomocą funkcji Shader.PropertyToID, która przyjmuje nazwę zmiennej jako argument. Drugim jest wartość tej zmiennej.

Uzupełnij funkcję initParameters o inicjalizację zmiennych sizeX i sizeY. przypisz im wartość atrybutów sizeX i sizeY (wartości tych zmiennych pokrywają się z rozmiarem mapy detali).

Bufor trzeba najpierw stworzyć po stronie C#, klasa je obsługująca nazywa się ComputeBuffer. konstruktor przyjmuje 2 argumenty: liczbę komórek i rozmiar jednego pola. Pierwsza z nich w naszym przypadku wynosi sizeX*sizeY a druga sizeof(float). Stworzony bufor zapisz w jako atrybut o nazwie vegetation, będzie on potrzebny później, żeby odzyskać dane. Bufor inicjalizuje się za pomocą metody SetBuffer(int kernelIndex,str Name, ComputeBuffer buffer) compute shadera. Pierwszy jej argument w naszym przypadku wynosi zero, drugi to nazwa bufora (czyli vegetation) a trzeci to bufor.

Uzupełnij funkcję initComputeShader o inicjalizację bufora wegetacji.

W metodzie initBufferValues stwórz tablicę o wymiarze sizeX*sizeY i zapełnij ją losowymi wartościami. Następnie zapisz ją do bufora vegetation za pomocą metody SetData. usuń Wszystko z metody reset.

Teraz należy wywołać shader i odczytać dane. Shader wywołuje się za pomocą metody Dispatch(int kernelIndex, int threadGroupsX, int threadGroupsY, int threadGroupsZ), która jako pierwszy argument przyjmuje numer kernela (czyli 0). Natomiast pozostałe to liczba wątków jakie wywołą w danym wymiarze. Te liczby odpowiadają zakresowi jaki przyjmie wektor id w CSMain. W naszym przypadku te wartości mają odpowiadać wymiarom tablicy, czyli odpowiednio sizeX, sizeY i 1.

Uzupełnij funkcję doSimulationSteps(int steps) o wywołanie shader steps razy w pętli.

Odczytanie danych wykonuje się za pomocą metody GetData bufora. Jako argument należy podać tablicę, w której dane zostaną umieszczone. W metodzie recoverData utwórz tablicę typu float o nazwie result o wymiarze sizeX*sizeY i pobierz do niej wartości wegetacji.

Pozostaje zinterpretować wyniki i wykorzystać je do rysowania trawy. Wciąż wewnątrz recoverData utwórz tablicę typu int o wymiarach sizeX na sizeY. W niej umieść podłogę z wyniku z result pomnożonego przez 10.

Dyfuzja

Teraz zmodyfikujemy compute shader tak, żeby zachodziłą dyfuzja trawy w terenie z użyciem operatora Laplace. Operator odwołuje się do sąsiednich komórek, to może powodować wyjście poza tablicę przy indeksowaniu. Musimy obsłużyć to w jakiś sposób. napisz funkcję float get(StructuredBuffer<float> buffer, int x, int y), która zwróci wartość tablicy jeżeli index(x,y) jest pomiędzy 0 a sizeX*sizeY i zero w przeciwnym wypadku. Następnie wykorzystaj ją, żeby napisać funkcję float laplace(StructuredBuffer<float> buffer, int x, int y), która obliczy laplacian dla punkty x,y.

Dyfuzje można opisać wzorem

\[\frac{\partial v}{\partial t}=\Delta v.\]

W wersji dyskretnej będzie to:

\[\text{newV[i,j]} = \text{oldV}[i,j] + \Delta(\text{oldV},i,j).\]

Należy dodać jeszcze jeden RWStructuredBuffer<float>, w którym będziemy przechowywać poprzedni krok, nazwij go oldVegetation. Obliczenia będziemy wykonywać na danych z niego i zapisywać w vegetation. to pozwoli uniknąć problemów z synchronizacją bufora. Zaimplementuj powyższy wzór.

Zainicjalizuj go po stronie C# analogicznie co bufor vegetation. Zapisz go jako atrybut o nazwie . W initBufferValues przypisz mu te same wartości co buforowi vegetation.

Zaimplementuj funkcję swapBuffers, w której zamień ze sobą bufory vegetation i oldVegetation i wywołaj dla nich ponownie computeShader.SetBuffer. W doSimulationSteps dodaj wywołanie swapBuffers przed wywołaniem w pętli shadera.

Interakcja z terenem

W tej chwili trawa rośnie niezależnie od tekstury terenu i jego wysokości, powoduje to, że mamy trawę wyrastającą na drodze albo na szczycie góry. By zniwelować ten problem można uzależnić parametry feed i kill od terenu.

Zadanie

W tym celu stwórz dwa bufory typu float o nazwie feedModifier i killModifier i rozmiarze sizeX*sizeY. Następnie zapełnij je wartościami. Uzależnić killModifier od rodzaju tekstury.

Po stronie shadera odczytaj wartość wartości feedModifier i killModifier dla danej współrzędnej i przemnóż je przez parametry feed i kill w równaniach.

Zadanie domowe

Uzależnij feedModifier od wysokości terenu. możesz do tego wykorzystać na przykład funkcję transition napisaną wcześniej.