
做游戏开场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也就是 1UI_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 互不重叠。调整之后是这样相机priorityvisibility负责内容camBottom0UI_BOTTOM背景、背景装饰camVideo10VIDEO视频显示节点camTop20UI_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在洞内结果是 0alpha 乘 0 变成完全透明当dist大于等于feather在洞外足够远结果是 1保持原样。中间过渡的那一小段就是羽化让边缘不会锯齿。参数怎么给给你一个换算参考。假设设计分辨率是 1280×720视频窗口是居中、宽 800、高 450、圆角 24center(0.5, 0.5)居中halfSize.x800 / 1280 / 2 ≈ 0.3125halfSize.y450 / 720 / 2 ≈ 0.3125corner24 / 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 甚至更低往往完全看不出差别但包体能小一大截。这个取舍我一般是在真机上对着实际窗口尺寸看一遍再决定而不是凭想象压。