ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python + Pyglet 从零实现 Minecraft 风格 3D 方块世界

Python + Pyglet 从零实现 Minecraft 风格 3D 方块世界 简介这是一份以Minecraft为灵感、用Python与Pyglet实现的简易3D方块世界演示程序面向刚接触编程的青少年与Python初学者也适合希望用游戏化案例开展教学的教师。项目目标是把Minecraft的趣味性与Python的易学性结合让学习者在搭建世界、放置方块的过程中理解图形渲染与面向对象编程的基本思路。压缩包共4个文件包含1个Python主程序、1个PNG贴图、1份开源许可证和1份Markdown说明文档整体仅16KB轻量易读代码注释较为充分便于快速修改参数并查看效果。目前已有510人学习下载。读者可从中获得一个可直接运行的迷你世界原型参考其世界初始化、方块坐标设置与纹理加载方式并借鉴作者提出的库化/API化设计思路尝试将项目封装为可导入配置的模块是入门图形编程与游戏逻辑的实用练手素材。1. 用 Python 和 Pyglet 从零搭一个 Minecraft 风格方块世界很多人第一次想自己写个 3D 游戏脑子里冒出来的就是 Minecraft 那种方块世界能走、能看、能放方块。但真打开引擎一看Unity 要装几个 GUnreal 编译半小时还没写一行逻辑就先被工具链劝退。其实如果你只想搞懂「方块世界到底怎么渲染出来的」Python 加 Pyglet 是一条极短的路Pyglet 直接封装 OpenGL不需要游戏编辑器一个pip install pyglet就能开始几百行代码就能跑出一个能自由飞行、能看见地形、能放置和破坏方块的窗口程序。这篇笔记面向两类人一是刚学完 Python 基础语法、想找个能看见成果的项目练手的入门者二是写过业务代码、想理解 3D 渲染管线但不想被引擎黑盒挡住的老手。下面按「先跑起来、再拆原理、最后调优」的顺序把这条路径完整走一遍。2. 环境搭建与最小可运行窗口先把 Pyglet 跑通再谈方块2.1 为什么选 Pyglet 而不是 Pygame 或直接上引擎Pygame 做 2D 很顺手但它对 OpenGL 的封装偏薄写 3D 要自己处理大量矩阵和着色器绑定新手容易卡在「黑屏但不知道哪错了」。直接上 Unity 或 Godot 又太重你花在学编辑器上的时间远超学渲染原理的时间。Pyglet 的定位刚好卡在中间它自带窗口、事件循环、OpenGL 上下文管理同时把glBegin/glEnd这类底层调用暴露给你你能清楚看到每个顶点是怎么变成屏幕像素的。选型上还有几个实际理由。第一Pyglet 是纯 Python 包没有 C 扩展编译步骤Windows、macOS、Linux 上pip install pyglet基本一次成功不会出现「python 下载安装教程」里那种装完发现缺 VC 运行库的翻车。第二它的 API 稳定1.5 到 2.0 的迁移成本低网上大量老教程还能用。第三Pyglet 内置了pyglet.graphics模块批量提交顶点数据很方便这对后面渲染成千上万个方块面至关重要。需要提醒的是Pyglet 2.0 之后默认要求 OpenGL 3.3 以上老机器如果显卡驱动太旧会直接报No standard config is available。遇到这种情况要么升级驱动要么退回 Pyglet 1.5.x。我一般会在项目开头就固定版本避免不同机器上行为不一致。2.2 用 venv 隔离环境并锁定 Pyglet 版本不管你是用 VSCode 还是 PyCharm第一步都建议建虚拟环境。很多「vscode 配置 python 环境」的教程会教你直接全局装包结果项目一多就互相污染。下面这套命令在三大平台通用# 创建虚拟环境目录名用 .venv 是社区惯例 python -m venv .venv # 激活环境Windows 用下面这行 .venv\Scripts\activate # macOS / Linux 用下面这行 source .venv/bin/activate # 安装并锁定 Pyglet 版本1.5.27 对老显卡更友好 pip install pyglet1.5.27 # 导出依赖清单方便换机器复现 pip freeze requirements.txt逻辑说明venv把解释器和第三方包隔离在项目目录内删掉.venv就等于卸载干净。锁定1.5.27是因为这个版本对 OpenGL 2.1 仍兼容覆盖的机器更广如果你确定显卡较新也可以装最新的 2.0.x但要注意 API 差异。requirements.txt是给未来的自己或协作者留的后悔药换机器时pip install -r requirements.txt就能还原。参数上唯一要留意的是 Python 版本。Pyglet 1.5.x 支持 Python 3.6 到 3.113.12 之后部分内部调用有变动可能报AttributeError。如果你本地是 3.12建议用 3.11 建环境或者直接上 Pyglet 2.0。2.3 二十行代码验证窗口和 OpenGL 上下文装完先别急着写方块用最小窗口确认渲染管线是通的。下面这段代码只做三件事开窗、设背景色、画一个三角形。import pyglet from pyglet.gl import * # 创建 800x600 的窗口标题随意 window pyglet.window.Window(800, 600, captionMinecraft-like Test) window.event def on_draw(): # 每帧先清屏颜色是 RGBA这里用天空蓝 glClearColor(0.5, 0.7, 1.0, 1.0) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) # 用立即模式画一个三角形验证 OpenGL 可用 glBegin(GL_TRIANGLES) glColor3f(1.0, 0.0, 0.0) # 顶点1 红色 glVertex3f(-0.5, -0.5, 0.0) glColor3f(0.0, 1.0, 0.0) # 顶点2 绿色 glVertex3f(0.5, -0.5, 0.0) glColor3f(0.0, 0.0, 1.0) # 顶点3 蓝色 glVertex3f(0.0, 0.5, 0.0) glEnd() pyglet.app.run()逻辑说明on_draw是 Pyglet 每帧自动调用的回调所有绘制都写在这里。glClear清掉颜色和深度缓冲深度缓冲是后面 3D 遮挡关系的基础现在先带上。glBegin/glEnd是 OpenGL 立即模式虽然性能差但用来验证环境最直观——如果窗口是蓝底加一个彩色三角形说明驱动、上下文、Pyglet 三者都正常。参数说明Window(800, 600)是像素尺寸可以按屏幕缩放glClearColor四个分量范围都是 0 到 1不是 0 到 255这是新手常踩的坑。如果运行后窗口一闪而过通常是没调pyglet.app.run()或者事件循环被阻塞了。3. 方块世界的渲染原理从单个立方体到批量顶点3.1 一个方块为什么是 12 个三角形而不是 6 个面Minecraft 里的方块看着是六个面但每个面要拆成两个三角形才能被 GPU 光栅化所以一个完整立方体是 12 个三角形、36 个顶点。如果你每个方块都单独提交这 36 个顶点一个 16×16×16 的区块就是 4096 个方块、将近 15 万个顶点Python 循环提交会直接卡成幻灯片。这里的关键认知是GPU 不怕顶点多怕的是提交次数多。每次glBegin/glEnd或每次 draw call 都有固定开销Python 层调用越频繁越慢。所以正确做法是把整个区块的顶点数据合并成一个大数组一次性提交。Pyglet 的pyglet.graphics.Batch和VertexList就是干这个的它内部用ctypes把数据打包成 C 数组绕过了 Python 循环的瓶颈。另一个优化点是面剔除。两个相邻方块之间的面是看不见的没必要渲染。判断规则很简单如果某个方向的邻居方块是实心的这个面就不加入顶点列表。一个被完全包围的方块贡献 0 个面地表方块通常只贡献 1 到 3 个面。这一条能把顶点数砍掉一半以上是方块世界性能的分水岭。3.2 用 Batch 批量构建区块顶点下面这段代码演示如何把一个区块的方块面收集起来用 Batch 一次性渲染。为了聚焦原理先不做纹理只用颜色区分面朝向。import pyglet from pyglet.gl import * from pyglet.graphics import Batch # 定义立方体六个面的顶点偏移每个面两个三角形 # 顺序底面、顶面、前、后、左、右 FACES [ [(0,0,0),(1,0,0),(1,0,1),(0,0,0),(1,0,1),(0,0,1)], # 底 [(0,1,0),(0,1,1),(1,1,1),(0,1,0),(1,1,1),(1,1,0)], # 顶 [(0,0,1),(1,0,1),(1,1,1),(0,0,1),(1,1,1),(0,1,1)], # 前 [(0,0,0),(0,1,0),(1,1,0),(0,0,0),(1,1,0),(1,0,0)], # 后 [(0,0,0),(0,0,1),(0,1,1),(0,0,0),(0,1,1),(0,1,0)], # 左 [(1,0,0),(1,1,0),(1,1,1),(1,0,0),(1,1,1),(1,0,1)], # 右 ] # 每个面的颜色方便肉眼区分朝向 FACE_COLORS [ (0.4,0.4,0.4), (0.6,0.6,0.6), (0.8,0.2,0.2), (0.2,0.8,0.2), (0.2,0.2,0.8), (0.8,0.8,0.2), ] def build_chunk(batch, blocks, ox, oy, oz): 把 blocks 集合里的方块面加入 batchox/oy/oz 是区块原点偏移 for (x, y, z) in blocks: for i, face in enumerate(FACES): # 面剔除检查该方向邻居是否存在 nx, ny, nz x, y, z if i 0: ny - 1 elif i 1: ny 1 elif i 2: nz 1 elif i 3: nz - 1 elif i 4: nx - 1 elif i 5: nx 1 if (nx, ny, nz) in blocks: continue # 邻居是实心这个面不渲染 # 收集这个面的 6 个顶点坐标和颜色 verts [] colors [] for (vx, vy, vz) in face: verts.extend([ox x vx, oy y vy, oz z vz]) colors.extend(FACE_COLORS[i]) # 加入 batch模式是三角形 batch.add(6, GL_TRIANGLES, None, (v3f, verts), (c3f, colors))逻辑说明FACES用局部坐标描述每个面的两个三角形顶点顺序遵循逆时针保证法线朝外。build_chunk遍历方块集合对每个面先做邻居检查被遮挡就跳过。batch.add的第二个参数是图元类型(v3f, verts)表示顶点位置是 3 个 float(c3f, colors)表示颜色也是 3 个 float。所有面加完后在on_draw里调batch.draw()一次提交。参数说明batch.add的第一个参数是顶点数一个面 6 个顶点所以填 6。None位置是索引数组这里不用索引所以传 None。如果你的方块数量很大建议改用IndexedVertexList配合索引缓冲能进一步省显存。面剔除的判断顺序要和FACES的索引严格对应写错一个方向就会出现「某些面莫名消失」的玄学问题。3.3 用投影矩阵把 3D 坐标映射到屏幕上面画的顶点还是 2D 平面坐标要变成能走进去的 3D 世界必须设置投影矩阵和视图矩阵。Pyglet 提供了window.projection和window.view两个矩阵分别控制「怎么投影」和「摄像机在哪」。from pyglet.gl import glu window.event def on_resize(width, height): # 设置视口保证画面不被拉伸 glViewport(0, 0, width, height) # 投影矩阵45 度视野近裁剪面 0.1远裁剪面 100 glMatrixMode(GL_PROJECTION) glLoadIdentity() gluPerspective(45.0, width / float(height), 0.1, 100.0) glMatrixMode(GL_MODELVIEW) def set_camera(px, py, pz, yaw, pitch): 根据玩家位置和朝向设置视图矩阵 glLoadIdentity() # 先旋转再平移顺序不能反 glRotatef(pitch, 1, 0, 0) glRotatef(yaw, 0, 1, 0) glTranslatef(-px, -py, -pz)逻辑说明gluPerspective的四个参数分别是视野角度、宽高比、近裁剪面、远裁剪面。视野越大画面越广但边缘畸变越明显45 到 70 是常见范围。set_camera里旋转和平移的顺序很关键摄像机变换等价于把整个世界反向变换所以要先转再移写反了会出现「转头时场景绕着某个点乱飞」的翻车。参数说明近裁剪面不能设 0否则深度缓冲精度会崩远裁剪面越大远处物体越容易闪烁z-fighting100 到 300 对小型方块世界够用。yaw是水平旋转角度pitch是俯仰角通常把pitch限制在 -89 到 89 度之间避免万向节死锁导致视角翻转。4. 交互与地形生成让世界能走、能挖、能放4.1 键盘鼠标事件绑定与第一人称移动一个能玩的方块世界核心交互就三样WASD 移动、鼠标转视角、左右键挖放。Pyglet 的事件系统用装饰器绑定写起来很直白。keys set() window.event def on_key_press(symbol, modifiers): keys.add(symbol) if symbol pyglet.window.key.ESCAPE: window.close() window.event def on_key_release(symbol, modifiers): keys.discard(symbol) window.event def on_mouse_motion(x, y, dx, dy): global yaw, pitch yaw dx * 0.2 pitch - dy * 0.2 # 限制俯仰角防止翻转 pitch max(-89, min(89, pitch)) def update(dt): 每帧根据按键更新玩家位置 speed 5.0 * dt # 每秒 5 个单位 # 根据 yaw 计算前后左右方向 import math rad math.radians(yaw) fx, fz -math.sin(rad), -math.cos(rad) # 前方向 rx, rz math.cos(rad), -math.sin(rad) # 右方向 if pyglet.window.key.W in keys: player[0] fx * speed; player[2] fz * speed if pyglet.window.key.S in keys: player[0] - fx * speed; player[2] - fz * speed if pyglet.window.key.A in keys: player[0] - rx * speed; player[2] - rz * speed if pyglet.window.key.D in keys: player[0] rx * speed; player[2] rz * speed pyglet.clock.schedule_interval(update, 1/60.0)逻辑说明keys集合记录当前按下的键on_key_press/release负责增删。鼠标移动的dx/dy是相对位移乘一个灵敏度系数累加到yaw/pitch。update用dt做时间步长保证不同帧率下移动速度一致这是所有实时程序的基本功。方向向量用三角函数从yaw推导前方向是(-sin, -cos)右方向是(cos, -sin)这个推导基于 OpenGL 默认相机朝 -Z 看。参数说明灵敏度0.2可以按手感调太大容易晕太小转不动。移动速度5.0单位每秒对小型世界合适想飞快点可以加 Shift 加速。schedule_interval的1/60是目标帧率Pyglet 会尽量按这个间隔调用但实际帧率受渲染负载影响。4.2 用射线检测实现方块拾取挖方块和放方块的前提是知道「玩家看着哪个方块」。标准做法是从摄像机位置沿视线方向发射一条射线和方块网格求交。方块世界有个便利所有方块都是轴对齐的单位立方体可以用 DDA 算法逐格步进比通用射线求交快得多。def raycast(origin, direction, max_dist8.0): DDA 步进返回第一个命中的方块坐标和命中面法线 import math x, y, z [math.floor(v) for v in origin] dx, dy, dz direction step_x 1 if dx 0 else -1 step_y 1 if dy 0 else -1 step_z 1 if dz 0 else -1 # 到下一个格边界的距离 t_max_x ((x (1 if dx 0 else 0)) - origin[0]) / dx if dx ! 0 else float(inf) t_max_y ((y (1 if dy 0 else 0)) - origin[1]) / dy if dy ! 0 else float(inf) t_max_z ((z (1 if dz 0 else 0)) - origin[2]) / dz if dz ! 0 else float(inf) t_delta_x abs(1 / dx) if dx ! 0 else float(inf) t_delta_y abs(1 / dy) if dy ! 0 else float(inf) t_delta_z abs(1 / dz) if dz ! 0 else float(inf) normal (0, 0, 0) t 0.0 while t max_dist: if (x, y, z) in blocks: return (x, y, z), normal # 沿最小的 t_max 方向步进 if t_max_x t_max_y and t_max_x t_max_z: x step_x; t t_max_x; t_max_x t_delta_x normal (-step_x, 0, 0) elif t_max_y t_max_z: y step_y; t t_max_y; t_max_y t_delta_y normal (0, -step_y, 0) else: z step_z; t t_max_z; t_max_z t_delta_z normal (0, 0, -step_z) return None, None逻辑说明DDA 的核心是维护三个方向的「到下一个格边界的参数距离」t_max每次挑最小的那个方向走一格同时更新该方向的t_max。这样射线经过的格子顺序和真实相交顺序一致不会漏格。命中时返回方块坐标和进入面的法线法线用来决定新方块放在哪一侧。参数说明max_dist是交互距离Minecraft 生存模式大约 4.5 格创造模式 5 格这里给 8 格方便调试。direction必须是单位向量否则t_max的计算会失真。除零用float(inf)兜底避免方向分量为 0 时崩溃。4.3 用值噪声生成起伏地形手工摆方块只能做小场景想要一片能跑的地形得靠噪声。Perlin 噪声或 Simplex 噪声是标准选择Python 里可以用noise库也可以自己写一个简化的值噪声。下面用值噪声加双线性插值生成高度图。import random, math def value_noise_2d(x, y, seed42): 简化值噪声格点随机值 双线性插值 random.seed(seed) def grid_val(gx, gy): random.seed(seed gx * 73856093 ^ gy * 19349663) return random.random() x0, y0 math.floor(x), math.floor(y) fx, fy x - x0, y - y0 # 平滑插值曲线避免格点处出现折角 sx fx * fx * (3 - 2 * fx) sy fy * fy * (3 - 2 * fy) v00 grid_val(x0, y0) v10 grid_val(x0 1, y0) v01 grid_val(x0, y0 1) v11 grid_val(x0 1, y0 1) top v00 * (1 - sx) v10 * sx bot v01 * (1 - sx) v11 * sx return top * (1 - sy) bot * sy def generate_terrain(size32, scale0.1, height8): 生成 size x size 的地形返回方块集合 result set() for x in range(size): for z in range(size): # 多个八度叠加地形更自然 n value_noise_2d(x * scale, z * scale) n value_noise_2d(x * scale * 2, z * scale * 2) * 0.5 n / 1.5 h int(n * height) for y in range(h 1): result.add((x, y, z)) return result逻辑说明值噪声在每个整数格点生成随机值格点之间用平滑曲线插值得到连续起伏。单个八度太规则叠加两个频率不同的八度第二个频率翻倍、幅度减半能模拟出更自然的山丘。generate_terrain把高度以下的方块全部填实形成实心地形。参数说明scale控制地形起伏频率越小山越平缓0.05 到 0.2 之间比较自然。height是最大高度配合size决定世界规模。seed固定后地形可复现换 seed 就是新地图。注意这个实现每次调用grid_val都重设随机种子性能一般正式项目建议预生成一张噪声表缓存起来。5. 性能与踩坑排查方块世界的血泪经验5.1 帧率从 60 掉到 5 的常见原因现象方块数量加到几千个后画面明显卡顿鼠标移动有延迟。 原因最常见的是每个方块单独调batch.add且没有做面剔除顶点数爆炸其次是每帧重新构建 Batch把构建开销算进了渲染循环。 解决面剔除必须做这是第一优先级。Batch 只在方块集合变化时重建比如挖掉或放下方块后标记dirty下一帧再重建不要每帧都重建。如果还卡把区块按 16×16 分块每块一个 Batch视锥外的区块直接跳过绘制。5.2 方块面闪烁或深度错乱现象相邻方块的接触面出现闪烁或者远处方块穿透近处方块显示。 原因深度缓冲没开或者开了但glDepthFunc设置不对也可能是近远裁剪面比例过大导致深度精度不足。 解决在on_draw开头确保glEnable(GL_DEPTH_TEST)并且每帧glClear带上GL_DEPTH_BUFFER_BIT。近裁剪面不要小于 0.1远裁剪面不要超过 500。如果还有闪烁检查顶点顺序是否一致逆时针和顺时针混用会让背面剔除失效。5.3 鼠标转视角时画面抖动现象移动鼠标时视角一顿一顿或者转头后场景整体偏移。 原因on_mouse_motion的dx/dy在某些系统上会给出异常大的跳变值尤其是鼠标移到窗口边缘时另一个原因是旋转和平移顺序写反。 解决对dx/dy做限幅比如dx max(-50, min(50, dx))过滤掉跳变。矩阵顺序必须是先glRotatef再glTranslatef这个顺序错了就会出现「转头时世界绕着玩家转」的诡异效果。另外建议把鼠标设为独占模式避免指针跑出窗口。5.4 地形生成后内存暴涨现象生成 64×64 的地形后内存占用几个 G程序越来越慢。 原因用 Python 的set存几万个元组每个元组和哈希表项都有开销如果还缓存了每个方块的顶点列表内存翻倍。 解决方块坐标用整数编码成单个整数存进set比如x * 1000000 y * 1000 z比元组省一半以上内存。顶点数据不要按方块缓存按区块构建完就交给 GPUPython 侧只保留方块集合。如果世界再大考虑用numpy数组或array模块存体素数据。5.5 Pyglet 窗口在 macOS 上无法获得焦点现象macOS 上运行后窗口出现但键盘鼠标无响应或者窗口标题栏灰色。 原因macOS 对窗口焦点和事件循环有额外要求Pyglet 1.5.x 在某些系统版本上需要手动激活。 解决在pyglet.app.run()之前调window.activate()如果还不行升级到 Pyglet 2.0.x它对 macOS 的支持更完善。另外确认 Python 是从终端启动而不是从 IDE 的嵌入式终端启动后者有时不传递焦点事件。6. 进阶技巧用显示列表和纹理让方块世界更像样走到这里你已经有一个能跑、能挖、能放的第一人称方块世界了。但纯色方块看着还是像工程 demo离「Minecraft 风格」差一层纹理。这一章讲两个具体技巧用显示列表降低重复绘制开销以及用纹理图集给方块贴图。先说显示列表。如果你的地形是静态的比如一整片不常改动的地面可以把它的顶点提交包进一个glNewList/glEndList之后每帧只调glCallList。显示列表把顶点数据存在 GPU 侧省掉了每帧从 Python 传数据的开销。实测在几千个面的规模下显示列表能把 CPU 占用降一半。代价是地形一变就得重建列表所以适合「静态区块 动态区块」混合的方案地面用显示列表玩家附近可交互的区块用 Batch。# 为静态区块创建显示列表 list_id glGenLists(1) glNewList(list_id, GL_COMPILE) # 这里放该区块所有面的 glBegin/glEnd 绘制 glEndList() # 每帧绘制时直接调用 glCallList(list_id)逻辑说明GL_COMPILE表示只编译不执行顶点数据被记录到列表里。之后glCallList触发 GPU 回放不再经过 Python。注意显示列表一旦创建就不能修改方块变化时要glDeleteLists再重建。再说纹理。Minecraft 的方块纹理是一张图集每个方块面取其中一小块。用 Pyglet 加载纹理后把(v3f, verts)换成(v3f, verts), (t2f, tex_coords)其中tex_coords是该面对应的 UV 坐标。UV 坐标范围是 0 到 1图集里第i行第j列的小块对应u从j/cols到(j1)/colsv同理。这里有个容易翻车的点OpenGL 的v轴原点在左下而图片坐标原点在左上UV 要上下翻转否则纹理会倒过来。# 加载纹理并设置最近邻过滤保持像素风 texture pyglet.image.load(atlas.png).get_texture() glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST) # 每个面的 UV假设图集是 4x4取第 0 行第 0 列 u0, v0 0.0, 0.75 u1, v1 0.25, 1.0 tex_coords [u0,v0, u1,v0, u1,v1, u0,v0, u1,v1, u0,v1]参数说明GL_NEAREST是最近邻采样方块边缘锐利符合像素风用GL_LINEAR会糊成一片。图集尺寸建议是 2 的幂比如 256×256 或 512×512老显卡对非 2 幂纹理支持不好。UV 的v翻转如果懒得算可以在加载图片后调texture.anchor_y或者直接对图片做垂直翻转。最后说一个验证方法写个简单的帧率显示把pyglet.clock.get_fps()画在窗口标题上改一处代码就看一眼帧率变化。我自己的习惯是每加一个优化点就记录一次帧率这样能清楚知道哪个改动真正有效而不是凭感觉。方块世界这个方向从几十行到几千行都有得玩关键是先把最小闭环跑通再一层层加纹理、加光照、加存档。希望帮到你。本文还有配套的精品资源点击获取
返回列表