起因:相机贴近模型时可能会出现遮挡或者钻入模型中的问题
对于3D游戏来说,摄像机在运行过程中可能会出现钻入模型内部的情况,可能会使玩家看到模型内部的网格,或是双面贴图等,严重时会让玩家看到模型的内部结构,如身体器官(眼球,牙齿,舌头等),或透过衣服看见人物模型身体;其次玩家角色有可能出现被场景中的次要物体遮挡的情况,为游戏开发和实际游戏体验带来负面效果。为了解决这个问题,在主流游戏中有两种解决方案,例如在塞尔达传说旷野之息中,林克的模型网格在摄像机靠的距离过近时会逐渐变透明直至消失,另一种则是通过不改变透明度,依靠有规律的丢弃模型显示的像素来实现近似透明的效果,例如合金装备V:幻痛以及斯普拉遁3中的靶标气球。本文则尝试在Unity引擎中实现通过丢弃片段达到近似半透明的效果。
拜尔矩阵的计算原理
拜尔矩阵抖动图案在实际应用中具有良好的均匀性,可以用来实现均匀的抖动丢弃片段
设矩阵M1、U1,其中:
M1 = | 0 2 | U1 = | 1 1 |
| 3 1 | | 1 1 |
Mn+1 号矩阵的公式为:
Mn+1 = | 4Mn +0Un 4Mn + 3Un |
| 4Mn+2Un 4Mn + 1Un |
Un为与Mn同行数列数,值均为1的矩阵
由此可得(以伪代码形式给出)
M2 = { { 0, 8, 2, 10 },
{ 12, 4, 14, 6 },
{ 3, 11, 1, 9 },
{ 15, 7, 13, 5 } };
M3 = { { 0, 32, 8, 40, 2, 34, 10, 42 },
{ 48, 16, 56, 24, 50, 18, 58, 26 },
{ 12, 44, 4, 36, 14, 46, 6, 38 },
{ 60, 28, 52, 20, 62, 30, 54, 22 },
{ 3, 35, 11, 43, 1, 33, 9, 41 },
{ 51, 19, 59, 27, 49, 17, 57, 25 },
{ 15, 47, 7, 39, 13, 45, 5, 37 },
{ 63, 31, 55, 23, 61, 29, 53, 21 } };
将M3转换为仿色图案并输出为贴图文件,通过将矩阵值+1再除64得到像素灰度,通过unity引擎脚本实现
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
public class DitherPatternGerenate : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
int[,] BayerDither = new int[,] {
{ 0, 32, 8, 40, 2, 34, 10, 42 },
{ 48, 16, 56, 24, 50, 18, 58, 26 },
{ 12, 44, 4, 36, 14, 46, 6, 38 },
{ 60, 28, 52, 20, 62, 30, 54, 22 },
{ 3, 35, 11, 43, 1, 33, 9, 41 },
{ 51, 19, 59, 27, 49, 17, 57, 25 },
{ 15, 47, 7, 39, 13, 45, 5, 37 },
{ 63, 31, 55, 23, 61, 29, 53, 21 }
};
Texture2D texture = new Texture2D(8, 8, TextureFormat.RGBA32, false);
for(int y = 0; y<8; ++y)
{
for(int x = 0; x<8; ++x)
{
float rgb = (BayerDither[y, x] + 1) / 64.0f;
Color c = new Color(rgb,rgb,rgb,1.0f);
texture.SetPixel(x, 7-y, c);
}
}
texture.Apply();
Byte[] bytes = texture.EncodeToPNG();
File.WriteAllBytes("Assets/DitherTests/BayerDitherPattern2.png", bytes);
UnityEditor.AssetDatabase.Refresh();
}
}
这张图片会在之后使用到
在unity上实现基于alpha值的拜尔矩阵丢弃片段
在Unity的工程里新建Unlit Shader,修改第一行名称为"Custom/DitherBasic",在Properties添加颜色属性,并将拜尔矩阵的数组添加到frag函数中
Shader "Custom/DitherBasic"
{
Properties
{
_Color("Color", Color) = (1,1,1,1)
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
}
float4 _MainTex_ST;
float4 _Color;
v2f vert (appdata v)
{
fixed4 frag (v2f i) : SV_Target
{
const int BayerDither[8][8] = {
{ 0, 32, 8, 40, 2, 34, 10, 42 },
{ 48, 16, 56, 24, 50, 18, 58, 26 },
{ 12, 44, 4, 36, 14, 46, 6, 38 },
{ 60, 28, 52, 20, 62, 30, 54, 22 },
{ 3, 35, 11, 43, 1, 33, 9, 41 },
{ 51, 19, 59, 27, 49, 17, 57, 25 },
{ 15, 47, 7, 39, 13, 45, 5, 37 },
{ 63, 31, 55, 23, 61, 29, 53, 21 } };
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
此时在着色器中便可以读取矩阵中的值,让我们先把矩阵以灰度形式显示出来
新建一个材质,设置着色器为Custom/DitherBasic,在场景中添加一个Quad,把新添加的材质拖到Quad上
接下来,我们通过着色器中该片段的UV坐标来确定应该读取矩阵的哪一个值
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
float gray = (BayerDither[7 - floor(i.uv.y * 8)][floor(i.uv.x * 8)] + 1) / 64.0;
col.x = gray;
col.y = gray;
col.z = gray;
很好,我们已经完成了第一步,接下来便是开始丢弃片段的工作了
在Unity的着色器中,丢弃片段通过clip()函数实现,该函数括号内的参数若小于0,则丢弃该片段
我们使用之前添加的_Color的w分量作为该片段的alpha值,当alpha值小于灰度时,便丢弃
在代码中添加clip
col.z = gray;
clip(_Color.w - gray);
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
此时保存代码文件并回到Unity编辑器,拖动材质颜色的A分量,便可以看到随着A分量的减少Quad在一块一块的消失
实现基于屏幕空间的丢弃片段
在上一节中,我们依照片段的UV坐标对片段进行丢弃片段,对于一个平面来说,这个效果看起来还算不错,但当网格变得复杂时,丢弃片段的效果加面的透视看起来就会很奇怪,也很难达到近似于透明的效果
为了使丢弃片段的效果在显示上绝对均匀,我们首先需要将片段的坐标转化为屏幕坐标,然后再基于片段的屏幕坐标进行丢弃片段,在这里,我假定你已经了解计算机图形学中的坐标转换流程
让我们先获得片段的屏幕坐标,新建Unlit Shader,命名为ScreenPos,并新建一个材质设置为该着色器
添加计算屏幕坐标的代码到新的着色器中
struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
float4 screenPos : TEXCOORD1;//新添加的代码
};
...
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.screenPos = ComputeScreenPos(o.vertex);//新添加的代码
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
现在我们已经得到了片段的屏幕坐标screenPos,但通过ComputeScreenPos()计算出来的坐标是齐次空间坐标,具体情况可以查阅相关文档
在片段着色器中,我们通过把屏幕坐标赋给片段颜色的方式,可以直接读出片段所在的位置,screenPos在使用中,xyz分量需要除以w分量
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
col.x = i.screenPos.x / i.screenPos.w;
col.y = i.screenPos.y / i.screenPos.w;
col.z = 0.0;
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
接下来把新的材质拖到物体上,再把主相机怼到物体上,确保物体充满相机的画面,这样我们便在相机画面中直观的看到了屏幕坐标,屏幕坐标排布从坐下到右上,从(0.0,0.0)到(1.0,1.0)
左上角为绿色,对应RGB中G的分量为1.0,屏幕坐标Y分量为1.0
右下角为红色,对应RGB中R的分量为1.0,屏幕坐标X分量为1.0
右上角黄色则是R和G分量同时为1.0时的黄色,屏幕坐标XY均为1.0
左下角各分量全为0.0,显示为黑色
现在让我们回到丢弃片段,把ScreenPos中添加的代码复制到DitherBasic中,并在gray的计算中用屏幕坐标取代片段的UV坐标
//float gray = (BayerDither[7 - floor(i.uv.y *8 )][floor(i.uv.x *8)] + 1) / 64.0;
float gray = (BayerDither[7 - floor(i.screenPos.y / i.screenPos.w *8 )][floor(i.screenPos.x / i.screenPos.w *8)] + 1) / 64.0;
此时将相机怼到物体上,可以看到相机画面中的丢弃片段效果,与第一次实现丢弃片段的效果是相同的,好像Quad的被铺到的屏幕画面上
在画面中一下子空这么大一个格子肯定是让人无法接受的,产生这种情况的原因是我们在读取拜尔矩阵时把整个矩阵直接映射到了屏幕坐标的0.0到1.0中,这样做直接把屏幕分成了8X8的64个格子,不同格子对应的值作为clip判断的参数来决定是否丢弃,其次这里还有第二个问题,因为矩阵是8X8的正方形,而主流游戏设备的屏幕一般都是16:9的长方形,矩阵映射到屏幕上之后被拉长了,这是我们首先需要解决的问题
在Unity引擎的着色器中,我们可以通过_ScreenParams获得屏幕长宽的像素数。其中X分量为横向长度,Y分量为纵向长度,用纵向长度除以横向长度得出两个分量的比值,并在计算屏幕坐标时乘上这个值
float ratio = _ScreenParams.y / _ScreenParams.x;
float gray = (BayerDither[7 - floor(i.screenPos.y / i.screenPos.w * 8 * ratio)][floor(i.screenPos.x / i.screenPos.w *8)] + 1) / 64.0;
现在的显示分辨率为1280X720,可以看到格子变成正方形了,然后纵坐标方向发生了裁剪。或许有聪明的读者已经想到了一些问题,当屏幕变成纵坐标比横坐标大的时候,这个算法可能就不行了
实际上确实是这样,但我们也没必要在这里绞尽脑汁去想一个通用的算法,在后面,我们会用更好的方式来实现它,并从根本上杜绝这个问题
现在我们已经解决了横纵比例问题,接下来是第二个问题,格子太大了,一整块屏幕只有八个格子,显然做不出近似半透明的效果,这是由于矩阵直接映射到了0.0到1.0的原因,那么只要在0.0到1.0中多映射几个矩阵,就可以解决这个问题了
float ratio = _ScreenParams.y / _ScreenParams.x;
//调整我们的算法,映射更多个矩阵到0.0到1.0,由repeat控制映射几个矩阵,使用fmod进行浮点数求余
float repeat = 2.0;
int arrayX = floor(fmod(i.screenPos.x / i.screenPos.w, 1.0/repeat) * repeat * 8);
int arrayY = 7 - floor(fmod(i.screenPos.y / i.screenPos.w * ratio, 1.0/repeat) * repeat * 8 );
float gray = (BayerDither[arrayY][arrayX] + 1) / 64.0;
现在的分辨率为1280X720,也就是说当宽边映射160个矩阵的时候,每个格子就会对应到每一个屏幕的像素点上
使用仿色图案纹理控制丢弃片段
现在的仿色矩阵,是通过在片段着色器中声明一个数组实现的,对于着色器来说,这样的实现方式并不是一个好办法,而且也不具备灵活性
在前文我们已经实现了将拜尔矩阵输出为仿色图案纹理,接下来我们将会使用它来实现丢弃片段的控制
修改DitherBasic着色器的代码,添加新的纹理
Properties
{
_Color("Color", Color) = (1,1,1,1)
_MainTex ("Texture", 2D) = "white" {}
_DitherTex("DitherPattern", 2D) = "white" {}//新的纹理
}
...
sampler2D _DitherTex;//新纹理
float4 _DitherTex_ST;//新纹理的缩放和偏移
sampler2D _MainTex;
float4 _MainTex_ST;
...
随后我们修改片段着色器,让纹理直接按照屏幕坐标显示在屏幕上
float ratio = _ScreenParams.y / _ScreenParams.x;
float repeat = 8.0;
int arrayX = floor(fmod(i.screenPos.x / i.screenPos.w, 1.0/repeat) * repeat * 8);
int arrayY = 7 - floor(fmod(i.screenPos.y / i.screenPos.w * ratio, 1.0/repeat) * repeat * 8 );
float gray = (BayerDither[arrayY][arrayX] + 1) / 64.0;
col = tex2D(_DitherTex, i.screenPos.xy / i.screenPos.w);//采样仿色纹理
//clip(_Color.w - gray);//暂时先不丢弃片段
把之前生成的仿色图案,它应该已经出现在了工程的文件夹中,拖到材质的DitherPattern上
产生这个问题的原因,是8X8个像素的纹理在放大到整个屏幕后,贴图的过滤模式没有修改,默认为插值模式,我们把它改为Point模式,别忘了点击下面的Apply
现在我们的纹理还是不能直接使用,因为Unity在导入纹理时会对纹理进行压缩处理,以适合游戏在运行时使用,但是我们的仿色图案只用于提供屏幕空间的alpha对比值,所以让我们调整其他的导入选项
在Advanced中,取消勾选Generate Mip Map
在下面的窗口中,修改Max Size为32,Format为R8
点击Apply
你会看到画面变成了红色,但这正是我们需要的,它去除了其他所有无关的东西,只留下了我们需要的对比值
现在我们遇到了和之前一样的问题,8X8的仿色图案直接铺在整个屏幕中。我们现在需要让它重复铺开,对于纹理来说,这要容易的多
设置Wrap Mode为Repeat,这样在UV坐标大于1.0时,会以UV值除1.0的余数进行重复采样,其次我们需要修正横纵坐标比例,像之前一样横向对齐,修改片段着色器的代码
float ratio = _ScreenParams.y / _ScreenParams.x;
float repeat = 1.0;
float2 DitherUV = i.screenPos.xy / i.screenPos.w;
DitherUV.y *= ratio;
col = tex2D(_DitherTex, DitherUV * repeat);
因为Wrap Mode为Repeat,因此在整个纹理在以横向对齐时,在纵向我们不需要做任何处理,它将自动进行重复采样
现在我们已经将仿色图案贴到了屏幕上,但是需要计算图案需要在屏幕中重复几次是一个不太方便的想法,因为屏幕的尺寸和仿色图案的尺寸都是不确定的,我们需要一个有效的方法来彻底解决这个问题
在之前我们得出屏幕宽度为1280时,宽度为8的仿色图案需要重复160次才能达到像素对应,此时1个屏幕像素对应一个仿色图案像素;当重复80次时,2X2个屏幕像素对应仿色图案的一个像素;当重复40次时,4X4个屏幕像素对应仿色图案的一个像素
我们可以得出一个公式:
仿色图案一个像素对应的屏幕像素个数 =(屏幕横向尺寸 / 仿色图案尺寸)/ 实际重复次数
而公式中实际重复次数就是之前我们代码中的repeat
也就是说repeat = (屏幕横向尺寸 / 仿色图案尺寸)/ 仿色图案一个像素对应的屏幕像素个数
接下来我们更换计算屏幕坐标的方式,使用_ScreenParams,它是一个float4类型的内置变量,x和y对应了相机渲染纹理的宽高,也就是屏幕尺寸
在着色器中v2f的vertex变量就是该片段在屏幕中的位置,用vertex除_ScreenParams的x或y我们可以得到片段在以屏幕宽/高边为长度的正方形中的UV坐标,用它来替代DitherUV
float repeat = _ScreenParams.x / 8.0 / 160.0;
float2 DitherUV = i.vertex / _ScreenParams.x;
col = tex2D(_DitherTex, DitherUV * repeat);
在这个算式中,我们可以发现_ScreenParams.x被消掉了,也就是说,现在的算法已经变为了完全基于像素在屏幕的位置来计算与仿色图案的对应关系,彻底摆脱了屏幕尺寸和比例
//化简后的计算
col = tex2D(_DitherTex, i.vertex / 8.0 / 160.0);
在Unity的shader中,可以通过float4 _TextureName_TexelSize;的方式来获得着色器纹理的尺寸,它的前两个分量包含纹素大小,作为 U 和 V 的分数。另外两个分量包含像素数量。例如,对于 256×128 的纹理,它将包含 (0.00390625, 0.0078125, 256, 128)。对于DitherTex来说,一般都是正方形,横纵尺寸相等,所以只需要读取z分量的值。
然后,我们再新加一个float属性,用于确定仿色图案一个像素对应的屏幕像素个数。
Properties
{
_Color("Color", Color) = (1,1,1,1)
_MainTex ("Texture", 2D) = "white" {}
_DitherTex("DitherPattern", 2D) = "white" {}
_DitherPixel("DitherPixel", float) = 1//仿色图案一个像素对应的屏幕像素个数
}
...
sampler2D _DitherTex;
float4 _DitherTex_ST;
sampler2D _MainTex;
float4 _MainTex_ST;
float4 _Color;
float4 _DitherTex_TexelSize;//纹理尺寸
float _DitherPixel;//仿色图案一个像素对应的屏幕像素个数
...
col = tex2D(_DitherTex, i.vertex / _DitherTex_TexelSize.z / _DitherPixel);
这样一来,我们就可以通过在编辑器中调整材质的值,来控制仿色图案的比例了
在clip函数中,用col值的x分量代替gray,因为现在的纹理只有一个通道
clip(_Color.w - tex2D(_DitherTex, i.vertex / _DitherTex_TexelSize.z / _DitherPixel).x);
因为我们使用了仿色图案纹理,因此我们可以随便画点灰度图,实现特殊的仿色图案效果
参考:
https://zhuanlan.zhihu.com/p/60325181
https://en.wikipedia.org/wiki/Ordered_dithering#cite_note-2
https://bisqwit.iki.fi/story/howto/dither/jy/
https://blog.csdn.net/linjf520/article/details/121991470
https://blog.csdn.net/paris_he/article/details/40341233