模块化 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 读表:复用率高不等于场景好
| 场景 | 去重模块 | 复用率 | 最大遮挡 | 可达 / 可行走 | 违例 |
|---|---|---|---|---|---|
| 大堂 hall | 6 | 906‰ | 2 | 35 / 35 | 无 |
| 后仓 storeroom | 3 | 953‰ | 5 | 30 / 31 | occlusion、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 特效强度、时长与屏占比预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。