ターミナル上に3D空間を作ってみた
はじめに
こんにちは!
今日の team411 Advent Calendar 2025 の記事を担当する takaaat です。昨日は ふ さんによるコンピュータの仕組みを(ちょっと)理解したい でした。
この記事では、高校数学と線形代数の知識を少しだけ使って3D描画の仕組みを学び、ターミナル上で実際に1から実装してみたというお話をしていきます。
3D描画と聞くとめちゃくちゃ難しそうなイメージがありましたが、実装してみると思った以上にシンプルにそれっぽいものが作れます。数学やプログラミングに苦手意識がある方でも、ゆっくり見ていってもらえればと思います。
今回作るもの
早速今回作るものがこちら!


球体が惑星のように回っている様子と、立方体がぐるぐる回っているアニメーションをCUI(ターミナル)上で実行しています。
「いや、それだけ...?」と思われるかもしれませんが(というか実際にそれだけなんですが)、たったこれだけのことをするプログラムのために数学が活用されていると考えるとなんか良いですよね...!
CGの勉強の基礎になりますし、何より理屈を学ぶだけでなく実際に動くものを作ったほうが楽しいですよね。
今回は、この描画の仕組みとその実装コードを示しながら解説していきます。
n番煎じなネタではありますが、数学が苦手な視点から引っかかったポイントなども書いていくので、3D CGの勉強のきっかけになれば嬉しいです。
※私自身も勉強しつつ記述しているため、厳密な解説ではない部分もあります。なんとなく雰囲気でこういうことをやっているんだという理解のためのものとして捉えてください。
それでは早速作っていきましょう!
3Dを2Dに落とし込む
3D空間の物体を2Dのディスプレイ上に表示するには、3次元ベクトル
これを実現するために、以下のような図を考えます。

まずxyz空間があります。原点にユーザーの目である視点があるとします。
今回はz軸のプラスの方向に目線が向いているとしましょう。簡単のため、視点は常に
次に、青色で示された長方形のスクリーンを考えます。この平面に3次元の景色が投影され、それが最終的に私たちの目に入ることになります。
例えば、スクリーンより奥側(
この点
上の図形に示されたように、
このようにして、3Dの位置を2Dのスクリーンの座標に変換していきます。
この図形的な処理により、3D上の点
実際に図形の比を使って座標を計算してみましょう。
目の位置とスクリーンとの距離を

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

これより、
これでスクリーン上の
3D上の物体が座標
同様に
となります。透視投影の基本的な考え方ではこのような方法を用います。
点ではない球体や立方体を描画したい
上記の方法では3Dの点の座標を2Dのスクリーン上の座標に変換することはできました。
しかし、球体や立方体のような、面を持つ物体を描画する場合、すべての表面の点を計算するのは大変です。
そこで、先ほどとは逆向きの方法をとります。
つまり、「3D座標 → スクリーン」ではなく、「目 → スクリーン → 3D空間」の順で考えます。
「目からスクリーンのあるピクセルを通して視線を伸ばし、その先に物体があれば色を塗る」という考え方です。簡単なレイトレーシングの手法を使います。
ここでベクトルを使います。
目の座標を原点
これで、視線の先にある点
次に球体について考えます。球体の中心座標を
これら2つの式を連立させます。つまり、視線上の点
両辺を2乗して展開していくと、
これは
解の公式を使って

二次方程式の判別式を
したがって、スクリーン上のすべてのピクセルについてこの計算を行い、
一般化すると、スクリーン上の全てのピクセルの座標
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文字の密度が高そうなものと低そうなものを使い分けて濃淡の表現をします。
太陽などの無限遠にある光源を想定し、光の方向ベクトルを
物体の表面の法線ベクトル(表面に対して垂直なベクトル)と、光の方向ベクトルの内積をとることで、その面がどれくらい光を受けているかを計算できます。

上の図では、黄色い線が光源からの光のベクトルで、黒い線が物体の表面、オレンジの線が物体の表面の法線ベクトルです。正規化した二つのベクトルの内積を計算すると、
ここでは光の方向ベクトル
物体の表面が光源に真向かいになっているとき、法線ベクトルと光のベクトルは逆向きで、なす角が180度に近くなります。
つまり、内積の値がマイナスに大きいほど光を正面から受けているため明るく、逆に0に近いほど光が当たっていないことになります。
球体の場合、表面上の点
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_current が min_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つが成り立つ必要があります。
平面上の点
法線ベクトルは以下の画像のように

次に、長方形の内部にあるかどうかを確認していきます。交点
以下が実装した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