Temporal & 3D Visualization in PyQGIS
Two of the more recent additions to QGIS — the temporal framework and the 3D map view — share a shape. Both attach a set of properties to a layer, both are driven by a controller that sits outside the layer, and both are almost entirely scriptable while being almost entirely undocumented from Python. The result is that people configure them by clicking, then cannot reproduce the configuration in a batch job.
This guide sits inside PyQGIS Cartography & Data Visualization and covers all three: making a layer time-aware, driving an animation from the controller, reading and styling mesh datasets, and building a 3D scene with extruded geometry.
Making a layer time-aware
Temporal properties tell QGIS how to decide whether a feature exists at a given instant. The mode you choose determines which other settings matter.
from qgis.core import QgsProject, Qgis
incidents = QgsProject.instance().mapLayersByName("incidents")[0]
props = incidents.temporalProperties()
props.setMode(Qgis.VectorTemporalMode.FeatureDateTimeInstantFromField)
props.setStartField("reported_at")
props.setIsActive(True)
Breakdown: setIsActive(True) is the switch everyone forgets; without it the properties are configured and ignored. The mode enum moved into the scoped Qgis namespace in 3.30 — on 3.28 and earlier the same value is QgsVectorLayerTemporalProperties.ModeFeatureDateTimeInstantFromField. FeatureDateTimeInstantFromField means each feature is an instant, and QGIS shows it when the current range contains that instant, which is the right model for events. For things that persist — a road closure, a tenancy — FeatureDateTimeStartAndEndFromFields with both a start and an end field is the model, and it is what makes a feature stay on screen across many frames.
The five modes cover distinct data shapes: a fixed range for the whole layer, an instant per feature, a start and end per feature, a start plus a duration field, and "redraw only", which re-renders on every frame without filtering — the mode you want when a data-defined symbol reads the current time itself.
An instant-mode layer with no duration is invisible except in the exact frame containing its timestamp, which on an hourly animation of second-resolution data means every point flashes for one frame. setFixedDuration() widens each feature's window without touching the data, and it is usually what turns a blank animation into a legible one. Setting layer temporal properties works through all five modes and the duration units.
Driving the animation
The canvas holds a temporal controller. Setting a range on it is what actually filters the layers.
from qgis.core import QgsDateTimeRange, QgsInterval
from qgis.PyQt.QtCore import QDateTime, QDate, QTime
controller = iface.mapCanvas().temporalController()
controller.setTemporalExtents(QgsDateTimeRange(
QDateTime(QDate(2026, 6, 1), QTime(0, 0)),
QDateTime(QDate(2026, 6, 8), QTime(0, 0)),
))
controller.setFrameDuration(QgsInterval(1, Qgis.TemporalUnit.Hours))
print(controller.totalFrameCount(), "frames")
controller.setCurrentFrameNumber(0)
Breakdown: The controller derives its frame count from the extent divided by the frame duration, so those two settings together decide how long the animation is — 168 frames for a week at hourly steps. setCurrentFrameNumber() moves to a frame and triggers a canvas refresh, which makes it the right call for stepping through frames in a script rather than play(), which runs on a timer and returns immediately. In a headless script there is no canvas and no controller; there you set the temporal range directly on a QgsMapSettings per frame, which is exactly what exporting temporal animation frames does.
Raster and mesh time, and where they differ
Vector layers are the common case, but the temporal framework covers rasters and meshes too, and each has its own properties class with its own vocabulary.
A raster layer's temporal properties are simpler than a vector's: the usual mode is a fixed range for the whole layer, which is how a stack of dated satellite scenes becomes an animation. Each scene is one layer with one range, and the controller switching between frames switches which layers are visible.
scene = QgsProject.instance().mapLayersByName("s2_2026_06_04")[0]
raster_props = scene.temporalProperties()
raster_props.setMode(Qgis.RasterTemporalMode.FixedTemporalRange)
raster_props.setFixedTemporalRange(QgsDateTimeRange(
QDateTime(QDate(2026, 6, 4), QTime(10, 30)),
QDateTime(QDate(2026, 6, 4), QTime(10, 40)),
))
raster_props.setIsActive(True)
Breakdown: Giving each scene a narrow range centred on its acquisition time is what stops two scenes being visible at once at a frame boundary. Where the imagery is a single multi-band file with a band per date, the mode is instead the band-based one and QGIS picks the band from the frame — which is far more efficient than fifty layers, and depends on the provider exposing per-band time, which not all do.
A mesh layer's temporal properties are different again, because the time lives inside the file as a sequence of datasets. The properties you set are the reference time — the absolute instant that the file's relative hours count from — and how to handle times between datasets. Getting the reference time wrong shifts an entire model run by whatever the offset is, and because the shape of the animation is unchanged it is very hard to spot.
Mesh layers: the other time-aware data
A mesh layer is an unstructured grid — triangles or quads — carrying one or more dataset groups, each of which holds a value per vertex or per face for one or more times. Hydrodynamic models, wind fields and wave models all arrive this way, and QGIS reads them through MDAL.
from qgis.core import QgsMeshLayer, QgsMeshDatasetIndex
mesh = QgsMeshLayer("/data/model/flood.nc", "flood", "mdal")
print(mesh.isValid(), mesh.datasetGroupCount())
for group in range(mesh.datasetGroupCount()):
meta = mesh.datasetGroupMetadata(QgsMeshDatasetIndex(group, 0))
kind = "vector" if meta.isVector() else "scalar"
print(group, meta.name(), kind, mesh.datasetCount(QgsMeshDatasetIndex(group, 0)), "steps")
Breakdown: QgsMeshDatasetIndex(group, dataset) is the coordinate pair that addresses everything in a mesh — the group is which variable, the dataset is which timestep. A group with one dataset is static (bed elevation, roughness); a group with many is a time series. Scalar groups carry a single value and render as a colour surface; vector groups carry two components and render as arrows or streamlines. Enumerating groups before doing anything else is essential, because the group indices are file order and the names vary between models.
Sampling a value at a point is direct:
from qgis.core import QgsPointXY
value = mesh.datasetValue(QgsMeshDatasetIndex(0, 12), QgsPointXY(445120, 187330))
print(value.scalar())
Breakdown: datasetValue interpolates within the containing face, so it works anywhere inside the mesh and returns a NaN outside it — check with math.isnan() rather than comparing against a nodata number. For a vector group, .x() and .y() give the components and .scalar() gives the magnitude, which is the sensible thing to plot. Sampling a whole time series at one point is a loop over the dataset index, and it is the fastest way to turn a model output into a chart. Loading and styling a mesh layer covers the renderer settings.
The 3D map scene
The 3D view is configured through Qgs3DMapSettings, which is an entirely separate object from the 2D map settings and shares almost nothing with it beyond the CRS and the layer list.
from qgis.core import QgsProject
from qgis._3d import Qgs3DMapSettings, QgsFlatTerrainGenerator
project = QgsProject.instance()
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: The import is qgis._3d — the underscore is deliberate, because 3d is not a legal Python identifier, and it is the single most common reason a 3D snippet copied from a C++ example fails to import. A terrain generator is mandatory; a flat one is the right choice when you have no DEM, and QgsDemTerrainGenerator takes a raster layer when you do. The extent matters more here than in 2D: it defines the world the scene is built in, and a scene whose extent is the whole planet renders one pixel of building.
Height comes from a 3D renderer attached to the layer:
from qgis._3d import QgsVectorLayer3DRenderer, QgsPolygon3DSymbol
symbol = QgsPolygon3DSymbol()
symbol.setExtrusionHeight(12.0)
renderer = QgsVectorLayer3DRenderer(symbol)
buildings.setRenderer3D(renderer)
Breakdown: setRenderer3D is independent of setRenderer — a layer can have a 2D style and a 3D style that share nothing, and changing one does not touch the other. A constant extrusion height is rarely what you want; binding it to an attribute through the symbol's data-defined properties is what makes a city look like a city, and that is the subject of extruding buildings in 3D.
Styling a mesh
Mesh rendering is configured through a settings object that you take, modify and put back — the same read-modify-write pattern as several other parts of the QGIS API.
from qgis.core import QgsColorRampShader, QgsStyle
settings = mesh.rendererSettings()
settings.setActiveScalarDatasetGroup(0)
scalar = settings.scalarSettings(0)
shader = scalar.colorRampShader()
shader.setColorRampType(QgsColorRampShader.Interpolated)
shader.setSourceColorRamp(QgsStyle.defaultStyle().colorRamp("Blues"))
shader.classifyColorRamp(classes=10, band=-1)
scalar.setColorRampShader(shader)
settings.setScalarSettings(0, scalar)
mesh.setRendererSettings(settings)
mesh.triggerRepaint()
Breakdown: rendererSettings() returns a copy, so modifying it does nothing until setRendererSettings() puts it back — a mistake that produces code which looks right and changes nothing. The same applies one level down: scalarSettings(group) gives a copy that must be returned with setScalarSettings(group, ...). setActiveScalarDatasetGroup chooses which variable is drawn as the colour surface; there is a matching setActiveVectorDatasetGroup for arrows, and a mesh can show one of each at once, which is how a flood map shows depth as colour and velocity as arrows over it.
The classification range is the other decision. Classifying against a single timestep produces a ramp that saturates as the flood grows; classifying against the whole run's minimum and maximum keeps the colours comparable across frames, which is what an animation needs. Neither is the default, so it is worth setting explicitly.
Lighting and materials
The part of a 3D scene that decides whether it reads as a model or as a mess is the lighting, and it is entirely scriptable.
from qgis.core import QgsPhongMaterialSettings
from qgis.PyQt.QtGui import QColor
material = QgsPhongMaterialSettings()
material.setAmbient(QColor(60, 60, 60))
material.setDiffuse(QColor(200, 195, 185))
material.setSpecular(QColor(20, 20, 20))
symbol.setMaterialSettings(material)
Breakdown: The three colours do different jobs. Ambient is the light a surface receives regardless of orientation, and raising it flattens the scene while lowering it makes shadowed faces black. Diffuse is the surface's apparent colour under direct light and is the one people mean by "colour". Specular is the highlight; leaving it near-black is right for masonry and concrete, and raising it makes everything look wet. A default material with high specular is why a first attempt at a city model often looks like it is made of plastic.
Directional lights are set on the scene rather than on the symbol, and the single most useful change is to move the default light off the camera axis. A light coming from directly behind the viewer removes every shadow that would tell them one building is in front of another, which is exactly the depth information a 3D view exists to supply.
Combining the two
Temporal and 3D compose: a 3D scene honours the temporal range in the same way the 2D canvas does, so an animated flood surface over extruded buildings is a matter of setting both up and stepping the frames. What makes it awkward is that the 3D view has no direct equivalent of the map canvas's export-to-image call, and the offscreen rendering helpers have changed shape across releases. The reliable pattern is to configure everything from Python, render through a layout containing a 3D map item, and export that — which also gives you titles, a legend and a north arrow for free, as covered in automated map layout generation.
What to reach for, and when
Three of these tools overlap enough that choosing between them is worth a paragraph.
If the data is a set of dated features in one layer, temporal properties on that layer are the answer, and the whole animation is one layer plus a controller. If it is a set of dated files, the choice is between many layers each with a fixed range — simple, and slow past a few dozen — and one multi-band raster with band-based time, which is fast and requires the data to be assembled that way first.
If the data came out of a hydraulic or atmospheric model, it is almost certainly a mesh, and fighting it into a raster stack loses the unstructured grid that the model actually computed on. Read it as a mesh.
And if the question is about height rather than time, the 3D view is worth the setup only when the height genuinely carries information — a city's building heights, a geological model, a flood surface over terrain. Extruding administrative polygons by a statistic produces something that looks impressive and is harder to read than a choropleth, because the viewer cannot compare heights across a perspective view.
Performance and what makes a scene slow
Both frameworks have a characteristic performance trap, and both are avoidable once you know where they are.
For temporal animation, the cost is that every frame is a full re-render of every visible layer, with the temporal filter applied as an extra condition on the feature request. On a layer with a hundred thousand features and no index on the time field, that condition is evaluated per feature per frame — so a 200-frame animation evaluates it twenty million times. Where the data is in a database, an index on the timestamp column removes the problem entirely. Where it is in a file, converting to GeoPackage and indexing the field does the same. Pre-filtering the layer to the animation's overall range, so that frames only ever consider features that could appear, is the other easy win.
For 3D, the cost is geometry. Every extruded polygon becomes a mesh of triangles built on the CPU and uploaded to the graphics card, and a scene is slow in proportion to how many there are. Fifty thousand simple building footprints is comfortable; the same footprints with their true outlines, each carrying a hundred vertices, is not. Simplifying the geometry before extruding — as covered in simplifying geometry — often makes a scene ten times faster with no visible difference at the zoom levels anyone will use.
The two combine badly if you let them: an animated 3D scene rebuilds geometry per frame unless the geometry itself is static. Where only a surface changes over time, keep the buildings static and animate the surface alone.
Key takeaways
- Temporal properties describe the layer; the controller holds the current instant. Both are needed, and
setIsActive(True)is easy to forget. - Instant-mode features need a duration or they flash for a single frame.
- The mode enums moved into the scoped
Qgisnamespace in 3.30; the old class-level names still work. - Address mesh data with
QgsMeshDatasetIndex(group, dataset)— group is the variable, dataset is the timestep. datasetValue()interpolates inside a face and returns NaN outside the mesh.- Import 3D classes from
qgis._3d, with the underscore. - A 3D scene needs a terrain generator, an extent and a camera; none is inherited from the 2D canvas.
- A layer's 2D and 3D renderers are independent objects.
Frequently Asked Questions
Why does my temporal layer disappear entirely? Either the controller's range does not overlap the data, or the mode is instant and the frame duration is far shorter than the gaps between events. Print the layer's data range and the controller's extents side by side — they are almost never what you assumed.
Can I animate without a GUI?
Yes. There is no controller headless, but QgsMapSettings.setTemporalRange() accepts a range per render, so a loop over frames rendering to PNG works exactly as it does interactively. That is the approach in exporting temporal animation frames.
Which mesh formats does QGIS read?
Whatever MDAL supports, which covers NetCDF, GRIB, UGRID, XMDF, 2DM, and several hydraulic-model native formats. QgsMeshLayer(path, name, "mdal") is always the constructor; the driver is chosen from the file.
Is the 3D view available in a headless build? It needs an OpenGL context, so a truly headless server generally cannot render it. A virtual framebuffer works in practice for batch rendering on Linux.
How do I set the camera position from Python? Through the scene's camera controller, which is reachable from the 3D map canvas rather than from the settings. Because that path has changed between releases, a layout 3D map item — whose view is set declaratively — is the more portable way to script a fixed viewpoint.
Do temporal properties survive saving the project? Yes, they are written into the project file along with the styling, so a configured layer reopens time-aware. The controller's current frame does not persist, which is usually what you want.
Related
- PyQGIS Cartography & Data Visualization — the section this guide belongs to
- Set Layer Temporal Properties in PyQGIS
- Animate Layers with the Temporal Controller
- Export Temporal Animation Frames in PyQGIS
- Load and Style a Mesh Layer in PyQGIS
- Configure a 3D Map View in PyQGIS
- Extrude Buildings in 3D with PyQGIS
- Map Canvas & Image Export in PyQGIS