From e9c2c91420474af66d1c9d397e28998713dfa53c Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Wed, 8 Oct 2025 16:59:04 -0400 Subject: [PATCH 1/2] allow text atlas to be exported and replaced to support batching --- ext/text/atlas.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/ext/text/atlas.go b/ext/text/atlas.go index 1ce1b29..892a14d 100644 --- a/ext/text/atlas.go +++ b/ext/text/atlas.go @@ -2,6 +2,7 @@ package text import ( "image" + "image/color" "image/draw" "sort" "unicode" @@ -24,7 +25,7 @@ type Glyph struct { // Atlas is a set of pre-drawn glyphs of a fixed set of runes. This allows for efficient text drawing. type Atlas struct { face font.Face - pic pixel.Picture + pic *pixel.PictureData mapping map[rune]Glyph ascent float64 descent float64 @@ -104,6 +105,52 @@ func (a *Atlas) Picture() pixel.Picture { return a.pic } +// PictureDataCopy returns a full copy of the underlying picture data of the Atlas. +func (a *Atlas) PictureDataCopy() *pixel.PictureData { + newPic := &pixel.PictureData{ + Stride: a.pic.Stride, + Rect: a.pic.Rect, + Pix: make([]color.RGBA, len(a.pic.Pix)), + } + copy(newPic.Pix, a.pic.Pix) + return newPic +} + +// CloneWithPictureData returns a new Atlas with the same glyphs but utilizing the supplied PictureData +func (a *Atlas) CloneWithPictureData(pic *pixel.PictureData, frame pixel.Rect) *Atlas { + if a.pic.Bounds().W() != frame.W() || a.pic.Bounds().H() != frame.H() { + panic("atlas: new frame dimensions do no match prior picture") + } + if !pic.Bounds().Contains(frame.Min) || !pic.Bounds().Contains(frame.Max) { + panic("atlas: new frame is out of bounds of supplied pic") + } + newAtlas := &Atlas{ + face: a.face, + pic: pic, + mapping: make(map[rune]Glyph, len(a.mapping)), + ascent: a.ascent, + descent: a.descent, + lineHeight: a.lineHeight, + } + // account for non (0,0) origin images + picFrameDelta := frame.Min.Sub(a.pic.Rect.Min) + // for each glyph, translate the dot and frame to account for the new frame location within the supplied pic + for r, glyph := range a.mapping { + rMin := glyph.Frame.Min.Add(picFrameDelta) + rMax := rMin.Add(pixel.V(glyph.Frame.W(), glyph.Frame.H())) + newFrame := pixel.Rect{ + Min: rMin, + Max: rMax, + } + newAtlas.mapping[r] = Glyph{ + Dot: glyph.Dot.Add(picFrameDelta), + Frame: newFrame, + Advance: glyph.Advance, + } + } + return newAtlas +} + // Contains reports wheter r in contained within the Atlas. func (a *Atlas) Contains(r rune) bool { _, ok := a.mapping[r] From 551020cbd6b8954f2a6569ed8878a8838af1148e Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Mon, 20 Apr 2026 22:38:17 -0400 Subject: [PATCH 2/2] Fix typo, improve docs, add tests for PictureDataCopy and CloneWithPictureData Fix panic message typo ("do no match" -> "do not match"), expand doc comments to document the frame argument and batch-sharing use case, and add table-driven tests covering deep-copy semantics for PictureDataCopy and identity/translated/wrong-size cases for CloneWithPictureData. --- ext/text/atlas.go | 13 +++-- ext/text/atlas_test.go | 114 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/ext/text/atlas.go b/ext/text/atlas.go index 892a14d..8664df5 100644 --- a/ext/text/atlas.go +++ b/ext/text/atlas.go @@ -105,7 +105,8 @@ func (a *Atlas) Picture() pixel.Picture { return a.pic } -// PictureDataCopy returns a full copy of the underlying picture data of the Atlas. +// PictureDataCopy returns a deep copy of the atlas's underlying pixel data. The caller +// owns the returned PictureData and may modify it without affecting the Atlas. func (a *Atlas) PictureDataCopy() *pixel.PictureData { newPic := &pixel.PictureData{ Stride: a.pic.Stride, @@ -116,10 +117,16 @@ func (a *Atlas) PictureDataCopy() *pixel.PictureData { return newPic } -// CloneWithPictureData returns a new Atlas with the same glyphs but utilizing the supplied PictureData +// CloneWithPictureData returns a new Atlas that uses pic as its backing image. +// frame is the sub-rectangle of pic where the atlas glyphs live; it must have +// the same width and height as the original atlas picture. All glyph coordinates +// are translated so they remain correct relative to the new location in pic. +// +// The intended use is to blit PictureDataCopy into a larger shared atlas image and +// then call CloneWithPictureData so text can share a pixel.Batch with other sprites. func (a *Atlas) CloneWithPictureData(pic *pixel.PictureData, frame pixel.Rect) *Atlas { if a.pic.Bounds().W() != frame.W() || a.pic.Bounds().H() != frame.H() { - panic("atlas: new frame dimensions do no match prior picture") + panic("atlas: new frame dimensions do not match prior picture") } if !pic.Bounds().Contains(frame.Min) || !pic.Bounds().Contains(frame.Max) { panic("atlas: new frame is out of bounds of supplied pic") diff --git a/ext/text/atlas_test.go b/ext/text/atlas_test.go index 97746c3..86b039c 100644 --- a/ext/text/atlas_test.go +++ b/ext/text/atlas_test.go @@ -1,8 +1,10 @@ package text_test import ( + "image/color" "testing" + "github.com/gopxl/pixel/v2" "github.com/gopxl/pixel/v2/ext/text" "golang.org/x/image/font/inconsolata" ) @@ -27,3 +29,115 @@ func TestAtlas7x13(t *testing.T) { func TestAtlasInconsolata(t *testing.T) { text.NewAtlas(inconsolata.Regular8x16, text.ASCII) } + +func TestAtlasPictureDataCopy(t *testing.T) { + a := text.NewAtlas(inconsolata.Regular8x16, text.ASCII) + orig := a.Picture().(*pixel.PictureData) + + cp := a.PictureDataCopy() + if cp == orig { + t.Fatal("PictureDataCopy returned the same pointer as the original") + } + if cp.Stride != orig.Stride { + t.Errorf("Stride mismatch: got %d, want %d", cp.Stride, orig.Stride) + } + if cp.Rect != orig.Rect { + t.Errorf("Rect mismatch: got %v, want %v", cp.Rect, orig.Rect) + } + if len(cp.Pix) != len(orig.Pix) { + t.Fatalf("Pix length mismatch: got %d, want %d", len(cp.Pix), len(orig.Pix)) + } + // Verify deep copy: mutating the copy does not affect the original. + if len(cp.Pix) > 0 { + origPix := orig.Pix[0] + cp.Pix[0] = color.RGBA{R: ^origPix.R, G: ^origPix.G, B: ^origPix.B, A: ^origPix.A} + if orig.Pix[0] != origPix { + t.Error("PictureDataCopy is not a deep copy: mutating the copy affected the original") + } + } +} + +func TestAtlasCloneWithPictureData(t *testing.T) { + a := text.NewAtlas(inconsolata.Regular8x16, text.ASCII) + tests := []struct { + name string + offset pixel.Vec + shouldPanic bool + panicMessage string + }{ + { + name: "identity (frame == original bounds)", + offset: pixel.ZV, + }, + { + name: "translated into larger shared picture", + offset: pixel.V(50, 30), + }, + { + name: "wrong frame size panics", + shouldPanic: true, + panicMessage: "atlas: new frame dimensions do not match prior picture", + }, + } + + origBounds := a.Picture().Bounds() + origW, origH := origBounds.W(), origBounds.H() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.shouldPanic { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic, got none") + } + if msg, ok := r.(string); !ok || msg != tt.panicMessage { + t.Errorf("wrong panic: %v", r) + } + }() + // Supply a frame with wrong dimensions. + wrongPic := pixel.MakePictureData(pixel.R(0, 0, origW+1, origH)) + wrongFrame := pixel.R(0, 0, origW+1, origH) + a.CloneWithPictureData(wrongPic, wrongFrame) + return + } + + // Build a shared picture large enough to hold the atlas at the given offset. + sharedW := origW + tt.offset.X + sharedH := origH + tt.offset.Y + sharedPic := pixel.MakePictureData(pixel.R(0, 0, sharedW, sharedH)) + frame := pixel.R(tt.offset.X, tt.offset.Y, tt.offset.X+origW, tt.offset.Y+origH) + + clone := a.CloneWithPictureData(sharedPic, frame) + + // The clone must contain every rune the original does. + for _, r := range text.ASCII { + if !clone.Contains(r) { + t.Errorf("clone does not contain rune %q", r) + } + } + + // Glyph coordinates must be shifted by the frame offset relative to + // the original atlas origin. + delta := frame.Min.Sub(origBounds.Min) + for _, r := range text.ASCII { + if !a.Contains(r) { + continue + } + origGlyph := a.Glyph(r) + cloneGlyph := clone.Glyph(r) + wantDot := origGlyph.Dot.Add(delta) + if cloneGlyph.Dot != wantDot { + t.Errorf("rune %q: Dot got %v, want %v", r, cloneGlyph.Dot, wantDot) + } + wantFrame := origGlyph.Frame.Moved(delta) + if cloneGlyph.Frame != wantFrame { + t.Errorf("rune %q: Frame got %v, want %v", r, cloneGlyph.Frame, wantFrame) + } + if cloneGlyph.Advance != origGlyph.Advance { + t.Errorf("rune %q: Advance got %v, want %v", r, cloneGlyph.Advance, origGlyph.Advance) + } + } + }) + } +}