CocosCreator动态截图与Base64转换实战:从渲染到存储的完整方案
1. 项目概述:为什么我们需要更灵活的图片处理方案?
在CocosCreator游戏开发中,图片处理远不止于UI贴图和精灵帧的静态使用。无论是实现游戏内的分享功能(比如把当前战局截图分享给好友)、保存玩家的个性化创作(例如头像编辑、关卡设计图),还是为了优化网络传输(将图片转为Base64字符串嵌入协议),动态的图片捕捉与转换都是绕不开的进阶课题。很多开发者,尤其是刚接触CocosCreator不久的朋友,可能会觉得截图无非就是调用一下cc.Camera的render,但真到项目里一用,问题就来了:截出来的图尺寸不对、方向反了、在移动端存储权限一堆坑,更别提如何高效地把图片数据转换成能塞进JSON里的Base64字符串了。
我自己在做一个类“麻将”游戏的复盘分享功能时,就深有体会。玩家打完一局,需要把最终的牌桌局面(包含手牌、打出的牌、分数信息等动态UI)生成一张图片,既能保存到本地相册,又能一键分享。这听起来简单,但CocosCreator的渲染到纹理、不同平台的存储路径、Base64编码的性能与内存,每一个环节都有门道。网上资料零散,官方文档又偏原理,缺乏一个从实战出发、串联起整个流程的指南。所以,今天我就结合自己的踩坑经验,把这套“动态截图 -> 本地存储 -> Base64转换”的完整链路给大家拆解清楚,目标是让你看完就能在自己的项目里用起来,并且知道每一步为什么要这么做,以及如何避开那些常见的“坑”。
2. 核心思路与方案选型:从渲染到数据的完整路径
要实现这个功能链,我们首先要理清核心的技术路径。整个过程可以抽象为三个核心阶段:捕获阶段、持久化阶段和编码转换阶段。每个阶段都有多种技术方案,我们的选择直接决定了功能的性能、兼容性和易用性。
2.1 捕获阶段:如何“拍下”游戏画面?
捕获游戏画面,本质上是将当前渲染结果输出为一张图片数据。在CocosCreator中,主要有三种主流思路:
使用
cc.Camera渲染到纹理(RenderTexture):这是最灵活、最强大的方式。你可以创建一个专门用于截图的相机,将其targetTexture设置为一个cc.RenderTexture。这个相机会将其视野内的所有节点(可以是整个Canvas,也可以是某个特定UI层)渲染到这张纹理上。之后,再从RenderTexture中读取像素数据。它的优势在于可以精确控制截图范围(通过调整相机视口和层级掩码),并且能捕获到包括粒子、Shader效果在内的所有渲染内容。缺点是设置稍显复杂,需要注意相机的渲染顺序和清除标志,避免干扰主渲染流程。使用
cc.grabScreenAPI:这是一个更便捷的全局截图方法。它会捕获当前整个游戏窗口的最终呈现画面。使用起来非常简单,通常在渲染一帧后调用即可。但它的局限性也很明显:你只能截取整个屏幕,无法针对特定区域或节点;在某些WebGL后端上可能存在性能问题;并且截取的是最终合成后的画面,可能包含你不想截取的调试信息或其他覆盖层。基于Canvas 2D Context的绘制:如果你的目标主要是UI节点,并且对性能要求极高,可以考虑遍历需要截图的节点树,利用Canvas 2D的
drawImage方法将其逐一绘制到一个离屏Canvas上。这种方法非常精细,可以完全控制绘制过程和输出质量,但实现成本最高,需要处理节点的变换矩阵、透明度、裁剪区域等,对于复杂的渲染树几乎不可行。
方案选择建议:对于绝大多数需要动态、灵活截图的场景(如截取某个特定UI界面、某个角色所在的区域),使用
cc.Camera渲染到RenderTexture是最推荐、最专业的方案。它提供了最好的控制力,并且是后续进行本地存储和Base64转换的通用数据源。本文的实战也将围绕此方案展开。
2.2 持久化阶段:图片存到哪里?怎么存?
获取到图片数据(通常是ImageData或ArrayBuffer)后,我们需要将其保存为图片文件。这里的关键在于平台差异性。
- Web平台:浏览器环境出于安全限制,无法直接写入用户文件系统。通常的作法是触发一个下载(使用
URL.createObjectURL和<a>标签的download属性),或者将图片显示在页面上让用户右键另存为。 - 原生平台(iOS/Android/Windows/Mac):我们可以访问设备的文件系统。这里需要用到CocosCreator提供的
jsb模块(对于CocosCreator 3.x+)或相应的原生扩展能力。核心是使用FileUtils相关的API,将图片数据写入到应用的持久化数据路径(如Application.persistentDataPath)或公共目录(如相册)。这里最大的坑是权限问题,尤其是在Android上,从Android 10(API 29)开始,作用域存储(Scoped Storage)带来了巨大的变化,向公共目录写文件需要动态申请权限或使用MediaStore API。
2.3 编码转换阶段:Base64的得与失
Base64编码是一种将二进制数据(如图片数据)转换成由64个字符(A-Z, a-z, 0-9, +, /)组成的ASCII字符串的方法。它的最大优点是“文本化”,可以轻松地嵌入JSON、XML或URL中,无需处理二进制协议,非常适合网络传输或简单的本地缓存(如localStorage)。
然而,Base64有其明显的代价:
- 体积膨胀:编码后数据体积会增加约33%。
- 编码解码开销:需要额外的CPU计算。
- 内存占用:在JavaScript中,Base64字符串会以UTF-16格式存储,内存占用可能是原始二进制数据的两倍。
因此,Base64转换适用于数据量不大、且必须使用文本格式传输的场景。如果只是本地保存文件,直接存储二进制数据(如PNG格式的字节流)是更高效的选择。我们的实战中会将两种方式都实现,让你了解如何根据需求选择。
3. 实战:构建动态截图管理器
接下来,我们一步步实现一个功能完整的ScreenShotManager。这个管理器将封装截图、保存和转换的所有逻辑。
3.1 第一步:创建截图相机与渲染纹理
我们首先创建一个专门用于截图的节点和相机。
// ScreenShotManager.ts import { _decorator, Component, Node, Camera, renderer, RenderTexture, ImageAsset, dynamicAtlasManager } from 'cc'; const { ccclass, property } = _decorator; @ccclass('ScreenShotManager') export class ScreenShotManager extends Component { // 用于渲染截图的相机 private _captureCamera: Camera | null = null; // 渲染纹理 private _renderTexture: RenderTexture | null = null; onLoad() { this.initCaptureCamera(); } /** * 初始化截图相机和渲染纹理 * @param width 纹理宽度,默认使用设计分辨率宽度 * @param height 纹理高度,默认使用设计分辨率高度 */ initCaptureCamera(width: number = cc.view.getDesignResolutionSize().width, height: number = cc.view.getDesignResolutionSize().height) { // 1. 创建渲染纹理 this._renderTexture = new RenderTexture(); this._renderTexture.reset({ width: width, height: height, }); // 2. 创建相机节点(如果场景中没有,可以动态创建) let cameraNode = new Node('CaptureCamera'); this.node.addChild(cameraNode); this._captureCamera = cameraNode.addComponent(Camera); // 3. 配置相机 this._captureCamera.projection = Camera.ProjectionType.ORTHO; // 使用正交投影,适合2D UI this._captureCamera.orthoHeight = height / 2; // 正交相机高度的一半 this._captureCamera.clearFlags = Camera.ClearFlag.SOLID_COLOR; // 清除为纯色 this._captureCamera.clearColor = cc.color(0, 0, 0, 0); // 透明背景,RGBA(0,0,0,0) this._captureCamera.priority = 100; // 设置一个较高的渲染优先级,确保在其他相机之后渲染 this._captureCamera.targetTexture = this._renderTexture; // 绑定渲染目标 // 重要:设置相机的可见性掩码,决定哪些节点会被渲染 // 例如,你可以设置一个专门的截图层级,避免截到调试信息 // this._captureCamera.visibility = 1 << 你的层级索引; // 4. 调整相机位置和视口,确保覆盖你想要截取的区域 cameraNode.setPosition(width / 2, height / 2, 1000); // 将相机放在能俯瞰整个区域的位置 } }关键点解析:
- 透明背景:
clearColor设置为透明黑色(0,0,0,0),这样截出来的图背景是透明的PNG,方便后期合成。 - 相机优先级:设置较高的
priority,确保它在主相机渲染之后执行,这样能捕获到最终画面。如果需要在中间某一步截图,可以调整此值。 - 可见性掩码(Visibility Mask):这是控制截图内容的精确定位器。你可以为需要截图的节点设置特定的层级(Layer),然后在相机上只显示这个层级。例如,你的UI节点放在第8层,就可以用
this._captureCamera.visibility = 1 << 8;。这样可以完美避免HUD、调试按钮等无关内容入镜。 - 动态图集管理器:如果你的项目使用了CocosCreator的动态合图(Dynamic Atlas),在截图前可能需要临时禁用它,因为合图可能会改变纹理的渲染顺序和区域,导致截图内容错乱。可以在截图前后调用
dynamicAtlasManager.enabled = false/true;。
3.2 第二步:执行截图并获取图像数据
配置好相机后,我们需要在合适的时机触发一次渲染,并从RenderTexture中读取像素数据。
// 在 ScreenShotManager.ts 中继续添加方法 import { ImageAsset, Texture2D, director } from 'cc'; export class ScreenShotManager extends Component { // ... 之前的代码 ... /** * 执行截图 * @param targetNode 可选,指定要截图的根节点,如果为null则截取相机视野内所有可见节点 * @returns 一个Promise,resolve时返回ImageAsset对象 */ capture(targetNode: Node | null = null): Promise<ImageAsset> { return new Promise((resolve, reject) => { // 0. 可选:临时禁用动态合图 const wasAtlasEnabled = dynamicAtlasManager.enabled; dynamicAtlasManager.enabled = false; // 1. 如果指定了目标节点,将其移动到相机节点下(临时改变层级) let originalParent: Node | null = null; let originalSiblingIndex = -1; if (targetNode && targetNode.isValid) { originalParent = targetNode.parent; originalSiblingIndex = targetNode.getSiblingIndex(); // 临时将目标节点挂载到相机节点下,确保其被渲染 // 注意:这可能会短暂影响节点变换,截图后需恢复 // 更优做法是通过相机visibility mask控制,这里演示简单方案 this._captureCamera!.node.addChild(targetNode); } // 2. 安排一帧后执行渲染和读取 director.getScheduler().scheduleOnce(() => { if (!this._captureCamera || !this._renderTexture) { reject(new Error('Capture camera or render texture not initialized.')); return; } // 3. 从渲染纹理创建ImageAsset const imageAsset = new ImageAsset(); // readPixels是同步操作,会从GPU读取数据,可能引起性能卡顿 this._renderTexture!.readPixels((pixels: Uint8Array | null) => { if (pixels) { // 注意:readPixels读取的数据是RGBA格式,且原点在左下角 imageAsset.reset({ _data: pixels, width: this._renderTexture!.width, height: this._renderTexture!.height, format: Texture2D.PixelFormat.RGBA8888, }); resolve(imageAsset); } else { reject(new Error('Failed to read pixels from render texture.')); } // 4. 恢复现场 if (targetNode && originalParent && targetNode.isValid) { originalParent.insertChild(targetNode, originalSiblingIndex); } dynamicAtlasManager.enabled = wasAtlasEnabled; }); }, this, 0); }); } }关键点与避坑指南:
readPixels的性能警告:renderTexture.readPixels()是一个同步调用,它会强制GPU完成所有绘制命令并将帧缓冲区数据读回CPU内存。这个过程称为“GPU回读”,是众所周知的性能瓶颈,可能导致当前帧卡顿。因此,绝对避免在每帧或频繁更新的循环中调用此方法。它只应在用户主动触发(如点击截图按钮)或低频事件中使用。- 数据格式与原点:
readPixels返回的是Uint8Array,每个像素包含R、G、B、A四个通道(0-255)。一个重要细节是,WebGL和许多图形API的纹理原点在左下角,而Canvas 2D和图片显示的原点在左上角。如果你发现截图上下颠倒,很可能是因为这个原因。后续在转换为Base64或Canvas绘制时可能需要翻转Y轴。 - 恢复现场:如果临时改变了节点层级(如示例中简单演示的),务必在截图完成后立即恢复。更健壮的做法是始终通过相机的
visibility掩码来控制渲染内容,避免操作节点树。
3.3 第三步:平台适配的本地存储
获取到ImageAsset后,我们将其保存为图片文件。这里需要区分平台。
// 在 ScreenShotManager.ts 中继续添加方法 import { sys, native, jsb } from 'cc'; export class ScreenShotManager extends Component { // ... 之前的代码 ... /** * 保存ImageAsset到本地文件 * @param imageAsset 图像资源 * @param fileName 文件名(不含后缀) * @returns 一个Promise,resolve时返回保存的文件路径 */ saveToLocal(imageAsset: ImageAsset, fileName: string = `screenshot_${Date.now()}`): Promise<string> { return new Promise((resolve, reject) => { // 1. 先将ImageAsset转换为平台可用的格式(如HTMLImageElement或NativeImage) // 这里以转换为Base64为例,作为中间桥梁。实际上,原生平台有更高效的直接写入方式。 this.imageAssetToBase64(imageAsset).then((base64Data: string) => { const platform = sys.platform; if (platform === sys.Platform.WECHAT_GAME) { // 微信小游戏 this._saveInWeChat(base64Data, fileName, resolve, reject); } else if (platform === sys.Platform.BYTEDANCE_MINI_GAME) { // 字节小游戏 this._saveInByteDance(base64Data, fileName, resolve, reject); } else if (sys.isBrowser) { // Web浏览器 this._saveInBrowser(base64Data, fileName, resolve, reject); } else if (sys.isNative) { // 原生平台 (iOS, Android, Windows, Mac) this._saveInNative(imageAsset, fileName, resolve, reject); } else { reject(new Error(`Unsupported platform: ${platform}`)); } }).catch(reject); }); } private _saveInBrowser(base64Data: string, fileName: string, resolve: (path: string) => void, reject: (error: any) => void) { // 移除Base64头 const dataPart = base64Data.replace(/^data:image\/\w+;base64,/, ''); const binaryString = atob(dataPart); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } const blob = new Blob([bytes], { type: 'image/png' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${fileName}.png`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // 释放内存 resolve(`下载已触发: ${fileName}.png`); } private async _saveInNative(imageAsset: ImageAsset, fileName: string, resolve: (path: string) => void, reject: (error: any) => void) { // 注意:以下代码为原理示意,实际原生API可能随引擎版本变化 // CocosCreator 3.x 推荐使用 jsb.fileUtils 或 fs 模块 try { // 获取应用的持久化数据路径 const writablePath = jsb.fileUtils.getWritablePath(); const fullPath = `${writablePath}${fileName}.png`; // 将ImageAsset的数据写入文件 // 这里需要将 imageAsset._data (Uint8Array) 保存为PNG文件。 // 原生平台通常需要借助扩展或第三方库(如libpng)来编码PNG。 // 一个更简单但低效的方法是:先转成Base64,再解码写入。 // 以下是简化流程: // 假设我们有一个原生桥接方法 `writeImageDataToPNGFile` // 该方法接收图片数据指针、宽、高、文件路径 if (jsb && jsb.writeImageDataToPNGFile) { const success = jsb.writeImageDataToPNGFile( imageAsset._data, imageAsset.width, imageAsset.height, fullPath ); if (success) { // 对于Android,可能需要通知媒体库扫描新文件 if (sys.os === sys.OS.ANDROID) { // 调用Java方法扫描文件,使其出现在相册中 // native.reflection.callStaticMethod(...) } resolve(fullPath); } else { reject(new Error('Native write failed.')); } } else { // 回退方案:通过Base64和jsb.fileUtils写入 const base64 = await this.imageAssetToBase64(imageAsset); const dataPart = base64.replace(/^data:image\/\w+;base64,/, ''); const buffer = this._base64ToArrayBuffer(dataPart); const success = jsb.fileUtils.writeDataToFile(new Uint8Array(buffer), fullPath); if (success) { resolve(fullPath); } else { reject(new Error('Failed to write file via fileUtils.')); } } } catch (error) { reject(error); } } // 微信、字节等小游戏平台保存实现(略,原理类似,调用其提供的写入用户相册API) private _saveInWeChat(base64Data: string, fileName: string, resolve: (path: string) => void, reject: (error: any) => void) { // 调用 wx.saveImageToPhotosAlbum // 注意:需要用户授权 scope.writePhotosAlbum } // 将ImageAsset转换为Base64字符串 private imageAssetToBase64(imageAsset: ImageAsset): Promise<string> { return new Promise((resolve, reject) => { // 创建一个临时的Canvas来转换 const canvas = document.createElement('canvas'); canvas.width = imageAsset.width; canvas.height = imageAsset.height; const ctx = canvas.getContext('2d'); if (!ctx) { reject(new Error('Failed to get 2d context.')); return; } // 创建Image对象 const img = new Image(); img.onload = () => { // 注意:如果截图数据原点在左下角,可能需要先翻转Y轴 // ctx.scale(1, -1); // ctx.drawImage(img, 0, -canvas.height); ctx.drawImage(img, 0, 0); const base64 = canvas.toDataURL('image/png'); // 默认PNG格式 resolve(base64); }; img.onerror = () => reject(new Error('Image load error.')); // 将Uint8Array转换为Blob URL供Image加载 const blob = new Blob([imageAsset._data!], { type: 'image/png' }); img.src = URL.createObjectURL(blob); }); } private _base64ToArrayBuffer(base64: string): ArrayBuffer { const binaryString = atob(base64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes.buffer; } }平台存储的核心难点:
- Web平台:本质是触发下载,无法指定路径。用户体验是浏览器下载弹窗。
- 微信/字节小游戏:必须调用其提供的
saveImageToPhotosAlbumAPI,并且需要用户授权。务必在合适的时机(如用户首次点击保存时)使用wx.authorize申请scope.writePhotosAlbum权限,并做好被拒绝的引导处理。 - 原生平台(重点):
- 路径:使用
jsb.fileUtils.getWritablePath()获取应用可写的持久化目录。这个目录在应用卸载时会被清除。如果希望保存到公共相册,路径和API完全不同(如Android的MediaStore,iOS的Photos Framework)。 - 权限:这是最大的坑!Android 6.0+需要动态申请存储权限(
READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE)。Android 10+,即使有了权限,向公共目录(如DCIM)写文件也受“作用域存储”限制,推荐使用MediaStoreAPI。iOS保存到相册需要NSPhotoLibraryAddUsageDescription权限描述,并使用UIImageWriteToSavedPhotosAlbum。 - 编码:直接将RGBA的
Uint8Array写成.png文件并非易事,因为PNG是一种压缩格式。通常需要集成原生库(如libpng)或调用系统API(如Android的Bitmap.compress)。上述代码中的jsb.writeImageDataToPNGFile是一个假设的桥接方法,实际项目中你需要自己通过原生扩展(Native Extension)来实现,或者寻找社区已有的插件。
- 路径:使用
3.4 第四步:高效的Base64转换与传输
最后,我们实现Base64转换,并探讨其应用场景。
// 在 ScreenShotManager.ts 中继续添加方法 export class ScreenShotManager extends Component { // ... 之前的代码 ... /** * 将ImageAsset直接转换为Base64字符串(跳过Canvas,性能更高) * @param imageAsset 图像资源 * @param format 输出格式,如 'image/png', 'image/jpeg' * @param quality JPEG质量,0-1 * @returns Base64字符串 */ async convertToBase64Direct(imageAsset: ImageAsset, format: string = 'image/png', quality?: number): Promise<string> { // 方法1:通过Canvas(兼容性好,但有一定开销) // return this.imageAssetToBase64(imageAsset); // 方法2:直接编码(性能更好,但需要处理数据) return new Promise((resolve, reject) => { const pixels = imageAsset._data; if (!pixels) { reject(new Error('Image data is empty.')); return; } const width = imageAsset.width; const height = imageAsset.height; // 创建一个离屏Canvas进行编码 const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { reject(new Error('Failed to get 2d context.')); return; } // 将Uint8Array RGBA数据放入ImageData // 注意:ImageData.data要求Uint8ClampedArray const clampedData = new Uint8ClampedArray(pixels); const imageData = new ImageData(clampedData, width, height); // 将ImageData绘制到Canvas上 ctx.putImageData(imageData, 0, 0); // 转换为Data URL (Base64) let dataUrl; if (format === 'image/jpeg') { dataUrl = canvas.toDataURL('image/jpeg', quality || 0.92); } else { dataUrl = canvas.toDataURL('image/png'); } resolve(dataUrl); }); } /** * 一个完整的截图->保存->转换示例流程 */ async captureAndProcess(targetNode: Node | null = null): Promise<{ base64: string; filePath: string }> { try { console.time('截图总耗时'); // 1. 截图 const imageAsset = await this.capture(targetNode); console.timeLog('截图总耗时', '截图完成'); // 2. 并行处理:保存到本地 和 转换为Base64 const savePromise = this.saveToLocal(imageAsset); const base64Promise = this.convertToBase64Direct(imageAsset); const [filePath, base64] = await Promise.all([savePromise, base64Promise]); console.timeLog('截图总耗时', '保存和转换完成'); console.timeEnd('截图总耗时'); // 3. 此时,base64可以用于网络传输(如上传服务器、WebSocket发送) // 注意:base64字符串很长,可以考虑压缩或分片 // const shortBase64 = base64.split(',')[1]; // 去掉头部信息,只留数据部分 // filePath是本地存储路径(原生平台有效) return { base64, filePath }; } catch (error) { console.error('截图处理流程失败:', error); throw error; } } }Base64转换的优化与抉择:
- 性能考量:
canvas.toDataURL()是同步操作,对于大图(如1080p)可能耗时几十到几百毫秒,会阻塞主线程。如果对流畅度要求极高,可以考虑使用Web Worker在后台线程进行编码,或者使用更高效的库(如libpng的Wasm版本)。 - 内存与垃圾回收:
ImageAsset、ImageData、Canvas、Base64字符串都是内存大户。在移动端,频繁进行大图截图和Base64转换极易引发内存峰值,导致卡顿甚至崩溃。务必在操作完成后及时释放引用(如将临时变量置为null),并避免在短时间内连续操作。 - 应用场景:
- 即时预览:将Base64字符串直接赋值给
cc.Sprite的spriteFrame,可以立即在游戏中显示截图效果。 - 网络传输:将Base64字符串作为JSON的一个字段发送给服务器。但如前所述,体积会增大。对于实时性要求高的场景(如“麻将”游戏的实时分享),可以先在客户端进行有损压缩(如转换为JPEG并降低质量),再编码为Base64。
- 本地缓存:可以存入
localStorage或cc.sys.localStorage,但注意浏览器对localStorage有大小限制(通常5MB),且Base64格式更占空间。仅适用于缓存非常小的图片(如用户头像缩略图)。
- 即时预览:将Base64字符串直接赋值给
4. 常见问题、性能优化与实战技巧
在实际项目中集成这套方案,你肯定会遇到各种问题。下面是我总结的“避坑指南”和优化建议。
4.1 截图内容不完整或错位
- 问题:截出来的图只有一部分,或者UI元素位置不对。
- 排查:
- 相机视口(Viewport)与正交高度(OrthoHeight):确保相机的
orthoHeight设置为渲染纹理高度的一半(因为正交高度是从中心到顶部的距离)。相机的视口(viewport属性)应设置为(0,0,1,1)覆盖整个纹理。检查相机节点的位置是否在截取区域的中心。 - 节点层级与渲染顺序:确认你想要截取的节点都在相机的渲染层级(
visibility掩码)内,并且没有被其他更高优先级的相机提前清除。检查节点的zIndex和渲染顺序。 - 动态合图干扰:尝试在截图前后禁用动态合图管理器(
dynamicAtlasManager.enabled = false)。
- 相机视口(Viewport)与正交高度(OrthoHeight):确保相机的
- 技巧:可以创建一个全屏的、纯色的Sprite节点,将其设置为相机可见,用来直观地确认相机的实际渲染区域。
4.2 截图性能卡顿严重
- 问题:点击截图按钮时,游戏明显卡顿一下。
- 原因:
readPixelsGPU回读是主要原因。此外,大尺寸纹理(如4K)的创建、Base64编码都会消耗大量CPU时间。 - 优化:
- 降低分辨率:如果不是必须全高清,可以将渲染纹理的宽高设置为设计分辨率的一半甚至更低。
initCaptureCamera(640, 360)。 - 延迟销毁:不要在同一帧内频繁创建和销毁
RenderTexture。可以复用同一个纹理对象。 - 异步分帧:将
readPixels和Base64编码放到setTimeout或requestIdleCallback中执行,避免阻塞主渲染循环。但注意,readPixels必须在当前渲染帧内调用才有效,通常需要用scheduleOnce安排到下一帧。 - 使用JPEG格式:如果不需要透明背景,在转换为Base64或保存时使用
'image/jpeg'格式并设置较低质量(如0.7),可以大幅减少数据量和编码时间。 - 原生平台优化:在原生平台,考虑直接使用原生API将渲染纹理保存为文件,避免经过JavaScript层的Base64转换。
- 降低分辨率:如果不是必须全高清,可以将渲染纹理的宽高设置为设计分辨率的一半甚至更低。
4.3 移动端保存失败或权限问题
- 问题:在iOS或Android上,调用保存API后没有任何反应,或者直接报错。
- iOS:
- 确保
Info.plist中包含了NSPhotoLibraryAddUsageDescription权限描述。 - 保存到相册是异步操作,需要在主线程调用。
- 确保
- Android:
- Android 6.0+:必须在保存前动态申请
WRITE_EXTERNAL_STORAGE权限。可以使用CocosCreator提供的native模块调用Java方法。 - Android 10 (API 29)+:即使有权限,直接写文件到
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)也可能失败。必须使用MediaStoreAPI。这是最常见的坑!你需要编写Java代码,通过ContentResolver插入一条媒体记录,并获取一个OutputStream来写入图片数据。 - 路径分隔符:使用
jsb.fileUtils时,路径要用/,而不是\。
- Android 6.0+:必须在保存前动态申请
- 通用建议:对于复杂的原生文件操作,强烈建议封装成单独的原生扩展模块,提供简单的JavaScript接口,如
nativeBridge.saveImageToGallery(base64String)。
4.4 Base64字符串传输问题
- 问题:Base64字符串太长,导致POST请求被服务器拒绝,或WebSocket发送缓慢。
- 解决方案:
- 压缩图片:先使用
canvas.toDataURL('image/jpeg', 0.5)进行有损压缩。 - 分片传输:将长字符串按一定长度(如1024*1024字符)分割成多个片段,分批发送,在接收端重组。
- 考虑替代方案:如果服务器支持,直接上传二进制文件(
FormData或ArrayBuffer)是更优选择。可以先将图片保存到本地临时文件,然后使用XMLHttpRequest或fetch上传这个文件。
- 压缩图片:先使用
4.5 在“麻将”类游戏中的实战应用
以开头的“麻将游戏复盘分享”为例,完整的流程可以这样设计:
- 准备阶段:创建一个隐藏的“复盘UI”节点树,包含牌桌背景、所有玩家的手牌、打出的牌、分数等信息的克隆或快照。将这个节点树设置到一个专门的渲染层级(如
LAYER_8)。 - 截图触发:玩家点击“生成复盘图”按钮。
- 执行截图:调用
ScreenShotManager的capture方法,传入“复盘UI”的根节点,或将截图相机的visibility掩码设置为LAYER_8。 - 并行处理:
- 本地保存:调用
saveToLocal,将图片保存到用户相册,文件名为“麻将复盘_20231001.png”。在Android上处理好权限和MediaStore。 - 生成分享图:调用
convertToBase64Direct,得到Base64字符串。然后,可以将其展示在一个分享预览弹窗的cc.Sprite上,或者直接作为参数,调用微信小游戏的shareImageMessageAPI进行分享。
- 本地保存:调用
- 清理:截图完成后,销毁或隐藏“复盘UI”节点树,释放相关纹理引用。
这个流程将截图、存储、转换与具体的游戏逻辑紧密结合,提供了完整的用户体验。关键在于处理好异步操作,避免阻塞游戏主循环,并在移动端妥善处理权限和系统兼容性。
