Skip to main content

Chapter 102: Web Performance

Web Performance 在图形应用里讨论的核心问题,是一帧图像从资源加载、JavaScript 调度、DOM/CSS 渲染、Canvas 提交、GPU 执行到最终呈现之间,哪一段路径占用了帧预算。读者读完本章应能定位浏览器端图形卡顿,判断卡顿来自加载、主线程、布局绘制、WebGL/WebGPU 提交、GPU 执行、内存压力或垃圾回收,并能设计一套可持续监控方案。

本章使用一个贯穿场景:一个在线 3D 产品配置器。页面左侧是 WebGL 或 WebGPU 渲染的模型,右侧是 DOM 表单和材质缩略图列表;用户拖动模型时需要稳定交互,切换材质时需要异步加载贴图、编译 shader、上传 GPU 资源,并把性能数据上报到监控系统。这个场景覆盖了 Web 图形性能中最常见的路径:网络资源、JS 主线程、DOM/CSS、Canvas、GPU 命令、内存与遥测。

浏览器端性能诊断要先把“慢”拆成可测对象。MDN 对 Web performance 的定义同时包含客观指标和用户感知:加载时间、可交互时间、响应速度、滚动和动画流畅度都会影响体验。图形应用还要补上 GPU 维度,因为 Canvas 帧率下降可能来自 shader、纹理采样、draw call、资源上传或 GPU/CPU 同步。

本章最终建立一条检查顺序:先把一帧拆成加载、主线程、浏览器像素管线、图形 API 提交、GPU pass、present 六段;再用 FPS、frame time percentile、Long Animation Frame、Resource Timing、User Timing、GPU timestamp、内存曲线和错误事件把证据对齐;最后按瓶颈类型选择加载隐藏、渲染路径压缩、资源生命周期控制或监控预算。

102.1 浏览器渲染性能瓶颈分析

浏览器渲染性能的第一个判断点,是卡顿发生在普通页面像素管线,还是发生在 Canvas 图形管线。普通页面的视觉更新通常经过 JavaScript、Style、Layout、Paint、Composite;web.dev 的 Rendering performance 把这个路径整理成浏览器开发者可控制的五段。Canvas 图形应用会额外录制 WebGL/WebGPU 命令,由浏览器进程和 GPU 进程调度,再作为一个合成层进入 Composite。配置器场景里,模型旋转慢、按钮点击慢、材质面板滚动慢,三者可能对应三条不同路径。

60Hz 屏幕每秒刷新 60 次,单帧理论窗口约为 16.67ms。web.dev 在同一篇文章中给出的工程判断更严格:浏览器自身也有开销,高压动画路径中应用侧工作常需要压到约 10ms 内。这个数字是诊断起点。它把“帧率下降”从感受变成了预算问题:JS 更新、布局、绘制、Canvas 提交、GPU pass、纹理上传和合成阶段共同竞争一帧时间。

配置器的一帧可以按下图拆解。图中的每个节点都能对应一个可观察证据:Performance 面板的任务和样式布局记录、Resource Timing 的加载条目、图形 API 层的 draw/dispatch、GPU timestamp 的 pass 时间,以及 RUM 上报的帧时间分布。

这张图的关键含义是:Canvas 应用仍然受主线程调度影响。WebGL/WebGPU 能把大量像素计算交给 GPU,但命令生成、资源创建、部分 shader 编译触发点、输入事件、React/Vue 状态更新、DOM 测量、Canvas resize 和遥测逻辑仍可能在主线程上排队。拖动模型时如果主线程被材质面板重排占满,GPU 可能还有空闲,画面仍会延迟呈现。

诊断时先区分四类症状。第一类是 CPU 主线程瓶颈:Performance 录制中出现长任务、事件处理函数耗时高、React commit 或 DOM 操作密集,FPS 和输入响应同时下降。第二类是 DOM/CSS 管线瓶颈:样式计算、Layout、Paint 占比高,通常伴随大量节点、同步测量和写入交替、阴影滤镜或大面积重绘。第三类是图形提交瓶颈:JS 每帧执行大量 draw call、状态切换、buffer 更新、纹理上传或同步查询,CPU frame time 增长。第四类是 GPU 执行瓶颈:GPU pass 时间长,降低分辨率、关掉后处理或减少采样后帧时间明显改善。

Long Animation Frame 是主线程和渲染阶段的现场证据。MDN 的 Long animation frame timing 把超过 50ms 的渲染更新称为 LoAF,并说明 60 FPS 动画的单帧预算约为 16ms。LoAF 条目能给出脚本、渲染开始、样式布局和呈现相关时间,适合在真实用户环境中定位“某些设备上偶发卡顿”的来源。

配置器里一次典型问题可能表现为:模型旋转时 FPS 从 60 降到 28,LoAF 记录显示脚本 42ms,GPU timestamp 显示主渲染 pass 只有 7ms。这个证据组合指向主线程,优化方向应放在事件处理、状态更新、对象分配、材质列表 DOM 更新和上传节流。若证据反过来,主线程只有 4ms,GPU pass 达到 24ms,降低 Canvas 内部渲染分辨率后帧时间回落,就应检查 fragment shader、后处理、overdraw、纹理采样和 render target 带宽。

102.2 资源加载与延迟隐藏技巧

Web 图形应用的加载性能要拆成“首帧可见”和“完整质量达成”两个目标。配置器的首帧需要 HTML、CSS、主 JS、Canvas 初始化、基础模型、低清贴图和默认材质;完整质量还需要高清贴图、环境贴图、可选部件、后处理 shader、压缩纹理解码和用户后续可能点击的材质包。把所有资源放入首屏关键路径,会把白屏时间、可交互时间和首次模型显示时间绑定在一起。

延迟隐藏的基本策略,是让关键路径只承载首个可操作画面,再把高成本资源分批加载、解码、上传和切换。MDN 在 Web Performance 指南中把 lazy loading、speculative loading、resource hints、performance budgets 和 Resource Timing 归为常用性能手段。图形应用使用这些手段时,需要把网络下载时间和 GPU 资源就绪时间分开记录,因为贴图下载完成后还可能发生图片解码、格式转换、mipmap 生成和 GPU upload。

配置器的资源优先级可以按可见性和交互概率组织。默认相机能看到的 mesh、基础材质和低清环境贴图进入启动路径;折叠面板里的材质缩略图使用懒加载;用户即将悬停或已经展开的材质组使用 prefetch;用户明确选择材质后再请求全分辨率贴图和法线贴图。这样做改变的是资源时间线:网络、解码、GPU 上传和 shader 准备被拆到多个帧和多个交互阶段,首帧压力下降。

缓存策略要按资源稳定性划分。版本化 JS、WASM、shader 源码、KTX2/Basis/PNG/JPEG 贴图、glTF mesh 和缩略图都可以使用长缓存文件名;配置 JSON、A/B 实验参数和价格信息使用短缓存或重新验证。Service Worker 适合缓存稳定大资源和离线回访场景,但它本身也需要监控命中率、安装失败、缓存膨胀和版本切换。图形资源缓存命中只能说明网络成本下降,首次 GPU 上传仍可能带来帧尖峰。

贴图加载需要把“可显示”设计成渐进过程。启动时先使用低分辨率 baseColor 或单色材质,模型可旋转后再替换高清贴图;环境反射可以先使用低阶预过滤贴图,再补充高质量 mip 链;较大的纹理上传分摊到多个 requestAnimationFrame 帧。若应用使用 WebGPU,可以把 copy 和渲染 pass 的提交安排在明确的命令边界中;若使用 WebGL,应控制 texImage2DtexSubImage2D 和 mipmap 生成的触发时机。

Shader 和 pipeline 也属于加载路径。WebGL 程序编译、链接和状态校验可能在首次使用时造成停顿;MDN 的 WebGL best practices 建议让 shader 编译和 program link 具备并行机会,并在可用时使用 KHR_parallel_shader_compile 查询完成状态。WebGPU 的 createRenderPipelineAsync 和 pipeline cache 设计也应服务同一个目标:把首次交互前的编译尖峰前移到加载阶段,或在非关键帧中分批完成。

资源加载优化最终要回到证据。配置器应至少记录每类资源的 fetchStartresponseEnd、解码耗时、CPU 转换耗时、GPU 上传帧号、资源首次被 draw 的帧号和失败原因。只看网络瀑布图会漏掉浏览器图形应用中最昂贵的后半段:解码、分配、上传、pipeline 准备和第一次采样。

102.3 WebGL/WebGPU 性能调优

WebGL/WebGPU 调优要先测量 CPU frame time 和 GPU frame time。CPU frame time 覆盖 JS 事件、状态更新、命令录制、资源创建、API 调用和同步等待;GPU frame time 覆盖 render pass、compute pass、纹理采样、blend、depth、后处理和 resolve。二者同时下降时通常是减少了总工作量;只有 CPU 下降时,用户可能仍受 GPU pass 限制;只有 GPU 下降时,主线程长任务可能继续拖慢输入响应。

WebGL 的常见 CPU 陷阱是同步查询。MDN WebGL 最佳实践指出,getErrorgetParametergetShader/ProgramParametercheckFramebufferStatus 等入口可能造成同步停顿或等待图形工作完成。开发阶段可以保留错误检查和状态断言,生产帧循环应把这些查询从热路径移出,改用初始化阶段校验、采样上报、调试构建和上下文错误事件。

Draw call 和状态切换决定了 WebGL/WebGPU 提交层的固定开销。配置器中每个零件、每种材质、每个贴图绑定、每个 pipeline 切换都会增加 CPU 录制和驱动/浏览器验证成本。可复用的压缩顺序是:先按 pass 分组,再按 pipeline/material 排序,再合并静态 mesh 或使用 instancing,最后用纹理图集、数组纹理或 bind group 复用减少绑定变化。这个顺序保持了渲染结果稳定,也能把优化点对齐到 draw count、state change count 和 binding count。

Fragment shader 和带宽决定 GPU 热点。Bloom、SSAO、SSR、色调映射、透明材质和高分辨率阴影会增加 render target 读写、纹理采样和 overdraw。配置器在拖动模型时可以降低后处理质量、降低内部渲染分辨率、暂停昂贵反射更新或切换到低采样模式;交互停止后再恢复质量。MDN WebGL 最佳实践也提到可通过减小 back buffer 换取速度,这对高 DPR 移动设备和集成 GPU 很常见。

上传路径需要单独看。每帧更新大 buffer、创建新 texture、重新 bufferData、从 CPU 生成大 typed array、上传未压缩大图,都会在主线程、浏览器进程、GPU 队列和内存带宽之间制造压力。稳定做法是使用 ring buffer 或 staging buffer,合并小更新,限制每帧上传预算,把可预测资源提前上传,把动态数据布局改成连续写入。WebGPU 中还要区分 queue.writeBuffer、mapped buffer、copy buffer 和 texture upload 的适用场景;WebGL 中要注意 texSubImage2DbufferSubData 与同步点的组合。

GPU timestamp 是判断 GPU pass 成本的直接证据。MDN 的 GPUQuerySet 页面说明,WebGPU 的 query set 可记录 occlusion 或 timestamp 查询,timestamp 查询需要启用 timestamp-query feature。由于该能力存在兼容性边界,应用应先检查 adapter/device feature,再在支持的浏览器中采集 GPU 时间;在缺少 timestamp 的环境里,使用 CPU frame time、分辨率切换实验、pass 开关实验和浏览器开发工具作为替代证据。

下面的示例展示一个最小监控骨架。它的目标是把主线程帧时间、LoAF 和应用自定义阶段打点放到同一条时间线上;真实工程中应补充采样率、隐私字段过滤和上报节流。

const frameStats = {
lastFrameStart: performance.now(),
samples: [],
};

function markStage(name) {
performance.mark(name);
}

function measureStage(name, start, end) {
performance.measure(name, start, end);
}

function renderFrame(now) {
const frameTime = now - frameStats.lastFrameStart;
frameStats.lastFrameStart = now;
frameStats.samples.push(frameTime);

markStage("frame:begin");
updateInputAndScene();
markStage("scene:updated");

encodeGraphicsCommands();
markStage("graphics:encoded");

submitGraphicsCommands();
markStage("frame:end");

measureStage("scene", "frame:begin", "scene:updated");
measureStage("encode", "scene:updated", "graphics:encoded");
measureStage("submit", "graphics:encoded", "frame:end");

requestAnimationFrame(renderFrame);
}

if (PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
reportLongAnimationFrame({
duration: entry.duration,
blockingDuration: entry.blockingDuration,
renderStart: entry.renderStart,
styleAndLayoutStart: entry.styleAndLayoutStart,
});
}
});
observer.observe({ type: "long-animation-frame", buffered: true });
}

requestAnimationFrame(renderFrame);

调优结论需要用对照实验收束。关闭后处理后 GPU pass 下降,说明 shader、采样或 render target 带宽是主因;降低 Canvas 内部尺寸后帧时间下降,说明像素成本占主导;固定模型但继续滚动 DOM 面板仍然卡顿,说明主线程和 DOM/CSS 管线需要处理;暂停资源上传后尖峰消失,说明 upload budget 和解码分配路径需要重排。每一次优化都应对应一个指标变化,指标和管线阶段之间的关系要写入性能回归用例。

102.4 内存与垃圾回收对图形性能的影响

浏览器图形应用的内存由多层组成:JS heap、ArrayBuffer/TypedArray、WebAssembly memory、图片解码后的像素面、WebGL/WebGPU buffer、texture、render target、pipeline/cache,以及浏览器和 GPU 进程内部缓存。配置器中一张 4096×4096 RGBA 贴图的压缩文件可能只有数 MB,解码后像素面约 64 MB,生成 mip 链和上传到 GPU 后还会继续增加显存压力。性能诊断中要把“网络文件大小”和“运行时内存占用”分开。

垃圾回收影响图形性能的路径很直接:渲染循环每帧分配对象、数组、闭包、临时矩阵、材质参数包或遥测对象,JS heap 会持续增长;当 GC 触发时,主线程可能停在对象扫描和回收上,输入事件、命令录制和 DOM 更新都要排队。LoAF 条目中的 pauseDuration、Performance 面板中的 GC 标记、heap 曲线的锯齿、帧时间尖峰的周期性,都是这条路径的证据。

配置器应把热路径分配压到可控范围。矩阵、向量、uniform 数据、实例变换和骨骼矩阵使用预分配 typed array;每帧事件对象只保留必要字段;动画系统复用通道缓存;材质参数变化写入固定结构;上报队列按批次复用缓冲。这样做减少的是主线程分配率和 GC 频率,图像质量本身不会改变。

GPU 资源生命周期也会制造内存压力。WebGL texture、buffer、framebuffer、program 和 WebGPU buffer、texture、pipeline、bind group 被频繁创建后,即使 JS 对象已经失去引用,底层释放也可能延后到浏览器和 GPU 队列安全点。稳定做法是为资源建立显式所有权:资源创建时登记用途、大小、最后使用帧、所属场景或材质;材质切换、模型卸载、标签页隐藏和配置器销毁时执行统一释放;超预算时先降级不可见资源,再处理低优先级高清贴图。

内存预算应按像素规模估算。Canvas 内部尺寸由 CSS 尺寸和 device pixel ratio 共同决定,DPR 从 1 升到 2,render target 像素数量会变成 4 倍;HDR render target、depth texture、MSAA、后处理 ping-pong buffer 都会叠加。移动设备和集成 GPU 还常与系统内存共享,过大的 render target 会同时影响内存带宽、tile 存储、合成和电量。

浏览器提供的内存观测存在边界。performance.memory 主要反映部分 Chromium 环境中的 JS heap,无法代表所有 GPU 资源;navigator.deviceMemory 是粗粒度设备内存提示,适合做资源档位选择,无法当作实时显存。WebGL 可以监听 webglcontextlostwebglcontextrestored;WebGPU 可以处理 device.lost 和 uncaptured error。它们都应进入监控,因为真实用户设备上的资源压力最终可能表现为上下文丢失、设备丢失、纹理创建失败或页面崩溃。

内存问题的排查顺序是:先看 heap 和 ArrayBuffer 曲线是否持续增长,再看帧尖峰是否与 GC 标记重合;再按资源类型统计 texture、buffer、render target、pipeline cache 和图片对象;接着用分辨率、贴图质量、模型数量和后处理开关做分组实验;最后把释放路径和预算策略固化到资源管理层。这个顺序能把“内存大”拆成 JS 分配、解码面、GPU 资源和浏览器缓存四种不同处理对象。

102.5 综合优化方案与性能监控

综合优化方案要从单次排查升级成持续监控。配置器上线后,开发者无法看到每个用户的浏览器、GPU、驱动、网络和标签页状态;因此需要把每次会话中的加载、交互、帧时间、GPU 时间、内存、错误和降级状态采样上报。监控目标是定位退化来源,发现版本回归,验证优化是否覆盖真实设备;单台开发机录屏只能作为实验材料。

监控面应至少包含七组指标。第一组是加载:HTML、JS、WASM、模型、贴图、shader、配置、首帧、首个可交互模型。第二组是帧时间:FPS 只给平均感受,P50、P75、P95、P99 frame time 更适合描述尖峰。第三组是响应:INP、LoAF、输入事件到下一次呈现的间隔。第四组是图形:draw count、triangle count、render target 尺寸、upload bytes、shader/pipeline 编译次数、GPU pass 时间。第五组是内存:heap、ArrayBuffer、估算 GPU 资源、贴图数量、资源释放事件。第六组是加载隐藏:缓存命中、预取命中、低清到高清切换时间。第七组是错误:资源失败、WebGL context lost、WebGPU device lost、validation error、uncaptured error。

这些指标要和设备档位绑定。浏览器、操作系统、GPU/adapter 信息、DPR、Canvas 尺寸、刷新率、网络类型提示、deviceMemory 档位和页面可见性都影响解释。同样 25ms 的 GPU pass,在桌面独显上可能是 shader 回归,在移动设备上可能是默认质量档过高;同样 100ms LoAF,在页面首次加载阶段和模型拖动阶段的含义也不同。监控事件必须带上阶段和场景标签,例如 startupdraggingmaterial_switchidle_uploadtab_hidden_restore

性能预算应写成可执行阈值和降级策略。启动阶段可以设定首个低清模型显示时间、主 bundle 大小、首批贴图总字节;交互阶段可以设定 P95 frame time、LoAF 次数、每帧上传字节、最大 render target 尺寸;资源阶段可以设定贴图内存、模型顶点数、材质变体数量。阈值需要按设备档位分组,低端设备先降 render scale、后处理采样、阴影分辨率和贴图 LOD,高端设备保持视觉质量。

综合优化的执行顺序可以固定为五步。第一步建立基线:同一场景、同一相机、同一资源档、同一交互脚本,采集 CPU frame time、GPU pass、LoAF、内存和加载时间。第二步定位主瓶颈:用阶段证据判断加载、主线程、DOM/CSS、提交、GPU、内存中的主要路径。第三步做单变量实验:只关一个 pass、只降一个尺寸、只暂停上传、只冻结 DOM 更新。第四步把有效改动写入资源管理、渲染管线或调度器。第五步把对应指标加入回归监控,防止后续版本重新引入同类成本。

配置器最终可以形成一条稳定的性能策略:加载阶段先给出低成本可交互画面;交互阶段优先保障输入和模型旋转;空闲阶段补充高清资源和预编译;内存紧张时从不可见资源和高成本后处理开始降级;发生 WebGL/WebGPU 错误时记录上下文并回退到低质量路径或静态预览。这个策略把体验目标、资源生命周期、GPU pass 和监控指标统一到同一个 frame budget 中。

最小自检任务

一个在线 3D 配置器在桌面浏览器中启动后可以稳定 60 FPS。用户连续拖动模型并快速切换三种材质后,画面每隔几秒出现一次 80ms 左右的帧尖峰。采集结果如下:LoAF 中脚本时间约 35ms,样式布局约 6ms,GPU 主渲染 pass 约 7ms,纹理上传帧中 submit 阶段明显变长,heap 曲线每分钟增长约 20 MB,Resource Timing 显示高清贴图下载耗时 2s 以上。请判断主要瓶颈,并给出排查和优化顺序。

答案要点

主要瓶颈应先定位到主线程分配、资源上传和加载调度,当前证据显示 GPU shader 只承担较小成本。依据是 GPU pass 只有约 7ms,远低于 80ms 帧尖峰;LoAF 显示脚本时间高,heap 持续增长,说明渲染循环、材质切换或遥测逻辑存在持续分配;上传帧中 submit 变长,说明高清贴图解码、转换和 GPU upload 正在压缩交互帧预算;贴图下载 2s 以上说明资源切换还受到网络和缓存策略影响。

排查顺序应从可观察证据开始。先在 Performance 面板和自定义 mark 中把材质切换拆成请求、解码、CPU 转换、GPU 上传、pipeline 准备和首次 draw;再检查拖动期间是否每帧创建矩阵、数组、材质对象、事件对象或上报对象;接着统计每帧上传字节、texture 创建数量、buffer 更新量和 shader/pipeline 编译次数;随后把高清贴图上传分批安排到交互空闲帧,并使用低清材质先完成可见切换;最后按设备档位设置贴图 LOD、render scale、上传预算和缓存策略。

核心优化动作包括:复用 typed array 和临时对象,减少热路径分配;材质切换先绑定低清贴图,再异步补齐高清贴图和 mip 链;把每帧上传字节设为预算项;预取用户已经展开材质组中的候选贴图;把 shader/pipeline 准备从首次点击路径移到加载或空闲阶段;把 LoAF、heap 增长、upload bytes、GPU pass 和资源加载耗时一起上报。完成后需要重新采集同一交互脚本,确认 P95/P99 frame time 下降,LoAF 数量减少,heap 增长停止,GPU pass 仍保持在预算内。

本章知识点总结

  • 帧预算:Web 图形性能要把一帧拆成加载、主线程、DOM/CSS、图形提交、GPU pass 和合成呈现共同判断。
  • 像素管线:JavaScript、Style、Layout、Paint、Composite 是普通页面视觉更新的核心路径,Canvas 图形层仍会进入合成阶段。
  • 瓶颈分类:CPU 主线程、DOM/CSS、图形提交、GPU 执行、资源加载和内存压力需要使用不同证据定位。
  • LoAF 证据:Long Animation Frame 能记录超过 50ms 的渲染更新,并帮助定位脚本、渲染和样式布局贡献。
  • 加载分层:首帧可见和完整质量达成应拆成不同目标,低清资源、懒加载、预取和缓存共同压缩启动压力。
  • 上传预算:贴图下载完成后还会发生解码、转换、mipmap 和 GPU upload,这些阶段需要独立记录和分批调度。
  • 同步查询:WebGL 的状态和错误查询可能造成同步停顿,生产热路径应把这些查询移出帧循环。
  • 提交成本:draw call、状态切换、绑定变化和资源更新决定 CPU 录制与浏览器验证压力。
  • GPU 证据:WebGPU timestamp query 可在支持环境中测量 GPU pass,缺少该能力时应使用开关实验和分辨率实验辅助判断。
  • GC 路径:渲染循环中的持续对象分配会推高 JS heap,并通过垃圾回收形成周期性帧尖峰。
  • 资源所有权:texture、buffer、render target 和 pipeline cache 需要显式登记用途、大小、生命周期和释放路径。
  • 监控闭环:FPS、frame time percentile、LoAF、Resource Timing、GPU timing、memory 和错误事件需要一起进入 RUM 与回归测试。
  • 设备档位:同一帧时间在不同 GPU、DPR、刷新率和内存档位上含义不同,性能预算应按设备能力分组。
  • 优化顺序:建立基线、定位主瓶颈、做单变量实验、固化有效改动、加入回归监控,是 Web 图形优化的稳定流程。