1.2 节末尾留了一个问题:如果连引擎都不想要呢?1.1 的谱系右端是「无引擎」路线,1.2 里 PixiJS 与原生 Canvas 在包体维度上名列前茅——但它们的自研成本也最高。这一节把「自研还是用引擎」从一个立场之争,变成一个可算的分界问题。定位先说清:本站的 Defold 引擎入门 是单一引擎的入门,小程序小游戏综述 是引擎选型的定性综述,本站《引擎内核》系列 讲的是自研引擎内核的实现(渲染管线、WebGPU);本节都不重复它们,只给一条成本分界判据——在什么条件下,自己写比用引擎更划算。
分界的直觉:两条成本曲线
「自研 vs 引擎」最容易吵成价值观问题。理性地看,它其实是两条累计成本曲线谁先被谁超过的问题:
- 引擎路径:前期便宜(有编辑器、有运行时、有适配层),但每做一项功能都要迁就引擎——想改渲染顺序要绕,想塞自定义 UI 要绕,包体被引擎基线撑大。这些「迁就」就是引擎约束代价。功能越多、越定制,这笔代价越大。
- 自研路径:前期昂贵(启动、渲染、输入、资源、平台适配全都要自己搭),但每写一层就沉淀一层,边际成本随功能数递减。当你的功能足够定制、数量足够多时,自研的累计成本会反过来低于引擎。
两条曲线一开始引擎在下(便宜),自研在上(贵);如果自研的斜率更低,迟早会交叉。交叉点就是分界:功能项超过它,自研划算;没到它,用引擎。
这条直觉要变成可算的东西,需要三个参数:两条路径的前置成本、引擎的每项效率与约束代价、自研的提速节奏。它们全部放进 config。
成本分界模型参数(D 层)
下面这段是 D 层静态配置,即分界模型的全部参数。功能清单给出 12 项典型工作(前 6 项是引擎友好的通用功能,后 6 项标记为 custom——合成链、平台宿主、性能包体优化这类引擎帮不上太多忙的定制项);两条路径各给前置人时与效率系数;扫描网格用于后面的「什么条件」分析。要点:工时是示例 / 设计假设口径的量级估算,不是任何团队实测;所有数字都是整数,无浮点。
{
"version": 1,
"series": "《小游戏开发实战》",
"volume": "卷二《引擎实现与体验打磨》",
"targetGame": "merge-island",
"observedAt": "2026-10",
"note": "自研运行时 vs 引擎的成本分界模型参数。工时数字是【示例 / 设计假设】口径的量级估算,不是任何团队实测;用于第 1 章 1.3 节的分界仿真,绝对数值不可移植。",
"units": { "hours": "人时(整数,示例量级)", "pct": "整数百分比" },
"features": [
{ "id": "boot", "cn": "启动与资源加载", "hours": 40, "custom": false },
{ "id": "render", "cn": "渲染与合批", "hours": 60, "custom": false },
{ "id": "input", "cn": "触控与手势", "hours": 30, "custom": false },
{ "id": "ui", "cn": "UI 布局与弹窗", "hours": 70, "custom": false },
{ "id": "anim", "cn": "缓动与时间轴", "hours": 35, "custom": false },
{ "id": "audio", "cn": "音频与震动", "hours": 25, "custom": false },
{ "id": "physics", "cn": "轻量碰撞", "hours": 45, "custom": true },
{ "id": "merge", "cn": "合成链与棋盘", "hours": 80, "custom": true },
{ "id": "save", "cn": "存档与迁移", "hours": 40, "custom": true },
{ "id": "host", "cn": "平台 API 抽象与 mock 宿主", "hours": 55, "custom": true },
{ "id": "perf", "cn": "性能与包体优化", "hours": 65, "custom": true },
{ "id": "tools", "cn": "构建与调试工具链", "hours": 50, "custom": true }
],
"engine": {
"upfrontHours": 120,
"speedPct": 70,
"customPenaltyPct": 55,
"note": "引擎路径:前置学习与工程搭建 120 人时;每项功能按基线工时的 70% 消耗(引擎省力);对 custom 功能额外付出 55% 的约束代价(迁就引擎 / 绕开限制)。"
},
"self": {
"coreUpfrontHours": 260,
"speedPctStart": 110,
"improvePctPerStep": 4,
"speedCapPct": 82,
"note": "自研路径:前置自建运行时 260 人时;首项功能按 110% 基线工时(比引擎慢),每推进一步效率提升 4 个百分点,最低降到 82%。"
},
"sweep": {
"selfCoreUpfront": [180, 220, 260, 300, 340],
"engineCustomPenaltyPct": [30, 40, 55, 70, 85]
}
}
参数里最该盯着的是 customPenaltyPct(引擎约束代价)与 selfCoreUpfrontHours(自研前置)。前者是「引擎路径的隐性税」,后者是「自研路径的门票」。分界点主要由这两者决定——这也是为什么后面的扫描网格只在这两个方向上扫。
分界模型(S 层)
下面这段是 S 层仿真脚本,读 config/e01_buildvs.json,用整数递推把两条路径的累计人时逐步算出来,找出分界点,再做 5×5 网格扫描,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/e01_build_vs_engine.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e01_build_vs_engine.py --config config/ --out out/
"""自研运行时 vs 引擎:成本 / 收益分界模型(整数递推)。
读 config/e01_buildvs.json(功能清单 / 引擎路径参数 / 自研路径参数 / 扫描网格)。
两条路径按功能项逐步累加人时:
引擎路径 E[k] = E[k-1] + h[k]*speedPct//100 + (h[k]*customPenaltyPct//100 if custom else 0)
自研路径 S[k] = S[k-1] + h[k]*spd[k]//100
其中 spd[k] = max(speedCapPct, speedPctStart - improvePctPerStep*(k-1))
引擎前置 E[0] = upfrontHours,自研前置 S[0] = coreUpfrontHours。
分界点 = 第一个 S[k] <= E[k] 的功能序号(无则记 ">N")。
再做 5×5 网格扫描(自研前置 × 引擎约束代价),对每个组合给出分界点,
回答「什么条件下自研划算」。产出 JSON + CSV + SVG。全整数地板除,无浮点;
工时是【示例 / 设计假设】量级,绝对数值不可移植。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(p: str) -> dict:
with open(p, encoding="utf-8") as f:
return json.load(f)
def cumulative(features: list[dict], upfront: int, speed_pct: int, penalty_pct: int,
self_mode: bool, spd_start: int, improve: int, spd_cap: int) -> list[int]:
"""整数递推,返回累计人时列表(索引 0 = 前置,1..N = 逐功能)。"""
out = [upfront]
acc = upfront
for i, f in enumerate(features):
h = f["hours"]
if self_mode:
spd = max(spd_cap, spd_start - improve * i)
acc += h * spd // 100
else:
acc += h * speed_pct // 100
if f["custom"]:
acc += h * penalty_pct // 100
out.append(acc)
return out
def breakeven(eng: list[int], self_: list[int]) -> str:
for k in range(1, len(eng)):
if self_[k] <= eng[k]:
return str(k)
return f">{len(eng) - 1}"
def svg(path: str, eng: list[int], self_: list[int], cn: list[str], be: str) -> int:
W, H, PAD = 720, 420, 90
n = len(eng) - 1
plot_w, plot_h = W - PAD - 140, H - 2 * 60
vmax = max(max(eng), max(self_)) or 1
sx = lambda k: PAD + k / n * plot_w
sy = lambda v: H - 60 - v / vmax * plot_h
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(root, "text", {"x": "40", "y": "26", "font-size": "15",
"font-family": "sans-serif"}).text = (
f"累计人时:引擎路径(蓝)vs 自研路径(橙),分界点 = 第 {be} 项")
ET.SubElement(root, "line", {"x1": str(PAD), "y1": str(H - 60), "x2": str(W - 140),
"y2": str(H - 60), "stroke": "#9ca3af"})
ET.SubElement(root, "line", {"x1": str(PAD), "y1": "40", "x2": str(PAD),
"y2": str(H - 60), "stroke": "#9ca3af"})
for series, color in ((eng, "#2563eb"), (self_, "#ea580c")):
pts = " ".join(f"{sx(k):.1f},{sy(v):.1f}" for k, v in enumerate(series))
ET.SubElement(root, "polyline", {"points": pts, "fill": "none",
"stroke": color, "stroke-width": "2"})
for k, v in enumerate(series):
ET.SubElement(root, "circle", {"cx": f"{sx(k):.1f}", "cy": f"{sy(v):.1f}",
"r": "2.5", "fill": color})
for k in range(len(eng)):
ET.SubElement(root, "text", {"x": f"{sx(k):.1f}", "y": str(H - 44),
"font-size": "9", "text-anchor": "middle",
"font-family": "sans-serif"}).text = str(k)
ET.SubElement(root, "text", {"x": f"{sx(0):.1f}", "y": f"{sy(eng[0]) - 8:.1f}",
"font-size": "11", "font-family": "sans-serif"}).text = "引擎前置"
ET.SubElement(root, "text", {"x": f"{sx(0):.1f}", "y": f"{sy(self_[0]) + 16:.1f}",
"font-size": "11", "font-family": "sans-serif"}).text = "自研前置"
ET.SubElement(root, "text", {"x": str(W - 130), "y": "60", "font-size": "11",
"font-family": "sans-serif"}).text = f"引擎末值 {eng[-1]}"
ET.SubElement(root, "text", {"x": str(W - 130), "y": "80", "font-size": "11",
"font-family": "sans-serif"}).text = f"自研末值 {self_[-1]}"
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(os.path.join(args.config, "e01_buildvs.json"))
feats = cfg["features"]
eng_p, self_p = cfg["engine"], cfg["self"]
eng = cumulative(feats, eng_p["upfrontHours"], eng_p["speedPct"],
eng_p["customPenaltyPct"], False, 0, 0, 0)
self_ = cumulative(feats, self_p["coreUpfrontHours"], 0, 0, True,
self_p["speedPctStart"], self_p["improvePctPerStep"], self_p["speedCapPct"])
be = breakeven(eng, self_)
# 5×5 网格扫描
grid, rows = [], []
for up in cfg["sweep"]["selfCoreUpfront"]:
line = []
for pen in cfg["sweep"]["engineCustomPenaltyPct"]:
e2 = cumulative(feats, eng_p["upfrontHours"], eng_p["speedPct"], pen, False, 0, 0, 0)
s2 = cumulative(feats, up, 0, 0, True, self_p["speedPctStart"],
self_p["improvePctPerStep"], self_p["speedCapPct"])
b = breakeven(e2, s2)
line.append(b)
rows.append({"selfCoreUpfront": up, "engineCustomPenaltyPct": pen, "breakeven": b,
"engineEnd": e2[-1], "selfEnd": s2[-1]})
grid.append(line)
with open(os.path.join(args.out, "e01_buildvs.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["selfCoreUpfront", "engineCustomPenaltyPct", "breakeven", "engineEnd", "selfEnd"])
for r in rows:
w.writerow([r["selfCoreUpfront"], r["engineCustomPenaltyPct"], r["breakeven"],
r["engineEnd"], r["selfEnd"]])
svgb = svg(os.path.join(args.out, "e01_buildvs.svg"), eng, self_,
[f["cn"] for f in feats], be)
report = {
"version": 1, "source": "sim/e01_build_vs_engine.py",
"featureCount": len(feats), "customCount": sum(1 for f in feats if f["custom"]),
"engine": {"upfront": eng[0], "end": eng[-1], "series": eng},
"self": {"upfront": self_[0], "end": self_[-1], "series": self_},
"breakeven": be,
"selfWinsAtEnd": self_[-1] <= eng[-1],
"grid": {"selfCoreUpfront": cfg["sweep"]["selfCoreUpfront"],
"engineCustomPenaltyPct": cfg["sweep"]["engineCustomPenaltyPct"],
"breakeven": grid},
"csv": os.path.join(args.out, "e01_buildvs.csv"),
"svg": os.path.join(args.out, "e01_buildvs.svg"), "svgBytes": svgb,
}
with open(os.path.join(args.out, "e01_buildvs.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({
"engineEnd": eng[-1], "selfEnd": self_[-1], "breakeven": be,
"selfWinsAtEnd": report["selfWinsAtEnd"],
"gridBreakeven": grid, "svgBytes": svgb,
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
脚本把两条路径都算成累计数列而不是只给一个终点值——因为决策关心的从来不是「最后谁便宜」,而是「在第几项功能上开始便宜」。breakeven 就是这个序号。
实跑结果:基线
执行 /opt/homebrew/bin/python3 sim/e01_build_vs_engine.py --config config/ --out out/,输出如下(同机相对量级,绝对数值不可移植):
engineEnd: 716
selfEnd: 793
breakeven: ">12"
selfWinsAtEnd: false
读法:在本套基线参数下(引擎前置 120、每项按 70% 消耗、定制项加 55% 约束;自研前置 260、逐项提速到 82%),跑完 12 项功能,引擎路径累计 716 人时,自研路径累计 793 人时——自研始终更贵,分界点落在 12 项之外(记作 >12)。结论是:在基线假设下,用引擎更划算。
但这不是「自研永远不划算」。关键在于:基线里引擎的约束代价只有 55%,自研的前置高达 260。把这两个数改一改,结论就可能翻转——这正是网格扫描要回答的。
网格扫描:什么条件下自研划算
扫描网格在自研前置(180–340 人时)与引擎约束代价(30%–85%)两个方向各取 5 档,共 25 个组合,每个组合算一次分界点。结果(> 表示 12 项内不交叉):
自研前置 \ 约束代价 30% 40% 55% 70% 85%
180 >12 >12 12 11 10
220 >12 >12 >12 12 11
260 >12 >12 >12 >12 12
300 >12 >12 >12 >12 >12
340 >12 >12 >12 >12 >12
这张表把「什么条件下自研划算」读成一条清晰的边界:
- 右下角是自研区:当引擎约束代价高(70%–85%)且自研前置低(180–220)时,分界点提前到第 10–12 项。比如「自研前置 180 + 约束代价 85%」时,第 10 项(性能与包体优化)之后自研就开始便宜。
- 左上角是引擎区:约束代价 30%–40%,或自研前置 300+ 时,12 项内自研从不交叉——引擎稳赢。
- 边界是斜的:要让自研划算,要么引擎的「隐性税」很重(约束代价高),要么自研的「门票」很便宜(前置低),或者两者兼有。只满足一个条件往往不够。
换成人话:自研划算,只在「引擎约束代价高」与「自研前置低」同时成立时才出现。引擎约束代价高,意味着你的产品里有大量引擎帮不上忙的定制功能(合成链、平台宿主、性能包体优化);自研前置低,意味着你的团队本来就有一套可复用的运行时底子。两个条件缺一个,分界点就退回到 12 项之外,自研不划算。
决策判据与边界
把这一节压成一条可操作的判据:
- 先算
custom项占比。如果你的功能清单里,引擎友好项占大头(拿引擎就能用),约束代价低,别自研。 - 再估自研前置。如果团队没有可复用的运行时底子,自研前置会远高于 260,分界点直接推出视野之外,别自研。
- 两个条件都满足才考虑自研:定制功能多(约束代价 ≥ 70%)且有运行时底子(前置 ≤ 220)。这时分界点落在第 10–12 项,值得认真评估。
边界要说清三件事。第一,这是量级模型,不是报价单:工时是示例量级,换团队、换产品,绝对数值全变;能迁移的是判据的形状(两个条件同时成立),不是具体数字。第二,自研的收益不止省工时:还有完全掌控渲染与包体、不被引擎版本绑架等长期收益,这些没进模型——模型只算可量化的人力账。第三,自研的风险没进模型:自建运行时的 bug、性能坑、平台适配遗漏,都可能让实际成本远超估算。模型给的是下界,真实决策要在这条下界上再加一层风险缓冲。
小结
本节把「自研 vs 引擎」从立场之争变成了分界计算:用整数递推把两条成本曲线逐步累加,基线结论是12 项功能内引擎更划算;5×5 网格扫描进一步划出边界——只有「引擎约束代价高」与「自研前置低」同时成立,自研才划算。至此第 1 章的选型闭环完成:1.1 画格局,1.2 算评分与敏感度,1.3 定自研分界。有了这条路线,下一章转入工程骨架:项目结构怎么分、分包与加载管线怎么搭、包体预算怎么落。
阅读导航:上一节:选型评分模型与敏感度分析 · 下一节:项目结构与模块边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。