Three.js粒子特效,shader渲染初探

這大概是個序

關於Three.js,網上有很少很多的零碎教程,有些過於初級,有些雲裏霧裏,而Three.js官網上的示例逼格之高又使人望而卻步,這些對於沒學過計算機圖形學的童鞋來講,就像入門邁檻不知先邁左腳仍是右腳,興趣使然,因而我就先雙腳蹦了進去試試水......css

本文將以儘可能戲劇化的語言描述網頁3D世界的構建流程及表面原理(由於深層原理我目前也不懂...),這裏你應該/可能/或許/大概/看造化會學會(基本的):html

  • 3D場景構建流程
  • 物體添加與外部模型導入
  • 鼠標與場景中的物體交互
  • 3D粒子系統構建
  • 粒子動畫
  • 部分shader渲染原理

實誠地說,爲了吸引各位看官,少囉嗦,先看東西(gif圖可能稍大): 前端

能夠觀察到成品效果:粒子變換、輕微粒子抖動、粒子模糊、顏色變換及過渡。

鍵盤走起~node


1、3D場景構建流程

先得擺出幾個關鍵詞:場景燈光模型材質貼圖與紋理相機渲染器git

而後我開始裝模做樣地解釋:github

上帝說,要有場景!因而就有了場景,場景去納這萬事萬物json

上帝說,要有光!因而就有了光,燈光去現這大千世界,不然一片漆黑canvas

上帝以爲缺乏了些生氣,便用泥巴捏了一個小人兒,不叫亞當,她叫小芳。數組

上帝左看右看,上看下看,這小芳果真生得俊俏,五官精緻加長腿,此曰模型;promise

雖然小芳不是水作的,卻也在這晨光的照射下顯得皮膚吹彈可破,此曰材質

上帝莫名竟害羞了,揮手便給他穿上一件花格子長裙,配上了烏黑的長髮,此曰貼圖與紋理

上帝嘴角不揚卻滿心欣喜,他默默注視着本身的做品,上帝視角彷彿定格在了這一瞬間,這上帝之眼就是相機

上帝之所見如何,由世界入眼以後大腦冥想計算所得,這智慧高效的大腦就是渲染器

接下來預先恭喜你,你能夠成爲這網頁3D世界的一個小上帝。

  1. 總體流程

class ThreeDWorld {
    constructor(canvasContainer) {
        // canvas容器
        this.container = canvasContainer || document.body;
        // 建立場景
        this.createScene();
        // 建立燈光
        this.createLights();
        // 性能監控插件
        this.initStats();
        // 物體添加
        this.addObjs();
        // 軌道控制插件(鼠標拖拽視角、縮放等)
        this.orbitControls = new THREE.OrbitControls(this.camera);
        this.orbitControls.autoRotate = true;
        // 循環更新渲染場景
        this.update();
    }
}
複製代碼
  1. 建立場景

咱們須要在該過程當中建立Three.js 的相機實例,並設定相機位置,即視線位置;

而後建立渲染器實例,設定其尺寸背景色,同時還開啓了它的陰影效果,在光照下會更真實,它的主要工做即是計算當前在本身的尺寸範圍下看到全部視象並繪製到canvas上;

最後考慮到可能的屏幕縮放,監聽窗口大小變更來動態調整渲染器尺寸與相機橫縱比,達到最佳顯示效果。

createScene() {
    this.HEIGHT = window.innerHeight;
    this.WIDTH = window.innerWidth;
    // 建立場景
    this.scene = new THREE.Scene();
    // 在場景中添加霧的效果,參數分別表明‘霧的顏色’、‘開始霧化的視線距離’、恰好霧化至看不見的視線距離’
    this.scene.fog = new THREE.Fog(0x090918, 1, 600);
    // 建立相機
    let aspectRatio = this.WIDTH / this.HEIGHT;
    let fieldOfView = 60;
    let nearPlane = 1;
    let farPlane = 10000;
    /** * PerspectiveCamera 透視相機 * @param fieldOfView 視角 * @param aspectRatio 縱橫比 * @param nearPlane 近平面 * @param farPlane 遠平面 */
    this.camera = new THREE.PerspectiveCamera(
        fieldOfView,
        aspectRatio,
        nearPlane,
        farPlane
    );

    // 設置相機的位置
    this.camera.position.x = 0;
    this.camera.position.z = 150;
    this.camera.position.y = 0;
    // 建立渲染器
    this.renderer = new THREE.WebGLRenderer({
        // 在 css 中設置背景色透明顯示漸變色
        alpha: true,
        // 開啓抗鋸齒
        antialias: true
    });
    // 渲染背景顏色同霧化的顏色
    this.renderer.setClearColor(this.scene.fog.color);
    // 定義渲染器的尺寸;在這裏它會填滿整個屏幕
    this.renderer.setSize(this.WIDTH, this.HEIGHT);

    // 打開渲染器的陰影地圖
    this.renderer.shadowMap.enabled = true;
    // this.renderer.shadowMapSoft = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap
    // 在 HTML 建立的容器中添加渲染器的 DOM 元素
    this.container.appendChild(this.renderer.domElement);
    // 監聽屏幕,縮放屏幕更新相機和渲染器的尺寸
    window.addEventListener('resize', this.handleWindowResize.bind(this), false);
}
複製代碼
// 窗口大小變更時調用
handleWindowResize() {
    // 更新渲染器的高度和寬度以及相機的縱橫比
    this.HEIGHT = window.innerHeight;
    this.WIDTH = window.innerWidth;
    this.renderer.setSize(this.WIDTH, this.HEIGHT);
    this.camera.aspect = this.WIDTH / this.HEIGHT;
    this.camera.updateProjectionMatrix();
}
複製代碼

解釋幾點:

當須要模擬物體在遠處逐漸模糊的視覺現象,可開啓「霧化」效果,此時霧的顏色最好與場景色相同。

關於相機,可分爲兩種:正交投影相機(OrthographicCamera)透視投影相機(PerspectiveCamera)

先看第一種,正交投影相機,構造函數以下:

OrthographicCamera(left, right, top, bottom, near, far);
複製代碼

這六個投影面圍成的區域就是相機投影的可見區域,該相機所看到的物體並不會出現近大遠小的視覺現象,如同高中數學幾何題中的圖例,實際相同長的邊遠處近處均同樣長(怪不得當時有人用尺子量答案...),效果以下:

第二種,透視投影相機,這種更符合人眼看到的近大遠小的真實世界,本文均使用該相機。

構造函數以下:

/** * PerspectiveCamera 透視相機 * @param fov 視角 * @param aspect 縱橫比 * @param near 近平面 * @param far 遠平面 */
PerspectiveCamera(fov, aspect, near, far);
複製代碼

它看到的正方體就是下面這樣的:

  1. 建立燈光

這裏建立了3種光源:戶外光源(HemisphereLight)環境光源(AmbientLight)DirectionalLight(平行光源)

戶外光源能夠用來模擬靠天越亮,靠地越暗的戶外反光效果。

環境光源可做用於物體的任何一個角落,通常設置爲近白色的極淡光,用來避免物體某角度下某部分出現徹底漆黑的狀況。

平行光源是咱們使用的主光源,像太陽光平行照射在地面同樣,用它來生成陰影效果。

createLights() {
    // 戶外光源
    // 第一個參數是天空的顏色,第二個參數是地上的顏色,第三個參數是光源的強度
    this.hemisphereLight = new THREE.HemisphereLight(0xaaaaaa, 0x000000, .9);

    // 環境光源
    this.ambientLight = new THREE.AmbientLight(0xdc8874, .2);

    // 方向光是從一個特定的方向的照射
    // 相似太陽,即全部光源是平行的
    // 第一個參數是關係顏色,第二個參數是光源強度
    this.shadowLight = new THREE.DirectionalLight(0xffffff, .9);

    // 設置光源的位置方向
    this.shadowLight.position.set(50, 50, 50);

    // 開啓光源投影
    this.shadowLight.castShadow = true;

    // 定義可見域的投射陰影
    this.shadowLight.shadow.camera.left = -400;
    this.shadowLight.shadow.camera.right = 400;
    this.shadowLight.shadow.camera.top = 400;
    this.shadowLight.shadow.camera.bottom = -400;
    this.shadowLight.shadow.camera.near = 1;
    this.shadowLight.shadow.camera.far = 1000;

    // 定義陰影的分辨率;雖然分辨率越高越好,可是須要付出更加昂貴的代價維持高性能的表現。
    this.shadowLight.shadow.mapSize.width = 2048;
    this.shadowLight.shadow.mapSize.height = 2048;

    // 爲了使這些光源呈現效果,須要將它們添加到場景中
    this.scene.add(this.hemisphereLight);
    this.scene.add(this.shadowLight);
    this.scene.add(this.ambientLight);
}
複製代碼

若是隻有單一光源會是什麼效果?例如只有主光源(平行光源)。

初中物理學過,看到的物體顏色是光線照射到物體表面,通過物體表面的吸取後反射回人眼的顏色,咱們通常所說的物體顏色,即是指它在太陽光的照射下呈現給咱們的視覺顏色,而它的視覺顏色,即是它不吸取的顏色的混合。

有點繞,例如,咱們說這個方塊是紅色(0xff0000)的(在太陽光下),那麼一束白光(0xffffff)(太陽光)打過去,二者取「與」(0xff0000 & 0xffffff)獲得0xff0000(視覺顏色),還是紅色。

以下圖,左邊是紅色方塊,右邊是白色方塊,採用0xffffff平行光照射:

this.shadowLight = new THREE.DirectionalLight(0xffffff, 1.0);
複製代碼
// 物體添加
addObjs(){
    // 紅色方塊
    let cube = new THREE.BoxGeometry(20, 20, 20);
    let mat = new THREE.MeshPhongMaterial({
        color: new THREE.Color(0xff0000)
    });
    let m_cube = new THREE.Mesh(cube, mat);
    m_cube.castShadow = true;
    m_cube.position.x = -20;

    // 白色方塊
    let cube2 = new THREE.BoxGeometry(20, 20, 20);
    let mat2 = new THREE.MeshPhongMaterial({
        color: new THREE.Color(0xffffff)
    });
    let m_cube2 = new THREE.Mesh(cube, mat2);
    m_cube2.castShadow = true;
    m_cube2.position.x = 20;

    // 物體添加至場景
    this.scene.add(m_cube);
    this.scene.add(m_cube2);
}

複製代碼

若換成0x00ffff的平行光源照射,0xff0000 & 0x00ffff獲得0x000000(黑色),因此左邊本來紅色的方塊直接變漆黑了。

this.shadowLight = new THREE.DirectionalLight(0x00ffff, 1.0);
複製代碼

因而可知,在只有單一光源下,不只場景略顯黯淡,當設定的光源色還不是白色時,會出現咱們可能以爲意料以外的顏色,因此添加戶外光源與環境光源,來弱化這種光色吸取效果,也使場景更加明亮,添加後的效果以下(此時主光源還是0x00ffff):

  1. 性能監控

使用stats.js插件作性能監控,能夠直觀地看到渲染幀率,當幀率在60FPS及以上時,人眼看起來會以爲很是流暢,玩吃雞拼顯卡性能也是爲了保持高幀率且流暢的遊戲體驗,因此當發現本身構建的網頁3D幀率在複雜場景下幀率降低明顯,就得須要考慮性能優化了。

<!-- 在引入three.js庫以後引入插件 -->
<script src="./lib/stats.min.js"></script>
複製代碼
initStats() {
    this.stats = new Stats();
    // 將性能監控屏區顯示在左上角
    this.stats.domElement.style.position = 'absolute';
    this.stats.domElement.style.bottom = '0px';
    this.stats.domElement.style.zIndex = 100;
    this.container.appendChild(this.stats.domElement);
}
複製代碼

  1. 物體添加

3D物體的構成可分爲兩個部分:

  • 幾何模型(Geometry):它用來承載構成這個幾何體的全部頂點信息以及變換屬性和方法。 例如建立一個空的幾何模型,能夠看到基本屬性以下
    vertices用於保存頂點位置信息;而faceVertexUvs是一個多維數組,用來保存該模型上的UV映射關係,例如一個貼圖該以怎樣的位置關係被貼在模型上。它還有一些原生方法,支持模型的拷貝clone,矩陣變換applyMatrix,旋轉rotate、縮放scale與平移translate等等。
    固然了,Three.js爲咱們內置了許多種不一樣形狀的幾何模型,常見好比盒子模型(BoxGeometry)圓形模型(CircleGeometry)球體模型(SphereGeometry)圓柱體模型(CylinderGeometry)平面模型(PlaneGeometry),它們均預置好了頂點位置的排列規則及初始的UV映射,其餘幾何模型及詳細使用可參照官方文檔。

  • 材質(Materials):可簡單的描述爲物體在光線照射下表現的反射特徵,這些特徵反饋到咱們人眼就能看到粗糙光滑透明等視覺現象。

常見的材質介紹:

(1) 基礎網孔材料(MeshBasicMaterial):一個以簡單着色(平面或線框)方式來繪製幾何形狀的材料。

例如以平面方式繪製的環面扭結模型長這樣:

(2)蘭伯特網孔材料(MeshLambertMaterial):一種非發光材料(蘭伯特)的表面,計算每一個頂點;能夠理解爲它是擁有漫反射表面屬性的材質,可用來模擬非光滑粗糙的材質效果。

(3)Phong網孔材料(MeshPhongMaterial):用於表面有光澤的材料,計算每一個像素;經常使用來模擬金屬的光澤效果。

(4)着色器材料(ShaderMaterial):使用自定義着色器渲染的材料。着色器(shader)是一段使用 GLSL 語言編寫的可在GPU上直接運行的程序,能夠實現除內置 materials 以外的效果。(木有圖)介紹這個是由於後面實現粒子渲染時會用到。

因此!當咱們擁有模型材質後,將他們mesh起來,即將材質應用到該模型,而後設定好位置將它添加到場景,就能獲得一個完整的3D物體了!

建立3D物體

先來個最基本的,建立一個立方體BoxGeometry,其構造函數爲:

BoxGeometry(width, height, depth, widthSegments, heightSegments, depthSegments)
複製代碼

前三個參數分別表明立方體的,後三個參數表明對應方向上的分段數量;分段數量有什麼做用?能夠理解爲分段數越大,該幾何模型就會被劃分得更精細,頂點數量就會越多(例如建立球體模型時分段數量足夠大,它就足夠圓)。

示例,將不一樣分段的一樣大小的立方體放在一塊兒作對比:

// 物體添加
addObjs(){
    // 使用基礎網孔材料
    let mat = new THREE.MeshBasicMaterial({
        color: 0xff0000,
        // 繪製爲線框
        wireframe: true
    });
    // 建立立方體幾何模型
    let cube1 = new THREE.BoxGeometry(10, 20, 30, 1, 1, 1);
    // 混合模型與材質
    let m_cube1 = new THREE.Mesh(cube1, mat);
    let cube2 = new THREE.BoxGeometry(10, 20, 30, 2, 2, 2);
    let m_cube2 = new THREE.Mesh(cube2, mat);
    let cube3 = new THREE.BoxGeometry(10, 20, 30, 3, 3, 3);
    let m_cube3 = new THREE.Mesh(cube3, mat);
    m_cube1.position.x = -30;
    m_cube2.position.x = 0;
    m_cube3.position.x = 30;
    this.scene.add(m_cube1);
    this.scene.add(m_cube2);
    this.scene.add(m_cube3);
}

複製代碼

用貼圖材質來建立個「木箱」:

// 物體添加
addObjs(){
    let cube = new THREE.BoxGeometry(20, 20, 20);
    // 使用Phong網孔材料
    let mat = new THREE.MeshPhongMaterial({
        color: new THREE.Color(0xffffff),
        // 導入紋理貼圖
        map: THREE.ImageUtils.loadTexture('img/crate.jpg')
    });
    let m_cube = new THREE.Mesh(cube, mat);
    m_cube.castShadow = true;
    this.scene.add(m_cube);
}
複製代碼

立方體模型在使用貼圖的狀況下,會基於自身預置的UV映射把圖像重複應用到每一個面上,而在尺寸不相符的狀況下貼圖會被自動拉伸或擠窄去適應該面,例如把立方體尺寸修改下就變成下面這樣:

固然了,咱們能夠針對每一個面都賦予不一樣的貼圖,或者修改其UV映射關係,將一張圖的不一樣區域顯示到特定的面上,有興趣的能夠戳這兒,查看實現原理。

  1. 場景渲染

作了以上那麼多準備工做,終於到了把這小3D世界渲染呈現的時候了:

// 循環更新渲染
update() {
    // 動畫插件
    TWEEN.update();
    // 性能監測插件
    this.stats.update();
    // 渲染器執行渲染
    this.renderer.render(this.scene, this.camera);
    // 循環調用
    requestAnimationFrame(() => {
        this.update()
    });
}
複製代碼

這裏冒出了一個TWEEN,這是執行動畫效果的經常使用插件,能夠設置屬性值的隨時間變化的過程,而在每一幀渲染的時候都需調用TWEEN.update()使屬性值及時更新。

同理,stats插件也在每幀調用其update函數,方便其計算幀率等性能信息。

最關鍵的,每幀需調用渲染操做,更新場景當下的視覺畫面。

最後一步使用requestAnimationFrame重複調用自身,從而達到循環渲染的目的。requestAnimationFrame採用系統時間間隔,保持最佳繪製效率,不會由於間隔時間太短,形成過分繪製,增長開銷;也不會由於間隔時間太長,使用動畫卡頓不流暢,網頁動畫居家旅行必備。

建立3D組合

咱們能夠將多個不一樣的物體塞到同一個3D組合裏,重拾樂高積木的樂趣。

addObjs(){
    let mat = new THREE.MeshPhongMaterial({
        color: new THREE.Color(0xffffff);
    });
    // 建立一個3D物體組合容器
    let group = new THREE.Object3D();
    let radius = 40;
    let m_cube;
    for (let deg = 0; deg < 360; deg += 30) {
        // 建立白色方塊的mesh
        m_cube = new THREE.Mesh(new THREE.BoxGeometry(20, 20, 20), mat);
        // 設置它能夠產生投影
        m_cube.castShadow = true;
        // 設置它能夠接收其餘物體在其表面的投影
        m_cube.receiveShadow = true;
        // 用方塊畫個圈
        m_cube.position.x = radius * Math.cos(Math.PI * deg / 180);
        m_cube.position.y = radius * Math.sin(Math.PI * deg / 180);
        // z軸位置錯落擺放
        m_cube.position.z = deg % 60 ? 5 : -5;
        // 放入容器
        group.add(m_cube);
    }
    // 3D組合添加至場景
    this.scene.add(group);
}
複製代碼


2、 外部模型導入

當須要在網頁上呈現複雜模型的時候,就須要從外部導入模型了,不一樣模型製做軟件例如3dmaxBlender,它們能夠導出不一樣格式的3d文件;市面上的3d格式之多恕我孤陋寡聞我大多基本都沒據說過...,但Three.js有提供較爲全面的模型導入插件,可戳這兒查看。

這裏介紹我認爲經常使用的三種3d格式(不敢義正詞嚴,僅僅是我認爲):

  • js/json 前端仔看見這個格外親切,它是專門爲Three.js設計的3D格式,Three.js庫中也自帶該loader。
  • obj與mtl OBJ是一種簡單的三維文件格式,只用來定義對象的幾何體。MTL文件一般和OBJ文件一塊兒使用,在一個MTL文件中,定義對象的材質。
  • fbx 是FilmBoX這套軟件所使用的格式,其最大的用途是用在諸如在max、maya、softimage等軟件間進行模型、材質、動做和攝影機信息的互導,所以在建立三維內容的應用軟件之間具備無與倫比的互用性。

還有vtk格式與ply格式,先不介紹了。(由於我手頭沒這些模型...(´-ι_-`)

使用前記得先引入對應插件:

<script src="./lib/OBJLoader.js"></script>
<script src="./lib/MTLLoader.js"></script>
<script src="./lib/inflate.min.js"></script>
<script src="./lib/FBXLoader.js"></script>
複製代碼

其中出現了個inflate.min.js,是FBXLoader在使用時所必須的插件。

接下來寫個本身的loader,能夠併發加載多個不一樣格式的模型:

// 自定義模型加載器
loader(pathArr) {
    // 各種loader實例
    let jsonLoader = new THREE.JSONLoader();
    let fbxLoader = new THREE.FBXLoader();
    let mtlLoader = new THREE.MTLLoader();
    let objLoader = new THREE.OBJLoader();
    let basePath, pathName, pathFomat;
    if (Object.prototype.toString.call(pathArr) !== '[object Array]') {
        pathArr = new Array(1).fill(pathArr.toString());
    }
    let promiseArr = pathArr.map((path) => {
        // 模型基礎路徑
        basePath = path.substring(0, path.lastIndexOf('/') + 1);
        // 模型名稱
        pathName = path.substring(path.lastIndexOf('/') + 1, path.lastIndexOf('.'));
        // 後綴爲js或json的文件統一當作js格式處理
        pathName = pathName === 'json' ? 'js' : pathName;
        // 模型格式
        pathFomat = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
        switch (pathFomat) {
            case 'js':
                return new Promise(function(resolve) {
                    jsonLoader.load(path, (geometry, material) => {
                        resolve({
                            // 對於js文件,加載到的模型與材質分開放置
                            geometry: geometry,
                            material: material
                        })
                    });
                });
                break;
            case 'fbx':
                return new Promise(function(resolve) {
                    fbxLoader.load(path, (object) => {
                        resolve(object);
                    });
                });
                break;
            case 'obj':
                return new Promise(function(resolve) {
                    objLoader.load(path, (object) => {
                        resolve(object);
                    });
                });
                break;
            case 'mtl':
                return new Promise(function(resolve) {
                    mtlLoader.setPath(basePath);
                    mtlLoader.load(pathName + '.mtl', (mtl) => {
                        resolve(mtl);
                    });
                });
                break;
            case 'objmtl':
                return new Promise(function(resolve, reject) {
                    mtlLoader.setPath(basePath);
                    mtlLoader.load(`${pathName}.mtl`, (mtl) => {
                        mtl.preload();
                        objLoader.setMaterials(mtl);
                        objLoader.setPath(basePath);
                        objLoader.load(pathName + '.obj', resolve, undefined, reject);
                    });
                });
                break;
            default:
                return '';
        }
    });
    return Promise.all(promiseArr);
}
複製代碼

以上除了單獨加載js/jsonfbxobjmtl文件外,還加了個自定義的objmtl,方便將同名的objmtl文件mesh好後返回。需注意,這裏列的僅僅是靜態模型的導入,像有些能夠包含動畫信息的3D格式例如js/jsonfbx,要執行其動畫效果需額外處理,Three.js官網示例有各種loader的詳細使用方法其動畫執行代碼,須要時可去參考。

試驗下:

addObjs() {
    this.loader(['obj/bumblebee/bumblebee.FBX', 'obj/teapot.js', 'obj/monu9.objmtl']).then((result) => {
        let bumblebee = result[0];
        // 加載的js/json格式需手動mesh
        let teapot = new THREE.Mesh(result[1].geometry, result[1].material);
        let monu = result[2];

        // 按場景要求縮放及位移

        bumblebee.scale.x = 0.03;
        bumblebee.scale.y = 0.03;
        bumblebee.scale.z = 0.03;
        bumblebee.rotateX(-Math.PI / 2);
        bumblebee.position.y -= 30;

        teapot.applyMatrix(new THREE.Matrix4().makeTranslation(0, -30, 20));
        teapot.scale.x = 0.2;
        teapot.scale.y = 0.2;
        teapot.scale.z = 0.2;

        monu.applyMatrix(new THREE.Matrix4().makeTranslation(0, -30, 0));
    
        // 開啓投影 
        this.onShadow(monu);
        this.onShadow(bumblebee);
        this.onShadow(teapot);
        // 添加至場景
        this.scene.add(bumblebee);
        this.scene.add(teapot);
        this.scene.add(monu);
    });
}
// 遞歸遍歷模型及模型子元素並開啓投影
onShadow(obj) {
    if (obj.type === 'Mesh') {
        obj.castShadow = true;
        obj.receiveShadow = true;
    }
    if (obj.children && obj.children.length > 0) {
        obj.children.forEach((item) => {
            this.onShadow(item);
        })
    }
    return;
}
複製代碼

因而獲得了大黃蜂記念碑谷地圖中魏然屹立卻又凝視着地上莫名冒出來的小茶壺的一番景象 ( ˙-˙ )。

若是以爲仍是js/json格式用得爽,想把其餘3D格式轉換成js/json,除使用軟件如Blender轉換導出外,網上還有個小工具能夠完成這件事 —— convert_to_threejs.py


3、 鼠標與場景中的物體交互

  1. 軌道控制

還記得在最開始constructor函數裏建立的orbitControls插件嗎?將它引入並開啓後,鼠標即可以控制場景的旋轉縮放位移,這些只是看上去的效果,它操控的實際上是相機位置,縮放時將相機拉遠拉近,旋轉時將相機位置圍繞場景中心點旋轉,位移時須要更多一些的計算調整相機位置,使場景看上去在平移。

該控件還有可細調的參數,例如阻尼係數控制旋轉或縮放範圍等等,可搜索查閱。

<script src="./lib/OrbitControls.js"></script>
複製代碼
// 軌道控制插件
this.orbitControls = new THREE.OrbitControls(this.camera);
this.orbitControls.autoRotate = true;
複製代碼

  1. 鼠標點擊與懸浮交互

因爲3D世界裏的物體不能像網頁dom那樣,直接綁定「click」等事件,那鼠標在屏幕上點擊,該如何肯定裏面的物體是被點擊到了呢?

Three.js提供了射線(Raycaster)類,能夠從一點到另外一點發射射線,返回射線通過的物體甚至距離。

因此用鼠標點擊來拾取物體,可歸納爲如下流程:

(1)獲取鼠標點擊的屏幕座標點

(2)根據canvas窗口大小與屏幕座標點,計算該點映射到3D場景中的場景座標點

(3)由視線位置向點擊的場景座標點發射射線;

(4)獲取射線的返回信息,射線會按通過物體的順序收集相應信息並放入數組,一般從該數組首項提取到拾取的物體。

一段簡單的鼠標點擊拾取物體代碼以下:

this.container.addEventListener("mousedown", (event) => {
    let mouse = new THREE.Vector2();
    let raycaster = new THREE.Raycaster();
    // 計算鼠標點擊位置轉換到3D場景後的位置
    mouse.x = (event.clientX / this.renderer.domElement.clientWidth) * 2 - 1;
    mouse.y = -(event.clientY / this.renderer.domElement.clientHeight) * 2 + 1;
    // 由當前相機(視線位置)像點擊位置發射線
    raycaster.setFromCamera(mouse, this.camera);
    let intersects = raycaster.intersectObjects(this.scene.children, true)
    if (intersects.length > 0) {
        // 拿到射線第一個照射到的物體
        console.log(intersects[0].object);
    }
});
複製代碼

可是,身爲前端仔,仍是以爲要是能像通常網頁那樣爲物體綁定點擊事件該多好。

因而我封裝了這麼個東西(僅供參考):

addMouseListener() {
    // 層層往上尋找模型的父級,直至它是場景下的直接子元素
    function parentUtilScene(obj) {
        if (obj.parent.type === 'Scene') return obj;
        while (obj.parent && obj.parent.type !== 'Scene') {
            obj = obj.parent;
        }
        return obj;
    }
    // canvas容器內鼠標點擊事件添加
    this.container.addEventListener("mousedown", (event) => {
        this.handleRaycasters(event, (objTarget) => {
            // 尋找其對應父級爲場景下的直接子元素
            let object = parentUtilScene(objTarget);
            // 調用拾取到的物體的點擊事件
            object._click && object._click(event);
            // 遍歷場景中除當前拾取外的其餘物體,執行其未被點擊到的事件回調
            this.scene.children.forEach((objItem) => {
                if (objItem !== object) {
                    objItem._clickBack && objItem._clickBack();
                }
            });
        });
    });
    // canvas容器內鼠標移動事件添加
    this.container.addEventListener("mousemove", (event) => {
        this.handleRaycasters(event, (objTarget) => {
            // 尋找其對應父級爲場景下的直接子元素
            let object = parentUtilScene(objTarget);
            // 鼠標移動到拾取物體上且未離開時時,僅調用一次其懸浮事件方法
            !object._hover_enter && object._hover && object._hover(event);
            object._hover_enter = true;
            // 遍歷場景中除當前拾取外的其餘物體,執行其未有鼠標懸浮的事件回調
            this.scene.children.forEach((objItem) => {
                if (objItem !== object) {
                    objItem._hover_enter && objItem._hoverBack && objItem._hoverBack();
                    objItem._hover_enter = false;
                }
            });
        })
    });
    // 爲全部3D物體添加上「on」方法,可監聽物體的「click」、「hover」事件
    THREE.Object3D.prototype.on = function(eventName, touchCallback, notTouchCallback) {
        switch (eventName) {
            case "click":
                this._click = touchCallback ? touchCallback : undefined;
                this._clickBack = notTouchCallback ? notTouchCallback : undefined;
                break;
            case "hover":
                this._hover = touchCallback ? touchCallback : undefined;
                this._hoverBack = notTouchCallback ? notTouchCallback : undefined;
                break;
            default:;
        }
    }
}
// 射線處理
handleRaycasters(event, callback) {
    let mouse = new THREE.Vector2();
    let raycaster = new THREE.Raycaster();
    mouse.x = (event.clientX / this.renderer.domElement.clientWidth) * 2 - 1;
    mouse.y = -(event.clientY / this.renderer.domElement.clientHeight) * 2 + 1;
    raycaster.setFromCamera(mouse, this.camera);
    let intersects = raycaster.intersectObjects(this.scene.children, true)
    if (intersects.length > 0) {
        callback && callback(intersects[0].object);
    }
}
複製代碼

雖然帶了儘量詳盡的註釋,但我不得不跳出來解釋下上面代碼幹了什麼事情。

鼠標在屏幕裏移動時,整個canvas容器綁定的mousedownmouseupmousemove事件(touch類的事件先放一放)會是鼠標與3D場景之間交互的紐帶。

因此我在3D對象的基類THREE.Object3D上增長了個叫「on」的原型方法,目前只支持自定義的clickhover事件,當調用時,該原型方法僅是在該3D對象上掛載了對應的回調處理函數;

例如綁定click事件,將會將剩下兩個入參touchCallbacknotTouchCallback賦值到該3D對象的_click_clickBack方法屬性上,分表表明點擊到該物體的回調函數點擊了卻沒有點擊到本身時候的回調函數(後面那個回調看上去沒卵用,在某些狀況下卻好使,不須要的話不傳就行了);

而後爲整個canvas容器綁定原生的mousedownmousemove事件;

mousedown觸發時,發射線拿到點擊到的第一個物體,看它身上是否有_click方法,有就執行;接着,遍歷場景中除自身以外的直系子元素(就是被scene.add方法加入至場景的那些物體),看看它們身上有_clickBack方法,有就執行下。

mousemove用來模擬物體的hover事件,當鼠標在屏幕移動時,同以前同樣用射線獲取到鼠標接觸到的物體,執行其_hover方法,場景中的其餘物體執行其_hoverBack方法,值得注意的是,我多添加了一個_hover_enter的標誌變量來區分鼠標在當前物體的狀態,是鼠標已在物體之上仍是鼠標已離開物體,避免回調函數重複執行。

可是!當點擊物體的時候,你覺得你的射線小能手幫你拿到的就必定是你看到的這個物體的本體了嗎!!!!∑(゚Д゚ノ)ノ

當使用複雜的外部模型的時候,導入獲得的多是一個物體組合(Group),它裏面的children共同組裝成了它本體的樣子;例如導入了大黃蜂模型,你想實如今點擊到它的時候它可以原地旋轉360度,因而你biu一個射線發了出去,自信滿滿地將返回的第一個物體進行變換,結果你可能發現,只是它的一隻胳膊開始跳舞,亦或者空氣忽然安靜

爲了解決可能存在的上述問題,上面的代碼裏添加了parentUtilScene函數,用來層層往上尋找物體的父級容器,直至它是場景中的直系子元素,這樣拿到的纔是該模型的完總體。

示例,爲上述導入的模型添加點鼠標交互:

addObjs(){
    this.loader(['obj/bumblebee/bumblebee.FBX', 'obj/teapot.js', 'obj/monu9.objmtl']).then((result) => {
        let bumblebee = result[0];
        let teapot = new THREE.Mesh(result[1].geometry, result[1].material);
        let monu = result[2];

        bumblebee.scale.x = 0.03;
        bumblebee.scale.y = 0.03;
        bumblebee.scale.z = 0.03;
        bumblebee.rotateX(-Math.PI / 2);
        bumblebee.position.y -= 30;

        teapot.applyMatrix(new THREE.Matrix4().makeTranslation(0, -30, 20));
        teapot.scale.x = 0.2;
        teapot.scale.y = 0.2;
        teapot.scale.z = 0.2;

        monu.applyMatrix(new THREE.Matrix4().makeTranslation(0, -30, 0));

        this.onShadow(monu);
        this.onShadow(bumblebee);
        this.onShadow(teapot);
        // 大黃蜂模型被點擊時向z軸移動一段距離
        bumblebee.on("click", function() {
            let tween = new TWEEN.Tween(this.position).to({
                z: -10
            }, 200).easing(TWEEN.Easing.Quadratic.InOut).start();
        });
        // 茶壺模型在鼠標懸浮時放大,鼠標離開時縮小
        teapot.on("hover", function() {
            let tween = new TWEEN.Tween(this.scale).to({
                x: 0.3,
                y: 0.3,
                z: 0.3
            }, 200).easing(TWEEN.Easing.Quadratic.InOut).start();
        }, function() {
            let tween = new TWEEN.Tween(this.scale).to({
                x: 0.2,
                y: 0.2,
                z: 0.2
            }, 200).easing(TWEEN.Easing.Quadratic.InOut).start();
        });

        this.scene.add(bumblebee);
        this.scene.add(teapot);
        this.scene.add(monu);
    });
}
複製代碼


4、 3D粒子系統構建

上面爲了大概解釋清楚Three.js基本價值觀,弘揚社會主義我爲人人、知識共享的精神風貌,我如今竟然纔開始着手實現文章開頭的粒子效果... 痛苦流涕頓首頓首 ヾ(༎ຶД༎ຶ)ノ"

不,我只是被風沙迷了眼。

咳~

一段最簡單的粒子系統代碼:

// 建立球體模型
let ball = new THREE.SphereGeometry(40, 30, 30);
// 建立粒子材料
let pMaterial = new THREE.PointsMaterial({
        // 粒子顏色
        color: 0xffffff,
        // 粒子大小
        size: 2
    });
// 建立粒子系統
let particleSystem = new THREE.ParticleSystem(ball, pMaterial);
// 加入場景
this.scene.add(particleSystem);
複製代碼

粒子系統讀取模型中的vertices屬性,即全部頂點位置,結合粒子材質來建立粒子效果,以上代碼效果以下;能夠觀察到,粒子默認會展現爲方塊形狀,若要修改,能夠在構建粒子材質時時傳入map屬性,應用一張圖片或者應用canvas的繪圖結果,具體後面會提到。


5、 粒子變換

粒子的變換說到底就是粒子的位置顏色尺寸的變換,渲染方式根據計算場景的不一樣可分爲CPU渲染GPU渲染

針對粒子渲染粗暴來講,

將全部粒子的狀態所有維護在js代碼中進行計算,屬於CPU渲染

將粒子的狀態信息維護在shader(着色器)代碼中進行計算,屬於GPU渲染

當咱們只需簡單改變各個粒子的位置時,使用CPU渲染性能ok也易於理解,假若同時對全部粒子進行多狀態的變化,使用GPU渲染會更加流暢。

這裏會使用GPU渲染方法,即須要編寫shader程序。

仍是以前的一樣簡單的粒子效果,使用最基本的shader介入後代碼以下:

<!-- html中加入shader代碼 -->
<!-- 頂點着色器代碼 -->
<script type="x-shader/x-vertex" id="vertexshader"> void main() { gl_PointSize = 4.; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } </script>
<!-- 片元着色器代碼 -->
<script type="x-shader/x-fragment" id="fragmentshader"> uniform vec3 color; void main() { gl_FragColor = vec4(color, 1.0); } </script>
複製代碼
addObjs(){
    // 加載星球大戰裏那個叫「BB-8」的機器人模型
    this.loader(['obj/robot.fbx']).then((result) => {
        // 提取出其幾何模型
        let robotObj = result[0].children[1].geometry;
        // 適當變換使其完整在屏幕顯示
        robotObj.scale(0.08, 0.08, 0.08);
        robotObj.rotateX(-Math.PI / 2);
        robotObj.applyMatrix(new THREE.Matrix4().makeTranslation(0, 10, 0));
        // 把它變成粒子
        this.addPartice(robotObj);
    });
}
// 將幾何模型變成幾何緩存模型
toBufferGeometry(geometry) {
    if (geometry.type === 'BufferGeometry') return geometry;
    return new THREE.BufferGeometry().fromGeometry(geometry);
}
// 模型轉化成粒子
addPartice(obj) {
    obj = this.toBufferGeometry(obj);
    // 傳遞給shader的屬性
    let uniforms = {
        // 傳遞的顏色屬性
        color: {
            type: 'v3', // 指定變量類型爲三維向量
            value: new THREE.Color(0xffffff)
        }
    };
    // 建立着色器材料
    let shaderMaterial = new THREE.ShaderMaterial({
        // 傳遞給shader的屬性
        uniforms: uniforms,
        // 獲取頂點着色器代碼
        vertexShader: document.getElementById('vertexshader').textContent,
        // 獲取片元着色器代碼
        fragmentShader: document.getElementById('fragmentshader').textContent,
        // 渲染粒子時的融合模式
        blending: THREE.AdditiveBlending,
        // 關閉深度測試
        depthTest: false,
        // 開啓透明度
        transparent: true
    });
    let particleSystem = new THREE.Points(obj, shaderMaterial);
    this.scene.add(particleSystem);
}
複製代碼

效果以下:

什麼!這不和上一段短短几行代碼實現的效果一毛同樣嗎!爲啥還要多寫這麼多東西!

可是在這基礎上只改一行代碼:

<script type="x-shader/x-vertex" id="vertexshader"> void main() { // 這是被修改的那一行 gl_PointSize = 4. + 2. * sin(position.y / 4.); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } </script>
<script type="x-shader/x-fragment" id="fragmentshader"> uniform vec3 color; void main() { gl_FragColor = vec4(color, 1.0); } </script>
複製代碼

就能實現粒子尺寸沿Y軸週期性變化的效果:

如今又輪到我開始裝模做樣地解釋事情的前因後果:

1. 着色器(shader)是什麼?

(看名字好像就是用來上色的)

着色器是屏幕上呈現畫面以前的最後一步,用它能夠對先前渲染的結果作修改,包括對顏色、位置等等信息的修改,甚至能夠對先前渲染的結果作後處理,實現高級的渲染效果。

經常使用的着色器分爲頂點着色器(Vertex Shader)片元着色器(Fragment Shader)

頂點着色器:每一個頂點都調用一次的程序,在這之中可以訪問到頂點的位置顏色法向量等信息,對他們進行適當的計算能實現特定的渲染效果,也能夠將這些值傳遞到片元着色器中。

片元着色器:每一個片元都調用一次的程序,在這之中,能夠訪問到片元在二維屏幕上的座標、深度信息、顏色等信息。經過改變這些值,能夠實現特定的渲染效果。

2. shader程序裏基本有哪些東西?

shader程序是一種類C語言,void main(){}是程序的入口,在這以前需聲明需使用到的傳遞來的變量,例如uniform vec3 color;,分別表明變量傳遞類型 變量類型 變量名;

(「變量傳遞類型」這名字是我本身取的,便於理解)

變量傳遞類型有三種:

  • attribute:用來接受CPU傳遞過來的頂點數據,通常用來接收從js代碼中傳遞來的頂點座標法線紋理座標頂點顏色等信息,attribute 只能在頂點着色器中被聲明與使用
  • uniform:能夠在頂點着色器和片元着色器中共享使用,且聲明方式相同,通常用來聲明變換矩陣材質光照參數顏色等信息。
  • varying:它是vertex和fragment shader之間作數據傳遞用的。通常vertex shader修改varying變量的值,而後fragment shader使用該varying變量的值。所以varying變量在vertex和fragment shader兩者之間的聲明必須是一致的

變量類型有如下幾種:

  • void:和C語言的void同樣,無類型。
  • bool:布爾類型。
  • int:有符號整數。
  • float 浮點數。
  • vec2, vec3, vec4: 2,3,4維向量,也能夠理解爲2,3,4長度的數組。
  • bvec2, bvec3, bvec4:2,3,4維的布爾值的向量。
  • ivec2, ivec3, ivec4: 2,3,4維的int值的向量。
  • mat2, mat3, mat4: 2x2, 3x3, 4x4的浮點數矩陣。
  • sampler2D:紋理。
  • samplerCube:Cube紋理。

因爲是類C語言,不像js那樣會對變量類型進行自動隱式轉換,因此變量在使用前需嚴格聲明,並且在數字運算時,相同類型的數字才能進行加減乘除,例如1 + 1.0會報錯。

變量精度

用它們在變量類型前作修飾(例如varying highp float my_number

  • highp:16bit,浮點數範圍(-2^62, 2^62),整數範圍(-2^16, 2^16)
  • mediump:10bit,浮點數範圍(-2^14, 2^14),整數範圍(-2^10, 2^10)
  • lowp:8bit,浮點數範圍(-2, 2),整數範圍(-2^8, 2^8)

若是想設置全部的float都是高精度的,能夠在Shader頂部聲明precision highp float;,這樣就不須要爲每個變量聲明精度了。

shader中向量的訪問:

當咱們有一個vec4的四維向量時:

(這靈活的取值也是驚到我了)

  • vec4.x, vec4.y, vec4.z, vec4.w 經過x,y,z,w能夠分別取出4個值。
  • vec4.xy, vec4.xx, vec4.xz, vec4.xyz 任意組合能夠取出多種維度的向量。
  • vec4.r, vec4.g, vec4.b, vec4.a 還能夠經過r,g,b,a分別取出4個值,同上能夠任意組合。
  • vec4.s, vec4.t, vec4.p, vec4.q 還能夠經過s,t,p,q分別取出4個值,同上能夠任意組合。
  • vec3和vec2也是相似,只是變量相對減小,好比vec3只有x,y,z,vec2只有x,y。

shader內置變量

  • gl_Position:用於vertex shader, 寫頂點位置;被圖元收集、裁剪等固定操做功能所使用;其內部聲明是:highp vec4 gl_Position;
  • gl_PointSize:用於vertex shader, 寫光柵化後的點大小,像素個數;其內部聲明是:mediump float gl_Position;
  • gl_FragColor:用於Fragment shader,寫fragment color;被後續的固定管線使用;mediump vec4 gl_FragColor;
  • gl_FragData:用於Fragment shader,是個數組,寫gl_FragData[n] 爲data n;被後續的固定管線使用;mediump vec4 gl_FragData[gl_MaxDrawBuffers]; gl_FragColor和gl_FragData是互斥的,不會同時寫入;
  • gl_FragCoord: 用於Fragment shader,只讀,Fragment相對於窗口的座標位置 x,y,z,1/w; 這個是固定管線圖元差值後產生的;z 是深度值;mediump vec4 gl_FragCoord;
  • gl_FrontFacing: 用於判斷 fragment是否屬於 front-facing primitive,只讀;bool gl_FrontFacing;
  • gl_PointCoord:僅用於 point primitive, mediump vec2 gl_PointCoord;

shader內置函數:

3. shader程序該怎麼寫?

先看一遍vertex shader的基本的代碼:

(之因此包在script便籤裏只是爲了方便three.js獲取其文本值)

<!-- 頂點着色器-->
<script type="x-shader/x-vertex" id="vertexshader"> void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } </script>
複製代碼

vertex shader中必須指定gl_Position的值,由於頂點着色器的最重要的任務就是計算出頂點的在屏幕上的真實位置,沒有計算該值就至關於該頂點着色器無效了。

那麼後面沒聲明又忽然冒出來的projectionMatrixmodelViewMatrixposition又是什麼鬼?

看名字,分別是投影矩陣模型視圖矩陣位置;它們是頂點着色器運行時被自動計算好了傳入的。那爲何gl_Position要這麼計算?

position好說,指當表明下頂點的位置的三維向量,是模型裏頂點的那些xyz,可是隻知道這個值,渲染器是不足以畫出它在哪兒的,由於電腦屏幕是二維的,相機位置也是可能變化的,用來顯示的窗口大小也是不固定的...

因此就須要用到矩陣變換

首先祭出大神畫的示意圖:

口述下過程:一個物體的三維座標向量,乘以模型視圖矩陣後,可以獲得它在視圖座標系中的位置,也就是它相對於攝像機的座標位置;接着乘以投影矩陣,將每一個點的位置一巴掌拍到了二維平面上,獲得它在投影座標系中的位置;再乘以視口矩陣,獲得它在屏幕座標系中的位置,也就是咱們端坐在電腦前看到的模樣。

因此能夠獲得變換公式:

因爲矩陣乘法比較耗時,而視圖矩陣模型矩陣又一般不變,因此根據矩陣結合律,能夠先將它們的乘積 先計算並緩存下來,這即是modelViewMatrix,而模型點座標從本來的三維向量(position)被擴展到了四維向量(vec4(position, 1.0)),是由於其餘的矩陣實際上是4*4的,不將點座標擴展到四維便沒法相乘;

問題又來了,三維世界裏爲何要搞出四維的矩陣?

簡單解釋,三維矩陣能夠實現位置的旋轉縮放,但若想平移,還需補充一個齊次的一維,較詳細的解釋可參考這篇文章

弄清楚這些後,咱們想使用vertex shader花裏胡哨的動態改變模型點的位置,就能夠在這個相乘過程當中的任意位置下文章。

輪到vertex shader的基本的代碼了:

<script type="x-shader/x-fragment" id="fragmentshader"> uniform vec3 color; void main() { gl_FragColor = vec4(color, 1.0); } </script>
複製代碼

片元着色器的終極任務就是計算頂點顏色,因此在該段程序裏必須給出gl_FragColor的值,它同位置座標同樣,也是一個四維向量,前三維能夠理解爲rbg的色值,只不過範圍在(0.0,1.0),第四維表明顏色透明度。通常頂點的初始顏色可由CPU經過uniform傳入,再通過本身一頓花裏胡哨的計算以後給到gl_FragColor

另外這裏面向量的賦值方法頗有趣也很符合直覺;

gl_FragColor = vec4(color, 1.0);將三維向量color自動展開成了四維向量的前三位;

若是這麼寫:gl_FragColor = vec4(1.0, color);,那麼傳遞的顏色值中的藍色通道值就做用在了透明度上。

固然,最老實的一種:gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);,心無雜念純淨至白。


6、 shader渲染粒子特效

鋪墊了這麼多!終於開工了開工了!

1. 粒子位移變換

嘩啦啦上代碼:

addObjs() {
    // 加載了兩個模型,用於粒子變換
    this.loader(['obj/robot.fbx', 'obj/Guitar/Guitar.fbx']).then((result) => {
        let robot = result[0].children[1].geometry;
        let guitarObj = result[1].children[0].geometry;
        guitarObj.scale(1.5, 1.5, 1.5);
        guitarObj.rotateX(-Math.PI / 2);
        robot.scale(0.08, 0.08, 0.08);
        robot.rotateX(-Math.PI / 2);
        this.addPartices(robot, guitarObj);
    });
}
// 幾何模型轉緩存幾何模型
toBufferGeometry(geometry) {
    if (geometry.type === 'BufferGeometry') return geometry;
    return new THREE.BufferGeometry().fromGeometry(geometry);
}
// 粒子變換
addPartices(obj1, obj2) {
    obj1 = this.toBufferGeometry(obj1);
    obj2 = this.toBufferGeometry(obj2);
    let moreObj = obj1
    let lessObj = obj2;
    // 找到頂點數量較多的模型
    if (obj2.attributes.position.array.length > obj1.attributes.position.array.length) {
        [moreObj, lessObj] = [lessObj, moreObj];
    }
    let morePos = moreObj.attributes.position.array;
    let lessPos = lessObj.attributes.position.array;
    let moreLen = morePos.length;
    let lessLen = lessPos.length;
    // 根據最大的頂點數開闢數組空間,同於存放頂點較少的模型頂點數據
    let position2 = new Float32Array(moreLen);
    // 先把頂點較少的模型頂點座標放進數組
    position2.set(lessPos);
    // 剩餘空間重複賦值
    for (let i = lessLen, j = 0; i < moreLen; i++, j++) {
        j %= lessLen;
        position2[i] = lessPos[j];
        position2[i + 1] = lessPos[j + 1];
        position2[i + 2] = lessPos[j + 2];
    }
    // sizes用來控制每一個頂點的尺寸,初始爲4
    let sizes = new Float32Array(moreLen);
    for (let i = 0; i < moreLen; i++) {
        sizes[i] = 4;
    }
    // 掛載屬性值
    moreObj.addAttribute('size', new THREE.BufferAttribute(sizes, 1));
    moreObj.addAttribute('position2', new THREE.BufferAttribute(position2, 3));
    // 傳遞給shader共享的的屬性值
    let uniforms = {
        // 頂點顏色
        color: {
            type: 'v3',
            value: new THREE.Color(0xffffff)
        },
        // 傳遞頂點貼圖
        texture: {
            value: this.getTexture()
        },
        // 傳遞val值,用於shader計算頂點位置
        val: {
            value: 1.0
        }
    };
    // 着色器材料
    let shaderMaterial = new THREE.ShaderMaterial({
        uniforms: uniforms,
        vertexShader: document.getElementById('vertexshader').textContent,
        fragmentShader: document.getElementById('fragmentshader').textContent,
        blending: THREE.AdditiveBlending,
        depthTest: false,// 這個不設置的話,會致使帶透明色的貼圖始終會有方塊般的黑色背景
        transparent: true
    });
    // 建立粒子系統
    let particleSystem = new THREE.Points(moreObj, shaderMaterial);
    let pos = {
        val: 1
    };
    // 使val值從0到1,1到0循環往復變化
    let tween = new TWEEN.Tween(pos).to({
        val: 0
    }, 1500).easing(TWEEN.Easing.Quadratic.InOut).delay(1000).onUpdate(callback);
    let tweenBack = new TWEEN.Tween(pos).to({
        val: 1
    }, 1500).easing(TWEEN.Easing.Quadratic.InOut).delay(1000).onUpdate(callback);
    tween.chain(tweenBack);
    tweenBack.chain(tween);
    tween.start();
    // 每次都將更新的val值賦值給uniforms,讓其傳遞給shader
    function callback() {
        particleSystem.material.uniforms.val.value = this.val;
    }
    // 粒子系統添加至場景
    this.scene.add(particleSystem);
    this.particleSystem = particleSystem;
}
// 用canvas畫了個帶漸變的圓,將該圖像做爲紋理返回
getTexture(canvasSize = 64) {
    let canvas = document.createElement('canvas');
    canvas.width = canvasSize;
    canvas.height = canvasSize;
    canvas.style.background = "transparent";
    let context = canvas.getContext('2d');
    let gradient = context.createRadialGradient(canvas.width / 2, canvas.height / 2, canvas.width / 8, canvas.width / 2, canvas.height / 2, canvas.width / 2);
    gradient.addColorStop(0, '#fff');
    gradient.addColorStop(1, 'transparent');
    context.fillStyle = gradient;
    context.beginPath();
    context.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2, 0, Math.PI * 2, true);
    context.fill();
    let texture = new THREE.Texture(canvas);
    texture.needsUpdate = true;
    return texture;
}
複製代碼

shader代碼以下:

<script type="x-shader/x-vertex" id="vertexshader"> attribute float size; attribute vec3 position2; uniform float val; void main() { vec3 vPos; // 變更的val值引導頂點位置的遷移 vPos.x = position.x * val + position2.x * (1. - val); vPos.y = position.y * val + position2.y * (1. - val); vPos.z = position.z * val + position2.z * (1. - val); vec4 mvPosition = modelViewMatrix * vec4( vPos, 1.0 ); gl_PointSize = 4.; gl_Position = projectionMatrix * mvPosition; } </script>
<script type="x-shader/x-fragment" id="fragmentshader"> uniform vec3 color; uniform sampler2D texture; void main() gl_FragColor = vec4( color, 1.0 ); // 頂點顏色應用上2D紋理 gl_FragColor = gl_FragColor * texture2D( texture, gl_PointCoord ); } </script>
複製代碼

超簡單地歸納上面的流程:

(1)加載了兩個幾何模型並它們變成緩存幾何模型(效率更高)

(2)用點多的那個模型構建粒子系統,保存另外一個模型的頂點位置至position2屬性

(3)js只維護並改變val值並更傳遞到頂點着色器,着色點根據val值計算,使頂點座標在position(頂點多的模型的頂點位置)position2(頂點少的模型的頂點位置)之間過渡。

效果以下:

2. 粒子尺寸變換

以前代碼裏往shader裏傳入了size屬性但並無用到,如今用js改變這這個值並傳遞到shader。

在循環渲染的函數里根據時間修改size值:

update() {
    TWEEN.update();
    this.stats.update();
    // 動態改變size大小
    let time = Date.now() * 0.005;
    if (this.particleSystem) {
        let bufferObj = this.particleSystem.geometry;
        // 粒子系統緩緩旋轉
        this.particleSystem.rotation.y = 0.01 * time;
        let sizes = bufferObj.attributes.size.array;
        let len = sizes.length;
        for (let i = 0; i < len; i++) {
            sizes[i] = 1.5 * (2.0 + Math.sin(0.02 * i + time));
        }
        // 需指定屬性須要被更新
        bufferObj.attributes.size.needsUpdate = true;
    }
    this.renderer.render(this.scene, this.camera);
    requestAnimationFrame(() => {
        this.update()
    });
}
複製代碼

vertex shader中,只須要把

gl_PointSize = 4.;
複製代碼

替換爲

// size在以前已經用attribute聲明過了
gl_PointSize = size;
複製代碼

而後這粒子變換效果就帶了些許 buling buling 亮晶晶(gif圖上傳被壓縮了,因此顯得在快放):

3. 粒子模糊效果

這裏爲了模擬粒子越遠越模糊的效果,先計算粒子的模型視圖座標,即從相機看到的粒子座標,而後跟據其z軸值計算粒子的尺寸透明度,離視線越遠的粒子,使其尺寸越大,同時透明度越小

<script type="x-shader/x-vertex" id="vertexshader"> attribute float size; attribute vec3 position2; uniform float val; // 顏色透明度 varying float opacity; void main() { // 開始產生模糊的z軸分界 float border = -150.0; // 最模糊的z軸分界 float min_border = -160.0; // 最大透明度 float max_opacity = 1.0; // 最小透明度 float min_opacity = 0.03; // 模糊增長的粒子尺寸範圍 float sizeAdd = 20.0; vec3 vPos; vPos.x = position.x * val + position2.x * (1.-val); vPos.y = position.y* val + position2.y * (1.-val); vPos.z = position.z* val + position2.z * (1.-val); vec4 mvPosition = modelViewMatrix * vec4( vPos, 1.0 ); // z軸座標越小越模糊,即越遠越模糊 if(mvPosition.z > border){ opacity = max_opacity; gl_PointSize = size; }else if(mvPosition.z < min_border){ opacity = min_opacity; gl_PointSize = size + sizeAdd; }else{ // 模糊程度隨距離遠近線性增加 float percent = (border - mvPosition.z)/(border - min_border); opacity = (1.0-percent) * (max_opacity - min_opacity) + min_opacity; gl_PointSize = percent * (sizeAdd) + size; } gl_Position = projectionMatrix * mvPosition; } </script>
<script type="x-shader/x-fragment" id="fragmentshader"> uniform vec3 color; uniform sampler2D texture; varying float opacity; void main() { // 根據傳遞過來的透明度值設置顏色 gl_FragColor = vec4(color, opacity); gl_FragColor = gl_FragColor * texture2D( texture, gl_PointCoord ); } </script>
複製代碼

改造以後的效果以下:

固然,咱們能夠暫時去掉模型的變換動畫,直接用鼠標拖拽模型,更好的觀察粒子隨距離的變化。

4. 粒子彩色分佈

(原諒我又把代碼扔一遍) 這裏作了小小改動,增長了varying vec3 vColor,在vertex shader中根據頂點在模型視圖座標系中的y軸座標計算了vColor的值,並傳遞至fragment shader,從而使粒子產生橫向分佈的彩色條紋。

<script type="x-shader/x-vertex" id="vertexshader"> attribute float size; attribute vec3 position2; uniform float val; // 顏色透明度 varying float opacity; // 傳遞給片元着色器的顏色值 varying vec3 vColor; void main() { // 開始產生模糊的z軸分界 float border = -150.0; // 最模糊的z軸分界 float min_border = -160.0; // 最大透明度 float max_opacity = 1.0; // 最小透明度 float min_opacity = 0.03; // 模糊增長的粒子尺寸範圍 float sizeAdd = 20.0; vec3 vPos; vPos.x = position.x * val + position2.x * (1.-val); vPos.y = position.y* val + position2.y * (1.-val); vPos.z = position.z* val + position2.z * (1.-val); vec4 mvPosition = modelViewMatrix * vec4( vPos, 1.0 ); // z軸座標越小越模糊,即越遠越模糊 if(mvPosition.z > border){ opacity = max_opacity; gl_PointSize = size; }else if(mvPosition.z < min_border){ opacity = min_opacity; gl_PointSize = size + sizeAdd; }else{ // 模糊程度隨距離遠近線性增加 float percent = (border - mvPosition.z)/(border - min_border); opacity = (1.0-percent) * (max_opacity - min_opacity) + min_opacity; gl_PointSize = percent * (sizeAdd) + size; } float positionY = vPos.y; // 根據y軸座標計算傳遞的頂點顏色值 vColor.x = abs(sin(positionY)); vColor.y = abs(cos(positionY)); vColor.z = abs(cos(positionY)); gl_Position = projectionMatrix * mvPosition; } </script>
<script type="x-shader/x-fragment" id="fragmentshader"> uniform vec3 color; uniform sampler2D texture; varying float opacity; varying vec3 vColor; void main() { // 根據傳遞過來的顏色及透明度值計算最終顏色 gl_FragColor = vec4(vColor * color, opacity); gl_FragColor = gl_FragColor * texture2D( texture, gl_PointCoord ); } </script>
複製代碼

5. 粒子顏色變換

一成不變的彩色略顯單調,再改造下粒子運行動畫時候的屬性值,在每輪動畫結束後隨機生成新的粒子顏色值,而後在粒子模型變換過程當中,同時將舊顏色過渡到新顏色。

let tween = new TWEEN.Tween(pos).to({
    val: 0
}, 1500).easing(TWEEN.Easing.Quadratic.InOut).delay(2000).onUpdate(updateCallback).onComplete(completeCallBack.bind(pos, 'go'));
let tweenBack = new TWEEN.Tween(pos).to({
    val: 1
}, 1500).easing(TWEEN.Easing.Quadratic.InOut).delay(2000).onUpdate(updateCallback).onComplete(completeCallBack.bind(pos, 'back'));
tween.chain(tweenBack);
tweenBack.chain(tween);
tween.start();
// 動畫持續更新的回調函數
function updateCallback() {
    particleSystem.material.uniforms.val.value = this.val;
    // 顏色過渡
    if (this.nextcolor) {
        let val = (this.order === 'back' ? (1 - this.val) : this.val);
        let uColor = particleSystem.material.uniforms.color.value;
        uColor.r = this.color.r + (this.nextcolor.r - this.color.r) * val;
        uColor.b = this.color.b + (this.nextcolor.b - this.color.b) * val;
        uColor.g = this.color.g + (this.nextcolor.g - this.color.g) * val;
    }
}
// 每輪動畫完成時的回調函數
function completeCallBack(order) {
    let uColor = particleSystem.material.uniforms.color.value;
    // 保存動畫順序狀態
    this.order = order;
    // 保存舊的粒子顏色
    this.color = {
        r: uColor.r,
        b: uColor.b,
        g: uColor.g
    }
    // 隨機生成將要變換後的粒子顏色
    this.nextcolor = {
        r: Math.random(),
        b: Math.random(),
        g: Math.random()
    }
}
this.scene.add(particleSystem);
this.particleSystem = particleSystem;
複製代碼

至此,咱們就一步步實現了文章開頭出現的粒子特效!

(忍不住再放一遍圖)

固然,數學再好一點,結合一些神奇的非線性函數,能夠玩出更華麗麗的效果 ~


如今是大概真寫完了

我沒想到這篇文章寫起來剎不住... (´-ι_-`)

感謝各位看官耐心看我絮絮不休了這麼多 ~

雖然這些還僅僅只是網頁虛擬三維世界裏的冰山一角,但可以對有興趣的初學者有所裨益,本篇的目的就達到了;我得認可我也只是多本身翻了點資料的初學者,文章裏如有理解誤區,望路過的大神瞥眼發現後友情提醒,提早拜謝 ~

代碼地址:(拷貝下來需在服務器上運行)

github.com/youngdro/3D…

裏面有顆耐心等待被戳的star,你看它長這樣:★

在線預覽傳送門

--------- 致謝分割線 ---------

本文借鑑瞭如下兩篇文章的部分代碼與圖片,向前輩致敬:

three.js粒子效果(分別基於CPU&GPU實現)

卡通渲染(上)

--------- 廣告分割線 ---------

往期段子文:

console覺醒之路,打印個動畫如何?

node基金爬蟲,自導自演瞭解一下?

相關文章
相關標籤/搜索