☰
Doom强化学习环境搭建:ViZDoom随机动作基线跑通
2026/10/26 13:29:36 网站建设 项目流程

做强化学习的人多半都经历过那种“环境跑不起来,模型无从谈起”的尴尬。我这次盯上的是 Doom 枪战游戏 AI 这个方向,也就是用深度强化学习训练一个能自己在第一人称射击场景里找目标、开枪、走位的智能体。如果要给它一个定位,它属于“视觉输入 + 实时决策”这一类最接近真实游戏操作的强化学习任务,比 CartPole 那种玩具环境难得多,又比自建 3D 仿真便宜得多。适合谁参考?手里已经能跑通 PPO、DQN 基础代码,想找一个像素级输入、带真实游戏引擎的靶场来练手的人;也适合做游戏 AI 落地、想做智能 NPC 行为树之外方案的从业者。

这一篇只做一件事:把地基打稳。环境怎么装、游戏对象怎么建、状态怎么取、动作空间长什么样,最后用随机动作把一整局跑通,看到画面在动、变量在变,第一步就算完成。别看随机动作听起来很傻,它是后面所有算法的对照组,也是验证环境正确性的最便宜手段。我见过太多人跳过这一步直接上网络,训了两天发现奖励恒定为零,回头查才发现 cfg 文件压根没读到对应的关卡。

1. 选 Doom 当靶场,图的到底是什么

1.1 环境选型的三个硬指标

选训练环境这件事,我一般看三个指标:接口成熟度、状态信息丰富度、算力成本。自己用 Unity 或者 UE 搭一个射击场景当然自由,但你要额外维护渲染管线、物理同步、观测采集,光是时间同步这一块就足够耗掉半个月。而 Doom 这类老派引擎有个天然优势:它的游戏逻辑是定长时间步推进的,一秒钟固定若干个 tic,不会因为你的机器卡一下就跳变,这对强化学习来说太重要了。

开源社区维护的 ViZDoom 就是在这个引擎之上包了一层 Python 接口,把画面、深度、游戏内部变量、按键控制全部暴露出来。你写六行代码就能拿到一帧像素和一个奖励值,剩下的精力可以全放在算法上。这也是我一直推荐新手从这个方向入门的原因——环境复杂度足够高,但接入成本足够低。

第三个指标是算力。一个 320×240 的彩色画面,单帧 230KB 左右,配合 frame_skip 之后每秒的决策次数可以压到 10 次上下,普通消费级显卡甚至纯 CPU 都能跑起来做算法验证。等你把策略调通了,再往高分辨率、多智能体方向加码也不迟。

1.2 这个系列要解决的完整链路

把整条链路拆开看,其实是四段:环境与对象建立、观测与预处理、算法与训练循环、评估与调参。这一篇只覆盖第一段,但它是后面三段的前提。很多人对“建立对象”这件事有误解,以为就是Game()一下完事,实际上一个 Doom 实例里包含了好几层对象:引擎本体、屏幕缓冲区、深度缓冲区、标签缓冲区、游戏变量集合、动作空间定义。你得逐个搞清楚它们的关系,后面写观测封装的时候才不会乱。

我个人的习惯是先跑通一个“能看见东西在动”的最小脚本,再加观测处理,再加算法。这个顺序不能反。因为一旦你在黑盒状态下叠加了网络和预处理,出了问题根本分不清是环境配错了还是网络写错了。

1.3 随机动作基线为什么不能跳过

随机动作展示看着像玩具,它的价值在于给出三条基线数据:一局的平均步数、平均累计奖励、终止原因的分布。这三条数据是你后面判断训练是否真的有效的锚点。举个例子,如果随机策略在某个场景里平均能拿到 0.5 的累计奖励,而你的 DQN 训了一晚上只有 0.3,那说明模型根本没有学到东西,而不是“任务太难”。

另外随机采样还能帮你发现动作空间的坑。比如你定义了 5 个按钮,但采样时长度写成了 4,程序会静默地丢弃最后一维或者直接报错;再比如某些按钮组合在引擎里其实是互斥的,比如同时左移和右移,采样时经常出现,跑起来角色原地抖动,这些现象只有真跑一遍才能看见。

2. 环境搭建:把第一步踩实

2.1 依赖安装的最小闭环

核心依赖其实只有一个包,它内部自带引擎二进制和控制接口:

pip install vizdoom

如果你打算后面接标准的强化学习接口,再补一个 gymnasium:

pip install gymnasium numpy

Linux 环境下大概率还需要系统级的图形与音频运行库,常见的是 SDL2 相关的那几个包。装不上时可以先用一个最简单的 import 测试,看报错信息里缺哪个库名,再针对性补,比一次性装一大堆依赖要快得多。

import vizdoom as vzd print(vzd.__version__) print(vzd.scenarios_path)

第二行打印出来的目录很关键,里面自带了一批现成的 cfg 配置和 wad 关卡文件,比如基础的打靶场景、中央防守场景、走廊清怪场景。初期强烈建议直接用这些自带场景,别一上来就自己写 cfg,格式细节能坑你一整天。

注意:包名和代码里的模块名不一样,安装时是 vizdoom,导入时是 vizdoom 没错,但有些老教程里写的是 doom,那是完全另一套东西,别混。

2.2 配置文件里必须动的那几行

自带的 cfg 是通用默认值,直接拿来用会有两个问题:分辨率太大、渲染项太花。我一般复制一份出来改,重点改这几处:

配置项建议值这么改的理由
screen_resolutionRES_320X240再大对早期验证没收益,只会拖慢采样
screen_formatRGB888三通道够用,灰度可以后面自己转
render_hudfalseHUD 上的血条数字会干扰视觉特征提取
render_crosshairfalse准星是固定的,学不到信息
render_weaponfalse第一人称武器占画面下方一大块,纯干扰
render_particlesfalse粒子效果每帧不一样,引入无效方差
window_visibletrue调试阶段必须看得见,训练阶段再关
episode_timeout300按 tic 计,约 8.5 秒一局,够跑通流程

这里解释一下为什么要把渲染相关的全关掉。渲染出来的 HUD、准星、武器模型其实都是“与决策无关的常量或噪声”。HUD 更麻烦,血量和弹药数字会随状态变化,网络可能会去记住某些像素位置的数字形状,而不是真正理解场景。等你的智能体稳定了想加回来做消融实验,那是另一回事。

episode_timeout这个字段的单位是引擎 tic,不是秒也不是帧,这一点很多文档里说得含糊。默认引擎一秒跑 35 个 tic,所以 300 就是 8.57 秒。这个换算关系后面算决策频率时会反复用到。

2.3 三个验证点确认环境真的通了

装完不是看 pip 有没有报错,而是看能不能拿到数据。我一般按顺序验三件事:能不能创建 Game 对象、能不能拿到一帧画面、画面里是不是有内容。第一件事靠 try 块,第二件事靠shape,第三件事靠均值统计。

import numpy as np import vizdoom as vzd game = vzd.DoomGame() game.load_config("basic.cfg") game.init() game.new_episode() state = game.get_state() frame = state.screen_buffer print("shape:", frame.shape) print("dtype:", frame.dtype) print("mean:", frame.mean())

如果mean接近 0,那画面基本全黑,说明关卡没加载上或者 cfg 里的路径不对。如果打印出来是(3, 240, 320)而你以为会是(240, 320, 3),也别慌,不同版本的接口在通道顺序上确实存在差异,代码里加一层判断就行。我踩过这个坑,直接把(3, 240, 320)当成 HWC 送进卷积,训了半天准确率死活上不去,打印出来才发现通道位置错了。

if frame.shape[0] == 3: frame = np.transpose(frame, (1, 2, 0))

颜色顺序也要确认。引擎输出的通道序在不同版本、不同 format 配置下可能是 RGB 也可能是 BGR。做可视化对比一眼就看得出来:把画面存成图片打开,如果人脸或墙壁颜色发蓝,那就是通道反了。

3. 建立对象:Game 实例里的那几层结构

3.1 初始化的顺序不能乱

一个完整实例的生命周期是:创建对象、加载配置、设置参数、init、循环 new_episode、close。这里面最容易被忽略的是“设置参数必须放在 init 之前”。像set_screen_resolution、set_window_visible、add_available_game_variable、set_seed这些调用,一旦 init 之后再改,要么没效果,要么直接抛异常。

game = vzd.DoomGame() game.load_config("basic.cfg") game.set_screen_resolution(vzd.ScreenResolution.RES_320X240) game.set_window_visible(True) game.set_seed(42) game.add_available_game_variable(vzd.GameVariable.AMMO2) game.add_available_game_variable(vzd.GameVariable.POSITION_X) game.init()

set_seed这一行必须加。强化学习环境的随机性来源很多:关卡内的敌人生成、初始朝向、道具位置。不固定种子,你连“同一个策略跑两次结果差多少”都说不清楚,后面的对比实验就全废了。当然固定种子不等于完全确定,引擎内部还有一些时序相关的随机源,所以我的做法是固定种子做回归测试,多个种子做性能评估。

add_available_game_variable这行决定了get_state()返回的变量列表里有哪些字段。默认配置里可能一个都没有,你得显式声明。声明顺序决定了数组下标,这点在写取值代码时要注意,别用魔法数字,写个枚举映射表更稳。

3.2 屏幕缓冲区与深度缓冲区的分工

get_state()返回的是一个状态对象,里面装着好几样东西,最常用的是两个缓冲区:屏幕缓冲区和深度缓冲区。前者是彩色画面,后者是每个像素到摄像机的距离。

深度图在射击游戏里其实特别有用,因为它直接告诉你“前面多远有东西”。纯靠彩色画面判断距离,网络要从纹理、遮挡关系里硬学,难度大很多。我做过一个对比实验,同样的网络结构,加上深度通道之后在走廊类场景里的收敛速度肉眼可见地快。

代价是内存和 IO。320×240 的深度图是单通道,大约 76KB,加上彩色帧一共 300KB 出头。如果你用 frame_skip=4,每一步都要把这四帧的最后一帧拷出来,单步拷贝量不大,但一秒钟十几次决策,跑几百万步下来拷拉开销就不容忽视了。早期验证阶段我建议只开彩色画面,等确认算法能学了再加深度。

还有一类缓冲区叫标签缓冲区,它会用不同颜色给画面里的物体分类标注,做目标检测或者语义分割预训练时能省掉大量人工标注。第一阶段的随机动作演示用不上,这里先提一句,避免你以后看到别人的代码里冒出labels_buffer一脸懵。

3.3 游戏变量:比像素便宜得多的状态

一直盯着像素看容易忽略一个事实:引擎内部其实把很多关键状态都以数值形式存着,比如血量、弹药、击杀数、角色坐标、朝向角、当前武器。这些量通过get_game_variable直接读,零成本,而且比从画面里推断准得多。

for v in game.get_available_game_variables(): print(v, game.get_game_variable(v))

这里有个小陷阱需要注意:读取必须在 episode 还未结束时进行。一旦is_episode_finished()返回真,再去读变量会抛异常。我的封装里一般会先判断状态,再取值,取不到就给一个哨兵值。

这些变量在奖励设计上的价值极高。比如你想让智能体学会“保持距离”,直接拿 POSITION_X/Y 算欧氏距离就行,比用视觉估计靠谱一百倍。但要注意别滥用,全都塞进观测里会引入冗余维度,有些变量之间高度相关,比如坐标和距离,会让网络学得慢。

提示:血量、击杀数这类变量在 episode 结束前的最后一帧往往会被置零或重置,统计时记得取倒数第二帧的值,不然你的日志里会出现一堆莫名其妙的零。

4. 动作空间:把按键翻译成决策

4.1 三种动作模式怎么选

接口提供了三种动作模式,选错会直接影响训练效率。

模式控制方式适用场景代价
多二值模式多个按钮同时按或不按键盘类操作,主流选择维度随按钮数线性增长
单二值模式每步只按一个按钮极端简化版,教学演示无法做组合动作,走位不灵活
连续模式鼠标位移与按键强度需要精细瞄准维度高,探索困难,收敛慢

我第一阶段一定用多二值模式。理由很直接:随机动作演示要验证的是“动作能生效”,多二值模式下每次采样会同时触发多个按键,画面上能看到角色一边转一边走一边开枪,反馈最直观。连续模式给一个随机浮点数基本等于乱甩鼠标,画面变化没有规律,看不出对错。

按钮定义在 cfg 的available_buttons字段里,常见的有左移、右移、前进、后退、左转、右转、射击。注意有些场景里“左移”和“右移”是侧身平移,不是转向,转向是单独的按钮,这两组别搞混。动作向量的长度必须严格等于按钮数量,顺序也要和 cfg 里声明的一致。

4.2 frame_skip 决定了你的时间尺度

这个参数是初学者最容易无视、也最容易把训练搞崩的一个。它的含义是:执行一次动作后,引擎内部连续推进多少 tic,然后才返回新状态。设成 1 就是每一步都看新画面,设成 4 就是动作保持 4 个 tic 再刷新。

按引擎 35 tic/秒来算:

  • frame_skip = 1,每秒 35 次决策,一次 episode 有 300 步
  • frame_skip = 4,每秒约 8.75 次决策,一次 episode 有 75 步
  • frame_skip = 8,每秒约 4.4 次决策,一次 episode 只有 37 步

这个换算决定了你的经验回放池要多大、目标网络多久同步一次、gamma 该设多少。frame_skip 太小,相邻两帧差异极小,信息冗余严重,采样效率低;frame_skip 太大,动作一次持续太久,智能体反应变迟钝,打移动目标时根本来不及调整。

我实测下来,320×240 分辨率下 frame_skip 取 4 是一个比较舒服的平衡点。步数不至于太少导致奖励稀疏得可怕,也不至于太多让单次决策变得毫无意义。如果换成需要精细瞄准的场景,可以降到 2。

还有一个细节:奖励是在 frame_skip 期间累加的。如果设了living_reward,那 4 个 tic 就会加 4 次。这个设置一定要谨慎,别随手写个正数,否则智能体会发现“活得久就是胜利”,学会原地转圈保命,这在射击场景里是典型的奖励劫持。

4.3 随机采样器怎么写才靠谱

最朴素的写法是每个按钮独立抛硬币:

def random_action(rng, n_buttons, p=0.3): return [1 if rng.random() < p else 0 for _ in range(n_buttons)]

这里p别取 0.5。取 0.5 的话,平均每次会按下一半按钮,角色会同时前进后退左右转,实际位移接近零,画面上看着在抖但没动。取 0.3 左右,大多数步只有一到两个按钮生效,动作更像真人操作,能明显看到角色在场景里移动。

更讲究一点的做法是给关键动作加权。比如射击按钮的概率设高一些,移动按钮低一些,这样随机策略更容易打出奖励,基线数据更有参考意义。不过要小心,加权的随机策略已经不是“均匀随机”了,写论文或者做对比时要说明清楚。

def random_action_weighted(rng, button_names): probs = {"ATTACK": 0.5, "MOVE_LEFT": 0.15, "MOVE_RIGHT": 0.15, "MOVE_FORWARD": 0.15, "MOVE_BACKWARD": 0.05} return [1 if rng.random() < probs.get(b, 0.1) else 0 for b in button_names]

按钮名字可以通过game.get_available_buttons()拿到,返回的是枚举列表,转成字符串比较方便查表。别在代码里硬编码按钮顺序,一旦换了场景 cfg,按钮列表会变,硬编码的代码就静默地按错了键。

5. 随机动作跑通一整局

5.1 主循环的骨架

把前面几块拼起来,一个能跑的最小闭环长这样:

import numpy as np import vizdoom as vzd rng = np.random.default_rng(7) game = vzd.DoomGame() game.load_config("basic.cfg") game.set_window_visible(True) game.set_seed(42) game.add_available_game_variable(vzd.GameVariable.AMMO2) game.init() button_names = [str(b).split(".")[-1] for b in game.get_available_buttons()] n_buttons = len(button_names) print("available buttons:", button_names) episodes = 5 frame_skip = 4 stats = [] for ep in range(episodes): game.new_episode() total_reward = 0.0 steps = 0 while not game.is_episode_finished(): action = [1 if rng.random() < 0.3 else 0 for _ in range(n_buttons)] reward = game.make_action(action, frame_skip) total_reward += reward steps += 1 if steps % 10 == 0: state = game.get_state() if state is not None: ammo = game.get_game_variable(vzd.GameVariable.AMMO2) print(f"ep {ep} step {steps} r={reward:.2f} ammo={ammo:.0f}") stats.append((total_reward, steps)) print(f"episode {ep} done, return={total_reward:.2f}, steps={steps}") game.close()

这段代码有几个地方值得说。game.new_episode()必须在每一局开头调用,它会重置场景、重置角色位置、清空引擎内的状态缓冲。如果忘了调,第二局开始直接就是结束状态,循环一次都不进。

make_action的第二个参数就是 frame_skip。传进去之后引擎会推进对应 tic 数,把这段时间内的奖励累加后返回。所以你拿到的reward是这一段时间的总和,不是单 tic 的。

打印的时候我特意只在每 10 步读一次状态。原因是get_state()会做一次缓冲区拷贝,每一步都调会明显拖慢速度。调试阶段无所谓,正式跑训练时观测采集要和决策解耦,通常只在需要时取。

5.2 看数据:怎么判断这一局跑得对不对

跑完五局,你会得到一组看起来毫无规律的数字。判断环境是否正确,我一般看三件事。

第一,步数分布是否稳定。理论上每局的步数应该接近episode_timeout / frame_skip,也就是 75 步左右。如果每局都是 1 到 2 步就结束,说明角色一出生就死,可能 cfg 里的episode_start_time设得不对,或者关卡选错了。如果步数远小于 75 但又不是 1,可能是随机动作触发了某种快速失败的条件。

第二,弹药变量是否在减少。这一步特别能验证动作是否真的生效。如果跑了 75 步,弹药从头到尾没变过,那说明射击按钮没生效,要么按钮名对不上,要么动作向量的顺序错了。

第三,肉眼看画面。窗口开着就是为了这个。随机动作下的角色应该是在场景里乱晃、时不时开一枪、偶尔打中目标闪一下。如果角色纹丝不动,只有准星在晃,那就是移动类按钮没有被正确触发。

5.3 记录终止原因,这是后面调参的依据

只记总奖励不够,还得知道每一局是怎么结束的。引擎提供is_player_dead()这个判断,配合is_episode_finished()就能区分“超时结束”和“角色死亡结束”。

if game.is_player_dead(): reason = "dead" else: reason = "timeout"

这两种终止在训练里的意义完全不同。超时结束通常说明策略太保守,原地不动;死亡结束说明策略太激进或者完全不会躲。等你后面看到训练曲线不涨,回头翻这两个计数的比例,往往一眼就能定位问题。

我一般还会把每一局的奖励、步数、终止原因写进一个小 csv。跑几十次实验之后,这些表格比任何曲线图都更能说明问题,尤其是你想回溯“哪次改动之后基线变差了”的时候。

6. 常见问题与排查实录

6.1 报错速查

下面这张表是我和身边人踩过的坑汇总,基本覆盖了第一阶段的绝大多数卡点。

现象可能原因处理方式
加载配置时报找不到 wadcfg 里的路径是相对路径,工作目录不对改成绝对路径,或先切到 cfg 所在目录
init 后画面全黑关卡未加载或 start_time 过大检查episode_start_time,临时关掉 window 用均值判断
报动作维度不匹配动作列表长度与按钮数不一致用get_available_buttons()的长度动态生成
弹药一直不变按钮顺序错位,射击没被触发打印按钮名列表,按名字建立索引映射
每局只跑一步就结束出生即死亡或超时设得太小打印终止原因,检查 timeout 值
训练时速度极慢每步都调 get_state 且开着窗口关窗口,观测采集降频
多次 episode 后内存上涨未释放状态对象引用每局结束后把状态变量置空,避免累积
重复运行结果差异巨大未固定种子或引擎内部随机源固定种子做回归,多随机种子做评测

6.2 无窗口环境下的小心事项

想把脚本丢到没有图形界面的机器上批量跑,需要设置环境变量让渲染走空设备,同时把window_visible关掉。这两件事必须同时做,只做一半会直接报错退出。我见过有人只设了环境变量没关窗口,程序在某些环境下卡住不返回,看上去像死循环,其实是渲染初始化在等一个永远不会出现的显示设备。

还有一点,无窗口模式下如果你仍然调用get_state(),画面缓冲区是能正常拿到的,因为渲染是在内存里完成的,不依赖屏幕。所以做观测采集完全没问题,只是看不到实时画面而已。建议保留一个“每隔多少局存一张图”的逻辑,方便事后抽查。

6.3 关于崩溃与长时间运行的稳定性

连续跑成千上万局之后,偶尔会遇到引擎层面的崩溃,表现为进程直接退出、没有 Python 异常栈。这种情况很难从代码里定位,我的做法是加检查点:每隔固定局数保存一次模型和统计,同时用一个外层进程监控,崩了自动重启并从最近的检查点继续。

另外一个隐性问题是资源泄漏。每局new_episode()会重置场景,但如果你在循环里不断创建新的 Game 对象而不 close,句柄会一直涨。原则很简单:一个进程一个 Game 实例,复用到底,只在程序退出时 close 一次。

6.4 我个人的几条经验

第一条,别急着把分辨率调高。有人觉得 320×240 太糊,一上来就拉到 640×480,结果采样速度掉了四倍,一晚上只跑了几万步,什么都学不出来。像素多少不是关键,关键是单位时间内能看多少样本。

第二条,很值得花时间的是先写一个“傻瓜策略”验证动作空间。比如写一个固定只按射击的策略,看能不能稳定拿到分;再写一个只在左右之间切换的策略,看角色能不能平移。这两个脚本跑通之后,你对按钮到行为的映射关系就彻底清楚了,后面调试网络时能少走很多弯路。

第三条,日志里一定要带步数和帧号。强化学习的问题往往出在时间维度上,比如某一步之后奖励突然全为零、某个帧之后画面卡住。如果日志里只有“第几局”,你根本没法定位到具体是哪一步出的问题。

第四条也是最容易被忽略的:先把单局的完整流程画出来。不是画流程图,是真的把一局里每一步的动作、奖励、关键变量按时间顺序打印成表。这个表看一遍,很多设计上的漏洞自己就浮出来了。比如你会发现某一步的奖励异常大,追下去发现是某个事件被重复计数了。

到这儿,环境建起来了,对象搞清楚了,随机动作能跑满一局,弹药在减少、步数稳定、终止原因可区分。接下来才是真正费脑子的部分:把像素观测裁剪缩放、把动作空间接进策略网络、把奖励函数设计得既有引导性又不至于被钻空子。我在第一次做这个项目时,光是在观测预处理上就返工了三遍,原因就是最开始没把画面通道顺序和帧堆叠的逻辑理清楚,导致网络看到的输入其实是错位的,训练曲线看着在涨,实际上是过拟合了某一帧的伪特征。所以这一步慢一点,后面会快很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询