跳到主要内容

ASCII 地球

打开地球实验。按住鼠标或触摸拖动可任意旋转视角;拖拽期间自动暂停自转,松手后平滑恢复。禁用缩放与平移,确保页面阅读时不产生意外手势拦截。同一套渲染管线同时驱动首页展示与独立实验页。

图像如何转化为文本​

底层渲染目标是一个真实的 Three.js 3D 球体,而非平铺的二维贴图。自定义着色器(ShaderMaterial)同时采样两张纹理:镜面反光遮罩(用于区分陆地与水体)和地貌高程贴图(用于在陆地上呈现明暗起伏):

// 分离陆地检测与地形细节
// 镜面遮罩在海洋区域为高亮,取反后可令海洋保持纯黑
// 地貌贴图随后为陆地提供起伏亮度,无需设置额外动态光源
const material = new THREE.ShaderMaterial({
uniforms: {
oceanMap: {value: oceanTexture},
reliefMap: {value: reliefTexture},
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D oceanMap;
uniform sampler2D reliefMap;
varying vec2 vUv;
void main() {
float ocean = texture2D(oceanMap, vUv).g;
float land = 1.0 - smoothstep(0.28, 0.72, ocean);
float relief = texture2D(reliefMap, vUv).r;
float luminance = land * (0.48 + relief * 0.52);
gl_FragColor = vec4(vec3(luminance), 1.0);
}
`,
});

着色器读取镜面贴图的绿色通道,将其转为平滑的陆地遮罩,再用地貌贴图调节陆地亮度。海洋区域保持暗色,无需移动光源。

Three.js AsciiEffect 随后将该球体离屏渲染、下采样像素,并按灰度映射到预设字符表:

const effect = new AsciiEffect(renderer, ' .:-+*=%~@#01o', {
invert: true,
resolution: 0.16,
});

字符集开头的空格将海洋区域完全留空,使得大陆轮廓直接显现,而不会在球体外围形成呆板的实体黑色底盘。由于海洋不着墨,当旋转到大面积海洋(如太平洋中心)时,屏幕上字符显得较为稀疏,这是正确的几何投影,并不意味着程序卡死。

字符形状与视觉密度的搭配经过微调。横向笔画较强的符号如 -、= 和 ~ 在平缓地貌上能自然形成微弱等高线效果。

自转与动画控制​

地球沿 Y 轴以 0.28 rad/s 的角速度自转,渲染帧率限制在约 30 FPS,以降低长驻 CPU 与 GPU 功耗:

const controls = new OrbitControls(camera, effect.domElement);
controls.enableZoom = false;
controls.enablePan = false;
controls.rotateSpeed = 0.6;
controls.enableDamping = true;
controls.dampingFactor = 0.06;

拖拽通过 OrbitControls 改变相机视角,并暂时停止自转。点击暂停按钮只停止自动旋转,相机变化仍会触发重绘;松手后的阻尼运动结束后,渲染器才停止请求空闲动画帧。组件离屏或标签页隐藏时暂停动画,可见性变化和播放操作会在适当条件下唤醒渲染器。

零尺寸采样缓冲区的防护​

AsciiEffect 内部按 Math.floor(width * resolution) 与 Math.floor(height * resolution) 计算采样画布尺寸。当分辨率为 0.16 时,任何小于 6.25 像素的容器尺寸向下取整后都会得到 0 像素。若此时对画布调用 getImageData(0, 0, 0, 0),浏览器将抛出致命错误。

组件在重设尺寸前检查容器大小。容器过小时会清除 hasSize,使 draw() 跳过渲染,直到尺寸恢复有效:

const resize = () => {
const {width, height} = mountNode.getBoundingClientRect();
const nextWidth = Math.round(width);
const nextHeight = Math.round(height);
if (disposed || nextWidth < 1 / ASCII_RESOLUTION || nextHeight < 1 / ASCII_RESOLUTION) {
hasSize = false;
return;
}
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
effect.setSize(nextWidth, nextHeight);
hasSize = true;
draw();
};

组件生命周期与卸载​

组件卸载时,完整清理所有内存与句柄资源:

  1. 取消正在等待的 requestAnimationFrame。
  2. 断开 ResizeObserver 与 IntersectionObserver。
  3. 释放 OrbitControls、纹理、球体几何体、着色器材质及 WebGL 渲染器,强制释放上下文。
  4. 从 DOM 树中移除字符网格节点。
探索关联打开关联网络