We had a suspicion the compositor was in the wrong package. But it worked, there was only one consumer, and refactoring a GPU compositor is genuinely hard. So we shipped it and moved on.
Then the 3D renderer arrived. Then the overlays broke. Suspicion turned into certainty — at the worst possible time.
This is the story of that migration, and what we learned from Flutter, Go stdlib, and Chromium along the way.
The Setup: a Compositor Hidden Inside a Drawing Library
GoGPU is a Pure Go GPU ecosystem — 1.25M lines of code, zero CGO. The architecture has three layers:
gogpu — app framework (windowing, events, lifecycle)
gg — 2D graphics (paths, text, GPU SDF, rasterizer)
wgpu — WebGPU implementation (Vulkan/DX12/Metal/GLES/Software)
When we first built GPU-accelerated rendering for gg, the compositor logic naturally ended up inside gg. It was the only consumer. It owned the render target, the blit pipeline, the MSAA resolve, the damage tracking. It decided when to clear vs preserve content.
This is an ownership inversion. In every enterprise graphics stack — Chromium, Flutter, GTK4 — the compositor owns the render target and content cache. The drawing library just records draw commands into whatever surface it's given:
| Framework | Drawing library | Compositor | Who owns the surface? |
|---|---|---|---|
| Chromium | Skia | cc/ (tile manager) | Compositor |
| GTK4 | Render nodes | GSK | Compositor |
| Flutter | Dart Canvas | flow/ | Compositor |
| GoGPU (before) | gg | gogpu | gg ← wrong |
We suspected this wasn't right. But gg was the only renderer, it worked, and the refactoring would be complex. So we shipped it.
The Breaking Point: Three Problems, One Root Cause
Problem 1: Damage rects were single-source
Damage tracking already lived in gogpu — but as a simple passthrough. gg called SetDamageRects() with its dirty regions, gogpu forwarded them to the platform compositor via VkPresentRegionsKHR. One source, one consumer, worked fine.
Then g3d arrived. A 3D scene rotating at 60fps sends full-viewport damage. But SetDamageRects only accepted one set of rects — from gg. When gg rendered a HUD overlay on top of a g3d scene, only the HUD's damage was reported. The g3d viewport outside the HUD? Stale frames on Wayland compositors. Confirmed by @porjo on Asahi Linux (g3d#22) — visible mosaic artifacts because Mesa's wl_surface_damage_buffer only updated the HUD area.
So we redesigned the damage system from scratch. SetDamageRects (single source, simple passthrough) was replaced with RegisterDamageSource — each renderer registers as a named source, reports its own damage per frame, and gogpu unions all sources at present time. Chromium's DamageTracker uses the same union pattern. That was ADR-065 — not a refactor, a reimplementation.
Problem 2: Two overlays, two libraries
We had two independent debug overlay implementations:
-
gg had a damage overlay (
damage_debug.go) — green flash-and-fade on dirty regions (GOGPU_DEBUG_DAMAGE) -
ui had a dirty widget overlay (
debug_dirty.go) — cyan rectangles on repainted widgets (GOGPU_DEBUG_DIRTY)
Both worked fine in isolation. Each had its own env var, its own rendering logic, its own fade effects. And neither knew about the other. A g3d-only app had zero debug overlays — no damage visualization, no FPS counter, nothing.
The fix: move overlays to the compositor level. A g3d-only app without gg still needs overlays. A multi-renderer app (gg + g3d) needs a unified overlay that sees damage from all sources. That was ADR-066 — a pluggable DebugOverlay system at the compositor level, with built-in damage and FPS overlays.
Problem 3: The move that broke everything
ADR-065 and ADR-066 shipped together in v0.51.0. Damage sources worked. Overlays worked — until the content was idle and the overlay had a 400ms fade animation.
1. Overlay fade: NeedsAnimationFrame() → RequestRedraw()
2. Next frame: gg says "nothing dirty" → no GPU commands
3. gg owns the render target → no commands = frame skipped
4. Overlay never draws → animation freezes
The compositor (gogpu) couldn't render overlays independently because gg owned the surface. Without gg's cooperation, the compositor couldn't even decide whether to clear or preserve the render target. The Vulkan spec says acquired swapchain images have UNDEFINED content — so without gg drawing, the overlay rendered on garbage.
Three problems, one root cause: gg owned what the compositor should own. The shortcut that "worked fine" for one consumer became a blocking architectural problem when the second and third consumers appeared. Every enterprise reference confirmed what we'd only suspected — the compositor must own the render target, not the drawing library.
Delaying further would have meant building workarounds. Each workaround would cement the wrong architecture deeper. So we stopped delaying.
Phase 1: Moving the Compositor Out of gg (ADR-067)
The first step was getting the compositor code out of gg and into gogpu where it belongs.
We introduced a compositor-owned composition texture:
Before:
swapchain acquired → gg renders directly → present
(gg decides Clear/Load, gg owns MSAA resolve)
After:
swapchain acquired → gogpu clears composition texture
→ gg/g3d render INTO composition texture (they don't know)
→ gogpu draws overlays on top
→ gogpu blits composition texture → swapchain → present
Key insight: content renderers don't need to know about the compositor. Context.SurfaceView() returns the composition texture view instead of the swapchain view. From gg's perspective, nothing changed — it renders to a TextureView, same as before.
Overlay-only frames became first-class. When content is idle but an overlay is animating, the compositor uses LoadOpLoad (preserve) instead of LoadOpClear, draws the overlay on the preserved content, and presents. No cooperation from gg needed.
Phase 2: Extracting internal/compositor/ (ADR-069)
After moving compositor logic into gogpu, we had ~2,500 LOC of compositor code mixed with ~4,000 LOC of application framework in the same package. Time for the second step: extract it into internal/compositor/.
Research first
Before writing extraction code, we studied three references:
Flutter's flow/ (~22K lines) — concrete structs, unidirectional deps (shell/ → flow/, never reverse), GPU resources borrowed per frame through a callback. Closest to our situation.
Go stdlib ssa.Func (41 fields) — the largest internal/ split in Go. Struct ownership + Frontend callback interface. Parent creates the struct, calls methods. Internal package calls back through a minimal interface when it needs the parent.
Chromium cc/ (~190K lines) — interface-heavy dependency injection with *Client/*Delegate abstract classes everywhere. A C++ necessity, over-engineered for Go internal/.
What we actually built
The compositor isn't a single god-struct. It's a set of focused types that the root package composes:
gogpu/ (root package, renderer.go)
│
├── RenderTarget {
│ damageSources []*compositor.DamageSource
│ blitPipeline compositor.BlitPipeline
│ compositeState compositor.CompositeState
│ }
│
└── internal/compositor/
├── blit.go — BlitResources, BlitDrawRecorder interface
├── blit_pipeline.go — BlitPipeline, CompositeState
├── damage_source.go — DamageSource, per-renderer damage tracking
├── damage_scissor.go — pure geometry: rect union, scissor clipping
├── damage_overlay.go — DamageDebugOverlay (damage rect visualization)
├── fps_overlay.go — FPSDebugOverlay (frame rate counter)
├── overlay_pipeline.go — shared GPU pipeline for overlays
└── shader.go — WGSL shader sources
Content renderers (gg, g3d) implement one interface:
type BlitDrawRecorder interface {
RecordBlitDraws(pass *wgpu.RenderPassEncoder)
}
The compositor owns the render pass lifecycle. Renderers just record their draws.
The numbers
-
renderer.go: 2,174 → 1,892 lines (-282 LOC of compositor logic removed) -
internal/compositor/: 2,357 LOC (self-contained subsystem) - Root package wiring: 194 LOC (thin delegation)
Zero public API change. All App, Context, RenderTarget types stayed in the root package. RegisterDamageSource() return type narrowed to gpucontext.DamageReporter interface — which external consumers already used. No breaking change.
Rules That Emerged
These are now enforced in CI:
1. No type aliases for migrated types. type X = internal.X is for transitional migration only. We changed return types to interfaces instead.
2. Call internal directly. No unexported wrapper functions around internal/ calls. At call sites: r.blitPipeline.Init(device, format, shader), not an unexported initBlitPipeline() wrapper.
3. Unidirectional deps, enforced in CI:
- name: Compositor dependency direction
run: |
if grep -r '"github.com/gogpu/gogpu"' internal/compositor/; then
echo "compositor must not import root package"
exit 1
fi
4. Performance is not a reason to avoid extraction. We benchmarked interface calls: ~1.4-3.0ns vs ~1.3ns for direct calls. At 10 calls/frame × 60fps = ~60ns per frame vs ~16.7ms budget. 0.0005% overhead. Go 1.20+ devirtualizes most of these anyway.
What We Learned
"We'll fix it later" has a deadline you don't control. We suspected the compositor was in the wrong place for months. The deadline to fix it wasn't set by us — it was set by the overlay freeze bug and the 3D renderer needing damage tracking. Under pressure, every refactoring is harder.
Enterprise references agree on ownership. We checked Chromium, Flutter, and GTK4. In all three, the compositor owns the surface and content cache. The drawing library just draws. This isn't a coincidence — it's the architecture that survives multi-renderer apps.
Not everything needs a god struct. The Go stdlib ssa.Func pattern (single struct, 40+ fields, callback interface) is powerful but not universal. Our compositor worked better as a set of focused types (3-8 fields each) that the root package composes. Each type has a clear responsibility.
internal/ is underrated. Most Go projects either put everything in one package or create deep hierarchies. internal/ gives you encapsulation without the API commitment. The compositor is free to change its struct layout without breaking external consumers.
The Ecosystem
GoGPU is 1.25M lines of Pure Go — a full GPU ecosystem, not just one library:
| Project | LOC | What it does |
|---|---|---|
| naga | 324K | Shader compiler (WGSL → SPIR-V/MSL/GLSL/HLSL/DXIL) |
| gg | 312K | 2D graphics (Skia-class rasterizer, GPU SDF, ClearType) |
| wgpu | 254K | WebGPU (Vulkan/DX12/Metal/GLES/Software/Browser) |
| ui | 211K | GUI toolkit (27 widgets, 4 design systems) |
| gogpu | 96K | App framework + compositor |
| g3d | 14K | 3D rendering (scene graph, PBR, forward renderer) |
| compose | 10K | Multi-process composition (Unix socket, LZ4, hot-plug) |
| systray | 8K | System tray (Win32/macOS/Linux, zero CGO) |
| audio | 3.5K | Audio engine (WASAPI, WAV, Mixer) |
Zero CGO. go build and it works. Windows, macOS, Linux, Browser.
1,200+ GitHub stars. Listed in awesome-go. Used by Born ML for GPU compute, ironwail-go for Quake 1 rendering.
The compositor migration landed across three releases: v0.51.0 (ADR-065 + ADR-066, damage source reimplementation + pluggable overlays), v0.52.0 (ADR-067, compositor-owned render target), and v0.52.1 (ADR-069, package extraction).
If your drawing library owns compositor state and you only have one consumer — fix it now. The second consumer always arrives sooner than you expect.
Try It, Break It, Tell Us
GoGPU is pre-v1.0 — this is the best time to influence the API before it freezes. We need your help:
- Test on your hardware. We develop on Intel Iris Xe (Windows) and Mesa llvmpipe (Linux). AMD, NVIDIA, Apple Silicon, Adreno — every GPU we haven't tested is a bug we haven't found. Run the examples, file issues.
- Contribute. PRs welcome — from typo fixes to new backends. We review everything. Check good first issues across the org.
- Write about it. Tutorials, reviews, comparisons — in any language. Pavel Tišnovský wrote three parts on root.cz (Czech), covering gg 2D graphics and gogpu windowing with 12 interactive examples. If you write something, let us know — we'll link it.
- Spread the word. Star us, mention us in your talks, recommend us when someone asks "how do I do GPU in Go?"
Support the Project
GoGPU is built by a small team in their spare time. If you find it useful, consider supporting continued development:
Support GoGPU on Open Collective — every contribution helps us spend more time on the ecosystem instead of client work.
Star us on GitHub: github.com/gogpu/gogpu ⭐
Top comments (0)