今天,我们基于 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 的流程。可以将其拆分为以下关键步骤:
- 获取 WebGL 上下文
- 编写并编译着色器
- 创建并链接着色器程序
- 准备绘制数据(全屏三角形/矩形)
- 关联并更新 Uniform、Attribute
- 使用
requestAnimationFrame
进行连续渲染 - 实现实时更新 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 中,需要写两种着色器:
- 顶点着色器(Vertex Shader):主要处理顶点的坐标变换或属性计算。
- 片段着色器(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.shaderSource
和 gl.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;
}
该函数的作用:
- 创建一个 Shader(类型可以是
gl.VERTEX_SHADER
或gl.FRAGMENT_SHADER
); - 将我们编写的 GLSL 代码附加到这个 Shader 上;
- 编译并检查是否有错误;
- 如果编译失败,打印错误日志;成功则返回 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
);
gl.createBuffer()
:在 GPU 上创建一个空的缓冲区对象,存放顶点数据等。gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
:把这个缓冲区绑定到ARRAY_BUFFER
目标(表示要存储顶点属性数据)。new Float32Array([...])
:我们用 JavaScript 数组存储一堆浮点数,并转成Float32Array
以满足 WebGL 的要求。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>