0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Canvas のドラッグが重い原因は、画面外の先読みで塗っていた別の図でした

0
Posted at

こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。

このサイトには、月の満ち欠けを見せる模型のページがあります。携帯の幅で図をドラッグするとコマ落ちが残っていて、最初は「ドラッグしている図に、軽く描く仕組みが入っていないから」だと考えていました。測り直すと、重かったのはドラッグしている図ではなく、同じコマで一緒に描き直されていた画面の外の図でした。この記事では、動いている間だけ描画を軽くする 3 つの手当てと、最後の 1 つにたどり着くまでの誤診を書きます。

前提: 3 枚の canvas と、重い球の描画

ページには canvas が 3 枚あり、縦に 1 列で並んでいます。

  • 地球から見た月の円盤
  • 月の軌道を上から見た図
  • 地平線から見上げた空の図

3 枚とも同じ 1 つの日時から描いていて、日付を動かすスライダーや図のドラッグで、すべての図が同時に変わります。

重いのは月の球の描画です。WebGL を使わず、JS のループで 1 画素ずつ色を計算して putImageData で置いています。円盤は約 16 万画素あり、1 枚で 1 コマの予算(16.7ms)を大きく超えます。手当ての前は、4 秒の再生のうち 3.0 秒がこの円盤の描画で、50ms を超えるコマが 18 枚出ていました(PC 幅 1440x1000 で測定)。

手当て 1: 画面の外の canvas は塗らない

素直に「状態が変わったら 3 枚とも描き直す」と書くと、再生の 1 コマごとに 3 枚を描くことになります。3 枚は 1 列に並んでいるので、同時に見えるのはふつう 1 枚です。

そこで IntersectionObserver で各 canvas が画面に入っているかを見て、画面の外にあるものは描かずに「古くなった」という印だけを立てます。画面に入ったときに、印が立っていれば 1 回だけ描きます。

ここで止めるのは描画だけで、状態(日時)は止めません。状態まで止めると、図ごとに違う日時を指してしまうためです。

IntersectionObserver には、画面に入る 150px 手前から「入った」とみなす余白(rootMargin)を付けました。スクロールで図が現れた瞬間に、古い絵が一瞬見えるのを防ぐためです。この余白が、あとで問題になります。

手当て 2: 動いている間だけ粗い解像度で描く

画素ループの書き方の改善と合わせて PC 幅の 50ms 超えは 0 枚になりましたが、390px・CPU 4 分の 1(携帯の遅い機械の目安として、CPU の速さを 4 分の 1 に落とした条件)で測り直すと、50ms 超えが 23〜42 枚残っていました。

再生やドラッグで絵が流れている間は、細部は読み取れません。そこで、動いている間だけ球の半径を 0.6 倍にして描き(画素の数で 2.8 分の 1)、置くときに元の大きさへ引き伸ばすことにしました。最後の描画から 0.2 秒空いたら止まったとみなし、もう 1 度だけ本来の解像度で描き直します。

function drawDisc(canvas: HTMLCanvasElement, time: Date, moving = false): void {
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  const state = moonState(time);
  const radius = discRadius(state.distance);
  ctx.clearRect(0, 0, BOX, BOX);
  const corner = BOX / 2 - radius;
  if (!moving) {
    ctx.drawImage(renderSphere(radius, state), corner, corner);
    return;
  }
  ctx.drawImage(renderSphere(radius * 0.6, state), corner, corner, radius * 2, radius * 2);
}

止まっている絵で drawImage に大きさを渡していないのには理由があります。生成した canvas の一辺は Math.ceil(radius * 2) なので、大きさに radius * 2 を指定すると 1px 未満の縮小が掛かり、絵が全体にわずかに変わります。実測で縁の画素が最大 41 違いました。2 引数の drawImage で置くと、止まっている絵は手当ての前と 1 画素も変わりません。

解像度を表示している CSS 幅まで常に落とす案は採りませんでした。携帯は devicePixelRatio が 3 あり、CSS 幅 315px は端末の画素で 945px になります。内部の解像度 440px でもまだ足りていないので、Pixel 5 相当の画面で比べると、315px に落とした絵は縁とクレーターがはっきり軟らかくなりました。

外れた仮説: 軌道の図に粗い描画が無いから重い

手当て 2 は円盤にだけ入れました。その後、軌道の図を左右にドラッグして日付を動かせるようにしたところ、390px・CPU 4 分の 1 で、4 秒のドラッグ中に 50ms 超えのコマが 17〜23 枚出ました。

軌道の図にも月と地球の球があり、こちらには粗い描画の仕組みがありません。そのため「軌道の図の球も、動いている間は 0.6 倍で描けばよい」と考えて作業を始めました。

実装の前に前後を比べるつもりで測ったところ、この見立ては外れていました。手がかりになったのは、ページのスクロール位置を変えて同じドラッグをした比較です。

  • 軌道の図を画面に入れた位置(円盤の下端が画面の上端から 66px 上にある) — 50ms 超え 10〜12 枚、最長 116.7ms。
  • 同じドラッグで、円盤をさらに上へ(先読みの 150px の外まで)スクロールした位置 — 50ms 超え 0 枚、最長 33.4ms。

ドラッグしている図は同じで、違うのは円盤が先読みの余白に入っているかどうかだけです。円盤は画面の外にいても余白の中にいるので「画面に入っている」と判定され、ドラッグで日付が進むたびに描き直されていました。粗い解像度で描いても、CPU 4 分の 1 では 1 枚に 30ms 台かかります。

軌道の図の球は、軌道上の月(一辺 46px)と左上に置いた小さな月(50px)だけでした。4 秒のドラッグで 78 枚描いても putImageData の合計は 40ms ほどで、0.6 倍にしても 1 コマあたり 1ms 程度しか削れません。最初の見立てどおりに実装していたら、効果のない変更を入れたうえで、原因が残っていたことになります。

手当て 3: ドラッグしている間は先読みをやめる

先読みの余白は、止まっているときにスクロールで図が現れる場面のためのものです。ドラッグしている間は、そもそも読者はスクロールしていません。そこで、読者が図を引きずっている間だけ先読みをやめ、画面に入っている図だけを描くことにしました。

function request(painter: Painter, canvas: RefObject<HTMLCanvasElement | null>): void {
  if (painter.frame) return;
  painter.frame = requestAnimationFrame(() => {
    painter.frame = 0;
    const target = canvas.current;
    if (!target) return;
    if (!painter.visible) {
      painter.stale = true;
      return;
    }
    if (painter.busy && !inView(target.getBoundingClientRect(), { width: innerWidth, height: innerHeight })) {
      painter.stale = true;
      return;
    }
    painter.stale = false;
    const now = performance.now();
    const moving = now - painter.painted < SETTLE_MS;
    painter.painted = now;
    painter.draw(target, moving);
    clearTimeout(painter.settle);
    if (!moving) return;
    painter.settle = setTimeout(() => {
      const settled = canvas.current;
      if (!settled) return;
      if (painter.visible) painter.draw(settled, false);
      else painter.stale = true;
    }, SETTLE_MS);
  });
}

function inView(rect: { top: number; bottom: number; left: number; right: number }, view: { width: number; height: number }): boolean {
  return rect.bottom > 0 && rect.top < view.height && rect.right > 0 && rect.left < view.width;
}

コメントは省いています。busy はドラッグ中の印で、visible は先読みの余白込みの IntersectionObserver の結果です。手当て 1 から 3 がこの 1 つの関数に入っています。

  • 余白込みでも画面の外なら描かず、印(stale)だけ立てる(手当て 1)。
  • ドラッグ中は余白を使わず、実際の画面に一部でも入っているかを見る(手当て 3)。飛ばしたぶんも同じ印に積むので、指を離した時点で描き直されます。
  • 直前の描画から 0.2 秒以内なら動いているとみなして粗く描き、止まったら本来の解像度で描き直す(手当て 2)。

ドラッグ中の判定に IntersectionObserver をもう 1 つ足さず、描く直前の getBoundingClientRect() で見ているのは、観測の結果が次のコマに届くためです。引きずっている最中に 1 コマ前の位置で判断することになるので、描く直前に位置を読むほうが確実です。

この印を渡しているのは、日付のドラッグがある円盤と軌道の図の 2 枚だけです。地平線の図の視点のドラッグには掛けていません。

390px・CPU 4 分の 1 で、軌道の図の 4 秒のドラッグは 50ms 超え 17〜23 枚・最長 100〜133ms から、1 枚・最長 100ms になりました(load average 3〜15 の手元の機械での測定)。PC 幅(1280px)は前後とも 0 枚・最長 16.8ms のままです。

一般化できる部分

描画が重いとき、最初に疑うのはいま操作している図の描画関数です。しかし、1 コマの重さはそのコマに描かれた canvas 全部の合計です。画面に見えていない canvas も、先読みの余白に入っていれば描かれています。

今回の誤診を避けられたのは、関数の中身を見る前に「条件を 1 つだけ変えて同じ操作を測る」比較をしたからでした。スクロール位置だけを変えると、ドラッグしている図の負担は変わらず、周りの図だけが外れます。その差が、重さがどこから来ているかをそのまま示しました。

先読みの余白にも同じことが言えます。止まっているときは読者の体験を良くする設定でも、状態が毎コマ変わる間は、見えない図を毎コマ描くコストに変わります。余白の大きさを、操作の状態によって切り替えてよい設定として扱うと、両方の場面に合わせられます。

まとめ

  • 画面の外の canvas は描かずに「古くなった」印だけ立て、状態は止めない
  • 動いている間だけ粗い解像度で描き、止まって 0.2 秒後に本来の解像度で描き直す
  • 止まった絵を置くときは drawImage に大きさを渡さない(端数の縮小で絵が変わる)
  • 「ドラッグしている図に粗い描画が無いから重い」は外れで、先読みの余白にいた別の図が同じコマで描き直されていた
  • 条件を 1 つだけ変えて同じ操作を測ると、重さの出どころが分かる
  • ドラッグ中は先読みをやめ、描く直前の位置で画面の内外を判定する
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?