简介:这是一份以 Minecraft 为灵感、用 Python 与 Pyglet 编写的简易 3D 世界演示程序,面向刚接触编程的青少年与 Python 初学者,也适合家长和教师作为编程启蒙素材。项目通过构建方块世界、放置与破坏方块等直观操作,让学习者在趣味场景中理解坐标、循环、面向对象与图形渲染等基础概念,作者也希望它能逐步演变为可导入配置的教育工具库。压缩包共 4 个文件,约 16KB,包含 1 个 Python 主程序、1 个纹理图片、1 份开源许可证和 1 份说明文档,体量轻巧,便于快速运行与二次修改。目前已有 512 人学习下载。代码注释较为充分,读者可据此了解世界生成、方块设置与渲染循环的实现思路,并尝试调整参数观察效果,是入门图形编程与游戏逻辑的实用练手项目。
1. 用 Python 手搓一个方块世界:这个项目能让你把体素渲染吃透
很多人第一次接触 3D 图形编程,都是从“想做一个自己的小世界”开始的。这个项目就是干这件事的:用 Python 加 Pyglet,从零搭出一个受方块沙盒游戏启发的可交互 3D 场景。它不依赖游戏引擎,所有顶点数据、纹理图集、相机矩阵、面剔除逻辑都得自己写。你打开它看到的是草地、泥土、石头组成的方块地形,鼠标能转视角,WASD 能走动,滚轮能切换方块类型。适合谁?适合已经会写 Python 函数和类、但一碰到 OpenGL 就发怵的开发者。它把“渲染管线”这个黑匣子拆成了几百行能读懂的代码,跑通之后你对顶点缓冲、索引绘制、深度测试的理解会直接上一个台阶。下面我从目录结构开始,一步步拆给你看。
2. 项目骨架与渲染管线:从窗口创建到第一帧画面
2.1 目录结构与模块职责
拿到源码先别急着跑,花两分钟把文件树看清楚,后面改代码时才知道往哪儿下手。这个项目的结构非常扁平,没有多余的包管理花活,常见做法是长这样:
minecraft_pyglet/ ├── main.py # 入口:创建窗口、注册事件、启动主循环 ├── world.py # 世界模型:区块数据、方块增删、地形生成 ├── player.py # 玩家:相机位置、朝向、移动与碰撞 ├── renderer.py # 渲染器:着色器编译、VBO/VAO 管理、绘制调用 ├── blocks.py # 方块定义:类型枚举、纹理坐标、是否透明 ├── textures/ │ └── atlas.png # 纹理图集,所有方块贴图拼在一张图上 └── shaders/ ├── vertex.glsl # 顶点着色器 └── fragment.glsl # 片元着色器main.py只做三件事:初始化 Pyglet 窗口、实例化 World 和 Player、把on_draw和on_update挂到 Pyglet 的时钟上。world.py持有所有方块坐标,用字典或三维数组存,键是(x, y, z)元组。renderer.py是最核心的部分,它负责把世界数据翻译成 GPU 能吃的顶点数组。blocks.py里每个方块类型对应图集上的一个 UV 偏移,改贴图只需要动这个文件。
提示:如果你拿到的版本里没有
shaders/目录,说明着色器是硬编码在renderer.py里的字符串,逻辑一样,只是不方便改。
2.2 窗口初始化与主循环
Pyglet 的窗口创建比 Pygame 更贴近 OpenGL 原生流程,因为它本身就是为 OpenGL 设计的。入口代码通常是这样:
import pyglet from pyglet.gl import * from world import World from player import Player from renderer import Renderer # 创建带深度缓冲的窗口,深度测试是 3D 渲染的命根子 window = pyglet.window.Window(width=1280, height=720, caption="Python Voxel World") glEnable(GL_DEPTH_TEST) # 开启深度测试,防止远处方块盖住近处 glEnable(GL_CULL_FACE) # 开启背面剔除,减少一半绘制量 glCullFace(GL_BACK) world = World() player = Player(position=(0, 10, 0)) renderer = Renderer(world) @window.event def on_draw(): glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) # 每帧清颜色和深度 renderer.draw(player) # 用玩家相机矩阵渲染 def update(dt): player.update(dt, world) # 每帧根据输入更新玩家位置 pyglet.clock.schedule_interval(update, 1/60.0) # 锁定 60 FPS 更新 pyglet.app.run()这段代码里glEnable(GL_DEPTH_TEST)和glEnable(GL_CULL_FACE)是两个必须开的开关。不开深度测试,后画的方块会覆盖先画的,画面会像透明玻璃一样乱套;不开背面剔除,每个方块的六个面都会画,性能直接砍半。schedule_interval的参数1/60.0表示每秒调用 60 次update,dt是距离上一帧的秒数,玩家移动速度要乘dt才能保证不同帧率下速度一致。
2.3 顶点数据组织与纹理图集
体素渲染的性能瓶颈不在画多少方块,而在怎么把方块数据塞给 GPU。一个方块有 6 个面,每个面 4 个顶点,每个顶点包含位置(3 个 float)和纹理坐标(2 个 float),一共 5 个 float。如果每个方块单独画一次,一万个方块就是六万次绘制调用,帧率会掉到个位数。这个项目的做法是:把所有可见方块的顶点拼成一个大数组,一次性提交。
import numpy as np def build_mesh(world): vertices = [] for (x, y, z), block_type in world.blocks.items(): for face in FACES: # 只画暴露在空气中的面,被邻居挡住的跳过 neighbor = (x + face.normal[0], y + face.normal[1], z + face.normal[2]) if neighbor in world.blocks: continue for vertex in face.vertices: px, py, pz = x + vertex[0], y + vertex[1], z + vertex[2] u, v = block_type.uv_for_face(face.index) vertices.extend([px, py, pz, u, v]) return np.array(vertices, dtype=np.float32)FACES是一个预定义的列表,存了六个面的法线方向和四个顶点相对于方块原点的偏移。world.blocks是一个字典,键是整数坐标元组,值是方块类型。if neighbor in world.blocks: continue这行就是面剔除,只保留暴露面。block_type.uv_for_face(face.index)根据方块类型和面方向返回图集上的 UV 坐标。最后返回的np.array会直接传给glBufferData。
纹理图集是一张包含所有方块贴图的 PNG,比如 16x16 的格子拼成 256x256 的图。UV 坐标不是 0 到 1,而是对应图集上的小格子。常见做法是每个方块类型存一个(u_min, v_min, u_max, v_max)四元组,画的时候直接取。
注意:图集边缘要做几个像素的 padding,否则开启纹理过滤后相邻格子会渗色,方块边缘出现奇怪的线条。
2.4 相机矩阵与玩家控制
玩家控制的核心是把鼠标移动转成相机的偏航角和俯仰角,再用三角函数算出观察方向。Pyglet 默认不锁定鼠标,需要手动设置window.set_exclusive_mouse(True)。
import math class Player: def __init__(self, position): self.position = list(position) self.yaw = -90.0 # 偏航角,-90 度朝向 -Z 方向 self.pitch = 0.0 # 俯仰角,0 度水平看 self.speed = 5.0 def update(self, dt, world): # 根据偏航角计算前后左右移动方向 rad = math.radians(self.yaw) forward = (math.cos(rad), 0, math.sin(rad)) right = (math.cos(rad + math.pi/2), 0, math.sin(rad + math.pi/2)) # 键盘状态由 Pyglet 的 key handler 维护,这里只做位移 move = [0, 0, 0] if keys[pyglet.window.key.W]: move[0] += forward[0]; move[2] += forward[2] if keys[pyglet.window.key.S]: move[0] -= forward[0]; move[2] -= forward[2] # 归一化后乘以速度和 dt,保证斜向移动不加速 length = math.sqrt(move[0]**2 + move[2]**2) if length > 0: self.position[0] += move[0] / length * self.speed * dt self.position[2] += move[2] / length * self.speed * dtyaw和pitch由on_mouse_motion事件更新,通常yaw += dx * sensitivity,pitch -= dy * sensitivity,并且pitch要夹在 -89 到 89 度之间,防止翻转。forward向量只取水平分量,所以抬头低头不影响走路方向。length归一化是为了斜向按 W+D 时速度不会变成 1.414 倍。dt保证不同帧率下移动距离一致。
相机矩阵用gluLookAt或者手动构造视图矩阵,投影矩阵用gluPerspective(fov, aspect, near, far)。fov一般设 70 度,near设 0.1,far设 1000。每帧在renderer.draw里把这两个矩阵传给着色器。
3. 方块世界的数据结构与地形生成:让世界有得看
3.1 用字典还是三维数组
存方块坐标有两种常见做法。三维数组blocks[x][y][z]访问快,但世界稍微大一点内存就爆炸,而且地形是无限的,数组没法动态扩展。字典{(x,y,z): block_type}内存只花在存在的方块上,增删改查都是 O(1),代价是哈希计算比数组索引慢一点。这个项目用字典,因为它的世界是动态生成的,玩家走到哪生成到哪。
class World: def __init__(self): self.blocks = {} # 键是 (x, y, z),值是方块类型枚举 self.chunk_size = 16 # 每个区块 16x16 水平范围 self.generated_chunks = set() def set_block(self, x, y, z, block_type): self.blocks[(x, y, z)] = block_type def get_block(self, x, y, z): return self.blocks.get((x, y, z), None) # 不存在返回 None,代表空气get_block返回None表示空气,渲染时跳过。generated_chunks记录已经生成过的区块,防止重复生成。区块大小 16 是体素游戏的经典选择,因为 16x16 的平面在内存和生成速度之间比较平衡。
3.2 用噪声函数生成地形高度
地形不能是平的,否则跟一张纸一样。常见做法是用 Perlin 噪声或 Simplex 噪声生成高度图。Python 标准库没有噪声函数,需要自己实现一个简易版,或者用noise库。这里给一个不依赖第三方库的简化实现:
import math import random def simple_noise(x, z, seed=42): # 用正弦函数叠加模拟噪声,周期不同,叠加后看起来自然 random.seed(seed) n = math.sin(x * 0.1) * math.cos(z * 0.1) * 3 n += math.sin(x * 0.05 + 1.3) * math.cos(z * 0.07 + 0.7) * 6 n += math.sin(x * 0.02 + 2.1) * math.cos(z * 0.03 + 1.9) * 12 return n def generate_chunk(world, cx, cz): for x in range(cx * 16, (cx + 1) * 16): for z in range(cz * 16, (cz + 1) * 16): height = int(10 + simple_noise(x, z)) for y in range(height): if y == height - 1: world.set_block(x, y, z, GRASS) # 最上层是草 elif y > height - 4: world.set_block(x, y, z, DIRT) # 下面几层是泥土 else: world.set_block(x, y, z, STONE) # 再往下是石头simple_noise用不同频率的正弦波叠加,低频决定大起伏,高频决定小细节。generate_chunk遍历 16x16 的水平面,每个位置算高度,然后从 y=0 填到高度值。GRASS、DIRT、STONE是blocks.py里定义的枚举。这个生成逻辑很简单,但跑起来能看到起伏的草地和地下岩层,对理解体素地形生成足够了。
提示:如果你想要更自然的地形,把
simple_noise换成真正的 Perlin 噪声实现,接口不用变,只改函数内部。
3.3 区块加载与卸载策略
玩家一直往前走,世界不能无限生成下去,否则内存迟早撑爆。常见做法是以玩家为中心,加载周围一定半径内的区块,超出半径的卸载。这个项目里可以这样写:
def update_chunks(world, player_pos, radius=4): pcx = int(player_pos[0]) // 16 pcz = int(player_pos[2]) // 16 needed = set() for dx in range(-radius, radius + 1): for dz in range(-radius, radius + 1): needed.add((pcx + dx, pcz + dz)) # 生成缺失的区块 for cx, cz in needed - world.generated_chunks: generate_chunk(world, cx, cz) world.generated_chunks.add((cx, cz)) # 卸载超出范围的区块 for cx, cz in list(world.generated_chunks): if (cx, cz) not in needed: remove_chunk(world, cx, cz) world.generated_chunks.discard((cx, cz))radius=4表示加载玩家所在区块周围 4 个区块,总共 9x9=81 个区块,每个区块 16x16 水平面,视野范围大约 144x144 个方块。remove_chunk遍历该区块内所有坐标,从world.blocks里删掉。这个策略保证内存占用稳定,玩家走再远也不会爆。
4. 避坑与常见问题:跑不起来先看这几条
4.1 窗口黑屏或闪退
现象:运行python main.py后窗口一闪而过,或者窗口全黑没有画面。原因:最常见的是着色器编译失败,或者 VBO 没有正确绑定。Pyglet 在着色器出错时不一定抛异常,可能只是静默不画。解决:在renderer.py里编译着色器后加glGetShaderiv检查编译状态,失败就打印日志。另外确认glClearColor设的不是黑色,或者相机位置在方块内部导致看不到东西。
4.2 方块面闪烁或深度错乱
现象:相邻方块的接触面出现闪烁,或者远处的方块盖住近处的。原因:深度缓冲没开,或者投影矩阵的near和far设置不合理。near太小会导致深度精度不足,far太大同样。解决:确认glEnable(GL_DEPTH_TEST)在on_draw之前调用。near设 0.1,far设 500 左右,不要设 10000。如果还有闪烁,检查面剔除逻辑,确保被遮挡的面真的没提交。
4.3 鼠标移动视角乱转
现象:鼠标一动视角就飞出去,或者鼠标移出窗口后视角卡住。原因:没有锁定鼠标,或者pitch没有夹紧。解决:在on_mouse_press里调用window.set_exclusive_mouse(True),在on_mouse_motion里更新yaw和pitch后立刻pitch = max(-89, min(89, pitch))。如果鼠标移出窗口,Pyglet 会停止发送on_mouse_motion,需要监听on_deactivate事件重新锁定。
4.4 帧率随方块数量增加骤降
现象:刚开始 60 帧,走远一点掉到 10 帧。原因:每帧重新构建整个网格,或者没有做面剔除。解决:把网格构建移到区块生成时,只在方块增删时重建受影响的区块。面剔除必须做,否则每个方块画 6 个面,GPU 填充率直接拉满。另外确认用的是glDrawArrays而不是每个方块一次glBegin/glEnd。
4.5 纹理显示为纯色或错位
现象:方块全是白色或灰色,或者贴图位置对不上。原因:纹理没有绑定,或者 UV 坐标算错。解决:确认glEnable(GL_TEXTURE_2D)和glBindTexture在绘制前调用。UV 坐标检查uv_for_face返回的四个值是否在 0 到 1 之间,图集上的格子偏移是否算对。如果纹理上下颠倒,把 V 坐标翻转一下,OpenGL 的纹理原点在左下角,图片原点在左上角。
5. 二次开发改造:从能跑到好用
5.1 加方块交互:射线检测与放置破坏
项目跑通之后,最想加的功能肯定是“能挖能放”。这需要从相机位置沿视线方向发射一条射线,检测第一个击中的方块。体素世界的射线检测可以用 DDA 算法,逐格前进,直到碰到非空气方块。
def raycast(world, origin, direction, max_dist=8): # DDA 步进:每次沿最接近的轴前进一格 x, y, z = [int(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 表示沿射线走到下一个格子边界需要的距离 t_max_x = ((x + (step_x > 0)) - origin[0]) / dx if dx != 0 else float('inf') t_max_y = ((y + (step_y > 0)) - origin[1]) / dy if dy != 0 else float('inf') t_max_z = ((z + (step_z > 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') dist = 0 while dist < max_dist: if world.get_block(x, y, z) is not None: return (x, y, z) # 命中方块坐标 if t_max_x < t_max_y and t_max_x < t_max_z: x += step_x; dist = t_max_x; t_max_x += t_delta_x elif t_max_y < t_max_z: y += step_y; dist = t_max_y; t_max_y += t_delta_y else: z += step_z; dist = t_max_z; t_max_z += t_delta_z return Noneorigin是相机位置,direction是相机朝向的单位向量。t_max记录沿射线到下一个格子边界的距离,t_delta是穿过一个格子需要的距离。每次选t_max最小的轴前进一格,保证不会跳过任何格子。命中后返回方块坐标,左键破坏就把该坐标从world.blocks删掉,右键放置就在命中面相邻的格子加一个新方块。改完记得重建受影响的区块网格。
5.2 性能优化:从字典到分块 VBO
当前实现每次重建网格都要遍历整个世界的方块,方块多了会卡。改造方向是把网格构建按区块拆分,每个区块维护自己的 VBO,只在区块内方块变化时重建那个区块的 VBO。这样玩家挖一个方块,只重建一个 16x16x16 的区块,而不是整个世界。
class ChunkMesh: def __init__(self, cx, cz): self.cx = cx self.cz = cz self.vbo = glGenBuffers(1) self.vertex_count = 0 def rebuild(self, world): vertices = build_mesh_for_chunk(world, self.cx, self.cz) glBindBuffer(GL_ARRAY_BUFFER, self.vbo) glBufferData(GL_ARRAY_BUFFER, vertices.nbytes, vertices, GL_STATIC_DRAW) self.vertex_count = len(vertices) // 5 # 每个顶点 5 个 floatbuild_mesh_for_chunk只遍历该区块范围内的方块,范围是cx*16到cx*16+15。GL_STATIC_DRAW表示数据不常改,驱动会把它放到显存里。绘制时遍历所有ChunkMesh,每个调一次glDrawArrays。区块数量控制在视野半径内,一般不超过 200 个,绘制调用次数完全可接受。
5.3 验证改造是否生效
改完代码别急着说“好了”,用几个硬指标验证。第一,打开窗口后按 F3 显示帧率,走动时帧率应该稳定在 60 附近,波动不超过 10。第二,挖一个方块,观察画面是否立刻更新,没有残留的旧面。第三,走到世界边缘,看新区块是否平滑生成,没有卡顿。第四,用glGetError检查有没有 OpenGL 错误,常见的是GL_INVALID_OPERATION,通常是 VBO 没绑定就调了glVertexAttribPointer。
| 检查项 | 预期结果 | 失败时看哪里 |
|---|---|---|
| 帧率 | 稳定 60 FPS | 面剔除、VBO 重建频率 |
| 挖方块 | 立即消失 | 区块网格重建触发条件 |
| 新区块 | 平滑出现 | 区块加载半径、生成耗时 |
| 纹理 | 正确显示 | UV 坐标、图集绑定 |
| 深度 | 无闪烁 | 深度测试、near/far |
5.4 一个我踩过的坑
刚开始做面剔除时,我只判断了邻居坐标是否在world.blocks里,忘了考虑世界边界。结果世界边缘的方块面全被剔掉了,从外面看世界是透明的。后来改成:如果邻居坐标超出已生成区块范围,就当作空气处理,面保留。这个逻辑在build_mesh里加一行判断就行,但当时调了两个小时才找到。从那以后我每次写剔除逻辑,都强制在边界坐标上跑一遍单元测试,确认边缘面不会被误删。希望这个经验帮你省下那两个小时。
本文还有配套的精品资源,点击获取