12 KiB
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 iDetail Resolution
na 256 lub 128.
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:
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.
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. Dodaj też zmienną dt
i zainicjalizuj ją atrybutem dt
. 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.
Zadanie
Zaimplementuj model reakcji dyfuzji:
- Dodaj bufory, które będą przechowywać stan wody w obecnym i poprzednim kroku, zainicjalizuj je i obsłuż przy swapowaniu.
- Dodaj pozostałe parametry, zainicjalizuj je wartościami atrybutów.
- znajdź parametry dające ciekawe wzory,
dWater
idVegetation
ustaw na0.3
i0.1
odpowiednio.
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
. Następnie zapełnij je wartościami. Uzależnij killModifier
od rodzaju tekstury. Pobierz wagi ze spaltmapy i jeżeli wartość śniegu będzie dodatnia ustaw killModifier
na 2, w przeciwnym wypadku na 1. Teren ma inną rozdzielczość niż splatmapa, uwzględnij to przy pobieraniu danych.
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ą na poprzednich zajęciach .