ブログ |

PROMPT → WEB
#009
LUMINOGRAM
CLAUDE CODE × CANVAS 2D / LIGHT & SHADOW

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

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

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

PROMPT → WEB #009

Luminogram

INTRODUCTION

前回(#08)の最後に、次は「影だけで形を見せる表現」と書きました。今回がそれです。

このデモには、物体を描いている行が1行もありません。 葉も、輪も、格子も、画面に描画してはいません。あるのは光源と、光が届いた範囲の計算だけです。にもかかわらず、画面には形がはっきりと見えます。光が届かなかった場所として、そこにあるものが浮かび上がるからです。

下敷きにしたのはフォトグラムという写真技法です。カメラを使わず、印画紙の上に直接ものを置いて光を当てる。光の当たった紙は黒く、ものの下は白く残る。マン・レイがレイヨグラフと呼んで作品にした、写真の最も原始的な形です。

もう一つ、今回は時間を入れました。光源を動かすと、通った跡が紙に焼き付いて残ります。長時間露光です。一瞬の影は形の半分しか教えてくれませんが、動かし続けると、影は流れ、動かないものだけが残ります。 見えているのは現在の影ではなく、光の履歴です。

RESULT

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

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

暗い紙の上に灯りがひとつ。カーソルを動かすと、光の届いた場所から紙が感光していきます。操作は2つです。

しばらく触らずにいると、灯りはひとりでにゆっくり漂いはじめます。放っておいても露光は進み、像は勝手に焼き上がっていきます。左下に露光時間が出ています。ESC または CLOSE で終了します。

WHAT WE MADE

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

HOW IT WORKS

1. 光源から見えている範囲を求める

遮蔽物をすべて線分の集まりとして持ちます。光源から、すべての頂点に向かって光線を飛ばし、最初にぶつかった線分までの距離を求める。それを角度順に並べてつなぐと、光の届く範囲が多角形になります。

for (const s of segs) {
  const a1 = Math.atan2(s.ay - ly, s.ax - lx);
  // 頂点そのものだけでなく、その両脇へも撃つ(±ε)
  angles.push(a1 - EPS, a1, a1 + EPS);
}

±ε の2本が要ります。頂点ぴったりの1本だけだと、光はそこで必ず止まってしまい、物の角を回り込んだ先へ到達できません。 角のわずかに外側へ撃った光線が奥の壁まで抜けて、影の境界が決まります。1行ですが、これが無いと影の形になりません。

もう一つ、画面の四隅へ向かう角度も足しておきます。遮蔽物が何も無い方向では光線が画面の縁で止まるだけなので、四隅を入れないと多角形が閉じません。

2. 半影は「時間」で作る

現実の影の縁がぼけるのは、光源に大きさがあるからです。電球のどの一点から見るかで影の位置がわずかに違い、その重なりが半影になります。

まじめにやるなら、光源の円周上に何十点も取って、そのたびに可視多角形を求めて平均します。当然重い。今回は1フレームに4点だけ、円の中にランダムに散らして焼きます。1フレームでは粗いのですが、露光は蓄積されるので、数十フレームのうちに自然と滑らかな半影になります。

for (let i = 0; i < SAMPLES; i++) {
  const a = Math.random() * TAU, d = Math.sqrt(Math.random()) * r;
  burn(light.x + Math.cos(a) * d, light.y + Math.sin(a) * d, 0.05);
}

これは手抜きの帳尻合わせに見えて、実は題材そのものです。長時間露光とは、時間をかけて光を溜めることでした。 技術的な都合と表現の理屈が一致したので、そのまま採用しています。

3. 一様に塗ると、光に見えない

最初の実装は、可視多角形を紙の色でべた塗りしていました。動くには動くのですが、まったく光に見えません。平らな図形が切り替わるだけです。

原因は明快で、現実の光は距離で弱まるのに、それを無視していたからです。塗りを光源中心の放射グラデーションに変えたら、一度で光になりました。

const g = ectx.createRadialGradient(lx, ly, 0, lx, ly, reach);
g.addColorStop(0, paper);            // 光源のそば
g.addColorStop(1, hexA(paper, 0));   // 遠く

4. 物の足跡は、決して焼かない

灯りが物の上を通り過ぎたとき、そのままだと物の内側まで感光してしまいます。紙に置かれた物の下に光は回り込まないので、毎フレーム、遮蔽物の形を destination-out で未感光に戻しています。

ここで1つ、はまりました。一部の葉だけが黒く抜けない。 位置も形も正しいのに、画面の左側の2枚だけ中途半端に灰色のまま残る。

原因は destination-out の仕様でした。この合成は「塗りのアルファ」の分だけ消します。そして fillStyle には、直前の露光で使った放射グラデーションがそのまま残っていた。 グラデーションは光源から遠いほど透明なので、光源から遠い形ほど消え残る。だから左側だけだったわけです。

ectx.globalCompositeOperation = 'destination-out';
ectx.fillStyle = '#000';   // ← これが無いと、直前のグラデーションで「薄く」消える

見つけ方も書いておきます。目で見ても「なんとなく灰色」としか分からなかったので、葉の中心の画素値を8枚ぶん読み出しました。 6枚が18前後(=未感光)で、2枚だけが68と41。数字にした瞬間に「左側だけ」という規則性が見え、原因に直行できました。画面を睨む時間を、計測に置き換えるのが結局いちばん速いです。

5. 同じ計算を2度しない

画面には、焼き付いた履歴だけでなく「いま光が届いている範囲」も重ねています。最初はこれを別途計算していたのですが、焼き付けのために計算した可視多角形と同じものでした。

焼くときに求めた多角形を配列に取っておき、現在の灯りの描画でも使い回すようにして、1フレームの可視計算を7回から4回に減らしました。

6. 影だけでも、立体には見せられる

ここまでで形は出ますが、最初の版は平らな切り絵のように見えていました。影は正しいのに、物が紙の上に「載っている」感じがしない。足したのは2つだけです。

接地影。 輪郭のすぐ外側に、短く濃い影を落とします。線幅を変えて3回なぞるだけの安上がりなぼかしですが、これで物に厚みが出ます。

縁の光。 灯り側の縁だけを細く明るく抜きます。作り方は、形の内側にクリップしてから、輪郭を灯りと逆方向へ少しずらして描く。ずらした輪郭のうち、灯り側の縁が形の内側に残り、三日月状のハイライトになります。

vctx.save();
traceShape(vctx, poly);
vctx.clip();                                   // 光は形の内側にとどめる
vctx.translate((-dx / len) * push, (-dy / len) * push);  // 灯りと「逆」へずらす
traceShape(vctx, poly);
vctx.stroke();
vctx.restore();

最初は灯りの方向へずらして書いていて、ハイライトが物の裏側に出ました。 クリップで内側に閉じ込める以上、残るのはずらした先とは反対側です。符号ひとつですが、逆にすると立体感が消えて、むしろ平たく見えます。

もう一つ効いたのが色温度の差です。灯りは暖かく、影は冷たく。地の色をわずかに青へ寄せ、縁の光を冷たい銀色にすると、同じ明暗でも奥行きが出ます。絵画で古くから使われてきた手ですが、画面でもそのまま効きました。

最後に、光の中でだけきらめく塵を浮かべ、灯りに光条を立て、紙全体に薄い艶と周辺光量落ちを掛けています。どれも1つずつは地味ですが、重なると空気が写ります。

THE PROMPT

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

あなたはCreative Codingと計算幾何に精通したフロントエンドエンジニアです。
「Luminogram」= 物体を一切描画せず、光が届かなかった場所として形を見せる表現を、
Canvas 2D で実装してください。フォトグラム(印画紙にものを置いて感光させる技法)が下敷きです。
(物理エンジンや幾何ライブラリは使わない。可視多角形は自前で書く)

# 置き換える変数
- PRINTS = 4種類。それぞれ「置くもの」と「調色」を持つ:
    銀     SILVER    地 "#121317" 紙 "#e9eaec" 灯 "#ffd9a4" … 葉を散らす
    セピア SEPIA     地 "#17110b" 紙 "#f0dfc0" 灯 "#ffcf8a" … ガラスの輪と粒
    青写真 CYANOTYPE 地 "#0b1622" 紙 "#cfe2f2" 灯 "#ffe4bb" … 格子
    鉄     IRON      地 "#14120f" 紙 "#ded6c8" 灯 "#ffd6a0" … 破片と輪
- LABEL = "LUMINOGRAM"

# 遮蔽物
- 多角形の配列として持つ。座標は 0..1 の正規化(リサイズで構図が崩れないように)
- 【重要】遮蔽物そのものは絶対に描画しない。影としてのみ現れること
- 形は葉・円・中空の輪・細い棒などを関数で生成する。1シーンあたり線分150〜200本を上限の目安に

# 可視多角形(光源から見える範囲)
- 遮蔽物を線分の集合に展開する
- 各線分の両端点への角度に加え、その両脇 ±0.00012 の角度へも光線を飛ばす
  (±εが無いと物の角を回り込めず、影の形にならない)
- 画面の四隅への角度も足す(遮蔽の無い方向で多角形が閉じないため)
- 角度でソートし、各光線について最も近い交点までの距離を求め、点を順に結ぶ
- 画面の枠でも光線を止める

# 露光(ここが主題)
- 蓄積用のオフスクリーンcanvasを1枚持つ。毎フレーム、可視多角形を薄く塗り重ねる
- 塗りは source-over(lighter は白に飛ぶ。source-over なら紙の色に収束する)
- 【重要】可視多角形は一様に塗らない。光源中心の放射グラデーションで塗り、
  距離で減衰させる。一様塗りだと光に見えず、ただの図形になる
- 光源は点ではなく小さな円とみなし、円内に散らした数点(4点程度)を毎フレーム焼く。
  重なるうちに影の縁が半影になる(面光源を「時間」で解く)
- 毎フレームの焼き付けのあと、遮蔽物の形を destination-out で未感光に戻す。
  【罠】destination-out は塗りのアルファで消すので、fillStyle を不透明色に
  戻してから塗ること。直前のグラデーションのままだと遠い形ほど消え残る
- 焼き付けで求めた可視多角形は、画面に重ねる「現在の灯り」の描画にも使い回す
  (同じ計算を2度しない)

# 描画
- 画面 = 地の色 → 蓄積canvas → 現在の灯り → 立体の手がかり → 塵 → 灯り本体 → 艶 → 粒子
- 現在の灯りは弱めに。強くすると焼き付いた像が消え、3D CGのような絵になる
- 【立体】接地影: 遮蔽物の輪郭を、線幅を変えて3回、地の色で薄くなぞる(安上がりなぼかし)
- 【立体】縁の光: 形の内側にクリップし、輪郭を灯りと「逆」方向へ少しずらして描く。
  灯り側の縁が内側に残り三日月状のハイライトになる。
  【罠】灯りの方向へずらすと、ハイライトが物の裏側に出て、かえって平たく見える
- 【色温度】灯りは暖色、地と縁の光は寒色に寄せる。同じ明暗でも奥行きが出る
- 【空気】光の当たる範囲にだけ塵を浮かべ、灯りからの距離で明るさと瞬きを変える。
  粒ごとにグラデーションを作らず、32px程度のスプライトを1枚焼いて drawImage で使い回す
- 【灯り】芯の白熱に加え、レンズのような十字の光条を2本(横を長く、縦を短く)
- 【艶】紙全体に斜めの淡いハイライトと、周辺光量落ち(vignette)を薄く掛ける
- 粒子(グレイン)は 168px 程度のノイズタイルを1枚作り、overlay で薄く敷く
- 隅に小さく「印画 NN / 調色名 / 露光 00:00」を置く(等幅でも明朝でもない、細いサンセリフで)
- ネオン・rainbow・紫グラデ・glassmorphism は使わない

# インタラクション
- ポインタを動かすと灯りが追う(補間して滑らかに)
- クリック/タップで次の印画へ(置くものと調色が同時に変わり、露光はリセット)
- 1.6秒ほど操作が無ければ、灯りをゆっくり漂わせて露光を続ける(長時間露光)
- 開幕は無地だと寂しいので、光を一周漂わせた助走を先に焼いてから始める

# 品質要件(production)
- 全画面オーバーレイ。CLOSE ボタンと ESC で終了。多重起動を防ぐ
- レスポンシブ: リサイズでキャンバスを作り直す。
  【重要】作り直したら、それまでの露光を拡大コピーして引き継ぐこと(作り直した瞬間に像が消える)
- devicePixelRatio は最大2でクランプ。モバイルは1フレームの焼き付け点数を減らす
- prefers-reduced-motion: アニメーションせず、十分に露光した1枚を描いて静止させる
- 終了時に requestAnimationFrame を停止し、全リスナを解除し、CanvasをDOMから除去する
- Canvas 2D が使えない環境では静かに何もしない。本文の閲覧を妨げない

CUSTOMIZE

PERFORMANCE

今回のコストは、ほぼすべて遮蔽物の線分数で決まります。光線の数が線分数に比例し、その1本ごとに全線分との交点を調べるので、素直に書くと線分数の2乗で効いてきます。

WHAT AI DID / WHAT HUMAN DIRECTION MATTERS

可視多角形は、計算幾何では教科書に載っている題材です。「Canvasで光源から見える範囲を角度走査で求めて」と言えば、動くものはすぐ出てきます。 ±εの3本撃ちも、画面の枠での打ち切りも、AIは知っています。ここは完全にAIの領分でした。

その上で、人間が決めたのは次のような点です。

最後の一つは、AIに任せきりにできない部分の典型でした。バグの症状を言葉で説明してAIに投げても、返ってくるのは一般論です。どの数字を読めば規則性が出るかを決めるのは、まだ人の仕事です。 8枚の葉の中心を読む、と決めた時点で答えは出ていました。

9回続けて、実装の速さはもう問題ではなくなりました。残っているのは、何を作るかを決めること、そして出てきたものが良くない理由を言葉にすることです。「なんか嘘っぽい」を「距離で減衰していない」に翻訳できるかどうか。そこだけは、手が速くなっても短縮されません。

NEXT

粒子、文字、線、物理、計算、製版、音、分割、そして今回の光と影。

次回は、幾何の証明が組み上がっていく線か、スクロール連動あたりを考えています。

今回のプロンプトも丸ごとコピーして使えます。置くものを自社のロゴの輪郭に差し替えれば、光を動かすうちに社名が浮かび上がる1枚になります。描かずに見せるというのは、なかなか贅沢な表現です。

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

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

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