Вычисления на видеокарте, руководство, лёгкий уровень

habr.png

Это руководство поясняет работу простейшей программы, производящей вычисления на 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, на который смотрит камера.

© Habrahabr.ru