從零開始手擼WebGL3D引擎10: 材質,前向渲染以及里程碑4

前言

本篇總結一下里程碑3到4階段的材質和前向渲染的實現。本部分仍然處於早期,爲了看到效果,很多地方只有思路沒有實現,代碼裏有很多TODO。儘管很不完善,但是還是可以用來實現一些渲染技術和效果的,比如里程碑4的法線貼圖和RenderTexture鏡子。
mini3d.js法線貼圖演示

材質封裝

在實現之前,對於材質我想可能用一個json文件描述吧,但最終我選擇了直接用代碼寫材質。一方面定義並解析一個json格式或其他格式的材質很費時間,另外目前對於材質的需求就是簡單好用,能快速實現效果,並且手寫shader的方案想做的很完善也是有很多的工作要做的。在實現的過程中我感覺到,最終的方向應該還是自動生成shader代碼吧,畢竟shader往往需要很多變體,並不是你寫一套shader就完事的,爲了處理很多不同的情況,又爲了避免簡單複製shader代碼,在shader中定義不同的編譯選項是不可避免的,這麼走下去就是Unity的shader實現了,看上去是寫了CG,實際背後引擎做了很多事情,最後還是會生成shader代碼。而且生成代碼還可以接上連節點的圖形shader編輯器。好了,回到mini3d.js,看看目前的方案解決了哪些問題。

多個Pass

很多渲染技術都需要多個Pass,而且在多光源渲染時,多Pass渲染也是一個方案。那麼每個pass就是對應一個shader program,而且同一個材質的同一個pass的不同實例,是共享一個shader program的,這避免了創建重複program以及切換program的開銷。但是不同的pass可能是需要使用不同的uniform的,那麼我們要將uniform放到pass級別去設置嗎?這樣易用性差一些。我還是選擇在材質這一級設置uniform。然後在爲每個pass設置uniform時先判斷一下是否存在,由於shader的封裝,這個操作很簡單。

創建Shader

材質的基類Material提供了一個靜態方法去創建Shader

	static createShader(vs, fs, attributesMap){
        let shader = new Shader();
        if (!shader.create(vs, fs)) {
            console.log("Failed to initialize shaders");
            //Set to a default error replace shader
            shader.create(vs_errorReplace, fs_errorReplace);            
        }
        shader.setAttributesMap(attributesMap);
        return shader;
    }

這個方法傳入vs/fs代碼,以及屬性映射(之前Shader的封裝講過)。如果shader創建失敗,則會創建一個特殊的顯示紫色的shader來提醒開發者。

創建pass並添加到材質

首先,基類提供了一個方法addRenderPass創建pass並添加到材質中。

	addRenderPass(shader, lightMode=LightMode.None){
        let pass = new RenderPass(lightMode);
        pass.shader = shader;
        pass.index = this.renderPasses.length;
        this.renderPasses.push(pass);
        return pass;
    }

lightMode參數用來定義該pass在渲染路徑中的作用,比如是基礎pass還是額外pass,亦或者是投影pass。後面前向渲染會講到。
創建pass是在材質類的構造函數中完成,首先會使用上面的createShader代碼,每個pass都要調用一次,並且對於所有實例只保存一個shader對象。然後傳入shader調用addRenderPass創建pass並添加到材質。

shader代碼寫在哪兒

createShader方法接收代碼字符串,因此只要在創建材質時能獲取到shader的字符串即可。引擎內置的材質爲了方便是自己寫在材質代碼中的。當然用戶是可以將shader單獨存放到文件中,並使用引擎提供的資源管理載入shader文本。但是我覺得直接寫在材質類中有利於保持完整性。

System Uniforms

因爲很多材質都會使用一些同樣的uniform,比如MVP矩陣,因此我將這類常用的uniform單獨拿出來,組成一組SystemUniforms。材質可以定義自己使用了哪些systemUniform。渲染框架會在渲染之前給材質設置上它使用到的system uniform,並且這些uniform的計算都是引擎提供的,這樣自定義的材質可以很好的和引擎結合使用。舉個例子,在matNormalMap這個材質中,我們需要這些system uniform:

	//Override
    get systemUniforms(){
        return [SystemUniforms.MvpMatrix,
            SystemUniforms.World2Object,
            SystemUniforms.Object2World,
            SystemUniforms.WorldCameraPos,
            SystemUniforms.SceneAmbient,
            SystemUniforms.LightColor, SystemUniforms.WorldLightPos]; 
    }

Custom Uniforms

相對於預定義並且引擎提供計算的System uniform,其他材質使用的Uniform被稱爲custom uniform。在材質的基類Material中,定義了一個接口:

	//Override
    //材質子類中手動設置uniform,需要重載
    setCustomUniformValues(pass){

    }

這個接口會在渲染pass之前被調用,因此具體的材質類只要實現這個接口,就可以設置自定義的屬性了。例如matNormalMap中:

	//Override
    setCustomUniformValues(pass){                           
        pass.shader.setUniformSafe('u_specular', this._specular);
        pass.shader.setUniformSafe('u_gloss', this._gloss);
        pass.shader.setUniformSafe('u_colorTint', this._colorTint);
        pass.shader.setUniformSafe('u_texMain_ST', this._mainTexture_ST); 
        pass.shader.setUniformSafe('u_normalMap_ST', this._normalMap_ST);     
        if(this._mainTexture){
            this._mainTexture.bind(0);
            pass.shader.setUniformSafe('u_texMain', 0);
        }  
        if(this._normalMap){
            this._normalMap.bind(1);
            pass.shader.setUniformSafe('u_normalMap', 1);
        }
        
    }

使用setUniformSafe的原因是渲染pass時並不知道pass使用了哪部分的自定義屬性,因爲材質可能有多個pass,而Uniform是定義在材質這一層上,作爲材質的屬性的。所以setUniformSafe內部會檢查shader是否使用了這個屬性。

渲染pass

	renderPass(mesh, context, pass){
        pass.shader.use();
        this.setSysUniformValues(pass, context);
        this.setCustomUniformValues(pass);
        mesh.render(pass.shader);
    }

很簡單,設置當前pass的shader爲use,設置system uniform和custom uniform,然後對於mesh調用render,傳入pass的shader。這兒context的作用傳入引擎計算好的system uniform。

引擎提供的材質

到里程碑4,引擎提供了單色,逐頂點光照,逐像素光照,法線貼圖,鏡子等材質。這個階段的材質還沒有處理陰影和Lightmap。後期會繼續添加各種材質,並且會增加陰影和lightmap的支持。

前向渲染

在延遲渲染出現之後,傳統的渲染路徑被稱作前向渲染。簡單來說,就是針對每個camera可以看到/渲染的物體逐個進行渲染。如果物體受光照,還要根據某種規則選擇可以照亮它的燈光。另外,由於透明物體和不透明物體的渲染順序不同,以及會有一些特殊的需求,產生了渲染隊列的概念。物體被放到不同的隊列裏面去渲染。在不同的隊列中,物體可能需要按材質以及深度進行排序,這是爲了優化(比如降低填充以及有利於batch)或者半透明物體渲染的正確性。這些概念,幾乎所有的3D引擎都有,而且從早期的固定流水線時代就已經存在。回到mini3d.js,我們探索一下這些成熟的技術,很多隻是從使用方式上了解某某引擎是這麼設定的,並沒有源碼可以參考,所以符合我們從零開始手擼的設定,當然目前不可能做的那麼完善,因此留着一些TODO以後去完善,主要是優化方面的。目前實現的部分是多camera多光源多pass的不透明物體的渲染。暫時沒有渲染隊列,等到後面實現半透明物體渲染時再添加。暫時也沒有材質排序和深度排序,仍然留着後面完善。

scene.render()

render(){
        //TODO: 找出camera, 燈光和可渲染結點,逐camera進行forward rendering
        //1. camera frustum culling
        //2. 逐隊列渲染
        //   2-1. 不透明物體隊列,按材質實例將node分組,然後排序(從前往後)
        //   2-2, 透明物體隊列,按z序從後往前排列

        //TODO: camera需要排序,按指定順序渲染
        for(let camera of this.cameras){
            camera.beforeRender();
            //TODO:按優先級和範圍選擇燈光,燈光總數要有限制
            for(let rnode of this.renderNodes){
                rnode.render(this, camera, this.lights);
            }
            camera.afterRender();
        }
    }

場景管理了所有的節點,因此場景知道自己有哪些camera,有哪些燈光。可能從設計上來說,直接將render的入口放到scene並不好,至少也得有個sceneRenderer之類的東西吧。但是我不喜歡在事情沒那麼複雜之前把事情搞複雜。而且我對mini3d.js的定位是研究學習爲主,兼顧實用,所以結構越簡單越好。那麼直接render也挺好。代碼裏面看有很多的TODO,目前我們做的就是遍歷所有的camera,在渲染之前執行camera.beforeRender()這裏面會更新camera的視圖矩陣,設置clear color, depth,並且根據設置執行clear。然後我們對於每個camera,遍歷所有的可渲染節點,目前每個camera都會渲染場景中的所有可渲染節點,後期應該會加上一個culling layer/culling mask來區分,並且會加上frustum culling以及空間劃分優化。目前這些不是重點,目前的重點是執行渲染。對於每個可渲染節點,我們執行它的render方法,傳入當前場景,camera和燈光。之後調用camera.afterRender(),這裏面會做一些清理工作,例如如果這個camera是渲染到貼圖的,需要清空FBO綁定以恢復屏幕的view port。

renderer.render()

上面節點的render方法,其實是調用了其renderer組件的render方法。因爲節點使用對象組件模式,所以render方法只是一個包裝,其內部實現只是獲取renderer組件,如果存在,則調用renderer.render()。目前只實現了一個MeshRenderer,未來應該會有SkinnedMeshRenderer,所以代碼仍然會繼續重構。我們這兒討論的代碼是基於里程碑3和4的。雖然有很多東西都是TODO狀態,MeshRenderer.render()方法仍然有很多代碼,下面具體說一下整個流程。

傳入參數

render(scene, camera, lights){
 ...
}

需要傳入當前scene,camera和燈光。會從這些信息計算出uniform。

計算system uniform

第一步,會根據當前材質的systemUniforms,去按需計算用到的system uniform。所有計算好的uniform值,會存放到uniformContext這個map中,這個map會在renderPass時傳入。

燈光規則

這部分還沒完善,主要參考了Unity的設計。大體上來說是選出一個最亮的平行光作爲主光源。然後其他的光源作爲附加光源。這裏面有多種策略可選擇,比如你可以在一個shader裏面執行多個光源的光照計算,但由於FS中的計算不能太複雜,所以一般只在VS中這麼做。你也可以使用多Pass渲染來疊加多個光源的效果。這樣單個FS的計算不會很複雜,但代價是Pass次數增多了。目前採用的規則是第二個,只有第一個平行光作爲主光源,並執行lightMode爲ForwardBase的pass,其他光源都作爲額外光源,使用LightMode爲ForwardAdd的pass渲染多次併疊加。也就是說如果場景中有三個光源,一個平行光和兩個其他光源,那麼ForwardBase的pass會針對每個物體渲染一次,而ForwardAdd的pass會針對每個物體渲染兩次。

如何疊加多個pass的光照

採樣混合即可,混合Func爲gl.blendFunc(gl.ONE, gl.ONE);即直接加法疊加。所以要注意的是,自發光和環境光只能放到forwardBase中計算,否則會被疊加多次。設置好混合後並沒有看到疊加效果,爲什麼?因爲渲染同一個物體,由於深度相同,所以最終只有第一個pass的結果能看到。我不知道Unity具體是怎麼實現的。我使用了polygon offset,每個燈光渲染時稍微便宜一下。貌似是應該可以通過修改投影矩陣來達到同樣的效果。

非光照pass

直接使用material.renderPass渲染。

里程碑4

實現材質和前向渲染框架之後,順手實現了兩個材質作爲測試。一是法線貼圖,分別在切線空間和世界空間進行了計算,另外一個是基於RenderTexture的實時鏡子效果。
視頻如下:

mini3d.js 法線貼圖演示

mini3d.js 實時RT鏡子演示

在線體驗

Next

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