[CGクロニクル #03] 見えないものを消す:隠面消去と画家のアルゴリズム
序文:存在の重なりと「見えない」ことの証明
私たちが生きる現実世界では、手前にある物体が奥にある物体を隠すのは自明の理です。
しかし、0と1で構築された初期のコンピュータグラフィックスにおいて、画面(スクリーン)はただの平面的なピクセルの配列に過ぎません。
前回の「DDAとBresenhamのアルゴリズム」によって、私たちは空間に直線を引く方法を手に入れました。しかし、ポリゴンに「面(サーフェス)」を張った途端、システムは深刻な実存の危機に陥ります。コンピュータは、奥にあるはずの面を平気で手前に描画し、空間の前後関係はカオスと化しました。
「見えないものを、計算して消す」。 これは単なる描画の手法ではなく、情報空間に「奥行き(Depth)」という概念を定義するための、先人たちの哲学的な戦いの記録です。
前回の記事:
[CGクロニクル #02] 線を引くという哲学 — DDAとBresenham // PROTOCOL.LAIN
1960年代、離散的なピクセル空間にいかにして連続した直線を引くか。浮動小数点演算を避け、整数演算のみで世界を描き出したブレゼンハムのアルゴリズムの哲学と、ジャギー(Jaggies)の美しさを考察します。
lain-lab.com1. 画家のアルゴリズム(Painter’s Algorithm)のジレンマと実存の危機
最も原始的で直感的な解決策は、人間がキャンバスに向かう際のアプローチをそのままシリコンに教え込むことでした。遠くの空や山から描き始め、最後に手前の人物を描き重ねていく。これは「Zソート法」とも呼ばれる、極めて人間臭いアルゴリズムです。
空間上のすべてのポリゴンについて、カメラからの距離(代表点の 座標)を計算し、降順(遠い順)に並べ替えます。
この順序に従ってポリゴンをスクリーンという二次元のピクセル配列に塗りつぶしていけば、自然と手前の物体が奥の物体を上書き(Overdraw)し、正しい前後関係が構築されるはずでした。
しかし、この「人間にとっては完璧な論理」は、計算機の世界に持ち込まれた途端、二つの致命的なパラドックスを引き起こします。
破綻その1:循環的な重なり(Cyclic Overlap)
一つ目は、エッシャーのだまし絵のような状態です。 「ポリゴンAはBより手前、BはCより手前、CはAより手前」という、三すくみの配置関係が発生した場合、厳密なソート(順序付け)は不可能になります。
数学的に言えば、推移律( かつ ならば )が崩壊する瞬間です。システムは永遠に「どちらが手前か」を決定できず、描画順序は無限ループに陥るか、毎フレーム順序が入れ替わって画面が激しく点滅する「グリッチ」を生み出してしまいます。
破綻その2:ポリゴンの交差(Intersection)
二つ目は、剣が盾を貫通するような「面の交差」です。 画家のアルゴリズムは「ポリゴンという1枚の面」を最小単位として扱うため、2つの面が空間上で の字に交差している場合、「どちらか一方を先に描く」という二択しか選べません。結果として、貫通しているはずの剣が盾の手前に完全に浮き出るか、あるいは盾の奥に完全に隠れてしまうという物理法則の無視が発生します。
美しき敗北:空間の分割というアプローチ
この破綻を前に、当時のエンジニアたちは「ポリゴンを分割する」というエレガントな解決策を模索しました。 交差しているなら、交点でポリゴンを2つに割ればいい。重なりが複雑なら、画面を4分割、さらに16分割と再帰的に細分化し、単純な重なりになるまで解体を続ける(ワーノックのアルゴリズム)。
しかし、これら「論理的な美しさ」を追求したアルゴリズムは、ポリゴン数が増えるにつれて天文学的な計算コスト(CPUの負荷)を要求しました。当時の非力なプロセッサにとって、動的なポリゴンの分割はあまりにも重すぎる処理だったのです。
「人間の直感(画家のアルゴリズム)」も「論理的な分割(ワーノック)」も、リアルタイムCGの圧倒的な情報量の前では限界を迎えていました。
世界を正しく描画するためには、面や空間という「マクロな視点」を捨て、ピクセルという「ミクロな暴力」へとパラダイムを転換する必要があったのです。
2. Zバッファ(Depth Buffer)という力技の革命:シリコンに託された未来
画家のアルゴリズムが抱えていた「ソートの破綻」や「ポリゴンの交差」という難問に対し、1974年、ユタ大学のエドウィン・キャットムル(後のピクサー創設者の一人)は、あまりにも鮮やかで、かつ傲慢な解決策を提示しました。
彼は、ポリゴンという構造物をどう並べるかというマクロな視点を完全に放棄しました。代わりに、画面を構成する最小単位である「ピクセル」というミクロな領域に、世界の前後関係を決定する全権限を委譲したのです。
ピクセルごとの「記憶」と「比較」
Zバッファ法の仕組みは、驚くほど単純です。 画面の色を記録する「カラーバッファ(フレームバッファ)」と全く同じ解像度を持つ、もう一つのメモリ領域「Zバッファ(深度バッファ)」を用意します。そこには色ではなく、カメラからそのピクセルまでの「距離(値)」だけを記録します。
ポリゴンを描画(ラスタライズ)する際、システムは1ピクセルごとに以下の審判を下します。
「今から塗ろうとするこの色は、既に塗られている色よりも手前にあるか?」 もし手前(が小さい)なら上書きし、その距離を新しい基準としてバッファに刻む。そうでなければ、そのピクセルは「見えないもの」として冷徹に棄却する。
ソートも、ポリゴンの分割も、複雑な幾何学計算も必要ありません。ただ、ピクセル単位で「今、誰が一番近いか」を問い続ける。この究極の分散処理によって、空間の前後関係は自動的に、そして完璧に解決されました。
メモリという聖域を侵す「富豪的プログラミング」
しかし、1970年代中盤において、このアルゴリズムは狂気の沙汰でした。 当時のメモリは、1KB(キロバイト)単位で数千ドルものコストがかかる、最高級の希少資源でした。画面解像度が であっても、それと同じサイズの深度情報を保持するためには、当時のスーパーコンピュータですら悲鳴を上げるほどのメモリ容量を「ただ、隠れた線を消すためだけ」に占有することになります。
キャットムルの提案は、計算機の性能が指数関数的に向上することを見越した、いわば未来からの前借りでした。
アルゴリズムの複雑さを、ハードウェアの物量(メモリ)で解決する。この「力技(ブルートフォース)」への転換は、現代のGPUへと続く道の決定的な分岐点となりました。
オーパーツとしてのZバッファ
Zバッファは、シリコンの進化が追いつくのを10年以上も待ち続けたオーパーツでした。やがて1980年代後半、SGI(シリコングラフィックス)などのワークステーションが専用のハードウェアとしてこのバッファを搭載し始めたとき、ようやく人類はリアルタイムで矛盾のない三次元空間を手に入れたのです。
「論理的に正しい手順」よりも「計算機の特性に適した物量作戦」が勝利する。 この残酷なまでのエンジニアリングの真理が、Zバッファという一見シンプルな仕組みの中には脈々と流れています。
3. 結び:見えないものを捨てる美学と、シリコンの鼓動
1974年当時、あまりにも贅沢で狂気的とさえ言われたZバッファ法は、半世紀の時を経て、私たちが息をするように使う「当たり前」のインフラとなりました。
Three.jsのようなモダンなライブラリやWebGPUの裏側で、現代のGPUは毎秒何兆回となく「」という無慈悲な問いを繰り返しています。さらに現代では、「視界に入らないものは最初から宇宙に存在しないことにする(Frustum Culling)」、「ポリゴンの裏側は見えないから計算を放棄する(Backface Culling)」など、隠面消去の哲学はより先鋭化しています。
しかし、根底にある思想は今も昔も変わりません。
「見えないものを計算し、そして捨てる」。 それは一見すると、膨大なエネルギーの無駄遣いにも思えます。描画されないピクセルのためにメモリを割き、比較演算を走らせ、そして最終的には画面から消し去るのですから。
しかし、その「棄却された無数のピクセル」たちの沈黙の上にのみ、私たちが「現実(リアル)」と呼ぶ画面上の秩序は成り立っています。見えない部分の存在をシステムに一度認識させ、その上で明示的に「消去」する。不完全なものを削ぎ落としていくその冷徹なプロセスにこそ、デジタル空間の本当の美しさが隠されているのではないでしょうか。
今日、あなたがR3Fの空間でアバターと至近距離で対峙したとき、その背後や交差するポリゴンの裏側でも、この「見えないものを捨てる」ための静かな計算が確実に行われていました。
0と1の海に沈んでいった無数の見えない光たちに思いを馳せながら、今回はこの「深度(Depth)」そのものを可視化した数式で、この記事を締めくくりたいと思います。
【一分間の数式美】深度の可視化
Zバッファの概念をフラグメントシェーダー(GLSL)で再現しました。レイマーチングにおいて、光の計算(ライティング)を一切行わず、「そこにあるという距離の事実」だけで描かれた世界です。
レイが物体に衝突した際の「距離(Depth)」を色彩に変換し、手前にあるものほど明るい白へ、奥にあるものほど深い青の深淵へと沈んでいきます。
// CG Chronicle #03 : Visualize Depth
// 隠面消去とZ値の証明
#version 300 es
precision highp float;
out vec4 fragColor;
uniform vec2 u_resolution;
uniform float u_time;
// 空間の回転行列
mat2 rot(float a) {
float s = sin(a), c = cos(a);
return mat2(c, -s, s, c);
}
// 距離関数(複数の立方体を配置)
float map(vec3 p) {
vec3 q = p;
q.xz *= rot(u_time * 0.2);
q.yz *= rot(u_time * 0.3);
// 空間を繰り返し(無限の立方体)
q = mod(q, 2.0) - 1.0;
vec3 d = abs(q) - 0.3;
return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}
void main() {
vec2 uv = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
// カメラのセットアップ
vec3 ro = vec3(0.0, 0.0, 3.0); // レイの起点
vec3 rd = normalize(vec3(uv, -1.0)); // レイの方向
float t = 0.0;
float max_d = 10.0; // Zバッファの最大深度に相当
// レイマーチング
for(int i = 0; i < 64; i++) {
vec3 p = ro + rd * t;
float d = map(p);
if(d < 0.001 || t > max_d) break;
t += d;
}
// 深度(t)を色に変換
// 手前(tが小さい)ほど白、奥(tが大きい)ほど青黒くなる
float depthVal = clamp(1.0 - (t / max_d), 0.0, 1.0);
vec3 color = mix(vec3(0.02, 0.05, 0.2), vec3(0.9, 0.95, 1.0), depthVal);
// 背景の処理
if(t > max_d) color = vec3(0.01, 0.01, 0.05);
fragColor = vec4(color, 1.0);
}
サンプル
今回の記事のテーマである「画家のアルゴリズムの破綻」と「Zバッファの力技」、そして「深度の可視化」。これらすべてをブラウザ上でインタラクティブに切り替えて体験できる、R3F(React Three Fiber)のサンプルコードを組み上げました。
import React, { useState } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
// --- Types ---
type ViewMode = 'zbuffer' | 'painter' | 'depth';
// --- 描画コンポーネント ---
const Scene = ({ mode }: { mode: ViewMode }) => {
// Zバッファを無効化(depthTest: false)すると、画家のアルゴリズム(後から描いたものが手前になる)状態を再現できる
const isPainterMode = mode === 'painter';
const matProps = {
depthTest: !isPainterMode,
transparent: false,
};
// 深度(Z値)の可視化モード
if (mode === 'depth') {
return (
<group>
<mesh position={[-0.5, 0, 0]}>
<boxGeometry args={[1.5, 1.5, 0.2]} />
<meshDepthMaterial />
</mesh>
<mesh position={[0.5, 0, 0.5]}>
<sphereGeometry args={[0.8, 32, 32]} />
<meshDepthMaterial />
</mesh>
<mesh position={[0, 0, -0.5]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[1, 0.3, 16, 100]} />
<meshDepthMaterial />
</mesh>
</group>
);
}
return (
<group>
{/* あえて交差する(貫通し合う)形状を配置。
画家のアルゴリズム(ZバッファOFF)では、この交差の描画が完全に破綻する。
renderOrderを指定して、意図的な「描画順」を強制している。
*/}
<mesh position={[-0.5, 0, 0]} renderOrder={1}>
<boxGeometry args={[1.5, 1.5, 0.2]} />
<meshStandardMaterial color="#aa0033" {...matProps} />
</mesh>
<mesh position={[0.5, 0, 0.5]} renderOrder={2}>
<sphereGeometry args={[0.8, 32, 32]} />
<meshStandardMaterial color="#00aa88" {...matProps} />
</mesh>
<mesh position={[0, 0, -0.5]} rotation={[Math.PI / 2, 0, 0]} renderOrder={3}>
<torusGeometry args={[1, 0.3, 16, 100]} />
<meshStandardMaterial color="#0044cc" {...matProps} />
</mesh>
</group>
);
};
// --- メインコンポーネント ---
export default function DepthBufferDemo() {
const [mode, setMode] = useState<ViewMode>('zbuffer');
return (
<div style={{ width: '100%', height: '400px', position: 'relative', backgroundColor: '#050505', border: '1px solid #333' }}>
{/* UI Overlay (lain-lab style) */}
<div style={{ position: 'absolute', top: 15, left: 15, zIndex: 10, color: '#00ffcc', fontFamily: 'monospace' }}>
<div style={{ fontSize: '11px', marginBottom: '10px', opacity: 0.8 }}>
PROTOCOL.Z_BUFFER // STATUS
</div>
<button onClick={() => setMode('zbuffer')} style={btnStyle(mode === 'zbuffer')}>
[ ENABLE_Z-BUFFER ]
</button>
<button onClick={() => setMode('painter')} style={btnStyle(mode === 'painter')}>
[ PAINTERS_ALGORITHM ]
</button>
<button onClick={() => setMode('depth')} style={btnStyle(mode === 'depth')}>
[ VISUALIZE_DEPTH ]
</button>
</div>
<Canvas camera={{ position: [2.5, 2, 3.5], fov: 45 }}>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 10, 5]} intensity={1.2} />
<Scene mode={mode} />
{/* 自動回転で破綻の様子を観察しやすくする */}
<OrbitControls autoRotate autoRotateSpeed={1.5} enableZoom={false} />
</Canvas>
</div>
);
}
// --- Styles ---
const btnStyle = (active: boolean): React.CSSProperties => ({
display: 'block',
marginTop: '5px',
background: active ? 'rgba(0, 255, 204, 0.2)' : 'transparent',
color: active ? '#fff' : '#00ffcc',
border: active ? '1px solid #00ffcc' : '1px solid #333',
padding: '6px 12px',
cursor: 'pointer',
fontFamily: 'monospace',
fontSize: '10px',
letterSpacing: '0.05em',
transition: 'all 0.2s',
textAlign: 'left',
width: '160px',
});