PU 光线追踪是当今的热门话题,所以让我们来谈谈它!今天我们将光线追踪一个单个球体。
使用片段着色器。
是的,我知道。并不特别花哨。你可以在 Shadertoy 上搜索并获得数百个示例(https://www.shadertoy.com/results?query=sphere)。甚至已经有一些很棒的教程教你如何做 球体Imposter
(https://paroj.github.io/gltut/Illumination/Tutorial 13.html),
这就是我们要做的。那么我为什么要写另一篇关于它的文章呢?它甚至不是正确类型的 GPU 光线追踪!
好吧,因为光线追踪部分并不是我真正要关注的部分。这篇文章更多的是关于如何在 Unity 中将不透明的光线追踪或光线行进物体注入到光栅化场景中。但也介绍了一些处理渲染球体Imposter的额外技巧,这些技巧并不总是显而易见或被我见过的其他教程所涵盖。在这篇文章的最后,我们将得到一个紧凑的四边形上的球体Imposter,它支持多个灯光、阴影投射、阴影接收和正交相机,用于内置的前向渲染器,几乎完美地模拟了一个高多边形网格。无需额外的 C# 脚本。
如引言中所述,这是一个已经被广泛探索的领域。绘制球体的准确高效的数学方法已经为人所知。所以我只是要从 Inigo Quilez 的代码中窃取适用的函数,来创建一个基本的光线追踪球体着色器,我们可以将其贴到立方体网格上。
https://www.iquilezles.org/www/articles/intersectors/intersectors.htm
Inigo 的示例都是用 GLSL 编写的。所以我们需要稍微修改一下代码才能让它适用于 HLSL。幸运的是,对于这个函数来说,这实际上只需要将 vec 替换成 float。
float sphIntersect( float3 ro, float3 rd, float4 sph )
{
float3 oc=ro - sph.xyz;
float b=dot( oc, rd );
float c=dot( oc, oc ) - sph.w*sph.w;
float h=b*b - c;
if( h<0.0 ) return -1.0;
h=sqrt( h );
return -b - h;
}
该函数接受 3 个参数:ro(光线起点)、rd(归一化的光线方向)和 sph(球体位置 xyz 和半径 w)。它返回光线从起点到球体表面的长度,或者在未命中时返回 -1.0。简单明了。所以我们只需要这三个向量,我们就可以得到一个漂亮的球体。
光线起点可能是最容易获得的点。对于 Unity 着色器来说,它将是相机位置。方便地传递给全局着色器 _WorldSpaceCameraPos 中的每个着色器。对于正交相机来说,它稍微复杂一些,但幸运的是,我们不必担心。
不祥的预兆
对于球体位置,我们可以使用我们正在应用着色器的物体的世界空间位置。这可以通过 unity_ObjectToWorld._m03_m13_m23 从物体的变换矩阵中轻松提取。我们可以将半径设置为某个任意值。为了没有特别的理由,让我们选择 0.5。
最后是光线方向。这只是从相机到我们代理网格的世界位置的方向。通过在顶点着色器中计算它并将向量传递给片段着色器,我们可以很容易地获得它。
float3 worldPos=mul(unity_ObjectToWorld, v.vertex);
float3 rayDir=_WorldSpaceCameraPos.xyz - worldPos;
请注意,在顶点着色器中对其进行归一化非常重要。你需要在片段着色器中执行此操作,否则插值的值将无法正常工作。我们正在插值的值是表面位置,而不是实际的光线方向。
但是经过所有这些,我们得到了光线追踪球体所需的三个值。
现在我说上面的函数返回光线长度。所以要获得球体表面的实际世界空间位置,你将归一化的光线乘以光线长度,然后加上光线起点。你甚至可以通过从球体位置减去表面位置并进行归一化来获得世界法线。我们将光线长度传递给 clip() 函数,以隐藏球体外部的任何东西,因为该函数在未命中时返回 -1.0。
球体Imposter的最后一个要点是 z 深度。如果我们希望我们的球体与世界正确地相交,我们需要从片段着色器中输出球体的深度。否则,我们将被迫使用我们用来渲染的网格的深度。这实际上比听起来容易得多。由于我们已经在片段着色器中计算了世界位置,我们可以应用我们在顶点着色器中使用的相同视图和投影矩阵来获得 z 深度。Unity 甚至包含一个方便的 UnityWorldToClipPos() 函数,使它变得更加容易。然后,它需要一个使用 SV_Depth 的输出参数,其中包含剪切空间位置的 z 除以其 w。
将所有这些与一些基本的光照结合起来,你就会得到类似这样的东西:
它看起来像一个球体,但实际上是一个立方体。
让所有男人都为之惊叹的一个非常圆的立方体
Shader "Basic Sphere Impostor"
{
Properties
{
}
SubShader
{
Tags { "RenderType"="AlphaTest" "DisableBatching"="True" }
LOD 100
Pass
{
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 rayDir : TEXCOORD0;
float3 rayOrigin : TEXCOORD1;
};
v2f vert (appdata v)
{
v2f o;
// get world position of vertex
// using float4(v.vertex.xyz, 1.0) instead of v.vertex to match Unity's code
float3 worldPos=mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0));
// calculate and world space ray direction and origin for interpolation
o.rayDir=worldPos - _WorldSpaceCameraPos.xyz;
o.rayOrigin=_WorldSpaceCameraPos.xyz;
o.pos=UnityWorldToClipPos(worldPos);
return o;
}
// https://www.iquilezles.org/www/articles/spherefunctions/spherefunctions.htm
float sphIntersect( float3 ro, float3 rd, float4 sph )
{
float3 oc=ro - sph.xyz;
float b=dot( oc, rd );
float c=dot( oc, oc ) - sph.w*sph.w;
float h=b*b - c;
if( h<0.0 ) return -1.0;
h=sqrt( h );
return -b - h;
}
half3 _LightColor0;
half4 frag (v2f i, out float outDepth : SV_Depth) : SV_Target
{
// ray origin
float3 rayOrigin=i.rayOrigin;
// normalize ray vector
float3 rayDir=normalize(i.rayDir);
// sphere position
float3 spherePos=unity_ObjectToWorld._m03_m13_m23;
// ray box intersection
float rayHit=sphIntersect(rayOrigin, rayDir, float4(spherePos, 0.5));
// above function returns -1 if there's no intersection
clip(rayHit);
// calculate world space position from ray, front hit ray length, and ray origin
float3 worldPos=rayDir * rayHit + rayOrigin;
// world space surface normal
float3 worldNormal=normalize(worldPos - spherePos);
// basic lighting
half3 worldLightDir=_WorldSpaceLightPos0.xyz;
half ndotl=saturate(dot(worldNormal, worldLightDir));
half3 lighting=_LightColor0 * ndotl;
// ambient lighting
half3 ambient=ShadeSH9(float4(worldNormal, 1));
lighting +=ambient;
// output modified depth
float4 clipPos=UnityWorldToClipPos(worldPos);
outDepth=clipPos.z / clipPos.w;
return half4(lighting, 1.0);
}
ENDCG
}
}
}
好吧,这并不太令人兴奋。我们应该在上面放一个纹理。为此,我们需要 UV,幸运的是,对于球体来说,这些 UV 非常容易获得。
让我们在上面贴一个等距矩形纹理。为此,我们只需要将法线方向输入到 atan2() 和 acos() 中,我们就会得到类似这样的东西:
float2 uv=float2(
// atan 返回 -pi 到 pi 之间的值
// 所以我们除以 pi * 2 来得到 -0.5 到 0.5
atan2(normal.z, normal.x) / (UNITY_PI * 2.0),
// acos 在顶部返回 0.0,在底部返回 pi
// 所以我们将 y 翻转以与 Unity 的 OpenGL 风格对齐
// 纹理 UV,所以 0.0 在底部
acos(-normal.y) / UNITY_PI
);fixed4 col=tex2D(_MainTex, uv);
地球,最后的疆域。
看看,我们得到一个完美的……等等。这是什么!?
那是格林威治子午线吗?
这是一个 UV 缝!我们怎么会出现 UV 缝呢?好吧,这取决于 GPU 如何为 mip 贴图计算 mip 层级。
GPU 通过所谓的屏幕空间偏导数来计算 mip 层级。粗略地说,这是值从一个像素到它旁边的一个像素(向上或向下)的变化量。GPU 可以为每组 2x2 像素计算此值,因此 mip 层级由这些 2x2“像素四边形”中 UV 的变化量决定。当我们在这里计算 UV 时,atan2() 突然在两个像素之间从大约 0.5 跳到大约 -0.5。这使得 GPU 认为整个纹理在这两个像素之间显示。因此,它会使用它拥有的绝对最小的 mip 贴图来响应。
那么我们如何解决这个问题呢?当然,通过禁用 mip 贴图!
不不不! 我们绝对不会这样做。 但这是你通常会找到的解决大多数 mip 贴图相关问题的方案。相反,Marco Tarini 提供了一个很好的解决方案。
http://vcg.isti.cnr.it/~tarini/no-seams/
这个想法是使用两个 UV 集,它们在不同的位置有缝合。对于我们的特定情况,由 atan2() 计算的经度 UV 已经是 -0.5 到 0.5 的范围,所以我们只需要一个 frac() 来将它们转换为 0.0 到 1.0 的范围。然后使用相同的偏导数来选择变化最小的 UV 集。神奇的函数 fwidth() 给出了值在任何屏幕空间方向上的变化量。
// -0.5 到 0.5 的范围
float phi=atan2(worldNormal.z, worldNormal.x) / (UNITY_PI * 2.0);
// 0.0 到 1.0 的范围
float phi_frac=frac(phi);float2 uv=float2(
// 使用一个小偏差来优先考虑第一个“UV 集”
fwidth(phi) < fwidth(phi_frac) - 0.001 ? phi : phi_frac,
acos(-worldNormal.y) / UNITY_PI
);
现在我们没有缝合了!
我保证它没有隐藏在另一边
** 后记:我注意到这种技术可能只在使用 Direct3D、集成英特尔 GPU 或(某些?)Android OpenGLES 设备时才能正常工作。在桌面设备上使用 OpenGL 时,* fwidth() 函数可能使用比 GPU 用于确定 mip 层级的精度更高的导数,这意味着缝合仍然可见。Metal 保证始终以更高的精度运行。Vulkan 可以通过使用粗导数函数来强制以较低的精度运行,但截至撰写本文时,Unity 似乎没有正确地转译粗导数或精导数。我写了一篇后续文章,其中介绍了一些替代解决方案:
https://bgolus.medium.com/distinctive-derivative-differences-cce38d36797b
或者,你可以直接使用立方体贴图。Unity 可以为你将导入的等距矩形纹理转换为立方体贴图。但这意味着你将失去各向异性过滤。立方体贴图纹理采样的 UVW 本质上只是球体的法线。不过,你确实需要翻转 x 轴或 z 轴,因为立方体贴图被假定为从球体的“内部”进行观察,而在这里我们希望它映射到外部。
此时,如果我们将现有的光线追踪球体着色器与使用相同等距矩形 UV 的实际高多边形网格球体进行比较,你可能会注意到一些奇怪的事情。看起来光线追踪球体周围有一个轮廓,而网格没有。一个非常锯齿的轮廓。
Imposter的粗糙“轮廓”。
原因是我们讨厌的导数再次出现了。我们错过了另一个 UV 缝!在网格上,导数是针对每个像素四边形、每个三角形计算的。事实上,如果一个三角形只接触到一个 2x2 像素四边形中的一个像素,GPU 仍然会为所有 4 个像素运行片段着色器!这样做的好处是,它可以准确地计算出合理的导数,从而防止在真实网格上出现此问题。但我们在球体外部没有一个好的 UV,该函数在未命中时只返回一个常数 -1.0,因此我们在球体外部有错误的 UV。如果在着色器中注释掉 clip() 和 outDepth 行,我们可以清楚地看到这一点。
隐藏的 UV 缝
我们想要的是让 UV 接近球体可见边缘的值,或者可能刚刚超过边缘。这令人惊讶地难以计算。但我们可以通过找到光线到球体中心的最近点来获得一个相当接近的值。在球体边缘,这是 100% 准确的,但当离球体越来越远时,它会开始向相机方向弯曲。但这很便宜,足以消除这个问题,并且与完全正确的修复几乎没有区别。
更棒的是,当球体相交函数返回 -1.0 时,我们可以通过用一个 dot() 替换光线长度来应用此修复。两个向量的点积的一个超级能力是,如果至少一个向量是归一化的,则输出是另一个向量沿归一化向量方向的幅度。这对于获取某个方向上的距离非常有用,例如相机沿视图光线距离球体枢轴的距离。
// 相同的球体相交函数
float rayHit=sphIntersect(rayOrigin, rayDir, float4(0,0,0,0.5));
// 如果是 -1.0,则剪切以在未命中时隐藏球体
clip(rayHit);
// 点积获取最靠近球体的点处的光线长度
rayHit=rayHit < 0.0 ? dot(rayDir, spherePos - rayOrigin) : rayHit;
不再有缝合。
所以一切都进展顺利,但如果我们想做一个更大的球体或旋转它怎么办?我们可以移动网格位置,球体会随之移动,但其他所有东西都被忽略了。
我们可以手动更改球体半径,但随后你必须手动保持你正在使用的网格同步。所以,从物体变换本身提取缩放比例会更容易。我们可以应用一个任意的旋转矩阵,但同样,如果我们能直接使用物体变换,那就更容易了。
或者,我们可以做一些更简单的事情,在物体空间中进行光线追踪!这带来了一些其他的好处,我们将在后面介绍。但在那之前,我们想要在着色器代码中添加几行。首先,我们想要使用 unity_WorldToObject 矩阵将光线起点和光线方向在顶点着色器中转换为物体空间。在片段着色器中,我们不再需要从变换中获取世界空间物体位置,因为球体现在可以位于物体的原点。
// 顶点着色器
float3 worldSpaceRayDir=worldPos - _WorldSpaceCameraPos.xyz;
// 只想旋转和缩放 dir 向量,所以 w=0
o.rayDir=mul(unity_WorldToObject, float4(worldSpaceRayDir, 0.0));
// 需要对起点向量应用完整的变换
o.rayOrigin=mul(unity_WorldToObject, float4(_WorldSpaceCameraPos.xyz, 1.0));// 片段着色器
float3 spherePos=float3(0,0,0);
仅通过添加上面的代码到我们的着色器,你就可以旋转和缩放游戏物体,球体也会按预期进行缩放和旋转。它甚至支持非均匀缩放!请记住,着色器中的所有这些“世界空间”位置现在都在物体空间中。所以我们需要将法线和球体表面位置转换为世界空间。只需确保使用物体空间法线作为 UV。
// 现在获取物体空间表面位置,而不是世界空间
float3 objectSpacePos=rayDir * rayHit + rayOrigin;// 仍然需要在物体空间中对其进行归一化以用于 UV
float3 objectSpaceNormal=normalize(objectSpacePos);float3 worldNormal=UnityObjectToWorldNormal(objectSpaceNormal);
float3 worldPos=mul(unity_ObjectToWorld, float4(objectSpacePos, 1.0));
大、小和可怕的三明治地球。
其他优势包括更好的整体精度,因为对所有内容使用世界空间会在远离原点时导致一些精度问题。在使用物体空间时,这些问题至少可以部分避免。这也意味着我们可以删除几个地方对 spherePos 的使用,因为它都是零,从而简化代码。
到目前为止,我们一直在使用立方体网格。在某些情况下,使用立方体确实有一些好处,但我承诺在本文的标题中使用四边形。而且,实际上没有充分的理由为一个球体使用整个立方体。在侧面有很多浪费的空间,我们在那里支付了渲染球体的成本,而我们知道它不会在那里。尤其是默认的 Unity 立方体,它有 24 个顶点!为什么还要浪费计算额外的 20 个顶点?
有很多公告牌着色器的示例。它们的基本原理是忽略物体的变换的旋转(和缩放!),而是将网格对齐到某个方向以面向相机。
这可能是最常见的版本。这是通过将枢轴位置转换为视图空间,并将顶点偏移量添加到视图空间位置来实现的。这样做相对便宜。请记住更新光线方向以匹配。
// 从变换矩阵中获取物体的世界空间枢轴
float3 worldSpacePivot=unity_ObjectToWorld._m03_m13_m23;// 转换为视图空间
float3 viewSpacePivot=mul(UNITY_MATRIX_V, float4(worldSpacePivot, 1.0));// 物体空间顶点位置 + 视图枢轴=公告牌四边形
float3 viewSpacePos=v.vertex.xyz + viewSpacePivot;// 从视图空间位置计算物体空间光线 dir
o.rayDir=mul(unity_WorldToObject,
mul(UNITY_MATRIX_I_V, float4(viewSpacePos, 0.0))
);// 应用投影矩阵以获取剪切空间位置
o.pos=mul(UNITY_MATRIX_P, float4(viewSpacePos, 1.0));
但是,如果我们只是将上面的代码添加到我们的着色器中,球体就会出现一些问题。它在边缘被剪切,尤其是在球体位于侧面或靠近相机时。
想得太超出了范围。
这是因为四边形是一个平面,而球体不是。球体有一定的深度。由于透视,球体的体积将覆盖比四边形更多的屏幕!
艺术家对犯罪现场的再现
你可能会使用的解决方案是将公告牌按某个任意量进行缩放。但这并不能完全解决问题,因为你必须将四边形放大很多。尤其是在你靠近球体或具有非常宽的视场时。这在一定程度上违背了使用四边形而不是立方体的初衷。事实上,与立方体相比,即使是相对较小的缩放比例增加,现在也有更多像素渲染了空的空间。
幸运的是,我们可以做得更好。一个部分的解决方案是使用面向相机的公告牌,而不是面向视图的公告牌,并将四边形稍微拉向相机。面向视图的公告牌和面向相机的公告牌之间的区别在于,面向视图的公告牌与视图所面向的方向对齐。面向相机的公告牌面向相机的位置。区别可能很细微,代码也稍微复杂一些。
我们不再在视图空间中执行操作,而是需要构建一个旋转矩阵,将四边形旋转到面向相机。这听起来比实际操作更可怕。你只需要获取从物体位置指向相机的向量、前进向量,并使用叉积来获取向上向量和向右向量。将这三个向量放在一起,你就得到了一个旋转矩阵。
float3 worldSpacePivot=unity_ObjectToWorld._m03_m13_m23;// 枢轴和相机之间的偏移量
float3 worldSpacePivotToCamera=_WorldSpaceCameraPos.xyz - worldSpacePivot;// 相机向上向量
// 用作一个相当任意的向上方向起点
float3 up=UNITY_MATRIX_I_V._m01_m11_m2;// 前进向量是归一化的偏移量
// 这是从枢轴到相机的方向
float3 forward=normalize(worldSpacePivotToCamera);// 叉积获取一个垂直于输入向量的向量
float3 right=normalize(cross(forward, up));// 另一个叉积确保向上向量垂直于两者
up=cross(right, forward);// 构建旋转矩阵
float3x3 rotMat=float3x3(right, up, forward);// 上面的旋转矩阵是转置的,这意味着组件是
// 顺序错误,但我们可以通过交换
// 矩阵和向量在 mul() 中的顺序来解决
float3 worldPos=mul(v.vertex.xyz, rotMat) + worldSpacePivot;// 光线方向
float3 worldRayDir=worldPos - _WorldSpaceCameraPos.xyz;
o.rayDir=mul(unity_WorldToObject, float4(worldRayDir, 0.0));// 剪切空间位置输出
o.pos=UnityWorldToClipPos(worldPos);
这更好,但仍然不好。球体仍然剪切了四边形的边缘。实际上,现在是所有四个边缘。至少它是居中的。好吧,我们忘记将四边形移向相机了!从技术上讲,我们也可以按任意量缩放四边形,但让我们回到这一点。
float3 worldPos=mul(float3(v.vertex.xy, 0.3), rotMat) + worldSpacePivot;
我们忽略了四边形的 z,并添加了一个小的(任意的)偏移量以将其拉向相机。与任意缩放相比,这种选择的好处是,当距离较远时,它应该更紧密地限制在球体的边界内,并且当距离较近时,由于透视变化而进行缩放,就像球体本身一样。只有当非常靠近时,它才会开始覆盖比需要更多的屏幕空间。我在上面的示例中选择了 0.3,因为它是在靠近时不会覆盖太多屏幕空间,同时仍然覆盖所有可见球体,直到你非常非常靠近。
你知道,你可能可以用一些数学方法来计算出在给定距离下拉动或缩放四边形的确切值……
等等!我们可以用一些数学方法来计算出这个值!我们可以计算出相机到枢轴向量和相机到球体可见边缘之间的角度。事实上,它始终是一个直角三角形,直角位于球体的表面!还记得你老朋友 SOHCAHTOA 吗?我们知道相机到枢轴的距离,那是斜边。我们也知道球体的半径。由此,我们可以计算出从将该角度投影到四边形的平面所形成的直角三角形的底边。有了它,我们可以缩放四边形,而不是修改 v.vertex.z。
// 获取直角三角形的正弦值,斜边是 // 球体枢轴距离,对边使用球体半径
float sinAngle=0.5 / length(viewOffset);// 转换为余弦
float cosAngle=sqrt(1.0 - sinAngle * sinAngle);// 转换为正切
float tanAngle=sinAngle / cosAngle;// 上面的两行等效于此,但速度更快
// tanAngle=tan(asin(sinAngle));// 获取直角三角形对边,直角位于球体枢轴处,乘以 2 以获取四边形大小
float quadScale=tanAngle * length(viewOffset) * 2.0;// 按计算的大小缩放四边形
float3 worldPos=mul(float3(v.vertex.xy, 0.0) * quadScale, rotMat) + worldSpacePivot;
在这篇文章的开头,我们将所有内容转换为使用物体空间,这样我们就可以轻松地支持旋转和缩放。我们仍然支持旋转,因为四边形的朝向实际上并不重要。但四边形不会像立方体那样随着物体的变换进行缩放。解决这个问题最简单的方法是从变换矩阵的轴中提取缩放比例,并将我们使用的半径乘以最大缩放比例。
// 获取物体缩放比例
float3 scale=float3(
length(unity_ObjectToWorld._m00_m10_m20),
length(unity_ObjectToWorld._m01_m11_m21),
length(unity_ObjectToWorld._m02_m12_m22)
);
float maxScale=max(abs(scale.x), max(abs(scale.y), abs(scale.z)));// 将球体半径乘以最大缩放比例
float maxRadius=maxScale * 0.5;// 使用新的半径更新我们的正弦计算
float sinAngle=maxRadius / length(viewOffset);// 执行其余的缩放代码
现在你可以均匀地缩放游戏物体,球体仍然会完美地限制在四边形内。
也应该可以计算出椭球体或非均匀缩放球体的精确边界。不幸的是,这开始变得有点困难了。所以我现在不会花精力去解决这个问题。我将把它留作“读者的练习”。(也就是说,我不知道怎么做。)
使用四边形的另一个问题是 Unity 的视锥体剔除。它不知道四边形在着色器中被旋转了,因此,如果游戏物体被旋转,使其以边缘朝向观察者,它可能会被视锥体剔除,而球体仍然可见。解决这个问题的方法是使用一个自定义的四边形网格,其边界已通过 C# 代码手动修改为一个盒子。或者,你可以使用一个四边形网格,其中一个顶点向前推了 0.5,另一个顶点向后推了 0.5,位于 z 轴上。我们已经在着色器中通过用 0.0 替换 v.vertex.z 来展平网格。
所以现在我们得到了一个漂亮渲染的球体,它位于一个四边形上,可以被照亮、纹理化,并且可以移动、缩放和旋转。所以让我们让它投射阴影!为此,我们需要在着色器中创建一个阴影投射器通道。幸运的是,相同的顶点着色器可以在这两个通道中重复使用,因为它只创建了一个四边形,并将光线起点和方向传递下去。当然,这些对于阴影来说与相机完全相同,对吧?然后,片段着色器实际上只需要输出深度,这样你就可以删除所有讨厌的 UV 和光照代码。
哦。
光线起点和方向需要来自光源,而不是相机。我们用来表示光线起点的值始终是当前相机位置,而不是光源。好消息是,这并不难修复。我们可以用 UNITY_MATRIX_I_V._m03_m13_m23 替换任何对 _WorldSpaceCameraPos 的使用,它从逆视图矩阵中获取当前视图的世界位置。现在,只要阴影是用透视投影渲染的,它就应该可以正常工作!
哦。哦,不。
方向阴影使用正交投影。
透视投影和光线追踪的优点是,光线起点位于相机的位置。这很容易获得,即使对于任意视图也是如此,如上所示。对于正交投影,光线方向是前进视图向量。这很容易从逆视图矩阵中再次获得。
// 视图空间中的前进方向是 -z,所以我们想要负向量
float3 worldSpaceViewForward=-UNITY_MATRIX_I_V._m02_m12_m22;
但是我们如何获得正交光线起点呢?如果你尝试在线搜索,你可能会看到很多示例使用 C# 脚本来获取逆投影矩阵。或者滥用当前的 unity_OrthoParams,它包含有关正交投影的宽度和高度的信息。然后,你可以使用剪切空间位置来重建光线起源的近视平面位置。这些方法的问题在于,它们都获取的是相机的正交设置,而不是当前光源的设置。所以我们必须在着色器中计算逆矩阵!
float4x4 inverse(float4x4 m) {
float n11=m[0][0], n12=m[1][0], n13=m[2][0], n14=m[3][0];
float n21=m[0][1], n22=m[1][1], n23=m[2][1], n24=m[3][1];
float n31=m[0][2], n32=m[1][2], n33=m[2][2], n34=m[3][2];
float n41=m[0][3], n42=m[1][3], n43=m[2][3], n44=m[3][3]; float t11=n23 * n34 * n42 - n24 * n33 * n42 + n24 * n32 * n43 - n22 * n34 * n43 - n23 * n32 * n44 + n22 * n33 * n44;// ... 等等,还有多少行?
好吧,我们不要这样做。这些只是超过 30 行函数的前几行,而且越来越长,越来越复杂。一定有更好的方法。
事实证明,你不需要任何这些。我们实际上并不需要光线起点位于近平面。光线起点实际上只需要是沿着前进视图向量拉回的网格位置。只要足够远,以确保它没有从球体的体积内部开始。至少假设相机本身还没有位于球体内部。并且相机位置处的“近平面”而不是实际的近平面完全符合这个要求。
我们已经在顶点着色器中知道了顶点的世界位置。所以我们可以将世界位置转换为视图空间。将 viewSpacePos.z 设置为零,然后转换回世界空间。这将产生一个可用于正交投影的光线起点!
// 将世界空间顶点位置转换为视图空间
float4 viewSpacePos=mul(UNITY_MATRIX_V, float4(worldPos, 1.0));// 将视图空间位置展平到相机平面上
viewSpacePos.z=0.0;// 转换回世界空间
float4 worldRayOrigin=mul(UNITY_MATRIX_I_V, viewSpacePos);// 正交光线 dir
float3 worldRayDir=worldSpaceViewForward;// 以及到物体空间
o.rayDir=mul(unity_WorldToObject, float4(worldRayDir, 0.0));
o.rayOrigin=mul(unity_WorldToObject, worldRayOrigin);
实际上,我们甚至不需要做所有这些。还记得上面提到的 dot() 的超级能力吗?我们只需要相机到顶点位置向量和归一化的前进视图向量。我们已经有了相机到顶点位置向量,那是原始的透视世界空间光线方向。我们知道前进视图向量,可以通过从上面提到的矩阵中提取它来获得。方便的是,此向量已经归一化了!所以我们可以删除上面的代码中的两个矩阵乘法,并改为执行以下操作:
float3 worldSpaceViewPos=UNITY_MATRIX_I_V._m03_m13_m23;
float3 worldSpaceViewForward=-UNITY_MATRIX_I_V._m02_m12_m22;// 原始的透视光线 dir
float3 worldCameraToPos=worldPos - worldSpaceViewPos;// 正交光线 dir
float3 worldRayDir=worldSpaceViewForward * -dot(worldCameraToPos, worldSpaceViewForward);// 正交光线起点
float3 worldRayOrigin=worldPos - worldRayDir;o.rayDir=mul(unity_WorldToObject, float4(worldRayDir, 0.0));
o.rayOrigin=mul(unity_WorldToObject, float4(worldRayOrigin, 1.0));
** 这里有一个小问题。这对于倾斜投影(即剪切的正交投影)不起作用。为此,你确实需要逆投影矩阵。但是剪切的透视投影是可以的!*
还记得我们是如何做面向相机的公告牌的吗?以及用于缩放四边形以考虑透视的那些花哨的数学方法吗?对于正交投影,我们不需要任何这些。只需要执行面向视图的公告牌,并将四边形按物体的变换的最大缩放比例进行缩放。但是也许我们不要删除所有这些代码。我们可以照常使用现有的旋转矩阵构建,只是将 forward 向量更改为负的 worldSpaceViewForward 向量,而不是 worldSpacePivotToCamera 向量。
事实上,现在可能是讨论聚光灯和点光源如何使用透视投影的好时机。如果我们想要支持方向光、聚光灯和点光源阴影,我们需要在同一个着色器中同时支持透视和正交投影。Unity 还使用此通道来渲染相机深度纹理。这意味着我们需要检测当前投影矩阵是否是正交的,并在两种路径之间进行选择。
好吧,我们可以通过检查投影矩阵的特定组件来找出我们正在使用哪种类型的投影矩阵。如果投影矩阵的最后一个组件是 0.0,则它是透视投影矩阵,如果它是 1.0,则它是正交投影矩阵。
bool isOrtho=UNITY_MATRIX_P._m33==1.0;// 公告牌代码
float3 forward=isOrtho ? -worldSpaceViewForward : normalize(worldSpacePivotToCamera);
// 执行其余的公告牌代码// 四边形缩放代码
float quadScale=maxScale;
if (!isOrtho)
{
// 执行完美的缩放代码
}// 光线方向和起点代码
float3 worldRayOrigin=worldSpaceViewPos;
float3 worldRayDir=worldPos - worldSpaceRayOrigin;
if (isOrtho)
{
worldRayDir=worldSpaceViewForward * -dot(worldRayDir, worldSpaceViewForward);
worldRayOrigin=worldPos - worldRayDir;
}o.rayDir=mul(unity_WorldToObject, float4(worldRayDir, 0.0));
o.rayOrigin=mul(unity_WorldToObject, float4(worldRayOrigin, 1.0));// 不要担心,我稍后会展示整个顶点着色器
现在,我们得到了一个可以正确处理正交投影和透视投影的顶点函数!片段着色器中不需要更改任何内容来考虑这一点。哦,我们实际上可以使用同一个函数来表示阴影投射器通道和前向照明通道。现在,你也可以使用正交相机了!
现在,如果你一直在关注,你将得到一个输出深度的阴影投射器通道。但我们没有调用阴影投射器通常用于应用偏移的任何常用函数。目前,这并不明显,因为我们还没有进行自阴影,但如果我们不修复它,这将是一个问题。
我们不会使用内置的 TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) 宏来表示顶点着色器,因为我们需要在片段着色器中进行偏差。幸运的是,在物体空间中进行光线追踪还有另一个好处。阴影投射器顶点着色器宏调用的第一个函数假设传递给它的位置位于物体空间中!我的意思是,这是有道理的,因为它假设它正在处理起始的物体空间顶点位置。但这意味着我们可以直接使用阴影投射器宏调用的偏差函数,使用我们光线追踪的位置,它们就会正常工作!
是的,实际上仍然只是一个四边形。
Tags { "LightMode"="ShadowCaster" }ZWrite On ZTest LEqualCGPROGRAM
#pragma vertex vert
#pragma fragment frag_shadow#pragma multi_compile_shadowcaster// 是的,我知道顶点函数缺失fixed4 frag_shadow (v2f i,
out float outDepth : SV_Depth
) : SV_Target
{
// 光线起点
float3 rayOrigin=i.rayOrigin; // 归一化光线向量
float3 rayDir=normalize(i.rayDir); // 光线球体相交
float rayHit=sphIntersect(rayOrigin, rayDir, float4(0,0,0,0.5)); // 上面的函数在没有相交时返回 -1
clip(rayHit); // 计算物体空间位置
float3 objectSpacePos=rayDir * rayHit + rayOrigin; // 输出修改后的深度
// 是的,我们将 objectSpacePos 作为两个参数传递
// 第二个用于物体空间法线,在本例中
// 是归一化的位置,但该函数将其转换为
// 世界空间并进行归一化,所以我们不必这样做
float4 clipPos=UnityClipSpaceShadowCasterPos(objectSpacePos, objectSpacePos);
clipPos=UnityApplyLinearShadowBias(clipPos);
outDepth=clipPos.z / clipPos.w; return 0;
}
ENDCG
就是这样。这适用于所有阴影投射器变体。方向光阴影、聚光灯阴影、点光源阴影以及相机深度纹理!你知道,如果我们想支持多个灯光……
** 我没有添加对 GLES 2.0 点光源阴影的支持。这需要将距离光源的距离作为阴影投射器通道的颜色值输出,而不是仅仅硬编码 *0*。添加它并不难,但这会使着色器变得更加混乱,因为需要添加一些 *#if* 和我们需要计算的特殊情况数据。所以我没有包含它。*
** 编辑:我忘记了在处理 OpenGL 平台上的深度时的一件事。OpenGL 的剪切空间 z 是 -w 到 +w 的范围,所以你需要执行一个额外的步骤将其转换为片段着色器输出深度所需的 0.0 到 1.0 的范围。*
#if !defined(UNITY_REVERSED_Z) // 基本上只有 OpenGL
outDepth=outDepth * 0.5 + 0.5;
#endif
所以现在我们得到了一个有效的阴影投射。那么阴影接收呢?这将进入 Unity 特定内容的阴暗面。如果你不是凡人,现在就转身吧……或者,如果你不太关心 Unity 的内置前向渲染路径。(或者至少跳到下一节关于 深度 的内容。)
在早期,我发布了一个带有基本漫反射光照设置的着色器。如果你一直关注这篇文章,那么前向基本通道的光照代码现在应该看起来像这样。
// 世界空间表面法线和位置
float3 worldNormal=UnityObjectToWorldNormal(objectSpaceNormal);
float3 worldPos=mul(unity_ObjectToWorld, float4(objectSpacePos, 1.0));// 基本光照
half3 worldLightDir=UnityWorldSpaceLightDir(worldPos);
half ndotl=saturate(dot(worldNormal, worldLightDir));
half3 lighting=_LightColor0 * ndotl;// 环境光照
half3 ambient=ShadeSH9(float4(worldNormal, 1));
lighting +=ambient;// 应用光照
col.rgb *=lighting;
没什么特别的。获取你的世界法线和世界位置。获取世界光线方向。执行一个钳位点积。将光线颜色乘以点积,添加环境光照,并将纹理乘以光照。这有点像你开始学习光照着色器教程时的代码。但我们显然缺少阴影。
对于传统的向前基本照明着色器,我们想要在一些地方添加一些宏,Unity 会自动为我们提供所需的内容。将 SHADOW_COORDS(#) 添加到 v2f 结构体中,在顶点函数中调用 TRANSFER_SHADOW(o);,然后在片段着色器中调用 UNITY_LIGHT_ATTENUATION(atten, i, worldPos);。我们当然可以这样做,至少对于向前基本通道来说可以这样做。在桌面和主机上,Unity 的方向光的阴影使用屏幕空间阴影。也就是说,阴影贴图被渲染,然后它们被投射到从相机深度纹理中事先计算出的世界位置上,并保存在屏幕空间纹理中。所以上面的宏只是将屏幕空间位置传递下去,你可以从剪切空间位置中廉价地计算出它。
通常,这是通过上面提到的 TRANSFER_SHADOW(o); 宏来完成的,并从顶点着色器传递到片段着色器。但我们已经在片段着色器中计算了剪切空间位置。我们可以重复使用它,使用宏调用的同一个 ComputeScreenPos(clipPos) 函数来计算屏幕空间位置。然后,我们可以使用最终的内置宏,让它完成剩下的工作。
我们确实想要使用 UNITY_LIGHT_ATTENUATION(atten, i, worldPos); 宏。它为我们处理额外的功能,例如光线饼干。以及另一个我将在稍后提到的原因。
但有一个小问题。内置的阴影宏期望你传递一个包含屏幕空间位置的结构体。而我们的 v2f 结构体没有它,如果我们不必这样做,我们也不想把它添加到该结构体中。
谢天谢地,我们不需要这样做,我们可以创建一个虚拟结构体!它只需要 SHADOW_COORDS(0) 宏来添加其他宏期望的结构体元素,然后我们就可以自己设置它添加的值。
// 虚拟结构体
struct shadowInput {
SHADOW_COORDS(0)
);// 世界空间位置和剪切空间位置
float3 worldPos=mul(unity_ObjectToWorld, float4(surfacePos, 1.0));
float4 clipPos=UnityWorldToClipPos(float4(worldPos, 1.0));#if defined (SHADOWS_SCREEN)
// 为屏幕空间阴影设置阴影结构体
shadowInput shadowIN;
#if defined(UNITY_NO_SCREENSPACE_SHADOWS)
// 移动阴影
shadowIN._ShadowCoord=mul(unity_WorldToShadow[0], float4(worldPos, 1.0));
#else
// 屏幕空间阴影
shadowIN._ShadowCoord=ComputeScreenPos(clipPos);
#endif // UNITY_NO_SCREENSPACE_SHADOWS
#else
float shadowIN=0;
#endif // SHADOWS_SCREEN// 宏创建一个名为 atten 的变量,其中包含阴影
UNITY_LIGHT_ATTENUATION(atten, shadowIN, worldPos);// 将方向光照乘以 atten
half3 lighting=_LightColor0 * ndotl * atten;
现在,我们可以接收方向阴影了!
捕捉阴影。
所以我说过我们确实想要使用上面的 UNITY_LIGHT_ATTENUATION 宏。这是真正的原因。它还处理其他灯光类型!Unity 的内置前向渲染器通过为每个灯光再次渲染物体来绘制多个灯光。所以我们需要一个前向添加通道。而我们现在用于前向基本通道的唯一的阻止它与前向添加通道一起工作的东西是环境光照。所以你可以复制片段着色器函数并删除两行环境光照代码。
或者,你可以在 #if defined(UNITY_SHOULD_SAMPLE_SH) 中放置这三行环境光照代码,它只对基本通道为真。然后,你可以为这两个通道共享完全相同的函数。
RTX 关闭!
使用 SV_Depth 有一个很大的问题。它禁用了早期深度拒绝。基本上,这意味着如果你在视锥体中,你将支付渲染Imposter的成本。即使它位于其他东西的后面,并且不可见。通常,GPU 可以使用深度缓冲区来跳过对位于相机更近的其他物体后面的网格运行片段着色器。但由于 GPU 在片段着色器运行之后才知道深度是多少,因此它无法做到这一点。
“那么 SV_DepthLessEqual 或 SV_DepthGreaterEqual 呢?”
是的!这是一个很棒的问题,佩蒂尼奥先生。你怎么知道(https://mynameismjp.wordpress.com/2010/11/14/d3d11-features/) 我在想这个?
SV_DepthLessEqual 和 SV_DepthGreaterEqual 语义是 SV_Depth 的替代品,它们告诉 GPU 仍然执行早期深度拒绝,这是为着色器模型 5.0 添加的。但是要使用它,我们必须确保网格比我们要渲染的球体更靠近或更靠近相机。为此,我们想要将网格拉向相机。现在,面向相机的四边形位于球体的中心。
问题是我们需要将顶点移近相机,而不会修改它们的屏幕空间位置。我们已经为它们计算出了完美的边界,所以如果我们最终取消了这些操作,那就很不幸了。
一个选择是计算比球体枢轴更靠近相机 maxRadius 的视平面的剪切空间位置。然后替换已经计算出的剪切空间位置的 z。剪切空间有一个非常酷的功能,你可以更改剪切空间位置的 z,而不会影响它在屏幕上的位置或导致插值问题。
// 着色器末尾的常用剪切空间
o.pos=UnityWorldToClipPos(worldPos);// 获取球体枢轴沿 // 前进视图向量更靠近 `maxRadius` 的位置
float4 nearerClip=UnityWorldToClipPos(worldSpacePivotPos — worldSpaceViewForward * maxRadius);// 转换应用“透视除法”以获取真实的深度 Z
float nearerZ=nearerClip.z / nearerClip.w// 用新的值替换原始的剪切空间 z
o.pos.z=nearerZ * o.pos.w;
但这种技术有一个很大的缺陷。如果你将相机移得太靠近或试图穿过我们的Imposter球体,那么当我们应该仍然看到它时,它就会消失。问题是“更近的深度”被放置在相机的后面。我们可以尝试对此进行更多工作。例如,尝试将 z 限制为近平面。或者,更确切地说,是将 z 限制在近剪切平面的内部,因为在近剪切平面上仍然会导致它被剔除。
// 限制为近剪切平面的内部
o.pos.z=min(o.pos.w - 1.0e-6f, nearerZ * o.pos.w);
但……这实际上并没有按预期工作*。
当我 说你可以更改剪切空间位置的 z 而不会出现任何问题时,我撒了点谎。这在一种情况下会失败,那就是当网格的某些顶点位于相机后面时。我们试图解决的正是这种情况。即使进行了钳位,四边形仍然比它应该的更被剪切。所以这失败了。
老实说,我不太了解这个问题,无法解释原因。
但有一个更便宜的解决方案,它在一般情况下表现良好,并且不会在“某些顶点位于相机后面”的情况下失败!我们可以沿着光线方向将顶点移动一个球体半径。对于正交投影,这实际上只是世界位置减去前进视图乘以球体半径。对于透视投影,如果我们使用归一化的光线方向,它实际上不会拉得足够远。所以我们需要再次调用我们的朋友 dot(),以找出我们需要偏移多远才能正确地将四边形的表面拉近一个球体半径。
// 这将顶点推向相机
// 在顶点着色器中的 UnityWorldToClipPos 行之前添加
worldPos +=worldSpaceRayDir / dot(normalize(viewOffset), worldSpaceRayDir) * maxRadius;// 着色器末尾的常用剪切空间
o.pos=UnityWorldToClipPos(worldPos);
现在,当你的相机靠近时,它仍然会与球体进行近剪切,但结果与剪切实际球体网格非常相似。一般来说,如果网格没有被剪切,那么光线偏移四边形也不会被剪切。
添加了这一点之后,只需要将片段着色器中的 SV_Depth 语义替换为适当的选项。对于任何不是 OpenGL 的内容,你应该使用 SV_DepthLessEqual。这是因为 Unity 为非 OpenGL 平台使用反向 Z 深度。反向 Z 深度意味着距离更远的物体具有比更近的物体更小的深度值。所以实际上,我们只需要检查 UNITY_REVERSED_Z 关键字是否处于活动状态。对于 OpenGL……好吧,实际上这都是无用的。我们无法保证 OpenGL 平台支持与 SV_DepthGreaterEqual 等效的功能,直到 OpenGL 4.2。 基本上,你可能被迫在任何不使用反向 Z 深度的平台上使用 SV_Depth。然后,所有这些将四边形拉近相机以减少过度阴影的操作对于这些平台来说都是毫无意义的。但我们至少可以在着色器中处理这两种情况。
** 编辑:运行 OpenGL 4.2+ 的 Unity 仍然使用常规的 z 深度。你可以为它使用 *SV_DepthGreaterEqual*,但实际上,任何支持 OpenGL 4.2 的平台,你都希望改为运行 Direct3D、Vulkan 或 Metal。*
// 这样更新片段着色器函数
half4 frag_(forward/shadow) (v2f i
#if UNITY_REVERSED_Z && SHADER_TARGET > 40
, out float outDepth : SV_DepthLessEqual
#else
// 该设备可能无法使用保守深度
, out float outDepth : SV_Depth
#endif
) : SV_Target
还有一些小细节需要完善着色器。支持“每个顶点”非重要灯光、雾和基本实例化。这些并不十分有趣,所以我将快速介绍一下。
由于我们实际上没有很多顶点,所以我们还需要在片段着色器中调用“顶点灯光”函数。这实际上只是复制和粘贴顶点灯光函数,将其放在一个 #if 中,并将返回值添加到 lighting 中。
#if defined(VERTEXLIGHT_ON)
// “每个顶点”非重要灯光
half3 vertexLighting=Shade4PointLights(
unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
unity_LightColor[0].rgb, unity_LightColor[1].rgb,
unity_LightColor[2].rgb, unity_LightColor[3].rgb,
unity_4LightAtten0,
worldPos, worldNormal);lighting +=vertexLighting;
#endif
或者至少它应该这么简单。VERTEXLIGHT_ON 是由 #pragma multi_compile_fwdbase 控制的关键字之一。但似乎,如果你在顶点着色器中没有这个函数,那么具有该关键字的着色器变体将永远不会创建。所以你必须用自己的多编译行来强制执行它。
#pragma multi_compile _ VERTEXLIGHT_ON
与这篇文章中介绍的许多内容一样,Unity 的内置宏假设你正在从顶点着色器中输出某种值。对于桌面,这只是将原始的 clipPos.z 传递给片段着色器,然后片段着色器在调用那里的雾宏时计算实际的雾衰减。所以,我们可以在前向通道的片段着色器末尾添加带有 UNITY_APPLY_FOG(clipPos.z, col); 的常用宏。
对于移动设备,衰减是在顶点着色器中计算的。但我们需要使用我们在片段着色器中计算的 clipPos.z,所以如果你想要同时支持移动设备和桌面,我们不能只使用常用的 UNITY_APPLY_FOG(clipPos.z, col) 宏。所以我们必须计算衰减并将它传递给宏,但只在移动设备上这样做。
// 雾
float fogCoord=clipPos.z;#if (SHADER_TARGET < 30) || defined(SHADER_API_MOBILE)
// 宏计算雾衰减
// 并创建一个 unityFogFactor 变量来保存它
UNITY_CALC_FOG_FACTOR(fogCoord);
fogCoord=unityFogFactor;
#endifUNITY_APPLY_FOG(fogCoord, col);
要将实例化添加到着色器中,请复制和粘贴 Unity 关于此内容的文档中提到的适当宏:
https://docs.unity3d.com/Manual/GPUInstancing.html
转到将实例化添加到顶点和片段着色器部分,并将宏复制到 appdata 和 v2f 结构体、顶点函数以及片段函数中。忽略 BUFFER 和 PROP 宏。但你确实需要在片段着色器中使用 UNITY_SETUP_INSTANCE_ID(i);。在实例化着色器中,unity_ObjectToWorld 和 unity_WorldToObject 矩阵是实例化属性。由于我们在片段着色器中使用它们,因此我们也需要实例 ID。
话不多说,这是完成的着色器,完整代码如下。
完整代码(https://gist.github.com/bgolus/1188cd89968b977d5c468bf7bbb3250b)
因为我知道下一个问题每个人都会问的是“如何在表面着色器/着色器图中做这个?”。以下是这些问题的答案。
你不能。*
好吧,你可以构建光线起点和方向。你可以进行球体的光线追踪。你当然也可以执行所有过程式 UV 操作。你甚至可以更新表面法线,使其像球体一样被照亮。
你不能做的一件事是从片段着色器中调整用于光照和阴影的深度或世界位置。因此,深度相交看起来会很奇怪,阴影看起来会很奇怪,并且非常靠近表面的灯光看起来也不正确。因为它们都将使用原始网格表面的位置。
因此,在 Unity 的任何渲染器中使用这种技术的唯一选择是使用手写的顶点片段着色器。至少目前是这样。我希望有一天你能够在着色器图中输出修改后的深度值。但截至撰写本文时,他们还没有提到要添加此功能。
** 人们指出,HDRP 的着色器图确实具有在主节点上设置深度以执行每个片段深度功能的能力。不过,它使用的是 *SV_Depth* 而不是 *SV_DepthLessEqual*,因此不需要执行四边形的射线方向偏移。感谢 Rémy 提醒我。希望他们能将此功能添加到 URP 中。
https://portal.productboard.com/unity/1-unity-graphics/tabs/7-shader-graph
我的许多其他文章都是关于抗锯齿的,为什么我在这里跳过了它?因为这是一个没有完美解决方案的难题。
Inigo Quiles 在这里有一个关于如何处理光线追踪球体的抗锯齿的优秀示例:
https://www.shadertoy.com/view/MsSSWV
基本原理是使用光线到点距离计算(这也用于修复外部边缘的 UV),以近似地了解光线在屏幕空间中距离球体边缘有多近。这可以为你提供一个渐变,可以使用类似于我在 Alpha to Coverage 文章中使用的函数来锐化,然后将其用作输出 alpha。也可以用于非 MSAA 和非不透明用例中的 alpha 混合。
使用原始着色器的 4x MSAA 与使用 Alpha to Coverage 的比较。
// 将此添加到通道中,位于 CGPROGRAM 之外,以启用
// alpha to coverage
AlphaToMask On
// 光线到球体枢轴距离
float rayToPointDist=length(rayDir * dot(rayDir, -rayOrigin) + rayOrigin);// fwidth 获取 ddx 和 ddy 偏导数的总和
// float fDist=fwidth(rayToPointDist);// fwidth 是对此的粗略近似
float fDist=length(float2(ddx(rayToPointDist), ddy(rayToPointDist)));// 锐化光线到点距离
// 以球体半径为中心,根据导数 +/- 半个像素
float alpha=(0.5 - rayToPointDist) / max(fDist, 0.0001) + 0.5;// 根据锐化的 alpha 剪切
// 不要根据光线命中未命中进行剪切
clip(alpha);// 将 alpha 限制在 0 到 1 的范围内,并在
// 采样纹理后将其应用于输出 alpha
col.a=saturate(alpha);
这似乎应该足够好了,对吧?那么为什么我说没有完美的解决方案呢?为什么我没有默认实现它呢?对外部边缘进行抗锯齿并不能解决与光栅化网格或从片段着色器输出深度的其他着色器相交时的锯齿问题。当启用 MSAA 光栅化三角形时,会为三角形覆盖的每个子样本计算深度,但片段着色器只对每个像素运行一次。这意味着两个相交网格的每个子样本覆盖可以准确地确定到子样本计数。此着色器正在从片段着色器中写入深度,因此每个像素只有一个深度。然后,相同的深度用于所有子样本。因此,相交处没有 AA。从技术上讲,在光栅化几何体和输出深度的片段着色器之间仍然存在一些 AA,因为会考虑相交三角形的平面。但在两个深度写入着色器之间将不会存在任何 AA。
使用原始着色器的 4x MSAA 与使用 Alpha to Coverage 的比较。请注意,两种方法的相交处都是相同的。与视平面对齐的光栅化表面在与Imposter相交处显示锯齿。以角度观察的光栅化表面显示抗锯齿,但它等效于与视平面对齐的表面相交。
上面的 Shadertoy 示例可以处理相交,因为它在一个通道中渲染所有这些球体,并对分析形状执行每个像素排序和合成。它甚至没有执行任何 MSAA。
据我所知,没有一种有效的方法可以在启用 MSAA 的情况下处理片段着色器深度写入,同时仍然只对每个像素运行一次片段着色器。这将导致使用 sample 插值修饰符来强制片段着色器对每个子样本运行。当 MSAA 的全部目的是不这样做时,这对于性能来说并不理想。但它看起来确实很不错。
使用原始着色器的 4x MSAA 与强制每个子样本渲染的着色器的比较。
使用原始着色器的 4x MSAA 与强制每个子样本渲染的着色器的比较。请注意,超级采样情况下的所有相交处都得到了适当的抗锯齿。
// 更新 v2f 结构体以使用插值的 ray dir 和 ray origin 向量的样本修饰符,以强制片段
// 着色器对每个子样本运行,并为插值
// 值获取每个子样本位置的唯一计算
struct v2f
{
float4 pos : SV_POSITION;
sample float3 rayDir : TEXCOORD0;
sample float3 rayOrigin : TEXCOORD1;
UNITY_VERTEX_INPUT_INSTANCE_ID
};// 将此添加到 CGPROGRAM 块中,作为通道,因为
// 样本修饰符是着色器模型 5.0 的功能
#pragma target 5.0// 你可能还想对纹理 mip 层级进行偏差
// 因为如果我们已经进行了超级采样,为什么不呢!
half4 col=tex2Dbias(_MainTex, float4(uv, 0, -1));
Alpha to Coverage 的 4x MSAA 与强制每个子样本渲染的着色器的比较。
原始着色器的 4x MSAA 与 Alpha to Coverage 与强制超级采样相交比较。
我没有在示例着色器中包含延迟渲染通道。没有理由认为这不能与延迟渲染一起使用。它甚至会更容易编写。但我试图使着色器尽可能简单。
如果喜欢今天的文章,请多点点赞和在看,后续就会有更多此类的文章~
语义化标签,可以让页面有更加完善的结构,让页面的元素有含义,同时利于被搜索引擎解析,有利于SEO,主要标签包括下面的标签:
html5新的常用标签
②增强型表单
可以通过input的type属性指定类型是number还是date或者url,同时还添加了placeholder和required等表单属性。
<input type="range" id="a" value="50" required>
<input type="number" id="b" value="50" placeholder="请输入数字">
③媒体元素
新增了audio和video两个媒体相关的标签,可以让开发人员不必以来任何插件就能在网页中嵌入浏览器的音频和视频内容。
<video width="320" height="240" controls>
<source src="movie.mp4" type="video/mp4">
// 有些低版本浏览器不支持Video标签。
</video>
<audio controls>
<source src="horse.mp3" type="audio/mpeg">
// 有些低版本浏览器不支持 audio 元素。
</audio>
④canvas绘图
canvas绘图指的是在页面中设定一个区域,然后通过JS动态的在这个区域绘制图形。
<canvas id="canvas" width="300" height="300"></canvas>
⑤svg绘图
//画了一个圆
<svg xmlns="http://www.w3.org/2000/svg" version="1.1">
<circle cx="100" cy="50" r="40" stroke="black" stroke-width="2" fill="red" />
</svg>
⑥地理定位
getCurrentPosition()方法来获取用户的位置,从而实现队地理位置的定位。
var x=document.getElementById("demo");
function getLocation()
{
if (navigator.geolocation)
{
navigator.geolocation.getCurrentPosition(showPosition);
}
else
{
x.innerHTML="该浏览器不支持获取地理位置。";
}
}
function showPosition(position)
{
x.innerHTML="纬度: " + position.coords.latitude +
"<br>经度: " + position.coords.longitude;
}
⑦拖放API
通过给标签元素设置属性draggable值为true,能够实现对目标元素的拖动。
<img draggable="true"> // 拖放图片
⑧Web Worker
Web Worker通过加载一个脚本文件,进而创建一个独立工作的线程,在主线程之外运行,worker线程运行结束之后会把结果返回给主线程,worker线程可以处理一些计算密集型的任务,这样主线程就会变得相对轻松,这并不是说JS具备了多线程的能力,而实浏览器作为宿主环境提供了一个JS多线程运行的环境。
if(typeof(Worker)!=="undefined")
{
// 是的! Web worker 支持!
// 一些代码.....
}
else
{
//抱歉! Web Worker 不支持
}
⑨Web Storage
需要重点掌握的是cookie、Localstorage和SessionStorage三者之间的区别:
1.有效期
2.存储数据的大小
3.作用范围
4.安全性
⑩Websocket
websocket和HTTP的区别:
【注】HTML5 定义的 WebSocket 协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。
webSocket
学习记录,如有侵权请联系删除
为一名前端爱好者, 我利用空余时间研究了几个国外网站的源码,发现不管是库,还是业务代码,都会用到了一些比较有意思的API,虽然平时在工作中部分接触过,但是经过这次的研究,觉得很有必要总结一下,毕竟已经2020年了,是时候更新一下技术储备了,本文主要通过实际案例来带大家快速了解以下几个知识点:
我会对部分API做一些比较有意思的案例,那么开始我们的学习吧~
Observer是浏览器自带的观察者,它主要提供了Intersection, Mutation, Resize, Performance这四类观察者, 这里笔者重点介绍Intersection Observer.
IntersectionObserver提供了一种异步观察目标元素与其祖先元素交叉状态的方法。当一个IntersectionObserver对象被创建时,其被配置为监听根中一段给定比例的可见区域,并且无法更改其配置,所以一个给定的观察者对象只能用来监听可见区域的特定变化值;然而,我们可以在同一个观察者对象中配置监听多个目标元素。
说简单点就是该api可以异步监听目标元素在根元素里的位置变动,并触发响应事件.我们可以利用它来实现更为高效的图片懒加载, 无限滚动以及内容埋点上报等.接下来我们通过一个例子来说明一下它的使用步骤.
// 1.定义观察者及观察回调
const intersectionObserver=new IntersectionObserver((entries, observer)=> {
entries.forEach(entry=> {
console.log(entry)
// ...一些操作
});
},
{
root: document.querySelector('#root'),
rootMargin: '0px',
threshold: 0.5
}
)
// 2. 定义要观察的目标对象
const target=document.querySelector(“.target”);
intersectionObserver.observe(target);
以上代码就实现了一个基本的Intersection Observer,虽然已有代码中还体现不出什么实质性功能. 接下来介绍一下代码中使用到的参数的含义: * callback IntersectionObserver实例的第一个参数, 当目标元素与根元素通过阈值 时就会触发该回调.回调中第一个参数是被观察对象列表,一旦被观察对象发生突变就会被移入该列表, 列表中每一项都保留有观察者的位置信息;第二个参数为observer,观察者本身.如下图控制台打印:
其中rootBounds表示根元素的位置信息, boundingClientRect表示目标元素的位置信息,intersectionRect表示叉部分的位置信息, intersectionRatio表示目标元素的可见比例.
当我们设置rootMargin为10px时,我们的root会增大影响范围,但目标元素移动到淡红色区域式就会被监听到,当然我们还可以设置rootMargin为负值来减少影响区域.其支持的值为百分比和px,如下:
rootMargin: '10px'
rootMargin: '10%'
rootMargin: '10px 0px 10px 10px'
thresholds可以如下图理解:
由上图所示,当我们设置阈值为[0.25, 0.5]时, 目标元素的25%和50%进入根元素的影响范围时都会触发回调.利用这个特性我们往往可以实现位差动画,或者更根据目标元素的位置变化做不同的交互. 当然Intersection还提供了以下几个方法来控制观察对象: disconnect() 使IntersectionObserver对象停止监听工作 takeRecords() 返回所有观察目标的IntersectionObserverEntry对象数组 * unobserve() 使IntersectionObserver停止监听特定目标元素
了解了使用方法和api之后,我们来看看一个实际应用--实现图片懒加载:
<img src="loading.gif" data-src="absolute.jpg">
<img src="loading.gif" data-src="relative.jpg">
<img src="loading.gif" data-src="fixed.jpg">
<script>
let observerImg=new IntersectionObserver(
(entries, observer)=> {
entries.forEach(entry=> {
// 替换为正式的图片
entry.target.src=entry.target.dataset.src;
// 停止监听
observer.unobserve(entry.target);
});
},
{
root: documennt.getElementById('scrollView'),
threshold: 0.3
}
);
document.querySelectorAll('img').forEach(img=> { observerImg.observe(img) });
</script>
以上代码就实现了一个图片懒加载功能, 当图片的30%进入根元素时才加载真实的图片,这又让我想起了之前在某条做广告埋点上报时使用react-lazyload的画面.大家还可以利用它实现无限滚动, H5视差动画等有意思的交互场景.
Mutation Observer主要用来实现dom变动时的监听,同样也是异步触发,对监听性能非常友好. Resize Observer主要用来监听元素大小的变化,相比于每次窗口变动都触发的window.resize事件, Resize Observer有更好的性能和对dom有更细粒度的控制,它只会在绘制前或布局后触发调用. 以上两个api的使用和Intersection使用非常类似,官方资料也写得很全,大家可以好好研究一下.
这个问题主要是之前有朋友问过我,当时的想法就是简单的认为script内的代码执行完之后以及与dom绑定了,存放在了浏览器内存中,最近查了很多资料发现有一个有点意思的解释,放出来大家可以感受一下:
JavaScript解释器在执行脚本时,是按块来执行的,也就是说浏览器在解析HTML文档流时,如果遇到一个script标签,javascript解释器会等待这个代码块都加载完了,才进行预编译,然后才执行。所以,当开始执行这个代码块的代码时,这个代码段已经被解析完了。这时再从DOM中删去也就不影响代码的执行了。
Proxy/Reflect虽然是es6的api,出现也已经有几年了,但是在项目中用的还是比较少,如果是做底层架构方面的工作,还是建议大家多去使用,毕竟vue/react这种框架源码把这些api玩的如火纯青,还是很有必要掌握一下的。
其实我们认真看mdn的介绍或者阮一峰老师的文章,还是很好理解这些api的用法的,接下来我们详细介绍一下这两个api以及应用场景.
Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy在很多场景中都会和Reflect一起使用. 用法也很简单,我们看看Proxy的基本用法:
const obj={
name: '徐小夕',
age: '120'
}
const proxy=new Proxy(obj, {
get(target, propKey, receiver) {
console.log('get:' + propKey)
return Reflect.get(target, propKey, receiver)
},
set(target, propKey, value, receiver) {
console.log('set:' + propKey)
return Reflect.set(target, propKey, value, receiver)
}
})
console.log(proxy.name) // get:name 徐小夕
proxy.work='frontend' // set:work frontend
以上代码拦截了obj对象,并重新定义了读写(get/set)方法,这样我们就可以在访问对象时进行额外的操作了.
Proxy还有apply(拦截 Proxy 实例作为函数调用的操作)和construct(拦截 Proxy 实例作为构造函数调用的操作)等属性可以使用,我们可以在对象操作的不同阶段进行拦截,这里我就不一一样举例了.接下来看看Proxy的实际应用场景. * 实现数组读取负数的索引
我们一般操作数组大多数都是正向操作的,不能通过指定负数来逆向查找数组,如下图:
我们不能通过arr[-1]来拿到数组的尾部元素(字符串同理),这个时候我们就可以用Proxy来实现这一功能,这是我们的结构有点像环状:
这种实现的好处是如果我们想访问数组的最后一个元素时,我们不需要先拿到长度,再通过索引访问了:
// 原始写法
arr[arr.length -1]
// 通过proxy改造后写法
arr[-1]
实现代码如下:
function createArray(...elements) {
let handler={
get(target, propKey, receiver) {
let index=Number(propKey);
if (index < 0) {
propKey=String(target.length + index);
}
return Reflect.get(target, propKey, receiver);
}
};
let target=[];
target.push(...elements);
return new Proxy(target, handler);
}
我们可以发现以上代码使用proxy来代理数组的读取操作,在内部封装了支持负值查找的功能,当然我们也可以不用proxy来实现同样的功能,这里实现参考阮一峰老师的实现. * 利用proxy实现更优雅的校验器
一般我们在做表单校验的时候会写一些if else或者switch判断来实现对不同属性值的校验,同样我们也可以用proxy来优雅的实现它,代码如下:
const formData={
name: 'xuxi',
age: 120,
label: ['react', 'vue', 'node', 'javascript']
}
// 校验器
const validators={
name(v) {
// 检验name是否为字符串并且长度是否大于3
return typeof v==='string' && v.length > 3
},
age(v) {
// 检验age是否为数值
return typeof v==='number'
},
label(v) {
// 检验label是否为数组并且长度是否大于0
return Array.isArray(v) && v.length > 0
}
}
// 代理校验对象
function proxyValidator(target, validator) {
return new Proxy(target, {
set(target, propKey, value, receiver) {
if(target.hasOwnProperty(propKey)) {
let valid=validator[propKey]
if(!!valid(value)) {
return Reflect.set(target, propKey, value, receiver)
}else {
// 一些其他错误业务...
throw Error(`值验证错误${propKey}:${value}`)
}
}
}
})
}
有了以上实现模式,我们就可以实现对表单中某个值进行设置时进行校验了,用法如下:
let formObj=proxyValidator(formData, validators)
formObj.name=333; // Uncaught Error: 值验证错误name:f
formObj.age='ddd' // Uncaught Error: 值验证错误age:f
以上代码中当设置了不合法的值时,控制台将会剖出错误,如果在实际业务中,我们可以给用户做出适当的提醒. 实现请求拦截和错误上报 实现数据过滤
以上几点笔者在之前的文章中也写过,所以这里不在详细介绍了.大家也可以根据实际情况自己实现更加灵活的拦截操作.当然Proxy提供的API远远不止这几个,我们可以在MDN或者其他渠道了解更多高级用法.
Reflect对象与Proxy对象一样,也是 ES6 为了操作对象而提供的新 API,更多的应用场景是配合proxy一起使用,在上文中已经用到了.可以将Object对象的一些明显属于语言内部的方法放到Reflect对象上,并修改某些Object方法的返回结果. Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。
CustomEvent API是个非常有意思的api, 而且非常实用, 更重要的是学起来非常简单,而且被大部分现代浏览器支持.我们可以让任意dom元素监听和触发自定义事件,只需要如下操作:
// 添加一个适当的事件监听器
dom1.addEventListener("boom", function(e) { something(e.detail.num) })
// 创建并分发事件
var event=new CustomEvent("boom", {"detail":{"num":10}})
dom1.dispatchEvent(event)
我们来看看CustomEvent的参数介绍: type 事件的类型名称,如上面代码中的'boom' CustomEventInit 提供了事件的配置信息,具体有以下几个属性 * bubbles 一个布尔值,表明该事件是否会冒泡 * cancelable 一个布尔值,表明该事件是否可以被取消 * detail 当事件初始化时传递的数据
我们可以通过dispatchEvent来触发自定义事件.其实他的用途有很多,比如创建观察者模式, 实现数据双向绑定, 亦或者在游戏开发中实现打怪掉血,比如下面的例子:
笔者上面画了一个打boss的草图, 现在的场景是两个玩家一起打boss, 我们可以在玩家发动攻击的时候触发dispatch掉血的自定义事件, boss监听到事件后将血量自动扣除, 至于不同角色的伤害值,我们可以存放在detail中,然后通过策略模式去分发伤害.笔者曾今在学校开发的H5游戏时就大量采用类似的模式,还是非常有意思的.
File API使得我们在浏览器端可以访问文件的数据,比如预览文件,获取文件信息(比如文件名,文件内容,文件大小等), 并且可以在前端实现文件下载(可以借助canvas和 window.URL.revokeObjectURL的一些能力).当然我们还可以实现拖拽上传文件这样高用户体验的操作.接下来我们来看看几个实际例子. * 显示缩略图
function previewFiles(files, previewBox) {
for (var i=0; i < files.length; i++) {
var file=files[i];
var imageType=/^image\//;
if (!imageType.test(file.type)) {
continue;
}
var img=document.createElement("img");
previewBox.appendChild(img); // 假设"preview"就是用来显示内容的div
var reader=new FileReader();
reader.onload=(function(imgEl) {
return function(e) { imgEl.src=e.target.result; };
})(img);
reader.readAsDataURL(file);
}
}
以上代码可以在reviewBox容器中显示已上传好的图片,当然我们还可以基于此来扩展,利用canvas将图片画到canvas上,然后进行图片压缩,最后再把压缩后的图片上传到服务器.这中方式其实目前很多工具型网站都在用,比如在线图片处理网站,提供的批量压缩图片,批处理水印等功能,套路都差不多,感兴趣的朋友可以尝试研究一下. * 封装文件上传组件
这块笔者之前也写过详细的文章,这里就不一一举例了.
6. Fullscreen
全屏API主要是让网页能在电脑屏幕中全屏显示,它允许我们打开或者退出全屏模式,以便我们根据需要进行对应的操作,比如我们常用的网页图形编辑器或者富文本编辑器, 为了让用户专心于内容设计,我们往往提供切换全屏的功能供用户使用.由于全屏API比较简单,这里我们直接上代码:
// 开启全屏
document.documentElement.requestFullscreen();
// 退出全屏
document.exitFullscreen();
以上代码的document.documentElement也可以换成任何一个你想让其全屏的元素.默认情况下我们还可以通过document.fullscreenElement来判断当前页面是否处于全屏状态,来实现屏幕切换的效果.如果是react开发者,我们也可以将其封装成一个自定义hooks来实现与业务相关的全屏切换功能.
URL API是URL标准的组成部分,URL标准定义了构成有效统一资源定位符的内容以及访问和操作URL的API。
我们利用URL组件可以做很多有意思的事情.比如我们有个需求需要提取url的参数传给后台,传统的做法是自己写一个方法来解析url字符串,手动返回一个query对象.但是利用URL对象,我们可以很方便的拿到url参数,如下:
let addr=new URL(window.location.href)
let host=addr.host // 获取主机地址
let path=addr.pathname // 获取路径名
let user=addr.searchParams.get("user") // 获取参数为user对应的值
以上代码可知,我们如果将url转化为URL对象,那么我们就可以很方便的通过searchParams提供的api来拿到url参数而无需自己再写一个方法了.
另一方面,如果网站安全性比较高,我们还可以对参数进行自然数排序然后再加密上传给后端.具体代码如下:
function sortMD5WithParameters() {
let url=new URL(document.location.href);
url.searchParams.sort();
let keys=url.searchParams.keys();
let params={}
for (let key of keys) {
let val=url.searchParams.get(key);
params[key]=val
};
// ...md5加密
return MD5(params)
}
地理位置 API 通过 navigator.geolocation 提供, 这个浏览器API也比较实用, 我们在网站中可以用此方式确定用户的位置信息,从而让网站有不同的展现,增强用户体验.
举几个有意思的例子可以让大家感受一下: 根据不同地区,网站展示不同的主题:
根据用户所在地区,展示不同推荐内容 这一点电商网站或者内容网站用的比较多, 比如用户在新疆,则给他推荐瓜果类广告, 在北京,则给他推荐旅游景点类广告等,虽然实际应用中往往会更复杂,但是也是一种思路.
其实应用远远不止如此,程序员可以发挥想象来实现更有意思的事情,让自己的网站更智能.接下来笔者就基于promise写一段获取用户位置的代码:
function getUserLocation() {
return new Promise((resolve, reject)=> {
if (!navigator.geolocation) {
reject()
} else {
navigator.geolocation.getCurrentPosition(success, error);
}
function success(position) {
const latitude=position.coords.latitude;
const longitude=position.coords.longitude;
resolve({latitude, longitude})
}
function error() {
reject()
}
})
}
使用方式和结果如下图所示:
我们基于获取到的经纬度调用第三方api(比如百度,高德)就可以获取用户所在为精确位置信息了.
Notifications API 允许网页或应用程序在系统级别发送在页面外部显示的通知;这样即使应用程序空闲或在后台,Web应用程序也会向用户发送信息。
我们举个实际的例子,比如我们网站内容有更新,通知用户,效果如下:
相关代码如下:
Notification.requestPermission( function(status) {
console.log(status); // 仅当值为 "granted" 时显示通知
var n=new Notification("趣谈前端", {body: "从零搭建一个CMS全栈项目"}); // 显示通知
});
当然浏览器的Notification还给我们提供了4个事件触发api方便我们做更全面的控制: onshow 当通知被显示给用户时触发 (已废弃, 但部分浏览器仍然能用) onclick 当用户点击通知时触发 onclose 当通知被关闭时触发(已废弃, 但部分浏览器仍然能用) onerror 当通知发生错误的时候触发
有了这样的事件监听,我们就可以控制当用户点击通知时, 跳转到对应的页面或者执行相关的业务逻辑.如下代码所示:
Notification.requestPermission( function(status) {
console.log(status); // 仅当值为 "granted" 时显示通知
var n=new Notification("趣谈前端", {body: "从零搭建一个CMS全栈项目"}); // 显示通知
n.onshow=function () {
// 消息显示时执行的逻辑
console.log('show')
}
n.onclick=function () {
// 消息被点击时执行的逻辑
history.push('/detail/1232432')
}
n.onclose=function () {
// 消息关闭时执行的逻辑
console.log('close')
}
});
当然我们在使用前需要获取权限,方式也很简单,大家可以在mdn上学习了解.
Battery Status API提供了有关系统充电级别的信息并提供了通过电池等级或者充电状态的改变提醒用户的事件。 这个可以在设备电量低的时候调整应用的资源使用状态,或者在电池用尽前保存应用中的修改以防数据丢失。
之前的版本中Battery Status API提供了几个事件监听函数来监听电量的变化以及监听设备是否充电,但是笔者看文档时这些api都已经废弃,如下: chargingchange 监听设别是否充电 levelchange 监听电量充电等级 chargingtimechange 充电时间变化 dischargingtimechange 放电时间变化
虽然以上几个看似有用的api已经被弃用,但是笔者亲测谷歌还是可以正常使用的,但是为了让自己代码更可靠,我们可以用其他方式代替,比如用定时器定期去检测电量情况,进而对用户做出不同的提醒.
接下来我们看看基本的用法:
navigator.getBattery().then(function(battery) {
console.log("是否在充电? " + (battery.charging ? "是" : "否"));
console.log("电量等级: " + battery.level * 100 + "%");
console.log("充电时间: " + battery.chargingTime + " s");
console.log("放电时间: " + battery.dischargingTime + "s");
});
我们可以通过getBattery拿到设备电池信息,这个api非常有用,比如我们可以在用户电量不足时禁用网站动画或者停用一些耗时任务,亦或者是对用户做适当的提醒,改变网站颜色等,对于webapp中播放视频或者直播时,我们也可以用css画一个电量条,当电量告急时提醒用户.作为一个优秀的网站体验师,这一块还是不容忽视的.
*请认真填写需求信息,我们会在24小时内与您取得联系。