Compute Shaders в Unity: основні елементи та рев’ю обчислювального шейдера
Даніл Гошко — Technical Art Director, веде свій блог Decompiled Art. У червні Даніл завершив серію туторіалів, присвячених обчислювальним шейдерам в Unity. З дозволу автора ми перекладаємо всю серію з трьох матеріалів українською.
Далі переклад другої статті з серії. Слідкуйте за оновленнями форуму, щоб не пропустити фінальний матеріал.
Це друга частина серії статей, присвячених обчислювальним шейдерам в Unity. У цій частині ми детально розглянемо основні елементи, з яких складаються обчислювальні шейдери. Крім того, знову розглянемо наш перший обчислювальний шейдер і відповідний C#-скрипт, який ми створювали у попередній статті.
Обов’язково прочитайте першу статтю, якщо хочете продовжувати:
Compute Shaders в Unity: обчислення на графічному процесорі, перший обчислювальний шейдер
Основні елементи Compute Shader: Ядро (Kernel), Потік (Thread), Група (Group)
Перш ніж пояснювати конкретну реалізацію, необхідно розібратися з основними елементами обчислювального шейдера та концепціями, що лежать в основі їх функціональності.
Кожен шайдер складається з таких будівельних блоків: Ядро (Kernel), Потік (Thread), Група (Group).
Ядро (Kernel) — це точка входу у коді обчислювального шейдера, який виконується на графічному процесорі (також розглядається як функція). Він визначає операції та обчислення, які необхідно виконати з даними. Кожне ядро можна розглядати, як незалежне завдання, яке виконується паралельно.
Потік (Thread) окремий юніт виконання в ядрі. Потоки є найменшою одиницею роботи в обчислювальному шейдері. Кілька потоків створюються та виконуються одночасно для паралельної обробки даних. Кожен потік зазвичай працює з унікальним набором даних або виконує певне обчислення. Один потік виконує одне ядро. Ймовірно, здатність виконувати ядра одночасно в кількох потоках — одна з чудових переваг обчислювальних шейдерів. І коли мова вже зайшла за паралельність...
Паралельність — це здатність системи або програми виконувати кілька завдань або операцій одночасно. У паралельній системі різні завдання можуть виконуватися незалежно одне від одного і виконуватися одночасно, потенційно збігаючись у часі. Докладніше про паралелізм (або ж рівночасність) можна прочитати у Вікіпедії.
Сам по собі потік задається значеннями в трьох вимірах (X, Y, Z).
За прикладом з попередньої статті, [numthreads(8,8,1)] запускатиме 8*8*1 = 64 потоки одночасно. А [numthreads(32,2,1)], запустить 32*2*1 = 64 потоки, що виконуватимуться одночасно. Хоча загальна кількість потоків залишається незмінною, є скрипти, у яких вигідніше вказати потоки у двох вимірах, наприклад (8, 8, 1). Але в ці деталі ми заглибимося дещо пізніше.
Група (Group) — це юніт для виконання потоків (threads). Потоки, що виконуються групою, називаються групою потоків. Це набір потоків, згрупованих разом для синхронізації та зв’язку. Група потоків може обмінюватися даними та координувати свої операції. Розмір групи визначається розробником і залежить від конкретних вимог до обчислень, які вони виконують.
![](https://s.dou.ua/storage-files/image_64278601421688487310800.jpg)
Рев’ю першого обчислювального шейдера
Тепер, оскільки ви знайомі з основними елементами обчислювальних шейдерів, доцільно розібрати C#-cкрипт і код обчислювального шейдера з попередньої статті.
Код обчислювального шейдера (під назвою CS_00):
#pragma kernel CSMain RWTexture2D<float4> Result; [numthreads(8,8,1)] void CSMain (uint3 id : SV_DispatchThreadID) { Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0); }
Створення ядра
#pragma kernel CSMain
Створення обчислювальної функції ядра за допомогою директиви #pragma. За замовчуванням функція ядра називається CSMain, але ви можете її перейменувати. Обов’язковим є наявність принаймні одного ядра, яке можна викликати (відправляти) з будь-якого C#-скрипта за допомогою методу Dispatch().
Texture2D із прапорцем, що дозволяє увімкнути Read/Write
RWTexture2D<float4> Result;
Створення Texture2D. Float4 відповідає за канали R, G, B, A відповідно. Префікс «RW» означає, що ця текстура використовується як для читання (read), так і для запису (write). Вони потрібні, тому що ми робимо попіксельні обчислення та зберігаємо результати в цій текстурі.
Оператор Numthreads
[numthreads(8,8,1)]
Як вже зазначалося раніше, групи потоків у обчислювальних шейдерах вказуються в багатовимірному масиві. Кожна група містить кілька потоків, які теж працюють у трьох вимірах. Оператор numthreads інформує обчислювальний шейдер про кількість потоків, присутніх у кожному вимірі групи потоків. У цьому конкретному скрипті масив потоків представлено як 8×8x1.
Це викликає поширене запитання: «Навіщо вказувати третю координату, якщо використовуються лише дві координати?»
Відповідь полягає в тому, що загальна кількість потоків визначається множенням усіх трьох значень координат. Якщо третє значення встановлено на 0, загальний добуток також дорівнюватиме 0. Наприклад, розглянемо координати (4, 2, 0), де 4 * 2 * 0 дорівнює 0.
Наша мета — заповнити пікселі значеннями з обчислювального шейдера, візуалізувати процес створення груп потоків, тож зрозуміти цю логіку стає надзвичайно просто.
![](https://s.dou.ua/storage-files/image_74629799331688487395263.jpg)
Щоб створити групу потоків, ми використовуємо вказані раніше номери потоків для координат X, Y та Z відповідно. Отже, 8*8*1 = 64, що означає, що одна група потоків оброблятиме область 8 на 8 пікселів. Перша група потоків матиме ідентифікатор (0,0,0).
![](https://s.dou.ua/storage-files/image_20983273841688487479874.jpg)
Наступна (зміщена на X) — (1,0,0) і так далі.
![](https://s.dou.ua/storage-files/image_64007622451688487490264.jpg)
Таким чином, загальна кількість груп потоків, необхідних для обробки загальної кількості пікселів Texture, дорівнюватиме texResolution/8.
Функція ядра
void CSMain (uint3 id : SV_DispatchThreadID) { Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0); }
Щоб запустити ядро, необхідно задати ідентифікатор параметра (id) типу uint3 (трикомпонентний вектор) із семантикою SV_DispatchThreadID.
Семантика — це набір інструкцій обчислювального шейдера, який визначає ряд дій, що мабть бути виконані компілятором із заданим параметром «id» (uint3). Семантика використовується між різними етапами обробки шейдерів у пайплайні.
Більше про семантику SV_DispatchThreadID ви можете дізнатися тут.
Тіло ядра (функції)
Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0);
Не заплутайтеся в цьому конкретному рівнянні. Це фрактал, створений польським математиком Вацлавом Серпінським. Ви можете дізнатися про це більше.
Наразі значно важливіше зрозуміти, як формується значення Result[id.xy], що створюється за допомогою структури float4().
Структура float4<> містить чотири значення: R, G, B і A. Ці значення відповідають за червоний, зелений, синій та альфа-канали відповідно.
Код C#-скрипта (з назвою GenerateRenderTexture)
using UnityEngine; namespace CS_00 { [RequireComponent(typeof(Renderer))] public class GenerateRenderTexture : MonoBehaviour { [SerializeField] private ComputeShader computeShader; [SerializeField] private string kernelName = "CSMain"; [SerializeField] private int resolution = 128; private RenderTexture _renderTexture; private int _kernelHandle; private Renderer _renderer; private static readonly int MainTex = Shader.PropertyToID("_MainTex"); private void Start() { //GET RENDERER COMPONENT REFERENCE TryGetComponent(out _renderer); //CREATE NEW RENDER TEXTURE TO RENDER DATA TO _renderTexture = new RenderTexture(resolution, resolution, 0) { enableRandomWrite = true }; _renderTexture.Create(); //COMPUTE SHADER & RESULTING RENDERTEXTURE SETUP _kernelHandle = computeShader.FindKernel(kernelName); computeShader.SetTexture(_kernelHandle, "Result", _renderTexture); _renderer.sharedMaterial.SetTexture(MainTex, _renderTexture); computeShader.Dispatch(_kernelHandle, resolution/8, resolution/8, 1); } //TO MAKE SURE THAT GENERATED RENDERTEXTURE IS DISPOSED/CLEARED private void OnDisable() { if (_renderTexture != null) Destroy(_renderTexture); _renderer.sharedMaterial.SetTexture(MainTex, null); } } }
Для вашого кращого розуміння коду я прокоментував логічні блоки. А тепер звернімо увагу на певні частини, що стосуються логіки обробки обчислювальних шейдерів.
Керівник ядра (Kernel handle)
_kernelHandle = computeShader.FindKernel(kernelName);
Використовується для пошуку індексу ядра обчислювального шейдера. Оскільки один обчислювальний шейдер може мати декілька ядер (про що ми поговоримо в наступних статтях), метод FindKernel() використовується для отримання індексу ядра на основі наданого йому імені.
Задаємо параметр текстури обчислювального шейдера
computeShader.SetTexture(_kernelHandle, "Result", _renderTexture);
Функція SetTexture() може встановити текстуру для читання в обчислювальному шейдері або ж для запису на вивід (output).
Задаємо властивості текстури матеріалу MeshRenderer
_renderer.sharedMaterial.SetTexture(MainTex, _renderTexture);
Запуск / Виконання / Відправлення обчислювального шейдера
computeShader.Dispatch(_kernelHandle, resolution/8, resolution/8, 1);
Функція Dispatch() виконує обчислювальний шейдер, запускаючи певну кількість груп потоків обчислювальних шейдерів у вимірах X, Y і Z. Як зазначалося вище, знаменник дорівнює значенню потоків за координатами X і Y.
Заради експерименту та задля цікавості ви можете маніпулювати значенням знаменника та подивитися на результи. (Спойлер) Коли ви збільшуєте знаменник, зменшується кількість пікселів текстури, що отримують обчислені дані. Не бійтеся спробувати та насолодитися цим процесом!
На цьому все! Сподіваюся, ця стаття покращить ваше розуміння роботи з обчислювальними шейдерами в Unity.
Дуже дякую за увагу!
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів