☰
纯前端实现ASCII 3D赛博朋克城市:从投影到深度缓冲
2026/9/27 9:58:05 网站建设 项目流程

之前在不少社区里看到过一类很“炫技”的 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文件。核心逻辑可以拆成四部分:

  1. 用代码随机生成城市建筑数据,包括建筑位置、高度、轮廓点和窗户点。
  2. 将三维坐标通过透视投影映射到二维屏幕。
  3. 用字符网格保存每个屏幕单元最前方的物体。
  4. 配合雨滴、闪烁霓虹和旋转视角,形成赛博朋克城市夜景。

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 lang="zh-CN"> <head> <meta charset="UTF-8"> <title>ASCII 赛博朋克城市</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 id="c"></canvas> <div id="info">ASCII 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 尚未加载完成就执行 resize,Canvas 拿不到正确的浏览器尺寸。解决办法是把<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,增强颜色混合和粒子效果,甚至加入音频驱动灯光。两种路线都能让你进一步巩固对图形渲染底层逻辑的理解。

写这个项目最大的乐趣在于,它证明了一个观点:做出有意思的视觉效果,不一定要依赖重型引擎和昂贵素材。一支画笔、一套字符映射规则,加上一点点数学,就足以在浏览器里建造一座完整的城市。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询