12.1 特效强度、时长与屏占比预算

特效预算不是「看着炫就行」:本节把《星屑旅店》四个特效拆成爆发、持续、消散三阶段,用整数毫秒、千分比屏占比、峰值存活粒子数与叠加层数算一笔可跑的账,并按四项上限逐项判违例,给出超屏占比与超层数必须 fail 的负例,附 S 层脚本与 P 层 TS 内核的逐位一致复算。

特效是美术里最容易被「审美主导」的一环:一张图好不好看,评审还能吵出个结论;一团粒子「够不够炫」,往往是谁嗓门大谁赢。本节把这件事拉回可算的数:一个特效由爆发、持续、消散三个阶段组成,每个阶段给出整数毫秒时长、峰值存活粒子数、粒子精灵边长,再合成一个千分比的屏占比和一个叠加层数,最后按上限逐项判违例。分工先划清:粒子的渲染原理——GPU 上的发射器、实例化、排序与软粒子——在 GPU 粒子与特效管线 ,本节不重推原理,只做美术侧的特效预算与反馈层级规范:把「炫」翻译成一组能被门禁拦下来的整数。

12.1.1 为什么特效要先做预算

特效有两个和别的资产不同的性质:

  • 它是时间维度的资产。一张家具图是静态的,一次特效却是「几百毫秒里每帧都在变」。评审看截图看不出问题,必须看时长。
  • 它会互相叠加。UI 动效、拾取反馈、升级光环可能同时触发。单个特效合格,不代表它们叠在一起合格——屏幕只有一块。

于是特效规范不能只写「拾取用金色火花」,而要写清:多长时间、多少粒子、占屏多少、能和谁同时播。这四件事全是整数,正好落在本套「预算与阈值一律整数」的纪律上。

12.1.2 三阶段与四个整数口径

把任何一次特效播放切成三段:

阶段含义典型时长粒子行为
爆发 burst能量释放的瞬间90–300ms粒子数陡增到峰值
持续 sustain效果维持的主段150–900ms粒子数稳定
消散 dissipate余韵与淡出210–600ms粒子数回落

四个整数口径:

  1. 总时长 totalMs = 三阶段之和(毫秒,整数)。
  2. 峰值存活粒子数 maxAlive = 三阶段里最大的那个数(不是求和,是「同一时刻最多几个」)。
  3. 屏占比 coveragePermille = maxAlive × spriteArea × 1000 // designArea。分子是「最坏情况:所有粒子精灵互不重叠地铺开」,分母是设计分辨率面积。整数地板除,单位千分比(‰)。
  4. 叠加层数 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=36+75=81‰通过
拾取+蒸汽+升级1+2+3=66+75+284=365‰越界(层数 6>3、屏占比 365>300)

pickup_and_steam 刚好卡在层数上限 3 上(「刚好卡满」是允许的,因为判据是 > 而非 >=),但再叠一个升级光环就爆了。修法有三条,按代价从小到大:

  1. 降级:升级时把拾取特效降为最简版本(层数 0,用 UI 数字代替粒子);
  2. 排队:升级特效延后 200ms 再播,错开叠加窗口;
  3. 合并:把三个特效合成一条多层管线,层数按「管道」算而不是按「特效」算。

三条都不是「把粒子数砍一半」这种伤观感的做法——预算逼出的是编排决策,不是审美妥协。

12.1.7 边界与取舍

  • 真实粒子系统的性能开销(发射器吞吐、overdraw、GPU 占用)需要真实环境(引擎 / 渲染器 / 真机),命令行不可复现,标 X;本节只对参数表做整数预算,不预测真实帧率。
  • 屏占比是最坏情况上界,真实值只会更小;用它做预算保守但安全。
  • 上限(300‰ / 3 层 / 128 粒子 / 2000ms)是**《星屑旅店》这一款治愈系休闲游戏的取值**,不是普适真理。重动作游戏的上限会高得多——换项目就要换 caps,但算法不变。
  • 全部量为整数类,TS 与 Python 逐位一致;token 引用完整性是布尔判据,同样逐位一致。

阅读导航:上一节:11.3 场景构图与模块化 kit · 下一节:12.2 动画帧率与状态规范 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则