Explaining fluid-simulation-react

Kathan Chaudhari
11 min readDec 28, 2023

--

I created npm library call fluid-simulation-react. You can checkout code on github.
So first of all, I am not originally owner of all the code.
It is based on this:https://paveldogreat.github.io/WebGL-Fluid-Simulation/
I just made changes to make it work on React.js. Idea is not mine, I just wanted it to make usable for people without to much hustle in React.

You can directly use it via installing package and following usage.
here I am explaining the code(or part what I understand) So if you want to help or create something like this it should be starting point.

export const defaultConfig = {
textureDownsample: 1,
densityDissipation: 0.98,
velocityDissipation: 0.99,
pressureDissipation: 0.8,
pressureIterations: 25,
curl: 30,
splatRadius: 0.005,
};

const Pointer = () => {
return {
id: -1,
x: 0,
y: 0,
dx: 0,
dy: 0,
down: false,
moved: false,
color: [30, 0, 300],
};
};

This is the default value we are provide. NOW what are this?

  • textureDownsample: This controls resolution of textures used in the simulation.
  • densityDissipation: This determines how quickly the fluid's density dissipates or fades over time.
  • velocityDissipation: Similar to densityDissipation, but for the fluid's velocity. It controls how quickly the fluid's movement slows down.
  • pressureDissipation: This affects the dissipation of pressure in the fluid. It's another factor that influences how the simulation stabilizes over time.
  • pressureIterations: This specifies the number of iterations to be used in the pressure calculations.
  • curl: This parameter controls the amount of curl or swirl in the fluid motion, adding to the visual complexity of the simulation.
  • splatRadius: This sets the radius of the impact when a new force is introduced into the fluid (like a splat). It determines the size of these disturbances.

The Pointer function is a factory function that creates and returns a new pointer object. This object represents a point of interaction in the fluid simulation, like a touch or mouse position.

Now inside FluidAnimation:

useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const width = canvas.offsetWidth;
const height = canvas.offsetHeight;
if (canvas.width !== width || canvas.height !== height) {
canvasRef.current.width = width;
canvasRef.current.height = height;
}
const { gl, ext } = getGLContext(canvas);
glRef.current = gl;
extRef.current = ext;
gl.getError()
const programs = initPrograms(gl, ext);

programsRef.current = programs;
initFramebuffers(gl, ext);
initBlit(gl);
}, []);

First setup Canvas, To render the simulation at the correct size.
Then we are calling getGLContextwhich is important for all WebGL operations. Then,

  • Shader Program Initialization: initPrograms creates shader programs using vertex and fragment shaders. Shader programs are responsible for rendering different aspects of the fluid simulation (like advection, splat, pressure, blah blah.).
  • Framebuffer Initialization: initFramebuffers sets up framebuffers that are used for off-screen rendering.
  • Blit Function Initialization: initBlit is a function to initialize settings or resources for blitting, which is a process to copy from one part of memory to another.

Now inside getGLContext:

export default function getWebGLContext (canvas) {
const params = {
alpha: false,
depth: false,
stencil: false,
antialias: false
}

let gl = canvas.getContext('webgl2', params)
const isWebGL2 = !!gl
if (!isWebGL2) {
gl =
canvas.getContext('webgl', params) ||
canvas.getContext('experimental-webgl', params)
}

let halfFloat
let supportLinearFiltering
if (isWebGL2) {
gl.getExtension('EXT_color_buffer_float')
supportLinearFiltering = gl.getExtension('OES_texture_float_linear')
} else {
halfFloat = gl.getExtension('OES_texture_half_float')
supportLinearFiltering = gl.getExtension('OES_texture_half_float_linear')
}

gl.clearColor(0.0, 0.0, 0.0, 1.0)

const halfFloatTexType = isWebGL2 ? gl.HALF_FLOAT : halfFloat.HALF_FLOAT_OES
let formatRGBA
let formatRG
let formatR

if (isWebGL2) {
formatRGBA = getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, halfFloatTexType)
formatRG = getSupportedFormat(gl, gl.RG16F, gl.RG, halfFloatTexType)
formatR = getSupportedFormat(gl, gl.R16F, gl.RED, halfFloatTexType)
} else {
formatRGBA = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType)
formatRG = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType)
formatR = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType)
}

return {
gl,
ext: {
formatRGBA,
formatRG,
formatR,
halfFloatTexType,
supportLinearFiltering
}
}
}

function getSupportedFormat (gl, internalFormat, format, type) {
if (!supportRenderTextureFormat(gl, internalFormat, format, type)) {
switch (internalFormat) {
case gl.R16F:
return getSupportedFormat(gl, gl.RG16F, gl.RG, type)
case gl.RG16F:
return getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, type)
default:
return null
}
}

return {
internalFormat,
format
}
}

function supportRenderTextureFormat (gl, internalFormat, format, type) {
const texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, 4, 4, 0, format, type, null)

let fbo = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
texture,
0
)

const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER)
if (status !== gl.FRAMEBUFFER_COMPLETE) return false
return true
}

This function initializes and returns the WebGL context with certain parameters:

  • It tries to get a WebGL2 context first and falls back to WebGL if not available.
  • Extensions like OES_texture_float_linear and OES_texture_half_float are checked and enabled if available. These are used for more advanced texture handling.
  • The function also determines the supported texture formats and types, which is crucial for compatibility across different browsers and devices.

getSupportedFormat and supportRenderTextureFormat Functions

These functions check for the supported texture formats:

  • They are used to find the best internal format and texture format that the current WebGL context supports.
  • This is important for ensuring that the simulation works correctly across different graphics hardware and browsers.

initPrograms Function

const initPrograms = (gl, ext) => {
const programs = {};
programs.clear = new GLProgram(gl, shaders.vert, shaders.clear);
programs.display = new GLProgram(gl, shaders.vert, shaders.display);
programs.splat = new GLProgram(gl, shaders.vert, shaders.splat);
programs.advection = new GLProgram(
gl,
shaders.vert,
ext.supportLinearFiltering
? shaders.advection
: shaders.advectionManualFiltering
);
programs.divergence = new GLProgram(gl, shaders.vert, shaders.divergence);
programs.curl = new GLProgram(gl, shaders.vert, shaders.curl);
programs.vorticity = new GLProgram(gl, shaders.vert, shaders.vorticity);
programs.pressure = new GLProgram(gl, shaders.vert, shaders.pressure);
programs.gradientSubtract = new GLProgram(
gl,
shaders.vert,
shaders.gradientSubtract
);
return programs;
};

This function initializes various shader programs:

  • Each shader program is created for a specific purpose in the simulation (e.g., clear, display, splat, etc.).
  • It uses a vertex shader (shaders.vert) and various fragment shaders (like shaders.clear, shaders.display, etc.).
  • The advection program has a conditional to choose between linear filtering and manual filtering, based on the capabilities of the WebGL context.

initFramebuffers Function:

const initFramebuffers = (gl, ext) => {
const createFBO = (texId, w, h, internalFormat, format, type, param) => {
gl.activeTexture(gl.TEXTURE0 + texId);
const texture = gl.createTexture();

gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, param);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, param);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(
gl.TEXTURE_2D,
0,
internalFormat,
w,
h,
0,
format,
type,
null
);

const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
texture,
0
);
gl.viewport(0, 0, w, h);
gl.clear(gl.COLOR_BUFFER_BIT);

return [texture, fbo, texId];
};

const createDoubleFBO = (
texId,
w,
h,
internalFormat,
format,
type,
param
) => {
let fbo1 = createFBO(texId, w, h, internalFormat, format, type, param);
let fbo2 = createFBO(
texId + 1,
w,
h,
internalFormat,
format,
type,
param
);

return {
get read() {
return fbo1;
},
get write() {
return fbo2;
},
swap() {
const temp = fbo1;
fbo1 = fbo2;
fbo2 = temp;
},
};
};

textureWidthRef.current = gl.drawingBufferWidth >> defaultConfig.textureDownsample;
textureHeightRef.current = gl.drawingBufferHeight >> defaultConfig.textureDownsample;

const texType = ext.halfFloatTexType;
const rgba = ext.formatRGBA;
const rg = ext.formatRG;
const r = ext.formatR;

densityRef.current = createDoubleFBO(
2,
textureWidthRef.current,
textureHeightRef.current,
rgba.internalFormat,
rgba.format,
texType,
ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST
);

velocityRef.current = createDoubleFBO(
0,
textureWidthRef.current,
textureHeightRef.current,
rg.internalFormat,
rg.format,
texType,
ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST
);

divergenceRef.current = createFBO(
4,
textureWidthRef.current,
textureHeightRef.current,
r.internalFormat,
r.format,
texType,
gl.NEAREST
);

curlRef.current = createFBO(
5,
textureWidthRef.current,
textureHeightRef.current,
r.internalFormat,
r.format,
texType,
gl.NEAREST
);

pressureRef.current = createDoubleFBO(
6,
textureWidthRef.current,
textureHeightRef.current,
r.internalFormat,
r.format,
texType,
gl.NEAREST
);
};

This function creates framebuffers and textures needed for the simulation:

  • createFBO and createDoubleFBO functions create framebuffers and textures with specific settings. Framebuffers allow rendering to textures instead of directly to the canvas, enabling complex image processing and effects.
  • densityRef, velocityRef, divergenceRef, etc., are references to these framebuffers, which store various states of the fluid (like density, velocity, etc.).

initblit and blit Functions

 const initBlit = (gl) => {
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]),
gl.STATIC_DRAW
);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(
gl.ELEMENT_ARRAY_BUFFER,
new Uint16Array([0, 1, 2, 0, 2, 3]),
gl.STATIC_DRAW
);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(0);
};

const blit = ( destination) => {
const gl = glRef.current;
gl.bindFramebuffer(gl.FRAMEBUFFER, destination);
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
};

These functions set up and perform blitting, a process of transferring data from one part of memory to another.

  • initBlit: This function initializes buffers for vertex positions and element indices. It sets up the data needed for drawing a quad that covers the entire screen. This quad is used in the blit function to draw textures.
  • blit: This function renders a texture to a destination (either the screen or a framebuffer). It's used throughout the simulation to render various stages of the fluid dynamics process.

splat Function

 const splat = (gl, x, y, dx, dy, color) => {
programsRef.current.splat.bind();

gl.uniform1i(
programsRef.current.splat.uniforms.uTarget,
velocityRef.current.read[2]
);
gl.uniform1f(
programsRef.current.splat.uniforms.aspectRatio,
canvasRef.current.width / canvasRef.current.height
);
gl.uniform2f(
programsRef.current.splat.uniforms.point,
x / canvasRef.current.width,
1.0 - y / canvasRef.current.height
);
gl.uniform3f(programsRef.current.splat.uniforms.color, dx, -dy, 1.0);
gl.uniform1f(programsRef.current.splat.uniforms.radius, config.splatRadius);
blit(velocityRef.current.write[1]);
velocityRef.current.swap();

gl.uniform1i(
programsRef.current.splat.uniforms.uTarget,
densityRef.current.read[2]
);
gl.uniform3f(
programsRef.current.splat.uniforms.color,
color[0] * 0.3,
color[1] * 0.3,
color[2] * 0.3
);
blit(densityRef.current.write[1]);
densityRef.current.swap();
};

This function simulates the effect of a fluid “splat” — like a drop hitting the surface of the fluid.

  • splat.bind(): Binds the splat shader program.
  • uniform1i, uniform1f, uniform2f, uniform3f: These lines set the uniforms for the shader program. Uniforms are global GLSL variables set from the WebGL application. They include the target texture, aspect ratio, splat position, color, and radius.
  • blit: The blit function is called twice, first to apply the splat effect to the velocity texture and then to the density texture. This modifies the fluid's velocity and appearance at the splat point.
  • swap: Swaps the read and write framebuffers, preparing them for the next rendering pass.

compileShaders and GLProgram :

function compileShader (gl, type, source) {
const shader = gl.createShader(type)
gl.shaderSource(shader, source)
gl.compileShader(shader)

if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw gl.getShaderInfoLog(shader)
}

return shader
}

export default class GLProgram {
constructor(gl, vertexSource, fragmentSource) {
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource)
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource)

this.uniforms = {}
this.program = gl.createProgram()
this.gl = gl

gl.attachShader(this.program, vertexShader)
gl.attachShader(this.program, fragmentShader)
gl.linkProgram(this.program)

if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
throw gl.getProgramInfoLog(this.program)
}

const uniformCount = gl.getProgramParameter(
this.program,
gl.ACTIVE_UNIFORMS
)

for (let i = 0; i < uniformCount; i++) {
const uniformName = gl.getActiveUniform(this.program, i).name
this.uniforms[uniformName] = gl.getUniformLocation(
this.program,
uniformName
)
}
}

bind() {
this.gl.useProgram(this.program)
}
}

These parts of the code handle the compilation of GLSL shaders and the creation of WebGL shader programs.

  • compileShader: Compiles a shader from source code. It checks for compilation errors and throws an exception if any are found.
  • GLProgram: A class that represents a WebGL program consisting of a vertex and a fragment shader. It compiles the shaders, attaches them to a program, links the program, and retrieves locations of uniform variables.

Mouse and Touch Event Handlers

const onMouseMove = (e) => {
const pointer = pointersRef.current[0];
pointer.down = true;
pointer.moved = true;
pointer.dx = (e.clientX - pointer.x) * 10.0;
pointer.dy = (e.clientY - pointer.y) * 10.0;
pointer.x = e.clientX;
pointer.y = e.clientY;
pointer.color = getRandomColor();
};


const onMouseDown = (e) => {
const pointer = pointersRef.current;
pointer.down = true;
pointer.color = getRandomColor();
};
const onMouseUp = (e) => {
const pointer = pointersRef.current;
pointer.down = false;
pointer.moved = false;
};

const onTouchStart = (e) => {
for (let i = 0; i < e.touches.length; ++i) {
if (pointersRef.current[i]) {
pointersRef.current[i].down = true;
pointersRef.current[i].color = getRandomColor();
} else {
pointersRef.current[i] = { down: true, x: 0, y: 0, dx: 0, dy: 0, color: [1, 0, 0] };
}
}
};

const onTouchMove = (e) => {
for (let i = 0; i < e.touches.length; ++i) {
const touch = e.touches[i];
const pointer = pointersRef.current[i];
pointer.moved = true;
pointer.dx = (touch.clientX - pointer.x) * 10.0;
pointer.dy = (touch.clientY - pointer.y) * 10.0;
pointer.x = touch.clientX;
pointer.y = touch.clientY;
}
};

const onTouchEnd = (e) => {
pointersRef.current.forEach((pointer) => {
pointer.down = false;
});
};

These functions handle mouse and touch interactions, updating the pointer object that represents the position and movement of the user's input.

  • onMouseMove: Updates the pointer object with the new mouse position and movement. It sets the pointer.moved flag, which can trigger a splat in the simulation.
  • onMouseDown and onMouseUp: These functions handle mouse button press and release events. onMouseDown sets the pointer as active, and onMouseUp deactivates it.
  • onTouchStart and onTouchMove: Similar to the mouse event handlers, but for touch events. They handle multiple touches by updating a pointer object for each touch point.

update Function:

useEffect(() => {
let animationFrameId;

const update = async () => {
const gl = glRef.current;
const now = Date.now();
const dt = Math.min((now - timeRef.current) / 1000, 0.016);
timeRef.current = now;
timerRef.current += 0.0001;
const w = textureWidthRef.current;
const h = textureHeightRef.current;
const iW = 1.0 / w;
const iH = 1.0 / h;

gl.viewport(0, 0, w, h);

programsRef.current.advection.bind();
gl.uniform2f(programsRef.current.advection.uniforms.texelSize, iW, iH);
gl.uniform1i(
programsRef.current.advection.uniforms.uVelocity,
velocityRef.current.read[2]
);
gl.uniform1i(
programsRef.current.advection.uniforms.uSource,
velocityRef.current.read[2]
);
gl.uniform1f(programsRef.current.advection.uniforms.dt, dt);
gl.uniform1f(
programsRef.current.advection.uniforms.dissipation,
config.velocityDissipation
);
blit(velocityRef.current.write[1]);
velocityRef.current.swap();

gl.uniform1i(
programsRef.current.advection.uniforms.uVelocity,
velocityRef.current.read[2]
);
gl.uniform1i(
programsRef.current.advection.uniforms.uSource,
densityRef.current.read[2]
);
gl.uniform1f(
programsRef.current.advection.uniforms.dissipation,
config.densityDissipation
);
blit(densityRef.current.write[1]);
densityRef.current.swap();

for (let i = 0; i < pointersRef.current.length; i++) {
const pointer = pointersRef.current[i];
if (pointer.moved) {
splat(gl,pointer.x, pointer.y, pointer.dx, pointer.dy, pointer.color);
pointer.moved = false; // Reset the moved flag
}
}

programsRef.current.curl.bind();
gl.uniform2f(programsRef.current.curl.uniforms.texelSize, iW, iH);
gl.uniform1i(
programsRef.current.curl.uniforms.uVelocity,
velocityRef.current.read[2]
);
blit(curlRef.current[1]);

programsRef.current.vorticity.bind();
gl.uniform2f(programsRef.current.vorticity.uniforms.texelSize, iW, iH);
gl.uniform1i(
programsRef.current.vorticity.uniforms.uVelocity,
velocityRef.current.read[2]
);
gl.uniform1i(
programsRef.current.vorticity.uniforms.uCurl,
curlRef.current[2]
);
gl.uniform1f(programsRef.current.vorticity.uniforms.curl, config.curl);
gl.uniform1f(programsRef.current.vorticity.uniforms.dt, dt);
blit(velocityRef.current.write[1]);
velocityRef.current.swap();

programsRef.current.divergence.bind();
gl.uniform2f(programsRef.current.divergence.uniforms.texelSize, iW, iH);
gl.uniform1i(
programsRef.current.divergence.uniforms.uVelocity,
velocityRef.current.read[2]
);
blit(divergenceRef.current[1]);

programsRef.current.clear.bind();
let pressureTexId = pressureRef.current.read[2];
gl.activeTexture(gl.TEXTURE0 + pressureTexId);
gl.bindTexture(gl.TEXTURE_2D, pressureRef.current.read[0]);
gl.uniform1i(programsRef.current.clear.uniforms.uTexture, pressureTexId);
gl.uniform1f(
programsRef.current.clear.uniforms.value,
config.pressureDissipation
);
blit(pressureRef.current.write[1]);
pressureRef.current.swap();

programsRef.current.pressure.bind();
gl.uniform2f(programsRef.current.pressure.uniforms.texelSize, iW, iH);
gl.uniform1i(
programsRef.current.pressure.uniforms.uDivergence,
divergenceRef.current[2]
);
pressureTexId = pressureRef.current.read[2];
gl.uniform1i(
programsRef.current.pressure.uniforms.uPressure,
pressureTexId
);
gl.activeTexture(gl.TEXTURE0 + pressureTexId);
for (let i = 0; i < config.pressureIterations; i++) {
gl.bindTexture(gl.TEXTURE_2D, pressureRef.current.read[0]);
blit(pressureRef.current.write[1]);
pressureRef.current.swap();
}

programsRef.current.gradientSubtract.bind();
gl.uniform2f(
programsRef.current.gradientSubtract.uniforms.texelSize,
iW,
iH
);
gl.uniform1i(
programsRef.current.gradientSubtract.uniforms.uPressure,
pressureRef.current.read[2]
);
gl.uniform1i(
programsRef.current.gradientSubtract.uniforms.uVelocity,
velocityRef.current.read[2]
);
blit(velocityRef.current.write[1]);
velocityRef.current.swap();

gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
programsRef.current.display.bind();
gl.uniform1i(
programsRef.current.display.uniforms.uTexture,
densityRef.current.read[2]
);
blit(null);
animationFrameId = requestAnimationFrame(update);
};
animationFrameId = requestAnimationFrame(update);
return () => {
cancelAnimationFrame(animationFrameId);
};

}, []);

This is an asynchronous function that gets called every animation frame to update and render your simulation.

  • Time Calculation: It calculates dt, the time difference between frames, which is used to ensure consistent animation speed regardless of the frame rate.
  • Setting the Viewport: gl.viewport(0, 0, w, h) sets the size of the viewport according to the texture dimensions.
  • Advection Step: This part of the code advances the fluid simulation by a time step dt. It updates both the velocity (velocityRef.current) and the density (densityRef.current) of the fluid. The advection shader program is used here.
  • Splatting: If any pointer has moved (pointer.moved), the splat function is called, which adds a disturbance at the pointer's location in the fluid. This simulates effects like drops in the fluid or other interactions.
  • Curl and Vorticity: These steps calculate the curl of the fluid and then apply vorticity forces to the velocity field.
  • Divergence and Pressure: These steps first calculate the divergence of the velocity field, then perform a pressure solve to make the velocity field divergence-free (important for fluid simulation).
  • Gradient Subtraction: This step subtracts the gradient of the pressure from the velocity field to enforce the incompressibility of the fluid.
  • Rendering to the Screen: Finally! the fluid’s density field is rendered to the screen using the display shader program. The blit function is used to render the texture to the canvas.

requestAnimationFrame(update): This line creates a loop by requesting that the update function be called before the next repaint. requestAnimationFrame is a browser API that's commonly used for animations as it optimizes frame rates and CPU usage.

Cleanup Function: The return statement in the useEffect provides a cleanup function. It cancels the animation frame request when the component is unmounted. This is important to prevent memory leaks and unnecessary computations when the component is no longer in use.

lastly we are calling this function inside Index.js and exporting it.

BOOM! it’s ready now,

do something like this:

Happy Coding :)

--

--

Kathan Chaudhari

Full stack developer, With experience in React, Next,js and Node.js