最近翻出了最初学习Unity的项目,似乎我当时在研究像素屏幕,但表现的效果很有限。现将其重新实现,并添加新的功能。
在开始之前,我先给出本文章的实现效果:
屏幕视差深度、凸面屏幕
像素级缩放
自定义分辨率像素采样 图片大小限制,使用较低的GIF质量
RGB像素形状、LCD最低背光亮度、像素偏移
亮度、形状 | 像素偏移 |
---|---|
![]() |
![]() |
荧幕自发光亮度调整
用户交互
交互 | 细节 |
---|---|
![]() |
![]() |
目录
像素化采样
首先实现像素化采样,这是让屏幕从近距离看起来像液晶屏幕的基础。
我们需要提供两个参数:width
/height
分别表示横纵分辨率。
考虑一种极端情况,假设预期分辨率为2x2
,也就是只有四个像素:
方块内的每一个片段
(片段着色器所渲染的单位),都应该显示本方块采样点的颜色(图中的红点)。
则每一个片段
都需要知道自己的方块位于哪一个位置,进而根据方块位置计算采样点位置。如下图所示:
根据图中的逻辑,在连连看中实现采样点UV
的计算方法
之后根据这个坐标,即可直接进行图像采样:
采样 | 输出 |
---|---|
![]() |
![]() |
单像素边缘裁切
仔细观察输出结果,发现在方块边缘处会出现错误的颜色
首先需要明确,像素的显示时机是在显示三原色之前,因此每个像素之间需要存在一定间隙,这也是现实生活中的现象:
因此我们不会去解决边缘错色的问题,取而代之的是直接裁切每个像素的边缘,使其相互间隔一段距离,而不是连接在一起。
首先我们计算每个像素方块到边缘处的距离,即边缘的SDF:
其算法是:
首先使用减法和绝对值组合,使范围从(0,1)
映射到(0.5,0)(0,0.5)
之后一个减法将范围从(0.5,0)(0,0.5)
映射到(0,0.5)(0.5,0)
,使其恰好反映出到边缘的距离。
之后使用step
函数,让最接近边缘的片段输出为1
,其他部分输出为0
。
为了绘制出最细的线段,使用DDXY
来实现标记最靠近边缘的片段
。
如果你对
DDXY
在这里的用法感到疑惑,在上一篇文章中,我解释了使用DDXY绘制单像素抗锯齿网格的原理_CNDS。通俗的讲,DDXY
在这里的作用是获取每一个片段
所占据的UV宽度。由此来裁切掉最靠近边缘的片段
,同时确保了结果不会失真。
你可以按照上一篇文章的内容,将step
换为smooth
,以消除边缘锯齿,代价是牺牲部分性能。
将结果的01值,对像素颜色使用乘法可以做到裁切像素的效果
动态裁切像素边缘
本节开始出现容易混淆的名词,在此先强调:
”屏幕像素“
指的是真实的像素,是实体显示器上显示出来的像素。
“仿真像素”
指的是我们模拟出来的虚假的仿真像素,是由多个屏幕像素
组合而成。
”片段“
指的是在Shader中,片段着色器的基本单位
没有开启多重抗锯齿时,片段
和屏幕像素
是大小相等,一一对应的,本文建立在此基础之上。
现在存在一个问题:如果仿真像素
分辨率极大,或摄像机距离较远,会导致大部分片段
被当作仿真像素
的边缘而裁切(因为最靠近边缘的片段
始终被裁切,而整个仿真像素
的宽度可能才只有3个片段
)。
为了防止这类情况,需要在恰当时机显示边缘,而不是总是裁切。
我们希望当画面上的仿真像素
比屏幕像素
更大时显示边缘,反之则裁切。
为了做到这一点,我们需要将仿真像素
的UV宽度,与屏幕像素
的UV宽度比较,并以此为权重显示对应的内容。
注意
:不要试图使用摄像机位置作为影响因子来管理这一过程。因为摄像机位置无法反应出焦距、UV视差深度的变化。
注意
:DDXY
计算的结果是相较于前一个片段
的差值,将其乘以二用于大致估算此片段的完整UV宽度。一个并不准确的示意图(此图仅为示意):
我们比较了两个UV宽度,将结果作为因数,用于决定是否裁切:
使用add
而不是max
或multiply
可避免切换前的整体颜色骤降。
裁切边缘因数也可用于之后的步骤,请牢记。
屏幕视差效果(Parallax)
完成像素部分后,现在可以开始实现视差效果。
关于视差,网络上有很多教程,在这里我仅将重点写出,细节清参考权威网站。
视差原理很简单,但是如果想要彻底的理解究竟为何这样计算,实际上很费脑筋。
所幸本文的屏幕视差仅仅是将颜色挪个位置,不涉及复杂光线着色计算。
为了欺骗摄像机让其以为自己看到的始终是绿色位置。我们需要在计算UV1
的片段时,实际上使用UV2
的值。
即将给UV
一个变换,让UV2
的位置移动到UV1
的位置。对每一个UV
坐标都进行基于各自视角的变换。
为了计算红色箭头的向量,我们只需要将蓝色箭头的View Direction
压缩到平面上。
viewDirection
位于切线空间,因此viewDirection.xy
就是压缩后的方向,为了统一比例,将其除以viewDirection.z
:
如果你疑惑为什么
viewDirection.xy
要除以viewDirection.z
:
- 当视线接近垂直于表面时(viewDir.z 较大),这意味着表面看起来几乎没有位移
- 而当视线接近平行于表面时(viewDir.z 接近0),这导致较大的UV坐标偏移
其中distance
是参数,用于控制视差深度。基于此,你可以为其叠加一个球形的SDF
用于添加凸形效果:
由于前文中裁切边缘因数
是基于UV
而计算的,因此调整视差深度同样会自动的让裁切因数
位于恰当的数值。
视差裁切
我们希望裁切后,只有中间能够显示屏幕内容,超出范围的部分不显示内容。
若要裁切,只需要判断UV
是否位于0-1
范围内,如果在此范围则正常输出,否则裁切:
其中,使用saturate
和equal
来判断是否位于01
范围,若都符合则输出1
,否则输出0
。
之后我们将输出与采样后的颜色相乘即可做到裁切视差范围。
RGB子像素绘制
首先使用像素方块的UV
坐标
使用fraction
取其小数:
之后我们在此基础上绘制各种类型的子像素:
例如绘制条形像素的方法是:
其中颜色输入来自于视差裁剪后的采样颜色。
使用
Rectangle
时,有时会发现相同的代码会绘制出不同的效果(如UV边缘处出现细线)此时需要使用Nicest
确保计算的稳定性。
接下来需要在恰当时机,混合RGB
和采样图案,实现无缝的画面缩放。
在开始之前,将裁切边缘因数
重命名为屏幕缩放因数
,因为此处再一次用到了它的计算结果,其名称容易引起误解。
总体逻辑为:
通过屏幕缩放因数,来选择输出像素采样
或RGB
。
其中,绘制RGB子像素前,使用的是视差裁剪后
的图片采样结果
而在混合时,叠加的是像素边缘裁剪后
的采样结果。
叠加边缘裁切
采样的好处是在渐变到RGB画面时会有更明显的像素颗粒感,从而更贴近现实。
混合边缘裁切 采样结果 |
不混合边缘裁切 |
---|---|
![]() |
![]() |
应当明确一点:更强的颗粒,意味着更多高频信息,从而带来相较更明显的摩尔纹。
多种RGB像素类型
如果想要实现切换,则需要添加一个Enum
的Shader
属性pixelShape
。
首先实现一个自定义的像素,例如圆形:
通过Enum
的值,选择对应的像素颜色:
由于无论是否显示某种像素,所有的像素绘制计算都会执行。因此像素种类越多,计算负载越大。
间隔行列像素偏移
现实中有些像素排列的方式较特殊,每一行有固定偏移。
为了实现这个功能,我们只需要改进采样前的逻辑,对UV
设置偏移。
首先明确:只能在行或列一个方向上存在偏移,两个方向上的偏移不能同时存在。
因此添加一个Enum
类型属性offsetDirction
用于表示偏移的方向是行width
还是列height
。(由于不好确定行列概念,使用width
和height
代替说明)
对像素方块的UV进行处理:
其中:offset
范围是0~1
,用于控制偏移距离,为1时则偏移一个像素长度。
使用mod
判断行列的奇偶性,通过使用Enum
属性生成不同的基向量。当为横向(width
)时,生成的Vector2
为(0,1)
,如果是奇数,则会将此向量与offset
相乘并反转分量顺序,并添加偏移。
后续的功能无需改动,只需要基于此UV照常处理即可。
但在采样中心点颜色的步骤中需要做处理:将其sampler state
设置为clamp
,可以防止偏移后的像素采样到另一侧的颜色。
自发光
除非想要实现墨水屏效果,否则需要使用自发光选项让荧幕在无光照环境中也具有颜色。
我们只需要将基础色的内容连接到自发光上即可。
但现在存在几个问题:
首先是我们需要调节亮度,要解决它只需要添加一个乘数:
其次是当前远处与近处的亮度差异大:
远距离 | 近距离 |
---|---|
![]() |
![]() |
这是由于远距离时,每个片段的RGB
分量都包含数据。在近距离中,只有某个分量有数据,同时画面包含不发光的间隙,使亮度进一步下降。
一个简单的方式是直接根据屏幕缩放因数
来控制亮度:
在远处时,将数值限制于0.3~1
之间。缩放越大,则乘数越接近1
;缩放越小,乘数越接近0.3
LCD背光模拟
当画面完全黑暗时,像素会完全不可见。如果你想要模拟LCD屏幕
(无法显示纯黑的颜色),则需要添加最小亮度限制:
在提供给RGB
像素颜色前,使用MAX
节点限制各个颜色通道的最小值便能做到钳制最小亮度。
但为了让超出背光板范围的区域永远不显示RGB像素,则需要添加对此的处理逻辑:
由于最终画面叠加的采样色与RGB
像素的采样色并不一致,导致在远处时,所呈现的的黑色依然是纯黑色(只钳制了RGB
子像素的最低亮度),如果你希望让画面效果在远处也能模拟LCD
的效果,则可以考虑将钳制颜色的逻辑移动到sampler
之后:
更改前
更改后
如此一来,即使在远处也能看到最低亮度钳制后的画面:
钳制后 | 钳制前 |
---|---|
![]() |
![]() |
屏幕固有色(脏迹)
为屏幕添加脏迹可大幅增加画面质感。
添加一个Texture2D
属性screen
用于采样屏幕上的脏迹。
屏幕材质的UV
不受像素影响,因此只需要使用Add
叠加屏幕材质图案的采样即可。
可以额外添加一个Texture2D
用于采集屏幕的光滑度(即描述屏幕粗糙度的贴图)
如果你的屏幕脏迹较为简单,还可以直接采样screen
颜色作为smoothness
为节省资源,可以考虑将基础色与光滑度组合在一起,可使用PS后期
或SP
直接生成。
下面我讲介绍如何使用SP生成脏迹贴图。
Substance Painter创建屏幕脏迹
使用Substance Painter可直接将颜色
、粗糙度
导出为一张材质图(基础色
作为RGB
通道,粗糙度
作为A
通道)。
实现较好的屏幕效果,通常你只需要三层图层:
一层纯黑色的BaseColor通道
图层作为基础色
一层只有BaseColor通道
的图层作为脏迹,可以在其上使用任意颜色绘制图案。
一层只有Roughness通道
的图层作为粗糙图层
屏幕通常不需要
normal
数据,但如果你想实现例如雨中水迹的效果,可以使用normal来表现,日后可能会深入研究,本文且先按下不表。
在导出预设中新建一个预设,将颜色通道和Glossiness
通道合并在一张材质上。
则在Unity
中你可以直接提取A通道
的数据作为光滑度
:
添加了光滑度
贴图的显示效果如下:
实现渲染GUI
若要实现GUI
渲染,只需要将画面渲染到RenderTexture
中,之后将RenderTexture
作为材质输入即可。
UI Toolkit
自带了渲染UI到RT的功能。
首先创建一个RenderTexture
,让其大小为800x800
(或其他大小,推荐与屏幕的UV
成比例,避免拉伸)
安装UI工具包
后,新建一个Panel Setting
,指定相关参数:
- 在
Panel Setting
中:- 指定了将UI绘制于
RenderTexture
上。 - 指定了缩放参考分辨率大小为
800x800
,其中Match Mode
可以随意,但分辨率
应与RenderTexture
相同。UI将会以此为依据渲染到RT
中,不合适的数据会导致内容显示不全。 - 指定了
清除颜色
、深度Stencil
,避免在渲染多层透明度UI控件时出现显示问题。
- 指定了将UI绘制于
接下来新建一个UI文档
(*.uxml
)文件,编辑UI
UXML
的用法与HTML
相似,因此如果你有web编程基础,则应该很容易排布出任何风格的UI。
关于
UI Toolkit
我写过几篇教程文章,实现了一些高级用法,那些用法在此处同样适用。UI Toolkit教程_专栏地址
在场景中添加UI文档
分配相关的属性:
其中Panel Setting
和Source Assert
为刚才新建的内容。
一旦分配了内容,则RenderTexture
将自动的更新为UI画面。同时,在修改UXML
布局时,RenderTexture
也能够得到自动更新。
注意:
当画面较小时,RenderTexture
会产生高频锯齿,解决方法是为RenderTexture
开启Mip生成
,则可以缓解这个问题。
未开启Mip生成 | 开启Mip生成 |
---|---|
![]() |
![]() |
GUI交互
UI Toolkit
提供了在世界空间中,与UI交互的方法:
在代码中,使用PanelSettings
的SetScreenToPanelSpaceFunction
方法,设定一个鼠标坐标映射函数(格式为Func<Vector2,Vector2>
)
此函数将负责将鼠标从屏幕坐标,映射到UI坐标。
一个基础功能代码是:
//将此脚本绑定到屏幕物体上
public class UIProjection : MonoBehaviour
{
public Camera targetCamera;
private Camera TargetCamera
{
get
{
if (targetCamera == null)
targetCamera = Camera.main;
return targetCamera;
}
}
public PanelSettings targetPanel;
private void OnEnable()
{
if (targetPanel == null) return;
//启用时设定转换函数
targetPanel.SetScreenToPanelSpaceFunction(ScreenCoordinatesToRenderTexture);
}
private void OnDisable()
{
//禁用时还原
if (targetPanel != null)
{
targetPanel.SetScreenToPanelSpaceFunction(null);
}
}
/// <summary>
/// 将屏幕位置转换为相对于 MeshRenderer 使用的渲染纹理的位置。
/// </summary>
/// <param name="screenPosition">鼠标于屏幕坐标中的位置</param>
/// <returns>返回UV空间中的坐标</returns>
private Vector2 ScreenCoordinatesToRenderTexture(Vector2 screenPosition)
{
screenPosition.y = Screen.height - screenPosition.y;
var cameraRay = TargetCamera.ScreenPointToRay(screenPosition);
if (!Physics.Raycast(cameraRay, out var hit) || !hit.transform.Equals(transform))
{
return new Vector2(float.NaN, float.NaN);
}
var targetTexture = targetPanel.targetTexture;
var pixelUV = hit.textureCoord;
//UI Toolkit中Y坐标是倒置的,因此需要翻转
pixelUV.y = 1 - pixelUV.y;
pixelUV.x *= targetTexture.width;
pixelUV.y *= targetTexture.height;
return pixelUV;
}
}
其逻辑是通过射线判断判断是否击中了屏幕网格,击中后获取击中点的UV坐标,通过UV坐标映射到UI坐标。
如果想要此逻辑能够正常运行,必须要求屏幕的UV需要保持
正向
,即保持UV的0,0->1,1
向量与模型对应顶点的向量一致,若不一致会导致鼠标映射到错误的位置。
建议将屏幕网格分离,尽量避免圆角、异形屏幕。
至此,屏幕的交互便实现了
不仅可以响应鼠标事件,也能够对键盘按键作出反应,例如切换焦点,输入文本,唤起输入法输入中文等。一切操作和UI无异。因此也可以通过绑定脚本实现复杂的事件逻辑。
结束
CSDN上的资源我无法修改为免费下载,因此这里是ShaderGraph的下载链接
至此本文完结,任何想法或建议,欢迎在评论区留言探讨。如果文章中有任何疏漏或不准确之处,恳请指正。