1KB极限挑战:用JavaScript和Canvas实现3D图形
1. 1KB的极限从Demoscene到JS1K的图形挑战1.1 为什么“1KB”是图形开发的试金石1KB能做什么在大多数人眼里1024字节连一张高清壁纸的零头都不到甚至放不下一个CtrlC、CtrlV的往返。但偏偏有一群图形爱好者和极客把“用1KB代码做出能跑的3D画面”当作一场数字艺术的极限运动。这个传统最早可以追溯到Demoscene文化——那是上世纪80年代程序员们用几百KB甚至几十KB的代码在Amiga、Atari等老式计算机上生成音乐和动态画面比拼的是“在资源极限内能把硬件榨出多少视觉冲击力”。后来Web时代到来JS1K、JS13k等比赛继承了这种精神把限制从几十KB压缩到1KB目标不变证明复杂视觉不一定需要庞大代码关键在于数学直觉和编码功夫。你觉得1KB很小我们可以算一笔账一个中文字符在UTF-8下占3字节1KB大约能写340个汉字。而一段JavaScript代码用CRLF换行就要去掉2个字节代码里多写一个空格就少一分机会。所以在JS1K社区里“1KB”不是约等于而是硬性的1024字节多一个字符都不行。你不仅要写功能还得写“能被压进这个框”的功能。1.2 1KB能实现什么样的3D效果很多人会直觉认为1KB的3D游戏大概就是画个线框立方体转一转。但如果你真的逛过JS1K的作品页会发现里面有不少让人惊艳的东西一个完整的3D地形飞行器视角随着鼠标移动地面是渐变色的山丘有旋转的3D分形类似于Mandelbulb的极简版本甚至有带碰撞检测、计分和音效的小游戏。这个“奇迹”是怎么发生的答案是放弃“常规做法”在数学和图形API之间走极端。以Canvas 2D为例它本身没有3D能力但我们可以用transform方法设置一个仿射变换矩阵再配合lineTo、fillRect这些基础绘图命令手动完成顶点投影。1KB代码中我们既不需要游戏引擎也不需要复杂的库甚至连requestAnimationFrame的兼容写法都要压缩成几个字符。这就像是把一张桌子四条腿全拆掉然后只用一根扁担和几根绳子愣是把桌面撑了起来。我见过不少第一次接触JS1K的开发者上来就想用Three.js结果光引入库就占了120KB然后他们就放弃了。这其实误解了1KB挑战的本质极简主义的图形奇迹靠的不是堆砌功能而是用数学和编码技巧去“翻译”视觉需求让每一步计算都同时干好几件事。接下来我会从渲染原理、实战案例到压缩技巧把这条极限之路完整拆开。2. 在字节夹缝中构建3D空间核心数学与渲染思路2.1 没有引擎就用线代矩阵和投影的迷你实现任何3D画面第一步都是把三维坐标变成屏幕上的二维坐标。在完整引擎里这需要模型矩阵、视图矩阵、投影矩阵有的还要逆矩阵、法线矩阵但在1KB的预算下我们只能用手写的简化公式。最常用的方案是透视投影。假设相机在Z轴正方向观察者看向原点屏幕在Z1的平面上那么一个3D点(x,y,z)投影到屏幕的坐标大约是s f / z; sx x * s cx; sy -y * s cy;其中f是焦距控制视野大小cx、cy是屏幕中心。这段代码压榨后可以短到10个字符以内。那么旋转呢如果你不想手写矩阵乘法可以利用Canvas自带的ctx.setTransform(a,b,c,d,e,f)——它接受六个参数组成2D变换矩阵。如果你把旋转矩阵嵌入这六个参数中那么你画的所有点都会自动旋转你只需要把3D投影后的坐标画出来即可。这是一个非常经典的“白嫖”技巧让Canvas帮你完成90%的数学你只负责投影。对我来说最实用的做法是把三个轴的旋转拆成两次setTransform调用——一次旋转x和y另一次旋转y和z。每转一次矩阵参数变化很小但组合起来就能实现任意旋转。这比用完整的三维矩阵乘法要省几十个字符代价是旋转会有轻微的非正交误差但在1KB的游戏画面里完全看不出来。2.2 点、线、面极简几何体与着色技巧有了投影和旋转接下来就是画什么。在1KB里最省钱的是“点阵”和“线框”。比如一个立方体我们只需要8个顶点坐标然后定义12条边用顶点的索引对。在代码里可以用一个数字数组存顶点比如v[1,1,-1, ...]然后用索引数组存连线e[0,1,1,3,...]。投影后画点用fillRect(x,y,1,1)画线用moveTo和lineTo。这些操作非常简单但组合起来就有立体感。如果你想超越线框让画面有“面”的感觉可以尝试给每个面填充不同的颜色并根据面的法线方向调整亮度。法线可以用叉积计算但在1KB预算里更简单的是用“深度排序半透明填充”。比如投影后计算每个面的平均Z值按从远到近的顺序画多边形每个面用不同的透明度。这样即便没有光照计算画面也会因为层次感显得很立体。一个有趣的技巧是“伪着色”直接给每个顶点赋予一个灰度值然后用一个很短的函数根据顶点的Z坐标或投影后的Y坐标来返回颜色。这样画面会产生渐变看起来像有光打在物体上。我曾经在一个1KB作品中通过fillStylehsl((z*90),50%,50%)这种方式让同一个物体上的不同顶点呈现从红到蓝的渐变效果出乎意料地好。关键是要记住1KB里的“好看”靠的是对比和渐变不是高精度的物理光照。2.3 程序化纹理与伪光影用数学“画”出材质说完几何来到颜色。如果你想在1KB内存入一张纹理贴图那纯粹是白日梦——一个64×64的RGBA纹理就要16384字节。但程序化纹理可以做到用数学函数在运行时生成图案函数本身只有十几个字节。比如经典的“棋盘格”纹理可以用(x16)^(z16)这种位运算来生成0或1分别对应两种颜色。更高级的玩法是“体感纹理”。比如用正弦函数sin(x*0.3)*cos(z*0.3)生成起伏的灰度值再映射成色相就能得到流动的波面效果。我见过有人用一条10字节的公式就画出了一个看起来像地表植被覆盖的3D场景——其实那只是sin(x)*cos(z)0和0两种颜色但混合了地形起伏和投影之后视觉复杂度瞬间提升。伪光影方面一个被反复使用的技巧是“高度着色法”。如果你渲染的是一个高度图比如地形可以在投影后根据点的y坐标或原始高度值来决定颜色。高处更亮低处更暗仅这一句判断就能让平面地形看起来像有阳光照射的山峦。如果你把光源方向绑定到玩家的移动方向上甚至能模拟“日落时阴影拉长”的错觉。所有这些加起来不超过30个字符。3. 实战拆解用1024字节写一个可玩的3D滚球游戏3.1 从零设计游戏逻辑飞行迷宫还是滚球为了让这套思路更具体我选了一个简单但能“玩起来”的3D滚球游戏。游戏规则一个球在由若干立柱构成的3D网格中滚动玩家用方向键让它前进、转向目标是吃到金币一个闪烁的点并避开红色障碍。听起来朴素但足够展示完整的游戏循环、输入处理和3D渲染。为什么选滚球而不选飞行器因为滚球场景的几何结构固定只需要画地面和柱子投影后画面就能形成明确的透视。而且滚球的重力感可以用“球的位置沿Y轴随高度变化”来模拟——玩家按上键球向前移动一次同时根据地形高度更新Y坐标这样就有一种上下坡的感觉。整个逻辑的核心数据只是一个坐标(px,pz)外加一个角度pa表示球的方向。游戏状态也压缩到极致用一个数组存储障碍物的位置用一个变量score计数。没有精灵图没有音频整个游戏的关键就是“投影→画线→画球→碰撞检测”。3.2 代码骨架与逐段解析附关键代码我把这个项目的核心代码写成了尽量接近1KB风格的版本。这里只展示核心片段完整压缩版在JS1K比赛中常见的做法是先把所有变量名改成单字符再把函数合并但为了让你看清逻辑我先保留可读性。基础框架// 设置画布 c.widthwinnerWidth; c.heighthinnerHeight; // 定义相机和投影 xy0; z10; pa0; // 障碍物数组 o[]; for(i0;i20;i)o[i][Math.random()*40-20,Math.random()*40-20]; // 主循环 function draw(){ ctx.clearRect(0,0,w,h); // 更新根据按键改变pa和位置 if(k[37])pa-0.05; if(k[39])pa0.05; if(k[38]){xMath.sin(pa)*0.5; zMath.cos(pa)*0.5;} if(k[40]){x-Math.sin(pa)*0.5; z-Math.cos(pa)*0.5;} // 绘制地面一堆格子线 for(i-10;i10;i){ // 投影一条X线段 projLine(i,-10,i,10); // 投影一条Z线段 projLine(-10,i,10,i); } // 画障碍物红色小球 for(i0;i20;i){ var pproj(o[i][0],0,o[i][1]); if(p[2]0) ctx.fillRect(p[0]-5,p[1]-5,10,10); // 简单碰撞 if(Math.abs(x-o[i][0])1 Math.abs(z-o[i][1])1) score; } // 画球 var bproj(x,0,z); ctx.fillStyle#0f0; ctx.fillRect(b[0]-8,b[1]-8,16,16); // 画方向指示 ... requestAnimationFrame(draw); }这里proj(pX,pY,pZ)返回一个三维数组[screenX, screenY, depth]实现方式如下function proj(x,y,z){ var dxx-cx, dyy-cy, dzz-cz; var s400/dz; return [cxdx*s, cy-dy*s, dz]; }为了节省字节常见的做法是“一个函数多用”把proj同时当作投影和碰撞检测器因为返回的depth正好可以用于判断物体是否在相机前方。代码里没有真正的3D旋转因为滚球只需要在水平面上移动相机固定朝一个方向。这种情况下旋转矩阵可以完全省略投影公式直接基于玩家坐标和世界坐标的差值计算这比做真正的3D旋转要快得多也省得多。3.3 键盘交互与游戏循环用最少的代码撑起“可玩性”1KB游戏很容易陷入“看起来是动画但实际上没有交互”的窘境。为了避免这一点你的键盘处理必须极快。浏览器里监听键盘可以用onkeydown和onkeyup也可以用一个全局数组k[]在事件里设置k[e.keyCode]1。这样主循环就可以直接判断k[38]之类。注意一个坑e.keyCode在有些浏览器中已被废弃但为了1KB大家通常会直接用e.keyCode||e.which这样的老式写法。在移动端上触摸事件可以映射成虚拟按键但1KB的地方实在不够处理触摸所以多数JS1K作品还是面向PC键盘。我们还需要“分数”显示。在1KB里最省的方法是直接在画布上把分数画出来把数值转成字符串然后ctx.fillText(score, 10, 20)。但有些浏览器在加载字体时要消耗字节所以你也可以用document.titlescore来显示——虽然不优雅但零字节开销。这个技巧在压缩比赛中非常常见。为了让“可玩性”更强我还加入了一个简单的“目标点”金币。它会每隔一段时间闪烁玩家碰到它分数加一。代码中用Math.sin(t*0.1)0来控制闪烁其中t是主循环的次数不断累加。这样既让画面有节奏感又给玩家明确的游戏目标。4. 压缩与混淆让代码瘦进1KB的关键操作4.1 变量替换、函数合并与算术表达式重构写完了功能代码下一步就是“瘦身”。这是1KB挑战中最有成就感也最伤脑筋的环节。我一般按下面几步操作每步都能省下几十字节。第一步把所有局部变量和全局变量改成单字母。比如width改成wheight改成hcontext改成cscore改成s。这看似简单但要注意不能把不同语义的变量弄混。第二步把函数体内部重复使用的表达式提取出来。比如投影公式中反复出现的w/2和h/2可以放在初始化时算好存为cx和cy。第三步用逻辑运算符替代条件语句。比如if(a)b(); else c();可写成a?b():c()但三元运算符里可能更短更进一步if(k[38]!p)x...可以改成x(k[38]!p)*0.5——把布尔值乘进数值里。这是一个非常实用的压缩技巧很多新手想不到。算术表达式重构也能省很多字节。例如Math.sin(pa)*0.5可以写Math.sin(pa)/2前提是除法的优先级和乘法一样。再比如如果你想同时累加两个变量可以用xd*Math.sin(pa), zd*Math.cos(pa)这种逗号表达式比用分号短一个字符。逗号表达式在压缩中几乎是神技它允许你在一条语句里塞进多个赋值且不花括号。4.2 利用Canvas内置API“白嫖”图形功能Canvas 2D几乎是为极简图形量身定做的。为什么这么说因为它内置了旋转、平移、缩放、贝塞尔曲线、线性渐变、径向渐变、阴影等能力。在1KB里你不需要实现任何特效算法只需要调用API。但注意这些API的参数也要花钱所以你得想想能不能用更短的参数达到类似效果。举个例子如果你要画一个马赛克风格的球体createRadialGradient需要六个参数起止圆心坐标和半径还要addColorStop至少20个字节。但如果你只是画一个普通圆并用一个渐变模拟光影可以用ctx.arc(x,y,r,0,7)——注意这里的7不是2π精确值但在浏览器中会自动归一化到2π你就能省下Math.PI*2十几个字节。类似的技巧还有把fillRect当作画点工具参数(x,y,1,1)把strokeRect当作画空心的方块。更神奇的“白嫖”是用ctx.globalAlpha来做场景淡入淡出而不是手动管理每个物体的透明度。用这个属性你可以在主循环里设置globalAlpha0.3然后所有后续绘制都是半透明的从而得到“透明实体”的效果不需要在每句fillStyle前写rgba(...)。当然使用时要注意在下一帧之前重置回1.0否则整个画面会越叠越花。4.3 压缩工具链与校验别让代码“压坏”了手写压缩是第一步工具压缩是第二步。我常用的工具是UglifyJS和Terser但JS1K社区里更流行的是自己写一个“谐音压缩”脚本因为通用压缩器不会做“把Math改成M并全局替换”这种高代价操作。不过手工替换容易出错——我建议分两步先用Terser做语法级压缩去掉空格、换行、进行一定的变量重命名。再手动把高频全局API替换成短字符比如Math换成Mctx换成cfillRect换成f如果你把ctx.fillRect赋给一个变量。这里有个技巧with语句在严格模式下不可用但有些1KB代码会钻空子使用with(ctx)把接下来的所有方法去掉前缀。这可以省大量字节但浏览器兼容性有风险很多压缩器也不支持。我个人的建议是在非严格模式下with有时候能压出奇迹但如果你要发布到正式页面还是别冒这个险。校验过程也很重要。你可以写一个脚本检查最终代码的length是否小于等于1024并且在压缩后立即用new Function(code)执行一遍看是否有语法错误。甚至可以在浏览器控制台里手动跑一下确认游戏能正常循环。我遇到过很多次压缩后“看起来没语法错误但画面全黑”的情况原因通常是压缩时丢掉了分号或自动插入分号ASI改变了解析结果。所以最后一步我会把压缩版本和可读版本分别运行并对比控制台输出的错误信息一步步定位。5. 踩坑实录调试1KB代码的日常与终极武器5.1 压缩后代码报错的艰难定位压缩代码最让人抓狂的就是报错找不到源头。想象一下你写了500行可读代码压缩后变成了一行长长的字符其中某个地方缺少一个分号导致整个函数执行到一半失效。你打开控制台错误信息告诉你“第1行第65535列出现未定义的标识符”但那一行全挤在一起根本看不清。我的办法是“分步回退”我把压缩器的选项调成“只去空格不断行”先得到一个可读性略好但仍保留换行的版本。这样虽然文件变大了但至少能看清每一行。然后根据语法高亮找可疑点。比如如果你看到某个变量名变色异常很可能是因为它被定义成了关键字。如果你用了with那么里面的变量全带有不确定性高亮也很怪。还有一个小技巧用“二分注释法”压缩版本中插入临时的throw 1然后看错误是否出现。每注释掉一段功能就在压缩前先屏蔽那个函数的调用看看画面是否恢复。这个过程很费时间但当你最终定位到是var aparseInt(...)少了一个右括号时你会感慨“早就该用可读版本”的。所以我现在的流程是先写可读版本并确保它完全正常工作再压缩压缩后只测试功能若出现问题再基于可读版本修改逻辑而不是直接改压缩代码。5.2 浏览器兼容性陷阱救命的分号与奇怪的渲染差异同一个压缩代码可能在Chrome上运行完美Firefox上画面闪烁Edge上直接黑屏。我和同行交流后总结出几个高频坑requestAnimationFrame在不同浏览器的兼容写法不一样早期版本需要用webkitRequestAnimationFrame。通常我们用requestAnimationFrame||setTimeout的方式来兼容但这会多出不少字节。目前主流浏览器都支持标准API但如果你要参加JS1K并希望更多人看到最好加一段短的兼容代码。Math.imul在部分老版本浏览器上性能极差如果你用它做乘法运算可能会拖慢帧率。用普通*反而更快。颜色解析差异同一句fillStyle#0f0在不同浏览器上可能略有偏差但这不是大问题。真正的坑是globalAlpha在累加时受“像素整数化”影响会产生不均匀的色带。如果你发现画面在某一层总有些奇怪的接缝试着把半透明绘制顺序调换一下。最“救命”的是分号。压缩工具通常会尽力合并语句但如果你依赖ASI有些浏览器会理解成不同的逻辑。比如a1 (b2)在Chrome中这会被解释成a1(b2)吗不它会尝试把a1当作函数调用实际上这里会产生ReferenceError因为1不是函数。所以为了避免这些神游我自己写压缩时会在涉及跨行操作的地方显式加分号虽然这会多几个字节但比调试一整天好。5.3 从“能显示”到“画面炫酷”后期调优心得当你终于把功能代码压进1KB并且能在浏览器里跑起来后接下来的工作才是真正的“图形打磨”。很多初学者的1KB作品能转但画面就是丑线框稀稀拉拉颜色呆板缺乏层次感。这里我有几个实践经验可以分享。第一大胆使用“错误”但有效的数学。比如为了让球体带有光泽感我在它的表面用fillRect画了几条线颜色值来自sin(x*y*9)。严格来说这不是正确的高光计算但产生的视觉效果非常接近珍珠的光泽。你用不到等30个字符就能获得“看起来像用了法线贴图”的效果。不要怕“错误”的公式只要视觉对它就是对的。第二善用后处理。1KB主循环里通常会每帧画很多小东西如果你想让画面整体有“胶片感”或“霓虹感”可以在主循环末尾画一个半透明的全屏矩形颜色是暗蓝色透明度设为0.1。这样形成“拖影”运动物体会有动态模糊的感觉。这个操作两行就能完成但画面质感瞬间提升一个档次。我见过有作品用这个拖影效果直接替代了“三维场景分层”整个场景像在水下一样迷幻。第三别忽略背景。很多1KB代码只画物体背景留白显得很空。你可以用一个循环画一组从近到远的点阵星星利用投影逐渐变小、变暗这样不仅填充了背景还强化了纵深。这段代码大约20字节。或者用一条线性渐变填充整个画布模仿天空到地面的过渡。代码量不大但效果立竿见影。第四性能优先于画质。1KB代码虽然短但如果每帧计算量太大照样会掉帧。比如你在投影计算里用Math.sin和Math.cos很慢。可以预计算一张sin查找表用整数索引去取速度会快很多。在1KB内你完全可以放一个长度为32的数组保存各角度的正弦值需要时直接查表。虽然看起来粗糙但游戏体验更流畅。最后关于调试工具我最常用的是console.log配合计数器。因为空间小无法写复杂的日志但可以每隔几十帧输出一次状态。比如在主循环里加一句t%30||console.log(x,z,score)这样你能在控制台看到玩家坐标和分数变化。这个调试代码在最终压缩时会被删掉但开发过程中非常有帮助。当所有优化都做完你的代码会像一块精心雕刻的石头每一字节都有存在的理由每一行都有它的任务。这个过程的乐趣不亚于玩一个3A大作的图形品质打磨。也正是这种“极限中抠细节”的体验让1KB 3D游戏开发成为图形程序员最好的思维训练场——它逼着你理解什么是真正重要的数学、什么是可舍弃的复杂、什么是让画面“活”起来的灵魂。