Stage 3: True 3D rendering with a free mouse-driven camera
Summary
Replace the fixed isometric viewport with a real 3D renderer: a free orbit camera (rotate / tilt / zoom / pan with the mouse), the ground rendered as a textured heightfield mesh, and all objects (buildings, trees, vehicles) rendered as textured quads ("billboards") in 3D space with a Z-buffer. This is the third and final stage of the 3D roadmap (see README).
Stage 1 (perspective camera, projective.hpp) and Stage 2 (continuous depth scaling) are done and serve as the foundation: the mode-7 projection, the CameraParams model and the software-scaled sprite data are all reused here.
Goal: a camera the player can freely steer with the mouse — drag to orbit, wheel to zoom, right-drag to pan — while the whole game (rendering, picking, UI) keeps working.
Current state and why this is a new renderer
What exists today (stages 1+2, on master):
src/core/projective.hpp — CameraParams (focal, focus, pitch, iso_ref), CameraProject / CameraUnproject (mode-7 perspective), ScaleForDepth (continuous depth factor).
- The viewport draw path (
src/viewport.cpp) projects sprites via RemapCoords-based math, sorts them with the painter's algorithm (AddSortableSpriteToDraw → ViewportDrawTileSprites / ViewportDrawParentSprites), and rasterises them in software (GfxBlitter in src/gfx.cpp, 32bpp SSE blitters with RGBA mip-maps in SpriteData).
- OpenGL exists only as a screen blitter (
src/video/opengl.cpp, OpenGLBackend + the 40bpp-anim blitter): sprites are still rasterised on the CPU, the result is uploaded as one texture and drawn with a shader. There is no 3D: no meshes, no matrices, no depth buffer.
- Picking (
TranslateXYToTileCoord, src/viewport.cpp:458) inverts the projection analytically.
Hard limits of the current approach:
- No yaw. Sprites are painted from a fixed direction. Houses and industry have 4 views (
TileHash2Bit in town_cmd.cpp / industry_cmd.cpp), vehicles 8 (GetImage(Direction)), but trees, ground, signals and most NewGRF objects exist in exactly one view. Rotating the camera cannot be faked with these.
- Painter's algorithm. Sprites are sorted back-to-front each frame; a 3D camera with arbitrary angles makes this both wrong (overlaps) and expensive.
- Flat ground. Terrain is a set of flat sprites; there is no heightfield geometry to intersect with a camera ray or to silhouette against the sky.
Stage 3 therefore adds a second, parallel renderer for the main viewport: a GL pipeline with its own scene graph (ground mesh + billboards), while the existing software renderer remains as fallback and for 2D mode.
Architecture overview
World coordinates (existing): world_x = (x - y) * TILE_SIZE / 2, world_y = (x + y) * TILE_SIZE / 2, world_z = z * TILE_HEIGHT
(x, y = tile coords, z = tile height — the isometric projection is a 45° rotation + tilt of this cartesian grid)
Camera (new): orbit center C, distance d, yaw φ, pitch θ → view = LookAt(C + d·dir(φ,θ), C, up), proj = Perspective(fov, aspect, near, far)
Scene (built per frame):
- ground mesh: one quad (2 triangles) per tile, corners from the tile heightmap, textured with the ground sprites
- object billboards: ParentSpriteToDraw entries (buildings, trees, vehicles) as camera-facing or yaw-quantised quads, textured from the sprite cache (SpriteData RGBA mip-maps upload 1:1 as GL textures)
- Z-buffer replaces the painter's algorithm for opaque sprites
Output: GL framebuffer → blit into the existing screen surface (reuses the OpenGLBackend presentation path).
Key reuse from stages 1+2:
CameraParams is extended (yaw, distance, target) instead of being replaced — the mode-7 code stays for the "tilted 2.5D" mode and as fallback.
- The sprite cache already stores RGBA mip-maps per zoom level (
SpriteData.infos[zoom]) — these upload to GL textures without conversion.
ScaleForDepth becomes unnecessary (the projection itself scales), but its clamp logic is the reference for LOD behaviour.
Implementation steps
Step 1 — Camera model and projection math
Goal: a Camera3D (position, yaw, pitch, distance, target) with full MVP math, unit-tested.
Approach:
- Add
src/core/camera3d.hpp (or extend projective.hpp): Camera3D { Vector3 target; float distance, yaw, pitch; float fov; } with ViewMatrix() (LookAt) and ProjectionMatrix() (perspective, near/far derived from map size).
- World-to-screen:
screen = proj · view · world; screen-to-world ray: inverse of the same (for picking, step 7).
- A small
Matrix4 / Vec3 header (or pull in a minimal math dependency — prefer a ~100-line self-contained implementation, the project has no math library).
- Coordinate conventions: OpenTTD's
RemapCoords world axes map 1:1 onto the cartesian grid above; the legacy 2D projection is exactly yaw = 45°, pitch ≈ 30° — verify this equivalence in a unit test (project the same tile through both paths and compare).
- Unit tests (extend
src/tests/projective_test.cpp): forward/inverse round-trips, the 45°/30° equivalence to RemapCoords, near-plane clipping.
Step 2 — Enable OpenGL in the build (headless + desktop)
Goal: a GL context on both desktop and the headless CI/container setup.
Approach:
- Build: link
OpenGL::GL in CMakeLists.txt (option WITH_OPENGL, already partially wired for opengl.cpp — verify the current state; the container build has no GL compiled in).
- Context: use SDL2's
SDL_GL_CreateContext (SDL2 is already a dependency) with a 3.3 core profile; on the headless container, run under Xvfb with Mesa's software GL (llvmpipe) — install libgl1-mesa-dev / libegl1-mesa-dev rootless into the prefix (same dpkg -x workflow as the existing SDL2 setup).
- Wire the existing
OpenGLBackend (texture + present shader) so the 3D renderer can blit into the window; keep the 40bpp-anim blitter path untouched for 2D.
- Verification: a headless smoke test that renders one frame with llvmpipe and compares a known pixel.
Step 3 — Ground mesh (heightfield)
Goal: terrain rendered as a real mesh with the current ground look.
Approach:
- Per tile: 4 corner heights (derive from
TileHeight + GetTileSlope, or add a helper in tile_map.cpp), 2 triangles; the whole map is one static index/vertex buffer (rebuild only when terrain changes, via the existing dirty-tile notifications).
- Texturing: the ground sprites are drawn per tile in 2D (
DrawGroundSprite → AddTileSpriteToDraw). For the mesh, upload the ground sprite set (grass/water/rough/rock variants, foundations) as a texture atlas and assign UVs per tile. Two options:
- (a) Atlas per tile type — a small set of base ground textures (grass, water, rough, rock, snow, desert) with the 4 slope variants; cheap, uniform.
- (b) Exact per-tile sprites — upload the actual sprite of each visible tile (sprite IDs from the tile's draw routine); pixel-identical to 2D but one texture per unique sprite (cached, atlas-packed).
- Start with (a), fall back to (b) per-tile for NewGRF ground tiles.
- Water: flat quads at water level with the animated water sprites (see step 5 for transparency).
- Verification: screenshot comparison — 3D camera at the legacy angle must look similar (not identical) to 2D; terrain contours visible from low angles.
Step 4 — Objects as billboards
Goal: buildings, trees, vehicles, signals rendered as textured quads in 3D, Z-buffered.
Approach:
- Reuse the existing scene collection:
AddSortableSpriteToDraw already computes the world position (x, y, z) and the sprite; in 3D mode, instead of pushing to the painter's lists, push to a Billboard { sprite, pal, world_pos, size, z_anchor } list.
- The sprite data (
SpriteData RGBA mip-maps) uploads as a GL texture; the quad size = sprite pixel size × world scale (1 sprite px ≈ TILE_SIZE/32 world units — verify against RemapCoords).
- Orientation (the core yaw question):
- Phase A (camera-facing): every billboard faces the camera (the classic 2.5D trick). Simple, works for all sprites including 1-view trees; objects look "flat" when viewed from grazing angles.
- Phase B (yaw-quantised): sprites with multiple views (houses 4, vehicles 8) pick the view nearest to the camera yaw; single-view sprites remain camera-facing. This gives real volume to buildings while trees stay simple.
- Z-anchor: sort billboards by depth only for transparent sprites (see step 5); opaque ones use the Z-buffer.
- Verification: screenshot from yaw = 0° vs 90° — buildings show different facade views, trees stay recognisable.
Step 5 — Z-buffer and transparency
Goal: correct occlusion without the painter's algorithm.
Approach:
- Enable a depth buffer (GL
DEPTH_TEST, 24-bit); opaque billboards and the ground mesh write depth.
- Transparent sprites (water, glass roofs, smoke, vehicle windows, effect sprites) need alpha blending: draw them in a second pass sorted back-to-front by depth (keep a small per-frame sort for these only — they are a small fraction of the scene).
- The existing
SpriteCombine (trees) maps to one billboard with the combined sprite — no change needed.
- Verification: a tall building in front of a smaller one — no popping/overlap artefacts from any camera angle.
Step 6 — Mouse controls (free camera)
Goal: steer the camera with the mouse, like a 3D editor.
Approach:
- In
MainWindow's viewport event handlers (src/main_gui.cpp, OnMouseWheel, OnMouseDrag):
- Left-drag: orbit — yaw += dx·k, pitch = clamp(pitch + dy·k, 5°…85°), pivoting around the tile under the cursor (so the point under the cursor stays put — standard orbit behaviour).
- Wheel: zoom — distance *= 1.1^(±1), clamped to a sensible range (e.g. 2× to 200× TILE_SIZE).
- Right-drag / middle-drag: pan — move the orbit target in the camera plane.
- Double-click / hotkey
CTRL+F: reset to the legacy angle (45°, 30°).
- Keyboard: cursor keys = pan,
+/- = zoom (consistent with the existing scroll keys where sensible).
- The new input path must coexist with the existing scroll-to-tile machinery (
ScrollMainWindowTo etc.) — in 3D mode these map to moving the orbit target.
- Verification: scripted mouse events via xdotool (already used in the hotkey test) — orbit changes the screenshot; the tile under the cursor stays under the cursor while orbiting.
Step 7 — Picking (mouse → tile)
Goal: clicks, ScrollMainWindowTo, the tile highlight and the land_info tool all work in 3D.
Approach:
- Replace
TranslateXYToTileCoord in 3D mode with a ray cast: unproject the cursor ray (inverse MVP), intersect with the ground mesh (grid traversal over the heightfield — a DDA over tiles, GetTileZ per step, ~30 lines), then with billboard bounding boxes (AABB test) for object selection.
- The tile highlight (
SetSelectionTilesDirty / _thd) reuses the tile indices — only the projection of the highlight outline changes.
- Verification: click a known tile from a tilted angle —
land_info reports the expected coordinates (automated: RCON + screenshot of the highlight).
Step 8 — Mode switching and settings
Goal: 2D / tilted-2.5D (current) / free-3D modes, no regressions.
Approach:
- New setting
gui.three_d_camera: off | tilted (current) | orbit (default off).
- In
ViewportDoDraw, dispatch to the 3D scene renderer when orbit; keep all existing redraw rules (full-viewport redraw in 3D, no buffer shift — already enforced).
- The mode-7 code path (
projective.hpp) remains the fallback when GL is unavailable (headless without llvmpipe, old machines) — orbit degrades to tilted with a console warning.
- Verification: existing regression suite stays green (2D pixel-identical, tilted unchanged); new 3D smoke tests for
orbit.
Step 9 — Performance
Goal: playable frame rates (≥ 30 FPS at 1080p on mid-range hardware).
Approach:
- One draw call for the whole ground mesh (index buffer, atlas).
- Billboards: batch by texture (sort by sprite ID → single texture bind per batch);
glDrawArraysInstanced for repeated identical objects (trees!) — the sprite cache makes this natural.
- View-frustum culling per tile (the existing
ViewportAddLandscape walking range is the conservative start).
- Texture memory: upload at the viewport zoom mip level only (the cache already keeps 6 levels); LRU eviction for NewGRF sprites.
- Reuse the stage-2 lesson: the software scaler cost was ~0 — the GL path should be strictly cheaper.
- Verification: frame-time log (the stage-2 perf harness) — 3D mode within 2× of 2D mode.
Step 10 — Fallback and maintenance
- Keep the software renderer fully functional (it is the reference for correctness and the no-GL fallback).
- All 3D code behind the same
_settings_client.gui.three_d_mode gate; no gameplay/network/savegame changes.
Milestones
| Milestone |
Scope |
Exit criteria |
| M1 |
Steps 1–3 |
Camera math unit-tested; ground mesh renders from arbitrary angles headless (llvmpipe screenshot) |
| M2 |
Steps 4–5 |
Billboards + Z-buffer; screenshot from yaw 0°/45°/90° shows correct occlusion |
| M3 |
Steps 6–7 |
Mouse orbit/zoom/pan + picking; xdotool-driven interaction tests |
| M4 |
Steps 8–10 |
Mode switch, settings, performance within 2× of 2D, full regression green |
Risks and open questions
- Single-view sprites under yaw (trees, ground, most NewGRF): camera-facing billboards (phase A) are the pragmatic answer; they look flat at grazing angles. Acceptable? (I believe yes for stage 3.)
- Ground look fidelity: an atlas of base ground textures will differ from NewGRF-heavy maps; per-tile sprite uploads fix it but cost memory. Decide in M1 based on a side-by-side screenshot.
- Transparency sorting: a handful of pathological cases (very tall transparent structures) will still sort wrong without per-pixel depth — acceptable, matches many commercial isometric→3D ports.
- NewGRF compatibility: sprites render as textures, so NewGRFs keep working visually; exotic sprite types (zoom-dependent, recolour chains) need the existing remap path — the
SpriteData flags already cover this.
- Headless CI: llvmpipe must be pinned in the test harness (
-b and env vars in autotest.py, like the 32bpp-sse2 pin).
- Scope check: this is intentionally not "real 3D models" — that would be a stage 4 (authoring meshes for every object). Billboards + heightfield is the minimum that makes a free camera look right with the existing assets.
Stage 3: True 3D rendering with a free mouse-driven camera
Summary
Replace the fixed isometric viewport with a real 3D renderer: a free orbit camera (rotate / tilt / zoom / pan with the mouse), the ground rendered as a textured heightfield mesh, and all objects (buildings, trees, vehicles) rendered as textured quads ("billboards") in 3D space with a Z-buffer. This is the third and final stage of the 3D roadmap (see README).
Stage 1 (perspective camera,
projective.hpp) and Stage 2 (continuous depth scaling) are done and serve as the foundation: the mode-7 projection, theCameraParamsmodel and the software-scaled sprite data are all reused here.Goal: a camera the player can freely steer with the mouse — drag to orbit, wheel to zoom, right-drag to pan — while the whole game (rendering, picking, UI) keeps working.
Current state and why this is a new renderer
What exists today (stages 1+2, on
master):src/core/projective.hpp—CameraParams(focal, focus, pitch, iso_ref),CameraProject/CameraUnproject(mode-7 perspective),ScaleForDepth(continuous depth factor).src/viewport.cpp) projects sprites viaRemapCoords-based math, sorts them with the painter's algorithm (AddSortableSpriteToDraw→ViewportDrawTileSprites/ViewportDrawParentSprites), and rasterises them in software (GfxBlitterinsrc/gfx.cpp, 32bpp SSE blitters with RGBA mip-maps inSpriteData).src/video/opengl.cpp,OpenGLBackend+ the40bpp-animblitter): sprites are still rasterised on the CPU, the result is uploaded as one texture and drawn with a shader. There is no 3D: no meshes, no matrices, no depth buffer.TranslateXYToTileCoord,src/viewport.cpp:458) inverts the projection analytically.Hard limits of the current approach:
TileHash2Bitintown_cmd.cpp/industry_cmd.cpp), vehicles 8 (GetImage(Direction)), but trees, ground, signals and most NewGRF objects exist in exactly one view. Rotating the camera cannot be faked with these.Stage 3 therefore adds a second, parallel renderer for the main viewport: a GL pipeline with its own scene graph (ground mesh + billboards), while the existing software renderer remains as fallback and for 2D mode.
Architecture overview
Key reuse from stages 1+2:
CameraParamsis extended (yaw, distance, target) instead of being replaced — the mode-7 code stays for the "tilted 2.5D" mode and as fallback.SpriteData.infos[zoom]) — these upload to GL textures without conversion.ScaleForDepthbecomes unnecessary (the projection itself scales), but its clamp logic is the reference for LOD behaviour.Implementation steps
Step 1 — Camera model and projection math
Goal: a
Camera3D(position, yaw, pitch, distance, target) with full MVP math, unit-tested.Approach:
src/core/camera3d.hpp(or extendprojective.hpp):Camera3D { Vector3 target; float distance, yaw, pitch; float fov; }withViewMatrix()(LookAt) andProjectionMatrix()(perspective, near/far derived from map size).screen = proj · view · world; screen-to-world ray: inverse of the same (for picking, step 7).Matrix4/Vec3header (or pull in a minimal math dependency — prefer a ~100-line self-contained implementation, the project has no math library).RemapCoordsworld axes map 1:1 onto the cartesian grid above; the legacy 2D projection is exactlyyaw = 45°, pitch ≈ 30°— verify this equivalence in a unit test (project the same tile through both paths and compare).src/tests/projective_test.cpp): forward/inverse round-trips, the 45°/30° equivalence toRemapCoords, near-plane clipping.Step 2 — Enable OpenGL in the build (headless + desktop)
Goal: a GL context on both desktop and the headless CI/container setup.
Approach:
OpenGL::GLinCMakeLists.txt(optionWITH_OPENGL, already partially wired foropengl.cpp— verify the current state; the container build has no GL compiled in).SDL_GL_CreateContext(SDL2 is already a dependency) with a 3.3 core profile; on the headless container, run under Xvfb with Mesa's software GL (llvmpipe) — installlibgl1-mesa-dev/libegl1-mesa-devrootless into the prefix (samedpkg -xworkflow as the existing SDL2 setup).OpenGLBackend(texture + present shader) so the 3D renderer can blit into the window; keep the40bpp-animblitter path untouched for 2D.Step 3 — Ground mesh (heightfield)
Goal: terrain rendered as a real mesh with the current ground look.
Approach:
TileHeight+GetTileSlope, or add a helper intile_map.cpp), 2 triangles; the whole map is one static index/vertex buffer (rebuild only when terrain changes, via the existing dirty-tile notifications).DrawGroundSprite→AddTileSpriteToDraw). For the mesh, upload the ground sprite set (grass/water/rough/rock variants, foundations) as a texture atlas and assign UVs per tile. Two options:Step 4 — Objects as billboards
Goal: buildings, trees, vehicles, signals rendered as textured quads in 3D, Z-buffered.
Approach:
AddSortableSpriteToDrawalready computes the world position (x, y, z) and the sprite; in 3D mode, instead of pushing to the painter's lists, push to aBillboard { sprite, pal, world_pos, size, z_anchor }list.SpriteDataRGBA mip-maps) uploads as a GL texture; the quad size = sprite pixel size × world scale (1 sprite px ≈ TILE_SIZE/32 world units — verify againstRemapCoords).Step 5 — Z-buffer and transparency
Goal: correct occlusion without the painter's algorithm.
Approach:
DEPTH_TEST, 24-bit); opaque billboards and the ground mesh write depth.SpriteCombine(trees) maps to one billboard with the combined sprite — no change needed.Step 6 — Mouse controls (free camera)
Goal: steer the camera with the mouse, like a 3D editor.
Approach:
MainWindow's viewport event handlers (src/main_gui.cpp,OnMouseWheel,OnMouseDrag):CTRL+F: reset to the legacy angle (45°, 30°).+/-= zoom (consistent with the existing scroll keys where sensible).ScrollMainWindowToetc.) — in 3D mode these map to moving the orbit target.Step 7 — Picking (mouse → tile)
Goal: clicks,
ScrollMainWindowTo, the tile highlight and theland_infotool all work in 3D.Approach:
TranslateXYToTileCoordin 3D mode with a ray cast: unproject the cursor ray (inverse MVP), intersect with the ground mesh (grid traversal over the heightfield — a DDA over tiles,GetTileZper step, ~30 lines), then with billboard bounding boxes (AABB test) for object selection.SetSelectionTilesDirty/_thd) reuses the tile indices — only the projection of the highlight outline changes.land_inforeports the expected coordinates (automated: RCON + screenshot of the highlight).Step 8 — Mode switching and settings
Goal: 2D / tilted-2.5D (current) / free-3D modes, no regressions.
Approach:
gui.three_d_camera:off | tilted (current) | orbit(defaultoff).ViewportDoDraw, dispatch to the 3D scene renderer whenorbit; keep all existing redraw rules (full-viewport redraw in 3D, no buffer shift — already enforced).projective.hpp) remains the fallback when GL is unavailable (headless without llvmpipe, old machines) —orbitdegrades totiltedwith a console warning.orbit.Step 9 — Performance
Goal: playable frame rates (≥ 30 FPS at 1080p on mid-range hardware).
Approach:
glDrawArraysInstancedfor repeated identical objects (trees!) — the sprite cache makes this natural.ViewportAddLandscapewalking range is the conservative start).Step 10 — Fallback and maintenance
_settings_client.gui.three_d_modegate; no gameplay/network/savegame changes.Milestones
Risks and open questions
SpriteDataflags already cover this.-band env vars inautotest.py, like the32bpp-sse2pin).