Rendering Loop - Sixth 3D

Table of Contents

Back to main documentation

1. Rendering loop

The rendering loop is the heart of the engine, continuously generating frames on a dedicated background thread. It orchestrates the entire rendering pipeline from 3D world space to pixels on screen.

1.1. What is a render loop?

A render loop is a continuous process that generates visual frames from 3D data. Think of it like a movie camera: each "frame" captures the current state of the 3D world and converts it into a 2D image that can be displayed on screen.

The process transforms shapes through multiple coordinate systems:

Shapes Transform Sort Paint Blit Screen 3D vertices world→screen back-to-front 8 threads copy buffer

Each step has a specific purpose:

Step Input Output Purpose
Shapes 3D vertices, meshes Scene data Objects waiting to be drawn
Transform World coordinates Screen coordinates Convert 3D positions to where they appear on screen (see coordinate system)
Sort Unordered shapes Ordered by depth Ensure correct visibility (far objects painted first)
Paint Sorted shapes Pixels in buffer Clear segments (parallel) and rasterize triangles into pixel array
Blit Pixel buffer Screen image Copy completed frame to display

This pipeline runs repeatedly, typically 60 times per second (60 FPS). Even if nothing moves, the loop continues running—but the engine skips unnecessary work when the scene is static.

1.2. Main loop structure

The render thread runs continuously in a dedicated daemon thread:

while (renderThreadRunning) {
    ensureThatViewIsUpToDate();  // Render one frame
    maintainTargetFps();         // Sleep if needed
}

The thread is a daemon, so it automatically stops when the JVM exits. You can stop it explicitly with ViewPanel.stop().

1.3. Frame rate control

The engine supports two modes:

  • Target FPS mode: Set with setFrameRate(int). The engine tries to maintain the target rate by sleeping between frames.

    • When rendering is slower than target: No sleeping occurs. The engine runs at maximum hardware speed. Missed frames are skipped, not rendered later — the timing simply resets to current time.
    • When rendering is faster than target: The thread sleeps to limit FPS to the target rate, avoiding unnecessary CPU usage.

    For example, with a 60 FPS target:

    • If a complex scene takes 30ms per frame, you get ~33 FPS (hardware limit)
    • If the scene later simplifies to 10ms per frame, you get exactly 60 FPS (throttled by sleeping)
  • Unlimited mode: Set setFrameRate(0) or negative. No sleeping — renders as fast as possible. Useful for benchmarking.

2. Rendering phases

Each frame goes through 5 phases:

2.1. Phase 1: Transform shapes

All shapes are transformed from world space to screen space:

  1. Build camera-relative transform (inverse of camera position/rotation)
  2. For each shape:
    • Apply camera transform
    • Project 3D → 2D (perspective projection)
    • Calculate onScreenZ for depth sorting
    • Queue for rendering

What is coordinate transformation?

Every shape exists in "world space" — its own position in the 3D world. To render it, we must convert to "screen space" — where it appears on your monitor. This involves:

  • Translation: Move coordinates relative to camera position
  • Rotation: Rotate coordinates based on camera orientation
  • Projection: Convert 3D (x, y, z) to 2D (x, y) screen pixels

Objects further away appear smaller (perspective). The coordinate system uses Y-down to match screen conventions, making projection straightforward.

This is single-threaded but very fast — just math, no pixel operations.

2.2. Phase 2: Sort shapes

Shapes are sorted by onScreenZ (depth) in descending order:

Collections.sort(queuedShapes, (a, b) -> Double.compare(b.onScreenZ, a.onScreenZ));

Why sort back-to-front?

This implements the painter's algorithm — like painting a landscape: first paint the sky (farthest), then mountains, then trees, then the foreground. Each layer covers what's behind it.

Far (Z=500) — painted first Medium (Z=300) — painted second Near (Z=100) — painted last

Without sorting, nearby objects might be painted first and then covered by distant ones, causing visual errors. This is especially important for transparent objects — you need to see through the near ones to what's behind.

The onScreenZ value represents distance from the camera after transformation. Larger values = further away.

2.3. Phase 3: Clear and paint segments (multi-threaded)

The screen is divided into 8 horizontal segments, each processed by a separate thread. Each thread performs two operations:

  1. Clear segment: Fill its Y-range with background color
  2. Paint shapes: Render all shapes within that Y-range

Both operations happen within the same thread task, ensuring clearing completes before painting begins. This provides greater RAM bandwidth utilization compared to single-threaded clearing.

Segment 0 (Thread 0) Segment 1 (Thread 1) Segment 2 (Thread 2) Segment 3 (Thread 3) Segment 4 (Thread 4) Segment 5 (Thread 5) Segment 6 (Thread 6) Segment 7 (Thread 7)

Each thread:

  • Gets a SegmentRenderingContext with Y-bounds (minY, maxY)
  • Clears its segment to background color (parallel memory fill)
  • Iterates all shapes and paints pixels within its Y-range
  • Clips triangles/lines at segment boundaries
  • Detects mouse hits (before clipping)

A CountDownLatch waits for all 8 threads to complete before proceeding.

Why 8 segments? This matches the typical core count of modern CPUs. The fixed thread pool (Executors.newFixedThreadPool(8)) avoids the overhead of creating threads per frame.

Why parallel clearing? Each thread clears its own memory region (disjoint Y-ranges), avoiding synchronization overhead while maximizing memory bandwidth utilization on multi-core systems.

2.4. Phase 4: Combine mouse results

During painting, each segment tracks which shape is under the mouse cursor. Since all segments paint the same shapes (just different Y-ranges), they should all report the same hit. Phase 4 takes the first non-null result:

for (SegmentRenderingContext ctx : segmentContexts) {
    if (ctx.getSegmentMouseHit() != null) {
        renderingContext.setCurrentObjectUnderMouseCursor(ctx.getSegmentMouseHit());
        break;
    }
}

2.5. Phase 5: Blit to screen

The rendered BufferedImage is copied to the screen using BufferStrategy for tear-free page-flipping:

do {
    Graphics2D g = bufferStrategy.getDrawGraphics();
    g.drawImage(renderingContext.bufferedImage, 0, 0, null);
    g.dispose();
} while (bufferStrategy.contentsRestored());

bufferStrategy.show();
Toolkit.getDefaultToolkit().sync();

What is double-buffering?

Without double-buffering, the screen updates while pixels are being written. This causes screen tearing — visible horizontal splits where the top of the frame shows old content while the bottom shows new.

Without double-buffering display shows partial update old frame ← tear new frame With double-buffering Back buffer (draw here) swap Front buffer (displayed) complete frame

Double-buffering uses two pixel buffers:

  • Back buffer: Where rendering happens (offscreen, invisible)
  • Front buffer: What's currently displayed on screen

When rendering completes, the buffers swap in one atomic operation. The viewer always sees complete frames, never partial updates.

The do-while loop handles the case where the OS recreates the back buffer (common during window resizing). Since our offscreen BufferedImage still has the correct pixels, we only need to re-blit, not re-render.

3. Frame listeners and smart repaint skipping

A FrameListener is a callback that runs custom logic before each potential frame. Think of it as your "per-frame hook" — the engine calls all registered listeners, giving them a chance to update animations, physics, or game logic.

3.1. Registering a frame listener

Use addFrameListener() to register your callback:

// This is how you register a frame listener
viewPanel.addFrameListener((panel, deltaMs) -> {
    // Example: simple animation listener
    double rotationSpeed = 1.0;  // radians per second
    shape.rotate(rotationSpeed * deltaMs / 1000.0);  // Framerate-independent rotation
    return true;  // Request repaint (shape moved)
});

The listener receives two parameters:

  • panel: The ViewPanel that's rendering
  • deltaMs: Milliseconds since last frame (for framerate-independent animation)

The return value controls whether the frame gets rendered:

  • true: "Something changed — repaint the screen"
  • false: "Nothing changed — can skip this frame"

3.2. Frame skipping optimization

The engine avoids unnecessary rendering. A frame is skipped when:

  • All listeners return false (nothing changed in your scene)
  • Camera velocity is zero (built-in Camera listener returns false when not moving)
  • No resize or repaint requests

This means a static scene with no animations consumes almost zero CPU. The render thread keeps running (checking for changes), but actual pixel rendering is skipped entirely.

// Example: listener that only requests repaint when needed
viewPanel.addFrameListener((panel, deltaMs) -> {
    if (gameState.hasUpdates()) {
        gameState.processUpdates();
        return true;   // Only repaint when game state actually changed
    }
    return false;      // Skip frame — nothing to update
});

3.3. Built-in listeners

The engine registers these listeners by default:

  • Camera — returns true when user is actively navigating (velocity > 0)
  • InputManager — processes mouse/keyboard events

When the camera stops moving and you release all keys, the Camera listener returns false. If your custom listeners also return false, the frame is skipped until something changes.

4. Rendering context

The RenderingContext holds all state for a single frame:

Field Purpose
pixels[] Raw pixel buffer (int[] in RGB format)
bufferedImage Java2D wrapper around pixels
graphics Graphics2D for text, lines, shapes
width, height Rendering area dimensions
centerCoordinate Screen center (for projection)
projectionScale Perspective scale factor
frameNumber Monotonically increasing frame counter

A new context is created when the window is resized. Otherwise, the same context is reused — prepareForNewFrameRendering() just resets per-frame state like mouse tracking.

Created: 2026-05-12 Tue 22:22

Validate