[졸전로그: 4/15] React + Canvas / 반응형 애니메이션 / Canvas로 wave (파동) 만들기

intersoom·2023년 4월 16일
0

졸업전시

목록 보기
4/4
post-thumbnail

✏️ 오늘의 기록

📍 React + Canvas 사용기 (Three.js 사용 ❌)
📍 Canvas 활용해서 파동 만들기
📍 유저의 입력 값(state)에 따라서 Canvas 애니메이션 다시 그리게 하기

기존에 올렸던 장면 말고 조금 더 메인에 가까운 장면을 현재 제작중이다.
해당 장면에는 firebase DB, Arduino, Canvas 등 조금 더 다양한 요소들이 들어가서 공부를 열심히 하면서 하나하나 뽀개는중이다...

그 중에서도 오늘은 Canvas를 뿌신 것에 관한 이야기이다!

📍 React + Canvas 사용기

물론 Three.js 활용하면서 React + Canvas 사용법에 대해서는 익혔다.
그래서 이 파트는 간단히만 이야기하고 넘어가겠다.

먼저 코드를 봐보자 !

const containerRef = useRef(null);
.
.
.
useEffect(() => {
    const canvas = document.createElement("canvas");
    canvas.style.width = "100%";
    canvas.style.height = "100%";
    const context = canvas.getContext("2d");
    containerRef.current.appendChild(canvas);
.
.
.
return (
    <div
      style={{ width: "100vw", height: "100vh", overflow: "hidden" }}
      ref={containerRef}
    />
  );

Canvas를 사용할 때 중요한 코드들만 간단히 가져와봤다.

먼저, div 태그를 불러오고 이를 useRef를 통해서 접근하여 이의 child로 canvas Element를 만들어서 삽입해준다.

이 때, canvas를 미리 만들어두고 해당 요소에 useRef로 접근을 하면 계속해서 context가 null 값이 나왔다. 이는 캔버스 요소가 생기기 전에 useEffect가 실행되면서 발생하는 문제라고 생각했다.
그래서 이를 해결하기 위해서 div 태그 내에 canvas를 만들고 삽입해주는 방식으로 요소가 생기고 접근하는 것을 보장하는 방식을 선택했다.

그리고 뒤에 레티나 디스플레이와 같이 픽셀이 2:1로 대응되는 경우를 고려하여서 canvas의 크기를 2배로 만들 예정이기 때문에 반드시 style(width: 100%, height: 100%)을 작성해줘야한다

canvas에 그림을 그리기 위해서는 이의 drawing context에 접근을 해야하는데, 이를 가능하게 해주는 것이 cavas.getContext('2d')이다.

getContext 관련 공식 문서: Mdn Web Docs

우리는 2d 파동을 만들 것이기 때문에 contextType'2d'를 넣어주면 된다. 문서를 보면 webgl 등의 contextType을 넣어서 3d context도 형성할 수 있는 것처럼 보인다.

이런 식으로 기본적인 세팅은 마무리하고 추가적으로 작성해줘야할 것은 resize() 함수와 draw() 함수이다. 이름은 변경되어도 무방하다.

  • resize():
	function resize() {
      canvas.width = stageWidth * 2;
      canvas.height = stageHeight * 2;
      context.scale(2, 2);
      waveGroup.resize(stageWidth, stageHeight);
    }

위와 같이 화면이 조정됨에 따라서 바뀌는 화면 크기에 대응할 수 있게 해주는 함수이다.

따라서 다음과 같이 eventListner도 함께 작성해줘야한다!

 window.addEventListener("resize", resize, false);
  • draw():
const draw = () => {
      if (context) {
        context.clearRect(0, 0, stageWidth, stageHeight);
      }
      console.log(propsRef.current.skyUpMax);
      waveGroup.draw(
        context,
        propsRef.current.skyUpMax,
        propsRef.current.terraUpMax
      );
      setRequestId(requestAnimationFrame(draw));
    };

    requestAnimationFrame(draw);

위와 같이 화면에 계속해서 그림을 그려주는 함수이다.
끊기지 않는 그림을 계속해서 그려주고 싶다면 clearRect()requestAnimationFrame()이 있어야 한다!

draw() 함수와 관련된 자세한 내용은 뒤에 해보겠다.

📍 Canvas 활용해서 파동 만들기

일단 이의 출처를 먼저 밝히자면 해당 유투브를 참고해서 코드를 작성했다!

영상을 보면 정~말 자세하게 잘 설명해주시니까 내가 수정한 부분들만 말해보자면..

1. wave 상하반전

영상을 보면 같은 곳에서 wave를 그리시는데 우리 기획은

이런 식으로 세계와 대지에 대한 표현이기 때문에 위/아래로 파동이 존재해야했다.

그래서 나는 코드를 다음과 같이 수정했다👇🏻
<Wave.js>

draw(ctx, i, upMax) {
    ctx.beginPath();
    ctx.fillStyle = this.color;

    let prevX = this.points[0].x;
    let prevY = this.points[0].y;

    ctx.moveTo(prevX, prevY);

    for (let i = 1; i < this.totalPoints; i++) {
      if (i < this.totalPoints - 1) {
        this.points[i].update(upMax);
      }

      const cx = (prevX + this.points[i].x) / 2;
      const cy = (prevY + this.points[i].y) / 2;

      ctx.quadraticCurveTo(prevX, prevY, cx, cy);

      prevX = this.points[i].x;
      prevY = this.points[i].y;
    }

    ctx.lineTo(prevX, prevY);
    ctx.lineTo(this.stageWidth, i * this.stageHeight);
    ctx.lineTo(this.points[0].x, i * this.stageHeight);
    ctx.fill();
    ctx.closePath();
}

<WaveGrup.js>

draw(ctx, skyUpMax, terraUpMax) {
    for (let i = 0; i < this.totalWaves; i++) {
      const wave = this.waves[i];
      if (i % 2 === 0) {
      	// 짝수번째 -> 1
        wave.draw(ctx, 1, skyUpMax);
      } else {
      	// 홀수번째 -> -1
        wave.draw(ctx, -1, terraUpMax);
      }
    }
  }

위와 같이 draw 함수의 매개변수에 i라는 변수를 추가하여서 인자를 -1 또는 1을 받아서 lineTo 위치 값을 넣을 때 istageHeight에 곱하게 하였다.

2. 최대 높이

위의 기획을 보면 알다싶이 사람들이 직관적으로 파동의 균형을 맞춰야한다.
그러기 위해서는 높이 값 또는 속도를 조정해야했는데, 이는 아두이노와 함께 작업하며 차츰 더 수정해나가야할 것이라고 생각한다.

속도를 수정하는 방법은 간단하기 때문에 (Point.jsspeed 속성을 수정해주면 된다)

일단, 최대 높이를 조정하는 방법을 고안해보았다.
사실 최대 높이를 조정하는 것 자체는 그렇게 복잡하지 않다. 이도 속성의 특정 값만 수정해주면 되기 때문이다.

<Point.js>

export class Point {
  constructor(index, x, y, max) {
    this.x = x;
    this.y = y;
    this.fixedY = y;
    this.speed = 0.06;
    this.cur = index;
    this.max = Math.random() * 100 + max;
  }

  update(max_) {
    this.cur += this.speed;
    this.y = this.fixedY + Math.sin(this.cur) * max_;
  }
}

<Wave.js>

init() {
    this.points = [];

    for (let i = 0; i < this.totalPoints; i++) {
      const point = new Point(
        this.index + i,
        this.pointGap * i,
        this.centerY,
        this.max
      );
      this.points[i] = point;
    }
  }

  draw(ctx, i, upMax) {
    ctx.beginPath();
    ctx.fillStyle = this.color;

    let prevX = this.points[0].x;
    let prevY = this.points[0].y;

    ctx.moveTo(prevX, prevY);

    for (let i = 1; i < this.totalPoints; i++) {
      if (i < this.totalPoints - 1) {
        this.points[i].update(upMax);
      }

      const cx = (prevX + this.points[i].x) / 2;
      const cy = (prevY + this.points[i].y) / 2;

      ctx.quadraticCurveTo(prevX, prevY, cx, cy);

      prevX = this.points[i].x;
      prevY = this.points[i].y;
    }

    ctx.lineTo(prevX, prevY);
    ctx.lineTo(this.stageWidth, i * this.stageHeight);
    ctx.lineTo(this.points[0].x, i * this.stageHeight);
    ctx.fill();
    ctx.closePath();
  }

<WaveGroup.js>

draw(ctx, skyUpMax, terraUpMax) {
    for (let i = 0; i < this.totalWaves; i++) {
      const wave = this.waves[i];
      if (i % 2 === 0) {
        wave.draw(ctx, 1, skyUpMax);
      } else {
        wave.draw(ctx, -1, terraUpMax);
      }
    }
  }

WaveGroup에서 각각 세계대지max 값을 인자로 받아오고 이를 Wave에 넘겨주고 최종적으로 Point에서 max 값을 수정하여서 파동의 크기를 조절할 수 있다.

📍 유저의 입력 값(state)에 따라서 Canvas 애니메이션 다시 그리게 하기

앞의 과정까지는 그래도 순조롭게 진행되었다..
그런데 문제는 이 애니메이션에 state 값을 반영하게 하는 것이었다..!

🤯 시행착오 #1

useEffect 내에서 draw 함수를 정의하고 실행하니까 문제가 되나 싶어서 state 값update 했을 때, draw()만 실행시키도록 하였더니 앞서 언급했던 context가 만들어지지 않는 등의 다양한 문제들이 발생했다. (context가 정의되지 않아서 clearRect 메소드가 실행되지 않는 등)

그래서 draw 함수의 인자로 context 등 필요한 값들을 전달해봤지만.. 이도 안됐다..

-> ❌ 탈락..

🤯 시행착오 #2

원래는 Scene 내부에서 state 선언 / 변경, draw, canvas 모두 했는데 useEffect로 업데이트가 안되니까 canvasdraw를 하위 컴포넌트에 두고 상태 값을 상위 컴포넌트에서 props로 전달 받아서 상태가 바뀌어서 리렌더링 되게끔 구성해보았다.

또한, 컴포넌트가 mount되고 animationFrame을 만들고 이가 unmount될 때 삭제 되게 해보았다. 당연히 한 번 그려지고 state 변경되면 멈췄다..

<GPT가 준 예시 코드>

function App() {
  const canvasRef = useRef(null);
  const [xPos, setXPos] = useState(0);

  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    let requestId;

    function draw(timestamp) {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.fillStyle = 'red';
      ctx.fillRect(xPos, 50, 100, 100);

      setXPos(prevXPos => prevXPos + 1);

      requestId = requestAnimationFrame(draw);
    }

    requestId = requestAnimationFrame(draw);

    return () => {
      cancelAnimationFrame(requestId);
    };
  }, []);

-> ❌ 탈락..

🤯 시행착오 #3

그렇다면..? props 값이 업데이트될 때마다 실행되게 해보았다.
useEffect의 의존성 배열 값을 [] -> [props]로 변경해보았다.

정상적으로 변경이 되지 않고 그리는 속도만 느려지길래 draw 함수 내에서 props 값을 콘솔에 찍어보았더니 상태 변화 이전, 이후의 값이 번갈아가면서 찍혔다..

-> ❌ 탈락..

❤️‍🩹 결론: useRef 활용하기 !

useRef는 컴포넌트의 생명주기 동안에 지속되는 변수를 만들어 줍니다. 이 변수는 useState 훅으로 관리되는 상태와는 별개로 업데이트 되며, current 속성을 통해 최신 값을 참조할 수 있습니다.
출처: Chat GPT.....

이를 활용한 코드는 다음과 같다 👇🏻

useEffect(() => {
    propsRef.current = props;
  }, [props]);
.
.
.
const draw = () => {
      if (context) {
        context.clearRect(0, 0, stageWidth, stageHeight);
      }
      console.log(propsRef.current.skyUpMax);
      waveGroup.draw(
        context,
        propsRef.current.skyUpMax,
        propsRef.current.terraUpMax
      );
      setRequestId(requestAnimationFrame(draw));
    };

    requestAnimationFrame(draw);

propsRef 변수를 만들어서 props의 값이 업데이트될 때마다 useEffect를 활용해서 propsRef가 props의 값을 참조하게 하였다.

그리고 draw 함수 내에서는 propsRef.current.skyUpMax와 propsRef.current.terraUpMax를 불러와서 화면을 그려주니까 내가 원하던 대로 잘~~~ 작동했다!

😇 느낀점

난 여전히 React에 대해서 깊이 이해하고 있지 않구나.. 싶었다...ㅠ
사실 Hook 관련 지식들이 예전에 비해서는 많아졌다고 한들,, 아직 얕다...
심지어 useRef는 졸업전시 준비하면서 본격적으로 시작했다.

바쁜거 좀 지나면 더 찐~하게 공부해주마...

0개의 댓글