【Unity3D】基于模板测试和顶点膨胀的描边方法
创始人
2024-05-13 19:15:40
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 targets; // 选中的游戏对象private List loseFocus; // 失焦的游戏对象private RaycastHit hit; // 碰撞信息private void Awake() {targets = new List();loseFocus = new List();}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() == null) {obj.AddComponent();} else {obj.GetComponent().enabled = true;}});loseFocus.ForEach(obj => {if (obj.GetComponent() != null) {obj.GetComponent().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();outlineMaterial = new Material(Shader.Find("MyShader/OutlineEffect"));LoadSmoothNormals();}private void OnEnable() {outlineMaterial.SetFloat("_StartTime", Time.timeSinceLevelLoad * 2);foreach (var renderer in renderers) {List materials = renderer.sharedMaterials.ToList();materials.Add(outlineMaterial);renderer.materials = materials.ToArray();}}private void OnDisable() {foreach (var renderer in renderers) {// 这里只能用sharedMaterials, 使用materials会进行深拷贝, 使得删除材质会失败List materials = renderer.sharedMaterials.ToList();materials.Remove(outlineMaterial);renderer.materials = materials.ToArray();}}private void LoadSmoothNormals() { // 加载平滑的法线(对相同顶点的所有法线取平均值)foreach (var meshFilter in GetComponentsInChildren()) {List smoothNormals = SmoothNormals(meshFilter.sharedMesh);meshFilter.sharedMesh.SetUVs(3, smoothNormals); // 将平滑法线存储到UV3中var renderer = meshFilter.GetComponent();if (renderer != null) {CombineSubmeshes(meshFilter.sharedMesh, renderer.sharedMaterials.Length);}}foreach (var skinnedMeshRenderer in GetComponentsInChildren()) {// 清除SkinnedMeshRenderer的UV3skinnedMeshRenderer.sharedMesh.uv4 = new Vector2[skinnedMeshRenderer.sharedMesh.vertexCount];CombineSubmeshes(skinnedMeshRenderer.sharedMesh, skinnedMeshRenderer.sharedMaterials.Length);}}private List SmoothNormals(Mesh mesh) { // 计算平滑法线, 对相同顶点的所有法线取平均值// 按照顶点进行分组(如: 立方体有8个顶点, 但网格实际存储的是24个顶点, 因为相较的3个面的法线不同, 所以一个顶点存储了3次)var groups = mesh.vertices.Select((vertex, index) => new KeyValuePair(vertex, index)).GroupBy(pair => pair.Key);List smoothNormals = new List(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"}// 将待描边物体的屏幕区域像素对应的模板值标记为1Pass {Cull Off // 关闭剔除渲染, 取值有: Off、Front、Back, Off表示正面和背面都渲染ZTest Always // 总是通过深度测试, 使得物体即使被遮挡时, 也能生成模板ZWrite Off // 关闭深度缓存, 避免该物体遮挡前面的物体ColorMask 0 // 允许通过的颜色通道, 取值有: 0、R、G、B、A、RGBA的组合(RG、RGB等), 0表示不渲染颜色Stencil { // 模板测试, 只有通过模板测试的像素才会渲染Ref 1 // 设定参考值为1Pass 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 // 设定参考值为1Comp NotEqual // 这里只有模板值为0的像素才会通过测试, 即只有膨胀的外环上的像素能通过模板测试}CGPROGRAM#include "UnityCG.cginc"#pragma vertex vert#pragma fragment fraguniform float _OutlineWidth;uniform float _StartTime;struct appdata {float4 vertex : POSITION;float3 normal : NORMAL;float3 smoothNormal : TEXCOORD3; // 平滑的法线, 对相同顶点的所有法线取平均值};struct v2f {float4 position : SV_POSITION;};v2f vert(appdata input) {v2f output;float3 normal = any(input.smoothNormal) ? input.smoothNormal : input.normal; // 光滑的法线float3 viewPosition = UnityObjectToViewPos(input.vertex); // 相机坐标系下的顶点坐标float3 viewNormal = normalize(mul((float3x3)UNITY_MATRIX_IT_MV, normal)); // 相机坐标系下的法线向量// 裁剪坐标系下的顶点坐标, 将顶点坐标沿着法线方向向外延伸, 延伸的部分就是描边部分// 乘以(-viewPosition.z)是为了抵消透视变换造成的描边宽度近大远小效果, 使得物体无论距离相机多远, 描边宽度都不发生变化// 除以1000是为了将描边宽度单位转换到1mm(这里的宽度是世界坐标系中的宽度, 而不是屏幕上的宽度)output.position = UnityViewToClipPos(viewPosition + viewNormal * _OutlineWidth * (-viewPosition.z) / 1000);return output;}fixed4 frag(v2f input) : 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 推荐阅读

  •  渲染管线
  • 固定管线着色器一
  • 固定管线着色器二
  • 表面着色器
  • 顶点和片段着色器
  • 选中物体描边特效
  • 水波特效
  • 半球卷屏特效
  • 卷轴特效

相关内容

热门资讯

经典全陪导游词 经典全陪导游词  导游词其主要特点是口语化些,此外还具有知识性、文学性、礼节性等,经典全陪导游词。和...
焦作博爱青天河导游词 焦作博爱青天河导游词  作为一名乐于助人的导游,常常要根据讲解需要编写导游词,导游词是讲解当地的基本...
河南省龙隐导游词 河南省龙隐导游词  作为一位出色的导游人员,往往需要进行导游词编写工作,导游词一般是根据实际的游览景...
长春长影世纪城导游词 长春长影世纪城导游词  作为一名乐于为游客排忧解难的导游,总归要编写导游词,导游词具有形象、生动、具...
阳朔聚龙潭景区导游词 阳朔聚龙潭景区导游词(精选5篇)  作为一名默默奉献的导游,就难以避免地要准备导游词,导游词可以加深...
大屿山导游词 大屿山导游词  大屿山岛是香港特区最大的岛屿,其面积比香港岛大近一倍。位于珠江口外。大屿山地势西南高...
湖南天子山导游词 湖南天子山导游词  导语:导游词是导游人员引导游客观光游览时的讲解词,是导游员同游客交流思想,向游客...
重庆大足石刻导游词 重庆大足石刻导游词(精选11篇)  作为一名专门为游客提供帮助的导游,常常需要准备导游词,导游词事实...
安徽宏村景点导游词介绍 安徽宏村景点导游词介绍  作为一名旅游从业人员,就有可能用到导游词,导游词可以加深游客对景点的印象,...
庐山芦林湖导游词 庐山芦林湖导游词  作为一名导游,编写导游词是必不可少的,导游词具有注重口语化、精简凝练、重点突出的...
北海公园九龙壁的导游词 北海公园九龙壁的导游词范文  作为一名导游,时常要开展导游词准备工作,导游词是导游员在游览时为口头表...
介绍那拉提草原导游词 介绍那拉提草原导游词  那拉提”是蒙古语“太阳”的意思,对于名字的由来,有一个小小的传说。以下是“介...
江西省庐山山南太乙村导游词 江西省庐山山南太乙村导游词  各位游客,大家好!欢迎来到太乙村旅游。  去过庐山的人很多,但去过太乙...
旅游圣地大理苍山洱海导游词 旅游圣地大理苍山洱海导游词  作为一名优秀的旅游从业人员,就有可能用到导游词,导游词可以帮助旅游者欣...
蓬莱阁的导游词 蓬莱阁的导游词精选  尊敬的旅客朋友们,大家好啊!欢迎来到具有“人间仙境”美称的蓬莱阁参观旅游。我是...
断桥残雪导游词 断桥残雪导游词范文  在白堤的尽头,到了断桥,全长1公里的白堤就由此而“断”了。  断桥的名字最早取...
丽江概况导游词 丽江概况导游词(通用5篇)  作为一名专门为游客提供帮助的导游,时常需要编写导游词,导游词作为一种解...
故宫讲解导游词 2022故宫讲解导游词(精选23篇)  作为一位出色的导游人员,通常需要用到导游词来辅助讲解,借助导...