Rayrai Workflow: Blender Scene Import
Rayrai can import complete authored visual scenes through glTF/GLB. This is the
recommended workflow for rooms, offices, shelves, lamps, props, and other scenes that
should stay authored in Blender instead of being reconstructed object by object in C++.
This page describes a workflow, not a build target; the rayrai_blue_wall_scene
target loads a GLB scene and its light sidecar in the same way.
The pipeline is:
Author or download the complete scene in Blender.
Export it with
scripts/export_blender_scene.py.Load the resulting
.glbwithRayraiWindow::importVisualScene.Add separate RaiSim collision objects only where physics is needed.
Why glTF/GLB
Use glTF/GLB for whole-scene rendering. Compared with OBJ, glTF carries much more of the visual intent:
node hierarchy and transforms
PBR metallic-roughness materials
base-color, normal, metallic-roughness, occlusion, and emissive textures
alpha modes and material factors
tangent data for normal mapping
punctual lights through
KHR_lights_punctual
OBJ remains useful for simple mesh interchange, but it is a poor format for modern scene rendering because material, normal-map, and light semantics are too limited.
Exporter location
The general Blender exporter ships with raisim2Lib as
scripts/export_blender_scene.py. The commands below assume the checkout is
at $HOME/raisim2Lib:
$HOME/raisim2Lib/scripts/export_blender_scene.py
The script is not asset-specific. It exports renderable Blender objects, applies modifiers, preserves Z-up coordinates, writes glTF/GLB output, and emits a rayrai sidecar for Blender area lights that glTF cannot store directly.
Export command
Run Blender in background mode:
blender --background /path/to/scene.blend \
--python $HOME/raisim2Lib/scripts/export_blender_scene.py \
-- --format glb --output /path/to/scene.glb
The exporter also supports separate glTF output:
blender --background /path/to/scene.blend \
--python $HOME/raisim2Lib/scripts/export_blender_scene.py \
-- --format gltf --output /path/to/scene.gltf
.glb is usually easier to move around because most texture images are embedded in
one file. Separate .gltf is easier to inspect and edit by hand.
Area-light sidecar
glTF’s standard light extension supports directional, point, and spot lights, but not Blender area lights. Indoor scenes often rely on area lights for windows, ceiling panels, and lamp shades. The exporter writes those lights next to the scene:
scene.glb
scene.glb.rayrai_lights.json
Rayrai automatically looks for both scene.glb.rayrai_lights.json and
scene.rayrai_lights.json next to the imported scene. Keep the sidecar beside the
GLB when moving assets.
C++ loading
Load the scene as a visual scene:
raisin::RayraiWindow::SceneImportReport report;
auto scene = viewer.importVisualScene(
"imported_room",
"/path/to/scene.glb",
&report,
/*importLights=*/true,
/*lightIntensityScale=*/0.08f);
The returned object owns the imported visual nodes. The import report records whether
the visual loaded, how many scene lights were found, imported, or dropped, whether one
was promoted to the main shadow caster, and any light-import error in notes. Use it
when lights are missing or look too strong or too weak. For missing maps or unexpected
alpha, pass the report to RayraiWindow::writeRenderDiagnosticsFiles; the
mesh_diagnostics_full.json it writes lists the texture slots and alpha mode of every
mesh.
The scene import is renderer-only. It does not create RaiSim collision bodies. For a simulation scene, create simplified collision geometry separately and use the glTF scene for visual fidelity.
Lighting quality
For authored interiors, start from the High or Ultra quality preset and tune the
shadow budget explicitly:
auto quality = raisin::RayraiWindow::defaultRenderQualitySettings(
raisin::RayraiWindow::RenderQualityPreset::Ultra);
quality.autoSelectImportedShadowLight = true;
quality.updateShadowsEveryFrame = false;
quality.maxAdditionalLightsPerFrame = 12;
quality.shadowedLightBudget = 5; // the main light plus four additional lights
quality.maxPointShadowLights = 2;
quality.additionalShadowResolutionScale = 0.5f;
quality.pointShadowResolutionScale = 0.5f;
viewer.setRenderQualitySettings(quality);
With updateShadowsEveryFrame = false, shadow maps are re-rendered only when the
scene, a light, or the camera moves enough, or when requestShadowUpdate() is
called. This is much faster for static rooms. Enable per-frame updates when lights or
shadow-casting objects move every frame.
Material and texture expectations
Rayrai’s importer uses explicit glTF material slots first, then falls back to sibling PBR texture files when assets are packaged loosely. It supports:
base color factor and texture
metallic and roughness factors
metallic-roughness texture
normal texture and normal scale
occlusion texture
emissive factor and texture
alpha mode and alpha cutoff
Color maps are uploaded as sRGB. Normal, metallic-roughness, occlusion, and mask maps
are uploaded as linear data. Normal-map convention is detected from common file names
such as *_nor_gl and *normaldx* where possible.
Common failure checks
If the imported scene looks wrong, check these first:
White or flat objects usually mean the texture path is missing or the asset package omitted the PBR map slot.
Transparent walls or frames usually mean an alpha mode/cutoff is being imported from the source material; check the alpha mode in
mesh_diagnostics_full.json.Missing lamp or ceiling meshes usually mean the Blender object was hidden for render, disabled in the view layer, or not converted/exportable by Blender’s glTF exporter.
Missing area lights usually mean the
.rayrai_lights.jsonsidecar was not copied next to the GLB.Over-bright interiors usually mean HDR/environment light, imported light scale, and authored light energy need to be balanced for real-time rendering.
Sharp window shadows usually mean the light was exported as a small directional/spot light rather than an area light with an appropriate size.
Exporter source
--lights-only and --probes-only rewrite only the light sidecar or the
reflection-probe sidecar (scene.glb.rayrai_probes.json) of an existing
glTF/GLB export. The probe sidecar lists Blender objects named
RAYRAI_PROBE* or carrying the rayrai_reflection_probe custom property.
For reference, the exporter source is:
#!/usr/bin/env python3
"""General Blender scene exporter for RayRai examples."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import bpy
from mathutils import Vector
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, help="Output path. Defaults next to the .blend file.")
parser.add_argument(
"--format",
choices=("obj", "glb", "gltf"),
default="glb",
help="Export format.",
)
parser.add_argument(
"--material-diffuse",
action="append",
default=[],
metavar="MATERIAL=TEXTURE",
help="OBJ-only MTL map_Kd override. May be repeated.",
)
parser.add_argument(
"--lights-only",
action="store_true",
help="Only write RayRai light sidecar metadata for an existing glTF/GLB export.",
)
parser.add_argument(
"--probes-only",
action="store_true",
help="Only write RayRai reflection probe sidecar metadata for an existing glTF/GLB export.",
)
argv = sys.argv
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
return parser.parse_args(argv)
def renderable_objects() -> list[bpy.types.Object]:
return [
obj
for obj in bpy.context.scene.objects
if obj.type in {"MESH", "CURVE", "SURFACE", "FONT", "META"}
and not obj.hide_get()
and not obj.hide_render
]
def default_output_path(fmt: str) -> Path:
blend = Path(bpy.data.filepath)
suffix = ".obj" if fmt == "obj" else f".{fmt}"
return blend.with_suffix(suffix)
def export_obj(output: Path, material_diffuse: list[str]) -> None:
bpy.ops.object.select_all(action="DESELECT")
for obj in renderable_objects():
obj.select_set(True)
bpy.context.view_layer.objects.active = next((obj for obj in renderable_objects()), None)
bpy.ops.wm.obj_export(
filepath=str(output),
export_selected_objects=True,
export_materials=True,
export_uv=True,
export_normals=True,
export_triangulated_mesh=True,
apply_modifiers=True,
path_mode="RELATIVE",
forward_axis="Y",
up_axis="Z",
)
if material_diffuse:
patch_mtl(output.with_suffix(".mtl"), material_diffuse)
def patch_mtl(path: Path, overrides: list[str]) -> None:
if not path.exists():
return
mapping: dict[str, str] = {}
for item in overrides:
name, sep, texture = item.partition("=")
if sep:
mapping[name.strip()] = texture.strip()
if not mapping:
return
lines = path.read_text(encoding="utf-8").splitlines()
out: list[str] = []
current: str | None = None
wrote_map = False
for line in lines:
if line.startswith("newmtl "):
if current in mapping and not wrote_map:
out.append(f"map_Kd {mapping[current]}")
current = line.removeprefix("newmtl ").strip()
wrote_map = False
out.append(line)
continue
if current in mapping and line.startswith("map_Kd "):
if not wrote_map:
out.append(f"map_Kd {mapping[current]}")
wrote_map = True
continue
out.append(line)
if current in mapping and not wrote_map:
out.append(f"map_Kd {mapping[current]}")
path.write_text("\n".join(out) + "\n", encoding="utf-8")
def export_gltf(output: Path, fmt: str) -> None:
try:
bpy.ops.export_scene.gltf(
filepath=str(output),
export_format="GLB" if fmt == "glb" else "GLTF_SEPARATE",
export_yup=False,
export_apply=True,
export_lights=True,
export_cameras=False,
export_materials="EXPORT",
export_image_format="AUTO",
)
finally:
export_rayrai_light_sidecar(output)
export_rayrai_probe_sidecar(output)
def vec3(value: Vector) -> list[float]:
return [float(value.x), float(value.y), float(value.z)]
def custom_property(obj: bpy.types.ID, name: str, default: object = None) -> object:
try:
return obj.get(name, default)
except AttributeError:
return default
def custom_bool(obj: bpy.types.ID, name: str, default: bool = False) -> bool:
value = custom_property(obj, name, default)
if isinstance(value, str):
return value.lower() in {"1", "true", "yes", "on"}
return bool(value)
def custom_float(obj: bpy.types.ID, name: str, default: float) -> float:
value = custom_property(obj, name, default)
try:
return float(value)
except (TypeError, ValueError):
return default
def custom_int(obj: bpy.types.ID, name: str, default: int, minimum: int, maximum: int) -> int:
value = custom_property(obj, name, default)
try:
return max(minimum, min(maximum, int(value)))
except (TypeError, ValueError):
return default
def custom_vec3(obj: bpy.types.ID, name: str) -> list[float] | None:
value = custom_property(obj, name)
if value is None or isinstance(value, (str, bytes)):
return None
try:
values = [float(value[i]) for i in range(3)]
except (TypeError, ValueError, IndexError):
return None
return values
def rayrai_probe_objects() -> list[bpy.types.Object]:
probes: list[bpy.types.Object] = []
for obj in bpy.context.scene.objects:
if obj.hide_get() or obj.hide_render:
continue
if custom_bool(obj, "rayrai_reflection_probe", False) or obj.name.startswith("RAYRAI_PROBE"):
probes.append(obj)
return probes
def object_world_aabb(obj: bpy.types.Object) -> tuple[list[float], list[float]] | None:
corners: list[Vector] = []
if getattr(obj, "type", None) == "EMPTY":
corners = [
obj.matrix_world @ Vector((x, y, z))
for x in (-0.5, 0.5)
for y in (-0.5, 0.5)
for z in (-0.5, 0.5)
]
elif getattr(obj, "bound_box", None):
corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
if not corners:
return None
min_corner = Vector((min(c.x for c in corners), min(c.y for c in corners), min(c.z for c in corners)))
max_corner = Vector((max(c.x for c in corners), max(c.y for c in corners), max(c.z for c in corners)))
if min((max_corner - min_corner).x, (max_corner - min_corner).y, (max_corner - min_corner).z) <= 1.0e-4:
return None
return vec3(min_corner), vec3(max_corner)
def probe_filter_settings(obj: bpy.types.Object) -> dict[str, object] | None:
if not custom_bool(obj, "rayrai_probe_filter", False):
return None
return {
"irradiance_resolution": custom_int(obj, "rayrai_probe_irradiance_resolution", 16, 0, 128),
"irradiance_samples": custom_int(obj, "rayrai_probe_irradiance_samples", 64, 0, 4096),
"prefiltered_resolution": custom_int(obj, "rayrai_probe_prefiltered_resolution", 64, 0, 512),
"prefiltered_mip_levels": custom_int(obj, "rayrai_probe_prefiltered_mip_levels", 5, 0, 16),
"prefiltered_samples": custom_int(obj, "rayrai_probe_prefiltered_samples", 64, 0, 4096),
"brdf_lut_size": custom_int(obj, "rayrai_probe_brdf_lut_size", 128, 0, 512),
"brdf_lut_samples": custom_int(obj, "rayrai_probe_brdf_lut_samples", 128, 0, 4096),
}
def export_rayrai_probe_sidecar(output: Path) -> None:
"""Write explicit static reflection probe authoring metadata.
Add a Blender object with custom property ``rayrai_reflection_probe=true``.
Empty cube objects are useful for box-projected room probes; their world-space
bounding box is exported when ``rayrai_probe_box_projection=true``.
"""
probes: list[dict[str, object]] = []
for obj in rayrai_probe_objects():
probe: dict[str, object] = {
"name": obj.name,
"position": vec3(obj.matrix_world.translation),
"radius": max(0.0, custom_float(obj, "rayrai_probe_radius", 5.0)),
"strength": max(0.0, custom_float(obj, "rayrai_probe_strength", 1.0)),
"capture": {
"resolution": custom_int(obj, "rayrai_probe_resolution", 64, 16, 512),
"visualization_objects": custom_bool(obj, "rayrai_probe_visualization_objects", True),
"coordinate_frames": custom_bool(obj, "rayrai_probe_coordinate_frames", False),
"point_clouds": custom_bool(obj, "rayrai_probe_point_clouds", False),
"shadows": custom_bool(obj, "rayrai_probe_shadows", False),
"environment_background": custom_bool(obj, "rayrai_probe_environment_background", False),
"environment_exposure": max(
0.0, custom_float(obj, "rayrai_probe_environment_exposure", 1.0)
),
},
}
box_projection = custom_bool(obj, "rayrai_probe_box_projection", False)
box_min = custom_vec3(obj, "rayrai_probe_box_min")
box_max = custom_vec3(obj, "rayrai_probe_box_max")
if box_projection and (box_min is None or box_max is None):
aabb = object_world_aabb(obj)
if aabb is not None:
box_min, box_max = aabb
if box_projection and box_min is not None and box_max is not None:
if min(box_max[i] - box_min[i] for i in range(3)) > 1.0e-4:
probe["box_projection"] = True
probe["box_min"] = box_min
probe["box_max"] = box_max
filter_settings = probe_filter_settings(obj)
if filter_settings is not None:
probe["filter"] = filter_settings
probes.append(probe)
sidecar = output.with_suffix(output.suffix + ".rayrai_probes.json")
if not probes:
if sidecar.exists():
sidecar.unlink()
return
quality: dict[str, object] = {}
scene = bpy.context.scene
if custom_property(scene, "rayrai_probe_max_reflection_probes_per_selection") is not None:
quality["max_reflection_probes_per_selection"] = custom_int(
scene, "rayrai_probe_max_reflection_probes_per_selection", 1, 1, 2
)
if custom_property(scene, "rayrai_probe_min_reflection_probe_weight") is not None:
quality["min_reflection_probe_weight"] = max(
0.0, custom_float(scene, "rayrai_probe_min_reflection_probe_weight", 0.0)
)
if custom_property(scene, "rayrai_probe_filtering_enabled") is not None:
quality["filtering_enabled"] = custom_bool(scene, "rayrai_probe_filtering_enabled", False)
document: dict[str, object] = {
"version": 1,
"coordinate_system": "z_up",
"probes": probes,
}
if quality:
document["quality"] = quality
sidecar.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
print(f"Exported RayRai reflection probe sidecar: {sidecar}")
def export_rayrai_light_sidecar(output: Path) -> None:
"""Preserve Blender lights that glTF cannot represent directly.
The standard KHR_lights_punctual extension only supports directional, point,
and spot lights. Blender area lights are important for authored indoor scenes,
so RayRai stores them in a small sidecar next to the exported glTF/GLB.
"""
lights: list[dict[str, object]] = []
for obj in bpy.context.scene.objects:
if obj.type != "LIGHT" or obj.hide_get() or obj.hide_render:
continue
light = obj.data
if light.type != "AREA":
continue
matrix = obj.matrix_world
quat = matrix.to_quaternion()
direction = quat @ Vector((0.0, 0.0, -1.0))
right = quat @ Vector((1.0, 0.0, 0.0))
up = quat @ Vector((0.0, 1.0, 0.0))
size_x = float(getattr(light, "size", 1.0))
size_y = float(getattr(light, "size_y", size_x))
if getattr(light, "shape", "SQUARE") in {"SQUARE", "DISK"}:
size_y = size_x
lights.append(
{
"name": obj.name,
"type": "area",
"position": vec3(matrix.translation),
"direction": vec3(direction.normalized()),
"right": vec3(right.normalized()),
"up": vec3(up.normalized()),
"size": [size_x, size_y],
"energy": float(getattr(light, "energy", 0.0)),
"color": [float(c) for c in getattr(light, "color", (1.0, 1.0, 1.0))],
"shape": getattr(light, "shape", "RECTANGLE"),
}
)
sidecar = output.with_suffix(output.suffix + ".rayrai_lights.json")
if not lights:
if sidecar.exists():
sidecar.unlink()
return
sidecar.write_text(
json.dumps({"version": 1, "coordinate_system": "z_up", "lights": lights}, indent=2) + "\n",
encoding="utf-8",
)
print(f"Exported RayRai light sidecar: {sidecar}")
def main() -> None:
args = parse_args()
output = args.output or default_output_path(args.format)
output.parent.mkdir(parents=True, exist_ok=True)
wrote_scene = not args.lights_only and not args.probes_only
if args.lights_only:
export_rayrai_light_sidecar(output)
elif args.probes_only:
export_rayrai_probe_sidecar(output)
elif args.format == "obj":
export_obj(output, args.material_diffuse)
else:
export_gltf(output, args.format)
if wrote_scene:
print(f"Exported {args.format}: {output}")
if __name__ == "__main__":
main()