最近在b站上面看到up主:gamemcu的3D作品,着实让人感到非常震撼,作品中的SU7模型,利用的是Blender进行建模,利用了webGL的技术进行开发。本次编写效果如下:
初识Three
gamemcu大佬的作品
su7xiaomi su7 made by gamemcuhttps://gamemcu.com/su7/
官网学习
利用了一天的时间,到官方网站(Three.js – JavaScript 3D Library)看了documentation、examples和editor。到b站中看了视频,学习了基础的知识。
首页也有推荐一些教学视频,比如resource中提到的Three.js Journery。也是up主:gamemcu推荐去看的,在b站中可以找到免费的教学视频
Three.js Journey(新版-双语字幕)_哔哩哔哩_bilibili
源码学习
Three.js是开源的,可以把源码下载来看,官网里的所有examples在源码里面都有demo。
docs:是官网的文档,网络卡的时候,可以用本地的看。
examples:放的是所有的demo,可以学习到官方的编码方式
editor:是模型编辑器,可视化编辑模型,提高在编写代码时的效率。
初识Blender
大概了解部分Three.js知识后,还需要了解一下建模的软件-Blender,这款软件功能强大,而且是开源的。当需要给模型添加一些动画效果,可能就要对模型进行修改。就可以用Blender对模型进行建模处理了。如果想更加深入,可以在Blender建好模型,添加好动画,然后直接Three.js导入Animation。
开发作品
经过这几天的基础学习和准备,现在可以着手开发了。
搭建场景
首先是搭建基本场景
function init() {
// 初始化场景
const initScene = () =>{
scene = new THREE.Scene();
scene.background = new THREE.Color( 0xa0a0a0 );
scene.fog = new THREE.Fog( 0xa0a0a0, 4, 20 );
}
// 初始化相机
const initCamera = () =>{
camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 100 );
camera.position.set( 10, 20, 10 );
}
// 初始化半球光
const hemiLight = () =>{
const hemiLight = new THREE.HemisphereLight( 0xffffff, 0x444444, 3 );
hemiLight.position.set( 0, 20, 0 );
scene.add( hemiLight );
}
// 初始化平行光
const initLight = () =>{
dLight = new THREE.DirectionalLight( 0xffffff, 3 );
dLight.position.set( 0, 20, 10 );
dLight.castShadow = true;
dLight.shadow.camera.top = 2;
dLight.shadow.camera.bottom = - 2;
dLight.shadow.camera.left = - 2;
dLight.shadow.camera.right = 2;
scene.add( dLight );
}
// 初始化地面
const initGround = () =>{
const ground = new THREE.Mesh( new THREE.PlaneGeometry( 40, 40 ), new
THREE.MeshPhongMaterial( { color: 0xbbbbbb, depthWrite: false } ) );
ground.rotation.x = - Math.PI / 2;
ground.receiveShadow = true;
scene.add( ground );
}
// 初始化地面网格
const initGrid = () =>{
const grid = new THREE.GridHelper( 40, 20, 0x000000, 0x000000 );
grid.material.opacity = 0.2;
grid.material.transparent = true;
scene.add( grid );
}
// 初始化场景辅助标线
const initAxesHelper = () =>{
let axesHelper = new THREE.AxesHelper( 3 );
scene.add( axesHelper );
}
}
导入模型
这里提前导入了两个模型,同时利用回调,添加了加载模型过程的progress。
// 模型1
function loadStaticScene(){
return new Promise(resolve => {
let loader = new GLTFLoader();
const url = "../models/gltf/WM161_zhankai_1k.glb"
loader.load(url, (gltf) => {
gltf.scene.position.set(0,27,0)
gltf.scene.scale.set(15, 15, 15);
resolve(gltf);
},(event) =>{
const {loaded,total} = event
handleLoadProgress({url,loaded,total})
});
})
}
// 模型2
function loadStaticScene_v11(){
return new Promise(resolve => {
let loader = new GLTFLoader();
const url = "../models/gltf/wm161_v11_zhedie_1k.glb"
loader.load(url, (gltf) => {
gltf.scene.position.set(0,-27,0)
gltf.scene.scale.set(15, 15, 15);
resolve(gltf);
},(event) =>{
const {loaded,total} = event
handleLoadProgress({url,loaded,total})
});
})
}
// 用于加载
function handleLoadProgress({url, loaded, total}){
const percentage = ((loaded / total) * 100).toFixed(2);
if (/.*\.(blob|glb)$/i.test(url)) {
updateLoadingProgress(`加载场景模型:${percentage}%`);
}
}
function updateLoadingProgress(loading_text){
progress && (progress.textContent = loading_text);
}
加载模型
可用看到模型加载的时候,出现了加载进度。
loadScenes(); // 先加载模型
async function loadScenes(){
gltf = await loadStaticScene(); // 加载模型1
gltf_v11 = await loadStaticScene_v11(); // 加载模型2
showLoading(); // loading过程
handleIntoScene(); // 进入场景
}
模型被加载到场景
工作台
为了更换操作模型,在页面添加一个工作台。同时加上标题,丰富画面。
这是一个普通HTML编写的工作台 ,加了mac-docker动画效果
<div class="dock_con display-none">
<div class="dock">
<div class="dock-item">
<i class="icon icon_fly" data-name="起飞"></i>
</div>
<div class="dock-item">
<i class="icon icon_color" data-name="颜色贴图"></i>
</div>
<div class="dock-item">
<i class="icon icon_check" data-name="模型切换"></i>
</div>
<div class="dock-item">
<i class="icon icon_setup" data-name="设置"></i>
</div>
</div>
</div>
切换模型
点击切换的时候,利用TWEEN进行动画式的切换效果
function handleDockClick() {
// 点击切换
const handleCheck = () =>{
isCheck = !isCheck;
const gltf_vector = isCheck ? new THREE.Vector3(0,0,0): new THREE.Vector3(0,27,0)
const gltf_v11_vector = isCheck ? new THREE.Vector3(0,0,0): new THREE.Vector3(0,-27,0)
new TWEEN.Tween( gltf.scene.position)
.to({ x: gltf_vector.x, y: gltf_vector.y, z: gltf_vector.z }, 500)
.easing(TWEEN.Easing.Quadratic.Out)
.start();
new TWEEN.Tween( gltf_v11.scene.position)
.to({ x: gltf_v11_vector.x, y: gltf_v11_vector.y, z: gltf_v11_vector.z }, 500)
.easing(TWEEN.Easing.Quadratic.Out)
.start();
}
const handleColor = () =>{}
const handleFly = () =>{}
// 点击事件的事件委托
delegate(dock, 'click', 'i', function(e){
const DOCK_MAP = [
{
target: ()=> e.target.classList.contains('icon_check'),
handler: handleCheck
},
{
target: ()=> e.target.classList.contains('icon_color'),
handler: handleColor
},
{
target: ()=> e.target.classList.contains('icon_fly'),
handler: handleFly
}
]
const event = DOCK_MAP.find(item => item.target());
if (event) {
event.handler();
}
})
}
操作模型
这里给模型添加一个起飞的动画效果。(由于帧率问题gif图的机翼旋转看上去会有点奇怪)
async function loadScenes(){
gltf = await loadStaticScene();
gltf_v11 = await loadStaticScene_v11();
setRotateAnimation(gltf) // 缓存飞起动画
showLoading();
handleIntoScene();
}
// 在加载模型的时候,先给模型加入动画进行缓存
function setRotateAnimation(gltf){
gltf.scene.traverse((e)=>{
const polySurface_number = e.name.replace(/polySurface/g,'')
const rotatePoly = ['58','81','90','102']
if (rotatePoly.includes(polySurface_number)){
const rotation = e.rotation
const tween = new TWEEN.Tween( e.rotation)
.to({x:rotation.x,y:Math.PI * 2,z:rotation.z}, 50)
.easing(TWEEN.Easing.Linear.None)
.repeat(Infinity)
flyPoly.push(tween)
}
})
}
function handleDockClick() {
const handleCheck = () =>{}
const handleColor = () =>{}
// 起飞
const handleFly = () =>{
isFly = !isFly
flyPoly.forEach(item=>{
if (isFly){
item && item.start()
}else {
item && item.stop()
}
})
}
// 点击事件的事件委托
delegate(dock, 'click', 'i', function(e){
const DOCK_MAP = [
{
target: ()=> e.target.classList.contains('icon_check'),
handler: handleCheck
},
{
target: ()=> e.target.classList.contains('icon_color'),
handler: handleColor
},
{
target: ()=> e.target.classList.contains('icon_fly'),
handler: handleFly
}
]
const event = DOCK_MAP.find(item => item.target());
if (event) {
event.handler();
}
})
}
起飞这里为了给机翼有个由慢到快的加速效果,然后停止的时候也有个由快到慢的效果,动画的方式需要做个阻尼运动。因此,改变一下动画的方式,取消TWEEN动画,用requestAnimationFrame代替。(由于帧率问题gif图的机翼旋转看上去会有点奇怪)
function flyGltf(){
gltf.scene.traverse((e)=>{
const polySurface_number = e.name.replace(/polySurface/g,'')
const rotatePoly = ['60','81','90','94']
if (rotatePoly.includes(polySurface_number)){
e.rotation.y += flySeep
}
})
// flySeep 来控制节奏
if (flySeep <= 1 && isFly){
flySeep += 0.002
}else {
flySeep -= 0.002
}
if (flySeep <= 0){
flySeep = 0
}
}
function animate(){
requestAnimationFrame( animate );
updateJump();
controls.update();
TWEEN.update();
beginFly && flyGltf(); // 在requestAnimationFrame下执行动画,实现快慢节奏变换
isLoading && updateCamera();
!isLoading && hoverPoint();
renderer.render( scene, camera );
}
颜色板块
这里给页面添加一个色板,用于改变模型的颜色,当然也可以利用该方式去对模型进行其他操作
如何知道替换颜色的模型,是哪个部位呢?可以用在上文提到过的editor编辑器,模型导入进去后,点击某个部位,就可以看到他的名称是什么了。
function handleDockClick() {
const handleCheck = () =>{}
// 替换颜色。
const handleColor = () =>{
isAppendColor = !isAppendColor;
if (isAppendColor){
color_map.classList.add('show-color-map');
delegate(color_map, 'click', 'div', function(e){
setColorMap(e.target.style.backgroundColor)
})
}else {
color_map.classList.remove('show-color-map');
}
const setColorMap = (color) =>{
gltf.scene.traverse((e)=>{
const polySurface_number = e.name.replace(/polySurface/g,'')
const windBlade = ['21','101','201'] // 给名为这几个的模型,替换颜色
if (windBlade.includes(polySurface_number)){
e.material.color.set(color)
}
})
}
}
const handleFly = () =>{}
// 点击事件的事件委托
delegate(dock, 'click', 'i', function(e){
const DOCK_MAP = [
{
target: ()=> e.target.classList.contains('icon_check'),
handler: handleCheck
},
{
target: ()=> e.target.classList.contains('icon_color'),
handler: handleColor
},
{
target: ()=> e.target.classList.contains('icon_fly'),
handler: handleFly
}
]
const event = DOCK_MAP.find(item => item.target());
if (event) {
event.handler();
}
})
}
射线处理
射线处理,就是当鼠标移动到射线投射的位置上时候,可以显示出对应的模型信息和知道当前鼠标位置是否在某个模型上面,一般用于碰撞检测。我这里只用来显示一些大概的内容。
function hoverPoint(){
if (mouse.x === 0 && mouse.y ===0) return;
raycaster.setFromCamera(mouse, camera); // 投射到鼠标上面
let intersects = raycaster.intersectObjects(scene.children); // 作用到场景
if(INTERSECTED) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );
if (intersects.length && intersects[0].object.name){
INTERSECTED = intersects[0].object; // 用一个变量来缓存被投射到的模型信息
// 这里改变的是变量中保存的模型信息,就不会改变到原本模型的信息,也有利用做其他操作
INTERSECTED.currentHex = INTERSECTED.material.emissive.getHex();
INTERSECTED.material.emissive.setHex( 0x8F8BFF );
showTip(INTERSECTED?.material.name)
}else {
if (INTERSECTED) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );
INTERSECTED = null;
tip.style.display = 'none';
}
}
// 监听鼠标移动事件,这里需要对鼠标做一个处理
canvas.addEventListener('mousemove', function(event) {
client = {
x: event.clientX,
y: event.clientY
}
clearMouse();
mouse.x = (event.clientX / window.innerWidth) * 2 - 1; // -1 ~ 1
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; // -1 ~ 1
});
射线的选中效果也可以换另外一种方式,例如下面这种高亮边方式
const initRender = () =>{
renderer = new THREE.WebGLRenderer( { antialias: true,canvas:canvas } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.shadowMap.enabled = true;
// 后期处理
composer = new EffectComposer( renderer );
const renderPass = new RenderPass( scene, camera );
composer.addPass( renderPass );
outlinePass = new OutlinePass( new THREE.Vector2( window.innerWidth, window.innerHeight ), scene, camera );
composer.addPass( outlinePass );
// const textureLoader = new THREE.TextureLoader();
// textureLoader.load( '../image/tri_pattern.jpg', function ( texture ) {
// 用贴图方式设置高亮边
// outlinePass.patternTexture = texture;
// texture.wrapS = THREE.RepeatWrapping;
// texture.wrapT = THREE.RepeatWrapping;
//
// } );
// 高亮边的样式
outlinePass.edgeStrength = 10.0 // 边框的亮度
outlinePass.edgeGlow = 1 // 光晕[0,1]
outlinePass.usePatternTexture = false // 是否使用父级的材质
outlinePass.edgeThickness = 1.0 // 边框宽度
outlinePass.downSampleRatio = 1 // 边框弯曲度
outlinePass.pulsePeriod = 5 // 呼吸闪烁的速度
outlinePass.visibleEdgeColor.set(parseInt(0x00ff00)) // 呼吸显示的颜色
outlinePass.hiddenEdgeColor = new THREE.Color(0, 0, 0) // 呼吸消失的颜色
outlinePass.clear = true
const outputPass = new OutputPass();
composer.addPass( outputPass );
effectFXAA = new ShaderPass( FXAAShader );
effectFXAA.uniforms[ 'resolution' ].value.set( 1 / window.innerWidth, 1 / window.innerHeight );
effectFXAA.renderToScreen = true
composer.addPass( effectFXAA );
}
// 收集射线位置,调用前,先清空之前的收集
function addSelectedObject( object ) {
selectedObjects = [];
selectedObjects.push( object );
}
function hoverPoint(){
if (mouse.x === 0 && mouse.y ===0) return;
raycaster.setFromCamera(mouse, camera);
let intersects = raycaster.intersectObjects(scene.children);
if(INTERSECTED) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );
if (intersects.length && intersects[0].object.name && intersects[0].object.name !== 'sprite'){
INTERSECTED = intersects[0].object;
addSelectedObject( INTERSECTED ); // 收集射线到的区域并执行高亮边
outlinePass.selectedObjects = selectedObjects;
// INTERSECTED.currentHex = INTERSECTED.material.emissive.getHex();
// INTERSECTED.material.emissive.setHex( 0x8F8BFF );
showTip(INTERSECTED?.material.name)
}else {
// if (INTERSECTED) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );
INTERSECTED = null;
outlinePass.selectedObjects = []
tip.style.display = 'none';
}
}
动画处理
剩下的就是一些常用的CSS动画、CSS过渡动画和JS动画的细节处理了。当然这里还有一个加载完毕进场的阻尼效果。
function updateCamera() {
const vector = new THREE.Vector3()
targetPosition.addScaledVector(vector, 0.25);
const {x,y,z} = targetPosition
camera.position.x += (targetPosition.x - camera.position.x) * dampingFactor;
camera.position.y += (targetPosition.y - camera.position.y) * dampingFactor;
camera.position.z += (targetPosition.z - camera.position.z) * dampingFactor;
const cameraX = camera.position.x.toFixed(1);
const cameraY = camera.position.y.toFixed(1);
const cameraZ = camera.position.z.toFixed(1);
if (Number(cameraX) <= x && Number(cameraY) <= y && Number(cameraZ) <= z){
isLoading = false
title.classList.remove("display-none");
dock_con.classList.remove("display-none");
}
}
function animate(){
requestAnimationFrame( animate );
updateJump();
controls.update();
TWEEN.update();
isLoading && updateCamera();
!isLoading && hoverPoint();
renderer.render( scene, camera );
}
操作杆处理
给摄像机添加一个前后左右的类似操作杆的效果,然后移动的时候,视口中间出现一些跳动的文字对应操作的按键。
let mouse = new THREE.Vector2();
let velocity = new THREE.Vector3();
let up_vector = new THREE.Vector3(0, 1, 0);
let temp_vector = new THREE.Vector3();
let targetPosition = new THREE.Vector3(1.8, 1.9, 3.4);
// 跳动的文字
const throttledScrollHandler = throttle(function() {
const id = document.querySelector('.text-up')
new TextUp( {id:id,text: keyCode.toUpperCase()} ) // 在节流的控制下生成文字
}, 500);
// 操作杆
document.addEventListener('keydown', function(event) {
keyCode = event.key === ' '? 'space' : event.key
camera.position.addScaledVector(velocity, 0.5);
const angle = controls.getAzimuthalAngle();
const speed = 0.25
mouse = new THREE.Vector2(0,0)
switch (event.key) {
case 'w':
temp_vector.set(0, 0, -1).applyAxisAngle(up_vector, angle);
camera.position.addScaledVector(temp_vector, speed);
throttledScrollHandler()
break;
case 's':
temp_vector.set(0, 0, 1).applyAxisAngle(up_vector, angle);
camera.position.addScaledVector(temp_vector, speed);
throttledScrollHandler()
break;
case 'a':
temp_vector.set(-1, 0, 0).applyAxisAngle(up_vector, angle);
camera.position.addScaledVector(temp_vector, speed);
throttledScrollHandler()
break;
case 'd':
temp_vector.set(1, 0, 0).applyAxisAngle(up_vector, angle);
camera.position.addScaledVector(temp_vector, speed);
throttledScrollHandler()
break;
case ' ':
jump();
throttledScrollHandler()
break;
}
});
function updateJump(){
if (isJumping) {
camera.position.y += jumpVelocity;
jumpVelocity -= 0.05;
if (camera.position.y <= 1.5) {
camera.position.y = 1.5;
isJumping = false;
}
}
}
function jump() {
if (!isJumping) {
jumpVelocity = 0.3 * maxJumpHeight
console.log(jumpVelocity);
isJumping = true;
}
}
动态反射
给地面添加一个模型反射效果和一个shader反射效果。
材质添加反光贴图
cubeCamera.position.copy(camera.position);
将cubeCamera
的位置设置为与常规相机camera
相同的位置。在Three.js中,每个对象(包括相机)都有一个.position
属性,它是一个Vector3
对象,表示该对象在三维空间中的位置。.copy()
方法用于复制另一个Vector3
对象的位置到当前对象。
cubeCamera.position.y *= -1;
将cubeCamera
的y坐标(即高度)取反。换句话说,如果cubeCamera
原本在y=10的位置,执行这行代码后,它会被移动到y=-10的位置。这通常用于确保立方体相机能够捕获场景的背面(即常规相机背后的场景),因为立方体相机需要拍摄场景的六个方向(前、后、左、右、上、下)。
cubeCamera.update(renderer, scene);
更新cubeCamera
的六个纹理面。它告诉cubeCamera
在当前的位置和方向上,使用给定的renderer
(渲染器)和scene
(场景)来捕获场景的六个方向的图像,并将这些图像存储在cubeCamera
的立方体纹理中。
// 新建一个立方相机cubeCamera,用于存储立方体相机捕获的图像
cubeRenderTarget = new THREE.WebGLCubeRenderTarget(1024);
cubeRenderTarget.texture.type = THREE.HalfFloatType;
cubeCamera = new THREE.CubeCamera(0.1,200,cubeRenderTarget);
const initGround = () =>{
const planeGeometry = new THREE.PlaneGeometry( 40, 40 )
const meshPhongMaterial = new THREE.MeshStandardMaterial( {
color: 0x333333,
depthWrite: false,
roughness: 0.1,
metalness: 0.5,
envMap: cubeRenderTarget.texture, // 给材质添加一个环境贴图,实现反光效果
} )
const ground = new THREE.Mesh( planeGeometry, meshPhongMaterial);
ground.rotation.x = - Math.PI / 2;
ground.receiveShadow = true;
addShader( meshPhongMaterial ); // 给地面加入shader
scene.add( ground );
}
function animate(){
requestAnimationFrame( animate );
// 确保cubeCamera与常规相机camera在同一位置,但高度相反,然后更新cubeCamera的立方体纹理以
// 捕获场景的六个方向的图像。
cubeCamera.position.copy(camera.position);
cubeCamera.position.y *= -1;
cubeCamera.update(renderer,scene);
}
加入shader
vertexShader1
: 开始部分的顶点着色器代码。
vertexShader3
: 顶点着色器中的一段替换代码,包含 #include <fog_vertex>
的替换。
fragmentShader1
: 开始部分的片段着色器代码。
fragmentShader2
: 片段着色器中的一段替换代码,包含 #include <dithering_fragment>
的替换。
function addShader(material){
const vertexShader1 = `uniform float uSize;
varying vec2 vUv;
void main() {`;
const vertexShader3 = `#include <fog_vertex>
vUv=vec2(-position.x,-position.y)/uSize;`;
const fragmentShader1 = `varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSize;
void main() {`;
const fragmentShader2 = `#include <dithering_fragment>
float d=length(vUv);
if(d >= uTime&&d<=uTime+ 0.1) {
gl_FragColor.rgb = gl_FragColor.rgb+mix(uColor,gl_FragColor.rgb,1.0-(d-uTime)*10.0 )*0.5 ;
}`;
material.onBeforeCompile = (shader) => {
shaders.push(shader);
shader.uniforms.uSize = { value: 50 };
shader.uniforms.uTime = { value: 0.1 };
shader.uniforms.uColor = { value: new THREE.Color('#4ff9ff') };
shader.vertexShader = shader.vertexShader.replace('void main() {', vertexShader1);
shader.vertexShader = shader.vertexShader.replace(
'#include <fog_vertex>',
vertexShader3
);
shader.fragmentShader = shader.fragmentShader.replace('void main() {', fragmentShader1);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
fragmentShader2
);
};
}
function animate(){
if (shaders?.length) {
// 着色器动画
shaders.forEach((shader) => {
shader.uniforms.uTime.value += 0.008;
if (shader.uniforms.uTime.value >= 1) {
shader.uniforms.uTime.value = 0;
}
});
}
}
截取图片保存
通过获取场景,来实现拍照功能
function getGltfUrl() {
return new Promise((resolve) => {
renderer.render(scene, camera)
resolve(canvas.toDataURL('image/png'))
})
},
const handleTakePicture = async ()=> {
imageMask.src = await getGltfUrl();
photoMask.classList.remove('display-none');
showWhiteScreen()
}
源码地址
下载源码的时候,记得顺手留下个star ♥♥
GitHub - Trevor-Han/basis-three: 基于threejs的模型交互,包括方向控制、颜色贴图、阻尼效果、TWEEN动画、JavaScript动画、CSS3过渡等效果