【翻訳】KotlinのASCIIアートロゴをターミナルで回転させる
はじめに
Kotlinでの技術力をあげたいなあとしみじみと思っているのですが、いかんせん作りプロダクトを思いつかないのです。とくに人に役に立つようなツールやマネタイズは僕は心底大嫌いで、これ何の役に立つんだろう、というものの方が好きなのです。そんな中、Youtube上でMaking a 3D rotating ASII cube in the terminalという動画を見つけて、自分でもこれKotlinで作ってみたいなあと思いました。
ですが、ふと思いつくことは大体誰かが先にやっているものです。実際調べてみると、今回翻訳する記事を見つけることになりました1。まずはこの記事で基礎的なことを学んで、数学をおさらいしてから、また別の記事で実装を試みたいと思います。
Rotating the Kotlin Logo with ASCII art in terminal
おそらく古典的なターミナルで回転するASCIIドーナツを一度は見たことがあるでしょう。スクラッチのKotlinで3Dターミナルレンダラーを作ろうと決めました。ドーナツの代わりに、自分自身のチャレンジとしてKotlinのロゴををレンダリングすることにしました。
こちらは、その様子です。
これは数学的な変換だけで、ターミナル画面全体で動きます。
プロセスの概略
アニメーションをレンダリングするために、レンダラーは9つのステップを実行します。
- 3次元空間の面として幾何学を定義する
- それぞれの面について、局所座標系を作成する
- point-in-polygonテストを使用して、面の内部の局所座標系にある点を見つける
- 面に3次元回転を適用する
- それぞれの点で色のアタを計算する
- ランベルトの余弦則2を用いてそれぞれの面の光の角度を計算する
- 3次元上面を2次元の視点に射影する
- 射影された2次元の点をASCII文字に置き換える
- バッファを使ってocclusionを扱う
ステップ1: 幾何学的な表現
Kotlinのロゴは3つの主要な部分で構成されています。2つの三角形のピースと1つの台形のピースです。それぞれが面の集合としてモデル化されます。
訳註:
このように2つの三角形のピースと1つの台形のピースに分解できるという意味ですが、現在のロゴは紫のグラデーションの単一の図形で構成されており、分解はできません。
面の垂直方向は面がどの方向に向いているかを示してくれます。
外積は両端のベクトルに対する垂直なベクトルを生成し、それを規格化して長さ1にします。
訳註: 視覚的には次のような図を考えてください。
面の定義の例です:
Face(
points = listOf(
Vector3d(0.0, 1.0, 0.25),
Vector3d(-1.0, 1.0, 0.25),
Vector3d(-1.0, 0.0, 0.25),
),
color = FaceColor.Gradient(
Vector3d(-1.0, 0.0, 0.25),
Vector3d(0.0, 1.0, 0.25),
Color.KOTLIN_BLUE,
Color.KOTLIN_PURPLE
)
)
ステップ2: 局所座標系を作成する
問題:3次元空間の頂点(vertex) によって定義される面があります。点がなす面を効率的に決定する必要があるとします。2次元座標が面の中の点へ写すかどうかテストするために、3次元面を2次元平面上へ平坦化する必要があります。
解: 面の表面上に平坦に存在する、互いに直交する2本の基底ベクトルとを構成します。
- (に並行であることを除いて)任意のベクトルをとります
val arbitrary = if (abs(normal.x) > 0.5)
Vector3d(0.0, 1.0, 0.0)
else
Vector3d(1.0, 0.0, 0.0)
- 法線方向に平行な成分を射影によって取りのぞきます:
訳註: 視覚的には次のような図を考えてください。
それから規格化します:
- 直交基底(ベクトル)を完成させます:
よって今、面上の任意の点は次のように書けるようになります:
ここで、とは局所座標系の2次元座標です。
訳註: 視覚的には次のような図を考えてください。
ステップ3: point-in-polygonテスト
レイキャスティングアルゴリズム:判定したい点から左方向に水平な半直線(ray = 光線)を伸ばします。この半直線が多角形の辺と交差する回数を数えます。 交差回数が奇数なら内部、偶数なら外部と判断します。
頂点からのそれぞれの辺に対して、
- 辺が水平ならスキップします()
- テスト点の座標がとの間に入っていない場合はスキップする
- 半直線がその辺と交差する座標を計算する
- であれば、交差回数をインクリメントする
private fun pointInPolygon(testX: Double, testY: Double): Boolean {
var intersections = 0
for (i in projected2D.indices) {
val vertex1 = projected2D[i]
val vertex2 = projected2D[(i + 1) % projected2D.size]
if (vertex1.y == vertex2.y) continue
if (testY < min(vertex1.y, vertex2.y) ||
testY >= max(vertex1.y, vertex2.y)) continue
val xIntersect = vertex1.x + (testY - vertex1.y) *
(vertex2.x - vertex1.x) / (vertex2.y - vertex1.y)
if (testX < xIntersect) intersections++
}
return intersections % 2 == 1
}
訳註: 視覚的には次のような図を考えてください。
ステップ4: 3次元回転変換
回転するロゴをアニメとして映すためには、3次元の点に対して回転行列を作用させる必要があります。
軸回転 (pitch)
軸回転 (yaw)
合成された変換:まずは軸回転、次に軸回転:
注意:回転行列は交換しないので、順番は重要です!
点の位置と面上の法線の両方が変換されます:
val rotatedPoint = Rotation.rotateXY(point, angleX, angleY)
val rotatedNormal = Rotation.rotateXY(normal, angleX, angleY)
ステップ5: グラデーションカラーの計算
Kotlinロゴはカラーグラデーションが使用されています。点色のから点色のまでのグラデーションを計算する必要があります。
- 勾配方向のベクトルを定義します:
- 現在の点から勾配へ射影します
この値は:
- は開始の点を意味します
- は終了の点を意味します
- は開始と終了のどこかの間を意味します
色の線形補完:
RGB成分に対して:
例 赤RGB(255, 0, 0)から青RGB(0, 0, 255)まで線形補完するときの(半分の地点)の場合、
結果:紫RGB(128, 0, 128)
ステップ6: Lambertの余弦則による拡散反射ライティング
位置にある点光源をシミュレートします。明るさは表面と光の間の角度に依存します。
- 表面上の点から光源への光の方向を計算する:
- 両方のベクトルを規格化する:
- ライティングの強度を計算します:
内積はを計算します:
- もし表面が光源に相対していたら:角度はで、明るい
- もし表面が光源の垂直であったら:角度はで、暗い
- もし表面が光源の裏側にあったら:角度で、強度を0に固定する、暗い
fun calculateDiffuseLighting(
surfacePoint: Vector3d,
surfaceNormal: Vector3d
): Double {
val toLightVector = Vector3d(position).sub(surfacePoint)
val normalUnit = Vector3d(surfaceNormal).normalize()
val lightUnit = toLightVector.normalize()
return normalUnit.dot(lightUnit).coerceAtLeast(0.0)
}
ステップ7: 視点の射影
いま3次元空間の世界は2次元のスクリーン上に射影されています。遠くにあるオブジェクトほど小さく現れます。
- 座標(深さ)で割る
- スクリーンの座標を計算する
ここで、
- はスクリーンの幅と高さ
- は視野をコントロールするスケール因子
- はカメラの距離(ゼロで割ることを防ぐため)
val oneOverZ = 1.0 / (point.z + config.zOffset)
val xScreen = (config.screenWidth / 2 +
point.x * config.scaleX * oneOverZ).toInt()
val yScreen = (config.screenHeight / 2 -
point.y * config.scaleY * oneOverZ).toInt()
ステップ8: ASCII文字へのマッピング
明るさと色を色付きASCII文字に変換します。
- 明るさに基づく文字選択:
ここで、ramp=”.,- ␠:;=!*#$@“です。
訳註:はフロア記号で、以下の最大の整数をとります。例えば、、です。
疎な文字(例:.)は暗い領域を表し、密な文字(例:@)は明るい領域を表す。
- 照明に基づく色の暗化(減光処理):
ここでは最小の明るさの閾値です(0.85くらいがいい結果であることが分かりました)。これは影の中で色が完全に黒になることを防ぎます。
それぞれのRGB成分:
ステップ9: オクルージョン(遮蔽)のためのバッファ
可視性の問題: 複数の表面が同じスクリーン上のピクセルに投影された場合、どれを表示すべきでしょうか?
解決策は各ピクセルについて、これまでに描画された中で最もカメラに近い表面の深度を保存することです。
なぜなら、であり、この値が大きいということは小さいを意味するので、それはカメラに近いことを意味します。
fun trySetPixel(x: Int, y: Int, depth: Double, content: String): Boolean {
val index = x + y * width
if (depth > zBuffer[index]) {
zBuffer[index] = depth
displayBuffer[index] = content
return true
}
return false
}
以上です! これらすべての要素を組み合わせれば、美しいグラデーションと回転を備えた、ほぼあらゆる形状をターミナル上で描画できます。
詳細を学び、実際に動かしてみたい方は、私の GitHub にあるソースコードを確認してください。
https://github.com/jashioq/ASCII_kotlin_logo
翻訳中のメモ
- Chances are (that) …: 多分…であろう
- occlusion: 排除、閉塞、閉鎖、妨害、閉塞症、咬合、遮閉法
Inuverse Sci. X Tech. Blog
このように2つの三角形のピースと1つの台形のピースに分解できるという意味ですが、現在のロゴは紫のグラデーションの単一の図形で構成されており、分解はできません。


