VBOs vs Data Textures

Understanding Two WebGL GPU Data Strategies used in xeokit

xeokit.io

xeokit Meetup #2, Nov 18, 2025 

Lindsay Kay, Software Engineer @ Creoox AG

What WebGL Is

WebGL is a browser API for GPU-accelerated graphics.

• based on OpenGL ES
• runs inside an HTML canvas
• renders 2D and 3D graphics
• used by xeokit

xeokit.io

How WebGL Works

High-level flow:

  1. Get a WebGL context from HTMLCanvas
  2. Create buffers and shaders
  3. Upload geometry
  4. Issue draw calls
  5. GPU renders the scene

WebGL is low-level: you manage most steps manually.

xeokit.io

WebGL Rendering Pipeline

CPU → WebGL → GPU

CPU responsibilities:
• prepare buffers
• configure shaders
• manage state
• call draw commands

GPU responsibilities:
• run vertex + fragment shaders
• rasterize triangles
• write pixels to the screen

xeokit.io

VBOs

Vertex Buffer Objects

xeokit.io

What VBOs Are

VBO = GPU array containing geometry data.

[x y z] [x y z] [x y z]
vertex   vertex   vertex

Common attributes:

• positions
• normals
• UVs
• colors

Uploaded once, reused many times.

xeokit.io

How VBOs Work

Basic usage:

gl.bindBuffer(ARRAY_BUFFER, vbo)
gl.bufferData(...)

gl.vertexAttribPointer(...)
gl.enableVertexAttribArray(...)

Vertex shader receives data via attributes.

Fast and ideal for traditional geometry.

xeokit.io

Data Textures

A More Flexible Data Source

xeokit.io

What Data Textures Are

A data texture is a float/uint texture storing structured information.

Examples of what they can store:

• vertex positions
• colors
• transforms
• materials
• lookup tables
• indexing data

Textures can be randomly indexed in shaders, unlike VBOs which can only be accessed one vertex at a time.

xeokit.io

How Data Textures Work

Flow:

  1. Pack data into (e.g.) a Float32Array
  2. Upload into a 2D floating-point texture
  3. In shaders, fetch data using texelFetch()

This enables GPU-side indirection.

xeokit.io

Data Texture Shader Access

The vertex shader might do:

pos = texelFetch(positionTex, vertexIndex)

This replaces attribute fetches from VBOs.

xeokit.io

When Data Textures Are Useful

• arbitrary attributes beyond VBO limits
• large or heterogeneous datasets
• GPU-driven rendering
• pointer-style indirection
• aggressive resource reuse
• BIM/IFC model rendering
• instancing with high data variety

They trade speed for flexibility.

xeokit.io

VBOs vs Data Textures

Key Differences

xeokit.io

VBO Characteristics

• fastest vertex access
• simple API
• limited attribute count
• fixed layout
• ideal for conventional meshes

xeokit.io

Data Texture Characteristics

• slower than VBO access
• unlimited attribute count
• random indexing
• pack many data types together
• great for complex pipelines

xeokit.io

xeokit Geometry Packing

Understanding how xeokit formats its data textures

xeokit.io

Goal of xeokit's Packing

• Minimize GPU memory
• Avoid duplicated geometry
• Allow GPU-side indirection
• Enable random access into shared arrays

The format is designed for large BIM/IFC models.

xeokit.io

Global Geometry Arrays

xeokit stores all raw geometry in large, shared arrays:

• positions
• normals
• colors
• indices

These arrays are uploaded into data textures.

positionTex: [x y z x y z ...]  
normalTex:   [nx ny nz ...]  
colorTex:    [r g b ...]  
indexTex:    [i0 i1 i2 ...]

Everything is global, not per-mesh.

All values are quantized to unsigned integers.

xeokit.io

Why Global Arrays?

• Only store each vertex once
• Enable reuse of identical shapes
• Reduce fragmentation

Meshes do not own memory.
They refer into shared memory.

xeokit.io

Primitive → Mesh Lookup Table

Each draw primitive corresponds to one mesh instance.

xeokit stores a table:

primitiveIndex → meshIndex

This table is a data texture.

The shader reads this to determine what to draw for each primitive.

xeokit.io

Mesh → Ranges Lookup Table

Each mesh index points into ranges in the shared global arrays:

meshIndex → {
    vertexOffset  
    vertexCount  
    indexOffset  
    indexCount  
    colorOffset  
    pickColor  
}

This is a second lookup texture.

xeokit.io

Two-Level Indirection

// Each vertex belongs to a triangle → 3 verts per primitive
int primitiveIndex = gl_VertexID / 3;

// Primitive → Mesh lookup (stored in a data texture)
int meshIndex = texelFetch(primitiveToMeshTex, primitiveIndex).r;

// Mesh → Vertex range lookup (also stored in a data texture)
Range range;
range.start = texelFetch(meshVertexRangesTex, meshIndex).r;
range.count = texelFetch(meshVertexRangesTex, meshIndex).g;

// Compute vertex index within packed attributes
int vertexIndex = range.start + (gl_VertexID % 3);

// Fetch packed attributes from data textures
vec3 position = texelFetch(positionsTex,  vertexIndex).xyz;
vec3 normal   = texelFetch(normalsTex,    vertexIndex).xyz;
vec4 color    = texelFetch(colorsTex,     vertexIndex);

// Standard transform
gl_Position = projMat * viewMat * modelMat * vec4(position, 1.0);

Reading geometry from data textures in the vertex shader:

xeokit.io

Indirection Flow

Vertex_ID (WebGL built-in vertex index generated by draw call)
    │  
    ▼  
primitiveId  
    │  
    ▼  
meshIndex (lookup texture)  
    │  
    ▼  
meshRanges (lookup texture)  
    │  
    ▼  
positionTex / normalTex / colorTex / indexTex  
    │  
    ▼  
gl_Position (WebGL built-in output)

All resolved inside the vertex shader.

xeokit.io

More abstractly:

Example Shader Access Pattern

meshIndex = texelFetch(meshTable, primitiveId)  
ranges = texelFetch(rangeTable, meshIndex)

pos = texelFetch(positionTex, ranges.vertexOffset + vertexId)  
color = texelFetch(colorTex, ranges.colorOffset)

Indices and normals resolve the same way.

xeokit.io

Shared Geometry Reuse

If two meshes share identical geometry:

• They share vertex ranges
• They share color ranges
• Only transforms differ

No duplicated vertices or indices.

xeokit.io

Memory Footprint Benefits

• Only a few textures allocated
• Shared data reused across many objects
• Mesh definitions cost only a few bytes
• Very low overhead per IFC element

The entire scene is stored compactly.

xeokit.io

Upload Efficiency

• Large arrays uploaded once
• Lookup tables are small
• No thousands of buffer creations
• Faster load times for huge IFC models

Ideal for streaming.

xeokit.io

Draw Call Simplicity

xeokit's draw loop is essentially:

gl.drawArrays( N )

All per-object state is fetched inside the shader via table lookups.

This batched rendering avoids expensive CPU state changes.

xeokit.io

The primitive->mesh lookup texture contains N / 3 items.

Summary of the Packing Format

  1. All geometry stored in shared global arrays
  2. Arrays loaded into data textures
  3. Primitive → mesh index table
  4. Mesh → ranges table
  5. Shader resolves geometry via indirection
  6. Meshes reuse data aggressively without duplication

This structure gives xeokit a very small memory footprint and stable performance.

xeokit.io

Enabling Data Textures 

xeokit.io

const viewer = new Viewer({
   canvasId: "myCanvas",
   dtxEnabled: true 
});


const xktLoader = new XKTLoaderPlugin(viewer);

const sceneModel = xktLoader.load({
    id: "myModel",
    dtxEnabled: true,
    src: "myModel.xkt"
});

Text

Demo:

Thanks!

xeokit.io

This slide deck

xeokit Blog

Demo:

Original experiments by Toni Marti @ Tribia

VBOs vs Data Textures

By xeolabs

VBOs vs Data Textures

  • 11