From 272a7bd2298ce8c2c75e3c4f88f27db18a948cf8 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 19 Aug 2026 11:19:57 +0200 Subject: [PATCH 1/2] A 16-bit RGB 5/6/5 image format for canvases that must fit in 8 MB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image gains a format field: pfRgbx (everything as before) or pfRgb565, a packed 16-bit RGB surface with no alpha. newImage565 / newImage565Over create one; view, copy, subImage, flips, rotate90 carry the format; the unsafe accessors, fill, the draw family (blendRect, drawSmooth, drawCorrect), the rasterizer's coverage/hits kernels, fillGradient, applyOpacity, invert, ceil, blur and the decoder write seams (RowBoxSampler, JPEG via unsafe[]=, WebP, copyIntoTarget) all handle it. Encoders read through toContiguousSeq, which expands. shadow/spread raise; minify/magnify convert through an RGBX copy; the opaque/transparent predicates answer truthfully. The SIMD variants guard on the format too: on amd64/arm64 hasSimd replaces the scalar body outright, so a check only in the scalar body would be compiled out exactly where a NEON kernel would read a 565 buffer as RGBX. RGBX output is unchanged — every xray score is identical to before. tests/test_rgb565.nim draws every scenario onto an RGBX and a 565 canvas and requires the quantised RGBX result to match: bit-exact for single layers over representable backdrops, one 5-bit step for compound cases. Co-Authored-By: Claude Fable 5 --- README.md | 17 ++ src/pixie.nim | 30 ++- src/pixie/common.nim | 234 ++++++++++++++++++++++- src/pixie/fileformats/qoi.nim | 8 +- src/pixie/fileformats/webp.nim | 8 +- src/pixie/images.nim | 235 ++++++++++++++++++++--- src/pixie/internal.nim | 21 +++ src/pixie/paints.nim | 23 +-- src/pixie/paths.nim | 127 ++++++++++++- src/pixie/rgb565.nim | 54 ++++++ src/pixie/simd/avx2.nim | 17 +- src/pixie/simd/neon.nim | 19 +- src/pixie/simd/sse2.nim | 19 +- tests/test_rgb565.nim | 330 +++++++++++++++++++++++++++++++++ tests/tests.nim | 1 + 15 files changed, 1074 insertions(+), 69 deletions(-) create mode 100644 src/pixie/rgb565.nim create mode 100644 tests/test_rgb565.nim diff --git a/README.md b/README.md index 9512aea9..23973481 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,23 @@ are unfiltered in place, and multi-`IDAT` streams are inflated as segments rather than concatenated first. The fork depends on stock zippy again as a result. +**A 16-bit canvas.** `newImage565(w, h)` (and `newImage565Over` for a +buffer the caller owns) makes an `Image` whose pixels are packed RGB 5/6/5 — +half the memory of RGBA, no alpha. It is a *presentation surface*: the final +canvas that opaque geometry, text and decoded pictures are composited onto and +that a display driver then reads. Every drawing operation works on one — +fills, antialiased paths and text, `draw` with any blend mode, gradients, +opacity, views, copies, the scaled and streamed decoders writing straight +into it — and the result is what you would get by drawing onto RGBA and then +quantising: bit-for-bit for a single layer over a representable backdrop, one +5-bit step at most for layers over layers (`tests/test_rgb565.nim` is that +oracle). What a 565 image refuses is being used *as an alpha mask*: +`shadow`/`spread` raise, and the mask blend modes blacken rather than clear. +`Image.format` says which kind you hold; RGBA images are untouched and +remain the default everywhere — this exists for a 1200×1600 panel on a +microcontroller with 8 MB of PSRAM, where a 7.3 MB RGBA canvas does not fit +and a 3.7 MB one does. + **Images that can borrow pixels.** `view(image, x, y, w, h)` is a window onto another image's memory rather than a copy, with `newImageFrom`, `toContiguousSeq`, the `forEachSpan` template and `items`/`pairs` iterators as diff --git a/src/pixie.nim b/src/pixie.nim index 6ec17e7f..6c944c4e 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -94,12 +94,32 @@ proc copyIntoTarget(target, source: Image) {.raises: [PixieError].} = raise newException(PixieError, "Image dimensions do not match target") # Row at a time: either side may be a view, whose rows are `stride` apart # rather than adjacent. + if target.format != source.format: + # A decoded RGBX picture landing in a 565 canvas (or the reverse) goes + # through the accessors; the same-format case stays a row memcpy. + for y in 0 ..< target.height: + var + ti = target.dataIndex(0, y) + si = source.dataIndex(0, y) + for x in 0 ..< target.width: + target.setPixel(ti, source.getPixel(si)) + inc ti + inc si + return + let rowBytes = target.width * target.bytesPerPixel for y in 0 ..< target.height: - copyMem( - target.data[target.dataIndex(0, y)].addr, - source.data[source.dataIndex(0, y)].unsafeAddr, - target.width * sizeof(ColorRGBX) - ) + if target.format == pfRgb565: + copyMem( + target.data16[target.dataIndex(0, y)].addr, + source.data16[source.dataIndex(0, y)].unsafeAddr, + rowBytes + ) + else: + copyMem( + target.data[target.dataIndex(0, y)].addr, + source.data[source.dataIndex(0, y)].unsafeAddr, + rowBytes + ) template isWebpData(data: string): bool = data.len > 12 and data.readStr(0, 4) == WebpRiffSignature and diff --git a/src/pixie/common.nim b/src/pixie/common.nim index f41cd4b7..4a6a2604 100644 --- a/src/pixie/common.nim +++ b/src/pixie/common.nim @@ -37,9 +37,38 @@ type fitCover ## fill the whole target, cropping the source centered fitContain ## fit the whole source centered, leaving target borders untouched + PixelFormat* = enum + ## How an image stores its pixels. Everything in pixie composites in + ## premultiplied RGBA (`ColorRGBX`); the storage format only decides what + ## those values are packed into on the way in and out of memory. + pfRgbx ## 32-bit premultiplied RGBA — the format every image had before + pfRgb565 ## 16-bit packed RGB (5/6/5), no alpha; reads back fully opaque + Image* {.acyclic.} = ref object ## Image object that holds bitmap data in premultiplied alpha RGBA format. ## + ## Or — since `pfRgb565` exists — in a 16-bit RGB format that halves the + ## memory and drops alpha. A 565 image is a *presentation surface*: a + ## final canvas that opaque geometry and decoded pictures are composited + ## onto and that a display driver then reads. Everything that draws onto + ## an image works on one (solid fills, antialiased paths and text, image + ## draws with any blend mode, gradients, opacity), and so does reading + ## pixels back, copying and viewing. What does NOT work is using one as + ## an *alpha mask* or as a filter scratch buffer — `shadow`, `spread`, + ## `blur`, `minifyBy2`/`magnifyBy2`, `invert`, `ceil`, `rotate90` and + ## the opaque/transparent predicates raise `PixieError` rather than + ## guess, because those operations are defined by an alpha channel a 565 + ## image does not have. Drawing a 565 image onto another image works + ## (it reads back opaque); scaling one is done through an RGBX copy. + ## + ## Premultiplied colour written into a 565 image keeps its premultiplied + ## RGB and forgets the alpha, which is exactly what a display driver that + ## reads `.r/.g/.b` off an RGBA canvas saw anyway: a half-transparent + ## pixel over nothing reads as a half-dark one. Reads come back with + ## alpha 255, so compositing onto a 565 image is compositing onto an + ## opaque backdrop, which is what a canvas filled with a background + ## colour is. + ## ## `{.acyclic.}` is load-bearing, not an optimisation. `root` makes this ## type look cyclic to ORC, but a view's `root` always points at an owner ## (see `subImageView`), never at another view, so no cycle can form. @@ -64,9 +93,13 @@ type width*, height*: int stride*: int ## elements between vertically adjacent pixels origin*: int ## index of (0, 0) within the shared buffer - storage: seq[ColorRGBX] ## owned pixels; empty for a view + format*: PixelFormat ## how `pixels` / `pixels16` are laid out + storage: seq[ColorRGBX] ## owned pixels; empty for a view or a 565 image + storage16: seq[uint16] ## owned 565 pixels; empty for a view, an RGBX + ## image, or a 565 image over a borrowed buffer root: Image ## the owner whose buffer this views; nil if owner - pixels: ptr UncheckedArray[ColorRGBX] ## the buffer, owned or borrowed + pixels: ptr UncheckedArray[ColorRGBX] ## the RGBX buffer, owned or borrowed + pixels16: ptr UncheckedArray[uint16] ## the 565 buffer, owned or borrowed ImageSourceProc* = proc( dst: pointer, maxBytes: int @@ -116,11 +149,77 @@ template data*(image: Image): ptr UncheckedArray[ColorRGBX] = ## The pixels this image addresses — its own, or its owner's when it is a ## view. Indexed with `dataIndex`, never with a flat running counter unless ## `isContiguous` says that is safe. + ## + ## Only meaningful for a `pfRgbx` image. Code that can be handed a 565 image + ## goes through `getPixel`/`setPixel` (or `unsafe[]`), which branch on the + ## format, and the flat kernels check `image.format` before touching this. image.pixels +template data16*(image: Image): ptr UncheckedArray[uint16] = + ## The 565 pixels of a `pfRgb565` image, addressed like `data`. + image.pixels16 + template dataIndex*(image: Image, x, y: int): int = image.origin + image.stride * y + x +template isRgbx*(image: Image): bool = + image.format == pfRgbx + +template isRgb565*(image: Image): bool = + image.format == pfRgb565 + +proc rgbxToRgb565*(c: ColorRGBX): uint16 {.inline, raises: [].} = + ## Packs a premultiplied RGBX colour into 5/6/5, rounding each channel to + ## nearest. Alpha is dropped; the premultiplied RGB is what survives, so a + ## translucent pixel lands as the darker colour a driver reading the RGBA + ## canvas's RGB would have seen. + ## + ## The multiply-shift forms are exact `round(v * 31 / 255)` and + ## `round(v * 63 / 255)` for every byte, and they are idempotent with + ## `rgb565ToRgbx`: expanding then re-packing a 565 value gives it back, so + ## a read-modify-write that changes nothing changes nothing. + let + r = (c.r.uint32 * 249 + 1014) shr 11 + g = (c.g.uint32 * 253 + 505) shr 10 + b = (c.b.uint32 * 249 + 1014) shr 11 + uint16((r shl 11) or (g shl 5) or b) + +proc rgb565ToRgbx*(p: uint16): ColorRGBX {.inline, raises: [].} = + ## Expands a 5/6/5 pixel to an opaque RGBX colour, replicating the top bits + ## into the low bits so 0 stays 0 and full scale stays 255. + let + r = (p.uint32 shr 11) and 31 + g = (p.uint32 shr 5) and 63 + b = p.uint32 and 31 + result.r = ((r shl 3) or (r shr 2)).uint8 + result.g = ((g shl 2) or (g shr 4)).uint8 + result.b = ((b shl 3) or (b shr 2)).uint8 + result.a = 255 + +template getPixel*(image: Image, index: int): ColorRGBX = + ## The pixel at a `dataIndex`, whatever the storage format. + (if image.format == pfRgbx: image.pixels[index] + else: rgb565ToRgbx(image.pixels16[index])) + +template setPixel*(image: Image, index: int, color: ColorRGBX) = + ## Stores a premultiplied pixel at a `dataIndex`, whatever the storage format. + if image.format == pfRgbx: + image.pixels[index] = color + else: + image.pixels16[index] = rgbxToRgb565(color) + +template bytesPerPixel*(image: Image): int = + (if image.format == pfRgbx: 4 else: 2) + +template requireRgbx*(image: Image, what: string) = + ## Guard for operations that are only defined on an RGBA image — the ones + ## whose meaning is the alpha channel. Raising is the honest answer: a 565 + ## image has no alpha to spread, mask, or test, and silently treating it as + ## opaque would make a mask-based effect produce a rectangle. + if image.format != pfRgbx: + raise newException(PixieError, what & " needs an RGBA image, not a " & + $image.format & " one") + template isContiguous*(image: Image): bool = ## True when the image's rows sit back to back in the buffer, which is what a ## whole-image flat loop needs. Always true for an owner; true for a view only @@ -232,12 +331,15 @@ proc writeSampledRow( (if sampler.boxX: sampler.colCount[tx].uint64 else: 1'u64) base = tx * 4 half = area div 2 - target.data[target.dataIndex(sampler.dstX + tx, outY)] = ColorRGBX( + # One write point for every row-streamed decode, so a 565 target costs + # the decoders nothing: the accumulator is 8-bit-per-channel all the way + # to here and only the final store packs. + target.setPixel(target.dataIndex(sampler.dstX + tx, outY), ColorRGBX( r: ((values[base + 0] + half) div area).uint8, g: ((values[base + 1] + half) div area).uint8, b: ((values[base + 2] + half) div area).uint8, a: ((values[base + 3] + half) div area).uint8 - ) + )) proc flushBoxRow(sampler: var RowBoxSampler, target: Image) = if sampler.currentTy < 0 or sampler.rowsInBox == 0: @@ -292,6 +394,66 @@ proc newImage*(width, height: int): Image {.raises: [PixieError].} = result.storage = newSeq[ColorRGBX](width * height) result.pixels = cast[ptr UncheckedArray[ColorRGBX]](result.storage[0].addr) +proc newImage565*(width, height: int): Image {.raises: [PixieError].} = + ## Creates a new 16-bit RGB (5/6/5) image, half the memory of `newImage`. + ## Starts black (which is also what "transparent" packs to). See the notes + ## on `Image` for what a 565 image can and cannot do. + if width <= 0 or height <= 0: + raise newException(PixieError, "Image width and height must be > 0") + result = Image() + result.width = width + result.height = height + result.stride = width + result.origin = 0 + result.format = pfRgb565 + result.storage16 = newSeq[uint16](width * height) + result.pixels16 = cast[ptr UncheckedArray[uint16]](result.storage16[0].addr) + +proc newImage565Over*( + width, height: int, buffer: pointer +): Image {.raises: [PixieError].} = + ## A 565 image over memory the caller owns — `width * height * 2` bytes at + ## `buffer`, which must outlive the image and everything viewed from it. + ## + ## This is how a device keeps one canvas for its whole uptime: allocate the + ## block once at boot, before anything can fragment the heap, and hand it + ## to pixie every render instead of asking the allocator for a multi-MB + ## contiguous run each time. The image is an owner as far as views are + ## concerned (it is what `root` points at) but frees nothing. + if width <= 0 or height <= 0: + raise newException(PixieError, "Image width and height must be > 0") + if buffer.isNil: + raise newException(PixieError, "newImage565Over needs a buffer") + result = Image() + result.width = width + result.height = height + result.stride = width + result.origin = 0 + result.format = pfRgb565 + result.pixels16 = cast[ptr UncheckedArray[uint16]](buffer) + +proc newImageOver*( + width, height: int, buffer: pointer +): Image {.raises: [PixieError].} = + ## `newImage565Over` for an RGBX buffer of `width * height * 4` bytes. + if width <= 0 or height <= 0: + raise newException(PixieError, "Image width and height must be > 0") + if buffer.isNil: + raise newException(PixieError, "newImageOver needs a buffer") + result = Image() + result.width = width + result.height = height + result.stride = width + result.origin = 0 + result.pixels = cast[ptr UncheckedArray[ColorRGBX]](buffer) + +proc newImageLike*(image: Image, width, height: int): Image {.raises: [PixieError].} = + ## A fresh image in the same storage format as `image`. + if image.format == pfRgb565: + newImage565(width, height) + else: + newImage(width, height) + proc newImageFrom*( width, height: int, data: sink seq[ColorRGBX] ): Image {.raises: [PixieError].} = @@ -320,6 +482,17 @@ proc toContiguousSeq*(image: Image): seq[ColorRGBX] {.raises: [].} = ## mutates `copy` would be scribbling on the image it was asked to read. result = newSeq[ColorRGBX](image.width * image.height) if image.width * image.height > 0: + if image.format == pfRgb565: + # A 565 image expands to opaque RGBX on the way out: this is the seam + # every encoder reaches the pixels through, so a 565 canvas can still + # be saved as a PNG for a preview without the encoders knowing. + for y in 0 ..< image.height: + let + src = image.dataIndex(0, y) + dst = y * image.width + for x in 0 ..< image.width: + result[dst + x] = rgb565ToRgbx(image.pixels16[src + x]) + return for y in 0 ..< image.height: copyMem( result[y * image.width].addr, @@ -327,6 +500,51 @@ proc toContiguousSeq*(image: Image): seq[ColorRGBX] {.raises: [].} = image.width * 4 ) +proc toRgbxImage*(image: Image): Image {.raises: [].} = + ## An RGBX copy of any image. For an RGBX image this is `copy`; for a 565 + ## image it is the expansion a filter or scaler that only knows RGBA needs. + result = Image() + result.width = image.width + result.height = image.height + result.stride = image.width + result.origin = 0 + result.storage = newSeq[ColorRGBX](image.width * image.height) + result.pixels = cast[ptr UncheckedArray[ColorRGBX]](result.storage[0].addr) + if image.format == pfRgb565: + for y in 0 ..< image.height: + let + src = image.dataIndex(0, y) + dst = y * image.width + for x in 0 ..< image.width: + result.pixels[dst + x] = rgb565ToRgbx(image.pixels16[src + x]) + else: + for y in 0 ..< image.height: + copyMem( + result.pixels[y * image.width].addr, + image.pixels[image.dataIndex(0, y)].addr, + image.width * 4 + ) + +proc toRgb565Image*(image: Image): Image {.raises: [].} = + ## A 565 copy of any image — the quantising direction. + result = Image() + result.width = image.width + result.height = image.height + result.stride = image.width + result.origin = 0 + result.format = pfRgb565 + result.storage16 = newSeq[uint16](image.width * image.height) + result.pixels16 = cast[ptr UncheckedArray[uint16]](result.storage16[0].addr) + for y in 0 ..< image.height: + let + src = image.dataIndex(0, y) + dst = y * image.width + if image.format == pfRgb565: + copyMem(result.pixels16[dst].addr, image.pixels16[src].addr, image.width * 2) + else: + for x in 0 ..< image.width: + result.pixels16[dst + x] = rgbxToRgb565(image.pixels[src + x]) + proc newImageFromUnchecked*( width, height: int, data: sink seq[ColorRGBX] ): Image {.raises: [].} = @@ -359,16 +577,20 @@ proc view*(image: Image, x, y, w, h: int): Image {.raises: [PixieError].} = result.height = h result.stride = image.stride result.origin = image.dataIndex(x, y) + result.format = image.format result.root = if image.root.isNil: image else: image.root result.pixels = image.pixels + result.pixels16 = image.pixels16 template isView*(image: Image): bool = ## True when the image borrows another's pixels rather than owning them. not image.root.isNil proc copy*(image: Image): Image {.raises: [].} = - ## Copies the image data into a new image. A view copies out, so the result - ## always owns its pixels. + ## Copies the image data into a new image, in the same storage format. A + ## view copies out, so the result always owns its pixels. + if image.format == pfRgb565: + return image.toRgb565Image() result = Image() result.width = image.width result.height = image.height diff --git a/src/pixie/fileformats/qoi.nim b/src/pixie/fileformats/qoi.nim index b63c3d7f..80f4bfb4 100644 --- a/src/pixie/fileformats/qoi.nim +++ b/src/pixie/fileformats/qoi.nim @@ -266,13 +266,7 @@ proc encodeQoi*(image: Image): string {.raises: [PixieError].} = qoi.colorspace = Linear # Packed copy rather than a flat one: a view's rows are not adjacent, and # `image.data` is a pointer into a buffer that may be larger than the image. - qoi.data.setLen(image.width * image.height) - for y in 0 ..< image.height: - copyMem( - qoi.data[y * image.width].addr, - image.data[image.dataIndex(0, y)].addr, - image.width * 4 - ) + qoi.data = cast[seq[ColorRGBA]](image.toContiguousSeq()) qoi.data.toStraightAlpha() encodeQoi(qoi) diff --git a/src/pixie/fileformats/webp.nim b/src/pixie/fileformats/webp.nim index 2a90050a..78e90ec1 100644 --- a/src/pixie/fileformats/webp.nim +++ b/src/pixie/fileformats/webp.nim @@ -2969,12 +2969,12 @@ proc frameToTargetRect( sumB += px.b sumA += px.a let area = uint32((sy1 - sy0) * (sx1 - sx0)) - target.data[target.dataIndex(dstX, dstY)] = ColorRGBX( + target.setPixel(target.dataIndex(dstX, dstY), ColorRGBX( r: ((sumR + area div 2) div area).uint8, g: ((sumG + area div 2) div area).uint8, b: ((sumB + area div 2) div area).uint8, a: ((sumA + area div 2) div area).uint8 - ) + )) proc rgbaBytesToTargetRect( rgbaData: seq[uint8], width, height: int, forceOpaque: bool, @@ -3002,12 +3002,12 @@ proc rgbaBytesToTargetRect( sumB += px.b sumA += px.a let area = uint32((sy1 - sy0) * (sx1 - sx0)) - target.data[target.dataIndex(dstX, dstY)] = ColorRGBX( + target.setPixel(target.dataIndex(dstX, dstY), ColorRGBX( r: ((sumR + area div 2) div area).uint8, g: ((sumG + area div 2) div area).uint8, b: ((sumB + area div 2) div area).uint8, a: ((sumA + area div 2) div area).uint8 - ) + )) proc decodeWebpScaledInto*( data: string, target: Image, fit = fitStretch diff --git a/src/pixie/images.nim b/src/pixie/images.nim index 148b5cf6..8c51f1f4 100644 --- a/src/pixie/images.nim +++ b/src/pixie/images.nim @@ -1,4 +1,4 @@ -import blends, bumpy, chroma, common, internal, simd, vmath +import blends, bumpy, chroma, common, internal, rgb565, simd, vmath export Image, copy, dataIndex, newImage @@ -20,19 +20,27 @@ proc inside*(image: Image, x, y: int): bool {.inline, raises: [].} = template unsafe*(src: Image): UnsafeImage = cast[UnsafeImage](src) -template `[]`*(view: UnsafeImage, x, y: int): var ColorRGBX = +template `[]`*(view: UnsafeImage, x, y: int): ColorRGBX = ## Gets a color from (x, y) coordinates. ## * No bounds checking * ## Make sure that x, y are in bounds. ## Failure in the assumptions will cause unsafe memory reads. - cast[Image](view).data[cast[Image](view).dataIndex(x, y)] + ## + ## Returns by value (it used to return `var`): a 565 image has no + ## `ColorRGBX` in memory to hand out a reference to, and reading through + ## this is the one accessor that works on every storage format. + (block: + let img {.inject.} = cast[Image](view) + img.getPixel(img.dataIndex(x, y))) template `[]=`*(view: UnsafeImage, x, y: int, color: ColorRGBX) = ## Sets a color from (x, y) coordinates. ## * No bounds checking * ## Make sure that x, y are in bounds. ## Failure in the assumptions will cause unsafe memory writes. - cast[Image](view).data[cast[Image](view).dataIndex(x, y)] = color + block: + let img = cast[Image](view) + img.setPixel(img.dataIndex(x, y), color) proc `[]`*(image: Image, x, y: int): ColorRGBX {.inline, raises: [].} = ## Gets a pixel at (x, y) or returns transparent black if outside of bounds. @@ -56,6 +64,11 @@ proc fill*(image: Image, color: SomeColor) {.inline, raises: [].} = ## Fills the image with the color. # An owner is a single span, so this stays the one memset it always was. # A view fills row by row, which is how a tiled render clears its cell. + if image.format == pfRgb565: + let packed = rgbxToRgb565(color.asRgbx()) + image.forEachSpan: + fillUnsafe16(image.data16, packed, spanStart, spanLen) + return image.forEachSpan: fillUnsafe(image.data, color, spanStart, spanLen) @@ -93,12 +106,23 @@ proc pixelsEqual*(a, b: Image): bool {.raises: [].} = return a.isNil and b.isNil if a.width != b.width or a.height != b.height: return false + if a.format != b.format: + # Different storage, same picture? Compare what each would read back. + for y in 0 ..< a.height: + for x in 0 ..< a.width: + if a.unsafe[x, y] != b.unsafe[x, y]: + return false + return true + let rowBytes = a.width * a.bytesPerPixel for y in 0 ..< a.height: - if not equalMem( - a.data[a.dataIndex(0, y)].addr, - b.data[b.dataIndex(0, y)].addr, - a.width * 4 - ): + let same = + if a.format == pfRgb565: + equalMem(a.data16[a.dataIndex(0, y)].addr, + b.data16[b.dataIndex(0, y)].addr, rowBytes) + else: + equalMem(a.data[a.dataIndex(0, y)].addr, + b.data[b.dataIndex(0, y)].addr, rowBytes) + if not same: return false true @@ -107,6 +131,8 @@ proc isOneColor*(image: Image): bool {.hasSimd, raises: [].} = # This is an optimization hint with no callers inside pixie, so a view gets # the conservative answer rather than a row walk. The SIMD variants bail out # the same way — the dispatch the hasSimd pragma inserts runs before this. + if image.format != pfRgbx: + return image.isOneColor565() if image.isView: return false result = true @@ -117,8 +143,9 @@ proc isOneColor*(image: Image): bool {.hasSimd, raises: [].} = proc isTransparent*(image: Image): bool {.hasSimd, raises: [].} = ## Checks if this image is fully transparent or not. - # Conservative for a view, for the same reason as isOneColor. - if image.isView: + # Conservative for a view, for the same reason as isOneColor. A 565 image + # has no alpha and reads back opaque, so it is never transparent. + if image.isView or image.format == pfRgb565: return false result = true for i in 0 ..< image.dataLen: @@ -129,6 +156,8 @@ proc isOpaque*(image: Image): bool {.raises: [].} = ## Checks if the entire image is opaque (alpha values are all 255). # No image-level SIMD variant can bypass this walk, so unlike isOneColor and # isTransparent a view can be answered exactly instead of conservatively. + if image.format == pfRgb565: + return true result = true image.forEachSpan: if not isOpaque(image.data, spanStart, spanLen): @@ -141,10 +170,16 @@ proc flipHorizontal*(image: Image) {.raises: [].} = var left = image.dataIndex(0, y) right = left + image.width - 1 - for x in 0 ..< halfWidth: - swap(image.data[left], image.data[right]) - inc left - dec right + if image.format == pfRgb565: + for x in 0 ..< halfWidth: + swap(image.data16[left], image.data16[right]) + inc left + dec right + else: + for x in 0 ..< halfWidth: + swap(image.data[left], image.data[right]) + inc left + dec right proc flipVertical*(image: Image) {.raises: [].} = ## Flips the image around the X axis. @@ -153,8 +188,12 @@ proc flipVertical*(image: Image) {.raises: [].} = let topStart = image.dataIndex(0, y) bottomStart = image.dataIndex(0, image.height - y - 1) - for x in 0 ..< image.width: - swap(image.data[topStart + x], image.data[bottomStart + x]) + if image.format == pfRgb565: + for x in 0 ..< image.width: + swap(image.data16[topStart + x], image.data16[bottomStart + x]) + else: + for x in 0 ..< image.width: + swap(image.data[topStart + x], image.data[bottomStart + x]) proc rotate90*(image: Image) {.raises: [PixieError].} = ## Rotates the image 90 degrees clockwise. @@ -163,18 +202,21 @@ proc rotate90*(image: Image) {.raises: [PixieError].} = if image.isView: raise newException(PixieError, "Cannot rotate90 a view, copy it first") - let rotated = newImage(image.height, image.width) + let rotated = image.newImageLike(image.height, image.width) for y in 0 ..< rotated.height: for x in 0 ..< rotated.width: - rotated.data[rotated.dataIndex(x, y)] = - image.data[image.dataIndex(y, image.height - x - 1)] + rotated.setPixel(rotated.dataIndex(x, y), + image.getPixel(image.dataIndex(y, image.height - x - 1))) # The pixels are copied back rather than the buffer handed over, because an # image's storage belongs to it: rebinding it to the temporary's would leave # this image pointing at pixels that die with the temporary. The pixel count # is unchanged, so the existing buffer still fits. swap(image.width, image.height) image.stride = image.width - copyMem(image.data[0].addr, rotated.data[0].addr, image.dataLen * 4) + if image.format == pfRgb565: + copyMem(image.data16[0].addr, rotated.data16[0].addr, image.dataLen * 2) + else: + copyMem(image.data[0].addr, rotated.data[0].addr, image.dataLen * 4) proc subImage*(image: Image, x, y, w, h: int): Image {.raises: [PixieError].} = ## Gets a sub image from this image. @@ -189,7 +231,15 @@ proc subImage*(image: Image, x, y, w, h: int): Image {.raises: [PixieError].} = "Params y: " & $y & " h: " & $h & " invalid, image height is " & $image.height ) - result = newImage(w, h) + result = image.newImageLike(w, h) + if image.format == pfRgb565: + for y2 in 0 ..< h: + copyMem( + result.data16[result.dataIndex(0, y2)].addr, + image.data16[image.dataIndex(x, y + y2)].addr, + w * 2 + ) + return for y2 in 0 ..< h: copyMem( result.data[result.dataIndex(0, y2)].addr, @@ -242,7 +292,9 @@ proc minifyBy2*( if power == 0: return image.copy() - var src = image + # The box filter below averages raw RGBX; a 565 source is expanded first so + # the averaging happens at 8 bits and the result is an ordinary RGBX image. + var src = if image.format == pfRgb565: image.toRgbxImage() else: image for _ in 1 .. power: # When minifying an image of odd size, round the result image size up # so a 99 x 99 src image returns a 50 x 50 image. @@ -304,7 +356,9 @@ proc magnifyBy2*( if power < 0: raise newException(PixieError, "Cannot magnifyBy2 with negative power") - let scale = 2 ^ power + let + scale = 2 ^ power + image = if image.format == pfRgb565: image.toRgbxImage() else: image result = newImage(image.width * scale, image.height * scale) for y in 0 ..< image.height: @@ -336,6 +390,10 @@ proc applyOpacity*(image: Image, opacity: float32) {.hasSimd, raises: [].} = image.fill(rgbx(0, 0, 0, 0)) return + if image.format != pfRgbx: + image.applyOpacity565(opacity) + return + image.forEachSpan: for i in spanStart ..< spanStart + spanLen: var rgbx = image.data[i] @@ -347,6 +405,9 @@ proc applyOpacity*(image: Image, opacity: float32) {.hasSimd, raises: [].} = proc invert*(image: Image) {.hasSimd, raises: [].} = ## Inverts all of the colors and alpha. + if image.format != pfRgbx: + image.invert565() + return image.forEachSpan: for i in spanStart ..< spanStart + spanLen: var rgbx = image.data[i] @@ -367,6 +428,9 @@ proc invert*(image: Image) {.hasSimd, raises: [].} = proc ceil*(image: Image) {.hasSimd, raises: [].} = ## A value of 0 stays 0. Anything else turns into 255. + if image.format != pfRgbx: + image.ceil565() + return image.forEachSpan: for i in spanStart ..< spanStart + spanLen: var rgbx = image.data[i] @@ -386,6 +450,20 @@ proc blur*( if radius < 0: raise newException(PixieError, "Cannot apply negative blur") + if image.format == pfRgb565: + # Blur at 8 bits in an RGBX copy and quantise the result back in. The + # copy is the price of not writing a second blur; a 565 canvas is blurred + # rarely enough that paying it beats carrying that code. + let tmp = image.toRgbxImage() + tmp.blur(radius.float32, outOfBounds) + for y in 0 ..< image.height: + let + src = tmp.dataIndex(0, y) + dst = image.dataIndex(0, y) + for x in 0 ..< image.width: + image.data16[dst + x] = rgbxToRgb565(tmp.data[src + x]) + return + let kernel = gaussianKernel(radius) outOfBounds = outOfBounds.asRgbx() @@ -540,15 +618,82 @@ proc blendLineMask(a, b: ptr UncheckedArray[ColorRGBX], len: int) {.hasSimd.} = for i in 0 ..< len: a[i] = blendMask(a[i], b[i]) +template rowAddr(image: Image, x, y: int): pointer = + ## Address of pixel (x, y) whatever the storage format. + (if image.format == pfRgb565: + cast[pointer](image.data16[image.dataIndex(x, y)].addr) + else: + cast[pointer](image.data[image.dataIndex(x, y)].addr)) + template zeroRows(image: Image, y, h: int) = ## Clears `h` whole rows starting at row `y`. One memset when the rows are ## back to back, which is every owner, and one per row for a strided view — ## whose rows must not be zeroed through, since the gaps belong to the parent. + ## Zero is "transparent black" for RGBX and "black" for 565 — the same + ## answer once a driver reads the colour off either. + let bpp = image.bytesPerPixel if image.isContiguous: - zeroMem(image.data[image.dataIndex(0, y)].addr, h * image.width * 4) + zeroMem(image.rowAddr(0, y), h * image.width * bpp) else: for yy in y ..< y + h: - zeroMem(image.data[image.dataIndex(0, yy)].addr, image.width * 4) + zeroMem(image.rowAddr(0, yy), image.width * bpp) + +proc blendLineAny( + a: Image, ax, ay: int, b: Image, bx, by: int, len: int, blendMode: BlendMode +) = + ## One row of `b` blended onto one row of `a` through the format-aware + ## accessors: the slow generic path that exists so that any mix of RGBX and + ## 565 on either side is correct. The RGBX/RGBX case never comes here. + var + ai = a.dataIndex(ax, ay) + bi = b.dataIndex(bx, by) + case blendMode: + of OverwriteBlend: + for _ in 0 ..< len: + a.setPixel(ai, b.getPixel(bi)) + inc ai + inc bi + of NormalBlend: + for _ in 0 ..< len: + a.setPixel(ai, blendNormal(a.getPixel(ai), b.getPixel(bi))) + inc ai + inc bi + of MaskBlend: + for _ in 0 ..< len: + a.setPixel(ai, blendMask(a.getPixel(ai), b.getPixel(bi))) + inc ai + inc bi + else: + let blender = blendMode.blender() + for _ in 0 ..< len: + a.setPixel(ai, blender(a.getPixel(ai), b.getPixel(bi))) + inc ai + inc bi + +proc blendSampleLineAny( + a: Image, ax, ay: int, samples: ptr UncheckedArray[ColorRGBX], len: int, + blendMode: BlendMode +) = + ## `blendLineAny` where the source row is already an RGBX scratch line. + var ai = a.dataIndex(ax, ay) + case blendMode: + of OverwriteBlend: + for i in 0 ..< len: + a.setPixel(ai, samples[i]) + inc ai + of NormalBlend: + for i in 0 ..< len: + a.setPixel(ai, blendNormal(a.getPixel(ai), samples[i])) + inc ai + of MaskBlend: + for i in 0 ..< len: + a.setPixel(ai, blendMask(a.getPixel(ai), samples[i])) + inc ai + else: + let blender = blendMode.blender() + for i in 0 ..< len: + a.setPixel(ai, blender(a.getPixel(ai), samples[i])) + inc ai proc blendRect(a, b: Image, pos: Ivec2, blendMode: BlendMode) = let @@ -566,6 +711,23 @@ proc blendRect(a, b: Image, pos: Ivec2, blendMode: BlendMode) = xEnd = min(b.width, a.width - px) yEnd = min(b.height, a.height - py) + if a.format != pfRgbx or b.format != pfRgbx: + # Any 565 on either side: the raw-pointer kernels below assume 4-byte + # pixels, so go through the accessors. The mask clears stay the same. + if blendMode == MaskBlend and yStart + py > 0: + a.zeroRows(0, yStart + py) + for y in yStart ..< yEnd: + if blendMode == MaskBlend: + if xStart + px > 0: + zeroMem(a.rowAddr(0, y + py), (xStart + px) * a.bytesPerPixel) + if xEnd + px < a.width: + zeroMem(a.rowAddr(xEnd + px, y + py), + (a.width - (xEnd + px)) * a.bytesPerPixel) + blendLineAny(a, xStart + px, y + py, b, xStart, y, xEnd - xStart, blendMode) + if blendMode == MaskBlend and yEnd + py < a.height: + a.zeroRows(yEnd + py, a.height - (yEnd + py)) + return + case blendMode: of NormalBlend: for y in yStart ..< yEnd: @@ -673,6 +835,20 @@ proc drawSmooth(a, b: Image, transform: Mat3, blendMode: BlendMode) = sampleLine[x] = b.getRgbaSmooth(srcPos.x, srcPos.y) srcPos += dx + if a.format != pfRgbx: + # The sample line is RGBX whatever `b` is (getRgbaSmooth reads through + # the accessors); only the store into `a` needs the format-aware path. + if blendMode == MaskBlend and xStart > 0: + zeroMem(a.rowAddr(0, y), xStart * a.bytesPerPixel) + blendSampleLineAny( + a, xStart, y, + cast[ptr UncheckedArray[ColorRGBX]](sampleLine[xStart].addr), + xEnd - xStart, blendMode + ) + if blendMode == MaskBlend and a.width - xEnd > 0: + zeroMem(a.rowAddr(xEnd, y), (a.width - xEnd) * a.bytesPerPixel) + continue + case blendMode: of NormalBlend: blendLineNormal( @@ -778,6 +954,7 @@ proc resize*(srcImage: Image, width, height: int): Image {.raises: [PixieError]. proc spread(image: Image, spread: float32) {.raises: [PixieError].} = ## Grows the mask by spread. + image.requireRgbx("spread") let spread = round(spread).int if spread == 0: return @@ -794,7 +971,7 @@ proc spread(image: Image, spread: float32) {.raises: [PixieError].} = maxValue = value if maxValue == 255: break - spreadX.unsafe[y, x].a = maxValue + spreadX.unsafe[y, x] = rgbx(0, 0, 0, maxValue) # Spread in the Y direction and modify mask. for y in 0 ..< image.height: @@ -840,6 +1017,7 @@ proc shadow*( image: Image, offset: Vec2, spread, blur: float32, color: SomeColor ): Image {.raises: [PixieError].} = ## Create a shadow of the image with the offset, spread and blur. + image.requireRgbx("shadow") var mask: Image if offset == vec2(0, 0): mask = image.copy() @@ -871,6 +1049,9 @@ proc opaqueBounds*(image: Image): Rect = ## visible part of the image and then use subImage to cut it out. ## Returns zero rect if whole image is transparent. ## Returns just the size of the image if no edge is transparent. + if image.format == pfRgb565: + # Opaque by construction, so every pixel counts. + return rect(0, 0, image.width.float32, image.height.float32) var xMin = image.width xMax = 0 diff --git a/src/pixie/internal.nim b/src/pixie/internal.nim index f2f6cd4e..f6205153 100644 --- a/src/pixie/internal.nim +++ b/src/pixie/internal.nim @@ -65,6 +65,23 @@ proc fillUnsafe*( for i in start ..< start + len: data[i] = rgbx +template getUncheckedArray16*( + image: Image, x, y: int +): ptr UncheckedArray[uint16] = + cast[ptr UncheckedArray[uint16]](image.data16[image.dataIndex(x, y)].addr) + +proc fillUnsafe16*( + data: ptr UncheckedArray[uint16], packed: uint16, start, len: int +) {.raises: [].} = + ## `fillUnsafe` for a 565 buffer: stores `packed` `len` times from `start`. + ## One memset when both bytes of the value agree (black, white, and the + ## greys that pack that way), a plain store loop otherwise. + if (packed and 0xFF) == (packed shr 8): + nimSetMem(data[start].addr, (packed and 0xFF).cint, len * 2) + else: + for i in start ..< start + len: + data[i] = packed + const straightAlphaTable = block: var table: array[256, array[256, uint8]] for a in 0 ..< 256: @@ -88,6 +105,8 @@ proc toStraightAlpha*(image: Image) {.raises: [].} = ## is one span, so this is the same single pass it always was. The seq ## overload below keeps the SIMD path for decoders that hold their pixels ## outside an Image. + if image.format == pfRgb565: + return # opaque: straight and premultiplied coincide image.forEachSpan: for i in spanStart ..< spanStart + spanLen: var c = image.data[i] @@ -98,6 +117,8 @@ proc toStraightAlpha*(image: Image) {.raises: [].} = proc toPremultipliedAlpha*(image: Image) {.raises: [].} = ## Converts an image to premultiplied alpha from straight alpha, in place. + if image.format == pfRgb565: + return # opaque: straight and premultiplied coincide image.forEachSpan: for i in spanStart ..< spanStart + spanLen: var c = image.data[i] diff --git a/src/pixie/paints.nim b/src/pixie/paints.nim index 203ae511..7024282d 100644 --- a/src/pixie/paints.nim +++ b/src/pixie/paints.nim @@ -120,7 +120,7 @@ proc fillGradientLinear(image: Image, paint: Paint) = var x: int while x < image.width: when allowSimd and (defined(amd64) or defined(arm64)): - if x + 4 <= image.width: + if x + 4 <= image.width and image.format == pfRgbx: var colors: array[4, ColorRGBX] for i in 0 ..< 4: let @@ -155,16 +155,17 @@ proc fillGradientLinear(image: Image, paint: Paint) = rgbx = paint.gradientColor(t) var x: int when allowSimd: - when defined(amd64): - let colorVec = mm_set1_epi32(cast[int32](rgbx)) - for _ in 0 ..< image.width div 4: - mm_storeu_si128(image.data[image.dataIndex(x, y)].addr, colorVec) - x += 4 - elif defined(arm64): - let colorVec = vmovq_n_u32(cast[uint32](rgbx)) - for _ in 0 ..< image.width div 4: - vst1q_u32(image.data[image.dataIndex(x, y)].addr, colorVec) - x += 4 + if image.format == pfRgbx: + when defined(amd64): + let colorVec = mm_set1_epi32(cast[int32](rgbx)) + for _ in 0 ..< image.width div 4: + mm_storeu_si128(image.data[image.dataIndex(x, y)].addr, colorVec) + x += 4 + elif defined(arm64): + let colorVec = vmovq_n_u32(cast[uint32](rgbx)) + for _ in 0 ..< image.width div 4: + vst1q_u32(image.data[image.dataIndex(x, y)].addr, colorVec) + x += 4 for x in x ..< image.width: image.unsafe[x, y] = rgbx diff --git a/src/pixie/paths.nim b/src/pixie/paths.nim index b0aa3d02..6dfcf882 100644 --- a/src/pixie/paths.nim +++ b/src/pixie/paths.nim @@ -1437,7 +1437,55 @@ proc clearUnsafe(image: Image, startX, startY, toX, toY: int) = let start = image.dataIndex(startX, startY) len = image.dataIndex(toX, toY) - start - fillUnsafe(image.data, rgbx(0, 0, 0, 0), start, len) + if image.format == pfRgb565: + fillUnsafe16(image.data16, 0, start, len) + else: + fillUnsafe(image.data, rgbx(0, 0, 0, 0), start, len) + +proc fillCoverage565( + image: Image, + rgbx: ColorRGBX, + startX, y: int, + coverages: seq[uint8], + blendMode: BlendMode +) = + ## `fillCoverage` for a 565 destination. The source colour is scaled by the + ## antialiasing coverage exactly as for RGBX — coverage lives in the source + ## alpha, which is why AA survives a destination without one — and the + ## backdrop is expanded to 8 bits, blended, and packed again per pixel. + var idx = image.dataIndex(startX, y) + let data16 = image.data16 + case blendMode: + of OverwriteBlend: + for i in 0 ..< coverages.len: + let coverage = coverages[i] + if coverage != 0: + data16[idx] = rgbxToRgb565(rgbx * coverage) + inc idx + of NormalBlend: + for i in 0 ..< coverages.len: + let coverage = coverages[i] + if coverage != 0: + data16[idx] = rgbxToRgb565( + blendNormal(rgb565ToRgbx(data16[idx]), rgbx * coverage)) + inc idx + of MaskBlend: + for i in 0 ..< coverages.len: + let coverage = coverages[i] + if coverage != 255: + data16[idx] = rgbxToRgb565( + blendMask(rgb565ToRgbx(data16[idx]), rgbx * coverage)) + inc idx + image.clearUnsafe(0, y, startX, y) + image.clearUnsafe(startX + coverages.len, y, image.width, y) + else: + let blender = blendMode.blender() + for i in 0 ..< coverages.len: + let coverage = coverages[i] + if coverage != 0: + data16[idx] = rgbxToRgb565( + blender(rgb565ToRgbx(data16[idx]), rgbx * coverage)) + inc idx proc blendLineCoverageOverwrite( line: ptr UncheckedArray[ColorRGBX], @@ -1483,6 +1531,10 @@ proc fillCoverage( coverages: seq[uint8], blendMode: BlendMode ) = + if image.format == pfRgb565: + image.fillCoverage565(rgbx, startX, y, coverages, blendMode) + return + var x = startX dataIndex = image.dataIndex(x, y) @@ -1537,6 +1589,61 @@ proc blendLineMask( for i in 0 ..< len: line[i] = blendMask(line[i], rgbx) +proc fillHits565( + image: Image, + rgbx: ColorRGBX, + startX, y: int, + hits: seq[(Fixed32, int16)], + numHits: int, + windingRule: WindingRule, + blendMode: BlendMode, + maskClears: bool +) = + ## `fillHits` for a 565 destination: the same span walk, with the solid + ## spans stored packed and the blended ones done per pixel at 8 bits. + let + data16 = image.data16 + packed = rgbxToRgb565(rgbx) + case blendMode: + of OverwriteBlend: + for (start, len) in hits.walkInteger(numHits, windingRule, y, image.width): + fillUnsafe16(data16, packed, image.dataIndex(start, y), len) + + of NormalBlend: + for (start, len) in hits.walkInteger(numHits, windingRule, y, image.width): + if rgbx.a == 255: + fillUnsafe16(data16, packed, image.dataIndex(start, y), len) + else: + var idx = image.dataIndex(start, y) + for _ in 0 ..< len: + data16[idx] = rgbxToRgb565(blendNormal(rgb565ToRgbx(data16[idx]), rgbx)) + inc idx + + of MaskBlend: + var filledTo = startX + for (start, len) in hits.walkInteger(numHits, windingRule, y, image.width): + if maskClears: + let gapBetween = start - filledTo + if gapBetween > 0: + fillUnsafe16(data16, 0, image.dataIndex(filledTo, y), gapBetween) + if rgbx.a != 255: + var idx = image.dataIndex(start, y) + for _ in 0 ..< len: + data16[idx] = rgbxToRgb565(blendMask(rgb565ToRgbx(data16[idx]), rgbx)) + inc idx + filledTo = start + len + if maskClears: + image.clearUnsafe(0, y, startX, y) + image.clearUnsafe(filledTo, y, image.width, y) + + else: + let blender = blendMode.blender() + for (start, len) in hits.walkInteger(numHits, windingRule, y, image.width): + var idx = image.dataIndex(start, y) + for _ in 0 ..< len: + data16[idx] = rgbxToRgb565(blender(rgb565ToRgbx(data16[idx]), rgbx)) + inc idx + proc fillHits( image: Image, rgbx: ColorRGBX, @@ -1547,6 +1654,11 @@ proc fillHits( blendMode: BlendMode, maskClears = true ) = + if image.format == pfRgb565: + image.fillHits565( + rgbx, startX, y, hits, numHits, windingRule, blendMode, maskClears) + return + case blendMode: of OverwriteBlend: for (start, len) in hits.walkInteger(numHits, windingRule, y, image.width): @@ -1657,7 +1769,10 @@ proc fillShapes( let start = image.dataIndex(0, y) len = image.dataIndex(0, y + partitionHeight) - start - fillUnsafe(image.data, rgbx, start, len) + if image.format == pfRgb565: + fillUnsafe16(image.data16, rgbxToRgb565(rgbx), start, len) + else: + fillUnsafe(image.data, rgbx, start, len) else: for r in 0 ..< partitionHeight: hits[0] = (cast[Fixed32](minX * 256), 1.int16) @@ -1800,13 +1915,13 @@ proc fillShapes( ((y + 1).float32 - prevPenY) * run area = triangleArea + rectArea + rightRectArea dataIndex = image.dataIndex(x, y) - backdrop = image.data[dataIndex] + backdrop = image.getPixel(dataIndex) source = when allowSimd and defined(amd64): applyOpacity(vecRgbx, area) else: rgbx * area - image.data[dataIndex] = blender(backdrop, source) + image.setPixel(dataIndex, blender(backdrop, source)) block: # Right-side partial coverage let @@ -1838,13 +1953,13 @@ proc fillShapes( ((y + 1).float32 - penY) * run area = leftRectArea + triangleArea + rectArea dataIndex = image.dataIndex(x, y) - backdrop = image.data[dataIndex] + backdrop = image.getPixel(dataIndex) source = when allowSimd and defined(amd64): applyOpacity(vecRgbx, area) else: rgbx * area - image.data[dataIndex] = blender(backdrop, source) + image.setPixel(dataIndex, blender(backdrop, source)) let fillBegin = leftCoverEnd.clamp(0, image.width) diff --git a/src/pixie/rgb565.nim b/src/pixie/rgb565.nim new file mode 100644 index 00000000..5c3e6c0c --- /dev/null +++ b/src/pixie/rgb565.nim @@ -0,0 +1,54 @@ +## Whole-image operations on a `pfRgb565` image, shared by the scalar bodies +## in `images.nim` and by every SIMD variant in `simd/`. +## +## The SIMD variants need these too, not just the scalar fallbacks: on amd64 +## and arm64 the `hasSimd` macro replaces the scalar body wholesale with a +## call to the SIMD variant, so a format check written only in the scalar +## body is compiled out exactly where the SIMD kernels would then read a 565 +## buffer as RGBX. Every image-level SIMD kernel therefore starts with +## `if image.format != pfRgbx: return `. + +import chroma, common + +when defined(release): + {.push checks: off.} + +proc isOneColor565*(image: Image): bool {.raises: [].} = + if image.isView: + return false + result = true + let first = image.data16[0] + for i in 0 ..< image.dataLen: + if image.data16[i] != first: + return false + +proc applyOpacity565*(image: Image, opacity: uint16) {.raises: [].} = + ## `opacity` already scaled to 0..255. No alpha to scale, so this scales the + ## colour — which is what a driver reading RGB off a premultiplied RGBA + ## canvas would have seen after `applyOpacity` there. + image.forEachSpan: + for i in spanStart ..< spanStart + spanLen: + var rgbx = rgb565ToRgbx(image.data16[i]) + rgbx.r = ((rgbx.r * opacity) div 255).uint8 + rgbx.g = ((rgbx.g * opacity) div 255).uint8 + rgbx.b = ((rgbx.b * opacity) div 255).uint8 + image.data16[i] = rgbxToRgb565(rgbx) + +proc invert565*(image: Image) {.raises: [].} = + ## Complementing each packed field is the exact complement of the expanded + ## channel: `expand(31 - r) + expand(r) == 255` for every `r`. + image.forEachSpan: + for i in spanStart ..< spanStart + spanLen: + image.data16[i] = not image.data16[i] + +proc ceil565*(image: Image) {.raises: [].} = + image.forEachSpan: + for i in spanStart ..< spanStart + spanLen: + var rgbx = rgb565ToRgbx(image.data16[i]) + rgbx.r = if rgbx.r == 0: 0 else: 255 + rgbx.g = if rgbx.g == 0: 0 else: 255 + rgbx.b = if rgbx.b == 0: 0 else: 255 + image.data16[i] = rgbxToRgb565(rgbx) + +when defined(release): + {.pop.} diff --git a/src/pixie/simd/avx2.nim b/src/pixie/simd/avx2.nim index 2460a3e8..e1f6db84 100644 --- a/src/pixie/simd/avx2.nim +++ b/src/pixie/simd/avx2.nim @@ -1,4 +1,4 @@ -import avx, chroma, nimsimd/hassimd, nimsimd/avx2, ../blends, ../common, vmath +import avx, chroma, nimsimd/hassimd, nimsimd/avx2, ../blends, ../common, ../rgb565, vmath when defined(gcc) or defined(clang): {.localPassC: "-mavx2".} @@ -42,6 +42,8 @@ template blendMaskSimd(backdrop, source: M256i): M256i = mm256_or_si256(backdropEven, mm256_slli_epi16(backdropOdd, 8)) proc isOneColorAvx2*(image: Image): bool {.simd.} = + if image.format != pfRgbx: + return image.isOneColor565() # A view's pixels are not one flat run, and this answer is only ever used to # take a shortcut, so declining for views is safe: the caller falls back to # the general path. @@ -78,6 +80,8 @@ proc isOneColorAvx2*(image: Image): bool {.simd.} = return false proc isTransparentAvx2*(image: Image): bool {.simd.} = + if image.format != pfRgbx: + return false # See isOneColorAvx2: a false answer for a view is conservative, not wrong. if image.isView: return false @@ -196,6 +200,9 @@ proc toPremultipliedAlphaAvx2*(data: var seq[ColorRGBA | ColorRGBX]) {.simd.} = data[i] = rgbx proc invertAvx2*(image: Image) {.simd.} = + if image.format != pfRgbx: + image.invert565() + return image.forEachSpan: let spanEnd = spanStart + spanLen @@ -243,6 +250,9 @@ proc invertAvx2*(image: Image) {.simd.} = image.data[i] = rgbx proc applyOpacityAvx2*(image: Image, opacity: float32) {.simd.} = + if image.format != pfRgbx: + image.applyOpacity565(round(255 * opacity).uint16) + return let opacity = round(255 * opacity).uint16 if opacity == 255: return @@ -303,6 +313,9 @@ proc applyOpacityAvx2*(image: Image, opacity: float32) {.simd.} = image.data[i] = rgbx proc ceilAvx2*(image: Image) {.simd.} = + if image.format != pfRgbx: + image.ceil565() + return image.forEachSpan: let spanEnd = spanStart + spanLen @@ -342,6 +355,8 @@ proc ceilAvx2*(image: Image) {.simd.} = proc minifyBy2Avx2*(image: Image, power = 1): Image {.simd.} = ## Scales the image down by an integer scale. + if image.format != pfRgbx: + return minifyBy2Avx2(image.toRgbxImage(), power) if power < 0: raise newException(PixieError, "Cannot minifyBy2 with negative power") if power == 0: diff --git a/src/pixie/simd/neon.nim b/src/pixie/simd/neon.nim index 96dd1719..d77aaed1 100644 --- a/src/pixie/simd/neon.nim +++ b/src/pixie/simd/neon.nim @@ -1,4 +1,4 @@ -import chroma, nimsimd/hassimd, nimsimd/neon, ../blends, ../common, vmath +import chroma, nimsimd/hassimd, nimsimd/neon, ../blends, ../common, ../rgb565, vmath when defined(release): {.push checks: off.} @@ -55,6 +55,8 @@ proc fillUnsafeNeon*( data[i] = rgbx proc isOneColorNeon*(image: Image): bool {.simd.} = + if image.format != pfRgbx: + return image.isOneColor565() # A view's pixels are not one flat run, and this answer is only ever used to # take a shortcut, so declining for views is safe: the caller falls back to # the general path. @@ -100,6 +102,8 @@ proc isOneColorNeon*(image: Image): bool {.simd.} = return false proc isTransparentNeon*(image: Image): bool {.simd.} = + if image.format != pfRgbx: + return false # See isOneColorNeon: a false answer for a view is conservative, not wrong. if image.isView: return false @@ -199,6 +203,9 @@ proc toPremultipliedAlphaNeon*(data: var seq[ColorRGBA | ColorRGBX]) {.simd.} = data[i] = c proc invertNeon*(image: Image) {.simd.} = + if image.format != pfRgbx: + image.invert565() + return image.forEachSpan: let spanEnd = spanStart + spanLen @@ -247,6 +254,9 @@ proc invertNeon*(image: Image) {.simd.} = image.data[i] = rgbx proc applyOpacityNeon*(image: Image, opacity: float32) {.simd.} = + if image.format != pfRgbx: + image.applyOpacity565(round(255 * opacity).uint16) + return let opacity = round(255 * opacity).uint8 if opacity == 255: return @@ -286,6 +296,9 @@ proc applyOpacityNeon*(image: Image, opacity: float32) {.simd.} = image.data[i] = rgbx proc ceilNeon*(image: Image) {.simd.} = + if image.format != pfRgbx: + image.ceil565() + return image.forEachSpan: let spanEnd = spanStart + spanLen @@ -315,6 +328,8 @@ proc ceilNeon*(image: Image) {.simd.} = proc minifyBy2Neon*(image: Image, power = 1): Image {.simd.} = ## Scales the image down by an integer scale. + if image.format != pfRgbx: + return minifyBy2Neon(image.toRgbxImage(), power) if power < 0: raise newException(PixieError, "Cannot minifyBy2 with negative power") if power == 0: @@ -413,6 +428,8 @@ proc minifyBy2Neon*(image: Image, power = 1): Image {.simd.} = proc magnifyBy2Neon*(image: Image, power = 1): Image {.simd.} = ## Scales image up by 2 ^ power. + if image.format != pfRgbx: + return magnifyBy2Neon(image.toRgbxImage(), power) if power < 0: raise newException(PixieError, "Cannot magnifyBy2 with negative power") diff --git a/src/pixie/simd/sse2.nim b/src/pixie/simd/sse2.nim index 0185cbd1..fc5ad7ee 100644 --- a/src/pixie/simd/sse2.nim +++ b/src/pixie/simd/sse2.nim @@ -1,4 +1,4 @@ -import chroma, nimsimd/hassimd, nimsimd/sse2, ../blends, ../common, vmath +import chroma, nimsimd/hassimd, nimsimd/sse2, ../blends, ../common, ../rgb565, vmath when defined(release): {.push checks: off.} @@ -74,6 +74,8 @@ proc fillUnsafeSse2*( data[i] = rgbx proc isOneColorSse2*(image: Image): bool {.simd.} = + if image.format != pfRgbx: + return image.isOneColor565() # A view's pixels are not one flat run, and this answer is only ever used to # take a shortcut, so declining for views is safe: the caller falls back to # the general path. @@ -118,6 +120,8 @@ proc isOneColorSse2*(image: Image): bool {.simd.} = return false proc isTransparentSse2*(image: Image): bool {.simd.} = + if image.format != pfRgbx: + return false # See isOneColorSse2: a false answer for a view is conservative, not wrong. if image.isView: return false @@ -236,6 +240,9 @@ proc toPremultipliedAlphaSse2*(data: var seq[ColorRGBA | ColorRGBX]) {.simd.} = data[i] = rgbx proc invertSse2*(image: Image) {.simd.} = + if image.format != pfRgbx: + image.invert565() + return image.forEachSpan: let spanEnd = spanStart + spanLen @@ -287,6 +294,9 @@ proc invertSse2*(image: Image) {.simd.} = image.data[i] = rgbx proc applyOpacitySse2*(image: Image, opacity: float32) {.simd.} = + if image.format != pfRgbx: + image.applyOpacity565(round(255 * opacity).uint16) + return let opacity = round(255 * opacity).uint16 if opacity == 255: return @@ -345,6 +355,9 @@ proc applyOpacitySse2*(image: Image, opacity: float32) {.simd.} = image.data[i] = rgbx proc ceilSse2*(image: Image) {.simd.} = + if image.format != pfRgbx: + image.ceil565() + return image.forEachSpan: let spanEnd = spanStart + spanLen @@ -389,6 +402,8 @@ proc ceilSse2*(image: Image) {.simd.} = proc minifyBy2Sse2*(image: Image, power = 1): Image {.simd.} = ## Scales the image down by an integer scale. + if image.format != pfRgbx: + return minifyBy2Sse2(image.toRgbxImage(), power) if power < 0: raise newException(PixieError, "Cannot minifyBy2 with negative power") if power == 0: @@ -496,6 +511,8 @@ proc minifyBy2Sse2*(image: Image, power = 1): Image {.simd.} = proc magnifyBy2Sse2*(image: Image, power = 1): Image {.simd.} = ## Scales image up by 2 ^ power. + if image.format != pfRgbx: + return magnifyBy2Sse2(image.toRgbxImage(), power) if power < 0: raise newException(PixieError, "Cannot magnifyBy2 with negative power") diff --git a/tests/test_rgb565.nim b/tests/test_rgb565.nim new file mode 100644 index 00000000..1a5ccde3 --- /dev/null +++ b/tests/test_rgb565.nim @@ -0,0 +1,330 @@ +## The 565 surface, tested as an oracle: every scenario is drawn onto an RGBX +## canvas and onto a 565 canvas with the same calls, and the RGBX result +## quantised to 565 must come out the same. Where the backdrop is an exactly +## representable colour (white, black) and the drawing is a single layer the +## match is bit-exact, because both paths run the same 8-bit blend and the +## only difference is where the final pack happens. Compound cases (layers +## over layers, non-representable backdrops) are allowed one 5-bit step: +## adjacent 5-bit codes expand 8 or 9 apart, so `oneStep = 9`. + +import pixie, pixie/fileformats/png, pixie/fileformats/jpeg, pixie/fileformats/svg, strformat + +proc maxChannelDiff(a565, b: Image): int = + ## Largest per-channel difference between a 565 image and any image, read + ## back as expanded RGB (alpha ignored — a 565 image has none). + doAssert a565.format == pfRgb565 + doAssert a565.width == b.width and a565.height == b.height + for y in 0 ..< a565.height: + for x in 0 ..< a565.width: + let + p = a565.unsafe[x, y] + q = b.unsafe[x, y] + result = max(result, abs(p.r.int - q.r.int)) + result = max(result, abs(p.g.int - q.g.int)) + result = max(result, abs(p.b.int - q.b.int)) + +template both(w, h: int, body: untyped) = + ## Runs `body` once with `canvas` an RGBX image and once a 565 one, then + ## yields the pair as (rgbx, packed) for comparison. + block: + let rgbxCanvas = newImage(w, h) + let packedCanvas = newImage565(w, h) + block: + let canvas {.inject.} = rgbxCanvas + body + block: + let canvas {.inject.} = packedCanvas + body + check(rgbxCanvas, packedCanvas) + +var checkTolerance = 0 +var checkName = "" +proc check(rgbxCanvas, packedCanvas: Image) = + let + oracle = rgbxCanvas.toRgb565Image() + diff = maxChannelDiff(packedCanvas, oracle) + doAssert diff <= checkTolerance, + &"{checkName}: 565 canvas differs from quantised RGBX by {diff} (> {checkTolerance})" + +const oneStep = 9 + +template scenario(name: string, tolerance: int, w, h: int, body: untyped) = + checkName = name + checkTolerance = tolerance + both(w, h, body) + +block: # pack / unpack properties + # Endpoints survive, expansion is exact at 0 and 255. + doAssert rgbxToRgb565(rgbx(0, 0, 0, 255)) == 0 + doAssert rgbxToRgb565(rgbx(255, 255, 255, 255)) == 0xFFFF + doAssert rgb565ToRgbx(0) == rgbx(0, 0, 0, 255) + doAssert rgb565ToRgbx(0xFFFF) == rgbx(255, 255, 255, 255) + # Round to nearest, bounded error, idempotent round trip. + for v in 0 .. 255: + let + c = rgbx(v.uint8, v.uint8, v.uint8, 255) + back = rgb565ToRgbx(rgbxToRgb565(c)) + doAssert abs(back.r.int - v) <= 4 + doAssert abs(back.g.int - v) <= 2 + doAssert abs(back.b.int - v) <= 4 + doAssert rgbxToRgb565(back) == rgbxToRgb565(c) + # Alpha is dropped, premultiplied colour kept, reads come back opaque. + let half = rgb565ToRgbx(rgbxToRgb565(rgbx(128, 64, 32, 128))) + doAssert half.a == 255 + doAssert abs(half.r.int - 128) <= 4 + +block: # construction, views, copies + let img = newImage565(10, 6) + doAssert img.format == pfRgb565 + doAssert img.bytesPerPixel == 2 + doAssert img.isOpaque + doAssert not img.isTransparent + doAssert img.opaqueBounds == rect(0, 0, 10, 6) + img.fill(rgba(10, 200, 30, 255)) + doAssert img.isOneColor + let v = img.view(2, 1, 4, 3) + doAssert v.format == pfRgb565 + v.fill(rgba(255, 0, 0, 255)) + doAssert img[2, 1] == rgb565ToRgbx(rgbxToRgb565(rgbx(255, 0, 0, 255))) + doAssert img[1, 1] == rgb565ToRgbx(rgbxToRgb565(rgbx(10, 200, 30, 255))) + doAssert img[6, 1] == rgb565ToRgbx(rgbxToRgb565(rgbx(10, 200, 30, 255))) + let c = img.copy() + doAssert c.format == pfRgb565 + doAssert c.pixelsEqual(img) + let asRgbx = img.toRgbxImage() + doAssert asRgbx.format == pfRgbx + doAssert img.pixelsEqual(asRgbx) # cross-format compare reads both back + doAssert img.toContiguousSeq()[0] == img[0, 0] + let sub = img.subImage(2, 1, 4, 3) + doAssert sub.format == pfRgb565 and sub.pixelsEqual(v) + # flips and rotate keep the format + let f = img.copy() + f.flipHorizontal() + doAssert f[9 - 2, 1] == img[2, 1] + f.flipVertical() + doAssert f[9 - 2, 5 - 1] == img[2, 1] + let r = img.copy() + r.rotate90() + doAssert r.width == 6 and r.height == 10 and r.format == pfRgb565 + # external buffer + var buf = newSeq[uint16](8 * 4) + let over = newImage565Over(8, 4, buf[0].addr) + over.fill(rgba(255, 255, 255, 255)) + doAssert buf[0] == 0xFFFF and buf[31] == 0xFFFF + doAssert newImage565Over(8, 4, buf[0].addr).isView == false + +block: # what a 565 image refuses, and what it converts for + let img = newImage565(8, 8) + doAssertRaises PixieError: + discard img.shadow(vec2(1, 1), 1, 1, rgba(0, 0, 0, 255)) + # Scaling converts through RGBX rather than refusing. + img.fill(rgba(200, 100, 50, 255)) + let small = img.minifyBy2() + doAssert small.format == pfRgbx and small.width == 4 + doAssert abs(small[0, 0].r.int - 200) <= 4 + let big = img.magnifyBy2() + doAssert big.format == pfRgbx and big.width == 16 + let rs = img.resize(4, 4) + doAssert rs.width == 4 + +scenario("fill", 0, 16, 16): + canvas.fill(rgba(200, 100, 50, 255)) + +scenario("fill transparent is black", 0, 8, 8): + canvas.fill(rgba(0, 0, 0, 0)) + +scenario("AA circle over white", 0, 64, 64): + canvas.fill(rgba(255, 255, 255, 255)) + let p = newPath() + p.circle(32, 32, 20) + canvas.fillPath(p, rgba(30, 60, 200, 255)) + +scenario("AA circle translucent over white", 0, 64, 64): + canvas.fill(rgba(255, 255, 255, 255)) + let p = newPath() + p.circle(32, 32, 20) + canvas.fillPath(p, rgba(30, 60, 200, 120)) + +scenario("AA stroke over black", 0, 64, 64): + canvas.fill(rgba(0, 0, 0, 255)) + let p = newPath() + p.rect(10.5, 10.5, 40, 30) + canvas.strokePath(p, rgba(255, 200, 0, 255), strokeWidth = 3) + +scenario("non-AA axis-aligned rect", 0, 32, 32): + canvas.fill(rgba(255, 255, 255, 255)) + let p = newPath() + p.rect(4, 4, 20, 12) + canvas.fillPath(p, rgba(0, 128, 0, 255)) + +scenario("two overlapping AA layers", oneStep, 64, 64): + canvas.fill(rgba(255, 255, 255, 255)) + let p = newPath() + p.circle(28, 32, 18) + canvas.fillPath(p, rgba(255, 0, 0, 160)) + let q = newPath() + q.circle(40, 32, 18) + canvas.fillPath(q, rgba(0, 0, 255, 160)) + +scenario("text over white", 0, 120, 40): + canvas.fill(rgba(255, 255, 255, 255)) + let font = readFont("tests/fonts/Roboto-Regular_1.ttf") + font.size = 24 + canvas.fillText(font, "565 ok", translate(vec2(4, 4))) + +scenario("text stroke over white", 0, 120, 40): + canvas.fill(rgba(255, 255, 255, 255)) + let font = readFont("tests/fonts/Roboto-Regular_1.ttf") + font.size = 24 + font.paint = rgba(0, 80, 160, 255) + canvas.strokeText(font, "565 ok", translate(vec2(4, 4)), strokeWidth = 1.5) + +scenario("draw image overwrite (blendRect path)", 0, 40, 40): + canvas.fill(rgba(255, 255, 255, 255)) + let src = newImage(16, 16) + src.fill(rgba(200, 40, 40, 255)) + canvas.draw(src, translate(vec2(8, 8)), OverwriteBlend) + +scenario("draw translucent image normal (blendRect path)", 0, 40, 40): + canvas.fill(rgba(255, 255, 255, 255)) + let src = newImage(16, 16) + src.fill(rgba(200, 40, 40, 100)) + canvas.draw(src, translate(vec2(8, 8)), NormalBlend) + +scenario("draw image scaled (drawSmooth path)", 0, 40, 40): + canvas.fill(rgba(255, 255, 255, 255)) + let src = newImage(16, 16) + src.fill(rgba(20, 140, 40, 255)) + canvas.draw(src, translate(vec2(5, 5)) * scale(vec2(1.7, 1.3)), NormalBlend) + +scenario("draw image rotated (drawSmooth path)", 0, 64, 64): + canvas.fill(rgba(255, 255, 255, 255)) + let src = newImage(20, 20) + src.fill(rgba(20, 40, 140, 200)) + canvas.draw(src, translate(vec2(32, 32)) * rotate(0.4.float32) * translate(vec2(-10, -10))) + +scenario("draw with multiply blend", 0, 32, 32): + canvas.fill(rgba(255, 200, 200, 255)) + let src = newImage(16, 16) + src.fill(rgba(100, 255, 100, 255)) + canvas.draw(src, translate(vec2(8, 8)), MultiplyBlend) + +scenario("mask blend blackens outside", 0, 32, 32): + canvas.fill(rgba(255, 255, 255, 255)) + let src = newImage(16, 16) + src.fill(rgba(255, 255, 255, 255)) + canvas.draw(src, translate(vec2(8, 8)), MaskBlend) + +scenario("gradient", 0, 40, 40): + let paint = newPaint(LinearGradientPaint) + paint.gradientHandlePositions = @[vec2(0, 0), vec2(40, 0)] + paint.gradientStops = @[ + ColorStop(color: color(1, 0, 0, 1), position: 0), + ColorStop(color: color(0, 0, 1, 1), position: 1) + ] + canvas.fillGradient(paint) + +scenario("opacity on canvas", 0, 24, 24): + canvas.fill(rgba(255, 255, 255, 255)) + canvas.applyOpacity(0.5) + +block: # invert on a 565 canvas is the RGB complement + # (Not oracle-tested: RGBX `invert` also inverts alpha, which turns an + # opaque canvas into transparent black — rgb 0 everywhere once a driver + # reads it. A 565 image has no alpha to invert, so it gives the picture.) + let img = newImage565(24, 24) + img.fill(rgba(255, 255, 255, 255)) + let p = newPath() + p.rect(4, 4, 10, 10) + img.fillPath(p, rgba(0, 0, 0, 255)) + img.invert() + doAssert img[0, 0] == rgbx(0, 0, 0, 255) + doAssert img[5, 5] == rgbx(255, 255, 255, 255) + img.fill(rgba(200, 100, 50, 255)) + img.invert() + doAssert abs(img[0, 0].r.int - 55) <= 4 + doAssert abs(img[0, 0].g.int - 155) <= 2 + doAssert abs(img[0, 0].b.int - 205) <= 4 + +scenario("view: draw into a cell writes through, leaves the rest", 0, 40, 40): + canvas.fill(rgba(255, 255, 255, 255)) + let cell = canvas.view(10, 10, 20, 20) + cell.fill(rgba(0, 0, 0, 255)) + let p = newPath() + p.circle(10, 10, 8) + cell.fillPath(p, rgba(255, 0, 0, 255)) + +scenario("context API on a 565 canvas", 0, 64, 64): + canvas.fill(rgba(255, 255, 255, 255)) + let ctx = newContext(canvas) + ctx.fillStyle = rgba(0, 100, 200, 255) + ctx.fillRoundedRect(rect(8, 8, 40, 30), 6) + ctx.strokeStyle = rgba(200, 0, 0, 255) + ctx.lineWidth = 2 + ctx.strokeRect(rect(4, 4, 56, 56)) + +scenario("svg renderInto", 0, 64, 64): + canvas.fill(rgba(255, 255, 255, 255)) + let svg = parseSvg( + """""" & + """""" & + """""" & + """""") + svg.renderInto(canvas) + +scenario("gradient-paint fillPath (mask+fill scratch path)", oneStep, 64, 64): + canvas.fill(rgba(255, 255, 255, 255)) + let paint = newPaint(LinearGradientPaint) + paint.gradientHandlePositions = @[vec2(8, 0), vec2(56, 0)] + paint.gradientStops = @[ + ColorStop(color: color(1, 0, 0, 1), position: 0), + ColorStop(color: color(0, 0, 1, 1), position: 1) + ] + let p = newPath() + p.circle(32, 32, 24) + canvas.fillPath(p, paint) + +scenario("decode PNG scaled into", 0, 30, 20): + canvas.fill(rgba(255, 255, 255, 255)) + let data = readFile("tests/fileformats/png/lenna.png") + discard decodeImageScaledInto(data, canvas, fitCover) + +scenario("decode JPEG scaled into", 0, 30, 20): + canvas.fill(rgba(255, 255, 255, 255)) + let data = readFile("tests/fileformats/jpeg/masters/mandrill.jpg") + discard decodeImageScaledInto(data, canvas, fitContain) + +proc sourceOf(data: string): PngSourceProc = + var pos = 0 + result = proc (dst: pointer, maxBytes: int): int = + let n = min(min(4096, maxBytes), data.len - pos) + if n <= 0: + return 0 + copyMem(dst, data[pos].unsafeAddr, n) + pos += n + n + +scenario("decode PNG streamed into", 0, 33, 21): + canvas.fill(rgba(255, 255, 255, 255)) + let data = readFile("tests/fileformats/png/lenna.png") + decodePngStreamScaledInto(sourceOf(data), data.len, canvas, fitStretch) + +block: # encoding a 565 canvas round-trips through the expansion + let img = newImage565(8, 8) + img.fill(rgba(200, 100, 50, 255)) + let png = decodeImage(encodePng(img)) + doAssert png.width == 8 + doAssert png[0, 0] == img[0, 0] + +block: # a 565 image as a draw SOURCE onto RGBX + let src = newImage565(8, 8) + src.fill(rgba(200, 100, 50, 255)) + let dst = newImage(16, 16) + dst.fill(rgba(255, 255, 255, 255)) + dst.draw(src, translate(vec2(4, 4))) + doAssert dst[5, 5] == src[0, 0] + doAssert dst[5, 5].a == 255 + dst.draw(src, translate(vec2(2, 2)) * scale(vec2(1.5, 1.5))) + doAssert dst[8, 8] == src[0, 0] + +echo "test_rgb565 ok" diff --git a/tests/tests.nim b/tests/tests.nim index 002bfeeb..6a490c0b 100644 --- a/tests/tests.nim +++ b/tests/tests.nim @@ -12,6 +12,7 @@ import test_png, test_ppm, test_qoi, + test_rgb565, test_webp, test_svg, xrays From 64ce3c9d50170ba5c1e879b414710650a2811423 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 19 Aug 2026 11:54:29 +0200 Subject: [PATCH 2/2] bufferPointer and byteSize: identity and size that do not assume 4 bytes A FrameOS cache asks "does this value alias the live canvas?" by comparing buffer pointers; with a 565 canvas the RGBX pointer of both sides is nil and the answer was wrong. bufferPointer answers by whichever buffer the format uses; byteSize is width * height * bytesPerPixel for the memory limits that used to write the 4 out by hand. Co-Authored-By: Claude Fable 5 --- src/pixie/common.nim | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/pixie/common.nim b/src/pixie/common.nim index 4a6a2604..84abceb3 100644 --- a/src/pixie/common.nim +++ b/src/pixie/common.nim @@ -211,6 +211,19 @@ template setPixel*(image: Image, index: int, color: ColorRGBX) = template bytesPerPixel*(image: Image): int = (if image.format == pfRgbx: 4 else: 2) +template bufferPointer*(image: Image): pointer = + ## The start of the pixel buffer this image addresses, whatever its format. + ## Two images share memory (one is a view of the other, or of the same + ## owner) exactly when this is equal — the identity question "does this + ## value alias my canvas?" asked in a way that is not fooled by one side + ## being RGBX and the other 565. + (if image.format == pfRgb565: cast[pointer](image.pixels16) + else: cast[pointer](image.pixels)) + +template byteSize*(image: Image): int = + ## Bytes the image's own pixels occupy: width * height * bytesPerPixel. + image.width * image.height * image.bytesPerPixel + template requireRgbx*(image: Image, what: string) = ## Guard for operations that are only defined on an RGBA image — the ones ## whose meaning is the alpha channel. Raising is the honest answer: a 565