image-20211214200711937

鼠标拾取是引擎场景编辑器的一个非常基础的功能,通过点击屏幕像素选择场景中的物体,能够使用户更加方便地选择物体、编辑场景。利用空闲时间我将IlumEngine场景编辑器的鼠标拾取功能做了一个优化,从原来的基于Ray Casting方法到现在所使用的G-Buffer回读的方法,两种方法各有优劣,下面详细介绍这两种鼠标拾取的方法。

1. 基于Ray Casting的鼠标拾取方法

基于Ray Casting的鼠标拾取是一种几何方法,其基本原理如下:

  1. 由鼠标点击的屏幕像素坐标,生成一条从摄像机发射的射线
  2. 对场景作求交计算(与Ray Tracing中的相交检测相同)
  3. 寻找与光线相交的最近包围盒,其对应的物体即为鼠标将选中的物体

已知我们已从窗口/UI系统中得到鼠标点击的像素坐标click_pos,首先将其转化为屏幕空间坐标:

1
2
float x = (click_pos.x / scene_view_size.x) * 2.f - 1.f;
float y = -((click_pos.y / scene_view_size.y) * 2.f - 1.f);

我们希望利用拾取点的屏幕空间坐标从相机发射一条射线,一种思路是计算拾取点的远近平面投影坐标,然后将它们连起来即可:

1
2
3
4
5
6
7
8
9
10
glm::mat4 inv = glm::inverse(main_camera.view_projection);

glm::vec4 near_point = inv * glm::vec4(x, y, 0.f, 1.f);
near_point /= near_point.w;
glm::vec4 far_point = inv * glm::vec4(x, y, 1.f, 1.f);
far_point /= far_point.w;

geometry::Ray ray;
ray.origin = main_camera.position;
ray.direction = glm::normalize(glm::vec3(far_point - near_point));

最后,用射线做与包围盒的求交,求交的计算也可用BVH、KD-Tree等加速结构进行加速,这里为了快速实现只是简单遍历并对每个包围盒进行求交:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float distance = std::numeric_limits<float>::infinity();
const auto group = Scene::instance()->getRegistry().group<>(entt::get<cmpt::MeshRenderer, cmpt::Transform>);
group.each([&](const entt::entity &entity, const cmpt::MeshRenderer &mesh_renderer, const cmpt::Transform &transform) {
if (!Renderer::instance()->getResourceCache().hasModel(mesh_renderer.model))
{
return;
}
auto &model = Renderer::instance()-getResourceCache().loadModel(mesh_renderer.model);
float hit_distance = ray.hit(model.get().bounding_box.transform(transform.world_transform));
if (distance > hit_distance)
{
distance = hit_distance;
Editor::instance()->select(Entity(entity));
}
});
}

具体求交的计算后续光线追踪模块的开发将会提到,这里不作具体阐述。

2. 基于G-Buffer回读的鼠标拾取方法

基于G-Buffer的鼠标拾取是一种图像方法,在几何阶段生成G-Buffers时我们顺带生成一张带有场景物体实体ID的G-Buffer,格式为VK_FORMAT_R32_UINT。在得到鼠标响应时,将该G-Buffer的数据回读到CPU中,利用像素坐标查找相应的实体ID,得到拾取到的对象。完整过程的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ImageReference entity_id_buffer = Renderer::instance()->getRenderGraph()->getAttachment("debug - entity");

CommandBuffer cmd_buffer;
cmd_buffer.begin();
Buffer staging_buffer(static_cast<VkDeviceSize>(entity_id_buffer.get().getWidth() * entity_id_buffer.get().getHeight()) * sizeof(uint32_t), VK_BUFFER_USAGE_TRANSFER_DST_BIT, VMA_MEMORY_USAGE_GPU_TO_CPU);
cmd_buffer.transferLayout(entity_id_buffer, VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_USAGE_TRANSFER_SRC_BIT);
cmd_buffer.copyImageToBuffer(ImageInfo{entity_id_buffer, VK_IMAGE_USAGE_TRANSFER_SRC_BIT, 0, 0}, BufferInfo{staging_buffer, 0});
cmd_buffer.transferLayout(entity_id_buffer, VK_IMAGE_USAGE_TRANSFER_SRC_BIT, VK_IMAGE_USAGE_SAMPLED_BIT);
cmd_buffer.end();
cmd_buffer.submitIdle();
std::vector<uint32_t> image_data(entity_id_buffer.get().getWidth() * entity_id_buffer.get().getHeight());
std::memcpy(image_data.data(), staging_buffer.map(), image_data.size() * sizeof(uint32_t));

click_pos.x = glm::clamp(click_pos.x, 0.f, static_cast<float>(entity_id_buffer.get().getWidth()));
click_pos.y = glm::clamp(click_pos.y, 0.f, static_cast<float>(entity_id_buffer.get().getHeight()));

auto entity = Entity(static_cast<entt::entity>(image_data[static_cast<uint32_t>(click_pos.y) * entity_id_buffer.get().getWidth() + static_cast<uint32_t>(click_pos.x)]));
Editor::instance()->select(entity);

staging_buffer.unmap();

由于G-Buffer的内存访问方式均为GPU_only的,我们需要使用一块GPU_to_CPU的Buffer进行暂存,最后Map到CPU内存中。

3. 比较与选择

  • Ray Casting方法
    • 优点
      • CPU实现,不依赖于渲染管线,能够很方便地集成
    • 缺点
      • 不够精确,由于是射线与包围盒求交,拾取的实际上是物体对应的包围盒而不是物体本身,有时候会带来误差,在场景复杂时效果不好
      • 性能受场景规模影响较大,而使用加速结构进行求交加速实际上也增加了集成复杂度(需要引擎具有光追或物理模块支持)
  • G-Buffer方法
    • 优点
      • 精准,由于是直接把实体ID贴到纹理上,因此能够做到像素级的拾取
    • 缺点
      • 需要一张G-Buffer,增加了带宽开销
      • 需要回读GPU数据,不过只有在鼠标点击时才会触发,影响并不大
      • 需要渲染管线支持,需要配合整个渲染系统进行设计

在开发前期,Render Graph还不够完善,渲染管线扩展能力一般,为了简便,我先直接用Ray Casting的方法给IlumEngine加上一个基本能用的拾取方法,后来为了拾取精度的需要,将拾取算法改为了基于G-Buffer方法。

最终实现效果如下: