UNPKG

@loaders.gl/textures

Version:

Framework-independent loaders for compressed and super compressed (basis) textures

427 lines (353 loc) 13 kB
// loaders.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import type {Texture, TextureLevel} from '@loaders.gl/schema'; import {GL_RGBA32F} from '../gl-extensions'; const HDR_MAGIC_HEADERS = ['#?RADIANCE', '#?RGBE']; const HDR_FORMAT = '32-bit_rle_rgbe'; /** Application-facing metadata extracted from Radiance HDR header fields. */ export type RadianceHDRMetadata = { /** Per-channel color correction factors from the `COLORCORR` header field. */ colorCorrection?: [number, number, number]; /** Scene exposure multiplier from the `EXPOSURE` header field. */ exposure?: number; /** Display gamma hint from the `GAMMA` header field. */ gamma?: number; /** Pixel aspect ratio from the `PIXASPECT` header field. */ pixelAspectRatio?: number; /** Chromaticity primaries and white point from the `PRIMARIES` header field. */ primaries?: [number, number, number, number, number, number, number, number]; /** Producer software identifier from the `SOFTWARE` header field. */ software?: string; /** View specification string from the `VIEW` header field. */ view?: string; }; type HeaderState = { data: Uint8Array; offset: number; }; type HDRHeader = { width: number; height: number; majorAxis: 'X' | 'Y'; majorSign: 1 | -1; minorAxis: 'X' | 'Y'; minorSign: 1 | -1; metadata?: RadianceHDRMetadata; }; export function isHDR(arrayBuffer: ArrayBuffer): boolean { const state: HeaderState = { data: new Uint8Array(arrayBuffer), offset: 0 }; const firstLine = readLine(state); return firstLine ? HDR_MAGIC_HEADERS.includes(firstLine) : false; } export function parseHDR(arrayBuffer: ArrayBuffer): Texture<RadianceHDRMetadata> { const state: HeaderState = { data: new Uint8Array(arrayBuffer), offset: 0 }; const header = readHeader(state); const {width, height} = header; const rgbeData = readPixels(state, header); const data = convertRGBEToFloat(rgbeData); const level: TextureLevel = { shape: 'texture-level', compressed: false, width, height, data, levelSize: data.byteLength, format: GL_RGBA32F, textureFormat: 'rgba32float' }; return { shape: 'texture', type: '2d', format: 'rgba32float', ...(header.metadata ? {metadata: header.metadata} : {}), data: [level] }; } function readHeader(state: HeaderState): HDRHeader { const magicHeader = readLine(state); if (!magicHeader || !HDR_MAGIC_HEADERS.includes(magicHeader)) { throw new Error('RadianceHDRLoader: bad initial token'); } let hasFormat = false; const metadata: RadianceHDRMetadata = {}; while (state.offset < state.data.length) { const line = readLine(state); if (line === null) { break; } if (!line || line.startsWith('#')) { continue; } if (line.startsWith('FORMAT=')) { hasFormat = line.slice('FORMAT='.length) === HDR_FORMAT; if (!hasFormat) { throw new Error('RadianceHDRLoader: unsupported format specifier'); } continue; } parseMetadataLine(metadata, line); const dimensions = parseDimensions(line); if (dimensions) { if (!hasFormat) { throw new Error('RadianceHDRLoader: missing format specifier'); } return { ...dimensions, ...(hasMetadata(metadata) ? {metadata} : {}) }; } } if (!hasFormat) { throw new Error('RadianceHDRLoader: missing format specifier'); } throw new Error('RadianceHDRLoader: missing image size specifier'); } function parseDimensions(line: string): HDRHeader | null { const match = line.match(/^([+-])([YX])\s+(\d+)\s+([+-])([YX])\s+(\d+)$/); if (!match) { return null; } const majorSign = match[1] === '+' ? 1 : -1; const majorAxis = match[2] as 'X' | 'Y'; const majorLength = Number(match[3]); const minorSign = match[4] === '+' ? 1 : -1; const minorAxis = match[5] as 'X' | 'Y'; const minorLength = Number(match[6]); if (majorAxis === minorAxis) { throw new Error('RadianceHDRLoader: invalid image dimensions'); } const width = majorAxis === 'X' ? majorLength : minorLength; const height = majorAxis === 'Y' ? majorLength : minorLength; if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { throw new Error('RadianceHDRLoader: invalid image dimensions'); } return {width, height, majorAxis, majorSign, minorAxis, minorSign}; } function readPixels(state: HeaderState, header: HDRHeader): Uint8Array { const {width, height} = header; const pixelCount = width * height; const flatByteLength = pixelCount * 4; const scanlineLength = header.minorAxis === 'X' ? width : height; const scanlineCount = header.majorAxis === 'Y' ? height : width; if (scanlineLength < 8 || scanlineLength > 0x7fff) { return reorderPixels(readFlatPixels(state, flatByteLength), header); } if (state.offset + 4 > state.data.length) { throw new Error('RadianceHDRLoader: unexpected end of file'); } const data = state.data; const isRunLengthEncoded = data[state.offset] === 2 && data[state.offset + 1] === 2 && !(data[state.offset + 2] & 0x80); if (!isRunLengthEncoded) { return reorderPixels(readFlatPixels(state, flatByteLength), header); } const scanlineWidth = (data[state.offset + 2] << 8) | data[state.offset + 3]; if (scanlineWidth !== scanlineLength) { return reorderPixels(readFlatPixels(state, flatByteLength), header); } const pixels = new Uint8Array(flatByteLength); const scanlineBuffer = new Uint8Array(scanlineLength * 4); for (let scanlineIndex = 0; scanlineIndex < scanlineCount; scanlineIndex++) { if (state.offset + 4 > data.length) { throw new Error('RadianceHDRLoader: unexpected end of file'); } const red = data[state.offset++]; const green = data[state.offset++]; const blue = data[state.offset++]; const exponent = data[state.offset++]; if (red !== 2 || green !== 2 || ((blue << 8) | exponent) !== scanlineLength) { throw new Error('RadianceHDRLoader: bad rgbe scanline format'); } for (let channelIndex = 0; channelIndex < 4; channelIndex++) { const channelOffset = channelIndex * scanlineLength; const channelEnd = channelOffset + scanlineLength; let pixelOffset = channelOffset; while (pixelOffset < channelEnd) { if (state.offset >= data.length) { throw new Error('RadianceHDRLoader: unexpected end of file'); } let count = data[state.offset++]; if (count > 128) { count -= 128; if (count === 0 || pixelOffset + count > channelEnd || state.offset >= data.length) { throw new Error('RadianceHDRLoader: bad scanline data'); } const value = data[state.offset++]; scanlineBuffer.fill(value, pixelOffset, pixelOffset + count); pixelOffset += count; continue; } if (count === 0 || pixelOffset + count > channelEnd || state.offset + count > data.length) { throw new Error('RadianceHDRLoader: bad scanline data'); } scanlineBuffer.set(data.subarray(state.offset, state.offset + count), pixelOffset); pixelOffset += count; state.offset += count; } } for (let pixelIndex = 0; pixelIndex < scanlineLength; pixelIndex++) { const outputOffset = getOutputOffset(header, scanlineIndex, pixelIndex); pixels[outputOffset] = scanlineBuffer[pixelIndex]; pixels[outputOffset + 1] = scanlineBuffer[pixelIndex + scanlineLength]; pixels[outputOffset + 2] = scanlineBuffer[pixelIndex + scanlineLength * 2]; pixels[outputOffset + 3] = scanlineBuffer[pixelIndex + scanlineLength * 3]; } } return pixels; } function reorderPixels(data: Uint8Array, header: HDRHeader): Uint8Array { const pixels = new Uint8Array(data.length); const scanlineLength = header.minorAxis === 'X' ? header.width : header.height; const scanlineCount = header.majorAxis === 'Y' ? header.height : header.width; for (let scanlineIndex = 0; scanlineIndex < scanlineCount; scanlineIndex++) { for (let pixelIndex = 0; pixelIndex < scanlineLength; pixelIndex++) { const sourceOffset = (scanlineIndex * scanlineLength + pixelIndex) * 4; const outputOffset = getOutputOffset(header, scanlineIndex, pixelIndex); pixels[outputOffset] = data[sourceOffset]; pixels[outputOffset + 1] = data[sourceOffset + 1]; pixels[outputOffset + 2] = data[sourceOffset + 2]; pixels[outputOffset + 3] = data[sourceOffset + 3]; } } return pixels; } function getOutputOffset(header: HDRHeader, scanlineIndex: number, pixelIndex: number): number { const majorCoordinate = getCoordinate( header.majorAxis === 'X' ? header.width : header.height, header.majorSign, scanlineIndex ); const minorCoordinate = getCoordinate( header.minorAxis === 'X' ? header.width : header.height, header.minorSign, pixelIndex ); const x = header.majorAxis === 'X' ? majorCoordinate : minorCoordinate; const y = header.majorAxis === 'Y' ? majorCoordinate : minorCoordinate; return ((header.height - 1 - y) * header.width + x) * 4; } function getCoordinate(length: number, sign: 1 | -1, index: number): number { return sign === 1 ? index : length - 1 - index; } function readFlatPixels(state: HeaderState, byteLength: number): Uint8Array { if (state.offset + byteLength > state.data.length) { throw new Error('RadianceHDRLoader: unexpected end of file'); } const pixels = state.data.slice(state.offset, state.offset + byteLength); state.offset += byteLength; return pixels; } function convertRGBEToFloat(data: Uint8Array): Float32Array { const floatData = new Float32Array(data.length); for (let sourceOffset = 0; sourceOffset < data.length; sourceOffset += 4) { const exponent = data[sourceOffset + 3]; const destinationOffset = sourceOffset; if (exponent > 0) { const scale = Math.pow(2, exponent - 128) / 255; floatData[destinationOffset] = data[sourceOffset] * scale; floatData[destinationOffset + 1] = data[sourceOffset + 1] * scale; floatData[destinationOffset + 2] = data[sourceOffset + 2] * scale; } floatData[destinationOffset + 3] = 1; } return floatData; } function readLine(state: HeaderState): string | null { if (state.offset >= state.data.length) { return null; } const lineStart = state.offset; while (state.offset < state.data.length) { const byte = state.data[state.offset++]; if (byte === 0x0a) { const line = decodeASCII(state.data.subarray(lineStart, state.offset - 1)); return line.endsWith('\r') ? line.slice(0, -1) : line; } } const line = decodeASCII(state.data.subarray(lineStart, state.offset)); return line.endsWith('\r') ? line.slice(0, -1) : line; } function decodeASCII(data: Uint8Array): string { let line = ''; for (const byte of data) { line += String.fromCharCode(byte); } return line; } function parseMetadataLine(metadata: RadianceHDRMetadata, line: string): void { if (line.startsWith('COLORCORR=')) { const values = parseNumberList(line.slice('COLORCORR='.length), 3); if (values) { metadata.colorCorrection = values as [number, number, number]; } return; } if (line.startsWith('EXPOSURE=')) { const value = parseNumber(line.slice('EXPOSURE='.length)); if (value !== null) { metadata.exposure = value; } return; } if (line.startsWith('GAMMA=')) { const value = parseNumber(line.slice('GAMMA='.length)); if (value !== null) { metadata.gamma = value; } return; } if (line.startsWith('PIXASPECT=')) { const value = parseNumber(line.slice('PIXASPECT='.length)); if (value !== null) { metadata.pixelAspectRatio = value; } return; } if (line.startsWith('PRIMARIES=')) { const values = parseNumberList(line.slice('PRIMARIES='.length), 8); if (values) { metadata.primaries = values as [ number, number, number, number, number, number, number, number ]; } return; } if (line.startsWith('SOFTWARE=')) { metadata.software = line.slice('SOFTWARE='.length).trim(); return; } if (line.startsWith('VIEW=')) { metadata.view = line.slice('VIEW='.length).trim(); } } function parseNumber(text: string): number | null { const value = Number(text.trim()); return Number.isFinite(value) ? value : null; } function parseNumberList(text: string, count: number): number[] | null { const values = text .trim() .split(/\s+/) .map((value) => Number(value)); if (values.length !== count || values.some((value) => !Number.isFinite(value))) { return null; } return values; } function hasMetadata(metadata: RadianceHDRMetadata): boolean { return Object.keys(metadata).length > 0; }