Equal Width Bezier Curve

在Unity的UI中绘制等宽的贝赛尔曲线

动机和贝塞尔曲线相关的背景知识 动机当然是要在UI上绘制一个贝塞尔曲线的形状了,想做的效果大概就和虚幻引擎蓝图连接节点的线差不多了。这里要绘制的是一种较为特殊的贝塞尔曲线,它的两个端点的切线是水平的,且拥有旋转对称的特性。 我们想要绘制的S形曲线是三阶的贝塞尔曲线。三阶贝塞尔曲线有四个控制点\(P_0, P_1, P_2, P_3\),对于一个从0到1的变量\(t\),贝塞尔曲线的做法是对这四个点按照顺序以\(t\)做插值生成三个新的点,然后对这三个点按照顺序以\(t\)做插值生成新的两个点,再对这两个点以t做插值生成最后的点,当t在0到1中变化时,这个点的轨迹就构成了贝塞尔曲线。贝塞尔曲线上的点可以用四个控制点和\(t\)来表示: $$ P_{bezier} = P_0 \cdot (1 - t)^3 + 3 P_1 \cdot (1 - t)^2 \cdot t + 3 P_2 \cdot (1 - t) \cdot t^2 + P_3 \cdot t^3 $$ 如果初始四个点分别是(0.0, 1.0), (d, 1.0), (1.0 - d, 0.0),和(1.0, 0.0)的话,也就是我们所要绘制的特殊的贝塞尔曲线,可以算出贝塞尔曲线的坐标为 $$ P_{bezier} = ((3 t - 9t ^ 2 + 6t^3) \cdot d + 3t^2 - 2t^3, 1 - 3t^2 + 2t^3) $$ 但是即使得到了贝塞尔曲线的参数方程,想要将其表达成\(f(x)\)的形式仍然是相当困难的。Alan Wolfe在他的博客中提到了一种一维贝塞尔曲线,也是一种贝塞尔曲线的特殊情况,四个控制点在水平方向上等距排开,这样子贝塞尔曲线的参数方程的水平分量就刚好是\(t\),它的竖直分量也就是我们需要的\(f(x)\)。唯一美中不足的是,能轻易得到\(f(x)\)的一维贝赛尔曲线,往往是一个“躺倒”的贝赛尔曲线,感官上看上去是横着的,Shadertoy上有相关的演示。但这种一维贝塞尔曲线又有一种特殊情况,也就是前两个控制点的竖直高度相等,后两个控制点的竖直高度也相等,这时这种特殊的一维贝塞尔曲线就是我们耳熟能详的smoothstep曲线了(数学真奇妙啊)。可惜smoothstep不能满足我们随意控制曲线形状的需求,只能另求他法。...

December 15, 2021 · zznewclear13
Equal Width Outline Cover

在Unity中绘制等宽的描边

对于描边的思考 描边可以说是一个特别关键的效果,不仅仅是二次元卡通渲染需要用到描边,在用户交互的方面,描边也是一个增强用户交互的关键效果。 一般的描边的做法是绘制一个沿物体空间顶点法线(或是记录在顶点色中的描边方向)外扩的模型背面,这种做法在绝大部分情况都看上去不错,但是描边的深度测试会有一些小瑕疵,同时在物体距离摄像机较近的时候,描边会显得较粗,此外这种描边没有抗锯齿的效果,绘制模型的背面也让造成了性能的浪费。另外一种方法是使用Multiple Render Targets,渲染出一个模型的剪影,然后使用类似高斯模糊的办法,对采样进行偏移,这样可以渲染出一个较好的可以有抗锯齿效果的描边,但是仅限于模型向外的描边,缺少模型内部的描边效果。 最好的描边应该是能够支持模型外描边、内描边、材质描边的描边效果,pencil +实现了这些效果,但是效率不是很高,这里有相关的演示(我也是看了这个之后才决定用安吉拉的模型的)。我看到的较好的方案应该还是L-灵刃的使用退化四边形生成描边的办法,github上也分享了源码。 这篇博客中介绍的描边,是基于我上一篇博客中讲的世界空间中绘制等宽线条的方法,使用DrawProcedural绘制的等宽的描边。我认为只有等宽的描边,才是最能表现二次元画面特征的描边。这里的“等宽”,并不是说线条的宽度处处相等,线条当然可以控制每一部分的粗细,但是这个控制的粗细是基于一个固定值的相对粗细(也就是存在顶点色中的描边粗细值),当粗细值相同时,不管是画面的哪个部分的描边的粗细(不管是内描边还是外描边),都应该是相同的。 实现描边时需要注意的点 首先参考退化四边形的案例,需要先对模型文件进行预处理。这里我做了简化,只去寻找两个三角面共用的边,忽略了只属于一个三角面的边的情况(事实上我觉得这样看上去的视觉效果也蛮不错的)。一条共用边对应了这条边的两个顶点,两侧的两个三角形和这两个三角形对应的额外的两个顶点。这里都用序号来表示,需要6个int值(事实上可以忽略两个三角形的编号,就能存在一个int4里了)。判断一条边共同属于两个三角形,就相当于判断两个三角形中的某两个顶点的序号是相同的(实际上顺序是相反的)。但是实际操作中,即使是相同的顶点,在两个三角形中顶点的序号也不一定是相同的,因此需要先把两个相同的顶点(使用距离来判断)合并成一个顶点,这也就是我使用vertRemapping这个数组的目的。剩下的就是循环所有三角形,获取共用边的算法部分了,尽可能的优化一下,不然三角面一多运算的时间要很久。 有了共用边的数据,通过SkinnedMeshRenderer.BakeMesh()可以获取到当前帧每个顶点的物体空间的坐标,就能进行描边的计算了。使用DrawProcedural时顶点的数量可以是公用边数量的两倍,这样需要在Geometry Shader中把顶点数目从2扩充到6,或者是在绘制时将顶点数量设置成共用边数量的六倍,可能后者效率会高一点,不过思考的时候会有点乱,这里就使用Geometry Shader的方法了。 如果是像之前的博客介绍的,以共用边两个顶点为中心,同时向左右两侧外扩的话,会因为深度测试的原因,导致描边部分被模型遮挡,这个问题比较严重,他直接导致了外描边和内描边的粗细不一样,也导致了在一条描边中会露出一部分模型的问题。这里采用的方法是仅向外侧描边,在计算是不是轮廓边的时候同时计算需要描边的方向,使用这个方向向外扩展描边,最后效果还蛮不错的。 要实现风格化描边的话,除了使用顶点色来控制描边的粗细之外,还能使用一张贴图作为描边的笔刷,在绘制描边的时候采样这张贴图,本篇博客就暂不使用这种方法了。 具体的实现描边的操作 对当前模型获取到所有的共用边对应的四个顶点序号,严格保持顺序! 每一帧使用SkinnedMeshRenderer.BakeMesh,获取所有顶点当前的物体空间的坐标。 传入顶点坐标,顶点重映射数组,共用边信息,如果需要的话还要传顶点色到描边的Shader中。 使用DrawProcedural绘制描边,顶点数量为共用边的数量的两倍。 在顶点着色器中,计算共用边四个顶点的裁剪空间(实际上用的是屏幕空间)的坐标,判断这条边是不是轮廓边,同时记录描边外扩的方向,相当于对于每一条边(每两个点)存两个bool变量。 在几何体着色器中,计算共用边两个顶点的屏幕空间的坐标,计算出两个点之间的向量,计算与之相垂直的外扩的方向,根据两个向量,计算出描边的四个顶点的裁剪空间的坐标,并赋予uv的值。 在片元着色器中,根据uv计算出描边的颜色,可以采样贴图,也可以直接返回计算的颜色。 由于整个描边的操作较为复杂,我尽可能多的写了注释。 OutlineObject.cs 定义共用边的结构体,也定义用于保存共用边数据的ScriptableObject。 using UnityEngine; namespace ZZNEWCLEAR13.Outline { [System.Serializable] public class OutlineObject : ScriptableObject { [System.Serializable] public class MeshOutlineInfo { public string meshName; //尽量不要都显示出来,不然很卡。。 //顶点、法线、切线和顶点色 [HideInInspector] public Vector3[] vertices; [HideInInspector] public Vector3[] normals; [HideInInspector] public Vector4[] tangents; [HideInInspector] public Color[] colors; //vertRemapping把相同位置的顶点编号映射到第一个该位置顶点的编号 [HideInInspector] public int[] vertRemapping; //三角形对应的顶点编号 [HideInInspector] public Vector3Int[] triangles; public Line[] commonLines; } public MeshOutlineInfo outlineInfo; } [System....

December 12, 2021 · zznewclear13
Equal Width Line Cover

在Unity中绘制等宽线条

动机 直接动机是想要在unity中制作一个描边效果。对于卡通渲染的描边效果,已经有很多很多的案例了,但是我觉得这些案例不一定能完全满足我的需求,于是想要从画直线开始研究。 从普通绘画的角度来看,很重要的一点就是描边的宽度基本上是一致的:考虑一下远景的物体,画家在绘画的时候使用和近景相同粗细的笔(用一样或者稍弱的力),绘制一个较不精细的物体,而不是使用很细的笔去绘制一个精细的物体,结论就是远处的描边可能稍细一些,但最好是相同粗细,其颜色可能会变浅。 从另一个角度来看,描边往往需要能够控制其宽度,对于较细的线,则会有较明显的锯齿(事实上只要角度不太好的描边,就会有很明显的边缘锯齿),那么控制粗细和进行一定程度的抗锯齿也是一个研究的方向。 从第三个角度,碰巧看到了Freya Holmér制作的Shapes插件,能够绘制高质量的线条画,看上去渲染的效果很好。但是价格过于高昂,于是想要研究一个能够做到差不多效果的工具。 抗锯齿和宽度的思考 在Unity中画直线有蛮多办法,Debug.DrawLine或者Gizmos.DrawLine都能绘制等宽的直线,不过只能固定一个像素宽,而且因为只有一个像素宽,所以会有明显的锯齿。使用LineRenderer可以绘制任意宽度的直线,写特定的shader通过透明度混合能够防止锯齿的出现(不过线段两端不太好做抗锯齿),但是不能保证在不同角度下直线的宽度相等。那么答案就很明显了,通过线段的顶点的数据,生成两个三角面(一个Quad),在GPU中计算出三角面每个顶点的屏幕空间的位置,确保线段的宽度一致。也由于有宽度的存在,给后续的透明度混合抗锯齿留下了操作的空间。 具体的绘制线条的操作 首先看一张图(完了三角形顶点顺序画反了,已经积重难返了,代码表现对就当做对的吧) 我们将使用Graphics.DrawProcedural这个方法来绘制我们的直线,每一段直线由两个三角形组成(这里要注意在unity中三角形顶点顺序是顺时针的),也就是说要绘制N条直线的话,要传入N+1个节点位置数据,而实际绘制时会绘制6N个顶点。这样传入Shader的是一个表示线段节点位置的长度是N+1的Vector3数据_VerticesBuffer,和一个表示顶点序号的从0到6N-1的uint数据,也就是在绘制时图形API自动传输的SV_VERTEXID。 这也就导致了我们的顶点着色器输入数据和普通的Shader有所区别,只有一个uint数据: struct Attributes { uint vertexID : SV_VERTEXID; }; 在顶点着色器获取到SV_VERTEXID之后,我们可以以此来计算出线段的序号lineID和每个顶点在绘制线段时的序号vertexID。在绘制时我们又要知道当前线段的两个节点的位置,在传入_VerticesBuffer之后,我们需要对每个顶点确定其对应的线段节点的序号,即lineID + 0或者lineID + 1,我们可以将这个可以通过vertexID确定的0或者1储存到一个数组vertexIndexes中,方便使用vertexID读取。 为了让线段取得较好的抗锯齿效果,我们把线段整体外扩了_OutlineWidth个像素宽(实际线段宽度是这个的两倍)。此时对于每一个顶点,我们需要知道外扩之后的屏幕空间的顶点位置.由于我们需要使用从LineStart到LineEnd的方向offset(对于1, 2, 4这几个顶点来说,offset是从LineEnd到LineStart的方向)和与其相垂直的方向来做外扩,我们需要把每个顶点的两个方向值储存到数组vertexOffsets中。可以看到顶点永远是往远离较远节点的方向移动的,因此vertexOffset的x值都是-1。而我们选取offset逆时针旋转90°的方向作为相垂直的方向,因此vertexOffset的y值会有正负之分。 最后是uv的数值,我们还需要给每个顶点传入uv的数值,就比较简单了,储存到数组vertexUVs中。 此外还要注意一点,在片元着色器中,我们需要知道LineStart和LineEnd对应的uv位置来做线段的圆形端点,因此我们还需要把这个长方体的高宽比ratio传给片元着色器。 这样,我们片元着色器的输入数据就是positionCS,uv和ratio了: struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float ratio : TEXCOORD1; }; 绘制线条的流程 C#脚本获取要绘制线条的节点数据,传入_VerticesBuffer,调用Graphics.DrawProcedural方法进行绘制。 Shader的顶点着色器根据SV_VERTEXID计算出每个顶点对应的线段序号lineID和顶点序号vertexID。 通过线段序号、顶点序号和vertexIndexes从_VerticesBuffer中找到较近的线段节点的世界坐标,和较远的线段节点的世界坐标。 将两个世界坐标转换到屏幕空间,得到屏幕空间两点对应的向量和与之相垂直的向量,进行归一化。 将每个顶点根据vertexOffsets和描边宽度进行外扩。 将外扩后的裁剪空间的坐标,从vertexUVs中获得的uv和高宽比ratio传给片元着色器。 片元着色器根据ratio和uv,绘制出一个两头圆形的线段。 DrawEqualWidthLine.cs C#脚本比较简单了,没什么特别需要注意的地方。 using UnityEngine; public class DrawEqualWidthLine : MonoBehaviour { public Vector3[] vertices; private int vertexCount; public Material equalWidthMaterial; ComputeBuffer verticesBuffer; private void EnsureBuffer(ref ComputeBuffer buffer, int count, int stride) { if (buffer == null) { buffer = new ComputeBuffer(count, stride, ComputeBufferType....

December 2, 2021 · zznewclear13
Temporal Anti-Aliasing Cover

在Unity SRP中实现TAA效果

TAA的原理 首先是要了解画面上的锯齿是如何产生的。锯齿发生在光栅化的阶段,光栅化的时候会丢失掉小于一个像素宽的细节,也就导致了锯齿的产生。 从字面上来看,TAA (Temporal Anti-Aliasing)的抗锯齿效果来源于Temporal一词,是一种时间上的抗锯齿。TAA会结合当前渲染的画面和之前渲染的画面,通过这两个画面之间的融合,达成抗锯齿的效果。基本思想是在光栅化的时候对画面进行抖动,让亚像素的细节在不同帧渲染到不同的像素上,最后再对这些像素按时间的权重来混合,就能达到抗锯齿的效果。 Temporal Reprojection Anti-Aliasing Temporal Reprojection Anti-Aliasing是由PlayDead在他们的游戏Inside中使用的一种TAA的方法,他们在GDC2016的演示中分享了这个方法。相较于普通的TAA来说,Temporal Reprojection Anti-Aliasing中使用了Velocity Buffer中的屏幕像素的速度信息和Depth Buffer中对应的屏幕像素的世界坐标信息,这样当物体移动或者相机移动的时候,在做到抗锯齿的同时也减少了TAA带来的拖影效果,同时也把TAA和运动模糊相结合达到更理想的抗锯齿的效果。 PlayDead提供了对应的源代码。本博客中TAA在SRP中的实现也参考了sienaiwun的TAA代码。 在Unity SRP中实现TAA的操作 我们通过RendererFeature的方式在渲染管线中加入TAA。在ForwardRendererData中加入RendererFeature后,往Global Volume中添加Temporal Anti-Aliasing以在场景中启用TAA效果。启用TAA效果后,会现在渲染不透明物体之前调用一个Jitter Pass对相机的栅格化阶段进行抖动;在渲染TAA Pass时(在Bloom等跟物体渲染相关的后处理效果之后,在Chromatic Aberration等跟屏幕空间位置相关的后处理效果之前)根据抖动值还原出正常的不抖动的画面,并和AccumTexture进行混合,获得最终的渲染画面。因此我们需要TAARendererFeature、TAAJitterPass、TAARenderPass这三个脚本来处理渲染管线,TemporalAntiAliasing这个脚本来处理Volume,TAAShader这个Shader文件来进行TAA的混合操作。 对栅格化阶段进行抖动,也就相当于是修改了相机的透视变换矩阵的第一第二行的第三位的值,抖动值最好和TexelSize相结合,这样在TAA反向抖动还原正常值的时候,在shader中会比较好写。抖动值和TAA的反向抖动是正比关系,因此可以不需要特别纠结于计算,在shader中传入一个debug值再和抖动值相乘用作反向抖动,观察最后的画面是否存在抖动,就能很好的判断出这两个值的比例了。抖动的方式有很多,纯随机的抖动也可以选择,不过稍不如使用均匀分布的随机抖动的效果好,这里使用Inside中的方式即利用Halton数列进行抖动。 为了让相机移动时也能有较好的抗锯齿效果且削弱拖影现象,Temporal Reprojection Anti-Aliasing需要采样当前的深度贴图,还原出物体的世界空间的坐标,再计算出这个世界空间在AccumTexture中的UV值(Reprojection),使用这个UV值采样AccumTexture再和当前渲染画面进行融合。 因为Velocity Buffer比较麻烦,这里暂且忽略掉物体移动对TAA带来的影响。 在ScriptableRenderPass中使用cmd.GetTemporaryRT()获得的Render Texture,在当帧过后就会被回收,因此AccumTexture需要使用RenderTexture.GetTemporary()来获取。这里我把AccumTexture放在TemporalAntiAliasing.cs中,方便使用。 TemporalAntiAliasing.cs 除了普通的Volume的设置之外,还需要提供Render Texture的接口。lastFrame的x值和y值分别对应最后渲染画面中对AccumTexture进行线性插值的最小和最大系数。 using System; namespace UnityEngine.Rendering.Universal { [Serializable, VolumeComponentMenu("Post-processing/Temporal Anti-Aliasing")] public class TemporalAntiAliasing : VolumeComponent, IPostProcessComponent { public BoolParameter isEnabled = new BoolParameter(false); public NoInterpFloatRangeParameter lastFrame = new NoInterpFloatRangeParameter(new Vector2(0.2f, 0.8f), 0f, 1f); public Vector2Parameter jitterIntensity = new Vector2Parameter(Vector2....

July 15, 2021 · zznewclear13
zznewclear13 技术美术 图形学 个人博客 technical art computer graphics