UltraHDRLoader.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  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. // Pre-calculated sRGB to linear lookup table for values 0-1023
  30. const SRGB_TO_LINEAR = new Float64Array( 1024 );
  31. for ( let i = 0; i < 1024; i ++ ) {
  32. // (1/255) * 0.9478672986 = 0.003717127
  33. SRGB_TO_LINEAR[ i ] = Math.pow( i * 0.003717127 + 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](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 {UltraHDRLoader} 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 bytes = new Uint8Array( buffer );
  109. const sections = [];
  110. // JPEG segment-aware scanner using length headers
  111. let offset = 0;
  112. while ( offset < bytes.length - 1 ) {
  113. // Find marker prefix
  114. if ( bytes[ offset ] !== 0xff ) {
  115. offset ++;
  116. continue;
  117. }
  118. const markerType = bytes[ offset + 1 ];
  119. // SOI (0xD8) - Start of Image, no length field
  120. if ( markerType === 0xd8 ) {
  121. sections.push( {
  122. sectionType: markerType,
  123. section: bytes.subarray( offset, offset + 2 ),
  124. sectionOffset: offset + 2,
  125. } );
  126. offset += 2;
  127. continue;
  128. }
  129. // APP0-APP2 segments have length headers
  130. if ( markerType === 0xe0 || markerType === 0xe1 || markerType === 0xe2 ) {
  131. // Length is stored as big-endian 16-bit value (includes length bytes, excludes marker)
  132. const segmentLength = ( bytes[ offset + 2 ] << 8 ) | bytes[ offset + 3 ];
  133. const segmentEnd = offset + 2 + segmentLength;
  134. sections.push( {
  135. sectionType: markerType,
  136. section: bytes.subarray( offset, segmentEnd ),
  137. sectionOffset: offset + 2,
  138. } );
  139. offset = segmentEnd;
  140. continue;
  141. }
  142. // Skip other markers with length fields (0xC0-0xFE range, except RST and EOI)
  143. if ( markerType >= 0xc0 && markerType <= 0xfe && markerType !== 0xd9 && ( markerType < 0xd0 || markerType > 0xd7 ) ) {
  144. const segmentLength = ( bytes[ offset + 2 ] << 8 ) | bytes[ offset + 3 ];
  145. offset += 2 + segmentLength;
  146. continue;
  147. }
  148. // EOI (0xD9) or RST markers (0xD0-0xD7) - no length field
  149. offset += 2;
  150. }
  151. let primaryImage, gainmapImage;
  152. for ( let i = 0; i < sections.length; i ++ ) {
  153. const { sectionType, section, sectionOffset } = sections[ i ];
  154. if ( sectionType === 0xe0 ) {
  155. /* JPEG Header - no useful information */
  156. } else if ( sectionType === 0xe1 ) {
  157. /* XMP Metadata */
  158. this._parseXMPMetadata(
  159. textDecoder.decode( new Uint8Array( section ) ),
  160. xmpMetadata
  161. );
  162. } else if ( sectionType === 0xe2 ) {
  163. /* Data Sections - MPF / EXIF / ICC Profile */
  164. const sectionData = new DataView( section.buffer, section.byteOffset + 2, section.byteLength - 2 );
  165. const sectionHeader = sectionData.getUint32( 2, false );
  166. if ( sectionHeader === 0x4d504600 ) {
  167. /* MPF Section */
  168. /* Section contains a list of static bytes and ends with offsets indicating location of SDR and gainmap images */
  169. /* First bytes after header indicate little / big endian ordering (0x49492A00 - LE / 0x4D4D002A - BE) */
  170. /*
  171. ... 60 bytes indicating tags, versions, etc. ...
  172. bytes | bits | description
  173. 4 32 primary image size
  174. 4 32 primary image offset
  175. 2 16 0x0000
  176. 2 16 0x0000
  177. 4 32 0x00000000
  178. 4 32 gainmap image size
  179. 4 32 gainmap image offset
  180. 2 16 0x0000
  181. 2 16 0x0000
  182. */
  183. const mpfLittleEndian = sectionData.getUint32( 6 ) === 0x49492a00;
  184. const mpfBytesOffset = 60;
  185. /* SDR size includes the metadata length, SDR offset is always 0 */
  186. const primaryImageSize = sectionData.getUint32(
  187. mpfBytesOffset,
  188. mpfLittleEndian
  189. );
  190. const primaryImageOffset = sectionData.getUint32(
  191. mpfBytesOffset + 4,
  192. mpfLittleEndian
  193. );
  194. /* 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 */
  195. const gainmapImageSize = sectionData.getUint32(
  196. mpfBytesOffset + 16,
  197. mpfLittleEndian
  198. );
  199. const gainmapImageOffset =
  200. sectionData.getUint32( mpfBytesOffset + 20, mpfLittleEndian ) +
  201. sectionOffset +
  202. 6;
  203. primaryImage = new Uint8Array(
  204. buffer,
  205. primaryImageOffset,
  206. primaryImageSize
  207. );
  208. gainmapImage = new Uint8Array(
  209. buffer,
  210. gainmapImageOffset,
  211. gainmapImageSize
  212. );
  213. }
  214. }
  215. }
  216. /* Minimal sufficient validation - https://developer.android.com/media/platform/hdr-image-format#signal_of_the_format */
  217. if ( ! xmpMetadata.version ) {
  218. throw new Error( 'THREE.UltraHDRLoader: Not a valid UltraHDR image' );
  219. }
  220. if ( primaryImage && gainmapImage ) {
  221. this._applyGainmapToSDR(
  222. xmpMetadata,
  223. primaryImage,
  224. gainmapImage,
  225. ( hdrBuffer, width, height ) => {
  226. onLoad( {
  227. width,
  228. height,
  229. data: hdrBuffer,
  230. format: RGBAFormat,
  231. type: this.type,
  232. } );
  233. },
  234. ( error ) => {
  235. throw new Error( error );
  236. }
  237. );
  238. } else {
  239. throw new Error( 'THREE.UltraHDRLoader: Could not parse UltraHDR images' );
  240. }
  241. }
  242. /**
  243. * Starts loading from the given URL and passes the loaded Ultra HDR texture
  244. * to the `onLoad()` callback.
  245. *
  246. * @param {string} url - The path/URL of the files to be loaded. This can also be a data URI.
  247. * @param {function(DataTexture, Object)} onLoad - Executed when the loading process has been finished.
  248. * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
  249. * @param {onErrorCallback} onError - Executed when errors occur.
  250. * @return {DataTexture} The Ultra HDR texture.
  251. */
  252. load( url, onLoad, onProgress, onError ) {
  253. const texture = new DataTexture(
  254. this.type === HalfFloatType ? new Uint16Array() : new Float32Array(),
  255. 0,
  256. 0,
  257. RGBAFormat,
  258. this.type,
  259. UVMapping,
  260. ClampToEdgeWrapping,
  261. ClampToEdgeWrapping,
  262. LinearFilter,
  263. LinearMipMapLinearFilter,
  264. 1,
  265. LinearSRGBColorSpace
  266. );
  267. texture.generateMipmaps = true;
  268. texture.flipY = true;
  269. const loader = new FileLoader( this.manager );
  270. loader.setResponseType( 'arraybuffer' );
  271. loader.setRequestHeader( this.requestHeader );
  272. loader.setPath( this.path );
  273. loader.setWithCredentials( this.withCredentials );
  274. loader.load( url, ( buffer ) => {
  275. try {
  276. this.parse(
  277. buffer,
  278. ( texData ) => {
  279. texture.image = {
  280. data: texData.data,
  281. width: texData.width,
  282. height: texData.height,
  283. };
  284. texture.needsUpdate = true;
  285. if ( onLoad ) onLoad( texture, texData );
  286. }
  287. );
  288. } catch ( error ) {
  289. if ( onError ) onError( error );
  290. console.error( error );
  291. }
  292. }, onProgress, onError );
  293. return texture;
  294. }
  295. _parseXMPMetadata( xmpDataString, xmpMetadata ) {
  296. const domParser = new DOMParser();
  297. const xmpXml = domParser.parseFromString(
  298. xmpDataString.substring(
  299. xmpDataString.indexOf( '<' ),
  300. xmpDataString.lastIndexOf( '>' ) + 1
  301. ),
  302. 'text/xml'
  303. );
  304. /* Determine if given XMP metadata is the primary GContainer descriptor or a gainmap descriptor */
  305. const [ hasHDRContainerDescriptor ] = xmpXml.getElementsByTagName(
  306. 'Container:Directory'
  307. );
  308. if ( hasHDRContainerDescriptor ) {
  309. /* There's not much useful information in the container descriptor besides memory-validation */
  310. } else {
  311. /* Gainmap descriptor - defaults from https://developer.android.com/media/platform/hdr-image-format#HDR_gain_map_metadata */
  312. const [ gainmapNode ] = xmpXml.getElementsByTagName( 'rdf:Description' );
  313. xmpMetadata.version = gainmapNode.getAttribute( 'hdrgm:Version' );
  314. xmpMetadata.baseRenditionIsHDR =
  315. gainmapNode.getAttribute( 'hdrgm:BaseRenditionIsHDR' ) === 'True';
  316. xmpMetadata.gainMapMin = parseFloat(
  317. gainmapNode.getAttribute( 'hdrgm:GainMapMin' ) || 0.0
  318. );
  319. xmpMetadata.gainMapMax = parseFloat(
  320. gainmapNode.getAttribute( 'hdrgm:GainMapMax' ) || 1.0
  321. );
  322. xmpMetadata.gamma = parseFloat(
  323. gainmapNode.getAttribute( 'hdrgm:Gamma' ) || 1.0
  324. );
  325. xmpMetadata.offsetSDR = parseFloat(
  326. gainmapNode.getAttribute( 'hdrgm:OffsetSDR' ) / ( 1 / 64 )
  327. );
  328. xmpMetadata.offsetHDR = parseFloat(
  329. gainmapNode.getAttribute( 'hdrgm:OffsetHDR' ) / ( 1 / 64 )
  330. );
  331. xmpMetadata.hdrCapacityMin = parseFloat(
  332. gainmapNode.getAttribute( 'hdrgm:HDRCapacityMin' ) || 0.0
  333. );
  334. xmpMetadata.hdrCapacityMax = parseFloat(
  335. gainmapNode.getAttribute( 'hdrgm:HDRCapacityMax' ) || 1.0
  336. );
  337. }
  338. }
  339. _srgbToLinear( value ) {
  340. // 0.04045 * 255 = 10.31475
  341. if ( value < 10.31475 ) {
  342. // (1/255) * 0.0773993808
  343. return value * 0.000303527;
  344. }
  345. if ( value < 1024 ) {
  346. return SRGB_TO_LINEAR[ value | 0 ];
  347. }
  348. // (1/255) * 0.9478672986 = 0.003717127
  349. return Math.pow( value * 0.003717127 + 0.0521327014, 2.4 );
  350. }
  351. _applyGainmapToSDR(
  352. xmpMetadata,
  353. sdrBuffer,
  354. gainmapBuffer,
  355. onSuccess,
  356. onError
  357. ) {
  358. const decodeImage = ( data ) => createImageBitmap( new Blob( [ data ], { type: 'image/jpeg' } ) );
  359. Promise.all( [ decodeImage( sdrBuffer ), decodeImage( gainmapBuffer ) ] )
  360. .then( ( [ sdrImage, gainmapImage ] ) => {
  361. const sdrWidth = sdrImage.width;
  362. const sdrHeight = sdrImage.height;
  363. const sdrImageAspect = sdrWidth / sdrHeight;
  364. const gainmapImageAspect = gainmapImage.width / gainmapImage.height;
  365. if ( sdrImageAspect !== gainmapImageAspect ) {
  366. onError(
  367. 'THREE.UltraHDRLoader Error: Aspect ratio mismatch between SDR and Gainmap images'
  368. );
  369. return;
  370. }
  371. const canvas = document.createElement( 'canvas' );
  372. const ctx = canvas.getContext( '2d', {
  373. willReadFrequently: true,
  374. colorSpace: 'srgb',
  375. } );
  376. canvas.width = sdrWidth;
  377. canvas.height = sdrHeight;
  378. /* Use out-of-the-box interpolation of Canvas API to scale gainmap to fit the SDR resolution */
  379. ctx.drawImage(
  380. gainmapImage,
  381. 0,
  382. 0,
  383. gainmapImage.width,
  384. gainmapImage.height,
  385. 0,
  386. 0,
  387. sdrWidth,
  388. sdrHeight
  389. );
  390. const gainmapImageData = ctx.getImageData(
  391. 0,
  392. 0,
  393. sdrWidth,
  394. sdrHeight,
  395. { colorSpace: 'srgb' }
  396. );
  397. ctx.drawImage( sdrImage, 0, 0 );
  398. const sdrImageData = ctx.getImageData(
  399. 0,
  400. 0,
  401. sdrWidth,
  402. sdrHeight,
  403. { colorSpace: 'srgb' }
  404. );
  405. /* HDR Recovery formula - https://developer.android.com/media/platform/hdr-image-format#use_the_gain_map_to_create_adapted_HDR_rendition */
  406. /* 1.8 instead of 2 near-perfectly rectifies approximations introduced by precalculated SRGB_TO_LINEAR values */
  407. const maxDisplayBoost = 1.8 ** ( xmpMetadata.hdrCapacityMax * 0.5 );
  408. const unclampedWeightFactor =
  409. ( Math.log2( maxDisplayBoost ) - xmpMetadata.hdrCapacityMin ) /
  410. ( xmpMetadata.hdrCapacityMax - xmpMetadata.hdrCapacityMin );
  411. const weightFactor = Math.min(
  412. Math.max( unclampedWeightFactor, 0.0 ),
  413. 1.0
  414. );
  415. const sdrData = sdrImageData.data;
  416. const gainmapData = gainmapImageData.data;
  417. const dataLength = sdrData.length;
  418. const gainMapMin = xmpMetadata.gainMapMin;
  419. const gainMapMax = xmpMetadata.gainMapMax;
  420. const offsetSDR = xmpMetadata.offsetSDR;
  421. const offsetHDR = xmpMetadata.offsetHDR;
  422. const invGamma = 1.0 / xmpMetadata.gamma;
  423. const useGammaOne = xmpMetadata.gamma === 1.0;
  424. const isHalfFloat = this.type === HalfFloatType;
  425. const toHalfFloat = DataUtils.toHalfFloat;
  426. const srgbToLinear = this._srgbToLinear;
  427. const hdrBuffer = isHalfFloat
  428. ? new Uint16Array( dataLength ).fill( 15360 )
  429. : new Float32Array( dataLength ).fill( 1.0 );
  430. for ( let i = 0; i < dataLength; i += 4 ) {
  431. for ( let c = 0; c < 3; c ++ ) {
  432. const idx = i + c;
  433. const sdrValue = sdrData[ idx ];
  434. const gainmapValue = gainmapData[ idx ] * 0.00392156862745098; // 1/255
  435. const logRecovery = useGammaOne
  436. ? gainmapValue
  437. : Math.pow( gainmapValue, invGamma );
  438. const logBoost = gainMapMin + ( gainMapMax - gainMapMin ) * logRecovery;
  439. const hdrValue =
  440. ( sdrValue + offsetSDR ) *
  441. ( logBoost * weightFactor === 0.0
  442. ? 1.0
  443. : Math.pow( 2, logBoost * weightFactor ) ) -
  444. offsetHDR;
  445. const linearHDRValue = Math.min(
  446. Math.max( srgbToLinear( hdrValue ), 0 ),
  447. 65504
  448. );
  449. hdrBuffer[ idx ] = isHalfFloat
  450. ? toHalfFloat( linearHDRValue )
  451. : linearHDRValue;
  452. }
  453. }
  454. onSuccess( hdrBuffer, sdrWidth, sdrHeight );
  455. } )
  456. .catch( ( e ) => {
  457. onError( e );
  458. } );
  459. }
  460. }
  461. export { UltraHDRLoader };
粤ICP备19079148号