游戏后期特效#2:相交高亮(扫描效果)

作者:音速键盘猫
2017-05-13
15 13 3

编者按

本文已向作者 @音速键盘猫 授权转载,原载于知乎,如需转载请务必联系原作者。

介绍

大萌喵现在还只是学生党一枚, 对图形学和渲染技术的理解依然比较肤浅, 如果文章中有不明确甚至出错的地方, 烦请前辈们斧正啦~

OK, 我们进入Warming Up环节。

相交高亮(Intersection Highlight)是个啥

相交高亮, 是一种附加在Mesh上的着色器特效, 其功能是将所有其他穿过该Mesh表面的截面轮廓绘制出来, 产生一种类似于扫描一样的效果。 多用于科幻类游戏中。

1

在这里大萌喵要检讨下 ... 这种相交高亮的特效出现的频次真心不低, 我能确切想起来的实际应用游戏便有杀戮地带系列, 质量效应系列, 泰坦陨落系列死亡空间系列. 不过大萌喵在youtube上找了好几圈也没有找到一个包含了这个效果的视频 ... 所以说只能贴上这张图啦.。(喵, 下次玩游戏要边玩边截图了哈哈哈哈)

所以说我们要干啥?

根据摄像机的CameraDepthTexture(深度纹理? 不知道这么翻译对不对)绘制相交区域的高亮颜色。

想看懂这篇文章, 我得知道啥?

对着色器的混合模式, 深度测试, 点元着色器和片元着色器有一定了解. 写作过程中我也会贴上一些可供查阅的资料。

我顺带着也打算讲解下一个坐标转换的原理, 如果想要看懂的话需要一定的线性代数基础。

看完了这篇文章, 我能得到啥?

你会知道一种优雅地使用DepthBuffer的方法。

以及, 说好的源代码。

大萌喵.不正常模式.SetStatus(false);

大萌喵.SetFace (Face.严肃脸);

相交高亮着色器工作原理

获取当前摄像机渲染的场景的DepthBuffer, 在渲染当前模型的时候判断每一个经过坐标变换的片元的世界坐标Z是否和DepthBuffer的对应点深度足够接近. 如果足够接近, 则将其渲染成另一种颜色.

首先, 假设我们什么都不知道

2
还是那个熟悉的水壶, 只不过加上了一台优雅的Macbook(不过为毛那个桌面长得有点像OpenSUSE)和一个凌空飞舞放荡不羁的键盘。当然了这不关键, 关键的是后面的那个黄色的正方体

我们要实现的就是那个正方体的材质. 从图中我们清楚的看到, 水壶, 电脑和键盘在正方体外面的部分是非常正常的, 在正方体内部的部分蒙上了一层黄色。 但是和正方体的相交截面的外轮廓被绘制成了蓝颜色。

到此, 我们能够推断出来的事实有:

  1. Blend Mode为: Blend SrcAlpha OneMinusSrcAlpha 原因很简单, 因为我们能够通过正方体看到其后面的物体, 这说明正方体本身的颜色和原本的ColorBuffer的Alpha值被"平分秋色"后进行了混合。 依然看不懂的童鞋请参见Unity官方文档.
  2. RenderQueue为Transparent 很明显, 我们当然是希望这个正方体在Geometry后渲染出来, 这样才能透过它看到优先渲染的Opaque Materials. 关于Render Order的详细描述可以看Unity官方文档
  3. 正因为我们的正方体是被后渲染出来的, 所以我们可以通过当前的ColorBuffer或者是DepthBuffer等资源来以某种方式处理相交截面。
  4. 但是不管截面到底是怎么被处理出来的, 我们必须得知道屏幕上某点的世界坐标相对于正方体某个片元的世界坐标的相对关系

很明显, 我们要做的就是优雅地解决第四个问题。

如何优雅地比较坐标

可以通过DepthBuffer, 摄像机Near Clip Plane, Far Clip Plane, Field of View来计算出屏幕上每一个点的世界坐标, 但是传统的在片元着色器中计算世界坐标的方式是处理后依次乘以世界-视图矩阵的逆, 效率堪忧。 就算利用点元着色器预先计算视椎体射线, 效率有了些许提升, 也远远达不到"优雅"的水准。

(PS: 我会在后面的文章中详细介绍Global Fog后期处理特效, 其中会对在片元着色器中通过DepthBuffer计算世界坐标的方法展开讨论。 )

说了这么多, 我们发现直接求世界坐标这种套路最直接, 最好理解, 但似乎并不太可取. 那么我们就要思考一个问题: 我们真的必须得知道具体的世界坐标嘛?

通过观察上面的那张图, 我们发现为了确定如何渲染并混合颜色, 我们只需要知道相对于摄像机来讲, 正方体的片元和原本场景中对应位置的像素谁离得更远就行了。 也就是说, 我们只需要知道两个三维向量的长度, 也就是两个实数, 而并不需要知道这两个三维向量的xyz都分别是什么。

所以说, 求世界坐标的话有点儿杀鸡用牛刀了。

如何获取一个片元所在屏幕位置的DepthBuffer

如果你不知道DepthBuffer, 或者是Unity的CameraDepthTexture, 那么强烈建议你谷歌下, 要不然接下来的东东就都GG了。

3

我们是如何知道一个片元的投影坐标的呢? 恐怕下面这段代码你都看烂了:

o.pos = mul ( UNITY_MATRIX_MVP, v.vertex );

我甚至不用说出o和v的变量声明以及这段代码出自何处, 你就知道我在说什么了。 (语气颇像 ... 额, 专栏还是要办下去的, 打住)

所以说我们要怎么通过世界坐标来将其xy映射到[0, 1]区间呢? 毕竟只有这样我们才能采样DepthBuffer啊! 不用担心, Unity都替我们做好了: 在UnityCG.cginc中, 有这么个函数:

inline float4 ComputeNonStereoScreenPos(float4 pos) {    float4 o = pos * 0.5f;
    #if defined(UNITY_HALF_TEXEL_OFFSET)
        o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w * _ScreenParams.zw;
    #else
        o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;#endif    o.zw = pos.zw;
    return o;
}

当然了并不是要调用它, 而是要使用ComputeScreenPos函数:

inline float4 ComputeScreenPos (float4 pos) {
    float4 o = ComputeNonStereoScreenPos(pos);
    #ifdef UNITY_SINGLE_PASS_STEREO
        o.xy = TransformStereoScreenSpaceTex(o.xy, pos.w);
    #endif
    return o;
}

呐, 带着编译器指令原封不动地搬过来, 大家看起来肯定有点方. 其实大多数情况下, ComputeScreenPos函数可以重写成以下形式:

inline float4 ComputeScreenPos (float4 pos){
    float4 o = pos * 0.5;
    o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;
    o.zw = pos.zw;
    return o;
}

传入Clip Space坐标pos, 最终输出xy在[0, 1]之间, z值为Camera Space深度的结果。

不过 ... 这个函数什么鬼 ... 拿到了Clip Space坐标先乘个0.5 ... 然后 ... 还要加上w分量的二分之一? 

4

大萌喵接下来打算稍微介绍下这个函数的原理. 如果你之前对坐标转换不是很了解, 大萌喵提供了几个链接, 可供参考:

如果你觉得Math = Mental Abuse To Human(对人类的精神侮辱), 那暂时略过也无所谓。 ComputeScreenPos当成黑盒子使用也没什么问题

如无需要可略过

首先上一张图:

3J

在Unity中, mul ( UNITY_MATRIX_MVP, v.vertex )和UnityObjectToClipPos(float4 ( v.vertex.xyz, 1.0 ) )干的差不多都是一回事儿, 就是将模型坐标转换到摄像机的Homogeneous Clip Space. 详情参加官方文档。

但是, 一般渲染管线不会立刻将Clip后的坐标标准化(也就是除以w分量. 不知道w分量代表什么的童鞋 ... 请先补课), 而是在点元着色函数结束以后将其标准化. 这个地方有点坑.

(原文摘录如下: Once all the vertices are transformed to clip space a final operation called perspective division is performed where we divide the x, y and z components of the position vectors by the vector's homogeneous w component; perspective division is what transforms the 4D clip space coordinates to 3D normalized device coordinates. This step is performed automatically at the end of each vertex shader run.)

所以, 可以认为我们现在得到的是已经经过Clipping, 但是还没有标准化的投影坐标. 我们的目的是要将这个坐标转化为xy在[0, 1]之间, 而z反映深度的屏幕坐标.

既然是[0, 1]之间, 那么我们自然就不用向上图一样乘以ViewPort宽高了. 同时要注意ViewPort坐标原点的问题: Unity中是左下角, 而上图采用的是左上角. 所以具体到我们的情况下y和x的处理方式应该是相同的.

为了将[-1, 1]映射到[0, 1]上, 将原坐标加1然后除2是显而易见的. 但是要注意我们的x和y都比人家多乘着一个w分量. 因为运算到这个时候我们依然在点元着色器函数中, 因此最终的标准化过程还没有执行.

所以, 我们就得到了下面这段代码(其实也是上面那段)

float4 o = pos * 0.5;
o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;

如果没想清楚是怎么转过这个弯来的 ... 对比上图倒数第二个框框和上面的文字就OK了.

千呼万唤始出来的点元着色器函数

v2f vert ( appdata_base v ){
    v2f o;
    o.pos = UnityObjectToClipPos ( v.vertex );
    o.projPos = ComputeScreenPos ( o.pos );
    return o;
}

(如果我不是手贱翻了下ComputeScreenPos的源代码, 也就没这么多麻烦事儿了哈哈哈)

实际上非常简单的片元着色器函数

在片元着色器中, 我们只需要提取出对应屏幕位置的深度信息, 然后和点元着色器的输出深度信息作比较, 根据相差结果进行插值即可.

fixed4 frag ( v2f i ) : SV_TARGET{
    float4 finalColor = _MainColor;
    float sceneZ = LinearEyeDepth (tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(i.projPos)));
    float partZ = i.projPos.z;
    float diff = min ( (abs(sceneZ - partZ)) / _Threshold, 1);
    finalColor = lerp(_HighlightColor, _MainColor, diff);
    return finalColor;
}

不知道那个插值是怎么回事儿的童鞋, 请动用C语言的思考模式, 写个if出来, 然后想办法消除掉这个if.

虽然话说回来就算这有个if也不算动态分支, 对性能的影响不太大 ... 但还是养成良好的习惯吧.

最终成果(其实一般再加上个Texture糊到Quad上实现扫描的特效)

5

后记

其实这个特效的原理真心一点也不复杂, 只是用到了DepthTexture来获取屏幕中每个像素的深度信息来进行比对以决定模型最终的颜色. 但是UnityCG.cginc里面的ComputeScreenPos函数那个奇怪的外观引发了我极大的好奇心.

FIN

大萌喵是个学生党, 非常热切地希望能和诸位前辈们交流! 如果文章中存在任何疏漏, 不足, 或错误之处, 希望您能批评指正! 谢谢!

Preview: 大萌喵最近对DepthTexture有点着迷呀, 下几次打算讲讲Global Fog, Volumetric Light Scattering和Edge Detection. 不过中间也会夹杂一些小的好玩儿的着色器效果 ~

近期点赞的会员

 分享这篇文章

音速键盘猫 

alphamistral.github.io 

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. eastecho 2017-05-14

    希望能多看到这样有价值的技术文章

  2. Kinglarfy 2017-05-14

    顶 很有意义

  3. AlexMercerX 2018-04-14 新浪微博会员

    感谢分享 回家动手试一试

您需要登录或者注册后才能发表评论

登录/注册