flutter3d
Showcase Changelog 38 packages API reference

Image decoding without dart:ui

since 0.7.0 Formats and I/O

flutter3d_core cannot import dart:ui: it has no window, and a texture node's own bake needs pixels somewhere that does not require one either. So PNG, JPEG and Radiance .hdr are each read by a decoder written here, not borrowed from Flutter. This page runs all three.

Step 1: A real JPEG, encoded elsewhere #

This is a real, quality-100 JPEG of a flat colour, one 8x8 block, the smallest a baseline JPEG can be.

// A real JPEG, one 8x8 MCU encoded at quality 100 from a flat
// (200, 100, 50) fill, the same fixture the decoder's own test checks
// against that exact colour.
static const String _jpegBase64 =
    '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB'
    'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEB'
    'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAAR'
    'CAAIAAgDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAA'
    'AgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK'
    'FhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG'
    'h4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl'
    '5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREA'
    'AgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk'
    'NOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOE'
    'hYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk'
    '5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDn6/yvP9sD/9k=';

Step 2: Encode a PNG, and decode it back #

Unlike the JPEG, the PNG here is built by this page: encodeCompressedPng writes a small checkerboard, and decodePng reads it straight back.

// A tiny checkerboard, encoded here and decoded straight back: a real
// compressed PNG, not a fixture on disk.
final width = 4, height = 4;
final rgba = Uint8List(width * height * 4);
for (var y = 0; y < height; y++) {
  for (var x = 0; x < width; x++) {
    final lit = (x + y).isEven;
    final o = (y * width + x) * 4;
    rgba[o] = lit ? 240 : 20;
    rgba[o + 1] = lit ? 240 : 20;
    rgba[o + 2] = lit ? 240 : 20;
    rgba[o + 3] = 255;
  }
}
final pngBytes = encodeCompressedPng(width, height, rgba);
_png = decodePng(pngBytes)!;

Step 3: Decode the JPEG #

decodeJpeg is this package's own baseline decoder: Huffman tables, chroma upsampling, the inverse DCT, all of it. A flat fill has no block edges to hide a rounding error behind, which is why the source file is exactly that.

final jpegBytes = base64Decode(_jpegBase64);
_jpeg = decodeJpeg(jpegBytes)!;

Step 4: Decode a hand-built HDR #

Radiance's .hdr stores three floats a pixel as four bytes: a shared exponent and three mantissas. This file is two pixels wide, which is under the width the newer run-length form requires, so it is written the old, flat way: a header, then every pixel's four raw bytes in order.

// Radiance's old, flat scanline encoding: a header, a resolution line,
// then every pixel as four raw bytes (R, G, B, a shared exponent). Two
// by two is under the width the new run-length form requires, so this
// is read the simple way.
final hdrText = '#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 2 +X 2\n';
final hdrBytes = Uint8List.fromList(<int>[
  ...utf8.encode(hdrText),
  // Top row: a dim red pixel, a bright red pixel.
  255, 0, 0, 120, 255, 0, 0, 128,
  // Bottom row: two pixels at half that brightness again.
  255, 0, 0, 119, 255, 0, 0, 127,
]);
_hdr = readHdr(hdrBytes);

Step 5: Read the numbers back #

Three decoders, three answers, none of them touching dart:ui.

String _report() =>
    'PNG:  ${_png.width}x${_png.height}, top-left pixel '
    '${_png.rgba[0]},${_png.rgba[1]},${_png.rgba[2]}\n'
    'JPEG: ${_jpeg.width}x${_jpeg.height}, decodes back to about '
    '${_jpeg.rgba[0]},${_jpeg.rgba[1]},${_jpeg.rgba[2]} (encoded from '
    '200,100,50)\n'
    'HDR:  ${_hdr.width}x${_hdr.height}, top-left red channel '
    '${_hdr.rgb[0].toStringAsFixed(3)}';

Step 6: Check the claim #

The PNG has to survive its own round trip, the JPEG has to decode close to the colour it was encoded from, and the HDR pixel has to come back brighter than black.

if (_png.width != 4 || _png.height != 4) {
  throw StateError('the PNG did not round-trip its own size');
}
if (_png.rgba[0] < 200) {
  throw StateError('the PNG round trip lost its lit corner');
}
if ((_jpeg.rgba[0] - 200).abs() > 5 || (_jpeg.rgba[1] - 100).abs() > 5) {
  throw StateError('the JPEG did not decode close to its own fill');
}
if (_hdr.rgb[0] <= 0.0) {
  throw StateError('the HDR pixel decoded to nothing');
}
if (frame.drawCalls < 1) {
  throw StateError('the block was not drawn');
}