Tech · ArtShaderUnityComputerGraphic

「图形学」Impostor cloud 骗子云学习记录 光照、impostor二

by Ayse, 2022-06-30


CLOUD

Kapture 2022-06-30 at 15.50.44.gif Kapture 2022-06-30 at 15.23.48.gif 虽然说之前已经在shadertoy里面实现过一遍云了,但是没有特别的深印象,这次做云更加深入理解了当时的ray marching的步骤,也对云渲染有了更深的理解,这里就不标unity了,因为更偏向图形和渲染,那就开个新tag图形学吧!先来说说云的光照问题解决。


一篇论文

早在2001年,Mark J. Harris and Anselmo Lastra就实现了这个过程。论文在文末有引用可以自行跳转。然后我仔细得看完之后,发现虽然让我对云的渲染有了更深入的理解,但并没有解决marching性能的问题,我要做的是骗子云,和这个也没啥关系。下面展开讲讲这篇文章。

这是一张很牛的文章截图。 51.png 经过云到眼睛里光的总量是from direction l that is not absorbed by intervening particles, plus light scattered to P from other particles。翻译过来就是直射光和散射光,之前的Raymarching也提到了这件事(想到面试的时候被问到这个居然没答上来也是悔恨万分)。吸收较为简单,根据Beer-Lambert公式,也就是这个公式里面的光学深度,它是最重要的体积内部的传播公式,e^-∫τ dt。当然在这里需要离散化计算积分。 5.png

云进入到我们眼睛里其实是一个多次散射的过程,这个图显示的就是单次和多次散射的区别(single/multiple scattering model)当然云的计算就得采用后者会更加真实多次散射就离不开相位函数,这是一个形容光在在这个地方出现的概率——这里我们用的是瑞利散射的trick(这个是用来估算空气散射的)。散射的光有3/16Π (1 + cos^2 θ)概率出现(俺也不知道为什么这里写的3/4,因为伪代码里面也除多了一个4Π。当然这个公式实际上更复杂,和波长都有关系,不过在实时计算中可以理解为一个常数,推导见大佬,跳转。所以我们计算云的光照的时候两个部分,散射可以理解为就是光的入射——能量损耗——出射计算相位函数。透射就是背景光(图像)被视线方向厚度阻挡的程度(其实也是散射只是这里因为本来就弱,其他方向带来的几乎没有)。 52.png 在这里我就已经觉得不对了——为什么imposter还是raymarching感觉的离散化?然后看到了伪代码。大概的过程就是光拍一张厚度作为离线,人眼拍一张厚度作为实时的,拿出像素对应到的那部分粒子,再根据距离排序这些粒子,然后开始类似MARCH的行为!!(只是这里是each particle)整个计算分为两部分。预计算和实时计算,它的impostor只是说它用了那个面片代表一团云的trick技术,而非我们现在说的拍一堆角度的那个。它更像是我们普遍意义的billboard。 53.png

预计算部分

第一段是计算有云的那部分像素单独拎出来做march,把云作为粒子计算,一个粒子只作为一次散射,然后每一次散射的color都会加入到最后的成像中。Albedo τ light * solid angle(感觉是用类似视锥角度来找到需要bake的那些像素。)然后赋值给 color,alpha存入τ,(这个如果不均匀可以实现内部的密度变化?)

实时计算部分

还是Sort完之后计算phase之后的光照color(有角度的存在)然后一个个用alpha blend上这些算出结果

问题:显然每次变换光源都要重新计算。每次用imposter记录下来预计算的灯光信息。 文章后面的就完全是一些其他的trick了,包括穿插问题,impostor的性能优化之类的,(比如远的隔帧渲染)就不多赘述,或许到后面我有时间实现体积云的时候抄抄大表哥的优化策略。显然这个方法并不能做我需要的“低消耗billboard云"。


光照解决方案

当然,在这里,我们的骗子云似乎还没有找到合适的光照解决方案,然后我看到了一个叫六向光照图的东西,就是给这团云拍6向光照,然后使用的时候混合。

To render this we simply render lights and shadows from a Light that’s tangently positioned to the camera. You can do it all in 1 pass if you are a Houdini guru but to put it simply you can do it in 2 passes: Red Light Top + Green Light Bottom, render. Red light Right + Green Light Left, render. Combine them in photoshop in a single RGBA texture. From there, all you need is to unpack it and mix it in the shader. Here’s the shader code I am currently using:

到这里,很trick,但是也费,如果我用的16pic的图,我每一个角度都用六向光图来解决么?这似乎在内存上就不太合理,如果只是一个公告牌,那确实没问题。但问题是现在我们还会飞,云的上面得看到。后来想着反正我都有法线有深度,那就按正常trick云来吧。在知乎上看到了杨超大佬分享的文章,遗憾在光照那一part被删掉了(https://zhuanlan.zhihu.com/p/386314798)于是只好自己开始乱试。看到了乐乐女神在知乎的一篇回答摸瓜到了[The Witness做的云光照]8然后大刀阔斧改了一下——本来是一个插片云的方案,加入了次表面的背光常数。最终勉强算是把光照解决得还行。

//float noise = tex2D(_Noisemap, i.uvs + _Time.y);
float lthick = baseTex.a ;
float thickness = 1 - exp(-5 * lthick );

color.a = saturate(thickness * 1.2 * dis);  ;

float a = 0.33;
float edgeatten =10;

float forward_scattering =  exp(-7 * lthick );
forward_scattering = pow(forward_scattering, saturate(_atten * (0.95 + dot(viewdir, lightdir))))  ;

float3 H = lightdir + Normal * a;
float phaselight = (1 + VL * VL );
float sss = saturate(dot(viewdir,-H));
float spe = pow(sss, edgeatten) * (1.1- thickness);

//float3 ambient =pow ((1-lthick),0.2 )* 0.3 ;  
float3 backlight = spe* 1.3 * _LightColor0 ;

forward_scattering *=  saturate(1.45 - abs(dot(Normal,lightdir))) ;

float3 directlight = NL * _LightColor0 * 0.8 ;
//float3 scatter = _TintColor * smoothstep(pow(1-thickness,0.6),0,0.05) / 4 / 3.14 * 4;

//color.rgb = 0.25 * _Ambientcolor * (1.5-NL) + forward_scattering * 0.9 ;
color.rgb = forward_scattering + directlight + 0.45 * _Ambientcolor * (1.5-NL)*(1- forward_scattering) + backlight;

Impostor计算方法

Imposter的计算方法在上一篇文章里面讲过了具体的数学细节,可以到博客里去找一下。上次提到了ONV,核心原因是这是最好的impostor办法,因为如果直接的经纬线会导致球上部的精度下降,所以Octahedron是很好的存图的办法。最终这个过程基本的思路就是找到三张图,然后根据视角和对应角度的差值来混合三张图,最后加入一个视差映射来进行对应每一张图的变形拉伸。思路代码: 大概的代码中文翻译过来解释就是:

--
1、首先修正一个大uv称为grid,找到viewdir对应的uv,
2、OCN找到采样向量以及周围最近的三个frame,然后重新根据frame的整数uv重构出法线(逆OCN),
3、对于任一texcoord求到摄像机和单位球面的小三角的交点,先求出小菱形的x和z在模型空间的向量,然后算出交点的uv( virtual plane uv)。
4、得到了三组uv,和三组frame在x和z方向的向量。存入frame
--
1、根据三组uvfrac 得到grid,floor得到frame,并求到新的frame(整数uv),和原来的uv(个体uv)相加,得到可以在图上准确采样的uv,三次结合视差对图进行采样,然后混合权重。
--

贴一个主要函数的代码

inline void OctaImpostorVertex( inout ImposterData imp )
{
    // Inputs
    float2 uvOffset = _AI_SizeOffset.zw;
    float parallax = -_Parallax; // check sign later
    float UVscale = _ImpostorSize;
    float framesXY = _Frames;
    float prevFrame = framesXY - 1;
    float3 fractions = 1.0 / float3( framesXY, prevFrame, UVscale );
    float fractionsFrame = fractions.x;
    float fractionsPrevFrame = fractions.y;
    float fractionsUVscale = fractions.z;

    // Basic data
    float3 worldOrigin = 0;
    float4 perspective = float4( 0, 0, 0, 1 );

    //float3 worldCameraPos = worldOrigin + mul( UNITY_MATRIX_I_V, perspective ).xyz;
    float3 worldCameraPos = _WorldSpaceCameraPos;
    float3 objectCameraPosition = mul( ai_WorldToObject, float4( worldCameraPos, 1 ) ).xyz - _Offset.xyz; //ray origin
    float3 objectCameraDirection = normalize( objectCameraPosition );

    // Create orthogonal vectors to define the billboard
    float3 upVector = float3( 0,1,0 );
    float3 objectHorizontalVector = normalize( cross( objectCameraDirection, upVector ) );
    float3 objectVerticalVector = cross( objectHorizontalVector, objectCameraDirection );

    // Billboard
    float2 uvExpansion = imp.vertex.xy;//obj space,本来平均分的四个象限顶点就会被变换到billboard的位置
    float3 billboard = objectHorizontalVector * uvExpansion.x + objectVerticalVector * uvExpansion.y;

    float3 localDir = billboard - objectCameraPosition; // ray direction 后续相当于插值为任意的ray

    // Octahedron Frame
    float2 frameOcta = VectortoOctahedron( objectCameraDirection.xzy ) * 0.5 + 0.5;

    // Setup for octahedron
    float2 prevOctaFrame = frameOcta * prevFrame;//frame的具体数字
    float2 baseOctaFrame = floor( prevOctaFrame );//frame的整数
    float2 fractionOctaFrame = ( baseOctaFrame * fractionsFrame );//整数frame在整张贴图的uv位置(归零)

    // Octa 1
    float2 octaFrame1 = ( baseOctaFrame * fractionsPrevFrame ) * 2.0 - 1.0;//将uv重新映射回-1到1
    float3 octa1WorldY = OctahedronToVector( octaFrame1 ).xzy;//重构回世界的向量,并且交换zy轴?? 或者我可以理解为叉乘么?
    float3 octa1LocalY;
    float2 uvFrame1;
    RayPlaneIntersectionUV( octa1WorldY, objectCameraPosition, localDir, /*inout*/ uvFrame1, /*inout*/ octa1LocalY );
    //因为normal不是相机空间完整的y(有3frame)所以这里localy是octa的camera space normal是乘上了parallax,得到的就是采样原图的基础偏移向量parallax1
    //但这里的parallax不是针对pom的,而是针对frame采样上的
    float2 uvParallax1 = octa1LocalY.xy * fractionsFrame * parallax / octa1LocalY.z; //  octa1LocalY.xy = viewDir.xy / viewDir.z    
    uvFrame1 = ( uvFrame1 * fractionsUVscale + 0.5 ) * fractionsFrame + fractionOctaFrame;// for converting the parallax into 0-1 (originally -0.5-0.5) then find the all count
    imp.uvsFrame1 = float4( uvParallax1, uvFrame1) - float4( 0, 0, uvOffset );

    // Octa 2
    float2 fractPrevOctaFrame = frac( prevOctaFrame );//frame的小数,是具体uv
    float2 cornerDifference = lerp( float2( 0,1 ) , float2( 1,0 ) , saturate( ceil( ( fractPrevOctaFrame.x - fractPrevOctaFrame.y ) ) ));
    float2 octaFrame2 = ( ( baseOctaFrame + cornerDifference ) * fractionsPrevFrame ) * 2.0 - 1.0;
    float3 octa2WorldY = OctahedronToVector( octaFrame2 ).xzy;
    float3 octa2LocalY;
    float2 uvFrame2;
    RayPlaneIntersectionUV( octa2WorldY, objectCameraPosition, localDir, /*inout*/ uvFrame2, /*inout*/ octa2LocalY );

    float2 uvParallax2 = octa2LocalY.xy * fractionsFrame * parallax / octa2LocalY.z;
    uvFrame2 = ( uvFrame2 * fractionsUVscale + 0.5 ) * fractionsFrame + ( ( cornerDifference * fractionsFrame ) + fractionOctaFrame );
    imp.uvsFrame2 = float4( uvParallax2, uvFrame2) - float4( 0, 0, uvOffset );

    // Octa 3
    float2 octaFrame3 = ( ( baseOctaFrame + 1 ) * fractionsPrevFrame  ) * 2.0 - 1.0;
    float3 octa3WorldY = OctahedronToVector( octaFrame3 ).xzy;
    float3 octa3LocalY;
    float2 uvFrame3;
    RayPlaneIntersectionUV( octa3WorldY, objectCameraPosition, localDir, /*inout*/ uvFrame3, /*inout*/ octa3LocalY );

    float2 uvParallax3 = octa3LocalY.xy * fractionsFrame * parallax / octa3LocalY.z;
    uvFrame3 = ( uvFrame3 * fractionsUVscale + 0.5 ) * fractionsFrame + ( fractionOctaFrame + fractionsFrame );
    imp.uvsFrame3 = float4( uvParallax3, uvFrame3) - float4( 0, 0, uvOffset );

    imp.octaFrame = 0;
    imp.octaFrame.xy = prevOctaFrame;
    imp.vertex.xyz = billboard + _Offset.xyz;
    imp.normal.xyz = objectCameraDirection;

    imp.viewPos.xyz = UnityObjectToViewPos( imp.vertex.xyz );
}

然后在frag里面采样的步骤可以用下面的

inline void OctaImpostorFragment(in ImposterData imp,inout half3 Normal, inout float4 clipPos, inout float3 worldPos,inout half4 baseTex )
{
    float depthBias = -1.0;
    float textureBias = _TextureBias;

    // Weights
    float2 fraction = frac( imp.octaFrame.xy );
    float2 invFraction = 1 - fraction;
    float3 weights;
    weights.x = min( invFraction.x, invFraction.y );
    weights.y = abs( fraction.x - fraction.y );
    weights.z = min( fraction.x, fraction.y );

    //using zw to sample the depth,the real pom here
    //0-1 ~ -0.5 0.5
    /*
    float4 parallaxSample1 = tex2Dbias( _Normals, float4( imp.uvsFrame1.zw, 0, depthBias) );
    float2 parallax1 =  (( 0.5 - parallaxSample1.a ) * imp.uvsFrame1.xy ) + imp.uvsFrame1.zw;
    float4 parallaxSample2 = tex2Dbias( _Normals, float4( imp.uvsFrame2.zw, 0, depthBias) );
    float2 parallax2 = ( ( 0.5 - parallaxSample2.a ) * imp.uvsFrame2.xy ) + imp.uvsFrame2.zw;
    float4 parallaxSample3 = tex2Dbias( _Normals, float4( imp.uvsFrame3.zw, 0, depthBias) );
    float2 parallax3 = ( ( 0.5 - parallaxSample3.a ) * imp.uvsFrame3.xy ) + imp.uvsFrame3.zw;
    */
    float depth1, depth2, depth3;
    float2 parallax1 = ParallaxMapping(imp.uvsFrame1, depth1);
    float2 parallax2 = ParallaxMapping(imp.uvsFrame2, depth2);
    float2 parallax3 = ParallaxMapping(imp.uvsFrame3, depth3);

    // albedo alpha
    float4 albedo1 = tex2Dbias( _Albedo, float4( parallax1, 0, textureBias) );
    float4 albedo2 = tex2Dbias( _Albedo, float4( parallax2, 0, textureBias) );
    float4 albedo3 = tex2Dbias( _Albedo, float4( parallax3, 0, textureBias) );
    float4 blendedAlbedo = albedo1 * weights.x + albedo2 * weights.y + albedo3 * weights.z;
    baseTex.rgb = blendedAlbedo.rgb;
    // early clip
    baseTex.a = saturate( blendedAlbedo.r - _ClipMask);

    // normal depth
    float4 normals1 = tex2Dbias( _Normals, float4( parallax1, 0, textureBias) );
    float4 normals2 = tex2Dbias( _Normals, float4( parallax2, 0, textureBias) );
    float4 normals3 = tex2Dbias( _Normals, float4( parallax3, 0, textureBias) );
    float4 blendedNormal = normals1 * weights.x  + normals2 * weights.y + normals3 * weights.z;

    //float3 localNormal = blendedNormal.rgb * 2.0 - 1.0;
    //localNormal = float3(localNormal.x,localNormal.y,localNormal.z);
    float3 localNormal = blendedNormal.rgb
    ;
    //float3 worldNormal = UnityObjectToWorldNormal( localNormal);
    Normal = localNormal;

    float3 viewPos = imp.viewPos.xyz;
    float depthOffset = ( ( depth1 * weights.x + depth2 * weights.y + depth3 * weights.z ) - 0.5 /** 2.0 - 1.0*/ ) /** 0.5*/ * _DepthSize * length( ai_ObjectToWorld[ 2 ].xyz );

    // else add offset normally
    viewPos.z += depthOffset;
    worldPos = mul( UNITY_MATRIX_I_V, float4( viewPos.xyz, 1 ) ).xyz;
    clipPos = mul( UNITY_MATRIX_P, float4( viewPos, 1 ) );
    clipPos.xyz /= clipPos.w;
    if( UNITY_NEAR_CLIP_VALUE < 0 )
        clipPos = clipPos * 0.5 + 0.5;
}

视差所需要的深度图可以存在Normal的A通道里面。一开始我认为抖动是由于我的视差不到位产生的,后来发现并不是,我从原来单次采样的视差算法变成了steep pararllax 多采样了五六次,最终抖动减缓的效果依然有限。Houdini的impostor相机工具为我们提供了很便捷的图片生成方式,但是需要注意yz轴在unity里面需要调整一下。

两张imposter的资产 WechatIMG664.png WechatIMG663.png


Kapture 2022-06-30 at 15.23.48.gif 然后这一朵还算流畅的云,就通过这两张图实现了!当然,这里是有硬伤的,就是云不是树,异质化太强,而这个只能做有限款的云,而且在相交的地方很难融合,当然如果用延迟渲染拿gbuffer里面的图做点什么处理或许有可能实现,但是很麻烦,导致如果是用这个渲染的天空其实还挺出戏的,都是一团团的,然后就是图的分辨率限制了图的精细程度,现在一张2048的图每一个frame长边只有不到100像素,再多也就两百左右的像素,所以精度很低,impostor的原生问题是靠近看了之后就会抖动严重,而且用这个方法去渲染边边的动态也不好搞,加了效果很一般。那么这团云能干嘛呢?我觉得主要是可以自定义形状并且不像billboard那么固定,可能比较适合远山又不是那么远的山的那团特殊的云?或者无视抖动的土地云?

WechatIMG665.png


上面的两个图都是没开任何后处理的raw效果,开了可能更好一点。不过想起来的时候已经录完了,算了就这样吧~ 最后看到了杨超大佬在知乎发的那个云在游戏中的成品应用,吃鸡游戏的低空云以及荒野的召唤里的Sprite云,感觉就是:imposter干啥,完全没必要。。(上面的那个shader也稍微参考了一下超哥翻译的代码)。再抬头看看天空的云,多数时候,这天上的云还真的没有体积感——面片挺好的。


参考

https://realtimevfx.com/t/smoke-lighting-and-texture-re-usability-in-skull-bones/5339 Mark J. Harris and Anselmo Lastra, Real-Time Cloud Rendering. Computer Graphics Forum (Eurographics 2001 Proceedings), 20(3):76-84, September2001. [PDF] http://ma-yidong.com/2020/12/27/game-art-trick-light-field-and-imposter/ 以及偶然发现了一个和我一样转行的学长的个人博客,他也研究了云哈哈。他还研究了球谐函数来实现存储。 https://lab.uwa4d.com/lab/5b3e2362d6d8c0171a943d87 大佬的开源impostor算法源码 Unity asset store Amplify impostor

作者: Ayse

2024 © typecho & elise