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

资讯详情

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

用ASCII字符实现3D赛博朋克城市:从体素到透视投影的轻量渲染

用ASCII字符实现3D赛博朋克城市:从体素到透视投影的轻量渲染 做 3D 场景很多人的第一反应是打开 Blender 建模或者去模型网站下载一堆资产再往 Three.js 里加载。但如果你只是想在浏览器里快速呈现一个“看起来很像样”的 3D 城市其实有一个更轻、更接近图形学本质的做法不准备任何 3D 模型文件只用 HTML、Canvas 和字符集把城市画出来。标题里“用 ASCII 字符实现完整城市”不是噱头。它背后是一整套体素化、透视投影、深度排序和程序化生成思路。这件事真正的价值不在于“字符画很酷”而在于当你把 3D 城市拆成一个个可以打印的字符时你被迫理解坐标系、相机、投影和遮挡关系这些底层概念。这篇文会把这个单 HTML 文件从原理到代码拆开讲读完你能自己跑出一个可以转动的赛博朋克风格字符城市也知道哪里容易出问题。1. 这篇文章真正要解决的问题先说痛点。很多前端开发者和游戏爱好者想做 3D 项目但会被两件事劝退一是建模二是搭建复杂的工程环境。加载一个 GLTF 模型需要处理材质、光照、纹理、相机控制哪怕是“Hello World”级别的场景也要引入几个模块并理解它们的生命周期。用单个 HTML 文件做 ASCII 3D 城市绕开了“建模”这个门槛。城市里的每一栋建筑都是由程序生成的方块体素不需要美术资产。视觉效果则依靠字符的密度、颜色和排列来模拟霓虹感。这件事能让你用最低成本跑通一个“3D 渲染”的完整链路。所以这篇文章适合这样几类读者想弄清 3D 投影原理但不想一上来就啃 OpenGL 或 WebGL 的人想给游戏原型加一个“能看的城市背景”的独立开发者做前端可视化偶尔需要生成动态背景或视觉 demo 的工程师单纯觉得字符艺术很酷想动手实现一个同类作品的人。读完这篇文章你会掌握四个核心能力用二维数组生成城市布局、把二维地图扩展成三维体素、用透视投影把三维坐标映射到屏幕、通过深度排序解决前后遮挡问题。更实际的是你会得到一个复制即可运行的 HTML 文件以及配套的排错思路。2. 为什么能用 ASCII 字符构建 3D 城市2.1 字符和像素的边界没有那么明显我们平时看到的屏幕画面本质是像素阵列。分辨率足够的像素组合在一起能形成照片级画面。ASCII 艺术的思路是用字符代替像素把画面抽象成“字符阵列”。单个字符能表达的信息比单个像素更丰富。同样是暗部.、:、*的视觉重量不同同样是亮部#、、%的视觉冲击也不同。所以字符画可以用更低的横向纵向分辨率表达出物体轮廓和明暗层次。在 3D 城市这个场景里我们把 Canvas 划分成一个个格子每个格子放一个字符。这个思路和像素画几乎一样只是绘制单元从“色块”换成了“字符”。2.2 从体素到字符“体素”是 Volume Pixel 的缩写也就是三维空间里的一个点或者一个小方块。Minecraft 的建筑就是最典型的体素应用。ASCII 城市的基本单位不是“一栋楼”而是“一个体素”。每一栋楼都是由纵向排列的体素组成的柱子。当我们把体素投影到屏幕就在对应的屏幕坐标上绘制一个字符。多个字符叠在一起就形成了建筑、街区、天际线。这里有一个关键认知字符并不代表真实的墙面材质它只是“这个位置有东西”的信号。字符选得越密越亮越靠近观众字符选得稀疏、颜色偏暗就会形成远处建筑的雾感。2.3 字符密度如何模拟光照与距离如果你用过图像处理软件应该见过把图片转成“字符画”的工具把图片亮度映射到字符集上亮的地方用或#暗的地方用.或空格。3D 渲染里也可以这样干的。每个体素在屏幕上的字符取决于深度、高度和颜色组合深度越近字符越“满”深度越远字符越“空”建筑顶部通常是城市天际线的视觉锚点可以固定使用高亮度字符墙面颜色则用赛博朋克风格的霓虹色点缀。换句话说字符密度是“深度”的一种可视化表达。为什么要这么做因为如果你对所有距离都使用同一个字符画面会变成一堵密不透风的墙根本无法分辨远近。2.4 一个关键误区这不是字符堆成的模型很多第一次看到这类 demo 的人会问这是“用字符拼出一个 3D 城市”吗拼字确实是字符画的一种但要理解的是这里并不是把字符摆成一个静态的“城市图片”而是每一帧都在重新计算所有体素在屏幕上的位置。每一帧要做的事情是根据相机位置和角度把世界坐标系里的每个体素顶点转换到相机坐标系再做透视投影得到屏幕坐标按深度排序从远到近绘制字符对字符和颜色做映射绘制到 Canvas。所以这是一个实时 3D 渲染管线只不过最后的输出设备不是像素而是字符。3. 环境准备只需要浏览器和文本编辑器这个项目对环境的要求低到几乎没有。操作系统Windows、macOS、Linux 均可浏览器Chrome、Edge、Firefox 等现代浏览器确保支持 ES6 语法和 Canvas 2D API编辑器VS Code、Sublime、Notepad甚至系统自带的文本编辑器都行不需要 Node.js不需要 npm不需要 Three.js不需要 WebGL。因为不涉及外部资源请求直接双击 HTML 文件就能运行。我仍然建议你用一个本地静态服务器跑尤其当你以后想把城市地图数据抽成独立 JS 文件时HTTP 协议会更顺手。项目目录只要这样ascii-city/ └── index.html如果你习惯用 Python可以在这个目录下执行python3 -m http.server 8080然后访问http://localhost:8080。如果你用 VS Code也可以安装 Live Server 插件右键index.html选择 Open with Live Server。本地服务器不是必须的但能避免以后引入外部资源时遇到浏览器安全限制。4. 核心流程拆解从城市地图到字符渲染这一节先不讲完整代码我们按流程把每一步拆开搞清楚为什么需要这一步。4.1 Step 1用二维数组描述城市布局城市首先是一张俯视图。把地图划分成网格每个格子表示一个位置。格子里的数值代表这栋建筑的高度0 表示空地。例如这样0 0 0 0 0 0 0 5 5 0 0 0 0 5 7 7 0 0 0 0 7 8 0 0 0 0 0 0 0 0中心区域有几栋比较高的建筑外围是空地。这个二维数组就是城市的基础数据。这样做的好处是生成城市非常灵活。你可以手动画地图也可以写一个随机函数在指定范围生成建筑群甚至可以把真实城市的经纬度高度数据映射到这个矩阵里。4.2 Step 2把二维地图“长高”成体素有了高度图之后遍历所有坐标只要格子高度大于 0就从 y1 到 yheight 生成一排体素。每个体素至少包含四个信息世界坐标 x、y、z颜色是否是建筑顶部深度值在投影阶段计算。建筑顶部和墙面的视觉处理完全不同。顶部使用高亮度字符通常是#颜色偏青色墙面使用中低亮度字符颜色偏暗紫或品红少量位置用暖黄色模拟窗户灯光。这个阶段会生成几百到几千个体素数量直接决定渲染性能。4.3 Step 3世界坐标到屏幕坐标的透视投影3D 世界的坐标是(x, y, z)而屏幕是二维的(sx, sy)。投影要做的事情就是把前者映射到后者。最简单的模型是“针孔相机”模型。相机在某个位置观察世界远处的物体会变小近处的物体会变大。透视投影的核心公式可以简化成sx 屏幕中心X (相机空间X / 相机空间Z) * 焦距 sy 屏幕中心Y - (相机空间Y / 相机空间Z) * 焦距注意这里除以 Z正是“近大远小”的来源。Z 越小物体离相机越近投影坐标偏移越大。在代码里投影函数通常写成这样function project(wx, wy, wz, camX, camY, camZ, rotY) { const dx wx - camX; const dy wy - camY; const dz wz - camZ; const cosR Math.cos(rotY); const sinR Math.sin(rotY); // 绕 Y 轴旋转先得到相机坐标系中的坐标 const rx dx * cosR - dz * sinR; const ry dy; const rz dx * sinR dz * cosR; // 在相机后方的点不绘制 if (rz 1) return null; const fov 220; const sx W / 2 (rx / rz) * fov; const sy H / 2 - (ry / rz) * fov; return { sx, sy, depth: rz }; }这个函数包含了两个关键操作把世界坐标相对于相机位置做平移再绕 Y 轴旋转随后做透视除法。depth保存了深度值下一节排序要用。4.4 Step 4深度排序决定遮挡关系3D 渲染里有个基础问题哪个物体在前面如果不处理遮挡近处的建筑会被远处的建筑盖住画面就是乱的。解决办法是按深度从远到近绘制。先画远处的点再画近处的点这样近处的点会覆盖远处的点形成正确遮挡。在字符城市里体素数量通常在几千以内每帧直接排序开销可接受list.sort((a, b) b.depth - a.depth);这里depth是相机空间内的 Z 值值越大代表越远。如果城市规模变大比如体素数量达到几万全量排序就会拖慢帧率。优化方向包括空间网格、只排序可见体素、用桶排序代替比较排序等。4.5 Step 5字符映射与霓虹配色所有体素投影到屏幕后绘制时不再使用填充矩形而是使用字符。字符密度的选择逻辑是const depthRatio Math.max(0, 1 - depth / MAX_DEPTH); const idx Math.floor(depthRatio * (CHARS.length - 1)); const ch CHARS[idx];如果MAX_DEPTH是 140那么深度接近 0 时depthRatio接近 1字符接近深度接近 140 时字符接近空格。这样远处自然变淡形成空气透视效果。颜色上赛博朋克风格的核心是夜景、霓虹灯、高饱和对比色。常用色板是黑色背景、青色建筑顶部、品红或琥珀色窗户。颜色不是随便定的它在视觉上承担了“这座城市还在运作”的信息量。5. 完整代码实现单个 HTML 的赛博朋克城市下面给出一个可以直接运行的完整示例。复制到index.html浏览器打开即可。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleASCII Cyberpunk City/title style html, body { margin: 0; padding: 0; background: #07070d; overflow: hidden; } canvas { display: block; } /style /head body canvas idscene/canvas script const canvas document.getElementById(scene); const ctx canvas.getContext(2d); const W 960; const H 640; const CH 14; canvas.width W; canvas.height H; ctx.font CH px Courier New, monospace; ctx.textBaseline top; const CHARS %#*-:. ; const MAX_DEPTH 140; // 地图尺寸 const SIZE 40; const map []; for (let z 0; z SIZE; z) { map.push(new Array(SIZE).fill(0)); } // 随机生成建筑高度 function generateMap() { const buildingCount 28; for (let i 0; i buildingCount; i) { const cx 4 Math.floor(Math.random() * (SIZE - 8)); const cz 4 Math.floor(Math.random() * (SIZE - 8)); const bw 2 Math.floor(Math.random() * 4); const bd 2 Math.floor(Math.random() * 4); const bh 3 Math.floor(Math.random() * 9); for (let dz 0; dz bd; dz) { for (let dx 0; dx bw; dx) { const x cx dx - Math.floor(bw / 2); const z cz dz - Math.floor(bd / 2); if (x 0 || x SIZE || z 0 || z SIZE) continue; if (Math.random() 0.2) { map[z][x] Math.max(map[z][x], bh); } } } } // 地标建筑 map[Math.floor(SIZE / 2)][Math.floor(SIZE / 2)] 20; map[12][15] 16; map[25][28] 18; } generateMap(); // 把高度图扩展为体素列表 const voxels []; for (let z 0; z SIZE; z) { for (let x 0; x SIZE; x) { const h map[z][x]; if (h 0) continue; for (let y 1; y h; y) { const isRoof (y h); let color; if (isRoof) { color #00e5ff; } else { const rnd ((x * 31 z * 17 y * 7) % 10); if (rnd 0) color #ff2ec4; else if (rnd 1) color #ffaa00; else color #3a3a5c; } voxels.push({ x: x - SIZE / 2, y: y, z: z - SIZE / 2, color: color, isRoof: isRoof }); } } } // 三维坐标投影到屏幕 function project(wx, wy, wz, camX, camY, camZ, rotY) { const dx wx - camX; const dy wy - camY; const dz wz - camZ; const cosR Math.cos(rotY); const sinR Math.sin(rotY); const rx dx * cosR - dz * sinR; const ry dy; const rz dx * sinR dz * cosR; if (rz 1) return null; const fov 220; const sx W / 2 (rx / rz) * fov; const sy H / 2 - (ry / rz) * fov; return { sx, sy, depth: rz }; } // 预渲染字符到离屏 Canvas避免每帧重复 fillText const glyphCache {}; function getGlyph(ch, color) { const key ch | color; if (glyphCache[key]) return glyphCache[key]; const c document.createElement(canvas); c.width CH; c.height CH; const g c.getContext(2d); g.font CH px Courier New, monospace; g.textBaseline top; g.fillStyle color; g.fillText(ch, 0, 0); glyphCache[key] c; return c; } // 渲染循环 function frame(ts) { const elapsed ts / 1000; const rotY elapsed * 0.3; const camDist 52; const camX Math.cos(rotY) * camDist; const camZ Math.sin(rotY) * camDist; const camY 24; // 清空背景 ctx.fillStyle #060609; ctx.fillRect(0, 0, W, H); // 绘制地面网格 ctx.fillStyle #1b1b2a; const gridStep 2; for (let gz 0; gz SIZE; gz gridStep) { for (let gx 0; gx SIZE; gx gridStep) { const p project(gx - SIZE / 2, 0, gz - SIZE / 2, camX, camY, camZ, rotY); if (!p || p.depth MAX_DEPTH) continue; if (p.sx -CH || p.sx W || p.sy -CH || p.sy H) continue; ctx.fillText(., p.sx, p.sy); } } // 投影所有体素 const list []; for (const v of voxels) { const p project(v.x, v.y, v.z, camX, camY, camZ, rotY); if (!p || p.depth MAX_DEPTH) continue; if (p.sx -CH || p.sx W CH || p.sy -CH || p.sy H CH) continue; list.push({ p, v }); } // 深度排序远处的先画 list.sort((a, b) b.p.depth - a.p.depth); // 绘制字符 for (const item of list) { const { p, v } item; const depthRatio Math.max(0, 1 - p.depth / MAX_DEPTH); let ch; if (v.isRoof) { ch #; } else { const idx Math.floor(depthRatio * (CHARS.length - 1)); ch CHARS[idx]; } let color v.color; // 楼层窗户闪烁效果 if (!v.isRoof) { const flicker Math.sin(elapsed * 2 v.x * 3.7 v.y * 1.3 v.z * 2.9); if (flicker 0.85 depthRatio 0.3) { color #ffe26a; ch o; } } ctx.drawImage(getGlyph(ch, color), p.sx, p.sy); } requestAnimationFrame(frame); } requestAnimationFrame(frame); /script /body /html5.1 代码结构说明整个文件分成五部分地图生成generateMap()随机生成建筑高度并预设几个地标建筑体素构建遍历高度图把每栋建筑展开成纵向排列的体素投影函数project()负责世界坐标到屏幕坐标的转换字符缓存getGlyph()把字符和颜色的组合预渲染成小 Canvas避免每帧调用fillText渲染循环更新相机角度、投影、排序、逐字符绘制。其中字符缓存是很容易忽略却很重要的优化。fillText的开销远远高于drawImage如果每个体素每帧都直接调用fillText字符一多就会出现明显的掉帧。预渲染后渲染阶段只剩内存拷贝速度会快一个量级。6. 运行结果与效果验证6.1 运行步骤把上面的代码保存为index.html双击打开或者在项目目录执行python3 -m http.server 8080然后访问http://localhost:8080。6.2 预期视觉效果页面打开后你会看到黑色背景上是密密麻麻的字符网格城市中心有一栋明显更高的青色顶部建筑建筑外墙以暗紫色为主偶尔有品红和琥珀色字符点缀部分窗户会随着时间闪烁成暖黄色整个城市绕着中心缓慢旋转整体有“近大远小”的透视感。城市旋转的核心是相机绕 Y 轴转动而不是城市本身在动。这是 3D 实时渲染里很常用的一个技巧保持世界不动移动相机画面就产生了运动。6.3 判断运行成功的标准运行成功的标志有四个画面上能看到透视线远处的字符更稀疏近处的字符更密集建筑顶部字符始终是#且颜色为青色一栋建筑不会被另一栋建筑错误地盖住近处大楼一定绘制在远处大楼前面页面没有报错打开开发者工具 Console 面板没有红色错误信息。如果画面全黑优先看控制台是否有Uncaught TypeError之类的报错。如果只是没有城市检查generateMap()是否被调用。6.4 性能观察打开开发者工具切到 Performance 面板录制几秒动画可以看到主线程上的绘制任务。从实现原理看体素数量是性能的关键变量。当前地图大小是 40×40随机生成 28 栋建筑体素总量大致在 2000 到 3000 之间。这个数量级在普通笔记本上跑 60 帧问题不大。如果建筑数量或高度大幅增加性能会快速下降届时要考虑优化。7. 常见问题与排查思路问题现象可能原因排查方式解决方案页面全黑Canvas 尺寸为 0 或 JS 报错打开控制台查看报错信息确认 Canvas 有宽高修复语法错误场景为空但页面不黑generateMap()未调用检查代码顺序确保在体素构建前调用地图生成建筑顺序混乱深度排序缺失或排序方向错误检查list.sort的返回值改为b.p.depth - a.p.depth从远到近绘制画面卡顿明显体素数量过多或每帧fillText查看体素总量和渲染调用减少建筑数量使用字符离屏缓存字符太小看不清画布逻辑分辨率与字符尺寸不匹配调整CH常量增大CH例如从 14 改为 18旋转时城市穿帮跳变相机后方点未被剔除检查project()内的rz 1判断必须过滤相机后方体素远处建筑过亮MAX_DEPTH设置过大对比深度阈值减小MAX_DEPTH让远距离更快衰减第一优先级永远是控制台报错。如果画面异常先按 F12 打开开发者工具看 Console 面板的红色信息。绝大多数“黑屏”问题都是语法错误、变量未定义或者 Canvas 没取到导致的。8. 最佳实践与工程建议8.1 把常量提取出来SIZE、CH、MAX_DEPTH、fov这类参数不要散落在代码各处。建议统一放在文件顶部形成一份“配置区”。当你想要一个更密、更亮、视角更广的城市时调整配置区比逐行改代码效率高得多。8.2 控制体素数量40×40 地图乘以 10 层建筑视觉上已经足够丰富。不要为了细节无限增加体素。一个很现实的判断标准是在低配电脑上主线程绘制开销至少要能稳定超过 30 帧每秒。如果城市规模必须大采用分区渲染或者视锥剔除。体素数量超过一万时每帧遍历所以体素并且排序就会开始吃紧。8.3 字符缓存是必备优化代码里的glyphCache是这类字符渲染项目的核心优化不是锦上添花。它的思路是相同字符和颜色的组合只绘制一次缓存成离屏 Canvas渲染循环里只做drawImage。如果以后你想增加更丰富的字符映射比如方向线、霓虹边框、发光效果也优先扩展缓存策略不要让绘制循环去做昂贵操作。8.4 动画时间使用时间戳requestAnimationFrame回调参数ts是毫秒级时间戳。用它计算动画时间而不是每帧简单地加固定角度。这样即使设备刷新率变化城市旋转速度也保持一致。8.5 数据驱动城市生成城市布局数据不要写死在渲染代码里。map二维数组是很好的数据边界你既可以用随机函数生成也可以从 JSON、服务端接口甚至真实地图数据导入。保持“数据生成”和“渲染”分离后续扩展会轻松很多。8.6 注意生产环境的资源边界如果这个 demo 要放到公司官网或真实产品里当背景建议给页面加上prefers-reduced-motion媒体查询尊重用户减少动态效果的偏好。另外渲染循环需要有销毁机制避免页面组件卸载后继续占用 CPU。9. 后续学习方向跑通这个 ASCII 城市之后下一步可以做很多有意思的扩展。第一个方向是视觉增强。给城市加一条流动的车流光带或者让部分屋顶变成动态霓虹灯牌只需要在体素数据里增加“动态对象”类型。第二个方向是交互。把鼠标移动映射到相机旋转角度就可以从“自动旋转的城市”变成“用户可控的 3D 场景”。第三个方向是数据可视化。把真实的城市建筑高度数据导入map再做投影你就拥有了一台“字符版城市沙盘”。如果继续向传统 3D 渲染方向走建议把这套流程迁移到 Three.js。你会发现这里手写的投影、旋转、深度排序正是 Three.js 里相机、矩阵、深度缓冲的简化版。理解了这个简化版再看 WebGL 的着色器概念会顺畅很多。真正的收获在于你不再把 3D 当作一个黑盒而是知道一帧画面里的每个字符是怎么从三维坐标变成屏幕坐标的。先跑通再创造这是学习图形学最好的路径。
返回列表