Skip to content

Stage 3: True 3D rendering with a free mouse-driven camera #3

Description

@harrytyp

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.hppCameraParams (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 (AddSortableSpriteToDrawViewportDrawTileSprites / 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:

  1. 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.
  2. Painter's algorithm. Sprites are sorted back-to-front each frame; a 3D camera with arbitrary angles makes this both wrong (overlaps) and expensive.
  3. 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 (DrawGroundSpriteAddTileSpriteToDraw). 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions