本篇讨论UnityGraphicsPrograming第一册第十章内容ProjectionSpray。

▲10.1 首先
大家好,我是すぎのひろのり!很遗憾的是,他这次不在。
临近截稿日的某一天,我问道“すぎ亲,文章写了没?”。
“啊!”,他回复道。看来完全忘掉了的样子。
最近他很忙的样子,机会难得,我想介绍一下他的作品,就简单的为他代笔一下。

▲10.1.1 ProjectionSpray
すぎ很积极的在Github上公开自己的作品,里面我个人觉得很有趣的是这个:
https://github.com/sugi-cho/Unity-ProjectionSpray-v2
向3D模型喷射喷雾,实现上色。

喷雾从喷雾设备中喷出,凃到模型的身体表面。令人感到神奇。

可以实现Stencil一样的过滤。

▲10.2 总结
以后有机会的话,一定要请すぎ详细解说一下。
すぎ在面试的时候说过,感觉和自己很相似的同事,仲田先生的仓库中,也有很多优秀的代码,值得推荐。
https://github.com/nobnak
失礼了。

▲00_viewNormal
既然真正的作者没写教学文章,代笔的作者也只是简单的表述了项目的意图,那么只能从工程代码开始看起了,原作者准备了6个场景。
00_viewNormal这个场景中包含了一个显示World法线方向的shader。
画面中的模型看起来,佛像正面绿色、黄色、红色、黑色都有,没有什么特别的。

▲01_pointLight
在脚本PointLightComponent.cs中:
为模型的材质提供3个参数:_LitPos _Intencity _LitCol
模仿了点光源组件的效果,提供光源坐标、光源强度、光源颜色。

在simple-pointLight.shader中:
简单计算漫反射颜色,简单处理了点光源的衰弱。

▲02_spotLight
在脚本SpotLightComponent.cs中:
为模型材质提供了6个参数,除了Spot光源的位置、强度、颜色,还有Spot光源的WorldToLight矩阵、Clip矩阵、Cookie。虽然挂了一个Spot Light组件,但是shader中并未使用系统默认参数,所以这个组件不会生效。
Spot光源相比Point光源要更复杂一些,除了会衰减外,还有明显的光照朝向,而Cookie贴图可以产生过滤效果,就好比从窗户射进来的光线会在墙上留下窗栏的影子。
在simple-spotLight.shader中:
除了类似于Point光源的衰减和漫反射光照,还有Cookie采样、有效区域判定。
将Spot光源看做相机,转移到Spot空间,再进行投影矩阵计算,使有效顶点约束在指定范围
var projMatrix = Matrix4x4.Perspective(angle, 1f, 0f, range);
near为0导致变换的位移为0,aspect为1意味着angle内的顶点都会缩放进一个正方形。
half2 litUv = projPos.xy / projPos.z;
直接将Z坐标作为W分量相除,在near为0的特殊情况,得到NDC坐标。
这样计算Spot光源光照的过程挺简单优美。

▲03_spotLight-withShadow
这个场景中实现了使用自定义的RT绘制阴影,摆脱渲染管线的束缚。
在传统的阴影渲染流程中,将相机位置移动到光源位置渲染出一个深度图,称为ShadowMap。
着色shader中对ShadowMap的采样可以判断当前位置是否在阴影内,以及阴影衰减。
本例中创建了一个enabled属性为false的Camera,用于处理投影矩阵和ShadowMap绘制Pass:
Camera.targetTexture = depthOutput; //指定渲染目标
Camera.SetReplacementShader(depthRenderShader, "RenderType");
Camera.Render(); //执行一次相机的渲染循环
Camera.SetReplacementShader或者Camera.RenderWithShader使用shader替换功能:
1.如果replacementTag为空,指定shader中的Subshader直接生效。
2.如果replacementTag不为空,将对比Tag值。不符合Tag和Tag值的Object将不被渲染。

本例中用到了阴影Pass,并在着色Pass中采样ShadowMap
o.depth = abs(UnityObjectToViewPos(pos).z); //使用光源空间坐标的Z值写入深度
half lightDepth = tex2D(_LitDepth, litUv).r; //采样shader
atten *= 1.0 - saturate(10 * abs(lightSpacePos.z) - 10 * lightDepth);
这里阴影Pass的输出为RFloat,单个32位浮点数,可保存超过1的值。

▲00_shouUv2
本例用到了一个UV展开Shader,返回UV的xy分量作为Clip空间位置和颜色输出。
我们希望渲染出来的结果表现的与贴图上下一致,所以需要在DX平台反转UV的Y轴。
例如:UV位置为(0, 0)的点,在DX中对应Texture的左上角,当我们用这个位置进行采样时,Unity已经内置帮我们处理了Texture的翻转,所以结果不会出错。但是当我们要将位置发送到屏幕上时(对UV空间进行写入),需要手动处理UV的翻转,使(0, 0)对应(0,1)这个位置。

▲01_spotDraw
最后一个场景,将揭开喷雾实现的真相。
本例中可被喷雾的模型采用的shader采样_MainTex获得颜色值,但是_MainTex使用自定义的RenderTexture赋值,所以绘制相关的信息其实是被存储到_MainTex中。

在Drawable.cs中为单个物体的绘制逻辑:
Graphics.DrawMeshNow(mesh, transform.localToWorldMatrix);
对_MainTex进行DrawMeshNow绘制,基于上一例中屏幕上位置与UV的对应关系。
这个绘制过程中,我们要指定绘制的范围,从而实现一帧喷雾的效果。
fillCrack材质则使Graphics.Blit实现一个可选的后处理效果,例如运动模糊。
Graphics.CopyTexture类似于Graphics.Blit的无材质参数模式,但是没有drawcall。

DrawingController.cs和SpotDrawer.cs中控制渲染流程:
SpotDrawer中并没有Start/Update类型的逻辑,单纯提供一个spot光源的计算逻辑。
DrawingController类中提供两个SpotDrawer实例,分别用于渲染常规光照和模拟喷雾。
spot.UpdateDrawingMat(); //执行一次ShadowMap绘制循环,并设置spot光源渲染相关参数。
pinSpot.transform.position = cam.transform.position;
pinSpot.transform.LookAt(pos); //设置喷雾的起点和朝向
pinSpot.UpdateDrawingMat(); //再执行一次ShadowMap绘制循环,叠加到上次的绘制。
在本例中,spot光源和喷雾设备并无明显的区别:
喷雾设备的起点和朝向跟随主相机变化,模拟出人在手持设备的体验;
喷雾设备的FOV很小,光照强度很高,能使一小块区域很亮;
spot光源和喷雾设备都将包括阴影在内的效果叠加渲染到pingPongRts[0]中

最后的疑问都聚集到Hidden_SpotDrawer这个材质:
vertex方法中,输出UV坐标到SV_POSITION;还提供uv、World坐标、World法线;
fragment方法中,计算atten、采样Cookie、采样阴影、采样_MainTex;
i.uv.y = 1 - i.uv.y; //采样操作并不需要Texture上下翻转,既然翻转了一次就再来一次。
half4 col = tex2D(_MainTex, i.uv);
col.rgb = lerp(col.rgb, _Color.rgb, saturate(col.a * _Emission * atten * cookie));
col来自于上一次shader计算的结果,表示延续使用旧结果,初始值需逐物体设置。
_Color则是喷雾的颜色,表示喷雾效果生效,覆盖旧结果。

有以下几个因素决定一个点的作色:
col.a:渲染过一次以后都变成了1,可看做固定值。
_Emission:在赋值时与光照强度有关,强度为20的喷雾使结果靠近_Color。
atten:阴影区域结果保持不变,使对阴影区域的喷涂失效。
cookie:非光照区域结果保持不变,使喷涂范围仅限于spot光照范围以内。
整个工程的主要逻辑到此结束。

总结:
作者灵活运用了点光源的机制,而不仅仅是将点光源用于辅助光源。
精确控制对Texture的写入逻辑,可以过滤掉不想要的数据,实现假设的用途。


关注成长,注重因果。