
Глава 2: Шейдерное программирование
Во второй главе перейдём от теоретических основ освещения к практике его реализации через шейдерное программирование. Шейдеры — это сердце современной графики, программы, выполняющиеся непосредственно на графическом процессоре и определяющие внешний вид каждого пикселя. Мы разберём их основы, коснёмся создания материалов, визуальных эффектов и методов оптимизации.
2.1 Основы шейдеров
Повторим что такое шейдеры. Шейдеры — это небольшие программы, которые выполняются на графическом процессоре (GPU) и отвечают за то, как объекты информацию о которых загружается из CPU, выглядят на экране. Шейдеры являются неотъемлемой частью современного рендеринга в реальном времени, позволяя создавать от простого затенения до сложных фотореалистичных материалов и визуальных эффектов. Представим себе процесс рисования 3D-объекта на 2D-экране как конвейер. На входе у нас есть 3D-модель (или меш) которая состоит из вершин и индексов их нумерации на основе которых строится треугольная сетка, и на выходе получается цветное изображение. Шейдеры — это программируемые этапы этого конвейера, которые позволяют нам управлять тем, как геометрия модели преобразуется и как окрашиваются её пиксели. Существуют разные типы шейдеров, но два самых фундаментальных и обязательных для любого рендеринга — это вершинный и фрагментный (или пиксельный) шейдеры.
Вершинный шейдер (Vertex Shader):
Основная задача вершинного шейдера это — обработка вершин 3D модели. Вершинный шейдер выполняется один раз для каждой вершины объекта, которую мы хотим отрисовать. Его главная обязанность — преобразовать координаты каждой вершины из её локального пространства (как она была смоделирована) в пространство экрана (где она в итоге окажется). Ключевые операции вершинного шейдера: • Преобразование координат — это самая важная его функция. Используя матрицы такие как матрица модели, вида и проекции, вершинный шейдер вычисляет конечное положение вершины в 2D-пространстве экрана. Без этого шага GPU просто не будет знать, где рисовать объект. • Вершинный шейдер может принимать данные, связанные с вершиной (например, цвет вершины, её нормаль, текстурные координаты,тангенты, кости для анимаций и веса материалов для смешивания), и передавать их дальше по конвейеру, обычно фрагментному шейдеру. Например, вершинный шейдер может передать текстурные координаты, чтобы фрагментный шейдер знал, какую часть текстуры наложить на данный пиксель. • Манипуляции с геометрией: Вершинный шейдер также может изменять положение вершин для создания различных эффектов, таких как анимация, волны на воде или деформация объектов.
Пример простого вершинного шейдера (R7Engine, GLSL).
Фрагментный (пиксельный) шейдер (Pixel/Fragment Shader):
Основная задача фрагментного шейдера — это определение цвета каждого пикселя либо фрагмента. После того как вершинный шейдер обработал все вершины полигона (например, треугольника), GPU определяет, какие пиксели на экране покрывает этот полигон. Для каждого из этих пикселей запускается пиксельный шейдер. Ключевые операции пиксельного шейдера: • Главная и конечная цель — вернуть цвет (RGBA: красный, зелёный, синий, альфа/прозрачность) для текущего пикселя. Этот цвет может быть простым (например, сплошной красный), или результатом сложных вычислений. • Сэмплирование текстур: Пиксельный шейдер может считывать данные из текстур, используя текстурные координаты, полученные от вершинного шейдера. Это позволяет наносить изображение на поверхность модели. • Расчёт освещения: Шейдер может использовать информацию о нормалях поверхности, положении источников света и свойствах материала для расчёта того, как свет взаимодействует с поверхностью, создавая блики, тени и диффузное освещение. Также можно делать расчёт каскадных карт теней. • Создание эффектов: С помощью пиксельных шейдеров реализуются такие эффекты, как рельефное текстурирование, отражения, преломления, параллакс и многое другое.
Пример простого фрагментного шейдера (R7Engine, GLSL).
Также кратко нужно пройтись по остальным шейдерам, которые не являются обязательными, но без которых невозможно создать некоторые вещи.
Геометрический шейдер (Geometry shader):
Геометрический шейдер принимает на вход набор вершин, которые образуют один примитив, например, точку или треугольник. Затем геометрический шейдер может преобразовать эти вершины по своему усмотрению, прежде чем отправить их на следующий этап шейдера. Что делает геометрический шейдер интересным, так это то, что он способен преобразовывать оригинальную примитиву (набор вершин) в совершенно разные примитивы, возможно, генерируя больше вершин, чем было изначально дано.
Пример начала геометрического шейдера (R7Engine, GLSL).
Тесселяционные шейдеры (TesselationEvaluation и TesselationControl):
Тесселяционные типы это шейдеры связанные с тесселяцией — разбиением примитива на несколько меньших. Эти шейдеры необязательные, но их наличие необходимо для работы тесселяции. Например в R7Engine тесселяция используется для тайлового террейна (это когда создаётся множество плоскостей).
Вычислительные шейдеры (Compute Shader):
Вычислительные шейдеры это — вспомогательные шейдеры, которые не взаимодействуют с графическими объектами напрямую но могут получить информацию об 3D объекте через передачу SSBO. В основном эти шейдеры выполняют математические операции. Вычислительные шейдеры могут выполнять разные операции от оптимизации мирового пространства до симуляции света и различных эффектов. Но это только вычисления, после нам обязательно нужен будет фрагментный шейдер что бы использовать сэмплер текстуры с результатом для конечного цвета.
Пример начала вычислительного шейдера который отвечает за ssgi эффект (R7Engine, GLSL).
Для написания шейдеров используются специальные C-подобные языки. Два самых популярных — это GLSL и HLSL. • GLSL (OpenGL Shading Language): Используется с графическим API OpenGL и Vulkan, которыe является кроссплатформенным (Windows, macOS, Linux, мобильные устройства). • HLSL (High-Level Shading Language): Используется с Direct3D (часть DirectX), который в основном используется на платформах Microsoft (Windows, Xbox). Языки шейдеров строго типизированы и имеют типы данных, оптимизированные для графических вычислений. • Скалярные типы: float, int, bool. • Векторные типы: vec2, vec3, vec4 (векторы из 2, 3 и 4 чисел с плавающей запятой,а также и целочисленные). Они очень удобны для представления координат (x, y), цветов (r, g, b) или направлений. • Матричные типы: mat2, mat3, mat4 (матрицы 2×2, 3×3, 4×4). Используются для математических преобразований. • Sampler2D, Sampler3D, Image2D и Image3D — специальные типы для работы с текстурами.
2.2 Реализация PBR и материалов
Физически корректный рендеринг (рендеринг основанный на физических данных) (PBR), или Physically Based Rendering, — это не конкретный алгоритм, а скорее концепция и набор методик для рендеринга графики, основанных на принципах реальной физики. Цель PBR — как можно точнее смоделировать взаимодействие света с поверхностями материалов, что позволяет добиться предсказуемого и реалистичного внешнего вида объектов при любом освещении. PBR стал индустриальным стандартом в играх, кино и визуализации благодаря своей способности создавать невероятно правдоподобные материалы. PBR в целом выглядит более реалистично по сравнению с оригинальными алгоритмами освещения, такими как Phong и Blinn-Phong. Тем не менее, рендеринг на основе физических данных (PBR) по-прежнему является приближением к реальности (основанным на принципах физики), поэтому он называется не физическим затенением, а затенением на основе физических данных. В основе PBR лежит принцип микроповерхностной теории (microfacet theory). Эта теория гласит, что даже самая гладкая на вид поверхность на микроскопическом уровне является шероховатой и состоит из множества крошечных граней (микрограней), каждая из которых отражает свет. Именно ориентация этих микрограней определяет, как материал выглядит: будет ли он матовым или глянцевым, тусклым или блестящим.
Ключевые свойства PBR-материалов
Чтобы описать материал с точки зрения физики, PBR использует набор интуитивно понятных параметров, которые художники задают с помощью текстур. В PBR в основном используются следующие основные карты:
Albedo (Альбедо) / Base Color (Diffuse):
Это основной цвет поверхности. Для неметаллов (диэлектриков) он определяет цвет, который мы видим (например, красный для пластика, коричневый для дерева). Для металлов он определяет цвет отраженного света (например, золотистый оттенок для золота). Карта альбедо должна содержать только чистый цвет материала, без каких-либо эффектов освещения, теней или бликов. Все это рассчитывается шейдером.
Пример Albedo текстуры
Metallic (Металличность):
Эта текстурная карта (обычно в градациях серого) определяет, является ли материал металлом. Значение 0 (черный) означает неметалл (диэлектрик), а 1 (белый) — металл. Промежуточные значения используются для смешанных поверхностей, например, ржавого металла. Диэлектрики (пластик, дерево, ткань): Свет частично проникает в поверхность, рассеивается, окрашивается и выходит наружу (диффузное отражение). Блики (зеркальное отражение) при этом остаются бесцветными (белыми). Свет не проникает внутрь, а полностью отражается от поверхности. При этом зеркальное отражение (блик) окрашивается в цвет самого металла, а диффузное отражение отсутствует.
Пример Metallic текстуры
Пример модели имеющую значение 1 у текстуры Metallic (R7Engine).
Пример модели имеющую значение 0 у текстуры Metallic (R7Engine).
Roughness (Шероховатость):
Это, пожалуй, самый важный параметр для определения внешнего вида материала. Он описывает, насколько гладкой или шероховатой является поверхность. Значение 0 (черный) соответствует идеально гладкой поверхности (как зеркало или хром), а 1 (белый) — максимально шероховатой как матовый пластик. Свет отражается очень сфокусированно, создавая маленькие, яркие и четкие блики. Высокая шероховатость (матовая поверхность) микрограни ориентированы хаотично. Свет, попадая на них, рассеивается в разных направлениях, создавая большие, тусклые и размытые блики.
Пример Roughness текстуры
Ambient Occlusion (AO):
Карта затенения окружающим светом. Она определяет, какие части модели (обычно трещины, складки и углубления) получают меньше рассеянного окружающего света. Она не создает жестких теней от прямых источников света, а добавляет мягкие, контактные тени, которые значительно увеличивают визуальную глубину и реализм объекта. Эта текстура очень полезна для маленьких объектов где эффект SSAO может не давать чётких результатов.
Пример Ambient Occlusion текстуры
Сердцем PBR-шейдера является математическая модель под названием BRDF (Bidirectional Reflectance Distribution Function) — двунаправленная функция распределения отражения. Эта сложная функция описывает, как свет, падающий на поверхность с одного направления, отражается в другом направлении (в сторону камеры). Упрощенно, итоговый цвет пикселя в PBR-шейдере рассчитывается как сумма двух компонентов: диффузного и зеркального отражения
Уравнение коэффициента отражения
Это уравнение коэффициента отражения рендеринга, которое лежит в основе PBR. На практике оно упрощается и аппроксимируется. Шейдер вычисляет его примерно так: • Диффузный компонент (Diffuse): Отвечает за рассеянный цвет поверхности. Для его расчета часто используется модель Lambertian, которая гласит, что поверхность отражает свет одинаково во всех направлениях. Для металлов этот компонент равен нулю. Для диэлектриков его цвет берется из карты Albedo. • Зеркальный компонент (Specular): Отвечает за блики. Это самая сложная часть. Для его расчета используется модель микрограней, например, Cook-Torrance BRDF. Эта модель учитывает три ключевых фактора: Распределение микрограней (Distribution) это как микрограни ориентированы относительно основной поверхности. Зависит от параметра Roughness. Чем выше шероховатость, тем более хаотично они распределены. Затенение и маскировка микрограней (Geometry). Некоторые микрограни могут затенять или маскировать друг друга от света или камеры. Этот эффект также усиливается с ростом шероховатости. Отражение Френеля (Fresnel). Этот эффект описывает, что количество отраженного света зависит от угла обзора. Эффект Френеля присутствует у всех материалов. Шейдер рассчитывает его на основе параметра Metallic и угла обзора. Процесс в шейдере выглядит так: Пиксельный шейдер получает данные цвета из Albedo, значения Metallic и Roughness, а также AO. Он определяет, является ли точка металлом или диэлектриком. Используя параметры Roughness, нормаль поверхности, направление света и направление к камере, шейдер вычисляет диффузный и зеркальный компоненты света с помощью BRDF-модели. Эффект Френеля корректирует баланс между диффузным и зеркальным отражением. Карта AO затемняет итоговый результат в нужных местах. Все источники света суммируются, и полученный цвет возвращается для пикселя.
Пример расчётов PBR (R7Engine, GLSL).
При манипулировании параметрами текстурных карт можно добиться любого разнообразия материалов, даже если текстуры имеют чёрный и белые цвета — этого уже достаточно для того чтобы можно было симулировать металл, пластик и матовые поверхности.
Пример параметров материала (R7Engine, GLSL).
Таким образом, PBR позволяет художникам мыслить не в терминах «как настроить блик», а в терминах реальных физических свойств материала, что делает процесс создания ассетов более интуитивным, а результат реалистичным и консистентным при любом освещении.
Пример работы PBR в конечном результате (R7Engine).
2.3 VFX и пост-обработка
В современной 3D-графике рендеринг геометрии — это лишь половина дела. Чтобы превратить «сырое» изображение в атмосферную и визуально богатую сцену, разработчики прибегают к техникам визуальных эффектов (VFX) и пост-обработки. Это многоступенчатый процесс, который происходит уже после того, как основная 3D-сцена была отрисована. Вместо работы с 3D-моделями, эти техники оперируют с 2D-изображением сцены, сохраненным в специальных текстурах, и применяют к нему различные фильтры и эффекты. Шейдеры, особенно compute shaders, являются ключевым инструментом для реализации этих сложных алгоритмов с высокой производительностью. Рассмотрим два ярких примера из которые есть: God Rays (Божественные лучи) и Bloom (Свечение).
God Rays (Божественные лучи):
God Rays, или лучи, — это оптический эффект, который мы наблюдаем в реальной жизни, когда солнечный свет проходит через запыленную или влажную атмосферу (например, лучи солнца в туманном лесу). В компьютерной графике этот эффект имитируется для создания ощущения объема, атмосферы и для подчёркивания яркости источников света. Основная идея экранного (screen-space) алгоритма God Rays заключается в симуляции рассеивания света. Для каждого пикселя на экране выполняется маршировка луча (ray marching) от этого пикселя в направлении источника света (например, солнца) на экране. Вдоль этого луча делается несколько выборок (сэмплов) из текстуры глубины или яркости сцены. Если сэмпл попадает на объект, который ближе к камере, чем текущая точка на луче, считается, что этот объект блокирует свет. Суммируя свет от всех сэмплов с учётом затенения, шейдер создает иллюзию светящихся лучей, исходящих от источника. Реализация в проекте использует продвинутый подход через compute shader (godrays_comp.glsl), который управляется из C# кода в методе GodRaysComputeBuffer класса RenderEffects.
Подготовка данных в C#:
• Метод GodRaysComputeBuffer подготавливает и передаёт в шейдер все необходимые данные. • Входные текстуры: gViewPosition (позиция пикселей в пространстве вида) и gEmission (карта ярких, излучающих свет поверхностей) служат основной информацией о сцене. • Выходная текстура: Результат работы шейдера (готовый эффект лучей) записывается в godRaysTex. • Параметры (Uniforms): Передаются матрицы камеры (u_View, u_Proj), позиция камеры (u_CamPos), направление и цвет солнца (u_SunDir, u_SunColor), а также множество настроек для управления качеством и внешним видом эффекта, такие как количество сэмплов, плотность, затухание и вес.
Метод вызова расчётов лучей (R7Engine, C#, OpenGL).
Работа Compute Shader (godrays_comp.glsl):
• Определение положения солнца: Шейдер сначала вычисляет 2D-координаты солнца на экране, используя матрицы проекции и вида. Если солнце находится за камерой, эффект не применяется.
Функция с возвращаемым 2d вектором вычисления координат солнца (R7Engine, GLSL).
• Адаптивная выборка: Вместо фиксированного числа шагов, шейдер сначала делает небольшое количество «предварительных» выборок (u_PreSampleFraction), чтобы оценить яркость и наличие преград на пути луча. На основе этой оценки он динамически определяет необходимое количество сэмплов (numSamples), варьируя его от u_MinSamples до u_MaxSamples. Это позволяет экономить ресурсы в тех частях экрана, где эффект слабо выражен.
Адаптивная выборка (R7Engine, GLSL).
• Многослойный реймарчинг: Эффект усиливается за счёт симуляции нескольких слоёв рассеивания (u_NumLayers). Каждый слой имеет свой вес и расстояние между сэмплами, что создаёт более глубокий и объёмный вид лучей. • Физически-обоснованная модель: Шейдер использует не простое смешивание, а более сложные формулы, учитывающие рассеивание (scatter), поглощение (absorption) и фазовую функцию Хени-Гринстейна (Henyey-Greenstein). Это позволяет более реалистично имитировать то, как свет взаимодействует с частицами в атмосфере. • Затухание: Применяются различные виды затухания: по мере удаления от источника света, а также по мере приближения солнца к краям экрана или при взгляде на него под острым углом, что делает эффект более естественным.
Результат работы лучей (R7Engine).
Bloom (Свечение):
Bloom — это эффект, имитирующий несовершенство линз фотокамеры, из-за которого очень яркие области на изображении «просачиваются» на соседние, более тёмные участки, создавая вокруг них мягкое свечение. Этот эффект значительно усиливает восприятие яркости и добавляет сцене «кинематографичности». Алгоритм Bloom состоит из нескольких ключевых шагов: • Извлечение ярких участков (Extraction): Сначала создаётся новая текстура, в которую копируются только те пиксели исходной сцены, чья яркость превышает определённый порог (threshold). • Размытие (Blur): Эта текстура с яркими участками сильно размывается. Чтобы сделать размытие эффективным и качественным, его обычно выполняют на текстурах уменьшенного разрешения (downsampling). • Композитинг (Composition): Размытая текстура свечения накладывается (аддитивно смешивается) поверх оригинального изображения сцены. Реализация Bloom в проекте также выполнена с помощью compute шейдеров и разделена на логические части, что соответствует классическому конвейеру этого эффекта. Подготовка данных на CPU и извлечение с даунсэмплингом (bloom_comp.glsl): • Этот шейдер, управляемый из метода BloomCompute в C#, принимает на вход HDR-текстуру всей сцены (uSceneHDR).
Метод вызова расчётов свечения (R7Engine, C#, OpenGL).
• Для каждого пикселя вычисляется светимость (luminance), используя стандартные коэффициенты для RGB каналов (LUM = vec3(0.2126, 0.7152, 0.0722)). • Если светимость превышает порог uThreshold, пиксель проходит. Параметр uKnee используется для создания плавного перехода у порога, чтобы избежать резких границ. • Шейдер также применяет 5×5 фильтр Гаусса (KERN) для первоначального смягчения и качественного уменьшения разрешения при записи в выходную текстуру (uOutBloom).
Пример всего кода шейдера bloom_comp.glsl (R7Engine, GLSL).
Размытие по Гауссу в отдельном шейдере (bloom_separable_blur.glsl):
Это сердце эффекта. • Для максимальной производительности применяется сепарабельный (разделимый) фильтр Гаусса. Вместо одного «квадратного» 2D-размытия, которое очень затратно, выполняется два более дешёвых 1D-прохода: один по горизонтали, другой по вертикали. • Управление из C#: Метод BloomCompute организует так называемый «пинг-понг» рендеринг. Он несколько раз вызывает RunBlurPass, который запускает шейдер bloom_separable_blur.glsl. На первой итерации он размывает текстуру bloomA и пишет результат в bloomB по горизонтали (uDirection = (1,0)). На второй — читает из bloomB и пишет размытый по вертикали (uDirection = (0,1)) результат обратно в bloomA. Эти итерации повторяются blurIterations раз для достижения сильного размытия.
Метод вызова расчётов размытия (R7Engine, C#, OpenGL).
• Работа шейдера: Шейдер динамически вычисляет веса для Гауссова распределения на основе параметров uRadius и uSigma. Затем он проходит по пикселям в заданном направлении (uDirection), считывает их цвет и суммирует с соответствующими весами, получая размытое значение.
Пример всего кода шейдера bloom_separable_blur.glsl (R7Engine, GLSL).
После всех итераций размытия, финальная текстура свечения (в коде это bloomA) готова. В методе DrawBloom она передаётся в финальный шейдер (_screenShader), который просто накладывает её поверх основной сцены
Метод рендера эффекта в полноэкранный квад (R7Engine, C#, OpenGL).
Результат работы свечения (R7Engine).
В заключение, шейдеры пост-обработки, такие как God Rays и Bloom, являются мощными инструментами в арсенале разработчиков. Они позволяют, оперируя с уже готовым 2D-изображением, симулировать сложные оптические явления, значительно повышая визуальное качество, атмосферность и реализм финальной картинки, превращая стандартный рендер в произведение искусства.