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. 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.2. Frame rate control

The engine supports two modes:

  • Target FPS mode: Set with setFrameRate(int). The thread sleeps between frames to maintain the target rate. If rendering takes longer than the frame interval, the engine catches up naturally without sleeping.
  • Unlimited mode: Set setFrameRate(0) or negative. No sleeping — renders as fast as possible. Useful for benchmarking.

1.3. Frame listeners

Before each frame, the engine notifies all registered FrameListeners:

viewPanel.addFrameListener((panel, deltaMs) -> {
    // Update animations, physics, game logic
    shape.rotate(0.01);
    return true;  // true = force repaint
});

Frame listeners can trigger repaints by returning true. Built-in listeners include:

2. Rendering phases

Each frame goes through 6 phases. Open the Developer Tools panel (F12) to see these phases logged in real-time:

2.1. Phase 1: Clear canvas

The pixel buffer is filled with the background color (default: black).

Arrays.fill(pixels, 0, width * height, backgroundColorRgb);

This is a simple Arrays.fill operation — very fast, single-threaded.

2.2. Phase 2: 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

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

2.3. Phase 3: Sort shapes

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

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

Back-to-front sorting is essential for correct transparency and occlusion. Shapes further from the camera are painted first.

2.4. Phase 4: Paint shapes (multi-threaded)

The screen is divided into 8 horizontal segments, each rendered by a separate thread:

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)
  • 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.

2.5. Phase 5: 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 5 takes the first non-null result:

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

2.6. Phase 6: 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();

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. Smart repaint skipping

The engine avoids unnecessary rendering:

  • viewRepaintNeeded flag: Set to true only when something changes
  • Frame listeners can return false to skip repaint
  • Resizing, component events, and explicit repaintDuringNextViewUpdate() calls set the flag

This means a static scene consumes almost zero CPU — the render thread just spins checking the flag.

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 Screen 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-04-04 Sat 14:55

Validate