PngEncoder.kt
src/jvmMain/kotlin/afield/media/image/util/PngEncoder.kt · 210 lines
package afield.media.image.util
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
Encodes ARGB pixels, or an RGBA ByteBuffer read back from the GPU, into PNG bytes without AWT or
javax.imageio.
Output is 8-bit RGBA (PNG colour type 6), one unfiltered scanline per row, compressed with
java.util.zip.Deflater, with an sRGB chunk. PngEncoder.encode takes one ARGB Int per pixel;
PngEncoder.encodeFromBuffer takes a readback buffer and can flip it vertically and force alpha
to opaque.
object PngEncoder
Pure-Kotlin PNG writer used for screenshot capture and image export.
object PngEncoder {
fun encode(pixels: IntArray, width: Int, height: Int): ByteArray
Encode ARGB pixel data to PNG format.
@param pixels IntArray containing ARGB pixel data (one int per pixel) @param width Image width in pixels @param height Image height in pixels @return ByteArray containing the complete PNG file data
fun encode(pixels: IntArray, width: Int, height: Int): ByteArray {
val output = ByteArrayOutputStream()
// PNG signature (8 bytes)
output.write(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))
// IHDR chunk (image header)
writeChunk(output, "IHDR", createIHDR(width, height))
// Mark screenshot/export PNGs as sRGB. Browsers assume this in practice,
// but the explicit chunk keeps native viewers and docs captures aligned.
writeChunk(output, "sRGB", byteArrayOf(0))
// IDAT chunk (image data)
writeChunk(output, "IDAT", createIDAT(pixels, width, height))
// IEND chunk (end of file)
writeChunk(output, "IEND", byteArrayOf())
return output.toByteArray()
}
fun encodeFromBuffer
Convert a ByteBuffer containing RGBA image data to PNG bytes.
@param buffer ByteBuffer containing RGBA pixel data (4 bytes per pixel) @param width Image width in pixels @param height Image height in pixels @param flipY When true, invert row order for framebuffer-style readbacks whose origin is bottom-left. Vulkan image-to-buffer readbacks usually want false. @param opaque When true, force every pixel's alpha to 255 and ignore the source alpha.
Pass true for any readback of a presented or otherwise composited surface — a swapchain
image, a whole-window framebuffer. Such a surface is opaque by definition, and its alpha
channel is leftover per-fragment coverage rather than transparency anyone meant to keep.
Shipping that leftover is not cosmetic. Measured 2026-08-29 across the 207 RGBA PNGs under
public/docs/assets: exactly three had non-opaque alpha, and all three were alpha-blended
particle scenes captured through this function (gpu_state/particle-field.png 29.5% of pixels
partially transparent, gpu_state/flat-fbm.png 22.7%, field_probes/particles-galaxy.png
8.0%). frac(alpha == 0) was exactly 0 in all three, so nothing was ever meant to be
transparent. The effect is that the image's appearance becomes a function of the viewer's
background: those figures render up to 3.28x brighter on a white page than on a dark one,
which is how the bug was found -- the same file looked like two different pictures in a docs
page and in a browser tab. Opaque geometry captures were unaffected, because their swapchain
alpha happens to already be 1.0, which is why this went unnoticed for as long as the
field_probes page has existed.
Forcing alpha also shrinks the file, because a constant channel compresses to nothing:
flat-fbm.png went 7,208,908 -> 2,720,375 bytes, 2.65x smaller, with identical RGB.
@return ByteArray containing the complete PNG file data
fun encodeFromBuffer(
buffer: ByteBuffer,
width: Int,
height: Int,
flipY: Boolean = false,
opaque: Boolean = false
): ByteArray {
// Reset buffer position
buffer.rewind()
// Create ARGB array (PNG encoder expects ARGB ints)
val pixels = IntArray(width * height)
// Read pixels, optionally flipping vertically.
for (y in 0 until height) {
for (x in 0 until width) {
val r = buffer.get().toInt() and 0xFF
val g = buffer.get().toInt() and 0xFF
val b = buffer.get().toInt() and 0xFF
// Always consume the source alpha byte -- the buffer is packed RGBA and the read
// must advance whether or not the value is used.
val sourceAlpha = buffer.get().toInt() and 0xFF
val a = if (opaque) 0xFF else sourceAlpha
val argb = (a shl 24) or (r shl 16) or (g shl 8) or b
val targetY = if (flipY) height - 1 - y else y
pixels[x + targetY * width] = argb
}
}
return encode(pixels, width, height)
}
Create IHDR (Image Header) chunk data.
private fun createIHDR(width: Int, height: Int): ByteArray {
val ihdr = ByteArrayOutputStream()
writeInt(ihdr, width)
writeInt(ihdr, height)
ihdr.write(8) // bit depth
ihdr.write(6) // color type (RGBA)
ihdr.write(0) // compression method (deflate)
ihdr.write(0) // filter method (adaptive)
ihdr.write(0) // interlace method (none)
return ihdr.toByteArray()
}
Create IDAT (Image Data) chunk data with DEFLATE compression.
private fun createIDAT(pixels: IntArray, width: Int, height: Int): ByteArray {
val uncompressed = ByteArrayOutputStream()
// Write each scanline with filter byte
for (y in 0 until height) {
uncompressed.write(0) // filter type: none
for (x in 0 until width) {
val pixel = pixels[x + y * width]
uncompressed.write((pixel shr 16) and 0xFF) // R
uncompressed.write((pixel shr 8) and 0xFF) // G
uncompressed.write(pixel and 0xFF) // B
uncompressed.write((pixel shr 24) and 0xFF) // A
}
}
// Compress using DEFLATE
val compressed = java.util.zip.Deflater().let { deflater ->
val data = uncompressed.toByteArray()
deflater.setInput(data)
deflater.finish()
val buffer = ByteArray(data.size + 1024)
val compressedSize = deflater.deflate(buffer)
deflater.end()
buffer.copyOf(compressedSize)
}
return compressed
}
Write a PNG chunk (length, type, data, CRC).
private fun writeChunk(output: ByteArrayOutputStream, type: String, data: ByteArray) {
// Length (4 bytes)
writeInt(output, data.size)
// Type (4 bytes)
output.write(type.toByteArray(Charsets.US_ASCII))
// Data (variable length)
output.write(data)
// CRC (4 bytes) - calculated over type and data
val crc = calculateCRC(type.toByteArray(Charsets.US_ASCII) + data)
writeInt(output, crc.toInt())
}
Write a 32-bit integer in big-endian format.
private fun writeInt(output: ByteArrayOutputStream, value: Int) {
output.write((value shr 24) and 0xFF)
output.write((value shr 16) and 0xFF)
output.write((value shr 8) and 0xFF)
output.write(value and 0xFF)
}
Calculate CRC-32 checksum for PNG chunks. Uses the polynomial defined in the PNG specification.
private fun calculateCRC(data: ByteArray): Long {
var crc = 0xFFFFFFFFL
for (b in data) {
crc = crc xor (b.toLong() and 0xFF)
for (i in 0 until 8) {
crc = if ((crc and 1) != 0L) {
0xEDB88320L xor (crc ushr 1)
} else {
crc ushr 1
}
}
}
return crc xor 0xFFFFFFFFL
}
}