做一个有“分享图”功能的游戏,策划经常一句话丢过来:截个图存到手机相册呗。刚开始觉得挺简单,真正上手才发现坑不少,黑屏、颠倒、权限弹窗、相册刷不出来,全踩过一遍才算把这条链路理顺。这篇把 Cocos Creator 里“显示截图并保存图片到手机”的完整思路、核心代码、平台差异和排查经验整理出来,希望对正在做类似功能的同学有直接帮助,特别适合已经会基本场景搭建、准备做社交裂变或成就分享功能的小游戏开发者和工具类 App 开发者。1. 需求拆解与方案选型1.1 这个功能到底要解决什么表面上看是“截图 + 保存”,做进去才知道,用户真正关心的是三件事:第一,截图里的内容要跟当前画面一致,不能黑屏、不能花屏;第二,图片要能进系统相册,微信小游戏里要能直接保存;第三,整个过程不能卡住主线程,游戏不能因此顿一下。很多时候我们只顾着把像素读出来,忽略后两条,结果测版本时被测试同学反复提单。从使用场景看,主流需求可以分成四类:社交分享图,通常是激励用户分享到群或朋友圈;排行榜炫耀图,把分数、角色形象拼进一张底图;问题反馈,玩家遇到 Bug 时需要带图上报;还有工具类产品的凭证保存,比如票据、二维码、报表结果。这四类场景对清晰度、文件格式、保存位置的要求不太一样,但底层技术链路是共通的。1.2 三条技术路线对比Cocos Creator 里实现截图保存,主流做法无外乎三条路线:路线实现成本画面还原度平台覆盖缺点原生 Canvas 绘制低差,只能画 UI,无法捕获 3D 相机全部 Web 环境无法用于包含模型、粒子、场景相机的截图Cocos 渲染纹理读取像素中好,能完整捕获相机画面全部 Cocos 支持平台需要处理坐标翻转和像素格式原生平台桥接截图高最真实,直接截系统屏幕Android/iOS 原生需要写原生代码,屏幕外 UI 会被截掉,成本高很多新手第一个想到的是 Canvas 截图,因为浏览器里就是这么干的。但在 Cocos 场景里,Canvas 截图只能拿到 UI 层之外的稀疏内容,场景相机里的 3D 模型、粒子特效根本不在其中。所以我的建议很直接:不要用 Canvas,直接用 Cocos 渲染纹理,把相机画到一张纹理上,再用 GPU 回读像素,这是和引擎渲染管线最契合的方式。1.3 为什么选渲染纹理方案渲染纹理方案最大的优势是“所见即所得”。你不需要关心屏幕分辨率、DPR、引擎窗口大小这些细节,直接把目标相机渲染到一张同等尺寸的纹理上,然后从纹理里把像素数据拷出来,再做图片编码。整个过程都在 Cocos 的渲染框架里完成,Web、Android、iOS、小游戏这些平台都能用同一套 TS API 走通,不会出现“换个平台代码重写”的情况。选这条路也有代价,代价就是要理解几个底层概念:RenderTexture 是什么、readPixels 的方向、像素数据的排列方式。这些概念对只用过 UI 的开发者来说有点陌生,但一旦搞懂了,截图只是几行代码的事。2. 核心原理:Cocos 里的一张截图是怎么产生的2.1 从渲染管线说起要明白截图,先要明白画面是怎么出来的。Cocos 的渲染流程大致是:场景里的每个相机经过裁剪、坐标变换、光栅化,把画好的颜色写入到一块帧缓冲区(Framebuffer)里,最终这块缓冲区的内容被显示到屏幕窗口。平时我们看到的屏幕,其实是默认相机写入的这块缓冲区。截图就是在“写入缓冲区”这个环节做一次副本。Cocos 里提供了一种对象叫 RenderTexture,本质上就是一块可以离屏渲染的“虚拟画板”。你把相机绑定到这块画板上,相机就不会把画面画到默认的屏幕缓冲区,而是画到这块画板里。这时候屏幕上的内容可能会消失,所以正确做法是:要么复制一个相机专门渲染到画板,要么用一个额外的相机在隐藏层渲染。2.2 RenderTexture 扮演的角色RenderTexture 可以理解为游戏世界的“第二摄像头记录设备”。普通相机直接输出到屏幕,RenderTexture 相机输出到一块纹理。这块纹理不会出现在屏幕上,但可以在代码里读取和复用。在 Cocos Creator 3.x 里,创建一个用于截图的 RenderTexture 需要指定尺寸。最稳妥的尺寸是view.getVisibleSizeInPixel(),因为这个尺寸分析和实际屏幕像素一致。然后新建一个相机组件,指定它的targetTexture指向这张 RenderTexture,再把要截的内容(主相机视角或专用拍摄视角)摆到这个相机面前。有一个细节容易被忽略:Camera 的visibility和cullingMask要设置正确。如果截图相机的剔除层没有包含场景物体,画面就是空的。很多“截出来是黑屏”的案例,不是代码错了,而是相机什么都没拍到。2.3 GPU 像素回读与坐标翻转相机画到 RenderTexture 后,纹理里的数据还在 GPU 上,TS 层需要调用读取接口把像素从 GPU 拷到 CPU 内存里。Cocos 提供了readPixels一类方法,把纹理数据拷贝到一个ArrayBufferView中。这一步是整条链路里最容易出问题的地方。第一个问题是坐标方向。底层图形 API 的像素原点通常在左下角,而图像文件(PNG/JPG)的原点在左上角。如果你直接把读出来的数据编码成图片,保存到相册后会发现上下颠倒。解决办法是在回读时从最后一行开始逐行拷贝,把行顺序反一下。第二个问题是像素格式。Cocos 的 RenderTexture 默认可能是 RGBA,每个像素四个字节,排列顺序是 R、G、B、A。如果你读出来之后直接当作 RGB 三字节数据去编码,颜色会完全错乱。处理时要么保持 RGBA 格式,要么在做格式转换时明确告诉编码器数据里包含 Alpha 通道。2.4 从像素数据到图片文件拿到一行行像素之后,还需要一个编码过程。Web 环境里可以把数据转成Blob,再生成 Object URL 下载;原生环境里建议做一个通用的编码封装,或者直接传给原生层,用各平台自带的图片编码 API。Cocos 3.x 中比较省力的做法是先把像素数据构造成ImageAsset,再转成Texture2D,就能作为 Sprite 显示在 UI 上。“显示截图”这个需求,本质就是把这步直接摆在屏幕上,给用户一个预览反馈。保存到相册时,再把ImageAsset对应的数据流交给平台层去落盘。3. 实操实现:截图并保存到相册3.1 环境准备与版本约定下面示例基于 Cocos Creator 3.x 写 TypeScript 代码。3.0 之后的 API 比 2.x 稳定很多,RenderTexture 的使用方式也更规范。如果你的项目还在用 2.x,API 名称略有不同,但核心思路一样。创建一个独立摄像机,专门用于截图:import { _decorator, Camera, Component, RenderTexture, Sprite, SpriteFrame, Texture2D, view } from 'cc'; const { ccclass, property } = _decorator;3.2 截图工具类的核心代码我先放一份可直接抄的完整工具类,再逐步解释每一块在做什么:@ccclass('CaptureTool') export class CaptureTool { // 创建一张和屏幕像素尺寸一致的渲染纹理 public static createRenderTexture(): RenderTexture { const size = view.getVisibleSizeInPixel(); const rt = new RenderTexture(); rt.reset({ width: size.width, height: size.height }); return rt; } // 将相机画面渲染到纹理上 public static captureCameraToTexture(targetCamera: Camera, rt: RenderTexture): void { targetCamera.targetTexture = rt; } // 从纹理中读取像素,翻转后输出为 ImageAsset public static textureToImageAsset(rt: RenderTexture): ImageAsset { const width = rt.width; const height = rt.height; // 4 字节为 RGBA const buffer = new Uint8Array(width * height * 4); const gfxTexture = rt.GFXPixelBuffer; // 实际工程中可用 readPixels 系列接口把像素读入 buffer rt.readPixels(0, 0, width, height, buffer); // 坐标翻转:因为底层原点在左下,图像文件原点在左上 const flipped = new Uint8Array(width * height * 4); const rowBytes = width * 4; for (let y = 0; y < height; y++) { const srcRow = (height - 1 - y) * rowBytes; const dstRow = y * rowBytes; flipped.set(buffer.subarray(srcRow, srcRow + rowBytes), dstRow); } const imageAsset = new ImageAsset({ width: width, height: height, format: PixelFormat.RGBA8888, data: flipped }); return imageAsset; } }这里readPixels是示意接口,不同版本调用名可能稍有差别。关键是理解三个步骤:分配固定大小的 Uint8Array、读取、翻转行序。3.3 将图片保存到手机相册TS 层拿到ImageAsset后,真正的落盘任务是原生层的事。Cocos 本身不提供“保存到相册”的通用按钮,需要自己写原生扩展或者引入第三方封装。整体流程是这样:TS 层把 ImageAsset 编码成 PNG 或 JPG 的字节流。PNG 适合有透明区域的游戏画面,JPG 适合照片类内容,文件更小但有损。将字节流转成 Base64 字符串或写入临时文件路径。调用原生桥接方法,把数据传给 Android/iOS 层。原生层把数据写入相册,并返回成功/失败回调。Web 端最简单,直接利用浏览器下载能力:// 把 Uint8Array 保存为本地文件(Web 环境) function saveUint8ArrayToBrowser(data: Uint8Array, fileName: string) { const blob = new Blob([data], { type: 'image/png' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; a.click(); URL.revokeObjectURL(url); }小游戏平台的保存方式又不一样,许多小游戏环境要求使用平台提供的相册接口,并且必须在真实用户点击事件触发的回调里调用,否则会被拦截。弹权限和保存这两步,一定要放到手指点击的同一帧调用链里,提前写好或异步延迟调用都可能被平台判定为违规。3.4 界面显示截图预览“显示截图”和“保存图片”通常是两件事。玩家点“分享”后,先弹出一个预览框,里面显示刚截好的图片,再放一个“保存到相册”按钮。这个交互能极大降低误操作率,也符合用户对一款成熟游戏的预期。预览实现也不复杂,把刚才的 ImageAsset 转成 SpriteFrame,再赋给一个 Sprite:import { SpriteFrame, Texture2D } from 'cc'; // 把 ImageAsset 转成 SpriteFrame 供 UI 显示 const texture = new Texture2D(); texture.image = imageAsset; const spriteFrame = new SpriteFrame(); spriteFrame.texture = texture; // 假设 previewSprite 是场景里的一个 Sprite 组件 previewSprite.spriteFrame = spriteFrame;这里需要注意:Texture2D 的尺寸和 ImageAsset 尺寸必须一致,否则显示会拉伸或裁切。建议在spriteFrame上设置好sizeMode,预览框用固定宽高加适配,避免不同分辨率机器上显示变形。3.5 推荐的最小调用流程在实际项目中,我通常把流程封装成一步搞定:async function captureAndSave(saveToAlbum: boolean): Promise<void> { // 1. 创建渲染纹理 const rt = CaptureTool.createRenderTexture(); // 2. 把要截图的相机绑定到纹理上,等待一帧渲染完成 captureCameraToTexture(uiCamera, rt); // 留意:绑定后需要等待相机渲染完成 // 3. 读取像素并生成 ImageAsset const asset = CaptureTool.textureToImageAsset(rt); // 4. 显示预览 showPreview(asset); // 5. 如果需要保存相册,再走保存通道 if (saveToAlbum) { await saveImageAssetToAlbum(asset); } }等待相机渲染完成这一步,很多项目会忽略。解决方案是注册相机的CameraAfterDraw事件,在事件回调里再执行读取,确保纹理已经写入。4. 常见问题与排查实录4.1 截出来是黑屏或空白黑屏十有八九是相机或者物体出问题。排除顺序我一般这样走:第一,目标相机挂没挂。挂targetTexture之后,这个相机就不会往最终屏幕输出,如果项目里只有这一个相机,整个游戏画面都会消失。第二,相机的cullingMask和截图物体的layer是否匹配。很多美术资源挂在默认层,截图相机却只渲染 UI 层,自然什么都拍不到。第三,模型和灯光是否在相机视野内。相机位置、朝向、远近裁切距离都可能把物体剔除掉。还有一个隐藏点:场景没有活动灯光,或模型用了无光照材质的单色状态,拍出来看起来像一块黑布。这种不是代码错误,是画面内容本身就没有有效信息。4.2 图片上下颠倒这个问题在 Web 上很少暴露,原生平台更容易遇到。根源是纹理在显存里的行序跟图片编码时的行序相反。我见过不少人在网上抄到代码,发现相册里图片是倒的,就直接在图片编码层二次翻转,结果转到 Web 平台又变正了,其实是在两个平台修同一套数据的行序,轮番出错。最干净的做法是在读取像素后,统一按“读一行、翻一行”的方式标准化成图像坐标。代码示例里已经写了flipped那段逻辑,不要再在保存层旋转,应该保证“进入编码器之前数据永远是图像坐标的正序”。4.3 相册里迟迟刷不出来新图片Android 上保存成功后,相册 App 并不会立刻看到新图片,因为系统媒体库没有收到通知。有些机型上你把文件写到相册目录,但是没触发媒体扫描,图库里就是看不到,看起来像是保存失败。解决办法是保存完成之后发送一次媒体扫描广播。Android 原生代码里可以对刚保存的文件路径做一次媒体库刷新,iOS 的 Photos 框架会自动处理,通常不需要额外操作。在调试阶段,先确认文件是否真实存在,再确认相册是否刷新,能帮你快速定位是写入失败还是扫描延迟。4.4 Android 10+ 的存储权限问题新版 Android 对公共存储目录的管理越来越严格,直接把 PNG 写到随意路径很可能失败。标准做法是写进系统相册目录或应用专属目录,再通过媒体库注册。如果你用的是 Cocos 官方或第三方封装,一定要确认封装版本是否适配了 Android 10 以上的分区存储。否则你就会遇到一种怪现象:系统返回“保存成功”,文件也确实存在于应用沙盒里,但相册、文件管理器都看不到。处理要点是不要手动申请读写整个外部存储的权限。修改成使用对应新版本平台的媒体库接口,才是干净做法。小游戏平台更特殊,平台内部帮开发者管理了文件系统,你只需要调用它的相册保存接口。4.5 权限弹窗和用户交互时序iOS 上如果没有相册写入权限,首次保存会弹窗;弹窗出现的时候,你的游戏如果处于后台或渲染挂起状态,很容易出现回调丢失。最稳妥的处理是提前在设置的引导页做一次权限预申请,或者在用户第一次点“保存”时明确告知会弹权限框,避免“点了没反应”的误解。不少平台的 API 文档写明“保存按钮必须在用户点击回调里同步调用”。意思是不要在点击之后先发网络请求,等响应回来再保存;也不要保存前先setTimeout延时 1 秒。应该把保存动作放到点击事件的处理链路中,尽快触发系统接口。4.6 常见问题速查表现象可能原因优先排查方式全黑图截图相机没有捕获目标层检查挂载相机的 cullingMask半透明区域变黑块RGBA 被误当 RGB 编码检查像素格式和解码格式是否一致图片上下颠倒原点行序未翻转在像素读取层翻转行序,不要改成旋转保存成功但相册没有未触发媒体库扫描检查原生层是否发送媒体刷新广播权限弹窗后回调丢失生命周期切换导致回调未恢复用全局回调管理器,绑定生命周期节点小游戏点击无反应未在用户手势回调内调用保存调整调用链,让保存接口同步被触发这张表建议直接随项目文档留在工程里,之后再有人问截图问题,先对表再查代码,效率高很多。5. 性能、边界与上线避坑5.1 截图应该是“用户主动触发”,不是每帧执行很多人觉得截图就是把渲染纹理的数据拷出来,开销可以接受。但要知道,ReadPixels 是一个非常重的同步操作,它让 GPU 停止手头工作,等待像素数据回传到 CPU。如果你在做自动截图录屏类功能,比如每 100 帧截一张,游戏会明显掉帧,甚至瞬间卡顿。更合理的做法是只在 UI 操作后单帧截图。保存图片后在短时间内给玩家一个“处理中”的状态提示,避免连续点击导致多个截图任务并发。我在一个小游戏项目里做过限制:一个按钮冷却期内只允许一次截图请求,连续点击直接忽略,很大程度上保护了运行时性能。5.2 RenderTexture 和像素缓冲的复用反复创建和销毁 RenderTexture,会造成内存碎片和 GC 压力。每个 RenderTexture 都对应一块 GPU 显存,大尺寸纹理更明显。更好的策略是维护一个截图专用池子,按屏幕尺寸创建一张,使用完成后不要销毁,直接留着下次复用。像素缓冲的 Uint8Array 同理。如果每张 1080p 截图是 1920×1080×4,差不多 8MB 内存。频繁分配会触发 GC,用户会感觉到轻微的周期性卡顿。推荐的做法是在工具类里缓存一个大小足够的 ArrayBuffer,重复使用,只有屏幕分辨率真的变化时才重新分配。5.3 超大纹理的分辨率上限一些折叠屏、平板的逻辑分辨率很高,截 4K 尺寸图理论上可行,但低端机 GPU 可能撑不住。建议做一个“截图分辨率的软上限”,比如限制最长边不超过 2560 像素。超出后对 RenderTexture 做等比缩放,先截小图再放大,肉眼基本看不出区别。特别留意 UI Canvas 的适配模式。如果 UI 使用的是ONLY_FIT_HEIGHT模式,截出来给不同宽高比设备看,两边的 UI 可能被裁掉。保存分享图时,我一般会用自定义Fit方式,把核心信息放在安全区域内,不依赖屏幕边缘。5.4 多个相机的叠加画面怎么截游戏里有背景相机、UI 相机、特效相机时,单一相机截图只能拿到它自己渲染的内容,画面合并不了。这时候需要建立一个隐藏的“截图专用相机组”,让这些相机的渲染结果都输出到同一个 RenderTexture 上。Cocos 里多相机合并的一般做法是设置相机的clearFlags,第一个相机清除颜色和深度,后面的相机设置合适的清除标志和渲染优先级。这个方案相对复杂,但比“把多个相机画面各截一张再 PS 拼接”要可靠多了。如果你的游戏画面本来就只有一个主相机,别主动去扩展这一层,维护成本会增加不少。5.5 上线前的合规自查涉及相册保存的功能,上架前一定要做一轮隐私与权限自查。Android 和 iOS 商店对敏感权限的说明越来越严格。在应用描述和隐私政策里,要清楚写明“保存图片到相册”的用途,并且不能在用户拒绝权限后仍然反复弹窗骚扰。另外,分享图里如果出现其他玩家头像、昵称、聊天内容,要考虑隐私保护。很多游戏把头像和昵称拼进截图后,被第三方截图工具抓取传播,引发纠纷。开发阶段就要确认:截图内容是否包含用户隐私、是否经过授权、是否提供撤回通道。5.6 调试阶段的小技巧原生平台调试相册保存,最怕的是“保存成功但相册看不到”这类问题。我的习惯是在工具类里留一个测试开关,打开后不仅保存到相册,同时把同一个像素数据保存到游戏日志目录。真机连上数据线,直接用文件管理器查看应用专用目录里的文件,能确认到底是写入失败还是数据库刷新失败。Web 调试时可以打印生成的 Blob 大小,如果都是同一个固定值,就要怀疑像素数据是否真的在变,可能读的是同一块缓存。换一个动态场景、动一下摄像机,再对比图片字节数,能很快判断缓存是否失效。最后要记住的是:截图成功只是第一步,保存到相册后被用户看到,才是功能的真正闭环。我在实际项目里踩过最深的坑,是只盯着“代码跑通”,忽略了不同系统相册的刷新延迟和权限交互。后来我把“保存→刷新媒体库→回调→UI 提示”做成一条完整的可观测链路,每个环节都有状态反馈,问题才彻底压下去。如果你正卡在某个平台上,建议先把链路拆开,逐段验证,比整体调试要快得多。
做一个有“分享图”功能的游戏,策划经常一句话丢过来:截个图存到手机相册呗。刚开始觉得挺简单,真正上手才发现坑不少,黑屏、颠倒、权限弹窗、相册刷不出来,全踩过一遍才算把这条链路理顺。这篇把 Cocos Creator 里“显示截图并保存图片到手机”的完整思路、核心代码、平台差异和排查经验整理出来,希望对正在做类似功能的同学有直接帮助,特别适合已经会基本场景搭建、准备做社交裂变或成就分享功能的小游戏开发者和工具类 App 开发者。1. 需求拆解与方案选型1.1 这个功能到底要解决什么表面上看是“截图 + 保存”,做进去才知道,用户真正关心的是三件事:第一,截图里的内容要跟当前画面一致,不能黑屏、不能花屏;第二,图片要能进系统相册,微信小游戏里要能直接保存;第三,整个过程不能卡住主线程,游戏不能因此顿一下。很多时候我们只顾着把像素读出来,忽略后两条,结果测版本时被测试同学反复提单。从使用场景看,主流需求可以分成四类:社交分享图,通常是激励用户分享到群或朋友圈;排行榜炫耀图,把分数、角色形象拼进一张底图;问题反馈,玩家遇到 Bug 时需要带图上报;还有工具类产品的凭证保存,比如票据、二维码、报表结果。这四类场景对清晰度、文件格式、保存位置的要求不太一样,但底层技术链路是共通的。1.2 三条技术路线对比Cocos Creator 里实现截图保存,主流做法无外乎三条路线:路线实现成本画面还原度平台覆盖缺点原生 Canvas 绘制低差,只能画 UI,无法捕获 3D 相机全部 Web 环境无法用于包含模型、粒子、场景相机的截图Cocos 渲染纹理读取像素中好,能完整捕获相机画面全部 Cocos 支持平台需要处理坐标翻转和像素格式原生平台桥接截图高最真实,直接截系统屏幕Android/iOS 原生需要写原生代码,屏幕外 UI 会被截掉,成本高很多新手第一个想到的是 Canvas 截图,因为浏览器里就是这么干的。但在 Cocos 场景里,Canvas 截图只能拿到 UI 层之外的稀疏内容,场景相机里的 3D 模型、粒子特效根本不在其中。所以我的建议很直接:不要用 Canvas,直接用 Cocos 渲染纹理,把相机画到一张纹理上,再用 GPU 回读像素,这是和引擎渲染管线最契合的方式。1.3 为什么选渲染纹理方案渲染纹理方案最大的优势是“所见即所得”。你不需要关心屏幕分辨率、DPR、引擎窗口大小这些细节,直接把目标相机渲染到一张同等尺寸的纹理上,然后从纹理里把像素数据拷出来,再做图片编码。整个过程都在 Cocos 的渲染框架里完成,Web、Android、iOS、小游戏这些平台都能用同一套 TS API 走通,不会出现“换个平台代码重写”的情况。选这条路也有代价,代价就是要理解几个底层概念:RenderTexture 是什么、readPixels 的方向、像素数据的排列方式。这些概念对只用过 UI 的开发者来说有点陌生,但一旦搞懂了,截图只是几行代码的事。2. 核心原理:Cocos 里的一张截图是怎么产生的2.1 从渲染管线说起要明白截图,先要明白画面是怎么出来的。Cocos 的渲染流程大致是:场景里的每个相机经过裁剪、坐标变换、光栅化,把画好的颜色写入到一块帧缓冲区(Framebuffer)里,最终这块缓冲区的内容被显示到屏幕窗口。平时我们看到的屏幕,其实是默认相机写入的这块缓冲区。截图就是在“写入缓冲区”这个环节做一次副本。Cocos 里提供了一种对象叫 RenderTexture,本质上就是一块可以离屏渲染的“虚拟画板”。你把相机绑定到这块画板上,相机就不会把画面画到默认的屏幕缓冲区,而是画到这块画板里。这时候屏幕上的内容可能会消失,所以正确做法是:要么复制一个相机专门渲染到画板,要么用一个额外的相机在隐藏层渲染。2.2 RenderTexture 扮演的角色RenderTexture 可以理解为游戏世界的“第二摄像头记录设备”。普通相机直接输出到屏幕,RenderTexture 相机输出到一块纹理。这块纹理不会出现在屏幕上,但可以在代码里读取和复用。在 Cocos Creator 3.x 里,创建一个用于截图的 RenderTexture 需要指定尺寸。最稳妥的尺寸是view.getVisibleSizeInPixel(),因为这个尺寸分析和实际屏幕像素一致。然后新建一个相机组件,指定它的targetTexture指向这张 RenderTexture,再把要截的内容(主相机视角或专用拍摄视角)摆到这个相机面前。有一个细节容易被忽略:Camera 的visibility和cullingMask要设置正确。如果截图相机的剔除层没有包含场景物体,画面就是空的。很多“截出来是黑屏”的案例,不是代码错了,而是相机什么都没拍到。2.3 GPU 像素回读与坐标翻转相机画到 RenderTexture 后,纹理里的数据还在 GPU 上,TS 层需要调用读取接口把像素从 GPU 拷到 CPU 内存里。Cocos 提供了readPixels一类方法,把纹理数据拷贝到一个ArrayBufferView中。这一步是整条链路里最容易出问题的地方。第一个问题是坐标方向。底层图形 API 的像素原点通常在左下角,而图像文件(PNG/JPG)的原点在左上角。如果你直接把读出来的数据编码成图片,保存到相册后会发现上下颠倒。解决办法是在回读时从最后一行开始逐行拷贝,把行顺序反一下。第二个问题是像素格式。Cocos 的 RenderTexture 默认可能是 RGBA,每个像素四个字节,排列顺序是 R、G、B、A。如果你读出来之后直接当作 RGB 三字节数据去编码,颜色会完全错乱。处理时要么保持 RGBA 格式,要么在做格式转换时明确告诉编码器数据里包含 Alpha 通道。2.4 从像素数据到图片文件拿到一行行像素之后,还需要一个编码过程。Web 环境里可以把数据转成Blob,再生成 Object URL 下载;原生环境里建议做一个通用的编码封装,或者直接传给原生层,用各平台自带的图片编码 API。Cocos 3.x 中比较省力的做法是先把像素数据构造成ImageAsset,再转成Texture2D,就能作为 Sprite 显示在 UI 上。“显示截图”这个需求,本质就是把这步直接摆在屏幕上,给用户一个预览反馈。保存到相册时,再把ImageAsset对应的数据流交给平台层去落盘。3. 实操实现:截图并保存到相册3.1 环境准备与版本约定下面示例基于 Cocos Creator 3.x 写 TypeScript 代码。3.0 之后的 API 比 2.x 稳定很多,RenderTexture 的使用方式也更规范。如果你的项目还在用 2.x,API 名称略有不同,但核心思路一样。创建一个独立摄像机,专门用于截图:import { _decorator, Camera, Component, RenderTexture, Sprite, SpriteFrame, Texture2D, view } from 'cc'; const { ccclass, property } = _decorator;3.2 截图工具类的核心代码我先放一份可直接抄的完整工具类,再逐步解释每一块在做什么:@ccclass('CaptureTool') export class CaptureTool { // 创建一张和屏幕像素尺寸一致的渲染纹理 public static createRenderTexture(): RenderTexture { const size = view.getVisibleSizeInPixel(); const rt = new RenderTexture(); rt.reset({ width: size.width, height: size.height }); return rt; } // 将相机画面渲染到纹理上 public static captureCameraToTexture(targetCamera: Camera, rt: RenderTexture): void { targetCamera.targetTexture = rt; } // 从纹理中读取像素,翻转后输出为 ImageAsset public static textureToImageAsset(rt: RenderTexture): ImageAsset { const width = rt.width; const height = rt.height; // 4 字节为 RGBA const buffer = new Uint8Array(width * height * 4); const gfxTexture = rt.GFXPixelBuffer; // 实际工程中可用 readPixels 系列接口把像素读入 buffer rt.readPixels(0, 0, width, height, buffer); // 坐标翻转:因为底层原点在左下,图像文件原点在左上 const flipped = new Uint8Array(width * height * 4); const rowBytes = width * 4; for (let y = 0; y < height; y++) { const srcRow = (height - 1 - y) * rowBytes; const dstRow = y * rowBytes; flipped.set(buffer.subarray(srcRow, srcRow + rowBytes), dstRow); } const imageAsset = new ImageAsset({ width: width, height: height, format: PixelFormat.RGBA8888, data: flipped }); return imageAsset; } }这里readPixels是示意接口,不同版本调用名可能稍有差别。关键是理解三个步骤:分配固定大小的 Uint8Array、读取、翻转行序。3.3 将图片保存到手机相册TS 层拿到ImageAsset后,真正的落盘任务是原生层的事。Cocos 本身不提供“保存到相册”的通用按钮,需要自己写原生扩展或者引入第三方封装。整体流程是这样:TS 层把 ImageAsset 编码成 PNG 或 JPG 的字节流。PNG 适合有透明区域的游戏画面,JPG 适合照片类内容,文件更小但有损。将字节流转成 Base64 字符串或写入临时文件路径。调用原生桥接方法,把数据传给 Android/iOS 层。原生层把数据写入相册,并返回成功/失败回调。Web 端最简单,直接利用浏览器下载能力:// 把 Uint8Array 保存为本地文件(Web 环境) function saveUint8ArrayToBrowser(data: Uint8Array, fileName: string) { const blob = new Blob([data], { type: 'image/png' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; a.click(); URL.revokeObjectURL(url); }小游戏平台的保存方式又不一样,许多小游戏环境要求使用平台提供的相册接口,并且必须在真实用户点击事件触发的回调里调用,否则会被拦截。弹权限和保存这两步,一定要放到手指点击的同一帧调用链里,提前写好或异步延迟调用都可能被平台判定为违规。3.4 界面显示截图预览“显示截图”和“保存图片”通常是两件事。玩家点“分享”后,先弹出一个预览框,里面显示刚截好的图片,再放一个“保存到相册”按钮。这个交互能极大降低误操作率,也符合用户对一款成熟游戏的预期。预览实现也不复杂,把刚才的 ImageAsset 转成 SpriteFrame,再赋给一个 Sprite:import { SpriteFrame, Texture2D } from 'cc'; // 把 ImageAsset 转成 SpriteFrame 供 UI 显示 const texture = new Texture2D(); texture.image = imageAsset; const spriteFrame = new SpriteFrame(); spriteFrame.texture = texture; // 假设 previewSprite 是场景里的一个 Sprite 组件 previewSprite.spriteFrame = spriteFrame;这里需要注意:Texture2D 的尺寸和 ImageAsset 尺寸必须一致,否则显示会拉伸或裁切。建议在spriteFrame上设置好sizeMode,预览框用固定宽高加适配,避免不同分辨率机器上显示变形。3.5 推荐的最小调用流程在实际项目中,我通常把流程封装成一步搞定:async function captureAndSave(saveToAlbum: boolean): Promise<void> { // 1. 创建渲染纹理 const rt = CaptureTool.createRenderTexture(); // 2. 把要截图的相机绑定到纹理上,等待一帧渲染完成 captureCameraToTexture(uiCamera, rt); // 留意:绑定后需要等待相机渲染完成 // 3. 读取像素并生成 ImageAsset const asset = CaptureTool.textureToImageAsset(rt); // 4. 显示预览 showPreview(asset); // 5. 如果需要保存相册,再走保存通道 if (saveToAlbum) { await saveImageAssetToAlbum(asset); } }等待相机渲染完成这一步,很多项目会忽略。解决方案是注册相机的CameraAfterDraw事件,在事件回调里再执行读取,确保纹理已经写入。4. 常见问题与排查实录4.1 截出来是黑屏或空白黑屏十有八九是相机或者物体出问题。排除顺序我一般这样走:第一,目标相机挂没挂。挂targetTexture之后,这个相机就不会往最终屏幕输出,如果项目里只有这一个相机,整个游戏画面都会消失。第二,相机的cullingMask和截图物体的layer是否匹配。很多美术资源挂在默认层,截图相机却只渲染 UI 层,自然什么都拍不到。第三,模型和灯光是否在相机视野内。相机位置、朝向、远近裁切距离都可能把物体剔除掉。还有一个隐藏点:场景没有活动灯光,或模型用了无光照材质的单色状态,拍出来看起来像一块黑布。这种不是代码错误,是画面内容本身就没有有效信息。4.2 图片上下颠倒这个问题在 Web 上很少暴露,原生平台更容易遇到。根源是纹理在显存里的行序跟图片编码时的行序相反。我见过不少人在网上抄到代码,发现相册里图片是倒的,就直接在图片编码层二次翻转,结果转到 Web 平台又变正了,其实是在两个平台修同一套数据的行序,轮番出错。最干净的做法是在读取像素后,统一按“读一行、翻一行”的方式标准化成图像坐标。代码示例里已经写了flipped那段逻辑,不要再在保存层旋转,应该保证“进入编码器之前数据永远是图像坐标的正序”。4.3 相册里迟迟刷不出来新图片Android 上保存成功后,相册 App 并不会立刻看到新图片,因为系统媒体库没有收到通知。有些机型上你把文件写到相册目录,但是没触发媒体扫描,图库里就是看不到,看起来像是保存失败。解决办法是保存完成之后发送一次媒体扫描广播。Android 原生代码里可以对刚保存的文件路径做一次媒体库刷新,iOS 的 Photos 框架会自动处理,通常不需要额外操作。在调试阶段,先确认文件是否真实存在,再确认相册是否刷新,能帮你快速定位是写入失败还是扫描延迟。4.4 Android 10+ 的存储权限问题新版 Android 对公共存储目录的管理越来越严格,直接把 PNG 写到随意路径很可能失败。标准做法是写进系统相册目录或应用专属目录,再通过媒体库注册。如果你用的是 Cocos 官方或第三方封装,一定要确认封装版本是否适配了 Android 10 以上的分区存储。否则你就会遇到一种怪现象:系统返回“保存成功”,文件也确实存在于应用沙盒里,但相册、文件管理器都看不到。处理要点是不要手动申请读写整个外部存储的权限。修改成使用对应新版本平台的媒体库接口,才是干净做法。小游戏平台更特殊,平台内部帮开发者管理了文件系统,你只需要调用它的相册保存接口。4.5 权限弹窗和用户交互时序iOS 上如果没有相册写入权限,首次保存会弹窗;弹窗出现的时候,你的游戏如果处于后台或渲染挂起状态,很容易出现回调丢失。最稳妥的处理是提前在设置的引导页做一次权限预申请,或者在用户第一次点“保存”时明确告知会弹权限框,避免“点了没反应”的误解。不少平台的 API 文档写明“保存按钮必须在用户点击回调里同步调用”。意思是不要在点击之后先发网络请求,等响应回来再保存;也不要保存前先setTimeout延时 1 秒。应该把保存动作放到点击事件的处理链路中,尽快触发系统接口。4.6 常见问题速查表现象可能原因优先排查方式全黑图截图相机没有捕获目标层检查挂载相机的 cullingMask半透明区域变黑块RGBA 被误当 RGB 编码检查像素格式和解码格式是否一致图片上下颠倒原点行序未翻转在像素读取层翻转行序,不要改成旋转保存成功但相册没有未触发媒体库扫描检查原生层是否发送媒体刷新广播权限弹窗后回调丢失生命周期切换导致回调未恢复用全局回调管理器,绑定生命周期节点小游戏点击无反应未在用户手势回调内调用保存调整调用链,让保存接口同步被触发这张表建议直接随项目文档留在工程里,之后再有人问截图问题,先对表再查代码,效率高很多。5. 性能、边界与上线避坑5.1 截图应该是“用户主动触发”,不是每帧执行很多人觉得截图就是把渲染纹理的数据拷出来,开销可以接受。但要知道,ReadPixels 是一个非常重的同步操作,它让 GPU 停止手头工作,等待像素数据回传到 CPU。如果你在做自动截图录屏类功能,比如每 100 帧截一张,游戏会明显掉帧,甚至瞬间卡顿。更合理的做法是只在 UI 操作后单帧截图。保存图片后在短时间内给玩家一个“处理中”的状态提示,避免连续点击导致多个截图任务并发。我在一个小游戏项目里做过限制:一个按钮冷却期内只允许一次截图请求,连续点击直接忽略,很大程度上保护了运行时性能。5.2 RenderTexture 和像素缓冲的复用反复创建和销毁 RenderTexture,会造成内存碎片和 GC 压力。每个 RenderTexture 都对应一块 GPU 显存,大尺寸纹理更明显。更好的策略是维护一个截图专用池子,按屏幕尺寸创建一张,使用完成后不要销毁,直接留着下次复用。像素缓冲的 Uint8Array 同理。如果每张 1080p 截图是 1920×1080×4,差不多 8MB 内存。频繁分配会触发 GC,用户会感觉到轻微的周期性卡顿。推荐的做法是在工具类里缓存一个大小足够的 ArrayBuffer,重复使用,只有屏幕分辨率真的变化时才重新分配。5.3 超大纹理的分辨率上限一些折叠屏、平板的逻辑分辨率很高,截 4K 尺寸图理论上可行,但低端机 GPU 可能撑不住。建议做一个“截图分辨率的软上限”,比如限制最长边不超过 2560 像素。超出后对 RenderTexture 做等比缩放,先截小图再放大,肉眼基本看不出区别。特别留意 UI Canvas 的适配模式。如果 UI 使用的是ONLY_FIT_HEIGHT模式,截出来给不同宽高比设备看,两边的 UI 可能被裁掉。保存分享图时,我一般会用自定义Fit方式,把核心信息放在安全区域内,不依赖屏幕边缘。5.4 多个相机的叠加画面怎么截游戏里有背景相机、UI 相机、特效相机时,单一相机截图只能拿到它自己渲染的内容,画面合并不了。这时候需要建立一个隐藏的“截图专用相机组”,让这些相机的渲染结果都输出到同一个 RenderTexture 上。Cocos 里多相机合并的一般做法是设置相机的clearFlags,第一个相机清除颜色和深度,后面的相机设置合适的清除标志和渲染优先级。这个方案相对复杂,但比“把多个相机画面各截一张再 PS 拼接”要可靠多了。如果你的游戏画面本来就只有一个主相机,别主动去扩展这一层,维护成本会增加不少。5.5 上线前的合规自查涉及相册保存的功能,上架前一定要做一轮隐私与权限自查。Android 和 iOS 商店对敏感权限的说明越来越严格。在应用描述和隐私政策里,要清楚写明“保存图片到相册”的用途,并且不能在用户拒绝权限后仍然反复弹窗骚扰。另外,分享图里如果出现其他玩家头像、昵称、聊天内容,要考虑隐私保护。很多游戏把头像和昵称拼进截图后,被第三方截图工具抓取传播,引发纠纷。开发阶段就要确认:截图内容是否包含用户隐私、是否经过授权、是否提供撤回通道。5.6 调试阶段的小技巧原生平台调试相册保存,最怕的是“保存成功但相册看不到”这类问题。我的习惯是在工具类里留一个测试开关,打开后不仅保存到相册,同时把同一个像素数据保存到游戏日志目录。真机连上数据线,直接用文件管理器查看应用专用目录里的文件,能确认到底是写入失败还是数据库刷新失败。Web 调试时可以打印生成的 Blob 大小,如果都是同一个固定值,就要怀疑像素数据是否真的在变,可能读的是同一块缓存。换一个动态场景、动一下摄像机,再对比图片字节数,能很快判断缓存是否失效。最后要记住的是:截图成功只是第一步,保存到相册后被用户看到,才是功能的真正闭环。我在实际项目里踩过最深的坑,是只盯着“代码跑通”,忽略了不同系统相册的刷新延迟和权限交互。后来我把“保存→刷新媒体库→回调→UI 提示”做成一条完整的可观测链路,每个环节都有状态反馈,问题才彻底压下去。如果你正卡在某个平台上,建议先把链路拆开,逐段验证,比整体调试要快得多。