4.2 移动端与低端设备
上一节解决了「用哪个后端」,这一节解决「画成多大、画多细」。桌面独显可以无脑全开,移动端和低端设备不行——同样的场景,一台旗舰手机和一台四年前的集显笔记本能承受的像素量差一个数量级。工程上不能靠一句「移动端降画质」交差,得把「降什么、降多少」变成可计算的数字,让每一档都有明确的分辨率、阴影尺寸与视距。
移动端的三个硬约束
分辨率。移动端的 CSS 尺寸不大,但 devicePixelRatio 常见 2~3。1280×720 的 CSS 画布在 DPR=3 下如果按物理像素渲染,就是 3840×2160——像素量是 CSS 尺寸的 9 倍。但超过 DPR=2 之后,多出来的像素在肉眼上几乎不可见,纯粹是白烧带宽。所以第一件事是夹住 DPR。
带宽。移动 GPU 与内存共享总线(UMA),每帧读写 render target 的带宽是实打实的电量与热量。分辨率降一半,像素量降到四分之一,带宽同步降。这是低端设备上最立竿见影的一刀,也是为什么档位设计里最重的旋钮是 renderScale 而不是「关掉某个后处理」。
显存。一张 2560×1440 的 RGBA16F 颜色附件是 28.125 MiB(每像素 8 字节)。阴影贴图、G-Buffer、后处理链各占几张,低端设备的总预算可能只有几百 MiB。超了就 OOM 崩掉,不是「卡一点」。显存这条线是硬的:宁可少一张附件,也不能赌它不崩。
三条约束指向同一个旋钮:渲染分辨率。质量档位本质上就是「给不同设备一个不同的分辨率缩放系数」,再附带一组配套的阴影尺寸与视距。
质量档位决策表
helio 只做三档——再多会带来组合爆炸,也让真机验证的机型矩阵失控。三档的差别不只是分辨率,还有阴影贴图尺寸与后处理开关:
| 档位 | renderScale | 阴影贴图 | 视距 | 后处理 | 目标设备 |
|---|---|---|---|---|---|
| low | 0.5 | 512 | 40 | 关 | 低端手机、老集显 |
| medium | 0.75 | 1024 | 80 | 开 | 主流手机、轻薄本 |
| high | 1.0 | 2048 | 160 | 开 | 桌面独显、旗舰机 |
档位的判据顺序有讲究:显存与纹理上限是硬线(低于就必崩或必黑),放在最前;移动端是软线(能跑但要省电省热),放在后面。低端手机可能同时命中两条,落在 low。这套判据写成代码就是下面这个模块。
可运行产物:设备画像到质量档位
src/render/app/quality-tier.ts 层级 L1,完整可编译。它把设备画像(是否移动端、DPR、纹理上限、显存预算)翻译成三档画质,并给出分辨率缩放、显存占用估算与运行时降档。全程不碰 GPU。
// file: src/render/app/quality-tier.ts
// tier: L1
// verify: npx vitest run src/render/app/quality-tier.test.ts
//
// 跨端质量档位:把「设备画像」翻译成三档画质,并给出分辨率缩放、
// 显存占用估算与运行时降档。全部纯计算,可在 Node 里单测。
// 真机帧率是否达标属 L4,命令行不可复现。
export interface DeviceProfile {
readonly isMobile: boolean;
readonly devicePixelRatio: number;
readonly maxTextureDimension2D: number;
readonly memoryBudgetMiB: number;
}
export const QualityTier = { Low: 'low', Medium: 'medium', High: 'high' } as const;
export type QualityTierName = (typeof QualityTier)[keyof typeof QualityTier];
export interface QualitySettings {
readonly tier: QualityTierName;
readonly renderScale: number;
readonly shadowMapSize: number;
readonly viewDistance: number;
readonly postFx: boolean;
}
/** 档位决策表:先看显存与纹理上限这条硬线,再看移动端这条软线。 */
const TIER_TABLE: Readonly<Record<QualityTierName, Omit<QualitySettings, 'tier'>>> = {
low: { renderScale: 0.5, shadowMapSize: 512, viewDistance: 40, postFx: false },
medium: { renderScale: 0.75, shadowMapSize: 1024, viewDistance: 80, postFx: true },
high: { renderScale: 1.0, shadowMapSize: 2048, viewDistance: 160, postFx: true },
};
export function pickQualityTier(profile: DeviceProfile): QualityTierName {
if (profile.memoryBudgetMiB < 512 || profile.maxTextureDimension2D < 4096) return QualityTier.Low;
if (profile.isMobile || profile.memoryBudgetMiB < 2048) return QualityTier.Medium;
return QualityTier.High;
}
export function settingsFor(tier: QualityTierName): QualitySettings {
return { tier, ...TIER_TABLE[tier] };
}
/** DPR 超过 2 之后像素成本翻倍、肉眼收益为零,直接夹住。 */
const DPR_CEILING = 2;
/** 按 renderScale × DPR 算出实际渲染分辨率,再夹到设备纹理上限。 */
export function renderSize(
settings: QualitySettings,
cssWidth: number,
cssHeight: number,
dpr: number,
maxTextureDimension2D: number,
): { readonly width: number; readonly height: number } {
const scale = settings.renderScale * Math.min(dpr, DPR_CEILING);
let width = Math.round(cssWidth * scale);
let height = Math.round(cssHeight * scale);
if (width > maxTextureDimension2D || height > maxTextureDimension2D) {
const k = Math.min(maxTextureDimension2D / width, maxTextureDimension2D / height);
width = Math.floor(width * k);
height = Math.floor(height * k);
}
return { width, height };
}
/** 一张颜色附件的显存占用(MiB);默认 RGBA16F 每像素 8 字节。 */
export function renderTargetMiB(width: number, height: number, bytesPerPixel = 8): number {
return (width * height * bytesPerPixel) / (1024 * 1024);
}
const ORDER: readonly QualityTierName[] = ['high', 'medium', 'low'];
/** 一帧超预算就降一档;已在最低档则原样返回。 */
export function downgradeUnderPressure(
settings: QualitySettings,
frameMs: number,
budgetMs: number,
): QualitySettings {
if (frameMs <= budgetMs) return settings;
const i = ORDER.indexOf(settings.tier);
const next = ORDER[Math.min(i + 1, ORDER.length - 1)] as QualityTierName;
return settingsFor(next);
}
renderSize 里有两次夹取,顺序不能反:先算 renderScale × DPR 的期望尺寸,再对纹理上限做等比缩放。如果反过来先夹 DPR 再乘 renderScale,低端设备上可能算出一个超过硬件上限的尺寸,到创建纹理时才报错。
配套单测:把「四分之一」钉死成断言
src/render/app/quality-tier.test.ts 层级 L1,完整落盘。它把上面那句「分辨率减半、带宽降到四分之一」变成两条可执行的断言:
// file: src/render/app/quality-tier.test.ts
// tier: L1
// verify: npx vitest run src/render/app/quality-tier.test.ts
import { describe, expect, it } from 'vitest';
import {
downgradeUnderPressure,
pickQualityTier,
renderSize,
renderTargetMiB,
settingsFor,
} from './quality-tier.ts';
describe('quality-tier', () => {
it('低端手机落 low,桌面独显落 high', () => {
expect(pickQualityTier({ isMobile: true, devicePixelRatio: 2, maxTextureDimension2D: 4096, memoryBudgetMiB: 256 })).toBe('low');
expect(pickQualityTier({ isMobile: false, devicePixelRatio: 2, maxTextureDimension2D: 16384, memoryBudgetMiB: 4096 })).toBe('high');
});
it('显存充足但仍标移动端时落 medium', () => {
expect(pickQualityTier({ isMobile: true, devicePixelRatio: 3, maxTextureDimension2D: 8192, memoryBudgetMiB: 4096 })).toBe('medium');
});
it('高画质 DPR=2 下 1280x720 渲染成 2560x1440', () => {
const size = renderSize(settingsFor('high'), 1280, 720, 2, 8192);
expect(size).toEqual({ width: 2560, height: 1440 });
expect(renderTargetMiB(size.width, size.height)).toBeCloseTo(28.125, 5);
});
it('低画质 renderScale=0.5 把 DPR=3 压回 1.0 倍', () => {
const size = renderSize(settingsFor('low'), 1280, 720, 3, 8192);
expect(size).toEqual({ width: 1280, height: 720 });
expect(renderTargetMiB(size.width, size.height)).toBeCloseTo(7.03125, 5);
});
it('超过纹理上限时等比缩到上限', () => {
expect(renderSize(settingsFor('high'), 4096, 4096, 1, 2048)).toEqual({ width: 2048, height: 2048 });
});
it('超预算时降一档,已在 low 则不变', () => {
const high = settingsFor('high');
expect(downgradeUnderPressure(high, 20, 16.7).tier).toBe('medium');
expect(downgradeUnderPressure(high, 16, 16.7).tier).toBe('high');
expect(downgradeUnderPressure(settingsFor('low'), 99, 16.7).tier).toBe('low');
});
});
本机实跑:npx vitest run src/render/app/quality-tier.test.ts → 6 passed,npx tsc --noEmit 零错误。同一个 1280×720 的 CSS 画布:高画质 DPR=2 渲染成 2560×1440、单附件 28.125 MiB;低画质 DPR=3 被压回 1280×720、7.03125 MiB——正好是四分之一,这就是「分辨率减半、带宽降到四分之一」在数字上的样子。
运行时降档:一帧超预算就退一档
静态档位只是起点。设备在热起来之后会主动降频,同一台手机前五分钟和后五分钟能承受的负载不同。所以还需要一条运行时规则:帧时间超预算就退一档。downgradeUnderPressure 把这条规则也做成纯函数——frameMs 进、新的 QualitySettings 出,16.7ms(60fps 预算)超了就 high → medium,已在 low 则原样返回,避免反复抖动。
这里刻意不做「升档」:升档要靠连续多帧低于预算才敢做,而且升上去很可能立刻又超预算,来回抖。工程上先只做单向降档,把「稳」放在「好」前面——真机上的动态升档策略需要实测数据支撑,属于 L4。调用方只需要在每帧结束后把 frameMs 和预算喂进来,拿到新设置后按需重建 render target。
真实设备画像(L2):从浏览器读回档位
设备画像从哪来?不是硬编码,而是启动时从浏览器读。src/render/app/device-profile-l2.ts 层级 L2,读回 UA、DPR、WebGL2 的 MAX_TEXTURE_SIZE 与 navigator.deviceMemory,拼成 DeviceProfile,再走一遍 pickQualityTier → renderSize,把「探测 → 档位 → 分辨率」整条链在真浏览器里跑通。
// file: src/render/app/device-profile-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/app/device-profile-l2.ts
//
// 从浏览器读回真实设备画像(UA、DPR、WebGL2 MAX_TEXTURE_SIZE、deviceMemory),
// 喂给 quality-tier 的 pickQualityTier,断言「探测 → 档位 → 分辨率」这条链
// 在 headless Chromium 上闭合。真实机型的帧率属 L4,不在这里。
import {
pickQualityTier, renderSize, settingsFor, type DeviceProfile,
} from './quality-tier.ts';
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result {
readonly checks: L2Check[];
readonly data: Record<string, unknown>;
}
export async function runL2(): Promise<L2Result> {
const checks: L2Check[] = [];
const data: Record<string, unknown> = {};
const push = (name: string, ok: boolean, detail: unknown): void => {
checks.push({ name, ok, detail: String(detail) });
};
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2');
const maxTex = gl ? (gl.getParameter(gl.MAX_TEXTURE_SIZE) as number) : 0;
const nav = navigator as Navigator & { readonly deviceMemory?: number };
const dpr = window.devicePixelRatio || 1;
const profile: DeviceProfile = {
isMobile: /Android|iPhone|iPad/i.test(navigator.userAgent),
devicePixelRatio: dpr,
maxTextureDimension2D: maxTex,
memoryBudgetMiB: (nav.deviceMemory ?? 4) * 1024,
};
data.profile = profile;
const tier = pickQualityTier(profile);
const settings = settingsFor(tier);
const size = renderSize(settings, 1280, 720, dpr, maxTex || 8192);
data.tier = tier;
data.settings = settings;
data.size = size;
push('profile.dpr>=1', dpr >= 1, dpr);
push('profile.maxTexture>=4096', maxTex >= 4096, maxTex);
push('tier in {low,medium,high}', ['low', 'medium', 'high'].includes(tier), tier);
push('size<=maxTexture', size.width <= maxTex && size.height <= maxTex, `${size.width}x${size.height}`);
push('settings.tier===tier', settings.tier === tier, settings.tier);
return { checks, data };
}
本机实跑 node tools/run-l2.mjs src/render/app/device-profile-l2.ts:5 checks ALL PASS。headless Chromium 实测 isMobile=false、devicePixelRatio=1、MAX_TEXTURE_SIZE=8192、deviceMemory 折合 32768 MiB,判出 tier=high,renderSize 给出 1280×720。
L4:真机帧率、发热与内存压力
上面的决策表能保证「配置算得对」,但算得对的配置在真机上是否真的达到目标帧率,命令行给不出答案。低端设备的实际 GPU 吞吐、长时间运行后的热降频曲线、系统在内存紧张时的杀进程行为,都依赖具体机型和系统版本。
本节需在真实设备 / 浏览器中手动验证,命令行不可复现:需要在至少一台低端 Android 与一台低端 iOS 真机上,用 chrome://tracing / Xcode Instruments 采样,确认 low 档在目标机型上能稳定跑满 30fps,并观察十分钟后是否因发热掉帧。L1 只负责把档位策略算对,把「档位是否够用」这个判断留给真机数据。
小结
- 移动端的三条硬约束——分辨率、带宽、显存——最终都收敛到渲染分辨率这一个旋钮。
- 档位决策表先看显存/纹理上限(硬线)再看移动端(软线);
renderScale与 DPR 上限共同决定实际分辨率,两次夹取的顺序不能反。 - 运行时只做单向降档,把「稳」放在「好」前面;升档策略需要真机数据,属 L4。
- 真机帧率、发热与内存压力属 L4,命令行不可复现。
下一节离开浏览器,进入桌面打包:Electron 与 Tauri 两种壳怎么共用同一份宿主接口,以及可选原生路径的边界。
阅读导航:上一节:4.1 WebGPU / WebGL2 能力探测与降级 · 下一节:4.3 桌面打包与可选原生路径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。