对于描边的思考
描边可以说是一个特别关键的效果,不仅仅是二次元卡通渲染需要用到描边,在用户交互的方面,描边也是一个增强用户交互的关键效果。
一般的描边的做法是绘制一个沿物体空间顶点法线(或是记录在顶点色中的描边方向)外扩的模型背面,这种做法在绝大部分情况都看上去不错,但是描边的深度测试会有一些小瑕疵,同时在物体距离摄像机较近的时候,描边会显得较粗,此外这种描边没有抗锯齿的效果,绘制模型的背面也让造成了性能的浪费。另外一种方法是使用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.Serializable]
public struct Line
{
//Line
public int v0;
public int v1;
//Triangle One: v0, v1, v2
public int t0;
public int v2;
//Triangle Two: v0, v3, v1
public int t1;
public int v3;
public Line(int _v0, int _v1)
{
v0 = _v0;
v1 = _v1;
t0 = -1;
t1 = -1;
v2 = -1;
v3 = -1;
}
//重载了Equals方法
//两个三角形使用同一条边时,边的节点的顺序是相反的
//GetHashCode()不会写,也没必要写 :)
public override bool Equals(object obj)
{
if (!(obj is Line line))
{
return false;
}
return v0 == line.v1 && v1 == line.v0;
}
}
}
ModelPreProcess.cs
用于模型预处理,生成并保存模型的共用边的信息。
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
namespace ZZNEWCLEAR13.Outline
{
public class ModelPreProcess : EditorWindow
{
//当两个顶点之间距离小于EPSILON时,认为是同一个顶点
const float EPSILON = 0.00001f;
public GameObject fbxObj;
public string saveName = "OutlineInfo";
private Rect topToolBarRect
{
get { return new Rect(20, 10, position.width - 40, 120); }
}
[MenuItem("zznewclear13/Model Pre-process")]
public static ModelPreProcess GetWindow()
{
ModelPreProcess window = GetWindow<ModelPreProcess>();
window.titleContent = new GUIContent("Model Pre-process");
window.Focus();
window.Repaint();
return window;
}
private void OnGUI()
{
TopToolBar(topToolBarRect);
}
private void TopToolBar(Rect rect)
{
using (new GUILayout.AreaScope(rect))
{
fbxObj = (GameObject)EditorGUILayout.ObjectField("FBX Object", fbxObj, typeof(GameObject), false);
saveName = EditorGUILayout.TextField("Save Name", saveName);
using (new EditorGUI.DisabledGroupScope(!fbxObj))
{
if (GUILayout.Button("Process!", new GUILayoutOption[] { GUILayout.Height(30f) }))
{
ProcessAll();
}
}
}
}
private void ProcessAll()
{
MeshFilter[] meshFilters = fbxObj.GetComponentsInChildren<MeshFilter>();
SkinnedMeshRenderer[] skinnedMeshRenderers = fbxObj.GetComponentsInChildren<SkinnedMeshRenderer>();
int meshFilterLength = meshFilters.Length;
OutlineObject.MeshOutlineInfo[] mois = new OutlineObject.MeshOutlineInfo[meshFilterLength + skinnedMeshRenderers.Length];
for (int i = 0; i < meshFilters.Length; i++)
{
mois[i] = ProcessMesh(meshFilters[i].sharedMesh);
}
for (int i = 0; i < skinnedMeshRenderers.Length; i++)
{
mois[i + meshFilterLength] = ProcessMesh(skinnedMeshRenderers[i].sharedMesh);
}
SaveAsset(mois);
}
//找出其中的共同边,并储存三角形序号和另外两个顶点的序号
//注意顶点的顺序
//v0, v1, v2是一个正面的三角形
//v0, v3, v1是一个正面的三角形
private void CheckLine(int triangleIndex, int vertexIndex, Line line, ref List<Line> lineList, ref List<Line> commonLines)
{
bool hasLine = false;
int lineListIndex = -1;
for (int i = 0; i < lineList.Count; i++)
{
if (line.Equals(lineList[i]))
{
hasLine = true;
lineListIndex = i;
break;
}
}
if (hasLine)
{
Line tempLine = lineList[lineListIndex];
lineList.RemoveAt(lineListIndex);
tempLine.t1 = triangleIndex;
tempLine.v3 = vertexIndex;
commonLines.Add(tempLine);
}
else
{
line.t0 = triangleIndex;
line.v2 = vertexIndex;
lineList.Add(line);
}
}
//当两个顶点距离很近时,视做同一个顶点
//使用vertRemapping储存相同顶点的第一个顶点的序号
private int[] MergeIndexes(Vector3[] vertices)
{
int[] vertRemapping = new int[vertices.Length];
vertRemapping[0] = 0;
for (int i = 1; i < vertices.Length; i++)
{
bool hasVert = false;
for (int j = 0; j < i; j++)
{
if (vertRemapping[j] < j)
{
continue;
}
else
{
if ((vertices[i] - vertices[vertRemapping[j]]).magnitude < EPSILON)
{
vertRemapping[i] = vertRemapping[j];
hasVert = true;
}
}
}
if (!hasVert)
{
vertRemapping[i] = i;
}
}
return vertRemapping;
}
private OutlineObject.MeshOutlineInfo ProcessMesh(Mesh sharedMesh)
{
OutlineObject.MeshOutlineInfo moi = new OutlineObject.MeshOutlineInfo();
moi.meshName = sharedMesh.name;
Vector3[] vertices = sharedMesh.vertices;
Vector3[] normals = sharedMesh.normals;
Vector4[] tangents = sharedMesh.tangents;
Color[] colors = sharedMesh.colors;
moi.vertices = vertices;
moi.normals = normals;
moi.tangents = tangents;
moi.colors = colors;
int[] vertRemapping = MergeIndexes(vertices);
moi.vertRemapping = vertRemapping;
int[] triangles = sharedMesh.triangles;
List<Line> lineList = new List<Line>();
List<Line> commonLines = new List<Line>();
System.Diagnostics.Debug.Assert(triangles.Length % 3 == 0);
int triangleCount = triangles.Length / 3;
Vector3Int[] packedTriangles = new Vector3Int[triangleCount];
//遍历所有的三角形,注意边的两个顶点的顺序
for (int i = 0; i < triangleCount; i++)
{
int triangleIndex = 3 * i;
int vID0 = vertRemapping[triangles[triangleIndex]];
int vID1 = vertRemapping[triangles[triangleIndex + 1]];
int vID2 = vertRemapping[triangles[triangleIndex + 2]];
packedTriangles[i] = new Vector3Int(vID0, vID1, vID2);
Line line0 = new Line(vID0, vID1);
Line line1 = new Line(vID1, vID2);
Line line2 = new Line(vID2, vID0);
CheckLine(i, vID2, line0, ref lineList, ref commonLines);
CheckLine(i, vID0, line1, ref lineList, ref commonLines);
CheckLine(i, vID1, line2, ref lineList, ref commonLines);
}
moi.triangles = packedTriangles;
moi.commonLines = commonLines.ToArray();
return moi;
}
private void SaveAsset(OutlineObject.MeshOutlineInfo[] outlineInfos)
{
string path = AssetDatabase.GetAssetPath(fbxObj);
string assetPath = path.Substring(0, path.LastIndexOf('/')) + "/" + saveName;
if (!System.IO.Directory.Exists(assetPath))
{
System.IO.Directory.CreateDirectory(assetPath);
}
for (int i = 0; i < outlineInfos.Length; i++)
{
OutlineObject asset = ScriptableObject.CreateInstance<OutlineObject>();
asset.outlineInfo = outlineInfos[i];
string tempPath = assetPath + "/" + outlineInfos[i].meshName + ".asset";
AssetDatabase.CreateAsset(asset, tempPath);
}
AssetDatabase.SaveAssets();
}
}
}
DrawOutline.cs
这里以SkinnedMeshRenderer
为例,因为相对于普通的Mesh
来说较为复杂,不能直接使用保存在共用边信息里的物体空间的顶点坐标。
using System.Collections.Generic;
using UnityEngine;
namespace ZZNEWCLEAR13.Outline
{
//仅支持SkinnedMeshRenderer
//普通的Mesh只要稍改一下代码就好了
[ExecuteInEditMode]
[RequireComponent(typeof(SkinnedMeshRenderer))]
public class DrawOutline : MonoBehaviour
{
public Material outlineMaterial;
public GameObject targetGO;
public SkinnedMeshRenderer skinnedMeshRenderer;
public OutlineObject outlineObject;
private Mesh bakedMesh;
private List<Vector3> meshVertices;
private Vector3[] vertices;
private Color[] colors;
private int[] vertRemapping;
private Line[] lines;
private ComputeBuffer verticesBuffer;
private ComputeBuffer colorBuffer;
private ComputeBuffer vertRemappingBuffer;
private ComputeBuffer lineBuffer;
private void EnsureBuffer(ref ComputeBuffer buffer, int count, int stride)
{
if (buffer != null)
{
buffer.Release();
}
buffer = new ComputeBuffer(count, stride, ComputeBufferType.Structured);
}
private void OnEnable()
{
meshVertices = new List<Vector3>();
vertices = outlineObject.outlineInfo.vertices;
colors = outlineObject.outlineInfo.colors;
vertRemapping = outlineObject.outlineInfo.vertRemapping;
lines = outlineObject.outlineInfo.commonLines;
EnsureBuffer(ref verticesBuffer, vertices.Length, 3 * 4);
EnsureBuffer(ref colorBuffer, vertices.Length, 4 * 4);
EnsureBuffer(ref vertRemappingBuffer, vertRemapping.Length, 4);
EnsureBuffer(ref lineBuffer, lines.Length, 6 * 4);
bakedMesh = new Mesh();
}
private void Update()
{
DrawOutlineProcedural();
}
private void DrawOutlineProcedural()
{
skinnedMeshRenderer.BakeMesh(bakedMesh);
bakedMesh.GetVertices(meshVertices);
verticesBuffer.SetData(meshVertices);
colorBuffer.SetData(colors);
vertRemappingBuffer.SetData(vertRemapping);
lineBuffer.SetData(lines);
MaterialPropertyBlock mpb = new MaterialPropertyBlock();
mpb.SetBuffer("_VerticesBuffer", verticesBuffer);
mpb.SetBuffer("_ColorBuffer", colorBuffer);
mpb.SetBuffer("_VertRemappingBuffer", vertRemappingBuffer);
mpb.SetBuffer("_LineBuffer", lineBuffer);
//一般需要传入SkinnedMeshRenderer的父物体的物体空间到世界空间的变换矩阵
mpb.SetMatrix("_ObjToWorldMat", targetGO.transform.localToWorldMatrix);
Bounds bounds = skinnedMeshRenderer.bounds;
Graphics.DrawProcedural(outlineMaterial, bounds, MeshTopology.Lines, lines.Length * 2, properties: mpb);
}
private void OnDestroy()
{
verticesBuffer.Dispose();
vertRemappingBuffer.Dispose();
lineBuffer.Dispose();
}
}
}
OutlineShader.shader
感觉已经事无巨细的写在注释里了,最关键的就是时刻提醒自己绘制三角形时的顶点顺序。感觉我对使用数组来设计并行运算已经炉火纯青了。使用线性代数来判断三角形的其余两个顶点是不是在共用边的两侧,我感觉是一个比较好的办法(似乎比直接计算法线要稍好一点?)。唯一的问题是发现效果基本正确之后就不会再回过头去验证自己的代码了哈哈。
Shader "zznewclear13/OutlineShader"
{
Properties
{
_OutlineColor ("Outline Color", color) = (1.0, 1.0, 1.0, 1.0)
_OutlineExt ("Outline Extension", range(-1, 1)) = 0.1
_OutlineWidth("Outline Width", float) = 1.0
}
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
float4x4 _ObjToWorldMat;
float4 _OutlineColor;
float _OutlineExt;
float _OutlineWidth;
StructuredBuffer<float3> _VerticesBuffer;
StructuredBuffer<int> _VertRemappingBuffer;
//实际可以简化成一个int4,只储存顶点序号
struct LineStruct
{
int2 lineVertices; // v0, v1
int4 trianglesAndVertices; // t0, v2, t1, v3
};
StructuredBuffer<LineStruct> _LineBuffer;
struct Attributes
{
uint vertexID : SV_VERTEXID;
};
struct Geoms
{
float4 positionCS : TEXCOORD0;
bool2 edgeAndSide : TEXCOORD1;
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
};
Geoms OutlineVert(Attributes input)
{
Geoms output = (Geoms)0;
//获取顶点序号和边的序号
int vertexID = input.vertexID % 2;
int lineID = input.vertexID / 2;
//获取边上较近的顶点和较远的顶点的物体空间坐标
float3 vertexOne = _VerticesBuffer[_VertRemappingBuffer[_LineBuffer[lineID].lineVertices[vertexID]]];
float3 vertexTwo = _VerticesBuffer[_VertRemappingBuffer[_LineBuffer[lineID].lineVertices[1 - vertexID]]];
//获取第一个三角面的最后一个顶点和第二个三角面的最后一个顶点
float3 vertexThree = _VerticesBuffer[_VertRemappingBuffer[_LineBuffer[lineID].trianglesAndVertices[vertexID * 2 + 1]]];
float3 vertexFour = _VerticesBuffer[_VertRemappingBuffer[_LineBuffer[lineID].trianglesAndVertices[3 - vertexID * 2]]];
//转换到裁剪空间
float4x4 mvp = mul(UNITY_MATRIX_VP, _ObjToWorldMat);
float4 positionCSOne = mul(mvp, float4(vertexOne, 1.0));
float4 positionCSTwo = mul(mvp, float4(vertexTwo, 1.0));
float4 positionCSThree = mul(mvp, float4(vertexThree, 1.0));
float4 positionCSFour = mul(mvp, float4(vertexFour, 1.0));
//获取屏幕空间的坐标
float2 uvOne = positionCSOne.xy / positionCSOne.w;
float2 uvTwo = positionCSTwo.xy / positionCSTwo.w;
float2 uvThree = positionCSThree.xy / positionCSThree.w;
float2 uvFour = positionCSFour.xy / positionCSFour.w;
//这里简化了求法线的过程,相当于判断两个三角面的最后一个顶点是不是在边的两侧
float valueA = uvOne.y - uvTwo.y;
float valueB = uvOne.x * uvTwo.y - uvTwo.x * uvOne.y;
float valueC = uvOne.x - uvTwo.x;
float signThree = valueA * uvThree.x + valueB - valueC * uvThree.y;
float signFour = valueA * uvFour.x + valueB - valueC * uvFour.y;
//X:该顶点是不是轮廓边的顶点;Y:该顶点应该向边的顺时针方向还是逆时针方向外扩
output.edgeAndSide = bool2((signThree * signFour >= 0), (signThree >= 0));
output.positionCS = positionCSOne;
return output;
}
[maxvertexcount(6)]
void OutlineGeomTriangle(line Geoms input[2], inout TriangleStream<Varyings> triangleStream)
{
Varyings output = (Varyings)0;
//判断是不是边界边,其实只需要判断一个顶点就可以了
if(input[0].edgeAndSide.x && input[1].edgeAndSide.x)
{
//可以通过顶点色来调整描边宽度
float outlineWidthOne = _OutlineWidth;// * input[0].color.a;
float outlineWidthTwo = _OutlineWidth;// * input[1].color.a;
float4 positionCSOne = input[0].positionCS;
float4 positionCSTwo = input[1].positionCS;
//获取屏幕空间的两个顶点之间的向量
float2 offset = positionCSOne.xy / positionCSOne.w - positionCSTwo.xy / positionCSTwo.w;
float lengthOffset = length(offset);
float2 normalizedOffset = normalize(offset * (_ScreenParams.wz - 1.0));
//X:从一个顶点到另一个顶点的向量
//Y:与之垂直的另一个向量
//使用min(1.0, rcp(positionCSOne.w)来使描边按照距离变细,这里也可以使用平方根倒数来控制
float2 pointOffsetX = float2(normalizedOffset.x, normalizedOffset.y) * min(1.0, rcp(positionCSOne.w));
float2 pointOffsetY = float2(-normalizedOffset.y, normalizedOffset.x) * min(1.0, rcp(positionCSOne.w));
//第一第二个顶点不用外扩
//第三第四个顶点按照edgeAndSide来判断外扩的方向
//同时使用_OutlineExt来控制外扩出的梯形的形状
float4 csOne = positionCSOne;
float4 csTwo = positionCSTwo;
float4 csThree = float4(-positionCSTwo.w * (pointOffsetX * (1.0 + _OutlineExt) + pointOffsetY * (2 * input[1].edgeAndSide.y - 1)) * (_ScreenParams.zw - 1.0) * outlineWidthTwo, 0, 0) + positionCSTwo;
float4 csFour = float4(-positionCSOne.w * (-pointOffsetX * (1.0 + _OutlineExt) - pointOffsetY * (2 * input[0].edgeAndSide.y - 1)) * (_ScreenParams.zw - 1.0) * outlineWidthOne, 0, 0) + positionCSOne;
//四个顶点的UV
float2 uvOne = float2(0.0, 0.0);
float2 uvTwo = float2(1.0, 0.0);
float2 uvThree = float2(1.0, 1.0);
float2 uvFour = float2(0.0, 1.0);
//绘制外扩梯形的其中三个顶点
output.positionCS = csOne;
output.uv = uvOne;
triangleStream.Append(output);
output.positionCS = csTwo;
output.uv = uvTwo;
triangleStream.Append(output);
output.positionCS = csThree;
output.uv = uvThree;
triangleStream.Append(output);
//出于我不能解释的原因,这里的顶点顺序和前面是相反的
output.positionCS = csOne;
output.uv = uvOne;
triangleStream.Append(output);
output.positionCS = csFour;
output.uv = uvFour;
triangleStream.Append(output);
output.positionCS = csThree;
output.uv = uvThree;
triangleStream.Append(output);
}
triangleStream.RestartStrip();
}
float4 OutlineFrag(Varyings input) : SV_TARGET
{
float2 uv = input.uv;
//使用fwidth进行抗锯齿
return float4(_OutlineColor.rgb, smoothstep(1.0, 1.0 - fwidth(uv.y), input.uv.y));
}
ENDHLSL
SubShader
{
Tags {"Queue"="Transparent" "RenderType"="Transparent" }
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
HLSLPROGRAM
#pragma target 5.0
#pragma vertex OutlineVert
#pragma geometry OutlineGeomTriangle
#pragma fragment OutlineFrag
ENDHLSL
}
}
}
最后的思考
本博客的模型是网上下载的,我也没有看过乐园追放,好像衣服部分的uv出了点问题,不过跟描边的效果没有关系,就这样了。
在模型导入的时候最好能选择中等质量,这样模型的面数会少一些,也不会出现因为美术失误导致奇怪的地方产生描边的问题。最终描边效果确实还蛮不错的,确实都是一样的宽,使用fwidth
的抗锯齿效果也蛮不错的,就是并没有把外描边和内描边分开来做(我也不知道该怎么做了),根据共用边两个三角面法线的夹角还能绘制额外的描边,这里就不额外做了(不过应该不太好做,深度测试的问题比较大),两种材质之间的描边也没做(完全不知道该怎么做)。但整体看上去还是挺可以的了,嘿嘿。