Python+Pyglet体素引擎:纹理图集、噪声地形与射线拾取

发布时间:2026/10/12 6:25:59
Python+Pyglet体素引擎:纹理图集、噪声地形与射线拾取 简介基于 Python 与 Pyglet 编写的一款 Minecraft 风格迷你演示程序适合编程初学者、教育者和游戏开发入门者。项目以极简方式还原体素世界中的地形生成、方块放置与基本漫游操作代码保持紧凑并配有清晰注释便于理解 Pyglet 渲染与交互流程示例将抽象编程概念转化为可视化反馈有助于降低游戏开发的学习门槛。压缩包体积仅 16KB共 4 个文件一个 Python 主程序负责核心逻辑一张 PNG 纹理图片提供方块贴图一份 Markdown 文档说明结构及运行方式外加 License 许可证文件目前已有 511 人学习/下载。读者可在此基础上调整方块类型、纹理或世界参数快速看到修改效果并通过阅读源码学习体素坐标计算、纹理映射、键盘事件处理等基础概念也可尝试将工程改造成可复用模块进一步熟悉面向对象设计。项目默认配置开箱即用只需安装 Pyglet 依赖即可运行体验整体体量轻盈、依赖范围小适合作为 Python 图形编程的趣味练手项目也可用于课堂演示或引导孩子建立对代码的直观兴趣。1. 用 Python 和 Pyglet 写的 Minecraft 启发程序下载前先把这三个技术点看明白很多人看到「Minecraft Python Pyglet」的第一反应是以为拿到一个可以盖房子、打怪、联机的迷你客户端实际下载打开才发现这更像一个「看得见、能走、能拆方块」的体素渲染演示项目。这类资源真正的价值不是复刻一个游戏而是把三维体素引擎的三件事讲透纹理图集的 UV 拼接、基于噪声的地形生成、以及射线拾取与网格剔除。如果你正在做图形学入门课设或者想把 OpenGL 渲染流程从「画三角形」推进到「生成一个可交互的小世界」这份资源是合适的改装底座。下文我会按拆项目的方式把实现路径、参数含义和最容易翻车的环节逐一讲清。2. 先盘 Pyglet 这个渲染底座事件循环和绘制是两回事2.1 为什么这类项目普遍选 Pyglet 而不是 Pygame我拆过好几份类似的体素 Demo发现大家不约而同选了 Pyglet核心原因是它站在 OpenGL 之上却不强迫你写一堆窗口初始化样板。Pyglet 创建窗口只要一行pyglet.window.Window自带事件循环配合pyglet.clock.schedule就能把「输入、物理、渲染」分开驱动。相比之下用 Pygame 做 3D 体素会别扭很多Pygame 的底层是 SDL面向 2D 表面要做透视投影、深度测试、纹理绑定等于从负一层开始砌墙。而 Pyglet 直接给glBegin旧管线和现代的glDrawElements都能调用纹理、VBO、shader 都在自家 API 范围里写体素渲染少掉很多胶水代码。当然Pyglet 也有自身的「脾气」最典型的是窗口上下文默认配置和系统 OpenGL 驱动有关。如果你拿到资源跑起来发现黑屏但没报错第一件事不是去改纹理而是检查窗口初始化时有没有设置config.sample_buffers或config.double_buffer。不过这不是必须项很多轻量 Demo 就用默认窗口也能跑。2.2 入口文件与项目模块的大致划分这类资源的入口模块通常长这样结构基本可以预测import pyglet from world import World from player import Player window pyglet.window.Window(width1024, height768, captionVoxel Demo) world World(seed2024001) # 生成一个 8x8 chunk 的世界 player Player(position(0.0, 80.0, 0.0)) def update(dt): player.update(dt, world) # 移动、重力、碰撞 def on_draw(): window.clear() batch.draw() # 所有方块 mesh 都挂在这个批里 pyglet.clock.schedule(update) window.event def on_resize(width, height): window.viewport (0, 0, width, height) # 这里要同步更新透视矩阵否则窗口一拉就变形 pyglet.app.run()这段代码里有三个细节值得留意。第一batch必须是主文件里全局唯一的对象所有 chunk 的顶点都要add进同一个batch千万不要每帧新建pyglet.graphics.Batch()否则帧率会直接掉到个位数。第二on_resize里面只更新视口还不够透视矩阵也要按新宽高重建很多人在窗口全屏之后发现世界被拉扁就是这里漏了。第三World(seed...)里传入 seed 是这类资源的标准做法方便同一世界可复现调试地形时最好固定 seed否则每次启动地形都不一样你很难判断改改参数有没有生效。2.3 体素数据模型区块chunk才是性能分水岭看完入口再看核心的数据结构。一份能用的体素 Demo几乎不会用一个大数组存整个地图而是切成固定大小的区块。这样做有两个直接的工程收益一是区块级隔离地形更新时只需重算一个区块的 mesh不用全量重建二是区块可以惰性生成玩家没走到的地方不会浪费 CPU 去算。区块内部的组织一般是这样的CHUNK_SIZE 16 HEIGHT 128 class Chunk: def __init__(self, cx, cz): self.cx cx self.cz cz # blocks[x][y][z] 存方块 id0 表示空气 self.blocks [[[0 for _ in range(CHUNK_SIZE)] for _ in range(HEIGHT)] for _ in range(CHUNK_SIZE)] self.mesh None def block_index(self, wx, wy, wz): # 世界坐标 - 区块本地坐标 lx wx - self.cx * CHUNK_SIZE lz wz - self.cz * CHUNK_SIZE return lx, wy, lz参数上最需要较真的是HEIGHT。很多克隆项目的世界高度是 64看起来够用一加高楼、山体就会露顶64 的整数倍在噪声采样上也容易出「阶梯地形」。我见过的同类 Demo 大多用 96 或 128配合垂直高度噪声既能看出山体起伏又不至于让网格生成太慢。坐标换算这里要特别小心负数的取模。Python 的%结果永远是非负的所以-2 % 16 14这个特性对区块内索引非常友好不需要像 C 那样额外处理负数余数。但如果用//做区块坐标计算-2 // 16 -1正好落在正确区块上这两个运算配合得当世界坐标转区块坐标就是两行除法加取模的事。很多新手在这写错导致世界边缘的方块跑到别处排查起来很痛苦。3. 纹理拼图与噪声地形先让世界「有脸见人」3.1 纹理图集Texture Atlas与 UV 提取一个体素世界如果每一种方块单独加载一张图片显卡会被纹理切换拖垮。这类资源几乎统一用「纹理图集」方案把多种方块贴图拼到一张大图上渲染时通过 UV 坐标采样到对应小格子。Pyglet 加载图集后后续所有 draw 都只绑定这一张纹理性能损失小得多。UV 提取是这个环节的核心代码不同方块 id 的六个面可能对应图集里不同格子ATLAS_SIZE 16 # 图集是 16x16 的格子 TEX_SIZE 1.0 / ATLAS_SIZE def get_face_uv(block_id, face): 返回某个方块的某个面对应的四个 UV 坐标 (u_min, v_min, u_max, v_max) # 假设每行每列都按 (行号, 列号) 索引 row block_id // ATLAS_SIZE col block_id % ATLAS_SIZE u0 col * TEX_SIZE v0 row * TEX_SIZE u1 u0 TEX_SIZE v1 v0 TEX_SIZE # 不同朝向可以偏移行/列来获取不同贴图 # 例如 top 面用 (row 1) % ATLAS_SIZE 的格子 if face top: row (row 1) % ATLAS_SIZE u0, v0 col * TEX_SIZE, row * TEX_SIZE u1, v1 u0 TEX_SIZE, v0 TEX_SIZE return (u0, v0, u1, v1)这里有个常被忽略的坑图集的第 0 行在最上方还是最下方取决于图片加载时 OpenGL 的行序约定。Pyglet 用pyglet.image.TextureGrid加载图集时一般默认 UV 原点在左下而美术资源多数是从左上往下排。如果不做翻转你看到的地形会全部「上下颠倒」。处理方式有两种要么在图集加载后对整张贴图做垂直翻转要么在提取 UV 时把v改成1.0 - v。具体用哪种以你加载图片后第一张测试贴图的结果为准——我一般先放一个草方块到屏幕上转一圈确认纹理方向再继续往下写这一步能省后续一堆排查时间。3.2 噪声高度图参数的敏感程度比想象大地形生成最常见的方案是高度图加方块密度划分。高度图由分形噪声构成本质是对基础噪声做多倍频叠加。这份资源的实现大概率在terrain.py或world_gen.py里大体逻辑是这样的import random import math def smooth_noise(x, z): # 伪随机 双线性插值简单起见用整型哈希 n int(x) * 374761393 int(z) * 668265263 n (n ^ (n 13)) * 1274126177 return ((n 0x7fffffff) / 0x7fffffff) * 2.0 - 1.0 def fractional_noise(x, z, octaves4, persistence0.5): total 0.0 frequency 1.0 amplitude 1.0 max_value 0.0 for _ in range(octaves): total smooth_noise(x * frequency, z * frequency) * amplitude max_value amplitude amplitude * persistence frequency * 2.0 return total / max_value def generate_heightmap(chunk, seed): random.seed(seed) for lx in range(CHUNK_SIZE): for lz in range(CHUNK_SIZE): wx chunk.cx * CHUNK_SIZE lx wz chunk.cz * CHUNK_SIZE lz h fractional_noise(wx * 0.02, wz * 0.02) h int(h * 20 40) # 映射到 20~60 之间 chunk.heightmap[lx][lz] h这套「整数哈希 双线性插值」虽然不够精致比真正柏林噪声要多一层手写逻辑但它没有第三方依赖拿到任何机器上都能直接跑。真正需要调的是三个参数octaves倍频数决定地形的丰富度太高会产生高频毛刺4 到 6 之间常见persistence振幅衰减控制细节叠加幅度取 0.5 是通用值想做出陡峭山体可以提高到 0.55 到 0.65frequency缩放系数这里的0.02控制山峰间距数值越小地形越平缓。拿到高度图后填充方块就变成一层一层比较高度值。常规做法是def fill_blocks_from_heightmap(chunk): hm chunk.heightmap for lx in range(CHUNK_SIZE): for lz in range(CHUNK_SIZE): h hm[lx][lz] for y in range(h): if y h - 1: bid 2 # 草方块 elif y h - 4: bid 3 # 泥土 else: bid 4 # 石头 chunk.blocks[lx][y][lz] bid这里的分层逻辑是经验值顶部一格草方块、下面 3 格泥土、再往下全部石头。如果你再想加一层基岩就在 y 0 时强制填 7基岩 id。这类资源里通常不会做水下洞穴但如果要做推荐做法是额外用一个三倍频噪声对 y 轴采样大于 0.6 的体素强制置为空气——阈值低于 0.55 会导致洞穴过于密集地表大面积塌方。3.3 把两段逻辑串起来从噪声到可渲染的网格有了高度图和方块填充还得让数据变成 GPU 能画的三角形。这一步是在build_chunk_mesh里完成的核心是遍历区块内每个方块只生成与空气相邻的面的顶点和索引。注意面剔除逻辑属于渲染优化但在代码组织上通常就放在地形填充之后。def build_chunk_mesh(chunk): # 对每个非空气方块检查上下左右前后六个邻居 vertices [] tex_coords [] indices [] vertex_index 0 for x in range(CHUNK_SIZE): for y in range(HEIGHT): for z in range(CHUNK_SIZE): block chunk.blocks[x][y][z] if block 0: continue for face in (top, bottom, left, right, front, back): nx, ny, nz get_neighbor(x, y, z, face) if is_air(chunk, nx, ny, nz): # 为该面增加 4 个顶点和 6 个索引 v face_vertices[face](x, y, z) uv get_face_uv(block, face) vertices.extend(v) tex_coords.extend([uv[0], uv[1], uv[2], uv[1], uv[2], uv[3], uv[0], uv[3]]) idx [ vertex_index0, vertex_index1, vertex_index2, vertex_index0, vertex_index2, vertex_index3 ] indices.extend(idx) vertex_index 4 return vertices, tex_coords, indices刚开始写的时候建议先不做面剔除全量生成六个面确认世界长什么样再打开剔除功能做对比。这一步的肉眼差别会非常明显打开后地形边缘的三角形数量能降一半以上。注意代码里的is_air要处理跨区块的邻居——如果邻居在当前区块外需要向邻接区块查询否则世界边界的方块侧面会永远暴露渲染出多余的面。区块没生成时保守策略是「按空气处理」等邻区块生成了再重建自己。4. 让世界能走、能拆、能看碰撞、移动与射线拾取4.1 方块面剔除与可见性判定比你想的更重要上一段代码里已经出现了面剔除逻辑这里单独展开说是因为它对帧率的影响最大。全量渲染一个 16×16×128 的区块方块总数 32768每个方块六个面、每面四个顶点总计约 786432 个顶点而剔除相邻面之后内部方块完全不产生顶点裸露面可能只有几万个顶点数量级差出十倍不止。实际实现时很多人会误以为「只统计当前区块内邻居」。以left面为例nx x - 1如果nx 0说明它越过区块边界此时应该去chunk_left.blocks[CHUNK_SIZE-1][y][z]查。我在拆过的项目里见过一种折中做法把is_air改成接受一个get_block_at回调内部先做chunk.blocks[x][y][z] 0判断越界则通过World.get_block(wx, wy, wz)去全局查。这样代码更集中也避免每个 Chunk 持有邻接引用导致耦合。代价是多一次函数调用但对 Python 项目来说这点开销在可接受范围内。4.2 射线拾取从相机指向方块的核心算法在体素场景里「点击方块拆/放」需要一条从相机出发的射线穿过网格找到第一个被击中的方块。相比数学上更严格的包围盒求交DDADigital Differential Analyzer步进算法才是体素场景的主流它沿射线逐格前进每步检查当前格子是否有方块命中即返回。def raycast(world, start, direction, max_distance8): x, y, z map(int, start) step_x 1 if direction[0] 0 else -1 step_y 1 if direction[1] 0 else -1 step_z 1 if direction[2] 0 else -1 # 计算到达下一个整数边界的距离 t_max_x ((x (step_x 0)) - start[0]) / direction[0] if direction[0] ! 0 else float(inf) t_max_y ((y (step_y 0)) - start[1]) / direction[1] if direction[1] ! 0 else float(inf) t_max_z ((z (step_z 0)) - start[2]) / direction[2] if direction[2] ! 0 else float(inf) t_delta_x abs(1 / direction[0]) if direction[0] ! 0 else float(inf) t_delta_y abs(1 / direction[1]) if direction[1] ! 0 else float(inf) t_delta_z abs(1 / direction[2]) if direction[2] ! 0 else float(inf) for _ in range(max_distance * 3): if t_max_x t_max_y: if t_max_x t_max_z: x step_x t_max_x t_delta_x else: z step_z t_max_z t_delta_z else: if t_max_y t_max_z: y step_y t_max_y t_delta_y else: z step_z t_max_z t_delta_z block world.get_block(x, y, z) if block ! 0: return (x, y, z) return None这段代码有两个参数值得说。第一是max_distance一般拆方块的距离取 4 到 6 格放置更远可以到 8太远会因为玩家视角晃动出现「没点中」的错觉。第二是循环次数上限max_distance * 3这是一个懒人上限当射线沿对角线穿过网格时走过的格子数约为最大距离的两倍多一点乘以 3 留足余量。如果不开射线命中不了先去查两个方向分量为 0 时的inf边界是不是写对了——如果把float(inf)写成了0会因为t_max永远不增大而卡死循环。4.3 移动、重力与基础碰撞分开做别图省事把碰撞写成「先移动再检测并回退」是初学者最容易踩坑的地方。短距离下看起来能跑一旦速度变大就可能出现「卡进方块里」「踏上地面时穿模半个身位」的情况。这类资源里常见且稳妥的方案是按轴分离def move_and_collide(player, world, dt): dx, dy, dz player.velocity # X 轴 player.position[0] dx * dt if check_collision(world, player.position, player.size): player.position[0] - dx * dt player.velocity[0] 0 # Z 轴 player.position[2] dz * dt if check_collision(world, player.position, player.size): player.position[2] - dz * dt player.velocity[2] 0 # Y 轴重力作用在这 player.velocity[1] - 9.8 * dt player.position[1] dy * dt if check_collision(world, player.position, player.size): player.position[1] - dy * dt player.velocity[1] 0 player.on_ground True else: player.on_ground False按轴分开的核心思想是一次只处理一个轴向的碰撞回退也只回退那个轴。这样在走斜坡、靠近墙体时不会出现「被斜向卡死」的玄学问题。check_collision的实现是取玩家包围盒覆盖到的所有方块逐个检查是否非空气。注意player.size不要用整数 0.6 之类的固定值更合理的是用一个小 AABB如长 0.6、宽 0.6、高 1.8否则在 1.0 格的方格里走很容易出现尺寸刚好大于空隙导致行动阻塞的尴尬局面。参数上玩家宽度取 0.6 是为了在 1 格通道里留出 0.4 的余量跳跃高度与重力9.8相关这里速度与重力需要配合调整重力 9.8、跳跃初速度 6~7 时最多能跳起约 1.8 格刚好够上一格台阶。5. 避坑指南这类资源最容易出现的五个问题5.1 纹理全部上下颠倒或出现错位贴图现象世界能显示但泥土和草皮的方向反了草皮贴到了侧面侧面的树皮贴到了顶面。原因OpenGL 的纹理坐标原点在左下而大多数图集 PNG 资源按左上到右下排列。UV 提取代码里如果没有做 v 翻转就会出现上下颠倒方块六个面的 UV 如果共用同一行格子则会出现侧面与顶面错位。解决在get_face_uv里把返回的 v 坐标统一改为1.0 - v或者加载贴图后调用image.get_texture().set_min_filter(pyglet.gl.GL_NEAREST)并按image.anchor_y image.height调整锚点。最稳的验证方式是单独渲染一个草方块转动视角确认顶面、侧面纹理方向与预期一致再继续后面的工作。5.2 帧率低到个位数GPU 利用率却不高现象窗口能打开但明显卡顿一帧要几十甚至上百毫秒。原因最常见的是初始化时把「所有方块」的六个面全部加进 batch没有做面剔除或者build_chunk_mesh在每次改动了任意方块时对整个地形全量重建。解决按区块维度做面剔除重建玩家拆放方块时只重建对应区块同时检查 batch 构建有没有放在update里被每帧调用。拆这类资源时我习惯在on_draw最开始打印一次顶点数对比面剔除开关前后的数量差距数字直观到不用看帧率曲线。5.3 射线拾取偶尔穿透方块点不中目标现象站得稍微远一点对着草方块点击结果是背后的空气或根本无反应。原因DDA 计算t_max_x时向量分量恰好为 0 或接近 0 导致除法异常或者是射线起点没有取到玩家眼睛位置而取到了脚底位置向下倾斜时射线起点已经陷进地面。解决先打印射线起点和方向确认起点高度然后把起点改为player.position player.eye_height眼睛高度一般取 1.62。距离再调远一点时也顺手提高max_distance的循环上限否则算法会在命中前提前退出。5.4 放置方块时出现半格错位方块悬空或陷进墙里现象点地面放方块方块总是离手边半格远甚至生成在墙体内部。原因拾取射线返回的是「被击中的方块」坐标而放置位置应为「被击中面旁边的坐标」。如果漏掉沿射线方向偏移一步就会出现半格错位如果偏移量用的不是整数步长而是相机方向向量的单位乘积则会因为浮点误差陷入方块内部。解决拿到raycast返回的方块坐标后再按击中面法线方向做整数偏移。常见做法是在raycast里同时返回「击中面法线」然后target hit_pos normal。整数偏移强制为int(normal[0])的形式杜绝半格浮点误差。5.5 修改地形生成参数后已经加载的地块没变化现象把地形frequency从 0.02 调到 0.005重新启动后只有新生成的区块变了老区块还是原来的地形。原因世界种子和区块生成结果被缓存了且缓存没有在参数变化时失效。解决改地形参数后要么删除世界存档/缓存目录要么在World.__init__里加一个version字段与 seed 一起参与区块生成键计算。养成「每次调节参数都确认 seed version 一起变」的习惯就不容易被旧数据误导。这也算我在拆了第三份体素项目后才总结出来的教训。6. 进阶验证给这份资源加一个「可见区块裁剪」的量化对比最后聊一个进阶技巧也是我自己拿到任何体素 Demo 都会先做的验证把「全量遍历所有区块」改成「根据玩家位置只更新附近的区块」然后量化对比帧率。这能帮你快速判断资源本身的瓶颈到底在 CPU 侧的网格生成还是 GPU 侧的绘制调用。# 简单的 3x3 区块视野裁剪 def update_visible_chunks(world, player_pos, radius1): pcx int(player_pos[0]) // CHUNK_SIZE pcz int(player_pos[2]) // CHUNK_SIZE for cx in range(pcx - radius, pcx radius 1): for cz in range(pcz - radius, pcz radius 1): if not world.chunks.get((cx, cz)): world.load_chunk(cx, cz) # 超出半径的区块不主动卸载而是延迟到 5 秒后 # 这样玩家反复横跳时不会频繁重建 mesh这段代码里的radius1意味着只加载 3×39 个区块恰好覆盖一个普通视野范围。更大的radius225 个区块会明显增加加载时间但对现代显卡来说 GPU 仍有余力瓶颈会倒回到 CPU 的 mesh 构建。运行前在on_draw第一行打印len(world.loaded_chunks)和当前帧耗时你可以做三组对照全量加载 64 区块、只裁剪 9 区块、裁剪 9 区块且打开面剔除。我自己跑这类 Demo 的典型结论是面剔除影响最大往往带来 2~3 倍帧率提升区块裁剪影响次之而 lighting 计算如果资源里有的话反而是最小头——除非它写成了每帧全量重算。做完这个验证你对这份资源的上限心里基本有数了如果裁到 9 区块帧率还上不去问题大概率出在顶点格式太冗余或有锁频繁的全局状态如果帧率上去了但地形加载出现缝隙那就要检查跨区块邻居的获取逻辑。从那以后我每次拿到含 Pyglet 的体素项目都会先做一遍「纹理方向确认 面剔除开关对比 区块裁剪量化」这三步整体摸熟之后再去动光源、天空盒这类锦上添花的东西。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询