Skip to content

Материалы и свет

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

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

  • нормали, направленный свет, ambient
  • instancing, camera, depth buffer
  • текстуры, UV-координаты, сэмплеры

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

  • несколько источников света: массив в uniform-буфере
  • diffuse texture вместо плоского цвета
  • объединение light uniform, текстуры и сэмплера в один bind group
  • аддитивное смешивание: вклад каждого источника складывается

Итог: сетка 3×3×3 кубов, освещённая тремя источниками разного цвета, с текстурой вместо плоского серого


В прошлой главе мы добавили один направленный свет. Но в реальных сценах несколько источников — солнце, небо, отражения. В этой главе мы добавим три источника с разными цветами и заменим плоский цвет на diffuse texture.

Несколько источников света

Источники хранятся в массиве фиксированной длины внутри uniform-буфера:

rust
#[derive(ShaderType, Clone, Copy)]
struct Light {
    direction: Vec3,
    color: Vec3,
}

#[derive(ShaderType)]
struct LightUniforms {
    lights: [Light; 3],
    ambient: f32,
}

Vec3 из glam реализует encase::ShaderType, поэтому Light тоже можно поместить в uniform. Массив [Light; 3] — фиксированный размер. WGSL не поддерживает динамические массивы в uniform, поэтому количество источников зашито на этапе компиляции.

Структура в WGSL

wgsl
struct Light {
    direction: vec3<f32>,
    color: vec3<f32>,
};

struct LightUniforms {
    lights: array<Light, 3>,
    ambient: f32,
};

Выравнивание массива структур

WGSL требует, чтобы каждый элемент массива был выровнен по размеру структуры, округлённому до ближайшего кратного 16 байт. Light содержит два vec3<f32>:

struct Light {             размер   смещение
  direction: vec3<f32>     12 байт  0
  // паддинг               4 байта  12
  color: vec3<f32>         12 байт  16
  // паддинг               4 байта  28
};                         32 байта total

vec3<f32> занимает 12 байт, но выравнивание vec3 — 16 байт (как у vec4). Поэтому после direction идёт 4 байта паддинга, и color начинается со смещения 16. Итоговый размер Light — 32 байта вместо ожидаемых 24.

LightUniforms целиком:

lights[0]   32 байта   смещение 0
lights[1]   32 байта   смещение 32
lights[2]   32 байта   смещение 64
ambient     4 байта    смещение 96
// паддинг  12 байт    смещение 100
                        всего 112 байт

encase автоматически вычисляет размер и паддинг, поэтому Rust-структура с #[derive(ShaderType)] совпадает с WGSL-представлением. Без encase пришлось бы вручную добавлять _padding: f32 поля.

WARNING

Если бы мы описали Light как struct Light { dir: vec4<f32>, col: vec4<f32> }, размер тоже был бы 32 байта — но без паддинга, с явным хранением w-компоненты. Оба подхода валидны, но vec3 + паддинг чище семантически.

Суммирование вклада источников

Фрагментный шейдер проходит по всем источникам в цикле:

wgsl
var total = vec3<f32>(0.0);
for (var i = 0u; i < 3u; i++) {
    let light_dir = normalize(-light.lights[i].direction);
    let diffuse = max(dot(normal, light_dir), 0.0);
    total += light.lights[i].color * diffuse * tex_color.rgb;
}
let ambient = light.ambient * tex_color.rgb;
return vec4<f32>(ambient + total, 1.0);

Каждый источник даёт свой вклад. Это аддитивное смешивание — яркости складываются. Если три источника с цветами (1, 0, 0), (0, 1, 0), (0, 0, 1) осветят одну грань, результат будет близок к белому.

Аддитивное смешивание трёх источников

Если сумма каналов превышает 1.0 — значение обрезается (clamp) до 1.0 при записи в framebuffer. Это означает потерю информации: текстура под тремя яркими источниками будет выглядеть белой, независимо от исходного цвета. В главе про HDR мы научимся работать с яркостями > 1.0 через tone mapping.

Ambient добавляется один раз — он моделирует рассеянный свет, который не принадлежит конкретному источнику.

Diffuse texture

Вместо плоского vec3<f32>(0.85, 0.85, 0.85) используем текстуру:

wgsl
let tex_color = textureSample(diffuse_tex, diffuse_sampler, input.uv);

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

Для этого вершины теперь хранят UV-координаты:

rust
struct Vertex {
    position: [f32; 3],
    normal: [f32; 3],
    uv: [f32; 2],
}

Кубу по-прежнему нужны 24 вершины — у каждой грани своя нормаль и свой набор UV.

Шахматная текстура генерируется процедурно — массив пикселей в памяти. Фильтрация Nearest подходит для чётких клеток. Для фотографий или плавных градиентов Linear дал бы более гладкий результат.

Объединение в один bind group

В прошлой главе light uniform был в отдельном bind group. Теперь добавились текстура и сэмплер — все три ресурса используются только фрагментным шейдером, поэтому логично поместить их в один bind group:

rust
let light_bgl = ctx.device.create_bind_group_layout(&BindGroupLayoutDescriptor {
    entries: &[
        // binding 0: Light uniform
        // binding 1: Diffuse texture
        // binding 2: Diffuse sampler
    ],
});

Все три ресурса используются только во фрагментном шейдере — visibility: ShaderStages::FRAGMENT.

Bind group 0 (camera) остаётся отдельным — он используется в вершинном шейдере и обновляется каждый кадр. Разделение по частоте обновления: camera меняется при движении, light и текстура — константы.

Три источника с разными цветами

rust
lights: [
    Light { direction: Vec3::new(-0.5, -1.0, -0.3), color: Vec3::new(1.0, 0.95, 0.85) },
    Light { direction: Vec3::new(0.7, -0.3, 0.5), color: Vec3::new(0.3, 0.5, 1.0) },
    Light { direction: Vec3::new(-0.2, -0.5, 0.8), color: Vec3::new(0.9, 0.3, 0.3) },
],

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

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

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

  • vec3<f32> в WGSL struct занимает 16 байт (с паддингом), не 12 — если не учесть, буфер не совпадёт
  • Сумма цветов > 1.0 обрезается до 1.0 — потеря информации при ярких источниках
  • Хотя разные binding могут иметь разную visibility, обычно ресурсы, используемые одним шейдером, объединяют в один bind group с одинаковой visibility — это устоявшаяся практика

27 кубов (3×3×3) с шахматной текстурой. Три источника света создают разноцветные блики и тени на гранях. Камера перемещается как обычно.

Попробуем

  • Изменить цвета источников — посмотреть, как смешиваются
  • Увеличить массив до [Light; 5] (и в WGSL array<Light, 5>) — добавить больше источников
  • Убрать один источник (color = Vec3::ZERO) — увидеть разницу
  • Заменить текстуру на однотонную — сравнить с результатом прошлой главы

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

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