抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

简介

给物体描边的 Shader,实现的方法有多种,例如顶点扩大、法线与视线点积、后处理等。不同的方法有其对应的优缺点和适用范围。

本文采用顶点扩大的方法。

原理

顶点扩大

顶点扩大的原理很简单。

描边 Shader 包含两个 Pass,一个 Pass 正常渲染物体,另一个 Pass 渲染描边。

正常的 Pass 剔除背面,描边的 Pass 剔除正面。

描边的 Pass 把原来模型的顶点沿法线方向移动一定距离,也就是扩大模型顶点,这样就有一个轮廓套在原来的模型外面。

如图所示,把原模型(蓝色)顶点向外扩展到红色大小,这样红色和蓝色之间的区域就是描边。

描边粗细固定

由于顶点的变化在 NDC(标准设备坐标)空间中的变化不会受到相机距离的影响,因此我们把顶点扩大放在这里进行。

又因为 NDC 空间是 -1 到 1 的归一化坐标空间,因此我们需要计算屏幕的宽高比,然后令变化后的顶点 X 坐标乘以宽高比得到正确的坐标,否则在现实的时候 X 方向的粗细会不均匀。

常见问题

当模型某些顶点在多个面存在的时候,描边就会产生断开的现象。最典型的例子就是立方体。

如图所示。

这时候我们就要平滑法线。我们把存在于多个面的顶点的法线相加,得到一个最终的法线方向,这样就能避免这个问题。

平滑法线的过程可以放在运行前,提前处理模型数据,也可以放在运行时,不过运行时处理会造成额外的性能开销。

本文把平滑法线的过程放在运行时,结果写入切线中(写入法线可能会造成光影计算出问题),读取的时候也是从切线读取。

代码

描边Shader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
Shader "Custom/Outline"
{
Properties
{
_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_OutlineWidth ("OutlineWidth", Range(0,1)) = 0.1
_OutlineColor ("OutlineColor", Color) = (0,0,0,1)
}
SubShader
{
Pass
{
Tags
{
"LightMode"="ForwardBase"
}

Cull Back

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdbase

#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"

fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;

sampler2D _MainTex;

struct v2f
{
float4 pos : SV_POSITION;
fixed4 uv : TEXCOORD0;
fixed4 worldPos : TEXCOORD1;
float3 worldNormal : TEXCOORD2;
SHADOW_COORDS(3)
};

v2f vert(appdata_base v)
{
v2f o;
//顶点转为裁剪空间坐标
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.worldPos = mul(unity_ObjectToWorld,v.vertex.xyz);
o.worldNormal = UnityObjectToWorldNormal(v.normal);

TRANSFER_SHADOW(o);
return o;
}

float4 frag(v2f i) : SV_Target
{
//texture采样
fixed4 color = tex2D(_MainTex,i.uv);
//光影计算
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));

fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);

fixed atten = 1.0;//光照衰减

fixed shadow = SHADOW_ATTENUATION(i);

return fixed4(color * (ambient + (diffuse + specular) * atten * shadow), 1.0);
}
ENDCG
}
Pass
{
Cull Front

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct v2f
{
float4 pos : SV_POSITION;
};

float _OutlineWidth;
fixed4 _OutlineColor;

v2f vert(appdata_tan v)
{
v2f o;
//把顶点转换到裁剪空间
float4 pos = UnityObjectToClipPos(v.vertex);
//把法线转换到ndc空间
float3 ndcNormal = normalize(mul((float3x3)unity_MatrixMVP, v.tangent.xyz)) * pos.w;
//将近裁剪面右上角位置的顶点变换到观察空间
float4 nearUpperRight = mul(unity_CameraInvProjection, float2(1, 1));
//求得屏幕宽高比
const float aspect = abs(nearUpperRight.y / nearUpperRight.x);
//使法线方向正确适配屏幕宽高比
ndcNormal.x *= aspect;
//顶点扩张
pos.xy += 0.1 * _OutlineWidth * ndcNormal.xy;
o.pos = pos;
return o;
}

float4 frag(v2f i) : SV_Target
{
return _OutlineColor;
}
ENDCG
}
}
FallBack "Diffuse"
}
平滑法线运行时脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
using System.Collections.Generic;
using UnityEngine;
public class SmoothOutline : MonoBehaviour
{
Mesh MeshNormalAverage(Mesh mesh)
{
//存储顶点和对应的索引
Dictionary<Vector3, List<int>> map = new Dictionary<Vector3, List<int>>();
for (int v = 0; v < mesh.vertexCount; ++v)
{
if (!map.ContainsKey(mesh.vertices[v]))
{
map.Add(mesh.vertices[v], new List<int>());
}
map[mesh.vertices[v]].Add(v);
}

Vector3[] normals = mesh.normals;
Vector3 normal;
foreach(var p in map)
{
normal = Vector3.zero;
//根据顶点所有对应索引计算法线总方向
foreach (var n in p.Value)
{
normal += mesh.normals[n];
}
//归一化
normal /= p.Value.Count;
foreach (var n in p.Value)
{
normals[n] = normal;
}
}
//把平滑后的顶点法线信息存入切线信息
var tangents = new Vector4[mesh.vertexCount];
for (var j = 0; j < mesh.vertexCount; j++)
{
tangents[j] = new Vector4(normals[j].x, normals[j].y, normals[j].z, 0);
}
mesh.tangents= tangents;
return mesh;
}
void Awake()
{
if (GetComponent<MeshFilter>())
{
Mesh tempMesh = (Mesh)Instantiate(GetComponent<MeshFilter>().sharedMesh);
tempMesh=MeshNormalAverage(tempMesh);
gameObject.GetComponent<MeshFilter>().sharedMesh = tempMesh;
}
if (GetComponent<SkinnedMeshRenderer>())
{
Mesh tempMesh = (Mesh)Instantiate(GetComponent<SkinnedMeshRenderer>().sharedMesh);
tempMesh = MeshNormalAverage(tempMesh);
gameObject.GetComponent<SkinnedMeshRenderer>().sharedMesh = tempMesh;
}
}
}

项目工程

更新日志

2024-04-01

  1. 更新基本内容。

评论