Babylon.js Integration¶
Use @ifc-lite/geometry with Babylon.js instead of the built-in WebGPU renderer.
Why Babylon.js?¶
The built-in @ifc-lite/renderer uses WebGPU for maximum performance, but you may want Babylon.js when:
- WebGPU is unavailable — Babylon.js Engine works with WebGL (wider browser support)
- Existing Babylon.js ecosystem — integrate with your existing 3D scene, GUI, physics, XR
- Built-in camera controls —
ArcRotateCamerahandles orbit/pan/zoom with no extras - PBR & post-processing — Babylon.js ships with a full PBR pipeline, SSAO, bloom, and more
Three.js or Babylon.js?
Both engines receive the same MeshData typed arrays from @ifc-lite/geometry — the bridge code is the only difference. Choose whichever engine your team already uses. See also the Three.js Integration tutorial.
Architecture¶
The key insight: @ifc-lite/geometry outputs plain typed arrays that are engine-agnostic:
interface MeshData {
expressId: number;
ifcType?: string; // "IfcWall", "IfcDoor", etc.
positions: Float32Array; // [x,y,z, x,y,z, ...]
normals: Float32Array; // [nx,ny,nz, ...]
indices: Uint32Array; // Triangle indices
color: [number, number, number, number]; // RGBA (0-1)
origin?: [number, number, number]; // Per-element local-frame origin (see below)
}
Two contracts to respect when consuming MeshData:
origin: when present,positionsare relative to a per-element local frame and the world position of vertexiisorigin + positions[3i..3i+3](this preserves f32 precision on building-scale coordinates). Fold it in by translating the mesh (mesh.position.set(...)), or by adding it to the vertices when you merge geometry. Absent means positions are already absolute.- Winding order is unreliable: IFC triangle winding is not consistently
outward. If faces appear missing, set
material.backFaceCulling = false.
You only need @ifc-lite/geometry (and its dependency @ifc-lite/wasm) — skip @ifc-lite/renderer entirely.
Quick Start¶
Scaffold a project¶
Or install manually¶
Convert MeshData to Babylon.js¶
import {
Mesh, VertexData, StandardMaterial, Color3,
} from '@babylonjs/core';
import type { Scene } from '@babylonjs/core';
import type { MeshData } from '@ifc-lite/geometry';
function meshDataToBabylon(meshData: MeshData, scene: Scene): Mesh {
const mesh = new Mesh('entity-' + meshData.expressId, scene);
const vertexData = new VertexData();
vertexData.positions = meshData.positions;
vertexData.normals = meshData.normals;
vertexData.indices = meshData.indices;
vertexData.applyToMesh(mesh);
// Fold the per-element local-frame origin (world = origin + positions).
if (meshData.origin) {
mesh.position.set(meshData.origin[0], meshData.origin[1], meshData.origin[2]);
}
const [r, g, b, a] = meshData.color;
const material = new StandardMaterial('mat-' + meshData.expressId, scene);
material.diffuseColor = new Color3(r, g, b);
material.specularColor = new Color3(0.15, 0.15, 0.15);
if (a < 1) {
material.alpha = a;
material.backFaceCulling = false;
}
mesh.material = material;
mesh.metadata = { expressId: meshData.expressId };
return mesh;
}
Load and Display¶
import { GeometryProcessor } from '@ifc-lite/geometry';
const processor = new GeometryProcessor();
await processor.init();
const buffer = new Uint8Array(await file.arrayBuffer());
const result = await processor.process(buffer);
for (const mesh of result.meshes) {
meshDataToBabylon(mesh, scene);
}
Streaming (Progressive Display)¶
For large files, use streaming to show geometry as it loads:
for await (const event of processor.processStreaming(new Uint8Array(buffer))) {
switch (event.type) {
case 'batch':
for (const mesh of event.meshes) {
meshDataToBabylon(mesh, scene);
}
break;
case 'complete':
console.log(`Loaded ${event.totalMeshes} meshes`);
break;
}
}
processAdaptive(buffer) yields the same events and picks the strategy for
you: small files (under 2 MB by default) arrive as a single batch event,
large files go through the streaming or parallel worker path. On the parallel
path, heavily repeated opaque elements are packed into instancedShards
buffers instead of event.meshes; if you consume only event.meshes,
construct the processor with new GeometryProcessor({ enableInstancing: false })
so every element stays on the flat path.
Batching for Performance¶
Individual meshes per entity give you picking granularity but many draw calls. For large models, merge geometry with vertex colors:
function batchWithVertexColors(
meshes: MeshData[],
scene: Scene,
): Mesh {
// Calculate totals
let totalVertices = 0, totalIndices = 0;
for (const m of meshes) {
totalVertices += m.positions.length / 3;
totalIndices += m.indices.length;
}
const positions = new Float32Array(totalVertices * 3);
const normals = new Float32Array(totalVertices * 3);
const colors = new Float32Array(totalVertices * 4); // RGBA per vertex
const indices = new Uint32Array(totalIndices);
let vOffset = 0, iOffset = 0;
for (const m of meshes) {
const vertCount = m.positions.length / 3;
// Merging bakes vertices into one buffer, so fold each mesh's
// local-frame origin into the copied positions.
const [ox, oy, oz] = m.origin ?? [0, 0, 0];
for (let i = 0; i < m.positions.length; i += 3) {
positions[vOffset * 3 + i] = m.positions[i] + ox;
positions[vOffset * 3 + i + 1] = m.positions[i + 1] + oy;
positions[vOffset * 3 + i + 2] = m.positions[i + 2] + oz;
}
normals.set(m.normals, vOffset * 3);
// Write vertex colors
const [r, g, b] = m.color;
for (let v = 0; v < vertCount; v++) {
const base = (vOffset + v) * 4;
colors[base] = r;
colors[base + 1] = g;
colors[base + 2] = b;
colors[base + 3] = 1;
}
// Offset indices
for (let i = 0; i < m.indices.length; i++) {
indices[iOffset + i] = m.indices[i] + vOffset;
}
vOffset += vertCount;
iOffset += m.indices.length;
}
const mesh = new Mesh('batched', scene);
const vertexData = new VertexData();
vertexData.positions = positions;
vertexData.normals = normals;
vertexData.colors = colors;
vertexData.indices = indices;
vertexData.applyToMesh(mesh);
// White diffuse so vertex colors show through
const material = new StandardMaterial('batched-mat', scene);
material.diffuseColor = new Color3(1, 1, 1);
material.specularColor = new Color3(0.15, 0.15, 0.15);
mesh.material = material;
return mesh;
}
Scene Setup¶
Engine and Scene¶
import {
Engine, Scene, ArcRotateCamera,
HemisphericLight, DirectionalLight,
Vector3, Color4,
} from '@babylonjs/core';
const canvas = document.getElementById('viewer') as HTMLCanvasElement;
const engine = new Engine(canvas, true);
const scene = new Scene(engine);
scene.clearColor = new Color4(0.1, 0.1, 0.18, 1);
// ArcRotateCamera handles orbit/pan/zoom with no extra library
const camera = new ArcRotateCamera(
'camera',
-Math.PI / 4, // horizontal angle
Math.PI / 3, // vertical angle (~60 deg)
50, // distance from target
Vector3.Zero(), // target
scene,
);
camera.attachControl(canvas, true);
camera.minZ = 0.1;
camera.maxZ = 10000;
// Lighting
const hemiLight = new HemisphericLight('hemi', new Vector3(0, 1, 0), scene);
hemiLight.intensity = 0.6;
const dirLight = new DirectionalLight(
'dir', new Vector3(-1, -2, -1).normalize(), scene,
);
dirLight.intensity = 0.8;
dirLight.position = new Vector3(50, 80, 50);
// Resize
window.addEventListener('resize', () => engine.resize());
// Render loop
engine.runRenderLoop(() => scene.render());
Fit Camera to Model¶
function fitCameraToScene(camera: ArcRotateCamera, scene: Scene) {
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const mesh of scene.meshes) {
mesh.computeWorldMatrix(true);
const bounds = mesh.getBoundingInfo().boundingBox;
const bMin = bounds.minimumWorld;
const bMax = bounds.maximumWorld;
if (bMin.x < minX) minX = bMin.x;
if (bMin.y < minY) minY = bMin.y;
if (bMin.z < minZ) minZ = bMin.z;
if (bMax.x > maxX) maxX = bMax.x;
if (bMax.y > maxY) maxY = bMax.y;
if (bMax.z > maxZ) maxZ = bMax.z;
}
const center = new Vector3(
(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2,
);
const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
camera.target = center;
camera.radius = maxDim * 1.5;
camera.minZ = maxDim * 0.001;
camera.maxZ = maxDim * 100;
}
Entity Picking¶
Click to Select¶
Babylon.js provides built-in scene.pick() — no separate raycaster needed:
canvas.addEventListener('click', (event) => {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const pickResult = scene.pick(x, y);
if (pickResult.hit && pickResult.pickedMesh) {
const expressId = pickResult.pickedMesh.metadata?.expressId;
console.log('Selected entity:', expressId);
}
});
Highlight on Hover¶
let hoveredMesh: Mesh | null = null;
const highlightColor = new Color3(0.31, 0.27, 0.90); // #4f46e5
canvas.addEventListener('pointermove', (event) => {
// Reset previous
if (hoveredMesh?.material) {
const mat = hoveredMesh.material as StandardMaterial;
mat.emissiveColor = Color3.Black();
}
const rect = canvas.getBoundingClientRect();
const pickResult = scene.pick(
event.clientX - rect.left,
event.clientY - rect.top,
);
if (pickResult.hit && pickResult.pickedMesh) {
hoveredMesh = pickResult.pickedMesh as Mesh;
const mat = hoveredMesh.material as StandardMaterial;
mat.emissiveColor = highlightColor;
} else {
hoveredMesh = null;
}
});
Picking with Batched Meshes¶
When geometry is merged into a single mesh, use faceId and a triangle range map to identify the original entity:
type TriangleRange = { expressId: number; start: number; count: number };
function findEntityByFace(
ranges: TriangleRange[],
faceId: number,
): number | null {
// Binary search — ranges are sorted by start
let lo = 0, hi = ranges.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const r = ranges[mid];
if (faceId < r.start) { hi = mid - 1; }
else if (faceId >= r.start + r.count) { lo = mid + 1; }
else { return r.expressId; }
}
return null;
}
// Usage with scene.pick()
const pickResult = scene.pick(x, y);
if (pickResult.hit && pickResult.faceId >= 0) {
const ranges = triangleMaps.get(pickResult.pickedMesh as Mesh);
if (ranges) {
const expressId = findEntityByFace(ranges, pickResult.faceId);
console.log('Selected entity:', expressId);
}
}
Coordinate Handling¶
IFC files may use large georeferenced coordinates. The geometry processor handles this automatically:
const result = await processor.process(new Uint8Array(buffer));
if (result.coordinateInfo.hasLargeCoordinates) {
const shift = result.coordinateInfo.originShift;
console.log(`Coordinates shifted by (${shift.x}, ${shift.y}, ${shift.z})`);
// The shift was SUBTRACTED from the mesh positions, so to recover the
// original file coordinates (e.g. for geolocation) add it back:
function toOriginal(local: Vector3): Vector3 {
return new Vector3(
local.x + shift.x,
local.y + shift.y,
local.z + shift.z,
);
}
}
Performance Tips¶
| Strategy | When to use | Benefit |
|---|---|---|
| Individual meshes | Small models, need picking | Simple, per-entity control |
| Vertex-color batching | Medium–large models | 1–2 draw calls for entire model |
mesh.freezeWorldMatrix() |
Static models | Skip per-frame matrix recalc |
material.freeze() |
Static materials | Skip per-frame shader dirty checks |
scene.skipPointerMovePicking |
Custom hover logic | Skip Babylon's built-in move picking |
| Frustum culling | Large scenes | Only render visible geometry |
Babylon.js-specific optimisations¶
// After loading is complete, freeze everything that won't move
for (const mesh of scene.meshes) {
mesh.freezeWorldMatrix();
}
for (const mat of scene.materials) {
mat.freeze();
}
// Disable Babylon's per-frame pointer-move picking when you handle hover yourself
scene.skipPointerMovePicking = true;
// Use preserveDrawingBuffer: false to avoid GPU→CPU readback each frame
const engine = new Engine(canvas, true, {
preserveDrawingBuffer: false,
stencil: false,
});
Vite Configuration¶
The COOP/COEP headers enable SharedArrayBuffer, which the geometry worker
pool uses to share the IFC bytes across workers. The workers are ES modules,
so force the ESM worker format for production builds:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
worker: {
format: 'es',
},
optimizeDeps: {
exclude: ['@ifc-lite/wasm'],
},
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});
server.headers is dev-only
The server.headers block above only applies to Vite's dev server. In
production or preview builds these responses come from your hosting layer or
CDN, so serve the same Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp headers there too — otherwise
SharedArrayBuffer is unavailable and the worker pool falls back to slower
per-worker copies. (Vite's preview server also honours a matching
preview.headers block.)
Full Example¶
See examples/babylonjs-viewer/ for a complete, runnable example with streaming geometry, vertex-color batching, entity picking, property panel, and spatial tree navigation.
Comparison: Three.js vs Babylon.js Bridge¶
Both engines receive the same MeshData typed arrays. Here's how the bridge code differs:
| Concept | Three.js | Babylon.js |
|---|---|---|
| Geometry container | BufferGeometry + BufferAttribute |
VertexData + applyToMesh() |
| Material | MeshStandardMaterial |
StandardMaterial |
| Vertex colors | setAttribute('color', ...) + vertexColors: true |
vertexData.colors (used automatically) |
| Grouping | THREE.Group |
TransformNode |
| Camera + orbit | PerspectiveCamera + OrbitControls addon |
ArcRotateCamera (built-in) |
| Picking | Raycaster + intersectObjects() |
scene.pick(x, y) |
| Render loop | requestAnimationFrame + renderer.render() |
engine.runRenderLoop() |
Next Steps¶
- Three.js Integration — Same workflow with Three.js / R3F
- Building a Viewer — Full viewer with WebGPU
- Geometry Processing — Geometry API details
- API Reference — Complete API docs