Lafortune光照模型由Eric Lafortune在1997年提出,它是Phong模型的广义形式,用一个矩阵形式的多叶(lobe)反射项来描述物体表面的方向性反射特性。相比只能模拟单一高光方向的Phong模型,Lafortune模型可以表现各向异性反射、后向反射等多种真实材质现象。这篇文章介绍如何在Node.js环境中实现一个基于该模型的简易光线跟踪渲染器,最终输出一张真实感图像,也就是所谓Lafortune2Image——把Lafortune光照计算转化为可保存的图片文件。

一、Lafortune光照模型的数学原理
Lafortune模型的核心思想是用一个3x3的系数矩阵替换Phong模型中的镜面反射项。Phong模型中,镜面反射强度由反射向量与视线向量的点积的幂次决定,而Lafortune模型则把入射方向和出射方向都变换到局部坐标系中,再通过矩阵作用得到一个标量,最后取幂次。数学上可以写成:对于入射方向L和出射方向V(均为指向光源和眼睛的单位向量),镜面项为 (Lx*M11*Vx + Ly*M22*Vy + Lz*M33*Vz)^n,其中M11、M22、M33是矩阵对角元素,n是高光指数。
这个看似简单的改动带来了很强的表达能力。当M11=M22=M33时,模型退化为标准的Phong模型;当三个系数不相等时,可以模拟各向异性高光,比如拉丝金属表面的细长高光;当系数为负值时,高光峰会朝向入射方向的另一侧偏移,能够表现后向反射现象,这正是月球表面看起来在满月时特别亮的原因。因此在实现时,我们把材质定义为一组lobe,每个lobe包含三个系数和一个指数,多个lobe叠加可以逼近更复杂的反射分布。
在代码层面,每个材质可以这样描述:
// Lafortune材质定义
const material = {
diffuse: [0.8, 0.2, 0.2], // 漫反射颜色 RGB
lobes: [
{ coeffs: [0.6, 0.6, 0.6], exponent: 60 }, // 近似Phong的lobe
{ coeffs: [-0.2, -0.2, 0.5], exponent: 15 } // 后向反射lobe
]
};
二、搭建Node.js渲染框架:像素缓冲与几何求交
在Node.js中做渲染,第一个问题是没有DOM和Canvas(除非引入node-canvas依赖)。为了保持零依赖,我们直接用一个Float32Array作为像素缓冲区,逐像素计算颜色,最后编码成PPM格式输出。PPM是最简单的图片格式之一,文件头是宽高和最大颜色值,后面依次是每个像素的RGB字节,用图片查看器或转换工具都能打开。如果需要PNG输出,也可以通过npm安装pngjs,代价非常小。
几何部分我们从最简单的球体和平面入手。球体求交只需解一元二次方程,代码简洁且能清晰展示光照效果。光线定义为起点O加方向D乘以参数t,对每个像素发射一条主光线,找到最近的交点后,在该点计算法线、光照方向和视线方向,再代入Lafortune模型求颜色。完整框架如下:
// 简易向量运算
function sub(a, b) { return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
function dot(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
function normalize(v) {
const len = Math.sqrt(dot(v, v));
return [v[0]/len, v[1]/len, v[2]/len];
}
// 球体求交,返回最近t或null
function intersectSphere(origin, dir, center, radius) {
const oc = sub(origin, center);
const b = 2 * dot(oc, dir);
const c = dot(oc, oc) - radius * radius;
const disc = b * b - 4 * c;
if (disc < 0) return null;
const sq = Math.sqrt(disc);
const t1 = (-b - sq) / 2, t2 = (-b + sq) / 2;
if (t1 > 0.001) return t1;
if (t2 > 0.001) return t2;
return null;
}
注意这里使用了0.001的epsilon偏移来避免浮点误差导致的自相交问题。这是一种常见的阴影粉刺(shadow acne)防护手段,在后面计算阴影光线时同样需要应用。
三、实现Lafortune着色与图像输出
着色阶段需要把入射方向L和视线方向V都变换到表面局部坐标系。构造局部坐标系的方法是:取表面法线N作为z轴,再选取一个与N不平行的辅助向量做叉积得到x轴,最后用N叉乘x轴得到y轴。Lafortune原始论文的一个巧妙之处在于,如果把方向向量在世界坐标中直接与法线对齐处理,可以避免显式构造切线基,实现上更简洁。下面给出完整的着色函数:
function lafortuneShade(point, normal, lightDir, viewDir, mat) {
// 漫反射项(朗伯体)
const ndl = Math.max(0, dot(normal, lightDir));
let r = mat.diffuse[0] * ndl;
let g = mat.diffuse[1] * ndl;
let b = mat.diffuse[2] * ndl;
// 叠加每个镜面lobe
let spec = 0;
for (const lobe of mat.lobes) {
const term =
lightDir[0] * lobe.coeffs[0] * viewDir[0] +
lightDir[1] * lobe.coeffs[1] * viewDir[1] +
lightDir[2] * lobe.coeffs[2] * viewDir[2];
spec += Math.pow(Math.max(0, term), lobe.exponent);
}
// 高光统一为白色
r += spec; g += spec; b += spec;
return [Math.min(1, r), Math.min(1, g), Math.min(1, b)];
}
// 输出PPM图像
function savePPM(width, height, buffer, file) {
const fs = require('fs');
let header = `P6\n${width} ${height}\n255\n`;
const data = Buffer.from(buffer.map(v => Math.round(v * 255)));
fs.writeFileSync(file, Buffer.concat([Buffer.from(header), data]));
}
主循环部分,遍历所有像素,根据视场角计算光线方向,找到最近交点后调用着色函数。为了增强立体感,还可以加入简单的硬阴影:从交点向光源发射一条阴影光线,如果被其他物体挡住则只保留环境光。整个渲染器在Node.js中跑一张640x480的图像通常只需一两百毫秒,性能完全够用,说明JavaScript引擎的JIT优化对这类数值密集计算已经有很好的支持。
渲染完成后运行node render.js,会在当前目录生成output.ppm文件,用GIMP或在线工具即可查看。如果想直接得到PNG,安装pngjs后把savePPM替换成PNG流写入即可,改动不超过十行代码。
四、与Phong模型的效果对比及调参建议
把材质中lobe的三个系数全部设为相同值,渲染结果和Phong模型几乎一致;而将系数设为诸如(1, 0.1, 1)的组合时,高光会明显沿着一个方向拉伸,这正是各向异性材质的表现。如果把某一个lobe的系数全部设为负数,比如(-0.3, -0.3, -0.3),会观察到高光偏向光源一侧而非反射方向,这就是后向反射。通过对比这三组参数的渲染图,可以直观理解Lafortune模型比Phong强大的表达能力。
调参方面有几个实用经验。首先,多个lobe的系数之和不宜过大,否则高光区域会过曝,可以在最终输出前对颜色做tone mapping,比如简单的Reinhard映射 c/(1+c)。其次,指数n控制高光锐利程度,取值范围通常在10到200之间,金属材质用大指数,塑料用小指数。最后,漫反射颜色和镜面lobe之间要保持能量上的平衡,物理上两者的总和不应超过1太多,否则渲染结果会显得不真实。掌握这些规律后,你就可以通过调参数值逼真地还原铜、陶瓷、织物等不同材质,进一步还可以把这套着色逻辑集成到ray marching或WebGL管线中,实现更复杂的场景渲染。
Node.jsLafortune光照模型真实感渲染修改时间:2026-09-12 06:44:36