在基于物理的渲染管线中,高光形状并不是靠一个简单的指数曲线就能准确描述的。GGX分布又叫Trowbridge-Reitz分布,是微表面模型里描述法线分布函数(NDF)的一种数学形式。它在粗糙度较低时能产生非常紧凑锐利的高光,在粗糙度升高时又能保持较长的衰减尾,因此被Unity、Unreal Engine以及大多数离线渲染器采用。理解GGX分布的最直观方式,就是把它逐像素绘制成一幅图像,也就是标题里所说的GGX2Image。

GGX的NDF公式通常写作:D(h) = α² / (π((n·h)²(α² - 1) + 1)²)。其中 h 是半程向量,n 是表面法线,α 是粗糙度参数。实际使用中,很多实现会对粗糙度做平方,也就是让 α 等于用户输入的粗糙度的平方,这样高光变化更符合美术感知。公式中的 n·h 可以换成 cosθ,θ是半程向量偏离法线的角度。当 θ=0 时,n·h=1,此时 D 值达到峰值 1/(πα²);当 θ 接近 90 度时,n·h 趋近 0,D 值急剧衰减。
与其他NDF相比,GGX的尾部衰减比Blinn-Phong更慢,所以在掠射角附近能够保留更多高光能量。直接用一个公式生成二维图像,可以清晰看到粗糙度参数如何影响高光分布的范围和强度。
一、GGX分布公式与参数含义
GGX分布的核心输入是半程向量与法线的夹角余弦值。因为半程向量是视线向量和光线向量的中间方向,所以它的分布直接决定高光形状。公式中的 α 一般取用户粗糙度的平方,让粗糙度在0到1之间的变化更接近线性感知。这一步很重要:如果直接把用户粗糙度传给公式,低粗糙度区域的变化会过于集中,高粗糙度区域又显得不够敏感。
从数学上看,GGX的分母是 n·h 的二次函数的平方,这种结构使得它在 n·h=1 附近有较高的峰值,而在远离中心时衰减相对平缓。相比之下,Blinn-Phong使用余弦的幂次项,衰减更快。GGX的长尾特性意味着即使表面比较粗糙,在掠射角附近仍然会反射一部分光,这与现实中的材质表现一致。理解这一点,就能明白为什么PBR渲染中普遍选择GGX而不是更旧的Phong模型。
function computeGGX(alpha, cosTheta) {
if (cosTheta <= 0) return 0;
const a2 = alpha * alpha;
const denom = cosTheta * cosTheta * (a2 - 1) + 1;
return a2 / (Math.PI * denom * denom);
}
这段代码把 n·h 直接作为 cosTheta 传入。计算时先将粗糙度平方得到 a2,然后根据公式计算分母。分母中的 cosTheta * cosTheta * (a2 - 1) + 1 是原始公式展开后的写法,返回结果就是该点的NDF值。注意当 cosTheta 小于等于 0 时,半程向量已经转到表面下方,对正向反射没有贡献,所以直接返回 0。
二、坐标映射与像素值计算
要把GGX分布画成图像,首先需要确定横轴和纵轴各代表什么。最直接的方案是让横轴表示半程向量偏离法线的角度 θ,范围从 0 到 π/2;让纵轴表示粗糙度 α,范围从 0.05 到 1.0。这样每一行对应一个粗糙度,每一列对应一个角度,像素灰度值就是该坐标下的 NDF 响应值。这种二维扫描方式能够同时展示角度和粗糙度两个维度的变化,很适合观察GGX的整体形状。
如果直接使用 D(h) 的原始值作为灰度,会发现低粗糙度时中心像素的数值远大于 255,而边缘又非常接近 0,图像几乎全黑只有一个小亮点。为了让可视化更均衡,通常需要对 D 做一次归一化或对数映射。最简单的做法是用 Math.log(1 + d * k) 做色调映射,再缩放到 0 到 255。下面这个函数只负责原始 GGX 计算,后续映射留在图像编码阶段处理。
三、Node.js写图像文件与完整实现
Node.js本身没有内置的Canvas或图像编码API,但生成一张简单灰度图并不需要复杂依赖。PPM格式是文本加二进制的组合头部,可以手写。PPM P6模式只需要一个ASCII文件头 P6、宽高和最大色值,后面紧跟RGB字节流。这个特性特别适合验证算法,因为不用处理PNG的压缩和CRC校验。
下面的代码把上面的计算逻辑扩展成完整脚本。它逐行设置粗糙度,逐列计算角度,把归一化后的 D 值写入像素缓冲,最后用 fs.writeFileSync 写出 ggx.ppm 文件。PPM文件可以被GIMP、Photoshop、IrfanView以及大多数图像查看器打开。执行时只需要确保当前目录有写权限,不需要安装任何第三方包。
const fs = require('fs');
const width = 512;
const height = 256;
const maxVal = 255;
function computeGGX(alpha, cosTheta) {
if (cosTheta <= 0) return 0;
const a2 = alpha * alpha;
const denom = cosTheta * cosTheta * (a2 - 1) + 1;
return a2 / (Math.PI * denom * denom);
}
const header = Buffer.from(`P6\n${width} ${height}\n${maxVal}\n`, 'ascii');
const pixels = Buffer.alloc(width * height * 3);
for (let y = 0; y < height; y++) {
const alpha = 0.05 + (y / (height - 1)) * 0.95;
for (let x = 0; x < width; x++) {
const theta = (x / (width - 1)) * Math.PI / 2;
const cosTheta = Math.cos(theta);
const d = computeGGX(alpha, cosTheta);
const v = Math.min(255, Math.floor(Math.log(1 + d * 12) / Math.log(1 + 12) * 255));
const idx = (y * width + x) * 3;
pixels[idx] = v;
pixels[idx + 1] = v;
pixels[idx + 2] = v;
}
}
fs.writeFileSync('ggx.ppm', Buffer.concat([header, pixels]));
这个脚本可以直接保存为 ggx2image.js,执行 node ggx2image.js 即可。代码里使用 Math.log(1 + d * 12) 做对数映射,避免中心亮度过高导致图像全部溢出。如果希望直接生成PNG,可以使用 pngjs 或 sharp 一类的包,把 pixels 交给它们编码即可。不过核心的GGX计算和坐标映射不会变,换一种文件格式只是编码层的事情。
四、参数调节与图像结果分析
运行脚本后,图像顶部对应粗糙度0.05,底部对应粗糙度1.0。顶部行只有中心几列有亮度,说明低粗糙度时微表面法线高度集中在几何法线附近;底部行的亮区明显向两侧扩展,表示高粗糙度下微表面法线分布更加分散。这个变化过程直接对应PBR材质从镜面到漫反射的过渡。
在实际渲染中,GGX分布通常还会配合几何衰减项和菲涅尔项一起构成完整的BRDF。单看NDF可以判断高光中心强度和尾部衰减是否合理,但没法完整描述表面反射。如果发现图像右侧仍有较多能量,这是GGX的尾部特性导致的,与Blinn-Phong相比,它在接近掠射角时衰减更慢,这也是它更接近真实材质的原因之一。
还可以把纵轴改成方位角φ,固定粗糙度,观察GGX是否具有各向同性。各向同性GGX只依赖 n·h,所以图像会沿垂直方向均匀分布;如果引入各向异性参数,就需要同时考虑切线和副切线方向的粗糙度,此时二维图像会更复杂,但核心思路仍然相同。通过Node.js实现GGX2Image,最核心的价值不是输出漂亮图片,而是用少量代码把抽象的PBR公式变成可检查、可调试、可分享的可视化结果。