从贴图空间(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 aPass
block. - To define SubShader tags, place the
Tags
block inside aSubShader
block but outside aPass
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 中很多时候会“展开”这个乘法写成:
顶点插值不支持结构体或矩阵传递