OSG中利用RenderToTexture(RTT)技术实现拾取场景内特殊图形的方法(RTTPicker)

麦可的博客 / 2024-12-19 / 原文

懒人省流版

  • 适用情景

需要进行识别操作,但是对象是在着色器shader里进行顶点等图元相关数据的组织的,自定义osg::Geometry类,或者osg::OpenglGeometry类,因为没有设置顶点数组,导致osg自带的求交器无法拾取。一般可适用于点云数据拾取、高精度模型优化后拾取等情况。

  • 方法流程
  1. 对所有需要被拾取的对象进行id编码,并实现一个id和颜色之间的双映射编码。
  2. 所有可拾取对象将编码的颜色和拾取标志位传入着色器Shader。平时拾取标志位为否时输出正常颜色。
  3. 用RTT技术,将一个专门的相机观察场景并把Buffer输出到专门的图片。
  4. 用Handler处理拾取事件,在拾取发生时用Handler修改所有可绘制对象所在Node标志位的值,让每个可拾取对象变为编码后的颜色。
  5. 根据拾取的屏幕坐标在图片上拾取像素,可使用像素迭代器对区域附近的像素进行遍历查找合法颜色值。遍历完成后无论成功与否,拾取标志位置否,可拾取对象重新变为正常颜色。
  6. 尝试将拾取到的颜色转为id,得到拾取结果。拾取完毕。

场景内拾取对象的一般方法及其局限性

在osg的三维场景开发中,时常会遇到在场景里获取特定位置位置节点的问题,比如获得当前鼠标指针所在的节点对象。对此一般使用的方法是使用osg自带的求交器osgUtil::InterSector,以其中最典型的osgUtil::LineSegmentIntersector线段求交器为例,其使用方法如下所示:

``

osg::Vec2 screenXY;
osg::ref_ptr<osgViewer::View> viewer;
osg::ref_ptr<osgUtil::LineSegmentIntersector> picker = new osgUtil::LineSegmentIntersector(osgUtil::INtersector::WINDOW, screenXY.x(), screenXY.y());
osgUtil::IntersectionVisitor iv(picker.get());
viewer->getCamera()->accept(iv);
if(!picker.containsIntersections())
{
    return;
}
osgUtil::LineSegmentIntersection::Intersections& intersections = picker->getIntersections();
for(auto iter = intersections.begin(); iter != intersections.end(); iter++)
{
    for(osg::Node* node: iter->nodePath)
    {
        ...
    }
}

``

这种代码一般出现在事件处理器处理鼠标事件中。其首先获取指定的屏幕坐标screenXY,以此创建一个求交器picker,再根据求交器创建一个节点访问器对象iv;再将指定的父节点接收这个访问器,代码中这个父节点为当前场景对应的主相机viewer->getCamera()。在被接收对象调用了accept()函数后,节点访问器就会遍历接收对象及其所有子节点。在这个例子中因为场景根节点也是相机的子节点,所以可以保证在场景里的所有Node都会被访问器遍历到。

在遍历途中,访问器将每个访问到的node传到求交器,求交器根据自身参数对这个node的包围盒进行求交,在上面的例子中使用的LineSegmentIntersector类传入的参数是Window枚举值和坐标screenXY,那么它的求交方法是根据传入的屏幕坐标生成一道射线,用线段方程和Node进行求交判断,通过判断的Node对象会保存在intersection中。最后intersection会形成一个节点队列,其中每一个值代表着场景树的一条路径上所有求交判断通过的Node节点。我们遍历intersection每个对象的每个nodePath的每个Node,就能找到需要的指定对象。

在上述流程中涉及到射线和Node进行求交判断,这里涉及到Node类的一个接口void accept(osg::PrimitiveFunctor& functor),在这个Node类是Drawable子类实例的时候,Node会把它的顶点数组传给算子functor进行后续计算。由此可见,这种利用求交器获取指定位置坐标对应Node的方法,需要这个Node有顶点数组进行计算。(按道理来说直接拿包围盒计算就行了不知道为什么还需要这个)

由此可引申出一个问题,如果我的一个自定义可绘制对象Drawable没有在内存里储存顶点数组的话该怎么办?这个情况其实比想象中还要常见,在需要大量数据下的保持绘制效率,或者需要使用高精度数据时,开发人员会将数据以顶点数组以外的方式维护,并在shader里进行数据重建并绘制(比如第一种情况,会将大量数据保存在纹理里;第二种情况,将高精度数据拆分成几个float,在shader里拼回来)。对于这些无法获取顶点数组的对象,怎么对他们进行拾取操作呢?

OSGEARTH中的RTTPICKER技术

在osg的三维地球子开发平台OSGEARTH中也存在上述这种情况。比如开发者导入的矢量文件,其矢量要素在OE里以osgEarth::Features::Feature类的形式维护,它不是一个可绘制对象,最终绘制出来的各种矢量的效果也不能用求交器获取到这个对象。为了实现矢量的拾取功能,OE提供了对应例子Sample osgearth_pick(文件名为osgearth_pick.cpp)。例子中提到了一种新的基于渲染到纹理(RTT)的拾取技术,其各部分如下所示:

对象编码为id

为了能分辨出不同的对象,首先要对所有可拾取对象进行编码,即每个对象都要有一个唯一的id对应。OE中这个id本质是一个无符号整数,有个单例类osgEarth::Registry::objectindex()维护所有id。其内部有个映射表map,Key是id,Value是一个osg::Referenced可引用对象,通过传入id可以查询到对应的Value。这个Value可以是你要编码的那个可绘制对象node,也可以是开发者自己维护的一些参数类或者其他,比如OE专门创建一个叫osgEarth::Features::FeatureIndex类作为id的映射值,通过这个类的接口可以访问到osgEarth::Features::Featrure这个类,并在Feature类中得到这个要素的所有数据。在例子代码中,通过id获取对应矢量要素对象的代码如下所示:

``

unsigned int oid;
osgEarth::ObjectIndex* index = osgEarth::Registry::objectIndex();
osg::ref_ptr<osgEarth::Features::FeatureIndex> fi = index->get<osgEarth::Features::FeatureIndex>(oid);
osgEarth::Features::Feature* feature = fi? if->getFeature(oid): 0L;
...

``

为了能将指定对象传给objectindex进行编码,其提供了很多相关接口,如下所示:

``

//记录一个可绘制对象drawable并生成一个id返回
osgEarth::ObjectID tagDrawable(osg::Drawable* drawable, osg::Refereced* object);

//记录一个节点的所有可绘制对象子节点,并生成一个id返回
osgEarth::ObjectID tagAllDrawables(osg::Node* node, osg::Refereced* object);

//记录一个节点node并生成一个id返回
osgEarth::ObjectID tagNode(osg::Node* node, osg::Refereced* object);

...

``

还有许多类似的未展示的接口,但本质都是一样的,都需要传入两个参数。tagNode的第一个参数是你需要得到对应id的可绘制对象,第二个参数是一个纯记录的引用类。OE会对第一个参数Node的状态集进行一些设置,详细后续再说;而对第二个参数object就是映射表里id对应的值,前面矢量的实例代码中index->get<osgEarth::Features::FeatureIndex>(oid)这里就是获得对应key值的value对象。由此可以判断OE内部在处理矢量要素时,也是调用了tagNode或者类似的代码,传的第一个参数是矢量要素的可绘制对象,第二个参数就是这个要素对应的osgEarth::Features::FeatureIndex类。如果我们单纯的只需要通过id查询到对应的node而不是其他东西的话,完全可以两个参数都为node传进去,即tagNode(node, node);

id编码为颜色

为什么要将可拾取对象的id编码为颜色呢?这里需要从可绘制对象的特性说起。我们虽然不能用求交器对这些可拾取对象进行拾取识别,是因为他们的绘制都在着色器Shader里实现了;虽然获取不到它的顶点数据,但是最终渲染到屏幕上这个对象还是可见的,所以对他们的拾取只能从最终渲染出来的图像上下手。再联想一下我们平时识别一张图像上的东西的原理,其实就是识别到了特定位置是属于特定对象的一部分,也就是识别一张图像上特定位置那个像素对应的对象,我能想到的有如下两种方案:

  1. 维护每个可绘制对象再屏幕上的坐标范围,拾取时遍历每个对象求最符合拾取区域的那个对象
  2. 每个可绘制对象渲染成唯一对应的一种颜色。拾取时根据颜色直接得到那个对象

第一种方法不仅维护的数据多,遍历影响性能,还要手动判断拾取区域内所有符合对象的前后遮挡关系;第二种方法只需要一个id和颜色的双向映射关系,直接根据最终渲染出来的颜色就可以剔除掉该区域内其他对象。由此可见第二种方法更加简单快捷,OE也是这么做的。在拾取器的实现代码osgEarthUtil/RTTPicker.cpp中可以找到shader里的颜色生成代码:

``

"    oe_pick_color_contains_objectid = (oe_index_objectid == 1u) ? 1 : 0; \n"
"    if ( oe_pick_color_contains_objectid == 0 ) \n"
"    { \n"
"        float b0 = float((oe_index_objectid & 0xff000000u) >> 24u); \n"
"        float b1 = float((oe_index_objectid & 0x00ff0000u) >> 16u); \n"
"        float b2 = float((oe_index_objectid & 0x0000ff00u) >> 8u ); \n"
"        float b3 = float((oe_index_objectid & 0x000000ffu)       ); \n"
"        oe_pick_encoded_objectid = vec4(b0, b1, b2, b3) * 0.00392156862; \n" // 
"    } \n"

``

本质就是将一个无符号整数分成四个分量,然后乘以0.00392156862(除以256)得到一个合法的颜色值,再通过相反的计算可以从颜色计算得到id值。因此在拾取时,只要拿到像素编码过的合法颜色值,就能得到对象的id。

RTT技术的运用:相机和图片的设置

通过上面的技术我们将每个实体渲染出了特定颜色,通过拾取到像素的颜色就可以得到拾取对象了。但是这样的话会改变对象原来的颜色;那如何让所有可拾取对象显示正常颜色的同时,我们做拾取操作时他们又会变成特定编码对应的颜色呢?答案显而易见,我们正常看到的是场景主相机拍摄到的内容,那么拾取时用到的图像就由另外设的一个新相机拍摄就好了。因为要拿来拾取的图像不需要用来看,所以需要把它拍摄的内容保存到一张图片里,后续再进行取像素的操作。这就是渲染到纹理Render To Texture(RTT)要实现的目标。

在OE的拾取器代码osgEarthUtil/RTTPicker.cpp中可以看到渲染到纹理所必要的一些类及其用法,如142行所示:

``

RTTPicker::getOrCreatePickContext(osg::View* view)
{
    for(PickContextVector::iterator i = _pickContexts.begin(); i != _pickContexts.end(); ++i)
    {
        if ( i->_view.get() == view )
        {
            return *i;
        }
    }

    // Make a new one:
    _pickContexts.push_back( PickContext() );
    PickContext& c = _pickContexts.back();

    c._view = view;

    c._image = new osg::Image();
    c._image->allocateImage(_rttSize, _rttSize, 1, GL_RGBA, GL_UNSIGNED_BYTE);    

    // make an RTT camera and bind it to our imag:
    c._pickCamera = new osg::Camera();
    c._pickCamera->addChild( _group.get() );
    c._pickCamera->setClearColor( osg::Vec4(0,0,0,0) );
    c._pickCamera->setClearMask( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );
    c._pickCamera->setReferenceFrame( osg::Camera::ABSOLUTE_RF_INHERIT_VIEWPOINT ); 
    c._pickCamera->setViewport( 0, 0, _rttSize, _rttSize );
    c._pickCamera->setRenderOrder( osg::Camera::PRE_RENDER, 1 );
    c._pickCamera->setRenderTargetImplementation( osg::Camera::FRAME_BUFFER_OBJECT );
    c._pickCamera->attach( osg::Camera::COLOR_BUFFER0, c._image.get() );

    osg::StateSet* rttSS = c._pickCamera->getOrCreateStateSet();

    // disable all the things that break ObjectID picking:
    osg::StateAttribute::GLModeValue disable = osg::StateAttribute::OFF | osg::StateAttribute::OVERRIDE |   osg::StateAttribute::PROTECTED;

    rttSS->setMode(GL_BLEND,     disable );    
    rttSS->setMode(GL_LIGHTING,  disable );
    rttSS->setMode(GL_CULL_FACE, disable );

    // Disabling GL_BLEND is not enough, because osg::Text re-enables it
    // without regard for the OVERRIDE.
    rttSS->setAttributeAndModes(new osg::BlendFunc(GL_ONE, GL_ZERO), osg::StateAttribute::OVERRIDE);

    // install the picking shaders:
    VirtualProgram* vp = createRTTProgram();
    rttSS->setAttribute( vp );

    // designate this as a pick camera, overriding any defaults below
    rttSS->addUniform( new osg::Uniform("oe_isPickCamera", true), osg::StateAttribute::OVERRIDE );

    // default value for the objectid override uniform:
    rttSS->addUniform( new osg::Uniform(Registry::objectIndex()->getObjectIDUniformName().c_str(), 0u) );

    // install the pick camera on the main camera.
    view->getCamera()->addChild( c._pickCamera.get() );

    // associate the RTT camara with the view's camera.
    c._pickCamera->setUserData( view->getCamera() );

    return c;
}   

``

从中可以看到,拾取需要有对应的纹理图片osg::Image和相机osg::Camera对象,OE这两个封装成了拾取的上下文PickContext。相机通过attach函数将输出的颜色数据传到图片上,并且关闭了光照、裁剪、颜色混合等各种会影响最终颜色效果的模式;同时设了两个uniform,其中一个oe_isPickCamera是拾取标志位,在鼠标点击事件时,会有事件处理器识别到这个事件并把这个uniform设置为true,这个时候可拾取对象就会采用objectID对应的颜色,并最终“拍”成图片保存在图片里。

这里真的很想吐槽一下osgEarth的着色器解决方案。OE的开发者把osg的osg::Shader着色器管理类魔改成了osgEarth::VirtualProgram类,不仅和osg一样在着色器代码里可以使用更多的特色自带内置变量,还允许开发者把shader写成一个单独的片段而不是完整的main函数,最后oe在执行时把所有着色器片段代码合并完整并编译使用。虽然开发起来或许真的方便了,可是别人看代码的时候就遭殃了,代码东一段西一段,比如这个OE的拾取器rttpicker用到了objectIndex的设置变量,而拾取上下文PickContext设置的变量起作用的地方在oe的渲染驱动着色器代码MPEngine.frag.glsl里面,代码一复杂起来简直让人看的晕头转向。更离谱的是这会使得自己写的一些着色器特效会被OE自己给覆盖掉,着色器效果互相冲突,简直是醉了!

RTT技术的运用:像素拾取并识别

我们用专门的相机拍摄了被objectID编码过的场景,并在拾取时得到此时被修改过颜色的纹理图片,接下来就是在图片中获取拾取区域的像素了。一般来说我们获取特定像素的颜色值时,直接根据宽高值在图片的内存里读取特定位置的数据就行了;OE则考虑到了鼠标拾取位置存在偏差的情况,设计了一种迭代器来遍历拾取周围区域的所有像素。其代码在osgEarthUtil/RTTPicker.cpp309行开始:

``

struct SpiralIterator
{
    unsigned _ring;
    unsigned _maxRing;
    unsigned _leg;
    int      _x, _y;
    int      _w, _h;
    int      _offsetX, _offsetY;
    unsigned _count;

    SpiralIterator(int w, int h, int maxDist, float u, float v) : 
        _w(w), _h(h), _maxRing(maxDist), _count(0), _ring(1), _leg(0), _x(0), _y(0)
    {
        _offsetX = (int)(u * (float)w);
        _offsetY = (int)(v * (float)h);
    }

    bool next()
    {
        // first time, just use the start point
        if ( _count++ == 0 )
            return true;

        // spiral until we get to the next valid in-bounds pixel:
        do {
            switch(_leg) {
            case 0: ++_x; if (  _x == _ring ) ++_leg; break;
            case 1: ++_y; if (  _y == _ring ) ++_leg; break;
            case 2: --_x; if ( -_x == _ring ) ++_leg; break;
            case 3: --_y; if ( -_y == _ring ) { _leg = 0; ++_ring; } break;
            }
        }
        while(_ring <= _maxRing && (_x+_offsetX < 0 || _x+_offsetX >= _w || _y+_offsetY < 0 || _y+_offsetY >= _h));

        return _ring <= _maxRing;
    }

    int s() const { return _x+_offsetX; }

    int t() const { return _y+_offsetY; }
};

``

从名字和代码可以看出这是一种螺旋形的迭代器,从输入的那个坐标点开始,调用next()会让迭代器在外一周的像素遍历一遍,完了再遍历更外一圈的像素,直到探到边界。为了对拾取结果进行处理,OE给拾取器设计了专门的回调基类osgEarth::Picker::Callback类,如下所示:
``

//所在文件: osgEarth/Picker
class Picker : public osgGA::GUIEventHandler
{
public:
    struct Callback : public osg::Referenced
    {
        // Called when an ID is hit
        virtual void onHit(ObjectID id) { }

        // Called when a query results in nothing
        virtual void onMiss() { }

        // Called to ask whether to perform a query based on events
        virtual bool accept(const osgGA::GUIEventAdapter& ea, const osgGA::GUIActionAdapter& aa) { return false; }
    };

public:
    
    /**
     * Initiate a pick. The picker will invoke the callback when the pick is complete.
     * Returns true is the pick was successfully queued.
     */
    virtual bool pick(osg::View* view, float mouseX, float mouseY, Callback* callback) = 0;

protected:

    virtual ~Picker() { }
};

``

实现回调基类需要重载这三个虚函数,前两个onHit和onMiss都很好理解就是命中ObjectID为id的可拾取对象和拾取失败时会分别调用的接口,第三个accept函数就是拾取器的判断条件,在它返回true时拾取器开始进行拾取操作。比如写成return ea.getEventType() == ea.MOVE;就会在鼠标移动时不停的进行拾取操作。开发者可以使用OE封装好的RTTPicker类并实现回调类,也可以直接自己写一个拾取器,封装自己的拾取上下文等。

后记

一般来说拾取完成后应该有个将拾取标志位设为否,让可拾取对象重新变为普通颜色给主相机渲染的操作,但是目前暂时没有在OE源码里找到对应的部分,可能我的理解还有一定误差吧。但是我按照自己的理解实现了自己的拾取器,是可以正常工作的。 详见下一章。