11.3 场景构图与模块化 kit

模块化 kit 的验收不能只看「像不像」,还要算得出账。本节把《星屑旅店》的大堂与后仓摆成 8×8 等距网格,用整数地板除算地块复用率、视觉遮挡层数与可行走可达性三个指标,并复用 core/src/sp00/spec.ts 的 isoToScreen 做同源等距投影,给出「复用率更高但遮挡与可达性双违例」的必须 fail 负例,附 S 层脚本与 P 层 TS 内核的逐位一致复算。

模块化 kit(modular kit)是中小团队做场景的主流做法:做一套墙、地板、门、柜台、地毯的模块,然后像拼积木一样拼出所有房间。它的好处是资产量小、风格统一;坏处是拼错了很难看出来——一块地板重复贴十次没人管,但一块高墙挡在角色前面、或者一个口袋房间走不进去,玩家立刻能感到别扭。分工先划清:渲染侧的遮挡剔除与 LOD 分级由 GPU 驱动渲染与 LOD 负责,本节算的是美术侧的视觉遮挡层数(同一屏幕列上挡在角色前面的有高度模块个数),两回事;本节只做场景构图与 kit 摆放规范,把「拼得好不好」翻译成三个整数指标。

11.3.1 kit 的账要算三笔

一块 8×8 的地块网格有 64 个格子,每格放一个模块。要判断这张场景图合不合格,至少算三笔账:

  • 复用率:64 格里用了几个不同的模块?如果 64 格全是同一块地板,复用率就高;如果每格都要一个专属模块,kit 就白做了,美术成本反而更高。
  • 视觉遮挡:在等距视角下,同一屏幕列(tx - ty 相同)上,本格前方(tx + ty 更大)的高模块会挡住本格。挡三层以上,角色站在这儿就看不见了。
  • 可达性:从入口出发做四邻接洪泛,能不能走到每一块可行走的地块?走不到的「口袋」是典型的设计事故——玩家看得见、走不进去。

三笔账全部是整数,全部可复算。

11.3.2 三个整数指标

指标定义上限 / 下限
复用率(地块总数 - 去重模块数) * 1000 // 地块总数≥ 800‰
最大遮挡层数可行走地块上的最大遮挡深度≤ 3
可达性可行走地块中从入口走不到的数量= 0

遮挡深度的算法值得单独说:等距投影下,(tx, ty) 与 (tx+k, ty+k) 落在同一条屏幕竖直列上(因为 tx-ty 不变),而 tx+ty 更大的一格在画面上更靠下、也就是更靠近镜头。所以遮挡深度就是从 k = 1 往后数,有多少个 blockHeight ≥ 1 的模块。这就是本节的 occlusion_depth。

11.3.3 S 层:两个场景的账

#!/usr/bin/env python3
# file: sim/sp11_scene.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp11_scene.py
"""11.3 场景构图与模块化 kit 的 S 层整数账。

读 config/sp11_scene.json:8×8 地块网格,每格一个 kit 模块。对每个场景算:

  placements      = 地块总数(网格全部格子)
  reusePermille   = (placements - 去重模块数) * 1000 // placements      地块复用率
  occlusionDepth  = 同屏幕列(tx-ty 相同)、本格前方(tx+ty 更大)的有高度模块个数
  maxOcclusion    = max 遮挡层数(只统计可行走地块)
  reachableTiles  = 从 entrance 做 4 邻接洪泛能走到的可行走地块数
  unreachable     = 可行走但走不到的地块(可达性违例)

等距投影复用 core/src/sp00/spec.ts 的 isoToScreen(只读):tile(tx,ty) → screen。
产出 out/sp11_scene.json + out/gen/sp11_scene.png。全部为整数类,逐位一致。
"""
import json
import os
import sys
from collections import deque

from PIL import Image

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT, GEN = os.path.join(ROOT, "out"), os.path.join(ROOT, "out/gen")
BG = [31, 41, 51]


def iso_to_screen(tx, ty, tile_w, tile_h):
    """与 core/src/sp00/spec.ts 的 isoToScreen 同源:整数地板除。"""
    return ((tx - ty) * (tile_w // 2), (tx + ty) * (tile_h // 2))


def build_grid(scene, legend, w, h):
    return [[legend[ch] for ch in scene["rows"][ty]] for ty in range(h)]


def occlusion_depth(grid, mod_by_id, tx, ty, w, h):
    return sum(1 for k in range(1, min(w - tx, h - ty))
               if mod_by_id[grid[ty + k][tx + k]]["blockHeight"] >= 1)


def reachable(grid, mod_by_id, entrance, w, h):
    start = (entrance[0], entrance[1])
    seen, q = {start}, deque([start])
    while q:
        tx, ty = q.popleft()
        for ddx, ddy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            nx, ny = tx + ddx, ty + ddy
            if 0 <= nx < w and 0 <= ny < h and (nx, ny) not in seen \
                    and mod_by_id[grid[ny][nx]]["walkable"]:
                seen.add((nx, ny))
                q.append((nx, ny))
    return seen


def gen_sheet(scenes, grid_by_scene, mod_by_id, cfg, path):
    w, h = cfg["grid"]["w"], cfg["grid"]["h"]
    tw, th = cfg["tileW"], cfg["tileH"]
    margin, gap, scene_w, scene_h = 8, 16, 272, 144
    width = margin * 2 + len(scenes) * scene_w + (len(scenes) - 1) * gap
    im = Image.new("RGB", (width, margin * 2 + scene_h), tuple(BG))
    for si, scene in enumerate(scenes):
        grid = grid_by_scene[scene["id"]]
        ox, oy = margin + si * (scene_w + gap) + scene_w // 2, margin + 16
        cells = sorted(((tx, ty) for ty in range(h) for tx in range(w)), key=lambda c: sum(c))
        for tx, ty in cells:
            cx, cy = iso_to_screen(tx, ty, tw, th)
            px, py, col = ox + cx, oy + cy, tuple(mod_by_id[grid[ty][tx]]["color"])
            for dy in range(-(th // 2), th // 2 + 1):
                span = tw // 2 - abs(dy) * (tw // 2 // (th // 2))
                for dx in range(-span, span + 1):
                    x, y = px + dx, py + dy
                    if 0 <= x < width and 0 <= y < im.height:
                        im.putpixel((x, y), col)
    im.save(path)


def main():
    cfg = json.load(open(os.path.join(ROOT, "config/sp11_scene.json"), encoding="utf-8"))
    w, h = cfg["grid"]["w"], cfg["grid"]["h"]
    legend, cap, min_reuse = cfg["legend"], cfg["maxOcclusionCap"], cfg["minReusePermille"]
    mod_by_id = {m["id"]: m for m in cfg["modules"]}
    grid_by_scene, scenes_out, iso_samples = {}, [], []
    for scene in cfg["scenes"]:
        grid = build_grid(scene, legend, w, h)
        grid_by_scene[scene["id"]] = grid
        placements = w * h
        used = sorted({grid[ty][tx] for ty in range(h) for tx in range(w)})
        walk = [(tx, ty) for ty in range(h) for tx in range(w) if mod_by_id[grid[ty][tx]]["walkable"]]
        depth = {(tx, ty): occlusion_depth(grid, mod_by_id, tx, ty, w, h)
                 for tx in range(w) for ty in range(h)}
        max_occ = max(depth[p] for p in walk)
        over = [f"{tx},{ty}" for tx, ty in sorted(walk) if depth[(tx, ty)] > cap]
        seen = reachable(grid, mod_by_id, scene["entrance"], w, h)
        unreachable = [f"{tx},{ty}" for tx, ty in walk if (tx, ty) not in seen]
        reuse = (placements - len(used)) * 1000 // placements
        v = (["occlusion"] if max_occ > cap else []) + (["reachability"] if unreachable else []) \
            + (["reuse"] if reuse < min_reuse else [])
        scenes_out.append({
            "id": scene["id"], "cn": scene["cn"], "placements": placements,
            "distinctModules": len(used), "modulesUsed": used, "reusePermille": reuse,
            "maxOcclusion": max_occ, "occlusionOverCap": over, "walkableTiles": len(walk),
            "reachableTiles": len([1 for p in walk if p in seen]),
            "unreachable": unreachable, "violations": v})
        if scene["id"] == cfg["scenes"][0]["id"]:
            for tx, ty in ((0, 0), (3, 2), (7, 7)):
                iso_samples.append({"tile": [tx, ty],
                                    "screen": list(iso_to_screen(tx, ty, cfg["tileW"], cfg["tileH"]))})
    summary = {"project": cfg["project"], "specVersion": cfg["specVersion"], "grid": cfg["grid"],
               "tileW": cfg["tileW"], "tileH": cfg["tileH"], "maxOcclusionCap": cap,
               "minReusePermille": min_reuse, "sceneCount": len(scenes_out), "scenes": scenes_out,
               "isoSamples": iso_samples,
               "violatingScenes": [s["id"] for s in scenes_out if s["violations"]]}
    os.makedirs(GEN, exist_ok=True)
    with open(os.path.join(OUT, "sp11_scene.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    gen_sheet(cfg["scenes"], grid_by_scene, mod_by_id, cfg, os.path.join(GEN, "sp11_scene.png"))
    print(f"grid={w}x{h} tile={cfg['tileW']}x{cfg['tileH']} cap={cap} minReuse={min_reuse}‰")
    for s in scenes_out:
        print(f"  {s['id']:<10} reuse={s['reusePermille']:>4}‰ distinct={s['distinctModules']} "
              f"maxOcc={s['maxOcclusion']} walk={s['walkableTiles']} reach={s['reachableTiles']} "
              f"unreach={s['unreachable']} occOver={s['occlusionOverCap']} viol={s['violations']}")
    print(f"isoSamples={[(tuple(s['tile']), tuple(s['screen'])) for s in iso_samples]}")
    print(f"violatingScenes={summary['violatingScenes']}")
    print("wrote out/sp11_scene.json out/gen/sp11_scene.png")
    return 0


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

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

grid=8x8 tile=32x16 cap=3 minReuse=800‰
  hall       reuse= 906‰ distinct=6 maxOcc=2 walk=35 reach=35 unreach=[] occOver=[] viol=[]
  storeroom  reuse= 953‰ distinct=3 maxOcc=5 walk=31 reach=30 unreach=['6,6'] occOver=['1,1'] viol=['occlusion', 'reachability']
isoSamples=[((0, 0), (0, 0)), ((3, 2), (16, 40)), ((7, 7), (0, 112))]
violatingScenes=['storeroom']
wrote out/sp11_scene.json out/gen/sp11_scene.png

11.3.4 P 层:等距投影必须同源

P 层最重要的一条纪律是投影公式只能有一份。本节的 TS 内核 core/src/sp11/scene.ts 不重写等距投影,而是 import { isoToScreen } from "../sp00/spec.ts"——那个文件是整套书的共享真源,本节只读复用,不修改。S 层的 iso_to_screen 与它同源,两边都是整数地板除:

// file: core/src/sp11/scene.ts
// tier: P
// verify: node --test 'core/test/sp11_*.test.ts'
// 11.3 场景构图与模块化 kit 的 TS 侧校验器。等距投影复用 core/src/sp00/spec.ts 的
// isoToScreen(只读,同一份真源)。复用率/遮挡层数/可达性全为整数地板除,逐位一致。
import { isoToScreen } from "../sp00/spec.ts";

export function buildGrid(scene: any, legend: any, w: number, h: number): string[][] {
  const grid: string[][] = [];
  for (let ty = 0; ty < h; ty += 1) {
    const row: string[] = [];
    for (let tx = 0; tx < w; tx += 1) row.push(legend[scene.rows[ty][tx]]);
    grid.push(row);
  }
  return grid;
}

export function reusePermille(placements: number, distinct: number): number {
  return Math.floor(((placements - distinct) * 1000) / placements);
}

export function occlusionDepth(grid: string[][], modById: any, tx: number, ty: number, w: number, h: number): number {
  let d = 0;
  let k = 1;
  while (tx + k < w && ty + k < h) {
    if (modById[grid[ty + k][tx + k]].blockHeight >= 1) d += 1;
    k += 1;
  }
  return d;
}

export function reachable(grid: string[][], modById: any, entrance: number[], w: number, h: number): Set<string> {
  const seen = new Set<string>([`${entrance[0]},${entrance[1]}`]);
  const q: number[][] = [[entrance[0], entrance[1]]];
  while (q.length) {
    const [tx, ty] = q.shift() as number[];
    for (const [ddx, ddy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
      const nx = tx + ddx;
      const ny = ty + ddy;
      if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
      const key = `${nx},${ny}`;
      if (seen.has(key)) continue;
      if (modById[grid[ny][nx]].walkable) {
        seen.add(key);
        q.push([nx, ny]);
      }
    }
  }
  return seen;
}

export function sceneAccount(scene: any, cfg: any): any {
  const w = cfg.grid.w;
  const h = cfg.grid.h;
  const modById: any = Object.fromEntries(cfg.modules.map((m: any) => [m.id, m]));
  const grid = buildGrid(scene, cfg.legend, w, h);
  const placements = w * h;
  const used = new Set<string>();
  const walk: number[][] = [];
  for (let ty = 0; ty < h; ty += 1) {
    for (let tx = 0; tx < w; tx += 1) {
      used.add(grid[ty][tx]);
      if (modById[grid[ty][tx]].walkable) walk.push([tx, ty]);
    }
  }
  const depths: number[][] = [];
  let maxOcc = 0;
  const over: string[] = [];
  for (const [tx, ty] of walk) {
    const d = occlusionDepth(grid, modById, tx, ty, w, h);
    depths.push([tx, ty, d]);
    if (d > maxOcc) maxOcc = d;
    if (d > cfg.maxOcclusionCap) over.push(`${tx},${ty}`);
  }
  const seen = reachable(grid, modById, scene.entrance, w, h);
  const unreachable = walk.filter(([tx, ty]) => !seen.has(`${tx},${ty}`)).map(([tx, ty]) => `${tx},${ty}`);
  const reuse = reusePermille(placements, used.size);
  const v: string[] = [];
  if (maxOcc > cfg.maxOcclusionCap) v.push("occlusion");
  if (unreachable.length) v.push("reachability");
  if (reuse < cfg.minReusePermille) v.push("reuse");
  return {
    id: scene.id, placements, distinctModules: used.size, modulesUsed: [...used].sort(),
    reusePermille: reuse, maxOcclusion: maxOcc, occlusionOverCap: over,
    walkableTiles: walk.length, reachableTiles: walk.filter(([tx, ty]) => seen.has(`${tx},${ty}`)).length,
    unreachable, violations: v,
  };
}

P 层测试 core/test/sp11_scene.test.ts 先断言 isoToScreen 与 Python 侧的 isoSamples 逐位一致(isoToScreen(3,2,32,16) === [16,40]、isoToScreen(7,7,32,16) === [0,112]),再逐场景比对复用率、最大遮挡、可行走与可达地块数、不可达集合与违例集合。实跑 node --test 'core/test/sp11_*.test.ts' 全绿,本节摘要为 scene digest = 85838b94bff14d00。

11.3.5 读表:复用率高不等于场景好

场景去重模块复用率最大遮挡可达 / 可行走违例
大堂 hall6906‰235 / 35无
后仓 storeroom3953‰530 / 31occlusion、reachability

这张表里最值得记住的一点是:后仓的复用率反而更高(953‰ > 906‰),因为它的墙更多、地板更单一;但它的违例最多。三个结论:

  • 复用率是成本指标,不是质量指标。它衡量的是「kit 用得省不省」。只看这一个数,会得出「后仓比大堂好」的错误结论。
  • 遮挡是构图指标。后仓用一排斜向的 # 墙阵,把 (1,1) 这个可行走地块的遮挡深度顶到 5 层,远超上限 3——角色站在那儿会被整排墙挡死。大堂只有 2 层,合格。
  • 可达性是设计指标。后仓右下角的 (6,6) 是一块可行走地板,但它被 ### 围成了一个封闭口袋,从入口 (7,3) 出发的四邻接洪泛到不了。unreachable=['6,6'] 精确报出坐标,美术改一格墙就能修。

所以门禁必须三项一起判:violatingScenes=['storeroom'],而后仓的复用率在所有场景里是最高的。这条负例是本节的中心思想——「省」和「好」是两个方向,规范必须分别量化。

11.3.6 边界与取舍

  • 等距投影只有一份真源。isoToScreen 在 core/src/sp00/spec.ts,本节只读复用;任何一节想改投影公式,都要先改那个文件,否则 S/P 两层立刻不一致。
  • 遮挡深度只数同屏幕列。真实渲染里「挡住」还涉及前后遮挡与透明,本节刻意用最粗的口径,因为它纯整数、可复算、美术看得懂;精细的遮挡剔除交给渲染侧。
  • 可达性是纯几何判据。它只回答「走不走得到」,不回答「走过去好不好玩」;关卡的节奏与引导不在本节范围。
  • 本节不含真渲染截图与画师主观判断(标 X)。out/gen/sp11_scene.png 是程序化绘制的等距示意图,仅供对照;真实项目的场景评审仍需人工确认。

至此第十一章的三节收口:11.1 度量单个物件的剪影,11.2 度量单张贴片的细节,11.3 度量整张场景的构图——三节共用同一套「整数口径 + 千分比 + 上限判违例」的骨架。

阅读导航:上一节:11.2 细节密度与视觉噪声 · 下一节:12.1 特效强度、时长与屏占比预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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