1.3 自研 vs 引擎:什么时候值得自己写

把「自研还是用引擎」变成一个可算的分界问题:用整数递推把引擎路径(前置 120 人时、每项功能按 70% 消耗、对定制功能加 55% 约束代价)与自研路径(前置 260 人时、逐项提速)逐步累加,看累计人时何时交叉,再用 5×5 网格扫出什么条件下自研划算。

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 项之外,自研不划算。

决策判据与边界

把这一节压成一条可操作的判据:

  1. 先算 custom 项占比。如果你的功能清单里,引擎友好项占大头(拿引擎就能用),约束代价低,别自研。
  2. 再估自研前置。如果团队没有可复用的运行时底子,自研前置会远高于 260,分界点直接推出视野之外,别自研。
  3. 两个条件都满足才考虑自研:定制功能多(约束代价 ≥ 70%)且有运行时底子(前置 ≤ 220)。这时分界点落在第 10–12 项,值得认真评估。

边界要说清三件事。第一,这是量级模型,不是报价单:工时是示例量级,换团队、换产品,绝对数值全变;能迁移的是判据的形状(两个条件同时成立),不是具体数字。第二,自研的收益不止省工时:还有完全掌控渲染与包体、不被引擎版本绑架等长期收益,这些没进模型——模型只算可量化的人力账。第三,自研的风险没进模型:自建运行时的 bug、性能坑、平台适配遗漏,都可能让实际成本远超估算。模型给的是下界,真实决策要在这条下界上再加一层风险缓冲。

小结

本节把「自研 vs 引擎」从立场之争变成了分界计算:用整数递推把两条成本曲线逐步累加,基线结论是12 项功能内引擎更划算;5×5 网格扫描进一步划出边界——只有「引擎约束代价高」与「自研前置低」同时成立,自研才划算。至此第 1 章的选型闭环完成:1.1 画格局,1.2 算评分与敏感度,1.3 定自研分界。有了这条路线,下一章转入工程骨架:项目结构怎么分、分包与加载管线怎么搭、包体预算怎么落。

阅读导航:上一节:选型评分模型与敏感度分析 · 下一节:项目结构与模块边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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