14.2 帧率与卡顿定位

把一次运行拆成稳定帧与长帧两种:长帧占比极小时,平均帧耗时几乎不动,卡顿感知却直线下滑。用整数模型算出长帧频率、有效帧率与感知评分,再用 B 基准扫长帧频率,看评分在哪一档跌破软底线——全程显式声明这是解析模型而非真机实测,避免被平均值误导。

站内已有一整卷 《Cocos 游戏开发·高级》 ——那 34 篇讲的是 Cocos Creator 引擎内部与定制:怎么改渲染管线、怎么接原生、怎么做引擎级优化。本章讲的是与引擎无关的性能分析方法:首包体积 → 启动耗时的分解模型、卡顿的归因路径、内存峰值与包体的取舍——方法可迁移到任何引擎。那卷教你「在 Cocos 里用 profiler」,本章教你「不看 profiler 也能先把卡顿拆成可算的量」。

上一节把冷启动拆成四段,回答了「第一次打开为什么慢」。这一节处理进游戏之后的持续问题:帧率与卡顿。这里有个反直觉的坑——用「平均帧耗时」衡量流畅度,几乎一定会漏掉真正让玩家烦躁的东西。

卡顿不是平均帧耗时的问题

设想一次 60fps 的运行:绝大多数帧都很快,但每隔一会儿冒出一帧明显拖长。如果只看平均值,那几帧长帧会被大量稳定帧「稀释」掉,平均帧耗时看起来几乎没变。但玩家的主观感受完全由那几帧决定——一次 48ms 的长帧,在 60fps 的节奏里相当于卡了约 3 帧,眼睛能清楚看到顿挫。

所以定位卡顿的第一步,是把「帧耗时」从单个数拆成分布:稳定帧多快、长帧多长、长帧出现的频率是多少。频率才是关键变量,平均值不是。

两种帧与三个量

本节模型把一次运行简化成两种帧:绝大多数是 stableFrameMs 的稳定帧,少数是 jankFrameMs 的长帧,长帧占比由 jankPerMille(千分比)给定。由此算出三个量(全整数):

量公式含义
长帧数 longFramesrunFrames × jankPerMille ÷ 1000整场运行里长帧有几帧
平均帧耗时 avgFrameMs(stable×(1000−j) + jank×j) ÷ 1000被稀释后的均值
感知评分 scorebaseScore − jankPerMille × penaltyPerMille直接由长帧频率决定

再加一个 effectiveFps = 1000 ÷ avgFrameMs 的整数倒数,作为「名义帧率」。

⚠️ 老规矩:这是解析模型,不是真机实测。稳定帧 / 长帧耗时与长帧占比都是模型假设,用来比较「平均值」与「长帧频率」谁更能反映卡顿,绝对数值不可移植。真实卡顿还受 GC、纹理上传、网络回调的影响,本模型把它们统统折进「长帧比例」这一个参数里。

平均值会骗人

拿三个档位跑一遍:优化后(长帧 6‰)、中等(40‰)、优化前(120‰):

档位长帧占比长帧数平均帧耗时名义帧率感知评分
优化后6‰10812ms83964
中等40‰72013ms76760
优化前120‰216016ms62280

从「优化后」到「优化前」,平均帧耗时只从 12ms 涨到 16ms(涨了 333‰),看着还在可接受范围;但感知评分从 964 掉到 280(跌了 709‰),也就是玩家感受层面已经是两个游戏。这就是「平均值陷阱」:均值被大量稳定帧压住,长帧频率却在快速上涨。优化卡顿时该盯的是长帧频率,不是平均帧耗时。

先看配置

下面这段是 D 层配置 config/e14_jank.json:帧预算、运行帧数、两种帧的耗时、三个档位的长帧占比,以及感知评分的参数。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "帧耗时分布的解析模型(不是真机实测)。稳定帧/长帧耗时与长帧比例都是模型假设,用来比较「平均帧耗时」与「长帧频率」哪个更能反映卡顿感知,绝对数值不可移植。全整数毫秒/千分比,禁浮点。",
  "fps": 60,
  "frameBudgetMs": 16,
  "runFrames": 18000,
  "stableFrameMs": 12,
  "jankFrameMs": 48,
  "profiles": [
    {"id": "tuned", "label": "优化后", "jankPerMille": 6},
    {"id": "mid", "label": "中等", "jankPerMille": 40},
    {"id": "raw", "label": "优化前", "jankPerMille": 120}
  ],
  "perception": {"baseScore": 1000, "penaltyPerMille": 6, "minScore": 0, "softScoreFloor": 600},
  "benchScanPerMille": [2, 5, 10, 20, 40, 80, 160]
}

penaltyPerMille=6 的含义是「每 1‰ 的长帧扣 6 分」——这是一条主观标定,不同品类的容忍度不同:休闲合成游戏对顿挫的容忍度低,动作游戏更低。它不该被当成客观常数,而应作为可调参数。

S 层:帧耗时分布模型

下面这段是 S 层脚本 sim/e14_jank.py:读配置,对三个档位算长帧数、平均帧耗时、名义帧率与感知评分,并对比「均值涨幅」与「评分跌幅」,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/e14_jank.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e14_jank.py --config config/ --out out/
"""帧耗时分布模型:稳定帧 vs 长帧 → 卡顿感知评分。

⚠️ 这是**解析模型,不是真机实测**。它把一次运行拆成两种帧:绝大多数是 stableFrameMs 的稳定帧,
少数是 jankFrameMs 的长帧,长帧占比由 jankPerMille(千分比)给定。核心结论是:
  - 平均帧耗时几乎被长帧「稀释」掉(长帧占比小,均值变化不大);
  - 但长帧频率直接决定卡顿感知:score = baseScore − jankPerMille × penaltyPerMille。
各段系数都是**模型假设**,用来比较「平均值」与「长帧频率」谁更能反映卡顿,绝对数值不可移植。

产出:out/e14_jank.json + out/e14_jank_profiles.csv + out/e14_jank.svg
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def load(cfg_dir):
    with open(os.path.join(cfg_dir, "e14_jank.json"), encoding="utf-8") as f:
        return json.load(f)


def profile_stats(jank_per_mille, cfg):
    """一个长帧占比档位的整数统计量。全整数,无浮点。"""
    j = jank_per_mille
    run = cfg["runFrames"]
    stable, jank = cfg["stableFrameMs"], cfg["jankFrameMs"]
    long_frames = run * j // 1000
    avg_ms = (stable * (1000 - j) + jank * j) // 1000
    eff_fps = 1000 // avg_ms if avg_ms else 0
    p = cfg["perception"]
    score = max(p["minScore"], p["baseScore"] - j * p["penaltyPerMille"])
    return {"jankPerMille": j, "longFrames": long_frames,
            "longPerMinute": cfg["fps"] * 60 * j // 1000,
            "avgFrameMs": avg_ms, "effectiveFps": eff_fps,
            "worstFrameMs": jank, "score": score}


def draw_svg(path, rows):
    """纯 Python SVG:每个档位一根横条(感知评分 0–1000),右侧标长帧频率与平均帧耗时。"""
    W, PAD_L, PAD_R, ROW = 780, 170, 260, 56
    H = 92 + ROW * len(rows)
    bar_max = W - PAD_L - PAD_R
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "30", "font-size": "15", "fill": "#111827"}
                  ).text = "长帧频率 → 感知卡顿评分(解析模型,非真机实测)"
    ET.SubElement(svg, "text", {"x": "16", "y": "50", "font-size": "11", "fill": "#9ca3af"}
                  ).text = "评分 0–1000;长帧 48ms(约 3 帧),稳定帧 12ms"
    for i, r in enumerate(rows):
        y = 72 + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 24), "font-size": "12", "fill": "#374151"}
                      ).text = f'{r["label"]}'
        w = max(3, (bar_max * r["score"]) // 1000)
        color = "#16a34a" if r["score"] >= 800 else ("#f59e0b" if r["score"] >= 500 else "#dc2626")
        ET.SubElement(svg, "rect", {"x": str(PAD_L), "y": str(y + 8), "width": str(w), "height": "26",
                                    "rx": "4", "fill": color})
        ET.SubElement(svg, "text", {"x": str(PAD_L + 6), "y": str(y + 26), "font-size": "12",
                                    "fill": "#ffffff"}).text = str(r["score"])
        ET.SubElement(svg, "text", {"x": str(W - PAD_R + 8), "y": str(y + 26), "font-size": "12",
                                    "fill": "#6b7280"}
                      ).text = f'{r["jankPerMille"]}‰ · 均{r["avgFrameMs"]}ms · {r["longPerMinute"]}长帧/分'
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config)
    rows = []
    for prof in cfg["profiles"]:
        st = profile_stats(prof["jankPerMille"], cfg)
        st["id"], st["label"] = prof["id"], prof["label"]
        rows.append(st)

    # 「平均值陷阱」:从最优化到最差档,平均帧耗时涨幅 vs 感知评分跌幅
    first, last = rows[0], rows[-1]
    avg_rise_per_mille = ((last["avgFrameMs"] - first["avgFrameMs"]) * 1000) // max(1, first["avgFrameMs"])
    score_drop_per_mille = ((first["score"] - last["score"]) * 1000) // max(1, first["score"])

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "e14_jank.svg"), rows)
    report = {
        "version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
        "model": "frame-time distribution analytic model (NOT a device measurement)",
        "fps": cfg["fps"], "frameBudgetMs": cfg["frameBudgetMs"], "runFrames": cfg["runFrames"],
        "stableFrameMs": cfg["stableFrameMs"], "jankFrameMs": cfg["jankFrameMs"],
        "perception": cfg["perception"], "profiles": rows,
        "avgFrameMsRisePerMille": avg_rise_per_mille,
        "perceptionScoreDropPerMille": score_drop_per_mille,
        "csv": os.path.join(args.out, "e14_jank_profiles.csv"),
        "svg": os.path.join(args.out, "e14_jank.svg"), "svgBytes": svg_bytes,
    }
    with open(os.path.join(args.out, "e14_jank.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "e14_jank_profiles.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "label", "jankPerMille", "longFrames", "longPerMinute",
                    "avgFrameMs", "effectiveFps", "worstFrameMs", "score"])
        for r in rows:
            w.writerow([r["id"], r["label"], r["jankPerMille"], r["longFrames"], r["longPerMinute"],
                        r["avgFrameMs"], r["effectiveFps"], r["worstFrameMs"], r["score"]])

    for r in rows:
        print(f'{r["id"]:>6} {r["label"]:>5} jank={r["jankPerMille"]:>3}‰ long={r["longFrames"]:>5} '
              f'long/min={r["longPerMinute"]:>3} avg={r["avgFrameMs"]}ms effFps={r["effectiveFps"]} '
              f'score={r["score"]}')
    print(json.dumps({"avgFrameMsRisePerMille": avg_rise_per_mille,
                      "perceptionScoreDropPerMille": score_drop_per_mille,
                      "tunedScore": first["score"], "rawScore": last["score"],
                      "tunedAvgMs": first["avgFrameMs"], "rawAvgMs": last["avgFrameMs"],
                      "svgBytes": svg_bytes}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑输出如下(同机相对量级,绝对数值不可移植):

$ /opt/homebrew/bin/python3 sim/e14_jank.py --config config/ --out out/
 tuned   优化后 jank=  6‰ long=  108 long/min= 21 avg=12ms effFps=83 score=964
   mid    中等 jank= 40‰ long=  720 long/min=144 avg=13ms effFps=76 score=760
   raw   优化前 jank=120‰ long= 2160 long/min=432 avg=16ms effFps=62 score=280
{"avgFrameMsRisePerMille": 333, "perceptionScoreDropPerMille": 709, "tunedScore": 964, "rawScore": 280, "tunedAvgMs": 12, "rawAvgMs": 16, "svgBytes": 1570}

注意 avgFrameMsRisePerMille=333(均值只涨 333‰)与 perceptionScoreDropPerMille=709(评分跌 709‰)的对比——这正是「平均值陷阱」的量化:感知评分的相对跌幅(709‰)明显大于平均帧耗时的相对涨幅(333‰)。

B 层:长帧频率 → 感知评分

上面只看了三个离散档位。真实调优过程中,长帧比例是个连续变化的目标。下面这段是 B 层脚本 sim/e14_jank_bench.py:扫一串长帧占比,算每档的感知评分与长帧 / 分钟,评分低于 softScoreFloor 的档位给软告警。关键观察是:平均帧耗时几乎不动,评分却单调下滑。

#!/usr/bin/env python3
# file: sim/e14_jank_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/e14_jank_bench.py --config config/ --out out/
"""B 基准:扫「长帧出现频率 → 感知卡顿评分」(软告警,不硬失败)。

复用 sim/e14_jank.py 的模型假设,扫 benchScanPerMille 各档长帧占比,算每档的感知评分、
长帧/分钟与平均帧耗时。评分低于 softScoreFloor 的档位给软告警——只是模型内的提示,不是真机结论。
关键观察:平均帧耗时在这些档位间几乎不动,评分却单调下滑,说明「平均值」会掩盖卡顿。

产出:out/e14_jank_bench.json + out/e14_jank_bench.csv
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from e14_jank import load, profile_stats  # noqa: E402


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config)
    floor = cfg["perception"]["softScoreFloor"]

    rows, warns = [], []
    for j in cfg["benchScanPerMille"]:
        st = profile_stats(j, cfg)
        warn = st["score"] < floor
        st["warn"] = warn
        rows.append(st)
        if warn:
            warns.append(f"长帧 {j}‰ → 感知评分 {st['score']} 低于软底线 {floor}")

    avg_lo = min(r["avgFrameMs"] for r in rows)
    avg_hi = max(r["avgFrameMs"] for r in rows)
    os.makedirs(args.out, exist_ok=True)
    report = {"source": "sim/e14_jank_bench.py", "tier": "B",
              "model": "frame-time distribution analytic model (NOT a device measurement)",
              "softScoreFloor": floor, "rows": rows, "warnings": warns, "ok": not warns,
              "avgFrameMsRange": {"min": avg_lo, "max": avg_hi, "spread": avg_hi - avg_lo},
              "scoreRange": {"max": max(r["score"] for r in rows), "min": min(r["score"] for r in rows)},
              "csv": os.path.join(args.out, "e14_jank_bench.csv")}
    with open(os.path.join(args.out, "e14_jank_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "e14_jank_bench.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["jankPerMille", "longPerMinute", "avgFrameMs", "effectiveFps", "score", "warn"])
        for r in rows:
            w.writerow([r["jankPerMille"], r["longPerMinute"], r["avgFrameMs"],
                        r["effectiveFps"], r["score"], str(r["warn"]).lower()])

    for r in rows:
        print(f'jank={r["jankPerMille"]:>3}‰ long/min={r["longPerMinute"]:>3} avg={r["avgFrameMs"]}ms '
              f'score={r["score"]:>4} {"WARN" if r["warn"] else ""}')
    for wmsg in warns:
        print(f"  WARN {wmsg}")
    print(f'平均帧耗时区间 {avg_lo}–{avg_hi}ms(仅差 {avg_hi - avg_lo}ms),评分区间 '
          f'{report["scoreRange"]["min"]}–{report["scoreRange"]["max"]}')
    print(f"== 卡顿感知基准:{'OK' if report['ok'] else 'WARN'}(软告警不硬失败)")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑输出如下:

$ /opt/homebrew/bin/python3 sim/e14_jank_bench.py --config config/ --out out/
jank=  2‰ long/min=  7 avg=12ms score= 988
jank=  5‰ long/min= 18 avg=12ms score= 970
jank= 10‰ long/min= 36 avg=12ms score= 940
jank= 20‰ long/min= 72 avg=12ms score= 880
jank= 40‰ long/min=144 avg=13ms score= 760
jank= 80‰ long/min=288 avg=14ms score= 520 WARN
jank=160‰ long/min=576 avg=17ms score=  40 WARN
  WARN 长帧 80‰ → 感知评分 520 低于软底线 600
  WARN 长帧 160‰ → 感知评分 40 低于软底线 600
平均帧耗时区间 12–17ms(仅差 5ms),评分区间 40–988
== 卡顿感知基准:WARN(软告警不硬失败)

看这两行:平均帧耗时从 12ms 到 17ms,整整跨了 7 个长帧档位却只差 5ms;而评分从 988 掉到 40,几乎跌到底。如果拿平均帧耗时当验收指标,这七个档位「看起来都一样」;可玩家的感受是从「丝滑」到「根本玩不下去」。这就是为什么卡顿定位必须落到长帧频率,而不是任何形式的平均值。

这些数字怎么读

三点结论。第一,长帧频率是唯一值得盯的指标:它单调映射到感知评分,而平均帧耗时会把问题藏起来。第二,软底线是可调的价值判断:softScoreFloor=600 只是「跌到这里就该动手」的团队约定,改它不改模型,改的是团队对流畅度的要求。第三,别把它当成真机数据:真机的长帧来自 GC、纹理上传、平台回调,频率会随设备、场景、后台任务波动;本模型只回答「给定长帧频率,感知会差到什么程度」这类结构性问题。真要定位是哪一行代码造成的长帧,还是得在真机上做帧耗时打点——但那已经是「测量」,不是「建模」了。

小结与下一步

这一节把帧率问题从「平均值」扭到「长帧频率」:稳定帧与长帧的分布、长帧数、平均帧耗时、感知评分四个量里,只有长帧频率和它衍生的评分真正对应玩家感受。平均值被大量稳定帧稀释,会系统性低估卡顿。但性能优化还有第三条战线——内存:它既影响包体,又和「降包体」的优化互相打架。下一节就把内存峰值与包体优化放在同一张帕累托前沿上,看它们何时是朋友、何时是敌人。

阅读导航:上一节:首包与冷启动优化 · 下一节:内存与包体优化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制