实战项目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流程图

把上面四个模块串起来,引擎的架构就清晰了。我画了一张图,你看一眼就明白:

简易3D游戏引擎架构 主循环 (GLSurfaceView) 场景图管理 (SceneNode树) 碰撞检测 (AABB) 粒子系统 (ParticleEmitter) 音效集成 (SoundManager) 渲染管线 (OpenGL ES 2.0/3.0) APK 发布

你看,主循环每帧做三件事:更新场景图(包括碰撞检测)、更新粒子、播放音效,最后统一渲染。模块之间通过场景节点传递数据——比如碰撞检测到碰撞后,通知粒子系统在碰撞位置生成爆炸粒子,同时通知音效管理器播放爆炸声。

六、发布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