ArunavoRay.
cover image

Jul 11, 20244 min Read

Creating an Aqua Vortex Shader using ThreeJS

A guide on how to add custom shaders using Three.js to your React Next.js project

Adding custom shaders to your Next.js project can bring visually stunning effects and enhance the user experience. In this guide, we'll walk you through integrating a custom Aqua Vortex shader using Three.js and React Three Fiber while keeping performance in mind.


Step 1: Setting Up Your Next.js Project

First, ensure your Next.js project is set up. If you're starting from scratch, create a new Next.js app:

npx create-next-app@latest my-next-app
cd my-next-app

Then, install the necessary dependencies:

npm install three @react-three/fiber

Step 2: Creating the Shader Material Component

Create a component for your shader material. This will handle the shader code and uniforms:

"use client";

import React, { forwardRef, useMemo, useRef, useImperativeHandle } from 'react';
import * as THREE from 'three';
import { useThree, useFrame } from '@react-three/fiber';

interface Uniforms {
  [key: string]: {
    value: number | number[] | number[][] | THREE.Vector2;
    type: string;
  };
}

const prepareUniforms = (uniforms: Uniforms) => {
  const preparedUniforms: { [key: string]: any } = {};

  for (const key in uniforms) {
    const uniform = uniforms[key];
    switch (uniform.type) {
      case '1f':
        preparedUniforms[key] = { value: uniform.value, type: '1f' };
        break;
      case '3fv':
        preparedUniforms[key] = {
          value: (uniform.value as number[][]).map(
            (v) => new THREE.Vector3(...v)
          ),
          type: '3fv',
        };
        break;
      case '1fv':
        preparedUniforms[key] = { value: uniform.value, type: '1fv' };
        break;
      case '2f':
        preparedUniforms[key] = { value: uniform.value, type: '2f' };
        break;
      default:
        console.error(`Invalid uniform type for '${key}'.`);
        break;
    }
  }

  return preparedUniforms;
};

const ShaderMaterial = forwardRef<THREE.ShaderMaterial, { source: string; uniforms: Uniforms; maxFps?: number }>(
  ({ source, uniforms, maxFps = 60 }, ref) => {
    const { size } = useThree();
    const meshRef = useRef<THREE.Mesh>(null);
    const lastFrameTime = useRef(0);

    const preparedUniforms = useMemo(() => {
      const baseUniforms = prepareUniforms(uniforms);
      baseUniforms.u_time = { value: 0, type: '1f' };
      baseUniforms.u_resolution = {
        value: new THREE.Vector2(size.width * 2, size.height * 2),
        type: '2f',
      };
      return baseUniforms;
    }, [uniforms, size]);

    useFrame(({ clock }) => {
      if (!meshRef.current) return;
      const timestamp = clock.getElapsedTime();
      if (timestamp - lastFrameTime.current < 1 / maxFps) return;
      lastFrameTime.current = timestamp;

      (meshRef.current!.material as any).uniforms.u_time.value = timestamp;
    });

    const material = useMemo(() => {
      return new THREE.ShaderMaterial({
        vertexShader: `
          precision mediump float;
          in vec2 coordinates;
          uniform vec2 u_resolution;
          out vec2 fragCoord;
          void main() {
            float x = position.x;
            float y = position.y;
            gl_Position = vec4(x, y, 0.0, 1.0);
            fragCoord = (position.xy + vec2(1.0)) * 0.5 * u_resolution;
            fragCoord.y = u_resolution.y - fragCoord.y;
          }
        `,
        fragmentShader: source,
        uniforms: preparedUniforms,
        glslVersion: THREE.GLSL3,
      });
    }, [preparedUniforms, source]);

    useImperativeHandle(ref, () => material);

    return (
      <mesh ref={meshRef}>
        <planeGeometry args={[2, 2]} />
        <primitive object={material} attach="material" />
      </mesh>
    );
  }
);

ShaderMaterial.displayName = 'ShaderMaterial';
export default ShaderMaterial;

Step 3: Creating the Aqua Vortex Shader Component

Next, create a component to use the ShaderMaterial with your shader source code:

const AquaVortexShader: React.FC = () => {
  const meshRef = useRef<THREE.Mesh>(null);
  const materialRef = useRef<THREE.ShaderMaterial>(null);
  const { size } = useThree();
  const aspect = size.width / size.height;
  const [seed] = useState(Math.random());

  useFrame(({ clock }) => {
    if (materialRef.current) {
      materialRef.current.uniforms.iTime.value = clock.getElapsedTime();
      materialRef.current.uniforms.iResolution.value = new THREE.Vector2(size.width, size.height);
    }
  });

  return (
    <mesh ref={meshRef} scale={[aspect, 1, 1]}>
      <planeGeometry args={[10, 10]} />
      <ShaderMaterial
        ref={materialRef}
        source={fragmentShader}
        uniforms={{
          iTime: { value: 0, type: '1f' },
          iResolution: { value: new THREE.Vector2(size.width, size.height), type: '2f' },
          iSeed: { value: seed, type: '1f' },
        }}
      />
    </mesh>
  );
};
export default AquaVortexShader;

const fragmentShader = `
  precision mediump float;
  uniform float iTime;
  uniform vec2 iResolution;
  uniform float iSeed;

  float random(float seed) {
    return fract(sin(seed) * 43758.5453123);
  }

  out vec4 fragColor;

  void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = fragCoord / iResolution.xy;
    float speed = 0.4;
    float scale = 0.0035;
    vec2 p = fragCoord * scale;
    float seed = iSeed;
    for (int i = 1; i < 8; i++) {
      seed += float(i) * 10.0;
      p.x += 0.3 / float(i) * cos(float(i) * 3.0 * p.y + iTime * speed + random(seed)) + uv.x / 1000.0;
      p.y += 0.3 / float(i) * sin(float(i) * 3.0 * p.x + iTime * speed + random(seed)) + uv.y / 1000.0;
    }

    vec3 color1 = vec3(30.0 / 255.0, 210.0 / 255.0, 223.0 / 255.0);
    vec3 color2 = vec3(0.0 / 255.0, 175.0 / 255.0, 134.0 / 255.0);
    vec3 color3 = vec3(25.0 / 255.0, 28.0 / 255.0, 32.0 / 255.0);

    float mixFactor1 = (sin(p.x + p.y + 1.0) * 0.5 + 0.5);
    float mixFactor2 = (cos(p.x + p.y + 1.0) * 0.5 + 0.5);
    vec3 color = mix(color1, color2, mixFactor1);
    color = mix(color, color3, mixFactor2);

    fragColor = vec4(color, 1.0);
  }

  void main() {
    mainImage(fragColor, gl_FragCoord.xy);
  }
`;

Step 4: Integrating the Shader Component into the Canvas

Finally, create a canvas component to render the Aqua Vortex Shader:

const ShaderCanvas: React.FC = () => {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleResize = () => {
      const canvas = containerRef.current?.querySelector('canvas');
      if (canvas) {
        canvas.style.width = '100%';
        canvas.style.height = '100%';
      }
    };

    window.addEventListener('resize', handleResize);
    handleResize();

    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return (
    <div ref={containerRef} className="w-64 h-64">
      <Canvas>
        <AquaVortexShader />
      </Canvas>
    </div>
  );
};

export default ShaderCanvas;

Conclusion

By following these steps, you can create and integrate a custom Aqua Vortex shader in your Next.js project using Three.js and React Three Fiber. This setup allows you to add dynamic and visually appealing shaders, enhancing the user experience of your web applications keeping the performance in mind. Happy coding!

avatar

GOT A PROJECT IN MIND?

LET'S TALK