10.2 多分辨率资源分级

同一套 UI 图集要发到 720p 手机、平板和 4K 桌面,全档打包会白白多背几十 MB。本节把分级做成整数账:由屏幕与设计基准算缩放千分比,选最小且不小于它的档位,按档位比例算图集字节,再对照全档打包与按需打包的差额,Python、TS 与浏览器看板三处逐位一致。

10.2 多分辨率资源分级

10.1 安全区与刘海屏适配 解决「元件放在哪」;这一节解决「元件按多大的图发」。同一套 UI 图集要覆盖 720p 手机到 4K 桌面,如果只发最高档,低端机要白背几倍的显存与包体;如果每台设备单独切一份,包体又碎得无法管理。分级是把连续的缩放比切成有限几档,每档一份图集,设备按自己的缩放比选一档——档位是离散的,账才是可算的。

10.2.1 档位表与选档规则

config/ta10_tiers.json 里设计基准 designResolution=[1920,1080]、基础图集 baseAtlas=[2048,2048]、每像素 bytesPerPixel=4,四档比例为 1000 / 1500 / 2000 / 3000(即 1x / 1.5x / 2x / 3x):

档位ratioPermille图集尺寸字节
t1x10002048×204816777216
t1_5x15003072×307237748736
t2x20004096×409667108864
t3x30006144×6144150994944

两条规则都在整数域:缩放千分比取宽高各自与设计基准之比的较小者(min(w*1000//baseW, h*1000//baseH)),选档取第一个 ratioPermille >= scale 的档位,全都不够则退到最大档。为什么取较小者而不是较大者?因为 UI 只要保证「不糊」,宽度够但高度不够时整屏已经装不下,按高度缩小才是安全解。

10.2.2 S 层:缩放、选档与字节账

下面这段是 S 层脚本,读配置算每档字节、每台设备的缩放千分比与选档,并对照「全档打包」与「只打设备群实际用到的档位」:

#!/usr/bin/env python3
# file: sim/ta10_tiers.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta10_tiers.py
"""多分辨率资源分级的字节账(S 层,卷二 10.2)。缩放千分比取宽高比的小者,选最小且不小于它的
档位,图集字节按档位比例缩放(地板除)后乘 bpp;对照「全档打包」与「只打设备群实际需要的档位」。
"""
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config/ta10_tiers.json")
OUT = os.path.join(ROOT, "out")


def scale_permille(screen, design):
    """设备缩放千分比:宽高各自与设计基准比,取小者。整数地板除。"""
    return min(screen[0] * 1000 // design[0], screen[1] * 1000 // design[1])


def pick_tier(tiers, scale):
    for t in tiers:
        if t["ratioPermille"] >= scale:
            return t
    return tiers[-1]


def atlas_bytes(base, ratio, bpp):
    """该档图集字节:宽高各按 ratio 缩放(地板除),再乘每像素字节。"""
    return (base[0] * ratio // 1000) * (base[1] * ratio // 1000) * bpp


def main():
    cfg = json.load(open(CFG, encoding="utf-8"))
    os.makedirs(OUT, exist_ok=True)
    base, bpp, tiers = cfg["baseAtlas"], cfg["bytesPerPixel"], cfg["tiers"]
    tb = {t["id"]: atlas_bytes(base, t["ratioPermille"], bpp) for t in tiers}
    lines, devices, used = [], [], set()
    for d in cfg["devices"]:
        s = scale_permille(d["screen"], cfg["designResolution"])
        t = pick_tier(tiers, s)
        used.add(t["id"])
        devices.append({"id": d["id"], "screen": d["screen"], "scalePermille": s, "tier": t["id"],
                        "tierRatioPermille": t["ratioPermille"],
                        "overshootPermille": (t["ratioPermille"] - s) * 1000 // t["ratioPermille"],
                        "tierBytes": tb[t["id"]]})
        lines.append(f"{d['id']}:{t['id']}:{s}")
    ship_all = sum(tb.values())
    ship_used = sum(tb[t] for t in used)
    rep = {"project": cfg["project"], "designResolution": cfg["designResolution"], "baseAtlas": base,
           "bytesPerPixel": bpp, "usedTiers": sorted(used), "devices": devices, "tiers": [
               {"id": t["id"], "ratioPermille": t["ratioPermille"], "bytes": tb[t["id"]]} for t in tiers],
           "shipAllBytes": ship_all, "shipUsedBytes": ship_used, "savedBytes": ship_all - ship_used,
           "digest": hashlib.sha256("\n".join(sorted(lines)).encode("utf-8")).hexdigest()[:16]}
    with open(os.path.join(OUT, "ta10_tiers.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2, sort_keys=True)
    for t in rep["tiers"]:
        print(f"  {t['id']:<6} ratio={t['ratioPermille']:<5} bytes={t['bytes']}")
    for d in devices:
        print(f"  {d['id']:<12} scale={d['scalePermille']:<5} tier={d['tier']:<6} "
              f"overshoot={d['overshootPermille']} bytes={d['tierBytes']}")
    print(f"usedTiers={rep['usedTiers']}")
    print(f"shipAll={ship_all} shipUsed={ship_used} saved={ship_all - ship_used}")
    print(f"digest={rep['digest']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/ta10_tiers.py:

  t1x    ratio=1000  bytes=16777216
  t1_5x  ratio=1500  bytes=37748736
  t2x    ratio=2000  bytes=67108864
  t3x    ratio=3000  bytes=150994944
  phone_low    scale=562   tier=t1x    overshoot=438 bytes=16777216
  phone_hi     scale=750   tier=t1x    overshoot=250 bytes=16777216
  tablet       scale=1066  tier=t1_5x  overshoot=289 bytes=37748736
  desktop_4k   scale=2000  tier=t2x    overshoot=0 bytes=67108864
usedTiers=['t1_5x', 't1x', 't2x']
shipAll=272629760 shipUsed=121634816 saved=150994944
digest=037de4f6ab30ff81

四台设备只用到三档:t3x 没有任何设备选到,全档打包 272629760 字节里它是纯浪费。desktop_4k 的 overshoot=0 说明 2000 千分比恰好命中 t2x;phone_low 的 overshoot=438 说明它被拉到 1x 档仍有一大截余量,这是档位离散的必然代价。

10.2.3 P 层:同一套账的 TS 内核

下面这段是 P 层内核,与 Python 逐条对应——同一个取小者公式、同一个「最小且不小于」的选档、同一个先缩放后乘 bpp 的字节公式:

// file: core/src/ta10/tiers.ts
// tier: P
// verify: node --test 'core/test/ta10_*.test.ts'
// 卷二 10.2 多分辨率资源分级的 TS 侧实现。与 sim/ta10_tiers.py 同一套规则:缩放千分比取宽高比
// 的较小者(整数地板除),选最小且不小于它的档位,图集字节按档位比例缩放(地板除)再乘 bpp。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { createHash } from "node:crypto";

export type Tier = { id: string; ratioPermille: number };
export type Device = { id: string; screen: number[] };

export function scalePermille(screen: number[], design: number[]): number {
  return Math.min(Math.floor((screen[0] * 1000) / design[0]), Math.floor((screen[1] * 1000) / design[1]));
}

export function pickTier(tiers: Tier[], scale: number): Tier {
  for (const t of tiers) if (t.ratioPermille >= scale) return t;
  return tiers[tiers.length - 1];
}

export function atlasBytes(base: number[], ratio: number, bpp: number): number {
  const w = Math.floor((base[0] * ratio) / 1000);
  const h = Math.floor((base[1] * ratio) / 1000);
  return w * h * bpp;
}

export function selectionLines(cfg: any): string[] {
  const tiers = cfg.tiers as Tier[];
  const lines: string[] = [];
  for (const d of cfg.devices as Device[]) {
    const s = scalePermille(d.screen, cfg.designResolution);
    lines.push(`${d.id}:${pickTier(tiers, s).id}:${s}`);
  }
  return lines.sort();
}

export function digest16(lines: string[]): string {
  return createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
}

P 层测试读的是 Python 落盘的 out/ta10_tiers.json(不是同一份源码),做跨语言对照。实跑输出(node --test core/test/ta10_tiers.test.ts):

✔ 分级:TS 复算的选档行与 Python 逐位一致
✔ 每台设备的缩放千分比 / 档位 / 过冲 / 字节与 Python 一致
✔ 档位字节与打包对照与 Python 逐位一致
ℹ tests 3
ℹ pass 3
ℹ fail 0

两侧的选档 digest 都是 037de4f6ab30ff81——整数类,逐位一致。字节账同理:t3x 的 150994944 字节在 TS 侧用 Math.floor((2048*3000)/1000) 得到 6144,再乘 6144 与 4,与 Python 的 // 完全同值;换成浮点 2048*3.0 也不会有分歧,但一旦引入 Math.round 之类的补偿就会立刻分叉。

10.2.4 W 层:把档位账画成条形

下面这段是 W 层构建脚本,把 spec.ts 与 tiers.ts 擦除类型后内联进单文件 HTML,画四条按字节比例取宽的档位条,并列出台设备的选档:

// file: web/build_ta10_tiers.mjs
// tier: W
// verify: node web/build_ta10_tiers.mjs
// 卷二 10.2 W 层:把 core/src/sp00/spec.ts + ta10/tiers.ts 擦除类型后内联进单文件 HTML。
// 页面画 4 条档位字节条(宽度按字节比例,整数),并列出台设备选中的档位。
import { stripTypeScriptTypes } from "node:module";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta10_tiers.json"), "utf8"));
const strip = (p) => stripTypeScriptTypes(readFileSync(resolve(ROOT, p), "utf8"), { mode: "strip" })
  .replace(/^import[^;]*;\s*$/gm, "").replace(/^export\s*\{[^}]*\};?\s*$/gm, "").replace(/^export\s+/gm, "");
const js = [strip("core/src/sp00/spec.ts"), strip("core/src/ta10/tiers.ts")].join("\n");
const html = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>prism 多分辨率分级 · ${cfg.project}</title>
<style>body{margin:0;padding:20px;background:#1f2933;color:#eceff1;font:14px/1.6 system-ui}
canvas{display:block;border:1px solid #3d4a57;background:#252f3b}table{border-collapse:collapse;font:12px/1.5 ui-monospace,monospace;margin-top:14px}th,td{text-align:left;padding:3px 10px;border-bottom:1px solid #2d3a47}td.n{text-align:right}.row{display:flex;gap:28px;flex-wrap:wrap;align-items:flex-start}</style></head><body>
<h1>多分辨率资源分级看板</h1>
<div style="color:#94a3b8;font-size:12px">档位字节条(宽度按字节比例)· 设备选档按「最小且不小于缩放千分比」</div>
<div class="row"><canvas id="bars" width="760" height="180"></canvas>
<table id="dev"><thead><tr><th>设备</th><th>屏幕</th><th class="n">scale‰</th><th>档位</th><th class="n">过冲‰</th><th class="n">字节</th></tr></thead><tbody></tbody></table>
</div>
<script type="module">
${js}
const SPEC = ${JSON.stringify(spec)};
const CFG = ${JSON.stringify(cfg)};
const COLORS = { t1x: [56, 189, 248], t1_5x: [34, 197, 94], t2x: [245, 158, 11], t3x: [239, 68, 68] };
const BAR_X = 10, ROW_H = 42, BAR_MAX = 700;
const tierBytes = Object.fromEntries(CFG.tiers.map((t) => [t.id, atlasBytes(CFG.baseAtlas, t.ratioPermille, CFG.bytesPerPixel)]));
const maxBytes = Math.max(...Object.values(tierBytes));
const ctx = document.getElementById("bars").getContext("2d");
ctx.fillStyle = "rgb(" + SPEC.tokens.surface.join(",") + ")"; ctx.fillRect(0, 0, 760, 180);
const bars = {};
CFG.tiers.forEach((t, i) => {
  const w = Math.floor((tierBytes[t.id] * BAR_MAX) / maxBytes);
  const y = 10 + i * ROW_H;
  ctx.fillStyle = "rgb(" + COLORS[t.id].join(",") + ")";
  ctx.fillRect(BAR_X, y, w, 30);
  bars[t.id] = { x: BAR_X, y, w, h: 30 };
});
const devices = [], usedTiers = new Set();
const tb = document.querySelector("#dev tbody");
for (const d of CFG.devices) {
  const sc = scalePermille(d.screen, CFG.designResolution);
  const t = pickTier(CFG.tiers, sc);
  usedTiers.add(t.id);
  const over = Math.floor(((t.ratioPermille - sc) * 1000) / t.ratioPermille);
  devices.push({ id: d.id, screen: d.screen, scalePermille: sc, tier: t.id, overshootPermille: over, tierBytes: tierBytes[t.id] });
  const tr = document.createElement("tr");
  tr.innerHTML = "<td>" + d.id + "</td><td>" + d.screen.join("x") + "</td><td class=\\"n\\">" + sc + "</td><td>" + t.id + "</td><td class=\\"n\\">" + over + "</td><td class=\\"n\\">" + tierBytes[t.id] + "</td>";
  tb.append(tr);
}
const shipAll = Object.values(tierBytes).reduce((a, b) => a + b, 0);
const shipUsed = [...usedTiers].reduce((a, id) => a + tierBytes[id], 0);
globalThis.__prism = { tierBytes, bars, colors: COLORS, devices, usedTiers: [...usedTiers].sort(), shipAll, shipUsed, saved: shipAll - shipUsed };
document.title = "prism ta10 tiers ready";
</script></body></html>`;
const out = resolve(ROOT, "web/dist/ta10_tiers.html");
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, html, "utf8");
console.log(`built web/dist/ta10_tiers.html (${Buffer.byteLength(html)} bytes)`);

下面这段是 W 层断言脚本,用无头 Chromium 打开看板,采样 globalThis.__prism 与 Python 落盘的 JSON 对照,再按像素验证四条档位条的颜色:

// file: web/verify_ta10_tiers.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ta10_tiers.mjs
// 卷二 10.2 W 层断言:无头 Chromium 打开分级看板,采样 globalThis.__prism 与 Python 落盘的
// out/ta10_tiers.json 对照;再按像素验证 4 条档位条的颜色。
import { createRequire } from "node:module";
import { createHash } from "node:crypto";
import { readFileSync, mkdirSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta10_tiers.json"), "utf8"));
const pageUrl = pathToFileURL(resolve(ROOT, "web/dist/ta10_tiers.html")).href;
mkdirSync(resolve(ROOT, "out/shots"), { recursive: true });
let pass = 0, fail = 0;
const check = (n, ok, extra = "") => { ok ? (pass += 1) : (fail += 1); console.log(`${ok ? "pass" : "fail"} ${n}${extra ? " " + extra : ""}`); };

const browser = await chromium.launch({ headless: true });
const warm = await browser.newPage();          // 冷启动预热:首个 page 读 canvas 恒空白
await warm.goto(pageUrl);
await warm.close();
const page = await browser.newPage({ viewport: { width: 1100, height: 700 } });
const errors = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(pageUrl);
await page.waitForFunction(() => globalThis.__prism !== undefined, null, { timeout: 15000 });
const s = await page.evaluate(() => globalThis.__prism);
check("无 pageerror", errors.length === 0, errors.join("|"));
const lines = s.devices.map((d) => `${d.id}:${d.tier}:${d.scalePermille}`).sort();
const digest = createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
check("选档 digest 与 Python 逐位一致", digest === py.digest, `${digest}`);
check("每台设备的缩放/档位/过冲/字节与 Python 逐位一致",
  JSON.stringify(s.devices.map((d) => [d.id, d.scalePermille, d.tier, d.overshootPermille, d.tierBytes])) ===
  JSON.stringify(py.devices.map((d) => [d.id, d.scalePermille, d.tier, d.overshootPermille, d.tierBytes])));
check("档位字节与使用档位与 Python 逐位一致", JSON.stringify(s.tierBytes) === JSON.stringify(Object.fromEntries(py.tiers.map((t) => [t.id, t.bytes]))) && JSON.stringify(s.usedTiers) === JSON.stringify(py.usedTiers));
check("打包对照与 Python 逐位一致", s.shipAll === py.shipAllBytes && s.shipUsed === py.shipUsedBytes && s.saved === py.savedBytes, `${s.shipAll}/${s.shipUsed}/${s.saved}`);
const px = await page.evaluate(() => { const ctx = document.getElementById("bars").getContext("2d"); const out = {};
  for (const [id, b] of Object.entries(globalThis.__prism.bars)) { const d = ctx.getImageData(b.x + (b.w >> 1), b.y + 15, 1, 1).data; out[id] = [d[0], d[1], d[2]]; }
  return { out, colors: globalThis.__prism.colors }; });
let barOk = true;
for (const id of Object.keys(s.tierBytes)) if (JSON.stringify(px.out[id]) !== JSON.stringify(px.colors[id])) barOk = false;
check("4 条档位条中心色 = 各自调色板色", barOk, JSON.stringify(px.out));
const drawn = 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 = 3; i < d.length; i += 4) if (d[i] > 0) n += 1; return n; });
check("canvas 已绘制", drawn >= 1, `${drawn} px`);
const shot = resolve(ROOT, "out/shots/ta10_tiers.png");
await page.screenshot({ path: shot, fullPage: true });
await browser.close();
check("截图非空", statSync(shot).size > 0, `${statSync(shot).size} bytes`);
console.log(`ℹ pass ${pass}`);
console.log(`ℹ fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);

实跑 node web/build_ta10_tiers.mjs 后接 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ta10_tiers.mjs:

built web/dist/ta10_tiers.html (9308 bytes)
pass 无 pageerror
pass 选档 digest 与 Python 逐位一致 037de4f6ab30ff81
pass 每台设备的缩放/档位/过冲/字节与 Python 逐位一致
pass 档位字节与使用档位与 Python 逐位一致
pass 打包对照与 Python 逐位一致 272629760/121634816/150994944
pass 4 条档位条中心色 = 各自调色板色 {"t1x":[56,189,248],"t1_5x":[34,197,94],"t2x":[245,158,11],"t3x":[239,68,68]}
pass canvas 已绘制 136800 px
pass 截图非空 40306 bytes
ℹ pass 8
ℹ fail 0

8 项全绿。档位条的宽度是 tierBytes * 700 / maxBytes 的整数地板除,颜色是脚本内的整数调色板(t1x [56,189,248]、t1_5x [34,197,94]、t2x [245,158,11]、t3x [239,68,68]),像素采样与之一致。canvas 已绘制 只断言「≥ 1 px」:无头 Chromium 冷启动的首个 page 读 canvas 会恒为空白,故脚本先开一个预热页走一遍再关掉。

10.2.5 三条边界

  • 档位是离散的,过冲无法消除。phone_low 的 overshoot=438 就是它被迫用 1x 档多出来的余量;档位越密余量越小,但每多一档就多一份图集与一次切换成本。
  • 本节只算字节,不算纹理格式。bytesPerPixel=4 假定 RGBA8 未压缩;真机上的 ASTC / ETC2 压缩比、mipmap 与图集排布都要在真实打包器里出数——需真实环境(引擎 / 打包器),命令行不可复现。
  • 「全档打包」与「按需打包」是两条发布口径,不是优劣判断:前者换的是运行时无缺档风险,后者换的是包体与显存;本节只把两者的差额算成整数,选哪条由发行策略决定。

阅读导航:上一节:10.1 安全区与刘海屏适配 · 下一节:10.3 UI 合批与性能 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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