UltraHDRLoader.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. import {
  2. ClampToEdgeWrapping,
  3. DataTexture,
  4. DataUtils,
  5. FileLoader,
  6. HalfFloatType,
  7. LinearFilter,
  8. LinearMipMapLinearFilter,
  9. LinearSRGBColorSpace,
  10. Loader,
  11. RGBAFormat,
  12. UVMapping,
  13. } from 'three';
  14. /**
  15. * UltraHDR Image Format - https://developer.android.com/media/platform/hdr-image-format
  16. *
  17. * Short format brief:
  18. *
  19. * [JPEG headers]
  20. * [XMP metadata describing the MPF container and *both* SDR and gainmap images]
  21. * [Optional metadata] [EXIF] [ICC Profile]
  22. * [SDR image]
  23. * [XMP metadata describing only the gainmap image]
  24. * [Gainmap image]
  25. *
  26. * Each section is separated by a 0xFFXX byte followed by a descriptor byte (0xFFE0, 0xFFE1, 0xFFE2.)
  27. * Binary image storages are prefixed with a unique 0xFFD8 16-bit descriptor.
  28. */
  29. // Calculating this SRGB powers is extremely slow for 4K images and can be sufficiently precalculated for a 3-4x speed boost
  30. const SRGB_TO_LINEAR = Array( 1024 )
  31. .fill( 0 )
  32. .map( ( _, value ) =>
  33. Math.pow( ( value / 255 ) * 0.9478672986 + 0.0521327014, 2.4 )
  34. );
  35. /**
  36. * A loader for the Ultra HDR Image Format.
  37. *
  38. * Existing HDR or EXR textures can be converted to Ultra HDR with this [tool]{@link https://gainmap-creator.monogrid.com/}.
  39. *
  40. * Current feature set:
  41. * - JPEG headers (required)
  42. * - XMP metadata (required)
  43. * - XMP validation (not implemented)
  44. * - EXIF profile (not implemented)
  45. * - ICC profile (not implemented)
  46. * - Binary storage for SDR & HDR images (required)
  47. * - Gainmap metadata (required)
  48. * - Non-JPEG image formats (not implemented)
  49. * - Primary image as an HDR image (not implemented)
  50. *
  51. * ```js
  52. * const loader = new UltraHDRLoader();
  53. * const texture = await loader.loadAsync( 'textures/equirectangular/ice_planet_close.jpg' );
  54. * texture.mapping = THREE.EquirectangularReflectionMapping;
  55. *
  56. * scene.background = texture;
  57. * scene.environment = texture;
  58. * ```
  59. *
  60. * @augments Loader
  61. * @three_import import { UltraHDRLoader } from 'three/addons/loaders/UltraHDRLoader.js';
  62. */
  63. class UltraHDRLoader extends Loader {
  64. /**
  65. * Constructs a new Ultra HDR loader.
  66. *
  67. * @param {LoadingManager} [manager] - The loading manager.
  68. */
  69. constructor( manager ) {
  70. super( manager );
  71. /**
  72. * The texture type.
  73. *
  74. * @type {(HalfFloatType|FloatType)}
  75. * @default HalfFloatType
  76. */
  77. this.type = HalfFloatType;
  78. }
  79. /**
  80. * Sets the texture type.
  81. *
  82. * @param {(HalfFloatType|FloatType)} value - The texture type to set.
  83. * @return {RGBELoader} A reference to this loader.
  84. */
  85. setDataType( value ) {
  86. this.type = value;
  87. return this;
  88. }
  89. /**
  90. * Parses the given Ultra HDR texture data.
  91. *
  92. * @param {ArrayBuffer} buffer - The raw texture data.
  93. * @param {Function} onLoad - The `onLoad` callback.
  94. */
  95. parse( buffer, onLoad ) {
  96. const xmpMetadata = {
  97. version: null,
  98. baseRenditionIsHDR: null,
  99. gainMapMin: null,
  100. gainMapMax: null,
  101. gamma: null,
  102. offsetSDR: null,
  103. offsetHDR: null,
  104. hdrCapacityMin: null,
  105. hdrCapacityMax: null,
  106. };
  107. const textDecoder = new TextDecoder();
  108. const data = new DataView( buffer );
  109. let byteOffset = 0;
  110. const sections = [];
  111. while ( byteOffset < data.byteLength ) {
  112. const byte = data.getUint8( byteOffset );
  113. if ( byte === 0xff ) {
  114. const leadingByte = data.getUint8( byteOffset + 1 );
  115. if (
  116. [
  117. /* Valid section headers */
  118. 0xd8, // SOI
  119. 0xe0, // APP0
  120. 0xe1, // APP1
  121. 0xe2, // APP2
  122. ].includes( leadingByte )
  123. ) {
  124. sections.push( {
  125. sectionType: leadingByte,
  126. section: [ byte, leadingByte ],
  127. sectionOffset: byteOffset + 2,
  128. } );
  129. byteOffset += 2;
  130. } else {
  131. sections[ sections.length - 1 ].section.push( byte, leadingByte );
  132. byteOffset += 2;
  133. }
  134. } else {
  135. sections[ sections.length - 1 ].section.push( byte );
  136. byteOffset ++;
  137. }
  138. }
  139. let primaryImage, gainmapImage;
  140. for ( let i = 0; i < sections.length; i ++ ) {
  141. const { sectionType, section, sectionOffset } = sections[ i ];
  142. if ( sectionType === 0xe0 ) {
  143. /* JPEG Header - no useful information */
  144. } else if ( sectionType === 0xe1 ) {
  145. /* XMP Metadata */
  146. this._parseXMPMetadata(
  147. textDecoder.decode( new Uint8Array( section ) ),
  148. xmpMetadata
  149. );
  150. } else if ( sectionType === 0xe2 ) {
  151. /* Data Sections - MPF / EXIF / ICC Profile */
  152. const sectionData = new DataView(
  153. new Uint8Array( section.slice( 2 ) ).buffer
  154. );
  155. const sectionHeader = sectionData.getUint32( 2, false );
  156. if ( sectionHeader === 0x4d504600 ) {
  157. /* MPF Section */
  158. /* Section contains a list of static bytes and ends with offsets indicating location of SDR and gainmap images */
  159. /* First bytes after header indicate little / big endian ordering (0x49492A00 - LE / 0x4D4D002A - BE) */
  160. /*
  161. ... 60 bytes indicating tags, versions, etc. ...
  162. bytes | bits | description
  163. 4 32 primary image size
  164. 4 32 primary image offset
  165. 2 16 0x0000
  166. 2 16 0x0000
  167. 4 32 0x00000000
  168. 4 32 gainmap image size
  169. 4 32 gainmap image offset
  170. 2 16 0x0000
  171. 2 16 0x0000
  172. */
  173. const mpfLittleEndian = sectionData.getUint32( 6 ) === 0x49492a00;
  174. const mpfBytesOffset = 60;
  175. /* SDR size includes the metadata length, SDR offset is always 0 */
  176. const primaryImageSize = sectionData.getUint32(
  177. mpfBytesOffset,
  178. mpfLittleEndian
  179. );
  180. const primaryImageOffset = sectionData.getUint32(
  181. mpfBytesOffset + 4,
  182. mpfLittleEndian
  183. );
  184. /* Gainmap size is an absolute value starting from its offset, gainmap offset needs 6 bytes padding to take into account 0x00 bytes at the end of XMP */
  185. const gainmapImageSize = sectionData.getUint32(
  186. mpfBytesOffset + 16,
  187. mpfLittleEndian
  188. );
  189. const gainmapImageOffset =
  190. sectionData.getUint32( mpfBytesOffset + 20, mpfLittleEndian ) +
  191. sectionOffset +
  192. 6;
  193. primaryImage = new Uint8Array(
  194. data.buffer,
  195. primaryImageOffset,
  196. primaryImageSize
  197. );
  198. gainmapImage = new Uint8Array(
  199. data.buffer,
  200. gainmapImageOffset,
  201. gainmapImageSize
  202. );
  203. }
  204. }
  205. }
  206. /* Minimal sufficient validation - https://developer.android.com/media/platform/hdr-image-format#signal_of_the_format */
  207. if ( ! xmpMetadata.version ) {
  208. throw new Error( 'THREE.UltraHDRLoader: Not a valid UltraHDR image' );
  209. }
  210. if ( primaryImage && gainmapImage ) {
  211. this._applyGainmapToSDR(
  212. xmpMetadata,
  213. primaryImage,
  214. gainmapImage,
  215. ( hdrBuffer, width, height ) => {
  216. onLoad( {
  217. width,
  218. height,
  219. data: hdrBuffer,
  220. format: RGBAFormat,
  221. type: this.type,
  222. } );
  223. },
  224. ( error ) => {
  225. throw new Error( error );
  226. }
  227. );
  228. } else {
  229. throw new Error( 'THREE.UltraHDRLoader: Could not parse UltraHDR images' );
  230. }
  231. }
  232. /**
  233. * Starts loading from the given URL and passes the loaded Ultra HDR texture
  234. * to the `onLoad()` callback.
  235. *
  236. * @param {string} url - The path/URL of the files to be loaded. This can also be a data URI.
  237. * @param {function(DataTexture, Object)} onLoad - Executed when the loading process has been finished.
  238. * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
  239. * @param {onErrorCallback} onError - Executed when errors occur.
  240. * @return {DataTexture} The Ultra HDR texture.
  241. */
  242. load( url, onLoad, onProgress, onError ) {
  243. const texture = new DataTexture(
  244. this.type === HalfFloatType ? new Uint16Array() : new Float32Array(),
  245. 0,
  246. 0,
  247. RGBAFormat,
  248. this.type,
  249. UVMapping,
  250. ClampToEdgeWrapping,
  251. ClampToEdgeWrapping,
  252. LinearFilter,
  253. LinearMipMapLinearFilter,
  254. 1,
  255. LinearSRGBColorSpace
  256. );
  257. texture.generateMipmaps = true;
  258. texture.flipY = true;
  259. const loader = new FileLoader( this.manager );
  260. loader.setResponseType( 'arraybuffer' );
  261. loader.setRequestHeader( this.requestHeader );
  262. loader.setPath( this.path );
  263. loader.setWithCredentials( this.withCredentials );
  264. loader.load( url, ( buffer ) => {
  265. try {
  266. this.parse(
  267. buffer,
  268. ( texData ) => {
  269. texture.image = {
  270. data: texData.data,
  271. width: texData.width,
  272. height: texData.height,
  273. };
  274. texture.needsUpdate = true;
  275. if ( onLoad ) onLoad( texture, texData );
  276. }
  277. );
  278. } catch ( error ) {
  279. if ( onError ) onError( error );
  280. console.error( error );
  281. }
  282. }, onProgress, onError );
  283. return texture;
  284. }
  285. _parseXMPMetadata( xmpDataString, xmpMetadata ) {
  286. const domParser = new DOMParser();
  287. const xmpXml = domParser.parseFromString(
  288. xmpDataString.substring(
  289. xmpDataString.indexOf( '<' ),
  290. xmpDataString.lastIndexOf( '>' ) + 1
  291. ),
  292. 'text/xml'
  293. );
  294. /* Determine if given XMP metadata is the primary GContainer descriptor or a gainmap descriptor */
  295. const [ hasHDRContainerDescriptor ] = xmpXml.getElementsByTagName(
  296. 'Container:Directory'
  297. );
  298. if ( hasHDRContainerDescriptor ) {
  299. /* There's not much useful information in the container descriptor besides memory-validation */
  300. } else {
  301. /* Gainmap descriptor - defaults from https://developer.android.com/media/platform/hdr-image-format#HDR_gain_map_metadata */
  302. const [ gainmapNode ] = xmpXml.getElementsByTagName( 'rdf:Description' );
  303. xmpMetadata.version = gainmapNode.getAttribute( 'hdrgm:Version' );
  304. xmpMetadata.baseRenditionIsHDR =
  305. gainmapNode.getAttribute( 'hdrgm:BaseRenditionIsHDR' ) === 'True';
  306. xmpMetadata.gainMapMin = parseFloat(
  307. gainmapNode.getAttribute( 'hdrgm:GainMapMin' ) || 0.0
  308. );
  309. xmpMetadata.gainMapMax = parseFloat(
  310. gainmapNode.getAttribute( 'hdrgm:GainMapMax' ) || 1.0
  311. );
  312. xmpMetadata.gamma = parseFloat(
  313. gainmapNode.getAttribute( 'hdrgm:Gamma' ) || 1.0
  314. );
  315. xmpMetadata.offsetSDR = parseFloat(
  316. gainmapNode.getAttribute( 'hdrgm:OffsetSDR' ) / ( 1 / 64 )
  317. );
  318. xmpMetadata.offsetHDR = parseFloat(
  319. gainmapNode.getAttribute( 'hdrgm:OffsetHDR' ) / ( 1 / 64 )
  320. );
  321. xmpMetadata.hdrCapacityMin = parseFloat(
  322. gainmapNode.getAttribute( 'hdrgm:HDRCapacityMin' ) || 0.0
  323. );
  324. xmpMetadata.hdrCapacityMax = parseFloat(
  325. gainmapNode.getAttribute( 'hdrgm:HDRCapacityMax' ) || 1.0
  326. );
  327. }
  328. }
  329. _srgbToLinear( value ) {
  330. if ( value / 255 < 0.04045 ) {
  331. return ( value / 255 ) * 0.0773993808;
  332. }
  333. if ( value < 1024 ) {
  334. return SRGB_TO_LINEAR[ ~ ~ value ];
  335. }
  336. return Math.pow( ( value / 255 ) * 0.9478672986 + 0.0521327014, 2.4 );
  337. }
  338. _applyGainmapToSDR(
  339. xmpMetadata,
  340. sdrBuffer,
  341. gainmapBuffer,
  342. onSuccess,
  343. onError
  344. ) {
  345. const getImageDataFromBuffer = ( buffer ) =>
  346. new Promise( ( resolve, reject ) => {
  347. const imageLoader = document.createElement( 'img' );
  348. imageLoader.onload = () => {
  349. const image = {
  350. width: imageLoader.naturalWidth,
  351. height: imageLoader.naturalHeight,
  352. source: imageLoader,
  353. };
  354. URL.revokeObjectURL( imageLoader.src );
  355. resolve( image );
  356. };
  357. imageLoader.onerror = () => {
  358. URL.revokeObjectURL( imageLoader.src );
  359. reject();
  360. };
  361. imageLoader.src = URL.createObjectURL(
  362. new Blob( [ buffer ], { type: 'image/jpeg' } )
  363. );
  364. } );
  365. Promise.all( [
  366. getImageDataFromBuffer( sdrBuffer ),
  367. getImageDataFromBuffer( gainmapBuffer ),
  368. ] )
  369. .then( ( [ sdrImage, gainmapImage ] ) => {
  370. const sdrImageAspect = sdrImage.width / sdrImage.height;
  371. const gainmapImageAspect = gainmapImage.width / gainmapImage.height;
  372. if ( sdrImageAspect !== gainmapImageAspect ) {
  373. onError(
  374. 'THREE.UltraHDRLoader Error: Aspect ratio mismatch between SDR and Gainmap images'
  375. );
  376. return;
  377. }
  378. const canvas = document.createElement( 'canvas' );
  379. const ctx = canvas.getContext( '2d', {
  380. willReadFrequently: true,
  381. colorSpace: 'srgb',
  382. } );
  383. canvas.width = sdrImage.width;
  384. canvas.height = sdrImage.height;
  385. /* Use out-of-the-box interpolation of Canvas API to scale gainmap to fit the SDR resolution */
  386. ctx.drawImage(
  387. gainmapImage.source,
  388. 0,
  389. 0,
  390. gainmapImage.width,
  391. gainmapImage.height,
  392. 0,
  393. 0,
  394. sdrImage.width,
  395. sdrImage.height
  396. );
  397. const gainmapImageData = ctx.getImageData(
  398. 0,
  399. 0,
  400. sdrImage.width,
  401. sdrImage.height,
  402. { colorSpace: 'srgb' }
  403. );
  404. ctx.drawImage( sdrImage.source, 0, 0 );
  405. const sdrImageData = ctx.getImageData(
  406. 0,
  407. 0,
  408. sdrImage.width,
  409. sdrImage.height,
  410. { colorSpace: 'srgb' }
  411. );
  412. /* HDR Recovery formula - https://developer.android.com/media/platform/hdr-image-format#use_the_gain_map_to_create_adapted_HDR_rendition */
  413. let hdrBuffer;
  414. if ( this.type === HalfFloatType ) {
  415. hdrBuffer = new Uint16Array( sdrImageData.data.length ).fill( 23544 );
  416. } else {
  417. hdrBuffer = new Float32Array( sdrImageData.data.length ).fill( 255 );
  418. }
  419. const maxDisplayBoost = Math.sqrt(
  420. Math.pow(
  421. /* 1.8 instead of 2 near-perfectly rectifies approximations introduced by precalculated SRGB_TO_LINEAR values */
  422. 1.8,
  423. xmpMetadata.hdrCapacityMax
  424. )
  425. );
  426. const unclampedWeightFactor =
  427. ( Math.log2( maxDisplayBoost ) - xmpMetadata.hdrCapacityMin ) /
  428. ( xmpMetadata.hdrCapacityMax - xmpMetadata.hdrCapacityMin );
  429. const weightFactor = Math.min(
  430. Math.max( unclampedWeightFactor, 0.0 ),
  431. 1.0
  432. );
  433. const useGammaOne = xmpMetadata.gamma === 1.0;
  434. for (
  435. let pixelIndex = 0;
  436. pixelIndex < sdrImageData.data.length;
  437. pixelIndex += 4
  438. ) {
  439. const x = ( pixelIndex / 4 ) % sdrImage.width;
  440. const y = Math.floor( pixelIndex / 4 / sdrImage.width );
  441. for ( let channelIndex = 0; channelIndex < 3; channelIndex ++ ) {
  442. const sdrValue = sdrImageData.data[ pixelIndex + channelIndex ];
  443. const gainmapIndex = ( y * sdrImage.width + x ) * 4 + channelIndex;
  444. const gainmapValue = gainmapImageData.data[ gainmapIndex ] / 255.0;
  445. /* Gamma is 1.0 by default */
  446. const logRecovery = useGammaOne
  447. ? gainmapValue
  448. : Math.pow( gainmapValue, 1.0 / xmpMetadata.gamma );
  449. const logBoost =
  450. xmpMetadata.gainMapMin * ( 1.0 - logRecovery ) +
  451. xmpMetadata.gainMapMax * logRecovery;
  452. const hdrValue =
  453. ( sdrValue + xmpMetadata.offsetSDR ) *
  454. ( logBoost * weightFactor === 0.0
  455. ? 1.0
  456. : Math.pow( 2, logBoost * weightFactor ) ) -
  457. xmpMetadata.offsetHDR;
  458. const linearHDRValue = Math.min(
  459. Math.max( this._srgbToLinear( hdrValue ), 0 ),
  460. 65504
  461. );
  462. hdrBuffer[ pixelIndex + channelIndex ] =
  463. this.type === HalfFloatType
  464. ? DataUtils.toHalfFloat( linearHDRValue )
  465. : linearHDRValue;
  466. }
  467. }
  468. onSuccess( hdrBuffer, sdrImage.width, sdrImage.height );
  469. } )
  470. .catch( () => {
  471. throw new Error(
  472. 'THREE.UltraHDRLoader Error: Could not parse UltraHDR images'
  473. );
  474. } );
  475. }
  476. }
  477. export { UltraHDRLoader };