Three.js案例
大气压+风速联动,Three.js 也能像风一样自由

大气压+风速联动,Three.js 也能像风一样自由

通过原生 JavaScript 驱动 Three.js 场景,结合 lil-gui 控制大气压和风速参数,动态生成多层球体结构与流动尾迹。球体数量、尺寸、旋转速度实时联动变化,尾迹随角度与速度自然拖曳,整体呈现细腻流动感与空间层次感,支持参数动态调整与实时响应。

先看下效果

搭建基本渲染环境

一开始,我就明确了一个核心目标:所有交互参数必须即调即生效,保证流畅体验。所以直接用原生 JS,不引入额外框架干扰响应速度。

首先,初始化 three.js 渲染环境:

import * as THREE from "three";
import GUI from "lil-gui";
import { Power4 } from "gsap";
 
const container = document.getElementById("logo-anim");
const containerW = container.clientWidth;
const containerH = container.clientHeight;
 
let scene, camera, renderer;

这里选择了 lil-gui 替代传统的 dat.GUI,一是因为体积更小,二是界面交互更现代。

考虑到画面是透明叠加的,所以在 WebGLRenderer 初始化时,特地加了 alpha: true,避免黑色背景遮挡后续页面元素。

renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(containerW, containerH);
container.appendChild(renderer.domElement);

引入交互控制:大气压与风速

在设计参数结构时,我刻意保持简单,只定义了两个核心变量,大气压 (AtmosPressure) 和风速 (WindSpeed),范围统一设为 [0,1],后续映射到不同物理量上,便于统一管理。

const uisettings = {
  AtmosPressure: 0.5,
  WindSpeed: 0.5,
};
 
const gui = new GUI();
gui.add(uisettings, "AtmosPressure", 0, 1, 0.01).name("大气压");
gui.add(uisettings, "WindSpeed", 0, 1, 0.01).name("风速");

在这里,我没有直接映射到实际尺寸或速度,而是保留了一个中间层映射关系,这样未来扩展(比如增加温度、湿度参数)时,只需调整中间映射逻辑,界面部分无需变动。

初始化核心对象:球体与尾巴

为了呈现动态流动感,我选择了以球体 (Sphere) 代表每一层的核心粒子,并为每个球体附加一个尾巴 (Tail)。球体表现静态形态,尾巴则强调运动轨迹。

考虑到性能和画面密度,我在一开始就创建了最大层数的对象,并通过控制 visible 属性动态显示,不在运行中频繁增删,避免引入 GC 抖动。

let vSpheres = [], vTails = [], initRotations = [], targetRotations = [];
const minVLayers = 8;
const maxVLayers = 24;
const tailSegments = 45;
const vMaxRadius = containerH / 1.4;
const yOffset = -(containerH * 1.4) / 2 - 100;
 
for (let i = 0; i < maxVLayers; i++) {
  const sphere = new THREE.Mesh(
    new THREE.SphereGeometry(25, 20, 20),
    new THREE.MeshBasicMaterial({ color: 0xe1e1e1 })
  );
  scene.add(sphere);
  vSpheres.push(sphere);
 
  const tail = new THREE.Mesh(
    new THREE.PlaneGeometry(0, 0, tailSegments, 1),
    new THREE.MeshBasicMaterial({
      color: 0xe1e1e1,
      side: THREE.DoubleSide,
      transparent: true,
      opacity: 0.33,
    })
  );
  scene.add(tail);
  vTails.push(tail);
 
  initRotations[i] = Math.random();
  targetRotations[i] = Math.random();
}

这里提前预生成随机初始角度 (initRotations) 和目标角度 (targetRotations),是为了后续动画可以有自然的分层错位感,而不是全部同步转动,破坏视觉丰富度。

动画更新逻辑

到动画部分,设计思路就更强调"流动感的连续性"了。

每一帧,都会根据当前参数动态计算出可见层数、球体尺寸、旋转速度,再逐层逐点去更新对象状态。

function animate() {
  requestAnimationFrame(animate);
 
  const vLayers = Math.round((maxVLayers - minVLayers) * uisettings.AtmosPressure + minVLayers);
  const plotRadius = 25 - Math.round((25 - 7) * uisettings.AtmosPressure);
  const linearRotationPerFrame = 
    ((Math.PI * 2) / 150 - (Math.PI * 2) / 600) * uisettings.WindSpeed + (Math.PI * 2) / 600;

可见层数与粒子尺寸,都与大气压成正比。风速则影响旋转角速度,数值调整上,我特地让旋转角速度变化范围在 [600帧一圈, 150帧一圈] 之间,保证变化平滑但不至于无感。

接下来是逐层处理:

for (let i = 0; i < maxVLayers; i++) {
  if (i < vLayers) {
    vSpheres[i].visible = true;
    vTails[i].visible = true;
    
    const layerAnimationProgress = Math.min(animationProgress / (5 * 30), 1);
    const eased = Power4.easeInOut(layerAnimationProgress);
 
    const layerAngleStart = initRotations[i] * Math.PI * 2 + frameCount * (linearRotationPerFrame * eased);
    const layerAnimationNext = layerAngleStart + eased * (targetRotations[i] * Math.PI * 2);
    
    const layerScale = Math.tan((Math.PI / 4 / vLayers) * (i + 1));
    const layerRadius = vMaxRadius * layerScale;
    const baseY = yOffset + (containerH * 1.4 / vLayers) * (i + 1);
 
    vSpheres[i].scale.set(layerScale, layerScale, layerScale);
    vSpheres[i].position.set(
      layerRadius * Math.cos(layerAnimationNext),
      baseY,
      layerRadius * Math.sin(layerAnimationNext)
    );

特别注意的是,eased 插值不仅控制位置变化速度,同时也同步控制了角度变化和尾巴形态变化,确保整体过渡连贯自然。

尾巴的更新则是基于当前主角位置,按角度递减依次往回推,模拟出流动的拖尾效果:

const tail = vTails[i];
const positionAttr = tail.geometry.attributes.position;
const tailVertexPairs = positionAttr.count / 2;
const radianIncrement = (Math.PI * 2 * layerAnimationProgress) / 90;
 
for (let v = tailVertexPairs - 1; v >= 0; v--) {
  const pProximity = 1 - Math.max(1, v) / tailVertexPairs;
  const tailWeight = eased * (plotRadius * layerScale) * pProximity;
  const nextAngle = layerAnimationNext - v * radianIncrement;
  const tailX = layerRadius * Math.cos(nextAngle);
  const tailY = baseY + tailWeight / 2;
  const tailZ = layerRadius * Math.sin(nextAngle);
 
  positionAttr.setXYZ(v, tailX, tailY, tailZ);
  positionAttr.setXYZ(v + tailVertexPairs, tailX, tailY - tailWeight, tailZ);
}
positionAttr.needsUpdate = true;

为了性能考虑,每帧只更新顶点属性数组,不重建几何体。

相机运动:制造立体感

画面如果只是旋转物体,缺乏动感。所以我同步设计了轻微的相机俯仰动画,进一步增强空间感。

const camProgress = Math.min(animationProgress / (5 * 30), 1);
const camEased = Power4.easeInOut(camProgress);
rotation = 0.7 - (camEased * 0.2);
 
camera.position.x = 0;
camera.position.y = Math.sin(rotation) * 550;
camera.position.z = Math.cos(rotation) * 550;
camera.lookAt(scene.position);

这段相机插值,同样使用 Power4.easeInOut,和前面所有过渡逻辑一致,保证整体动效风格统一。

代码

https://github.com/calmound/threejs-example/tree/main/src/demos/rotation (opens in a new tab)