Three.js中的后处理是相对比较难的部分。以我个人学习经历而言,之所以感觉难是因为前期缺乏对其宏观理解,不知道什么是后处理,甚至第一次听说后处理就直接去看后处理的API,看官网提供的案例,不解其原理。初看后处理的Demo感觉就很懵,这代码为什么可以?背后的运行逻辑是什么?出现问题不知道为什么,不知哪一步出的错。直到学了渲染到纹理,看了EffectComposer的源码,看了常用的后处理通道代码,才大概理解后处理的概念。还是那句话:源码之下无秘密。本文就先宏观讲一下我理解的后处理,再来讲Three.js的具体代码,如果觉得有所帮助还请点赞关注一下~~
我所理解的后处理
大部分情况下,我们都是将三维场景直接渲染场景到屏幕。借助WebGLRenderTarget,我们也可以将场景渲染的到一张纹理或者说是一块GPU buffer里面。渲染完成之后,我们可以获取这张纹理,可作为普通的贴图使用,或者将这张贴图进一步处理,增强其视觉特效或渲染质量。在这张图的基础上进一步加工,这种技术就叫后处理。也就是说后处理更像PS技术或者说是图像处理,输入的是图片,输出的也是图片,跟前期的三维场景关系已经不大了,甚至可以完全没有关系。将后处理说出图像处理技术是我自己琢磨的,不合适可讨论。

在Three.js中,后处理的每一次处理称为Pass,经常翻译成通道。那么什么是通道?通道就是处理输入图片的一道工序,输出是另一张图片。如一张图片可经过辉光、模糊、outline、抗锯齿、输出等多道工序。前一道工序的输出是后一道工序的输入。相对于通道,我更喜欢用工序这个词,好理解。
另外,不要认为工序的输入输出都是图形,那么工序的内部就不会涉及三维渲染。是不是涉及是工序内部自己决定。如某道工序内部可渲染另一个场景,并将结果与输入的图片进行整合。
渲染到纹理
渲染到纹理是后处理的基础,所以先看一下这块的API。three.js中渲染到纹理API还是比较简单的,其渲染结果会存储到WebGLRenderTarget目标对象中,通过其属性.texture可以获得渲染结果的RGBA像素数据,也就是一个Three.js的纹理对象THREE.Texture,可以作为材质对象颜色贴图属性map的属性值;下面是API演示代码:
javascript
// 1. 创建WebGLRenderTarget对象,可设置宽高、格式、颜色空间、是否有深度buffer、stencilBuffer等
const target = new THREE.WebGLRenderTarget(200, 200, { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, format: THREE.RGBFormat });
// 2. 设置渲染目标,这样就不会输出到屏幕
renderer.setRenderTarget(target)
// 3. 执行渲染
renderer.render(scene, camera)
// 4. 获取纹理
const map = target.texture
// 5. 重新设置渲染到屏幕
renderer.setRenderTarget(null)
EffectComposer源码解读
EffectComposer 是 Three.js 中用于实现后期处理效果的核心类,它EffectComposer 是一个渲染通道的管理器,它按照你添加的顺序执行这些通道或工序,每个通道可以对前一通道的输出进行处理,最后一个通道输出到屏幕。我们在开发中一般先创建一个EffectComposer,之后增加各种渲染通道。形成类似以下代码:
javascript
// 创建一个EffectComposer
const composer = new EffectComposer( renderer );
// 添加后处理通道
const renderPass = new RenderPass( scene, camera );
composer.addPass( renderPass );
const glitchPass = new GlitchPass();
composer.addPass( glitchPass );
const outputPass = new OutputPass();
composer.addPass( outputPass );
// 进行渲染
composer.render();
所以很有必要看一下EffectComposer的源码,印证一下前面所讲的内容。打开three.js源码中examples/jsm/postprocessing/EffectComposer.js文件。先看其构造函数:
javascript
class EffectComposer {
constructor( renderer, renderTarget ) {
this.renderer = renderer;
this._pixelRatio = renderer.getPixelRatio();
// 默认创建一个WebGLRenderTarget
if ( renderTarget === undefined ) {
const size = renderer.getSize( new Vector2() );
this._width = size.width;
this._height = size.height;
renderTarget = new WebGLRenderTarget( this._width * this._pixelRatio, this._height * this._pixelRatio, { type: HalfFloatType } );
renderTarget.texture.name = 'EffectComposer.rt1';
} else {
this._width = renderTarget.width;
this._height = renderTarget.height;
}
this.renderTarget1 = renderTarget;
this.renderTarget2 = renderTarget.clone();
this.renderTarget2.texture.name = 'EffectComposer.rt2';
// 创建两个WebGLRenderTarget
this.writeBuffer = this.renderTarget1;
this.readBuffer = this.renderTarget2;
this.renderToScreen = true;
// 存储的渲染通道数组
this.passes = [];
this.copyPass = new ShaderPass( CopyShader );
this.copyPass.material.blending = NoBlending;
this.clock = new Clock();
}
其构造函数主要是对其字段的初始化,比较重要的是writeBuffer、readBuffer两个RenderTarget对象和passes数组。writeBuffer、readBuffer是渲染通道的输入与输出,有些通道的输入输出都是readBuffer,有些输出是writeBuffer,这需要与Pass中的needsSwap结合使用。passes是存储通道的数组,渲染时会按顺序执行。
再来看一下其render方法,这是其渲染核心方法,会依次调用各pass的render方法进行渲染。
javascript
render( deltaTime ) {
// deltaTime value is in seconds
if ( deltaTime === undefined ) {
deltaTime = this.clock.getDelta();
}
// 保存渲染器当前的render target
const currentRenderTarget = this.renderer.getRenderTarget();
let maskActive = false;
// 依次调用各个Pass进行渲染
for ( let i = 0, il = this.passes.length; i < il; i ++ ) {
const pass = this.passes[ i ];
if ( pass.enabled === false ) continue;
pass.renderToScreen = ( this.renderToScreen && this.isLastEnabledPass( i ) );
// 调用pass的render方法进行渲染
pass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime, maskActive );
// 是否需要交换writeBuffer、readBuffer
// 一些pass的输出是writeBuffer,需要交换
// 输出是readBuffer的则不需要交换
if ( pass.needsSwap ) {
// 特殊逻辑,模版测试相关
if ( maskActive ) {
const context = this.renderer.getContext();
const stencil = this.renderer.state.buffers.stencil;
//context.stencilFunc( context.NOTEQUAL, 1, 0xffffffff );
stencil.setFunc( context.NOTEQUAL, 1, 0xffffffff );
this.copyPass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime );
//context.stencilFunc( context.EQUAL, 1, 0xffffffff );
stencil.setFunc( context.EQUAL, 1, 0xffffffff );
}
// 交换writeBuffer、readBuffer
this.swapBuffers();
}
if ( MaskPass !== undefined ) {
if ( pass instanceof MaskPass ) {
maskActive = true;
} else if ( pass instanceof ClearMaskPass ) {
maskActive = false;
}
}
}
// 恢复render的默认render target
this.renderer.setRenderTarget( currentRenderTarget );
}
看完EffectComposer的源码是不是觉得后处理就是依次调用各pass,每个pass输入输出都是WebGLRenderTarget。这就是后处理的全局流程图景。
常用的通道
学完后处理的整体流程之后就可以关注具体的Pass了。Three.js本身提供了不少Pass,这些Pass代码在postprocessing文件夹下。

常用的Pass有:
- RenderPass:用于将场景渲染到后处理链中,是大多数后处理流程的起点;
- ShaderPass: 可以传入自定义着色器,如高斯模糊、色调调节等;
- BloomPass/UnrealBloomPass:泛光效果;
- SMAAPass / SSAAPass / FXAAPass:抗锯齿类Pass;
- SSAOPass:环境光遮蔽,增强阴影与立体感;
- OutlinePass:物体边缘高亮;
- OutputPass:输出后处理结果到屏幕,可作为后处理链条的最后一个Pass;
还有很多的Pass,不一一介绍了。使用一个Pass之前最好看一下其源码,理解其作用。这里已OutputPass为例,看看这个Pass如何进行渲染的。
javascript
class OutputPass extends Pass {
constructor() {
super();
//
const shader = OutputShader;
this.uniforms = UniformsUtils.clone( shader.uniforms );
// 定义材质
this.material = new RawShaderMaterial( {
name: shader.name,
uniforms: this.uniforms,
vertexShader: shader.vertexShader,
fragmentShader: shader.fragmentShader
} );
// 定义了一个可覆盖整个视口的大三角形
this.fsQuad = new FullScreenQuad( this.material );
// internal cache
this._outputColorSpace = null;
this._toneMapping = null;
}
render( renderer, writeBuffer, readBuffer/*, deltaTime, maskActive */ ) {
// 获取传过来纹理,设置到材料上
this.uniforms[ 'tDiffuse' ].value = readBuffer.texture;
// 设置色调调节的曝光度
this.uniforms[ 'toneMappingExposure' ].value = renderer.toneMappingExposure;
// rebuild defines if required
// 设置颜色空间与色调调节,这些参数最终会传给Shader
if ( this._outputColorSpace !== renderer.outputColorSpace || this._toneMapping !== renderer.toneMapping ) {
this._outputColorSpace = renderer.outputColorSpace;
this._toneMapping = renderer.toneMapping;
this.material.defines = {};
if ( ColorManagement.getTransfer( this._outputColorSpace ) === SRGBTransfer ) this.material.defines.SRGB_TRANSFER = '';
if ( this._toneMapping === LinearToneMapping ) this.material.defines.LINEAR_TONE_MAPPING = '';
else if ( this._toneMapping === ReinhardToneMapping ) this.material.defines.REINHARD_TONE_MAPPING = '';
else if ( this._toneMapping === CineonToneMapping ) this.material.defines.CINEON_TONE_MAPPING = '';
else if ( this._toneMapping === ACESFilmicToneMapping ) this.material.defines.ACES_FILMIC_TONE_MAPPING = '';
else if ( this._toneMapping === AgXToneMapping ) this.material.defines.AGX_TONE_MAPPING = '';
else if ( this._toneMapping === NeutralToneMapping ) this.material.defines.NEUTRAL_TONE_MAPPING = '';
this.material.needsUpdate = true;
}
// 一般情况下这里为True
if ( this.renderToScreen === true ) {
renderer.setRenderTarget( null );
this.fsQuad.render( renderer );
} else {
renderer.setRenderTarget( writeBuffer );
if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
this.fsQuad.render( renderer );
}
}
}
从代码可看出,这个Pass工作原理是获取输入的纹理,设置颜色空间与色调调节,将贴图贴到一个大的三角形上渲染输出。这个Pass的shader也很值得研究,包含了颜色空间变换、色调调节的巨量知识。
总结
- 后处理是建立在渲染到纹理基础之上,依次对WebGLRenderTarget对象进行再次加工
- 每个Pass的作用算法各不相同,使用前最好研究其内部实现才能得心应手