特效是美术里最容易被「审美主导」的一环:一张图好不好看,评审还能吵出个结论;一团粒子「够不够炫」,往往是谁嗓门大谁赢。本节把这件事拉回可算的数:一个特效由爆发、持续、消散三个阶段组成,每个阶段给出整数毫秒时长、峰值存活粒子数、粒子精灵边长,再合成一个千分比的屏占比和一个叠加层数,最后按上限逐项判违例。分工先划清:粒子的渲染原理——GPU 上的发射器、实例化、排序与软粒子——在 GPU 粒子与特效管线 ,本节不重推原理,只做美术侧的特效预算与反馈层级规范:把「炫」翻译成一组能被门禁拦下来的整数。
12.1.1 为什么特效要先做预算
特效有两个和别的资产不同的性质:
- 它是时间维度的资产。一张家具图是静态的,一次特效却是「几百毫秒里每帧都在变」。评审看截图看不出问题,必须看时长。
- 它会互相叠加。UI 动效、拾取反馈、升级光环可能同时触发。单个特效合格,不代表它们叠在一起合格——屏幕只有一块。
于是特效规范不能只写「拾取用金色火花」,而要写清:多长时间、多少粒子、占屏多少、能和谁同时播。这四件事全是整数,正好落在本套「预算与阈值一律整数」的纪律上。
12.1.2 三阶段与四个整数口径
把任何一次特效播放切成三段:
| 阶段 | 含义 | 典型时长 | 粒子行为 |
|---|---|---|---|
| 爆发 burst | 能量释放的瞬间 | 90–300ms | 粒子数陡增到峰值 |
| 持续 sustain | 效果维持的主段 | 150–900ms | 粒子数稳定 |
| 消散 dissipate | 余韵与淡出 | 210–600ms | 粒子数回落 |
四个整数口径:
- 总时长
totalMs= 三阶段之和(毫秒,整数)。 - 峰值存活粒子数
maxAlive= 三阶段里最大的那个数(不是求和,是「同一时刻最多几个」)。 - 屏占比
coveragePermille=maxAlive × spriteArea × 1000 // designArea。分子是「最坏情况:所有粒子精灵互不重叠地铺开」,分母是设计分辨率面积。整数地板除,单位千分比(‰)。 - 叠加层数
layers= 这个特效同时占用几条独立的绘制层。
屏占比是上界不是精确值:真实粒子会重叠、会被遮挡,实际覆盖只会更小。用上界做预算的好处是保守且可算——它永远不会低估风险。
12.1.3 预算表(D 层配置)
下面这段是 D 层配置,即《星屑旅店》的特效预算表。它声明了设计分辨率、四项上限(屏占比 / 层数 / 存活粒子 / 时长)、四个特效的三阶段参数,以及两个并发叠加组合。第四个特效 overload_burst 是故意越界的样本,用来证明门禁真的会拦下东西:
{
"project": "stardust-inn",
"schemaVersion": 1,
"specRef": "config/visual_spec.json",
"note": "特效预算表:每个特效给出三阶段(爆发 burst / 持续 sustain / 消散 dissipate)的整数毫秒时长、各阶段同时存活粒子数、粒子精灵边长(像素)、叠加层数与颜色 token 引用。屏占比 = 峰值存活粒子数 × 精灵面积 × 1000 // 设计分辨率面积(千分比,整数地板除,最坏情况忽略重叠)。",
"designResolution": { "width": 1280, "height": 720 },
"caps": {
"screenPermilleCap": 300,
"layerCap": 3,
"aliveParticleCap": 128,
"durationCapMs": 2000
},
"effects": [
{
"id": "coin_pickup", "cn": "拾取金币", "tier": "light",
"phases": { "burstMs": 90, "sustainMs": 150, "dissipateMs": 210 },
"alive": { "burst": 10, "sustain": 6, "dissipate": 4 },
"spritePx": 24, "layers": 1, "tokens": ["accent"]
},
{
"id": "steam_vent", "cn": "蒸汽喷口", "tier": "medium",
"phases": { "burstMs": 180, "sustainMs": 540, "dissipateMs": 360 },
"alive": { "burst": 18, "sustain": 30, "dissipate": 14 },
"spritePx": 48, "layers": 2, "tokens": ["muted"]
},
{
"id": "level_up", "cn": "升级光环", "tier": "heavy",
"phases": { "burstMs": 300, "sustainMs": 900, "dissipateMs": 600 },
"alive": { "burst": 48, "sustain": 64, "dissipate": 32 },
"spritePx": 64, "layers": 3, "tokens": ["accent", "warn"]
},
{
"id": "overload_burst", "cn": "过载爆闪(越界样本)", "tier": "heavy",
"phases": { "burstMs": 240, "sustainMs": 720, "dissipateMs": 480 },
"alive": { "burst": 128, "sustain": 120, "dissipate": 96 },
"spritePx": 96, "layers": 5, "tokens": ["danger"]
}
],
"combos": [
{ "id": "pickup_and_steam", "cn": "拾取+蒸汽", "effects": ["coin_pickup", "steam_vent"] },
{ "id": "triple_stack", "cn": "拾取+蒸汽+升级", "effects": ["coin_pickup", "steam_vent", "level_up"] }
]
}
注意 tokens 字段:每个特效必须声明它引用的颜色 token,而这些 token 必须存在于 config/visual_spec.json(本套唯一色彩真源,本节只读)。这样「特效用了什么颜色」就不再是口口相传,而是可校验的引用。
12.1.4 S 层:三阶段整数账
下面这段是 S 层脚本,读 visual_spec.json(只读 token 真源)与 sp12_vfx.json,对每个特效算三阶段账,再算两个并发组合:
#!/usr/bin/env python3
# file: sim/sp12_vfx.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp12_vfx.py
"""12.1 特效三阶段(爆发/持续/消散)整数预算的 S 层账。
读 config/visual_spec.json(token 真源,只读)与 config/sp12_vfx.json:
totalMs = burstMs + sustainMs + dissipateMs
maxAlive = 三阶段存活粒子数的最大值
spriteArea = spritePx * spritePx
coveragePermille = maxAlive * spriteArea * 1000 // (designW * designH) ← 整数地板除
再按 caps 判违例(超屏占比 / 层数 / 存活粒子 / 时长),并算并发叠加组合的合计。
产出 out/sp12_vfx.json,供 P 层跨语言对照。全部整数类,逐位一致。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/visual_spec.json")
CFG = os.path.join(ROOT, "config/sp12_vfx.json")
OUT = os.path.join(ROOT, "out")
def load(p):
with open(p, encoding="utf-8") as f:
return json.load(f)
def effect_account(effect, design_area):
"""单个特效的三阶段整数预算账。"""
ph = effect["phases"]
total_ms = ph["burstMs"] + ph["sustainMs"] + ph["dissipateMs"]
max_alive = max(effect["alive"].values())
sprite_area = effect["spritePx"] * effect["spritePx"]
coverage = (max_alive * sprite_area * 1000) // design_area
return {
"id": effect["id"], "cn": effect["cn"], "tier": effect["tier"],
"totalMs": total_ms, "maxAlive": max_alive, "spritePx": effect["spritePx"],
"spriteArea": sprite_area, "layers": effect["layers"],
"coveragePermille": coverage, "tokens": effect["tokens"],
}
def effect_violations(acc, caps):
"""按 caps 逐项判违例,返回违例字段名列表(有序、可复现)。"""
v = []
if acc["coveragePermille"] > caps["screenPermilleCap"]:
v.append("coveragePermille")
if acc["layers"] > caps["layerCap"]:
v.append("layers")
if acc["maxAlive"] > caps["aliveParticleCap"]:
v.append("aliveParticleCap")
if acc["totalMs"] > caps["durationCapMs"]:
v.append("durationCapMs")
return v
def combo_account(combo, accs, caps):
"""并发叠加组合:层数求和、屏占比求和(最坏情况,忽略重叠)。"""
parts = [accs[e] for e in combo["effects"]]
layers = sum(p["layers"] for p in parts)
coverage = sum(p["coveragePermille"] for p in parts)
v = []
if coverage > caps["screenPermilleCap"]:
v.append("coveragePermille")
if layers > caps["layerCap"]:
v.append("layers")
return {"id": combo["id"], "cn": combo["cn"], "effects": combo["effects"],
"layers": layers, "coveragePermille": coverage, "violations": v}
def main() -> int:
spec, cfg = load(SPEC), load(CFG)
dr = cfg["designResolution"]
design_area = dr["width"] * dr["height"]
caps = cfg["caps"]
tokens = spec["tokens"]
accs = {}
rows = []
bad_tokens = []
for e in cfg["effects"]:
for t in e["tokens"]:
if t not in tokens:
bad_tokens.append({"effect": e["id"], "token": t})
acc = effect_account(e, design_area)
acc["violations"] = effect_violations(acc, caps)
accs[e["id"]] = acc
rows.append(acc)
combos = [combo_account(c, accs, caps) for c in cfg["combos"]]
summary = {
"project": spec["project"], "specVersion": spec["specVersion"],
"designArea": design_area, "caps": caps,
"effectCount": len(rows), "effects": rows,
"combos": combos,
"violatingEffects": [r["id"] for r in rows if r["violations"]],
"violatingCombos": [c["id"] for c in combos if c["violations"]],
"badTokens": bad_tokens,
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "sp12_vfx.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"designArea={design_area} effects={len(rows)} combos={len(combos)}")
for r in rows:
print(f" {r['id']:<15} total={r['totalMs']:>5}ms alive={r['maxAlive']:>3} "
f"cov={r['coveragePermille']:>4}‰ layers={r['layers']} "
f"viol={r['violations']}")
for c in combos:
print(f" combo {c['id']:<16} layers={c['layers']} cov={c['coveragePermille']}‰ "
f"viol={c['violations']}")
print(f"violatingEffects={summary['violatingEffects']} badTokens={len(bad_tokens)}")
print("wrote out/sp12_vfx.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp12_vfx.py:
designArea=921600 effects=4 combos=2
coin_pickup total= 450ms alive= 10 cov= 6‰ layers=1 viol=[]
steam_vent total= 1080ms alive= 30 cov= 75‰ layers=2 viol=[]
level_up total= 1800ms alive= 64 cov= 284‰ layers=3 viol=[]
overload_burst total= 1440ms alive=128 cov=1280‰ layers=5 viol=['coveragePermille', 'layers']
combo pickup_and_steam layers=3 cov=81‰ viol=[]
combo triple_stack layers=6 cov=365‰ viol=['coveragePermille', 'layers']
violatingEffects=['overload_burst'] badTokens=0
wrote out/sp12_vfx.json
读这张表:coin_pickup 屏占比只有 6‰(0.6%),steam_vent 75‰,level_up 284‰——一个越重的特效确实占屏越多,但仍在 300‰ 上限内。overload_burst 屏占比 1280‰(比整屏还大,因为 128 个 96px 精灵铺开远超设计分辨率)、层数 5,两项同时越界,violatingEffects=['overload_burst'] 精确把它抓出来。badTokens=0 说明所有 token 引用都在真源里。
12.1.5 P 层:同一套规则的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp12/vfx.ts,与 Python 用同一套整数地板除规则:
// file: core/src/sp12/vfx.ts
// tier: P
// verify: node --test 'core/test/sp12_*.test.ts'
// 12.1 特效预算的 TS 侧校验器。与 sim/sp12_vfx.py 同一套整数规则:
// totalMs 求和、maxAlive 取最大、coveragePermille = maxAlive * spriteArea * 1000 // designArea
// (整数地板除),再按 caps 逐项判违例。整数类逐位一致。
export function effectAccount(effect: any, designArea: number): any {
const ph = effect.phases;
const totalMs = ph.burstMs + ph.sustainMs + ph.dissipateMs;
const maxAlive = Math.max(effect.alive.burst, effect.alive.sustain, effect.alive.dissipate);
const spriteArea = effect.spritePx * effect.spritePx;
const coveragePermille = Math.floor((maxAlive * spriteArea * 1000) / designArea);
return {
id: effect.id,
totalMs,
maxAlive,
spriteArea,
layers: effect.layers,
coveragePermille,
};
}
export function effectViolations(acc: any, caps: any): string[] {
const v: string[] = [];
if (acc.coveragePermille > caps.screenPermilleCap) v.push("coveragePermille");
if (acc.layers > caps.layerCap) v.push("layers");
if (acc.maxAlive > caps.aliveParticleCap) v.push("aliveParticleCap");
if (acc.totalMs > caps.durationCapMs) v.push("durationCapMs");
return v;
}
export function comboAccount(combo: any, accs: any, caps: any): any {
const parts = combo.effects.map((id: string) => accs[id]);
const layers = parts.reduce((s: number, p: any) => s + p.layers, 0);
const coveragePermille = parts.reduce((s: number, p: any) => s + p.coveragePermille, 0);
const v: string[] = [];
if (coveragePermille > caps.screenPermilleCap) v.push("coveragePermille");
if (layers > caps.layerCap) v.push("layers");
return { id: combo.id, layers, coveragePermille, violations: v };
}
配套的 P 层测试 core/test/sp12_vfx.test.ts 读 Python 落盘的 out/sp12_vfx.json 做跨语言对照,并用内存样本构造「超屏占比」「超层数」两个负例。
实跑 node --test 'core/test/sp12_*.test.ts'(此处只列本节相关的 5 条;三节合计 14 条全绿):
✔ 特效三阶段预算账:TS 复算与 Python 产物逐位一致
✔ 并发叠加组合:层数与屏占比合计逐位一致
✔ 负例:超屏占比必须 fail
✔ 负例:超叠加层数必须 fail
✔ 正例:三阶段在预算内的特效零违例
ℹ tests 14
ℹ pass 14
ℹ fail 0
屏占比、层数、粒子数、时长全是整数,TS 与 Python 逐位一致;本节不涉及任何色彩派生量,故没有浮点容差问题。
12.1.6 并发叠加:单特效合格不代表同时播放合格
这是特效预算最容易被忽略的一条。看两个组合:
| 组合 | 层数合计 | 屏占比合计 | 判定 |
|---|---|---|---|
| 拾取+蒸汽 | 1+2=3 | 6+75=81‰ | 通过 |
| 拾取+蒸汽+升级 | 1+2+3=6 | 6+75+284=365‰ | 越界(层数 6>3、屏占比 365>300) |
pickup_and_steam 刚好卡在层数上限 3 上(「刚好卡满」是允许的,因为判据是 > 而非 >=),但再叠一个升级光环就爆了。修法有三条,按代价从小到大:
- 降级:升级时把拾取特效降为最简版本(层数 0,用 UI 数字代替粒子);
- 排队:升级特效延后 200ms 再播,错开叠加窗口;
- 合并:把三个特效合成一条多层管线,层数按「管道」算而不是按「特效」算。
三条都不是「把粒子数砍一半」这种伤观感的做法——预算逼出的是编排决策,不是审美妥协。
12.1.7 边界与取舍
- 真实粒子系统的性能开销(发射器吞吐、overdraw、GPU 占用)需要真实环境(引擎 / 渲染器 / 真机),命令行不可复现,标 X;本节只对参数表做整数预算,不预测真实帧率。
- 屏占比是最坏情况上界,真实值只会更小;用它做预算保守但安全。
- 上限(300‰ / 3 层 / 128 粒子 / 2000ms)是**《星屑旅店》这一款治愈系休闲游戏的取值**,不是普适真理。重动作游戏的上限会高得多——换项目就要换 caps,但算法不变。
- 全部量为整数类,TS 与 Python 逐位一致;token 引用完整性是布尔判据,同样逐位一致。
阅读导航:上一节:11.3 场景构图与模块化 kit · 下一节:12.2 动画帧率与状态规范 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。