站内《游戏引擎实现原理》四卷 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²) 的检测次数压下来。
阅读导航:上一节:平台输入差异适配 · 下一节:轻量碰撞检测与空间划分 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。