
如果你跟我一样第一次听到“用 Python 写 3D 游戏”这个说法第一反应多半是写个 2D 小游戏都得排队渲染3D 不得要命了但我在偶然接触了 Ursina 之后这个想法被彻底改变了。这篇不是什么新手入门教程而是我把一个用 Python Ursina 做的 3D 小游戏从第一版“能跑”到后来反复修改、逐步变好玩的完整记录。里面有完整代码、改版思路也有我在这个过程中踩过的实实在在的坑。适合谁看如果你是刚学 Python 不久、想做点有空间感的东西练手或者你已经用 Pygame 做过 2D 游戏、想往 3D 方向迈一脚这篇文章应该能帮你省下不少自己摸索的时间。我会把“为什么这么改”也讲清楚而不是单纯甩一堆代码给你。1. 为什么选 Ursina在 Python 生态里做 3D 的取舍1.1 先说说 pygame 的边界我在用 Ursina 之前先用 Pygame 写过几个小游戏。说实话Pygame 做 2D 是真的顺手画面渲染、事件循环、音效播放全都给你安排得明明白白。但一到 3D 就完全不是那么回事了——Pygame 本身没有 3D 渲染管线只有最基础的 Surface 绘制你想做一个立方体旋转得自己写投影公式、自己处理深度排序、自己算背面剔除。我还真试过用 Pygame 做一个“伪 3D”迷宫本质上是把二维瓦片按距离缩放贴图制造一种纵深错觉。跑起来是能跑但画面稍微复杂一点就露馅更别提真正的模型加载、碰撞检测、摄像机控制这些游戏基本功了。所以就 3D 这件事而言Pygame 的边界非常清晰它能模拟但它不是一个 3D 引擎。1.2 Ursina 与 Panda3D 的关系没那么神秘Ursina 这个库底层封装的是 Panda3D。Panda3D 是迪士尼参与开发的一个开源 3D 引擎能力很强但它的 API 设计偏工程化对刚接触 3D 开发的 Python 用户来说门槛不低——加载模型、管理场景、设置光照、处理输入每样都要接触比较底层的概念。Ursina 就是在这个基础上做了一层非常友好的封装。它把“场景里的物体”统一抽象成 Entity把“模型、纹理、碰撞体、位置、旋转”这些属性直接暴露在构造参数里让原本需要十几行 Panda3D 代码才能创建的东西压缩到一行。可以这么理解Panda3D 是卡车的底盘Ursina 是给你装好的驾驶舱——你不需要知道发动机内部怎么转挂挡踩油门就能跑起来。1.3 如果你在 Unity、Godot 和 Ursina 之间犹豫我当初也纠结过是不是直接去学 Unity毕竟市面上教程多、资料全。但我的目标是“用 Python 快速验证一个 3D 玩法原型”而不是做商业游戏这个前提决定了选型方向。方案需要学的语言3D 能力上手难度适合场景PygamePython几乎没有原生 3D低2D 小游戏、学习图形基础UrsinaPython封装了 Panda3D足够应付中小型 3D很低Python 学习、原型验证、小型 3D 游戏Panda3DPython非常完整专业级偏高需要深度 3D 控制的 Python 项目UnityC#非常完整生态庞大中等商业项目、跨平台发布对我来说选 Ursina 的理由很朴素不用学新语言代码写到一半不会被引擎细节打断能专注在“这个玩法到底好不好玩”这件事上。1.4 知道它不擅长什么Ursina 也绝对不是万能的。它的物理系统比较基础和 PhysX、Bullet 这类专业物理引擎没法比它的渲染性能在场景实体非常多的时候会明显下降移动端支持和 Web 导出也基本处于“能用但不成熟”的状态。你做一个小体量 3D 游戏、一个教学演示、一个创意原型Ursina 很舒服但如果你要做大型开放世界、或者想一键发布到手机那还是趁早换 Unity 或 Godot。这个边界越早认清后面踩坑越少。2. 环境搭建与第一个 3D 窗口别急着写游戏逻辑2.1 安装与版本注意事项安装很简单一条命令搞定pip install ursina它会自动把 Panda3D 也装上因为 Ursina 的运行依赖它。Python 版本方面我建议使用 3.8 到 3.11不要用太新的版本倒不是说新版一定跑不起来而是 Ursina 的部分依赖对 Python 3.12 的兼容性更新没那么及时我身边已经有朋友在 3.13 上碰到过诡异的报错。装完之后建议先跑一下官方示例确认环境没问题python examples/00_ursina.py如果你是用 venv 虚拟环境记得先激活虚拟环境再安装如果之前装过 Panda3D最好确认一下版本Ursina 对特定 Panda3D 版本有依赖关系手动改过版本容易出问题。2.2 最小程序拆解Ursina 的最小程序非常简洁from ursina import * app Ursina() player Entity( modelcube, colorcolor.orange, position(0, 0, 0) ) app.run()这段代码创建了一个橙色的立方体并把它放在世界坐标原点。你可能会惊讶Ursina 竟然内置了 cube、sphere、quad 这些基础模型不需要任何外部资源就能跑起来一个带光照、带摄像机视角的场景。这里有三件事你需要先建立概念Ursina()是引擎的主程序入口相当于初始化整个游戏系统。Entity是场景中所有可见物体的基类不管是玩家、敌人、道具、还是灯光本质上都是 Entity。app.run()是主循环它做的事情类似于 Pygame 里的 while True负责不断刷新画面、处理输入、调用 update 函数。只要理解了这三个概念后面的代码基本上都是在这个框架里做文章。2.3 我遇到的“窗口闪退”和“画面黑屏”环境搭建这块我遇到过两个比较典型的坑。第一个坑是安装完后一运行就闪退窗口刚弹出来立刻消失没有任何报错。排查了半天发现是显卡驱动太老Panda3D 初始化渲染器的时候直接崩溃了。解决办法是更新显卡驱动如果你的电脑很老或者用的是某些精简版系统可以考虑在 Ursina 初始化时强制使用兼容渲染模式app Ursina(render_modeimmediate)第二个坑是画面全黑但窗口正常显示。这种情况一般是灯光设置的问题Ursina 默认场景里自带一个环境光但当你的场景比较复杂或者你手动添加了其他光源之后光照参数可能会混乱。最简单的排查方法是先不加任何自定义光源只保留默认配置等场景能正常显示了再逐个加灯。3. 第一版 Demo这个版本真的只能叫跑通3.1 25 行代码实现“移动 得分”第一版我做了个最原始的收集游戏玩家控制一个方块在地面上移动碰到金色的球就得分。整个游戏逻辑就二十五行左右当时我觉得已经很“完整”了现在回头看它只能叫“跑通了”连“游戏”都谈不上。from ursina import * import random app Ursina() player Entity(modelcube, colorcolor.orange, position(0, 0.5, 0), scale(1, 1, 1)) coin Entity(modelsphere, colorcolor.yellow, position(3, 0.5, 3), scale0.5) score_text Text(text得分: 0, position(-0.8, 0.45), scale2) score 0 def update(): global score if held_keys[w]: player.z - 1 if held_keys[s]: player.z 1 if held_keys[a]: player.x - 1 if held_keys[d]: player.x 1 if distance(player.position, coin.position) 1: score 1 score_text.text f得分: {score} coin.position (random.uniform(-5, 5), 0.5, random.uniform(-5, 5)) app.run()这段代码里用到了 held_keys 判断按键状态用 Ursina 内置的 distance 函数计算玩家和金币的距离距离小于 1 就判定得分。第一次看到它跑起来的时候我的成就感还是很大的——至少一个 3D 场景里真的出现了“可控制物体”和“交互反馈”。3.2 第一版我没考虑到的三个问题代码跑通之后我很快就发现了几个毛病。第一个问题是移动方式非常“生硬”。按住 W 键方块不是平滑移动而是每次刷新都直接跳一格完全没有速度感和过渡感。原因很简单我把位移写成了固定值没有乘以时间增量导致移动速度直接和帧率挂钩帧率高就走得快帧率低就走得慢手感极其别扭。第二个问题是碰撞判定太粗糙。distance(player.position, coin.position) 1 这个判断本质上是一个“以玩家位置为圆心的距离圈”根本不管玩家和金币的模型到底有多大、碰到了哪个面。我甚至试过玩家从金币旁边快速飞过去还没碰到模型就给算分了。这在做一个简单 demo 时无所谓但它完全不具备“碰撞体”的概念后续要加墙壁、加障碍物这种判定方式根本撑不住。第三个问题是摄像机视角是固定的。玩家一旦跑出屏幕中心区域你就看不到自己了游戏基本没法继续。第一版没有做摄像机跟随这是最影响体验的一个问题。3.3 跑通之后的直观感受能运行但不好玩把第一版拿给朋友看他玩了十秒钟说了一句很扎心的话“这东西没有目标也没有挑战。”确实整个游戏就是走过去拿金币金币消失又在随机位置刷新没有任何失败条件也没有时间压力。跑通的意义在于验证了 Ursina 的可行性但从“能运行”到“好玩”中间还隔着很大一段路。也是从那一刻起我决定认真做一次修改不是推翻重写而是基于现有代码逐步演进。4. 修改重点把“能跑”变成“能玩”4.1 让移动不飘方向向量归一化与 time.dt第一个修改点是移动系统。我需要让方块按照恒定速度平滑移动而不是每帧跳一格。这里有两个关键改动。先看第一版的坏味道按下 W 和 D 同时移动时x 和 z 方向各加 1最终移动距离是根号 2比只按一个方向走得快。这就是经典的“斜向加速”问题。解决办法是构造一个方向向量先归一化再乘以速度。再看帧率问题游戏循环每秒跑很多次如果每次位移固定帧率越高移动越快。解决办法是乘以 time.dt它是上一帧到当前帧的时间差这样位置变化就从“每帧移动固定距离”变成了“每秒移动固定距离”。class Player(Entity): def __init__(self, position(0, 0.5, 0)): super().__init__( modelcube, colorcolor.orange, scale(1, 1, 1), positionposition, colliderbox, ) self.speed 6 def update(self): move_dir Vec3(0, 0, 0) if held_keys[w] or held_keys[up arrow]: move_dir.z - 1 if held_keys[s] or held_keys[down arrow]: move_dir.z 1 if held_keys[a] or held_keys[left arrow]: move_dir.x - 1 if held_keys[d] or held_keys[right arrow]: move_dir.x 1 if move_dir.length() 0: move_dir move_dir.normalized() self.position move_dir * self.speed * time.dt注意这里我把 Player 写成了 Entity 的子类。Ursina 会为所有启用状态下的 Entity 自动调用 update 方法所以玩家的移动逻辑放进类里面之后不需要在全局 update 里手动调用代码职责也清晰了。4.2 判定碰撞从算距离换成真正的碰撞体第一版的距离判定到处都是隐患。Ursina 本身支持实体碰撞体设置很简单创建 Entity 时加入 collider 参数。立方体用 box球体用 sphere然后就可以用 intersects 方法来判断两个实体是否真的接触。class Coin(Entity): def __init__(self, position(0, 0.5, 0)): super().__init__( modelsphere, colorcolor.gold, scale0.5, collidersphere, positionposition, )在游戏主循环里碰撞检测从“算距离小于阈值”改成了def update(): for coin in coins: if player.intersects(coin).hit: player.score 1 score_text.text f得分: {player.score} coin.position (random.uniform(-4, 4), 0.5, random.uniform(-4, 4))这个改动让判定精确到了模型表面玩家必须真的“碰到”金币才能得分。看起来只是换了个 API但概念完全不同——你现在有了一个可以被墙壁挡住、可以被障碍物撞停的物理边界后续一切玩法扩展都有了基础。4.3 第三人称跟随与鼠标视角第一版固定视角的问题必须解决。最简单的策略是每帧把摄像机放在玩家后上方def update(): camera.position player.position Vec3(0, 10, -12) camera.rotation_x -25这里 camera.rotation_x -25 是让镜头向下俯视这样玩家始终在画面中央偏下视野也足够开阔。不过直接赋值会导致镜头非常“硬”玩家一转弯镜头就瞬移看久了会晕。更好一点的做法是用线性插值实现平滑跟随camera.position lerp(camera.position, player.position Vec3(0, 10, -12), 0.1) camera.rotation_x lerp(camera.rotation_x, -25, 0.1)lerp 是线性插值每次主循环让镜头向目标位置靠近一点这样镜头就带了一点惯性观感舒服很多。如果你的游戏需要鼠标控制视角Ursina 还提供了鼠标锁定的支持可以在 update 里读取 mouse.velocity 修改摄像机的旋转角度但这要看你的玩法是否需要——收集金币这种休闲玩法第三人称固定跟随就已经够了。4.4 加入开始、结束、计分的状态闭环好的小游戏循环一定是“开始 - 进行 - 结束 - 再来一次”。第一版没有这个闭环玩家停下来不是因为赢了或输了而是因为无聊了。我加了一个简单的时间限制60 秒内尽可能收集金币时间归零游戏结束显示最终得分点击“重新开始”按钮重置游戏。为了不让代码乱成一锅粥我用一个状态变量控制游戏阶段game_state menu # menu / playing / game_over def start_game(): global game_state, score, time_left score 0 time_left 60.0 game_state playing def update(): global game_state, time_left if game_state playing: time_left - time.dt if time_left 0: game_state game_over # 游戏结束显示结算界面页面上的按钮直接用 Ursina 提供的 Button 类menu 状态显示题目和说明playing 状态显示倒计时game_over 状态显示最终得分和重玩按钮。这个状态闭环一旦建立游戏的“目标”和“压力”就出来了玩家才能体验到最基本的乐趣。5. 进阶修改性能、资源与代码结构5.1 实体数量一多就掉帧减少场景实体与合并绘制修改到后面我开始往场景里塞更多的金币、障碍物、装饰物很快遇到了性能问题——场景里实体数量超过一百个帧率就开始往下掉。Ursina 的每一个 Entity 都是一个独立场景节点虽然代码写起来方便但渲染时每个节点都有开销。我的第一步是砍掉多余的装饰光源Ursina 场景里不需要每个柱子都放一盏灯全局一盏环境光加一盏方向光就足够。第二步是合并静态网格。对于完全不动的装饰物地面、墙壁、树木这类可以把它们的模型合并成一个 Mesh再用一个 Entity 渲染。Ursina 里可以用 Mesh.combine 的方式合并也可以用代码把顶点数据拼在一起后者性能更好但实现稍复杂。对于我这个体量的项目最简单的优化是静态物体直接禁用阴影或者用纹理贴图代替真正的模型细节。还有一个小工具打开 FPS 显示面板随时观察帧率变化。window.fps_counter.enabled True这句代码会直接在窗口标题上显示实时帧率所有性能优化有没有效果一眼就知道。5.2 外部模型与纹理导入 glb/gltf 时踩过的坑Ursina 内置的 cube、sphere 只能用于原型想让游戏更像样得导入外部模型。我最开始导入了一个 .obj 格式的小房子结果纹理加载不出来整个模型变成灰色。查了半天发现是 .obj 旁边的 .mtl 材质文件中纹理路径是相对的而我加载模型的时候用了相对路径一换目录就找不到贴图。后来我干脆改用 .glb 格式这是 3D 模型打包最省心的格式纹理直接内嵌在文件里不需要额外处理路径问题。使用方式也很简单house Entity( modelmodels/house.glb, position(2, 0, 3), scale1.5 )注意一个细节Ursina 的世界单位默认是“米”但从网上下载的模型可能用的是厘米单位导入后经常出现一个 1.0 大的模型实际占了几百个单位的情况。所以每次导入模型第一件事是看它的实际大小不对就调 scale不要等到场景里穿模了再回头调。还有一点模型文件路径不要带中文不要放在中文目录下。这不是 Ursina 独有的问题Panda3D 在资源加载上的中文路径兼容做得不算好我吃过一次亏之后所有模型资源都放在纯英文路径下。5.3 把单文件脚本拆成模块与组件随着代码越来越长原来的 main.py 已经膨胀到几百行找东西全靠滚动条。我做了第二次结构修改把不同职责拆到不同文件mini_game/ ├── main.py # 程序入口负责创建场景和主循环 ├── game.py # 游戏状态管理、UI 界面、倒计时 ├── player.py # 玩家实体移动逻辑 ├── coin.py # 金币实体刷新逻辑 ├── obstacle.py # 障碍物实体 ├── models/ │ └── house.glb └── textures/每个类都像前面写的 Player 一样继承 Entity把自身的 update 逻辑放在自己类里面。这样每个文件都很短改金币刷新逻辑不会误伤玩家移动逻辑重新开始游戏时的重置代码也有清晰的归属。Ursina 的组件化能力比很多 Python 库都强它的 Entity 本身就是一个可以自由添加属性和方法的对象很适合把游戏玩法拆成一个个“实体组件”。做小游戏不要一上来就把所有内容塞进一个文件规则越清晰后续修改越轻松。5.4 修改过程中的两个救命调试手段代码量上来之后改 bug 的效率直接决定项目的成败。我自己最常用的两个调试手段都非常朴素。第一个是 print() 大法。Ursina 的 update 每个 tick 都在跑想确认某个逻辑有没有被执行直接在对应分支里 print 一行跑一遍游戏看控制台输出。比如我遇到“金币碰到不消失”的问题就在碰撞分支里打印 hit 状态几秒钟就锁定了原因——金币碰撞体的 scale 写错了模型看着是 0.5碰撞体却只有原始大小的 0.1。第二个是 EditorCamera()。Ursina 提供了一个调试用的编辑器摄像机运行时只要在代码里加一行EditorCamera()就可以按住鼠标中键旋转视角、右键平移、滚轮缩放从任意角度观察场景里的碰撞体和物理边界。排查“为什么玩家被空气墙挡住”这类问题特别好用因为你一眼就能看到碰撞体比模型大了一圈还是偏了一截。6. 打包发布与后续还能改什么6.1 PyInstaller 打包 Ursina 游戏的实际流程改完的版本想发给朋友玩就不能老是让他在命令行里 pip install 再跑脚本打包这一步绕不开。Ursina 游戏打包用的是 PyInstaller命令大致是这个样子pip install pyinstaller pyinstaller --onefile --windowed --name MiniGame main.py但保险起见我会在后面加上 --hidden-import因为 Panda3D 的很多模块是通过动态导入加载的打包时容易漏pyinstaller --onefile --windowed --name MiniGame \ --hidden-import panda3d.core \ --hidden-import panda3d.main \ main.py注意打包出来的 exe 体积会非常大动辄上百 MB这是正常的因为 Panda3D 的运行时库本身就大。--onefile 模式虽然分发方便但启动时每次都要释放临时文件启动会明显变慢。如果你更在意启动速度可以考虑用 --onedir 模式把整个目录打包分发。还有一个可能遇到的问题某些杀毒软件会误报 PyInstaller 生成的程序因为它的特征和常见病毒类似。这个没有彻底根治的办法通常加个签名或者自己手动添加白名单就行了不是代码的问题。6.2 后续扩展方向计时挑战、随机地图、多关卡把基础的收集金币玩法做好之后其实可以往很多方向扩展。计时挑战模式我在前面已经做了雏形可以继续增加奖励时间机制连续收集三个金币不失误额外加两秒。这样玩家就需要规划路线而不是漫无目的地逛。随机地图也值得一试。Ursina 的 Entity 创建很轻量完全可以在游戏开始时动态生成地面、墙壁和障碍物。比如用一个二维数组表示地图0 是空地、1 是墙壁然后用两层 for 循环生成方格玩家出生在指定位置金币刷新在可达区域内。这种改动不需要换技术方案只改数据生成逻辑对想要练手的同学特别合适。还可以给障碍物加一点“生命”比如让人工智障小方块在地图上巡逻玩家碰到就扣时间。这就要用到简单的移动 AI本质上和玩家移动逻辑一样只不过输入从键盘变成了预定义路径。6.3 如果让我重来一次我会在一开始就注意的三件事第一先搭好目录结构再做功能。第一版把所有代码写进一个文件爽是爽了但后面拆模块的时候花了很长时间处理依赖关系。如果一开始就按 player、enemy、game 分开写后期根本不用动结构。第二先设计好游戏状态机再写交互逻辑。第一版完全没有状态概念导致加入开始界面和结束界面时代码里到处是 if 判断非常邋遢。后来用 menu/playing/game_over 三个状态统一管理只改了半个小时就利索了。第三别贪多先把核心循环做扎实。我一度想往游戏里加技能系统、加商店、加多角色后来发现光是把“移动 - 收集 - 时间压力 - 结算”这条线打磨顺滑就已经花掉了大部分精力。小体量游戏最忌讳的是玩法还没成形就开始堆内容核心循环不清晰加再多东西都是负担。如果你也从零开始做过一个 3D 小游戏你可能会发现最难的永远不是某个 API 怎么用而是“怎么把一堆能运行的代码组织成一个真正好玩的东西”。用 Ursina 最大的价值就是它把第一个坎——让 3D 场景在 Python 里跑起来——变得几乎不需要思考让你能把精力全部放在修改和玩法迭代上。我从第一版到最终版代码量翻了将近五倍但每一步修改都对应着一个具体的体验问题。做完这个项目之后明显感觉自己对“游戏结构”的理解比练习两个月 Pygame 还扎实。