Unity 3D: 实现可交互液晶屏幕

最近翻出了最初学习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而不是maxmultiply可避免切换前的整体颜色骤降。
效果
裁切边缘因数也可用于之后的步骤,请牢记。

屏幕视差效果(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范围内,如果在此范围则正常输出,否则裁切:
算法
其中,使用saturateequal来判断是否位于01范围,若都符合则输出1,否则输出0
之后我们将输出与采样后的颜色相乘即可做到裁切视差范围。
裁切

RGB子像素绘制

首先使用像素方块的UV坐标
聚焦
使用fraction取其小数:在这里插入图片描述
之后我们在此基础上绘制各种类型的子像素:
在这里插入图片描述
例如绘制条形像素的方法是:
在这里插入图片描述
其中颜色输入来自于视差裁剪后的采样颜色。

使用Rectangle时,有时会发现相同的代码会绘制出不同的效果(如UV边缘处出现细线)此时需要使用Nicest确保计算的稳定性。

接下来需要在恰当时机,混合RGB和采样图案,实现无缝的画面缩放。
在开始之前,将裁切边缘因数重命名为屏幕缩放因数,因为此处再一次用到了它的计算结果,其名称容易引起误解。在这里插入图片描述
总体逻辑为:
逻辑
通过屏幕缩放因数,来选择输出像素采样RGB
其中,绘制RGB子像素前,使用的是视差裁剪后的图片采样结果
而在混合时,叠加的是像素边缘裁剪后的采样结果。

叠加边缘裁切采样的好处是在渐变到RGB画面时会有更明显的像素颗粒感,从而更贴近现实。

混合边缘裁切采样结果 不混合边缘裁切
在这里插入图片描述 在这里插入图片描述

应当明确一点:更强的颗粒,意味着更多高频信息,从而带来相较更明显的摩尔纹。

效果

多种RGB像素类型

如果想要实现切换,则需要添加一个EnumShader属性pixelShape
首先实现一个自定义的像素,例如圆形:
圆形
通过Enum的值,选择对应的像素颜色:
对比

由于无论是否显示某种像素,所有的像素绘制计算都会执行。因此像素种类越多,计算负载越大。

间隔行列像素偏移

现实中有些像素排列的方式较特殊,每一行有固定偏移。
像素
为了实现这个功能,我们只需要改进采样前的逻辑,对UV设置偏移。

首先明确:只能在行或列一个方向上存在偏移,两个方向上的偏移不能同时存在。
因此添加一个Enum类型属性offsetDirction用于表示偏移的方向是行width还是列height。(由于不好确定行列概念,使用widthheight代替说明)

对像素方块的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文档*.uxml)文件,编辑UI
UI
UXML的用法与HTML相似,因此如果你有web编程基础,则应该很容易排布出任何风格的UI。

关于UI Toolkit我写过几篇教程文章,实现了一些高级用法,那些用法在此处同样适用。UI Toolkit教程_专栏地址

在场景中添加UI文档
UI文档
分配相关的属性:
在这里插入图片描述
其中Panel SettingSource Assert为刚才新建的内容。

一旦分配了内容,则RenderTexture将自动的更新为UI画面。同时,在修改UXML布局时,RenderTexture也能够得到自动更新。
更新后的内容
注意:
当画面较小时,RenderTexture会产生高频锯齿,解决方法是为RenderTexture开启Mip生成,则可以缓解这个问题。

未开启Mip生成 开启Mip生成
锯齿 无锯齿

GUI交互

UI Toolkit提供了在世界空间中,与UI交互的方法:
在代码中,使用PanelSettingsSetScreenToPanelSpaceFunction方法,设定一个鼠标坐标映射函数(格式为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的下载链接

至此本文完结,任何想法或建议,欢迎在评论区留言探讨。如果文章中有任何疏漏或不准确之处,恳请指正。

猜你喜欢

转载自blog.csdn.net/qq_36288357/article/details/143168179