菲涅尔效应在计算机图形学中常用来描述光线从透明介质表面反射时,入射角越接近掠射角反射率越高的现象。把这个规律映射到二维图像上,就形成了 Fresnel2Image 的核心思路:图像中心对应法线方向,反射较弱,边缘对应切线方向,反射增强,于是得到一种中心暗、边缘亮的径向渐变效果。Node.js 本身并不直接提供像素级图像操作,但借助 node-canvas 或 Jimp 这类库,可以用 JavaScript 直接遍历像素并写入颜色值。

在开始编码之前,需要理解菲涅尔近似计算中的两个关键参数:法线与入射方向的夹角余弦值 cosTheta,以及基础反射率 F0。对于二维图像,可以用像素到中心的归一化距离来近似夹角变化。距离越小,越接近法线方向,菲涅尔项越小;距离越大,越接近边缘掠射方向,菲涅尔项趋近于 1。下面先推导公式,再把它应用到每个像素上。
一、菲涅尔方程与像素映射
菲涅尔反射率的完整公式包含两个偏振分量的平方项,计算复杂。实时图形学里普遍采用 Schlick 近似,因为它只需一次幂运算,就能得到足够视觉真实的结果。Schlick 近似的表达式为 F = F0 + (1 - F0)(1 - cosTheta)^5。F0 是垂直入射时的反射率,对于常见的透明材质如玻璃、塑料、水,通常取 0.02 到 0.08 之间。当 cosTheta 接近 1 时,F 接近 F0;当 cosTheta 接近 0 时,F 接近 1。
在二维图像中,如果以图像中心为原点建立坐标系,每个像素的 x、y 坐标归一化到 -1 到 1 之间,那么该点到中心的距离 r 就在 0 到根号 2 之间。直接用 r 作为夹角输入并不准确,但视觉效果足够。一种常见做法是定义 cosTheta = 1 - r,并把它限制在 0 到 1 之间。这样中心 r=0 时 cosTheta=1,菲涅尔项最小;边缘 r 接近 1 时 cosTheta 接近 0,菲涅尔项快速上升到 1。
需要注意的是,直接使用线性 r 计算出来的边缘可能过宽或过窄。实际生成 Fresnel2Image 时,可以对 r 做一次 power 曲线调整,例如 r = Math.pow(r, gamma),gamma 越小,亮度过渡越向边缘集中;gamma 越大,中心亮区范围越大。这样可以控制菲涅尔光晕的宽度,满足不同贴图需求。
二、使用 node-canvas 生成 Fresnel2Image
node-canvas 是 Node.js 中最接近浏览器 Canvas API 的实现,它可以把绘制结果导出为 PNG 或 JPEG 文件。安装依赖后,通过 createCanvas 创建画布,再通过 createImageData 获得可写的像素缓冲区。每个像素占 4 个字节,顺序为 RGBA,因此遍历时需要把二维坐标转换成一维索引。
下面这段代码会生成一张 512 乘 512 的灰度菲涅尔图像。代码里先定义 fresnelSchlick 函数计算反射系数,然后对每个像素计算归一化距离 r,再根据 r 得到 cosTheta,最终把反射系数映射到 0 到 255 的灰度值。为了让边缘更明显,alpha 通道也做了轻微衰减,中心完全不透明,边缘稍微降低不透明度,这样叠加到其他图层上时过渡会更柔和。
const fs = require('fs');
const { createCanvas } = require('canvas');
const width = 512;
const height = 512;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
function fresnelSchlick(cosTheta, f0) {
return f0 + (1 - f0) * Math.pow(1 - cosTheta, 5);
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const nx = (x / (width - 1)) * 2 - 1;
const ny = (y / (height - 1)) * 2 - 1;
const r = Math.min(Math.sqrt(nx * nx + ny * ny), 1);
const cosTheta = Math.max(0, 1 - r);
const f = fresnelSchlick(cosTheta, 0.04);
const gray = Math.floor(f * 255);
const alpha = Math.floor(255 * (1 - r * 0.4));
const idx = (y * width + x) * 4;
data[idx] = gray;
data[idx + 1] = gray;
data[idx + 2] = gray;
data[idx + 3] = alpha;
}
}
ctx.putImageData(imageData, 0, 0);
fs.writeFileSync('fresnel.png', canvas.toBuffer('image/png'));
console.log('saved fresnel.png');
运行这段代码前,需要先执行 npm install canvas。如果安装原生依赖遇到编译问题,可以换用纯 JavaScript 的 Jimp 库,它不需要本地编译,虽然性能略低,但生成几千像素以内的图像完全够用。Jimp 的位图数据同样是 RGBA 序列,遍历思路与上面几乎一致。
三、平滑过渡与色彩映射
灰度菲涅尔图像可以直接用作遮罩,但如果想做蓝紫渐变、青色高光或火焰边缘,就需要在灰度基础上做色彩映射。常见做法是把菲涅尔系数当作参数 t,然后通过一组颜色停止点进行线性插值。例如 t=0 时接近黑色或深蓝,t 接近 1 时变成青白或橙红。
下面的 mapColor 函数展示了三通道独立缩放的方式。它没有使用复杂的渐变库,只用 Math.min 和 Math.max 做了范围限制。你可以根据实际审美调整 R、G、B 的缩放系数和偏移量,生成冷色调或暖色调的菲涅尔光晕。
function mapColor(t) {
const r = Math.floor(255 * Math.min(1, Math.max(0, t * 1.2)));
const g = Math.floor(255 * Math.min(1, Math.max(0, t * 0.75)));
const b = Math.floor(255 * Math.min(1, Math.max(0, t * 1.5)));
return [r, g, b];
}
除了颜色映射,还可以在计算 r 时使用 smoothstep 函数,即 r' = r * r * (3 - 2 * r)。这样可以让中心到边缘的亮度变化更平缓,避免在中间区域出现生硬的断层。如果把 smoothstep 和 gamma 曲线叠加使用,能够获得类似镜头光晕的柔和效果,特别适合 UI 卡片的高光边框和 3D 场景中的粒子贴图。
四、性能优化与常见问题
当图像尺寸从 512 扩大到 2048 或 4096 时,像素循环次数会迅速增长。以 4096 乘 4096 为例,总共需要处理约 1677 万个像素,每个像素做平方、开方和幂运算,单线程耗时可能超过几秒。Node.js 的事件循环会被同步循环阻塞,这期间无法响应其他请求。对于一次性生成场景可以接受,但如果要批量产出,建议把计算放到 worker_threads 中执行。
使用 worker_threads 时,主线程只负责任务分发和结果收集,计算逻辑可以原样迁移到 worker 文件里。每个 worker 处理图像的一个横向切片,处理完后把该切片的 Uint8Array 传输回主线程,再由主线程写入完整位图。这样可以充分利用多核 CPU,同时避免阻塞主线程的事件循环。另一个优化方向是减少 Math.pow 的调用,Schlick 近似的五次方可以展开为连续乘法,在 JavaScript 引擎中通常比 pow 快。
const { Worker } = require('worker_threads');
function runGeneration() {
const worker = new Worker('./fresnel-worker.js', {
workerData: { width: 2048, height: 2048, f0: 0.04 }
});
worker.on('message', function(result) {
console.log('生成完成,耗时 ' + result.ms + ' 毫秒');
});
worker.on('error', function(err) {
console.error(err);
});
}
runGeneration();
常见错误集中在坐标归一化和数据类型上。例如除以 width 时忘了减 1,会让最右列和最底行的 r 达不到 1,导致边缘亮度不足。另一个问题是把 Uint8ClampedArray 和普通数组混用,写入超过 255 的值时,Uint8ClampedArray 会自动截断,而普通数组不会,最终输出图像可能出现颜色溢出或反色。建议始终使用 ImageData.data 提供的 Uint8ClampedArray 来写像素,并在写入前用 Math.floor 取整。
Node.jsFresnel2Image菲涅尔效果修改时间:2026-09-22 11:39:06