📍 React + Canvas 사용기 (Three.js
사용 ❌)
📍 Canvas 활용해서 파동 만들기
📍 유저의 입력 값(state)에 따라서 Canvas 애니메이션 다시 그리게 하기
기존에 올렸던 장면 말고 조금 더 메인에 가까운 장면을 현재 제작중이다.
해당 장면에는 firebase DB
, Arduino
, Canvas
등 조금 더 다양한 요소들이 들어가서 공부를 열심히 하면서 하나하나 뽀개는중이다...
그 중에서도 오늘은 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()
함수와 관련된 자세한 내용은 뒤에 해보겠다.
일단 이의 출처를 먼저 밝히자면 해당 유투브를 참고해서 코드를 작성했다!
영상을 보면 정~말 자세하게 잘 설명해주시니까 내가 수정한 부분들만 말해보자면..
영상을 보면 같은 곳에서 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
위치 값을 넣을 때 i
를 stageHeight
에 곱하게 하였다.
위의 기획을 보면 알다싶이 사람들이 직관적으로 파동의 균형을 맞춰야한다.
그러기 위해서는 높이 값 또는 속도를 조정해야했는데, 이는 아두이노와 함께 작업하며 차츰 더 수정해나가야할 것이라고 생각한다.
속도를 수정하는 방법은 간단하기 때문에 (Point.js
의 speed
속성을 수정해주면 된다)
일단, 최대 높이를 조정하는 방법을 고안해보았다.
사실 최대 높이를 조정하는 것 자체는 그렇게 복잡하지 않다. 이도 속성의 특정 값만 수정해주면 되기 때문이다.
<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 값을 반영하게 하는 것이었다..!
useEffect
내에서 draw 함수를 정의하고 실행하니까 문제가 되나 싶어서 state 값
을 update
했을 때, draw()
만 실행시키도록 하였더니 앞서 언급했던 context가 만들어지지 않는 등의 다양한 문제들이 발생했다. (context가 정의되지 않아서 clearRect 메소드가 실행되지 않는 등)
그래서 draw 함수의 인자로 context 등 필요한 값들을 전달해봤지만.. 이도 안됐다..
-> ❌ 탈락..
원래는 Scene 내부에서 state
선언 / 변경, draw
, canvas
모두 했는데 useEffect
로 업데이트가 안되니까 canvas
와 draw
를 하위 컴포넌트에 두고 상태 값을 상위 컴포넌트에서 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);
};
}, []);
-> ❌ 탈락..
그렇다면..? props 값이 업데이트될 때마다 실행되게 해보았다.
useEffect의 의존성 배열 값을 [] -> [props]로 변경해보았다.
정상적으로 변경이 되지 않고 그리는 속도만 느려지길래 draw 함수 내에서 props 값을 콘솔에 찍어보았더니 상태 변화 이전, 이후의 값이 번갈아가면서 찍혔다..
-> ❌ 탈락..
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는 졸업전시 준비하면서 본격적으로 시작했다.
바쁜거 좀 지나면 더 찐~하게 공부해주마...