ImportanceSampledEnvironment.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. /**
  2. * HDR environment importance sampling (CDF tables + MIS) for screen-space effects.
  3. *
  4. * CDF precomputation and the MIS env-miss estimator are adapted from
  5. * [three-gpu-pathtracer](https://github.com/gkjohnson/three-gpu-pathtracer).
  6. *
  7. * @see {@link https://github.com/gkjohnson/three-gpu-pathtracer}
  8. */
  9. import { If, dot, equirectUV, float, luminance, max, normalize, texture, uniform, vec2, vec4 } from 'three/tsl';
  10. import { ClampToEdgeWrapping, DataTexture, DataUtils, FloatType, HalfFloatType, LinearFilter, RedFormat, RepeatWrapping, Source, Vector2 } from 'three/webgpu';
  11. import { D_GTR, F_Schlick, GeometryTerm, SmithG, equirectDirPdf, misPowerHeuristic } from '../utils/SpecularHelpers.js';
  12. function colorToLuminance( r, g, b ) {
  13. return 0.2126 * r + 0.7152 * g + 0.0722 * b;
  14. }
  15. function binarySearchFindClosestIndexOf( array, targetValue, offset = 0, count = array.length ) {
  16. let lower = offset;
  17. let upper = offset + count - 1;
  18. while ( lower < upper ) {
  19. const mid = ( lower + upper ) >> 1;
  20. if ( array[ mid ] < targetValue ) {
  21. lower = mid + 1;
  22. } else {
  23. upper = mid;
  24. }
  25. }
  26. return lower - offset;
  27. }
  28. function preprocessEnvMap( envMap ) {
  29. const map = envMap.clone();
  30. map.source = new Source( { ...map.image } );
  31. const { width, height, data } = map.image;
  32. let newData = data;
  33. if ( map.type !== HalfFloatType ) {
  34. newData = new Uint16Array( data.length );
  35. let maxIntValue;
  36. if ( data instanceof Int8Array || data instanceof Int16Array || data instanceof Int32Array ) {
  37. maxIntValue = 2 ** ( 8 * data.BYTES_PER_ELEMENT - 1 ) - 1;
  38. } else {
  39. maxIntValue = 2 ** ( 8 * data.BYTES_PER_ELEMENT ) - 1;
  40. }
  41. for ( let i = 0, l = data.length; i < l; i ++ ) {
  42. let v = data[ i ];
  43. if ( map.type === HalfFloatType ) {
  44. v = DataUtils.fromHalfFloat( data[ i ] );
  45. }
  46. if ( map.type !== FloatType && map.type !== HalfFloatType ) {
  47. v /= maxIntValue;
  48. }
  49. newData[ i ] = DataUtils.toHalfFloat( v );
  50. }
  51. map.image.data = newData;
  52. map.type = HalfFloatType;
  53. }
  54. if ( map.flipY ) {
  55. const ogData = newData;
  56. newData = newData.slice();
  57. for ( let y = 0; y < height; y ++ ) {
  58. for ( let x = 0; x < width; x ++ ) {
  59. const newY = height - y - 1;
  60. const ogIndex = 4 * ( y * width + x );
  61. const newIndex = 4 * ( newY * width + x );
  62. newData[ newIndex + 0 ] = ogData[ ogIndex + 0 ];
  63. newData[ newIndex + 1 ] = ogData[ ogIndex + 1 ];
  64. newData[ newIndex + 2 ] = ogData[ ogIndex + 2 ];
  65. newData[ newIndex + 3 ] = ogData[ ogIndex + 3 ];
  66. }
  67. }
  68. map.flipY = false;
  69. map.image.data = newData;
  70. }
  71. return map;
  72. }
  73. /**
  74. * Precomputes marginal and conditional CDF textures from an equirectangular HDR environment map
  75. * for luminance importance sampling.
  76. */
  77. class EnvMapCDFGenerator {
  78. constructor() {
  79. this.map = null;
  80. this.marginalWeights = null;
  81. this.conditionalWeights = null;
  82. this.totalSum = 0;
  83. }
  84. updateFrom( hdr ) {
  85. this.updateMapOnly( hdr );
  86. const { width, height, data } = this.map.image;
  87. const pdfConditional = new Float32Array( width * height );
  88. const cdfConditional = new Float32Array( width * height );
  89. const pdfMarginal = new Float32Array( height );
  90. const cdfMarginal = new Float32Array( height );
  91. let totalSumValue = 0.0;
  92. let cumulativeWeightMarginal = 0.0;
  93. for ( let y = 0; y < height; y ++ ) {
  94. let cumulativeRowWeight = 0.0;
  95. for ( let x = 0; x < width; x ++ ) {
  96. const i = y * width + x;
  97. const r = DataUtils.fromHalfFloat( data[ 4 * i + 0 ] );
  98. const g = DataUtils.fromHalfFloat( data[ 4 * i + 1 ] );
  99. const b = DataUtils.fromHalfFloat( data[ 4 * i + 2 ] );
  100. const weight = colorToLuminance( r, g, b );
  101. cumulativeRowWeight += weight;
  102. totalSumValue += weight;
  103. pdfConditional[ i ] = weight;
  104. cdfConditional[ i ] = cumulativeRowWeight;
  105. }
  106. if ( cumulativeRowWeight !== 0 ) {
  107. for ( let i = y * width, l = y * width + width; i < l; i ++ ) {
  108. pdfConditional[ i ] /= cumulativeRowWeight;
  109. cdfConditional[ i ] /= cumulativeRowWeight;
  110. }
  111. }
  112. cumulativeWeightMarginal += cumulativeRowWeight;
  113. pdfMarginal[ y ] = cumulativeRowWeight;
  114. cdfMarginal[ y ] = cumulativeWeightMarginal;
  115. }
  116. if ( cumulativeWeightMarginal !== 0 ) {
  117. for ( let i = 0, l = pdfMarginal.length; i < l; i ++ ) {
  118. pdfMarginal[ i ] /= cumulativeWeightMarginal;
  119. cdfMarginal[ i ] /= cumulativeWeightMarginal;
  120. }
  121. }
  122. const marginalDataArray = new Uint16Array( height );
  123. const conditionalDataArray = new Uint16Array( width * height );
  124. for ( let i = 0; i < height; i ++ ) {
  125. const dist = ( i + 1 ) / height;
  126. const row = binarySearchFindClosestIndexOf( cdfMarginal, dist );
  127. marginalDataArray[ i ] = DataUtils.toHalfFloat( ( row + 0.5 ) / height );
  128. }
  129. for ( let y = 0; y < height; y ++ ) {
  130. for ( let x = 0; x < width; x ++ ) {
  131. const i = y * width + x;
  132. const dist = ( x + 1 ) / width;
  133. const col = binarySearchFindClosestIndexOf( cdfConditional, dist, y * width, width );
  134. conditionalDataArray[ i ] = DataUtils.toHalfFloat( ( col + 0.5 ) / width );
  135. }
  136. }
  137. if ( this.marginalWeights ) {
  138. this.marginalWeights.dispose();
  139. }
  140. if ( this.conditionalWeights ) {
  141. this.conditionalWeights.dispose();
  142. }
  143. this.marginalWeights = new DataTexture( marginalDataArray, height, 1 );
  144. this.marginalWeights.type = HalfFloatType;
  145. this.marginalWeights.format = RedFormat;
  146. this.marginalWeights.minFilter = LinearFilter;
  147. this.marginalWeights.magFilter = LinearFilter;
  148. this.marginalWeights.wrapS = ClampToEdgeWrapping;
  149. this.marginalWeights.wrapT = ClampToEdgeWrapping;
  150. this.marginalWeights.generateMipmaps = false;
  151. this.marginalWeights.needsUpdate = true;
  152. this.conditionalWeights = new DataTexture( conditionalDataArray, width, height );
  153. this.conditionalWeights.type = HalfFloatType;
  154. this.conditionalWeights.format = RedFormat;
  155. this.conditionalWeights.minFilter = LinearFilter;
  156. this.conditionalWeights.magFilter = LinearFilter;
  157. this.conditionalWeights.wrapS = ClampToEdgeWrapping;
  158. this.conditionalWeights.wrapT = ClampToEdgeWrapping;
  159. this.conditionalWeights.generateMipmaps = false;
  160. this.conditionalWeights.needsUpdate = true;
  161. this.totalSum = totalSumValue;
  162. }
  163. updateMapOnly( hdr ) {
  164. if ( this.map ) {
  165. this.map.dispose();
  166. }
  167. const map = preprocessEnvMap( hdr );
  168. map.wrapS = RepeatWrapping;
  169. map.wrapT = ClampToEdgeWrapping;
  170. this.map = map;
  171. this.totalSum = 0;
  172. }
  173. dispose() {
  174. if ( this.marginalWeights ) {
  175. this.marginalWeights.dispose();
  176. this.marginalWeights = null;
  177. }
  178. if ( this.conditionalWeights ) {
  179. this.conditionalWeights.dispose();
  180. this.conditionalWeights = null;
  181. }
  182. if ( this.map ) {
  183. this.map.dispose();
  184. this.map = null;
  185. }
  186. }
  187. }
  188. /**
  189. * Manages a preprocessed HDR environment map (CDF textures, uniforms) and exposes
  190. * TSL helpers for BRDF-direction lookups and MIS importance sampling.
  191. *
  192. * @see {@link https://github.com/gkjohnson/three-gpu-pathtracer}
  193. */
  194. class ImportanceSampledEnvironment {
  195. /**
  196. * @param {boolean} [importanceSampling=false] - When `true`, builds luminance CDF tables and enables MIS env sampling.
  197. */
  198. constructor( importanceSampling = false ) {
  199. this._importanceSampling = importanceSampling;
  200. this._cdf = new EnvMapCDFGenerator();
  201. this._totalSum = uniform( 0.0, 'float' );
  202. this._size = uniform( new Vector2( 1, 1 ) );
  203. this.intensity = uniform( 1.0, 'float' );
  204. this._mapNode = null;
  205. this._marginalNode = null;
  206. this._conditionalNode = null;
  207. }
  208. /**
  209. * @param {Texture} hdr - Equirectangular HDR environment map.
  210. */
  211. updateFrom( hdr ) {
  212. if ( this._importanceSampling ) {
  213. this._cdf.updateFrom( hdr );
  214. this._totalSum.value = this._cdf.totalSum;
  215. } else {
  216. this._cdf.updateMapOnly( hdr );
  217. }
  218. this._size.value.set( this._cdf.map.image.width, this._cdf.map.image.height );
  219. if ( this._mapNode === null ) {
  220. this._mapNode = texture( this._cdf.map );
  221. if ( this._importanceSampling ) {
  222. this._marginalNode = texture( this._cdf.marginalWeights );
  223. this._conditionalNode = texture( this._cdf.conditionalWeights );
  224. }
  225. } else {
  226. this._mapNode.value = this._cdf.map;
  227. if ( this._importanceSampling ) {
  228. this._marginalNode.value = this._cdf.marginalWeights;
  229. this._conditionalNode.value = this._cdf.conditionalWeights;
  230. }
  231. }
  232. }
  233. clear() {
  234. this.dispose();
  235. this._cdf = new EnvMapCDFGenerator();
  236. this._mapNode = null;
  237. this._marginalNode = null;
  238. this._conditionalNode = null;
  239. this._totalSum.value = 0;
  240. this._size.value.set( 1, 1 );
  241. }
  242. /**
  243. * Simple environment lookup along the reflected direction (no MIS).
  244. *
  245. * @param {Object} params
  246. * @param {UniformNode<Matrix4>} params.cameraWorldMatrix
  247. * @param {Node<vec3>} params.viewReflectDir
  248. * @param {Node<float>} [params.sampleWeight] - Optional radiance scale (defaults to 1).
  249. * @return {Node<vec3>}
  250. */
  251. sampleReflect( { cameraWorldMatrix, viewReflectDir, sampleWeight = float( 1 ) } ) {
  252. const worldReflectDir = cameraWorldMatrix.mul( vec4( viewReflectDir, float( 0 ) ) ).xyz.normalize();
  253. const envUV = equirectUV( worldReflectDir );
  254. // Explicit LOD 0: the per-pixel reflected direction is discontinuous at the equirect pole/seam
  255. // (atan is undefined at the poles), so derivative-driven mip selection collapses to the coarsest
  256. // (near-average) mip there and produces a bright streak. Roughness is handled via direction sampling.
  257. return texture( this._mapNode, envUV ).level( 0 ).rgb.mul( this.intensity ).mul( sampleWeight );
  258. }
  259. /**
  260. * Environment reflection for a screen-space miss using only the BRDF / reflected-ray direction.
  261. *
  262. * @param {Object} params
  263. * @param {UniformNode<Matrix4>} params.cameraWorldMatrix
  264. * @param {Node<vec3>} params.viewReflectDir - View-space GGX-sampled reflected ray.
  265. * @param {Node<vec3>} params.N - View-space shading normal.
  266. * @param {Node<vec3>} params.V - View-space direction to camera.
  267. * @param {Node<float>} params.alpha - GGX roughness (alpha).
  268. * @param {Node<vec3>} params.f0
  269. * @return {Node<vec3>}
  270. */
  271. sampleEnvironmentBRDF( {
  272. cameraWorldMatrix,
  273. viewReflectDir,
  274. N,
  275. V,
  276. alpha,
  277. f0
  278. } ) {
  279. const worldNormal = cameraWorldMatrix.mul( vec4( N, 0 ) ).xyz.normalize().toVar();
  280. const worldV = cameraWorldMatrix.mul( vec4( V, 0 ) ).xyz.normalize().toVar();
  281. const NdotV = max( float( 0 ), dot( worldNormal, worldV ) ).toVar();
  282. const L1 = cameraWorldMatrix.mul( vec4( viewReflectDir, float( 0 ) ) ).xyz.normalize().toVar();
  283. // Explicit LOD 0: the equirect mapping is singular at the poles (atan undefined when the reflected
  284. // ray points straight up/down, e.g. a flat floor under a top-down camera), so derivative-driven mip
  285. // selection picks the coarsest, near-average mip and yields a bright streak. Sample full-res instead.
  286. const brdfEnvColor = texture( this._mapNode, equirectUV( L1 ) ).level( 0 ).rgb;
  287. const H1 = normalize( worldV.add( L1 ) ).toVar();
  288. const NdotL1 = max( float( 0 ), dot( worldNormal, L1 ) ).toVar();
  289. const VdotH1 = max( float( 0 ), dot( worldV, H1 ) ).toVar();
  290. const W1 = F_Schlick( f0, VdotH1 ).mul( GeometryTerm( NdotL1, NdotV, alpha ) ).div( SmithG( NdotV, alpha ).max( float( 1e-4 ) ) );
  291. return brdfEnvColor.mul( W1 ).mul( this.intensity );
  292. }
  293. /**
  294. * Environment reflection for a screen-space miss, estimated with multiple importance
  295. * sampling (MIS) between the BRDF / reflected-ray direction and the env-luminance CDF
  296. * direction. Both techniques use consistent solid-angle PDFs (`D·G1(N·V)/(4·N·V)`), so
  297. * the power heuristic is unbiased. Adapted from three-gpu-pathtracer.
  298. *
  299. * @see {@link https://github.com/gkjohnson/three-gpu-pathtracer}
  300. *
  301. * @param {Object} params
  302. * @param {UniformNode<Matrix4>} params.cameraWorldMatrix
  303. * @param {Node<vec3>} params.viewReflectDir - View-space GGX-sampled reflected ray.
  304. * @param {Node<vec3>} params.N - View-space shading normal.
  305. * @param {Node<vec3>} params.V - View-space direction to camera.
  306. * @param {Node<float>} params.alpha - GGX roughness (alpha).
  307. * @param {Node<vec3>} params.f0
  308. * @param {Node<vec4>} params.Xi2 - Second blue-noise sample (zw used for the CDF).
  309. * @return {Node<vec3>}
  310. */
  311. sampleEnvironmentMIS( {
  312. cameraWorldMatrix,
  313. viewReflectDir,
  314. N,
  315. V,
  316. alpha,
  317. f0,
  318. Xi2
  319. } ) {
  320. const mapNode = this._mapNode;
  321. const marginalNode = this._marginalNode;
  322. const conditionalNode = this._conditionalNode;
  323. const totalSum = this._totalSum;
  324. const envW = this._size.x;
  325. const envH = this._size.y;
  326. const envMapIntensity = this.intensity;
  327. const worldNormal = cameraWorldMatrix.mul( vec4( N, 0 ) ).xyz.normalize().toVar();
  328. const worldV = cameraWorldMatrix.mul( vec4( V, 0 ) ).xyz.normalize().toVar();
  329. const NdotV = max( float( 0 ), dot( worldNormal, worldV ) ).toVar();
  330. // MIS sample 1: the BRDF / reflected-ray direction
  331. const L1 = cameraWorldMatrix.mul( vec4( viewReflectDir, float( 0 ) ) ).xyz.normalize().toVar();
  332. const brdfEnvColor = texture( mapNode, equirectUV( L1 ) ).level( 0 ).rgb;
  333. const H1 = normalize( worldV.add( L1 ) ).toVar();
  334. const NdotL1 = max( float( 0 ), dot( worldNormal, L1 ) ).toVar();
  335. const NdotH1 = max( float( 0 ), dot( worldNormal, H1 ) ).toVar();
  336. const VdotH1 = max( float( 0 ), dot( worldV, H1 ) ).toVar();
  337. // Solid-angle PDF of the reflected ray for the BRDF technique: D(H)·G1(N·V)/(4·N·V).
  338. const pdfBrdf1 = D_GTR( alpha, NdotH1, float( 2 ) ).mul( SmithG( NdotV, alpha ) ).div( max( float( 1e-6 ), float( 4 ).mul( NdotV ) ) ).max( float( 1e-8 ) );
  339. // Env-luminance CDF PDF evaluated at the same direction.
  340. const pdfEnv1 = envW.mul( envH ).mul( luminance( brdfEnvColor ).div( totalSum ) ).mul( equirectDirPdf( L1 ) ).max( float( 1e-8 ) );
  341. const w1 = misPowerHeuristic( pdfBrdf1, pdfEnv1 );
  342. // Monte-Carlo weight f·cosθ/pdfBrdf1 = F·G1(N·L) (GGX D cancels analytically — stable at low
  343. // roughness). G2 and the pdf's G1 must use the same alpha for the cancellation to hold.
  344. const W1 = F_Schlick( f0, VdotH1 ).mul( GeometryTerm( NdotL1, NdotV, alpha ) ).div( SmithG( NdotV, alpha ).max( float( 1e-4 ) ) );
  345. const result = brdfEnvColor.mul( W1 ).mul( w1 ).toVar();
  346. // MIS sample 2: the env-luminance CDF direction
  347. // Mitigates noise on high-dynamic-range environments (the CDF lands samples on bright regions
  348. // the BRDF lobe rarely hits). Skipped for near-mirror lobes (alpha ≲ 0.01, i.e. roughness ≲ 0.1):
  349. // a global CDF direction almost never lands inside such a tight specular lobe.
  350. If( alpha.greaterThan( 0.01 ), () => {
  351. const r_env = vec2( Xi2.z, Xi2.w );
  352. const v_cdf = texture( marginalNode, vec2( r_env.x, float( 0 ) ) ).r;
  353. const u_cdf = texture( conditionalNode, vec2( r_env.y, v_cdf ) ).r;
  354. const isEnvUV = vec2( u_cdf, v_cdf );
  355. const envDirWS = equirectUV( isEnvUV );
  356. const envHalf = normalize( worldV.add( envDirWS ) );
  357. const envNdotL = max( float( 0 ), dot( worldNormal, envDirWS ) );
  358. const envNdotH = max( float( 0 ), dot( worldNormal, envHalf ) );
  359. const envVdotH = max( float( 0 ), dot( worldV, envHalf ) );
  360. If( envNdotL.greaterThan( 0.001 ), () => {
  361. // GGX normal-distribution term, shared by the BRDF pdf and the specular BRDF
  362. // (both evaluate D(envNdotH)) so the pow is computed once.
  363. const D = D_GTR( alpha, envNdotH, float( 2 ) ).toVar();
  364. const sampledColor = texture( mapNode, isEnvUV ).level( 0 ).rgb;
  365. const pdfEnv2 = envW.mul( envH ).mul( luminance( sampledColor ).div( totalSum ) ).mul( equirectDirPdf( envDirWS ) ).max( float( 1e-8 ) );
  366. // BRDF technique pdf at the env direction — same solid-angle form as pdfBrdf1 (no V·H).
  367. const pdfBrdf2 = D.mul( SmithG( NdotV, alpha ) ).div( max( float( 1e-6 ), float( 4 ).mul( NdotV ) ) ).max( float( 1e-8 ) );
  368. const w2 = misPowerHeuristic( pdfEnv2, pdfBrdf2 );
  369. // Specular BRDF (without Fresnel): D·G2 / (4·N·L·N·V), reusing D. Same GGX alpha as the pdf.
  370. const envBrdfSpec = D.mul( GeometryTerm( envNdotL, NdotV, alpha ) ).div( max( float( 1e-6 ), float( 4 ).mul( envNdotL ).mul( NdotV ) ) );
  371. const envFresnelWeight = F_Schlick( f0, envVdotH ); // vec3 — chromatic metal tint
  372. result.addAssign( sampledColor.mul( envBrdfSpec ).mul( envFresnelWeight ).mul( envNdotL ).div( pdfEnv2 ).mul( w2 ) );
  373. } );
  374. } );
  375. return result.mul( envMapIntensity );
  376. }
  377. dispose() {
  378. this._cdf.dispose();
  379. }
  380. }
  381. export default ImportanceSampledEnvironment;
粤ICP备19079148号