☰
Python+Pyglet体素引擎:纹理图集、噪声地形与射线拾取
2026/10/12 6:25:52 网站建设 项目流程

简介:基于 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(width=1024, height=768, caption="Voxel Demo") world = World(seed=2024001) # 生成一个 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] 存方块 id,0 表示空气 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, octaves=4, persistence=0.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.65;frequency缩放系数(这里的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_index+0, vertex_index+1, vertex_index+2, vertex_index+0, vertex_index+2, vertex_index+3 ] 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 射线拾取:从相机指向方块的核心算法

在体素场景里「点击方块拆/放」需要一条从相机出发的射线穿过网格,找到第一个被击中的方块。相比数学上更严格的包围盒求交,DDA(Digital Differential Analyzer)步进算法才是体素场景的主流:它沿射线逐格前进,每步检查当前格子是否有方块,命中即返回。

def raycast(world, start, direction, max_distance=8): 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, radius=1): 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

这段代码里的radius=1意味着只加载 3×3=9 个区块,恰好覆盖一个普通视野范围。更大的radius=2(25 个区块)会明显增加加载时间,但对现代显卡来说 GPU 仍有余力,瓶颈会倒回到 CPU 的 mesh 构建。运行前在on_draw第一行打印len(world.loaded_chunks)和当前帧耗时,你可以做三组对照:全量加载 64 区块、只裁剪 9 区块、裁剪 9 区块且打开面剔除。我自己跑这类 Demo 的典型结论是:面剔除影响最大,往往带来 2~3 倍帧率提升;区块裁剪影响次之;而 lighting 计算(如果资源里有的话)反而是最小头——除非它写成了每帧全量重算。

做完这个验证,你对这份资源的上限心里基本有数了:如果裁到 9 区块帧率还上不去,问题大概率出在顶点格式太冗余或有锁频繁的全局状态;如果帧率上去了但地形加载出现缝隙,那就要检查跨区块邻居的获取逻辑。从那以后,我每次拿到含 Pyglet 的体素项目,都会先做一遍「纹理方向确认 + 面剔除开关对比 + 区块裁剪量化」这三步,整体摸熟之后再去动光源、天空盒这类锦上添花的东西。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询