.js source code for zbuffer/matrix style ASCII cube
// js/main.js ─ ASCII spinning OBJECT based on the classic z-buffer approach:
/* Must be used with the following css:
# your-id {
white-space: pre; preserves shape
font-family: monospace; characters equal spacing in x axis
line-height: 0.9; characters equal spacing in y axis
}
*/
let COLS // characters across (screen height)
let ROWS // characters down (screen width)
// INCLUDES WHITESPACE ^
let SCALE // projection multiplier (size of cube WITHIN screen)
let DIST // camera distance (perspective)
let screen = []
let zbuffer = []
function initializeCubeSettings() {
COLS = parseInt(document.getElementById('cols-input')?.value) || 25
ROWS = parseInt(document.getElementById('rows-input')?.value) || 9
SCALE = parseFloat(document.getElementById('scale-input')?.value) || 10
DIST = parseFloat(document.getElementById('dist-input')?.value) || 5
screen = new Array(COLS * ROWS).fill(' ')
zbuffer = new Array(COLS * ROWS).fill(-Infinity)
}
const XADJUST = 1.6 // y == x * XADJUST
let $out // will be assigned to the correct web hook at the end
const symbols = '$$##!!++--..'
let rx = 0, ry = 0, rz = 0 // current angle of rotation in radians
// define the 8 vertices of the cube V = [x, y, z]
const cube_vertices = [
[-1,-1,-1], // corner 0
[-1,-1, 1], // corner 1
[-1, 1,-1], // corner 2
[-1, 1, 1], // corner 3
[ 1,-1,-1], // corner 4
[ 1,-1, 1], // corner 5
[ 1, 1,-1], // corner 6
[ 1, 1, 1] // corner 7
]
// define vectors for the 6 faces of the cube (using two triangles each)
const triangle_faces = [
// front face (+Z)
[1, 5, 7],
[1, 7, 3],
// back face (-Z)
[0, 2, 6],
[0, 6, 4],
// left face (-X)
[0, 1, 3],
[0, 3, 2],
// right face (+X)
[4, 6, 7],
[4, 7, 5],
// top face (+Y)
[2, 3, 7],
[2, 7, 6],
// bottom face (-Y)
[0, 4, 5],
[0, 5, 1]
]
function drawCube() {
// update screen buffer such that it contains an image of the cube
// for each cube_face
for (let i = 0; i < triangle_faces.length; i++) {
let tsfrd_vertices = []
// iterate through xyz for each triangle face
for (let j = 0; j < 3; j++) {
//tsfrd_vertices[j] = cube_vertices[i][j]
const idx = triangle_faces[i][j]
const vector = cube_vertices[idx]
let vec_copy = [...vector] // copy is made to avoid changing the original
// rotate it
vec_copy = rotateX(vec_copy, rx)
vec_copy = rotateY(vec_copy, ry)
vec_copy = rotateZ(vec_copy, rz)
// push it down range into the background (otherwise would overlap with screen)
vec_copy[2] += DIST
// scale the x and y values (o.w. the cube will be unit size)
vec_copy[0] *= SCALE
vec_copy[1] *= SCALE
vec_copy[2] *= SCALE
tsfrd_vertices[j] = vec_copy
}
// back face culling (find the normal vector, dot it with the camera vector)
const vec_1 = [
tsfrd_vertices[1][0] - tsfrd_vertices[0][0],
tsfrd_vertices[1][1] - tsfrd_vertices[0][1],
tsfrd_vertices[1][2] - tsfrd_vertices[0][2]
]
const vec_2 = [
tsfrd_vertices[2][0] - tsfrd_vertices[0][0],
tsfrd_vertices[2][1] - tsfrd_vertices[0][1],
tsfrd_vertices[2][2] - tsfrd_vertices[0][2]
]
const normal_vec = cross_prod_V3(vec_1, vec_2)
const camera_vec = [
(tsfrd_vertices[0][0] + tsfrd_vertices[1][0] + tsfrd_vertices[2][0]) / 3,
(tsfrd_vertices[0][1] + tsfrd_vertices[1][1] + tsfrd_vertices[2][1]) / 3,
(tsfrd_vertices[0][2] + tsfrd_vertices[1][2] + tsfrd_vertices[2][2]) / 3
] // represents a vector from the screen origin to the cube center
// only if the dot product is negative will we draw the computed face
if (dot_prod_V3(normal_vec, camera_vec) < 0) {
// project 3 dimensional vertices onto 2 dim screen
const proj_vertices = tsfrd_vertices.map(project)
// draw the triangles
drawTriangle(proj_vertices[0], proj_vertices[1], proj_vertices[2], symbols[i])
}
}
}
/* MATRIX MATH v */
function cross_prod_V3(v1, v2) {
// computes the cross product of two 3 dimensional vectors
return [
v1[1] * v2[2] - v1[2] * v2[1],
v1[2] * v2[0] - v1[0] * v2[2],
v1[0] * v2[1] - v1[1] * v2[0]
]
}
function dot_prod_V3(v1, v2) {
return v1[0]*v2[0] + v1[1]*v2[1] + v1[2]*v2[2]
}
function rotateX(V3, r) {
return [
V3[0],
Math.cos(r)*V3[1] + (-Math.sin(r))*V3[2],
Math.sin(r)*V3[1] + Math.cos(r)*V3[2]
]
}
function rotateY(V3, r) {
return [
Math.cos(r) * V3[0] - Math.sin(r) * V3[2],
V3[1],
Math.sin(r) * V3[0] + Math.cos(r) * V3[2]
]
}
function rotateZ(V3, r) {
return [
Math.cos(r) * V3[0] - Math.sin(r) * V3[1],
Math.sin(r) * V3[0] + Math.cos(r) * V3[1],
V3[2]
]
}
function project(V3) {
// prevent divide by zero
const z = Math.max(V3[2], 0.01)
// normalize screen vectors
const perspective = SCALE / z
const x_ndc = V3[0] * perspective
const y_ndc = V3[1] * perspective
// center on the screen by subtracting half dimensions
const screen_x = Math.round(COLS / 2 + x_ndc * XADJUST)
const screen_y = Math.round(ROWS / 2 + y_ndc)
return [screen_x, screen_y]
}
/* MATRIX MATH ^ */
/* DRAWING/PRINTING v */
function drawTriangle(v_0, v_1, v_2, symbol) {
// sort vertices ST v0...v2 are ascending in order of y
// (this is done so that the midpoint can be found mathematically later)
// sort vertices in order of ascending y value
const [v0, v1, v2] = [v_0, v_1, v_2].sort((a, b) => a[1] - b[1])
// prevent later division by zero, a 1D line does not need to be drawn anyways
if (v2[1] - v0[1] === 0) return
// w/ vertices sorted, find midpoint using trig rules
const midpoint = [
Math.floor(v0[0] + (v2[0] - v0[0]) * (v1[1] - v0[1]) / (v2[1] - v0[1])),
Math.floor(v1[1])
]
// if va == vb then just draw, OW draw using midpoint
if (v1[1] == v2[1]) {
drawUpperTriangle(v0, v1, v2, symbol)
} else {
drawUpperTriangle(v0, v1, midpoint, symbol)
}
if (v0[1] == v1[1]){
drawLowerTriangle(v2, v0, v1, symbol)
} else{
drawLowerTriangle(v2, v1, midpoint, symbol)
}
}
function drawUpperTriangle(t, b0, b1, symbol) {
// called by drawTriangle, draws 1/2 of the cube's face
// t, b0 and b1 are 2D vectors [x, y]
// x will be conv to int later
let x0 = t[0]
let x1 = t[0]
// incriment x using Thales theorem
const x0_inc = (b0[0] - t[0]) / (b0[1] - t[1])
const x1_inc = (b1[0] - t[0]) / (b1[1] - t[1])
// y must be conv to int
const yt = Math.floor(t[1])
const yb = Math.floor(b0[1])
if (yb === yt) return // Flat triangle, nothing to draw
for (let i = yt; i < yb+1; i++) {
//update values of x0 and x1
const xStart = Math.floor(Math.min(x0, x1))
const xEnd = Math.floor(Math.max(x0, x1))
drawToBuffer(i, xStart, xEnd, symbol)
x0 += x0_inc
x1 += x1_inc
}
}
function drawLowerTriangle(b, t0, t1, symbol) {
// called by drawTriangle, draws 1/2 of the cube's face
// t, b0 and b1 are 2D vectors [x, y]
// x will be conv to int later
let x0 = t0[0]
let x1 = t1[0]
// incriment x using Thales theorem
const x0_inc = (b[0] - t0[0]) / (b[1] - t0[1])
const x1_inc = (b[0] - t1[0]) / (b[1] - t1[1])
// y must be conv to int
const yt = Math.floor(t0[1])
const yb = Math.floor(b[1])
if (yb === yt) return // Flat triangle, nothing to draw
for (let i = yt; i < yb+1; i++) {
//update values of x0 and x1
const xStart = Math.floor(Math.min(x0, x1))
const xEnd = Math.floor(Math.max(x0, x1))
drawToBuffer(i, xStart, xEnd, symbol)
x0 += x0_inc
x1 += x1_inc
}
}
function drawToBuffer(y, xleft, xright, symbol) {
// updates the buffer values at y from x1 to x2
if (y < 0 || y >= ROWS) return
let left = Math.max(0, Math.min(xleft, xright))
let right = Math.min(COLS - 1, Math.max(xleft, xright))
for (let x = left; x <= right; x++) {
screen[y * COLS + x] = symbol
}
}
/* DRAWING/PRINTING ^ */
/* MAIN METHOD V */
function frame() {
// frame() flushes the ascii off the old screen and then draws the new frame
initializeCubeSettings()
screen.fill(' ')
zbuffer.fill(-Infinity)
drawCube()
// draw screen using a single string inside our tag
let out = ''
for (let i = 0; i < screen.length; i++) {
out += screen[i] // print char
if ((i + 1) % COLS === 0) out += '\n' // newline behavior
}
$out.textContent = out
// rotation speeds are adjustable
rx += 0.05
ry += 0.05
rz += 0.01
}
// Main
document.addEventListener('DOMContentLoaded', () => {
$out = document.getElementById('ASCII')
initializeCubeSettings()
setInterval(frame, 20)
})