DEV Community

Cover image for We Built a Pure Go 3D Renderer — Then Embedded It Inside a GUI Widget
Andrey Kolkov
Andrey Kolkov

Posted on

We Built a Pure Go 3D Renderer — Then Embedded It Inside a GUI Widget

Go has had a 2D graphics story for a while now. But 3D? The options have been either CGO wrappers around OpenGL, abandoned projects from 2019, or "use Ebiten and write your own engine."

We decided to build the missing piece: g3d — a Pure Go 3D rendering library with scene graph, PBR materials, and forward rendering pipeline. Zero CGO, all five GPU backends (Vulkan, Metal, DX12, GLES, Software), 374 tests, built on our WebGPU implementation.

But a 3D renderer that only works standalone is half the story. The real question is: how does it compose with UI?

This post covers the two integration patterns we shipped in v0.1.4 — patterns that every enterprise 3D application uses, from Unity to Blender to CAD tools. And how we filed eight cross-repo issues across five repositories to make it work.

The 20-Line Rotating Cube

Before we get to integration — here's what g3d looks like standalone:

scene := g3d.NewScene()
scene.SetBackground(g3d.RGB(0.1, 0.1, 0.15))

sun := g3d.NewDirectionalLight(
    g3d.WithLightColor(g3d.White),
    g3d.WithLightIntensity(1.0),
)
scene.Add(sun.LightNode())

cube := g3d.NewMesh(
    g3d.NewBoxGeometry(1, 1, 1),
    g3d.NewStandardMaterial(
        g3d.WithColor(g3d.RGB(0.4, 0.7, 1.0)),
        g3d.WithRoughness(0.6),
    ),
)
scene.Add(cube.MeshNode())

camera := g3d.NewPerspectiveCamera(75, 800.0/600.0, 0.1, 1000)
camera.CameraNode().SetPosition(g3d.Vec3{X: 0, Y: 0.5, Z: 3})
Enter fullscreen mode Exit fullscreen mode

Scene graph, PBR materials, directional lighting — all CPU data structures. No GPU device needed until you render. The API is inspired by Three.js, but idiomatic Go: interfaces + functional options, not OOP inheritance chains.

The renderer creates itself lazily on first frame:

app.OnDraw(func(dc *gogpu.Context) {
    if renderer == nil {
        renderer, _ = g3d.NewRenderer(app.GPUContextProvider())
    }
    renderer.Render(scene, camera, dc.SurfaceView())
})
Enter fullscreen mode Exit fullscreen mode

That's it. Rotating PBR cube on Vulkan. Single binary, zero CGO, go build && ./app.

The Real Challenge: 3D + UI

A standalone 3D window is a demo. A 3D viewport inside a GUI application — that's a product. Think about what real 3D applications look like:

  • Game: fullscreen 3D world with health bar, minimap, crosshair overlaid
  • CAD tool: 3D model viewer with toolbar, property panel, status bar around it
  • IDE: 3D preview panel embedded alongside code editor and file tree

These are two fundamentally different composition patterns:

Pattern 3D Renders To UI Renders To Who Owns the Surface
Fullscreen overlay Swapchain directly Same swapchain (on top) Both, sequentially
Embedded widget Offscreen texture Swapchain (compositor) UI framework

We implemented both. And both required solving problems that no Go project had solved before.

Pattern 1: Fullscreen 3D + 2D Overlay

This is the game/CAD pattern. g3d renders the 3D scene to fill the entire window, then gg (our 2D graphics library) draws HUD elements on top: title, FPS counter, crosshair, status bar.

The enterprise pattern is well-established — we researched seven engines:

Engine How They Do It
Bevy + egui Separate render pass, LoadOp::Load, depth_stencil: None
Dear ImGui Separate render pass, DepthEnable=false, alpha blending
Unity Screen Space Overlay canvas, rendered after everything
Godot CanvasLayer, separate from 3D viewport
Three.js clearDepth() between 3D and HUD render calls

The universal answer: two render passes on the same surface. First pass clears and renders 3D with depth testing. Second pass loads the existing content and renders 2D with alpha blending, no depth.

In g3d + gogpu + gg, this looks like:

app.OnDraw(func(dc *gogpu.Context) {
    // Pass 1: g3d renders 3D scene (LoadOp::Clear + depth)
    renderer.Render(scene, camera, dc.SurfaceView())

    // Bridge: tell gogpu the surface has content
    dc.MarkExternalContent()

    // Pass 2: gg renders 2D HUD (LoadOp::Load + alpha blend)
    canvas.Draw(func(cc *gg.Context) {
        drawHUD(cc, width, height, fps)
    })
    canvas.Render(dc.RenderTarget())
})
Enter fullscreen mode Exit fullscreen mode

MarkExternalContent() is the key API. It tells the gogpu framework: "someone already rendered to this surface — don't clear it." Without this call, gg's render pass would wipe the 3D scene with LoadOp::Clear. With it, gg uses LoadOp::Load and draws transparently on top.

This is the same concept as Qt's beginExternal()/endExternal(), Flutter's InlinePassContext, and Unity's Camera.DontClear.

The Result

A rotating PBR cube at 60 FPS with a full HUD overlay — title, live FPS counter, animated crosshair, status bar with backend info. All Pure Go, all on the same swapchain, zero copies.

GOGPU_GRAPHICS_API=vulkan go run ./examples/fullscreen-overlay/
Enter fullscreen mode Exit fullscreen mode

Pattern 2: 3D Inside a GUI Widget

This is the CAD/IDE pattern. The 3D viewport is one widget among many — surrounded by buttons, text, panels. The UI framework owns the window; g3d renders into an offscreen GPU texture; the compositor blits it into the widget tree.

This is how Qt's QRhiWidget, Unity's RenderTexture, and Godot's SubViewport work.

In g3d + gogpu/ui:

vp := gpuview.New(
    gpuview.Size(600, 400),
    gpuview.Continuous(true),
    gpuview.OnRender(func(view gpucontext.TextureView) {
        wgpuView := (*wgpu.TextureView)(view.Pointer())
        renderer.Render(scene, camera, wgpuView)
    }),
)
Enter fullscreen mode Exit fullscreen mode

The gpuview.Widget (formerly Viewport3D, renamed for universality) handles the GPU texture lifecycle. It creates an offscreen texture, passes it to your OnRender callback, and hands the result to the Layer Tree compositor for blitting into the final frame.

Your 3D renderer doesn't know or care that it's inside a widget. It receives a TextureView and renders to it — same API as fullscreen rendering.

The UI layout is standard gogpu/ui:

uiApp.SetRoot(primitives.Box(
    primitives.Text("g3d GPUView").FontSize(22).Bold(),
    vp, // 3D viewport widget
    button.New(
        button.TextOpt("Pause / Resume"),
        button.OnClick(func() { paused = !paused }),
    ),
).Padding(28).Gap(14).Rounded(12).ShadowLevel(2))
Enter fullscreen mode Exit fullscreen mode

Material 3 themed card with a 3D viewport, title, and control buttons. The 3D content renders at GPU speed inside the widget while the rest of the UI renders through the standard compositor pipeline.

go run ./examples/viewport3d/
Enter fullscreen mode Exit fullscreen mode

Eight Issues Across Five Repos

Getting both patterns working wasn't just API design — it was a debugging marathon across five repositories (g3d, gg, gogpu, ui, wgpu) and eight issues. Here are the three hardest:

Bug 1: MSAA Resolve Overwrites External Content

When gg renders its 2D overlay, it uses 4x MSAA for antialiased edges. The MSAA render pass writes to an intermediate texture, then resolves to the swapchain. The resolve is a full overwrite — it doesn't care what was on the swapchain before.

So even with LoadOp::Load, the MSAA intermediate texture was empty (it never contained the 3D scene), and the resolve wrote empty + HUD to the swapchain. 3D content gone.

Fix: gg v0.50.9 implemented Strategy C from Skia's playbook — render the overlay into a separate offscreen MSAA texture, resolve to a 1x intermediate with alpha, then alpha-blend composite onto the swapchain via textured quad. The 3D content survives because the swapchain is never a resolve target.

Bug 2: GPU Buffer Use-After-Free

g3d's renderer created uniform buffers per frame with MappedAtCreation, used them in a render pass, submitted the command buffer, and immediately released them via defer. But queue.Submit() is asynchronous — the GPU was still reading the buffers when Go freed them.

command buffer at index 0 references released buffer "g3d_frame_uniforms"
Enter fullscreen mode Exit fullscreen mode

Fix: Replaced per-frame allocation with persistent uniform buffers updated via queue.WriteBuffer(). Geometry buffers cached by identity. Bind groups deferred-released at the start of the next frame. This is the same pattern gg uses internally — zero allocations in the hot path.

Bug 3: Layer Tree Missing External Texture Node

The gpuview widget created its texture, fired OnRender, and g3d rendered into it correctly. But the widget was invisible. The Layer Tree compositor had ExternalTextureLayer support in its rendering code — but nobody created those nodes.

buildBoundaryLayer() only created PictureLayer nodes. Widgets with external GPU textures needed a parallel ExternalTextureLayer node so the compositor would blit their content.

Fix: ui v0.1.49 added externalTextureWidget interface detection in the Layer Tree builder. Widgets that provide Texture() and ViewportSize() automatically get an ExternalTextureLayer alongside their PictureLayer.

Shared Command Encoder: The Third Way

Sometimes you don't want two separate queue.Submit() calls. g3d v0.1.3 added RenderTo() — record g3d render passes into a caller-owned command encoder:

encoder, _ := device.CreateCommandEncoder(nil)

// g3d records its render pass
renderer.RenderTo(encoder, scene, camera, targetView)

// Other renderers record their passes
overlayRenderer.RecordTo(encoder, ...)

// One submit for the entire frame
commands, _ := encoder.Finish()
queue.Submit(commands)
Enter fullscreen mode Exit fullscreen mode

This eliminates the multi-submit overhead and gives you precise control over render pass ordering. It's the pattern gogpu v0.48.4 uses internally via dc.CommandEncoder().

The Architecture

Here's how g3d fits into the gogpu ecosystem:

Your Application
    ├── gogpu (window + GPU device)
    ├── g3d  (3D scene → render passes)
    ├── gg   (2D graphics → render passes)
    └── ui   (widget tree → compositor → render passes)
              └── gpuview widget ← g3d renders here
Enter fullscreen mode Exit fullscreen mode

g3d depends down only — on wgpu and gpucontext. Never on gogpu, gg, or ui. This means you can use g3d in any context: with gogpu, with your own windowing, or headless for testing.

The shared GPU device comes through gpucontext.DeviceProvider — the same database/sql-style interface pattern that gg, ui, and Born ML use. One device, shared across all renderers, zero-copy resource sharing.

The Ecosystem Is Accelerating

g3d doesn't exist in isolation. The gogpu ecosystem is growing in directions that directly benefit 3D applications:

Zero-CGO Android. @besmpl proved that CGO_ENABLED=0 -buildmode=c-shared works on Android arm64 via //go:nativeexport — a Go toolchain patch that eliminates the last CGO dependency for mobile GPU apps. The prototype runs on API 30 emulator. Once upstream, g3d will run on Android with the same go build simplicity as desktop.

Pure Go Race Detector. The GoGPU ecosystem can't use go test -race today because GPU backends require CGO_ENABLED=0. We're contributing to a Pure Go race detector that achieves read-path parity with TSAN (0.985x on Apple M1). @besmpl delivered 20 optimization commits in 5 days. When merged, every GPU-accelerated Go project gets race detection for free.

Browser/WASM. wgpu's triple-backend architecture (ADR-038) means g3d will run in the browser via WebGPU — same Go code, GOOS=js GOARCH=wasm go build. The browser backend is already shipping in wgpu for 2D; 3D follows the same path.

Composition library. gogpu/compose enables multi-process GPU composition — think VS Code's renderer process architecture, but for Go. A g3d viewport in one process, a 2D editor in another, composited via Unix socket + LZ4.

Community tutorials. Pavel Tišnovský (2,185+ articles on root.cz, Czech Republic) published 88 minutes of tutorial content covering the ecosystem with 54 working examples — the first serious European press coverage of Go GPU graphics.

Games are happening. @kivutar built Goro — a Ragnarok Online client using gogpu/ui in Pure Go (no CGO), with working UI, mercenaries, and gameplay. @darkliquid's Quake 1 port runs on our Vulkan stack. The Go game dev community is real — and it needs a 3D library that doesn't require CGO.

By the Numbers

Metric Value
g3d LOC ~11,700
Tests 374
GPU backends 5 (Vulkan, Metal, DX12, GLES, Software)
Examples 3 (hello-cube, fullscreen-overlay, viewport3d)
Cross-repo bugs found 8
Cross-repo bugs fixed 8
Releases 3 (v0.1.2 → v0.1.4, Jul 26 – Aug 2)
Ecosystem total 1.2M+ LOC Pure Go

Try It

# Standalone 3D
go run github.com/gogpu/g3d/examples/hello-cube@latest

# Fullscreen 3D + 2D overlay
go run github.com/gogpu/g3d/examples/fullscreen-overlay@latest

# 3D inside UI widget
go run github.com/gogpu/g3d/examples/viewport3d@latest
Enter fullscreen mode Exit fullscreen mode

Select your GPU backend:

GOGPU_GRAPHICS_API=vulkan   go run ./examples/hello-cube/
GOGPU_GRAPHICS_API=dx12     go run ./examples/hello-cube/
GOGPU_GRAPHICS_API=software go run ./examples/hello-cube/
Enter fullscreen mode Exit fullscreen mode

What's Next

g3d v0.1.4 has the foundation: scene graph, PBR materials, forward renderer, and two production-ready integration patterns. Here's what's coming:

Phase 2 — Real Materials. Cook-Torrance BRDF replaces Blinn-Phong. Shadow mapping (directional + point). Normal maps, metallic/roughness textures, emissive maps. This is where g3d goes from "demo-ready" to "product-ready."

Phase 3 — GLTF 2.0. Binary .glb and JSON .gltf loading with PBR materials, skeletal animation, and morph targets. GLTF is the "JPEG of 3D" — every modeling tool exports it, every engine imports it. g3d will too.

Phase 4 — Scale. Instance batching for thousands of objects. Environment maps for reflections. Post-processing pipeline (bloom, tone mapping, FXAA). Skybox.

Phase 5 — Performance. BVH-accelerated frustum culling, LOD (level of detail), SIMD math via Go 1.25+ goexperiment.simd. The GoMLX PackGEMM team proved 14x speedup with Pure Go AVX-512 — same approach applies to matrix math.

The rendering library was the last missing piece in Go's graphics ecosystem. 2D graphics, GUI toolkit, shader compiler, audio engine, system tray — all Pure Go, all shipping. Now 3D joins them.

How You Can Help

This project grows through real-world usage. Every bug report from a different GPU, every feature request from a real application, every "it works on my AMD/NVIDIA/Apple M4" — that's data we can't generate alone.

Test it on your hardware. We develop on Intel Iris Xe. AMD, NVIDIA, Apple Silicon, Adreno — we need your GPU. A simple go run ./examples/hello-cube/ and a one-line issue "works on RTX 4080 / Vulkan" is genuinely valuable.

Build something with it. A 3D file viewer. A data visualizer. A game prototype. A CAD preview panel. The API is designed to be embedded — g3d doesn't own your application, you own g3d. The integration patterns in this article work today.

Report what's missing. We're tracking Phase 2–5 features, but priority depends on what people actually need. Shadows? GLTF? Instance batching? Textures? Tell us.

Spread the word. Star the repos, share the examples, write about your experience. Go's graphics ecosystem has been invisible for 17 years because nobody knew it existed. That changes when the community talks about it.

Contribute code. The CONTRIBUTING.md has everything you need. Good first issues are labeled. We review PRs within 24 hours. Every contributor gets credit in the CHANGELOG and release notes.

Support the project. We accept donations via Open Collective to fund development, testing on diverse GPU hardware, and CI infrastructure. Every contribution helps us test on more platforms and ship faster.

The ecosystem is 1.2M+ lines of Pure Go — but the codebase isn't what makes it real. Users make it real. Applications make it real. The more people building on g3d, the faster it reaches the quality bar that Go deserves.

Links

Go waited 17 years for a professional graphics ecosystem. We're building it — and 3D rendering just shipped. Now we need you building on it.

Top comments (0)