scimesh is a fast, headless, GPU-free software renderer for 3D triangle meshes that produces publication-quality images for papers, slides, and presentations. It works anywhere R works — no X11, no OpenGL, no GPU required.
Applications span any field that works with 3D surfaces: - Neuroimaging: cortical surface visualisation (FreeSurfer data) - Structural biology: molecule surfaces from PDB files - Computer graphics: Stanford models, procedural geometry, textured meshes - Engineering and simulation: mesh-based scientific visualisation of any kind
All rendering is done in modern C++17 and returns in-memory RGBA images that can be saved to PNG or composed into multi-panel figures.
While scimesh can serve as a drop-in renderer backend when rgl/OpenGL is unavailable (e.g., on macOS without XQuartz, on HPC clusters, in CI containers), it is a general-purpose visualisation tool — not tied to any specific domain or package.
For the full viridis colormap family (magma, inferno, cividis, etc.),
you may optionally install viridisLite:
But this is not required — scimesh ships built-in
viridis_colormap() and diverging_colormap()
functions that use only base R.
library(scimesh)
sphere <- generate_sphere(c(0, 0, 0), radius = 1.2,
segments = 32, color = c(0.9, 0.3, 0.2, 1.0))
cam <- camera_auto(sphere, direction = c(1.2, 0.8, 1))
# Flat-shaded sphere
img <- render_mesh(sphere$vertices, sphere$triangles,
colors = sphere$colors, camera = cam,
options = render_options(
lights = list(
list(position = c(0.5, 1.0, 0.8), intensity = 1.5),
list(position = c(-0.5, 0.2, 0.6), intensity = 0.5))))
tmp_file <- tempfile(fileext = ".png")
write_png(img, tmp_file)
printf("Rendered sphere written to: %s\n", tmp_file)All rendering is controlled via render_options(). The
following parameters are available:
| Parameter | Type | Default | Description |
|---|---|---|---|
width, height |
integer | 800, 600 | Output image dimensions in pixels |
shading |
"smooth" / "flat" |
"smooth" |
Per-vertex (smooth) or per-face (flat) shading |
backface_culling |
logical | TRUE |
Skip triangles facing away from camera |
background_color |
RGBA vector | c(0,0,0,0) |
Transparent black by default |
default_color |
RGBA vector | c(0.7,0.7,0.7,1) |
Fallback when no per-vertex colors |
invert_normals |
logical | FALSE |
Flip face orientation |
wireframe |
logical | FALSE |
Render edges only |
wireframe_color |
RGBA vector | c(0,0,0,1) |
Edge color in wireframe mode |
projection |
"perspective" / "orthographic" |
"perspective" |
Camera projection type |
specular_color |
RGBA vector | c(0,0,0,0) |
Specular highlight color (off by default) |
shininess |
numeric | 0 | Gloss tightness (8–128) |
ambient |
numeric | 0.3 | Ambient light level (0–1) |
contrast |
numeric | 1.0 | S-curve contrast multiplier |
lights |
list or NULL |
NULL |
Custom light list (auto-default if NULL) |
fog_enabled |
logical | FALSE |
Enable depth fog |
fog_start |
numeric | 0 | Distance where the fog fade begins |
fog_end |
numeric | 1 | Distance where fog is fully opaque |
fog_space |
"world" / "ndc" |
"world" |
Unit of fog_start/fog_end |
fog_color |
RGBA vector | c(0,0,0,0) |
Fog color |
aa_samples |
integer (1, 2, or 4) | 1 | Ordered-grid supersampling factor |
ssao_enabled |
logical | FALSE |
Enable screen-space ambient occlusion |
ssao_radius |
numeric | 16 | SSAO sample radius in pixels |
ssao_intensity |
numeric | 0.8 | SSAO occlusion strength (0–1) |
threads |
integer | 0 | Number of CPU threads (0 = auto) |
clip_planes |
list or NULL |
NULL |
Clip planes (see Clip Planes) |
near_plane, far_plane |
numeric | 0.1, 10000 | Depth range of the camera |
The following sections cover the most important options in detail.
scimesh uses a Blinn-Phong shading model with support for multiple light sources, specular highlights, ambient control, and contrast adjustments.
The ambient parameter (default 0.3) controls how much
light reaches surfaces that face away from the light source. Lower
values produce deeper shadows and more contrast:
Explicit lights give you full control over direction, colour, and intensity:
Add a glossy sheen to surfaces:
render_options(
specular_color = c(0.4, 0.4, 0.4, 1), # white highlight
shininess = 64) # tight spotshininess value |
Look |
|---|---|
| 8–16 | Soft plastic |
| 32–64 | Shiny surface |
| 128 | Glass-like tight spot |
Pass contrast to render_options() to apply
an S-curve contrast stretch after shading. Values > 1.0 push darks
toward black and lights toward white, increasing perceived contrast. The
default 1.0 means no change:
The formula applied is (value - 0.5) * contrast + 0.5,
clamped to [0, 1]. Typical values are 1.1–1.2 for a gentle
boost, or up to 1.5 for a dramatic look.
You can also apply contrast as post-processing to an existing image:
camera_auto() computes a camera that fits any mesh or
vertex set:
It accepts either an Nx3 matrix or a mesh descriptor list.
For full control, use camera():
camera_orbit() rotates a camera’s eye and up vector
around its center by a given angle about an axis. This is useful for
generating turntable-style frame sequences:
cam <- camera_auto(mesh, direction = c(1, 1, 1))
for (i in seq_len(8)) {
cam_i <- camera_orbit(cam, angle_degrees = 360 / 8 * (i - 1))
img <- render_mesh(mesh$vertices, mesh$triangles, camera = cam_i,
options = render_options(width = 600, height = 400))
# Construct the file path inside tempdir()
file_name <- sprintf("frame_%04d.png", i - 1)
file_path <- file.path(tempdir(), file_name)
# Write the image to the temporary directory
write_png(img, file_path)
}
message("Frames saved in: ", tempdir())For more complex trajectories, replace camera_orbit()
with your own function — it only needs to set camera$eye
and camera$up.
The resulting PNG frames can be assembled into a video with ffmpeg or
into an animated GIF with your tool of choice. See
examples/R/video_frames_orbit/ for a runnable R example,
and examples/cpp/brain_video/ for a C++ version that
renders 48 full-brain frames.
camera_auto() accepts an rgl_compat
parameter. When TRUE, it mimics rgl’s default view
parameters:
| Parameter | scimesh default | rgl_compat = TRUE |
|---|---|---|
| FOV | 45° | 30° |
| Elevation | 0° (front-on) | 15° above horizon |
| Distance basis | bounding box | bounding sphere |
The distance formula follows rgl’s implementation exactly:
distance = sphere_radius / sin(FOV / 2), where
sphere_radius is half the length of the axis-aligned
bounding box diagonal.
mesh <- generate_cuboid(c(0, 0, 0), c(1, 1, 1))
# scimesh default: straight front view
img_default <- render_mesh(mesh)
# rgl-compatible view: elevated, matching rgl's default look
cam <- camera_auto(mesh, rgl_compat = TRUE)
img_rgl <- render_mesh(mesh, camera = cam)This is especially useful when comparing outputs between scimesh and rgl, or when you prefer rgl’s slightly elevated default perspective.
Perspective (default) or orthographic (parallel projection, matching
rgl’s view3d(fov = 0) convention):
scimesh supports ordered-grid supersampling (SSAA). Pass
aa_samples = 2L for 2x2 SSAA (renders internally at double
resolution, downsamples by box averaging):
Values of 1 (off), 2, or 4 are supported. Higher values give smoother edges but use proportionally more memory and time.
Edges are computed via barycentric distance testing inside the triangle rasterizer — no separate line primitives needed. Edge thickness adapts to triangle size so small triangles don’t disappear.
SSAO adds contact shadows in crevices and concavities, dramatically improving depth perception and realism. It’s a screen-space post-processing effect — no extra geometry needed:
render_options(
ssao_enabled = TRUE,
ssao_radius = 12, # sample radius in pixels
ssao_intensity = 0.5) # occlusion strength (0–1)Higher ssao_radius values sample a larger area (more
expensive but softer shadows). Higher ssao_intensity
darkens occluded regions more. Typical settings: radius 8–16, intensity
0.4–0.8.
SSAO is demonstrated in the spot_cow and
dragon R examples, and extensively in the C++ examples.
Atmospheric depth fog fades distant geometry toward a background color, useful for emphasising foreground objects or creating stylised renders. By default the fog distances are given in world units (distance from the camera), so a scene looks the same no matter where the camera is placed:
render_options(
fog_enabled = TRUE,
fog_start = 20, # start fading 20 world units away
fog_end = 60, # fully fogged 60 world units away
fog_color = c(0.9, 0.95, 1, 1)) # pale blue fogThe fade is applied linearly between fog_start and
fog_end, measured along the viewing direction. This is
independent of the near/far clipping planes (near_plane,
far_plane) and of the projection type.
For backwards compatibility, the pre-0.3.5 behaviour of specifying
fog in normalized device depth (the raw depth buffer,
-1 = near plane, +1 = far plane) is still
available, but has to be requested explicitly:
Clip planes remove the geometry on one side of a plane, which is
useful for cross-sections, cutaways, and “half-brain” style figures. A
plane is built with clip_plane() and passed to
render_options(clip_planes = ...); several planes are
combined with a logical AND. A point p is kept when
dot(normal, p) + offset >= 0, i.e. normal
points toward the part that stays visible.
By default the plane is defined in world space, so the cut is a fixed feature of the scene and does not move when the camera is moved — which is what makes it usable for orbit videos and multi-view figures:
opts <- render_options(clip_planes = list(
clip_plane(normal = c(-1, 0, 0), offset = 0))) # keep world x <= 0The same scene rendered from any camera position always shows the same cut.
Pass space = "eye" for a plane that is defined
relative to the camera and therefore travels with it
(the classic OpenGL glClipPlane behaviour). This is handy
for camera-attached cutaways, e.g. removing everything closer than 2
units to the viewer so that the camera always sees inside the mesh:
opts <- render_options(clip_planes = list(
clip_plane(normal = c(0, 0, -1), offset = -2, space = "eye")))Both modes can be mixed in the same clip_planes
list.
scimesh blends meshes whose colors are not fully opaque. The fourth
column of colors (or of face_colors, or the
alpha of color for the generated meshes) is the alpha
value, and there is nothing to switch on: a mesh with alpha < 1 takes
part in the blended pass automatically. The renderer
holes), e.g. for the
medial wall.set_mesh_alpha(mesh, alpha) is the shortcut for a
uniform alpha (set_mesh_alpha(mesh, 0.1) for the 10 % brain
shell used as a spatial reference).
R example:
library(scimesh)
white <- freesurferformats::read.fs.surface("sub-01/surf/lh.white")
pial <- freesurferformats::read.fs.surface("sub-01/surf/lh.pial")
nv <- nrow(white$vertices)
white_mesh <- list(
vertices = white$vertices,
triangles = white$faces,
colors = matrix(c(0.7, 0.7, 0.7, 1.0), nv, 4, byrow = TRUE))
pial_mesh <- list(
vertices = pial$vertices,
triangles = pial$faces,
colors = matrix(c(0.9, 0.3, 0.2, 0.35), nv, 4, byrow = TRUE))
cam <- camera_auto(pial_mesh, direction = c(-1, 0, 0.2))
img <- render_scene(list(white_mesh, pial_mesh), cam,
render_options(width = 1200, height = 900,
backface_culling = FALSE,
specular_color = c(0.4, 0.4, 0.4, 1),
shininess = 64))
temp_file <- tempfile(fileext = ".png")
write_png(img, temp_file)
printf("Transparent render written to: %s\n", temp_file)Recipe: a half-transparent medial wall (in this case with the cortex colored by sulcal depth, so the inside of the hemisphere stays visible):
surface <- freesurferformats::read.fs.surface("sub-01/surf/lh.white")
sulc <- freesurferformats::read.fs.morph("sub-01/surf/lh.sulc")
cortex <- freesurferformats::read.fs.label("sub-01/label/lh.cortex.label")
nv <- nrow(surface$vertices)
medial_wall <- !(seq_len(nv) %in% cortex)
sulc[medial_wall] <- NA # no data on the medial wall
colors <- apply_colormap(sulc, colormap = viridis_colormap(256L),
nan_color = c(1, 1, 1, 1), winsor_percentiles = c(2, 98))
if (ncol(colors) == 3) colors <- cbind(colors, 1)
colors[medial_wall, 4] <- 0.5 # 50 % transparent
brain <- list(vertices = surface$vertices, triangles = surface$faces,
colors = colors)
img <- render_scene(list(brain), camera_auto(brain, direction = c(1, 0, 0)),
render_options(background_color = c(1, 1, 1, 1)))Per-vertex alpha can vary freely, which makes gradients possible:
quads <- generate_plane(color = c(0.8, 0.2, 0.2, 1))
quads$colors[, 4] <- rep(c(0, 1), each = nrow(quads$colors) / 2)File formats: PLY vertex colors are read with their
alpha (8-bit or float), including RGBA files whose alpha is stored with
a different type than the RGB channels. OBJ and STL do not carry
transparency, and the FreeSurfer converters take it from the data:
mesh_from_fs(..., detect_transparency = TRUE) (C++) turns
vertices without data into holes, and
convert_fs_mesh(fs_mesh, morph, rgb, nan_alpha = 0.5f)
makes them half-transparent instead.
C++ example: See
examples/cpp/transparency/ in the repository.
Known limitations: transparency is resolved per
triangle (painter’s algorithm), so intersecting,
coplanar or very large translucent triangles can blend in the
wrong order, and a translucent surface never occludes anything (it
writes no depth). Point rendering (render_points()) does
not blend at all; use render_spheres() (which renders
meshes) for translucent spheres.
text_layer() adds text to a scene, which is what figures
need to name a region, an atom or a panel. Labels are
billboards: they always face the camera and keep the
size given in size (in output pixels), so they stay
readable from every viewpoint — unlike text that is converted into 3D
geometry, which skews as the camera moves. Labels create no geometry,
they are drawn after the meshes and lines (so they appear on top of the
model), and the font ships with the package (Inter, SIL OFL 1.1), so no
system font is required.
Positions are either world space (the default) or
screen space (space = "screen", measured
in output pixels from the top-left corner):
surf <- generate_sphere(c(0, 0, 0), radius = 1)
cam <- camera_auto(list(surf), direction = c(0, 0, 1))
opts <- render_options(width = 500, height = 400,
background_color = c(1, 1, 1, 1))
sc <- scene(list(surf), camera = cam, options = opts,
texts = list(
# world space: anchored above the sphere, follows the camera
text_layer(matrix(c(0, 1.3, 0), ncol = 3), "superior",
size = 20, adj = c(0.5, 0)),
# a direction label that must stay readable: no depth test
text_layer(matrix(c(1.4, 0, 0), ncol = 3), "anterior",
size = 18, adj = c(0, 0.5),
colors = c(0.75, 0.15, 0.15, 1), depth_test = FALSE),
# screen space: a panel tag in the top-left corner
text_layer(c(12, 10), "A", space = "screen", adj = c(0, 1),
size = 28, halo_color = c(1, 1, 1, 0.9)),
# screen space: a vertical axis label (rotation in degrees,
# counter-clockwise, about the anchor)
text_layer(c(16, 210), "sulcal depth", space = "screen",
adj = c(0, 0.5), size = 16, rotation = 90)))
img <- render_scene(sc)Labelling a brain surface works exactly the same way: the labels
carry world coordinates of the region they name, and are hidden when the
surface is in front of them (unless
depth_test = FALSE).
surface <- freesurferformats::read.fs.surface("sub-01/surf/lh.white")
sulc <- freesurferformats::read.fs.morph("sub-01/surf/lh.sulc")
colors <- apply_colormap(sulc, colormap = viridis_colormap(256L),
winsor_percentiles = c(2, 98))
if (ncol(colors) == 3) colors <- cbind(colors, 1)
brain <- list(vertices = surface$vertices, triangles = surface$faces,
colors = colors)
cam <- camera_auto(brain, direction = c(1, 0, 0)) # lateral view
# The free surface is roughly 100 mm wide, so the labels sit at x = +-55 mm.
sc <- scene(list(brain), camera = cam,
options = render_options(width = 600, height = 500,
background_color = c(1, 1, 1, 1)),
texts = list(
text_layer(matrix(c(-55, 0, 0), ncol = 3), "posterior",
size = 22, adj = c(1, 0.5), depth_test = FALSE),
text_layer(matrix(c(55, 0, 0), ncol = 3), "anterior",
size = 22, adj = c(0, 0.5), depth_test = FALSE)))
img <- render_scene(sc)Things worth knowing:
adj places the anchor on the text box, exactly like
adj in base R graphics: c(0, 0) is the
bottom-left corner, c(1, 1) the top-right one, and
c(0.5, 0.5) (default) centres the text on the
position.rotation turns the label about its anchor, in degrees,
counter-clockwise: 90 writes along a vertical axis (a y-axis label), 180
upside down, and any other angle lets a label follow an annotation line.
The rotation happens in the image plane, so it works for screen-space
and world-space labels alike.offset shifts the label by a number of pixels after
anchoring, which is the easy way to put an atom label next to
an atom instead of onto it.depth_test (default TRUE) hides a
world-space label when geometry is in front of its anchor. Use
FALSE for annotations that must always be readable, and for
labels anchored on a surface.halo_color (with halo_width) draws an
outline behind the glyphs, which keeps labels readable on dark or busy
geometry.colors sets per-label colours, "\n" starts
a new line, and text_extent() measures a label so you can
right-align a caption or keep two labels from overlapping.world_to_screen() converts world coordinates into the
pixels of a rendered image, which is what you need to place a
screen-space label next to a location in the scene (or to draw a callout
line with line_layer()).render_text() draws labels onto the background described by
the render options.font_file = "path/to/font.ttf", or for the whole session by
setting the SCIMESH_FONT environment variable.The same feature is available in the C++ API (TextLayer,
Scene::add_texts(), draw_text(), see
examples/cpp/text_labels/) and in the CLI renderer
(--text, --text-at, --text-size,
--text-screen, --text-rotation, …).
By default, scimesh renders with a transparent background
(background_color = c(0, 0, 0, 0)), which is ideal for
compositing.
For a solid background (e.g., white for papers), set:
The background color is written to the output image’s alpha channel, so transparent backgrounds survive PNG export and can be further composited in tools like ImageMagick or layout packages.
scimesh provides C++ and R functions for generating primitive geometry:
cube <- generate_cuboid(c(0, 0, 0), c(1, 1, 1), c(1, 0, 0, 1))
sphere <- generate_sphere(c(0, 0, 0), radius = 1.2,
segments = 32, color = c(0.9, 0.3, 0.2, 1))
cyl <- generate_cylinder(c(0, -1, 0), c(0, 1, 0), 0.5, 32,
c(0.1, 0.7, 0.3, 1))
cone <- generate_cone(c(0, -1.2, 0), c(0, 1.2, 0), 0.6, 32,
c(0.9, 0.7, 0.1, 1))
pyramid <- generate_pyramid(c(0, 0, 0), c(0, 1.5, 0), 1,
c(0.7, 0.2, 0.8, 1))
tetra <- generate_tetrahedron(c(-1, -0.5, -1), c(1, -0.5, -1),
c(0, -0.5, 1), c(0, 1.2, 0), c(0.2, 0.8, 0.8, 1))
torus <- generate_torus(c(0, 0, 0), 1.0, 0.35, 24, 12,
c(0.6, 0.4, 0.2, 1))
plane <- generate_plane(c(0, 0, 0), c(0, 1, 0), 1.2, 0.8,
c(0.5, 0.5, 0.5, 1))See examples/R/primitives/run.R for a gallery script
that renders all primitives side-by-side in both shaded and wireframe
mode.
scimesh can read and write standard 3D mesh file formats. All readers
return a mesh descriptor list with vertices,
triangles, and optionally normals,
uv, or colors.
# Read a Wavefront OBJ file (with optional UVs and normals)
mesh <- read_obj("model.obj")
# Read a Stanford PLY file (with optional vertex colors)
mesh <- read_ply("model.ply")
# Read an STL file (binary or ASCII)
mesh <- read_stl("model.stl")
# Write a mesh to STL
tmp_file1 <- tempfile(fileext = ".stl")
tmp_file2 <- tempfile(fileext = ".stl")
write_stl(mesh, tmp_file1) # binary (default)
write_stl(mesh, tmp_file2, format = "ascii")The OBJ reader supports multi-shape files and texture coordinates. The PLY reader supports per-vertex RGB colors. STL writes preserve vertex normals when available.
Translate, scale, rotate, or apply arbitrary 4x4 matrices to meshes:
Per-face coloring lets you assign a single color to every vertex of a triangle. This is useful for parcellation overlays or material assignment:
mesh <- generate_cuboid(c(0, 0, 0), c(1, 1, 1))
# 12 triangles, each gets a color
fc <- matrix(c(1, 0, 0, 1), nrow = 12, ncol = 4, byrow = TRUE)
img <- render_mesh(mesh$vertices, mesh$triangles, face_colors = fc)When face_colors is provided, it takes precedence over
vertex colors.
Load a texture image, give the mesh UV coordinates and render it:
# Requires the 'png' package
library(png)
tex <- readPNG("texture.png") # H x W x C array, row 1 = top of the image
# UV coordinates (Nx2, values 0-1) must match the vertex order of the mesh.
# scimesh UVs are image-space: c(0, 0) is the TOP LEFT pixel of the texture.
uv <- cbind(c(0, 1, 1, 0), c(0, 0, 1, 1)) # e.g. a quad
img <- render_mesh(mesh$vertices, mesh$triangles,
uv = uv, texture = tex)Bilinear texture sampling is used for smooth results, and the texel is multiplied with the vertex color (so use white vertices to show the texture unchanged).
UVs are read from files by no scimesh reader:
read_obj() and read_ply() return geometry (and
colors/normals where available), but drop texture coordinates, so you
have to supply uv yourself. Note also that OBJ files, rgl,
OpenGL and tools like Blender store UVs with the opposite
origin (v = 0 at the bottom of the image); convert such
coordinates once with flip_uvs():
# UVs taken from an OBJ file / another 3D tool: v = 0 is the bottom
uv_obj <- rbind(c(0, 1), c(1, 1), c(1, 0), c(0, 0))
mesh <- flip_uvs(list(vertices = mesh$vertices, triangles = mesh$triangles,
uv = uv_obj))
img <- render_mesh(mesh$vertices, mesh$triangles, uv = mesh$uv, texture = tex)C++ example: examples/cpp/spot_cow/
renders Keenan Crane’s textured cow from an OBJ file plus a PNG texture,
including the UV conversion.
Compute or visualise the axis-aligned bounding box:
Beyond render_mesh() and render_scene(),
scimesh provides lower-level functions for special use cases.
Render geometry where positions and colors are flat arrays with 3 vertices per triangle — useful for dynamically generated geometry:
Render point cloud data with depth-tested circular markers:
scimesh can produce horizontal or vertical colour bars in pure R (no X11), ready to be composed alongside rendered images:
cbar <- colorbar_horizontal(viridis_colormap,
n_colors = 256, width = 600, height = 80,
ticks = c(0, 0.5, 1),
tick_labels = c("min", "mid", "max"),
title = "Value")Both colorbar_horizontal() and
colorbar_vertical() accept any colormap, specified either
as: - A function returning hex colors (e.g.,
viridis_colormap, diverging_colormap,
grDevices::hcl.colors, or
viridisLite::viridis) - A vector of color
strings (e.g., c("red", "white", "blue"))
Built-in colormaps (no extra packages required):
# Viridis (perceptually uniform, colourblind-friendly)
viridis_colormap(256)
# Blue-white-red diverging (for signed data like Z-scores)
diverging_colormap(256)
# Any base R palette via wrapper functions
my_cmap <- function(n) grDevices::hcl.colors(n, palette = "inferno")
cbar <- colorbar_horizontal(my_cmap)
# Or pass colors directly
cbar <- colorbar_horizontal(c("darkblue", "cyan", "yellow", "red"))If you have viridisLite installed, you can use its full
colormap family (magma, inferno, plasma, cividis) directly:
See examples/R/colormaps/run.R for a complete
demonstration.
compose_layout() arranges rendered images in a grid with
optional per-row/per-column cropping to eliminate wasted whitespace:
Quick helpers for combining images:
scimesh and rgl mesh formats can be converted in both directions, and scimesh render functions accept rgl meshes transparently — no manual conversion needed.
Pass an rgl tmesh3d object (or any list with
vb and it components) directly to
render_mesh() or include it in a scene list for
render_scene() — the conversion happens automatically:
if (requireNamespace("rgl", quietly = TRUE)) {
rgl_mesh <- rgl::tetrahedron3d()
img <- render_mesh(rgl_mesh) # transparent conversion
}This also works when mixing scimesh and rgl meshes in a scene:
If you prefer explicit conversion, use mesh_from_rgl().
This also works without the rgl package installed — any
list with vb and it components is
accepted:
Convert a scimesh mesh back to rgl’s tmesh3d format with
mesh_to_rgl(). Vertex colors are forwarded automatically if
present in the mesh:
mesh <- generate_cuboid(c(0, 0, 0), c(1, 1, 1), color = c(1, 0, 0, 1))
rgl_mesh <- mesh_to_rgl(mesh)
if (requireNamespace("rgl", quietly = TRUE)) {
rgl::shade3d(rgl::tmesh3d(
vertices = rgl_mesh$vb,
indices = rgl_mesh$it), col = "red")
}The returned list has vb (4×N homogeneous coordinates),
it (3×M index matrix), and optionally normals
and mat (material/colors).
When rendering an rgl mesh with scimesh, you may want the output to
visually match what you’d see in an rgl window. Pass
rgl_compat = TRUE to camera_auto() to get
rgl’s default 30° FOV, 15° elevation, and bounding-sphere distance:
if (requireNamespace("rgl", quietly = TRUE)) {
rgl_mesh <- rgl::cube3d() # rgl primitive
cam <- camera_auto(rgl_mesh, rgl_compat = TRUE)
img <- render_mesh(rgl_mesh, camera = cam)
write_png(img, "cube_rgl_view.png")
}The rgl_compat camera uses the same algorithm rgl uses
internally: it computes the bounding sphere (half-diagonal of the AABB)
and places the eye at
distance = sphere_radius / sin(FOV / 2) along a direction
tilted 15° above the −Z axis.
The rgl mesh3d/tmesh3d format is the lingua
franca for 3D meshes in R. Packages like Rvcg (mesh processing
via VCGLIB), Morpho (geometric
morphometrics), and fsbrain
(neuroimaging) all produce and consume rgl-format meshes. Since scimesh
transparently accepts rgl meshes, you can process a mesh with any of
these packages and render it with scimesh without any conversion
step:
The repository includes runnable R example scripts in
examples/R/:
| Example | What it shows |
|---|---|
spot_cow/run.R |
Textured OBJ mesh with multi-light setup and SSAO |
dragon/run.R |
Stanford Dragon with specular highlights and 4x AA |
primitives/run.R |
All procedural primitives in shaded + wireframe gallery |
transparency/run.R |
Semi-transparent pial overlay on white matter |
whole_brain_sulc/run.R |
Whole-brain sulcal depth with cortex masking |
video_frames_orbit/run.R |
Turntable orbit frame sequence |
colormaps/run.R |
Custom colormaps with colorbars |
Run all examples at once:
How fast is it? A typical cortical surface (~300k triangles) at 1200x900 with 2x SSAA renders in 1–3 seconds on a modern CPU. Smaller meshes at lower resolution can render in 200 ms or less, depending on the CPU and number of lights.
Why is there no interactive 3D window in which I can rotate the mesh? scimesh is a headless (off-screen) renderer, also known as a software rasterizer. It produces images in roughly seconds, on just the CPU. For interactive rotation or real-time animation, you need to render at least 30 images per second. This is only achievable with a hardware renderer, i.e., a graphics card and the full software stack required to make use of it. If you have a graphics card, the typical solution in R is to use rgl/OpenGL for interactive visualization.
What about volume data, like 3D MRI scans in
neuroimaging? scimesh renders 3D surface meshes. Volume slice
visualisation (e.g., volvis.lb.with.surface() in fsbrain)
is done entirely in R/magick without 3D rendering — it works
independently of the renderer backend. Technically it’s just a 2D image,
no renderer needed. This means you can use the functions in fsbrain for
volume visualisation even if you do not have rgl/OpenGL, and are using
scimesh for surface (mesh) rendering.
My images look pixelated / jagged — how do I fix
this? Render at higher resolution,
e.g. (render_options(width = 2560, height = 1440)), and
enable anti-aliasing: render_options(aa_samples = 2L), or
even higher like 4L.
How do I get a transparent background? This is the
default (background_color = c(0, 0, 0, 0)). See the
Background and Transparency section under Rendering Features.
How do I control the number of CPU threads? Set the
threads option in render_options(). The
default 0 automatically uses all available cores. Set to 1 for
single-threaded rendering, or any positive integer to cap the thread
count.
What affects render performance? The main factors
are triangle count, output resolution (pixels), anti-aliasing level
(aa_samples), number of lights, SSAO, and whether
transparency sorting is needed. For largest meshes, the most effective
optimisations are reducing aa_samples and output
resolution. SSAO and multi-light setups each add roughly constant
overhead per pixel.