资讯动态

Python手搓体素渲染引擎:从零实现方块世界与OpenGL管线

发布时间:2026/10/17 15:12:56 来源:尧图企业网站定制
简介这是一份以 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(width1280, height720, captionPython 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 次updatedt是距离上一帧的秒数玩家移动速度要乘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, dtypenp.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 * sensitivitypitch - dy * sensitivity并且pitch要夹在 -89 到 89 度之间防止翻转。forward向量只取水平分量所以抬头低头不影响走路方向。length归一化是为了斜向按 WD 时速度不会变成 1.414 倍。dt保证不同帧率下移动距离一致。相机矩阵用gluLookAt或者手动构造视图矩阵投影矩阵用gluPerspective(fov, aspect, near, far)。fov一般设 70 度near设 0.1far设 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, seed42): # 用正弦函数叠加模拟噪声周期不同叠加后看起来自然 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 的水平面每个位置算高度然后从 y0 填到高度值。GRASS、DIRT、STONE是blocks.py里定义的枚举。这个生成逻辑很简单但跑起来能看到起伏的草地和地下岩层对理解体素地形生成足够了。提示如果你想要更自然的地形把simple_noise换成真正的 Perlin 噪声实现接口不用变只改函数内部。3.3 区块加载与卸载策略玩家一直往前走世界不能无限生成下去否则内存迟早撑爆。常见做法是以玩家为中心加载周围一定半径内的区块超出半径的卸载。这个项目里可以这样写def update_chunks(world, player_pos, radius4): 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))radius4表示加载玩家所在区块周围 4 个区块总共 9x981 个区块每个区块 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.1far设 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_dist8): # 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*1615。GL_STATIC_DRAW表示数据不常改驱动会把它放到显存里。绘制时遍历所有ChunkMesh每个调一次glDrawArrays。区块数量控制在视野半径内一般不超过 200 个绘制调用次数完全可接受。5.3 验证改造是否生效改完代码别急着说“好了”用几个硬指标验证。第一打开窗口后按 F3 显示帧率走动时帧率应该稳定在 60 附近波动不超过 10。第二挖一个方块观察画面是否立刻更新没有残留的旧面。第三走到世界边缘看新区块是否平滑生成没有卡顿。第四用glGetError检查有没有 OpenGL 错误常见的是GL_INVALID_OPERATION通常是 VBO 没绑定就调了glVertexAttribPointer。检查项预期结果失败时看哪里帧率稳定 60 FPS面剔除、VBO 重建频率挖方块立即消失区块网格重建触发条件新区块平滑出现区块加载半径、生成耗时纹理正确显示UV 坐标、图集绑定深度无闪烁深度测试、near/far5.4 一个我踩过的坑刚开始做面剔除时我只判断了邻居坐标是否在world.blocks里忘了考虑世界边界。结果世界边缘的方块面全被剔掉了从外面看世界是透明的。后来改成如果邻居坐标超出已生成区块范围就当作空气处理面保留。这个逻辑在build_mesh里加一行判断就行但当时调了两个小时才找到。从那以后我每次写剔除逻辑都强制在边界坐标上跑一遍单元测试确认边缘面不会被误删。希望这个经验帮你省下那两个小时。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑