3D 城市动画实战教程
今天演示一个基于 Three.js (opens in a new tab) 和 GSAP (opens in a new tab) 的 3D 城市动画场景。它将从初始化一个空白的 3D 世界开始,逐步添加灯光、地面、建筑,并最终为它们赋予动态效果。
准备 HTML 结构
任何 web 项目都需要一个 HTML 文件作为入口。对于 Three.js 场景,需要一个 <div>
元素来容纳渲染结果,并需要通过 <script>
标签引入 Three.js 库和 GSAP 动画库。
<!DOCTYPE html>
<html lang="en">
<head>
<title>3D City</title>
<style>
body {
margin: 0;
}
#container {
display: block;
}
</style>
</head>
<body>
<div id="container"></div>
<!-- 引入外部库 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.9.1/gsap.min.js"></script>
<!-- 引入主逻辑脚本 -->
<script src="./index.js"></script>
</body>
</html>
-
<div id="container"></div>
: 这是 Three.js 场景的渲染目标容器。 -
body { margin: 0; }
: 清除默认的边距,确保 canvas 能够铺满整个窗口。 -
<script>
标签: 分别从 CDN 加载了 Three.js 核心库和 GSAP 动画库,并链接到项目的index.js
文件。
初始化 3D 场景
搭建 3D 世界的第一步是准备三大核心组件:渲染器(Renderer)、相机(Camera)和场景(Scene)。它们共同构成了所有视觉元素的基础。
// 渲染器设置
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
const container = document.getElementById("container");
container.appendChild(renderer.domElement);
// 相机设置
const camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
1,
1000
);
camera.position.set(0, 180, 400);
// 场景设置
const scene = new THREE.Scene();
// 渲染循环
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
// 窗口大小调整处理
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
window.addEventListener("resize", onWindowResize, false);
-
THREE.WebGLRenderer
: Three.js 的渲染器,负责将scene
和camera
的内容绘制到 HTML 的<canvas>
元素上。antialias: true
开启了抗锯齿效果。 -
THREE.PerspectiveCamera
: 透视相机,模拟人眼观察世界的方式,具有近大远小的效果。 -
camera.position.set()
: 设置相机在 3D 空间中的坐标。 -
THREE.Scene
: 场景是所有 3D 对象的容器,例如模型、灯光和辅助对象。 -
requestAnimationFrame(animate)
: 创建一个持续渲染的循环,确保场景内容在每一帧都被重新绘制,从而实现动画效果。 -
onWindowResize
: 这是一个响应式设计的关键函数,当浏览器窗口大小变化时,它会同步更新相机的宽高比和渲染器的尺寸,防止画面变形。
📌 当前项目已可运行,请运行项目查看效果,并视情况截图记录。
布置核心场景元素
一个纯黑的场景无法观察到任何物体。需要添加雾效和光照,来定义场景的氛围和可见性。雾效可以增加景深感,而光照则决定了物体表面的明暗和色彩。
// ... (前序代码) ...
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x1e2630, 0.002);
renderer.setClearColor(scene.fog.color);
// 光源设置
const mainLight = new THREE.DirectionalLight(0xffffff, 2);
mainLight.position.set(1, 1, 1);
scene.add(mainLight);
const secondaryLight = new THREE.DirectionalLight(0x002288, 1.5);
secondaryLight.position.set(-1, -1, -1);
scene.add(secondaryLight);
scene.add(new THREE.AmbientLight(0x404040));
// ... (后续代码) ...
-
scene.fog
: 为场景添加了指数雾 (FogExp2) (opens in a new tab)。它模拟了大气效果,远处物体会逐渐融入雾色。 -
renderer.setClearColor()
: 将渲染器的背景色设置为与雾相同的颜色,使场景融合得更自然。 -
THREE.DirectionalLight
: 平行光,模拟像太阳光一样的光源,光线是平行的。此处设置了主光源和辅助光源,分别从不同方向和颜色照射,以增加场景的立体感。 -
THREE.AmbientLight
: 环境光,为整个场景提供一个基础的、均匀的光照,可以柔化阴影,确保场景最暗的部分也不是纯黑色。
构建场景地理环境
有了基础环境,接下来需要为城市添加背景和地面。这里将使用一个巨大的几何体作为天空背景(穹顶),并用一个平面作为地面。
// ... (前序代码) ...
// 穹顶创建
const domeGeometry = new THREE.IcosahedronGeometry(700, 1);
const domeMaterial = new THREE.MeshPhongMaterial({
color: 0xfb3550,
flatShading: true,
side: THREE.BackSide,
});
scene.add(new THREE.Mesh(domeGeometry, domeMaterial));
// 地面创建
const groundGeometry = new THREE.PlaneGeometry(600, 600);
const groundMaterial = new THREE.MeshPhongMaterial({
color: 0x222a38,
transparent: true,
opacity: 0.8,
flatShading: true,
side: THREE.DoubleSide,
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = Math.PI / 2;
scene.add(ground);
// 网格辅助线
scene.add(new THREE.GridHelper(600, 10));
// ... (后续代码) ...
-
THREE.IcosahedronGeometry
: 一个由 20 个等边三角形组成的多面体。通过将其放大并设置side: THREE.BackSide
,可以创建一个包裹整个场景的穹顶作为天空。 -
THREE.PlaneGeometry
: 一个简单的二维平面。通过rotation.x
将其旋转 90 度,使其水平放置,作为地面。 -
flatShading: true
: 这种材质属性会使模型的每个面都使用单一颜色进行着色,产生一种低多边形(Low Poly)的艺术风格。 -
THREE.GridHelper
: 在场景中创建一个网格平面,它对于在开发阶段观察物体的位置和尺寸非常有帮助。
程序化生成建筑群
城市的核心是建筑。手动创建大量建筑非常低效,更好的方法是程序化生成。通过循环,可以快速创建一组位置和样式各异的建筑。
// ... (前序代码) ...
// 建筑物生成
const geometry = new THREE.BoxGeometry(10, 10, 10);
const colors = [0xfb3550, 0xffffff, 0x000000];
const buildings = [];
for (let i = 0; i < 100; i++) {
const material = new THREE.MeshPhongMaterial({
color: colors[Math.floor(Math.random() * 3)],
flatShading: true,
});
const building = new THREE.Mesh(geometry, material);
buildings.push(building);
scene.add(building);
}
// ... (后续代码) ...
THREE.BoxGeometry
: 立方体几何体,作为所有建筑的基础形状。for
循环: 循环 100 次以创建 100 个建筑物。buildings
数组: 创建一个数组来存储所有建筑物的实例。这对于后续统一管理和执行动画至关重要。Math.random()
: 通过随机函数从预设的colors
数组中为每个建筑选择一个颜色,增加了视觉的丰富性。scene.add(building)
: 在每次循环中,将新创建的建筑网格(Mesh)添加到场景中。
添加建筑动画
静态的城市缺乏活力。使用 GSAP (opens in a new tab) 动画库,可以为建筑物的尺寸和位置添加动态变化,使整个城市场景变得生动。
// ... (在 animate() 函数之前定义) ...
// 动画系统
function startAnimation() {
function animateLoop() {
buildings.forEach((building) => {
const duration = Math.random() * 0.6 + 0.3;
const specialHeight = Math.random() < 0.1 ? 15 : 0;
// 缩放动画
TweenMax.to(building.scale, duration, {
x: 1 + Math.random() * 3,
y: 1 + Math.random() * 20 + specialHeight,
z: 1 + Math.random() * 3,
ease: Power2.easeInOut,
});
// 位置动画
TweenMax.to(building.position, duration, {
x: -200 + Math.random() * 400,
z: -200 + Math.random() * 400,
ease: Power2.easeInOut,
});
});
// ... 暂未完成 ...
}
animateLoop();
}
// ... (在代码末尾调用) ...
startAnimation();
startAnimation
与animateLoop
: 定义了一个独立的动画逻辑。animateLoop
会被重复调用,以实现循环动画。TweenMax.to()
: 这是 GSAP 库的核心函数,用于创建补间动画。它会在指定的duration
(时长) 内,将目标对象的属性平滑地过渡到指定值。building.scale
: 控制建筑物的缩放。通过随机改变x
,y
,z
值,使建筑产生高低胖瘦不一的生长效果。building.position
: 控制建筑物的位置。通过随机改变x
,z
坐标,让建筑在地面上随机移动。ease: Power2.easeInOut
: 定义动画的缓动函数,使动画过程看起来更自然,具有加速和减速的效果。
实现相机环绕动画
为了更好地展示整个动态城市,需要让相机也运动起来。通过 GSAP,可以实现相机围绕场景中心进行平滑、随机的环绕飞行。
// ... (在相机定义之后) ...
camera.animAngle = 0; // 自定义属性:用于动画中的角度计算
camera.position.set(
Math.cos(camera.animAngle) * 400,
180,
Math.sin(camera.animAngle) * 400
);
// ... (在 animateLoop 函数内部) ...
function animateLoop() {
// ... (建筑物动画代码) ...
// 相机动画
TweenMax.to(camera, 1.5, {
animAngle: camera.animAngle + (Math.random() - 0.5) * 2 * Math.PI,
ease: Power1.easeInOut,
onUpdate: () => {
camera.position.x = Math.cos(camera.animAngle) * 400;
camera.position.z = Math.sin(camera.animAngle) * 400;
camera.updateProjectionMatrix();
camera.lookAt(scene.position);
},
});
// 循环控制
TweenMax.to(window, 3.5, { onComplete: animateLoop });
}
camera.animAngle
: 在相机对象上附加一个自定义属性,专门用于控制其在圆形轨道上的角度。直接对position
做动画会产生不自然的直线运动。Math.cos()
&Math.sin()
: 利用三角函数,将animAngle
角度转换为(x, z)
坐标,从而实现完美的圆形路径运动。onUpdate
: 这是 GSAP 动画过程中的一个回调函数。在animAngle
变化的每一帧,它都会被调用,用于实时更新相机的位置。camera.lookAt(scene.position)
: 确保相机在运动过程中,始终朝向场景的中心点(坐标0,0,0
),保持焦点稳定。TweenMax.to(window, 3.5, { onComplete: animateLoop });
: 这是一个巧妙的延迟调用。它创建了一个不产生任何视觉效果的动画,仅利用其onComplete
回调,在 3.5 秒后再次触发animateLoop
函数,从而实现整个动画序列的无限循环。
代码
https://github.com/calmound/threejs-demo/tree/main/city (opens in a new tab)