强化学习玩射击游戏这件事,真正卡住新手的从来不是算法,而是第一步:让游戏在你自己的机器上跑起来,并且能被代码“看见”和“操控”。这个 Doom 枪战游戏 AI 项目的第一阶段,做的就是这件事——把经典 FPS 游戏包装成一个可编程的强化学习环境,创建出智能体要交互的游戏对象,最后用一串随机动作把整条链路打通。整个过程不涉及任何神经网络训练,却决定了后面所有工作的上限:观察怎么取、动作怎么给、奖励怎么算、回合怎么终止,这四件事在这个阶段定下来,后面改起来成本极高。
我会把这一阶段拆成四块:选型和边界怎么定、环境怎么装、对象怎么建、随机动作怎么跑,最后附一份我自己踩过的坑。适合刚接触游戏强化学习、手上有 Python 基础但没碰过游戏模拟器的人,也适合已经跑过通用 Gym 环境、想换一个有视觉输入和复杂动作空间的场景练手的读者。
1. 项目整体设计与技术选型思路拆解
1.1 为什么第一阶段只做环境和随机动作
很多人拿到这类项目的第一反应是直接抄一个 DQN 或者 PPO 的模板,把游戏塞进去看能不能跑。我试过,也见过不少人这么干,结果通常是两种:一种是训练起来了,损失在降,但智能体永远在贴墙走;另一种是代码直接报错,然后卡在环境接口上几天。原因很简单——游戏环境不是 Gym 里那种状态干净、奖励清晰的标准任务,它有渲染循环、有帧缓冲格式、有动作组合、有游戏内部变量,任何一项没对齐,后面的训练全是白费力气。
所以第一阶段刻意不引入任何学习算法,只用随机动作做压力测试。随机动作的价值在于它是“最笨的智能体”,如果环境本身有问题,比如动作空间定义错了、观察维度不对、奖励恒为零,随机动作跑几千步就能立刻暴露出来。这比训练几十万步才发现问题要省太多时间。我在实际项目里会把这一阶段称为“链路验证”,标准是:能连续跑满至少 200 个回合不崩、观察形状稳定、每回合的累计奖励有波动而不是常数。
另一个考虑是调试成本。游戏环境一旦和训练循环耦合,报错栈会非常长,你很难判断是环境崩了还是算法崩了。把环境单独拎出来,用一个几十行的循环反复跑,报错位置一眼就能看清。
1.2 为什么选 ViZDoom 这类游戏模拟器
选择这个方向做强化学习沙盒,核心原因有三个,我按重要性排一下。
第一是接口成熟度高。ViZDoom 这类平台本质上是把游戏引擎做成了一个可编程的客户端,它对外暴露的不只是画面,还包括游戏内部的变量,比如生命值、弹药数量、击杀计数。这意味着你既可以做纯视觉的端到端训练,也可以做特征工程式的轻量训练,两种路线的实验成本都很低。相比之下,很多游戏模拟器只给你画面,想拿内部状态就得自己做 OCR 或者逆向,那完全是另一个量级的工作。
第二是动作空间足够“脏”。标准的 Gym 环境动作通常是离散的几个选项,而射击游戏的动作是多个按键的组合:前进、后退、左右平移、左右转向、开火、切换武器。这种组合式动作空间更接近真实控制问题,用它练出来的策略迁移到别的场景时,鲁棒性会好一些。当然代价是搜索空间大,这也是后面要用各种技巧压缩动作的原因。
第三是资源占用可控。这个引擎的分辨率可以压到很低,比如 160x120 甚至更小,帧率也可以限制。我在一台只有集成显卡的机器上跑,把分辨率压到 160x120、窗口隐藏、单帧跳数设为 4,CPU 占用能控制在单核 60% 左右,同时开两三个环境做并行采样完全没问题。这一点对个人开发者很关键,很多视觉类环境没有独显基本没法玩。
1.3 第一阶段的三条边界
我给这一阶段划了三条明确的边界,写在这里供参考,因为边界不清是返工的主要原因。
边界一:不训练,只验证。所有动作都来自随机采样或者固定序列,不做任何梯度更新。这一阶段产出的代码是“环境层”,后面训练层的代码单独一个文件,两者通过标准接口对接。
边界二:先用手写配置,不做自动生成。场景配置文件(那些 .cfg 文件)先手写一份能跑通的,把每个参数的含义搞清楚,再考虑用代码动态生成。新手常见的错误是上来就写一堆参数拼接逻辑,结果某个参数拼错了,排查半天。
边界三:观察先用最小维度。一开始不要上高分辨率彩色图,用灰度、低分辨率、单帧。等链路跑通了,再逐步加通道数、加帧堆叠、加分辨率。这个顺序很重要,因为高维观察带来的问题(显存、形状、归一化)会掩盖掉底层环境的真实问题。
2. 环境搭建与依赖配置的实操要点
2.1 版本矩阵:三个依赖的兼容关系
这类项目最容易翻车的地方不是代码,是版本。我整理了一份我在多台机器上验证过的组合,见下表。注意这不是唯一可行组合,但属于“最少折腾”的那一类。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| Python | 3.9 / 3.10 | 3.11 以上部分预编译包还没跟上,容易触发源码编译 |
| vizdoom | 1.2.1 及以上 | 这个版本开始自带 Gymnasium 风格封装 |
| gymnasium | 0.29 及以上 | 接口比老版 gym 稳定,reset返回(obs, info)二元组 |
| numpy | 1.24 - 1.26 | 2.x 在部分旧封装里会有形状相关的兼容问题 |
| opencv-python | 4.x | 只用来做可视化和缩放,非必需但强烈建议装 |
| torch | 2.x | 这一阶段还用不到,但建议提前装好避免后面返工 |
关于 Python 版本我要多说一句。这个引擎的核心是 C++ 写的,Python 包其实是绑定层,它依赖预编译的二进制文件。如果你的 Python 版本太新,官方还没发布对应 wheel,pip 就会尝试从源码编译,这时候会需要 CMake、编译器工具链、SDL 开发库,一套下来半小时就没了,而且大概率失败。我的习惯是直接建一个 3.10 的虚拟环境,把所有实验都放进去。
conda create -n doom_rl python=3.10 -y conda activate doom_rl pip install vizdoom==1.2.1 gymnasium==0.29.1 numpy==1.26.4 opencv-python2.2 安装完成后的三步验证
装完不要急着写项目代码,先做三步验证,每步都能独立定位问题。
第一步,验证包能导入并且能打印出版本号:
import vizdoom print(vizdoom.__version__)这一步如果报ImportError或者DLL load failed,基本可以确定是二进制层的问题,常见原因是系统缺少图形运行库。Linux 下多半是缺 SDL2 相关库,Windows 下多半是缺 Visual C++ 运行库。这类问题不要去改代码,改代码没用。
第二步,验证无窗口模式能初始化。这是最关键的一步,因为很多人是在远程服务器或者没有显示器的环境里跑的。这个引擎默认会尝试创建窗口,没有显示设备就会抛异常。解决办法是在配置文件里把窗口关掉,或者初始化前设置纯无头模式。
import vizdoom as vzd game = vzd.DoomGame() game.load_config("./scenarios/basic.cfg") game.set_window_visible(False) game.set_screen_format(vzd.ScreenFormat.GRAY8) game.set_screen_resolution(vzd.ScreenResolution.RES_160X120) game.init() print("init ok") game.close()第三步,验证能推进一帧。初始化成功不代表能跑,有时候能 init 但一 make_action 就崩,通常是动作维度或者按键定义不匹配。
game.new_episode() for i in range(10): game.make_action([1, 1, 0], 1) print(i, game.get_game_variable(vzd.GameVariable.HEALTH)) game.close()三步全过,说明底层没问题,可以开始建对象了。这个顺序我强烈建议保持,因为它是“从静态到动态”的递进,任何一步失败都能明确归因。
2.3 场景配置文件的结构与关键参数
场景配置是一个纯文本文件,本质是一堆键值对。新手看它容易懵,其实只要把它分成四组就清楚了:资源路径组、画面组、按键组、变量组、奖励组。
资源路径组决定游戏加载哪个地图、哪个场景脚本、哪个资源包。这一组里的路径是相对路径,容易出错的地方是工作目录,很多人从 IDE 里运行没问题,从命令行运行就找不到文件。我的做法是统一用绝对路径拼出来,用os.path.join加os.path.dirname(__file__)。
画面组决定观察的形状,包括分辨率和像素格式。像素格式我建议一开始用灰度 8 位,也就是单通道,这样观察形状是(H, W),省掉了通道维度的坑。等跑通了再改成 RGB24,形状变成(H, W, 3),注意这个顺序是“高、宽、通道”,跟 PyTorch 要的“通道、高、宽”差一个转置,这是后面必踩的坑之一。
按键组的写法有个细节,可用按键列表里每一项是一个按键名,动作向量里的每个元素对应一个按键的按下状态,取值 0 或 1。这里最容易犯的错是按键数量和动作向量长度对不上,编译器不会报错,但行为会完全错乱。
变量组决定你能读到哪些游戏内部数值。常用的有生命值、弹药、击杀数、当前武器弹药。变量列表的顺序很重要,因为你读出来的是一个数组,取值要靠索引,索引和列表顺序严格对应。我一般会在代码里定义一组常量把索引映射成名字,避免后面写state[2]这种魔法数字。
奖励组在这一阶段可以先简单设置,比如设置一个较小的存活奖励和一个击杀奖励,主要目的是让随机动作产生的累计奖励有区分度,方便观察。
3. 建立对象的完整拆解:状态、动作、奖励
3.1 游戏对象的三层结构
这个项目标题里的“建立对象”,我理解成两层:一层是代码层面的游戏实例对象,另一层是场景层面的游戏实体对象。两层都要说清楚。
代码层面的对象就是一个游戏引擎实例,它包含了所有的状态和接口。创建它的时候要一次性把配置灌进去,因为部分参数(比如分辨率和像素格式)在初始化之后就改不了了,改了也没效果。这一点我吃过亏:一开始想在中途降低分辨率来提速,结果改完没反应,还以为是缓存问题,查了半天文档才发现是初始化时就固定了。
场景层面的对象指的是地图里的实体,包括玩家、武器、补给、敌人。这些对象的生成有两种方式:一种是在场景文件里预先定义,加载场景时自动生成;另一种是运行时通过控制台命令动态生成,比如在指定坐标放一个补给箱。第一种方式稳定、可复现,适合做固定任务;第二种方式灵活,适合做随机化训练,让智能体见到更多样的场景。
我在这个阶段的做法是先固定场景对象,等链路完全跑通,再考虑随机化。因为随机化会引入不确定性,一旦出问题,你无法区分是环境 bug 还是随机种子导致的偶发情况。
3.2 观察怎么取:屏幕缓冲与游戏变量的配合
观察是智能体的眼睛,这里有两个来源,我建议都用上,但用在不同地方。
屏幕缓冲是原始画面,形状由分辨率和像素格式决定。它的数据类型是无符号 8 位整数,取值 0 到 255。送进神经网络之前必须做两件事:转成浮点、归一化到 0 到 1 或者 -1 到 1,以及调整维度顺序。很多人会忘记归一化,结果网络的第一层输入是几百的量级,学习率稍微大一点就梯度爆炸,表现是损失变成 nan。
游戏变量是引擎算好的结构化数值。它的好处是信息密度高,缺点是依赖场景配置,换一个场景可能就取不到了。我的建议是:视觉输入用于端到端训练,游戏变量用于监控和调试,比如用它来判断智能体是不是一直在掉血、弹药是不是一直没消耗,这类判断比看画面直观得多。
这里有个实测下来的小技巧:如果你暂时只用游戏变量不用画面,可以把画面分辨率压到最低,甚至关掉屏幕缓冲,速度能提升好几倍。因为渲染是这一步的主要开销。
3.3 动作空间:组合式还是离散式
动作空间的设计直接决定了后面训练的难度,值得单独说。这个引擎的动作是“多键同时按下”,也就是每一帧给一个 0/1 向量,长度等于可用按键数量。
如果可用按键有 8 个,理论动作空间是 2 的 8 次方,也就是 256 种组合。实际上大部分组合是无意义的,比如同时前进和后退,同时左转和右转。所以主流做法是手工枚举一组有意义的组合,把动作空间压到十几个甚至几个。
我常用的压缩方案分三档,按场景复杂度选:
| 方案 | 动作数量 | 适用场景 | 训练难度 |
|---|---|---|---|
| 极简三键 | 3 | 只要前进、左转、右转 | 很低,几十万步能出效果 |
| 标准六键 | 6 到 8 | 加入后退、左右平移、开火 | 中等,需要百万步量级 |
| 完整组合 | 15 以上 | 再加入切枪、蹲伏 | 高,需要精细的探索策略 |
新手我建议从三键起步,先用随机动作验证三键能跑,再逐步加。原因是动作多了之后,随机动作的探索效率会急剧下降,你在验证阶段会看到智能体原地打转,还以为是环境问题。
还有一点是关于“跳帧”的,也就是一次动作推进多少个游戏内部时钟。设得太小,一秒钟要做的决策太多,计算量大;设得太大,控制精度下降,智能体会“刹不住车”。我一般在 2 到 4 之间试,配合分辨率一起调。
3.4 奖励与终止条件的设计
奖励函数是这一阶段最容易被忽视、但影响最大的部分。随机动作跑的时候,你其实是在检查奖励的“数值尺度”是否合理。
举个具体的例子:假设击杀奖励设为 100,存活奖励每帧设为 0.01,一次动作跳 4 个时钟,游戏内部时钟频率是每秒 35 个,那么一秒钟推进约 8.75 次动作,存活奖励每秒累计 0.0875。跑 200 步才 1.75 分,而一次击杀就是 100 分。这个比例意味着智能体会极度偏向“追杀”,完全不考虑生存。反过来如果击杀奖励太低,智能体会学会“苟着不动”,因为活着就有分。
我的经验值是:把单回合的存活总奖励控制在和一次击杀奖励同一个数量级,也就是差不超过 5 倍。这样两个目标的权重比较均衡。
终止条件通常有两个:生命值归零,或者达到最大步数。第一个是自然的游戏结束,第二个是人为截断,必须有,否则一个回合可能无限长,采样效率极低还在其次,主要是统计口径会乱。
4. 随机动作循环:先跑通再谈训练
4.1 最小可运行循环的完整代码
下面这段是我实际用的验证脚本,去掉了一切不必要的抽象,就是为了看得清楚。它做的事情是:初始化引擎、跑若干个回合、每回合用随机动作推进、统计奖励和步数、最后打印汇总。
import os import random import numpy as np import vizdoom as vzd CFG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scenarios", "basic.cfg") def build_game(): game = vzd.DoomGame() game.load_config(CFG_PATH) game.set_window_visible(False) game.set_screen_format(vzd.ScreenFormat.GRAY8) game.set_screen_resolution(vzd.ScreenResolution.RES_160X120) game.set_episode_timeout(2100) game.set_episode_start_time(10) game.set_living_reward(-1) game.init() return game def main(): game = build_game() n_actions = game.get_available_buttons_size() print("按键数量:", n_actions) episodes = 20 all_returns = [] all_steps = [] for ep in range(episodes): game.new_episode() total_reward = 0.0 steps = 0 while not game.is_episode_finished(): # 每个按键独立采样,得到 0/1 向量 action = [random.randint(0, 1) for _ in range(n_actions)] reward = game.make_action(action, 4) total_reward += reward steps += 1 if steps % 50 == 0: state = game.get_state() if state is not None: gv = state.game_variables print(f" ep{ep} step{steps} 奖励累计={total_reward:.2f} 变量={np.round(gv, 2)}") all_returns.append(total_reward) all_steps.append(steps) print(f"回合 {ep} 结束 | 总奖励={total_reward:.2f} | 步数={steps}") print("=" * 40) print(f"平均回合奖励: {np.mean(all_returns):.2f} 标准差: {np.std(all_returns):.2f}") print(f"平均步数: {np.mean(all_steps):.1f}") print(f"最大步数: {np.max(all_steps)} 最小步数: {np.min(all_steps)}") game.close() if __name__ == "__main__": main()这段代码有四个地方值得说。
第一,动作向量必须和按键数量严格一致。我用get_available_buttons_size()动态取,而不是写死数字。写死的后果是:你换了一个场景配置文件,按键数量变了,代码不报错但行为全乱。
第二,make_action的第二个参数是这次动作持续的时钟数。设成 4 意味着这一次决策推进 4 个游戏时钟。它同时影响控制精度和运算量,是个需要调的参数。
第三,get_state()在回合结束后会返回None,所以调用前必须判空。这个坑很经典,不判空就是AttributeError。
第四,回合超时设置在初始化时通过set_episode_timeout完成,单位是时钟数。我设 2100,配合每步 4 个时钟,理论上限是 525 步,实际会因为有存活惩罚而提前结束。
4.2 帧数与时钟数的换算关系
这个换算关系必须搞清楚,否则你对“跑得快不快”完全没有概念。
这个引擎的内部时钟频率默认是每秒 35 个。也就是说,1 个时钟约等于 28.6 毫秒。如果你的单步跳数是 4,那么一次动作对应约 114 毫秒的游戏时间,一秒真实游戏时间大约对应 8.75 次决策。
再看渲染帧率。渲染帧率和内部时钟不是一回事,渲染帧率可以更高也可以更低。我们看到的画面,每推进一个时钟会更新一次。所以如果你想让智能体“看得更连续”,就得减小单步跳数;想让它“反应更快”,就得增加跳数。
我给个实用的换算公式,用来说明为什么参数要一起调:
设分辨率为 160x120 灰度,观察张量大小是 19200 个元素,约 19KB(8 位)。单步跳数为 4,每回合上限 525 步,那么每回合产生的原始观察总量约 525 × 19KB ≈ 10MB。这个量级对内存完全没压力,但如果加上帧堆叠(比如叠 4 帧)和并行环境(比如 8 个),就会变成 320MB 左右,这时候就要开始考虑用共享内存或者降低分辨率了。
换算清楚了,你就知道为什么新手教程总说“先用低分辨率跑通”——不是因为低分辨率好看,是因为它能把整条链路的数值规模压到可以直观理解的程度。
4.3 观察数据的可视化与记录
随机动作跑起来之后,光看日志是不够的,必须把观察存下来看一眼,确认画面确实在变、像素值范围正确。
import cv2 import numpy as np def save_obs(state, path): if state is None: return buf = state.screen_buffer # 灰度格式下 buf 是 (H, W),转为 0-255 的 uint8 if buf.ndim == 2: img = buf.astype(np.uint8) else: img = buf.transpose(1, 2, 0).astype(np.uint8) # (C,H,W) -> (H,W,C) img = cv2.resize(img, (480, 360), interpolation=cv2.INTER_NEAREST) cv2.imwrite(path, img)这段代码里有个容易忽略的点:不同像素格式下缓冲的形状不一样。RGB24 格式下,缓冲是“通道、高、宽”的顺序,跟 OpenCV 要的“高、宽、通道”差一个转置。灰度格式下只有两维,不需要转置。所以代码里要按维度分支处理,不然存出来的图会是一堆彩色噪点。
我一般在验证阶段会存 20 张左右,覆盖不同时刻,然后肉眼看一遍:画面是不是在推进、有没有大片黑屏、有没有出现撕裂。如果画面全黑,多半是渲染在无窗口模式下没生效,需要调整配置。
5. 常见问题与排查技巧实录
5.1 环境类报错速查表
下面这张表是我自己整理的,遇到问题先查表,能省掉大量搜索时间。
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 导入时报动态库加载失败 | 系统缺图形运行库 | 装 SDL2 相关运行库,Windows 装 VC++ 运行库 |
| 初始化时报显示相关异常 | 无显示设备下仍尝试开窗口 | 初始化前关窗口可见性,或使用无头模式 |
| 找不到场景文件 | 工作目录不同导致相对路径失效 | 全部改成基于脚本所在目录的绝对路径 |
| 动作无效果 | 动作向量长度和按键数不一致 | 用接口动态取按键数,不要写死 |
| 动作行为错乱 | 动作元素顺序和按键列表顺序对不上 | 打印按键列表,逐项核对顺序 |
| 回合结束后读状态崩溃 | 结束后状态返回空 | 调用前判空 |
| 观察形状不符合预期 | 像素格式和转置处理不匹配 | 按维度分支处理,打印形状确认 |
这里面我最想强调的是“动作元素顺序”这一条。很多人以为动作向量就是按自己理解的顺序给,实际上它严格对应配置文件里可用按键列表的顺序。顺序错了不会报错,但智能体会做出完全不符合预期的动作,比如你想让它前进,它在原地转身。这种问题不看代码根本发现不了,只能打印列表比对。
5.2 画面与数据类问题的排查思路
画面类问题有两个典型症状,我分开说。
症状一:画面能显示但不动。这种情况多半是动作没有真正推进游戏时间。检查点在于单步跳数参数是否传了、传的是不是 0。有些封装里这个参数默认是 1,但如果你从别的示例里抄了一个 0,那一帧都不会推进,画面就定格了。
症状二:画面在动但数值不变。这说明游戏在跑,但你读的是错误的变量,或者变量列表没配好。这个引擎读变量是按索引读的,索引对应配置里变量列表的顺序。配置里只写了一个变量,你读索引 1,拿到的就是越界值或者空。我的处理办法是把配置里所有变量名打印出来,跟代码里的常量一一核对,这一步花不了两分钟,能省掉半天。
还有一类是数值尺度问题。有些场景的存活奖励是负数,每帧扣一分,跑 500 步就是负 500。这时候如果你把奖励直接累加去判断好坏,会得出“随机动作表现极差”的结论,其实是奖励设计问题,不是环境问题。判断方法很简单:把奖励设为 0,看回合长度是否稳定,稳定就说明环境本身没问题。
5.3 性能与稳定性上的几个实测心得
第一,无窗口模式的提速非常明显,我在同一台机器上测过,开窗口和关窗口的单步耗时差了将近三倍。所以只要不是必须看画面,一律关窗口,需要看的时候单独存图。
第二,单步跳数从 1 调到 4,整体吞吐能提升两倍多,但控制精度会下降。我的做法是:验证阶段用 4,追求速度;正式训练时按任务需要回调到 2 或 3。
第三,长时间跑随机动作时要注意内存。游戏实例本身占用不大,但如果每一帧都存观察不做释放,几百个回合下来内存会持续上涨。我习惯在循环里只保留最近若干帧,历史数据直接丢弃或者只留统计量。
第四,随机种子一定要固定。虽然这一阶段不训练,但你需要可复现的对比。我的做法是把 Python 的随机种子、numpy 的随机种子和游戏本身的种子都设一遍,三处缺一处,复现结果就会有差异。
第五,多进程并行要小心。这个引擎的实例不是进程安全的,同一个实例被多个进程访问会直接崩。正确做法是每个进程各自创建一个实例,互不干扰。
5.4 一个容易被忽略的细节:回合起始的静默期
场景配置里有个“起始时间”参数,含义是回合开始后先空转多少个时钟,让游戏把场景加载完。如果这个值设得太小,你会看到前几帧画面是黑的或者半加载状态,智能体拿到的观察是无效的。
我一开始把这个值设成 1,结果每次回合开头都有两三帧是黑屏,随机动作的统计里出现了大量异常小的奖励值,排查了很久才发现是这个参数的问题。后来改到 10,问题消失。这个参数没有通用最优值,跟机器性能有关,我的建议是先设一个偏大的值(比如 20),跑通之后再往下调,找到刚好不出黑屏的那个点。
还有个小问题:有些场景在地图加载时会有短暂的资源读取过程,表现为前几个回合明显比后面的回合慢。这是正常的,不是性能问题,不需要优化。
我个人在这一阶段花时间最多的地方,其实不是写代码,而是把每个参数的含义搞清楚并记下来。因为后面一旦开始训练,出问题的时候你需要在“环境对不对”和“算法对不对”之间快速判断,而这个判断依赖你对环境参数的熟悉程度。所以我的建议是:这一阶段宁可慢一点,每个参数都亲手改一遍、看一次效果,把现象和参数对应起来,这份直觉在后面会反复救你。
代码层面的下一步扩展,我通常会先做两件事:一是把动作空间从随机采样改成手工枚举的固定集合,二是把观察从单帧改成帧堆叠,观察智能体的运动感知是否明显变好。这两步做完,再接入训练算法,返工率会低很多。