强化学习玩射击游戏这件事真正卡住新手的从来不是算法而是第一步让游戏在你自己的机器上跑起来并且能被代码“看见”和“操控”。这个 Doom 枪战游戏 AI 项目的第一阶段做的就是这件事——把经典 FPS 游戏包装成一个可编程的强化学习环境创建出智能体要交互的游戏对象最后用一串随机动作把整条链路打通。整个过程不涉及任何神经网络训练却决定了后面所有工作的上限观察怎么取、动作怎么给、奖励怎么算、回合怎么终止这四件事在这个阶段定下来后面改起来成本极高。我会把这一阶段拆成四块选型和边界怎么定、环境怎么装、对象怎么建、随机动作怎么跑最后附一份我自己踩过的坑。适合刚接触游戏强化学习、手上有 Python 基础但没碰过游戏模拟器的人也适合已经跑过通用 Gym 环境、想换一个有视觉输入和复杂动作空间的场景练手的读者。1. 项目整体设计与技术选型思路拆解1.1 为什么第一阶段只做环境和随机动作很多人拿到这类项目的第一反应是直接抄一个 DQN 或者 PPO 的模板把游戏塞进去看能不能跑。我试过也见过不少人这么干结果通常是两种一种是训练起来了损失在降但智能体永远在贴墙走另一种是代码直接报错然后卡在环境接口上几天。原因很简单——游戏环境不是 Gym 里那种状态干净、奖励清晰的标准任务它有渲染循环、有帧缓冲格式、有动作组合、有游戏内部变量任何一项没对齐后面的训练全是白费力气。所以第一阶段刻意不引入任何学习算法只用随机动作做压力测试。随机动作的价值在于它是“最笨的智能体”如果环境本身有问题比如动作空间定义错了、观察维度不对、奖励恒为零随机动作跑几千步就能立刻暴露出来。这比训练几十万步才发现问题要省太多时间。我在实际项目里会把这一阶段称为“链路验证”标准是能连续跑满至少 200 个回合不崩、观察形状稳定、每回合的累计奖励有波动而不是常数。另一个考虑是调试成本。游戏环境一旦和训练循环耦合报错栈会非常长你很难判断是环境崩了还是算法崩了。把环境单独拎出来用一个几十行的循环反复跑报错位置一眼就能看清。1.2 为什么选 ViZDoom 这类游戏模拟器选择这个方向做强化学习沙盒核心原因有三个我按重要性排一下。第一是接口成熟度高。ViZDoom 这类平台本质上是把游戏引擎做成了一个可编程的客户端它对外暴露的不只是画面还包括游戏内部的变量比如生命值、弹药数量、击杀计数。这意味着你既可以做纯视觉的端到端训练也可以做特征工程式的轻量训练两种路线的实验成本都很低。相比之下很多游戏模拟器只给你画面想拿内部状态就得自己做 OCR 或者逆向那完全是另一个量级的工作。第二是动作空间足够“脏”。标准的 Gym 环境动作通常是离散的几个选项而射击游戏的动作是多个按键的组合前进、后退、左右平移、左右转向、开火、切换武器。这种组合式动作空间更接近真实控制问题用它练出来的策略迁移到别的场景时鲁棒性会好一些。当然代价是搜索空间大这也是后面要用各种技巧压缩动作的原因。第三是资源占用可控。这个引擎的分辨率可以压到很低比如 160x120 甚至更小帧率也可以限制。我在一台只有集成显卡的机器上跑把分辨率压到 160x120、窗口隐藏、单帧跳数设为 4CPU 占用能控制在单核 60% 左右同时开两三个环境做并行采样完全没问题。这一点对个人开发者很关键很多视觉类环境没有独显基本没法玩。1.3 第一阶段的三条边界我给这一阶段划了三条明确的边界写在这里供参考因为边界不清是返工的主要原因。边界一不训练只验证。所有动作都来自随机采样或者固定序列不做任何梯度更新。这一阶段产出的代码是“环境层”后面训练层的代码单独一个文件两者通过标准接口对接。边界二先用手写配置不做自动生成。场景配置文件那些 .cfg 文件先手写一份能跑通的把每个参数的含义搞清楚再考虑用代码动态生成。新手常见的错误是上来就写一堆参数拼接逻辑结果某个参数拼错了排查半天。边界三观察先用最小维度。一开始不要上高分辨率彩色图用灰度、低分辨率、单帧。等链路跑通了再逐步加通道数、加帧堆叠、加分辨率。这个顺序很重要因为高维观察带来的问题显存、形状、归一化会掩盖掉底层环境的真实问题。2. 环境搭建与依赖配置的实操要点2.1 版本矩阵三个依赖的兼容关系这类项目最容易翻车的地方不是代码是版本。我整理了一份我在多台机器上验证过的组合见下表。注意这不是唯一可行组合但属于“最少折腾”的那一类。组件推荐版本说明Python3.9 / 3.103.11 以上部分预编译包还没跟上容易触发源码编译vizdoom1.2.1 及以上这个版本开始自带 Gymnasium 风格封装gymnasium0.29 及以上接口比老版 gym 稳定reset返回(obs, info)二元组numpy1.24 - 1.262.x 在部分旧封装里会有形状相关的兼容问题opencv-python4.x只用来做可视化和缩放非必需但强烈建议装torch2.x这一阶段还用不到但建议提前装好避免后面返工关于 Python 版本我要多说一句。这个引擎的核心是 C 写的Python 包其实是绑定层它依赖预编译的二进制文件。如果你的 Python 版本太新官方还没发布对应 wheelpip 就会尝试从源码编译这时候会需要 CMake、编译器工具链、SDL 开发库一套下来半小时就没了而且大概率失败。我的习惯是直接建一个 3.10 的虚拟环境把所有实验都放进去。conda create -n doom_rl python3.10 -y conda activate doom_rl pip install vizdoom1.2.1 gymnasium0.29.1 numpy1.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 个元素约 19KB8 位。单步跳数为 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), interpolationcv2.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跑通之后再往下调找到刚好不出黑屏的那个点。还有个小问题有些场景在地图加载时会有短暂的资源读取过程表现为前几个回合明显比后面的回合慢。这是正常的不是性能问题不需要优化。我个人在这一阶段花时间最多的地方其实不是写代码而是把每个参数的含义搞清楚并记下来。因为后面一旦开始训练出问题的时候你需要在“环境对不对”和“算法对不对”之间快速判断而这个判断依赖你对环境参数的熟悉程度。所以我的建议是这一阶段宁可慢一点每个参数都亲手改一遍、看一次效果把现象和参数对应起来这份直觉在后面会反复救你。代码层面的下一步扩展我通常会先做两件事一是把动作空间从随机采样改成手工枚举的固定集合二是把观察从单帧改成帧堆叠观察智能体的运动感知是否明显变好。这两步做完再接入训练算法返工率会低很多。
阅读完成 · 觉得有帮助?