平滑法线处理 - 八面体映射
游戏角色(尤其是NPR/卡通渲染)有个经典问题:硬表面的 split normal(分裂法线)在描边和阴影计算时会出现接缝。解决方案是额外烘培一套”平滑法线”——但模型的UV1已经用来存贴图坐标了,怎么传进Shader?
范数
在处理八面体之前需要先了解范数
简单解释
简单来说,它是“长度”或“距离”这一直觉概念在多维向量空间(甚至函数空间)中的一般化定义。 如果你把一个向量想象成空间中的一个点,那么范数就是描述这个点离原点有多远,或者这个向量有多大的一个数值。
范数的类型
这里只介绍两种相关的范数 L1范数(曼哈顿距离): L1范数可以理解为出租车距离,也就是它的距离是由出租车行驶的每段距离相加,也就是各个分量之和
\[|v| = |x| + |y| + |z|\]L2范数(欧几里得长度): L2范数则是接触最多的,比如求两点之间的直线距离,直角三角形求斜边长度
\[|v| = \sqrt{(x² + y² + z²)}\]在二维中图像表示如下图

八面体映射原理
三维与二维
从二维图到三维,就是加上Z轴,二维图像其实就是八面体的顶视图,这其实就引出一个问题,八面体是上下对称重叠的,那么如果将下半部分,也就是z<0的部分展开,就可以变成一个平面数据,也就可以变相将三维数据存入二维了 
三维映射原理
对于模型的法线向量来说,归一化后它的范围就是一个球范围,那么如果要映射到八面体上,就可以让X和Y分量除以L1范数 
对于Z分量,则是另有用途,前面说过八面体上下是重叠,这里就需要使用Z分量进行处理,如果是在下方,那么X分量和Y分量则需要反转或者说折叠 
两种理解方法
从公式入手推导
折叠其实就是相对于边界对称,这里则需要先计算点和边界的距离了,也就是点与直线的距离
\[d = \frac{|Ax_0 + By_0 + C|}{\sqrt{A^2 + B^2}}\]这里会有四条边界 第一象限(x>0, y>0): x + y = 1 第二象限(x<0, y>0): -x + y = 1 第三象限(x<0, y<0): -x - y = 1 第四象限(x>0, y<0): x - y = 1 那么原坐标沿着对称线法线方向移动2d,就可以得到对称的点坐标了,因为范围是0~1,从一象限来看,边界法线就是$(1,1)$,归一化后就是$(\frac{1}{\sqrt{2}},\frac{1}{\sqrt{2}})$,对于$\sqrt{A^2 + B^2}$实际上就是边界的长度,也就是$\sqrt{2}$, 假设原坐标为(a,b),那么对称的坐标就是$a-2d *\frac{1}{\sqrt{2}}$,化简之后,会得到变换后的坐标X分量是$(1-b)$ Y分量则是$(1-a)$,因为四条象限的法向量有正负之分,那么最终结果会取决于法向量的个分量的正负,但是这里的法向量正负正好符合象限X,Y的正负,也就是最终表达式是\(x = (1-|b|) * Sign(x)\)\(y = (1-|a|) * Sign(y)\) 
从边界限制条件入手
这个约束来自折叠是镜像——边界上的点折叠前后位置不变。 取边界上任意一点 $(a,b)$,代入折叠公式后应该得到原点: 要让边界点不动,需要:
\[new_x = a\] \[new_y = b\]| 但边界上点满足 $ | a | + | b | = 1$,所以可以得到 $$a = (1 - | b | ) * sign(a)$$ |
| 折叠后的点必须满足 $ | new_x | + | new_y | > 1$,即在菱形外侧。验证通过 |
平滑法线
可以理解为对法线进行权重影响,这个权重来自于角度,一个顶点的法线,由周边的所有三角形法线和经过角度加权之后得到 
平滑法线烘焙到UV
blender脚本
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
130
131
132
133
134
135
136
import bpy
from mathutils import *
from math import *
import numpy as np
#创建空字典列表list
dict = {}
#获取模型Mesh
mesh = bpy.data.meshes['MESHNAME']
#calc_tangents:
#在网格中计算每个顶点的切线(tangent)、双切线(bitangent)和法线(normal),以便后续的方向相关计算。
mesh.calc_tangents(uvmap = 'UVMap')
#计算两个向量之间的夹角
# a·b = |a||b|cosθ
# θ = arccos(a·b / (|a||b|))
# 这里返回的弧度值 θ * (π/180) ≈ xxx弧度
def included_angle(v0, v1):
return np.arccos(v0.dot(v1)/(v0.length * v1.length))
# 3维降为2维
def unitVectorToOct(v):
# 步骤1:计算L1范数(曼哈顿距离)
d = abs(v.x) + abs(v.y) + abs(v.z)
# 步骤2:通过除以L1范数进行投影到八面体表面
# o 是八面体的坐标
o = Vector((v.x / d, v.y / d))
# 步骤3:如果z是负数,需要进行特殊处理以保持连续性
if v.z <= 0:
# 使用折叠技术处理八面体的下半部分
o.x = (1 - abs(o.y)) * (1 if o.x >= 0 else -1)
o.y = (1 - abs(o.x)) * (1 if o.y >= 0 else -1)
return o
#.co是coordinate的缩写,表示顶点的坐标。
# 取出模型的每一个顶点坐标,然后清空置空列表,后续存入新的坐标数据
for vertex in mesh.vertices:
# 初始化 "<Vector (1.0, 2.0, 3.0)>": []
dict[str(vertex.co)] = []
# 获取模型Mesh的每一个面
# l0,l1,l2分别表示模型Mesh的每一个面中的每一个顶点数据
# l0,l1,l2这样的写法是Blender的写法,表示loop0,loop1,loop2的循环体
# mesh.loops 存储了多边形顶点的循环信息
# poly.loop_start 表示多边形顶点的循环开始索引
for poly in mesh.polygons:
#获取模型Mesh的每一个三角面中的每一个顶点数据
l0 = mesh.loops[poly.loop_start]
l1 = mesh.loops[poly.loop_start + 1]
l2 = mesh.loops[poly.loop_start + 2]
#获取模型Mesh的每一个面中的每一个顶点数据
## 顶点的主要属性:
#vertex.co 顶点的3D坐标 (Vector类型,包含x,y,z)
#vertex.normal 顶点的法线方向
#vertex.index 顶点在mesh.vertices数组中的索引号
#vertex.groups 顶点组信息(用于骨骼绑定等)
v0 = mesh.vertices[l0.vertex_index]
v1 = mesh.vertices[l1.vertex_index]
v2 = mesh.vertices[l2.vertex_index]
#计算向量,三角形两条边向量
vec0 = v1.co - v0.co
vec1 = v2.co - v0.co
#计算向量叉积,三角形两条边向量叉乘,得到法线
n = vec0.cross(vec1)
n = n.normalized()
#v0.co 是一个 Vector 类型,包含 (x,y,z) 坐标值
#Python的字典要求键(key)必须是"可哈希的"(hashable)
#Vector 类型不能直接用作字典的键
#这里将顶点坐标转换为字符串,便于后续使用
#k0,k1,k2是字典的Key
k0 = str(v0.co) # 将顶点0的坐标转换为字符串
k1 = str(v1.co) # 将顶点1的坐标转换为字符串
k2 = str(v2.co) # 将顶点2的坐标转换为字符串
# 计算三角形三个顶点的权重
if k0 in dict:
#计算顶点0的权重,w实际上是两向量夹角角度
w = included_angle(v2.co - v0.co, v1.co - v0.co)
# 添加数据
dict[k0].append({"n" : n, "w" : w})
if k1 in dict:
w = included_angle(v0.co - v1.co, v2.co - v1.co)
dict[k1].append({"n" : n, "w" : w})
if k2 in dict:
w = included_angle(v1.co - v2.co, v0.co - v2.co)
dict[k2].append({"n" : n, "w" : w})
for poly in mesh.polygons:
# 获取每一个三角面数据
#range(poly.loop_start, poly.loop_start + 3)遍历3个点
for loop_index in range(poly.loop_start, poly.loop_start + 3):
l = mesh.loops[loop_index]
vertex_index = l.vertex_index
v = mesh.vertices[vertex_index]
#smoothNormal初始化
smoothNormal = Vector((0,0,0))
weightSum = 0
k = str(v.co)
if k in dict:
a = dict[k]
for d in a:
n = d['n']
w = d['w']
#加权平均法线的计算公式:
#例如:smoothNormal = (n1 * w1 + n2 * w2 + n3 * w3) / (w1 + w2 + w3)
smoothNormal += n * w
weightSum += w
if smoothNormal.length != 0:
smoothNormal /= weightSum
smoothNormal = smoothNormal.normalized()
else:
# 如果计算出的平滑法线无效(长度为0),
# 使用原始顶点法线作为后备方案
smoothNormal = l.normal
normal = l.normal # 获取顶点的原始法线
tangent = l.tangent # 获取顶点的原始切线
bitangent = l.bitangent # 获取顶点的原始副切线
# 计算投影
# 使用原始的切线空间基底,计算平滑法线在这个空间中的表示
x = tangent.dot(smoothNormal) # 平滑法线在原始切线方向上的投影
y = bitangent.dot(smoothNormal) # 平滑法线在原始副切线方向上的投影
z = normal.dot(smoothNormal) # 平滑法线在原始法线方向上的投影
#UV数组的大小总是等于loops的数量
uv1 = mesh.uv_layers['UVMap.001'].uv[loop_index]
uv1.vector = unitVectorToOct(Vector((x,y,z)))
Enjoy Reading This Article?
Here are some more articles you might like to read next: