1.2 eCPM、填充率与收益模型

《合成小岛》的广告收益不是「eCPM 越高越好」:底价压低则填充率高但单价低,底价抬高则单价高但填充率低,收益是填充率与 eCPM 的乘积,必有一个内部最大值。本节给出一张 D 层底价档位表、一段扫描六个档位找最优的 S 层脚本,以及一个用两个滑杆实时拖动每日上限、观察展示数、当日收益、留存乘数与净收益代理如何联动的单文件 HTML 原型(W 层),并用 Playwright 无头断言它可复现。

1.1 把广告拆成了形态与场景,得到的是「每个场景值多少」。但那个数字建立在两个外部变量上——填充率与 eCPM——而它们不是你能直接设的,是广告平台根据你的底价与实时竞价环境决定的。本节要回答的是:我该把底价设多高。本站那篇广告变现设计清单 只列了「要关注 eCPM 与填充率」;本节把它变成一段能扫出最优档位的脚本。1.1 已经用失败复盘 的视角提醒过「别只看 eCPM」,这里给出它背后的数学。设底价。底价设得低,几乎每一笔请求都有广告可填(填充率高),但竞价被压到低位,每千次展示收益(eCPM)低;底价设得高,只有出得起价的广告主才来,eCPM 高,可填的库存却少了(填充率低)。关键性质是:收益 = 请求数 × 填充率 × eCPM,是填充率与 eCPM 的乘积。填充率随底价单调下降、eCPM 随底价单调上升,一个降一个升,乘积必然在中间某处取到最大值——既不设底价,也不设天价。这段脚本要做的,就是把这个「中间某处」扫出来。

把底价档位写成一张可被程序消费的表(D 层)

下面这段是 D 层静态配置:每 DAU 日均 20 次请求,六个底价档位各自给出对应的填充率与 eCPM。要点:请求数与各档参数都是示例假设值,真实 eCPM 与填充率需真实业务环境,命令行不可复现(X);所有数字为整数,收益用 0.1 分记账。

{
  "version": 1,
  "series": "《小游戏开发实战》",
  "volume": "卷三《上线变现与长线运营》",
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "eCPM 与填充率的权衡模型(D 层)。requestsPerDau 与 tiers(fillPermille, ecpmFen)均为示例假设参数;真实 eCPM 与填充率需真实业务环境,命令行不可复现(X)。收益用 0.1 分(deciFen)记账。",
  "units": {
    "requestsPerDau": "每 DAU 日均广告请求次数",
    "fillPermille": "该底价档位的填充率,千分比 0–1000",
    "ecpmFen": "该档位的每千次展示收益,单位分",
    "revenueDeciFen": "每 DAU 日均收益,单位 0.1 分"
  },
  "requestsPerDau": 20,
  "tiers": [
    { "id": "floor-none", "floorFen": 0, "fillPermille": 1000, "ecpmFen": 800, "label": "不设底价,全量填充" },
    { "id": "floor-low", "floorFen": 8, "fillPermille": 850, "ecpmFen": 1500, "label": "低底价" },
    { "id": "floor-mid", "floorFen": 18, "fillPermille": 700, "ecpmFen": 2200, "label": "中底价" },
    { "id": "floor-high", "floorFen": 30, "fillPermille": 550, "ecpmFen": 3000, "label": "较高底价" },
    { "id": "floor-vhigh", "floorFen": 42, "fillPermille": 400, "ecpmFen": 4200, "label": "高底价" },
    { "id": "floor-max", "floorFen": 52, "fillPermille": 250, "ecpmFen": 5200, "label": "极高底价" }
  ]
}

用一段 S 层脚本扫出最优档位

下面这段是 S 层仿真脚本,读上面那份配置,对每个底价档位算每 DAU 展示次数、收益与等效 eCPM,找出收益最大的档位,产出 JSON + CSV + SVG。注意它的核心事实:等效 eCPM 恒等于该档的 eCPM(因为展示已经按填充率折过),所以比的是「填充率 × eCPM」这个乘积,不是单价本身。下面在本机工作区实跑,输出如下(同机相对量级,绝对数值不可移植)。

#!/usr/bin/env python3
# file: sim/l01_ecpm.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l01_ecpm.py --config config/ --out out/
"""eCPM 与填充率的权衡模型:读 config/l01_ecpm.json,对每个「底价档位」算每 DAU 展示
次数、收益与等效 eCPM,找出收益最大的档位。产出 JSON + CSV + SVG。

核心事实:填充率与 eCPM 是**反向**的——底价压低则填充率高但单价低,底价抬高则单价高
但填充率低。收益 = 请求数 × 填充率 × eCPM,是个有内部最大值的乘积。

  impressionsPerDau = requestsPerDau * fillPermille // 1000
  revenueDeciFen    = requestsPerDau * fillPermille * ecpmFen // 100000
  effectiveEcpmFen  = revenueDeciFen * 100 // impressionsPerDau    # 恒等于 ecpmFen

requestsPerDau 与各档 (fillPermille, ecpmFen) 均为示例假设参数,真实 eCPM 与填充率需
真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

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


def load(d: str) -> dict:
    with open(os.path.join(d, "l01_ecpm.json"), encoding="utf-8") as f:
        return json.load(f)


def evaluate(req: int, t: dict) -> dict:
    fill, ecpm = t["fillPermille"], t["ecpmFen"]
    imp = req * fill // 1000
    rev = req * fill * ecpm // 100000
    return {"id": t["id"], "floorFen": t["floorFen"], "label": t["label"], "fillPermille": fill,
            "ecpmFen": ecpm, "impressionsPerDau": imp, "revenueDeciFen": rev,
            "effectiveEcpmFen": rev * 100 // imp if imp else 0}


def svg(path: str, rows: list[dict], best_id: str) -> int:
    """纯 Python SVG:横轴为每 DAU 收益,六档竖排;最优档高亮。"""
    W, PAD, ROW = 720, 190, 38
    H = 72 + ROW * len(rows)
    r = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                           "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(r, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(r, "text", {"x": "16", "y": "26", "font-size": "15",
                              "fill": "#111827"}).text = "各底价档位的每 DAU 广告收益(0.1 分,示例参数)"
    hi = max((x["revenueDeciFen"] for x in rows), default=1) or 1
    bmax = W - PAD - 120
    for i, x in enumerate(rows):
        y = 60 + i * ROW
        best = x["id"] == best_id
        w = bmax * x["revenueDeciFen"] // hi
        ET.SubElement(r, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
                                  "fill": "#111827" if best else "#374151"}).text = (
            f'{x["label"]}(底价 {x["floorFen"]}分)')
        ET.SubElement(r, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(max(w, 1)),
                                  "height": "20", "rx": "4",
                                  "fill": "#0369a1" if best else "#93c5fd"})
        ET.SubElement(r, "text", {"x": str(PAD + w + 8), "y": str(y + 21), "font-size": "12",
                                  "fill": "#6b7280"}).text = (
            f'{x["revenueDeciFen"] / 10:.1f}分 · 填充{x["fillPermille"]}‰')
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(r).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config)
    req = cfg["requestsPerDau"]
    rows = [evaluate(req, t) for t in cfg["tiers"]]
    best = max(rows, key=lambda x: (x["revenueDeciFen"], x["id"]))
    worst = min(rows, key=lambda x: (x["revenueDeciFen"], x["id"]))

    os.makedirs(a.out, exist_ok=True)
    sb = svg(os.path.join(a.out, "l01_ecpm.svg"), rows, best["id"])
    with open(os.path.join(a.out, "l01_ecpm.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "label", "floorFen", "fillPermille", "ecpmFen",
                    "impressionsPerDau", "revenueDeciFen", "effectiveEcpmFen"])
        for x in rows:
            w.writerow([x["id"], x["label"], x["floorFen"], x["fillPermille"], x["ecpmFen"],
                        x["impressionsPerDau"], x["revenueDeciFen"], x["effectiveEcpmFen"]])

    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "requestsPerDau": req, "tierCount": len(rows), "rows": rows, "bestTier": best["id"],
           "bestRevenueDeciFen": best["revenueDeciFen"],
           "worstRevenueDeciFen": worst["revenueDeciFen"],
           "bestVsWorstPermille": best["revenueDeciFen"] * 1000 // worst["revenueDeciFen"],
           "svg": os.path.join(a.out, "l01_ecpm.svg"), "svgBytes": sb}
    with open(os.path.join(a.out, "l01_ecpm.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"requestsPerDau": req,
                      "revenueByTier": {x["label"]: x["revenueDeciFen"] for x in rows},
                      "bestTier": best["label"], "bestRevenueDeciFen": best["revenueDeciFen"],
                      "bestFillPermille": best["fillPermille"], "bestEcpmFen": best["ecpmFen"],
                      "bestVsWorstPermille": rep["bestVsWorstPermille"], "svgBytes": sb},
                     ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
{
  "requestsPerDau": 20,
  "revenueByTier": {
    "不设底价,全量填充": 160,
    "低底价": 255,
    "中底价": 308,
    "较高底价": 330,
    "高底价": 336,
    "极高底价": 260
  },
  "bestTier": "高底价",
  "bestRevenueDeciFen": 336,
  "bestFillPermille": 400,
  "bestEcpmFen": 4200,
  "bestVsWorstPermille": 2100,
  "svgBytes": 1745
}

收益随底价先升后降:不设底价只拿 160,一路爬到「高底价」(填充率 400‰、eCPM 42 分)的 336,再往上「极高底价」反而跌回 260——因为填充率掉得太快,补不回单价。最优档的收益是最差档的 2100‰(2.1 倍),但请注意这是同机相对量级:真实环境里填充率曲线长什么样,只有你自己的广告后台知道(X)。

用单文件 HTML 原型把频次与收益联动起来(W 层)

光看静态档位不够——真实运营要能一边拖每日上限、一边看指标怎么动。下面两段是 W 层:build_l01_ads.mjs 把 1.1 的 P 层闸门内核内联进一个单文件 HTML,跑一条确定性的「一天 60 次请求」流,两个滑杆分别调激励视频与插屏的每日上限;verify_l01_ads.mjs 用 Playwright 无头打开它,断言默认与三组拖动后的展示数、当日收益、留存乘数、净收益代理逐位可复现,并检查画布真画了像素。

#!/usr/bin/env node
// file: web/build_l01_ads.mjs
// tier: W
// verify: node web/build_l01_ads.mjs
// 把 core/src/l01/adgate.ts 的广告闸门打成单文件 HTML:跑一条确定性的「一天 60 次请求」
// 流,两个滑杆分别调激励视频 / 插屏的每日上限,实时看展示数、当日收益、留存乘数与净收益
// 代理。产物 web/dist/l01.html。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const ads = JSON.parse(readFileSync(join(ROOT, "config", "l01_ads.json"), "utf8"));
const freq = JSON.parse(readFileSync(join(ROOT, "config", "l01_frequency.json"), "utf8"));
const js = bundle(ROOT, ["core/src/l01/adgate.ts"]);
const rev = Object.fromEntries(ads.formats.map((f) => [f.id, f.revDeciFenPerImp]));

const script = `const cfg = ${inlineConfig({ formats: ads.formats })};
const freq = ${inlineConfig({ volPen: freq.voluntaryPenaltyPermillePerImp,
  forPen: freq.forcedPenaltyPermillePerImp, minFactor: freq.minFactorPermille })};
const REV = ${inlineConfig(rev)};
/* ---- core bundle: TS -> strip -> inline ---- */
${js}
/* ---- end bundle ---- */
const PATTERN = ["rewarded", "interstitial", "rewarded", "banner", "rewarded", "native"];
const STREAM = [];
for (let i = 0; i < 60; i += 1) {
  const fmt = PATTERN[i % PATTERN.length];
  STREAM.push({ tSec: i * 20, formatId: fmt, optedIn: fmt === "rewarded" });
}
let volCap = 8, forCap = 0;
const gateCfg = () => ({ formats: cfg.formats.map((f) => f.id === "rewarded" ? { ...f, dailyCap: volCap }
  : f.id === "interstitial" ? { ...f, dailyCap: forCap } : f) });
const cv = document.getElementById("bars"), ctx = cv.getContext("2d");
const set = (id, v) => { document.getElementById(id).textContent = String(v); };
function run() {
  const st = runRequests(newGate(), STREAM, gateCfg());
  let revenue = 0;
  for (const id of Object.keys(REV)) revenue += (st.counts[id] ?? 0) * REV[id];
  const factor = Math.max(freq.minFactor, 1000 - (st.counts.rewarded ?? 0) * freq.volPen
    - (st.counts.interstitial ?? 0) * freq.forPen);
  const net = Math.floor(revenue * factor / 1000);
  set("impressions", st.impressions); set("revenue", (revenue / 10).toFixed(1));
  set("factor", factor); set("net", (net / 10).toFixed(1));
  set("vlab", volCap); set("flab", forCap);
  set("counts", Object.keys(REV).map((id) => id + "=" + (st.counts[id] ?? 0)).join("  "));
  const ids = Object.keys(REV), maxC = Math.max(1, ...ids.map((id) => st.counts[id] ?? 0));
  ctx.fillStyle = "#0b1220"; ctx.fillRect(0, 0, cv.width, cv.height);
  ids.forEach((id, i) => {
    const c = st.counts[id] ?? 0, x = 40 + i * 110, h = Math.floor(150 * c / maxC);
    ctx.fillStyle = "#38bdf8"; ctx.fillRect(x, 180 - h, 70, h);
    ctx.fillStyle = "#94a3b8"; ctx.font = "12px system-ui";
    ctx.fillText(id, x, 196); ctx.fillText(String(c), x, 180 - h - 6);
  });
  globalThis.__pebble = { rewardedCap: volCap, interstitialCap: forCap, impressions: st.impressions,
    revenueDeciFen: revenue, factorPermille: factor, netProxyDeciFen: net,
    counts: st.counts, canvasDrawn: true };
}
document.getElementById("vol").addEventListener("input", (e) => { volCap = Number(e.target.value); run(); });
document.getElementById("forced").addEventListener("input", (e) => { forCap = Number(e.target.value); run(); });
document.getElementById("vol").value = String(volCap);
document.getElementById("forced").value = String(forCap);
run();
`;
const body = `  <h1>《合成小岛》· 广告频次与收益原型</h1>
  <div class="sub">内核由 core/src/l01/adgate.ts 提供:一天 60 次确定性请求流,闸门按 optIn / 每日上限 / 冷却判定;拖动滑杆看频次如何换成收益、又如何压低留存乘数</div>
  <div class="card row"><div><div class="k">展示总数</div><div class="big" id="impressions">-</div></div><div><div class="k">当日收益(分)</div><div class="big" id="revenue">-</div></div></div>
  <div class="card row"><div><div class="k">留存乘数(‰)</div><div class="big" id="factor">-</div></div><div><div class="k">净收益代理(分)</div><div class="big" id="net">-</div></div></div>
  <div class="card"><div class="k">各形态展示次数</div><div class="big" id="counts" style="font-size:15px">-</div></div>
  <div class="card"><canvas id="bars" width="480" height="210" style="width:480px;height:210px"></canvas></div>
  <div class="card"><div class="k">激励视频每日上限 <span id="vlab">8</span></div><input id="vol" type="range" min="0" max="12" step="1" style="width:100%"></div>
  <div class="card"><div class="k">插屏每日上限 <span id="flab">0</span></div><input id="forced" type="range" min="0" max="8" step="1" style="width:100%"></div>`;
const bytes = writePage(join(ROOT, "web/dist/l01.html"),
  page({ title: "《合成小岛》广告频次原型 · l01", css: "#bars { background: #0b1220; }", body, script }));
console.log(JSON.stringify({ html: "web/dist/l01.html", htmlBytes: bytes,
  bundleLines: js.split("\n").length }, null, 2));
#!/usr/bin/env node
// file: web/verify_l01_ads.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l01_ads.mjs web/dist/l01.html
// W 层验收:Playwright 无头打开页面,断言默认频次下的展示数 / 收益 / 留存乘数 / 净收益代理;
// 拖两个真实滑杆改每日上限并断言指标随之变化;检查画布真画了像素、页面无报错,并截图。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";

const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/l01.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const page = await browser.newPage({ viewport: { width: 760, height: 1200 } });
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const snap = () => page.evaluate(() => globalThis.__pebble);
const nums = (s) => [s.impressions, s.revenueDeciFen, s.factorPermille, s.netProxyDeciFen];

const s0 = await snap();
assert.deepEqual([s0.rewardedCap, s0.interstitialCap], [8, 0]);
assert.deepEqual(nums(s0), [21, 283, 520, 147]);
assert.deepEqual(s0.counts, { rewarded: 8, banner: 10, native: 3 });

await page.locator("#vol").fill("12");
const s1 = await snap();
assert.deepEqual(nums(s1), [25, 387, 280, 108]);

await page.locator("#forced").fill("6");
const s2 = await snap();
assert.deepEqual(nums(s2), [31, 459, 200, 91]);

await page.locator("#vol").fill("0"); await page.locator("#forced").fill("0");
const s3 = await snap();
assert.deepEqual([s3.impressions, s3.revenueDeciFen, s3.factorPermille], [13, 75, 1000]);

const painted = await page.evaluate(() => {
  const cv = document.getElementById("bars");
  const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
  let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0) n += 1;
  return n;
});
await page.screenshot({ path: join(ROOT, "out/shots/l01.png"), fullPage: true });
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.ok(painted > 40000, `条形画布应有绘制,实测 ${painted} px`);

console.log(JSON.stringify({ page: "web/dist/l01.html",
  default: nums(s0), vol12: nums(s1), vol12forced6: nums(s2),
  allZero: [s3.impressions, s3.revenueDeciFen, s3.factorPermille],
  canvasPaintedPx: painted, screenshot: "out/shots/l01.png" }, null, 2));
console.log("SMOKE-WL01: PASS");

Playwright 无头断言四组频次下 [展示数, 当日收益, 留存乘数, 净收益代理] 逐位可复现(默认 [21, 283, 520, 147]、激励上限拉到 12 时 [25, 387, 280, 108]、再叠插屏 6 次时 [31, 459, 200, 91]、全归零时 [13, 75, 1000]),画布实测绘制 100800 像素,末行打印 SMOKE-WL01: PASS。

拖动滑杆能看到本节的核心张力:把激励视频上限从 8 拉到 12,当日收益从 283 升到 387,但留存乘数从 520 掉到 280、净收益代理从 147 掉到 108——多展示换来的收益,被体验损失吃掉了。这正是 1.3 要正式建模的问题:频次不是越高越好,存在一个让净收益最大的中间值。

阅读导航:上一节:1.1 广告形态与场景设计 · 下一节:1.3 广告频次与体验平衡 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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