Пишем небольшой шейдер подсветки интерактивных объектов для игры Jarl (на модифицированном Bevy Engine) – разбор + код

Небольшая заметка о том как написать аутлайн-шейдер для рисования границ объектов на примере пиксель арт игры Jarl (Discord, YouTube, Twitter, Reddit). Материал предполагает наличие у читателя базового знания и интереса к графическим технологиям. Игра использует модифицированый движок Bevy и языки Rust + WGSL.

 

Сразу к сути – как это работает (очень условно):

1. Создаем отдельные рендер таргеты (промежуточные текстуры в которые будем рисовать эффект) для объектов которые нужно подсветить и для промежуточных результатов с нужными флагами для использования:

1) Флаг для рендер пайплайна RENDER_ATTACHMENT (текстуру можно использовать в стандартном render pipeline для отрисовки)

2) Флаг для чтения из компьют шейдере STORAGE_BINDING (текстуру можно читать в компьют шейдере для обработки):

let mut outline_objects = Image { texture_descriptor: TextureDescriptor { label: Some("target_outline_objects"), size: target_size, dimension: TextureDimension::D2, format: TextureFormat::bevy_default(), mip_level_count: 1, sample_count: 1, usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT | TextureUsages::STORAGE_BINDING, view_formats: &[], }, ..Default::default() };

2. Создаем 2D камеру которая будет видеть только те объекты которые нужно посвечивать и присваиваем ей отдельный слой RenderLayersCAMERA_LAYER_OUTLINES. В Bevy пока что нельзя задать для одной камеры несколько таргетов стандартными средствами, поэтому придется делать такой костыль. Хотя в моем форке это было поправлено.

let main_camera_outlines = cmds .spawn(( Camera2dBundle { camera: Camera { hdr: true, order: MIN_CAMERA_ORDER + OUTLINE_INDEX, target: RenderTarget::Image(camera_targets.outline_objects.clone()), ..Default::default() }, projection: projection, ..Default::default() }, Name::new("main_camera_outlines"), )) .insert(SpriteCamera) .insert(OutlineCamera) .insert(RenderLayers::from_layers(CAMERA_LAYER_OUTLINES)) .insert(UiCameraConfig { show_ui: false }) .id();

3. Иерархиям сущностей которые нужно подсветить добавляем два компонента: один для включения подсветки, второй для системы индескирования (чтобы быстро находить объекты которые близки к курсору). Тут просто работа с ECS. Код приводить не буду поскольку это тривиально.

AABBs объектов которые лежат в разных слоях индекса. Пока делал видео – нашел баг, ширина AABB почему-то в два раза больше чем нужно.

4. В рантайме выбираем под-иерархии тех сущностей которые прошли тест на близость с курсором и отрисовываем их в нужный таргет добавляя его через RenderLayers (после чего нужно также не забыть его убрать):

for entity in computed.iter() { if let Ok(mut layers) = query_render_layers.get_mut(*entity) { for outline_layer in CAMERA_LAYER_OUTLINES { *layers = layers.with(*outline_layer); } } }
Выбранный объект сначала попадает в созданную текстуру для отрисовки (слева) через присвоение нужного слоя, после чего она обрабатывается аутлайн-шейдером (справа). 

5. Теперь самая нудная часть если у вашего движка нет шейдер графа – руками настроить сами все нужные пайплайны для WGPU, описать типы, входы, выходы и пр. Здесь нужно пройти все церемонии перед тем как начать писать шейдер который будет читать из текстуры объекты которые нужно подсветить и рисовать для них аутлайн.

Разработка дорожает и граф отрисовки для небольшой инди игры с пиксель-арт графикой уже начинает напоминать AAA игру из середины нулевых.
Разработка дорожает и граф отрисовки для небольшой инди игры с пиксель-арт графикой уже начинает напоминать AAA игру из середины нулевых.

5.1 Создаем Binding Group — коллекцию ресурсов (например, текстур и буферов), которая объединяется и привязывается к шейдеру. Привязываем ее к 1) текстуре из которой будем читать объекты 2) к промежуточной текстуре куда будем писать аутлайн:

let outline_bind_group = render_device.create_bind_group(&BindGroupDescriptor { label: "outline_bind_group".into(), layout: &pipeline.outline_bind_group_layout, entries: &[ BindGroupEntry { binding: 0, resource: BindingResource::TextureView(&outline_objects.texture_view), }, BindGroupEntry { binding: 1, resource: BindingResource::TextureView(&outline_ui.texture_view), }, ], });

5.2 Создаем Bind Group Layout — описание структур и типов ресурсов, которые будут связаны с шейдером. Здесь прописываем все типы и свойства используемых ресурсов в рамках конкретного шейдера.

let outline_bind_group_layout = render_device.create_bind_group_layout(&BindGroupLayoutDescriptor { label: Some("outline_group_layout"), entries: &[ // Outline image. BindGroupLayoutEntry { binding: 0, visibility: ShaderStages::COMPUTE, ty: BindingType::Texture { sample_type: TextureSampleType::Float { filterable: true }, view_dimension: TextureViewDimension::D2, multisampled: false, }, count: None, }, // Outline UI. BindGroupLayoutEntry { binding: 1, visibility: ShaderStages::COMPUTE, ty: BindingType::StorageTexture { access: StorageTextureAccess::WriteOnly, format: OUTLINE_TARGET_FORMAT, view_dimension: TextureViewDimension::D2, }, count: None, }, ], });

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

let outline_pipeline = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { label: Some("outline_pipeline".into()), layout: vec![outline_bind_group_layout.clone()], push_constant_ranges: vec![], shader: outline_shader, shader_defs: vec![], entry_point: "main".into(), });

5.4 Диспатчим шейдер для каждого кадра, здесь все тривиально:

let aligned = util::align_to_work_group_grid(target_sizes.outline_isize).as_uvec2(); let grid_w = aligned.x / WORKGROUP_SIZE; let grid_h = aligned.y / WORKGROUP_SIZE; pass.set_bind_group(0, &pipeline_bind_groups.outline_bind_group, &[]); pass.set_pipeline(outline_pipeline); pass.dispatch_workgroups(grid_w, grid_h, 1);

5. Самое интересное – это сам шейдер который все это отрисовывает. Тут особо никакой магии, берем кернел 3x3 (размер на ваше усмотрение) и проходимся им по всей текстуре с объектами проверяя сколько пикселей имеют альфа-значение выше какого-то порога. Если количество таких пикселей больше нуля, но меньше скажем 90%, то значит у нас граница объекта. Здесь можно экспериментировать с разными параметрами и кернелами, но для начала и так сойдет.

@group(0) @binding(0) var outline_objects: texture_2d<f32>; @group(0) @binding(1) var outline_ui: texture_storage_2d<rgba8unorm, write>; @compute @workgroup_size(8, 8, 1) fn main(@builtin(global_invocation_id) id: vec3<u32>) { let id_x = i32(id.x); let id_y = i32(id.y); let x = i32(id.x) * 2; let y = i32(id.y) * 2; var isEdge = false; var count = 0.0; var total = 0.0; let jump = 3; for (var j = -1; j <= 1; j += 1) { for (var i = -1; i <= 1; i += 1) { let nx = x + i * jump; let ny = y + j * jump; let neighbor_val = textureLoad(outline_objects, vec2<i32>(nx, ny), 0).a; if (neighbor_val > 0.7) { isEdge = true; count += 1.0; } total += 1.0; } } let ratio = f32(count) / total; let base_alpha = 0.7; if (ratio > 0.9) { isEdge = false; } let a = base_alpha * ratio * 1.2; let c = vec3<f32>(0.6, 0.6, 0.9) * 1.1; let color = vec4<f32>(c.rgb, a) * f32(isEdge); textureStore(outline_ui, vec2<i32>(id_x, id_y), color); }
Разные параметры и их влияние на конечный результат.
Финальная картинка.
Финальная картинка.

Что можно улучшить?

  • Использовать GPU object picking вместо индекса на CPU. В этом методе вместо цвета, в первую текстуру пишутся ID объектов после чего читается ID пикселя на который указывает курсор мыши. Этот метод может работать быстрее, но нам он не подходит. Игра должна подсвечивать объекты которые находятся близко к курсору, а пиксельная точность будет только мешать.
  • Передавать цвет которым нужно рисовать контур, этот код был пропущен для упрощения материала.
  • Думаю можно было бы обойтись пиксельным шейдером и немного укорить отрисовку, но это как нибудь потом.
  • Поскольку это пиксель арт игра, то эффект можно и запеч.

Если понравился материал, напиши в комментариях. Могу рассказать как например сделать материалы для пикскль-арт воды или тумана войны:

Вода
Туман Войны
3131
44
22
17 комментариев

бриллиант на этом сайте среди щитпостов и жоп

10

@mira.mervi23 а вот и ответ на твой вопрос "а чё там с bevy в геймдеве", даже со сниппетами кода

5

Ого, пост от растовчанина, aka гигачада. Если бы у вас была возможность откатиться назад во времени, то вы бы всё равно пошли с Bevy, а не условным Godot? Просто любопытно.

Автор

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

5
Автор

Да, конечно. Рассматривал только условно низко-уровневые движки где все можно собрать под себя – bevy, bgfx, libgdx и пр. Какой-то прямо огромной разницы между ними особо нет, я просто выбрал тот который был написан Rust.

2

С виду игрок скажет, что это очередное юнити хрючево, так что есть только два пути, делать игру или делать движок

Автор

Увы, но в это раз вы не угадали, большинство игроков плейтеста и моего Дискорда отмечают продвинутое освещение и графику. Можете зайти на тот же YT и почитать комментарии.

2