整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          一個能讓渲染性能提高100倍的辦法

          一個能讓渲染性能提高100倍的辦法

          PU 光線追蹤是當今的熱門話題,所以讓我們來談談它!今天我們將光線追蹤一個單個球體。

          使用片段著色器。

          是的,我知道。并不特別花哨。你可以在 Shadertoy 上搜索并獲得數百個示例(https://www.shadertoy.com/results?query=sphere)。甚至已經有一些很棒的教程教你如何做 球體Imposter
          (https://paroj.github.io/gltut/Illumination/Tutorial 13.html),
          這就是我們要做的。那么我為什么要寫另一篇關于它的文章呢?它甚至不是正確類型的 GPU 光線追蹤!

          好吧,因為光線追蹤部分并不是我真正要關注的部分。這篇文章更多的是關于如何在 Unity 中將不透明的光線追蹤或光線行進物體注入到光柵化場景中。但也介紹了一些處理渲染球體Imposter的額外技巧,這些技巧并不總是顯而易見或被我見過的其他教程所涵蓋。在這篇文章的最后,我們將得到一個緊湊的四邊形上的球體Imposter,它支持多個燈光、陰影投射、陰影接收和正交相機,用于內置的前向渲染器,幾乎完美地模擬了一個高多邊形網格。無需額外的 C# 腳本。

          我的第一個球體Imposter

          如引言中所述,這是一個已經被廣泛探索的領域。繪制球體的準確高效的數學方法已經為人所知。所以我只是要從 Inigo Quilez 的代碼中竊取適用的函數,來創建一個基本的光線追蹤球體著色器,我們可以將其貼到立方體網格上。

          https://www.iquilezles.org/www/articles/intersectors/intersectors.htm

          Inigo 的示例都是用 GLSL 編寫的。所以我們需要稍微修改一下代碼才能讓它適用于 HLSL。幸運的是,對于這個函數來說,這實際上只需要將 vec 替換成 float

          float sphIntersect( float3 ro, float3 rd, float4 sph )
          {
              float3 oc=ro - sph.xyz;
              float b=dot( oc, rd );
              float c=dot( oc, oc ) - sph.w*sph.w;
              float h=b*b - c;
              if( h<0.0 ) return -1.0;
              h=sqrt( h );
              return -b - h;
          }
          

          該函數接受 3 個參數:ro(光線起點)、rd(歸一化的光線方向)和 sph(球體位置 xyz 和半徑 w)。它返回光線從起點到球體表面的長度,或者在未命中時返回 -1.0。簡單明了。所以我們只需要這三個向量,我們就可以得到一個漂亮的球體。

          光線起點可能是最容易獲得的點。對于 Unity 著色器來說,它將是相機位置。方便地傳遞給全局著色器 _WorldSpaceCameraPos 中的每個著色器。對于正交相機來說,它稍微復雜一些,但幸運的是,我們不必擔心。

          不祥的預兆

          對于球體位置,我們可以使用我們正在應用著色器的物體的世界空間位置。這可以通過 unity_ObjectToWorld._m03_m13_m23 從物體的變換矩陣中輕松提取。我們可以將半徑設置為某個任意值。為了沒有特別的理由,讓我們選擇 0.5

          最后是光線方向。這只是從相機到我們代理網格的世界位置的方向。通過在頂點著色器中計算它并將向量傳遞給片段著色器,我們可以很容易地獲得它。

          float3 worldPos=mul(unity_ObjectToWorld, v.vertex);
          float3 rayDir=_WorldSpaceCameraPos.xyz - worldPos;
          

          請注意,在頂點著色器中對其進行歸一化非常重要。你需要在片段著色器中執行此操作,否則插值的值將無法正常工作。我們正在插值的值是表面位置,而不是實際的光線方向。

          但是經過所有這些,我們得到了光線追蹤球體所需的三個值。

          現在我說上面的函數返回光線長度。所以要獲得球體表面的實際世界空間位置,你將歸一化的光線乘以光線長度,然后加上光線起點。你甚至可以通過從球體位置減去表面位置并進行歸一化來獲得世界法線。我們將光線長度傳遞給 clip() 函數,以隱藏球體外部的任何東西,因為該函數在未命中時返回 -1.0

          深度查找器

          球體Imposter的最后一個要點是 z 深度。如果我們希望我們的球體與世界正確地相交,我們需要從片段著色器中輸出球體的深度。否則,我們將被迫使用我們用來渲染的網格的深度。這實際上比聽起來容易得多。由于我們已經在片段著色器中計算了世界位置,我們可以應用我們在頂點著色器中使用的相同視圖和投影矩陣來獲得 z 深度。Unity 甚至包含一個方便的 UnityWorldToClipPos() 函數,使它變得更加容易。然后,它需要一個使用 SV_Depth 的輸出參數,其中包含剪切空間位置的 z 除以其 w。

          將所有這些與一些基本的光照結合起來,你就會得到類似這樣的東西:

          它看起來像一個球體,但實際上是一個立方體。

          讓所有男人都為之驚嘆的一個非常圓的立方體

          Shader "Basic Sphere Impostor"
          {
              Properties
              {
              }
              SubShader
              {
                  Tags { "RenderType"="AlphaTest" "DisableBatching"="True" }
                  LOD 100
          
                  Pass
                  {
                      Tags { "LightMode"="ForwardBase" }
          
                      CGPROGRAM
                      #pragma vertex vert
                      #pragma fragment frag
          
                      #include "UnityCG.cginc"
          
                      struct appdata
                      {
                          float4 vertex : POSITION;
                      };
          
                      struct v2f
                      {
                          float4 pos : SV_POSITION;
                          float3 rayDir : TEXCOORD0;
                          float3 rayOrigin : TEXCOORD1;
                      };
          
                      v2f vert (appdata v)
                      {
                          v2f o;
          
                          // get world position of vertex
                          // using float4(v.vertex.xyz, 1.0) instead of v.vertex to match Unity's code
                          float3 worldPos=mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0));
          
                          // calculate and world space ray direction and origin for interpolation
                          o.rayDir=worldPos - _WorldSpaceCameraPos.xyz;
                          o.rayOrigin=_WorldSpaceCameraPos.xyz;
          
                          o.pos=UnityWorldToClipPos(worldPos);
          
                          return o;
                      }
          
                      // https://www.iquilezles.org/www/articles/spherefunctions/spherefunctions.htm
                      float sphIntersect( float3 ro, float3 rd, float4 sph )
                      {
                          float3 oc=ro - sph.xyz;
                          float b=dot( oc, rd );
                          float c=dot( oc, oc ) - sph.w*sph.w;
                          float h=b*b - c;
                          if( h<0.0 ) return -1.0;
                          h=sqrt( h );
                          return -b - h;
                      }
          
                      half3 _LightColor0;
          
                      half4 frag (v2f i, out float outDepth : SV_Depth) : SV_Target
                      {
                          // ray origin
                          float3 rayOrigin=i.rayOrigin;
          
                          // normalize ray vector
                          float3 rayDir=normalize(i.rayDir);
          
                          // sphere position
                          float3 spherePos=unity_ObjectToWorld._m03_m13_m23;
          
                          // ray box intersection
                          float rayHit=sphIntersect(rayOrigin, rayDir, float4(spherePos, 0.5));
          
                          // above function returns -1 if there's no intersection
                          clip(rayHit);
          
                          // calculate world space position from ray, front hit ray length, and ray origin
                          float3 worldPos=rayDir * rayHit + rayOrigin;
          
                          // world space surface normal
                          float3 worldNormal=normalize(worldPos - spherePos);
          
                          // basic lighting
                          half3 worldLightDir=_WorldSpaceLightPos0.xyz;
                          half ndotl=saturate(dot(worldNormal, worldLightDir));
                          half3 lighting=_LightColor0 * ndotl;
          
                          // ambient lighting
                          half3 ambient=ShadeSH9(float4(worldNormal, 1));
                          lighting +=ambient;
          
                          // output modified depth
                          float4 clipPos=UnityWorldToClipPos(worldPos);
                          outDepth=clipPos.z / clipPos.w;
          
                          return half4(lighting, 1.0);
                      }
                      ENDCG
                  }
              }
          }
          

          紋理化球體

          好吧,這并不太令人興奮。我們應該在上面放一個紋理。為此,我們需要 UV,幸運的是,對于球體來說,這些 UV 非常容易獲得。

          等距矩形 UV

          讓我們在上面貼一個等距矩形紋理。為此,我們只需要將法線方向輸入到 atan2()acos() 中,我們就會得到類似這樣的東西:

          float2 uv=float2(
            // atan 返回 -pi 到 pi 之間的值
            // 所以我們除以 pi * 2 來得到 -0.5 到 0.5
            atan2(normal.z, normal.x) / (UNITY_PI * 2.0),
            // acos 在頂部返回 0.0,在底部返回 pi
            // 所以我們將 y 翻轉以與 Unity 的 OpenGL 風格對齊
            // 紋理 UV,所以 0.0 在底部
            acos(-normal.y) / UNITY_PI
          );fixed4 col=tex2D(_MainTex, uv);
          

          地球,最后的疆域。

          看看,我們得到一個完美的……等等。這是什么!?

          那是格林威治子午線嗎?

          這是一個 UV 縫!我們怎么會出現 UV 縫呢?好吧,這取決于 GPU 如何為 mip 貼圖計算 mip 層級。

          縫合

          GPU 通過所謂的屏幕空間偏導數來計算 mip 層級。粗略地說,這是值從一個像素到它旁邊的一個像素(向上或向下)的變化量。GPU 可以為每組 2x2 像素計算此值,因此 mip 層級由這些 2x2“像素四邊形”中 UV 的變化量決定。當我們在這里計算 UV 時,atan2() 突然在兩個像素之間從大約 0.5 跳到大約 -0.5。這使得 GPU 認為整個紋理在這兩個像素之間顯示。因此,它會使用它擁有的絕對最小的 mip 貼圖來響應。

          那么我們如何解決這個問題呢?當然,通過禁用 mip 貼圖!

          不不不! 我們絕對不會這樣做。 但這是你通常會找到的解決大多數 mip 貼圖相關問題的方案。相反,Marco Tarini 提供了一個很好的解決方案。

          http://vcg.isti.cnr.it/~tarini/no-seams/

          這個想法是使用兩個 UV 集,它們在不同的位置有縫合。對于我們的特定情況,由 atan2() 計算的經度 UV 已經是 -0.50.5 的范圍,所以我們只需要一個 frac() 來將它們轉換為 0.01.0 的范圍。然后使用相同的偏導數來選擇變化最小的 UV 集。神奇的函數 fwidth() 給出了值在任何屏幕空間方向上的變化量。

          // -0.5 到 0.5 的范圍
          float phi=atan2(worldNormal.z, worldNormal.x) / (UNITY_PI * 2.0);
          // 0.0 到 1.0 的范圍
          float phi_frac=frac(phi);float2 uv=float2(
            // 使用一個小偏差來優先考慮第一個“UV 集”
            fwidth(phi) < fwidth(phi_frac) - 0.001 ? phi : phi_frac,
            acos(-worldNormal.y) / UNITY_PI
          );
          

          現在我們沒有縫合了!

          我保證它沒有隱藏在另一邊

          ** 后記:我注意到這種技術可能只在使用 Direct3D、集成英特爾 GPU 或(某些?)Android OpenGLES 設備時才能正常工作。在桌面設備上使用 OpenGL 時,* fwidth() 函數可能使用比 GPU 用于確定 mip 層級的精度更高的導數,這意味著縫合仍然可見。Metal 保證始終以更高的精度運行。Vulkan 可以通過使用粗導數函數來強制以較低的精度運行,但截至撰寫本文時,Unity 似乎沒有正確地轉譯粗導數或精導數。我寫了一篇后續文章,其中介紹了一些替代解決方案:

          https://bgolus.medium.com/distinctive-derivative-differences-cce38d36797b

          或者,你可以直接使用立方體貼圖。Unity 可以為你將導入的等距矩形紋理轉換為立方體貼圖。但這意味著你將失去各向異性過濾。立方體貼圖紋理采樣的 UVW 本質上只是球體的法線。不過,你確實需要翻轉 x 軸或 z 軸,因為立方體貼圖被假定為從球體的“內部”進行觀察,而在這里我們希望它映射到外部。

          粗糙邊緣(又名導數再次出現)

          此時,如果我們將現有的光線追蹤球體著色器與使用相同等距矩形 UV 的實際高多邊形網格球體進行比較,你可能會注意到一些奇怪的事情。看起來光線追蹤球體周圍有一個輪廓,而網格沒有。一個非常鋸齒的輪廓。

          Imposter的粗糙“輪廓”。

          原因是我們討厭的導數再次出現了。我們錯過了另一個 UV 縫!在網格上,導數是針對每個像素四邊形、每個三角形計算的。事實上,如果一個三角形只接觸到一個 2x2 像素四邊形中的一個像素,GPU 仍然會為所有 4 個像素運行片段著色器!這樣做的好處是,它可以準確地計算出合理的導數,從而防止在真實網格上出現此問題。但我們在球體外部沒有一個好的 UV,該函數在未命中時只返回一個常數 -1.0,因此我們在球體外部有錯誤的 UV。如果在著色器中注釋掉 clip()outDepth 行,我們可以清楚地看到這一點。

          隱藏的 UV 縫

          我們想要的是讓 UV 接近球體可見邊緣的值,或者可能剛剛超過邊緣。這令人驚訝地難以計算。但我們可以通過找到光線到球體中心的最近點來獲得一個相當接近的值。在球體邊緣,這是 100% 準確的,但當離球體越來越遠時,它會開始向相機方向彎曲。但這很便宜,足以消除這個問題,并且與完全正確的修復幾乎沒有區別。

          更棒的是,當球體相交函數返回 -1.0 時,我們可以通過用一個 dot() 替換光線長度來應用此修復。兩個向量的點積的一個超級能力是,如果至少一個向量是歸一化的,則輸出是另一個向量沿歸一化向量方向的幅度。這對于獲取某個方向上的距離非常有用,例如相機沿視圖光線距離球體樞軸的距離。

          // 相同的球體相交函數
          float rayHit=sphIntersect(rayOrigin, rayDir, float4(0,0,0,0.5));
          // 如果是 -1.0,則剪切以在未命中時隱藏球體
          clip(rayHit);
          // 點積獲取最靠近球體的點處的光線長度
          rayHit=rayHit < 0.0 ? dot(rayDir, spherePos - rayOrigin) : rayHit;
          

          不再有縫合。

          物體縮放和旋轉

          所以一切都進展順利,但如果我們想做一個更大的球體或旋轉它怎么辦?我們可以移動網格位置,球體會隨之移動,但其他所有東西都被忽略了。

          我們可以手動更改球體半徑,但隨后你必須手動保持你正在使用的網格同步。所以,從物體變換本身提取縮放比例會更容易。我們可以應用一個任意的旋轉矩陣,但同樣,如果我們能直接使用物體變換,那就更容易了。

          或者,我們可以做一些更簡單的事情,在物體空間中進行光線追蹤!這帶來了一些其他的好處,我們將在后面介紹。但在那之前,我們想要在著色器代碼中添加幾行。首先,我們想要使用 unity_WorldToObject 矩陣將光線起點和光線方向在頂點著色器中轉換為物體空間。在片段著色器中,我們不再需要從變換中獲取世界空間物體位置,因為球體現在可以位于物體的原點。

          // 頂點著色器
          float3 worldSpaceRayDir=worldPos - _WorldSpaceCameraPos.xyz;
          // 只想旋轉和縮放 dir 向量,所以 w=0
          o.rayDir=mul(unity_WorldToObject, float4(worldSpaceRayDir, 0.0));
          // 需要對起點向量應用完整的變換
          o.rayOrigin=mul(unity_WorldToObject, float4(_WorldSpaceCameraPos.xyz, 1.0));// 片段著色器
          float3 spherePos=float3(0,0,0);
          

          僅通過添加上面的代碼到我們的著色器,你就可以旋轉和縮放游戲物體,球體也會按預期進行縮放和旋轉。它甚至支持非均勻縮放!請記住,著色器中的所有這些“世界空間”位置現在都在物體空間中。所以我們需要將法線和球體表面位置轉換為世界空間。只需確保使用物體空間法線作為 UV。

          // 現在獲取物體空間表面位置,而不是世界空間
          float3 objectSpacePos=rayDir * rayHit + rayOrigin;// 仍然需要在物體空間中對其進行歸一化以用于 UV
          float3 objectSpaceNormal=normalize(objectSpacePos);float3 worldNormal=UnityObjectToWorldNormal(objectSpaceNormal);
          float3 worldPos=mul(unity_ObjectToWorld, float4(objectSpacePos, 1.0));
          

          大、小和可怕的三明治地球。

          其他優勢包括更好的整體精度,因為對所有內容使用世界空間會在遠離原點時導致一些精度問題。在使用物體空間時,這些問題至少可以部分避免。這也意味著我們可以刪除幾個地方對 spherePos 的使用,因為它都是零,從而簡化代碼。

          使用四邊形

          到目前為止,我們一直在使用立方體網格。在某些情況下,使用立方體確實有一些好處,但我承諾在本文的標題中使用四邊形。而且,實際上沒有充分的理由為一個球體使用整個立方體。在側面有很多浪費的空間,我們在那里支付了渲染球體的成本,而我們知道它不會在那里。尤其是默認的 Unity 立方體,它有 24 個頂點!為什么還要浪費計算額外的 20 個頂點?

          公告牌著色器

          有很多公告牌著色器的示例。它們的基本原理是忽略物體的變換的旋轉(和縮放!),而是將網格對齊到某個方向以面向相機。

          面向視圖的公告牌

          這可能是最常見的版本。這是通過將樞軸位置轉換為視圖空間,并將頂點偏移量添加到視圖空間位置來實現的。這樣做相對便宜。請記住更新光線方向以匹配。

          // 從變換矩陣中獲取物體的世界空間樞軸
          float3 worldSpacePivot=unity_ObjectToWorld._m03_m13_m23;// 轉換為視圖空間
          float3 viewSpacePivot=mul(UNITY_MATRIX_V, float4(worldSpacePivot, 1.0));// 物體空間頂點位置 + 視圖樞軸=公告牌四邊形
          float3 viewSpacePos=v.vertex.xyz + viewSpacePivot;// 從視圖空間位置計算物體空間光線 dir
          o.rayDir=mul(unity_WorldToObject,
            mul(UNITY_MATRIX_I_V, float4(viewSpacePos, 0.0))
          );// 應用投影矩陣以獲取剪切空間位置
          o.pos=mul(UNITY_MATRIX_P, float4(viewSpacePos, 1.0));
          

          但是,如果我們只是將上面的代碼添加到我們的著色器中,球體就會出現一些問題。它在邊緣被剪切,尤其是在球體位于側面或靠近相機時。

          想得太超出了范圍。

          這是因為四邊形是一個平面,而球體不是。球體有一定的深度。由于透視,球體的體積將覆蓋比四邊形更多的屏幕!

          藝術家對犯罪現場的再現

          你可能會使用的解決方案是將公告牌按某個任意量進行縮放。但這并不能完全解決問題,因為你必須將四邊形放大很多。尤其是在你靠近球體或具有非常寬的視場時。這在一定程度上違背了使用四邊形而不是立方體的初衷。事實上,與立方體相比,即使是相對較小的縮放比例增加,現在也有更多像素渲染了空的空間。

          面向相機的公告牌

          幸運的是,我們可以做得更好。一個部分的解決方案是使用面向相機的公告牌,而不是面向視圖的公告牌,并將四邊形稍微拉向相機。面向視圖的公告牌和面向相機的公告牌之間的區別在于,面向視圖的公告牌與視圖所面向的方向對齊。面向相機的公告牌面向相機的位置。區別可能很細微,代碼也稍微復雜一些。

          我們不再在視圖空間中執行操作,而是需要構建一個旋轉矩陣,將四邊形旋轉到面向相機。這聽起來比實際操作更可怕。你只需要獲取從物體位置指向相機的向量、前進向量,并使用叉積來獲取向上向量和向右向量。將這三個向量放在一起,你就得到了一個旋轉矩陣。

          float3 worldSpacePivot=unity_ObjectToWorld._m03_m13_m23;// 樞軸和相機之間的偏移量
          float3 worldSpacePivotToCamera=_WorldSpaceCameraPos.xyz - worldSpacePivot;// 相機向上向量
          // 用作一個相當任意的向上方向起點
          float3 up=UNITY_MATRIX_I_V._m01_m11_m2;// 前進向量是歸一化的偏移量
          // 這是從樞軸到相機的方向
          float3 forward=normalize(worldSpacePivotToCamera);// 叉積獲取一個垂直于輸入向量的向量
          float3 right=normalize(cross(forward, up));// 另一個叉積確保向上向量垂直于兩者
          up=cross(right, forward);// 構建旋轉矩陣
          float3x3 rotMat=float3x3(right, up, forward);// 上面的旋轉矩陣是轉置的,這意味著組件是
          // 順序錯誤,但我們可以通過交換
          // 矩陣和向量在 mul() 中的順序來解決
          float3 worldPos=mul(v.vertex.xyz, rotMat) + worldSpacePivot;// 光線方向
          float3 worldRayDir=worldPos - _WorldSpaceCameraPos.xyz;
          o.rayDir=mul(unity_WorldToObject, float4(worldRayDir, 0.0));// 剪切空間位置輸出
          o.pos=UnityWorldToClipPos(worldPos);
          

          這更好,但仍然不好。球體仍然剪切了四邊形的邊緣。實際上,現在是所有四個邊緣。至少它是居中的。好吧,我們忘記將四邊形移向相機了!從技術上講,我們也可以按任意量縮放四邊形,但讓我們回到這一點。

          float3 worldPos=mul(float3(v.vertex.xy, 0.3), rotMat) + worldSpacePivot;
          

          我們忽略了四邊形的 z,并添加了一個小的(任意的)偏移量以將其拉向相機。與任意縮放相比,這種選擇的好處是,當距離較遠時,它應該更緊密地限制在球體的邊界內,并且當距離較近時,由于透視變化而進行縮放,就像球體本身一樣。只有當非常靠近時,它才會開始覆蓋比需要更多的屏幕空間。我在上面的示例中選擇了 0.3,因為它是在靠近時不會覆蓋太多屏幕空間,同時仍然覆蓋所有可見球體,直到你非常非常靠近。

          你知道,你可能可以用一些數學方法來計算出在給定距離下拉動或縮放四邊形的確切值……

          完美的透視公告牌縮放

          等等!我們可以用一些數學方法來計算出這個值!我們可以計算出相機到樞軸向量和相機到球體可見邊緣之間的角度。事實上,它始終是一個直角三角形,直角位于球體的表面!還記得你老朋友 SOHCAHTOA 嗎?我們知道相機到樞軸的距離,那是斜邊。我們也知道球體的半徑。由此,我們可以計算出從將該角度投影到四邊形的平面所形成的直角三角形的底邊。有了它,我們可以縮放四邊形,而不是修改 v.vertex.z

          // 獲取直角三角形的正弦值,斜邊是 // 球體樞軸距離,對邊使用球體半徑
          float sinAngle=0.5 / length(viewOffset);// 轉換為余弦
          float cosAngle=sqrt(1.0 - sinAngle * sinAngle);// 轉換為正切
          float tanAngle=sinAngle / cosAngle;// 上面的兩行等效于此,但速度更快
          // tanAngle=tan(asin(sinAngle));// 獲取直角三角形對邊,直角位于球體樞軸處,乘以 2 以獲取四邊形大小
          float quadScale=tanAngle * length(viewOffset) * 2.0;// 按計算的大小縮放四邊形
          float3 worldPos=mul(float3(v.vertex.xy, 0.0) * quadScale, rotMat) + worldSpacePivot;
          

          考慮物體縮放

          在這篇文章的開頭,我們將所有內容轉換為使用物體空間,這樣我們就可以輕松地支持旋轉和縮放。我們仍然支持旋轉,因為四邊形的朝向實際上并不重要。但四邊形不會像立方體那樣隨著物體的變換進行縮放。解決這個問題最簡單的方法是從變換矩陣的軸中提取縮放比例,并將我們使用的半徑乘以最大縮放比例。

          // 獲取物體縮放比例
          float3 scale=float3(
            length(unity_ObjectToWorld._m00_m10_m20),
            length(unity_ObjectToWorld._m01_m11_m21),
            length(unity_ObjectToWorld._m02_m12_m22)
          );
          float maxScale=max(abs(scale.x), max(abs(scale.y), abs(scale.z)));// 將球體半徑乘以最大縮放比例
          float maxRadius=maxScale * 0.5;// 使用新的半徑更新我們的正弦計算
          float sinAngle=maxRadius / length(viewOffset);// 執行其余的縮放代碼
          

          現在你可以均勻地縮放游戲物體,球體仍然會完美地限制在四邊形內。

          橢球體邊界?

          也應該可以計算出橢球體或非均勻縮放球體的精確邊界。不幸的是,這開始變得有點困難了。所以我現在不會花精力去解決這個問題。我將把它留作“讀者的練習”。(也就是說,我不知道怎么做。)

          視錐體剔除

          使用四邊形的另一個問題是 Unity 的視錐體剔除。它不知道四邊形在著色器中被旋轉了,因此,如果游戲物體被旋轉,使其以邊緣朝向觀察者,它可能會被視錐體剔除,而球體仍然可見。解決這個問題的方法是使用一個自定義的四邊形網格,其邊界已通過 C# 代碼手動修改為一個盒子。或者,你可以使用一個四邊形網格,其中一個頂點向前推了 0.5,另一個頂點向后推了 0.5,位于 z 軸上。我們已經在著色器中通過用 0.0 替換 v.vertex.z 來展平網格。

          陰影投射

          所以現在我們得到了一個漂亮渲染的球體,它位于一個四邊形上,可以被照亮、紋理化,并且可以移動、縮放和旋轉。所以讓我們讓它投射陰影!為此,我們需要在著色器中創建一個陰影投射器通道。幸運的是,相同的頂點著色器可以在這兩個通道中重復使用,因為它只創建了一個四邊形,并將光線起點和方向傳遞下去。當然,這些對于陰影來說與相機完全相同,對吧?然后,片段著色器實際上只需要輸出深度,這樣你就可以刪除所有討厭的 UV 和光照代碼。

          哦。

          光線起點和方向需要來自光源,而不是相機。我們用來表示光線起點的值始終是當前相機位置,而不是光源。好消息是,這并不難修復。我們可以用 UNITY_MATRIX_I_V._m03_m13_m23 替換任何對 _WorldSpaceCameraPos 的使用,它從逆視圖矩陣中獲取當前視圖的世界位置。現在,只要陰影是用透視投影渲染的,它就應該可以正常工作!

          哦。哦,不。

          方向陰影使用正交投影。

          正交痛苦

          透視投影和光線追蹤的優點是,光線起點位于相機的位置。這很容易獲得,即使對于任意視圖也是如此,如上所示。對于正交投影,光線方向是前進視圖向量。這很容易從逆視圖矩陣中再次獲得。

          // 視圖空間中的前進方向是 -z,所以我們想要負向量
          float3 worldSpaceViewForward=-UNITY_MATRIX_I_V._m02_m12_m22;
          

          但是我們如何獲得正交光線起點呢?如果你嘗試在線搜索,你可能會看到很多示例使用 C# 腳本來獲取逆投影矩陣。或者濫用當前的 unity_OrthoParams,它包含有關正交投影的寬度和高度的信息。然后,你可以使用剪切空間位置來重建光線起源的近視平面位置。這些方法的問題在于,它們都獲取的是相機的正交設置,而不是當前光源的設置。所以我們必須在著色器中計算逆矩陣!

          float4x4 inverse(float4x4 m) {
            float n11=m[0][0], n12=m[1][0], n13=m[2][0], n14=m[3][0];
            float n21=m[0][1], n22=m[1][1], n23=m[2][1], n24=m[3][1];
            float n31=m[0][2], n32=m[1][2], n33=m[2][2], n34=m[3][2];
            float n41=m[0][3], n42=m[1][3], n43=m[2][3], n44=m[3][3];  float t11=n23 * n34 * n42 - n24 * n33 * n42 + n24 * n32 * n43 - n22 * n34 * n43 - n23 * n32 * n44 + n22 * n33 * n44;// ... 等等,還有多少行?
          

          好吧,我們不要這樣做。這些只是超過 30 行函數的前幾行,而且越來越長,越來越復雜。一定有更好的方法。

          幾乎是視平面

          事實證明,你不需要任何這些。我們實際上并不需要光線起點位于近平面。光線起點實際上只需要是沿著前進視圖向量拉回的網格位置。只要足夠遠,以確保它沒有從球體的體積內部開始。至少假設相機本身還沒有位于球體內部。并且相機位置處的“近平面”而不是實際的近平面完全符合這個要求。

          我們已經在頂點著色器中知道了頂點的世界位置。所以我們可以將世界位置轉換為視圖空間。將 viewSpacePos.z 設置為零,然后轉換回世界空間。這將產生一個可用于正交投影的光線起點!

          // 將世界空間頂點位置轉換為視圖空間
          float4 viewSpacePos=mul(UNITY_MATRIX_V, float4(worldPos, 1.0));// 將視圖空間位置展平到相機平面上
          viewSpacePos.z=0.0;// 轉換回世界空間
          float4 worldRayOrigin=mul(UNITY_MATRIX_I_V, viewSpacePos);// 正交光線 dir
          float3 worldRayDir=worldSpaceViewForward;// 以及到物體空間
          o.rayDir=mul(unity_WorldToObject, float4(worldRayDir, 0.0));
          o.rayOrigin=mul(unity_WorldToObject, worldRayOrigin);
          

          實際上,我們甚至不需要做所有這些。還記得上面提到的 dot() 的超級能力嗎?我們只需要相機到頂點位置向量和歸一化的前進視圖向量。我們已經有了相機到頂點位置向量,那是原始的透視世界空間光線方向。我們知道前進視圖向量,可以通過從上面提到的矩陣中提取它來獲得。方便的是,此向量已經歸一化了!所以我們可以刪除上面的代碼中的兩個矩陣乘法,并改為執行以下操作:

          float3 worldSpaceViewPos=UNITY_MATRIX_I_V._m03_m13_m23;
          float3 worldSpaceViewForward=-UNITY_MATRIX_I_V._m02_m12_m22;// 原始的透視光線 dir
          float3 worldCameraToPos=worldPos - worldSpaceViewPos;// 正交光線 dir
          float3 worldRayDir=worldSpaceViewForward * -dot(worldCameraToPos, worldSpaceViewForward);// 正交光線起點
          float3 worldRayOrigin=worldPos - worldRayDir;o.rayDir=mul(unity_WorldToObject, float4(worldRayDir, 0.0));
          o.rayOrigin=mul(unity_WorldToObject, float4(worldRayOrigin, 1.0));
          

          ** 這里有一個小問題。這對于傾斜投影(即剪切的正交投影)不起作用。為此,你確實需要逆投影矩陣。但是剪切的透視投影是可以的!*

          面向光源的公告牌

          還記得我們是如何做面向相機的公告牌的嗎?以及用于縮放四邊形以考慮透視的那些花哨的數學方法嗎?對于正交投影,我們不需要任何這些。只需要執行面向視圖的公告牌,并將四邊形按物體的變換的最大縮放比例進行縮放。但是也許我們不要刪除所有這些代碼。我們可以照常使用現有的旋轉矩陣構建,只是將 forward 向量更改為負的 worldSpaceViewForward 向量,而不是 worldSpacePivotToCamera 向量。

          透視點

          事實上,現在可能是討論聚光燈和點光源如何使用透視投影的好時機。如果我們想要支持方向光、聚光燈和點光源陰影,我們需要在同一個著色器中同時支持透視和正交投影。Unity 還使用此通道來渲染相機深度紋理。這意味著我們需要檢測當前投影矩陣是否是正交的,并在兩種路徑之間進行選擇。

          好吧,我們可以通過檢查投影矩陣的特定組件來找出我們正在使用哪種類型的投影矩陣。如果投影矩陣的最后一個組件是 0.0,則它是透視投影矩陣,如果它是 1.0,則它是正交投影矩陣。

          bool isOrtho=UNITY_MATRIX_P._m33==1.0;// 公告牌代碼
          float3 forward=isOrtho ? -worldSpaceViewForward : normalize(worldSpacePivotToCamera);
          // 執行其余的公告牌代碼// 四邊形縮放代碼
          float quadScale=maxScale;
          if (!isOrtho)
          {
            // 執行完美的縮放代碼
          }// 光線方向和起點代碼
          float3 worldRayOrigin=worldSpaceViewPos;
          float3 worldRayDir=worldPos - worldSpaceRayOrigin;
          if (isOrtho)
          {
            worldRayDir=worldSpaceViewForward * -dot(worldRayDir, worldSpaceViewForward);
            worldRayOrigin=worldPos - worldRayDir;
          }o.rayDir=mul(unity_WorldToObject, float4(worldRayDir, 0.0));
          o.rayOrigin=mul(unity_WorldToObject, float4(worldRayOrigin, 1.0));// 不要擔心,我稍后會展示整個頂點著色器
          

          現在,我們得到了一個可以正確處理正交投影和透視投影的頂點函數!片段著色器中不需要更改任何內容來考慮這一點。哦,我們實際上可以使用同一個函數來表示陰影投射器通道和前向照明通道。現在,你也可以使用正交相機了!

          陰影偏差

          現在,如果你一直在關注,你將得到一個輸出深度的陰影投射器通道。但我們沒有調用陰影投射器通常用于應用偏移的任何常用函數。目前,這并不明顯,因為我們還沒有進行自陰影,但如果我們不修復它,這將是一個問題。

          我們不會使用內置的 TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) 宏來表示頂點著色器,因為我們需要在片段著色器中進行偏差。幸運的是,在物體空間中進行光線追蹤還有另一個好處。陰影投射器頂點著色器宏調用的第一個函數假設傳遞給它的位置位于物體空間中!我的意思是,這是有道理的,因為它假設它正在處理起始的物體空間頂點位置。但這意味著我們可以直接使用陰影投射器宏調用的偏差函數,使用我們光線追蹤的位置,它們就會正常工作!

          是的,實際上仍然只是一個四邊形。

          Tags { "LightMode"="ShadowCaster" }ZWrite On ZTest LEqualCGPROGRAM
          #pragma vertex vert
          #pragma fragment frag_shadow#pragma multi_compile_shadowcaster// 是的,我知道頂點函數缺失fixed4 frag_shadow (v2f i,
            out float outDepth : SV_Depth
            ) : SV_Target
          {
            // 光線起點
            float3 rayOrigin=i.rayOrigin;  // 歸一化光線向量
            float3 rayDir=normalize(i.rayDir);  // 光線球體相交
            float rayHit=sphIntersect(rayOrigin, rayDir, float4(0,0,0,0.5));  // 上面的函數在沒有相交時返回 -1
            clip(rayHit);  // 計算物體空間位置
            float3 objectSpacePos=rayDir * rayHit + rayOrigin;  // 輸出修改后的深度
            // 是的,我們將 objectSpacePos 作為兩個參數傳遞
            // 第二個用于物體空間法線,在本例中
            // 是歸一化的位置,但該函數將其轉換為
            // 世界空間并進行歸一化,所以我們不必這樣做
            float4 clipPos=UnityClipSpaceShadowCasterPos(objectSpacePos, objectSpacePos);
            clipPos=UnityApplyLinearShadowBias(clipPos);
            outDepth=clipPos.z / clipPos.w;  return 0;
          }
          ENDCG
          

          就是這樣。這適用于所有陰影投射器變體。方向光陰影、聚光燈陰影、點光源陰影以及相機深度紋理!你知道,如果我們想支持多個燈光……

          ** 我沒有添加對 GLES 2.0 點光源陰影的支持。這需要將距離光源的距離作為陰影投射器通道的顏色值輸出,而不是僅僅硬編碼 *0*。添加它并不難,但這會使著色器變得更加混亂,因為需要添加一些 *#if* 和我們需要計算的特殊情況數據。所以我沒有包含它。*

          ** 編輯:我忘記了在處理 OpenGL 平臺上的深度時的一件事。OpenGL 的剪切空間 z 是 -w 到 +w 的范圍,所以你需要執行一個額外的步驟將其轉換為片段著色器輸出深度所需的 0.0 到 1.0 的范圍。*

          #if !defined(UNITY_REVERSED_Z) // 基本上只有 OpenGL
          outDepth=outDepth * 0.5 + 0.5;
          #endif
          

          陰影接收

          所以現在我們得到了一個有效的陰影投射。那么陰影接收呢?這將進入 Unity 特定內容的陰暗面。如果你不是凡人,現在就轉身吧……或者,如果你不太關心 Unity 的內置前向渲染路徑。(或者至少跳到下一節關于 深度 的內容。)

          點亮它

          在早期,我發布了一個帶有基本漫反射光照設置的著色器。如果你一直關注這篇文章,那么前向基本通道的光照代碼現在應該看起來像這樣。

          // 世界空間表面法線和位置
          float3 worldNormal=UnityObjectToWorldNormal(objectSpaceNormal);
          float3 worldPos=mul(unity_ObjectToWorld, float4(objectSpacePos, 1.0));// 基本光照
          half3 worldLightDir=UnityWorldSpaceLightDir(worldPos);
          half ndotl=saturate(dot(worldNormal, worldLightDir));
          half3 lighting=_LightColor0 * ndotl;// 環境光照
          half3 ambient=ShadeSH9(float4(worldNormal, 1));
          lighting +=ambient;// 應用光照
          col.rgb *=lighting;
          

          沒什么特別的。獲取你的世界法線和世界位置。獲取世界光線方向。執行一個鉗位點積。將光線顏色乘以點積,添加環境光照,并將紋理乘以光照。這有點像你開始學習光照著色器教程時的代碼。但我們顯然缺少陰影。

          對于傳統的向前基本照明著色器,我們想要在一些地方添加一些宏,Unity 會自動為我們提供所需的內容。將 SHADOW_COORDS(#) 添加到 v2f 結構體中,在頂點函數中調用 TRANSFER_SHADOW(o);,然后在片段著色器中調用 UNITY_LIGHT_ATTENUATION(atten, i, worldPos);。我們當然可以這樣做,至少對于向前基本通道來說可以這樣做。在桌面和主機上,Unity 的方向光的陰影使用屏幕空間陰影。也就是說,陰影貼圖被渲染,然后它們被投射到從相機深度紋理中事先計算出的世界位置上,并保存在屏幕空間紋理中。所以上面的宏只是將屏幕空間位置傳遞下去,你可以從剪切空間位置中廉價地計算出它。

          通常,這是通過上面提到的 TRANSFER_SHADOW(o); 宏來完成的,并從頂點著色器傳遞到片段著色器。但我們已經在片段著色器中計算了剪切空間位置。我們可以重復使用它,使用宏調用的同一個 ComputeScreenPos(clipPos) 函數來計算屏幕空間位置。然后,我們可以使用最終的內置宏,讓它完成剩下的工作。

          我們確實想要使用 UNITY_LIGHT_ATTENUATION(atten, i, worldPos); 宏。它為我們處理額外的功能,例如光線餅干。以及另一個我將在稍后提到的原因。

          但有一個小問題。內置的陰影宏期望你傳遞一個包含屏幕空間位置的結構體。而我們的 v2f 結構體沒有它,如果我們不必這樣做,我們也不想把它添加到該結構體中。

          謝天謝地,我們不需要這樣做,我們可以創建一個虛擬結構體!它只需要 SHADOW_COORDS(0) 宏來添加其他宏期望的結構體元素,然后我們就可以自己設置它添加的值。

          // 虛擬結構體
          struct shadowInput {
            SHADOW_COORDS(0)
          );// 世界空間位置和剪切空間位置
          float3 worldPos=mul(unity_ObjectToWorld, float4(surfacePos, 1.0));
          float4 clipPos=UnityWorldToClipPos(float4(worldPos, 1.0));#if defined (SHADOWS_SCREEN)
          // 為屏幕空間陰影設置陰影結構體
          shadowInput shadowIN;
          #if defined(UNITY_NO_SCREENSPACE_SHADOWS)
          // 移動陰影
          shadowIN._ShadowCoord=mul(unity_WorldToShadow[0], float4(worldPos, 1.0));
          #else
          // 屏幕空間陰影
          shadowIN._ShadowCoord=ComputeScreenPos(clipPos);
          #endif // UNITY_NO_SCREENSPACE_SHADOWS
          #else
          float shadowIN=0;
          #endif // SHADOWS_SCREEN// 宏創建一個名為 atten 的變量,其中包含陰影
          UNITY_LIGHT_ATTENUATION(atten, shadowIN, worldPos);// 將方向光照乘以 atten
          half3 lighting=_LightColor0 * ndotl * atten;
          

          現在,我們可以接收方向陰影了!

          捕捉陰影。

          多個燈光

          所以我說過我們確實想要使用上面的 UNITY_LIGHT_ATTENUATION 宏。這是真正的原因。它還處理其他燈光類型!Unity 的內置前向渲染器通過為每個燈光再次渲染物體來繪制多個燈光。所以我們需要一個前向添加通道。而我們現在用于前向基本通道的唯一的阻止它與前向添加通道一起工作的東西是環境光照。所以你可以復制片段著色器函數并刪除兩行環境光照代碼。

          或者,你可以在 #if defined(UNITY_SHOULD_SAMPLE_SH) 中放置這三行環境光照代碼,它只對基本通道為真。然后,你可以為這兩個通道共享完全相同的函數。

          RTX 關閉!

          碎片深度

          使用 SV_Depth 有一個很大的問題。它禁用了早期深度拒絕。基本上,這意味著如果你在視錐體中,你將支付渲染Imposter的成本。即使它位于其他東西的后面,并且不可見。通常,GPU 可以使用深度緩沖區來跳過對位于相機更近的其他物體后面的網格運行片段著色器。但由于 GPU 在片段著色器運行之后才知道深度是多少,因此它無法做到這一點。

          “那么 SV_DepthLessEqualSV_DepthGreaterEqual 呢?”

          是的!這是一個很棒的問題,佩蒂尼奧先生。你怎么知道(https://mynameismjp.wordpress.com/2010/11/14/d3d11-features/) 我在想這個?

          保守深度輸出

          SV_DepthLessEqualSV_DepthGreaterEqual 語義是 SV_Depth 的替代品,它們告訴 GPU 仍然執行早期深度拒絕,這是為著色器模型 5.0 添加的。但是要使用它,我們必須確保網格比我們要渲染的球體更靠近或更靠近相機。為此,我們想要將網格拉向相機。現在,面向相機的四邊形位于球體的中心。

          問題是我們需要將頂點移近相機,而不會修改它們的屏幕空間位置。我們已經為它們計算出了完美的邊界,所以如果我們最終取消了這些操作,那就很不幸了。

          一個選擇是計算比球體樞軸更靠近相機 maxRadius 的視平面的剪切空間位置。然后替換已經計算出的剪切空間位置的 z。剪切空間有一個非常酷的功能,你可以更改剪切空間位置的 z,而不會影響它在屏幕上的位置或導致插值問題。

          // 著色器末尾的常用剪切空間
          o.pos=UnityWorldToClipPos(worldPos);// 獲取球體樞軸沿 // 前進視圖向量更靠近 `maxRadius` 的位置
          float4 nearerClip=UnityWorldToClipPos(worldSpacePivotPos — worldSpaceViewForward * maxRadius);// 轉換應用“透視除法”以獲取真實的深度 Z
          float nearerZ=nearerClip.z / nearerClip.w// 用新的值替換原始的剪切空間 z
          o.pos.z=nearerZ * o.pos.w;
          

          但這種技術有一個很大的缺陷。如果你將相機移得太靠近或試圖穿過我們的Imposter球體,那么當我們應該仍然看到它時,它就會消失。問題是“更近的深度”被放置在相機的后面。我們可以嘗試對此進行更多工作。例如,嘗試將 z 限制為近平面。或者,更確切地說,是將 z 限制在近剪切平面的內部,因為近剪切平面上仍然會導致它被剔除。

          // 限制為近剪切平面的內部
          o.pos.z=min(o.pos.w - 1.0e-6f, nearerZ * o.pos.w);
          

          但……這實際上并沒有按預期工作*。

          當我 說你可以更改剪切空間位置的 z 而不會出現任何問題時,我撒了點謊。這在一種情況下會失敗,那就是當網格的某些頂點位于相機后面時。我們試圖解決的正是這種情況。即使進行了鉗位,四邊形仍然比它應該的更被剪切。所以這失敗了。

          老實說,我不太了解這個問題,無法解釋原因。

          但有一個更便宜的解決方案,它在一般情況下表現良好,并且不會在“某些頂點位于相機后面”的情況下失敗!我們可以沿著光線方向將頂點移動一個球體半徑。對于正交投影,這實際上只是世界位置減去前進視圖乘以球體半徑。對于透視投影,如果我們使用歸一化的光線方向,它實際上不會拉得足夠遠。所以我們需要再次調用我們的朋友 dot(),以找出我們需要偏移多遠才能正確地將四邊形的表面拉近一個球體半徑。

          // 這將頂點推向相機
          // 在頂點著色器中的 UnityWorldToClipPos 行之前添加
          worldPos +=worldSpaceRayDir / dot(normalize(viewOffset), worldSpaceRayDir) * maxRadius;// 著色器末尾的常用剪切空間
          o.pos=UnityWorldToClipPos(worldPos);
          

          現在,當你的相機靠近時,它仍然會與球體進行近剪切,但結果與剪切實際球體網格非常相似。一般來說,如果網格沒有被剪切,那么光線偏移四邊形也不會被剪切。

          添加了這一點之后,只需要將片段著色器中的 SV_Depth 語義替換為適當的選項。對于任何不是 OpenGL 的內容,你應該使用 SV_DepthLessEqual。這是因為 Unity 為非 OpenGL 平臺使用反向 Z 深度。反向 Z 深度意味著距離更遠的物體具有比更近的物體更小的深度值。所以實際上,我們只需要檢查 UNITY_REVERSED_Z 關鍵字是否處于活動狀態。對于 OpenGL……好吧,實際上這都是無用的。我們無法保證 OpenGL 平臺支持與 SV_DepthGreaterEqual 等效的功能,直到 OpenGL 4.2。 基本上,你可能被迫在任何不使用反向 Z 深度的平臺上使用 SV_Depth。然后,所有這些將四邊形拉近相機以減少過度陰影的操作對于這些平臺來說都是毫無意義的。但我們至少可以在著色器中處理這兩種情況。

          ** 編輯:運行 OpenGL 4.2+ 的 Unity 仍然使用常規的 z 深度。你可以為它使用 *SV_DepthGreaterEqual*,但實際上,任何支持 OpenGL 4.2 的平臺,你都希望改為運行 Direct3D、Vulkan 或 Metal。*

          // 這樣更新片段著色器函數
          half4 frag_(forward/shadow) (v2f i
          #if UNITY_REVERSED_Z && SHADER_TARGET > 40
            , out float outDepth : SV_DepthLessEqual
          #else
          // 該設備可能無法使用保守深度
            , out float outDepth : SV_Depth
          #endif
            ) : SV_Target
          

          收尾工作

          還有一些小細節需要完善著色器。支持“每個頂點”非重要燈光、霧和基本實例化。這些并不十分有趣,所以我將快速介紹一下。

          “每個頂點”非重要燈光

          由于我們實際上沒有很多頂點,所以我們還需要在片段著色器中調用“頂點燈光”函數。這實際上只是復制和粘貼頂點燈光函數,將其放在一個 #if 中,并將返回值添加到 lighting 中。

          #if defined(VERTEXLIGHT_ON)
          // “每個頂點”非重要燈光
          half3 vertexLighting=Shade4PointLights(
            unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
            unity_LightColor[0].rgb, unity_LightColor[1].rgb,
            unity_LightColor[2].rgb, unity_LightColor[3].rgb,
            unity_4LightAtten0,
            worldPos, worldNormal);lighting +=vertexLighting;
          #endif
          

          或者至少它應該這么簡單。VERTEXLIGHT_ON 是由 #pragma multi_compile_fwdbase 控制的關鍵字之一。但似乎,如果你在頂點著色器中沒有這個函數,那么具有該關鍵字的著色器變體將永遠不會創建。所以你必須用自己的多編譯行來強制執行它。

          #pragma multi_compile _ VERTEXLIGHT_ON
          

          與這篇文章中介紹的許多內容一樣,Unity 的內置宏假設你正在從頂點著色器中輸出某種值。對于桌面,這只是將原始的 clipPos.z 傳遞給片段著色器,然后片段著色器在調用那里的霧宏時計算實際的霧衰減。所以,我們可以在前向通道的片段著色器末尾添加帶有 UNITY_APPLY_FOG(clipPos.z, col); 的常用宏。

          對于移動設備,衰減是在頂點著色器中計算的。但我們需要使用我們在片段著色器中計算的 clipPos.z,所以如果你想要同時支持移動設備和桌面,我們不能只使用常用的 UNITY_APPLY_FOG(clipPos.z, col) 宏。所以我們必須計算衰減并將它傳遞給宏,但只在移動設備上這樣做。

          // 霧
          float fogCoord=clipPos.z;#if (SHADER_TARGET < 30) || defined(SHADER_API_MOBILE)
          // 宏計算霧衰減
          // 并創建一個 unityFogFactor 變量來保存它
          UNITY_CALC_FOG_FACTOR(fogCoord);
          fogCoord=unityFogFactor;
          #endifUNITY_APPLY_FOG(fogCoord, col);
          

          實例化

          要將實例化添加到著色器中,請復制和粘貼 Unity 關于此內容的文檔中提到的適當宏:

          GPU 實例化

          https://docs.unity3d.com/Manual/GPUInstancing.html

          轉到將實例化添加到頂點和片段著色器部分,并將宏復制到 appdatav2f 結構體、頂點函數以及片段函數中。忽略 BUFFERPROP 宏。但你確實需要在片段著色器中使用 UNITY_SETUP_INSTANCE_ID(i);。在實例化著色器中,unity_ObjectToWorldunity_WorldToObject 矩陣是實例化屬性。由于我們在片段著色器中使用它們,因此我們也需要實例 ID。

          完成的著色器

          話不多說,這是完成的著色器,完整代碼如下。
          完整代碼(https://gist.github.com/bgolus/1188cd89968b977d5c468bf7bbb3250b)

          其他想法

          表面著色器和著色器圖

          因為我知道下一個問題每個人都會問的是“如何在表面著色器/著色器圖中做這個?”。以下是這些問題的答案。

          你不能。*

          好吧,你可以構建光線起點和方向。你可以進行球體的光線追蹤。你當然也可以執行所有過程式 UV 操作。你甚至可以更新表面法線,使其像球體一樣被照亮。

          不能做的一件事是從片段著色器中調整用于光照和陰影的深度或世界位置。因此,深度相交看起來會很奇怪,陰影看起來會很奇怪,并且非常靠近表面的燈光看起來也不正確。因為它們都將使用原始網格表面的位置。

          因此,在 Unity 的任何渲染器中使用這種技術的唯一選擇是使用手寫的頂點片段著色器。至少目前是這樣。我希望有一天你能夠在著色器圖中輸出修改后的深度值。但截至撰寫本文時,他們還沒有提到要添加此功能。

          ** 人們指出,HDRP 的著色器圖確實具有在主節點上設置深度以執行每個片段深度功能的能力。不過,它使用的是 *SV_Depth* 而不是 *SV_DepthLessEqual*,因此不需要執行四邊形的射線方向偏移。感謝 Rémy 提醒我。希望他們能將此功能添加到 URP 中。

          https://portal.productboard.com/unity/1-unity-graphics/tabs/7-shader-graph

          抗鋸齒

          我的許多其他文章都是關于抗鋸齒的,為什么我在這里跳過了它?因為這是一個沒有完美解決方案的難題。

          Inigo Quiles 在這里有一個關于如何處理光線追蹤球體的抗鋸齒的優秀示例:

          https://www.shadertoy.com/view/MsSSWV

          基本原理是使用光線到點距離計算(這也用于修復外部邊緣的 UV),以近似地了解光線在屏幕空間中距離球體邊緣有多近。這可以為你提供一個漸變,可以使用類似于我在 Alpha to Coverage 文章中使用的函數來銳化,然后將其用作輸出 alpha。也可以用于非 MSAA 和非不透明用例中的 alpha 混合。

          使用原始著色器的 4x MSAA 與使用 Alpha to Coverage 的比較。

          // 將此添加到通道中,位于 CGPROGRAM 之外,以啟用
          // alpha to coverage
          AlphaToMask On
          // 光線到球體樞軸距離
          float rayToPointDist=length(rayDir * dot(rayDir, -rayOrigin) + rayOrigin);// fwidth 獲取 ddx 和 ddy 偏導數的總和
          // float fDist=fwidth(rayToPointDist);// fwidth 是對此的粗略近似
          float fDist=length(float2(ddx(rayToPointDist), ddy(rayToPointDist)));// 銳化光線到點距離
          // 以球體半徑為中心,根據導數 +/- 半個像素
          float alpha=(0.5 - rayToPointDist) / max(fDist, 0.0001) + 0.5;// 根據銳化的 alpha 剪切
          // 不要根據光線命中未命中進行剪切
          clip(alpha);// 將 alpha 限制在 0 到 1 的范圍內,并在
          // 采樣紋理后將其應用于輸出 alpha
          col.a=saturate(alpha);
          

          這似乎應該足夠好了,對吧?那么為什么我說沒有完美的解決方案呢?為什么我沒有默認實現它呢?對外部邊緣進行抗鋸齒并不能解決與光柵化網格或從片段著色器輸出深度的其他著色器相交時的鋸齒問題。當啟用 MSAA 光柵化三角形時,會為三角形覆蓋的每個子樣本計算深度,但片段著色器只對每個像素運行一次。這意味著兩個相交網格的每個子樣本覆蓋可以準確地確定到子樣本計數。此著色器正在從片段著色器中寫入深度,因此每個像素只有一個深度。然后,相同的深度用于所有子樣本。因此,相交處沒有 AA。從技術上講,在光柵化幾何體和輸出深度的片段著色器之間仍然存在一些 AA,因為會考慮相交三角形的平面。但在兩個深度寫入著色器之間將不會存在任何 AA。

          使用原始著色器的 4x MSAA 與使用 Alpha to Coverage 的比較。請注意,兩種方法的相交處都是相同的。與視平面對齊的光柵化表面在與Imposter相交處顯示鋸齒。以角度觀察的光柵化表面顯示抗鋸齒,但它等效于與視平面對齊的表面相交。

          上面的 Shadertoy 示例可以處理相交,因為它在一個通道中渲染所有這些球體,并對分析形狀執行每個像素排序和合成。它甚至沒有執行任何 MSAA。

          據我所知,沒有一種有效的方法可以在啟用 MSAA 的情況下處理片段著色器深度寫入,同時仍然只對每個像素運行一次片段著色器。這將導致使用 sample 插值修飾符來強制片段著色器對每個子樣本運行。當 MSAA 的全部目的是這樣做時,這對于性能來說并不理想。但它看起來確實很不錯。

          使用原始著色器的 4x MSAA 與強制每個子樣本渲染的著色器的比較。

          使用原始著色器的 4x MSAA 與強制每個子樣本渲染的著色器的比較。請注意,超級采樣情況下的所有相交處都得到了適當的抗鋸齒。

          // 更新 v2f 結構體以使用插值的 ray dir 和 ray origin 向量的樣本修飾符,以強制片段
          // 著色器對每個子樣本運行,并為插值
          // 值獲取每個子樣本位置的唯一計算
          struct v2f
          {
            float4 pos : SV_POSITION;
            sample float3 rayDir : TEXCOORD0;
            sample float3 rayOrigin : TEXCOORD1;
            UNITY_VERTEX_INPUT_INSTANCE_ID
          };// 將此添加到 CGPROGRAM 塊中,作為通道,因為
          // 樣本修飾符是著色器模型 5.0 的功能
          #pragma target 5.0// 你可能還想對紋理 mip 層級進行偏差
          // 因為如果我們已經進行了超級采樣,為什么不呢!
          half4 col=tex2Dbias(_MainTex, float4(uv, 0, -1));
          

          Alpha to Coverage 的 4x MSAA 與強制每個子樣本渲染的著色器的比較。

          原始著色器的 4x MSAA 與 Alpha to Coverage 與強制超級采樣相交比較。

          延遲渲染

          我沒有在示例著色器中包含延遲渲染通道。沒有理由認為這不能與延遲渲染一起使用。它甚至會更容易編寫。但我試圖使著色器盡可能簡單。


          如果喜歡今天的文章,請多點點贊在看,后續就會有更多此類的文章~

          語義化標簽,可以讓頁面有更加完善的結構,讓頁面的元素有含義,同時利于被搜索引擎解析,有利于SEO,主要標簽包括下面的標簽:

          html5新的常用標簽

          ②增強型表單

          可以通過input的type屬性指定類型是number還是date或者url,同時還添加了placeholder和required等表單屬性。

          <input type="range" id="a" value="50" required>
          <input type="number" id="b" value="50" placeholder="請輸入數字">

          ③媒體元素

          新增了audio和video兩個媒體相關的標簽,可以讓開發人員不必以來任何插件就能在網頁中嵌入瀏覽器的音頻和視頻內容。

          <video width="320" height="240" controls>
            <source src="movie.mp4" type="video/mp4">
          // 有些低版本瀏覽器不支持Video標簽。
          </video>
          <audio controls>
            <source src="horse.mp3" type="audio/mpeg">
          // 有些低版本瀏覽器不支持 audio 元素。
          </audio>

          ④canvas繪圖

          canvas繪圖指的是在頁面中設定一個區域,然后通過JS動態的在這個區域繪制圖形。

          <canvas id="canvas" width="300" height="300"></canvas>

          ⑤svg繪圖

          //畫了一個圓
          <svg xmlns="http://www.w3.org/2000/svg" version="1.1">
             <circle cx="100" cy="50" r="40" stroke="black" stroke-width="2" fill="red" />
          </svg>

          ⑥地理定位

          getCurrentPosition()方法來獲取用戶的位置,從而實現隊地理位置的定位。

          var x=document.getElementById("demo");
          function getLocation()
          {
              if (navigator.geolocation)
              {
                  navigator.geolocation.getCurrentPosition(showPosition);
              }
              else
              {
                  x.innerHTML="該瀏覽器不支持獲取地理位置。";
              }
          }
           
          function showPosition(position)
          {
              x.innerHTML="緯度: " + position.coords.latitude + 
              "<br>經度: " + position.coords.longitude;    
          }

          ⑦拖放API

          通過給標簽元素設置屬性draggable值為true,能夠實現對目標元素的拖動。

          <img draggable="true"> // 拖放圖片

          ⑧Web Worker

          Web Worker通過加載一個腳本文件,進而創建一個獨立工作的線程,在主線程之外運行,worker線程運行結束之后會把結果返回給主線程,worker線程可以處理一些計算密集型的任務,這樣主線程就會變得相對輕松,這并不是說JS具備了多線程的能力,而實瀏覽器作為宿主環境提供了一個JS多線程運行的環境。

          if(typeof(Worker)!=="undefined")
          {
              // 是的! Web worker 支持!
              // 一些代碼.....
          }
          else
          {
              //抱歉! Web Worker 不支持
          }

          ⑨Web Storage

          需要重點掌握的是cookie、Localstorage和SessionStorage三者之間的區別:

          1.有效期

          • cookies是會話機制,它是在設置的時間內(自己設置的)有效。
          • localStorage是屬于本地存儲的一種,是永久存儲,除非手動刪除,否則一直有效。
          • ? sessionStorage也是屬于本地存儲的一種,是臨時存儲,在關閉當前頁面或者當前瀏覽器窗口前有效。

          2.存儲數據的大小

          • cookies存儲的數據大小在4k左右。
          • ? localStorage存儲的數據大小在20M左右。
          • ? sessionStorage存儲的數據大小在5M左右。

          3.作用范圍

          • cookies只能作用于當面目錄以及當前的子目錄。
          • ? localStorage是同一瀏覽器不同標簽頁之間數據可以共享。
          • ? sessionStorage中存儲的數據是只能在當前標簽頁中使用。

          4.安全性

          • ? cookies的安全性比loaclStorage和sessionStorage的安全性好。

          ⑩Websocket

          websocket和HTTP的區別:

          • 相同點:HTTP和Websocket都是基于TCP的應用層協議。
          • 不同點:①websocket是雙向通信協議,模擬socket協議,可以雙向發送和接受消息,HTTP是單向的,意思是說通信只能由客戶端發起。②websocket是需要瀏覽器和服務器通過握手建立連接,但是HTTP是瀏覽器向服務器發送連接,服務器預先不知道這個連接。
          • 聯系:websocket建立握手時需要基于HTTP進行傳輸,建立連接之后呢便不再需要HTTP協議了。

          【注】HTML5 定義的 WebSocket 協議,能更好的節省服務器資源和帶寬,并且能夠更實時地進行通訊。

          webSocket

          學習記錄,如有侵權請聯系刪除

          為一名前端愛好者, 我利用空余時間研究了幾個國外網站的源碼,發現不管是庫,還是業務代碼,都會用到了一些比較有意思的API,雖然平時在工作中部分接觸過,但是經過這次的研究,覺得很有必要總結一下,畢竟已經2020年了,是時候更新一下技術儲備了,本文主要通過實際案例來帶大家快速了解以下幾個知識點:

          • Observer 原生觀察者
          • script標簽事件深入 - 移除script標簽后事件仍然能執行的原因
          • Proxy/Reflect 自定義事件
          • fileReader API Fullscreen 網頁全屏
          • URL API的使用
          • Geolocation 地理位置API的使用
          • Notifications 瀏覽器原生消息通知
          • Battery Status 設備電量情況

          我會對部分API做一些比較有意思的案例,那么開始我們的學習吧~

          1. Observer API

          Observer是瀏覽器自帶的觀察者,它主要提供了Intersection, Mutation, Resize, Performance這四類觀察者, 這里筆者重點介紹Intersection Observer.

          1.1 Intersection Observer

          IntersectionObserver提供了一種異步觀察目標元素與其祖先元素交叉狀態的方法。當一個IntersectionObserver對象被創建時,其被配置為監聽根中一段給定比例的可見區域,并且無法更改其配置,所以一個給定的觀察者對象只能用來監聽可見區域的特定變化值;然而,我們可以在同一個觀察者對象中配置監聽多個目標元素。

          說簡單點就是該api可以異步監聽目標元素在根元素里的位置變動,并觸發響應事件.我們可以利用它來實現更為高效的圖片懶加載, 無限滾動以及內容埋點上報等.接下來我們通過一個例子來說明一下它的使用步驟.

          // 1.定義觀察者及觀察回調
          const intersectionObserver=new IntersectionObserver((entries, observer)=> {
              entries.forEach(entry=> { 
                console.log(entry)
                // ...一些操作
              }); 
             },
             {
              root: document.querySelector('#root'),
              rootMargin: '0px',
              threshold: 0.5
             }
          )
          // 2. 定義要觀察的目標對象
          const target=document.querySelector(“.target”); 
          intersectionObserver.observe(target);
          

          以上代碼就實現了一個基本的Intersection Observer,雖然已有代碼中還體現不出什么實質性功能. 接下來介紹一下代碼中使用到的參數的含義: * callback IntersectionObserver實例的第一個參數, 當目標元素與根元素通過閾值 時就會觸發該回調.回調中第一個參數是被觀察對象列表,一旦被觀察對象發生突變就會被移入該列表, 列表中每一項都保留有觀察者的位置信息;第二個參數為observer,觀察者本身.如下圖控制臺打印:

          其中rootBounds表示根元素的位置信息, boundingClientRect表示目標元素的位置信息,intersectionRect表示叉部分的位置信息, intersectionRatio表示目標元素的可見比例.

          • 配置屬性 IntersectionObserver實例的第二個參數,用來配置監聽屬性,具體有以下三個屬性:
            • root 所監聽對象的具體祖先元素(element)。如果未傳入值或值為null,則默認使用頂級文檔的視窗。
            • rootMargin 計算交叉時添加到根(root)邊界盒bounding box的矩形偏移量, 可以有效的縮小或擴大根的判定范圍從而滿足計算需要
            • thresholds 一個包含閾值的列表, 按升序排列, 列表中的每個閾值都是監聽對象的交叉區域與邊界區域的比率。當監聽對象的任何閾值被越過時,都會生成一個通知(Notification)。如果構造器未傳入值, 則默認值為0。 以上屬性介紹字面上可能很難理解,筆者花幾個草圖來讓大家有個直觀的認知:

          當我們設置rootMargin為10px時,我們的root會增大影響范圍,但目標元素移動到淡紅色區域式就會被監聽到,當然我們還可以設置rootMargin為負值來減少影響區域.其支持的值為百分比和px,如下:

          rootMargin: '10px'
          rootMargin: '10%'
          rootMargin: '10px 0px 10px 10px'
          

          thresholds可以如下圖理解:

          由上圖所示,當我們設置閾值為[0.25, 0.5]時, 目標元素的25%和50%進入根元素的影響范圍時都會觸發回調.利用這個特性我們往往可以實現位差動畫,或者更根據目標元素的位置變化做不同的交互. 當然Intersection還提供了以下幾個方法來控制觀察對象: disconnect() 使IntersectionObserver對象停止監聽工作 takeRecords() 返回所有觀察目標的IntersectionObserverEntry對象數組 * unobserve() 使IntersectionObserver停止監聽特定目標元素

          了解了使用方法和api之后,我們來看看一個實際應用--實現圖片懶加載:

          <img src="loading.gif" data-src="absolute.jpg">
          <img src="loading.gif" data-src="relative.jpg">
          <img src="loading.gif" data-src="fixed.jpg">
          
          <script>
          let observerImg=new IntersectionObserver(
          (entries, observer)=> { 
              entries.forEach(entry=> {
                  // 替換為正式的圖片
                  entry.target.src=entry.target.dataset.src;
                  // 停止監聽
                  observer.unobserve(entry.target);
                });
              }, 
              {
                root: documennt.getElementById('scrollView'),
                threshold: 0.3
              }
          );
          
          document.querySelectorAll('img').forEach(img=> { observerImg.observe(img) });
          </script>

          以上代碼就實現了一個圖片懶加載功能, 當圖片的30%進入根元素時才加載真實的圖片,這又讓我想起了之前在某條做廣告埋點上報時使用react-lazyload的畫面.大家還可以利用它實現無限滾動, H5視差動畫等有意思的交互場景.

          1.2 Mutation Observer和Resize Observer

          Mutation Observer主要用來實現dom變動時的監聽,同樣也是異步觸發,對監聽性能非常友好. Resize Observer主要用來監聽元素大小的變化,相比于每次窗口變動都觸發的window.resize事件, Resize Observer有更好的性能和對dom有更細粒度的控制,它只會在繪制前或布局后觸發調用. 以上兩個api的使用和Intersection使用非常類似,官方資料也寫得很全,大家可以好好研究一下.

          2. 移除script標簽后事件仍然能執行的原因

          這個問題主要是之前有朋友問過我,當時的想法就是簡單的認為script內的代碼執行完之后以及與dom綁定了,存放在了瀏覽器內存中,最近查了很多資料發現有一個有點意思的解釋,放出來大家可以感受一下:

          JavaScript解釋器在執行腳本時,是按塊來執行的,也就是說瀏覽器在解析HTML文檔流時,如果遇到一個script標簽,javascript解釋器會等待這個代碼塊都加載完了,才進行預編譯,然后才執行。所以,當開始執行這個代碼塊的代碼時,這個代碼段已經被解析完了。這時再從DOM中刪去也就不影響代碼的執行了。

          3. Proxy/Reflect

          Proxy/Reflect雖然是es6的api,出現也已經有幾年了,但是在項目中用的還是比較少,如果是做底層架構方面的工作,還是建議大家多去使用,畢竟vue/react這種框架源碼把這些api玩的如火純青,還是很有必要掌握一下的。

          其實我們認真看mdn的介紹或者阮一峰老師的文章,還是很好理解這些api的用法的,接下來我們詳細介紹一下這兩個api以及應用場景.

          3.1 Proxy

          Proxy 可以理解成,在目標對象之前架設一層“攔截”,外界對該對象的訪問,都必須先通過這層攔截,因此提供了一種機制,可以對外界的訪問進行過濾和改寫。Proxy在很多場景中都會和Reflect一起使用. 用法也很簡單,我們看看Proxy的基本用法:

          const obj={
             name: '徐小夕',
             age: '120'
           }
           const proxy=new Proxy(obj, {
             get(target, propKey, receiver) {
              console.log('get:' + propKey)
              return Reflect.get(target, propKey, receiver)
             },
             set(target, propKey, value, receiver) {
              console.log('set:' + propKey)
              return Reflect.set(target, propKey, value, receiver)
             }
           })
           console.log(proxy.name) // get:name 徐小夕
           proxy.work='frontend' // set:work frontend
          

          以上代碼攔截了obj對象,并重新定義了讀寫(get/set)方法,這樣我們就可以在訪問對象時進行額外的操作了.

          Proxy還有apply(攔截 Proxy 實例作為函數調用的操作)和construct(攔截 Proxy 實例作為構造函數調用的操作)等屬性可以使用,我們可以在對象操作的不同階段進行攔截,這里我就不一一樣舉例了.接下來看看Proxy的實際應用場景. * 實現數組讀取負數的索引

          我們一般操作數組大多數都是正向操作的,不能通過指定負數來逆向查找數組,如下圖:

          我們不能通過arr[-1]來拿到數組的尾部元素(字符串同理),這個時候我們就可以用Proxy來實現這一功能,這是我們的結構有點像環狀:

          這種實現的好處是如果我們想訪問數組的最后一個元素時,我們不需要先拿到長度,再通過索引訪問了:

          // 原始寫法
          arr[arr.length -1]
          // 通過proxy改造后寫法
          arr[-1]
          

          實現代碼如下:

          function createArray(...elements) {
            let handler={
              get(target, propKey, receiver) {
                let index=Number(propKey);
                if (index < 0) {
                  propKey=String(target.length + index);
                }
                return Reflect.get(target, propKey, receiver);
              }
            };
          
            let target=[];
            target.push(...elements);
            return new Proxy(target, handler);
          }
          

          我們可以發現以上代碼使用proxy來代理數組的讀取操作,在內部封裝了支持負值查找的功能,當然我們也可以不用proxy來實現同樣的功能,這里實現參考阮一峰老師的實現. * 利用proxy實現更優雅的校驗器

          一般我們在做表單校驗的時候會寫一些if else或者switch判斷來實現對不同屬性值的校驗,同樣我們也可以用proxy來優雅的實現它,代碼如下:

          const formData={
             name: 'xuxi',
             age: 120,
             label: ['react', 'vue', 'node', 'javascript']
           }
           // 校驗器
           const validators={
             name(v) {
               // 檢驗name是否為字符串并且長度是否大于3
               return typeof v==='string' && v.length > 3
             },
             age(v) {
               // 檢驗age是否為數值
               return typeof v==='number'
             },
             label(v) {
               // 檢驗label是否為數組并且長度是否大于0
               return Array.isArray(v) && v.length > 0
             }
           }
           // 代理校驗對象
           function proxyValidator(target, validator) {
            return new Proxy(target, {
              set(target, propKey, value, receiver) {
                if(target.hasOwnProperty(propKey)) {
                  let valid=validator[propKey]
                  if(!!valid(value)) {
                    return Reflect.set(target, propKey, value, receiver)
                  }else {
                    // 一些其他錯誤業務...
                    throw Error(`值驗證錯誤${propKey}:${value}`)
                  }
                }
              }
            })
           }
          

          有了以上實現模式,我們就可以實現對表單中某個值進行設置時進行校驗了,用法如下:

          let formObj=proxyValidator(formData, validators)
          formObj.name=333;   // Uncaught Error: 值驗證錯誤name:f
          formObj.age='ddd'   // Uncaught Error: 值驗證錯誤age:f
          

          以上代碼中當設置了不合法的值時,控制臺將會剖出錯誤,如果在實際業務中,我們可以給用戶做出適當的提醒. 實現請求攔截和錯誤上報 實現數據過濾

          以上幾點筆者在之前的文章中也寫過,所以這里不在詳細介紹了.大家也可以根據實際情況自己實現更加靈活的攔截操作.當然Proxy提供的API遠遠不止這幾個,我們可以在MDN或者其他渠道了解更多高級用法.

          3.2 Reflect

          Reflect對象與Proxy對象一樣,也是 ES6 為了操作對象而提供的新 API,更多的應用場景是配合proxy一起使用,在上文中已經用到了.可以將Object對象的一些明顯屬于語言內部的方法放到Reflect對象上,并修改某些Object方法的返回結果. Reflect對象的方法與Proxy對象的方法一一對應,只要是Proxy對象的方法,就能在Reflect對象上找到對應的方法。

          4. 自定義事件

          CustomEvent API是個非常有意思的api, 而且非常實用, 更重要的是學起來非常簡單,而且被大部分現代瀏覽器支持.我們可以讓任意dom元素監聽和觸發自定義事件,只需要如下操作:

          // 添加一個適當的事件監聽器
          dom1.addEventListener("boom", function(e) { something(e.detail.num) })
          
          // 創建并分發事件
          var event=new CustomEvent("boom", {"detail":{"num":10}})
          dom1.dispatchEvent(event)
          

          我們來看看CustomEvent的參數介紹: type 事件的類型名稱,如上面代碼中的'boom' CustomEventInit 提供了事件的配置信息,具體有以下幾個屬性 * bubbles 一個布爾值,表明該事件是否會冒泡 * cancelable 一個布爾值,表明該事件是否可以被取消 * detail 當事件初始化時傳遞的數據

          我們可以通過dispatchEvent來觸發自定義事件.其實他的用途有很多,比如創建觀察者模式, 實現數據雙向綁定, 亦或者在游戲開發中實現打怪掉血,比如下面的例子:


          筆者上面畫了一個打boss的草圖, 現在的場景是兩個玩家一起打boss, 我們可以在玩家發動攻擊的時候觸發dispatch掉血的自定義事件, boss監聽到事件后將血量自動扣除, 至于不同角色的傷害值,我們可以存放在detail中,然后通過策略模式去分發傷害.筆者曾今在學校開發的H5游戲時就大量采用類似的模式,還是非常有意思的.

          5. fileReader

          File API使得我們在瀏覽器端可以訪問文件的數據,比如預覽文件,獲取文件信息(比如文件名,文件內容,文件大小等), 并且可以在前端實現文件下載(可以借助canvas和 window.URL.revokeObjectURL的一些能力).當然我們還可以實現拖拽上傳文件這樣高用戶體驗的操作.接下來我們來看看幾個實際例子. * 顯示縮略圖

          function previewFiles(files, previewBox) {
              for (var i=0; i < files.length; i++) {
                var file=files[i];
                var imageType=/^image\//;
          
                if (!imageType.test(file.type)) {
                  continue;
                }
          
                var img=document.createElement("img");
                previewBox.appendChild(img); // 假設"preview"就是用來顯示內容的div
          
                var reader=new FileReader();
                reader.onload=(function(imgEl) { 
                  return function(e) { imgEl.src=e.target.result; }; 
                })(img);
                reader.readAsDataURL(file);
              }
            }
          

          以上代碼可以在reviewBox容器中顯示已上傳好的圖片,當然我們還可以基于此來擴展,利用canvas將圖片畫到canvas上,然后進行圖片壓縮,最后再把壓縮后的圖片上傳到服務器.這中方式其實目前很多工具型網站都在用,比如在線圖片處理網站,提供的批量壓縮圖片,批處理水印等功能,套路都差不多,感興趣的朋友可以嘗試研究一下. * 封裝文件上傳組件

          這塊筆者之前也寫過詳細的文章,這里就不一一舉例了.

          6. Fullscreen

          全屏API主要是讓網頁能在電腦屏幕中全屏顯示,它允許我們打開或者退出全屏模式,以便我們根據需要進行對應的操作,比如我們常用的網頁圖形編輯器或者富文本編輯器, 為了讓用戶專心于內容設計,我們往往提供切換全屏的功能供用戶使用.由于全屏API比較簡單,這里我們直接上代碼:

          // 開啟全屏
          document.documentElement.requestFullscreen();
          // 退出全屏
          document.exitFullscreen();
          

          以上代碼的document.documentElement也可以換成任何一個你想讓其全屏的元素.默認情況下我們還可以通過document.fullscreenElement來判斷當前頁面是否處于全屏狀態,來實現屏幕切換的效果.如果是react開發者,我們也可以將其封裝成一個自定義hooks來實現與業務相關的全屏切換功能.

          7. URL

          URL API是URL標準的組成部分,URL標準定義了構成有效統一資源定位符的內容以及訪問和操作URL的API。

          我們利用URL組件可以做很多有意思的事情.比如我們有個需求需要提取url的參數傳給后臺,傳統的做法是自己寫一個方法來解析url字符串,手動返回一個query對象.但是利用URL對象,我們可以很方便的拿到url參數,如下:

          let addr=new URL(window.location.href)
          let host=addr.host  // 獲取主機地址
          let path=addr.pathname  // 獲取路徑名
          let user=addr.searchParams.get("user")  // 獲取參數為user對應的值
          

          以上代碼可知,我們如果將url轉化為URL對象,那么我們就可以很方便的通過searchParams提供的api來拿到url參數而無需自己再寫一個方法了.

          另一方面,如果網站安全性比較高,我們還可以對參數進行自然數排序然后再加密上傳給后端.具體代碼如下:

          function sortMD5WithParameters() {
              let url=new URL(document.location.href);
              url.searchParams.sort();
              let keys=url.searchParams.keys();
              let params={}
          
              for (let key of keys) {
                let val=url.searchParams.get(key);
                params[key]=val
              };
              // ...md5加密
              return MD5(params)
           }
          

          8. Geolocation

          地理位置 API 通過 navigator.geolocation 提供, 這個瀏覽器API也比較實用, 我們在網站中可以用此方式確定用戶的位置信息,從而讓網站有不同的展現,增強用戶體驗.

          舉幾個有意思的例子可以讓大家感受一下: 根據不同地區,網站展示不同的主題:

          根據用戶所在地區,展示不同推薦內容 這一點電商網站或者內容網站用的比較多, 比如用戶在新疆,則給他推薦瓜果類廣告, 在北京,則給他推薦旅游景點類廣告等,雖然實際應用中往往會更復雜,但是也是一種思路.

          其實應用遠遠不止如此,程序員可以發揮想象來實現更有意思的事情,讓自己的網站更智能.接下來筆者就基于promise寫一段獲取用戶位置的代碼:

          function getUserLocation() {
              return new Promise((resolve, reject)=> {
                if (!navigator.geolocation) {
                  reject()
                } else {
                  navigator.geolocation.getCurrentPosition(success, error);
                }
          
                function success(position) {
                  const latitude=position.coords.latitude;
                  const longitude=position.coords.longitude;
                  resolve({latitude, longitude})
                }
          
                function error() {
                  reject()
                }
              })
            }
          

          使用方式和結果如下圖所示:

          我們基于獲取到的經緯度調用第三方api(比如百度,高德)就可以獲取用戶所在為精確位置信息了.

          9. Notifications

          Notifications API 允許網頁或應用程序在系統級別發送在頁面外部顯示的通知;這樣即使應用程序空閑或在后臺,Web應用程序也會向用戶發送信息。

          我們舉個實際的例子,比如我們網站內容有更新,通知用戶,效果如下:

          相關代碼如下:

          Notification.requestPermission( function(status) {
            console.log(status); // 僅當值為 "granted" 時顯示通知
            var n=new Notification("趣談前端", {body: "從零搭建一個CMS全棧項目"}); // 顯示通知
          });
          

          當然瀏覽器的Notification還給我們提供了4個事件觸發api方便我們做更全面的控制: onshow 當通知被顯示給用戶時觸發 (已廢棄, 但部分瀏覽器仍然能用) onclick 當用戶點擊通知時觸發 onclose 當通知被關閉時觸發(已廢棄, 但部分瀏覽器仍然能用) onerror 當通知發生錯誤的時候觸發

          有了這樣的事件監聽,我們就可以控制當用戶點擊通知時, 跳轉到對應的頁面或者執行相關的業務邏輯.如下代碼所示:

          Notification.requestPermission( function(status) {
            console.log(status); // 僅當值為 "granted" 時顯示通知
            var n=new Notification("趣談前端", {body: "從零搭建一個CMS全棧項目"}); // 顯示通知
                n.onshow=function () { 
                  // 消息顯示時執行的邏輯
                  console.log('show') 
                }
                n.onclick=function () { 
                  // 消息被點擊時執行的邏輯
                  history.push('/detail/1232432')
                }
                n.onclose=function () { 
                  // 消息關閉時執行的邏輯
                  console.log('close')
                }
          });
          

          當然我們在使用前需要獲取權限,方式也很簡單,大家可以在mdn上學習了解.

          10. Battery Status

          Battery Status API提供了有關系統充電級別的信息并提供了通過電池等級或者充電狀態的改變提醒用戶的事件。 這個可以在設備電量低的時候調整應用的資源使用狀態,或者在電池用盡前保存應用中的修改以防數據丟失。

          之前的版本中Battery Status API提供了幾個事件監聽函數來監聽電量的變化以及監聽設備是否充電,但是筆者看文檔時這些api都已經廢棄,如下: chargingchange 監聽設別是否充電 levelchange 監聽電量充電等級 chargingtimechange 充電時間變化 dischargingtimechange 放電時間變化

          雖然以上幾個看似有用的api已經被棄用,但是筆者親測谷歌還是可以正常使用的,但是為了讓自己代碼更可靠,我們可以用其他方式代替,比如用定時器定期去檢測電量情況,進而對用戶做出不同的提醒.

          接下來我們看看基本的用法:

          navigator.getBattery().then(function(battery) {
            console.log("是否在充電? " + (battery.charging ? "是" : "否"));
            console.log("電量等級: " + battery.level * 100 + "%");
            console.log("充電時間: " + battery.chargingTime + " s");
            console.log("放電時間: " + battery.dischargingTime + "s");
          });
          

          我們可以通過getBattery拿到設備電池信息,這個api非常有用,比如我們可以在用戶電量不足時禁用網站動畫或者停用一些耗時任務,亦或者是對用戶做適當的提醒,改變網站顏色等,對于webapp中播放視頻或者直播時,我們也可以用css畫一個電量條,當電量告急時提醒用戶.作為一個優秀的網站體驗師,這一塊還是不容忽視的.

          參考文獻

          • ECMAScript 6 入門 - 阮一峰
          • MDN web English docs

          主站蜘蛛池模板: 欧洲无码一区二区三区在线观看| 中文字幕一区二区区免| 无码国产精品一区二区免费vr| 久久无码一区二区三区少妇| 内射白浆一区二区在线观看 | 奇米精品一区二区三区在线观看| 东京热无码av一区二区| 国产亚洲无线码一区二区 | 韩国福利视频一区二区| 动漫精品第一区二区三区| 成人精品视频一区二区三区不卡| 日本不卡免费新一区二区三区| 蜜桃无码AV一区二区| 狠狠做深爱婷婷久久综合一区 | 狠狠色婷婷久久一区二区三区| 日韩精品在线一区二区| 久久精品国产AV一区二区三区| 福利电影一区二区| 少妇人妻精品一区二区三区| 日韩高清一区二区| 国产成人无码一区二区三区 | 中文字幕一区二区人妻| 97一区二区三区四区久久| 乱人伦一区二区三区| 末成年女A∨片一区二区| 国产在线不卡一区| 色一乱一伦一图一区二区精品| 日本一区二区三区久久| 亚洲一区精品中文字幕| 蜜桃视频一区二区| 亚洲精品色播一区二区| 台湾无码AV一区二区三区| 怡红院一区二区在线观看| 亚洲一区二区三区自拍公司| 国产精品无码一区二区三区免费 | 国产精品一区二区三区99| 日韩成人无码一区二区三区 | 日韩精品电影一区| 精品国产伦一区二区三区在线观看 | 熟妇人妻系列av无码一区二区| 成人日韩熟女高清视频一区|