☰
Canvas打造无限隧道:透视原理与循环纹理的视觉错觉实战
2026/10/14 4:27:18 网站建设 项目流程

最早被“无限隧道(Infinite tunnel)”这种视觉效果吸引,是很多年前逛创意市集时看到的一段循环投影。画面就是一个不断向深处延伸的通道,纹理从中心点向四周高速涌出,明明只是二维屏幕,我却盯着看了很久,直到旁边有人拍我才回过神。后来自己做这行,才明白这类效果并不玄乎:它不依赖任何复杂引擎,用浏览器里最基础的Canvas绘图接口就能完整复现。这篇就把它从视觉原理、透视变换、节奏控制到落地踩坑,完整拆开聊一遍,适合对视觉错觉、创意编程感兴趣的开发者,也适合刚学Canvas想找练手项目的朋友。

先说结论:无限隧道看上去“无限”,核心其实是两个词,循环纹理和透视滚动。几何体本身没有无限长,屏幕空间里只有一段几十个单位长度的隧道壁,纹理坐标一直在循环滚动,视觉系统就被骗过去了。抓住这一点,整个实现就不会走偏。接下来我从原理讲起,再给一套我实测可行的纯Canvas实现,最后把调试中遇到的几个容易卡住的问题一并交代清楚。

1. 无限隧道为什么让人“看进去”:视觉机制与核心参数

1.1 灭点、径向扩张与视觉吸引力

无限隧道的第一个关键视觉元素是灭点(vanishing point)。所有原本平行的线条在透视下汇聚到屏幕中央某一个点,屏幕上会形成一圈一圈向外扩张的密集纹理。人的视觉系统对径向扩张非常敏感,因为这在自然界里通常意味着“正在向某个方向快速运动”——比如飞鸟向你俯冲,或你穿过一条林间道路。计算视觉里称这类运动模式为光流场(optical flow),隧道效果产生的就是标准的“向外辐射状光流”。大脑不需要额外处理就能理解画面,于是注意力会一直锁定在灭点上。

这也是为什么很多旋转几何体看一会儿就头晕,而隧道效果耐看很多。旋转图形同时包含绕圈运动和深度变化,视觉系统需要用更多资源去解释,一旦解释不过来就会产生眩晕;隧道的光流方向高度一致,只有一个“往深处飞”的解释,看久了反而有沉浸感。

1.2 循环与周期:一条评价视觉节奏的重要公式

要让隧道看起来“一直往前走”而不是“突然卡住”,纹理滚动必须满足一个条件:纹理在z轴方向上是周期重复的。假设纹理周期长度是depthCycle,隧道近端到远端的深度范围是[zNear, zFar],屏幕上的滚动速度则由归一化偏移量决定:

ScrollOffset(t) = (ScrollOffset(0) + t * speed / depthCycle) % 1.0

这里的t是帧时间,speed是单位时间内纹理前进的距离。关键是:几何体不动,动的只是纹理坐标。这个设计很反直觉,但正确。我最初写的时候随手把每个方块的位置逐帧向前移动,结果近端方块跑出屏幕后,远端新补进来的方块没有对齐,画面立刻出现“断层感”,整体就露馅了。

用纹理坐标滚动替代几何体移动,还能避免另一个常见问题:浮点误差累积。如果用pos += velocity * deltaTime的方式每帧累加,跑几分钟后坐标会因为数量级差异出现颤动。用绝对时间计算偏移量,就不会出现这种漂移。

1.3 隧道深度范围选择:为什么不要贪心

深度范围直接影响画面空间感,但很多人在这里会犯一个同样的错:把zFar设得超大,比如100000,觉得这样“隧道更深”。实际效果往往是近处空间拉不开,远处糊成一团。透视深度可以近似看作:

screenScale(z) = focal / z

坐标z从10增加到20,屏幕缩放比例会从focal/10变化到focal/20,下一年差一倍;但从1000变化到1010,几乎看不出差异。所以把zFar设得过大,只是在浪费数值精度和绘制段数,并不会让空间感更强。我最终用的深度范围是:

参数推荐值说明
zNear0.5小于0.5时太贴近观察者,遮挡严重
zFar12再远就需要极多分段才能保持清晰
focal400相当于镜头焦距,值越大透视越弱
depthCycle3纹理每3个单位距离重复一次

这个组合在640像素宽的画布上,大概只需要30到40个分段就能撑满整个隧道效果,性能上相当从容。

2. 从单张贴图到一个可运行的隧道:Canvas 2D实现拆解

2.1 方案选型对比:为什么不用WebGL

做视觉特效时,很多人第一反应是上Three.js或WebGL,觉得“像素级效果肯定要上GPU”。无限隧道确实可以用着色器写得非常漂亮,几十行就能在GPU上实现极坐标贴图的效果。但如果你只是想在页面上快速跑出效果,或者初学透视原理,Canvas 2D其实更合适。

原因是这个效果的计算压力集中在“生成可见的多边形顶点”和“纹理映射的近似”,这两件事Canvas 2D都能做。隧道壁本质上不是一个连续的曲面,而是一圈一圈的四边形分段;只要分段数足够多,肉眼就看不出来折痕。每个四边形绘制的成本很低,几十个四边形画完一帧,在现代浏览器里远达不到性能瓶颈。

2.2 透视投影的基础换算

先解决一个基础问题:三维空间里的隧道壁点,怎么映射到屏幕坐标。假设观察者站在原点,视线沿着-z方向看,隧道就是一根以原点为中心、末端在远处的四边形柱体。一个点(x, y, z)在屏幕上的位置是:

screenX = centerX + (x / z) * focal screenY = centerY + (y / z) * focal

这个公式就是透视投影的简化版。分母上的z是那个最关键的变量:越远的点,z越大,映射到屏幕上的偏移就越小,于是产生近大远小的效果。用一次齐次坐标矩阵来表达会更通用,但手写时直接用这个简化公式反而更直观,也没必要引入矩阵库。

隧道壁由四块面组成:上、下、左、右。每块面用一系列首尾相连的四边形近似。伪代码大概是:

for (let i = 0; i < segments; i++) { z0 = zNear + (i / segments) * (zFar - zNear); z1 = zNear + ((i + 1) / segments) * (zFar - zNear); y0a = halfHeightAt(z0); y0b = halfHeightAt(z1); // 分别计算四块墙的四个顶点,然后填充纹理 }

halfHeightAt(z)表示隧道在深度z处的半高。如果隧道是圆柱体投影到四边形,这个值可以保持不变;想做得更丰富,也可以让隧道半径随z变化,形成起伏感。

2.3 分段贴图:用仿射变换近似透视纹理

隧道壁每块面都是梯形,但Canvas的drawImage只能绘制轴对齐矩形,不支持任意四边形贴图。解决办法有两个:

第一个办法,也是最简单的:把每块面再沿z方向切成更薄的四边形,每个薄四边形宽度变化很小,可以直接用drawImage画成一个矩形,矩形的高按两端的透视缩放分别插值。因为每段很薄,肉眼几乎看不出拼接痕迹。实际每个四边形两端的缩放比例差异在5%以内,拼接效果就很自然。

第二个办法,利用Canvas的仿射变换把纹理“压扁”到梯形。setTransform(a, b, c, d, e, f)后配合clip路径绘制,可以让纹理贴合梯形轮廓。但由于仿射变换只能做平行四边形变换,不能真正模拟近大远小的透视,单独一块面积大的面会很生硬。我的建议是仍以切薄段为主,仿射变换只用来统一处理单段的细微倾斜。

代码骨架大致如下:

function drawTunnel(ctx, wall, texture, uOffset) { for (let i = 0; i < wall.segments.length; i++) { const seg = wall.segments[i]; const sx = (uOffset + seg.u0) * texture.width; const sw = (seg.u1 - seg.u0) * texture.width; ctx.drawImage(texture, sx, 0, sw, texture.height, seg.screenX0, seg.screenY0, seg.screenX1 - seg.screenX0, seg.screenY1 - seg.screenY0); } }

这里每个seg都记录了该段在屏幕上四角的矩形边界,以及对应贴图横坐标。关键点是贴图高度固定,因为墙壁上下边界在屏幕上是两条平行的斜线,段内差异可以忽略,所以细切之后这种近似就足够。

2.4 首版运行:一段能动的隧道雏形

把所有墙壁的分段绘制放到requestAnimationFrame循环里,然后用绝对时间更新纹理偏移量:

function update(time) { const uOffset = (time * speed / depthCycle) % 1.0; ctx.clearRect(0, 0, width, height); drawTunnel(ctx, wall, texture, uOffset); requestAnimationFrame(update); } requestAnimationFrame(update);

跑通这一版之后,你已经能看到一个完整的、持续向深处延伸的隧道。接下来不需要急着加特效,先把节奏和材质调对,观感会提升一大截。

3. 让隧道“无限”而不单调:材质、节奏与深度层次

3.1 纹理材质的选择逻辑

很多新手一上来就找“隧道贴图”,其实高端效果往往不需要复杂的图像素材。无限隧道的美感恰恰来自纹理重复的节奏感,所以自己画材质反而更可控。常见三类材质各有适合的场景:

材质类型制作方式适用场景
规则条纹黑白竖条纹均匀排布赛博感、速度感强
霓虹网格画网格线,线体用渐变光晕科技展览、音乐可视化
砖块/瓦片重复矩形拼接偏写实、工业风

我自己最常用的是条纹和霓虹网格。原因有两个:第一,规则纹理在透视下会产生明显的“莫尔条纹效应”,近处线条粗、远处线条密集收缩成闪烁光晕,这种视觉层次非常出效果;第二,绘制成本低,运行时只需要一张很小的贴图,甚至可以程序化生成,省去加载外部图片的麻烦。

贴图尺寸也不用大。在Canvas里,一张256x256的贴图足够来回滚动,因为透视缩小后远处看到的有效纹理宽度不超过30像素,更大的贴图只会增加带宽和采样成本,视觉上不会有任何差别。

3.2 滚动速度与视觉加速度:被误解的“越近越快”

一个普遍存在的直觉是:近处物体看起来移动快,远处慢,所以应该把纹理速度按深度做衰减。这句话既对也不对。

对的地方在于,穿过隧道时,近处的纹理确实在屏幕上跨过了更多像素;不对的地方在于,那个现象是透视投影自然产生的,不需要额外调速度。如果你手动把远处纹理放慢,反而会使光流方向在深度方向上变得不连续,观感会明显“发涩”。

所以正确的做法是:保持所有深度上纹理偏移速度完全一致,让屏幕上的运动速度差异完全交给透视公式去体现。能额外控制的是总体速度。我习惯用每个纹理周期对应的“移动时间”来控制,比如duration = 0.8秒走完一个depthCycle。这个值在0.6到1.5之间观感都很好,低于0.4会让人紧张,高于2.4会觉得凝滞。

3.3 景深氛围:不用做得很复杂

隧道效果的一个常见短板是“太干净”,像实验室测试图,缺少电影感。补上氛围最关键的一步是加深度雾。深度雾的本质是让远处的东西逐渐融进背景色,不需要真正做景深模糊,一个渐变遮罩就够了。

实现上,在隧道全部绘制完成后,覆盖一个从中心到边缘逐渐变透明的径向渐变:中心保留画面,四角沉入暗色。或者反过来,做从中心向外扩散的亮色雾。具体画法:

const fog = ctx.createRadialGradient(cx, cy, radius * 0.2, cx, cy, radius); fog.addColorStop(0, 'rgba(0, 0, 0, 0)'); fog.addColorStop(1, 'rgba(0, 0, 0, 0.85)'); ctx.fillStyle = fog; ctx.fillRect(0, 0, width, height);

这一步能立刻让隧道从“几何演示”变成“有空间感的场景”。如果你想进一步增加沉浸感,可以另外绘制一个半透明的离屏层,每帧叠加前一帧,实现类似动态模糊的尾迹效果。但要注意别做过头,动态模糊强度控制在一个很低的值,否则隧道中心会变成一团白光,细节全丢。

3.4 中心灭点处理:一个容易被忽视的细节

隧道深处的纹理收缩到接近零大小,会在屏幕中心形成一块由高频闪烁像素组成的“躁点区域”。这个现象在低分辨率纹理下尤其明显。最简单的优化方式是,在隧道绘制完成后,用一个很小的圆盖住中心灭点区域。这个圆可以是纯色,也可以带微弱的径向渐变,让它和周围环境过渡自然。盖住之后,丢失的细节减少了,闪烁感大幅降低,隧道反而显得更干净。

4. 实际调试中踩过的坑:从画面抖动到移动端发热

4.1 纹理滚动抖动:问题出在累计误差

第一次让隧道动起来时,我把纹理偏移量做成了每帧累加:

// 错误示例 offset += speed * dt;

当时画面在低帧率下会明显抖动,尤其是中心区域,像打冷颤一样。后来定位到是浮点数累计误差:长时间运行后,offset数值变得很大,再和小于1的纹理周期取模,低位精度已经丢失。

解决办法是把offset改成由绝对时间推导:

// 正确做法 const offset = (performance.now() * speed / depthCycle) % 1.0;

这样每帧计算出的offset只与当前时间相关,与上一帧的结果无关,帧率波动也不会带来误差累积。这个改动看起来不起眼,但对长时间运行的视觉项目影响很大,算是一个很典型的设计教训:能由时间推导出的状态,就尽量不要用增量方式维护。

4.2 远处纹理的摩尔纹噪声:分段裁剪与过滤

隧道深处,纹理每一段的宽度在屏幕上可能不足1像素。大量亚像素纹理挤在一起,会产生闪烁的摩尔纹。完全消除需要做逐像素的mipmap,Canvas 2D里没有直接接口,但可以绕开。

我的做法是:当某一段的屏幕宽度小于一定阈值(比如1.5像素)时,直接跳过不画。配合之前说过的中心圆遮罩,几乎看不见跳过的痕迹。另一个有效措施是把贴图制作成本身带一定的“低通滤波”效果:画条纹时不要画成纯黑纯白,而是用带有灰阶过渡的柔和线条,这样在远处自动混成灰色,减少高频闪烁。这个方法不需要增加任何运行成本。

4.3 移动端性能:drawImage调用次数与离屏拼合

隧道分段数如果设成60,四块墙壁意味着每帧要调用240次drawImage。桌面浏览器没压力,但移动端在低性能机型上会出现明显掉帧。优化方向有两个。

第一个思路是减少绘制次数:把四块墙壁按深度切片后,把同一个深度段的上下左右纹理先画到一张离屏小画布上,形成一张环状隧道切片图,再一次性drawImage到主画布。这样每帧绘制调用从240次降到了60次左右。考虑到切片图本身分辨率很小,离屏绘制的开销也不大。

第二个思路是降低画布的实际分辨率,再用CSS放大到显示尺寸。比如把画布分辨率设为640x360,CSS里拉满到容器宽度,很多视觉上无关紧要的频率细节被抹平,帧率明显提升。这个方法对无限隧道这种“远距离看不清细节”的效果特别适用,因为观众注意力集中在中心运动的光流上,低分辨率造成的画质下降很不容易觉察。

4.4 中心黑洞还是中心白洞:透明叠加的陷阱

调试中还会遇到一个问题:把中心圆遮罩做成半透明叠多层之后,中心区域会越来越亮(或越来越暗),最后变成一坨亮斑或黑斑。原因是多层半透明叠加的alpha不是线性累加,叠加次数多了颜色会逼近不透明。

处理方式有两种。如果想让遮罩恒定,就要像“雾”那样一次性绘制,不要每帧重复叠加。如果确实想用“上一帧残影”做动态模糊,就要给残影层单独设一个很低的全局alpha值,并且每帧在主画布上重新绘制完整背景,而不是在残影上继续叠加。我后来采用的是:主画布每帧清空,动态模糊只通过单独的离屏层做,两层最后一起合成,效果稳定可控。

5. 在无限隧道基础上还能迭代出的方向

5.1 加入视点交互:鼠标与陀螺仪控制

静态隧道看久了会腻,最简单的增强是让灭点跟随鼠标或手机陀螺仪移动。实现上不需要真的修改三维视点,只需要在投影公式里把中心点换成动态值:

screenX = mouseX + (x / z) * focal; screenY = mouseY + (y / z) * focal;

画面所有内容会整体偏向指针方向,产生一种“从洞口探头往里看”的感觉。由于几何结构和纹理坐标都没有变化,这个交互只增加两行计算,性能几乎零损耗。如果配合设备陀螺仪,甚至可以在手机上做出“把手机当作手电筒照进隧道”的体验,用手旋转角度来控制视野摆动。

5.2 与音乐节拍联动:让隧道“呼吸”起来

视觉和音频的联动能让整个作品从演示变成氛围装置。最基础的联动是用Web Audio API分析音频频谱:

const analyser = audioContext.createAnalyser(); analyser.fftSize = 256; const data = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(data); const bassLevel = average(data.slice(0, 20));

然后用低频能量映射到两个参数:隧道滚动速度和中心光晕的亮度。低频段对应鼓点,瞬间提升速度会产生“被音乐推向隧道深处”的爆发感。映射关系建议先做线性区间,不要直接让速度从一个极端跳到另一个极端,否则视觉跳变会很突兀。我常用:

speedScale = 0.8 + bassNorm * 0.5;

bassNorm 取值范围0到1,速度整体只会变化50%,既明显又不失控。音乐高潮时你会发现,人群会跟着鼓点前后晃动,这就是光流和听觉协同后带来的节奏共振。

5.3 从平面隧道到曲面变体:圆形螺旋与分形结构

如果基础版的四边形隧道已经玩熟,下一步可以尝试把隧道截面从矩形改成圆形。圆形隧道的投影不是四边形,而是一圈一圈的同心圆环,越靠近中心圆环越密集,纹理沿着圆周滚动,再用旋转角度控制滚动方向。这个变体的视觉效果和矩形隧道完全不同,中心区域会形成类似螺旋的尾迹,很有迷幻感。

另一个低成本变体是“递归隧道”:在隧道深处不断插入更小的隧道入口,每个入口内部又套一层相同的结构,形成分形嵌套。实现方式只需要在深度方向的某一层做复制粘贴,远处依然是同一张贴图滚动,但观感会复杂很多,更像是盗梦空间式的空间套叠。

5.4 下一步建议:从Canvas到WebGL的迁移路径

如果你已经用Canvas实现了一个完整的无限隧道,并且想在效果上继续升级,下一步应该是WebGL。Canvas 2D的极限是分段近似,而WebGL的片元着色器可以把“隧道”当成一个数学曲面逐像素渲染,能够做到真正的圆柱体透视、全屏景深模糊、以及更精细的摩尔纹抑制。

迁移路径也很顺:Canvas里的投影公式,在着色器里就是顶点变换;Canvas里手工切的四边形,在WebGL里变成模型矩阵和投影矩阵;Canvas里预生成的纹理,在WebGL里可以写成程序化纹理,甚至直接对纹理坐标做数学变换,生成无限精细的图案。

我个人在实际操作中的体会是,别急着上来就追着高难度技术栈跑。无限隧道这个效果最迷人的地方,恰恰在于它用最简单的数学公式,就能在二维屏幕上造出一个让人愿意一直看下去的深度空间。先把Canvas版跑通,把透视、循环、节奏这三件事吃透,再往WebGL走,你会发现自己对视觉特效的理解在不知不觉中上了一个台阶。

最后分享一个小技巧:调参数的时候,把速度、分段数、深度范围全部抽成滑块控件,放在页面底部。你会发现同样一段代码,滑块拖到不同位置,给人的情绪感受完全不同。一个合适的参数组合能让隧道有种“吞噬感”,而换一组参数可能立刻就变成了廉价屏保。这个“调参的过程”,才是无限隧道项目里最值得花时间的地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询