首页 / Three.js 入门教程 / 着色器基础:GPU 上的小程序

Three.js 入门教程

着色器基础:GPU 上的小程序

本教程共 40 篇 · 第 33 篇 · 更新于 2026-08-14 · 约 8 分钟阅读

Three.js着色器ShaderMaterial顶点着色器片元着色器uniform

本节目标:理解着色器是什么、顶点着色器和片元着色器怎么分工,掌握 attribute、uniform、varying 三种变量的数据流,会用 ShaderMaterial 写出第一个自定义材质。

从这一章开始,进入 three.js 最硬核也最自由的部分:着色器。内置材质做不到的效果——水面、岩浆、发光描边、粒子风暴——几乎都靠它。概念不复杂,但和普通 JavaScript 编程的思维完全不同。

着色器是什么

着色器(Shader)是跑在 GPU 上的小程序,用 GLSL 语言编写。普通的 JS 代码跑在 CPU 上,一条一条执行;着色器跑在 GPU 上,成千上万份副本同时执行。

每一份副本处理一个”小单元”:

  • 顶点着色器(vertex shader):每个顶点执行一次;
  • 片元着色器(fragment shader):每个像素(片元)执行一次。

一个 10 万顶点的模型,顶点着色器就被执行 10 万次;1920×1080 的屏幕,片元着色器最多被执行 200 多万次。所以着色器里的代码必须简单高效,任何计算都是放大百万倍执行的。

两个着色器的分工

一次绘制,两个着色器接力完成:

  1. 顶点着色器:决定顶点画在哪里。输入几何体的顶点数据,输出裁剪空间坐标(gl_Position);
  2. 片元着色器:决定像素是什么颜色。输入插值后的数据,输出颜色(gl_FragColor)。

中间过程由 GPU 自动完成:顶点着色器跑完后,GPU 把三角形连起来,扫描每个被覆盖的像素,把顶点的数据按位置插值,交给片元着色器。这个”自动插值”是理解 varying 的关键。

三种变量的数据流

自定义着色器里,数据通过三种变量进出:

  • attribute:每个顶点一份的数据,来自几何体。position、uv、normal 都是 attribute;
  • uniform:所有顶点、所有像素共享一份,来自 JavaScript。每帧可更新,动画时间就放这里;
  • varying:顶点着色器写给片元着色器的数据,GPU 会自动插值。

数据流可以画成一条线:

几何体 attribute ──> 顶点着色器 ──varying──> 片元着色器 ──> 屏幕
       ↑                                        ↑
    uniform 从 JS 进来                 uniform 从 JS 进来

uniform 两边都能用;attribute 只能进顶点着色器;varying 只在两个着色器之间传递。记住这条线,着色器代码就不会乱。

Note

three.js 里 attribute 和 uniform 的命名习惯:attribute 不带前缀(position、uv、normal),自定义 uniform 常用 u 开头(uTime、uColor)。这是社区约定,帮你一眼分清变量来源。
varying 的插值值得展开说。三角形三个顶点各有一个值,比如 vUv 分别是 (0,0)、(1,0)、(0,1)。三角形中间的像素该用哪个?GPU 按像素到三个顶点的距离加权平均,越靠近哪个顶点,越像哪个顶点的值。这就是插值:顶点提供”样本”,片元收到”渐变”。顶点着色器里任何 varying 都会被这样处理,所以片元着色器收到的 vUv 是连续渐变的,画出来的颜色才是平滑过渡。

three.js 自动提供的变量

ShaderMaterial 里不用自己声明,three.js 会自动注入一批常用变量:

变量类型含义
positionattribute顶点局部坐标
uvattribute纹理坐标,范围 0~1
normalattribute顶点法线
modelViewMatrixuniform模型视图矩阵
projectionMatrixuniform投影矩阵
cameraPositionuniform相机世界坐标

标准公式 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0) 就是把这些内置变量串起来:先把局部坐标变换到视图空间,再投影到裁剪空间。刚学时可以照抄这个公式,不必深究矩阵细节(第 9 章讲过变换,矩阵运算在第 34 章)。

ShaderMaterial 的基本结构

ShaderMaterial 接收两个字符串:顶点着色器源码和片元着色器源码。着色器代码通常放在 HTML 的 script 标签里,用 textContent 取出来,避免在 JS 里拼字符串。

<script type="x-shader/x-vertex" id="vertexShader">
  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
</script>

<script type="x-shader/x-fragment" id="fragmentShader">
  uniform float uTime;
  varying vec2 vUv;

  void main() {
    vec3 color = vec3(vUv.x, vUv.y, 0.5 + 0.5 * sin(uTime));
    gl_FragColor = vec4(color, 1.0);
  }
</script>

两个着色器都有 main 函数,这是 GPU 程序的入口。顶点着色器里给 vUv 赋值;片元着色器里同名声明 vUv,就能收到插值后的纹理坐标。颜色用 vUv 拼出来:左下角是 (0,0) 黑色,右上角偏 (1,1),蓝色分量随时间正弦波动。

Note

ShaderMaterial 会自动注入 position、uv、modelViewMatrix 这些内置变量。想要完全空白、什么都不注入的材质,用 RawShaderMaterial——它连矩阵都要自己声明,入门用不到,知道存在即可。
JS 侧用 ShaderMaterial 组合它们:

const material = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0 }
  },
  vertexShader: document.getElementById('vertexShader').textContent,
  fragmentShader: document.getElementById('fragmentShader').textContent
});

uniforms 对象的每个键对应着色器里的一个 uniform 声明。注意结构:{ value: 0 },值必须包在 value 里。

完整示例:动画渐变平面

把上面几段拼成一个能直接运行的页面:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title>33 着色器基础</title>
  <style>body { margin: 0; overflow: hidden; }</style>
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
    }
  }
  </script>
</head>
<body>
  <script type="x-shader/x-vertex" id="vertexShader">
    varying vec2 vUv;

    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  </script>

  <script type="x-shader/x-fragment" id="fragmentShader">
    uniform float uTime;
    varying vec2 vUv;

    void main() {
      vec3 color = vec3(vUv.x, vUv.y, 0.5 + 0.5 * sin(uTime));
      gl_FragColor = vec4(color, 1.0);
    }
  </script>

  <script type="module">
    import * as THREE from 'three';

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 100);
    camera.position.z = 2;

    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(innerWidth, innerHeight);
    document.body.appendChild(renderer.domElement);

    const material = new THREE.ShaderMaterial({
      uniforms: {
        uTime: { value: 0 }
      },
      vertexShader: document.getElementById('vertexShader').textContent,
      fragmentShader: document.getElementById('fragmentShader').textContent
    });

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

    renderer.setAnimationLoop(() => {
      material.uniforms.uTime.value += 0.02; // 每帧更新 uniform
      renderer.render(scene, camera);
    });
  </script>
</body>
</html>

动画的秘诀在最后几行:每帧给 material.uniforms.uTime.value 加一点,片元着色器里的 sin(uTime) 就会持续变化。uniform 就是 JS 和 GPU 之间的传声筒。

顶点着色器也能做动画。想让平面像旗帜一样飘动,就在顶点着色器里改位置:

// 顶点着色器片段:按 x 坐标做波浪
uniform float uTime;

void main() {
  vec3 pos = position;
  pos.z += sin(pos.x * 3.0 + uTime) * 0.2;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}

顶点着色器动的是”骨架”,片元着色器动的是”皮肤”。位移、变形这类几何操作放顶点着色器,颜色、光照放片元着色器,分工清楚,代码自然清晰。

着色器常见坑

写 GLSL 和写 JS 手感差很多,几个高频翻车点:

  1. 字符串里没有语法高亮和报错,出错时控制台只有编译日志,行号要自己数;
  2. GLSL 是强类型,float 和 int 不能直接混用,1.0 才是浮点数,1 是整数;
  3. 每条语句必须分号结尾,漏一个整个着色器编译失败;
  4. 变量必须先声明后使用,没有 JS 的变量提升;
  5. 着色器里不能 console.log,调试靠”把值写进颜色”:gl_FragColor = vec4(0.5 + 0.5 * vUv.x, 0.0, 0.0, 1.0) 就能把数值可视化。
Tip

渲染全黑时,先检查顶点着色器的 gl_Position 公式;颜色不对时,检查片元着色器的取值范围——很多颜色值是负数或大于 1,会被截断成黑或白。

着色器是字符串这件事,是 three.js 长期被吐槽的点。下一章讲 GLSL 语法细节,第 35 章讲 three.js 的新答案 TSL——用 JavaScript 对象写着色器,彻底告别字符串。