当前位置: 首页 > ds >正文

《Unity Shader入门精要》学习笔记四(高级纹理)

1、立方体纹理

解释:站在一个完全透明的玻璃盒子中心,就可以看到6个面。把这个玻璃盒子的6个面都贴上一张照片。这6张照片合起来,就记录了周围360度的环境,比如蓝天、地面、建筑、树木等。

在2D纹理中,使用坐标来找颜色,比如左上角是(0,0),右下角是(1,1)。

在立方体纹理中,是用一个方向来查颜色,查出来的是这个方向上看到的环境。

比如,你想知道“往正上方看”会看到什么颜色?你就告诉电脑:“我从中心往 (0, 1, 0) 这个方向看”(也就是向上)。

对立方体纹理的采样如下:

总结:立方体纹理就是一个“360度全景贴图”,用6张图包住一个盒子,让你能快速查到任意方向上看到的环境颜色。

(1)缺点

1)不能自己照自己

比如你有两个金属球靠得很近,它们应该互相反射对方。但立方体纹理是提前拍好的照片,它只记录了环境,不包含这两个球!所以它们没法互相反射,看起来就不真实。

2)环境不能动

如果房间里有人走动,或者灯打开了,环境变了,那这张“照片”就过时了!所以你必须重新拍一次(也就是重新生成立方体纹理),否则反射还是旧的样子。

3)不适合凹进去的物体

比如一个碗、一个杯子,它们内部会反射自己。但立方体纹理不知道“自己长什么样”,所以反射会出错。

(2)天空盒子

游戏中用于模拟背景的一种方法。

天空盒子这个名字包含了两个信息:它是用来模拟天空的(尽管现在我们仍可以用它模拟室内等背景)​,它是一个盒子。当我们在场景中使用了天空盒子时,整个场景就被包围在一个立方体内。这个立方体的每个面使用的技术就是立方体纹理映射技术。

示例:

1)创建SkyboxMat材质

2)Shader选择Skybox/6 Sided,同时放入6张图片

3)每张纹理图片的Wrap Mode设置为Clamp。Clamp模式会将UV坐标限制在[0,1]范围内,这意味着边缘像素的颜色会被拉伸到边缘之外的区域,从而避免了与对侧像素的意外混合,消除了接缝。

在Window -> Rendering -> Lighting -> Environment中,将上面的SkyboxMat放入Skybox Material选项中。

(3)环境映射

立方体纹理另一个常见的用处是用于环境映射。通过这种方法,可以模拟出金属质感的材质。

当我们需要根据物体在场景中位置的不同,生成它们各自不同的立方体纹理,此时就可以在Unity中使用脚本来创建。

这是通过利用Unity提供的Camera.RenderToCubemap函数来实现的。Camera.RenderToCubemap函数可以把从任意位置观察到的场景图像存储到6张图像中,从而创建出该位置上对应的立方体纹理。

1)创建编辑器向导

代码:

using UnityEngine;
using UnityEditor;
using System.Collections;public class RenderCubemapWizard : ScriptableWizard {public Transform renderFromPosition;public Cubemap cubemap;void OnWizardUpdate () {helpString = "Select transform to render from and cubemap to render into";isValid = (renderFromPosition != null) && (cubemap != null);}void OnWizardCreate () {// create temporary camera for renderingGameObject go = new GameObject( "CubemapCamera");go.AddComponent<Camera>();// place it on the objectgo.transform.position = renderFromPosition.position;// render into cubemap		go.GetComponent<Camera>().RenderToCubemap(cubemap);// destroy temporary cameraDestroyImmediate( go );}[MenuItem("GameObject/Render into Cubemap")]static void RenderCubemap () {ScriptableWizard.DisplayWizard<RenderCubemapWizard>("Render cubemap", "Render!");}
}

解释:

这段代码是一个Unity编辑器扩展脚本,它创建了一个想到(Wizard),允许用户通过简单的界面选择一个位置和一个立方体纹理(cubemap),然后从那个位置生成一个新的立方体纹理。这个过程就像是在一个场景中放置一个虚拟相机,并让他拍摄周围环境的照片,然后将这些照片拼接成一个立方体纹理。

public Transform renderFromPosition;
public Cubemap cubemap;
  • renderFromPosition: 这是一个Transform类型的变量,代表你想从哪个位置渲染立方体纹理。你可以把它想象成你想要放置虚拟相机的位置。
  • cubemap: 这是一个Cubemap对象,用来存储渲染的结果。可以把这个看作是你要填满的6张图片的集合。
void OnWizardUpdate () {helpString = "Select transform to render from and cubemap to render into";isValid = (renderFromPosition != null) && (cubemap != null);
}

当你打开这个向导时,这段代码会检查是否已经选择了位置(renderFromPosition)和立方体纹理(cubemap)。如果这两个都选好了,那么“Render!”按钮就会变成可用状态,否则会显示提示信息告诉你需要选择这两项。

void OnWizardCreate () {// 创建临时相机用于渲染GameObject go = new GameObject("CubemapCamera");go.AddComponent<Camera>();// 将其放置在选定的对象上go.transform.position = renderFromPosition.position;// 渲染到立方体纹理go.GetComponent<Camera>().RenderToCubemap(cubemap);// 销毁临时相机DestroyImmediate(go);
}
  • 这里创建了一个新的游戏对象并添加了一个相机组件。这个新创建的游戏对象就相当于我们刚刚说的“虚拟相机”。
  • 然后将这个虚拟相机移动到你之前选择的位置(renderFromPosition)。
  • 接着使用这个相机对周围的环境进行拍照,并把结果保存到你指定的立方体纹理(cubemap)中。
  • 最后,因为我们只是想用这个相机来拍照,拍完之后就不需要它了,所以直接销毁这个临时创建的相机。
[MenuItem("GameObject/Render into Cubemap")]
static void RenderCubemap () {ScriptableWizard.DisplayWizard<RenderCubemapWizard>("Render cubemap", "Render!");
}

这行代码在Unity的“GameObject”菜单下添加了一个名为“Render into Cubemap”的选项。当你点击这个选项时,就会弹出上面描述的向导窗口,允许你选择位置和立方体纹理,然后开始渲染。

(4)反射

使用了反射效果的物体看起来就像镀了层金属。

想要模拟反射效果,只需要通过入射光线的方向和表面法线方向来计算反射反向,再利用反射方向对立方体纹理采样即可。

示例代码:

// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'Shader "Custom/Reflection"
{Properties{_Color("Color Tint", Color) = (1,1,1,1)_ReflectColor("Reflection Color", Color) = (1,1,1,1)_ReflectAmount("Reflect Amount", Range(0,1)) = 1_Cubemap("Reflection Cubemap", Cube) = "_Skybox"{}}SubShader{Tags{"RenderType"="Opaque"  "Queue"="Geometry"}Pass{Tags{"LightMode"="ForwardBase"}CGPROGRAM#pragma multi_compile_fwdbase#pragma vertex vert #pragma fragment frag #include "Lighting.cginc"#include "AutoLight.cginc"fixed4 _Color;fixed4 _ReflectColor;fixed _ReflectAmount;samplerCUBE _Cubemap;struct a2v{float4 vertex: POSITION;float3 normal: NORMAL;};struct v2f{float4 pos: SV_POSITION;float3 worldPos: TEXCOORD0;fixed3 worldNormal: TEXCOORD1;fixed3 worldViewDir: TEXCOORD2;fixed3 worldRefl: TEXCOORD3;SHADOW_COORDS(4)};v2f vert(a2v v){v2f o;o.pos = UnityObjectToClipPos(v.vertex);o.worldNormal = UnityObjectToWorldNormal(v.normal);o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);// Compute the reflect dir in world spaceo.worldRefl = reflect(-o.worldViewDir, o.worldNormal);TRANSFER_SHADOW(o);return o;}fixed4 frag(v2f i): SV_Target{fixed3 worldNormal = normalize(i.worldNormal);fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));fixed3 worldViewDir = normalize(i.worldViewDir);fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;fixed3 diffuse = _LightColor0.rgb * _Color.rgb * max(0, dot(worldNormal, worldLightDir));// Use the reflect dir in world space to access the Cubemapfixed3 reflection = texCUBE(_Cubemap, i.worldRefl).rgb * _ReflectColor.rgb;UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);// Mix the diffuse color with the reflected Colorfixed3 color = ambient + lerp(diffuse, reflection, _ReflectAmount) * atten;return fixed4(color, 1.0);}ENDCG}}FallBack "Reflective/VertexLit"}

效果:

我们看到茶壶反射的金属效果:

代码解读:

1)worldViewDir 是从物体表面的某一点指向“摄像机(你的眼睛)”的方向。换句话说,它是“你从哪个方向看这个点”的方向向量

2)反射核心逻辑

o.worldRefl = reflect(-viewDir, normal);

如果光线从你的眼睛打到物体表面,它会往哪个方向反射出去。这个方向,就是我们去查立方体纹理的方向。

texCUBE(_Cubemap, i.worldRefl)

拿着算好的反射方向i.worldRefl,去_Cubemap这个6面贴图里查一下,这个方向上看到的是什么颜色。查出来的颜色,就是“反射出来的环境颜色”。

fixed3 reflection = texCUBE(_Cubemap, i.worldRefl).rgb * _ReflectColor; 

然后再乘以_ReflectColor,可以调节反射的亮度或色调。

lerp(diffuse, reflection, _ReflectAmount)

lerp是插值函数,根据_ReflectAmount的值,决定是多看点物体本身,还是多看点反射。

最后再乘上光照衰减atten(比如阴影、距离衰减),加上环境光,得到最终颜色。

总结这个Shader做了什么:

步骤做了什么
1获取物体表面的法线和视线方向
2计算出“反射方向”
3用反射方向去“立方体纹理”里查环境颜色
4把物体本身的颜色和反射的颜色混合在一起
5根据你调节的滑块(_ReflectAmount)控制反射强弱

最终效果:一个看起来会反射周围环境的金属物体。

(5)折射

斯涅尔定律计算反射角:

η1sinθ1=η2sinθ2

通常来说,当得到折射方向后我们就会直接使用它来对立方体纹理进行采样,但这是不符合物理规律的。对一个透明物体来说,一种更准确的模拟方法需要计算两次折射—— 一次是当光线进入它的内部时,而另一次则是从它内部射出时。但是,想要在实时渲染中模拟出第二次折射方向是比较复杂的,而且仅仅模拟一次得到的效果从视觉上看起来“也挺像那么回事的”。因此,在实时渲染中我们通常仅模拟第一次折射。

示例代码:

Shader "Unity Shaders Book/Chapter 10/Refraction" {Properties {_Color ("Color Tint", Color) = (1, 1, 1, 1)_RefractColor ("Refraction Color", Color) = (1, 1, 1, 1)_RefractAmount ("Refraction Amount", Range(0, 1)) = 1_RefractRatio ("Refraction Ratio", Range(0.1, 1)) = 0.5_Cubemap ("Refraction Cubemap", Cube) = "_Skybox" {}}SubShader {Tags { "RenderType"="Opaque" "Queue"="Geometry"}Pass { Tags { "LightMode"="ForwardBase" }CGPROGRAM#pragma multi_compile_fwdbase	#pragma vertex vert#pragma fragment frag#include "Lighting.cginc"#include "AutoLight.cginc"fixed4 _Color;fixed4 _RefractColor;float _RefractAmount;fixed _RefractRatio;samplerCUBE _Cubemap;struct a2v {float4 vertex : POSITION;float3 normal : NORMAL;};struct v2f {float4 pos : SV_POSITION;float3 worldPos : TEXCOORD0;fixed3 worldNormal : TEXCOORD1;fixed3 worldViewDir : TEXCOORD2;fixed3 worldRefr : TEXCOORD3;SHADOW_COORDS(4)};v2f vert(a2v v) {v2f o;o.pos = mul(UNITY_MATRIX_MVP, v.vertex);o.worldNormal = UnityObjectToWorldNormal(v.normal);o.worldPos = mul(_Object2World, v.vertex).xyz;o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);// Compute the refract dir in world spaceo.worldRefr = refract(-normalize(o.worldViewDir), normalize(o.worldNormal), _RefractRatio);TRANSFER_SHADOW(o);return o;}fixed4 frag(v2f i) : SV_Target {fixed3 worldNormal = normalize(i.worldNormal);fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));fixed3 worldViewDir = normalize(i.worldViewDir);fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;fixed3 diffuse = _LightColor0.rgb * _Color.rgb * max(0, dot(worldNormal, worldLightDir));// Use the refract dir in world space to access the cubemapfixed3 refraction = texCUBE(_Cubemap, i.worldRefr).rgb * _RefractColor.rgb;UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);// Mix the diffuse color with the refract colorfixed3 color = ambient + lerp(diffuse, refraction, _RefractAmount) * atten;return fixed4(color, 1.0);}ENDCG}} FallBack "Reflective/VertexLit"
}

效果:

代码解读:

这个Shader让一个物体看起来像玻璃、水晶、水一样,光线穿过它时会发生弯曲,这就是折射。

反射和折射的区别:

反射(Reflection)折射(Refraction)
模拟“镜面反射”——光被物体弹回来模拟“透过去”——光穿过物体并弯曲
像金属、镜子像玻璃、水、水晶
用 reflect() 函数用 refract() 函数
Properties {_Color ("Color Tint", Color) = (1, 1, 1, 1)_RefractColor ("Refraction Color", Color) = (1, 1, 1, 1)_RefractAmount ("Refraction Amount", Range(0, 1)) = 1_RefractRatio ("Refraction Ratio", Range(0.1, 1)) = 0.5_Cubemap ("Refraction Cubemap", Cube) = "_Skybox" {}
}
参数意思
_Color物体本身的颜色(比如淡蓝色玻璃)
_RefractColor折射颜色的强度或色调(可以调亮或加滤镜)
_RefractAmount折射的“强度”:0=完全不折射(像普通物体),1=完全显示折射效果
_RefractRatio折射率,控制光线“弯多少”(后面细讲)
_Cubemap用来模拟“物体后面的世界”——就是背景环境的6面贴图
o.worldRefr = refract(-normalize(o.worldViewDir), normalize(o.worldNormal), _RefractRatio);

refract(入射方向, 法线, 折射率),这个函数会计算:如果光线从你的眼睛射向物体,穿过它之后,会往哪个方向走。这个穿过去的方向,就是我们用来查立方体纹理的方向。

它的第一个参数即为入射光线的方向,它必须是归一化后的矢量;第二个参数是表面法线,法线方向同样需要是归一化后的;第三个参数是入射光线所在介质的折射率和折射光线所在介质的折射率之间的比值,例如如果光是从空气射到玻璃表面,那么这个参数应该是空气的折射率和玻璃的折射率之间的比值,即1/1.5。它的返回值就是计算而得的折射方向,它的模则等于入射光线的模。

fixed3 refraction = texCUBE(_Cubemap, i.worldRefr).rgb * _RefractColor.rgb;

这行代码的意思是:拿着刚刚算好的“穿过去的方向”,去_Cubemap(环境贴图)里查一下,这个方向上看到的是什么颜色。查出来的颜色,就是透过这个玻璃看到的背景。

总结这个Shader做了什么:

步骤做了什么
1获取视线方向和表面朝向
2用物理公式 refract() 算出光线“穿过物体后的方向”
3用这个方向去“立方体纹理”里查背景颜色
4把物体本身的颜色和“透过它看到的颜色”混合
5通过滑块控制折射强弱和弯曲程度

最终效果:一个看起来像玻璃或水一样透明、能让背景看起来弯曲的物体。

(6)菲涅耳反射

菲涅耳反射描述了一种光学现象,即当光线照射到物体表面上时,一部分发生反射,一部分进入物体内部,发生折射或散射。

菲涅耳效果:当你站在湖边,直接低头看脚边的水面时,你会发现水几乎是透明的,你可以直接看到水底的小鱼和石子;但是,当你抬头看远处的水面时,会发现几乎看不到水下的情景,而只能看到水面反射的环境。

在实时渲染中,我们通常会使用近似公式来计算,

Fschlick(v, n)=F0+(1-F0)(1-v·n)5

其中,F0是一个反射系数,用于控制菲涅耳反射的强度,v是视角方向,n是表面法线。

使用上面的菲涅耳近似等式,我们可以在边界处模拟反射光强和折射光强/漫反射光强之间的变化。在许多车漆、水面等材质的渲染中,我们会经常使用菲涅耳反射来模拟更加真实的反射效果。

示例代码:

// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'Shader "Custom/Fresnel"
{Properties{_Color("Color Tint", Color) = (1,1,1,1)_FresnelScale("Fresnel Scale", Range(0,1)) = 0.5_Cubemap("Reflection Cubemap", Cube) = "_Skybox"{}}SubShader{Tags{"RenderType"="Opaque"  "Queue"="Geometry"}Pass{Tags{"LightMode" ="ForwardBase"}CGPROGRAM#pragma multi_compile_fwdbase#pragma vertex vert #pragma fragment frag#include "Lighting.cginc"#include "AutoLight.cginc"fixed4 _Color;fixed _FresnelScale;samplerCUBE _Cubemap;struct a2v{float4 vertex: POSITION;float3 normal: NORMAL;};struct v2f{float4 pos: SV_POSITION;float3 worldPos: TEXCOORD0;fixed3 worldNormal: TEXCOORD1;fixed3 worldViewDir: TEXCOORD2;fixed3 worldRefl: TEXCOORD3;SHADOW_COORDS(4)};v2f vert(a2v v){v2f o;o.pos = UnityObjectToClipPos(v.vertex);o.worldNormal = UnityObjectToWorldNormal(v.normal);o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);o.worldRefl = reflect(-o.worldViewDir, o.worldNormal);TRANSFER_SHADOW(o);return o;}fixed4 frag(v2f i): SV_Target{fixed3 worldNormal = normalize(i.worldNormal);fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));fixed3 worldViewDir = normalize(i.worldViewDir);fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);fixed3 reflection = texCUBE(_Cubemap, i.worldRefl).rgb;fixed fresnel = _FresnelScale + (1 - _FresnelScale) * pow(1- dot(worldViewDir, worldNormal), 5);fixed3 diffuse = _LightColor0.rgb * _Color.rgb * max(0, dot(worldNormal, worldLightDir));fixed3 color = ambient + lerp(diffuse, reflection, saturate(fresnel)) * atten;return fixed4(color, 1.0);}ENDCG}}FallBack "Reflective/VertexLit"
}

效果:

代码解读:

这个Shader是干什么的:它实现了一个非常真实、漂亮的视觉效果。让物体在边缘看起来有“金属光泽”或“镜面反光”,中间则显示原本的颜色。

fixed fresnel = _FresnelScale + (1 - _FresnelScale) * pow(1 - dot(worldViewDir, worldNormal), 5);
  • dot(worldViewDir, worldNormal):计算“你的眼睛看向表面的角度”

    • 正对着看 → 值接近 1
    • 斜着看 → 值接近 0
  • 1 - dot(...):反过来,斜着看时值更大

  • pow(..., 5):让变化更剧烈(边缘突然变亮)

  • _FresnelScale:控制整体反光强度

结果:

  • 中心区域:fresnel 很小 → 反射弱 → 显示物体本身颜色
  • 边缘区域:fresnel 接近 1 → 反射强 → 显示环境反射

就像玻璃或水珠的边缘闪闪发光!

saturate(fresnel):确保值在 0~1 之间

总结这个Shader干了什么:

步骤做了什么
1获取每个点的法线和视线方向
2计算“菲涅尔系数”:边缘反光强,中间反光弱
3查环境贴图,得到反射颜色
4根据菲涅尔系数,平滑混合“本体颜色”和“反射颜色”
5输出最终颜色

效果:一个中间是实体颜色、边缘闪闪发光像镜子一样的物体。

2、渲染纹理

(1)概念

1)渲染纹理(Render Texture)

把摄像机拍到的画面,先不直接显示在屏幕上,而是先存到一张“虚拟的贴图”里,之后我们可以对这张贴图做各种特效处理,最后再显示出来。

这就像是拍电影:

  • 摄像机先录下来画面(→ 存到“渲染纹理”)
  • 后期加特效(比如模糊、发光、扭曲)
  • 最后再播放给观众看

2)渲染目标纹理(Render Target Texture)

不让摄像机直接把画面显示在屏幕上,而是先存到一张“贴图”里,这种贴图就叫渲染目标纹理。

3)渲染纹理 vs 普通纹理

普通纹理渲染纹理(Render Texture)
是一张固定的图片(比如木头贴图)是一张“动态生成”的图片
内容不会变内容每帧都在更新(比如摄像机视角)
可以拖到材质球上用也可以拖到材质球上用 

所以可以把渲染纹理当成一张会动的贴图来用。

4)多重渲染目标(MRT)

通常一个Pass只能输出一种颜色(比如最终颜色),但有了MRT,可以让一个Pass同时输出多个信息。

输出目标存什么
RT0物体颜色
RT1法线方向
RT2深度信息
RT3金属度、光滑度

应用:延迟渲染(Deferred Rendering)

先把这些信息全部画出来(存到不同纹理),然后再统一算光照。

好处:可以高效处理多个光源

5)总结

渲染纹理=把摄像机画面存成一张会动的贴图,然后你想怎么用就怎么用。

它就像是Unity里的“录屏+后期合成”系统。

(2)镜子效果实战

1)Shader代码

先上Shader的代码:

Shader "Custom/Mirror"
{Properties {_MainTex ("Main Tex", 2D) = "white" {}}SubShader {Tags { "RenderType"="Opaque" "Queue"="Geometry"}Pass {CGPROGRAM#pragma vertex vert#pragma fragment fragsampler2D _MainTex;struct a2v {float4 vertex : POSITION;float3 texcoord : TEXCOORD0;};struct v2f {float4 pos : SV_POSITION;float2 uv : TEXCOORD0;};v2f vert(a2v v) {v2f o;o.pos = UnityObjectToClipPos(v.vertex);o.uv = v.texcoord;// Mirror needs to filp xo.uv.x = 1 - o.uv.x;return o;}fixed4 frag(v2f i) : SV_Target {return tex2D(_MainTex, i.uv);}ENDCG}} FallBack Off
}

代码解读:

这个Shader是干什么的?它实现了一个镜子效果。比如你在游戏里看到一面镜子,能照出你角色的背影。但这块Shader本身不会“照出场景”,它只是把一张图片贴在物体上,并且左右翻转一下,看起来像镜子。真正的“照出画面”需要配合 渲染纹理(Render Texture)来实现。

_MainTex ("Main Tex", 2D) = "white" {}

_MainTex:就是一张纹理图片,比如“镜子的材质图”,后面可以把一张“监控画面”或“摄像机视角”贴图托给它。

struct a2v {float4 vertex : POSITION;float3 texcoord : TEXCOORD0;
};

表示每个顶点有2个信息:

1是vertex:这个点在模型上的位置

2是texcoord:这个点对应的“贴图坐标”(UV坐标,值是0到1),用来知道贴图的哪一部分贴在这里。UV就像给一个盒子贴包装纸时的“展开图”。

o.uv.x = 1 - o.uv.x;

它把贴图的X方向翻转了。让贴图左右颠倒,模拟镜面反射效果。

假设贴图是一张人脸,右脸有颗痣:

位置原始 UV翻转后 UV显示效果
左脸uv.x = 0.31 - 0.3 = 0.7显示在右边
右脸(有痣)uv.x = 0.71 - 0.7 = 0.3显示在左边

结果:痣从右边跑到了左边 → 看起来就像照镜子!

fixed4 frag(v2f i) : SV_Target {return tex2D(_MainTex, i.uv);
}

根据翻转后的UV坐标,去_MainTex这张图里查颜色,然后显示出来。

FallBack Off

如果不支持这个Shader,也不要找替代方案,直接关闭渲染。

总结:

步骤做了什么
1接收一张贴图(比如摄像机画面)
2把这张贴图的 UV 坐标左右翻转
3把翻转后的贴图画在物体表面

2)完整操作步骤

第1步,创建一个场景,并且去掉默认的天空盒子。

Window -> Rendering -> Lighting:

第2步,创建一个新材质命名为MirrorMat

第3步,创建一个新Shader命名为Mirror,并且赋给第2步中的材质

第4步,创建6个立方体,并调整它们的位置和大小,使得它们构成围绕着摄像机的房间的6面墙。

同时在房间里面在额外放3个点光源,避免封闭的房间过暗。

MainCamera刚好卡在一面墙中间。

第5步,找一个材质赋给6面墙:

第6步,创建2个球体和1个正方体,调整它们的位置和大小,并且赋给材质:

第7步,创建一个四边形(Quad),重命名为Mirror,调账它的位置和大小,它将作为镜子。

第8步,在Project视图下创建一个渲染纹理(右击Create -> Render Texture),设置如下:

第9步,为了得到从镜子出发观察到的场景图像,我们还需要创建一个摄像机,并调整它的位置、裁剪平面、视角等,使得它的显示图像是我们希望的镜子图像。由于这个摄像机不需要直接显示在屏幕上,而是用于渲染到纹理。

第10步,把第8步创建的渲染纹理赋值给第2步创建的材质。

此时就可以看到镜面效果:

(3)玻璃效果实战

我们可以在Unity Shader中使用一种特殊的Pass来完成获取屏幕图像的目的,这就是GrabPass。

当我们在Shader中定义了一个GrabPass后,Unity会把当前屏幕的图像绘制在一张纹理中,以便我们在后续的Pass中访问它。我们通常会使用GrabPass来实现诸如玻璃灯透明材质的模拟,与使用简单的透明混合不同,使用GrabPass可以让我们对该物体后面的图像进行更复杂的处理,例如使用法线来模拟折射效果,而不再是简单和原屏幕颜色进行混合。

白话解读:

我们向做一个玻璃效果,看起来像真的一样:能反射周围的光,又能折射后面的景物。但普通的透明材质(比如半透明面板)只能做到“模糊看到后面”,而做不到扭曲或反光。所以我们用GrabPass来做出更真实地玻璃效果。

GrabPass就是Unity地快拍按钮,它会在玻璃被画出来之前,先拍一张当前屏幕的照片(也就是玻璃后面的所有东西的画面),然后把这张照片存到一个纹理里。

为什么需要这张照片?

因为真正的玻璃不是平平淡淡地投过去,他会:

反射:能在玻璃上看到灯光、天空的倒影

折射:光线穿过玻璃时会弯曲,导致后面的物体看起来扭曲、偏移

我们要用这张照片来做两件事:

模拟反射(像镜子一样照出环境)

模拟折射(让后面的画面看起来扭曲)

怎么做折射?————用法线贴图帮忙

我们有一张玻璃后面的照片(来自GrabPass),但现在它是平的。怎么让它看起来像被玻璃扭曲了呢?

  • 读取法线贴图,知道“哪里凸、哪里凹”
  • 根据这些凹凸,稍微移动照片的采样位,比如原本该看(0.5, 0.5)的点,现在去看(0.52, 0.48)

结果:后面的图像看起来波浪形扭曲,就像真的透过玻璃看世界!

怎么做反射?———— 用Cubemap模拟环境

Cubemap:立方体贴图

它就像一个360度的环境球,记录了周围有哪些光、颜色。

Shader会计算:

  • 哪些反射光能照射到玻璃上
  • 然后从Cubemap里查出来对应的颜色

结果:玻璃上有反光,闪闪反光。

为什么要把渲染队列设成“透明”

Tags { "Queue" = "Transparent" }

虽然GrabPass本身没有透明代码,但它必须等所有不透明的东西先画完,才能拍照。意思是:我是透明物体,请把我放在最后一批绘制。

实战Shader代码:

// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'Shader "Unity Shaders Book/Chapter 10/Glass Refraction" {Properties {_MainTex ("Main Tex", 2D) = "white" {}_BumpMap ("Normal Map", 2D) = "bump" {}_Cubemap ("Environment Cubemap", Cube) = "_Skybox" {}_Distortion ("Distortion", Range(0, 100)) = 10_RefractAmount ("Refract Amount", Range(0.0, 1.0)) = 1.0}SubShader {// We must be transparent, so other objects are drawn before this one.Tags { "Queue"="Transparent" "RenderType"="Opaque" }// This pass grabs the screen behind the object into a texture.// We can access the result in the next pass as _RefractionTexGrabPass { "_RefractionTex" }Pass {		CGPROGRAM#pragma vertex vert#pragma fragment frag#include "UnityCG.cginc"sampler2D _MainTex;float4 _MainTex_ST;sampler2D _BumpMap;float4 _BumpMap_ST;samplerCUBE _Cubemap;float _Distortion;fixed _RefractAmount;sampler2D _RefractionTex;float4 _RefractionTex_TexelSize;struct a2v {float4 vertex : POSITION;float3 normal : NORMAL;float4 tangent : TANGENT; float2 texcoord: TEXCOORD0;};struct v2f {float4 pos : SV_POSITION;float4 scrPos : TEXCOORD0;float4 uv : TEXCOORD1;float4 TtoW0 : TEXCOORD2;  float4 TtoW1 : TEXCOORD3;  float4 TtoW2 : TEXCOORD4; };v2f vert (a2v v) {v2f o;o.pos = UnityObjectToClipPos(v.vertex);o.scrPos = ComputeGrabScreenPos(o.pos);o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpMap);float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;  fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);  fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);  fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);  o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);  o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);  return o;}fixed4 frag (v2f i) : SV_Target {		float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));// Get the normal in tangent spacefixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));	// Compute the offset in tangent spacefloat2 offset = bump.xy * _Distortion * _RefractionTex_TexelSize.xy;i.scrPos.xy = offset * i.scrPos.z + i.scrPos.xy;fixed3 refrCol = tex2D(_RefractionTex, i.scrPos.xy/i.scrPos.w).rgb;// Convert the normal to world spacebump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));fixed3 reflDir = reflect(-worldViewDir, bump);fixed4 texColor = tex2D(_MainTex, i.uv.xy);fixed3 reflCol = texCUBE(_Cubemap, reflDir).rgb * texColor.rgb;fixed3 finalColor = reflCol * (1 - _RefractAmount) + refrCol * _RefractAmount;return fixed4(finalColor, 1);}ENDCG}}FallBack "Diffuse"
}

代码解读:

1)属性

参数意思
_MainTex主贴图,比如玻璃的颜色或花纹
_BumpMap法线贴图,让玻璃表面有凹凸感(比如毛玻璃)
_Cubemap环境贴图,用来模拟反光(比如天空、灯光)
_Distortion扭曲程度,值越大,后面的东西看起来越“波浪形”
_RefractAmount控制“折射 vs 反射”的比例:0=全是反光,1=全看后面

2)SubShader和Pass

  • Tags { "Queue"="Transparent" }:指定渲染队列为透明,确保所有不透明物体先被绘制,这样才能正确获取屏幕后面的内容。
  • GrabPass { "_RefractionTex" }:这是一个特殊的Pass,它会抓取当前屏幕内容并保存到名为_RefractionTex的纹理中,这样我们就可以在后续处理中使用这张“照片”。
	sampler2D _MainTex;float4 _MainTex_ST;

sampler2D _MainTex:表示这是一张2D图片

_MainTex_ST:固定写法,2D图片名称+ST,ST是缩放和平移(Scale&Translate),用于UV坐标转换

			sampler2D _RefractionTex;float4 _RefractionTex_TexelSize;

sampler2D _RefractionTex:就是刚才拍的后面画面

float4 _RefractionTex_TexelSize:每个像素的大小(比如1/1920,1/1080),用于精确偏移。

_MyTex_TexelSize是固定写法,表示这张纹理的每个像素的大小。

Texel= Texture Pixel(纹理像素)。

_TexelSize是一个float4类型的变量,它的值是:

_TexelSize = float4(1.0 / width, 1.0 / height, width, height)

分量含义
.x1.0 / 纹理宽度 → 每个像素有多宽
.y1.0 / 纹理高度 → 每个像素有多高
.z纹理原始宽度(可选)
.w纹理原始高度(可选)

为什么需要_TexelSize?

你想在一张地图上移动 1 厘米。

但地图比例尺是:1 厘米 = 1 公里。

如果你直接写“移动 1”,那在游戏里就是“移动 1 个世界单位”(可能是 1 米),完全不对!

所以你要:实际移动 = 想移动的像素数 × 每个像素对应的 UV 大小

而这个“每个像素对应的 UV 大小”就是 _TexelSize.xy

			struct a2v {float4 vertex : POSITION;float3 normal : NORMAL;float4 tangent : TANGENT; float2 texcoord: TEXCOORD0;};

vertex:顶点位置

normal:法线方向

tangent:切线方向

texcoord:UV坐标

			struct v2f {float4 pos : SV_POSITION;float4 scrPos : TEXCOORD0;float4 uv : TEXCOORD1;float4 TtoW0 : TEXCOORD2;  float4 TtoW1 : TEXCOORD3;  float4 TtoW2 : TEXCOORD4; };

定义“顶点输出 -> 像素输入结构”,v2f=vertex to fragment

pos:顶点在屏幕上的位置

scrPos:抓屏坐标(用于采样_RefractionTex)

uv:主贴图和法线贴图的UV

TtoW0~2:切线空间到世界空间的变换矩阵(用于法线计算)

		o.scrPos = ComputeGrabScreenPos(o.pos);

因为屏幕有 透视变形、拉伸等问题,ComputeGrabScreenPos 帮助修正这些问题,得到正确的采样坐标。就像拍照后校正镜头畸变。

				o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpMap);

处理UV坐标(贴图位置)

TRANSFORM_TEX:应用材质上的“缩放”和“平移”设置

uv.xy:主贴图用的UV

uv.zw:法线贴图的UV

fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; 

worldBinormal:副法线,通过叉积计算,v.tangent.w控制方向

【副法线概念】

它是和法线、切线垂直的第三个方向,和前两个一起构成坐标系,用来把法线贴图里的凹凸信息正确地摆正。

而v.tangent.w是一个小开关,用来纠正方向,放置凹凸贴图看起来反了或扭曲了。

为什么需要这3个方向

想象你要在墙上贴一张立体浮雕壁纸(比如有山有树地凹凸图案),但墙是歪地,壁纸也可能是斜着贴地。你得告诉Unity:

哪边是上?-> 法线(Normal)

哪边是右? -> 切线(Tangent)

哪边是往你脸上凸? -> 副法线(Binormal)

这三个方向合起来,就是一个局部坐标系,叫:切线空间(Tangent Space)。

目的:让法线贴图里的凹凸能正确地贴在模型表面上。

为什么要用v.tangent.w来纠正方向

叉积的结果有两个方向

正方向(比如从屏幕往外凸)

负方向(比如往屏幕里面凹)

但有时候Unity自动计算的切线方向是反的(尤其在一些复杂的模型上,比如手、脸、翻转的面),这时候如果不纠正,法线贴图就会看起来像是往里凹,而不是往外凸。

v.tangent.w是模型中每个顶点自带的小标记,通常值是+1或-1,它的作用是告诉Unity,这次叉积的结果要不要反过来。

总结:

概念作用
副法线(Binormal)和法线、切线一起构成“局部坐标系”,让法线贴图能正确应用
叉积 cross(N, T)数学方法算出第三个垂直方向
v.tangent.w“方向开关”,防止某些面的凹凸贴图翻转错误
fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));	

tex2D(_BumpMap, ...):采样法线贴图

UnpackNormal:把贴图里的颜色(0~1)转成真正的法线向量(-1 ~ 1)

float2 offset = bump.xy * _Distortion * _RefractionTex_TexelSize.xy;

计算扭曲偏移量。

bump.xy:法线贴图的X和Y方向,代表表面倾斜

_Distortion:控制扭曲强度

_RefractionTex_TexelSize.xy:每个像素的实际大小(避免偏移太大)

结果:表面越斜,后面画面偏移越多。

i.scrPos.xy = offset * i.scrPos.z + i.scrPos.xy;

把原始抓屏坐标srcPos偏移一下 

i.srcPos.z:深度值(越远越大),用来模拟“近处扭曲强,远处弱”

加上offset:让采样位置发生偏移

模拟光线穿过玻璃时的弯曲(折射)。

fixed3 refrCol = tex2D(_RefractionTex, i.scrPos.xy/i.scrPos.w).rgb;

从扭曲后的坐标采样_RefractionTex,即后面画面,得到透过玻璃看到的颜色。

为什么要除以i.srcPos.w

i.srcPos.w时透视投影的深度信息,除以它是为了做透视校正,让扭曲效果在远近物体上都能正确显示。

o.srcPos = ComputeGrabScreenPos(o.pos);

ComputeGrabScreenPos是Unity提供的一个函数,它的作用是:计算这个点在屏幕上应该从哪里去抓画面。它返回的是一个齐次坐标,有4个分量:.xyzw。

.xy:初步的屏幕位置(还没校正)

.z:深度(可用来控制扭曲强度)

.w:透视因子

透视效果:越远的东西,在屏幕上占的像素越小。如果不校正w,就会出现一个问题:Unity认为所有像素是均匀分布的,但实际上不是。

假设你有一个玻璃板,离你近的一边和远的一边都被扭曲。

不校正(直接用 .xy校正后(.xy / .w
远处扭曲太强(像素挤在一起,偏移过大)远处扭曲适中(符合视觉)
近处扭曲太弱近处扭曲正常
画面看起来“拉皮”、“撕裂”画面自然、真实

举个例子:

一个点非常远 -> i.srcPos.w=10.0

i.srcPos.xy = (500, 300)

如果不除以w,就去采样(500, 300)这个像素。但实际上,这个点在屏幕上只占一个很小的像素区域,应该采样(50, 30)左右。除以w后:500/10=50,300/10=30,正确。

总结:

项目说明
i.scrPos.xy初步的屏幕坐标(未校正)
i.scrPos.w透视深度因子(越大表示越远)
i.scrPos.xy / i.scrPos.w透视校正后的正确屏幕 UV
为什么必须除?否则远近物体扭曲不一致,画面拉伸、错乱

记住:如果使用GrabPass或ScreenPos时,只要你要采样屏幕纹理,就必须写:

tex2D(tex, scrPos.xy / scrPos.w)

	bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));

把法线从切线空间转到世界空间。

该Shader总结:

  1. 用 GrabPass 拍下屏幕后面的画面 → 模拟“透过玻璃看到的景象”
  2. 用法线贴图扭曲这个画面 → 模拟折射(光线弯曲)
  3. 用立方体贴图加反光 → 模拟反射(玻璃反光)
  4. 混合两者 → 控制玻璃是“更透明”还是“更反光”

(4)渲染纹理 vs GrabPass

想拍下屏幕画面来做特效(比如玻璃、镜子),有两种方法:

GrabPass:在Shader里写一行代码,自动拍照

渲染纹理+额外摄像机:自己搭一套“监控系统”来拍

它们都能达到类似效果,但一个简单,一个高效。

1)GrabPass

优点:

  • 只要在Shader里加依据GrabPass{"_MyTex"}
  • Unity自动拍下当前屏幕
  • 然后就可以在Shader里用这张图片做扭曲、折射等效果

缺点:

  • 分辨率太高,太占资源: 它拍的是整个屏幕的真实分辨率。在手机上,就像用高清相机一直录像,非常吃带宽(数据传输量大)
  • 拖慢CPU和GPU:正常情况下,CPU和GPU是并行工作的。但是GrabPass要求:GPU必须先把画面画完,CPU才能去拿照片。
  • 有些手机根本不支持

2)渲染纹理+额外摄像机

优点:

  • 可以控制拍多大:可以设想监控摄像头只输出512*512,而不是全屏1080p
  • 可以控制拍什么:只拍人物和桌子,不拍天花板和地板
  • 不打断CPU和GPU:摄像机提前把画面渲染到一张小图上,Shader直接用这张图,不需要等待,CPU和GPU可以并行工作
  • 兼容性好,几乎所有设备都支持

缺点:(麻烦)

  • 你要手动创建:
    • 一个“渲染纹理”(Render Texture)→ 相当于“照片存储卡”
    • 一个“额外摄像机” → 相当于“监控头”
  • 把摄像机的“输出”指向那张纹理
  • 再把这张纹理传给 Shader
  • 如果场景变了,还得调参数……

(5)程序纹理

程序纹理=用代码“现场画图”

它不像普通贴图那样是死的图片,而是活的、能变得、可动画得材质。

虽然做起来稍微难一点,但自由度高、省内存、效果酷!

实战:

创建一个材质,再创建一个Shader赋给刚才的材质,

创建一个Cube,创建一个脚本赋给Cube。

脚本代码如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;[ExecuteInEditMode]
public class ProceduralTextureGenerationHugh : MonoBehaviour
{public Material material = null;#region Material properties[SerializeField]private int m_textureWidth = 512;public int textureWidth{get{return m_textureWidth;}set{m_textureWidth = value;_UpdateMaterial();}}[SerializeField]private Color m_backgroundColor = Color.white;public Color backgroundColor{get{return m_backgroundColor;}set{m_backgroundColor = value;_UpdateMaterial();}}[SerializeField]private Color m_circleColor = Color.yellow;public Color circleColor{get{return m_circleColor;}set{m_circleColor = value;_UpdateMaterial();}}[SerializeField]private float m_blurFactor = 2.0f;public float blurFactor{get{return m_blurFactor;}set{m_blurFactor = value;_UpdateMaterial();}}#endregionprivate Texture2D m_generatedTexture = null;#if UNITY_EDITORprivate void OnValidate(){// 只在 material 为 null 时初始化if (material == null){Renderer renderer = GetComponent<Renderer>();if (renderer != null){material = new Material(renderer.sharedMaterial);renderer.material = material;}}// 在编辑器模式下实时更新纹理if (!Application.isPlaying && material != null){_UpdateMaterial();}}#endif// Use this for initializationvoid Start(){if (material == null){Renderer renderer = gameObject.GetComponent<Renderer>();if (renderer == null){Debug.LogWarning("Cannot find a renderer.");return;}material = new Material(renderer.sharedMaterial);renderer.material = material;}_UpdateMaterial();}private void _UpdateMaterial(){if (material != null){m_generatedTexture = _GenerateProceduralTexture();material.SetTexture("_MainTex", m_generatedTexture);}}private Color _MixColor(Color color0, Color color1, float mixFactor){Color mixColor = Color.white;mixColor.r = Mathf.Lerp(color0.r, color1.r, mixFactor);mixColor.g = Mathf.Lerp(color0.g, color1.g, mixFactor);mixColor.b = Mathf.Lerp(color0.b, color1.b, mixFactor);mixColor.a = Mathf.Lerp(color0.a, color1.a, mixFactor);return mixColor;}private Texture2D _GenerateProceduralTexture(){Texture2D proceduralTexture = new Texture2D(textureWidth, textureWidth);// The interval between circlesfloat circleInterval = textureWidth / 4.0f;// The radius of circlesfloat radius = textureWidth / 10.0f;// The blur factorfloat edgeBlur = 1.0f / blurFactor;for (int w = 0; w < textureWidth; w++){for (int h = 0; h < textureWidth; h++){// Initalize the pixel with background colorColor pixel = backgroundColor;// Draw nine circles one by onefor (int i = 0; i < 3; i++){for (int j = 0; j < 3; j++){// Compute the center of current circleVector2 circleCenter = new Vector2(circleInterval * (i + 1), circleInterval * (j + 1));// Compute the distance between the pixel and the centerfloat dist = Vector2.Distance(new Vector2(w, h), circleCenter) - radius;// Blur the edge of the circleColor color = _MixColor(circleColor, new Color(pixel.r, pixel.g, pixel.b, 0.0f), Mathf.SmoothStep(0f, 1.0f, dist * edgeBlur));// Mix the current color with the previous colorpixel = _MixColor(pixel, color, color.a);}}proceduralTexture.SetPixel(w, h, pixel);}}proceduralTexture.Apply();return proceduralTexture;}}

核心代码解读:

public Material material = null;

material:这是要操作的材质,Unity中的所有物体都需要材质来决定它们看起来的样子

[SerializeField]
private int m_textureWidth = 512;
public int textureWidth {get { return m_textureWidth; }set { m_textureWidth = value; _UpdateMaterial(); }
}

    textureWidth: 纹理的宽度(以像素为单位),默认是 512 像素宽。当你修改这个值时,会自动调用 _UpdateMaterial() 方法来更新材质。

    if (material == null)
    {Renderer renderer = gameObject.GetComponent<Renderer>();if (renderer == null){Debug.LogWarning("Cannot find a renderer.");return;}material = new Material(renderer.sharedMaterial);renderer.material = material;
    }_UpdateMaterial();
    • 如果没有指定材质,则尝试从当前游戏对象中获取渲染器组件,并使用其共享材质。
    • 最后调用 _UpdateMaterial() 来生成并应用纹理。

    效果:

    http://www.xdnf.cn/news/19506.html

    相关文章:

  • ing Data JPA 派生方法 数据操作速查表
  • 【WEB】[BUUCTF] <GXYCTF2019禁止套娃>《php函数的运用》
  • ADC platfrom day65
  • MVC架构模式
  • Blender建模:对于模型布线的一些思考
  • 介绍GSPO:一种革命性的语言模型强化学习算法
  • 现代C++性能陷阱:std::function的成本、异常处理的真实开销
  • Luma 视频生成 API 对接说明
  • AI 智能体汇总,自动执行任务的“真 Agent”
  • 查看所有装在c盘软件的方法
  • Trae接入自有Deepseek模型,不再排队等待
  • OpenStack 03:创建实例
  • 并发编程——11 并发容器(Map、List、Set)实战及其原理分析
  • Opencv的数据结构
  • wifi控制舵机
  • AI热点周报(8.24~8.30):Grok 2.5开源,OpenAI Realtime正式商用,Meta或与OpenAI或Google合作?
  • 从零开始的python学习——语句
  • python pyqt5开发DoIP上位机【自动化测试的逻辑是怎么实现的?】
  • lumerical_FDTD_光源_TFSF
  • 《中国棒垒球》垒球世界纪录多少米·垒球8号位
  • 第2.3节:AI大模型之Claude系列(Anthropic)
  • [特殊字符]️ STL 容器快速参考手册
  • LangChain实战(五):Document Loaders - 从多源加载数据
  • Python库2——Matplotlib2
  • JAVA EE初阶 4:文件操作和IO
  • PCIe 6.0 vs 5.0:带宽翻倍背后的技术革新与应用前景
  • 防护墙技术(一):NAT
  • 粒子群优化算法(PSO)
  • 从分子工具到技术革新:链霉亲和素 - 生物素系统与 M13 噬菌体展示的交叉应用解析
  • 项目管理方法适用场景对比