简介受《我的世界》启发的 Python/Pyglet 小型 3D 世界演示定位为编程入门与教育工具。作者有意把核心代码写得便于阅读和二次配置希望让喜欢 Minecraft 的孩子通过修改简单参数快速看到结果从而对 Python 产生兴趣也适合想了解体素世界与基础图形渲染的初学者动手研究。压缩包共 4 个文件仅 16KB包含主程序脚本、纹理贴图、README 说明和许可证主脚本实现了可交互的方块世界PNG 贴图提供纹理README 则给出运行方式几乎没有上手门槛。目前已有 511 人学习下载。虽然是极简版本但保留了放置与破坏方块的标志性交互并有清晰注释读者可以借此理解事件循环、纹理映射、基本输入处理等概念再按自己的创意扩展地形或玩法也可以把项目当作向库化 API 演进的教学样例。1. 用 Python 和 Pyglet 还原“我的世界”先能看见世界才算入门游戏引擎“Minecraft使用Python和Pyglet的受Minecraft启发的简单程序”这个标题散落在不少英文教程、开源演示和标榜“免费 Python 源码大全”的资源包里你搜 Minecraft python 相关学习资料时大概率见过它。它讲的其实是一个体素世界的最小实现用 Pyglet 打开一个 OpenGL 窗口用字典存方块用手工拼出的纹理图集渲染再配一个第一人称相机。它能跑起来、能走路、能挖放但又不追求完整游戏性。恰恰是这份“不完整”让它有价值一个中等水平的 Python 学习者花一个周末就能读完全部代码并借此把渲染管线的轮廓嵌进脑子里。这篇笔记适合想验证 3D 原理、想做交互原型以及准备转游戏相关方向的从业者我会按选型、渲染、交互、排错的顺序把整个方向拆开讲。2. 为什么选 Pyglet 而不是 Pygame体素渲染的选型逻辑与最小工程2.1 Pyglet 的真正卖点OpenGL 上下文和面向 3D 的事件模型要复刻 Minecraft 的视觉效果最核心的是把三维坐标里的四边形画出来而不是贴 2D 精灵。Pyglet 在 1.5.x 时代就提供了完整的 OpenGL 接口默认窗口上下文自带 GL 函数你可以在 on_draw 里直接调用 glBegin/glEnd把方块画成六面体它还内建了事件循环、键盘鼠标状态、图片加载与文本渲染省掉自己封装窗口的工夫。不少人在 python 教程里看到它被拿来写 2D 小游戏就以为 Pyglet 只能做 2D。其实它底层直接绑定 OpenGL而 OpenGL 是 3D 的。真正让它显得“2D”的是教程作者选择了平面坐标而已。你在 Pyglet 里画 3D 只需要三样东西透视投影矩阵、模型视图矩阵、以及带纹理坐标的四边形顶点。这三样都是几次 GL 函数调用的事。如果你是想理解体素原理而不是啃完整游戏引擎Pyglet 是最低门槛的入口。这里有一个必须提前说明的版本问题如果你去看 2019 年前后的资料它们几乎都以 Pyglet 1.5.x 的固定管线写法为主。Pyglet 2.x 已经把默认渲染切换到现代 OpenGL 上下文旧的 glBegin/glEnd 立即模式会变得不可用。所以下文所有代码都以 1.5.x 为前提安装时我会明确锁定版本。2.2 Pyglet 与 Pygame 的边界2D 框架和 3D 需求的差异做技术选型不能只看“哪个库火”要看数据流。体素引擎每帧要做的事情是遍历世界数据、算可见面、把顶点和纹理坐标交给 GPU。Pygame 在这条链路上没有原生支持Pyglet 从窗口到纹理到顶点缓冲是一条龙。下表把边界画清楚对比点PygamePyglet 1.5.x3D 能力需要自己加载 OpenGL 或用软件渲染做体素很别扭原生接入 OpenGL固定管线和现代管线都能用图片转纹理Surface 转 GL 纹理步骤繁琐还要处理像素格式pyglet.image 直接 get_texture()事件循环需要自己管理 pygame.event 队列装饰器风格on_draw / on_mouse_press 清晰绘制批次靠 sprite 组顶点级控制弱有 Batch 和 VertexList适合批量体素文本和窗口可用但偏 2D内建字体渲染开箱即用我的结论是2D 游戏、教学演示、UI 原型选 Pygame目标是往 3D 体素方向走哪怕只是“简单程序”Pyglet 也明显更顺。你会在写的过程中发现Pygame 的情况下你一直在跟 GL texture 搏斗而这些恰恰是 Pyglet 帮你做掉的。2.3 落地最小工程python 环境配置、锁定 pyglet 1.5.x、目录结构安装是许多人卡住的第一步。如果你按 python 安装教程装完建议用 python 官网下载 3.10 或 3.11因为 pyglet 1.5.27 对这两个版本兼容最好。Windows 安装时记得勾选“Add Python to PATH”否则后面 python 环境变量配置不全会调用错解释器。装好后在命令行做两件事python --version python -m pip install pyglet1.5.27这里用python -m pip而不是裸pip是为了避免多版本共存时 pip 指向了别的 Python。Ubuntu 下同理只是可能要改成python3 -m pip。装完验证一下python -c import pyglet; print(pyglet.version)能看到 1.5.27 就算过关。这个最小工程不依赖 numpy也不需要 Pillow只有到了第 6 章做噪声地形时numpy 安装库的方法才会派上用场。目录我习惯这样拆simple-mc/ ├── main.py # 窗口、事件、循环 ├── world.py # 方块数据与网格生成 ├── texture.py # 纹理图集加载 ├── camera.py # 相机与移动 └── assets/ └── atlas.png # 3x3 的方块纹理图集我见过很多人把全部代码塞进一个 main.py跑通再拆模块这没问题尤其你只是想复现一次。但一旦开始加世界生成算法世界数据和渲染分离会省很多事。下面几章按这个目录逐步展开。3. 把方块放到屏幕上纹理图集、表面剔除与第一人称相机3.1 纹理图集把 3×3 张 16px 小图拼成一张一次绑定每个方块有顶部、侧面、底面三种纹理不同方块又要不同皮肤。如果每张都单独加载并绑定画几个方块就要切换好几次纹理性能立刻崩。常见做法是所有纹理按固定格子拼到一张 atlas 图片里渲染时按格子坐标取样。我一般做一张 3×3 的 48×48 图集第一行放草、泥土、石头第二行放木头侧面、木头年轮、沙子第三行留给玻璃或树叶。from pyglet.gl import * import pyglet def init_texture(tex_fileassets/atlas.png): atlas pyglet.image.load(tex_file) texture atlas.get_texture() glBindTexture(GL_TEXTURE_2D, texture.id) # 体素要的是像素感必须选 NEAREST不选 LINEAR glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST) return texture.idGL_NEAREST的意思是采样时不插值拿到的像素就是像素纹理才会清晰锐利如果用GL_LINEAR方块棱角会变模糊整个风格像被磨过皮。更讲究的做法还要设置GL_CLAMP_TO_EDGE防止边缘采样越界这个坑放到第 5 章展开。有了图集之后每个方块面需要用一组(u, v)纹理坐标。假设每个小格子占图集的 1/3那么“草顶”的 UV 范围就是(0, 2/3)到(1/3, 1)我这里按图集从左下角原点算v 轴向上。3.2 世界数据用 dict 而不是数组方块存储的取舍体素世界最简单有效的存储是 Python 字典键是(x, y, z)元组值是方块类型 int。为什么不用三维 list因为 list 必须先分配固定大小的空间而我们需要一个理论上无限延伸的世界每新增一块地都做预留很麻烦。字典的另外两个优势查找是 O(1)删掉键就等于挖掉方块。WORLD {} # {(x, y, z): block_type} def init_world(radius8): for x in range(-radius, radius): for z in range(-radius, radius): WORLD[(x, 0, z)] 0 # 0 草方块 WORLD[(x, -1, z)] 1 # 1 泥土 WORLD[(x, -2, z)] 2 # 2 石头方块类型用 int 而不是字符串是为了后续做噪声、存档和批量处理时能走数组计算。dict 的缺点是每帧全量遍历会有开销几十万方块时会卡所以在“简单程序”阶段我们默认世界只有几百个方块就够。等到第 6 章我会讲怎么把它换成 chunk 列表加索引。如果想让地面起伏一点可以加一层高度表先随机生成每个柱子的高度 h然后从 y0 到 yh 填方块。这样看起来比平地更像 Minecraft 的“山丘”import random def init_hilly_world(radius8): for x in range(-radius, radius): for z in range(-radius, radius): h random.randint(1, 5) for y in range(h): block 2 if y 0 else 1 WORLD[(x, y, z)] block这段代码说明一个事实世界生成和渲染是解耦的。你可以随时加生成规则渲染部分不用改。这也是把 world.py 单独拆出来的原因。3.3 表面剔除只画看得见的面让渲染量直接降掉一半体素方块有 6 个面但两个方块紧挨时相邻的 4 个面永远看不见。聪明的做法是画某块之前检查它上下左右前后的邻居是否存在于 WORLD存在就不画那几面def should_draw_face(x, y, z, dx, dy, dz): return (x dx, y dy, z dz) not in WORLD对每个方块只有暴露在空气里的面才进入绘制逻辑。平坦地面上这个剔除能把 6 面降到 3 面左右整体渲染量少一半。下面是一个方块的完整绘制函数按六个面依次处理每面上的四个顶点按逆时针排好方便 OpenGL 背面剔除FACES [ (0, 1, 0), (0, -1, 0), (1, 0, 0), (-1, 0, 0), (0, 0, 1), (0, 0, -1), ] def draw_block_quads(x, y, z, uv): # uv 是一个 dict每个墙面给一个 (u0, v0, u1, v1) for i, (dx, dy, dz) in enumerate(FACES): if not should_draw_face(x, y, z, dx, dy, dz): continue # 这里拿到该面的四个顶点坐标按 (xdx, ydy, zdz) 为基准生成 # 再叠加上与面垂直的两个轴向组成一个 1x1 的四边形 glBegin(GL_QUADS) glTexCoord2f(uv[i][0][0], uv[i][0][1]); glVertex3f(...) glTexCoord2f(uv[i][1][0], uv[i][0][1]); glVertex3f(...) glTexCoord2f(uv[i][1][0], uv[i][1][1]); glVertex3f(...) glTexCoord2f(uv[i][0][0], uv[i][1][1]); glVertex3f(...) glEnd()代码里我把真正的四组顶点坐标省略为...因为那一大堆 xdx 的组合写开会占一整屏你需要做的是画一张坐标草稿纸把每个面两个相邻轴的偏移量推出来。上手时很容易把两个面画反我的检查方法在草稿纸上画一个立方体先把一个面写全然后旋转坐标轴套到其它面。这个坑我也在第 5 章记录了。3.4 第一人称相机偏航/俯仰角构造视图矩阵相机用三个要素位置 pos、水平旋转 yaw、垂直旋转 pitch。鼠标左右移动改 yaw上下移动改 pitch。在 Pyglet 1.5 中启用了鼠标独占后事件回调给的是相对位移 dx、dyclass Camera: def __init__(self, pos(0, 2, 0)): self.pos list(pos) self.yaw 0.0 self.pitch 0.0 self.speed 5.0 window.event def on_mouse_motion(x, y, dx, dy): camera.yaw dx * 0.1 camera.pitch dy * 0.1 camera.pitch max(-1.5, min(1.5, camera.pitch))每帧构造视图矩阵时用 gluLookAt 把相机放在 pos视线方向用 yaw 和 pitch 算出来import math def set_view(camera): glMatrixMode(GL_MODELVIEW) glLoadIdentity() gluLookAt(camera.pos[0], camera.pos[1], camera.pos[2], camera.pos[0] math.cos(camera.yaw) * math.cos(camera.pitch), camera.pos[1] math.sin(camera.pitch), camera.pos[2] math.sin(camera.yaw) * math.cos(camera.pitch), 0, 1, 0)注意 yaw 对 x/z 的影响是新人最容易翻车的地方有人用 sin 放在 x 轴结果朝向对不上别的模块。我习惯让 yaw0 时面朝 Zx 用 cos、z 用 sin整体保持一致就行。窗口尺寸变化时还要重设投影矩阵window.event def on_resize(width, height): glViewport(0, 0, width, height) glMatrixMode(GL_PROJECTION) glLoadIdentity() gluPerspective(70.0, width / height, 0.1, 1000.0) glMatrixMode(GL_MODELVIEW) return pyglet.event.EVENT_HANDLED最后在 on_draw 里清屏、开深度测试、调用 set_view 再画所有方块整个画面就立起来了window.event def on_draw(): window.clear() glEnable(GL_DEPTH_TEST) glEnable(GL_TEXTURE_2D) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) set_view(camera) for pos, block_type in WORLD.items(): draw_block_quads(pos[0], pos[1], pos[2], get_uv(block_type))到这里你就拥有一个能站着看、能转头的方块世界。4. 从看世界到改世界射线拾取、挖放与局部网格重建4.1 射线与方块求交步进法 vs DDA挖方块之前你得知道鼠标点在了哪个方块上。这是拾取问题从眼睛位置沿着视线方向发一根射线找到它碰到的第一个方块。最简单可读的算法是逐步前进——从眼睛出发每次前进一小段距离检查当前所在位置落进哪个格子def pick_block(origin, direction, max_dist8.0): step_dist 0.05 total 0.0 while total max_dist: total step_dist px origin[0] direction[0] * total py origin[1] direction[1] * total pz origin[2] direction[2] * total cell (math.floor(px), math.floor(py), math.floor(pz)) if cell in WORLD: return cell return None这个算法的优缺点非常明显实现简单但步长太大会跳过薄方块太小又慢。对简单程序的规模步长 0.05 是比较稳的。如果你追求更严谨的结果可以用 DDA 网格射线求交把视线按格子边界切成多段每段只检查一个新格子不会有漏检。这部分很多地方叫 voxel traversal实现起来比步进法复杂一些但更接近游戏级方案。实际使用时眼睛位置要稍微往前推一点比如origin direction * 0.1否则光标刚好点在眼睛所在格子里会显示挖到了自己头上。射线的 direction 从哪里来就是你瞄准的方向向量和相机朝向是一套def get_look_vector(camera): return ( math.cos(camera.yaw) * math.cos(camera.pitch), math.sin(camera.pitch), math.sin(camera.yaw) * math.cos(camera.pitch), )4.2 挖方块和放方块鼠标左右键对应两个不同坐标鼠标事件绑定后左键删格子右键加格子window.event def on_mouse_press(x, y, button, modifiers): look get_look_vector(camera) origin (camera.pos[0], camera.pos[1], camera.pos[2]) if button pyglet.window.mouse.LEFT: hit pick_block(origin, look) if hit: del WORLD[hit] elif button pyglet.window.mouse.RIGHT: hit pick_block(origin, look) if hit: place_adjacent(hit)放方块不能直接放在 hit 上那会把原来的顶掉正确位置是射线打中的那个面外侧。怎么知道面方向一个简单做法是让 pick_block 同时返回进入格子时最后的步进方向FACES [ (0, 1, 0), (0, -1, 0), (1, 0, 0), (-1, 0, 0), (0, 0, 1), (0, 0, -1), ] def place_adjacent(hit, block0): x, y, z hit for dx, dy, dz in FACES: n (x dx, y dy, z dz) if n not in WORLD: WORLD[n] block return这个“找个空邻格就放”的做法在洞穴里很好用因为射线打中的面背后往往是空的。但它有一个极端情况如果玩家紧贴在方块边上射线打中的邻居格其实就是玩家所在的位置你又把方块放回玩家身体里。处理办法是放之前检查目标格和相机位置是否重叠重叠就跳过def try_place(pos): px, py, pz camera.pos if int(px) pos[0] and int(py) pos[1] and int(pz) pos[2]: return WORLD[pos] 04.3 增量更新只重建受影响的面而不是整个地图挖掉一块后它四周的方块会突然暴露新的面必须把新面补上否则洞口会“看到天空却看不到墙”。大部分简单实现会在删方块后调用全量重绘勉强能跑但方块一多就卡。推荐的折中做法删掉一块后只重画它的 6 个邻居加上它自己。def rebuild_area(center): x, y, z center for dx, dy, dz in FACES: n (x dx, y dy, z dz) if n in WORLD: draw_block_quads(n[0], n[1], n[2], get_uv(WORLD[n]))真正的工程里你不会在事件回调里直接调 glBegin而是把“该重画的邻居”加入一个脏区列表在下一帧统一提交。这个“脏区重建”思路是从简单程序迈向正式体素引擎的必经之路。如果继续“每帧全量遍历 WORLD 并画所有方块”地图一大一抬头、一转身都会掉帧。实现上只需要把世界数据和渲染列表解耦即可世界数据是事实来源渲染列表是每帧真正交给 GPU 的东西。5. 避坑体素渲染常见的 6 个坑现象、原因、解法5.1 方块周围出现一圈黑线或细缝现象相邻方块的边缘有半透明细缝尤其在深色纹理上特别明显。原因有两个纹理坐标正好落在图集的格子边界采样混进了相邻格子的像素或者纹理过滤用了 LINEAR边缘被插值。解决方法是把纹理过滤改为 NEAREST并给 UV 加一个半像素收缩量。假设图集宽为 48 像素单位 UV 收缩量uv_shrink 0.5 / 48.0 # 画某个面时左下角 u uv_shrink右上角 u size - uv_shrink这个操作在体素圈叫“纹理内缩”几乎所有商业体素引擎都会做。5.2 鼠标方向不对或镜头乱转现象鼠标往上移镜头却往下看水平转向和鼠标左右相反或者鼠标到屏幕边缘开始加速乱甩。原因on_mouse_motion 的 dx、dy 在不同平台语义不同另外有人把 yaw 和 pitch 的符号写反gluLookAt 里方向向量算错。解决统一把角速度设为每像素 0.1 弧度并明确约定 yaw 增加是向右转、pitch 增加是向上看。调试时可以打印 yaw/pitch 值用固定鼠标输入观察变化。5.3 复制来的老代码在 Pyglet 2.x 上直接黑屏现象代码没问题装好 pyglet 运行只有黑窗口或报某个 GL 函数不存在。原因2.x 默认使用现代 OpenGL 上下文旧固定管线的 glBegin/glEnd 不可用。解决用虚拟环境锁定 1.5.xpython -m venv mcenv source mcenv/bin/activate # Windows 下是 mcenv\Scripts\activate pip install pyglet1.5.27如果你想坚持用 2.x就得把所有绘制改成现代 GL 加 shader那就不再是“简单程序”的范畴了。认清版本边界能省下很多排查时间。5.4 挖不到方块或者老挖到奇怪位置现象点击后世界里没变化或总挖到别处。原因步进法步长太大漏检射线起点没往后推有人用 int(px) 取整而非 floor负数坐标整体错位一格。解决cell (math.floor(px), math.floor(py), math.floor(pz)) origin (camera.pos[0] look[0] * 0.1, ...)max_dist 也要足够大至少 8 格否则远处的方块永远挖不到。5.5 走路穿模走进方块里出不来现象不透明方块一穿就过卡在里面视野黑。原因这个简单程序没有碰撞检测标题也说明只是 inspired by。多数实现只负责渲染和交互不负责物理。解决加最小地面碰撞。每次移动先算目标坐标如果目标点在 WORLD 里就取消该轴移动def try_move(dx, dy, dz): new_x, new_y, new_z camera.pos if not (int(new_x dx), int(new_y), int(new_z)) in WORLD: new_x dx if not (int(new_x), int(new_y dy), int(new_z)) in WORLD: new_y dy if not (int(new_x), int(new_y), int(new_z dz)) in WORLD: new_z dz camera.pos [new_x, new_y, new_z]这个简化版只做地面碰撞够用想要墙碰撞就得把玩家看成 1.8 格高的 AABB去检测周边 8 个格子。5.6 地图一大帧数断崖式下降现象半径从 8 调到 30平时也卡转身更卡。原因每帧全量遍历 WORLD 再逐块立即模式绘制方块数量上千后 CPU 和 GPU 都扛不住。解决第一步把立即模式换成 Pyglet Batch第二步做视锥剔除相机看不见的方向直接跳过第三步分 chunk按区块做可见性和脏区判断。这三招能把性能提升一到两个数量级下一章展开。6. 用 Pyglet Batch 和视锥剔除验证优化效果并确定下一步方向6.1 一个可验证的实验FPS 与顶点数先去掉花哨优化在窗口标题上每秒打印一次帧率def update(dt): fps int(pyglet.clock.get_fps()) window.set_caption(fsimple-mc | FPS: {fps} | blocks: {len(WORLD)}) pyglet.clock.schedule(update)这是整个项目里最值得先做的验证。改变一个参数、看一次数字比任何直觉判断都可靠。如果 FPS 掉到 60 以下说明当前规模已经到立即模式的上限了。6.2 用 Batch 替换立即模式把 glBegin/glEnd 换到 Batch 的思路不难启动时把每个方块所有可见面的顶点追加到一个大 VertexList每帧只调一次 draw。挖放方块后重算对应区块的顶点并替换到 Batch 里。数据结构变成batch pyglet.graphics.Batch() def add_block_to_batch(pos, uv): # 把四边形的 4 个顶点和 4 个纹理坐标 append 进 batch batch.add(4, GL_QUADS, texture_group, (v3f, vertices), (t2f, uvs))这样几千个方块可以保持流畅帧率。配合 cull face 的GL_CULL_FACE性能还有富余。我实测过的体感是同样半径 16 的世界立即模式 30FPS 都难Batch 可以稳定 60FPS 以上。6.3 视锥剔除与噪声地形然后才是你真正想做的事视锥剔除最简单的实现是把相机朝向做成一个“视锥体”判断每个方块中心是否在可视范围内不在就跳过绘制。这一步对开阔地形尤其有效能再砍掉一半多的绘制量。把上面的改完这个简单程序已经变成原型的骨架。再往下走建议按顺序加三件事噪声地形生成用 numpy 装多层叠加的柏林噪声做海拔图然后是紧贴视觉的明暗变化不需要阴影只要按朝向给亮度最后是 AABB 碰撞把玩家当成长方体处理。这个方向值不值得做如果你目的是学渲染和体素算法非常值得几百行 Python 换来的是对坐标、纹理和交互的扎实理解这份手感以后写任何 3D 项目都能复用。如果你想要一个能长期玩的完整游戏那不如直接去用现成的引擎或模组开发。就体素原型来说我自己的习惯是每改一次渲染逻辑先强制自己用 FPS 和方块数验证而不是靠肉眼感觉每加一个新功能先在最小世界半径里跑通再放开规模。这样能避开大多数翻车现场。希望这条经验对你也有用。本文还有配套的精品资源点击获取