如果你跟我一样,第一次听到“用 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 能力 | 上手难度 | 适合场景 |
|---|---|---|---|---|
| Pygame | Python | 几乎没有原生 3D | 低 | 2D 小游戏、学习图形基础 |
| Ursina | Python | 封装了 Panda3D,足够应付中小型 3D | 很低 | Python 学习、原型验证、小型 3D 游戏 |
| Panda3D | Python | 非常完整,专业级 | 偏高 | 需要深度 3D 控制的 Python 项目 |
| Unity | C# | 非常完整,生态庞大 | 中等 | 商业项目、跨平台发布 |
对我来说,选 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( model='cube', color=color.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_mode='immediate')第二个坑是画面全黑,但窗口正常显示。这种情况一般是灯光设置的问题,Ursina 默认场景里自带一个环境光,但当你的场景比较复杂或者你手动添加了其他光源之后,光照参数可能会混乱。最简单的排查方法是先不加任何自定义光源,只保留默认配置,等场景能正常显示了再逐个加灯。
3. 第一版 Demo:这个版本真的只能叫跑通
3.1 25 行代码实现“移动 + 得分”
第一版我做了个最原始的收集游戏:玩家控制一个方块,在地面上移动,碰到金色的球就得分。整个游戏逻辑就二十五行左右,当时我觉得已经很“完整”了,现在回头看,它只能叫“跑通了”,连“游戏”都谈不上。
from ursina import * import random app = Ursina() player = Entity(model='cube', color=color.orange, position=(0, 0.5, 0), scale=(1, 1, 1)) coin = Entity(model='sphere', color=color.yellow, position=(3, 0.5, 3), scale=0.5) score_text = Text(text='得分: 0', position=(-0.8, 0.45), scale=2) 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__( model='cube', color=color.orange, scale=(1, 1, 1), position=position, collider='box', ) 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__( model='sphere', color=color.gold, scale=0.5, collider='sphere', position=position, )在游戏主循环里,碰撞检测从“算距离小于阈值”改成了:
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( model='models/house.glb', position=(2, 0, 3), scale=1.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 还扎实。