news 2026/10/15 3:56:47

用libgdx打造跨平台体素世界:面剔除、网格合并与性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用libgdx打造跨平台体素世界:面剔除、网格合并与性能优化

简介:Voxel_world 是一份基于 libgdx 框架实现的跨平台体素世界游戏源码项目,面向有 Java 基础、想入门 3D 游戏开发的 Android/桌面/iOS 学习者,也可用于课程设计或毕业设计参考。项目包含地形生成、物体交互、玩家移动与操作等核心功能,采用一次编写多端运行的架构覆盖 Android、桌面与 iOS 三端,通过 LWJGL/OpenGL 完成 3D 渲染,Scene2D 负责 UI 管理,模块划分清晰,便于循代码梳理体素引擎从数据组织到屏幕呈现的完整链路。压缩包共含 48 个文件、约 744KB,其中 8 个 Java 文件承载主要逻辑,6 个 gradle 与 4 个 properties 负责多平台构建配置,5 个 xml 用于 Android/iOS 工程设置,17 个 png 和 1 个 jpg 为界面与场景素材,另含 gradlew 脚本与 README 说明。目前已有 218 人学习浏览。通过研读源码与目录结构,可掌握 core、android、desktop、ios 等模块职责,了解 Input、Graphics、Scene2D 接口的实际用法,以及体素数据组织、地形更新与渲染刷新的常见思路,进而复现、改造出跨平台体素小游戏。

1. 一个 32³ 区块能产生近 40 万个三角形:Voxel_world 如何用 libgdx 驯服体素渲染

一个 32×32×32 的区块,如果无脑画格子,会生成 196608 个面、约 39 万个三角形,这在手机上连一秒都撑不过。Voxel_world 就是这样一个基于 libgdx 的跨平台体素世界项目,同时面向安卓、桌面和 iOS。它不靠外部建模工具出模型,而是在运行时从体素数据里现场拼网格,只把能看见的面画出来。所以这套方案的技术重心集中在三个地方:把看不见的面剔除、把零碎网格合并、用一张纹理图集管住所有方块。这篇笔记会从 Chunk 的数据结构讲起,带你搭一个三端同步跑的工程,再把容易翻车的五个点集中讲透。适合想用 Java 做体素游戏、或者正在评估 libgdx 3D 能力的开发者。它不需要你熟悉引擎底层实现,但要求你先想清楚数据怎么存、网格怎么长出来,否则后面每一步都是黑匣子。

2. 体素数据与网格构建:先让 Chunk 学会自己长出面来

体素世界的第一行代码不是渲染,是数据。数据怎么布局,决定了后面面剔除、存档、挖方块重建网格的复杂度。我开始做模拟项目 X 的时候,直接套了普通三维数组去存,结果在 32×32×32 的区块里跑得动,换成 64 就明显卡顿,问题不在数组访问,而在每次遍历时的包装和边界判断。

2.1 数据结构选型:byte[32][32][32] 与区块边界的取舍

体素数据最常见、也最可靠的存法是用一个三维数组,下标就是方块在区块内的坐标,数组里存方块类型 ID。类型 ID 用 byte 而不是 int,因为一整个 32³ 的区块如果存 int 要 128KB,存 byte 只要 32KB,存档扫描和网络同步时差距更大。

public class Chunk { public static final int SIZE = 32; private final byte[][][] blocks = new byte[SIZE][SIZE][SIZE]; // 方块类型常量,值对应纹理图集的格子 public static final byte BLOCK_AIR = 0; public static final byte BLOCK_GRASS = 1; public static final byte BLOCK_DIRT = 2; public static final byte BLOCK_STONE = 3; public static final byte BLOCK_WATER = 4; public int getBlock(int x, int y, int z) { if (x < 0 || x >= SIZE || y < 0 || y >= SIZE || z < 0 || z >= SIZE) { return BLOCK_AIR; // 区块边界外统一视为空气 } return blocks[x][y][z] & 0xFF; } public void setBlock(int x, int y, int z, int type) { blocks[x][y][z] = (byte) type; } }

getBlock 里返回blocks[x][y][z] & 0xFF是因为 Java 的 byte 有符号,类型值 128 以上会被读成负数,做比较时容易出诡异问题。越界统一返回空气,是给下一节面剔除用的:区块边缘的方块要生成侧面,就必须把“隔壁没有方块”当成可画条件,如果这里直接抛异常或返回 1,边缘就会穿孔。

区块边长选 16 还是 32,是体素项目第一个要拍板的参数。16 的区块加载更灵活,编辑单方块时重建范围小;32 的区块在网格合并时 CPU 开销更低、存档文件头更少,但坏处是挖掉一个方块要重建整块 32³ 的网格。实际项目里我一般用 32,因为地形的连续性和渲染批次收益更明显,代价只是偶尔重建时掉几毫秒帧。

提示:如果你的世界需要很高的纵向地形,比如 64 层,可以把 y 维度单独定义成byte[SIZE][HEIGHT][SIZE]。上面示例保持三个维度相等,是为了让逻辑更容易理解。

2.2 面剔除:邻居是空气才生成面,这是 90% 性能的来源

一个实心方块有六个面,但真正露在外面的往往只有一个到两个。体素渲染第一优化就是面剔除:遍历区块里所有非空方块,检查上下左右前后六个邻居,邻居是空气才生成这个面,邻居是同类实心方块就跳过。这一步做完,网格顶点数通常能砍掉 70% 到 90%。

private static final int[][] FACE_DIRS = { { 1, 0, 0}, {-1, 0, 0}, // 右、左 { 0, 1, 0}, { 0,-1, 0}, // 上、下 { 0, 0, 1}, { 0, 0,-1} // 前、后 }; public void buildMesh(FloatArray verts, ShortArray indices) { for (int x = 0; x < SIZE; x++) { for (int y = 0; y < SIZE; y++) { for (int z = 0; z < SIZE; z++) { int type = getBlock(x, y, z); if (type == BLOCK_AIR) continue; for (int f = 0; f < FACE_DIRS.length; f++) { int nx = x + FACE_DIRS[f][0]; int ny = y + FACE_DIRS[f][1]; int nz = z + FACE_DIRS[f][2]; if (getBlock(nx, ny, nz) == BLOCK_AIR) { addFace(verts, indices, x, y, z, f, type); } } } } } }

这里我用的是 libgdx 自带的 FloatArray 和 ShortArray,而不是 Java 的 ArrayList。体素网格动辄几万顶点,如果用 ArrayList 的自动装箱,产生的小对象会在手机上频繁触发 GC,帧率曲线会像锯齿。FloatArray 是 libgdx 对 float 数组的轻量封装,add 方法不装箱,遍历也快。

addFace 是这个流程里的核心方法,它的职责是:根据面方向 f 取出该面的四个顶点坐标,从纹理图集里取四个 UV,追加到 verts 数组,再按两个三角形的顺序追加六个索引到 indices 数组。一个顶点暂时用 5 个 float:位置 3 个(x、y、z)加 UV 2 个(u、v)。后面如果要加环境光遮蔽 AO,每个顶点再加一个 float 就行。

网格构建完成后,需要把它转成 libgdx 的 Mesh 对象:

Mesh mesh = new Mesh(true, verts.size / 5, indices.size, new VertexAttribute(Usage.Position, 3, "a_position"), new VertexAttribute(Usage.TextureCoordinates, 2, "a_texCoord0")); mesh.setVertices(verts.toArray()); mesh.setIndices(indices.toArray());

第一个参数是 isStatic。体素区块建好后不会被单独修改顶点,挖方块时整个区块重建,所以只用静态 Mesh 就够。如果你要用 GPU 直接改地形做特效,才需要把这里改成 false 配合动态缓冲。网格构建完,verts 和 indices 就可以让 GC 回收了,Mesh 才是真正占显存的东西。

2.3 纹理图集与 UV 映射:防纹理渗色的三个参数

体素世界最少也有几种方块,纹理切换会打断渲染批次、抬高 draw call。所以常见做法是手工做一张纹理图集,把草、土、石、水按网格排在同一张图片里,渲染时全程只绑定这张图集。假设图集是 16×16 的格子,方块类型 ID 直接对应格子坐标,UV 计算就变成了纯算术:

public class AtlasUV { private static final int GRID = 16; private static final float INV = 1f / GRID; public static float[] uvFor(int typeId) { int col = typeId % GRID; int row = typeId / GRID; float u0 = col * INV; float v0 = row * INV; return new float[] { u0, v0, u0 + INV, v0 + INV }; } }

这个函数的输出是纹理矩形的左下角和右上角。实际写入顶点时,要根据面方向决定四个顶点分别取矩形的哪个角,否则草方块顶面和侧面看起来是旋转过的。做法是固定的:顶部面四个 UV 按原方向写,侧面按 u 或 v 翻转后写入,翻转让纹理的“草皮垂下来”方向统一朝下。

图集最闹心的坑是纹理渗色。如果纹理过滤方式不是 Nearest,而是 Linear 或带 MipMap,GPU 采样时会把相邻格子的边缘像素一起采进去,方块边缘出现细线或者别的颜色。解决方式是在纹理加载时给每个格子留出 padding,或者在 UV 里做半像素收缩:

float hw = 0.5f / atlasWidth; float hh = 0.5f / atlasHeight; u0 += hw; v0 += hh; u1 -= hw; v1 -= hh;

这三个参数是体素图集能不能用得舒服的关键:图集尺寸、格子数量、半像素收缩量。如果用了 MipMap,还需要把 MipMap 层级设得低一点,否则远处区块的纹理会闪成噪点。我一般只在贴图集时设置 TextureFilter.MipMapLinearNearest,UV 收缩还是靠那两行半像素。

3. 跨平台工程搭建:一份 core 逻辑,安卓、桌面、iOS 各自启动

libgdx 的跨平台模型不复杂:所有游戏逻辑写在一个 core 模块里,桌面、安卓、iOS 各有一个薄薄的启动器模块,平台差异被框架抽象成一致的接口。你不需要为三端各写一份渲染代码,但必须知道每一端启动时有哪些配置会悄悄影响体素项目。

3.1 模块划分与两个启动器配置:core 不 import 任何一个平台类

工程结构一般是 core、desktop(lwjgl3)、android、ios 四个模块。core 里只有游戏逻辑,不 import 任何桌面或安卓专属类,三端共同依赖它。桌面端启动器是 Java 的 main 方法,安卓端启动器继承框架提供的 Activity 基类,iOS 端则是 Delegate 模式,但三者都只做一件事:创建配置对象,然后把你写的 ApplicationListener 塞进去。

public class DesktopLauncher { public static void main(String[] args) { Lwjgl3ApplicationConfiguration config = new Lwjgl3ApplicationConfiguration(); config.setTitle("Voxel_world"); config.setWindowedMode(1280, 720); config.setForegroundFPS(60); new Lwjgl3Application(new VoxelGame(), config); } }

桌面上窗口大小直接决定默认视锥体比例,体素项目建议锁 60 FPS,因为很多机型的高刷屏会把 delta 值波动放大,导致第一人称视角一顿一顿。setForegroundFPS(60) 还能降低整机功耗,对长时间调试友好。如果你在桌面调试时发现帧率被垂直同步锁到 30,多半是显卡驱动强开了垂直同步,跟这里没关系。

安卓端启动器比桌面端多几个要命的配置项:

public class AndroidLauncher extends AndroidApplication { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); AndroidApplicationConfiguration config = new AndroidApplicationConfiguration(); config.useImmersiveMode = true; // 隐藏系统状态栏和导航栏 config.useWakelock = true; // 屏幕常亮 initialize(new VoxelGame(), config); } }

useImmersiveMode 对体素游戏几乎是必须的,否则屏幕上角落一直有个状态栏,点击方块时系统手势先抢走事件。useWakelock 适合长时间编辑世界的玩法,代价是耗电,如果你做的是短会话关卡,可以关掉。iOS 端启动器更简短,继承 Delegate 后同样只是创建配置并返回 Application,真正的差异全在工具链的构建配置上,那部分属于打包环节,跑通一次之后基本不用再动。

3.2 第一人称相机:Yaw/Pitch 控制在三端手感统一

体素世界标配是第一人称视角。libgdx 的 PerspectiveCamera 设好 fieldOfView、near、far 三个参数,然后每帧根据玩家输入去转 direction。这里最容易犯的错是在手机和桌面写两套操作逻辑,结果手感完全不同。正确做法是只在 core 里用 Gdx.input 的抽象接口,三端都会自动响应。

public class PlayerController { private final PerspectiveCamera camera; private float yaw = 0f, pitch = 0f; public PlayerController(PerspectiveCamera camera) { this.camera = camera; camera.fieldOfView = 67f; camera.near = 0.1f; camera.far = 900f; } public void update(float delta) { if (Gdx.input.isTouched()) { float dx = Gdx.input.getDeltaX(); float dy = Gdx.input.getDeltaY(); yaw -= dx * 0.12f; pitch = MathUtils.clamp(pitch + dy * 0.12f, -89f, 89f); camera.direction.set( MathUtils.cosDeg(yaw) * MathUtils.cosDeg(pitch), MathUtils.sinDeg(pitch), MathUtils.sinDeg(yaw) * MathUtils.cosDeg(pitch) ).nor(); } camera.update(); } }

yaw 控制水平旋转,pitch 控制俯仰。pitch 必须 clamp 在 ±89 度,否则视角翻过头顶后方向向量反转,画面会像被拧了一下。方向向量的三个分量分别乘余弦和正弦,是为了保证向量长度始终接近 1,最后再 nor 一次消除浮点误差。

参数推荐值说明
fieldOfView6765 到 75 之间都行,太大边缘畸变明显
near0.1不要小于 0.01,深度缓冲精度会崩
far900对应约 14 个区块视距,再远需要 LOD
deltaX 灵敏度0.1260 FPS 的参考值,120 帧要减半

灵敏度这块是三端手感差异最大的地方:同样的滑动距离,在手机上是手指划过屏幕,在桌面上是鼠标移动,两者天然不同。如果你发现安卓上转得太快,不是因为代码错了,而是因为设备上报的底层触控采样频率不同。我一般用屏幕宽度做归一化:deltaX * 0.12f / (float) Gdx.graphics.getWidth(),这样不同分辨率的机器手感能基本一致。

3.3 地形生成与存档:噪声算海拔,固定字节序写入

地形生成不需要在文章里过度展开,常见做法是二维噪声给海拔高度,再按高度分层填方块。草皮一层、泥土两三层、下面全是石头,这已经是体素沙盒里经过验证的最简方案。洞穴和矿脉属于进阶内容,靠三维噪声实现,结构上是在高度图生成完后再挖空。

public void generateTerrain(int seed) { SimplexNoise noise = new SimplexNoise(seed); // 简化噪声封装 for (int x = 0; x < SIZE; x++) { for (int z = 0; z < SIZE; z++) { int height = 12 + (int) (noise.at(x * 0.03f, z * 0.03f) * 12f); height = MathUtils.clamp(height, 1, SIZE - 8); for (int y = 0; y <= height; y++) { int type; if (y == height) type = Chunk.BLOCK_GRASS; else if (y >= height - 3) type = Chunk.BLOCK_DIRT; else type = Chunk.BLOCK_STONE; setBlock(x, y, z, type); } } } }

噪声的采样频率 0.03 决定地形起伏的疏密。调大一点,比如 0.1,山会更碎;调小到 0.01,会出现大片平原和缓坡。这个值属于玄学范畴,得根据你的玩法反复试。注意先 clamp height 再填方块,否则 y 越界会直接触发 2.1 里 getBlock 的越界逻辑,填进去的方块丢在空气里。

存档推荐用 DataOutputStream 写入,DataInputStream 读出。Java 的 Data 系列读写浮点和整数的字节序是全平台统一固定的,三端互换存档不会乱。不要在这里混用 ByteBuffer 的 nativeOrder,那是踩坑重灾区,第 4 章会细说。存档读写成对出现、嵌套顺序一致,是体素世界不存坏档的前提。

4. 跨平台避坑:Voxel_world 在安卓、iOS 上最常翻车的 5 个点

同一套代码跑三个平台,最大的问题不是“框架不能做”,而是每个平台有自己的小脾气。下面五个坑是体素项目里最高频的,每一条我都见过不止一次,按现象、原因、解决三步走。

4.1 iOS 首次启动白屏:GL 上下文还没就绪,资源别急着加载

现象:桌面上跑得正常,打包到 iOS 模拟器后,应用能启动但一直白屏,控制台偶尔看到 OpenGL 报错,主线程没有崩溃。

原因:iOS 后端的 GL 上下文不是 Java 层 new 出来就立刻可用的。我在模拟项目 X 里一开始把纹理图集、区块网格生成放在启动回调里,那会儿视图还没有真正挂到屏幕上,上传纹理的 GL 调用被系统忽略或直接报错,画面自然白屏。

解决:所有 GPU 资源创建全部挪进 ApplicationListener 的 create() 里,启动回调只保留配置。如果资源太多导致启动卡顿,用分帧加载:每帧最多生成一个或两个区块网格,避免一帧内卡死几百毫秒。判断 GL 是否就绪最简单的标准,是看 create() 是否已经执行——在那之前不要碰任何纹理和 Mesh。

4.2 桌面正常手机倒置:纹理坐标 v 的 flip 约定不一致

现象:同一张草方块纹理,桌面窗口里显示正常;装到安卓上侧面纹理上下颠倒;换一台设备,又可能变成左右镜像。

原因:Pixmap 读取像素和 GPU 上传的行序在各个后端存在差异,日志不会报错。体素网格顶点是自己手工写入的,如果 UV 的 v 方向在某个平台约定不同,等于每个面都埋了一个雷,而且只在特定机型上爆。

解决:不要在 addFace 里到处补翻转。定一个全局约定:纹理加载阶段统一执行垂直翻转,然后手工 UV 里的 v 坐标统一修正。让这个约定只出现在一处代码里,review 时一眼能看穿。桌面端正常不代表手机端正常,加一条崩溃日志在创建纹理时输出flipped=true/false,比肉眼盯着纹理猜快得多。

4.3 透明方块黑边闪烁:深度写入与渲染顺序

现象:加了水和玻璃材质后,桌面小窗口不明显,手机上转动视角时方块边缘频繁出现黑边和闪烁。

原因:透明面和不透明面混在同一个 Mesh 里,绘制顺序完全依赖索引顺序。不透明面先画但写入了深度,透明面后画时被深度测试干掉大片区域;透明面彼此之间又不排序,远处的后画覆盖近处的,叠加越多越黑。纹理图集渗色再给黑边补一刀。

解决:把透明方块从 buildMesh 里拆出来,单独建一个 transparentMesh。渲染顺序固定为:先画所有不透明 Mesh,开启深度写入;再画透明 Mesh,关闭深度写入,开启混合,混合参数用GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA。透明区块还要按到相机的距离排序,从远到近画。你可以在世界管理器里维护两个列表,一个是 solid 渲染列表,一个是 transparent 渲染列表,每帧分别遍历。

4.4 三端存档互通地形乱掉:先查路径,再查读写是否成对

现象:桌面生成的存档放到安卓能打开,安卓写的存档拿到 iOS 打开后,地形高度整体错乱,像每若干行被抽掉了几格,甚至读档直接失败。

原因:第一,Gdx.files.local()在 iOS 上解析到沙盒 Documents 目录,在安卓上是应用私有目录,桌面则是程序工作目录。如果代码里图省事拼了绝对路径,三端其实指向三个不同的地方,读的永远不是刚写的那份文件。第二,写入用 writeInt,读取却用了 readByte,或者写入顺序是 x-y-z、读取顺序却是 z-y-x,数据索引一错位,后面全乱。

解决:所有存档路径都从Gdx.files.local("world/")派生,禁止拼绝对路径。读写必须严格同一顺序、同一类型成对出现:

// 写入 out.writeInt(chunkX); out.writeInt(chunkZ); for (int x = 0; x < Chunk.SIZE; x++) for (int y = 0; y < Chunk.SIZE; y++) for (int z = 0; z < Chunk.SIZE; z++) out.writeByte(blocks[x][y][z]); // 读取 int cx = in.readInt(); int cz = in.readInt(); for (int x = 0; x < Chunk.SIZE; x++) for (int y = 0; y < Chunk.SIZE; y++) for (int z = 0; z < Chunk.SIZE; z++) blocks[x][y][z] = in.readByte();

嵌套循环的顺序必须一致。我见过最隐蔽的一次翻车,是写入按 x-y-z 嵌套、读取按 x-z-y 嵌套,地形看起来只是局部扭曲,排查了三个晚上。建议在存档文件头写一个版本号字段,版本不符直接拒绝读取并弹提示,比读出来一堆烂数据再优雅恢复强得多。

4.5 帧率悄悄掉一半:Mesh 忘记 dispose

现象:开局 60 帧,玩十几分钟后掉到 30 帧,切后台再回来更严重,最后直接闪退。

原因:每次挖掉一个方块就会重建区块网格,new 出新的 Mesh。旧的 Mesh 如果不 dispose,显存会被无主资源慢慢吃满。桌面显卡显存大,很久才爆;手机 GPU 显存紧张,几分钟就能感受到。

解决:World 层用一个集合持有所有 Chunk 的 Mesh,重建之前先遍历调用 dispose(),再解散旧引用。Android 端还要在 onPause 时挂一个释放回调,防止系统在后台直接回收 GL 上下文后,进程回来还拿着失效的 Mesh:

public void rebuildChunk(int cx, int cz) { Chunk chunk = chunkMap.get(key(cx, cz)); if (chunk != null && chunk.getMesh() != null) { chunk.getMesh().dispose(); } Chunk newChunk = buildChunk(cx, cz); chunkMap.put(key(cx, cz), newChunk); }

一个通用的判断标准是:在渲染循环里统计每帧 new 出来的 Mesh 数量,正常情况下应该是 0。只要不是 0,就说明有资源只生不灭,跑得越久越卡。

5. 进阶优化:在 Voxel_world 里把 draw call 与顶点数再压一个量级

前面做完面剔除,区块网格已经能跑了,但离量产还差两步:面数还是多,可见区块还是全部在画。这两个问题分别由 Greedy Meshing 和视锥剔除解决。

5.1 Greedy Meshing:把相邻同纹理面合并成大平面

面剔除之后,一个平坦的地面上可能有几百个相邻的小四边形,它们纹理相同、朝向相同、高度相同。Greedy Meshing 的思路是沿坐标轴扫描,把这些相邻格子合并成一个细长的大矩形,一次只画一个大四边形。面数能压到原来的三分之一甚至十分之一,取决于地形平坦程度。

// 以 X 轴正方向的面为例,在 yz 平面上扫描 boolean[] mask = new boolean[SIZE * SIZE]; // 先标记哪些格子需要画这个方向的面 for (int y = 0; y < SIZE; y++) { for (int z = 0; z < SIZE; ) { if (!mask[y * SIZE + z]) { z++; continue; } int width = 1; while (z + width < SIZE && mask[y * SIZE + z + width]) width++; int height = 1; outer: while (y + height < SIZE) { for (int k = 0; k < width; k++) { if (!mask[(y + height) * SIZE + z + k]) break outer; } height++; } // 生成一个 (width, height) 的大四边形,并清除对应 mask addMergedFace(x, y, z, width, height); clearMask(mask, y, z, width, height); z += width; } }

核心是 mask 数组:先标记所有需要生成的面,扫描时横向扩展宽度,纵向扩展高度,每合并完一块就把 mask 清掉,防止重复生成。这个算法的复杂度差不多 O(n²),对面剔除后的表面来说负担不大。clearMask 一定要做,否则同一个面会被后面的大矩形再画一次,顶点反而更多。

5.2 视锥剔除:只渲染相机看得见的区块

面剔除解决单个区块内部的浪费,视锥剔除解决区块之间的浪费。体素世界视距 900 格,按 32 的区块尺寸就是大约 28×28 个区块,全画的话 draw call 还是太多。常见做法是给每个区块维护一个 BoundingBox,渲染前用 libgdx 的 camera.frustum 做一次包含测试。

BoundingBox box = chunk.getBoundingBox(); // 预先创建,不要每帧 new if (camera.frustum.boundsInFrustum(box)) { renderList.add(chunk); }

BoundingBox 一定要在区块生成时创建并复用。每帧 new 几百个 Vector3 和 BoundingBox,GC 压力比多画几个区块还大。如果你发现区块边缘偶尔被裁剪,那是 box 边界算窄了,把范围往外扩 1 格即可;如果视野边缘出现“穿帮”的空白区块,优先检查 far 值和 box 的尺寸是否一致。

5.3 用日志验证效果:顶点数、draw call 和帧耗时

优化不能靠感觉。在渲染循环里每 60 帧打印一次统计日志,把顶点总数、draw call 次数和帧率一起输出,然后在不同地形间飞行查看数据变化:

frameCount++; if (frameCount % 60 == 0) { Gdx.app.log("voxel", "verts=" + totalVerts + " drawCalls=" + drawCalls + " fps=" + Gdx.graphics.getFramesPerSecond()); totalVerts = 0; drawCalls = 0; }

draw call 的统计可以在每次 mesh.render 之前把计数器加一。正常的体素世界在视距 10 个区块左右时,draw call 应该控制在 100 以内,顶点数根据地形疏密变化很大,但不应比面剔除前多。如果发现方块表面有奇怪的条纹,检查半像素收缩是否生效;如果远处区块闪烁,检查 far 值和深度缓冲位宽,不要盲目调大 far。

我做 Voxel_world 这类项目最后悔的一件事,是把优化全部堆到了最后一刻。面剔除、Greedy Meshing、Mesh 释放这些手段单个看都不难,可一旦网格建好、存档写通、操作手感调完,再回头改数据结构就是牵一发动全身。建议从第一个可玩版本开始就让统计日志常驻,把顶点数和 draw call 当作和帧率同等重要的发布硬指标,每加一个功能就看一眼数据有没有恶化。希望这篇笔记能帮到你少走弯路。

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

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

EtherCAT 主站如何设计实时线程?从周期收发、数据快照到异常恢复

在机器人控制系统中&#xff0c;EtherCAT 主站实时线程是连接上层控制算法与底层伺服设备的关键环节。它不仅要按照设定周期完成过程数据交换&#xff0c;还要确保输入数据能够被正确读取、控制输出能够及时更新&#xff0c;并在通信异常、周期超期或设备状态变化时&#xff0c…

作者头像 李华
网站建设 2026/10/15 3:55:43

智慧供应链顶层规划解决方案

该文档是智慧供应链解决方案&#xff0c;聚焦智慧供应链的特点、解决方案、整体框架及应用。其特点包括兼容性、先进性、敏捷化、数字化、智能化等&#xff0c;强调技术与管理的综合集成。解决方案涵盖集中采购、分散发货等多种业务模式&#xff0c;通过大数据操作平台、订单履…

作者头像 李华
网站建设 2026/10/15 3:55:43

IDEA 的命令补全又进化了

我们先来还原一个极其真实的场景 你正在写一个 Java 类&#xff0c;突然想重构一个方法。你明明知道 IDEA 能做到&#xff0c;甚至昨天刚用过。但此刻&#xff0c;你的大脑一片空白——快捷键是什么来着&#xff1f; CtrlAltShiftT&#xff1f;还是 CtrlAltM&#xff1f; 你开始…

作者头像 李华
网站建设 2026/10/15 3:55:42

题解:洛谷 P1671 [USACO05FEB] Rigging the Bovine Election S

本文分享的必刷题目是从蓝桥云课、洛谷、AcWing等知名刷题平台精心挑选而来&#xff0c;并结合各平台提供的算法标签和难度等级进行了系统分类。题目涵盖了从基础到进阶的多种算法和数据结构&#xff0c;旨在为不同阶段的编程学习者提供一条清晰、平稳的学习提升路径。 欢迎大…

作者头像 李华
网站建设 2026/10/15 3:55:08

记录里没有「不知道」这个取值时,空值会替你说「一切正常」

先说一件刚发生的事。我们修掉了一处缺陷&#xff1a;四种不同的「没有」被压成一个开关&#xff0c;读数里分不出「设计如此」和「出错了」。修完&#xff0c;接着做另一件事。那个新字段落地的第二天&#xff0c;一位读者在评论里&#xff0c;指出了同一处缺陷——并且写下了…

作者头像 李华