UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

1,048 lines (839 loc) • 27.6 kB
/*global Uint8Array:true ArrayBuffer:true */ "use strict"; import zlib from 'pako'; import { assert } from "../../../../../core/assert.js"; import { BinaryBuffer } from "../../../../../core/binary/BinaryBuffer.js"; import { EndianType } from "../../../../../core/binary/EndianType.js"; import { platform_compute_endianness } from "../../../../../core/binary/platform_compute_endianness.js"; import { isArrayEqualStrict } from "../../../../../core/collection/array/isArrayEqualStrict.js"; import { crc32 } from "./crc32.js"; import { PNG } from './PNG.js'; import { PNG_HEADER_BYTES } from "./PNG_HEADER_BYTES.js"; /** * * @param {Uint8Array} encoded_chunk * @returns {ArrayBuffer} */ function inflate(encoded_chunk) { const inflator = new zlib.Inflate(); inflator.push(encoded_chunk); if (inflator.err) { throw new Error(inflator.err); } return inflator.result.buffer; } /** * * @param {Uint8Array} buffer * @param {number} offset * @return {number} */ function readUInt32(buffer, offset) { return (buffer[offset] << 24) | (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | (buffer[offset + 3]); } /** * * @param {Uint8Array} buffer * @param {number} offset * @return {number} */ function readUInt8(buffer, offset) { return buffer[offset]; } /** * * @param {ArrayBuffer} bytes * @constructor */ export function PNGReader(bytes) { /** * current pointer * @type {number} */ this.i = 0; /** * bytes buffer * @type {Uint8Array} */ this.bytes = new Uint8Array(bytes); /** * Output object * @type {PNG} */ this.png = new PNG(); /** * * @type {Uint8Array[]} */ this.dataChunks = []; /** * * @type {BinaryBuffer} */ this.buffer = new BinaryBuffer(); // see https://www.w3.org/TR/2003/REC-PNG-20031110/#7Integers-and-byte-order this.buffer.endianness = EndianType.BigEndian; this.buffer.fromArrayBuffer(bytes); /** * Whether CRC should be performed or not * @type {boolean} */ this.crc_enabled = false; /** * * @type {Uint8Array} */ this.header = new Uint8Array(8); } /** * * @param {number} length * @return {Uint8Array} */ PNGReader.prototype.readBytes = function (length) { const buffer = this.buffer; // Reference instead of a copy const result = new Uint8Array(buffer.data, buffer.position, length); buffer.skip(length); return result; }; /** * http://www.w3.org/TR/2003/REC-PNG-20031110/#5PNG-file-signature */ PNGReader.prototype.decodeHeader = function () { if (this.i !== 0) { throw new Error('file pointer should be at 0 to read the header'); } const buffer = this.buffer; const header = this.header; buffer.readBytes(header, 0, 8) // see https://www.w3.org/TR/PNG-Structure.html if (!isArrayEqualStrict(header, PNG_HEADER_BYTES)) { throw new Error('invalid PNGReader file (bad signature)'); } }; /** * http://www.w3.org/TR/2003/REC-PNG-20031110/#5Chunk-layout * * length = 4 bytes * type = 4 bytes (IHDR, PLTE, IDAT, IEND or others) * chunk = length bytes * crc = 4 bytes * * @returns {string} chunk type */ PNGReader.prototype.decodeChunk = function () { const buffer = this.buffer; const length = buffer.readUint32(); if (length < 0) { throw new Error('Bad chunk length ' + (0xFFFFFFFF & length)); } const chunk_address = buffer.position; const type = buffer.readASCIICharacters(4); /** * * @type {Uint8Array} */ const chunk = this.readBytes(length); // checksum const crc_expected = buffer.readUint32(); if (this.crc_enabled) { // NOTE: CRC includes the "type" tag, not just the chunk bytes const crc_actual = crc32(buffer.raw_bytes, chunk_address, length + 4); if (crc_actual !== crc_expected) { console.warn(`CRC (Cyclic Redundancy Check) error at block '${type}' at address ${chunk_address}`); } } // console.log(`chunk: ${type}`); switch (type) { case 'IHDR': this.decodeIHDR(chunk); break; case 'PLTE': this.decodePLTE(chunk); break; case 'IDAT': this.decodeIDAT(chunk); break; case 'tRNS': this.decodeTRNS(chunk); break; case 'IEND': this.decodeIEND(chunk); break; case 'sRGB': this.decodesRGB(chunk); break; case 'tIME': this.decodetIME(chunk); break; case 'zTXt': this.decodezTXt(chunk); break; case 'iTXt': this.decodeiTXt(chunk); break; default: // skip unknown block // console.warn(`Unsupported block ${type}`); break; } return type; }; /** * https://www.w3.org/TR/2003/REC-PNG-20031110/#11sRGB * @param {Uint8Array} chunk */ PNGReader.prototype.decodesRGB = function (chunk) { const rendering_intent = readUInt8(chunk, 0); // TODO add metadata to the PNG representation } /** * https://www.w3.org/TR/2003/REC-PNG-20031110/#11tIME * @param {Uint8Array} chunk */ PNGReader.prototype.decodetIME = function (chunk) { const year_high = readUInt8(chunk, 0); const year_low = readUInt8(chunk, 1); const year = (year_high << 8) | year_low; const month = readUInt8(chunk, 2); const day = readUInt8(chunk, 3); const hour = readUInt8(chunk, 4); const minute = readUInt8(chunk, 5); const second = readUInt8(chunk, 6); } /** * International textual data * @see https://www.w3.org/TR/2003/REC-PNG-20031110/ * @param {Uint8Array} chunk */ PNGReader.prototype.decodeiTXt = function (chunk) { const buffer = BinaryBuffer.fromArrayBuffer(chunk.buffer); const keyword = buffer.readASCIICharacters(79, true); const compression_flag = buffer.readUint8(); const compression_method = buffer.readUint8(); const language_tag = buffer.readASCIICharacters(Infinity, true); const translated_keyword = buffer.readASCIICharacters(Infinity, true); const remaining_bytes = buffer.data.length - buffer.position; let text; if (compression_flag === 0) { text = buffer.readASCIICharacters(remaining_bytes); } else if (compression_flag === 1) { if(compression_method !== 0){ throw new Error('only compression_method 0 is supported'); } const decoded = inflate(new Uint8Array(buffer.data, buffer.position, remaining_bytes)); buffer.fromArrayBuffer(decoded); text = buffer.readASCIICharacters(decoded.byteLength); } else { throw new Error(`Invalid compression flag value '${compression_flag}'`); } return { keyword, language_tag, translated_keyword, text } } /** * Compressed textual data * @see https://www.w3.org/TR/2003/REC-PNG-20031110/#11zTXt * @param {Uint8Array} chunk */ PNGReader.prototype.decodezTXt = function (chunk) { const buffer = BinaryBuffer.fromArrayBuffer(chunk.buffer); const keyword = buffer.readASCIICharacters(79, true); const compression_method = buffer.readUint8(); let value; if (compression_method === 0) { // deflate method if(compression_method !== 0){ throw new Error('only compression_method 0 is supported'); } const encoded_chunk = new Uint8Array(chunk.buffer, buffer.position); const decompressed_data = inflate(encoded_chunk); buffer.fromArrayBuffer(decompressed_data); value = buffer.readASCIICharacters(decompressed_data.length); } else { throw new Error(`Unsupported compression method '${compression_method}'`); } return { keyword: keyword, text: value } } /** * https://www.w3.org/TR/PNG/#11tEXt * @param {Uint8Array} chunk */ PNGReader.prototype.decodetEXt = function (chunk) { const buff = BinaryBuffer.fromArrayBuffer(chunk.buffer); const keyword = buff.readASCIICharacters(Number.POSITIVE_INFINITY, true); const value = buff.readASCIICharacters(keyword.length - 1, false); this.png.text[keyword] = value; } /** * NOTE: untested * https://www.w3.org/TR/PNG/#11iEXt * @param {Uint8Array} chunk */ PNGReader.prototype.decodeiEXt = function (chunk) { const buff = BinaryBuffer.fromArrayBuffer(chunk.buffer); const keyword = buff.readASCIICharacters(Number.POSITIVE_INFINITY, true); const compression_flag = buff.readUint8(); const compression_method = buff.readUint8(); // TODO process compression properly const language_tag = buff.readASCIICharacters(Number.POSITIVE_INFINITY, true); const translated_keyword = buff.readUTF8String(); const separator = buff.readUint8(); if (separator !== 0) { throw new Error('Expected Null Separator after Translated keyword'); } const text = buff.readUTF8String(); this.png.text[keyword] = text; } /** * http://www.w3.org/TR/2003/REC-PNG-20031110/#11IHDR * http://www.libpng.org/pub/png/spec/1.2/png-1.2-pdg.html#C.IHDR * * Width 4 bytes * Height 4 bytes * Bit depth 1 byte * Colour type 1 byte * Compression method 1 byte * Filter method 1 byte * Interlace method 1 byte */ PNGReader.prototype.decodeIHDR = function (chunk) { const png = this.png; png.setWidth(readUInt32(chunk, 0)); png.setHeight(readUInt32(chunk, 4)); png.setBitDepth(readUInt8(chunk, 8)); png.setColorType(readUInt8(chunk, 9)); png.setCompressionMethod(readUInt8(chunk, 10)); png.setFilterMethod(readUInt8(chunk, 11)); png.setInterlaceMethod(readUInt8(chunk, 12)); }; /** * * http://www.w3.org/TR/PNG/#11PLTE * @param {Uint8Array} chunk */ PNGReader.prototype.decodePLTE = function (chunk) { this.png.setPalette(chunk); }; /** * http://www.w3.org/TR/2003/REC-PNG-20031110/#11IDAT */ PNGReader.prototype.decodeIDAT = function (chunk) { // multiple IDAT chunks will concatenated this.dataChunks.push(chunk); }; /** * https://www.w3.org/TR/PNG/#11tRNS * @param {Uint8Array} chunk */ PNGReader.prototype.decodeTRNS = function (chunk) { this.png.transparency_lookup = chunk; }; /** * http://www.w3.org/TR/2003/REC-PNG-20031110/#11IEND */ PNGReader.prototype.decodeIEND = function () { }; /** * Uncompress IDAT chunks */ PNGReader.prototype.decodePixels = function () { const png = this.png; const inflator = new zlib.Inflate(); const chunks = this.dataChunks; const chunk_count = chunks.length; for (let i = 0; i < chunk_count; i++) { inflator.push(chunks[i]); if (inflator.err) { throw new Error(inflator.err); } } const decompressed_data = inflator.result; // https://www.w3.org/TR/png-3/#8InterlaceMethods if (png.getInterlaceMethod() === 0) { this.png.pixels = this.interlaceNone(decompressed_data); } else { this.png.pixels = this.interlaceAdam7(decompressed_data); } }; // Different interlace methods /** * @param {Uint8Array} data */ PNGReader.prototype.interlaceNone = function (data) { const png = this.png; const depth = png.bitDepth; const bits_per_line = png.colors * depth; const bytes_per_pixel = bits_per_line / 8; const width = png.width; const height = png.height; // color bytes per row const color_bytes_per_row = Math.ceil(bytes_per_pixel * width); const pixels = new Uint8Array(color_bytes_per_row * height); let offset = 0; const data_length = data.length; for (let i = 0; i < data_length; i += color_bytes_per_row + 1) { const scanline_address = i + 1; // scanline = slice.call(data, scanline_address, scanline_address + cpr); const filter_type = readUInt8(data, i); this.unFilter( filter_type, data, scanline_address, pixels, depth, offset, offset - color_bytes_per_row, color_bytes_per_row ); offset += color_bytes_per_row; } return pixels; }; /** * De-interlace image according to Adam 7 scheme * @param {Uint8Array} data */ PNGReader.prototype.interlaceAdam7 = function (data) { const png = this.png; const depth = png.bitDepth; const bytes_per_pixel = png.colors * depth / 8; const pixels = new Uint8Array(bytes_per_pixel * png.width * png.height); // Adam7 interlacing pattern const passes = [ { x: 0, y: 0, xStep: 8, yStep: 8 }, // Pass 1 { x: 4, y: 0, xStep: 8, yStep: 8 }, // Pass 2 { x: 0, y: 4, xStep: 4, yStep: 8 }, // Pass 3 { x: 2, y: 0, xStep: 4, yStep: 4 }, // Pass 4 { x: 0, y: 2, xStep: 2, yStep: 4 }, // Pass 5 { x: 1, y: 0, xStep: 2, yStep: 2 }, // Pass 6 { x: 0, y: 1, xStep: 1, yStep: 2 }, // Pass 7 ]; const width = png.width; const height = png.height; let offset = 0; // Create a new line for the unfiltered data const line_bytes_buffer = new Uint8Array(width * bytes_per_pixel); // Process each pass for (let passIndex = 0; passIndex < 7; passIndex++) { const pass = passes[passIndex]; // Calculate pass dimensions const pass_width = Math.ceil((width - pass.x) / pass.xStep); const pass_height = Math.ceil((height - pass.y) / pass.yStep); if (pass_width <= 0 || pass_height <= 0) { continue; } const passLineBytes = pass_width * bytes_per_pixel; // Indicate to unfiltering that this is the first line (no previous line) let previous_line_offset = -1; // Process each scanline in this pass for (let y = 0; y < pass_height; y++) { // First byte is the filter type const filter_type = data[offset++]; // we use alternating line offsets to save on memory allocations const line_offset = (y % 2) * passLineBytes; // Apply the appropriate unfilter this.unFilter( filter_type, data, offset, line_bytes_buffer, bytes_per_pixel, line_offset, previous_line_offset, passLineBytes, ); offset += passLineBytes; previous_line_offset = line_offset; for (let x = 0; x < pass_width; x++) { const outputX = pass.x + x * pass.xStep; const outputY = pass.y + y * pass.yStep; if (outputX >= width || outputY >= height) { continue; } for (let i = 0; i < bytes_per_pixel; i++) { const out_offset = (outputY * width + outputX) * bytes_per_pixel + i; pixels[out_offset] = line_bytes_buffer[line_offset + x * bytes_per_pixel + i]; } } } } function swap16(val) { return ((val & 0xff) << 8) | ((val >> 8) & 0xff); } if (depth === 16) { const uint16Data = new Uint16Array(pixels.buffer); const osIsLittleEndian = platform_compute_endianness() === EndianType.LittleEndian; if (osIsLittleEndian) { for (let k = 0; k < uint16Data.length; k++) { // PNG is always big endian. Swap the bytes. uint16Data[k] = swap16(uint16Data[k]); } } return uint16Data; } else { return pixels; } }; // Unfiltering /** * * @param {number} filter_type * @param {Uint8Array} data * @param {number} scanline_address * @param {Uint8Array} output * @param {number} bytes_per_pixel * @param {number} output_offset * @param {number} output_offset_previous where does result of previous scanline begin in the output? Needed for various filters such as `Up` * @param {number} length */ PNGReader.prototype.unFilter = function ( filter_type, data, scanline_address, output, bytes_per_pixel, output_offset, output_offset_previous, length ) { assert.isInteger(filter_type, 'filter_type'); assert.isNonNegativeInteger(scanline_address, 'scanline_address'); assert.isNonNegativeInteger(bytes_per_pixel, 'bytes_per_pixel'); assert.isNonNegativeInteger(output_offset, 'output_offset'); assert.isNonNegativeInteger(length, 'length'); switch (filter_type) { case 0: this.unFilterNone( data, scanline_address, output, bytes_per_pixel, output_offset, output_offset_previous, length ); break; case 1: this.unFilterSub( data, scanline_address, output, bytes_per_pixel, output_offset, output_offset_previous, length ); break; case 2: this.unFilterUp( data, scanline_address, output, bytes_per_pixel, output_offset, output_offset_previous, length ); break; case 3: this.unFilterAverage( data, scanline_address, output, bytes_per_pixel, output_offset, output_offset_previous, length ); break; case 4: this.unFilterPaeth( data, scanline_address, output, bytes_per_pixel, output_offset, output_offset_previous, length ); break; default: throw new Error(`unknown filtered scanline type '${filter_type}'`); } } /** * * @param {Uint8Array} data * @param {number} scanline_address * @param {Uint8Array} output * @param {number} bytes_per_pixel * @param {number} output_offset * @param {number} output_offset_previous * @param {number} length */ PNGReader.prototype.unFilterNone = function ( data, scanline_address, output, bytes_per_pixel, output_offset, output_offset_previous, length ) { const png = this.png; const depth = png.bitDepth; if (depth === 1) { for (let x = 0; x < length; x++) { const q = x >>> 4; const datum = data[q + scanline_address]; const shift = ((x) & 0x7); const out_value = (datum >>> shift) & 0x1; output[output_offset + x] = out_value; } } else if (depth === 2) { for (let x = 0; x < length; x++) { const q = x >>> 2; const datum = data[q + scanline_address]; const shift = ((~x) & 0x3) << 1; const out_value = (datum >>> shift) & 0x3; output[output_offset + x] = out_value; } } else if (depth === 4) { for (let x = 0; x < length; x++) { const q = x >>> 1; const datum = data[q + scanline_address]; const shift = ((~x) & 0x1) << 2; const out_value = (datum >>> shift) & 0xF; output[output_offset + x] = out_value; } } else if (depth === 8) { for (let x = 0; x < length; x++) { // straight copy output[output_offset + x] = data[x + scanline_address]; } } else { throw new Error(`unsupported bit depth ${depth}`) } }; /** * The Sub() filter transmits the difference between each byte and the value * of the corresponding byte of the prior pixel. * Sub(x) = Raw(x) + Raw(x - bpp) * * @param {Uint8Array} scanline raw data * @param {number} scanline_offset * @param {Uint8Array} pixels processed output * @param {number} bpp bytes-per-pixel * @param {number} offset * @param {number} output_offset_previous * @param {number} length */ PNGReader.prototype.unFilterSub = function ( scanline, scanline_offset, pixels, bpp, offset, output_offset_previous, length ) { let i = 0; for (; i < bpp; i++) { pixels[offset + i] = scanline[i + scanline_offset]; } for (; i < length; i++) { // Raw(x) + Raw(x - bpp) const of_i = offset + i; pixels[of_i] = (scanline[i + scanline_offset] + pixels[of_i - bpp]) & 0xFF; } }; /** * The Up() filter is just like the Sub() filter except that the pixel * immediately above the current pixel, rather than just to its left, is used * as the predictor. * Up(x) = Raw(x) + Prior(x) * * @param {Uint8Array} scanline raw data * @param {number} scanline_offset * @param {Uint8Array} pixels processed output * @param {number} bpp bytes-per-pixel * @param {number} offset * @param {number} output_offset_previous * @param {number} length */ PNGReader.prototype.unFilterUp = function ( scanline, scanline_offset, pixels, bpp, offset, output_offset_previous, length ) { let i = 0, byte, prev; // Prior(x) is 0 for all x on the first scanline if (output_offset_previous < 0) { for (; i < length; i++) { pixels[offset + i] = scanline[i + scanline_offset]; } } else { for (; i < length; i++) { // Raw(x) byte = scanline[i + scanline_offset]; // Prior(x) prev = pixels[output_offset_previous + i]; pixels[offset + i] = (byte + prev) & 0xFF; } } }; /** * The Average() filter uses the average of the two neighboring pixels (left * and above) to predict the value of a pixel. * Average(x) = Raw(x) + floor((Raw(x-bpp)+Prior(x))/2) * * @param {Uint8Array} scanline raw data * @param {number} scanline_offset * @param {Uint8Array} pixels processed output * @param {number} bpp bytes-per-pixel * @param {number} offset * @param {number} output_offset_previous * @param {number} length */ PNGReader.prototype.unFilterAverage = function ( scanline, scanline_offset, pixels, bpp, offset, output_offset_previous, length ) { let i = 0, byte, prev, prior; if (output_offset_previous < 0) { // Prior(x) == 0 && Raw(x - bpp) == 0 for (; i < bpp; i++) { pixels[offset + i] = scanline[i + scanline_offset]; } // Prior(x) == 0 && Raw(x - bpp) != 0 (right shift, prevent doubles) for (; i < length; i++) { const of_i = offset + i; pixels[of_i] = (scanline[i + scanline_offset] + (pixels[of_i - bpp] >> 1)) & 0xFF; } } else { // Prior(x) != 0 && Raw(x - bpp) == 0 for (; i < bpp; i++) { pixels[offset + i] = (scanline[i + scanline_offset] + (pixels[output_offset_previous + i] >> 1)) & 0xFF; } // Prior(x) != 0 && Raw(x - bpp) != 0 for (; i < length; i++) { byte = scanline[i + scanline_offset]; prev = pixels[offset + i - bpp]; prior = pixels[output_offset_previous + i]; pixels[offset + i] = (byte + (prev + prior >> 1)) & 0xFF; } } }; /** * The Paeth() filter computes a simple linear function of the three * neighboring pixels (left, above, upper left), then chooses as predictor * the neighboring pixel closest to the computed value. * This technique is due to Alan W. Paeth. * * Paeth(x) = Raw(x) + * PaethPredictor(Raw(x-bpp), Prior(x), Prior(x-bpp)) * function PaethPredictor (a, b, c) * begin * ; a = left, b = above, c = upper left * p := a + b - c ; initial estimate * pa := abs(p - a) ; distances to a, b, c * pb := abs(p - b) * pc := abs(p - c) * ; return nearest of a,b,c, * ; breaking ties in order a,b,c. * if pa <= pb AND pa <= pc then return a * else if pb <= pc then return b * else return c * end * * @param {Uint8Array} scanline raw data * @param {number} scanline_offset * @param {Uint8Array} pixels processed output * @param {number} bpp bytes-per-pixel * @param {number} offset * @param {number} output_offset_previous * @param {number} length */ PNGReader.prototype.unFilterPaeth = function ( scanline, scanline_offset, pixels, bpp, offset, output_offset_previous, length ) { let i = 0, raw, a, b, c, p, pa, pb, pc, pr; if (output_offset_previous < 0) { // Prior(x) == 0 && Raw(x - bpp) == 0 for (; i < bpp; i++) { pixels[offset + i] = scanline[i + scanline_offset]; } // Prior(x) == 0 && Raw(x - bpp) != 0 // paethPredictor(x, 0, 0) is always x for (; i < length; i++) { pixels[offset + i] = (scanline[i + scanline_offset] + pixels[offset + i - bpp]) & 0xFF; } } else { // Prior(x) != 0 && Raw(x - bpp) == 0 // paethPredictor(x, 0, 0) is always x for (; i < bpp; i++) { pixels[offset + i] = (scanline[i + scanline_offset] + pixels[output_offset_previous + i]) & 0xFF; } // Prior(x) != 0 && Raw(x - bpp) != 0 for (; i < length; i++) { raw = scanline[i + scanline_offset]; c = pixels[output_offset_previous + i - bpp]; b = pixels[output_offset_previous + i]; a = pixels[offset + i - bpp]; p = a + b - c; pa = Math.abs(p - a); pb = Math.abs(p - b); pc = Math.abs(p - c); if (pa <= pb && pa <= pc) { pr = a; } else if (pb <= pc) { pr = b; } else { pr = c; } pixels[offset + i] = (raw + pr) & 0xFF; } } }; /** * Parse the PNG file * @returns {PNG} */ PNGReader.prototype.parse = function () { this.decodeHeader(); for (; ;) { const type = this.decodeChunk(); if (type === 'IEND') { // reached the end break; } } this.decodePixels(); return this.png; };