[chrome] CSS Paint API in 65 #2 / 2

개요top

이전 1편에서는 Paint API의 기본을 다뤘습니다. 이제 기초를 벗어나 다양한 응용을 해볼 차례입니다.
이미 Paint API뿐만 아니라 후디니 전체를 이용한 다양한 케이스를 선보이고 있는 사이트가 많이 있습니다. 그 중 구글이 포스팅에서 직접 언급하고 있는 iamvdo님의 사이트를 보면 background섹션에서 여러가지 Paint API의 응용을 보여주고 있습니다.

하지만 간단한 것들이라, 쇼케이스만으로 Paint API의 특성을 이해하기는 어렵습니다.
이번 포스팅에서는 여러 CSS사양과 결합하여 실제 어떻게 동작하는지 구석구석 살펴보겠습니다.
(2편은 1편의 내용을 이해하고 있다는 가정 하에 작성되었기 때문에 Paint API에 대한 이해가 없으신 분들은 꼭 1편을 보신 후에 보시길 권합니다!)

reflow를 통한 paint호출top

기본적으로 paint 메소드는 지오메트리(geometry)가 변경되면 다시 그려야하니 reflow마다 호출됩니다. css를 통해 reflow를 계속 일으키고 싶다면 keyframe을 이용해볼 수 있습니다.

<style>
  body{margin:0;padding:0}
  .bg{height:300px;background: black}

  @keyframes reflow{
    from{width:500px}
    to{width:500.1px}
  }    

  .test{
    background:paint(heart);
    animation:reflow 0.016s linear infinite;
    height:500px
  }
</style>

위의 ‘reflow’ 키프레임 애니메이션을 걸면 눈에는 보이지 않지만 0.1px만큼 늘었다 줄었다 하면서 실제로 reflow를 일으키게 될 것입니다.
0.016s으로 시간을 정하여 반복시키면 60fps에 근사하게 되므로 총 60번씩 paint를 부르게 될 것입니다.
paint 메소드에서는 호출된 시점의 시간에 따라 alpha값을 조정하도록 로직을 변경합니다.

paint(ctx, geom, props){

  //밀리세컨에 맞춰 알파를 조정한다.
  ctx.fillStyle = `rgba(255, 0, 0, ${(new Date).getMilliseconds() / 1000})`;

  for(let y = 0; y < geom.height; y+=60){
    for(let x = 0; x < geom.width; x+=60){
      ctx.save();
      ctx.translate(x, y);
      ctx.scale(0.5, 0.5)
      ctx.beginPath();
      ctx.moveTo(75, 40);
      this.path.forEach(v=>ctx.bezierCurveTo(...v));
      ctx.fill();
      ctx.restore();
    }
  }
}

실제 결과는 아래와 같이 나올 것입니다(만약 안보이신다면 브라우저의 주소 창 링크를 https로 바꿔주세요 ^^)

예제3. reflow응용

paint메소드가 실행될 때의 전역 객체인 PaintWorkletGlobalScope 상황 아래서는 워낙 없는 객체가 많습니다. performance를 비롯해 window에서 제공되는 대부분의 객체가 없습니다만, js코어 객체는 있으니 이를 활용할 수는 있습니다. Date를 이용해 시간의 흐름에 따른 함수를 구성해볼 수 있는 거죠.

결국 paint함수 스스로 reflow를 일으키는 방식은 불가능하지만 외부 reflow를 규칙적으로 일으키게 트리거를 걸면 얼마든지 애니메이션을 만들 수 있는 구조입니다. 하지만 reflow만 paint를 호출할 수 있는 것은 아닙니다.

property변화를 통한 paint호출top

앞의 샘플이 직접적인 지오메트리의 변화(width)로 reflow를 일으켜 paint를 호출했다면, 이번에는 paint가 참조하는 property의 값을 변화시켜 paint를 부르는 방식을 보겠습니다.

<style>
  body{margin:0;padding:0}
  .bg{height:100px;background: black}
  .test{
    --color:blue;background-image:paint(heart);
    width:300px;height:200px;display: block;
  }
  .test:hover{--color:red}
</style>

이 구조에서는 마우스오버시 –color속성값이 blue에서 red로 바뀌게 됩니다. 이러한 속성의 변화도 paint메소드를 호출합니다.

static get inputProperties(){return ['--color'];}
paint(ctx, geom, props){

  //--color이 변화하면 호출된다!
  ctx.fillStyle = props.get('--color');

  for(let y = 0; y < geom.height; y+=60){
    for(let x = 0; x < geom.width; x+=60){
      ...
    }
  }
}

예제4. property응용. 마우스오버(or 터치)시 색상변화

속성 변화 시 paint메소드가 호출된다는 점을 고려하면 keyframe애니메이션을 통해 보다 정교한 프레임 애니메이션을 만들 수 있을 것입니다. 약간 노가다지만 작성해 보겠습니다(짜피 SASS로 하면 간단할테지만, 주의를 분산하지 않기 위해 그냥 작성하겠습니다)

<style>
  body{margin:0;padding:0}
  .bg{height:100px;background: black}

  @keyframes reflow{
    0%{--frame:0} 1.67%{--frame:1} 3.34%{--frame:2} 5.01%{--frame:3} 6.68%{--frame:4}
    8.35%{--frame:5} 10.02%{--frame:6} 11.69%{--frame:7} 13.36%{--frame:8} 15.03%{--frame:9}
    16.7%{--frame:10} 18.37%{--frame:11} 20.04%{--frame:12} 21.71%{--frame:13} 23.38%{--frame:14}
    25.05%{--frame:15} 26.72%{--frame:16} 28.39%{--frame:17} 30.06%{--frame:18} 31.73%{--frame:19}
    33.4%{--frame:20} 35.07%{--frame:21} 36.74%{--frame:22} 38.41%{--frame:23} 40.08%{--frame:24}
    41.75%{--frame:25} 43.42%{--frame:26} 45.09%{--frame:27} 46.76%{--frame:28} 48.43%{--frame:29}
    50.1%{--frame:30} 51.77%{--frame:31} 53.44%{--frame:32} 55.11%{--frame:33} 56.78%{--frame:34}
    58.45%{--frame:35} 60.12%{--frame:36} 61.79%{--frame:37} 63.46%{--frame:38} 65.13%{--frame:39}
    66.8%{--frame:40} 68.47%{--frame:41} 70.14%{--frame:42} 71.81%{--frame:43} 73.48%{--frame:44}
    75.15%{--frame:45} 76.82%{--frame:46} 78.49%{--frame:47} 80.16%{--frame:48} 81.83%{--frame:49}
    83.5%{--frame:50} 85.17%{--frame:51} 86.84%{--frame:52} 88.51%{--frame:53} 90.18%{--frame:54}
    91.85%{--frame:55} 93.52%{--frame:56} 95.19%{--frame:57} 96.86%{--frame:58} 98.53%{--frame:59}
  }

  .test{
    --frame:0;
    background:paint(heart);
    animation:reflow 1s linear infinite;
    height:200px;
  }
</style>

키프레임애니메이션을 1초를 1/60으로 나눠 1.67%씩 총 60프레임으로 구성하여 –frame값을 증가시켜주고 있습니다. paint메소드는 이제 –frame을 추적하여 로직을 작성할 수 있습니다.

static get inputProperties(){return ['--frame'];}
paint(ctx, geom, props){

  //속성으로부터 frame값을 추출하여 알파에 반영
  const [frame] = props.get('--frame');
  ctx.fillStyle = `rgba(255,0,0,${frame / 60})`;

  for(let y = 0; y < geom.height; y+=60){
    ...
  }
}

구동해 보면 아래와 같이 될 것입니다.

예제5.keyframe애니메이션

속성 변화를 통해 paint메소드를 호출하는 방식이 reflow를 일으키는 것보다 훨씬 효율적입니다. 다른 DOM요소에 영향을 주지 않고 통제 가능한 구조로 paint 메소드를 호출할 수 있게 되었네요.

css속성을 통한 이미지 사용top

이제 Paint API에 익숙해지셨는지 모르겠습니다. 보다 구체적이고 세부적인 사양을 파고 들어 보겠습니다. w3c제안에는 paint 메소드가 인자로 받는 캔버스 2d context에 대해 The 2D rendering context항목에서 제약 사항을 명시하고 있습니다. 이 제약에 따르면 paint메소드에 전달되는 캔버스 컨텍스트는 일반 브라우저 것보다 상당히 기능이 제한됩니다. 가능한 기능목록은 다음과 같습니다.

  • CanvasState
  • CanvasTransform
  • CanvasCompositing
  • CanvasImageSmoothing
  • CanvasFillStrokeStyles
  • CanvasShadowStyles
  • CanvasRect
  • CanvasDrawPath
  • CanvasDrawImage
  • CanvasPathDrawingStyles
  • CanvasPath

대충만 봐도 글자를 쓸 수 없고, path, circle 등의 메소드도 사용할 수 없다는 것을 알 수 있습니다.
하지만 다행히 DrawImage를 지원하고 State(save, restore)와 Transform은 사용할 수 있습니다.

여기서 핵심적으로 다룰 내용은 DrawImage를 지원한다는 점입니다. 헌데 비트맵으로 그림을 그릴려고 해도 어떻게 이미지원본을 가져오냐의 문제가 있습니다.

DOM엘리먼트도 없고 ajax도 없고 File, Blob도 없으며 컨텍스트에는 putImageData()도 없는 상황입니다.

일단 이미지를 그릴려고 해도 이미지 자체를 어떻게 구해오느냐가 문제입니다. w3c초안에서는 CSS.registerProperty를 활용하도록 하고 있습니다.
이 사양은 후디니의 하위항목인 Value API와 관련된 것으로 아직 크롬66에서도 구현되지 않았습니다. 우선 구현되었다고 가정하는 를 분석해보죠.

<style>
#example {
  --image:url('#someUrlWhichIsLoading');
  background-image:paint(image);
}
</style>

–image속성에 url을 할당하고 있습니다만 이 상태로는 –image가 이미지로 인식되는 게 아니라 그저 url값으로 인식될 뿐입니다. 이를 이미지타입으로 바꿔주는 선언을 해야 합니다. 이 선언과 다양한 타입이 전부 Value API의 기능입니다. –image속성이 이미지를 값으로 갖게 하는 코드는 다음과 같습니다.

CSS.registerProperty({
  name: '--image',
  syntax: '<image> | none',
  initialValue: 'none',
});

위의 등록 절차로 –image속성의 값은 이미지가 되거나 none이 된다고 정의할 수 있습니다. 이젠 –image의 값을 가져와 그리면 됩니다.

static get inputProperties() { return ['--image']; }
paint(ctx, geom, props) {

  //--image가 진짜 이미지 객체로 들어온다!
  const img = props.get('--image');

  //그대로 컨텍스트에 그리면 됨
  ctx.drawImage(img, 0, 0, geom.width, geom.height);
}

하지만 현시점(크롬66)까지는 후디니의 ValueAPI가 제공되지 않아 작동하지 않습니다(아예 CSS.registerProperty 자체도 존재하지 않습니다 ^^)

즉 위의 방식으로 Paint API에서 이미지를 그릴 방법은 없습니다(곧 개선되겠죠 ^^)

따라서 다른 방식을 강구해야 합니다.

원래 value가 image타입인 css속성을 이용한 이미지 사용top

우아한 사용자 정의 속성기반으론 이미지를 쓸 수 없는 상황입니다. 따라서 원래 CSS스펙을 이용하는 방법만 남았습니다.
w3c의 css image모듈 버전3를 구경하다보면 Image Values와 이 값을 갖는 속성을 정의하는 부분을 발견할 수 있습니다.

Image Values: the <image> type

이 정의에 따르면 속성에 이미지타입의 값을 갖는 경우는 cursor, list-style-image, background-image 이렇게 3개입니다.

하지만 background-image에 Paint API를 적용할 예정이므로 list-style-image와 cursor를 이용해 paint에 이미지를 전달할 수 있을 것입니다.
..하지만2 cursor속성의 url은 굉장히 까다로운 조건이 걸려있습니다. 이 조건을 충족해야만 paint에 image로 들어오고 그 외엔 그냥 auto같은 값으로 들어와버립니다.

최종적으로 제약없이 아무 이미지나 보낼 수 있는 속성은 list-style-image인 셈입니다.
이를 이용해 이미지를 전송해보죠.

<style>
  body{margin:0;padding:0}
  .bg{height:100px;background: black}
  .test{
    list-style-image:url(rem.jpg);
    background:paint(img);
    height:200px;
  }
</style>

이제 img모듈에서는 list-style-image속성을 인식해야 할 것입니다.

static get inputProperties(){return ['list-style-image'];}
paint(ctx, geom, props){

  //이미지를 가져온다!
  const image = props.get('list-style-image');

  for(let y = 0; y < geom.height; y+=200){
    for(let x = 0; x < geom.width; x+=200){
      ctx.save();
      ctx.translate(x, y);
      ctx.filter = `opacity(${Math.random()*50 + 50}%)`;

      //가져온 이미지로 그린다!
      ctx.drawImage(image, 0, 0, 200, 200);
      ctx.restore();
    }
  }
}

이제 다음과 같은 화면을 볼 수 있습니다.

속성을 통한 이미지 사용

아틀라스구성과 애니메이션top

일반적으로 스프라이트라고도 알려져 있는데 여러 장의 이미지를 하나의 이미지에 넣고 일부를 사용하는 경우 이 이미지를 아틀라스(atlas)라 합니다.
(사실 스프라이트는 아틀라스 상의 한 장 한 장 이미지…)
위에서 다뤘던 속성 기반의 애니메이션과 아틀라스를 결합하면 키프레임 애니메이션을 만들 수 있을 것입니다.
이미 만들었던 60프레임구성을 이용하도록 60프레임짜리 아틀라스를 만들어보죠.

우선 프레임애니메이션에서 사용했던 프레임구조와 list-style-item을 모두 스타일에 정의해줍니다.

<style>
  body{margin:0;padding:0}
  .bg{height:100px;background: black}
  @keyframes reflow{
    0%{--frame:0} ...
    ...98.53%{--frame:59}
  }    
  .test{
    list-style-image:url(atlas7.png);
    --frame:0;background:paint(img);
    animation:reflow 1s linear infinite;
    background:paint(img);
    height:480px;
  }
</style>

paint에서 사용할 atlas7이미지는 다음과 같은 이미지 입니다.

7×9로 구성된 아틀라스로 60프레임에 대응하는 가벼운 불꽃놀이 스프라인트입니다(진리의 일루젼으로 만들었..)

이제 paint는 프레임에 맞춰 아틀라스를 적당히 처리해주면 됩니다.

//두 개 다 받아들임
static get inputProperties(){return ['list-style-image', '--frame'];}
  
paint(ctx, geom, props){

  //이미지와 프레임을 받아들이고
  const image = props.get('list-style-image');
  const [frame] = props.get('--frame');

  //개별 스프라이트의 크기는 320x240이며 한 줄에 7개가 들어간다. 
  const w = 320, h = 240, cw = 7;

  //위 기본 정보를 이용해 현재 프레임의 이미지상 위치를 특정할 수 있다.
  const sx = (frame % cw) * w, sy = parseInt(frame / cw) * h;

  for(let y = 0; y < geom.height; y += h){
    for(let x = 0; x < geom.width; x += w){
      ctx.drawImage(image, sx, sy, w, h, x, y, w, h);
    }
  }
}

이러한 조합으로 다음과 같은 결과물이 보이게 됩니다.

아틀라스(atlas) 구성과 애니메이션

필터top

사실 컨텍스트 상에 filter가 지원되기 때문에 부하가 많이 걸리는 픽셀별 작업이 진정 필요한가 신중하게 생각해볼 필요가 있습니다.
특히 Paint API상의 컨텍스트는 putImageData()가 안되기 때문에 일일히 점을 순회하면서 갱신할 수 없습니다. 최선은 필터 조합으로 일단 커버하는 것입니다.
마우스를 올리면 블러와 그레이스케일이 먹도록 수정해보죠.

<style>
  body{margin:0;padding:0}
  .bg{height:100px;background: black}
  @keyframes reflow{
    0%{--frame:0} ...
    ...98.53%{--frame:59}
  }    
  .test{
    list-style-image:url(atlas7.png);
    --gray:0%;--blur:0px;
    --frame:0;background:paint(img);
    animation:reflow 1s linear infinite;
    background:paint(img);
    height:480px;
  }
  .test:hover{--gray:80%;--blur:3px;}
</style>

앞의 코드에서 –gray와 –blur를 추가했습니다. 이제 오버되면 이 값이 바뀌게 되는 것이죠.

//--gray와 --blur를 추가로 로딩
static get inputProperties(){return ['list-style-image', '--frame', '--gray', '--blur'];}
paint(ctx, geom, props){
  //두 속성을 읽어 filter에 반영한다.
  const [gray] = props.get('--gray');
  const [blur] = props.get('--blur');
  ctx.filter = `grayscale(${gray}) blur(${blur})`;

  ...상동
}

이제 마우스를 올리면 그레이스케일과 블러를 먹는 걸 볼 수 있습니다.

마우스를 오버해보시면..필터

Blob으로 인라인화가 가능한가?top

웹워커 등 외부 js를 요구하는 다양한 경우 이미 알려진 인라인 스크립팅을 사용하는 경우가 많습니다. 그렇다면!! Paint API도 가능할 것인가를 실험해봐야겠죠.

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>PaintAPI 8</title>
<style>
  body{margin:0;padding:0}
  .bg{height:100px;background: black}
  .test{
    list-style-image:url(rem.jpg);
    background:paint(img);
    height:400px;
  }
</style>
</head>
<body>
  <div class="bg">
    <div class="test"></div>
  </div>
<script>
//1. blob생성
const blob = new Blob([`
registerPaint('img', class{
  static get contextOptions(){ return {alpha: true};}
  static get inputProperties(){return ['list-style-image'];}
  paint(ctx, geom, props){
    const image = props.get('list-style-image');
    for(let y = 0; y < geom.height; y+=200){
      for(let x = 0; x < geom.width; x+=200){
        ctx.save();
        ctx.translate(x, y);
        ctx.drawImage(image, 0, 0, 200, 200);
        ctx.restore();
      }
    }
  }
});
`], {type:'text/javascript'});

//2. url생성
const url = URL.createObjectURL(blob);

//3. 진짜 url대신 2번에서 생성한 url전달
CSS.paintWorklet.addModule(url);
</script>
</body>
</html>

[/js]

깔끔하게 잘 됩니다. 일반적인 인라인스크립트 생성절차와 마찬가지로 Blob을 만들어 URL을 만든 뒤 넘겨주면 문제 없이 실행됩니다.

blob인라인화

결론top

Paint API에 대해 여러 특성을 심층적으로 살펴봤습니다. 이 사양을 이용하면 기존에는 굉장히 복잡하게 애니메이션 레이어를 구현하던 부분도 상당 부분 분리하여 적용하고 싶은 DOM에 효과적으로 적용할 수 있는 모듈화가 가능합니다. 특히 Blob과 같이 쓰면 별도의 스크립트를 만들어야 하는 부담도 거의 없고 아틀라스를 이용하면 이미지가 하나만 전달된다는 문제도 손쉽게 해결할 수 있습니다.

이제 모든 걸 모아보면 다음과 같은 예제를 손쉽게 만들 수 있게 됩니다.

마우스를 큐브에 올려보면…종합예제