Хабрахабр

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

image

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

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

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

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

Подготовка

Несколько предыдущих проектов в 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, которые не меняются, все это Просто Работает.) Вместо этого я буду растягивать четырёхугольники частиц на основании их скорости, что в свою очередь растягивает шарики глубины, делая их не сферическими, а эллиптическими.

Stretched Billboard выполняет безусловное растягивание вдоль вектора скорости в пространстве мира. Опытные пользователи Unity могут задаться вопросом — почему я просто не использую встроенный режим Stretched Billboard, имеющийся в системе частиц Unity. 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 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») предлагается использовать шум Перлина.

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

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

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

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

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

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

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

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

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

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

Шейдинг

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

Я решил освещать воду только светом солнца и скайбоксом; поддержка дополнительных источников освещения требует или нескольких проходов (это расточительно!) или построения структуры поиска освещения на стороне GPU (затратно и довольно сложно). Здесь мы сталкиваемся со множеством ограничений рендеринга Unity. Можно прикрепить к источнику солнечного света буфер команд, чтобы создать карту теней экранного пространства специально для воды, но пока я этого не сделал. Кроме того, поскольку 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 точку для реконструкции нормали. После того, как мы получили способ реконструкции позиций, с нормалями всё обстоит проще: вычисляем позицию соседних точек в буфере глубин и строим по ним касательный базис. Этот способ работает на удивление хорошо и вызывает проблемы только в случае очень тонких объектов.

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

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

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

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

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

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

Для корректного преломления требуется трассировка лучей (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);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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