做游戏开场CG、做产品宣传片、做场景里的电视机和监控大屏,只要一碰 Cocos 的 VideoPlayer,八成都撞过同一堵墙:视频在编辑器里摆得好好的,一跑起来要么盖住所有按钮,要么干脆飘到屏幕外面去。想让视频规规矩矩待在两层 UI 中间——底层有背景衬托,上层有边框和按钮压着——难度堪比把一块夹心按进两层饼干之间。团队里给这套需求起了个外号叫"奥利奥夹心饼干":下面一片饼干是背景层,中间那坨白色夹心是视频画面,上面再压一片饼干,是带窗口和装饰的 UI,视频只从窗口里露出来。
这个效果听起来像纯美术活,实际动起手来全是引擎层面的知识点:VideoPlayer 的渲染位置到底在哪儿、相机的 Layer 位掩码怎么算、材质实例怎么改、APK 打包之后为什么又不一样了。我前前后后在三四个项目里实现过类似需求,踩的坑足够写一篇长文。这篇就按我实际的做法从头捋一遍,从原理讲到代码,从编辑器讲到打包出包,中间会夹带一些文档里不会写的土办法。
1. 需求落点:为什么 VideoPlayer 要做成"奥利奥"
1.1 夹心效果的三个视觉层
先把目标画面拆开说清楚,不然很容易做成"全屏视频盖所有"的将就方案。所谓奥利奥,指的是屏幕从下到上叠着三个明确的层:
- 下层饼干:一张背景图或者一段动态背景,负责烘托氛围,可以是主界面的底图,也可以是某个场景的静态插画。
- 夹心:视频画面本身,它占据屏幕中间的一块矩形区域,不铺满全屏,四周留白,或者被上层 UI 的异形边框裁剪成圆角、圆形甚至不规则形状。
- 上层饼干:一层带镂空窗口的 UI。窗口之外的部分是不透明的装饰,比如木质相框、科技感的金属边框、儿童 App 里那种圆滚滚的卡通卡片边。
关键在于"夹"这个词。视频如果在最上层,那上层饼干的边框就压不住它,看起来像是把饼干贴在屏幕玻璃外面;视频如果在最下层,那下层饼干又会盖住它,视频干脆看不见。只有真正做到"视频夹在两层之间",这个视觉说服力才成立。
我来举两个真实场景。第一个是儿童教育 App 的开场页:背景是卡通教室里的一面墙,视频是墙上的"黑板动画",黑板外面有一圈木框,木框上还挂着粉笔和小板擦。这里的黑板其实就是一块视频,木框和粉笔就是上层 UI。第二个是模拟经营游戏里的监控室:屏幕中央有一块"监控画面"在播放,外面有实体显示器外壳、几颗指示灯、一圈扫描线特效,指示灯和扫描线必须压在视频上面,否则画面的空间感就塌了。
这两个场景的共同点是:视频区域是规则矩形,但上层的装饰细节很丰富,而且这些细节必须遮挡视频。这就是为什么不能简单地"把视频放在最上方然后接受现实",也不能简单地"把视频放在最下方然后被背景吃掉"。
1.2 VideoPlayer 为什么天生"不听使唤"
要理解为什么调节点层级没用,得先知道 Cocos 的渲染其实跑在两套彼此独立的体系上。
第一套是引擎自己的渲染体系。场景里所有的 Sprite、Label、Spine、粒子,最终都会由渲染管线按照节点树顺序、相机优先级、材质队列深度,一路画到后缓冲上再上屏。你在编辑器里拖节点的上下顺序,本质上就是在改这套管线的绘制次序。这套体系是可控的、可预测的。
第二套是原生视图体系。在 Android 上,VideoPlayer 最终会落到一个系统级的视图控件上,它由系统的窗口管理器决定位置和层级,和游戏的 GL 上下文是两个独立的东西。系统把这层视图"打洞"叠在 Activity 窗口之上,所以它天然就浮在游戏画面上面,而且优先级由系统说了算。在 iOS 上也是类似道理,视频层挂在原生视图上,和引擎渲染出来的画面不在同一个图层堆里。
这两套体系的存在,解释了一个很反直觉的现象:你在编辑器里怎么调节点的父子顺序,都不会改变视频和其它 UI 的最终遮挡关系。因为视频压根就没参与引擎的渲染队列,它是"另一张纸",只是刚好贴在游戏画面这张纸上而已。
注意:很多人第一次遇到"按钮被视频盖住"时,会去疯狂调整节点的 SiblingIndex,甚至新建 Canvas 分层,折腾半天发现毫无变化。这不是操作错了,而是方向错了——你得先解决"视频层位于哪张纸上"的问题。
能打破这个限制的思路只有两条:要么让原生视图沉到最底层,让游戏画面整体盖上去;要么干脆别用原生视图,把视频帧自己搬进引擎的渲染树。后面要讲的三个方案,本质上都是这两条思路的变体。
1.3 三条可选路线与选型结论
我把实践中验证过的方案列成一张表,方便你按项目情况对号入座。
| 方案 | 核心原理 | 适用平台 | 优点 | 代价与限制 |
|---|---|---|---|---|
| A. 原生视图沉底 + 上层挖洞 | 让视频沉到游戏画面之下,上层 UI 用自定义效果在窗口处"开洞"透出视频 | Android / iOS 原生包 | 改动小、性能好、不需要额外解码 | 依赖版本是否提供沉底能力;窗口形状需要自己写遮罩 |
| B. 视频帧转引擎纹理 | 把视频帧当作一张普通纹理,用 Sprite 显示在场景里 | Web / H5 最顺;原生端需额外处理 | 层级完全自由,想插在哪层就插在哪层 | 原生端不能直接用组件输出,需要自己做帧数据通路 |
| C. 手动控制网页元素层级 | 直接调整视频元素与画布的层级关系 | Web / H5 | 十几行代码搞定 | 只对 H5 有效,原生端完全无效 |
选型结论很干脆:如果目标是打包 APK,走方案 A;如果目标是 H5 页游或者网页小游戏,走方案 C 最省事;如果项目对层级自由度要求极高、且愿意投入人力,才考虑方案 B。
这里要说明的是,方案 A 里那个"沉底"的能力,在不同版本的 VideoPlayer 组件上叫法不一样,有些版本直接提供了对应的开关,有些版本则需要自己通过平台层代码去调。写这篇的时候我用的思路是不管版本怎么变,都按"先尝试让原生视图沉底,沉不了就走自定义纹理"这个顺序去处理。如果你手上的版本找不到对应属性,不要死磕,直接看方案 B 的思路就行,核心原理是一样的。
2. 场景与相机的分层骨架怎么搭
2.1 奥利奥三层节点树
骨架搭得好,后面的挖洞和适配都会顺很多。我推荐的结构是这样的(省掉了无关节点):
Canvas ├── LayerBottom // 下层饼干 │ ├── BgSprite // 背景图或动态背景 │ └── BgDecor // 背景上的装饰元素 ├── LayerVideo // 夹心(视频显示节点) │ └── VideoNode // 挂 VideoPlayer 或视频 Sprite ├── LayerMiddle // 视频和上层之间的过渡元素(可选) │ └── GlowEffect // 例如视频周围的辉光,半透明压在视频边上 └── LayerTop // 上层饼干 ├── HollowMask // 挖洞遮罩,负责在窗口处透明 ├── FrameDecor // 边框、指示灯、粉笔板擦这类装饰 └── Buttons // 交互按钮这个结构里有两个设计点值得展开讲。
第一个是LayerMiddle 的存在。有些效果需要在视频边缘压一点半透明的辉光或者扫描线,这些东西介于视频和上层装饰之间。如果你把它们全塞进 LayerTop,倒也能用,但一旦后期要调整辉光强度、或者让辉光跟随视频尺寸变化,混在一起会很乱。单独拎一层,改起来清爽。
第二个是HollowMask 和 FrameDecor 分开。挖洞遮罩需要挂自定义材质,而边框装饰用的是普通图片材质。如果把两者合并成一个节点用一张图,那这张图就必须用自定义效果渲染,图片本身的透明通道和挖洞的透明通道会打架。分开之后,遮罩只负责"哪儿透明",装饰只负责"长什么样",职责清楚,出问题也好定位。
提示:节点命名尽量带上层级前缀,比如
LayerTop_Frame01。项目做到后期,光看节点树能省下大量找节点的时间,尤其是当你的窗口形状需要反复微调的时候。
2.2 自定义 Layer 与位掩码计算
要让多台相机各管一摊,就必须用到 Layer。Cocos 里的 Layer 本质上是一个 32 位整数的位掩码,每一位代表一个层。内置的层占用了其中一部分位:
// Cocos Creator 内置 Layer 枚举(节选,具体数值以你的版本为准) // UI_2D = 1 << 25 // UI_3D = 1 << 23 // DEFAULT = 1 << 30 // PROFILER = 1 << 28用户自己能用的位是从低位开始的,所以自定义层建议从1 << 0往后加。添加方式是在项目设置的 Layers 面板里手动加一条,比如叫VIDEO,然后在代码里这样取到它的掩码:
import { Layers } from 'cc'; // 取到自定义层的掩码 const videoLayer = Layers.nameToLayer('VIDEO'); // 取到内置 UI_2D 的掩码 const ui2dLayer = Layers.Enum.UI_2D; // 相机的 visibility 支持按位或,表示"这台相机要渲染哪些层" camera.visibility = ui2dLayer | videoLayer;这里有个必须搞清楚的区别,不搞清楚后面一定出问题:节点的 layer 是"我属于哪一层",相机的 visibility 是"我要渲染哪些层"。前者是单个位掩码,后者是多个位或起来的结果。一个节点只要它的 layer 位在相机 visibility 里被包含,这台相机就会渲染它。
举个具体的例子。假设VIDEO这一位的值是1 << 0,也就是 1;UI_2D是1 << 25,也就是 33554432。那么:
- 视频节点的
layer设成1(只属于 VIDEO 层) - 下层 UI 的
layer设成33554432(只属于 UI_2D 层) - 相机 A 的
visibility设成33554432,它只画下层 UI - 相机 B 的
visibility设成1 | 33554432,它同时画视频层和 UI 层
这样就能做到"某台相机只看某几层",从而实现分层渲染。
注意:把 UI 节点放到自定义层(比如 VIDEO)时,有个坑要留意。点击事件的射线检测是拿节点的 layer 和相机的 visibility 做与运算来判断的,如果这台相机的 visibility 里没有包含该节点的层,点击就穿透过去了。所以视频节点本身一般是不需要点击的,真要点击,请把点击热区单独放到 UI_2D 层,别硬塞在视频节点上。
另外,UI 的屏幕适配(Widget 组件)主要依赖父节点链,和 layer 关系不大,所以把视频节点放到独立层不会导致适配错乱。真正会错乱的是多相机同时渲染 UI 时的 Canvas 适配基准,这个我在 2.3 里展开说。
2.3 相机优先级与渲染顺序
Cocos 里相机的绘制顺序由priority决定,数值越小越先画,也就是越靠底层。所以"奥利奥"的相机排布可以这样来:
import { _decorator, Component, Camera, Layers } from 'cc'; const { ccclass, property } = _decorator; @ccclass('CameraLayerSetup') export class CameraLayerSetup extends Component { @property(Camera) camBottom: Camera = null!; @property(Camera) camVideo: Camera = null!; @property(Camera) camTop: Camera = null!; onLoad() { const videoLayer = Layers.nameToLayer('VIDEO'); const ui2d = Layers.Enum.UI_2D; // 下层饼干:只画 UI_2D,最先画 this.camBottom.priority = 0; this.camBottom.visibility = ui2d; // 夹心:画视频层 this.camVideo.priority = 10; this.camVideo.visibility = videoLayer; // 上层饼干:画 UI_2D,最后画,压在最上面 this.camTop.priority = 20; this.camTop.visibility = ui2d; } }但这里有个很容易翻车的地方:如果你让 StringBottom 和 camTop 两台相机都画 UI_2D 层,那么所有 UI_2D 层的节点会被画两遍。一遍在下、一遍在上,结果就是上层 UI 把下层 UI 也盖住了。解决办法是把下层的 UI 节点也单独分到一个层(比如再加一个UI_BOTTOM层),让两台相机的 visibility 互不重叠。
调整之后是这样:
| 相机 | priority | visibility | 负责内容 |
|---|---|---|---|
| camBottom | 0 | UI_BOTTOM | 背景、背景装饰 |
| camVideo | 10 | VIDEO | 视频显示节点 |
| camTop | 20 | UI_TOP | 挖洞遮罩、边框、按钮 |
三层各画各的,互不干扰,这才是干净的奥利奥结构。
提示:多相机方案带来一个副作用——UI 的 Click 事件在 3.x 里默认只对 UI_2D 层生效得比较顺利,其它层的点击需要确认相机配置。我一般会在最上面再放一个专门负责交互的 UI_2D 层节点树,让它只做热区,不参与视觉渲染。这样视觉和交互分开,改起来互不影响。
3. 核心实现:把视频"夹"进两层 UI 之间
3.1 底层锚定:让原生视图沉到最下面
前面说了,原生视图默认是浮在游戏画面上面的。要让上层 UI 能压住它,第一步就是把它压到最下面。
如果版本支持的话,直接在 VideoPlayer 组件上把"沉底"相关的开关打开即可。开启之后,视频就会跑到整个游戏画面的下方,Canvas 里的所有内容都盖在它上面。此时视频其实还在渲染,只是被上面的背景挡住了——所以下一步必须让上层 UI 在视频区域的中心"开个洞",让它透出来。
对于 H5 场景,原理一样但操作方式不同。H5 上的视频是一段网页元素,它和画布是平级关系。你可以取到它,调整它的堆叠层级,让它沉到画布下面:
// 仅用于 H5,示意层级调整思路,具体选择器以你的工程结构为准 const videoEl = document.querySelector('video'); if (videoEl) { videoEl.style.zIndex = '0'; videoEl.style.position = 'absolute'; }然后再让画布的层级高于它:
const canvasEl = document.querySelector('canvas'); if (canvasEl) { canvasEl.style.position = 'relative'; canvasEl.style.zIndex = '1'; }这样视频沉到画布下面,画布上的 UI 就能压住它,接下来同样靠挖洞把视频露出来。
注意:在 H5 上调层级时,要确认视频元素和画布确实在同一个定位上下文里,否则
zIndex是不生效的。如果调了半天没变化,先检查父容器的position是不是static,把它改成relative再试。
3.2 上层挖洞:用自定义效果做透明窗口
这是整套方案的核心。上层 UI 需要在视频区域开一个窗口,窗口内的像素完全透明,让下面的视频透出来;窗口外的像素保持不透明,把视频挡住。
有两条实现路径。
路径一:用 Mask 组件。简单矩形或圆形窗口可以用 Mask 组件做,把要透出的区域做成一个形状,Mask 之外的 UI 被裁掉。但 Mask 的问题在于:它的原理是裁剪渲染,窗口边缘是硬的、无法羽化,而且复杂形状(圆角矩形、异形)支持得不好,多个 Mask 嵌套还容易出性能问题。
路径二:自定义片元效果。这是我在实际项目里用的方案。做法是给窗口遮罩节点挂一张铺满整屏的图,然后用自定义的片元逻辑决定每个像素的透明度。想开圆洞就开圆洞,想开圆角矩形就开圆角矩形,还能加羽化让边缘柔和。
下面是我常用的核心片元逻辑,用的是圆角矩形的距离场写法:
// 写在自定义 effect 的片元部分 precision highp float; in vec2 v_uv; // 当前像素的 UV in vec4 v_color; // 顶点色,用于整体透明度控制 uniform sampler2D texture; // 遮罩贴图,通常是一张纯白图 uniform Hollow { vec2 center; // 洞口中心点,UV 空间,范围 0~1 vec2 halfSize; // 洞口半宽半高,UV 空间 float corner; // 圆角半径,UV 空间 float feather; // 边缘羽化宽度,避免硬边 }; vec4 frag () { vec4 col = texture(texture, v_uv) * v_color; // 圆角矩形距离场:计算当前点到圆角矩形边界的距离 vec2 q = abs(v_uv - center) - halfSize + vec2(corner); float dist = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - corner; // dist < 0 在洞内(透明),dist > 0 在洞外(保留) float mask = smoothstep(0.0, feather, dist); col.a *= mask; return col; }这段逻辑值得展开讲,因为它是整篇文章里最"技术"的一块。
q = abs(v_uv - center) - halfSize + corner这一步,是把坐标原点挪到洞口中心,然后减去洞口的半宽半高。用的是abs,所以四分之一象限算完就等于整个矩形都算完了,这是距离场的一种常见优化。
length(max(q, 0.0))处理的是点在矩形外部的情况,这是"到矩形边的最短距离"里的角上部分;min(max(q.x, q.y), 0.0)处理的是点在矩形内部的情况,算的是到最近边的距离(负值)。两者相加,就得到了带符号的距离:外面是正、里面是负。最后再减去corner,就把直角矩形变成了圆角矩形。
smoothstep(0.0, feather, dist)把距离映射成 0 到 1 的遮罩值。当dist小于等于 0(在洞内),结果是 0,alpha 乘 0 变成完全透明;当dist大于等于feather(在洞外足够远),结果是 1,保持原样。中间过渡的那一小段就是羽化,让边缘不会锯齿。
参数怎么给?给你一个换算参考。假设设计分辨率是 1280×720,视频窗口是居中、宽 800、高 450、圆角 24:
center=(0.5, 0.5)(居中)halfSize.x=800 / 1280 / 2 ≈ 0.3125halfSize.y=450 / 720 / 2 ≈ 0.3125corner=24 / 1280 ≈ 0.01875(按较小边算更稳)feather给0.005 ~ 0.01之间比较自然,太小会有锯齿,太大边缘会发虚
注意:
halfSize的归一化基准是屏幕的宽和高,不是正方形的。如果你在横竖屏之间切换,同一个halfSize值在两种方向下的视觉宽高比是不一样。稳妥做法是在屏幕尺寸变化时重新计算这几个参数,而不是写死在材质里。
3.3 脚本控制:播放、暂停、进度与结束回调
视觉效果搭好之后,就轮到播放逻辑了。下面是我常用的一个控制脚本骨架,职责是把视频播放状态和挖洞参数都管起来:
import { _decorator, Component, VideoPlayer, Sprite, Node, view, UITransform } from 'cc'; const { ccclass, property } = _decorator; @ccclass('VideoHollowController') export class VideoHollowController extends Component { @property(VideoPlayer) videoPlayer: VideoPlayer = null!; @property(Sprite) maskSprite: Sprite = null!; @property(Node) videoNode: Node = null!; private _maskMat: any = null; onLoad() { // 关键:取材质实例,不要改共享材质 this._maskMat = this.maskSprite.getMaterialInstance(0); this.syncHollowParams(); view.on('canvas-resize', this.syncHollowParams, this); // 播放事件 this.videoPlayer.node.on(VideoPlayer.EventType.READY_TO_PLAY, () => { console.log('视频就绪,可以播放'); }, this); this.videoPlayer.node.on(VideoPlayer.EventType.COMPLETED, () => { console.log('视频播放完毕'); this.onVideoComplete(); }, this); this.videoPlayer.node.on(VideoPlayer.EventType.ERROR, () => { console.error('视频播放出错,检查资源路径和编码格式'); }, this); } start() { // 等就绪之后再播,避免首帧黑屏 this.videoPlayer.play(); } // 把视频节点的尺寸换算成挖洞参数 syncHollowParams() { if (!this._maskMat) return; const designSize = view.getDesignResolutionSize(); const vt = this.videoNode.getComponent(UITransform); const w = vt.width; const h = vt.height; // 这里假设视频节点居中,实际项目里按节点世界坐标换算 const centerX = 0.5; const centerY = 0.5; this._maskMat.setProperty('center', [centerX, centerY]); this._maskMat.setProperty('halfSize', [ (w / designSize.width) * 0.5, (h / designSize.height) * 0.5, ]); this._maskMat.setProperty('corner', 24 / designSize.width); this._maskMat.setProperty('feather', 0.008); } onVideoComplete() { // 播完之后怎么处理,比如隐藏视频层、显示结算 UI this.videoPlayer.stop(); } onDestroy() { view.off('canvas-resize', this.syncHollowParams, this); } }这段代码里有三个细节必须强调,都是踩过坑才知道的。
第一,一定要用getMaterialInstance而不是getMaterial。前者拿到的是一份只属于这个 Sprite 的材质副本,你改参数只影响自己;后者拿到的是共享材质,改了会影响所有用这个材质的节点,而且引擎重新渲染时可能把你改的值覆盖回去。我最早做的时候就是直接改共享材质,结果两个界面的洞口一起变大变小,排查了很久。
第二,材质参数用setProperty传数组。像center、halfSize这类 vec2 参数,要传[x, y]形式的数组。传单个数字或者对象都不行,控制台会报类型错误。
第三,屏幕尺寸变化要重算参数。手机横竖屏切换、窗口拉伸,都会触发画布尺寸变化。如果不重算halfSize,洞口会和视频错位,出现"视频露出一个角,其余被挡住"的诡异现象。用view.on('canvas-resize', ...)监听是一种做法,也可以自己监听节点的尺寸变化事件。
3.4 视频与上层 UI 的位置对齐
挖洞挖得再准,如果视频本身的位置和上层 UI 对不上,一样是白搭。
在原生平台上,视频画面的位置通常是基于屏幕坐标的,而 UI 节点用的是设计分辨率下的局部坐标。这两者之间需要一个换算。思路是:先把 UI 节点在窗口里的世界坐标算出来,转成屏幕坐标,再按设计分辨率换算到对应的位置。
import { Camera, Vec3 } from 'cc'; // 把 UI 节点的世界坐标换算成屏幕坐标,再映射到设计分辨率 const worldPos = this.videoNode.getWorldPosition(); const screenPos = new Vec3(); uiCamera.worldToScreen(worldPos, screenPos); const designSize = view.getDesignResolutionSize(); const screenSize = view.getVisibleSize(); // 或按实际像素取 const designX = screenPos.x / screenSize.width * designSize.width; const designY = screenPos.y / screenSize.height * designSize.height;换算完之后,把designX、designY设到视频显示节点上。
这里有几个必须注意的点。第一,别用getPosition用getWorldPosition,因为 UI 的坐标是相对父节点的,父节点本身可能被容器挪动过。第二,横屏转竖屏时要重新算,屏幕宽高比例变了,同一个世界坐标映射出来的设计坐标完全不同。第三,刘海屏和挖孔屏要留安全区,边缘区域可能被系统裁掉或者被异形屏遮挡,视频窗口尽量放在安全区内。
我个人的经验是,与其每次运行时动态换算,不如把视频的显示区域固定成一组设计稿上标好的常量(比如"距离顶部 180,距左右各 240"),然后在上层 UI 里按这组常量摆装饰。这样 UI 和视频的对应关系是写死的,不会因为某个环节的换算误差而飘。运行时换算只在两种情况下才必要:窗口可自由拖拽、或者支持多分辨率自适应到极致。
4. 打包 APK 与联调中的坑
4.1 常见问题速查表
这一节是我用血泪换来的,建议直接对照排查。
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 只有声音没有画面 | 编码格式设备不支持,比如某些较新的编码 | 转成 H.264 的 mp4,档次选兼容性最好的那档 |
| 视频盖住全部 UI | 原生视图还在最上层,沉底没生效 | 确认沉底开关是否开启,或改用纹理方案 |
| 洞口没透出视频 | 挖洞层在视频层之下,或者相机 visibility 配错 | 检查相机 priority 和 visibility 位掩码 |
| 洞口边缘有锯齿或黑边 | 羽化值太小,或 UV 边缘没对齐 | feather调到 0.008 以上,检查遮罩图是否铺满 |
| 视频位置整体偏移 | 坐标系混淆,或安全区没考虑 | 用世界坐标换算,避开刘海区域 |
| 切后台再回来白屏 | 播放上下文被系统回收 | 在恢复前台事件里重新play() |
| APK 里视频不播 | 视频没打进包,或路径大小写不对 | 检查资源是否在构建目录里,路径全小写 |
| 首次播放有短暂黑屏 | 播放器还没就绪就显示 | 监听就绪事件后再显示视频节点 |
关于"切后台回来"这一条,展开说一下。移动端系统在应用退到后台、尤其是内存紧张的时候,可能会释放视频的播放上下文。表现就是切回来之后视频区域一片白或者一片黑。稳妥的做法是在应用恢复前台的时机里检查视频状态,必要时重新播一遍:
import { game, Game } from 'cc'; game.on(Game.EVENT_SHOW, () => { // 恢复前台时重新拉起播放 if (this.videoPlayer) { this.videoPlayer.play(); } });4.2 顺带说清 Blender 贴图在 Cocos 报错这件事
做这个"奥利奥"效果,绕不开贴图。下层背景、上层边框、挖洞遮罩,全是图。有一个特别高频的问题:模型和贴图在 Blender 里看着一切都好,导入 Cocos 之后报错,或者显示成一片黑一块紫。我排查过很多次,总结出几个最常见的原因。
第一,格式问题。Blender 默认可能导出 TGA 或者带特殊通道的图,而 Cocos 对图集和压缩纹理的支持是挑格式的。最稳的格式是 PNG 和 JPG,前者支持透明,后者体积小但不支持透明。如果你的贴图需要透明通道,务必用 PNG,别偷懒用 TGA。
第二,尺寸不是 2 的幂。很多平台的压缩纹理格式(比如面向移动端的那些)要求贴图的宽高是 2 的幂次,像 256、512、1024、2048。如果你的贴图是 300×450 这种,压缩环节就会报错或者退化成不压缩。最省事的做法是在导入前把尺寸统一处理成 2 的幂,多出来的边留一点余量。
第三,贴图尺寸超过设备上限。移动设备单张纹理的最大尺寸常见是 4096 或 8192,超了就会加载失败。背景图最容易超标,一张 6000 像素宽的全景图打进包,老设备直接挂。
第四,文件名带中文或者空格。有些平台的资源加载对路径字符很敏感,中文和空格会引发找不到资源的错误。统一用英文加下划线命名,路径全小写,能省掉一堆玄学问题。
第五,色彩空间不一致。Blender 里如果用了线性空间的颜色,导入到 Cocos 之后可能看着偏暗或者偏灰。这不是报错,是视觉差异,但经常被误以为是加载失败。遇到这种情况,检查一下贴图导入设置里的色彩相关选项。
提示:遇到贴图报错,第一件事是去看控制台的完整错误文本,而不是猜。报错里通常会明确写"尺寸不支持"、"格式不支持"还是"找不到文件",直接对症下药比盲目换图快十倍。
4.3 打包参数与资源管理
最后说说打包成 APK 时容易忽略的几件事。
屏幕方向。如果视频是横屏内容,而 APK 是竖屏,那视频会被拉伸或者加黑边。我的做法是这套"奥利奥"界面所在的项目统一用一个方向,不要在同一份包里混横竖屏逻辑,否则坐标换算会牵扯一堆分支。
视频资源放哪儿。视频文件通常比较大,不建议塞进首包。可以放到远程加载的目录里,或者用分包的形式在需要时下载。如果一定要打进包体,注意构建时确认资源确实被复制到了输出目录——我遇到过资源在编辑器的资源目录里,构建时因为没被任何场景引用而被裁掉,结果包里没有视频文件。
权限与网络。如果视频是远程地址,Android 需要在清单里声明网络访问权限,还要注意从安全站点加载资源。如果视频是包内的本地文件,就不涉及这些。为了省事,演示和小型项目建议直接用本地视频。
首帧处理。移动端视频从加载到出画有延迟,常见几百毫秒。如果一上来就显示视频节点,用户会先看到一个空白矩形。我的处理是让视频节点初始不可见,等到就绪事件触发后再显示,同时在那个位置放一张和视频第一帧近似的静态图做占位。视觉上几乎无缝。
内存与释放。视频播放期间,播放器本身和视频纹理会占内存。如果这个界面只是偶尔进一次,记得在离开时把视频停掉、把相关资源释放掉,不然连着进出几次可能就吃紧。用videoPlayer.stop()停播,再用资源管理相关的接口把不再用的纹理释放掉。
5. 实战心得与后续扩展方向
这套方案我在两个项目里跑过。一个是儿童教育 App 的开场动画,一个是模拟经营游戏里的监控屏界面。第一次做的时候,最折磨我的不是 Shader 也不是相机,而是洞口边缘的一条细细的黑边。切换到某些机型上,洞口四周会出现一圈半透明暗色,怎么调都不干净。折腾了大半天才发现,问题出在羽化值给小了,遮罩层的最外圈 UV 和视频的实际边界没有完全对齐,留下来的那一圈像素被半透明混合之后就显得发暗。把feather从 0.003 提到 0.01,同时把遮罩节点的尺寸比视频区域各多出几个像素,黑边就彻底没了。
第二个教训是坐标基准一定要统一。我一开始图省事,有的地方用设计分辨率算,有的地方用可见尺寸算,结果在一种分辨率下好好的,换台设备就偏移几十像素。后来我定了个规矩:整个项目里所有涉及屏幕区域换算的计算,都从同一组世界坐标出发,中间不混用其它坐标系。这条规矩一立,后面几乎没再出现位置飘的问题。
第三个心得是别迷信一个方案走到底。原生平台用沉底加挖洞,H5 用层级调整,这两条路各走各的,中间用一个统一的接口层来屏蔽差异,比如对外都暴露"显示视频""隐藏视频""设置窗口区域"这几个方法,内部再根据平台走不同实现。这样上层业务逻辑完全不用关心底层是怎么实现的,后期换平台也不用大改。
再往下扩展,还有几个有意思的方向。一个是自动化测试。洞口和视频的对齐关系很难靠肉眼在几十台设备上逐个验证,可以写个脚本在运行时采样几个关键点的像素颜色,跟预期色值做比对,跑一遍构建就自动出报告。另一个是让视频区域参与实时特效,比如在视频上再叠一层扫描线或者噪点,让"监控屏"的感觉更足,这部分需要让特效层和视频层的更新节奏对齐,避免撕裂感。还有一个是把窗口做成可拖拽的,用户可以自己把视频窗口在屏幕里拖来拖去,这时候前面的坐标换算逻辑就要从静态改成每帧更新,对性能会有新的要求,得做好缓存。
最后再补一句关于资源体积的实在话。视频是这套方案里最占体积的东西,一段 1080p 十几秒的 mp4 动辄十几兆。如果你的包体本来就紧,优先考虑降分辨率和降码率,视觉上在一个几千像素的窗口里播放,720p 甚至更低往往完全看不出差别,但包体能小一大截。这个取舍我一般是在真机上对着实际窗口尺寸看一遍再决定,而不是凭想象压。