Three.js教程
Shader
neon-corridor
neon-corridor

纯 Three.js 实现无限延伸的赛博走廊,没有 3D 模型,全靠一段 GLSL

CodePen 上看到一个效果:赛博风格的走廊无限延伸,墙上全是绿色终端面板,地板有反射,摄像机自动沿路径穿行。

点开源码,没有任何模型文件,没有加载任何贴图——走廊的几何形状、墙上的终端面板、地板反射,全部是运行时生成的。核心就是一个 ShaderMaterial

实现方式叫 Raymarching:不传网格数据,直接在 Fragment Shader 里用光线步进找到场景表面。在 Three.js 里跑这套东西需要一些特殊的管线设置,下面拆开看。

全屏 Quad:让 Shader 接管整个画面

Three.js 默认是传统的光栅化管线——几何体 → 顶点着色器 → 片元着色器。Raymarching 不需要真实的几何体,它在片元着色器里自己算光线,所以需要一个"载体"把 Fragment Shader 撑满整个屏幕。

做法是一个铺满视口的平面 + 正交相机:

// 正交相机覆盖 [-1, 1] 范围,确保 Quad 精确填满屏幕
const orthoScene = new THREE.Scene();
const orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
 
// 2x2 的平面,配合正交相机刚好铺满
const geometry = new THREE.PlaneGeometry(2, 2);
const quad = new THREE.Mesh(geometry, raymarchMaterial);
orthoScene.add(quad);

顶点着色器极简——直接把顶点位置原样输出,不做任何变换:

varying vec2 vUv;
void main() {
    vUv = uv;
    gl_Position = vec4(position, 1.0);
}

渲染时用 renderer.render(orthoScene, orthoCamera) 而不是透视相机。这样片元着色器就能通过 vUv 拿到每个像素的屏幕坐标,在里面算光线方向。

同时还保留了一个透视相机,只用来计算矩阵传给 Shader——uCameraMatrixWorlduProjectionMatrixInverse,让 Raymarching 场景能响应正确的透视投影和摄像机位移。

走廊 SDF:用 fract() 做无限重复

Raymarching 需要一个 SDF(有符号距离函数),输入空间中任意一点,返回它到最近表面的距离。步进时每步走这个距离,保证不会穿透表面。

走廊的 SDF 用 fract() 实现无限重复:

float getDist(vec3 p) {
    float spacing = 40.0; // 走廊格子间距
    float w = 8.0;        // 走廊宽度
 
    // fract 把空间折叠成重复单元,模拟无限走廊
    vec2 q = fract(p.xz / spacing + 0.5) * spacing - spacing * 0.5;
    vec2 d = abs(q) - (spacing * 0.5 - w * 0.5);
    float walls = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
 
    float floorDist = p.y + 4.0;  // 地板在 y = -4
    float ceilDist  = 12.0 - p.y; // 天花板在 y = 12
 
    return min(walls, min(floorDist, ceilDist));
}

fract(p.xz / spacing + 0.5) * spacing - spacing * 0.5 这一步把整个世界坐标折叠成以 spacing 为周期的单元,每个单元里的几何形状完全相同。光线步进时会穿越多个这样的重复格子,产生无限延伸的感觉。

法线用有限差分估算,对 SDF 在三个轴方向微扰后计算梯度:

vec3 getNormal(vec3 p) {
    vec2 e = vec2(0.01, 0.0);
    return normalize(getDist(p) - vec3(
        getDist(p - e.xyy),
        getDist(p - e.yxy),
        getDist(p - e.yyx)
    ));
}

程序化 Canvas 纹理贴墙

墙上的终端面板不是图片,是在 JS 里用 Canvas 2D API 画出来的。这里有三张纹理:游戏状态列表(tList)、代码块(tCode)、ASCII 艺术(tASCII),每张都是 1024×1024 或 2048×2048 的 Canvas 渲染结果。

每张纹理内部是 2×2 或 4×4 的网格,每格内容不同,后续在 Shader 里用 hash 函数随机选用哪一格,实现面板内容的视觉多样性:

function createProceduralTexture(drawFn, texWidth = 1024, texHeight = 1024) {
    const canvas = document.createElement('canvas');
    canvas.width = texWidth;
    canvas.height = texHeight;
    const ctx = canvas.getContext('2d');
    ctx.fillStyle = '#000000';
    ctx.fillRect(0, 0, texWidth, texHeight);
    drawFn(ctx, texWidth, texHeight);         // 调用自定义绘制函数
 
    const texture = new THREE.CanvasTexture(canvas);
    texture.anisotropy = maxAnisotropy;       // 开启各向异性过滤,斜角看字清晰
    texture.needsUpdate = true;
    return texture;
}

画的时候每行文字带一个 highlight 标志,高亮行用绿色(rgb(0, 255, 0)),普通行用红色(rgb(255, 0, 0))。这不是最终颜色,而是一种双通道编码——红通道存"主色"信息,绿通道存"强调色"信息,在 Shader 里再映射成实际颜色:

// R 通道 = 主色,G 通道 = 高亮色
vec3 col = uColorPrimary * texCol.r + uColorHighlight * texCol.g;

这样用户可以通过 GUI 实时修改 uColorPrimaryuColorHighlight,颜色立刻变化,不需要重新生成纹理。

纹理投影用 Wall UV:根据法线判断是 X 方向的墙还是 Z 方向的墙,取对应的坐标轴作为 UV,保证纹理以固定比例贴在墙面上:

if (abs(n.x) > 0.5) {
    wallUv = vec2((n.x < 0.0) ? p.z : -p.z, p.y);
} else {
    wallUv = vec2((n.z > 0.0) ? p.x : -p.x, p.y);
}

比例系数 0.25 刻意选得和走廊尺寸对齐:走廊高度 16 单位 × 0.25 = 4 个纵向面板,格子间距 40 单位 × 0.25 = 10 个横向面板,整数比保证面板不会被截断。

地板反射:二次 Raymarch

地板反射不是屏幕空间技术,而是在命中地板的像素上再发射一条反射光线,完整跑一遍 Raymarching:

// 计算反射方向
vec3 ref_rd = reflect(rd, n);
vec3 ref_ro = p + n * 0.1; // 偏移一点防止自交
 
float ref_d = 0.0;
bool ref_hit = false;
for(int j = 0; j < 50; j++) {  // 反射光线步数减半
    float dS = getDist(ref_ro + ref_rd * ref_d);
    if(dS < SURF_DIST) { ref_hit = true; break; }
    if(ref_d > 120.0) break;
    ref_d += dS;
}
 
// 距离衰减模拟粗糙度散射
ref_col *= exp(-ref_d * uFloorRoughness);
 
// Fresnel:掠射角反射更强
float fresnel = mix(0.15, 1.0, pow(1.0 - max(dot(n, -rd), 0.0), 3.0));
col = floorBase + ref_col * uFloorReflectivity * fresnel;

exp(-ref_d * uFloorRoughness) 让远处的反射随距离指数衰减,模拟真实地面的漫反射散射——光泽地板(低 roughness)反射清晰,磨砂地板(高 roughness)反射快速消失。Fresnel 项则保证直视地面时反射弱、斜视时反射强,符合物理直觉。

摄像机路径:确定性网格 + Bezier 平滑转弯

摄像机沿着程序生成的格子路径移动,路径用确定性哈希生成——相同时间点永远在相同位置,不用存储路径数据:

function pathHash(x) {
    let t = x * 127.1;
    return Math.sin(t) * 43758.5453123 % 1;
}

每个格子节点有 70% 概率直走,15% 左转,15% 右转。转弯处用二次 Bezier 曲线过渡,转弯窗口占路段的 30%,用 smootherstep(C² 连续)做缓动,避免转弯时摄像机方向突变:

// Smootherstep: C² 连续,转弯起止速度都为 0
const ease = u * u * u * (u * (u * 6.0 - 15.0) + 10.0);
return a1 + diff * ease;

摄像机 Y 轴叠加一个 sin(time * 4π) * 0.15 的轻微上下浮动,模拟行走时的步伐感。

这套东西放在一起:全屏 Quad 跑 Raymarching、Canvas 2D 生成纹理、SDF 折叠空间、反射光线二次步进——每个部分单独看都不复杂,组合起来就能出这个效果。对 Shader 开发感兴趣的话源码可以直接在浏览器里跑,改颜色参数看变化是最快的入门方式。

完整源码:https://codepen.io/sabosugi/pen/XJNpVNe (opens in a new tab)