做实时渲染时,最常用到的数学运算就是坐标系变换。比如图形学一道几乎必问的面试题就是:
请问在常见的3D渲染管线里,从制作好mesh,到这个mesh渲染到显示屏上,中间经历了哪几个坐标系变换?
一说到坐标系变换,玩图形的人第一时间都能想到3D空间中的旋转/平移/缩放。但是我还是准备从2D变换部分写起。原因有两点:
- 3D变换的很多内容,可以从2D部分衍生得来;
- 2D坐标系变换,实际上在实时渲染中用得绝对不比3D坐标系少,因为实时渲染中存在两个很重要的2维空间:
- 贴图纹理空间(texture space)
- 屏幕空间(screen space)
本文主要介绍实时渲染中常见的2D坐标系(以Unity和UE4来进行分析)、常见的4种2D坐标系变换的推导,以及2D坐标系变换的应用的大致描述。最后附带一个只使用4种基础变换得到的demo效果:
常用2D坐标系:Unity / Unreal
常见的2维空间如下图所示:
但是渲染里面常用的还有另一个2维空间,跟上图的区别是y轴反向:
举例来说,先看Unity。测试Unity的 texture space:往场景中丢一个 quad模型,给quad指定用下面这段 shader 生成的 material:
Shader "Custom/CheckUVSpace"
{
Properties
{
}
SubShader
{
Tags {
"RenderType"="Opaque"
}
Pass
{
Lighting Off
Blend Off
Cull Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct VertexInput {
float4 vertex : POSITION;
float2 texcoord0 : TEXCOORD0;
};
struct VertexOutput {
float4 pos : SV_POSITION;
float2 uv0 : TEXCOORD0;
};
VertexOutput vert (VertexInput v) {
VertexOutput o = (VertexOutput)0;
o.uv0 = v.texcoord0;
o.pos = UnityObjectToClipPos(v.vertex);
return o;
}
float4 frag(VertexOutput i) : COLOR {
//return float4(i.uv0.xxx, 1.0);
return float4(i.uv0.yyy, 1.0);
}
ENDCG
}
}
FallBack "Diffuse"
}
得到的两张效果图为:
上图可以理解为,Unity的 texture space 是“x右y上”坐标系。(当然,如果把相机颠倒180度,会得到另一个坐标系。此处的分析,是在保证x轴向右的情况下,对应地确定y轴朝向)
Unity的 screen space:将如下C#脚本挂到camera上,然后将脚本的material属性设置为上面这个shader对应的material。可以得到如下画面:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
[ExecuteInEditMode]
public class PP_Blog : MonoBehaviour
{
public Material material;
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
Graphics.Blit(source, destination, material);
}
}
到了Unreal里,情况有些不同。
Unreal的texture space:使用默认的quad,按与Unity相同的方式进行测试:(与上同理,在确保x轴朝右的情况下,确定y轴朝向)
Unreal的screen space:
结论:
- Unity的 texture space / screen space,都是“x轴向右,y轴向上”的2D坐标系
- Unreal的 texture space / screen space,都是“x轴向右,y轴向下”的2D坐标系
- “x轴向左”的情况不用考虑,因为跟“x轴向右”没有区别,只是换个角度观察而已。比如下图中的“x轴向左,y轴向下”,把屏幕颠倒过来看(旋转180度),其实就是“x轴向右,y轴向上”
"x轴向右,y轴向上"的2D坐标系变换
然后可以开始推导坐标系变换的公式了。先分析"x右y上"类型的坐标系,因为这符合以前数学课本的常用习惯。之后再推导"x右y下"。
2D坐标系中,常见的变换主要有以下几种:平移/旋转/缩放/错切。(图像处理领域的基础图像坐标系变换也是这几种)
为了将这些变换用统一的矩阵乘法方式表示,会使用到齐次坐标(齐次坐标是啥暂时不展开了,可以理解为在向量
- 平移
列向量表示法
行向量表示法
- 旋转
推导:假设在unit circle(半径为1的圆)上有两个点
(如果有较真的宝宝忘了三角函数求和公式,可以用几何的方法来证明,见下图)
即
用齐次坐标系来表示在”x右y上“的2D坐标系中逆时针旋转
列向量表示法
行向量表示法
- 缩放
列向量表示法
行向量表示法
这里的缩放系数,除了可以拿来做”缩放“效果外,还可以拿来做”镜像“效果(当
- 错切
列向量表示法
行向量表示法
错切这个操作乍一看,似乎除了在理论上学过以外,并无多大实际用途。但是这种”使x的变换结果依赖于y取值“的思路,是所有非刚性变形的基础。而且,3D图形学中的“透视投影矩阵”/perspective camera projection matrix,其实用到的也是”使x/y的变换结果依赖于z“,以此来实现”近大远小“的效果的。
"x轴向右,y轴向下"的2D坐标系变换
这个坐标系与”x右y上“的区别就是 y轴反向了。
缩放和错切这两种变换,“x右y下”与“x右y上”的变换矩阵相同。
平移:如果考虑的是”x方向移动
旋转:跟平移类似,会有一个“反向”,这个反向指的是:顺时针和逆时针的区别。同样一套公式,在“x右y上”中是逆时针旋转,到了“x右y下”中就成了顺时针旋转了。
Unity/UE4 shader里的2D变换是 row-major 还是 column-major
Unity:使用这张图片来进行测试
Unity的测试材质用这个:
Shader "Custom/CheckUVSpace"
{
Properties
{
_Tex("Tex", 2D) = "white" {}
_RotateSpeed("RotateSpeed", Float) = 0.1
}
SubShader
{
Tags {
"RenderType"="Opaque"
}
Pass
{
Lighting Off
Blend Off
Cull Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
uniform sampler2D _Tex;
uniform float4 _Tex_ST;
uniform float _RotateSpeed;
struct VertexInput {
float4 vertex : POSITION;
float2 texcoord0 : TEXCOORD0;
};
struct VertexOutput {
float4 pos : SV_POSITION;
float2 uv0 : TEXCOORD0;
};
VertexOutput vert (VertexInput v) {
VertexOutput o = (VertexOutput)0;
o.uv0 = v.texcoord0;
o.pos = UnityObjectToClipPos(v.vertex);
return o;
}
float4 frag(VertexOutput i) : COLOR {
float rotateAngle = _Time.x * _RotateSpeed;
float cosVal = cos(rotateAngle);
float sinVal = sin(rotateAngle);
float2x2 rotateMatrix = float2x2(cosVal, -sinVal, sinVal, cosVal);
//float2 uv = mul(rotateMatrix,i.uv0); // 顺时针旋转
float2 uv = mul(i.uv0, rotateMatrix); // 逆时针旋转
float4 _Tex_Var = tex2D(_Tex, uv);
return _Tex_Var;
}
ENDCG
}
}
FallBack "Diffuse"
}
测试结果如代码注释所示:当向量乘到矩阵右边(“mul(rotateMatrix,i.uv0)” )时,为顺时针旋转;当向量乘到矩阵左边(“mul(i.uv0, rotateMatrix);”)时,为逆时针旋转。联系上面的推导,可知:
- Unity shader中的这个“float2x2(cosVal, -sinVal, sinVal, cosVal)”,始终是按照 如下方式来解读
- 但是“float2 uv”,用来左乘矩阵时,会被当成行向量;用来右乘矩阵时,会被当成列向量。
Unreal:采用类似的方法测试,材质如下
其中Custom结点的hlsl代码段为:
float c = cos(Time);
float s = sin(Time);
float2x2 rotm = float2x2(c,-s,s,c);
//return mul(UV,rotm); // 顺时针
return mul(rotm,UV); // 逆时针
测试结果是“左乘/右乘”带来的“顺时针/逆时针”效果刚好跟Unity相反。不过考虑到UE4的 texture space/ screen space 都是“x右y下”,所以是合理的,没问题。
2D变换的效果设计示例
写了半天2D空间的坐标系变换,到底有什么用?图形渲染不是3D空间吗?
嗯,我的理解是,在实时渲染里,2D坐标系变换的重要性,丝毫不少于3D坐标系变换。包括但不限于:
- 3D mesh的材质计算中,会使用到大量的贴图,贴图内容包括光照模型计算常见的 basecolor/normal/metallic/roughness/lightmap等,还包括各种特效类贴图(各种noise和pattern)。这些贴图通常不只是用模型uv读取一下就万事大吉的,而是需要对uv做各种变换、来配合贴图制作各种效果
- 整个后处理PostProcessing,都可以理解为是图像处理(处理的图像可能只是color,也能包含depth/normal等)。既然是图像处理,那简直是可以把2D坐标系变换玩出花来。
- 曾经,实时渲染中的各种贴图都是美术同学一笔一笔画出来的。自从有了Subtance之类的东西以后,很多细节丰富的贴图都可以用程序化生成的方式来制作了。程序化贴图设计,本质上也是一个2D坐标系空间的创作。
当然上面讲的这些2D坐标系的东西已经超出“平移/旋转/缩放/错切”的范畴了,不过这几个基础变换确实是基础。在UE的材质设计里大量用到的“走uv”技巧,对应的Panner和Rotator结点,其实就是2D坐标系的“平移”和“旋转”。
何况,使用好“平移/旋转/缩放/错切”,就已经能够组合出一些“古怪”效果了。比如,使用如下这张简单贴图:
用Unity制作一个材质,只使用 旋转/平移/缩放,和一个用"sin*cos"构造的周期函数,就可以制作下面这个效果:
Unity shader代码如下:
Shader "Custom/CheckUVDemo"
{
Properties
{
_Tex("Tex", 2D) = "white" {}
_RotateSpeed("RotateSpeed", Float) = 0.1
_ScaleX("ScaleX", Float) = 1
_ScaleY("ScaleY", Float) = 1
_AnimSpeed("AnimSpeed", Float) = 1
_MinAnimControl("MinAnimControl", Float) = -0.2
_MaxAnimControl("MaxAnimControl", Float) = 0.2
_AnimBlend("AnimBlend", Range(0,1)) = 1
}
SubShader
{
Tags {
"RenderType"="Opaque"
}
Pass
{
Lighting Off
Blend Off
Cull Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
uniform sampler2D _Tex;
uniform float4 _Tex_ST;
uniform float _RotateSpeed;
uniform float _ScaleX;
uniform float _ScaleY;
uniform float _AnimSpeed;
uniform float _MinAnimControl;
uniform float _MaxAnimControl;
uniform float _AnimBlend;
struct VertexInput {
float4 vertex : POSITION;
float2 texcoord0 : TEXCOORD0;
};
struct VertexOutput {
float4 pos : SV_POSITION;
float2 uv0 : TEXCOORD0;
};
// rotator
float2 rotator2D(float2 inUV, float2 rotCenter, float rotAngle)
{
float cosVal = cos(rotAngle);
float sinVal = sin(rotAngle);
float2x2 rotateMatrix = float2x2(cosVal, -sinVal, sinVal, cosVal);
return mul(inUV-rotCenter, rotateMatrix) + rotCenter;
}
// panner
float2 panner2D(float2 inUV, float2 pan)
{
return inUV + pan;
}
// scaler
float2 scaler2D(float2 inUV, float2 scaleCenter, float2 scaleValue)
{
return (inUV - scaleCenter) * scaleValue + scaleCenter;
}
VertexOutput vert (VertexInput v) {
VertexOutput o = (VertexOutput)0;
o.uv0 = v.texcoord0;
o.pos = UnityObjectToClipPos(v.vertex);
return o;
}
float4 frag(VertexOutput i) : COLOR {
// 构造一个取值范围 [_MinAnimControl, _MaxAnimControl] 的周期性曲线
float timeVal = _AnimSpeed * _Time.y + i.uv0.r;
timeVal = sin(timeVal*15.0)*cos(timeVal*1.0)*0.5 + 0.5;
timeVal = timeVal * (_MaxAnimControl - _MinAnimControl) + _MinAnimControl;
// panner
float2 uv = panner2D(i.uv0, float2(0, timeVal));
// scaler
uv = scaler2D(uv, float2(0.5, 0.5), float2(_ScaleX, _ScaleY));
// rotator
float rotateAngle = _Time.y * _RotateSpeed;
uv = rotator2D(uv, float2(0.5,0.5), rotateAngle);
uv = lerp( uv, i.uv0, _AnimBlend );
float4 _Tex_Var = tex2D(_Tex, uv);
return _Tex_Var;
}
ENDCG
}
}
FallBack "Diffuse"
}