大气压+风速联动,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)