Visualization with CUDA-OpenGL Interoperability#
Real-time rendering of simulations using CUDA-OpenGL interoperability. Field data streams directly from GPU memory into the display without intermediate transfers to CPU memory.
Overview#
soliton_solver provides GPU-accelerated visualization using CUDA-OpenGL interoperability:
Zero-copy rendering — Field data flows directly from CUDA memory to OpenGL buffers
Real-time performance — Interactive exploration at 30–60 FPS on modern GPUs
GLFW windowing — Cross-platform window management and input handling
Theory-specific visualization — Each theory defines custom colormaps and observables
The visualization backend is implemented in soliton_solver/visualization/gl_backend.py. Each theory provides a theory-specific renderer in render_gl.py.
CUDA-OpenGL Interoperability#
CUDA-OpenGL interop allows CUDA kernels to read/write OpenGL buffer objects directly without CPU-GPU transfers.
CUDA-GL resource mapping#
The workflow is:
Create OpenGL buffer — Pixel buffer object (PBO) allocated via OpenGL
Register with CUDA — PBO is registered as a CUDA graphics resource
Map resource — CUDA gets a device pointer to the PBO memory
Compute on GPU — CUDA kernels write directly to the PBO
Unmap resource — Resource is released for OpenGL to use
Render texture — OpenGL displays the PBO as a texture
Implementation (from gl_backend.py):
class GLBackend:
def __init__(self, width, height, title="CUDA-OpenGL"):
# Create PBO
self.pbo = GL.glGenBuffers(1)
GL.glBindBuffer(GL.GL_PIXEL_UNPACK_BUFFER, self.pbo)
GL.glBufferData(
GL.GL_PIXEL_UNPACK_BUFFER,
width * height * 4, # 4 bytes per RGBA pixel
None,
GL.GL_STREAM_DRAW
)
# Register PBO with CUDA
self.resource = _cuda_register_gl_buffer(
self.pbo,
flags=_cuda_gl_write_discard_flag()
)
def map_pbo(self):
"""Get a CUDA device pointer to the PBO."""
ptr, nbytes = _cuda_map_resource(self.resource)
return cuda_array_from_ptr(ptr, (self.height, self.width, 4), dtype=np.uint8)
def unmap_pbo(self):
"""Release the PBO for OpenGL."""
_cuda_unmap_resource(self.resource)
Zero-copy transfers#
Traditional flow (CPU-GPU copies):
GPU Field Buffer → CPU Memory → OpenGL Texture → Display
CUDA-OpenGL interop (zero-copy):
GPU Field Buffer ← CUDA kernel → PBO ← OpenGL Texture → Display
Benefits:
Bandwidth saved — No PCIe transfers of rendered frame data (~50 MB/frame at 1024×1024)
Latency reduced — Eliminates GPU→CPU→GPU round-trip
Throughput improved — 30–60 FPS typical vs. 5–10 FPS with copies
Rendering pipeline#
The rendering pipeline consists of:
CUDA density computation — Field observables evaluated on GPU
CUDA colormap — Density mapped to RGBA via CUDA kernel
PBO mapping — Pixel buffer mapped from OpenGL to CUDA
CUDA pixel write — Density + colormap written to PBO
PBO unmapping — Resource released to OpenGL
Texture upload — PBO copied to GPU texture (fast, on-device)
OpenGL rendering — Texture displayed as fullscreen quad
Density kernels#
Each visualization mode computes a density field. Examples:
Energy density — Local energy at each grid point:
@cuda.jit
def compute_energy_kernel(en, Field, d1fd1x, p_i, p_f):
"""Compute energy density."""
x, y = cuda.grid(2)
if not in_bounds(x, y, p_i):
return
en[idx_field(0, x, y, p_i)] = (
# Theory-specific energy formula
...
)
Skyrmion number — Topological charge density:
@cuda.jit
def compute_skyrmion_number_kernel(density, Field, d1fd1x, p_i, p_f):
"""Compute skyrmion number density."""
# Integrand of winding number formula
...
Field magnitude — Norm of complex/vector fields:
@cuda.jit
def compute_norm_kernel(density, Field, p_i):
"""Compute field magnitude."""
x, y = cuda.grid(2)
if not in_bounds(x, y, p_i):
return
density[idx_field(0, x, y, p_i)] = math.sqrt(
Field[idx_field(0, x, y, p_i)]**2 +
Field[idx_field(1, x, y, p_i)]**2
)
Color mapping#
Density values are mapped to RGBA colors. Several colormaps are available:
Jet colormap#
Standard blue-cyan-green-yellow-red colormap. Used for scalar densities.
@cuda.jit
def render_jet_density_to_rgba(pbo_rgba, density, min_val, max_val, p_i):
"""Map density to jet colormap."""
x, y = cuda.grid(2)
if not in_bounds(x, y, p_i):
return
# Normalize density to [0, 1]
val = (density[idx] - min_val) / (max_val - min_val)
val = max(0.0, min(1.0, val)) # Clamp to [0, 1]
# Jet colormap interpolation
if val < 0.125:
r, g, b = 0.0, 0.0, 0.5 + 0.5 * (val / 0.125)
elif val < 0.375:
r, g, b = 0.0, 0.5 * ((val - 0.125) / 0.25), 1.0
elif val < 0.625:
r, g, b = 0.5 * ((val - 0.375) / 0.25), 1.0, 1.0 - 0.5 * ((val - 0.375) / 0.25)
elif val < 0.875:
r, g, b = 1.0, 1.0 - 0.5 * ((val - 0.625) / 0.25), 0.0
else:
r, g, b = 1.0, 0.5 * (1.0 - (val - 0.875) / 0.125), 0.0
pbo_rgba[y, x, 0] = int(r * 255)
pbo_rgba[y, x, 1] = int(g * 255)
pbo_rgba[y, x, 2] = int(b * 255)
pbo_rgba[y, x, 3] = 255 # Alpha
HSV colormap#
Hue-Saturation-Value colormap. Useful for vector fields (magnetization, etc.).
def magnetization_to_hsv(mx, my, mz):
"""Convert magnetization vector to HSV."""
# Hue from in-plane angle: atan2(mx, my)
hue = (0.5 + (1.0 / (2 * M_PI)) * atan2(mx, my)) * 360.0
# Saturation from out-of-plane component
saturation = 0.5 - 0.5 * tanh(3 * (mz - 0.5))
# Value proportional to mz
value = mz + 1.0
return hue, saturation, value
Advantages:
Intuitive — Hue encodes direction, saturation/value encode magnitude
Perceptually uniform — Color changes reflect data changes smoothly
Vector-friendly — Naturally represents 2D in-plane + 1D perpendicular components
Interactive controls#
The viewer supports real-time interaction via keyboard and mouse.
Keyboard controls#
Typical control scheme (theory-dependent):
Key |
Action |
|---|---|
|
Toggle arrested Newton flow on/off |
|
Toggle velocity arrest on energy increase |
|
Cycle display mode (energy, order parameter, etc.) |
|
Enter vortex placement mode |
|
Cycle vortex ansatz (Bloch, Neel, etc.) |
|
Set vortex number (winding number) |
|
Save results to disk |
|
Exit viewer |
Mouse interaction#
Vortex placement mode — Click to place vortices at cursor position:
def on_mouse_click(x, y, vortex_type):
"""Place a vortex at (x, y)."""
# Convert pixel coords to lattice indices
pxi = int(x * params.xlen / window_width)
pxj = int(y * params.ylen / window_height)
# Kernel writes vortex configuration
create_vortex_kernel[grid2d, block2d](
sim.Field, sim.grid, pxi, pxj, vortex_type, ...
)
Performance optimization#
Frame rate management#
Balance solver accuracy vs. interactive responsiveness:
run_viewer(sim, params, steps_per_frame=5)
steps_per_frame=1— Rendering dominates; interactive but solver takes few stepssteps_per_frame=5— Good balance; ~10 FPS solver + ~6 fps displaysteps_per_frame=20— Solver dominates; slower rendering but faster convergence
Display mode selection#
Different observables have different computational cost:
Energy density — Relatively cheap; fast gradient computation
Topological charge — Moderately expensive; requires derivatives
Magnetic field — Expensive; requires curl computation
Choose the cheapest mode for interactive work; switch to expensive modes for analysis.
Texture format#
RGBA8 (8 bits per channel) vs. RGBA16F (16-bit float):
RGBA8 — Default, uses less bandwidth, sufficient for visualization
RGBA16F — Higher precision, for detailed feature inspection
Custom visualization#
To add visualization to a new theory, implement render_gl.py:
# soliton_solver/theories/my_theory/render_gl.py
from soliton_solver.visualization.gl_backend import GLBackend
import numpy as np
from numba import cuda
class GLRenderer:
"""Custom renderer for my_theory."""
def __init__(self, width, height):
self.backend = GLBackend(width, height, title="My Theory")
self.display_mode = 1
def display_frame(self, sim):
"""Render one frame."""
# 1. Compute observable (density field)
grid2d, block2d = launch_2d(sim.p_i_h, threads=(8, 8))
sim.kernels.compute_my_observable_kernel[grid2d, block2d](
sim.en, sim.Field, sim.p_i_d, sim.p_f_d
)
cuda.synchronize()
# 2. Map PBO for CUDA writing
pbo_rgba = self.backend.map_pbo()
# 3. Color-map and write to PBO
my_colormap_kernel[grid2d, block2d](
pbo_rgba, sim.en, min_val, max_val, sim.p_i_d
)
cuda.synchronize()
# 4. Unmap PBO
self.backend.unmap_pbo()
# 5. Display
self.backend.upload_and_draw()
def run_viewer(sim, params, *, steps_per_frame=5):
"""Launch interactive viewer."""
renderer = GLRenderer(params.xlen, params.ylen)
while not renderer.backend.should_close():
renderer.display_frame(sim)
# Advance solver
energy, err = sim.step(prev_energy=energy)
Key points:
Implement
compute_*_kernelfor your observablesImplement a colormap kernel to map density → RGBA
Use
GLBackendfor the window and PBO managementCall
map_pbo()/unmap_pbo()around CUDA work
Performance characteristics#
Typical performance on NVIDIA A100:
Grid size |
Frame rate |
Pixel throughput |
|---|---|---|
512×512 |
60 FPS |
~150 M pixels/sec |
1024×1024 |
30 FPS |
~300 M pixels/sec |
2048×2048 |
15 FPS |
~600 M pixels/sec |
Bottleneck: GPU memory bandwidth and synchronization overhead between CUDA and OpenGL.
Troubleshooting#
Black screen / no display:
Check that density kernel writes valid data (not NaN, inf, or all zeros)
Verify colormap kernel writes to correct PBO array
Flickering / torn frames:
Enable vsync:
glfw.swap_interval(1)(default)Increase
steps_per_frameif solver is too slow
CUDA-GL errors:
Ensure both CUDA and OpenGL operations are synchronized
Call
cuda.synchronize()before unmapping resourcesCheck that PBO size matches window resolution