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

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

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

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

44bfe4fdc9a29b74a393a921f846710f.png


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

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

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

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


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

using UnityEngine;

public class RayTracingMaster : MonoBehaviour
{
    public ComputeShader RayTracingShader;

    private RenderTexture _target;

    private void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        Render(destination);
    }

    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 пикселей. В конце мы запишем результат на экран с помощью Graphics.Blit.

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


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

private Camera _camera;

private void Awake()
{
    _camera = GetComponent();
}

private void SetShaderParameters()
{
    RayTracingShader.SetMatrix("_CameraToWorld", _camera.cameraToWorldMatrix);
    RayTracingShader.SetMatrix("_CameraInverseProjection", _camera.projectionMatrix.inverse);
}


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

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

#pragma kernel CSMain

RWTexture2D 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);
}


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

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

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


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

Texture2D _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$ — достаточно простая задача. Однако мы рассматриваем только столкновения в положительном направлении луча и отбрасываем все столкновения, которые не ближе, чем потенциальное предыдущее столкновение.

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

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". После Cull Off ZWrite Off ZTest Always добавим Blend SrcAlpha OneMinusSrcAlpha, чтобы включить альфа-смешение. Затем заменим функцию frag следующими строками:

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));


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

1760097eafddf684da7b6e80e4264ac3.png


9b15106beb041c8abff7e94ffac9901c.png


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

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

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

// 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. Поместите в цикл несколько сфер и результат будет таким:

538dbcb1fbc9669f19a1a2df582e08a9.png


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

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

af5029f17bffce5f7489ff5e8420b26b.png


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

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

Затем определите StructuredBuffer _Spheres. В этом месте CPU будет хранить все сферы, из которых состоит сцена. Удалите все жёстко заданные сферы из функции 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 spheres = new List();

    // 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. Поэкспериментируйте с параметрами размещения сфер и наблюдайте за красивыми видами:

c774fc7bc40c87c6c93552481e3c514e.png


82391296a8247aec68737d4998b60e18.png


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

© Habrahabr.ru