zpaper-draft

Type to search...

to open search from anywhere

padding-bottomアスペクト比テクニックが画像にモアレを発生させる

概要

Takazudo ModularのWebサイトをNext.jsからAstro 6に移行する作業中、商品一覧ページの画像にモアレ(波状のアーティファクト)が発生する問題に遭遇した。特に、細かい水平線パターンを持つ製品画像で顕著だった。

最初は<img>sizes属性の不一致が原因だと考えたが、修正しても解消せず、最終的にpadding-bottomによるアスペクト比テクニックがサブピクセルレンダリングの不整合を引き起こしていたという結論に至った。Claude Codeとのデバッグのやりとりをそのまま残した記録。途中でClaude Codeの説明が間違っていたことも含めて。

背景

Takazudo Modularのサイトは商品一覧ページにグリッドレイアウトで商品画像を並べている。Next.jsからAstroへの移行中、Astro版のプレビューで画像にモアレが出ていることに気づいた。

商品一覧ページの比較 — 左がプレビュー、右がプロダクション

左がAstro版(プレビュー)、右がNext.js版(プロダクション)。細かい横線パターンを持つIromihon-ACRなどの製品画像で、プレビュー側にモアレが出ている。

最初の仮説: sizesの不一致

Issue #917でこの問題を報告し、最初の分析を行った。

商品グリッドの画像は実際には150〜220px程度で表示されている。ところが、<img>のデフォルトのsizes属性が以下のようになっていた。

(min-width: 1000px) 1000px, (min-width: 800px) 800px, (min-width: 600px) 600px, 100vw

ブラウザはこのsizesを見て600〜1200pxの画像を取得し、それを150〜220pxに3〜8倍ダウンスケールしていた。これが原因でモアレが出ているのではないかと考え、以下の修正を行った。

  • product-grid-item.astroに正確なsizesを追加: sizes="(min-width: 1536px) 220px, (min-width: 1280px) 190px, (min-width: 768px) 170px, 150px"
  • image-rendering: high-quality CSSを追加
  • ホームページの商品グリッドにもvwベースのsizesを追加

Takazudo

not resolved. continue debugging(解決してない。デバッグ続けて)

Claude Code

スクリーンショットを確認すると、まだモアレが残っていた。sizesの修正だけでは不十分だった。

computed styleの比較

sizesの修正では解決しないということで、もっと根本的な違いを探ることにした。Playwrightスクリプトでプロダクション(Next.js)とローカル(Astro)のcomputed styleを比較した。

プロダクション(Next.js)の商品画像:

{
  "objectFit": "fill",
  "position": "static",
  "paddingBottom": "0px"
}

ローカル(Astro)の商品画像:

{
  "objectFit": "cover",
  "position": "absolute",
  "paddingBottom": "188px"
}

ここで根本的な差異が見えた。

  • プロダクション: 画像は通常のフロー要素として、親のaspect-squareaspect-ratio: 1/1)コンテナ内でレンダリング
  • Astro: padding-bottomテクニック + position: absolute + object-fit: coverでレンダリング

padding-bottomテクニックというのは、aspect-ratioプロパティがなかった時代のアスペクト比維持の手法。padding-bottom: 100%で正方形のスペースを確保し、その中にposition: absoluteで画像を配置する。

Playwrightキャプチャの比較

プロダクションのGlassCyan-L — モアレなし

ローカルのGlassCyan-L — モアレあり

プロダクション(Next.js)では横線パターンがクリーンに表示されているのに対して、Astro版では同じ画像に波状のモアレアーティファクトが発生している。

:::info 備考

この調査中、blurhashプレースホルダーが4x4解像度で非常にぼやけているという別の問題も発見した。

ブランドページのぼやけたblurhashプレースホルダー

これはモアレとは別件で、別途対応した。

:::

修正: RESPONSIVE_IMAGE_COVER_CLASS

RESPONSIVE_IMAGE_COVER_CLASSを商品グリッドのResponsiveImageに適用した。このクラスには!pb-0padding-bottom: 0 !important)が含まれており、padding-bottomテクニックを無効化する。画像は親のaspect-squareコンテナのaspect-ratio: 1/1に任せてサイズが決まる。

修正後、Playwrightで再度キャプチャして確認。

修正後の検証 — プロダクション

修正後の検証 — ローカル(修正済み)

モアレが消失した。プロダクション(Next.js)と同等のレンダリング品質になっている。

なぜこの2つのレンダリング方法で結果が違うのか?

修正自体はできたが、「なぜpadding-bottomテクニックだとモアレが出て、aspect-ratioだと出ないのか」というブラウザの内部的な問題について、Claude Codeと議論した。以下はそのやりとり。

Takazudo

got it! totally, what was the cause for this?(なるほど、結局何が原因だったの?)

Claude Code

原因はpadding-bottomテクニックの組み合わせ。padding-bottom: 100% + position: absolute + object-fit: coverだと、画像がGPUコンポジティングレイヤーに昇格し、GPUのbilinear interpolation(2x2カーネル)でダウンスケーリングされる。これがモアレを引き起こしていた。修正後は通常のフロー要素としてh-fullで親に従うので、CPUベースのより高品質なリサンプリングが使われる。

Takazudo

why do these 2 diff bring the problem? about browser technically question. I’ve once faced it when we do GPU enabled. but these 2 looks same… sounds same(なんでこの2つの違いが問題を引き起こすの? ブラウザの技術的な話として。GPU有効化で同じ問題にあたったことはあるけど、この2つは同じに見えるんだよな。同じに聞こえる)

Claude Code

absolute positioning + opacity transitionの組み合わせでGPUレイヤーに昇格する。修正前はposition: absoluteがあったので GPU compositingが有効になり、修正後はposition: staticなのでGPUレイヤーには昇格しない、という差異がある。

Takazudo

hum? what was the key of these diff? I feel that we see opacity fading in after this fix. so both enables GPU rendering…?(ふーん? この差の鍵は何? 修正後もopacityのフェードインは残ってるはずだけど。だったら両方ともGPUレンダリングが有効なのでは…?)

Claude Code

…確かにその通り。opacity transitionは修正前も修正後も存在している。GPUコンポジティングの説明は間違っていた。両方ともopacity transitionがあるので、両方ともGPUレイヤーに昇格しているはず。GPU説では修正前後の差異を説明できない。

Takazudo

hum. even if we use opacity, absolute positioning makes GPU on, right?(ふーん。opacityを使ってても、absolute positioningでGPUが有効になるんだよね?)

Claude Code

実はabsolute positioning単体ではGPU compositingをトリガーしない。will-changetransformopacity transition/animationなどがトリガーになる。position: absolute自体は通常のレイヤーに留まる。

Takazudo

sounds same(同じに聞こえる)

Claude Code

確かに。両方ともopacity transitionがあるので、GPUの有無は修正前後で変わらない。GPU説は成立しない。

ここでWeb検索で調査した。

サブピクセルレンダリング仮説

Web検索の結果、サブピクセルレンダリングの不一致が最も有力な仮説として浮上した。

padding-bottomテクニックの場合

padding-bottom: 100%はcontaining blockのから計算される。CSSの仕様上、percentage paddingは幅を基準にする。例えばcontaining blockの幅が187.33pxだった場合、padding-bottom: 100%は187.33pxになる。

ここでブラウザのピクセルスナッピングが入る。padding値とabsolute positioned要素の寸法に対して、ブラウザが異なるピクセルスナッピングルールを適用する可能性がある。paddingは188pxにスナップされるが、absolute positioned childは187pxにスナップされる、というような微小なズレ。

この微小なズレ(0.33px程度のサブピクセルの不一致)が、規則的なパターン(横縞)に対してモアレを発生させる。モアレはそもそも2つの規則的パターンの微小なズレによって生じる干渉縞なので、サブピクセルレベルの不整合でも十分に発生しうる。

aspect-ratio + h-fullの場合

h-full + 親のaspect-ratio: 1/1では、親コンテナの寸法は既にレイアウト段階でピクセルスナップ済み。子要素のheight: 100%は親の計算済み高さ(整数値)を直接継承するため、paddingの介在がなく、サブピクセルの不一致が発生しない。

整理

方法高さの決定サブピクセルリスク
padding-bottom: 100% + absolutewidthからのpercentage計算 → padding → absolute childpadding値とchild寸法で異なるスナッピングが起きうる
aspect-ratio: 1/1 + h-full親の計算済み寸法を直接継承単一の値を継承するだけなので不整合が起きにくい

続報: RESPONSIVE_IMAGE_COVER_CLASSだけでは不十分だった

上記の修正をプッシュしてNetlifyのプレビューデプロイで確認したところ、モアレがまだ残っていた。ローカルのPlaywrightでは解消したように見えたが、実際のデプロイでは再現していた。

再度Playwrightでcomputed styleを比較し直した結果、より根本的な原因が判明した。

真の原因: プロダクションの「2段階レンダリング」

プロダクションのNext.js版ResponsiveImageClientは、ステートフルなレンダリングを行っていた。

ローディング中(isLoaded = false):

// picture: absolute配置
<picture className="absolute inset-0 block w-full z-[2]">
// img: absolute + object-cover + opacity-0
<img className="absolute inset-0 block w-full h-full object-cover opacity-0" />

ロード完了後(isLoaded = true):

// picture: 通常のフロー要素
<picture className="block w-full">
// img: 通常のフロー要素 + opacity-100
<img className="block w-full h-auto opacity-100" />

つまりプロダクションでは、画像がロードされた時点でposition: absoluteposition: staticobject-fit: coverobject-fit: fillに遷移していた。最終的なレンダリング状態はフロー要素としての描画になる。

一方、AstroのSSR同期スタブ(responsive-image-sync.tsx)はクライアントサイドの状態遷移がないため、ローディング状態のHTML(absolute + object-cover)がそのまま最終出力になっていた。RESPONSIVE_IMAGE_COVER_CLASS!pb-0を適用しても、<img>要素自体のposition: absoluteobject-fit: coverはコンポーネント内部でハードコードされていたため、上書きされていなかった。

本当の修正: aspect-ratio + フローレイアウト

コンポーネント自体のレンダリング手法を変更した。

修正前:

const containerStyle = { paddingBottom: `${aspectRatio}%` };
return (
  <span style={containerStyle}>
    <picture className="absolute inset-0 block w-full z-[2]">
      <img className="absolute inset-0 block w-full h-full object-cover opacity-0" />
    </picture>
  </span>
);

修正後:

const containerStyle = { aspectRatio: `${w} / ${h}` };
return (
  <span style={containerStyle}>
    <picture className="block w-full">
      <img className="block w-full h-auto opacity-0" />
    </picture>
  </span>
);

変更点:

  • コンテナ: padding-bottom → CSS aspect-ratioプロパティ
  • <picture>: absolute inset-0 を除去 → 通常のフロー要素
  • <img>: absolute inset-0 object-cover を除去 → block w-full h-auto

SSRの出力が、プロダクションのロード完了後の状態と同じフローレイアウトになる。Astroにはクライアントサイドの状態遷移がないので、最初から完了状態をレンダリングするのが正解だった。

まとめ

  • padding-bottomアスペクト比テクニックは古くからある手法だが、画像のダウンスケーリング時にモアレを引き起こす可能性がある
  • CSS aspect-ratioプロパティが使える現代のブラウザでは、padding-bottomテクニックは避けたほうが良い
  • sizes属性の修正だけでは解決しなかった。Playwrightでcomputed styleを比較してようやく根本原因が見えた
  • GPUコンポジティングが原因という仮説は、opacity transitionが修正前後で変わらないことから否定された
  • サブピクセルレンダリングの不一致が有力な仮説だったが、最終的にはそれも不十分だった
  • 真の原因: プロダクションのNext.js版はクライアントサイドでabsolute → staticに遷移する2段階レンダリングだった。AstroのSSRスタブはこの遷移がないため、永続的にabsolute + object-coverでレンダリングされていた
  • 最終的な修正: コンポーネント自体をpadding-bottom + absoluteからaspect-ratio + flow layoutに書き換え、SSR出力がプロダクションのロード完了後の状態と一致するようにした
  • RESPONSIVE_IMAGE_COVER_CLASS!pb-0)による呼び出し側の修正だけでは不十分で、コンポーネント内部のレンダリング手法の変更が必要だった