动机
直接动机是想要在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.Structured);
}
else if (buffer.count != count || buffer.stride != stride)
{
buffer.Release();
buffer = new ComputeBuffer(count, stride, ComputeBufferType.Structured);
}
}
private void Awake()
{
vertexCount = vertices.Length;
EnsureBuffer(ref verticesBuffer, vertexCount, 3 * 4);
}
private void Update()
{
if(vertexCount != vertices.Length)
{
vertexCount = vertices.Length;
EnsureBuffer(ref verticesBuffer, vertexCount, 3 * 4);
}
Bounds bounds = new Bounds(Vector3.zero, Vector3.one * 100.0f);
verticesBuffer.SetData(vertices);
MaterialPropertyBlock mpb = new MaterialPropertyBlock();
mpb.SetBuffer("_VerticesBuffer", verticesBuffer);
Graphics.DrawProcedural(equalWidthMaterial, bounds, MeshTopology.Triangles, (vertexCount - 1) * 6, properties: mpb);
}
void OnDestroy()
{
verticesBuffer.Dispose();
}
}
EqualWidthLineShader.shader
基本上需要注意的都写在shader的注释里面了,使用_OutlineColor
来控制线段的颜色,_OutlineWidth
来控制线段的宽度,_Sharpness
来控制锐利程度以达到抗锯齿的效果。
特别要注意的就是屏幕比例的问题,要确保在屏幕中两个偏移方向是互相垂直的,这样才能获得正确的新的裁剪空间的坐标,也才能获得正确的线段节点的uv值。还有要注意仿射变换和透视变换的区别,参考维基百科上的说明,决定了uv在屏幕空间中是不是线性的。
当然三个数组可以合并成一个,不过也没那个必要就是了。
Shader "zznewclear13/EqualWidthLineShader"
{
Properties
{
_OutlineColor ("Outline Color", color) = (1.0, 1.0, 1.0, 1.0)
_OutlineWidth("Outline Width", float) = 10.0
_Sharpness("Sharpness", range(0, 0.99)) = 0.5
}
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
StructuredBuffer<float3> _VerticesBuffer;
float4 _OutlineColor;
float _OutlineWidth;
float _Sharpness;
static int vertexIndexes[] =
{
0, 1, 1, 0, 1, 0
};
static float2 vertexOffsets[] =
{
float2(-1, -1),
float2(-1, 1),
float2(-1, -1),
float2(-1, -1),
float2(-1, -1),
float2(-1, 1),
};
static float2 vertexUVs[] =
{
float2(0, 0),
float2(1, 0),
float2(1, 1),
float2(0, 0),
float2(1, 1),
float2(0, 1),
};
struct Attributes
{
uint vertexID : SV_VERTEXID;
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float ratio : TEXCOORD1;
};
Varyings Vert(Attributes input)
{
Varyings output = (Varyings)0;
int vertexID = input.vertexID % 6;
int lineID = input.vertexID / 6;
//获取较近的线段节点的世界坐标,和较远的线段节点的世界坐标
float3 vertexPos = _VerticesBuffer[lineID + vertexIndexes[vertexID]];
float3 anotherVert = _VerticesBuffer[lineID + 1 - vertexIndexes[vertexID]];
//转换到裁剪空间
float4 positionCS = mul(UNITY_MATRIX_VP, float4(vertexPos, 1.0));
float4 anotherCS = mul(UNITY_MATRIX_VP, float4(anotherVert, 1.0));
//Unity相机是看向Z轴负方向的,所以裁剪空间w分量小于0
//这里就不需要用从远的节点减去近的节点了
float2 offset = positionCS.xy / positionCS.w - anotherCS.xy / anotherCS.w;
//得到长方形宽度
float lengthOffset = length(offset);
//要考虑到屏幕的比例
float2 normalizedOffset = normalize(offset * (_ScreenParams.wz - 1.0));
float2 pointOffset = vertexOffsets[vertexID];
float2 pointOffsetX = float2(normalizedOffset.x, normalizedOffset.y) * pointOffset.x;
float2 pointOffsetY = float2(-normalizedOffset.y, normalizedOffset.x) * pointOffset.y;
//考虑到屏幕的比例,根据上面的两个Offset,得到新的裁剪空间的坐标
float4 newClipPos = float4(-positionCS.w * (pointOffsetX + pointOffsetY) * (_ScreenParams.zw - 1.0) * _OutlineWidth, 0, 0) + positionCS;
//算的不一定对,不过也大差不差了。。
float lengthRadius = _OutlineWidth * length(pointOffsetX * (_ScreenParams.zw - 1.0));
output.positionCS = newClipPos;
//如果不乘上深度的话,会有透视变形的问题
//https://en.wikipedia.org/wiki/Texture_mapping#Affine_texture_mapping
output.uv = vertexUVs[vertexID] * (-newClipPos.w);
output.ratio = 2.0 * lengthRadius / (2.0 * lengthRadius + lengthOffset);
return output;
}
float GetDist(float2 uv, float ratio)
{
uv.x = 0.5 - abs(0.5 - uv.x);
float2 coord = float2(uv.x * rcp(ratio), uv.y);
float2 center = float2(0.5, 0.5);
float distToCenter = length(coord - center);
float distToLine = abs(uv.y - 0.5);
return coord.x < 0.5 ? distToCenter : distToLine;
}
float4 Frag(Varyings input) : SV_TARGET
{
float2 uv = input.uv / (-input.positionCS.w);
float distValue = GetDist(uv, input.ratio);
//用smoothstep进行抗锯齿
distValue = smoothstep(0.5, _Sharpness * 0.5, distValue);
return float4(_OutlineColor.rgb, distValue * _OutlineColor.a);
}
ENDHLSL
SubShader
{
Tags {"Queue"="Transparent" "RenderType"="Transparent" }
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
HLSLPROGRAM
#pragma vertex Vert
#pragma fragment Frag
ENDHLSL
}
}
}
结语
我对目前的效果还是很满意的!也没看到有什么人做这个画线的操作(除了Shapes,当然我也没去搜索别的案例就是了)。感觉对绘制顶点时的处理和空间变换的理解又大大加深了!下一个目标就是描边的计算了,目前感觉上会和原始模型有深度测试的问题,只能走一步看一步了。
这段时间一直在思考景深和写ShaderToy,也算收获不少,不过没能写出博客来,拿这个等宽且抗锯齿的世界坐标直线来弥补一下。