Matplotlib 3D Plotting — Deep Dive

Matplotlib’s 3D plotting uses a painter’s algorithm to render 3D geometry onto a 2D canvas. Understanding this rendering model, its limitations, and its extension points lets you create effective 3D visualizations while knowing when to reach for dedicated 3D tools.

The Projection Pipeline

Axes3D transforms 3D world coordinates through several stages:

  1. World coordinates — your data’s (x, y, z) values
  2. View transformation — rotation based on elev and azim using a rotation matrix
  3. Projection — perspective or orthographic projection onto a 2D plane
  4. Axes scaling — fitting projected coordinates into the axes bounding box
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import numpy as np

fig = plt.figure(figsize=(12, 5))

# Perspective projection (default)
ax1 = fig.add_subplot(121, projection='3d')
# Orthographic-like by setting large focal length
ax2 = fig.add_subplot(122, projection='3d')

# Generate a parametric surface — torus
u = np.linspace(0, 2 * np.pi, 100)
v = np.linspace(0, 2 * np.pi, 100)
u, v = np.meshgrid(u, v)
R, r = 3, 1
x = (R + r * np.cos(v)) * np.cos(u)
y = (R + r * np.cos(v)) * np.sin(u)
z = r * np.sin(v)

for ax, title in [(ax1, 'Default View'), (ax2, 'Top-Down')]:
    ax.plot_surface(x, y, z, cmap='viridis', alpha=0.8,
                    rstride=2, cstride=2, edgecolor='none')
    ax.set_title(title)

ax1.view_init(elev=30, azim=45)
ax2.view_init(elev=80, azim=0)
plt.tight_layout()

The rstride and cstride parameters control surface mesh density. Higher values skip more grid lines, reducing rendering time at the cost of smoothness. For a 100×100 grid, rstride=2, cstride=2 renders 2,500 polygons instead of 10,000.

Surface Plot Variations

Beyond basic plot_surface(), Matplotlib supports several surface rendering modes:

fig = plt.figure(figsize=(16, 5))

# Mathematical surface
x = np.linspace(-3, 3, 80)
y = np.linspace(-3, 3, 80)
X, Y = np.meshgrid(x, y)
Z = np.sin(np.sqrt(X**2 + Y**2)) * np.exp(-0.1 * (X**2 + Y**2))

# Solid surface with colormap
ax1 = fig.add_subplot(131, projection='3d')
surf = ax1.plot_surface(X, Y, Z, cmap='coolwarm', linewidth=0,
                        antialiased=True)
fig.colorbar(surf, ax=ax1, shrink=0.5, label='z-value')

# Wireframe
ax2 = fig.add_subplot(132, projection='3d')
ax2.plot_wireframe(X, Y, Z, rstride=4, cstride=4, color='steelblue',
                   linewidth=0.5)

# Surface with projected contours
ax3 = fig.add_subplot(133, projection='3d')
ax3.plot_surface(X, Y, Z, cmap='viridis', alpha=0.7)
ax3.contour(X, Y, Z, zdir='z', offset=Z.min() - 0.3,
            cmap='viridis', levels=15)

The projected contour trick — plotting contour() with zdir='z' and offset below the surface — creates a “shadow” that helps readers understand the surface shape without rotating.

Irregular Data and Triangulation

Real-world 3D data is rarely on a regular grid. For scattered (x, y, z) measurements, use triangulated surfaces:

from matplotlib.tri import Triangulation

# Irregular sensor measurements
np.random.seed(42)
n_points = 200
x = np.random.uniform(-5, 5, n_points)
y = np.random.uniform(-5, 5, n_points)
z = np.sin(np.sqrt(x**2 + y**2)) + np.random.normal(0, 0.05, n_points)

# Delaunay triangulation
tri = Triangulation(x, y)

fig = plt.figure(figsize=(10, 7))
ax = fig.add_subplot(111, projection='3d')
ax.plot_trisurf(tri, z, cmap='terrain', edgecolor='none', alpha=0.9)
ax.scatter(x, y, z, c='red', s=5, alpha=0.3)  # Show original points
ax.set_title('Triangulated Surface from Irregular Measurements')

plot_trisurf() creates a Delaunay triangulation of the x-y points and stretches triangular faces between them. For better control, create the Triangulation object manually and pass a mask to exclude long-edge triangles at the convex hull boundary.

Animated 3D Rotations

Rotating 3D views make excellent presentation material:

import matplotlib.animation as animation

fig = plt.figure(figsize=(8, 6))
ax = fig.add_subplot(111, projection='3d')

# Create surface
X, Y = np.meshgrid(np.linspace(-3, 3, 60), np.linspace(-3, 3, 60))
Z = np.sin(X) * np.cos(Y)
ax.plot_surface(X, Y, Z, cmap='plasma', alpha=0.85, edgecolor='none')

def rotate(frame):
    ax.view_init(elev=20 + 10 * np.sin(frame * 0.02),
                 azim=frame * 0.5)
    return []

anim = animation.FuncAnimation(fig, rotate, frames=720,
                                interval=33, blit=False)
anim.save('rotation.mp4', writer='ffmpeg', fps=30, dpi=150)

Note blit=False — 3D view changes affect the entire axes, so blitting doesn’t apply. The sinusoidal elevation change adds a “nodding” effect that reveals both the top and sides of the surface.

Combining 3D and 2D Panels

Dashboard-style figures with 3D and 2D panels side by side:

fig = plt.figure(figsize=(14, 6))

# 3D surface
ax3d = fig.add_subplot(121, projection='3d')
surf = ax3d.plot_surface(X, Y, Z, cmap='viridis', edgecolor='none')
ax3d.set_xlabel('X')
ax3d.set_ylabel('Y')
ax3d.set_zlabel('Z')

# 2D heatmap of the same data
ax2d = fig.add_subplot(122)
im = ax2d.pcolormesh(X, Y, Z, cmap='viridis', shading='auto')
ax2d.contour(X, Y, Z, colors='white', linewidths=0.5, levels=10)
ax2d.set_xlabel('X')
ax2d.set_ylabel('Y')
ax2d.set_aspect('equal')
fig.colorbar(im, ax=ax2d, label='Z value')

This side-by-side pattern — 3D surface + 2D heatmap — is effective in publications because the 3D view gives intuition while the 2D heatmap enables precise reading.

Z-Order and Occlusion Issues

Matplotlib’s 3D renderer uses a painter’s algorithm: it sorts polygons by depth and draws back-to-front. This fails when polygons intersect or have complex overlapping arrangements. Common symptoms:

  • Parts of a surface appear in front of other objects that should be closer
  • Overlapping 3D objects render incorrectly
  • Transparent surfaces show artifacts at polygon boundaries
# Workaround: manual z-order via computed_zorder
ax = fig.add_subplot(111, projection='3d', computed_zorder=False)

# Two intersecting planes — will have z-order issues
X, Y = np.meshgrid(np.linspace(-2, 2, 20), np.linspace(-2, 2, 20))
Z1 = X * 0.5
Z2 = -X * 0.5 + 0.5

# Setting zorder manually can help in simple cases
ax.plot_surface(X, Y, Z1, alpha=0.5, color='blue', zorder=1)
ax.plot_surface(X, Y, Z2, alpha=0.5, color='red', zorder=2)

For complex scenes with many intersecting objects, Matplotlib’s 3D is fundamentally limited. Switch to PyVista or Plotly, which use actual 3D rendering engines with proper depth buffering.

Performance Optimization

Matplotlib’s 3D rendering is CPU-bound (no GPU acceleration). Performance degrades with polygon count:

Grid SizePolygonsRender Time (approx)
50×502,500~50ms
100×10010,000~200ms
200×20040,000~1s
500×500250,000~10s

Optimization strategies:

Stride parameters: rstride=5, cstride=5 on a 500×500 grid renders only 10,000 polygons instead of 250,000.

Reduce antialiasing: antialiased=False skips edge smoothing, saving rendering time.

Rasterize for vector output: When saving to PDF/SVG, 3D surfaces generate enormous files (one path per polygon). Use ax.set_rasterized(True) to render the 3D content as a bitmap within the vector file.

# For PDF output with reasonable file size
ax.plot_surface(X, Y, Z, cmap='viridis', rasterized=True)
fig.savefig('surface.pdf', dpi=300)

Pre-downsample data: If your surface data comes from a 1000×1000 simulation grid, downsample to 100×100 with data[::10, ::10] before plotting.

When to Use Alternatives

NeedBest Tool
Interactive 3D rotation in browserPlotly
Large-scale scientific 3D (millions of points)PyVista
Volume rendering (CT scans, fluid dynamics)Mayavi or PyVista
3D molecular visualizationNGLView, py3Dmol
3D with VR/AR exportOpen3D, VTK
Quick 3D surface for a paperMatplotlib (good enough)

Matplotlib’s 3D is best for “good enough” figures in publications and quick exploration. For anything requiring true depth testing, GPU rendering, or complex 3D scenes, use a dedicated library.

Exporting 3D Figures

# High-quality PNG
fig.savefig('surface.png', dpi=300, bbox_inches='tight',
            facecolor='white', transparent=False)

# Vector with rasterized 3D content
ax.set_rasterized(True)
fig.savefig('surface.pdf', dpi=300, bbox_inches='tight')

# Multiple view angles for a figure panel
for angle in [0, 45, 90, 135]:
    ax.view_init(elev=25, azim=angle)
    fig.savefig(f'surface_azim{angle}.png', dpi=200)

One thing to remember: Matplotlib’s 3D plotting is a 2D renderer simulating 3D through projection and painter’s algorithm sorting — it’s perfect for publication surfaces and quick exploration, but understanding its occlusion limits and performance ceiling tells you exactly when to reach for PyVista, Plotly, or Mayavi.

pythonmatplotlib3ddata-visualization

See Also