Рендеринг воды в экранном пространстве

в 8:28, , рубрики: fluid simulation, Physically Based Shading, unity, unity3d, разработка игр
image

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

Мне не очень нравится подход с voxelized / marching cubes при рендеринге воды (см. например, рендеринг симуляции жидкости в Blender). Когда объём воды находится в том же масштабе, что и используемая для рендеринга сетка, движение получается заметно дискретным. Эту проблему можно решить, увеличив разрешение сетки, но для тонких струй на относительно длинные расстояния в реальном времени это просто непрактично, потому что сильно влияет на время выполнения и занимаемую память. (Есть прецедент использования разреженных воксельных структур, улучшающий ситуацию. Но я не уверен, насколько хорошо это работает для динамических систем. Кроме того, это это не тот уровень сложности, с которым я бы хотел работать.)

Первой альтернативой, которую я исследовал, были меши экранного пространства Мюллера (Müller’s Screen Space Meshes). В них используется рендеринг частиц воды в буфер глубин, его сглаживание, распознавание соединённых фрагментов похожей глубины и построение из результата меша с помощью marching squares. Сегодня этот способ, вероятно, уже стал более применимым, чем в 2007 году (поскольку теперь мы можем создавать меш в compute-шейдере), но он всё равно связан с бОльшим уровнем сложности и затрат, чем бы мне хотелось.

В конце концов я нашёл презентацию Саймона Грина с GDC 2010 «Screen Space Fluid Rendering For Games». Она начинается точно так же, как и Screen Space Meshes: с рендеринга частиц в буфер глубин и его сглаживания. Но вместо построения меша получившийся буфер используется для затенения и композитинга жидкости в основной сцене (с помощью записи глубины явным образом.) Именно такую систему я и решил реализовать.

Подготовка

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

Такая схема означает, что я могу рендерить в слое жидкости почти всё, что угодно, и это будет выглядеть так, как я этого ожидаю. В контексте моей демо-сцены это означает, что несколько струй и брызг от субэмиттеров могут сливаться вместе. Кроме того, это позволит примешивать другие системы воды, например, объёмы на основе полей высот, которые можно будет затем рендерить одинаково. (Пока я это не тестировал.)

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

Рендеринг буфера жидкости

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

Вычисления глубины и ширины достаточно просты:

frag_out o;

float3 N;
N.xy = i.uv*2.0 - 1.0;
float r2 = dot(N.xy, N.xy);
if (r2 > 1.0) discard;

N.z = sqrt(1.0 - r2);

float4 pixel_pos = float4(i.view_pos + N * i.size, 1.0);
float4 clip_pos = mul(UNITY_MATRIX_P, pixel_pos);
float depth = clip_pos.z / clip_pos.w;

o.depth = depth;

float thick = N.z * i.size * 2;

(Разумеется, вычисления глубины можно упростить; из clip position нам нужны только z и w.)

Немного позже мы вернёмся к фрагментному шейдеру ради векторов движения и шума.

Самое веселье начинается в вершинном шейдере, и именно здесь я отклоняюсь от техники Грина. Цель этого проекта — рендеринг высокоскоростных струй воды; его возможно реализовать с помощью сферических частиц, но для создания непрерывной струи потребуется огромное их количество. Вместо этого я буду растягивать четырёхугольники частиц на основании их скорости, что в свою очередь растягивает шарики глубины, делая их не сферическими, а эллиптическими. (Поскольку вычисления глубины основаны на UV, которые не меняются, все это Просто Работает.)

Опытные пользователи Unity могут задаться вопросом — почему я просто не использую встроенный режим Stretched Billboard, имеющийся в системе частиц Unity. Stretched Billboard выполняет безусловное растягивание вдоль вектора скорости в пространстве мира. В общем случае это вполне подходит, однако приводит к очень заметной проблеме, когда вектор скорости сонаправлен с направленным вперёд вектором камеры (или очень близок к нему). Billboard растягивает на экране, что делает очень заметной его двухмерную природу.

Вместо этого я использую направленный на камеру билборд и проецирую вектор скорости на плоскость частицы, используя его для растягивания четырёхугольника. Если вектор скорости перпендикулярен к плоскости (направлен в экран или от него), то частица остаётся нерастянутой и сферической, как и должна, а когда он наклонён, частица растягивается в этом направлении, что нам и нужно.

Оставим долгие объяснения, вот достаточно простая функция:

float3 ComputeStretchedVertex(float3 p_world, float3 c_world, float3 vdir_world, float stretch_amount)
{
    float3 center_offset = p_world - c_world;
    float3 stretch_offset = dot(center_offset, vdir_world) * vdir_world;

    return p_world + stretch_offset * lerp(0.25f, 3.0f, stretch_amount);
}

Для вычисления вектора движения экранного пространства мы вычисляем два множества позиций векторов:

float3 vp1 = ComputeStretchedVertex(
    vertex_wp,
    center_wp, 
    velocity_dir_w, rand);
float3 vp0 = ComputeStretchedVertex(
    vertex_wp - velocity_w * unity_DeltaTime.x,
    center_wp - velocity_w * unity_DeltaTime.x,
    velocity_dir_w, rand);

o.motion_0 = mul(_LastVP, float4(vp0, 1.0));
o.motion_1 = mul(_CurrVP, float4(vp1, 1.0));

Заметьте, что поскольку мы вычисляем векторы движения в основном проходе, а не в проходе векторов скорости, Unity не предоставляет нам предыдущую или неискажённую текущую проекцию из вида. Чтобы исправить это, я добавил к соответствующим системам частиц простой скрипт:

public class ScreenspaceLiquidRenderer : MonoBehaviour 
{
    public Camera LiquidCamera;

    private ParticleSystemRenderer m_ParticleRenderer;
    private bool m_First;
    private Matrix4x4 m_PreviousVP;

    void Start()
    {
        m_ParticleRenderer = GetComponent();
        m_First = true;
    }

    void OnWillRenderObject()
    {
        Matrix4x4 current_vp = LiquidCamera.nonJitteredProjectionMatrix * LiquidCamera.worldToCameraMatrix;
        if (m_First)
        {
            m_PreviousVP = current_vp;
            m_First = false;
        }
        m_ParticleRenderer.material.SetMatrix("_LastVP", GL.GetGPUProjectionMatrix(m_PreviousVP, true));
        m_ParticleRenderer.material.SetMatrix("_CurrVP", GL.GetGPUProjectionMatrix(current_vp, true));
        m_PreviousVP = current_vp;
    }
}

Я кэширую предыдущую матрицу вручную, потому что Camera.previousViewProjectionMatrix даёт некорректные результаты.

¯_(ツ)_/¯

(Также этот метод нарушает батчинг рендеринга; возможно, на практике будет разумнее задавать глобальные матричные константы, а не использовать их для каждого материала.)

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

float3 hp0 = i.motion_0.xyz / i.motion_0.w;
float3 hp1 = i.motion_1.xyz / i.motion_1.w;

float2 vp0 = (hp0.xy + 1) / 2;
float2 vp1 = (hp1.xy + 1) / 2;

#if UNITY_UV_STARTS_AT_TOP
vp0.y = 1.0 - vp0.y;
vp1.y = 1.0 - vp1.y;
#endif

float2 vel = vp1 - vp0;

(Вычисления векторов движения почти без изменений взяты из https://github.com/keijiro/ParticleMotionVector/blob/master/Assets/ParticleMotionVector/Shaders/Motion.cginc)

Наконец, последнее значение в буфере жидкости — это шум. Я использую стабильное случайное число для каждой частицы для выбора одного из четырёх шумов (упакованных в единую текстуру). Затем он масштабируется на скорость и единицу минус размер частицы (поэтому быстрые и мелкие частицы шумнее). Это значение шума используется в проходе шейдинга для искажения нормалей и добавления слоя пены. В работе Грина используется трёхканальный белый шум, но в более новой работе («Screen Space Fluid Rendering with Curvature Flow») предлагается использовать шум Перлина. Я использую шум Вороного/клеточный шум с разными масштабами:

Рендеринг воды в экранном пространстве - 2

Проблемы смешивания (и способы их обхода)

И здесь появляются первые проблемы моей реализации. Для правильного вычисления толщины частицы смешиваются аддитивно. Поскольку смешивание влияет на все выходные данные, это значит, что шум и векторы движения тоже примешиваются аддитивно. Аддитивный шум вполне нас устраивает, но не аддитивные векторы, а если оставить их как есть, то получаются отвратительный временной антиалиасинг (TAA) и motion blur. Чтобы решить эту проблему, я при рендеринге буфера жидкости просто умножаю векторы движения на толщину и делю на общую толщину в проходе шейдинга. Это даёт нам взвешенный усреднённый вектор движения для всех накладывающихся частиц; не совсем то, что нам нужно (создаются странные артефакты при пересечении нескольких струй), но вполне приемлемо.

Более сложная проблема — это глубина; для правильного рендеринга буфера глубин нам нужно, чтобы одновременно были активны запись глубин и проверка глубин. Это может вызывать проблемы, если частицы не отсортированы (поскольку разница в порядке рендеринга может приводить к тому, что выходные данные частиц, перекрытых другими, будут отсекаться). Поэтому мы приказываем системе частиц Unity сортировать частицы по глубине, а потом скрещиваем пальцы и надеемся. что системы тоже будут рендериться по глубине. У нас *будут* случаи наложения систем (например, пересечение двух струй частиц), обрабатываемые неправильно, что приведёт к меньшей толщине. Но такое происходит не очень часто, и не сильно влияет на внешний вид.

Скорее всего, правильный подход заключался бы в полностью раздельном рендеринге буферов глубин и цветов; расплатой за это будет рендеринг в два прохода. Стоит изучить этот вопрос при настройке системы.

Сглаживание глубин

Наконец, самое важное в технике Грина. Мы отрендерили в буфер глубин кучу сферических шариков, но в реальности вода не состоит из «шариков». Поэтому теперь мы возьмём эту аппроксимацию и выполним размытие, чтобы она больше напоминала поверхность жидкости.

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

Здесь возникает только одна проблема: такие изменения делают размытие неразделяемым. Разделяемое размытие можно выполнять в два прохода: размывать горизонтально, а потом вертикально. Неразделяемое размытие выполняется за один проход. Это различие важно, потому что разделяемое размытие масштабируется линейно (O(w) + O(h)), а неразделяемое масштабируется квадратично (O(w*h)). Крупномасштабное неразделяемое размытие быстро становится неприменимым на практике.

Как взрослые, ответственные разработчики, мы можем сделать очевидный ход: закрыть глаза, притвориться, что двусторонний шум *является* разделяемым, и всё равно реализовать его с отдельными горизонтальным и вертикальным проходами.

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

Шейдинг

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

Здесь мы сталкиваемся со множеством ограничений рендеринга Unity. Я решил освещать воду только светом солнца и скайбоксом; поддержка дополнительных источников освещения требует или нескольких проходов (это расточительно!) или построения структуры поиска освещения на стороне GPU (затратно и довольно сложно). Кроме того, поскольку Unity не предоставляет доступа к картам теней, а направленные источники освещения (directional lights) используют тени экранного пространства (на основании буфера глубин, отрендеренного непрозрачной геометрией), у нас нет доступа к информации о тенях от источника солнечного света. Можно прикрепить к источнику солнечного света буфер команд, чтобы создать карту теней экранного пространства специально для воды, но пока я этого не сделал.

Последний этап шейдинга управляется через скрипт, а для отправки вызовов отрисовки использует буфер команд. Это обязательно, потому что текстуру векторов движения (используемую для временного антиалиасинга (TAA) и для motion blur) невозможно использовать для прямого рендеринга с помощью Graphics.SetRenderTarget(). В скрипте, прикреплённом к основной камере, напишем следующее:

void Start() 
{
    //...
    
    m_QuadMesh = new Mesh();
    m_QuadMesh.subMeshCount = 1;
    m_QuadMesh.vertices = new Vector3[] { 
        new Vector3(0, 0, 0.1f),
        new Vector3(1, 0, 0.1f),
        new Vector3(1, 1, 0.1f),
        new Vector3(0, 1, 0.1f),
    };
    m_QuadMesh.uv = new Vector2[] {
        new Vector2(0, 0),
        new Vector2(1, 0),
        new Vector2(1, 1),
        new Vector2(0, 1),
    };
    m_QuadMesh.triangles = new int[] {
        0, 1, 2, 0, 2, 3,
    };
    m_QuadMesh.UploadMeshData(false);

    m_CommandBuffer = new CommandBuffer();
    m_CommandBuffer.Clear();
    m_CommandBuffer.SetProjectionMatrix(
        GL.GetGPUProjectionMatrix(
            Matrix4x4.Ortho(0, 1, 0, 1, -1, 100), false));
    
    m_CommandBuffer.SetRenderTarget(
        BuiltinRenderTextureType.CameraTarget, 
        BuiltinRenderTextureType.CameraTarget);
    m_CommandBuffer.DrawMesh(
        m_QuadMesh, Matrix4x4.identity, m_Mat, 0, 
        m_Mat.FindPass("LIQUIDCOMPOSITE"));

    m_CommandBuffer.SetRenderTarget(
        BuiltinRenderTextureType.MotionVectors, 
        BuiltinRenderTextureType.Depth);
    m_CommandBuffer.DrawMesh(
        m_QuadMesh, Matrix4x4.identity, m_Mat, 0, 
        m_Mat.FindPass("MOTION"));   
}

Буферы цвета и векторов движения невозможно одновременно рендерить с помощью MRT (multi render targets). Причину мне выяснить не удалось. Кроме того, они требуют привязки к разным буферам глубин. К счастью, мы записываем глубину в оба эти буфера глубин, поэтому повторное проецирование временного антиалиасинга работает нормально (о, это удовольствие от работы с движком-«чёрным ящиком»).

В каждом кадре мы выбрасываем из OnPostRender() композитный рендер:

RenderTexture GenerateRefractionTexture()
{
    RenderTexture result = RenderTexture.GetTemporary(m_MainCamera.activeTexture.descriptor);
    Graphics.Blit(m_MainCamera.activeTexture, result);
    return result;
}

void OnPostRender()
{
    if (ScreenspaceLiquidCamera && ScreenspaceLiquidCamera.IsReady())
    {
        RenderTexture refraction_texture = GenerateRefractionTexture();

        m_Mat.SetTexture("_MainTex", ScreenspaceLiquidCamera.GetColorBuffer());
        m_Mat.SetVector("_MainTex_TexelSize", ScreenspaceLiquidCamera.GetTexelSize());
        m_Mat.SetTexture("_LiquidRefractTexture", refraction_texture);
        m_Mat.SetTexture("_MainDepth", ScreenspaceLiquidCamera.GetDepthBuffer());
        m_Mat.SetMatrix("_DepthViewFromClip", ScreenspaceLiquidCamera.GetProjection().inverse);
        if (SunLight)
        {
            m_Mat.SetVector("_SunDir", transform.InverseTransformVector(-SunLight.transform.forward));
            m_Mat.SetColor("_SunColor", SunLight.color * SunLight.intensity);
        }
        else
        {
            m_Mat.SetVector("_SunDir", transform.InverseTransformVector(new Vector3(0, 1, 0)));
            m_Mat.SetColor("_SunColor", Color.white);
        }
        m_Mat.SetTexture("_ReflectionProbe", ReflectionProbe.defaultTexture);
        m_Mat.SetVector("_ReflectionProbe_HDR", ReflectionProbe.defaultTextureHDRDecodeValues);

        Graphics.ExecuteCommandBuffer(m_CommandBuffer);

        RenderTexture.ReleaseTemporary(refraction_texture);
    }
}

И на этом участие ЦП заканчивается, позже идут только шейдеры.

Давайте начнём с прохода векторов движения. Вот как выглядит весь шейдер:

#include "UnityCG.cginc"

sampler2D _MainDepth;
sampler2D _MainTex;

struct appdata
{
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
};

struct v2f
{
    float2 uv : TEXCOORD0;
    float4 vertex : SV_POSITION;
};

v2f vert(appdata v)
{
    v2f o;
    o.vertex = mul(UNITY_MATRIX_P, v.vertex);
    o.uv = v.uv;
    return o;
}

struct frag_out
{
    float4 color : SV_Target;
    float depth : SV_Depth;
};

frag_out frag(v2f i)
{
    frag_out o;

    float4 fluid = tex2D(_MainTex, i.uv);
    if (fluid.a == 0) discard;
    o.depth = tex2D(_MainDepth, i.uv).r;

    float2 vel = fluid.gb / fluid.a;

    o.color = float4(vel, 0, 1);
    return o;
}

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

Стоит заметить, что при работе с бОльшими объёмами воды может потребоваться другой способ обработки буфера скоростей. Поскольку мы выполняем рендеринг без смешивания, векторы движения для всего за водой теряются, разрушая TAA и motion blur этих объектов. При работе с тонкими струйками воды это не проблема, но может мешать при работе с бассейном или озером, когда нам нужно, чтобы TAA или motion blur объектов были чётко видны сквозь поверхность.

Более интересен основной проход шейдинга. Нашей первоочередной задачей после маскировки с помощью толщины жидкости является реконструирование позиции и нормали пространства обзора (view space).

float3 ViewPosition(float2 uv)
{
    float clip_z = tex2D(_MainDepth, uv).r;
    float clip_x = uv.x * 2.0 - 1.0;
    float clip_y = 1.0 - uv.y * 2.0;

    float4 clip_p = float4(clip_x, clip_y, clip_z, 1.0);
    float4 view_p = mul(_DepthViewFromClip, clip_p);
    return (view_p.xyz / view_p.w);
}

float3 ReconstructNormal(float2 uv, float3 vp11)
{
    float3 vp12 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(0, 1));
    float3 vp10 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(0, -1));
    float3 vp21 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(1, 0));
    float3 vp01 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(-1, 0));

    float3 dvpdx0 = vp11 - vp12;
    float3 dvpdx1 = vp10 - vp11;

    float3 dvpdy0 = vp11 - vp21;
    float3 dvpdy1 = vp01 - vp11;

    // Pick the closest
    float3 dvpdx = dot(dvpdx0, dvpdx0) > dot(dvpdx1, dvpdx1) ? dvpdx1 : dvpdx0;
    float3 dvpdy = dot(dvpdy0, dvpdy0) > dot(dvpdy1, dvpdy1) ? dvpdy1 : dvpdy0;

    return normalize(cross(dvpdy, dvpdx));
}

Это затратный способ реконструкции позиции пространства обзора: мы берём позицию в пространстве отсечения (clip space) и выполняем операцию, обратную проецированию.

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

Это означает, что мы выполняем на пиксель пять отдельных обратных проецированию операций (для текущей точки и четырёх соседних). Существует и менее затратный способ, но этот пост уже и так слишком длинный, поэтому оставлю это на потом.

Получающиеся нормали:

Рендеринг воды в экранном пространстве - 3

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

N.xy += NoiseDerivatives(i.uv, fluid.r) * (_NoiseStrength / fluid.a);
N = normalize(N);

Мы наконец можем приступить к самому шейдингу. Шейдинг воды состоит из трёх основных частей: зеркального отражения (specular reflection), зеркального преломления (specular refraction) и пены.

Отражение — это стандартный GGX, целиком взятый из стандартного шейдера Unity. (С одним исправлением — для воды используется правильный F0, равный 2%.)

С преломлением всё более интересно. Для корректного преломления требуется трассировка лучей (raytracing) (или raymarching для приближенного результата). К счастью, преломление менее интуитивно понятно для глаза, чем отражение, а потому неправильные результаты не так заметны. Поэтому мы смещаем сэмпл UV для текстуры преломления на x и y нормали, отмасштабированные на толщину и параметр силы:

float aspect = _MainTex_TexelSize.y * _MainTex_TexelSize.z;
float2 refract_uv = (i.grab_pos.xy + N.xy * float2(1, -aspect) * fluid.a * _RefractionMultiplier) / i.grab_pos.w;
float4 refract_color = tex2D(_LiquidRefractTexture, refract_uv);

(Заметьте, что используется коррекция соотношений; она необязательна — в конце концов, это всего лишь приближение, но добавить её достаточно просто.)

Этот преломлённый свет проходит сквозь жидкость, поэтому часть его поглощается:

float3 water_color = _AbsorptionColor.rgb * _AbsorptionIntensity;
refract_color.rgb *= exp(-water_color * fluid.a);

Заметьте, что _AbsorptionColor определяется ровно противоположным ожидаемому способом: значения каждого канала обозначают величину поглощаемого, а не пропускаемого света. Поэтому _AbsorptionColor со значением (1, 0, 0) даёт не красный, а бирюзовый цвет (teal).

Отражение и преломление смешиваются с помощью френелевских коэффициентов:

float spec_blend = lerp(0.02, 1.0, pow(1.0 - ldoth, 5));
float4 clear_color = lerp(refract_color, spec, spec_blend);

До этого момента мы играли по правилам (в основном) и использовали физический шейдинг.

Он вполне хорош, но у него есть проблема с водой. Её немного сложно увидеть:

Рендеринг воды в экранном пространстве - 4

Чтобы устранить её, давайте добавим немного пены.

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

float3 foam_color = _SunColor * saturate((dot(N, L)*0.25f + 0.25f));

Он прибавляется к окончательному цвету с помощью специального множителя, зависящего от шума жидкости и смягчённого френелевского коэффициента:

float foam_blend = saturate(fluid.r * _NoiseStrength) * lerp(0.05f, 0.5f, pow(1.0f - ndotv, 3));
clear_color.rgb += foam_color * saturate(foam_blend);

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

Но в целом всё выглядит хорошо и делает струю более заметной:

Рендеринг воды в экранном пространстве - 5

Дальнейшая работа и усовершенствования

В созданной системе можно многое улучшить.

  • Использование нескольких цветов. На текущий момент поглощение вычисляется только на последнем этапе шейдинга и использует для всей жидкости на экране постоянный цвет и яркость. Поддержка разных цветов возможна, но требует второго буфера цвета и решения интеграла поглощения для каждой частицы в процессе рендеринга базового буфера жидкости. Потенциально это может оказаться затратной операцией.
  • Полное освещение. Имея доступ к структуре поиска освещения на стороне GPU (или построенной вручную, или благодаря привязке к новому конвейеру HD-рендеринга Unity), мы сможем правильно освещать воду произвольным количеством источников освещения и создавать правильное окружающее освещение.
  • Улучшенное преломление. С помощью размытых mip-текстур фоновой текстуры мы сможем лучше симулировать преломление для грубых поверхностей. На практике это не очень полезно для небольших струй жидкости, но может пригодиться для бОльших объёмов.

Будь у меня возможность, я бы улучшал эту систему до потери пульса, но на данный момент её можно назвать завершённой.

Автор: PatientZero

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js