Unity Shader 卡通渲染 模型描边之退化四边形

目录

前言

一、基于空间的边缘检测算法

二、退化四边形

三、Unity中的CommandBuffer和ComputeBuffer

四、构成描边的简单实例

五、模型描边的实现


前言

之前写了一篇《Unity Shader 卡通渲染 实时模型动画描边的研究》,因为对unity3d的渲染工具不太熟悉,用了一种又较笨、又不太现实的方法进行边缘检测,写得也不是很好。最近在搜索无意间搜到一篇文章了CommandBuffer.DrawProcedural examples,发现unity3d可以自定义渲染指令。简单的探究后,发现它可以让着色器使用自定义数据缓冲(ComputeBuffer)来绘制图形。

 


一、基于空间的边缘检测算法

在《Unity Shader 卡通渲染 实时模型动画描边的研究》中提到的算法是基于空间几何的边缘检测算法。模型上一条边(构成三角面的边)是否要描绘出来由以下情况决定:

Unity Shader 卡通渲染 模型描边之退化四边形_第1张图片

Unity Shader 卡通渲染 模型描边之退化四边形_第2张图片

很容易得出:是否要绘制边需要它的相邻三角面信息,那么最少也要4个顶点。在《Unity Shader 卡通渲染 实时模型动画描边的研究》描述到了一种基于GPU的算法,把4个顶点作为顶点着色器的输入并绘制,这样就可以在顶点着色器运行。然而,文中并没有使用这个算法,可能当时文中使用的引擎不支持?

在Unity中是支持这种做法的,提供的工具是CommandBuffer和ComputeBuffer。

 


二、退化四边形

Unity Shader 卡通渲染 模型描边之退化四边形_第3张图片

把复杂对象A当做简单对象B来简化运算,有一个形象的名字叫作“退化对象A”。这里是把四边形当做顶点来简化运算,所以这个(包含了4个顶点数据)四边形就叫“退化四边形”。因此退化四边形即可叫边,可叫2个三角面,也可叫4个顶点。

 


三、Unity中的CommandBuffer和ComputeBuffer

在动手前先来简单介绍下需要使用的工具类。

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来创建一个退化四边形资源文件。

Unity Shader 卡通渲染 模型描边之退化四边形_第4张图片


然后通过如下步骤得到一个mesh的所有退化四边形

Unity Shader 卡通渲染 模型描边之退化四边形_第5张图片

Unity Shader 卡通渲染 模型描边之退化四边形_第6张图片

Unity Shader 卡通渲染 模型描边之退化四边形_第7张图片


实例2:使用CommandBuffer和ComputeBuffer绘制一个简单线条图形

先创建必要的资源,1个名为ProceduralGeometry的材质、1个名为ProceduralGeometry的Unlit着色器、1个名为ProceduralGeometry的c#脚本。

给材ProceduralGeometry质球选上ProceduralGeometry着色器

Unity Shader 卡通渲染 模型描边之退化四边形_第8张图片


创建一个空物体命名为ProceduralGeometry并挂上名为ProceduralGeometry的c#脚本。

Unity Shader 卡通渲染 模型描边之退化四边形_第9张图片


现在开始进行代码编写

打开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轴的直线。

Unity Shader 卡通渲染 模型描边之退化四边形_第10张图片

这里有个很有趣的实验,多加几个在一条直线上的顶点,把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

你可能感兴趣的:(Unity Shader 卡通渲染 模型描边之退化四边形)