Cocos 3D 渲染分享:用实时反射 Shader 增强画面颜值
细心的小伙伴可能已经发现了,最近麒麟子关于 3D 图形渲染方面的文章输出比之前多了许多。
灵感来源于生活,却更有可能是来源于工作!
随着 Cocos Creator 3.x 的发布,,Cocos 的 3D 工具链越来越完善,越来越多开发者开始关注并使用 Cocos 制作 3D 项目,麒麟子每天会收到许多来自 开发者们咨询的 3D 项目相关问题。
在对这些项目进行需求交流和技术解决方案探讨时,也收获了不少有趣的灵魂、动人的故事、实用的方案。
独卷卷不如众卷卷,希望能分享出来,对有需要的人能有所帮助。
既能开箱即用,又能增长知识。
本文开头的图就是一个开发者让我帮忙处理的项目,他们的需求是跨平台 在线 3D 选车系统。
由于要在各个平台发布,对比各个技术栈后,最终选择了Cocos。
可是他们不熟悉 Cocos Creator,特来求助。
早期的在线选车,只需用几个全景图+热点拼出来一个可交互式程序即可。常见的效果如下图所示:
从上图中可以明显看到,左上角已经开始变形。
像这样简单的交互式程序显然已经不能满足人们日益增长的物质文化需求。
如今的在线选车已经需要模拟现实环境、VR 查看、甚至还能像赛车游戏一样体验一把了。
然而,他们一开始发我的效果是像这样的:
这产品要是放出去,怕是会让人误会 Cocos Creator 的渲染能力吧。
麒麟子觉得有必要拯救一下。
经过一番操作之后,效果如下所示:
此效果主要用到了实时反射原理,但由于引擎目前没有内置实时反射组件,因此麒麟子自己动手做了一个。
实时反射可以用在非常多的场合用于增强环境效果。
比如,下面截图中的斗罗大陆-武魂殿大厅,整个地板就是带反射的,如果去掉反射,整个场景将失色不少。
基于这个实时反射 Shader,麒麟子又搭建了一些其他有意思场景(文末可查看高清效果视频和工程源文件):
接下来,让我们从最基础的空间几何开始,一步步推导公式,并实现上面的效果。
麒麟小贴士: 推导过程非常简单,童叟无欺。
基本原理
在中学物理课程中我们学过,平面镜中的虚像是由光的反射光线形成的,看到的虚像与实际的物体关于镜面对称,且大小相等,左右相反。如下图所示:
结合 光路的可逆性 我们可以推导出:
如果将摄像机放到关于镜面对称的位置,移除镜子,再以等价的视线方向看物体。
此时所看到的画面内容会与镜中画面一致,如下图所示:
有了上面的基础知识,我们的问题就变成了:
1、求摄像机关于镜面对称的位置 2、求摄像机处于镜面后的视线方向
平面对称点求解
书上和网络上有许多关于镜面对称的解题思路,但都是基于代数推导过程,不能直观的理解,且运算复杂。
麒麟子今天以空间几何的方式给大家讲解:如何计算一个点关于任意平面的对称点。
麒麟小贴士: 空间几何相比代数更直观,但有两个基本要求:
1、讲解问题的时候,需要配图。
2、需要一定的空间想象力。
请大家先看上面这张图,点 Pm 是 点 P 关于平面 Plane(紫色线)的对称点,N 为平面的法向量。
接下来我们开始进行公式推导,大家一定要集中精力,不然一不留神推导过程就结束了,它真的简单到让你难以相信。
设平面法向量为 N,到原点的距离为 d。
设点 P 到平面的距离为 Dist。
将点 P 沿 N 的反方向移动 2 倍 Dist 的距离,即可到达点 Pm 的位置。
从而得到公式:Pm = P + 2 * Dist * (-N)
由平面点法式相关知识可得:Dist = P·N - d 。
代入公式可以得到: Pm = P + 2 * (P·N - d)* (-N) 。
整理过后,最终的公式为:
在项目中的 Mirror.ts 文件里,麒麟子写了一个 getMirrorPoint 函数,内容如下:
麒麟小贴士:
关于平面的表示法,请看麒麟子的文章《Cocos Shader 入门基础六:平面、双面材质与自定义裁剪面》。
也可以查阅 白玉无冰 等社区 KOL 们写的 3D 数学 相关文章。
通过 getMirrorPoint 函数,我们可以很容易求得一个点关于 3D 空间中任意平面的对称点。
平面对称向量求解
在三维空间中,每一个物体都有三个互相垂直的方向向量:
前方向量(Forward) 上方向量(Up) 右方向量(Right)
我们至少需要求得两个方向向量才能确定一个物体各个方向的旋转角度。
通常我们会选择 Forward 和 Up。
因为三个相向相互垂直,可以通过叉乘法则求得 Right 向量。
接下来,我们看看如何求得向量关于镜面对称的向量。
麒麟小贴士:
摄像机并不是关于镜面对称的,它只是旋转到镜面背后。
大家可以用一张A4纸和自己的手机做实验,会发现只有 Forward 和 Up 是关于镜面对称的,Right 刚好是镜面对称方向的反方向。
求方向向量的对称向量,可以借助上面的对称点公式。大家先看下面这张图:
我们以求 Forward 为例,可以采用下面的简单步骤:
在 Forward 上找到一个点 Pf 求得点 Pf 关于平面的对称点 Pfm 点 Pfm - Pm 即为所求( Pm 是 P 关于平面的对称点)
一切都是这么的直观、简单、易懂。
既然点有自己关于平面对称的简单公式,向量有没有直接的公式呢?如果能够找到,我们应该是可以省下不少计算的。
由于向量是一个只有大小和方向,没有位置的量。
我们可以发现,在不改变法向量的情况下将平面在三维空间中平移,Forward 关于平面的对称向量是不会变的。
既然这样,我们就把平面移动到坐标原点,P点也移动到坐标原点。此种情况下,P = (0,0,0) 且可以令 Pf = Forward, 代入公式可得:
Pfm - Pm = Pfm - P 而 Pm = P = (0,0,0) 可得:Pfm - Pm = Pfm
最终,我们依旧可以采用镜面对称公式进行求解:
只是,其参数为向量,且 d 为 0, 整理后如下:
麒麟小贴士:在代码中,我们不用为向量对称新写函数,只需要在调用 getMirrorPoint 函数的时候 d 传 0 即可。
麒麟子搭了一个方便检查公式是否正确的测试组合,效果如下:
镜面实现
镜面效果的实现,主要由两部分组成:
1、关于镜面翻转摄像机,并渲染到 RenderTexture 2、使用投影纹理映射技术,对 RenderTexure 进行采样
摄像机翻转
利用 getMirrorPoint 函数计算摄像机在镜像空间中的位置,如下图所示:
利用 getMirrorPoint 函数计算摄像机的 Forward 和 Up 向量的镜像向量,如下图所示:
细心的朋友应该发现了,麒麟子在这里没有使用 target.forward 和 target.up。这是因为 forward 和 up 的获取会生成临时的 Vec3 对象,而我们的摄像机翻转函数每帧都会执行,需要避免临时对象的产生。
最后,利用 Quat.fromViewUp 方法,求得最终的旋转四元数,并赋值给摄像机,如下图所示:
麒麟小贴士: 由于Cocos Creator 引擎默认的正方向为 **-Z (0,0,-1)**,我们的 forward 在使用 fromViewUp 时需要取反。
投影纹理映射
投影纹理映射是指,将经过投影变换后的顶点信息用于计算 UV,进行纹理采样。
最终看到的结果,是以观察方向看过去的贴面效果。镜面反射、ShadowMap、模型贴花等效果都是基于投影纹理映射技术呈现的。
要实现投影纹理映射只需要两步。
第一步:将投影过后的坐标信息从 vs 传到 fs,如下图所示:
v_screenPos 是我们定义的一个 vs out 变量。
第二步: 执行透视除法,并将 xy 的值规范化到 [0.0, 1.0] 区间,如下图所示:
经过了 MVP 变换的坐标,处于裁剪空间,再使用透视除法,可将此坐标变到 NDC 空间。
而 NDC 空间中 xy 的值在 [-1.0, 1.0] 区间,我们对其进行 *0.5 + 0.5 变换,可将 xy 的值规范化到 [0.0,1.0] 区间内。
加入菲涅尔现象
到这一步,我们已经实现了基于任意平面的实时镜面反射效果。
但现实生活中的物体,并不是像镜子一样,在任意角度都具备很强的反射。
当视线与平面垂直时,它的反射最弱;当斜视与平面无限接近平行时,它的反射最强。这个现象就叫菲涅尔现象。
菲涅尔现象是一系列光学反应结果,如果要考虑到所有因素是不太科学的,但我们可以使用下面这个近似的公式:
每个参数含义如下:
-V: 视线反方向 N: 平面法线方向 Ffactor: 视线因子 Rmax: 物体最大反射系数 Rmin: 物体最小反射系数 Pow(Ffactor,p): 指数函数,表示Ffactor 的 p 次方 Fresnel: 最终的菲涅尔因子
通过调节 Rmax,Rmin,p 的值,我们可以非常容易地模拟出自然界中各物体表面的菲涅尔效果。具体代码如下:
从下图可以明显看到,越近的地砖,反射是越弱的。
反射掩码图
但当我们仔细观察身边的物体,某些物体的表面,部分反射很强,部分反射很弱。总不至于用非常多细小的平面来拼接吧?
一种常用的技巧就是:反射掩码图(Reflection Mask Map)。
我们用一个地砖效果为例,下图中,左边为颜色贴图,右边为掩码图。掩码图中越亮的区域表示反射越强,越暗的区域,表示反射越弱。
实现反射掩码非常简单,只需要将掩码图乘以菲涅尔因子,从而达到控制最终反射率,如下所示:
仔细观察,相邻两块地砖的接缝处几乎没有反射,就是因为掩码图中对应位置的像素值为黑色。如下图所示:
扰动图
如果我们想要模拟出一些不太光滑的平面上的实时反射效果,又应该怎么做呢?
答案是:利用噪声图进行纹理采样干扰
噪声图几乎参与了所有非光滑表面效果的模拟场合,常见噪声图如下所示:
单通道噪声图:只有一个通道,或者各通道的RGB值相等。
多通道噪声图:RGB+A通道分别存了不同的值,一般用于同时要分开扰动多个目标的情况。
我们先简单的搭一个下面这样的场景,并创建一个实时反射的平面,效果如下:
在 Shader 中加入 NoiseMap 相关参数,并在反射贴图的 uv 采样时进行干扰,如下图所示:
reflNoiseScale: 控制噪声贴图的重复率 reflNoiseMove: 控制噪声贴图 u 和 v 方向的流动速度 reflNoiseStrengthen: 控制噪声干扰强度
调节到适合的参数后,我们可以实现下面这样的效果:
看起来像水面了,但它只是简单的反射+噪声扰动而已。
真正的水面渲染,至少还应该有水深、折射等效果。
麒麟子下一篇文章会在实时镜面反射的基础上,给大家实现一个写实风格的水面渲染,欢迎关注。
思考题: 如果我们要实现下图的 巴黎街头艺术装置-环形镜,又当如何是好呢?。
麒麟小贴士:当然可以使用这样的方法来拼装,但要注意性能问题。
一个折衷的方案是采用实时环境贴图实现,这样可以将场景渲染开销控制在六个面。
DEMO与源码
DEMO内容:
实现了第三人称主角摄像机控制器 实现了 W A S D 角色移动控制器 实现了任意平面反射组件 Mirror.ts 实现了支持 投影纹理映射、掩码、噪声扰动的着色器 effect-mirror-pbr.effect/effect-mirror.effect 一个城市风格的角色漫游场景 一个实时反射的水面场景 一个实时反射车展场景 一个模拟的巴黎街头艺术装置-环形镜场景
源码获取:
完整项目源码已上架 Cocos Store。
获取方式一: Cocos Store 搜索 kylins
获取方式二: 点击左下方【阅读原文】可直接跳转至下载页面。
版权说明: 文章中使用的车模是免费模型,但不包含在本 DEMO 中,有需要的朋友可以在 Cocos Store 中搜索 车 即可找到。
视频有彩蛋,一定要看哦!