跪拜 Guibai
← Back to the summary

GoGUI: A Pure-Go, Zero-CGO GUI That Draws Web-Style UI Directly on OpenGL

Pure Go, Zero CGO: A Web-Style GUI Framework Without WebView

6.45 MB → 4.54 MB → 1.52 MB; one rectangle = 6 vertices, 22 floats; rounded corners, strokes, shadows, frosted glass, supersampling, and Chinese typography all drawn by hand. This article breaks down GoGUI's rendering pipeline, layout engine, font atlas, and 9 pits that ground me into the floor.

Repo: https://github.com/2252688138/Go-gui Size: 129 Go source files, ~38,000 lines (including examples and tools), MIT license.


0. Why build another GUI wheel

When writing desktop programs in Go, there are three obvious paths:

Option Representative Pros Cons
Bind native controls walk, Win32 wrappers Native system look Windows only, styling barely customizable
Frontend + WebView Wails, Tauri approach Mature frontend ecosystem Extra runtime layer, large package, IPC latency
Pure Go self-drawn Fyne, gio No browser kernel, small package Controls and Web look must be built yourself

What I actually want is specific:

So came GoGUI: the ui layer is completely platform-agnostic; underneath it uses purego to dynamically load Win32/WGL, uses OpenGL 3.3 for 2D batched rendering, and x/image/font/opentype for glyph rasterization. Zero cgo.


1. See results first

Real screenshots (1180×760, dark theme; images hosted in repo ui/assets, if Juejin doesn't hotlink, find original at same name in local path):

dashboard

chart

markdown

light theme

No HTML, no CSS, no WebView in the main rendering; all drawn by OpenGL.

Minimal runnable program:

package main

import "gogui/ui"

func main() {
	ui.Run(ui.Options{
		Title:      "My App",
		Width:      1180,
		Height:     760,
		FontFamily: "Microsoft YaHei UI",
		Background: "#070a12",
	}, view)
}

func view() *ui.Node {
	return ui.Column(
		ui.Text("Console").St(ui.St().Text(26, 700, "#eef2ff")),
		ui.Button("New", func() { /* click callback */ }).
			St(ui.St().Text(13, 600, "#fff").PadXY(18, 9).R(10).
				BgGrad("#6366f1", "#8b5cf6", 90).
				Shadow(18, 6, 0, "rgba(99,102,241,0.45)")),
	).St(ui.St().Padding(28).GapOf(20))
}
go run ./cmd/demo
# release build without console window
go build -ldflags "-H=windowsgui" -o app.exe ./cmd/demo

2. Architecture: Five layers, zero cgo

cmd/gallery          example app (8 reusable interfaces)
ui/                  declarative component tree / style DSL / layout / events / theme
internal/gpu         OpenGL 3.3 batched renderer + GLSL
internal/text        opentype glyph rasterization / atlas / metrics
internal/win32       Win32 + WGL + COM bindings (purego dynamic load)
internal/geom        geometry primitives

The key to zero cgo is github.com/ebitengine/purego:

// pseudocode: get function address from DLL at runtime, then wrap as Go function
lib := purego.Dlopen("opengl32.dll", purego.RTLD_NOW)
var wglCreateContext func(hdc uintptr) uintptr
purego.RegisterLibFunc(&wglCreateContext, lib, "wglCreateContext")

The cost is real:

The payoff: the whole program is a single .exe, no MinGW, no MSVC runtime, and cross-compilation won't get stuck because of cgo.

2.1 Render target isn't a "canvas", it's a set of Draw instructions

The ui layer compiles the component tree into draw instructions each frame: shapes, text, clip push/pop. gpu.Renderer consumes them in order, flushing only when type, font, texture, or clip region changes, merging into as few glDrawArrays as possible.

// internal/gpu/renderer.go (simplified)
func (r *Renderer) Draw(instrs []Instr) {
	for _, in := range instrs {
		switch in.Kind {
		case InstrShape:
			r.pushShape(in.Shape)
		case InstrText:
			r.pushText(in.Text)
		case InstrClipPush:
			r.clipSaved = append(r.clipSaved, in.Clip)
			r.setClip(in.Clip)
		case InstrClipPop:
			// restore previous clip (nested intersection)
		}
	}
}

2.2 One rectangle = 6 vertices × 22 floats

Shape vertex format:

pos(2) | local(2) | color(4) | color2(4) | halfSize(2) | radius4(4) | params(4)

local is coordinates relative to rectangle center, halfSize is half width/height, radius4 supports four different corner radii, params packs feather/mode/strokeWidth/glow. One rectangle = 6 vertices, 528 bytes.

Why not "one draw call per style"? Because a screen with hundreds of rounded cards, tags, and strokes would crush the driver. After batching, a screen typically takes only single-digit to a few dozen submissions.


3. Declarative UI: Rebuild every frame, no diff

GoGUI's view() is a pure function: on every redraw it reconstructs the entire component tree, and the framework re-measures / re-arranges / re-paints. No virtual DOM, no diff, no Key reuse (Keys only preserve scroll offset and interaction state).

var current = "dashboard"

ui.Button("Library", func() {
	current = "components"
	ui.RequestRedraw() // next frame rebuild → re-layout → repaint
}).St(ui.St().Text(13, 500, "#8b93ad").PadXY(14, 10).R(10))

This model works thanks to three designs:

1) State is external. Components don't hold state; state lives in caller-level package variables or structs. Components are just "projections of current state."

2) Callbacks are deferred one frame. Click callbacks are not executed immediately in the event callback, but cached to next frame and triggered based on the latest component tree:

// ui/app.go comment:
// Click callbacks fire next frame based on latest tree (avoid stale pointer), no need to set Key on nodes

Otherwise the *Node captured by the closure is invalid after tree rebuild; worst case, it modifies the wrong node or becomes a dangling pointer.

3) Redraw requests are atomic. ui.RequestRedraw() can be called from any goroutine:

// any goroutine can call; only sets a flag, doesn't touch UI cross-thread
func RequestRedraw() {
	for _, a := range snapshotApps() {
		a.redrawRequest.Store(true)
	}
}

Main loop condition roughly:

if a.dirty || a.redrawRequest.Swap(false) || needAnim || a.hoverProbe {
	// rebuild + layout + draw
}

When static, no animation, no input, no draw frames are produced. Idle polling also drops from 120Hz to 60Hz to avoid spinning the message pump and burning CPU.

3.1 Event routing: hit test + bubbling

Mouse events first do a hit test, then bubble from deepest node up to nearest interactive ancestor. Scroll container offsets are stored in App by node Key, so:

ui.Scroll(content).K("scroll-main")

Scroll containers must have a unique Key, otherwise two scroll areas share the same offset.


4. Layout engine: Flutter-style Constraints + two phases

Layout happens in two steps:

  1. measure: bottom-up, compute sizes (text uses opentype metrics, images read only file headers);
  2. arrange: top-down, position nodes and distribute remaining space for Grow.

Constraint model is Flutter-style:

type Constraints struct {
	MinW, MaxW, MinH, MaxH float32
}

Row / Column default to AlignStretch, consistent with CSS align-items: stretch.

4.1 Why flex needs to be measured twice

First pass measures all children using parent's available size and sums fixed items; second pass distributes remaining main-axis space by Grow ratio, only re-measuring children without explicit main-axis size.

// ui/layout.go (simplified)
if growSum > 0 {
	free := availW - fixedMain
	if !horiz {
		free = availH - fixedMain
	}
	if free < 0 {
		free = 0
	}
	for _, c := range n.Children {
		if c.Style.Grow <= 0 || c.Style.PosAbs {
			continue
		}
		// items with explicit size don't need re-measure
		if horiz && (c.Style.Width > 0 || c.Style.WidthPct > 0 || c.Style.Basis > 0) {
			continue
		}
		share := free * c.Style.Grow / growSum
		measureNode(lc, c, Constraints{0, share, 0, availH}, n)
	}
}

Here's an easy pit: when measuring children, don't set a MaxW cap on the main axis.

// measurement constraints: main axis has no upper limit (same as CSS flex), cross axis uses parent's available size.
// If main axis also has a cap, child's percentage sizes will be computed against parent's full width,
// causing "auto-width" containers to inflate.
childC := func() Constraints {
	if horiz {
		return Constraints{0, 0, 0, availH} // MaxW = 0, unlimited
	}
	return Constraints{0, availW, 0, 0}
}

Similarly, percentage sizes need to be recomputed at arrange stage because the parent may be stretched under AlignStretch, and the old measure-stage size would make Wp(100) overflow.

4.2 Content-type containers are not clipped by parent height

Children inside Scroll must stretch to natural content height, otherwise content gets truncated to viewport height and can't scroll. Wrap / Grid / Masonry also follow this:

content := n.Kind == KWrap || n.Kind == KGrid || n.Kind == KMasonry
inScroll := parent != nil && parent.Kind == KScroll
if c.MaxH > 0 && h > c.MaxH && !st.Scrollable && !content && !inScroll {
	h = c.MaxH
}

4.3 More than flex

Constructor Capability
Grid Equal-width column grid, fixed column count or adaptive by column width
Wrap flex-wrap: wrap, supports space-between within rows
Masonry Waterfall, items fall into currently shortest column
EqualRow / EqualCol Equal division of main axis
Pick / BP Responsive branch based on viewport width
ui.Grid(tiles...).St(ui.St().ColWidth(180).GapOf(12))
ui.Wrap(tags...).St(ui.St().GapOf(8)).JustifyTo(ui.AlignSpaceBetween)
ui.Masonry(cards...).St(ui.St().ColsN(3).GapOf(12))
ui.Pick(narrow, wide, wide, wide)

5. Rendering: One SDF shader for rounded corners, strokes, shadows

Rounded rectangles use an analytic signed distance field (SDF):

float sdRoundBox(vec2 p, vec2 b, vec4 r) {
    r.xy = (p.x > 0.0) ? r.xy : r.zw;
    r.x  = (p.y > 0.0) ? r.x  : r.y;
    vec2 q = abs(p) - b + r.x;
    return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x;
}

Fragment shader splits into three paths by mode:

float d = sdRoundBox(vLocal, vHalf, vRadius);
if (glow > 0.0) {
    // shadow: SDF expands glow pixels outward, distance falloff approximates Gaussian
    float k = clamp(-d / max(glow, 0.001), 0.0, 1.0);
    d = d + glow * (1.0 - k);
}

if (mode < 0.5) {
    alpha = 1.0 - smoothstep(-feather, feather, d);              // fill
} else if (mode < 1.5) {
    alpha = 1.0 - smoothstep(-feather, feather,
            abs(d + strokeW * 0.5) - strokeW * 0.5);             // stroke
} else {
    alpha = (1.0 - smoothstep(-glow, glow, d)) * 0.9;            // shadow
}

One draw completes fill, stroke, and shadow simultaneously. Shadow isn't a separate texture and doesn't need a blur pass: the CPU side expands the rectangle by spread + blur; the fragment does distance falloff.

feather defaults to 0.7 logical pixels, combined with smoothstep for anti-aliased edges:

r.appendQuad(rs, sh.Radius, sh.Fill, Paint{A: -1}, 0.7, 0, 0)

5.1 Gradients and batching

Gradients interpolate two vertex colors; enabled when vColor2.a >= 0:

vec4 col = vColor;
if (vColor2.a >= 0.0) {
    col = mix(vColor, vColor2, vT.x);
}
fragColor = vec4(col.rgb, col.a * alpha);

Batches split on four conditions:

Nested clips intersect; setting the same rectangle repeatedly doesn't break batch:

func (r *Renderer) setClip(c geom.Rect) {
	if r.clipOn && r.clipCur == c {
		return // common in nested scroll containers, avoid useless flush
	}
	r.flush()
	// glScissor origin is bottom-left, flip y
}

5.2 Vertex buffer: orphan + SubData

Early implementation called glBufferData(data) every frame to reallocate the whole buffer; scrolling caused frequent driver allocations and sync with previous frame. Now it only grows when capacity is insufficient; otherwise orphans the buffer and overwrites with glBufferSubData.

func (r *Renderer) uploadVerts(vbo *uint32, capBytes *int, data []float32) {
	n := len(data) * 4
	glBindBuffer(GL_ARRAY_BUFFER, *vbo)
	if *capBytes < n {
		*capBytes = n + n/2 + 4096
	}
	// orphan: break dependency with previous frame
	glBufferAlloc(GL_ARRAY_BUFFER, *capBytes, GL_STREAM_DRAW)
	glBufferSubData(GL_ARRAY_BUFFER, 0, data)
}

Also, initial vertex slice capacity changed from "reserve for 60k vertex hard cap" to "reserve for typical screen":

maxVertsShapeInitial = 8192
maxVertsTextInitial  = 20480

Old implementation allocated 60000×22×4B ≈ 5.3MB + 60000×9×4B ≈ 2.2MB of Go slices on window open, but a real screen usually has only hundreds to a few thousand vertices.


6. Text: Chinese clarity is the lifeline of self-drawn GUI

Text pipeline:

  1. x/image/font/opentype reads TTF/TTC, gets glyph outlines;
  2. Vector outlines rasterize to 8-bit coverage;
  3. Written into RGBA8 glyph atlas (normal glyphs as (v,v,v,255), emoji with real color);
  4. Vertex carries mode; mode=1 outputs texture color directly;
  5. One quad per glyph, UV points to atlas.

6.1 Atlas strategy: start at 256², grow on demand, LRU eviction

This is the biggest memory win.

Old version had fixed 2048² atlas per font = 16MB. A page using a dozen font sizes/weights would eat over 100MB.

Now:

Stage Atlas Per-font memory
Start 256² 256 KB
Overflow 512² 1 MB
Normal cap 1024² 4 MB
Raster scale ≥ 1.75 (supersampling) 2048² 16 MB

Font instance cap is 24; LRU eviction frees corresponding textures:

const maxFonts = 24

func (fc *fontCache) evictOldest() {
	// find least recently used font
	if f, ok := fc.fonts[oldKey]; ok {
		if fc.onEvict != nil {
			fc.onEvict(f) // upper layer releases atlas texture
		}
		f.Close()
		delete(fc.fonts, oldKey)
	}
	// wrap cache keyed by *Font; closing font must invalidate all
	clearWrapMemo()
}

Real measurement (GOGUI_MEMSTATS=1, gallery runtime sample output):

[gogui] frames=120 heap=19.4MB inuse=20.6MB sys=27.7MB gc=2 fonts=13 atlas=3.2MB images=0

13 font instances, atlas total 3.2MB. With old fixed 2048², atlases alone would be 13 × 16MB = 208MB.

6.2 Only upload dirty rectangles

When new glyphs appear, no longer re-upload the whole atlas; record dirty rects and use glTexSubImage2D for partial updates:

if x0, y0, x1, y1, dirty := f.TakeDirty(); dirty {
	r.uploadAtlasRegion(f, t, x0, y0, x1, y1)
}

Whole-row regions upload directly (contiguous memory); otherwise copy row-by-row into a reused buffer and submit. Scrolling constantly reveals new text; this removes the stutter from continuous full uploads.

6.3 Font files use memory mapping

msyh.ttc and similar are mapped with MapViewOfFile; data is handled by system page cache, not Go heap.

6.4 Glyph gamma: why self-drawn text always looks "washed out"

This is the most hidden pit.

Browsers (Skia / DirectWrite) apply gamma correction to coverage before blending in sRGB space, effectively boosting mid-level coverage: a pixel at brightness 0.5 appears near 0.7 in the browser.

Self-drawn rendering usually does straight sRGB blending, so the same coverage looks paler and vaguer; this is especially visible in small Chinese horizontal/vertical strokes.

The fix is to apply gamma to coverage:

float lum = dot(vColor.rgb, vec3(0.2126, 0.7152, 0.0722));
if (uTextGamma != 1.0 && lum > 0.5) {
	a = pow(a, uTextGamma);
}

Only bright text (dark background, light chars) is compensated; dark chars on light background already blend linearly and don't need lifting. Measured 1/1.8 ≈ 0.55 is closest to browser appearance; can be overridden with GOGUI_TEXT_GAMMA.

6.5 Emoji fallback chain

When main font lacks a glyph, fall back in order:

Microsoft YaHei UI → Segoe UI Symbol → GDI color Emoji (Segoe UI Emoji)

After GDI renders to 32bpp DIB, a second white mask fills dark pixels' alpha, then writes into atlas; vertex mode=1 outputs color directly.

6.6 One input box, half of an IME blood-and-tears history

A self-drawn input box isn't just "draw a rectangle + collect keyboard events". It must handle:

IME's three message segments must be wired end-to-end:

case EvCompositionStart:
	a.imeText, a.imeCaret = "", 0
case EvComposition:
	a.imeText, a.imeCaret = ev.Text, ev.Caret   // GCS_COMPSTR
case EvCompositionEnd:
	a.imeText, a.imeCaret = "", 0
// ui/widgets.go (simplified)
if comp := ComposingText(); comp != "" && !password {
	cp := ComposingCaret()
	cr := []rune(comp)
	cells = append(cells, Text(string(cr[:cp])).St(St().Text(13, 400, ColText).BgC(ColBgSoft).R(3)))
	cells = append(cells, Text(string(cr[cp:])).St(St().Text(13, 400, ColText).BgC(ColHover).R(3)))
	showCaret = false // don't blink cursor during composition
}

7. Supersampling: solving "blurry text" and "rough edges" together

Rounded-corner SDF anti-aliasing has only a 1-pixel transition band; small font strokes are worse. The idea of full-frame SSAA is straightforward:

Render the whole frame at N× resolution into an off-screen FBO, then average-downsample back to window pixels.

ui.SetSuperSample(ui.SSAuto) // 2× when DPI < 150%, else 1× (recommended)
ui.SetSuperSample(ui.SS200)  // force 2×
ui.SetSuperSample(ui.SSOff)  // 1:1, lowest GPU cost
// also SS125 / SS150

Downsample is a 4×4 sub-band box filter:

vec2 base = uTexel * (uSpan * 0.125);
vec4 c = vec4(0.0);
for (int y = 0; y < 4; y++) {
    for (int x = 0; x < 4; x++) {
        vec2 o = vec2(float(x) * 2.0 - 3.0, float(y) * 2.0 - 3.0) * base;
        c += texture(uTex, vUV + o);
    }
}
c *= 0.0625;

Logical layout, hit testing, and mouse coordinates stay unchanged; only render resolution changes. But there are four counter-intuitive details.

7.1 Edge transition band must be constant in device pixels

feather was originally 0.7 logical pixels. At 2× supersampling, the canvas is 2× resolution, so 0.7 logical pixels become 1.4 device pixels; after downsampling, edges get softer instead of sharper—supersampling wasted.

So CPU side divides the transition band by the multiplier:

// fill/stroke SDF edge transition width stays constant in device pixels
if r.ssaa > 1.01 && mode < 1.5 && feather > 0 {
	feather /= r.ssaa
}

Fragment also must lower the lower bound from 0.5 to 0.25, otherwise the tightened 0.35 at 2× gets lifted back to 0.5 and half the sharpness is eaten.

7.2 Glyph gamma doesn't converge by multiplier

At first I assumed: after supersampling averaging N² samples, gamma compensation should weaken to 1-(1-g)/ss. Result: at 2× text looked grayer and fuzzier than 1:1.

Why: the downsampler is linear averaging of sub-samples; gamma compensation applied after averaging isn't distorted. So all multipliers use the same compensation value; SSAA only improves geometry/edge precision, not stroke solidity.

7.3 Box averaging makes 1px details "soft"

Mathematically box downsampling is correct, but 1px horizontal/vertical strokes and 1px borders lose contrast after averaging; the human eye judges it as "less sharp than 1:1."

So a light unsharp pass is added after downsampling:

if (uSharpen > 0.0) {
    vec2 o = uTexel * uSpan;
    vec4 blur = (texture(uTex, vUV + vec2(o.x, 0.0)) +
                 texture(uTex, vUV - vec2(o.x, 0.0)) +
                 texture(uTex, vUV + vec2(0.0, o.y)) +
                 texture(uTex, vUV - vec2(0.0, o.y))) * 0.25;
    c = clamp(c + (c - blur) * uSharpen, 0.0, 1.0);
}

Default 0.4 (GOGUI_SS_SHARPEN adjustable); only recovers a small part of the smoothed high-frequency, keeping anti-aliasing without white halos.

7.4 Pixel budget and semi-transparent backgrounds


8. Multimedia: play mp4 without cgo

Playing audio/video doesn't necessarily require linking FFmpeg dynamic libraries. GoGUI's approach: treat ffmpeg as a subprocess.

p, err := ui.NewMediaPlayer("movie.mkv") // or m3u8 direct link
if err != nil { /* ffmpeg not installed / no video stream / link unreachable */ }
defer p.Close() // release ffmpeg process and audio device
p.Play()

ui.VideoPlayer("vp", p).St(ui.St().H(420))

Internally two independent pipelines:

Pipeline Implementation
Video ffmpeg -f rawvideo -pix_fmt rgba → pipe → frame ring (max 48MB / 2 sec) → fetch frame by play position → glTexSubImage2D partial texture update
Audio second ffmpeg process outputs s16le / 48k / stereo → WinMM waveOut

8.1 Use audio as master clock

Video frame rate and audio sample rate can't be aligned exactly. When there's a track, the player uses waveOut played sample count as clock (MediaClock); picture follows audio position to fetch frames; playback speed uses atempo filter to rebuild the whole audio chain.

This is far more stable than "estimate play position with time.Now()"—the latter drifts hundreds of milliseconds after a few minutes.

8.2 Dragging progress bar shouldn't restart ffmpeg on every event

Initial implementation did -ss restart on every mousemove, causing CPU to max out and video to freeze.

Current strategy is MediaScrub:

Dragging no longer stutters; the cost is preview frames aren't perfectly synced during drag—a worthwhile trade-off in a player.

8.3 Fall back to system decode without ffmpeg

ui.OpenMedia first probes PATH for ffmpeg / ffprobe; if absent, uses Windows Media Foundation:

os.Setenv("GOGUI_DECODER", "system")
src, _ := ui.NewMediaPlayer("movie.mp4")
fmt.Println(src.Info().Backend) // system
ffmpeg backend system decode backend
Containers mp4/mkv/mov/avi/webm/flv/ts/m3u8 system-supported containers
Codecs basically all installed MFTs
Playback speed supported (atempo) only 1x
Drag preview single-frame grab keeps last frame

MF pitfall mentioned earlier: all IMFSourceReader calls must be serialized on a single locked thread (win32.MFRun), otherwise creating a second reader causes access violation.


9. Components: reimplementing part of the browser

Self-drawn frameworks easily become "demo looks great, but you're missing ten widgets once you use it." So ui/widgets.go implements common controls, with all state held by the caller—components have no hidden state, can be copied out and used.

9.1 Markdown renderer

ui.Stack(
	ui.Scroll(
		ui.Markdown("md", src, ui.MarkdownOptions{Width: 760}),
	).K("scroll-md"),
	ui.MarkdownLightbox(), // click image for fullscreen preview
)

Supports: headings / paragraphs / bold / italic / strikethrough / inline code / links / images / ordered and unordered lists / task lists / blockquotes and GitHub callouts / code blocks (line numbers + syntax highlight + copy + fold over 22 lines) / tables / horizontal rules / [toc] / [tabs] / [details] / [card] / [badge] / [progress] / [gallery], plus math formulas and Mermaid.

Parser, syntax highlighter, and keyword tables are all hand-written, without goldmark or chroma. Code highlighting covers go / js / ts / py / json / bash / sql / html / css / yaml—ten languages, enough and tiny.

9.2 Charts: 1 draw call when data unchanged

chart := ui.NewChart(ui.ChartLine, labels,
	ui.Series{Name: "This week", Values: week, Color: "#6366f1"},
	ui.Series{Name: "Last week", Values: last, Color: "#22d3ee"},
)
chart.Smooth = true
chart.OnPick = func(series, index int) { /* click data point */ }

ui.ChartView("trend", chart).St(ui.St().H(300))

Charts aren't re-rasterized every frame; they cache one RGBA texture keyed by "node Key + size + scale + data fingerprint":

Supports line / area / bar / pie / donut / radar / scatter; stacked, smooth (Catmull-Rom), custom axis range, OnPick click callback.

9.3 Other built-ins

Category Components
Form Input PasswordInput Checkbox Radio Switch Slider Progress Ring
Display Card Title Divider Tag Dot Avatar Badge KeyValue Empty Skeleton Spinner
Navigation Tabs TabsBar Breadcrumb Accordion
Data Table (fixed/elastic column width, zebra stripes, full-row click) ListItem
Overlay Modal MenuPanel TooltipOf Toast Banner
Media VideoPlayer MusicPlayer (with spectrum / cover / playlist)
Browser WebView + self-drawn address bar / status line

ui/COMPONENTS.md lists file ownership and minimal examples for each component; import by file module.


10. Performance checklist: what's saved is frame time

Optimization How Effect
On-demand redraw choose one of dirty / redrawRequest / animBusy / hoverProbe no draw frames when static
Minimize not-render skip frame when window minimized no GPU burn in background
Skip offscreen scroll containers only traverse visible subtree smoother long-list scrolling
Hover dedup mouse move only does hit test; no redraw if hover target unchanged no jitter when mouse passes
VBO reuse orphan + SubData when capacity sufficient reduces driver allocation and sync
Atlas partial upload dirty rect glTexSubImage2D new glyphs no longer full re-upload
Async image decode background goroutine decode (max 4 concurrent), UI thread only uploads texture many images don't stutter
Font mmap MapViewOfFile not on Go heap
Media frame ring cap 48MB / 2 sec memory controlled for long videos

GOGUI_MEMSTATS=1 prints memory composition every 120 frames:

[gogui] frames=120 heap=19.4MB inuse=20.6MB sys=27.7MB gc=2 fonts=13 atlas=3.2MB images=0

11. Pitfall collection: things documentation won't write

11.1 DirectComposition vtable slots are reversed

To let WebView2 web content be covered by self-drawn menus, DComp composition is needed. dcomp runtime's vtable puts "animated" variants before "float" variants, opposite to SDK headers:

SetOffsetX(float) = 4
SetOffsetY(float) = 6
SetClip           = 14
Float versions of rect clip (left/right/top/bottom/corner radii) all on even slots (4/6/8/10/12…26)

Using 3/5/7/9/11/13 would treat float as animation pointer and dereference, crashing at addr=0x50. This took a long time to find because the crash site didn't look related to DComp.

11.2 Go linker only accepts one .rsrc section

Icon and version info must be generated by the same syso:

too many .rsrc sections

tools/winres directly outputs COFF .syso, writing RT_ICON + RT_GROUP_ICON + RT_VERSION into one .rsrc section; no windres, no cgo.

11.3 NSIS script containing Chinese must be UTF-8 with BOM

Otherwise makensis errors:

Bad text encoding

11.4 InstallDirRegKey pollutes directory page

It stuffs old directory into $INSTDIR before .onInit, causing "install for all users" to mistakenly use current user's old path. Correct approach: call SetScope only when switching scope.

11.5 Don't assign $INSTDIR inside install Section

That overwrites the path user manually selected on directory page. Custom path must be preserved fully; scope switching only switches between two default directories.

11.6 WebView2's HWND sits above OpenGL content

In HWND hosting mode, web pixels don't enter the GL batch, so:

Current strategy: temporarily hide web page when overlay expands; try DComp composition (GOGUI_COMPOSE=1, experimental), auto-fallback on failure.

11.7 WM_DROPFILES has no dragover

System only posts a message on release, no dragenter/dragover, so hover highlight isn't possible. For hover feedback, must use OLE IDropTarget.

11.8 Media Foundation must be serial on same thread

IMFSourceReader is extremely sensitive to cross-thread scheduling. All calls (create / ReadSample / Seek / Release) must go through win32.MFRun serialized on one locked thread, otherwise creating a second reader causes access violation.

11.9 1px white border on borderless windows

NoTitleBar windows need to explicitly set DWMWA_BORDER_COLOR to DWMWA_COLOR_NONE, otherwise DWM draws a 1px white system border, very jarring on dark interface.


12. Size and release: 6.45MB → 1.52MB

build.ps1 sets release parameters:

./build.ps1                 # gallery -> dist\gallery.exe
./build.ps1 -Target all     # gallery + demo + tools
./build.ps1 -Upx            # plus UPX -9
./build.ps1 -Dev            # dev build: keep symbols + console logs
./build.ps1 -Installer      # build + NSIS installer
./build.ps1 -Bump patch -Installer

Measured (gallery, go1.26 windows/amd64):

Build Size Notes
go build (default) 6.45 MB with symbol table, debug info, local paths
build.ps1 4.54 MB −30%, strips symbols/debug info and build paths
build.ps1 -Upx 1.52 MB −76%, self-extracts on start (occasional AV false positive)

Key flags: -trimpath -buildvcs=false, -s -w -H windowsgui -buildid=, CGO_ENABLED=0.

12.1 Version number written in one place

VERSION.txt is the single source; one build injects into three locations:

Location Injection Read
Runtime -ldflags -X gogui/internal/version.Number=... version.String()
exe file properties tools/winres generates syso right-click → Details
Installer makensis /DVERSION=... version in "Apps & features"
version.String()   // v0.1.0
version.Title()    // GoGUI v0.1.0 (window title)
version.Full()     // v0.1.0 · release · 1a2b3c4 · 2026-10-29T03:49:04Z

12.2 Installer supports two scopes

Option Install dir Registry Permission
Install for me only (default) %LOCALAPPDATA%\Programs\GoGUI HKCU no admin
Install for all users %ProgramFiles%\GoGUI HKLM UAC re-entry if not elevated

Silent install:

GoGUI-0.1.0-setup.exe /S /currentuser /D=D:\Apps\GoGUI
GoGUI-0.1.0-setup.exe /S /allusers

/D must be last argument, no quotes around path.


13. Cross-platform: swap backend without changing business code

internal/gpu.Backend is abstracted; adding a platform only needs window layer + render layer:

Platform Window layer Render layer Status
Windows Win32 OpenGL 3.3 done
macOS Cocoa + CAMetalLayer Metal / OpenGL 4.1 planned
Linux X11 / Wayland OpenGL / Vulkan planned
Android ANativeWindow + EGL GLES 3 planned
Web canvas WebGL 2 + WASM adapter layer in repo

WASM side reuses same ui tree: internal/gpu/glport_wasm.go provides WebGL2 shader/program/buffer/texture wrappers, ui/*_wasm.go provides platform stubs.