Implementing Ammo.js Debug Drawer in Custom Renderer

Visualizing physics colliders is crucial for debugging 3D simulations. This article provides a step-by-step guide on how to implement a visual physics debug drawer for the Ammo.js physics engine inside a custom WebGL or Three.js renderer. You will learn how to interface with Ammo’s btIDebugDraw interface, extract line drawing data, and render it using your custom graphics pipeline.

1. Understand the Ammo.js Debug Interface

Ammo.js (a port of the Bullet Physics engine) features a built-in debug drawer system. It works by traversing the physics world, calculating the wireframes of all active collision shapes, and passing the start and end coordinates of each line segment to a user-defined helper class.

To bridge Ammo.js with your custom renderer, you must implement a JavaScript object that mimics the btIDebugDraw interface. The key methods Ammo.js expects are:

2. Implementing the JS Debug Drawer Wrapper

In Ammo.js, you can instantiate a btIDebugDraw subclass by wrapping a plain JavaScript object. Because physics debug rendering generates thousands of lines per frame, you should collect all vertices into a dynamic array during the draw phase, and then upload them to your GPU in a single batch.

Below is the implementation of the debug drawer class:

class AmmoDebugDrawer {
    constructor(ammoInstance, world) {
        this.ammo = ammoInstance;
        this.world = world;
        
        this.debugMode = 1; // 1 corresponds to DBG_DrawWireframe
        this.vertices = [];
        this.colors = [];

        // Create the Ammo.js C++ wrapper
        this.drawer = new this.ammo.DebugDrawer();
        
        // Bind the required C++ interface methods
        this.drawer.drawLine = this.drawLine.bind(this);
        this.drawer.drawContactPoint = this.drawContactPoint.bind(this);
        this.drawer.reportErrorWarning = this.reportErrorWarning.bind(this);
        this.drawer.draw3dText = this.draw3dText.bind(this);
        this.drawer.setDebugMode = this.setDebugMode.bind(this);
        this.drawer.getDebugMode = this.getDebugMode.bind(this);

        // Register the drawer with the physics world
        this.world.setDebugDrawer(this.drawer);
    }

    drawLine(from, to, color) {
        // Wrap pointers in btVector3 if they are not already object instances
        const pFrom = this.ammo.wrapPointer(from, this.ammo.btVector3);
        const pTo = this.ammo.wrapPointer(to, this.ammo.btVector3);
        const pColor = this.ammo.wrapPointer(color, this.ammo.btVector3);

        // Push positions
        this.vertices.push(pFrom.x(), pFrom.y(), pFrom.z());
        this.vertices.push(pTo.x(), pTo.y(), pTo.z());

        // Push colors (RGB) for both vertices
        const r = pColor.x();
        const g = pColor.y();
        const b = pColor.z();
        this.colors.push(r, g, b, r, g, b);
    }

    drawContactPoint(pointOnB, normalOnB, distance, lifeTime, color) {
        // Optional: Implement visual markers for collision contacts
    }

    reportErrorWarning(warningString) {
        console.warn("Ammo.js Warning: ", warningString);
    }

    draw3dText(location, textString) {
        // Optional: Render text in 3D space
    }

    setDebugMode(debugMode) {
        this.debugMode = debugMode;
    }

    getDebugMode() {
        return this.debugMode;
    }

    clear() {
        this.vertices.length = 0;
        this.colors.length = 0;
    }
}

3. Integrating with Your Custom WebGL Renderer

Once you have gathered the lines, you need to draw them. In a custom WebGL renderer, you should create a dedicated shader program for lines and a dynamic WebGL Buffer.

The Shader Program

Use a simple vertex shader that passes positions and colors, and a fragment shader that renders the vertex colors:

// Vertex Shader
attribute vec3 position;
attribute vec3 color;
varying vec3 vColor;
uniform mat4 uViewProjection;

void main() {
    vColor = color;
    gl_Position = uViewProjection * vec4(position, 1.0);
}

// Fragment Shader
precision mediump float;
varying vec3 vColor;

void main() {
    gl_FragColor = vec4(vColor, 1.0);
}

The WebGL Rendering Loop

In your application loop, clear the line arrays, force Ammo.js to populate them by calling debugDrawWorld(), upload the arrays to your WebGL buffers, and issue a draw call using gl.LINES.

class PhysicsDebugger {
    constructor(gl, ammoInstance, world) {
        this.gl = gl;
        this.drawer = new AmmoDebugDrawer(ammoInstance, world);
        
        // Create WebGL Buffers
        this.positionBuffer = gl.createBuffer();
        this.colorBuffer = gl.createBuffer();
        
        // Set up your shader program references here (program, uniforms, attributes)
        this.shaderProgram = this.initShaderProgram(); 
    }

    updateAndRender(viewProjectionMatrix) {
        const gl = this.gl;

        // 1. Clear previous frame's geometry
        this.drawer.clear();

        // 2. Ask Ammo.js to populate the drawer with current physics state
        this.drawer.world.debugDrawWorld();

        if (this.drawer.vertices.length === 0) return;

        // 3. Bind shader program
        gl.useProgram(this.shaderProgram);
        gl.uniformMatrix4fv(this.uViewProjectionLocation, false, viewProjectionMatrix);

        // 4. Upload Positions
        gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.drawer.vertices), gl.DYNAMIC_DRAW);
        gl.enableVertexAttribArray(this.positionAttribLocation);
        gl.vertexAttribPointer(this.positionAttribLocation, 3, gl.FLOAT, false, 0, 0);

        // 5. Upload Colors
        gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.drawer.colors), gl.DYNAMIC_DRAW);
        gl.enableVertexAttribArray(this.colorAttribLocation);
        gl.vertexAttribPointer(this.colorAttribLocation, 3, gl.FLOAT, false, 0, 0);

        // 6. Draw the wireframes
        const vertexCount = this.drawer.vertices.length / 3;
        gl.drawArrays(gl.LINES, 0, vertexCount);
    }
}

By separating the data-gathering step (drawLine) from the rendering pipeline, you keep performance high. This design ensures that you only make one draw call per frame to render the entire physics world wireframe, keeping the overhead of your debugging tools minimal.