卡通渲染也叫非真实感渲染(英文简写:NPR),"描边"在图形学和数字图像里都叫边缘检测。因此你可以在很多文献网站上面找到很多这类文献,但最后我发现基于图形学使用的方式基本都是类似的,没有太多特别的做法。最后忙着研究unity3d的坑,都忘记记下来了,只保留了几个映像比较深的文献。有误请指正。
很长一段时间我在网上找到的描边法,给我的感觉都是或多或少地漏掉很多边缘,然后我开始反思这边缘是不是有很多种类,并尝试对边缘进行分类。仔细翻阅文献就会发现3d模型的边缘在学术上分成四类,参考文献[1],这里直接引用。
实际中在非真实感上用得很多的“背面法”在很多文献上都有提及,但我们会发现“背面法”是没办法把模型的所有边缘都获取到,这种方法在很多文献上被记载只能得到模型的轮廓边缘。
四种边缘都有各自的检测方法,这一部分的研究也已经有前人做了。这里直接引用文献[1]的部分。
经过上面两个部分,就可以看出3d模型的边缘其实很简单,只需要知道一条边的两个相邻面可以完成除材质边缘的其他三类边缘检测。但我们也能看出,这些方法是基于模型三角面的三条边进行检测,因此很容易得到模型精度过低无法使用的结论,对于这类模型很多人是直接画在贴图上。
渲染的3个主要对象,网格mesh、材质material、着色器shader,本文不做阐述。通过这3个对象我们有很多做边缘检测的方法,这里就使用着色器shader。在着色器中,有一个着色器叫几何着色器,它提供一个三角图元(面)相邻的另外3个三角图元(面),这正好符合我们的需求。
在directx的手册上几何着色器支持这几种类型的输入:
(1). point:输入的图元为点。
(2). line:输入的图元为线。
(3). triangle:输入的图元为三角形。
(4). lineadj:输入的图元为相邻的线段。
(5). triangleadj:输入的图元为相邻的三角形。
很自然地,我们会直接开始着手去写我们的程序,但在中途你会很不幸地发现unity3d的几何着色器的支持度貌似并不完善,它不支持triangleadj相邻三角图元作为输入。因此我通过搜索在国内找到了一些讨论,但数量极少,随后我跑去unity3d的官网搜索,找到了一些相关的讨论,参考文献[6][7]。
再仔细研究会发现unity3d的几何着色器仅且执行mesh.triangles.Length / 3次,unity3d的几何着色器输入如下:
第1次调用几何着色器---mesh.vertices[0]~mesh.vertices[2]
第2次调用几何着色器---mesh.vertices[3]~mesh.vertices[5]
第3次调用几何着色器---mesh.vertices[6]~mesh.vertices[8]
…………
你指明point作为输入,那么得到输入是:
第1次调用几何着色器---mesh.vertices[0]
第2次调用几何着色器---mesh.vertices[3]
第3次调用几何着色器---mesh.vertices[6]
…………
如果指明是triangleadj作为输入
第1次调用几何着色器---mesh.vertices[0]~mesh.vertices[2]和3个(0, 0, 0, 0)
第2次调用几何着色器---mesh.vertices[3]~mesh.vertices[5]和3个(0, 0, 0, 0)
第1次调用几何着色器---mesh.vertices[6]~mesh.vertices[8]和3个(0, 0, 0, 0)
…………
正常情况下应该是支持下图的输入方式,可以去阅读一下文献[9]。
经过3.1节,我们知道unity的几何着色器并不能给我们相邻图元的信息,但我还是要使用几何着色器,因为它能提供三角图元,这样在实现上会比较直观,剩下的只能用cpu做了。
因为几何着色器的输入是三角图元,因此想办法在几何着色器找到三角图元对应边相邻的另外一个三角图元就可以得到一条边的两个相邻面的关键信息了,这个关键信息这里取三角面的法向量。
Project窗口右键新建一个c#脚本
删掉void Start()和void Update(),添加如下代码。
void Awake() {
SkinnedMeshRenderer renderer = GetComponent();
mesh = renderer.sharedMesh;
Material material = renderer.material;
if (mesh == null) {
return;
}
Color[] colors = new Color[mesh.vertexCount];
for (int i = 0; i < mesh.vertexCount; i++) {
colors[i].r = 1.0f * i / (mesh.vertexCount - 1);
}
mesh.colors = colors;
var normaladj1 = new Dictionary();
var normaladj2 = new Dictionary();
int length = mesh.triangles.Length / 3;
for (int i = 0; i < length; i++) {
int point1_index = mesh.triangles[i * 3];
int point2_index = mesh.triangles[i * 3 + 1];
int point3_index = mesh.triangles[i * 3 + 2];
AddNormal(point1_index, point2_index, point3_index, normaladj1, normaladj2);
AddNormal(point2_index, point3_index, point1_index, normaladj1, normaladj2);
AddNormal(point3_index, point1_index, point2_index, normaladj1, normaladj2);
}
Texture2D texture = new Texture2D(mesh.vertexCount, mesh.vertexCount, TextureFormat.RGB24, false);//这里没写好
for (int i = 0; i < mesh.vertexCount; i++) {
for (int j = 0; j < mesh.vertexCount; j++) {
string line_index = i + "-" + j;
Color color = new Color();
if (normaladj1.ContainsKey(line_index)) {
color.r = (normaladj1[line_index][0].x + 1.0f) / 2.0f;
color.g = (normaladj1[line_index][0].y + 1.0f) / 2.0f;
color.b = (normaladj1[line_index][0].z + 1.0f) / 2.0f;
} else if (normaladj2.ContainsKey(line_index)) {
color.r = (normaladj2[line_index][1].x + 1.0f) / 2.0f;
color.g = (normaladj2[line_index][1].y + 1.0f) / 2.0f;
color.b = (normaladj2[line_index][1].z + 1.0f) / 2.0f;
} else {
color.r = 0.0f;
color.g = 0.0f;
color.b = 0.0f;
}
texture.SetPixel(i, j, color);
}
}
texture.filterMode = FilterMode.Point;
texture.Apply();
material.SetTexture("_AdjacentNormal", texture);
}
void AddNormal(int point1_index, int point2_index, int point3_index, Dictionary normaladj1, Dictionary normaladj2) {
Vector3 point1 = vertices[point1_index];
Vector3 point2 = vertices[point2_index];
Vector3 point3 = vertices[point3_index];
Vector3 v1 = point2 - point1;
Vector3 v2 = point3 - point1;
Vector3 normal = Vector3.Cross(v2, v1);
normal.Normalize();
string line_index1 = point1_index + "-" + point2_index;
string line_index2 = point2_index + "-" + point1_index;
if (normaladj1.ContainsKey(line_index1) || normaladj2.ContainsKey(line_index1)) {
if (normaladj1.ContainsKey(line_index1)) {
normaladj1[line_index1][1] = normal;
} else {
normaladj2[line_index1][1] = normal;
}
} else if (!normaladj1.ContainsKey(line_index1) && !normaladj2.ContainsKey(line_index1)) {
Vector3[] face_normals = new Vector3[2];
face_normals[0] = normal;
normaladj1[line_index1] = face_normals;
normaladj2[line_index2] = face_normals;
}
}
把c#脚本添加给需要描边的物体,代码可能不适用所有物体,mesh和material应该按照自己的方式查找,边的检测有点缺陷,会有很多重复边(有时间我再改进)。
几何着色器的输入是三角图元,那么如何在几何着色器里得到一条边的另外一个三角图元的法向量呢?这里用了最笨的方法,直接在每个顶点的color的r通道上写入了一个点的唯一标识,然后两点就可以组成边的唯一标识。然后用这个边的唯一标识去获取贴图数据,这个贴图保存着边的另一个邻接面的法向量。
但这空间代价很大,如果有100个顶点,那么就要用100*100的贴图去保存法向量。而实际顶点的数量可以轻松破万,有个办法就是把网格mesh切分成很多小网格,然后套用这方法,但不实际(有...有时间我再改进)。
然后就是着色器的编码,思路是:从贴图里找到边的另外一个三角图元的法向量,然后做检测。
Project窗口右键新建一个着色器(Shader)
具体编码就各自发挥了,这里贴上我的代码:
WireFrameShader.shader
Shader "Unlit/WireFrameShader" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
_AdjacentNormal ("边邻接法向量", 2D) = "black" {}
_AngleSensitive ("折缝边缘敏感度", Range(0, 1.57)) = 1.04719755120
_BoundarySensitive ("边界边缘敏感度", Range(0, 0.1)) = 0.01
}
SubShader {
Tags { "RenderType"="Opaque" }
LOD 100
// 正常渲染
Pass {
Cull Back
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata {
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f {
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v) {
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target {
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
// 描边渲染
Pass {
Cull Back
CGPROGRAM
#pragma target 5.0
#pragma vertex vert
#pragma fragment frag
#pragma geometry geom
#include "UnityCG.cginc"
#include "WireFrame.cg"
ENDCG
}
}
}
到工程目录新建一个文本文件WireFrame.cg
sampler2D _AdjacentNormal;
float4 _AdjacentNormal_ST;
float _AngleSensitive;
float _BoundarySensitive;
struct appdata {
float4 vertex : POSITION;
float4 color : COLOR;
float3 normal : NORMAL;
};
struct v2g {
float4 vertex : POSITION;
float4 color : COLOR;
float3 normal : NORMAL;
};
struct g2f {
float4 vertex : SV_POSITION;
float4 color : COLOR;
};
//顶点着色器
v2g vert(appdata v) {
v2g o;
o.vertex = v.vertex;
o.color = v.color;
o.normal = v.normal;
return o;
}
void paint_line(v2g point1, v2g point2, float3 view_dir, inout LineStream stream) {
g2f o;
float2 adjacent_normal1_uv = float2(point1.color.r, point2.color.r);
float2 adjacent_normal2_uv = float2(point2.color.r, point1.color.r);
float3 adjacent_normal1 = tex2Dlod(_AdjacentNormal, float4(adjacent_normal1_uv, 0, 0)) * 2.0 - 1.0;
float3 adjacent_normal2 = tex2Dlod(_AdjacentNormal, float4(adjacent_normal2_uv, 0, 0)) * 2.0 - 1.0;
// 边界边缘,只有1个邻接面,adjacent_normal2采样出来是(0, 0, 0)向量
// 因此如果2个面的法向量夹角大于等于90度也会被认为是边界边缘
float edge = dot(adjacent_normal1, adjacent_normal2) < _BoundarySensitive ? 1 : 0;
// 轮廓边缘
if (edge == 0) {
edge = dot(adjacent_normal1, view_dir) * dot(adjacent_normal2, view_dir) < 0 ? 1 : 0;
}
// 折缝边缘
if (edge == 0) {
//cos(x) 返回[-1, 1] pow(x, y)x的y次方
//pow(dot(adjacent_normal1, adjacent_normal2) / cos(degree), 2)在[0, PI/2]上单调递增, 可避免开方
edge = dot(adjacent_normal1, adjacent_normal1) * dot(adjacent_normal2, adjacent_normal2) > pow(dot(adjacent_normal1, adjacent_normal2) / cos(_AngleSensitive), 2.0) ? 1 : 0;
}
//沿法线方向平移一点,防止边陷入模型中。
point1.vertex.xyz = point1.vertex.xyz + point1.normal * 0.01;
point2.vertex.xyz = point2.vertex.xyz + point2.normal * 0.01;
o.color = float4(0, 0, 0, 0);
o.vertex = UnityObjectToClipPos(point1.vertex) * edge;
stream.Append(o);
o.color = float4(0, 0, 0, 0);
o.vertex = UnityObjectToClipPos(point2.vertex) * edge;
stream.Append(o);
stream.RestartStrip();
}
//几何着色器
[maxvertexcount(6)]
void geom(triangle v2g input[3], inout LineStream stream) {
float4 center_point = input[0].vertex + input[1].vertex + input[2].vertex;
float3 view_dir = ObjSpaceViewDir(center_point / 3.0); // 以输入的三角图元的中心为视线方向
paint_line(input[0], input[1], view_dir, stream);
paint_line(input[1], input[2], view_dir, stream);
paint_line(input[2], input[0], view_dir, stream);
}
//片元着色器
fixed4 frag(g2f input) : SV_Target {
return fixed4(0, 0, 0, 0);
}
之后新建一个材质ToonChanMat,材质选择我们新建的着色器WireFrameShader,给物体指定好这个材质。
运行效果如下:
有锯齿,但处理简单,和普通的锯齿一样的处理即可。用贴图就能去掉多余的边,因为边是三角图元2个顶点画出来的,而点又保存有贴图的uv坐标,所以可以用一个贴图就能很好的去掉不要的线,这里我没做了(偷懒)。
这种基于空间物体的描边效果比较好,很多细节都已经被勾出来了,而且还可控。但这是静态的描边,当模型加上动画时描边还是很困难。因为模型动画在改变顶点的位置,那么边相邻的两个面的法向量应该也在改变,要实现模型播放动画的时候也要表现出比较好的描边效果就必须去更新那个巨大的贴图。
如果只是静态的描边的话,那么就不需要那个巨大的贴图了,直接在模型初始化的时候检测出所有边,然后给每个顶点的color的r通道写入一个标识表明这是边的一个点,然后再几何着色器遍历三角图元的时候只要每2个点都有这个标识就表明他是边缘,直接绘制即可,崩坏3的细节描边应该就是用的这个思想吧。
然而,最近我发现了Unity3d可以自定义渲染管线,指令buff(CommandBuffer),下篇文章《Unity Shader 卡通渲染 模型描边之退化四边形》打算使用这个工具把本文众多缺陷都优化掉。
文献:
[1]基于着色器技术实时卡通渲染的的研究
[2]基于GPU实时非真实感渲染的研究与实现
[3]3D日式卡通人物渲染的经验分享
[4]Unity网格编程篇(二) 非常详细的Mesh编程入门文章
[5]Unity Shader入门精要
[6]Geometry Shader Input: GL_TRIANGLES_ADJACENCY
[7]Adjacency information in geometry shaders
[8]Direct X 12 – Geometry Shader 几何着色器
[9]OpenGL 图元处理