使用ThreeJs從零開始構建3D智能倉庫——第三章(選中物體與特效)

寫在前面——目錄結構

這一章我們來完成激動人心的,關於如何鼠標單擊選中一個物體,並讓該物體周圍閃爍白光,並在鼠標點擊位置的上方顯示該物體的名字。如下圖所示:
2019.11.26 更新:我最近建立了個人網站,大家可以訪問下面的鏈接查看演示
3D倉庫演示
2019.11.28 更新:代碼和圖片資源等已上傳至GitHub
https://github.com/xiao149/ThreeJsDemo
在這裏插入圖片描述
在開始這章內容的講解之前,我想先給大家推薦下項目的目錄結構,因爲這一章我們將要引入自己寫的一個JS文件ThreeJs_Composer.js,該JS的作用就是添加選中特效和顯示物體的名字等等一系列功能。我們這個3D倉庫未來將會用到很多不同的功能,全部寫進主頁面的HTML會導致代碼過長,可閱讀性下降。所以我推薦大家將不同的功能封裝成不同的JS(比如選中的效果、拖動物體、顯示提示信息、加載模型等等),需要用到的時候再導入這些JS就會方便易懂很多。
我的目錄結構如下:
在這裏插入圖片描述
稍微介紹下,layui是一個開源的UI框架,最近我正在研究,前臺向來是我這種初學者的痛啊!現在還用不到,大家略過。ThreeJs放着我們需要用的一切,images存放圖片,js存放ThreeJs原生的JS,pace是第三方的加載控件,這裏也不用管,剩下的是我們自己創建的JS和主頁面test.html,今天我們主要來研究ThreeJs_Composer.js這個文件。

如何選中物體

選中物體的原理

鑑於我也是從各種百度到的地方瞭解到這些內容的,只能說是知其然而不知其所以然,再次我就僅僅描述我所知道的東西,若有所紕漏還請原諒~~
選中一個物體我們需要依靠Raycaster這個工具,其實質是在你鼠標所指的地方(如下圖)發射一條射線,對我們可以想象一條直線,垂直你的電腦屏幕,在你鼠標的位置,從屏幕外直直指向屏幕內,系統將會獲取到該射線依次經過的物體。
在這裏插入圖片描述
舉個栗子,我們可以在瀏覽器中按下F12打開調試模式,我這時點擊了一個窗戶,進入斷點在我紅色箭頭所指地方的intersects便保存了射線依次經過的物體,分別是窗戶、地面、地面。
在這裏插入圖片描述
在這裏插入圖片描述
換一個比較刁鑽的角度,這次我點擊了最左側的門,由下圖可見射線返回了兩個物體,分別是左門1和左邊的那個牆面:
在這裏插入圖片描述

選中物體的實現

經過上面的描述,我想大家對如何點擊選中一個物體有了初步的瞭解,現在我們可以來創建ThreeJs_Composer這個自定義的JS了:

/*
  * 需要在jsp中導入的包
  <script src="./ThreeJs/three.js"></script>
  <script src="./ThreeJs/EffectComposer.js"></script>
  <script src="./ThreeJs/RenderPass.js"></script>
  <script src="./ThreeJs/OutlinePass.js"></script>
  <script src="./ThreeJs/FXAAShader.js"></script>
  <script src="./ThreeJs/ShaderPass.js"></script>
  <script src="./ThreeJs/CopyShader.js"></script>
  */

THREE.ThreeJs_Composer = function ( _renderer, _scene, _camera) {
    var raycaster = new THREE.Raycaster();
  	var mouse = new THREE.Vector2();
    var selectedObjects = [];

    window.addEventListener( 'click', onMouseClick);
    
    function onMouseClick( event ) {
        var x, y;
        if ( event.changedTouches ) {
            x = event.changedTouches[ 0 ].pageX;
            y = event.changedTouches[ 0 ].pageY;
        } else {
            x = event.clientX;
            y = event.clientY;
        }
        mouse.x = ( x / window.innerWidth ) * 2 - 1;
        mouse.y = - ( y / window.innerHeight ) * 2 + 1;
        raycaster.setFromCamera( mouse, _camera );
        var intersects = raycaster.intersectObjects( [ _scene ], true );

        if(intersects.length == 0){
            $("#label").attr("style","display:none;");//隱藏說明性標籤
            return;
        }
        if(intersects[0].object.name == "地面" || (intersects[0].object.name == "") || (intersects[0].object.name == "牆面")){
            $("#label").attr("style","display:none;");//隱藏說明性標籤
            selectedObjects.pop();
        }else{
            $("#label").attr("style","display:block;");// 顯示說明性標籤
            $("#label").css({left: x, top: y-40});// 修改標籤的位置
            $("#label").text(intersects[0].object.name);// 顯示模型信息

            selectedObjects.pop();
            selectedObjects.push( intersects[0].object );
        }
    }
}

我來簡單介紹下,首先需要傳入的三個參數_renderer, _scene, _camera分別是HTML中創建好的渲染器,場景和相機,我們需要在test.html中導入這個JS就像這樣

<script src="./ThreeJs/ThreeJs_Composer.js"></script>

然後在init()方法中加入(這裏的test.html依舊是第二章所講的那個,忘記的同學可以回到第二章回顧下)

//添加選中時的蒙版
new THREE.ThreeJs_Composer(renderer, scene, camera);

回到我們創建的JS中,下面這個變量已經保存了我們鼠標點擊處發出射線所依次經過的物體:

var intersects = raycaster.intersectObjects( [ _scene ], true );

接下來我們會有三種情況:
1.鼠標點擊的地方啥都沒有,就直接隱藏我們的說明性標籤(就是顯示在鼠標上面的那個框),直接return。

if(intersects.length == 0){
    $("#label").attr("style","display:none;");//隱藏說明性標籤
    return;
}

2.點擊到了地面或者牆面,這種情況下我們一般不顯示說明,並且把selectedObjects這個數組清空,這個數組將會存放我們選中的物體,方便之後添加發光特效。

if(intersects[0].object.name == "地面" || (intersects[0].object.name == "") || (intersects[0].object.name == "牆面")){
    $("#label").attr("style","display:none;");//隱藏說明性標籤
    selectedObjects.pop();
}

3.點擊到了門窗或者其他能夠選中的物體,這種情況下顯示說明(物體的名字),並且把selectedObjects這個數組清空後賦值。

else{
    $("#label").attr("style","display:block;");// 顯示說明性標籤
    $("#label").css({left: x, top: y-40});// 修改標籤的位置
    $("#label").text(intersects[0].object.name);// 顯示模型信息

    selectedObjects.pop();
    selectedObjects.push( intersects[0].object );
}

完成後效果圖如下(鼠標點擊了左門1):
在這裏插入圖片描述

添加選中後的發光特效

這個需要我們用到ThreeJs的後期處理THREE.EffectComposer,以及一系列“通道”,THREE.RenderPassTHREE.OutlinePassTHREE.ShaderPass
我們在之前的自定義JS中修改如下:

/*
  * 需要在jsp中導入的包
  <script src="./ThreeJs/three.js"></script>
  <script src="./ThreeJs/EffectComposer.js"></script>
  <script src="./ThreeJs/RenderPass.js"></script>
  <script src="./ThreeJs/OutlinePass.js"></script>
  <script src="./ThreeJs/FXAAShader.js"></script>
  <script src="./ThreeJs/ShaderPass.js"></script>
  <script src="./ThreeJs/CopyShader.js"></script>
  */

THREE.ThreeJs_Composer = function ( _renderer, _scene, _camera) {
    var raycaster = new THREE.Raycaster();
  	var mouse = new THREE.Vector2();
    var composer = new THREE.EffectComposer( _renderer );
    var renderPass = new THREE.RenderPass( _scene, _camera );
    var selectedObjects = [];
    composer.addPass( renderPass );
    var outlinePass = new THREE.OutlinePass( new THREE.Vector2( window.innerWidth, window.innerHeight ), _scene, _camera );
    outlinePass.edgeStrength = 5;//包圍線濃度
    outlinePass.edgeGlow = 0.5;//邊緣線範圍
    outlinePass.edgeThickness = 2;//邊緣線濃度
    outlinePass.pulsePeriod = 2;//包圍線閃爍頻率
    outlinePass.visibleEdgeColor.set( '#ffffff' );//包圍線顏色
    outlinePass.hiddenEdgeColor.set( '#190a05' );//被遮擋的邊界線顏色
    composer.addPass( outlinePass );
    var effectFXAA = new THREE.ShaderPass( THREE.FXAAShader );
    effectFXAA.uniforms[ 'resolution' ].value.set( 1 / window.innerWidth, 1 / window.innerHeight );
    effectFXAA.renderToScreen = true;
    composer.addPass( effectFXAA );

    window.addEventListener( 'click', onMouseClick);

    function onMouseClick( event ) {
        var x, y;
        if ( event.changedTouches ) {
            x = event.changedTouches[ 0 ].pageX;
            y = event.changedTouches[ 0 ].pageY;
        } else {
            x = event.clientX;
            y = event.clientY;
        }
        mouse.x = ( x / window.innerWidth ) * 2 - 1;
        mouse.y = - ( y / window.innerHeight ) * 2 + 1;
        raycaster.setFromCamera( mouse, _camera );
        var intersects = raycaster.intersectObjects( [ _scene ], true );

        if(intersects.length == 0){
            $("#label").attr("style","display:none;");//隱藏說明性標籤
            return;
        }
        if(intersects[0].object.name == "地面" || (intersects[0].object.name == "") || (intersects[0].object.name == "牆面")){
            $("#label").attr("style","display:none;");//隱藏說明性標籤
            selectedObjects.pop();
        }else{
            $("#label").attr("style","display:block;");// 顯示說明性標籤
            $("#label").css({left: x, top: y-40});// 修改標籤的位置
            $("#label").text(intersects[0].object.name);// 顯示模型信息

            selectedObjects.pop();
            selectedObjects.push( intersects[0].object );
            outlinePass.selectedObjects = selectedObjects;//給選中的線條和物體加發光特效
        }
    }

    return composer;
}

關於後期通道這方面我瞭解的很少,有興趣的同學可以自行百度,官網上也有很多栗子,比如雪花特效啊,變暗變亮的特效啊等等。這裏修改後的JS和之前章節創建的差不多,白色光圈的特效主要依靠OutlinePass通道,我們將射線獲取到的selectedObjects賦給outlinePass就可以實現文章開頭演示的效果啦!其中下面的這些參數大家可以依據自己的喜好適度修改。

outlinePass.edgeStrength = 5;//包圍線濃度
outlinePass.edgeGlow = 0.5;//邊緣線範圍
outlinePass.edgeThickness = 2;//邊緣線濃度
outlinePass.pulsePeriod = 2;//包圍線閃爍頻率
outlinePass.visibleEdgeColor.set( '#ffffff' );//包圍線顏色
outlinePass.hiddenEdgeColor.set( '#190a05' );//被遮擋的邊界線顏色

HTML更新如下

<!DOCTYPE html>
<html>

<head includeDefault="true">
  <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
  <title>3D庫圖顯示</title>
  <style>
    body {
      margin: 0;
      overflow: hidden;
    }

    #label {
      position: absolute;
      padding: 10px;
      background: rgba(255, 255, 255, 0.6);
      line-height: 1;
      border-radius: 5px;
    }
  </style>
  <script src="./ThreeJs/js/three.js"></script>
  <script src="./ThreeJs/js/stats.min.js"></script>
  <script src="./ThreeJs/js/DragControls.js"></script>
  <script src="./ThreeJs/js/OrbitControls.js"></script>
  <script src="./ThreeJs/js/dat.gui.min.js"></script>
  <script src="./ThreeJs/js/EffectComposer.js"></script>
  <script src="./ThreeJs/js/RenderPass.js"></script>
  <script src="./ThreeJs/js/OutlinePass.js"></script>
  <script src="./ThreeJs/js/FXAAShader.js"></script>
  <script src="./ThreeJs/js/CopyShader.js"></script>
  <script src="./ThreeJs/js/ShaderPass.js"></script>
  <script src="./ThreeJs/js/OBJLoader.js"></script>
  <script src="./ThreeJs/js/MTLLoader.js"></script>
  <script src="./ThreeJs/js/ThreeBSP.js"></script>
  <script src="./ThreeJs/ThreeJs_Composer.js"></script>
  <script src="./ThreeJs/js/jquery-1.11.0.min.js"></script>
</head>

<body>
  <div id="label"></div>
  <div id="container"></div>
  <script>
    var stats = initStats();
    var scene, camera, renderer, controls, light, composer;
    var matArrayA = []; //內牆
    var matArrayB = []; //外牆
    var group = new THREE.Group();

    // 初始化場景
    function initScene() {
      scene = new THREE.Scene();
      scene.fog = new THREE.Fog(scene.background, 3000, 5000);
    }

    // 初始化相機
    function initCamera() {
      camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 10000);
      camera.position.set(0, 800, 1500);
      camera.lookAt(new THREE.Vector3(0, 0, 0));
    }

    // 初始化燈光
    function initLight() {
      var directionalLight = new THREE.DirectionalLight(0xffffff, 0.3); //模擬遠處類似太陽的光源
      directionalLight.color.setHSL(0.1, 1, 0.95);
      directionalLight.position.set(0, 200, 0).normalize();
      scene.add(directionalLight);

      var ambient = new THREE.AmbientLight(0xffffff, 1); //AmbientLight,影響整個場景的光源
      ambient.position.set(0, 0, 0);
      scene.add(ambient);
    }

    // 初始化性能插件
    function initStats() {
      var stats = new Stats();

      stats.domElement.style.position = 'absolute';
      stats.domElement.style.left = '0px';
      stats.domElement.style.top = '0px';

      document.body.appendChild(stats.domElement);
      return stats;
    }

    // 初始化渲染器
    function initRenderer() {
      renderer = new THREE.WebGLRenderer({
        antialias: true
      });
      renderer.setSize(window.innerWidth, window.innerHeight);
      renderer.setClearColor(0x4682B4, 1.0);
      document.body.appendChild(renderer.domElement);
    }

    //創建地板
    function createFloor() {
      var loader = new THREE.TextureLoader();
      loader.load("./ThreeJs/images/floor.jpg", function(texture) {
        texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
        texture.repeat.set(10, 10);
        var floorGeometry = new THREE.BoxGeometry(2600, 1400, 1);
        var floorMaterial = new THREE.MeshBasicMaterial({
          map: texture,
          side: THREE.DoubleSide
        });
        var floor = new THREE.Mesh(floorGeometry, floorMaterial);
        floor.position.y = -0.5;
        floor.rotation.x = Math.PI / 2;
        floor.name = "地面";
        scene.add(floor);
      });
    }

    //創建牆
    function createCubeWall(width, height, depth, angle, material, x, y, z, name) {
      var cubeGeometry = new THREE.BoxGeometry(width, height, depth);
      var cube = new THREE.Mesh(cubeGeometry, material);
      cube.position.x = x;
      cube.position.y = y;
      cube.position.z = z;
      cube.rotation.y += angle * Math.PI; //-逆時針旋轉,+順時針
      cube.name = name;
      scene.add(cube);
    }

    //創建門_左側
    function createDoor_left(width, height, depth, angle, x, y, z, name) {
      var loader = new THREE.TextureLoader();
      loader.load("./ThreeJs/images/door_left.png", function(texture) {
        var doorgeometry = new THREE.BoxGeometry(width, height, depth);
        doorgeometry.translate(50, 0, 0);
        var doormaterial = new THREE.MeshBasicMaterial({
          map: texture,
          color: 0xffffff
        });
        doormaterial.opacity = 1.0;
        doormaterial.transparent = true;
        var door = new THREE.Mesh(doorgeometry, doormaterial);
        door.position.set(x, y, z);
        door.rotation.y += angle * Math.PI; //-逆時針旋轉,+順時針
        door.name = name;
        scene.add(door);
      });
    }

    //創建門_右側
    function createDoor_right(width, height, depth, angle, x, y, z, name) {
      var loader = new THREE.TextureLoader();
      loader.load("./ThreeJs/images/door_right.png", function(texture) {
        var doorgeometry = new THREE.BoxGeometry(width, height, depth);
        doorgeometry.translate(-50, 0, 0);
        var doormaterial = new THREE.MeshBasicMaterial({
          map: texture,
          color: 0xffffff
        });
        doormaterial.opacity = 1.0;
        doormaterial.transparent = true;
        var door = new THREE.Mesh(doorgeometry, doormaterial);
        door.position.set(x, y, z);
        door.rotation.y += angle * Math.PI; //-逆時針旋轉,+順時針
        door.name = name;
        scene.add(door);
      });
    }

    //創建窗戶
    function createWindow(width, height, depth, angle, x, y, z, name) {
      var loader = new THREE.TextureLoader();
      loader.load("./ThreeJs/images/window.png", function(texture) {
        var windowgeometry = new THREE.BoxGeometry(width, height, depth);
        var windowmaterial = new THREE.MeshBasicMaterial({
          map: texture,
          color: 0xffffff
        });
        windowmaterial.opacity = 1.0;
        windowmaterial.transparent = true;
        var window = new THREE.Mesh(windowgeometry, windowmaterial);
        window.position.set(x, y, z);
        window.rotation.y += angle * Math.PI; //-逆時針旋轉,+順時針
        window.name = name;
        scene.add(window);
      });
    }

    //返回牆對象
    function returnWallObject(width, height, depth, angle, material, x, y, z, name) {
      var cubeGeometry = new THREE.BoxGeometry(width, height, depth);
      var cube = new THREE.Mesh(cubeGeometry, material);
      cube.position.x = x;
      cube.position.y = y;
      cube.position.z = z;
      cube.rotation.y += angle * Math.PI;
      cube.name = name;
      return cube;
    }

    //牆上挖門,通過兩個幾何體生成BSP對象
    function createResultBsp(bsp, objects_cube) {
      var material = new THREE.MeshPhongMaterial({
        color: 0x9cb2d1,
        specular: 0x9cb2d1,
        shininess: 30,
        transparent: true,
        opacity: 1
      });
      var BSP = new ThreeBSP(bsp);
      for (var i = 0; i < objects_cube.length; i++) {
        var less_bsp = new ThreeBSP(objects_cube[i]);
        BSP = BSP.subtract(less_bsp);
      }
      var result = BSP.toMesh(material);
      result.material.flatshading = THREE.FlatShading;
      result.geometry.computeFaceNormals(); //重新計算幾何體側面法向量
      result.geometry.computeVertexNormals();
      result.material.needsUpdate = true; //更新紋理
      result.geometry.buffersNeedUpdate = true;
      result.geometry.uvsNeedUpdate = true;
      scene.add(result);
    }

    //創建牆紋理
    function createWallMaterail(){
        matArrayA.push(new THREE.MeshPhongMaterial({color: 0xafc0ca}));  //前  0xafc0ca :灰色
        matArrayA.push(new THREE.MeshPhongMaterial({color: 0xafc0ca}));  //後
        matArrayA.push(new THREE.MeshPhongMaterial({color: 0xd6e4ec}));  //上  0xd6e4ec: 偏白色
        matArrayA.push(new THREE.MeshPhongMaterial({color: 0xd6e4ec}));  //下
        matArrayA.push(new THREE.MeshPhongMaterial({color: 0xafc0ca}));  //左    0xafc0ca :灰色
        matArrayA.push(new THREE.MeshPhongMaterial({color: 0xafc0ca}));  //右

        matArrayB.push(new THREE.MeshPhongMaterial({color: 0xafc0ca}));  //前  0xafc0ca :灰色
        matArrayB.push(new THREE.MeshPhongMaterial({color: 0x9cb2d1}));  //後  0x9cb2d1:淡紫
        matArrayB.push(new THREE.MeshPhongMaterial({color: 0xd6e4ec}));  //上  0xd6e4ec: 偏白色
        matArrayB.push(new THREE.MeshPhongMaterial({color: 0xd6e4ec}));  //下
        matArrayB.push(new THREE.MeshPhongMaterial({color: 0xafc0ca}));  //左   0xafc0ca :灰色
        matArrayB.push(new THREE.MeshPhongMaterial({color: 0xafc0ca}));  //右
    }
    
    // 初始化模型
    function initContent() {
      createFloor();
      createWallMaterail();
      createCubeWall(10, 200, 1400, 0, matArrayB, -1295, 100, 0, "牆面");
      createCubeWall(10, 200, 1400, 1, matArrayB, 1295, 100, 0, "牆面");
      createCubeWall(10, 200, 2600, 1.5, matArrayB, 0, 100, -700, "牆面");
      //創建挖了門的牆
      var wall = returnWallObject(2600, 200, 10, 0, matArrayB, 0, 100, 700, "牆面");
      var door_cube1 = returnWallObject(200, 180, 10, 0, matArrayB, -600, 90, 700, "前門1");
      var door_cube2 = returnWallObject(200, 180, 10, 0, matArrayB, 600, 90, 700, "前門2");
      var window_cube1 = returnWallObject(100, 100, 10, 0, matArrayB, -900, 90, 700, "窗戶1");
      var window_cube2 = returnWallObject(100, 100, 10, 0, matArrayB, 900, 90, 700, "窗戶2");
      var window_cube3 = returnWallObject(100, 100, 10, 0, matArrayB, -200, 90, 700, "窗戶3");
      var window_cube4 = returnWallObject(100, 100, 10, 0, matArrayB, 200, 90, 700, "窗戶4");
      var objects_cube = [];
      objects_cube.push(door_cube1);
      objects_cube.push(door_cube2);
      objects_cube.push(window_cube1);
      objects_cube.push(window_cube2);
      objects_cube.push(window_cube3);
      objects_cube.push(window_cube4);
      createResultBsp(wall, objects_cube);
      //爲牆面安裝門
      createDoor_left(100, 180, 2, 0, -700, 90, 700, "左門1");
      createDoor_right(100, 180, 2, 0, -500, 90, 700, "右門1");
      createDoor_left(100, 180, 2, 0, 500, 90, 700, "左門2");
      createDoor_right(100, 180, 2, 0, 700, 90, 700, "右門2");
      //爲牆面安裝窗戶
      createWindow(100, 100, 2, 0, -900, 90, 700, "窗戶");
      createWindow(100, 100, 2, 0, 900, 90, 700, "窗戶");
      createWindow(100, 100, 2, 0, -200, 90, 700, "窗戶");
      createWindow(100, 100, 2, 0, 200, 90, 700, "窗戶");
    }

    // 初始化軌跡球控件
    function initControls() {
      controls = new THREE.OrbitControls(camera, renderer.domElement);
      controls.enableDamping = true;
      controls.dampingFactor = 0.5;
      // 視角最小距離
      controls.minDistance = 100;
      // 視角最遠距離
      controls.maxDistance = 5000;
      // 最大角度
      controls.maxPolarAngle = Math.PI / 2.2;
    }

    // 更新控件
    function update() {
      stats.update();
      controls.update();
    }

    // 初始化
    function init() {
      initScene();
      initCamera();
      initRenderer();
      initContent();
      initLight();
      initControls();

      //添加選中時的蒙版
      composer = new THREE.ThreeJs_Composer(renderer, scene, camera);
      document.addEventListener('resize', onWindowResize, false);
    }

    // 窗口變動觸發的方法
    function onWindowResize() {
      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(window.innerWidth, window.innerHeight);
    }

    function animate() {
      requestAnimationFrame(animate);
      renderer.render(scene, camera);
      composer.render();
      update();
    }

    init();
    animate();
  </script>
</body>

</html>

結束語

碼着碼着就到十一點半了,最近項目快上線了比較忙,工作時間基本都在處理項目上的瑣事,也只有犧牲下晚上玩遊戲的時間來更新文章了(滑稽),本章我們講解了如何選中一個物體並添加特效。下一章我們將會推出如何給門添加打開關閉的動畫,以及如何添加庫位,敬請期待!
我跟廣大學習ThreeJs的初學者一樣,仍帶着懵懂的心去探索這片新大陸,CSDN上的許多前輩都給了我很多關鍵的靈感和技術方法,如果大家有興趣,也可以互相交流成長,歡迎大家指導諮詢。PS:大家有興趣可以點進去我的頭像,陸陸續續也寫了十來篇了。
鏈接:使用ThreeJs從零開始構建3D智能倉庫——第一章: 點我跳轉.
鏈接:使用ThreeJs從零開始構建3D智能倉庫——第二章: 點我跳轉.
鏈接:使用ThreeJs從零開始構建3D智能倉庫——第三章: 點我跳轉.
鏈接:使用ThreeJs從零開始構建3D智能倉庫——第四章: 點我跳轉.
鏈接:使用ThreeJs從零開始構建3D智能倉庫——第五章: 點我跳轉.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章