This document is the internal reference for IRenderer and the prepare/submit pipeline. For everyday use, the runtime sets up the renderer for you and you call app.update() / app.present() instead of touching the renderer directly; see runtime.md. Read this when you need to drive the renderer manually, when you want to understand what app.prepare() and app.submit() do internally, or when you're implementing a custom render path.
For the GPU data model and backend architecture, see render-backend.md. For what velk::instance().update() does (which runs before any rendering), see update-cycle.md.
- Views: renderer, surfaces, and cameras
- prepare / present split
- FrameDesc: selective rendering
- Frame slots and back-pressure
- Frame skipping
- Multi-rate rendering
- What prepare() does internally
- What present() does internally
- Performance profiling
- Classes
The renderer draws scenes onto surfaces through views. A view is a pairing of a camera element and a surface:
auto renderer = velk::ui::create_renderer(*render_ctx);
renderer->add_view(camera_element, surface);When using the runtime, app.add_view(window, camera) does the equivalent: it pulls the surface from the window and forwards to renderer->add_view.
Surfaces (ISurface) represent render targets. A surface maps to a backend swapchain with a surface_id. It has width and height properties but no knowledge of scenes or cameras. Surfaces are created via IRenderContext::create_surface().
Camera elements are regular scene elements with an ICamera trait attached. The camera provides the view-projection matrix for rendering. The camera's element also provides the scene: camera_element->get_scene() is how the renderer finds which scene to draw.
Scenes own the element hierarchy, layout solver, and dirty tracking. They are passive during rendering: the renderer pulls state via scene->consume_state().
A scene can be rendered to multiple surfaces by adding multiple views with cameras from the same scene:
renderer->add_view(main_camera, monitor_surface);
renderer->add_view(main_camera, projector_surface);Each surface gets its own swapchain and presentation timing. The scene is consumed once per prepare; both surfaces share the same draw commands (rebuild happens once) but get separate GPU submissions.
Multiple cameras can render to the same surface (e.g. a split-screen or picture-in-picture setup):
renderer->add_view(camera_left, surface);
renderer->add_view(camera_right, surface);Each camera provides a different view-projection matrix. The renderer processes them sequentially within a single frame, each producing its own set of draw calls for the same surface.
Cameras from different scenes can coexist in the same renderer:
renderer->add_view(game_camera, main_surface); // game scene
renderer->add_view(hud_camera, main_surface); // HUD scene (overlay)
renderer->add_view(minimap_camera, minimap_surface); // minimap sceneEach camera's get_scene() returns its own scene. The renderer consumes state from each scene independently.
IRenderer
├── View: camera_a + surface_1 ──► Scene A (via camera_a->get_scene())
├── View: camera_b + surface_1 ──► Scene B (via camera_b->get_scene())
└── View: camera_c + surface_2 ──► Scene A (via camera_c->get_scene())
Surface 1 ──► Backend swapchain (surface_id=1)
Surface 2 ──► Backend swapchain (surface_id=2)
Views are registered with add_view() and removed with remove_view(). The FrameDesc passed to prepare() can filter which surfaces and cameras to include in a given frame (see Selective rendering below).
Rendering is split into two phases:
| Phase | Method | Thread | Work |
|---|---|---|---|
| Prepare | renderer->prepare(desc) |
Main thread | Consume scene state, rebuild draw commands, write GPU buffers, and record the frame's command buffers (the whole frame is recorded here). Returns an opaque Frame handle. |
| Present | renderer->present(frame) |
Any thread | Submit the already-recorded frame to the GPU and present it (submit_frame). Records nothing itself; blocks on vsync. |
The convenience method renderer->render() calls present(prepare({})) for the simple single-threaded case.
Each frame slot owns its own GPU staging buffer (1 MB initial, growing on demand). Whatever prepare() writes there goes into the slot's own buffer, not a shared one, so a prepared frame's data is never overwritten by a subsequent prepare() call; it stays valid until present() submits the frame and recycles the slot.
Very little still goes through it. Its one remaining job is the indirect-draw commands of batches that have no persistent storage buffer of their own (the environment batch). Everything else (draw headers, per-instance arrays, material records, view globals, lights, mesh geometry, glyph tables) lives in a stable region of a shared set = 1 arena owned by its producer and rewritten only when its contents change. See the GPU resource model.
Because those regions are stable and their frees are deferred behind the frame-completion fence, an in-flight GPU read can never see its bytes reassigned, and a steady-state frame uploads almost nothing.
Heavy data like textures lives in separate GPU allocations. Even a complex frame with thousands of draw entries typically writes well under 1 MB per frame.
present() blocks on vsync (typically 16-17ms at 60Hz). During that time the main thread could be running the next velk::instance().update() and prepare().
The renderer does not create threads. The application decides the threading strategy:
- Single-threaded: call
renderer->render()(orapp.present()) which does prepare + present sequentially. - Threaded: prepare on the main thread, send the
Framehandle to a render thread, present from there. - Platform-driven: e.g. on Android, prepare from the framework's update callback and present from
onDrawFrameon the render thread.
The runtime layer wraps these patterns; see runtime.md for app.prepare() / app.submit() and how to split them across threads. The frame slot system below is what makes this safe regardless of the threading strategy.
prepare() accepts a FrameDesc that controls which surfaces and cameras to render:
struct ViewDesc
{
ISurface::Ptr surface;
vector<IElement::Ptr> cameras; // Empty = all cameras for this surface
};
struct FrameDesc
{
vector<ViewDesc> views; // Empty = all registered views
};An empty FrameDesc (the default) renders all registered views. Specifying surfaces or cameras filters the work.
The renderer manages a pool of frame slots. Each prepare() claims a slot and fills it with draw calls. present() submits the slot and recycles it.
If all slots are occupied (prepare is outpacing present), prepare() blocks until a slot becomes available. This provides natural back-pressure without unbounded memory growth.
The pool size is configurable at runtime:
renderer->set_max_frames_in_flight(2); // default is 3, minimum 1Lower values reduce latency (fewer pre-rendered frames) at the cost of potentially stalling prepare when present is slow. Higher values allow more overlap but increase input-to-display latency.
present(frame) presents that frame and silently discards all older unpresented frames that target the same surfaces, recycling their slots. This means:
- No frame leaks: stale frames are cleaned up automatically
- Skipping frames is a normal operation, not an error (i.e. the app can decide that an intermediate frame is stale)
- Independent surfaces are not affected: presenting frame on
surface1does not discard pending frames onsurface2if they have been prepared separately.
Different surfaces can update at different frequencies. Include multiple surfaces in a single prepare() call when they need to update together, so that present() submits them back-to-back without blocking between surfaces:
// This example renders main_surface on every frame but secondary_surface only when time_for_60Hz is true
if (time_for_60hz) {
// Both surfaces update: one prepare, one present, one block
auto f = renderer->prepare({{main_surface}, {secondary_surface}});
renderer->present(f);
} else {
// Only the main display updates this tick
auto f = renderer->prepare({{main_surface}});
renderer->present(f);
}A single prepare() call can target any combination of surfaces. The resulting frame contains draw calls for all of them, and present() submits them in sequence within a single call.
- Claim a frame slot from the pool (block if none free)
backend->begin_frame(): wait on the slot's GPU fence, start the primary command buffer- For each view matching the
FrameDesc: a. Check for surface resize / native-window swap; update the backend if needed (resize_surface/recreate_surface) b.scene->consume_state()to get the redraw/removed lists c. Evict removed elements from the draw command cache d.rebuild_commands()for dirty elements (queryIVisualattachments) e. Upload dirty textures (e.g. glyph atlas updates) f.rebuild_batches()if batches are dirty (group by pipeline + texture) g. Run the upload sweep: allocate / refresh each producer's arena region (instances, draw headers, material records, view globals, lights) for those whose data changed h. Build theDrawCallarray and emit the view's passes into the frame graph - Compile + execute the frame graph (records the primary command buffer), then
backend->close_frame() - Return the
Framehandle; the frame is now fully recorded
- Discard older unpresented frames targeting the same surfaces
backend->submit_frame(): acquire the swap image, run the composite-to-swap blit, submit GPU work, and present- Recycle the frame slot
The renderer is instrumented with VELK_PERF_SCOPE at key stages. With stats collection enabled (on by default), accumulated timing data is printed at shutdown:
[PERF] renderer.prepare med= 0.007ms p95= 0.007ms ...
[PERF] renderer.rebuild_commands med= 0.002ms p95=139.026ms ...
[PERF] renderer.rebuild_batches med= 0.008ms p95= 0.008ms ...
[PERF] renderer.build_draw_calls med= 0.004ms p95= 0.004ms ...
[PERF] renderer.present med= 6.927ms p95= 7.147ms ...
[PERF] renderer.begin_frame med= 6.825ms p95= 7.024ms ...
Custom perf scopes can be added with #include <velk/api/perf.h>:
VELK_PERF_SCOPE("my_operation");Stats can be queried programmatically via instance().perf_log().get_stats().
ClassIds for the rendering layer's main types. The runtime constructs these on first window creation; manual construction is only needed when bypassing the runtime.
| ClassId | Implements | Description |
|---|---|---|
velk::ClassId::RenderContext |
IRenderContext |
Owns the render backend, surface factory, shader compiler, pipeline registry. Construct via velk::create_render_context(config). |
velk::ClassId::Surface |
ISurface |
Render target with width, height, update_rate, target_fps properties. Created by IRenderContext::create_surface(SurfaceConfig). The actual swapchain is built lazily when the renderer's add_view first sees the surface. |
velk::ClassId::Renderer |
IRenderer, IRendererInternal |
Scene renderer. Walks views, builds batches, writes GPU buffers, submits to the backend. Construct via velk::ui::create_renderer(ctx). |
For shader materials see Materials. For the lower-level GPU interface (IRenderBackend, buffers, pipelines, bindless textures) see Render backend.