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();