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 广告频次与体验平衡 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。