RhinoFreak 发表于 2022-1-5 21:15

体素引擎,来自元宇宙的基本问题2·实现路径

上一篇文章中,我介绍了体素的价值,并且自己尝试着剔除多余的三角面进行渲染,但是做完之后一直感觉自己是野路子,因为渲染确实成功了,但我都渲染方式对编辑操作并不友好,特别是在对比MagicaVoxel的一些场景后,更加疑惑到底他是如何做的那么快的。例如MagicVoxel最大支持256^3这么多的体素(去年刚刚提升到这一规模),然后可以生成一种随机的网格:


对于这种网格,大量的面会保留下来,instancing的数量惊人,然后在此基础上,支持resize:


几乎无延时地缩减整个尺寸,如果用我曾经使用的剔除方法,这时候上表面都无法构成立方体,全部都是镂空的。
带着这样的问题,我开始尝试搜索magicaVoxel背后的方法,无奈这是一款闭源的软件,找到了一款C写的Goxel,但是C的代码几乎看不清结构,最关键的是,这款尝试模仿MagicaVoxel的软件只支持32^3这么多的体素,在这个规模下很多事情都会容易很多。
在我苦思冥想之时,甚至开始怀疑上面这个场景用的是Cone Tracing,但后来翻看了作者在Twitter上的一些内容,感觉ConeTracing只是他曾经实验的一种方案而已,并且实际上SVO能够支持的体素规模也大部分在2000^3以上,256^3这样的规模,应该还是可以通过传统光栅化的路径解决的。
PolyVox

在知乎,Reddit上检索一圈之后,终于找到了解决方案。看到了一个06年就诞生的远古项目:
我不知道MagicaVoxel的作者是不是有参考过(毕竟他对于开源这件事总是闭口不谈),但从时间来看,2010年诞生的MagicaVoxel应该了解这方面的内容。最关键的是,这一远古项目的作者对体素渲染这件事做了很多的普及工作,并且在Game Engine Gems上介绍过自己的工作《Volumetric Representation of Virtual Environments》:
并且作者到现在为止还在体素渲染上做一些事情,这个后面再说。
这套代码的主要思想其实就是表面提取,有一系列的文章进行过介绍:
在数据结构上,采用了如图的形式:


立方体体素可以被切分为8个block,但是这些block只是block data的引用计数,因为block是可以通过材质共享的,因此在网格提取的时候,也要按照相同材质进行合并。从代码上看,作者提供了PagedVolume和RawVolume两种类型,前者就是分块,后者则是统一存储,他建议只有当规模遇到瓶颈的时候,再去考虑分块的做法,因为采用了模板的方式,所以切换底层数据结构对用户是无感的。在CubicSurfaceExtractor当中分为以下几步:
网格提取

对六个面都提取网格,这一步和我上一篇文章的做法如出一辙:
// X
if (isQuadNeeded(currentVoxel, negXVoxel, material))
{
        uint32_t v0 = addVertex(regX, regY, regZ, material, m_previousSliceVertices, result);
        uint32_t v1 = addVertex(regX, regY, regZ + 1, material, m_currentSliceVertices, result);
      uint32_t v2 = addVertex(regX, regY + 1, regZ + 1, material, m_currentSliceVertices, result);
        uint32_t v3 = addVertex(regX, regY + 1, regZ, material, m_previousSliceVertices, result);

        m_vecQuads.push_back(Quad(v0, v1, v2, v3));
}
网格合并

将同一切面,同一材质的网格进行合并,方法就是贪心算法,不断冒泡迭代直到没有可以合并的对象:
template<typename MeshType>
bool performQuadMerging(std::list<Quad>& quads, MeshType* m_meshCurrent)
{
        bool bDidMerge = false;
        for (typename std::list<Quad>::iterator outerIter = quads.begin(); outerIter != quads.end(); outerIter++)
        {
                typename std::list<Quad>::iterator innerIter = outerIter;
                innerIter++;
                while (innerIter != quads.end())
                {
                        Quad& q1 = *outerIter;
                        Quad& q2 = *innerIter;

                        bool result = mergeQuads(q1, q2, m_meshCurrent);

                        if (result)
                        {
                                bDidMerge = true;
                                innerIter = quads.erase(innerIter);
                        }
                        else
                        {
                                innerIter++;
                        }
                }
        }

        return bDidMerge;
}
Buffer重整

对于合并后的顶点,剔除没有用的顶点并且对index的指标做重新映射:
result->removeUnusedVertices();
Cubiquity

即使如此,MagicaVoxel上还是留下了一个问题,即上述的方式可以合并同一ID的材质,但是MagicaVoxel还可以随机颜色:


这使得无论是立方体的位置还是材质都充满了随机性,几乎没有合并的可能,这样的情况下竟然还可以非常流畅地运行,不过从这张图中可以明显感受到使用了LOD,即距离相机近的这个角立方体看起来大一点,远处的要小一点。对于体素的LOD,PolyVox提供了一种方案就是通过VolumeResampler采样两种尺度的网格,近处可以使用较高的分辨率,远处使用较低的分辨率。
但除此之外,可以做的更加精细,因此,为了让开发者更加方便,作者又开发了一个上层封装Cubiquity,这一上层封装直接对接了Unity和Unreal的插件,使得在编辑器当中就可以进行处理:
在这层封装当中,作者又整合了Octree的结构:
void ColoredCubicSurfaceExtractionTask::process(void)
{
        mProcessingStartedTimestamp = Clock::getTimestamp();

        Region lod0Region = mOctreeNode->mRegion;

        //Extract the surface
        mPolyVoxMesh = new ColoredCubesMesh;
        mOwnMesh = true;

        uint32_t downScaleFactor = 0x0001 << mOctreeNode->mHeight;

        ColoredCubesIsQuadNeeded isQuadNeeded;

        if(downScaleFactor == 1)
        {
                extractCubicMeshCustom(mPolyVoxVolume, mOctreeNode->mRegion, mPolyVoxMesh, isQuadNeeded, true);
        }
        else if(downScaleFactor == 2)
        {
               
                Region srcRegion = mOctreeNode->mRegion;

                srcRegion.grow(2);

                Vector3I lowerCorner = srcRegion.getLowerCorner();
                Vector3I upperCorner = srcRegion.getUpperCorner();

                upperCorner = upperCorner - lowerCorner;
                upperCorner = upperCorner / static_cast<int32_t>(downScaleFactor);
                upperCorner = upperCorner + lowerCorner;

                Region dstRegion(lowerCorner, upperCorner);

                ::PolyVox::RawVolume<Color> resampledVolume(dstRegion);
                rescaleCubicVolume(mPolyVoxVolume, srcRegion, &resampledVolume, dstRegion);

                dstRegion.shrink(1);
               
                //dstRegion.shiftLowerCorner(-1, -1, -1);

                extractCubicMeshCustom(&resampledVolume, dstRegion, mPolyVoxMesh, isQuadNeeded, true);

                scaleVertices(mPolyVoxMesh, downScaleFactor);
                //translateVertices(mPolyVoxMesh, Vector3DFloat(0.5f, 0.5f, 0.5f)); // Removed when going from float positions to uin8_t. Do we need this?
        }
}更大的规模

这套代码从06年开始到现在,可以称得上远古了,初看的时候我很怀疑他的可读性,没想到结合作者的文章和代码非常容易入手,实际上这套代码是我在看另外一套体素引擎的时候注意到的:
这套引擎封装了PolyVox,并且提供了一系列更加高级的封装包括编辑器的细节,以及体素文字,体素树的生成器等等。如果想要更加贴近应用,可以参考这套框架,这套框架同样也是基于组件架构的,非常容易入手。
好了,通过表面提取的方式可以处理大概512^3这么大的体素网格,在作者的文章中也展示过效率,他建议在时间上进行切片,大概2-3帧就能够完成数据处理的操作,用户基本上是无感的。但是更大规模的体素要怎么做?近年来SVO和Cone Tracing大行其道,知乎上有非常多的文章介绍其中的实现。更随着PolyVox的视野,也可以看到他在逐步尝试更大规模的体素编辑:
Cubiquity项目已经停止更新,目前作者正在开发Cubiquity2:
这一项目更多是实验性质的,主要基于DAG-SVO结构:
总结

TearDown是近年一款广受关注的体素游戏,用户可以随意破坏场景,这款游戏基于的方案还是Polygen的,即提取体素的三角面,而另外
也开始公测,体素的尺度更小,却可以提供无限的操作细节。在DAG-SVO方法的论文中,展示的就是在这种微体素的情况下实现实时场景编辑的方法。
通过这样的调研,Minecraft风格的体素游戏对我来说已经驱魅了,从而也对后面引擎的技术发展有了一个基本的预期,包括Octree场景管理,合批或者Intancing等等,并且结合物理引擎可以尝试一系列的场景和编辑器技术,如果有一些进展,再来同步给大家。体素这件事,看起来简单,实际上有很多人在尝试,在Reddit上有一个讨论帖,每周都会有人过来同步进展:
这种技术氛围很值得我们学习。

xiaozongpeng 发表于 2022-1-5 21:20

对于大规模体素渲染,感觉UE5那套软光栅是不错的优化方案。体素真的酷……

maltadirk 发表于 2022-1-5 21:21

我印象中UE5的软光栅只是为了做剔除,UE本身并不做体素渲染

jquave 发表于 2022-1-5 21:29

不是体素渲染,单纯是渲染。
软光栅时忽略掉比像素还小的三角形。感觉体素规模大了,应该会遇到类似问题。

Arzie100 发表于 2022-1-5 21:38

非常好的文章[赞]

RecursiveFrog 发表于 2022-1-5 21:43

软光栅好像是为了防止overdraw的

Ilingis 发表于 2022-1-5 21:51

谢谢
页: [1]
查看完整版本: 体素引擎,来自元宇宙的基本问题2·实现路径