- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
导读
本文基于 webgl-fundamentals 仓库中的 Create image warping effect in WebGL 问答展开。它回答了一个典型问题:想给图片施加类似"slit scan(狭缝扫描)"的扭曲变形效果,究竟是继续使用 three.js,还是应该回到纯 WebGL?读完本文你将掌握两种完整实现路径:一是仅用 Canvas 2D 的drawImage逐行搬运实现扭曲;二是用 WebGL 着色器对每个像素独立做采样映射,从而获得任意夸张的扭曲能力,并理解两者的性能边界与适用场景。
一、问题背景:three.js 能做,但可能杀鸡用牛刀
原问答中的提问者正在学习 three.js,试图用THREE.TextureLoader加载图片后平铺成平面矩形来实验图片变换效果。其典型代码如下:
var loader = new THREE.TextureLoader(); loader.load( 'clouds.jpg', function (texture) { init(new THREE.MeshBasicMaterial({ map: texture })); }, function (xhr) { console.log((xhr.loaded / xhr.total * 100) + '% loaded'); }, function (xhr) { console.log('An error happened'); } );他遇到的核心困惑是:图片能以平面 2D 形状渲染出来,但"如何把效果施加到图片上"——并且怀疑这需要在render循环里做,却不知道具体怎么做。最终他问出两个关键问题:效果是否应该发生在render内?还是应该改用纯 WebGL?
作者的答复给出了非常明确的路线判断(这也是本文的核心结论):
- 没有理由必须用 WebGL。这种按行扭曲的效果用 2D Canvas 也能做(见仓库示例 webgl-qna-create-image-warping-effect-in-webgl-example-1.html)。
- 该效果的灵感来源是slit scan(狭缝扫描)一类技法,作者并不清楚参考站点的确切公式,但给出了自洽的实现思路。
- 在 WebGL 中做会更疯狂,因为可以对每个像素独立扭曲,而 Canvas 2D API 若逐像素处理会慢到不可用。
- three.js 可以做,但没有理由为了这么小的效果引入如此庞大的库。
仓库给出的对应实现是 webgl-qna-create-image-warping-effect-in-webgl-example-2.html,它使用 twgl 库以极少的样板代码完成了同一效果,并额外附带了一个旋转极坐标扭曲的变体。
二、方案一:Canvas 2D 逐行搬运(slit scan 思路)
2.1 核心思想:不是变形图片,而是"逐行取样"
与其把整张图片做几何变形,更简单的思路是:对目标画布上的每一行,决定它应该取自源图片的哪一行。只要每行的"取样偏移"随时间变化,就能产生波浪形扭曲——这就是 slit scan 的灵魂:在时间维度上逐行(或逐像素)地重新排列画面。
示例一的完整渲染逻辑如下:
function mix(a, b, l) { return a + (b - a) * l; } function upDown(v) { return Math.sin(v) * 0.5 + 0.5; } function render(time) { time *= 0.001; resize(canvas); var t1 = time; var t2 = time * 0.37; // 对画布每一行处理 for (var dstY = 0; dstY < canvas.height; ++dstY) { // v 从 0 渐变到 1,代表画布从上到下的位置 var v = dstY / canvas.height; // 计算源图取样行的偏移量(单位:像素) var off1 = Math.sin((v + 0.5) * mix(3, 12, upDown(t1))) * 300; var off2 = Math.sin((v + 0.5) * mix(3, 12, upDown(t2))) * 300; var off = off1 + off2; // 若 off = 0,则只是把图片纵向拉伸铺满画布 var srcY = dstY * img.height / canvas.height + off; // 把取样行钳制在图片范围内 srcY = Math.max(0, Math.min(img.height - 1, srcY)); // 从源图取 1 像素高的行,画到画布的对应行 ctx.drawImage( img, 0, srcY, img.width, 1, // 源矩形:整宽、1 像素高、位于 srcY 0, dstY, canvas.width, 1); // 目标矩形:整宽、1 像素高、位于 dstY } requestAnimationFrame(render); }2.2 公式拆解:三个工具函数如何驱动波浪
| 函数 | 表达式 | 作用 |
|---|---|---|
mix(a, b, l) | a + (b - a) * l | 线性插值,l=0得a,l=1得b |
upDown(v) | Math.sin(v) * 0.5 + 0.5 | 把正弦值从[-1,1]映射到[0,1],让参数在上下界间往复摆动 |
off = off1 + off2 | 两个不同频率的正弦叠加 | 叠加出更复杂、更自然的扭曲轨迹 |
两个时间变量t1 = time与t2 = time * 0.37的频率错开,配合mix(3, 12, upDown(t))让"波数"在 3 到 12 之间动态漂移,最终偏移幅度* 300以像素为单位。upDown(t2)的 0.37 倍率并非魔法数字,它只是为了让两层波动周期不同步,从而产生更有机的运动——你可以自由调整这两个系数。
2.3 为什么这个方案只适合"逐行"效果
示例一在每一帧里执行了canvas.height次drawImage,每次只搬运 1 像素高的条带。这在现代浏览器上可以流畅运行,但它天然是逐行(per-line)的粒度。若想对每个像素做独立扭曲(比如径向畸变、旋涡),同样的循环将膨胀为width × height次绘制调用,Canvas 2D API 立刻变得不可用——这正是作者强调"WebGL 可以逐像素扭曲"的原因。
如果你想深入了解drawImage九参数形式的每个参数含义,可参考仓库课程 WebGL 2D DrawImage。
三、方案二:WebGL 着色器逐像素扭曲
3.1 整体架构:全屏四边形 + 片段着色器采样
示例二不再用 three.js,而是用twgl(仓库自带 3rdparty/twgl-full.module.js)以最小样板搭建 WebGL 管线:
- 顶点着色器只负责输出覆盖全屏的四边形,并把
position映射为纹理坐标; - 图片以纹理形式上传到 GPU;
- 片段着色器对每个像素计算"应该采样纹理的哪个 UV 坐标"——这就是逐像素扭曲的入口。
var vs = ` attribute vec4 position; varying vec2 v_texcoord; void main() { gl_Position = position; v_texcoord = position.xy * .5 + .5; } `;position取值[-1,1](裁剪空间),乘0.5加0.5后得到[0,1]的纹理坐标,正好覆盖整张纹理。这是 WebGL 中渲染全屏图像的经典技巧,其纹理坐标基础可参见 WebGL Image Processing。
JavaScript 侧用 twgl 把六个顶点、两个三角形、纹理和 uniform 一次配齐:
var gl = document.querySelector("canvas").getContext("webgl"); var programInfo1 = twgl.createProgramInfo(gl, [vs, fs1]); var programInfo2 = twgl.createProgramInfo(gl, [vs, fs2]); var bufferInfo = twgl.createBufferInfoFromArrays(gl, { position: { numComponents: 2, data: [ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, ], }, }); var texture = twgl.createTexture(gl, { src: "https://i.imgur.com/v38pV.jpg", crossOrigin: '', }); var uniforms = { tex: texture, time: 0 };逐帧渲染循环则负责更新时间、适配画布尺寸并提交绘制:
function render(time) { uniforms.time = time * 0.001; twgl.resizeCanvasToDisplaySize(gl.canvas); gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); var programInfo = programInfos[programIndex]; gl.useProgram(programInfo.program); twgl.setBuffersAndAttributes(gl, programInfo, bufferInfo); twgl.setUniforms(programInfo, uniforms); twgl.drawBufferInfo(gl, gl.TRIANGLES, bufferInfo); requestAnimationFrame(render); } requestAnimationFrame(render);3.2 片段着色器一:把 slit scan 从像素搬到纹理坐标
示例二的第一个片段着色器fs1是 Canvas 版本思路的 GPU 复刻:
precision mediump float; uniform float time; uniform sampler2D tex; varying vec2 v_texcoord; float upDown(float v) { return sin(v) * .5 + .5; } void main() { float t1 = time; float t2 = time * 0.37; float v = v_texcoord.y; float off1 = sin((v + 0.5) * mix(1., 6., upDown(t1))) * .2; float off2 = sin((v + 0.5) * mix(1., 3., upDown(t2))) * .2; float off = off1 + off2; // off = 0 时图片只是被纵向拉伸铺满; // off 表示“当前目标像素应从源图多偏多少”的纹理坐标偏移 vec2 uv = vec2( v_texcoord.x, 1. - (v + off)); gl_FragColor = texture2D(tex, uv); }需要注意的一个关键差异:Canvas 版里off是像素单位,而着色器里是纹理坐标单位。纹理坐标永远从 0 到 1,与纹理尺寸无关(该规则在 WebGL Image Processing 中同样强调)。例如纹理高 100 像素时,off = 0.01就相当于偏移 1 像素;示例注释也说明,如果需要精确的像素偏移,完全可以把画布尺寸与纹理尺寸作为 uniform 传入后自行换算。这正是"逐像素扭曲"与"逐行搬运"的本质区别:v_texcoord.y对每个像素连续变化,偏移公式作用于每一个像素而非每一行。
3.3 片段着色器二:极坐标旋涡扭曲
示例二还内置了第二个着色器fs2,展示了 WebGL 能做到的"更疯狂"的扭曲——把纹理坐标重映射为极坐标,实现旋转与涟漪般的缩放:
#define PI radians(180.) mat2 rot(float a) { float c = cos(a); float s = sin(a); return mat2(c, s, -s, c); } float bounce(float v) { v = fract(v * .2); return mix(v, 2. - v, step(1., v)); } void main() { float t1 = time; float t2 = time * 0.37; float t3 = time * 0.1; float t4 = time * 1.23; vec2 tc = rot(time * 0.1) * (v_texcoord - 0.25); vec2 xy = fract(tc * mix(.5, 3., upDown(t4))) * 2. - 1.; float a = fract(abs(atan(xy.x, xy.y)) / PI + t3); float r = bounce(length(xy) + t1); r = pow(r, mix(0.2, 1., upDown(t2))); vec2 uv = vec2(a, r); gl_FragColor = texture2D(tex, uv); }要点解析:
rot(a)构造 2D 旋转矩阵,(v_texcoord - 0.25)先把坐标原点移到画面中心附近;fract(tc * mix(.5, 3., upDown(t4))) * 2. - 1.产生随t4缩放的重复网格;atan(xy.x, xy.y) / PI把直角坐标转成角度,作为采样坐标u,实现"沿环向展开纹理";bounce(length(xy) + t1)产生随半径往复弹跳的"环带"效果,pow再根据upDown(t2)动态调整环带密度。
这个例子充分印证了作者的判断:着色器里所有扭曲都只是"重映射采样 UV"这一件事,公式自由度几乎不受限制。
3.4 两个效果如何切换
示例二通过监听事件在programInfo1与programInfo2之间循环切换,演示两种效果对比:
var programIndex = 0; var programInfos = [programInfo1, programInfo2]; function nextProgram() { programIndex = (programIndex + 1) % programInfos.length; } window.addEventListener('keydown', nextProgram); window.addEventListener('mousedown', nextProgram); window.addEventListener('touchstart', nextProgram);切换只发生在"使用哪个着色器程序"上,顶点数据、纹理和渲染循环完全复用——这正是把效果封装在片段着色器里的好处。
四、twgl 背后:源码级看它替你做了什么
示例二之所以简洁,是因为 twgl 把 WebGL 的冗长样板封装成了几个高层函数。在仓库的 3rdparty/twgl-full.module.js 中可以看到这些函数的实现,全部对应示例二的实际调用:
| 示例二调用 | 源码实现 | 作用 |
|---|---|---|
twgl.createProgramInfo(gl, [vs, fs]) | createProgramInfo | 编译链接着色器,并为每个 uniform/attribute 生成 setter(对应 createAttributeSetters 等辅助函数) |
twgl.createBufferInfoFromArrays(gl, {...}) | createBufferInfoFromArrays | 依据 JS 数组创建缓冲并计算numElements,同时封装成可复用的bufferInfo |
twgl.createTexture(gl, {src, crossOrigin}) | createTexture | 创建纹理、按src从 URL 异步加载图片并上传,自动设置过滤参数 |
twgl.setBuffersAndAttributes(...) | setBuffersAndAttributes | 绑定缓冲并启用、配置顶点属性指针 |
twgl.setUniforms(programInfo, uniforms) | setUniforms | 遍历传入的对象,用程序信息里的 setter 逐个设置 uniform 并绑定纹理单元 |
twgl.drawBufferInfo(gl, gl.TRIANGLES, bufferInfo) | drawBufferInfo | 依据bufferInfo自动选择drawArrays/drawElements并提交 |
twgl.resizeCanvasToDisplaySize(gl.canvas) | resizeCanvasToDisplaySize | 让画布缓冲尺寸跟随 CSS 显示尺寸,避免模糊或拉伸 |
其中 setUniforms 的遍历式赋值,正是把"每次gl.uniform1f(uniformTimeLoc, ...)逐个手写"压缩成一行twgl.setUniforms(programInfo, uniforms)的关键。想理解这种"用 setter 替代样板代码"的设计思路,可以阅读仓库课程 WebGL Less Code, More Fun——它详细对比了手写 uniform/attribute 查询与使用统一 setter 对象的代码量差异。这也是作者在问答中选择 twgl 而非 three.js 的深层原因:这个效果只需要"全屏四边形 + 一段片段着色器",twgl 正好提供了刚好够用、不臃肿的抽象层。
五、方案对比与选型建议
| 维度 | Canvas 2D(示例一) | WebGL(示例二) |
|---|---|---|
| 依赖 | 浏览器原生 API,零依赖 | 需要 WebGL 上下文,示例使用 twgl 简化 |
| 扭曲粒度 | 逐行(per-line) | 逐像素(per-pixel) |
| 性能瓶颈 | 每帧多次drawImage调用 | GPU 并行处理所有像素,采样开销在着色器内 |
| 效果上限 | 适合 slit scan 类行级扭曲 | 极坐标、旋涡、涟漪、任意 UV 重映射均可 |
| 上手成本 | 极低,几十行代码 | 需要理解纹理坐标与着色器管线 |
| three.js | 不必引入 | 可用但"杀鸡用牛刀",twgl 或原生 WebGL 更合适 |
选型结论与原文档一致:如果只是逐行扭曲,Canvas 2D 已经足够;如果想做逐像素的疯狂扭曲,用 WebGL(twgl 或原生),不必为此引入 three.js 这种大库。three.js 本身没有问题,但它的场景图、相机、光照等能力对这个单纹理扭曲效果来说属于无关开销。
六、运行与验证
两个示例都在仓库根目录的webgl目录下,与本文档位于同一路径层级:
- Canvas 版:webgl-qna-create-image-warping-effect-in-webgl-example-1.html
- WebGL 版:webgl-qna-create-image-warping-effect-in-webgl-example-2.html
由于示例从网络加载图片(https://i.imgur.com/v38pV.jpg),且 WebGL 读取纹理遵循跨域限制,建议通过本地静态服务器访问,而非直接双击打开文件。具体搭建方式可参考仓库课程 WebGL Setup and Installation。另外,示例二中的 twgl 在示例 HTML 内通过 CDN 引用,仓库根目录的 3rdparty/twgl-full.module.js 提供了同版本的本地模块源码可供对照阅读。
在文档体系中,本问答是 Questions and Answers 集合的一篇,与之并列的还有 Creating a smudge/liquify effect、How to get pixelize effect in WebGL? 等同类图像效果问答,可互相参照。原文档通过{{{example url="..."}}}语法把 HTML 示例嵌入到 Markdown 课程中(该机制由仓库根目录的 Gruntfile.js 构建流程处理),这也解释了为什么本文档与示例文件分处webgl/lessons/与webgl/两个目录。
七、进一步深入
如果你希望在此基础上继续挖掘:
- 纹理坐标与图片处理基础:先读 WebGL Image Processing,理解纹理坐标从 0 到 1 的规则、varying 插值以及"通过重映射 UV 实现卷积/模糊"的思想——扭曲本质上只是更自由的 UV 重映射。
- GLSL 与着色器基础:WebGL Shaders and GLSL 与 WebGL How It Works 能补齐 attribute/varying/uniform 的底层机制。
- 减少样板代码:WebGL Less Code, More Fun 详细解释了 twgl 这类 setter 方案为什么能大幅压缩代码量。
- 动手改造:尝试把示例二的
fs1中的mix(1., 6., upDown(t1))系数、*.2的偏移幅度、t2 = time * 0.37的倍率改掉,或在fs2中替换atan/bounce的映射公式,观察 UV 重映射如何影响最终画面——这是理解"任何扭曲都是 UV 重映射"最直观的实验。
- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
相关推荐
用 `<HtmlInCanvas>` 在 Remotion 中把 DOM 渲染进 Canvas:从 Canvas 2D 后处理到 WebGL 着色器
用 <HtmlInCanvas 在 Remotion 中把 DOM 渲染进 Canvas:从 Canvas 2D 后处理到 WebGL 着色器 导读 本篇文章围
音视频AI 应用前端WebGL 图像处理实战:从纹理采样到 3x3 卷积核,在 webgl-fundamentals 中用着色器完成图片特效
WebGL 图像处理实战:从纹理采样到 3x3 卷积核,在 webgl fundamentals 中用着色器完成图片特效 本文是 webgl fundament
图形学WebGL 2D 缩放(Scale)实战:在顶点着色器中实现几何体缩放(webgl-fundamentals 系列)
WebGL 2D 缩放(Scale)实战:在顶点着色器中实现几何体缩放(webgl fundamentals 系列) 本篇是 webgl fundamental
图形学
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考