Shader "Holistic/Glass"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_ScaleUVX ("Scale X", Range(1,10)) = 1
_ScaleUVY ("Scale Y", Range(1,10)) = 1
}
SubShader
{
Tags{ "Queue" = "Transparent"}
GrabPass{}
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _GrabTexture;
sampler2D _MainTex;
float4 _MainTex_ST;
float _ScaleUVX;
float _ScaleUVY;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.uv.x = sin(o.uv.x * _ScaleUVX);
o.uv.y = sin(o.uv.y * _ScaleUVY);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_GrabTexture, i.uv);
return col;
}
ENDCG
}
}
}
o.vertex = UnityObjectToClipPos(v.vertex);
把模型空间中的顶点
v.vertex
转换到裁剪空间(Clip Space),用于最终屏幕上正确显示图形。
它是顶点着色器(vertex shader)中必须执行的基本步骤之一。
为什么一定要有它?
因为这一步 告诉 GPU:这个点最终应该画在哪个像素位置上。
如果你不写这一句,Unity 就不会知道这个顶点该画在屏幕哪里,它就无法参与后续的图元组装、光栅化、像素计算。
为什么即使你只在片元中操作 UV 也要它?
因为这是一个完整的 Pass,要经过顶点阶段 → 片元阶段。
即便你在 frag()
里只用了 GrabTexture
和 uv
,
你仍然需要给 vertex : SV_POSITION
一个值,不然这个 Shader 无法运行。
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
将 UV 坐标自动应用材质面板中的 Tiling(平铺)和 Offset(偏移)设置,保证纹理在 Shader 中正确显示。
Unity 材质面板里,每张贴图都可以设置:
Tiling(平铺):控制 UV 缩放
Offset(偏移):控制纹理起始位置
_MainTex Tiling: (2, 2) Offset: (0.1, 0.2)
如果你直接 o.uv = v.uv,这些面板设置就完全没用了。
o.uv = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;
那数值放大了(比如乘了 2),怎么还会和贴图“一个像素对一个像素”地对得上?不会错位吗?
UV 本质上是一个 “比例坐标”(从左上到右下 0~1),不管贴图实际多大,它只是用来描述纹理的相对位置。
放大(Tiling > 1)时不会错位,而是重复铺满更多次数;缩小(Tiling < 1)时则是只显示其中一部分。
概念 | 意思 |
---|---|
v.uv |
顶点原始的 UV 坐标(一般在 [0,1] 范围) |
_MainTex_ST.xy |
材质里设置的 Tiling(缩放) |
_MainTex_ST.zw |
材质里设置的 Offset(偏移) |
tex2D(tex, uv) |
取样纹理中与 uv 对应的位置 |
举个具体例子说明为什么不“错位”:
假设你一张贴图是 256×256 的:
▶ 原始情况(不放大):
v.uv = (0.5, 0.5)
_MainTex_ST.xy = (1,1)
,_MainTex_ST.zw = (0,0)
所以
o.uv = (0.5, 0.5)
→ 正好采样贴图的中心
▶ 放大(Tiling = 2):
_MainTex_ST.xy = (2,2)
o.uv = v.uv * 2
于是:
顶点的 UV 范围从 [0,1] 变成了 [0,2]
tex2D()
采样时,UV 超出 1 会重复平铺(wrap)所以它会看到贴图重复显示 2 次(横 2 次,纵 2 次)
💡 这就是 一个图被重复显示的本质:不是错位,而是比例坐标范围扩大,导致纹理被重复取样
o.uv.x = sin(o.uv.x * _ScaleUVX);
(假设 X 轴):
原始 UV.x | 拉伸后 UV.x × 5 | sin(UV.x × 5) |
---|---|---|
0.0 | 0.0 | 0 |
0.2 | 1.0 | ~0.84 |
0.4 | 2.0 | ~0.91 |
0.6 | 3.0 | ~0.14 |
0.8 | 4.0 | ~-0.76 |
1.0 | 5.0 | ~-0.96 |
What does sin()
really do here?
Take this:
o.uv.x = sin(o.uv.x * _ScaleUVX);
Say o.uv.x
went from 0 to 1 normally (a flat ramp). After applying sin(o.uv.x * scale)
, it becomes a wavy pattern between -1 and 1, or between 0 and 1 if you remap it.
For example, with _ScaleUVX = 10
:
o.uv.x = 0.0
→sin(0.0)
= 0o.uv.x = 0.1
→sin(1.0)
≈ 0.84o.uv.x = 0.2
→sin(2.0)
≈ 0.91...
o.uv.x = 1.0
→sin(10.0)
≈ -0.54
So now, instead of a smooth ramp from left to right across the screen, the UVs are wiggling in a sine wave.
TRANSFORM_TEX(v.uv, _MainTex)
+ sin(...)
If I already do TRANSFORM_TEX
(applying tiling and offset), then I feed the result into sin(...)
, does the tiling and offset still make meaningful sense — or is it just noise at that point?
TRANSFORM_TEX(v.uv, _MainTex)
Applies:
Tiling: scales the UVs (e.g. 2× means repeat twice across the surface)
Offset: shifts UVs (moves the texture around on the mesh)
Visual analogy
Imagine you're warping a flag in the wind:
TRANSFORM_TEX
= how tightly the flag is printed (tiling), and how it's placed on the pole (offset).sin(...)
= the wind blowing through it (distortion).
如果你不是做 Unity,而是做 .NET 开发:
那你可以选择如下组合来获得完整体验:
C#
插件(核心支持)C# Dev Kit
(管理项目、调试、UI 提示)C# Extensions
(快速创建 class/interface/file).NET Install Tool
(辅助自动下载 SDK)
你也可以直接安装 .NET Extension Pack
,它包含上面几个组合包。
最后还是切换回原来的版本,就没这些事情了
当顶点着色器处理一个模型的每个顶点时,它会输出一些变量(比如 UV、位置、法线方向等),
然后这些变量要传给像素着色器(frag),告诉它:
“你这个像素该用什么信息来画?”
这个传递过程不是“直接复制”,而是 GPU 会:
在相邻三个顶点之间插值,算出每个像素所需要的值。
uv
、法线
、worldPos
这些,也都会自动插值。
而这些变量就要通过 TEXCOORDx
来告诉 GPU:“我这个值要插值!”
Unity 编译器/显卡不会知道你要怎么传这值,它就不会给你分配插值寄存器。
结果就是:这个变量不会传过来,或值是乱的。
所以你可以随便起名字,但必须加语义!
float2 myScreenUV : TEXCOORD3;
float3 crazyValue : TEXCOORD5;
这些名字你爱怎么叫都行,但你得告诉它 “我用的是哪条轨道”。
一、TEXCOORD3 的值是哪里“来的”?谁“提供”它的?
答案是:
TEXCOORD3 是你在顶点着色器(vertex shader)里手动写进去的!
它不是自动来的。你要在顶点函数里明确地赋值。
TRANSFORM_TEX(v.uv, _MainTex)
它是 Unity 提供的一个宏,用来自动对 UV 坐标进行缩放和平移(Tiling & Offset)处理。
就是把你的原始 UV:
根据材质设置中的 Tiling(重复)和 Offset(偏移)
变成一个“已经应用过缩放和偏移”的新 UV
o.uvgrab.xy = (float2(o.vertex.x, o.vertex.y) + o.vertex.w) * 0.5;
o.vertex
是经过 UnityObjectToClipPos()
得到的裁剪空间坐标,范围是 [-w, +w]
所以你先把
x, y
从[-w, +w]
变成[0, 2w]
:x + w
然后乘
0.5
把范围映射到[0, w]
float4 clipPos = UnityObjectToClipPos(v.vertex);
这个 clipPos 是:
一个四维向量 float4(x, y, z, w)
表示一个点在**裁剪空间(Clip Space)**里的位置
范围是 [-w, +w](注意不是 [-1, +1]!)
在屏幕空间(或 GrabTexture)里,贴图坐标 UV 的范围是:
x
: 0 左边,1 右边y
: 0 下边,1 上边所以是
[0, 1] × [0, 1]
clipPos.xy / clipPos.w
→ 得到 NDC(Normalized Device Coordinates),范围是 [-1, +1]
(ndc + 1)
→ 范围变成 [0, 2]
/ 2
→ 最终变成 [0, 1]
,这就是 UV 坐标
float4(x, y, z, w)
是在 裁剪空间(clip space) 中的坐标。
它是在顶点变换完所有矩阵(模型 → 世界 → 投影)之后,传给 GPU 光栅器的坐标。
分量 意义
x, y, z 顶点在投影空间中的位置,范围是 [-w, +w]
w 一个重要的比例因子,通常就是从透视投影矩阵里带下来的“深度缩放因子”
如果你用的是正交投影(比如 UI 相机),那么:
所有顶点的
w = 1
;透视除法相当于“没除”,所以没有远近大小之分;
w
是一个透视比例因子,它的存在是为了让 GPU 在最终阶段做 (x / w, y / w, z / w),实现真实的“透视缩放”。
我们再来对比:
空间 | x/y/z 范围 | 用来干嘛 |
---|---|---|
Clip Space | [-w, +w] |
裁剪(剔除不可见) |
NDC Space | [-1, +1] |
映射到屏幕像素坐标 |
透视除法是在裁剪之后才做的,裁剪操作不能等着除以 w,因为那会造成非线性裁剪问题。
“在你除 w 之前,我就要判断你这个点有没有出界。”
判断方式就是:
-w <= x <= +w -w <= y <= +w
只要某个方向超出,就被裁掉了。
这就是裁剪空间范围是 [-w, +w]
的原因!
o.uvgrab.zw = o.vertex.zw;
把裁剪空间的 z 和 w 分量 存到 uvgrab.zw
,
用于后面在像素着色器里执行 tex2Dproj()
做投影采样时使用。
tex2Dproj()
函数来从 _GrabTexture
(屏幕截图)中采样,
但 tex2Dproj()
需要你传入的是一个 4D 向量,其中:
分量 | 说明 |
---|---|
xy |
屏幕采样坐标(UV 空间) |
z |
深度信息(有些平台会用到) |
w |
透视除法用的分母(关键!) |
o.uvgrab.xy = (o.vertex.xy + o.vertex.w) * 0.5;
这是在把裁剪空间的 x, y 分量从 [-w, +w] 映射到 [0, w]
✅ 这行干什么?
o.uvgrab.zw = o.vertex.zw;
把 z 和 w 塞进去,是为了保留:
z → 有时用于深度校正(不总用)
w → 非常重要! 是在 tex2Dproj() 里用于做透视除法的
fixed4 col = tex2Dproj(_GrabTexture, UNITY_PROJ_COORD(i.uvgrab));
i.uvgrab.xy → 之前计算好的屏幕 UV
i.uvgrab.w → 在这里起到除法分母作用
UNITY_PROJ_COORD() 宏会展开成 float4(x, y, z, w) 并交给 tex2Dproj 使用
那为什么不自己除以 w 呢?
你可以,但你会失去两大好处:
平台差异:有些平台(尤其是早期 mobile / OpenGL)要求用
tex2Dproj()
来做正确的 perspective UV;GPU 优化:使用
tex2Dproj()
可以让 GPU 自动优化xy/w
的插值,尤其在透视失真严重时效果更准;UNITY 宏支持:Unity 提供了
UNITY_PROJ_COORD()
宏,自动处理各种平台下z/w
的兼容性。
o.uvgrab.zw = o.vertex.zw;
其实是干两件事:
成员 作用
z 虽然不总用,但 UNITY_PROJ_COORD() 需要它(某些平台可能参与深度校正)
w 透视除法的分母!关键!必须保留
你可以想象一个屏幕空间采样坐标是 (0.3, 0.6)
,
但你站得远(w=2),那你看到的图像就要更“紧缩”一点,变成:
(0.3,0.6)2=(0.15,0.3)\frac{(0.3, 0.6)}{2} = (0.15, 0.3)2(0.3,0.6)=(0.15,0.3)
所以采样点从原本的 (0.3, 0.6) 被“压”到了更近的位置,
这正是 tex2Dproj()
帮你自动完成的。
Unity 提供的一个跨平台宏,目的是:
统一不同平台上是否做除以 w(透视除法)的方式
有些平台(比如某些 OpenGL、移动 GPU)在像素阶段会自动做除以 w,
但有些平台则要求你手动传 x/w
进去,或者明确写成 tex2Dproj()
。
所以 Unity 用这个宏帮你自动判断当前平台是否需要手动做除以 w:
#if defined(SHADER_API_D3D11) || defined(SOME_PLATFORM)
#define UNITY_PROJ_COORD(a) a
#else
#define UNITY_PROJ_COORD(a) (a.xy / a.w)
#endif
✅ 有的平台直接 pass-through; ✅ 有的平台自动除以 w; ✅ 你不需要知道平台细节,它帮你写好。
“是不是 UNITY_PROJ_COORD 是把 clip space 的坐标变成了 UV?”
你可以这么理解:它把你传进来的坐标 “准备好” 成适合 tex2Dproj()
使用的格式。
它并不是完全“把 clipPos 转换成 UV”,
而是“确保你在 tex2Dproj()
时,不会因为平台差异而算错 UV”。
tex2Dproj()
是一个 “投影贴图采样函数”,它会自动帮你把传入的坐标的 .xy
除以 .w
,从而实现透视矫正的采样。
普通 tex2D()
做的事情是:
tex2D(_Texture, uv) // uv 是 float2
直接从纹理中采样某个坐标点,比如 (0.3, 0.6)
。
而 tex2Dproj()
做的是:
tex2Dproj(_Texture, float4(x, y, z, w))
它内部帮你先做一件事:
real2 uv = (x / w, y / w);
tex2D(_Texture, uv);
所以:
它用的是
xy / w
得到最终采样点;这个 “w” 一般是来自裁剪空间的坐标,反映了深度/透视投影比例;
也就是说,它让你采样 GrabTexture 的时候能考虑视角深度/角度失真,否则贴图会错位。
它不是告诉 GPU 整体贴图的范围,而是告诉 GPU:
“我要采样的这个像素点,在屏幕贴图上,应该对应的位置是哪里。”
具体地说,tex2Dproj()
让 GPU 用:
传入的
float4(x, y, z, w)
里的x/w
,y/w
作为采样位置;自动处理透视缩放下的偏差,保持图像的“视觉一致性”。
默认情况下,Unity 中一个模型只有一组 UV 坐标(v.uv
),但可以通过不同材质的 Tiling / Offset 配置,让同一组 UV 映射出不同的纹理效果。所以虽然传入的是同一组 v.uv
,结果可以完全不一样!
o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 主纹理 UV
o.uvbump = TRANSFORM_TEX(v.uv, _BumpMap); // 法线贴图 UV
输入的 v.uv
相同,是模型自带的主 UV 坐标。
Unity 的模型导入时,一般只有一组主 UV;
你可以用这套 UV 去采所有的贴图(_MainTex, _BumpMap, _MaskTex...);
TRANSFORM_TEX(...)
会加上每张纹理自己的 Tiling & Offset!
TRANSFORM_TEX(uv, _Tex) ≡ uv * _Tex_ST.xy + _Tex_ST.zw
每张纹理都会自动生成一个 _Tex_ST 变量(由 Unity 材质系统提供):
_MainTex_ST.xy 主纹理的 Tiling(缩放)
_MainTex_ST.zw 主纹理的 Offset(偏移)
同样 _BumpMap_ST 是法线贴图的偏移缩放。
所以,即使输入是同一个 v.uv
,但:
表达式 | 实际效果 |
---|---|
TRANSFORM_TEX(v.uv, _MainTex) |
使用 _MainTex_ST ,对 UV 做缩放+平移 |
TRANSFORM_TEX(v.uv, _BumpMap) |
使用 _BumpMap_ST ,对同一组 UV 用不同的缩放平移 |
最终就可以做到:
“用同一张 UV,贴出不同大小、不同位置的图”
是的,大多数模型(尤其是从 Blender、Maya、3ds Max 导出)默认只携带 UV0(第一组 UV 坐标),也就是你在 shader 中访问的
v.uv
。
这是:
float2 uv : TEXCOORD0; // 主 UV(UV0)
能有多套 UV 吗?当然可以!
Unity 最多支持 8 套 UV 坐标:
名称 | 对应语义 | 用途(常见) |
---|---|---|
uv |
TEXCOORD0 | 主纹理采样 |
uv2 |
TEXCOORD1 | Lightmap(光照贴图) |
uv3 |
TEXCOORD2 | 第二张法线/Detail Map |
uv4 ~uv7 |
TEXCOORD3~6 | 自定义需求 |
你可以在模型导出时勾选 第二套 UV
(比如烘焙光照时),也可以在 Unity 中用 Mesh.uv2
动态设置。
在 appdata
里,它表示:
“从 GPU 的顶点缓冲区(Mesh 数据) 中的 第 0 套 UV 坐标,读入这段数据,赋值给
uv
”
你在 Unity 里往
mesh.uv3
赋值,那么这个数据就会自动传到 shader 的appdata.uv3 : TEXCOORD2
。你不需要手动指定绑定,只要保证 shader 中语义用的是正确的
TEXCOORDx
,Unity 会自动对上。
half2 bump = UnpackNormal(tex2D(_BumpMap, i.uvbump)).rg;
tex2D(_BumpMap, i.uvbump):从法线贴图中读取颜色
UnpackNormal(...):把颜色从 [0,1] 转换成 [-1,1]
.rg:只取 X 和 Y 分量(我们只想要平面的扰动方向)
✅ 这给你一个“这个像素想往哪个方向偏移”的 2D 向量
float2 offset = bump * _ScaleUV * _GrabTexture_TexelSize.xy;
这步把 bump 换算成在 GrabTexture 上的实际像素单位偏移:
变量 解释
bump 法线贴图给的方向 [-1, 1]
_ScaleUV 一个你控制的系数,控制扰动强度
_GrabTexture_TexelSize.xy 屏幕纹理中一个像素有多大(比如 1/1920, 1/1080)
→ 所以 offset 是最终你要加到 UV 上的扰动向量。
i.uvgrab.xy = offset * i.uvgrab.z + i.uvgrab.xy;
这一步的作用是:
✅ 把你刚才算好的扰动加到屏幕采样 UV 上(也就是 GrabTexture 的坐标)
拆解:
i.uvgrab.xy 是原本你要从哪采屏幕颜色;
offset 是要偏移多少;
i.uvgrab.z 是之前保留下来的裁剪空间中的 z 值,它其实代表了深度的一个线性缩放因子;
乘上 z,让深度越远的地方扰动越小(更真实);
加上去:最终的 i.uvgrab.xy 被扰动!
GLSL与HLSL分别基于OpenGL和Direct3D的接口,两者不能混用,事实上OpenGL和Direct3D一直都是
冤家对头,争斗良久。OpenGL在其长期发展中积累下的用户群庞大,这些用户会选择GLSL学习。GLSL继
承了OpenGL的良好移植性,一度在Unix等操作系统上独领风骚。但GLSL的语法体系自成一家。微软的
HLSL移植性较差,在Windows平台上可谓一家独大,这一点在很大程度上限制了HLSL的推广和发展。但是
HLSL用于DX游戏领域却是深入人心。
Cg语言(CforGraphic)是为GPU编程设计的高级着色语言,Cg极力保留C语言的大部分语义,并让
开发者从硬件细节中解脱出来,Cg同时也有一个高级语言的其它好处,如代码的易重用性,可读性得到提
高,编译器代码优化。Cg是一个可以被OpenGL和Direct3D广泛支持的图形处理器编程语言。Cg语言和
OpenGL、Direct3D并不是同一层次的语言,而是OpenGL和Directx的上层,即Cg程序是运行在OpenGL和
DirecX标准顶点和像素着色的基础上的。Cg由NVIDIA公司和微软公司相互协作在标准硬件光照语言的语法
和语义上达成了一致开发。所以,HLSL和Cg其实是同一种语言。
平台对应关系(非常关键):
图形 API | 对应 Shader 语言 | 常用于的平台 |
---|---|---|
DirectX (DX) | HLSL | Windows, Xbox |
OpenGL / OpenGL ES | GLSL | Android, macOS(部分), WebGL |
Metal | MSL | iOS, macOS |
Vulkan | SPIR-V(可由 HLSL/GLSL 编译) | 各平台通用(取代 OpenGL) |
Unity 自己不会要求你直接写 GLSL 或 HLSL,而是:
推荐你写 ShaderLab + HLSL(CG 语法)
Unity 编译时会自动把它转换成目标平台对应的格式:
目标平台 | Unity 会自动转换成 |
---|---|
Windows | HLSL → DXBC (DirectX Bytecode) |
Android (OpenGL ES) | HLSL → GLSL |
iOS (Metal) | HLSL → MSL |
WebGL | HLSL → GLSL ES |
Vulkan | HLSL → SPIR-V |
✅ 所以你可以一直写 HLSL 语法,Unity 自动处理转换
几何阶段和光栅化阶段,开发者无法拥有绝对的控制权,其实现的载体是Gpu。Gpu通过实现流水
线化,大大加快了渲染速度。虽然我们无法完全控制这两个阶段的实现细节,但是Gpu向开发者开
放了很多控制权。
顶点数据为输入,顶点数据是由应用阶段加载到显存中,再由DrawCall指定的。这些数据随后
被传递给顶点着色器。
顶点着色器是完全可编程的,它通常用于实现顶点的空间变换,顶点着色器等功能。曲面细分
着色器是一个可选着色器,用于细分图元。几何着色器同样是可选着色器,可以被用于执行逐图元
的着色操作,或者被产生于更多的图元。裁剪,这一阶段的目的是将那些不在摄像机视野内的顶点
裁剪掉,剔除某些三角图元的面片。这个阶段可配置。屏幕映射,这一阶段不可配置和编程,负责
把每个图元的坐标转换到屏幕坐标系中。
光栅化概念阶段中的三角形设置和三角形遍历都是固定函数的阶段。片元着色器则是完全可编
程的,用于实现逐片元的着色操作。逐片元操作阶段负责很多重要操作,如修改颜色,深度缓冲,
进行混合等,不可编程,但是可配置。
屏幕映射的任务是将裁剪后的齐次坐标(NDC)转换到屏幕坐标系,屏幕坐标系是一个二维坐
标系,和用于显示画面的分辨率有很大关系。
三角形设置
这个阶段会计算光栅化一个三角网格所需要的信息。上一个阶段输出的都是三角网格的顶点,
即我们得到的是三角网格每条边的两个端点。如果要得到正规三角网格对像素的覆盖情况,就必须
计算每条边上的像素坐标。为了能够计算边界像素的坐标信息,就需要得到三角形边界的表示方式
三角形遍历
三角形遍历阶段将会检查每个像素是否被一个三角网格所覆盖。如果被覆盖的情况下,就会产
生一个片元。而这样一个找到那些像素被三角网格覆盖的过程就叫做三角形遍历,也被称作扫描变
换。
三角形遍历阶段会根据上一个阶段的计算结果来判断一个三角网格覆盖了哪些像素,并使用三
角网格3个顶点的顶点信息对整个覆盖区域的像素进行插值。
和ps这些类似