こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。
このサイトには、月の満ち欠けを見せる模型のページがあります。携帯の幅で図をドラッグするとコマ落ちが残っていて、最初は「ドラッグしている図に、軽く描く仕組みが入っていないから」だと考えていました。測り直すと、重かったのはドラッグしている図ではなく、同じコマで一緒に描き直されていた画面の外の図でした。この記事では、動いている間だけ描画を軽くする 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 つだけ変えて同じ操作を測ると、重さの出どころが分かる
- ドラッグ中は先読みをやめ、描く直前の位置で画面の内外を判定する