美文网首页Unity3D
【Unity3D】基于模板测试和顶点膨胀的描边方法

【Unity3D】基于模板测试和顶点膨胀的描边方法

作者: LittleFatSheep | 来源:发表于2023-03-19 09:41 被阅读0次

    1 前言

    选中物体描边特效 中介绍了基于模板纹理模糊膨胀的描边方法,该方法实现了软描边,效果较好,但是为了得到模糊纹理,对屏幕像素进行了多次渲染,效率欠佳。本文将介绍另一种描边方法:基于模板测试和顶点膨胀的描边方法,该方法绘制的是硬描边,但效率较高。

    基于顶点膨胀的描边方法都会遇到以下问题:

    • 法线突变处(如:立方体的两面交界处),描边断裂
    • 描边宽度受透视影响,远处描边较窄,近处描边较宽

    本文通过平滑法线解决描边断裂物体,通过深度信息抵消透视对描边宽度的影响。

    本文代码见→基于模板测试和顶点膨胀的描边方法

    2 原理

    1)概述

    在 SubShader 中开 2 个 Pass 渲染通道,第一个 Pass 通道将待描边物体的屏幕区域像素对应的模板值标记为 1,第二个 Pass 通道将待描边物体的顶点向外膨胀,绘制模板值为非 1 的膨胀区域,即外环区域。

    2)原图

    3)模板

    说明:由于第一个 Pass 通道只需要标记模板值,不需要渲染颜色,因此可以通过 "ColorMask 0" 过滤掉颜色。

    4)膨胀外环

    5)合成纹理

    3 代码实现

    SelectController.cs

    using System.Collections.Generic;
    using UnityEngine;
     
    public class SelectController : MonoBehaviour { // 单击选中控制
        private List<GameObject> targets; // 选中的游戏对象
        private List<GameObject> loseFocus; // 失焦的游戏对象
        private RaycastHit hit; // 碰撞信息
     
        private void Awake() {
            targets = new List<GameObject>();
            loseFocus = new List<GameObject>();
        }
     
        private void Update() {
            if (Input.GetMouseButtonUp(0)) {
                GameObject hitObj = GetHitObj();
                if (hitObj == null) { // 未选中任何物体, 已描边的全部取消描边
                    targets.ForEach(obj => loseFocus.Add(obj));
                    targets.Clear();
                }
                else if (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl)) {
                    if (targets.Contains(hitObj)) { // Ctrl重复选中, 取消描边
                        loseFocus.Add(hitObj);
                        targets.Remove(hitObj);
                    } else { // Ctrl追加描边
                        targets.Add(hitObj);
                    }
                } else { // 单选描边
                    targets.ForEach(obj => loseFocus.Add(obj));
                    targets.Clear();
                    targets.Add(hitObj);
                    loseFocus.Remove(hitObj);
                }
                DrawOutline();
            }
        }
    
        private void DrawOutline() { // 绘制描边
            targets.ForEach(obj => {
                if (obj.GetComponent<OutlineEffect>() == null) {
                    obj.AddComponent<OutlineEffect>();
                } else {
                    obj.GetComponent<OutlineEffect>().enabled = true;
                }
            });
            loseFocus.ForEach(obj => {
                if (obj.GetComponent<OutlineEffect>() != null) {
                    obj.GetComponent<OutlineEffect>().enabled = false;
                }
            });
            loseFocus.Clear();
        }
     
        private GameObject GetHitObj() { // 获取屏幕射线碰撞的物体
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            if (Physics.Raycast(ray, out hit)) {
                return hit.transform.gameObject;
            }
            return null;
        }
    }
    

    OutlineEffect.cs

    using System.Collections.Generic;
    using System.Linq;
    using UnityEngine;
     
    [DisallowMultipleComponent]
    public class OutlineEffect : MonoBehaviour { // 描边特效
        private Renderer[] renderers; // 当前对象及其子对象的渲染器
        private Material outlineMaterial; // 描边材质
    
        private void Awake() {
            renderers = GetComponentsInChildren<Renderer>();
            outlineMaterial = new Material(Shader.Find("MyShader/OutlineEffect"));
            LoadSmoothNormals();
        }
    
        private void OnEnable() {
            outlineMaterial.SetFloat("_StartTime", Time.timeSinceLevelLoad * 2);
            foreach (var renderer in renderers) {
                List<Material> materials = renderer.sharedMaterials.ToList();
                materials.Add(outlineMaterial);
                renderer.materials = materials.ToArray();
            }
        }
    
        private void OnDisable() {
            foreach (var renderer in renderers) {
                // 这里只能用sharedMaterials, 使用materials会进行深拷贝, 使得删除材质会失败
                List<Material> materials = renderer.sharedMaterials.ToList();
                materials.Remove(outlineMaterial);
                renderer.materials = materials.ToArray();
            }
        }
    
        private void LoadSmoothNormals() { // 加载平滑的法线(对相同顶点的所有法线取平均值)
            foreach (var meshFilter in GetComponentsInChildren<MeshFilter>()) {
                List<Vector3> smoothNormals = SmoothNormals(meshFilter.sharedMesh);
                meshFilter.sharedMesh.SetUVs(3, smoothNormals); // 将平滑法线存储到UV3中
                var renderer = meshFilter.GetComponent<Renderer>();
                if (renderer != null) {
                    CombineSubmeshes(meshFilter.sharedMesh, renderer.sharedMaterials.Length);
                }
            }
            foreach (var skinnedMeshRenderer in GetComponentsInChildren<SkinnedMeshRenderer>()) {
                // 清除SkinnedMeshRenderer的UV3
                skinnedMeshRenderer.sharedMesh.uv4 = new Vector2[skinnedMeshRenderer.sharedMesh.vertexCount];
                CombineSubmeshes(skinnedMeshRenderer.sharedMesh, skinnedMeshRenderer.sharedMaterials.Length);
            }
        }
    
        private List<Vector3> SmoothNormals(Mesh mesh) { // 计算平滑法线, 对相同顶点的所有法线取平均值
            // 按照顶点进行分组(如: 立方体有8个顶点, 但网格实际存储的是24个顶点, 因为相交的3个面的法线不同, 所以一个顶点存储了3次)
            var groups = mesh.vertices.Select((vertex, index) => new KeyValuePair<Vector3, int>(vertex, index)).GroupBy(pair => pair.Key);
            List<Vector3> smoothNormals = new List<Vector3>(mesh.normals);
            foreach (var group in groups) {
                if (group.Count() == 1) {
                    continue;
                }
                Vector3 smoothNormal = Vector3.zero;
                foreach (var pair in group) { // 计算法线均值(如: 对立方体同一顶点的3个面的法线取平均值, 平滑法线沿对角线向外)
                    smoothNormal += smoothNormals[pair.Value];
                }
                smoothNormal.Normalize();
                foreach (var pair in group) { // 平滑法线赋值(如: 立方体的同一顶点的3个面的平滑法线都是沿着对角线向外)
                    smoothNormals[pair.Value] = smoothNormal;
                }
            }
            return smoothNormals;
        }
    
        private void CombineSubmeshes(Mesh mesh, int materialsLength) { // 绑定子网格
            if (mesh.subMeshCount == 1) {
                return;
            }
            if (mesh.subMeshCount > materialsLength) {
                return;
            }
            mesh.subMeshCount++;
            mesh.SetTriangles(mesh.triangles, mesh.subMeshCount - 1);
        }
    }
    

    OutlineEffect.shader

    Shader "MyShader/OutlineEffect" {
        Properties {
            _OutlineWidth("Outline Width", Range(0, 10)) = 8
            _StartTime ("startTime", Float) = 0 // _StartTime用于控制每个选中的对象颜色渐变不同步
        }
    
        SubShader {
            Tags {
                // 渲染队列: Background(1000, 后台)、Geometry(2000, 几何体, 默认)、Transparent(3000, 透明)、Overlay(4000, 覆盖)
                "Queue" = "Transparent+110"
                "RenderType" = "Transparent"
                "DisableBatching" = "True"
            }
    
            // 将待描边物体的屏幕区域像素对应的模板值标记为1
            Pass {
                Cull Off // 关闭剔除渲染, 取值有: Off、Front、Back, Off表示正面和背面都渲染
                ZTest Always // 总是通过深度测试, 使得物体即使被遮挡时, 也能生成模板
                ZWrite Off // 关闭深度缓存, 避免该物体遮挡前面的物体
                ColorMask 0 // 允许通过的颜色通道, 取值有: 0、R、G、B、A、RGBA的组合(RG、RGB等), 0表示不渲染颜色
    
                Stencil { // 模板测试, 只有通过模板测试的像素才会渲染
                    Ref 1 // 设定参考值为1
                    Pass Replace // 如果通过模板测试, 将像素的模板值设置为参考值(1), 模板值的初值为0, 没有Comp表示总是通过模板测试
                }
            }
    
            // 绘制模板标记外的物体像素, 即膨胀的外环上的像素
            Pass {
                Cull Off // 关闭剔除渲染, 取值有: Off、Front、Back, Off表示正面和背面都渲染
                ZTest Always // 总是通过深度测试, 使得物体即使被遮挡时, 也能生成描边
                ZWrite Off // 关闭深度缓存, 避免该物体遮挡前面的物体
                Blend SrcAlpha OneMinusSrcAlpha // 混合测试, 与背后的物体颜色混合
                ColorMask RGB // 允许通过的颜色通道, 取值有: 0、R、G、B、A、RGBA的组合(RG、RGB等), 0表示不渲染颜色
    
                Stencil { // 模板测试, 只有通过模板测试的像素才会渲染
                    Ref 1 // 设定参考值为1
                    Comp NotEqual // 这里只有模板值为0的像素才会通过测试, 即只有膨胀的外环上的像素能通过模板测试
                }
    
                CGPROGRAM
                #include "UnityCG.cginc"
    
                #pragma vertex vert
                #pragma fragment frag
    
                uniform float _OutlineWidth;
                uniform float _StartTime;
       
                struct a2v {
                    float4 vertex : POSITION;
                    float3 normal : NORMAL;
                    float3 smoothNormal : TEXCOORD3; // 平滑的法线, 对相同顶点的所有法线取平均值
                };
     
                struct v2f {
                    float4 pos : SV_POSITION;
                };
     
                v2f vert(a2v v) {
                    v2f o;
                    float3 normal = any(v.smoothNormal) ? v.smoothNormal : v.normal; // 光滑的法线
                    float3 viewNormal = normalize(mul((float3x3)UNITY_MATRIX_IT_MV, normal)); // 观察坐标系下的法线向量
                    float3 viewPos = UnityObjectToViewPos(v.vertex); // 观察坐标系下的顶点坐标
                    // 裁剪坐标系下的顶点坐标, 将顶点坐标沿着法线方向向外延伸, 延伸的部分就是描边部分
                    // 乘以(-viewPos.z)是为了抵消透视变换造成的描边宽度近大远小效果, 使得物体无论距离相机多远, 描边宽度都不发生变化
                    // 除以1000是为了将描边宽度单位转换到1mm(这里的宽度是世界坐标系中的宽度, 而不是屏幕上的宽度)
                    o.pos = UnityViewToClipPos(viewPos + viewNormal * _OutlineWidth * (-viewPos.z) / 1000);
                    return o;
                }
    
                fixed4 frag(v2f i) : SV_Target {
                    float t1 = sin(_Time.z - _StartTime); // _Time = float4(t/20, t, t*2, t*3)
                    float t2 = cos(_Time.z - _StartTime);
                    // 描边颜色随时间变化, 描边透明度随时间变化, 视觉上感觉描边在膨胀和收缩
                    return float4(t1 + 1, t2 + 1, 1 - t1, 1 - t2);
                }
    
                ENDCG
            }
        }
    }
    

    4 运行效果

    5 推荐阅读

    声明:本文转自【Unity3D】基于模板测试和顶点膨胀的描边方法

    相关文章

      网友评论

        本文标题:【Unity3D】基于模板测试和顶点膨胀的描边方法

        本文链接:https://www.haomeiwen.com/subject/lnpohdtx.html