[threejs-journey] Part 14

36. Adding details to the scene

Introduction

Our scene is looking great with the baked texture, but we are not really using the full potential of WebGL. Let’s add more details and give more life to the scene.

First, we are going to add fireflies floating around. Then, we are going to create a cool animation inside the portal. But first, we need to change that black background.

Setup

The code is exactly the same as we left it in the previous lesson.

We have our scene already imported in Three.js, we can rotate around with the OrbitControls and we have our Dat.GUI instance ready.

Background color

For the background, we are not going to make something too fancy. We will simply change the color to something that matches our scene a bit more. To find the perfect color, we are going to add a tweak to Dat.GUI.

Right before creating our Dat.GUI instance, add a debugObject :

// Debug
const debugObject = {}
const gui = new dat.GUI({
    width: 400
})

JavaScript

Like we did in the previous lessons, this object’s purpose is only to store the color as a property so that we can add it as a tweak to Dat.GUI.

After instancing the renderer , add a clearColor property to the debugObject object:

// Clear color
debugObject.clearColor = '#ff0000'

JavaScript

Then add it to the tweaks of Dat.GUI using gui.addColor(...) :

gui.addColor(debugObject, 'clearColor')

JavaScript

You should see the color tweak but we are not using it yet.

Use the value to change the background color with renderer.setClearColor(...) :

debugObject.clearColor = '#ff0000'
renderer.setClearColor(debugObject.clearColor)
gui.addColor(debugObject, 'clearColor')

JavaScript

Unfortunately, changing the tweak won’t change the color of the background because we are only changing the clearColor property.

We need to call the renderer.setClearColor(...) again when the tweak changes:

gui
    .addColor(debugObject, 'clearColor')
    .onChange(() =>
    {
        renderer.setClearColor(debugObject.clearColor)
    })

JavaScript

You can now find the color that matches your scene the best:

debugObject.clearColor = '#201919'

JavaScript

Fireflies

To create the fireflies, we are going to use particles with a custom geometry.

Base

Start by creating an empty BufferGeometry:

/**
 * Fireflies
 */
// Geometry
const firefliesGeometry = new THREE.BufferGeometry()

JavaScript

Create a BufferAttribute named position to set the position of each particle:

const firefliesGeometry = new THREE.BufferGeometry()
const firefliesCount = 30
const positionArray = new Float32Array(firefliesCount * 3)

for(let i = 0; i < firefliesCount; i++)
{
    positionArray[i * 3 + 0] = Math.random() * 4
    positionArray[i * 3 + 1] = Math.random() * 4
    positionArray[i * 3 + 2] = Math.random() * 4
}

firefliesGeometry.setAttribute('position', new THREE.BufferAttribute(positionArray, 3))

JavaScript

The positions of the fireflies are random, but we will improve their positions once we can see the fireflies.

We are going to use a PointsMaterial in order to see our fireflies, but we will change it to a custom ShaderMaterial later:

Create a PointsMaterial with a size of 0.1 and set the sizeAttenuation to true :

// Material
const firefliesMaterial = new THREE.PointsMaterial({ size: 0.1, sizeAttenuation: true })

JavaScript

Finally, create the Points instance using the firefliesGeometry and the firefliesMaterial and add it to the scene:

// Points
const fireflies = new THREE.Points(firefliesGeometry, firefliesMaterial)
scene.add(fireflies)

JavaScript

You should see some big square particles.

Let’s change the positions so that it fits in the scene:

for(let i = 0; i < firefliesCount; i++)
{
    positionArray[i * 3 + 0] = (Math.random() - 0.5) * 4
    positionArray[i * 3 + 1] = Math.random() * 1.5
    positionArray[i * 3 + 2] = (Math.random() - 0.5) * 4
}

JavaScript

The particles shouldn’t go too far on the x and z axes and stay above the floor on the y axis.

Custom shader material

We can now change the material to a ShaderMaterial in order to animate them but also draw something other than those ugly squares.

  • In the /src/ folder, create a shaders/ folder.
  • In that shaders/ folder, create a fireflies/ folder.
  • In that fireflies/ folder (hang on), create a vertex.glsl and a fragment.glsl file.

You can try to write those shaders on your own.

Here’s the code for /src/shaders/fireflies/vertex.glsl :

void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    vec4 projectionPosition = projectionMatrix * viewPosition;

    gl_Position = projectionPosition;
    gl_PointSize = 40.0;
}

GLSL

This code isn’t doing anything special. The vertex get positioned where it should be by applying the various matrices. We also added the gl_PointSize to control the size of the particles.

Here’s the code for /src/shaders/fireflies/fragment.glsl :

void main()
{
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

GLSL

As with the vertex shader, this code isn’t doing anything special and the particles should look red.

We can now import those shaders into our script:

import firefliesVertexShader from './shaders/fireflies/vertex.glsl'
import firefliesFragmentShader from './shaders/fireflies/fragment.glsl'

JavaScript

The rule to load .glsl files has already been added to the Webpack configuration. firefliesVertexShader and firefliesFragmentShader should contain the two shaders we wrote.

Replace the PointsMaterial by a ShaderMaterial with the vertexShader and fragmentShader properties:

const firefliesMaterial = new THREE.ShaderMaterial({
    vertexShader: firefliesVertexShader,
    fragmentShader: firefliesFragmentShader
})

JavaScript

You should get red squares as the fireflies.

Point size

Those squares might look bigger on your screen and this is due to the pixel ratio. In our vertex shader, we wrote gl_PointSize = 40.0; which means that each particle will take 40 pixels of the render in width and height, but if you have a high pixel ratio screen, 40 pixels will look smaller because you have more pixels in the same space.

To fix that, we can send the pixel ratio as a uniform :

// Material
const firefliesMaterial = new THREE.ShaderMaterial({
    uniforms:
    {
        uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) }
    },
    vertexShader: firefliesVertexShader,
    fragmentShader: firefliesFragmentShader
})

JavaScript

We are not using renderer.getPixelRatio() because the renderer is being declared afterwards. Doing it this way would result in an error. But if you prefer, you can move the entire fireflies code to after the renderer.

We can now retrieve the uPixelRatio uniform in the vertex shader and multiply our gl_PointSize by it:

uniform float uPixelRatio;

void main()
{
    // ...

    gl_PointSize = 40.0 * uPixelRatio;
}

GLSL

You should now see the same particle size regardless of the pixel ratio of the screen.

We might have a problem if the user changes the window from one screen to another one with a different pixel ratio. Fortunately, most of the time, when a user does that, he is also resizing the window so that it fits better. This is why we can simply update the uniform in the resize callback function:

window.addEventListener('resize', () =>
{
    // ...

    // Update fireflies
    firefliesMaterial.uniforms.uPixelRatio.value = Math.min(window.devicePixelRatio, 2)
})

JavaScript

Another problem with our particles is that the size of the squares is the same regardless of the distance. We need to activate the size attenuation and we can do that with the following code:

// ...

void main()
{
    // ...

    gl_PointSize = 40.0 * uPixelRatio;
    gl_PointSize *= (1.0 / - viewPosition.z);
}

GLSL

The particles’ size should now depend on the distance to the camera.

Finally, we can create a uSize uniform to control the size of the particles:

const firefliesMaterial = new THREE.ShaderMaterial({
    uniforms:
    {
        uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) },
        uSize: { value: 100 }
    },
    // ...
})

JavaScript

Use it in the shader:

// ...
uniform float uSize;

void main()
{
    // ...

    gl_PointSize = uSize * uPixelRatio;
    // ...
}

GLSL

And add a tweak to control it:

gui.add(firefliesMaterial.uniforms.uSize, 'value').min(0).max(500).step(1).name('firefliesSize')

JavaScript

Firefly pattern

It’s time to change those red squares into better looking fireflies. We are going to create some shiny points to represent them.

We could have used a texture and it’s actually more performant to do that. But, for the sake of this lesson and to have more control over the pattern, we are going to draw it in the fragment shader.

Increase the particle size to something like 200 . Start by showing the UV coordinates of the point with gl_PointCoord :

void main()
{
    gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
}

GLSL

We are going to use these coordinates to draw a shiny point at each center.

First, we know that we need to play with the value which contains the distance to the center of the coordinates.

Create a distanceToCenter variable and calculate the distance between the center ( vec2(0.5) ) and gl_PointCoord :

void main()
{
    float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
    gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
}

GLSL

Use this distanceToCenter variable as the alpha of gl_FragColor :

void main()
{
    float distanceToCenter = distance(gl_PointCoord, vec2(0.5));

    gl_FragColor = vec4(1.0, 1.0, 1.0, distanceToCenter);
}

GLSL

The particles are all white even though the alpha was supposed to change. This is because we forgot to set the transparent property of the material to true :

const firefliesMaterial = new THREE.ShaderMaterial({
    transparent: true,
    // ...
})

JavaScript

We can now see our pattern through the alpha.

Next, we will try to create a shiny pattern. To do that, we can start with a very small value and divide it by the distanceToCenter .

Create a strength variable, divide 0.05 by distanceToCenter and send it to the gl_FragColor :

void main()
{
    float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
    float strength = 0.05 / distanceToCenter;

    gl_FragColor = vec4(1.0, 1.0, 1.0, strength);
}

GLSL

They look good, but unfortunately, we can see the edges. This is because this formula gets very low with distance but never reaches 0 :

To fix that, we can subtract a small value but big enough to make sure that the result goes below 0.0 before reaching the edges:

void main()
{
    float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
    float strength = 0.05 / distanceToCenter - 0.1;

    gl_FragColor = vec4(1.0, 1.0, 1.0, strength);
}

GLSL

/assets/lessons/36/step-16.png

The fireflies now look like little points of light.

Scale randomness

When creating something organic, it’s always good to add randomness. So far, all the fireflies have the same size.

We are going to send a different scale multiplier to each one.

Create an aScale attribute and fill it with random values using Math.random() . We only need 1 value per vertex, so make sure to get the Float32Array length right and also set the second parameter of the BufferAttribute to 1 :

const scaleArray = new Float32Array(firefliesCount)

for(let i = 0; i < firefliesCount; i++)
{
    // ...

    scaleArray[i] = Math.random()
}

// ...
firefliesGeometry.setAttribute('aScale', new THREE.BufferAttribute(scaleArray, 1))

JavaScript

We can retrieve the attribute in the vertex shader and use it in the gl_PointSize formula:

// ...

attribute float aScale;

void main()
{
    // ...

    gl_PointSize = uSize * aScale * uPixelRatio;
    gl_PointSize *= (1.0 / - viewPosition.z);
}

GLSL

The fireflies now have different sizes.

Blending

To make them even more shiny and look like points made from light, set the blending property of the material to THREE.AdditiveBlending :

const firefliesMaterial = new THREE.ShaderMaterial({
    blending: THREE.AdditiveBlending,
    // ...
})

JavaScript

Depth write

At some specific angles, you will notice some clipping issues where one particle seems to hide the one behind. This is true even if the alpha is supposed to be set at a low value.

To fix that, we can deactivate depth writing by setting the depthWrite property to false on the material:

const firefliesMaterial = new THREE.ShaderMaterial({
    depthWrite: false,
    // ...
})

JavaScript

Floating animation

Finally, we want the fireflies to float up and down to give them more life.

Because we are going to do the animation in the vertex shader, we need to send it the time.

Create a uTime uniform and update it in the tick function by using the elapsedTime variable:

const firefliesMaterial = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        uTime: { value: 0 },
        // ...
    },
    // ...
})

// ...
const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update materials
    firefliesMaterial.uniforms.uTime.value = elapsedTime

    // ...
}

JavaScript

Retrieve the uTime uniform in the vertex shader and update the modelPosition.y using sin() :

uniform float uTime;

// ...

void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    modelPosition.y += sin(uTime);

    // ...
}

GLSL

That’s a good start but we need to add some randomness. First, let’s de-synchronize them.

We could have sent a new attribute, but instead (since they only move on the y axis), we can use the x axis to offset the value in sin() :

// ...

void main()
{
    // ...
    modelPosition.y += sin(uTime + modelPosition.x * 100.0);

    // ...
}

GLSL

The animation looks more random, but we also need to randomize and reduce the amplitude.

We can use the aScale so that the smallest fireflies move less. We can also multiply by a small value to reduce the general amplitude:

// ...

void main()
{
    // ...
    modelPosition.y += sin(uTime + modelPosition.x * 100.0) * aScale * 0.2;

    // ...
}

GLSL

That’s it for the fireflies but you can go further by adding more tweaks to Dat.GUI, improving the pattern, or maybe sending a color using a uniform.

Portal

The final piece of our scene is the portal. We are going to draw something that looks better than this uniform color.

Custom shader material

We can start by changing the portal material to a ShaderMaterial.

In the /src/shaders/ folder, create a portal/ folder.

In that portal/ folder, create a vertex.glsl file and a fragment.glsl file.

Again, you can try to write those basic vertex and fragment shaders on your own.

Here’s the code for /src/shaders/portal/vertex.glsl :

void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    vec4 projectionPosition = projectionMatrix * viewPosition;

    gl_Position = projectionPosition;
}

GLSL

Here’s the code for /src/shaders/portal/fragment.glsl :

void main()
{
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

GLSL

We are placing the vertices where they are supposed to be and we are setting the color to a uniform red.

We can now import those shaders into our script:

import portalVertexShader from './shaders/portal/vertex.glsl'
import portalFragmentShader from './shaders/portal/fragment.glsl'

JavaScript

Replace the MeshBasicMaterial by a ShaderMaterial with the vertexShader and fragmentShader properties:

const portalLightMaterial = new THREE.ShaderMaterial({
    vertexShader: portalVertexShader,
    fragmentShader: portalFragmentShader
})

JavaScript

You should get a red portal which doesn’t match the rest of the scene, but we are going to fix that.

As in the Shader Patterns lesson, we are going to use the UV coordinates to draw on the portal.

Send the UV coordinates from the vertex shader to the fragment shader using a vUv varying.

The vertex shader:

varying vec2 vUv;

void main()
{
    // ...

    vUv = uv;
}

GLSL

The fragment shader:

varying vec2 vUv;

void main()
{
    gl_FragColor = vec4(vUv, 1.0, 1.0);
}

GLSL

If you are using your own model and you don’t get this gradient, it might be because your portal hasn’t been unwrapped properly.

Fix the UV coordinates

To fix that, open your .blend file:

Select the portal only:

Go into Edit Mode , select all the faces and check how the UV is being unwrapped in the UV Editor :

Here, the unwrap is good, but you may have a different result or maybe nothing at all.

Don’t worry about the baked texture in the background of the UV Editor . We don’t need to be concerned with it. All we want to do is to unwrap the disc-shaped portal so that it fills the whole map.

To do that, while still in the UV Editor with all the faces selected, press U and choose Unwrap .

And that’s all. We don’t really care about the rotation of the UV because the pattern we are going to draw will be circular.

Leave the Edit Mode and select the merged object and all the emissions. Then, export everything as portal.glb into the /static/ folder:

Refresh your scene, and you should now have the same gradient:

If the gradient isn’t oriented the same, it’s not a problem. Our pattern will be circular.

Perlin

Now comes the fun part. You can draw anything you want in the portal, as long as you find the right formula.

The following steps lead straight to a pleasing result. But, obviously, it was a long process of trying many things until it ended up looking good. Do not be frustrated if it takes you hours when doing it on your own. It’s all about time and practice.

We are going to start with a classic Perlin noise. In this example, we are going to use a 3D one, so we can animate the third value with the time in order to create variations to the noise.

Take the following function made by Stefan Gustavson, which is available in this gist, and add it to your src/shaders/portal/fragment.glsl file:

//  Classic Perlin 3D Noise 
//  by Stefan Gustavson
//
vec4 permute(vec4 x){ return mod(((x*34.0)+1.0)*x, 289.0); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
vec3 fade(vec3 t) { return t*t*t*(t*(t*6.0-15.0)+10.0); }

float cnoise(vec3 P)
{
    vec3 Pi0 = floor(P); // Integer part for indexing
    vec3 Pi1 = Pi0 + vec3(1.0); // Integer part + 1
    Pi0 = mod(Pi0, 289.0);
    Pi1 = mod(Pi1, 289.0);
    vec3 Pf0 = fract(P); // Fractional part for interpolation
    vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
    vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
    vec4 iy = vec4(Pi0.yy, Pi1.yy);
    vec4 iz0 = Pi0.zzzz;
    vec4 iz1 = Pi1.zzzz;

    vec4 ixy = permute(permute(ix) + iy);
    vec4 ixy0 = permute(ixy + iz0);
    vec4 ixy1 = permute(ixy + iz1);

    vec4 gx0 = ixy0 / 7.0;
    vec4 gy0 = fract(floor(gx0) / 7.0) - 0.5;
    gx0 = fract(gx0);
    vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
    vec4 sz0 = step(gz0, vec4(0.0));
    gx0 -= sz0 * (step(0.0, gx0) - 0.5);
    gy0 -= sz0 * (step(0.0, gy0) - 0.5);

    vec4 gx1 = ixy1 / 7.0;
    vec4 gy1 = fract(floor(gx1) / 7.0) - 0.5;
    gx1 = fract(gx1);
    vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
    vec4 sz1 = step(gz1, vec4(0.0));
    gx1 -= sz1 * (step(0.0, gx1) - 0.5);
    gy1 -= sz1 * (step(0.0, gy1) - 0.5);

    vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
    vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
    vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
    vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
    vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
    vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
    vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
    vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);

    vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
    g000 *= norm0.x;
    g010 *= norm0.y;
    g100 *= norm0.z;
    g110 *= norm0.w;
    vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
    g001 *= norm1.x;
    g011 *= norm1.y;
    g101 *= norm1.z;
    g111 *= norm1.w;

    float n000 = dot(g000, Pf0);
    float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
    float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
    float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
    float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
    float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
    float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
    float n111 = dot(g111, Pf1);

    vec3 fade_xyz = fade(Pf0);
    vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
    vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
    float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x); 

    return 2.2 * n_xyz;
}

GLSL

At the end of this process, we are going to display a mix between two colors, but for now, we are going to focus on just one variable.

Create a strength variable and use the cnoise function we just added.

This function needs a vec3 as parameter. For now, we will use the vUv as the x and y and we will use the time variable later for the z :

// ...

void main()
{
    float strength = cnoise(vec3(vUv * 5.0, 0.0));

    gl_FragColor = vec4(vUv, 1.0, 1.0);
}

GLSL

If there is no error, send the strength to the gl_FragColor :

// ...

void main()
{
    float strength = cnoise(vec3(vUv * 5.0, 0.0));

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}

GLSL

That’s a good start and we can see the Perlin noise.

In your JavaScript file, create a uTime uniform and update it in the tick function like we did with the fireflies:

const portalLightMaterial = new THREE.ShaderMaterial({
    uniforms:
    {
        uTime: { value: 0 }
    },
    // ...
})

// ...

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update materials
    firefliesMaterial.uniforms.uTime.value = elapsedTime
    portalLightMaterial.uniforms.uTime.value = elapsedTime

    // ...
}

tick()

JavaScript

Retrieve it in the fragment shader and use it as the third value of the vec3 that we send to cnoise :

uniform float uTime;

// ...

void main()
{
    // Perlin noise
    float strength = cnoise(vec3(vUv * 5.0, uTime));

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}

GLSL

We are already getting some Stargate vibes.

This classic Perlin noise is good, but we can get something even better by displacing the UV coordinates. We are going to create new UV coordinates based on the initial ones, but we are going to offset the x and the y by using another Perlin noise:

// ...

void main()
{
    // Displace the UV
    vec2 displacedUv = vUv + cnoise(vec3(vUv * 5.0, uTime));

    // Perlin noise
    float strength = cnoise(vec3(displacedUv * 5.0, uTime));

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}

GLSL

The animation is great but a little too fast. In order to slow it down and get some organic randomness, multiply the uTime by small values that are different for displacedUv and for strength :

// ...

void main()
{
    // Displace the UV
    vec2 displacedUv = vUv + cnoise(vec3(vUv * 5.0, uTime * 0.1));

    // Perlin noise
    float strength = cnoise(vec3(displacedUv * 5.0, uTime * 0.2));

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}

GLSL

Outer glow

We are on the right path, but the main concern now is the junction between the portal itself and the bricks around it. The junction between them should be totally white.

We need to create an outer glow. To do that, we start by finding the distance to the center and displaying the result in the gl_FragColor :

// ...

void main()
{
    // ...

    // Outer glow
    float outerGlow = distance(vUv, vec2(0.5));

    gl_FragColor = vec4(outerGlow, outerGlow, outerGlow, 1.0);
}

GLSL

The gradient is the one we want, but we need to push the value at the edges in order to make sure that the gradient is totally white when we reach those edges. To do that, multiply and offset the value until you are satisfied:

// ...

void main()
{
    // ...

    // Outer glow
    float outerGlow = distance(vUv, vec2(0.5)) * 5.0 - 1.4;

    gl_FragColor = vec4(outerGlow, outerGlow, outerGlow, 1.0);
}

GLSL

We can now add the outerGlow to the strength and send the strength to the gl_FragColor :

// ...

void main()
{
    // ...

    // Outer glow
    float outerGlow = distance(vUv, vec2(0.5)) * 5.0 - 1.4;
    strength += outerGlow;

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}

GLSL

We kind of get the result we want, but unfortunately, the edges aren’t perfectly white. This is because the Perlin noise isn’t clamped between 0.0 to 1.0 . That means the value can actually go below -1.0 and above +1.0 . But, we aren’t going to do anything about it because the next step will fix it.

Currently, we have a very smooth pattern, but it’s actually too smooth. We want to add some sharpness to it.

Step

To do that, we can apply a step() function to the strength .

The first parameter of the step() function is a limit (also called edge). When the value of the second parameter is above this limit, you’ll get 1.0 and if the value is below this limit, you’ll get 0.0 .

You can try different values for this limit:

// ...

void main()
{
    // ...

    // Apply cool step
    strength = step(- 0.2, strength);

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}

GLSL

We get a nice sharp result, but now it’s too sharp.

Instead of replacing the strength with that step() function, we are going to add it to the initial strength . We can also multiply it by a lower value to dim the step effect a little:

// ...

void main()
{
    // ...

    // Apply cool step
    strength += step(- 0.2, strength) * 0.8;

    gl_FragColor = vec4(strength, strength, strength, 1.0);
}

GLSL

And here is our final pattern. As you can see, the edges are perfectly white and we get a good mix between smooth and sharp.

Colors

In this example, we are fine with the black and white result because our portal light was initially white. But, let’s add colors in case you went for a different portal color.

In the JavaScript file, add a uColorStart and a uColorEnd uniform with red and blue colors . Use the Color class:

const portalLightMaterial = new THREE.ShaderMaterial({
    uniforms:
    {
        uTime: { value: 0 },
        uColorStart: { value: new THREE.Color(0xff0000) },
        uColorEnd: { value: new THREE.Color(0x0000ff) }
    },
    // ...
})

JavaScript

Retrieve those uniform in the fragment shader, mix() them into a color variable using the strength and send it to the gl_FragColor :

uniform float uTime;
uniform vec3 uColorStart;
uniform vec3 uColorEnd;

// ...

void main()
{
    // ...

    // Final color
    vec3 color = mix(uColorStart, uColorEnd, strength);

    gl_FragColor = vec4(color, 1.0);
}

GLSL

You should get a nice mix between the red and blue.

Let’s add those colors to our Dat.GUI. As always, we add them as properties of the debugObject and listen to changes to update the uniforms accordingly:

debugObject.portalColorStart = '#ff0000'
debugObject.portalColorEnd = '#0000ff'

gui
    .addColor(debugObject, 'portalColorStart')
    .onChange(() =>
    {
        portalLightMaterial.uniforms.uColorStart.value.set(debugObject.portalColorStart)
    })

gui
    .addColor(debugObject, 'portalColorEnd')
    .onChange(() =>
    {
        portalLightMaterial.uniforms.uColorEnd.value.set(debugObject.portalColorEnd)
    })

JavaScript

You can now tweak the colors.

But there is a catch. With some colors like deep blue to orange, you’ll notice that you end up with yellow on the edges and we also lose the sharpness we had before:

This is because the strength is actually going below 0.0 and above 1.0 which results on the colors being extrapolated. It’s not much of a problem with black and white, but it gets messy with some other colors.

To fix that, we need to make sure that the value is clamped between 0.0 and 1.0 by using the clamp(...) function:

void main()
{
    // ...

    // Clamp the value
    strength = clamp(strength, 0.0, 1.0);

    // ...
}

GLSL

Anyway, if you are using the model from the starter, you should probably stick with black and white.

Don’t forget to use the debugObject.portalColorStart and debugObject.portalColorEnd in the initial values of the uniforms:

debugObject.portalColorStart = '#000000'
debugObject.portalColorEnd = '#ffffff'

// ...

const portalLightMaterial = new THREE.ShaderMaterial({
    uniforms:
    {
        uColorStart: { value: new THREE.Color(debugObject.portalColorStart) },
        uColorEnd: { value: new THREE.Color(debugObject.portalColorEnd) },
        // ...
    },
    // ...
})

JavaScript

Go further

That’s it for our portal scene. Now would be a good time to add more tweaks and maybe more details.

You can obviously try to create your own scene from scratch using all the modeling and baking processes you have learnt. Take your time, make sure to have the proper geometries, and clean UV coordinates. Don’t hesitate to share your result on Twitter or in the Discord server.