Skip to content

274. Shader 入门(一):uniform 与 UV 图案

Published:

Three.js 里常用的 MeshStandardMaterialMeshLambertMaterial 背后都是写好的顶点、片元着色器,参数只是在改 uniform。业务里一旦要做流动噪声、UV 扭曲、自定义光照,就绕不开 自己写 Shader:在 GPU 上并行跑的小程序,WebGL 里用 GLSL 书写。

着色器代码和前端业务代码最大的不同,是 思维维度:CPU 上我们习惯「顺序执行一次逻辑」,GPU 上则是「对每个顶点执行一遍顶点着色器、对每个像素执行一遍片元着色器」。所以你会看到大量 varying 把数据从顶点阶段插值到片元阶段,看到 uniform 从 JavaScript 每帧推一组全局参数。先把这一条流水线跑通,比死记语法更重要。

下面三节在同一条时间线上推进:先固定颜色确认管线用 uniform 传时间让画面动起来用 vUv 做空间图案。读完这一篇,你手里应该有一个可随时改 GLSL 的空白画板。

创建项目

在终端执行:

mkdir shader-playground
cd shader-playground
npm init -y

image.png

安装本地静态服务,避免用 file:// 直接打开页面导致 ES Module、跨域或缓存行为异常:

npm install --save-dev live-server

package.jsonscripts 里加入(与已有字段合并即可):

"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>

importmapthree 映射到 CDN,本机不必先 npm install three 也能跑;若你更喜欢锁版本,可再装 threenode_modules 并改成包导入,工程仍放在同一目录即可。

可选:安装类型定义,编辑器里能点进类型声明、跳文档:

npm install --save-dev @types/three

步骤一:ShaderMaterial 与固定灰平面

第一个目标:不求好看,只求编译通过、屏幕上有可控颜色。用 ShaderMaterial 传入两段字符串:vertexShader 负责把顶点变换到裁剪空间,并把 uv 传给片元;fragmentShader 负责输出 gl_FragColor

顶点里务必声明 varying vec2 vUv,并在 main 里写 vUv = uvuv 是 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);
});

image.png

步骤二: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);
});

2026-04-19 22.21.02.gif

跑起来后,平面应呈现轻微周期性明暗变化。

步骤三:vUv 做渐变与条纹

有了时间,再加 空间vUv 在片元里是每个像素一组插值后的 UV,横向用 vUv.x、纵向用 vUv.y,配合 mix 做双色渐变,配合 fractstep 做重复条纹。下面示例里还用 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);
});

2026-04-19 22.21.46.gif

案例代码上传了小册仓库

总结

评论