Ваш первый 3D-шейдер

Вы решили начать писать свой собственный пространственный шейдер. Возможно, вы увидели в интернете интересный трюк с шейдерами, или обнаружили, что StandardMaterial3D не совсем соответствует вашим потребностям. В любом случае, вы решили написать свой собственный шейдер, и теперь вам нужно понять, с чего начать.

В этом руководстве объясняется, как написать Spatial (пространственный) шейдер, и рассматривается больше тем, чем в руководстве CanvasItem.

Spatial шейдеры обладают более широким набором встроенных функций, чем шейдеры CanvasItem. От spatial шейдеров ожидается, что Godot уже предоставляет функциональность для распространённых случаев использования, и пользователю остаётся лишь задать необходимые параметры в шейдере. Это особенно актуально для рабочего процесса PBR (физически корректного рендеринга).

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

Примечание

В этом руководстве предполагается наличие базовых знаний о шейдерах, таких как типы (vec2, float, sampler2D) и функции. Если вы не знакомы с этими понятиями, перед началом работы рекомендуется кратко ознакомиться с The Book of Shaders.

Куда направить мой материал

В 3D объекты рисуются с помощью Meshes. Сетки - это тип ресурса, который хранит геометрию (форму объекта) и материалы (цвет и реакцию объекта на свет) в единицах, называемых "поверхностями". Сетка может иметь несколько поверхностей или только одну. Как правило, вы импортируете сетку из другой программы (например, Blender). Но в Godot также есть несколько PrimitiveMeshes, которые позволяют добавить базовую геометрию в сцену без импорта мешей.

Существует несколько типов узлов, которые можно использовать для рисования сетки. Основной — MeshInstance3D, но также можно использовать GPUParticles3D, MultiMeshesMultiMeshInstance3D) и другие.

Обычно материал связан с заданной поверхностью в сетке, но некоторые узлы, такие как MeshInstance3D, позволяют переопределить материал для определенной поверхности или для всех поверхностей.

Если вы установите материал для самой поверхности или сетки, то все экземпляры MeshInstance3D, использующие эту сетку, будут использовать этот материал. Однако, если вы хотите использовать одну и ту же сетку в нескольких экземплярах, но при этом использовать разные материалы для каждого экземпляра, необходимо установить материал для MeshInstance3D.

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

Настройка

Добавьте новый узел MeshInstance3D в вашу сцену.

На вкладке Инспектор задайте свойству Mesh объекта MeshInstance3D новый ресурс PlaneMesh, щёлкнув по <empty> и выбрав New PlaneMesh. Затем разверните ресурс, щёлкнув по появившемуся изображению плоскости.

Это добавит самолет к нашей сцене.

Затем в области просмотра нажмите кнопку Perspective в левом верхнем углу. В появившемся меню выберите Отобразить каркас.

Это позволит вам увидеть треугольники, составляющие плоскость.

../../../_images/plane.webp

Теперь установите Subdivide Width и Subdivide Depth для PlaneMesh на 32.

../../../_images/plane-sub-set.webp

Видите, что теперь в MeshInstance3D стало гораздо больше треугольников. Это даст нам больше вершин для работы и позволит добавить больше деталей.

../../../_images/plane-sub.webp

PrimitiveMeshes, как и PlaneMesh, имеют только одну поверхность, поэтому вместо массива материалов используется только один. Задайте Material для нового ShaderMaterial, затем разверните материал, щёлкнув по появившейся сфере.

Примечание

Материалы, наследующие ресурс Material, такие как StandardMaterial3D и ParticleProcessMaterial, можно преобразовать в ShaderMaterial, а их существующие свойства будут преобразованы в соответствующий текстовый шейдер. Для этого щёлкните правой кнопкой мыши по материалу в доке "FileSystem" и выберите Convert to ShaderMaterial. Это также можно сделать, щёлкнув правой кнопкой мыши по любому свойству, содержащему ссылку на материал, в инспекторе.

Теперь задайте Shader материала, нажав <пустой> и выберите New Shader.... Оставьте настройки по умолчанию, дайте шейдеру имя и нажмите Create.

Щелкните по шейдеру в инспекторе, и должен открыться редактор шейдеров. Теперь вы готовы приступить к написанию своего первого пространственного шейдера!

Магия шейдеров

../../../_images/shader-editor.webp

Новый шейдер уже сгенерирован с переменной shader_type, функцией vertex() и функцией fragment(). Первое, что требуется шейдерам Godot, — это объявление типа шейдера. В данном случае shader_type установлено как spatial, поскольку это пространственный шейдер.

shader_type spatial;

Функция vertex() определяет, где вершины вашего MeshInstance3D появятся в финальной сцене. Мы будем использовать её для смещения высоты каждой вершины, чтобы наша плоская поверхность выглядела как небольшой ландшафт.

Поскольку функция vertex() пуста, Godot будет использовать вершинный шейдер по умолчанию. Мы можем начать вносить изменения, добавив одну строку:

void vertex() {
  VERTEX.y += cos(VERTEX.x) * sin(VERTEX.z);
}

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

../../../_images/cos.webp

Хорошо, давайте разберёмся. Значение y вершины VERTEX увеличивается. Мы передаём компоненты x и z вершины VERTEX в качестве аргументов cos() и sin(); это создаёт волнообразный эффект по осям x и z.

В конце концов, мы хотим добиться эффекта небольших холмов. cos() и sin() уже выглядят как холмы. Мы добиваемся этого, масштабируя входные данные функций cos() и sin().

void vertex() {
  VERTEX.y += cos(VERTEX.x * 4.0) * sin(VERTEX.z * 4.0);
}
../../../_images/cos4.webp

Выглядит лучше, но все еще слишком много острых углов и повторений. Давайте сделаем это немного интереснее.

Карта высот шума

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

Godot предоставляет ресурс NoiseTexture2D для генерации текстуры шума, к которой можно получить доступ из шейдера.

Чтобы получить доступ к текстуре в шейдере, добавьте следующий код в верхнюю часть шейдера, за пределами функции vertex().

uniform sampler2D noise;

Это позволит вам отправить текстуру шума в шейдер. Теперь откройте инспектор под вашим материалом. Вы увидите раздел Shader Parameters. Открыв его, вы увидите параметр "Noise".

Задайте для этого параметра Noise новое значение NoiseTexture2D. Затем в NoiseTexture2D задайте для его свойства Noise новое значение FastNoiseLite. Класс FastNoiseLite используется NoiseTexture2D для генерации карты высот.

Как только вы его настроите, он и должен выглядеть вот так.

../../../_images/noise-set.webp

Теперь получите доступ к текстуре шума, используя функцию texture():

void vertex() {
  float height = texture(noise, VERTEX.xz / 2.0 + 0.5).x;
  VERTEX.y += height;
}

Функция texture() принимает текстуру в качестве первого аргумента и vec2 для позиции на текстуре в качестве второго аргумента. Мы используем каналы x и z функции VERTEX для определения точки на текстуре.

Поскольку координаты PlaneMesh находятся в диапазоне [-1.0, 1.0] (для размера 2.0), а координаты текстуры находятся в диапазоне [0.0, 1.0], для перераспределения координат мы делим размер PlaneMesh на 2.0 и добавляем 0.5.

texture() возвращает vec4 каналов r, g, b, a в указанной позиции. Поскольку текстура шума представлена в оттенках серого, все значения одинаковы, поэтому мы можем использовать любой из каналов в качестве высоты. В данном случае мы будем использовать канал r или x.

Примечание

xyzw — это то же самое, что rgba в GLSL, поэтому вместо texture().x, приведённого выше, можно использовать texture().r. Подробнее см. в документации OpenGL <https://www.khronos.org/opengl/wiki/Data_Type_(GLSL)#Vectors>`_.

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

../../../_images/noise.webp

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

Uniform-переменные

Uniform variables позволяют передавать данные из игры в шейдер. Они очень полезны для управления эффектами шейдера. Uniforms могут быть практически любым типом данных, который можно использовать в шейдере. Чтобы использовать Uniform, объявите его в Shader с помощью ключевого слова uniform.

Давайте создадим uniform, которая будет менять высоту рельефа.

uniform float height_scale = 0.5;

Godot позволяет инициализировать юниформу заданным значением; в данном случае height_scale равен 0.5. Вы можете задать юниформу из GDScript, вызвав функцию set_shader_parameter() для материала, соответствующего шейдеру. Значение, переданное из GDScript, имеет приоритет над значением, использованным для его инициализации в шейдере.

# called from the MeshInstance3D
mesh.material.set_shader_parameter("height_scale", 0.5)

Примечание

Изменение униформ в узлах Spatial отличается от изменений в узлах CanvasItem. Здесь мы задаём материал внутри ресурса PlaneMesh. В других ресурсах сетки может потребоваться сначала получить доступ к материалу, вызвав surface_get_material(). В MeshInstance3D доступ к материалу осуществляется с помощью get_surface_material() или material_override.

Помните, что строка, передаваемая в set_shader_parameter(), должна совпадать с именем переменной uniform в шейдере. Вы можете использовать переменную uniform в любом месте шейдера. Здесь мы будем использовать её для установки значения высоты вместо произвольного умножения на 0.5.

VERTEX.y += height * height_scale;

Теперь все выглядит намного лучше.

../../../_images/noise-low.webp

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

Взаимодействие со светом

Сначала отключите каркасную модель. Для этого снова откройте меню Perspective в левом верхнем углу области просмотра и выберите Display Normal. Кроме того, на панели инструментов 3D-сцены отключите предварительный просмотр sunlight.

../../../_images/normal.webp

Обратите внимание, что цвет сетки стал плоским. Это потому, что освещение на ней плоское. Давайте добавим свет!

Сначала мы добавим OmniLight3D к сцене и перетащим его так, чтобы он оказался над ландшафтом.

../../../_images/light.webp

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

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

Вы можете вручную рассчитать новую нормаль в вершинной функции, а затем просто установить NORMAL. Если установлено NORMAL, Godot выполнит все сложные расчёты освещения за нас. Мы рассмотрим этот метод в следующей части урока, а пока будем считывать нормали из текстуры.

Вместо этого мы снова положимся на NoiseTexture для расчёта нормалей. Для этого мы передаём вторую текстуру шума.

uniform sampler2D normalmap;

Установите эту вторую однородную текстуру на другую NoiseTexture2D с другим FastNoiseLite. Но на этот раз выберите Как карту нормалей.

../../../_images/normal-set.webp

Если у нас есть нормали, соответствующие определённой вершине, мы устанавливаем NORMAL, но если у вас есть карта нормалей, полученная из текстуры, задайте нормаль с помощью NORMAL_MAP в функции fragment(). Таким образом, Godot автоматически обёртывает сетку текстурой.

Наконец, чтобы гарантировать, что мы считываем данные с одних и тех же мест на текстуре шума и текстуре карты нормалей, мы передадим позицию VERTEX.xz из функции vertex() в функцию fragment(). Это делается с помощью varying.

Над функцией vertex() определите varying vec2 с именем tex_position. Внутри функции vertex() присвойте VERTEX.xz значению tex_position.

varying vec2 tex_position;

void vertex() {
  tex_position = VERTEX.xz / 2.0 + 0.5;
  float height = texture(noise, tex_position).x;
  VERTEX.y += height * height_scale;
}

И теперь мы можем получить доступ к tex_position из функции fragment().

void fragment() {
  NORMAL_MAP = texture(normalmap, tex_position).xyz;
}

После установки нормалей свет теперь динамически реагирует на высоту сетки.

../../../_images/normalmap.webp

Мы даже можем перетаскивать источник света, и освещение будет обновляться автоматически.

../../../_images/normalmap2.webp

Полный код

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

shader_type spatial;

uniform float height_scale = 0.5;
uniform sampler2D noise;
uniform sampler2D normalmap;

varying vec2 tex_position;

void vertex() {
  tex_position = VERTEX.xz / 2.0 + 0.5;
  float height = texture(noise, tex_position).x;
  VERTEX.y += height * height_scale;
}

void fragment() {
  NORMAL_MAP = texture(normalmap, tex_position).xyz;
}

На этом всё в этой части. Надеюсь, теперь вы понимаете основы vertex (вершинных) шейдеров в Godot. В следующей части урока мы напишем fragment функцию для этой вершинной функции и рассмотрим более продвинутый метод превращения этой поверхности в океан движущихся волн.