计算机图形学 · 延迟渲染 · 内存优化

八面体法线编码
Octahedral Encoding

3D 单位向量到 2D 坐标的高效映射方案
G-Buffer 优化 HLSL 无三角函数 法线压缩 均匀分布

在现代图形学和高阶渲染管线中,八面体法线编码(Octahedral Normal Encoding)是一种将三维单位向量(如表面法线)编码为二维坐标的极其高效的方法。它是对八面体环境贴图技术的扩展应用。本文我们将拆解其几何投射原理,并提供可直接用于实际渲染管线的 HLSL 实现。

01 核心逻辑:降维与映射

这种编码方法的基本思想是将法线向量投影到一个八面体上,然后将八面体“剪开”并平铺折叠在一个正方形上。

Octahedron Mapping Unfolded Octahedron
核心映射公式(无三角函数)
注意:当 时,必须对 xy 结果额外应用一次空间折叠操作:
极低的计算开销

编码和解码过程完全无需使用三角函数(sin/cos),仅使用基础的加减乘除、绝对值和符号判断,ALU 指令消耗极小。

🎯
分布均匀 & 无极点崩坏

相比球面坐标映射在极点造成的严重精度聚集和扭曲失真,八面体展开法在全空间的精度分布更为均匀。

💾
极致的显存压缩

将 3 通道法线完美压缩为 2 通道,在延迟渲染的 G-Buffer 中释放出宝贵通道,也是 BC5 (ATI2) 纹理格式的绝配。

02 交互式理解:空间折叠推演

我们可以通过交互面板与流水线推演来建立物理直觉:

🕹️ 交互式可视化:向量在 2D UV 空间的折叠映射

* 操作提示:尝试将 Z 分量滑至负数,会直观地感受到映射点从菱形中心“跃迁”并向外四角对折的现象,这正是 1.0 - abs(result.yx) 逻辑在起作用。

1
构建基准:单位球体与内置八面体
想象一个半径为 1 的球面,以及放置在球心的一个正八面体。
八面体由 8 个等边三角形面组成,其 6 个顶点精准地钉在坐标轴上:。球面上每一个点都代表一个可能的法线。
2
射线投影:球面落到平直面上
沿球心发射射线穿过球面,记录在八面体上的落点。
从球心向外看,每个单位向量都会像手电筒的光一样,穿过八面体的某一个面。这个交点就是向量在八面体上的初步投影,即 范数除法。
3
二维降维:展开十字与正方形映射
纸盒拆解与连续性折叠。
将八面体像纸模一样沿边剪开,它会变成一个二维的十字形状。为了最优化纹理存储,我们要把外围的四个三角形(对应 的下半球)对折,拼成一个完整的正方形,确保法线在 UV 空间中首尾连续。
03 工程实践:HLSL 源码实现

HLSL 源码,以及针对 G-Buffer 读写的映射处理示例。

// 八面体法线编码函数 - 将单位向量编码为 2D 坐标 [-1, 1]
float2 EncodeNormalOctahedral(float3 normal)
{
    normal = normalize(normal);

    // 步骤1:计算 L1 范数并归一化 xy
    float sum = abs(normal.x) + abs(normal.y) + abs(normal.z);
    float2 result = normal.xy * (1.0 / sum);

    // 步骤2:当 z 为负时进行下半球折叠
    if (normal.z < 0.0)
    {
        // 硬件级并行技巧:result >= 0.0 返回的是 bool2 向量
        float2 signedResult = (result >= 0.0) ? float2(1.0, 1.0) : float2(-1.0, -1.0);
        result = (1.0 - abs(result.yx)) * signedResult;
    }

    return result;
}
// 八面体法线解码函数 - 将 2D 坐标还原为 3D 单位法线
float3 DecodeNormalOctahedral(float2 encoded)
{
    float3 normal;
    normal.xy = encoded;
    
    // 基于 L1 范数为 1 的假设反推 Z 分量
    normal.z = 1.0 - abs(encoded.x) - abs(encoded.y);

    // 当 z 为负时,逆向还原 xy 分量的折叠
    if (normal.z < 0.0)
    {
        float2 signedNormal = (normal.xy >= 0.0) ? float2(1.0, 1.0) : float2(-1.0, -1.0);
        normal.xy = (1.0 - abs(normal.yx)) * signedNormal;
    }

    return normalize(normal);
}
// [GBuffer 写入通道]
float2 encoded = EncodeNormalOctahedral(worldNormal);
// [-1, 1] 映射至 [0, 1] 以存入 8-bit / 10-bit 纹理
float2 quantized = encoded * 0.5 + 0.5; 
OutGBuffer1 = float4(quantized, roughness, metallic);

// ----------------------------------------------------
// [延迟光照通道读取]
float2 gbufferNormal = GBuffer1Texture.Sample(samplerState, uv).rg;
// 恢复至 [-1, 1] 空间
float2 encoded = gbufferNormal * 2.0 - 1.0; 
float3 worldNormal = DecodeNormalOctahedral(encoded);
FAQ: 向量比较机制剖析

问:float2 result = ...; (result >= 0.0) 的判断基准是要求 xy 分量同时都大于 0 吗?

答:不是的。在 HLSL/GLSL 中,向量的比较运算是逐分量(Component-wise)独立执行的。表达式 result >= 0.0 返回的是一个 bool2 值。三元运算符 ? : 也会据此生成对应的二维浮点向量(例如 x≥0, y<0 时返回 float2(1.0, -1.0))。利用硬件级的并行指令流,我们只需极简的代码即可计算出正确的符号修正矩阵(Sign Mask)。

机制总结

八面体法线映射用极低开销的代数计算,置换了昂贵的三角函数解算。它通过在 时引入巧妙的“对称折叠”,实现了表面法线向量的完美降维和数据重构,是我们在处理大规模场景渲染和高规格光照计算时不可或缺的底层基础设施。

参考与文献索引

Octahedron unit-vector encoding
Tangent Spaces and Diamond Encoding (Jeremy's Blog)
A Survey of Efficient Representations for Independent Unit Vectors (JCGT)
Octahedron Environment Maps