Table of Contents

How to create Lava Lamp Visuals with Three.js

vue-typescript-three-webgl

Tutorial on how to create lava lamp visuals with three.js and glsl.

Tags: Vue 3, Typescript, Three.js, WebGL,

Time to read: 9 min

Prerequisites

  • Familiarity with the command line
  • Install Node.js version 16.0 or higher

Setup project

We will focus on building the lava lamp functionality, so to save us some time we will begin from a scaffold. Lets setup the project from the scaffold.

Run below commands in the command line.

mkdir lava-app && cd lava-app
npx degit crunchwrap89/Vue3-TS-Scaffold
npm i three @types/three nice-color-palettes
npm run dev

Now if everything has been done correctly you can visit http://127.0.0.1:5000/ and you should be faced with a message saying ⭐ Success ⭐

Create the Scene component

Open the new project folder in Visual studio code and lets begin.

First we will create a new file and place it under the components folder: src/components/LavaScene.vue

Add the following code to the file:

<script setup lang="ts">
import {
  Scene,
  WebGLRenderer,
  ShaderMaterial,
  Vector4,
  sRGBEncoding,
  PerspectiveCamera,
  DoubleSide,
  PlaneGeometry,
  Mesh,
  AmbientLight,
  DirectionalLight,
  Color,
} from "three";
import colors from "nice-color-palettes";
import { lavaFragment } from "@/shaders/fragment";
import { vertex } from "@/shaders/vertex";
import {computed, ref, onMounted, } from "vue";
//get a random color palette from nice-color-palettes. Adjust the ind variable for different color schemes.
let ind = Math.floor(Math.random() * colors.length);
ind = 5;
let palette: string[] | Color[] = colors[ind];
palette = palette.map((color) => new Color(color));
let _scene: Scene;
let _renderer: WebGLRenderer;
let _camera: PerspectiveCamera;
let _frameId: number;
const _canvas = computed(
  () => document.getElementById("lavaMountId") as HTMLCanvasElement
);
const _isPlaying = ref(false);
const _time = ref(0);
let _geometry: PlaneGeometry;
let _plane: Mesh;
let _material: any;
//the objects make up of only one plane object of size 1x1 and 300x300 segments, the material is whats make the cool effects on the plane.
function addObjects() {
  _material = new ShaderMaterial({
    extensions: {
      derivatives: false,
    },
    side: DoubleSide,
    uniforms: {
      time: {
        value: 0,
      },
      uColor: { value: palette },
      resolution: {
        value: new Vector4(),
      },
    },
    wireframe: false,
    vertexShader: vertex(),
    fragmentShader: lavaFragment(),
  });
  _geometry = new PlaneGeometry(1, 1, 300, 300);
  _plane = new Mesh(_geometry, _material);
  _scene.add(_plane);
}
// add lighting to the scene, experiment with colors and intensity to get different effects.
function addLights() {
  const light1 = new AmbientLight("green", 0.5);
  const light2 = new DirectionalLight("purple", 0.5);
  _scene.add(light1);
  _scene.add(light2);
}
//initialize the scene. this will be done when the component has been mounted.
function init() {
  _scene = new Scene();
  _renderer = new WebGLRenderer({ canvas: _canvas.value });
  _renderer.setClearColor(0xeeeeee, 1);
  _renderer.physicallyCorrectLights = true;
  _renderer.outputEncoding = sRGBEncoding;
  _camera = new PerspectiveCamera(
    70,
    _width.value / _height.value,
    0.001,
    1000
  );
  _camera.position.set(0, 0, 0.3);
  _isPlaying.value = true;
  _time.value = 0;
  addObjects();
  addLights();
  render();
  window.addEventListener("resize", reSize);
  reSize();
}
const _width = ref(400);
const _height = ref(400);
const _imageAspect = ref(0);
//resize, this will be done on first render and when the window resizes.
//it will also rescale the material sizes.
function reSize() {
  if (!_renderer) return;
  _width.value = document.documentElement.clientWidth;
  _height.value = document.documentElement.clientHeight;
  _imageAspect.value = _width.value / _height.value;
  _renderer.setSize(_width.value, _height.value, false);
  let a1;
  let a2;
  if (_height.value / _width.value > _imageAspect.value) {
    a1 = (_width.value / _height.value) * _imageAspect.value;
    a2 = 1;
  } else {
    a1 = 1;
    a2 = _height.value / _width.value / _imageAspect.value;
  }
  _material.uniforms.resolution.value.x = _width.value;
  _material.uniforms.resolution.value.y = _height.value;
  _material.uniforms.resolution.value.z = a1;
  _material.uniforms.resolution.value.w = a2;
  _camera.aspect = _canvas.value.clientWidth / _canvas.value.clientHeight;
  _camera.updateProjectionMatrix();
}
//the render loop. this runs atleast 30 times per second, depending on your monitor refresh rate.
function render() {
  if (!_isPlaying.value) return;
  _time.value += 0.001;
  _material.uniforms.time.value = _time.value;
  _renderer.render(_scene, _camera);
  _frameId = requestAnimationFrame(render);
}
onMounted(() => {
  if (_canvas) {
    init();
  }
});
</script>
<template>
  <canvas id="lavaMountId" style="width: 100%; height: 100%;" />
</template>

Add fragmentshader.

Now we must add the fragment shader.

Create a new file: src/shaders/fragment.ts

Add below code to the file:

export function lavaFragment() {
  return `
    uniform float time;
    uniform float progress;
    uniform sampler2D texture1;
    uniform vec4 resolution;
    varying vec2 vUv;
    varying vec3 vPosition;
    varying vec3 vColor;
    float PI = 3.141592653589793238;
    void main() {
        gl_FragColor = vec4(vColor,0.5);
    }`;
}

This file could also be a .glsl file instead of a .ts file with a function that returns a string. But this works just as well and will do for now.

Add vertexshader.

Now for the final step before testing it out we must add the vertex shader.

Create a new file: src/shaders/vertex.ts

Add below code to the file:

export function vertex() {
  return `
    uniform float time;
    varying vec2 vUv;
    varying vec3 vPosition;
    uniform vec3 uColor[5];
    varying vec3 vColor;
    uniform vec2 pixels;
    float PI = 3.141592653589793238;
    
    
    vec4 permute(vec4 x){return mod(((x*34.0)+1.0)*x, 289.0);}
    vec4 taylorInvSqrt(vec4 r){return 1.79284291400159 - 0.85373472095314 * r;}
    
    float snoise(vec3 v){ 
      const vec2  C = vec2(1.0/6.0, 1.0/3.0) ;
      const vec4  D = vec4(0.0, 0.5, 1.0, 2.0);
    
    // First corner
      vec3 i  = floor(v + dot(v, C.yyy) );
      vec3 x0 =   v - i + dot(i, C.xxx) ;
    
    // Other corners
      vec3 g = step(x0.yzx, x0.xyz);
      vec3 l = 1.0 - g;
      vec3 i1 = min( g.xyz, l.zxy );
      vec3 i2 = max( g.xyz, l.zxy );
    
      //  x0 = x0 - 0. + 0.0 * C 
      vec3 x1 = x0 - i1 + 1.0 * C.xxx;
      vec3 x2 = x0 - i2 + 2.0 * C.xxx;
      vec3 x3 = x0 - 1. + 3.0 * C.xxx;
    
    // Permutations
      i = mod(i, 289.0 ); 
      vec4 p = permute( permute( permute( 
                i.z + vec4(0.0, i1.z, i2.z, 1.0 ))
              + i.y + vec4(0.0, i1.y, i2.y, 1.0 )) 
              + i.x + vec4(0.0, i1.x, i2.x, 1.0 ));
    
    // Gradients
    // ( N*N points uniformly over a square, mapped onto an octahedron.)
      float n_ = 1.0/7.0; // N=7
      vec3  ns = n_ * D.wyz - D.xzx;
    
      vec4 j = p - 49.0 * floor(p * ns.z *ns.z);  //  mod(p,N*N)
    
      vec4 x_ = floor(j * ns.z);
      vec4 y_ = floor(j - 7.0 * x_ );    // mod(j,N)
    
      vec4 x = x_ *ns.x + ns.yyyy;
      vec4 y = y_ *ns.x + ns.yyyy;
      vec4 h = 1.0 - abs(x) - abs(y);
    
      vec4 b0 = vec4( x.xy, y.xy );
      vec4 b1 = vec4( x.zw, y.zw );
    
      vec4 s0 = floor(b0)*2.0 + 1.0;
      vec4 s1 = floor(b1)*2.0 + 1.0;
      vec4 sh = -step(h, vec4(0.0));
    
      vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ;
      vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ;
    
      vec3 p0 = vec3(a0.xy,h.x);
      vec3 p1 = vec3(a0.zw,h.y);
      vec3 p2 = vec3(a1.xy,h.z);
      vec3 p3 = vec3(a1.zw,h.w);
    
    //Normalise gradients
      vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
      p0 *= norm.x;
      p1 *= norm.y;
      p2 *= norm.z;
      p3 *= norm.w;
    
    // Mix final noise value
      vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
      m = m * m;
      return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1), 
                                    dot(p2,x2), dot(p3,x3) ) );
    }
    
    
    void main() {
        vec2 noiseCoord = uv*vec2(3., 4.);
        float noise = snoise(vec3(noiseCoord.x + (time * .3), noiseCoord.y, time * 0.1));
        noise = max(0., noise);
        float tilt = -0.8*uv.y;
        float incline = uv.x*0.5;
        float offset = incline*mix(-.25,0.25,uv.y);
        vec3 pos = vec3(position.x,position.y,position.z + noise * 0.3 + tilt + incline + offset);
        vColor = uColor[4];
        for (int i = 0; i < 5; i++) {
          float noiseFlow = 1. + float(i) * 0.01;
          float noiseSpeed = 1. + float(i) * 0.01;
          float noiseSeed = 1. + float(i) * 0.1;
          vec2 noiseFreq = vec2(0.3, 0.5);
          float noise = snoise(vec3(noiseCoord.x * noiseFreq.x + time * noiseFlow,
                                    noiseCoord.y,
                                    time * noiseSpeed + noiseSeed));
          vColor = mix(vColor, uColor[i], noise);
        } 
        vUv = uv;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
    }
  `;
}

To really understand whats going on in this code its best to play around with the values and see what happens.

Before doing that we must make this application work. So lets move to the final step.

Replace the App.vue file

The last step we will replace the App.vue file with the below code:

<script setup lang="ts">
  import LavaScene from '@/components/LavaScene.vue';
</script>
<template>
  <LavaScene />
</template>
<style>
  @import '@/assets/base.css';
</style>

and now, if your application is not running yet:

npm run dev

There! Now we have built a lavalamp effect! Play around with it, tweak the variables and use it on your website. The best way to learn how to work shaders is to reverse engineer them. Try to break it apart bit by bit and replace the variables with new values. See what happens. Cya!

Did you like this tutorial?

You can support me, so that i can continue to make more tutorials like this one.

buy-me-a-coffee