实战项目2:简易3D游戏引擎——场景图管理、碰撞检测、粒子与音效集成
说实话,到了这个阶段,你已经不是那个只会画三角形的初学者了。前面我们聊了纹理、光照、模型加载,现在该把这些东西串起来,做一个真正能跑起来的“小引擎”。
这个项目我称之为“简易3D游戏引擎”,名字听着唬人,其实核心就四件事:场景图管理、碰撞检测、粒子系统、音效集成。做完它,你就能发布一个APK,在手机上跑自己的3D小世界了。
项目目标:构建一个可复用的3D游戏引擎框架,支持场景节点树管理、AABB碰撞检测、粒子特效播放、以及音效触发。最终打包为APK。
一、场景图管理——让物体有“父子关系”
场景图(Scene Graph)说白了就是一棵树。每个节点代表一个物体,节点可以有子节点。父节点移动,子节点跟着动。这在我们做复杂场景时特别有用——比如一个人物拿着剑,剑是手的子节点,手动了剑自然跟着动。
我在项目中遇到过最典型的场景:一个机器人由头、身体、手臂、腿组成,每个部件都是独立的模型。如果没有场景图,你得手动计算每个部件的位置矩阵,那代码写出来自己都不想看第二遍。
我们定义一个简单的节点结构:
public class SceneNode {
public String name;
public float[] localTransform = new float[16]; // 局部变换矩阵
public float[] worldTransform = new float[16]; // 世界变换矩阵
public SceneNode parent;
public List<SceneNode> children = new ArrayList<>();
public Mesh mesh; // 可选的网格数据
public Material material;
public SceneNode(String name) {
this.name = name;
Matrix.setIdentityM(localTransform, 0);
Matrix.setIdentityM(worldTransform, 0);
}
public void addChild(SceneNode child) {
child.parent = this;
children.add(child);
}
public void updateWorldTransform() {
if (parent != null) {
Matrix.multiplyMM(worldTransform, 0,
parent.worldTransform, 0, localTransform, 0);
} else {
System.arraycopy(localTransform, 0, worldTransform, 0, 16);
}
for (SceneNode child : children) {
child.updateWorldTransform();
}
}
}
你想想看,每次更新场景时,只需要调用根节点的 updateWorldTransform(),整棵树的变换矩阵就全算好了。渲染时直接取 worldTransform 传给着色器就行。
我的习惯:在节点里加一个 boolean visible 字段。有些节点只是用来做逻辑分组(比如“敌人组”),本身不需要渲染。这样遍历渲染时可以跳过不可见节点,省点性能。
二、碰撞检测——AABB就够了
碰撞检测是游戏的基础。对于简易引擎,我们不需要精确到三角面的碰撞,用轴对齐包围盒(AABB)就足够了。
AABB的定义很简单:一个长方体,每条边都平行于坐标轴。用最小点和最大点就能描述。
public class AABB {
public float minX, minY, minZ;
public float maxX, maxY, maxZ;
public AABB(float minX, float minY, float minZ,
float maxX, float maxY, float maxZ) {
this.minX = minX; this.minY = minY; this.minZ = minZ;
this.maxX = maxX; this.maxY = maxY; this.maxZ = maxZ;
}
// 检测两个AABB是否相交
public boolean intersects(AABB other) {
return (this.minX <= other.maxX && this.maxX >= other.minX) &&
(this.minY <= other.maxY && this.maxY >= other.minY) &&
(this.minZ <= other.maxZ && this.maxZ >= other.minZ);
}
// 将AABB应用变换矩阵(更新位置)
public void transform(float[] matrix) {
// 这里需要将8个顶点分别变换,然后重新计算min/max
// 实际项目中我会用临时数组缓存顶点
}
}
我曾经踩过一个坑:AABB变换后忘记重新计算min/max,结果碰撞检测一直不准。调试了整整一个下午才发现是缓存没刷新。嗯,这里要注意——每次物体移动或旋转后,一定要调用AABB的transform方法。
在场景图中,我们给每个需要碰撞的节点加一个AABB字段。每帧遍历所有节点,检测两两之间的AABB是否相交。如果相交,触发碰撞回调。
性能提醒:如果场景里有100个物体,两两检测就是100*99/2=4950次。对于手机来说,这个量级还行。但如果超过500个物体,建议用空间划分(比如八叉树)来优化。我们这个项目先不搞那么复杂。
三、粒子系统——让场景活起来
粒子系统用来做火焰、烟雾、爆炸、魔法效果。核心思想很简单:管理一群小精灵,每个精灵有自己的位置、速度、生命周期。
我习惯用一个 ParticleEmitter 来管理粒子池:
public class Particle {
public float x, y, z;
public float vx, vy, vz;
public float life; // 剩余生命
public float maxLife; // 最大生命
public float size;
public float r, g, b, a;
public boolean alive;
}
public class ParticleEmitter {
private List<Particle> particles = new ArrayList<>();
private int maxParticles = 200;
public void emit(float x, float y, float z) {
if (particles.size() >= maxParticles) return;
Particle p = new Particle();
p.x = x; p.y = y; p.z = z;
p.vx = (float)(Math.random() - 0.5f) * 2f;
p.vy = (float)(Math.random()) * 3f;
p.vz = (float)(Math.random() - 0.5f) * 2f;
p.life = p.maxLife = 1.5f;
p.size = 0.3f;
p.r = 1f; p.g = 0.5f; p.b = 0f; p.a = 1f;
p.alive = true;
particles.add(p);
}
public void update(float deltaTime) {
for (Particle p : particles) {
if (!p.alive) continue;
p.x += p.vx * deltaTime;
p.y += p.vy * deltaTime;
p.z += p.vz * deltaTime;
p.vy -= 2f * deltaTime; // 模拟重力
p.life -= deltaTime;
p.a = p.life / p.maxLife; // 逐渐透明
if (p.life <= 0) p.alive = false;
}
// 移除死亡粒子
particles.removeIf(p -> !p.alive);
}
}
渲染粒子时,我用的是公告牌(Billboard)技术——让每个粒子始终面向摄像机。这样用一个四边形纹理就能模拟出立体效果。
我的经验:粒子纹理最好用圆形渐变图,边缘透明。这样叠加起来效果自然,不会出现方块感。网上搜“particle sprite”就能找到很多免费资源。
四、音效集成——用SoundPool就够了
Android上播放音效有两种方式:MediaPlayer和SoundPool。对于游戏中的短音效(碰撞声、爆炸声、拾取声),SoundPool是首选。它延迟低,支持同时播放多个音效。
我封装了一个简单的音效管理器:
public class SoundManager {
private SoundPool soundPool;
private Context context;
private SparseIntArray soundMap = new SparseIntArray();
public SoundManager(Context context) {
this.context = context;
soundPool = new SoundPool.Builder()
.setMaxStreams(10)
.build();
}
public void load(int resId) {
int soundId = soundPool.load(context, resId, 1);
soundMap.put(resId, soundId);
}
public void play(int resId) {
int soundId = soundMap.get(resId);
if (soundId != 0) {
soundPool.play(soundId, 1f, 1f, 1, 0, 1f);
}
}
public void release() {
soundPool.release();
}
}
在碰撞检测的回调里,调用 soundManager.play(R.raw.explosion) 就行了。注意音效文件要放在 res/raw/ 目录下,格式推荐用OGG或WAV。
我曾经犯过一个低级错误:在GL线程里直接调用SoundPool.play()。结果音效时断时续,因为GL线程和音频线程抢资源。后来我把音效调用放到主线程的消息队列里,问题就解决了。
五、整体架构与SVG流程图
把上面四个模块串起来,引擎的架构就清晰了。我画了一张图,你看一眼就明白:
你看,主循环每帧做三件事:更新场景图(包括碰撞检测)、更新粒子、播放音效,最后统一渲染。模块之间通过场景节点传递数据——比如碰撞检测到碰撞后,通知粒子系统在碰撞位置生成爆炸粒子,同时通知音效管理器播放爆炸声。
六、发布APK——最后一步
在Android Studio里,Build -> Build Bundle(s) / APK(s) -> Build APK(s)。等几分钟,就能在 app/build/outputs/apk/debug/ 下找到APK文件。
发布前记得做几件事:
- 权限声明:如果用了音效,不需要额外权限。但如果要读取外部存储的模型文件,需要
READ_EXTERNAL_STORAGE。 - 性能调优:在低端手机上测试一下,如果掉帧严重,减少粒子数量或降低阴影精度。
- 签名:正式发布要用自己的签名密钥,debug签名只能用于测试。
注意:有些国产手机对OpenGL ES的支持有差异。我在某款手机上遇到过纹理格式不支持的问题,后来改用ETC2格式才解决。建议多找几台不同品牌的手机测试。
好了,这个简易引擎的核心就这些。你把它跑起来后,可以试着加一些自己的玩法——比如让粒子跟随鼠标点击位置生成,或者给场景加一个天空盒。嗯,动手试试吧。
公众号:蓝海资料掘金营,微信deep3321