写実的表現とリアリティの要素

CG

CGで生み出される画像には、形だけを線で示したものから、現実の景色を写した写真とほとんど区別がつかないものまであります。

線画による表現
CG

線だけで立体を表す線画は、形を伝える手段として古くから使われてきました。

三面図

特に三面図 は、3方向から見た図を組にして立体を示す描き方で、図の上で測った長さがそのまま寸法として読み取れるため、工業製図ではよく使われてきたものです。

透視図

三面図から形状を把握するには訓練が必要ですが、透視図であれば、目で見たときと近い表示ができるため、形状の把握が誰でも直感的にできます。

透視図は、透視投影 で描いた図であり、奥にあるものほど小さく写り、遠近感が生まれます。透視投影は、ピンホールカメラ に像ができる仕組みをそのまま計算に置き換えたものなので、目で見たときと近い表示になるのです、

しかし、線画から読み取れるのは形と寸法までです。表面が何色なのか、どんな材質でできているのか、どの向きから光が当たっているのかは、輪郭をなぞっただけの線画には現れません。

写実的表現
CG

線には現れないこうした情報まで含めて、実物を目にしたときの見え方を画像として作り出す表現を写実的表現といいます。

写実的表現を行うためにCGプログラムで計算するのは、物体の色や照明効果にとどまりません。光の当たり方でできる陰影、材質ごとに異なる反射特性、周囲の風景の映り込み、ガラスのような透明な物体での透過と屈折まで、見え方を左右するものはすべて対象になります。

こうした写実的な画像は、工業製品のデザインや建築、照明設計 などの分野において、実物ができあがる前に見え方を確かめる用途で使われます。

モデリングとレンダリング
CG

写実的な画像は、2つの工程を経て作られます。

  1. 立体の形状をコンピュータの中に作り上げるモデリング
  2. その形状が光の下でどう見えるかを計算して画像に落とし込むレンダリング

モデリングでは、形状だけでなく、表面が光をどう跳ね返すかといった材質の情報も合わせて持たせます。この情報が欠けていると、レンダリングの段階で金属と布を描き分けることができません。

実物と見分けがつかない画像の生成を目標としたレンダリングをフォトリアリスティックレンダリングといいます。画像がどれだけ実物らしく見えるかをリアリティといい、リアリティの要素を1つずつ積み上げて写実性を高めていきます。

形状のリアリティ
CG

同じ形状モデルでも、それを表示する手法によって、リアリティは増していきます。透視図をベースに、表示法を発展させていく形で、リアリティの変化を解説します。

ワイヤフレーム表示

立体は複数の面によってできていますが、面を塗らずに、物体の稜線(隣り合う面と面の境目)だけを線で描く表示がワイヤフレーム表示です。計算が軽く、形状を確かめるには十分ですが、線画である以上、実物らしさはほとんどありません。

デプスキューイングによる奥行き

視点から遠い稜線ほど輝度を落として描くことで、遠くのものが霞んで見える効果をもたらすことができます。

輝度 は、面を見たときにどれくらい明るく見えるかを表す量です。ここでは、画面上で線が描かれた部分がどれくらい明るく光るかにあたります。

この表示法をデプスキューイングといい、単なる透視図よりもさらに奥行きを表現することができます。デプスキューとは、直訳すると「奥行きの手がかり」という言葉です。

Action

奥行きの効きによって、右図の遠くにある稜線がどこまで背景に沈むかを観察しよう
奥行きの効きを0にすると、左右がまったく同じ図になることも確認してみよう

Three.jsによる実装概要
/** 霞ませる奥行きの半径(立体を包む球の半径) */
const DEPTH_RADIUS = 1.45

/** 線の太さ(ピクセル) */
const LINE_WIDTH = 2.5

/** デプスキューイングの効き。1 で立体のいちばん奥が背景に溶けきる */
const STRENGTH = 0.9

// 稜線 1 本を 1 本の線分として、両端の座標を並べたもの
const positions = createEdgePositions()

const geometry = new LineSegmentsGeometry()
geometry.setPositions(positions)

// 太さのある線。太さはピクセル単位なので canvas の大きさが要るが、
// その受け渡しは LineSegments2 が描画の直前に引き受ける。
// fog は既定で無効なので、霞ませる線では明示的に有効にする
const material = new LineMaterial({ color: EDGE_COLOR, alphaToCoverage: true, fog: true })
material.linewidth = LINE_WIDTH
const wireframe = new LineSegments2(geometry, material)
scene.add(wireframe)

// 視点から遠い線ほど背景色へ近づける(デプスキューイング)。
// 混ぜる割合は、シェーダがフラグメントごとに奥行きから計算する
const fog = new Fog(BACKGROUND_COLOR, 0, 1)
scene.fog = fog

// 視点からの奥行きを測るための作業用ベクトル(毎フレーム使い回す)
const forward = new Vector3()
const point = new Vector3()

// 霞ませる範囲を立体に合わせる。立体を包む球の手前側で霞み始め、
// 効きが弱いほど、溶けきるまでの奥行きを遠くへ伸ばす。
// 視線の向きが変わると奥行きも変わるので、視点が動くたびに計算し直す
camera.getWorldDirection(forward)
const centerDepth = point.copy(wireframe.position).sub(camera.position).dot(forward)
fog.near = centerDepth - DEPTH_RADIUS
fog.far = fog.near + (DEPTH_RADIUS * 2) / STRENGTH

線を引くだけの表示でも、輝度の付け方ひとつで奥行きの伝わり方は変わります。

見えない線の削除

ワイヤフレーム表示では、手前の面に隠れているはずの稜線まで描かれるため、どちらが手前なのか判断できないことがあります。そこで、手前の面に隠れる稜線を取り除きます。この処理を陰線消去といいます。

可視面の表示

面を塗って表示する場合は、線ではなく面に対して、陰線消去のような処理が必要になります。

手前の面に隠れて見えない面を取り除く処理を隠面消去といい、見えている面だけを残して表示することを可視面表示といいます。この隠面消去 で使われる手法はさまざまあり、計算の重さや向き不向きが異なります。

Action

消えた稜線を薄く残すことで、陰線消去がどの稜線を取り除いたのかを確認しよう
ドラッグ操作で見える方向を変えると、取り除かれる稜線が変わることにも注目しよう

Three.jsによる実装概要
/** 線の太さ(ピクセル) */
const LINE_WIDTH = 2.5

// 面を三角形に分割して並べた座標と、稜線 1 本ずつの両端の座標
const faceGeometry = new BufferGeometry()
faceGeometry.setAttribute("position", new Float32BufferAttribute(createFacePositions(), 3))

const edgeGeometry = new LineSegmentsGeometry()
edgeGeometry.setPositions(createEdgePositions())

// 陰線消去した線画。
// 面は色を書かずに深度だけを書き、稜線はその深度に対する深度テストで隠れる。
// polygonOffset で面をわずかに奥へずらし、面の縁にある稜線が z ファイティングしないようにする
const occluderMaterial = new MeshBasicMaterial({
  colorWrite: false,
  polygonOffset: true,
  polygonOffsetFactor: 1,
  polygonOffsetUnits: 1
})
const occluder = new Mesh(faceGeometry, occluderMaterial)
occluder.renderOrder = 0
scene.add(occluder)

const edgeMaterial = new LineMaterial({ color: EDGE_COLOR, alphaToCoverage: true })
edgeMaterial.linewidth = LINE_WIDTH
const edges = new LineSegments2(edgeGeometry, edgeMaterial)
// 深度を書いた面より後に描く(描く順が入れ替わると稜線が隠れない)
edges.renderOrder = 2
scene.add(edges)

// 可視面表示。隠面消去は深度バッファが行う。
// 陰影を付けないので、見えている面はすべて同じ色になる
const surfaceMaterial = new MeshBasicMaterial({ color: SURFACE_COLOR })
scene.add(new Mesh(faceGeometry, surfaceMaterial))

シェーディングによる陰影

面が見えているかどうかが決まっても、すべての面を同じ色で塗ったのでは、立体は平らな図形にしか見えません。そこで、面ごとの明るさを計算して塗り分けることで陰影を表現するシェーディングという処理を施します。

表面の明るさは、光源から届く光の強さと、その光に対する面の傾き、そして面の反射率で決まります。同じ白い壁でも、光を正面から受ける部分は明るく、斜めを向いた部分は暗くなります。この明暗の差が、立体の丸みや角を読み取る手がかりになります。

面が跳ね返す光の色は、当たっている光の成分と物体の反射率 の組み合わせで決まります。物体の色として目に入るのは、この跳ね返った後の光です。

影付け

シェーディングが考慮しているのは、面に当たる光の強さや方向だけです。そのため、光源との間に別の物体があって、物体に影が生じても、影を落とすはずの面は明るいまま計算されてしまいます。

光を遮る物体があることで暗くなる領域が影です。影を表現することで、物体が床に接しているのか浮いているのか、光源がどの方向にあるのかまで読み取れるようになります。

影の輪郭がどれくらいぼやけるかは光源の大きさで決まり、平行光源・点光源による影 では、輪郭のはっきりした影ができます。

Action

影付けを行った右図では、階段の段差による影が描画されていることに注目しよう

Three.jsによる実装概要
// 影を落とすには renderer 側で影の描画を有効にする
renderer.shadowMap.enabled = true

// 面を三角形に分割して並べ、面ごとに独立した頂点から法線を求める
const geometry = new BufferGeometry()
geometry.setAttribute("position", new Float32BufferAttribute(createFacePositions(), 3))
geometry.computeVertexNormals()

// 面の明るさは、光の強さ・面の傾き・反射率だけで決まる(鏡面反射は考えない)
const material = new MeshLambertMaterial({ color: SURFACE_COLOR })
const solid = new Mesh(geometry, material)
// 影を落とす側・影を受ける側の両方に指定する。同じ立体に両方を指定すると、
// 段差が落とした影を、その下の段の面が受ける。指定しなければ陰影だけの表示になる
solid.castShadow = true
solid.receiveShadow = true
scene.add(solid)

// 平行光源。影用のカメラは立体のまわりだけに絞り、輪郭のはっきりした影を得る
const light = new DirectionalLight(LIGHT_COLOR, 2.5)
light.castShadow = true
light.shadow.mapSize.set(2048, 2048)
light.shadow.camera.left = -4
light.shadow.camera.right = 4
light.shadow.camera.top = 4
light.shadow.camera.bottom = -4
light.shadow.camera.near = 0.5
light.shadow.camera.far = 25
// 写す範囲は、変えたあとに投影行列を作り直さないと反映されない
light.shadow.camera.updateProjectionMatrix()
// 面と影の境目に縞が出ないよう、面の向きに応じて影の判定をわずかにずらす
light.shadow.normalBias = 0.02
scene.add(light)

// 光源の向きは方位角と仰角から決める(平行光源なので距離は見え方に関わらない)
const azimuth = MathUtils.degToRad(80)
const elevation = MathUtils.degToRad(55)
light.position.set(
  LIGHT_DISTANCE * Math.cos(elevation) * Math.sin(azimuth),
  LIGHT_DISTANCE * Math.sin(elevation),
  LIGHT_DISTANCE * Math.cos(elevation) * Math.cos(azimuth)
)

// 光の届かない面も真っ暗にはならない。上を向いた面ほど明るい環境光を足すことで、
// 影に入った段の面と、光を背にした段差の面が同じ明るさに溶けるのを防ぐ
scene.add(new HemisphereLight(LIGHT_COLOR, GROUND_COLOR, 0.55))

細部にわたる形状表示

細かい部分が複雑な形状を表現するには、曲面 として形状を計算することもあります。

球のような滑らかな曲面は、小さなポリゴン に分割して近似計算します。しかし、その境目が段差として見えてしまうことも多くあります。

そこで、ポリゴンの内部でも濃淡を連続的に変化させ、ポリゴンの境目を目立たなくする手法がスムーズシェーディングです。
濃淡をどう補間するかによって、さまざまなスムーズシェーディングの手法 が開発されいます。

Action

段差が目立つ左図では、各ポリゴンが単色で塗られていることを確認しよう

Three.jsによる実装概要
/** 球の半径 */
const RADIUS = 1.15

// ポリゴンで近似した球。分割数を減らすほど 1 枚 1 枚のポリゴンが大きくなる。
// 縦方向は横方向の半分にして、ポリゴンの形が細長くなりすぎないようにする
const geometry = new SphereGeometry(RADIUS, SEGMENTS, Math.max(2, Math.round(SEGMENTS / 2)))

// ポリゴンごとに明るさを一定にする。面の境目が段差として見える
const flatMaterial = new MeshLambertMaterial({ color: SURFACE_COLOR, flatShading: true })
const flat = new Mesh(geometry, flatMaterial)
flat.position.x = -OFFSET_X
scene.add(flat)

// 頂点の法線を面の内部で補間する(既定の動き)。濃淡が連続する
const smoothMaterial = new MeshLambertMaterial({ color: SURFACE_COLOR })
const smooth = new Mesh(geometry, smoothMaterial)
smooth.position.x = OFFSET_X
scene.add(smooth)

// 光源の向きは主題ではないので固定する。左右どちらの球にも同じ向きから当たる
const light = new DirectionalLight(LIGHT_COLOR, 2.5)
light.position.set(4, 5, 3)
scene.add(light)

scene.add(new AmbientLight(LIGHT_COLOR, 0.4))

分割するポリゴンの数を増やすほどカクカクした形状になりますが、スムーズシェーディングを用いて濃淡を連続させるだけで、表面の滑らかさを維持することができます。

物体表面のリアリティ
CG

3Dモデルの描き方を工夫するだけで、物体の形状自体にリアリティを持たせることができます。しかし、物体表面の材質による見え方の違いを表現するには、さらに工夫が必要です。

テクスチャの利用

木目やタイルの目地といった模様は、形状の情報だけでは表せません。また、その模様を精密に描こうとすると、レンダリングでの計算がとても重く、複雑な実装になってしまいます。

そこで、表面の模様については事前に画像を用意しておき、その画像を形状の表面に貼りつける手法がよく用いられます。表面に貼り付ける模様の画像をテクスチャと呼び、テクスチャを形状の表面に対応づけて貼ることをテクスチャマッピング といいます。

テクスチャマッピングでは、表面が滑らかな物体に模様をつけるだけでなく、表面の凹凸を表現することもできます。面の向きを画像内の座標によってずらし、平らな面に凹凸があるように見せる手法がバンプマッピング です。形状そのものは平らなままなので、輪郭に凹凸は現れませんが、擬似的に凹凸を表現できます。

さまざまなテクスチャマッピングの手法を使うと、ポリゴンを増やさずに表面の質感の情報を加えられるため、計算量を抑えたまま見え方を大きく変えられます。

光の進む方向
CG

模様や凹凸の表現を加えても、金属の照り返しやガラスの透明感までは表現できません。こうした違いは、物体の面が光をどれだけ跳ね返し、どれだけ通すかという、物体の反射特性から生まれます。

反射と透過

よく磨かれた金属や水面のように、決まった向きへ強く反射 する面には、光源だけでなく周囲の風景まで写り込みます。この映り込みがあるかどうかで、同じ形状でも材質の印象は大きく変わります。

また、ガラスや水のように光を通す物体では、物体に当たった光が、反射する光(面で跳ね返る光)と透過する光(内部へ入っていく光)の2種類に分かれます。

物体の内部へ入った透過光は、物体の境目で進む向きが変わる屈折 を起こすため、物体の向こう側は歪んで見えます。屈折の度合いも材質ごとに決まっているため、ガラスと水では同じ厚みでも歪み方が異なります。

このように、写実的なレンダリングでは、反射・透過・屈折の表現 も重要となります。

間接光

光の反射や透過を計算しても、光源から直接届く光だけを追っていたのでは、リアリティとしては不十分な場合もあります。

たとえば、照明が直接当たっていない場所も、真っ暗に見えることはありません。壁や床で跳ね返った反射光が回り込んで、その場所を照らしているためです。

光源から直接届く光に対して、他の物体で反射してから届く光を間接光といいます。間接光まで計算に入れると、影の中がうっすらと明るくなり、たとえば赤い壁のそばに置いた白い物体がほんのり赤みを帯びる見え方まで再現できます。

光の物理的性質
CG

光の波としての振る舞いも、さらにリアリティを向上させる要素となります。
たとえば、光が他の物体を通り抜けきれずに回り込む干渉 や、光が大気の中を進む間に散らされる散乱 などです。

散乱と減衰

遠くの山が白っぽく霞んで見えるのは、人間の眼と山との間にある大気が関係しています。
大気中では、空気中の分子や細かな粒子が光を散乱させ、遠くから届く光ほど途中で減衰してしまいます。

散乱・減衰の表現 についての処理を加えると、霧や霞に包まれた風景、青く見える空、光を透かす雲まで扱えるようになります。ワイヤフレーム表示で使われていたデプスキューイングは、この見え方を線の輝度だけで近似したものともいえます。

分散・干渉・回折

プリズム に白色光を通すと、光の波長ごとの色を分けて観察することができます。これは、屈折の度合いが波長によって違うために起こる現象で、光の分散と呼ばれます。

シャボン玉の膜や、CDの記録面に浮かぶ虹色 は、また別の理由で生まれます。薄い膜の表と裏で反射した光が重なり合う干渉や、細かい溝で光の進む向きが曲げられる回折が、見る角度によって色を変えているためです。

このように、分散・干渉・回折も、光を1本の線として追うだけでは再現できません。

どこまでのリアリティを求めるかによって、こうした現象まで計算に含めるかどうかを判断する必要があります。リアリティに関わる要素を積み上げるほど実物に近づく一方で、計算の量もその分増えていきます。適切なモデリング手法とレンダリング手法を組み合わせて、処理を軽量化することも重要です。