前言
Threejs 可以实现特别多炫酷的 3D 效果,作为当下极度内卷的前端时代的前端 er,需要不断的提升自己,但是苦于 Three 的高质量资料较少,学起来有几分困难。
从去年最开始写 Threejs 专栏开始,就想写一个汽车展示平台,一直没能够很完善的实现,这次在 AI 的协助下,写一个 su7 和保时捷的展示 &对比平台,咱们就来较个真,看看到底咋好看?
通过本文,你能学到:
观看本文前,推荐先阅读:Threejs 从入门到实战专栏,建立起对 Three 的基本概念
准备工作
在实现一个 3D 展示平台之前,首先需要一些准备工作
获取模型
这里比较推荐 sketchfab,从里面下载一下,su7 的模型比较多, porsche_taycan 模型相对少一些,而且还不够精美
其他 3D 模型获取方式:free3d cgmodel cgtrade ...
模型有很多种格式,本文选用 GLTF 格式,根据扩展名可以分为两种
创建项目
建议使用 vue 或者 react 进行实现,避免引入 Threejs 有一堆莫名其妙的问题。
// 创建项目npm create vite@latest// 安装依赖npm i// 安装three.jsnpm i --save-dev three// 启动项目npm run dev
复制代码
搭建 Three 项目
搭建一个基础的 Three 演示代码,Threejs 中存在核心三要素
输入:在本仓库的基础上,使用 vue 写一个 threejs 演示代码。由于当前为 vue 仓库,输入指令限定一下范围,避免生成原生 javascript 代码
<template> <div id="container"></div></template>
<script>import * as THREE from "three";
export default { name: "ThreejsDemo", mounted() { this.initThreejs(); }, methods: { initThreejs() { const width = window.innerWidth; const height = window.innerHeight;
const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000); const renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height); document.getElementById("container").appendChild(renderer.domElement);
const geometry = new THREE.BoxGeometry(); const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
camera.position.z = 5;
const animate = () => { requestAnimationFrame(animate);
cube.rotation.x += 0.01; cube.rotation.y += 0.01;
renderer.render(scene, camera); };
animate(); }, },};</script>
<style>#container { width: 100%; height: 100%;}</style>
复制代码
重新运行程序,就可以实现一个转动的绿色正方体。另外,我比较习惯将 scene、renderer、camera 等变量定义为全局变量,方便后续使用,这里按需使用。
在 Threejs 开发中,在开发的过程中,官方提供的某些辅助功能,非常方便,这里也添加一下,简单的建议还是使用 MarsCode 直接生成就好。
输入:添加轨道控制器 control 和辅助线和窗口自适应
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
initThree() { // 创建坐标辅助线 const axesHelper = new THREE.AxesHelper(5); scene.add(axesHelper); // 创建轨道控制器 controls = new OrbitControls(camera, renderer.domElement); // 窗口自适应 window.addEventListener("resize", () => { const width = window.innerWidth / 2; const height = window.innerHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height); });}
复制代码
有可能还有一些更深层次的辅助需求,比如想看相机的位置,那可以启用相机位置指示器
// 创建相机位置指示器cameraHelper = new THREE.CameraHelper(camera);scene.add(cameraHelper);
复制代码
据我体验,只是在页面中多了一个灰色的坐标系,交叉点是相机指向的方向,目前没有感觉出什么用。
想获取相机的位置,因为在后续操作中,可以根据相机的位置进行一些复杂交互。
输入:能不能渲染出相机的位置,并可视化渲染的区域。
此次 MarsCode 没有给出满意的结果啊,反复问了几次都没有成功,依旧推荐 CameraHelper,遇到这种情形,不要头铁,换一种描述来做:
输入:尝试通过一个物体来模拟相机的位置
MarsCode 生成的代码如下:
camera.position.z = 5;
const markerGeometry = new THREE.SphereGeometry(0.1, 16, 16);const markerMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 });marker = new THREE.Mesh(markerGeometry, markerMaterial);// 将标记物体的位置设置为相机的位置marker.position.copy(camera.position);// 将标记物体添加到场景scene.add(marker);
const animate = () => { requestAnimationFrame(animate);
// 更新标记物体的位置为相机的位置 marker.position.copy(camera.position); cube.rotation.x += 0.01; cube.rotation.y += 0.01;
renderer.render(scene, camera);};
复制代码
非常有创意的实现方式,相当于在场景中创建了一个物体,物体的位置和相机重合,从而实现相机的可视化,注意物体的创建要在相机设定位置之后。而且,要删除掉 animate 中的 marker.position.copy(camera.position); 否则看不到物体。这也提醒了,使用 MarsCode 生成的代码,可能需要根据场景做一些动态调整。
到这里基本工作就完成了,下面开始核心部分开发。
核心开发
要实现一个 su7 vs 保时捷的 3D 汽车展示平台,要实现以下内容
加载 glb 模型
渲染展示场景
添加灯光
车体颜色修改
车体贴膜修改
场景切换
更多功能...
定义一个 Car 类,用于实例汽车。推荐借助 MarsCode 实现一个基础,然后再手动进行完善
// 定义 Car 类import * as THREE from "three";
export default class Car { constructor(model, scene, camera, controls, color, name) { this.model = model; this.scene = scene; this.camera = camera; this.controls = controls; this.color = color;
// 初始化方法 this.init(); }
init() { // 将汽车模型添加到场景中 this.scene.add(this.model); } // 修改颜色 changeColor(color) {} // 修改粗糙度 changeRoughness(roughness) {} // 修改金属度 changeMetalness(metalness) {}}
复制代码
加载模型
使用 MarsCode 帮助写一段加载 glb 模型的方法:定义一个 GLTF 模型加载的函数
// 引入 Three.js 和 GLTF 加载器import * as THREE from "three";import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
export default { name: "GLTFModelViewer", data() { return { model: null, }; }, methods: { loadGLTFModel() { const loader = new GLTFLoader(); loader.load( // 模型文件的路径 "/path/to/your/model.gltf", // 加载成功后的回调函数 (gltf) => { this.model = gltf.scene; this.scene.add(this.model); }, // 加载过程中的回调函数 (xhr) => { console.log((xhr.loaded / xhr.total) * 100 + "% loaded"); }, // 加载失败后的回调函数 (error) => { console.error(error); } ); }, }, mounted() { this.loadGLTFModel(); },};
复制代码
MarsCode 写的太完善了,给简化一下
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";import Car from "../model/Car";
export default { mounted() { this.initThree(); this.loadModel(); }, methods: { loadModel() { this.loader = new GLTFLoader(); // 分别传入两个汽车模型的 path this.loader.load(this.modelPath, (gltf) => { this.car = new Car(gltf.scene, scene, camera, controls, this.carColor); // 传递初始颜色 }); }, },};
复制代码
保时捷模型完全看不到,小米 su7 只能模模糊糊的看到一些轮廓。
代码开发中遇到未知的情形,还是推荐多问一些 MarsCode 或者豆包,问题是:需要设定光源才能实现预览
添加光源
输入:期望添加来自一个四面八方的光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);directionalLight.position.set(1, 1, 1);scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.8);directionalLight2.position.set(-1, -1, -1);scene.add(directionalLight2);
const directionalLight3 = new THREE.DirectionalLight(0xffffff, 0.8);directionalLight3.position.set(-1, 1, -1);scene.add(directionalLight3);
const directionalLight4 = new THREE.DirectionalLight(0xffffff, 0.8);directionalLight4.position.set(1, -1, 1);scene.add(directionalLight4);
复制代码
使用了一组四个方向的平行光 DirectionalLight 和一个环境光 AmbientLight,就可以成功看到 su7 和保时捷了
调整模型清晰度
两个车有点偏像素风,非常模糊,继续询问一下 MarsCode
设定一下抗锯齿和设备像素比
renderer = new THREE.WebGLRenderer({ antialias: true });renderer.setPixelRatio(window.devicePixelRatio);
复制代码
相较于 su7,保时捷显得有点小,这种情况修改一下相机位置,拉紧一些保时捷的相机,拉远一些 su7 的。
// su7camera.position.set(5, 5, 5);// 保时捷camera.position.set(-3, 3, 3);
复制代码
添加背景
输入:现在需要生成一个具有科技感的背景用于汽车模型的展示
上面给出的 prompt 就有些过于笼统了,导致这里和 MarsCode 拉扯了一会,后续补充了多组进一步提示,才实现展示背景。
createBackground() { // 创建背景网格 const geometry = new THREE.PlaneGeometry(100, 100);
// 使用 MeshPhysicalMaterial 实现金属效果 const material = new THREE.MeshPhysicalMaterial({ color: 0xcccccc, // 起始颜色:浅灰色 metalness: 0.5, // 金属度 roughness: 0.2, // 粗糙度 envMapIntensity: 1.0, // 环境贴图强度 });
// 加载环境贴图 // const loader = new THREE.CubeTextureLoader(); // const envMap = loader.load([ // 'path_to_positive_x.jpg', // 'path_to_negative_x.jpg', // 'path_to_positive_y.jpg', // 'path_to_negative_y.jpg', // 'path_to_positive_z.jpg', // 'path_to_negative_z.jpg', // ]);
// 将环境贴图应用到材质上 // material.envMap = envMap;
const plane = new THREE.Mesh(geometry, material); plane.rotation.x = -Math.PI / 2; scene.add(plane);
// 增加环境光的强度 const ambientLight = new THREE.AmbientLight(0xffffff, 1.5); // 增加到1.5 scene.add(ambientLight);
// 调整光源位置和强度 const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5); directionalLight.position.set(1, 1, 1); scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5); directionalLight2.position.set(-1, -1, -1); scene.add(directionalLight2);
const directionalLight3 = new THREE.DirectionalLight(0xffffff, 0.5); directionalLight3.position.set(-1, 1, -1); scene.add(directionalLight3);
const directionalLight4 = new THREE.DirectionalLight(0xffffff, 0.5); directionalLight4.position.set(1, -1, 1); scene.add(directionalLight4);},
复制代码
推荐使用环境贴图,一时没有找到合适的,就没有添加,本质上就是创建一个平面,使用 MeshPhysicalMaterial 进行渲染,效果如下:
环境贴图获取方式 MarsCode 也提供了一些,有需要可以参考一下
颜色修改
车颜色可以说就相当于 QQ 飞车的皮肤,好看的颜色,那简直炫酷极了。
输入:定义一串颜色选择按钮,点击相应按钮,汽车模型颜色替换为该颜色
// Car.jsclass Car { // ... changeColor(color) { this.color = color; this.model.material.color.set(color); // 更新颜色 }}
// Car.vue
export default { //... methods: { changeCarColor(color) { this.carColor = color; // 更新汽车模型的颜色 this.car.changeColor(color); }, },};
复制代码
上述实现是存在问题的,this.model.material.color.set(color) 抛出了错误。
既然 MarsCode 没有给出解决方案,先尝试一下豆包:现在在开发一个 three 汽车展示程序,已经成功加载了 glb 模型,如何实现模型的颜色变换
豆包给出了一种非常彻底的实现方式:
// 假设model是加载后的glb模型model.traverse((child) => { if (child.isMesh) { // 获取网格的材质 const material = child.material; // 设置新的颜色,这里以红色为例(0xff0000是红色的十六进制表示) material.color.set(0xff0000); }});
复制代码
由于只有 Mesh 节点支持颜色修改,遍历汽车模型的所有子节点,如果为 Mesh 节点,替换颜色。
颜色的替换实现了,现实世界中不可能整车是一个颜色吧,可以沿着豆包提供的思路进行思考:是否有方法可以只修改其中某几个部分的颜色?
在遍历的过程中,可以打印一下各个子 Mesh 节点,可以获取到汽车模型的各个组成部分,每个 Mesh 都存在 name 属性,通过 name 进行进一步约束,实现局部的颜色替换。
新的问题来了,如何获取车身的 name 那,总不能挨着去尝试吧?Threejs 非常任性的提供了一个在线编辑平台,支持各类模型的导入,同时可以详细的看到模型的组成。
在线编辑平台:https://threejs.org/editor/
以小米 su7 为例,选中车身,右侧场景部分展示详细信息,其中有一个 Car_body,操作箭头,可以将该部分从汽车整体中拉出。Car_body 即车身,也就是需要替换颜色的部分,但是注意该部分的名字叫 Object_18。同样方法获取保时捷的车身 name 为 Body_Textures_Body_1_0。
下面附上使用的颜色数组
const color= [{ name: '土豪金', color: '#ff9900', isSelected: true,},{ name: '传奇黑', color: '#343a40', isSelected: false,},{ name: '海蓝', color: '#409EFF', isSelected: false,},{ name: '玫瑰紫', color: '#6600ff', isSelected: false,},{ name: '银灰色', color: '#DCDFE6', isSelected: false,}],
复制代码
贴膜替换
贴膜替换本质上与颜色替换相同,只不过修改的是材质的金属度和粗糙度,因此借助 MarsCode 生成一组修改粗糙度和金属度的按钮。
输入:类比颜色变换,生成一组按钮来控制汽车模型的金属度和粗糙度变换
生成的核心代码:
<button v-for="(metalness, index) in metalnesses" :key="metalness.name" :style="{ backgroundColor: metalness.color, left: index * 100 + 'px' }" @click="changeCarMetalness(metalness.value)"> {{ metalness.name }}</button><button v-for="(roughness, index) in roughnesses" :key="roughness.name" :style="{ backgroundColor: roughness.color, left: index * 100 + 'px' }" @click="changeCarRoughness(roughness.value)"> {{ roughness.name }}</button>
复制代码
{metalnesses: [ { name: '低金属度', value: 0.2, color: '#888888', }, { name: '中金属度', value: 0.5, color: '#aaaaaa', }, { name: '高金属度', value: 0.8, color: '#cccccc', }, ], roughnesses: [ { name: '低粗糙度', value: 0.2, color: '#888888', }, { name: '中粗糙度', value: 0.5, color: '#aaaaaa', }, { name: '高粗糙度', value: 0.8, color: '#cccccc', }, ],}
复制代码
按钮的只渲染了 left 位置,没有写 bottom 会发生重合,手动调整一下。
其实代码遗漏了修改 Car 的金属度和粗糙度的代码,进一步交互一下:在 Car.js 中如何修改模型的粗糙度和金属度
// 假设你已经导入了必要的Three.js模块import * as THREE from 'three';
// 在Car类中添加方法来更改粗糙度和金属度changeRoughness(roughness) { this.model.traverse((child) => { if (child.isMesh) { const material = child.material; if (material instanceof THREE.MeshStandardMaterial || material instanceof THREE.MeshPhysicalMaterial) { material.roughness = roughness; } } });}
changeMetalness(metalness) { this.model.traverse((child) => { if (child.isMesh) { const material = child.material; if (material instanceof THREE.MeshStandardMaterial || material instanceof THREE.MeshPhysicalMaterial) { material.metalness = metalness; } } });}
复制代码
根据 color 部分的实现,可以添加上 name 的约束,避免修改整体汽车模型的粗糙度和金属度。
到这里,就可以实现基于粗糙度、金属度和颜色来实现车身的多种变化,具体效果如下:
驾驶位视角
最后实现一个复杂的效果,动态平滑切换到主驾驶位置,具体原理见下图:
可以想象一下驾车时候的感觉,然后将人的感觉转变成相机即可,也就是将相机位置固定在主驾驶处,相机朝向外面。
这一部分比较难通过 MarsCode 来实现,除非描述清楚具体的车辆位置、大小,这有些繁琐,只需要 MarsCode 提供大致怎么写,再陆续调整就行。
定义一个从内部看的函数,当该函数触发时,调整相机位置和指向
viewFromInside() { camera.position.set(-0.2, 0.9, -0.6); // 设置主驾驶视角目标点,假设看向汽车前方中心位置,比如(0.5, 0, 0.8) const targetPoint = new THREE.Vector3(2.8, 1.2, -0.4); camera.lookAt(targetPoint);}
复制代码
视角的转换非常突然,尝试添加一下动画效果,更圆滑的实现转变。动画也没必要重复造轮子,询问一下
gsap,一定注意时 gsap,总是习惯打成 gasp。
需要动态的修改相机的位置和相机的朝向,相机的位置可以通过 camera.position 修改,lookAt 是个瞬间操作,无法通过动画来实现。
输入:现在需要实现一个从汽车模型外部到内部的 gsap 动画切换效果,在修改 camera.position 的同时,同步修改相机指向的位置,这里需要怎么实现
// 假设你已经导入了必要的Three.js模块和GSAP库import * as THREE from 'three';import gsap from 'gsap';
// 在Car类中添加方法来实现视角切换动画viewFromOutsideToInside() { // 设置初始相机位置和目标点(外部视角) const initialCameraPosition = new THREE.Vector3(-5, 5, 5); const initialTargetPoint = new THREE.Vector3(0, 0, 0);
// 设置最终相机位置和目标点(内部视角) const finalCameraPosition = new THREE.Vector3(-0.2, 0.9, -0.6); const finalTargetPoint = new THREE.Vector3(2.8, 1.2, -0.4);
// 使用GSAP的to方法来动画相机位置 gsap.to(camera.position, { x: finalCameraPosition.x, y: finalCameraPosition.y, z: finalCameraPosition.z, duration: 2, ease: 'power2.inOut', onUpdate: () => { // 在动画过程中更新相机的目标点 camera.lookAt(initialTargetPoint.lerp(finalTargetPoint, gsap.utils.clamp(0, 1, gsap.getProperty(camera.position, 'x') / finalCameraPosition.x))); } });}
复制代码
效果很奇怪,跟预想的不同,尝试调整一下
当时尝试了很多提示词,都没能实现比较满意的效果,主要不知道如何描述所需场景。
后面问了豆包,OrbitControls 是用于围绕目标对象进行旋转、缩放和平移操作,存在一个 target 属性,定义了相机围绕点。动态地改变 target 属性的值,可以实现相机焦点的平滑转移。
那么将原来的 lookAt 位置设置为 target 的位置,就能实现相机位置修改的同时,围绕点也在旋转,代码如下:
viewFromInside() { // 设置相机位置到汽车内部 gsap.to(camera.position, { x: -0.2, y: 0.9, z: -0.4, duration: 2, ease: "power2.inOut", onUpdate: () => { // 在动画过程中更新相机的视角 camera.lookAt(controls.target); }, });
gsap.to(controls.target, { x: 2.8, y: 0.9, z: -0.4, duration: 2, ease: "power2.inOut", });},
复制代码
更多操作
上面实践了模型内部的遍历 traverse,通过 isMesh 和 name 锁定车身,借助 gsap 实现动画效果,这几个操作任意组合,能实现各式各样的效果。
例如去 https://threejs.org/editor/ 定位到车门所在 Mesh 为 Object_52。
添加一个按钮,点击按钮时,旋转车门
openCarDoor() { this.model.traverse((child) => { if (child.isMesh && child.name === 'Object_52') { gsap.to(child.rotation, { x: -Math.PI / 4, y: -Math.PI / 4, duration: 1, ease: 'power2.out' }); } })}
复制代码
诸如此类,整个模型就可以随意折腾了。
总结
本文实现了一个 su7 & 保时捷模型的展示,包括背景、颜色、金属度、粗糙度、视角切换等,从中可以吸收到非常多
loadModel() { this.loader = new GLTFLoader(); this.loader.load(this.modelPath, (gltf) => { // xxx });},
复制代码
在线预览模型,查看模型组成部分
实现颜色、金属度、粗糙度改变
借助 gsap 实现各类动画
另外,还是要反复提到 MarsCode 和豆包,作为开发的辅助,还是非常 nice 的,尤其是对于一些简单代码逻辑和开发所遇问题,是解答利器。
用好这两个东西,还是存在一定的难度,需求描述一定要清晰,步骤分解合理,循序渐进的进行描述。
源码
最后,附上粗糙的项目源码,有兴趣可以体验一下。
评论