各種ブラウザのフレームレートを調べた

ちょっと気になることがあって各主要ブラウザのフレームレートを調べてみたのでその結果をメモ。

調べ方

PCのChromeであれば、DevToolsで下記のようにリアルタイムのフレームレートを表示する事が可能です。

今回はChrome以外のブラウザや、スマホでも調べたかったので下記のようなコードを用意してみました。

requestAnimationFrame() を1秒間回して、その回数を調べる単純なコードです。

async function measureFPS() {
    return new Promise((resolve) => {
        let count = 0;
        const start = performance.now();
        function frame() {
            if (performance.now() - start >= 1000) {
                resolve(count);
                return;
            }

            count = count + 1;
            requestAnimationFrame(frame);
        }
        frame();
    });
}

それをHTML上に実装したものが こちらのページ で確認できます

Macに144Hzのディスプレイを繋いでChromeで試した結果がこちら

どうやら1〜2fpsほど大きい数字が出てしまうようです。
コードの問題かもしれませんが、まあいいでしょう。なんとなく測れればよいのです。

このHTMLを使って各ブラウザで計測していきます。

計測環境

結果発表の前に、計測した環境について書いておきます

  • Macbook Pro M1 Max
    - 内蔵ディスプレイ(ProMotionの120Hz)
    - 外部ディスプレイ:AG274UXP/11(144Hzで接続)
    - Sidecar の iPad Pro (ProMotion対応だが、Sidecar接続のため60Hz)

  • iPad Pro 11インチ 第4世代 M2チップ 
    - 内蔵ディスプレイ(ProMotion 120Hz)
    - 外部ディスプレイ:AG274UXP/11(ステージマネージャ有効)

  • Xiaomi 13T Pro
    - 内蔵ディスプレイ(最高144Hzまでの動的フレームレート設定)
    - 内蔵ディスプレイ(固定144Hz)
    - 内蔵ディスプレイ(固定60Hz)

AG274UXP/11が長いので以下AGONと表記します

計測した結果

Macbook Pro M1

ブラウザ ディスプレイ FPS
Chrome 内蔵 122
Chrome AGON 145
Chrome Sidecar 62
Safari 内蔵 62
Safari AGON 74
Safari Sidecar62
Firefox 内蔵 145
Firefox AGON 146
Firefox Sidecar 144

この結果から、Chromeはディスプレイのリフレッシュレートに合わせてレンダリングのフレームレート制限を可変させることがわかった。

Safariも基本的にはChromeと同じくディスプレイのリフレッシュレートを最大値としてフレームレート制限をかけるようだが、

  • 最高でも72FPS(?)までに制限される
  • ProMotionの下では60FPSに制限される

という条件がつくようだ。

Firefoxだけ理解できなかったのでAGONを物理的に切断して、MacとSidecarだけ繋がってる状態で再計測

ブラウザ ディスプレイ FPS
Firefox 内蔵 61
Firefox Sidecar(プライマリ) 62

なるほど、どうやらプライマリディスプレイのリフレッシュレートでレンダリングされるらしい。(↑の計測時はSidecarがプライマリになっていた)

今度は内蔵をプライマリにしてみる

ブラウザ ディスプレイ FPS
Firefox 内蔵(プライマリ) 120
Firefox Sidecar 120

なるほどね

iPad Pro 11インチ

ブラウザ ディスプレイ FPS
Safari 内蔵 61
Safari AGON 62

ステージマネージャの外部ディスプレイはブラウザの外側でリフレッシュレートがどのくらいなのか調べられなかったのだけど、マウスポインターをグルグル動かしたときの軌跡がProMotionな内蔵ディスプレイと比べると明らかに少ないので、おそらく60Hzで接続されてるのだと思う。

ちなみにAGONを接続せずにiPad単体で測っても62FPSでした。

Xiaomi 13T Pro

ブラウザ ディスプレイ FPS
Chrome 内蔵・動的Hz 122
Chrome 内蔵・固定144Hz 121
Chrome 内蔵・固定60Hz 62

Mac+Chromeと同じ結果を予想していたけど、144Hz固定にしても120相当しか出なかったのは予想外。この端末の特有な現象の可能性もある。

もしかしたらAndroidのChromeは上限120FPSで制限されているのかもしれない。

結論

今回iPhone実機では調査しなかったけど、まあ間違いなくiPadと同じで60FPSに制限されていると思います。

フロントエンド界隈では「ヌルサクアニメーションにするために60FPSを死守すべし!」というのはよく聞く話ですが、実際に調べてみた結果Safariをターゲットとするならばやはり60FPSがひとつの基準になるのは間違いないようです。
ただ、Safari以外はハードウェアさえ対応していれば少なくとも120FPSでレンダリングできるので、これからは120FPSを基準にしていくべきなのかもしれません。

iPhoneだってハードウェア的には120Hzなわけで、Apple的にハイリフレッシュレートに対して否定的な立場ではないハズ。
OS/Safariのアップデートで突然60FPS制限を引き上げるのも時間の問題なのでは?と思いました。

スタイルを隔離してHTMLを埋め込むにはShadowDOMを使うとスマートかもしれない

管理画面があるようなWebサイト・Webアプリを作るとき、稀によくHTMLテキストを値として入力したいという要件があります。

Webサイトの運用者がある程度自由に内容を書き換えられる必要があるコンテンツ、例えばWordPressの記事本文などがそうですが、管理画面ではWYSIWYGエディタのようなものだったり、場合によってはmarkdownを書く仕様だったりすると思います。

これを表示する側の実装ではその部分にも当然スタイルを当てなければなりませんが、多くの場合は出力されるHTMLに自由にクラス名などを付与することができないため、 <h1><p> など、クラス名のついていない素のタグに対してスタイルを適用することになります。

例えば、下記のような。

<div class="post_content">
  <h1>見出し1</h1>
  <p>ほげほげ</p>
  <h2>見出し2</h2>
  <ul>
    <li>リスト1</li>
    <li>リスト2</li>
  </ul>
</div>
.post_content h1 {
  font-size: 24px;
  font-weight: bold;
}
.post_content h2 {
  font-size: 20px;
  font-weight: bold;
}

.post_content p {
  margin-top: 12px;
  margin-bottom: 12px;
}

.post_content ul {
  list-style: disc;
}

...

私はこういうスタイルを書くとき、このサイトがベースのスタイルとしてtailwindやリセットCSSを使っているとしたら、わりと気を使うんですよね。

h1やpのようなよく使うタグはもちろんデザインに合わせてスタイルを書いておきますが、 <blockquote> みたいな普段使わないような、デザインの想定にもないようなタグを使われた場合、スタイルを用意できておらず見た目ではdivと同じになってしまいます。

normalize.cssをベースにしていれば少なくともデフォルトのスタイルが存在するので、スタイルがないよりマシだと思いますが、ベースがリセットCSSだと前後の余白すらなくなってしまって酷い見た目になります。

上記の例で言うところの .post_content 以下だけCSS的に隔離ができて、normalize.cssもしくは独自のCSSを適用できるのだとしたら、外からの影響を受けずに堅牢なスタイルが作れるのではないかと思いました。

親要素からの隔離だったら ShadowDOM が使えるのでは? というのが今回の話の趣旨になります。

ShadowDOMとは?

詳しい説明はみんなだいすき MdN を見てもらうとして、一言でいうならばDOMツリーの中に独立したDOMツリーを構築する機能のこと。

その影響としてCSSのスコーピングが可能になります。

ShadowDOMの外はtailwind、中はnormalizeができるわけですね。

<div class="js-dom-prison">
  <h1>見出し1</h1>
  <p>ほげほげ</p>
  <h2>見出し2</h2>
  <ul>
    <li>リスト1</li>
    <li>リスト2</li>
  </ul>
</div>

.js-dom-prison をShadowDOMのrootにして、内容物をすべてShadowDOMの中に配置し直します。

const container = document.querySelector('.js-dom-prison');
const children = container.childNodes;
const shadowRoot = container.attachShadow({mode: 'open'});
shadowRoot.append(...children);

ShadowDOMの中にnormalize.css(もちろん、独自のCSSでも可)を適用したい場合は、こう↓

const container = document.querySelector('.js-dom-prison');
const children = container.childNodes;
const shadowRoot = container.attachShadow({mode: 'open'});
const stylesheet = document.createElement('link');
stylesheet.setAttribute('href', 'https://necolas.github.io/normalize.css/8.0.1/normalize.css');
stylesheet.setAttribute('rel', 'stylesheet');
shadowRoot.append(stylesheet, ...children);

これをDevToolsでみるとこうなります↓

あえてinnerHTMLなどを使わずに最初から .js-dom-prison にHTMLを展開しておくことで、たぶんSEO的にも問題にならない。(たぶん)

querySelector等で探せなくなるなど、デメリットがないわけではないので使い所が選ぶ必要がありそうですが、iframeなどと違ってオーバーヘッドも小さいですし、もののJS数行でCSS的に隔離ができるので、ハマる状況ならとてもスマートなんじゃいかと思ってます

Alpine.jsの$storeはいつ使うのか?

Alpine.jsでは x-data="{open: false}" というように x-data 属性を付与することで、その要素の内側で x-show="open" のように参照することができます。

<div x-data="{open: false}">
  <button @click="open = !open">開閉するよ</button>
  <div x-show="open">
    開いてるね
  </div>
</div>

とまあこれはAlpine.jsを知っている人なら全員知ってることなんですが、 x-data でなくても $store で同じようなことができます。

例えばこんな感じですね

<button x-data @click="$store.menu.open = !$store.menu.open">開閉するよ</button>

<div x-data x-show="$store.menu.open">
  開いてるね
</div>

<script>
  document.addEventListener('alpine:init', () => {
    Alpine.store('menu', {
      open: false,
    })
  })
</script>

コードでみると結構違いますが、やってることは一緒で、openという変数の定義場所が違うだけなんですよね。

$store はコンポーネントを跨いで参照することができます。便利。

ただし、v3から x-data入れ子になっていても変数名がカブらない限り祖先の変数も参照できるという仕様なので、

<body x-data="{open: false}">
  ...
  <button @click="open = !open">開閉するよ</button>
  ...
  <div x-show="open">
    開いてるね
  </div>
  ...
</body>

このように <body>x-data しちゃえばどこからでも参照できるグローバル変数を定義できちゃうんですよね。これでは$storeの立場がありません。

じゃあ $store っていつ使うの?って話なんですが、おそらく主に下記2点に注目すべきポイントがあります

外部のJSと連携したい!

UI系のライブラリを利用する場合など、Alpine.js外のJSとステートの共有をしたい場合は往々にしてあると思います。

そんな場合には x-dataAlpine.data で頑張るより素直にstoreを使ったほうが良いでしょう。

<script>
    Alpine.store('menu').open = true;
</script>

パフォーマンスへの影響

x-data を付与すると Alpine.start() を実行したときに、その要素以下がコンポーネントとして初期化されます。

もしその要素が巨大なツリーだった場合、初期化にかかるコストが大きいものになるため、できる限りコンポーネントは小さくするのがベター。

ちょっとした状態管理のためだけにbodyで x-data をしてしまうのはコストに見合わないので、$storeを使うのがよいでしょう。

いい感じの色を作りたいならOklchを使うといい感じになる

時折、明度と彩度を一定に保ったまま色相を変えていくつかの色を作りたいときがあります。

なすびのウェブサイトで例えると、制作事例のタグは頻繁な増減があり、数も多く、色自体に拘りたいわけではないので、すべての色をひとつずつ決めるのは結構な手間になります。
そのため、ここでは codePointAt() を使ってタグ名を数値に変換し、色相に当てはめてランダムではない動的な色を作ることにしました。

このような場合、これまではhsl()を使うことで簡単に明度彩度を固定したまま色相だけ違う色をたくさん作ることができましたが、hslによる指定では明度彩度を一定にしても視覚的な均一性はありませんでした(特に黄色系の色味が明るすぎるなど)。

最近のブラウザではhslに似た指定で視覚的均一性のあるカラー指定oklch()が使えるようになっています。

hslとoklchをわかりやすく並べて比較するとこのくらい違いがありました。

上下のどちらの色が均一に見えるでしょうか。

色相だけでなく、例えばデザインシステムのカラー設計で色相と明度(+彩度)を軸にしたカラーグリッドを作る場合でも、Oklchを使うことで視覚的に統一された色を簡単に作ることができます。

使用する上で注意点を挙げるとするならば、oklchはカラースペースがsRGBではなく、より広い範囲の色を指定することができることです。

極彩色に近い色だとディスプレイによっては色の差がわからないことがあるかもしれません。

とはいえ一般的なスマートフォンやMacbookのようなdisplay-p3対応を謳っているディスプレイであれば、ほぼ正確に出力できます。


参考にさせていただいたサイト
https://griponminds.jp/blog/relearn-css-color/

FrankenPHPでサクッとWordPressの開発環境を作る

FrankenPHPは先日v1.0がリリースされたばかりの、PHPとWebサーバがセットになったDockerイメージです。

ちょうどローカルでWordPressを動かしたいタイミングがあったので試してみました

基本的にはdunglas/frankenphp-wordpressにひな形があるのでこれを参考にします。

まずはDockerfile。
ほぼひな形通りですが、PHP8.3以上の環境だとImagickのビルドでコケるので、今回は明示的に sha-2eabec8-php8.2 を指定しています。

FROM dunglas/frankenphp:sha-2eabec8-php8.2

# install the PHP extensions we need (https://make.wordpress.org/hosting/handbook/handbook/server-environment/#php-extensions)
RUN install-php-extensions \
    bcmath \
    exif \
    gd \
    intl \
    mysqli \
    zip \
    imagick \
    opcache

COPY --from=wordpress /usr/local/etc/php/conf.d/* /usr/local/etc/php/conf.d/
COPY --from=wordpress /usr/local/bin/docker-entrypoint.sh /usr/local/bin/
COPY --from=wordpress --chown=root:root /usr/src/wordpress /app/public

VOLUME /app/public

RUN sed -i \
    -e 's/\[ "$1" = '\''php-fpm'\'' \]/\[\[ "$1" == frankenphp* \]\]/g' \
    -e 's/php-fpm/frankenphp/g' \
    -e 's#/usr/src/wordpress#/app/public#g' \
    /usr/local/bin/docker-entrypoint.sh

ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]

docker-compose.ymlはそのままでも大丈夫そうだけど、好みの問題で下記のようにしました

version: '3.1'

services:

  wordpress:
    build: .
    ports:
      - 80:80
      - 443:443
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: docker
      WORDPRESS_DB_PASSWORD: docker
      WORDPRESS_DB_NAME: dbname
    volumes:
      - ./:/app/public

  db:
    image: mysql
    environment:
      MYSQL_DATABASE: dbname
      MYSQL_USER: docker
      MYSQL_PASSWORD: docker
      MYSQL_RANDOM_ROOT_PASSWORD: '1'
    ports:
      - 3306:3306

上記2ファイルをドキュメントルートの階層に置いて、起動すればlocalhostでアクセスできました。

docker compose up

aspidaのRestClientで多重送信を抑制する

OpenAPIでAPIが定義されているプロジェクトにおいて @aspida/fetch を使ってRestClientを生成・利用しています。

とあるGETリクエストを複数のコンポーネントからほぼ同時に呼び出す必要があり、リクエストの回数を1回にまとめたい状況がありました。
これをaspidaでやろうと思ったときにちょっと思ったようにできなかったのでメモ。

最初にやろうとした実装は、aspidaに渡すfetchをラップする方法。

const promises = {};
function customFetch(url, option) {
if (option.method === 'GET') {
const key = `GET__${url}`;
if (!promises[key]) {
promises[key] = fetch(url, option);
}
return promises[key];
} else {
return fetch(url, option);
}
}

promiseを再利用したいわけですが、こうすると、ResponseのStreamが使用済みなのでエラーになります。そりゃそうだ。

Response.clone() で複製してから使えばよいわけですが、aspidaの生成するtsコードで Response.json() を実行してしまうので、外からcloneを挟むようなことは無理そう…

色々考えた結果、こうなりました

const promises = {};
function dedupe(api, option) {
const key = `GET__${api.$path(option)}`;
if (!promises[key]) {
promises[key] = api.$get(option);
}
return promises[key];
}

使う側では

const result = await client.path.to.hoge.$get({ query: { fuga: 'piyo' } });

これを、

const result = await dedupe(client.path.to.hoge, { query: { fuga: 'piyo' } });

こうする。

関数で囲わなければいけなくなったのがちょっとスマートじゃないですが、まあ重複排除したいところだけ使えてポータブルな関数になったので良いということにしよう。

Google Publisher Tag のよくあるエラー

googletag.openConsole() で表示される警告文の意味と主な対処方法の自分メモ

用語

GPT: Google Publisher Tag
GAM: Google Ad Manager

ドキュメント

⚠️ 広告ユニットが取得されませんでした

何が起きているか

defineSlotは行われたが、displayやrefreshを行っておらず、広告リクエストが走っていない状態

対処方法

displayやrefreshを実行する。

googletag.display('div-id');
googletag.pubads().refresh([slot]);

disableInitialLoad() が利用されている場合は、displayでは広告リクエストが走らなくなるのでrefreshを使う。

⚠️ 広告ユニットが埋められませんでした

何が起きているか

配信条件に一致する広告申込情報が見つからずに広告を埋められなかった

対処方法

GAMで該当の広告ユニットと広告申込情報の配信設定やターゲティングを見直す

CSSのfont-synthesisとtext-renderingが原因でAndroidで太字にならない

Viteで新規にプロジェクトを作るとapp.cssに font-synthesistext-rendering が指定されています。

:root {
  ...
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  ...
}

これらのプロパティがあると、 Android Chrome で見たときにfont-weightで700以上を指定しても太字にならない場合があります。

font-synthesis は、フォントに太字やイタリックの字体が含まれていない場合にブラウザが合成してよいかどうかを制御するもの。

text-rendering は、合字やカーニングの処理を行うか否かを制御し、読みやすさと速度のどちらを優先するか決めることができます。

これら2つの両方もしくはどちらかと、おそらく font-family によるフォント指定の組み合わせによって太字書体が使える環境にも関わらず太字にならないことがあるようです。

基本的に欧文フォントのためのプロパティなので、日本語環境では、少なくとも現段階では使わないほうが無難なようです。

Web Designing 2022年12月号にインタビューが掲載されました

2022年10月18日にマイナビ出版さんより発売された「Web Designing 2022年12月号」に菅沼のインタビューを掲載していただきました。

12月号はJavaScriptフレームワーク特集ですが、その中の「モダンフロントエンド開発技術の使い分けと現場の実情」というコーナーで、「普段どういう目線でフレームワークを選定しているのか?」「VueやReactなどを使う際に気をつけるべきこと」など、鼎談形式で話をさせていただきました
技術の深い話ではなく、フレームワークを使うと何が変わるのか、といった内容が主なので、現役のエンジニアよりはディレクターに向けた内容になっています

出版社サイトはこちら:
https://book.mynavi.jp/ec/products/detail/id=132319

ご興味があればぜひご覧ください!

クリエイティブに最適なAG274UXP/11をレビュー

以前の記事で、制作現場で使える最強ディスプレイを探した結果 Eve Spectrum を購入したことをお伝えしたが、今回2台目としてAOCのAG274UXP/11を購入した。
Macbook のディスプレイや Eve Spectrum とも比較をしながらレビューしていく

今回購入したAG274UXP/11はAOCというメーカーのもので、日本ではあまり聞き馴染みがないが欧米ではわりとメジャーな、台湾の老舗ディスプレイメーカーらしい。
実際にドット抜けもなく非常にコスパに優れた良い製品でした。

スペック

制作現場で重要となる項目について、AG274UXP/11のスペックは下記の通り

解像度:4K
ビット深度:True10bit
リフレッシュレート:160Hz
色域:P3 102%

2021年の M1 Pro/Max を搭載した MacbookPro が ProMotion を採用したので、外部ディスプレイで必ずしも120Hzが必要かと言われるとそうでも無くなってきているのだが、特筆すべきはP3の100%を超える色域だろう。
MacbookのP3カバー率は明記されていないが、実測で96%程度らしいので、おそらく公称値98%のディスプレイと同程度と考えると良さそう。
少なくとも AG274UXP/11 は、 Spectrum や MacbookPro より広い色域を表現できるということだ。

色域以外でもビット深度が8bit+FRCではなく True 10bit であることやリフレッシュレートが最大160Hzである点など、完全な上位互換となっている

あと、おまけでモニターフードもついてきます
マルチディスプレイとは相性が悪いが、つけてるとデキるデザイナーっぽい演出ができるのでオススメ

実際に繋いでみた

Spectrum では、おそらくディスプレイのファームウェアの問題で type-c to type-c で繋ぐと60Hzまでしか出力できず、 type-c to DP で繋いで4K144Hzを出力していたが、AG274UXP/11では type-c to type-c で繋いでも4K120Hzを選ぶことができた。
144Hzや160Hzが出力できない理由は現状わかっていないが、120Hzあれば制作用途であれば必要十分なので問題ない。

もちろんThunderboltなので、1本繋ぐだけでディスプレイをUSBハブとしても利用可能。
電源供給は60Wなので M1 Pro/Max のマシンでは少し心許ないが、高負荷な作業を連続して行わない限りバッテリーが目減りすることはないので個人的には不満には感じていない。

ロゴプロジェクターと背景のライティングは不要

やはりゲーマー向けモデルなだけあって背景は虹色に光る。
それだけならよくあるゲーミングディスプレイなのだが、AG274UXP/11に至ってはなんとロゴプロジェクターまでついている。

AOCの、このフラッグシップモデルに対する熱い思いはひしひしと伝わってくるが、まったくもって不要である。

ちなみに、背面のライティングとロゴプロジェクターはRGBそれぞれ100段階で色の変更も可能なので、最初の10分くらいは楽しく遊べます。

設定からすべてオフにできるので不要な方もご心配なく。

現状最強ディスプレイ

制作現場で使うディスプレイとしては完璧と言っていいくらいのスペックで、それに対する価格では最強と言っても過言ではないと思う

もし120Hz環境が必要ないのであれば、 HUAWEI の MateView もMacbookレベルの色域と広い作業領域が7万円程度で手に入るので非常におすすめ。
ちょっとクセは強めですが。

  • 解像度が4Kよりも縦に広い(デザインツールなどでは使いやすいと思う)
  • VESAマウントに非対応

AG274UXP/11はVESAにも対応してるけど、アームでロゴプロジェクターって相性悪くないのかなあ。