Matter.js Spatial Chunking for Moving Cameras
This article explains how to optimize large-scale 2D worlds in Matter.js by implementing a spatial chunking system that dynamically sleeps and wakes bodies based on a moving camera's position. By partitioning the simulation space into discrete grid cells and querying only visible chunks plus a small buffer, you can maintain a smooth 60 FPS even when managing thousands of physics entities.
Enabling the Matter.js Sleeping Module
Before managing bodies manually, you must configure the Matter.js engine to allow sleep states. Enable this feature during engine initialization:
const engine = Matter.Engine.create({
enableSleeping: true
});When a body is set to sleep, Matter.js skips broadphase collision detection, narrowphase resolution, and position integration for that body, drastically reducing CPU load.
Designing the Spatial Grid
Divide your game world into fixed-size grid cells (chunks). The chunk size should typically be slightly larger than your physics bodies, often between 256 and 512 pixels, to balance memory overhead and lookup frequency.
Use a hash map to store chunk keys paired with arrays of bodies residing in those chunks:
class SpatialGrid {
constructor(chunkSize = 512) {
this.chunkSize = chunkSize;
this.chunks = new Map();
}
getChunkKey(x, y) {
const cx = Math.floor(x / this.chunkSize);
const cy = Math.floor(y / this.chunkSize);
return `${cx},${cy}`;
}
insert(body) {
const key = this.getChunkKey(body.position.x, body.position.y);
if (!this.chunks.has(key)) {
this.chunks.set(key, new Set());
}
this.chunks.get(key).add(body);
body.currentChunkKey = key;
}
updateBodyChunk(body) {
const newKey = this.getChunkKey(body.position.x, body.position.y);
if (body.currentChunkKey !== newKey) {
if (this.chunks.has(body.currentChunkKey)) {
this.chunks.get(body.currentChunkKey).delete(body);
}
this.insert(body);
}
}
}Calculating Visible Chunks Around the Camera
To prevent physics pop-in where objects suddenly freeze or drop visibly at the edge of the screen, determine the bounding box of the camera and apply a padding margin (buffer).
Calculate the range of chunk coordinates that overlap this expanded camera rectangle:
function getActiveChunkKeys(camera, viewportWidth, viewportHeight, chunkSize, padding = 1) {
const left = camera.x - viewportWidth / 2;
const right = camera.x + viewportWidth / 2;
const top = camera.y - viewportHeight / 2;
const bottom = camera.y + viewportHeight / 2;
const minChunkX = Math.floor(left / chunkSize) - padding;
const maxChunkX = Math.floor(right / chunkSize) + padding;
const minChunkY = Math.floor(top / chunkSize) - padding;
const maxChunkY = Math.floor(bottom / chunkSize) + padding;
const activeKeys = new Set();
for (let cx = minChunkX; cx <= maxChunkX; cx++) {
for (let cy = minChunkY; cy <= maxChunkY; cy++) {
activeKeys.add(`${cx},${cy}`);
}
}
return activeKeys;
}Activating and Sleeping Bodies Dynamically
Run a management function inside your game update loop (e.g., prior
to Matter.Engine.update). Compare the active chunks from
the current frame against previously loaded chunks:
let currentlyActiveKeys = new Set();
function updatePhysicsStreaming(grid, camera, viewportWidth, viewportHeight) {
const newActiveKeys = getActiveChunkKeys(camera, viewportWidth, viewportHeight, grid.chunkSize);
// Wake up bodies in newly entered chunks
for (const key of newActiveKeys) {
if (!currentlyActiveKeys.has(key) && grid.chunks.has(key)) {
for (const body of grid.chunks.get(key)) {
Matter.Sleeping.set(body, false);
}
}
}
// Put bodies to sleep in chunks no longer in view
for (const key of currentlyActiveKeys) {
if (!newActiveKeys.has(key) && grid.chunks.has(key)) {
for (const body of grid.chunks.get(key)) {
Matter.Sleeping.set(body, true);
}
}
}
currentlyActiveKeys = newActiveKeys;
}Handling Moving Bodies
While static bodies remain in their assigned chunks permanently, moving bodies (such as players, projectiles, or active debris) require chunk updates.
Hook into the beforeUpdate event of the Matter.js engine
to update the grid positions of moving objects:
Matter.Events.on(engine, 'beforeUpdate', () => {
// Only re-index awake, moving bodies
for (const body of engine.world.bodies) {
if (!body.isStatic && !body.isSleeping) {
grid.updateBodyChunk(body);
}
}
updatePhysicsStreaming(grid, camera, window.innerWidth, window.innerHeight);
});Performance Considerations
- Chunk Resizing: If bodies frequently clump together in small areas, decrease the chunk size to avoid waking too many non-visible bodies at once.
- Excluding Critical Entities: Entities like the player, critical quest items, or global triggers should either bypass the chunk manager or be marked as persistent so they never sleep regardless of camera movement.
- Static Body Aggregation: If your static environment is massive, consider keeping static obstacles fully awake since sleeping static bodies yield minimal CPU savings, and apply chunk streaming only to dynamic bodies.