简介:“libGDX加载G3DJ模型”是一款面向Java/Kotlin游戏开发者的实战示例项目,演示了利用G3dModelLoader将轻量级G3DJ模型导入libGDX,并通过ModelBatch完成渲染、动画播放与材质贴图应用的全过程。G3DJ以JSON形式存储顶点、纹理、材质及动画数据,文件体积小,便于运行时解析;资源适用于已有一定libGDX基础、希望在自己的2D/3D项目中加入3D模型资源的Android或桌面端开发者,无论是学习引擎模型管线,还是排查加载错误,都能从中获得直接参考。资源包共486个文件,总大小84.83MB,类型涵盖xml、json、g3dj、png、java、gradle等,其中g3dj为模型数据、png为纹理贴图、java为核心逻辑、gradle为构建配置。已有150人学习下载。示例工程包含完整可运行Demo,覆盖G3DJ结构解析、Model/ModelInstance使用、fbx-conv转换流程、纹理材质加载及动画控制等关键点,并附带assets资源与构建脚本,方便对照代码快速迁移,缩短模型接入与调试周期。
1. libGDX 加载 G3DJ:先在 fbx-conv 这一关把模型导顺
做过 libGDX 3D 的人应该都有同感:模型格式选错,后面全是坑。libGDX 不像 Unity 帮你把 FBX、OBJ 全包办了,它自己的运行时格式是 G3D,而 G3DJ 是其中带 JSON 文本结构的那个变体。你从 Blender 或者 3ds Max 导出的 FBX,先得过fbx-conv这道命令行工序,转成 G3DJ 之后才轮到G3dModelLoader去读它。换句话说,真正的门槛不在写代码,而在转换链路。这套资源里带的就是一个完整的 Android 工程,里面 assets 和 core 模块把加载、渲染、动画的链路都摆出来了,适合已经能跑 Hello World、但第一次摸 3D 模型的 libGDX 开发者照着拆。这篇就把整个链路从文件结构到渲染调用捋一遍,顺带把我踩过的坑标出来。
2. 搞懂 G3DJ 的文件结构:JSON 骨架、顶点流和材质索引
2.1 G3DJ 到底长什么样
G3DJ 是 G3D 格式的 JSON 文本变体,G3D 本身还有二进制版.g3db。G3DJ 的优势是可以直接用文本编辑器打开检查,出问题的时候能一眼看出是顶点数据少了还是贴图路径写错了。文件顶层通常是这样几块内容:
{ "version": [0, 1], "meshes": [ { "id": "mesh-01", "attributes": ["POSITION", "NORMAL", "TEXCOORD0"], "vertices": [0.1, 0.2, 0.3, ...], "parts": [ { "id": "part1", "indices": [0, 1, 2, ...], "materialId": "mat-rock" } ] } ], "materials": [ { "id": "mat-rock", "diffuse": [0.8, 0.8, 0.8], "textures": [ { "id": "tex0", "type": "DIFFUSE", "filename": "rock.png" } ] } ] }这里attributes声明了 interleaved 顶点流里每段数据的顺序和类型,libGDX 的VertexAttributes在加载时要跟它对上;vertices是一个扁平的 float 数组,不是二维数组;parts里的indices决定三角面怎么连。文件里出现"skeleton"和"animations"字段时,说明模型带骨骼和动画,加载时要用G3dModelLoader的loadModel重载方法。
2.2 G3dModelLoader 的加载时序
G3dModelLoader继承自ModelLoader,它自己带一个JsonReader来解析文件。加载动作实际上分三步:读 JSON 拿节点树、解析 attributes 构建网格、把材质和纹理塞进Material。关键点是纹理加载发生在 loader 内部,你传入的FileHandle决定了纹理的相对路径基准。
G3dModelLoader loader = new G3dModelLoader(new JsonReader()); Model model = loader.loadModel( Gdx.files.internal("models/character.g3dj"), Gdx.files.internal("models") // 纹理相对路径的基准目录 );第一个参数是 G3DJ 文件,第二个参数是纹理查找目录,两个都可以直接塞FileHandle。如果第二个参数传的是打包后的Gdx.files.internal,纹理路径会严格按照 g3dj 里的filename字段去 assets 里找。Model.dispose()会释放顶点缓冲、纹理和材质,但不释放骨架,所以骨架要另外处理。
2.3 从 JSON 到 Model 对象的那几步
为了之后排查方便,我建议你拿到一个 g3dj 后先写个小工具把它的层级打出来,结构对了再进渲染循环:
// 递归打印 Model 节点树,确认骨骼层级和 mesh 归属 for (Node node : model.nodes) { logNode(node, 0); } private static void logNode(Node node, int depth) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < depth; i++) sb.append(" "); sb.append(node.id); if (node.meshPart != null) { sb.append(" [mesh: ").append(node.meshPart.mesh.id) .append(", part: ").append(node.meshPart.id).append("]"); } Gdx.app.log("G3DJ", sb.toString()); if (node.children != null) { for (Node child : node.children) logNode(child, depth + 1); } }model.nodes是Node的树,每个Node里挂meshPart引用的是model.meshes里的某个网格段。如果模型加载后位置不对、零件乱飞,先看这棵树和 DCC 工具里的场景层级是否一致。meshPart的offset和size决定渲染时取顶点流里的哪一段,这俩字段错了画面会直接撕裂。
3. 搭一条能跑的加载链:从 build.gradle 到 ModelBatch 渲染
3.1 gradle 工程里 assets 和 core 的分工
这套资源带的工程是标准 libGDX 多模块结构:core放平台无关的业务代码,assets跟着平台模块走,android模块里看到resources-debug.ap_和android-debug.apk是 gradle 构建 APK 时自动产出的中间产物,不是手工维护的文件。你要改的是 assets 里放 g3dj、纹理的位置,以及 core 里的渲染逻辑。
assets目录在 Android 上是android/assets/,桌面端是assets/。g3dj 通常放assets/models/,纹理放assets/textures/。加载时Gdx.files.internal("models/player.g3dj")的相对路径,就是相对于这个 assets 根目录,不需要加assets/前缀。
3.2 渲染循环里让模型先显示出来
最小可运行链路是三段:加载成Model→ 建ModelInstance→ModelBatch里绘制。
// 初始化阶段 G3dModelLoader loader = new G3dModelLoader(new JsonReader()); Model model = loader.loadModel( Gdx.files.internal("models/scene.g3dj"), Gdx.files.internal("textures") ); ModelInstance instance = new ModelInstance(model); // 渲染循环内部 ModelBatch batch = new ModelBatch(); batch.begin(cam); batch.render(instance, environment); batch.end();environment要提前塞灯和环境光,否则模型是黑的。ModelInstance构造时会复用同一个Model的网格数据,所以同一模型多份实例只需要一个Model。每帧batch.render之前改instance.transform就能移动旋转。循环结束记得batch.dispose()和model.dispose(),Android 上不释放会直接被系统杀。
3.3 ModelBatch 和 Shader 的自动匹配
ModelBatch默认会根据模型 attributes 自动选择 shader,比如带BONE4的网格会用 skinning shader,纯静态网格用 default shader。如果你的模型加载后一部分黑了、一部分正常,多半是多个 mesh 的 attributes 不一致,导致一个 batch 内切了 shader。这时候要么在 DCC 里统一导出设置,要么给ModelBatch配一个自定义ShaderProvider。最常见的做法是打开G3DJ的 attributes 字段逐一核对,确保每个 mesh 都是同一套属性。
3.4 参数对照表
| 参数 | 常用值 | 说明 |
|---|---|---|
new JsonReader() | 默认 | 可换成自己的 reader,但一般不用改 |
loadModel第二个参数 | Gdx.files.internal("models") | 纹理查找基准目录,必须和 g3dj 里 filename 匹配 |
ModelBatch默认 shader | 内置 | 带骨骼动画会切 skinning shader |
instance.transform | IDT | 局部坐标变换,旋转缩放都走它 |
loadModel第二参数是最容易翻车的点:传Gdx.files.internal("")时,有些版本的 loader 会把纹理相对路径当作 assets 根目录去找,导致纹理加载失败。我自己的规矩是永远显式传纹理所在目录,绝不留空字符串。
4. 材质、纹理和动画:加载只是开始
4.1 把 Textures 和 Materials 拆开看
g3dj 里材质和纹理是分离的:materials数组里每项有diffuse、specular颜色,textures数组里只有纹理引用,真正的Texture对象是 loader 在加载时创建的。你要做纹理替换时,不该去改 g3dj 文件,而是在model.materials里找TextureAttribute.Diffuse然后换掉。
// 替换模型第一个材质的漫反射贴图 for (Material mat : model.materials) { TextureAttribute attr = (TextureAttribute) mat.get(TextureAttribute.Diffuse); if (attr != null) { Texture tex = new Texture(Gdx.files.internal("textures/replacement.png")); attr.textureDescription.texture = tex; } }TextureAttribute.Diffuse是枚举类型,同一个材质里可能同时存在Diffuse、Normal、Specular三种贴图属性。替换时要判断attr.type,别把法线贴图的值塞到漫反射上。Texture的过滤方式可以用Texture.setFilter设置,否则缩放时容易糊。
4.2 动画加载和 AnimationController 的基本玩法
带骨骼的 g3dj 在animations字段里存放动画数据,G3dModelLoader加载后这些动画会挂在Model.animations上。要播放动画,创建一个AnimationController绑定到ModelInstance:
AnimationController controller = new AnimationController(instance); controller.setAnimation("idle", 1, 0.5f, null); controller.update(Gdx.graphics.getDeltaTime());四个参数分别是动画名、循环次数(1是无限循环)、过渡时间、监听器。动画名必须和 g3dj 里animations[].id完全一致,多一个空格都会报Animation not found。切换动画时,transitionTime给个0.3f左右能避免动作跳变。注意controller.update必须在渲染前调用,而且要传真实 delta 秒数,不能用帧号。
4.3 多模型实例共享动画状态
多个角色同屏时,不该为每个角色新建AnimationController来播同一段动画。正确做法是只维护一份Model,每个ModelInstance各配一个AnimationController,控制器各自记录时间,但底层的骨骼动画数据只有一份。这样内存占用小,而且每个角色的动画相位可以不同。
// 多个角色实例,各自独立的动画相位 for (Actor actor : actors) { actor.controller.setAnimation("walk", 1, 0.2f, null); actor.controller.update(actor.getDelta()); }如果是带武器、坐骑这类有挂点需求的模型,可以在代码里从model.getNode("weapon_slot")拿到节点的全局变换,然后手工把武器ModelInstance的 transform 对齐到挂点。这是ModelInstance最常见的高级用法,比在建模软件里做父子绑定灵活得多。
5. 避坑排查:fbx-conv 转换失败、贴图花屏与动画串帧的七个现场
5.1 fbx-conv 转出来的 g3dj 没有纹理
现象:模型网格正常,但场景里一片白或灰,Logcat 打出Texture not found。
原因:fbx-conv转换时,会读取 FBX 里的相对纹理路径,如果路径带盘符或者反斜杠,输出到 g3dj 的filename字段就是坏的。常见于 Windows 机器上从 Maya 导出的 FBX。
解决:先用文本编辑器打开 g3dj,看textures[].filename字段。手动改成相对 assets 的路径,比如textures/rock.png,再重新loadModel。如果你用的是fbx-conv的-b参数输出二进制 G3DB,则没有这个字段可以直接看,问题会变成运行时纹理文件找不到——路径问题同样适用。
5.2 转换后模型在 libGDX 里是躺着的
现象:模型在 Blender/Max 里是站姿,进 libGDX 后沿 X 轴躺倒。
原因:libGDX 的坐标系是 Y 轴朝上,而 3ds Max 默认 Z 轴朝上。fbx-conv转换时如果不做轴系变换,顶点数据原样搬运,模型自然翻倒。FBX 文件本身把轴信息写在文件头里,不同 DCC 导出时轴设置不一致。
解决:反向思路,别在代码里硬转。在 DCC 里把模型的-90°旋转烘焙到网格数据(Edit Mode 里旋转并应用旋转),再导出 FBX。代码里可以做兜底,但建模端解决最干净——instance.transform.rotate(Vector3.X, 90)只能保证视觉上立起来,碰撞检测和后续动画会变得很别扭。
5.3 动画只播了一帧就停住
现象:setAnimation("walk")后模型保持在一个姿势,不再更新。
原因:AnimationController.update(delta)没有被每帧调用,或者调用位置在batch.end()之后。libGDX 的动画控制器依赖每帧传入的 delta 推进时间,不调用 update 就等于暂停动画。
解决:把controller.update(Gdx.graphics.getDeltaTime())放进batch.begin之前。注意用ScreenAdapter时,别把 update 放在render里却被if (Gdx.graphics.getDeltaTime() == 0)挡住。另外检查 delta 是否传了 0,pause状态会导致进度一直不动。
5.4 多个动画之间互相串帧
现象:setAnimation("attack1", 1, 0.3f, listener),结果实际播放的是上一段动画的中间帧。
原因:动画切换的transitionTime是在当前动画的时间轴上做混合。如果上一个动画已经播完了,时间轴停在末尾,切换时从末尾倒回到开头,混合过程中就会出现串帧。动画集的id如果在建模阶段重名,也会出现加载后 map 覆盖。
解决:切换前先调用controller.stop()或者把transitionTime设成0f(切得硬但不会串)。如果你的模型是从同一套 FBX 导出多段动画,确保每段动画在 DCC 里的命名不重名。我一般会在 g3dj 里搜一遍"id"字段,确认没有重复值。
5.5 模型加载后一部分面片是黑的
现象:同一个 g3dj 里,某些 mesh 渲染黑面,旋转视角时一部分三角面消失。
原因:fbx-conv转换时索引顺序没有统一逆时针,导致部分面片的法线朝向与顶点绕序不一致,背面剔除把看到的面丢弃了。更隐蔽的可能是 mesh 的attributes里缺少NORMAL,光照计算时法线全是零向量,模型整体发黑。
解决:优先查 attributes 里有没有NORMAL,没有的话回到 DCC 里勾选Export Smoothing Groups和法线选项。如果是有法线但部分黑面,把模型导入 Blender 执行一次Recalculate Normals(快捷键 Shift+N)再导出。代码里可以用ModelBatch的渲染顺序设置临时关掉背面剔除来确认,但最终结果必须在 DCC 端修正,不要在 libGDX 端硬调,否则场景其他模型的深度测试会跟着乱。
5.6 Android 上纹理加载闪退,桌面端正常
现象:同样代码在 desktop 运行正常,打包到 Android 上直接 Crash,报Texture width and height must be powers of two。
原因:libGDX 的 OpenGL ES 2.0 在 Android 上要求纹理长宽必须是 2 的幂。桌面端显卡驱动宽限了这一点,移动端不行。fbx-conv不负责处理贴图尺寸,G3DJ 也不会自动 resize 贴图。
解决:把纹理在 Photoshop 或命令行里缩到512x512、1024x1024这类尺寸,再重新转换。libGDX 2.x 开始支持GL_KHR_texture_npot扩展,但老设备上不一定启用,所以我的习惯是出图时直接按 2 的幂尺寸出,省得后面每个模型都检查一遍。
5.7 g3dj 体积太大,首次加载卡顿
现象:模型文件 20MB,加载时黑屏好几秒。
原因:g3dj 是 JSON 文本格式,vertices和indices都以字符串形式展开,解析时要先做字符串转浮点,性能自然不如二进制。带骨骼的模型再加上蒙皮权重,文件会膨胀很多。
解决:转换时用fbx-conv -b输出 G3DB 二进制格式,运行时仍可以用G3dModelLoader加载,但解析速度能快一个量级。调试期用 G3DJ 方便看结构,发布前切到 G3DB 才是正经做法。这一步也解释了资源包里为什么能看到 apk 产物——调试构建下模型数据和代码都打进去了,真正上生产还需要你手动把资源目录精简一遍。
6. 进阶:用 ModelInstance 做实例化渲染,再给 G3DJ 模型加一个 LOD 切换
6.1 大量同模型重复出现的渲染优化
场景里放十棵一样的树,最蠢的做法是加载十个Model文件。正确姿势是加载一次Model,建十个ModelInstance,渲染时ModelBatch会自动把相同网格的渲染合并,降低 draw call。如果你想进一步压,可以手工给ModelBatch喂Renderable列表,再按meshPart分组排序,同一材质的连续渲染能省掉状态切换。
// 同一 Model 批量实例化 Model treeModel = loader.loadModel(...); ModelInstance[] trees = new ModelInstance[10]; for (int i = 0; i < trees.length; i++) { trees[i] = new ModelInstance(treeModel); trees[i].transform.translate(i * 3f, 0, 0); }ModelInstance构造很轻,它不复制顶点数据,只维护变换矩阵。所以它的数量可以上千,但Model要严格控制数量。这也意味着G3DJ 模型本身的顶点数量决定了内存上限,顶点过高的场景依旧要回到建模阶段优化,LOD 就是干这个的。
6.2 给静态模型加一个距离切换的 LOD
远处的树不需要 1 万面,但直接在游戏逻辑里切模型会闪断。一个常见的做法是维护两个ModelInstance,一个精细版一个粗糙版,根据相机距离切换,并设一个缓冲区间避免抖动。
// 每帧检查相机距离,切换细节 float dist = camera.position.dst(instance.transform.getTranslation(new Vector3())); if (dist > 40f && current != lowModel) { batch.dispose(); current = lowModel; instance = new ModelInstance(current); }LOD 切换时不要用loadModel重新加载,那会在渲染线程里拉低帧率。应该预先加载好两档模型,切换只是换引用。切换的瞬间,用一个交叉淡出或者轻微的位移偏移来遮盖视觉跳跃,比硬切自然得多。这个技巧在 G3DJ 模型上尤其好用,因为你可以直接生成两套 g3dj 文件,不需要在运行时做减面。
我自己做项目时有一条铁律:引擎里所有模型文件,从 DCC 导出到进游戏,必须走一遍 fbx-conv 并在文本编辑器里 grep 一次filename和id。这比在渲染层调试省时间的多。模型半夜加载黑屏、动画鬼畜这种事,十次有八次是面上这一步出的问题。这套资源里的工程正好能让你完整走一遍这条链路,折叠好代码对照着自己加模型试试,希望帮到你。
本文还有配套的精品资源,点击获取