伪随机算法系列-03 Value Noise(Lattice Noise)
翻译—-Pseudorandom Noise-Value Noise
如果你觉得这篇教程不错,请去支持原作者
此教程使用的Unity版本为2020.3.6f1
- 建立一个可视化抽象类
- 引入一个通用的噪音生成器
- 生成1D,2D,3D的值噪音
这是伪随机噪声系列教程的第三篇.教大家如何把可视化效果从纯hash生成的方式变成晶格(Lattice)生成的方式.
1 可复用的效果(Reusable Visualization)
可视化效果展示了hash算法是如何基于整数坐标将空间划分为离散的值块.原理是噪声算法使用hash值产生了一种不连续的模式.本教程中我们将专门实现一种值噪声(ValueNoise),使hash块之间更加平滑.此算法会输出一种连续的模式,生成浮点型结果而不是bit型结果.这需要使用一种跟我们现在拥有的可视化效果有点像,但又有些不同的新算法.
我们可以复制一份HashVisualization里的代码并在新类NoiseVisualization里重用.但是这会弄出大量的重复代码.所以我们引入一个新的抽象类Visualization,通过继承来解决这个问题.
1.1 抽象基类Visualization(Abstract Visualization Class)
复制一份HashVisualization类的代码拿到Visualization类中,然后移除掉Job功能和hash算法需要的所有字段.还有hash seed和hash domain.
1 | //using Unity.Burst; |
移除掉OnEnable
和OnDisable
里对应的地方.
1 | void OnEnable() |
然后移除掉Update
中hash job的调度方法和hash buffer的设置方法.
1 | void Update() |
这个类包含所有可视化所需的抽象信息数据,但是不包含算法和字段数据.因此这个类本身是没有任何作用的,也不可以被挂载到Unity的GameObject组件上,应该把它设置成abstract.
1 | public abstract class Visualization : MonoBehaviour { … } |
这样任何人都无法直接创建此类的实例了.
1.2 抽象方法(Abstract Methods)
无论我们怎么使用可视化效果,它必须要支持启用和禁用功能.现在已经有了OnEnable
和OnDisable
函数,但是它们没有包含创建和移除NativeArray,Buffers和任何与有关数据的功能.我们假设把这些工作放到Visualization类中的EnableVisualization
和DisableVisualization
中来完成.这两个方法中并不需要任何代码,所以把它们声明为abstract,就像一个接口.因为此类是抽象类,所以这样声明并省略掉代码部分而不会报错.
1 | abstract void EnableVisualization(); |
当启用一个可视化效果时,需要数据长度以及材质属性块作为参数,所以我们向EnableVisualization
添加下列形参.
1 | abstract void EnableVisualization(int dataLength, MaterialPropertyBlock propertyBlock); |
在OnEnable
中调用一次EnableVisualization
方法.在OnDisable
中调用一次DisableVisualization
方法.
1 | void OnEnable() |
在更新可视化效果的时候,我们还需要做一些工作,不过现在并不能确定是什么.所以先加一个抽象方法UpdateVisualization
,形参为位置点的NativeArray<**float3x4**>,resolution以及Joh的handle.
1 | abstract void UpdateVisualization(NativeArray<float3x4> positions, int resolution, JobHandle handle); |
在Update
中调用此方法,传入一个shapeJobs里的数据作为handle.
1 | //JobHandle handle = shapeJobs[(int)shape](positions, normals, resolution, transform.localToWorldMatrix, default); |
现在必须让具体的实例类重写这些接口来提供他们自己的操作流程.但是目前还不行,因为它们的访问权限是private,我们可以把他们变成public,但是没有必要,因为只有这个类本身会使用这些方法.所以我们把访问权限设置为protected,这样就只有此类本身和它的子类可以使用这些方法了.
1 | protected abstract void EnableVisualization(int dataLength, MaterialPropertyBlock propertyBlock); |
1.3 继承抽象基类(Extending an Abstract Class)
现在修改下HashVisualization类的代码使其继承于Visualization而不是MonoBehaviour,从而继承了所有通用的可视化功能.
1 | public class HashVisualization : Visualization { … } |
移除内部类Shape还有所有HashVisualization里定义的字段,因为已经继承了.
1 | //public enum Shape { Plane, Sphere, Torus } |
把OnEnable
函数改成EnableVisualization
,删掉所有的代码只保留与hash只计算有关的.
1 | void EnableVisualization(int dataLength, MaterialPropertyBlock propertyBlock) |
我们必须使用override关键字明确指出这个方法是重写它继承的基类的,它的访问权限也必须与基类相同.
1 | protected override void EnableVisualization(int dataLength, MaterialPropertyBlock propertyBlock) { … } |
对应的,把OnDisable
改成DisableVisualization
.
1 | protected override void DisableVisualization() |
删掉整个OnValidate
,因为基类中已经有了.
1 | //void OnValidate () { … } |
最后把Update
方法改成UpdateVisualization
,只做调度和计算hash值的工作,然后把输入填入缓冲区.
1 | protected override void UpdateVisualization(NativeArray<float3x4> positions, int resolution, JobHandle handle) |
现在我们的可视化Hash程序依然可以正常工作,只是通用的代码都已经提到Visualization类中了.
1.4 可视化噪声(Visualizing Noise)
现在新建一个继承Visualization的NoiseVisualization类.复制一份HashVisualization的代码,移除掉里面的hashjob功能,把里面所有对hash的引用改成对noise的引用.由于噪声是由浮点数组成,所以需要把NativeArray的类型改为float4.目前的UpdateVisualization
方法只做handle.Complete
和设置噪声缓冲区的操作.此时会产生一个只有0的噪声效果.
1 | //using Unity.Burst; |
噪声的可视化效果需要一个不同的Shader.复制一份HashGPU的代码并改名为NoiseGPU.用noiseBuffer替换hashBuffer,并且直接使用noise的值在ConfigureProcedural
里修正位置.这样做是没有问题的,因为噪声值将在[-1,1]之间.
1 |
|
然后用GetNoiseColor
方法代替GetHashColor
,并且如果是正数就直接返回噪音值来产生一个灰度值.如果是负数我们就返回一个红色值,这样就能轻易区分正负的效果.
1 | float3 GetNoiseColor() |
为噪声创建一个新的ShaderGraph文件或者SurfaceShader,复制一份hashshader的代码进去.如果你是用的surfaceshader你还必须改一下颜色函数的调用.然后创建一个材质参数,并且把整个NoiseVisualization挂载到GameObject上.
我把hash和nosie的可视化功能分别建立在单独的场景中,但你也可以放在同一个场景中,但是只能启用其中一个.
1.5 重写继承的方法(Extension Methods)
一旦创建了计算噪声的Job,我们将有三个地方需要执行向量化的矩阵-向量变换.与其引入TransformPositions
或TransformVectors
的另一个实例,不如让我们把这段代码放在一个通用的地方.最简单的方法是创建一个静态类MathExtensions,它包含一个Shapes.Job类中TransformVectors
方法的公共静态副本.
1 | using Unity.Mathematics; |
现在我们在任何地方使用MathExtensions.TransformVectors(trs,v)
.在使用了using static MathExtensions后,写法可以简化为TransformVectors(trs,v).不过,还有一种方法是将其转换为扩展方法.
扩展方法是一个静态方法,它假装是一个类型的实例方法.通过将this修饰符添加到方法的第一个参数来创建.
1 | public static float4x3 TransformVectors (this float3x4 trs, float4x3 p, float w = 1f) => float4x3(…); |
然后可以在该类型的实例上直接调用该方法,省略它的第一个参数,因此写为trs.TransformVectors(v)
.修改一下Shapes.Job
里对应的地方.
1 | //float4x3 TransformVectors (float3x4 trs, float4x3 p, float w = 1f) => float4x3(…); |
HashVisualization.HashJob
里也做对应的修改.
1 | //float4x3 TransformPositions (float3x4 trs, float4x3 p) => float4x3(…); |
让我们为MathExtensions添加另一个扩展方法Get3x4
,用它来提取float4x4矩阵的float3x4部分.
1 | public static float3x4 Get3x4(this float4x4 m) => float3x4(m.c0.xyz, m.c1.xyz, m.c2.xyz, m.c3.xyz); |
How do extension methods work? If you go deep enough, there are no such things as objects. There is just data, some of which represents information and some of which represents instructions. Objects are an abstraction. When invoking a method on an object what really happens is that the CPU pushes some data—the arguments—on a data stack and then jumps to the relevant instructions. The object on which the method was invoked is just another argument. An extension method makes this explicit.
2 晶格噪声(Lattice Noise)
我们将在本教程中创建的噪声被称为值噪声.这是一种特殊类型的晶格噪声,它基于几何晶格,通常是一个规则的网格.
2.1 泛型噪声生成器(Generic Noise Job)
因为噪声的样子千奇百怪,所以我们将创建一个专用的静态噪声类,就像创建形状生成器时一样.它包含一个接口和一个通用的Job结构类型.在这里,接口是INoise,它定义了一个GetNoise4
函数,该函数利用位置和hash值算出一个向量化float4类型的noise值.
1 | using Unity.Burst; |
该Job类使用位置数据作为输入,计算出噪声并输出,因此还需要一个hash值和域的变换矩阵.在Execute
函数里调用Noise类的GetNoise4
方法,并将转置后的位置数据,以及hash值传递给它.
1 | public struct Job : IJobFor where N : struct, INoise |
最后,在Job中添加一个对应的ScheduleParallel
方法,再添加一个对应Noise的委托函数.
1 | public struct Job<N> : IJobFor where N : struct, INoise |
2.2 Partial Classes
下一步是将lattice noise的代码添加到Noise类中,但不是将它们都放在同一个c#文件里,而是将lattice-specific部分的代码放在一个单独的文件中,以保持代码整洁.这可以通过将Noise类转换为Partial类来实现.这将告诉编译器Noise类的内容被拆分成多个文件.
1 | public static partial class Noise { … } |
为了简单起见,我们先从1D噪声开始.创建一个新的c#文件并将其命名为Noise.Lattice.不过这种命名方式不是强制的,只是为了明确地指明此文件包含了Noise的Lattice部分.我们再次定义partial Noise类,这次添加了一个Lattice1D结构体,现在它总是返回零.
1 | using Unity.Mathematics; |
现在我们可以在NoiseVisualization中创建一个1D的lattice噪声效果了.
1 | … |
2.3 1D噪声(1D Noise)
创建1D噪声的第一步就是用Lattice1D.GetNoise4
函数来计算整数坐标X的hash值,将整数的第一个字节的数据转换为float,再将其范围转换为[−1,1].因此,先floor
第一列的坐标,用于hash计算,再其转换为uint4,接着使用255作为掩码计算出第一个字节的数据并将其转换为浮点值,最后重新计算范围.
1 | public float4 GetNoise4(float4x3 positions, SmallXXHash4 hash) |
这一步就忽略掉了数据的小数部分,只计算了整数部分的hash数据.为了使噪声平滑和连续,我们必须在整数坐标之间做混合处理.晶格点是处于整数位置上的,在这些点之间的小数空间是空的,必须用连续的噪声数据来填满它们.为了完成这个功能,我们需要知道空白空间两端两个点的值.
我们把当前的点命名为p0,加一个单位的点命名为p1.p1的可视化效果跟p0差不多,只是移动了一个单位而已.
1 | int4 p0 = (int4)floor(positions.c0); |
To fill the span between the lattice points we need to combine both values, which means that we’ll have to convert hashes to floating-point values twice. To simplify code that needs bytes or floating-points values let’s add two properties to SmallXXHash4, one to retrieve the first vectorized byte—designated as bytes A—and one to retrieve the same data but converted to a value in the 0–1 range.
为了填充两点之间的空白,我们需要合并这两个值,这意味着必须进行两次hash值转换浮点值.为了简化需要写的代码,让我们向SmallXXHash4添加两个属性,一个用于计算向量化数据的第一个字节,另一个用于把前一个数据转换到[0,1]之间.
1 | public uint4 BytesA => (uint4)this & 255; |
现在我们可以通过上面的方法轻松地得到范围是[0,1]的p0和p1两个点的hash值,然后在GetNoise4
里做如下操作,就能得到[−1,1]范围内两个点之间的平均值.
1 | //float4 v = (uint4)hash.Eat(p0) & 255; |
使用0.5为系数的插值算法也能得到同样的值.但是要记得在减1之前先乘2
1 | return lerp(hash.Eat(p0).Floats01A, hash.Eat(p1).Floats01A, 0.5f) * 2f - 1f; |
最后,为了得到一个基于晶格坐标的,介于p0到p1之间的,连续的插值系数,需要用到的坐标小数部分的数据.可以通过从当前坐标中减去p0来得到,称之为t.
1 | float4 t = positions.c0 - p0; |
让我们将剩余3个字节的提取以及后续0-1计算的函数添加到SmallXXHash4方便以后使用.
1 | public uint4 BytesA => (uint4)this & 255; |
2.4 2D噪声(2D Noise)
现在我们有了连续的1D噪声,尽管它还不是那么的平滑.在考虑优化平滑效果之前,让我们先做一个简单的2D噪音.
当只考虑一个维度时,我们需要跟踪两个晶格点和一个插值系数.让我们为该数据定义一个LatticeSpan4结构体.因为此类是晶格噪声专用的,所以只用private就行了.把它放到Noise.Latticewe文件中.
1 | private struct LatticeSpan4 |
接下来,添加一个静态GetLatticeSpan4
函数,此函数利用一组1D坐标作为参数.
1 | private static LatticeSpan4 GetLatticeSpan4(float4 coordinates) |
接下来简化Lattice1D.GetNoise4
函数的代码.
1 | public float4 GetNoise4(float4x3 positions, SmallXXHash4 hash) |
新建一个Lattice2D类,先复制Lattice1D的代码用.
1 | public struct Lattice1D : INoise { … } |
修改NoiseVisualization.UpdateVisualization
里的代码,将泛型类型数据改为Lattice2D.
1 | Job<Lattice2D>.ScheduleParallel(positions, noise, seed, domain, resolution, handle).Complete(); |
现在修改一下Lattice2D.GetNoise4
函数,同时处理Z方向上的lattice数据,然后用z变量里的数据来计算噪音.
1 | public float4 GetNoise4(float4x3 positions, SmallXXHash4 hash) |
这将会产生和以前一样的效果,不过现在是在Z轴上而不是X轴上.我们也可以用Y轴,但这在XZ平面上看不到效果,除非我们旋转一下域的方向.
为了创建一个依赖于X轴和Z轴的效果,我们必须同时考虑这两个维度,于是可以得到一个方形的晶格,方形的四个角上有各自的hash值.
让我们先计算X轴上的hash值,成为h0和h1.
1 | LatticeSpan4 x = GetLatticeSpan4(positions.c0); |
然后将Z喂给h0,而不是原始hash值.
1 | return lerp(h0.Eat(z.p0).Floats01A, h0.Eat(z.p1).Floats01A, z.t) * 2f - 1f; |
正如看到的那样,我们现在的噪声效果是基于单独的X轴以及Z轴上两点的插值计算得来的,它沿着Z轴是带状连续,但是沿着X轴是不连续的.为了让整个图形连续,我们必须在X轴上的h0和h1之间进行插值.所以我们要对两个线性插值进行线性插值,这被成为双线性插值算法.
1 | return lerp( |
2.5 平滑噪声(Smooth Noise)
虽然我们有一个连续的2D图形,但它还不是那么的平滑.晶格点之间是平直的线段,因此在晶格方块的边缘采样计算时,方向会发生突然的变化.为了使其平滑,我们需要考虑噪声的变化率.假设我们有一个函数,那么它的一阶导数就是它的变化率.当线性插值的函数曲线是一条直线时,所以它的导数是一个常数.此外还有二阶导数,它是一阶导数的导数.你可以把它想象成曲率的变化率,或者噪声的加速度.对于线性插值来说,它的二阶导数总是为0.
例如,在下图中,一维噪声用黑色实线表示,其一阶导数为橙色虚线,二阶导数为紫色虚线.这里为了方便观察将导数整体除以了6.可以看到在晶格点的中间,橙色线断开了,说明噪声在此点突然改变了方向.
在GetLatticeSpan4
函数中用smoothstep
函数平滑插值系数.
1 | span.t = coordinates - points; |
使用smoothstep
函数是可行的,即$-3t^2−2t^3$,它在输入值是0和1的时候,这两点的切线是水平的(也就是变化率为0).这意味着它的一阶导数$-6t−6t^2$在两端(0和1)都是0,所以它是C1连续,但是我们的线性插值只是C0连续.然而,这对于它的二阶导数$-6−12t$来说是不对的,所以它不是C2连续.
如何求函数导数 记住一个概念,即$ax^b$的导数是$abx^{b-1}$,常数的导数是0.这也同样适用于函数的每一个部分.
比如,$4x^3+5x−2$的导数是$12x^2+5$.
2.6 二阶连续性(Second-Order Continuity)
虽然smoothstep
是C1-连续的,但这个函数的导数是不连续的.这意味着它的变化率在晶格的两端是不同的.在采用基于点生成的可视化效果时,缺陷不是很明显,但当噪声用于计算光滑的网格表面或法线贴图时,这种不连续就会像折痕一样非常明显.为了避免这种情况,必须更进一步,找到一个C2-连续的函数,因此我们可以使用$6t^5−15t^4+10t^3$.它的一阶导数是$30t^4-60t^3+30t^2$,它的二阶导数是$120t^3-180t^2+60t$.当输入为0和1时,结果都为零.
修改一下GetLatticeSpan4
函数里的代码,我们可以重新组织一下函数,将其变为$ttt(t(t6−15)+10)$.
1 | span.t = coordinates - points; |
3 3D噪声(3D Noise)
最后我们添加3D噪声.
复制一份Lattice2D的代码来创建Lattice3D,并计算Y轴的晶格数据.然后还要跟踪XY平面上的四个点的hash值.
1 | public struct Lattice3D : INoise |
修改返回值,沿X轴在YZ平面上进行两个插值计算.
1 | return lerp( |
最后,创建添加一个静态数组和一个滑块属性来做动态噪声选择器.
1 | static ScheduleDelegate[] noiseJobs = |
现在我们可以方便地看到不同的噪声函数所生产的效果,以及它们之间的区别.在使用球体等3D形状时是最明显的.
域缩放19
伪随机算法系列-03 Value Noise(Lattice Noise)
https://tzkt623.github.io/2022/01/11/Pseudorandom Noise-03 Value Noise/