From a25dc36761bac7a43ea72e0bc774fad915a50823 Mon Sep 17 00:00:00 2001 From: Michael Herzog Date: Fri, 20 Mar 2026 11:39:25 +0100 Subject: [PATCH] EXRLoader: Add multi-part and deep scanline support. (#33218) --- examples/jsm/loaders/EXRLoader.js | 431 ++++++++++++++++++++++++++++-- 1 file changed, 403 insertions(+), 28 deletions(-) diff --git a/examples/jsm/loaders/EXRLoader.js b/examples/jsm/loaders/EXRLoader.js index 5b691e189d40f5..6dd1f72ab5e394 100644 --- a/examples/jsm/loaders/EXRLoader.js +++ b/examples/jsm/loaders/EXRLoader.js @@ -121,6 +121,14 @@ class EXRLoader extends DataTextureLoader { */ this.outputFormat = RGBAFormat; + /** + * For multi-part EXR files, the index of the part to load. + * + * @type {number} + * @default 0 + */ + this.part = 0; + } /** @@ -2520,9 +2528,300 @@ class EXRLoader extends DataTextureLoader { } - function parseHeader( dataView, buffer, offset ) { + function parseMultiPartScanline() { + + const EXRDecoder = this; + const chunkOffsets = EXRDecoder.chunkOffsets; + const tmpOffset = { value: 0 }; + + for ( let chunkIdx = 0; chunkIdx < chunkOffsets.length; chunkIdx ++ ) { + + const offset = { value: chunkOffsets[ chunkIdx ] }; + + offset.value += INT32_SIZE; // skip part number + + const line = parseInt32( EXRDecoder.viewer, offset ) - EXRHeader.dataWindow.yMin; + EXRDecoder.size = parseUint32( EXRDecoder.viewer, offset ); + EXRDecoder.lines = ( ( line + EXRDecoder.blockHeight > EXRDecoder.height ) ? ( EXRDecoder.height - line ) : EXRDecoder.blockHeight ); + + const bytesPerLine = EXRDecoder.columns * EXRDecoder.totalBytes; + const isCompressed = EXRDecoder.size < EXRDecoder.lines * bytesPerLine; + + const savedOffset = EXRDecoder.offset; + EXRDecoder.offset = offset; + const viewer = isCompressed ? EXRDecoder.uncompress( EXRDecoder ) : uncompressRAW( EXRDecoder ); + EXRDecoder.offset = savedOffset; + + for ( let line_y = 0; line_y < EXRDecoder.blockHeight; line_y ++ ) { + + const true_y = line_y + line; + if ( true_y >= EXRDecoder.height ) continue; + + const lineOffset = line_y * bytesPerLine; + const outLineOffset = ( EXRDecoder.height - 1 - true_y ) * EXRDecoder.outLineWidth; + + for ( let channelID = 0; channelID < EXRDecoder.inputChannels.length; channelID ++ ) { + + const name = EXRHeader.channels[ channelID ].name; + const lOff = EXRDecoder.channelByteOffsets[ name ] * EXRDecoder.columns; + const cOff = EXRDecoder.decodeChannels[ name ]; + + if ( cOff === undefined ) continue; + + tmpOffset.value = lineOffset + lOff; + + for ( let x = 0; x < EXRDecoder.columns; x ++ ) { + + const outIndex = outLineOffset + x * EXRDecoder.outputChannels + cOff; + EXRDecoder.byteArray[ outIndex ] = EXRDecoder.getter( viewer, tmpOffset ); + + } + + } + + } + + } + + } + + function decompressDeepData( array, compressedOffset, compressedSize, compression ) { + + if ( compressedSize === 0 ) return null; + + const compressed = array.slice( compressedOffset, compressedOffset + compressedSize ); + + switch ( compression ) { + + case 'NO_COMPRESSION': + return new DataView( compressed.buffer, compressed.byteOffset, compressed.byteLength ); + + case 'RLE_COMPRESSION': { + + const rawBuffer = new Uint8Array( decodeRunLength( compressed.buffer.slice( compressed.byteOffset, compressed.byteOffset + compressed.byteLength ) ) ); + const tmpBuffer = new Uint8Array( rawBuffer.length ); + predictor( rawBuffer ); + interleaveScalar( rawBuffer, tmpBuffer ); + return new DataView( tmpBuffer.buffer ); + + } + + case 'ZIPS_COMPRESSION': { + + const rawBuffer = unzlibSync( compressed ); + const tmpBuffer = new Uint8Array( rawBuffer.length ); + predictor( rawBuffer ); + interleaveScalar( rawBuffer, tmpBuffer ); + return new DataView( tmpBuffer.buffer ); + + } + + default: + throw new Error( 'EXRLoader.parse: ' + compression + ' is unsupported for deep data' ); + + } + + } + + function parseDeepScanline() { + + const EXRDecoder = this; + const chunkOffsets = EXRDecoder.chunkOffsets; + const width = EXRDecoder.width; + const height = EXRDecoder.height; + const deepChannels = EXRDecoder.deepChannels; + const compression = EXRHeader.compression; + const isMultiPart = EXRDecoder.multiPart; + + // Build a map from channel name to decode output slot + const decodeChannels = EXRDecoder.decodeChannels; + const outputChannels = EXRDecoder.outputChannels; + const isHalfOutput = EXRDecoder.byteArray instanceof Uint16Array; + + // Find the alpha channel index in deepChannels (for compositing) + let alphaChannelIdx = - 1; + + for ( let i = 0; i < deepChannels.length; i ++ ) { + + if ( deepChannels[ i ].name === 'A' ) { + + alphaChannelIdx = i; + break; + + } + + } + + for ( let chunkIdx = 0; chunkIdx < chunkOffsets.length; chunkIdx ++ ) { + + const chunkOffset = { value: chunkOffsets[ chunkIdx ] }; + + // Multi-part files have a part number prefix per chunk + if ( isMultiPart ) chunkOffset.value += INT32_SIZE; + + const line = parseInt32( EXRDecoder.viewer, chunkOffset ) - EXRHeader.dataWindow.yMin; + + // Read deep scanline sizes + const sctCompressedSize = parseInt64( EXRDecoder.viewer, chunkOffset ); + const dataCompressedSize = parseInt64( EXRDecoder.viewer, chunkOffset ); + parseInt64( EXRDecoder.viewer, chunkOffset ); // uncompressed data size (unused) + + // Decompress sample count table + const sctView = decompressDeepData( EXRDecoder.array, chunkOffset.value, sctCompressedSize, compression ); + chunkOffset.value += sctCompressedSize; + + if ( sctView === null ) continue; + + // Parse cumulative sample counts + const cumulativeCounts = new Uint32Array( width ); + + for ( let x = 0; x < width; x ++ ) { + + cumulativeCounts[ x ] = sctView.getUint32( x * 4, true ); + + } + + const totalSamples = cumulativeCounts[ width - 1 ]; + + if ( totalSamples === 0 ) { + + chunkOffset.value += dataCompressedSize; + continue; + + } + + // Decompress pixel data + const pixelView = decompressDeepData( EXRDecoder.array, chunkOffset.value, dataCompressedSize, compression ); + + // Compute channel byte offsets within the decompressed pixel data. + // Deep data layout: channels are contiguous, each has totalSamples values. + const channelOffsets = []; + let bytePos = 0; + + for ( let i = 0; i < deepChannels.length; i ++ ) { + + channelOffsets.push( bytePos ); + bytePos += totalSamples * deepChannels[ i ].bytesPerSample; + + } - const EXRHeader = {}; + // Flatten deep samples: front-to-back composite with premultiplied alpha + const outLineOffset = ( height - 1 - line ) * EXRDecoder.outLineWidth; + + for ( let x = 0; x < width; x ++ ) { + + const startSample = x === 0 ? 0 : cumulativeCounts[ x - 1 ]; + const endSample = cumulativeCounts[ x ]; + const numSamples = endSample - startSample; + + if ( numSamples === 0 ) continue; + + // Composite samples front-to-back (premultiplied alpha) + const composited = new Float32Array( outputChannels ); + let compositedAlpha = 0; + + for ( let s = 0; s < numSamples; s ++ ) { + + const sampleIdx = startSample + s; + const factor = 1 - compositedAlpha; + + if ( factor <= 0 ) break; + + // Read alpha for this sample + let sampleAlpha = 1; + + if ( alphaChannelIdx >= 0 ) { + + const aBps = deepChannels[ alphaChannelIdx ].bytesPerSample; + const aOff = channelOffsets[ alphaChannelIdx ] + sampleIdx * aBps; + + sampleAlpha = aBps === 2 + ? decodeFloat16( pixelView.getUint16( aOff, true ) ) + : pixelView.getFloat32( aOff, true ); + + } + + // Read and composite each output channel + for ( let ci = 0; ci < deepChannels.length; ci ++ ) { + + const ch = deepChannels[ ci ]; + const cOff = decodeChannels[ ch.name ]; + + if ( cOff === undefined ) continue; + + const bps = ch.bytesPerSample; + const dataOff = channelOffsets[ ci ] + sampleIdx * bps; + + const value = bps === 2 + ? decodeFloat16( pixelView.getUint16( dataOff, true ) ) + : pixelView.getFloat32( dataOff, true ); + + composited[ cOff ] += value * factor; + + } + + compositedAlpha += sampleAlpha * factor; + + } + + // If alpha channel is being output, set it + if ( decodeChannels[ 'A' ] !== undefined ) { + + composited[ decodeChannels[ 'A' ] ] = compositedAlpha; + + } + + // Write to output buffer + const outIndex = outLineOffset + x * outputChannels; + + for ( let c = 0; c < outputChannels; c ++ ) { + + EXRDecoder.byteArray[ outIndex + c ] = isHalfOutput + ? DataUtils.toHalfFloat( composited[ c ] ) + : composited[ c ]; + + } + + } + + } + + } + + function parsePartHeader( dataView, buffer, offset ) { + + const header = {}; + let hasAttributes = false; + + while ( true ) { + + const attributeName = parseNullTerminatedString( buffer, offset ); + + if ( attributeName === '' ) break; + + hasAttributes = true; + + const attributeType = parseNullTerminatedString( buffer, offset ); + const attributeSize = parseUint32( dataView, offset ); + const attributeValue = parseValue( dataView, buffer, offset, attributeType, attributeSize ); + + if ( attributeValue === undefined ) { + + console.warn( `THREE.EXRLoader: Skipped unknown header attribute type \'${attributeType}\'.` ); + + } else { + + header[ attributeName ] = attributeValue; + + } + + } + + return hasAttributes ? header : null; + + } + + function parseHeader( dataView, buffer, offset ) { if ( dataView.getUint32( 0, true ) != 20000630 ) { // magic @@ -2530,11 +2829,11 @@ class EXRLoader extends DataTextureLoader { } - EXRHeader.version = dataView.getUint8( 4 ); + const version = dataView.getUint8( 4 ); const spec = dataView.getUint8( 5 ); // fullMask - EXRHeader.spec = { + const flags = { singleTile: !! ( spec & 2 ), longName: !! ( spec & 4 ), deepFormat: !! ( spec & 8 ), @@ -2545,44 +2844,43 @@ class EXRLoader extends DataTextureLoader { offset.value = 8; // start at 8 - after pre-amble - let keepReading = true; - - while ( keepReading ) { + const headers = []; - const attributeName = parseNullTerminatedString( buffer, offset ); + if ( flags.multiPart ) { - if ( attributeName === '' ) { + // Multi-part files: parse all part headers. + // Each part header ends with an empty attribute name (null byte). + // The header section ends when a null byte is read with no preceding attributes. - keepReading = false; + while ( true ) { - } else { + const header = parsePartHeader( dataView, buffer, offset ); + if ( header === null ) break; - const attributeType = parseNullTerminatedString( buffer, offset ); - const attributeSize = parseUint32( dataView, offset ); - const attributeValue = parseValue( dataView, buffer, offset, attributeType, attributeSize ); + header.version = version; + header.spec = flags; + headers.push( header ); - if ( attributeValue === undefined ) { - - console.warn( `THREE.EXRLoader: Skipped unknown header attribute type \'${attributeType}\'.` ); - - } else { + } - EXRHeader[ attributeName ] = attributeValue; + if ( headers.length === 0 ) { - } + throw new Error( 'THREE.EXRLoader: No valid part headers found.' ); } - } + } else { - if ( ( spec & ~ 0x06 ) != 0 ) { // unsupported deep-image, multi-part + // Single-part (standard or deep): one header - console.error( 'THREE.EXRHeader:', EXRHeader ); - throw new Error( 'THREE.EXRLoader: Provided file is currently unsupported.' ); + const header = parsePartHeader( dataView, buffer, offset ); + header.version = version; + header.spec = flags; + headers.push( header ); } - return EXRHeader; + return headers; } @@ -2882,7 +3180,33 @@ class EXRLoader extends DataTextureLoader { } - if ( EXRHeader.spec.singleTile ) { + if ( EXRHeader.spec.deepFormat ) { + + // Deep format: offset tables are already parsed in the main flow. + // Compute per-channel byte sizes for the deep pixel data layout. + + EXRDecoder.deepChannels = []; + let deepBytesPerSample = 0; + + for ( const channel of EXRHeader.channels ) { + + // UINT=0→4bytes, HALF=1→2bytes, FLOAT=2→4bytes + const bytesPerSample = channel.pixelType === 0 ? 4 : channel.pixelType * 2; + EXRDecoder.deepChannels.push( { + name: channel.name, + pixelType: channel.pixelType, + bytesPerSample: bytesPerSample, + } ); + deepBytesPerSample += bytesPerSample; + + } + + EXRDecoder.deepBytesPerSample = deepBytesPerSample; + EXRDecoder.chunkOffsets = EXRHeader._chunkOffsets; + EXRDecoder.multiPart = EXRHeader.spec.multiPart; + EXRDecoder.decode = parseDeepScanline.bind( EXRDecoder ); + + } else if ( EXRHeader.spec.singleTile ) { EXRDecoder.blockHeight = EXRHeader.tiles.ySize; EXRDecoder.blockWidth = EXRHeader.tiles.xSize; @@ -2902,6 +3226,13 @@ class EXRLoader extends DataTextureLoader { EXRDecoder.decode = parseTiles.bind( EXRDecoder ); + } else if ( EXRHeader.spec.multiPart ) { + + // Multi-part scanline: offsets already parsed in main flow. + EXRDecoder.blockWidth = EXRDecoder.width; + EXRDecoder.chunkOffsets = EXRHeader._chunkOffsets; + EXRDecoder.decode = parseMultiPartScanline.bind( EXRDecoder ); + } else { EXRDecoder.blockWidth = EXRDecoder.width; @@ -2924,7 +3255,38 @@ class EXRLoader extends DataTextureLoader { const uInt8Array = new Uint8Array( buffer ); // get header information and validate format. - const EXRHeader = parseHeader( bufferDataView, buffer, offset ); + const EXRHeaders = parseHeader( bufferDataView, buffer, offset ); + + // select part to decode + const partIndex = Math.max( 0, Math.min( this.part, EXRHeaders.length - 1 ) ); + const EXRHeader = EXRHeaders[ partIndex ]; + + // for multi-part deep files, skip offset tables for other parts + if ( EXRHeader.spec.multiPart || EXRHeader.spec.deepFormat ) { + + for ( let p = 0; p < EXRHeaders.length; p ++ ) { + + const chunkCount = EXRHeaders[ p ].chunkCount; + + if ( p === partIndex ) { + + // store offset table for the selected part + EXRHeader._chunkOffsets = []; + + for ( let i = 0; i < chunkCount; i ++ ) + EXRHeader._chunkOffsets.push( parseInt64( bufferDataView, offset ) ); + + } else { + + // skip other parts' offset tables + for ( let i = 0; i < chunkCount; i ++ ) + parseInt64( bufferDataView, offset ); + + } + + } + + } // get input compression information and prepare decoding. const EXRDecoder = setupDecoder( EXRHeader, bufferDataView, uInt8Array, offset, this.type, this.outputFormat ); @@ -3034,6 +3396,19 @@ class EXRLoader extends DataTextureLoader { } + /** + * For multi-part EXR files, sets which part to load. + * + * @param {number} value - The part index to load. + * @return {EXRLoader} A reference to this loader. + */ + setPart( value ) { + + this.part = value; + return this; + + } + load( url, onLoad, onProgress, onError ) { function onLoadCallback( texture, texData ) {