이 글은 https://developers.google.com/web/updates/2018/01/paintapi 문서 번역을 기초로, 필요한 설명을 일부 추가한 글입니다.

Chrome 65에 도입된 CSS의 새로운 가능성

“CSS Custom Paint” 혹은 “Houdini’s paint worklet”이라고도 불리는 CSS Paint API가 크롬 65에 추가되었다.
지금까지 background-image나 border-image와 같은 속성은 이미지 파일을 로딩하기 위해서 속성 값으로 url() 혹은 linear-gradient()를 사용해 왔다.
크롬에서 새로 도입된 CSS Paint API를 사용하면 프로그래밍 방식으로 이미지를 생성할 수 있기 때문에, 이미지를 참조하는 대신 paint 함수를 사용하여 <canvas> 요소와 유사하게 이미지를 그릴 수 있다. 쉽게 말해서 개발자가 직접 요소의 배경, 테두리 등 꾸미는 요소들을 직접 그릴 수 있게 된 것이다.
사용하는 방법은 다음과 같이 아주 간단하다.

<style>
  .myElem { background-image: paint(checkerboard); }
</style>
<script>
  CSS.paintWorklet.addModule('checkerboard.js');
</script>

CSS의 PaintWorklet에 checkerboard.js라는 이름의 스크립트 파일을 모듈로 추가하고, background-image의 속성 값으로 paint(checkerboard);와 같은 함수를 사용한 것을 알 수 있다.

Houdini? worklet?

Houdini(후디니) 는 “웹 개발자들이 렌더링을 핸들링 할 수 있는 인터페이스를 만들어 주자.”라는 목적으로 만들어진 프로젝트 모임이며, 그들이 만들어낸 기술 자체를 뜻하기도 한다. LayoutAPITyped OMAnimationWorklet 등 다양한 기술들을 만들어 내고 있는데, CSS Paint API는 그 일부일 뿐이다.
후디니에서는 높은 성능이 필요한 그래픽 렌더링이나 오디오 처리를 하기 위해 CSS의 기능 일부를 자바스크립트를 연결하여 구동시킬 수 있다. 이렇게 웹 렌더링 과정 일부에 접근할 수 있도록 하는 스크립트를 worklet 이라고 부른다. 주제 별로 PaintWorkletAudioWorkletAnimationWorkletLayoutWorklet로 나뉘며, 그중에서 가장 먼저 크롬에 적용된 PaintWorklet 은 사용자가 커스텀 한 CSS 속성과 관련된 스크립트를 관리한다.
“CSS Paint API를 사용한다.”는 것은 곧 “PaintWorklet를 사용한다.”라고 표현할 수 있다.


효과

1. DOM 크기 감소

디자인 요소가 많이 있는 페이지에서 paintWorklet을 사용하면 HTML 문서의 DOM 크기는 눈에 띄게 감소한다.
시각적인 효과를 만들어 내기 위해 <span></span>과 같이 비어 있는 태그를 추가하거나, 요소 자체를 몇 겹씩 감싸야 할 때가 있다. 효과가 화려할수록 더 많은 DOM을 필요로 하는 것은 어쩔 수 없는 일이지만, 이 빈 요소들이 웹 페이지 전반에 반복 사용된다면 성능 저하 역시 피할 수 없다.
간단한 예로 다음과 같이 클릭할 때마다 버튼에 물결이 치는 효과 (Ripple) 를 보면 쉽게 확인할 수 있다.


일반적인 CSS 애니메이션으로 구현했을 경우, 물결이 치는 애니메이션을 입힌 빈 요소에 클래스를 추가하는 방식으로 처리할 수 있을 것이다.

반면에 PaintWorklet를 사용한다면 DOM을 추가할 필요 없이 버튼 요소에 직접 프로그래밍 한 CSS를 변경하는 방식으로 기존과 차이가 있는 것을 확인할 수 있다.

#ripple 버튼에 적용한 CSS에서는 우리가 평소에 볼 수 없었던 코드를 찾을 수 있다. --ripple-x: 0; 과 같은 CSS 변수와 background-image 속성이 바로 PaintWorklet으로 새로 정의한 CSS를 사용하는 방법이다.
좀 낯설어 보일 수도 있겠지만 성능 향상뿐만 아니라, 어떤 요소나 복잡한 애니메이션 코드 수정 없이도 효과를 커스텀 하는 일도 훨씬 쉬어진다.

2. CSS의 그래픽 효과 구현

CSS 스펙이 발전하고는 있지만 아직 다양한 그래픽 효과들을 처리하고 있지는 않기 때문에, 이미지로 대체하거나 과감히 포기할 수밖에 없었다. 웹 개발자들은 CSS 속성 별로 브라우저가 지원해 줄 때까지 기다려야 하는 입장이었다. 하지만 PaintWorklet은 개발자가 직접 그림을 그릴 수 있기 때문에, CSS Paint API만 브라우저에서 지원해 준다면 더 이상 CSS 스펙에 연연해 하지 않아도 된다.
아주 간단한 예로 원뿔 형태의 그라데이션 효과를 주고 싶다면, 기존에는 배경 이미지를 불러오는 방법을 사용했을 것이다. 현재 conic-gradation이라는 CSS 속성이 논의 중이긴 하지만 아직 지원하는 브라우저는 없기 때문이다.

하지만 paintWorklet으로 background에 속성 값으로 paint 함수를 사용하여 처리할 수 있다.

이미지 URL을 값으로 갖는 CSS 속성 중에서 가장 쉽고 흔하게 적용할 수 있는 예는 앞서 살펴본 바와 같이 background-image 혹은 border-image일 것이다. 그 외에도 섬네일 디자인 효과 중에서 DOM 요소를 원하는 모양만큼만 보이도록 하는 마스크 효과 역시 매우 유용하게 적용할 수 있다.
불러온 이미지의 모양대로 마스크 효과가 적용되는 mask-image라는 CSS 속성은 비교적 많은 브라우저에서 지원하고 있는 속성이다. 하지만 PaintWorklet을 사용하여 고정된 마스크 이미지를 사용하지 않고, 커스텀 한 CSS 변수 값을 변경하는 방식으로 어떻게 처리할 수 있는지 확인해보자.

mask-image는 DOM 요소의 크기만큼 마스크를 생성하며, 마스크의 투명한 영역에서만 DOM 요소가 보이게 된다. 커스텀 CSS 속성인 --top-width와 --top-height 값을 변경하면 마스크 모양이 바뀌는 것을 확인할 수 있다.

3. 작은 용량

PaintWorklet를 사용하는 대부분의 경우에 용량이 작게 나간다는 점도 하나의 장점이다. 대부분이라고 한 이유는 paint 코드는 캔버스의 크기나 매개 변수들이 변경될 때마다 실행되기 때문이다. 따라서 만약 코드가 복잡하고 길다면 별로 좋지 않을 수도 있다. 크롬은 오랫동안 사용해왔던 PaintWorklet이더라도 메인 스레드에서만큼은 동적인 부분들에 영향을 미치지 않도록 제거하는 작업을 하고 있다.


사용 방법

1. PaintWorklet 등록하기

PaintWorklet에 my-paint-worklet.js를 모듈로 등록하려고 한다면 기본적인 형태는 다음과 같다.

/* my-paint-worklet.js */
class MyPainter {
  paint(ctx, geom, properties) {
    // 그림을 그릴 로직을 작성한다.
  }
}
// 위에 정의한 클래스를 등록할 이름을 정한다.
registerPaint('myPainter', MyPainter);

실제 그림을 그릴 로직을 담은 paint 함수를 포함하여 MyPainter 클래스로 정의하고, registerPaint 함수로 'myPainter'라는 이름을 붙여서 클래스를 등록한다. 이렇게 등록된 이름이 CSS 속성값으로 사용된다. registerPaint 함수는 my-paint-worklet.js 내에 존재하지 않는 전역 함수로, PaintWorklet에 의해 로딩 된 경우에만 존재한다.
paint 함수의 매개 변수에 대해 살펴보면, 기본적으로 ctxgeomproperties 세 가지 변수를 사용할 수 있다.

  • ctx : 렌더링 켄텍스트로 <canvas>의 CanvasRenderingContext2D를 사용하는 방식과 유사함
  • geom : 그림이 그려질 캔버스의 가로 세로 값
  • properties : 현재 페인팅을 하고 있는 요소에 지정할 스타일

더 자세한 내용은 실제 사용 예시를 보면서 이해해 보도록 하자. <canvas>와 100% 동일하지는 않지만 <canvas>에서 그림을 그리는 방법을 알고 있다면 PaintWorklet을 충분히 사용할 수 있을 것이다.

2. CSS 속성 값으로 적용하기

PaintWoklet에 등록된 my-paint-worklet.js를 로딩하여 CSS로 적용하는 방법은 다음과 같다.

<!-- paint.html -->
<!doctype html>
<style>
  div {
    background-image: paint(myPainter);
  }
</style>
<div></div>
<script>
  CSS.paintWorklet.addModule('my-paint-worklet.js');
</script>

myPainter로 불리는 PaintWorklet을 사용하기 위해서 CSS.paintWorklet.addModule('my-paint-worklet.js');로 js 파일을 로딩해야 한다. registerPaint 함수로 정의한 이름인 myPainter가 CSS에서 background-image: paint(myPainter);에서 사용된 것을 다시 한번 확인할 수 있다.


사용 예

쉬운 예로 checkerboard라는 이름의 paintWorklet을 사용하여 <textarea>에 체스판 모양의 배경 이미지를 적용해보자.

/*  checkerboard.js */
class CheckerboardPainter {
  paint(ctx, geom, properties) {
    const colors = ['red', 'green', 'blue'];
    const size = 32;
    for(let y = 0; y < geom.height/size; y++) {
      for(let x = 0; x < geom.width/size; x++) {
        const color = colors[(x + y) % colors.length];
        ctx.beginPath();
        ctx.fillStyle = color;
        ctx.rect(x * size, y * size, size, size);
        ctx.fill();
      }
    }
    console.log('painted'); // paint 함수가 언제 실행되는지 확인
  }
}
registerPaint('checkerboard', CheckerboardPainter);

CheckerboardPainter라는 클래스는 체스판을 구성할 작은 정사각형들의 색상과 크기를 변수로 지정해서 for 반복문을 이용해 체스판을 그리는 paint 함수를 담고 있다. 그리고 registerPaint 함수를 이용하여 CheckerboardPainter 클래스를 'chekerboard'라는 이름으로 등록하였다.

<!-- index.html -->
<!doctype html>
<style>
  textarea {
    background-image: paint(checkerboard);
  }
</style>
<textarea></textarea>
<script>
  CSS.paintWorklet.addModule('checkerboard.js');
</script>

등록된 PaintWorklet을 CSS 속성 값으로 사용하는 방법은 위에서 본 것처럼 아주 간단하다.
렌더링 된 화면은 다음과 같다.

일반적인 배경 이미지를 사용하는 것과의 차이점은 사용자가 <textarea> 영역의 크기를 조절할 때마다 paint 함수가 실행되며 패턴을 다시 그린다는 점이다. paint 함수 내에 console.log('painted'); 가 계속 실행되는 것을 확인할 수 있다.
이는 곧 고해상도 디스플레이를 대응하기 위해서 혹은 다양한 크기의 영역에 적용 되도록 불필요하게 큰 이미지를 사용할 필요 없이, 항상 필요한 크기만큼 정해진다는 것을 뜻한다.
로컬에서 테스트해보기
거의 모든 새로운 API가 그렇듯이, CSS Paint API도 HTTPS 혹은 localhost를 통해서만 구현 가능하다.

CSS 속성에 접근하기

실제로 그림을 그릴 로직을 작성하는 paint 외에, 클래스에서 사용할 수 있는 메서드를 살펴보면 다음과 같다.

class CheckerboardPainter {
    static get inputProperties() { return ['--foo']; }
    static get inputArguments() { return ['<color>']; } // 크롬에서 아직 지원 안됨
    static get contextOptions() { return {alpha: true}; } // alpaha 속성만 변경 가능
    paint(ctx, geom, properties) {
        // ...
    }
}
  • inputProperties
  • inputArguments
  • contextOptions

이 중에서 현재로서 가장 유용하게 쓸 수 있는 inputProperties부터 살펴보자.

inputProperties

다시 체스판 예시로 돌아가 보자. 같은 패턴이지만 다른 크기의 정사각형으로 이루어진 체스판을 배경 이미지로 사용하고 싶다면 새로운 PaintWorklet을 등록해야 할까?
물론 그렇지 않다. paint 함수의 마지막 매개 변수인 properties로 CSS 속성에 접근하면 된다. inputProperties 속성으로 어떤 CSS든지 접근하여 변경할 수 있으며, 그 값은 매개 변수 properties로 할당된다.

/* checkerboard.js */
class CheckerboardPainter {
  // inputProperties로 CSS와 연동하고 싶은 속성값을 배열 안에 문자열로 기술한다.
  static get inputProperties() { return ['--checkerboard-spacing', '--checkerboard-size']; }
  paint(ctx, geom, properties) {
    // properties의 get을 이용하여 사이즈와 간격을 얻어온다.
    const size = parseInt(properties.get('--checkerboard-size').toString());
    const spacing = parseInt(properties.get('--checkerboard-spacing').toString());
    const colors = ['red', 'green', 'blue'];
    for(let y = 0; y < geom.height/size; y++) {
      for(let x = 0; x < geom.width/size; x++) {
        ctx.fillStyle = colors[(x + y) % colors.length];
        ctx.beginPath();
        ctx.rect(x*(size + spacing), y*(size + spacing), size, size);
        ctx.fill();
      }
    }
  }
}
registerPaint('checkerboard', CheckerboardPainter);

이전 코드에서는 const size = 32;로 체스판의 사각형 사이즈를 고정했지만, CSS로 사각형의 사이즈와 사각형 사이의 여백을 조절할 수 있도록 변수화 한 것을 알 수 있다.

<!-- index.html -->
<!doctype html>
<style>
  textarea {
    // 아래 선언된 CSS 변수들은 Paint worklet에서 연동되도록 추가한 것이다.
    --checkerboard-spacing: 10;
    --checkerboard-size: 32;
    background-image: paint(checkerboard);
  }
</style>
<textarea></textarea>
<script>
  CSS.paintWorklet.addModule('checkerboard.js');
</script>

사용하는 방법은 일반적으로 CSS 속성 값을 변경하듯, 디자인에 따라 커스텀 하여 사용하면 된다. 개발자 도구에서 값을 조절해 보며 같은 js 코드로 다른 종류의 체스판들을 만들 수 있다는 것을 확인해보자.

inputArguments

paint 함수에서 추가로 인자를 전달할 수 있기 때문에 CSS로 적용할 경우, background-image: paint(checkerboard, blue); 와 같이 더 직관적으로 CSS 값을 변경할 수 있다. 하지만 아직 크롬에서 구현되지 않은 스펙이기 때문에 사용할 수 없다.

static get inputArguments() { return ['<color>']; }
paint(ctx, geom, properties, arg) {
    //컬러를 arg로 얻어온다.
    ctx.fillStyle = arg[0];
}
<style>
    textarea {
        background-image: paint(checkerboard, blue);
    }
</style>

contextOptions

캔버스 2D 렌더링 컨텍스트에 대한 설정을 변경할 수 있지만 현재로서는 alpha 값만 조절 가능하다.

static get contextOptions() { return {alpha: true}; }

PaintWorklet을 지원하지 않는 브라우저 대응

아직까지는 Houdini는 크롬 브라우저와 Opera에서만 PaintWorklet을 사용할 수 있다(2018/04/23). paint 뿐만 아니라 Houdini의 각 주제별 브라우저 지원 상황 및 W3C 문서 단계는 Is Houdini Ready Yet?에서 확인할 수 있다.
PaintWorklet을 지원하지 않는 브라우저에서는 일반적인 배경 이미지 속성 값을 사용할 수 있도록 CSS와 JS 파일 수정이 필요하다. 먼저 스크립트로 CSS 객체에 PaintWorklet이 있는지 확인하여 지원 여부를 체크한다.

if ('paintWorklet' in CSS) {
    CSS.paintWorklet.addModule('mystuff.js');
    console.log('paint script installed!');
}

CSS에서 코드를 작성하는 방법은 두 가지가 있는데, 먼저 @supports를 사용할 수 있다.

@supports (background: paint(id)) {
  /* ... */
}

더 간단한 방법은 SVG를 사용할 때와 마찬가지로 알려지지 않은 함수가 있다면 선언된 CSS 속성을 덮어쓰거나 무시하는 것이다. 일반 CSS 속성을 먼저 선언하고 PaintWorklet 속성을 뒤에 선언하여 총 두 번 선언하여, 원하는 결과를 얻을 수 있다.

textarea {
  background-image: linear-gradient(0, red, blue);
  background-image: paint(myGradient, red, blue);
}

브라우저가 PaintWorklet을 지원한다면 두 번째 선언된 paint() 값이 첫 번째 linear-gradient()를 덮어쓰게 된다. 반면에 PaintWorklet을 지원하지 않는다면 두 번째 선언된 paint()가 무시되고 첫 번째에 선언한 linear-gradient()가 동작할 것이다.


참고

Houdini의 더 많은 예시는 https://lab.iamvdo.me/houdini/ 에서, CSS Paint API 스펙에 관련한 WC3 문서는 https://www.w3.org/TR/css-paint-api-1/에서 확인할 수 있다.
이 글에서 사용된 예는 다음과 같다.


1개의 댓글

CSS Houdini – LeeHyoJin · 2020년 1월 13일 3:41 오후

[…] Chrome 65에 도입된 CSS의 새로운 가능성 […]

답글 남기기

아바타 플레이스홀더

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다