Stereoscopic Rendering - Sixth 3D

Table of Contents

<- Back to index

1. What stereo rendering adds

A single rendered image is flat: the brain infers depth only from monocular cues (occlusion, shading, perspective, motion parallax while you move). Stereoscopic rendering adds the strongest depth cue of all — binocular disparity: your two eyes see slightly different images, and the visual cortex turns the difference into a direct sensation of depth.

Sixth 3D implements the simplest and most portable form: side-by-side stereo. Every frame renders the scene twice — once from the left eye position, once from the right — into the left and right halves of the same image. A VR headset, 3D TV, or a pair of XR glasses in side-by-side mode feeds each half to the corresponding eye, and the scene gains real volume.

stereo-side-by-side.png

Figure 1: A side-by-side stereoscopic frame of the House demo, rendered headlessly with the Snapshot tool. Left half: left eye. Right half: right eye. Compare the dark cube and the doorway between the halves — the horizontal shift is the disparity your brain reads as depth.

2. The geometry: two parallel cameras

The two eye cameras are identical to the mono camera except for one thing: the left eye's position is shifted by -IPD/2 and the right eye's by +IPD/2 along the world X axis (the offset is applied to the camera translation's x component directly, then restored). IPD (inter-pupillary distance) defaults to 6.5 world units — the House demo treats 1 unit as 1 cm, and 6.5 cm is the median human IPD.

Both cameras look in exactly the same direction (parallel cameras, no toe-in). Objects at different depths then land at different horizontal offsets between the two images — that offset is the disparity:

Stereo geometry.svg

Figure 2: Top-down view: the two eye positions and how a near and a far object project onto the screen plane. The near object separates much more between the eyes than the far one.

  • near object -> large disparity -> feels close,
  • far object -> small disparity -> feels far,
  • object at infinity -> zero disparity.

Larger IPD exaggerates disparity (stronger but potentially straining depth); smaller IPD flattens the scene. +=/-= keys adjust it live in 0.5-unit steps while stereo is active.

3. One frame = two passes

Stereo does not add a second pipeline — it runs the existing triple-buffered pipeline twice per frame. The render thread in ViewPanel.renderFrame() executes two render passes back to back:

Stereo pipeline.svg

Figure 3: Per frame, the camera is nudged left, a full transform/sort/bin pass runs for the left viewport, then the camera is nudged right and a second pass runs for the right viewport. Both paint into one shared frame buffer, clipped to their half.

  1. Pass LEFT: camera translation.x is temporarily decreased by IPD/2, the scene is transformed, depth-sorted and tile-binned into a per-pass RenderingContext copy whose viewport is the left half of the frame ([0, width/2)), and the paint continuation is submitted to the worker pool.
  2. Pass RIGHT: the same with +IPD/2 and the right viewport ([width/2, width)). The camera offset is always restored in a finally block, so the camera never drifts.
  3. The two paints write into one shared frame buffer — each clipped to its half — and the completed side-by-side image is blitted to the screen in one go.

The triple-buffer machinery (3 vertex slots, 3 aggregator slots, 3 framebuffers) does not change: a pass takes the slot passCounter % 3, so left and right passes of the same frame simply occupy consecutive slots and overlap exactly like consecutive mono frames do. Workers flow from one pass's tiles straight into the next pass's tiles with no idle gap.

4. What adapts per eye

The scene itself — geometry, textures, lightmaps, global illumination — is shared and identical for both eyes. Only the viewpoint moves, so only view-dependent stages differ per pass:

Stereo per eye.svg

Figure 4: The per-eye surface area of the engine. Everything not listed here is eye-independent.

  • Projection: Vertex projects with projectionScale = eyeWidth/3 (per-eye horizontal FOV) and adds stereoViewportOffsetX so the projected image lands in the correct half of the buffer. The same offset is applied for near-plane-clip vertices created directly in camera space.
  • Frustum culling: the frustum is rebuilt per pass from stereoViewportWidth, so each eye culls against its own (narrower) view volume — nothing leaks in from the other eye's half.
  • Painting: every painter clips X to [renderMinX, renderMaxX), which the pass set to its viewport. No eye can paint into the other half, even if a polygon crosses the center line.
  • Mouse picking: in stereo each eye shows the same object at a different screen X, so a hit can only be resolved against one eye. ViewPanel combines mouse results only for the pass whose viewport actually contains the cursor.
  • HUD/overlays: developer tools, crosshair and text are drawn once over the finished frame, at zero disparity — they sit on the screen surface, not in the world.

5. Enabling stereo

ViewPanel viewPanel = ...;

// Side-by-side stereo on:
viewPanel.setStereoModeEnabled(true);

// Optional: match the viewer (default 6.5 world units):
viewPanel.setStereoIPD(6.5);

In the demos, SHIFT+F11 toggles stereo and fullscreen together (XR glasses want both); plain F11 remains fullscreen-only. With stereo active, + and - adjust the IPD in 0.5-unit steps (clamped at 0.5) so the viewer can tune comfort at runtime.

mono-comparison.png

Figure 5: The same scene rendered as a normal mono frame (for comparison with the pair above). Notice there is no horizontal offset to read depth from — the picture is flat.

6. Performance and limitations

  • Stereo doubles the per-frame transform, sort and paint work — two full passes instead of one. The pipeline overlaps them the same way it overlaps consecutive mono frames, so throughput drops less than 2x on a multi-core machine, but expect a real cost.
  • Each eye gets half the horizontal resolution of the panel. On a 1920x1080 fullscreen window each eye sees 960x1080 — pixels are shared, not duplicated.
  • IPD is in world units: 6.5 only means "6.5 cm" if the scene is modeled at 1 unit = 1 cm. In a scene with a different scale, divide or multiply accordingly — or just tune with +=/-= until the depth feels right.
  • The eye offset is applied along the world X axis, not the camera's right vector: it is exactly correct when the camera faces along Z (yaw = 0) and degrades as you turn — at yaw = 90° the eyes would be offset front-to-back instead of side-to-side. For a fixed-viewing- direction demo this is fine; a fully rotational stereo camera would need to apply the IPD along the rotated right vector.
  • Side-by-side is a display format, not a headset driver: the engine produces the image; an XR viewer, 3D TV or video player is responsible for delivering the halves to the eyes.
  • Global illumination is unaffected: lightmaps live on the surfaces, so both eyes sample the same converged lighting for free.

Author: Svjatoslav Agejenko

Created: 2026-09-08 ti 00:46

Validate