three
Version:
JavaScript 3D library
771 lines (514 loc) • 21.1 kB
JavaScript
import {
CubeReflectionMapping,
CubeRefractionMapping,
LinearMipmapLinearFilter,
NoBlending,
HalfFloatType,
BackSide,
LinearSRGBColorSpace
} from '../constants.js';
import { Mesh } from '../objects/Mesh.js';
import { BoxGeometry } from '../geometries/BoxGeometry.js';
import { CubeCamera } from '../cameras/CubeCamera.js';
import { ShaderMaterial } from '../materials/ShaderMaterial.js';
import { Color } from '../math/Color.js';
import { floorPowerOfTwo } from '../math/MathUtils.js';
import { Vector3 } from '../math/Vector3.js';
import { ShaderChunk } from '../renderers/shaders/ShaderChunk.js';
import { WebGLCubeRenderTarget } from '../renderers/WebGLCubeRenderTarget.js';
// Smaller inputs are upsampled so that every PMREM has enough mip levels.
const MIN_SIZE = 256;
// Log2 of the face size of the roughest mip level. Smaller faces can't
// represent the diffuse irradiance that is stored in this level.
const LOD_MIN = 3;
// Spiral samples per pass of the initial fromScene() blur.
const BLUR_SAMPLES = 20;
// GGX VNDF samples for the sharp mip levels.
const GGX_SAMPLES = 256;
// Integrate a small source mip for the rough levels to avoid sampling noise.
const INTEGRATION_SIZE = 16;
const INTEGRATION_LEVELS = 3;
const _origin = /*@__PURE__*/ new Vector3();
const _clearColor = /*@__PURE__*/ new Color();
/**
* Generates a Prefiltered, Mipmapped Radiance Environment Map (PMREM) for
* image-based lighting. The result is a cube render target whose mip levels
* store GGX-filtered radiance at increasing roughness, from a mirror at level 0
* to roughness 1. See {@link PMREMGenerator.lodToRoughness} for the mapping.
*
* Filtering assumes the view direction equals the surface normal. Sharp levels
* use GGX visible normal sampling (Heitz, 2018), while rough levels integrate a
* lower-resolution source cubemap.
*
* @see {@link https://jcgt.org/published/0007/04/01/ | Sampling the GGX Distribution of Visible Normals}
*/
class PMREMGenerator {
/**
* Constructs a new PMREM generator.
*
* @param {WebGLRenderer} renderer - The renderer.
*/
constructor( renderer ) {
this._renderer = renderer;
this._cubeSize = 0;
this._sourceTarget = null;
this._cubeCamera = new CubeCamera( 1, 10, null );
this._boxMesh = new Mesh( new BoxGeometry( 5, 5, 5 ), null );
this._cubemapMaterial = null;
this._equirectMaterial = null;
this._blurMaterial = null;
this._ggxMaterial = null;
this._integrationMaterial = null;
}
/**
* Generates a PMREM from a scene, optionally applying a Gaussian blur before
* GGX filtering.
*
* @param {Scene} scene - The scene to be captured.
* @param {number} [sigma=0] - The blur radius in radians.
* @param {number} [near=0.1] - The near plane distance.
* @param {number} [far=100] - The far plane distance.
* @param {Object} [options={}] - The configuration options.
* @param {number} [options.size=256] - The texture size of the PMREM, rounded down to a power of two and at least 256.
* @param {Vector3} [options.position=origin] - The position of the internal cube camera that renders the scene.
* @return {WebGLCubeRenderTarget} The resulting PMREM.
*/
fromScene( scene, sigma = 0, near = 0.1, far = 100, options = {} ) {
const {
size = 256,
position = _origin,
} = options;
const renderer = this._renderer;
this._setSize( size );
const pmremTarget = this._allocateTarget();
const sourceTarget = this._getSourceTarget( true );
if ( sigma > 0 ) {
sourceTarget.texture.mipmapsAutoUpdate = false;
}
// Clear every captured face, independent of the application's clear settings.
const autoClear = renderer.autoClear;
const autoClearColor = renderer.autoClearColor;
const autoClearDepth = renderer.autoClearDepth;
const autoClearStencil = renderer.autoClearStencil;
const background = scene.background;
renderer.autoClear = true;
renderer.autoClearColor = true;
renderer.autoClearDepth = true;
renderer.autoClearStencil = true;
if ( background === null ) scene.background = renderer.getClearColor( _clearColor );
const cubeCamera = new CubeCamera( near, far, sourceTarget );
cubeCamera.position.copy( position );
cubeCamera.update( renderer, scene );
renderer.autoClear = autoClear;
renderer.autoClearColor = autoClearColor;
renderer.autoClearDepth = autoClearDepth;
renderer.autoClearStencil = autoClearStencil;
scene.background = background;
if ( sigma > 0 ) {
sourceTarget.texture.mipmapsAutoUpdate = true;
this._blur( pmremTarget, sigma );
}
this._applyPMREM( pmremTarget );
return pmremTarget;
}
/**
* Generates a PMREM from an LDR or HDR equirectangular texture. The cube face
* size is one quarter of the image width, rounded down to a power of two and
* at least 256.
*
* @param {Texture} equirectangular - The equirectangular texture to be converted.
* @param {?WebGLCubeRenderTarget} [renderTarget=null] - The render target to use.
* @return {WebGLCubeRenderTarget} The resulting PMREM.
*/
fromEquirectangular( equirectangular, renderTarget = null ) {
return this._fromTexture( equirectangular, renderTarget );
}
/**
* Generates a PMREM from an LDR or HDR cubemap. The cube face size matches
* the input, rounded down to a power of two and at least 256.
*
* @param {Texture} cubemap - The cubemap texture to be converted.
* @param {?WebGLCubeRenderTarget} [renderTarget=null] - The render target to use.
* @return {WebGLCubeRenderTarget} The resulting PMREM.
*/
fromCubemap( cubemap, renderTarget = null ) {
return this._fromTexture( cubemap, renderTarget );
}
/**
* Pre-compiles the cubemap shader. You can get faster start-up by invoking this method during
* your texture's network fetch for increased concurrency.
*/
compileCubemapShader() {
if ( this._cubemapMaterial === null ) {
this._cubemapMaterial = _getCubemapMaterial();
this._compileMaterial( this._cubemapMaterial );
}
}
/**
* Pre-compiles the equirectangular shader. You can get faster start-up by invoking this method during
* your texture's network fetch for increased concurrency.
*/
compileEquirectangularShader() {
if ( this._equirectMaterial === null ) {
this._equirectMaterial = _getEquirectMaterial();
this._compileMaterial( this._equirectMaterial );
}
}
/**
* Disposes of the PMREMGenerator's internal memory. The PMREMs it returned
* belong to the caller and are not disposed.
*/
dispose() {
if ( this._sourceTarget !== null ) this._sourceTarget.dispose();
if ( this._cubemapMaterial !== null ) this._cubemapMaterial.dispose();
if ( this._equirectMaterial !== null ) this._equirectMaterial.dispose();
if ( this._blurMaterial !== null ) this._blurMaterial.dispose();
if ( this._ggxMaterial !== null ) this._ggxMaterial.dispose();
if ( this._integrationMaterial !== null ) this._integrationMaterial.dispose();
this._boxMesh.geometry.dispose();
}
/**
* Returns the roughness a mip level of a PMREM has been prefiltered for.
* The inverse mapping is used by the renderers when sampling a PMREM.
*
* @param {number} lod - The mip level.
* @param {number} maxLod - The last mip level of the PMREM.
* @return {number} The roughness.
*/
static lodToRoughness( lod, maxLod ) {
return maxLod > 0 ? 1 - Math.sqrt( 1 - lod / maxLod ) : 0;
}
// private interface
_setSize( cubeSize ) {
this._cubeSize = Math.max( MIN_SIZE, floorPowerOfTwo( cubeSize ) );
}
_fromTexture( texture, renderTarget ) {
if ( texture.mapping === CubeReflectionMapping || texture.mapping === CubeRefractionMapping ) {
this._setSize( texture.image.length === 0 ? MIN_SIZE : ( texture.image[ 0 ].width || texture.image[ 0 ].image.width ) );
} else { // Equirectangular
this._setSize( texture.image.width / 4 );
}
const pmremTarget = renderTarget || this._allocateTarget();
this._textureToCubemap( texture );
this._applyPMREM( pmremTarget );
return pmremTarget;
}
_allocateTarget() {
const size = this._cubeSize;
const pmremTarget = _createRenderTarget( size, false, false );
// Allocate only the prefiltered mip levels without automatic mipmap generation.
const maxLod = Math.log2( size ) - LOD_MIN;
for ( let lod = 0; lod <= maxLod; lod ++ ) {
pmremTarget.texture.mipmaps.push( { width: size >> lod, height: size >> lod } );
}
pmremTarget.texture.name = 'PMREM';
pmremTarget.texture.isPMREMTexture = true;
return pmremTarget;
}
_getSourceTarget( depthBuffer = false ) {
const size = this._cubeSize;
const sourceTarget = this._sourceTarget;
if ( sourceTarget === null || sourceTarget.width !== size || ( depthBuffer && sourceTarget.depthBuffer === false ) ) {
if ( sourceTarget !== null ) sourceTarget.dispose();
this._sourceTarget = _createRenderTarget( size, true, depthBuffer );
}
return this._sourceTarget;
}
_compileMaterial( material ) {
this._boxMesh.material = material;
this._renderer.compile( this._boxMesh, this._cubeCamera.children[ 0 ] );
}
/**
* Renders the box mesh with the given material into all six faces of a cube render target.
*
* @private
* @param {WebGLCubeRenderTarget} target - The render target.
* @param {number} lod - The mip level to render into.
* @param {ShaderMaterial} material - The material.
*/
_renderCube( target, lod, material ) {
const boxMesh = this._boxMesh;
const cubeCamera = this._cubeCamera;
boxMesh.material = material;
cubeCamera.renderTarget = target;
cubeCamera.activeMipmapLevel = lod;
// WebGLRenderer uses the target viewport without scaling it to the active mip.
const size = target.width >> lod;
target.viewport.set( 0, 0, size, size );
cubeCamera.update( this._renderer, boxMesh );
target.viewport.set( 0, 0, target.width, target.height );
}
_textureToCubemap( texture ) {
let material;
if ( texture.mapping === CubeReflectionMapping || texture.mapping === CubeRefractionMapping ) {
if ( this._cubemapMaterial === null ) {
this._cubemapMaterial = _getCubemapMaterial();
}
material = this._cubemapMaterial;
material.uniforms.flipEnvMap.value = ( texture.isRenderTargetTexture === false ) ? - 1 : 1;
} else {
if ( this._equirectMaterial === null ) {
this._equirectMaterial = _getEquirectMaterial();
}
material = this._equirectMaterial;
}
material.uniforms.envMap.value = texture;
this._renderCube( this._getSourceTarget(), 0, material );
}
/**
* Prefilters the source cubemap into the mip levels of the PMREM. The sharp levels
* integrate the GGX lobe of their roughness with VNDF importance sampling, reading the
* mip level of the source that matches the solid angle of each sample. The rough
* levels weight every texel of a small source mip with the GGX lobe instead.
*
* @private
* @param {WebGLCubeRenderTarget} pmremTarget - The PMREM.
*/
_applyPMREM( pmremTarget ) {
if ( this._ggxMaterial === null ) {
this._ggxMaterial = _getGGXMaterial();
this._integrationMaterial = _getIntegrationMaterial();
}
const size = this._cubeSize;
const maxLod = pmremTarget.texture.mipmaps.length - 1;
const ggxUniforms = this._ggxMaterial.uniforms;
ggxUniforms.envMap.value = this._sourceTarget.texture;
const integrationUniforms = this._integrationMaterial.uniforms;
integrationUniforms.envMap.value = this._sourceTarget.texture;
integrationUniforms.sourceLod.value = Math.log2( size / INTEGRATION_SIZE );
for ( let lod = 0; lod <= maxLod; lod ++ ) {
const roughness = PMREMGenerator.lodToRoughness( lod, maxLod );
if ( lod > maxLod - INTEGRATION_LEVELS ) {
integrationUniforms.roughness.value = roughness;
this._renderCube( pmremTarget, lod, this._integrationMaterial );
} else {
// For V = N, pdf( L ) = D( H ) / 4. Match the source texel solid angle to
// 1 / ( GGX_SAMPLES * pdf ), with a half-mip bias to reduce sampling noise.
// The shader supplies log2 of the GGX denominator.
const lodBias = roughness > 0 ? Math.log2( size ) + 0.5 * Math.log2( 6 / ( GGX_SAMPLES * Math.pow( roughness, 4 ) ) ) + 0.5 : 0;
ggxUniforms.roughness.value = roughness;
ggxUniforms.lodBias.value = lodBias;
this._renderCube( pmremTarget, lod, this._ggxMaterial );
}
}
}
/**
* Applies the initial fromScene() blur in two passes using a golden-angle
* spiral kernel. Level 0 of the PMREM serves as the intermediate target.
*
* @private
* @param {WebGLCubeRenderTarget} pmremTarget - The PMREM.
* @param {number} sigma - The blur radius in radians.
*/
_blur( pmremTarget, sigma ) {
if ( this._blurMaterial === null ) {
this._blurMaterial = _getBlurMaterial();
}
const material = this._blurMaterial;
const uniforms = material.uniforms;
const sourceTarget = this._sourceTarget;
// Split the blur variance between two passes. Clamp sigma to the sphere's
// maximum angular distance.
uniforms.sigma.value = Math.min( sigma, Math.PI ) / Math.SQRT2;
uniforms.envMap.value = sourceTarget.texture;
this._renderCube( pmremTarget, 0, material );
uniforms.envMap.value = pmremTarget.texture;
this._renderCube( sourceTarget, 0, material );
}
}
function _createRenderTarget( size, generateMipmaps, depthBuffer ) {
return new WebGLCubeRenderTarget( size, {
minFilter: LinearMipmapLinearFilter,
generateMipmaps: generateMipmaps,
type: HalfFloatType,
colorSpace: LinearSRGBColorSpace,
depthBuffer: depthBuffer
} );
}
function _getMaterial( name, uniforms, fragmentShader ) {
return new ShaderMaterial( {
name: name,
uniforms: uniforms,
vertexShader: ShaderChunk.cube_vert,
fragmentShader: fragmentShader,
side: BackSide,
blending: NoBlending,
depthTest: false,
depthWrite: false
} );
}
function _getGGXMaterial() {
return _getMaterial( 'PMREMGGXConvolution', {
'envMap': { value: null },
'roughness': { value: 0 },
'lodBias': { value: 0 }
}, /* glsl */`
#define GGX_SAMPLES ${ GGX_SAMPLES }u
varying vec3 vWorldDirection;
uniform samplerCube envMap;
uniform float roughness;
uniform float lodBias;
#include <common>
// Van der Corput radical inverse
float radicalInverse_VdC( uint bits ) {
bits = ( bits << 16u ) | ( bits >> 16u );
bits = ( ( bits & 0x55555555u ) << 1u ) | ( ( bits & 0xAAAAAAAAu ) >> 1u );
bits = ( ( bits & 0x33333333u ) << 2u ) | ( ( bits & 0xCCCCCCCCu ) >> 2u );
bits = ( ( bits & 0x0F0F0F0Fu ) << 4u ) | ( ( bits & 0xF0F0F0F0u ) >> 4u );
bits = ( ( bits & 0x00FF00FFu ) << 8u ) | ( ( bits & 0xFF00FF00u ) >> 8u );
return float( bits ) * 2.3283064365386963e-10; // / 0x100000000
}
// Hammersley sequence
vec2 hammersley( uint i, uint N ) {
return vec2( float( i ) / float( N ), radicalInverse_VdC( i ) );
}
void main() {
vec3 N = normalize( vWorldDirection );
// For very low roughness, just sample the environment directly
if ( roughness < 0.001 ) {
gl_FragColor = vec4( textureLod( envMap, N, 0.0 ).rgb, 1.0 );
return;
}
float alpha = roughness * roughness;
float alpha2 = alpha * alpha;
// Tangent space basis for VNDF sampling
vec3 up = abs( N.z ) < 0.999 ? vec3( 0.0, 0.0, 1.0 ) : vec3( 1.0, 0.0, 0.0 );
vec3 tangent = normalize( cross( up, N ) );
vec3 bitangent = cross( N, tangent );
vec3 prefilteredColor = vec3( 0.0 );
float totalWeight = 0.0;
for ( uint i = 0u; i < GGX_SAMPLES; i ++ ) {
vec2 Xi = hammersley( i, GGX_SAMPLES );
// With V = N, sample the reflected direction directly.
float invQ = 1.0 / ( 1.0 - Xi.x + alpha2 * Xi.x );
float NdotL = ( 1.0 - Xi.x - alpha2 * Xi.x ) * invQ;
if ( NdotL > 0.0 ) {
float phi = 2.0 * PI * Xi.y;
float sinTheta = 2.0 * alpha * sqrt( Xi.x * ( 1.0 - Xi.x ) ) * invQ;
vec3 L = N * NdotL + ( tangent * cos( phi ) + bitangent * sin( phi ) ) * sinTheta;
// Match the source mip to the sample's solid angle. The terms
// independent of the sample are precomputed in lodBias.
float d = alpha2 * invQ;
float lod = max( log2( d ) + lodBias, 0.0 );
// Weight by NdotL for the split-sum approximation
prefilteredColor += textureLod( envMap, L, lod ).rgb * NdotL;
totalWeight += NdotL;
}
}
gl_FragColor = vec4( prefilteredColor / totalWeight, 1.0 );
}
` );
}
function _getIntegrationMaterial() {
return _getMaterial( 'PMREMGGXIntegration', {
'envMap': { value: null },
'roughness': { value: 0 },
'sourceLod': { value: 0 },
'sourceSize': { value: INTEGRATION_SIZE } // a uniform so the loops aren't unrolled
}, /* glsl */`
varying vec3 vWorldDirection;
uniform samplerCube envMap;
uniform float roughness;
uniform float sourceLod;
uniform int sourceSize;
void main() {
vec3 N = normalize( vWorldDirection );
float alpha = roughness * roughness;
float alpha2 = alpha * alpha;
float texelSize = 2.0 / float( sourceSize );
vec3 prefilteredColor = vec3( 0.0 );
float totalWeight = 0.0;
// Pair opposite texels: only the one in N's hemisphere contributes.
for ( int face = 0; face < 3; face ++ ) {
for ( int y = 0; y < sourceSize; y ++ ) {
for ( int x = 0; x < sourceSize; x ++ ) {
vec2 uv = ( vec2( x, y ) + 0.5 ) * texelSize - 1.0;
vec3 texelDirection = face == 0 ? vec3( 1.0, uv ) : ( face == 1 ? vec3( uv.x, 1.0, uv.y ) : vec3( uv, 1.0 ) );
float invDistance = inversesqrt( 1.0 + dot( uv, uv ) );
float NdotL = dot( N, texelDirection );
texelDirection *= NdotL < 0.0 ? - 1.0 : 1.0;
NdotL = abs( NdotL ) * invDistance;
// With V = N, NdotH squared is ( 1 + NdotL ) / 2. Common factors
// in the GGX distribution and texel solid angle cancel when normalized.
float d = 1.0 + alpha2 + ( alpha2 - 1.0 ) * NdotL;
float weight = NdotL * invDistance * invDistance * invDistance / ( d * d );
prefilteredColor += textureLod( envMap, texelDirection, sourceLod ).rgb * weight;
totalWeight += weight;
}
}
}
gl_FragColor = vec4( prefilteredColor / totalWeight, 1.0 );
}
` );
}
function _getBlurMaterial() {
return _getMaterial( 'PMREMSphericalGaussianBlur', {
'envMap': { value: null },
'sigma': { value: 0 }
}, /* glsl */`
#define SAMPLES ${ BLUR_SAMPLES }
#define GOLDEN_ANGLE 2.39996322973
varying vec3 vWorldDirection;
uniform samplerCube envMap;
uniform float sigma;
#include <common>
void main() {
vec3 outputDirection = normalize( vWorldDirection );
vec3 up = abs( outputDirection.z ) < 0.999 ? vec3( 0.0, 0.0, 1.0 ) : vec3( 1.0, 0.0, 0.0 );
vec3 tangent = normalize( cross( up, outputDirection ) );
vec3 bitangent = cross( outputDirection, tangent );
// Truncate the kernel at three standard deviations or at the antipode.
float thetaMax = min( 3.0 * sigma, PI );
float truncation = 1.0 - exp( - 0.5 * thetaMax * thetaMax / ( sigma * sigma ) );
vec3 accumColor = vec3( 0.0 );
float accumWeight = 0.0;
for ( int i = 0; i < SAMPLES; i ++ ) {
// Stratified inverse-CDF sampling of the Gaussian, placed on a golden-angle spiral.
float stratum = ( float( i ) + 0.5 ) / float( SAMPLES );
float theta = sigma * sqrt( - 2.0 * log( 1.0 - stratum * truncation ) );
float phi = float( i ) * GOLDEN_ANGLE;
vec3 offset = cos( phi ) * tangent + sin( phi ) * bitangent;
vec3 sampleDirection = cos( theta ) * outputDirection + sin( theta ) * offset;
// Correct the planar sample density to solid angle.
float weight = sin( theta ) / theta;
accumColor += weight * textureLod( envMap, sampleDirection, 0.0 ).rgb;
accumWeight += weight;
}
gl_FragColor = vec4( accumColor / accumWeight, 1.0 );
}
` );
}
function _getEquirectMaterial() {
return _getMaterial( 'PMREMEquirectangularToCubemap', {
'envMap': { value: null }
}, /* glsl */`
varying vec3 vWorldDirection;
uniform sampler2D envMap;
#include <common>
void main() {
// Average four subpixel samples to preserve small, bright features.
vec3 direction = normalize( vWorldDirection );
vec3 dx = dFdx( direction ) * 0.25;
vec3 dy = dFdy( direction ) * 0.25;
vec3 color = textureLod( envMap, equirectUv( normalize( direction - dx - dy ) ), 0.0 ).rgb;
color += textureLod( envMap, equirectUv( normalize( direction + dx - dy ) ), 0.0 ).rgb;
color += textureLod( envMap, equirectUv( normalize( direction - dx + dy ) ), 0.0 ).rgb;
color += textureLod( envMap, equirectUv( normalize( direction + dx + dy ) ), 0.0 ).rgb;
gl_FragColor = vec4( color * 0.25, 1.0 );
}
` );
}
function _getCubemapMaterial() {
return _getMaterial( 'PMREMCubemapToCubemap', {
'envMap': { value: null },
'flipEnvMap': { value: - 1 }
}, /* glsl */`
varying vec3 vWorldDirection;
uniform samplerCube envMap;
uniform float flipEnvMap;
void main() {
gl_FragColor = vec4( textureCube( envMap, vec3( flipEnvMap * vWorldDirection.x, vWorldDirection.yz ) ).rgb, 1.0 );
}
` );
}
export { PMREMGenerator };