브라우저에는 Scalable Vector Graphics(SVG) 언어부터 <canvas>
엘리먼트에 그리기 위한 API까지 매우 강력한 그래픽 프로그래밍 도구를 포함하고 있다.
웹은 원래 텍스트에 불과했는데 이는 매우 지루했기 때문에 처음엔 <img>
엘리먼트가, 나중엔 배경 이미지 및 SVG와 같은 CSS 프로퍼티가 도입되었다.
그러나 이것들로는 여전히 충분하기 않았다.
CSS와 자바스크립트를 사용하여 마크업 언어인 SVG 벡터 이미지를 애니메이션화할 수 있지만, 여전히 비트맵 이미지에 대해서는 동일한 방법을 적용할 수 없었고, 사용할 수 있는 도구는 다소 제한적이었다.
웹은 여전히 애니메이션, 게임, 3D 장면, 또는 C++나 자바와 같은 low level 언어들에 의해 처리되는 여타 작업들을 다룰 수 없었다.
2004년 브라우저가 <canvas>
엘리먼트와 관련된 Canvas API를 지원하기 시작하면서 상황이 개선되기 시작했다.
Canvas는 2D 애니메이션, 게임, 데이터 시각화 및 기타 유형의 응용 프로그램을 만드는 데 유용한 도구를 제공한다.
하지만, 웹 플렛폼이 제공하는 다른 API와 결합할 경우 액세스가 어렵거나 불가능 할 수 있다.
2006년부터 2007년까지 Mozilla는 실험적인 3D Canvas 구현 작업을 시행했다.
이것은 브라우저 공급업체들 사이에서 주목을 받은 WebGL이 되었고 2009-2010년경에 표준화되었다.
WebGL을 통해 웹 브라우저 내에서 3D 그래픽을 생성할 수 있다.
여기서는 2D 캔버스에 초점을 맞출 것이다.
WebGL을 배우고자 한다면 WebGL을 다루는 튜토리얼➡️에서 공부하자.
<canvas>
"웹 페이지에서 2D 또는 3D 장면을 만들려면 HTML <canvas>
엘리먼트로 시작해야 한다.
이 엘리먼트는 이미지를 그릴 페이지의 영역을 정의하는 데 사용한다.
<canvas width="320" height="240"></canvas>
<canvas>
엘리먼트 내부에 fallback content를 넣어야 한다.
이는 canvas를 지원하지 않는 브라우저 사용자 또는 screen readers에게 canvas 내용을 설명하기 위함이다.
<canvas width="320" height="240">
<p>Description of the canvas for those unable to view it.</p>
</canvas>
fallback은 canvas contents에 대한 유용한 대체 contents를 제공해야 한다.
예를 들어, 지속적으로 업데이트 되는 주가 그래프를 렌더링하는 경우 fallback contents는 최신 주가 그래프의 정적 이미지가 될 수 있으며, alt
텍스트는 가격이 텍스트로 표시되거나 개별 주가 페이지에 대한 링크 목록이 될 수 있다.
screen readers에서 캔버스 내용에 액세스할 수 없다.
<canvas>
엘리먼트의aria-label
어트리뷰트의 값으로 설명 텍스트를 직접 설정하거나 위와 같이 fallback contents를 nested elements로 포함해야 한다.
Canvas content는 DOM의 일부가 아니지만, fallback content는 DOM의 일부이다.
<canvas class="myCanvas">
<p>Add suitable fallback here.</p>
</canvas>
width
, height
어트리뷰트를 제거했다.
자바스크립트에서 설정할 것이기 때문이다.
명시적인 너비와 높이가 없다면 canvas는 기본적으로 너비 300px, 높이 150px을 가진다.
const canvas = document.querySelector(".myCanvas");
const width = canvas.width = window.innerWidth;
const height = canvas.height = window.innerHeight;
canvas의 너비와 높이를 뷰포트의 너비와 높이로 설정한다.
뿐만아니라 너비와 높이는 자주쓰이는 값이기 때문에 쉽게 액세스하기 위해 width
, height
변수로 설정했다.
위의 예제 처럼 Canvas의 크기는 HTML 어트리뷰트 또는 DOM 프로퍼티를 사용하여 설정해야 한다.
CSS를 사용할 수도 있지만, 문제는 크기 조정이 캔버스가 렌더링된 후에 수행된다는 것이며, 다른 이미지(렌더링된 Canvas는 이미지일 뿐)와 마찬가지로 이미지가 픽셀화되거나 왜곡될 수 있다.
Canvas에 그림을 그리려면 context라고 불리는 그리기 영역에 대한 특별한 참조가 필요하다.
이것은 HTMLCanvasElement.getContext()
메서드를 사용하여 수행된다.
이 메서드는 context 유형을 뜻하는 하나의 문자열을 매개변수로 받는다.
우리는 2D 캔버스를 볼 것이기 때문에 다음과 같이 작성한다.
const ctx = canvas.getContext("2d");
사용가능한 매개변수 값으로는
webgl
,webgl2
등이 있다.
이제 ctx
변수에는 CanvasRenderingContext2D
객체가 포함되어 있으며 canvas의 모든 그리기 작업에는 이 객체를 조작하는 작업이 포함된다.
다음은 canvas 배경을 검은색으로 색칠하는 canvas API의 사용법이다.
ctx.fillStyle = "rgb(0, 0, 0)";
ctx.fillRect(0, 0, width, height);
캔버스의 fillStyle
프로퍼티를 사용하여 채우기 색을 설정한다.
이때 색상 값은 CSS의 프로퍼티의 값과 동일하다.
그런 다음 fillRect()
메서드를 사용하여 캔버스의 전체 영역을 덮는 직사각형을 그린다.
처음 두 매개변수는 직사각형의 왼쪽 상단 모서리의 좌표이다.
마지막 두 개는 직사각형을 그릴 너비와 높이이다.
위에서 말한 것처럼 모든 그리기 작업은 CanvasRenderingContext2D 객체를 조작하여 수행된다.
캔버스의 좌표계는 왼쪽 상단이 점(0,0)이고, 가로축은 왼쪽에서 오른쪽으로, 세로축은 위에서 아래로 이동한다.
도형 그리기는 직사각형 모양 기본값을 사용하거나 특정 경로를 따라 선을 추적한 다음 도형을 채우는 방식으로 수행되는 경향이 있다.
아래에서 두 가지 모두를 수행하는 방법을 살펴보자.
ctx.fillStyle = "rgb(255, 0, 0)";
ctx.fillRect(50, 50, 100, 150);
빨간색 사각형이 표시된다.
빨간색 사각형의 왼쪽 상단 모서리의 좌표는 (50,50)이고 너비 100px, 높이 150px이다.
직사각형을 하나 더 추가해보자.
ctx.fillStyle = "rgb(0, 255, 0)";
ctx.fillRect(75, 75, 100, 100);
직사각형, 선 등을 그리는 그래픽 작업은 발생하는 순서대로 수행된다.
벽에 페인트칠을 하는 것과 같다.
페이트가 겹치면 밑에 부분이 안보인다.
CSS였다면 z-index
프로퍼티를 사용해 우선순위를 설정할 수 있겠지만, 캔버스에서는 할 수 있는 방법이 없다.
때문에 그래픽을 그리는 순서에 대해 신중하게 생각해야 한다.
ctx.fillStyle = "rgba(255, 0, 255, 0.75)";
ctx.fillRect(25, 100, 175, 50);
투명도가 지정된 rgab()
값을 설정할 수도 있다.
위에서는 색칠된 직사각형만을 살펴보았다.
윤곽만 있는 직사각형(즉, 속이 비어있는)도 그릴 수 있다.
그래픽 디자인에서는 이러한 사각형을 "Strokes"라고 부른다.
Stroke에 색상을 설정하기 위해서는 fillStyle
이 아닌, strokeStyle
프로퍼티를 사용한다.
눈치챘겠지만, stroke를 그릴때 역시 fillRect()
이 아닌, strokeRect()
메서드를 사용한다.
ctx.strokeStyle = "rgb(255, 255, 255)";
ctx.strokeRect(25, 25, 175, 200);
Stroke의 윤곽 두께는 1px이다.
lineWidth
프로퍼티 값을 조정하여 이 값을 변경할 수 있다.
값은 픽셀 너비를 나타내는 숫자이다.
ctx.lineWidth = 5;
직사각형보다 더 복잡한 것을 그리려면 경로를 그려야 한다.
기본적으로 그리고자 하는 모양을 추적하기 위해 캔버스에서 펜이 이동해야 하는 경로를 정확히 지정하는 코드가 포함되어야 한다.
beginPath()
: 현제 펜이 있는 지점에서 경로를 그리기 시작한다.
펜의 위치를 따로 지정하지 않았다면 (0,0)이다.
moveTo()
: 선을 기록하거나 추적하지 않고 캔버스의 다른 지점으로 펜을 이동한다.
fill()
: 지금까지 추적한 경로에 색을 채워서 도형을 그린다.
stroke()
: 지금까지 추적한 경로에 선을 그려서 윤곽선 도형을 그린다.
lineWidth
및 fillStyle
/ strokeStyle
과 같은 기능을 Drawing paths에서도 사용할 수 있다.
ctx.fillStyle = "rgb(255, 0, 0)";
ctx.beginPath();
ctx.moveTo(50, 50);
// draw your path
ctx.fill();
캔버스에 정삼각형을 그려보자.
function degToRad(degrees) {
return degrees * Math.PI / 180;
}
ctx.fillStyle = "rgb(255, 0, 0)";
ctx.beginPath();
ctx.moveTo(50, 50);
ctx.lineTo(150, 50);
const triHeight = 50 * Math.tan(degToRad(60));
ctx.lineTo(100, 50 + triHeight);
ctx.lineTo(50, 50);
ctx.fill();
캔버스에 원을 그려보자.
arc()
메소드를 이용해서 그린다.
ctx.fillStyle = "rgb(0, 0, 255)";
ctx.beginPath();
ctx.arc(150, 106, 50, degToRad(0), degToRad(360), false);
ctx.fill();
arc()
메서드의 처음 두 개의 매개변수는 원의 중심 좌표를 지정한다.
세 번째 매개변수는 원의 반지름이고, 네 번째와 다섯 번째는 원을 그리는 시작 각도와 끝 각도이다.
0 degrees는 오른쪽을 가리킨다.
여섯 번째 매개변수는 시계방향(false) , 반시계방향(true)을 나타낸다.
ctx.fillStyle = "yellow";
ctx.beginPath();
ctx.arc(200, 106, 50, degToRad(-45), degToRad(45), true);
ctx.lineTo(200, 106);
ctx.fill();
위의 코드에서는 시작 각도가 -45도이고 끝 각도가 45도, 반시계방향으로 설정했음에도 불구하고 이 부분의 내부가 아닌 270도 주위에 호를 그린다.
여기서 주의할 점은 -45도는 ↗️방향이고 45도는 ↘️방향이라는 점이다.
y축 좌표가 위에서 아래로 흐르고 있기 때문이다.
또한 시계 방향 및 반시계 방향은 양의 x축 기준으로 출발하여 startAngle을 만나면 거기서부터 도형이 그려지기 시작해서 endAngle까지 그린다.
따라서 위의 코드는 양의 x축 기준으로 반시계 방향으로 돌다가 -45도인 ↗️방향에서 도형을 그리기 시작해서 270도를 지나고 45도인 ↘️방향에서 멈추는 도형이 그려진다.
또한, 위의 코드에서 fill()
을 호출하기 전에 lineTo()
를 호출했는데, 이는 원의 중심좌표이다.
만약 이 코드를 주석처리후 실행하면 원호의 사작점과 끝점을 잇는 원의 가장자리만 잘린 도형이 만들어진다.
불완전한 경로(즉, 닫히지 않는 경로)를 채우려고 하면 브라우저가 시작 지점과 끝점 사이의 직선을 이은 다음 색을 채운다.
Bezier and quadratic curves를 다루기 위한 canvas는 canvas 튜토리얼에서 확인하자.
캔버스에는 텍스트를 그리는 기능도 있다.
텍스트는 다음 두 메서드를 이용해 그린다.
fillText()
strokeText()
위의 두 메소드 모두 기본적으로 세 개의 매개변수를 전달 받는다.
첫 번째 매개변수는 텍스트 문자열이고 두 번째 세 번째 매개변수는 좌표이다.
텍스트 좌표는 텍스트 상자의 왼쪽 아래 모서리에서 작동하며 왼쪽 위 모서리와 헷갈릴 수 있으니 주의한다.
글꼴 및 크기 등을 설정할 수 있는 font
와 같이 텍스트 렌더링을 제어하는 데 도움이 되는 여러 프로퍼티도 있다.
CSS의 font 프로퍼티와 동일한 구문을 값으로 사용한다.
ctx.strokeStyle = "white";
ctx.lineWidth = 1;
ctx.font = "36px arial";
ctx.strokeText("Canvas text", 50, 50);
ctx.fillStyle = "red";
ctx.font = "48px georgia";
ctx.fillText("Canvas text", 50, 150);
canvas.setAttribute("aria-label", "Canvas text");
screen readers에서 canvas content에 액세스할 수 없으므로 텍스트 content를 aria-label
값으로 표시했다.
외부 이미지를 캔버스에 렌더링할 수 있다.
이것들은 단순한 이미지, 비디오 프레임 또는 다른 캔버스의 contents(이것도 이미지로 취급한다)일 수 있다.
지금은 캔버스에 간단한 이미지를 사용하는 경우를 살펴보자.
이미지는 drawImage()
메서드를 사용하여 캔버스에 그려진다.
가장 간단한 버전은 렌더링할 이미지에 대한 참조와 이미지의 왼쪽 상단 모서리의 X 및 Y 좌표의 세 가지 매개 변수를 사용한다.
먼저 캔버스에 이미지 소스를 삽입하는 것부터 시작한다.
const image = new Image();
image.src = "firefox.png";
여기서는 Image()
constructor를 사용하여 새 HTMLImageElement
객체를 생성한다.
그런 다음 src
프로퍼티를 파이어폭스 로고이미지와 동일하게 설정한다.
이때 브라우저가 이미지 로드를 시작한다.
drawImage()
를 사용하여 이미지를 넣으면 되겠구나 라고 생각할 수 있지만 이미지 파일이 먼저 로드되어있는지 확인해야 한다.
그렇지 않으면 코드가 실패한다.
이미지 로드가 완료된 경우에만 실행되는 load
이벤트를 사용하여 이 작업을 수행할 수 있다.
image.addEventListener("load", () => ctx.drawImage(image, 20, 20));
load
이벤트는 요소와 그 하위 요소가 모두 완전히 로드되었을 때 발생하는 이벤트이다.
이미지, 스크립트, 프레임, iframe 등 URL과 연결된 모든 요소와 window 객체에 대해 발생할 수 있다.
loaded
이벤트와 혼동할 수 있는데loaded
이벤트는 자바스크립트에서 표준적인 이벤트가 아니다.
하지만 일부 라이브러리나 프레임워크에서 사용되는 경우가 있다.
예를 들어 jQuery에서는 loaded 메서드를 사용하여 이미지나 비디오 등의 리소스가 로드되었을 때 콜백 함수를 실행할 수 있다.
간단한 버전이 아닌 복잡한 버전에서는 무엇을 할 수 있을까?
이미지의 일부만의 표시하거나 크기를 조정할 수 있다.
ctx.drawImage(image, 20, 20, 185, 175, 50, 50, 185, 175);
두 세 번째 매개변수는 이미지 원본의 왼쪽 상단 모서리(0,0)를 기준으로한 잘라낼 영역의 왼쪽 상단 좌표이다.
네 다섯 번째 매개변수는 잘라낼 영역의 너비와 높이이다.
여섯 일곱 번째 매개변수는 잘라낸 이미지를 붙일 캔버스의 좌표이다. 기준은 이미지의 왼쪽 상단이다.
여덟 아홉 번째 매개변수는 잘라낸 이미지를 그리기 위한 캔버스의 너비와 높이이다. 즉, 이미지를 늘이거나 줄일 수 있도록 한다.
이미지가 의미 있게 업데이트되면 액세스 가능한 설명도 업데이트 해야 한다.
canvas.setAttribute("aria-label", "Firefox Logo");
어떤 방식으로든 캔버스를 업데이트하거나 애니메이션화하지 않을 것이라면 캔버스가 아닌 정적인 이미지를 사용하는 것이 더 나을 것이다.
캔버스는 동적인 이미지 또는 애니메이션이 필요할 때 사용한다.
for
안에서 캔버스 명령을 실행해서 동적으로 캔버스 contents를 처리해보자.
ctx.translate(width / 2, height / 2);
캔버스의 원점을 이동하는 코드이다.
이렇게 하면 좌표 원점(0,0)이 왼쪽 상단 모서리에서 뷰포트의 정중앙으로 이동한다.
캔버스의 중심을 기준으로 디자인을 그릴때 유용하다.
function degToRad(degrees) {
return degrees * Math.PI / 180;
}
function rand(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
let length = 250;
let moveOffset = 20;
for (let i = 0; i < length; i++) {
ctx.fillStyle = `rgba(${255 - length},0,${255 - length},0.9)`;
ctx.beginPath();
ctx.moveTo(moveOffset, moveOffset);
ctx.lineTo(moveOffset + length, moveOffset);
const triHeight = length / 2 * Math.tan(degToRad(60));
ctx.lineTo(moveOffset + length / 2, moveOffset + triHeight);
ctx.lineTo(moveOffset, moveOffset);
ctx.fill();
length--;
moveOffset += 0.7;
ctx.rotate(degToRad(5));
}
반복할 때마다 삼각형을 그리는데, 삼각형이 그려지는 위치가 변경되고 삼각형의 길이가 점점 작아지며 캔버스가 5도씩 회전한다.
애니메이션화 하기위해선 위에서 구현한 루프 동작대신에 동영상처럼 프레임마다 실행되는 지속적인 루프가 필요하다.
초당 60프레임이면 움직임이 부드럽게 보인다.
window.requestAnimationFrame()
메소드는 함수를 반복적으로 실행하도록 해준다.
각 프레임당 실행할 함수의 이름을 매개변수로 전달 받는다.
해당 함수가 애니메이션에 새 업데이트를 적용한 다음 함수 종료 직전에 requestAnimationFrame()
을 호출하면 애니메이션 루프가 실행된다.
루프를 멈추기 위해서는 requestAnimationFrame()
호출을 중지하거나 requestAnimationFrame()
을 호출했지만 아직 프레임이 변경되기 전일 경우엔window.cancelAnimationFrame()
을 호출하면 된다.
애니메이션 사용이 끝나면 명시적으로
window.cancelAnimationFrame()
를 호출하여 실행 대기중인 업데이트가 없도록 하는것이 좋다.
예제 코드는 다음과 같다.
function loop() {
ctx.fillStyle = "rgba(0, 0, 0, 0.25)";
ctx.fillRect(0, 0, width, height);
for (const ball of balls) {
ball.draw();
ball.update();
ball.collisionDetect();
}
requestAnimationFrame(loop);
}
loop();
첫 번째 애니메이션 프레임을 그리는 loop()
함수 마지막 부분에서 requestAnimationFrame()
을 호출하였다.
이를 통해 다음 프레임을 반복적으로 실행한다.
각 프레임에서 캔버스를 완전히 지우고 다시 그리는 것에 주목하자.
일단 그래픽을 캔버스에 그린 후에는 DOM 엘리먼트를 사용하는 것처럼 개별적으로 그래픽을 조작할 수 없다.
대신 프레임 전체를 지우고 모든 것을 다시 그리거나, 어떤 부분을 지어야 하는지 명확히 알고 필요한 최소 면적만 지우고 다시 그리는 코드가 있어야 한다.
일반적으로 캔버스 애니메이션을 만드는 과정은 다음과 같은 단계를 지닌다.
fillRect()
또는 clearRect()
를 사용한다.)save()
를 통해 상태를 저장한다.restore()
를 사용해 복원한다.requestAnimationFrame()
을 호출한다.var canvas = document.getElementById("canvas");
const width = canvas.width = window.innerWidth;
const height = canvas.height = window.innerHeight;
var ctx = canvas.getContext("2d");
ctx.translate(width/2, height/ 2);
function degToRad(degrees) {
return degrees * Math.PI / 180;
}
function rand(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
let length = 250;
let moveOffset = 20;
let i = 0;
function draw(){
if(i++ < length){
ctx.fillStyle = "rgba(0, 0, 0, 0.25)";
ctx.fillRect(-width, -height, width, height);
ctx.fillStyle = `rgba(${255 - length},0,${255 - length},0.9)`;
ctx.beginPath();
ctx.moveTo(moveOffset, moveOffset);
ctx.lineTo(moveOffset + length, moveOffset);
const triHeight = length / 2 * Math.tan(degToRad(60));
ctx.lineTo(moveOffset + length / 2, moveOffset + triHeight);
ctx.lineTo(moveOffset, moveOffset);
ctx.fill();
length--;
moveOffset += 0.7;
ctx.rotate(degToRad(5));
requestAnimationFrame(draw);
}
}
draw();
[참고] : MDN