在程序化生成场景时,很多开发团队会撞上一个奇怪的问题:生成的迷宫、走廊、废弃建筑,技术指标完全正常,但玩家走进去后就是会感到“不对”。这种不对不是 bug,而是一种心理层面的不安,就像网络上流行的“后室”(Backrooms)视频那样——无限延伸的黄色地毯走廊、均匀的荧光灯、没有任何人的办公室,明明符合物理规则,却让人浑身不自在。
这个现象的背后,并不是“随机性太强”那么简单。如果我们想从后室式的阈限空间中走出来,真正做出让人感到归属感和方向感的公共虚拟空间,就需要重新理解程序化生成的设计目标。它不只是从空房间填满物体,而是在生成阶段就引入“空间可读性”和“场所精神”。
这篇文章会从后室现象切入,讲清楚什么是阈限空间,为什么程序化生成容易踩中它,然后给出一个可落地的技术方案:用空间句法指标约束程序化生成,用 Unity 和 Python 做一套“先分析、后生成、再装饰”的完整流程。无论你做游戏场景、虚拟展厅,还是数字孪生公共空间,这套思路都值得收藏。
1. 这篇文章真正要解决的问题
先直接说结论:程序化生成一个“看起来合理”的虚拟空间,和生成一个“让人待得住”的虚拟空间,是两码事。
前者只需要随机数、碰撞检测和一组美术资源;后者还需要考虑空间心理学,至少要让用户感觉有方向、有目的、有人气。很多开发者在做独立游戏、虚拟博物馆或线上活动地图时,只关注了“房间不重叠”“走廊能连通”,却没有关注空间布局本身带来的情绪影响,结果做出来的成品就像一张无限延展的办公室地图。
这篇文章解决的就是这个痛点:如何把空间体验从“合格”提高到“友好”。
以 Backrooms 文化为切入点,它其实是一个很好的反面教材。“后室”之所以让人恐惧,不是因为贴图精度低,也不是因为缺少 NPC,而是因为它太纯粹地呈现了一个“没有开始、没有结束、没有中心、没有记忆”的空间。用建筑理论的话说,这是典型的“非场所”(non-place),是阈限空间(liminal space)的极致化。
在真实建筑设计中,建筑师会用空间句法、视线分析、动线规划来避免非场所的出现。而虚拟空间开发中,这些方法还没有被足够重视。这篇文章会把空间句法中最核心的“整合度”和“可懂度”概念,转化成可计算的指标,再接入程序化生成管线。
读完这篇文章,你会得到三个东西:
- 理解为什么程序化生成容易产出阈限空间;
- 一套用 C# 在 Unity 中生成“结构化空间骨架”的代码;
- 一个用 Python 做空间指标分析的脚本套路,以及几个让空间更像“场所”的环境装饰技巧。
2. 基础概念与核心原理:从阈限空间到空间句法
2.1 什么是阈限空间与后室效应
“阈限”(liminal)来自拉丁语 limen,意思是“门槛”。人类学里的阈限是指一个过渡状态,比如婚礼上从单身到已婚的那个阶段,或者飞机落地后等待行李时的那段空白感。用在空间上,阈限空间就是让人感觉“只是经过,不是停留”的地方,比如机场候机厅、医院走廊、深夜的办公区。
真正的物理空间再冷清,也有天气、光照变化、人来人往的痕迹。但程序化生成的虚拟空间如果缺少这些痕迹,就很容易变成一种特别的阈限空间:它在物理上可以走通,但心理上无法被“锚定”——记住某个转角、某个地标、某个区域的功能,这对用户来说非常困难。
后室效应指的就是这种状态被夸张后形成的不安感:空间无限重复,方向感失效,所有区域几乎没有区别。
2.2 场所感源于“可读性”,而不是更多装饰
要打破后室效应,关键不是堆更多道具,而是提升空间的可读性(legibility),也就是一个人理解空间结构、判断自己位置和方向的能力。
在建筑学中,这个能力可以用空间句法理论来量化。空间句法由比尔·希利尔(Bill Hillier)提出,核心思路是把建筑布局抽象成节点图,然后计算每个节点的拓扑属性。其中两个指标最关键:
- 整合度(Integration):衡量一个空间到所有其他空间的总距离。整合度高的空间,人流量会更高,也更容易被识别为中心区域。
- 可懂度(Intelligibility):描述局部空间信息与整体空间结构的相关性。简单说,你站在某个走廊里,能不能大致猜到整个地图的结构?
如果我们把这两个指标引入程序化生成,那么生成算法就不再只是“把房间随机摆进去”,而是会优先选择能够形成清晰中心、清晰路径的布局。
2.3 后室与公共虚拟空间的差别,就在拓扑结构
后室没有中心,也没有层级。它的走廊全部等价,房间全部相似,所以整合度没有梯度,可懂度极低。而好的公共虚拟空间,比如一个常见的中庭式商场,一定包含明显的中心、次级中心和边缘区域。用户从入口进入时,视觉和拓扑上都会感受到“重点区域在哪里”。
所以,一个更稳妥的技术判断是:要让虚拟公共空间不那么阈限,核心不是用更复杂的建筑造型,而是为空间骨架引入“拓扑梯度”——让不同区域有不同的连接度、不同的地位。
3. 环境准备与前置条件
这一节给出本文示例代码的运行条件。请注意,版本号会随项目时间变化,我这里强调通用思路,而不是绑定某个具体版本。
3.1 引擎与语言
- 游戏引擎:Unity 2021 或更高版本,也可以使用 Godot 4。本文代码以 Unity + C# 为主。
- 脚本语言:C# 用于 Unity 编辑器扩展或运行时生成;Python 3.8 或更高版本用于离线空间分析。
- Python 依赖库:networkx,用于图分析;matplotlib,用于可视化节点图。
- 可选:Unity 的 AI Navigation 包,用于之后做行人模拟。
3.2 基础储备
- 理解 Unity 的 GameObject、Transform、Rect 或 Bounds 基础概念。
- 理解图(Graph)的邻接表表示。
- 不需要有建筑设计背景,但建议先了解“空间句法”四个基本指标:连接度、控制度、深度、整合度。
- 建议在开始前,先创建一个空的 3D 项目,然后把本文脚本挂到一个空对象上运行。
3.3 文本思路,不依赖特定美术资源
本文不会教你建模高精度的办公楼,而是演示一套生成逻辑。你需要准备几个最简单的原始几何体即可:Cube 作为房间,Cylinder 或空 GameObject 作为门口标记。
4. 核心流程拆解:把随机生成改成带约束的空间生成
我们不用传统的“随机填满地块”方式,而是采用“图驱动生成”的思路。整个过程分为四步:
- 生成空间骨架:先决定有哪些房间、房间之间如何连接。
- 拓扑分析:计算节点图的空间句法指标。
- 约束与迭代:如果整张图的整合度分布过于平坦,则重新生成或调整。
- 环境增强:根据节点属性,给不同区域分配不同的人气装饰、光照和声音。
为什么这样设计?
因为“随机 Tilemap”生成的是瓷砖级别的空间,很难控制拓扑;而“图驱动生成”先决定宏观结构,再生成几何体,天然适合空间句法分析。这也是很多程序化地图工具(比如 Source 引擎的寻路网格生成)使用的思路。
下面我们来逐步实现。
4.1 第一步:生成房间节点
在 Unity 中创建 RoomGraphGenerator 脚本。它会完成以下事情:
- 在地面矩形范围内随机生成 N 个矩形房间。
- 用检测函数保证房间之间有间隔。
- 用“最近邻居”或 Delaunay 三角剖分连接房间,形成图结构。
4.2 第二步:计算整合度并输出到文本
生成图之后,把图导出为邻接表,送给 Python 脚本做整合度计算。这里有一个工程选择:直接在 C# 中实现图算法也行,但用 Python + networkx 做分析和可视化更高效,也方便团队里的数据分析师参与。
4.3 第三步:根据整合度调整房间属性
拿到每个房间的整合度后,我们回到 Unity 中,把整合度高的房间标记为“公共中庭”,整合度低的房间标记为“背景房间”。在后续装饰阶段,中庭会被赋予更多视线引导、更高亮度的光照、更多活动道具;背景房间则保持相对安静。
4.4 第四步:装饰与氛围层
最后一步不是技术难点,但最容易被人忽略。我们可以把这一步理解为“添加人的痕迹”:桌面上的咖啡杯、角落的绿植、墙上的海报、空调声、日光变化。这些东西会让用户感受到“这里有人待过”或“这里即将有人来”,从而脱离阈限感。
5. 完整示例代码实现
下面进入核心代码环节。我会准备三个示例:Unity C# 房间骨架生成器、Python 空间句法分析脚本、Unity 装饰挂点代码。
5.1 Unity C# 生成房间骨架
新建目录 Scripts,创建 RoomGraphGenerator.cs。
// 文件路径:Assets/Scripts/RoomGraphGenerator.cs using System.Collections.Generic; using UnityEngine; public class RoomGraphGenerator : MonoBehaviour { [System.Serializable] public class RoomNode { public Rect rect; public Vector2 center; public List<int> neighbours = new List<int>(); } [Header("生成参数")] public int roomCount = 12; public float mapWidth = 60f; public float mapHeight = 40f; public float minRoomSize = 4f; public float maxRoomSize = 9f; public float margin = 1.5f; [Header("运行时数据")] public List<RoomNode> graph = new List<RoomNode>(); public void Generate() { graph.Clear(); for (int i = 0; i < roomCount; i++) { RoomNode node = CreateRoomNode(); if (node == null) { i--; continue; } graph.Add(node); } ConnectNearNeighbours(); } private RoomNode CreateRoomNode() { for (int attempt = 0; attempt < 30; attempt++) { float w = Random.Range(minRoomSize, maxRoomSize); float h = Random.Range(minRoomSize, maxRoomSize); float x = Random.Range(0f, mapWidth - w); float y = Random.Range(0f, mapHeight - h); Rect candidate = new Rect(x, y, w, h); bool overlaps = false; foreach (RoomNode node in graph) { Rect expanded = node.rect; expanded.x -= margin; expanded.y -= margin; expanded.width += margin * 2; expanded.height += margin * 2; if (candidate.Overlaps(expanded)) { overlaps = true; break; } } if (!overlaps) { RoomNode newNode = new RoomNode(); newNode.rect = candidate; newNode.center = candidate.center; return newNode; } } return null; } private void ConnectNearNeighbours() { // 简单策略:每个房间与最近的2个房间相连,生成走廊时可复用该信息 for (int i = 0; i < graph.Count; i++) { List<int> near = new List<int>(); for (int j = 0; j < graph.Count; j++) { if (i == j) continue; near.Add(j); } near.Sort((a, b) => { float da = Vector2.Distance(graph[i].center, graph[a].center); float db = Vector2.Distance(graph[i].center, graph[b].center); return da.CompareTo(db); }); int links = Mathf.Min(2, near.Count); for (int k = 0; k < links; k++) { int target = near[k]; if (!graph[i].neighbours.Contains(target)) { graph[i].neighbours.Add(target); } if (!graph[target].neighbours.Contains(i)) { graph[target].neighbours.Add(i); } } } } private void OnDrawGizmos() { Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.6f); foreach (RoomNode node in graph) { Vector3 center = new Vector3(node.center.x, 0f, node.center.y); Gizmos.DrawWireCube(center, new Vector3(node.rect.width, 1f, node.rect.height)); Gizmos.color = Color.yellow; foreach (int nb in node.neighbours) { Vector3 nbCenter = new Vector3(graph[nb].center.x, 0f, graph[nb].center.y); Gizmos.DrawLine(center, nbCenter); } } } }这段代码会创建一批不重叠的房间,并产生一张无向图。注意这里用的是圆形随机放置加扩展矩形检测,实际工程中建议改用泊松圆盘采样,可以得到更均匀的分布。
运行方式:在 Unity 场景中创建一个空 GameObject,把脚本挂上去,调用Generate()。如果你不想在运行时调用,可以在 Inspector 上做一个按钮扩展,但我这里只保留核心逻辑。
5.2 Python 空间句法指标分析脚本
把上面的图导出为邻接表后,就可以用 Python 分析。
为了演示,我直接构造一个示例图。实际项目中,你可以从 Unity 的 MonoBehaviour 中把 graph 序列化为 JSON 或 CSV,再喂给这个脚本。
# 文件路径:analysis/space_syntax.py import networkx as nx import matplotlib.pyplot as plt def compute_integration(adjacency): """计算图节点的接近中心性,作为整合度的近似值。 越接近中心的节点,整合度越高。 """ G = nx.Graph() for node, nbs in adjacency.items(): for nb in nbs: G.add_edge(node, nb) # closeness_centrality 是节点到所有其他节点最短路径距离的倒数, # 在空间句法中常用于近似整合度。 centrality = nx.closeness_centrality(G) # 同时输出度中心性,便于对比局部连接度。 degree = nx.degree_centrality(G) return centrality, degree def draw_graph(adjacency, centrality): G = nx.Graph() for node, nbs in adjacency.items(): for nb in nbs: G.add_edge(node, nb) plt.figure(figsize=(8, 6)) pos = nx.spring_layout(G, seed=42) nc = nx.draw_networkx_nodes(G, pos, node_color=list(centrality.values()), cmap=plt.cm.viridis, node_size=500) nx.draw_networkx_edges(G, pos, alpha=0.3) nx.draw_networkx_labels(G, pos) plt.colorbar(nc, label='Integration approx.') plt.title('Room Graph Integration') plt.show() if __name__ == '__main__': # 示例邻接表:0号房间连接1、2,2号房间连接3 adjacency = { 0: [1, 2], 1: [0, 3], 2: [0, 3], 3: [1, 2], } integration, degree = compute_integration(adjacency) print("节点 -> 整合度近似值 -> 度中心性") for node in sorted(integration): print(f"{node} -> {integration[node]:.4f} -> {degree[node]:.4f}") draw_graph(adjacency, integration)这里用closeness_centrality近似空间句法中的整合度。更精确的算法需要计算拓扑深度下的受控访问,但在大多数虚拟空间项目中,这个近似值已经足够帮助你判断“中心区域”和“边缘区域”。
5.3 Unity 装饰挂点与“人气”生成器
生成骨架后,我们要把空间装饰得像个“被使用过的场所”,而不是一座空城。下一个脚本演示了如何根据房间整合度放置不同密度的装饰物。
// 文件路径:Assets/Scripts/LivableSpaceDecorator.cs using System.Collections.Generic; using UnityEngine; public class LivableSpaceDecorator : MonoBehaviour { public GameObject chairPrefab; public GameObject plantPrefab; public GameObject deskPrefab; [Header("人气阈值")] public float highTrafficThreshold = 0.5f; private RoomGraphGenerator generator; public void Decorate(RoomGraphGenerator graphGenerator, Dictionary<int, float> integrationMap) { generator = graphGenerator; for (int i = 0; i < generator.graph.Count; i++) { Rect roomRect = generator.graph[i].rect; float integration = integrationMap.ContainsKey(i) ? integrationMap[i] : 0f; // 整合度高的房间,放更多桌椅和绿植 int chairCount = integration > highTrafficThreshold ? 3 : 1; int plantCount = integration > highTrafficThreshold ? 2 : 1; for (int c = 0; c < chairCount; c++) { Vector3 pos = RandomPointInRect(roomRect); Instantiate(chairPrefab, pos, Quaternion.identity, transform); } for (int p = 0; p < plantCount; p++) { Vector3 pos = RandomPointInRect(roomRect); Instantiate(plantPrefab, pos, Quaternion.identity, transform); } } } private Vector3 RandomPointInRect(Rect rect) { float x = rect.x + Random.Range(1f, rect.width - 1f); float z = rect.y + Random.Range(1f, rect.height - 1f); return new Vector3(x, 0f, z); } }这个方法的核心逻辑是:整合度高的节点承担“公共客厅”职能,整合度低的节点则更像“储藏室”或低频办公区。通过装饰密度形成空间主次,帮助用户下意识理解“我现在在一个重要的公共区域”。
6. 运行结果与效果验证
代码写完不是结束,我们还要用数据验证“阈限感下降了”。
一个直观的方法是对比两组地图:
- 第一组:纯随机地图,房间完全随机摆放,房间之间的连接随机生成。
- 第二组:用 5.1 的带间隔随机 + 最短连接策略生成,并通过 5.2 的整合度分析筛掉“平坦图”。
然后让玩家或测试者走一遍,回答三个问题:
- 你能不能画出大致地图草图?
- 你能不能指出“中心区域”?
- 你是否感到方向感丧失或焦虑?
如果你希望做客观指标,可以统计路径导航时间:用 A* 算法在两种地图上分别计算从起点到终点的平均路径长度和绕路率。整合度高的地图,绕路率通常会明显下降。
另一个更贴近工程的做法是:把整合度数值写成颜色图,在 Unity 的 Scene 视图里可视化。热区是红色,冷区是蓝色。你会看到好的生成结果在空间中间出现一个红色中心区,而不是整张图杂乱无章。
如果运行失败,第一步优先检查两点:
- 检查 Unity 的 Gizmos 是否打开,否则房间节点不会显示。
- 检查 Python 脚本是否安装了 networkx。如果环境缺少依赖,使用
pip install networkx matplotlib安装。
7. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 房间数量不足,生成很稀疏 | 房间彼此距离要求太严格,采样次数不够 | 打开 Console 查看生成日志 | 增大尝试次数,或减小 margin 值 |
| 走廊绕路严重,玩家频繁重复走 | 图连接只用了最近邻居,局部路径被拉长 | 用 Python 绘制节点图,检查连接数量 | 增加少量随机全局连接,或使用 Delaunay 三角剖分连接 |
| 整合度数值都差不多 | 节点连接太平均,缺少核心区域 | 计算所有节点的整合度方差 | 在生成中随机引入一个“中心广场”节点,强制连接更多邻居 |
| 装饰把通道堵住,玩家无法移动 | 装饰物随机放置时没有做碰撞检测 | 运行 NavMesh 或检查射线检测 | 让装饰物放置在房间边界周围,并留出 1 米通道 |
| 运行一段时间后帧率下降 | 使用 Instantiate 生成了大量零散 GameObject | 查看 Profiler 中的 Instance 数量 | 改用对象池,或先离线烘焙成静态场景 |
| 空间仍不够“活”,像样板间 | 缺少环境叙事,比如声音、光影变化 | 进入场景后站在原地观察五分钟 | 增加声音源、动态光影、NPC 或环境动画 |
这些问题的核心是:生成空间不只是几何问题,还是心理和叙事问题。每次修改参数后,最好重新走一遍“生成-分析-试玩”的循环,而不是只在代码里调随机数种子。
8. 最佳实践与工程建议
8.1 使用种子随机和存档
程序化生成最大的痛点是不可控。建议在生成前记录随机种子,并把种子和生成结果保存到文件。这样测试环境出问题时,可以复现同一个布局,也是一个有效的回滚手段。
int seed = Random.Range(0, 999999); Random.InitState(seed); Debug.Log($"Seed: {seed}");8.2 给房间加语义标签
不要只把房间当成物理矩形,要给每个节点补上RoomType字段,比如Entrance、Lobby、Office、Storage。语义标签可以参与装饰逻辑、寻路逻辑和 UI 小地图显示。
这也是打破阈限空间的叙事关键:一个“储物间”和一个“员工休息室”,即使尺寸相同,用户感受也会完全不同。
8.3 光照设计是“非场所”的开关
阈限空间通常使用均匀的荧光灯,没有阴影、没有色温变化。想减少阈限感,可以这样做:
- 活动区使用 3000K 左右的暖色光,通道区使用 4000K 中性光。
- 添加大面积柔和阴影,避免整个空间一片死白。
- 动态光斑或窗口自然光变化,能让空间产生“时间流动感”。
8.4 空间句法结果要可视化
为开发团队增加一个调试面板,用颜色或数字显示每个房间的整合度。这会让程序化生成从“盲调”变成“有依据地调”。建议把这套分析脚本接入 CI,每次地图版本更新后自动输出一份整合度热力图。
8.5 最小权限与变更控制,同样适用于地图生成
如果地图是生产环境的一部分(比如线上虚拟展会),生成脚本必须支持离线生成、人工审核后再上线。不要在服务器运行时随机生成地图,否则出问题很难回溯到具体的随机种子和版本。
9. 总结与后续学习方向
回到标题:Beyond the Backrooms,超越后室。我们真正要超越的,不是那些黄色走廊和压抑天花板,而是“随机性失控”带来的空间无意义感。
这篇文章从后室和阈限空间概念出发,说明了为什么程序化生成不能只追求连通性,还要追求空间句法层面的可懂度。通过 Unity C# 构建房间节点图、Python NetworkX 计算整合度、以及装饰密度差异化,你可以把“这是个合理的空间”升级成“这是个有人情味的空间”。
下一步值得深入的方向是:
- 研究空间句法的完整指标,比如真实整合度、控制度、深度值,而不是只用中心性近似。
- 把生成算法接入 AI Agent 导航系统,让虚拟 NPC 根据整合度自动选择“人流量更大的区域”。
- 尝试用 Vision Transformer 做场景截图分类,用“是否像阈限空间”作为生成结果的自动化评估指标。
- 在手头的 3D 项目里,先跑通本文的最小示例,再把不同整合度对应到灯光、音乐和环境音效,形成一套完整的可调参数表。
虚拟公共空间的时代才刚开始。那些让人愿意停留、愿意社交、愿意再来的虚拟场所,一定会是“可读、有中心、有痕迹”的空间,而不是一张优美的后室地图。希望这套方法能帮你少走弯路。