📦

ターミナル上に3D空間を作ってみた

に公開

はじめに

こんにちは!
今日の team411 Advent Calendar 2025 の記事を担当する takaaat です。昨日は ふ さんによるコンピュータの仕組みを(ちょっと)理解したい でした。

この記事では、高校数学と線形代数の知識を少しだけ使って3D描画の仕組みを学び、ターミナル上で実際に1から実装してみたというお話をしていきます。

3D描画と聞くとめちゃくちゃ難しそうなイメージがありましたが、実装してみると思った以上にシンプルにそれっぽいものが作れます。数学やプログラミングに苦手意識がある方でも、ゆっくり見ていってもらえればと思います。

今回作るもの

早速今回作るものがこちら!


球体が惑星のように回っている様子と、立方体がぐるぐる回っているアニメーションをCUI(ターミナル)上で実行しています。

「いや、それだけ...?」と思われるかもしれませんが(というか実際にそれだけなんですが)、たったこれだけのことをするプログラムのために数学が活用されていると考えるとなんか良いですよね...!
CGの勉強の基礎になりますし、何より理屈を学ぶだけでなく実際に動くものを作ったほうが楽しいですよね。

今回は、この描画の仕組みとその実装コードを示しながら解説していきます。
n番煎じなネタではありますが、数学が苦手な視点から引っかかったポイントなども書いていくので、3D CGの勉強のきっかけになれば嬉しいです。

※私自身も勉強しつつ記述しているため、厳密な解説ではない部分もあります。なんとなく雰囲気でこういうことをやっているんだという理解のためのものとして捉えてください。

それでは早速作っていきましょう!

3Dを2Dに落とし込む

3D空間の物体を2Dのディスプレイ上に表示するには、3次元ベクトル (x, y, z) で表される座標を、ディスプレイ上の2次元座標 (x, y) に変換しなければなりません。

これを実現するために、以下のような図を考えます。

まずxyz空間があります。原点にユーザーの目である視点があるとします。
今回はz軸のプラスの方向に目線が向いているとしましょう。簡単のため、視点は常に +z 方向に固定します。

次に、青色で示された長方形のスクリーンを考えます。この平面に3次元の景色が投影され、それが最終的に私たちの目に入ることになります。

例えば、スクリーンより奥側(y 方向に少し上)にある物体(点 P)を見るとします。
この点 P を目 O から見るとき、スクリーン上ではどの位置に見えるでしょうか?

上の図形に示されたように、 S の位置に投影されます。目 O と点 P を結んだ紫色の線と、スクリーンとの交点 S を考えると、その交点がちょうど2D画面上で見える位置になります。

このようにして、3Dの位置を2Dのスクリーンの座標に変換していきます。
この図形的な処理により、3D上の点 P(P_x, P_y, P_z) を、スクリーン上の座標 S(S_x, S_y) に変換できます。

実際に図形の比を使って座標を計算してみましょう。
目の位置とスクリーンとの距離を d とおき、図のように横から見ると相似な三角形が現れます。

比を考えると以下のようになります。

d : P_z = S_y : P_y

これより、

S_y = d \cdot \frac{P_y}{P_z}

これでスクリーン上の y 座標を求めることができました。

3D上の物体が座標 P にあるとき、その y, z 座標とスクリーン距離 d を計算すれば、画面上の位置 S_y がわかるということです。

同様に x 座標についても計算すると、

d : P_z = S_x : P_x より、 S_x = d \cdot \frac{P_x}{P_z}

となります。透視投影の基本的な考え方ではこのような方法を用います。

点ではない球体や立方体を描画したい

上記の方法では3Dの点の座標を2Dのスクリーン上の座標に変換することはできました。
しかし、球体や立方体のような、面を持つ物体を描画する場合、すべての表面の点を計算するのは大変です。

そこで、先ほどとは逆向きの方法をとります。
つまり、「3D座標 → スクリーン」ではなく、「目 → スクリーン → 3D空間」の順で考えます。
「目からスクリーンのあるピクセルを通して視線を伸ばし、その先に物体があれば色を塗る」という考え方です。簡単なレイトレーシングの手法を使います。

ここでベクトルを使います。
目の座標を原点 O、スクリーン上の座標を S とすると、直線 OS 上にある任意の点 P は、媒介変数 t を用いて以下のように表されます。

\overrightarrow{OP} = t \overrightarrow{OS}

これで、視線の先にある点 P の座標が t を用いて表せるようになりました。
次に球体について考えます。球体の中心座標を C、半径を r とすると、球の表面上の点 P は以下の関係を満たします。

|\overrightarrow{OP} - \overrightarrow{OC}| = r

これら2つの式を連立させます。つまり、視線上の点 P が球面上にあるときを考えます。
両辺を2乗して展開していくと、

|\overrightarrow{OP} - \overrightarrow{OC}|^2 = r^2

(t \overrightarrow{OS} - \overrightarrow{OC}) \cdot (t \overrightarrow{OS} - \overrightarrow{OC}) = r^2

t^2 |\overrightarrow{OS}|^2 - 2t (\overrightarrow{OS} \cdot \overrightarrow{OC}) + |\overrightarrow{OC}|^2 - r^2 = 0

これは t に関する二次方程式 at^2 + bt + c = 0 の形になっています。
解の公式を使って t を解けば、視線と球体の交点が求められます。

二次方程式の判別式を D とすると、D < 0では解なしで、視線は球体に当たらない、D = 0では重解で球体に接する、D > 0では異なる2つの実数解をもち球体を貫通する、つまり手前と奥の2点で交わることになります。

したがって、スクリーン上のすべてのピクセルについてこの計算を行い、D \ge 0 となるピクセルに色を塗れば、画面上に球体が浮かび上がります!

一般化すると、スクリーン上の全てのピクセルの座標 S についてそれぞれ、視点からのベクトルOSの線のを伸ばした先に物体があると仮定した点 P を考え、物体上の点を表す式に点 P を代入し計算したときに t が存在する場合はスクリーンの S のピクセルに色を塗るという処理を行います。

Rubyで実装してみる

まずは単純に「球体がある場所」を塗りつぶすコードを実装します。

ピクセルを塗る情報が記録されるboardを定数に沿って作成し、各ピクセルについて上記の計算を行って、実際にd>=0となった箇所に"#"を書きます。

また、ターミナルのフォントは縦長なので、Y座標を2倍して計算上の比率を合わせています。

require 'matrix'

SCREEN_WIDTH = 100
SCREEN_HEIGHT = 50
SCREEN_DISTANCE = 50

def main
  board = Array.new(SCREEN_HEIGHT) { Array.new(SCREEN_WIDTH, ".") }
  c = Vector[10, 10, 200]
  r = 50

  SCREEN_HEIGHT.times do |y|
    SCREEN_WIDTH.times do |x|
      s = Vector[x - SCREEN_WIDTH / 2, (y - SCREEN_HEIGHT / 2) * 2, SCREEN_DISTANCE]
      sdotc = s.inner_product(c)
      d = sdotc**2 - s.norm**2 * (c.norm**2 - r**2)
      
      if d >= 0
        t = (sdotc - Math.sqrt(d)) / (s.norm**2)
        board[y][x] = "#"
      end
    end
  end

  print "\e[H\e[2J"
  board.each do |line|
    puts line.join
  end
end

main

実行すると、球の形に文字が表示されます。まだのっぺりとしていて立体感はありません。これを次の章で影の効果をそれっぽく作ることで立体的に見せていきます。

影の効果を作る

立体感を出すために影をつけていきます。光が当たっている面は明るく、当たっていない面は暗く表示します。今回はアスキーアートを使うので濃い部分はASCII文字の密度が高そうなものと低そうなものを使い分けて濃淡の表現をします。

太陽などの無限遠にある光源を想定し、光の方向ベクトルを \overrightarrow{L} とします。
物体の表面の法線ベクトル(表面に対して垂直なベクトル)と、光の方向ベクトルの内積をとることで、その面がどれくらい光を受けているかを計算できます。

上の図では、黄色い線が光源からの光のベクトルで、黒い線が物体の表面、オレンジの線が物体の表面の法線ベクトルです。正規化した二つのベクトルの内積を計算すると、\theta の比較ができます。

ここでは光の方向ベクトル \overrightarrow{L} を光が射す方向として定義しています。
物体の表面が光源に真向かいになっているとき、法線ベクトルと光のベクトルは逆向きで、なす角が180度に近くなります。
つまり、内積の値がマイナスに大きいほど光を正面から受けているため明るく、逆に0に近いほど光が当たっていないことになります。

球体の場合、表面上の点 P における法線ベクトルは、中心 C からその点へのベクトル \overrightarrow{CP} を正規化したものです。

Rubyで実装してみる

影の効果を追加し、さらに球体を複数個置いてアニメーションさせてみます。

require 'matrix'

SCREEN_WIDTH = 100
SCREEN_HEIGHT = 50
SCREEN_DISTANCE = 50

class Sphere
  attr_accessor :centre, :radius

  def initialize(centre, radius)
    @centre = centre
    @radius = radius
  end
end

def main
  spheres = [
    Sphere.new(Vector[0, 0, 200], 50),
    Sphere.new(Vector[140, 0, 300], 50),
    Sphere.new(Vector[-140, 0, 300], 50),
  ]

  theta = 0
  print "\e[2J"

  loop do
    board = Array.new(SCREEN_HEIGHT) { Array.new(SCREEN_WIDTH, " ") }
    theta += 0.1

    kaiten = Matrix[
      [Math.cos(theta), Math.sin(theta), 0],
      [-Math.sin(theta), Math.cos(theta), 0],
      [0, 0, 1],
    ]

    light_direction = kaiten * Vector[-1, -1, -1].normalize

    SCREEN_HEIGHT.times do |y|
      SCREEN_WIDTH.times do |x|
        s = Vector[x - SCREEN_WIDTH / 2, (y - SCREEN_HEIGHT / 2) * 2, SCREEN_DISTANCE]
        min_t = Float::INFINITY

        spheres.each do |sphere|
          c = sphere.centre
          r = sphere.radius
          sdotc = s.inner_product(c)
          d = sdotc**2 - s.norm**2 * (c.norm**2 - r**2)

          if d >= 0
            t_current = (sdotc - Math.sqrt(d)) / (s.norm**2)
            
            if t_current > 0 && t_current < min_t
              min_t = t_current
              p = s * t_current
              housen = (p - c).normalize
              a = housen.inner_product(light_direction)
              
              char_idx = if a < -0.8 then "#"
                   elsif a < -0.6 then "%"
                   elsif a < -0.4 then "o"
                   elsif a < -0.2 then "+"
                   else ","
               end
              board[y][x] = char_idx
            end
          end
        end
      end
    end

    output = "\e[H"
    board.each { |line| output << line.join + "\n" }
    print output
    sleep 0.05
  end
end

main

順番に見ていきます。Sphereクラスを球体のオブジェクトとします。spheresにSphereのオブジェクトをいくつか作って保存しておきます。

光源を動かして影が機能しているか確認するために kaiten 行列を作って、thetaを調整しながらlight_direction(光の向き)にかけています。
そして先程示した方法で以下のように法線ベクトル、法線ベクトルと光の向きのベクトルの内積aをもとにしてアスキーアートを濃さ別に塗っています。

  p = s * t_current
  housen = (p - c).normalize
  a = housen.inner_product(light_direction)
  
  char_idx = if a < -0.8 then "#"
             elsif a < -0.6 then "%"
             elsif a < 0.4 then "o"
             elsif a < 0.2 then "+"
             else ","
             end
  board[y][x] = char_idx

複数の球体が重なった場合、単純に後から描画すると前後関係がおかしくなります。
そこで、min_t という変数を用意し、今まで描画した中で最も手前にある物体の距離を記録しておきます。新しく計算した交点までの距離 t_currentmin_t より小さい場合に、ピクセルを上書きするようにしています。

色々追加してみる

一番初めに示したようなごちゃごちゃした感じにするために球体をいくつか追加したり重ねたり動かしてみました。

ASCII_CHARSを使用して順番に薄い順番で使うように変更しました。Sphereは、目線の先で公転してほしかったので、初めに原点を基準において回転行列をかけたあと、スクリーンの奥になるように移動しています。

require 'matrix'


SCREEN_WIDTH = 100
SCREEN_HEIGHT = 50
SCREEN_DISTANCE = 50
ASCII_CHARS = ".,:-=+*#%@"

class Sphere
  attr_accessor :centre, :radius

  def initialize(centre, radius)
    @centre = centre
    @radius = radius
  end
end

def main()
    print "\e[2J"

    spheres = [
        Sphere.new(Vector[0, 0, 0], 36),
        Sphere.new(Vector[70, 0, 0], 18),
        Sphere.new(Vector[-70, 0, 0], 18),
        Sphere.new(Vector[0, 70, 0], 18),
        Sphere.new(Vector[0, -70, 0], 18),
    ]

    tilt_rad = 0.5
    tilt_matrix = Matrix[
      [Math.cos(tilt_rad), -Math.sin(tilt_rad), 0],
      [Math.sin(tilt_rad), Math.cos(tilt_rad), 0],
      [0, 0, 1]
    ]


    theta = 0
    loop do
        board = Array.new(SCREEN_HEIGHT) { Array.new(SCREEN_WIDTH, " ") }
        theta += 0.1
    
        cos_t = Math.cos(theta)
        sin_t = Math.sin(theta)
        rot_matrix = Matrix[
          [cos_t, 0, sin_t],
          [0, 1, 0],
          [-sin_t, 0, cos_t]
        ]
    
        rotated_spheres = spheres.map do |s|
          rotated_centre = tilt_matrix * rot_matrix * s.centre
          Sphere.new(rotated_centre+Vector[0,0,100], s.radius)
        end
    
    
        kaiten = Matrix[
            [Math.cos(theta), Math.sin(theta), 0],
            [-Math.sin(theta), Math.cos(theta), 0],
            [0, 0, 1],
        ]
    
        light_direction = Vector[Math.sin(theta), -1, Math.cos(theta)].normalize
    
    
        SCREEN_HEIGHT.times do |y|
            SCREEN_WIDTH.times do |x|
                s = Vector[x-SCREEN_WIDTH/2,(y-SCREEN_HEIGHT/2)*2,SCREEN_DISTANCE]
                t = Float::INFINITY
                rotated_spheres.each do |sphere|
                    c = sphere.centre; r = sphere.radius
                    sdotc = s.inner_product(c)
                    d = sdotc**2 - s.norm**2 * (c.norm**2 - r**2)
                    if (d >= 0)
                        t_current = (sdotc + Math::sqrt(d))/(s.norm**2)
                        if (t_current > 0 && t_current < t)
                            t = t_current
                            p = Vector[0,0,0] + s*t
                            normal = (p - c).normalize
                            intensity = normal.inner_product(-light_direction)
                            idx = ([0, intensity].max * (ASCII_CHARS.length - 2)).to_i
                            board[y][x] = ASCII_CHARS[idx]
                       end
                    end
                end
            end
        end
    
        output = "\e[H"
        board.each { |line| output << line.join + "\n" }
        print output
        sleep 0.05
    end
end

main()

立方体も作る

最後に立方体をつくります。
立方体は6つの平面の長方形で構成されています。

球体のときにも行ったように、視点からのベクトルOSの線のを伸ばした先の目線上に物体があるかどうかを調べていきます。まず、四角形ABCDのある平面上にあることが成り立つことと、長方形の内部にあるかどうかの2つが成り立つ必要があります。

平面上の点 A と法線ベクトル n があるとき、平面上の任意の点 P(\overrightarrow{OP} - \overrightarrow{OA}) \cdot \overrightarrow{n} = 0 を満たします。これに視線の式 \overrightarrow{P} = t\overrightarrow{S} を代入して t を求めると、以下のようになります。
t = \frac{\overrightarrow{OA} \cdot \overrightarrow{n}}{\overrightarrow{S} \cdot \overrightarrow{n}}

法線ベクトルは以下の画像のように \overrightarrow{AB} \times \overrightarrow{AD} で計算できます。

次に、長方形の内部にあるかどうかを確認していきます。交点 P が長方形 ABCD の中にあるかは、外積を使います。各辺のベクトル \overrightarrow{AB} と、頂点Aから点Pへのベクトル \overrightarrow{AP} の外積を計算し、それが法線ベクトルと同じ向きかどうかを調べます。これを4つの辺すべてで行い、すべて条件を満たせば点は長方形の内部にあると判定できます。

以下が実装したRubyのプログラムです。同様にRectクラスを作成し、rects配列にデータを入れておきます。今回はlightを固定して、rotate_matrixを2つ用意してそれを立方体の各面に同じように掛けることで立方体を回転させます。

require 'matrix'

SCREEN_WIDTH = 100
SCREEN_HEIGHT = 50
SCREEN_DISTANCE = 50
ASCII_CHARS = ".,:-=+*#%@"

class Rect
  attr_accessor :a, :b, :c, :d

  def initialize(a, b, c, d)
    @a = a
    @b = b
    @c = c
    @d = d
  end

  def normal
    (@b - @a).cross_product(@d - @a).normalize
  end
end

def main
  print "\e[2J"

  rects = [
    Rect.new(Vector[-50, 50, -50], Vector[-50, -50, -50], Vector[50, -50, -50], Vector[50, 50, -50]),
    Rect.new(Vector[50, 50, 50], Vector[50, -50, 50], Vector[-50, -50, 50], Vector[-50, 50, 50]),
    Rect.new(Vector[-50, 50, 50], Vector[-50, -50, 50], Vector[-50, -50, -50], Vector[-50, 50, -50]),
    Rect.new(Vector[50, 50, -50], Vector[50, -50, -50], Vector[50, -50, 50], Vector[50, 50, 50]),
    Rect.new(Vector[-50, 50, 50], Vector[-50, 50, -50], Vector[50, 50, -50], Vector[50, 50, 50]),
    Rect.new(Vector[-50, -50, -50], Vector[-50, -50, 50], Vector[50, -50, 50], Vector[50, -50, -50])
  ]

  theta = 0
  light_direction = Vector[-1, -1, -1].normalize

  loop do
    board = Array.new(SCREEN_HEIGHT) { Array.new(SCREEN_WIDTH, " ") }
    theta += 0.1

    cos_t = Math.cos(theta)
    sin_t = Math.sin(theta)
    
    rot_matrix_y = Matrix[
      [cos_t, 0, sin_t],
      [0, 1, 0],
      [-sin_t, 0, cos_t]
    ]

    rot_matrix_x = Matrix[
      [1, 0, 0],
      [0, cos_t, -sin_t],
      [0, sin_t, cos_t]
    ]

    rot_matrix = rot_matrix_x * rot_matrix_y
    
    rotated_rects = rects.map do |r|
      offset = Vector[0, 0, 200]
      Rect.new(
        rot_matrix * r.a + offset,
        rot_matrix * r.b + offset,
        rot_matrix * r.c + offset,
        rot_matrix * r.d + offset
      )
    end

    SCREEN_HEIGHT.times do |y|
      sy = (y - SCREEN_HEIGHT / 2) * 2
      SCREEN_WIDTH.times do |x|
        sx = x - SCREEN_WIDTH / 2
        s = Vector[sx, sy, SCREEN_DISTANCE]
        
        min_t = Float::INFINITY

        rotated_rects.each do |rect|
          normal = rect.normal
          s_dot_n = s.inner_product(normal)
          
          next if s_dot_n == 0

          t_current = rect.a.inner_product(normal).to_f / s_dot_n
          
          next if t_current <= 0 || t_current >= min_t

          p = s * t_current
          
          edges = [
            [rect.a, rect.b],
            [rect.b, rect.c],
            [rect.c, rect.d],
            [rect.d, rect.a]
          ]

          is_inside = edges.all? do |p1, p2|
            (p2 - p1).cross_product(p - p1).inner_product(normal) >= 0
          end

          if is_inside
            min_t = t_current
            intensity = normal.inner_product(light_direction)
            idx = ([0, -intensity].max * (ASCII_CHARS.length - 1)).round
            board[y][x] = ASCII_CHARS[idx]
          end
        end
      end
    end

    output = "\e[H"
    board.each { |line| output << line.join + "\n" }
    print output
    sleep 0.05
  end
end

main

以上で、ターミナル上で3Dアニメーションを描画することができました。

改善点

今回はベクトルを扱いやすくするためにRubyのVectorを使いましたが、簡単な内積や外積の計算だけで行えるので、それらを計算する関数を用意して使うようにすればより高速に動作します。

おわりに

影っぽいものの描画はかなり簡単な方法を採用しましたが、数式部分を考えながらそれを実装してみると楽しいのでぜひ試してみてください!以上CUIで3D描画っぽいことをしてみる話でした。

明日のアドベントカレンダーの記事はTaragodさんによる記事です。お楽しみに!

Discussion