游戏角色(尤其是NPR/卡通渲染)有个经典问题:硬表面的 split normal(分裂法线)在描边和阴影计算时会出现接缝。解决方案是额外烘培一套”平滑法线”——但模型的UV1已经用来存贴图坐标了,怎么传进Shader?

范数

在处理八面体之前需要先了解范数

简单解释

简单来说,它是“长度”或“距离”这一直觉概念在多维向量空间(甚至函数空间)中的一般化定义。 如果你把一个向量想象成空间中的一个点,那么范数就是描述这个点离原点有多远,或者这个向量有多大的一个数值。

范数的类型

这里只介绍两种相关的范数 L1范数(曼哈顿距离): L1范数可以理解为出租车距离,也就是它的距离是由出租车行驶的每段距离相加,也就是各个分量之和

∣v∣=∣x∣+∣y∣+∣z∣|v| = |x| + |y| + |z|

L2范数(欧几里得长度): L2范数则是接触最多的,比如求两点之间的直线距离,直角三角形求斜边长度

∣v∣=(x2+y2+z2)|v| = \sqrt{(x² + y² + z²)}

在二维中图像表示如下图

八面体映射原理

三维与二维

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

三维映射原理

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

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

两种理解方法

从公式入手推导

折叠其实就是相对于边界对称,这里则需要先计算点和边界的距离了,也就是点与直线的距离

d=∣Ax0+By0+C∣A2+B2d = \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)(1,1),归一化后就是(12,12)(\frac{1}{\sqrt{2}},\frac{1}{\sqrt{2}}),对于A2+B2\sqrt{A^2 + B^2}实际上就是边界的长度,也就是2\sqrt{2}, 假设原坐标为(a,b),那么对称的坐标就是a−2d∗12a-2d *\frac{1}{\sqrt{2}},化简之后,会得到变换后的坐标X分量是(1−b)(1-b) Y分量则是(1−a)(1-a),因为四条象限的法向量有正负之分,那么最终结果会取决于法向量的个分量的正负,但是这里的法向量正负正好符合象限X,Y的正负,也就是最终表达式是

x=(1−∣b∣)∗Sign(x)x = (1-|b|) * Sign(x) y=(1−∣a∣)∗Sign(y)y = (1-|a|) * Sign(y)

从边界限制条件入手

这个约束来自折叠是镜像——边界上的点折叠前后位置不变。 取边界上任意一点 (a,b)(a,b),代入折叠公式后应该得到原点: 要让边界点不动,需要:

newx=anew_x = a newy=bnew_y = b

但边界上点满足 ∣a∣+∣b∣=1|a| + |b| = 1,所以可以得到

a=(1−∣b∣)∗sign(a)a = (1 - |b|) * sign(a) b=1−∣a∣∗sign(b)b = 1 - |a| * sign(b)

折叠后的点必须满足 ∣newx∣+∣newy∣>1|new_x| + |new_y| > 1,即在菱形外侧。验证通过

∣newx∣+∣newy∣|new_x| + |new_y| =(1−∣b∣)+(1−∣a∣)= (1 - |b|) + (1 - |a|) =2−(∣a∣+∣b∣)= 2 - (|a| + |b|)

平滑法线

可以理解为对法线进行权重影响,这个权重来自于角度,一个顶点的法线,由周边的所有三角形法线和经过角度加权之后得到

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