go-gpuimage is an offscreen WebGPU rendering for Golang, built on
gogpu/wgpu. The Go package is gpuimage.
Render into a GPU texture with your own shaders, upload and sample Go
images, and read pixels back as *image.NRGBA. There is no window and no
event loop. The same API builds natively and for browser WebAssembly
(GOOS=js GOARCH=wasm).
The library manages the render target, texture resources, GPU execution, and readback. Shaders, pipelines, buffers, and drawing use upstream WebGPU types directly.
The current release is v0.1.0. The API may change before v1.0. DESIGN.md explains the design and compares related GoGPU libraries. VALIDATION.md records what was tested and how.
Install the library:
go get github.com/NimbleMarkets/go-gpuimage@v0.1.0import (
"github.com/gogpu/gputypes"
"github.com/gogpu/wgpu"
gpuimage "github.com/NimbleMarkets/go-gpuimage"
)
r, err := gpuimage.New(ctx, gpuimage.Options{Width: 640, Height: 480, Depth: true})
if err != nil { /* errors.Is(err, gpuimage.ErrNoAdapter), or device creation failed */ }
defer r.Close()
// Create pipelines, shaders, and buffers on the renderer's GPU thread.
err = r.Do(func(dev *wgpu.Device) error { /* dev.CreateRenderPipeline(...) */ return nil })
img, stats, err := r.Render(ctx, func(f *gpuimage.Frame) error {
// f.Device.Queue().WriteBuffer(...) updates uniforms for this frame.
pass, err := f.Encoder.BeginRenderPass(&wgpu.RenderPassDescriptor{
ColorAttachments: []wgpu.RenderPassColorAttachment{{
View: f.Color, LoadOp: gputypes.LoadOpClear, StoreOp: gputypes.StoreOpStore,
ClearValue: gputypes.Color{R: 0.1, G: 0.1, B: 0.1, A: 1},
}},
DepthStencilAttachment: &wgpu.RenderPassDepthStencilAttachment{
View: f.Depth, DepthLoadOp: gputypes.LoadOpClear, DepthStoreOp: gputypes.StoreOpStore,
DepthClearValue: 1, StencilReadOnly: true, // required in the browser
},
})
if err != nil { return err }
// pass.SetPipeline(...); pass.Draw(...)
return pass.End()
})
// img is a new *image.NRGBA that the caller owns.Run the examples:
go run ./examples/clear -out frame.png # clear a target and read it back
go run ./examples/texture -out texture.png # upload, sample, update, read back| Call | Purpose |
|---|---|
New(ctx, Options) |
Create the device and the first target. |
Renderer.Do(func(*wgpu.Device) error) |
Run setup or teardown on the GPU thread. |
Renderer.Render(ctx, draw) |
Draw, then read the frame back in the same submission. |
Renderer.RenderToTexture(ctx, draw) |
Draw without reading back. |
Renderer.ReadPixels(ctx) |
Read back the most recent frame. |
Renderer.Resize(w, h), Size() |
Replace the target. A failed resize keeps the old one. |
Renderer.AdapterInfo() |
Adapter name, backend, and device type. |
Renderer.UploadTexture(image, TextureOptions) |
Create a sampled texture from a Go image. |
Texture.Update, ReadPixels, Size, Close |
Replace pixels in place, copy them back, release. |
Texture.BindGroup(), BindGroupLayout() |
Handles for the caller's pipeline. |
Renderer.Close() |
Release everything, including textures still open. |
Frame gives the draw callback Device, Encoder, Color, Depth,
Width, Height, and Seq. Stats reports Draw, Submit, Map,
Copy, and Total durations for a frame.
- Targets and textures are
RGBA8Unorm. Colors are not converted between color spaces. - Readback returns the target's bytes as
image.NRGBA. If a shader writes premultiplied color, un-premultiply before treating the image as straight alpha. Opaque output needs no conversion. UploadTextureaccepts anyimage.Image, including sub-images and padded strides, and stores straight alpha. Linear filtering of straight alpha darkens edges next to transparent texels; use an opaque image, nearest filtering, or a padded atlas where that matters.- GoGPU's
gogpuandgglibraries use premultiplied alpha. Convert when exchanging images with them.
tile, err := r.UploadTexture(sourceImage, gpuimage.TextureOptions{})
if err != nil { /* handle error */ }
defer tile.Close()
// Include tile.BindGroupLayout() in your pipeline layout. In WGSL:
// @group(0) @binding(0) var tileTexture: texture_2d<f32>;
// @group(0) @binding(1) var tileSampler: sampler;
// Inside a draw callback, bind tile.BindGroup() at that group index.
err = tile.Update(nextImage) // same dimensions; the bind group stays valid
pixels, err := tile.ReadPixels(ctx) // optional CPU copy of the textureTextureOptions{Filter: gputypes.FilterModeNearest} selects nearest
filtering. The zero value selects linear. UVs clamp at the edges.
- Each
Rendererowns its instance, adapter, device, target, and textures, and releases them inClose. Calls that need the device returnErrClosedafter it closes. - All GPU work runs on one goroutine pinned to an OS thread. Calls from any goroutine are serialized, with one frame in flight.
- Do not call methods that queue GPU work from inside
Door a draw callback. They would wait for the thread they already occupy. Texture handle accessors such asBindGroupandBindGroupLayoutare safe to use in callbacks. - Objects you create with the device are yours. Release them inside
DobeforeClose. - Do not keep the device, encoder, or views from a callback for use on another goroutine.
- Sharing a device owned by another library is not supported yet. DESIGN.md describes a possible API.
Render, RenderToTexture, and both ReadPixels methods take a context.
- A call waiting for the GPU thread returns
ctx.Err()when the context is cancelled or expires, without drawing. - Cancellation during the draw callback or while the GPU finishes the
frame also returns
ctx.Err(). A running draw callback is not interrupted. - After cancellation the GPU may still complete the abandoned frame. Later calls are unaffected.
- On Metal, a cancellable context added about 0.1 ms per readback in
testing, because the wait must poll.
context.Background()uses a single blocking wait. - With a cancellable context, native
RenderToTexturereturns once the frame is submitted instead of waiting for the GPU. Do,Resize,UploadTexture,Update, andClosetake no context and wait their turn.
- Requires a WebGPU-capable browser in a secure context (HTTPS or localhost).
- Call the library from a Go goroutine, not from inside a JavaScript callback. Readback awaits a promise, which would deadlock the event loop.
- While a readback is pending, the library requests animation frames so that the browser settles the map promptly.
- Set
StencilReadOnly: truewhen attachingFrame.Depth. The browser backend otherwise emits stencil operations, which WebGPU rejects for a depth-only format. AdapterInfocannot identify a software adapter in the browser. Upstream reports every browser adapter as"WebGPU Adapter".
With Task installed, task list shows every task.
| Task | Runs | Needs |
|---|---|---|
task |
Race tests and builds of examples, the browser runner, and WASM | Nothing |
task ci |
Everything CI runs: race tests, vet, WASM build, Node tests | Node |
task test-gpu |
The full suite on a native adapter | A GPU |
task bench |
Clear and readback benchmarks, three runs per case | A GPU |
task test-browser |
The full suite in headless Chrome | Chrome with WebGPU |
task validate |
ci, test-gpu, and test-browser |
All of the above |
task test-vulkan |
The full suite on Vulkan under the validation layer | Linux with Vulkan |
task test-vulkan-remote HOST=name |
Copies the tree to name over ssh and runs test-vulkan there |
ssh access |
task run-examples |
Both examples, writing PNG files to bin/ |
A GPU |
The same commands without Task:
go test -race ./... # helpers and executor, no GPU
GPUIMAGE_GPU_TEST=1 go test ./... # real native adapter; never with -race
# Helper tests under Node, which has no WebGPU:
GOOS=js GOARCH=wasm go test -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" .
# The full suite in headless Chrome with WebGPU:
go build -o /tmp/browsertest ./internal/browsertest
GPUIMAGE_GPU_TEST=1 GOOS=js GOARCH=wasm go test -exec=/tmp/browsertest -v .GPU tests log the adapter they ran on. A run on a CPU or fallback adapter does not test hardware GPU behavior.
BenchmarkRenderReadback measures one complete frame: clear an RGBA8
color target, submit it, wait for the GPU, and copy the pixels into a new
image.NRGBA. It includes allocation and garbage collection during the
loop. Device creation, ten warmup frames, context creation, and cleanup
are outside the timer. It does not draw geometry, upload textures, or use
a depth attachment.
Each size runs with context.Background() and with one reused cancellable
context that is never cancelled. This measures the cost of checking for
cancellation, not the cost of cancelling a frame.
task bench # choose the default native backend
task bench BACKEND=metal # macOS
task bench BACKEND=vulkan # Linux
task bench BENCHTIME=3s COUNT=5 # longer runs
# Without Task (set GPUIMAGE_BENCH_BACKEND=metal or vulkan to choose):
GPUIMAGE_GPU_TEST=1 go test -run='^$' -bench=BenchmarkRenderReadback \
-benchmem -benchtime=1s -count=3 .Run without the race detector or Vulkan validation layers. The benchmark
logs the adapter and rejects software adapters. MB/s is the output image
size divided by frame time; it is not a measurement of GPU bus bandwidth.
Allocation counts cover Go memory, not GPU memory.
Results from 2026-09-27, using wgpu v0.34.5 and naga v0.19.0. Each value
is the median of three runs with -benchtime=1s. Lower is better.
| Frame size | Context | Apple M3 Max / Metal | NVIDIA GB10 / Vulkan |
|---|---|---|---|
| 640x400 | Background | 0.587 ms | 0.480 ms |
| 640x400 | Cancellable | 0.645 ms | 0.684 ms |
| 1280x720 | Background | 0.657 ms | 0.768 ms |
| 1280x720 | Cancellable | 0.758 ms | 0.938 ms |
| 1920x1080 | Background | 0.820 ms | 1.077 ms |
| 1920x1080 | Cancellable | 0.954 ms | 1.199 ms |
The Metal run used macOS 27.0 arm64 and Go 1.27.1. The Vulkan run used
Ubuntu 24.04.5 arm64, Go 1.26.8, and NVIDIA driver 580.178.04. Vulkan used
VK_DRIVER_FILES=/usr/share/vulkan/icd.d/nvidia_icd.json to select the
NVIDIA driver. These numbers compare complete systems, including CPU,
driver, and Go runtime costs.
Raw output, including throughput and allocations: Metal and Vulkan.
- naga v0.19.0 generates invalid Metal source for arithmetic on a component
of an indexed
letorconstarray, such aspositions[i].x * 0.5. List the values explicitly, as the examples do.TestDerivedUVShaderreports when upstream fixes this. - The color target cannot be sampled by a later pass. It has no
TextureBindingusage. - No mipmaps, MSAA, sRGB formats, or partial texture updates.
- Metal, Vulkan on one NVIDIA adapter, and Chrome were run. DX12, GLES, and other browsers compile but were not tested.
ReadPixelsfails until a frame has been drawn to the current target.
We welcome contributions and feedback. Please adhere to our Code of Conduct when engaging our community.
Thanks to the GoGPU organization for their dedication to making GPU processing available to Golang developers, especially in the form of the wgpu WebGPU library.
This project is released under the MIT License, see LICENSE.txt.
Copyright (c) 2026 Neomantra Corp.
Made with ❤️ and 🔥 by the team behind Nimble.Markets.