Python+Pyglet体素世界:OpenGL渲染、面剔除与AABB碰撞

发布时间:2026/11/2 11:30:11
Python+Pyglet体素世界:OpenGL渲染、面剔除与AABB碰撞

简介:这是一份面向Python初学者与编程教育场景的Minecraft风格迷你项目源码,基于Python与Pyglet实现,可用于课堂演示、少儿编程启蒙或自学图形与游戏循环基础。压缩包共4个文件,约16KB,包含可运行的py主程序、说明文档、开源许可证及一张纹理图片,体量轻巧,便于快速解压、阅读与二次修改。已有514人学习下载,说明其在入门练手与教学参考方面具备一定关注度。代码结构简洁,围绕世界搭建、方块设置与基础渲染展开,适合读者理解Pyglet窗口、事件循环和简单三维场景的组织方式,也能作为将Minecraft式项目逐步扩展为可配置教学工具或轻量API的起点。

1. 想搞个能走能挖的 3D 世界,未必要先啃引擎文档

很多 Python 开发者第一次动 3D 的念头,是被引擎文档劝退的:场景树、材质系统、着色器管线,一层套一层,写了三天还没看见一个立方体。这份用 Python + Pyglet 写成的类 Minecraft 体素小程序走的是另一条路——没有引擎,没有资源管线,一个主程序文件加一张方块贴图,窗口、事件循环、OpenGL 渲染全部由 Pyglet 直接接管。地形生成、第一人称相机、方块增删、重力与 AABB 碰撞,全在这份代码里摊开给你看。它适合两类人:一类是写腻了脚本、想摸一摸 3D 渲染但不想被 C++ 和着色器劝退的;另一类是已经会写业务代码,想找一份能整份读透的小工程,搞明白「顶点、批次、面剔除」到底怎么落到键盘鼠标上的。下面按一条完整的运行链路拆,顺手把跑起来会翻车的地方标出来。

2. 技术选型与工程结构:为什么是 Pyglet 直接吃 OpenGL

拿到这个项目,先别急着敲python main.py。花十分钟弄清楚它为什么选 Pyglet、文件各自扛什么职责,后面看代码会顺很多。体素游戏的特殊性在于它不追求「好看的模型」,而是追求「几十万个方块还能在普通机器上跑起来」,这直接决定了技术栈的取舍。

2.1 Pyglet、Pygame、Ursina 的取舍

Pyglet 最大的优势是自带窗口系统和 OpenGL 绑定,一次pip install就能拿到上下文,不像 Pygame 那样需要自己搭渲染管线,也不像 Ursina / Panda3D 那样把渲染封装得太厚——你想看到顶点数据长什么样,在 Ursina 里基本看不到。

体素渲染的核心手法是:把世界表面所有「暴露的面」拼成一串四边形顶点,然后一次性交给显卡。这活儿需要能手动组织vertex_list和Batch的库,Pyglet 的pyglet.graphics.Batch刚好干这个,管线的批量合并、顶点属性格式、纹理绑定都给你留了操作口。

代价也很明确:Pyglet 的生态比 Pygame 单薄,踩到问题搜到的答案少,很多 OpenGL 状态要你自己开、自己关。这份项目的依赖很少,大致是:

依赖作用说明
Python运行时3.8 及以上,写过爬虫的环境基本都能跑
pyglet窗口、事件、OpenGL 渲染1.x 与 2.x 的 API 有差异,后文专门标
Pillow(可选)生成或检查texture.png只在你要改贴图时才需要

不用 numpy 也能跑,因为在顶点数据是list[float]的时候,拼接开销还可以接受。如果你的目标是几十万方块规模,那时候把顶点列表换成array.array('f')比上 numpy 收益更直接。

2.2 目录结构与每个文件的职责

这份项目在结构上保持极简,典型形态是这样:

voxel-demo/ ├── main.py # 全部逻辑:窗口、世界数据、渲染、玩家 ├── texture.png # 方块纹理图集,一张图塞下所有方块面 ├── requirements.txt # 通常只有一行 pyglet └── README.md # 按键说明与启动方式

main.py里通常按段落分块,从上到下大致是:常量区(窗口尺寸、视野角、重力)、方块与面定义、世界数据结构、网格生成、纹理加载、玩家物理、输入回调、渲染循环、程序入口。你读的时候不用从头逐行,直接跳到if __name__ == '__main__':那一行,顺着pyglet.app.run()往回找,链路上每一环都能对上。

texture.png是整套项目里唯一的美术资源,它是一张图集(atlas):横向纵向各切若干格,每格对应一种方块的一个面。常见做法是 16×16 格、每格 16×16 像素,这样一张 256×256 的图就能装下上百种方块面,同时保持像素风的粗糙手感。

提示:图集的行列数不是写死的常量,是你在代码里声明、在贴图里遵守的约定。两边对不上,方块就会显示成错位的色块,这是新人最常翻车的第一处。

2.3 启动骨架与投影矩阵

启动部分本身没什么玄学,一个窗口加一个事件循环就够了:

import pyglet from pyglet.gl import * from pyglet.window import key, mouse WINDOW_W, WINDOW_H = 960, 600 FOV, NEAR, FAR = 70.0, 0.1, 600.0 window = pyglet.window.Window(WINDOW_W, WINDOW_H, caption='voxel demo', resizable=True) window.set_exclusive_mouse(True) # 把鼠标锁进窗口,第一人称必备

set_exclusive_mouse(True)这一句后面还会反复提到。它让鼠标光标消失、移动事件持续产生相对位移,第一人称视角才能转得动。少了这一句,你只能把鼠标顶到屏幕边缘才勉强转半圈。

真正容易出问题的是投影矩阵。老代码习惯用gluPerspective,但 Pyglet 2.x 把pyglet.gl.glu移除了,直接调会抛AttributeError。稳妥写法是自己拼一个透视矩阵,两个版本都能跑:

import math def perspective_matrix(fov_deg, aspect, near, far): """按列主序返回 4x4 透视矩阵,可直接喂给 glLoadMatrixf""" f = 1.0 / math.tan(math.radians(fov_deg) / 2.0) return (f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / (near - far), -1, 0, 0, (2 * far * near) / (near - far), 0)

四个参数里,fov_deg是垂直视野角,第一人称常用 65 到 75;aspect是宽高比,窗口resizable=True时要在on_resize里跟着更新,否则拉伸窗口画面会变形;near和far决定深度精度,near别设得太小,0.05 以下在方块贴脸时容易出现 z-fighting 闪烁。

配合竖屏旋转和位移,一帧的相机设置就是:

def setup_3d(width, height, player): glViewport(0, 0, width, height) glMatrixMode(GL_PROJECTION) glLoadIdentity() glLoadMatrixf((GLfloat * 16)(*perspective_matrix(FOV, width / height, NEAR, FAR))) glMatrixMode(GL_MODELVIEW) glLoadIdentity() pitch, yaw = player.rotation x, y, z = player.position glRotatef(-pitch, 1, 0, 0) # 上下看 glRotatef(-yaw, 0, 1, 0) # 左右转 glTranslatef(-x, -y, -z) # 世界往反方向平移,等价于相机移动

这里最容易搞反的是符号:相机旋转是「把世界反向旋转」,所以pitch和yaw都取负;平移同理,要平移到世界的反方向。第一次改这段代码时如果发现左右转是反的,多半就是这里少了个负号——这几乎是每个手写相机的人都要交的学费。

3. 核心数据结构拆解:方块表、图集坐标与 sector 分块

体素游戏看起来复杂,本质就是把「一个三维格子对应一个方块名」这件事做到足够快。这份项目的做法很朴素但很有效:字典存世界,字典存方块表,渲染时按面剔除。

3.1 方块表与纹理坐标映射

方块的定义不是类,是一行字典。每种方块记三个面在图集里的位置(列、行),顺序是顶面、侧面、底面:

# 值:(顶面(列,行), 侧面(列,行), 底面(列,行)) BLOCKS = { 'grass': ((0, 0), (1, 0), (2, 0)), # 草:绿顶、草边、土底 'sand': ((0, 1), (0, 1), (0, 1)), # 沙:三面同图 'brick': ((0, 2), (0, 2), (0, 2)), 'stone': ((1, 0), (1, 0), (1, 0)), }

命名用字符串而不是枚举,好处是存档直接json.dump就能读回来,少了数字和名字对不上的尴尬。代价是字典查找比整数索引略慢,在几千方块的规模下完全无感。

图集坐标换算要处理一个 OpenGL 的老规矩:纹理坐标原点在左下角,而图片的行是从上往下数的。所以行号要翻转:

TEX_COLS, TEX_ROWS = 16, 16 # 图集切成 16x16 格 DU, DV = 1.0 / TEX_COLS, 1.0 / TEX_ROWS def tex_rect(col, row): """返回 (u0, v0, u1, v1),v 轴已翻转""" u0 = col * DU v1 = 1.0 - row * DV # 该格子的上边界 v0 = v1 - DV # 下边界 return u0, v0, u0 + DU, v1

col、row就是BLOCKS里写的那两个数字,换算完得到的是一个归一化的矩形。这里如果忘了1.0 -,整张图会上下颠倒,草方块的地面被贴到侧面,一眼就能看出来,但排查时新人常常绕远路去怀疑渲染顺序。

3.2 六个面与顶点绕序

面定义是整份代码里最需要小心的地方。每个面记录法线方向、四个顶点相对方块原点的偏移、以及它属于顶面还是侧面:

# (法线方向, 四个顶点偏移, 面类型),顶点按逆时针排列(从外面看) FACES = [ (( 0, 1, 0), [(0,1,0), (0,1,1), (1,1,1), (1,1,0)], 'top'), (( 0, -1, 0), [(0,0,0), (1,0,0), (1,0,1), (0,0,1)], 'bottom'), (( 1, 0, 0), [(1,0,0), (1,1,0), (1,1,1), (1,0,1)], 'side'), ((-1, 0, 0), [(0,0,0), (0,0,1), (0,1,1), (0,1,0)], 'side'), (( 0, 0, 1), [(0,0,1), (1,0,1), (1,1,1), (0,1,1)], 'side'), (( 0, 0, -1),[(0,0,0), (0,1,0), (1,1,0), (1,0,0)], 'side'), ]

绕序这件事必须说清楚:开启背面剔除后,OpenGL 默认认为逆时针绕的三角形是正面。如果某个面的四个顶点写成顺时针,你在外面看它时就什么都看不到,能透过方块看到内壁。判断方法很简单,取前两个边向量做叉乘,结果应该等于法线方向。

法线方向还有一个隐藏用途——判断邻居。(x+1, y, z)在不在世界里,决定了+X这个面要不要画。这个判断是后面面剔除的基础。

3.3 世界存储与 sector 分块

世界不是一个大字典,而是按水平面切成16×16的区块(sector),每个区块自己一个字典:

SECTOR = 16 class World: def __init__(self): self.sectors = {} # (sx, sz) -> {(x, y, z): block_name} self.batches = {} # (sx, sz) -> pyglet.graphics.Batch self.dirty = set() # 需要重建网格的区块键 @staticmethod def sector_key(x, z): return int(x // SECTOR), int(z // SECTOR) def add(self, pos, block): x, y, z = pos key = self.sector_key(x, z) self.sectors.setdefault(key, {})[pos] = block self.dirty.add(key) def remove(self, pos): x, y, z = pos key = self.sector_key(x, z) if pos in self.sectors.get(key, {}): del self.sectors[key][pos] self.dirty.add(key) def get(self, pos): x, y, z = pos return self.sectors.get(self.sector_key(x, z), {}).get(pos) def __contains__(self, pos): return self.get(pos) is not None

为什么要分块?因为每次破坏一个方块,你只需要重建它所在的那个区块的顶点数据,而不是整个世界。区块大小取 16 是个经验值:太小则批次切换频繁,太大则一次重建卡顿明显。int(x // SECTOR)里的int()不能省,Python 的//对负数向下取整,-1 // 16是-1,不加int()在某些写法里会得到奇怪的结果,导致负坐标区域的方块莫名其妙丢失。

dirty集合是这套设计的精髓。任何写操作只打标记,重建动作统一放到帧循环里批量做,避免连续放置多个方块时重复构建顶点。这是个很小的缓存思路,但少了它,连续右键放十几个方块就会明显掉帧。

3.4 网格生成:只画看得见的那一面

有了世界数据和面定义,生成顶点就是纯体力活,但有两个细节必须做对:

import pyglet from pyglet.gl import GL_QUADS def build_batch(world, key, texture): vertices, tex_coords = [], [] for (x, y, z), name in world.sectors[key].items(): top, side, bottom = BLOCKS[name] for normal, quad, kind in FACES: # 邻居存在就不画,这一面永远看不到 if (x + normal[0], y + normal[1], z + normal[2]) in world: continue if kind == 'top': rect = tex_rect(*top) elif kind == 'bottom': rect = tex_rect(*bottom) else: rect = tex_rect(*side) u0, v0, u1, v1 = rect # 顶点顺序与 UV 顺序必须一一对应:左下、左上、右上、右下 uvs = ((u0, v0), (u0, v1), (u1, v1), (u1, v0)) for (ox, oy, oz), (u, v) in zip(quad, uvs): vertices.extend((x + ox, y + oy, z + oz)) tex_coords.extend((u, v)) batch = pyglet.graphics.Batch() if vertices: batch.add(len(vertices) // 3, GL_QUADS, None, ('v3f/static', vertices), ('t2f/static', tex_coords)) return batch

('v3f/static', ...)和('t2f/static', ...)告诉 Pyglet:顶点是 3 个 float,纹理坐标是 2 个 float,/static表示数据不会每帧变动,Pyglet 会把它放进静态缓冲。体素世界的网格只在方块变化时重建,用static是最合适的,用dynamic反而会每帧上传,白白浪费带宽。

UV 和顶点的一一对应很容易搞错。上面代码里quad的顺序是从「该面看过去」的左下、左上、右上、右下,那么 UV 也必须按同样顺序排。顺序错了不会报任何错,只是贴图旋转 90 度或者被拉伸——这种没有异常的错误,只能用眼睛盯出来。

面剔除这一步的效果非常直观:一片 64×64 的平地,如果不剔除要画四万多个面,剔除之后只剩顶面加四周边界,几百个面而已。这就是体素游戏还能在集显上跑起来的原因,也是后面所有性能优化的起点。

4. 渲染与交互链路:一次左键从鼠标到方块消失

前面的数据是静态的,真正让世界「活」起来的是这一条链路:鼠标移动改变朝向 → 键盘改变位置 → 视线射线找到方块 → 修改世界 → 标记区块重建 → 下一帧画出新网格。这条链路上每一环都不长,但环环相扣。

4.1 鼠标视角与键盘状态

视角通常用俯仰角和偏航角两个浮点数表示,存进player.rotation。移动事件的增量直接累加,并用clamp限制俯仰角,防止翻转:

@window.event def on_mouse_motion(x, y, dx, dy): pitch, yaw = player.rotation pitch = min(90.0, max(-90.0, pitch + dy * MOUSE_SENS)) yaw += dx * MOUSE_SENS player.rotation = (pitch % 360.0, yaw % 360.0)

dy直接加而不是减,是为了配合前面相机里glRotatef(-pitch, ...)的取负。这两个符号必须同时考虑,改一个不改另一个,上下看的方向就反了。

键盘状态用 Pyglet 的KeyStateHandler,它替你维护「哪个键正按着」,省得自己写集合:

key_state = key.KeyStateHandler() window.push_handlers(key_state) def movement_vector(rotation, key_state): """把 WASD 换算成世界坐标下的单位方向""" pitch, yaw = rotation strafe = int(key_state[key.D] - key_state[key.A]) # 左右 forward = int(key_state[key.W] - key_state[key.S]) # 前后 if not (strafe or forward): return 0.0, 0.0, 0.0 ry = math.radians(yaw) sin_y, cos_y = math.sin(ry), math.cos(ry) dx = strafe * cos_y - forward * sin_y dz = -strafe * sin_y - forward * cos_y length = math.hypot(dx, dz) return dx / length, 0.0, dz / length

关键在于最后要做归一化。不归一化的话,同时按 W 和 D 会有 1.414 倍速度,斜着走比直走快一大截——这个 bug 几乎所有手写移动的人都会遇到一次,而且往往是玩家先发现而不是作者。

4.2 视线射线:hit_test 的步进逻辑

要挖掉视线正对的方块,就得从眼睛位置沿视线方向一步步走,直到撞进一个非空网格。这里用固定步长的小步进而不是解析求交,代码短、边界情况少:

def hit_test(world, position, direction, max_distance=8.0, step=0.02): """返回 (命中的方块坐标, 命中前的那一格),没命中返回 (None, None)""" x, y, z = position dx, dy, dz = direction norm = math.sqrt(dx * dx + dy * dy + dz * dz) or 1.0 dx, dy, dz = dx / norm, dy / norm, dz / norm prev = None t = 0.0 while t <= max_distance: cell = (int(math.floor(x + dx * t)), int(math.floor(y + dy * t)), int(math.floor(z + dz * t))) if cell != prev and cell in world: return cell, prev # prev 用于「在它旁边放新方块」 prev = cell t += step return None, None

三个参数值得说明:max_distance是手能够到的距离,默认 8 格,改成 32 就是「长臂猿模式」,调试时很好用;step决定精度,0.02 意味着大约每 1/50 格采样一次,太小会拖慢帧率,太大则会漏掉贴着边角的薄方块;起点要用眼睛位置而不是脚底位置,否则你低头只能挖到脚下那一格。

返回值里第二个prev是命中格子的前一格,放置方块时就放在那里。它同时也是「不许把方块放到自己身上」这个检查的依据——如果prev落在玩家自己的碰撞盒里,这次放置就得拒掉,否则你会把自己卡在方块里动不了。

4.3 左键挖、右键放

输入回调把射线和世界写操作连起来:

@window.event def on_mouse_press(x, y, button, modifiers): direction = sight_vector(player.rotation) origin = eye_position(player) # 通常是 position 加上身高 hit, prev = hit_test(world, origin, direction) if not hit: return if button == mouse.LEFT: world.remove(hit) elif button == mouse.RIGHT and prev is not None: if prev not in player_occupied_cells(player): world.add(prev, player.selected_block)

player_occupied_cells是玩家碰撞盒覆盖的整数格集合,写成生成器即可。这个检查漏了的话,右键往脚底下放方块会把自己封进去,然后你会发现人物完全不能动,也跳不出来——除非退出重进,这就是典型的「后悔药」现场。

每次remove/add都会往dirty里塞一个区块键,帧循环里统一处理:

def update(dt): player.update(dt, world) for key in list(world.dirty): world.batches[key] = build_batch(world, key, texture) world.dirty.discard(key)

注意list(world.dirty)这一手。直接在集合上迭代的同时删除会抛RuntimeError: Set changed size during iteration,这是 Python 里最典型的迭代中修改容器的坑。

4.4 玩家物理:重力、跳跃与逐轴碰撞

体素游戏的碰撞不需要物理引擎,因为障碍物全是规整的格子。用 AABB 对格子做重叠检测,然后逐轴处理就够:

def intersects(world, pos, height=1.8, radius=0.3): """玩家 AABB 是否与任何方块重叠""" x, y, z = pos for bx in range(int(math.floor(x - radius)), int(math.floor(x + radius)) + 1): for by in range(int(math.floor(y)), int(math.floor(y + height)) + 1): for bz in range(int(math.floor(z - radius)), int(math.floor(z + radius)) + 1): if (bx, by, bz) in world: return True return False def move_with_collision(world, pos, delta): """分三轴推进,某一轴被挡住就回退该轴,其余轴继续""" pos = list(pos) for axis in range(3): if delta[axis] == 0: continue pos[axis] += delta[axis] if intersects(world, pos): pos[axis] -= delta[axis] # 该轴回退,其他轴不受影响 return tuple(pos)

分轴推进的意义在于「贴墙滑行」:你斜着撞墙时,X 方向被挡住但 Z 方向仍能走,手感就是顺着墙滑过去,而不是整个卡死。如果一次性把三轴位移全加上再判断,撞墙就是硬停,操作起来会非常别扭。

竖直方向的特殊处理是落地:

def update(self, dt, world): dx, dy, dz = movement_vector(self.rotation, key_state) mx, mz = dx * FLY_SPEED * dt, dz * FLY_SPEED * dt if self.flying: my = (int(key_state[key.SPACE]) - int(key_state[key.LSHIFT])) * FLY_SPEED * dt self.position = move_with_collision(world, self.position, (mx, my, mz)) return self.dy -= GRAVITY * dt self.dy = max(self.dy, -TERMINAL_SPEED) if key_state[key.SPACE] and self.on_ground: self.dy = JUMP_SPEED before = self.position self.position = move_with_collision(world, before, (mx, self.dy * dt, mz)) # Y 轴没走动,说明撞到了地面或者天花板 self.on_ground = (self.position[1] == before[1] and self.dy <= 0) if self.position[1] == before[1]: self.dy = 0.0

把GRAVITY设在 20 到 30、JUMP_SPEED设在 8 到 9、TERMINAL_SPEED设在 50 左右,手感接近常见的体素沙盒。on_ground的判定不要去检测「脚下一格是不是实心」,直接看这一帧 Y 轴有没有被回退更准,因为回退就是把碰撞结果用上了。

5. 跑通与排查:环境、黑屏和那几条血泪经验

代码读得再顺,跑不起来都是空谈。这一章按「先能开窗 → 再能看见世界 → 最后能操作」的顺序走,中间穿插我在类似项目上踩过的几个坑。

5.1 环境搭建与第一次启动

Python 版本建议 3.8 以上,用虚拟环境隔离,避免和系统里的其他包打架:

# 1. 建环境 python -m venv .venv # 2. 激活(Windows 用 .venv\Scripts\activate) source .venv/bin/activate # 3. 装依赖,先锁 1.x 这一条线,跑通再考虑升级 pip install "pyglet<2" # 4. 确认资源文件和入口在同一目录 ls main.py texture.png # 5. 启动 python main.py

为什么建议先用 1.x?因为这份项目里大量使用了固定管线接口:glMatrixMode、glLoadIdentity、glRotatef、GL_QUADS。这些在 Pyglet 2.x 里大部分还能用,但默认 shader 和上下文的行为有变化,第一次跑的时候变量越多越难定位问题。等画面正常了,再照着第 2 章给的透视矩阵换成 2.x 也来得及。

启动正常的话,你会看到窗口打开、鼠标消失、画面中央一个十字准星,脚下一片方块地面,远处隐约有起伏。按 W 前进、空格跳跃、左键挖、右键放。这些按键通常在README.md里列着,如果没列,对着on_key_press里的分支看一遍最准确。

5.2 关键参数速查表

调参是这类项目最直接的二次开发方式,改几个数字,手感立刻不一样。下面这些是最常动的:

参数常见位置典型值调大 / 调小的效果
FOV常量区70调大到 90 视野更广但有鱼眼畸变
FAR常量区600调小能提升深度精度,但远处地形会被裁掉
SECTOR常量区16调大减少批次数量,但单次重建更卡
FLY_SPEED常量区5~10调大跑图快,调试时调到 30 很爽
GRAVITY常量区20~30调小跳跃更飘,调大会有坠落感
max_distancehit_test默认参数8调到 32 就是「长臂猿」,方便远处施工
stephit_test默认参数0.02调大到 0.1 会漏掉斜角处的薄方块

SECTOR这个值特别值得试一次:从 16 改成 8,再改成 32,用F3或者自己加个帧率显示,观察你机器上的表现差异。很多优化决策不是靠推理,是靠这两个数字对比出来的。

5.3 常见问题排查

现象一:窗口开了,但画面全黑,只有准星和字母。

原因通常是渲染顺序或者状态没设对。缺glEnable(GL_DEPTH_TEST)会导致后画的盖住先画的,缺glEnable(GL_CULL_FACE)则会把面朝里的三角形都画出来,两者叠加就是一团糊。另一个可能是on_draw里忘了调window.clear(),或者 clear 之后又重新绑了一次错误的纹理。

解决顺序是:先在on_draw开头把glClearColor(0.5, 0.7, 1.0, 1.0)设成天空蓝再 clear,如果背景还是黑的,说明on_draw根本没进去;如果背景是蓝的但方块不见,就断点在build_batch再看len(vertices),为 0 就说明世界是空的或者剔除逻辑把面全剔掉了。这个二分法比猜快得多。

现象二:AttributeError: module 'pyglet.gl' has no attribute 'glu'。

原因很明确:装了 Pyglet 2.x,而这个版本移除了 GLU 辅助库,老代码里的gluPerspective、gluLookAt全部失效。

解决有两条路:一是pip install "pyglet<2"退回 1.x,最快;二是改用第 2 章给的perspective_matrix手写矩阵,配合glLoadMatrixf,这样 1.x 和 2.x 都能跑。我一般选第二条,因为迟早要升。

现象三:方块之间有细缝,或者贴着脸看时表面闪烁。

细缝是纹理采样的问题。默认的线性过滤会在格子边缘插值到相邻格的颜色,方块之间就出现一条半透明的线。解决是把过滤方式改成最近邻:

texture = pyglet.image.load('texture.png').get_texture() glBindTexture(GL_TEXTURE_2D, texture.id) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST)

表面闪烁则是 z-fighting,根源在NEAR设得太小或者方块面完全共面。把NEAR从 0.01 提到 0.1,通常立刻消失。

现象四:鼠标一动视角就疯转,或者鼠标移出窗口就转不动了。

前者是灵敏度乘子太大,或者on_mouse_motion里对dx、dy又做了一次缩放。把MOUSE_SENS从 0.15 降到 0.05 先试,如果还是转得离谱,检查是不是同时注册了on_mouse_drag和on_mouse_motion,两个回调都在改角度就会翻倍。

后者是没开独占鼠标。window.set_exclusive_mouse(True)要在窗口创建后立刻调用,并且在on_deactivate里记得放开,否则切出去以后鼠标在别的程序里点不了东西。

现象五:放几十个方块以后帧率掉到个位数。

这大概率不是显卡的问题,是每帧都在重建整个世界的网格。检查update里是不是直接遍历了所有 sector 重建,而不是只处理dirty集合;再检查batch.add用的属性格式是static还是dynamic,后者每帧上传顶点,方块一多就废了。

还有一种情况是hit_test的step设得过小,比如 0.001,每帧要采样几千次,而且on_mouse_press里调一次就够,如果误写进了update就会持续拖慢。

6. 二次开发:加方块、存世界与把性能再往前推一步

摸清楚结构之后,最想做的三件事通常是:加自己的方块、把世界存下来、让它在更多方块下还能跑。这三件事都不需要动主链路,属于典型的增量改造。

加方块是纯粹的查表工作,改两处就行。先在texture.png里空出一格画上新纹理,然后在BLOCKS里加一行:

BLOCKS['glowstone'] = ((3, 3), (3, 3), (3, 3)) # 顶、侧、底都指向图集第 3 行第 3 列

再在按键回调里绑一个数字键切换player.selected_block。注意图集的行列数一旦改了,TEX_COLS/TEX_ROWS必须同步,否则所有方块的 UV 都会整体错位,而且错得毫无规律。

存档比想象中简单,因为世界本来就是「坐标元组 → 字符串」的字典,序列化直接摊平就行:

<p> <a href="https://download.csdn.net/download/weixin_42099858/15496767" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>