Add offscreen render targets and rename AssetRegistry to ResourceRegistry

Add a RenderTarget resource so the scene can render offscreen instead of
straight to the swapchain. The editor uses it to show the scene inside the
Viewport panel.

- Rename AssetRegistry to ResourceRegistry (AssetTraits -> ResourceTraits,
  assets() -> resources()) and move the pools from engine/src/assets to
  engine/src/resources. It now covers non-asset GPU resources as well.
- Replace loadMesh() with create<T>(...), and add create<RenderTarget>(),
  requestResize(), getExtent() and isAlive().
- Add RenderTarget and RenderTargetPool with RenderTargetHandle.
  Resizes are deferred and applied at the start of the next frame, when the
  GPU is idle.
- Add Application::setSceneTarget(). With no target set, the scene still
  renders to the swapchain. With a target set, the scene renders offscreen
  and the swapchain receives only the UI. Frame extent and camera projection
  follow the target's size.
- Add sophia::UI (Application::ui()) with image() and getTextureId() to draw
  a render target in an ImGui window, sizing the target to the available
  space. ImGuiRenderer now registers the target textures for this.
- ViewportPanel takes the UI and a target, and draws the scene image in place
  of the flat background fill.
- Update the testbed, the boids example, and the glossary for the rename.
This commit is contained in:
2026-09-20 16:21:22 -07:00
parent 1485ce01f3
commit 5e1caabde6
26 changed files with 743 additions and 189 deletions
@@ -0,0 +1,81 @@
#include "resources/pools/render_target_pool.hpp"
#include <sophia/util/assert.hpp>
#include <utility>
namespace sophia {
namespace {
constexpr u32 GENERATION_MASK = (1u << 12) - 1;
} // namespace
RenderTargetPool::RenderTargetPool(Device& device, vk::Format format)
: device{device}, format{format} {}
RenderTargetPool::~RenderTargetPool() = default;
RenderTargetHandle RenderTargetPool::create(u32 width, u32 height) {
auto target = std::make_unique<RenderTarget>(device, format, width, height);
if (!freeList.empty()) {
u32 index = freeList.back();
freeList.pop_back();
slots[index].target = std::move(target);
return RenderTargetHandle{index, slots[index].generation};
}
slots.push_back(Slot{std::move(target), 0});
return RenderTargetHandle{static_cast<u32>(slots.size() - 1), 0};
}
const RenderTargetPool::Slot& RenderTargetPool::validate(
RenderTargetHandle handle) const {
SOPHIA_ASSERT(handle.isValid(), "invalid RenderTargetHandle");
SOPHIA_ASSERT(handle.index < slots.size(),
"RenderTargetHandle index out of range:", handle.index);
const Slot& slot = slots[handle.index];
SOPHIA_ASSERT(
slot.generation == handle.generation,
"stale RenderTargetHandle (generation mismatch):", handle.index);
SOPHIA_ASSERT(
slot.target != nullptr,
"RenderTargetHandle refers to a released target:", handle.index);
return slot;
}
RenderTarget& RenderTargetPool::get(RenderTargetHandle handle) {
return const_cast<RenderTarget&>(std::as_const(*this).get(handle));
}
const RenderTarget& RenderTargetPool::get(RenderTargetHandle handle) const {
return *validate(handle).target;
}
void RenderTargetPool::release(RenderTargetHandle handle) {
validate(handle);
// Earlier frames may still be rendering into or sampling the target.
device.waitIdle();
Slot& slot = slots[handle.index];
slot.target.reset();
slot.generation = (slot.generation + 1) & GENERATION_MASK;
freeList.push_back(handle.index);
}
bool RenderTargetPool::isAlive(RenderTargetHandle handle) const {
return handle.isValid() && handle.index < slots.size() &&
slots[handle.index].generation == handle.generation &&
slots[handle.index].target != nullptr;
}
void RenderTargetPool::applyPendingResizes() {
for (Slot& slot : slots) {
if (slot.target) {
slot.target->applyPendingResize();
}
}
}
} // namespace sophia