说真的,Python写3D游戏,很多人第一反应是“不靠谱”。帧率、性能、引擎成熟度……这些话听多了,我反而觉得得看场景。如果你像我一样,只是想把脑袋里一个小想法变成能跑、能玩、能拿去演示的东西,Python + Ursina的组合会给你完全不同的体验。这个项目是我把之前照着官方教程做的一个3D小Demo整体回炉重改了一遍:从只能看的几个方块,改成包含玩家控制、跳跃收集、障碍躲避、倒计时判定的一整套小游戏流程。这篇博文就完整记录这轮“修改”的思路、代码和踩坑,适合有Python入门基础、想尝试3D游戏开发、又不打算一头扎进Unity或Unreal的同学参考。不需要图形学基础,所有原理我会尽量用大白话讲清楚,看完你至少能拥有一个自己可以边玩边改的3D小游戏框架。
1. 为什么选Ursina:Python做3D游戏的现实判断
1.1 Ursina到底是什么
简单说,Ursina是一个构建在Panda3D之上的Python游戏框架。Panda3D是迪士尼参与开发的成熟3D引擎,负责渲染、资源加载、碰撞检测这些底层脏活;Ursina把它的接口包了一层“新手友好”的皮,让你能用几行代码创建模型、设置灯光、控制摄像机。它给人的感觉非常像用Flask写Web后端的体验:没有复杂配置,专注业务逻辑本身。
from ursina import * app = Ursina() cube = Entity(model='cube', color=color.orange, position=(0, 0, 0)) app.run()这大概是全Python生态里生成一个可旋转、带光影的3D对象最短的代码。如果用pygame实现同样的效果,你得自己处理投影矩阵、三角形顶点数组、Z缓冲,几十行起步;而Ursina把这一切压缩成了Entity(model='cube')。它的意义就在这:让写代码的人把精力放在“游戏里有什么、怎么玩”,而不是“画面怎么渲染出来”。
对比Panda3D原生写法,Ursina的代码量大约是原来的三分之一甚至更少。Panda3D里要新建一个窗口、加载模型、设置摄像机,每个步骤都有对应的管线概念;Ursina把这些都自动化了,直接给一个Ursina()对象,窗口、场景、默认摄像机全帮你备好。上手门槛从“要先学会引擎的架构”直接降到“只要会写Python类就行”。
1.2 它适合谁,不适合谁
先说结论:Ursina适合做学习项目、快速原型、Game Jam作品、学生毕设,以及给AI训练提供可视化环境这类工具型项目。它最擅长的场景是“三五天之内把创意验证出来”。我这次修改的项目,本质上就是把一个验证原型升级成带有完整游戏循环的作品。
它不适合做什么呢?大规模商业项目、重度性能要求的射击游戏、需要复杂自定义Shader渲染的产品级应用。原因很简单:Ursina属于高层封装,底层优化空间被框架的结构限制住了;Python本身的GIL和多线程模型也会在大量物理计算时成为瓶颈。但这完全不影响它作为“开发效率和可玩性的平衡点”来使用。我自己在写这个游戏时,手里还有Unity,但用Unity就得建项目、选模板、等导入,等这些搞完,Ursina这边游戏可能已经能跑了。
所以我的判断是:选型不是越强大越好,而是匹配你的目标。你的目标如果是快速做出一个能玩的3D游戏,Ursina是一个被低估的答案。
1.3 这个“修改版”项目到底改了什么
这个项目原来是个标准教程Demo:一个地面、几个飘浮的方块,玩家能走能跳,拿相机转来转去,没了。这轮修改我做了三件事。
第一,补上“游戏性”。加了可收集的金色方块、障碍物、得分计数器、倒计时,输赢判定也清楚了,玩家现在进去知道自己要干嘛。
第二,重构代码结构。把收集物、障碍物从一堆散落的Entity调用改成独立的类,各自把update逻辑封装在内部。这一步改造收益很大,之后加新物体类型几乎不碰主逻辑。
第三,打磨手感。调了移动速度、跳跃高度、碰撞体尺寸,还让收集物有旋转和上下浮动动画。别小看这些细节,实际测试中,手感差一点玩家十分钟就流失了。
在后文的讲解里,你会看到每一处修改背后的“为什么”,而不只是“怎么做”。
2. 环境准备与项目初建
2.1 安装与版本选择
如果你用的是Windows,安装基本是零门槛的。打开命令行,执行:
pip install ursinaUrsina会根据平台自动拉取对应的Panda3D构建版本。实测Python 3.8到3.12都能正常运行,建议直接用比较新的稳定版,但不建议追Python 3.12之前的小版本,因为某些Panda3D预编译包对个别小版本适配滞后,容易出一些莫名其妙的DLL错误。
装完之后别急着写代码,先跑一个最小验证,确认环境真的通了:
from ursina import * app = Ursina() player = Entity(model='cube', color=color.cyan) app.run()如果弹出一个灰蓝色背景窗口、中间一个青色方块,说明环境没问题。我第一次装完直接写完整游戏,结果因为某个依赖缺失,报错信息混在一堆日志里,排查了很久才知道是基础环境没通。新手建议老老实实从最小窗口开始验证,别跳步。
Linux环境麻烦一点,Debian系可能要补装系统库,比如GPU相关的libegl1、libgl1、libx11-6,以及编译用的gcc、python3-dev。macOS相对顺利,但Apple Silicon上有时会遇到Panda3D编译版本兼容问题,优先用官方推荐的Python版本会稳很多。
2.2 编辑器热加载:改代码不用重启
Ursina一大特色是自带编辑器模式。窗口激活状态下,按F5热加载代码、按Ctrl+Q退出。这意味着你改一行颜色、调一个坐标,马上能看到结果,开发体验特别接近Web前端的Live Reload。
不过热加载有个坑:它会把整个游戏进程的全局变量重置,但不会重新打开那个已经存在的窗口。如果你的游戏逻辑依赖某些启动时计算的全局状态,比如随机地图种子、累计分数,热加载后这些状态会从零开始,可能和你预期不一致。我的做法是:所有需要持久化的状态都放进专门的类或场景对象里,热加载后通过scene重新初始化。另外,热加载对“当前正在运行的update循环”也有影响——如果在update里执行了半截操作,热加载后可能留下残留对象。遇到诡异现象时,Ctrl+Q重启比反复F5更快。
2.3 项目文件组织建议
小游戏初期可以全写在一个game.py里,方便、启动快、找逻辑也容易。但一旦代码超过300行,我强烈建议拆文件。这是我个人比较舒服的结构:
game/ ├── main.py # 启动入口,创建app、加载场景 ├── settings.py # 常量配置:地图尺寸、数量、时间 ├── entities/ │ ├── __init__.py │ ├── treasure.py # 收集物类 │ └── obstacle.py # 障碍物类 └── scenes/ └── game_scene.py # 场景布置、UI、游戏流程Ursina里全局变量多,拆文件时候要注意一个事:每个模块都尽量让“类定义”与“实例创建”分离,不要把全局实体挂在模块顶层,否则热加载时容易重复创建对象。我在改造时就把原来挂在game.py顶层的score、timer_text收拢到了GameState类里,这里建议你也这么做。后面讲实操代码时,会体现这套组织方式。
3. 核心概念拆解:Entity、生命周期与碰撞
3.1 Entity是万物的根基
在Ursina中,场景里几乎一切都是Entity:方块、角色、摄像机、光源,甚至UI上的文字坐标也来自Entity体系。可以把Entity理解成一个“什么都能装”的容器,它身上挂着一堆属性,比如model决定长什么样,color决定颜色,position/rotation/scale决定它在三维空间里的位置、姿态和大小,collider决定它有没有物理边界。
wall = Entity( model='cube', color=color.gray, position=(0, 1, 0), scale=(4, 2, 0.5), collider='box', )以生活类比:Entity像一个货架上的商品包装盒,模型是里面的商品,位置是它在仓库里的货架坐标,缩放是盒子大小,collider则是包装盒的外壳边界——其他东西碰到这个外壳才算碰撞。这种设计最大的好处是:你可以完全不用关心底层渲染管线怎么工作,只要设置属性,引擎自动帮你完成所有坐标变换。
3.2 update、input、init:三个关键生命周期
Ursina的类回调非常有规律:
__init__:对象创建时调用,适合设置初始属性、生成子物体。update:每帧调用一次,适合写持续变化逻辑:旋转动画、移动、距离检测。input:收到键盘或鼠标事件时调用,适合写一次性操作,比如按下空格跳跃。
这里有个重要的性能认知:update每秒钟默认调用60次左右。只要在update里做一次数据库查询或者大规模遍历,帧率立刻掉给你看。我在初版代码里犯过一个错误——在update里反复调用random.uniform生成新敌人,结果每帧都在造对象,几秒之后场景就卡成PPT。后来改成定时生成器,用时间累积控制节奏,问题立刻解决。
input回调的写法也比较讲究。它收到一个字符串参数,比如'left arrow'或'a',你需要自己判断按的是什么:
def input(self, key): if key == 'space': self.jump() if key == 'escape': player.enabled = False3.3 碰撞检测的三件套
Ursina的碰撞检测依赖collider属性,常用三种:
box:立方体碰撞体,简单粗暴,计算快,适合方块类物体。sphere:球形碰撞体,适合球体或近似球形的物体。mesh:精确到模型网格的碰撞体,最精确但开销最大,不建议在动态物体上用。
我给地面、障碍物、收集物都用了box。注意一个问题:如果给旋转中的收集物加box碰撞体,碰撞边界会跟着物体一起旋转,这在物理上没问题,但如果你以固定方向去接近它,碰撞判定边界和视觉大小会有出入,所以我会把收集物的碰撞体做小一点,给玩家留一点容错空间。这个细节直接关系游戏手感,后面会详细讲。
另外Ursina里有个内置的距离工具:distance(a, b),它返回两个Vector3坐标的直线距离。做拾取判定时,与其严格依赖物理碰撞事件,不如直接算距离,效率高、逻辑直观。这次修改后的项目就是用的距离判定,后面代码里你会看到。
4. 实操:从零做一个“能量收集”3D小游戏
4.1 设计思路和规则设定
为了让游戏既简单又有可玩性,我定了三条规则:
第一,玩家用第一人称视角在场地里移动、跳跃。第二,场地里随机散落金色方块,玩家走过去碰到就能得分。第三,场地里也有红色障碍物,它们不扣分,但会挡住你的路线,逼你规划跑动路径。整局限时60秒,时间到算结束,集齐所有能量块则直接获胜。
为什么用第一人称而不是第三人称?因为FirstPersonController是Ursina自带的预制体,几行代码就能获得完整的“行走+跳跃+鼠标视角”能力,非常适合快速验证玩法。第三人称需要自己计算摄像机跟随、旋转偏移、遮挡处理,第一版做这些就容易劝退新手。
4.2 场景搭建与玩家控制
先创建应用,设置背景色,然后生成地面和玩家:
from ursina import * from ursina.prefabs.first_person_controller import FirstPersonController import random import math app = Ursina() window.color = color.rgb(0.5, 0.8, 0.9) player = FirstPersonController(speed=8, jump_height=2) ground = Entity( model='cube', scale=(40, 1, 40), position=(0, -1, 0), collider='box', color=color.rgb(0.35, 0.28, 0.18), )这里有几个参数需要解释一下。speed=8是玩家每秒移动的米数,Ursina里一个单位大约是一米,8的移速在40米见方的地图里跑起来节奏刚好;jump_height=2是跳跃高度,两个单位,能轻松越过普通障碍,又不至于跳得失控。地面尺度是40×1×40,因为中心在原点、高度1,所以地面顶部在y=0,玩家站在上面正好。这个坐标系理解很重要,很多新手做出来的地面是“陷进去”的,就是因为没搞清中心和偏移。
4.3 独立实体:收集物与障碍物
把功能封装成类,是这次修改的核心思路之一。每个收集物不仅是一个方块,它还有自己的动画和拾取检测逻辑:
class Treasure(Entity): def __init__(self, position=(0, 1, 0)): super().__init__( model='cube', color=color.gold, position=position, scale=0.6, collider='box', ) self.anim_speed = random.uniform(60, 120) def update(self): # 旋转动画 self.rotation_y += self.anim_speed * time.dt # 上下浮动 self.y = 1 + math.sin(time.time() * 2 + self.x) * 0.15 # 拾取判定 if distance(self.position, player.position) < 1.3: global score score += 1 score_text.text = f'Score: {score}' destroy(self)拾取半径1.3,相对于玩家碰撞体直径约1、收集物0.6来说,这是一个有“磁吸感”的判定范围。拾取判定放在收集物自身的update里,每次帧循环都会检查自身和玩家的距离,一旦进入范围就自毁并更新分数。把逻辑封装进类里,最大的好处是以后新增一种收集物,只要写一个类似的新类就行,互不干扰。
障碍物更简单:
class Obstacle(Entity): def __init__(self, position=(0, 0.5, 0)): super().__init__( model='cube', color=color.rgb(0.8, 0.2, 0.2), position=position, scale=1.4, collider='box', )注意障碍物的y坐标是0.5,但尺度1.4意味着它中心在0.5时上下各占0.7,底部在y=-0.2,嵌入了地面一点。这样视觉上不会“浮空”,碰撞时玩家也只能从侧面绕过去,符合真实物体感。
4.4 倒计时与胜负判定
UI部分用Text实现,位置参数用归一化坐标,(0, 0.47)大致是屏幕上方中间,(-0.82, 0.47)是左上角附近:
score = 0 score_text = Text( text=f'Score: {score}', position=(-0.82, 0.47), scale=2, ) timer_text = Text( text='Time: 60', position=(0.82, 0.47), origin=(1, 0), scale=2, )倒计时放在主update里:
TIME_LIMIT = 60.0 game_ended = False def update(): global TIME_LIMIT, game_ended if game_ended: return TIME_LIMIT -= time.dt timer_text.text = f'Time: {int(TIME_LIMIT)}' if TIME_LIMIT <= 0: game_ended = True这里用的是time.dt,也就是每一帧经过的真实时间(秒),用它做倒计时才能保证不同帧率下剩余时间一致。如果你错误地每帧固定减1,那60帧/秒的机器里一秒就没了。
收集完所有能量块时,在Treasure.update里把game_ended置为True,并显示胜利信息。到这里,这个游戏的核心逻辑就完整了。
4.5 完整可运行代码
把以上内容拼起来,就是一份可运行的小游戏代码:
from ursina import * from ursina.prefabs.first_person_controller import FirstPersonController import random import math TREASURE_COUNT = 15 OBSTACLE_COUNT = 10 MAP_RANGE = 8 TIME_LIMIT = 60.0 app = Ursina() window.color = color.rgb(0.5, 0.8, 0.9) player = FirstPersonController(speed=8, jump_height=2) ground = Entity( model='cube', scale=(40, 1, 40), position=(0, -1, 0), collider='box', color=color.rgb(0.35, 0.28, 0.18), ) score = 0 score_text = Text(text='Score: 0', position=(-0.82, 0.47), scale=2) timer_text = Text(text='Time: 60', position=(0.82, 0.47), origin=(1, 0), scale=2) class Treasure(Entity): def __init__(self, position=(0, 1, 0)): super().__init__( model='cube', color=color.gold, position=position, scale=0.6, collider='box', ) self.anim_speed = random.uniform(60, 120) def update(self): self.rotation_y += self.anim_speed * time.dt self.y = 1 + math.sin(time.time() * 2 + self.x) * 0.15 if distance(self.position, player.position) < 1.3: global score, game_ended if game_ended: return score += 1 score_text.text = f'Score: {score}' destroy(self) if score >= TREASURE_COUNT: game_ended = True class Obstacle(Entity): def __init__(self, position=(0, 0.5, 0)): super().__init__( model='cube', color=color.rgb(0.8, 0.2, 0.2), position=position, scale=1.4, collider='box', ) for _ in range(TREASURE_COUNT): Treasure(position=(random.uniform(-MAP_RANGE, MAP_RANGE), 1, random.uniform(-MAP_RANGE, MAP_RANGE))) for _ in range(OBSTACLE_COUNT): Obstacle(position=(random.uniform(-MAP_RANGE, MAP_RANGE), 0.5, random.uniform(-MAP_RANGE, MAP_RANGE))) game_ended = False def update(): global TIME_LIMIT, game_ended if game_ended: return TIME_LIMIT -= time.dt timer_text.text = f'Time: {int(TIME_LIMIT)}' if TIME_LIMIT <= 0: game_ended = True app.run()复制运行时如果感觉金色方块获得太容易或障碍太碍事,调MAP_RANGE和TREASURE_COUNT就行。这里game_ended在Treasure.update中被引用,所以我在类内部用global声明,并且把game_ended的初始化放在类定义之后、实例创建之前——运行时顺序没有问题,但把它移到类定义之前会更严谨,这点你可以按自己习惯调整。
4.6 中文字体:一个小坑和一个解法
上面代码里UI用的是英文,原因是Ursina默认字体不包含中文字形,直接写“得分”会显示成方块或乱码。想显示中文,需要指定一个中文字体文件。Windows上最省事的就是直接引用系统字体:
score_text = Text( text='得分:0', position=(-0.82, 0.47), scale=2, font='C:/Windows/Fonts/msyh.ttc', )macOS可以用/System/Library/Fonts/PingFang.ttc。Linux根据发行版字体目录改路径。如果你用的是在线字体编辑环境或打包机,记得把字体文件拷贝到项目assets/fonts目录下,再通过相对路径引用,避免打包后路径失效。
5. 踩坑实录与性能优化
5.1 新手最容易踩的六个坑
这个游戏改完,我顺手整理了一份问题速查表,基本覆盖是Ursina新手高频问题:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 玩家走出地面掉下去 | 地面位置和碰撞体中心偏移,或玩家初始y低于地面顶面 | 让地面顶部对齐y=0,玩家初始y设为0.5以上 |
| 碰撞没反应 | 物体没加collider,或加的是mesh但模型没有细分网格 | 给所有参与碰撞的物体显式指定box或sphere |
| 中文文字乱码 | 默认字体不支持CJK字形 | 通过font参数指定中文字体路径 |
| F5热加载后状态混乱 | 全局变量被重置,而实例对象还保留旧引用 | 把状态收进类或场景对象,初始化逻辑写成统一方法 |
| 画面模糊或窗口异常 | 高DPI缩放和Panda3D窗口适配冲突 | 设置window.fullscreen或将系统缩放恢复100%,也可以window.size显式指定 |
| 物体如影随形地围着自己转 | 用错鼠标事件,每帧都在旋转摄像机 | 检查input回调,别把移动和旋转逻辑混在update里 |
5.2 帧率优化:让游戏跑得稳
Ursina写原型容易,但优化还是得掌握几个基本原则:
第一,合并静态物体。40米见方的地图,如果每一堵墙都用独立Entity,场景里就会多出几十上百个绘制调用。正确做法是把多个不动的方块用Entity(model='cube', ...)逐个生成也行,性能影响有限,但更推荐用Entity组合成“块状结构”,或者提前在3D建模软件里做好一个整体模型导入,大幅降低批次。
第二,慎用collider='mesh'。这东西精确但开销大,实测在一个带纹理的浮雕地面上开启mesh碰撞,帧率直接掉到30以下。如果你只需要平面阻挡,一个box碰撞体就够。
第三,控制动态对象数量。Treasure如果生成200个,每个每帧都要算旋转、浮动的三角函数、距离检测,性能必然吃紧。我的建议是:动态实体超过100个时,考虑把它们挂到一个Entity父节点下,并在父节点上做粗粒度距离剔除——离玩家太远的直接不更新。
第四,window.color尽量用浅色或天空盒,不要开高分辨率阴影。Ursina默认没有实时阴影,这对性能是好事。想加影子效果,建议用假阴影:生成一个半透明黑色圆形贴图实体,固定在角色下方,视觉效果够用且开销极小。
5.3 打包成exe:最后一步
游戏写完,总想发给朋友双击就能玩。PyInstaller是目前比较成熟的打包方案。基本命令是这样的:
pip install pyinstaller pyinstaller -w --onefile --name EnergyRun game.py-w代表打包后不显示黑色控制台窗口,--onefile是把资源全部塞进单个可执行文件。Ursina运行时会从项目目录加载assets等资源,如果打包后提示找不到贴图,通常是资源路径问题,可以用--add-data参数把资源目录一起打进去:
pyinstaller -w --onefile --add-data "assets;assets" game.py注意Windows上--add-data的路径分隔符是分号,Linux和macOS是冒号。我几次打包踩坑都卡在这个分隔符上。另外,打包含Panda3D的程序体积通常会上百MB,这是引擎本身的体量,别担心,正常现象。打包完成后先在自己机器上跑一遍,如果报错,多数是隐藏依赖没打进去,用--hidden-import补上。
6. 还能往哪改:进阶方向与个人体会
6.1 五个低成本高收益的扩展方向
游戏能跑只是起点,想继续玩下去,下面几个方向都值得试。
加音效。Ursina里一句Audio('collect.wav')就能播放音效,给拾取、跳跃、倒计时报警各配一个声音文件,游戏的“质感”立刻提升一个档次。声音素材可以去免费音效站找,注意格式用wav或ogg,兼容性最好。
换模型。金色方块改成金币模型,障碍物换成尖刺或石柱,第一步用blender简单建模,或者从免费模型站下载,导出为gltf或obj,通过Entity(model='coin.gltf')加载。模型导入需要注意单位缩放,Ursina的默认比例通常是米,从blender导出时把尺寸对齐一下。
加关卡。用二维数组定义地图,0代表空地、1代表墙体、2代表宝物,加载时循环生成实体。这样你可以手写多个关卡,每关通过后切换数组重新生成场景。我改造过程中把随机摆放改成数组驱动后,测试效率提升明显,出了问题直接看地图数据就行,不用反复进游戏跑图。
加敌人。最简单的巡逻敌人,逻辑就是“在几个点之间来回移动,当玩家进入一定范围时追击”。写一个Enemy(Entity)类,里面维护一个状态枚举:巡逻、追击、返回。这个改动会迫使你去思考状态机,对代码能力提升很大。
换摄像机视角。把FirstPersonController换成第三人称,需要自己写跟随摄像机的逻辑:每帧把摄像机放在角色后方固定距离,朝向角色。第一次写出来时会很有成就感,Ursina的计算向量和插值函数lerp会让这个工作比你想象中简单。
6.2 这轮改完,我自己的几点体会
改完这一版,我对Ursina的能力有了更清晰的判断。它的上限不是引擎本身,而是你对游戏循环的理解有多少。Entity封装、生命周期回调、碰撞判定,这三个东西想明白了,剩下的事大多是加规则、加内容。
有一个小技巧是我改完最后一个版本才想到的:把Treasure.update里的拾取判定,从“检测所有宝物和玩家的距离”改成“只检测离玩家最近的几个宝物”,用空间网格或简单排序,在大场景里能省下大量计算。对当前规模的小游戏没有必要,但未来想扩展成成百上千物体时,这个优化思路会派上大用场。
这个项目最让我意外的收获是:用Ursina之后,我写游戏逻辑的方式反而更接近写业务代码了——类、状态、事件驱动,和写Web后端是同一套思维。如果你身边刚好有朋友抱怨“3D游戏门槛太高”,把这篇和代码发给TA,让TA先跑出一个能跳能捡的金色方块,剩下的,自然会有人追着问。