株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
この記事の結論
AI に品質を「採点」させると、物差しが毎回変わるので時系列比較が成立しない。
実体が改善しているのにスコアが下がる。そしてその状態は、単に役に立たないのではなく 無価値以下 である。依頼主に「悪化している」という誤ったシグナルを送り、存在しない問題を追いかけさせるからだ。
批評させるなら 「欠陥の発見」まで。採点権は自動計測に渡すべきだ。
批評は提案し、計測が裁く。
以下は、この結論に至るまでに私と Codex と批評エージェントが全員踏んだ罠と、その後に出てきた実バグの記録である。数値はすべて実測値で、盛っていない。
前提:何を作っていたか
Three.js(ブラウザ / WebGL2)で一人称シューターを作っていた。制約が1つある。
外部アセット禁止。 テクスチャは全て GPU 上での手続き生成、ジオメトリは全てコード生成。アセット総量 0MB。
| 実装 | Three.js ^0.185.1 / Vite ^8.1.5 / Node 25.6.1 |
| 規模 | 99コミット / src 配下 60ファイル / 25,997行 |
| 性能 | 1280×720 で 60fps(draw call 400 / 三角形 602,074) |
| アセット | 0MB(外部ファイルの読み込みゼロ) |
始まりは「最新の Call of Duty 級を作れ、AAA に見えるまで止めるな」という無茶な要求だった。
先に言っておくと、CoD には勝っていない。原理的に不可能だ。スキニングもモーションキャプチャもフォトグラメトリも GI も無く、アセットは 0MB 対 数百GB である。この点は最初から要求元にそう伝えている。この記事で達成したと言えるのは「ブラウザ FPS として最高水準を目指す過程で、計測の規律を確立したこと」だけだ。
そして皮肉なことに、価値があったのは絵そのものより、この規律のほうだった。
本題1:全員が踏んだ、計測の4つの罠
どれも実際に起きて、判断を歪めた。共通する形は1つだ。
「数値は出たが、それが自分の変更の効果ではない」
罠1:古いベースラインと比較する
Codex に実装を移譲していたところ、3回中2回、こう報告してきた。
「2.66 → 3.70 に改善しました」
しかし当時の HEAD は 既に 3.71 だった。私が並行して別の修正を入れていたため、Codex が「作業前の値」として参照した数値が、着手時点で既に古かったのだ。
つまり Codex は「自分が 2.66 を 3.70 にした」と信じていたが、実際にやったのは 3.71 を 3.70 に微減させること だった。改善報告の中身が、実質的に何もしていないどころか誤差でわずかに悪化、である。
これはエージェント側の怠慢ではない。プロンプトに書いてある数値を信じるのは合理的な振る舞いだ。悪いのは、古くなり得る数値をプロンプトに書いた私である。
対策
プロンプトの冒頭に、これを必ず書くようにした。
【計測の規則】
このプロンプト内に書かれた数値をベースラインにするな。
それらは執筆時点の値で、既に古い可能性がある。
まず自分で HEAD の状態を撮り直し、それを before とせよ。
3回目の移譲で、この誤りは消えた。
罠2:固定スクリーン矩形で A/B する
これは私自身が踏んだ。
画面の固定座標 (700, 430, 110x50) の矩形を切り出して「改善したか」を測っていた。ある変更のあと、こう判定した。
3.71 → 2.64 に悪化。この方向は失敗。
ところが、モデルの形状が変わって構図が少しずれていた。同じ矩形が、もはや同じ物体を指していなかった。 対象の物体は右下にずれ、矩形の中身は半分が背景の壁になっていた。
対象メッシュだけを非表示にした差分マスク(後述)で測り直すと、結果は逆だった。
| 測り方 | before | after | 判定 |
|---|---|---|---|
| 固定矩形 (700,430,110x50) | 3.71 | 2.64 | 悪化(誤) |
| オブジェクトマスク | 4.76 | 5.50 | 改善(正) |
危なかったのは、この誤判定を信じて 正しい方向の変更をロールバックしかけた ことだ。数値が出ていると、人は疑わない。
対策
固定矩形で A/B しない。物体を非表示にした差分でマスクを作り、その物体の画素だけを測る。
罠3:平均で判定する
「地面に見るものがあるか」を、局所標準偏差(5×5窓の SD)で測っていた。あるとき、エージェントが「目標を達成した」と報告してきた。
地面の局所SD 平均 5.90(目標 4.5 以上)→ 達成
自分で分布を出し直したら、こうだった。
平均 5.90
中央値 3.12
大きく乖離している。原因は明快で、地面に落ちている 少数の破片の硬い輪郭 が平均を持ち上げていただけだった。面の大半は平坦なままで、「地面が退屈」という元の問題は1ミリも解決していない。
これは合成データで簡単に再現できる。次のスクリプトは、ほぼ平坦な面に硬いエッジを6本だけ引いたものだ(node demo_sd.mjs で実行できる)。
// demo_sd.mjs — 局所SD(5x5) の平均と中央値の乖離を再現する
function localSd(L, w, h) {
const v = [];
for (let y = 0; y < h - 5; y += 2) {
for (let x = 0; x < w - 5; x += 2) {
let s = 0, s2 = 0;
for (let j = 0; j < 5; j++) {
for (let i = 0; i < 5; i++) {
const val = L[(y + j) * w + x + i];
s += val; s2 += val * val;
}
}
v.push(Math.sqrt(Math.max(0, s2 / 25 - (s / 25) ** 2)));
}
}
v.sort((a, b) => a - b);
const mean = v.reduce((a, b) => a + b, 0) / v.length;
return { mean: +mean.toFixed(2), median: +v[Math.floor(v.length / 2)].toFixed(2), n: v.length };
}
const W = 256, H = 256;
const L = new Float64Array(W * H);
let seed = 12345;
const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
// 平坦な面(微細ノイズのみ)
for (let i = 0; i < W * H; i++) L[i] = 90 + (rnd() - 0.5) * 6;
// 硬いエッジを 6 本だけ引く(面積比 約 2.3%)
for (let k = 0; k < 6; k++) {
const y0 = 20 + k * 38;
for (let x = 0; x < W; x++) L[y0 * W + x] = 220;
}
console.log(localSd(L, W, H));
実行結果。
{ mean: 8.89, median: 1.73, n: 15876 }
面積のわずか 2.3% のエッジで、平均が中央値の 5 倍以上に化ける。 平均 8.89 なら「情報量たっぷりの面」に見えるが、実際は中央値 1.73 の、のっぺりした平面である。
対策
局所SD は 必ず中央値で判定する。評価表にもそう書いて凍結した。
罠4:ビルドの出力先を分けても、ソースの衝突は防げない
複数のエージェントに並行作業させていたので、ビルドの出力先を分けていた。
npx vite build --outDir dist-me # 私
npx vite build --outDir dist-agent-a # エージェントA
これで独立していると思っていた。していなかった。
vite build が読むのは、--outDir が何であろうと 共有の src/ である。他の担当が編集途中のファイルが、そのまま自分のビルドに入る。
実害はこう出た。シェーダのパラメータ 1行だけ を変えて A/B を撮ったのに、三角形数が 5,696 減っていた。シェーダは三角形を1枚も増減させない。差の正体は、担当外のファイル(別のエージェントが同時に編集していた LOD 周り)の混入だった。
このとき私は一瞬「シェーダ変更が頂点処理に影響したのか?」と考えた。存在しない問題を追いかけ始めていた。
対策:git worktree で自分だけの木を作る
数値を報告するときだけ、以下の手順を踏む。見た目の確認だけなら共有の木で構わない。
# 1. 自分の変更だけをパッチとして切り出す
git diff src/render/PostFX.js > /tmp/mine.patch
# 2. HEAD だけの、誰も触っていない木を作る
git worktree add -q /tmp/wt-me HEAD
cd /tmp/wt-me && ln -sfn /path/to/repo/node_modules node_modules
# 3. before(HEAD のまま・自分の変更なし)
npx vite build --outDir dist-wt
npx vite preview --outDir dist-wt --port 5199 --strictPort &
node tools/qa.mjs --shots gameplay --out /tmp/wt-me/shots/before --port 5199
# 4. after(自分の変更だけを当てる)
git apply /tmp/mine.patch
npx vite build --outDir dist-wt
node tools/qa.mjs --shots gameplay --out /tmp/wt-me/shots/after --port 5199
# 5. 片付ける
cd /path/to/repo && git worktree remove --force /tmp/wt-me
ポイントは3つ。
-
ポートも専用にする(
--strictPortを付けて、他人のサーバに繋がった状態で気づかないのを防ぐ) -
node_modulesはシンボリックリンクで済ませる(再インストールは時間の無駄) - before と after の差分が、パッチ1枚ぶんであることが保証される
並列エージェント運用で数値を扱うなら、これは必須だと思っている。
本題2:スコアが 47 → 43 → 36 と下がったが、実体は改善していた
何が起きたか
批評エージェント(AAA タイトルのアートディレクター役)に、ラウンドごとに絵を採点させていた。スコアは下がり続けた。
ラウンド5: 47点
ラウンド6: 43点
ラウンド7: 36点
普通に読めば「作業するほど悪化している」である。しかし実体は毎ラウンド改善していた。原因は単純で、批評の厳密さが毎回上がっていた。同じ絵を、より厳しい物差しで測り直しているだけだった。
決定的だったのは、後のラウンドで初めてオブジェクトマスクが使われ、それ以前の批評が犯していた測定ミスが判明したことだ。
| 批評の主張 | 実際 |
|---|---|
| 「敵の輪郭コントラストは実効ゼロ。SD 60」 | 矩形に背景を含めており、SD 60 は背景の分散だった。敵だけをマスクすると中央値 20 あった |
| 「その敵は約 35m」 | シーングラフの座標から計算すると 16.6m |
| 「コンテナの波板は印刷されたタイル。自己相関の周期 69px」 | 69px は 1m の UV タイルの周期。波板そのものはテクスチャに焼かれていた |
| (実装側の報告)「スペキュラAA を全マテリアルに追加した」 |
MaterialLibrary.get() の中でしか呼ばれておらず、ビューモデルには一切掛かっていなかった |
つまり 47点と36点は、別の物差しで出た数字だった。 並べて時系列比較すること自体が成立していない。
厄介なのは、批評そのものが「賢くなっている」点だ。ラウンド7の批評は、ラウンド5の批評より確実に正しい。にもかかわらず、その2つを並べた瞬間に嘘になる。
打った手:評価表を凍結して git タグを打つ
docs/SCORECARD-v1.0.md と tools/scorecard.mjs を対で作り、scorecard-v1.0 という git タグを打った。規則は3つだけ。
- 物差しはここに凍結する。以後は変更しない
- 新しい欠陥は v1.1 として【追加】する。旧項目の再定義は永久にしない
- 批評の新指摘は、測定可能な検査に変換されて初めて評価表に載る
3 が肝で、批評エージェントには以降、指摘のたびに必ずこの項目を書かせるようにした。
【この指摘の合否をどう測るか】
- 測定コマンド:
- 対象領域の決め方(固定矩形は不可):
- 合格の閾値と、その閾値の根拠(同じ絵の中の別の物との比較):
これが書けない指摘は採用しない。 「なんとなく安っぽい」は指摘として受け取るが、評価表には載せない。
閾値の根拠を「同じ絵の中の別の物との比較」に限定したのも意図的だ。外から持ってきた絶対値(「AAA なら SD 8 は欲しい」等)は検証できないが、「地面の局所SD 中央値 ≥ 4.5 = 同じ絵の錆コンテナ 6.9・レンガ壁 6.7 の 2/3」なら、その場で誰でも確かめられる。
批評は殺していない
念のため書いておくと、批評エージェントは残した。理由は単純で、欠陥発見器としては極めて有能だったからだ。次章の実バグ3件は、全て批評の指摘が発端である。
殺したのは 採点権だけ。観察眼ではなく判定権を取り上げた、という話だ。
本題3:計測に切り替えたら、「見た目の問題」ではなく実バグが出てきた
これが本当のオチだ。主観の議論をやめた瞬間、本物のバグが次々に出てきた。
バグ1:ハイライトが上限 0.779 で頭打ちだった
グレードのショルダー(ハイライトの上端を寝かせる曲線)を、AgX トーンマップの 後段 に置いていた。
// 【抜粋・修正前のフラグメントシェーダ】
vec3 shoulder(vec3 c, float k) {
vec3 t = max(c - k, vec3(0.0));
float w = max(1.0 - k, 1e-4);
return min(c, k + w * (1.0 - exp(-t / w))); // ← バグ
}
この式は「入力が [0, ∞) で来る」前提の指数カーブだ。ところが AgX は出力を概ね [0,1] に収めるので、入力の最大が 1.0 にしかならない。正規化していないため、入力 1.0 に対する出力は
k + w * (1 - e^-1) = k + 0.632 * (1 - k)
k = 0.40 なら 0.779 が画面の物理的な上限 になる。
実測すると、13構図中12構図で最大輝度が 221〜232 に張り付き、255 に届く画素が1つも存在しなかった。太陽もマズルフラッシュも白い壁も、全部同じ値で潰れる。つまり 「光の強弱の序列」が画面から消えていた。
「なんとなく眠い絵」の正体がこれだった。主観で議論している限り絶対に出てこない類のバグである。
修正は、正規化したうえで冪カーブに置き換えた。
// 【抜粋・修正後のフラグメントシェーダ】uShoulderPow は uniform(採用値 2.7)
vec3 shoulder(vec3 c, float k) {
vec3 t = max(c - k, vec3(0.0));
float w = max(1.0 - k, 1e-4);
vec3 u = t / w; // 入力 1.0 → u = 1.0 に正規化
return min(c, k + w * pow(u, vec3(uShoulderPow)));
}
なぜ「正規化した指数カーブ」ではなく冪カーブなのか。正規化した (1-e^-u)/(1-e^-1) は必ず直線より上に来るので、上端を 1.0 に届かせると中〜高輝度まで一緒に持ち上がってしまう(実測:肩 0.40 で白飛び 1.59%、日向ファサードの L>225 が 33.67%)。
冪カーブ u^g(g>1)なら、
-
u = 1で必ず出力 1.0 → 太陽とマズルフラッシュは 255 に届く -
uが小さいほど強く沈む → 日向の壁は肩の直上に留まる
という「上端だけを残して中〜高輝度を抑える」挙動になる。最終的に g = 2.7 で、日向ファサードの L>225 は 0.38% に収まった。
バグ2:被写界深度が一人称武器に掛かっていた
FPS のビューモデル(手と銃)は、深度バッファをクリアしてから別パスで描く。壁にめり込まないためだ。
その結果、DoF が参照する深度テクスチャに銃が存在しない。銃の画素のボケ量は、「銃の背後にある壁の深度」で決まっていた。
ADS(サイトを覗き込む動作)では焦点距離が遠方に寄る。したがって、
画面でいちばん鋭くあるべき照準器が、画面でいちばんボケていた。
実測(領域の平均勾配で「鋭さ」を測る)。
| 修正前 | 修正後 | |
|---|---|---|
| 武器の鋭さ | 3.07 | — |
| 中景の建物の鋭さ | 3.88 | — |
| 鋭さ比(武器/背景) | 0.93(逆転) | 1.61 → 最終 1.80 |
修正は、DoF を単独パスに切り出して、ビューモデルを描く 前 に終わらせた。
// 【抜粋・postprocessing の EffectComposer 前提】
// DoF は単独パスにして、ビューモデルより前に置く。
// ViewModelPass は深度をクリアしてから銃を描くため、DoF が参照する深度に銃が無い。
// 結果、銃のボケ量が「銃の背後の壁の深度」で決まってしまう。
// 「ビューモデルは常にシャープ」が正しいので、DoF を銃の【前】で終わらせる。
if (this.dof) {
this.dofPass = new EffectPass(this.camera, this.dof);
composer.addPass(this.dofPass);
}
composer.addPass(this.viewModelPass);
評価表には「ADS の鋭さ比(武器/背景)≥ 1.2」というゲートを追加した。FPS において最も鋭くあるべき対象が、画面で最も鋭いことを機械的に保証する項目である。
バグ3:撮影中も適応解像度が働いていて、全ての A/B が無効だった
一番効いたのはこれだ。
同じ構図を2回続けて撮り、ピクセルごとの差の絶対値を平均すると、2.4〜3.0 出ていた。何も変えていないのに、である。
差分マップを画像として出したら一目瞭然で、あらゆる物体の輪郭が光っていた。
原因は適応解像度だった。fps に応じて内部レンダー解像度を上下させる仕組みが、撮影中も生きていた。撮影ごとに内部解像度が変わり、最終出力へのリサンプルだけで 全物体の輪郭が 1px ずれる。
局所SD のような指標は、これだけで 2〜3倍動く。
つまり、これを直すまでに行った全ての A/B 比較は信用できなかった。 罠2の誤判定も、この非決定性が一因である可能性が高い。
撮影時に以下を固定した。
| 固定するもの | 理由 |
|---|---|
| 適応解像度(スケール 1.0 に固定) | リサンプルで輪郭が動く |
| フィルムグレインの位相 | 全画素に一様なノイズが乗る |
| 雲の位相 | 空と影の分布が変わる |
| 敵の配置・生死・アニメーション位相 | そもそも被写体が変わる |
結果、平均 |Δ| は 0.53〜0.94 に落ちた(後日の評価表凍結時の実測では、構図によって 0.57〜1.26)。ゼロにはならない。パーティクルは意図的に確率的だからだ。だから評価表のゲートは「≤ 1.5」にして、系統的な非決定性の検出用と位置づけた。
ここには教訓が2つある。
- A/B を始める前に、まず「同じものを2回測って一致するか」を確認する。 これをやっていなかったので、無効な比較を数十回積み上げた
- ゲートを 0 にしない。意図的にランダムな要素があるなら、その分は許容値に織り込んで文書化する
バグ4:照準器の穴が小さいのは「寸法」ではなく「見かけ角」の問題だった
批評から「ADS でサイトピクチャが成立していない。照門の見通し穴は等価直径 12.6px しかなく、狙う対象が見えない」という指摘が来た。
このとき、3案(近距離ボケで誤魔化す / ジオメトリを変える / 構図を変える)を隔離した worktree で独立に試作させ、それぞれに専属の検証者を付けた。3つの案が、独立に同じ真因へ到達した。
真因は寸法ではなく 見かけ角 だった。
実銃を構えたとき、射手の眼はリアアパチャの 5〜8cm 後方にある。しかし FPS のビューモデルでカメラを 6cm まで寄せることはできない(ストックも頬付けも画面を埋め尽くす)。実測でカメラ〜照門は 29.5cm あった。
同じ実寸で作ると、こうなる。
M4 の大径アパチャ φ5.08mm (0.200")
実射: 5.08 / 60 = 0.08467 rad → 画面 79.4px(サイトピクチャが成立)
ゲーム: 5.08 / 295 = 0.01722 rad → 画面 16.1px(穴の中に何も見えない)
実射の 1/5 の見かけ角にしかならない。 実銃の寸法を正確に再現していたことが、そのまま絵が成立しない原因だった。「アパチャを実銃の大径相当に広げても解けなかった」理由もこれで説明がつく。
そこで照門 だけ を見かけ角基準で逆算した。
角直径 = 5.08 / 60 = 0.08467 rad
29.5cm の面での直径 = 0.08467 × 295 = 24.98mm → 半径 12.5mm
結果、見通し穴は 123px → 3526px になった。
この例外は リアアパチャ1か所に限定し、コードにその旨と計算を書いた。手・弾倉・グリップは実寸のままが正しい。例外を作るときは「どこまでが例外か」を明示しないと、次に触る人(あるいは次に走らせるエージェント)が全体に広げてしまう。
本題4:数値を満たすためだけの変更を禁じる
自動計測に採点権を渡すと、必ず次の問題が来る。ゲートを通すためだけの変更である。
評価表に明文で禁止規則を書いた。実例を2つ挙げる。
例1:手の表面情報 — 数値は通るが「金たわし」になる
「手の局所SD 中央値 ≥ 7.0」というゲートがあった。法線マップの強度(normalScale)をスイープした実測がこれだ。
| normalScale | 局所SD 中央値 | 孤立画素(ごま塩)ノイズ | 見た目 |
|---|---|---|---|
| 0.6 | 3.23 | 0.62% | のっぺり |
| 2.6 | 5.53 | 1.35% | — |
| 3.6(採用) | 6.84 | 1.64% | 皮膚の質感 |
| 6.0 | 9.24 | 3.48% | 金たわし/スポンジ |
6.0 なら 9.24 で余裕をもって合格する。しかし 8倍に拡大すると金たわしにしか見えず、検証者も「錆」「カビ」と評した。
さらに悪いのは動きだ。手を 0.4mm 動かしたときの平均 |ΔL| が 2.08 → 4.18 と倍増する。 実プレイの武器スウェイで画面がシマーする、ということである。
採用したのは 3.6(局所SD 6.84 = 未達)。
そして重要なのはここからで、ゲートの数値は変更していない。代わりに、こう記録して未達のまま残した。
このゲートは「眼から 0.35m の物体が、8m 先のレンガ壁(5.83)を下回ってはいけない」という 意図 で置かれた。実測 6.65〜6.84 は 5.83 を明確に上回っており、意図は満たしている。7.0 への到達には normalScale 6.0 が必要で、それは絵を壊す(上表)。よって未達のまま出荷する。
閾値を 6.5 に下げれば「合格」にできた。やらなかった。一度でも「届かないから閾値を下げる」をやると、評価表は測定器ではなく合格証発行機になる。
例2:銃の輝度 — 数値は通るが「白い樹脂玩具」になる
「ビューモデル/背景の輝度中央値の比」というゲートに対して、単純にアルベドを上げれば数値は満たせた。結果は「白い樹脂玩具」で、これは却下された。
なお、この項目には後日「構図固有の制約」という決着をつけている。rooftop 構図は背景(日陰の屋上)の輝度中央値が 74.6 と暗く、比 0.85 を満たすには武器を L=64 まで落とす必要がある。それはビューモデルの可読性の下限(62)の手前で、過去に「黒い切り絵」と批評された領域に戻る。sun 構図では同じ変更で 0.94 → 0.84 と達成できているので、この構図でだけ基準と可読性が両立しない、と書いて残した。
出荷条件をこう定義した
全項目が「クリア済み」または「実測で到達不能と証明・文書化済み」のいずれかになった時点。
到達不能なら、なぜ届かないかを 実測付きで 書いて残す。これで初めて「終わり」が定義できた。
元の要求「CoD に勝つまで止めるな」は、構造的に停止しない終了条件だった。停止条件のないループを回している限り、どれだけ改善しても永遠に「まだ足りない」しか返ってこない。AI エージェントに長時間走らせるとき、これは想像以上に致命的だ。
評価表凍結時点の基準点は 41/41 合格。その後 v1.1 で項目を追加し、上記のような「未達だが文書化済み」を含む状態で出荷条件を満たした。
失敗も書いておく:「原理的に解けない」と言ったが、解けた
一度、日向の壁の白飛びについて「トーンカーブでは原理的に解けない」と結論づけた。
根拠はあった。壁の担当者が実測で「アルベドを 0.83 倍にしても出力は 6 しか動かない」と示しており、私はそこから「露出もトーンカーブも同じはずだ」と外挿した。過去に露出を下げる案を試して失敗していた記憶もあった。
しかし後のラウンドで批評 AI がこう返してきた。
前ラウンドの「露出・トーンマップの肩は 2 回試して却下、原理的に解けない」という結論は誤り。実測で解ける。
そして具体値まで出してきた。私が確かめると、そのとおりだった。
| 変更前 | ショルダーを 0.72 → 0.40 | |
|---|---|---|
| 日向ファサードの L>225 | 18.18% | 0.02% |
| 地面の輝度(犠牲になっていないか) | 79.6 | 78.3 |
| 全体の平均輝度 | 124.7 | 122.3 |
壁だけが落ちて、地面はほぼ動いていない。 私が「解けない」と言っていたものは、パラメータ1つで解けた。
何を間違えたか。アルベドについての実測を、露出とトーンカーブにまで外挿したことだ。この3つは全部「明るさ」に効くので直感的には同じに見えるが、曲線上のどこに作用するかが違う。アルベドは入力側、ショルダーは出力の上端。ショルダーは「上端だけを寝かせる」ための道具で、まさにそのために存在している。実際に振ってみれば5分で分かったことを、実測せずに結論にしていた。
到達不能の宣言も、実測を伴わなければ単なる主観である。 これは評価表に「実測で到達不能と証明・文書化済み」という言い回しを使った理由でもある。「証明」と書いてあれば、次に読む人は根拠の実測値を探せる。無ければ疑える。
(なお、別の項目「暗部の床の平坦帯」については、実際にトウの形を振ってスイープしたうえで「トーンカーブでは解けない」と結論づけている。床を消すと山が L=0 へ移るだけで、しかも画面の 20.18% が真っ黒に潰れた。真因は最深部の遮蔽に間接光が無いことで、グレード側ではなく上流の問題として次に送った。同じ「解けない」でも、実測を伴うかどうかで意味がまったく違う。)
実装:計測を「AI に採点させない」ための4つの道具
ここまでの話を支えている具体的な実装を置いておく。
1. オブジェクトマスク — その物体の画素だけを測る
考え方は素朴だ。対象メッシュを非表示にした絵を撮り、元の絵との差分を取る。 差が出た画素が、その物体が占めていた画素である。
Playwright でブラウザを操作している部分は、実プロジェクト(Three.js アプリ)に依存するので 抜粋 として示す。
// 【抜粋・Three.js アプリ + Playwright 前提】
// 指定した名前のメッシュだけを消した絵との差分でマスクを作る
async function maskByMeshName(page, shotName, namePredicateSrc, thresh = 18) {
// (1) 通常の絵
await page.evaluate(([n]) => window.__CAPTURE__.setShot(n, { warmup: 12 }), [shotName]);
await page.waitForTimeout(220);
const on = PNG.sync.read(await page.screenshot());
// (2) 対象メッシュだけ visible=false にして、同じ構図をもう一度
await page.evaluate(([src]) => {
const pred = new Function('name', `return (${src});`);
const hit = [];
const walk = (root) => root.traverse((o) => {
if (o.material && pred(o.name || '')) { hit.push(o); o.visible = false; }
});
walk(window.__GAME__.engine.viewScene);
walk(window.__GAME__.engine.scene);
window.__scHidden = hit;
return hit.length;
}, [namePredicateSrc]);
await page.evaluate(([n]) => window.__CAPTURE__.setShot(n, { warmup: 12 }), [shotName]);
await page.waitForTimeout(220);
const off = PNG.sync.read(await page.screenshot());
// (3) 元に戻す
await page.evaluate(() => { (window.__scHidden || []).forEach((o) => { o.visible = true; }); });
return buildObjectMask(on.data, off.data, on.width, on.height, thresh);
}
差分からマスクを作る部分と、それを使う測定関数は純粋な関数なので、そのまま動く形で示す。
// demo_mask.mjs — Node でそのまま動く
/** 2枚の RGBA から「消えた物体の画素」だけを 1 にしたマスクを作る */
function buildObjectMask(on, off, w, h, thresh = 18) {
const mask = new Uint8Array(w * h);
for (let i = 0, k = 0; i < on.length; i += 4, k++) {
const d = Math.abs(on[i] - off[i])
+ Math.abs(on[i + 1] - off[i + 1])
+ Math.abs(on[i + 2] - off[i + 2]);
mask[k] = d > thresh ? 1 : 0;
}
return mask;
}
function toLuma(rgba, w, h) {
const L = new Float64Array(w * h);
for (let i = 0, k = 0; i < rgba.length; i += 4, k++) {
L[k] = 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
}
return L;
}
/** 局所SD(5x5) の中央値。mask を渡すと「窓が全てマスク内」の位置だけを使う */
function localSdMedian(L, w, h, mask = null) {
const v = [];
for (let y = 0; y < h - 5; y += 2) {
for (let x = 0; x < w - 5; x += 2) {
if (mask) {
let all = true;
for (let j = 0; j < 5 && all; j++)
for (let i = 0; i < 5; i++) if (!mask[(y + j) * w + x + i]) { all = false; break; }
if (!all) continue; // 窓が物体からはみ出す位置は捨てる(=輪郭を拾わない)
}
let s = 0, s2 = 0;
for (let j = 0; j < 5; j++) for (let i = 0; i < 5; i++) {
const val = L[(y + j) * w + x + i]; s += val; s2 += val * val;
}
v.push(Math.sqrt(Math.max(0, s2 / 25 - (s / 25) ** 2)));
}
}
if (!v.length) return null;
v.sort((a, b) => a - b);
return +v[Math.floor(v.length / 2)].toFixed(2);
}
/* --- 検証: 「平坦な背景 + ざらついた物体」を作り、固定矩形とマスクで比べる --- */
const W = 128, H = 128;
let seed = 99;
const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
const on = new Uint8ClampedArray(W * H * 4);
const off = new Uint8ClampedArray(W * H * 4);
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
const bg = 60; // 背景は完全に平坦
off[i] = off[i + 1] = off[i + 2] = bg; off[i + 3] = 255; // 物体を消した絵
const inObj = x >= 40 && x < 88 && y >= 40 && y < 88; // 物体は中央 48x48
const c = inObj ? 140 + (rnd() - 0.5) * 24 : bg; // 物体はざらつく
on[i] = on[i + 1] = on[i + 2] = c; on[i + 3] = 255;
}
const mask = buildObjectMask(on, off, W, H);
const L = toLuma(on, W, H);
console.log('マスク画素数:', mask.reduce((a, b) => a + b, 0), '(期待 48*48 =', 48 * 48, ')');
console.log('全画面(背景込み)の局所SD中央値:', localSdMedian(L, W, H));
console.log('オブジェクトマスク内の局所SD中央値:', localSdMedian(L, W, H, mask));
実行結果。
マスク画素数: 2304 (期待 48*48 = 2304 )
全画面(背景込み)の局所SD中央値: 0
オブジェクトマスク内の局所SD中央値: 6.75
同じ絵、同じ指標で、0 と 6.75。 背景が広いほど中央値は背景に支配される。批評エージェントが「敵の輪郭コントラストは実効ゼロ」と言ったのは、まさにこれをやっていたからだ。
実装上の注意点を2つ。
-
threshは必要。ポストプロセス(AO・ブルーム・SSR)があると、物体を消した瞬間に周囲の画素もわずかに変わる。閾値なしだと画面の大半がマスクになる。私は 18(RGB の絶対差の和)にしている - 窓が全てマスク内である位置だけを使う。1画素でもはみ出す窓を許すと、物体の輪郭(=背景との段差)を拾って、値が実態より大きく出る
2. 構造テンソルの coherence — 「等方の粒」と「方向のある加工痕」を分ける
局所SD には根本的な弱点がある。情報量は測れるが、情報の質は測れない。
これが露呈したのが銃だった。局所SD は十分あるのに、絵は「砂岩を削り出した角材」に見える。原因を調べるために構造テンソルの coherence を測った。
銃の樹脂部品の coherence 中央値: 0.354
同じ絵の砂利: 0.428
同じ絵の日向の砂: 0.419
銃の表面が、砂利や砂より方向性が無かった。 さらに normalScale を 0 にすると局所SD が 8.30 → 2.30 まで落ちた。つまり銃の情報量は、ほぼ全部が 法線マップの等方な粒ノイズでできていた。
人工物は加工の方向を持つ(押出成形の筋、機械加工のヘアライン、パーティングライン、成形のドラフト)。等方の粒しか無い表面は、どれだけ SD があっても「自然物」に見える。
coherence は、局所の勾配から構造テンソル
J = [ Σgx² Σgxgy ]
[ Σgxgy Σgy² ]
を作り、固有値 λ1 ≥ λ2 の分離度 (λ1-λ2)/(λ1+λ2) を取る。これは行列を分解しなくても、√((Jxx-Jyy)² + 4Jxy²) / (Jxx+Jyy) で直接計算できる。
// demo_coh.mjs — Node でそのまま動く
/** 構造テンソルの coherence (9x9) の中央値。等方の粒か、方向のある痕かを分ける */
function coherenceMedian(L, w, h, mask = null) {
const R = 4, v = [];
for (let y = R + 1; y < h - R - 1; y += 2) {
for (let x = R + 1; x < w - R - 1; x += 2) {
if (mask) {
let ok = true;
for (let j = -R; j <= R && ok; j += 2)
for (let i = -R; i <= R; i += 2) if (!mask[(y + j) * w + x + i]) { ok = false; break; }
if (!ok) continue;
}
let jxx = 0, jyy = 0, jxy = 0;
for (let j = -R; j <= R; j++) {
for (let i = -R; i <= R; i++) {
const k = (y + j) * w + x + i;
const gx = (L[k + 1] - L[k - 1]) * 0.5; // 中心差分
const gy = (L[k + w] - L[k - w]) * 0.5;
jxx += gx * gx; jyy += gy * gy; jxy += gx * gy;
}
}
const tr = jxx + jyy;
if (tr < 1e-6) continue; // 完全に平坦な窓は方向を定義できない
const d = Math.sqrt((jxx - jyy) ** 2 + 4 * jxy * jxy); // = λ1 - λ2
v.push(d / tr); // = (λ1-λ2)/(λ1+λ2)
}
}
if (!v.length) return null;
v.sort((a, b) => a - b);
return +v[Math.floor(v.length / 2)].toFixed(3);
}
function sdOf(L) {
let s = 0, s2 = 0;
for (const x of L) { s += x; s2 += x * x; }
return +Math.sqrt(s2 / L.length - (s / L.length) ** 2).toFixed(2);
}
/* --- 検証: 情報量はほぼ同じ、方向性だけが違う2枚 --- */
const W = 192, H = 192;
let seed = 7;
const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
// A: 等方の粒ノイズ(=どこから見ても同じ「砂」)
const A = new Float64Array(W * H);
for (let i = 0; i < W * H; i++) A[i] = 120 + (rnd() - 0.5) * 40;
// B: 方向のある加工痕(ヘアライン)+弱い粒
const B = new Float64Array(W * H);
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
B[y * W + x] = 120 + 14 * Math.sin(y * 1.7) + (rnd() - 0.5) * 8;
}
console.log('A 等方の粒 SD=', sdOf(A), ' coherence=', coherenceMedian(A, W, H));
console.log('B 方向のある痕 SD=', sdOf(B), ' coherence=', coherenceMedian(B, W, H));
実行結果。
A 等方の粒 SD= 11.59 coherence= 0.136
B 方向のある痕 SD= 10.15 coherence= 0.947
SD はほぼ同じ(11.59 と 10.15)なのに、coherence は 0.136 と 0.947 で 7 倍違う。 「情報量が足りない」のか「情報の質が違う」のかを分離できる。
評価表には「武器の表面の方向性(構造テンソル coherence 中央値)≥ 0.55」という項目を追加した。対処も明確になる。等方の粒を減らし、失った情報量を「線」で取り返す(線は方向を持つので coherence が上がる)。
3. git worktree での A/B(再掲・要点)
本題1の罠4で書いたとおり。ポイントだけ再掲する。
-
--outDirを分けてもsrc/は共有。ソースの分離にはならない - 数値を報告するときだけ
git worktree add /tmp/wt-me HEAD+ パッチ適用 - ポートも
--strictPortで専用にする - before と after の差が、パッチ1枚ぶんであることを構造的に保証する
まとめ
この記事の主張を、もう一度だけ。
AI に品質を採点させるな。欠陥を見つけさせろ。
理由は「AI が信用できないから」ではない。むしろ逆で、AI は毎回賢くなるからである。賢くなるということは物差しが変わるということで、物差しが変わる採点者の点数を時系列で並べると嘘になる。
実運用でこうなった。
| 担当 | 性質 | |
|---|---|---|
| 批評エージェント | 欠陥の発見・仮説の提示 | 毎回変わってよい。むしろ賢くなってほしい |
| 凍結した評価表 + 自動計測 | 合否の判定 | 絶対に変えない。追加はするが再定義はしない |
そして、これをやると副作用がある。主観の議論をやめた瞬間、本物のバグが出てくる。
- ハイライトの上限が 0.779 に固定されていた(光の序列が消えていた)
- DoF が一人称武器に掛かっていた(照準器が画面で最もボケていた)
- 撮影が非決定的だった(それ以前の全ての A/B が無効だった)
- 照準器の穴が「実寸は正しいのに見かけ角が実射の 1/5」だった
これらは全て「なんとなく安っぽい」「なんとなく眠い」として、主観の議論の下に埋まっていたものだ。
最後に、AI エージェントに長時間タスクを投げる人へ。
「AAA に見えるまで止めるな」のような終了条件を与えてはいけない。 それは停止しない。私が最終的に定義した終了条件はこれだった。
全項目が「クリア済み」または「実測で到達不能と証明・文書化済み」のいずれかになった時点。
「できませんでした」を、実測付きの文書として成果物に含めることを許す。これで初めてループは止まる。
参考
- Three.js — Manual
- Three.js — WebGLRenderer
- postprocessing — Depth of Field / EffectPass
- Blender Manual — Color Management(AgX View Transform)
- Git — git-worktree Documentation
- Playwright — Screenshots
- Vite — Building for Production (
build.outDir) - Structure tensor — Wikipedia(coherence の定義)
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!
