ブログ |

PROMPT → WEB
#006
HALFTONE PRESS
CLAUDE CODE × CANVAS 2D / HALFTONE SCREENING

そのWeb演出、Claude Codeで作れます。クリエイティブWeb表現のプロンプト集 #06 Halftone Press

株式会社グルーヴサインの米沢です。

AIコーディングでクリエイティブなWeb表現を実際に作り、そのプロンプトごと公開していく「PROMPT → WEB」の第6回です。

PROMPT → WEB #006

Halftone Press

INTRODUCTION

ここまでの5回は、ずっと何を描くかの話でした。粒子(#01)、文字(#02)、線(#03)、物体(#04)、そして計算そのもの(#05)。

今回はどう刷るかの話です。

印刷は長いあいだ、原理的に不可能なことをやってきました。インクは「乗っているか、いないか」しかありません。中間の濃さを出せないメディアで、写真のなめらかな階調をどう再現するか。19世紀に出た答えが網点です。点の大小に置き換えれば、離れて見たときに濃淡に見える。いまも新聞・雑誌・パッケージのほとんどが、この方式で刷られています。

やることは、その製版処理をブラウザの中で毎フレーム回すことです。ただし今回は、ひとつ条件を足しました。原画を画像として持たないこと。 写真ファイルもピクセル配列も用意せず、「その座標の明るさ」を返す関数だけを置きます。網点1個を打つたびに、その1点の濃度だけを聞きに行く。

題材は、一点に集まる布のドレープにしました。カーソルが光源です。

RESULT

まず現物です。下のボタンを押してみてください。

クリックすると、この画面の上で Halftone Press が再生されます(何度でも再生可能)。ESC または CLOSE で終了します。

生成りの紙に、藍・紅・黄の3色の網点でドレープが刷られています。操作は2つです。

近づいて点の並びを見ると、3色の網点が花のように渦を巻いているのが分かります。ロゼッタと呼ばれる模様で、印刷物をルーペで覗くと必ず出てくるものです。これは狙って出しています(後述)。

左下に、いまどの方式で何度の角度で刷っているかが出ています。ESC または CLOSE で終了します。

WHAT WE MADE

技術的に分解すると、次のようになります。

HOW IT WORKS

1. 原画を、画像として持たない

素直に作るなら、原画をオフスクリーンCanvasに描いて getImageData で読み、そのピクセルを見ながら点を打ちます。実際、最初にAIが書いてきたのもその形でした。

しかしよく考えると、必要な濃度は網点の数だけです。1920×1080の画面でも、打つ点は2万個弱。207万画素を全部作ってから2万点を拾うのは、200万回の無駄です。

そこで原画を関数にしました。

// 画像ではなく「その座標の明るさ」を返す関数が原画
const tone = (x, y) => { /* … 陰影を計算して 0〜1 を返す … */ };

const a = inkOf(tone(x, y), plane);   // 網点1個ぶんだけ、その場で評価する

実測すると、1フレームで評価する点は 20,343点。画面の画素数の 約1% です。副産物として、原画に解像度の概念が無くなりました。網点を細かくすれば、いくらでも細かい原画が出てきます。

2. 高さは要らない。傾きだけが要る

陰影を計算するには面の向き(法線)が要ります。ふつうは高さマップを作って、隣との差から法線を求めます。

ですが今回の布は、高さが sin の和で書けます。和の微分は微分の和なので、傾きは cos の和で直接書けます。つまり高さを一度も計算せずに、必要な傾きだけが手に入ります。

const u  = ang * FOLDS + 0.6 * Math.sin(inner) + T * 0.28;  // ひだの位相
const su = Math.sin(u), cu = Math.cos(u);
// h = A*r*sin(u) を px, py で微分した式をそのまま書く
const gx = 0.058 * (dxp * ir * su + cu * (-FOLDS * dyp * ir + wc * dxp)) + 0.50 * c2;
const gy = 0.058 * (dyp * ir * su + cu * ( FOLDS * dxp * ir + wc * dyp)) - 0.036 * c2;

ひだの位相を「ある一点を見込む角度」にしているのがポイントです。こうすると、ひだが一点に集まって放射状に広がり、その点に近いほど間隔が詰まります。カーテンを1か所で束ねたときの、あの形になります。

3. なぜ版ごとに角度をずらすのか

ここが今回いちばん面白かったところです。

3つの版を同じ角度で刷ると、点がぴたりと重なって色が濁ります。では少しずらせばいいかというと、逆です。試しに 15° / 20° / 25° にして刷ってみたところ、画面いっぱいに大きな波打つ縞が出ました。格子と格子のわずかな周期差が、低い周波数の唸りとして見えてしまう。これがモアレです。

対策が、30°ずつ大きく離すという古典的な答えです。周期差を大きくすると、唸りの周波数も高くなって網点の細かさに紛れ、代わりに花のような小さな渦(ロゼッタ)が残ります。ロゼッタは「モアレを、目に見えないほど細かいモアレに追い込んだ結果」です。

実装は、版ごとに傾けた格子を走査するだけです。

// 画面の4隅を回転座標へ移し、走査すべき格子の範囲を求める
const i0 = Math.floor(rx0 / cell) - 1, i1 = Math.ceil(rx1 / cell) + 1;
…
const x = rx * co - ry * sn;      // 格子点を世界座標へ戻す
const y = rx * sn + ry * co;
if (x < -cell || x > W + cell || y < -cell || y > H + cell) continue;  // 画面外は捨てる

回転した格子で画面全体を覆うには、回転前より広い範囲を回す必要があります。はみ出した点は座標を戻した時点で捨てています。捨てる判定は原画の評価より手前に置くこと。ここを逆にすると、見えない点の濃度まで計算することになります。

4. 色は、分解のカーブが作っている

原画はモノクロです。明るさが1つ返ってくるだけで、色の情報はどこにもありません。色は、明るさを3版へ振り分けるカーブが作っています。

let t = 1 - v;                     // インクの総量
t = t * (0.30 + 0.70 * t);
if (plane === 0) a = t * t * t * 1.15;   // C … 遅く入る → 影が青黒くなる
else if (plane === 1) a = t * t * 0.98;  // M … 中間調
else a = t * 0.70;                       // Y … 早く入る → 明部が暖かくなる

黄を早く、藍を遅く入れています。それだけで、明部は紙の色に近い暖かいクリーム、中間調はオレンジから赤、暗部は青黒く沈みます。同じ原画から、分解カーブを差し替えるだけで色調が全部変わります。 写真の色を再現するのではなく、色を作りにいくという意味では、リソグラフや版画の考え方に近いです。

黒版(K)は用意しませんでした。3色を重ねれば十分に黒くなるうえ、版が1つ減れば計算も1/4減ります。「黒は黒インクで刷るもの」という前提を外せたのは、実際に重ねて見てからです。

5. 同じ格子・同じ濃度、マークだけを差し替える

4つの製版方式は、別々に実装したわけではありません。走査する格子も、そこで得た濃度も共通で、最後に打つマークだけが違います。

製版方式というと大げさですが、濃淡を離散的なマークに置き換える方法の違いでしかない、というのがコードにするとよく分かります。ここが今回いちばん気持ちよかった部分です。

6. 砂目だけ、原画の評価を1回にまとめられる

砂目(FM)はスクリーン角度を持ちません。点の位置が不規則なので、そもそも格子の周期がなく、原理的にモアレが出ない方式です。

これは実装上のご褒美でもありました。3版とも角度が0なら、3つの格子は完全に一致します。ということは、同じ座標の原画を3回評価しているわけです。1回にまとめて、その場で3版へ振り分けました。

const v = tone(x, y);              // 原画の評価は1点につき1回
for (let p = 0; p < 3; p++) {
  const a = inkOf(v, p);
  if (a < 0.012 || a * 1.25 < hash2(i, j, p + 1)) continue;
  …
}

これで 6.50ms → 2.69ms。網点と万線は版ごとに格子の向きが違うので、この手は使えません。

7. 文字版は、焼いてから貼る

文字だけは毎フレーム fillText を数千回…とやると一気に破綻します。起動時に10種類のグリフを小さなCanvasへ1度だけ焼いて、あとは drawImage で貼るだけにしました。文字の描画がただの画像コピーになります。

なお文字版は3版ではなく単版(墨)です。3色で文字を重ねると、単純に読めなくなりました。ここは実際に刷ってから引き返した部分です。

THE PROMPT

ここが本題です。上のデモを作るためにClaude Codeへ渡せる、汎用プロンプト全文を掲載します。右上の COPY ボタンでそのままコピーできます。色や題材を置き換えれば、自分用になります。

あなたはCreative Codingと印刷製版に精通したフロントエンドエンジニアです。
「Halftone Press」= 画面全体を印刷の網点で刷り上げるWeb表現を、Canvas 2D で実装してください。
【最重要】原画をラスタ画像として持たないこと。「座標(x,y)を渡すと明るさ0〜1を返す関数」を
原画とし、網点1個を打つたびに、その1点だけを評価すること。
(getImageDataで全画素を作ってから点を拾う実装にはしない)

# 置き換える変数
- PAPER_COLOR = "#d7d2c4"    // わずかにグレーがかった紙
- INKS = 3版。名前・色・スクリーン角度:
    C 藍 "#2f7fa8" 15°
    M 紅 "#c8506a" 75°
    Y 黄 "#e3c46a"  0°
  (黒版Kは作らない。3色の重ね刷りで黒を出す)
- SUBJECT     = 一点に集まる布のドレープ(陰影のある連続階調なら何でもよい)
- LABEL       = "HALFTONE PRESS"

# 原画(関数として持つ)
- tone(x, y) は、その点の明るさ 0〜1 を返す
- 布のひだ: 高さを sin の和で定義し、【重要】高さは計算せず、微分(cos の和)だけを求める。
  陰影に要るのは高さではなく傾きなので、これで足りる
- ひだの位相は「画面外の一点を見込む角度」に比例させる。ひだが放射状に集まり、
  その点に近いほど間隔が詰まる
- 法線と光源方向の内積で拡散光、その16乗で光沢(pow は使わず2乗を4回)
- 光源はポインタ位置。周辺光量を軽く落とす(刷り物らしい落ち)

# 製版(ここが主題)
- 版ごとに、スクリーン角度だけ傾けた格子を走査する
  ・画面の4隅を回転座標へ移して走査範囲を出す
  ・格子点を世界座標へ戻し、画面外なら【原画を評価する前に】捨てる
- 明るさ → 各版のインク量は「分解カーブ」で決める。原画はモノクロで、色はこのカーブが作る
  ・黄は早く入れる(明部が暖かくなる)/藍は遅く入れる(暗部が青黒く沈む)
- 1つの版に打つ数千個のマークは Path2D 1本にまとめ、fill() は版につき1回だけ呼ぶ
- インクの重なりは globalCompositeOperation = 'multiply'(減法混色になる)
- 紙のざらつきは、小さなノイズタイルを1度だけ作って createPattern で敷く

# 4つの製版方式(クリック/タップで循環)
【重要】格子と濃度は共通にし、打つマークだけを差し替える構造にすること
1. 網点(AM)  … 半径が √濃度 に比例する円(面積が濃度に比例する。半径に比例させない)
2. 万線(LINE)… 格子の向きに伸びる帯を濃度ぶん太らせる。隣とつながって線幅が濃淡になる
3. 砂目(FM)  … 大きさ固定の点。格子番号のハッシュを閾値にして打つ/打たないで濃さを出す。
               角度を持たない=モアレが出ない。3版の格子が一致するので、
               【最適化】原画の評価は1点1回にまとめ、その場で3版へ振り分ける
4. 文字(TYPE)… 濃度順のグリフ列から1つ選んで貼る。単版(墨)でよい。
               【最適化】起動時にグリフを小さなCanvasへ焼き、以後は drawImage で貼る

# スクリーン角度の注意
- 3版の角度は 30° 程度ずつ大きく離すこと。数度しか離さないと、格子の周期差が
  低周波の唸り(モアレ)として画面いっぱいに出る。大きく離すとロゼッタになる
- 実装後、角度を 15/20/25 に変えて刷り比べ、モアレが出ることを自分で確認すること

# 演出
- 四隅にトンボ(十字+輪)を版の色で描く。版がずれると最初にここが割れて見える
- 方式を切り替えるたびに版を数px ずらし、毎フレーム 0.9 倍して見当が合っていく
- 隅に刷り出しの表示(方式名・角度・網点ピッチ)と、3版の色玉を小さく置く
- ネオン・紫グラデ・glassmorphism・黒背景は使わない(紙とインクの質感にする)

# 品質要件(production)
- 全画面オーバーレイ。CLOSE ボタンと ESC で終了。多重起動を防ぐ
- レスポンシブ: リサイズで格子を組み直す。devicePixelRatio は最大2でクランプし、
  以降の描画はすべて CSS px で行う
- 網点ピッチは画面の短辺に比例させ、上下限で頭打ちにする(大画面で点が増え続けないように)
- モバイル: ポインタが無いので光源をゆっくり自動で回す。タッチで方式を切り替える
- prefers-reduced-motion: アニメーションせず、1枚だけ刷って静止させる
- 終了時に requestAnimationFrame を停止し、全リスナを解除し、CanvasをDOMから除去する
  (何度開閉してもリークしないこと)
- Canvas 2D が使えない環境では静かに何もしない。本文の閲覧を妨げない

CUSTOMIZE

PERFORMANCE

全画面に毎フレーム2万個のマークを打つので、素直にやると重い題材です。今回も実測しながら決めました(1920×1080・網点ピッチ18px・計算部分をNodeで実測)。

WHAT AI DID / WHAT HUMAN DIRECTION MATTERS

「Canvasでハーフトーンを実装して」と言えば、動くものはすぐ出てきます。今回もそうでした。ただ、最初に出てきたものは印刷ではありませんでした。

出てきたのは、画像をグレースケールにして、明るさに応じた大きさの黒い円を格子状に並べるコードです。動きます。ハーフトーン風にも見えます。しかし、

つまり「網点っぽい絵」は書けても、「印刷」は書いていない。ここは知識の質の差というより、何を再現したいのかを誰が決めるかの差だと思います。

人間の仕事として残ったのは、次のような判断でした。

最後の2つは、性能の話でも正しさの話でもありません。「そう作ると何が言えるか」の話です。格子と濃度を共通にしたからこそ、製版方式の違いはマークの違いでしかないと示せる。ここはコードを書く前に決めておく必要があり、いまのところAIが勝手に決めてくれる部分ではありません。

6回続けて、感触は変わっていません。実装は速くなりました。速くなったぶん、何を捨て、何を並べるかに時間を使えるようになった。それだけです。

NEXT

粒子、文字、線、物理、計算、そして今回の製版。「描く」「育てる」に続いて、今回は「刷る」でした。

次回はまだ決めていませんが、音(Web Audio)、スクロール連動、ボロノイ分割あたりを候補にしています。

今回のプロンプトも丸ごとコピーして使えます。インクの色を自社のブランドカラー2色に置き換えれば、それだけでリソグラフ風の表現になります。画面をルーペで覗くように近づいて、点の渦を確かめてみてください。あれが、150年ぶん積み上がった工夫の形です。

レガシーPHPシステムの保守・引き継ぎのご相談を受け付けています。
初回のご相談・ヒアリングは無料です。

無料相談・お問い合わせはこちら

株式会社グルーヴサイン 代表取締役 米沢忠司。PHP開発歴約20年。他社開発システムの保守・引き継ぎを主力に、2007年の設立から19期目。