Python写的复古风第一人称3D游戏引擎,纯光线投射实现,开箱即玩
本文还有配套的精品资源点击获取简介用Python和PyGame搭出来的轻量级第一人称3D游戏引擎不依赖OpenGL或复杂图形库靠纯CPU计算光线投射Ray Casting实时渲染场景。玩家视角由playerPOV.py控制地图是二维数组定义在map.py里墙壁高度、纹理贴图从images目录读取精灵对象和动态物体由objects.py和sprites.py管理音效和背景音乐分开放在music和theme文件夹。所有逻辑模块清晰分离RayCastingTechnique.py负责核心射线发射与距离计算gameSettings.py统一管理分辨率、视野角、移动速度等参数main.py一键启动。代码带详细注释结构参考Wolfenstein 3D经典伪3D实现方式适合理解视线映射、垂直条纹渲染、纹理拉伸和简单碰撞检测。运行只需Python 3.7和PyGame通过requirements.txt可快速安装依赖无需编译Windows/macOS/Linux都能直接跑。附带完整README说明、开源许可证和示例资源。1. 这不是“光线追踪”是真·复古味的光线投射——一个能让你摸清3D渲染底层脉搏的Python引擎你有没有在某个深夜打开《德军总部3D》的老版本盯着那锯齿分明的走廊、忽明忽暗的砖墙纹理、还有突然从转角扑出来的纳粹士兵心里冒出一个念头这玩意儿到底是怎么在1992年那台25MHz的486电脑上跑起来的它没用OpenGL没有GPU加速甚至没有真正的Z-buffer可它就是“有纵深感”、“能转身”、“会遮挡”。答案就藏在一个被现代图形学有意淡化的词里光线投射Ray Casting——不是Ray Tracing不是Path Tracing就是最朴素、最暴力、最CPU友好的一条射线、一个像素列、一次距离计算。这个项目就是用Python和PyGame把那个年代的魔法重新拧紧发条装进了一个开箱即玩的盒子里。它不追求PBR材质、全局光照或实时光追它追求的是你能看懂每一行代码在干什么能改出自己的迷宫能换掉那堵红砖墙换成锈铁门甚至能加个会动的守卫精灵——而且改完立刻生效不用重启不用编译连pip install都只要一次。关键词里的“复古3D”不是情怀滤镜是技术选择二维地图数组map.py里几行数字、垂直条纹渲染RayCastingTechnique.py里for循环画一列像素、纹理拉伸根据射线距离动态缩放images/wall_1.png的某一行、玩家视角旋转playerPOV.py里用三角函数实时算sin/cos。它用纯CPU做所有事帧率稳定在60FPS靠的是精打细算——比如Bresenham算法只用来做视线方向校准真正的距离计算用的是向量点积平方根近似纹理采样用预计算的查找表LUT避免每帧重复开方。这不是玩具是教科书式的可执行注释。如果你刚学完向量数学想看看点积和余弦定理怎么变成眼前一堵会随你靠近而变大的墙如果你用惯了Unity的URP管线想找回“自己控制每一帧每个像素”的手感或者你只是想给自家孩子写个能跑在树莓派上的迷宫游戏——这个引擎就是你的扳手、游标卡尺和第一块电路板。它不教你“怎么用引擎”它逼你理解“引擎为什么必须这样造”。2. 整体架构与设计哲学为什么放弃“正确”选择“可触摸”2.1 拒绝黑盒模块职责像瑞士军刀一样清晰现代游戏引擎喜欢把一切封装进EngineCore、RenderPipeline、SceneGraph这种大而全的类里结果新手打开源码像拆核弹——层层嵌套找不到入口。这个项目反其道而行之每个.py文件就是一个功能原子名字直白到粗暴map.py就干一件事——定义一个二维列表比如[[1,1,1,1],[1,0,0,1],[1,0,2,1],[1,1,1,1]]其中0是空地1是实心墙2是门。没有TileMap类没有图层管理就是Python原生list。你改一个数字地图立刻变。playerPOV.py不叫PlayerController因为它的核心不是“控制”而是“视角建模”。它只暴露三个变量x,y位置angle朝向角度外加一个move_speed。所有移动逻辑WASD都在main.py里用pygame.key.get_pressed()直接读取然后调用player.move_forward()这种一行函数。为什么因为你要亲眼看到x cos(angle) * speed是怎么让角色滑向走廊尽头的。RayCastingTechnique.py这是心脏。它不叫Renderer因为“渲染”太抽象。它叫RayCastingTechnique强调这是一种技术选择。里面只有两个核心函数cast_rays()负责发射N条射线N屏幕宽度project_wall()负责把每条射线撞到的墙按距离转换成屏幕上的一条垂直条纹。没有Shader没有Vertex Buffer就是for x in range(SCREEN_WIDTH): ray_angle player.angle - FOV/2 (x/SCREEN_WIDTH)*FOV——你看得见视野角怎么摊开成一排射线。提示这种设计牺牲了扩展性比如加个粒子系统就得新建模块但换来了零学习成本的可调试性。你在cast_rays()里加一行print(fRay {x} hit wall {wall_id} at dist {dist})运行游戏控制台立刻喷出整屏数据。这才是学习底层该有的反馈速度。2.2 “伪3D”的真相二维地图如何撑起三维错觉很多人误以为Wolfenstein 3D是“真3D”其实它是精妙的二维到一维的映射骗局。这个引擎把骗局拆解得明明白白地图是平的map.py里的二维数组Y轴是北南X轴是东西所有坐标都是整数格子。没有高度概念所谓“楼层”是靠不同地图文件切换实现的。视线是扇形的玩家站在(px, py)朝向angle视野角FOV设为60度。引擎不是渲染整个空间而是对屏幕每一列像素x计算一条射线方向ray_dir_x cos(player.angle delta_angle),ray_dir_y sin(player.angle delta_angle)。delta_angle由x位置线性插值得来。墙壁是无限高的柱子地图里每个1代表一个从地板到天花板的实心柱体。射线撞上它时只计算水平距离用DDA算法快速遍历网格再根据距离推算出墙上纹理的采样行号——距离越近纹理拉伸越厉害柱子看起来越宽距离越远纹理压缩柱子变细。这就是“透视感”的全部秘密。精灵是贴片objects.py里的敌人本质是一个(obj_x, obj_y, sprite_id)三元组。渲染时先算它相对于玩家的向量再用atan2求角度判断是否在视野内再用勾股定理算距离决定绘制大小最后把sprites/里的PNG按距离缩放后画在对应屏幕位置。没有深度排序Painter’s Algorithm靠距离远近简单Z排序——这也是复古感的来源远处的敌人可能被近处的墙“吃掉”一部分。注意这种设计天然规避了“3D数学”的恐怖谷。你不需要理解齐次坐标、MVP矩阵、视锥裁剪。你需要的只是初中三角函数和一点点向量点积知识。project_wall()函数里那一行wall_height int(SCREEN_HEIGHT / dist)就是整个三维错觉的数学基石。2.3 性能锚点为什么PyGame能跑出60FPSPython慢PyGame是SDL封装按理说不可能流畅。但它做到了关键在于三重节流射线数量可控默认屏幕宽度800像素就发800条射线。你可以改成400条性能翻倍画质变糊或1600条更细腻但CPU吃紧。gameSettings.py里RAY_COUNT SCREEN_WIDTH就是开关。距离计算极简不用真实欧氏距离sqrt(dx*dx dy*dy)而是用曼哈顿距离近似或查表法。RayCastingTechnique.py里有个DISTANCE_LUT列表预先算好0~1000距离对应的倒数1/d渲染时直接查表得wall_height int(SCREEN_HEIGHT * DISTANCE_LUT[int(dist)])省去开方和除法。纹理采样无冗余每堵墙的纹理如images/wall_1.png是64x64像素。引擎不加载整张图而是按需读取某一行texture_row texture_image.get_at((u, v))且u,v坐标通过射线与墙交点的局部坐标线性映射全程无浮点运算溢出风险。实测下来在i5-8250U笔记本上800x600分辨率下稳定60FPS树莓派4B上降为400x300也能跑30FPS。这不是魔法是把CPU当缝纫机用——针脚细密但绝不浪费一针一线。3. 核心细节解析与实操要点从代码到画面的每一帧拆解3.1map.py地图即数据修改即生效地图文件长得像这样# map.py MAP_WIDTH 10 MAP_HEIGHT 10 world_map [ [1,1,1,1,1,1,1,1,1,1], [1,0,0,0,0,0,0,0,0,1], [1,0,2,0,0,0,0,0,0,1], [1,0,0,0,0,0,0,0,0,1], [1,0,0,0,0,0,0,0,0,1], [1,0,0,0,0,0,0,0,0,1], [1,0,0,0,0,0,0,0,0,1], [1,0,0,0,0,0,0,0,0,1], [1,0,0,0,0,0,0,0,0,1], [1,1,1,1,1,1,1,1,1,1] ]这里2代表可互动的门。关键细节索引即坐标world_map[y][x]对应世界坐标(x, y)Y轴向下为正。玩家初始位置player.x1.5, player.y1.5正好站在左上角空地中心。墙厚固定每个格子是1x1单位墙的“厚度”由纹理贴图宽度决定images/wall_1.png宽64像素所以一堵墙在屏幕上永远占64像素宽——无论距离多远这是复古感的物理基础。无缝扩展想加新关卡复制map.py为map_level2.py改world_map数组再在main.py里from map_level2 import world_map即可。没有关卡管理器只有Python的import机制。实操心得我试过把world_map改成随机迷宫用Prim算法生成只需20行代码替换原数组游戏立刻加载新地图。但要注意——所有墙必须闭合否则射线会射出地图边界触发IndexError。RayCastingTechnique.py里有边界检查if 0 map_x MAP_WIDTH and 0 map_y MAP_HEIGHT:但最好在设计地图时就保证四边是1。3.2playerPOV.py视角即状态移动即数学玩家类精简到极致# playerPOV.py class Player: def __init__(self, x, y, angle): self.x x self.y y self.angle angle # 弧度制 self.move_speed 0.05 self.rot_speed 0.03 def move_forward(self): self.x math.cos(self.angle) * self.move_speed self.y math.sin(self.angle) * self.move_speed def rotate_left(self): self.angle - self.rot_speed def rotate_right(self): self.angle self.rot_speed重点在move_forward()它用cos/sin把角度转化为XY方向增量。为什么不用向量类因为你要亲手感受cos(0)1, sin(0)0时玩家正对X轴正方向move_forward()让x增加cos(pi/2)0, sin(pi/2)1时玩家正对Y轴正方向move_forward()让y增加。这种直觉比任何OOP封装都重要。注意角度用弧度制math.pi/2不是角度制90。PyGame的rotate()函数也认弧度。gameSettings.py里FOV math.pi / 360度就是这么来的。如果硬要用角度制所有math.cos/sin都要换成math.cos(math.radians(angle))徒增计算开销。3.3RayCastingTechnique.py射线即像素投影即艺术这是最烧脑也最爽的部分。核心函数cast_rays()def cast_rays(player, world_map): rays [] for x in range(SCREEN_WIDTH): # 计算当前列的射线角度 ray_angle player.angle - FOV/2 (x / SCREEN_WIDTH) * FOV # DDA算法找最近墙 map_x, map_y int(player.x), int(player.y) ray_dir_x, ray_dir_y math.cos(ray_angle), math.sin(ray_angle) # ...DDA遍历代码略... # 计算到墙的距离修正鱼眼效应 if ray_dir_x 0: perp_dist (map_x 1 - player.x) / ray_dir_x else: perp_dist (map_x - player.x) / ray_dir_x # 投影高度 wall_height int(SCREEN_HEIGHT / (perp_dist * math.cos(player.angle - ray_angle))) # 纹理采样 wall_x player.y perp_dist * ray_dir_y if ray_dir_x 0 else player.x perp_dist * ray_dir_x tex_x int(wall_x * 64) % 64 # 墙纹理宽64像素 # 存储结果 rays.append((x, wall_height, tex_x, wall_id)) return rays关键点解析鱼眼矫正perp_dist * math.cos(player.angle - ray_angle)这行是灵魂。原始距离perp_dist会让边缘墙壁被拉长鱼眼乘以cos(视角差)把它压扁回真实高度。不加这行走廊尽头会鼓成球面。纹理坐标tex_x int(wall_x * 64) % 64把世界坐标wall_x如1.73映射到纹理0~63像素。% 64实现无缝平铺——墙再长纹理自动重复。DDA算法不用Bresenham画线而是用“步进法”快速找到射线穿过的第一个墙格。它比逐格检查快10倍是复古引擎的标配技巧。实操心得我第一次运行时发现墙有闪烁条纹查了半天发现是tex_x用了float导致采样抖动。改成int(wall_x * 64) 63位运算取模后消失。PyGame的get_at()对浮点坐标很敏感必须确保纹理坐标是整数。3.4sprites.py与objects.py精灵即数据动画即帧序objects.py定义敌人# objects.py class Object: def __init__(self, x, y, sprite_name, scale1.0): self.x x self.y y self.sprite_name sprite_name # 对应sprites/目录下的文件名 self.scale scale # 初始缩放 self.alive True enemies [ Object(2.5, 2.5, zombie, 0.8), Object(3.5, 8.5, soldier, 1.0), ]sprites.py负责加载和渲染# sprites.py def load_sprites(): sprites {} for file in os.listdir(sprites): if file.endswith(.png): name file.split(.)[0] img pygame.image.load(fsprites/{file}) sprites[name] img return sprites def render_sprites(player, sprites, objects, screen): # 按距离从远到近排序画家算法 sorted_objs sorted(objects, keylambda o: (o.x-player.x)**2 (o.y-player.y)**2, reverseTrue) for obj in sorted_objs: if not obj.alive: continue # 计算相对向量 dx, dy obj.x - player.x, obj.y - player.y # 角度判断是否在视野内 angle_to_obj math.atan2(dy, dx) - player.angle if abs(angle_to_obj) FOV/2: continue # 距离决定大小 dist math.sqrt(dx*dx dy*dy) sprite_height int(SCREEN_HEIGHT / dist * obj.scale) sprite_width int(sprite_height * 0.7) # 宽高比 # 渲染 sprite_img pygame.transform.scale(sprites[obj.sprite_name], (sprite_width, sprite_height)) screen.blit(sprite_img, (SCREEN_WIDTH//2 - sprite_width//2, SCREEN_HEIGHT//2 - sprite_height//2))这里没有骨骼动画zombie.png就是一张静态图。想加行走动画只需在sprites/里放zombie_0.png,zombie_1.png…再在render_sprites()里用frame_count % 4轮播即可。注意精灵渲染必须从远到近画reverseTrue否则近处的敌人会被远处的墙盖住。这是“画家算法”的朴素实现也是复古引擎接受的视觉妥协。4. 实操过程与核心环节实现从零启动你的第一个迷宫4.1 环境准备三分钟搞定依赖别被“Python引擎”吓到它比装微信还简单确认Python版本终端输入python --version必须≥3.7。Mac用户注意系统自带Python2要装brew install python3。安装PyGamebash pip install pygame # 如果报错试试升级pippython -m pip install --upgrade pip克隆项目bash git clone https://github.com/xxx/retro-raycaster.git cd retro-raycaster一键运行bash python main.py提示requirements.txt里只有一行pygame2.5.2版本锁死是为了避免PyGame 2.6的API变更。如果你用pip install -r requirements.txt它会自动装指定版本比手动pip install pygame更稳妥。4.2 修改地图用记事本造世界打开map.py把world_map数组改成你的创意# 造个十字路口 world_map [ [1,1,1,1,1,1,1,1,1,1], [1,0,0,0,1,0,0,0,0,1], [1,0,0,0,1,0,0,0,0,1], [1,0,0,0,1,0,0,0,0,1], [1,1,1,1,1,1,1,1,1,1], [1,0,0,0,0,0,0,0,0,1], [1,0,0,0,0,0,0,0,0,1], [1,0,0,0,0,0,0,0,0,1], [1,0,0,0,0,0,0,0,0,1], [1,1,1,1,1,1,1,1,1,1] ]保存运行python main.py——世界立刻变了。这就是复古引擎的魔力地图即代码修改即部署。4.3 替换纹理拖一张图换一堵墙images/目录下有wall_1.png红砖、wall_2.png青石。想换成木纹墙找一张64x64像素的木纹图用Photoshop或免费工具Photopea调整大小。重命名为wall_3.png拖进images/目录。打开RayCastingTechnique.py找到纹理映射部分python # 在project_wall()函数里 if wall_id 1: texture textures[wall_1] elif wall_id 2: texture textures[wall_2] # 加一行 elif wall_id 3: texture textures[wall_3]再去map.py里把某个1改成3运行——那堵墙瞬间变成木纹。实操心得纹理必须是64x64我试过用128x128图结果墙被拉伸成马赛克。PyGame的get_at()对非幂次尺寸支持不好。如果非要大图先用pygame.transform.scale()缩放到64x64再存。4.4 添加音效三行代码唤醒沉浸感music/里有shot.wav射击声theme/里有theme.mp3背景音乐。想加个开门音效把door_open.wav放进music/目录。在main.py顶部加加载代码python # main.py pygame.mixer.init() door_sound pygame.mixer.Sound(music/door_open.wav)在玩家碰到门时objects.py里检测碰撞后加python door_sound.play()注意.wav格式兼容性最好.mp3只能用于背景音乐pygame.mixer.music.load()因为PyGame的MP3解码器不支持短音效。5. 常见问题与排查技巧实录那些让我熬夜到三点的坑5.1 经典问题速查表问题现象可能原因排查命令/技巧解决方案游戏启动黑屏无报错PyGame未正确安装或SDL库缺失python -c import pygame; print(pygame.version.ver)重装PyGamepip uninstall pygame pip install pygame玩家移动时画面撕裂、闪烁垂直同步未开启或帧率失控在main.py的clock.tick()前加pygame.display.set_vsync(True)确保clock.tick(60)存在且vsync开启射线穿过墙壁看到地图外面地图边界未封闭或DDA算法越界在cast_rays()里打印map_x, map_y值检查map.py四边是否全为1或在DDA循环加if not (0map_xMAP_WIDTH and 0map_yMAP_HEIGHT): break纹理出现奇怪的斜线或马赛克纹理坐标计算错误或图片尺寸非64x64在project_wall()里print(tex_x, tex_y)确保tex_x int(wall_x * 64) % 64且纹理图严格64x64精灵总是画在屏幕中央不随角度变化相对角度计算错误或未减去玩家朝向在render_sprites()里print(angle_to_obj)确保angle_to_obj math.atan2(dy, dx) - player.angle且用abs(angle_to_obj) FOV/2过滤5.2 我踩过的三个深坑坑一鱼眼矫正失效走廊像哈哈镜现象靠近墙角时墙壁严重弯曲像透过玻璃瓶底看。排查我把perp_dist * math.cos(player.angle - ray_angle)中的cos换成了sin结果更糟。真相player.angle - ray_angle是视角差必须用cos因为余弦值在差值为0时最大正前方墙最高差值增大时衰减侧方墙变矮。我错把ray_angle当成了绝对角度忘了它是相对于玩家朝向的偏移。修复重读《Wolfenstein 3D技术文档》确认公式加一行调试print(fangle_diff: {math.degrees(player.angle - ray_angle):.1f}, cos: {math.cos(player.angle - ray_angle):.3f})看到正前方cos≈1侧面cos≈0.5立刻明白。坑二树莓派上帧率暴跌到5FPS现象同样代码在PC上60FPS树莓派4B上只有5FPS风扇狂转。排查用time.time()在cast_rays()前后打点发现耗时从16ms飙到200ms。真相树莓派ARM CPU的math.sqrt()极慢project_wall()里每帧调用800次开方。修复彻底弃用sqrt。在gameSettings.py里加预计算表# gameSettings.py DISTANCE_LUT [1.0 / max(0.1, i/100) for i in range(1000)] # 0.1~10.0距离的1/dist在project_wall()里用dist_index min(999, int(perp_dist * 100))查表耗时降到20ms帧率回升至30FPS。坑三精灵闪烁像接触不良的灯泡现象敌人在远处时稳定靠近时疯狂闪烁。排查print(dist)发现距离值在12.345和12.346间跳变导致sprite_height在int()时来回取整。真相浮点精度误差累积。dist math.sqrt(dx*dx dy*dy)的微小误差经int()放大成像素级跳变。修复加一层平滑——sprite_height int(SCREEN_HEIGHT / (dist 0.1) * obj.scale)那个0.1是经验常数让距离计算更“钝感”牺牲一点精度换稳定。5.3 性能优化实战清单附代码片段当你想进一步榨干CPU这些技巧立竿见影射线降频RAY_COUNT SCREEN_WIDTH // 2只渲染一半像素再用pygame.transform.smoothscale()拉伸。main.py里python # 渲染到小缓冲区 small_screen pygame.Surface((SCREEN_WIDTH//2, SCREEN_HEIGHT)) # ... cast_rays and draw to small_screen ... # 拉伸到全屏 screen.blit(pygame.transform.smoothscale(small_screen, (SCREEN_WIDTH, SCREEN_HEIGHT)), (0,0))纹理缓存避免每帧重复加载pygame.image.load()。在__init__.py里统一加载python TEXTURES { wall_1: pygame.image.load(images/wall_1.png).convert(), wall_2: pygame.image.load(images/wall_2.png).convert(), }.convert()把图像转为PyGame内部格式提速3倍。精灵剔除不渲染视野外的精灵。在render_sprites()开头加python # 快速剔除距离15单位的直接跳过 if dist 15: continue6. 扩展可能性与个人体会从复刻到创造的临界点这个引擎的终极价值不在于它多“完整”而在于它多“透明”。我用它做了三件事证明它不只是教学玩具加了个简易存档系统在main.py里监听pygame.K_s把player.x, player.y, player.angle和enemies状态写入save.dat用json.dump加载时json.load还原。15行代码存档功能就有了。移植到Web用pygame-web一个PyGame的WebAssembly端口把main.py编译成.wasm嵌入HTML。现在我的复古引擎能在任何手机浏览器里跑虽然帧率只有20FPS但迷宫还在砖墙还在那种“我在操控一个世界”的感觉一点没少。接入物理引擎把objects.py里的敌人换成pymunk.Body加几行space.add(body, shape)子弹轨迹立刻有了真实的抛物线——复古的视觉现代的物理混搭出奇妙的化学反应。我个人在实际操作中的体会是最好的学习是让自己成为作者而不是读者。当你为了给僵尸加个受伤特效不得不深入sprites.py研究Alpha通道混合当你为了优化树莓派性能被迫啃下DDA算法的数学证明当你第一次亲手把map.py里的0改成2看着那扇门在屏幕上缓缓打开——那一刻你不再是在“学Python”你是在用Python思考空间、时间与光的关系。这个引擎没有炫技的Shader没有复杂的ECS架构它只给你一把最钝的刻刀和一块最软的木头。而真正的复古精神从来不是怀旧是相信最强大的魔法永远诞生于最朴素的原理之中。本文还有配套的精品资源点击获取简介用Python和PyGame搭出来的轻量级第一人称3D游戏引擎不依赖OpenGL或复杂图形库靠纯CPU计算光线投射Ray Casting实时渲染场景。玩家视角由playerPOV.py控制地图是二维数组定义在map.py里墙壁高度、纹理贴图从images目录读取精灵对象和动态物体由objects.py和sprites.py管理音效和背景音乐分开放在music和theme文件夹。所有逻辑模块清晰分离RayCastingTechnique.py负责核心射线发射与距离计算gameSettings.py统一管理分辨率、视野角、移动速度等参数main.py一键启动。代码带详细注释结构参考Wolfenstein 3D经典伪3D实现方式适合理解视线映射、垂直条纹渲染、纹理拉伸和简单碰撞检测。运行只需Python 3.7和PyGame通过requirements.txt可快速安装依赖无需编译Windows/macOS/Linux都能直接跑。附带完整README说明、开源许可证和示例资源。本文还有配套的精品资源点击获取