news 2026/10/27 11:09:52

Unity WebGL 全屏实现:权限、尺寸同步与移动端适配

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity WebGL 全屏实现:权限、尺寸同步与移动端适配

1. 先把“全屏”这个词拆开:三层视口与三个不同的 API

做 Unity WebGL 交付的时候,全屏这个需求出现的频率高得离谱,几乎每个项目验收前都会被提一次。但真正动手改的时候你会发现,团队里不同的人说的"全屏"根本不是一回事:策划说的是"游戏要铺满浏览器窗口,不要有那条黑边";运营说的是"点一下右上角那个按钮,浏览器标题栏和地址栏都消失";而嵌了你页面的第三方站点,说的是"我 iframe 里放你的链接,点全屏没反应"。

这三句话对应三套完全不同的实现路径。我见过太多项目在这上面来回返工,改了一版发现方向就不对。所以动手前先把概念切干净,比急着写代码重要得多。

1.1 浏览器全屏、容器铺满、画布缩放是三件不同的事

浏览器全屏指的是调用 Fullscreen API,让某个 DOM 元素独占整个屏幕,浏览器外壳 UI 被隐藏。这个动作的权限在浏览器手里,不在你的代码手里,它有一个硬性前提:必须由真实用户交互触发。

容器铺满指的是页面布局层面的问题。你的canvas被包在#unity-container里,容器又被包在某个div里,如果中间任何一层有padding、margin、固定的width: 960px,画面就永远铺不满,哪怕你已经进了浏览器全屏。

画布缩放是第三层,也是最容易被忽略的一层。Unity WebGL 输出的是一个<canvas>,它有两个尺寸:一个是 CSS 尺寸(canvas.style.width),决定了它在页面上占多大;另一个是 drawingBuffer 尺寸(canvas.width/canvas.height),决定了它实际渲染多少像素。CSS 尺寸变了但 drawingBuffer 没跟上,画面就会糊;反过来 drawingBuffer 太大,性能会直接塌。

这三层的关系可以用一张表说清楚:

层级控制手段失效时的典型症状
浏览器全屏Fullscreen API + 用户手势点了按钮毫无反应,控制台报 NotAllowedError
容器铺满CSS 布局(fixed / flex / inset)全屏了但画面缩在左上角一块
画布缩放matchWebGLToCanvasSize+ 尺寸同步画面铺满了但模糊、拉伸、留黑边

实际项目里,绝大多数"全屏不好使"的反馈,是这三层里的某一层出了问题,但报障的人只会说一句"全屏有问题"。我自己的习惯是先问一句:你是点了按钮没反应,还是全屏了但画面不对?这一句话就能把排查范围砍掉三分之二。

1.2 Unity 的 Screen.fullscreen 在 WebGL 下真正走的链路

不少人的第一反应是在 Unity 里写一句Screen.fullscreen = true,挂在某个按钮的onClick上。跑起来发现桌面浏览器里时灵时不灵,移动端基本全废。

原因在于这条链路是跨语言调用的:C# 的Screen.fullscreen到达 WebGL 平台层之后,会转成对 Unity Loader 的调用,Loader 再去调canvas.requestFullscreen()。中间隔了一层 JS 和一层异步的浏览器权限判断。而requestFullscreen()在规范里明确要求"由用户的瞬时激活(transient activation)触发",也就是说,调用必须发生在用户点击的事件处理栈里,或者至少在激活窗口期内。

Unity 的Update循环和浏览器的事件栈不是同一回事。你在 C# 里点按钮,事件确实是从点击进来的,但经过 Unity 的输入系统、消息队列、MonoBehaviour 生命周期,等到真正执行到Screen.fullscreen = true那一行时,那点"用户激活"的时间窗可能已经过期了。浏览器一看不是用户主动触发的,直接 reject,控制台扔一个NotAllowedError就完事。

所以我的做法很固定:全屏的触发入口永远放在 JS 侧,绑在原生 DOM 事件上,Unity 只负责发指令和接收状态。Unity 那边的按钮点击不直接改Screen.fullscreen,而是调用一个 jslib 里的方法,让 JS 去点那个真正的 DOM 按钮,或者直接在 jslib 的调用里完成requestFullscreen。这里有个细节:jslib 被 C# 调用的时机同样不在用户手势栈里,所以更稳的做法是让 Unity 的 UI 按钮去"代理点击"一个隐藏的 HTML 按钮,或者干脆在页面上叠一层透明的 HTML 按钮盖在 Unity 画布上,点击事件由 HTML 接住。

听起来绕,但这是唯一在所有浏览器上都稳的方案。后面第 3 章我会给出完整代码。

1.3 用户手势这条硬性前提决定了你在哪里放按钮

沿着上一条往下推,还有一个结论很多人没意识到:全屏按钮的位置,技术上是被浏览器规范决定的,不是被 UI 设计决定的。

如果你的游戏界面是纯 Unity 绘制的(Canvas 里画一个按钮),那么这个按钮的点击在浏览器看来是"画布上某个坐标被点了",Unity 内部知道这是按钮,浏览器不知道。这时候你再从 C# 去发起全屏请求,就存在激活窗口过期的问题。

如果你的按钮是 HTML 元素叠在画布上方,浏览器清楚地知道这是用户点了这个button,同一个事件栈里发起requestFullscreen(),成功率接近 100%。

我在几个项目里的取舍是这样:首屏的"开始游戏"页面和加载页用 HTML 写,全屏按钮自然就放在 HTML 层;进入游戏后如果要保留全屏入口,就用一个绝对定位的 HTML 按钮浮在右上角,样式做得和游戏内 UI 尽量一致。这样既不破坏视觉,又吃到了原生手势的红利。如果产品死活要求按钮必须在 Unity 里画,那退一步的做法是:Unity 按钮点击后调 JS 去click()一个隐藏按钮,利用HTMLElement.click()在部分浏览器里能继承激活状态。这条路 Chrome 和 Edge 能跑,Safari 历史上不太稳,需要做降级提示。

2. 拆开默认模板:官方 index.html 里那个全屏按钮为什么在你的页面里失灵

Unity 导出的 WebGL 包里,index.html用的是构建模板。从 2021 版本开始,模板结构基本稳定下来了,里面其实自带一个全屏按钮,id 是unity-fullscreen-button。很多人的做法是把整个 build 目录往服务器上一扔,然后把index.html整个替换成自己的页面,只留Build/目录下的文件。这么一换,那个内置按钮自然就没了,全屏功能也随之消失。

我见过更糟的情况:有人把canvas抠出来放进自己的容器,但模板里配套的 CSS 和事件绑定全丢,结果就是画布尺寸不对、全屏失灵、鼠标坐标偏移,三个问题一起来。

2.1 默认模板的 DOM 结构和内置按钮

2021 之后的默认模板大致是这样组织的:

<div id="unity-container" class="unity-desktop"> <canvas id="unity-canvas" width=960 height=600 tabindex="-1"></canvas> <div id="unity-loading-bar"> <div id="unity-logo"></div> <div id="unity-progress-bar-empty"> <div id="unity-progress-bar-full"></div> </div> </div> <div id="unity-warning"></div> <div id="unity-footer"> <div id="unity-webgl-logo"></div> <div id="unity-fullscreen-button"></div> <div id="unity-build-title"></div> </div> </div>

#unity-fullscreen-button的点击处理写在加载器脚本里,逻辑大致是调用unityInstance.SetFullscreen(1)。这个SetFullscreen是 Unity Loader 暴露出来的方法,内部会对#unity-canvas做requestFullscreen,同时会调整容器的 class,让unity-desktop/unity-mobile这类状态切换正确。

关键点在于:SetFullscreen依赖#unity-canvas和#unity-container这两个 id 同时存在且结构正确。你只要改掉了 id,或者把 canvas 挪出了 container,这个方法就会静默失败——它不会抛错,就是什么也不做。这种"静默失败"是最难查的。

2.2 把 canvas 塞进自定义容器后,最容易丢的两样东西

如果你的页面是自研框架(Vue、React 或者纯手写),通常会把 Unity 画布挂到某个组件节点下。这时候有两样东西特别容易丢:

第一样是容器的高度链。canvas的height: 100%只有在父元素有确定高度时才生效。而body和html默认是没有高度的。很多页面看着"铺满了",其实是因为内容撑开的,一旦把内容清空,高度就变成 0,画布直接消失。标准的兜底写法是:

html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #000; }

第二样是display: block。canvas默认是 inline 元素,行内元素在基线对齐下底部会多出几像素的空隙。这个空隙平时看不出来,但一旦你做了全屏铺满、又用了黑色背景以外的底色,底部就会露出一条很细的缝隙。加一句#unity-canvas { display: block; }就干净了。

容器本身我一般用position: fixed加inset: 0:

#unity-container { position: fixed; inset: 0; width: 100%; height: 100%; } #unity-canvas { display: block; width: 100%; height: 100%; background: #231F20; }

这里用fixed而不是absolute,是因为fixed的定位基准是视口,不受祖先元素的transform、overflow影响(严格说transform会创建包含块,但常规页面结构下fixed更省心)。如果整个页面需要滚动、画布只是其中一块,那就用absolute加一个position: relative的父容器,把父容器撑到目标尺寸。

2.3 iframe 嵌入场景的 allow 属性与权限策略

这个坑我踩过不止一次,而且每次都很隐蔽。现象是:页面单独打开一切正常,一点全屏就进去了;但一旦被第三方站点用 iframe 嵌进去,全屏按钮点了完全没反应,控制台一片安静。

原因是 iframe 默认是不继承全屏权限的。宿主页面必须显式放权:

<iframe src="https://your-domain/game/index.html" allow="fullscreen" allowfullscreen style="width:100%;height:100%;border:0;"> </iframe>

allowfullscreen是老写法(布尔属性),allow="fullscreen"是新版 Permissions Policy 的写法。两个都写上是兼容性最好的做法。如果宿主页面还设置了allow="fullscreen 'none'"或者更严格的策略,那无论你这边怎么写都没用,这时候只能靠伪全屏兜底——也就是在自己的 iframe 内部用 CSS 把画布铺满 iframe 的可视区,虽然没有隐藏浏览器 UI,但视觉上是满的。

还有一个细节:跨域 iframe 内部调用document.fullscreenElement读到的是自己 document的状态,这个没问题。但如果你的页面需要在父页面里做状态同步(比如父页面要收起侧边栏),跨域是拿不到的,只能靠postMessage。

提示:在 iframe 场景下调试,一定要先确认"权限是否被放行",再去查代码。判断方法很简单,在 iframe 内部的 console 里手动执行document.querySelector('#unity-canvas').requestFullscreen(),如果报TypeError: fullscreen error或者直接被拒绝,基本就是权限问题。

3. 自己写一套全屏控制器:从事件监听到状态回传

到了这一步,思路应该很清楚了:不要依赖 Unity 内置的那个按钮,自己写一套控制器。这套控制器要解决四件事:发起请求、处理兼容前缀、同步状态、把状态回传给 Unity。我把它拆成一个独立的 JS 模块,几十行代码,但覆盖了绝大多数场景。

3.1 进入与退出:带前缀的 API 封装

现在的浏览器绝大多数支持无前缀的requestFullscreen(),但 Safari 在很长一段时间里只认webkitRequestFullscreen(),早期的一些国产内核浏览器也各有各的前缀。写一个带降级的封装,成本很低,收益很高:

function getFullscreenElement() { return document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement || null; } function requestFullscreen(el) { const fn = el.requestFullscreen || el.webkitRequestFullscreen || el.webkitRequestFullScreen || el.msRequestFullscreen; if (!fn) { return Promise.reject(new Error('Fullscreen API unsupported')); } try { const ret = fn.call(el); // 老内核不返回 Promise,包一层保证链式调用一致 return ret instanceof Promise ? ret : Promise.resolve(); } catch (e) { return Promise.reject(e); } } function exitFullscreen() { const fn = document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen; if (!fn) return Promise.resolve(); try { const ret = fn.call(document); return ret instanceof Promise ? ret : Promise.resolve(); } catch (e) { return Promise.reject(e); } } function toggleFullscreen(el) { return getFullscreenElement() ? exitFullscreen() : requestFullscreen(el); }

这里有两个容易忽略的点。一是老内核的返回值不是 Promise,如果你直接.catch()会抛TypeError,所以必须包一层。二是webkitRequestFullScreen这个大小写变体——Safari 早期版本用的就是大写的S,这个不是笔误,确实存在,加上没坏处。

绑定的目标元素也有讲究。绑#unity-canvas和绑document.documentElement效果不同:绑 canvas 全屏的是画布,页面上其他元素都看不见;绑documentElement全屏的是整个页面,如果你的全屏按钮是 HTML 元素,全屏后按钮还在,用户能点它退出。我一般绑 canvas,同时在页面上放一个"退出全屏"的浮层按钮,逻辑更可控。

3.2 状态同步:别只监听 fullscreenchange

fullscreenchange是标准事件,但前缀版本的事件名不一样:webkitfullscreenchange、msfullscreenchange。而且有一个极其常见的场景会让状态失准——用户按了 F11 或者 Esc。

按 F11 是浏览器原生全屏,这时候document.fullscreenElement依然是null,fullscreenchange也不会触发。你的 UI 以为还在窗口模式,但用户看到的已经是全屏了。这个状态差会导致后续所有基于"是否全屏"的判断全部出错,比如分辨率调整、UI 显隐。

按 Esc 退出时,fullscreenchange会正常触发,这个没问题。

处理 F11 只能靠尺寸变化做间接判断,虽然不精确:

function syncByViewport() { const w = window.innerWidth; const h = window.innerHeight; const screenW = window.screen.width; const screenH = window.screen.height; // 视口接近屏幕物理尺寸,且用户没通过 API 全屏,大概率是 F11 const likelyNativeFs = Math.abs(w - screenW) < 4 && Math.abs(h - screenH) < 4; return likelyNativeFs; }

这个判断在高 DPI 屏上会因为缩放比例算不准,别把它当唯一依据。我的做法是:提供一个显式的全屏按钮,让用户优先用它;fullscreenchange用来做精确同步;F11 这种走兜底逻辑,保证画面不崩就行,不追求状态完全一致。

事件监听这样写:

['fullscreenchange', 'webkitfullscreenchange', 'msfullscreenchange'] .forEach(name => document.addEventListener(name, onFullscreenChange)); function onFullscreenChange() { const isFs = !!getFullscreenElement(); applyLayout(isFs); notifyUnity(isFs); }

3.3 Unity 侧:jslib 与 C# 桥接的完整代码

Unity 这边需要两个方向的能力:向下发指令(请求进入/退出全屏)和向上接状态(知道当前是不是全屏)。

向下发指令用 jslib:

mergeInto(LibraryManager.library, { WebFullscreen_Request: function () { const canvas = document.querySelector('#unity-canvas'); if (!canvas) { console.warn('[FS] canvas not found'); return; } requestFullscreen(canvas).catch(err => { console.warn('[FS] request rejected:', err && err.name, err && err.message); }); }, WebFullscreen_Exit: function () { exitFullscreen().catch(err => { console.warn('[FS] exit rejected:', err && err.name); }); }, WebFullscreen_IsActive: function () { return getFullscreenElement() ? 1 : 0; } });

注意 jslib 里的函数如果不加$前缀,会被打成全局函数直接暴露在Module上,Unity 调用时不需要额外绑定。函数名里带下划线只是我的个人习惯,方便和业务代码区分。

C# 侧:

using System.Runtime.InteropServices; using UnityEngine; public class WebFullscreenBridge : MonoBehaviour { [DllImport("__Internal")] private static extern void WebFullscreen_Request(); [DllImport("__Internal")] private static extern void WebFullscreen_Exit(); [DllImport("__Internal")] private static extern int WebFullscreen_IsActive(); public static bool IsSupported { get { #if UNITY_WEBGL && !UNITY_EDITOR return true; #else return false; #endif } } public void Enter() { #if UNITY_WEBGL && !UNITY_EDITOR WebFullscreen_Request(); #endif } public void Exit() { #if UNITY_WEBGL && !UNITY_EDITOR WebFullscreen_Exit(); #endif } public bool QueryActive() { #if UNITY_WEBGL && !UNITY_EDITOR return WebFullscreen_IsActive() == 1; #else return Screen.fullscreen; #endif } // 由 JS 通过 SendMessage 回调 public void OnFullscreenStateChanged(string flag) { bool isFs = flag == "1"; // 这里处理游戏内的 UI 切换、相机刷新等 Debug.Log($"[FS] state changed -> {isFs}"); } }

向上接状态就是 JS 主动SendMessage:

let unityInstanceRef = null; function notifyUnity(isFs) { if (!unityInstanceRef) return; try { unityInstanceRef.SendMessage( 'WebFullscreenBridge', 'OnFullscreenStateChanged', isFs ? '1' : '0' ); } catch (e) { // 场景未加载完成时 SendMessage 会抛错,忽略即可 } }

unityInstanceRef要在createUnityInstance的 then 回调里赋值:

createUnityInstance(canvas, config, onProgress).then(instance => { unityInstanceRef = instance; });

注意:SendMessage在 Unity 场景还没加载完时调用会报错,这是正常的。我第一次做这个功能时被这个报错刷了满屏日志,一度以为是桥接写错了。加个 try-catch 或者用一个"场景就绪"标志位挡一下就好。

3.4 全屏失败的三种典型错误码怎么读

requestFullscreen()被拒绝时,err.name会告诉你原因。这几种我实际遇到过:

错误名含义常见触发场景
NotAllowedError无用户激活或权限策略禁止代码里自动调用;iframe 未放权
TypeError元素类型不支持或 API 参数错传了undefined;老内核签名不一致
NotSupportedError该元素或环境不支持全屏移动端 Safari;某些内嵌 WebView

NotAllowedError占了实际问题的八成以上。排查顺序很简单:先看调用是不是在click的事件栈里(打断点或者console.trace()),再看是不是在 iframe 里,最后看浏览器是不是有企业策略限制。

NotSupportedError在移动端是常态,不是 bug。iPhone 上的 Safari 至今不支持任意元素的 Fullscreen API,只有<video>能全屏。这个后面第 5 章专门讲。

4. 尺寸同步:全屏进去了,画面却只占左上角一角

这个症状我描述一下你应该很熟悉:点了全屏,浏览器确实进了全屏模式,地址栏没了,但 Unity 的画面还是原来那个大小,缩在左上角,右边和下面是黑的。或者反过来,画面铺满了但明显拉伸变形,圆形的角色变成了椭圆。

这两种表现分别对应两个不同的根因:前者是 CSS 尺寸没跟着变,后者是 drawingBuffer 和 CSS 尺寸的比例不一致。

4.1 matchWebGLToCanvasSize 与 drawingBuffer 的真实关系

Unity 2021 之后,Loader 的配置对象里有一个matchWebGLToCanvasSize,默认值是true。它的作用是:当 canvas 的 CSS 尺寸变化时,自动同步 drawingBuffer 的尺寸。这个机制是靠内部的ResizeObserver或者定时轮询实现的,具体实现各版本略有差异。

如果你的模板里显式写了config.matchWebGLToCanvasSize = false,那 canvas 的 CSS 尺寸会变,但渲染分辨率不变,结果就是模糊或者拉伸。我在接手别人的项目时,第一件事就是搜这个字段,十个里面有八个是历史遗留的false。

另一种情况更隐蔽:你用的是自定义的createUnityInstance配置,把config对象整个替换掉了,而默认配置里的一些字段没带过来。Unity 的 Loader 只会用你传进去的config,不会自动补默认值。所以稳妥的写法是:

const config = { dataUrl: buildUrl + '/xxx.data', frameworkUrl: buildUrl + '/xxx.framework.js', codeUrl: buildUrl + '/xxx.wasm', streamingAssetsUrl: 'StreamingAssets', companyName: 'YourCompany', productName: 'YourProduct', productVersion: '1.0', matchWebGLToCanvasSize: true, // 显式写上,别依赖默认 };

显式写的好处是:下次别人接手,一眼就知道这里是开着的,不会因为怀疑这个字段而去乱改。

4.2 用 ResizeObserver 替代 window.resize 的理由

window.resize事件只在窗口尺寸变化时触发。全屏切换需求下,这个事件够用,因为全屏必然伴随窗口变化。但它有两个局限:

一是元素级尺寸变化不触发。如果你的画布是嵌在一个可折叠侧边栏旁边的,侧边栏收起时画布变宽了,但窗口尺寸没变,window.resize不会触发,画布就停在旧尺寸。

二是触发频率高且没有节流。拖拽窗口边缘时每帧都在触发,如果回调里有重活,很容易卡。

ResizeObserver直接监听元素尺寸,天然解决第一个问题。第二个问题它也没解决,但配合一个简单的比较逻辑就能避免无限循环:

const container = document.querySelector('#unity-container'); let lastW = 0, lastH = 0; const ro = new ResizeObserver(entries => { for (const entry of entries) { const w = Math.round(entry.contentRect.width); const h = Math.round(entry.contentRect.height); if (w === lastW && h === lastH) return; // 值没变就不处理 lastW = w; lastH = h; onContainerResize(w, h); } }); ro.observe(container);

如果不做这个值比较,onContainerResize里如果又改了容器尺寸,就会触发ResizeObserver loop completed with undelivered notifications警告。这个警告不影响功能,但控制台会一直刷,很烦。

4.3 高 DPI 屏下的像素比与性能取舍

window.devicePixelRatio在主流笔记本上常见是 2,意味着 CSS 上 1 像素实际对应 2×2 的物理像素。Unity WebGL 的 Loader 会把 drawingBuffer 按这个比例放大,渲染分辨率直接变成 4 倍像素量,GPU 压力也跟着涨。

手机上的情况更极端,有些机型的 DPR 到 3。一个 1080p 的视口,实际渲染 3240×1920,中低端 GPU 直接跪。

我的做法是设一个上限:

function getEffectiveDPR() { const raw = window.devicePixelRatio || 1; return Math.min(raw, 2); }

然后把这个值通过 CSS 或者 Loader 配置传给 Unity。Unity 侧也可以在自己的渲染管线里做适配,比如用Screen.SetResolution按比例降,但 WebGL 平台上SetResolution的行为不太直观,容易被 Loader 覆盖。更可控的方式还是在 JS 层控制 canvas 的 drawingBuffer 大小。

需要说明的是,降分辨率一定会在视觉上有损失,尤其是 UI 上的细线条和小字号文字。所以这个上限值要按项目实际内容定:如果是 3D 场景为主、UI 元素大,压到 1.5 都没问题;如果是重度 UI、大量文本,那就别压太狠,宁可关掉一些后处理省性能。

4.4 相机 aspect 的自适应策略

画布尺寸变了,Unity 内部的Camera.aspect会跟着变,但变完之后画面里看到的内容范围会变。这是设计上的问题,不是 bug。

举个具体的例子:你的 UI 按 16:9 设计,按钮贴在屏幕最右侧。当用户全屏到一个 21:9 的超宽屏上,Camera.aspect变成 2.33,如果相机的orthographicSize不变,水平方向会看到更多内容——本来贴边的按钮还在,但右侧出现了新的画面,视觉重心偏了。反过来在 4:3 屏上,右侧可能被裁掉,按钮就看不见了。

两种常见策略:

固定高度,宽度自适应。保持orthographicSize不变,只让水平 FOV 跟随 aspect 变化。适合横版、内容主要水平排列的场景,代价是在超宽屏上会露出设计范围外的内容。

固定宽度,高度自适应。按相反方向算,适合竖版或者关键内容集中在中间的场景。

还有第三种,也是我在 UI 密集型项目里常用的:给相机设一个安全区,超出设计比例的部分用黑边或者背景色填掉。Unity 里可以开相机的Viewport Rect,或者用两个相机分层渲染。黑边方案在超宽屏上会让人不爽,但至少保证了设计还原度,不会出现按钮被裁的情况。

这块没有银弹,必须在项目开始前和设计侧对齐"我们的目标比例是哪几个"。我的经验是取 16:9 和 16:10 作为主设计比例,超宽和超窄做兼容处理,而不是追求所有比例都完美。

5. 移动端不是同一套规则:iPhone 上根本点不出全屏

如果项目只在桌面浏览器上跑,前面几章的内容足够了。但只要涉及移动端,规则就完全变了,而且变得很彻底。

最核心的事实是:iPhone 上的 Safari 不支持任意 DOM 元素的 Fullscreen API。你在 iPhone 上对canvas调requestFullscreen(),会拿到NotSupportedError或者干脆方法不存在。iPad 的情况稍好,但版本差异大,不能作为基线。Android 上的 Chrome 支持度好一些,但各家定制浏览器的行为五花八门。

所以移动端必须准备第二套方案:伪全屏。

5.1 元素级全屏在移动端的支持差异

先做一个能力探测,别用 UA 判断,那个不可靠:

function isElementFullscreenSupported() { const el = document.documentElement; return !!( el.requestFullscreen || el.webkitRequestFullscreen || el.msRequestFullscreen ); }

在 iPhone Safari 上,这个判断会返回false(或者方法存在但调用即失败)。在 Android Chrome 上返回true。iPad 上分版本,老版本可能返回true但实际行为是把整个页面缩放而不是真全屏。

探测到不支持,就走伪全屏:

function enterPseudoFullscreen(container) { document.body.classList.add('pseudo-fullscreen'); document.body.style.overflow = 'hidden'; document.documentElement.style.setProperty('--app-h', window.innerHeight + 'px'); } function exitPseudoFullscreen() { document.body.classList.remove('pseudo-fullscreen'); document.body.style.overflow = ''; }

配套的 CSS:

body.pseudo-fullscreen #unity-container { position: fixed; inset: 0; width: 100vw; height: var(--app-h, 100vh); z-index: 9999; }

伪全屏不是真的全屏,浏览器地址栏还在,但它做了三件事:把画布铺满可视区、禁掉页面滚动、把其他元素盖住。对游戏来说,视觉体验已经接近真全屏了。而且它有个额外的好处:在 iOS 上不会触发 Safari 的全屏手势冲突,也不会因为退出全屏导致页面重新布局。

5.2 伪全屏:100vh 的坑与 --app-h 变量

移动浏览器的100vh是个大坑。它的计算方式是"最大视口高度",包含了地址栏所占的空间。也就是说,在地址栏显示的状态下,设成100vh的元素会比可见区域高出 60 到 100 像素,底部内容被地址栏挡住。地址栏收起时,又刚好合适。

解决方案有两代。新一代是 CSS 的dvh(dynamic viewport height),按动态视口计算,100dvh就是当前可见高度。支持的浏览器已经不少了,但老版本 Safari 和部分 Android 内核还不认。

老一代的兜底就是用 JS 写一个 CSS 变量:

function syncViewportHeight() { // visualViewport 更准,拿不到就退回 innerHeight const h = window.visualViewport ? window.visualViewport.height : window.innerHeight; document.documentElement.style.setProperty('--app-h', h + 'px'); } syncViewportHeight(); window.addEventListener('resize', syncViewportHeight); window.addEventListener('orientationchange', syncViewportHeight); if (window.visualViewport) { window.visualViewport.addEventListener('resize', syncViewportHeight); }

visualViewport比innerHeight更准,因为它排除了虚拟键盘、地址栏动画中间态这些干扰。地址栏收缩过程会连续触发visualViewport.resize,这时候如果每一帧都去改容器高度,Unity 的画布会跟着抖动。所以实际项目里我一般加一个 100ms 的防抖,等动画稳定了再同步。

提示:orientationchange事件在各家浏览器上的触发时机不一致,有的在旋转完成后触发,有的在旋转开始时触发。所以光靠它不够,必须配合resize。而且旋转完成后,innerHeight需要等一小段时间才能拿到正确值,加个setTimeout(..., 200)更稳。

5.3 横屏、安全区与触控坐标偏移

移动端还有两个问题会连着出现:横屏锁定和安全区。

横屏锁定在 Web 上基本不用指望。screen.orientation.lock('landscape')这个 API 需要页面处于真全屏状态,在 Android Chrome 上部分可用,在 iOS 上完全没用。所以我的做法是:不锁,用 CSS 做视觉提示。检测到竖屏时,在画面上盖一层半透明的提示"请横屏体验",不阻塞游戏逻辑,只做引导。

安全区指的是刘海屏、挖孔屏、底部小横条这些区域。全屏铺满之后,UI 元素如果贴边,很容易被遮挡。CSS 的env(safe-area-inset-*)可以拿到安全区尺寸,前提是页面的meta viewport里带了viewport-fit=cover:

<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">

然后在容器上加内边距:

#unity-container { padding-top: env(safe-area-inset-top, 0); padding-bottom: env(safe-area-inset-bottom, 0); padding-left: env(safe-area-inset-left, 0); padding-right: env(safe-area-inset-right, 0); box-sizing: border-box; }

触控坐标偏移是伪全屏方案下的经典问题。Unity 的输入系统在计算触摸坐标时,依赖canvas.getBoundingClientRect()来把屏幕坐标换算成画布坐标。如果你用 CSS 的transform: scale()去做缩放(有些人为了适配小屏会这么干),getBoundingClientRect返回的是变换后的矩形,但 Unity 内部的换算逻辑不一定把transform算进去,结果就是点 A 位置响应到 B 位置。

我在这上面吃过一次亏:做了个transform: scale(0.8)想省点渲染面积,结果所有按钮的点击位置都往右下偏了一截,排查了两个多小时。结论很简单:别用 transform 做画布缩放,要改就改 CSS 的width/height,或者直接改渲染分辨率。前者的代价是 Unity 需要重新分配 drawingBuffer,但这是正常路径,Unity 处理得很好。

6. 一份排错清单:全屏相关的六个高频故障与定位顺序

功能做完了不代表上线没问题。全屏这个功能的特殊性在于,它的表现和浏览器、设备、嵌入方式强相关,同一段代码在不同环境下结果完全不同。我把自己遇到过的故障整理成一份排查清单,按"从外到内"的顺序排。

6.1 点了没反应:从手势栈往回查

这是最高频的反馈。排查顺序应该是这样:

第一步,确认事件有没有到。在按钮的 click 处理里打一个console.log,看有没有输出。如果没有,说明按钮被其他元素挡住了,这是 CSS 层级问题(典型的pointer-events: none或者 z-index 被盖),跟全屏 API 没关系。

第二步,确认调用有没有抛错。把requestFullscreen()的返回值接住:

requestFullscreen(canvas).catch(err => { console.error('FS failed:', err.name, err.message); });

有报错就按第 3.4 节的表格对照。没报错但也没全屏,那就往下走。

第三步,确认元素还在 DOM 里。这个听起来蠢,但真的常见——SPA 框架路由切换后,旧节点被销毁,你持有的canvas引用指向一个游离节点,对它调requestFullscreen()不会抛错,也不会有任何效果。用canvas.isConnected检查一下。

第四步,确认嵌套层级。如果页面本身在 iframe 里,回到 2.3 节检查allow属性。

这四步走完,95% 的问题能定位。

6.2 进去了但画面拉伸、模糊或留黑边

这三个症状经常被混为一谈,但根因完全不同:

症状根因修法
画面模糊drawingBuffer 小于 CSS 尺寸检查matchWebGLToCanvasSize;限制 DPR 时别压太低
画面拉伸相机 aspect 与画布宽高比不一致相机脚本里按 aspect 调整 FOV 或裁剪
四周黑边画布保持了固定宽高比,容器更大改 canvas 的 CSS 为 100%/100%,或接受黑边做背景装饰

"模糊"这一项特别值得说。很多人第一反应是"是不是抗锯齿没开",去改 Unity 的 Quality 设置,改完发现没用。实际上 WebGL 的画质问题九成出在分辨率匹配上,跟抗锯齿关系不大。判断方法很直接:在浏览器里 F12,用元素选择器量一下 canvas 的 CSS 宽高,再看一下它的width/height属性值,两个数值(乘以 DPR 后)应该大致相等。差得远就是分辨率没匹配上。

留黑边这一项,如果是产品明确要求的(保持画面比例不失真),那就把画布外面的背景做成品牌色或者渐变,别用纯黑,观感会好很多。如果产品要的是"铺满",那就得让相机自适应,接受视野变化。

6.3 退出后错位与输入偏移

退出全屏后画面错位,通常是因为fullscreenchange事件触发时,浏览器的布局还没完成更新。你在事件回调里读innerWidth/innerHeight,拿到的可能还是全屏时的值。

处理办法是延迟一帧再同步:

function onFullscreenChange() { const isFs = !!getFullscreenElement(); applyLayout(isFs); notifyUnity(isFs); // 布局稳定后再做一次尺寸校正 requestAnimationFrame(() => { requestAnimationFrame(() => { syncCanvasSize(); }); }); }

用两次requestAnimationFrame是为了确保浏览器完成了一次完整的布局和绘制。这个技巧在布局跳变场景下很通用。

输入偏移的问题前面提过,主要是transform引起的。如果排除了 transform 还是有偏移,那就要看是不是有 CSS 的zoom属性、页面整体的scale,或者容器有padding但计算 rect 时没考虑。调试手段是在 Unity 里加一个调试输出,把点击时的屏幕坐标和 Unity 收到的坐标都打出来,对比一下差多少,差值是不是固定的。

还有一类隐蔽的偏移:Unity 的输入系统在移动端会做 DPR 换算,如果你的 canvas 尺寸被手动改过而rect没同步,换算就会错。这种情况下的偏移量会随 DPR 变化,不是固定值,可以作为判断依据。

7. 我在几个项目里沉淀下来的做法

写到这里,技术上该说的基本说完了。最后分享几个不那么"技术"但确实影响交付质量的点。

第一,全屏按钮一定要给一个显式的退出入口。用户进了全屏之后,如果不知道按 Esc 能退出,很容易以为页面卡死了。我在画布上方叠一个极简的退出按钮,半透明、3 秒无操作自动淡出但保留热区,用户体验好很多。这个按钮是 HTML 元素,天然在用户手势栈里,退出调用百分百成功。

第二,桌面上做一次全屏,移动端做一次伪全屏,两套代码各走各的,别想着用一套逻辑兼顾。我见过有人试图用一套判断把两种情况都覆盖,结果是在 iPad 上既不真全屏也不伪全屏,卡在中间状态。能力探测之后分流,代码分支清晰,维护也简单。

第三,构建模板最好自己维护一份。每次升级 Unity 版本,官方模板都会变,直接覆盖你的定制会丢东西。我的做法是把模板文件从 Unity 安装目录拷到项目的Assets/WebGLTemplates/下面,起一个自己的名字,在 Player Settings 里选中它。这样引擎升级不会影响你的模板,需要合并新特性时手动对比一下就行。

第四,测试矩阵别偷懒。至少要覆盖:Chrome 桌面、Edge 桌面、Safari 桌面、Android Chrome、iOS Safari、以及一个"页面被 iframe 嵌入"的场景。这六项里面,iOS Safari 和 iframe 这两个最容易出问题,优先级最高。我吃过一次亏:内部测试全在 Chrome 上跑,上线当天收到一堆 iOS 用户的反馈说全屏按钮是死的,当天晚上加班改伪全屏。

第五,把"全屏状态"当成一个一等公民的状态来管理。别在十几个地方各自去读document.fullscreenElement,而是在模块里维护一个单一的状态源,所有需要响应的地方订阅它。这样 F11、Esc、按钮点击、程序调用这几条路径最终都汇到一个地方处理,不会出现某个路径漏了没同步的情况。踩过几次坑之后,我现在所有涉及全屏的项目都是这个结构,改起来心里有底。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 11:09:20

智能家居开关面板UI设计要素与行业趋势

1. 智能家居开关面板设计的行业背景 当前智能家居市场正经历着从单品智能向全屋智能的转型阶段。根据行业调研数据显示&#xff0c;2023年全球智能家居市场规模已突破1500亿美元&#xff0c;其中控制类设备占比达到23%。作为人机交互的重要入口&#xff0c;开关面板的设计质量直…

作者头像 李华
网站建设 2026/10/27 11:08:44

LEACH协议对比分析:从基础原理到Matlab仿真优化

1. 项目概述 在无线传感器网络(WSN)领域&#xff0c;路由协议的设计直接影响着整个网络的能耗效率和生命周期。LEACH&#xff08;Low-Energy Adaptive Clustering Hierarchy&#xff09;作为经典的层次型路由协议&#xff0c;通过分簇机制有效延长了网络生存时间。但原始LEACH协…

作者头像 李华
网站建设 2026/10/27 11:08:10

Magenta音乐生成实战:NoteSequence、预训练模型与序列建模

第一次打开 magenta 的命令行帮助文档时&#xff0c;很多人会有一个相似的困惑&#xff1a;明明只是想用几行 Python 让机器写一段旋律&#xff0c;为什么文档里全是 RNN、VAE、NoteSequence、bundle 这些看起来偏研究的词。magenta 就是这样一个有趣的 Python 库&#xff0c;它…

作者头像 李华
网站建设 2026/10/27 11:07:52

ISO/IEC 42001人工智能管理体系落地:三层结构与风险主线

简介&#xff1a;ISO/IEC 42001:2023 由国际标准化组织与国际电工委员会联合发布&#xff0c;是首版人工智能管理体系标准&#xff0c;面向信息技术安全管理人员、AI研发团队成员、企业决策层与质量管理体系建设者&#xff0c;可用于解决组织在引入或自研AI产品及服务时缺少统一…

作者头像 李华
网站建设 2026/10/27 11:07:06

Linux文件占用问题排查:lsof与fuser实战指南

1. Linux文件占用问题排查全指南 当你在Linux系统中尝试删除或移动文件时&#xff0c;突然遇到"文件正被占用"的提示&#xff0c;这种情况每个Linux用户都遇到过。作为使用Linux系统15年的老鸟&#xff0c;我处理过无数这类问题&#xff0c;今天就把最实用的排查方法…

作者头像 李华
网站建设 2026/10/27 11:06:52

银河麒麟系统DNS解析问题与360浏览器优化指南

1. 问题现象与初步排查银河麒麟操作系统搭配360浏览器出现链接无法访问的情况&#xff0c;通常表现为点击链接后长时间无响应、页面显示"无法解析DNS"或直接报错。根据实际运维经验&#xff0c;这类问题90%以上与DNS配置相关&#xff0c;剩下的可能涉及浏览器设置或系…

作者头像 李华