Volume rendering scientific data with VTK.js in a notebook
Render 3D scalar fields as translucent volumes, pull isosurfaces out of them with marching cubes, and drive a live WebGL canvas — all inside a notebook cell.
A line plot answers a question about one variable. A 3D scalar field — temperature through a solid, stress across a part, electron density around a molecule — answers a question about a whole volume, and a line plot can't touch it. To see inside volumetric data you need either to slice it, to pull surfaces out of it, or to render the entire cloud at once with light passing through it. That last technique is volume rendering, and in Equana it runs on a WebGL canvas embedded directly in a notebook cell, powered by VTK.js.
This post is a tour of what that buys you: interactive volume rendering, isosurface extraction, and full finite-element meshes, all rendered inline, all driven by a few lines of code in the same cell that computed the data.
Surface rendering vs. volume rendering
Most 3D graphics is surface rendering: the world is a shell of triangles, opaque, lit from outside. It is perfect for a machine part or a game character — anything whose inside you don't need to see. But a scalar field has no surface. Temperature doesn't stop at a boundary; it varies continuously through space. Draw only its outer skin and you've hidden the entire interior, which is usually the part you care about.
Volume rendering treats the data as a translucent gel. Every point in the 3D grid gets a color and an opacity, and the renderer accumulates light as it travels a ray through the whole cloud toward the camera. Dense or interesting regions glow; empty regions stay clear. Nothing is thrown away — you see straight through the outer layers into the structure beneath.
The knob that makes this work is the transfer function: a mapping from scalar value to color and opacity. It is what decides that "cold" is transparent blue and "hot" is solid red, that the value range you care about lights up while everything else fades out of the way. Tuning the transfer function is most of the art of volume rendering; choosing a colormap in Equana is choosing the color half of it.
A volume in four lines
Here is a 3D Gaussian blob — a scalar field that is brightest at the origin and falls off in every direction — rendered as a volume:
n = 21
coords = linspace(-2, 2, n)
(X, Y, Z) = meshgrid(coords, coords, coords)
data = exp(-(X.^2 + Y.^2 + Z.^2))
vtk.colormap("coolwarm")
vtk.volume(data, [n, n, n])
title("3D Gaussian Volume")
meshgrid with three arguments builds three 21×21×21 coordinate arrays; the elementwise exp(-(...)) evaluates the field at all 9,261 grid points at once, running through Equana's WASM math kernels. vtk.volume hands that flat scalar array and its dimensions to the renderer. Here you'd see a soft glowing sphere of haze, densest and most opaque at the center and dissolving to nothing at the edges — the interior fully visible because the outer shells are translucent. Drag it and it rotates smoothly; the entire ray-cast re-renders on the GPU every frame.
Swap in a field with structure — a heat source cooling toward the boundaries of a block — and the same four lines reveal the thermal gradient as a colored cloud you can look into, not just at.
Isosurfaces: pulling a shape out of the fog
Sometimes you don't want the whole cloud; you want a specific level — the surface where temperature equals exactly 80 °C, or where an implicit function crosses zero. That is an isosurface, and extracting one is the 3D generalization of drawing a contour line on a topographic map.
The algorithm underneath is marching cubes: march through the grid one cube of eight neighboring samples at a time, check which corners fall above the target value and which fall below, and stitch in triangles wherever the surface must thread between them. A lookup table of the 256 possible above/below patterns makes it fast and local. VTK.js runs it and hands back a triangle mesh you can rotate like any solid.
n = 51
coords = linspace(-2.5, 2.5, n)
(X, Y, Z) = meshgrid(coords, coords, coords)
# Implicit torus: (sqrt(x^2+y^2) - R)^2 + z^2 = r^2
torus = (sqrt(X.^2 + Y.^2) - 1.5).^2 + Z.^2 - 0.5^2
vtk.colormap("rainbow")
vtk.isosurface(torus, [n, n, n], 0)
title("Torus (R=1.5, r=0.5)")
The field itself is just a number at every grid point — there is no torus stored anywhere. Asking for the isosurface at zero conjures the doughnut into existence as a clean triangulated shell, colored by the rainbow map. Change the isovalue and the surface swells or shrinks continuously; the same trick draws a sphere from x²+y²+z², or a molecular surface from a sum of atomic electron densities.
Isosurfaces and full volume rendering are complementary. The volume shows you everything at once, softly; an isosurface commits to one level and gives you a crisp, measurable shape. Real workflows flip between them — render the volume to find the interesting range, extract an isosurface to pin the boundary down.
FEM meshes, rendered where you built them
The third thing that goes on the canvas is an actual finite-element mesh: nodes, elements, and a scalar field painted across them. This is where visualization stops being decoration and becomes the readout of a simulation.
beam_nodes = [
0,0,0, 1,0,0, 2,0,0, 3,0,0,
0,1,0, 1,1,0, 2,1,0, 3,1,0,
0,0,1, 1,0,1, 2,0,1, 3,0,1,
0,1,1, 1,1,1, 2,1,1, 3,1,1
]
beam_elements = [
8, 0,1,5,4, 8,9,13,12,
8, 1,2,6,5, 9,10,14,13,
8, 2,3,7,6, 10,11,15,14
]
beam_types = [12, 12, 12] # 12 = VTK_HEXAHEDRON
stress = [100,90,80,70, 100,90,80,70, 100,90,80,70, 100,90,80,70]
vtk.colormap("coolwarm")
vtk.edges("on")
vtk.femgrid(beam_nodes, beam_elements, beam_types, stress)
title("Beam with Stress Visualization")
vtk.femgrid takes the node coordinates, the element connectivity, a cell-type code per element (VTK's numbering — 10 for a tetrahedron, 12 for a hex brick, and so on), and a per-node scalar. Here you'd see a three-element beam with its edges drawn on, shading from red at the fixed end to blue at the tip — a von Mises stress field you can rotate to inspect from any angle. This is the exact rendering path the mode shapes in modal analysis in the browser travel through: solve for a field on a mesh, then paint the field onto the mesh in the next cell.
How it runs inside a cell
The thing that makes this feel different from generating a static image is that the output is the renderer. When a cell returns a VTK object, Equana mounts a live WebGL canvas in the cell's output area and hands the geometry to VTK.js, which draws it on the GPU. From there it's fully interactive: drag to rotate, scroll to zoom, shift-drag to pan, and toggle a clipping plane along X, Y, or Z to cut into a solid volume or mesh and inspect its interior. Nothing round-trips to a server — the canvas talks to your graphics card directly.
That locality is the whole point. The data was computed in the cell above by WASM kernels over a shared linear memory, and it's rendered in the cell below by your browser's WebGL stack. A 3D field never has to be serialized, uploaded, rendered on someone's cluster, and streamed back as pixels. You compute and you look, in the same tab, with the geometry staying on your machine the entire time.
Volume rendering is genuinely GPU-intensive, so the practical advice is to develop on coarse grids — a 21³ volume is instant, a 201³ volume will make a laptop work — and turn up the resolution once the transfer function and viewpoint are dialed in. Within that budget you get a real visualization toolkit, the same VTK lineage that drives desktop scientific viz, running inline next to your code.
The vtk namespace ships with the Equana runtime, and the notebook is free to try at equana.dev. To see these renderers put to work on real simulation output, read modal analysis in the browser; to meet the symbolic engine that rounds out the toolkit, read symbolic math with symwa.