Skip to content

Commit 95246a7

Browse files
committed
pixel: add package for efficiently working with raw pixel buffers
This has been optimized for working with SPI displays like the ST7789. By working directly in the native color format of the display, graphics operations can be much, _much_ faster. Also, this makes it easier to use a different color format like RGB444 simply by changing the generic type.
1 parent 47dfeb9 commit 95246a7

File tree

3 files changed

+449
-0
lines changed

3 files changed

+449
-0
lines changed

pixel/image.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package pixel
2+
3+
import (
4+
"unsafe"
5+
)
6+
7+
// Image buffer, used for working with the native image format of various
8+
// displays. It works a lot like a slice: it can be rescaled while reusing the
9+
// underlying buffer and should be passed around by value.
10+
type Image[T Color] struct {
11+
width int16
12+
height int16
13+
data unsafe.Pointer
14+
}
15+
16+
// NewImage creates a new image of the given size.
17+
func NewImage[T Color](width, height int) Image[T] {
18+
var zeroColor T
19+
var data unsafe.Pointer
20+
if zeroColor.BitsPerPixel()%8 == 0 {
21+
// Typical formats like RGB888 and RGB565.
22+
// Each color starts at a whole byte offset from the start.
23+
buf := make([]T, width*height)
24+
data = unsafe.Pointer(&buf[0])
25+
} else {
26+
// Formats like RGB444 that have 12 bits per pixel.
27+
// We access these as bytes, so allocate the buffer as a byte slice.
28+
bufBits := width * height * zeroColor.BitsPerPixel()
29+
bufBytes := (bufBits + 7) / 8
30+
buf := make([]byte, bufBytes)
31+
data = unsafe.Pointer(&buf[0])
32+
}
33+
return Image[T]{
34+
width: int16(width),
35+
height: int16(height),
36+
data: data,
37+
}
38+
}
39+
40+
// Rescale returns a new Image buffer based on the img buffer.
41+
// The contents is undefined after the Rescale operation, and any modification
42+
// to the returned image will overwrite the underlying image buffer in undefined
43+
// ways. It will panic if width*height is larger than img.Len().
44+
func (img Image[T]) Rescale(width, height int) Image[T] {
45+
if width*height > img.Len() {
46+
panic("Image.Rescale size out of bounds")
47+
}
48+
return Image[T]{
49+
width: int16(width),
50+
height: int16(height),
51+
data: img.data,
52+
}
53+
}
54+
55+
// LimitHeight returns a subimage with the bottom part cut off, as specified by
56+
// height.
57+
func (img Image[T]) LimitHeight(height int) Image[T] {
58+
if height < 0 || height > int(img.height) {
59+
panic("Image.LimitHeight: out of bounds")
60+
}
61+
return Image[T]{
62+
width: img.width,
63+
height: int16(height),
64+
data: img.data,
65+
}
66+
}
67+
68+
// Len returns the number of pixels in this image buffer.
69+
func (img Image[T]) Len() int {
70+
return int(img.width) * int(img.height)
71+
}
72+
73+
// RawBuffer returns a byte slice that can be written directly to the screen
74+
// using DrawRGBBitmap8.
75+
func (img Image[T]) RawBuffer() []uint8 {
76+
var zeroColor T
77+
var numBytes int
78+
if zeroColor.BitsPerPixel()%8 == 0 {
79+
// Each color starts at a whole byte offset.
80+
numBytes = int(unsafe.Sizeof(zeroColor)) * int(img.width) * int(img.height)
81+
} else {
82+
// Formats like RGB444 that aren't a whole number of bytes.
83+
numBits := zeroColor.BitsPerPixel() * int(img.width) * int(img.height)
84+
numBytes = (numBits + 7) / 8 // round up (see NewImage)
85+
}
86+
return unsafe.Slice((*byte)(img.data), numBytes)
87+
}
88+
89+
// Size returns the image size.
90+
func (img Image[T]) Size() (int, int) {
91+
return int(img.width), int(img.height)
92+
}
93+
94+
func (img Image[T]) setPixel(index int, c T) {
95+
var zeroColor T
96+
97+
if zeroColor.BitsPerPixel()%8 == 0 {
98+
// Each color starts at a whole byte offset.
99+
// This is the easy case.
100+
offset := index * int(unsafe.Sizeof(zeroColor))
101+
ptr := unsafe.Add(img.data, offset)
102+
*((*T)(ptr)) = c
103+
return
104+
}
105+
106+
if c, ok := any(c).(RGB444BE); ok {
107+
// Special case for RGB444.
108+
bitIndex := index * zeroColor.BitsPerPixel()
109+
if bitIndex%8 == 0 {
110+
byteOffset := bitIndex / 8
111+
ptr := (*[2]byte)(unsafe.Add(img.data, byteOffset))
112+
ptr[0] = uint8(c >> 4)
113+
ptr[1] = ptr[1]&0x0f | uint8(c)<<4 // change top bits
114+
} else {
115+
byteOffset := bitIndex / 8
116+
ptr := (*[2]byte)(unsafe.Add(img.data, byteOffset))
117+
ptr[0] = ptr[0]&0xf0 | uint8(c>>8) // change bottom bits
118+
ptr[1] = uint8(c)
119+
}
120+
return
121+
}
122+
123+
// TODO: the code for RGB444 should be generalized to support any bit size.
124+
panic("todo: setPixel for odd bits per pixel")
125+
}
126+
127+
// Set sets the pixel at x, y to the given color.
128+
// Use FillSolidColor to efficiently fill the entire image buffer.
129+
func (img Image[T]) Set(x, y int, c T) {
130+
index := y*int(img.width) + x
131+
img.setPixel(index, c)
132+
}
133+
134+
// Get returns the color at the given index.
135+
func (img Image[T]) Get(x, y int) T {
136+
var zeroColor T
137+
index := y*int(img.width) + x // index into img.data
138+
139+
if zeroColor.BitsPerPixel()%8 == 0 {
140+
// Colors like RGB565, RGB888, etc.
141+
offset := index * int(unsafe.Sizeof(zeroColor))
142+
ptr := unsafe.Add(img.data, offset)
143+
return *((*T)(ptr))
144+
}
145+
146+
if _, ok := any(zeroColor).(RGB444BE); ok {
147+
// Special case for RGB444 that isn't stored in a neat byte multiple.
148+
bitIndex := index * zeroColor.BitsPerPixel()
149+
var c RGB444BE
150+
if bitIndex%8 == 0 {
151+
byteOffset := bitIndex / 8
152+
ptr := (*[2]byte)(unsafe.Add(img.data, byteOffset))
153+
c |= RGB444BE(ptr[0]) << 4
154+
c |= RGB444BE(ptr[1] >> 4) // load top bits
155+
} else {
156+
byteOffset := bitIndex / 8
157+
ptr := (*[2]byte)(unsafe.Add(img.data, byteOffset))
158+
c |= RGB444BE(ptr[0]&0x0f) << 8 // load bottom bits
159+
c |= RGB444BE(ptr[1])
160+
}
161+
return any(c).(T)
162+
}
163+
164+
// TODO: generalize the above code.
165+
panic("todo: Image.Get for odd bits per pixel")
166+
}
167+
168+
// FillSolidColor fills the entire image with the given color.
169+
// This may be faster than setting individual pixels.
170+
func (img Image[T]) FillSolidColor(color T) {
171+
var zeroColor T
172+
173+
// Fast pass for colors of 8, 16, 24, etc bytes in size.
174+
if zeroColor.BitsPerPixel()%8 == 0 {
175+
ptr := img.data
176+
for i := 0; i < img.Len(); i++ {
177+
// TODO: this can be optimized a lot.
178+
// - The store can be done as a 32-bit integer, after checking for
179+
// alignment.
180+
// - Perhaps the loop can be unrolled to improve copy performance.
181+
*(*T)(ptr) = color
182+
ptr = unsafe.Add(ptr, unsafe.Sizeof(zeroColor))
183+
}
184+
return
185+
}
186+
187+
// Special case for RGB444.
188+
if c, ok := any(color).(RGB444BE); ok {
189+
// RGB444 can be stored in a more optimized way, by storing two colors
190+
// at a time instead of setting each color individually. This avoids
191+
// loading and masking the old color bits for the half-bytes.
192+
var buf [3]uint8
193+
buf[0] = uint8(c >> 4)
194+
buf[1] = uint8(c)<<4 | uint8(c>>8)
195+
buf[2] = uint8(c)
196+
rawBuf := unsafe.Slice((*[3]byte)(img.data), img.Len()/2)
197+
for i := 0; i < len(rawBuf); i++ {
198+
rawBuf[i] = buf
199+
}
200+
if img.Len()%2 != 0 {
201+
// The image contains an uneven number of pixels.
202+
// This is uncommon, but it can happen and we have to handle it.
203+
img.setPixel(img.Len()-1, color)
204+
}
205+
return
206+
}
207+
208+
// Fallback for other color formats.
209+
for i := 0; i < img.Len(); i++ {
210+
img.setPixel(i, color)
211+
}
212+
}

pixel/image_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package pixel_test
2+
3+
import (
4+
"image/color"
5+
"testing"
6+
7+
"tinygo.org/x/drivers/pixel"
8+
)
9+
10+
func TestImageRGB565BE(t *testing.T) {
11+
image := pixel.NewImage[pixel.RGB565BE](5, 3)
12+
if width, height := image.Size(); width != 5 && height != 3 {
13+
t.Errorf("image.Size(): expected 5, 3 but got %d, %d", width, height)
14+
}
15+
for _, c := range []color.RGBA{
16+
{R: 0xff, A: 0xff},
17+
{G: 0xff, A: 0xff},
18+
{B: 0xff, A: 0xff},
19+
{R: 0x10, A: 0xff},
20+
{G: 0x10, A: 0xff},
21+
{B: 0x10, A: 0xff},
22+
} {
23+
image.Set(4, 2, pixel.NewColor[pixel.RGB565BE](c.R, c.G, c.B))
24+
c2 := image.Get(4, 2).RGBA()
25+
if c2 != c {
26+
t.Errorf("failed to roundtrip color: expected %v but got %v", c, c2)
27+
}
28+
}
29+
}
30+
31+
func TestImageRGB444BE(t *testing.T) {
32+
image := pixel.NewImage[pixel.RGB444BE](5, 3)
33+
if width, height := image.Size(); width != 5 && height != 3 {
34+
t.Errorf("image.Size(): expected 5, 3 but got %d, %d", width, height)
35+
}
36+
for _, c := range []color.RGBA{
37+
{R: 0xff, A: 0xff},
38+
{G: 0xff, A: 0xff},
39+
{B: 0xff, A: 0xff},
40+
{R: 0x11, A: 0xff},
41+
{G: 0x11, A: 0xff},
42+
{B: 0x11, A: 0xff},
43+
} {
44+
encoded := pixel.NewColor[pixel.RGB444BE](c.R, c.G, c.B)
45+
image.Set(0, 0, encoded)
46+
image.Set(0, 1, encoded)
47+
encoded2 := image.Get(0, 0)
48+
encoded3 := image.Get(0, 1)
49+
if encoded != encoded2 {
50+
t.Errorf("failed to roundtrip color %v: expected %d but got %d", c, encoded, encoded2)
51+
}
52+
if encoded != encoded3 {
53+
t.Errorf("failed to roundtrip color %v: expected %d but got %d", c, encoded, encoded3)
54+
}
55+
c2 := encoded2.RGBA()
56+
if c2 != c {
57+
t.Errorf("failed to roundtrip color: expected %v but got %v", c, c2)
58+
}
59+
c3 := encoded3.RGBA()
60+
if c3 != c {
61+
t.Errorf("failed to roundtrip color: expected %v but got %v", c, c3)
62+
}
63+
}
64+
}

0 commit comments

Comments
 (0)