Capture, diagnostics, and headless rendering
This page covers embedding rayrai in an application and driving it without a GUI: ImGui integration, the headless/offscreen GL context and context hand-off between renderers, the camera and picking APIs, screenshot and diagnostics captures, exposure and calibration helpers, async mesh loading and the mesh caches, compressed texture storage, and heightmap textures and streaming updates.
ImGui integration (SDL2 + OpenGL)
rayrai is designed to be embedded in custom UI. The repository ships a minimal
SDL2/ImGui helper in rayrai/example_common.hpp. The core idea is:
Create an OpenGL context (SDL2 here).
Render the RayraiWindow texture into an ImGui
Image.Forward hover and cursor data to
RayraiWindow::update.
Minimal pattern (trimmed from the examples):
ExampleApp app;
if (!app.init("rayrai_example", 1280, 720))
return -1;
auto world = std::make_shared<raisim::World>();
auto viewer = std::make_shared<raisin::RayraiWindow>(world, 1280, 720);
while (!app.quit) {
app.processEvents();
world->integrate();
app.beginFrame();
app.renderViewer(*viewer); // ImGui::Image + viewer.update(...)
app.endFrame();
}
The renderViewer helper uses ImGui::IsItemHovered() and the mouse
position to drive camera interaction and picking. It renders at the window’s
drawable (framebuffer) size and scales the cursor from window to drawable
pixels, so input stays aligned on HiDPI displays.
Headless/offscreen OpenGL context
If you already manage an OpenGL context (or want a headless one), use the static helpers to create and bind a hidden SDL window context:
SDL_Window* window = nullptr;
SDL_GLContext glContext = nullptr;
raisin::RayraiWindow::createOffscreenGlContext(window, glContext, "rayrai_offscreen");
raisin::RayraiWindow::makeOffscreenContextCurrent(window, glContext);
auto world = std::make_shared<raisim::World>();
raisin::RayraiWindow viewer(world, 640, 480);
createOffscreenGlContext initializes SDL video if needed and requests an
OpenGL 4.3 core context, falling back to 3.3; it aborts when no context can be
created. The caller owns both handles and releases them with
SDL_GL_DeleteContext and SDL_DestroyWindow.
The offscreen path does not require an ImGui context. If an application embeds rayrai
without ImGui, pass false for the hover and click arguments of RayraiWindow::update
or drive the camera state explicitly. The renderer guards ImGui input access, so headless
tests and batch image generation can run without creating ImGui state.
End-to-end headless capture (build a scene, render once, write a PNG):
#include <SDL.h>
#define STB_IMAGE_WRITE_IMPLEMENTATION // in exactly one source file
#include <stb/stb_image_write.h>
#include <raisim/World.hpp>
#include <rayrai/RayraiWindow.hpp>
int main() {
SDL_Init(SDL_INIT_VIDEO);
SDL_Window* window = nullptr;
SDL_GLContext gl = nullptr;
raisin::RayraiWindow::createOffscreenGlContext(window, gl, "capture");
raisin::RayraiWindow::makeOffscreenContextCurrent(window, gl);
auto world = std::make_shared<raisim::World>();
world->addGround();
auto* box = world->addBox(0.4, 0.4, 0.4, 1.0);
box->setPosition(0.0, 0.0, 0.2);
box->setAppearance("0.95,0.43,0.12,1");
raisin::RayraiWindow viewer(world, 1280, 720);
auto q = raisin::RayraiWindow::defaultRenderQualitySettings(
raisin::RayraiWindow::RenderQualityPreset::Ultra);
q.colorMode = raisin::ViewerColorMode::AcesApprox;
q.pbrToneMapping = true;
q.bloomEnabled = true;
viewer.setRenderQualitySettings(q);
// Frame the box.
auto& cam = viewer.getCamera();
cam.position = glm::vec3(2.6f, 2.2f, 1.6f);
cam.front = glm::normalize(glm::vec3(-0.55f, -0.45f, -0.30f));
cam.worldUp = cam.up = glm::vec3(0.0f, 0.0f, 1.0f);
cam.yaw = glm::degrees(std::atan2(cam.front.y, cam.front.x));
cam.pitch = glm::degrees(std::asin(cam.front.z));
cam.aspect = 1280.0f / 720.0f;
cam.update(false);
// Warm up shadow maps / IBL / particle systems, then capture.
for (int i = 0; i < 3; ++i) viewer.update(1280, 720, false, 0, 0, true);
raisin::RayraiWindow::RenderOverrides ov;
ov.doShadows = true;
auto capture = viewer.captureSupersampledRgba(viewer.getCamera(), 2, ov);
// captureSupersampledRgba returns top-left row order, ready for PNG.
stbi_write_png("/tmp/headless.png", capture.width, capture.height,
4, capture.rgba.data(), capture.width * 4);
return 0;
}
This is the same pattern used by the doc image generators under
docs/image_generators/ — see doc_image_common.hpp for a packaged
helper that wraps the boilerplate above.
Several renderers and context hand-off
createOffscreenGlContext leaves the new context current on the calling
thread, and a context can be current on only one thread at a time. To hand a
context created on the main thread to a worker, release it with
RayraiWindow::detachOffscreenGlContext(window) (safe to call when no
context is current), then call makeOffscreenContextCurrent on the worker.
When several renderers share one thread, switch between their contexts with
makeOffscreenContextCurrent too: it also updates glbinding and resets
rayrai’s per-context GL binding caches, which a plain SDL_GL_MakeCurrent
does not.
A context created while another one is current joins its share group. Within a share group, the geometry of untextured meshes is uploaded once and reused; textured meshes and their textures are loaded separately for each context, because rayrai tunes texture filtering per renderer. See rayrai Visualizer for the threading contract.
SDL keyboard state is shared by the whole process, so with several renderers
enable keyboard input only on the focused one. setKeyboardInputEnabled(false)
stops WASD/Space camera movement, and setKeyboardShortcutsEnabled(false)
stops global shortcuts such as B (pick-buffer view). Both default to
true. Mouse input is already scoped by the hover flag passed to update.
Detectability in camera captures
External-camera captures draw RaiSim world objects according to the usual
object visibility rules, but they intentionally filter rayrai custom
visualization objects. Visuals, InstancedVisuals, and PointCloud
instances must be marked detectable to appear in capture paths where
RenderOverrides::drawVisualizationObjects or
RenderOverrides::drawPointClouds is enabled:
auto prop = viewer.addVisualMesh("visible_to_camera", "/path/prop.glb",
glm::dvec3(1.0), glm::vec4(1.0f));
prop->setDetectable(true);
RenderOverrides::drawVisualizationObjects and
RenderOverrides::drawPointClouds enable those object families for an
external render; they do not force non-detectable debug helpers into the
captured image. Leave overlays, camera frustums, coordinate aids, and temporary
debug geometry non-detectable when they should remain visible only in the
interactive viewer.
Detectability is not a physics flag. It does not create collision, dynamics, or a semantic label, and it does not affect normal viewer visibility.
The renderWithExternalCamera overloads that take a RaiSim RGBCamera or
DepthCamera are stricter: they never draw custom visualization objects,
point clouds, or coordinate frames, regardless of detectability and of the
overrides. That keeps RaiSim sensor images limited to world geometry.
Camera control and picking
RayraiWindow manages an internal camera for offscreen rendering.
You can access or override it through getCamera() or by using
renderWithExternalCamera when you want explicit control.
Picking is available through pickWithExternalCamera. It renders a
selection pass and returns the encoded object id for a pixel.
The internal update call drives camera input and picking:
// Cursor is in pixels of the render target, top-left origin.
viewer.update(width, height, isHovered, cursorX, cursorY, shouldClick);
For custom visuals under the internal camera, pickTargetVisualAt runs a
picking pass immediately (the GL context must be current) and returns the
picked Visuals* directly. A null pointer means nothing was hit:
// From an ImGui handler, right after the ImGui::Image that shows the viewer:
// convert the mouse position into render-target pixels and pick.
// renderWidth/renderHeight are the sizes passed to viewer.update().
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0)) {
const ImVec2 pos = ImGui::GetMousePos();
const ImVec2 origin = ImGui::GetItemRectMin();
const ImVec2 size = ImGui::GetItemRectSize();
const int fbX = static_cast<int>((pos.x - origin.x) * renderWidth / size.x);
const int fbY = static_cast<int>((pos.y - origin.y) * renderHeight / size.y);
if (auto* visual = viewer.pickTargetVisualAt(fbX, fbY)) {
viewer.setTargetVisual(visual);
}
}
For an explicit camera, pickWithExternalCamera returns the encoded
selection value (0 for no hit). The current public API does not expose an
encoded-id-to-object lookup. Treat that value as opaque and maintain an
application-side registry when external-camera picks must be correlated with
application entities. Known custom visuals can still be retrieved by name with
getVisualObject(name).
Picking renders a dedicated selection pass with a flat shader, so it is roughly as cheap as one MSAA-off pass of the scene. Use it on click events, not in the per-frame fast path.
The public picking helpers do not require setDetectable(true) by default.
Detectability is primarily for external-camera/capture render filtering; some
internal render policies can request detectable-only visualization candidates,
but ordinary click picking is not the reason to mark a custom visual
detectable.
Captures and diagnostics
Rayrai has explicit diagnostics APIs for renderer tuning, regression artifacts, and bug reports. These helpers are opt-in and are not part of the normal fast frame path.
captureSupersampledRgbarenders high-resolution screenshots without resizing the caller’s camera.scaleis clamped to 1–4.captureDebugPassescaptures the standard PBR/post-process debug-pass set (25 passes) as top-left ordered RGBA buffers. It overrides the PBR debug output for this renderer only and leaves the process environment untouched;pbrDebugOutputMode()returns the mode in force, which otherwise comes fromRAYRAI_PBR_DEBUG_OUTPUT. The capture temporarily switches to Ultra quality and afterwards restores the previous settings throughsetRenderQualitySettings, so application-added lights are cleared and the preset readsCustom.captureRenderPassTimingsinserts blocking GPU timer queries for pass-level measurements of an external camera. Use it for benchmarks, not ordinary frames.captureViewerPassTimings(width, height)measures the built-in viewer the same way. It callsupdateat that size, so it also resizes the viewer, and it includes non-detectable foliage.renderDiagnosticsJsonandwriteRenderDiagnosticsFilesexport structured quality, scene, material, shadow, transparent-rendering, and resource state.analyzeRgbaLuminance,recommendExposure, luminance histograms, and calibration helpers operate on captured RGBA pixels for screenshot/report workflows.
raisin::RayraiWindow::RenderOverrides ov;
ov.doShadows = true;
auto rgba = viewer.captureSupersampledRgba(viewer.getCamera(), 2, ov);
auto timings = viewer.captureRenderPassTimings(viewer.getCamera(), ov);
auto viewerTimings = viewer.captureViewerPassTimings(1280, 720);
std::string json = viewer.renderDiagnosticsJson();
viewer.writeRenderDiagnosticsFiles("/tmp/rayrai_diag", /*importReport=*/nullptr);
When the scene contains foliage batches, both timing captures render twice.
The coarse passes (shadow, scene_color or scene_color_and_resolve,
postprocess, and so on) come from the first render, and the foliage
sub-passes (scene_foliage* and shadow_foliage*) from the second. The
sub-passes overlap the coarse passes, so do not add them to a frame total.
Per-frame state can be read without extra rendering, and describes the most
recent update or external-camera render:
foliageRenderDiagnostics()— foliage draw calls, instance counts, culling and batching counters, and the foliage GPU times filled in by the timing captures.shadowCacheDiagnostics()— shadow-map renders versus reuses, including per-light and static/dynamic layer tracking.conservativeOcclusionCullingDiagnostics()— occlusion-query and Hi-Z culling counters.environmentBackgroundDiagnostics()—drawn,deferred(drawn inside the scene pass rather than right after the clear), anddepthTested(drawn after the depth prepass, so only uncovered samples were shaded).
For offline multi-view captures such as probe bakes, set
RenderOverrides::fixedShadowCenterEnabled and fixedShadowCenter so all
views share one directional shadow region instead of centering it on each
camera; use a single shadow cascade with it. External renders reuse the
previous frame when the camera, overrides, settings, and scene are unchanged;
call invalidateExternalFrameCache() after editing GL resources in place.
When the quality settings enable MSAA, an external camera keeps its
multisample buffers between renders; the next render of that camera without
MSAA, or camera.setSceneMsaaSamples(1), releases them.
With setLinearHdrRenderingEnabled(true) (off by default), scene color stays
linear through MSAA, fog, bloom, and depth of field, and exposure, the tone
curve, and gamma are applied once at the end of postprocessing; bloom
thresholds are then scene-linear. Renders with reprojected temporal AA use the
same linear pipeline even when the switch is off. Captures that run the
built-in postprocessing (RenderOverrides::postProcess, the default) follow
this pipeline, while renders with postProcess = false, a custom post
shader, or a PBR debug output keep the previous output.
For transparent scenes, transparentDrawDebugView reports draw order, OIT,
refraction, overdraw, and per-item sorting state. Shadow and reflection-probe
planning are similarly exposed through shadowDebugOverlaySummary,
planDirectionalShadowCascades, planAdditionalShadowAtlas, and
reflectionProbeDebugOverlaySummary. shaderWarmupDiagnostics reports
shader compile/link cost from startup, and estimateRenderPassAccounting
returns a CPU-only estimate of the passes the current settings need (fast-path
eligibility, shadow, scene, transparent, post-process, bloom, AO-denoise, and
debug-readback pass counts) together with a heuristic likelyBottleneck tag.
For long-running applications and offline pipelines, Render quality, tone mapping, color grading
describes two helpers that propose setting changes without applying them:
recommendDynamicQuality, which works from captureRenderPassTimings
measurements, and recommendMaterialTextureBudget, which works from a
texture-memory budget.
Exposure, calibration, and output transforms
Capture pipelines often need consistent brightness and color across screenshots. rayrai provides static helpers that operate on captured RGBA buffers without touching renderer state:
// rgba: std::vector<unsigned char> with width * height RGBA8 pixels.
auto metrics = raisin::RayraiWindow::analyzeRgbaLuminance(rgba, width, height);
auto rec = raisin::RayraiWindow::recommendExposure(
metrics, /*currentExposure=*/1.0f, /*targetMedian=*/0.18f,
/*targetP95=*/0.7f, /*minExp=*/0.05f, /*maxExp=*/16.0f);
float exposure = raisin::RayraiWindow::smoothExposure(
/*current=*/1.0f, rec.recommendedExposure,
/*dt=*/0.016f, /*brightenRate=*/4.0f, /*darkenRate=*/2.0f,
/*minExp=*/0.05f, /*maxExp=*/16.0f);
For batch capture validation, place known reference patches in the scene and
measure them with analyzeRgbaCalibrationPatches; the recommended output
transform from recommendCalibrationOutputTransform can then be applied
with transformRgbaForOutput before saving the image.
renderLuminanceHistogramRgba, renderCloudDebugRgba,
renderRainDebugRgba, and renderSnowDebugRgba render diagnostic panels
(luminance histogram, cloud/precipitation breakdown, wet/snow response) as
plain RGBA buffers for embedding into reports.
The renderer also has a built-in auto-exposure loop. With
RenderQualitySettings::autoExposureEnabled, each camera multiplies
pbrExposure by its own auto-exposure factor, which moves the average
luminance of the previous frame toward autoExposureKey. Every frame
corrects the fraction autoExposureSpeed of the remaining error, and the
factor is clamped to [autoExposureMinFactor, autoExposureMaxFactor];
pbrExposure itself is not modified. For applications that need to drive
exposure themselves (e.g. tone-matched batch capture), use the static helpers
above to read the current frame’s luminance and recommend a new exposure value.
setRenderQualitySettings also rebuilds the main light and removes
additional lights (see Lighting, shadows, and HDR/IBL), so re-add application lights after
each call in a loop like this one:
// Frame loop driving exposure manually from the most recent capture.
// dtSeconds is the frame time of the application loop.
float currentExposure = 1.0f;
while (running) {
auto frame = viewer.captureSupersampledRgba(viewer.getCamera(), 1, {});
auto metrics = raisin::RayraiWindow::analyzeRgbaLuminance(
frame.rgba, frame.width, frame.height);
auto rec = raisin::RayraiWindow::recommendExposure(
metrics, currentExposure,
/*targetMedian=*/0.18f, /*targetP95=*/0.7f,
/*minExp=*/0.05f, /*maxExp=*/16.0f);
currentExposure = raisin::RayraiWindow::smoothExposure(
currentExposure, rec.recommendedExposure,
/*dt=*/dtSeconds,
/*brightenRate=*/4.0f, /*darkenRate=*/2.0f,
/*minExp=*/0.05f, /*maxExp=*/16.0f);
auto q = viewer.getRenderQualitySettings();
q.pbrExposure = currentExposure;
viewer.setRenderQualitySettings(q);
}
Async mesh loading
Mesh files load asynchronously by default. The import runs on one of
rayrai’s worker threads, which also decode its texture images; once they are
decoded, the render thread resolves the materials, a worker converts and
optimizes the geometry, and the prepared submeshes are uploaded to the GPU one
per step on the render thread. With OpenGL 4.4 (or GL_ARB_buffer_storage)
the workers copy the images and vertex buffers into persistently mapped
staging buffers first, so each upload is a GPU copy. The HDR background of a
world’s .rscene scene also loads this way: a worker decodes and resamples
it, and a later render installs it.
Until an asset is ready, its visuals draw nothing; instanced batches also wait
for their LOD chains (see Custom visuals, instancing, and scene helpers). update() and every
external-camera render advance pending assets themselves, step after step,
until about 8 ms of render-thread time is spent (at least one step).
pollAsyncMeshLoads(n) advances up to n import or upload steps (0
does nothing) and returns the number of assets that finished;
RayraiGlobalAsset::pollAsyncLoadsUntil(deadline) advances every pending
import until a std::chrono::steady_clock deadline. OpenUSD files always
load synchronously.
auto visual = viewer.addVisualMesh("shelf", "/path/to/large_scene.glb",
glm::dvec3(1.0), glm::vec4(1.0f));
while (viewer.pendingAsyncMeshLoadCount() > 0) {
viewer.pollAsyncMeshLoads(/*maxAssets=*/4);
// continue rendering / updating the UI between polls
}
pendingAsyncMeshLoadCount() includes unfinished GPU uploads, instanced
LOD jobs and a scene’s HDR background while it loads. Call setAsyncMeshLoadingEnabled(false) before adding visuals when
assets must be complete as soon as addVisualMesh or importVisualScene
returns. Assets already held in memory are returned immediately either way.
Mesh caches on disk
rayrai keeps regenerable caches for imported meshes; deleting them only costs regeneration time on the next load.
Mesh metadata and generated LOD chains are written to
RAYRAI_MESH_PREPROCESS_CACHE_DIR, by default therayrai_mesh_preprocess_cachefolder in the system temporary directory. An entry is invalidated when the source file’s size or modification time changes, or when the cache format changes.RAYRAI_SUPPRESS_MESH_PREPROCESS_CACHE_FALLBACK_WARNINGS=1silences the warnings printed when the cache cannot be used.LOD chains that instanced batches prepare during asynchronous loading are stored next to each asset as
rayrai_cache_<file name>.lods. They are invalidated by a fingerprint of the imported geometry, so edits to external glTF buffers are detected. A new cache format (as in v2.9.4) also invalidates them, and the first launch builds them again.Models of at least 65,536 vertices also keep their prepared geometry (the optimized vertex order of foliage, tangents and UV statistics) next to the asset as
rayrai_cache_<file name>.rgeo, invalidated by a fingerprint of the arrays the import read, so a cached model’s vertices are not converted again. Both kinds also store each mesh’s bounds, index range and content digest. A large sidecar of either kind is read while its model still imports.Such a model also keeps how it was imported as
rayrai_cache_<file name>.rimp: its materials, embedded textures and what the import measured of its meshes, with the content digest of every file the import read and whether each file it looked for existed. While those files are unchanged and the prepared geometry is stored, a later load restores both and runs no import at all; any change (a buffer or material library edited in place, a file that appears) imports the model again. A damaged.rgeofound after a restore makes the load import the model for its vertices.RAYRAI_ASYNC_LOD_CACHE_DIRstores all three kinds in another folder instead (if that folder is not writable, nothing is cached). Without it, an unwritable asset folder falls back to the system temporary directory.
Compressed texture storage
rayrai/Bc7TextureStorage.hpp provides an opt-in, load-time helper that
re-encodes eligible color textures as BC7 to reduce texture memory. Call it
with the renderer’s GL context current, after the visuals are loaded:
#include <rayrai/Bc7TextureStorage.hpp>
std::vector<unsigned int> textures;
visual->collectMaterialTextureIds(textures);
std::sort(textures.begin(), textures.end());
textures.erase(std::unique(textures.begin(), textures.end()), textures.end());
for (unsigned int id : textures) {
const auto stored = raisin::compressRgbTextureBc7(id);
// stored.mipLevels == 0 means the texture was left unchanged.
}
Only mutable 8-bit RGB or sRGB 2D textures are converted. Textures with alpha,
float, depth, immutable, or already compressed storage are left untouched, as
is any texture that would not get smaller. Every authored mip level, the
filtering state, and the texture id are kept. supportsBc7TextureStorage()
reports whether the context has OpenGL 4.2 or
GL_ARB_texture_compression_bptc; without it the helper does nothing. The
driver’s encoder is lossy and runs again on every launch, so compare images
before enabling it for an asset.
Heightmap appearance and streaming updates
setHeightmapPatternResourcePath applies one tiled albedo texture to every
heightmap in the viewer. The matching
setHeightmapNormalResourcePath and setHeightmapHeightResourcePath
methods provide global normal and height/parallax textures. Passing an empty
path clears the corresponding resource.
setHeightmapTerrainMaterialParameters(normalStrength, roughness,
parallaxScale, deepParallax = true) tunes the heightmap terrain material
(defaults 1.0, 0.90, 4.0). With a height texture,
deepParallax = true uses layered parallax occlusion mapping and false a
single-sample parallax offset; parallaxScale = 0 disables parallax.
Parallax changes texture sampling only, not collision or the terrain
silhouette.
Per-heightmap data is authored on raisim::HeightMap itself. Use
setColor for a full RGB color map, setColorPatch for a rectangular
color patch after the full map has been initialized, and
updateVisualHeightPatch for a visualization-only height update. These
updates are synchronized into the mirrored rayrai heightmap; they do not create
a renderer-owned per-name texture override.
// heights holds 256 * 256 samples, indexed y * 256 + x.
auto* terrain = world->addHeightMap(/*xSamples=*/256, /*ySamples=*/256,
/*xSize=*/40.0, /*ySize=*/40.0,
/*centerX=*/0.0, /*centerY=*/0.0, heights);
viewer.setHeightmapPatternResourcePath("/path/to/terrain_albedo.png");
viewer.setHeightmapNormalResourcePath("/path/to/terrain_normal.png");
// Initialize one RGB value per height sample.
std::vector<raisim::ColorRGB> colors(256 * 256, {90, 130, 70});
terrain->setColor(colors);
// Patch bounds are inclusive; the patch vector is tightly packed.
constexpr size_t minX = 20, maxX = 59;
constexpr size_t minY = 30, maxY = 69;
std::vector<raisim::ColorRGB> patch(
(maxX - minX + 1) * (maxY - minY + 1), {210, 170, 70});
if (!terrain->setColorPatch(patch, minX, maxX, minY, maxY)) {
// Invalid bounds, patch size, or no initialized full color map.
}
updateVisualHeightPatch also takes inclusive bounds, but its height input is
the full row-major height array (plus a height offset); only the requested
region is copied. The center and size arguments must equal the heightmap’s
current ones, otherwise the call returns false without changes. The patch
changes only the rendered heights; call update(...) instead when physics
collision heights must change too.