OGL:专注 GLSL 的极简 WebGL 框架
2025-10-11 · 15min前言
最近半年一直在沉迷 Generative Art 的前端动画,最开始接触到的是 Processing,然后也很快速的了解到了 Web 版的 P5.js,我的网页流场背景动画也是由此实现的。
在 OpenProcessing 有非常多的 P5 作品,我被这些优秀的作品吸引了,也想着自己能够去作出一个优秀好看的动画效果。
但很快我就发现了这些优秀作品的共同点,关键效果基本上都是通过自己编写 GLSL Shader 去实现的,也就是用到了 WebGL 的层面。
之前工作中也有接触过 WebGL,WebGL的教程网上也有很多,深入了解后,发现网上的教程普遍存在一个问题,这些教程几乎是在讲 Javascript 层面如何去使用、调用 WebGL相关的 API。
虽然也有提到渲染管线和 CPU与GPU之间的数据传输,但这种教程也确实让我陷入了一种误区——只要会调用 WebGL API 就是会用 WebGL了。
当我自己尝试用在 P5 中用 WebGL 模式制作 3D动画时,我很快就发现了问题——虽然绘制是 WebGL在进行绘制,但是动画相关的计算,依然是在 CPU 计算的,我可以说是几乎完全没有利用到 GPU 强大的并行计算能力。
要想充分利用 GPU 的并行计算能力,就得把原本在 Javascript 进行计算的工作,该用 GLSL 去进行处理。
所以我马上也去学习了 GLSL 来编写自己的 Shader,关于 GLSL 的学习过程,可以参考下面两个网站:
我们可以在 ShaderToy 上在线编辑预览我们的片段着色器代码,里面也有大量的优秀视觉作品可以参考学习。
但学习了一段时间后,我又很快意识到了问题,尽管我可以在 ShaderToy 实现一些动画效果,但是 ShaderToy 的编程是片段着色器编程,本质上就是将画布的像素坐标(UV)映射到颜色(RBGA)的函数,有一些动画效果并不能很好只通过片段着色器来实现,但却很适合用顶点着色器去实现,比如粒子动画。
P5 与原生 WebGL 的弊端
掌握了 GLSL 编程后,我尝试在 P5 中运行我的 Shader 代码,P5 中运行 Shader 并不难,无论是 P5 官方文档还是 OpenProcessing 都能找到案例进行参考学习。
但是 P5 相比原生的 WebGL 有蛮多的缺点的:
- Typescript 支持非常糟糕。
- WebGL 的原生图元调用非常复杂。
- 过度封装的缘故,最小化修改数据的操作非常繁琐。
于是我就又把目标转向 WebGL,希望得到强大的灵活性,不过马上又发现了新的问题。
下面看一段原生 WebGL 的例子:
const vertex = /* glsl */ `
precision highp float;
attribute vec3 aPosition;
void main() {
gl_Position = vec4(aPosition, 1.0);
gl_PointSize = 3.0;
}
`;
const fragment = /* glsl */ `
precision highp float;
void main() {
gl_FragColor = vec4(1.0);
}
`;
/**
* @description 创建着色器并绑定编译
* @param gl WebGL2RenderingContext
* @param sourceCode 着色器代码
* @param type 着色器类型
* @returns
*/
export const createShader = (
gl: WebGL2RenderingContext,
sourceCode: string,
type: number
): WebGLShader | null => {
var shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, sourceCode);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
var info = gl.getShaderInfoLog(shader);
throw "Could not compile WebGL program. \n\n" + info;
}
return shader;
};
const canvas = document.getElementById("canvas")!;
const gl = canvas.getContext("webgl");
if(gl) {
// 分别创建顶点和片段着色器
const vertextShader = createShader(gl, vert, gl.VERTEX_SHADER);
const fragmentShader = createShader(gl, frag, gl.FRAGMENT_SHADER);
if (vertextShader === null || fragmentShader === null) return;
// 创建程序对象
const program = gl.createProgram();
if (program === null) return;
// 附加着色器
gl.attachShader(program, vertextShader);
gl.attachShader(program, fragmentShader);
// 链接程序
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("无法初始化着色器程序: " + gl.getProgramInfoLog(program));
return;
}
gl.useProgram(program);
// 获取属性位置
const aPosition = gl.getAttribLocation(program, "aPosition");
// 创建缓冲区
const positionBuffer = gl.createBuffer();
// 绑定缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
// 设置几何体数据
const positionArr: number[] = [];
const aspect = window.innerWidth / window.innerHeight;
// 随机生成点云数据
for (let i = 0; i < 10000; i++) {
const theta = Math.random() * 2 * Math.PI; // [0, 2π]
const phi = Math.random() * Math.PI - Math.PI / 2; // [-π/2, π/2]
const radius = 0.5; // 球体半径
const x =
(aspect > 1.0 ? radius / aspect : radius) *
Math.cos(phi) *
Math.cos(theta);
const y = (aspect > 1.0 ? radius : radius * aspect) * Math.sin(phi);
const z = radius * Math.cos(phi) * Math.sin(theta);
positionArr.push(x, y, z);
}
const positions = new Float32Array(positionArr);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
// 启用属性
gl.vertexAttribPointer(
aPosition,
3, // 每个顶点有3个分量 (x, y, z)
gl.FLOAT, // 数据类型为32位浮点数
false, // 不进行归一化
0, // 步长为0,表示数据是紧密排列的
0 // 从缓冲区的起始位置开始读取数据
);
gl.enableVertexAttribArray(aPosition);
gl.drawArrays(gl.POINTS, 0, 10000);
}
这样我们就创建了一个,由10000个粒子构成的球体。

上述代码中,除去 GLSL 的部分,真正用于实现效果代码只有以下这些:
// 设置几何体数据
const positionArr: number[] = [];
const aspect = window.innerWidth / window.innerHeight;
// 随机生成点云数据
for (let i = 0; i < 10000; i++) {
const theta = Math.random() * 2 * Math.PI; // [0, 2π]
const phi = Math.random() * Math.PI - Math.PI / 2; // [-π/2, π/2]
const radius = 0.5; // 球体半径
const x =
(aspect > 1.0 ? radius / aspect : radius) *
Math.cos(phi) *
Math.cos(theta);
const y = (aspect > 1.0 ? radius : radius * aspect) * Math.sin(phi);
const z = radius * Math.cos(phi) * Math.sin(theta);
positionArr.push(x, y, z);
}
const positions = new Float32Array(positionArr);
其余代码是非常繁琐冗余的着色器绑定、编译,数据属性的地址获取、缓冲区创建、绑定和启用。
一个着色器无关是顶点着色器还是片段着色器,我都需要通过重复的代码去绑定、编译才能使用,因此我将这部分代码封装成一个通用函数:
/**
* @description 创建着色器并绑定编译
* @param gl WebGL2RenderingContext
* @param sourceCode 着色器代码
* @param type 着色器类型
* @returns
*/
export const createShader = (
gl: WebGL2RenderingContext,
sourceCode: string,
type: number
): WebGLShader | null => {
var shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, sourceCode);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
var info = gl.getShaderInfoLog(shader);
throw "Could not compile WebGL program. \n\n" + info;
}
return shader;
};
而且后面对于 aPosition 这一个属性,将数据从 CPU 写入到内存中给 GPU 获取,就要写非常多的冗余代码,如果我后续要新增 attribute vec4 aColor 或者 uniform float uTime,就又要重复去写这么一段冗余代码,才能完成数据从 CPU 传递给 GPU 的操作。
虽然我也可以将这些代码封装成一个通用函数,但我并不想重复造轮子,我觉得既然 P5 都有对 WebGL 操作进行封装简化,那就应该有比我更早接触的 WebGL 的开发者早就发现了这些痛点问题,并且已经有了更好的解决方案。
网络上许多教程都是教用 Threejs 去运行 Shader的,但我觉得我目的是专注于 GLSL 的运行,我没必要上一个 3D 引擎,这太过于笨重了。
Ogl.js
很快我就找到了 Ogl.js 这么一个非常轻量化且高效的 WebGL 库。
文档介绍也是:面向喜欢最少抽象层并有兴趣创建自己的着色器的开发人员。( aimed at developers who like minimal layers of abstraction, and are interested in creating their own shaders.)
对 Typescript 也有非常优秀的支持,API 风格也借鉴了 Threejs,可以说是完全符合我的需求了。
虽然没有详细的官方文档进行说明,但有在线的 Example 进行参考学习,对熟悉 Threejs 和 WebGL 的开发者来说是毫无难度的。
将上述代码改用 Ogl 实现,
修改顶点着色器代码
precision highp float;
attribute vec3 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
gl_PointSize = 1.0;
}
modelViewMatrix 和 projectionMatrix 是 Ogl program 创建时会默认传入的 3D 视图变换矩阵,原生 WebGL 部分我没有进行这样的处理,因为手动实现非常复杂。
Ogl 这里通过 Camera 类进行自动更新并更新进内存中,是个非常友好的封装。
修改 Typescript 代码:
// 获取 #app 节点
const app = document.getElementById("app")!;
// 创建渲染器并挂在到 Dom 节点
const renderer = new Renderer({
dpr: 2,
});
const gl = renderer.gl;
app.appendChild(gl.canvas);
// 创建摄像机
const camera = new Camera(gl, { fov: 35 });
// 创建轨道控制器
const controls = new Orbit(camera, {
target: new Vec3(0, 0, 0),
});
// 监听窗口变化
function resize() {
renderer.setSize(window.innerWidth, window.innerHeight);
camera.perspective({ aspect: gl.canvas.width / gl.canvas.height });
}
window.addEventListener("resize", resize, false);
resize();
// 随机生成点云数据
const positions: number[] = [];
for (let i = 0; i < 10000; i++) {
const theta = Math.random() * 2 * Math.PI; // [0, 2π]
const phi = Math.random() * 2 * Math.PI - Math.PI; // [-π, π]
const radius = 0.5; // 球体半径
const x = radius * Math.cos(theta) * Math.sin(phi);
const y = radius * Math.sin(theta) * Math.sin(phi);
const z = radius * Math.cos(phi);
positions.push(x, y, z);
}
// 创建几何体
const geometry = new Geometry(gl, {
position: { size: 3, data: new Float32Array(positions) },
});
// 创建着色器程序
const program = new Program(gl, {
vertex,
fragment,
});
// 创建球体网格
const sphere = new Mesh(gl, { geometry, program, mode: gl.POINTS });
// 创建场景
const scene = new Transform();
scene.addChild(sphere);
function update(t: number) {
requestAnimationFrame(update);
controls.update();
renderer.render({ scene, camera });
}
requestAnimationFrame(update);
可以看到运行效果如下:

相比于原生 WebGL,用了更加少量简洁的代码,实现了更好的效果,粒子有了正确的3D透视效果,并且我们可以通过鼠标进行和 Threejs 中一样的3D旋转与拖拽。

Ogl 的另一个优点就是,将数据从 CPU 传递给 GPU 的操作进行非常大的简化,这里我新增一个 u_time 用于传递时间,给粒子增加变化效果:
const u_time = {value: 0};
const program = new Program(gl, {
vertex,
fragment,
uniforms: {
u_time
}
});
function update(t: number) {
u_time.value = t;
// ....
}
修改顶点着色器代码
precision highp float;
attribute vec3 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform float u_time;
void main() {
vec3 pos = position;
// 向量单位化
vec3 nPos = normalize(pos);
// 计算变化幅度 [0.9, 1.1]
float n = 1.0 + 0.1 * sin(nPos.x + nPos.y + nPos.z + u_time * 0.005);
pos = pos * r;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = 1.0;
}
这样就可以看到球体像个果冻一样在变化了。
注意
OGL 的 Orbit 控件会默认 preventDefault() 滚轮与右键事件,以获得完整相机控制。这在全屏 demo 中没问题,但嵌入到博客页面时会导致整页滚动失效。
只需要在创建 Orbit 时增加一个配置项即可解决问题:
controls = new Orbit(camera, {
target: new Vec3(0, 0, 0),
element: gl.canvas, // 将滚轮右键事件绑定到 canvas 上
});
GPGPU
Ogl 还提供很多的强大的功能,其中我最感兴趣就是 GPGPU(General-Purpose computing on Graphics Processing Units)。
简单来说,就是通过多个着色器协同管理状态,将渲染输出作为纹理反馈给主着色器,实现状态的连续更新。
因为 GLSL 虽然拥有极强的并行计算能力,但无法像 CPU 一样直接修改内存中的数据。当我们需要让上一帧的结果影响下一帧时,可以使用 GPGPU 技术:把渲染结果作为纹理传回主着色器,从而实现状态的更新。
具体可以参考官方例子,我这里就不多说了。
总结
Ogl 正如他介绍那样,是专门给需要专注于着色器的开发者的最小 WebGL 库,它并没有提供专门的说明文档来详细解释自己的 API,只是通过了大量的例子来介绍他的功能,需要非常熟悉 WebGL 以及对 Threejs 有一定了解的才能够读懂他的源码。
因此对于不熟悉 GLSL 的人来说,它并不是一个非常好的库,它只是刚刚好满足我想用最小程度封装 WebGL 的冗余代码的需求,请结合具体需求来使用。