注:本文既有经验上的总结,又有实现方式上的讲解。既有流程上的描述,又有代码细节上的剖析。
全文字数5000+,看的时候最好带上笔和纸。
零、你的序
感谢大家的厚爱,KylinsPostEffects
上架Cocos Store
两星期销量破百。
现在应广大用户的要求,写一篇关于这个3D后期效果框架的源码剖析文章,以供大家学习和文档查阅之用。
说实话,篇文章重写了好几稿,但左思右想觉得不妥,所以那几稿都没有发出来。
一开始给它的定位是源码剖析
。所谓源码剖析
的意思就是说,只需要写清楚一些关键的源码是什么意思,需要注意的点就行了。
如果只讲源码,那和附带的文档没有区别,并不能为一些想要了解这方面的朋友带来帮助。
并且,那种很干的文章阅读起来点都不丝滑
,也不是麒麟子的风格。
因此,本文将从故事说起,逐步涵盖需要讲解的内容。
- 1、它的故事
- 2、为何选择这样的实现方案
- 3、此方案的优缺点
- 4、方案中的若干技术细节
- 5、后期规划与可能提升的方向
同时,这篇文章在阅读后,还能获得一些可以高度复用到其它场合的知识点:
- 1、顶点
坐标系变换
流程 - 2、使用
Shader
渲染屏幕空间的Quad
- 3、使用
代码
创建Mesh
- 4、节点的
Layer
与摄像机的CullingMask
- 5、使用代码创建、管理
RenderTexture
没错,整了这么长的序,就是怕你跑了
昨天也写了一个序,但光是那个序就有5000+字,只好作为单独的文章发布了,名字叫
《麒麟子带你快速进入Cocos Creator的3D世界》
,有兴趣的朋友可以从公众号:麒麟子随笔
中查阅。
一、它的故事
其实这个后期效果框架,我也等了好久。
一方面是等Cocos Creator 的3D渲染管线自定义流程更轻便,另一方面是等社区中有大佬来写。
可能让大家想不到的是,这个后期处理框架第一版的完成时间是在2020年10月10日,当时做3D项目还是用的Cocos Creator 3D 1.2,是为一个3D MMORPG
项目写的。
时隔一年之多,Cocos Creator早已今非昔比,版本号已经v3.3.2了,马上就出v3.4。并且它的3D渲染
、原生性能
等能力早已甩掉一年前的版本好几条街,然而还是没有人提交后期效果解决方案。
基于社区童鞋们的强烈需求,麒麟子整理了之前写的这个方案,升级到了v3.3.2,并做了易用性优化,最终形成了KylinsPostEffects
。
麒麟子觉得,针对目前Cocos Store上关于这方面的作品不多的情况。真正的原因或许并不是用的人少,大概率可能是,有非常多的3D项目成果,但是它们并没有被公开出来,就像这个一年多前就已经完成并用于3D MMO项目的后期效果处理框架。
所以麒麟子做了这个示范,希望可以带动大家的分享氛围。
给不熟悉的朋友简单介绍一下
KylinsPostEffects
KylinsPostEffects
是一个不污染场景节点树
、不依赖Prefab
、不修改引擎渲染管线
、使用简单
、极易扩展
的3D后期效果处理方案。内置了Glow
、Bloom
、空间扰动
、LUT
等效果。
为了照顾广大C友的支持,双11那天,麒麟子所有作品(其实就两个)会参加Cocos Store
的促销活动,要买的可以提前放入购物车了。
麒麟子为了给大家带来更多Cocos相关的3D内容,深夜伏案写作。又为了让大家阅读内容时更加顺畅,主动关闭了文中和文末的广告。这样的麒麟子,偶尔打一个广告,想必不会被吐槽吧?
二、为何选择这样的实现方案
要实现后期效果的最佳方法,当然是自定义引擎的渲染管线啦
但是
有时候为了体现自己很厉害,都会先说一个但是,这是基本操作。
但是,大家都知道,一但改动引擎的渲染管线,那么引擎新版本升级的时候,将会带来合并版本的工作量。严重的情况下,会导致已修改过的管线需要全部重写,底层重写就需要对所有设备做兼容性测试,代价是巨大的。所以,一般情况下不建议改动管线。
并且,如果采用Hack的方式修改引擎渲染管线,将导致小游戏平台不能使用引擎分离
功能。 原生平台由于本身就是整个引擎打包,专注于原生的项目可以不理。
但是,又来一个但是。
采用Cocos Creator引擎的项目,大部分都是原生+小游戏多端发布的,
引擎分离
这个特性从目前来看,大部分CP还是蛮关注的。
基于兼容性、稳定性、包体和工作量的考虑,最终选择了这个基于多个摄像机的后期效果解决方案
三、此方案的优缺点
优缺点的参照对象为
自定义管线
的解决方案
优点
- 1、无需改动引擎渲染管线
- 2、基于逻辑层制作,兼容
原生
、小游戏
、H5
、Cosos Runtime
- 3、基于多摄像机渲染,理解成本低,便于入手
缺点
- 1、每一个Pass都需要一个摄像机,管理不便
- 2、每一个Pass都需要走完整的渲染管线流程,多出许多不必要的逻辑开销
- 3、需要消耗场景的Layer,这会导致能够同时激活的后期效果是有限的
四、方案中的若干技术细节
KylinsPostEffects
是一个采用多个摄像机实现的后期效果解决方案。
麒麟子对解决方案
的理解是:通过对需求和问题的分析,得出满足需求和解决问题的方法,并根据方法形成一种可实施的方案。
由于代码大家都拿到手了,过多的讲解代码细节意义不大。今天我们就从为什么要这样设计?是为了解决什么样的问题?
但不修改管线我们如何做到呢?在这里,麒麟子相到了通过渲染一个撑满屏幕的Qaud
来实现。
注:Cocos Creator v3.4之后,建议采用自定义管线来做后期效果,本文描述的是多摄像机渲染Quad的方法。
这两方面来讲解。
4.1、效果初始化与使用
//获取主摄像机
let mainCamera = find('Main Camera').getComponent(Camera);
mainCamera.visibility &= ~Layer3D.UI_3D.mask;
//设置需要的后效(由于每一个后效都会占用若干个RenderTexture,消耗显存,所以如果项目中有不需要的效果,则建议不要出现在列表中
let efxList = [
PEFX_GrapScene, //抓取主摄像机的内容,供所有后效使用 要确保它是第一个
PEFX_Glow,
PEFX_Distortion,
PEFX_Bloom,
PEFX_Lut,
PEFX_Final //最后呈现到屏幕上 要确保它是最后一个
];
PostEFXMgr.inst.setup(efxList, mainCamera, () => {
//在这里对效果进行开关和参数调节。
//开启Glow效果
PostEFXMgr.inst.setEfxEnable(PEFX_Glow.NAME,true);
let glow = PostEFXMgr.inst.getPEFX(PEFX_Glow.NAME) as PEFX_Glow;
//设置Glow效果参数
//设置模糊范围 值越大,GLOW的溢出边缘越大, 建议不要大于6.0
glow.blurRadius = 4.5;
//设置混合强度 值越大越亮
glow.blendIntensity = 1.5;
});
上面的代码,是位于TestApp.ts
文件,start
函数中的初始化代码,效果使用的代码由于太多,这里只贴了glow
作为示例。
问题:为什么setup
函数要传递一个main Camera?
因为不同的项目,需求不同。有的项目只需要一个一个摄像机,有的项目需要多个,或者主摄像机的名字不叫
Main Camera
。*
这里传递的mainCamera
就是用于最后呈现效果的Camera
,以方便各类项目在不修改源码的情况下直接使用本方案。
问题:mainCamera.visibility &= ~Layer3D.UI_3D.mask的作用
答:这是一个渲染冲突引起的,我们发现,如果主摄像机渲染了UI_3D这个
Layer
的话,就会引起渲染异常。
由于目前版本(Cocos Creator v3.3.2)并不支持3D空间中的UI,所以最快的解决办法就是让主摄像机去掉UI_3D这个Layer
。
问题:为什么使用代码需要在setup的回调函数里调用?
1、这是因为所有后期效果需要用到的资源会先进行
预加载
,为了确保所有调用是在资源加载完成后执行,所以设计了这个完成回调
。
2、每一个PEFX
都会继承自PEFX_Base
并实现getRes
函数,这函数返回的是这个PEFX
依赖的图片和材质等素材。
3、PEFXMgr
的setup
函数会收集完所有依赖的素材并进行加载,加载完成后触发这个回调。
问题:efxList中的顺序影响效果吗
影响的
- 1、由于
PEFX_GrapScene
和PEFX_Final
具有特殊性,因此必须保证PEFX_GrapScene
是第一个,PEFX_Final
是最后一个。 - 2、
PEFX_Lut
由于是校色功能,因此推荐放到PEFX_Final
之前。 - 3、
PEFX_Bloom
由于是全屏泛光,因此推荐放到PEFX_Lut
之前。 - 4、
PEFX_Glow
由于是单个物体发光,因此推荐放到PEFX_Bloom
之前。 - 5、
PEFX_Distortion
由于是单个物体导致的扰动效果,同时对物体的自发光应该也要产生影响才对,因此推荐放到PEFX_Glow
之后。
大家在做顺序编排的时候,要根据效果的作用进行放置,才能达到最佳效果。全局的靠后,针对单个物体的靠前。
4.2、整个方案的渲染流程是什么?
step 1
:将主摄像机渲染的内容输出到一张RenderTexture
(为了方便描述,我们给它取一个名字full_scene_rt
)step 2
:对full_scene_rt
效果处理P1step 3
:对full_scene_rt
效果处理P2step 4
:对full_scene_rt
效果处理…step 5
:对full_scene_rt
效果处理Pnstep 6
:将处理过的结果显示到屏幕上
问题:PEFX_GrapScene
和PEFX_Final
的作用是什么?
PEFX_GrapScene
就是负责将主摄像机渲染的3D场景内容抓取到RenderTexture
上的。
虽然它不做任何的后期处理,但为了保持架构的一致性,也把它抽象为了一个PEFX
,方便管理。
需要承认一个错误是,PEFX_Gra
p
Scene应该为PEFX_Grab
Scene,我打错单词了。但本文需要和源码保持一致,暂时就不动它。
PEFX_Final
就是负责将最终的那张处理过的RenderTexture
渲染到屏幕上。
因此,我们必须保证PEFX_GrapScene
是第一个,PEFX_Final
是最后一个。
问题:PEFX_Pass是什么意思?
PEFX_Pass
就是执行的一次后期处理Shader的最小单位。
每一个PEFX_Pass
有一个单独的Camera
,一个单独的Quad
。
为了让Camera
只渲染对应的Qaud
,我们就需要为这个Quad
分配一个独立的Layer
,且将此Camera
的Culling Mask
设置为这个Layer
值。
一个PEFX_Pass
大概包含以下内容:
PEFX_Pass:{
Camera, //摄像机
Quad, //用于渲染到屏幕大小的内容
Material, //材质文件
Effect, //对应的Shader文件
Uniforms, //对应的参数
Textures, //对应的纹理
}
问题:PEFX是什么意思?
PEFX
是Post Effect
的缩写。
一个PEFX
会包含一个或多个PEFX_Pass
。
比如PEFX_Lut
它只有一个PEFX_Pass
,而PEFX_Bllom
则有4个PEFX_Pass
。这个可以通过查看对应的源码文件,数一下它的setupPasses
函数中有多少个addPass
。
问题:新增一个PEFX需要做哪些工作
我们PEFX_Glow
为例。
- 1、新建一个TS脚本文件,取名为
PEFX_Glow.ts
。 - 2、编写一个类
PEFX_Glow
并继承自PEFX_Base。 - 3、新建
Material
相关文件,post-material-glow****
。 - 4、新建
Effect
相关文件,post-effect-glow*****
。 - 5、完成PEFX_Glow类的
getRes
和setPasses
函数。 - 6、由于只有特殊标记的对象才会进行GLOW渲染,因此需要在
Layer3D.ts
中新增一个GLOW
layer,以用作特殊用途。 - 7、编写
GlowObject.ts
,用来方便用户标记需要Glow的对象。 - 8、将
PEFX_Glow
添加到setup的efxList表的适合位置。
由此,新增一个PEFX
的工作就做完了。
4.3、实现过程中的一些技术要点
要点1:如何确保渲染的内容能撑满屏幕
由于是采用摄像机的方式渲染内容,那我们需要做的就是确保我们渲染的模型刚好与屏幕一样大。
但用户机型的分辨率是完全不一样的,难道我们需要针对不同机型分辨率动态生成对应的四边形吗?
也不是不可以的,但在这里,我们还有更简单的方式:
利用3D图形管线中
NDC Space
与设备分辨率
的无关性
。
如上图所示,对图形管线熟悉的同学应该明白,当我们的顶点经过Projection Transformation
(投影变换)之后,会进入Clipping Space
(裁剪坐标系)。
Clipping Space
中的顶点经过Perspective Divison
(透视除法)后会进入NDC Space
(标准化设备坐标系)。
而NDC Space
是一个 x,y,z
的值域为(-1.0,1.0)
的边长为2.0
的立方体。
透视除法
与NDC
:顶点坐标 P(x,y,z,1.0)
经过MVP变换
之后,会变成Clipping Space
中的点Pc(x',y',z',w)
。 点Pc
,除以Pc.w
后,就得到了NDC
中的点 Pndc(x'/w,y'/w,z'/w,1.0)
。
NPC Space与设备分辨率的无关性
是指NPC Space
中的点经过Viewport Mapping
(视口映射)后,才会转到屏幕坐标系。 而视口映射
时,渲染目标
的宽
和高
才会参与运算。如果一个顶点在NDC Space
中的x或者y的值为-1或者1,则表示它渲染出来的位置是在屏幕边缘。
基于上面的背景知识,我们就知道,不管我们是通过代码手工构建Mesh
,还是通过美术建模Mesh
,又或者通过Vertex Shader
特殊处理Mesh
,最终我们要的是让它撑满整个屏幕。
在介绍这个高端操作
之前,我们先看一下对应的Vertex Shader
代码:
vec4 vert () {
vec4 position;
CCVertInput(position);
v_uv = a_texCoord * tilingOffset.xy + tilingOffset.zw;
//flip v_uv.y
v_uv.y = 1.0 - v_uv.y;
//z=0,w=1
vec4 outPos = vec4(sign(position.x),sign(position.y),0,1);
return outPos;
}
你没看错,它确实就这么简单。
在vs中,使用了sign
函数来让一个值变成-1或者1。这是麒麟子10年前学会的骚操作。
不管你的模型是什么形状,只要其顶点满足以下4种情况:
- 1、有一个顶点的x小于0
- 2、有一个顶点的y小于0
- 3、有一个顶点的x小于0
- 4、有一个顶点的y小于0
它就可以渲染出基于屏幕对齐的QUAD
。
注意,我们将其z=0,w=1。 这就能使
QUAD
的Clipping Space
和NDC Space
中的顶点值是一样的。
而w为何要强行给1,通过前面的顶点变换流程讲解我们可以理解,w=1的时候,整个运算过程将会变得非常简便,不易出错。
最终我们可以得到一个非常简单的运算过程,能非常容易地使得结果符合预期。
要点2:如何用代码生成一个带Quad Mesh的结点
当走到这一步的时候,我们随便扔一个Sphere
或者Cube
,甚至是其它Mesh
给我们上面说的Shader
,它都可以渲染出我们想要的撑满屏幕的Quad
。但硬件运算能力能不浪费就不浪费,通过下面两个方案我们可以拿到一个Quad Mesh
。
- 1、新建
Quad
节点,通过代码访问使用 - 2、复制引擎
internal
中的Quad Mesh
到resources
目录采用resources.load
进行动态加载 - 3、用
utils.createMesh
构建一个Quad Mesh
前面两种方式,不需要特别讲解,基本上都会。我主要提一下第三种,因为创建Mesh
的接口不是那么直观。
private static _quadMesh: Mesh = null;
public static get quadMesh(): Mesh {
if (!this._quadMesh) {
let positions = [-1.0, 1.0, 0.0, -1.0, -1.0, 0.0, 1.0, -1.0, 0.0, 1.0, 1.0, 0.0];
let indices = [0, 1, 2, 0, 2, 3];
let uvs = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0];
this._quadMesh = utils.createMesh({ positions: positions, uvs: uvs, indices: indices });
}
return this._quadMesh;
}
如上面代码所示,我们构建了一个-1.0,1.0的四边形,并采用utils.createMesh
接口进行创建。
注:
utils.createMesh
会重新开辟VAO
,对显存
和性能
的影响蛮大,不适合
每帧调用。千,万,不,要
用来在update
中动态更新模型。
使用这个QuadMesh
的代码也非常简单,只需要将它赋值给MeshRenderer
即可
let quadNode = new Node();
pass.quad = quadNode.addComponent(MeshRenderer);
pass.quad.mesh = PEFXResources.quadMesh;
let mat = PEFXResources.getRes(materialFilePath);
pass.quad.setMaterial(mat, 0);
pass.node.addChild(quadNode);
要点3:RenderTextureUsage的作用
RenderTextureUsage
用来设置Camera
输出的目标纹理信息。
当一个PEFX_Pass
被添加RenderTextureUsage
组件时,表示它需要将结果输出到一张RenderTexture
上。
很显然,
PEFX_Final
是没有这个RenderTextureUsage
组件的。
我们来看看RenderTextureUsage
的属性:
-
1、
widthScale:number
,heightScale:number
:此RenderTexture
相对于设备分辨宽、高的缩放,假如设备分辨率为1280x640,widthScale
和heightScale
设置为0.5
就表示,它需要一张640x320大小的RenderTexture
-
2、
renderTextureUsage:string
:RenderTexture
的用途、名字。 假如赋值为full_scene_rt
,则我们可以通过RTMgr.inst.getRenderTexture('full_scene_rt')
获取到它。 -
3、
camera:Camera
:此RenderTexture
需要操作的目标摄像机,对于普通的PEFX
来说,操作的是属于自己的PEFX_Pass的摄像机,对于GrabScene
,或者深度图
等PEFX
则直接使用主摄像机,这个在addPass
的时候可进行设置。
要点4:RTAsSampler的作用
后期效果是对RenderTexture
的连续处理,因此RTAsSampler
的作用就是将为PEFX_Pass
配置需要的RenderTexure
。
我们来看看RenderTexture
的属性:
-
1、
model: MeshRenderer
:每一个PEFX_Pass
自己的那个Mesh Quad
。持有它的引用是为了方便我们对其材质参数进行设置。 -
2、
samplerMap: string[]
:用于配置需要的RenderTexture
和Shader
中Texture Sampler
映射关系。以
PEFX_Glow
最后一个Pass为例:['prev_rt:mainTexture','glow_rt2:bluredTexture']
它表示,glow shader
中的mainTexture
需要使用prev_rt
对应的那张RenderTexture
,glow shader
中的bluredTexture
需要使用glow_rt2
对应的这张Render Texture
。
注:
prev_rt
是一个特殊的标记,它表示需要取获取上一个PEFX的结果。这是由于,我们的效果会按需开启,我们无法知道上一个开启的是谁。通过prev_rt
这个特殊的名字,进行查找。源码请看:PostEFXMgr类中的getPrevRT函数
。
!!
特别注意!!
:一个Render Texture
不,能,同,时
作为纹理参数
和渲染目标
。
要点5:SyncWidthMainCamera的作用
由于此方案未修改管线,目前无法做到利用MRT
来做特效标记。
因此我们的Glow效果
和Distortion效果
只能将需要的处理对象额外渲染一次到对应的Render Texture
上。
而这个额外渲染一次用到的摄像机,则需要和主摄像机做同步。SyncWidthMainCamera
的作用就是自动同步摄像机相关的参数。
要点6:RenderTexture的大小
let dpr = view.getDevicePixelRatio();
let size = view.getFrameSize();
width = widthArg * size.width * dpr;
height = heightArg * size.height * dpr;
上面的代码来自RTMgr.ts,想强调的是,在做RenderTexture
分配的时候,getFrameSize
需要乘以getDevicePixelRatio
的值,才能保证效果清晰。在高分屏上,可能会分配出特别大的RenderTexture,造成显存开销大和像素填充率压力。
从实际测试来看,dpr >= 1.5
就可以满足需求。如果项目画面效果能接受,不妨加上let dpr = Math.min(view.getDevicePixelRatio(),1.5)
,以限制最大分辨率。
还有一个点就是,在窗口大小变化时,我们得响应resize
事件,并调用所有Render Texture
的reset
接口,重新设置宽高。
五、后期规划与可能提升的方向
-
Cocos Creator v3.4
即将发布,据说可以很方便地自定义后期效果管线,那样的话就可以通过管线来实现,减少多个Camera
的开销,以及对Layer
的依赖。
-
- 目前的效果无法在编辑器中预览,只能在运行时查看,有兴趣的朋友可以考虑改为编辑器中也能实时查看效果。
-
- 通过管线拿到深度图,实现更多有趣的效果,如
TAA
,DoF
等。
- 通过管线拿到深度图,实现更多有趣的效果,如
-
- 借助
MRT
,优化Glow
和Distortion
,减少特殊Pass的开销,RT可合用1张,正确的深度遮挡关系。
- 借助
六、总结
以上就是关于KylinsPostEffects
的故事、流程与源码剖析,相信看完这篇文章的小伙伴,就能够轻松的使用和修改它了。
如果还有疑惑的地方,请大家添加私信麒麟子。
关于诸如BLOOM
,GLOW
,DISTORTION
,LUT
等实现原理。麒麟子会在后面的文章中逐一和大家讲解。请大家关注麒麟子的自媒体账号即可。
长按二维码,即可关注麒麟子的公众号
关注
和转发
是对一个作者最高的认可。
各位的认可,又将会成为作者写作的动力
。