7.1 小游戏的物理需求分级

把「小游戏要不要上物理引擎」变成可核算的分级问题:把玩法类型映射到所需物理能力,再折算成代码行数、实现工时与包体占用,用 S 层脚本逐层汇总五个层级,讲清《合成小岛》为什么只需最轻的一级、多上一级要付多少代价,以及分级为何要按能力增量补齐而非整体升级。

站内《游戏引擎实现原理》四卷 180 篇 是从零实现引擎内核(含 ECS 与系统调度),它关心的是「一套通用引擎怎么搭」;本章讲的是小游戏语境下到底需要多少物理:分级取舍、轻量碰撞与空间划分、手感参数。两者不是重复,而是两个方向——那边把物理做全,这边把物理做小。另外站内已有一篇小游戏开发综述 ,那篇是引擎选型 / 性能 / 变现的单篇综述,本章把其中一句「注意物理开销」拆成一张能算钱的表。

物理在很多团队里是个「全有或全无」的开关:要么不接物理,要么接一整套刚体引擎。但对小游戏来说,这个开关其实有五档,而且档位之间的成本差着数量级。这一节就来做这件反直觉的事——先给物理需求分级,再决定用哪一档。

物理不是「有或没有」,而是分级的

《合成小岛》需要物理吗?直觉会说「合成要判定两个方块挨不挨着,当然需要」。但「需要物理」和「需要物理引擎」是两回事。合成判定只需要两个矩形相交,那是几十行代码;而一套刚体引擎要处理冲量、摩擦、约束、连续检测,是几千行。把这两件事混为一谈,就是包体和工时的浪费来源。

正确的做法是先问「这个玩法到底要用到哪些物理能力」,再按能力组合出层级。下表就是这一节的 D 层(静态层)产物:它把玩法类型映射到所需物理能力,再映射到推荐实现层级。注意 loc(代码行数)与 kb(包体占用)是本模型自设的实现规模估计,不是任何平台或引擎的实测数据,真实数字取决于你的实现语言与压缩方式。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "物理需求分级表:玩法类型 -> 需要的物理能力 -> 推荐实现层级。loc 与 kb 是本模型自设的实现规模估计,不是平台实测数据。",
  "hourPerLoc": 8,
  "abilities": [
    { "id": "aabb", "name": "矩形相交 AABB", "loc": 40, "kb": 2 },
    { "id": "circle", "name": "圆相交(平方距离)", "loc": 30, "kb": 2 },
    { "id": "grid", "name": "网格空间划分", "loc": 60, "kb": 3 },
    { "id": "impulse", "name": "冲量解算与摩擦", "loc": 120, "kb": 8 },
    { "id": "constraint", "name": "约束与关节", "loc": 180, "kb": 12 },
    { "id": "raycast", "name": "射线与连续检测", "loc": 50, "kb": 3 }
  ],
  "tiers": [
    { "id": "t0", "name": "无物理(纯逻辑)", "abilities": [] },
    { "id": "t1", "name": "点/矩形相交", "abilities": ["aabb"] },
    { "id": "t2", "name": "圆相交 + 网格", "abilities": ["circle", "grid"] },
    { "id": "t3", "name": "冲量刚体", "abilities": ["aabb", "circle", "grid", "impulse"] },
    { "id": "t4", "name": "完整刚体引擎", "abilities": ["aabb", "circle", "grid", "impulse", "constraint", "raycast"] }
  ],
  "genres": [
    { "id": "merge", "name": "合成 / 放置", "required": "t1" },
    { "id": "match", "name": "消除 / 解谜", "required": "t1" },
    { "id": "tower", "name": "塔防 / 弹幕", "required": "t2" },
    { "id": "platformer", "name": "平台跳跃", "required": "t3" },
    { "id": "sandbox", "name": "物理沙盒", "required": "t4" }
  ]
}

五个层级是这样排的:t0 无物理(纯逻辑,比如数字比大小)、t1 点/矩形相交(合成、消除只需要知道「两个格子是否重叠」)、t2 圆相交 + 网格(弹幕、塔防要判圆形碰撞,且实体多到需要空间划分)、t3 冲量刚体(平台跳跃要重力、反弹、摩擦)、t4 完整刚体引擎(物理沙盒要约束与关节)。每个能力有独立成本,层级就是能力的组合——所以层级成本可以直接相加,这就是下面 S 层要算的东西。

S 层:把分级折算成成本与包体

有了能力清单和层级组合,下一步是把它变成数字。下面这段是 S 层(仿真层)脚本:它读上面的配置,逐层汇总 loc / 成本工时 / 包体,并统计每个层级服务多少玩法类型。全整数运算,图表用纯 Python SVG。

#!/usr/bin/env python3
# file: sim/e07_physics_tier.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e07_physics_tier.py --config config/ --out out/
"""物理需求分级统计:把「玩法类型 -> 物理能力 -> 实现层级」折算成成本与包体。

读 config/e07_physics.json:每个能力有 loc(代码行)与 kb(包体占用),每个层级
由若干能力组成,每个玩法类型映射到一个推荐层级。逐层汇总 loc / 成本工时 / 包体,
并统计每个层级服务多少玩法。全整数运算,图表用纯 Python SVG。

产出:out/e07_physics_tier.json + out/e07_physics_tier_tiers.csv
     + out/e07_physics_tier_genres.csv + out/e07_physics_tier.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, "e07_physics.json"), encoding="utf-8") as f:
        return json.load(f)


def draw_svg(path, rows, hour_per_loc):
    """纯 Python SVG:每个层级一根横条,长度正比于累计成本工时。"""
    W, H, PAD, ROW = 760, 70 + 46 * len(rows), 190, 46
    peak = max((r["costHours"] for r in rows), default=1) or 1
    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": "28", "font-size": "15", "fill": "#111827"}).text = (
        "各物理层级的实现成本(小时,每行 %d 小时)" % hour_per_loc
    )
    bar_max = W - PAD - 30
    for i, r in enumerate(rows):
        y = 54 + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
                                    "fill": "#374151"}).text = "%s %s" % (r["id"], r["name"])
        w = (bar_max * r["costHours"]) // peak
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "22",
                                    "rx": "4", "fill": "#2563eb"})
        ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 22), "font-size": "12",
                                    "fill": "#6b7280"}).text = "%d h · %d KB · 服务 %d 玩法" % (
            r["costHours"], r["kb"], r["genreCount"])
    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)
    hour = cfg["hourPerLoc"]
    abl = {a["id"]: a for a in cfg["abilities"]}
    genres = cfg["genres"]

    rows = []
    prev_loc = 0
    for t in cfg["tiers"]:
        loc = sum(abl[a]["loc"] for a in t["abilities"])
        kb = sum(abl[a]["kb"] for a in t["abilities"])
        served = [g for g in genres if g["required"] == t["id"]]
        rows.append({
            "id": t["id"], "name": t["name"], "abilityCount": len(t["abilities"]),
            "abilities": t["abilities"], "loc": loc, "costHours": loc * hour, "kb": kb,
            "incrLoc": loc - prev_loc, "incrHours": (loc - prev_loc) * hour,
            "genreCount": len(served), "genres": [g["id"] for g in served],
        })
        prev_loc = loc

    # 玩法 -> 推荐层级 -> 成本,一行一个玩法
    tier_by_id = {r["id"]: r for r in rows}
    genre_rows = []
    for g in genres:
        t = tier_by_id[g["required"]]
        genre_rows.append({
            "id": g["id"], "name": g["name"], "required": g["required"],
            "tierName": t["name"], "costHours": t["costHours"], "kb": t["kb"],
        })

    recommended = tier_by_id["t1"]  # 《合成小岛》= 合成 / 放置 -> t1
    report = {
        "version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
        "hourPerLoc": hour, "tierCount": len(rows), "genreCount": len(genres),
        "abilityCount": len(cfg["abilities"]),
        "fullTierCostHours": rows[-1]["costHours"], "fullTierKb": rows[-1]["kb"],
        "recommendedTier": recommended["id"], "recommendedCostHours": recommended["costHours"],
        "recommendedKb": recommended["kb"],
        "savingVsFullPct": ((rows[-1]["costHours"] - recommended["costHours"]) * 100)
        // rows[-1]["costHours"],
        "tiers": rows, "genres": genre_rows,
    }

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "e07_physics_tier.svg"), rows, hour)
    report["svg"] = os.path.join(args.out, "e07_physics_tier.svg")
    report["svgBytes"] = svg_bytes
    with open(os.path.join(args.out, "e07_physics_tier.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "e07_physics_tier_tiers.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "abilityCount", "loc", "costHours", "incrHours", "kb", "genreCount", "abilities"])
        for r in rows:
            w.writerow([r["id"], r["name"], r["abilityCount"], r["loc"], r["costHours"],
                        r["incrHours"], r["kb"], r["genreCount"], "|".join(r["abilities"])])
    with open(os.path.join(args.out, "e07_physics_tier_genres.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "required", "tierName", "costHours", "kb"])
        for r in genre_rows:
            w.writerow([r["id"], r["name"], r["required"], r["tierName"], r["costHours"], r["kb"]])

    print(json.dumps({k: report[k] for k in
                      ("recommendedTier", "recommendedCostHours", "recommendedKb",
                       "fullTierCostHours", "fullTierKb", "savingVsFullPct")}, ensure_ascii=False))
    return 0


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

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"recommendedTier": "t1", "recommendedCostHours": 320, "recommendedKb": 2, "fullTierCostHours": 3840, "fullTierKb": 30, "savingVsFullPct": 91}

同时落盘 out/e07_physics_tier_tiers.csv(每层级一行)与 out/e07_physics_tier.svg(成本条形图)。层级表是这样:

id,name,abilityCount,loc,costHours,incrHours,kb,genreCount,abilities
t0,无物理(纯逻辑),0,0,0,0,0,0,
t1,点/矩形相交,1,40,320,320,2,2,aabb
t2,圆相交 + 网格,2,90,720,400,5,1,circle|grid
t3,冲量刚体,4,250,2000,1280,15,1,aabb|circle|grid|impulse
t4,完整刚体引擎,6,480,3840,1840,30,1,aabb|circle|grid|impulse|constraint|raycast

玩法表 out/e07_physics_tier_genres.csv 是矩阵的「另一半」——它回答「我的玩法该落在哪一级」:

id,name,required,tierName,costHours,kb
merge,合成 / 放置,t1,点/矩形相交,320,2
match,消除 / 解谜,t1,点/矩形相交,320,2
tower,塔防 / 弹幕,t2,圆相交 + 网格,720,5
platformer,平台跳跃,t3,冲量刚体,2000,15
sandbox,物理沙盒,t4,完整刚体引擎,3840,30

三个数字读出的结论

第一,t1 到 t4 的成本差了一个数量级。 t1 是 320 小时 / 2 KB,t4 是 3840 小时 / 30 KB,savingVsFullPct = 91——对只需要矩形相交的玩法来说,上完整刚体引擎要付的成本明显高出一个数量级(同机相对量级)。这不是说刚体引擎「不好」,而是说多数小游戏根本用不到它的能力,为用不到的能力付钱就是浪费。

第二,增量成本集中在 t3。 看 incrHours 一列:t1 从 0 到 320,t2 加 400,t3 一下加 1280,t4 再加 1840。真正的「贵」是从 t2 跳到 t3(引入冲量解算),因为冲量解算要处理速度、摩擦、穿透修正,复杂度不是线性增长。如果你的玩法落在 t2,就没有理由往 t3 走。

第三,包体占用和工时不成正比。 t1 到 t4 的工时差了约一个数量级,包体只从 2 KB 涨到 30 KB。原因是这些物理代码在压缩后都极小,真正影响首包的是贴图和音频(第 4 章会专门讲)。所以物理分级的主要收益是工时,不是包体——这一点常被搞反。

《合成小岛》落在哪一级

回到贯穿游戏。merge(合成 / 放置)在配置里映射到 t1:它只需要知道「两个格子的矩形是否重叠」,合成判定用 AABB 就够了,既不需要圆碰撞,也不需要空间划分(棋盘只有几十个格子,暴力遍历绰绰有余)。这就是 recommendedTier = t1、recommendedCostHours = 320、recommendedKb = 2 的来源。

但这里有个容易忽略的细节:分级不是永远固定。当《合成小岛》后面加「掉落物落到棋盘上」的动效时(第 12 章的打击感),它可能需要 t2 的圆碰撞来判「掉落物是否碰到已有方块」。这时候不该整体升到 t3,而应该只补 circle 这一个能力(30 行 / 2 KB),层级仍然是「t1 + 圆」。这就是分级的真正价值——按能力增量补齐,而不是按层级整体重写。

这份表是判断,不是数据

必须把话说死:表里的 loc、kb、hourPerLoc 全是本模型自设的估计,不是任何引擎或平台的实测。真实项目中,loc 取决于你的实现风格,kb 取决于压缩方式,hourPerLoc 取决于团队熟练度。这张表的价值不在于数字准不准,而在于它给出了一套结构化的决策框架:先列能力,再组层级,再按玩法选层级,最后只补差额。

工程上最危险的做法,是「反正物理引擎是现成的,接上再说」——接上的代价往往不是那几千行代码,而是你被迫为整个引擎的每帧开销买单。第 9 章的对象池与内存管理会接着讲:物理引擎每帧产生的临时对象,正是弱机卡顿的常见来源。

小结与下一步

这一节把「小游戏要不要物理」从一个开关变成了五档分级:t0 无物理、t1 矩形相交、t2 圆 + 网格、t3 冲量刚体、t4 完整刚体引擎。用一张能力表和一段可跑脚本,把每个层级的成本、包体、服务玩法算清楚。《合成小岛》落在 t1,需要补能力时按差额补,不整体升级。

但分级只解决了「用多重」,没解决「怎么写」。下一节就动手写 t1 + t2 里最核心的部分——轻量碰撞检测与空间划分:AABB 与圆的相交判定,以及当实体多起来时,怎么用网格把 O(n²) 的检测次数压下来。

阅读导航:上一节:平台输入差异适配 · 下一节:轻量碰撞检测与空间划分 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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