Skip to content

Нормали и базовый свет

Полный код главы

Что уже должно быть понятно:

  • instancing, instance buffer
  • камера, view/projection матрицы
  • depth buffer, bind groups

Что появится в этой главе:

  • нормали — векторы, определяющие направление поверхности
  • 24 вершины для куба: по 4 на грань с индивидуальной нормалью
  • направленный свет (directional light) и ambient-освещение
  • normal matrix — корректное преобразование нормалей
  • два bind groups: camera (group 0) и light (group 1)

Итог: сетка 5×5×5 кубов с освещением, грани видны благодаря разной яркости


До сих пор кубы выглядели плоскими — текстура или цвет одинаковы со всех сторон. В реальном мире грани объекта освещены по-разному: те, что повёрнуты к источнику света, ярче, а повёрнутые от него — темнее. Для этого нужны нормали.

Нормали

Нормаль — единичный вектор, перпендикулярный поверхности. Для плоской грани все точки имеют одну и ту же нормаль. У куба 6 граней, и у каждой своя нормаль:

ГраньНормаль
Передняя (z+)(0, 0, 1)
Задняя (z−)(0, 0, −1)
Правая (x+)(1, 0, 0)
Левая (x−)(−1, 0, 0)
Верхняя (y+)(0, 1, 0)
Нижняя (y−)(0, −1, 0)

24 вершины

В главе про трансформации мы обсуждали, почему кубу нужны 24 вершины вместо 8. Ключевой момент: один геометрический угол куба принадлежит трём граням, и каждая грань нуждается в собственной нормали. Поскольку нормаль хранится в вершине, угол приходится дублировать — по одной вершине на грань:

              Ny = (0, 1, 0)

                   |
                   ●─────────→ Nx = (1, 0, 0)



          Nz = (0, 0, 1)

Угол (1, 1, 1) — точка пересечения правой, верхней и передней граней. Три грани = три нормали = три вершины в буфере вместо одной.

Итого: 8 углов × 3 грани на угол = 24 вершины (эквивалентно: 6 граней × 4 вершины = 24).

Если использовать только 8 вершин и усреднить нормали в каждом углу, рёбра куба «сгладятся» — он будет выглядеть как сфера. Для сферы это правильно, для куба — нет.

Три нормали в одной вершине куба
rust
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct Vertex {
    position: [f32; 3],
    normal: [f32; 3],
    uv: [f32; 2],
}

Каждая вершина хранит позицию, нормаль и UV-координату — по 8 float, 32 байта. Нормаль интерполируется между вершинами при растеризации — для плоской грани все пиксели получат одну и ту же нормаль.

Направленный свет

Простейшая модель освещения — направленный свет (directional light). Источник света бесконечно далеко (как солнце), все лучи параллельны. Яркость грани зависит от угла между нормалью и направлением на источник света:

Угол между нормалью N и направлением света L
wgsl
let diffuse = max(dot(normal, light_dir), 0.0);

Скалярное произведение dot(N, L) максимально, когда нормаль и направление света совпадают (грань обращена прямо к свету), и равно нулю, когда они перпендикулярны (свет скользит вдоль поверхности). max(..., 0.0) отсекает отрицательные значения — грани, развёрнутые от света, не дают отрицательной яркости.

Итоговая интенсивность:

wgsl
let intensity = light.ambient + diffuse * (1.0 - light.ambient);

ambient — минимальная яркость, чтобы грани в тени не были полностью чёрными. При ambient = 0.1 даже развёрнутая от света грань будет видна на 10% яркости.

Normal matrix

Нормали нельзя трансформировать той же матрицей, что и позиции. Если model-матрица содержит неравномерное масштабирование, нормали искажаются. Корректное преобразование:

N=(M1)Tn

где M — верхняя левая подматрица 3×3 model-матрицы, n — нормаль. Эта матрица называется normal matrix.

rust
let normal_matrix = Mat3::from_mat4(model.inverse().transpose());

Для чистого сдвига (как в нашем примере) inverse().transpose() даёт единичную матрицу — нормали не меняются. Но при неравномерном масштабировании или скашивании normal matrix обеспечит корректный результат.

Данные экземпляра

Instance buffer теперь содержит две матрицы — model и normal matrix:

rust
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct InstanceData {
    model: [[f32; 4]; 4],
    normal_matrix: [[f32; 3]; 3],
}

Normal matrix — 3×3, 9 float, 36 байт. В шейдере она передаётся через три атрибута @location(7), @location(8), @location(9) и собирается в mat3x3:

wgsl
struct InstanceInput {
    // model — четыре vec4 (16 байт каждый), совпадает с Float32x4 в Rust
    @location(3) model_col0: vec4<f32>,
    @location(4) model_col1: vec4<f32>,
    @location(5) model_col2: vec4<f32>,
    @location(6) model_col3: vec4<f32>,
    // normal_matrix — три vec4, но Rust передаёт Float32x3
    @location(7) normal_col0: vec4<f32>,
    @location(8) normal_col1: vec4<f32>,
    @location(9) normal_col2: vec4<f32>,
}

let normal_matrix = mat3x3<f32>(
    instance.normal_col0.xyz,
    instance.normal_col1.xyz,
    instance.normal_col2.xyz,
);

На стороне Rust для нормальной матрицы используется формат VertexFormat::Float32x3 — по три float на колонку. Шейдер ожидает vec4<f32>, но wgpu автоматически заполняет .w значением 1.0. Поскольку при создании mat3x3 мы берём только .xyz, это работает корректно — четвёртый компонент просто игнорируется.

Два bind groups

Uniform-данные разделены на две группы по частоте обновления:

Архитектура двух bind groups
  • group 0 (camera)view_proj, обновляется каждый кадр. Используется в вершинном шейдере.
  • group 1 (light) — параметры света, в нашем случае константа. Используется во фрагментном шейдере.

Pipeline layout содержит два bind group layout:

rust
bind_group_layouts: &[
    Some(&camera_bind_group_layout),
    Some(&light_bind_group_layout),
],

В render pass оба bind group привязываются перед draw:

rust
rpass.set_bind_group(0, &self.camera_bind_group, &[]);
rpass.set_bind_group(1, &self.light_bind_group, &[]);
rpass.draw_indexed(0..36, 0, 0..NUM_INSTANCES as u32);

Разделение по группам позволяет обновлять данные независимо — при смене камеры не нужно трогать параметры света.

Шейдер

Вершинный шейдер преобразует нормаль через normal matrix и передаёт мировую позицию во фрагментный шейдер:

wgsl
let world_pos = model * vec4<f32>(input.position, 1.0);
output.position = uniforms.view_proj * world_pos;
output.normal = normal_matrix * input.normal;
output.world_pos = world_pos.xyz;

Фрагментный шейдер вычисляет освещённость:

wgsl
let normal = normalize(input.normal);
let light_dir = normalize(-light.light_dir);

let diffuse = max(dot(normal, light_dir), 0.0);
let intensity = light.ambient + diffuse * (1.0 - light.ambient);

let tex_color = textureSample(diffuse_tex, diffuse_sampler, input.uv);
let color = tex_color.rgb * light.light_color * intensity;

Направление света хранится как «куда светит» — (-0.5, -1.0, -0.3), свет падает сверху-слева. В шейдере мы инвертируем его: normalize(-light.light_dir) — получаем направление «от поверхности к источнику».

Цвет берётся из diffuse-текстуры через textureSample. Текстура и сэмплер находятся в bind group 1 вместе с light uniform — группа теперь содержит три привязки: буфер, текстуру и сэмплер.

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

WGSL alignment и vec3

LightUniforms содержит vec3<f32> + f32 — это не случайно. В WGSL vec3<f32> выравнивается на 16 байт: занимает 12 байт данных, но следующий элемент начинается со смещения, кратного 16. Если после vec3 поставить другой vec3, между ними будет 4 байта паддинга.

Здесь ambient: f32 занимает ровно 4 байта паддинга, поэтому структура компактная — 32 байта без лишних промежутков. Крейт encase автоматически учитывает правила WGSL alignment при записи данных в буфер, но при ручном расчёте размеров легко ошибиться. Подробно правила alignment разобраны в главе про материалы.

Что получилось

Типичные ошибки

  • Нормаль не нормализована после normal matrix — normalize() обязателен, иначе dot(N, L) даёт неверный результат
  • Normal matrix = (M^-1)^T, а не M^T — при неравномерном масштабе освещение будет неправильным
  • 24 вершины, не 8 — каждый угол дублируется 3 раза (по нормали на грань), иначе нормали усреднятся и куб будет выглядеть как сфера (см. выше)

125 кубов с освещением. Грани, обращённые к свету, яркие, обращённые от света — тёмные. Камера свободно перемещается между кубами.

Попробуем

  • Изменить light_dir на (0.0, -1.0, 0.0) — свет прямо сверху, боковые грани тёмные
  • Поставить ambient: 0.5 — все грани ярче, контраст ниже
  • Изменить light_color на (1.0, 0.3, 0.3) — красный свет
  • Добавить масштабирование в model-матрицу: Mat4::from_scale(Vec3::new(0.5, 1.0, 1.0)) — без normal matrix освещение было бы некорректным

Полный код главы

Опубликовано под лицензией CC-BY-4.0