普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月27日首页

我决定写一个 3D 地球仪来记录下我要去的地方

作者 Mh
2026年4月26日 23:00

我要去南极。 那里是地球最后的留白。去看那万年不化的冰川在阳光下透着幽幽的深蓝色。

我要去北极。 站在世界的顶点,等一场美到窒息的极光。

我要去非洲萨瓦那。 那是离赤道最近的金色草原。听万千角马奔腾而过的蹄声,感受那种野性而滚烫的生命力,在耳边呼啸而过。

我要去南美亚马逊。 钻进那片被称为“地球肺叶”的雨林,听雨水噼里啪啦地敲在宽大的树叶上。

我要去热带海岛。 去看海水从浅浅的薄荷绿慢慢变成深邃的宝石蓝。

我要去崖边海岸。 去海边的悬崖。看守护在悬崖尽头的灯塔。

我要去欧洲古镇。 踩在湿漉漉的石板路上,听风铃在街角清脆地响。

我要去赛博都市。 去感受雨后的街道的霓虹倒影,高耸入云的大楼在水雾里若隐若现。

image.png

虽然作为社会主义的接班人,至今没有走出过国门。但是没有关系,我还另一个身份。作为一名程序员,我只需要动动手指就可以在地球上看到它们。

话不多说,说干就干!!

globe.gl 的介绍

这次我决定用 globe.gl 去实现,至于啥是 globe.gl 呢?

简单来说,它是一个基于 Three.js 封装的开源 JavaScript 组件,专门用来进行 地球空间数据的可视化。它的强大之处在于:你不需要写复杂的 WebGL 底层代码,就可以做出一个 3D 交互式地球。

为什么选择它呢?

因为它足够简单,下面是实用资源。

快速开始

先看效果图

202604116223817.gif

代码预览

<template>
  <section class="floor3-container floor-container" ref="containerRef">
    <div class="sticky">
      <div id="chart__container" ref="chartRef"></div>
      <div class="text" ref="textRef">
        <p class="title">Earth: A Never-Ending Dream</p>
        <p class="desc">The flickers on this map are more than just distant auroras and waves; they are our deepest gaze upon this planet. From the golden savannas to the neon-lit streets after rain, stories are unfolding quietly in every corner of the world.</p>
      </div>
    </div>
  </section>
</template>

<script setup>
import { onMounted, ref, onBeforeUnmount } from 'vue';
import Globe from 'globe.gl';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import earthImg from '@/images/earth-night.jpg';
import skyImg from '@/images/night-sky.png';


gsap.registerPlugin(ScrollTrigger);
const containerRef = ref(null);
const chartRef = ref(null);
const textRef = ref(null);
const highlightIndex = ref(-1);
let world = null;

const initData = [
  { name: "Antarctica", value: [0, -82.8628, 0], zIndex: 0 },
  { name: "The Arctic", value: [0, 90, 0], zIndex: 1 },
  { name: "Savanna", value: [34.8888, -2.3333, 0], zIndex: 2 },
  { name: "Amazon", value: [-62.2159, -3.4653, 0], zIndex: 3 },
  { name: "Maldives", value: [73.2207, 3.2028, 0], zIndex: 4 },
  { name: "Cliffs of Moher", value: [-9.4309, 52.9719, 0], zIndex: 5 },
  { name: "Prague", value: [14.4378, 50.0755, 0], zIndex: 6 },
  { name: "Tokyo", value: [139.6503, 35.6762, 0], zIndex: 7 }
];

onMounted(() => {
  const width = window.innerWidth;
  const height = window.innerHeight

  // 初始化地球
  world = Globe()(chartRef.value)
    .width(width) // 设置地球画布的宽度
    .height(height)
    .globeImageUrl(earthImg) // 设置地球表面的贴图
    .backgroundImageUrl(skyImg)  // 设置背景图
    .atmosphereColor("#ffb4ff") // 设置地球周围“大气层”的光晕颜色
    .atmosphereAltitude(0.06) // 设置大气层的厚度
    .pointOfView({
      lat: 36.818188, // 设置相机初始化时正对着的经纬度
      lng: 12.227512,
      altitude: 2.5, // 相机距离地表的高度
    })
    .labelsData(initData)  // 注入数据源
    .labelLat(d => d.value[1]) // 数据里的纬度在 value 数组的第 2 个位置
    .labelLng(d => d.value[0])
    .labelText(d => d.name) // 显示在地球上的文字内容
    .labelSize(d => initData.indexOf(d) === highlightIndex.value ? 2.5 : 1.6) // 文字的大小
    .labelColor(() => "rgba(255, 165, 0, 0.75)") // 文字的颜色
    .labelDotRadius(d => {
      return initData.indexOf(d) === highlightIndex.value ? 1.2 : 0.5;
    })
    .enablePointerInteraction(true); // 开启鼠标交互

  const controls = world.controls(); // 交互控制器
  controls.enableZoom = false; // 禁用缩放
  controls.enablePan = false; // 禁用平移
  controls.autoRotate = true; // 开启自动旋转
  controls.autoRotateSpeed = -1; // 设置旋转速度和方向 负值代表是逆时针

  // 自动高亮循环 
  const interval = setInterval(() => {
    window.requestIdleCallback(() => {
      highlightIndex.value = (highlightIndex.value + 1) % initData.length;
      world.labelsData([...initData]);
    });
  }, 2000);

  // GSAP 滚动动画逻辑
  gsap.fromTo(textRef.value,
    { y: 100, opacity: 0, zIndex: -1 },
    {
      y: 0, opacity: 0.8, duration: 1, zIndex: 1,
      scrollTrigger: {
        trigger: containerRef.value,
        start: "top top",
        end: "+=50%",
        scrub: 1,
        markers: false
      }
    }
  );

  // 监听窗口变化
  const handleResize = () => {
    world.width(window.innerWidth);
    world.height(window.innerHeight);
  };
  window.addEventListener("resize", handleResize);

  // 清理函数
  onBeforeUnmount(() => {
    if (interval) clearInterval(interval);
    window.removeEventListener("resize", handleResize);
    if (world) world._destructor?.(); // 销毁地球实例防止内存泄漏
  });
});
</script>

<style scoped>
#chart__container {
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.sticky {
  position: sticky;
  top: 0;
  left: 0;
  width: 100%;
  height: 100vh;
}

.text {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
  width: 162rem;
}

.title {
  font-size: 6rem;
}

.desc {
  font-size: 3rem;
  margin-top: 3rem;
}

.floor-container {
  width: 100%;
  height: 200vh;
  position: relative;
  color: #fff;
  background-color: #000;
}
</style>

代码分析

需要用到的工具:

  1. Globe.gl: 基于 Three.js 的封装,将复杂的 WebGL 地球渲染简化为数据驱动的API组合。
  2. GSAP & ScrollTrigger: 写动效的神器,这里主要负责处理文案随页面滚动的平滑视觉过渡。

核心代码分析:

  1. 这里选择 Globe.gl 的原因是其拥有强大的数据映射能力, 可以轻松的将地理坐标 (GPS) 轻松转换为 3D 空间坐标
  .labelsData(initData)
  .labelLat(d => d.value[1])
  .labelLng(d => d.value[0])
  1. 这里利用 setInterval 配合 requestIdleCallback,动态调整标签大小 (labelSize),增加“呼吸感”。
  const interval = setInterval(() => {
    window.requestIdleCallback(() => {
      highlightIndex.value = (highlightIndex.value + 1) % initData.length;
      world.labelsData([...initData]);
    });
  }, 2000);
  1. 这里引入 gsap 动画滚动控制文字浮现,增加整体趣味。
  gsap.fromTo(textRef.value,
    { y: 100, opacity: 0, zIndex: -1 },
    {
      y: 0, opacity: 0.8, duration: 1, zIndex: 1,
      scrollTrigger: {
        trigger: containerRef.value,
        start: "top top",
        end: "+=50%",
        scrub: 1,
        markers: false
      }
    }
  );
  1. 销毁定时器(interval)、解绑全局事件(resize)、销毁地球实例(world),以上这些做法都是防止内存泄露。
  onBeforeUnmount(() => {
    if (interval) clearInterval(interval);
    window.removeEventListener("resize", handleResize);
    if (world) world._destructor?.(); 
  });

写在最后

以上就是使用 globe.gl 创建 3D 交互式动画的全部内容了,其实相对比较简单,大多数都是 API 的配置,后期如果有时间,研究一下出个2.0版本,可以在坐标的位置添加对应的图片,点击图片放大。或者增加国家地区的选择,可以让用户自定义选择国家地区,增加功能交互。

最后多说一句,人生是旷野不是轨道。如果有机会希望我们都能去世界看看,如果你喜欢我的分享,不要忘记 “一键三连” 哈!!!

昨天以前首页

录音与音频可视化

作者 hmh12345
2026年4月22日 10:39

需求背景

实现效果

现在很多AI智能体应用,会向用户开放自定义音色的功能,以便用户能使用自己的声音生成一个角色。实现自定义音色的前提就是采集用户自己的声音,以供后续大模型生成角色的自定义音色。本文将介绍,怎么在web中实现录音以及音频的可视化。

实现效果如下,类似iphone的语音备忘录:

它具有以下特点:

  • 波形随着音量实时变化
  • 从右向左滚动
  • 播放时可以复现录音波形

技术选型

怎么采集声音

浏览器中录音主要有三种方案:MediaRecorder、Web Audio API、Recorder-Core
1. MediaRecorder API:是浏览器提供的高层封装录音接口 用法如下:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

const recorder = new MediaRecorder(stream);

recorder.ondataavailable = (e) => {
  const blob = e.data; // 音频文件
};

recorder.start();

这是浏览器原生支持的API,简单高效实现录音,但是只能拿到编码后的blob,无法获取原始的振幅数据,也就无从实现实时可视化了。

2. Web Audio API 这是浏览器提供的音频处理引擎,只提供原始的振幅数据,可以实现音频可视化。可是,这套API不做任何封装,需要自行处理buffer 拼接、多通道处理、文件头封装等问题,实现过于复杂,因此不做考虑

3. Recorder-Core 这个库基于Web Audio API,实现了以下功能:

  • 实时回调输出振幅数据 (实现音频实时可视化的关键)
  • WAV编码
  • 生命周期封装 大大简化了音频采集的实现,并且具有良好的浏览器兼容性,在PC + 主流移动浏览器上都能使用,因此,最终选择recorder-core来实现音频采集

怎么实现音频的实时可视化

高频更新的动画,使用DOM实现会频繁触发回流、重绘,会造成显著的性能浪费,而这种场景使用canvas再合适不过了,用一张独立布局的画布,承接频繁更新的动画

因此,我们选择Recorder-Core + Canvas来实现本次需求。

核心流程

录音与采集音频数据

1️⃣ 初始化录音器

function createRecorder() {
    return RecorderCore({
        type: "wav",
        // 采样率
        sampleRate: 44100,
        onProcess(buffers, powerLevel, duration, sampleRate, newBufferIdx) {
            ...
        },
    });
}

recorder = createRecorder();


  • type: "wav" → 输出 wav 文件
  • onProcess: 实时拿到 PCM(振幅) 数据,以供后续绘制图形

2️⃣ 音频数据处理

在这一步中,原始采集到的音频数据在[-32768, 32767]区间,去掉正负值,并转换成[0, 1]区间的值。

const latest = buffers[newBufferIdx] || buffers[buffers.length - 1];

// 将原始 PCM 采样转换成 0 到 1 之间的能量值,供可视化使用。
function calcEnergyFromPCM(pcm: Int16Array): number {
    if (!pcm || pcm.length === 0) return 0;
    let sum = 0;
    for (let i = 0; i < pcm.length; i++) {
        sum += Math.abs(pcm[i]);
    }
    return Math.min(1, sum / pcm.length / 32768);
}

3️⃣ 实时数据采集

  • liveEnergyQueue,用来存实时展示所需的数据
  • energyTimeline,用来存全量数据,播放回放时使用
const energy = calcEnergyFromPCM(latest);

// 实时显示
liveEnergyQueue.push(energy);
// 播放回放
energyTimeline.push(energy);

4️⃣ 录音控制

开始录音

// 开始录音
await recorder.open(
    () => resolve(),
    (msg: string, _isUserNotAllow: boolean) => reject(new Error(msg)),
);

recorder.start();

结束录音

recorder.stop((blob, duration) => {
    wavBlob.value = blob;
});

注意:开始录音会请求麦克风权限,只有https和localhost能获取,其他环境会报错

音频可视化

1️⃣ 核心数据准备

const liveEnergyQueue: number[] = []; // 实时队列
const energyTimeline: number[] = [];  // 全量数据(播放用)
const visualBuffer: number[] = [];    // 当前屏幕显示

2️⃣ 动画驱动

录音、回放都统一使用startVisual实现绘制,内部通过requestAnimationFrame绘制每一帧动画

function startVisual(mode) {
    function draw() {
        ...
        animationId = requestAnimationFrame(draw);
    }
    draw();
}

3️⃣ 滚动窗口

两种模式energy的取值方式不一样,最终都是加入visualBuffer,visualBuffer会维护最大窗口,超出窗口就不再展示

if (mode === "record") {
    energy = liveEnergyQueue.shift();
} else {
    energy = energyTimeline[playIndex++];
}

visualBuffer.push(energy);

if (visualBuffer.length > MAX_COLUMNS) {
    visualBuffer.shift();
}

4️⃣ 绘制柱状图

从右往左绘制柱状图

const startX = width - barWidth;

for (let i = visualBuffer.length - 1, col = 0; i >= 0; i--, col++) {
    const x = startX - col * step;
    if (x + barWidth < 0) break;

    const e = visualBuffer[i];
    const barHeight = Math.min(
        maxBarHeight,
        Math.max(minBarHeight, e * height * VISUAL_HEIGHT_GAIN),
    );
    const y = (height - barHeight) / 2;
    ctx.fillStyle = "#ff3b30";
    ctx.fillRect(x, y, barWidth, barHeight);
}

5️⃣ Canvas高清适配

现代高清显示屏的dpr通常会大于1,也就是说实际的物理像素会比css像素更大,以dpr=2,css像素200x200为例,浏览器实际的物理像素时400x400,也就是会拉伸canvas尺寸,把一个200x200尺寸画布上绘制的图形,渲染在400x400的画布上,图形就容易模糊,因此需要对canvas根据dpr进行整体缩放

const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

鼠标跟随倾斜动效

作者 Mh
2026年4月21日 23:28

前言

最近在 gsap 上看到一个有趣的动效(Cursor-driven perspective tilt),于是决定自己实现一下,下面将介绍实现的过程,希望你能喜欢。

202604111231046.gif

观察动效

  1. 卡片的倾斜角度会随着鼠标的移入在 x 轴和 y 轴上向内进行倾斜。
  2. 卡片上的文字是悬浮在卡片,给人一种悬空在空中的错觉。

技术拆解

要实现这种 3D 的效果,在 css 中你首先想到的是什么?

在 CSS 中有三个属性实现 3D 效果至关重要。它们分别是 perspective、transform-style: preserve-3dtransform: rotateX() rotateY()。下面将详细的介绍他们在 3D 动效中的作用。

  1. perspective (透视/视距):它是 3D 的灵魂,如果没有它,你看到的效果看起来只像是在平面上进行拉伸和缩放。你可以理解它是3维空间中的z轴,定义观察者距离 z = 0平面的距离。通常设定在父容器上,数值越小(如500px),透视畸变越强烈(近大远小极度明显);数值越大(如 2000px),效果越平缓。
  2. transform-style: preserve-3d :它的作用是告诉子元素(文字层)也要保持在 3D 空间中,这样我们看到的容器的内容是有深度的,同时也可以在侧面看到元素与元素之间的距离。当父元素设置了transform-style: preserve-3d 的时候,同时子元素需要设置 transform: translateZ()。
  3. transform: rotateX() rotateY():这个属性相信大家都知道,这也是这次动效能实现的关键。rotateX 控制卡片绕水平轴转动,rotateY 控制卡片绕垂直轴转动。

总结一下

如果把 CSS 3D 比作一场电影:

  • perspective 是摄影机,决定了画面的纵深感。
  • transform-style: preserve-3d 是舞台搭建,决定了演员(元素)能不能在台前幕后来回走动,而不是画在背景板上。
  • transform: rotate / translate 是演员的动作,决定了物体怎么摆放和移动。

效果展示

如果你已经理解了上面属性,相信实现效果只是时间的问题,下面我就提前剧透一下效果吧!同时在浏览器中为你演示各个的属性的具体效果,让你更加深刻的理解上面的属性。

试想一下,如果没有设置 perspective 属性会怎么样呢?

为了更好的演示,我会将卡片绕着它的y轴固定旋转30度。然后对比设置了 perspective 属性和没有设置 perspective 的效果如下。

image.png

在对比了设置 perspective 的作用后,接下来为你演示 transform-style: preserve-3d 的效果,为了更好的演示,接下来调整一下卡片在y轴的旋转角度为-80度,同时对子元素设置 transform: translateZ(50px); 将背景调整为白色,让文字和背景不会重合。对比效果如下:

image.png

从上面的效果可以看出,设置了 transform-style: preserve-3d 的文字和背景卡片是分离的,没有设置 transform-style: preserve-3d 的文字被拍扁在卡片上面。

注意事项: 当容器设置了 transform-style: preserve-3d; 的时候,不能再设置 overflow: hidden; 不然 transform-style: preserve-3d; 不会生效。

经过上面的对比可以帮助我们更好的理解每个属性在具体场景中的使用,下面就使用 vue3 去实现具体的功能。

代码拆解

完整代码

<template>
  <div class="container">
    <div 
      class="card"
      ref="cardRef"
      :style="cardStyle"
      @mousemove="handleMouseMove"
      @mouseleave="handleMouseLeave"
    >
      <div class="content">
        <span>ANIMATION</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed } from 'vue';

const cardRef = ref(null);

// 存储旋转角度
const transform = reactive({
  rotateX: 0,
  rotateY: 0
});

// 计算最终的 CSS 样式
const cardStyle = computed(() => {
  const scale = 1;
  return {
    transform: `rotateX(${transform.rotateX}deg) rotateY(${transform.rotateY}deg)`,
    transition: 'transform 0.5s ease-out'
  };
});

const handleMouseMove = (e) => {
  if (!cardRef.value) return;

  const rect = cardRef.value.getBoundingClientRect();
  const centerX = rect.left + rect.width / 2;
  const centerY = rect.top + rect.height / 2;
  
  // 计算鼠标距离中心点的偏移量 (-1 到 1)
  const percentX = (e.clientX - centerX) / (rect.width / 2);
  const percentY = (e.clientY - centerY) / (rect.height / 2);

  const deg = 25; // 最大旋转角度
  transform.rotateY = percentX * deg;
  transform.rotateX = -percentY * deg; // 取反是因为鼠标向上移动时图片应向下倾斜
};

const handleMouseLeave = () => {
  transform.rotateX = 0;
  transform.rotateY = 0;
};
</script>

<style scoped>
.container {
  /* 3D 透视的关键 */
  perspective: 1000px; 
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100vh;
  background-color: #0f0f0f;
}

.card {
  position: relative;
  width: 320px;
  height: 200px;
  background: linear-gradient(135deg, #6ee7b7, #3b82f6);
  border-radius: 20px;
  transform-style: preserve-3d;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
  /* overflow: hidden; */
}

.content {
  font-family: 'Arial Black', sans-serif;
  font-size: 2.5rem;
  color: #000;
  /* 让文字在 3D 空间悬浮 */
  transform: translateZ(50px); 
  pointer-events: none;
}
</style>

简要分析:

  1. 绑定事件:鼠标移入卡片触发 mousemove 事件,设置卡片旋转。鼠标移除触发 mouseleave 事件将旋转的角度置为0。
  2. 样式动态计算:动态绑定 style,通过计算属性实时更新旋转的角度。
  3. 计算偏移量: 这里主要利用鼠标当前的位置减去卡片中心点计算出偏移距离,然后再除以卡片宽高的一半,等到一个-1到1的偏移值。
  4. 角度映射:通过得到的偏移值乘以 deg (25度),刚好可以映射到对应的角度,比如鼠标移动到最左边,卡片正好偏转 -25度。

优化补充

下面是一些优化的建议,有兴趣的同学可以自己实现一下:

  1. 增加光影变化,跟随鼠标移动的卡片增加渐变层的光影,让整体更加真实。
  2. mousemove 在移动端不支持,增加移动端的支持。
❌
❌