news 2026/10/14 3:02:44

在 WebGL 中实现图片扭曲(Image Warping)效果:从 Canvas 2D 到着色器实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
在 WebGL 中实现图片扭曲(Image Warping)效果:从 Canvas 2D 到着色器实战
  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

导读

本文基于 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 管线:

  1. 顶点着色器只负责输出覆盖全屏的四边形,并把position映射为纹理坐标;
  2. 图片以纹理形式上传到 GPU;
  3. 片段着色器对每个像素计算"应该采样纹理的哪个 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/两个目录。

七、进一步深入

如果你希望在此基础上继续挖掘:

  1. 纹理坐标与图片处理基础:先读 WebGL Image Processing,理解纹理坐标从 0 到 1 的规则、varying 插值以及"通过重映射 UV 实现卷积/模糊"的思想——扭曲本质上只是更自由的 UV 重映射。
  2. GLSL 与着色器基础:WebGL Shaders and GLSL 与 WebGL How It Works 能补齐 attribute/varying/uniform 的底层机制。
  3. 减少样板代码:WebGL Less Code, More Fun 详细解释了 twgl 这类 setter 方案为什么能大幅压缩代码量。
  4. 动手改造:尝试把示例二的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

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载
上一篇:六款游戏的模组管理,一个 XXMI Launcher 就够:新手安装与使用全记录
下一篇:免费完整备份QQ空间全部历史说说:一份找回十年青春记忆的终极指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 3:01:01

Python代码格式化工具Black:终结团队风格之争的实战指南

代码格式化这件事&#xff0c;我一度觉得是程序员的“信仰战争”。团队里最浪费时间的几件事&#xff0c;开需求评审算一个&#xff0c;讨论缩进、引号、换行风格也能算一个——有人坚持单引号&#xff0c;有人力推双引号&#xff0c;有人写函数参数像掏口袋一样一个一个往外掏…

作者头像 李华
网站建设 2026/10/14 3:00:56

ET2017免狗版部署全指南:绕过加密狗校验,解决授权失效问题

简介&#xff1a;面向受加密狗与打印驱动问题困扰的ET2017软件用户&#xff0c;这份免狗万能版压缩包将新一代引擎、授权模块和打印驱动整合在一起&#xff0c;省去单独寻找补丁和驱动的流程&#xff0c;适合机械、电气、建筑等工程制图场景。压缩包为rar格式&#xff0c;整体体…

作者头像 李华
网站建设 2026/10/14 3:00:00

MSVC链接器ICF折叠:为何两个不同函数地址相同?

有段时间没碰MFC老代码了&#xff0c;前阵子在一个遗留项目上排查偶发异常&#xff0c;遇到一个很有意思的“鬼问题”&#xff1a;两个函数名不同、实现逻辑也完全一样&#xff0c;结果链接之后地址居然被映射到了同一处。一开始我还以为是自己眼花了&#xff0c;或者是调试信息…

作者头像 李华
网站建设 2026/10/14 2:58:58

从非结构化文本构造知识图:管道设计与工程实践避坑指南

简介&#xff1a;一套面向NLP与知识图谱工程师的端到端知识提取工具包&#xff0c;可从非结构化文本自动抽取RDF三元组并构建知识图谱&#xff0c;全程无需监督。项目整合spaCy、Stanford CoreNLP、Neural Coref与句子转换器&#xff0c;覆盖实体识别、共指消解、关系抽取和实体…

作者头像 李华
网站建设 2026/10/14 2:58:30

FTP文件的上传

1.获取FTP主机地址、端口、账号、密码 2. 打开FTP客户端&#xff0c;填入信息&#xff0c;点击连接 3.在本地电脑中&#xff0c;找到你网页文件打开目标存放文件夹 4.选中本地文件&#xff0c;点击到右侧远程目录&#xff0c;开始上传 5.底部查看传输进度 6.上传结束&#xff…

作者头像 李华