用最簡單方式打造Three.js 3D汽車展示廳

前言

在上一篇文章簡單粗略的描述了開發(fā)3D汽車展廳,筆者想再寫一篇比較詳細(xì)的教程。對于筆者來說Three.js說難不難,說簡單也不簡單。說簡單因?yàn)樗喕藢θS知識的理解,簡化了很多操作。說難是因?yàn)閍pi很多,要用熟也不是一朝半夕的時(shí)間。筆者在這給大家介紹一下以最簡單方式打造一個(gè)3D汽車展示廳。這個(gè)3D汽車展廳實(shí)現(xiàn)出來也不算完整,主要想讓同學(xué)們找找感覺,找些成就感,有感覺自己也有學(xué)下去的動(dòng)力。_

簡單粗略了解三維

在2D里只有兩個(gè)坐標(biāo),分別是X軸,和Y軸。在3D就多了一個(gè)Z軸。相信剛學(xué)3D的同學(xué)對X軸和Y軸都比較熟悉,Z軸是比較陌生,筆者建議大家可以上 three編輯器的網(wǎng)站嘗試創(chuàng)建一些幾何物體,找找對3D理解。

image.png

完整效果

屏幕錄制2021-07-08 下午1.39.03.gif

需要了解這幾個(gè)概念

筆者用舞臺表演來比如:

  1. 場景 Sence 相當(dāng)于在一個(gè)舞臺,在這里是布置場景物品和表演者表演的地方
  2. 相機(jī) Carma 相當(dāng)于觀眾的眼睛去觀看
  3. 幾何體 Geometry 相當(dāng)于舞臺的表演者
  4. 燈光 light 相當(dāng)于舞臺燈光照射
  5. 控制 Controls 相當(dāng)于這出舞臺劇的總導(dǎo)演

既然知道這幾個(gè)概念,我們就根據(jù)這幾大概念以函數(shù)形式區(qū)分,就很好理解。在這個(gè)three程序我分別創(chuàng)建了:
setScenesetCarmaloadfilesetLightsetControls分別對應(yīng)以上幾個(gè)概念

創(chuàng)建場景

首先我們還是用vue3的setup方式編寫,npm安裝three包, 引入 SceneWebGLRenderer 兩個(gè)對象,創(chuàng)建兩個(gè)變量 scenerenderer并賦值,這樣就簡單搭建了一個(gè)場景,場景背景默認(rèn)是黑色。創(chuàng)建一個(gè)init初始化函數(shù),并在onMounted調(diào)用

<script setup>
  import {onMounted} from 'vue'
  import { Scene,WebGLRenderer,PerspectiveCamera} from 'three'
  let scene,renderer
  //創(chuàng)建場景
  const setScene = ()=>{
        scene = new Scene()
        renderer = new WebGLRenderer()
        renderer.setSize(innerWidth, innerHeight)
        document.querySelector('.boxs').appendChild(renderer.domElement)
    }
   //初始化所有函數(shù) 
   const init = () => {
        setScene()
    }
    //用vue鉤子函數(shù)調(diào)用
    onMounted(init)
</script>

創(chuàng)建相機(jī)

有了場景就要加相機(jī),相機(jī)相當(dāng)于人的眼睛去觀察幾何物體,引入PerspectiveCamera
參數(shù)有4個(gè),具體可以看看官網(wǎng)文檔。然后通過實(shí)例方法position.set設(shè)置相機(jī)坐標(biāo)

<script setup>
  import { Scene,WebGLRenderer,PerspectiveCamera} from 'three'
  let scene,renderer,camera
  //相機(jī)的默認(rèn)坐標(biāo)
  const defaultMap = {
        x: 510,
        y: 128,
        z: 0,
    }
  //創(chuàng)建場景
  const setScene = ()=>{
        scene = new Scene()
        renderer = new WebGLRenderer()
        renderer.setSize(innerWidth, innerHeight)
        document.querySelector('.boxs').appendChild(renderer.domElement)
    }
  //創(chuàng)建相機(jī)  
  const setCamera = () => {
        const {x, y, z} = defaultMap
        camera = new PerspectiveCamera(60, innerWidth / innerHeight, 1, 1000)
        camera.position.set(x, y, z)
    }
   //初始化所有函數(shù) 
   const init = () => {
        setScene()
        setCamera()
    }
    //用vue鉤子函數(shù)調(diào)用
    onMounted(init)
</script>

引入特斯拉模型

image.png

在three我們除了可以通過api創(chuàng)建幾何物體,還可以引入第三方3d模型,具體可以上 sketchfab ,國外一個(gè)3d模型下載網(wǎng)站,里面有很多免費(fèi)的模型下載,這次用特斯拉汽車模型為例,下載一個(gè)gltf格式的3D模型。引入GLTFLoader 創(chuàng)建一個(gè)loadfile函數(shù)并通過Promise返回模型數(shù)據(jù),在init函數(shù)加上async調(diào)用loadfile得到返回模型數(shù)據(jù)并添加到場景scene

  import {GLTFLoader} from 'three/examples/jsm/loaders/GLTFLoader'
  import { Scene,WebGLRenderer,PerspectiveCamera} from 'three'
  let scene,renderer,camera,directionalLight,dhelper
  let isLoading = ref(true)
  let loadingWidth = ref(0)
  //相機(jī)的默認(rèn)坐標(biāo)
  const defaultMap = {
        x: 510,
        y: 128,
        z: 0,
    }
  //創(chuàng)建場景
  const setScene = ()=>{
        scene = new Scene()
        renderer = new WebGLRenderer()
        renderer.setSize(innerWidth, innerHeight)
        document.querySelector('.boxs').appendChild(renderer.domElement)
    }
  //創(chuàng)建相機(jī)  
  const setCamera = () => {
        const {x, y, z} = defaultMap
        camera = new PerspectiveCamera(60, innerWidth / innerHeight, 1, 1000)
        camera.position.set(x, y, z)
    }
    //通過Promise處理一下loadfile函數(shù)
     const loadFile = (url) => {
        return new Promise(((resolve, reject) => {
            loader.load(url,
                (gltf) => {
                    resolve(gltf)
                }, ({loaded, total}) => {
                    let load = Math.abs(loaded / total * 100)
                    loadingWidth.value = load
                    if (load >= 100) {
                        setTimeout(() => {
                            isLoading.value = false
                        }, 1000)
                    }
                    console.log((loaded / total * 100) + '% loaded')
                },
                (err) => {
                    reject(err)
                }
            )
        }))
    }
    
    //初始化所有函數(shù) 
   const init = async() => {
        const gltf =await loadFile('src/assets/3d/tesla_2018_model_3/scene.gltf')
        setScene()
        setCamera()
        scene.add(gltf.scene)
    }
    //用vue鉤子函數(shù)調(diào)用
    onMounted(init) 

創(chuàng)建燈光

汽車模型還看不見,所以我們要給它設(shè)置燈光,引入DirectionalLight,DirectionalLightHelper,HemisphereLight,HemisphereLightHelper,并設(shè)置燈光的參數(shù),使模型可見,并有些反射光面,陰影的效果,然后也在init函數(shù)調(diào)用 setLight,再增加loop函數(shù),使場景、照相機(jī)、模型不停循環(huán)調(diào)用。然后車模型就能看見啦,看到車出現(xiàn)的那一刻,好像自己的新買的一樣,_

image.png
  import {GLTFLoader} from 'three/examples/jsm/loaders/GLTFLoader'
 import { Scene,WebGLRenderer,PerspectiveCamera,   DirectionalLight,
        DirectionalLightHelper,
        HemisphereLight,
        HemisphereLightHelper} from 'three'
  let scene,renderer,camera,directionalLight,hemisphereLight,dhelper,hHelper
  let isLoading = ref(true)
  let loadingWidth = ref(0)
  //相機(jī)的默認(rèn)坐標(biāo)
  const defaultMap = {
        x: 510,
        y: 128,
        z: 0,
    }
  //創(chuàng)建場景
  const setScene = ()=>{
        scene = new Scene()
        renderer = new WebGLRenderer()
        renderer.setSize(innerWidth, innerHeight)
        document.querySelector('.boxs').appendChild(renderer.domElement)
    }
  //創(chuàng)建相機(jī)  
  const setCamera = () => {
        const {x, y, z} = defaultMap
        camera = new PerspectiveCamera(60, innerWidth / innerHeight, 1, 1000)
        camera.position.set(x, y, z)
    }
    //通過Promise處理一下loadfile函數(shù)
     const loadFile = (url) => {
        return new Promise(((resolve, reject) => {
            loader.load(url,
                (gltf) => {
                    resolve(gltf)
                }, ({loaded, total}) => {
                    let load = Math.abs(loaded / total * 100)
                    loadingWidth.value = load
                    if (load >= 100) {
                        setTimeout(() => {
                            isLoading.value = false
                        }, 1000)
                    }
                    console.log((loaded / total * 100) + '% loaded')
                },
                (err) => {
                    reject(err)
                }
            )
        }))
    }
    // 設(shè)置燈光
     const setLight = () => {
        directionalLight = new DirectionalLight(0xffffff, 0.5)
        directionalLight.position.set(-4, 8, 4)
        dhelper = new DirectionalLightHelper(directionalLight, 5, 0xff0000)
        hemisphereLight = new HemisphereLight(0xffffff, 0xffffff, 0.4)
        hemisphereLight.position.set(0, 8, 0)
        hHelper = new HemisphereLightHelper(hemisphereLight, 5)
        scene.add(directionalLight)
        scene.add(hemisphereLight)
    }
    //初始化所有函數(shù) 
   const init = async() => {
        const gltf = await loadFile('src/assets/3d/tesla_2018_model_3/scene.gltf')
        setScene()
        setCamera()
        setLight()
        scene.add(gltf.scene)
       loop()
    }
    //使場景、照相機(jī)、模型不停調(diào)用
     const loop = () => {
        requestAnimationFrame(loop)
        renderer.render(scene, camera)
    }
    //用vue鉤子函數(shù)調(diào)用
    onMounted(init) 

控制模型

屏幕錄制2021-07-08 下午12.04.36.gif

想用鼠標(biāo)自由旋轉(zhuǎn),或者自動(dòng)旋轉(zhuǎn),就要引用 OrbitControls對象,創(chuàng)建setControls函數(shù)也是在init調(diào)用,通過綁定change還可以監(jiān)聽坐標(biāo)變化,另外也要在loop函數(shù)增加
controls.update()才可以更新位置變化

  import { Scene,WebGLRenderer,PerspectiveCamera} from 'three'
  import {GLTFLoader} from 'three/examples/jsm/loaders/GLTFLoader'
  import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js'

  let scene,renderer,camera,directionalLight,hemisphereLight,dhelper,hHelper,controls
  let isLoading = ref(true)
  let loadingWidth = ref(0)
  //相機(jī)的默認(rèn)坐標(biāo)
  const defaultMap = {
        x: 510,
        y: 128,
        z: 0,
    }
  //創(chuàng)建場景
  const setScene = ()=>{
        scene = new Scene()
        renderer = new WebGLRenderer()
        renderer.setSize(innerWidth, innerHeight)
        document.querySelector('.boxs').appendChild(renderer.domElement)
    }
  //創(chuàng)建相機(jī)  
  const setCamera = () => {
        const {x, y, z} = defaultMap
        camera = new PerspectiveCamera(60, innerWidth / innerHeight, 1, 1000)
        camera.position.set(x, y, z)
    }
    //通過Promise處理一下loadfile函數(shù)
     const loadFile = (url) => {
        return new Promise(((resolve, reject) => {
            loader.load(url,
                (gltf) => {
                    resolve(gltf)
                }, ({loaded, total}) => {
                    let load = Math.abs(loaded / total * 100)
                    loadingWidth.value = load
                    if (load >= 100) {
                        setTimeout(() => {
                            isLoading.value = false
                        }, 1000)
                    }
                    console.log((loaded / total * 100) + '% loaded')
                },
                (err) => {
                    reject(err)
                }
            )
        }))
    }
    // 設(shè)置燈光
     const setLight = () => {
        directionalLight = new DirectionalLight(0xffffff, 0.5)
        directionalLight.position.set(-4, 8, 4)
        dhelper = new DirectionalLightHelper(directionalLight, 5, 0xff0000)
        hemisphereLight = new HemisphereLight(0xffffff, 0xffffff, 0.4)
        hemisphereLight.position.set(0, 8, 0)
        hHelper = new HemisphereLightHelper(hemisphereLight, 5)
        scene.add(directionalLight)
        scene.add(hemisphereLight)
    }
    // 設(shè)置模型控制
    const setControls = () => {
        controls = new OrbitControls(camera, renderer.domElement)
        controls.maxPolarAngle = 0.9 * Math.PI / 2
        controls.enableZoom = true
        controls.addEventListener('change', render)
    }
    const render = () => {
        map.x = Number.parseInt(camera.position.x)
        map.y = Number.parseInt(camera.position.y)
        map.z = Number.parseInt(camera.position.z)
    }
    //初始化所有函數(shù) 
   const init = async() => {
        const gltf =await loadFile('src/assets/3d/tesla_2018_model_3/scene.gltf')
        setScene()
        setCamera()
        setLight()
        setControls()
        scene.add(gltf.scene)
    }
    
   //使場景、照相機(jī)、模型不停調(diào)用和更新位置數(shù)據(jù)
     const loop = () => {
        requestAnimationFrame(loop)
        renderer.render(scene, camera)
        controls.update()

    }
    
    //用vue鉤子函數(shù)調(diào)用
    onMounted(init) 

改變車身顏色

到這里基礎(chǔ)的已經(jīng)搭建好了,接下來我們再加一個(gè)功能改變汽車車身顏色,也是展廳展示一個(gè)比較基礎(chǔ)的功能.創(chuàng)建一個(gè)setColor 這里說一下實(shí)例scene 有一個(gè)traverse函數(shù),它回調(diào)了所有模型的子模型信息,只要我們找到對應(yīng)name屬性,就可以更改顏色,和增加貼圖等等,
因?yàn)閷δP徒Y(jié)構(gòu)不怎熟悉,所以根據(jù)名字來猜了一下 找到door_前序的名字大概應(yīng)該就車身的套件。當(dāng)然如果細(xì)分到我只想改引擎蓋的顏色就要找出引擎蓋套件。當(dāng)然要很熟悉這個(gè)模型結(jié)構(gòu)了

 
    //設(shè)置車身顏色
      const setCarColor = (index) => {
        const currentColor = new Color(colorAry[index])
        scene.traverse(child => {
            if (child.isMesh) {
                console.log(child.name)
                if (child.name.includes('door_')) {
                    child.material.color.set(currentColor)
                }
            }
        })
    }
   

上完整代碼

其他操作都是交給vue控制,包括設(shè)置車身顏色、是否自動(dòng)轉(zhuǎn)動(dòng)等等。大家運(yùn)行以下代碼的時(shí)候記得用vite打包工具創(chuàng)建vue3模板

<template>
    <div class="boxs">
        <div class="maskLoading" v-if="isLoading">
            <div class="loading">
                <div :style="{width : loadingWidth +'%' }"></div>
            </div>
            <div style="padding-left: 10px;">{{parseInt(loadingWidth)}}%</div>
        </div>
        <div class="mask">
            <p>x : {{x}} y:{{y}} z :{{z}}</p>
            <button @click="isAutoFun">轉(zhuǎn)動(dòng)車</button>
            <button @click="stop">停止</button>
            <div class="flex">
                <div @click="setCarColor(index)" v-for="(item,index) in colorAry"
                     :style="{backgroundColor : item}"></div>
            </div>
        </div>
    </div>
</template>

<script setup>
    import {onMounted, reactive, ref, toRefs} from 'vue'
    import {
        Color,
        DirectionalLight,
        DirectionalLightHelper,
        HemisphereLight,
        HemisphereLightHelper,
        PerspectiveCamera,
        Scene,
        WebGLRenderer
    } from 'three'
    import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js'
    import {GLTFLoader} from 'three/examples/jsm/loaders/GLTFLoader'
    //車身顏色數(shù)組
    const colorAry = [
        "rgb(216, 27, 67)", "rgb(142, 36, 170)", "rgb(81, 45, 168)", "rgb(48, 63, 159)", "rgb(30, 136, 229)", "rgb(0, 137, 123)",
        "rgb(67, 160, 71)", "rgb(251, 192, 45)", "rgb(245, 124, 0)", "rgb(230, 74, 25)", "rgb(233, 30, 78)", "rgb(156, 39, 176)",
        "rgb(0, 0, 0)"] // 車身顏色數(shù)組 
    const loader = new GLTFLoader() //引入模型的loader實(shí)例
    const defaultMap = {
        x: 510,
        y: 128,
        z: 0,
    }// 相機(jī)的默認(rèn)坐標(biāo)
    const map = reactive(defaultMap)//把相機(jī)坐標(biāo)設(shè)置成可觀察對象
    const {x, y, z} = toRefs(map)//輸出坐標(biāo)給模板使用
    let scene, camera, renderer, controls, floor, dhelper, hHelper, directionalLight, hemisphereLight // 定義所有three實(shí)例變量
    let isLoading = ref(true) //是否顯示loading  這個(gè)load模型監(jiān)聽的進(jìn)度
    let loadingWidth = ref(0)// loading的進(jìn)度

    //創(chuàng)建燈光
    const setLight = () => {
        directionalLight = new DirectionalLight(0xffffff, 0.5)
        directionalLight.position.set(-4, 8, 4)
        dhelper = new DirectionalLightHelper(directionalLight, 5, 0xff0000)
        hemisphereLight = new HemisphereLight(0xffffff, 0xffffff, 0.4)
        hemisphereLight.position.set(0, 8, 0)
        hHelper = new HemisphereLightHelper(hemisphereLight, 5)
        scene.add(directionalLight)
        scene.add(hemisphereLight)
    }

    // 創(chuàng)建場景
    const setScene = () => {
        scene = new Scene()
        renderer = new WebGLRenderer()
        renderer.setSize(innerWidth, innerHeight)
        document.querySelector('.boxs').appendChild(renderer.domElement)

    }
    
    // 創(chuàng)建相機(jī)
    const setCamera = () => {
        const {x, y, z} = defaultMap
        camera = new PerspectiveCamera(60, innerWidth / innerHeight, 1, 1000)
        camera.position.set(x, y, z)
    }
    
    // 設(shè)置模型控制
    const setControls = () => {
        controls = new OrbitControls(camera, renderer.domElement)
        controls.maxPolarAngle = 0.9 * Math.PI / 2
        controls.enableZoom = true
        controls.addEventListener('change', render)
    }
    
    //返回坐標(biāo)信息
     const render = () => {
        map.x = Number.parseInt(camera.position.x)
        map.y = Number.parseInt(camera.position.y)
        map.z = Number.parseInt(camera.position.z)
    }
    
    
 
    // 循環(huán)場景 、相機(jī)、 位置更新
    const loop = () => {
        requestAnimationFrame(loop)
        renderer.render(scene, camera)
        controls.update()
    }
    
 
    //是否自動(dòng)轉(zhuǎn)動(dòng)
    const isAutoFun = () => {
        controls.autoRotate = true
    }
    //停止轉(zhuǎn)動(dòng)
    const stop = () => {
        controls.autoRotate = false
    }

    //設(shè)置車身顏色
    const setCarColor = (index) => {
        const currentColor = new Color(colorAry[index])
        scene.traverse(child => {
            if (child.isMesh) {
                console.log(child.name)
                if (child.name.includes('door_')) {
                    child.material.color.set(currentColor)
                }
            }
        })
    }
    
    const loadFile = (url) => {
        return new Promise(((resolve, reject) => {
            loader.load(url,
                (gltf) => {
                    resolve(gltf)
                }, ({loaded, total}) => {
                    let load = Math.abs(loaded / total * 100)
                    loadingWidth.value = load
                    if (load >= 100) {
                        setTimeout(() => {
                            isLoading.value = false
                        }, 1000)
                    }
                    console.log((loaded / total * 100) + '% loaded')
                },
                (err) => {
                    reject(err)
                }
            )
        }))
    }
    
    
       //初始化所有函數(shù)
    const init = async () => {
        setScene()
        setCamera()
        setLight()
        setControls()
        const gltf = await loadFile('src/assets/3d/tesla_2018_model_3/scene.gltf')
        scene.add(gltf.scene)
        loop()
    }
      //用vue鉤子函數(shù)調(diào)用
    onMounted(init) 
</script>

<style>
    body {
        margin: 0;
    }

    .maskLoading {
        background: #000;
        position: fixed;
        display: flex;
        justify-content: center;
        align-items: center;
        top: 0;
        left: 0;
        bottom: 0;
        right: 0;
        z-index: 1111111;
        color: #fff;
    }

    .maskLoading .loading {
        width: 400px;
        height: 20px;
        border: 1px solid #fff;
        background: #000;
        overflow: hidden;
        border-radius: 10px;

    }

    .maskLoading .loading div {
        background: #fff;
        height: 20px;
        width: 0;
        transition-duration: 500ms;
        transition-timing-function: ease-in;
    }

    canvas {
        width: 100%;
        height: 100%;
        margin: auto;
    }

    .mask {
        color: #fff;
        position: absolute;
        bottom: 0;
        left: 0;
        width: 100%;
    }

    .flex {
        display: flex;
        flex-wrap: wrap;
        padding: 20px;

    }

    .flex div {
        width: 10px;
        height: 10px;
        margin: 5px;
        cursor: pointer;
    }
</style>

最后

在這個(gè)3D汽車展示廳筆者只是簡單創(chuàng)建了一些基礎(chǔ)功能,還有很多功能可以增加,比如創(chuàng)建背景、地板、一些陰影、定點(diǎn)顯示車套件位置信息等等。掌握套路,這些功能實(shí)現(xiàn)也不難。希望大家如果喜歡能給小弟點(diǎn)個(gè)贊,謝謝啦 _

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容