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的作用算法各不相同,使用前最好研究其内部实现才能得心应手
相关推荐
夕水15 分钟前
这个提升效率宝藏级工具一定要收藏使用
前端·javascript·trae
会飞的鱼先生29 分钟前
vue3 内置组件KeepAlive的使用
前端·javascript·vue.js
斯~内克43 分钟前
前端浏览器窗口交互完全指南:从基础操作到高级控制
前端
Mike_jia1 小时前
Memos:知识工作者的理想开源笔记系统
前端
前端大白话1 小时前
前端崩溃瞬间救星!10 个 JavaScript 实战技巧大揭秘
前端·javascript
loveoobaby1 小时前
Shadertoy着色器移植到Three.js经验总结
前端
蓝易云1 小时前
在Linux、CentOS7中设置shell脚本开机自启动服务
前端·后端·centos
浩龙不eMo1 小时前
前端获取环境变量方式区分(Vite)
前端·vite
土豆骑士2 小时前
monorepo 实战练习
前端
土豆骑士2 小时前
monorepo最佳实践
前端