前言
一不小心真的变成年更博主了,主要是真的挺忙的,还有目前在腾讯一直做虚幻引擎,很多程序方案真的不太容易分享,不像效果类的科普,教教大家工具的使用就好,程序类的需要源码的资产的组合展示,而虚幻这块资产导出受制于版本号,在海量源码中修修改改又不好抽出成Unity类似的package包,所以没时间整理这些。
索性把一些通用技术类的移植到Unity上来科普来的更有效率


上半部分1.0倍率下半,新鲜的Demo

视频为使用Unity 2021.3 使用 NativeRendering Plugin 在URP 管线下在 RenderFeature中 使用 Apple Variable Rasterization Rates(可变光栅化率)技术进行渲染

Apple是这么介绍这个技术的
“在复杂的3D应用程序中,每个像素都需要执行大量的计算来输出图像,以生成高质量的结果。 然而一旦你的渲染画面随着屏幕分辨率变大,那么渲染更多高品质像素的代价也越大”
“一种通用解决方案是对画面进行局部降分辨率,对画面中不太好注意到的地方,或者可接受的范围进行降采样可以节省大量的消耗”
试想一款赛车游戏,在画面中的玩家赛车保持1.0倍的缩放渲染,周围运动模糊的部分进行“直接”降分辨率渲染。
为什么要强调 直接 ?因为传统的运动模糊做法需要先对屏幕进行一次截取(Blit),然后通过Stencil,Depth等各种不同的遮罩方案区分出玩家赛车和背景,然后将截取出的画面进行模糊后和原始画面进行合并。这会触发一次RenderPass切换,并伴随着全屏幕的Load/Store,这在移动端上会大量的带宽占用,结局就是设备发热。 而可变速率渲染/光栅化 在drawcall绘制到某个tile时就直接降分辨率了,因此不需要额外的后处理。
在写这个文章的时候发现B站一个妹子也做了这个而技术的介绍视频,可以结合参考下

这个技术并不是苹果的专利
在PC上 DX12 PC/移动端上 Vulkan上也都有各自的实现,甚至是加强版,他们把这个技术称为 变分辨率渲染 VRS
他们的技术思想是对于原本点对点的像素采样,现在可以将周围像素组合起来仅仅采样1次,目前支持的组合方式有 1×1,2×2,4×4,2×1,1×2,4×2,2×4,4×4
Vulkan 支持 PerImage, PerPrimitive, PerDrawll级别的可变分辨率渲染
顾名思义就是支持整张画面将分辨率(后处理),PerPrimitive(三角形), PerDrawllCall(单一绘制)
而PerPrimitive方案特别适合大世界植被渲染,对于大范围的植被,我们不但可以做LOD级别的切换,还可以在同一个Instancing DrawCall中根据距离切换渲染分辨率,这样可以让玩家不那么明显的感知到LOD的突兀的三段式模型
对于这项技术的另一个使用,就是全面推翻了之前在手游中普遍使用的 “离屏软粒子”技术, 这项技术我最早实在Gpu Gems3 中看到的
这个技术目前在各大游戏厂商的手游中都普遍用到了
https://developer.nvidia.com/gpugems/gpugems3/part-iv-image-effects/chapter-23-high-speed-screen-particles
它通过将代价很大的半透明渲染,比如特效,粒子渲染到一个分辨率为原生分辨率 1/4,8/1的 RenderTarget上,用以减少Pixel计算数量,最后回贴到Surface/FrameBuffer上的一种技术。 但是这个技术的代价是需要一张相同降分辨率的Depth/Stencil Texture,切换RenderPass 等涉及到bandwidth的操作,前面提到的后处理模糊处理类似的开销,这些在使用 VRR/VRS技术后,都不需要使用了。
目前主流的高通/联发科芯片产商也直接在驱动中集成了这项技术,相信之后这个技术普及后能见到更多高质量画质,但是省电不发热的游戏 😀
目前高通支持这个技术的GPU为 Adreno 660及之后,MTK天玑系列最新芯片也支持。 但是因为我的安卓手机还没有如此现金,所以使用Iphone 来制作这个Demo, Iphone从 IOS13 起就支持 PerImage/PerRegion级别的 VRR了。
说了这么多,开始说下
制作流程
首先假定你
熟悉C++编程
熟悉Metal Shader Language
熟悉Metal渲染API
熟悉Objective-C语法
熟悉Unity URP/RenderFeature开发
熟悉Unity NativePlugin流程
渲染流程大纲
1.Unity中创建RenderFeature,使用该Feature注册任意一个事件用以开启RatemapRenderPass, 比如以 AfterOpaqueEvent
2.RenderFeature中使用CommandBuffer 调用NativePlugin创建一个原生RenderPass,并将Unity的ColorTarget,DepthTarget提供过去
3.RenderFeature中使用CommandBuffer进行任意渲染
4.RenderFeature中使用CommandBuffer 调用NativePlugin结束原生RenderPass并提交GPU
5.RenderFeature中使用CommandBuffer 调用NativePlugin创建一个BlitPass对降采样过的ColorTarget进行UpScale,并将结果返回给Unity
相关技术点
可以参考官方NativeRendering demo
1.直接在Asset/Plugin/IOS下创建基于Objective-c的文件
2.实现CommandBuffer支持的Native Callback函数
有 EventWithData(携带数据) 和 Event(仅发送事件ID int形) 两种
对用CommandBuffer调用的API为
#if (UNITY_IOS && !UNITY_EDITOR) [DllImport (“__Internal”)] #endif private static extern IntPtr GetRenderEventFunc(); #if (UNITY_IOS && !UNITY_EDITOR) [DllImport (“__Internal”)] #endif private static extern IntPtr GetRenderEventAndDataFunc(); CommandBuffer.IssuePluginEventAndData(GetRenderEventAndDataFunc(), (int)EventID, void* data); CommandBuffer.IssuePluginEvent(GetRenderEvent(), (int)EventID);
不能在CommandBuffer中调用如下声明的Native Function,那是在代码中直接调用,无法被CommandBuffer识别的
extern “C” void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API SomeFunction
3.实现CommandBuffer和NativePlugin数据传递
Unity的资源比如 IndexBuffer,VertexBuffer,RenderTexture 实际上是上层抽象的资源,并非实际资源,但是Unity给了我们一个获取RHI资源的方式
RenderTexture.colorBuffer.GetNativeRenderBufferPtr() RenderTexture.depthBuffer.GetNativeRenderBufferPtr() Texture2D.GetNativeTexturePtr() Mesh.GetNativeIndexBufferPtr(); Mesh.GetNativeVertexBufferPtr(int SubMeshIndex);
这些数据被转换为一个指针,当我们传入NativePlugin时,可以用当前手机对应的渲染底层的资源类型去转换它
id<MTLBuffer> vertexBuffer = (__bridge id<MTLBuffer>)(vertexHandle); id<MTLBuffer> indexBuffer = (__bridge id<MTLBuffer>)(indexHandle); id<MTLTexture> srvTexture = (__bridge id<MTLTexture>)texturehandle; id<MTLBuffer> uvBuffer = (__bridge id<MTLBuffer>)uvHandle;
对了,在传递数据前,最后加上 GCHandle.Alloc 来保证这个数据所占用的内存不会被Unity这边回收
所以流程要多一小步
1.intPtr textureHandle = GCHandle.Alloc(RenderTexture.colorBuffer.GetNativeRenderBufferPtr(),AddrOfPinnedObject()); 2.id<MTLTexture> srvTexture = (__bridge id<MTLTexture>)texturehandle;
因为IssuePluginEventAndData 每次只能传递一个 void* data
那么如果你有很多数据要一起传过去就需要调用很多次这个API, 实际上有可以将数据打包成一个Struct进行一起传递
[StructLayout(LayoutKind.Sequential)] struct VRRPassData { public IntPtr colorTex; public IntPtr outputTex; public int validatedata; } GCHandle.Alloc(new VRRPassData(),AddrOfPinnedObject());
记住,一定要记得加上标签LayoutKind.Sequential 表示这是一段连续内存
然后我们在NativePlugin侧申明一个同结构的结构体进行转换
typedef struct { void* srcTexture; void* dstTexture; int validatedata; }BlitPassData; static void UNITY_INTERFACE_API OnRenderEventAndData(int eventID, void* data) g_BlitData = (BlitPassData*)data;
好了,NativeRendering 大致需要注意的知识点就这些
现在说说Metal VRR用到的API
1.MTLRenderPassDescriptor 用来描述一个Pass,包括ColorTarget,DepthTarget,以及是否使用VRR(通过赋值rasterizationRateMap属性的方式)
2.MTLRasterizationRateMapDescriptor 用来描述一个rasterizationRateMap
2.1 MTLRasterizationRateLayerDescriptor 用来描述Ratemap中层信息,以及划分区域,各个区域的渲染分辨率倍数
3.MTLRenderCommandEncoder Metal通过累计一帧中所有的渲染指令到Encoder后,最后调用EndEncoding进行指令编码为GPU可以理解的语言
4.MTLCommandBuffer 用来生成 MTLRenderCommandEncoder , 在一帧结束后,调用 [MTLCommandBuffer commit] 进行提交到GPU
创建Ratemap的代码很简单,官方给出的范例
MTLRasterizationRateMapDescriptor *descriptor = [[MTLRasterizationRateMapDescriptor alloc] init]; descriptor.label = @”My rate map”; descriptor.screenSize = destinationMetalLayer.drawableSize; MTLSize zoneCounts = MTLSizeMake(8, 4, 1);MTLRasterizationRateLayerDescriptor *layerDescriptor = [[MTLRasterizationRateLayerDescriptor alloc] initWithSampleCount:zoneCounts];
8,4,1 表示将屏幕分为横向8块,纵向4块, 1是占位值,Ratemap仅需要两个值,但是因为参数MTLSize构造函数需要3个值,所以第3个值填1即可,实际上用不到第3个值。
for (int row = 0; row < zoneCounts.height; row++) { layerDescriptor.verticalSampleStorage[row] = 1.0; } for (int column = 0; column < zoneCounts.width; column++) { layerDescriptor.horizontalSampleStorage[column] = 1.0; }
1.0表示使用 原生分辨率*1.0倍
layerDescriptor.horizontalSampleStorage[0] = 0.5; layerDescriptor.horizontalSampleStorage[7] = 0.5; layerDescriptor.verticalSampleStorage[0] = 0.5; layerDescriptor.verticalSampleStorage[3] = 0.5
对4个边角设置为0.5倍
[descriptor setLayer:layerDescriptor atIndex:0]; id<MTLRasterizationRateMap> rateMap = [_device newRasterizationRateMapWithDescriptor: descriptor];
最后使用Descriptor创建出真正的rateMap
firstPassDescriptor.rasterizationRateMap = _rateMap;
将rateMap赋值给 RenderPassDescriptor
id<MTLRenderCommandEncoder> commandEncoder = [MTLCommandBuffer renderCommandEncoderWithDescriptor:firstPassDescriptor];
通过RenderPassDescriptor 创建出这一帧用来接受渲染指令的RenderCommandEncoder
之后把让Unity的 RenderFeature进行正常渲染
渲染结束后,进行一个BlitPass 上采样输出一张ColorTexture贴合设备分辨率
准备工作(NativePlugin侧)
MTLSizeAndAlign rateMapParamSize = _rateMap.parameterBufferSizeAndAlign; _rateMapData = [_device newBufferWithLength: rateMapParamSize.size options:MTLResourceStorageModeShared]; [_rateMap copyParameterDataToBuffer:_rateMapData offset:0];
1.BlitPass 的Shader需要知道当前屏幕哪些部分被设置了什么样的倍数数据,因此我们要创建一个buffer来存储他们
[renderEncoder setFragmentBuffer:_rateMapData offset:0 atIndex:0];
我们将获取到数据塞入 Metal FragmentShader Buffer中,索引为0
typedef struct { float4 position [[position]]; } PassThroughVertexOutput; fragment float4 transformMappedToScreenFragments( PassThroughVertexOutput in [[stage_in]], constant rasterization_rate_map_data &data [[buffer(0)]], texture2d<half> intermediateColorMap [[ texture(0) ]]) { constexpr sampler s(coord::pixel, address::clamp_to_edge, filter::linear); rasterization_rate_map_decoder map(data); float2 physCoords = map.map_screen_to_physical_coordinates(in.position.xy); return float4(intermediateColorMap.sample(s, physCoords)); }
准备一个fragment shader, 其中 constant rasterization_rate_map_data &data [[buffer(0)]] 索引位置必须和[renderEncoder setFragmentBuffer:_rateMapData offset:0 atIndex:0]; 索引一致
constexpr sampler s(coord::pixel, address::clamp_to_edge, filter::linear); 中使用 coord::pixel 而不是 coord::normalized 表示我们需要使用真实纹理尺寸进行采样,而不是[0-1]的归一化uv坐标
之后在NativePlugin中进行类似Unity的操作 Blit(sourceTex, targetTex, Material)的操作即可
这里涉及到 MTLRenderPipelineDescriptor 的相关操作。 之后回到Unity就能得到正确的结果。
最终补充一个技术点,通过GPUFrameCapture可以发现,图中的白色渲染部分实际上仅占用原始RenderTarget对象(黑色区域) 很小的一个部分,我们可以通过 [Ratemap physicalSizeForLayer:AtIndex] 来获取到白色部分的真实大小,这样我们在创建这张RenderTarget时可以直接使用真实的缩放后的尺寸创建,减少多余内存的浪费。
由于本文不是MetalAPI教程,且假定你是熟悉对应API的同学,因此这里不展开介绍。 本文只是为Unity扩展可变速率渲染功能可行性的一种探索,及技术普及
相关项目文件之后会在github中放出,因为写代码时用了太多粗鄙词汇用在变量上,因此还在整理中。
 
引用

好久没有更新博客,距离上次分享技术相关有接近2年了。这两年积累好多很有意识的东西很想分享出来,但是因为加入了鹅厂的原因,然后又是在做3A游戏相关,技术上比较难以展开来分享,当然更重要的原因是鹅厂那个量子态的高压线啦,笑死。 不过最近会重回博客,做一些其他方面的技术分享,不过在这之前会先变成类似日志的更新。

黎之轨迹1最终章这么拖沓真的没想到,坑成这样。看了新出的黎之轨迹2的试玩,更坑,决定不买了。

神秘海域盗贼之海合集PC出来了,还是开这个坑吧,虽然之前Ps4上体验过了,但是毕竟PC上可以开画质模式+高FPS,DLSS等,还是值得再来一遍

最近项目中的小伙伴,打算在大世界地图上刷草,原本的计划是使用Terrain 的笔刷直接绘制
但是Terrain自带的效果,要么刷着刷着就卡死了,要么距离拉远就看不到了

而且实际上最终将草地还原到游戏中时,程序也需要提取草地信息,然后根据不同的硬件平台构建不同的渲染方案。

索性就给小伙伴写一个高性能的笔刷工具

未完待续…


目前网路上大部分的屏幕空间反射都是基于 延迟渲染管线,或者后处理流程来实现的。 其主要原因一个是它是基于屏幕空间的效果,同时对于反射方向还需要考虑反射物体的法线,射线触发方向。 最后的效果部分加上模糊处理等等 对于延迟管线和后处理流程也都顺手拈来。

但是走延迟渲染和后处理流程也有导致事情更复杂的情况,比如要处理自反射,对于自由摆放的反射物体筛选,手机端的性能适配等等。

本篇将基于 Unity 2019 URP Forawrd管线下快速实现一个屏幕空间反射

首先是快速场景搭建,放置1个Plane用作地面,2个Cube 和1个复杂模型(皮卡丘) 这些物件都以Opaque队列渲染
之后补充一个自定义天空盒,用于在反射不到任何物体时,以天空盒进行填充


之后在Plane之上摆放另一个等大且位置重合,近Y轴稍微提高(避免Z Fighting)的Plane,但是其渲染队列为Transparent,用于作为反射平面,之所以不复用之前的地面Plane直接做反射是因为我们接下来要利用 URP 中 CameraOpaqueTexture 一张在非透明队列渲染完毕后屏幕截图,以及CameraDepthAttachment 来制作反射, 而一个Transparent队列的Plane不会被上面2张渲染出来,同时可以取到地面Plane的深度

接下来就开始在这个Transprent 的Plane编写反射Shader了

屏幕空间反射的大致流程如下

1.根据相机方向和反射物件的位置,计算出视野到物体的向量,之后根据物体平面方向向量计算出反射向量
2.以视野方向跟物体相交的位置作为反射起点(反射像素将要填充的位置),以反射向量为方向进行步进, 在每次步进时投影回屏幕空间,进行屏幕空间深度碰撞检测
3.当发现屏幕空间深度和当前步进的检测点相交,或者接近 则视为碰撞成功,返回当前相交点的屏幕空间坐标(UV) 对 CameraOpaqueTexture进行采样像素回贴到反射起点, 否则继续步进,为了防止无限步进导致性能下降,设定一个最远步进距离,超过时,返回天空盒
Continue reading

一般来说使用GpuSkinning 已经能得到很不错的性能了,那么能不能再快一点呢?
答案当然是肯定的,这一次我们来使用ECS榨干CPU的部分

先上性能对比图
1万个蒙皮角色,每个角色472面,带有uv0,uv1
测试设备硬件 win10, Intel i7-7700, GPU GTX-1060 6G

可以看到Entity的帧数在 110帧以上, 而传统GPUSkinning 的帧数在 29帧

这个Demo使用的GPU蒙皮方案为 将骨骼矩阵数据以双四元数的方式存储在纹理上,具体实现方法不是这个Demo的重点,大家也可以参考这篇文章

GPU Skinning 加速骨骼动画
https://github.com/chengkehan/GPUSkinning

接下来一步一步开始分解这个Demo
首先实现Shader Include
Skinning.hlsl

#ifndef __AOI_GPUSKINNING
#define __AOI_GPUSKINNING

TEXTURE2D(_AnimTex);
SAMPLER(sampler_AnimTex);

inline float2 BoneIndexToTexUV(float index, float4 param) {
int row = (int)(index / param.y);
int col = index % param.x;
return float2(col * param.w, row * param.w);
}

inline float3 QuatMulPos(float4 rotation, float3 rhs)
{
float3 qVec = half3(rotation.xyz);
float3 c1 = cross(qVec, rhs);
float3 c2 = cross(qVec, c1);

return rhs + 2 * (c1 * rotation.w + c2);
}

inline float3 QuatMulPos(float4 real, float4 dual, float4 rhs) {
return dual.xyz * rhs.w + QuatMulPos(real, rhs.xyz);
}

inline float4 DQTexSkinning(float4 vertex, float4 texcoord, float4 startData, Texture2D animTex, SamplerState animTexSample) {

int index1 = startData.z + texcoord.x;
float4 boneDataReal1 = SAMPLE_TEXTURE2D_LOD(animTex, animTexSample, BoneIndexToTexUV(index1, startData), 0);
float4 boneDataDual1 = SAMPLE_TEXTURE2D_LOD(animTex, animTexSample, BoneIndexToTexUV(index1 + 1, startData), 0);
float4 real1 = boneDataReal1.rgba;
float4 dual1 = boneDataDual1.rgba;

int index2 = startData.z + texcoord.z;
float4 boneDataReal2 = SAMPLE_TEXTURE2D_LOD(animTex, animTexSample, BoneIndexToTexUV(index2, startData), 0);
float4 boneDataDual2 = SAMPLE_TEXTURE2D_LOD(animTex, animTexSample, BoneIndexToTexUV(index2 + 1, startData), 0);
float4 real2 = boneDataReal2.rgba;
float4 dual2 = boneDataDual2.rgba;

float3 position = (dual1.xyz * vertex.w) + QuatMulPos(real1, vertex.xyz);
float4 t0 = float4(position, vertex.w);

position = (dual2.xyz * vertex.w) + QuatMulPos(real2, vertex.xyz);
float4 t1 = float4(position, vertex.w);

return t0 * texcoord.y + t1 * texcoord.w;
}

inline void SkinningTex_float(float4 positionOS, float4 texcoord, float4 frameData, Texture2D animTex, SamplerState animTexSample, out float4 output) {
output = float4(DQTexSkinning(positionOS, texcoord, frameData, animTex, animTexSample).xyz,1);
}

#endif

Continue reading

业余时间自己使用JobSystem 优化了一遍DynamicBone,和大家分享下思路,以此互相交流下是否有更好的优化方案。

这一次的代码不会放出工程源码,因为DynamicBone是需要商店付费的,请大家多多支持原作者。 但是本文会放出Job实现的源码

DyanamicBone主要耗费性能地方

1.UpdateDynamicBones(float t)函数中大量反复计算变量

比如反复计算重力归一化 Vector3 fdir = m_Gravity.normalized;

2.UpdateParticles2函数

反复依赖 Transform, 及 localToWorldMatrix 矩阵变换,浪费性能的矩阵操作 m0.SetColumn(3, p0.m_Position), TransformDirection 等

那么我们开始一步一步优化吧

首先JobSystem 的NativeContainer容器是仅支持struct的,因此第一步开刀的是Particle

修改前

class Particle
{
public Transform m_Transform = null;
public int m_ParentIndex = -1;
public float m_Damping = 0;
public float m_Elasticity = 0;
public float m_Stiffness = 0;
public float m_Inert = 0;
public float m_Friction = 0;
public float m_Radius = 0;
public float m_BoneLength = 0;
public bool m_isCollide = false;

public Vector3 m_Position = Vector3.zero;
public Vector3 m_PrevPosition = Vector3.zero;
public Vector3 m_EndOffset = Vector3.zero;
public Vector3 m_InitLocalPosition = Vector3.zero;
public Quaternion m_InitLocalRotation = Quaternion.identity;
}

修改后,主要是排除Transform的引用, 并使用Unity优化过的数据格式 Unity.Mathematics; 比如float3,float4x4


public struct Particle
{
public int index;
public int m_ParentIndex;
public float m_Damping;
public float m_Elasticity;
public float m_Stiffness;
public float m_Inert;
public float m_Friction;
public float m_Radius;
public float m_BoneLength;
public int m_isCollide;

public float3 m_EndOffset;
public float3 m_InitLocalPosition;
public quaternion m_InitLocalRotation;
}

有的同学此时会问了,排除了Transform组件后,算法中大量的 向量从本地空间转换到世界,或者世界空间转换到本地的计算如何进行?

实际上我们只需要支持 RootBone 节点的世界坐标,再配合Particle自身的localPositon + localRotation 是可以一层一层计算出每个Particle的世界坐标的。

因此我们在Particle加入下列变量.


//for calc worldPos
public float3 localPosition;
public quaternion localRotation;

public float3 tmpWorldPosition;
public float3 tmpPrevWorldPosition;

public float3 parentScale;
public int isRootParticle;

//for output
public float3 worldPosition;
public quaternion worldRotation;

除了Particle 信息外我们还需要知道根骨骼的世界坐标,以及相关全局变量

比如m_ObjectMove,Gravity 等,可以抽象出一个 Struct Head 来存储这些信息


public struct HeadInfo
{
int m_HeadIndex;

public float m_UpdateRate;
public Vector3 m_PerFrameForce;

public Vector3 m_ObjectMove;
public float m_Weight;
public int m_particleCount;
public int m_jobDataOffset;

public float3 m_RootParentBoneWorldPos;
public quaternion m_RootParentBoneWorldRot;
}

准备完基础数据,就要开始JobSystem 化了

考虑的优化方案是

1.完全展开的并行Particle计算

2.完全展开的Transform结算,之所以Transform要完全展开的原因有2点

1)不展开的话,JobSystem无法将Transform分配给多个Worker 来执行,我猜测是由于在层级的中任意一个Transform发生变化,其子节点的Transform也会发生变化,因此符合Woker中并行优化的逻辑(就是只算自己的)

2)可以优化掉Hierachy层级中额外计算量,Unity不会再计算层级嵌套的世界坐标,毕竟你Job里都算了,何必要Unity再算一遍多余计算量

展开后的Transform就像这样

3.最少量的Transform交互,基本每个DynamicBone组只需要一次Transform交互提供根骨骼的世界坐标即可,其他Particle只是跟随根骨骼

4.如果使用GPU蒙皮,那么连展开Transform这一步骤都不需要了

那么具体的Job实现,我拆分为5个步骤

RootPosApplyJob:IJobParallelForTransform 用来一次性输入根骨骼世界坐标
2.PrepareParticleJob:IJob 用来一次性计算所有Particle此时的世界坐标

3.UpdateParticles1Job:IJobParallelFor

4.UpdateParticle2Job:IJobParallelFor

5.ApplyParticleToTransform:IJobParallelFor 结算,将所有结果应用到Transform

Job依赖执行图

各Job实现
Continue reading

效果图

制作可交互的水体,大致分为三步
1.标记水体碰撞的位置
2.计算水波的传递 通过波动公式,3D或者2D 波动公式都行
3.水面顶点采样波动传递结果计算结果做顶点Y轴偏移

本文参考的波动相关资料
https://en.wikipedia.org/wiki/Wave_equation
https://www.amazon.com/Mathematics-Programming-Computer-Graphics-Third/dp/1435458869 流体 章节
相关公式

根据公式可知波的下次一次传递 z(i,j,k+1) 为 当前波值+上一次波值+周围波值
当前波值 *= (4-8*c^2*t^2/d^2/d^2)/(u*t)
上一次波值 *= (ut-2) / (ut + 2)
四周波值 *= (2c^2t^2/d^2) / (ut + 2)
其中各参数含义为 c 波速, u 粘度, d 波的递进距离, t 为递进时间

ok~ 我们重头开始
首先要建立水面
这里直接用Unity Wiki的轮子的创建平面
https://wiki.unity3d.com/index.php/CreatePlane


这里我们直接创建一个宽10米,长10米,间隔100的平面, 间隔越多,水体的颗粒感越小

对应本文开头描述的三大步骤
创建3个纹理
对应水体碰撞标记,传递,渲染

m_waterWaveMarkTexture = new RenderTexture(WaveTextureResolution, WaveTextureResolution, 0, RenderTextureFormat.Default);
m_waterWaveMarkTexture.name = "m_waterWaveMarkTexture";
m_waveTransmitTexture = new RenderTexture(WaveTextureResolution, WaveTextureResolution, 0, RenderTextureFormat.Default);
m_waveTransmitTexture.name = "m_waveTransmitTexture";
m_prevWaveMarkTexture = new RenderTexture(WaveTextureResolution, WaveTextureResolution, 0, RenderTextureFormat.Default);
m_prevWaveMarkTexture.name = "m_prevWaveMarkTexture";

标记水体碰撞位置

void WaterPlaneCollider()
{
hasHit = false;
if (Input.GetMouseButton(0))
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hitInfo = new RaycastHit();
bool ret = Physics.Raycast(ray.origin, ray.direction, out hitInfo);
if (ret)
{
Vector3 waterPlaneSpacePos = WaterPlane.transform.worldToLocalMatrix * new Vector4(hitInfo.point.x, hitInfo.point.y, hitInfo.point.z, 1);

float dx = (waterPlaneSpacePos.x / WaterPlaneWidth) + 0.5f;
float dy = (waterPlaneSpacePos.z / WaterPlaneLength) + 0.5f;

hitPos.Set(dx, dy);
m_waveMarkParams.Set(dx, dy, WaveRadius * WaveRadius, WaveHeight);

hasHit = true;
}
}
}

由于我们默认Raycast 获取的是碰撞的世界坐标,我们期望的是直接获取到 [0-1] 范围的数值用来映射到uv空间,直接在 m_waterWaveMarkTexture 进行标记, 因此我们乘以一个 world2Local 矩阵变换到本地, 又因为CreatePlane默认创建的Pivot 位于中心,再除以宽高缩放到1区间时,值域落在[-0.5,0.5]上,因此我们还要做 + 0.5偏移

标记水体碰撞Shader

float dx = i.uv.x - _WaveMarkParams.x;
float dy = i.uv.y - _WaveMarkParams.y;

float disSqr = dx * dx + dy * dy;

int hasCol = step(0, _WaveMarkParams.z - disSqr);

float waveValue = DecodeHeight(tex2D(_MainTex, i.uv));

if (hasCol == 1) {
waveValue = _WaveMarkParams.w;
}

根据传入的_WaveMarkParams.xy 跟当前uv 对比,在笔刷范围内的像素标记位默认波高度

波的传递Shader

static const float2 WAVE_DIR[4] = { float2(1, 0), float2(0, 1), float2(-1, 0), float2(0, -1) };

float dx = _WaveTransmitParams.w;

float avgWaveHeight = 0;
for (int s = 0; s < 4; s++) { avgWaveHeight += DecodeHeight(tex2D(_MainTex, i.uv + WAVE_DIR[s] * dx)); } //(2 * c^2 * t^2 / d ^2) / (u * t + 2)*(z(x + dx, y, t) + z(x - dx, y, t) + z(x, y + dy, t) + z(x, y - dy, t); float agWave = _WaveTransmitParams.z * avgWaveHeight; // (4 - 8 * c^2 * t^2 / d^2) / (u * t + 2) float curWave = _WaveTransmitParams.x * DecodeHeight(tex2D(_MainTex, i.uv)); // (u * t - 2) / (u * t + 2) * z(x,y,z, t - dt) 上一次波浪值 t - dt float prevWave = _WaveTransmitParams.y * DecodeHeight(tex2D(_PrevWaveMarkTex, i.uv)); //波衰减 float waveValue = (curWave + prevWave + agWave) * _WaveAtten;

最后就是水体的呈现,因为需要做顶点纹理采样,因此需要至少ES3.0 硬体

v2f vert (appdata v)
{
v2f o;

float4 localPos = v.vertex;
float4 waveTransmit = tex2Dlod(_WaveResult, float4(v.uv, 0, 0));
float waveHeight = DecodeFloatRGBA(waveTransmit);

localPos.y += waveHeight * _WaveScale;

float3 worldPos = mul(unity_ObjectToWorld, localPos);
float3 worldSpaceNormal = mul(unity_ObjectToWorld, v.normal);
float3 worldSpaceViewDir = UnityWorldSpaceViewDir(worldPos);

o.vertex = mul(UNITY_MATRIX_VP, float4(worldPos, 1));
o.uv = v.uv;
o.worldSpaceReflect = reflect(-worldSpaceViewDir, worldSpaceNormal);
return o;
}

github地址
https://github.com/dreamfairy/interactivity-waterplane

Untiy 推出SRP 已经接近一年了,其中官方宣称 LWRP 在2018年年底时已经处于 production ready 既随时可以做产品了,于是改名为URP, 不过 HDRP 还需要2019.4 的到来才能到达完整版。 不过在我看来 URP 还不能说是 production ready 还处于玩具阶段。而且有时候觉得Unity官方对于技术路线偶尔会出现不明确,左右摇摆的情况。比如Unity 2018 新出的Camera.AddCommandBuffer 来做自定义渲染, 这在 Unity 2019 被废除了,取而代之使用 Render Feature /ScriptableRenderPass 来实现,不过这东西也处于实验阶段。

SRP不做任何修改是否可以直接提高项目性能,答案是可以直接减少CPU给GPU准备阶段的性能大约10%左右。无法直接提升GPU的渲染性能,对于不使用任何光照的项目且处于Opengl ES2.0 这类低端机,基本没有任何GPU性能提升。

以目前使用的SRP 有大量Bug 举两个例子
1. [In order to call GetTransformInfoExpectUpToDate, RendererUpdateManager.UpdateAll must be called first.] 莫名的内置渲染错误,无法自己修改。
官方Issue链接 https://issuetracker.unity3d.com/issues/errors-message-at-editor-play?_ga=2.202176470.695125147.1571176891-1511937231.1511185188
2. 使用渲染指令Blit 后,会导致RenderTarget 无法自动恢复原始RenderTarget,需要手动还原SetRenderTarget,这个在之前的CommandBuffer 里都不曾遇到

在项目中期切换到SRP可以直接优化的地方
1.相机Culling优化

https://connect.unity.com/p/unityzhi-zuo-ren-zhuan-chang-unity-aaayou-xi-shen-du-you-hua-zhu-ti-yan-jiang

根据官方优化参考,使用SRP后,可以控制相机 Culling(裁剪)行为,对于项目中有自己实现基于投影器Projector的阴影相机可以复用主相机的 Culling结果, 对于UI上模型RT相机可以不做任何Culling

2.相机 Stack 优化
SRP废弃了多个相机的实现,无法再使用多个相机 (比如我们项目1个GamePlay, 1个HUD, 1个UI相机的。使用官方SRP模板,UI相机背景色会盖住场景内容),原因为

如果只使用1个相机,渲染结果可以直接写入BackBuffer

如果有多个相机,由于第二个相机需要第一个相机结果填充画布后再渲染,因此至少需要一张RenderTexture的临时缓冲,且还需要针对不同的Viewport做裁剪等等,写入backBuffer的时机也会延迟

官方文档废弃Camera Stack原因
https://docs.google.com/document/d/1GDePoHGMngJ-S0Da0Fi0Ky8jPxYkQD5AkVFnoxlknUY/edit

3.UI OverDraw 优化
使用同一个相机绘制UI后,可以考虑给UI添加模板测试,将UI挡住场景的部分,场景可以不被绘制到。

4.UI 批次合并(Opengl 3.0+ Unity2019.2+ with SRP Batcher)
对于场景特效类,基本都无缘SRP batcher 他对Cbuffer的容量有限制
对于UI如果全局自定义Shader可以使用 SRP Batcher 不过目前还是实验阶段。

最后来说下 Camera.AddCommandBuffer 这个功能在 Unity 2019 替换为 ScriptableRenderPass 后如何实现一个XRay
使用 CommandBuffer时仅仅需要 camera.AddCommandBuffer(CameraEvent.AfterForwardOpaque, m_XRayBuffer);
然后再XRayBuffer.drawRenderer(renderer, XrayMat)即可

在2019里 需要创建XrayRenderPassFeature 类来实现
public class XRayRenderPassFeature : ScriptableRendererFeature

ScriptableRendererFeature有2个接口要实现分为
Creata() 创建一个实现具体Xray Pass的接口
AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) 将创角的pass 添加进renderer 队列

在XRayRenderPassFeature 里实现一个 CustomRenderPass : ScriptableRenderPass 来编写具体Xray逻辑
Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor) 准备阶段
Execute(ScriptableRenderContext context, ref RenderingData renderingData) 渲染阶段
FrameCleanup(CommandBuffer cmd) 清理阶段

基本实现都在Configure里

CommandBuffer xraycmd = CommandBufferPool.Get(m_profilerTag);

xraycmd.DrawMesh(m_drawMesh, m_xrayTarget.transform.localToWorldMatrix, m_xrayMaterial);
context.ExecuteCommandBuffer(xraycmd);

CommandBufferPool.Release(xraycmd);
}

大致流程是,Renderer 会根据 pass 的 renderPassEvent 进行和内置其他pass 比如天空盒,点光,深度 等等其他pass 一起sort, 之后分别在渲染前,渲染,渲染后调用接口

补充一个以官方的SRP FPS Demo 基础来实现XRay
git:https://github.com/Unity-Technologies/UniversalRenderingExamples

1.在FpsSetup 预制体里添加刚刚创建的Feature

2.编写一个简单ZTest Greater的Shader 用来绘制被遮挡的部分

[cc lang=”C#”]
Shader “Unlit/XrayShader”
{
SubShader
{
Tags { “RenderType”=”Opaque” “LightMode”=”LightweightForward” }
LOD 100

Pass
{
ZTest greater
offset -1,-1

HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include “Packages/com.unity.render-pipelines.universal/ShaderLibrary/core.hlsl”

struct appdata
{
float4 vertex : POSITION;
};

struct v2f
{
float4 vertex : SV_POSITION;
};

v2f vert (appdata v)
{
v2f o;
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
return o;
}

float4 frag (v2f i) : SV_Target
{
return float4(1,0,0,1);
}
ENDHLSL
}
}
}
[/cc]

3.在场景中放置一个示例Cube,取名为XRayTarget

最后运行游戏

最后 使用自定义 ScriptableRendererFeature 的话,还需要自己编写对应的Editor代码,比之前繁琐许多。

如果自己来编写SRP的话,RenderPassFeature 需要自己维护pass列表来实现, 也可以仅仅去实现自定义的ForwardRenderer,可以减少很多功能的重复造轮子。