Link Search Menu Expand Document

Offscreen Rendering

OffscreenRenderer renders [ViewportBody] to a CGImage or PNG file without an MTKView or a window. It uses the same Metal pipelines as the interactive viewport — the same Blinn-Phong shading, the same shadow map, the same transparent-surface sort — so headless output is visually identical to what a user sees on screen.

Typical uses: documentation figures, thumbnail generation, CI regression images, export workflows, and integration tests that check rendering without a display.

OffscreenRenderer is @MainActor. On macOS it can be driven from a plain @main Swift executable. In a test target annotate the test method @MainActor or call from a Task { @MainActor in … }.


Quick start

import OCCTSwiftViewport

// 1. Create the renderer (failable — returns nil if no Metal device).
guard let renderer = await MainActor.run(body: { OffscreenRenderer() }) else {
    fatalError("No Metal device")
}

// 2. Geometry.
let body = ViewportBody.box(size: SIMD3<Float>(2, 1, 3), color: SIMD4<Float>(0.4, 0.6, 0.9, 1))

// 3. Frame the camera to fit the geometry.
let aspectRatio: Float = 1024.0 / 768.0
let camera = CameraState.isometric.fit(to: [body], aspectRatio: aspectRatio) ?? CameraState.isometric

// 4. Render.
let options = OffscreenRenderOptions(
    width: 1024,
    height: 768,
    cameraState: camera
)
if let image = renderer.render(bodies: [body], options: options) {
    // use CGImage …
}

OffscreenRenderOptions — all fields

Field Type Default Notes
width Int 1024 Output pixel width
height Int 768 Output pixel height
cameraState CameraState CameraState() Camera position and projection
displayMode DisplayMode .shadedWithEdges See Display modes
lightingConfiguration LightingConfiguration .threePoint Key/fill/back + ambient
backgroundColor SIMD4<Float> (0.95, 0.95, 0.95, 1) RGBA, linear
showGrid Bool false Adaptive dot grid
showAxes Bool false RGB world-space axes
msaaSampleCount Int 4 MSAA samples (1, 2, or 4)
explicitOrthoBounds OrthoBounds? nil See Pixel-registered ortho
pixelPan SIMD2<Float>? nil Screen-space nudge (+x right, +y down)
measurements [ViewportMeasurement] [] See Measurement overlays

All fields have defaults so you can construct with only the overrides you need:

var options = OffscreenRenderOptions()   // 1024×768, shadedWithEdges, threePoint
options.backgroundColor = SIMD4<Float>(1, 1, 1, 1)   // white background
options.showGrid = true

Framing the camera with CameraState.fit

CameraState.fit(to:aspectRatio:padding:) returns a copy of the receiver with pivot, distance (perspective), or orthographicScale (orthographic) set to enclose a BoundingBox. The padding parameter is a multiplicative margin: 1.0 is a tight fit, 1.1 (the default) adds 10 % breathing room.

// Fit to an explicit bounding box.
let bounds = BoundingBox(min: SIMD3(-5, 0, -5), max: SIMD3(5, 10, 5))
let camera = CameraState.isometric.fit(to: bounds, aspectRatio: 4.0 / 3.0, padding: 1.15)

The convenience overload that accepts [ViewportBody] unions the bounding boxes of all visible bodies and returns nil when no body has geometry:

if let camera = CameraState.front.fit(to: bodies, aspectRatio: Float(width) / Float(height)) {
    options.cameraState = camera
}

Standard presets — CameraState.isometric, .top, .front, .right — are good starting points. Build a completely custom viewpoint with CameraState.lookAt:

let camera = CameraState.lookAt(
    target: SIMD3<Float>(0, 0, 0),
    from:   SIMD3<Float>(10, 8, 12)
)

Rendering to CGImage and to a PNG file

render(bodies:options:) -> CGImage?

Executes the Metal command buffer synchronously on the calling thread and returns a CGImage backed by a shared-memory blit. Returns nil only if the device is lost or texture allocation fails.

if let image = renderer.render(bodies: bodies, options: options) {
    // Write with ImageIO, pass to NSImageView / UIImageView, diff in tests …
}

renderToPNG(bodies:url:options:) throws -> Int

Calls render, encodes the result as PNG via ImageIO, writes it to url, and returns the file size in bytes. Throws OffscreenRenderError:

Error Meaning
.renderFailed Metal render returned nil
.fileCreationFailed Could not create CGImageDestination at the URL
.writeFailed CGImageDestinationFinalize failed (disk full, permissions, …)
let url = URL(fileURLWithPath: "/tmp/part-thumbnail.png")
do {
    let bytes = try renderer.renderToPNG(bodies: bodies, url: url, options: options)
    print("wrote \(bytes) bytes")
} catch OffscreenRenderError.renderFailed {
    // handle …
}

@discardableResult — the return value can be ignored when only the file matters.


Display modes

OffscreenRenderOptions.displayMode accepts any DisplayMode case.

Case Surfaces Edges Lighting
.wireframe no yes
.shaded yes no yes
.shadedWithEdges (default) yes yes yes
.flat yes no yes (flat normals)
.unlit yes no no
.xray yes (transparent) yes yes

.unlit — faithful diagnostic colour renders

.unlit draws each body at its constant color with no lighting, no ambient hemisphere, no shadows, no Fresnel rim, and no tone mapping. Every body appears exactly as its SIMD4<Float> colour with no shading variation across the surface.

Use it when colours carry semantic meaning — topology colouring, deviation maps, result visualisations — and lighting-induced brightness variation would obscure the data.

let redBody   = ViewportBody.box(size: .one, color: SIMD4<Float>(1, 0, 0, 1))
let greenBody = ViewportBody.box(size: .one, color: SIMD4<Float>(0, 1, 0, 1))
// place bodies at different positions via body.transform …

let options = OffscreenRenderOptions(
    displayMode: .unlit,
    backgroundColor: SIMD4<Float>(0.12, 0.12, 0.12, 1)
)

Lighting and background

lightingConfiguration accepts any LightingConfiguration value. The built-in presets cover most documentation needs:

options.lightingConfiguration = .studio         // soft, balanced
options.lightingConfiguration = .architectural  // high-contrast
options.lightingConfiguration = .flat           // ambient-only, no shadows
options.lightingConfiguration = .threePoint     // default: key + fill + back

backgroundColor is a linear-space RGBA SIMD4<Float>. Common values:

options.backgroundColor = SIMD4<Float>(1, 1, 1, 1)       // white
options.backgroundColor = SIMD4<Float>(0, 0, 0, 1)       // black
options.backgroundColor = SIMD4<Float>(0.18, 0.18, 0.18, 1)  // mid-grey

MSAA

msaaSampleCount accepts 1, 2, or 4 (the default). On Apple Silicon 4× MSAA resolves in the same pass at negligible extra cost. Set to 1 when doing pixel-exact SSIM comparisons or when writing tests that check raw pixel values.

options.msaaSampleCount = 1   // pixel-exact; no multi-sample blur

Pixel-registered ortho renders

When the output must align with an external drawing or reference image — for example SSIM-diffing against a CAD drawing view — set explicitOrthoBounds to pin the world-space region exactly rather than inferring it from CameraState.orthographicScale or the scene AABB:

let bounds = OrthoBounds(left: -50, right: 50, bottom: -37.5, top: 37.5)
var options = OffscreenRenderOptions(
    width: 2000, height: 1500,
    cameraState: CameraState.top,
    displayMode: .shadedWithEdges
)
options.explicitOrthoBounds = bounds

For sub-pixel registration nudges after the projection is set, use pixelPan. Positive x pans the image right; positive y pans it down (screen-space convention):

options.pixelPan = SIMD2<Float>(0.5, -1.2)  // nudge half a pixel right, 1.2 up

pixelPan is a lightweight alternative to recomputing explicitOrthoBounds when the calibration offset is already known in pixel space.


Measurement overlays

Populate options.measurements with ViewportMeasurement values to burn dimension annotations into the output image. MeasurementCompositor composites them over the Metal render using Core Graphics and Core Text, matching the visual style of the interactive MeasurementOverlay SwiftUI canvas exactly (same line weights, label font, halo, colours).

The three measurement kinds and their required world-space data:

Kind Type Required properties
Distance DistanceMeasurement start, end, midpoint, distance
Angle AngleMeasurement vertex, pointA, pointB, degrees
Radius RadiusMeasurement center, edgePoint, radius

World-space anchor points must be resolved before passing to OffscreenRenderOptions. The headless path performs no geometry or topology lookups.

let dist = DistanceMeasurement(
    start:    SIMD3<Float>(0, 0, 0),
    end:      SIMD3<Float>(10, 0, 0),
    midpoint: SIMD3<Float>(5, 0, 0),
    distance: 10.0
)
options.measurements = [.distance(dist)]

Labels default to auto-formatted values ("10.00", "45.0°", "R5.00"). Override with the label property on any measurement type:

var dist = DistanceMeasurement()
dist.label = "100 mm"

How the cookbook figures are made

The figures in Examples/DocFigures/ are produced with exactly this API — each figure is a small Swift script that constructs bodies, sets OffscreenRenderOptions, and calls renderToPNG. Running the scripts regenerates all figures deterministically from source geometry, with no manual screenshot step.


Thread safety

OffscreenRenderer is @MainActor. It is safe to hold a single instance and call render or renderToPNG multiple times; the renderer caches GPU buffers per body.id and only rebuilds them when body.generation changes.

Concurrent renders from multiple OffscreenRenderer instances are safe — each instance owns its Metal command queue, buffer cache, and textures independently.