feat: 双视图视锥剔除(相机/光源各自剔除)

- Frustum(Gribb-Hartmann):从 view-proj 行组合提取 6 平面,
  包围球相交测试;按 Vulkan [0,1] 深度约定取 near=row2
- Mesh 构造时缓存局部空间包围球(顶点包围盒 → 中心+半径)
- 实例收集重写:单趟遍历,每个 (实体, mesh 部件) 对相机视锥与
  光源视锥分别做剔除——相机外实体仍可投影子进视野,Shadow 必须用
  光源视锥独立剔除,不能用相机结果
- 实例矩阵数组 = [主视图段 | 阴影视图段],batch.first 全局下标;
  两视图共享同一份 ObjectBuffer SSBO
- 聚合改哈希(unordered_map),单趟填充替代原两趟遍历
- 面板显示 instances main/shadow 计数(剔除效果实时可见)

实测:1 背包 80 main / 80 shadow(修复前 shadow=21——near 平面
按 [-1,1] 矩阵误用 [0,1] 公式,光锥近半段被误剔)
This commit is contained in:
2026-09-23 11:21:28 +08:00
parent df4871a49b
commit fa9082706b
6 changed files with 168 additions and 41 deletions
+3
View File
@@ -169,6 +169,9 @@ protected:
// Pass 级 GPU 耗时(RG timestamp)
ImGui::Separator();
ImGui::Text("GPU Timings");
ImGui::Text("instances: %u main / %u shadow",
renderer.GetMainInstanceCount(),
renderer.GetShadowInstanceCount());
const auto& names = renderer.GetPassNames();
const auto& times = renderer.GetPassGpuMs();
for (size_t i = 0; i < names.size() && i < times.size(); ++i)
+64
View File
@@ -0,0 +1,64 @@
//
// Created by atdunbg on 2026/9/23.
//
// Frustum:Gribb-Hartmann 视锥平面提取 + 包围球相交测试。
//
// 平面从 view-proj 矩阵的行组合中直接抽出(无需分解参数),
// 用于按视图剔除(相机视图 / 光源视图各自独立一份)。
// 注意:near/far 平面按 Vulkan [0,1] 深度约定提取
//(z_clip ∈ [0,1]:near = row2,far = row3 - row2)。
//
#ifndef HELLOVULKAN_FRUSTUM_H
#define HELLOVULKAN_FRUSTUM_H
#include <Engine/Core/Types.h>
#include <glm/glm.hpp>
#include <array>
namespace Engine {
struct Frustum {
// 依次:left, right, bottom, top, near, far
// xyz = 平面法线(未归一化,仅做符号/点积测试),w = 偏移
std::array<glm::vec4, 6> planes{};
// 从 view-proj 矩阵提取 6 平面(列主序 glm 矩阵的"行"组合)。
// 注意必须传未做 [0,1] 深度重映射的矩阵,提取公式才成立。
static Frustum FromMatrix(const glm::mat4& m)
{
Frustum f;
// 列主序下的"行 i": (m[0][i], m[1][i], m[2][i], m[3][i])
const auto row = [&m](u32 i) {
return glm::vec4(m[0][i], m[1][i], m[2][i], m[3][i]);
};
const glm::vec4 r0 = row(0);
const glm::vec4 r1 = row(1);
const glm::vec4 r2 = row(2);
const glm::vec4 r3 = row(3);
f.planes[0] = r3 + r0; // left: x' >= -w
f.planes[1] = r3 - r0; // right: x' <= w
f.planes[2] = r3 + r1; // bottom: y' >= -w
f.planes[3] = r3 - r1; // top: y' <= w
f.planes[4] = r2; // near: z' >= 0(Vulkan [0,1])
f.planes[5] = r3 - r2; // far: z' <= w
return f;
}
// 包围球与视锥相交测试(外接球近似,略保守——宁多画不漏画)。
// 对每个平面:球心在平面负侧且距离超过 radius 才算外。
bool IntersectsSphere(const glm::vec3& center, f32 radius) const
{
for (const auto& p : planes) {
if (glm::dot(glm::vec3(p), center) + p.w < -radius) {
return false;
}
}
return true;
}
};
} // namespace Engine
#endif //HELLOVULKAN_FRUSTUM_H
+8
View File
@@ -37,11 +37,19 @@ namespace Engine
Material* GetMaterial() const { return m_Material.get(); }
u32 GetIndexCount() const { return m_IndexCount; }
// 局部空间包围球(构造时从顶点算出),视锥剔除用
struct Bounds {
glm::vec3 center{0.0f};
f32 radius = 0.0f;
};
const Bounds& GetBounds() const { return m_Bounds; }
private:
Scope<Buffer> m_VertexBuffer;
Scope<Buffer> m_IndexBuffer;
Scope<Material> m_Material;
u32 m_IndexCount = 0;
Bounds m_Bounds;
u64 m_VertexBytes = 0;
u64 m_IndexBytes = 0;
+16 -2
View File
@@ -11,6 +11,7 @@
#include <Engine/Core/FrameContext.h>
#include <Engine/RenderGraph/RenderGraph.h>
#include <vulkan/vulkan.hpp>
#include <unordered_map>
#include <vector>
#include <glm/glm.hpp>
@@ -138,8 +139,17 @@ namespace Engine {
glm::mat4 m_LightSpace{1.0f}; // 阴影光源 VP(每帧由 Render 算好)
// GPUDriven:当帧实例数据(Render 期收集,Forward pass 消费)
std::vector<InstanceBatch> m_InstanceBatches;
std::vector<glm::mat4> m_InstanceModels;
// 双视图剔除:相机视锥与光源视锥各出一份可见列表——
// 相机外的实体仍可能投影子进视野,所以 Shadow 用光源视锥单独剔除
struct CullResult {
std::vector<InstanceBatch> batches;
u32 instanceCount = 0;
};
CullResult m_MainView; // 相机视锥
CullResult m_ShadowView; // 光源视锥
std::unordered_map<Mesh*, u32> m_MainBatchLookup;
std::unordered_map<Mesh*, u32> m_ShadowBatchLookup;
std::vector<glm::mat4> m_InstanceModels; // [主视图段 | 阴影视图段]
static constexpr u32 kMaxInstances = 16384;
// 可选叠加层(不拥有;Application 持有)
@@ -174,6 +184,10 @@ namespace Engine {
const std::vector<std::string>& GetPassNames() const { return m_PassNames; }
const std::vector<f32>& GetPassGpuMs() const { return m_PassGpuMs; }
// 双视图剔除后的实例数(遥测)
u32 GetMainInstanceCount() const { return m_MainView.instanceCount; }
u32 GetShadowInstanceCount() const { return m_ShadowView.instanceCount; }
// 垂直同步开关(Mailbox ↔ Immediate):切换会重建 swapchain,
// 只应在帧 UI 构建段调用(Render 之前)
void SetVSync(bool vsync);
+18
View File
@@ -9,6 +9,8 @@
#include <Engine/Vulkan/Buffer.h>
#include <Engine/Core/Log.h>
#include <limits>
#include "Engine/Core/Stats.h"
namespace Engine
@@ -43,6 +45,22 @@ namespace Engine
m_IndexCount = static_cast<u32>(indices.size());
// 局部空间包围球:顶点包围盒 → 中心 + 最大半径(视锥剔除用)
{
glm::vec3 mn(std::numeric_limits<f32>::max());
glm::vec3 mx(std::numeric_limits<f32>::lowest());
for (const auto& v : vertices) {
mn = glm::min(mn, v.position);
mx = glm::max(mx, v.position);
}
m_Bounds.center = (mn + mx) * 0.5f;
m_Bounds.radius = 0.0f;
for (const auto& v : vertices) {
m_Bounds.radius = glm::max(m_Bounds.radius,
glm::length(v.position - m_Bounds.center));
}
}
ENGINE_INFO("Mesh created: {} vertices, {} indices", vertices.size(), indices.size());
m_VertexBytes = sizeof(Vertex) * vertices.size();
+59 -39
View File
@@ -20,6 +20,7 @@
#include <Engine/Core/Window.h>
#include <Engine/Core/Log.h>
#include <Engine/ImGui/ImGuiLayer.h>
#include <Engine/Renderer/Frustum.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
@@ -446,9 +447,8 @@ namespace Engine
vk::Rect2D scissor({0, 0}, {kShadowMapSize, kShadowMapSize});
cmd.setScissorWithCount(scissor);
// ---- 绘制:GPUDriven——与 Forward 共用同一批 batch ----
// lightVP 全 pass 只 push 一次;模型矩阵经实例索引从
// ObjectBuffer 取(与 Forward 同一份数据)
// ---- 绘制:GPUDriven——与 Forward 共用实例数据,但用
// 光源视锥剔除后的 batch(相机外实体仍投影子)----
cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics,
m_ShadowPipelineLayout, 0,
m_UBODescriptorSets[ctx.frame.frameSlot], {});
@@ -459,7 +459,7 @@ namespace Engine
vk::ShaderStageFlagBits::eFragment,
0, sizeof(glm::mat4), &m_LightSpace);
for (const auto& batch : m_InstanceBatches) {
for (const auto& batch : m_ShadowView.batches) {
batch.mesh->Draw(cmd, batch.count, batch.first);
}
@@ -475,7 +475,7 @@ namespace Engine
vk::ClearValue clearValue;
clearValue.color = vk::ClearColorValue(
std::array<float, 4>{0.0f, 0.0f, 0.0f, 0.0f});
std::array<float, 4>{0.1f, 0.1f, 0.1f, 0.0f});
vk::RenderingAttachmentInfo colorAttachment;
colorAttachment.setImageView(ctx.View(m_HDRColor))
@@ -570,11 +570,11 @@ namespace Engine
vk::ColorComponentFlagBits::eA;
cmd.setColorWriteMaskEXT(0, colorWriteMask);
// ---- 绘制:GPUDriven 按 mesh 分 batch ----
// ---- 绘制:GPUDriven 按 mesh 分 batch(相机视锥剔除后)----
// 实例矩阵已在 Render() 里写入 ObjectBuffer(per-slot SSBO);
// 同 mesh 的 N 个实体合并为一次 drawIndexed(instanceCount=N),
// draw 数 = 唯一 mesh 数,与实体数量解耦
for (const auto& batch : m_InstanceBatches) {
// 同 mesh 的 N 个可见实体合并为一次 drawIndexed(instanceCount=N),
// draw 数 = 可见唯一 mesh 数,与实体数量解耦
for (const auto& batch : m_MainView.batches) {
cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics,
m_PipelineLayout, kTextureSetIndex,
batch.mesh->GetMaterial()->GetDescriptorSet(), {});
@@ -945,49 +945,69 @@ namespace Engine
m_UniformBuffer->Update(frameSlot, &ubo, sizeof(ubo));
m_SyncManager->ResetFence(frameSlot);
// ---- GPUDriven:收集实例数据(按 mesh 聚合 batch)----
// 两趟遍历:先收集唯一 mesh(保持首现序),再逐 batch 追加实例矩阵,
// 保证每个 batch 的矩阵区间连续(draw 用 firstInstance 指向起点)。
// TODO: 实体量大时改哈希聚合 + 单趟填充
// ---- GPUDriven:单趟收集 + 双视图剔除 ----
// 每个实例对相机视锥、光源视锥分别做包围球测试:
// 进相机视锥 → 主视图 batch(Forward 画)
// 进光源视锥 → 阴影 batch(Shadow 画)——相机外实体仍可投影子
// 实例矩阵数组 = [主视图段 | 阴影视图段],batch.first 为全局下标。
// 哈希聚合保证各视图内 batch 区间连续。
const auto collectStart = std::chrono::steady_clock::now();
m_InstanceBatches.clear();
m_InstanceModels.clear();
m_MainView.batches.clear();
m_ShadowView.batches.clear();
m_MainView.instanceCount = 0;
m_ShadowView.instanceCount = 0;
m_MainBatchLookup.clear();
m_ShadowBatchLookup.clear();
bool instanceTruncated = false;
std::vector<Mesh*> uniqueMeshes;
const Frustum mainFrustum = Frustum::FromMatrix(ubo.proj * ubo.view);
// 注意:剔除平面提取按 [0,1] 深度约定——相机 proj 与 m_LightSpace
// 都已做 [0,1] 重映射;原始 [-1,1] 矩阵的 near 平面公式不同,勿用
const Frustum shadowFrustum = Frustum::FromMatrix(m_LightSpace);
scene.Each<TransformComponent, MeshRendererComponent>(
[&](Entity, TransformComponent&, MeshRendererComponent& mr) {
[&](Entity, TransformComponent& t, MeshRendererComponent& mr) {
Model* model = assets.GetModel(mr.model);
if (!model) return;
for (auto& mesh : model->GetMeshes()) {
if (std::find(uniqueMeshes.begin(), uniqueMeshes.end(),
mesh.get()) == uniqueMeshes.end()) {
uniqueMeshes.push_back(mesh.get());
}
}
});
for (Mesh* meshPtr : uniqueMeshes) {
InstanceBatch batch{meshPtr,
static_cast<u32>(m_InstanceModels.size()), 0};
scene.Each<TransformComponent, MeshRendererComponent>(
[&](Entity, TransformComponent& t, MeshRendererComponent& mr) {
Model* model = assets.GetModel(mr.model);
if (!model) return;
for (auto& m : model->GetMeshes()) {
if (m.get() != meshPtr) continue;
// 实例世界缩放(矩阵列向量最大长度,包围球半径随缩放)
const f32 worldScale = glm::max(
glm::length(glm::vec3(t.world[0])),
glm::max(glm::length(glm::vec3(t.world[1])),
glm::length(glm::vec3(t.world[2]))));
for (auto& mesh : model->GetMeshes()) {
const auto& bounds = mesh->GetBounds();
const glm::vec3 worldCenter =
glm::vec3(t.world * glm::vec4(bounds.center, 1.0f));
const f32 worldRadius = bounds.radius * worldScale;
auto tryAppend = [&](CullResult& view, const Frustum& frustum,
std::unordered_map<Mesh*, u32>& lookup) {
if (!frustum.IntersectsSphere(worldCenter, worldRadius)) {
return;
}
if (m_InstanceModels.size() >= kMaxInstances) {
instanceTruncated = true;
return;
}
const auto [it, inserted] = lookup.emplace(
mesh.get(), static_cast<u32>(view.batches.size()));
if (inserted) {
view.batches.push_back({mesh.get(),
static_cast<u32>(m_InstanceModels.size()), 0});
}
view.batches[it->second].count++;
view.instanceCount++;
m_InstanceModels.push_back(t.world);
++batch.count;
}
});
if (batch.count > 0) {
m_InstanceBatches.push_back(batch);
}
}
};
tryAppend(m_MainView, mainFrustum, m_MainBatchLookup);
tryAppend(m_ShadowView, shadowFrustum, m_ShadowBatchLookup);
}
});
if (instanceTruncated) {
// 只告警一次(每帧告警会刷屏)
static bool warned = false;