Calculating Billboard Position on 3DTiles Models Using Oriented Bounding Boxes in Cesium

Problem Overview

When working with 3D building models in Cesium, positioning billboards accurately on top of structures requires precise geometric calculations. The standard bounding sphere appraoch often places billboards at incorrect heights, as it doesn't conform to the actual shape of the building.

Initial Approach and Issue

The initial implementation used Cesium's bounding sphere to determine billboard placement:

viewer.screenSpaceEventHandler.setInputAction((event) => {
    const pickedFeature = viewer.scene.pick(event.position)
    const tileset = pickedFeature.tileset
    const centerCartographic = Cesium.Cartographic.fromCartesian(tileset.boundingSphere.center)
    
    const positionCartographic = new Cesium.Cartographic(
        centerCartographic.longitude,
        centerCartographic.latitude,
        centerCartographic.height + tileset.boundingSphere.radius
    )
    
    const positionCartesian3 = Cesium.Cartographic.toCartesian(positionCartographic)
    
    viewer.entities.add({
        id: 'BillboardPopup',
        name: 'Model Information',
        position: positionCartesian3,
        billboard: {
            image: 'info.png',
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM
        }
    })
})

This approach resulted in billboards floating above the building rather than sitting on its roof surface, as the bounding sphere doesn't conform to the building's actual geometry.

Soultion: Using Oriented Bounding Boxes

The solution involves utilizing OrientedBoundingBox (OBB) wich provides a more accurate representation of the 3D model's boundaries. The OBB contains half-axes vectors that define the model's local coordinate system.

Step 1: Extracting OBB Vectors

First, we need to extract the three axis vectors from the OBB's halfAxes matrix:

function extractOBBVectors(tileset) {
    const obb = tileset.root.boundingVolume.boundingVolume
    const halfAxes = obb.halfAxes
    
    const xAxis = new Cesium.Cartesian3()
    const yAxis = new Cesium.Cartesian3()
    const zAxis = new Cesium.Cartesian3()
    
    Cesium.Matrix3.getColumn(halfAxes, 0, xAxis)
    Cesium.Matrix3.getColumn(halfAxes, 1, yAxis)
    Cesium.Matrix3.getColumn(halfAxes, 2, zAxis)
    
    return { xAxis, yAxis, zAxis, center: obb.center }
}

Step 2: Calculating Roof Position

With the OBB vectors, we can calculate the exact position on the building's roof by adding the z-axis vector to the center point:

function calculateRoofPosition(tileset) {
    const { xAxis, yAxis, zAxis, center } = extractOBBVectors(tileset)
    
    const roofPosition = new Cesium.Cartesian3()
    Cesium.Cartesian3.add(center, zAxis, roofPosition)
    
    return roofPosition
}

Step 3: Updated Billboard Placement

The revised implementation uses the roof position for billboard placement:

viewer.screenSpaceEventHandler.setInputAction((event) => {
    const pickedFeature = viewer.scene.pick(event.position)
    const tileset = pickedFeature.tileset
    
    const roofPosition = calculateRoofPosition(tileset)
    
    viewer.entities.add({
        id: 'BillboardPopup',
        name: 'Model Information',
        position: roofPosition,
        billboard: {
            image: 'info.png',
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            heightReference: Cesium.HeightReference.NONE
        }
    })
})

Visualizing the OBB

To better understand the OBB, we can visualize its corners and local coordinate system:

Displaying OBB Corners

function displayOBBCorners(viewer, tileset) {
    const obb = tileset.root.boundingVolume.boundingVolume
    const halfAxes = obb.halfAxes
    const center = obb.center
    
    const xAxis = new Cesium.Cartesian3()
    const yAxis = new Cesium.Cartesian3()
    const zAxis = new Cesium.Cartesian3()
    
    Cesium.Matrix3.getColumn(halfAxes, 0, xAxis)
    Cesium.Matrix3.getColumn(halfAxes, 1, yAxis)
    Cesium.Matrix3.getColumn(halfAxes, 2, zAxis)
    
    // Calculate all 8 corners
    const corners = [
        Cesium.Cartesian3.subtract(
            Cesium.Cartesian3.subtract(
                Cesium.Cartesian3.subtract(center, xAxis, new Cesium.Cartesian3()),
                yAxis, new Cesium.Cartesian3()
            ),
            zAxis, new Cesium.Cartesian3()
        ),
        Cesium.Cartesian3.add(
            Cesium.Cartesian3.subtract(
                Cesium.Cartesian3.subtract(center, xAxis, new Cesium.Cartesian3()),
                yAxis, new Cesium.Cartesian3()
            ),
            zAxis, new Cesium.Cartesian3()
        ),
        Cesium.Cartesian3.add(
            Cesium.Cartesian3.add(
                Cesium.Cartesian3.subtract(center, xAxis, new Cesium.Cartesian3()),
                yAxis, new Cesium.Cartesian3()
            ),
            zAxis, new Cesium.Cartesian3()
        ),
        Cesium.Cartesian3.subtract(
            Cesium.Cartesian3.add(
                Cesium.Cartesian3.subtract(center, xAxis, new Cesium.Cartesian3()),
                yAxis, new Cesium.Cartesian3()
            ),
            zAxis, new Cesium.Cartesian3()
        ),
        Cesium.Cartesian3.subtract(
            Cesium.Cartesian3.subtract(
                Cesium.Cartesian3.add(center, xAxis, new Cesium.Cartesian3()),
                yAxis, new Cesium.Cartesian3()
            ),
            zAxis, new Cesium.Cartesian3()
        ),
        Cesium.Cartesian3.add(
            Cesium.Cartesian3.subtract(
                Cesium.Cartesian3.add(center, xAxis, new Cesium.Cartesian3()),
                yAxis, new Cesium.Cartesian3()
            ),
            zAxis, new Cesium.Cartesian3()
        ),
        Cesium.Cartesian3.add(
            Cesium.Cartesian3.add(
                Cesium.Cartesian3.add(center, xAxis, new Cesium.Cartesian3()),
                yAxis, new Cesium.Cartesian3()
            ),
            zAxis, new Cesium.Cartesian3()
        ),
        Cesium.Cartesian3.subtract(
            Cesium.Cartesian3.add(
                Cesium.Cartesian3.add(center, xAxis, new Cesium.Cartesian3()),
                yAxis, new Cesium.Cartesian3()
            ),
            zAxis, new Cesium.Cartesian3()
        )
    ]
    
    // Add corner points to viewer
    corners.forEach((corner, index) => {
        viewer.entities.add({
            position: corner,
            point: {
                color: Cesium.Color.RED,
                pixelSize: 10
            },
            label: {
                text: (index + 1).toString(),
                font: '28px sans-serif',
                style: Cesium.LabelStyle.FILL_AND_OUTLINE,
                fillColor: Cesium.Color.BLUE,
                outlineColor: Cesium.Color.WHITE,
                outlineWidth: 2
            }
        })
    })
}

Displaying Local Coordinate Axes

function displayLocalAxes(viewer, tileset) {
    const obb = tileset.root.boundingVolume.boundingVolume
    const halfAxes = obb.halfAxes
    const center = obb.center
    
    const xAxis = new Cesium.Cartesian3()
    const yAxis = new Cesium.Cartesian3()
    const zAxis = new Cesium.Cartesian3()
    
    Cesium.Matrix3.getColumn(halfAxes, 0, xAxis)
    Cesium.Matrix3.getColumn(halfAxes, 1, yAxis)
    Cesium.Matrix3.getColumn(halfAxes, 2, zAxis)
    
    // X-axis (Red)
    viewer.entities.add({
        position: center,
        polyline: {
            positions: [center, Cesium.Cartesian3.add(center, xAxis, new Cesium.Cartesian3())],
            width: 4,
            material: Cesium.Color.RED
        }
    })
    
    // Y-axis (Green)
    viewer.entities.add({
        position: center,
        polyline: {
            positions: [center, Cesium.Cartesian3.add(center, yAxis, new Cesium.Cartesian3())],
            width: 4,
            material: Cesium.Color.GREEN
        }
    })
    
    // Z-axis (Blue)
    viewer.entities.add({
        position: center,
        polyline: {
            positions: [center, Cesium.Cartesian3.add(center, zAxis, new Cesium.Cartesian3())],
            width: 4,
            material: Cesium.Color.BLUE
        }
    })
}

Displaying the OBB Box

function displayOrientedBoundingBox(viewer, tileset) {
    const obb = tileset.root.boundingVolume.boundingVolume
    const halfAxes = obb.halfAxes
    const center = obb.center
    
    const xAxis = new Cesium.Cartesian3()
    const yAxis = new Cesium.Cartesian3()
    const zAxis = new Cesium.Cartesian3()
    
    Cesium.Matrix3.getColumn(halfAxes, 0, xAxis)
    Cesium.Matrix3.getColumn(halfAxes, 1, yAxis)
    Cesium.Matrix3.getColumn(halfAxes, 2, zAxis)
    
    const length = Cesium.Cartesian3.distance(Cesium.Cartesian3.add(center, xAxis, new Cesium.Cartesian3()), center) * 2
    const width = Cesium.Cartesian3.distance(Cesium.Cartesian3.add(center, yAxis, new Cesium.Cartesian3()), center) * 2
    const height = Cesium.Cartesian3.distance(Cesium.Cartesian3.add(center, zAxis, new Cesium.Cartesian3()), center) * 2
    
    viewer.entities.add({
        position: center,
        box: {
            dimensions: new Cesium.Cartesian3(length, width, height),
            material: Cesium.Color.BLUEVIOLET.withAlpha(0.2),
            outline: true,
            outlineColor: Cesium.Color.CYAN
        }
    })
}

Understanding Matrix3 Operations

The Cesium.Matrix3.getColumn method retrieves a column from the matrix at the specified index. Despite the matrix being defined in row-major order for readability, the getColumn function accesses columns as expected. This behavior is documented in the Cesium API reference, where it's specified that the method retrieves a copy of the matrix column at the provided index.

When working with OrientedBoundingBox, the three columns of the halfAxes matrix represent the local x, y, and z axes of the 3D model, allowing for precise geometric calculations relative to the model's orientation.

Tags: cesium 3DTiles OrientedBoundingBox Billboard 3D Visualization

Posted on Wed, 03 Jun 2026 17:02:04 +0000 by snakez