Render Volumetric Data with Three.js Data3DTexture

This article provides a step-by-step guide on how to load, configure, and render 3D volumetric data in Three.js using the Data3DTexture class. You will learn how to structure raw binary volume data, instantiate a 3D texture, and utilize a custom shader material to visualize the volume using raymarching.

1. Preparing the Volumetric Data

Volumetric data (such as MRI scans, terrain voxel data, or physics simulations) is typically represented as a flat, one-dimensional array containing values for a 3D grid of a specific width, height, and depth.

To load this data in JavaScript, you usually read it into a typed array such as a Uint8Array (for 8-bit values per voxel) or a Float32Array (for high-precision data).

// Define the dimensions of your 3D grid
const width = 64;
const height = 64;
const depth = 64;

// Create a flat array to hold the voxel data (size = width * height * depth)
const data = new Uint8Array(width * height * depth);

// Populate the array with your volumetric data (example: a simple gradient sphere)
for (let z = 0; z < depth; z++) {
    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            const index = x + y * width + z * width * height;
            // Calculate distance from center to create a sphere shape
            const dx = x - width / 2;
            const dy = y - height / 2;
            const dz = z - depth / 2;
            const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
            
            data[index] = dist < 20 ? (255 - dist * 10) : 0; // Voxel density
        }
    }
}

2. Instantiating the Data3DTexture

Once you have your typed array, you can pass it to the THREE.Data3DTexture constructor. You must specify the texture dimensions, the pixel format, and the data type.

import * as THREE from 'three';

// Create the 3D texture
const texture = new THREE.Data3DTexture(data, width, height, depth);

// Set the format. Since we only stored density (1 channel), use RedFormat.
// Use RGBAFormat if you have color data (R, G, B, A).
texture.format = THREE.RedFormat;

// Set the data type to match your typed array (Uint8Array maps to UnsignedByteType)
texture.type = THREE.UnsignedByteType;

// Configure texture filtering for smooth interpolation between voxels
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;

// Define wrapping behavior
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.wrapR = THREE.ClampToEdgeWrapping; // R-axis is the depth dimension

// Trigger an update to upload the texture data to the GPU
texture.needsUpdate = true;

3. Creating the Volume Shader Material

Standard Three.js materials cannot render 3D textures directly onto volume boundaries. To visualize the internal structure, you must render a 3D boundary box and use a custom ShaderMaterial that samples the Data3DTexture using a technique called raymarching.

In your fragment shader, you define a ray starting at the camera position, shooting through the box, and sampling the 3D texture at incremental steps.

const vertexShader = `
    varying vec3 vUv;
    void main() {
        vUv = position + vec3(0.5); // Map position from [-0.5, 0.5] to [0.0, 1.0]
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
`;

const fragmentShader = `
    precision highp float;
    precision highp sampler3D;

    varying vec3 vUv;
    uniform sampler3D volumeTex;
    uniform vec3 cameraPosLocal; // Camera position relative to the box

    const int MAX_STEPS = 128;
    const float STEP_SIZE = 0.01;

    void main() {
        vec3 rayDir = normalize(vUv - cameraPosLocal);
        vec3 rayPos = vUv;
        vec4 accumulatedColor = vec4(0.0);

        for (int i = 0; i < MAX_STEPS; i++) {
            // Ensure the ray is still inside the [0.0, 1.0] bounding box
            if (rayPos.x < 0.0 || rayPos.x > 1.0 ||
                rayPos.y < 0.0 || rayPos.y > 1.0 ||
                rayPos.z < 0.0 || rayPos.z > 1.0) {
                break;
            }

            // Sample the 3D texture
            float density = texture(volumeTex, rayPos).r;

            if (density > 0.01) {
                // Apply a simple color mapping based on density
                vec4 color = vec4(vec3(density), density * 0.5);
                
                // Front-to-back compositing
                accumulatedColor.rgb += (1.0 - accumulatedColor.a) * color.rgb * color.a;
                accumulatedColor.a += (1.0 - accumulatedColor.a) * color.a;

                // Stop marching if the opacity is near fully opaque
                if (accumulatedColor.a >= 0.95) {
                    break;
                }
            }

            // Advance the ray
            rayPos += rayDir * STEP_SIZE;
        }

        gl_FragColor = accumulatedColor;
    }
`;

4. Setting up the Mesh in the Scene

To render the volume, apply the ShaderMaterial to a simple BoxGeometry. You must update the local camera position in the animation loop so that the raymarching algorithm knows the correct viewing angle.

// 1. Create the box geometry corresponding to the volume boundaries
const geometry = new THREE.BoxGeometry(1, 1, 1);

// 2. Define uniforms for the shaders
const uniforms = {
    volumeTex: { value: texture },
    cameraPosLocal: { value: new THREE.Vector3() }
};

// 3. Create the ShaderMaterial
const material = new THREE.ShaderMaterial({
    vertexShader: vertexShader,
    fragmentShader: fragmentShader,
    uniforms: uniforms,
    transparent: true,
    side: THREE.BackSide // Render the back of the box to prevent clipping when camera enters it
});

// 4. Create the mesh and add it to the scene
const volumeMesh = new THREE.Mesh(geometry, material);
scene.add(volumeMesh);

// 5. Update uniform on each render frame
function animate() {
    requestAnimationFrame(animate);

    // Compute the camera position relative to the volume mesh
    const inverseModelMatrix = new THREE.Matrix4().copy(volumeMesh.matrixWorld).invert();
    uniforms.cameraPosLocal.value.copy(camera.position).applyMatrix4(inverseModelMatrix);

    renderer.render(scene, camera);
}
animate();