three.js后处理原理及源码分析

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也很值得研究,包含了颜色空间变换、色调调节的巨量知识。

总结

  1. 后处理是建立在渲染到纹理基础之上,依次对WebGLRenderTarget对象进行再次加工
  2. 每个Pass的作用算法各不相同,使用前最好研究其内部实现才能得心应手
相关推荐
To_OC19 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
vipbic20 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy20 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志20 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble21 小时前
c端系统,其实更像一个信息展示平台
前端
李姆斯1 天前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜1 天前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司1 天前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
kaiking_g1 天前
vue项目中,静态导入 vs 动态导入 详解
前端·javascript·vue.js
江华森1 天前
Web 开发基础:HTTP 与 REST
前端·网络协议·http