JavaScript 的垃圾回收对实时图形(60FPS)的影响:如何编写‘零 GC’代码实现物理引擎的稳帧运行

各位同仁,各位对高性能JavaScript应用充满热情的开发者们,下午好!

今天,我们将深入探讨一个在Web平台上构建实时交互应用,特别是图形密集型和物理模拟应用时,一个既普遍又常常被忽视的关键挑战:JavaScript垃圾回收(GC)对帧率稳定性的影响。我们的目标是理解这种影响,并学习如何编写“零GC”代码,以确保我们的物理引擎能在60FPS下稳帧运行,为用户提供丝滑流畅的体验。

想象一下,你正在构建一个复杂的2D或3D物理模拟器,或者一个基于物理的游戏。你希望它在任何现代浏览器中都能以稳定的60帧每秒(FPS)运行。这意味着你的代码必须在每帧约16.67毫秒的时间内完成所有的计算、渲染准备和渲染工作。然而,JavaScript的垃圾回收机制,虽然为开发者带来了极大的便利,但在不经意间,它可能会引入微小的、难以预测的暂停,这些暂停足以破坏帧率的稳定性,导致“卡顿”或“掉帧”。

我们将从理解GC的工作原理开始,然后逐步深入到识别内存分配的“热点”,并最终掌握一系列编写“零GC”代码的策略和实践,尤其是在物理引擎的上下文中。


JavaScript垃圾回收的本质与实时性能的冲突

垃圾回收机制概述

JavaScript作为一种高级语言,其内存管理是自动进行的,这得益于其内置的垃圾回收器。开发者无需手动分配或释放内存,这大大降低了内存泄漏的风险,也简化了开发流程。主流的JavaScript引擎(如V8、SpiderMonkey、JavaScriptCore)通常采用标记-清除(Mark-and-Sweep)算法的变种,并结合分代回收(Generational GC)增量/并发回收(Incremental/Concurrent GC)等优化策略。

  1. 标记-清除 (Mark-and-Sweep): 这是最基本的算法。
    • 标记阶段 (Mark): 从一组根对象(如全局对象、栈上的局部变量)开始,遍历所有可达的对象,并将其标记为“活跃”。
    • 清除阶段 (Sweep): 遍历堆内存,回收所有未被标记的对象所占用的内存。
  2. 分代回收 (Generational GC): 基于“弱代假说”(大部分对象生命周期很短,而少数对象生命周期很长)。
    • 新生代 (Young Generation): 存放新创建的对象。这里会频繁地进行小规模的GC,通常采用Scavenge算法(复制算法的一种),效率很高,暂停时间短。
    • 老生代 (Old Generation): 存放经过多次新生代GC后仍然存活的对象(即生命周期较长的对象)。这里GC不那么频繁,但由于对象数量和大小都更大,GC过程会更复杂,可能导致较长的暂停。
  3. 增量/并发回收 (Incremental/Concurrent GC): 为了减少GC引起的暂停时间,现代GC引入了这些技术。
    • 增量回收: 将标记阶段分解成多个小块,与主线程交替执行,每次执行一小部分标记工作,从而减少单次暂停的长度。
    • 并发回收: GC的某些阶段(如大部分标记工作)可以与主线程并行执行,进一步减少主线程的暂停时间。

垃圾回收暂停对实时图形的影响

尽管有这些优化,GC仍然可能导致主线程的暂停。在实时应用中,即使是几十毫秒的暂停也足以破坏用户体验。

  • 新生代GC: 通常非常快,可能只有几毫秒甚至微秒级别,对60FPS的影响相对较小,但如果发生过于频繁,累积起来也会有问题。
  • 老生代GC: 发生在老生代的对象数量累积到一定阈值时。由于老生代对象更多、更复杂,其GC过程可能导致数百毫秒甚至秒级的暂停,这在60FPS应用中是完全不可接受的。一次老生代GC可能意味着十几甚至几十帧的丢失。

当GC暂停发生时,JavaScript主线程会停止执行任何代码,包括渲染帧、处理用户输入、执行物理模拟等。这在屏幕上表现为短暂的“冻结”或“卡顿”。对于一个追求60FPS的应用而言,这意味着每帧约16.67毫秒的预算被侵蚀。如果GC暂停耗时超过这个预算,就会导致帧率下降。

关键在于:我们无法精确控制GC的发生时机。 GC是引擎内部的决策,它会在内存达到某个阈值、或者在认为合适的时机触发。这意味着我们可能会在物理模拟的关键时刻,或者在渲染帧的中间,突然遭遇一次GC暂停。

识别内存分配热点

为了避免GC,我们首先需要知道哪些操作会引起内存分配。在JavaScript中,以下是常见的内存分配场景:

场景 描述 示例代码
new 关键字 创建新的对象实例。 new Vector3(), new Body()
对象字面量 创建新的普通对象。 { x: 0, y: 0 }, { force: 10 }
数组字面量 创建新的数组。 [], [1, 2, 3]
字符串拼接 创建新的字符串。在循环中频繁拼接字符串是GC杀手。 str1 + str2, Hello ${name}“
数组方法 map, filter, slice 等会返回新数组或新对象的数组方法。 arr.map(x => x * 2), arr.slice(0, 5)
函数返回新对象 函数内部创建并返回新对象。 function createVector() { return { x: 0, y: 0 }; }
闭包 (有时) 如果闭包捕获了大量变量或在循环中创建,可能影响GC或导致内存泄漏。 for (...) { let x = 0; return () => x++; }

我们的目标是在物理引擎的核心更新循环(updatestep 函数)中,杜绝以上所有类型的内存分配。


“零GC”编程哲学:预分配与复用

“零GC”并非指整个应用生命周期内完全没有GC,那是不现实的。它特指在应用程序的关键性能路径(Critical Performance Path),例如我们的物理引擎的每帧更新循环中,避免任何新的内存分配。这意味着所有必要的对象和数据结构都必须在初始化阶段预先分配好,并在运行时进行复用

这种哲学要求我们改变传统的“按需创建”思维,转向“管理与回收”的模式。

核心策略

  1. 对象池(Object Pooling):

    • 原理: 不断创建和销毁临时对象是GC的主要原因。对象池通过维护一个可复用对象的集合来解决这个问题。当需要一个对象时,从池中“借用”一个;当对象不再需要时,将其“归还”给池,而不是销毁它。归还的对象会被重置状态,以便下次借用时是干净的。
    • 适用场景: 频繁创建和销毁的临时对象,如粒子、碰撞对、临时的向量/矩阵、碰撞接触点等。
    /**
     * 简单的泛型对象池实现
     */
    class ObjectPool {
        constructor(ObjectType, initialSize = 100, ...constructorArgs) {
            this.ObjectType = ObjectType;
            this.pool = [];
            this.inUse = []; // 跟踪当前被使用的对象,方便调试和确保归还
            this.constructorArgs = constructorArgs;
    
            // 预填充对象池
            for (let i = 0; i < initialSize; i++) {
                this.pool.push(this._createNewObject());
            }
            console.log(`Pool for ${ObjectType.name} initialized with ${initialSize} objects.`);
        }
    
        _createNewObject() {
            // 使用 Reflect.construct 来处理带参数的构造函数
            const obj = Reflect.construct(this.ObjectType, this.constructorArgs);
            if (typeof obj.reset !== 'function') {
                console.warn(`Object ${this.ObjectType.name} in pool does not have a 'reset()' method. This can lead to stale data.`);
            }
            // 标记对象是否来自池,方便调试
            obj._isPooled = true;
            return obj;
        }
    
        /**
         * 从池中获取一个对象
         * @returns {Object} 一个可用的对象
         */
        get() {
            let obj;
            if (this.pool.length > 0) {
                obj = this.pool.pop();
            } else {
                // 如果池中没有可用对象,则创建一个新的。
                // 这表明 initialSize 可能过小,但为了避免GC,应尽量避免此分支。
                obj = this._createNewObject();
                console.warn(`ObjectPool for ${this.ObjectType.name} exhausted, creating new object. Consider increasing initialSize.`);
            }
            this.inUse.push(obj); // 标记为正在使用
            return obj;
        }
    
        /**
         * 将对象归还给池,并重置其状态
         * @param {Object} obj 要归还的对象
         */
        release(obj) {
            const index = this.inUse.indexOf(obj);
            if (index === -1) {
                // 如果对象不在inUse列表中,可能是重复归还或非池对象
                if (obj._isPooled) {
                    console.warn(`Attempted to release an object not currently in use or already released:`, obj);
                } else {
                    console.error(`Attempted to release a non-pooled object:`, obj);
                }
                return;
            }
    
            this.inUse.splice(index, 1); // 从使用列表中移除
            if (typeof obj.reset === 'function') {
                obj.reset(); // 重置对象状态
            }
            this.pool.push(obj); // 归还到池中
        }
    
        /**
         * 批量归还所有当前在用的对象
         * 在每帧结束时调用,清空所有临时对象
         */
        releaseAllInUse() {
            while (this.inUse.length > 0) {
                this.release(this.inUse[0]); // 逐个归还
            }
        }
    
        /**
         * 返回池中当前可用的对象数量
         */
        get availableCount() {
            return this.pool.length;
        }
    
        /**
         * 返回池中当前正在使用的对象数量
         */
        get inUseCount() {
            return this.inUse.length;
        }
    }

    reset() 方法对于池化对象至关重要。它负责将对象恢复到初始的、干净的状态,以便下次使用时不会携带旧数据。

  2. 预分配(Pre-allocation):

    • 原理: 对于数量相对固定或有明确上限的对象集合,在应用启动时就一次性分配好所有内存。
    • 适用场景: 物理世界中的刚体(RigidBody)、约束、碰撞器数组、渲染的顶点缓冲区等。
    • 示例: 创建一个固定大小的刚体数组,而不是在运行时动态添加。
    class PhysicsWorld {
        constructor(maxBodies = 1000) {
            this.bodies = new Array(maxBodies); // 预分配一个数组来存放刚体
            this.numActiveBodies = 0; // 实际激活的刚体数量
    
            // 预先创建所有刚体对象,而不是在添加时才创建
            for (let i = 0; i < maxBodies; i++) {
                this.bodies[i] = new RigidBody(); // 假设RigidBody构造函数内部也避免GC
            }
        }
    
        addBody(body) {
            if (this.numActiveBodies < this.bodies.length) {
                // 从预分配的池中激活一个刚体,而不是new RigidBody()
                // 实际上,更常见的是直接从this.bodies[i]中取一个已有的对象并初始化它
                // 这里为了简化,假设addBody是添加一个已经初始化的body
                // 更“零GC”的做法是:
                // const newBody = this.bodies[this.numActiveBodies];
                // newBody.init(position, mass, ...);
                // this.numActiveBodies++;
                // return newBody;
                this.bodies[this.numActiveBodies] = body; // 这是一个简化,实际应从池中取并初始化
                body.isActive = true;
                this.numActiveBodies++;
            } else {
                console.warn("Max bodies reached, cannot add more.");
            }
        }
    
        // ... update loop 会遍历 this.bodies 的前 numActiveBodies 个元素
    }
  3. 原地操作/修改器(In-place Operations / Mutators):

    • 原理: 避免返回新的对象。所有数学运算(向量、矩阵)都应该修改现有的对象,或者将结果写入一个预先提供的“输出”对象。
    • 适用场景: 向量加减、矩阵乘法、旋转、归一化等。这是物理引擎中GC的头号杀手,因为这些运算可能每帧发生数千次。
    // 传统的(会产生GC的)向量类
    class Vector3Allocating {
        constructor(x = 0, y = 0, z = 0) {
            this.x = x; this.y = y; this.z = z;
        }
        add(other) {
            return new Vector3Allocating(this.x + other.x, this.y + other.y, this.z + other.z); // 返回新对象
        }
        scale(s) {
            return new Vector3Allocating(this.x * s, this.y * s, this.z * s); // 返回新对象
        }
        // ...
    }
    
    // 零GC的向量工具类 (常用模式,如 gl-matrix 库)
    // 所有的操作都接受一个 'out' 参数,将结果写入其中
    class Vec3 {
        static create(x = 0, y = 0, z = 0) {
            // 这个方法在初始化时使用,或者在极少数情况必须创建新对象时使用
            // 在热路径中应避免
            return { x, y, z };
        }
    
        static set(out, x, y, z) {
            out.x = x;
            out.y = y;
            out.z = z;
            return out;
        }
    
        static copy(out, a) {
            out.x = a.x;
            out.y = a.y;
            out.z = a.z;
            return out;
        }
    
        /**
         * 向量加法: out = a + b
         * @param {object} out - 结果写入的对象
         * @param {object} a - 向量a
         * @param {object} b - 向量b
         * @returns {object} out
         */
        static add(out, a, b) {
            out.x = a.x + b.x;
            out.y = a.y + b.y;
            out.z = a.z + b.z;
            return out;
        }
    
        /**
         * 向量减法: out = a - b
         */
        static sub(out, a, b) {
            out.x = a.x - b.x;
            out.y = a.y - b.y;
            out.z = a.z - b.z;
            return out;
        }
    
        /**
         * 向量标量乘法: out = a * s
         */
        static scale(out, a, s) {
            out.x = a.x * s;
            out.y = a.y * s;
            out.z = a.z * s;
            return out;
        }
    
        /**
         * 向量点积: a . b
         * @returns {number} 点积结果
         */
        static dot(a, b) {
            return a.x * b.x + a.y * b.y + a.z * b.z;
        }
    
        /**
         * 向量长度
         */
        static length(a) {
            return Math.sqrt(Vec3.dot(a, a));
        }
    
        /**
         * 向量归一化: out = normalize(a)
         */
        static normalize(out, a) {
            const len = Vec3.length(a);
            if (len > 0) {
                const invLen = 1 / len;
                out.x = a.x * invLen;
                out.y = a.y * invLen;
                out.z = a.z * invLen;
            } else {
                out.x = 0; out.y = 0; out.z = 0; // 零向量归一化为零向量
            }
            return out;
        }
    
        // ... 更多向量操作
    }

    对于需要临时向量的计算,可以从对象池中获取,使用后归还。

  4. Float32ArrayArrayBuffer:

    • 原理: 对于大量同类型数值数据(如顶点坐标、颜色、法线),使用类型化数组(Typed Arrays)可以获得更紧凑的内存布局,避免为每个数字创建独立的JavaScript Number对象。它们直接在内存中分配一块连续的二进制数据。
    • 适用场景: 渲染管线中的几何数据、大规模粒子系统、GPU通信。
    • 示例:
    // 存储1000个3D向量
    const vectorData = new Float32Array(1000 * 3); // 3000个浮点数
    
    // 访问第i个向量的x, y, z
    function getVectorComponent(index, component) {
        return vectorData[index * 3 + component]; // component: 0=x, 1=y, 2=z
    }
    
    function setVectorComponent(index, component, value) {
        vectorData[index * 3 + component] = value;
    }
  5. 避免字符串拼接:

    • 在热路径中,避免频繁的字符串拼接。每次拼接都会创建新的字符串对象。
    • 如果需要构建日志或调试信息,将其移出热路径,或者使用固定大小的缓冲区。

物理引擎中的“零GC”实践

现在,我们将这些策略应用到物理引擎的核心组件中。一个典型的物理引擎包括刚体、碰撞器、碰撞检测、约束求解等。

1. 刚体(RigidBody)

刚体是物理世界的基本单元。它包含位置、速度、加速度、质量、惯性等属性。所有这些属性都应该是预分配的向量或标量。

class RigidBody {
    constructor(id) {
        this.id = id; // 用于标识
        this.isActive = false; // 是否在模拟中

        // 预分配所有内部向量对象,而不是在需要时创建
        this.position = Vec3.create();          // 位置
        this.velocity = Vec3.create();          // 线性速度
        this.force = Vec3.create();             // 累积力
        this.acceleration = Vec3.create();      // 线性加速度

        this.angularVelocity = Vec3.create();   // 角速度
        this.torque = Vec3.create();            // 累积扭矩
        this.orientation = Quat.create();       // 方向 (四元数)
        this.inverseInertiaTensor = Mat3.create(); // 逆惯性张量 (矩阵)

        this.mass = 1.0;
        this.invMass = 1.0; // 质量倒数,避免频繁除法

        this.friction = 0.5;
        this.restitution = 0.5; // 弹性

        this.reset(); // 初始化所有属性
    }

    /**
     * 重置刚体状态,供对象池使用
     */
    reset() {
        this.isActive = false;
        Vec3.set(this.position, 0, 0, 0);
        Vec3.set(this.velocity, 0, 0, 0);
        Vec3.set(this.force, 0, 0, 0);
        Vec3.set(this.acceleration, 0, 0, 0);

        Vec3.set(this.angularVelocity, 0, 0, 0);
        Vec3.set(this.torque, 0, 0, 0);
        Quat.set(this.orientation, 0, 0, 0, 1); // 单位四元数
        Mat3.identity(this.inverseInertiaTensor); // 单位矩阵

        this.mass = 1.0;
        this.invMass = 1.0;
        this.friction = 0.5;
        this.restitution = 0.5;
    }

    /**
     * 应用力,原地修改 this.force
     * @param {object} f - 要应用的力向量
     */
    applyForce(f) {
        Vec3.add(this.force, this.force, f);
    }

    /**
     * 应用扭矩,原地修改 this.torque
     * @param {object} t - 要应用的扭矩向量
     */
    applyTorque(t) {
        Vec3.add(this.torque, this.torque, t);
    }

    // ... 其他方法,如集成 (integrate),更新转换矩阵等
    // 这些方法内部也应使用 Vec3 和 Mat3 的静态方法进行原地操作
}

// 四元数和3x3矩阵也需要类似的零GC工具类
class Quat { /* ... 类似 Vec3 的静态方法 ... */ }
class Mat3 { /* ... 类似 Vec3 的静态方法 ... */ }

2. 碰撞检测与接触点(Collision Detection & Contact Points)

碰撞检测是物理引擎中GC的另一个高发区。在每帧中,可能需要检测数百甚至数千对对象之间的碰撞。每次碰撞可能会产生多个接触点。

  • 碰撞对(CollisionPair): 存储两个可能发生碰撞的刚体引用。
  • 接触流形(ContactManifold): 描述两个物体之间的所有接触点信息。
  • 接触点(ContactPoint): 描述一个具体的接触点,包含位置、法线、深度等。

这些临时对象都非常适合使用对象池。

// 碰撞对类
class CollisionPair {
    constructor() {
        this.bodyA = null;
        this.bodyB = null;
        this.reset();
    }
    reset() {
        this.bodyA = null;
        this.bodyB = null;
    }
    set(bodyA, bodyB) {
        this.bodyA = bodyA;
        this.bodyB = bodyB;
    }
}

// 接触点类
class ContactPoint {
    constructor() {
        this.position = Vec3.create(); // 世界坐标下的接触点
        this.normal = Vec3.create();   // 碰撞法线 (从A指向B)
        this.depth = 0;                // 穿透深度
        this.impulse = 0;              // 求解器计算的冲量
        this.reset();
    }
    reset() {
        Vec3.set(this.position, 0, 0, 0);
        Vec3.set(this.normal, 0, 0, 0);
        this.depth = 0;
        this.impulse = 0;
    }
    set(pos, norm, dep) {
        Vec3.copy(this.position, pos);
        Vec3.copy(this.normal, norm);
        this.depth = dep;
    }
}

// 接触流形类
class ContactManifold {
    constructor(maxContacts = 4) { // 一个流形通常有1-4个接触点
        this.bodyA = null;
        this.bodyB = null;
        this.contacts = new Array(maxContacts); // 预分配接触点数组
        this.numContacts = 0;

        // 预先创建所有接触点实例,并存储在数组中
        for (let i = 0; i < maxContacts; i++) {
            this.contacts[i] = new ContactPoint();
        }
        this.reset();
    }

    reset() {
        this.bodyA = null;
        this.bodyB = null;
        this.numContacts = 0;
        // 无需重置每个 ContactPoint,因为它们会通过 getNewContactPoint 被覆盖
    }

    /**
     * 获取一个空的 ContactPoint 实例来填充数据
     * @returns {ContactPoint}
     */
    getNewContactPoint() {
        if (this.numContacts < this.contacts.length) {
            const contact = this.contacts[this.numContacts];
            contact.reset(); // 重置要使用的接触点
            this.numContacts++;
            return contact;
        } else {
            console.warn("ContactManifold exhausted its pre-allocated contact points!");
            return null; // 或者返回一个临时池化对象,但最好避免
        }
    }

    // ... 其他方法,如添加接触点,合并流形等
}

// 全局对象池实例
const collisionPairPool = new ObjectPool(CollisionPair, 500); // 假设最多500对碰撞
const contactManifoldPool = new ObjectPool(ContactManifold, 200); // 假设最多200个碰撞流形
// 注意:ContactPoint 实例是预分配在 ContactManifold 内部的,不需要单独池化

3. 物理世界更新循环 (PhysicsWorld.step)

这是我们“零GC”努力的核心。整个物理模拟的每帧更新都必须避免分配新的内存。

class PhysicsWorld {
    constructor(maxBodies = 1000) {
        this.bodies = new Array(maxBodies);
        this.numActiveBodies = 0;

        // 预分配所有刚体
        for (let i = 0; i < maxBodies; i++) {
            this.bodies[i] = new RigidBody(i); // 传入ID以便调试
        }

        // 临时向量池,用于各种计算
        this.tempVec1 = Vec3.create();
        this.tempVec2 = Vec3.create();
        this.tempVec3 = Vec3.create();
        this.tempQuat1 = Quat.create(); // 临时四元数
        this.tempMat1 = Mat3.create();  // 临时矩阵

        // 碰撞相关的池
        this.collisionPairPool = new ObjectPool(CollisionPair, 500);
        this.contactManifoldPool = new ObjectPool(ContactManifold, 200);

        // 存储当前帧的碰撞对和流形
        this.activeCollisionPairs = []; // 存储 CollisionPair 对象
        this.activeContactManifolds = []; // 存储 ContactManifold 对象
    }

    addBody(body) {
        if (this.numActiveBodies < this.bodies.length) {
            this.bodies[this.numActiveBodies] = body;
            body.isActive = true;
            this.numActiveBodies++;
        } else {
            console.warn("Max bodies reached, cannot add more.");
        }
    }

    /**
     * 物理世界每帧更新
     * @param {number} dt - 时间步长 (例如 1/60 秒)
     */
    step(dt) {
        // 1. 清空上一帧的临时碰撞数据,归还到对象池
        this.collisionPairPool.releaseAllInUse();
        this.contactManifoldPool.releaseAllInUse();
        this.activeCollisionPairs.length = 0; // 清空数组,不触发GC
        this.activeContactManifolds.length = 0; // 清空数组,不触发GC

        // 2. 积分阶段 (Integration) - 更新位置、速度、方向
        for (let i = 0; i < this.numActiveBodies; i++) {
            const body = this.bodies[i];
            if (!body.isActive) continue;

            // 计算线性加速度: a = F/m
            Vec3.scale(body.acceleration, body.force, body.invMass);
            // 更新线性速度: v += a * dt
            Vec3.scale(this.tempVec1, body.acceleration, dt);
            Vec3.add(body.velocity, body.velocity, this.tempVec1);

            // 更新位置: p += v * dt
            Vec3.scale(this.tempVec1, body.velocity, dt);
            Vec3.add(body.position, body.position, this.tempVec1);

            // 类似地处理角加速度、角速度和方向 (四元数积分)
            // ...

            // 重置力/扭矩,以便下一帧重新累积
            Vec3.set(body.force, 0, 0, 0);
            Vec3.set(body.torque, 0, 0, 0);
        }

        // 3. 碰撞检测 (Collision Detection)
        // 3.1 宽相 (Broad Phase) - 找出潜在的碰撞对
        // 假设这里用一个简单的N^2检测,实际会用AABB树、BVH或空间哈希等
        for (let i = 0; i < this.numActiveBodies; i++) {
            const bodyA = this.bodies[i];
            if (!bodyA.isActive) continue;
            for (let j = i + 1; j < this.numActiveBodies; j++) {
                const bodyB = this.bodies[j];
                if (!bodyB.isActive) continue;

                // 简单的AABB重叠检测 (需要优化,这里只是示意)
                // Vec3.sub(this.tempVec1, bodyA.position, bodyB.position);
                // if (Vec3.length(this.tempVec1) < (bodyA.radius + bodyB.radius)) { // 假设有radius属性
                if (this._checkAABBOverlap(bodyA, bodyB, this.tempVec1, this.tempVec2)) { // _checkAABBOverlap 内部也零GC
                    const pair = this.collisionPairPool.get();
                    pair.set(bodyA, bodyB);
                    this.activeCollisionPairs.push(pair);
                }
            }
        }

        // 3.2 窄相 (Narrow Phase) - 精确碰撞检测,生成接触流形
        for (let i = 0; i < this.activeCollisionPairs.length; i++) {
            const pair = this.activeCollisionPairs[i];
            const manifold = this.contactManifoldPool.get(); // 从池中获取流形
            manifold.bodyA = pair.bodyA;
            manifold.bodyB = pair.bodyB;

            // 调用具体的碰撞算法 (例如 GJK 或 EPA)
            // _generateContacts 内部也会使用 ContactManifold 预分配的 ContactPoint
            if (this._generateContacts(pair.bodyA, pair.bodyB, manifold, this.tempVec1, this.tempVec2, this.tempVec3)) {
                this.activeContactManifolds.push(manifold);
            } else {
                // 如果没有碰撞,将流形归还
                this.contactManifoldPool.release(manifold);
            }
        }

        // 4. 碰撞求解 (Collision Resolution) - 施加冲量来解决穿透和模拟反弹
        for (let i = 0; i < this.activeContactManifolds.length; i++) {
            const manifold = this.activeContactManifolds[i];
            // 对每个接触点应用冲量和位置校正
            for (let k = 0; k < manifold.numContacts; k++) {
                const contact = manifold.contacts[k];
                // 计算冲量,并应用到刚体的速度和角速度上
                this._applyImpulse(manifold.bodyA, manifold.bodyB, contact, this.tempVec1, this.tempVec2); // 内部零GC
            }
        }

        // 5. 约束求解 (Constraint Solver) - 处理关节、摩擦等
        // ... (类似碰撞求解,使用预分配的临时变量和对象池)

        // 至此,一帧的物理更新完成,没有在核心循环中产生新的对象。
    }

    // 辅助函数,确保它们也遵循零GC原则
    _checkAABBOverlap(bodyA, bodyB, tempVecA, tempVecB) {
        // 使用传入的临时向量进行计算
        // ... 具体的AABB检测逻辑 ...
        return true; // 假设重叠
    }

    _generateContacts(bodyA, bodyB, manifold, tempVec1, tempVec2, tempVec3) {
        // 这是窄相的核心,复杂但必须零GC
        // 例如,对于球体-球体碰撞:
        Vec3.sub(tempVec1, bodyB.position, bodyA.position); // 相对位置
        const distSq = Vec3.dot(tempVec1, tempVec1);
        const radiusSum = bodyA.radius + bodyB.radius; // 假设有radius
        if (distSq < radiusSum * radiusSum) {
            const dist = Math.sqrt(distSq);
            const depth = radiusSum - dist;
            if (depth > 0) {
                const contact = manifold.getNewContactPoint(); // 从流形中获取预分配的接触点
                Vec3.normalize(contact.normal, tempVec1);
                Vec3.scale(tempVec2, contact.normal, bodyA.radius);
                Vec3.add(contact.position, bodyA.position, tempVec2); // 接触点位置
                contact.depth = depth;
                return true; // 发现碰撞
            }
        }
        return false; // 无碰撞
    }

    _applyImpulse(bodyA, bodyB, contact, tempVec1, tempVec2) {
        // 计算和应用冲量,所有计算都应使用临时向量和原地操作
        // ...
    }
}

关键点总结:

  • PhysicsWorld.step 方法内部没有 new 关键字。
  • 所有临时对象(如 CollisionPair, ContactManifold)都从对应的对象池中获取和归还。
  • 所有数学运算都使用静态工具类(如 Vec3.add(out, a, b)),将结果写入预先提供的 out 对象。
  • 刚体、流形等复杂对象内部的向量和矩阵也都是在构造函数中一次性分配好的,并在运行时原地修改。
  • 数组的 length = 0 操作可以清空数组而不会触发GC。

调试与性能分析

即使遵循了零GC原则,也需要工具来验证。

  • Chrome DevTools – Performance (性能): 录制性能配置文件,观察时间轴上的“垃圾回收”事件。如果看到频繁或长时间的GC暂停,说明还有内存分配热点。
  • Chrome DevTools – Memory (内存):
    • Heap Snapshot (堆快照): 拍摄前后两次快照,比较差异,找出哪些对象在持续增长,可能存在泄漏或未被正确回收。
    • Allocation timeline (分配时间线): 记录一段时间内的内存分配情况。这能精确显示代码的哪一行创建了哪些对象。这是找出GC热点的最有效工具。在录制时,如果看到在帧循环中持续出现大量的小对象分配,那就是GC的潜在来源。

高级考量与权衡

初始化阶段的GC是可接受的

“零GC”原则主要针对运行时性能关键路径。在应用程序的初始化阶段(例如加载关卡、创建初始物体、设置UI)进行内存分配和GC是完全可以接受的,因为这不会影响用户在游戏或模拟过程中的实时体验。

可读性与维护性

“零GC”代码通常比传统的“按需创建”代码更复杂、更冗长,对开发者要求更高。例如,频繁的 out 参数会使函数签名变长。

// 传统:
const newVec = vec1.add(vec2).scale(5);

// 零GC:
Vec3.add(tempVec1, vec1, vec2);
Vec3.scale(newVec, tempVec1, 5); // 假设newVec是预分配的

这要求团队在性能和代码可读性之间做出权衡。对于非性能关键部分,不必强求零GC。

Web Workers 与 SharedArrayBuffer

将物理计算卸载到Web Worker中是另一种优化策略,可以避免物理计算阻塞主线程的渲染。然而,如果Worker和主线程之间频繁传递大量数据,数据复制本身也是一种内存分配和性能开销。

为了实现真正的零拷贝通信,可以使用 SharedArrayBuffer 结合 Atomics。通过 SharedArrayBuffer,主线程和Worker可以共享同一块内存,避免数据复制,从而实现高效的零GC通信。但这引入了并发编程的复杂性,如竞态条件和同步问题。

什么时候不适用“零GC”

  • 非实时、非性能关键的应用: 对于普通的网站、后台管理系统、不涉及复杂动画和交互的Web应用,过早优化是浪费时间。JavaScript的GC在大多数情况下表现良好。
  • 开发初期: 在原型开发阶段,优先考虑开发速度和功能实现。过早引入零GC模式会增加开发难度。
  • 内存使用量不大: 如果你的应用总内存占用很小,GC的频率和暂停时间可能不会成为问题。

总结

在JavaScript中实现一个高性能、稳帧运行的物理引擎,需要我们对内存管理有一个深刻的理解。垃圾回收机制是双刃剑:它简化了开发,但也可能在不经意间引入性能瓶颈。通过采纳“零GC”的编程哲学,即在核心更新循环中杜绝新的内存分配,转而采用对象池、预分配和原地操作等策略,我们可以有效地消除GC暂停带来的卡顿,确保物理引擎在60FPS下稳定运行。

这要求开发者在思维模式上做出转变,从依赖自动内存管理转向主动管理内存。这无疑会增加代码的复杂性,但对于追求极致性能的实时应用而言,这种投入是值得的。通过Chrome DevTools等工具进行持续的性能分析和内存调试,将帮助我们识别和解决潜在的GC问题,最终交付一个流畅、响应迅速的用户体验。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注