UI, HUD & GUI Component Architecture Reference #
This module covers premultiplied alpha color invariants, 9-slice panel rendering, flexbox anchoring math, zero-allocation progress/status indicators, and interactive widget state machines using github.com/hajimehoshi/ebiten/v2/text/v2.
1. Premultiplied Alpha Invariant (color.RGBA vs. color.NRGBA) #
In Go’s standard image/color package and Ebitengine’s rendering pipeline, color.RGBA represents alpha-premultiplied 8-bit color channels, whereas color.NRGBA represents non-premultiplied (straight alpha) channels.
1.1 The R, G, B <= A Invariant #
For every valid color.RGBA{R, G, B, A}, the color channels must satisfy:
Passing a non-premultiplied literal such as color.RGBA{255, 0, 0, 100} violates $R \le A$ ($255 > 100$), causing additive blowout, corrupted blending, or glowing artifacts in Ebitengine shaders and ColorScale operations.
package ui
import (
"fmt"
"image/color"
)
// Premultiplied Alpha Examples:
var (
// WRONG: R (255) > A (100) violates premultiplied alpha invariant!
// InvalidTranslucentRed = color.RGBA{255, 0, 0, 100}
// CORRECT Option A: Use premultiplied color.RGBA where R, G, B <= A
ValidTranslucentRedRGBA = color.RGBA{R: 100, G: 0, B: 0, A: 100}
// CORRECT Option B: Use straight-alpha color.NRGBA (Ebitengine converts on ColorScale)
ValidTranslucentRedNRGBA = color.NRGBA{R: 255, G: 0, B: 0, A: 100}
)
// NewPremultipliedRGBA constructs a valid premultiplied color.RGBA from straight RGB and alpha,
// failing fast if any channel is out of bounds.
func NewPremultipliedRGBA(r, g, b, a uint8) color.RGBA {
alpha := uint16(a)
return color.RGBA{
R: uint8((uint16(r)*alpha + 127) / 255),
G: uint8((uint16(g)*alpha + 127) / 255),
B: uint8((uint16(b)*alpha + 127) / 255),
A: a,
}
}
// ValidatePremultipliedRGBA fails fast if a color.RGBA violates R, G, B <= A.
func ValidatePremultipliedRGBA(c color.RGBA) error {
if c.R > c.A || c.G > c.A || c.B > c.A {
return fmt.Errorf("ui: invalid premultiplied color.RGBA{%d, %d, %d, %d}: RGB channels must not exceed A (%d)", c.R, c.G, c.B, c.A, c.A)
}
return nil
}2. Zero-Allocation 9-Slice Panel Scaling #
9-slice rendering scales UI dialogs and containers to any width/height without stretching corner pixels. Pre-slice the 9 sub-images once at construction time (NewNineSlice) so Draw() performs zero heap allocations:
package ui
import (
"fmt"
"image"
"github.com/hajimehoshi/ebiten/v2"
)
type NineSlice struct {
patches [9]*ebiten.Image
CornerW, CornerH int
CenterW, CenterH int
drawOp ebiten.DrawImageOptions
}
func NewNineSlice(img *ebiten.Image, cornerW, cornerH, centerW, centerH int) (*NineSlice, error) {
if img == nil {
return nil, fmt.Errorf("ui: nil source image for NineSlice")
}
if cornerW <= 0 || cornerH <= 0 || centerW <= 0 || centerH <= 0 {
return nil, fmt.Errorf("ui: invalid NineSlice dimensions (corner=%dx%d, center=%dx%d)", cornerW, cornerH, centerW, centerH)
}
totalW := cornerW*2 + centerW
totalH := cornerH*2 + centerH
b := img.Bounds()
if b.Dx() < totalW || b.Dy() < totalH {
return nil, fmt.Errorf("ui: image bounds (%dx%d) smaller than required NineSlice grid (%dx%d)", b.Dx(), b.Dy(), totalW, totalH)
}
x0, x1, x2, x3 := b.Min.X, b.Min.X+cornerW, b.Min.X+cornerW+centerW, b.Min.X+totalW
y0, y1, y2, y3 := b.Min.Y, b.Min.Y+cornerH, b.Min.Y+cornerH+centerH, b.Min.Y+totalH
rects := [9]image.Rectangle{
image.Rect(x0, y0, x1, y1), // 0: Top-Left
image.Rect(x1, y0, x2, y1), // 1: Top-Center
image.Rect(x2, y0, x3, y1), // 2: Top-Right
image.Rect(x0, y1, x1, y2), // 3: Middle-Left
image.Rect(x1, y1, x2, y2), // 4: Center
image.Rect(x2, y1, x3, y2), // 5: Middle-Right
image.Rect(x0, y2, x1, y3), // 6: Bottom-Left
image.Rect(x1, y2, x2, y3), // 7: Bottom-Center
image.Rect(x2, y2, x3, y3), // 8: Bottom-Right
}
ns := &NineSlice{
CornerW: cornerW,
CornerH: cornerH,
CenterW: centerW,
CenterH: centerH,
}
for i, r := range rects {
ns.patches[i] = img.SubImage(r).(*ebiten.Image)
}
return ns, nil
}
func (ns *NineSlice) Draw(screen *ebiten.Image, destX, destY, width, height float64) {
cw, ch := float64(ns.CornerW), float64(ns.CornerH)
sw := width - (cw * 2)
sh := height - (ch * 2)
if sw <= 0 || sh <= 0 {
return
}
coords := [9][4]float64{
{destX, destY, cw, ch},
{destX + cw, destY, sw, ch},
{destX + cw + sw, destY, cw, ch},
{destX, destY + ch, cw, sh},
{destX + cw, destY + ch, sw, sh},
{destX + cw + sw, destY + ch, cw, sh},
{destX, destY + ch + sh, cw, ch},
{destX + cw, destY + ch + sh, sw, ch},
{destX + cw + sw, destY + ch + sh, cw, ch},
}
for i, c := range coords {
patch := ns.patches[i]
pb := patch.Bounds()
ns.drawOp.GeoM.Reset()
ns.drawOp.GeoM.Scale(c[2]/float64(pb.Dx()), c[3]/float64(pb.Dy()))
ns.drawOp.GeoM.Translate(c[0], c[1])
screen.DrawImage(patch, &ns.drawOp)
}
}3. Anchoring & Layout Math #
Anchor UI elements relative to virtual canvas edges with fail-fast validation on anchor types:
type AnchorType int
const (
AnchorTopLeft AnchorType = iota
AnchorTopCenter
AnchorTopRight
AnchorCenter
AnchorBottomLeft
AnchorBottomCenter
AnchorBottomRight
)
func GetAnchoredPosition(anchor AnchorType, screenW, screenH, elementW, elementH, offsetX, offsetY float64) (float64, float64, error) {
switch anchor {
case AnchorTopLeft:
return offsetX, offsetY, nil
case AnchorTopCenter:
return (screenW-elementW)*0.5 + offsetX, offsetY, nil
case AnchorTopRight:
return screenW - elementW - offsetX, offsetY, nil
case AnchorCenter:
return (screenW-elementW)*0.5 + offsetX, (screenH-elementH)*0.5 + offsetY, nil
case AnchorBottomLeft:
return offsetX, screenH - elementH - offsetY, nil
case AnchorBottomCenter:
return (screenW-elementW)*0.5 + offsetX, screenH - elementH - offsetY, nil
case AnchorBottomRight:
return screenW - elementW - offsetX, screenH - elementH - offsetY, nil
default:
return 0, 0, fmt.Errorf("ui: unsupported AnchorType %d", anchor)
}
}4. Zero-Allocation Progress & Status Indicators #
Never call ebiten.NewImage inside Draw(). Pre-allocate a shared 1×1 white pixel image at boot and scale/tint it via ebiten.DrawImageOptions:
type ProgressBar struct {
X, Y, Width, Height float64
CurrentVal, MaxVal float64
FillColor, BgColor color.RGBA
whitePixel *ebiten.Image
drawOp ebiten.DrawImageOptions
}
func NewProgressBar(whitePixel *ebiten.Image, x, y, width, height, maxVal float64, fillColor, bgColor color.RGBA) (*ProgressBar, error) {
if whitePixel == nil {
return nil, fmt.Errorf("ui: nil whitePixel texture for ProgressBar")
}
if width <= 0 || height <= 0 {
return nil, fmt.Errorf("ui: ProgressBar dimensions must be > 0 (got %.1fx%.1f)", width, height)
}
if maxVal <= 0 {
return nil, fmt.Errorf("ui: ProgressBar maxVal must be > 0 (got %.2f)", maxVal)
}
if err := ValidatePremultipliedRGBA(fillColor); err != nil {
return nil, fmt.Errorf("ui: ProgressBar fillColor: %w", err)
}
if err := ValidatePremultipliedRGBA(bgColor); err != nil {
return nil, fmt.Errorf("ui: ProgressBar bgColor: %w", err)
}
return &ProgressBar{
X: x,
Y: y,
Width: width,
Height: height,
CurrentVal: maxVal,
MaxVal: maxVal,
FillColor: fillColor,
BgColor: bgColor,
whitePixel: whitePixel,
}, nil
}
func (pb *ProgressBar) Draw(screen *ebiten.Image) {
// 1. Background bar (scaled 1x1 white pixel, zero allocations)
pb.drawOp.GeoM.Reset()
pb.drawOp.GeoM.Scale(pb.Width, pb.Height)
pb.drawOp.GeoM.Translate(pb.X, pb.Y)
pb.drawOp.ColorScale.Reset()
pb.drawOp.ColorScale.ScaleWithColor(pb.BgColor)
screen.DrawImage(pb.whitePixel, &pb.drawOp)
// 2. Foreground fill bar
ratio := pb.CurrentVal / pb.MaxVal
if ratio < 0 {
ratio = 0
} else if ratio > 1 {
ratio = 1
}
fillW := pb.Width * ratio
if fillW > 0 {
pb.drawOp.GeoM.Reset()
pb.drawOp.GeoM.Scale(fillW, pb.Height)
pb.drawOp.GeoM.Translate(pb.X, pb.Y)
pb.drawOp.ColorScale.Reset()
pb.drawOp.ColorScale.ScaleWithColor(pb.FillColor)
screen.DrawImage(pb.whitePixel, &pb.drawOp)
}
}5. Interactive Button Widget with ebiten/v2/text/v2 #
Manage StateNormal, StateHover, StatePressed, and StateDisabled states and render centered button labels using github.com/hajimehoshi/ebiten/v2/text/v2:
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
)
type ButtonState int
const (
StateNormal ButtonState = iota
StateHover
StatePressed
StateDisabled
)
type Button struct {
X, Y, Width, Height float64
State ButtonState
Label string
Face *text.GoTextFace
textOp text.DrawOptions
}
func (b *Button) Update(cursorX, cursorY float64, isClicked bool) bool {
if b.State == StateDisabled {
return false
}
isHovered := b.X <= cursorX && cursorX <= b.X+b.Width &&
b.Y <= cursorY && cursorY <= b.Y+b.Height
if isHovered {
if isClicked {
b.State = StatePressed
return true
}
b.State = StateHover
} else {
b.State = StateNormal
}
return false
}
func (b *Button) DrawLabel(screen *ebiten.Image, textColor color.RGBA) {
b.textOp.GeoM.Reset()
b.textOp.GeoM.Translate(b.X+b.Width*0.5, b.Y+b.Height*0.5)
b.textOp.ColorScale.Reset()
b.textOp.ColorScale.ScaleWithColor(textColor)
b.textOp.LayoutOptions.PrimaryAlign = text.AlignCenter
b.textOp.LayoutOptions.SecondaryAlign = text.AlignCenter
text.Draw(screen, b.Label, b.Face, &b.textOp)
}