最近看到一个很漂亮的 Three.js 特效案例——
星空下,极光缓缓流动,颜色在绿色和蓝色之间渐变,繁星点点还会闪烁。
先说说它用了什么技术
很多人看到这种"云雾状"效果,第一反应是"这肯定很复杂"。
其实背后就三个核心概念,搞懂这三个,你基本上看懂了 80%。
第一个是 GLSL 片元着色器。普通的 Three.js 用 JavaScript 画 3D 物体,但极光这种效果需要对每一个像素单独计算颜色,这就是片元着色器干的事。它跑在 GPU 上,速度极快,天生适合这类全屏特效。
第二个是光线步进(Ray Marching),这是模拟极光体积感的核心算法。想象你站在地面向天空射出一束光,这束光穿过"极光区域"时,沿途每隔一段距离采样一次颜色和密度,最后把所有采样值加起来——这就是你看到的极光颜色。步进次数越多效果越细腻,但性能消耗也越高,这个案例用了 75 次迭代,是个不错的平衡点。
第三个是分形噪声(Fractal Noise)。极光的形状不是规则的,而是有机的、流动的,这靠噪声函数实现。分形噪声就是多层噪声叠加,每层频率翻倍、权重减半,就像画山脉时先画大轮廓再叠细节。
好,概念有了,来看代码怎么组织的。
先把舞台搭起来
一切的起点是初始化 Three.js 场景,创建一个铺满屏幕的"画布",让着色器有地方运行。
这里有个值得注意的地方——用的是正交相机,而不是通常 3D 场景里的透视相机。原因很简单:我们画的不是立体模型,就是一张铺满全屏的矩形,不需要任何透视变形。
const scene = new THREE.Scene();
// 参数 (-1, 1, 1, -1) 正好对应屏幕四个边缘
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);舞台搭好之后,要把所有"旋钮"集中起来管理。极光的速度、颜色、星星的密度和闪烁频率,这些视觉参数都放进一个 settings 对象,再通过 lil-gui 暴露成右上角的交互面板,运行时可以实时拖拽调节。
const settings = {
auroraSpeed: 0.65, // 极光流动速度
noiseSeed: 19.6, // 噪声种子,改这个值会得到完全不同的极光形态
colorBase: '#59ff03', // 极光底色(绿色)
colorHigh: '#00aaff', // 极光高光色(蓝色)
starDensity: 0.073, // 星星密度
starBlinkRate: 6.26, // 星星闪烁频率
// ...
};着色器的主流程
真正的魔法在着色器里。每一帧,main() 函数都会对屏幕上的每个像素执行一次,决定它该是什么颜色。
整个绘制顺序是:先画天空背景,再叠星星,最后叠极光,然后做色彩后期处理。就像 Photoshop 里的图层,从下往上一层一层叠。
void main() {
// 根据像素位置计算视线方向——这个像素"看向"哪里
vec3 sightVec = normalize(vec3(screenPos, -focalLen));
// 相机缓慢摇摆,让静止的场景有呼吸感
gazePoint.x += sin(iTime * 0.1) * 0.1;
gazePoint.y += cos(iTime * 0.05) * 0.05;
// 第1层:深蓝渐变天空
vec3 finalOutput = paintBackdrop(sightVec);
// 底部淡出遮罩,让星星和极光不会"长"在地平线以下
float fadeMask = smoothstep(0.05, 0.4, screenY);
// 第2层:星星
finalOutput += renderStarfield(sightVec, iTime) * fadeMask;
// 第3层:极光(光线步进)
finalOutput += renderAtmosphericLights(...) * fadeMask;
// 色调映射防止过曝,Gamma 校正还原真实亮度感
finalOutput = applyToneMapping(finalOutput);
finalOutput = pow(finalOutput, vec3(0.4545));
}极光是怎么渲染出来的
极光渲染是整个代码里最核心的部分,也就是前面说的光线步进。
renderAtmosphericLights 做的事情是:先判断当前视线有没有穿过极光区域(一个预设的三维包围盒),没穿过就直接返回黑色跳过计算;穿过了就把这段路程均分成 75 段,逐步采样、累加颜色。
vec3 renderAtmosphericLights(vec3 camOrg, vec3 camDir, float noiseShift) {
bool hitVolume = checkVolumeHit(camOrg, camDir, startTrace, traceLen);
if(!hitVolume) return vec3(0.0); // 视线没穿过极光区,直接跳过
float marchStep = traceLen / RAY_ITERATIONS; // 每步步长
for(float stepIdx = 0.0; stepIdx < RAY_ITERATIONS; stepIdx++) {
float localDens = sampleCloudThickness(currentPos); // 这个点有多亮?
// 高度越高越偏蓝,低处偏绿——模拟真实极光的色彩分层
lightAccum += localDens * blendAtmosphereTints(heightRatio);
currentDist += marchStep;
}
return LUMINANCE_FACTOR * lightAccum * marchStep;
}每一步里都会调用 sampleCloudThickness,它决定空间中某个点的极光亮度。这个函数有个巧妙的地方:用分形噪声生成基础形状之后,在垂直方向乘以一个很小的系数(0.006),把原本膨胀的"云团"压成极薄的一层。
就是这个"压扁"操作,让极光看起来像帘子,而不是实心球。
float sampleCloudThickness(vec3 localPt) {
// 分形噪声生成流动的图案,时间驱动,持续变化
float basePattern = fractalVolumePattern(shiftedPt, 3);
// 垂直方向乘以 0.006,云团被压成薄薄一层
vec3 squishedPt = shapePt * vec3(1.0, 0.006, 1.0);
squishedPt.y += 0.48; // 控制极光悬浮的高度
// 离中心越近越亮,形成自然的光晕边缘
float thickness = calculateRadiance(length(squishedPt), 0.55, 12.0);
return max(0.0, thickness);
}星星比想象中简单
星星的实现要简单很多,用的是网格哈希法。
思路是把天空分成很多小格子,每个格子根据自身坐标生成一个固定随机数——随机数够大,这个格子就有一颗星,随机数不够大就是空的。因为同一个坐标每次算出来的值一样,星星位置就不会乱跳。
闪烁的处理也很克制:只让 15% 的星星闪,其余保持稳定。过多闪烁反而让人眼花缭乱。
vec3 renderStarfield(vec3 viewDir, float timeFlow) {
// 把视线方向映射到网格,每个格子一个固定随机数
vec3 spaceGrid = floor(viewDir * gridScale);
float cellHash = computeHash3(spaceGrid);
// 随机数超过阈值才显示这颗星
float starExistence = step(threshold, cellHash);
// 只有 15% 的星星会闪
float isTwinkling = step(0.85, blinkChance);
float blinkAnim = 0.3 + 0.7 * sin(timeFlow * uStarBlinkRate + cellHash * 100.0);
float twinkleFlow = mix(1.0, blinkAnim, isTwinkling);
// 冷蓝色和暖色混合,让星星颜色有细微差异,更自然
vec3 starTint = mix(vec3(0.7, 0.85, 1.0), uStarColor, fract(cellHash * 13.0));
return starTint * starExistence * starGlow * twinkleFlow;
}源码
https://codepen.io/sabosugi/pen/XJjoprL (opens in a new tab)