简介:Unity作为一款强大的跨平台3D引擎,广泛应用于游戏、VR/AR及真实感渲染开发。本Demo聚焦图形学中的核心概念——光线折射,通过实现Snell定律在Unity中的可视化效果,帮助开发者理解光在不同介质间传播时的方向变化。项目包含基于Shader(CG/HLSL)编写的自定义着色器,演示了从理论到实践的完整流程:创建着色器、应用折射定律、计算折射方向、材质集成与视觉优化。配合详细博客讲解,该Demo为学习者提供可运行的实例,提升对Unity图形编程和物理渲染的理解与应用能力。
光线折射与Unity着色器实战:从物理原理到视觉真实感的完整实现
在现代游戏和交互式应用中,我们常常被那些“看起来就很贵”的玻璃材质、水晶棱镜或水下场景所吸引。它们不仅仅是贴图漂亮,更关键的是—— 光线真的会“弯” 。这种看似微小的细节,实则是图形学中最迷人的物理模拟之一: 光的折射 。
但你有没有想过,当一束光穿过玻璃杯时,计算机到底是怎么知道它该往哪儿偏的?这背后既不是魔法,也不是随便调个参数糊弄过去,而是一套严谨的数学规则在驱动着每一个像素的颜色变化。今天,我们就来揭开这层神秘面纱,把从斯涅尔定律(Snell’s Law)到Unity中的 refract() 函数这条链路彻底打通,并亲手写一个可调节、能调试、还带边缘扰动的真实感折射Shader!
准备好了吗?Let’s dive in 🚀
当光说“我要拐个弯”,它是认真的
想象一下,你在阳光明媚的下午,拿着一杯冰水坐在窗边。透过杯子看外面的世界,你会发现景物好像被扭曲了,甚至有些地方出现了重影。这不是你的错觉,而是 光在玩“速度差”游戏 。
光在不同介质中跑得不一样快。空气中大约是每秒30万公里,在水中就慢到了约22.5万,在玻璃里更是降到20万左右。这个“减速”的过程,就导致了方向改变——也就是 折射 。
而描述这一现象的核心公式,早在1621年就被荷兰数学家威理博·司乃耳(Willebrord Snellius)总结了出来,这就是大名鼎鼎的 Snell定律 :
$$
n_1 \cdot \sin(\theta_1) = n_2 \cdot \sin(\theta_2)
$$
别被公式吓到 😅,其实它非常直观:
- $ n_1, n_2 $ 是两种介质的 折射率 (比如空气≈1.0,水≈1.33,普通玻璃≈1.5)
- $ \theta_1 $ 是入射角(光线和法线之间的夹角)
- $ \theta_2 $ 是折射角
简单来说: 光从低密度进高密度,会向法线“靠拢”;反过来,则会远离法线 。如果角度太大,还会发生全反射——就像光纤那样,光被困在里面反复弹跳前进 💡
这不仅是教科书里的知识,更是我们做透明材质渲染的 第一性原理 。
在GPU里“解方程”?不,让它直接算!
你可能会想:“那我在Shader里是不是要先求反正弦,再乘sin,最后反推角度?”
Nope ❌!这样不仅效率低,精度也容易出问题。
幸运的是,HLSL/CG语言早就为我们准备好了内置函数: refract(incident, normal, eta) ,它可以直接返回 折射后的方向向量 ,完全不用手动解三角函数!
来看它的原型:
float3 refract(float3 I, float3 N, float eta);
参数说明如下:
| 参数 | 含义 | 注意事项 |
|---|---|---|
I | 入射方向向量 | 必须指向表面(即从眼睛出发的方向取反) |
N | 表面法线 | 必须归一化,且方向影响结果符号 |
eta | 折射率比值 $ n_1 / n_2 $ | 例如空气→玻璃 → 1.0 / 1.5 ≈ 0.67 |
举个例子,假设摄像机看着一块玻璃立方体,我们要计算视线穿过去的路径:
float3 viewDir = normalize(worldPos - _WorldSpaceCameraPos); // 摄像机→像素
float3 refractedDir = refract(-viewDir, worldNormal, 1.0 / 1.5);
注意这里的 -viewDir —— 因为 refract 期望的是“入射光方向”,而我们的 viewDir 是从摄像机指向物体的,所以需要反转一次,才能代表“光进来”的方向 ✅
而且,如果发生了全反射(比如从玻璃内部看向空气,且角度太陡), refract 会返回一个零向量 (0,0,0) 。这时候我们可以优雅地 fallback 到反射:
if (!any(refractedDir)) {
refractedDir = reflect(-viewDir, worldNormal);
}
是不是很贴心?一行代码搞定物理边界判断 😎
Unity Shader到底是谁写的?我写的还是Unity写的?
很多人初学Shader的时候都有点懵:为什么同样的效果,有人用Surface Shader三行搞定,有人却要写一堆vert/frag函数?
其实啊,Unity提供了几种不同的抽象层级,你可以根据需求选择“偷懒”还是“硬核”。
Surface Shader:美术友好型“自动挡”
如果你只想快速做出一个标准光照模型的材质,比如漫反射+高光的那种,Surface Shader简直是救星:
Shader "Custom/GlassSurface"
{
Properties {
_Color ("Tint", Color) = (1,1,1,1)
_MainTex ("Texture", 2D) = "white" {}
_Glossiness ("Smoothness", Range(0,1)) = 0.5
}
SubShader {
Tags { "RenderType"="Transparent" }
CGPROGRAM
#pragma surface surf Standard alpha:fade
struct Input {
float2 uv_MainTex;
};
sampler2D _MainTex;
fixed4 _Color;
void surf (Input IN, inout SurfaceOutputStandard o) {
fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
o.Albedo = c.rgb;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}
你看,根本不需要写顶点着色器,也不用手动处理MVP矩阵,甚至连光照循环都帮你封装好了。Unity会在编译期自动生成底层的Vertex & Fragment Shader代码。
但代价是什么呢? 控制力下降 + 性能冗余 。
你想加个折射?抱歉,Surface Shader虽然支持 Opaque , Cutout , Fade , Transparent 四种模式,但它默认走的是标准光照管线,没法直接替换采样逻辑。除非你自己写Custom Lighting Model,否则很难灵活干预最终颜色输出。
Vertex/Fragment Shader:程序员专属“手动挡”
想要真正的自由?那就上手写V/F Shader吧!这才是实现高级效果的正道。
它的结构长这样:
Shader "Custom/RefractVF"
{
Properties {
_MainTex ("Base Texture", 2D) = "white" {}
_RefractiveIndex ("Refraction Index", Range(0.1, 3.0)) = 1.5
}
SubShader {
Pass {
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float2 uv : TEXCOORD2;
};
v2f vert(appdata v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.uv = v.uv;
return o;
}
fixed4 frag(v2f i) : SV_Target {
// TODO: 实现折射逻辑
return fixed4(1,0,0,1); // 临时红屏测试
}
ENDCG
}
}
}
这套模板你现在可以背下来了 🔥 它几乎是所有定制Shader的基础骨架。
-
appdata:告诉GPU“我需要哪些输入” -
v2f:定义“我要传什么数据给片元着色器” -
vert():完成坐标变换 -
frag():逐像素染色
而且最关键的是—— 你说了算 。无论是采样环境贴图、抓取屏幕纹理、还是叠加噪声扰动,统统都可以在这里实现。
渲染流水线:谁先谁后,不能乱!
为了真正理解上面那段代码在干什么,我们必须搞清楚GPU是怎么一步步把你写的Shader变成屏幕上的一帧画面的。
下面是典型的可编程渲染管线流程:
graph TD
A[应用程序阶段] --> B[顶点数据输入]
B --> C[顶点着色器 Vertex Shader]
C --> D[图元装配 Primitive Assembly]
D --> E[几何着色器 Geometry Shader (可选)]
E --> F[光栅化 Rasterization]
F --> G[片元着色器 Fragment Shader / Pixel Shader]
G --> H[逐片元操作: 深度测试、混合等]
H --> I[帧缓冲 Frame Buffer]
整个过程就像是工厂流水线:
-
顶点着色器 :处理每个顶点的位置变换、法线传递、UV映射等预计算工作。
👉 目标:把模型空间下的点,“搬”到裁剪空间去,让GPU知道它该出现在屏幕哪个位置。
-
光栅化 :把三角形网格切成一个个像素片段(fragments),并进行透视校正插值。
-
片元着色器 :对每个像素执行复杂的颜色计算,比如光照、纹理采样、折射等等。
👉 这里才是“魔法发生的地方”✨
-
后期处理 :深度测试决定是否遮挡、Alpha混合实现透明效果……
所以你看, UnityObjectToClipPos(v.vertex) 就是在顶点着色器里做的第一步空间转换,相当于告诉GPU:“这个点将来应该画在屏幕上的哪里”。
而 texCUBE(_EnvCube, T) 则是在片元着色器里查询环境信息,属于最后一步“上色”。
顺序错了,结果就崩了 ⚠️
数据怎么从CPU跑到GPU?变量传递机制揭秘
你以为Shader是孤立运行的?错啦!它必须和C#脚本配合,接收外部参数才能动态调整效果。
这就涉及到两个核心概念: Uniform变量 和 Varying变量 。
Uniform:全局只读常量,来自CPU
这些是你在 Properties 里声明的东西,比如:
Properties {
_Color ("Tint", Color) = (1,1,1,1)
_Intensity ("Brightness", Float) = 1.0
_MainTex ("Texture", 2D) = "white" {}
}
然后在CG块里重新声明同名变量:
fixed4 _Color;
float _Intensity;
sampler2D _MainTex;
接着就可以在C#脚本里动态修改:
public Material glassMat;
void Update() {
glassMat.SetColor("_Color", Color.Lerp(Color.white, Color.cyan, Mathf.PingPong(Time.time, 1)));
glassMat.SetFloat("_Intensity", Mathf.Sin(Time.time) * 0.5 + 1);
}
✅ 提示:设置前建议使用
new Material(original)创建实例,避免污染共享资源!
这类变量叫做 uniform ,因为它们在整个Draw Call期间保持不变,就像全局常量一样。常见的还有 _Time , _ScreenParams , _WorldSpaceCameraPos 等Unity内置变量。
Varying:顶点→像素的数据接力棒
还记得那个叫 v2f 的结构体吗?它是“varying variable”的缩写,意思是“变化的变量”。
它负责把顶点着色器的结果传递给片元着色器,并在中间经历 插值 过程。
比如你有两个顶点,一个UV是(0,0),另一个是(1,1),那么连接它们的边上所有像素的UV都会被线性插值得到。
struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
};
这里用了语义标签 TEXCOORD0~7 来标记通道,最多支持8个varying变量(具体取决于平台限制)。
⚠️ 注意:由于插值可能导致精度损失,尤其在大三角面上,建议在片元着色器中对关键向量(如法线、视线)再次 normalize() 。
动手写第一个能跑的Shader:光照+纹理都不落下
光说不练假把式,我们现在就来写一个完整的、可编译运行的Shader,包含基础光照和纹理采样。
目标:实现兰伯特漫反射 + 主光源 + 纹理着色。
Shader "Custom/LitWithTexture"
{
Properties {
_MainTex ("Texture", 2D) = "white" {}
_Color ("Tint", Color) = (1,1,1,1)
}
SubShader {
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc" // 获取_LightColor0等光照变量
struct appdata {
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};
struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
};
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Color;
v2f vert(appdata v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 处理tiling & offset
o.worldNormal = UnityObjectToWorldNormal(v.normal);
return o;
}
fixed4 frag(v2f i) : SV_Target {
float3 N = normalize(i.worldNormal);
float3 L = normalize(_WorldSpaceLightPos0.xyz); // 主光源方向
float ndotl = dot(N, L);
float diffuse = max(0, ndotl);
fixed4 baseCol = tex2D(_MainTex, i.uv) * _Color;
fixed4 litCol = baseCol * _LightColor0 * diffuse;
return litCol;
}
ENDCG
}
}
}
几点重点解释:
-
TRANSFORM_TEX(uv, name):自动应用材质面板上的Tiling和Offset。 -
_WorldSpaceLightPos0:Unity主方向光的世界空间方向(对于平行光是单位向量)。 -
_LightColor0:当前灯光颜色,来自光照系统。 -
dot(N,L):点积得到夹角余弦,用于兰伯特模型。
把这个Shader赋给材质,拖到场景物体上,就能看到有明暗变化的纹理渲染啦!
还可以临时输出法线看看是否正确:
return fixed4(normalize(i.worldNormal) * 0.5 + 0.5, 1);
RGB对应XYZ,青色表示Y向上,红色X右,绿色Z前,方便调试 👍
构建你的折射Shader:一步一步来
现在终于到了重头戏!我们要基于前面的知识,构建一个完整的、支持参数调节的折射着色器。
第一步:设计整体架构
我们需要以下组件:
- ✅ 输入:顶点位置、法线、UV
- ✅ 中间计算:世界坐标、视线方向、折射方向
- ✅ 输出:采样环境贴图作为背景
- ✅ 可调参数:折射率、边缘扰动强度
- ✅ 特效增强:法线扰动 + 全反射fallback
数据流如下:
graph TD
A[Mesh Data] --> B{Vertex Shader}
B --> C[对象空间→世界空间变换]
C --> D[法线归一化]
D --> E[Varying: worldPos, worldNormal, uv]
E --> F{Fragment Shader}
F --> G[计算视线向量 viewDir]
G --> H[调用 refract() 计算折射方向]
H --> I{是否全反射?}
I -- 是 --> J[改用 reflect()]
I -- 否 --> K[正常折射]
J --> L[采样CubeMap]
K --> L
L --> M[输出颜色]
清晰明了,每一步都有明确目的。
第二步:暴露可控属性给材质面板
为了让美术也能调,我们在 Properties 里加上这些:
Properties {
_RefractiveIndex ("Refraction Index", Range(0.1, 3.0)) = 1.5
_TexelOffset ("Edge Distortion", Range(0.0, 0.1)) = 0.02
_DistortTex ("Distortion Map", 2D) = "bump" {}
_EnvCube ("Environment Cubemap", Cube) = "" {}
}
对应的uniform声明:
float _RefractiveIndex;
float _TexelOffset;
sampler2D _DistortTex;
samplerCUBE _EnvCube;
这样就能在Inspector里滑动调节折射率了,比如改成1.33就是水,2.42就是钻石💎
第三步:编写完整Shader代码
Shader "Custom/RefractionShader"
{
Properties {
_RefractiveIndex ("Refraction Index", Range(0.1, 3.0)) = 1.5
_TexelOffset ("Edge Distortion", Range(0.0, 0.1)) = 0.02
_DistortTex ("Distortion Map", 2D) = "bump" {}
_EnvCube ("Environment Cubemap", Cube) = "" {}
}
SubShader {
Tags {
"Queue" = "Transparent"
"RenderType" = "Opaque"
"IgnoreProjector" = "True"
}
LOD 200
Pass {
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float2 uv : TEXCOORD2;
};
float _RefractiveIndex;
float _TexelOffset;
sampler2D _DistortTex;
samplerCUBE _EnvCube;
v2f vert(appdata v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.uv = v.uv;
return o;
}
fixed4 frag(v2f i) : SV_Target {
float3 V = normalize(i.worldPos - _WorldSpaceCameraPos);
float3 N = normalize(i.worldNormal);
float3 T = refract(-V, N, _RefractiveIndex);
[unroll]
if (!any(T)) {
T = reflect(-V, N);
}
T = normalize(T);
fixed4 cubeCol = texCUBE(_EnvCube, T);
// 添加边缘扰动模拟厚度变化
float3 distortSample = UnpackNormal(tex2Dlod(_DistortTex, float4(i.uv, 0, 0)));
float2 distortedUV = i.uv + distortSample.xy * _TexelOffset;
fixed4 distortCol = tex2Dlod(_DistortTex, float4(distortedUV, 0, 0));
// 混合原始折射与扰动效果
return lerp(cubeCol, distortCol, 0.1);
}
ENDCG
}
}
FallBack "Diffuse"
}
💡 关键技巧解析:
-
tex2Dlod(..., float4(uv, 0, 0)):绕过mipmap自动选择,防止边缘闪烁 -
UnpackNormal():将法线贴图的[0,1]范围转为[-1,1] -
[unroll]:提示编译器展开分支,提升性能 -
lerp(cubeCol, distortCol, 0.1):轻微叠加扰动增加真实感
法线与视线向量:别让精度毁了你的努力
即使你把公式写对了,如果法线或视线方向不准,照样会出现黑边、扭曲、边缘断裂等问题。
如何安全获取世界空间法线?
错误做法 ❌:
o.worldNormal = mul((float3x3)unity_ObjectToWorld, v.normal);
问题在哪? 没考虑非均匀缩放 !如果你把模型压扁了,法线就不会垂直于表面了。
正确做法 ✅:
o.worldNormal = UnityObjectToWorldNormal(v.normal);
这个函数已经帮你处理了逆转置矩阵变换,安全又高效。
视线向量怎么算?
有两种常见方式:
方法一:传递世界坐标后相减(推荐)
// 顶点着色器
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
// 片元着色器
float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
优点:精度高,适合大多数情况。
方法二:重建世界位置(延迟渲染专用)
适用于G-Buffer已有的高级渲染路径,不在本文展开。
⚠️ 无论哪种方式,请记住: 一定要在片元着色器中重新normalize()!
因为插值会让向量变短,尤其是大平面或多边形边缘。
实战部署:搭建测试场景 + 调试技巧
写完Shader只是开始,能不能看出效果还得靠正确的测试环境。
场景搭建建议:
- 创建两个测试模型 :
```csharp
GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.transform.position = new Vector3(-2f, 0.5f, 0f);
cube.transform.localScale = Vector3.one * 1.5f;
GameObject sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere);
sphere.transform.position = new Vector3(2f, 0.5f, 0f);
```
-
设置光照 :
- 平行光:强度0.8,角度X=50°
- 环境光:间接亮度设为0.3
- 关闭Fog,避免干扰观察 -
绑定材质 :
- 创建Material,选择你刚写的RefractionShader
- 拖到Cube和Sphere上 -
配置Render Queue :
hlsl Tags { "Queue" = "Transparent" } ZWrite Off Blend SrcAlpha OneMinusSrcAlpha
这样才能保证透明物体正确排序,不会挡住后面的景物。
可视化调试:让看不见的向量“说话”
最怕的就是“明明代码没错,但就是不对”。这时候就需要 可视化调试 。
技巧一:用颜色显示法线
临时修改frag函数:
#ifdef DEBUG_NORMAL
return fixed4(normalize(i.worldNormal) * 0.5 + 0.5, 1);
#endif
然后在材质面板的 Shader Keywords 栏输入 DEBUG_NORMAL ,立刻就能看到法线方向是否正确!
技巧二:绘制虚拟光线路径(C#辅助)
写个简单的Gizmo脚本,实时查看入射/折射方向:
[ExecuteInEditMode]
public class RefractionGizmo : MonoBehaviour
{
public int rayCount = 10;
public float rayLength = 5f;
void OnDrawGizmos()
{
Vector3 center = transform.position;
for (int i = 0; i < rayCount; i++)
{
Vector3 dir = Quaternion.Euler(0, i * (360f / rayCount), 0) * Vector3.forward;
Gizmos.color = Color.cyan;
Gizmos.DrawRay(center, dir * 2f);
Vector3 refracted = Vector3.Reflect(dir, transform.up); // 示例
Gizmos.color = Color.yellow;
Gizmos.DrawRay(center, refracted * 2f);
}
}
}
挂上去之后,编辑器里就能看到一堆彩色线条飞舞,超酷炫 🎇
性能优化:别让你的Shader拖垮手机
高端PC跑得欢,不代表移动端也能流畅。以下是几个实用优化策略:
1. 使用LOD动态切换Shader复杂度
public Material[] lodMaterials;
void Update()
{
float dist = Vector3.Distance(Camera.main.transform.position, transform.position);
int lod = dist switch
{
< 5f => 0,
< 10f => 1,
_ => 2
};
GetComponent<Renderer>().material = lodMaterials[lod];
}
远距离用简化版(无折射),近距离才启用完整效果。
2. 移动端简化TBN矩阵重建
原版法线扰动需要构建TBN矩阵:
float3 worldBinormal = cross(worldNormal, worldTangent) * tangent.w;
float3x3 TBN = float3x3(worldTangent, worldBinormal, worldNormal);
float3 finalNormal = mul(TBN, tnormal);
但在移动端可以换成预烘焙的 世界空间法线贴图 ,省去矩阵运算:
#ifdef MOBILE_PLATFORM
float3 worldNormal = tex2D(_WorldNormalMap, i.uv).rgb * 2 - 1;
#else
// 完整TBN重建
#endif
节省约40% ALU指令数,肉眼几乎看不出差别 👌
graph TD
A[原始法线贴图] --> B{是否移动端?}
B -- 是 --> C[使用预转换WS法线贴图]
B -- 否 --> D[运行时构建TBN矩阵]
C --> E[节省~40% ALU周期]
D --> F[更高真实感但开销大]
写在最后:你离“电影级”画质只差一个习惯的距离
今天我们从 物理定律出发 ,穿越 数学推导 ,深入 GPU流水线 ,亲手实现了 可交互的折射Shader ,还掌握了 调试与优化 的方法论。
但更重要的是——你学会了如何思考一个视觉效果背后的完整链条:
物理 → 数学 → API → GPU流程 → 数据传递 → 调试验证 → 性能平衡
这才是成为高级图形程序员的核心能力。
下次当你看到某个惊艳的效果时,不要再问“怎么做出来的”,而是试着拆解:“它依赖哪些输入?经历了什么变换?用了什么空间?是否有fallback机制?”
一旦你养成了这种思维方式,你就不再只是一个“抄代码的人”,而是真正掌控画面的大师 🎨
所以,别等了!打开Unity,新建一个Shader,把今天学到的一切都试一遍吧!
“Every pixel tells a story. Make yours worth watching.” 🖼️
简介:Unity作为一款强大的跨平台3D引擎,广泛应用于游戏、VR/AR及真实感渲染开发。本Demo聚焦图形学中的核心概念——光线折射,通过实现Snell定律在Unity中的可视化效果,帮助开发者理解光在不同介质间传播时的方向变化。项目包含基于Shader(CG/HLSL)编写的自定义着色器,演示了从理论到实践的完整流程:创建着色器、应用折射定律、计算折射方向、材质集成与视觉优化。配合详细博客讲解,该Demo为学习者提供可运行的实例,提升对Unity图形编程和物理渲染的理解与应用能力。
转载自CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_42361478/article/details/154903928



