DEV Community

Nicolas Rannou
Nicolas Rannou

Posted on • Edited on

Create textures from data in ThreeJS

I've been looking into creating a texture from data in three.js. It is super easy, but there are some caveats, and some parts can be confusing. I fell into some traps many years ago, then fall into it again recently, so I decided to write about it!

Table of contents generated with markdown-toc

What is confusing (me)?

When creating a new texture, from data, you must set a Format, a Type, and provide you data in a specific type of TypedArray.

const texture = new DataTexture(data, width, height, format, type, ...);

The doc says

"type" must correspond to the "format".

Ok... so how do I know which Type to set for the Format I want to use?

Grayscale textures

In this post, I will only discuss grayscale (single channel) texture for WebGL1 since it is my current focus. Everything in the rest of this post will apply to whatever you are trying to support.

Which format

Alright, I want to create a single channel (grayscale) texture.
In the 'internal format' section, you can find which Format is the best fit for you, depending on the number of channels and the bytes per pixel.

LUMINANCE is the natural fit for grayscale textures. The caveat is that it only supports UnsignedByte texture type for Webgl1.

If you work with WebGL2, R* formats allow you to support a variety of different bit-depth.

To know more about what the different file format means, I found this page useful: https://www.khronos.org/opengl/wiki/Image_Format. It explains what the suffix of the file format means *F, *_SNORM means and how those type of textures are interpreted. That is important regarding the data normalization. (keep reading)

Luminance format allows three types (WebGL1)

Alright, we just learned that for WebGL1, Luminance format goes with UnsignedByte type.

Can we do better?

If your browser supports the OES_texture_float extension, a bunch of new types (Float and HalfFloat) are available for the LUMINANCE format. (official documentation)

Format Type Byte per Pixel
RGBA FLOAT 16
RGB FLOAT 12
LUMINANCE_ALPHA FLOAT 8
LUMINANCE FLOAT 4
ALPHA FLOAT 4
RGBA HALF_FLOAT_OES 8
RGB HALF_FLOAT_OES 6
LUMINANCE_ALPHA HALF_FLOAT_OES 4
LUMINANCE HALF_FLOAT_OES 2
ALPHA HALF_FLOAT_OES 2

Type to TypedArray containing the data

It is pretty straight forward:

Type Byte per Pixel Typed Array
UnsignedByte 1 Uint8Array
HalfFloat 2 Uint16Array
Float 4 Float32Array

What is important there is that the number of bits in the type array matches the byte per pixel in the table. Also, for HalfFloat the data should be prepared appropriately.

Access the data in the fragment shader

All the integer textures (including UnsignedByteType) are normalized automatically while uploaded to the shaders, whereas the floating/integral textures (including Float and HalfFloat) are passed as it is.

Based on the Format name, you can know with which type of data you are dealing with and whether that will be normalized for you or not. (ref).

In other words, in the fragment shader, when using an UnsignedByteType texture, the values you get from the texture 2D are normalized between 0 and 1 automatically. For FloatType and HalfFloatType you get the value that was in the typed array without any normalization.

Gimme some concrete examples!

UnsignedByte Texture

const textureSize = 16
const dataSize = 10;
const data = new Uint8Array(dataSize);

for (let i = 0; i < dataSize) {
  data[i] = Math.round(Math.random() * 255); // pass anything from 0 to 255
}

const texture = new DataTexture(data, textureSize, textureSize, LUMINACE, UnsignedByteType);
varying vec2 vUv;
uniform sampler2D uData;

void main(){
  vec3 color;
  vec4 data = texture2D( uData, vUv );
  gl_FragColor = vec4( data.xyz, 1.0 );
}

HalfFloat Texture

To convert a number to half float, do it the three.js way: like this

⚠️ Watch out for precision errors when converting numbers to half float precision!

const textureSize = 16
const dataSize = 10;
const data = new Uint16Array(dataSize);

for (let i = 0; i < dataSize) {
  const largeNumber = Math.random() * 10000; // pass anything from 0 to 10000
  data[i] = toHalfFloat(largeNumber);
}

const texture = new DataTexture(data, textureSize, textureSize, LUMINACE, HalfFloatType);
varying vec2 vUv;
uniform sampler2D uData;
uniform float uMax;
uniform float uMin;

void main(){
  vec3 color;
  vec4 data = texture2D( uData, vUv );
  vec4 normalizedData = (data - uMin) / (uMax - uMin);
  gl_FragColor = vec4( data.xyz, 1.0 );
}

Float Texture

const textureSize = 16
const dataSize = 10;
const data = new Float32Array(dataSize);

for (let i = 0; i < dataSize) {
  const largeNumber = Math.random() * 10000; // pass anything from 0 to 10000
  data[i] = largeNumber;
}

const texture = new DataTexture(data, textureSize, textureSize, LUMINACE, FloatType);
varying vec2 vUv;
uniform sampler2D uData;
uniform float uMax;
uniform float uMin;

void main(){
  vec3 color;
  vec4 data = texture2D( uData, vUv );
  vec4 normalizedData = (data - uMin) / (uMax - uMin);
  gl_FragColor = vec4( data.xyz, 1.0 );
}

Until next time!

Until next time 🙋‍♂️, happy coding!

Top comments (0)