news 2026/10/29 5:17:39

Python+Pyglet 体素 3D 渲染与碰撞拾取实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python+Pyglet 体素 3D 渲染与碰撞拾取实战

简介:面向想用 Python 入门游戏编程的初学者、青少年及其指导教师,这份受 Minecraft 启发的简单演示以 Pyglet 构建基础三维场景,帮助读者理解方块世界渲染、窗口循环与简单交互的实现思路,也适合作为课堂教学或亲子编程的示例项目。压缩包共 4 个文件,约 16KB,包含 Python 主程序、Markdown 说明、开源授权文本与一张纹理图片,体量轻巧,下载后便于阅读和改造。已有 512 人学习,说明其在轻量级 Python 游戏示例中具有一定参考价值。代码结构简洁,读者可据此观察世界初始化、方块设置与渲染流程,并尝试调整参数、替换纹理或扩展方块交互;说明文档提供运行与项目背景信息,授权文件则方便确认复用条件,适合作为二次开发和学习笔记的起点。

1. 从按下 W 说起:这套 Python 体素 Demo 到底能干什么

鼠标被锁进窗口中央,按下 W,脚下一格草方块朝后退去,视角随鼠标左右转,右键点掉一块石头,它当场消失并留下一个凹坑。整套第一人称体验,压在一个用 Python + Pyglet 写成的小目录里,没有游戏引擎,没有 C++ 扩展,装两条依赖就能在集显笔记本上跑起来。它适合三类人:想弄明白 3D 渲染管线却不愿啃图形学教材的 Python 后端、需要一个「看得见」的教学 Demo 的老师,以及想拿一套能改的体素底盘做二次开发的独立开发者。代码不长,但投影矩阵、纹理图集、面剔除、AABB 碰撞、DDA 射线拾取这些真正值钱的东西,一个都没少。

2. 项目定位与技术选型:Pyglet 这套栈凭什么撑起方块世界

2.1 功能清单:哪些是真能玩的,哪些只是示意

先把预期摁住。这类受经典方块沙盒启发的教学型体素 Demo,功能边界通常划得很清楚:一份固定尺寸的地形(常见是 64×64 到 128×128 的区块),玩家出生在地表上方,受重力下落、站定、可以跳跃,撞墙会停住,走不出地图边缘;鼠标控制朝向,WASD 平移,空格起跳,左键放方块、右键挖方块,屏幕角落挂一个 FPS 数字。

它不会给你的部分同样明确:没有光照传播和昼夜循环,没有区块流式加载(世界就那么大,一次性生成完),没有存档,没有生物、没有红石、没有合成表,也没有音效。如果你要找的是一个能直接发布的游戏成品,这份资源会让你失望;但如果你要的是一个「每一行都能读懂、改一行就能看到效果」的体素底盘,它恰好停在最合适的位置上——比 Hello Triangle 复杂得多,又没复杂到需要读一个月。

我一般把这类项目当成三层来看:最底下是 OpenGL 状态机(投影、深度、剔除、纹理),中间是世界数据与网格构建(方块怎么存、面怎么拼),最上面是输入与物理(鼠标视角、碰撞、拾取)。三层之间靠一个World对象和一份blocks字典连着,拆起来特别干净,这也是它适合拿来教学和二次开发的根本原因。

2.2 技术栈拆解:Pyglet 负责什么,OpenGL 老管线负责什么

选 Pyglet 而不是 Pygame,是个很实际的判断。Pyglet 自带窗口创建、事件循环、OpenGL 上下文绑定,还提供了pyglet.graphics.Batch这种顶点批次工具——你可以把成百上千个四边形塞进一个 Batch,最后只调一次batch.draw(),省掉大量 glBegin/glEnd 的胶水代码。而 Pygame 的 3D 能力约等于零,想画立方体得自己接 PyOpenGL,多一层依赖也多一层坑。

依赖清单通常只有三样:pyglet本体、noise(Perlin/Simplex 噪声,用来生成地形起伏)、以及可选的numpy(世界大了以后用它存方块数组和批量拼顶点,性能比纯 Python 列表高一个数量级)。不用 numpy 也能跑,只是当地形尺寸往上翻的时候会明显吃力。

提示:这类项目大量使用glMatrixMode、glBegin年代的固定管线 API,配 Pyglet 1.5.x 最稳。装 2.x 有可能遇到pyglet.gl里某些符号没了、或者默认走 core profile 导致老函数直接报错,第一次跑建议先锁版本。

技术栈里真正承担工作的是 OpenGL 的三件事:投影矩阵把 3D 坐标压成 2D,深度测试决定谁挡住谁,背面剔除保证你看不到方块的内壁。Pyglet 只是把窗口和 GL 上下文递到你手上,剩下的矩阵和状态全得自己填——这恰恰是这份代码最值得读的地方,因为它把平时被引擎藏起来的东西摊开了。

2.3 目录结构与三步启动

代码量小,目录结构一般也就四五个文件,按职责切:

voxel-demo/ ├── main.py # 入口:建窗口、绑事件、起 pyglet.app.run() ├── world.py # 方块表、地形生成、网格构建、增删方块 ├── render.py # 投影/视图矩阵、纹理图集、Batch 与绘制 ├── player.py # 相机位置、重力、碰撞、射线拾取 └── assets/ └── texture.png # 4x4 的贴图图集,草/土/石/木/叶都在里面

跑起来通常就三步。第一步建虚拟环境,别把系统 Python 搞脏:

python -m venv .venv source .venv/bin/activate # Windows 上是 .venv\Scripts\activate

第二步装依赖。noise在某些 Python 版本上需要本地编译,装不上就换纯 Python 的噪声实现,或者直接把地形函数里的噪声调用换成math.sin叠加,效果差一点但能跑:

pip install pyglet==1.5.27 noise

第三步直接运行。启动后窗口会抢走鼠标,按 Esc 释放,关窗口退出:

python main.py

启动失败的排查顺序是:先看终端报错(ImportError是依赖问题,ContextException是显卡/GL 版本问题),再看窗口是不是黑屏(黑屏基本是矩阵没设对或者深度测试没开),最后才怀疑代码逻辑。这个顺序能省掉一大半无效调试。

3. 渲染主链路:从一张贴图图集到一次 batch.draw()

3.1 窗口与两个矩阵:把摄像机摆进世界

OpenGL 里没有「摄像机」这个东西,只有矩阵。视图矩阵的作用是把整个世界反向平移旋转,让摄像机回到原点看向 -Z。Pyglet 早期版本自带gluLookAt,但为了不依赖具体版本,我一般手写一份,二十行不到:

# render.py —— 手写视图矩阵,避免 gluLookAt 在部分版本里缺失 import math from pyglet.gl import * def look_at(eye, target, up=(0.0, 1.0, 0.0)): ex, ey, ez = eye # 视线方向 f = normalize(target - eye) fx, fy, fz = target[0] - ex, target[1] - ey, target[2] - ez fl = math.sqrt(fx * fx + fy * fy + fz * fz) or 1.0 fx, fy, fz = fx / fl, fy / fl, fz / fl # 右向量 s = f x up,上向量 u = s x f sx, sy, sz = fy * up[2] - fz * up[1], fz * up[0] - fx * up[2], fx * up[1] - fy * up[0] sl = math.sqrt(sx * sx + sy * sy + sz * sz) or 1.0 sx, sy, sz = sx / sl, sy / sl, sz / sl ux, uy, uz = sy * fz - sz * fy, sz * fx - sx * fz, sx * fy - sy * fx # OpenGL 收的是列主序 16 元组,注意转置 m = (sx, ux, -fx, 0.0, sy, uy, -fy, 0.0, sz, uz, -fz, 0.0, 0.0, 0.0, 0.0, 1.0) glLoadMatrixf((GLfloat * 16)(*m)) glTranslatef(-ex, -ey, -ez) # 先转基向量,再反向平移

三个参数里,eye是玩家眼睛位置(脚底坐标 + 1.6 左右),target是eye加上朝向向量,up保持(0,1,0)就够。target千万别写死成(0,0,0),否则你一走动画面就开始歪——这是新手最常见的翻车点。投影矩阵那边用gluPerspective(fov, w/h, near, far),fov取 70 度左右视野舒服,near别小于 0.1,far别超过地图对角线的两倍,否则深度精度不够会出现远处方块闪烁的 z-fighting。

3.2 贴图图集与最近邻采样:像素风的成本最低做法

体素游戏不会给每个方块单独一张贴图——那意味着上千次纹理绑定,帧率立刻塌。标准做法是把所有方块面拼成一张图集,比如 4×4 的网格装 16 种面,绘制时靠 UV 坐标决定取哪一格。

# render.py —— 把图集切分成 [0,1] 区间内的 UV def tex_coord(col, row, n=4): """col/row 是图集里的格子序号(左上角为 0,0),n 是图集边长""" m = 1.0 / n x0, y0 = col * m, row * m return x0, y0, x0 + m, y0 + m

关键在于采样方式。默认的双线性插值会把相邻格子的像素「糊」到边缘,一堵草墙立刻出现一圈脏边。必须把放大过滤设成GL_NEAREST:

# 加载图集后立刻设置,注意 MIN_FILTER 也一起改掉 texture = pyglet.image.load('assets/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) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)

CLAMP_TO_EDGE是为了防止 UV 正好落在 1.0 时采样到图集外的空白。如果你自己做图集,还建议每个格子四周留 1 像素的出血边——显卡在浮点误差下偶尔会采到隔壁格子的第一个像素,加了边就彻底没事。

3.3 顶点批次与面剔除:一个方块最多只画三个面

一个 64×64 的地图,光地表就四千多个方块,每个方块 6 个面,那就是两万多个四边形。但如果邻居是实心方块,这个面永远看不见,画它纯属浪费。构建网格时先判断邻居,只把暴露在外的面塞进批次:

# world.py —— 只生成「看得见」的面 import pyglet from pyglet.gl import GL_QUADS # 六个面:法线方向 + 四个角点(相对坐标,逆时针 = 正面朝向观察者) FACES = { 'top': ((0, 1, 0), [(0, 1, 0), (0, 1, 1), (1, 1, 1), (1, 1, 0)], (0, 0)), 'bottom': ((0, -1, 0), [(0, 0, 1), (0, 0, 0), (1, 0, 0), (1, 0, 1)], (0, 1)), 'front': ((0, 0, 1), [(0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)], (2, 0)), 'back': ((0, 0, -1), [(1, 0, 0), (0, 0, 0), (0, 1, 0), (1, 1, 0)], (2, 0)), 'right': ((1, 0, 0), [(1, 0, 1), (1, 0, 0), (1, 1, 0), (1, 1, 1)], (2, 0)), 'left': ((-1, 0, 0), [(0, 0, 0), (0, 0, 1), (0, 1, 1), (0, 1, 0)], (2, 0)), } def build(self): """方块有变动时整体重建,比逐个删改顶点省心得多""" self.batch = pyglet.graphics.Batch() for (x, y, z), kind in self.blocks.items(): for normal, corners, (col, row) in FACES.values() if False else []: pass

上面最后那段是骨架示意,真正跑起来要按方块类型取每个面的图集格子,完整写法是这样:

def build(self): self.batch = pyglet.graphics.Batch() for (x, y, z), kind in self.blocks.items(): for name, (normal, corners, tex) in BLOCK_FACES[kind].items(): # 邻居是实心方块,这个面永远看不到,直接跳过 if (x + normal[0], y + normal[1], z + normal[2]) in self.blocks: continue verts = [] for cx, cy, cz in corners: verts += [x + cx, y + cy, z + cz] u0, v0, u1, v1 = tex_coord(*tex) uvs = [u0, v0, u1, v0, u1, v1, u0, v1] # 与 corners 顺序一一对应 self.batch.add(4, GL_QUADS, self.texture_group, ('v3f/static', verts), ('t2f/static', uvs))

('v3f/static', verts)里的static是告诉 Pyglet 这批顶点不会每帧变,可以放进显存不动;如果世界每帧都改,就得用dynamic,反而更慢。BLOCK_FACES[kind]是一个方块类型到六个面贴图坐标的映射,草方块顶面用草的格子、侧面用带草边的格子、底面用土,全靠这张表区分——加新方块本质上就是往这张表里加一行。

3.4 每帧的 on_draw:清屏、装载矩阵、一次绘制

绘制函数短得出奇,因为所有重活都在build()里做完了:

@window.event def on_draw(): window.clear() # 清颜色 + 清深度缓冲 glEnable(GL_DEPTH_TEST) # 缺这一行,近处方块会被远处覆盖 glEnable(GL_CULL_FACE) # 背面剔除,省一半面 glCullFace(GL_BACK) glMatrixMode(GL_PROJECTION) glLoadIdentity() gluPerspective(70.0, window.width / max(window.height, 1), 0.1, 300.0) glMatrixMode(GL_MODELVIEW) glLoadIdentity() look_at(player.eye, player.target) world.batch.draw() # 一次调用画完整个世界 hud.draw() # FPS 文字,用正交投影单独画

这里的顺序不能乱:先清屏再开状态,先投影再视图。window.clear()默认同时清颜色和深度,如果你手动改成只清颜色,上一帧的深度值会残留,表现为走动时画面出现诡异的黑块和穿透——这类玄学问题九成出在状态没清干净。HUD 文字部分要临时切到正交投影(glOrtho(0, width, 0, height, -1, 1)),画完再切回来,否则 2D 文字会被透视矩阵拉到天边去。

4. 世界数据与玩家交互:区块存储、噪声地形、射线挖块

4.1 方块表与坐标寻址:字典够用,数组留着以后换

最省事的存储结构就是{(x, y, z): block_id}。查找是 O(1),增删方块一行代码,不用管世界边界,也不会因为地图变大就把内存撑爆。代价是每个方块要额外存一个元组和哈希开销,几万个方块时内存大概几十兆,能接受;上百万方块就得上numpy三维数组加区块切分了。

# world.py —— 方块常量与类型表 AIR, GRASS, DIRT, STONE, WOOD, LEAVES = 0, 1, 2, 3, 4, 5 # 每种方块六个面用图集里哪个格子:(列, 行) BLOCK_FACES = { GRASS: {'top': ((0, 0, 0), None, (0, 0)), 'bottom': ((0, 0, 0), None, (2, 0)), # 侧面、前后、左右同理,都指向带草边的格子 }, } class World: def __init__(self): self.blocks = {} # (x, y, z) -> block_id,0 表示空气不存 def get(self, x, y, z): return self.blocks.get((int(x), int(y), int(z)), AIR) def add(self, x, y, z, kind): self.blocks[(int(x), int(y), int(z))] = kind self.build() # 简单项目直接整重建,别做增量 def remove(self, x, y, z): self.blocks.pop((int(x), int(y), int(z)), None) self.build()

add/remove里直接调build()是刻意为之的偷懒。整重建一次几万个方块大概几十毫秒,手动挖一块方块会有一次肉眼可见的卡顿,但代码简单到不会出错。真要顺滑,就得做脏区块标记,只重建受影响的那个区块——那是后面二次开发章节的事。

4.2 噪声地形:两层正弦叠加出的山丘

地形生成的核心是「高度图」:对每个 (x, z) 算一个高度 y,然后把这一列从基岩填到高度,顶层放草、下面几层放土、再往下全是石头。高度用噪声算,别用随机数,否则得到的是锯齿状的针林。

# world.py —— 两层噪声:一层大起伏,一层小碎块 from noise import snoise2 def generate(width=64, depth=64, seed=2024): blocks = {} for x in range(width): for z in range(depth): # 低频噪声出丘陵,高频噪声出细节,振幅按比例缩放 h = snoise2(x / 24.0, z / 24.0, octaves=3, base=seed) * 6.0 h += snoise2(x / 8.0, z / 8.0, base=seed) * 1.5 top = int(round(h)) + 8 # 抬高基线,免得地形掉到负数 for y in range(top + 1): if y == top: blocks[(x, y, z)] = GRASS elif y > top - 4: blocks[(x, y, z)] = DIRT else: blocks[(x, y, z)] = STONE return blocks

snoise2返回 -1 到 1 的浮点,乘 6 得到 ±6 的高度变化,再叠一层 ±1.5 的细节。octaves=3让噪声自带三个频率的分形叠加,比单层自然得多。base=seed保证同一个种子每次生成的世界完全一样,这点很重要——调试碰撞和出生点时,你不想每次重启都站在不同的山坡上。想换风格就调这两个除数:除得越大越平坦,除得越小越破碎。

4.3 逐轴 AABB 碰撞:为什么不能三个轴一起判

玩家不是一个点,是一个轴对齐包围盒:水平半径 0.3,高度 1.8。碰撞检测就是把这个盒子覆盖的所有方块格子遍历一遍,只要有一格不是空气就算撞上。

# player.py —— 轴对齐包围盒与方块格子求交 import math def collides(blocks, x, y, z): """x,z 是玩家中心,y 是脚底;盒子水平 ±0.3,竖直 0 ~ 1.8""" for bx in range(int(math.floor(x - 0.3)), int(math.floor(x + 0.3)) + 1): for by in range(int(math.floor(y)), int(math.floor(y + 1.8)) + 1): for bz in range(int(math.floor(z - 0.3)), int(math.floor(z + 0.3)) + 1): if (bx, by, bz) in blocks: return True return False

真正容易翻车的是移动。三个轴一起加,玩家斜着撞墙时会被判定为「整体碰撞」然后原地卡死,或者从墙角穿过去。正确做法是一个轴一个轴地试:

def move(blocks, x, y, z, dx, dy, dz): if dx and not collides(blocks, x + dx, y, z): x += dx if dz and not collides(blocks, x, y, z + dz): z += dz if dy and not collides(blocks, x, y + dy, z): y += dy else: dy = 0.0 # 撞地或撞头,竖直速度立刻清零 return x, y, z, dy

先水平后竖直的顺序有讲究:先走水平,玩家能贴着墙滑行;再走竖直,落地判定才准。dy那个else分支是重力系统的关键——不清零的话,玩家会带着累积的向下速度一直「按」在地面上,某帧 dt 抖一下就直接沉进方块里,这是最经典的血泪经验。

4.4 DDA 射线拾取:右键挖块和左键放块的落点计算

玩家点到的是屏幕,要换成世界里的方块,就得从眼睛沿视线方向发一条射线,找第一个命中的方块。逐个小步长采样太慢也不准,标准解法是 Amanatides & Woo 的体素遍历:每次只跨越一个格子边界,代价是 O(距离)。

# player.py —— DDA 体素步进,返回 (命中的方块, 命中前的空格) def raycast(blocks, origin, direction, max_dist=8.0): ox, oy, oz = origin dx, dy, dz = direction x, y, z = int(math.floor(ox)), int(math.floor(oy)), int(math.floor(oz)) sx = 1 if dx > 0 else -1 sy = 1 if dy > 0 else -1 sz = 1 if dz > 0 else -1 inf = float('inf') # 走满一格需要的参数增量 tdx = abs(1.0 / dx) if dx else inf tdy = abs(1.0 / dy) if dy else inf tdz = abs(1.0 / dz) if dz else inf # 到下一个格边界的参数距离 tmx = ((x + (1 if dx > 0 else 0)) - ox) / dx if dx else inf tmy = ((y + (1 if dy > 0 else 0)) - oy) / dy if dy else inf tmz = ((z + (1 if dz > 0 else 0)) - oz) / dz if dz else inf prev, t = None, 0.0 while t <= max_dist: if (x, y, z) in blocks: return (x, y, z), prev # prev 就是「放方块」该落的位置 prev = (x, y, z) if tmx < tmy and tmx < tmz: x += sx; t = tmx; tmx += tdx elif tmy < tmz: y += sy; t = tmy; tmy += tdy else: z += sz; t = tmz; tmz += tdz return None, None

max_dist就是手能够到的距离,取 8 格体验比较好,取 64 你能隔空拆山。返回两个坐标是因为挖和放需要的不是同一个点:右键挖掉hit,左键要在prev(命中前经过的那个空气格)放新方块。如果只返回一个值,放方块就会把新方块塞进石头内部,表现为「点了没反应」,这个坑几乎所有人体素项目都会踩一次。

5. 踩坑排查:启动就崩、卡成幻灯片、视角乱飞

5.1 启动报错或者一片黑屏

现象:终端一行红字直接退出,提示ImportError或者ContextException;或者窗口出来了但全黑,只有标题栏。

原因:前者大概率是 Pyglet 版本对不上——教程用glBegin、glMatrixMode这套固定管线,2.x 默认会请求 core profile,这些函数根本不存在;后者是 GL 状态没设全,最常见的是漏了glEnable(GL_DEPTH_TEST),或者投影矩阵压根没设(默认是单位矩阵,所有东西都被压在 -1 到 1 的裁剪空间外)。

解决:先锁版本pip install pyglet==1.5.27;还黑屏就在on_draw开头把glClearColor(0.5, 0.7, 1.0, 1.0)加上,如果背景变成天蓝色,说明窗口和清屏没问题,问题一定在矩阵或深度;如果背景还是黑的,那就是on_draw根本没被调用,检查事件绑定写没写@window.event。

5.2 帧率掉到个位数

现象:刚进去还能动,走两步越来越卡,FPS 从 60 掉到 5,任务管理器里 CPU 单核跑满、GPU 占用却很低。

原因:典型是每帧都调了world.build()。构建网格是纯 Python 循环,几万个方块、十几万个顶点,一帧拼一次必然崩。次一级的原因是没做面剔除,地下几万块石头的六面全在画,GPU 被无谓的几何拖死。

解决:把build()从on_draw和update里彻底挪走,只在方块增删后调一次。再检查FACES循环里那句邻居判断,很多人抄代码时把它注释掉了。还有个隐藏坑是('v3f/dynamic', ...)——顶点声明成 dynamic 会每帧重传显存,静止的世界应该用static。

注意:加个 FPS 显示很有必要。Pyglet 的window.set_caption(f'FPS: {pyglet.clock.get_fps():.0f}')一行就够,比事后猜快得多。

5.3 鼠标视角乱飞、退不出窗口

现象:鼠标一动视角就翻到天花板,或者鼠标跑到窗口外、点不到关闭按钮,Esc 也退不出去。

原因:on_mouse_motion里用了绝对坐标x, y而不是相对增量dx, dy。绝对坐标在鼠标锁定后会在窗口范围内跳变,视角自然乱。至于退不出去,是没调set_exclusive_mouse(False)也没处理on_key_press里的 Esc。

解决:只吃dx、dy,并且给俯仰角加钳制:

@window.event def on_mouse_motion(self, x, y, dx, dy): self.yaw -= dx * 0.15 # 左右转,不限制 self.pitch += dy * 0.15 self.pitch = max(-89.0, min(89.0, self.pitch)) # 俯仰锁在 ±89°,避免万向锁翻转 @window.event def on_key_press(self, symbol, modifiers): if symbol == key.ESCAPE: self.set_exclusive_mouse(False) pyglet.app.exit()

灵敏度 0.15 是个手感基准,觉得飘就往下调到 0.08。另外记得处理on_deactivate,切走窗口时自动释放鼠标,否则 Alt+Tab 之后鼠标还是被吞着,只能强杀进程。

5.4 贴图错乱、黑边、面画反了

现象:草方块侧面糊成一片,或者每个格子边缘有一圈深色描边;有的方块从外面看是空的,从里面看反而正常。

原因:黑边是纹理过滤用了线性插值,采到了隔壁格子的像素;面反了是顶点绕序搞错了——GL_CULL_FACE默认剔除逆时针为背面的面,你如果按顺时针排顶点,正面就被剔没了。

解决:过滤改成GL_NEAREST(见 3.2),图集每格留 1 像素出血边。绕序问题不用死记,临时把glCullFace(GL_BACK)改成glCullFace(GL_FRONT)试一下,如果画面正常了,说明就是绕序反了,回去把四个角点的顺序整体反过来即可。

5.5 掉进方块里或者穿墙

现象:在斜坡上走两步突然掉到地下,看到一片黑;或者快速斜着跑能穿过墙角。

原因:dt 太大。update(dt)里的dt是真实帧间隔,切出去再切回来可能是 2 秒,一帧位移 10 格,直接跳过所有碰撞检测。另一个原因是移动没有逐轴判定,斜向位移一步跨进了方块内部。

解决:进门先限幅,再谈别的:

def update(self, dt): dt = min(dt, 0.05) # 单帧最多推进 50ms,防止卡顿后瞬移 # 位移超过 0.25 格就拆成多个子步 steps = max(1, int(max(abs(dx), abs(dy), abs(dz)) / 0.25)) for _ in range(steps): x, y, z, dy = move(blocks, x, y, z, dx / steps, dy / steps, dz / steps)

再加上出生点保护:y < -10时把玩家重置回地表最高方块上方一格。别让一次调试失误变成每次启动都要重开。

6. 二次开发:加方块、换地形、把世界存下来

这套代码最大的价值是每处改动都能立刻看见。想加一种方块,本质上只需要三步:在方块常量里加一个 id,在BLOCK_FACES里给它配六个面的图集坐标,然后在地形生成里按条件放进去。不需要碰渲染层一行代码——这就是把「方块类型 → 面贴图」抽成表的收益。

存档是很多人第一个想加的功能,而它的实现比想象中简单:blocks字典本身就是完整状态,序列化就行。

import json def save(world, path='world.json'): data = [[x, y, z, k] for (x, y, z), k in world.blocks.items()] with open(path, 'w', encoding='utf-8') as f: json.dump(data, f) def load(world, path='world.json'): with open(path, encoding='utf-8') as f: data = json.load(f) world.blocks = {(x, y, z): k for x, y, z, k in data} world.build() # 载入后必须重建网格,否则画面还是旧的

十万方块序列化成 JSON 大概几兆,加载要一两秒,够用;再大就该换二进制格式或者按区块分文件了。load之后那句build()千万别漏,不然你会盯着一个空世界怀疑人生。

再往下走,性能瓶颈会从渲染转移到世界尺寸上。我的建议是别急着上无限地图,先把世界切成 16×16 的区块,用一个dirty集合记录哪些区块的网格需要重建,build()只重建脏区块;等这一层稳了,再考虑按玩家位置流式加载周围区块。跳过区块

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 5:17:38

企业办公物品管理系统设计与实践:从库存预警到智能追踪

1. 企业办公物品管理系统概述办公物品管理是企业日常运营中最基础却最容易被忽视的环节。我曾为一家200人规模的中型企业实施过物品管理系统&#xff0c;仅三个月就帮他们减少了37%的办公耗材浪费。这个看似简单的管理系统&#xff0c;实际上需要解决三个核心痛点&#xff1a;物…

作者头像 李华
网站建设 2026/10/29 5:17:29

滚动截图与长截图全解析:四种技术路线、参数调优与翻车修复

滚动截图这件事&#xff0c;说起来简单——不就是把一屏装不下的东西一次性存成一张图嘛。但真到用的时候&#xff0c;十个人里有八个会翻车&#xff1a;截网页截到一半白屏&#xff0c;截聊天记录接缝处重复半行字&#xff0c;截完发现顶部导航栏在每张图里都出现了一遍&#…

作者头像 李华
网站建设 2026/10/29 5:17:24

市场能力六维模型:从模糊感知到可测量执行

1. 这不是“营销课”&#xff0c;而是一套可拆解、可验证、可复用的市场能力操作系统“marketingskills”这个词最近在招聘平台、自由职业接单站和小红书知识博主笔记里高频出现&#xff0c;但它绝不是“营销技巧合集”或“爆款文案模板”的代名词。我带过27个从零起步的初创团…

作者头像 李华
网站建设 2026/10/29 5:17:24

人工智能概论入门:三层认知地图与最小闭环实践

带过几届新人入门人工智能&#xff0c;我见过最常见的一种翻车方式&#xff0c;是把“人工智能概论”这门课当成名词词典来背&#xff1a;卷积、注意力、反向传播、大模型、对齐、微调&#xff0c;名词攒了一抽屉&#xff0c;真给一个任务&#xff0c;还是不知道从哪儿下手。问…

作者头像 李华
网站建设 2026/10/29 5:16:19

国产PLM软件实施挑战与成功路径解析

1. 国产PLM软件实施现状与挑战国产PLM&#xff08;产品生命周期管理&#xff09;软件近年来在功能完备性和技术成熟度上取得了长足进步&#xff0c;但实施成功率仍普遍低于国际主流产品。根据行业调研数据&#xff0c;国内制造企业PLM项目实施完全达到预期效果的不足40%&#x…

作者头像 李华
网站建设 2026/10/29 5:15:26

字符型验证码识别:CRNN+CTC对齐、合成数据与避坑

简介&#xff1a;围绕字符型图片数字验证码识别&#xff0c;这份项目资料以深度学习与Python实现为主线&#xff0c;面向具备一定神经网络基础、希望把CNN/RNN知识落到实处的开发者与安全方向学习者&#xff0c;覆盖数据集准备、图像预处理与归一化、CNN特征提取、LSTM/GRU序列…

作者头像 李华