[Portfolio] Canvas api 로 별자리 배경 만들기

yujin·2025년 11월 13일

프로젝트

목록 보기
24/26
post-thumbnail
  • 포트폴리오를 만들다 보니 배경이 너무 밋밋해서 움직이는 별자리 배경을 만들어보려고 한다.

⭐ ParticleCanvas.tsx 구현

1. class Particle {...}

  • TypeScript에게 각 속성이 가져야 할 타입을 정의한다.

  • x, y: number (숫자) // 현재 입자의 위치
  • directionX: number (숫자) // 입자의 이동 속도와 방향 (양수: 오른쪽, 음수: 왼쪽)
  • directionY: number (숫자) // 입자의 이동 속도와 방향 (양수: 아래쪽, 음수: 위쪽)
  • size: number (숫자) // 입자의 반지름 크기
  • color: string (문자열) //입자의 색상
  • ctx: CanvasRenderingContext2D // Canvas의 그리기 도구
  • canvas: HTMLCanvasElement // <canvas> HTML 태그 자체이다.

  • 이 입자만의 고유한 위치와 속도를 저장하고, ctx에 대한 연결 정보를 확보하여,언제든 독립적으로 작동할 수 있도록 객체를 준비하는 역할을 한다.

  • 외부로부터 값 받기
    => constructor의 8개 인자는 init() 함수가 new Particle(...)을 호출할 때 넘겨주는 값들이다. 이 값들의 타입을 선언한다.

  • 객체 내부에 값 저장하기
    => this.ctx = ctx;
    => 여기서 this는 지금 막 생성되는 개별적인 Particle 객체 자신을 의미한다.
    => 오른쪽에 있는 인자 ctx의 값을, 왼쪽에 있는 this.ctx라는 객체의 고유한 속성에 저장한다.


  • this.ctx.beginPath();
    => 새로운 그림을 그릴 준비를 시작하라는 명령어

  • this.ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2, false);
    => arc(): Canvas API에서 호(Arc) 또는 원(Circle)을 그리는 함수
    => this.x, this.y: 입자의 현재 위치를 원의 중심 좌표로 지정한다.
    => this.size: 입자의 반지름 크기를 지정한다.
    => 0, Math.PI * 2: 호를 그리기 시작하는 각도(0 라디안)부터 끝나는 각도(2π 라디안 = 360도)까지 지정한다.
    => false: 원을 그리는 방향을 false(시계방향)으로 설정한다.
    => 즉, 이 코드는 완전한 원을 그린다.

  • this.ctx.fillStyle = this.color;
    => 채우기 색상을 설정하는 속성
    => 원의 내부를 this.color에 해당하는 색으로 채울 준비를 한다.

  • this.ctx.fill();
    => 설정된 색상(fillStyle)으로 방금 스케치한 원의 내부를 채운다.


  • 입자의 움직임과 화면 경계를 처리하는 코드이다.

X축 경계 처리


if (this.x > this.canvas.width || this.x < 0)
  this.directionX = -this.directionX;
  • this.x > this.canvas.width: 입자의 X 좌표가 캔버스의 총 너비를 넘어섰는지 확인한다. (오른쪽 경계 이탈 확인)
  • this.x < 0: 입자의 X 좌표가 0 미만인지 확인한다. (왼쪽 경계 이탈 확인)
  • ||: 둘 중 하나라도 참이면 다음 줄을 실행한다.
  • this.directionX = -this.directionX: 입자의 가로 이동 방향에 마이너스를 곱한다.
    => 오른쪽으로 가던 입자는 왼쪽으로 방향이 바뀌고, 왼쪽으로 가던 입자는 오른쪽으로 방향이 바뀌어 화면 안쪽으로 배치된다.

Y축 경계 처리

if (this.y > this.canvas.height || this.y < 0)
  this.directionY = -this.directionY;
  • this.y > this.canvas.height: 입자의 Y 좌표가 캔버스의 총 높이를 넘어섰는지 확인한다. (아래쪽 경계 이탈 확인)
  • this.y < 0: 입자의 Y 좌표가 0 미만인지 확인한다. (위쪽 경계 이탈 확인)
  • this.directionY = -this.directionY: 입자의 세로 이동 방향에 마이너스를 곱하여 방향을 반전시킨다.

현재 위치 업데이트 (실제 이동)

this.x += this.directionX;
this.y += this.directionY;
  • 현재 입자의 위치에 this.directionX(속도/방향) 만큼을 더하여 새로운 위치를 계산한다.

this.draw();

  • 위치 업데이트가 끝난 후, draw() 메서드를 호출하여 새로 계산된 위치에 입자를 다시 그린다.

2. ParticleCanvas

  • useRef()
    => React에게 "리렌더링이 일어나도 내용물을 잊지 않는 참조 상자를 하나 만들어줘"라고 명령하는 훅이다.

  • <HTMLCanvasElement>
    <...> (제네릭 문법): 이 참조 상자가 담을 값의 종류를 TypeScript에 알려준다.

  • HTMLCanvasElement: JavaScript/DOM에서 <canvas> 태그를 가리킬 때 사용하는 공식 타입 이름이다.
    => 이 canvasRef 상자에는 오직 <canvas> HTML 태그만 담을 수 있다고 미리 선언한 것이다.

  • (null): useRef로 만든 상자의 초기 값.


  • useEffect 사용 이유: 브라우저 전용 API를 사용하기 위해서

  • Canvas API는 오직 브라우저 환경에서만 존재한다.
  • Next.js는 컴포넌트를 서버에서 먼저 실행하려고 한다. 하지만, 서버에는 window나 canvas 같은 객체가 없다.
  • useEffect는 컴포넌트가 브라우저에 완전히 그려진 후에야 실행되는 것이 보장된다.
  • Canvas 코드를 useEffect 안에 넣으면, "캔버스야, 네가 브라우저에 존재할 때만 그림을 그리기 시작해"라고 실행 시점을 안전하게 보장할 수 있다.
    (useEffect는 태그가 DOM에 붙은 후를 보장하기 때문에, 캔버스 코드를 안전하게 실행하는 표준 방식이다.)

  • React 컴포넌트 안에서 Canvas API를 안전하게 사용하기 위한 필수적인 안전장치이다.

  • const canvas = canvasRef.current;
    => useRef 훅으로 만든 canvasRef의 .current를 열어서, 그 안에 담긴 실제 <canvas> HTML 태그를 canvas 변수에 저장한다.

  • if (!canvas) return;
    => 만약 canvas 변수가 null이라면 (비어있다면), 이 useEffect 함수의 실행을 여기서 즉시 중단하도록 한다.
    => canvas 태그가 확실히 존재할 때만 다음 단계로 넘어가도록 보장하는 가장 기본적인 안전장치이다.

  • const ctx = canvas.getContext("2d"); (그리기 도구 생성)
    => canvas 태그가 존재하는 것을 확인했으므로, 이제 그 canvas 위에 2차원(2D) 그림을 그릴 수 있는 도구를 ctx 변수에 저장한다.
    => ctx: 이 변수는 Canvas API의 모든 그리기 명령어를 담고 있는 핵심 도구이다.

  • if (!ctx) return;
    => 만약 ctx가 null이라면, 이 useEffect 함수의 실행을 여기서 즉시 중단해라.
    => ctx가 유효한 상태일 때만 다음 단계로 넘어가도록 보장하는 안전 장치이다.

=> 즉, Canvas 태그를 찾아서, ctx를 확보하고 이 둘이 확실하게 존재할 때만 다음 코드를 실행하라는 코드


  • 이 함수는 캔버스의 크기를 브라우저 창의 크기에 항상 맞추는 역할을 한다.

  • canvas.width = window.innerWidth;
    => canvas.width: <canvas> HTML 태그 자체가 가지는 가로 길이 속성이다. 이 속성에 새로운 값을 할당하면 캔버스의 실제 그리기 영역이 변경된다.
    => window.innerWidth: 사용자가 보고 있는 브라우저 창의 현재 가로 너비와 똑같이 설정한다.

  • canvas.height = window.innerHeight;
    => canvas.height: <canvas> HTML 태그 자체가 가지는 세로 길이 속성이다.
    => window.innerHeight: 사용자가 보고 있는 브라우저 창의 현재 세로 높이와 똑같이 설정한다.

  • init() 함수가 실행될 때 이 함수가 호출되어, 페이지가 로드되자마자 캔버스가 화면 전체를 덮도록 만든다.

  • window.addEventListener('resize', handleResize); 코드를 통해 사용자가 브라우저 창 크기를 바꿀 때마다 이 함수가 다시 호출되어 캔버스 크기를 실시간으로 조정하도록 한다. (반응형 동작)


  • 앞으로 입자 객체들을 담을 배열을 준비한다

let particlesArray: Particle[]: particlesArray 변수의 타입은 Particle이라는 선언이다.
=> Particle: Particle 클래스로 정의한 입자 객체 타입.
=> [] (대괄호): 이 타입은 Particle 객체가 하나가 아니라 여러 개 들어있는 배열이어야 한다는 뜻이다.


2-1 init

캔버스 크기 조정

  • setCanvasSize(): canvas.width와 canvas.height를 현재 브라우저 창의 크기와 동일하게 설정한다.

배열 초기화 및 개수 계산

  • particlesArray = []: 이전에 있던 입자들을 모두 버리고 빈 배열로 초기화한다.

  • let numberOfParticles = (canvas.height * canvas.width) / 11000;
    => 화면 면적을 11000으로 나누어, 파티클의 총 개수를 계산한다.
    => 면적을 11000이라는 상수로 나누는 것은, 화면 11000픽셀당 파티클을 1개씩 만들겠다는 의미이다.
    => 숫자 ↑ : 파티클의 총 개수 ↓


무작위 속성 생성 (루프 내부)

  • size = (Math.random() * 2) + 1을 통해 1부터 3 사이의 무작위 크기를 할당합니다.
    => Math.random(): 0 이상 1 미만의 무작위 실수를 반환한다.
    => Math.random() * 2: 무작위 숫자의 범위가 0에서 2 미만이 된다.
    => + 1: 무작위 숫자의 범위가 1 이상 3미만으로 조정된다.
    => (결과) size를 1부터 3 사이의 무작위 값으로 설정함으로써, 파티클의 반지름 크기를 최소 1px에서 최대 3px 사이로 다양하게 만들어준다.

  • x, y (Math.random() * ((window.innerWidth - size * 2) - (size * 2)) + size * 2)
    => 입자가 잘려보이는것을 방지하기 위한 코드
    => window.innerWidth: 브라우저 창의 전체 가로 너비
    => (window.innerWidth - size * 2): size * 2: 입자의 지름, innerWidth - 지름: 캔버스의 오른쪽 끝에서 지름만큼 안쪽으로 들어온 지점
    => ((window.innerWidth - size * 2) - size * 2 ): 계산한 허용 너비에서, 입자가 왼쪽 끝에서 시작할 때 필요한 size * 2 (지름)만큼의 공간을 다시 빼준다.
    => Math.random(): 0부터 1 미만의 무작위 숫자. 이 값을 계산된 이동 가능한 범위에 곱하면, 0부터 이동 가능한 범위의 최대값 사이의 무작위 숫자가 나온다.
    => + size * 2: 왼쪽 끝 경계에서 시작하지 않고, 입자가 잘리지 않는 '최소 시작 지점'을 더해 기준점을 오른쪽으로 옮긴다. (2 ~ 계산한 이동 가능한 범위+2)

  • directionX, directionY (Math.random() * 0.4 - 0.2);
    => Math.random(): 0부터 1 미만의 무작위 숫자. 여기에 0.4를 곱하면 0 부터 0.4의 범위가 된다. 거기에 -0.2를 하면 -0.2부터 +0.2 사이의 값을 할당하여 매우 느린 속도와 방향을 할당할 수 있다. (천천히 움직이게 하기 위해 작은 값으로 계산했다.)

  • 색상 : 모든 입자에 푸른 계열의 반투명(0.6) 색상을 고정적으로 할당한다.

Particle 객체 생성 및 저장

  • particlesArray.push(new Particle(ctx, canvas, x, y, directionX, directionY, size, color));
  • 위에서 생성한 모든 속성들과 함께, ctx 와 canvas를 Particle 클래스의 constructor에 전달하여 새로운 입자 객체를 만든다.

2-2 connect

  • 입자들이 서로 가까워질 때 선으로 연결되도록 하는 코드이다.
const connect = () => {
    let opacityValue = 1;
    
    // 1. 첫 번째 파티클 A를 선택 (particlesArray 배열을 순회)
    for (let a = 0; a < particlesArray.length; a++) {
        
        // 2. 두 번째 파티클 B를 선택 (A 이후의 파티클만 비교하여 중복 방지)
        for (let b = a; b < particlesArray.length; b++) {
            
            // 3. 거리 제곱값 계산 (피타고라스 정리)
            let distance =
                (particlesArray[a].x - particlesArray[b].x) * (particlesArray[a].x - particlesArray[b].x) +
                (particlesArray[a].y - particlesArray[b].y) * (particlesArray[a].y - particlesArray[b].y);
                
            // 4. 연결 조건 확인 (특정 거리 이하인지)
            if (distance < (canvas.width / 7) * (canvas.height / 7)) {
                
                // 5. 선의 투명도(Opacity) 계산 (거리가 가까울수록 선명하게)
                opacityValue = 1 - distance / 20000;
                
                // 6. 선의 스타일 설정
                ctx.strokeStyle = "rgba(199, 210, 254," + opacityValue + ")"; // 색상과 계산된 투명도 적용
                ctx.lineWidth = 1;

                // 7. 선 그리기 (Canvas API)
                ctx.beginPath(); // 새로운 경로 시작
                ctx.moveTo(particlesArray[a].x, particlesArray[a].y); // 파티클 A로 이동
                ctx.lineTo(particlesArray[b].x, particlesArray[b].y); // 파티클 B까지 선을 그림
                ctx.stroke(); // 선을 실제로 화면에 그림
            }
        }
    }
};

중첩된 for 루프

  • for (let a ...): 첫 번째 입자 A를 선택한다.
  • for (let b = a; ...): 두 번째 입자 B를 선택합니다.
    => 여기서 b = a로 시작하는 이유는, '파티클 A와 B를 비교'하는 것과 'B와 A를 비교'하는 것은 중복이므로, 이미 비교한 파티클을 건너뛰어 계산량을 절반으로 줄이기 위해서이다.

거리 계산

  • let distance = (dx dx) + (dy dy);
    => (파티클 A와 B의 X좌표 차이 제곱 + Y좌표 차이 제곱) - 피타고라스 정리 사용 (성능 위해 제곱근 생략)

연결 조건

  • if (distance < (canvas.width / 7) * (canvas.height / 7))
    => (canvas.width / 7) * (canvas.height / 7): canvas.width를 7로 나누고, canvas.height를 7로 나눈 값들의 곱이다.
    => 화면이 크면 허용 거리도 길어지고, 화면이 작으면 허용 거리도 짧아진다.
    => 화면이 커졌는데 선이 짧으면 듬성듬성해 보일 수 있으니 연결 밀도가 비슷하게 보이도록 한다.
    => 만일 7이 아닌 더 작은 수로 나눴다! -> 더 멀리서도 연결된다.
    => 만일 7이 아닌 더 큰 수로 나눴다! -> 더 가까워야 연결된다.

투명도 계산

  • opacityValue = 1 - distance / 20000;
  • 선의 투명도는 0 (완전 투명)과 1 (완전 불투명) 사이로 설정해야 한다.
    거리가 가까울수록 선이 선명해지도록 설정합니다.
  • 20000보다 값이 작으면 선이 가까이서부터 빨리 사라지고, 이 값이 크면 멀리까지 선명하게 유지되어 복잡해보였다. 이 값이 가장 자연스럽게 보이는 값이라고 생각해 설정했다.

  • 입자가 매우 가까울 때 (붙어있을 때)

    • distance ≈ 0
    • 1 - 0 / 20000 = 1 - 0 = 1
    • 선이 1 (100% 불투명)이 되어 매우 선명하게 보인다.
  • 입자가 멀리 있을 때

    • distance ≈ 10000 (중간 정도)
    • 1 - 10000 / 20000 = 1 - 0.5 = 0.5
    • 선이 0.5 (50% 반투명)이 되어 희미하게 보입니다.
  • 입자가 매우 멀리 있을 때

    • distance ≈ 20000
    • 1 - 20000 / 20000 = 1 - 1 = 0
    • 선이 0 (완전 투명)이 되어 보이지 않습니다.

선 그리기

  • ctx.strokeStyle = ...: 선의 색상과 계산된 opacityValue를 설정한다.
  • ctx.beginPath(): 새로운 그림을 그릴 준비를 한다.
  • ctx.moveTo(...): 선을 그릴 시작점을 설정한다.
  • ctx.lineTo(...): 선을 그릴 종점을 설정한다.
  • ctx.stroke(...): 실제로 화면에 그린다.

2-3 animate

animationFrameId = requestAnimationFrame(animate);

  • requestAnimationFrame: 지금 animate 함수가 끝나기 전에, 다음 프레임이 그려질 시점에 animate 함수를 다시 실행해달라고 브라우저에게 예약한다.
  • animate 함수가 자기 자신을 계속해서 예약하기 때문에 무한 루프가 만들어진다. 이 루프가 초당 약 60회(60 FPS) 반복되어 파티클의 움직임이 눈에 보이게 된다.
  • requestAnimationFrame은 예약에 성공하면 요청 ID(숫자)를 반환한다. 이 ID를 animationFrameId 변수에 저장한다.
  • 이 ID는 나중에 return 문 안의 클린업 함수에서 cancelAnimationFrame(animationFrameId)를 호출하여 애니메이션을 멈출 때 사용된다.

ctx.clearRect(0, 0, canvas.width, canvas.height);

  • clearRect: 캔버스에 이미 그려진 모든 그림을 지운다.
  • 0, 0: 지우기를 시작할 X, Y 좌표이다.
  • canvas.width, canvas.height: 캔버스의 너비와 높이만큼 지운다.

particlesArray.forEach((particle) => particle.update());

  • particlesArray: init 함수에서 만든, 모든 파티클 객체들이 담겨있는 배열
  • .forEach(...): 배열의 모든 particle 객체를 순회한다.
  • particle.update(): 각 파티클 객체가 가진 update() 메서드를 호출합니다. 이 메서드가 실행되면 입자의 위치가 속도만큼 변경되고, 새 위치에 다시 그려진다.

connect();

  • 파티클의 위치 업데이트가 모두 끝난 후, connect 함수를 호출한다.

2-4 resize

const handleResize = () => { init(); };

  • handleResize라는 이름의 새로운 함수를 정의한다.
  • 호출될 때마다 init() 함수를 실행한다.

window.addEventListener("resize", handleResize);

  • window.addEventListener(...): 웹페이지의 전역 객체(window)에 특정 이벤트가 발생하는지 감시하는 감시자를 등록한다.
  • "resize": 감시할 이벤트의 종류. 사용자가 브라우저 창의 크기를 변경할 때 발생하는 이벤트이다.
  • handleResize: 이벤트가 발생했을 때 실행할 함수이다.
    => 브라우저 창 크기가 변경되는 순간, handleResize 함수(즉, init())를 실행한다.

2-5 return

return () => { window.removeEventListener("resize", handleResize); };

  • window.removeEventListener("resize", handleResize): addEventListener로 등록했던 감시자를 제거한다.
  • cancelAnimationFrame(animationFrameId): animate() 루프를 시작할 때 requestAnimationFrame이 반환했던 예약 번호로 다음 실행을 예약했던 animate 함수를 취소하고 멈추도록 한다.

최종 코드 및 작동 흐름

최종 작동 흐름 (정리)

  • init() 실행
    => ex) x=100, directionX=0.1로 파티클을 만든다.

  • animate() 루프 1회차
    => 캔버스를 지운다.
    => particle.update() 호출. -> this.x가 100에서 100.1로 변경
    => this.draw() 호출. -> 위치 (100.1, y)에 그림을 그린다.

  • animate() 루프 2회차:
    => 캔버스를 지웁니다.
    => particle.update() 호출. -> this.x가 100.1에서 100.2로 변경
    => this.draw() 호출. -> 위치 (100.2, y)에 그림을 그린다.


🩷 결과

  • 화면

0개의 댓글