前面我们已经用过 vUv、uniform、贴图采样、value noise。要做“像云一样”的东西,单层噪声通常不够,看起来像灰色污渍;真正的云更像 多尺度结构叠加:大轮廓 + 中等褶皱 + 小细节,再配上柔和的阈值与一点点光照感。
这一篇就做一件事:在同一个 shader-playground 工程里,只替换 main.js,用 simplex 噪声(snoise) + fBm(分形布朗运动,多层噪声叠加) + domain warp(用噪声扭曲采样坐标),得到一个可以动的云层平面。
云彩的“可信感”主要来自三点:
- 多层频率叠加:低频决定云团位置,高频决定边缘毛糙与细节。
- 阈值与软边:用
smoothstep把噪声变成“云密度”,让边缘羽化,而不是硬切。 - 简单的明暗:即使不做真实体积散射,给密度一个近似的“光照方向”也会立刻像云。
下面的实现是偏“二维云层贴片”的做法(屏幕空间/平面空间),不追求真实体积云,但足够作为很多场景的背景、天空盒替代、UI 氛围层。
在 shader-playground 中继续
仍然使用你前面建立好的 shader-playground(index.html、package.json、npm 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);
});

这里有几个你可以立刻改的旋钮(改一行就能看到变化):
fbm的层数:for (int i = 0; i < 5; i++)改成 4 或 6。层数越多细节越丰富,但片元更重。- 云的阈值:
smoothstep(0.45, 0.78, d)两个值越接近,云边越硬;差距越大,云越“雾”。 - warp 强度:
r = p + 0.35 * q的 0.35 改大云更卷、改小更平。 - 动画速度:
t = uTime * 0.06改大云飘得更快;动得太快会像水波而不是云。
案例代码上传了小册仓库
总结
这篇云彩 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 * q 的 0.35 改成 0.0 试试,会立刻变得更平、更雾。
第四,为什么要 smoothstep?云更像“密度场”而不是“灰度图”:密度高的地方接近白,密度低的地方接近透明。smoothstep(a, b, d) 相当于给密度加阈值并做软过渡,云边才会柔。若直接 cloud = d;,画面会整体发灰,缺少云团轮廓。
最后那一点“简单光照”是便宜但有效的体积感近似:沿光照方向做一次密度对比(本质是差分),变化更大的地方更像朝向光的坡面,于是加一点亮度就会像云的亮边。它不是物理正确的散射,但在背景云层、氛围层里非常实用。你后面想做日落,只要把亮边染成偏橙的颜色,就会立刻有“晚霞打在云上”的感觉。
从工程角度看,这类“全屏云层”属于典型的 片元密集型 效果:每个像素都要跑多次噪声。你后面想进一步逼真,有两条常见路线:
- 更真实:用 3D 噪声、体积步进(raymarch)做体积云,配合多次采样和相位函数,但成本更高。
- 更实用:把噪声预烘到低分辨率纹理里(或用多通道噪声贴图),实时只做采样与少量混合,性能更稳。
你如果要把云做成可复用模块,建议把 fragmentShader 里的参数都提成 uniform(阈值、warp、速度、颜色),然后用 dat.gui 或你习惯的参数面板调,调到满意再固化数值。