最早被“无限隧道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像素。大量亚像素纹理挤在一起会产生闪烁的摩尔纹。完全消除需要做逐像素的mipmapCanvas 2D里没有直接接口但可以绕开。我的做法是当某一段的屏幕宽度小于一定阈值比如1.5像素时直接跳过不画。配合之前说过的中心圆遮罩几乎看不见跳过的痕迹。另一个有效措施是把贴图制作成本身带一定的“低通滤波”效果画条纹时不要画成纯黑纯白而是用带有灰阶过渡的柔和线条这样在远处自动混成灰色减少高频闪烁。这个方法不需要增加任何运行成本。4.3 移动端性能drawImage调用次数与离屏拼合隧道分段数如果设成60四块墙壁意味着每帧要调用240次drawImage。桌面浏览器没压力但移动端在低性能机型上会出现明显掉帧。优化方向有两个。第一个思路是减少绘制次数把四块墙壁按深度切片后把同一个深度段的上下左右纹理先画到一张离屏小画布上形成一张环状隧道切片图再一次性drawImage到主画布。这样每帧绘制调用从240次降到了60次左右。考虑到切片图本身分辨率很小离屏绘制的开销也不大。第二个思路是降低画布的实际分辨率再用CSS放大到显示尺寸。比如把画布分辨率设为640x360CSS里拉满到容器宽度很多视觉上无关紧要的频率细节被抹平帧率明显提升。这个方法对无限隧道这种“远距离看不清细节”的效果特别适用因为观众注意力集中在中心运动的光流上低分辨率造成的画质下降很不容易觉察。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走你会发现自己对视觉特效的理解在不知不觉中上了一个台阶。最后分享一个小技巧调参数的时候把速度、分段数、深度范围全部抽成滑块控件放在页面底部。你会发现同样一段代码滑块拖到不同位置给人的情绪感受完全不同。一个合适的参数组合能让隧道有种“吞噬感”而换一组参数可能立刻就变成了廉价屏保。这个“调参的过程”才是无限隧道项目里最值得花时间的地方。