Fake 3D on a flat canvas在平面画布上伪造 3D
None of these demos has a 3D engine. The tunnel is circles at decreasing scale, the starfield is dots moving outward, the cube is six CSS planes. What they share is a single idea — divide by depth — and a discipline about draw order.这几个示例里没有一个 3D 引擎。隧道是逐渐缩小的圆环,星空是往外飞的圆点,立方体是六个 CSS 平面。它们的共同点是一个想法——除以深度——和一条关于绘制顺序的纪律。
Projection is one division
Give every object a z — distance from the viewer. To turn a 3D point into a screen point, scale it by a factor that shrinks as z grows:
That is the whole of perspective projection. f is the focal length: a bigger value flattens the scene (telephoto), a smaller value exaggerates it (wide angle). At z = 0 the scale is 1, so objects at the viewer's position draw at their real size; at z = f they are half size.
The consequence that trips people up is that screen position also depends on z. You do not just shrink the object, you pull it toward the centre of the canvas:
Forgetting the second line produces objects that shrink in place, which looks like a zoom-out rather than something receding. The horizon trick is that everything converges on the vanishing point (cx, cy), and the rate of convergence is the same f/(f+z).
const s = f / (f + z); // perspective scale const sx = x * s + cx; // screen position const sy = y * s + cy;
cx, cy is the vanishing point — usually the centre of the canvas, but moving it is how you get an off-axis shot.
Depth sorting: why the far thing has to be drawn first
Canvas 2D has no depth buffer. Whatever you draw last sits on top, regardless of its z. So if you are drawing more than one depth layer, the array order is part of the rendering, not an implementation detail:
The tunnel gets away without sorting because the rings are generated in order — far ring first, near ring last — every frame. That works because the geometry is a stack. The moment your objects can pass each other, you need an explicit sort, and sorting every frame is where naive 3D in 2D stops being cheap.
For a starfield the sort barely matters: the stars are small dots of uniform colour, so overlap is invisible. That is not luck, it is a design choice that lets you skip the sort. When you find yourself wanting translucent objects at different depths, you will have to sort — and at that point, ask whether the effect really needs to be in Canvas at all.
objects.sort((a, b) => b.z - a.z); // far first, near last for (const o of objects) draw(o);
The tunnel: never let z reach zero
A tunnel is a series of rings at fixed z intervals. Each frame, z decreases — they come toward you. When a ring passes the camera, it is not deleted; it is pushed back to the far end:
The z <= 0 check resets rather than removes, which keeps a constant number of rings alive and produces the infinite illusion. Delete instead and you get a burst followed by emptiness.
Note the scale line. Because s = f/(f+z) approaches infinity as z approaches -f, a ring that gets too close will be drawn astronomically large. Clamping the reset point matters: reset while the ring is still comfortably far, and you never see the frame where it explodes.
The vivid colour comes from picking the hue from z itself — hue = (z * 4) % 360 — rather than storing it. It means the tunnel's colour cycle is a byproduct of depth, and the colour pattern moves with the rings, which is what makes it read as motion rather than as a gradient animation.
rings.forEach(r => {
r.z -= speed;
if (r.z <= 0) r.z = FAR; // recycle, don't delete
const s = f / (f + r.z);
ctx.beginPath();
ctx.arc(cx, cy, r.r * s, 0, 7);
ctx.lineWidth = Math.max(0.5, 8 * s);
ctx.strokeStyle = 'hsl(' + (r.z * 4 % 360) + ',85%,60%)';
ctx.stroke();
});
The laser grid: a horizon you draw, not a horizon you compute
The laser grid is the classic synthwave floor, and it is not real 3D. Horizontal lines are produced by stepping z and projecting, which makes them compress toward the horizon automatically — that compression is the horizon, no formula for it:
Vertical lines are all drawn from the vanishing point outward, so they converge for free. The entire grid is a handful of strokes and one projection function.
The subtle part is that horizontal line spacing must be uniform in z, never in screen y. Space them evenly on screen and you get a fence, not a floor: the near lines look as dense as the far ones and the depth cue disappears entirely. This one mistake accounts for most failed attempts at this effect.
// horizontal lines: step in z, project
for (let z = 0; z < 40; z++) {
const s = f / (f + z * 20);
const y = horizon + 200 * s;
line(0, y, W, y);
}
// vertical lines: from the vanishing point outward
for (let i = -10; i <= 10; i++) {
line(cx, horizon, cx + i * 120, H);
}
The DNA helix: use parametric equations instead of z
A strand of DNA is two sine waves offset by π, which means you do not need a 3D model at all — you need one angle and two phases:
The depth cue comes from the same projection rule, but z here is derived from the angle: z = sin(angle). When a base pair is at the front of the helix it draws large; at the back it draws small. Scale the radius and the dot size by that, and the twist reads as rotation.
The one thing this needs that the tunnel does not: you must draw back-to-front for the two strands to cross correctly, which means sorting the base pairs by z each frame — the same painter's algorithm, now with a real cost. It is affordable because there are only a few dozen pairs.
The meteors demo is a different shortcut to the same feeling: a meteor is a short line whose tail is just the same point sampled at a slightly earlier position. No gravity in 3D, no trail buffer — one velocity, one offset, and the illusion holds because the eye reads the streak as motion.
for (let i = 0; i < pairs; i++) {
const a = i * 0.35 + t;
const y = i * 9 - offset;
const z1 = Math.sin(a), z2 = Math.sin(a + Math.PI);
const s1 = f / (f + z1 * 80), s2 = f / (f + z2 * 80);
dot(cx + Math.cos(a) * 60 * s1, y, 4 * s1); // strand A
dot(cx + Math.cos(a + Math.PI) * 60 * s2, y, 4 * s2); // strand B
if (z1 > z2) link(y, s1, s2); // rung, sorted by depth
}
When CSS 3D is the better answer
The cube uses no Canvas. It is six divs with transform: rotateX/Y/Z inside a transform-style: preserve-3d parent, animated with a CSS keyframe.
Why is this better than projecting eight vertices by hand? Because the browser does the projection, the depth sorting, and the compositing on the GPU, and the animation runs off the main thread. A CSS-rotated cube keeps spinning while your JS is busy; a Canvas one does not.
The limits are equally clear. CSS 3D gives you planes, and only planes — no particles, no per-pixel effects, no procedural geometry. Backface culling is a backface-visibility one-liner, but you cannot easily do objects that intersect. And you cannot read pixels back out.
The rule of thumb: if the thing is made of rectangles and its faces never intersect, CSS 3D will be smoother for less code. The moment you want a thousand of them, or want them to deform, go back to Canvas and accept that you own the projection and the sort.
.scene { perspective: 800px; }
.cube { transform-style: preserve-3d; animation: spin 12s linear infinite; }
.face { position: absolute; backface-visibility: hidden; }
@keyframes spin {
to { transform: rotateX(360deg) rotateY(360deg); }
}
Where to take this
Perspective is one division, and depth ordering is the thing that actually sells it. If your fake 3D looks flat, check the draw order before you touch the maths — and if it looks like it is zooming instead of receding, you forgot to pull the point toward the vanishing point.
投影就是一次除法
给每个物体一个 z(离观察者的距离)。要把 3D 点变成屏幕点,就乘一个随 z 增大而变小的系数:
透视投影总共就这些。f 是焦距:值越大画面越平(长焦),越小越夸张(广角)。z = 0 时系数是 1,物体按原尺寸画在观察者所在的位置;z = f 时缩小一半。
最容易踩的坑是:屏幕位置也依赖 z。你不是只把物体缩小,你还得把它往画布中心拉:
漏掉第二行,物体会在原地缩小,看起来像「拉远」,而不是「远去」。地平线的秘密就在于所有东西都收敛到消失点 (cx, cy),而收敛速率就是同一个 f/(f+z)。
const s = f / (f + z); // 透视系数 const sx = x * s + cx; // 屏幕坐标 const sy = y * s + cy;
cx、cy 是消失点,通常取画布中心;挪动它就能得到偏轴构图。
深度排序:为什么远的东西必须先画
Canvas 2D 没有深度缓冲。最后画的永远在最上面,跟它的 z 无关。所以只要你不止画一层深度,数组顺序就是渲染逻辑的一部分,不是实现细节:
隧道之所以不用排序,是因为每一帧的圆环都是按顺序生成的——先画最远,最后画最近。这能成立,是因为它的几何是一个栈结构。一旦你的物体能互相穿过,就必须显式排序,而每帧排序正是「2D 画 3D」开始变贵的地方。
星空几乎不用管排序:星星都是同色的小点,重叠了也看不出来。这不是运气,是一个让你能省掉排序的设计选择。等你想要不同深度的半透明物体时,就不得不排序了——到那一步,先问一句:这个效果真的非得用 Canvas 吗。
objects.sort((a, b) => b.z - a.z); // 远的在前,近的在后 for (const o of objects) draw(o);
隧道:永远别让 z 走到零
隧道的本质是一串等比间隔的圆环。每帧 z 减小——它们朝你飞过来。一个环穿过摄像机时不是被删掉,而是被扔回最远端:
z <= 0 时是重置而不是删除,这样存活的环数量恒定,无限的感觉才成立。改成删除,你会看到一次爆发,然后空掉。
注意缩放那一行。因为 s = f/(f+z) 在 z 接近 -f 时会趋向无穷,太靠近的环会被画成天文数字那么大。重置点必须卡住:趁着环还在足够远的地方就重置,你就永远看不到它爆掉的那一帧。
那种艳丽配色是把色相从 z 本身推出来的——hue = (z * 4) % 360——而不是存下来。这意味着颜色循环是深度的副产品,色彩图案跟着圆环一起动,这才是它读起来像运动、而不是像渐变动画的原因。
rings.forEach(r => {
r.z -= speed;
if (r.z <= 0) r.z = FAR; // 回收,不要删除
const s = f / (f + r.z);
ctx.beginPath();
ctx.arc(cx, cy, r.r * s, 0, 7);
ctx.lineWidth = Math.max(0.5, 8 * s);
ctx.strokeStyle = 'hsl(' + (r.z * 4 % 360) + ',85%,60%)';
ctx.stroke();
});
激光网格:地平线是画出来的,不是算出来的
激光网格是经典的合成波地板,而且它不是真 3D。横线靠步进 z 再投影得到,于是它们自动朝地平线压缩——那份压缩本身就是地平线,没有任何「地平线公式」:
竖线全部从消失点向外画,所以它们天然收敛。整个网格就是几笔,加一个投影函数。
微妙之处在于:横线的间距必须在 z 上均匀,绝不能在屏幕 y 上均匀。在屏幕上等距去排,你得到的是栅栏,不是地板——近处的线和远处的线看起来一样密,深度线索整个消失。这个效果失败的原因,绝大多数就这一个。
// 横线:步进 z,再投影
for (let z = 0; z < 40; z++) {
const s = f / (f + z * 20);
const y = horizon + 200 * s;
line(0, y, W, y);
}
// 竖线:从消失点向外
for (let i = -10; i <= 10; i++) {
line(cx, horizon, cx + i * 120, H);
}
DNA 螺旋:用参数方程代替 z 轴
一条 DNA 是两条相差 π 的正弦波,也就是说你根本不需要 3D 模型——你需要一个角度和两个相位:
深度线索还是同一条投影规则,只不过这里的 z 是从角度推出来的:z = sin(angle)。碱基对转到螺旋前面就画得大,转到后面就画得小。半径和点的大小都乘上它,扭转就读作旋转了。
这里比隧道多一件必须做的事:两条链要正确交叠,就得从后往前画,也就是每帧按 z 排序碱基对——同一个画家算法,这次是真有成本。之所以还付得起,是因为只有几十对。
流星是通往同一种感觉的另一条捷径:一颗流星就是一条短线,尾巴只是把同一个点往前采样一点。没有 3D 重力,没有拖尾缓冲——一个速度、一个偏移,错觉就成立了,因为眼睛会把那道亮痕读成运动。
for (let i = 0; i < pairs; i++) {
const a = i * 0.35 + t;
const y = i * 9 - offset;
const z1 = Math.sin(a), z2 = Math.sin(a + Math.PI);
const s1 = f / (f + z1 * 80), s2 = f / (f + z2 * 80);
dot(cx + Math.cos(a) * 60 * s1, y, 4 * s1); // A 链
dot(cx + Math.cos(a + Math.PI) * 60 * s2, y, 4 * s2); // B 链
if (z1 > z2) link(y, s1, s2); // 横档,按深度排
}
什么时候 CSS 3D 是更好的答案
立方体完全不用 Canvas。它是六个 div,在 transform-style: preserve-3d 的父元素里做 rotateX/Y/Z,用 CSS keyframe 播动画。
为什么这比手动投影八个顶点更好?因为浏览器替你做了投影、深度排序和合成,而且全部在 GPU 上,动画跑在主线程之外。CSS 旋转的立方体在你 JS 忙的时候照样转;Canvas 的不会。
限制同样清楚。CSS 3D 只给你平面,而且只有平面——没有粒子,没有逐像素效果,没有程序化几何。背面剔除是一行 backface-visibility,但物体互相穿插很难做,你也读不回像素。
经验法则:如果东西是由矩形拼的、面之间永不穿插,CSS 3D 会更顺、代码更少。一旦你想要一千个,或者想让它们变形,回到 Canvas,并接受投影和排序都归你管。
.scene { perspective: 800px; }
.cube { transform-style: preserve-3d; animation: spin 12s linear infinite; }
.face { position: absolute; backface-visibility: hidden; }
@keyframes spin {
to { transform: rotateX(360deg) rotateY(360deg); }
}
接下来可以怎么用
透视是一次除法,而真正让人相信的是深度顺序。如果你的伪 3D 看起来很平,先查绘制顺序,再动数学;如果它看起来像在「拉远」而不是「远去」,那是你忘了把点往消失点拉。
Related demos相关示例
Each one runs on its own page, with the source shown below the stage.每个都在独立页面实时运行,舞台下方附完整源码。
Every effect on this site is a single self-contained file. Copy it, change one number, see what happens.本站每个特效都是单个自包含文件。复制走,改一个数字,看会发生什么。
Browse all 131 effects浏览全部 131 个特效