
很多刚接触编程的朋友第一次看到屏幕上那个由方块构成、可以自由破坏和搭建的3D世界时第一反应都是“这玩意儿到底是怎么跑起来的”。尤其是当听说用Python也能做出类似“我的世界”那种第一人称视角的3D方块世界时好奇心直接拉满。我当初也是这么入坑的手里只有一台普通笔记本没学过图形学C和OpenGL的教程翻两页就犯困于是就想找一条更温和的路——用Python把“我的世界”最核心的那套东西复现出来。这篇文章要聊的就是围绕“Python我的世界代码3D”这个主题把一个能跑、能走、能挖方块、能放方块的3D体素世界从零搭起来。它解决的核心问题是如何在不深入底层图形API的前提下理解并实现3D体素渲染、第一人称相机、方块交互和地形生成这几块硬骨头。适合有一定Python基础、对3D图形好奇、想动手做个能玩的小项目来打通“代码到画面”这条链路的读者。哪怕你之前只写过爬虫和脚本跟着思路走也能把骨架立起来。1. 整体设计思路与方案选型拆解1.1 为什么选Python而不是直接上C先把一个现实问题摆出来真正商业级的体素游戏几乎清一色是C或C#打底因为要压榨每一帧的渲染时间。那为什么还要用Python做这件事我的判断是目标不同选型就不同。如果你的目标是“做一个能上架的游戏”Python确实不是最优解但如果你的目标是“搞明白3D体素世界是怎么运转的”Python的性价比高得离谱。原因有三点。第一Python的语法噪音小你可以把注意力集中在“相机怎么转”“方块怎么被选中”这些逻辑上而不是被指针和内存管理分散精力。第二Python有一批封装得相当成熟的图形库能把OpenGL那套繁琐的初始化流程压缩成几行调用让你快速看到画面。第三迭代速度快改一个参数、存盘、重跑几秒钟就能看到效果这种即时反馈对学习曲线非常友好。我试过用纯OpenGL的C教程起步光是配环境、写窗口初始化就耗掉一整天画面还没出来人就先泄气了。换成Python之后同样的窗口和三角形半小时内就能跑起来。这个心理上的正反馈对能不能坚持做完一个3D项目至关重要。1.2 图形库的取舍pyglet、Panda3D还是UrsinaPython做3D绕不开几个候选库。我把当时对比的几个方案列成表方便你直接抄作业库名上手难度3D能力适合场景我的评价pyglet中等偏底层需自己写着色器想理解渲染管线学习价值高但代码量大Panda3D中等偏高完整游戏引擎做完整游戏功能全但偏重体素项目有点杀鸡用牛刀Ursina低封装pyglet开箱即用快速原型、教学最适合本项目起步moderngl中等现代OpenGL封装自定义渲染灵活但需要图形学基础最后我选的是Ursina。它的定位很明确把pyglet的底层细节包起来给你一套“实体Entity”式的API创建一个方块就是Entity(modelcube)设置纹理就是传个参数。对于体素世界这种“大量重复方块”的场景Ursina的combine机制还能把成千上万个方块合并成少数几个网格直接绕过了逐方块绘制的性能瓶颈。这一点后面会详细讲它是整个项目能不能跑得动的关键。需要说明的是选Ursina不代表pyglet没用。Ursina本身就是构建在pyglet之上的理解pyglet的窗口和事件循环能帮你在出问题时知道该往哪层去查。所以我的建议是用Ursina快速出效果同时心里清楚它底下压着一层pyglet。1.3 体素世界的核心数据模型“我的世界”这类游戏本质是一个三维网格每个格子存一个方块类型。最直觉的存法是三维数组world[x][y][z]但这里有个坑如果世界是100×100×100那就是一百万个格子Python的嵌套列表在这种规模下内存和访问速度都会很难看。我实际采用的是字典加元组键的方案world[(x, y, z)] block_type。好处是只存“有方块”的位置空气不占空间键用元组哈希查找是O(1)取某个坐标的方块类型非常快。代价是遍历所有方块时要遍历字典的键但这个操作只在初始化建网格时做一次可以接受。# 世界数据核心结构 world {} # 键: (x, y, z) 元组, 值: 方块类型字符串 def set_block(pos, block_type): if block_type air: world.pop(pos, None) # 空气等于删除省内存 else: world[pos] block_type def get_block(pos): return world.get(pos, air)这个设计背后有个重要考量体素世界绝大多数空间是空的空气用稀疏存储能省下巨量内存。一个100×100×100的世界如果全用数组光指针就占不少用字典只存地表附近的方块实际条目可能只有几万个差距是数量级的。1.4 渲染性能的关键网格合并这是整个项目里最容易被新手忽略、却最致命的一环。如果你给每个方块都创建一个独立的Entity一个稍微像样的地形就有几万个方块每帧几万次绘制调用帧率会直接掉到个位数风扇狂转。Ursina提供了combine参数可以把一批共享同一纹理的方块合并成一个网格一次绘制调用画完。原理是把所有方块的顶点数据拼到一起提交给GPU一次性处理。我实测下来同样一个两万方块的地形逐方块创建时帧率大概5到8帧合并之后稳定在60帧上限差距非常夸张。from ursina import * # 错误示范逐方块创建方块多了必卡 # for pos in world: # Entity(modelcube, positionpos, texturewhite_cube) # 正确做法按方块类型分组合并网格 def build_mesh(block_type, positions): Entity( modelcube, textureblock_type, position(0, 0, 0), combineTrue, # 关键合并为一个网格 colliderNone # 合并后碰撞体单独处理 )这里有个细节要注意合并之后单个方块就不能再单独移动或删除了因为它们在GPU眼里已经是一个整体。所以“挖方块”这个操作不能直接改网格得走“删除数据 重建网格”的路线。重建有开销但只在玩家操作时触发不是每帧都做完全可以接受。2. 核心细节解析与实操要点2.1 第一人称相机让视角跟着鼠标走3D世界能不能“玩”第一人称相机是分水岭。Ursina自带FirstPersonController挂上就能用WASD移动、鼠标转视角但直接用会有几个问题默认重力参数偏大、鼠标灵敏度不一定合手、而且它默认会跟场景里的碰撞体互动体素世界的碰撞体如果没配好人会直接穿地或者卡住。我的做法是继承它再改参数而不是从零写相机。从零写要处理欧拉角、万向锁、鼠标锁定这些细节对新手不友好继承则保留了成熟的移动逻辑只调自己关心的部分。from ursina import * from ursina.prefabs.first_person_controller import FirstPersonController class VoxelPlayer(FirstPersonController): def __init__(self, **kwargs): super().__init__(**kwargs) self.gravity 0.8 # 默认值偏大调小更跟手 self.jump_height 1.2 # 刚好能跳上一格方块 self.speed 6 # 走路速度太快容易晕 self.mouse_sensitivity Vec2(40, 40) # 视角转动灵敏度jump_height设成1.2是有讲究的方块边长是1跳跃高度必须略大于1才能跳上一格台阶但又不能太大否则像在月球上蹦。gravity和jump_height是联动的重力太大跳不起来太小又飘。这两个值我调了七八次才找到手感建议你也在自己机器上微调。注意mouse_sensitivity的数值方向和直觉相反数值越大转得越慢Ursina内部是除以这个值。第一次调的时候我以为是反的结果越调越晕后来翻源码才明白。2.2 方块交互射线检测选中目标方块“我的世界”最爽的操作就是对着方块点一下挖掉、右键放一个。实现的关键是射线检测从相机位置沿视线方向发一条射线看它第一个撞到哪个方块。Ursina的raycast函数能直接干这事但它需要一个碰撞体才能命中。这里就遇到前面埋的伏笔合并网格后没有逐方块碰撞体了。我的解决方案是给每个方块位置单独挂一个不可见的碰撞体或者更省事的做法——用数学方法自己算射线与体素网格的交点。我实际用的是后者因为体素世界的网格是规整的射线和轴对齐网格求交有成熟算法类似DDA的体素遍历。但为了降低理解门槛这里给一个基于Ursinaraycast的简化版配合逐方块碰撞体方块数量控制在几千以内完全够用def get_target_block(max_distance8): hit raycast( camera.world_position, camera.forward, distancemax_distance, ignore[player] # 别打到自己 ) if hit.hit: # 命中点沿法线回退一点得到被点方块的坐标 block_pos hit.world_point - hit.normal * 0.5 return tuple(round(v) for v in block_pos), hit.normal return None, Nonemax_distance设成8是因为太远挖方块没有游戏感而且射线太长容易误判。hit.normal是命中面的法线挖方块时用它定位被点的方块放方块时用它决定新方块放在哪一面——这个法线的用法是很多人第一次做会卡住的地方记住“法线指向方块外部”就好理解了。2.3 地形生成噪声函数造出起伏地表一个平坦的世界很无聊得有山有谷。程序化地形生成的标准做法是噪声函数最常用的是Perlin噪声或Simplex噪声。Python里noise库提供了现成实现几行就能生成连续起伏的高度图。from noise import pnoise2 def generate_terrain(size32, scale0.1, height8): for x in range(-size, size): for z in range(-size, size): # pnoise2返回-1到1映射到0到height n pnoise2(x * scale, z * scale) h int((n 1) / 2 * height) for y in range(h): set_block((x, y, z), grass if y h - 1 else dirt)scale这个参数控制地形的“粗糙程度”值越小相邻采样点差异越小地形越平缓值越大起伏越剧烈。我一开始用0.5结果地形像锯齿一样全是尖峰后来降到0.1才顺眼。这个参数没有标准答案跟你的世界尺寸和想要的风格有关多试几组值找感觉。另外pnoise2是二维噪声只决定高度所以生成的是“高度图”式地形——每个x,z列只有一个高度下面是实心方块。这比三维噪声简单得多性能也好对入门项目足够。想要洞穴、悬空岛就得上三维噪声那是进阶话题。2.4 纹理与材质让方块有“我的世界”味纯色方块看着像积木加上像素纹理才有那个味道。Ursina支持直接给Entity指定纹理图片但要注意两点一是纹理最好是正方形、像素风16×16或32×32放大后才有颗粒感二是不同方块类型要对应不同纹理草地、泥土、石头各一张。我踩过的坑是纹理路径问题。Ursina默认在项目目录下找textures文件夹图片放错位置会静默失败——不报错但方块是白的。排查了半天才发现是路径不对。稳妥的做法是用绝对路径或者确认工作目录# 确认纹理能加载 texture_path textures/grass.png Entity(modelcube, texturetexture_path)如果暂时没有素材用color参数给不同方块上不同颜色也能区分先跑通逻辑再换纹理别在素材上卡住进度。3. 实操过程与核心环节实现3.1 环境搭建与依赖安装先把地基打好。Python版本建议3.8以上太老的版本某些库装不上。依赖就三个核心ursina负责渲染和交互noise负责地形Pillow用来处理纹理Ursina间接依赖它。pip install ursina noise pillow装完先跑一个最小验证确认图形环境正常from ursina import * app Ursina() Entity(modelcube, colorcolor.orange) app.run()能看到一个橙色方块就说明环境通了。这一步别跳过我见过不少人直接上完整代码结果报错分不清是环境问题还是逻辑问题白白浪费时间。3.2 世界初始化与网格构建接下来把世界数据、地形生成、网格合并串起来。核心流程是先生成地形数据存进字典再按方块类型分组最后每组合并成一个网格。from ursina import * from noise import pnoise2 app Ursina() world {} def set_block(pos, block_type): if block_type air: world.pop(pos, None) else: world[pos] block_type def generate_terrain(size24, scale0.12, height6): for x in range(-size, size): for z in range(-size, size): n pnoise2(x * scale, z * scale) h int((n 1) / 2 * height) for y in range(h): set_block((x, y, z), grass if y h - 1 else dirt) def rebuild_mesh(): # 清掉旧网格 for e in scene.entities: if getattr(e, is_voxel_mesh, False): destroy(e) # 按类型分组 groups {} for pos, btype in world.items(): groups.setdefault(btype, []).append(pos) # 每组合并 for btype, positions in groups.items(): e Entity( modelcube, texturebtype, combineTrue, colliderNone ) e.is_voxel_mesh True # 把合并网格的顶点挪到对应位置 for p in positions: Entity(modelcube, positionp, parente, visibleFalse)这里有个Ursina的用法细节combineTrue的Entity本身是个空壳需要把子Entity挂上去合并发生在渲染时。子Entity设visibleFalse是为了不重复绘制只贡献顶点数据。这个写法我第一次见也觉得绕但实测是有效的而且比手动拼顶点数组省事太多。3.3 玩家控制与方块交互闭环把玩家、射线检测、挖放方块接起来整个交互就闭环了。挖方块是删数据加重建网格放方块是加数据加重建网格。from ursina.prefabs.first_person_controller import FirstPersonController player FirstPersonController(position(0, 10, 0)) player.gravity 0.8 player.jump_height 1.2 def get_target(max_distance8): hit raycast(camera.world_position, camera.forward, distancemax_distance, ignore[player]) if hit.hit: pos hit.world_point - hit.normal * 0.5 return tuple(round(v) for v in pos), hit.normal return None, None def input(key): if key left mouse down: pos, _ get_target() if pos: set_block(pos, air) rebuild_mesh() if key right mouse down: pos, normal get_target() if pos and normal: new_pos tuple(int(pos[i] normal[i]) for i in range(3)) if new_pos not in world: set_block(new_pos, grass) rebuild_mesh()放方块时判断new_pos not in world是为了防止在已有方块的位置重复放虽然字典会覆盖但重建网格时会出现重叠顶点视觉上可能有闪烁。这个判断很便宜加上更稳。3.4 参数调优与手感打磨代码跑起来只是开始手感才是决定“能不能玩下去”的关键。我整理了一组自己反复调过的参数你可以作为起点参数我的取值作用调整方向gravity0.8下落加速度太大跳不起来太小飘jump_height1.2跳跃高度必须略大于1才能上台阶speed6移动速度太快晕3D太慢憋屈mouse_sensitivity40视角灵敏度数值越大转越慢max_distance8交互距离太远没手感太近够不着terrain scale0.12地形起伏越大越崎岖这些值没有绝对标准跟你的屏幕分辨率、个人习惯都有关。我的建议是先把gravity和jump_height调到手感自然再调speed最后微调鼠标灵敏度。一次只改一个参数改完跑一下感受差异别一次全改否则出了问题不知道是哪个引起的。4. 常见问题与排查技巧实录4.1 帧率暴跌先查网格是否合并新手最常见的抱怨就是“方块一多就卡成PPT”。九成以上的原因是没做网格合并每个方块一个Entity。排查方法很简单看场景里Entity的数量如果跟方块数一个量级那就是没合并。解决办法就是前面讲的combineTrue分组。如果合并后还卡检查是不是每次交互都全量重建网格——可以优化成只重建受影响的那一组但入门阶段全量重建通常也能接受因为交互不是每帧发生。4.2 方块点不中或点错位置射线检测点不中通常是碰撞体没配好。合并网格后colliderNone射线自然打不到。要么给每个方块单独加碰撞体方块少时可行要么改用数学求交。点错位置则多半是hit.normal的用法搞反了记住挖方块用命中点回退放方块用命中点加法线。4.3 玩家穿地或卡在方块里这是碰撞体和出生点的问题。出生点如果设在方块内部玩家会被挤出去或者卡住。我的做法是出生点设在最高地形上方几个单位让玩家自然落下。另外FirstPersonController的碰撞依赖方块的collider如果地形网格没碰撞体人会直接穿下去。给地形网格加一个整体的MeshCollider能解决大部分情况。4.4 纹理不显示或显示为白色前面提过多半是路径问题。Ursina找不到图片时不报错直接给白色。排查步骤确认图片存在、确认路径相对工作目录正确、确认图片格式是PNG或JPG。实在不行先用color参数顶着把逻辑跑通再回头处理素材。4.5 常见问题速查表现象最可能原因快速解决帧率极低未合并网格用combine分组点不中方块无碰撞体加collider或改数学求交点错方块位置法线用法反了挖用减、放用加玩家穿地地形无碰撞加MeshCollider方块全白纹理路径错检查路径或先用颜色视角太晕灵敏度或速度过高调低speed、调大灵敏度值4.6 几个少走弯路的实操心得第一别一上来就追求大世界。我最初直接生成64×64的地形结果调试时每次重建网格都要等好几秒改代码的节奏全被打乱。后来缩到24×24迭代飞快逻辑验证完再放大效率高得多。第二重建网格时记得销毁旧Entity。我漏过这一步结果每挖一个方块就多一层网格叠在上面内存一路涨画面还开始闪烁。destroy(e)这行不能省。第三把参数集中放在文件顶部。调手感时你会反复改那几个数散落在代码各处找起来很烦。集中定义成常量改起来一目了然。第四先跑通再优化。体素世界有很多可优化的点面剔除、区块加载、贪心网格但入门阶段把“能走能挖能放”跑通比提前优化重要得多。优化是有了性能问题之后的事不是一开始的事。这个项目后续还能往很多方向扩展加方块类型选择栏、做存档读写、引入区块分块加载、甚至加个简单的光照。但骨架就是上面这些——数据模型、网格合并、第一人称相机、射线交互、噪声地形。把这五块吃透剩下的都是在这个骨架上长肉。我自己做完这一版之后再看那些商业体素游戏的架构文档很多设计决策突然就看得懂了这种“打通”的感觉比单纯跑通一个demo值钱得多。