Rendering Loop - Sixth 3D
Table of Contents
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:
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:
- Build camera-relative transform (inverse of camera position/rotation)
- For each shape:
- Apply camera transform
- Project 3D → 2D (perspective projection)
- Calculate
onScreenZfor 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.
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:
- Clear segment: Fill its Y-range with background color
- 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.
Each thread:
- Gets a
SegmentRenderingContextwith 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.
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 renderingdeltaMs: 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.