相交高亮(扫描效果)

多用于科幻游戏中的Intersection Highlight特效

作者 Jingping Yu 日期 2016-11-27

本文同时也发表在我的知乎专栏上😄. 不过当时写作的风格实在不适合技术文, 因此在个人博客中的这篇文章经过了一些修改.

作者:音速键盘猫

链接:https://zhuanlan.zhihu.com/p/23971284?refer=MeowShader

来源:知乎

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

相交高亮(Intersection Highlight)是什么

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

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

相交高亮着色器工作原理

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

猜一猜实现的方法

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

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

  • 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

我们是如何知道一个片元的投影坐标的呢?

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

那么我们要怎么通过世界坐标来将其xy分量映射到[0, 1]区间呢? 毕竟只有这样我们才能采样DepthBuffer啊! 在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;
}

多数情况下上述代码也可以直接写成以下形式:

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;
}

看起来有点复杂 ... 先乘个0.5然后再加上w分量的二分之一 ... ?

"乘以0.5然后加上w分量的二分之一"到底是什么

首先上个图

在Unity中, mul ( UNITY_MATRIX_MVP, v.vertex )和UnityObjectToClipPos(float4 ( v.vertex.xyz, 1.0 ) )干的差不多都是一回事儿, 就是将模型坐标转换到摄像机的Homogeneous Clip Space. 详情参见官方文档(其实我个人更加推荐用UnityObjectToClipPos来代替传统的乘以MVP矩阵. 尤其是涉及到用GPU Generate Object的时候, 直接乘以MVP矩阵往往得到的是错误的结果).

但是, 一般渲染管线不会立刻将Clip后的坐标标准化(也就是除以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;

点元着色器

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

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

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

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;
}

最终效果

后记

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