Document and demonstrate prepared mesh LOD visibility

This commit is contained in:
Emil
2026-09-23 22:53:56 +03:00
parent c89298fefe
commit 5f61ee3750
4 changed files with 162 additions and 0 deletions
+6
View File
@@ -1,3 +1,9 @@
if(TARGET faset_render)
add_executable(faset_p2_visibility_example
"${PROJECT_SOURCE_DIR}/examples/renderer/p2_visibility.cpp")
target_link_libraries(faset_p2_visibility_example PRIVATE faset_render)
endif()
if(TARGET faset_render AND BUILD_TESTING)
add_executable(faset_render_gpu_visibility_tests
"${PROJECT_SOURCE_DIR}/tests/render_gpu_visibility_tests.cpp")
+68
View File
@@ -0,0 +1,68 @@
# GPU visibility and prepared mesh LOD
Faset can draw opaque static triangle meshes through GPU frustum culling and,
optionally, two-pass HZB occlusion. The original direct renderer remains available
for comparison. In the Editor, build with `FASET_DEBUG_IMGUI=ON`, press **F12**,
and choose **Direct**, **GPU frustum**, or **GPU occlusion** in Diagnostics. **Path:
active** confirms that the selected GPU path actually ran. See
[developer diagnostics](diagnostics.md) for the HZB preview and counters.
The current GPU path groups instances with the same mesh and texture into fixed
indirect draw bins. A previous-frame HZB can defer an object, but the current-frame
post pass checks it again before the final image. Sprites, editor UI, transparent
meshes, and shadow casters keep their existing ordered or shadow paths. This option
does not require ray tracing or mesh shaders.
## Supply prepared LODs from C++
`DrawItem::mesh` is LOD 0. Set `lod_meshes[0]` to a prepared LOD 1 mesh,
`lod_meshes[1]` to LOD 2, and so on. These are actual `Mesh` objects with a
complete triangle list; Faset does not simplify a source mesh automatically.
Keep a stable `instance_key` across frames. Give different scene objects different
keys, and retain the same shared mesh objects instead of rebuilding them every
frame. For a manually constructed `Snapshot`, supply both `projection` and
`view_projection`; set `view_id` for each camera and `camera_cut` when switching
shots. The Player's scene extraction fills the camera fields and instance keys.
```cpp
faset::render::DrawItem draw;
draw.mesh = prepared_high_detail;
draw.lod_meshes = {prepared_medium_detail, prepared_low_detail};
draw.instance_key = "scene/object-id/primitive-id";
draw.model = faset::render::transform({0, 0, 0});
snapshot.draws.push_back(std::move(draw));
```
LOD selection uses projected screen size. The first transition is 192 pixels;
each additional level halves that threshold. A 12% hysteresis band avoids
switching back and forth near a boundary. Missing levels fall back to an available
mesh. Changing level invalidates that instance's temporal occlusion state while
preserving its logical key. `FrameStats::lod_counts` reports selected levels 03
(higher levels are included in the final bucket).
For a runnable C++ example, build `faset_p2_visibility_example` and capture the
same scene in any mode:
```sh
cmake --build --preset linux-debug --target faset_p2_visibility_example
build/linux-debug/faset_p2_visibility_example occlusion /tmp/p2-visibility.ppm
build/linux-debug/faset_p2_visibility_example direct /tmp/p2-direct.ppm
```
The example creates a fine cube and a prepared coarse tetrahedron in
[`examples/renderer/p2_visibility.cpp`](https://github.com/emil28092005/Faset_Engine/blob/main/examples/renderer/p2_visibility.cpp).
Imported GLB geometry can be supplied as prepared levels by C++ integration,
but automatic LOD generation and assigning a GLB's alternate meshes as LODs in
the Inspector are not implemented yet.
## Measure before choosing a mode
The GPU route saves per-instance CPU vertex transformation and direct draw
submission, but its compute passes and indirect rendering have a cost. Keep the
same scene, resolution and camera path when comparing modes. Hide Diagnostics for
an ordinary Player measurement: opening it enables GPU counter readback, while
**Show HZB** adds a separate image copy. The renderer currently captures and
waits for every frame even without this overlay. Read
[profiling and measurements](profiling.md) and the
[P2 acceptance study](https://github.com/emil28092005/Faset_Engine/blob/main/docs/studies/19-p2-gpu-visibility-acceptance.md)
for the measured scope and limitations.
+87
View File
@@ -0,0 +1,87 @@
#include <faset/render/renderer.hpp>
#include <exception>
#include <filesystem>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
using namespace faset::render;
namespace {
std::shared_ptr<const Mesh> coarse_mesh() {
auto mesh = std::make_shared<Mesh>();
mesh->vertices = {
Vertex{{-1, -1, -1}, {-1, -1, -1}},
Vertex{{1, -1, -1}, {1, -1, -1}},
Vertex{{0, 1, 0}, {0, 1, 0}},
Vertex{{0, -1, 1}, {0, -1, 1}},
};
mesh->indices = {0, 2, 1, 0, 1, 3, 1, 2, 3, 2, 0, 3};
return mesh;
}
VisibilityMode mode_from_argument(std::string_view argument) {
if (argument == "direct")
return VisibilityMode::Direct;
if (argument == "frustum")
return VisibilityMode::GpuFrustum;
if (argument == "occlusion")
return VisibilityMode::GpuOcclusion;
throw std::invalid_argument("mode must be direct, frustum, or occlusion");
}
} // namespace
int main(int argc, char** argv) {
try {
if (argc > 3)
throw std::invalid_argument("usage: faset_p2_visibility_example "
"[direct|frustum|occlusion] [capture.ppm]");
RendererConfig config;
config.width = 640;
config.height = 360;
config.headless = true;
config.visibility_mode = argc > 1 ? mode_from_argument(argv[1])
: VisibilityMode::GpuOcclusion;
config.visibility_diagnostics = true;
Renderer renderer(config);
Snapshot frame;
frame.view_id = "p2-lod-example-camera";
frame.eye = {0, 2, 12};
frame.projection = perspective(.9f, float(config.width) / config.height, .1f, 100);
frame.view_projection = multiply(frame.projection,
look_at(frame.eye, {0, 0, 0}));
const auto prepared_coarse = coarse_mesh();
for (int i = 0; i < 9; ++i) {
DrawItem item;
item.mesh = cube_mesh();
item.lod_meshes = {prepared_coarse};
item.instance_key = "example/cube/" + std::to_string(i);
item.model = transform({float(i - 4) * 1.8f, 0, i % 2 ? -5.f : 0.f});
item.color = {0.28f + .07f * i, .6f, .8f, 1};
item.cast_shadow = false;
frame.draws.push_back(std::move(item));
}
// The second and later frames have compatible HZB history for this view.
for (int i = 0; i < 3; ++i)
renderer.render(frame);
const auto& stats = renderer.stats();
renderer.capture(argc > 2 ? std::filesystem::path(argv[2])
: std::filesystem::path("p2-visibility.ppm"));
std::cout << "GPU path: " << (stats.gpu_visibility_active ? "active" : "unavailable")
<< ", bins: " << stats.gpu_bins
<< ", visible: " << stats.gpu_visible_instances
<< ", LOD0: " << stats.lod_counts[0]
<< ", LOD1: " << stats.lod_counts[1]
<< ", validation errors: " << stats.validation_errors << '\n';
return stats.validation_errors ? 1 : 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+1
View File
@@ -44,6 +44,7 @@ nav:
- Editor workspace: editor/workspace.md
- Scene templates: editor/templates.md
- Assets and Blender: editor/assets.md
- GPU visibility and mesh LOD: editor/visibility-lod.md
- Build, Play, and export: editor/export.md
- Profiling and measurements: editor/profiling.md
- Optional developer diagnostics: editor/diagnostics.md