尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

纯前端实现ASCII 3D赛博朋克城市:从投影到深度缓冲

纯前端实现ASCII 3D赛博朋克城市:从投影到深度缓冲 之前在不少社区里看到过一类很“炫技”的 Demo不给任何 3D 模型也不引入 Three.js、Unity 这类引擎仅仅用浏览器里自带的东西就能渲染出一座完整的赛博朋克风格城市。拆开一看核心只有四个字——ASCII 字符。本文就带你从零实现一个只用一个 HTML 文件、不需要任何外部依赖的 3D ASCII 赛博朋克城市包含建筑生成、透视投影、深度缓冲、雨夜霓虹动画等完整逻辑。读完你不仅能跑通这个项目还能理解这种“伪 3D 字符渲染”的组合到底是怎么工作的。1. 背景与核心概念1.1 什么是 ASCII 3D 渲染ASCII 3D 渲染并不是一个严格意义上的 3D 渲染技术它更像是一种复古风格的可视化方案。简单来说就是把三维空间中的点投影到二维屏幕上之后用不同密度的 ASCII 字符来表示物体的远近、明暗和轮廓。例如在经典终端图形中、#、*、.这些字符经常被用来表示“近到远”的层次。近处的物体会用笔画多、视觉密度高的字符远处的物体则用密度低的字符最终在视觉上形成一种信息压缩版的透视画面。普通 3D 渲染是逐像素填充颜色而 ASCII 3D 渲染是逐字符填充显示。这样做的优点是计算量小不需要 GPU 参与。代码量极低适合在终端、HTML 页面里快速实现。自带复古未来感和赛博朋克、合成器浪潮等风格天然契合。1.2 赛博朋克城市为什么适合 ASCII赛博朋克风格的典型视觉元素包括高密度摩天楼、霓虹灯光、雨夜、反光地面、红蓝紫配色的明暗对比。这些东西放到 ASCII 渲染里反而更容易出效果因为字符天然有“信息密集感”一排排#和%组合起来就像密集的楼宇彩色字符在黑色背景上又能模拟霓虹灯光的发光感。而且赛博朋克城市的细节不需要像写实 3D 那样精确建筑的轮廓、窗户的亮灯、远处的高楼剪影都可以用字符抽象表达。换句话说这是一个“能跑起来就算成功”的视觉项目。1.3 技术路线概览本文的技术实现完全不依赖任何外部库只用浏览器三件套HTML搭建页面骨架和显示画布。CSS处理全屏黑色背景和字体信息。JavaScript负责城市数据生成、3D 坐标投影、深度判断、字符映射和动画循环。整个项目就是一个.html文件。核心逻辑可以拆成四部分用代码随机生成城市建筑数据包括建筑位置、高度、轮廓点和窗户点。将三维坐标通过透视投影映射到二维屏幕。用字符网格保存每个屏幕单元最前方的物体。配合雨滴、闪烁霓虹和旋转视角形成赛博朋克城市夜景。2. 准备工作2.1 环境要求这个项目不需要安装任何框架也不需要 Node.js 环境。你只需要一个现代浏览器推荐 Chrome、Edge 或 Firefox。一个文本编辑器推荐 VSCode、Notepad 或 Sublime Text。一个能正常运行本地 HTML 文件的浏览器环境。操作方法很简单把代码保存为cyber-city.html然后用浏览器双击打开即可。如果你的浏览器对本地脚本有限制可以在本地启动一个简单的静态服务器例如使用 Python 自带的 HTTP 服务python -m http.server 8080随后访问http://localhost:8080/cyber-city.html。2.2 项目结构说明由于目标是“一个 HTML 完成全部功能”所以项目结构很简洁cyber-city.html文件内部通过style标签写样式通过script标签写 JavaScript。整个页面只有一个canvas画布所有的字符渲染都在画布上完成。2.3 为什么要用 Canvas 而不是命令行终端有人可能会问既然用 ASCII 字符为什么不直接用终端输出因为 HTML 页面里的 Canvas 可以轻松控制字符颜色、位置和大小还可以叠加雨滴、发光效果和动态动画。终端输出受限于系统字体和背景色交互体验远不如网页。本文的渲染方案是把 Canvas 当成一块“字符画布”按行列划分网格然后在每个网格里绘制一个字符。这样既能享受 Canvas 的灵活性又能保留 ASCII 的复古味道。3. 核心原理拆解3.1 城市数据的生成真实 3D 城市渲染需要模型文件而我们要在代码里直接生成城市。最简单的思路是把城市划分成一块块网格某些网格是街道某些网格是建筑建筑高度和宽度随机生成。每栋建筑不需要真的用复杂的三角面片描述只需要三类点建筑底部和顶部四角形成的竖边。建筑外立面上的发光窗户点。楼顶上随机分布的霓虹灯点。这些点拥有三维坐标(x, y, z)y 轴代表高度。城市数据生成逻辑如下function addBuilding(x0, z0, w, h) { const points []; const x1 x0 w; const z1 z0 w; const corners [ [x0, z0], [x1, z0], [x1, z1], [x0, z1] ]; // 生成四条竖边上的点 for (const [cx, cz] of corners) { for (let y 0; y h; y 1.0) { points.push({ x: cx, y, z: cz, type: edge, color: hsl(${Math.random() 0.5 ? 180 : 320}, 100%, 60%) }); } } // 生成外立面窗户点 const windowCount Math.min(50, Math.floor(w * h * 1.5)); const windowColors [ hsl(185, 100%, 65%), hsl(320, 100%, 70%), hsl(45, 100%, 65%), hsl(150, 100%, 55%) ]; for (let i 0; i windowCount; i) { const side Math.floor(Math.random() * 4); let x, z; if (side 0) { x x0; z z0 Math.random() * w; } else if (side 1) { x x1; z z0 Math.random() * w; } else if (side 2) { x x0 Math.random() * w; z z0; } else { x x0 Math.random() * w; z z1; } const y 0.6 Math.random() * (h - 0.6); points.push({ x, y, z, type: window, color: windowColors[Math.floor(Math.random() * windowColors.length)] }); } // 楼顶霓虹灯 for (let i 0; i 4; i) { points.push({ x: x0 Math.random() * w, y: h, z: z0 Math.random() * w, type: roof, color: hsl(${Math.random() 0.5 ? 280 : 190}, 100%, ${60 Math.random() * 30}%) }); } buildings.push({ x0, z0, w, h, points }); }上述代码生成了建筑轮廓点、窗户点和屋顶霓虹点。你可能会问为什么不是建筑实体因为我们的目标是“字符化”不是“实体建模”。只要这些点经过投影后能在屏幕对应位置形成足够的字符密度人眼就会自动脑补出建筑的形状。3.2 透视投影投影是将三维点映射到二维屏幕的关键。本文采用最简单的针孔相机模型相机位于城市前方。相机看向城市中心。离相机越远的物体在屏幕上看起来越小。假设相机位置是(camX, camY, camZ)某一个三维点是(x, y, z)那么透视投影公式可以简化成screenX 屏幕中心X (x - camX) * fov / (z - camZ) screenY 屏幕中心Y - (y - camY) * fov / (z - camZ)其中fov是视场角相关的缩放系数值越大物体看起来越大。为了让城市有“环绕感”我在代码里让城市整体绕 Y 轴旋转。也就是在投影前先对(x, z)做旋转const cosA Math.cos(angle); const sinA Math.sin(angle); const rx p.x * cosA - p.z * sinA; const rz p.x * sinA p.z * cosA;旋转后再计算相对相机的距离。这个旋转实际上是“旋转世界、固定相机”和“固定世界、旋转相机”是等价的代码实现更简单。3.3 ASCII 字符映射当一个三维点被投影到屏幕后还需要确定它在屏幕上显示为什么字符。字符的选择由深度决定。距离越近字符的“视觉重量”越大距离越远字符越稀疏。一个常见的映射表是function charForDepth(z) { if (z 8) return ; if (z 14) return #; if (z 22) return ; if (z 32) return *; if (z 45) return ; if (z 60) return o; if (z 80) return .; return ; }这样近处的楼体会用、#表示远处的高楼变成o、.最后形成纵深感。3.4 深度缓冲与颜色叠加当许多三维点投影到同一个屏幕字符格时我们需要决定哪个点显示在最前面。最简单的方案就是深度缓冲 z-buffer。用一个和字符网格同样大小的数组保存每个格子当前最近的深度值每投影一个点就与已有深度比较如果更近则覆盖字符和颜色。由于 Canvas 最终是逐字符绘制的所以还需要为每个格子保存对应的颜色。这一步相当于把传统 3D 渲染中的“颜色缓冲”和“深度缓冲”都搬到了字符网格里。4. 实战搭建 ASCII 赛博朋克城市4.1 初始化画布与字符网格首先确定字符网格的大小。为了兼容不同屏幕我建议把 Canvas 尺寸设置为浏览器窗口尺寸的整数倍并且每个字符宽度固定为 8px、高度固定为 14px。const canvas document.getElementById(c); const ctx canvas.getContext(2d); const CELL_W 8; const CELL_H 14; let cols, rows; function resize() { canvas.width Math.floor(window.innerWidth / CELL_W) * CELL_W; canvas.height Math.floor(window.innerHeight / CELL_H) * CELL_H; cols Math.floor(canvas.width / CELL_W); rows Math.floor(canvas.height / CELL_H); } window.addEventListener(resize, resize); resize();字符网格不必单独创建二维数组用一维数组即可下标计算公式为const idx row * cols col;4.2 城市生成与灯光生成城市时遍历一块虚拟网格预留街道位置其余位置随机生成建筑。街道的留白规则可以自由调整例如function generateCity() { const gridSize 20; const cellSize 3.0; for (let gx 0; gx gridSize; gx) { for (let gz 0; gz gridSize; gz) { if (gx % 5 2 || gz % 7 3) continue; const x0 (gx - gridSize / 2) * cellSize; const z0 (gz - gridSize / 2) * cellSize; const w cellSize * (0.75 Math.random() * 0.5); const h 3 Math.random() * 16; addBuilding(x0, z0, w, h); } } }这样生成的城市会有明显的街道切割感建筑高度随机符合赛博朋克高密度城市的基本特征。4.3 投影与写入深度缓冲渲染每一帧时先清空深度缓冲和字符网格然后遍历所有建筑点的三维坐标投影并写入缓冲。function project(p, cosA, sinA) { const rx p.x * cosA - p.z * sinA; const rz p.x * sinA p.z * cosA; const dx rx - camX; const dy p.y - camY; const dz rz camDist; if (dz 0.1) return null; const px canvas.width / 2 (dx * fov / dz); const py canvas.height * 0.42 - (dy * fov / dz); const col Math.floor(px / CELL_W); const row Math.floor(py / CELL_H); if (col 0 || col cols || row 0 || row rows) return null; return { col, row, z: dz }; }拿到col和row后根据距离选择字符再与深度缓冲比较function plot(col, row, z, ch, color, zbuf, chars, colors) { const idx row * cols col; if (z zbuf[idx]) { zbuf[idx] z; chars[idx] ch; colors[idx] color; } }注意这里的zbuf初始化为Infinity这样每个点都有机会写入。4.4 雨滴与霓虹闪烁赛博朋克不能没有雨。我在像素画布上维护一个雨滴数组每帧让雨滴下落然后把雨滴以|字符写入字符网格并把深度设置为0让它覆盖在建筑之上。const raindrops []; for (let i 0; i 200; i) { raindrops.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, speed: 80 Math.random() * 160 }); }每一帧更新雨滴位置然后调用一个特殊的覆盖函数function plotRain(col, row, ch, color, chars, colors) { const idx row * cols col; chars[idx] ch; colors[idx] color; }霓虹闪烁则通过Math.sin(time i)控制某些点的显隐让楼顶灯光有忽明忽暗的效果。4.5 字符渲染所有点都写入字符网格后最后一步是逐字符绘制到 Canvas。为了让字符不那么生硬可以使用等宽字体并在字符底部留出 2px 的边距避免看起来贴得太挤。ctx.font 14px Consolas, Courier New, monospace; ctx.fillStyle #050510; ctx.fillRect(0, 0, canvas.width, canvas.height); for (let i 0; i chars.length; i) { const ch chars[i]; if (ch ) continue; const col i % cols; const row Math.floor(i / cols); ctx.fillStyle colors[i]; ctx.fillText(ch, col * CELL_W, row * CELL_H CELL_H - 2); }如果字符数量很多fillText的调用次数会直接影响性能。本文示例中的网格规模大约在 100×40也就是 4000 个字符左右对现代浏览器来说压力不大。5. 完整可运行代码下面是一个完整可运行的版本。你可以把代码保存为cyber-city.html用浏览器打开就能看到一座不断旋转的 ASCII 赛博朋克城市。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleASCII 赛博朋克城市/title style html, body { margin: 0; padding: 0; background: #000; overflow: hidden; } canvas { display: block; background: #050510; } #info { position: fixed; bottom: 12px; left: 16px; color: #0ff; font: 13px Consolas, Courier New, monospace; background: rgba(0, 0, 0, 0.5); padding: 6px 12px; border-radius: 4px; pointer-events: none; z-index: 10; } /style /head body canvas idc/canvas div idinfoASCII 3D CYBER CITY | NO 3D MODELS/div script const canvas document.getElementById(c); const ctx canvas.getContext(2d); const CELL_W 8; const CELL_H 14; let cols, rows; function resize() { canvas.width Math.floor(window.innerWidth / CELL_W) * CELL_W; canvas.height Math.floor(window.innerHeight / CELL_H) * CELL_H; cols Math.floor(canvas.width / CELL_W); rows Math.floor(canvas.height / CELL_H); } window.addEventListener(resize, resize); resize(); const FONT ${CELL_H}px Consolas, Courier New, monospace; const buildings []; function addBuilding(x0, z0, w, h) { const points []; const x1 x0 w; const z1 z0 w; const corners [ [x0, z0], [x1, z0], [x1, z1], [x0, z1] ]; for (const [cx, cz] of corners) { for (let y 0; y h; y 1.0) { points.push({ x: cx, y, z: cz, type: edge, color: hsl(${Math.random() 0.5 ? 180 : 320}, 100%, ${55 Math.random() * 20}%) }); } } const windowCount Math.min(50, Math.floor(w * h * 1.5)); const windowColors [ hsl(185, 100%, 65%), hsl(320, 100%, 70%), hsl(45, 100%, 65%), hsl(150, 100%, 55%) ]; for (let i 0; i windowCount; i) { const side Math.floor(Math.random() * 4); let x, z; if (side 0) { x x0; z z0 Math.random() * w; } else if (side 1) { x x1; z z0 Math.random() * w; } else if (side 2) { x x0 Math.random() * w; z z0; } else { x x0 Math.random() * w; z z1; } const y 0.6 Math.random() * (h - 0.6); points.push({ x, y, z, type: window, color: windowColors[Math.floor(Math.random() * windowColors.length)] }); } for (let i 0; i 4; i) { points.push({ x: x0 Math.random() * w, y: h, z: z0 Math.random() * w, type: roof, color: hsl(${Math.random() 0.5 ? 280 : 190}, 100%, ${60 Math.random() * 30}%) }); } buildings.push({ x0, z0, w, h, points }); } function generateCity() { const gridSize 20; const cellSize 3.0; for (let gx 0; gx gridSize; gx) { for (let gz 0; gz gridSize; gz) { if (gx % 5 2 || gz % 7 3) continue; const x0 (gx - gridSize / 2) * cellSize; const z0 (gz - gridSize / 2) * cellSize; const w cellSize * (0.75 Math.random() * 0.5); const h 3 Math.random() * 16; addBuilding(x0, z0, w, h); } } } generateCity(); const camDist 40; const camHeight 22; const fov 320; let angle 0; const raindrops []; for (let i 0; i 200; i) { raindrops.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, speed: 80 Math.random() * 160 }); } function charForDepth(z) { if (z 5) return ; if (z 9) return #; if (z 14) return ; if (z 20) return *; if (z 28) return ; if (z 38) return o; if (z 50) return .; return ; } function project(p, cosA, sinA) { const rx p.x * cosA - p.z * sinA; const rz p.x * sinA p.z * cosA; const dy p.y - camHeight; const dz rz camDist; if (dz 0.1) return null; const px canvas.width / 2 (rx * fov / dz); const py canvas.height * 0.42 - (dy * fov / dz); const col Math.floor(px / CELL_W); const row Math.floor(py / CELL_H); if (col 0 || col cols || row 0 || row rows) return null; return { col, row, z: dz }; } function plot(col, row, z, ch, color, zbuf, chars, colors) { const idx row * cols col; if (z zbuf[idx]) { zbuf[idx] z; chars[idx] ch; colors[idx] color; } } let lastTime performance.now(); function render(time, dt) { angle dt * 0.15; ctx.fillStyle #050510; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.font FONT; const zbuf new Float32Array(cols * rows).fill(Infinity); const chars new Array(cols * rows).fill( ); const colors new Array(cols * rows).fill(#000); const cosA Math.cos(angle); const sinA Math.sin(angle); for (const b of buildings) { for (const p of b.points) { const pr project(p, cosA, sinA); if (!pr) continue; const ch charForDepth(pr.z); plot(pr.col, pr.row, pr.z, ch, p.color, zbuf, chars, colors); } } // 雨滴 for (const r of raindrops) { r.y r.speed * dt; if (r.y canvas.height) { r.y 0; r.x Math.random() * canvas.width; } const col Math.floor(r.x / CELL_W); const row Math.floor(r.y / CELL_H); if (col 0 || col cols || row 0 || row rows) continue; const idx row * cols col; chars[idx] |; colors[idx] rgba(150, 220, 255, 0.6); } // 霓虹闪烁招牌 const signChars [A, X, $, #, Z, N, E, R]; const signColors [#0ff, #f0f, #ff0, #f80]; for (let i 0; i 30; i) { const x (Math.random() - 0.5) * 50; const y 8 Math.random() * 20; const z (Math.random() - 0.5) * 50; const fakePoint { x, y, z }; const pr project(fakePoint, cosA, sinA); if (!pr) continue; if (Math.sin(time * 0.003 i * 1.7) 0) { const idx pr.row * cols pr.col; chars[idx] signChars[i % signChars.length]; colors[idx] signColors[i % signColors.length]; } } // 绘制字符 for (let i 0; i chars.length; i) { const ch chars[i]; if (ch ) continue; const col i % cols; const row Math.floor(i / cols); ctx.fillStyle colors[i]; ctx.fillText(ch, col * CELL_W, row * CELL_H CELL_H - 2); } } function frame(now) { const dt Math.min(0.05, (now - lastTime) / 1000); lastTime now; render(now, dt); requestAnimationFrame(frame); } requestAnimationFrame(frame); /script /body /html运行后你会看到一座由字符组成的城市在黑色背景中缓缓旋转。建筑边缘是青色和品红色窗户以黄、绿、蓝等颜色随机点亮雨滴斜着落下部分楼顶的霓虹招牌会闪烁。整体不需要任何 3D 模型也不需要加载外部字体。6. 常见问题与排查思路6.1 页面上只有一个黑色背景没有城市这种现象最常见的原因是 Canvas 宽度或高度为 0。如果 DOM 尚未加载完成就执行 resizeCanvas 拿不到正确的浏览器尺寸。解决办法是把script放到/body之前或者在 resize 前用window.addEventListener(load, init)包一层。如果代码是复制粘贴生成的还要检查是否把/script标签提前写错了。这个项目是一个完整 HTML 文件任何标签断裂都可能导致脚本不执行。6.2 城市能显示但是转动速度太快或太慢转动的速度由angle dt * 0.15控制。你可以把0.15改小到0.05让城市缓慢旋转也可以改成0.3让它成为快速环绕的赛博空间效果。这个值本质上是“每秒旋转的弧度”。6.3 字符显示模糊或有锯齿如果 Canvas 的实际像素尺寸与浏览器窗口尺寸不是整数倍字符边缘会模糊。设置canvas.width和canvas.height时尽量取Math.floor(window.innerWidth / CELL_W) * CELL_W这种整数倍方式保证每个字符都对齐像素格。此外不要把ctx.imageSmoothingEnabled打开因为这里没有贴图所以它不影响字符。真正影响清晰度的是字体指定使用等宽字体是必须的。6.4 性能偏低帧率不稳定如果字符网格太大比如在 4K 屏幕上每帧绘制几千次fillText性能会明显下降。可以尝试以下优化增大CELL_W和CELL_H减少字符总数。减少建筑窗户点数量。降低雨滴数量。使用requestAnimationFrame自带的帧间隔避免dt过大导致雨滴跳跃。更高阶的优化是把字符预渲染成离屏 Canvas 的贴图然后逐格drawImage不过这会增加代码复杂度本文不展开。6.5 雨滴没有出现在建筑前面雨滴写入字符网格时直接覆盖了chars[idx]和colors[idx]没有进行深度比较。由于写入顺序在所有建筑点之后所以雨滴会自然覆盖建筑。如果你把雨滴写入逻辑放在建筑点之前就会被建筑点覆盖掉。记住这个顺序即可。下面用表格总结几个最常遇到的问题问题现象常见原因解决思路页面全黑Canvas 尺寸为 0 或脚本没有执行检查 DOM 加载顺序单独测试 JS 是否报错城市转动太快角度累加值过大调小angle dt * 0.15中的系数字符模糊Canvas 尺寸不是字符单元格整数倍使用Math.floor对齐尺寸帧率低字符太多或点太多减少网格密度、降低窗户点数量、减少雨滴雨滴不显示雨滴被后续建筑覆盖把雨滴绘制放到建筑点投影之后7. 最佳实践与扩展建议7.1 从数据生成到渲染的工程思路这个项目的核心不在于代码量而在于把“数据生成”和“画面渲染”分离。建筑数据是在初始化阶段一次性生成的渲染阶段只读取数据做投影。这种思路比在每一帧里重新生成建筑更清晰也更适合后续扩展。如果你要加入更多建筑类型例如低矮商铺、高塔、桥梁只需要在建筑数据中增加新的点类型然后调整字符映射规则即可。7.2 如何改造为第一人称视角当前版本是城市自转。如果想改成第一人称穿行效果需要调整相机位置和方向移除世界旋转逻辑。让相机坐标随时间变化。新增视角朝向使相机始终看向前方。由于透视投影已经写好改造的重点只是相机矩阵而不是渲染算法本身。这是一个很好的进阶练习。7.3 如何加入鼠标或键盘交互你可以在window.addEventListener(keydown)中监听方向键控制相机位置也可以在canvas.addEventListener(mousemove)中根据鼠标位置改变视角旋转速度。交互层和渲染层分开维护代码会干净很多。7.4 安全与性能注意事项如果你要把这个项目部署到公网需要注意几点这是纯前端页面不涉及后端也没有隐私数据。随机数生成使用了Math.random()不适用于需要可复现结果的环境。如果需要固定相同的城市可以使用带种子的随机数函数。不要在requestAnimationFrame里创建大量对象尽量复用数组和对象减少垃圾回收压力。如果后续接入 WebGL 或更复杂的渲染建议保留当前 Canvas 作为低配模式便于调试和兼容。7.5 风格优化的方向想让赛博朋克味更浓可以考虑增加地面反光效果在地面区域使用-、_字符模拟湿漉漉的街面。增加“故障”效果每隔一段时间让局部字符发生错位模拟全息影像不稳定。使用渐变色远处建筑颜色变暗近处建筑颜色变亮。加入版权信息或状态栏显示当前建筑的 FPS、点数、字符数等信息。8. 总结思考通过这个项目你可以完整走一遍“三维数据生成 → 透视投影 → 深度排序 → 字符映射 → Canvas 渲染”的流程。它虽然没有复杂的光照和纹理但覆盖了 3D 渲染中最重要的几个概念坐标变换、投影、深度缓冲和像素输出。如果你之前只是写过普通的 HTML 页面那么这个项目会是一次很好的思维训练用数学生成三维结构再用字符画布表现立体空间。不需要太多图形学基础也能做出很有视觉冲击力的效果。实现过程中最值得反复理解的部分是字符网格与深度缓冲的配合理解了这一步后面再接触 WebGL、Three.js 等技术时也会更加轻松。下一步我建议你在这个项目基础上尝试两个方向。第一个方向是体验优化把相机改成可交互模式加入键盘控制做一个“行走的赛博城市”。第二个方向是渲染优化把字符渲染从 Canvas 2D 迁移到 WebGL增强颜色混合和粒子效果甚至加入音频驱动灯光。两种路线都能让你进一步巩固对图形渲染底层逻辑的理解。写这个项目最大的乐趣在于它证明了一个观点做出有意思的视觉效果不一定要依赖重型引擎和昂贵素材。一支画笔、一套字符映射规则加上一点点数学就足以在浏览器里建造一座完整的城市。
返回列表