Building Pinterest Design of a Cube with Chromatic Dispersion in Three.js

Franky Hung
7 min readFeb 3, 2024

One time I came across this beautiful rotating cube on Pinterest: https://www.pinterest.com/pin/749567931745732553/ and can’t stop wondering if I can build this with Three.js. I think what makes this cube look so real is the chromatic dispersion and the reflections done in the inner faces. So as a code challenge, I made myself do it! It is actually not that complex, and I shall present to you the methodology in making this design real!

Quick Update! With the release of Three.js r164, there’s now a “dispersion” property added to MeshPhysicalMaterial which looks even better than my custom shaders here.

screenshot of my threejs demo

Quick primer: the first approach I tried didn’t work that well, my latest code and live demo uses the second approach I’m about to explain below.

chromatic dispersion, where you can see colorfully refracted light out of white light

Source code and live demo

Live demo: https://projects.arkon.digital/threejs/chromatic-dispersion-text/

Source code: https://github.com/franky-adl/chromatic-dispersion-text

First Approach: Custom ShaderMaterial with multiple render passes

Although this isn’t the final approach I’ve taken, it’s worth reading if you want to learn advanced three.js techniques. This first approach is mostly inspired by a very well written tutorial by Maxime Heckel. It is a pretty long tutorial and it took me two days to follow it! The final result from that tutorial is pretty amazing. So I was pretty excited to try out this technique on this project.

screenshot of one of the demos from Maxime’s tutorial

In short, this approach relies on custom shader code, multiple render passes and storing the interim renders in WebGLRenderTarget as textures for later use in the final render. The first pass renders the scene behind the refractive material, which in this case the white text. The second pass renders normal map of the cube’s back face. The final pass uses the fragment shader to refract the result from first pass, the second pass’s result also helps alter the refractions a bit so as to make it look like both the front and the back side of the cube are refracting the light. For the back face rendering technique, I copied it from this article: https://tympanus.net/codrops/2019/10/29/real-time-multiside-refraction-in-three-steps/.

Since we’re using custom shaders here, we are also responsible for calculating the light reflections ourselves. So if you’re interested in learning how to calculate light reflections in shaders, his tutorial should provide a good starting point. Despite the hard work, it’s pretty flexible if we want to add more spices like fresnel effect and chromatic dispersion, which is the core effect to be demonstrated in that tutorial.

Although the torus did a pretty good job in refracting the dots, after I applied this technique to my cube in front of some text, it just didn’t feel right, like the refraction felt fake and the light reflections also felt a bit out of place. It didn’t look close enough with the Pinterest animated design, and that is why I started exploring a new approach.

Much Simpler Approach! Extending MeshPhysicalMaterial’s fragment shader

Because of how the refraction and reflections were looking awry in the first approach, I thought why don’t I just extend on the existing MeshPhysicalMaterial so that I don’t have to worry about getting the refraction and reflection calculations correct. The only thing I have to add to MeshPhysicalMaterial is the chromatic dispersion effect, and I can just render this in a single pass! Sounds very doable.

The scene setup is pretty simple:

  • a PlaneGeometry with a MeshBasicMaterial using a CanvasTexture that has the text written on it
  • a RoundedBoxGeometry from the jsm library, using the customly-extendedMeshPhysicalMaterial , also using environment map on the material to get some realistic light reflections instead of setting up lighting ourselves

We need the proper configuration for the MeshPhysicalMaterial to achieve the transparency and nice reflections:

let refractionMaterial = new THREE.MeshPhysicalMaterial({
thickness: params.thickness,
roughness: 0.15,
transmission: 1,
envMap: envMap,
side: THREE.DoubleSide
})

The roughness here is to blur the reflections from the environment map a bit because I prefer some smooth patches of light instead of clear reflections of the industrial warehouse which could be distracting.

You can also notice that I switch DoubleSide on because this way you can see the reflections and refractions done via the backside as well, which adds more realism to it. It’s totally weird if it’s supposed to be transparent but you can’t see its backside.

And finally, the meat of simulating the chromatic dispersion effect is in the next code chunk:

refractionMaterial.onBeforeCompile = function( shader ) {
shader.fragmentShader = shader.fragmentShader.replace('#include <transmission_pars_fragment>', TransmissionShader)
}

This is to replace the line of #include <transmission_pars_fragment> in the MeshPhysicalMaterial’s fragment shader(source code) with our own modified version of “transmission_pars_fragment.glsl”. If you’re not familiar with this onBeforeCompile method, check out this great article.

The exact spot in MeshPhysicalMaterial’s fragment shader where the refraction is calculated is in#include <transmission_fragment> , line 192 as of the latest Threejs version. Digging into this shader chunk(source code), you should see it is calling getIBLVolumeRefraction to calculate the refracted transmission color, which is defined in transmission_pars_fragment.glsl.js(source code). I have to admit that I don’t totally understand every line of the code here, but after some reading and trial-and-error, I figured out that this line within the getIBLVolumeRefractionis what uses the IOR to calculate the refracted ray:

vec3 transmissionRay = getVolumeTransmissionRay( n, v, thickness, ior, modelMatrix );

Using the chromatic dispersion technique I learnt from Maxime’s tutorial, we just have to calculate the refraction for the 3 colors separately, with a different IOR each time, then add the color channels back together in the end. Thus, I have to copy the full transmission_pars_fragment.glsl file from Three.js source code, and then change just the implementation of the getIBLVolumeRefraction function. Here’s my modified definition of it with the chromatic dispersion calculation:

vec4 getIBLVolumeRefraction( const in vec3 n, const in vec3 v, const in float roughness, const in vec3 diffuseColor,
const in vec3 specularColor, const in float specularF90, const in vec3 position, const in mat4 modelMatrix,
const in mat4 viewMatrix, const in mat4 projMatrix, const in float ior, const in float thickness,
const in vec3 attenuationColor, const in float attenuationDistance ) {

// I've experimented different values in rgbIOR and rgbSpreadFactor
// There's an intricate balance between the two,
// If step between IORs is too large, then the SpreadFactors might not be large enough give a smooth effect
const float rIOR = 1.185;
const float gIOR = 1.20;
const float bIOR = 1.215;
vec4 transmittedLight = vec4(vec3(0.), 1.0);

// Red refraction
vec3 transmissionRayRed = getVolumeTransmissionRay( n, v, thickness, rIOR, modelMatrix );
vec4 ndcPos = projMatrix * viewMatrix * vec4( position + transmissionRayRed, 1.0 );
vec2 refractionCoordsRed = ndcPos.xy / ndcPos.w;

// Green refraction
vec3 transmissionRayGrn = getVolumeTransmissionRay( n, v, thickness, gIOR, modelMatrix );
ndcPos = projMatrix * viewMatrix * vec4( position + transmissionRayGrn, 1.0 );
vec2 refractionCoordsGrn = ndcPos.xy / ndcPos.w;

// Blue refraction
vec3 transmissionRayBlu = getVolumeTransmissionRay( n, v, thickness, bIOR, modelMatrix );
ndcPos = projMatrix * viewMatrix * vec4( position + transmissionRayBlu, 1.0 );
vec2 refractionCoordsBlu = ndcPos.xy / ndcPos.w;

// use 10 layers to spread out the chromatic dispersion smoothly
const int LOOP = 10;
const float rSpreadFactor = 0.05;
const float gSpreadFactor = 0.07;
const float bSpreadFactor = 0.09;
for ( int i = 0; i < LOOP; i ++ ) {
vec2 rslide = vec2(1.0 + float(i) / float(LOOP) * rSpreadFactor * transmissionRayRed.xy);
vec2 gslide = vec2(1.0 + float(i) / float(LOOP) * gSpreadFactor * transmissionRayGrn.xy);
vec2 bslide = vec2(1.0 + float(i) / float(LOOP) * bSpreadFactor * transmissionRayBlu.xy);

transmittedLight.r += getTransmissionSample( (refractionCoordsRed + 1.0) / 2.0 * rslide, 0., ior ).r;
transmittedLight.g += getTransmissionSample( (refractionCoordsGrn + 1.0) / 2.0 * gslide, 0., ior ).g;
transmittedLight.b += getTransmissionSample( (refractionCoordsBlu + 1.0) / 2.0 * bslide, 0., ior ).b;
}
// Divide by the number of layers to normalize colors (rgb values can be worth up to the value of LOOP)
transmittedLight.rgb = transmittedLight.rgb / float(LOOP);

vec3 transmittance = diffuseColor * volumeAttenuation( length( transmissionRayGrn ), attenuationColor, attenuationDistance );
vec3 attenuatedColor = transmittance * transmittedLight.rgb;

// Get the specular component.
vec3 F = EnvironmentBRDF( n, v, specularColor, specularF90, roughness );

// As less light is transmitted, the opacity should be increased. This simple approximation does a decent job
// of modulating a CSS background, and has no effect when the buffer is opaque, due to a solid object or clear color.
float transmittanceFactor = ( transmittance.r + transmittance.g + transmittance.b ) / 3.0;

return vec4( ( 1.0 - F ) * attenuatedColor, 1.0 - ( 1.0 - transmittedLight.a ) * transmittanceFactor );

}

This largely resembles the technique by Maxime, also using a loop here to smoothly spread out the refractions for each color. Without this sampling technique, the chromatic dispersion would look too sharp and thus less realistic. Personally I like the effect with the sampling better.

left: without sampling by loop; right: with sampling by looping 10 times

That’s it! It’s pretty amazing how this effect can be added without much effort! If you like this article, please give 50 claps! Or, 49 if 50 is too much 😎.

--

--

Franky Hung

Founder of Arkon Digital. I’m not a code fanatic, but I’m always amazed by what code can do. The endless possibilities in coding is what fascinates me everyday.