PyVista 3D Rendering¶
Overview¶
The PyVista 3D visualization module provides GPU-accelerated volume rendering, interactive slice planes, and true voxel cell-block displays using the VTK rendering engine. While the Plotly-based 3D module (covered in 3D Volume Rendering) produces interactive browser-based figures, the PyVista module renders in a native desktop window with hardware-accelerated graphics. This makes it better suited for large grids, detailed cell-level inspection, and workflows that need interactive manipulation tools like draggable slice planes, box cropping, and real-time threshold adjustment.
PyVista is an optional dependency. Install it with:
If PyVista is not installed, the bores.visualization.pyvista3d module is silently unavailable and all other visualization modules continue to work normally.
The module mirrors the Plotly 3D API closely. Both modules provide a DataVisualizer class with make_plot() and animate() methods that accept the same source, property, plot_type, and slicing parameters. The main differences are that the PyVista module returns pv.Plotter objects instead of Plotly Figure objects, supports an additional CELL_BLOCKS plot type, and provides interactive widgets for adjusting rendering parameters without rerunning your code.
Choose PyVista when you need to inspect individual cells, use interactive slice planes, render grids above 100,000 cells smoothly, or export high-resolution screenshots from a native window. Choose Plotly when you need browser-based interactivity, HTML export for sharing, or Jupyter notebook embedding without a display server.
Data Sources¶
The PyVista 3D visualizer accepts the same three input types as the Plotly 3D module:
ModelState (recommended for simulation results):
from bores.visualization.pyvista3d import DataVisualizer
viz = DataVisualizer()
states = list(bores.run(model, config))
plotter = viz.make_plot(states[-1], property="pressure")
plotter.show()
When you pass a ModelState, the visualizer uses the PropertyRegistry to look up metadata for the named property. It also extracts the cell dimensions and depth grid from the model to compute physical coordinates. The property parameter is a registry key such as "pressure", "oil_saturation", "water_saturation", "gas_saturation", "permeability", or any other registered property.
ReservoirModel (for initial conditions):
This works the same as ModelState but uses the model directly. Useful for inspecting the static reservoir description before running a simulation.
Raw 3D numpy array (for custom data):
import numpy as np
custom_data = np.random.rand(20, 15, 5)
plotter = viz.make_plot(custom_data)
plotter.show()
When you pass a raw array, the visualizer creates generic metadata unless you provide a property name that matches a registry entry. Physical coordinates are not available for raw arrays, so axis labels show cell indices instead of distances in feet.
Creating Plots¶
DataVisualizer¶
The DataVisualizer class is the main entry point for PyVista 3D rendering. Create one with optional configuration:
from bores.visualization.pyvista3d import DataVisualizer, PlotConfig, PlotType
from bores.visualization.base import ColorScheme
# Default configuration
viz = DataVisualizer()
# Custom configuration
viz = DataVisualizer(config=PlotConfig(
width=1400,
height=1000,
plot_type=PlotType.CELL_BLOCKS,
color_scheme=ColorScheme.PLASMA,
opacity=0.7,
show_colorbar=True,
show_axes=True,
show_cell_outlines=True,
enable_interactive=True,
))
A global instance viz is also available for quick use:
from bores.visualization.pyvista3d import viz
plotter = viz.make_plot(states[-1], "pressure")
plotter.show()
make_plot¶
The make_plot() method creates a single 3D visualization and returns a pv.Plotter:
plotter = viz.make_plot(
source=states[-1],
property="pressure",
plot_type="cell_blocks",
title="Reservoir Pressure at Day 365",
width=1200,
height=900,
)
plotter.show()
The plot_type parameter accepts either a PlotType enum value or a string. Available types are "volume", "isosurface", "scatter_3d", and "cell_blocks". Cell blocks is the default and is unique to the PyVista module.
The source parameter accepts a ModelState, ReservoirModel, or raw 3D numpy array. When using a ModelState or ReservoirModel, the property parameter is required and must match a key in the PropertyRegistry. When using a raw array, property is optional.
Because make_plot returns a pv.Plotter, you interact with it differently than a Plotly figure:
plotter = viz.make_plot(states[-1], "oil_saturation")
# Display interactively
plotter.show()
# Save a screenshot
plotter.screenshot("oil_saturation.png")
# Save as vector graphic
plotter.save_graphic("oil_saturation.svg")
animate¶
The animate() method creates a sequence of frames showing a property changing over time:
states = list(bores.run(model, config))
plotters = viz.animate(
sequence=states,
property="oil_saturation",
plot_type="cell_blocks",
frame_duration=200,
step_size=5,
title="Oil Saturation Evolution",
save="saturation_animation.gif",
)
The sequence parameter accepts a list of ModelState objects, ReservoirModel objects, or raw 3D arrays. The frame_duration parameter sets how many milliseconds each frame is displayed. The step_size parameter lets you skip frames for performance (1 means every frame, 5 means every fifth frame).
The save parameter accepts a file path string or an exporter object. The format is inferred from the file extension:
# GIF animation
viz.animate(states, "pressure", save="pressure.gif")
# MP4 video
viz.animate(states, "pressure", save="pressure.mp4")
# WebP animation
viz.animate(states, "pressure", save="pressure.webp")
You can also pass an exporter object directly for more control:
from bores.visualization.utils import GifExporter, Mp4Exporter
# GIF with infinite loop
viz.animate(states, "pressure", save=GifExporter("output.gif", loop=0))
# MP4 with custom quality
viz.animate(states, "pressure", save=Mp4Exporter("output.mp4", codec="libx264", quality=8))
Plot Types¶
Cell Blocks (Default)¶
Cell block rendering displays each reservoir cell as a solid 3D voxel, creating a faithful representation of the grid geometry. This is the default plot type and the most useful for cell-level inspection. Each cell is colored according to its property value and rendered as a separate block, making individual cells visually distinct when cell outlines are enabled.
plotter = viz.make_plot(
states[-1],
property="permeability",
plot_type="cell_blocks",
)
plotter.show()
Cell blocks are the recommended plot type for most reservoir visualization tasks. They give an accurate picture of the grid structure, especially for models with variable cell sizes or non-uniform layering. Unlike volume rendering (which interpolates between cells), cell blocks show the actual discrete values in each cell.
You can control subsampling for large grids and threshold filtering to hide low-value cells:
plotter = viz.make_plot(
states[-1],
"oil_saturation",
plot_type="cell_blocks",
subsampling_factor=2, # Sample every 2nd cell per axis
threshold_percentile=10.0, # Hide cells below 10th percentile
)
Volume Rendering¶
Volume rendering displays a continuous 3D scalar field using GPU-accelerated ray casting. Each cell is colored and its opacity is modulated by the data value, allowing you to see through low-value regions to the high-value interior structure.
The volume renderer automatically coarsens grids that exceed the configured cell limit (BORES_MAX_VOLUME_CELLS_3D) to maintain interactive frame rates. You can control the rendering backend with the volume_mapper keyword:
plotter = viz.make_plot(
states[-1],
"pressure",
plot_type="volume",
volume_mapper="smart", # "smart" (auto), "gpu" (force GPU), "fixed_point" (CPU)
shade=True, # Enable surface shading
)
Volume rendering works well for smooth, continuous properties like pressure and temperature. For properties with sharp boundaries (like saturation fronts), cell blocks or isosurfaces are usually more informative.
Isosurface¶
Isosurface plots extract 3D surfaces at specific value thresholds within the data using VTK Marching Cubes. Each surface connects all points where the property equals a specific value.
plotter = viz.make_plot(
states[-1],
property="oil_saturation",
plot_type="isosurface",
surface_count=5,
)
plotter.show()
Isosurfaces are particularly useful for visualizing flood fronts (where water saturation crosses a threshold), gas-oil contacts, and pressure isobars. You can control the isosurface range:
plotter = viz.make_plot(
states[-1],
"water_saturation",
plot_type="isosurface",
isomin=0.3,
isomax=0.9,
surface_count=4,
)
3D Scatter¶
Scatter plots display cells above a threshold as individual points in 3D space. Each point is positioned at the cell center and colored according to the property value. This is the lightest-weight plot type, making it a good choice for quick exploration of very large grids.
plotter = viz.make_plot(
states[-1],
property="gas_saturation",
plot_type="scatter_3d",
threshold=0.05, # Only show cells with Sg > 0.05
sample_rate=0.5, # Render 50% of qualifying cells
point_size=5.0,
)
plotter.show()
Interactive Features¶
When enable_interactive is True in the PlotConfig (the default), the PyVista viewer adds interactive widgets and keyboard shortcuts that let you manipulate the visualization in real time without rerunning your code. These tools are what distinguish PyVista rendering from Plotly: you can slice, crop, adjust thresholds, and change viewing angles interactively in the native window.
Sliders¶
A panel of sliders appears on the left side of the rendering window:
| Slider | Range | Effect |
|---|---|---|
| Opacity | 0.0 to 1.0 | Controls overall opacity of the rendered data |
| Threshold | Data range | Hides cells below the threshold value (grid meshes only) |
| Z-scale | 0.1 to 20.0 | Vertical exaggeration factor for thin reservoirs |
| C-min | Data range | Lower bound of the colormap (grid meshes only) |
| C-max | Data range | Upper bound of the colormap (grid meshes only) |
The threshold and colormap sliders only appear for grid-based plot types (cell blocks, volume). The Z-scale slider is useful for reservoirs that are much wider than they are thick. Setting it to 5 or 10 makes vertical structure easier to see.
Keyboard Shortcuts¶
Press h at any time to display a help overlay listing all available shortcuts. The shortcuts work in the interactive rendering window:
| Key | Action |
|---|---|
0 |
Reset camera to default position |
s |
Save screenshot to file |
a |
Toggle axes visibility |
g |
Toggle grid and cell edge visibility |
k |
Toggle colorbar visibility |
1 |
Add or remove X-axis slice plane |
2 |
Add or remove Y-axis slice plane |
3 |
Add or remove Z-axis slice plane |
b |
Toggle box-crop widget |
v |
Cycle through view presets (isometric, top, front, right) |
h |
Show or hide help overlay |
Slice Planes¶
The slice plane shortcuts (1, 2, 3) add draggable cutting planes to the scene. Each plane has an orange handle that you can grab and drag to move the slice position interactively. This is one of the most powerful features for inspecting internal reservoir structure: you can cut through the model at any position and see the property values on the exposed face.
Slice planes are additive. Press 1 once to add an X-slice, press 2 to add a Y-slice. Press the same key again to remove that slice. You can have all three slice planes active simultaneously.
Box Cropping¶
Press b to activate a 3D bounding box widget. The box has draggable faces that let you crop the model to any rectangular subvolume. This is useful for isolating a region of interest (for example, the near-well area) without modifying your data or rerunning the visualization.
Data Slicing¶
For large 3D grids, you can slice the data programmatically before rendering. The make_plot() method supports slicing along any combination of the x, y, and z axes, identical to the Plotly 3D module:
# Single layer at z-index 2
plotter = viz.make_plot(states[-1], "pressure", z_slice=2)
# Range of cells in x-direction
plotter = viz.make_plot(states[-1], "pressure", x_slice=(10, 20))
# Corner section
plotter = viz.make_plot(
states[-1],
"oil_saturation",
x_slice=(0, 25),
y_slice=(0, 25),
z_slice=(0, 10),
)
# Every 2nd cell in x using a slice object
plotter = viz.make_plot(states[-1], "pressure", x_slice=slice(0, 50, 2))
Programmatic slicing is useful when you know the region of interest in advance. For exploratory work, the interactive slice planes (press 1, 2, 3) are more convenient because you can move them in real time.
Well Visualization¶
When working with ModelState data, you can overlay well trajectories on the 3D plot. Wells are rendered as colored tubes with surface markers and well name labels:
The well visualization uses color coding to distinguish well types:
- Injection wells: red (default
#ff4444) - Production wells: green (default
#44dd44) - Shut-in wells: gray (default
#888888)
Each well is rendered with three components: a casing segment from the surface to the first perforation (dotted gray), colored perforation intervals, and a directional surface marker (arrow pointing down for injectors, up for producers).
You can customize the well appearance through keyword arguments:
plotter = viz.make_plot(
states[-1],
"oil_saturation",
show_wells=True,
injection_color="#ff6b6b",
production_color="#51cf66",
shut_in_color="#aaaaaa",
wellbore_width=11.0,
show_surface_marker=True,
show_well_labels=True,
)
| Parameter | Default | Description |
|---|---|---|
show_wellbore |
True |
Show wellbore trajectory as colored tube |
show_surface_marker |
True |
Show directional arrow at surface location |
show_well_labels |
True |
Show well name labels |
injection_color |
"#ff4444" |
Color for injection wells |
production_color |
"#44dd44" |
Color for production wells |
shut_in_color |
"#888888" |
Color for shut-in wells |
wellbore_width |
11.0 | Width of wellbore line in pixels |
surface_marker_size |
2.4 | Size scaling factor for surface markers |
Well visualization only works when source is a ModelState with active wells. When you pass a raw array or a ReservoirModel, the show_wells parameter is ignored.
Configuration Reference¶
The PlotConfig class for PyVista 3D plots extends the base configuration with rendering-engine-specific options:
General Settings¶
| Parameter | Default | Description |
|---|---|---|
width |
1200 | Window width in pixels |
height |
960 | Window height in pixels |
plot_type |
CELL_BLOCKS |
Default plot type |
color_scheme |
VIRIDIS |
Default color scheme |
opacity |
0.85 | Default opacity (0.0 to 1.0) |
show_colorbar |
True |
Display color scale bar |
show_axes |
True |
Display 3D axis labels and grid |
title |
"" |
Default plot title |
show_labels |
True |
Global toggle for labels |
aspect_mode |
None |
Aspect mode: "cube", "data", or "auto" |
Cell Display¶
| Parameter | Default | Description |
|---|---|---|
show_cell_outlines |
True |
Show wireframe edges around cells |
cell_outline_color |
"#404040" |
Color for cell outline wireframes |
cell_outline_width |
1.0 | Width of cell outline lines |
show_edges |
True |
Show edges on rendered meshes |
n_colors |
256 | Number of discrete colors in the colormap |
Rendering¶
| Parameter | Default | Description |
|---|---|---|
background_color |
"white" |
Background color of the rendering window |
off_screen |
False |
Render without displaying a window (for batch export) |
smooth_shading |
True |
Enable smooth surface shading |
notebook |
False |
Use trame backend for Jupyter notebooks |
enable_picking |
True |
Enable cell picking (click to inspect values) |
Interactivity¶
| Parameter | Default | Description |
|---|---|---|
enable_interactive |
True |
Enable sliders, keyboard shortcuts, and widgets |
use_opacity_scaling |
False |
Data-driven opacity modulation |
scalar_bar_args |
None |
Custom arguments for the colorbar (pyvista scalar bar API) |
Off-Screen Rendering¶
For batch processing or CI pipelines where no display is available, set off_screen=True:
viz = DataVisualizer(config=PlotConfig(
off_screen=True,
width=1920,
height=1080,
))
plotter = viz.make_plot(states[-1], "pressure")
plotter.screenshot("pressure_highres.png")
Jupyter Notebooks¶
To use PyVista inside Jupyter notebooks, set notebook=True. This uses the trame backend to render the 3D scene inline:
viz = DataVisualizer(config=PlotConfig(notebook=True))
plotter = viz.make_plot(states[-1], "pressure")
plotter.show()
Comparison with Plotly 3D¶
Both 3D modules share the same API design but target different use cases:
| Feature | Plotly (plotly3d) |
PyVista (pyvista3d) |
|---|---|---|
| Rendering engine | WebGL (browser) | VTK (native window) |
| Cell blocks plot type | No | Yes |
| Interactive slice planes | No | Yes (press 1/2/3) |
| Box cropping | No | Yes (press b) |
| Real-time threshold slider | No | Yes |
| GPU volume rendering | No | Yes |
| Jupyter inline | Yes (native) | Yes (via trame, set notebook=True) |
| HTML export | Yes (.write_html()) |
No |
| Animation controls | Play/pause slider in browser | Frame export to GIF/MP4/WebP |
| Large grid performance | Limited (~500K cells) | Good (~2M+ cells with GPU) |
show_perforations wells kwarg |
Yes | No |
In general, use Plotly for sharing and embedding, and PyVista for detailed inspection and large models.
Common Workflows¶
Cell-Level Inspection¶
Examine individual cells with cell outlines enabled:
from bores.visualization.pyvista3d import DataVisualizer, PlotConfig
viz = DataVisualizer(config=PlotConfig(
show_cell_outlines=True,
cell_outline_color="#404040",
cell_outline_width=1.0,
enable_interactive=True,
))
plotter = viz.make_plot(states[-1], "permeability", plot_type="cell_blocks")
plotter.show()
# Use interactive slice planes (press 1/2/3) to cut through the model
# Drag the orange handles to move the slices
Cross-Section with Slice Planes¶
Use programmatic slicing for a fixed cross-section, or interactive slicing for exploration:
# Fixed cross-section at the center of the model
ny = states[-1].model.grid_shape[1]
plotter = viz.make_plot(
states[-1],
"pressure",
y_slice=ny // 2,
title="Vertical Cross-Section (Center)",
)
plotter.show()
Waterflood Front with Threshold Filtering¶
Track the waterflood front by hiding cells where water saturation has not changed:
plotter = viz.make_plot(
states[-1],
"water_saturation",
plot_type="cell_blocks",
threshold_percentile=20.0, # Hide cells in the bottom 20%
)
plotter.show()
# Then use the Threshold slider to adjust interactively
High-Resolution Batch Export¶
Generate publication-quality images without a display:
viz = DataVisualizer(config=PlotConfig(
off_screen=True,
width=3840,
height=2160,
show_cell_outlines=True,
))
for prop in ["pressure", "oil_saturation", "water_saturation"]:
plotter = viz.make_plot(states[-1], prop)
plotter.screenshot(f"{prop}_final.png")
Animated Saturation Front to GIF¶
Export an animation of the waterflood advancing through the reservoir:
states = list(bores.run(model, config))
viz = DataVisualizer(config=PlotConfig(
off_screen=True,
show_cell_outlines=False,
))
viz.animate(
sequence=states,
property="water_saturation",
plot_type="cell_blocks",
step_size=10,
save="waterflood.gif",
)
Wells with Property Overlay¶
Visualize well placement in the context of the reservoir property distribution:
viz = DataVisualizer(config=PlotConfig(opacity=0.5))
plotter = viz.make_plot(
states[-1],
"oil_saturation",
show_wells=True,
injection_color="#4488ff",
production_color="#ff8844",
wellbore_width=11.0,
show_well_labels=True,
title="Oil Saturation with Well Layout",
)
plotter.show()
Performance Considerations¶
PyVista renders through VTK, which uses hardware-accelerated graphics. This gives it better performance than browser-based Plotly for large grids, but there are still practical limits.
For cell block rendering, grids up to about 500,000 cells render smoothly on modern hardware. Above 1 million cells, use the subsampling_factor parameter or programmatic slicing to reduce the rendered cell count. Volume rendering automatically coarsens grids that exceed BORES_MAX_VOLUME_CELLS_3D (default 1 million).
For batch rendering (screenshots, animations), set off_screen=True to avoid creating a display window. This is required on headless servers and CI environments. The rendering quality is identical to the interactive window.
The vertical exaggeration slider (Z-scale) is particularly useful for reservoir models that are much wider than they are thick. A typical reservoir might be 5,000 ft wide but only 100 ft thick, making it appear as a thin sheet at 1:1 scale. Setting Z-scale to 10 or 20 makes the vertical structure visible without distorting the horizontal layout.