The Russian programmer Tsoding has an excellent walkthrough One Formula That Demystifies 3D Graphics. In it he takes you step-by-step through creating simple 3D graphics in the browser culminating in the rotating cube below:
function startCube(canvasId) {
const game = document.getElementById(canvasId)
const BACKGROUND ="#101010"
const FOREGROUND ="#50FF50"
game.width = 800
game.height = 800
const ctx = game.getContext("2d")
function clear() {
ctx.fillStyle = BACKGROUND
ctx.fillRect(0, 0, game.width, game.height)
}
function point({x,y}) {
const s = 20;
ctx.fillStyle = FOREGROUND
ctx.fillRect(x-s/2, y-s/2, s, s)
}
function line(p1, p2) {
ctx.strokeStyle = FOREGROUND
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x,p2.y);
ctx.stroke();
}
function screen(p) {
return {
x: (p.x + 1)/2*game.width,
y: (1 - (p.y + 1)/2)*game.height,
}
}
function project({x,y,z}){
return {
x: x/z,
y: y/z,
}
}
const FPS = 60;
function translate_z({x,y,z}, dz) {
return {x, y, z: z + dz};
}
function rotate_xz({x,y,z}, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return{
x: x*c-z*s,
y,
z: x*s+z*c,
}
};
const vs = [
{x: 0.25, y: 0.25, z: 0.25},
{x: -0.25, y: 0.25, z: 0.25},
{x: -0.25, y: -0.25, z: 0.25},
{x: 0.25, y: -0.25, z: 0.25},
{x: 0.25, y: 0.25, z: -0.25},
{x: -0.25, y: 0.25, z: -0.25},
{x: -0.25, y: -0.25, z: -0.25},
{x: 0.25, y: -0.25, z: -0.25},
]
const fs = [
[0,1,2,3],
[4,5,6,7],
[0,4],
[1,5],
[2,6],
[3,7],
]
let dz = 1;
let angle = 0;
function frame() {
const dt = 1/FPS;
angle += Math.PI*dt;
clear()
for (const f of fs) {
for (let i=0; i < f.length; ++i) {
const a = vs[f[i]];
const b = vs[f[(i+1)%f.length]];
line(
screen(project(translate_z(rotate_xz(a, angle), dz))),
screen(project(translate_z(rotate_xz(b, angle), dz))))
}}
setTimeout(frame, 1000/FPS);
}
setTimeout(frame, 1000/FPS);
}
After walking through Tsoding's tutorial I decided to experiment with augmenting it to do other basic 3D objects to get a better sense of how everything works:
function startChessBoard(canvasId) {
const game = document.getElementById(canvasId)
const BACKGROUND = "#101010"
const FOREGROUND = "#50FF50"
game.width = 800
game.height = 800
const ctx = game.getContext("2d")
function clear() {
ctx.fillStyle = BACKGROUND
ctx.fillRect(0, 0, game.width, game.height)
}
function line(p1, p2, alpha = 1) {
ctx.strokeStyle = `rgba(80, 255, 80, ${alpha})`
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
function fillQuad(p1, p2, p3, p4, alpha = 1) {
ctx.fillStyle = `rgba(80, 255, 80, ${alpha})`
ctx.beginPath()
ctx.moveTo(p1.x, p1.y)
ctx.lineTo(p2.x, p2.y)
ctx.lineTo(p3.x, p3.y)
ctx.lineTo(p4.x, p4.y)
ctx.closePath()
ctx.fill()
}
function screen(p) {
return {
x: (p.x + 1) / 2 * game.width,
y: (1 - (p.y + 1) / 2) * game.height,
}
}
function project({ x, y, z }) {
return {
x: x / z,
y: y / z,
}
}
const FPS = 60;
const BOARD_SIZE = 2.0;
const SQUARE_SIZE = BOARD_SIZE / 8;
const BOARD_Y = 0.0;
const BOARD_THICK = 0.08;
const BORDER = 0.12;
const HALF = BOARD_SIZE / 2;
function buildChessboard() {
const squares = [];
const edges = [];
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
const dark = (row + col) % 2 === 1;
const x0 = -HALF + col * SQUARE_SIZE;
const x1 = x0 + SQUARE_SIZE;
const z0 = -HALF + row * SQUARE_SIZE;
const z1 = z0 + SQUARE_SIZE;
const y = BOARD_Y;
squares.push({
dark,
corners: [
{ x: x0, y, z: z0 },
{ x: x1, y, z: z0 },
{ x: x1, y, z: z1 },
{ x: x0, y, z: z1 },
],
});
}
}
const B = HALF + BORDER;
const yT = BOARD_Y;
const yB = BOARD_Y - BOARD_THICK;
function rimRect(corners) {
for (let i = 0; i < 4; i++) {
edges.push([corners[i], corners[(i + 1) % 4]]);
}
}
const topOuter = [
{ x: -B, y: yT, z: -B },
{ x: B, y: yT, z: -B },
{ x: B, y: yT, z: B },
{ x: -B, y: yT, z: B },
];
rimRect(topOuter);
const topInner = [
{ x: -HALF, y: yT, z: -HALF },
{ x: HALF, y: yT, z: -HALF },
{ x: HALF, y: yT, z: HALF },
{ x: -HALF, y: yT, z: HALF },
];
rimRect(topInner);
const botOuter = topOuter.map(p => ({ ...p, y: yB }));
rimRect(botOuter);
for (let i = 0; i < 4; i++) {
edges.push([topOuter[i], botOuter[i]]);
}
for (let i = 1; i < 8; i++) {
const t = -HALF + i * SQUARE_SIZE;
edges.push([
{ x: t, y: yT, z: -HALF },
{ x: t, y: yT, z: HALF },
]);
edges.push([
{ x: -HALF, y: yT, z: t },
{ x: HALF, y: yT, z: t },
]);
}
return { squares, edges };
}
const board = buildChessboard();
let angle = 0;
function rotateY({ x, y, z }, a) {
const c = Math.cos(a), s = Math.sin(a);
return { x: x * c - z * s, y, z: x * s + z * c };
}
function rotateX({ x, y, z }, a) {
const c = Math.cos(a), s = Math.sin(a);
return { x, y: y * c - z * s, z: y * s + z * c };
}
function translateZ({ x, y, z }, dz) {
return { x, y, z: z + dz };
}
const TILT = -Math.PI * 0.30;
const DZ = 2.8;
function transformPoint(p) {
return translateZ(rotateX(rotateY(p, angle), TILT), DZ);
}
function frame() {
const dt = 1 / FPS;
angle += Math.PI * 0.18 * dt;
clear();
for (const sq of board.squares) {
if (!sq.dark) continue;
const tc = sq.corners.map(transformPoint);
const avgZ = tc.reduce((s, p) => s + p.z, 0) / 4;
const alpha = Math.max(0.05, Math.min(0.55, (avgZ - 1.0) / 2.0));
const sc = tc.map(p => screen(project(p)));
fillQuad(sc[0], sc[1], sc[2], sc[3], alpha);
}
for (const [p1raw, p2raw] of board.edges) {
const p1t = transformPoint(p1raw);
const p2t = transformPoint(p2raw);
const avgZ = (p1t.z + p2t.z) / 2;
const alpha = Math.max(0.08, Math.min(1.0, (avgZ - 1.0) / 1.8));
line(screen(project(p1t)), screen(project(p2t)), alpha);
}
setTimeout(frame, 1000 / FPS);
}
setTimeout(frame, 1000 / FPS);
}
function startCastle(canvasId) {
const game = document.getElementById(canvasId)
const BACKGROUND = "#101010"
const FOREGROUND = "#50FF50"
console.log(game)
game.width = 400
game.height = 400
const ctx = game.getContext("2d")
function clear() {
ctx.fillStyle = BACKGROUND
ctx.fillRect(0, 0, game.width, game.height)
}
function line(p1, p2, alpha = 1) {
ctx.strokeStyle = `rgba(80, 255, 80, ${alpha})`
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
function screen(p) {
return {
x: (p.x + 1) / 2 * game.width,
y: (1 - (p.y + 1) / 2) * game.height,
}
}
function project({ x, y, z }) {
return {
x: x / z,
y: y / z,
}
}
const FPS = 60;
// -------------------------------------------------------
// Castle geometry helpers
// -------------------------------------------------------
// Returns vertices and edges for a single tower
// cx, cz = center position, r = radius, yBase, yTop = height range
// merlons = number of battlements on top
function makeTower(cx, cz, r, yBase, yTop, merlons = 6) {
const vs = [];
const es = [];
const sides = 12; // how round the tower is
function vi() { return vs.length; }
// Bottom ring
const botStart = vi();
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2;
vs.push({ x: cx + Math.cos(a) * r, y: yBase, z: cz + Math.sin(a) * r });
}
// Top ring (below battlements)
const topStart = vi();
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2;
vs.push({ x: cx + Math.cos(a) * r, y: yTop, z: cz + Math.sin(a) * r });
}
// Vertical edges
for (let i = 0; i < sides; i++) {
es.push([botStart + i, topStart + i]);
}
// Bottom ring edges
for (let i = 0; i < sides; i++) {
es.push([botStart + i, botStart + (i + 1) % sides]);
}
// Top ring edges
for (let i = 0; i < sides; i++) {
es.push([topStart + i, topStart + (i + 1) % sides]);
}
// Battlements (merlons) on top
const merH = 0.08; // merlon height
for (let m = 0; m < merlons; m++) {
const a1 = (m / merlons) * Math.PI * 2;
const a2 = ((m + 0.4) / merlons) * Math.PI * 2;
const bl = vi();
vs.push({ x: cx + Math.cos(a1) * r, y: yTop, z: cz + Math.sin(a1) * r });
vs.push({ x: cx + Math.cos(a2) * r, y: yTop, z: cz + Math.sin(a2) * r });
vs.push({ x: cx + Math.cos(a1) * r, y: yTop + merH, z: cz + Math.sin(a1) * r });
vs.push({ x: cx + Math.cos(a2) * r, y: yTop + merH, z: cz + Math.sin(a2) * r });
es.push([bl, bl + 1]);
es.push([bl + 2, bl + 3]);
es.push([bl, bl + 2]);
es.push([bl + 1, bl + 3]);
}
// Conical roof
const roofTip = vi();
vs.push({ x: cx, y: yTop + 0.35, z: cz });
for (let i = 0; i < sides; i++) {
es.push([topStart + i, roofTip]);
}
// Windows (simple arrow slits as line pairs)
const winLevels = 2;
for (let w = 0; w < winLevels; w++) {
const wy = yBase + (yTop - yBase) * ((w + 1) / (winLevels + 1));
for (let i = 0; i < 4; i++) {
const a = (i / 4) * Math.PI * 2;
const wx = cx + Math.cos(a) * r;
const wz = cz + Math.sin(a) * r;
const wb = vi();
vs.push({ x: wx, y: wy - 0.05, z: wz });
vs.push({ x: wx, y: wy + 0.05, z: wz });
es.push([wb, wb + 1]);
}
}
return { vs, es };
}
// Returns a rectangular wall section between two points at ground level
function makeWall(x1, z1, x2, z2, yBase, yTop, merlons = 8) {
const vs = [];
const es = [];
function vi() { return vs.length; }
// Four corners
vs.push({ x: x1, y: yBase, z: z1 }); // 0 bl left
vs.push({ x: x2, y: yBase, z: z2 }); // 1 bl right
vs.push({ x: x1, y: yTop, z: z1 }); // 2 tl left
vs.push({ x: x2, y: yTop, z: z2 }); // 3 tr right
es.push([0, 1]);
es.push([2, 3]);
es.push([0, 2]);
es.push([1, 3]);
// Merlons along top
const merH = 0.06;
for (let m = 0; m < merlons; m++) {
const ta = (m + 0.1) / merlons;
const tb = (m + 0.5) / merlons;
const ax = x1 + (x2 - x1) * ta;
const az = z1 + (z2 - z1) * ta;
const bx = x1 + (x2 - x1) * tb;
const bz = z1 + (z2 - z1) * tb;
const mb = vi();
vs.push({ x: ax, y: yTop, z: az });
vs.push({ x: bx, y: yTop, z: bz });
vs.push({ x: ax, y: yTop + merH, z: az });
vs.push({ x: bx, y: yTop + merH, z: bz });
es.push([mb, mb + 1]);
es.push([mb + 2, mb + 3]);
es.push([mb, mb + 2]);
es.push([mb + 1, mb + 3]);
}
return { vs, es };
}
// Makes a gatehouse (wider wall section with an arch gap)
function makeGate(x1, z1, x2, z2, yBase, yTop) {
const vs = [];
const es = [];
function vi() { return vs.length; }
// Left pillar
const mx1 = x1 + (x2 - x1) * 0.35;
const mz1 = z1 + (z2 - z1) * 0.35;
const mx2 = x1 + (x2 - x1) * 0.65;
const mz2 = z1 + (z2 - z1) * 0.65;
const gateTop = yBase + (yTop - yBase) * 0.55;
// Left wall section
vs.push({ x: x1, y: yBase, z: z1 });
vs.push({ x: mx1, y: yBase, z: mz1 });
vs.push({ x: x1, y: yTop, z: z1 });
vs.push({ x: mx1, y: yTop, z: mz1 });
es.push([0,1],[2,3],[0,2],[1,3]);
// Right wall section
vs.push({ x: mx2, y: yBase, z: mz2 });
vs.push({ x: x2, y: yBase, z: z2 });
vs.push({ x: mx2, y: yTop, z: mz2 });
vs.push({ x: x2, y: yTop, z: z2 });
es.push([4,5],[6,7],[4,6],[5,7]);
// Gate arch top
vs.push({ x: mx1, y: gateTop, z: mz1 });
vs.push({ x: mx2, y: gateTop, z: mz2 });
es.push([vi()-2, vi()-1]);
// Gate sides going up to arch
es.push([1, vi()-2]);
es.push([4, vi()-1]);
return { vs, es };
}
// -------------------------------------------------------
// Assemble castle
// -------------------------------------------------------
function buildCastle() {
const allVs = [];
const allEs = [];
function merge({ vs, es }) {
const offset = allVs.length;
allVs.push(...vs);
for (const [a, b] of es) {
allEs.push([a + offset, b + offset]);
}
}
const wallY0 = -0.5;
const wallY1 = -0.1;
const towerR = 0.12;
const towerY0 = -0.5;
const towerY1 = 0.1;
// Corner towers at four corners
const corners = [
[ 0.6, 0.6],
[-0.6, 0.6],
[-0.6, -0.6],
[ 0.6, -0.6],
];
for (const [cx, cz] of corners) {
merge(makeTower(cx, cz, towerR, towerY0, towerY1, 6));
}
// Central keep (taller, bigger)
merge(makeTower(0, 0, 0.22, -0.5, 0.4, 8));
// Walls between corners
const wallThickness = 0.04;
merge(makeWall( 0.6, 0.6, -0.6, 0.6, wallY0, wallY1, 6));
merge(makeWall(-0.6, 0.6, -0.6, -0.6, wallY0, wallY1, 6));
merge(makeWall(-0.6, -0.6, 0.6, -0.6, wallY0, wallY1, 6));
// Front wall with gatehouse
merge(makeGate(0.6, -0.6, -0.6, -0.6, wallY0, wallY1));
// Ground plane (simple cross lines)
const g = allVs.length;
allVs.push({ x: -1, y: wallY0, z: 0 });
allVs.push({ x: 1, y: wallY0, z: 0 });
allVs.push({ x: 0, y: wallY0, z: -1 });
allVs.push({ x: 0, y: wallY0, z: 1 });
allEs.push([g, g+1], [g+2, g+3]);
return { vs: allVs, es: allEs };
}
const castle = buildCastle();
let angle = 0;
function rotateY({ x, y, z }, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return {
x: x * c - z * s,
y,
z: x * s + z * c,
};
}
function translateZ({ x, y, z }, dz) {
return { x, y, z: z + dz };
}
const DZ = 2.5;
function frame() {
const dt = 1 / FPS;
angle += Math.PI * 0.15 * dt;
clear();
const transformed = castle.vs.map(v => translateZ(rotateY(v, angle), DZ));
const projected = transformed.map(v => screen(project(v)));
for (const [ai, bi] of castle.es) {
const a = transformed[ai];
const b = transformed[bi];
const avgZ = (a.z + b.z) / 2;
const alpha = Math.max(0.08, Math.min(1, (avgZ - 1.2) / 1.8));
line(projected[ai], projected[bi], alpha);
}
setTimeout(frame, 1000 / FPS);
}
setTimeout(frame, 1000 / FPS);
}
function startScript4(canvasId) {
const game = document.getElementById(canvasId)
const BACKGROUND = "#101010"
const FOREGROUND = "#50FF50"
game.width = 800
game.height = 800
const ctx = game.getContext("2d")
function clear() {
ctx.fillStyle = BACKGROUND
ctx.fillRect(0, 0, game.width, game.height)
}
function line(p1, p2, alpha = 1) {
ctx.strokeStyle = `rgba(80, 255, 80, ${alpha})`
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
function screen(p) {
return {
x: (p.x + 1) / 2 * game.width,
y: (1 - (p.y + 1) / 2) * game.height,
}
}
function project({ x, y, z }) {
return {
x: x / z,
y: y / z,
}
}
const FPS = 60;
function makeWheel(cx, cy, cz, radius, segments = 24) {
const vs = [];
const es = [];
function vi() { return vs.length; }
const rimStart = vi();
for (let i = 0; i < segments; i++) {
const a = (i / segments) * Math.PI * 2;
vs.push({
x: cx + Math.cos(a) * radius,
y: cy + Math.sin(a) * radius,
z: cz,
});
}
for (let i = 0; i < segments; i++) {
es.push([rimStart + i, rimStart + (i + 1) % segments]);
}
const hub = vi();
vs.push({ x: cx, y: cy, z: cz });
const spokeCount = 12;
for (let i = 0; i < spokeCount; i++) {
const rimIdx = Math.floor((i / spokeCount) * segments);
es.push([hub, rimStart + rimIdx]);
}
const hubR = radius * 0.08;
const hubRimStart = vi();
const hubSegs = 8;
for (let i = 0; i < hubSegs; i++) {
const a = (i / hubSegs) * Math.PI * 2;
vs.push({
x: cx + Math.cos(a) * hubR,
y: cy + Math.sin(a) * hubR,
z: cz,
});
}
for (let i = 0; i < hubSegs; i++) {
es.push([hubRimStart + i, hubRimStart + (i + 1) % hubSegs]);
}
return { vs, es };
}
function makeTube(x1, y1, z1, x2, y2, z2) {
const vs = [];
const es = [];
vs.push({ x: x1, y: y1, z: z1 });
vs.push({ x: x2, y: y2, z: z2 });
es.push([0, 1]);
return { vs, es };
}
function buildBicycle() {
const allVs = [];
const allEs = [];
const groups = {
rearWheel: { start: -1, end: -1 },
frontWheel: { start: -1, end: -1 },
chainRing: { start: -1, end: -1 },
cranks: { start: -1, end: -1 },
};
function merge({ vs, es }) {
const offset = allVs.length;
allVs.push(...vs);
for (const [a, b] of es) {
allEs.push([a + offset, b + offset]);
}
return offset;
}
function addEdge(p1, p2) {
return merge(makeTube(p1.x, p1.y, p1.z, p2.x, p2.y, p2.z));
}
const wheelRadius = 0.35;
const wheelY = -0.1;
const rearX = -0.55;
const frontX = 0.55;
const wheelZ = 0;
groups.rearWheel.start = allVs.length;
merge(makeWheel(rearX, wheelY, wheelZ, wheelRadius));
groups.rearWheel.end = allVs.length;
groups.frontWheel.start = allVs.length;
merge(makeWheel(frontX, wheelY, wheelZ, wheelRadius));
groups.frontWheel.end = allVs.length;
const rearHub = { x: rearX, y: wheelY, z: 0 };
const frontHub = { x: frontX, y: wheelY, z: 0 };
const bb = { x: -0.05, y: wheelY, z: 0 };
const seatTop = { x: -0.15, y: wheelY + 0.55, z: 0 };
const headTop = { x: 0.28, y: wheelY + 0.52, z: 0 };
addEdge(bb, seatTop);
addEdge(seatTop, headTop);
addEdge(headTop, bb);
const csZ = 0.05;
addEdge({ ...rearHub, z: -csZ }, { ...bb, z: -csZ });
addEdge({ ...rearHub, z: csZ }, { ...bb, z: csZ });
addEdge({ ...rearHub, z: -csZ }, { ...seatTop, z: -csZ });
addEdge({ ...rearHub, z: csZ }, { ...seatTop, z: csZ });
addEdge({ ...rearHub, z: -csZ }, { ...rearHub, z: csZ });
addEdge({ ...bb, z: -csZ }, { ...bb, z: csZ });
addEdge({ ...seatTop, z: -csZ }, { ...seatTop, z: csZ });
const headBot = { x: 0.32, y: wheelY + 0.18, z: 0 };
addEdge(headTop, headBot);
const forkZ = 0.05;
addEdge({ ...headBot, z: -forkZ }, { ...frontHub, z: -forkZ });
addEdge({ ...headBot, z: forkZ }, { ...frontHub, z: forkZ });
addEdge({ ...frontHub, z: -forkZ }, { ...frontHub, z: forkZ });
const stemTop = { x: 0.26, y: wheelY + 0.68, z: 0 };
addEdge(headTop, stemTop);
const hbLeft = { x: 0.22, y: wheelY + 0.70, z: -0.18 };
const hbRight = { x: 0.22, y: wheelY + 0.70, z: 0.18 };
addEdge(stemTop, hbLeft);
addEdge(stemTop, hbRight);
const hbDropL = { x: 0.28, y: wheelY + 0.58, z: -0.18 };
const hbDropR = { x: 0.28, y: wheelY + 0.58, z: 0.18 };
addEdge(hbLeft, hbDropL);
addEdge(hbRight, hbDropR);
const seatPost = { x: -0.18, y: wheelY + 0.62, z: 0 };
addEdge(seatTop, seatPost);
const seatL = { x: -0.08, y: wheelY + 0.64, z: -0.1 };
const seatR = { x: -0.08, y: wheelY + 0.64, z: 0.1 };
const seatBL = { x: -0.28, y: wheelY + 0.62, z: -0.07 };
const seatBR = { x: -0.28, y: wheelY + 0.62, z: 0.07 };
addEdge(seatPost, seatL);
addEdge(seatPost, seatR);
addEdge(seatL, seatR);
addEdge(seatL, seatBL);
addEdge(seatR, seatBR);
addEdge(seatBL, seatBR);
groups.cranks.start = allVs.length;
for (let i = 0; i < 8; i++) {
allVs.push({ x: 0, y: 0, z: 0 });
}
const ci = groups.cranks.start;
allEs.push([ci + 0, ci + 1]);
allEs.push([ci + 2, ci + 3]);
allEs.push([ci + 4, ci + 5]);
allEs.push([ci + 6, ci + 7]);
groups.cranks.end = allVs.length;
groups.chainRing.start = allVs.length;
merge(makeWheel(bb.x, bb.y, 0, 0.12, 16));
groups.chainRing.end = allVs.length;
return { vs: allVs, es: allEs, groups, bb, rearHub, frontHub };
}
const bicycle = buildBicycle();
const origVs = bicycle.vs.map(v => ({ ...v }));
const rearHubCenter = { x: bicycle.rearHub.x, y: bicycle.rearHub.y, z: 0 };
const frontHubCenter = { x: bicycle.frontHub.x, y: bicycle.frontHub.y, z: 0 };
const bbCenter = { x: bicycle.bb.x, y: bicycle.bb.y, z: 0 };
function localVerts(group, center) {
const result = [];
for (let i = group.start; i < group.end; i++) {
result.push({
x: origVs[i].x - center.x,
y: origVs[i].y - center.y,
z: origVs[i].z - center.z,
});
}
return result;
}
const rearWheelLocal = localVerts(bicycle.groups.rearWheel, rearHubCenter);
const frontWheelLocal = localVerts(bicycle.groups.frontWheel, frontHubCenter);
const chainRingLocal = localVerts(bicycle.groups.chainRing, bbCenter);
function rotateZ2D({ x, y, z }, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return {
x: x * c - y * s,
y: x * s + y * c,
z,
};
}
let viewAngle = 0;
let crankAngle = 0;
function rotateY({ x, y, z }, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return {
x: x * c - z * s,
y,
z: x * s + z * c,
};
}
function translateZ({ x, y, z }, dz) {
return { x, y, z: z + dz };
}
const DZ = 2.2;
const PEDAL_SPEED = -(Math.PI * 1.2);
const GEAR_RATIO = 3.0;
function updateAnimatedVerts(dt) {
crankAngle += PEDAL_SPEED * dt;
const wheelAngle = crankAngle * GEAR_RATIO;
function applySpinGroup(localList, group, center) {
for (let i = 0; i < localList.length; i++) {
const rotated = rotateZ2D(localList[i], wheelAngle);
const vi = group.start + i;
bicycle.vs[vi].x = center.x + rotated.x;
bicycle.vs[vi].y = center.y + rotated.y;
bicycle.vs[vi].z = center.z + rotated.z;
}
}
applySpinGroup(rearWheelLocal, bicycle.groups.rearWheel, rearHubCenter);
applySpinGroup(frontWheelLocal, bicycle.groups.frontWheel, frontHubCenter);
function applySpinGroupChain(localList, group, center) {
for (let i = 0; i < localList.length; i++) {
const rotated = rotateZ2D(localList[i], crankAngle);
const vi = group.start + i;
bicycle.vs[vi].x = center.x + rotated.x;
bicycle.vs[vi].y = center.y + rotated.y;
bicycle.vs[vi].z = center.z + rotated.z;
}
}
applySpinGroupChain(chainRingLocal, bicycle.groups.chainRing, bbCenter);
const bb = bicycle.bb;
const crankLen = 0.14;
const crankZOff = 0.06;
const pedalHalf = 0.07;
const ra = crankAngle;
const rTipX = bb.x + Math.cos(ra) * crankLen;
const rTipY = bb.y + Math.sin(ra) * crankLen;
const la = crankAngle + Math.PI;
const lTipX = bb.x + Math.cos(la) * crankLen;
const lTipY = bb.y + Math.sin(la) * crankLen;
const ci = bicycle.groups.cranks.start;
bicycle.vs[ci + 0] = { x: bb.x, y: bb.y, z: -crankZOff };
bicycle.vs[ci + 1] = { x: lTipX, y: lTipY, z: -crankZOff };
bicycle.vs[ci + 2] = { x: lTipX - pedalHalf, y: lTipY, z: -crankZOff };
bicycle.vs[ci + 3] = { x: lTipX + pedalHalf, y: lTipY, z: -crankZOff };
bicycle.vs[ci + 4] = { x: bb.x, y: bb.y, z: crankZOff };
bicycle.vs[ci + 5] = { x: rTipX, y: rTipY, z: crankZOff };
bicycle.vs[ci + 6] = { x: rTipX - pedalHalf, y: rTipY, z: crankZOff };
bicycle.vs[ci + 7] = { x: rTipX + pedalHalf, y: rTipY, z: crankZOff };
}
function frame() {
const dt = 1 / FPS;
viewAngle += Math.PI * 0.2 * dt;
updateAnimatedVerts(dt);
clear();
const transformed = bicycle.vs.map(v => translateZ(rotateY(v, viewAngle), DZ));
const projected = transformed.map(v => screen(project(v)));
for (const [ai, bi] of bicycle.es) {
const a = transformed[ai];
const b = transformed[bi];
const avgZ = (a.z + b.z) / 2;
const alpha = Math.max(0.08, Math.min(1, (avgZ - 1.0) / 1.5));
line(projected[ai], projected[bi], alpha);
}
setTimeout(frame, 1000 / FPS);
}
setTimeout(frame, 1000 / FPS);
}
And a small nod to Simon Willison's SVG Pelican riding a bicycle test. Though this is of course not an example of this test as this is not an SVG and is instead live rendering this 3D image using the HTML canvas and the base code is derived from Tsoding's entirely human authored tutorial.
function startScript5(canvasId) {
const game = document.getElementById(canvasId)
const BACKGROUND = "#101010"
const FOREGROUND = "#50FF50"
game.width = 800
game.height = 800
const ctx = game.getContext("2d")
function clear() {
ctx.fillStyle = BACKGROUND
ctx.fillRect(0, 0, game.width, game.height)
}
function line(p1, p2, alpha = 1) {
ctx.strokeStyle = `rgba(80, 255, 80, ${alpha})`
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
function screen(p) {
return {
x: (p.x + 1) / 2 * game.width,
y: (1 - (p.y + 1) / 2) * game.height,
}
}
function project({ x, y, z }) {
return {
x: x / z,
y: y / z,
}
}
const FPS = 60;
function makeWheel(cx, cy, cz, radius, segments = 24) {
const vs = [];
const es = [];
function vi() { return vs.length; }
const rimStart = vi();
for (let i = 0; i < segments; i++) {
const a = (i / segments) * Math.PI * 2;
vs.push({
x: cx + Math.cos(a) * radius,
y: cy + Math.sin(a) * radius,
z: cz,
});
}
for (let i = 0; i < segments; i++) {
es.push([rimStart + i, rimStart + (i + 1) % segments]);
}
const hub = vi();
vs.push({ x: cx, y: cy, z: cz });
const spokeCount = 12;
for (let i = 0; i < spokeCount; i++) {
const rimIdx = Math.floor((i / spokeCount) * segments);
es.push([hub, rimStart + rimIdx]);
}
const hubR = radius * 0.08;
const hubRimStart = vi();
const hubSegs = 8;
for (let i = 0; i < hubSegs; i++) {
const a = (i / hubSegs) * Math.PI * 2;
vs.push({
x: cx + Math.cos(a) * hubR,
y: cy + Math.sin(a) * hubR,
z: cz,
});
}
for (let i = 0; i < hubSegs; i++) {
es.push([hubRimStart + i, hubRimStart + (i + 1) % hubSegs]);
}
return { vs, es };
}
function makeTube(x1, y1, z1, x2, y2, z2) {
const vs = [];
const es = [];
vs.push({ x: x1, y: y1, z: z1 });
vs.push({ x: x2, y: y2, z: z2 });
es.push([0, 1]);
return { vs, es };
}
function buildBicycle() {
const allVs = [];
const allEs = [];
const groups = {
rearWheel: { start: -1, end: -1 },
frontWheel: { start: -1, end: -1 },
chainRing: { start: -1, end: -1 },
cranks: { start: -1, end: -1 },
};
function merge({ vs, es }) {
const offset = allVs.length;
allVs.push(...vs);
for (const [a, b] of es) {
allEs.push([a + offset, b + offset]);
}
return offset;
}
function addEdge(p1, p2) {
return merge(makeTube(p1.x, p1.y, p1.z, p2.x, p2.y, p2.z));
}
const wheelRadius = 0.35;
const wheelY = -0.1;
const rearX = -0.55;
const frontX = 0.55;
const wheelZ = 0;
groups.rearWheel.start = allVs.length;
merge(makeWheel(rearX, wheelY, wheelZ, wheelRadius));
groups.rearWheel.end = allVs.length;
groups.frontWheel.start = allVs.length;
merge(makeWheel(frontX, wheelY, wheelZ, wheelRadius));
groups.frontWheel.end = allVs.length;
const rearHub = { x: rearX, y: wheelY, z: 0 };
const frontHub = { x: frontX, y: wheelY, z: 0 };
const bb = { x: -0.05, y: wheelY, z: 0 };
const seatTop = { x: -0.15, y: wheelY + 0.55, z: 0 };
const headTop = { x: 0.28, y: wheelY + 0.52, z: 0 };
addEdge(bb, seatTop);
addEdge(seatTop, headTop);
addEdge(headTop, bb);
const csZ = 0.05;
addEdge({ ...rearHub, z: -csZ }, { ...bb, z: -csZ });
addEdge({ ...rearHub, z: csZ }, { ...bb, z: csZ });
addEdge({ ...rearHub, z: -csZ }, { ...seatTop, z: -csZ });
addEdge({ ...rearHub, z: csZ }, { ...seatTop, z: csZ });
addEdge({ ...rearHub, z: -csZ }, { ...rearHub, z: csZ });
addEdge({ ...bb, z: -csZ }, { ...bb, z: csZ });
addEdge({ ...seatTop, z: -csZ }, { ...seatTop, z: csZ });
const headBot = { x: 0.32, y: wheelY + 0.18, z: 0 };
addEdge(headTop, headBot);
const forkZ = 0.05;
addEdge({ ...headBot, z: -forkZ }, { ...frontHub, z: -forkZ });
addEdge({ ...headBot, z: forkZ }, { ...frontHub, z: forkZ });
addEdge({ ...frontHub, z: -forkZ }, { ...frontHub, z: forkZ });
const stemTop = { x: 0.26, y: wheelY + 0.68, z: 0 };
addEdge(headTop, stemTop);
const hbLeft = { x: 0.22, y: wheelY + 0.70, z: -0.18 };
const hbRight = { x: 0.22, y: wheelY + 0.70, z: 0.18 };
addEdge(stemTop, hbLeft);
addEdge(stemTop, hbRight);
const hbDropL = { x: 0.28, y: wheelY + 0.58, z: -0.18 };
const hbDropR = { x: 0.28, y: wheelY + 0.58, z: 0.18 };
addEdge(hbLeft, hbDropL);
addEdge(hbRight, hbDropR);
const seatPost = { x: -0.18, y: wheelY + 0.62, z: 0 };
addEdge(seatTop, seatPost);
const seatL = { x: -0.08, y: wheelY + 0.64, z: -0.1 };
const seatR = { x: -0.08, y: wheelY + 0.64, z: 0.1 };
const seatBL = { x: -0.28, y: wheelY + 0.62, z: -0.07 };
const seatBR = { x: -0.28, y: wheelY + 0.62, z: 0.07 };
addEdge(seatPost, seatL);
addEdge(seatPost, seatR);
addEdge(seatL, seatR);
addEdge(seatL, seatBL);
addEdge(seatR, seatBR);
addEdge(seatBL, seatBR);
groups.cranks.start = allVs.length;
for (let i = 0; i < 8; i++) {
allVs.push({ x: 0, y: 0, z: 0 });
}
const ci = groups.cranks.start;
allEs.push([ci + 0, ci + 1]);
allEs.push([ci + 2, ci + 3]);
allEs.push([ci + 4, ci + 5]);
allEs.push([ci + 6, ci + 7]);
groups.cranks.end = allVs.length;
groups.chainRing.start = allVs.length;
merge(makeWheel(bb.x, bb.y, 0, 0.12, 16));
groups.chainRing.end = allVs.length;
return { vs: allVs, es: allEs, groups, bb, rearHub, frontHub, seatTop, headTop };
}
function buildStork(bicycle) {
const allVs = [];
const allEs = [];
const groups = {
storkStatic: { start: -1, end: -1 },
storkLegLeft: { start: -1, end: -1 },
storkLegRight: { start: -1, end: -1 },
};
function addV(v) {
allVs.push(v);
return allVs.length - 1;
}
function addE(a, b) {
allEs.push([a, b]);
}
const wheelY = -0.1;
const seatX = -0.13;
const seatY = wheelY + 0.67;
const bodyBase = { x: seatX, y: seatY, z: 0 };
const bodyTop = { x: seatX + 0.05, y: seatY + 0.35, z: 0 };
const neckBase = { x: seatX + 0.05, y: seatY + 0.35, z: 0 };
const neckMid = { x: seatX + 0.12, y: seatY + 0.52, z: 0 };
const headCen = { x: seatX + 0.14, y: seatY + 0.60, z: 0 };
const beakTip = { x: seatX + 0.30, y: seatY + 0.58, z: 0 };
const beakBot = { x: seatX + 0.30, y: seatY + 0.555,z: 0 };
const headR = 0.045;
const headSegs = 10;
groups.storkStatic.start = allVs.length;
const ibodyBase = addV(bodyBase);
const ibodyTop = addV(bodyTop);
addE(ibodyBase, ibodyTop);
const ribOffsets = [0.1, 0.2, 0.3];
for (const t of ribOffsets) {
const rx = bodyBase.x + (bodyTop.x - bodyBase.x) * t;
const ry = bodyBase.y + (bodyTop.y - bodyBase.y) * t;
const w = 0.06 * (1 - t * 0.5);
const iL = addV({ x: rx, y: ry, z: -w });
const iR = addV({ x: rx, y: ry, z: w });
addE(iL, iR);
const iSpine = addV({ x: rx, y: ry, z: 0 });
addE(iSpine, iL);
addE(iSpine, iR);
}
const ineckBase = addV(neckBase);
const ineckMid = addV(neckMid);
const iheadCen = addV(headCen);
addE(ibodyTop, ineckBase);
addE(ineckBase, ineckMid);
addE(ineckMid, iheadCen);
const headVStart = allVs.length;
for (let i = 0; i < headSegs; i++) {
const a = (i / headSegs) * Math.PI * 2;
addV({
x: headCen.x + Math.cos(a) * headR,
y: headCen.y + Math.sin(a) * headR * 0.8,
z: 0,
});
}
for (let i = 0; i < headSegs; i++) {
addE(headVStart + i, headVStart + (i + 1) % headSegs);
}
const iheadFront = addV({ x: headCen.x + headR, y: headCen.y, z: 0 });
const ibeakTip = addV(beakTip);
const ibeakBot = addV(beakBot);
addE(iheadFront, ibeakTip);
addE(iheadFront, ibeakBot);
addE(ibeakTip, ibeakBot);
const eyeX = headCen.x + 0.01;
const eyeY = headCen.y + 0.01;
const eyeS = 0.008;
const ie1 = addV({ x: eyeX - eyeS, y: eyeY, z: 0 });
const ie2 = addV({ x: eyeX + eyeS, y: eyeY, z: 0 });
const ie3 = addV({ x: eyeX, y: eyeY - eyeS, z: 0 });
const ie4 = addV({ x: eyeX, y: eyeY + eyeS, z: 0 });
addE(ie1, ie2);
addE(ie3, ie4);
const wingRootL = { x: seatX + 0.02, y: seatY + 0.25, z: 0 };
const wingTipL = { x: seatX - 0.25, y: seatY + 0.32, z: -0.28 };
const wingMidL = { x: seatX - 0.10, y: seatY + 0.28, z: -0.16 };
const wingRootR = { x: seatX + 0.02, y: seatY + 0.25, z: 0 };
const wingTipR = { x: seatX - 0.25, y: seatY + 0.32, z: 0.28 };
const wingMidR = { x: seatX - 0.10, y: seatY + 0.28, z: 0.16 };
const iwRL = addV(wingRootL);
const iwML = addV(wingMidL);
const iwTL = addV(wingTipL);
const iwRR = addV(wingRootR);
const iwMR = addV(wingMidR);
const iwTR = addV(wingTipR);
addE(ibodyTop, iwRL);
addE(iwRL, iwML);
addE(iwML, iwTL);
addE(ibodyTop, iwRR);
addE(iwRR, iwMR);
addE(iwMR, iwTR);
const featherCount = 3;
for (let i = 1; i <= featherCount; i++) {
const t = i / (featherCount + 1);
const fx = wingRootL.x + (wingTipL.x - wingRootL.x) * t;
const fy = wingRootL.y + (wingTipL.y - wingRootL.y) * t;
const fz = wingRootL.z + (wingTipL.z - wingRootL.z) * t;
const il = addV({ x: fx, y: fy, z: fz });
const ilE = addV({ x: fx - 0.05, y: fy - 0.06, z: fz });
addE(il, ilE);
const fxR = wingRootR.x + (wingTipR.x - wingRootR.x) * t;
const fyR = wingRootR.y + (wingTipR.y - wingRootR.y) * t;
const fzR = wingRootR.z + (wingTipR.z - wingRootR.z) * t;
const ir = addV({ x: fxR, y: fyR, z: fzR });
const irE = addV({ x: fxR - 0.05, y: fyR - 0.06, z: fzR });
addE(ir, irE);
}
groups.storkStatic.end = allVs.length;
groups.storkLegLeft.start = allVs.length;
for (let i = 0; i < 4; i++) allVs.push({ x: 0, y: 0, z: 0 });
const lli = groups.storkLegLeft.start;
addE(lli + 0, lli + 1);
addE(lli + 1, lli + 2);
addE(lli + 2, lli + 3);
groups.storkLegLeft.end = allVs.length;
groups.storkLegRight.start = allVs.length;
for (let i = 0; i < 4; i++) allVs.push({ x: 0, y: 0, z: 0 });
const rli = groups.storkLegRight.start;
addE(rli + 0, rli + 1);
addE(rli + 1, rli + 2);
addE(rli + 2, rli + 3);
groups.storkLegRight.end = allVs.length;
return { vs: allVs, es: allEs, groups };
}
const bicycle = buildBicycle();
const stork = buildStork(bicycle);
const origVs = bicycle.vs.map(v => ({ ...v }));
const rearHubCenter = { x: bicycle.rearHub.x, y: bicycle.rearHub.y, z: 0 };
const frontHubCenter = { x: bicycle.frontHub.x, y: bicycle.frontHub.y, z: 0 };
const bbCenter = { x: bicycle.bb.x, y: bicycle.bb.y, z: 0 };
function localVerts(group, center) {
const result = [];
for (let i = group.start; i < group.end; i++) {
result.push({
x: origVs[i].x - center.x,
y: origVs[i].y - center.y,
z: origVs[i].z - center.z,
});
}
return result;
}
const rearWheelLocal = localVerts(bicycle.groups.rearWheel, rearHubCenter);
const frontWheelLocal = localVerts(bicycle.groups.frontWheel, frontHubCenter);
const chainRingLocal = localVerts(bicycle.groups.chainRing, bbCenter);
function rotateZ2D({ x, y, z }, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return {
x: x * c - y * s,
y: x * s + y * c,
z,
};
}
let viewAngle = 0;
let crankAngle = 0;
function rotateY({ x, y, z }, a) {
const c = Math.cos(a);
const s = Math.sin(a);
return {
x: x * c - z * s,
y,
z: x * s + z * c,
};
}
function translateZ({ x, y, z }, dz) {
return { x, y, z: z + dz };
}
const DZ = 2.2;
const PEDAL_SPEED = -(Math.PI * 1.2);
const GEAR_RATIO = 3.0;
const wheelY = -0.1;
const seatX = -0.13;
const seatY = wheelY + 0.67;
const hipY = seatY - 0.04;
const hipX = seatX;
const hipZOff = 0.055;
const thighLen = 0.22;
const shinLen = 0.20;
const toeLen = 0.07;
function updateStorkLegs(crankAngle) {
const bb = bicycle.bb;
const crankLen = 0.14;
const crankZOff = 0.06;
const ra = crankAngle;
const rPX = bb.x + Math.cos(ra) * crankLen;
const rPY = bb.y + Math.sin(ra) * crankLen;
const rPZ = crankZOff;
const la = crankAngle + Math.PI;
const lPX = bb.x + Math.cos(la) * crankLen;
const lPY = bb.y + Math.sin(la) * crankLen;
const lPZ = -crankZOff;
function solveIK(hipPos, targetPos, upperLen, lowerLen) {
const dx = targetPos.x - hipPos.x;
const dy = targetPos.y - hipPos.y;
const dz = targetPos.z - hipPos.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
const clampedDist = Math.min(dist, upperLen + lowerLen - 0.001);
const cosA = (upperLen * upperLen + clampedDist * clampedDist - lowerLen * lowerLen)
/ (2 * upperLen * clampedDist);
const angleA = Math.acos(Math.max(-1, Math.min(1, cosA)));
const baseAngle = Math.atan2(dy, Math.sqrt(dx * dx + dz * dz));
const horizAngle = Math.atan2(dz, dx);
const kneeAngle = baseAngle + angleA;
const horizDist = Math.sqrt(dx * dx + dz * dz);
const kX = hipPos.x + upperLen * Math.cos(kneeAngle) * (horizDist > 0.0001 ? dx / Math.sqrt(dx*dx+dz*dz) : 1);
const kY = hipPos.y + upperLen * Math.sin(kneeAngle);
const kZ = hipPos.z + upperLen * Math.cos(kneeAngle) * (horizDist > 0.0001 ? dz / Math.sqrt(dx*dx+dz*dz) : 0);
return { knee: { x: kX, y: kY, z: kZ } };
}
const lHip = { x: hipX, y: hipY, z: -hipZOff };
const lTarget = { x: lPX, y: lPY, z: lPZ };
const { knee: lKnee } = solveIK(lHip, lTarget, thighLen, shinLen);
const lToe = { x: lTarget.x + toeLen * 0.7, y: lTarget.y, z: lTarget.z };
const lli = stork.groups.storkLegLeft.start;
stork.vs[lli + 0] = lHip;
stork.vs[lli + 1] = lKnee;
stork.vs[lli + 2] = lTarget;
stork.vs[lli + 3] = lToe;
const rHip = { x: hipX, y: hipY, z: hipZOff };
const rTarget = { x: rPX, y: rPY, z: rPZ };
const { knee: rKnee } = solveIK(rHip, rTarget, thighLen, shinLen);
const rToe = { x: rTarget.x + toeLen * 0.7, y: rTarget.y, z: rTarget.z };
const rli = stork.groups.storkLegRight.start;
stork.vs[rli + 0] = rHip;
stork.vs[rli + 1] = rKnee;
stork.vs[rli + 2] = rTarget;
stork.vs[rli + 3] = rToe;
}
function updateAnimatedVerts(dt) {
crankAngle += PEDAL_SPEED * dt;
const wheelAngle = crankAngle * GEAR_RATIO;
function applySpinGroup(localList, group, center) {
for (let i = 0; i < localList.length; i++) {
const rotated = rotateZ2D(localList[i], wheelAngle);
const vi = group.start + i;
bicycle.vs[vi].x = center.x + rotated.x;
bicycle.vs[vi].y = center.y + rotated.y;
bicycle.vs[vi].z = center.z + rotated.z;
}
}
applySpinGroup(rearWheelLocal, bicycle.groups.rearWheel, rearHubCenter);
applySpinGroup(frontWheelLocal, bicycle.groups.frontWheel, frontHubCenter);
function applySpinGroupChain(localList, group, center) {
for (let i = 0; i < localList.length; i++) {
const rotated = rotateZ2D(localList[i], crankAngle);
const vi = group.start + i;
bicycle.vs[vi].x = center.x + rotated.x;
bicycle.vs[vi].y = center.y + rotated.y;
bicycle.vs[vi].z = center.z + rotated.z;
}
}
applySpinGroupChain(chainRingLocal, bicycle.groups.chainRing, bbCenter);
const bb = bicycle.bb;
const crankLen = 0.14;
const crankZOff = 0.06;
const pedalHalf = 0.07;
const ra = crankAngle;
const rTipX = bb.x + Math.cos(ra) * crankLen;
const rTipY = bb.y + Math.sin(ra) * crankLen;
const la = crankAngle + Math.PI;
const lTipX = bb.x + Math.cos(la) * crankLen;
const lTipY = bb.y + Math.sin(la) * crankLen;
const ci = bicycle.groups.cranks.start;
bicycle.vs[ci + 0] = { x: bb.x, y: bb.y, z: -crankZOff };
bicycle.vs[ci + 1] = { x: lTipX, y: lTipY, z: -crankZOff };
bicycle.vs[ci + 2] = { x: lTipX - pedalHalf, y: lTipY, z: -crankZOff };
bicycle.vs[ci + 3] = { x: lTipX + pedalHalf, y: lTipY, z: -crankZOff };
bicycle.vs[ci + 4] = { x: bb.x, y: bb.y, z: crankZOff };
bicycle.vs[ci + 5] = { x: rTipX, y: rTipY, z: crankZOff };
bicycle.vs[ci + 6] = { x: rTipX - pedalHalf, y: rTipY, z: crankZOff };
bicycle.vs[ci + 7] = { x: rTipX + pedalHalf, y: rTipY, z: crankZOff };
updateStorkLegs(crankAngle);
}
function frame() {
const dt = 1 / FPS;
viewAngle += Math.PI * 0.2 * dt;
updateAnimatedVerts(dt);
clear();
const transBike = bicycle.vs.map(v => translateZ(rotateY(v, viewAngle), DZ));
const projBike = transBike.map(v => screen(project(v)));
for (const [ai, bi] of bicycle.es) {
const a = transBike[ai];
const b = transBike[bi];
const avgZ = (a.z + b.z) / 2;
const alpha = Math.max(0.08, Math.min(1, (avgZ - 1.0) / 1.5));
line(projBike[ai], projBike[bi], alpha);
}
const transStork = stork.vs.map(v => translateZ(rotateY(v, viewAngle), DZ));
const projStork = transStork.map(v => screen(project(v)));
for (const [ai, bi] of stork.es) {
const a = transStork[ai];
const b = transStork[bi];
const avgZ = (a.z + b.z) / 2;
const alpha = Math.max(0.08, Math.min(1, (avgZ - 1.0) / 1.5));
line(projStork[ai], projStork[bi], alpha);
}
setTimeout(frame, 1000 / FPS);
}
setTimeout(frame, 1000 / FPS);
}