前言
一、基于空间的边缘检测算法
二、退化四边形
三、Unity中的CommandBuffer和ComputeBuffer
四、构成描边的简单实例
五、模型描边的实现
之前写了一篇《Unity Shader 卡通渲染 实时模型动画描边的研究》,因为对unity3d的渲染工具不太熟悉,用了一种又较笨、又不太现实的方法进行边缘检测,写得也不是很好。最近在搜索无意间搜到一篇文章了CommandBuffer.DrawProcedural examples,发现unity3d可以自定义渲染指令。简单的探究后,发现它可以让着色器使用自定义数据缓冲(ComputeBuffer)来绘制图形。
在《Unity Shader 卡通渲染 实时模型动画描边的研究》中提到的算法是基于空间几何的边缘检测算法。模型上一条边(构成三角面的边)是否要描绘出来由以下情况决定:
很容易得出:是否要绘制边需要它的相邻三角面信息,那么最少也要4个顶点。在《Unity Shader 卡通渲染 实时模型动画描边的研究》描述到了一种基于GPU的算法,把4个顶点作为顶点着色器的输入并绘制,这样就可以在顶点着色器运行。然而,文中并没有使用这个算法,可能当时文中使用的引擎不支持?
在Unity中是支持这种做法的,提供的工具是CommandBuffer和ComputeBuffer。
把复杂对象A当做简单对象B来简化运算,有一个形象的名字叫作“退化对象A”。这里是把四边形当做顶点来简化运算,所以这个(包含了4个顶点数据)四边形就叫“退化四边形”。因此退化四边形即可叫边,可叫2个三角面,也可叫4个顶点。
在动手前先来简单介绍下需要使用的工具类。
ComputeBuffer是GPU数据缓冲区,主要用于计算着色器(ComputeShader),可通过Material.SetBuffer在任意材质上使用。
CommandBuffer是一系列需要执行的图形指令,主要作用是扩展Unity3d渲染管线,可在Graphics.ExecuteCommandBuffer,Camera.AddCommandBuffer,Light.AddCommandBuffer上执行。
CommandBuffer有一个DrawProcedural绘制过程几何的方法,这个方法给顶点着色器的参数是SV_VertexID和SV_InstanceID,而不再是顶点数据。其中SV_VertexID代表的是索引号,这个索引号应该和通过Material.SetBuffer传入的某些ComputeBuffer一一对应。
在这里这些ComputeBuffer就是我们退化四边形的数据(4个顶点数据),一般的还会传入顶点ComputeBuffer、顶点法线ComputeBuffer等,或者其他任何你想要的数据。
完整的描边实现步骤并非一步到位,而是由各个简单的实例测试后拼接起来。
实例1:遍历模型找出所有退化四边形并保存起来。
退化四边形和三角面索引一样,模型建成后顶点的索引都不会改变,因此可保存,并且要求没有重复退化四边形(为了提高性能)。因此创建一个继承了ScriptableObject的DegradedRectangles类来完成这个实例。
下面给出DegradedRectangles.cs部分代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "DegradedRectanglesData", menuName = "Degraded Rectangles")]
public class DegradedRectangles : ScriptableObject {
public Mesh mesh;
public List degraded_rectangles;
[ContextMenu("Generate Degraded Rectangle")]
private void GenerateDegradedRectangle() {
if (mesh == null) {
Debug.LogError("mesh is null");
return;
}
int[] triangles = mesh.triangles;
Vector3[] vertices = mesh.vertices;
// 遍历Mesh.triangles来找到所有退化四边形,要求无重复
var custom_lines = new List();
int length = triangles.Length / 3;
for (int i = 0; i < length; i++) {
int vertex1_index = triangles[i * 3];
int vertex2_index = triangles[i * 3 + 1];
int vertex3_index = triangles[i * 3 + 2];
AddCustomLine(vertex1_index, vertex2_index, vertex3_index, vertices, custom_lines);//添加三角图元vertex1和vertex2构成的退化四边形(或叫边)
AddCustomLine(vertex2_index, vertex3_index, vertex1_index, vertices, custom_lines);//添加三角图元vertex2和vertex3构成的退化四边形(或叫边)
AddCustomLine(vertex3_index, vertex1_index, vertex2_index, vertices, custom_lines);//添加三角图元vertex3和vertex1构成的退化四边形(或叫边)
}
degraded_rectangles = new List(custom_lines.Count);
for (int i = 0; i < custom_lines.Count; i++) {
degraded_rectangles.Add(custom_lines[i].degraded_rectangle);
}
Debug.Log("成功生产退化四边形");
}
}
//退化四边形
[System.Serializable]
public struct DegradedRectangle {
public int vertex1;// 构成边的顶点1的索引
public int vertex2;// 构成边的顶点2的索引
public int triangle1_vertex3;// 边所在三角面1的顶点3索引
public int triangle2_vertex3;// 边所在三角面2的顶点3索引
}
//用边的2点来标识退化四边形,可方便去重,注意要重载==和!=
public class CustomLine {
public Vector3 point1;
public Vector3 point2;
public DegradedRectangle degraded_rectangle;
}
之后就可以在Unity3d编辑器里Project面板右键->Create->Degraded Rectangles来创建一个退化四边形资源文件。
然后通过如下步骤得到一个mesh的所有退化四边形
实例2:使用CommandBuffer和ComputeBuffer绘制一个简单线条图形
先创建必要的资源,1个名为ProceduralGeometry的材质、1个名为ProceduralGeometry的Unlit着色器、1个名为ProceduralGeometry的c#脚本。
给材ProceduralGeometry质球选上ProceduralGeometry着色器
创建一个空物体命名为ProceduralGeometry并挂上名为ProceduralGeometry的c#脚本。
现在开始进行代码编写
打开ProceduralGeometry.cs的c#脚本,写入如下代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
public class ProceduralGeometry : MonoBehaviour {
public Material prefab_material;
private CommandBuffer command_buffer;
private ComputeBuffer vertexs_buffer;
private Material material;
private int vertexs_count = 0;
private void OnEnable() {
if (prefab_material == null) {
return;
}
Release();
//实例化一个材质,避免互相影响
material = Instantiate(prefab_material);
command_buffer = new CommandBuffer();
//自行构造一些在一条直线上的顶点
vertexs_count = 4;
Vector3[] vertexs = new Vector3[vertexs_count];
vertexs[0] = new Vector3(0, 0, 0);
vertexs[1] = new Vector3(0, 0, 1);
vertexs[2] = new Vector3(0, 0, 2);
vertexs[3] = new Vector3(0, 0, 3);
// 3 * 4由Vector3中的结构决定,Vector3中由3个4位byte组成,所以是3 * 4
vertexs_buffer = new ComputeBuffer(vertexs_count, vertexs_count * 4, ComputeBufferType.Default);
vertexs_buffer.SetData(vertexs);
// 设置材质所用着色器中的_VertexsBuffer变量
material.SetBuffer("_VertexsBuffer", vertexs_buffer);
}
private void OnRenderObject() {
command_buffer.Clear();// 使用前先清空,因为DrawProcedural是添加指令不是重设指令
command_buffer.DrawProcedural(transform.localToWorldMatrix, material, 0, MeshTopology.LineStrip, vertexs_count);
Graphics.ExecuteCommandBuffer(command_buffer);
}
private void OnDestroy() {
Release();
}
private void OnDisable() {
Release();
}
private void Release() {
if (command_buffer != null) {
command_buffer.Release();
command_buffer = null;
}
if (vertexs_buffer != null) {
vertexs_buffer.Release();
vertexs_buffer = null;
}
}
}
打开ProceduralGeometry.shader着色器,写入如下代码:
Shader "Unlit/ProceduralGeometry"
{
Properties
{
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass {
Cull Back
CGPROGRAM
#include "UnityCG.cginc"
#pragma target 5.0
#pragma vertex vertex_shader
#pragma fragment fragment_shader
struct v2f {
float4 position : SV_POSITION;
};
StructuredBuffer _VertexsBuffer;
v2f vertex_shader(uint id : SV_VertexID, uint inst : SV_InstanceID) {
v2f o;
o.position = UnityObjectToClipPos(float4(_VertexsBuffer[id], 1.0f));
return o;
}
fixed4 fragment_shader(v2f i) : SV_Target {
return fixed4(0, 0, 0, 0);
}
ENDCG
}
}
}
在Inspector面板中给ProceduralGeometry脚本选上ProceduralGeometry材质,并允许,你可以得到一条沿着z轴的直线。
这里有个很有趣的实验,多加几个在一条直线上的顶点,把MeshTopology.LineStrip更改成MeshTopology.Lines你可以得到一些很有启发性的现象。
代码部分并不想多做介绍,具体细节是通过大量google案例和查阅官方文档得出,阐述它们并不是本文的目的。
本文直接在ProceduralGeometry.cs脚本上进行了大幅改动,你也可以重新建一个c#脚本,如下:
using UnityEngine;
using System.Runtime.InteropServices;
using UnityEngine.Rendering;
using System.Collections.Generic;
/*
思路是退化成点,输入到顶点着色器中,并在几何着色器等图元着色器进化成边
输入:
所有顶点,长度为vertices.length。
所有边的点1索引,长度为n。
所有边的点2索引,长度为n。
邻接面1除开边2点的点索引,长度为n。
邻接面2除开边2点的点索引,长度为n。
(注:退化四边形和三角面一样,模型建立完成后顶点的索引不会改变,因此可以保存。)
*/
[ExecuteInEditMode]
[RequireComponent(typeof(SkinnedMeshRenderer))]
public class ProceduralGeometry : MonoBehaviour {
public Material prefab_material; // 预置材质,用于实例化
public DegradedRectangles degraded_rectangles;// 退化四边形资源文件
private Mesh bake_mesh; // 用于接收动态网格
private Material material; // 动态网格用到的描边材质,由预置材质实例化生成
private SkinnedMeshRenderer mesh_renderer;
private CommandBuffer command_buffer; // 指令缓存
private List mesh_vertices; // 网格顶点,用来接收动态网格的顶点信息
private MaterialBufferManager buffer_manager; // 材质缓存管理器
private List cameras; // 用来清空指令缓存
private int degraded_rectangles_count = 0; // 退化四边形的个数
private bool is_visible = false;// 该动态网格是否可见
private CameraEvent camera_event = CameraEvent.AfterForwardOpaque;
private void OnEnable() {
if(prefab_material == null || degraded_rectangles == null) {
return;
}
ReleaseBuffer();
bake_mesh = new Mesh();
material = Instantiate(prefab_material);
mesh_renderer = GetComponent();
mesh_vertices = new List();
command_buffer = new CommandBuffer();
buffer_manager = new MaterialBufferManager(mesh_renderer.sharedMesh, degraded_rectangles.degraded_rectangles, material);
degraded_rectangles_count = buffer_manager.GetLines().count;
cameras = new List();
Camera.onPreCull += DrawWithCamera;
command_buffer.name = "Cartoon Line";// 让描边同时在Scene视图和Game视图显示
}
private void DrawWithCamera(Camera camera) {
mesh_renderer.BakeMesh(bake_mesh);
bake_mesh.GetVertices(mesh_vertices);
buffer_manager.GetVertices().SetData(mesh_vertices);
if (camera) {
if (!cameras.Contains(camera)) {
cameras.Add(camera);
camera.AddCommandBuffer(camera_event, command_buffer);
}
command_buffer.Clear();
if (is_visible) { // 模型可见时才进行描边
command_buffer.DrawProcedural(transform.localToWorldMatrix, material, 0, MeshTopology.Points, degraded_rectangles_count);
//Graphics.ExecuteCommandBuffer(command_buffer);
}
}
}
private void OnDestroy() {
ReleaseBuffer();
}
private void OnDisable() {
ReleaseBuffer();
}
private void ReleaseBuffer() {
if (cameras != null) {
for (int i = 0; i < cameras.Count; i++) {
var camera = cameras[i];
if (camera != null && command_buffer != null) {
camera.RemoveCommandBuffer(camera_event, command_buffer);
}
}
}
if (command_buffer != null) command_buffer.Release();
if (buffer_manager != null) buffer_manager.Release();
buffer_manager = null;
command_buffer = null;
Camera.onPreCull -= DrawWithCamera;
}
void OnBecameVisible() {
is_visible = true;
}
void OnBecameInvisible() {
is_visible = false;
}
}
// ComputeBuffer比较多,新建一个类来进行管理
public class MaterialBufferManager{
private ComputeBuffer vertices;
private ComputeBuffer normals;
private ComputeBuffer uvs;
private ComputeBuffer degraded_rectangles;
public MaterialBufferManager(Mesh mesh, List degraded_rectangles, Material material) {
Vector3[] vertices = mesh.vertices;
Vector3[] normals = mesh.normals;
Vector2[] uvs = mesh.uv;
this.normals = new ComputeBuffer(normals.Length, 12, ComputeBufferType.Default);// normals中每个元素都是3个4位的float, 所以是3 * 4 = 12
this.uvs = new ComputeBuffer(uvs.Length, 8, ComputeBufferType.Default);
this.degraded_rectangles = new ComputeBuffer(degraded_rectangles.Count, Marshal.SizeOf(typeof(DegradedRectangle)), ComputeBufferType.Default);
this.vertices = new ComputeBuffer(mesh.vertexCount, 12, ComputeBufferType.Default);
this.uvs.SetData(uvs);
this.normals.SetData(normals);
this.degraded_rectangles.SetData(degraded_rectangles);
// SetBuffer只需一次,后续直接操作ComputeBuffer即可
material.SetBuffer("_Normals", this.normals);
material.SetBuffer("_Uvs", this.uvs);
material.SetBuffer("_DegradedRectangles", this.degraded_rectangles);
material.SetBuffer("_Vertices", this.vertices);
}
~MaterialBufferManager() {
Release();
}
public ComputeBuffer GetVertices() {
return vertices;
}
public ComputeBuffer GetNormals() {
return normals;
}
public ComputeBuffer GetUvs() {
return uvs;
}
public ComputeBuffer GetLines() {
return degraded_rectangles;
}
public void Release() {
if(vertices != null) vertices.Release();
if(normals != null) normals.Release();
if(uvs != null) uvs.Release();
if(degraded_rectangles != null) degraded_rectangles.Release();
vertices = null;
normals = null;
uvs = null;
degraded_rectangles = null;
}
}
改动ProceduralGeometry.shader,下面给出部分代码:
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'
Shader "Unlit/ProceduralGeometry" {
Properties {
//_MainTex("Texture", 2D) = "white" {}
}
CGINCLUDE
StructuredBuffer _Vertices;
StructuredBuffer _Normals;
StructuredBuffer _Uvs;
ENDCG
SubShader{
Tags { "RenderType" = "Opaque" }
Pass {
Cull Back
CGPROGRAM
#include "UnityCG.cginc"
#pragma target 5.0
#pragma vertex vertex_shader
#pragma fragment fragment_shader
#pragma geometry geometry_shader
struct g2f {
float4 position : SV_POSITION;
};
struct v2g {
float4 vertex1 : POSITION;
float4 vertex2 : COLOR;
};
struct Line {
int vertex1;
int vertex2;
int triangle1_vertex3;
int triangle2_vertex3;
};
StructuredBuffer _DegradedRectangles;
v2g vertex_shader(uint id : SV_VertexID, uint inst : SV_InstanceID) {
v2g o;
//获取退化四边形数据,并得到实际的顶点数据。
Line _line = _DegradedRectangles[id];
float4 vertex1 = float4(_Vertices[_line.vertex1], 1.0f);
float4 vertex2 = float4(_Vertices[_line.vertex2], 1.0f);
float3 vertex1_normal = _Normals[_line.vertex1];
float3 vertex2_normal = _Normals[_line.vertex2];
float4 triangle1_vertex3 = float4(_Vertices[_line.triangle1_vertex3], 1.0f);
float4 center_point = (vertex1 + vertex2 + triangle1_vertex3) / 3.0f;
float3 view_dir = ObjSpaceViewDir(center_point);
bool is_edge = 1;
if (_line.triangle2_vertex3 > 0) { // 非边界边
通过叉乘计算出退化四边形2个三角面的法向量
bool is_outline = 用视线方向分别点乘2个面的法向量,再把2结果相乘可判断是否是轮廓边;
bool is_crease = 判断2个面的法向量夹角是否大于某个给定的角度;//这里最好使用cos的平方做角度大小比较,因为在cos平方[0, PI/2]上单调递增, 因而可避免开方
is_edge = is_outline | is_crease;
}
// 把顶点转换到裁剪空间
o.vertex1 = UnityObjectToClipPos(vertex1 + vertex1_normal * 0.0001f) * is_edge;
o.vertex2 = UnityObjectToClipPos(vertex2 + vertex2_normal * 0.0001f) * is_edge;
return o;
}
[maxvertexcount(2)]
void geometry_shader(point v2g input[1], inout LineStream stream) {
// 使用几何着色器把退化四边形进化成线条
// 直接使用stream.RestartStrip();即可,如有更好的方法请自行实现。
}
fixed4 fragment_shader(g2f i) : SV_Target {
// 给线条一个颜色
}
ENDCG
}
}
}
最后给需要的图形添加上脚本,并附上材质和退化四边形数据,运行即可。
最后你会得到如下结果:
但这是有缺陷的,所有边的细节都来源模型三角面,因此模型面数过低是没办法使用(当然高精度的渲染技术基本都有这缺陷)。所有线条都是由模型一个个三角图元的边构成,因此如果某三角图元太大就会出现不平滑的现象。还有要实现控制线条的粗细,还需在几何着色器上做一番功夫。
但好处很多,如建模不规范可擦除;如追求性能就只在要描边的地方精细建模,不重要的地方粗略建模;如线条可以随着模型动画而变化而非死线条。
文章结束,谢谢!!
附源码:https://github.com/L-LingRen/UnitySimpleCartoonLine