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.

Properties on the layer, state in the controllerEvery time-aware or three-dimensional capability in QGIS splits the same way. The layer carries properties describing what field holds its time or how tall its features are. A controller or a settings object holds the current state: which instant is being shown, or where the camera is. A renderer combines the two to produce the picture.The same split in both frameworkslayer propertieswhich field holds the timehow tall the features aresaved in the projectcontroller / settingswhich instant is shownwhere the camera isthe current staterenderera frame, or a sceneon canvas or to a filewhat you exportwhere each thing livestemporal → layer.temporalProperties() · canvas.temporalController()3D → layer.renderer3D() · Qgs3DMapSettingsa layer with temporal properties set but no controller range shows nothingand that is the single most common confusion in this area

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.

Frame duration and feature duration are different knobsAlong a timeline, three events occur at scattered instants. With no feature duration each appears in exactly one frame and the animation flickers. Giving each event a duration of several hours makes it visible across several consecutive frames, producing an animation in which patterns are readable.One flashes; the other readsno feature durationvisible in one frame each — the map blinksfeature duration 4 hourseach event persists for four frames — movement becomes visiblethe duration is a display choice — say so if the map implies persistence

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.

A scene is terrain plus renderers plus a cameraThe terrain generator produces the ground surface, either flat or from a digital elevation model. Each layer contributes geometry through its own 3D renderer, with heights either constant or read from an attribute. The camera position and the lighting then determine the image, and neither is inherited from the 2D canvas.Nothing here is inherited from the 2D canvasterrain generatorflat, or from a DEMper-layer 3D rendererextrusion from an attributecamera and lightsset explicitly, every timeQgs3DMapSettingsholds all three, plus the CRS and the extenta scene with no terrain generator renders nothing at alland reports no error while doing it

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 Qgis namespace 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.