Three.js案例
从0到1实现一个 GLSL 实时预览编辑器

今天,我们基于 webgl 来一起实现一个 glsl 的实时预览的编辑器。

一、布局

1.1 基本 HTML 结构

首先,在 body 中我们需要一个用于承载编辑器和渲染区域的容器 <div id="app">。其中,分别放置:

  • <textarea id="editor"></textarea>:文本编辑器区域,将由 CodeMirror 进行强化。
  • <canvas id="glCanvas"></canvas>:用于显示 WebGL 渲染结果的画布。
<body>
  <div id="app">
    <textarea id="editor"></textarea>
    <canvas id="glCanvas"></canvas>
  </div>
</body>

1.2 基本 CSS 布局

为了让编辑器和渲染画布平分屏幕,在 CSS 中设置:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
 
body {
  background-color: #282a36; /* 与dracula主题搭配 */
}
 
#app {
  display: flex;
  justify-content: space-between;
  width: 100%;
}
 
#editor {
  width: 50vw; /* 编辑器占据一半屏幕宽度 */
  height: 100vh;
}
 
#glCanvas {
  width: 50vw; /* WebGL画布同样占据一半屏幕宽度 */
  height: 100vh;
}

这样,页面就分成了左右两块:左侧为编辑器(白色区域),右侧为 WebGL 预览区域(黑色区域)。

二、添加编辑器

为了让我们在网页中进行 GLSL 代码的可视化编辑,需要引入一个文本编辑器库。这里我们选用 CodeMirror (opens in a new tab) 作为示例。

2.1 引入 CodeMirror 依赖

<head> 中通过 CDN 引入必要文件(若你本地有下载,也可改用本地文件):

<!-- CodeMirror JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"></script>
<!-- clike 模式支持 C、C++、GLSL 等语言的语法高亮 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/clike/clike.min.js"></script>
 
<!-- CodeMirror CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css" />
<!-- 这里使用 Dracula 主题 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/dracula.min.css" />

2.2 初始化编辑器

接下来的代码我们需要再 script 中完成

<script></script>

2.2.1 定义初始 GLSL 代码

先写好一段最基础的片段着色器(fragment shader),例如:

let fragmentShaderSource = `
precision mediump float;
uniform float iTime;
uniform vec2 iResolution;
void main() {
    // 将屏幕坐标转换到 [0,1] 区间
    vec2 uv = gl_FragCoord.xy / iResolution.xy;
    // 简单的循环彩色
    vec3 color = 0.5 + 0.5 * cos(iTime + uv.xyx + vec3(0,2,4));
    gl_FragColor = vec4(color, 1.0);
}
`;

2.2.2 创建 CodeMirror 实例

在脚本中,通过 CodeMirror.fromTextArea() 将我们之前的 <textarea> 转换成一个带有语法高亮和行号的编辑器。

let editor = CodeMirror.fromTextArea(document.getElementById("editor"), {
  mode: "x-shader/x-fragment", // 告诉 CodeMirror 这是 GLSL 片段着色器
  theme: "dracula", // 使用暗色 Dracula 主题
  lineNumbers: true, // 显示行号
});
// 将初始 GLSL 代码放入编辑器
editor.setValue(fragmentShaderSource);

到此我们在左侧就已经显示出来高亮的代码了。

三、添加 GLSL 预览

在这个环节,我们要将用户编写/修改的 GLSL 片段着色器(Fragment Shader)代码实时地在 <canvas> 上进行渲染预览。这里最核心的是WebGL 的流程。可以将其拆分为以下关键步骤:

  1. 获取 WebGL 上下文
  2. 编写并编译着色器
  3. 创建并链接着色器程序
  4. 准备绘制数据(全屏三角形/矩形)
  5. 关联并更新 Uniform、Attribute
  6. 使用 requestAnimationFrame 进行连续渲染
  7. 实现实时更新 GLSL 代码

3.1 获取 WebGL 上下文

3.1.1 canvas.getContext("webgl")

在 JavaScript 中,想要使用 WebGL 绘图功能,需要先通过 <canvas> 元素的 getContext("webgl")(或 experimental-webgl)方法获取 WebGL 的上下文对象,也就是 gl。它相当于是我们对底层 GPU 操作的接口。

const canvas = document.getElementById("glCanvas");
const gl = canvas.getContext("webgl");
if (!gl) {
  console.error("无法获取 WebGL 上下文");
}

一旦拿到 gl,就可以使用它提供的各种方法,比如:创建着色器、编译着色器、上传数据到显卡、执行绘图命令等等。如果获取失败,通常浏览器要么不支持 WebGL,要么被禁用了。

3.2 编译着色器与链接程序

在 WebGL 中,需要写两种着色器:

  1. 顶点着色器(Vertex Shader):主要处理顶点的坐标变换或属性计算。
  2. 片段着色器(Fragment Shader):决定屏幕上每个像素最终要渲染出的颜色。

3.2.1 编写“最小化”顶点着色器

我们想渲染一个覆盖全屏的矩形,所以顶点着色器只需要简单地把传进来的顶点坐标直接输出到 gl_Position。示例:

const vertexShaderSource = `
  attribute vec4 position;
  void main() {
    gl_Position = position;
  }
`;
  • attribute vec4 position;:声明一个顶点属性(类型是四维向量),在 JavaScript 里会与一个缓冲区(Buffer)关联,存储着每个顶点的位置值。
  • gl_Position = position;:将顶点属性直接赋值给内置变量 gl_Position,它会决定当前顶点在裁剪空间中的坐标。由于我们传入的坐标范围是 [-1,1],就能正好覆盖全屏。

3.2.2 编写片段着色器(用户 GLSL 部分)

片段着色器的源码会来自我们自己编写/编辑器获取的字符串,比如以下示例:

let fragmentShaderSource = `
precision mediump float;
uniform float iTime;
uniform vec2 iResolution;
void main() {
    vec2 uv = gl_FragCoord.xy / iResolution.xy;
    vec3 color = 0.5 + 0.5 * cos(iTime + uv.xyx + vec3(0.0, 2.0, 4.0));
    gl_FragColor = vec4(color, 1.0);
}
`;
  • uniform float iTime;:声明一个全局的只读浮点变量,用于传入时间参数。
  • uniform vec2 iResolution;:声明一个全局的只读二维向量,用于传入屏幕分辨率(或画布大小)。
  • gl_FragCoord.xy:内置变量,当前片段(像素)在屏幕坐标中的位置。
  • gl_FragColor = vec4(...): 决定了该像素的最终颜色。

3.2.3 编译着色器:createShader

编译着色器需要先调用 createShader 方法创建一个 Shader,然后通过 gl.shaderSourcegl.compileShader 编译之。为了减少重复代码,我们写一个通用函数:

function createShader(gl, type, source) {
  // 创建空的着色器对象
  const shader = gl.createShader(type);
 
  // 提供 GLSL 代码给这个着色器对象
  gl.shaderSource(shader, source);
 
  // 编译着色器
  gl.compileShader(shader);
 
  // 检查编译结果
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    console.error("Shader 编译错误: " + gl.getShaderInfoLog(shader));
    return null;
  }
  return shader;
}

该函数的作用:

  1. 创建一个 Shader(类型可以是 gl.VERTEX_SHADERgl.FRAGMENT_SHADER);
  2. 将我们编写的 GLSL 代码附加到这个 Shader 上;
  3. 编译并检查是否有错误;
  4. 如果编译失败,打印错误日志;成功则返回 Shader 对象。

3.2.4 创建并链接 Program:createProgram

在 WebGL 中,一个可用的 “着色器程序(Program)” 需要同时包含顶点着色器和片段着色器。我们先编译好两者,然后链接到一起:

function createProgram() {
  const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
  const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
  if (!vertexShader || !fragmentShader) {
    return null; // 如果有编译错误,返回 null
  }
 
  // 创建程序
  const prog = gl.createProgram();
 
  // 附加着色器
  gl.attachShader(prog, vertexShader);
  gl.attachShader(prog, fragmentShader);
 
  // 链接程序
  gl.linkProgram(prog);
 
  // 检查链接结果
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
    console.error("Program 链接错误: " + gl.getProgramInfoLog(prog));
    return null;
  }
 
  return prog;
}
  • gl.createProgram():创建一个空的 WebGL 程序。
  • gl.attachShader(...):将顶点和片段两种着色器分别附加上去。
  • gl.linkProgram(...):将它们链接到一起;若有变量不匹配或者其他错误会导致链接失败。
  • gl.getProgramParameter(prog, gl.LINK_STATUS):获取链接状态,用来检查成功或失败。

如果一切正常,就会得到一个可以被 GPU 执行的 “着色器程序” 对象。后续绘图时,我们要对它 “useProgram” 来启用。

3.3 准备绘制数据(全屏三角形/矩形)

3.3.1 为什么是两个三角形?

WebGL 绘制几何体的基本方式就是画三角形(Triangles)。要覆盖整个裁剪空间(即屏幕)的话,可以使用一个覆盖[-1,1] 范围的矩形,但在 GPU 层面上是以三角形来绘制,所以常见做法是把矩形拆成两个三角形。

3.3.2 创建顶点缓冲区:createBuffer, bufferData

const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(
  gl.ARRAY_BUFFER,
  new Float32Array([
    // 第一个三角形
    -1, -1, 1, -1, -1, 1,
    // 第二个三角形
    -1, 1, 1, -1, 1, 1,
  ]),
  gl.STATIC_DRAW
);
  1. gl.createBuffer():在 GPU 上创建一个空的缓冲区对象,存放顶点数据等。
  2. gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer):把这个缓冲区绑定到 ARRAY_BUFFER 目标(表示要存储顶点属性数据)。
  3. new Float32Array([...]):我们用 JavaScript 数组存储一堆浮点数,并转成 Float32Array 以满足 WebGL 的要求。
  4. gl.bufferData(..., ..., gl.STATIC_DRAW):把数据(顶点坐标)真正传入 GPU 缓冲区,并告知 WebGL 该缓冲区以后如何被使用(STATIC_DRAW 表示内容不会经常改动)。

这些坐标[-1, 1] 会在顶点着色器里传递给 position,最终填满裁剪空间,渲染全屏。

3.3.3 关联 Attribute:getAttribLocation, vertexAttribPointer

我们在顶点着色器定义了 attribute vec4 position;,而在 JavaScript 侧有一块名为 positionBuffer 的数据。如何把这两者关联起来?方法如下:

// 先得到着色器程序中 "position" 这个 attribute 的索引
const positionLocation = gl.getAttribLocation(program, "position");
 
// 启用这个 attribute
gl.enableVertexAttribArray(positionLocation);
 
// 告诉 WebGL 如何从当前绑定的缓冲区中取数据给这个 attribute
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
  • gl.getAttribLocation(program, "position"):找到对应的 attribute 位置(类似 ID 或索引)。
  • gl.enableVertexAttribArray(positionLocation):启用这个 attribute。
  • gl.vertexAttribPointer(location, size, type, normalized, stride, offset):指定数据布局,这里 size=2 表示每个顶点有两个数(x, y),type=gl.FLOAT 表示数据类型是浮点,offset=0 表示从缓冲区起始处读取。

这样,每当 GPU 绘制时,会自动从绑定的缓冲区中读取每个顶点的 (x,y),并以 (x,y,0,1) 的形式赋给着色器中的 position 变量。

3.4 关联并更新 Uniform 变量

3.4.1 什么是 Uniform?

uniform 是着色器里的一种全局只读变量,主要用于从 CPU(JavaScript)传递一些不随每个顶点或像素变化的参数,比如时间、屏幕尺寸、相机视角等。

3.4.2 获取 Uniform 位置:getUniformLocation

在片段着色器中,我们定义了:

uniform float iTime;
uniform vec2 iResolution;

为了在 JavaScript 中给它们赋值,需要先拿到它们在程序中的位置索引:

let timeUniform = gl.getUniformLocation(program, "iTime");
let resolutionUniform = gl.getUniformLocation(program, "iResolution");

3.4.3 在每帧里动态设置 Uniform:gl.uniform*

有多种 uniform 设置方法,比如 gl.uniform1f, gl.uniform2f, gl.uniform3f 等。它们对应着 GLSL 里的 float, vec2, vec3 等不同类型。

gl.uniform1f(timeUniform, time * 0.001);
gl.uniform2f(resolutionUniform, canvas.width, canvas.height);
  • time * 0.001:如果 time 是毫秒,可除以 1000 变成秒。
  • canvas.width, canvas.height:将画布的像素宽高作为 iResolution。

3.5 使用 requestAnimationFrame 进行连续渲染

3.5.1 为什么使用 requestAnimationFrame

想要在网页中不断“刷新”或“更新”画面,可以用 setInterval/setTimeout,但 requestAnimationFrame (简称 rAF) 更加适合动画渲染:

  • 浏览器会在每一帧绘制前调用回调函数;
  • 更好地保证流畅度与性能。

3.5.2 渲染主循环

function render(time) {
  // 告诉 WebGL 使用我们创建好的着色器程序
  gl.useProgram(program);
 
  // 设置 uniform
  gl.uniform1f(timeUniform, time * 0.001);
  gl.uniform2f(resolutionUniform, canvas.width, canvas.height);
 
  // 真正开始绘制
  gl.drawArrays(gl.TRIANGLES, 0, 6);
 
  // 再次请求下一帧绘制
  requestAnimationFrame(render);
}
 
// 启动渲染循环
requestAnimationFrame(render);
  • gl.drawArrays(gl.TRIANGLES, 0, 6):告诉 GPU,我们要以“三角形”模式,使用当前缓冲区的 6 个顶点。因为 2 个三角形就有 6 个顶点,所以能覆盖全屏。
  • 每次绘制之前,需要先 gl.useProgram(program) 切换或确认我们使用的是当前这个着色器程序。 到此为止,我们右侧的 glsl 预览就完成了。

3.6 实现实时更新 GLSL 代码

上面的代码还有一个问题就是当我们,修改左侧的 glsl 代码的时候,右侧是不会更新的。因为我们并没有监控 editor 的 change 事件。

3.6.1 监听编辑器事件

当用户在编辑器(CodeMirror)里修改了片段着色器代码后,就要重新编译并应用新的 program。示例:

editor.on("change", () => {
  // 取得最新代码
  fragmentShaderSource = editor.getValue();
  updateShaderProgram();
});

3.6.2 重新编译并切换 Program

function updateShaderProgram() {
  // 先尝试编译新的片段着色器,如果报错则保留旧的 Program
  const newFragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
  if (!newFragmentShader) {
    console.error("编译失败,继续使用旧的 Shader");
    return;
  }
 
  // 如果成功,则删掉旧的
  if (program) {
    gl.deleteProgram(program);
  }
  // 重新创建并链接
  program = createProgram();
 
  // 再启用新的 program
  gl.useProgram(program);
 
  // 重新获取 uniform 位置
  timeUniform = gl.getUniformLocation(program, "iTime");
  resolutionUniform = gl.getUniformLocation(program, "iResolution");
 
  // 重新关联 attribute
  const positionLocation = gl.getAttribLocation(program, "position");
  gl.enableVertexAttribArray(positionLocation);
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
}

注意,这里我们只在编译成功后才切换,否则万一新代码有语法错误,不至于让整个画面卡死。

3.7 自适应画布

为了让 <canvas> 能在窗口大小变化时自适应,我们可以用:

function resizeCanvas() {
  canvas.width = canvas.clientWidth;
  canvas.height = canvas.clientHeight;
  gl.viewport(0, 0, canvas.width, canvas.height);
}
 
window.addEventListener("resize", resizeCanvas);
resizeCanvas();

这样当浏览器窗口发生变化,画布分辨率也会更新,进而在下次渲染时,iResolution 会获得新的值。

代码

<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>GLSL Shader Editor</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/clike/clike.min.js"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/dracula.min.css" />
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
 
      body {
        background-color: #282a36;
      }
 
      #app {
        display: flex;
        justify-content: space-between;
        width: 100%;
      }
 
      #editor {
        width: 50vw;
        height: 100vh;
      }
 
      #glCanvas {
        width: 50vw;
        height: 100vh;
      }
    </style>
  </head>
 
  <body>
    <div id="app">
      <textarea id="editor"></textarea>
      <canvas id="glCanvas"></canvas>
    </div>
 
    <script>
      let fragmentShaderSource = `
        precision mediump float;
        uniform float iTime;
        uniform vec2 iResolution;
        void main() {
            vec2 uv = gl_FragCoord.xy / iResolution.xy;
            vec3 color = 0.5 + 0.5 * cos(iTime + uv.xyx + vec3(0,2,4));
            gl_FragColor = vec4(color, 1.0);
        }
        `;
 
      let editor = CodeMirror.fromTextArea(document.getElementById("editor"), {
        mode: "x-shader/x-fragment",
        theme: "dracula",
        lineNumbers: true,
      });
      editor.setValue(fragmentShaderSource);
 
      const canvas = document.getElementById("glCanvas");
      const gl = canvas.getContext("webgl");
      let program, timeUniform, resolutionUniform;
 
      function createShader(gl, type, source) {
        const shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
          console.error("Shader 编译错误: " + gl.getShaderInfoLog(shader));
          return null;
        }
        return shader;
      }
 
      function createProgram() {
        const vertexShaderSource = `
            attribute vec4 position;
            void main() {
                gl_Position = position;
            }
            `;
 
        const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
        const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
 
        if (!vertexShader || !fragmentShader) return null;
 
        const prog = gl.createProgram();
        gl.attachShader(prog, vertexShader);
        gl.attachShader(prog, fragmentShader);
        gl.linkProgram(prog);
        if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
          console.error("Program 链接错误: " + gl.getProgramInfoLog(prog));
          return null;
        }
        return prog;
      }
 
      function initWebGL() {
        program = createProgram();
        gl.useProgram(program);
 
        const positionBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
        const positionLocation = gl.getAttribLocation(program, "position");
        gl.enableVertexAttribArray(positionLocation);
        gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
 
        timeUniform = gl.getUniformLocation(program, "iTime");
        resolutionUniform = gl.getUniformLocation(program, "iResolution");
 
        resizeCanvas();
        window.addEventListener("resize", resizeCanvas);
 
        requestAnimationFrame(render);
      }
 
      function resizeCanvas() {
        canvas.width = canvas.clientWidth;
        canvas.height = canvas.clientHeight;
        gl.viewport(0, 0, canvas.width, canvas.height);
      }
 
      function render(time) {
        gl.useProgram(program);
        gl.uniform1f(timeUniform, time * 0.001);
        gl.uniform2f(resolutionUniform, canvas.width, canvas.height);
        gl.drawArrays(gl.TRIANGLES, 0, 6);
        requestAnimationFrame(render);
      }
 
      function updateShaderProgram() {
        const newFragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
 
        if (!newFragmentShader) {
          console.error("GLSL 编译错误,保持使用旧的 Shader");
          return;
        }
 
        if (program) {
          gl.deleteProgram(program);
        }
 
        program = createProgram();
        gl.useProgram(program);
 
        timeUniform = gl.getUniformLocation(program, "iTime");
        resolutionUniform = gl.getUniformLocation(program, "iResolution");
      }
 
      editor.on("change", () => {
        fragmentShaderSource = editor.getValue();
        updateShaderProgram();
      });
 
      initWebGL();
    </script>
  </body>
</html>