「私は人間ではありません」から始まる、人間らしさの検査装置を作った話

AIになろうとするほど、人間であることが明らかになる。
そんな逆説を体験するWeb作品「HUMAN CAPTCHA」を作っています。

白い画面の中央に reCAPTCHA 風のチェックボックスがあり、ラベルは「私は人間ではありません」。背景には淡い青の粒子が散っている

この記事では、作品を思いついた背景から、体験設計、Ghostという概念、WebGLによる表現、AIエージェントを使った開発までを振り返ります。

人間らしさは、どこに残るのか

生成AIが急速に進化し、文章を書くことも、絵を描くことも、コードを書くことも、人間だけの行為ではなくなってきました。
自分自身もエンジニアとして日常的にAIを使っていて、以前は自分の手で行っていたことを、どんどんAIに任せるようになっています。

便利になる一方で、ふと考えました。
人間とAIの境界が曖昧になったとき、自分が人間であることは何によって確かめられるのだろう。

そこで思いついたのが、CAPTCHAの反転です。
普通のCAPTCHAでは、人間が「I’m not a robot」と宣言します。
では逆に、「I’m not human」と宣言して、人間がAIであることを証明しようとしたらどうなるのか。

これがHUMAN CAPTCHAの出発点でした。

AIになろうとすると、人間性が漏れ出す

HUMAN CAPTCHAでは、架空のAI認証テストに挑戦してもらいます。
正確にクリックする。一定の時間で反応する。同じ動きを再現する。迷わず選択する。
求められるのは、正確さ、速さ、一貫性、再現性といった、作品の中で設定された理想的な機械の振る舞いです。

しかし、人間が操作すると必ず小さな揺らぎが生まれます。
クリック位置がわずかにずれる。マウスが行き過ぎて戻る。反応するまでに間が生まれる。入力した文字を消して打ち直す。

当初は個々の検証結果から人間らしさを測る構想でしたが、設計を進めるうちに、正解・不正解よりもその過程にこそ面白さがあると感じるようになりました。
そこで、クリックの結果だけでなく、マウスの軌跡、ホバー時間、修正動作、スクロール、操作していない時間なども観測対象にしています。

ユーザーがテストされていると思っていない瞬間にも、その人らしい振る舞いは現れます。
普段のWebサービスではノイズとして扱われるものを、この作品では主役にしたかったのです。

判定そのものは「アクセス許可」か「認証に失敗しました」の2つだけで、しきい値はかなり厳しくしてあります。人間がなんとなくで通ることは、まずありません。

30種類の検証。でも、30個のミニゲームにはしない

検証パターンは30種類を設計しました。
クリック精度、直線を描く操作、タイミング、反応速度、タイピング、記憶、曖昧な二択、何もしないという指示など、いろいろな方向から行動を観測します。

ただ、30個のミニゲームを順番に遊ばせたいわけではありません。
目指したのは、ひとつの認証システムが、異なる方法でユーザーを観測している感覚です。
そのため共通のUIを維持して、テストごとに変えるのは基本的に短い指示と操作領域だけにしています。

1回のセッションですべてを出題するのではなく、6〜7種類を組み合わせます。
運動、時間、判断、注意といった異なるカテゴリから観測できるようにして、単一のテストだけで結論が出ない構造にしました。
ここで得られる行動の特徴を、作品内では Human Signal と呼んでいます。

生のポインタ軌跡や入力した文字は保存していません。
手元で precisionError、motorNoise、hesitation のような正規化した特徴量に変換して、それだけを localStorage に貯めています。バックエンドもありません。

Ghostは、一度のテストでは現れない

設計中に大きく変えたのが、Ghostの出現条件です。

最初は、一連の検証を終えるとGhostが現れる構想でした。
でもそれでは、一度のゲームをクリアして結果を見るだけの体験になってしまいます。
表現したかったのは、もっと曖昧で、簡単には証明できない存在です。

そこで、Ghostを複数のセッションにまたがって観測する仕様に変えました。
初回の認証では、ユーザーはAIとしての検証に失敗します。ただ、何か説明できない痕跡が残ります。
もう一度検証に挑戦し、前回と似た揺らぎが異なる状況でも観測されたとき、初めて痕跡がつながります。

2回目以降のセッションには、前回と比べるための検証を1〜2問必ず混ぜ、残りはまだ観測が足りていない Human Signal を拾える検証から優先して選んでいます。
初回の挑戦では絶対にGhostを出さず、前回までと今回の分を合わせた証拠がしきい値を超えたときだけ、失敗画面のあとにGhostが現れます。

最初の実装では、この過程を UNKNOWN RESIDUAL DETECTED、TRACE MATCHED、GHOST DETECTED と段階的に表示していました。
翌日にはこの表示の仕組みごと外して、今は失敗したときは reCAPTCHA 風の「認証に失敗しました」のボックスと、画面に残る粒子だけを見せています。GHOST DETECTED が出るのは、Ghostが現れたときだけです。

一度の失敗ではなく、時間を隔てても残り続けるもの。
それをGhostと呼ぶことにしました。

Ghostは、人によって異なる形を持つ

Ghostのビジュアルを考えるうえで着想を得たのが、『攻殻機動隊』や『イノセンス』にある、自分という存在の境界が曖昧になる感覚です。
ただ、幽霊のキャラクターを出したいわけではありません。
何かがそこにいるように見える。でも、それが何なのかは断定できない。
粒子、波形、薄い膜、軌道、残像などを組み合わせて、形になりかけた存在を表現しようと考えました。

Ghostには10種類の形を用意しています。

当初はビジュアルのバリエーションとして考えていましたが、作っていくうちに、それぞれに魂の性質を与えることにしました。
DISSOLVE は形を保ちながらも外側へほどけていく存在、TRACE は過去の迷いや修正がその後の行動にも残り続ける性質、CONVERGE は迷い、戻り、試しながら、最後には自分なりの一点へ集まっていく存在です。
どれになるかは検証中に確認された Human Signal で決まり、結果画面には「このかたちになった理由」も一文で出します。たとえば PULSE なら「課題が同じでも、反応の間隔は変わっていた。」です。

これは性格診断ではありません。
ブラウザ上で観測された行動に、一時的な形と名前を与える試みです。
「あなたはこういう人です」ではなく、「あなたのあの迷い方が、この形になった」と感じてもらいたいと思っています。

静かなCAPTCHAから、魂が現れるまで

デザインは、最初から最後まで同じ世界観で成立させることを意識しました。
ベースは白い背景、淡い青、広い余白、そして中央の小さな reCAPTCHA 風のUIです。

初期画面で作品のテーマを長々と説明したくありませんでした。
まずは普通のCAPTCHAに見えること。そして「私は人間ではありません」という一文だけで違和感が生まれることを大切にしています。

一方で、背景には WebGL のパーティクルを置いています。
これは単なる装飾ではなく、作品の状態を表すものです。
最初は規則的に動いていた粒子が、検証を重ねるうちに少しずつ乱れ、分析のときには収束し、最終的には有機的なGhostへ変わっていく。
画面を切り替えるのではなく、同じ空間の性質が変わっていく。その連続性を表現したいと考えました。

実装でも、画面遷移で背景を差し替えず、1つの ParticleRenderer が ENTRY、TEST、FAILED、ANALYSIS、ACCESS、RESIDUAL、GHOST の状態を行き来する作りにしています。

構成と開発の進め方

構成は下記

開発は、ChatGPT で企画やビジュアルの方向性を固めて、実装は Cursor Cloud Agents に任せています。
9月16日に最初のコミットを入れてから9月25日までで175コミット、そのうち157コミットが Cursor Agent 名義です。
進め方は 手元に開発環境を持たずに、プロダクトを作り切れるか に書いたとおりで、今回もほぼスマホから指示を出していました。

リポジトリには最初に AGENTS.md を置いて、作品のコンセプトと守るべきルールをまとめました。
「reCAPTCHA の見慣れた感じを入口にする」「検証中に行動の数値を見せない」「サイバーパンクにしない」「紫のAIっぽいグラデーションを使わない」「Ghostを人の形や顔にしない」といったものです。
エージェントに毎回同じことを言わなくて済むように、判断の軸はここに寄せています。

画像生成のビジュアルを、WebGLで再現する難しさ

一番苦労しているのが、画像で作ったイメージを WebGL で再現する部分です。
画像生成では魅力的な有機体ができても、それを「粒子を配置してノイズで動かす」といった抽象的な指示だけで実装させると、まったく違う見た目になってしまいます。

実際、最初はパラメトリックな式でそれっぽい形を作っていたのですが、コンセプトシートとの差が大きすぎました。
そこで TRACE、FLOW、VEIL、PULSE、RESONANCE、CONVERGE の6体は、シートの画像からインクの濃淡や線をサンプリングして粒子を置くように作り直しています。

指示の出し方も、見た目の形容だけでなく、生成ルールまで分解して伝える方針に変えました。
たとえば DISSOLVE なら、中心は高密度で安定させ、外縁ほどノイズを強める。粒子は一方的に飛散するのではなく、ほどけて、漂い、ゆっくり戻る。
さらに、ユーザーの Human Signal を形状や動きに反映します。同じ DISSOLVE でも、観測された行動が違えば、輪郭の粗さ、密度、揺らぎ方が変わります。
固定画像を表示するのではなく、行動の特徴から形を生成することが、WebGL を使う意味だと考えています。

その DISSOLVE は「左を向いた横顔の胸像が、後ろから崩れていく」という形で、これが一番決まりませんでした。
シートの画像からサンプリングしたり、鼻の丸みを調整したりしたあと、3Dモデルから作る方向に切り替えています。
そこから Mixamo の Michelle、MakeHuman の坊主頭のモデル、また Michelle、最後に Lee Perry-Smith の頭部スキャンと、2日間でモデルを4回乗り換えました。

Michelle は髪をお団子にまとめているので、横顔にすると後頭部の塊が影として残ってしまいます。
坊主頭のモデルに替えたのに、ブラウザにキャッシュされていた Michelle の GLB が読み込まれてお団子が戻ってくる、ということもありました。モデルの URL を変えて、お団子の塊が残っているデータは弾くようにしています。
最終的には、鼻と唇と顎が読める Lee Perry-Smith のモデルを胸から上で切って左向きに回し、後頭部から崩れていく形にしました。

左を向いた横顔の胸像が粒子で描かれ、後頭部から崩れている。下に DISSOLVE の名前と説明文が並ぶ

GLB の読み込みのためだけに three を依存に入れていて、AGENTS.md の「Three.js は使わない」とは少し矛盾しています。描画は WebGL2 のままです。

この過程で、エージェントへの指示も「いい感じに再現して」から、形状、密度、動き、シェーダー、状態遷移、評価方法を具体的に定義するものに変わっていきました。
AIに実装を任せるほど、何を作りたいのかを人間側が明確にする必要があると感じています。

WebGLで詰まったところ

形以外にも、WebGL まわりではいくつか詰まっています。

最初に時間を使ったのは、検証中の粒子をカーソルに反応させるところでした。
この日のコミットを並べると、ほぼ同じことを言い直し続けています。

Map pointer input into canvas space so TEST particles swirl around the cursor.
Listen for mouse movement so TEST particles can follow the cursor.
Drive TEST pointer influence from the renderer so the field visibly swirls.
Always apply TEST pointer force so the swirl cannot drop to zero.
Pull TEST particles toward the pointer so the reaction is obvious.
Keep pointer force in the vertex shader only to avoid a precision mismatch.

ポインタ座標をキャンバス座標に変換していなかった、mousemove を拾っていなかった、力がゼロに落ちる経路があった、と原因が1つずつ出てきました。
最後は頂点シェーダーとフラグメントシェーダーの間で精度が食い違うのを避けるために、ポインタの力は頂点シェーダーだけで扱う形に落ち着いています。

Ghostが現れる前には、インクの染みのような斑点が広がっていく演出を入れています。
これを足したときに、キャンバスが突然真っ白になる現象が2回起きました。
原因はどちらも同じシェーダーの中での名前の衝突で、既存の hesitation 用の波と新しく足した斑点用の波が同じ uniform 名を使っていて、WebGL2 がプログラムごと拒否していました。
同じ日にもう一度、今度は伸縮用の変数名で同じことが起きたので、最後は斑点側のローカル変数にすべてプレフィックスを付けて衝突しないようにしています。

似た話で、使わなくなった u_emergeAge の宣言だけが残っていたときに、ANGLE が未使用の uniform を削除し、uniform を必須で取りにいく関数が初期化時に例外を投げて描画が止まったこともあります。
こちらは、uniform が見つからなくても例外を投げず、null ならアップロードを飛ばすようにしました。

途中で、WebGL の初期化に失敗したときに Canvas2D で粒子を描く代替も入れましたが、2D で描いた粒子は別の表現になってしまうので外しました。
WebGL が動かなければ、偽物の粒子は描かずにエラーを出します。検証のカード自体は、粒子が起動しなくても表示されます。

外部サイトに埋め込めるようにした

最後に、reCAPTCHA と同じように外部のフォームに埋め込める SDK も足しました。

<script src="https://human-captcha.lolipop-now.app/sdk.js" async defer></script>
<form>
  <div class="human-captcha" data-sitekey="your-public-site-id"></div>
  <button type="submit">Send</button>
</form>

検証そのものは iframe の中で動かすので、埋め込んだ側のページに生のポインタ軌跡や入力内容は渡りません。
埋め込みでは粒子の演出を出さず、検証はダイアログだけにして、Ghostも出しません。Ghostに会えるのは作品のページだけです。

人間らしさを、欠点ではなく痕跡として見る

HUMAN CAPTCHA は、AIと人間のどちらが優れているかを決める作品ではありません。
本当に魂を検出できると主張するものでもなく、あくまでブラウザ上で観測された行動を使ったフィクションです。
でも、だからこそWebで体験する意味があると思っています。

効率や正確さを追い求めると、間違いや迷いは取り除くべきものとして扱われます。
けれど、間違えること、迷うこと、気が変わること、同じことをしても少し違う結果になること。
そこにも、自分という存在の痕跡があるのかもしれません。

人間がAIになろうとするほど、人間であることが明らかになっていく。
そんな少し可笑しく、少し不気味な体験を通して、普段は見過ごしている自分自身の揺らぎを眺め直せる作品にしたいと思っています。

You couldn’t become a machine.
And something remains.


🤖 Generated with Claude Code