Three.js案例
城市建筑与烟雾效果

城市建筑与烟雾效果

今天看到一个效果,感觉挺好看的吗,分享一下,完整的代码:https://codepen.io/vcomics/pen/aGmoae (opens in a new tab)

思路概述

  1. 创建基础环境
    • 初始化渲染器(renderer)并将其插入 HTML 页面
    • 设置自适应窗口大小的监听
    • 创建透视相机(PerspectiveCamera)和主场景(Scene)
  2. 设置场景“雾”和背景
    • 将场景背景色设为一个较鲜艳的颜色
    • 在一定范围内启用雾效(Fog),增强纵深感
  3. 准备容器对象
    • 三个 Object3Dcitysmoketown,分别承载不同类型的对象(建筑、烟雾、辅助网格等)
  4. 辅助函数
    • 例如生成随机数 mathRandom()、切换颜色函数 setTintColor() 等,为后续创建建筑做准备
  5. 创建城市(init 函数)
    • 随机生成多个立方体,拉伸成不同高度,模拟“建筑”
    • 在地面处生成一个大平面来承载阴影
    • 生成一些小圆形几何体充当“烟雾”或“粒子”效果
  6. 光照与环境
    • AmbientLight、SpotLight、PointLight 组合
    • 开启阴影并设置光源位置、强度等
  7. 生成“小车”移动
    • 利用 createCars() 函数在 X 或 Z 方向往返移动的小立方体
    • 通过 generateLines() 创建多辆“车”来回穿梭
  8. 鼠标或触屏事件
    • 通过射线(Raycaster)或简单的鼠标移动函数来记录并处理交互
    • city 容器根据鼠标移动进行轻微旋转
  9. 动画主循环
    • requestAnimationFrame(animate) 实现不断刷新
    • 每帧让建筑物、烟雾、“汽车”等进行动画或位置更新
    • 最后使用 renderer.render(scene, camera) 进行渲染
  10. 启动
  • 先调用 generateLines()init() 初始化场景
  • 最后进入 animate() 循环

通过以上简单思路,即可快速把握整段代码的核心脉络。下面是每部分的详细解析,便于深入理解实现原理。

一、项目基础结构与核心对象

1. 创建渲染器(Renderer)

var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
  • WebGLRenderer:Three.js 最常用的渲染器,使用 WebGL API 进行渲染。
  • antialias:是否开启抗锯齿。
  • renderer.setSize():设置渲染器的宽高为浏览器当前窗口的宽高。
if (window.innerWidth > 800) {
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  renderer.shadowMap.needsUpdate = true;
}
  • 判断屏幕宽度大于 800 时,开启并设置阴影图参数(阴影质量、阴影类型等)。
  • PCFSoftShadowMap 提供了柔和阴影的效果。
document.body.appendChild(renderer.domElement);
  • 将渲染器的 DOM 元素(即 <canvas> 标签)插入到页面中,确保能够在页面中看到渲染结果。

2. 动态自适应屏幕大小

window.addEventListener("resize", onWindowResize, false);
function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}
  • 监听浏览器窗口变化,更新摄像机(camera)的宽高比和投影矩阵。
  • 同时更新渲染器大小,保证场景在窗口大小变化时保持正确的比例。

3. 创建相机(Camera)与场景(Scene)

var camera = new THREE.PerspectiveCamera(20, window.innerWidth / window.innerHeight, 1, 500);
camera.position.set(0, 2, 14);
 
var scene = new THREE.Scene();
  • PerspectiveCamera:使用透视投影相机,参数分别是 视角FOV宽高比近截面远截面
  • camera.position.set():将相机放置在 (0, 2, 14) 的位置。
  • 创建场景后,可以将 3D 对象、光照等全部添加到场景中。

4. 创建主要容器对象

var city = new THREE.Object3D();
var smoke = new THREE.Object3D();
var town = new THREE.Object3D();
  • 分别创建了三个 Object3D 对象,用来承载不同分组的 Mesh 或者子物体。
    • city:在这段代码中,承载主要地面、光源以及格子辅助器等。
    • smoke:存放烟雾粒子(一些小圆点的集合)。
    • town:存放城市中生成的建筑群(一个个立方体)。

二、场景环境与辅助函数

1. 场景背景与雾效

var setcolor = 0xf02050;
scene.background = new THREE.Color(setcolor);
scene.fog = new THREE.Fog(setcolor, 10, 16);
  • 将场景的背景色设置为 0xF02050(红粉色)。
  • 使用 THREE.Fog 创建线性雾效,其中 10 和 16 分别是开始产生雾和彻底被雾覆盖的距离。

2. 随机数生成函数

function mathRandom(num = 8) {
  var numValue = -Math.random() * num + Math.random() * num;
  return numValue;
}
  • 生成一个范围为 -num ~ +num 的随机数。例如,mathRandom(8) 会在 -8 ~ 8 的区间内产生随机值。

3. 切换建筑颜色函数

var setTintNum = true;
function setTintColor() {
  if (setTintNum) {
    setTintNum = false;
    var setColor = 0x000000;
  } else {
    setTintNum = true;
    var setColor = 0x000000;
  }
  return setColor;
}
  • 这段代码看似想实现颜色在两个值之间切换,实际上因为同时设为 0x000000,所以颜色并没有真正变化。可以根据需求自行改成其它颜色以达到不同效果。

三、创建城市(建筑、地面等)

1. init() 函数:核心建筑生成逻辑

function init() {
  var segments = 2;
  for (var i = 1; i<100; i++) {
    var geometry = new THREE.CubeGeometry(1,0,0,segments,segments,segments);
    var material = new THREE.MeshStandardMaterial({
      color:setTintColor(),
      wireframe:false,
      side:THREE.DoubleSide
    });
    var wmaterial = new THREE.MeshLambertMaterial({
      color:0xFFFFFF,
      wireframe:true,
      transparent:true,
      opacity: 0.03,
      side:THREE.DoubleSide
    });
 
    var cube = new THREE.Mesh(geometry, material);
    var wire = new THREE.Mesh(geometry, wmaterial);
    var floor = new THREE.Mesh(geometry, material);
    var wfloor = new THREE.Mesh(geometry, wmaterial);
 
    cube.add(wfloor);
    cube.castShadow = true;
    cube.receiveShadow = true;
    cube.rotationValue = 0.1 + Math.abs(mathRandom(8));
 
    // 设置地板/建筑等整体缩放
    floor.scale.y = 0.05;
    cube.scale.y = 0.1 + Math.abs(mathRandom(8));
    cube.scale.x = cube.scale.z = 0.9 + mathRandom(1 - 0.9);
 
    // 随机定位
    cube.position.x = Math.round(mathRandom());
    cube.position.z = Math.round(mathRandom());
 
    floor.position.set(cube.position.x, 0, cube.position.z);
 
    town.add(floor);
    town.add(cube);
  };
  // ...
  1. 立方体几何体CubeGeometry(1,0,0,...) 时,高宽深中,高和深等被初始化为 0(其实 Three.js 的新版中推荐使用 BoxGeometry)。后续是通过 scale 对建筑进行拉伸和缩放。
  2. 材料
    • MeshStandardMaterial:一种能够实现金属度、粗糙度等物理属性的材质。
    • MeshLambertMaterial:通过 Lambert 光照模型进行计算,适合简单光照场景。
  3. 缩放cube.scale.y = 0.1 + Math.abs(mathRandom(8)) 让建筑物高度随机。
  4. 随机位置cube.position.xcube.position.z 根据 mathRandom() 生成,分布在较小范围内,可以自行调整使建筑分布得更稀疏或更密集。
  5. 添加到容器:最终将 floor(扁平立方体)与 cube(高楼)加到 town 这个 Object3D 中。

2. 生成烟雾粒子(smoke)

var gmaterial = new THREE.MeshToonMaterial({ color: 0xffff00, side: THREE.DoubleSide });
var gparticular = new THREE.CircleGeometry(0.01, 3);
for (var h = 1; h < 300; h++) {
  var particular = new THREE.Mesh(gparticular, gmaterial);
  particular.position.set(mathRandom(5), mathRandom(5), mathRandom(5));
  particular.rotation.set(mathRandom(), mathRandom(), mathRandom());
  smoke.add(particular);
}
  • 使用 CircleGeometry(小圆形)模拟“烟雾”或类似“粒子”。
  • 随机位置与旋转,让这些圆点分散在空间内。

3. 创建地面

  var pmaterial = new THREE.MeshPhongMaterial({
    color:0x000000,
    side:THREE.DoubleSide,
    roughness: 10,
    metalness: 0.6,
    opacity:0.9,
    transparent:true
  });
  var pgeometry = new THREE.PlaneGeometry(60,60);
  var pelement = new THREE.Mesh(pgeometry, pmaterial);
  pelement.rotation.x = -90 * Math.PI / 180;
  pelement.position.y = -0.001;
  pelement.receiveShadow = true;
 
  city.add(pelement);
};
  • 用一个 PlaneGeometry(平面几何)来作为地面。
  • 这里将 rotation.x 设为 -90° 让其与 XZ 平面重合。
  • position.y 设为 -0.001,略微降低一点点,避免与其他物体(建筑)重叠产生闪烁。

四、鼠标交互事件

1. 射线(Raycaster)与鼠标坐标

var raycaster = new THREE.Raycaster();
var mouse = new THREE.Vector2(),
  INTERSECTED;
var intersected;
 
function onMouseMove(event) {
  event.preventDefault();
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
  • Raycaster:用于鼠标与场景中物体的碰撞检测。这里仅初始化了对象,并把当前鼠标位置存储在 mouse 中。
  • 之后可以使用 raycaster.setFromCamera(mouse, camera) 来检测射线与物体的交点。

2. 移动端触控事件

function onDocumentTouchStart(event) {
  if (event.touches.length == 1) {
    event.preventDefault();
    mouse.x = event.touches[0].pageX - window.innerWidth / 2;
    mouse.y = event.touches[0].pageY - window.innerHeight / 2;
  }
}
  • 用于在移动端获取触摸点的位置。此处的写法与 PC 端略有不同,是为了兼容移动端。

五、灯光设置

var ambientLight = new THREE.AmbientLight(0xffffff, 4);
var lightFront = new THREE.SpotLight(0xffffff, 20, 10);
var lightBack = new THREE.PointLight(0xffffff, 0.5);
 
lightFront.position.set(5, 5, 5);
lightFront.castShadow = true;
// ...
lightBack.position.set(0, 6, 0);
 
smoke.position.y = 2;
 
scene.add(ambientLight);
city.add(lightFront);
scene.add(lightBack);
scene.add(city);
city.add(smoke);
city.add(town);
  1. ambientLight:环境光,给场景一个整体的、无方向的光照。
  2. lightFront:聚光灯(SpotLight),能产生方向性的阴影效果。设置了相当高的强度 20,并且可投射阴影。
  3. lightBack:点光源(PointLight),强度 0.5,放在稍高处 (0, 6, 0)。
  4. 最后将这些光源及容器对象整合到场景中。

六、网格辅助线(GridHelper)

var gridHelper = new THREE.GridHelper(60, 120, 0xff0000, 0x000000);
city.add(gridHelper);
  • GridHelper 可以帮助我们在调试或场景开发初期,看清坐标轴地面位置。
  • 60 代表网格的大小,120 表示分割数量。0xFF00000x000000 分别是网格线的两种颜色(中心线和其他线)。

七、小方块移动

1. createCars() 函数

var createCars = function (cScale = 2, cPos = 20, cColor = 0xffff00) {
  var cMat = new THREE.MeshToonMaterial({ color: cColor, side: THREE.DoubleSide });
  var cGeo = new THREE.CubeGeometry(1, cScale / 40, cScale / 40);
  var cElem = new THREE.Mesh(cGeo, cMat);
  var cAmp = 3;
 
  if (createCarPos) {
    createCarPos = false;
    cElem.position.x = -cPos;
    cElem.position.z = mathRandom(cAmp);
 
    TweenMax.to(cElem.position, 3, {
      x: cPos,
      repeat: -1,
      yoyo: true,
      delay: mathRandom(3),
    });
  } else {
    createCarPos = true;
    cElem.position.x = mathRandom(cAmp);
    cElem.position.z = -cPos;
    cElem.rotation.y = (90 * Math.PI) / 180;
 
    TweenMax.to(cElem.position, 5, {
      z: cPos,
      repeat: -1,
      yoyo: true,
      delay: mathRandom(3),
      ease: Power1.easeInOut,
    });
  }
  cElem.receiveShadow = true;
  cElem.castShadow = true;
  cElem.position.y = Math.abs(mathRandom(5));
  city.add(cElem);
};
  1. 每次调用 createCars,会随机生成一个小立方体,代表一辆车。
  2. 通过 TweenMax.to (GreenSock 动画库) 来实现自动往返移动:
    • createCarPos 为 true,汽车在 x 轴与 z 轴的初始位置不一样,控制它在 X 轴或 Z 轴移动往返。
    • repeat:-1 表示无限循环,yoyo:true 表示移动会来回往返。

2. generateLines() 函数

var generateLines = function () {
  for (var i = 0; i < 60; i++) {
    createCars(0.1, 20);
  }
};
  • 调用 60 次 createCars,生成 60 辆“车”在场景中持续运动。

八、相机运动

var cameraSet = function () {
  createCars(0.1, 20, 0xffffff);
};
  • 只是额外创建了一辆车,颜色为白色,可根据需要持续调用,让场景更丰富。

九、动画循环与交互 (animate() 函数)

var animate = function () {
  var time = Date.now() * 0.00005;
  requestAnimationFrame(animate);
 
  city.rotation.y -= (mouse.x * 8 - camera.rotation.y) * uSpeed;
  city.rotation.x -= (-(mouse.y * 2) - camera.rotation.x) * uSpeed;
  if (city.rotation.x < -0.05) city.rotation.x = -0.05;
  else if (city.rotation.x > 1) city.rotation.x = 1;
 
  // 烟雾缓慢旋转
  smoke.rotation.y += 0.01;
  smoke.rotation.x += 0.01;
 
  camera.lookAt(city.position);
  renderer.render(scene, camera);
};
  1. 通过 requestAnimationFrame 进行循环渲染,每一帧都调用 animate
  2. 根据 mouse.xmouse.y 动态调整 city 的旋转,让整个城市跟随鼠标视角进行一些摆动。
  3. 设置 city.rotation.x 上下限,避免过度倾斜。
  4. 烟雾(smoke)在每帧做微小旋转。
  5. camera.lookAt(city.position):让相机始终朝向城市的中心。
  6. 最后使用 renderer.render(scene, camera) 完成本帧的渲染。