Three.js教程
Shader
threejs-aurora
threejs-aurora

最近看到一个很漂亮的 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)