做3D商品展示工具那阵子,被一个看上去很直接的需求绊住了:某件3D饰品无论镜头怎么转、周围怎么放,都必须悬在画面最上层;同时要把成型画面保存成PNG,而图片里绝对不能出现血条、按钮这些UI。网上搜一圈,方案各说各话,自己试了两天终于把路趟清楚。顺手把相机配置、分层逻辑、三种截图方式整理成一份可复现的笔记。如果你也在Unity里撞上“3D物体压在最前”加上“导出无UI图像”这种组合需求,这篇文章应该能帮你少走几趟弯路。
先给个结论:这类需求在Unity里不算一个功能点,而是“显示”和“导出”两个独立问题。显示层面,大多靠多相机叠加或Shader深度绕行解决;导出层面,真正干净的做法是用专用相机渲染RenderTexture,而不是在截图前临时隐藏UI。下面从原理到代码一步步说。
1. 先弄明白“最前面”和“无UI出图”是两个问题
1.1 渲染顺序与UI层到底是什么
Unity的最终画面并不是一次画出来的,而是分成了好几趟:场景里的不透明物体按深度排序先画,透明物体按距离再画一遍,屏幕空间Canvas又把UI文本、按钮画在后半程。说直白点,UI之所以永远挡在3D模型前面,不是因为它有什么“永久置顶”魔法,而是它压根不跟3D物体在同一趟绘制里排序,而是在相机画面渲染完成之后又叠了一层上去。
所以要让一个3D物体“永远显示在最前面”,本质上有两条路:要么把这个物体从普通场景渲染序列里摘出来,放到UI之上、甚至最后再画;要么在材质Shader层面无视深度测试和排序规则,强制它每次都赢。后者听起来神奇,实际用起来约束非常多,后面会展开讲。
1.2 不要把显示问题和导出问题混在一个相机里做
我看到不少人的第一反应是调RenderQueue,或者给物体加一个极高SortingOrder,结果发现物体确实“压住”场景了,但也被UI压住;等想导出无UI图时,还得临时SetActive关掉Canvas,导完再开回来,不仅麻烦,还可能连带触发UI重新布局、性能掉帧。
更干净的做法是把职责拆开:屏幕上用主相机加“前置相机”组合,一个管场景画面,一个把指定物体叠在最前;导出时再用一个“出图相机”连RenderTexture,让它只渲染你需要的那几层,UI层根本不靠近输出目标。这样显示归显示、导出归导出,两种需求互不踩踏。
2. 让3D物体永远显示在最前的三种做法
2.1 方案一:多相机叠加,Depth Only + Culling Mask
这是我最推荐、也是测试下来最稳的方案,尤其适合Built-in Render Pipeline。
思路不复杂:新建一个相机,把要“永远最前”的物体放进单独的Layer(比如叫Frontmost),把这个相机的Culling Mask设置为只渲染Frontmost;Clear Flags设成Depth Only;Depth设得比主相机高。相机的Depth数字决定的是“渲染顺序上的先后”,数字越大越晚渲染,也就越容易覆盖早先的画面。
有个细节我一直觉得容易被忽略:前置相机的Clear Flags必须处理对。主相机如果清的是Skybox或Solid Color,是往整张颜色缓冲里填颜色;前置相机设成Depth Only时,只清深度缓冲不清颜色,这样之前画好的场景颜色还在后面,新相机画出来的前景物才能干干净净叠上去。如果前置相机也设成Skybox或Solid Color,那它会把自己的整张背景盖上去,结果就是前面一片天空盒子,后面的场景全被遮住了。
提示:这不是“Layer设对了就行”的事,Clear Flags和Depth必须同时配置正确。两个相机都设成Depth Only且Depth顺序相反时,画面可能完全错乱。
实际项目里我用过这个组合做虚拟试戴:首饰放在角色模型前方,镜头绕来绕去,首饰始终顶在最前面,而角色的手哪怕抬到首饰前方也不会遮挡首饰。对展示类、试戴类、3D数字人特效这类场景,这个方案几乎是无脑适用的。
2.2 方案二:Shader深度绕行,ZTest Always
如果项目里不方便加相机、或者想尽量减少额外节点,可以给物体用带ZTest Always的材质。ZTest的默认值是LEqual,就是“深度比现有深度小或等于才通过”;改成Always之后,只要这个物体被画到,不论深度缓冲里存的什么值,它都会通过测试、直接写入颜色缓冲,相当于“永远赢”。
这招适合少量特殊物件,但不建议当通用方案。我的原话是:能用相机解决的问题,尽量别去惹Shader。原因有几个:
- ZTest Always会无视遮挡关系,场景里任何物体挡它都会被它挡住,这在展示场景可能正是想要的,但一旦场景里有多件“置顶物”,它们之间谁挡谁就全靠RenderQueue硬排了。
- 和屏幕空间UI仍然有纠缠,它是3D渲染阶段的事情,画完再叠UI,UI还是会压住它。想让它压住UI又得配合相机方案。
- 透明材质、后处理、深度贴图、URP的Overlay相机等条件下,表现都会不一样,排查起来比相机方案费劲得多。
2.3 方案三:URP RenderFeature注入,把置顶效果做成后处理
后来在URP项目里试过另一个做法:自定义RenderFeature,在一个额外的RenderPass里把指定物体画进单独的RT,再最终合成到画面。好处是所有逻辑收进管线里,屏幕上看到什么、导出图想要什么效果都能统一控制。这套玩法适合做武器第一人称视角、装饰性描边、局部轮廓强调这类需要和后处理打交道的事情。
但门槛确实高一点,你得理解RenderGraph和ScriptableRenderPass的生命周期、相机堆叠规则、以及管线事件插入时机。不是说不能用,而是对多数“就想让这个物体置顶”的需求而言,收益和成本不成正比。我第一次做的时候,光是搞明白Pass注入顺序就耗了半天。
2.4 三套方案的选型建议
| 方案 | 改动成本 | 稳定程度 | 适合场景 | 避坑点 |
|---|---|---|---|---|
| 多相机叠加 | 低,纯配置加少量脚本 | 高 | 商品展示、试戴、剧情高亮 | 别搞错Clear Flags |
| ZTest Always | 中等,要写或调Shader | 中 | 极少数特殊物件 | 排序、透明、UI共存 |
| RenderFeature注入 | 高,需要管线基础 | 高 | URP后处理联动需求 | 注意Pass注入时机 |
3. 实操:搭建“前置展示+无UI出图”的完整流程
3.1 相机层级与Layer配置
动手前先做一次Layer规划。我习惯把层级划分为:Default(场景里普通物体)、Frontmost(要置顶的前景物)、UI(Canvas专用)。注意Unity自带的UI层如果没有单独列出来,建议在Layer列表里加上一层,叫UI也好、叫UILayer也好,方便后面做Culling Mask排除。
具体配置步骤:
- 在Hierarchy里创建主相机MainCamera,Tag保持MainCamera。
- 在Project Settings的Tags and Layers里新建Frontmost层,序号随意。
- 把要置顶的物体拖进场景,设Layer为Frontmost,同时确保它的所有子物体Layer一致。必要时写个小函数递归设置,因为Unity对没落层的子物体会继承默认层,导致相机不渲染它。
- 新建相机FrontCamera,设为不需要AudioListener,Culling Mask只勾选Frontmost,Clear Flags选Depth Only,Depth设成1或大于主相机的值。
- 主相机保持默认渲染全部(或排除后续要用的导出专用层),Depth设0,Clear Flags用Skybox或Solid Color都行。
这里有个关键点:把物体移到Frontmost层之后,主相机的Culling Mask如果默认是Everything,它会继续渲染这个物体;但同时前置相机也渲染它,同一个物体会被画两遍。如果这个物体是不透明材质,画两遍不影响颜色,只是多一次渲染开销;但如果是透明材质或带额外Pass,可能出现叠影。所以我建议主相机的Culling Mask排除Frontmost层,让FrontCamera单独负责画它。
3.2 三种截图方式的代码与参数细节
导出部分我做了三种方式的对比测试,下面给一份可直接套用的核心代码,里面包含三个截图函数的完整实现。
材质准备阶段需要:一张RenderTexture、一个用于读写像素的Texture2D。RT在OnDestroy时要释放,不然编辑器里反复运行会积攒一堆内存。
using UnityEngine; using System.Collections; using System.IO; using System.Threading.Tasks; public class FrontMostAndSnapshot : MonoBehaviour { public GameObject targetObject; public Camera mainCamera; public Camera frontCamera; public Camera outCamera; public int exportWidth = 1024; public int exportHeight = 1024; public int antiAliasing = 4; private RenderTexture exportRT; void Start() { SetupExportRT(); } void SetupExportRT() { if (exportRT != null) { exportRT.Release(); } exportRT = new RenderTexture(exportWidth, exportHeight, 24, RenderTextureFormat.ARGB32); exportRT.antiAliasing = antiAliasing; exportRT.name = "ExportRT"; if (outCamera != null) { outCamera.targetTexture = exportRT; } } void OnDestroy() { if (exportRT != null) { exportRT.Release(); Destroy(exportRT); } } // 方案一:全屏截图,适合快速验证,结果包含UI public void SaveFullScreen(string path) { StartCoroutine(SaveFullScreenAsync(path)); } private IEnumerator SaveFullScreenAsync(string path) { // 等待帧末,保证相机渲染完成 yield return new WaitForEndOfFrame(); Texture2D tex = new Texture2D(Screen.width, Screen.height, TextureFormat.RGBA32, false); tex.ReadPixels(new Rect(0, 0, Screen.width, Screen.height), 0, 0); tex.Apply(); byte[] png = tex.EncodeToPNG(); File.WriteAllBytes(path, png); Destroy(tex); } // 方案二:专用相机渲染到RT,输出无UI图像 public async Task SaveWithoutUIFromRT(string path) { // 确保outCamera只渲染场景层,不渲染UI层 int uiLayer = LayerMask.NameToLayer("UI"); if (uiLayer >= 0) { int sceneMask = ~(1 << uiLayer); outCamera.cullingMask = sceneMask; } outCamera.targetTexture = exportRT; outCamera.Render(); RenderTexture.active = exportRT; Texture2D tex = new Texture2D(exportWidth, exportHeight, TextureFormat.RGBA32, false); tex.ReadPixels(new Rect(0, 0, exportWidth, exportHeight), 0, 0); tex.Apply(); RenderTexture.active = null; byte[] png = tex.EncodeToPNG(); await File.WriteAllBytesAsync(path, png); Destroy(tex); } // 方案三:ReadPixels读取屏幕区域,适合局部裁剪 public async Task SaveScreenRect(string path, Rect region) { yield return new WaitForEndOfFrame(); Texture2D tex = new Texture2D((int)region.width, (int)region.height, TextureFormat.RGBA32, false); tex.ReadPixels(region, 0, 0); tex.Apply(); byte[] png = tex.EncodeToPNG(); await File.WriteAllBytesAsync(path, png); Destroy(tex); } }注意方案三里我为了简洁写成了yield return风格,但函数签名又写了async,实际用的时候把WaitForEndOfFrame的协程改成async统一会更清晰。无论哪种方式,核心流程都一样:确定渲染目标 -> 渲染或等待渲染完成 -> ReadPixels读到Texture2D -> EncodeToPNG -> 写文件。
这里有一个特别重要的点:读RenderTexture像素之前,必须把RenderTexture.active指向目标RT,ReadPixels才会读取我们想读的那块区域,否则它读的是当前屏幕上激活的纹理。等读完了再恢复RenderTexture.active = null,避免影响后续渲染。
3.3 出图分辨率与RT抗锯齿
导出分辨率是很多人的盲区。我知道有相当多的团队直接用Screen.width和Screen.height当导出尺寸,这在手机应用里只能得到当前屏幕分辨率的图,一旦产品要求“导出图就按屏幕显示的样子”,那没问题;但如果要生成印刷级别或宣传用的高清图,就必须用专用RT尺寸。
建议分两步:产品想要多大图,你先确认好,比如1024x1024或2048x2048;然后把exportWidth和exportHeight设为这个值。RT尺寸不必跟屏幕分辨率一致,相机Render到RT时会按照RT分辨率渲染,等于用更高的内部分辨率出一张图。测试过用2048x2048的RT配合普通手机相机也能出图,色彩和细节比直接截屏好得多。
相机如果开了MSAA,RT也要设置AntiAliasing。大多数情况下设为4即可,数值再高未必有效而且占内存。导出后如果发现有锯齿,先区分是模型自带的几何锯齿还是贴图边缘锯齿,前者靠MSAA,后者靠改贴图导入设置,方向错了调半天都没用。
4. 三种截图方式逐项辨析与选择建议
4.1 CaptureScreenshot适合快速验证,不适合精确控制
Unity内置的Application.CaptureScreenshot最简单,传一个路径就能生成PNG。它的底层是直接从Backbuffer读取当前屏幕像素,所以“看到什么就存什么”,UI、后处理、FPS计数、调试信息全都包含。如果只是为了开发期截图给美术看,没问题;但拿到“不含UI层的PNG”需求里,它就不行了——除非你在截图前把Canvas整个禁用,再等一帧,再截,这又回到了开头说的临时隐藏UI的坑。
还有一个隐藏问题:CaptureScreenshot在部分移动平台上可能因为帧缓冲格式原因产生发灰、偏色,真要上线导出功能,我更愿意走RT方案。
4.2 RT相机截图是正式导出功能的正确打开方式
方案二用独立相机渲染到RenderTexture,再读像素。它之所以是“无UI图”的最佳载体,在于这个相机本身不渲染UI层,只渲染场景层。相机CullingMask是画面内容的开关,UI Canvas根本不在考虑范围内,因此不会出现“临时隐藏UI带来的一帧闪烁”问题。
我实际做过一个捏脸分享功能,玩家捏脸后点“保存”,那一刻把专用相机从角色背后对准角色,镜头FOV和主相机保持一致,RT输出1024宽,UI完全不进入画面,保存成功后弹个Toast提示完事。整个过程没有切换相机造成的画面跳跃,因为玩家看到的是主相机实时画面,保存动作只是多让一个相机多渲染了一次。
4.3 ReadPixels适合局部裁剪与拼接场景
ReadPixels是直接从一个Rect读取当前激活纹理的像素,天生适合“截取屏幕某一块区域”。比如只截头像框区域、只截物品图标区域、做九宫格拼图、做多分辨率缩略图,都能用到。它和CaptureScreenshot的区别在于,后者内部其实也调用了类似读取逻辑,但前者把读取区域和读取时机都交给你控制。
使用时要特别注意的是区域坐标:ReadPixels的Rect原点在左下角,也就是屏幕坐标的(0,0)对应左下角。做UI拼图时,如果你ReadPixels的Rect是按左上角习惯算的,图像会上下颠倒。我踩过这个坑,后来统一改成左下角坐标计算,再没出过问题。
4.4 三者的选型对比
| 截图方式 | 是否包含UI | 分辨率控制 | 适合场景 | 核心注意点 |
|---|---|---|---|---|
| CaptureScreenshot | 包含 | 弱,跟随屏幕 | 开发期快速记录 | 无法精确控制UI与分层 |
| 相机渲染RT | 不包含 | 强,可自定义尺寸 | 正式导出、产品功能 | CullingMask和RT分配要留意 |
| ReadPixels | 看你读哪个目标 | 中,局部区域可选 | 局部裁剪、拼图 | 坐标原点在左下角 |
实际交付时,我通常做两层:开发环境用CaptureScreenshot,问美术这个效果是不是想要的;正式功能里全部走相机RT方案。ReadPixels偶尔用在服务端切图的辅助流程,因为它的灵活性更高。
5. 常见问题与排查技巧实录
5.1 相机组合后画面变黑或前景消失
这个现象十有八九是Culling Mask和Layer没对上。比如把物体设成了Frontmost层,但前置相机Culling Mask忘了勾,或主相机Culling Mask还挂在Everything上导致画了两遍又叠出了怪效果。排查顺序建议:
- 先单独把场景切到前置相机预览,确认前景物体是否在它画面里。
- 检查物件的Layer,包括所有子物体,Unity对“只改父物体Layer、子物体还是Default”的情况非常宽容地不报错,就是渲染不出来。
- Layer数是不是超过32个,Culling Mask是32位掩码,超过就不被支持了。
5.2 导出的PNG颜色偏灰或偏暗
这个问题折磨过我很长时间。出图颜色不对,多数是颜色空间和像素格式不匹配。Unity渲染是Linear还是Gamma,RT是ARGB32还是RGBAFloat,EncodeToPNG时的纹理格式是RGBA32还是BGRA32,这些都影响最终色值。
经验是:导出纹理统一用TextureFormat.RGBA32,RT用RenderTextureFormat.ARGB32,如果项目开了Linear颜色空间,注意PNG编码前的颜色空间转换,必要的时候手动调一下色彩修正。偏色问题时先别急着改Shader,先在RT和Texture2D格式上找原因。
5.3 UI层被误截进PNG
用CaptureScreenshot一定会截到UI;如果用RT相机出图还截到UI,那肯定不是UI本身被截,而是UI挂在了Default层或某个场景层上。很多项目的Canvas不专门分UI层,或者TextMeshPro的3D文字混在场景层里,这就容易被出图相机一锅端。
所以Layer规划一定要前置。推荐至少分三个可见层:Default、Frontmost、UI,不要图省事。真遇到了,检查Canvas父子结构里的GameObject的Layer设置,以及每个需要排除的UI子物体的Layer是否都正确。
5.4 批量导出时内存暴涨
做批量导出功能最容易碰到这个问题。核心原因是每导一张图就new一个Texture2D和一个byte[],高频下很容易GC分配暴涨。我的做法是:
- 复用同一个Texture2D,在Start里创建一次,每次ReadPixels前保证宽高一致,尺寸变化时再重新分配。
- 用async文件写入,避免写文件期间占住主线程,造成明显的卡顿。
- RenderTexture也要复用,不要在OnGUI或Update里频繁创建释放。
按这个方式处理,批量跑上百张图的内存占用基本稳定。
6. 我最后想多说两句
做这类“置顶+出图”需求的这段时间,最大的感受是:需求越简单,越要早一点拆开成“显示链路”和“输出链路”两条线来处理。一旦你在显示链路里硬塞输出逻辑,或者反过来,后面配合UI、多相机、多目标分辨率时就会越来越扭。
我个人现在的方法已经固定成形:显示侧用多相机叠加,前置相机只负责Frontmost层;导出侧用专用RT相机,UI层不进入它的Culling Mask。这套组合从商品展示做到捏脸截图,再到角色立绘自动出图,都没再翻过方向性的错。如果你项目里已经被“某个东西永远在最前”和“导出图不要UI”折腾得头疼,按照这篇文章的结构重新捋一遍,应该很快能稳住。最后一个小技巧:所有跟截图相关的功能,一定留一个“手动测试按钮”,把相机参数暴露到Inspector上,调试效率能翻一倍。