
需求一句话用户在游戏里点“分享”我们把某个节点渲染成一张图保存到相册或者上传到服务器。听起来特别简单但我第一次交付这个功能的时候就被测试打回保存下来的图片整个是倒立的。从 Cocos 的节点截图到最终保存图片中间差了关键一步——翻转 Y 轴。这个问题在 Creator 2.x 里会遇到升级到 3.x 换了一套 API 之后还会遇到而且在浏览器调试和手机真机上表现还可能不一样。如果你也正在做分享图、头像裁剪、成就卡片生成这类功能这篇文章应该能帮你少走大半天弯路。我不打算只丢一个“把节点 scaleY 设为 -1”的偏方那只是其中一条路而且坑很多。我会把倒图产生的底层原因拆开讲清楚再给出几条不同的翻转方案和适用场景最后把我实际项目里稳定跑通的完整截图流程贴出来。涉及坐标、纹理、像素数组这些概念我会尽量用大白话讲明白代码以 Cocos Creator 2.x/3.x 的 TypeScript 风格为主具体 API 名称在不同版本有差异各位对着自己项目微调就行。1. 先搞懂根因节点截图为什么存成了倒图1.1 渲染坐标系和图片文件编码坐标系根本对不上先说结论这不是 Cocos 的 Bug而是渲染管线和图片编码器之间缺少了一次“翻面”。我们调用节点截图本质上发生的是这么一条数据流节点被渲染进一张 RenderTexture这张纹理在 GPU 里是一块显存然后引擎把纹理的像素数据读回到 CPU 内存也就是一个 Uint8Array 之类的字节数组最后这个字节数组交给图片编码器写成 PNG 或 JPG 文件。问题就出在第二步到第三步的衔接上。WebGL / OpenGL 这类图形 API 的默认纹理坐标系原点在左下角也就是纹理的第一行数据对应的是画面的最底部。而 PNG、JPG 这类图片格式在编码时文件里的第一行像素代表的是画面的最顶部从上往下逐行扫描。GPU 侧觉得“第一行是下边”图片编码器觉得“第一行是上边”两边都没错但中间没有人做 Y 轴翻转最后保存出来的图自然就是上下颠倒的。1.2 为什么在游戏屏幕上看着正常存出来就“倒头睡”你可能会问那屏幕上为什么一切正常因为引擎在最终上屏的时候会有一套完整的视口变换和投影矩阵来处理这种差异UI 显示层面对玩家是透明的。可当我们直接抓取 RenderTexture 里的裸像素数据去编码图片时绕过了引擎那套用于上屏的变换坐标系打架的问题就暴露出来了。生活里有个很好懂的类比假设货架上的商品从地板往上编号为 1、2、3、4但快递单必须从最上面一栏往下填。你把编号 1 的商品名字填在快递单第一栏结果就是最底下的商品出现在单子最顶上——所有东西都上下颠倒了。1.3 “翻转 Y 轴”具体翻的是什么很多人一看到“翻转 Y 轴”就想到把节点的缩放改成 (1, -1)这确实是一种物理层面的翻转。但更本质的做法是在拿到像素数组之后把数组的每一行按照“第一行和最后一行互换、第二行和倒数第二行互换”的规则重新排列。前者是改渲染输入后者是改输出数据。两条路都能解决倒图但适用场景和副作用差别很大这也是下一章要展开讲的核心。2. 三条翻转路线scale、逐行像素翻转和引擎内置 flipY2.1 最简单的做法渲染前把节点 scaleY 设为 -1先说最容易上手、也最容易理解的一条路在把目标节点渲染进 RenderTexture 之前临时把节点的 scaleY 改成 -1渲染完再恢复成 1。以 Creator 里常见的写法为例思路大概是这样的private captureNode(node: Node) { node.setScale(1, -1, 1); // 这里执行 RenderTexture 渲染 // renderTexture.render(node, camera); node.setScale(1, 1, 1); }这样做的好处是改动量极小不需要碰任何像素数据对渲染流程没有侵入。但它有一个非常致命的副作用scaleY 为 -1 的时候节点里所有内容都会被垂直镜像。中文字、英文字母、图标、UI 纹理里的方向性符号全部会倒过来。如果你的截图里包含用户昵称、功能介绍文字、按钮标识这类信息这种方法会让最终图片里的文字变成倒着的等于从“图片倒立”变成了“图片内容倒立”根本没法交付。所以这个方法只适合纯纹理、纯图形、不含可读文字的简单节点。真要这么干还有个小细节最好是在一个临时父节点上做 scale而不是直接改业务节点自身的 scale不然触发到子节点的 Layout 重排或者 Tween 动画恢复的时候容易出幺蛾子。2.2 最通用的做法拿到像素数组后逐行对调既然 scale 方案会影响内容可读性那更稳的做法就是不动渲染只修数据。思路是把读到的 RGBA 像素数组按行前后互换。下面是一个可以直接抄的 TypeScript 函数function flipImageY(pixels: Uint8Array, width: number, height: number) { const bytesPerRow width * 4; // RGBA每个像素 4 字节 const tempRow new Uint8Array(bytesPerRow); for (let y 0; y Math.floor(height / 2); y) { const topStart y * bytesPerRow; const bottomStart (height - 1 - y) * bytesPerRow; // 保存顶部一行 tempRow.set(pixels.subarray(topStart, topStart bytesPerRow)); // 底部行搬到顶部 pixels.copyWithin(topStart, bottomStart, bottomStart bytesPerRow); // 原顶部行搬到底部 pixels.set(tempRow, bottomStart); } }这个函数的逻辑和“把一摞纸上下翻转后重新整理”一样第 0 行和第 height - 1 行互换第 1 行和倒数第 2 行互换……只换一半因为换完一半之后后半行也已经归位了。性能方面完全不用担心。对一张 2048 x 2048 的图height 是 2048实际循环只需要做 1024 次行互换每次操作一行 8KB 的数据在移动端耗时也就是几十毫秒的量级而且是一次性开销不会影响游戏帧率。这个方法最稳的地方在于它对渲染内容完全无感知。管你节点里是文字、粒子、Mesh、还是 Mask只要像素数组被正确翻转最终图片一定是正的。我目前绝大多数项目都用这条路线。2.3 最省事的一种查你的引擎版本有没有内置 flipY在部分 Cocos Creator 3.x 版本里RenderTexture 或者相关纹理创建接口提供了 flipY 相关的选项。原理是让引擎在把纹理数据从 GPU 侧交到 CPU 侧之前先自动完成一次 Y 轴翻转。如果版本支持代码里可能就是一行开关的事省掉手动翻转函数。但这里我必须提醒一句这个 API 在不同小版本里的名字和可用性差异比较大有的版本放在 RenderTexture 上有的放在纹理描述符里2.x 版本则基本没有开放。我不会给你写一个可能和你的项目版本对不上的 API 名。最靠谱的做法是打开你当前引擎版本的 API 文档搜索 RenderTexture 相关接口看有没有类似 flipY 的属性或构造参数如果没有就回到 2.2 的手动翻转方案。另外即使引擎提供了 flipY也要确认它翻转的到底是哪一段数据流。有的开关只影响采样时的 UV 坐标并不会改变最终输出到图片文件的像素顺序这种开关对保存图片是无效的。所以引入了开关之后一定要用包含文字内容的节点做一次正反验证。2.4 三条路线怎么选一张对照表我把三条路线的特性整理成表格方便你按项目情况快速选择方案实现成本是否会镜像文字/内容性能开销适用场景scaleY 设为 -1最低改一行会文字内容全部倒立无额外开销纯图形、纯纹理的简单节点像素数组逐行翻转中等写一个函数不会内容保持原样几十毫秒/2048图任何场景推荐通用方案引擎内置 flipY最低版本支持时不会无额外开销3.x 高版本且有该开关我的建议是别把第一条路当默认方案它太容易坑到自己。第二条路的翻转函数写一次放在工具类里所有项目通用最不挑环境。3. 翻转解决了图还是糊或黑清晰度、透明底和截帧时机3.1 RenderTexture 尺寸要按物理像素算别拿设计分辨率硬顶很多项目分享图发出来之后被吐槽“糊”问题往往不在翻转而在 RenderTexture 的尺寸设置。如果你在设计分辨率 720 x 1280 下做 UI然后直接创建一个 720 x 1280 的 RenderTexture在高分屏手机上保存出来的图片分辨率就偏低放大会模糊。正确的做法是考虑设备像素比devicePixelRatio简称 DPR。比如 iPhone 的逻辑分辨率是 390 x 844但物理像素是 1170 x 2532DPR 是 3。如果节点实际显示区域是 300 x 400 逻辑像素那 RenderTexture 的宽高至少要设置成 900 x 1200 物理像素截图才会锐利。设置 RT 尺寸时可以用节点内容尺寸乘以 DPRconst scaleFactor view.getScaleX(); // 在某些版本可以用 view.getDevicePixelRatio() const rtWidth Math.floor(nodeSize.width * scaleFactor); const rtHeight Math.floor(nodeSize.height * scaleFactor);这里要特别留意不同版本获取 DPR 的方式不一样有的用view.getDevicePixelRatio()有的用view.getScaleX()你得对着当前版本的实际返回值测试一下。宁可多设置一些像素也不要让截图比预期小。3.2 透明通道丢了就是黑底RGBA8888 和 clearFlags做分享图片尤其是海报、头像框这类需要异形显示的内容透明背景是刚需。但很多人保存 PNG 出来发现背景是黑的或者透明的地方变成了一块块的杂色排查半天发现不是翻转问题而是纹理格式和清屏色没有设置对。首先要确保 RenderTexture 的颜色格式是带 alpha 通道的格式最好是 RGBA8888。如果底层选择了不带透明度的格式保存成 PNG 时透明信息根本不存在黑底就会出现。其次要检查渲染前的清屏行为。用 RenderTexture 渲染节点时如果 clearFlags 设置不对背景会被默认清成不透明的颜色。设置成清澈色即可renderTexture.clearFlags RenderClearFlag.COLOR; renderTexture.clearColor new Color(0, 0, 0, 0);这里我再补一个实际经验不要用 JPG 格式保存带透明的截图。JPG 本质上不支持透明通道保存时引擎或平台库会把 alpha 强行压掉背景一定会变成黑底或白底。要做透明背景分享图必须用 PNG 格式。3.3 截帧时机动画没走完截出来的图就缺胳膊少腿这个问题很隐蔽。假如用户点了一下“生成海报”按钮这时你才把海报节点的某些子节点内容更新完比如设置了一个新的 Sprite 图片、把某个进度条动画播放开紧接着在同一帧回调里立刻执行渲染和读取像素截出来的往往是旧内容或者是半透明过渡状态的画面。原因是节点虽然更新了数据但那一帧的渲染命令还没有被送到 GPU 执行RenderTexture 里还是上一帧的像素内容。粒子、拖尾这一类需要多帧累计的视觉效果就更明显第一帧根本来不及生成历史顶点数据。常见解决办法是把截图动作延后一帧或几帧this.scheduleOnce(() { // 渲染 RenderTexture 并保存图片 }, 0);也可以监听导演的绘制结束事件比如Director.EVENT_AFTER_DRAW之后再做截取。我的经验是海报里如果有粒子或拖尾动画等 2 到 3 帧再截效果更稳定。4. 节点截图实战里躲不掉的其它坑合图、Mask、拖尾和跨端4.1 目标节点还在动态合图里图会被“抠坏”或错位遇到一个怪现象单独截某个小图标节点保存出来的图片不是图标本身而是附近某个图标的一部分甚至图像完全错乱。十有八九是动态合图Dynamic Atlas在作怪。Cocos Creator 为了减少 draw call会把小图片在运行期动态合并进一张大纹理。如果你只是截取其中一个子节点的视觉内容渲染时读取到的可能不是原图中的独立区域而是合图里的某个小方块Y 方向翻转之后更容易和别的区域串位。处理方法一般有两种要么在截图前把目标节点的纹理帧临时替换成非合图的独立纹理要么在项目设置里对相关资源关闭动态合图。具体开关名称在不同版本里不一样2.x 和 3.x 的项目设置里都能找到建议打包之前先确认截图里有没有涉及这类小图标。4.2 Mask 遮罩和 MotionStreak 粒子节点首帧残缺如果你的截图目标节点挂着 Mask 组件比如圆角头像框、圆形小地图直接用 RenderTexture 渲染这个节点时可能出现遮罩范围外部的内容也被画出来或者遮罩区域整个空掉的情况。这是因为 Mask 依赖模板缓冲区stencil bufferRenderTexture 单独渲染时模板缓冲的处理逻辑和正常 Canvas 渲染并不完全一致。我的建议是不要试图在一个带 Mask 的复杂节点上直接做完美截图。先把要展示的内容平铺到一个临时节点树里让截图节点保持简单的渲染层级再在截图生成后用代码做一次矩形/圆形裁剪或者直接把整个界面截下来再做二次处理。这样绕开模板缓冲的不可控性稳定得多。MotionStreak 和粒子系统是另一个容易缺内容的点。拖尾需要前几帧的历史位置信息粒子系统第一帧往往还没发射出足够的粒子。如果你在节点激活后的第一帧就截图拖尾和粒子当然只有一点点。我踩过这个坑之后习惯在截图前手动让效果先跑两帧for (let i 0; i 3; i) { director.tick(); // 示意实际要看项目里如何手动推进帧 }如果项目不允许手动 tick也可以等待真实帧数过去再执行截图。4.3 浏览器调试正常、打包 APK 就翻车真机才是最终标准分享图功能做调试时很多人习惯在浏览器里预览用 Spector.js 这类 WebGL 调试工具看看纹理对不对。这些工具对游戏开发确实很有帮助能直观看到 RenderTexture 在 GPU 里的样子、各个 attach 的纹理方向、材质参数等等定位“图片到底是不是从某个环节开始倒的”很有效。但我要特别强调一个容易让人崩溃的事实浏览器里截图正常打包成 APK 到安卓真机上保存图片可能会倒反过来也有可能。不同平台底层的 OpenGL 实现、引擎封装层的像素读取逻辑、甚至图片编码库对行序的处理都可能存在差异。浏览器只是个模拟环境永远不能替代真机验证。所以在发布前务必在安卓和 iOS 真机上各跑一次完整的“截图 - 保存 - 打开相册 - 查看图片方向”流程。我把这个流程称之为“截图自测五连”方向正不正、清晰度够不够、透明背景对不对、内容是否完整、文字是否可读。五连都过了这个功能才真正算完成。5. 我目前稳定在用的完整截图流程5.1 一套可复制的 RenderTexture 截图流程经历了各种花样翻车之后我现在做节点截图基本固定一套流程步骤比较好记计算目标节点的物理像素尺寸创建对应大小的 RenderTexture。把 RenderTexture 的清屏颜色设为全透明确认格式为 RGBA8888。将目标节点渲染进 RenderTexture如有粒子或拖尾则先等待若干帧。读取像素数据得到 Uint8Array 数组。调用前面写的 flipImageY 函数对像素数组做逐行翻转。把处理好的像素数据交给图片编码和保存模块生成 PNG 并写入相册或上传。核心代码框架大致是这样的const view new RenderTexture(); view.reset({ width: rtWidth, height: rtHeight, format: Texture2D.PixelFormat.RGBA8888 }); const spriteFrame new SpriteFrame(); spriteFrame.texture view; // 渲染节点 view.render(targetNode, camera); // 读取像素 const pixels view.readPixels(); // 翻转 Y 轴 flipImageY(pixels, rtWidth, rtHeight); // 编码保存浏览器端可以转 canvas原生端一般走平台插件 saveAsPng(pixels, rtWidth, rtHeight);这里readPixels的返回值格式和接口细节在不同版本里有差异有的还需要自己创建 ArrayBufferView 传入。你以当前版本的 API 为准不要盲抄。5.2 加一个“是否已翻转”开关避免翻两次有个特别尴尬的场景某天你的同事升级了引擎版本RenderTexture 内部已经自动处理了 Y 轴翻转但你保留了旧代码里的手动翻转函数结果所有分享图又变成倒的了——因为翻了两次等于没翻。为避免这种问题我会在截图工具类里放一个布尔开关let flipYEnabled true;第一次在某个平台上跑通后如果确认图片方向正确就把这个开关固定为当前值如果引擎升级或迁移到新平台先打开真机测试看图片方向是否反了再决定是否切换。这个开关看着不起眼但能让你在不同版本、不同平台之间切换时少掉不少头发。5.3 真机自测的路线图最后说一个我自己的做法每次写完截图逻辑我不会先跑去调业务 UI而是先拉一个最简单测试页面页面上只有一张带中文文字的 Sprite 和一块纯色背景然后跑完整的截图保存链路。文字会让“方向是否正常”一目了然——如果图片倒着文字也是倒的如果被镜像文字也镜像。确认这张测试图正了再接入真实业务节点逐项检查粒子、Mask、动态合图这些复杂元素。这套小测试页面特别值得做因为截图功能涉及的变量太多坐标系、纹理格式、像素密度、清屏色、渲染时机、平台差异……任何一个环节出错最终表现可能都是“图片不对”但原因差了十万八千里。先把环境变量压到最少排查起来会轻松非常多。我也见过有人把截图和保存的逻辑全塞在业务页面里出了问题只能靠猜。几次踩坑之后你会发现把截图功能抽成独立的工具类再配一个可复现的最小测试场景是性价比最高的做法。这个习惯帮我省下来的时间比写功能本身的时间多得多。