從貼圖空間(texture space)將值還原到切線空間(tangent space)向量
tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale;
背后的知識點:法線貼圖中的 RGB 是在 0~1 范圍內編碼的向量
所以貼圖法線是怎么“壓縮”和“解壓”的?
💾 存儲時:
-
法線向量
float3(n.x, n.y, n.z)
∈ [-1, 1] -
存入貼圖時要轉為 0~1 范圍:
讀取后要“反解碼”回來:
fixed3 packedNormal = tex2D(_BumpMap, i.normalUv);讀到的是范圍是 [0,1]
所以:tangentNormal.xy = packedNormal.xy * 2 - 1;
-
這一步將
0~1
解碼為-1~1
-
_BumpScale
是調節法線擾動強度的乘子(可以人為增強凹凸感)
法線貼圖中,我們通常只顯式存儲了 x
和 y
分量(映射到 R 和 G 通道),而 z
是通過單位向量長度約束反推的。
RGB 貼圖中 xyz 的意義是什么?
通道 | 存儲的是什么 | 映射回 tangent space 的含義 |
---|---|---|
R(x) | 切線方向上的擾動(沿 tangent 軸) | normal 在 tangent 方向上的偏移量 |
G(y) | 副切線方向上的擾動(沿 bitangent 軸) | normal 在 bitangent 方向上的偏移量 |
B(z) | 法線方向(沿 normal 軸) | 通常通過反推計算,表示“凸出/凹陷”程度 |
這些值共同構成了貼圖中每個點的擾動法線向量(在 tangent space 中)。
法線貼圖并不改變頂點的 normal,而是在片元級別提供比頂點插值更精細的擾動方向
-
頂點 normal 是由模型網格決定的(每個頂點一個方向)
-
法線貼圖在片元級別提供 “在當前表面方向上的微擾”
-
Shader 會把貼圖中的
(x,y,z)
作為一個 Tangent Space 中的擾動法線 -
然后用 TBN 把這個擾動變換到世界空間,再用于光照計算
z 分量作用 保證法線是單位向量,體現凹凸“凸起”的程度
Unity UnpackNormal(tex2D(...)) is used to decode the normal
Normal map is saved as an RGB texture with values in 0–1.
_BumpMap ("Normal Map", 2D) = "bump" {}
Unity 會自動:
把這個屬性在材質面板中以 “Normal Map 類型”顯示
在材質面板中選擇貼圖時,Unity 會驗證它是否是 正常格式的法線貼圖
有些內置函數(如 UnpackNormal)也會默認適配
這是一種 語義提示 + 編輯器行為綁定機制。
值 | 含義解釋 |
---|---|
"white" | 默認使用 Unity 的純白紋理(全通、全亮),通常用于顏色貼圖、控制圖等 |
"black" | 默認使用純黑紋理 |
"gray" | 使用灰色紋理,常用于金屬度、AO、Roughness 等 |
"bump" | ? 使用 Unity 內置的法線貼圖格式(編碼好的 normal map),不會當成顏色貼圖 |
"red" , "green" , "blue" | 分別用純紅、綠、藍紋理測試 |
預處理判斷語句 并不是為了“再計算值”,而是為了根據光源類型(點光、方向光、多光源)不同,決定如何使用這個值。
#ifndef USING_LIGHT_MULTI_COMPILE
如果你沒有啟用多光源(不是 forward 渲染 path)
return objSpaceLightPos - v.xyz * _WorldSpaceLightPos0.w;
這段非常關鍵,它根據 _WorldSpaceLightPos0.w 來判斷當前是:
_WorldSpaceLightPos0.w 值 | 光源類型 | 表達形式 |
---|---|---|
1 | 點光源 | L = lightPos - vertexPos |
0 | 方向光 | L = -lightDir |
#ifndef USING_DIRECTIONAL_LIGHT
? ? return objSpaceLightPos.xyz - v.xyz;
表示點光源:
方向 = 光源位置 - 當前頂點位置
#else
? ? return objSpaceLightPos.xyz;
表示方向光:
方向 = 光線方向(由 _WorldSpaceLightPos0
直接編碼)
tex2D方法傳入的Sample2D和uv類型,這里的uv是經過了TRANSFORM_TEX的變換的,所以對應關系沒有出現問題
tex2D()
返回的是 RGBA 值,對應紋理的 4 個通道:
分量 | 意義(默認) |
---|---|
x / .r | Red 通道 → 通常表示 normal.x |
y / .g | Green 通道 → 通常表示 normal.y |
z / .b | Blue 通道 → 通常表示 normal.z(或重構) |
w / .a | Alpha 通道 → 通常無意義,默認值為 1,除非你主動使用它 |
“返回的是四維矩陣?w 是什么?”
-
? 它不是矩陣,是一個四維向量:
float4
-
?
.w
是采樣紋理像素的 Alpha 通道 -
? 在法線貼圖中,w/alpha 一般 沒有特別作用,除非你專門用它存東西
📌 例外情況:
有時你可能會看到設計得比較高級的 Packed Texture,用 RGBA 四個通道存多個信息,比如:
通道 | 內容(例) |
---|---|
R | 法線 x 分量 |
G | 法線 y 分量 |
B | AO or 金屬度 |
A | 透明度 or 粗糙度 |
在這種情況下,.w
(或 .a
)是你主動使用的額外通道。
常見的指令說明:
指令 | 含義 |
---|---|
#if defined(MACRO) | 如果宏 MACRO 被定義,執行下面的代碼塊 |
#elif defined(...) | 否則如果另一個宏被定義,執行這一塊 |
#else | 如果以上都不滿足,則執行這一塊 |
#endif | 結束 #if 條件塊 |
#define MACRO | 定義一個宏(一般由 Unity 自動定義) |
#undef MACRO | 取消一個宏定義 |
法線貼圖需要::編碼/解碼
貼圖壓縮技術::DXT5nm、BC5、ASTC 等壓縮格式對 normal map 做特定方式編碼
(解碼).x *= .w 在 RGorAG 模式中,w(即 alpha 通道)保存了縮放系數,乘上修正 x
宏分支控制,,,讓同一 Shader 能適配不同貼圖格式和硬件壓縮能力
從數學上講,xy 變大,z 的確必須變小,否則法線向量就不再是單位長度了。我們來精確、嚴謹地拆解這一點,并澄清“為什么我們不能隨意直接乘 z”。
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
-
xy 擾動越大 → z 越低 → 表示“偏離表面方向更多”
-
xy 越小 → z 越接近 1 → 趨近于原始平面法線(表面更平)
packedNormal.x *= packedNormal.w;
? 這是 Unity 在處理 DXT5nm 格式(或 RG/AG 編碼)法線貼圖時,
為了還原正確的 X 分量方向所做的“簽名恢復”操作(sign restoration)。
這句代碼的意義就是:
恢復 DXT5nm 格式貼圖中被拆開的 X 分量的符號信息。
使用情況 | 是否需要 x *= w ? |
---|---|
RGB 正常法線貼圖 | ? 不需要,RGB 已存完整 xyz |
DXT5nm (RGorAG) 格式 | ? 需要,從 R+A 通道還原 x |
ASTC RG 格式 | ? 一樣道理,可能用 .g *= .a |
“tangentNormal.xy *= _BumpScale
是在 z
已經被 sqrt()
計算之后進行的,那這時法線向量還是單位長度嗎?還合理嗎?”
?這樣寫是不嚴謹的,結果法線向量將不再是單位向量,可能會影響光照準確性,
但實際在某些低精度光照模型中可能“看起來”沒太大問題,所以有時會被這么寫。
正確的寫法應該是:先 scale,再重建 z
但也可以tangentNormal = normalize(tangentNormal);這雖然也能修復向量長度,但會引入不必要的性能消耗,并且對于低精度法線會有誤差積累。
如果這里沒設置成Normal map,是default,那就按注釋的那一塊來?
如果在 Unity 中導入貼圖時 沒有把它設置為 “Normal map” 類型,而是保留為
Default
,那就不能直接用UnpackNormal()
,而要自己手動解碼貼圖數據(如*2 - 1
等)——對不對?? 是的,這個判斷邏輯是完全正確的。
所以注意::cross(N, T?) * w ? 是 Unity 推薦的構造 binormal 方法
由于 HLSL 是“列向量 × 行矩陣”的表達順序 —— 不能直接這么寫!
也就是你寫的這個形式:
float3 worldNormal = float3(
? ? dot(i.TtiW0.xyz, tangentNormal),
? ? dot(i.TtiW1.xyz, tangentNormal),
? ? dot(i.TtiW2.xyz, tangentNormal)
);
為什么不需要逆矩陣?
這是一個關鍵誤區 —— 很多書說“方向向量要乘以逆轉置矩陣”,那是對 非正交矩陣 或 非單位正交基 的情況。
但是在這里:
-
T, B, N 都是單位向量
-
三者彼此正交
-
所以 TBN 是正交矩陣
對于正交矩陣:
tex2D(_RampTex, fixed2(halfLambert, halfLambert))
如果你貼的是一張黑 → 白的橫向漸變圖,那它就像是:
-
漫反射強 → 用白
-
光線垂直 → 用灰
-
背面 → 用黑(因為 dot = 0)
Half-Lambert 是為了讓漫反射“更柔和、避免背光發黑”而人為做的函數變換;而 Blinn-Phong 的 Half Vector 是光和視線之間的“幾何中間方向”,是完全不同的概念。
渲染操作 | 是否進行 | 說明 |
---|---|---|
深度測試 | ? 默認開啟,所有物體都可以參與 | 無論透明還是不透明,片元都可能接受深度測試 |
深度寫入 | ? 透明物體通常默認關閉 | 因為深度寫入會導致后繪制的透明物體被擋住 |
為什么有這些不同混合因子
藝術風格與公式的“對應性”
很多時候,所謂“自然的過渡”、“漂亮的發光”,其實是:
-
物理疊加模擬真實世界光照(如 Linear Add)
-
用 Cutoff + Lambert 模擬日光與背光
-
用 Soft Add 表現氛圍感或光暈擴散
這些都是人類在長期調試中找到的:
? “某些數學形式 → 在顯示器上經過 gamma → 在人眼中感覺剛剛好”
自然感 = 光 + 人眼 + 數學的共同結果
你看到的那種:
“怎么一個冷冰冰的
Blend One OneMinusSrcAlpha
,出來就能讓玻璃有那種淡淡的透光感?”
那是因為它剛好:
-
用透明度控制了透光量(alpha)
-
保留了背景(OneMinusSrcAlpha)
-
而你屏幕發光、你眼睛接收,剛好構成了感知的閉環
當你在片元著色器(frag()
)里寫了 discard;
,
GPU 會把這個 當前正準備寫入屏幕的像素,徹底丟棄,不再處理它。
舉個實際例子:一張帶 alpha 的葉子貼圖
比如:
-
樹葉是綠色,alpha = 1
-
葉子空隙是透明,alpha = 0
if (texColor.a < 0.5) discard;
那結果就是:
? 樹葉的綠色區域 → 保留渲染
? 空隙區域 → 這個片元直接 被丟棄,什么都不寫,像沒來過一樣
圖形管線視角下的“丟”
整個流程簡化如下(以一個像素為例):
-
頂點著色器執行完 → 光柵化 → 生成這個片元
-
進入片元著色器執行 → 執行到你寫的
discard;
-
GPU 立刻中止這個片元的寫入流程
-
不寫入顏色緩沖區,不寫入深度,不觸發混合,不投影陰影
-
后面的像素該怎么畫還怎么畫,就像這個像素從沒存在過
return 的顏色,還要經過 后處理通道
在 Unity 的標準 Shader 中,開啟 Blend
通常會:
🔹默認搭配
ZWrite Off
,否則效果可能異常(如透明遮擋錯誤)。
Tags { "Queue" = "Transparent" } ? Unity 內置 Shader 會自動設為 ZWrite Off
- To define Pass tags, place the?
Tags
?block inside a?Pass
?block. - To define SubShader tags, place the?
Tags
?block inside a?SubShader
?block but outside a?Pass
?block.
Common built-in RenderType
values Unity recognizes:
What does “replaced” mean in RenderType?
In this context, “replaced” refers to a feature called:
🎯 Camera.SetReplacementShader(...)
It means Unity can:
👉 Replace the shaders of everything in the scene at runtime,
BUT only for objects with a certainRenderType
.
This is what RenderType is actually used for in this case.
Aspect | Defined in Shader Language? | Used internally by Unity systems? | Example |
---|---|---|---|
"Transparent" | ? No | ? Yes | Replacement shaders, depth prepass |
"Opaque" | ? No | ? Yes | G-buffer, deferred passes |
"TransparentCutout" | ? No | ? Yes | Cutout lighting, shadows |
"MyCustomType" | ? No | ? Only if you use it | Custom pipeline or editor tools |
使用多個 Pass
的確是為了讓 一個 Shader 能夠對同一個材質進行多次渲染處理。
👉 每一個 Pass 相當于 GPU 要渲染這個物體一次。
所以多個 Pass 就是 同一個物體重復渲染多遍(不代表重復 draw call,但有性能代價)。
Pass {
? ? ZWrite On
? ? ColorMask 0
}
的作用是:
? 提前寫入深度值,但不渲染任何顏色,
這樣后續的透明 Pass 就可以正確地進行基于深度的遮擋排序,避免穿模。
為什么透明物體會錯位?
原因是:
-
透明物體一般不開 ZWrite(ZWrite Off)
-
Unity 渲染透明物體時默認按 背面→前面的順序(靠排序,不靠深度)
-
如果模型像“繩結”那樣自相交(自己遮擋自己),
👉 你就無法靠“渲染順序”判斷哪個面該擋住誰了
👉 導致“后面的反而蓋住了前面”
ZWrite 的深度信息只要寫入,就會保留在深度緩沖區中,直到下一幀/下一次清除,
? 所以:只要材質沒有 ZWrite Off
,無論是否最終畫出來,只要通過深度測試,它就確實會寫入深度。
三種法線外拓的shader寫法
法線向量不能被平移 法線必須保持和切平面垂直(幾何關系) 非均勻縮放會破壞這種垂直性
法線向量是一個協變向量(co-vector),
變換它需要使用原始變換矩陣的逆的轉置(inverse transpose)
左乘和右乘有明確、統一、嚴謹的數學定義,
它們的本質是取決于你采用的是**“列向量”還是“行向量”表示法”**。
一旦你選定了其中一種方式 —— 所有矩陣乘法的方向、變換鏈的順序、轉置等操作都有一致的邏輯。
常見約定:列向量形式是計算機圖形學中最常用的表示
使用方式 | 表示法 | 矩陣乘法順序 | 向量在右邊 or 左邊 |
---|---|---|---|
? 列向量約定 | v∈Rnv \in \mathbb{R}^nv∈Rn 是列 | MvMvMv | 向量在右邊(標準) |
? 行向量約定(不常用) | v?v^\topv? 是行向量 | v?Mv^\top Mv?M | 向量在左邊 |
圖形學、Unity、OpenGL、HLSL 統一采用列向量規范:
? 你常寫的這些:
float4 worldPos = mul ( unity_ObjectToWorld? ,? v.vertex );
本質就是:
向量是列向量,矩陣在左邊,左乘矩陣,右乘向量。
「左乘矩陣、右乘向量」這類說法,確實是在強調“向量在右邊”的這種形式。
它告訴你的是向量和矩陣在表達式中的排列關系,是列/行向量系統的一部分語義。
🎯 換句話說:
“左乘矩陣”,是說矩陣寫在左邊,向量在右邊
→ 即:Mv
(向量被左邊的矩陣變換)
→ 默認你在使用 列向量系統---------------Unity巴拉巴拉
“右乘矩陣”,是說向量寫在左邊,矩陣在右邊
→ 即:vM
(向量右乘矩陣)
→ 默認你在使用 行向量系統
所以,是不是「只有這兩種說法」?
是的,從向量和矩陣相乘的定義角度來說,就這兩種約定,沒有第三種本質上不同的方式。
表示方式 | 寫法 | 適配場景 |
---|---|---|
列向量 | Mv | ? 圖形學、Unity、OpenGL、HLSL、GLSL |
行向量 | vM | ? 一些數學教材、少部分線性代數風格(但不常用于圖形變換) |
---------
可是我寫成的形式只是fixed3(x,y,z),,實際上存儲數據的是不關注行列的,,,那么是不是意味著這里的tanspose同樣是只是邏輯上的提示,并不代表里面實際的數據處理方式真的有轉置?
🎯 簡要回答你:
? 是的,你是對的 ——
實際上fixed3(x, y, z)
或float3
本質上只是 一組線性數據,GPU 或 CPU 并不會自動知道它是“行向量”還是“列向量”,這是數學意義上的區別,不是內存物理結構。
而像你提到的
transpose
,更多是對數學邏輯的一種語義提示或約定,并不意味著內存中數據真的被 rearrange(重新排布)了。
Pass{Name "Outline"Cull FrontCGPROGRAM#pragma vertex vert#pragma fragment frag#include "UnityCG.cginc"float _Outline;fixed4 _OutlineColor;struct v2f{float4 vertex :SV_POSITION;};v2f vert (appdata_base v){v2f o;//物體空間法線外拓//v.vertex.xyz += v.normal * _Outline;//o.vertex = UnityObjectToClipPos(v.vertex);//視角空間法線外拓//float4 pos = mul(UNITY_MATRIX_V, mul(unity_ObjectToWorld, v.vertex));//float3 normal = normalize(mul((float3x3)UNITY_MATRIX_IT_MV,v.normal));//pos = pos + float4(normal,0) * _Outline;//o.vertex = mul(UNITY_MATRIX_P, pos);//裁剪空間法線外拓o.vertex = UnityObjectToClipPos(v.vertex);float3 normal = normalize(mul((float3x3)UNITY_MATRIX_IT_MV,v.normal));float2 viewNoraml = TransformViewToProjection(normal.xy);o.vertex.xy += viewNoraml * _Outline;return o;}float4 frag(v2f i):SV_Target{return _OutlineColor;}ENDCG}
把 UNITY_MATRIX_IT_MV
轉成 3x3 ((float3x3)UNITY_MATRIX_IT_MV
) 是合理的,因為我們處理的是法線方向向量,不需要考慮位移分量
-
對于方向向量如
v.normal
,這段3x3
就足夠完成旋轉和縮放變換。 -
如果你傳入的是位置
v.vertex
,那才需要用float4x4
,因為要考慮位移部分。
為什么只用 normal.xy
?
1. 目標是對 o.vertex.xy
做偏移
-
o.vertex
是裁剪空間(clip space)下的坐標,作用于屏幕上的 2D 位置。 -
所以我們只需要在 X 和 Y 平面內偏移這個像素點,制造出輪廓線的“外拓”效果。
-
z
分量在這里并不會影響你看到的輪廓(不會顯著影響屏幕上的位置,只影響深度)
TransformViewToProjection()
是做什么的?
-
這個函數將 觀察空間(View Space) 中的向量轉換為 投影空間(Projection Space)。
-
它用于將法線的方向也帶入透視變形中,避免輪廓線在不同視角下厚度不一致。
-
但它的輸入是
float2
,意味著只能轉換 2D 屏幕平面上的分量。
o.vertex.xy += viewNormal * _Outline;
明明 vertex
是 clip space 的 xyzw,
只改 xy
會不會出問題?
不會出問題,反而這是合理做法,因為這里我們就是要在屏幕空間做一個 2D 偏移效果,而不破壞 z
和 w
o.vertex = UnityObjectToClipPos(v.vertex);
它是 Clip Space(裁剪空間)坐標,四維向量 (x, y, z, w)。
后續會自動進入 NDC(x/w, y/w, z/w),再映射到屏幕。
o.vertex.xy += viewNormal * _Outline;
因為:
你只想讓模型邊緣沿屏幕方向“鼓出來”,產生描邊效果。
所以只改 xy,讓它在畫面中“擠出一點”。
改 z 會造成深度變化,可能導致遮擋錯誤。
改 w 會影響投影縮放關系,造成透視失真。
是否破壞了 clip space 的投影?
不會。
-
Clip space 是一個過渡空間,最終 GPU 會做
o.vertex.xyz / o.vertex.w
投影。 -
xy
改變后,在屏幕上會位移。 -
z
和w
不變,意味著你不會影響遮擋關系或投影縮放。
假設你有一張紙(屏幕),你把一個點輕輕沿著橫縱方向推開一點,但不把它抬起來(不改 z),也不改變它離你的“距離比例”(w)。那么它的視覺位置偏移了,但它的深度和透視仍然是對的。
smoothstep()
是一個在圖形編程中非常常用的函數,用于生成**平滑過渡(soft transition)**效果
smoothstep(edge0, edge1, x)
其作用是:
當 x 在 edge0 和 edge1 之間時,輸出一個從 0 到 1 的平滑插值值。
當 x <= edge0,返回 0;
當 x >= edge1,返回 1;
中間用一個平滑三次曲線過渡。
float t = saturate((x - edge0) / (edge1 - edge0));
return t * t * (3 - 2 * t);
這個公式創造了一個 S型曲線,即緩入緩出(ease in/out)的效果。
difLight = smoothstep(0, 1, difLight);
這一步是:
對 dot(worldLightDir, i.worldNormal) * 0.5 + 0.5 這個光照強度進行 平滑映射;
避免原始 dot 值帶來的硬切換或強烈對比;
實際上就是給卡通風格加一點“柔化的邊緣過渡”。
Shader 中前面那一堆 ShaderLab 語言(也就是 Pass
里的非 HLSL 部分),它們是用來控制渲染行為的,并不是計算顏色或坐標,而是告訴 GPU 在“怎么渲染”這段片元
Pass
{
? ? Name "XRay"
? ? Tags { "ForceNoShadowCasting" = "true" }
? ? Blend SrcAlpha One
? ? ZWrite Off
? ? ZTest Greater
Name "XRay"
-
給這個
Pass
起個名字,方便調試或多通道控制時引用。 -
不影響渲染行為。
Tags { "ForceNoShadowCasting" = "true" }
-
讓這個 Pass 不參與陰影投射。
-
對于半透明或特殊渲染(如 X-Ray)是有意義的:不需要參與陰影。
ZWrite Off
-
關閉深度寫入,也就是說這個 Pass 不會寫入 Z 緩沖區。
-
原因是:XRay 要“穿透”,你不希望它擋住別的物體。
ZTest Greater
-
表示只有在片元比當前深度更遠(Z更大)時才繪制。
-
非常關鍵!這讓這個 XRay Pass 只在物體背面被遮擋時才顯示。
-
也就是:只有當它“在其他物體后面”才會出現,模擬透視內部結構。
-
-
相比常用的
ZTest LEqual
或ZTest Always
,這是一種隱藏條件渲染。
fixed4 frag(v2f i) : SV_Target
{
? ? float3 normal = normalize(i.normal);
? ? float3 viewDir = normalize(i.viewDir);
? ? float rim = 1 - dot(normal, viewDir);
? ? return _XRayColor * pow(rim, 1 / _XRayPower);
}
Shader 計算了一個 Rim(邊緣高光)值 來模擬透視輪廓。
和前面的 ZTest Greater 聯合使用后,只有被擋住的背面邊緣才出現 XRay 效果,很自然地做出內透或透視顯示。
-
同一個模型的不同三角面片之間,只要一個片元比另一個更靠前,就會記錄它的深度。
-
所以模型自己也會遮擋自己。
-
這就是為什么你可以做 XRay 效果:它會在“模型自己后面的像素”上畫出邊緣線或高光。
切線空間(Tangent Space)轉世界空間(World Space)
fixed3 worldNormal = normalize(float3(
? ? dot(i.TtoW0.xyz, tangentNormal),
? ? dot(i.TtoW1.xyz, tangentNormal),
? ? dot(i.TtoW2.xyz, tangentNormal)
));
TBN矩陣 = [Tangent, Binormal, Normal] ?(每一列是一個向量)
由于你無法直接在 Unity 的 fragment shader 中使用 mul(float3x3, float3)
(尤其是當 TBN
來自頂點插值的時候),Unity 中很多時候會“展開”這個乘法寫成:
頂點插值不支持結構體或矩陣傳遞