Skip to content

Instantly share code, notes, and snippets.

@dduan
Last active November 26, 2020 08:55
Show Gist options
  • Save dduan/ed77683dcc9b1a52f533d17f266aa769 to your computer and use it in GitHub Desktop.
Save dduan/ed77683dcc9b1a52f533d17f266aa769 to your computer and use it in GitHub Desktop.
A rotating 3-D cube in terminal. Written in Swift
/// A rotating 3-D cube in terminal
/// Only works on macOS
/// Run `swift cube.swift` in a terminal application to run it.
/// For controlling the cube, see comments for `Key` in code.
import Darwin
enum RawModeError: Error {
case notATerminal
case failedToGetTerminalSetting
case failedToSetTerminalSetting
}
func runInRawMode(_ task: @escaping () throws -> Void) throws {
var originalTermSetting = termios()
guard isatty(STDIN_FILENO) != 0 else {
throw RawModeError.notATerminal
}
guard tcgetattr(STDIN_FILENO, &originalTermSetting) >= 0 else {
throw RawModeError.failedToGetTerminalSetting
}
var raw = originalTermSetting
raw.c_iflag &= ~(UInt(BRKINT) | UInt(ICRNL) | UInt(INPCK) | UInt(ISTRIP) | UInt(IXON))
raw.c_oflag &= ~(UInt(OPOST))
raw.c_cflag |= UInt(CS8)
raw.c_lflag &= ~(UInt(ECHO) | UInt(ICANON) | UInt(IEXTEN) | UInt(ISIG))
raw.c_cc.16 = 0
raw.c_cc.17 = 1
guard tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) >= 0 else {
throw RawModeError.failedToSetTerminalSetting
}
defer {
tcsetattr(STDIN_FILENO, TCSAFLUSH, &originalTermSetting)
print("\u{1b}[?25h", terminator: "")
print("\u{1b}[0;0H")
}
print("\u{1b}[2J")
print("\u{1b}[?25l")
try task()
}
enum Key {
static let q = Character("q").asciiValue! // quit
static let d = Character("d").asciiValue! // show debug info
static let j = Character("j").asciiValue! // increase yaw
static let k = Character("k").asciiValue! // decrease yaw
static let x = Character("x").asciiValue! // increase pitch
static let b = Character("b").asciiValue! // decrease pitch
static let m = Character("m").asciiValue! // increase roll
static let w = Character("w").asciiValue! // decrease roll
static let r = Character("r").asciiValue! // reset
}
final class Graphic {
var buffer: [Character]
let width: Int
let height: Int
var clearedBuffer: [Character]
init(width: Int, height: Int) {
self.width = width
self.height = height
clearedBuffer = .init(repeating: " ", count: width * height)
buffer = clearedBuffer
}
subscript(x: Int, y: Int) -> Character {
get {
let index = y * width + x
assert(index < buffer.count)
return buffer[index]
}
set {
let index = y * width + x
guard index >= 0 && index < buffer.count else { return }
buffer[index] = newValue
}
}
func clear() {
buffer = clearedBuffer
}
}
extension timeval {
func microseconds(since other: timeval) -> Double {
Double(self.tv_usec - other.tv_usec) / 1000 + Double(self.tv_sec - other.tv_sec) * 1000
}
}
class Game {
let graphic: Graphic
let fps: Double
let msPerRender: Double
var lastRender = timeval()
var showDebugInfo = false
var loopCount = 0
var exit = false
var now: timeval {
var result = timeval()
gettimeofday(&result, nil)
return result
}
init(graphicWidth: Int, graphicHeight: Int, fps: Double = 60) {
self.graphic = .init(width: graphicWidth, height: graphicHeight)
self.fps = fps
self.msPerRender = 1 / fps
}
func positionAt(x: Int, y: Int) {
print("\u{1b}[\(y);\(x)H", terminator: "")
}
func run() throws {
try runInRawMode { [weak self] in
guard let self = self else { return }
while !self.exit {
var input: UInt8 = 0
read(STDIN_FILENO, &input, 1)
self.handle(input: input)
self.update()
let now = self.now
let msSince = now.microseconds(since: self.lastRender)
if msSince >= self.msPerRender {
self.lastRender = now
self.render()
self.positionAt(x: 0, y: 0)
for line in 0 ..< self.graphic.height {
let start = line * self.graphic.width
let end = start + self.graphic.width
self.positionAt(x: 0, y: line)
print(String(self.graphic.buffer[start ..< end]))
}
if self.showDebugInfo {
self.positionAt(x: 1, y: 1)
let lps = Int(Double(self.loopCount) / (1 / msSince))
print("w: \(self.graphic.width) h: \(self.graphic.height) lps: \(lps)")
self.loopCount = 0
}
}
self.loopCount += 1
}
}
print("\u{1b}[2J")
}
open func handle(input: UInt8) {}
open func update() {}
open func render() {}
}
struct Vec3 {
let x, y, z: Float
static func + (lhs: Self, rhs: Self) -> Self {
.init(x: lhs.x + rhs.x, y: lhs.y + rhs.y, z: lhs.z + rhs.z)
}
static func * (lhs: Self, rhs: Float) -> Self {
.init(x: lhs.x * rhs, y: lhs.y * rhs, z: lhs.z * rhs)
}
}
struct Cube {
var size: Float = 1
var scanFrequency: Float = 0.1
// (texture for the surface, vector perpendicular to the surfaces, points on the surface)
var surfaces: [(Character, Vec3, [Vec3])] {
[(Character, Vec3, Vec3, Vec3, Vec3)]([
("", .init(x: 0, y: 0, z: -1), .init(x: -0.5, y: -0.5, z: -0.5), .init(x: 1, y: 0, z: 0), .init(x: 0, y: 1, z: 0)),
("", .init(x: 0, y: 1, z: 0), .init(x: -0.5, y: 0.5, z: -0.5), .init(x: 1, y: 0, z: 0), .init(x: 0, y: 0, z: 1)),
("", .init(x: 0, y: 0, z: 1), .init(x: -0.5, y: 0.5, z: 0.5), .init(x: 1, y: 0, z: 0), .init(x: 0, y: -1, z: 0)),
("", .init(x: 0, y: -1, z: 0), .init(x: -0.5, y: -0.5, z: 0.5), .init(x: 1, y: 0, z: 0), .init(x: 0, y: 0, z: -1)),
("", .init(x: 1, y: 0, z: 0), .init(x: 0.5, y: -0.5, z: -0.5), .init(x: 0, y: 0, z: 1), .init(x: 0, y: 1, z: 0)),
("", .init(x: -1, y: 0, z: 0), .init(x: -0.5, y: 0.5, z: -0.5), .init(x: 0, y: 0, z: 1), .init(x: 0, y: -1, z: 0)),
])
.map { texture, perp, start, d1, d2 in
(
texture,
perp,
stride(from: 0, to: 1, by: scanFrequency)
.flatMap { d2Offset in
stride(from: 0, to: 1, by: scanFrequency)
.map { d1Offset in
(start + d1 * d1Offset + d2 * d2Offset) * size
}
}
)
}
}
}
final class CubeGame: Game {
var cube: Cube
var yaw: Float = 0
var pitch: Float = 0
var roll: Float = 0
init() {
cube = .init(size: 18, scanFrequency: 0.04)
super.init(graphicWidth: 50, graphicHeight: 50)
}
override func handle(input: UInt8) {
switch input {
case Key.q:
self.exit = true
case Key.d:
self.showDebugInfo.toggle()
case Key.j:
self.yaw += 0.1
case Key.k:
self.yaw -= 0.1
case Key.x:
self.roll += 0.1
case Key.b:
self.roll -= 0.1
case Key.m:
self.pitch += 0.1
case Key.w:
self.pitch -= 0.1
case Key.r:
self.pitch = 0
self.yaw = 0
self.roll = 0
default:
break
}
}
// Apply standard rotation matrix
func rotate(vec: Vec3) -> Vec3 {
let x = vec.x
let y = vec.y
let z = vec.z
let cosYaw = cos(yaw)
let sinYaw = sin(yaw)
let cosPitch = cos(pitch)
let sinPitch = sin(pitch)
let cosRoll = cos(roll)
let sinRoll = sin(roll)
let rotatedX = cosYaw * cosPitch * x + (cosYaw * sinPitch * sinRoll - sinYaw * cosRoll) * y + (cosYaw * sinPitch * cosRoll + sinYaw * sinRoll) * z
let rotatedY = sinYaw * cosPitch * x + (sinYaw * sinPitch * sinRoll + cosYaw * cosRoll) * y + (sinYaw * sinPitch * cosRoll - cosYaw * sinRoll) * z
let rotatedZ = -sinPitch * x + cosPitch * sinRoll * y + cosPitch * cosRoll * z
return .init(x: rotatedX, y: rotatedY, z: rotatedZ)
}
override func render() {
graphic.clear()
for (texture, perp, points) in cube.surfaces {
// if an vector perpendicular to a surface has negative dot product with one such vector that points towards the view point, it is perpendicular to a hidden surface.
let rotatedPerp = rotate(vec: perp)
if -rotatedPerp.z <= 0 {
continue
}
for point in points {
let rotated = rotate(vec: point)
// coordinates need to be shifted to the middle of the view port
graphic[Int(rotated.x.rounded(.down)) + graphic.width / 2, Int(rotated.y.rounded(.down)) + graphic.height / 2 + 1] = texture
}
}
}
}
try CubeGame().run()
@dduan
Copy link
Author

dduan commented Nov 22, 2020

Demo:
demo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment