ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

3D滚动天空超低配实现:UV滚动与雾效性能优化实战

3D滚动天空超低配实现:UV滚动与雾效性能优化实战

做3D小游戏,最让人头疼的往往不是玩法逻辑,而是同一个画面既要看着像样、又要在老设备上跑得动。有两类游戏特别喜欢用“滚动天空”这种表现:一类是跑酷/竞速,另一类是飞行射击。玩家不一定真的在移动,只要天空和地面在向后滚动,人脑就会自动脑补出“前进”的感觉,这就像跑步机上的风景画,画面本身没变,变化的只是传送带。

这次要聊的就是这样一个案例——3D游戏案例:滚动天空(超低配版)。它不搞花哨的天空盒、不做粒子云海,目标非常明确:在一台入门级手机或者老笔记本的集成显卡上,依然能稳定跑出流畅的滚动天空效果。适合正在学3D场景搭建、被性能问题反复折腾的开发者,也适合想做小体量跑酷或飞行Demo的人。下面直接拆解这个场景的设计取舍、核心实现和完整步骤。

1. 项目定位与整体设计思路

1.1 滚动天空到底在滚动什么

“滚动天空”本质上不是让天空动,而是让摄像机能感知到自己正在前进。想象一下站在一列火车里看向窗外,近处的电线杆飞速后退,远处的山慢慢移动,更远处的云几乎不动。这种层次差给了大脑强烈的运动感。很多新手做3D场景时习惯把注意力放在建模和贴图精度上,却忽略了“运动感”才是这类玩法的灵魂。

滚动天空系统从结构上看可以拆成三层:天空背景层、远景装饰层、近景地面层。天空背景层负责大范围的云层和天色,远景装饰层负责提供速度差异,近景地面层负责把玩家真实的速度锚定下来。三层各自滚动,速度不同,画面就活了。超低配版的核心任务,是用最少的几何体、最少的贴图、最少的运算把这三层都转起来。

你可以把它理解成横向卷轴游戏思路在3D里的延伸。2D游戏里背景循环用的是一张长图,3D场景里则用一个圆柱形隧道加贴图滚动来实现。整个循环不需要生成任何新物体,也不需要动态加载,只靠UV偏移和时间累加就能做到。

1.2 为什么超低配版不能直接套天空盒

做滚动天空最快的方式当然是挂一个标准天空盒,再把相机转一转。但对低配设备来说,这是个陷阱。

我实测对比过几个方案,各自的成本和问题如下:

方案几何成本纹理成本主要穿帮点低配友好度
标准天空盒六面大平面6张256~1K纹理视差感弱,俯仰视角僵硬一般
动态粒子云海数百粒子多张噪声图透明排序混乱,alpha过渡难控差
半球天空罩数百顶点一张全景图或极坐标贴图极点处UV变形严重较好
圆柱隧道+UV滚动两圈环带几十顶点一张256贴图上下底面需遮罩处理最好

超低配场景里我选最后一行。圆柱方案最核心的优势在于:圆柱展开后就是一张可以无缝Repeat的长条贴图,滚动时只要改一个UV偏移量,不产生任何顶点运算,也不依赖粒子或后处理。半球和天空盒的问题在于纹理映射不规则,要么极点处明显变形,要么六面接缝难以处理。圆柱的纹理映射是直线映射,坐标非常规整,云层不管怎么滚都不会扭曲。

另外一个好处是圆柱对旋转不敏感。圆柱本身是旋转对称的,所以如果你需要模拟玩家转向,直接旋转圆柱体本身也不会让贴图变形。半球转起来就不是那么回事了,顶部和底部的UV会乱掉。

1.3 性能预算先定死再动手

做低配方案,我习惯先把性能预算写出来,再倒推技术选型。没有预算约束的优化都是自我安慰。

这个案例的预算如下:

指标预算说明
天空圆筒顶点数约50顶点16段环带,上下两圈
天空圆筒三角面约96面每段两个三角形
全部场景DrawCall小于20天空1次、装饰若干、地面1次
天空贴图尺寸256x256压缩纹理,内存占用极小
全场景纹理总量小于2MB超过就要砍
帧率目标老款入门级手机30FPS不开后处理、不开动态阴影

为什么把顶点数压到这么低?因为对低端GPU来说,真正稀缺的不是CPU的顶点计算能力,而是显存带宽和填充率。顶点数少意味着内存占用少,纹理小意味着每帧读取贴图的数据量小,DrawCall少意味着CPU端的提交压力小。你哪怕把圆柱段数加到64段,视觉差异可能只有一点点,但在低端设备上的开销差距却非常明显。

我见过有人把天空圆筒做到128段,贴图用到1024分辨率,然后抱怨低端机掉帧。实际上一张256贴图加16段圆柱,在滚动速度合理的情况下根本看不出段数少,因为雾效会把棱角全部吃干净。后面我会详细讲雾效的用法,这属于低配渲染里的“藏”字诀。

2. 核心技术点拆解:滚动、循环与伪装

2.1 UV滚动:一张贴图制造无限长传送带

滚动天空的核心机制就是UV滚动。原理一句话:网格模型原地不动,只改变材质贴图的采样坐标。贴图在u方向偏移,圆筒内壁看起来就在水平方向上移动。

这里有个关键的速度换算问题。很多新手直接写一个“速度=0.1”然后每帧加,结果云飘得比飞机还快。正确的做法是把滚动速度和世界尺寸挂钩。公式如下:

// 假设角色实际前进速度为移动速度 float worldSpeed = 10f; // 角色每秒前进10米 float skyVisualScale = 0.75f; // 天空视觉速度为角色速度的0.75倍 float rollSpeed = worldSpeed * skyVisualScale; // 圆筒周长 = 2 * PI * R float circumference = 2f * 3.14159265f * skyRadius; // 每秒UV偏移量 = 视觉速度 / 周长 float offsetPerSecond = rollSpeed / circumference; // 每帧在材质上设置偏移 float totalOffset = (offsetPerSecond * runningTime) % 1.0f; material.SetTextureOffset("_MainTex", new Vector2(totalOffset, 0f));

这样算出来的滚动速度有一个明确含义:天空上的某个云朵,其相对地面移动的速度等于角色速度的0.75倍。数值上0.75不是凭空来的,它是我试出来的一个经验值。现实中天空无限远,理论上云根本不移动,但游戏里如果云完全不动,你会觉得角色没在前进,失去了滚动的意义。所以游戏里的天空滚动普遍会比真实物理略快,让人眼能捕捉到运动感。0.6到0.9之间都可以,根据你想要的速度感微调。

跑酷类游戏尤其要注意这个换算。角色速度从0加速到20m/s时,天空滚动速度也要跟着变化,否则加速感会失真。做法是把公式里的worldSpeed实时换成角色当前的实际速度,而不是固定值。

2.2 圆筒网格:两圈顶点就够了

圆柱隧道的网格构造相当简单。两圈顶点,底圈和顶圈,每一圈均匀分布若干个点。每个底部顶点对应一个顶部顶点,中间用四边形连接,每个四边形切成两个三角形。

这里有一个非常容易踩的坑:首尾闭合。为了纹理无缝,每一圈生成的点数不是N个,而是N+1个,最后一个点和第一个点位置重合,但UV的u坐标从最后一个0.9375跳到1.0。这样三角形才能完整覆盖整个圆周,接缝处不会出现一条闪烁的线。

构建网格的通用逻辑如下:

int segments = 16; // 段数 float radius = 15f; // 圆筒半径 float height = 20f; // 圆筒高度 List<float[]> positions = new List<float[]>(); List<float[]> uvs = new List<float[]>(); List<int> triangles = new List<int>(); for (int i = 0; i <= segments; i++) { float u = (float)i / segments; // 0到1 float angle = 2f * 3.14159265f * u; // 圆周坐标 float x = (float)System.Math.Cos(angle) * radius; float z = (float)System.Math.Sin(angle) * radius; // 底部顶点 positions.Add(new float[]{ x, 0f, z }); uvs.Add(new float[]{ u, 0f }); // 顶部顶点 positions.Add(new float[]{ x, height, z }); uvs.Add(new float[]{ u, 1f }); } // 侧面三角形 for (int i = 0; i < segments; i++) { int a = i * 2; int b = a + 1; int c = a + 2; int d = a + 3; // 四边形切成两个三角形 triangles.Add(a); triangles.Add(d); triangles.Add(b); triangles.Add(a); triangles.Add(c); triangles.Add(d); }

uv的v方向分配很简单:底部v=0,顶部v=1。贴图里天空应该放在上半部分,地平线放在大约v=0.75的位置,下半部分是前景和地面过渡色。

还有一个关键点:法线方向。摄像机站在圆筒内部,看到的是内表面,所以法线必须指向圆心方向,也就是取圆周坐标的反方向。如果法线朝外,引擎的背面剔除会把整个圆筒内侧全部剔除,屏幕上一片黑。做法是在构建顶点时把法线设为(-x, 0, -z),记得归一化。有些引擎支持双面渲染,但双面渲染会增加填充开销,低配方案里我更推荐直接翻转法线。

2.3 雾效:把所有穿帮点埋进白茫茫里

低配场景的美术核心不是“堆料”,而是“藏”。圆筒边缘、装饰物循环点、地面接缝,所有你可能穿帮的地方,最终都要藏进雾里。

雾效为什么重要?因为圆柱段数只有16段,玩家在远处能看到明显的棱角。这时如果颜色在棱角处频繁跳变,视觉上非常突兀。但雾会把远距离的颜色压成一个统一色,棱角消失,就像隔着一层白纱看风景。

雾的密度选择有一个经验公式:雾的可见距离大致等于 4.6 / density。这是指数雾的衰减常数。假设你想让100米外完全看不见,density就取0.046。实际可以略高一点,取0.02到0.04之间都有效果。如果用的是线性雾,那就设置起点和终点,起点从圆筒半径的0.8倍开始,终点在80到120米之间。

雾色必须和天空贴图的地平线颜色统一。很多人忽略这一步:天空贴图地平线是偏灰蓝的,雾色却是纯白,结果远处出现一条明显的分界线,比穿帮还显眼。正确做法是先定雾色,再让美术照着雾色画贴图的地平线区域。例如雾色是RGB(0.62, 0.70, 0.78),贴图地平线附近也取这个数值,并做上下渐晕过渡。

我见过一些低配项目为了省性能不开雾,结果圆筒的棱边、装饰物的循环瞬移全部暴露,效果比开了雾还掉价。雾效的运算成本很低,一个全屏雾效可能只影响片段着色器里的一个乘法,换来的是整个场景的视觉完整性,这笔买卖绝对划算。

2.4 假太阳、假阴影:能省的光照全省掉

超低配方案里,不要开任何实时阴影,也不要开动态全局光照。这些功能在PC上无所谓,在低端移动设备上代价极高。替代方案全是“假”的,但效果足够。

太阳直接做成独立光斑贴片。方法是在场景远处放一个双面渲染的小四边形,贴一张圆形光晕贴图,始终面向摄像机。这个四边形不参与滚动,位置固定在世界某个方向。这样你滚动天空贴图时云在动,太阳却纹丝不动,符合真实世界的逻辑。如果用滚动天空贴图里的太阳,云层滚动时太阳也会跟着滑,玩家立刻会觉得不对劲。

阴影用一个半透明的黑色圆片模拟。圆片贴在角色脚下地面附近,偏移量根据太阳位置方向计算,透明度约0.3到0.5,半径大概一个身位。这个黑色圆片本质上是贴花,不参与光照计算,成本就是一个透明层。它给出的“假阴影”足够让角色感觉踩在了地面上,不会悬空。

环境光直接用固定颜色或者一个方向光,方向光只用来照亮模型,不吃阴影。模型材质尽量用最基础的漫反射材质,不要开高光和反射计算,很多低端机的性能黑洞都来自反射探针和实时反射。

3. 实操过程:从空场景到滚动天空

3.1 场景搭建四步走

先列一个完整流程,再逐步拆解。

第一步:创建圆筒网格。在编辑器里用一个空物体挂网格组件,把上面那段构建代码跑一遍,生成圆柱隧道的三角网格。生成后检查顶点数、法线方向和UV分布。检查法线最简单的方法是进场景后走到圆筒内部,确认能看到内壁且颜色正常。

第二步:制作天空贴图。256x256的尺寸,画上渐变天空和几朵云。地平线放在v=0.75的位置,地平线以下到v=1画大地的渐变色。贴图左右边缘必须能够无缝拼接,做的时候把云朵左右延伸出去,不要在中线处硬切。云朵要横向拉长一些,因为滚动时它们会水平运动,拉长的形状更接近真实云层的透视感。

第三步:设置材质和滚动脚本。材质用支持雾的默认不透明材质,主贴图设为天空贴图。贴图Wrap模式设置为Repeat,否则UV超过1会自动截断。滚动脚本挂到圆筒物体上,每帧根据上面公式更新材质的贴图偏移。注意如果同一个材质被多个物体使用,改偏移会同时影响所有使用该材质的物体,这个案例里没问题,因为只有圆筒用这张贴图。如果有多个不同滚动速度的物体,需要给它们各自克隆出一份材质实例,否则会互相干扰。

第四步:调整雾效参数。打开场景雾效,设置颜色和密度。颜色以地平线颜色为准,密度按可见距离估算。打开雾效后观察远景,确保圆筒边缘完全被雾色覆盖,同时近景处的物体还保留清晰轮廓。

运行之后检查三件事:天空是否在匀速滚动、远处是否能看到接缝或棱角、角色加速时滚动速度是否同步变化。这三项都过了,滚动天空主框架就算完成了。

3.2 装饰物循环:世界坐标取模大法

天空滚动了,地面上还需要一些远景装饰来增强空间层次。但这个装饰物不能无限生成,必须做循环。

循环的思路是“两组预制体接力跑”。假设你有一组远山和一个云朵群。把同一组装饰在场景里摆放两份:一份放在玩家前方0米处,另一份放在正前方L米处,L是循环区间的长度。两份位置完全相同,相当于复制了一份。当第一份跑到玩家身后时,直接把它的位置平移到第二份的前方L米处。这样玩家永远看不到装饰物凭空消失或出现,因为每次“瞬移”都发生在身后视野外。

更通用的做法是存一个“循环基准坐标”然后取模:

// 循环区间长度 float loopLength = 100f; float halfLoop = loopLength * 0.5f; // 每帧根据玩家位置计算装饰物的世界坐标 float playerX = player.transform.position.x; float localOffset = deco.localOffset; // 装饰物在循环区间内的相对位置 // 将坐标映射到玩家前方 float relative = (localOffset - playerX) % loopLength; if (relative < -halfLoop) relative += loopLength; if (relative > halfLoop) relative -= loopLength; // 显示位置 deco.transform.position = new Vector3(playerX + relative, deco.y, deco.z);

这个取模逻辑的优点是装饰物始终围绕玩家生成,任意时刻离玩家不会超过半个循环区间。看起来就像场景中一直有无限的装饰物,实际上只需要几份预制体。

循环区间长度不能小于摄像机可见距离。如果你雾的可见距离是100米,循环区间至少取120米,否则你会在雾里隐约看到装饰物的“克隆阵”。同理,同一种装饰物不要在循环区间内只放一个,放三个才没有规律感。

3.3 一套可直接抄作业的参数模板

下面是这个案例我最终定稿的参数,直接照着填就能得到一个可用的滚动天空效果。

参数推荐值说明
圆筒半径15米摄像机到圆筒内壁的距离,控制视场包裹感
圆筒高度20米顶部保持高于摄像机仰角最大范围
圆筒段数16段雾效会遮掉棱角,16段足够
天空贴图256x256,Repeat模式左右无缝,地平线在v=0.75
贴图格式压缩格式(ETC2/ASTC 4x4)低端机带宽友好
滚动基础速度角色速度x0.75/周长按公式实时计算
雾起点圆筒半径x0.8约12米
雾终点/密度密度0.02~0.04让100米外完全雾化
雾色地平线同款颜色避免天际线分界
实时阴影关闭用假阴影贴片
太阳独立光斑贴片不参与滚动

这套参数的合理性在于:圆筒半径15米,周长约94米,贴图宽度256像素,每像素代表约0.37米。玩家在近景处看到的云朵细节不会突兀,远景处雾效已经完全覆盖,分辨率再低也影响不大。雾起点设在12米意味着近景的圆筒棱角在玩家前方10米内还是可见的,因此段数不能低于12,16段是我测试下来性价比最高的选择。

4. 常见问题与排查技巧实录

4.1 接缝处闪线

这是滚动天空最经典的问题。症状是一条垂直的亮线或暗线在圆筒接缝处反复闪烁,尤其在低分辨率设备上更明显。

原因有三个:一是贴图Wrap模式没有设为Repeat,导致u=1和u=0之间出现颜色跳变;二是顶点首尾没有完全重合,出现了一个细长的畸变三角形;三是Gamma校正导致接缝处的半像素混叠。

排查顺序:先看贴图Wrap模式,改成Repeat;再看顶点数据,确认最后一段两个三角形覆盖了u从0.9375到1的区间;最后把摄像机移动到接缝处,放大观察是否为纹素对齐问题。如果是半像素问题,微调贴图偏移0.5个像素,或者把贴图左右边缘各多画6个像素的重复内容,接缝就基本消失了。

4.2 贴图被拉伸成面条

云朵明明画的是圆形的,滚动起来却变成横向长条。这个问题的根源是UV比例和物理尺寸不匹配。

圆筒展开后的周长是水平方向,高度是垂直方向。贴图的宽高比必须等于周长和高度的比,才不会拉伸。以半径15米、高度20米为例,周长94.2米,比例4.71 : 1。256x256的正方形贴图直接贴上去,水平方向会被拉伸成4.71倍。正确做法是贴图做成512x109这样的矩形比例,或者把圆筒高度改为贴图比例对应的值。最省事的方法是做一个自定义矩形画布来画贴图。

4.3 圆筒内壁发黑

进场景后圆筒内部是黑色的,从外面看却正常。这就是法线方向导致的背面剔除问题。

圆筒内表面法线必须朝向圆心。构建顶点时法线设为圆周坐标的反方向,并做归一化。有些引擎的剔除规则跟三角形绕序有关,还需要检查三角形索引的绕序是否顺时针。肉眼观察法:把相机放在圆筒内部,如果能看到内壁但颜色偏暗,说明法线还是反的,大概率是材质里开了双面渲染但光照方向没处理。

4.4 雾没盖住远处边缘

设置了雾效,但远处地平线仍然有一道明显的分界,或者能看到圆筒顶部的开口。

先确认雾效对材质生效。有些材质的shader是“不受雾影响”的,需要在shader里加入雾相关代码,或者在材质面板勾选受雾影响选项。如果已经生效但仍有分界,检查雾色和贴图地平线色的差异。最简单粗暴的验证法:把雾色涂成纯红色跑一帧,如果画面上圆筒远景变成一片红,说明雾效工作正常,剩下的问题就是颜色没调齐。

顶部开口的解法:圆筒顶上加一个实心圆盘,贴图用天空的延续色。圆盘和圆筒共用同一张贴图时uv要单独调整,或者干脆把圆盘做成纯色,反正雾效已经把它盖得差不多。

4.5 低端机仍然掉帧

参数都照抄了,老设备上还是卡。这时候要从三个地方排查。

第一是DrawCall。打开性能分析器看每帧提交次数。如果超过预算,优先检查半透明物体。天空圆筒本身是不透明材质,但装饰物如果用了带alpha的云片,透明排序会很耗性能。云片改成不透明背景加剪影外形的做法,或者合并到天空贴图里。

第二是填充率。天空圆筒占据屏幕很大面积,如果使用全屏后处理、泛光、色调映射,低端机基本撑不住。把后处理全关,场景色彩靠贴图和雾色本身来调。

第三是纹理带宽。256x256贴图在PC上小得可怜,但在低端移动设备上如果没压缩,每帧读取也占不少带宽。确认贴图导入设置里的压缩格式为ETC2或ASTC,并关闭Mipmap。很多引擎默认开Mipmap,这个案例里天空圆筒距离固定,Mipmap不仅没用还白耗内存。

4.6 问题速查表

症状可能原因解决操作
接缝闪线Wrap不是Repeat/顶点未闭合改贴图Wrap,检查首尾顶点重合
云拉成长条贴图宽高比与圆筒周长不匹配调整贴图比例或圆筒高度
内壁全黑法线朝外/背面剔除翻转法线,检查三角形绕序
远处有分界线雾色与地平线色不一致统一雾色与贴图地平线色
装饰物循环穿帮循环区间小于雾可见距离加大loopLength,补一份装饰预制体
老机型掉帧DrawCall或纹理带宽超标合并透明层,压缩贴图,关闭后处理

最后聊一点我做完这个案例后的体会。“超低配”不是把效果砍到最丑,而是把所有开销花在观众盯着看的地方。滚动天空这类场景,观众盯的是云层和地平线的连续感,那我就把预算全给UV滚动和雾效,把阴影、粒子、后处理全省掉,画面反而干净。这套思路扩展空间也大,想加昼夜变化就在贴图里做两套色带插值,想加云层深度就在圆筒里再加一层半透明云片,滚动速度错开就行。但不管怎么扩展,记住一条原则:低配方案里每个DrawCall、每张贴图都得能说出它存在的理由。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进