Это руководство поясняет работу простейшей программы, производящей вычисления на GPU. Вот ссылка на проект Юнити этой программы:

ссылка на файл проекта .unitypackage

Она рисует фрактал Мандельброта.

Я не буду пояснять каждую строчку кода, укажу только необходимые действия для реализации вычислений на GPU. Поэтому, лучше всего открыть код программы в Юнити и там смотреть, как используются поясняемые мной строчки кода.

Шейдер, который рисует фрактал, написан на языке HLSL. Ниже приведён его текст. Я кратко прокомментировал значимые строки, а развёрнутые объяснения будут ниже.

// выполняющаяся в GPU программа использует данные из видеопамяти через буфферы:

RWTexture2D<float4> textureOut;						// это текстура, в которую мы будем записывать пиксели
RWStructuredBuffer<double> rect;					// это границы области в пространстве фрактала, которую мы визуализируем
RWStructuredBuffer<float4> 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 читатель перебьёт: позвольте! А откуда следует, что количество потоков 32x32? И как понимать «id.xy»?

Второму я отвечу так: директива [numthreads(32,32,1)] говорит, что у нас 32х32х1 потоков. При этом, потоки образуют трёхмерную сетку, потому что параметр id принимает значения в виде координат пространства 32x32x1. Диапазон значений id.x [0, 31], диапазон значений id.y [0, 31], а id.z равен 0. А id.xy — это краткая запись uint2(id.x, id.y)

Именно 32x32 потоков у нас было бы (этой я уже отвечаю первому внимательному читателю), если бы мы вызвали этот кернел со стороны 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<ComputeShader>("csFractal");
kiCalc = _shader.FindKernel("pixelCalc");
_shader.SetBuffer(kiCalc, "colors", colorsBuffer);
_shader.SetTexture(kiCalc, "textureOut", outputTexture);

В этом состоит подготовка данных. Теперь остаётся только запустить кернел шейдера

_shader.Dispatch(kiCalc, 32, 32, 1);

После выполнения этой команды текстура заполняется цветами, которые мы сразу видим, потому что текстура RenderTexture использована в качестве mainTexture для компонента Image, на который смотрит камера.

Комментарии (12)


  1. Korhog
    09.01.2018 11:41
    -1

    Супир. Обожаю подобные статьи.


    1. Korhog
      09.01.2018 11:55

      Для полноты не хватает разве что анимированных гифок


  1. ser-mk
    09.01.2018 17:03

    Спасибо за статью!
    а что вы имелли ввиду за «маленькую область 2х2 пространства фрактала»? Для чего она нужна?
    И еще момент непонятный остается. Программа просчитывает один раз текстуру и выходит? или периодически вызывается и обновляет тестуру?
    Можно и самому проверить, но пока нет возможности поставить здоровенный Unity.


    1. ThisIsZolden Автор
      09.01.2018 17:14

      Фрактал Мандельброта ограничен координатами от -1 до 1 по х и у. А у пикселей координаты от 0 до 1023. Нужно проецировать, чтоб фрактал на весь экран был.

      Программа перерисовывает фрактал каждый раз, как пользователь изменит масштаб или позицию выводящего прямоугольника. Так что можно путешествовать по красочным прядям фрактала.


  1. leshabirukov
    09.01.2018 17:19

    cuda программируется точно также, только выходные данные идут не на рендеринг, а выгружаются обратно в память.

    Внимательный, но неопытный в вопросах вычислений на GPU читатель перебьёт: позвольте! А откуда следует, что количество потоков 32x32? И как понимать «id.xy»?

    Вопрос скорее, зачем бить на квадраты 32x32. А дело в том, что пачка данных одновременно обрабатываемых мультипроцессором это 32 треда в одном варпе (разделение по вычислительным блокам) Х 32 варпа (разделение по времени), и важно кормить эту гидру синхронизированными данными, к примеру читать память так:
    var = mem[ id.x ]
    хорошо, а так:
    var = mem[ id.x *1000 ]
    плохо.


    1. ThisIsZolden Автор
      09.01.2018 17:27

      Я этого не знал, но вы вероятно правы. Хотя, иногда нет другого выхода, кроме как делать примерно так:
      var = buffer[id.x * height + id.y]
      потому что буфферы одномерные, а данные частенько двумерные, а структуру потоков делать одномерной вроде бы тоже не очень эффективно, да и лимиты есть по каждой оси.


      1. SmallSnowball
        09.01.2018 18:35
        +1

        Можно поменять местами id.x и id.y внутри kernel'а и делать так:
        var = buffer[id.y * height + id.x]

        Правда, в таком случае надо об этом везде помнить, да и разные алгоритмы могут требовать разной укладки многомерного буффера в памяти. В некоторых случаях выгода от локальности данных внутри варпа может быть настолько большой, что имеет смысл в рантайме менять раскладку данных внутри буффера.


    1. ser-mk
      10.01.2018 00:00

      cuda программируется точно также, только выходные данные идут не на рендеринг, а выгружаются обратно в память.

      Так здесь разве нельзя так же поступить? в последней строчке вместо
      textureOut[id.xy] = colors[itn];	

      записать значения в какой-нибудь буфер и потом уже работать с этим буфером.


      1. leshabirukov
        10.01.2018 14:31

        Я имел в виду, записываются обратно в память процессора из памяти видеокарты. Всё что вы видите в kernel-части, происходит внутри видеокарты, и к примеру, на диск вы результат работы шейдера не запишете.


  1. alexoron
    09.01.2018 18:16

    Да кому интересно делать какие-то «пустые» вычисления, если можно майнить с одной видеокарты от $2,5 в сутки.
    А поставь таких видях с десяток и на работу не нужно ходить.


    1. midday
      09.01.2018 18:53

      Так не ходи.


  1. avtor13
    09.01.2018 19:12

    прочитав заголовок и первый абзац решил, что речь пойдет о GPGPU