three
Version:
JavaScript 3D library
561 lines (381 loc) • 16.5 kB
JavaScript
/**
* HDR environment importance sampling (CDF tables + MIS) for screen-space effects.
*
* CDF precomputation and the MIS env-miss estimator are adapted from
* [three-gpu-pathtracer](https://github.com/gkjohnson/three-gpu-pathtracer).
*
* @see {@link https://github.com/gkjohnson/three-gpu-pathtracer}
*/
import { If, dot, equirectUV, float, luminance, max, normalize, texture, uniform, vec2, vec4 } from 'three/tsl';
import { ClampToEdgeWrapping, DataTexture, DataUtils, FloatType, HalfFloatType, LinearFilter, RedFormat, RepeatWrapping, Source, Vector2 } from 'three/webgpu';
import { D_GTR, F_Schlick, GeometryTerm, SmithG, equirectDirPdf, misPowerHeuristic } from '../utils/SpecularHelpers.js';
function colorToLuminance( r, g, b ) {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function binarySearchFindClosestIndexOf( array, targetValue, offset = 0, count = array.length ) {
let lower = offset;
let upper = offset + count - 1;
while ( lower < upper ) {
const mid = ( lower + upper ) >> 1;
if ( array[ mid ] < targetValue ) {
lower = mid + 1;
} else {
upper = mid;
}
}
return lower - offset;
}
function preprocessEnvMap( envMap ) {
const map = envMap.clone();
map.source = new Source( { ...map.image } );
const { width, height, data } = map.image;
let newData = data;
if ( map.type !== HalfFloatType ) {
newData = new Uint16Array( data.length );
let maxIntValue;
if ( data instanceof Int8Array || data instanceof Int16Array || data instanceof Int32Array ) {
maxIntValue = 2 ** ( 8 * data.BYTES_PER_ELEMENT - 1 ) - 1;
} else {
maxIntValue = 2 ** ( 8 * data.BYTES_PER_ELEMENT ) - 1;
}
for ( let i = 0, l = data.length; i < l; i ++ ) {
let v = data[ i ];
if ( map.type === HalfFloatType ) {
v = DataUtils.fromHalfFloat( data[ i ] );
}
if ( map.type !== FloatType && map.type !== HalfFloatType ) {
v /= maxIntValue;
}
newData[ i ] = DataUtils.toHalfFloat( v );
}
map.image.data = newData;
map.type = HalfFloatType;
}
if ( map.flipY ) {
const ogData = newData;
newData = newData.slice();
for ( let y = 0; y < height; y ++ ) {
for ( let x = 0; x < width; x ++ ) {
const newY = height - y - 1;
const ogIndex = 4 * ( y * width + x );
const newIndex = 4 * ( newY * width + x );
newData[ newIndex + 0 ] = ogData[ ogIndex + 0 ];
newData[ newIndex + 1 ] = ogData[ ogIndex + 1 ];
newData[ newIndex + 2 ] = ogData[ ogIndex + 2 ];
newData[ newIndex + 3 ] = ogData[ ogIndex + 3 ];
}
}
map.flipY = false;
map.image.data = newData;
}
return map;
}
/**
* Precomputes marginal and conditional CDF textures from an equirectangular HDR environment map
* for luminance importance sampling.
*/
class EnvMapCDFGenerator {
constructor() {
this.map = null;
this.marginalWeights = null;
this.conditionalWeights = null;
this.totalSum = 0;
}
updateFrom( hdr ) {
this.updateMapOnly( hdr );
const { width, height, data } = this.map.image;
const pdfConditional = new Float32Array( width * height );
const cdfConditional = new Float32Array( width * height );
const pdfMarginal = new Float32Array( height );
const cdfMarginal = new Float32Array( height );
let totalSumValue = 0.0;
let cumulativeWeightMarginal = 0.0;
for ( let y = 0; y < height; y ++ ) {
let cumulativeRowWeight = 0.0;
for ( let x = 0; x < width; x ++ ) {
const i = y * width + x;
const r = DataUtils.fromHalfFloat( data[ 4 * i + 0 ] );
const g = DataUtils.fromHalfFloat( data[ 4 * i + 1 ] );
const b = DataUtils.fromHalfFloat( data[ 4 * i + 2 ] );
const weight = colorToLuminance( r, g, b );
cumulativeRowWeight += weight;
totalSumValue += weight;
pdfConditional[ i ] = weight;
cdfConditional[ i ] = cumulativeRowWeight;
}
if ( cumulativeRowWeight !== 0 ) {
for ( let i = y * width, l = y * width + width; i < l; i ++ ) {
pdfConditional[ i ] /= cumulativeRowWeight;
cdfConditional[ i ] /= cumulativeRowWeight;
}
}
cumulativeWeightMarginal += cumulativeRowWeight;
pdfMarginal[ y ] = cumulativeRowWeight;
cdfMarginal[ y ] = cumulativeWeightMarginal;
}
if ( cumulativeWeightMarginal !== 0 ) {
for ( let i = 0, l = pdfMarginal.length; i < l; i ++ ) {
pdfMarginal[ i ] /= cumulativeWeightMarginal;
cdfMarginal[ i ] /= cumulativeWeightMarginal;
}
}
const marginalDataArray = new Uint16Array( height );
const conditionalDataArray = new Uint16Array( width * height );
for ( let i = 0; i < height; i ++ ) {
const dist = ( i + 1 ) / height;
const row = binarySearchFindClosestIndexOf( cdfMarginal, dist );
marginalDataArray[ i ] = DataUtils.toHalfFloat( ( row + 0.5 ) / height );
}
for ( let y = 0; y < height; y ++ ) {
for ( let x = 0; x < width; x ++ ) {
const i = y * width + x;
const dist = ( x + 1 ) / width;
const col = binarySearchFindClosestIndexOf( cdfConditional, dist, y * width, width );
conditionalDataArray[ i ] = DataUtils.toHalfFloat( ( col + 0.5 ) / width );
}
}
if ( this.marginalWeights ) {
this.marginalWeights.dispose();
}
if ( this.conditionalWeights ) {
this.conditionalWeights.dispose();
}
this.marginalWeights = new DataTexture( marginalDataArray, height, 1 );
this.marginalWeights.type = HalfFloatType;
this.marginalWeights.format = RedFormat;
this.marginalWeights.minFilter = LinearFilter;
this.marginalWeights.magFilter = LinearFilter;
this.marginalWeights.wrapS = ClampToEdgeWrapping;
this.marginalWeights.wrapT = ClampToEdgeWrapping;
this.marginalWeights.generateMipmaps = false;
this.marginalWeights.needsUpdate = true;
this.conditionalWeights = new DataTexture( conditionalDataArray, width, height );
this.conditionalWeights.type = HalfFloatType;
this.conditionalWeights.format = RedFormat;
this.conditionalWeights.minFilter = LinearFilter;
this.conditionalWeights.magFilter = LinearFilter;
this.conditionalWeights.wrapS = ClampToEdgeWrapping;
this.conditionalWeights.wrapT = ClampToEdgeWrapping;
this.conditionalWeights.generateMipmaps = false;
this.conditionalWeights.needsUpdate = true;
this.totalSum = totalSumValue;
}
updateMapOnly( hdr ) {
if ( this.map ) {
this.map.dispose();
}
const map = preprocessEnvMap( hdr );
map.wrapS = RepeatWrapping;
map.wrapT = ClampToEdgeWrapping;
this.map = map;
this.totalSum = 0;
}
dispose() {
if ( this.marginalWeights ) {
this.marginalWeights.dispose();
this.marginalWeights = null;
}
if ( this.conditionalWeights ) {
this.conditionalWeights.dispose();
this.conditionalWeights = null;
}
if ( this.map ) {
this.map.dispose();
this.map = null;
}
}
}
/**
* Manages a preprocessed HDR environment map (CDF textures, uniforms) and exposes
* TSL helpers for BRDF-direction lookups and MIS importance sampling.
*
* @see {@link https://github.com/gkjohnson/three-gpu-pathtracer}
*/
class ImportanceSampledEnvironment {
/**
* @param {boolean} [importanceSampling=false] - When `true`, builds luminance CDF tables and enables MIS env sampling.
*/
constructor( importanceSampling = false ) {
this._importanceSampling = importanceSampling;
this._cdf = new EnvMapCDFGenerator();
this._totalSum = uniform( 0.0, 'float' );
this._size = uniform( new Vector2( 1, 1 ) );
this.intensity = uniform( 1.0, 'float' );
this._mapNode = null;
this._marginalNode = null;
this._conditionalNode = null;
}
/**
* @param {Texture} hdr - Equirectangular HDR environment map.
*/
updateFrom( hdr ) {
if ( this._importanceSampling ) {
this._cdf.updateFrom( hdr );
this._totalSum.value = this._cdf.totalSum;
} else {
this._cdf.updateMapOnly( hdr );
}
this._size.value.set( this._cdf.map.image.width, this._cdf.map.image.height );
if ( this._mapNode === null ) {
this._mapNode = texture( this._cdf.map );
if ( this._importanceSampling ) {
this._marginalNode = texture( this._cdf.marginalWeights );
this._conditionalNode = texture( this._cdf.conditionalWeights );
}
} else {
this._mapNode.value = this._cdf.map;
if ( this._importanceSampling ) {
this._marginalNode.value = this._cdf.marginalWeights;
this._conditionalNode.value = this._cdf.conditionalWeights;
}
}
}
clear() {
this.dispose();
this._cdf = new EnvMapCDFGenerator();
this._mapNode = null;
this._marginalNode = null;
this._conditionalNode = null;
this._totalSum.value = 0;
this._size.value.set( 1, 1 );
}
/**
* Simple environment lookup along the reflected direction (no MIS).
*
* @param {Object} params
* @param {UniformNode<Matrix4>} params.cameraWorldMatrix
* @param {Node<vec3>} params.viewReflectDir
* @param {Node<float>} [params.sampleWeight] - Optional radiance scale (defaults to 1).
* @return {Node<vec3>}
*/
sampleReflect( { cameraWorldMatrix, viewReflectDir, sampleWeight = float( 1 ) } ) {
const worldReflectDir = cameraWorldMatrix.mul( vec4( viewReflectDir, float( 0 ) ) ).xyz.normalize();
const envUV = equirectUV( worldReflectDir );
// Explicit LOD 0: the per-pixel reflected direction is discontinuous at the equirect pole/seam
// (atan is undefined at the poles), so derivative-driven mip selection collapses to the coarsest
// (near-average) mip there and produces a bright streak. Roughness is handled via direction sampling.
return texture( this._mapNode, envUV ).level( 0 ).rgb.mul( this.intensity ).mul( sampleWeight );
}
/**
* Environment reflection for a screen-space miss using only the BRDF / reflected-ray direction.
*
* @param {Object} params
* @param {UniformNode<Matrix4>} params.cameraWorldMatrix
* @param {Node<vec3>} params.viewReflectDir - View-space GGX-sampled reflected ray.
* @param {Node<vec3>} params.N - View-space shading normal.
* @param {Node<vec3>} params.V - View-space direction to camera.
* @param {Node<float>} params.alpha - GGX roughness (alpha).
* @param {Node<vec3>} params.f0
* @return {Node<vec3>}
*/
sampleEnvironmentBRDF( {
cameraWorldMatrix,
viewReflectDir,
N,
V,
alpha,
f0
} ) {
const worldNormal = cameraWorldMatrix.mul( vec4( N, 0 ) ).xyz.normalize().toVar();
const worldV = cameraWorldMatrix.mul( vec4( V, 0 ) ).xyz.normalize().toVar();
const NdotV = max( float( 0 ), dot( worldNormal, worldV ) ).toVar();
const L1 = cameraWorldMatrix.mul( vec4( viewReflectDir, float( 0 ) ) ).xyz.normalize().toVar();
// Explicit LOD 0: the equirect mapping is singular at the poles (atan undefined when the reflected
// ray points straight up/down, e.g. a flat floor under a top-down camera), so derivative-driven mip
// selection picks the coarsest, near-average mip and yields a bright streak. Sample full-res instead.
const brdfEnvColor = texture( this._mapNode, equirectUV( L1 ) ).level( 0 ).rgb;
const H1 = normalize( worldV.add( L1 ) ).toVar();
const NdotL1 = max( float( 0 ), dot( worldNormal, L1 ) ).toVar();
const VdotH1 = max( float( 0 ), dot( worldV, H1 ) ).toVar();
const W1 = F_Schlick( f0, VdotH1 ).mul( GeometryTerm( NdotL1, NdotV, alpha ) ).div( SmithG( NdotV, alpha ).max( float( 1e-4 ) ) );
return brdfEnvColor.mul( W1 ).mul( this.intensity );
}
/**
* Environment reflection for a screen-space miss, estimated with multiple importance
* sampling (MIS) between the BRDF / reflected-ray direction and the env-luminance CDF
* direction. Both techniques use consistent solid-angle PDFs (`D·G1(N·V)/(4·N·V)`), so
* the power heuristic is unbiased. Adapted from three-gpu-pathtracer.
*
* @see {@link https://github.com/gkjohnson/three-gpu-pathtracer}
*
* @param {Object} params
* @param {UniformNode<Matrix4>} params.cameraWorldMatrix
* @param {Node<vec3>} params.viewReflectDir - View-space GGX-sampled reflected ray.
* @param {Node<vec3>} params.N - View-space shading normal.
* @param {Node<vec3>} params.V - View-space direction to camera.
* @param {Node<float>} params.alpha - GGX roughness (alpha).
* @param {Node<vec3>} params.f0
* @param {Node<vec4>} params.Xi2 - Second blue-noise sample (zw used for the CDF).
* @return {Node<vec3>}
*/
sampleEnvironmentMIS( {
cameraWorldMatrix,
viewReflectDir,
N,
V,
alpha,
f0,
Xi2
} ) {
const mapNode = this._mapNode;
const marginalNode = this._marginalNode;
const conditionalNode = this._conditionalNode;
const totalSum = this._totalSum;
const envW = this._size.x;
const envH = this._size.y;
const envMapIntensity = this.intensity;
const worldNormal = cameraWorldMatrix.mul( vec4( N, 0 ) ).xyz.normalize().toVar();
const worldV = cameraWorldMatrix.mul( vec4( V, 0 ) ).xyz.normalize().toVar();
const NdotV = max( float( 0 ), dot( worldNormal, worldV ) ).toVar();
// MIS sample 1: the BRDF / reflected-ray direction
const L1 = cameraWorldMatrix.mul( vec4( viewReflectDir, float( 0 ) ) ).xyz.normalize().toVar();
const brdfEnvColor = texture( mapNode, equirectUV( L1 ) ).level( 0 ).rgb;
const H1 = normalize( worldV.add( L1 ) ).toVar();
const NdotL1 = max( float( 0 ), dot( worldNormal, L1 ) ).toVar();
const NdotH1 = max( float( 0 ), dot( worldNormal, H1 ) ).toVar();
const VdotH1 = max( float( 0 ), dot( worldV, H1 ) ).toVar();
// Solid-angle PDF of the reflected ray for the BRDF technique: D(H)·G1(N·V)/(4·N·V).
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 ) );
// Env-luminance CDF PDF evaluated at the same direction.
const pdfEnv1 = envW.mul( envH ).mul( luminance( brdfEnvColor ).div( totalSum ) ).mul( equirectDirPdf( L1 ) ).max( float( 1e-8 ) );
const w1 = misPowerHeuristic( pdfBrdf1, pdfEnv1 );
// Monte-Carlo weight f·cosθ/pdfBrdf1 = F·G1(N·L) (GGX D cancels analytically — stable at low
// roughness). G2 and the pdf's G1 must use the same alpha for the cancellation to hold.
const W1 = F_Schlick( f0, VdotH1 ).mul( GeometryTerm( NdotL1, NdotV, alpha ) ).div( SmithG( NdotV, alpha ).max( float( 1e-4 ) ) );
const result = brdfEnvColor.mul( W1 ).mul( w1 ).toVar();
// MIS sample 2: the env-luminance CDF direction
// Mitigates noise on high-dynamic-range environments (the CDF lands samples on bright regions
// the BRDF lobe rarely hits). Skipped for near-mirror lobes (alpha ≲ 0.01, i.e. roughness ≲ 0.1):
// a global CDF direction almost never lands inside such a tight specular lobe.
If( alpha.greaterThan( 0.01 ), () => {
const r_env = vec2( Xi2.z, Xi2.w );
const v_cdf = texture( marginalNode, vec2( r_env.x, float( 0 ) ) ).r;
const u_cdf = texture( conditionalNode, vec2( r_env.y, v_cdf ) ).r;
const isEnvUV = vec2( u_cdf, v_cdf );
const envDirWS = equirectUV( isEnvUV );
const envHalf = normalize( worldV.add( envDirWS ) );
const envNdotL = max( float( 0 ), dot( worldNormal, envDirWS ) );
const envNdotH = max( float( 0 ), dot( worldNormal, envHalf ) );
const envVdotH = max( float( 0 ), dot( worldV, envHalf ) );
If( envNdotL.greaterThan( 0.001 ), () => {
// GGX normal-distribution term, shared by the BRDF pdf and the specular BRDF
// (both evaluate D(envNdotH)) so the pow is computed once.
const D = D_GTR( alpha, envNdotH, float( 2 ) ).toVar();
const sampledColor = texture( mapNode, isEnvUV ).level( 0 ).rgb;
const pdfEnv2 = envW.mul( envH ).mul( luminance( sampledColor ).div( totalSum ) ).mul( equirectDirPdf( envDirWS ) ).max( float( 1e-8 ) );
// BRDF technique pdf at the env direction — same solid-angle form as pdfBrdf1 (no V·H).
const pdfBrdf2 = D.mul( SmithG( NdotV, alpha ) ).div( max( float( 1e-6 ), float( 4 ).mul( NdotV ) ) ).max( float( 1e-8 ) );
const w2 = misPowerHeuristic( pdfEnv2, pdfBrdf2 );
// Specular BRDF (without Fresnel): D·G2 / (4·N·L·N·V), reusing D. Same GGX alpha as the pdf.
const envBrdfSpec = D.mul( GeometryTerm( envNdotL, NdotV, alpha ) ).div( max( float( 1e-6 ), float( 4 ).mul( envNdotL ).mul( NdotV ) ) );
const envFresnelWeight = F_Schlick( f0, envVdotH ); // vec3 — chromatic metal tint
result.addAssign( sampledColor.mul( envBrdfSpec ).mul( envFresnelWeight ).mul( envNdotL ).div( pdfEnv2 ).mul( w2 ) );
} );
} );
return result.mul( envMapIntensity );
}
dispose() {
this._cdf.dispose();
}
}
export default ImportanceSampledEnvironment;