Хабрахабр

[Перевод] Трассировка лучей на GPU в Unity

Для трассировки лучей (ray tracing) настали удивительные времена. Компания NVIDIA реализует ускоренное с помощью ИИ шумоподавление, Microsoft объявляет о нативной поддержке в DirectX 12, а Питер Ширли продаёт свои книги по свободной цене (pay what you want). Похоже, что трассировка лучей наконец получила шанс быть принятой при дворе. Возможно, говорить о начале революции ещё слишком рано, но уже определённо стоит начать изучать и накапливать знания в этой области.

Скрипты мы будем писать на C#, а шейдеры — на HLSL. В этой статье мы напишем с нуля в Unity очень простой трассировщик лучей с помощью compute shaders. Весь код выложен на Bitbucket.

В результате у нас получится отрендерить нечто подобное:

Я хочу начать с краткого обзора основ теории трассировки лучей. Если вы знакомы с ней, то можете спокойно пропустить этот раздел.

Всё начинается с источника света, испускающего фотоны. Давайте представим, как в реальном мире появляются фотографии — очень упрощённо, но для объяснения рендеринга этого будет достаточно. Рано или поздно некоторые из фотонов попадают в сенсор камеры, который, в свою очередь, создаёт готовое изображение. Фотон летит по прямой, пока не столкнётся с поверхностью, после чего он отражается или преломляется, а затем продолжает своё путешествие, потеряв часть энергии, поглощённой поверхностью. По сути, процедура трассировки лучей имитирует эти шаги для создания фотореалистичных изображений.

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

Мы сможем имитировать резкие тени и идеально правильные отражения. Трассировщик лучей, который мы будем создавать, основан на статье 1980 года Тёрнера Уиттеда. Кроме того, трассировщик будет служить основой для реализации более сложных эффектов, таких как преломление, рассеянное глобальное освещение (diffuse global illumination), блестящих отражений и мягких теней.

Давайте начнём с создания нового проекта Unity. Создайте скрипт C# RayTracingMaster.cs и compute shader RayTracingShader.compute. Вставьте в скрипт C# следующий базовый код:

using UnityEngine; public class RayTracingMaster : MonoBehaviour
private void Render(RenderTexture destination) { // Make sure we have a current render target InitRenderTexture(); // Set the target and dispatch the compute shader RayTracingShader.SetTexture(0, "Result", _target); int threadGroupsX = Mathf.CeilToInt(Screen.width / 8.0f); int threadGroupsY = Mathf.CeilToInt(Screen.height / 8.0f); RayTracingShader.Dispatch(0, threadGroupsX, threadGroupsY, 1); // Blit the result texture to the screen Graphics.Blit(_target, destination); } private void InitRenderTexture() { if (_target == null || _target.width != Screen.width || _target.height != Screen.height) { // Release render texture if we already have one if (_target != null) _target.Release(); // Get a render target for Ray Tracing _target = new RenderTexture(Screen.width, Screen.height, 0, RenderTextureFormat.ARGBFloat, RenderTextureReadWrite.Linear); _target.enableRandomWrite = true; _target.Create(); } }
}

Функция OnRenderImage автоматически вызывается Unity после того, как камера завершит рендеринг. Чтобы выполнить рендеринг, нам сначала нужно создать целевой рендер (render target) с соответствующими размерами и сообщить об этом compute shader. 0 — это индекс функции ядра compute shader – у нас она только одна.

Это значит, что мы просим GPU заняться группами потоков, выполняющими код нашего шейдера. Затем мы передаём шейдер. Размер и количество групп потоков может указываться в трёх измерениях, благодаря чему можно просто применить compute shaders к задачам любой размерности. Каждая группа потоков состоит из нескольких потоков, количество которых задаётся в самом шейдере. Размер группы потоков по умолчанию, заданный в шаблоне compute shader Unity, равен [numthreads(8,8,1)], поэтому мы будем придерживаться его и создадим по одной группе потоков на каждые 8×8 пикселей. В нашем случае требуется создать по одному потоку на пиксель целевого рендера. Blit. В конце мы запишем результат на экран с помощью Graphics.

Добавьте к камере сцены компонент RayTracingMaster (это важно при вызове OnRenderImage), назначьте compute shader и запустите режим play. Давайте проверим программу. Вы должны увидеть вывод шаблона compute shader Unity в виде красивого треугольного фрактала.

Теперь, когда мы можем отображать изображения на экране, давайте сгенерируем лучи камеры. Так как Unity предоставляет нам полностью функциональную камеру, мы можем просто использовать для этого вычисленные матрицы. Начнём с задания матриц в шейдере. Добавьте следующие строки в скрипт RayTracingMaster.cs:

private Camera _camera; private void Awake()
{ _camera = GetComponent<Camera>();
} private void SetShaderParameters()
{ RayTracingShader.SetMatrix("_CameraToWorld", _camera.cameraToWorldMatrix); RayTracingShader.SetMatrix("_CameraInverseProjection", _camera.projectionMatrix.inverse);
}

Перед рендерингом вызовем SetShaderParameters из OnRenderImage.

Нужно учесть, что в HLSL, в отличие от C#, объявление функции или переменной должно выполняться до их использования. В шейдере мы определяем матрицы, структуру Ray и функцию для конструирования. Вот, как выглядит весь шейдер: Для центра каждого экранного пикселя мы вычисляем источник и направление луча, и выводим последнее как цвет.

#pragma kernel CSMain RWTexture2D<float4> Result;
float4x4 _CameraToWorld;
float4x4 _CameraInverseProjection; struct Ray
{ float3 origin; float3 direction;
}; Ray CreateRay(float3 origin, float3 direction)
{ Ray ray; ray.origin = origin; ray.direction = direction; return ray;
} Ray CreateCameraRay(float2 uv)
{ // Transform the camera origin to world space float3 origin = mul(_CameraToWorld, float4(0.0f, 0.0f, 0.0f, 1.0f)).xyz; // Invert the perspective projection of the view-space position float3 direction = mul(_CameraInverseProjection, float4(uv, 0.0f, 1.0f)).xyz; // Transform the direction from camera to world space and normalize direction = mul(_CameraToWorld, float4(direction, 0.0f)).xyz; direction = normalize(direction); return CreateRay(origin, direction);
} [numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{ // Get the dimensions of the RenderTexture uint width, height; Result.GetDimensions(width, height); // Transform pixel to [-1,1] range float2 uv = float2((id.xy + float2(0.5f, 0.5f)) / float2(width, height) * 2.0f - 1.0f); // Get a ray for the UVs Ray ray = CreateCameraRay(uv); // Write some colors Result[id.xy] = float4(ray.direction * 0.5f + 0.5f, 1.0f);
}

Попробуйте повращать камеру в инспекторе. Вы увидите, что «цветное небо» ведёт себя соответствующим образом.

В своих примерах я воспользуюсь Cape Hill с сайта HDRI Haven, но вы, разумеется, можете выбрать любой другой. Теперь давайте заменим цвета настоящим skybox. В параметрах импорта не забудьте увеличить максимальное разрешение, если разрешение скачанного файла больше 2048. Скачайте и перетащите его в Unity. Теперь добавьте в скрипт public Texture SkyboxTexture, назначьте текстуру в инспекторе и задайте её в шейдере, добавив в функцию SetShaderParameters эту строку:

RayTracingShader.SetTexture(0, "_SkyboxTexture", SkyboxTexture);

В шейдере определите текстуру и соответствующий сэмплер, а также константу, которую мы вскоре используем:

Texture2D<float4> _SkyboxTexture;
SamplerState sampler_SkyboxTexture;
static const float PI = 3.14159265f;

Теперь вместо записи цвета направления мы сэмплируем скайбокс. Для этого мы преобразуем декартов вектор направления в сферические координаты и свяжем его с координатами текстуры. Заменим последнюю часть CSMain следующим:

// Sample the skybox and write it
float theta = acos(ray.direction.y) / -PI;
float phi = atan2(ray.direction.x, -ray.direction.z) / -PI * 0.5f;
Result[id.xy] = _SkyboxTexture.SampleLevel(sampler_SkyboxTexture, float2(phi, theta), 0);

Пока всё получается хорошо. Теперь мы приступим к самой трассировке лучей. Математически мы можем вычислить пересечение между лучом и геометрией сцены, и сохранить параметры столкновения (позицию, нормаль и расстояние вдоль луча). Если наш луч сталкивается с несколькими объектами, то мы выберем ближайший. Давайте определим в шейдере struct RayHit:

struct RayHit
{ float3 position; float distance; float3 normal;
}; RayHit CreateRayHit()
{ RayHit hit; hit.position = float3(0.0f, 0.0f, 0.0f); hit.distance = 1.#INF; hit.normal = float3(0.0f, 0.0f, 0.0f); return hit;
}

Обычно сцены состоят из множества треугольников, но мы начнём с простого: с пересечения бесконечной плоскости земли и нескольких сфер!

Плоскость земли

Вычисление пересечения прямой с бесконечной плоскостью при $y=0$ — достаточно простая задача. Однако мы рассматриваем только столкновения в положительном направлении луча и отбрасываем все столкновения, которые не ближе, чем потенциальное предыдущее столкновение.

Мы передаём RayHit bestHit с квалификатором inout, чтобы иметь возможность изменять исходный struct. По умолчанию параметры в HLSL передаются по значению, а не по ссылке, поэтому мы будем работать только с копией и не сможем передать изменения вызывающей функции. Вот, как выглядит код шейдера:

void IntersectGroundPlane(Ray ray, inout RayHit bestHit)
{ // Calculate distance along the ray where the ground plane is intersected float t = -ray.origin.y / ray.direction.y; if (t > 0 && t < bestHit.distance) { bestHit.distance = t; bestHit.position = ray.origin + t * ray.direction; bestHit.normal = float3(0.0f, 1.0f, 0.0f); }
}

Чтобы использовать его, давайте добавим каркасную функцию Trace (сколько мы расширим её):

RayHit Trace(Ray ray)
{ RayHit bestHit = CreateRayHit(); IntersectGroundPlane(ray, bestHit); return bestHit;
}

Кроме того, нам нужна базовая функция затенения. Здесь мы снова передаём Ray с inout — мы изменим её позже, когда будем говорить об отражениях. В целях отладки мы будем возвращать при столкновении с геометрией нормаль, а в противном случае возвращаться к коду сэмплирования скайбокса:

float3 Shade(inout Ray ray, RayHit hit)
{ if (hit.distance < 1.#INF) { // Return the normal return hit.normal * 0.5f + 0.5f; } else { // Sample the skybox and write it float theta = acos(ray.direction.y) / -PI; float phi = atan2(ray.direction.x, -ray.direction.z) / -PI * 0.5f; return _SkyboxTexture.SampleLevel(sampler_SkyboxTexture, float2(phi, theta), 0).xyz; }
}

Мы будем использовать обе функции в CSMain. Удалите код сэмплирования скайбокса, если ещё этого не сделали, и добавьте следующие строки для трассировки луча и затенения столкновения:

// Trace and shade
RayHit hit = Trace(ray);
float3 result = Shade(ray, hit);
Result[id.xy] = float4(result, 1);

Сфера

Плоскость — не самый интересный объект в мире, поэтому давайте сразу же добавим сферу. Математические вычисления пересечения прямой и сферы можно найти в Википедии. На этот раз у нас будет всего два варианта столкновений луча: входная точка p1 - p2 и выходная точка p1 + p2. Сначала мы будем проверять входную точку, и используем выходную точку, если другая не подходит. В нашем случае сфера определяется как значение float4, состоящее из позиции (xyz) и радиуса (w). Вот, как выглядит код:

void IntersectSphere(Ray ray, inout RayHit bestHit, float4 sphere)
{ // Calculate distance along the ray where the sphere is intersected float3 d = ray.origin - sphere.xyz; float p1 = -dot(ray.direction, d); float p2sqr = p1 * p1 - dot(d, d) + sphere.w * sphere.w; if (p2sqr < 0) return; float p2 = sqrt(p2sqr); float t = p1 - p2 > 0 ? p1 - p2 : p1 + p2; if (t > 0 && t < bestHit.distance) { bestHit.distance = t; bestHit.position = ray.origin + t * ray.direction; bestHit.normal = normalize(bestHit.position - sphere.xyz); }
}

Чтобы добавить сферу, просто вызовем эту функцию из Trace, например так:

// Add a floating unit sphere
IntersectSphere(ray, bestHit, float4(0, 3.0f, 0, 1.0f));

У используемого подхода есть одна проблема: мы проверяем только центр каждого пикселя, поэтому в результате будут заметны искажения (некрасивые «лесенки»). Чтобы обойти эту проблему, мы будем трассировать не один, а несколько лучей на пиксель. Каждый луч получает случайное смещение внутри области пикселя. Чтобы сохранить приемлемый уровень частоты кадров, мы будем выполнять прогрессивное сэмплирование, то есть трассировать по одному лучу на пиксель за кадр и со временем усреднять значение, если камера не движется. При каждом движении камеры (или изменении любых других параметров — области видимости, геометрии сцены или освещения) нам придётся начинать всё заново.

Назовём этот шейдер AddShader и проверим, что в первой строке есть Shader "Hidden/AddShader". Давайте создадим очень простой шейдер эффекта изображения, который мы применим для сложения нескольких результатов. Затем заменим функцию frag следующими строками: После Cull Off ZWrite Off ZTest Always добавим Blend SrcAlpha OneMinusSrcAlpha, чтобы включить альфа-смешение.

float _Sample; float4 frag (v2f i) : SV_Target
{ return float4(tex2D(_MainTex, i.uv).rgb, 1.0f / (_Sample + 1.0f));
}

Сейчас этот шейдер будет просто отрисовывать первый сэмпл с непрозрачностью $1$, следующий с непрозрачностью $\frac{1}{2}$, затем $\frac{1}{3}$ и так далее, усредняя все сэмплы с равным весом.

В скрипте нам нужно считать сэмплы и применить шейдер эффекта изображения:

private uint _currentSample = 0;
private Material _addMaterial;

Также при перестройке целевого рендера в InitRenderTexture нам нужно выполнять сброс _currentSamples = 0 и добавить функцию Update, распознающую изменение преобразований камеры:

private void Update()
{ if (transform.hasChanged) { _currentSample = 0; transform.hasChanged = false; }
}

Чтобы использовать наш шейдер, мы должны инициализировать материал, сообщить ему о текущем сэмпле и использовать его для вставки на экран в функции Render:

// Blit the result texture to the screen
if (_addMaterial == null) _addMaterial = new Material(Shader.Find("Hidden/AddShader"));
_addMaterial.SetFloat("_Sample", _currentSample);
Graphics.Blit(_target, destination, _addMaterial);
_currentSample++;

Итак, мы уже выполняем прогрессивное сэмплирование, но по-прежнему используем центр пикселя. В compute shader зададим float2 _PixelOffset и используем его в CSMain вместо жёстко заданного смещения float2(0.5f, 0.5f). Вернёмся в скрипт и создадим случайное смещение, добавив к SetShaderParameters следующую строку:

RayTracingShader.SetVector("_PixelOffset", new Vector2(Random.value, Random.value));

Если перемещать камеру, то можно увидеть, что на изображении всё ещё видны искажения, но они быстро исчезают, если постоять неподвижно пару кадров. Вот сравнение того, что мы сделали:

Фундамент для нашего трассировщика лучей уже готов, поэтому мы можем приступить к сложным вещам, которые на самом деле выделяют трассировку лучей на фоне других техник рендеринга. Первыми в этом списке идут идеальные отражения. Идея проста: когда мы сталкиваемся с поверхностью, мы отражаем луч в соответствии с законом отражения, который вы, возможно, помните из школы (угол падения = угол отражения), снижаем его энергию и повторяем процесс, пока луч или не столкнётся с небом, или у него не закончится энергия после заданного количества отражений.

0f, 1. В шейдере добавьте к лучу переменную float3 energy и инициализируйте её в функции CreateRay как ray.energy = float3(1. 0f). 0f, 1. Изначально луч будет иметь максимальные значения во всех цветовых каналах, которые при каждом отражении будут снижаться.

Для примера представьте, что луч был отражён один раз и потерял $\frac{3}{4}$ своей энергии. Мы будем выполнять максимум 8 трассировок (исходный луч плюс 7 отражений), и складывать результаты в вызовах функции Shade, но умноженные на энергию луча. Измените CSMain следующим образом, заменив предыдущие вызовы Trace и Shade: Затем он продолжает двигаться и сталкивается с небом, поэтому мы переносим в пиксель только $\frac{1}{4}$ энергии неба.

// Trace and shade
float3 result = float3(0, 0, 0);
for (int i = 0; i < 8; i++)
{ RayHit hit = Trace(ray); result += ray.energy * Shade(ray, hit); if (!any(ray.energy)) break;
}

Наша функция Shade теперь также выполняет обновление энергии и генерирование отражённого луча, поэтому именно здесь становится важным inout. Для обновления энергии мы выполняем поэлементное умножение на отражённый цвет поверхности. Например, для золота коэффициент зеркального отражения примерно равен float3(1.0f, 0.78f, 0.34f), то есть оно отражает 100% красного цвета, 78% зелёного цвета и всего 34% синего цвета, придавая отражению характерный золотистый оттенок. Будьте внимательны, ни одно из этих значений не должно превышать 1, потому что иначе энергия у нас будет создаваться из ниоткуда. Кроме того, отражаемость часто ниже, чем можно подумать. Например, см. некоторые значения на слайде 64 в статье Physics and Math of Shading Нэти Хофмана.

Из-за неточности чисел с плавающей запятой может произойти так, что отражённый луч блокируется поверхностью, от которой он отражается. В HLSL есть встроенная функция для отражения луча с заданной нормалью, и это удобно. Вот как выглядит новая функция Shade: Чтобы избежать этого, мы немного сместим позицию вдоль направления нормали.

float3 Shade(inout Ray ray, RayHit hit)
{ if (hit.distance < 1.#INF) { float3 specular = float3(0.6f, 0.6f, 0.6f); // Reflect the ray and multiply energy with specular reflection ray.origin = hit.position + hit.normal * 0.001f; ray.direction = reflect(ray.direction, hit.normal); ray.energy *= specular; // Return nothing return float3(0.0f, 0.0f, 0.0f); } else { // Erase the ray's energy - the sky doesn't reflect anything ray.energy = 0.0f; // Sample the skybox and write it float theta = acos(ray.direction.y) / -PI; float phi = atan2(ray.direction.x, -ray.direction.z) / -PI * 0.5f; return _SkyboxTexture.SampleLevel(sampler_SkyboxTexture, float2(phi, theta), 0).xyz; }
}

Можно попробовать немного увеличить яркость скайбокса, умножив её на коэффициент больше 1. Теперь поэкспериментируйте с функцией Trace. Поместите в цикл несколько сфер и результат будет таким:

Итак, мы можем трассировать зеркальные отражения, что позволяет нам рендерить гладкие металлические поверхности, но для неметаллических поверхностей нам нужно ещё одно свойство: рассеянное отражение. Если вкратце, то металлы только отражают падающий свет с оттенком своего цвета отражения, а неметаллы позволяют свету преломиться на поверхности, рассеяться и покинуть её в случайном направлении, окрашенным в цвет своего albedo. В случае идеальной ламбертовой поверхности, которую обычно используют, вероятность пропорциональна косинусу угла между вышеуказанным направлением и нормалью поверхности. Более подробно эта тема рассмотрена здесь.

Также может потребоваться распознавать изменения преобразований источника освещения в функции Update, как мы делали это с преобразованиями камеры. Чтобы приступить к рассеянному освещению, давайте добавим к RayTracingMaster public Light DirectionalLight и зададим в сцене направленный источник освещения. Теперь добавьте в функцию SetShaderParameters следующие строки:

Vector3 l = DirectionalLight.transform.forward;
RayTracingShader.SetVector("_DirectionalLight", new Vector4(l.x, l.y, l.z, DirectionalLight.intensity));

В шейдере определите float4 _DirectionalLight. В функции Shade определите цвет albedo сразу после цвета specular:

float3 albedo = float3(0.8f, 0.8f, 0.8f);

Замените возвращаемые чёрные значения на простое рассеянное затенение:

// Return a diffuse-shaded color
return saturate(dot(hit.normal, _DirectionalLight.xyz) * -1) * _DirectionalLight.w * albedo;

Не забывайте, что скалярное произведение определяется как $a \cdot b = ||a||\ ||b|| \cos \theta$. Так как оба наших вектора (нормаль и направление света) имеют единичную длину, нам нужно именно скалярное произведение: косинус угла. Луч и свет имеют противоположные направления, поэтому при прямом освещении скалярное произведение возвращает не 1, а -1. Чтобы учесть это, мы должны сменить знак. Наконец, мы насыщаем это значение (например, ограничиваем его в интервале $[0,1]$), чтобы избежать отрицательной энергии.

Он начинается с рассматриваемой позиции поверхности (тоже с очень небольшим смещением, чтобы избежать самозатенения), и указывает в направлении, откуда пришёл свет. Чтобы направленный источник освещения отбрасывал тени, нам нужно трассировать луч тени. Добавьте эти строки над возвратом рассеянного цвета: Если что-то блокирует его путь к бесконечности, то мы не будем использовать рассеянное освещение.

// Shadow test ray
bool shadow = false;
Ray shadowRay = CreateRay(hit.position + hit.normal * 0.001f, -1 * _DirectionalLight.xyz);
RayHit shadowHit = Trace(shadowRay);
if (shadowHit.distance != 1.#INF)
{ return float3(0.0f, 0.0f, 0.0f);
}

Теперь мы можем трассировать глянцевые пластмассовые сферы с резкими тенями! Если задать 0.04 для specular и 0.8 для albedo, то мы получим следующие результаты:

Давайте теперь приступим к созданию более сложных и разноцветных сцен! Вместо жёсткого задания всего в шейдере мы для большей универсальности зададим сцену на C#.

Вместо глобального задания свойств материалов в функции Shade, мы будем определять их для каждого объекта и хранить их в RayHit. Для начала мы расширим структуру RayHit в шейдере. 0f, 0. Добавьте в struct float3 albedo и float3 specular и инициализируйте их со значениями float3(0. 0f) в CreateRayHit. 0f, 0. Также измените функцию Shade так, чтобы она использовала из hit вместо жёстко заданных значений эти значения.<

Со стороны шейдера это выглядит так: Чтобы понять в целом, чем является сфера в CPU и GPU, зададим struct Sphere в шейдере и в скрипте на C#.

struct Sphere
{ float3 position; float radius; float3 albedo; float3 specular;
};

Скопируйте эту структуру в скрипт на C#.

Это сделать просто: В шейдере нам нужно сделать так, чтобы функция IntersectSphere работала с нашим struct, а не с float4.

void IntersectSphere(Ray ray, inout RayHit bestHit, Sphere sphere)
{ // Calculate distance along the ray where the sphere is intersected float3 d = ray.origin - sphere.position; float p1 = -dot(ray.direction, d); float p2sqr = p1 * p1 - dot(d, d) + sphere.radius * sphere.radius; if (p2sqr < 0) return; float p2 = sqrt(p2sqr); float t = p1 - p2 > 0 ? p1 - p2 : p1 + p2; if (t > 0 && t < bestHit.distance) { bestHit.distance = t; bestHit.position = ray.origin + t * ray.direction; bestHit.normal = normalize(bestHit.position - sphere.position); bestHit.albedo = sphere.albedo; bestHit.specular = sphere.specular; }
}

Также задайте bestHit.albedo и bestHit.specular в функции IntersectGroundPlane, чтобы настроить её материал.

В этом месте CPU будет хранить все сферы, из которых состоит сцена. Затем определите StructuredBuffer<Sphere> _Spheres. Удалите все жёстко заданные сферы из функции Trace и добавьте следующие строки:

// Trace spheres
uint numSpheres, stride;
_Spheres.GetDimensions(numSpheres, stride);
for (uint i = 0; i < numSpheres; i++) IntersectSphere(ray, bestHit, _Spheres[i]);

Теперь мы вдохнём в сцену немного жизни. Давайте добавим в скрипт на C# общие параметры для управления расположением сфер и буфером compute:

public Vector2 SphereRadius = new Vector2(3.0f, 8.0f);
public uint SpheresMax = 100;
public float SpherePlacementRadius = 100.0f;
private ComputeBuffer _sphereBuffer;

Мы будем настраивать сцену в OnEnable и освобождать буфер в OnDisable. Таким образом, при каждом включении компонента будет генерироваться случайная сцена. Функция SetUpScene будет пытаться позиционировать сферы в определённом радиусе и отбрасывать те из них, которые пересекают уже имеющиеся. Половина сфер металлическая (чёрный albedo, цветной specular), другая половина — неметаллическая (цветной albedo, 4% specular):

private void OnEnable()
{ _currentSample = 0; SetUpScene();
} private void OnDisable()
{ if (_sphereBuffer != null) _sphereBuffer.Release();
} private void SetUpScene()
{ List<Sphere> spheres = new List<Sphere>(); // Add a number of random spheres for (int i = 0; i < SpheresMax; i++) { Sphere sphere = new Sphere(); // Radius and radius sphere.radius = SphereRadius.x + Random.value * (SphereRadius.y - SphereRadius.x); Vector2 randomPos = Random.insideUnitCircle * SpherePlacementRadius; sphere.position = new Vector3(randomPos.x, sphere.radius, randomPos.y); // Reject spheres that are intersecting others foreach (Sphere other in spheres) { float minDist = sphere.radius + other.radius; if (Vector3.SqrMagnitude(sphere.position - other.position) < minDist * minDist) goto SkipSphere; } // Albedo and specular color Color color = Random.ColorHSV(); bool metal = Random.value < 0.5f; sphere.albedo = metal ? Vector3.zero : new Vector3(color.r, color.g, color.b); sphere.specular = metal ? new Vector3(color.r, color.g, color.b) : Vector3.one * 0.04f; // Add the sphere to the list spheres.Add(sphere); SkipSphere: continue; } // Assign to compute buffer _sphereBuffer = new ComputeBuffer(spheres.Count, 40); _sphereBuffer.SetData(spheres);
}

Магическое число 40 в new ComputeBuffer(spheres.Count, 40) — это шаг нашего буфера, т.е. размер одной сферы в памяти в байтах. Чтобы вычислить его, подсчитаем количество float в struct Sphere и умножим его на байтовый размер float (4 байта). Наконец зададим буфер шейдера в функции SetShaderParameters:

RayTracingShader.SetBuffer(0, "_Spheres", _sphereBuffer);

Поздравляю, у нас получилось! Теперь у нас есть готовый трассировщик лучей Уиттеда на GPU, способный рендерить множество сфер с зеркальными отражениями, простым рассеянным освещением и резкими тенями. Полный исходный код выложен на Bitbucket. Поэкспериментируйте с параметрами размещения сфер и наблюдайте за красивыми видами:

Сегодня мы многого добились, но можно реализовать ещё многое: рассеянное глобальное освещение, мягкие тени, частично прозрачные материалы с преломлениями и, очевидно, применение вместо сфер треугольников. В следующей статье мы расширим наш трассировщик лучей Уиттеда в трассировщик путей (path tracer), чтобы освоить некоторые из перечисленных явлений.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть