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


八面体映射原理
三维与二维
从二维图到三维,就是加上Z轴,二维图像其实就是八面体的顶视图,这其实就引出一个问题,八面体是上下对称重叠的,那么如果将下半部分,也就是z<0的部分展开,就可以变成一个平面数据,也就可以变相将三维数据存入二维了

三维映射原理
对于模型的法线向量来说,归一化后它的范围就是一个球范围,那么如果要映射到八面体上,就可以让X和Y分量除以L1范数

对于Z分量,则是另有用途,前面说过八面体上下是重叠,这里就需要使用Z分量进行处理,如果是在下方,那么X分量和Y分量则需要反转或者说折叠

两种理解方法
从公式入手推导
折叠其实就是相对于边界对称,这里则需要先计算点和边界的距离了,也就是点与直线的距离
这里会有四条边界 第一象限(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,从一象限来看,边界法线就是,归一化后就是,对于实际上就是边界的长度,也就是, 假设原坐标为(a,b),那么对称的坐标就是,化简之后,会得到变换后的坐标X分量是 Y分量则是,因为四条象限的法向量有正负之分,那么最终结果会取决于法向量的个分量的正负,但是这里的法向量正负正好符合象限X,Y的正负,也就是最终表达式是

从边界限制条件入手
这个约束来自折叠是镜像——边界上的点折叠前后位置不变。 取边界上任意一点 ,代入折叠公式后应该得到原点: 要让边界点不动,需要:
但边界上点满足 ,所以可以得到
折叠后的点必须满足 ,即在菱形外侧。验证通过
平滑法线
可以理解为对法线进行权重影响,这个权重来自于角度,一个顶点的法线,由周边的所有三角形法线和经过角度加权之后得到

平滑法线烘焙到UV
blender脚本
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)))