Skip to content

278. Shader 入门(五):用 Shader 实现云彩

Published:

前面我们已经用过 vUvuniform、贴图采样、value noise。要做“像云一样”的东西,单层噪声通常不够,看起来像灰色污渍;真正的云更像 多尺度结构叠加:大轮廓 + 中等褶皱 + 小细节,再配上柔和的阈值与一点点光照感。

这一篇就做一件事:在同一个 shader-playground 工程里,只替换 main.js,用 simplex 噪声(snoise) + fBm(分形布朗运动,多层噪声叠加) + domain warp(用噪声扭曲采样坐标),得到一个可以动的云层平面。

云彩的“可信感”主要来自三点:

下面的实现是偏“二维云层贴片”的做法(屏幕空间/平面空间),不追求真实体积云,但足够作为很多场景的背景、天空盒替代、UI 氛围层。

在 shader-playground 中继续

仍然使用你前面建立好的 shader-playgroundindex.htmlpackage.jsonnpm run dev 都不动)。这一节只需要 整体替换 main.js

如果你想保留上一版效果,建议先把当前 main.js 复制一份,例如命名为 main.prev.js(可选,不强制)。

云彩 Shader:simplex + fBm + warp

main.js 完整替换为下面代码:

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1020);

const width = window.innerWidth;
const height = window.innerHeight;

const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 100);
camera.position.set(0, 0, 2);

const vertexShader = `
varying vec2 vUv;
void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

// 2D simplex noise: 常见的 Ashima 版本改写,适合入门练习
const fragmentShader = `
uniform float uTime;
uniform vec2 uResolution;
varying vec2 vUv;

vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }

float snoise(vec2 v) {
    const vec4 C = vec4(
        0.211324865405187,  // (3.0-sqrt(3.0))/6.0
        0.366025403784439,  // 0.5*(sqrt(3.0)-1.0)
        -0.577350269189626, // -1.0 + 2.0 * C.x
        0.024390243902439   // 1.0 / 41.0
    );

    vec2 i = floor(v + dot(v, C.yy));
    vec2 x0 = v - i + dot(i, C.xx);

    vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
    vec4 x12 = x0.xyxy + C.xxzz;
    x12.xy -= i1;

    i = mod289(i);
    vec3 p = permute(
        permute(i.y + vec3(0.0, i1.y, 1.0)) +
        i.x + vec3(0.0, i1.x, 1.0)
    );

    vec3 m = max(
        0.5 - vec3(
            dot(x0, x0),
            dot(x12.xy, x12.xy),
            dot(x12.zw, x12.zw)
        ),
        0.0
    );
    m = m * m;
    m = m * m;

    vec3 x = 2.0 * fract(p * C.www) - 1.0;
    vec3 h = abs(x) - 0.5;
    vec3 ox = floor(x + 0.5);
    vec3 a0 = x - ox;

    m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);

    vec3 g;
    g.x = a0.x * x0.x + h.x * x0.y;
    g.y = a0.y * x12.x + h.y * x12.y;
    g.z = a0.z * x12.z + h.z * x12.w;

    return 130.0 * dot(m, g);
}

float fbm(vec2 p) {
    float f = 0.0;
    float amp = 0.5;
    mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
    for (int i = 0; i < 5; i++) {
        f += amp * snoise(p);
        p = rot * p * 2.0 + vec2(11.3, 7.7);
        amp *= 0.5;
    }
    return f;
}

void main() {
    // 让不同屏幕比例下云的“尺度”一致一些
    vec2 uv = vUv;
    vec2 p = (uv - 0.5) * vec2(uResolution.x / uResolution.y, 1.0);

    float t = uTime * 0.06;

    // domain warp:先用低频噪声轻微扭曲采样坐标
    vec2 q;
    q.x = fbm(p * 1.2 + vec2(0.0, t));
    q.y = fbm(p * 1.2 + vec2(3.4, t));

    vec2 r = p + 0.35 * q;

    // 主密度:多尺度叠加
    float n = fbm(r * 1.6 + vec2(t * 1.2, t * 0.7));

    // 把 [-1,1] 近似映射到 [0,1]
    float d = 0.5 + 0.5 * n;

    // 云的“成团感”:阈值 + 软边
    float cloud = smoothstep(0.45, 0.78, d);

    // 细节:用更高频噪声调边缘,让云不那么“塑料”
    float detail = 0.5 + 0.5 * snoise(r * 6.0 + vec2(t * 2.0, -t * 1.5));
    cloud *= mix(0.85, 1.05, detail);

    // 简单“光照”:沿一个方向做密度差分,模拟云的亮边
    vec2 lightDir = normalize(vec2(-0.6, 0.8));
    float e = 0.01;
    float d1 = 0.5 + 0.5 * fbm((r + lightDir * e) * 1.6 + vec2(t * 1.2, t * 0.7));
    float c1 = smoothstep(0.45, 0.78, d1);
    float shade = clamp((c1 - cloud) * 6.0, -1.0, 1.0);

    vec3 sky = vec3(0.05, 0.07, 0.14);
    vec3 cloudCol = vec3(0.92, 0.95, 1.0);

    // 云内部稍微偏冷,边缘亮一点
    vec3 col = mix(sky, cloudCol, cloud);
    col += vec3(0.12, 0.14, 0.16) * shade * cloud;

    // 顶部更亮一点,增加天空层次
    col += vec3(0.02, 0.03, 0.05) * (1.0 - uv.y) * 0.6;

    gl_FragColor = vec4(col, 1.0);
}
`;

const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
    vertexShader,
    fragmentShader,
    uniforms: {
        uTime: { value: 0 },
        uResolution: { value: new THREE.Vector2(width, height) },
    },
});

const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableZoom = false;
controls.enablePan = false;
controls.enableRotate = false;

const clock = new THREE.Clock();

function tick() {
    requestAnimationFrame(tick);
    material.uniforms.uTime.value = clock.getElapsedTime();
    renderer.render(scene, camera);
}
tick();

window.addEventListener('resize', () => {
    const w = window.innerWidth;
    const h = window.innerHeight;
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
    renderer.setSize(w, h);
    material.uniforms.uResolution.value.set(w, h);
});

2026-04-19 22.38.45.gif

这里有几个你可以立刻改的旋钮(改一行就能看到变化):

案例代码上传了小册仓库

总结

这篇云彩 Shader 的骨架你可以记成一句话:simplex 噪声负责“随机但连续”,fBm 负责“多尺度结构”,warp 负责“卷曲感”,smoothstep 负责“成团与软边”

再补几句“为什么这样写”,你后面看别人的云 Shader 会更容易读懂。

第一,为什么这篇用 simplex,而不是上一节那种 value noise?value noise 是“格点随机 + 双线性插值”,做出来更像糊在一起的块,频率一高就容易露出格点方向的痕迹;simplex 属于梯度噪声,视觉上更自然,云边缘更像毛绒而不是马赛克。并不是 value noise 不能做云,而是你需要额外手段去掩盖格子感,入门阶段不太划算。

第二,fBm 为什么能让云“有层次”?因为它把不同尺度的噪声相加:低频决定大云团分布,中频决定褶皱,高频决定细边。这里每层把频率乘 2、幅度减半,是常见的能量分布;同时用一个旋转矩阵 rot 让采样方向每层略微旋转,能减少“沿某个方向重复”的条纹感。

第三,domain warp 解决什么问题?很多“纯 fBm”会像一张贴纸在飘动,只是整体平移;warp 用低频噪声扭曲采样坐标,相当于让纹理本身发生卷曲,云团会更像在翻滚。你把 r = p + 0.35 * q0.35 改成 0.0 试试,会立刻变得更平、更雾。

第四,为什么要 smoothstep?云更像“密度场”而不是“灰度图”:密度高的地方接近白,密度低的地方接近透明。smoothstep(a, b, d) 相当于给密度加阈值并做软过渡,云边才会柔。若直接 cloud = d;,画面会整体发灰,缺少云团轮廓。

最后那一点“简单光照”是便宜但有效的体积感近似:沿光照方向做一次密度对比(本质是差分),变化更大的地方更像朝向光的坡面,于是加一点亮度就会像云的亮边。它不是物理正确的散射,但在背景云层、氛围层里非常实用。你后面想做日落,只要把亮边染成偏橙的颜色,就会立刻有“晚霞打在云上”的感觉。

从工程角度看,这类“全屏云层”属于典型的 片元密集型 效果:每个像素都要跑多次噪声。你后面想进一步逼真,有两条常见路线:

你如果要把云做成可复用模块,建议把 fragmentShader 里的参数都提成 uniform(阈值、warp、速度、颜色),然后用 dat.gui 或你习惯的参数面板调,调到满意再固化数值。

评论