GLPlot is a Python plotting library with a Matplotlib-like API that renders on the GPU
through OpenGL instead of the CPU. Plots that make Matplotlib chug β millions of points,
dense line families, large 3D scenes β stay interactive: smooth pan, zoom, and rotation,
even at that scale. If you already know plt.plot() / plt.scatter(), most of what you
know carries over directly.
pip install glplotor from source:
git clone https://github.com/AkarisDimitry/GLPlot.git
cd GLPlot
pip install -e .Requires Python 3.11+. Core dependencies: numpy, scipy, matplotlib, glfw, PyOpenGL,
imgui-bundle (for the on-screen control panel).
GLPlot pins matplotlib>=3.6,<3.10. It mirrors Matplotlib's API (signatures of pyplot
functions, Animation, voxels, ...) and uses its Agg renderer for saved figures, and those
signatures changed between 3.9, 3.10 and 3.11. Without an upper bound, pip resolved different
Matplotlib majors on different Python versions, so there was no single API to match. If another
package in your environment needs Matplotlib 3.10 or newer, install GLPlot in its own virtual
environment.
To let an LLM drive GLPlot through MCP (see below), add the optional extra:
pip install "glplot[mcp]"import numpy as np
import glplot.pyplot as plt
x = np.linspace(0, 10, 100)
plt.figure("My Plot", figsize=(8, 5))
plt.plot(x, np.sin(x), "r-", lw=2, label="sin(x)")
plt.scatter(x[::10], np.sin(x[::10]), c="blue", s=20)
plt.xlabel("x")
plt.ylabel("y")
plt.legend()
plt.show()That's it β a real window opens, fully interactive (pan/zoom/rotate) from the first frame.
Explore 2D plots, 3D scenes, and real-time animations. Click on any figure or title below to expand its full Python code, or click the file link to view the script.
π Click to view code: 10-Million Point Spiral Scatter (02_scatter_fill.py)
π Full script: examples/gallery/02_scatter_fill.py
import numpy as np
import glplot.pyplot as plt
rng = np.random.default_rng(4)
n = 10_000_000
theta = rng.uniform(0, 18 * np.pi, n)
radius = 0.025 * theta + rng.gamma(1.5, 0.006, n)
x_pc = radius * np.cos(theta) + rng.normal(scale=0.012, size=n)
y_pc = radius * np.sin(theta) + rng.normal(scale=0.012, size=n)
formation_phase = theta + 6.0 * radius # proxy for age along the spiral arm
plt.figure("Simulated Spiral Star Field", figsize=(9, 6))
plt.scatter(x_pc, y_pc, c=formation_phase, cmap="turbo", s=1.5, alpha=0.72)
plt.title(f"Simulated stellar density along a spiral arm (N = {n:,} stars)")
plt.xlabel("x (pc)")
plt.ylabel("y (pc)")
plt.show()
π Click to view code: 10M-Sample 2D Density Histogram (10_massive_hist2d_density.py)
π Full script: examples/gallery/10_massive_hist2d_density.py
import numpy as np
import glplot.pyplot as plt
rng = np.random.default_rng(123)
n = 10_000_000
theta = rng.uniform(0, 2 * np.pi, n)
r = rng.gamma(shape=2.2, scale=0.8, size=n)
mm_per_unit = 10.0
x = mm_per_unit * (r * np.cos(theta) + rng.normal(scale=0.08, size=n))
y = mm_per_unit * (r * np.sin(theta) + rng.normal(scale=0.08, size=n))
plt.figure("Detector Impact Density", figsize=(8, 6))
lim = 45.0
plt.hist2d(
x, y,
bins=170,
range=[[-lim, lim], [-lim, lim]],
cmap="plasma",
s=40.0,
label=f"{n:,} hits",
)
plt.title(f"{n:,}-hit density map on detector plane")
plt.xlabel("x (mm)")
plt.ylabel("y (mm)")
plt.legend()
plt.show()
π Click to view code: 10,072-Line Oscilloscope Ensemble (01_line_plot.py)
π Full script: examples/gallery/01_line_plot.py
import colorsys
import numpy as np
import glplot.pyplot as plt
def chalk_color(hue: float, rng: np.random.Generator) -> tuple:
sat = rng.uniform(0.22, 0.42)
val = rng.uniform(0.90, 1.0)
return colorsys.hsv_to_rgb(hue % 1.0, sat, val)
rng = np.random.default_rng(101)
n_channels = 10_072
n_points = 180
t = np.linspace(0, 40 * np.pi, n_points)
t_ms = t * (50.0 / (40 * np.pi))
stimulus = np.sin(t) + 0.35 * np.sin(2.7 * t)
plt.figure("Oscilloscope Ensemble", figsize=(9, 5))
plt.plot_style("chalk", layers=False)
frac = np.linspace(0.0, 1.0, n_channels)
hues = (rng.uniform(0, 1) + frac + rng.uniform(-0.02, 0.02, n_channels)) % 1.0
for i in range(n_channels):
t_frac = frac[i]
phase = rng.uniform(0, 2 * np.pi)
amp = 0.12 + 0.781 * t_frac
freq = 1.1 + 0.852 * t_frac
offset = 3.195 * t_frac
channel = stimulus + amp * np.sin(freq * t + phase)
color = chalk_color(hues[i], rng)
plt.plot(t_ms, channel + offset, color=(*color, 0.35), lw=0.5)
plt.plot(t_ms, stimulus + 0.7, color=(0.95, 0.94, 0.90, 1.0), lw=3.0, label="Reference")
plt.xlabel("Time (ms)")
plt.ylabel("Amplitude")
plt.title(f"Traces from {n_channels} oscillator channels")
plt.legend()
plt.show()
π Click to view code: 1M-Point Circumstellar Dust Halo (07_projected_3d_cloud.py)
π Full script: examples/gallery/07_projected_3d_cloud.py
import numpy as np
import glplot.pyplot as plt
rng = np.random.default_rng(77)
n = 1_000_000
sigma = 42.0
outer_radius = 2.5 * sigma
n_gen = 1_140_000
x = rng.normal(0.0, sigma, n_gen)
y = rng.normal(0.0, sigma, n_gen)
z = rng.normal(0.0, sigma, n_gen)
inside = (x**2 + y**2 + z**2) < outer_radius**2
x, y, z = x[inside][:n], y[inside][:n], z[inside][:n]
plt.figure("Circumstellar Dust Halo", figsize=(9, 6))
plt.scatter3d(
x, y, z,
c=z,
cmap="viridis",
s=1.1,
alpha=0.75,
elev=22,
azim=-42,
label=f"{n:,} grains",
)
plt.title(f"Simulated circumstellar dust halo ({n:,} grains)")
plt.xlabel("x (AU)")
plt.ylabel("y (AU)")
plt.zlabel("z (AU)")
plt.legend()
plt.show()
π Click to view code: 1.75M-Point Volumetric Nebula (13_volumetric_nebula.py)
π Full script: examples/gallery/13_volumetric_nebula.py
import numpy as np
import glplot.pyplot as plt
rng = np.random.default_rng(1313)
n = 1_750_000
outer_radius = 3.2
n_gen = 2_000_000
theta = rng.uniform(0, 8 * np.pi, n_gen)
phi = rng.normal(0.0, 0.48, n_gen)
r = rng.gamma(2.0, 0.8, n_gen)
inside = r < outer_radius
theta, phi, r = theta[inside][:n], phi[inside][:n], r[inside][:n]
arm = rng.integers(0, 4, n) * np.pi / 2
twist = theta + arm + 0.62 * r
x = r * np.cos(twist) + rng.normal(scale=0.06, size=n)
y = r * np.sin(twist) + rng.normal(scale=0.06, size=n)
z = 0.65 * r * np.sin(phi) + 0.18 * np.sin(theta)
emission = np.exp(-0.12 * r**2) + 0.28 * np.sin(3 * theta) ** 2
plt.figure("Volumetric 3D Nebula", figsize=(9, 6))
plt.volume3d(
x, y, z, emission,
threshold=0.14,
cmap="magma",
alpha=0.42,
s=1.2,
elev=24,
azim=-52,
label=f"{n:,} points",
)
plt.title(f"{n:,}-point volumetric emission nebula")
plt.xlabel("x (ly)")
plt.ylabel("y (ly)")
plt.zlabel("z (ly)")
plt.legend()
plt.show()
π Click to view code: 3D Turbulent Vector Field (19_turbulent_vector_field_3d.py)
π Full script: examples/gallery/19_turbulent_vector_field_3d.py
import numpy as np
import glplot.pyplot as plt
rng = np.random.default_rng(1919)
cloud_n = 950_000
t = rng.uniform(0.0, 18.0 * np.pi, cloud_n)
shell_raw = rng.gamma(2.2, 0.42, cloud_n)
shell = 2.0 * np.tanh(shell_raw / 2.0)
twist = 0.22 * np.sin(2.6 * t) + 0.18 * np.cos(1.4 * t)
cloud_x = shell * np.sin(t) + 0.28 * np.sin(3.0 * t)
cloud_y = shell * np.cos(1.17 * t) + 0.32 * np.cos(2.4 * t)
cloud_z = 0.58 * np.sin(0.52 * t + twist) + 0.32 * shell * np.cos(0.33 * t)
cloud_energy = np.exp(-0.22 * shell**2) + 0.33 * np.sin(1.7 * t) ** 2
plt.figure("Turbulent 3D Flow", figsize=(10, 7), ssao=True)
plt.volume3d(
cloud_x, cloud_y, cloud_z, cloud_energy,
threshold=0.22,
cmap="inferno",
alpha=0.17,
s=0.72,
elev=27,
azim=-49,
label=f"{cloud_n:,} volumetric samples",
)
plt.title("Massive 3D turbulent swirl")
plt.xlabel("x")
plt.ylabel("y")
plt.zlabel("z")
plt.legend()
plt.show()
π Click to view code: Animated Standing-Wave Interference (28_chladni_wave_animation.py)
π Full script: examples/gallery/28_chladni_wave_animation.py
import numpy as np
import glplot.animation as animation
import glplot.pyplot as plt
N = 360
x = np.linspace(-1.0, 1.0, N)
y = np.linspace(-1.0, 1.0, N)
X, Y = np.meshgrid(x, y)
FRAMES = 54
def chladni_field(m: float, n: float) -> np.ndarray:
return np.sin(m * np.pi * X) * np.cos(n * np.pi * Y) - np.sin(n * np.pi * X) * np.cos(m * np.pi * Y)
def mode_pair(frame: int) -> tuple:
t = frame / FRAMES
m = 2.2 + 4.3 * (0.5 - 0.5 * np.cos(2 * np.pi * t))
n = 3.1 + 3.6 * (0.5 - 0.5 * np.cos(2 * np.pi * t * 1.5 + 1.1))
return m, n
def zoom_span(frame: int) -> float:
t = frame / FRAMES
return 0.75 - 0.35 * np.cos(2 * np.pi * t)
fig = plt.figure("Animated Standing-Wave Interference", figsize=(7.8, 6.2))
plt.plot_style("blueprint")
def update(frame: int):
m, n = mode_pair(frame)
span = zoom_span(frame)
Z = chladni_field(m, n)
plt.cla()
plt.imshow(Z, extent=(-1, 1, -1, 1), origin="lower", cmap="RdBu_r", vmin=-1.0, vmax=1.0, aspect="equal")
plt.xlim(-span, span)
plt.ylim(-span, span)
plt.title(f"Standing-wave interference (m={m:.1f}, n={n:.1f})")
plt.xlabel("x (plate width)")
plt.ylabel("y (plate width)")
return []
ani = animation.FuncAnimation(fig, update, frames=FRAMES, interval=42)
ani.save("chladni_wave_animation.gif", fps=20)
plt.show()
π Click to view code: Real-Time Audio Spectrum Analyzer (03_spectrum_analyzer_bars.py)
π Full script: examples/gallery/animations/03_spectrum_analyzer_bars.py
import numpy as np
import glplot.animation as animation
import glplot.pyplot as plt
N_BANDS = 64
FRAMES = 84
band_x = np.arange(N_BANDS)
fig = plt.figure("Audio Spectrum Analyzer", figsize=(9, 5))
def update(frame: int):
t = frame / FRAMES
levels = -25.0 + 18.0 * np.sin(np.linspace(0.2, 4.0 * np.pi, N_BANDS) + 2.0 * np.pi * t)
plt.cla()
plt.bar(band_x, levels, color="tab:cyan", alpha=0.85)
plt.ylim(-60, 0)
plt.title("Real-Time 64-Band Spectrum Analyzer")
plt.xlabel("Frequency Band")
plt.ylabel("Power (dBFS)")
return []
ani = animation.FuncAnimation(fig, update, frames=FRAMES, interval=33)
ani.save("spectrum_analyzer.gif", fps=30)
plt.show()
π Click to view code: Differential Keplerian Star Cluster (01_orbiting_star_cluster.py)
π Full script: examples/gallery/animations/01_orbiting_star_cluster.py
import numpy as np
import glplot.animation as animation
import glplot.pyplot as plt
N_STARS = 20_000
FRAMES = 80
LIM = 6.8
rng = np.random.default_rng(42)
radius = rng.exponential(scale=1.35, size=N_STARS) + 0.10
arm_id = rng.integers(0, 3, N_STARS)
theta0 = arm_id * (2.0 * np.pi / 3) + 1.65 * np.log(radius / 0.10)
omega = 1.35 / np.sqrt(radius + 0.30)
fig = plt.figure("Orbiting Star Cluster", figsize=(8, 8))
def update(frame: int):
t = frame * 0.08
theta = theta0 + omega * t
x = radius * np.cos(theta)
y = radius * np.sin(theta)
plt.cla()
plt.scatter(x, y, c=radius, cmap="plasma", s=1.2, alpha=0.8)
plt.xlim(-LIM, LIM)
plt.ylim(-LIM, LIM)
plt.title(f"Differential Keplerian rotation ({N_STARS:,} stars)")
return []
ani = animation.FuncAnimation(fig, update, frames=FRAMES, interval=40)
ani.save("star_cluster.gif", fps=25)
plt.show()All of the above render at 60+ FPS with interactive panning, zooming, and rotation, regardless of point count. Discover more in the example gallery (28 static scripts) and the animated gallery (16 animated scripts).
- Matplotlib-compatible API β
plot,scatter,bar,hist,hist2d,imshow,contour/contourf,quiver, format strings ("r-o","b--"), and more - Millions of points, still interactive β GPU instancing and density accumulation instead of CPU-side geometry construction
- Full 2D/3D β lines, scatter, filled regions, bars, histograms, matrices, surfaces, wireframes, 3D bars, vector fields, with SSAO depth shading in 3D
- Real multi-panel subplots β
plt.subplots(), per-panel interaction - Animation β
glplot.animation.FuncAnimation/ArtistAnimation, exportable to GIF/video - A live control panel β
python -m glplotopens a workstation for editing a scene, its layers, and its styling interactively, with undo history - Native interactive controls β
gplt.slider,button,checkbox,dropdown,range_slider,textbox,color_pickerand@gplt.interactput widgets in a Controls panel beside the plot; callbacks run once per frame with the latest values, errors are shown in place, values can be saved as presets.glplot.widgetsruns matplotlibSlider/Button/CheckButtons/RadioButtons/TextBoxscripts unchanged - A gallery you can point at your data β the Gallery panel turns the examples into 22 recipes: pick a card (each is a real render), map your columns, and the plot is drawn in the look it was designed in -- 2-D and 3-D, one undoable step, with a live preview of your data first
- 25 looks, each with its own colormap β from journal and colour-blind-safe to aurora, synthwave and vintage print; the Style panel shows every preset rendered on a line plot, a scatter, bars, a heat map or a 3-D surface of your choice
- Native MCP server β
python -m glplot.mcplets Claude or any MCP client build figures, datasets and plots and look at the result (every render returns an image), with no arbitrary code execution and file access confined to directories you choose - Numerically stable at extreme zoom β double-precision, viewport-relative coordinate transforms avoid the jitter that single-precision GPU pipelines show at large offsets
GLPlot is an MCP server: a model creates a figure, builds or loads data, draws, restyles, and gets every render back as an image plus a summary of the axes, so it can check and fix its own plot. No window is opened.
pip install "glplot[mcp]"
claude mcp add glplot -- python -m glplot.mcp --root ~/data # Claude CodeFor Claude Desktop, add to claude_desktop_config.json:
{ "mcpServers": { "glplot": { "command": "python", "args": ["-m", "glplot.mcp", "--root", "/Users/me/data"] } } }--root sets the directories the model may read data from or save images into (repeatable);
without it the model has no file access at all and builds data with create_dataset.
Formulas run through the same restricted expression evaluator as
the workstation, with size-capped array constructors, so the model can generate data
("sin(2*pi*t) * exp(-t)") instead of typing it, and there is no tool that runs arbitrary code.
Tools: create_figure, create_dataset, load_dataset, add_plot (14 kinds), add_function,
add_field (image/contour/surface...), add_recipe (the gallery's 22 recipes: a plot kind in the
look it was designed in, from your data or its own sample), add_annotation, set_axes,
set_style, render_figure, save_figure, and a few more. Full guide: docs/source/guide/mcp-server.rst.
A million lines at once (plot_lines)
Ordinary plotting draws each line as CPU-generated geometry β a million calls to plot()
would build a million separate meshes. plot_lines instead uploads each line as an
(a, b) coefficient pair and lets the GPU work out what's visible:
import numpy as np
import glplot.pyplot as plt
n = 1_000_000
a = np.random.randn(n)
b = np.random.randn(n)
plt.figure("Density")
plt.plot_lines(a, b, x_range=(-2, 2))
plt.show(density=True)Sliders and buttons that drive your analysis (interact)
Each keyword becomes a control in the Controls panel; the function re-runs whenever one
moves. Return (x, y) and GLPlot draws the curve and keeps it updated; return a dict
and it appears as a table of results:
import numpy as np
import glplot.pyplot as plt
x = np.linspace(0, 10, 5000)
@plt.interact(amp=(0, 5), freq=(0.1, 10), wave=["sin", "cos"])
def signal(amp=1.0, freq=1.0, wave="sin"):
return x, amp * getattr(np, wave)(freq * x)
@plt.interact
def stats(amp, freq): # reuses the controls above by name
return {"Peak": amp, "Period": 2 * np.pi / freq}
@plt.button("Double amplitude", shortcut="Mod+Alt+D")
def double():
plt.controls()["amp"].value *= 2
plt.show()Controls can also be used on their own (amp = plt.slider("Amplitude", 0, 5),
amp.on_change(fn), amp.value), grouped with with plt.control_group("Filter"):, and
saved or restored with plt.controls().save_preset(...) / .save(path). See
examples/ex_interactive_controls.py for a complete
signal-analysis bench.
| Feature | GLPlot | Matplotlib | Plotly | Datashader | VisPy |
|---|---|---|---|---|---|
| GPU acceleration | β (OpenGL) | β | β | β | β |
| Matplotlib-style API | β | β | β | β | β |
| Millions of points, interactive | β | β | Limited | β | β |
| Interactive 3D | β | Limited | β | Limited | β |
| Density visualization | β (HDR) | Basic | Limited | β | β |
| Precision at extreme zoom | β (double precision) | β | Basic | β | β |
GLPlot sits between "familiar API, CPU-bound" (Matplotlib) and "GPU-fast, low-level" (VisPy): a Matplotlib-shaped surface backed by a GPU renderer.
GLPlot runs two rendering pipelines β one for 2D primitives (lines, scatter, density) and
one for 3D geometry (bars, surfaces, wireframes, scatter3d) β both driven from the CPU
but doing their actual work on the GPU. glReadPixels is only ever called for export; the
interactive path never reads pixels back to the CPU.
See docs/ARCHITECTURE.md for the full derivation of each stage, including the density-accumulation math and the viewport-relative projection that keeps zoom numerically stable.
pytest # run everything
pytest --cov=glplot --cov-report=html # with coverage
pytest tests/test_pyplot.py::test_plot_accepts_y_only_and_returns_artists # one test6,800+ tests, run fully headless (no window is ever displayed) so they work in CI. The
matrix covers Python 3.11β3.13 on Ubuntu, macOS, and Windows; black, isort, and flake8
are enforced on every push. See examples/benchmark/ for reproducible performance
comparisons against Matplotlib, VisPy, fastplotlib, Datashader, and hvPlot, and tools/
for GPU/environment diagnostics.
- API reference: docstrings in
glplot.pyplot, or the built docs β see docs/README.md - Architecture: docs/ARCHITECTURE.md
- Dev tools: tools/README.md
- Contributing: CONTRIBUTING.md Β· Code of conduct: CODE_OF_CONDUCT.md
@software{lombardi2026glplot,
title={GLPlot: High-Performance GPU-Accelerated Plotting Library for Python},
author={Lombardi, Juan Manuel and Riccius, Felix and Holland, Julian and Ducci, Gianmarco},
year={2026},
url={https://github.com/AkarisDimitry/GLPlot},
doi={10.5281/zenodo.PLACEHOLDER}
}See CITATION.cff for other formats.
MIT.
Built on PyOpenGL, GLFW, NumPy, SciPy, Matplotlib, and Dear ImGui β thanks to those communities for the foundations this sits on.
