Вычисления на видеокарте, руководство, лёгкий уровень
Это руководство поясняет работу простейшей программы, производящей вычисления на GPU. Вот ссылка на проект Юнити этой программы:
ссылка на файл проекта .unitypackage
Она рисует фрактал Мандельброта.
Я не буду пояснять каждую строчку кода, укажу только необходимые действия для реализации вычислений на GPU. Поэтому, лучше всего открыть код программы в Юнити и там смотреть, как используются поясняемые мной строчки кода.
Шейдер, который рисует фрактал, написан на языке HLSL. Ниже приведён его текст. Я кратко прокомментировал значимые строки, а развёрнутые объяснения будут ниже.
// выполняющаяся в GPU программа использует данные из видеопамяти через буфферы:
RWTexture2D textureOut; // это текстура, в которую мы будем записывать пиксели
RWStructuredBuffer rect; // это границы области в пространстве фрактала, которую мы визуализируем
RWStructuredBuffer colors; // а это гамма цветов, которую мы подготовили на стороне CPU и передали в видеопамять
#pragma kernel pixelCalc // тут мы объявили кернел, по этому имени мы сможем его выполнить со стороны CPU
[numthreads(32,32,1)] // эта директива определяет количество потоков, в которыз выполнится этот кернел
void pixelCalc (uint3 id : SV_DispatchThreadID){ // тут мы задаём код кернела. Параметр id хранит индекс потока, который используется для адресации данных
float k = 0.0009765625; // это просто множитель для проекции пространства 1024х1024 текстуры на маленькую область 2х2 пространства фрактала
double dx, dy;
double p, q;
double x, y, xnew, ynew, d = 0; // использованы переменные двойной точности, чтобы отдалить столкновение с пределом точности при продвижении вглубь фрактала
uint itn = 0;
dx = rect[2] - rect[0];
dy = rect[3] - rect[1];
p = rect[0] + ((int)id.x) * k * dx;
q = rect[1] + ((int)id.y) * k * dy;
x = p;
y = q;
while (itn < 255 && d < 4){ // собственно суть фрактала: в этом цикле вычисляется число шагов, за которые точка покидает пространство 2x2
xnew = x * x - y * y + p;
ynew = 2 * x * y + q;
x = xnew;
y = ynew;
d = x * x + y * y;
itn++;
}
textureOut[id.xy] = colors[itn]; // вот так мы записываем пиксель цвета: пиксель текстуры определяется индексом, а индекс цвета - числом шагов
}
Внимательный читатель скажет: автор, поясни! Размер текстуры — 1024×1024, а количество потоков — 32×32. Как же параметр id.xy адресует все пиксели текстуры?
Внимательный, но неопытный в вопросах вычислений на GPU читатель перебьёт: позвольте! А откуда следует, что количество потоков 32×32? И как понимать «id.xy»?
Второму я отвечу так: директива [numthreads (32,32,1)] говорит, что у нас 32×32х1 потоков. При этом, потоки образуют трёхмерную сетку, потому что параметр id принимает значения в виде координат пространства 32×32x1. Диапазон значений id.x [0, 31], диапазон значений id.y [0, 31], а id.z равен 0. А id.xy — это краткая запись uint2(id.x, id.y)
Именно 32×32 потоков у нас было бы (этой я уже отвечаю первому внимательному читателю), если бы мы вызвали этот кернел со стороны CPU командой
ComputeShader.Dispatch(kernelIndex, 1, 1, 1)
Видите эти три единицы? Это то же самое, что цифры в директиве [numthreads (32,32,1)], они умножаются друг с другом.
Если бы мы запустили шейдер вот с такими параметрами:
ComputeShader.Dispatch(kernelIndex, 2, 4, 1)
То по оси x у нас было бы 32×2 = 64, по оси у 32×4 = 128, то есть всего — 64×128 потоков. Параметры просто перемножаются по каждой оси.
Но нашем случае кернел запущен так:
ComputeShader.Dispatch(kernelIndex, 32, 32, 1)
Что даёт нам в итоге 1024×1024 потока. И значит, индекс id.xy будет принимать значения, покрывающие всё пространство текстуры 1024×1024
Так сделано для удобства. Даные хранятся в массивах, каждый поток осуществляет одну и ту же операцию над единицей данных, и мы создаём количество потоков равным количеству единиц данных, при чём так, чтобы индекс потока адресовал свою единицу данных. Очень удобно.
Вот и всё, что нужно знать про шейдерный код нашей фракталорисующей программы.
Теперь рассмотрим, что мы сделали на стороне CPU, чтобы запустить шейдерный код.
Объявляем переменные: шейдер, буффер и текстуру
ComputeShader _shader
RenderTexture outputTexture
ComputeBuffer colorsBuffer
Инициализируем текстуру, не забыв включить enableRandomWrite
outputTexture = new RenderTexture(1024, 1024, 32);
outputTexture.enableRandomWrite = true;
outputTexture.Create();
Инииализируем буффер, задав количество объектов и размер объекта. И записываем данные предварительно наполненного массива цветов в видеопамять
colorsBuffer = new ComputeBuffer(colorArray.Length, 4 * 4);
colorsBuffer.SetData(colorArray);
Инициализируем шейдер и задаём для кернела текстуру и буффер, чтобы он мог писать в них данные
_shader = Resources.Load("csFractal");
kiCalc = _shader.FindKernel("pixelCalc");
_shader.SetBuffer(kiCalc, "colors", colorsBuffer);
_shader.SetTexture(kiCalc, "textureOut", outputTexture);
В этом состоит подготовка данных. Теперь остаётся только запустить кернел шейдера
_shader.Dispatch(kiCalc, 32, 32, 1);
После выполнения этой команды текстура заполняется цветами, которые мы сразу видим, потому что текстура RenderTexture использована в качестве mainTexture для компонента Image, на который смотрит камера.