本站首頁那個旋轉中的立方體,只是大約一百行純 JavaScript:沒有 canvas、沒有
WebGL、沒有任何函式庫。它每秒三十次把字元寫進一個 <pre>
標籤。這篇文章從零開始,一步一步把它砌出來。每一步都是一個完整、獨立的
HTML 檔案,可以直接貼進文字編輯器、用瀏覽器打開;而每一步在本頁都有即時運行的結果。
- 一個平面正方形 — 點雲、投影、z-buffer、明暗
- 加入旋轉 — 三個旋轉矩陣
- 持續旋轉 — 計時器加上不斷增加的角度
- 拖動旋轉 — 滑桿直接控制角度
- 自選旋轉軸 — Rodrigues 公式與可移動的軸
第 1 步 — 一個平面正方形
立方體就是六個平面。我們不需要三角形或邊線,只要把每個面取樣成 100×100 的網格,就得到表面上約 60,000 個點。每個點記住兩樣東西 — 它的位置,以及所屬那一面的向外法向量。
每個點都經過同一條簡單的流水線:
- 背面剔除。觀察者位於 z 的負方向,望向
+z。如果法向量背向觀察者(
nz > 0),這一面就在立方體的另一邊,直接略過。 - 投影。把 x ∈ [−0.5, 0.5] 映射到 80×25 字元畫面上的一欄。終端機的字元格高度大約是寬度的兩倍,所以 x 放大 20 倍,y 只放大 10 倍,立方體才會保持方正。
- 深度測試。z-buffer 為每一格記錄
1 / (z + 2); 只有比原有的點更近,新點才能佔用該格。 - 明暗。把法向量與來自上前方的光線
(0, −1, −1)/√2做點積,再從由暗到亮的十二個字元.,-~:;=!*#$@中選一個。
<!doctype html>
<pre id="screen" style="font: 12px/1.1 monospace"></pre>
<script>
var W = 80, H = 25; // screen size in characters
var SHADES = ".,-~:;=!*#$@"; // dark ... bright
var N = 100; // samples per face edge
// six faces: [normal, tangent u, tangent v]
var FACES = [
[[ 1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,1,0],[0,0,1]],
[[0, 1,0],[1,0,0],[0,0,1]], [[0,-1,0],[1,0,0],[0,0,1]],
[[0,0, 1],[1,0,0],[0,1,0]], [[0,0,-1],[1,0,0],[0,1,0]]
];
function render() {
var buf = [], zbuf = [];
for (var i = 0; i < W * H; i++) { buf[i] = " "; zbuf[i] = 0; }
for (var f = 0; f < 6; f++) {
var n = FACES[f][0], u = FACES[f][1], v = FACES[f][2];
for (var a = 0; a <= N; a++) {
for (var b = 0; b <= N; b++) {
var s = a / N - 0.5, t = b / N - 0.5;
// point on the face (cube side = 1, centred on the origin)
var px = 0.5 * n[0] + s * u[0] + t * v[0],
py = 0.5 * n[1] + s * u[1] + t * v[1],
pz = 0.5 * n[2] + s * u[2] + t * v[2];
// outward normal of that face
var nx = n[0], ny = n[1], nz = n[2];
if (nz > 0) continue; // facing away from the viewer
var x = 0 | (40 + 20 * px), // project to a screen cell
y = 0 | (12 + 10 * py),
D = 1 / (pz + 2), // "nearness" for the z-buffer
o = x + W * y;
var L = 0 | (11 * (-ny - nz) / Math.SQRT2); // -11 ... 11
if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) {
zbuf[o] = D;
buf[o] = SHADES[L > 0 ? L : 0];
}
}
}
}
var rows = [];
for (var r = 0; r < H; r++) rows.push(buf.slice(r * W, (r + 1) * W).join(""));
document.getElementById("screen").textContent = rows.join("\n");
}
render();
</script>
沒有旋轉時,唯一 nz < 0 的只有正面,所以我們只看到一個平坦、光暗均勻的正方形:
看起來不怎麼樣,但渲染器的每個部分其實都已經齊備。之後的每一步,改變的只是點在投影之前的位置。
第 2 步 — 加入旋轉
繞某一軸旋轉一個點,就是對另外兩個座標做 2×2 旋轉。繞 x 轉的是 y/z,繞 y 轉的是 z/x,繞 z 轉的是 x/y:
有兩條規則:旋轉必須同時套用在位置和法向量上(否則明暗和剔除描述的會是一個已經不在那裏的立方體);而且次序很重要 —
先 rotate(X) 再 rotate(Y),與相反次序得出的姿態並不相同。我們固定用 X、Y、Z 的次序。
完整檔案如下,新的 rotate() 函數和呼叫它的兩行都以註解標明:
<!doctype html>
<pre id="screen" style="font: 12px/1.1 monospace"></pre>
<script>
var W = 80, H = 25;
var SHADES = ".,-~:;=!*#$@";
var N = 100;
var FACES = [
[[ 1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,1,0],[0,0,1]],
[[0, 1,0],[1,0,0],[0,0,1]], [[0,-1,0],[1,0,0],[0,0,1]],
[[0,0, 1],[1,0,0],[0,1,0]], [[0,0,-1],[1,0,0],[0,1,0]]
];
// NEW: rotate vector p = [x, y, z] in place by ax, ay, az degrees
function rotate(p, ax, ay, az) {
var r = Math.PI / 180, c, s, t;
c = Math.cos(ax * r); s = Math.sin(ax * r); // --- X axis
t = p[1]; p[1] = t * c - p[2] * s; p[2] = t * s + p[2] * c;
c = Math.cos(ay * r); s = Math.sin(ay * r); // --- Y axis
t = p[0]; p[0] = t * c + p[2] * s; p[2] = -t * s + p[2] * c;
c = Math.cos(az * r); s = Math.sin(az * r); // --- Z axis
t = p[0]; p[0] = t * c - p[1] * s; p[1] = t * s + p[1] * c;
}
var ROT_X = 30, ROT_Y = 45, ROT_Z = 0; // NEW: the pose
function render() {
var buf = [], zbuf = [];
for (var i = 0; i < W * H; i++) { buf[i] = " "; zbuf[i] = 0; }
for (var f = 0; f < 6; f++) {
var n = FACES[f][0], u = FACES[f][1], v = FACES[f][2];
for (var a = 0; a <= N; a++) {
for (var b = 0; b <= N; b++) {
var s = a / N - 0.5, t = b / N - 0.5;
var p = [0.5 * n[0] + s * u[0] + t * v[0],
0.5 * n[1] + s * u[1] + t * v[1],
0.5 * n[2] + s * u[2] + t * v[2]];
var m = [n[0], n[1], n[2]];
rotate(p, ROT_X, ROT_Y, ROT_Z); // NEW: position
rotate(m, ROT_X, ROT_Y, ROT_Z); // NEW: normal
if (m[2] > 0) continue;
var x = 0 | (40 + 20 * p[0]),
y = 0 | (12 + 10 * p[1]),
D = 1 / (p[2] + 2),
o = x + W * y;
var L = 0 | (11 * (-m[1] - m[2]) / Math.SQRT2);
if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) {
zbuf[o] = D;
buf[o] = SHADES[L > 0 ? L : 0];
}
}
}
}
var rows = [];
for (var r = 0; r < H; r++) rows.push(buf.slice(r * W, (r + 1) * W).join(""));
document.getElementById("screen").textContent = rows.join("\n");
}
render();
</script>
繞 x 傾斜 30°、繞 y 轉 45°,就能看到三個面,而且每一面的亮度都不同,因為每條法向量與光線的夾角都不一樣:
第 3 步 — 持續旋轉
動畫是最便宜的一步。保留一個全域角度,每一格畫面把它加一點,再用計時器呼叫
render()。我們先固定繞 x 傾斜 45°、繞 z 傾斜
45°,讓立方體以一個角立着,然後繞垂直的 y 軸旋轉。
留意程式中的次序:先套用傾斜,再套用旋轉。這樣旋轉是繞着世界的垂直軸進行,傾斜了的立方體像陀螺一樣轉動,而不是連傾斜也一起被轉走。
<!doctype html>
<pre id="screen" style="font: 12px/1.1 monospace"></pre>
<script>
var W = 80, H = 25;
var SHADES = ".,-~:;=!*#$@";
var N = 100;
var FACES = [
[[ 1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,1,0],[0,0,1]],
[[0, 1,0],[1,0,0],[0,0,1]], [[0,-1,0],[1,0,0],[0,0,1]],
[[0,0, 1],[1,0,0],[0,1,0]], [[0,0,-1],[1,0,0],[0,1,0]]
];
function rotate(p, ax, ay, az) {
var r = Math.PI / 180, c, s, t;
c = Math.cos(ax * r); s = Math.sin(ax * r);
t = p[1]; p[1] = t * c - p[2] * s; p[2] = t * s + p[2] * c;
c = Math.cos(ay * r); s = Math.sin(ay * r);
t = p[0]; p[0] = t * c + p[2] * s; p[2] = -t * s + p[2] * c;
c = Math.cos(az * r); s = Math.sin(az * r);
t = p[0]; p[0] = t * c - p[1] * s; p[1] = t * s + p[1] * c;
}
var TILT_X = 45, TILT_Z = 45; // fixed pose
var spin = 0; // NEW: grows every frame (degrees)
function render() {
var buf = [], zbuf = [];
for (var i = 0; i < W * H; i++) { buf[i] = " "; zbuf[i] = 0; }
for (var f = 0; f < 6; f++) {
var n = FACES[f][0], u = FACES[f][1], v = FACES[f][2];
for (var a = 0; a <= N; a++) {
for (var b = 0; b <= N; b++) {
var s = a / N - 0.5, t = b / N - 0.5;
var p = [0.5 * n[0] + s * u[0] + t * v[0],
0.5 * n[1] + s * u[1] + t * v[1],
0.5 * n[2] + s * u[2] + t * v[2]];
var m = [n[0], n[1], n[2]];
rotate(p, TILT_X, 0, TILT_Z); rotate(p, 0, spin, 0); // tilt, then spin
rotate(m, TILT_X, 0, TILT_Z); rotate(m, 0, spin, 0);
if (m[2] > 0) continue;
var x = 0 | (40 + 20 * p[0]),
y = 0 | (12 + 10 * p[1]),
D = 1 / (p[2] + 2),
o = x + W * y;
var L = 0 | (11 * (-m[1] - m[2]) / Math.SQRT2);
if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) {
zbuf[o] = D;
buf[o] = SHADES[L > 0 ? L : 0];
}
}
}
}
var rows = [];
for (var r = 0; r < H; r++) rows.push(buf.slice(r * W, (r + 1) * W).join(""));
document.getElementById("screen").textContent = rows.join("\n");
}
setInterval(function () { // NEW: ~33 frames per second
spin += 4; // 4 degrees per frame
render();
}, 30);
</script>
留意每一面:當法向量轉向或背離光線時,亮度會在十二個字元之間平滑地變化。這就是整個「光照模型」,全部來自一個點積。
第 4 步 — 拖動旋轉
渲染器本來就接受三個角度;我們只需要三個 <input type="range">
滑桿,以及一個讀取數值並重新渲染的 input 事件。一格畫面只需幾毫秒,所以立方體會毫無延遲地跟着手指移動。這次不需要計時器 — 只有移動滑桿時才會重畫。
<!doctype html>
<pre id="screen" style="font: 12px/1.1 monospace"></pre>
<!-- NEW: the control board -->
<label>X <input type="range" id="rx" min="0" max="360" value="30"> <span id="rx-v">30</span>°</label><br>
<label>Y <input type="range" id="ry" min="0" max="360" value="45"> <span id="ry-v">45</span>°</label><br>
<label>Z <input type="range" id="rz" min="0" max="360" value="0"> <span id="rz-v">0</span>°</label>
<script>
var W = 80, H = 25;
var SHADES = ".,-~:;=!*#$@";
var N = 100;
var FACES = [
[[ 1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,1,0],[0,0,1]],
[[0, 1,0],[1,0,0],[0,0,1]], [[0,-1,0],[1,0,0],[0,0,1]],
[[0,0, 1],[1,0,0],[0,1,0]], [[0,0,-1],[1,0,0],[0,1,0]]
];
function rotate(p, ax, ay, az) {
var r = Math.PI / 180, c, s, t;
c = Math.cos(ax * r); s = Math.sin(ax * r);
t = p[1]; p[1] = t * c - p[2] * s; p[2] = t * s + p[2] * c;
c = Math.cos(ay * r); s = Math.sin(ay * r);
t = p[0]; p[0] = t * c + p[2] * s; p[2] = -t * s + p[2] * c;
c = Math.cos(az * r); s = Math.sin(az * r);
t = p[0]; p[0] = t * c - p[1] * s; p[1] = t * s + p[1] * c;
}
function render(ax, ay, az) { // NEW: angles are arguments
var buf = [], zbuf = [];
for (var i = 0; i < W * H; i++) { buf[i] = " "; zbuf[i] = 0; }
for (var f = 0; f < 6; f++) {
var n = FACES[f][0], u = FACES[f][1], v = FACES[f][2];
for (var a = 0; a <= N; a++) {
for (var b = 0; b <= N; b++) {
var s = a / N - 0.5, t = b / N - 0.5;
var p = [0.5 * n[0] + s * u[0] + t * v[0],
0.5 * n[1] + s * u[1] + t * v[1],
0.5 * n[2] + s * u[2] + t * v[2]];
var m = [n[0], n[1], n[2]];
rotate(p, ax, ay, az);
rotate(m, ax, ay, az);
if (m[2] > 0) continue;
var x = 0 | (40 + 20 * p[0]),
y = 0 | (12 + 10 * p[1]),
D = 1 / (p[2] + 2),
o = x + W * y;
var L = 0 | (11 * (-m[1] - m[2]) / Math.SQRT2);
if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) {
zbuf[o] = D;
buf[o] = SHADES[L > 0 ? L : 0];
}
}
}
}
var rows = [];
for (var r = 0; r < H; r++) rows.push(buf.slice(r * W, (r + 1) * W).join(""));
document.getElementById("screen").textContent = rows.join("\n");
}
// NEW: read the sliders and redraw whenever one of them moves
function fromSliders() {
var ax = +document.getElementById("rx").value,
ay = +document.getElementById("ry").value,
az = +document.getElementById("rz").value;
document.getElementById("rx-v").textContent = ax;
document.getElementById("ry-v").textContent = ay;
document.getElementById("rz-v").textContent = az;
render(ax, ay, az);
}
["rx", "ry", "rz"].forEach(function (id) {
document.getElementById(id).addEventListener("input", fromSliders);
});
fromSliders();
</script>
試試把另外兩個設為零,再把 Y 從 0 拖到 360:立方體剛好轉一圈;而當正面與螢幕平行時(0°、90°、180°…),我們又回到第 1 步的正方形。
第 5 步 — 自選旋轉軸
第 3 步只能繞 y 旋轉。真正的問題是:讀者能否任選一條軸,讓立方體繞着它轉?可以,只需要多一條公式。給定單位軸 k 和角度 θ,Rodrigues 旋轉公式可以旋轉任何向量 v:
$$ \mathbf{v}' = \mathbf{v}\cos\theta + (\mathbf{k}\times\mathbf{v})\sin\theta + \mathbf{k}\,(\mathbf{k}\cdot\mathbf{v})(1-\cos\theta) $$第 2 步的三個矩陣,只是 k = (1,0,0)、(0,1,0)、(0,0,1)
時的特例。為了讓使用者調整軸的方向,我們用兩個滑桿描述它:方位角(繞垂直方向)和仰角(向上或向下),再用球座標換算成單位向量。第三個滑桿控制速度,另外三個保留第 4 步的傾斜。額外加料:我們把軸本身畫成一行穿過 z-buffer 的 + 字元,所以它會在立方體內消失,再從兩邊穿出來。
<!doctype html>
<pre id="screen" style="font: 12px/1.1 monospace"></pre>
<label>tilt X <input type="range" id="rx" min="0" max="360" value="45"></label><br>
<label>tilt Y <input type="range" id="ry" min="0" max="360" value="0"></label><br>
<label>tilt Z <input type="range" id="rz" min="0" max="360" value="45"></label><br>
<label>axis azimuth <input type="range" id="az" min="0" max="360" value="0"></label><br>
<label>axis elevation <input type="range" id="el" min="-90" max="90" value="90"></label><br>
<label>speed °/frame <input type="range" id="sp" min="0" max="12" value="4"></label>
<script>
var W = 80, H = 25;
var SHADES = ".,-~:;=!*#$@";
var N = 100;
var FACES = [
[[ 1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,1,0],[0,0,1]],
[[0, 1,0],[1,0,0],[0,0,1]], [[0,-1,0],[1,0,0],[0,0,1]],
[[0,0, 1],[1,0,0],[0,1,0]], [[0,0,-1],[1,0,0],[0,1,0]]
];
function rotate(p, ax, ay, az) {
var r = Math.PI / 180, c, s, t;
c = Math.cos(ax * r); s = Math.sin(ax * r);
t = p[1]; p[1] = t * c - p[2] * s; p[2] = t * s + p[2] * c;
c = Math.cos(ay * r); s = Math.sin(ay * r);
t = p[0]; p[0] = t * c + p[2] * s; p[2] = -t * s + p[2] * c;
c = Math.cos(az * r); s = Math.sin(az * r);
t = p[0]; p[0] = t * c - p[1] * s; p[1] = t * s + p[1] * c;
}
// NEW: rotate p around unit axis k by deg degrees (Rodrigues)
function rotateAxis(p, k, deg) {
var th = deg * Math.PI / 180, c = Math.cos(th), s = Math.sin(th);
var dot = k[0] * p[0] + k[1] * p[1] + k[2] * p[2];
var cx = k[1] * p[2] - k[2] * p[1], // k x p
cy = k[2] * p[0] - k[0] * p[2],
cz = k[0] * p[1] - k[1] * p[0];
var q = 1 - c;
var x = p[0] * c + cx * s + k[0] * dot * q,
y = p[1] * c + cy * s + k[1] * dot * q,
z = p[2] * c + cz * s + k[2] * dot * q;
p[0] = x; p[1] = y; p[2] = z;
}
var spin = 0;
function val(id) { return +document.getElementById(id).value; }
function render() {
var ax = val("rx"), ay = val("ry"), az = val("rz");
var azm = val("az") * Math.PI / 180, elv = val("el") * Math.PI / 180;
// NEW: spherical -> unit vector (elevation 90 = straight up = the y axis)
var k = [Math.cos(elv) * Math.sin(azm), -Math.sin(elv), Math.cos(elv) * Math.cos(azm)];
var buf = [], zbuf = [];
for (var i = 0; i < W * H; i++) { buf[i] = " "; zbuf[i] = 0; }
for (var f = 0; f < 6; f++) {
var n = FACES[f][0], u = FACES[f][1], v = FACES[f][2];
for (var a = 0; a <= N; a++) {
for (var b = 0; b <= N; b++) {
var s = a / N - 0.5, t = b / N - 0.5;
var p = [0.5 * n[0] + s * u[0] + t * v[0],
0.5 * n[1] + s * u[1] + t * v[1],
0.5 * n[2] + s * u[2] + t * v[2]];
var m = [n[0], n[1], n[2]];
rotate(p, ax, ay, az); rotateAxis(p, k, spin); // tilt, then spin about k
rotate(m, ax, ay, az); rotateAxis(m, k, spin);
if (m[2] > 0) continue;
var x = 0 | (40 + 20 * p[0]),
y = 0 | (12 + 10 * p[1]),
D = 1 / (p[2] + 2),
o = x + W * y;
var L = 0 | (11 * (-m[1] - m[2]) / Math.SQRT2);
if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) {
zbuf[o] = D;
buf[o] = SHADES[L > 0 ? L : 0];
}
}
}
}
// NEW: draw the axle as a line through the z-buffer
for (var d = -1.1; d <= 1.1; d += 0.01) {
var qx = k[0] * d, qy = k[1] * d, qz = k[2] * d;
var x = 0 | (40 + 20 * qx), y = 0 | (12 + 10 * qy), D = 1 / (qz + 2), o = x + W * y;
if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) { zbuf[o] = D; buf[o] = "+"; }
}
var rows = [];
for (var r = 0; r < H; r++) rows.push(buf.slice(r * W, (r + 1) * W).join(""));
document.getElementById("screen").textContent = rows.join("\n");
}
setInterval(function () { spin += val("sp"); render(); }, 30);
</script>
可以試試:把仰角設為 0 再掃動方位角 — 軸變成水平,立方體翻筋斗般轉動。把速度設為 0,滑桿的效果就和第 4 步一樣,只是多畫了軸。又或者在旋轉時把仰角由 90 慢慢拖到 −90:旋轉完全不會卡頓,因為每一格都是從頭計算 — 除了 spin 這一個數字之外,沒有任何累積的狀態。
下一步
- 更平滑的明暗。只有十二個字元,面與面之間的亮度跳躍很明顯。改用一條按墨水密度排序的 70 字元階梯就能解決。
- 透視。把投影後的 x 和 y 乘以
D(我們本來就為 z-buffer 計算了這個值),立方體較遠的一邊就會縮小。 - 其他形狀。整條流水線從不在乎這是一個立方體。把
FACES換成任何參數曲面即可 — 這段程式最初的版本畫的是一個甜甜圈。
本站首頁的完整立方體,就是第 3 步的檔案,再加上一圈旋轉的文字。其餘的全是 CSS。