Getting Started with WebGPU
This page walks through the WebGPUWidget lifecycle and builds a minimal
application from scratch. It assumes you have already
installed PyNGL along with the wgpu and
PySide6 packages.
How the widget works
WebGPUWidget is an abstract PySide6 QWidget. Unlike an OpenGL widget
there is no window-system surface: the widget renders offscreen.
Each frame goes through these steps:
- Qt delivers a paint event and the widget calls your
paintWebGPU()override. - Your code records a render pass into a command encoder and submits it.
The pass draws into a multisampled texture that resolves into
self.colour_buffer_texture(RGBA8, with adepth24plusdepth buffer). - The widget copies that texture back into a numpy array
(
self.frame_buffer) and draws it onto the widget withQPainter, along with any text queued withrender_text().
The two abstract methods you must implement:
| Method | Called when | Your job |
|---|---|---|
paintWebGPU() |
every paint event | record and submit the frame's render pass |
resizeWebGPU(w, h) |
the window resizes (w/h are in device pixels) |
update your projection matrix and any size-dependent state |
The widget handles high-DPI scaling (self.ratio), recreates the render
textures on resize, and tracks the current pixel size in
self.texture_size.
A minimal application
import sys
import wgpu
from PySide6.QtWidgets import QApplication
from wgpu.utils import get_default_device
from ncca.ngl import PerspMode, Vec3, look_at, perspective
from ncca.ngl.webgpu import WebGPUWidget
class Scene(WebGPUWidget):
def __init__(self):
super().__init__(background_colour=(0.4, 0.4, 0.4, 1.0))
# 1. Get a device — everything is created from this.
self.device = get_default_device()
# 2. Create the colour / MSAA / depth textures.
self._create_render_buffer()
# 3. Set up cameras. Note PerspMode.WebGPU!
self.view = look_at(Vec3(0, 2, 4), Vec3(0, 0, 0), Vec3(0, 1, 0))
self.project = perspective(45.0, 1.0, 0.1, 100.0, PerspMode.WebGPU)
# 4. Repaint at ~60 FPS.
self.start_update_timer(16)
def paintWebGPU(self):
encoder = self.device.create_command_encoder()
# The widget builds a render pass with the clear colour,
# MSAA resolve, and depth attachment already configured.
render_pass = self._create_render_pass(encoder)
# ... set pipelines and draw here (see the next page) ...
render_pass.end()
self.device.queue.submit([encoder.finish()])
self.render_text(10, 20, "Hello WebGPU")
def resizeWebGPU(self, w, h):
self.project = perspective(
45.0, w / max(h, 1), 0.1, 100.0, PerspMode.WebGPU
)
if __name__ == "__main__":
app = QApplication(sys.argv)
win = Scene()
win.resize(1024, 720)
win.show()
sys.exit(app.exec())
Run it and you get a grey window — a cleared render target presented through Qt, ready for the pipelines on the next page.
Some points worth noting:
self.devicemust exist before the first resize/paint. Create it first thing in__init__; the widget's resize handling calls_create_render_buffer(), which needs it.- You don't submit the copy-to-screen yourself. After
paintWebGPU()returns, the widget reads the colour texture back and paints it — your only job is to fill the texture. _create_render_pass(encoder)is a convenience that begins a render pass targeting the widget's multisample texture, resolving into the colour buffer, clearing toself.background_colour, with the depth buffer attached. You can also callencoder.begin_render_pass(...)yourself withself.multisample_texture_view,self.colour_buffer_texture_view, andself.depth_buffer_viewif you need different load/store behaviour.- Animation is just Qt:
start_update_timer(16)(or aQTimer/startTimerof your own) schedules repaints; change your scene state each tick. - Text overlays —
render_text(x, y, text, size=10, font="Arial", colour=Qt.black)queues text that is drawn over the frame withQPainter. A negativeypositions relative to the bottom of the window, and sizes scale with the window height.
Projection: the WebGPU depth range
This deserves repeating because it is the most common porting mistake.
WebGPU clip space is z from 0 to 1; OpenGL's is z from −1 to 1. The
perspective (and ortho/frustum) helpers take a PerspMode argument
for exactly this reason:
from ncca.ngl import PerspMode, perspective
project = perspective(45.0, aspect, 0.1, 100.0, PerspMode.WebGPU)
If you use the default (OpenGL) mode with the WebGPU renderer, half your depth range falls outside the clip volume — geometry disappears or z-fights.
Uploading matrices
The math classes are numpy-backed, and wgpu wants raw float32 data.
The pattern used throughout the demos is to build the matrix with @ and
convert at the point of upload:
mvp = (self.project @ self.view @ model).to_numpy().astype(np.float32)
pipeline.update_uniforms(mvp=mvp)
Everything you hand to a pipeline — vertex positions, colours, uniform
matrices — must be np.float32. Data in float64 (numpy's default!) has
twice the byte size the GPU-side layout expects.
Next steps
With the widget skeleton in place, the quickest way to get something on screen is one of the fourteen built-in pipelines — continue to The Built-in Pipelines.