Configure a 3D Map View in PyQGIS

The QGIS 3D view is a separate rendering engine with its own settings object, its own terrain, its own lighting and its own idea of which layers exist. Almost none of it is inherited from the 2D canvas, which is why a scene configured by clicking is so hard to reproduce in a script — and why, once you know the four objects involved, it becomes straightforward.

This recipe belongs to Temporal & 3D Visualization in PyQGIS. It covers building the settings object, terrain from a DEM, vertical exaggeration, lighting, and getting a reproducible image out through a layout.

Four objects, and none of them optionalThe settings object holds the coordinate system, the extent and the layer list. A terrain generator supplies the ground surface. Each layer needs its own three dimensional renderer to contribute geometry. Lighting decides whether the result is readable. Omitting any of the four gives an empty or unreadable scene with no error.Miss one and the scene is empty, silentlyQgs3DMapSettingssetCrs()setExtent()setLayers()the containerterrain generatorflat, or from a DEMmandatoryrenderer3D per layera 2D style is not enoughlightingoff-axis, or no depthimport from qgis._3d — the underscore is requiredbecause "3d" is not a valid Python module name

Prerequisites

  • QGIS 3.34 LTR or newer, built with 3D support — every official package is.
  • An OpenGL-capable display, or a virtual framebuffer on a headless machine.
  • A DEM if you want real terrain, and a layer with height information if you want geometry.

Build the settings object

from qgis.core import QgsProject
from qgis._3d import Qgs3DMapSettings, QgsFlatTerrainGenerator

project = QgsProject.instance()
buildings = project.mapLayersByName("buildings")[0]
roads = project.mapLayersByName("roads")[0]

settings = Qgs3DMapSettings()
settings.setCrs(project.crs())
settings.setLayers([buildings, roads])
settings.setExtent(buildings.extent())

terrain = QgsFlatTerrainGenerator()
terrain.setCrs(project.crs())
terrain.setExtent(buildings.extent())
settings.setTerrainGenerator(terrain)

Breakdown: from qgis._3d import ... with the underscore is the correct import; qgis.3d is not a legal identifier and every C++ example you will find spells it without one. setExtent defines the world the scene occupies, and getting it wrong is the commonest reason a first scene appears empty — an extent covering a continent puts your buildings below one pixel. The terrain generator is required even when the terrain is flat: a scene with none renders nothing and reports nothing. Note that the layer list here is independent of the project's layer tree, so a layer visible in 2D is absent from the scene unless you put it in this list.

Terrain from a DEM

from qgis._3d import QgsDemTerrainGenerator

dem = project.mapLayersByName("dtm")[0]

terrain = QgsDemTerrainGenerator()
terrain.setCrs(project.crs())
terrain.setExtent(dem.extent())
terrain.setLayer(dem)
terrain.setResolution(32)
settings.setTerrainGenerator(terrain)
settings.setTerrainVerticalScale(1.0)

Breakdown: setResolution is the tile resolution in pixels — how finely each terrain tile is sampled — and it trades detail against memory quadratically, so 16 is coarse and fast, 32 is a sensible default and 128 is for a small area you are going to look at closely. setTerrainVerticalScale is the vertical exaggeration, and 1.0 means true scale. Exaggeration above about 2 makes a landscape look like a stage set; on genuinely flat terrain where the whole point is to show a two-metre difference, 5 or 10 is defensible provided the figure is stated on the map. An exaggerated DEM under unexaggerated buildings produces buildings floating or buried, since only the terrain is scaled.

Lighting that shows depth

from qgis.core import QgsDirectionalLightSettings
from qgis.PyQt.QtGui import QColor

light = QgsDirectionalLightSettings()
light.setDirection(QgsVector3D(-0.6, -1.0, -0.4))
light.setColor(QColor(255, 250, 240))
light.setIntensity(1.0)
settings.setLightSources([light])

Breakdown: A direction vector pointing down and to one side is what produces shadow on the faces turned away from it, and shadow is the only cue a static image gives about which building is in front of which. The default light in many builds sits close to the camera axis, which flattens everything — moving it off-axis is the single highest-value change to a 3D scene. A slightly warm light colour reads as daylight; a pure white one reads as a rendering. setLightSources replaces the whole list, so passing one light removes the defaults, which is what you want here.

Getting a reproducible image out

The layout route is the portable oneCapturing the interactive three dimensional canvas depends on helper functions whose names and signatures have changed across releases. A layout containing a three dimensional map item stores its camera position declaratively and exports through the normal layout exporter, so the same script works across versions.Same scene, very different reliability3D canvas captureneeds an offscreen enginehelper names changed in 3.30camera set imperativelya script pinned to one versionlayout 3D map itemviewpoint stored in the layoutexports through QgsLayoutExportertitle, legend and scale for freeworks unchanged across releasesset the viewpoint once by hand, then drive the export from Python

from qgis.core import QgsLayoutExporter, QgsPrintLayout

layout = project.layoutManager().layoutByName("site_3d")
item = layout.itemById("scene")
item.setMapSettings(settings)

exporter = QgsLayoutExporter(layout)
image_settings = QgsLayoutExporter.ImageExportSettings()
image_settings.dpi = 200
exporter.exportToImage("/data/output/site_3d.png", image_settings)

Breakdown: QgsLayoutItem3DMap holds both a settings object and a camera pose, and the camera is the part worth setting interactively once and saving with the layout — positioning a 3D camera by typing numbers is unpleasant, and the layout's "set from current 3D view" action does it in one click. Assigning fresh settings from Python then re-uses that saved viewpoint with new data, which is exactly what a repeatable report needs. The export is synchronous, so no waiting is required.

Draping a basemap over the terrain

Terrain with no texture on it is a grey landform. The 3D view drapes whatever 2D layers are in the settings list over the terrain surface, which is how an orthophoto or a styled land-cover layer becomes the ground's appearance.

ortho = project.mapLayersByName("orthophoto")[0]
landuse = project.mapLayersByName("landuse")[0]

settings.setLayers([ortho, landuse, buildings, roads])
settings.setTerrainMapTheme("3d_base")

Breakdown: Layers without a 3D renderer are drawn onto the terrain rather than as geometry, in the order given, so the list doubles as the drape stack — put the orthophoto first and semi-transparent overlays after it. setTerrainMapTheme is the alternative and often the better one: it names a saved map theme, and the terrain is textured with exactly what that theme shows, which lets you keep a dedicated 3D basemap styling separate from the 2D project without duplicating layers. Creating that theme is covered in creating a map theme.

The texture resolution is a separate consideration from the terrain resolution. A coarse terrain mesh with a high-resolution drape looks sharp and is cheap; a fine terrain mesh with a low-resolution drape looks blurry and is expensive. When a scene looks soft, it is almost always the drape rather than the terrain, and raising the map tile resolution rather than the terrain resolution is the fix.

Adding a 3D canvas in the GUI

Where you do want an interactive view from a plugin, the canvas is created and given the same settings object.

canvas3d = iface.createNewMapCanvas3D("Site view") if hasattr(
    iface, "createNewMapCanvas3D"
) else None

if canvas3d is not None:
    canvas3d.setMapSettings(settings)

Breakdown: The hasattr guard is not paranoia — the interface method for creating a 3D canvas has moved and been renamed across releases, and a plugin that must span versions needs to degrade rather than fail on import. Where it exists, handing it the same Qgs3DMapSettings you built above is all that is needed, and the user can then move the camera themselves. Anything more elaborate, particularly programmatic camera movement, is where the version differences bite hardest.

Making a scene that renders in reasonable time

Three settings account for most of the difference between a scene that turns in real time and one that stutters.

Terrain resolution is the first, and it is quadratic: doubling it quadruples the triangles per tile. Start at 16, raise it only if the terrain visibly facets at the zoom you care about, and remember that a drape at high resolution over coarse terrain usually looks better than the reverse.

Geometry count is the second. Every extruded polygon is triangulated on the CPU and uploaded to the graphics card once, so the cost is paid at scene construction and again whenever the layer changes. Fifty thousand simple footprints is comfortable; the same count with detailed outlines is not, and running a simplification first often removes ninety per cent of the vertices with no visible change.

The extent is the third and the one people forget. A scene extent covering far more than the camera will ever see still builds terrain tiles across all of it. Clipping the extent to the area of interest is free and frequently the largest single win.

area = buildings.extent()
area.grow(200)
settings.setExtent(area)
terrain.setExtent(area)

Breakdown: Growing the extent by a couple of hundred metres gives context around the edge of the data without building terrain across a whole county. Setting it on both the settings and the terrain generator is required — they are separate extents, and a mismatch produces terrain that stops partway across the scene, which looks like a rendering bug and is not.

QGIS version compatibility

The 3D framework arrived in QGIS 3.0 and changed substantially through 3.16. Qgs3DMapSettings, QgsFlatTerrainGenerator and QgsDemTerrainGenerator have been stable since 3.16. Light sources moved from a single light on the settings to a setLightSources list in 3.16; earlier code using setPointLights still appears in examples. QgsLayoutItem3DMap arrived in 3.4 and is the most version-stable route to an image. Offscreen capture helpers changed names in 3.30, which is why this recipe routes around them.

Troubleshooting

  • ImportError: No module named qgis.3d. Use qgis._3d.
  • The scene is empty. No terrain generator, an extent that is wrong, or layers with no 3D renderer.
  • Buildings float above or sink into the terrain. Vertical exaggeration applied to the terrain only, or the layer's height reference is absolute where it should be relative to terrain.
  • Everything is uniformly lit and unreadable. The light is on the camera axis; move it off.
  • The view is extremely slow. Terrain resolution too high, or too much unsimplified geometry.
  • Nothing renders on a server. No OpenGL context; run under a virtual framebuffer.

Conclusion

Build the settings object with a CRS, an extent and an explicit layer list, always give it a terrain generator, attach a 3D renderer to each layer that should have height, and move the light off the camera axis. Then take images through a layout 3D map item rather than through the canvas, and the script will still work after the next upgrade.

Frequently Asked Questions

Can the 3D view show a point cloud? Yes, through QgsPointCloudLayer3DRenderer, and it is one of the better uses of the 3D view. See styling a point cloud renderer for the 2D half.

Does the 3D view respect the temporal range? Yes. A scene with time-aware layers filters exactly as the 2D canvas does, so the same controller drives both.

How do I set the camera position numerically? Through the scene's camera controller, whose API has moved between releases. For anything that must be reproducible, save the viewpoint in a layout 3D map item instead.

Can I export a 3D scene as a model file? QGIS can export a scene to a 3D model format from the GUI in recent releases. There is no stable scripted equivalent, so treat it as an interactive step.