Three.js 里常用的 MeshStandardMaterial、MeshLambertMaterial 背后都是写好的顶点、片元着色器,参数只是在改 uniform。业务里一旦要做流动噪声、UV 扭曲、自定义光照,就绕不开 自己写 Shader:在 GPU 上并行跑的小程序,WebGL 里用 GLSL 书写。
着色器代码和前端业务代码最大的不同,是 思维维度:CPU 上我们习惯「顺序执行一次逻辑」,GPU 上则是「对每个顶点执行一遍顶点着色器、对每个像素执行一遍片元着色器」。所以你会看到大量 varying 把数据从顶点阶段插值到片元阶段,看到 uniform 从 JavaScript 每帧推一组全局参数。先把这一条流水线跑通,比死记语法更重要。
下面三节在同一条时间线上推进:先固定颜色确认管线 → 用 uniform 传时间让画面动起来 → 用 vUv 做空间图案。读完这一篇,你手里应该有一个可随时改 GLSL 的空白画板。
创建项目
在终端执行:
mkdir shader-playground
cd shader-playground
npm init -y

安装本地静态服务,避免用 file:// 直接打开页面导致 ES Module、跨域或缓存行为异常:
npm install --save-dev live-server
在 package.json 的 scripts 里加入(与已有字段合并即可):
"scripts": {
"dev": "live-server --port=5173"
}
新建 index.html,全屏无滚动条,只挂一个模块入口:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shader Playground</title>
<style>
body { margin: 0; overflow: hidden; }
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "https://esm.sh/three@0.174.0/build/three.module.js",
"three/addons/": "https://esm.sh/three@0.174.0/examples/jsm/"
}
}
</script>
<script type="module" src="./main.js"></script>
</body>
</html>
importmap 把 three 映射到 CDN,本机不必先 npm install three 也能跑;若你更喜欢锁版本,可再装 three 到 node_modules 并改成包导入,工程仍放在同一目录即可。
可选:安装类型定义,编辑器里能点进类型声明、跳文档:
npm install --save-dev @types/three
步骤一:ShaderMaterial 与固定灰平面
第一个目标:不求好看,只求编译通过、屏幕上有可控颜色。用 ShaderMaterial 传入两段字符串:vertexShader 负责把顶点变换到裁剪空间,并把 uv 传给片元;fragmentShader 负责输出 gl_FragColor。
顶点里务必声明 varying vec2 vUv,并在 main 里写 vUv = uv。uv 是 Three.js 内置属性,平面默认 0~1,后面所有图案都会用到。片元里先写死 vec3(0.15, 0.15, 0.18) 这种深灰,略偏蓝,方便肉眼区分「纯黑没渲染」和「真的就是这个颜色」。
把 main.js 写成下面完整内容:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
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);
}
`;
const fragmentShader = `
varying vec2 vUv;
void main() {
vec3 color = vec3(0.15, 0.15, 0.18);
gl_FragColor = vec4(color, 1.0);
}
`;
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
});
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);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
function tick() {
requestAnimationFrame(tick);
controls.update();
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);
});

步骤二:uniform 与 Clock 驱动时间
颜色写死只能做静态图。要让画面随时间变化,就要在片元里声明 uniform float uTime,并在 JavaScript 的 ShaderMaterial 上配置 uniforms: { uTime: { value: 0 } },每一帧在 tick 里写入 material.uniforms.uTime.value。
时间用 THREE.Clock().getElapsedTime() 取「从时钟创建开始的秒数」,和 requestAnimationFrame 同帧节奏,比手动 Date.now() 差分更干净。片元里用 sin(uTime) 把标量映射到 -1~1,再映射到灰度,就能做出呼吸灯式的明暗变化。
注意:GLSL 里声明的 uniform 名字、类型,必须和 JS 里 uniforms 的 key 一致,否则有的驱动直接编译失败,有的则静默用 0,排查起来很烦。
将 main.js 整体替换为:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
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);
}
`;
const fragmentShader = `
uniform float uTime;
varying vec2 vUv;
void main() {
float g = 0.15 + 0.1 * sin(uTime);
vec3 color = vec3(g, g, g + 0.05);
gl_FragColor = vec4(color, 1.0);
}
`;
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0 },
},
});
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);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
const clock = new THREE.Clock();
function tick() {
requestAnimationFrame(tick);
const t = clock.getElapsedTime();
material.uniforms.uTime.value = t;
controls.update();
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);
});

跑起来后,平面应呈现轻微周期性明暗变化。
步骤三:vUv 做渐变与条纹
有了时间,再加 空间:vUv 在片元里是每个像素一组插值后的 UV,横向用 vUv.x、纵向用 vUv.y,配合 mix 做双色渐变,配合 fract、step 做重复条纹。下面示例里还用 uTime 去微调混合权重和条纹相位,这样时间和空间就绑在一起了——后面做噪声、水面时全是这种组合。
将 main.js 再次整体替换为:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
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);
}
`;
const fragmentShader = `
uniform float uTime;
varying vec2 vUv;
void main() {
vec3 a = vec3(0.35, 0.25, 0.55);
vec3 b = vec3(0.2, 0.45, 0.5);
float t = vUv.x + 0.05 * sin(uTime + vUv.y * 6.28);
t = clamp(t, 0.0, 1.0);
vec3 grad = mix(a, b, t);
float lines = step(0.92, fract(vUv.x * 12.0 + uTime * 0.5));
vec3 color = mix(grad, vec3(1.0), lines * 0.15);
gl_FragColor = vec4(color, 1.0);
}
`;
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0 },
},
});
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);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
const clock = new THREE.Clock();
function tick() {
requestAnimationFrame(tick);
material.uniforms.uTime.value = clock.getElapsedTime();
controls.update();
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);
});

案例代码上传了小册仓库
总结
- ShaderMaterial 把顶点、片元字符串和
uniforms绑在一起,是自定义材质的核心入口。 - uniform 传全局参数(时间、分辨率、贴图),varying 传插值量(如
vUv),片元里写的是「对每个像素的颜色决策」。 - 下一篇仍在同一目录,把 Canvas 纹理 和 程序化噪声 接进片元,开始和资源、随机函数打交道。