状态 & 节点

之前在 webglfundamentals 上看到一篇使用 SceneGraph 绘制多个对象的文章,里面以太阳系为例,构造了这样一幅场景图。 对于父节点应用的变换会影响到子节点:

solarSystem
   |    |
   |   sun
   |
 earthOrbit
   |    |
   |  earth
   |
  moonOrbit
      |
     moon

这种思路在现代 3D 引擎中基本上都会使用到,例如骨骼动画中利用关节进行连接,再比如常见的 Group 类等等。 在进一步查阅之后我发现 OpenSceneGraph 是一个完全基于场景图(顾名思义)设计的引擎,原版是使用 C++ 和 OpenGL 实现,也有对应的 JS 实现的版本,即 OSG.js。 虽然 OSG 有一定年头了,相关资料也比较少(甚至官网的 API 链接都有误),但是通过研究其源码,能够学习到了不少引擎架构和优化的知识。

本文基本围绕网上一些介绍 OSG 的系列文章展开:

场景图的好处

这里直接引用来自 OSG 官网对于场景图好处的总结,其中我们只关注性能部分:

其实也就是一些常用的 WebGL 优化手段,只不过在场景图的架构下更容易实现:

抽象状态

记得之前在知乎上看到「反对函数式编程的政治正确」一文,提到 Web 中很多机制都不是纯函数的,例如 WebGL 渲染管线的 API 都是有状态的。将 WebGL 视作一个状态机,通过改变状态控制最终渲染结果就很容易理解了,以下引用自「Open Scene Graph: Completing the Triad」:

If we were drawing green things and now want to draw blue things, we have to change the OpenGL state. If we were drawing things with lighting enabled and now want to draw things with lighting disabled, we have to change the OpenGL state. The same goes for blending (used for translucency) and everything else.

那么在 OSG 中是如何改变状态的呢?首先 OSG 将状态分成 Mode 和 Attribute 两类,前者可以通过 gl.enable/disable 开启/关闭,后者例如 gl.viewport。这些状态的集合就是 StateSet,要注意它只是 WebGL 全部状态的子集,需要依附场景图中的节点而存在。例如下图中开启 wireframe 的叶节点:

下面我们来看看 OSG 中是如何使用 StateSet 的,原文是用 C++ 展示。 从例子中我们可以看出,Mode/Attribute 状态已经不仅仅是 WebGL 原生的状态,可以抽象到更高层次例如 Fog、Light、Texture、Shader 甚至是 Program:

const osg::Vec4 fogColor(0.5, 0.5, 0.5, 1.0);
// 创建模型节点
osg::ref_ptr<osg::StateSet> ss = loadedModel->getOrCreateStateSet();

// 设置 Mode 状态(关闭光照)
ss->setMode(GL_LIGHTING, osg::StateAttribute::OFF);

// 设置 Attribute 状态
osg::ref_ptr<osg::PolygonMode> polyMode(new osg::PolygonMode());
polyMode->setMode(osg::PolygonMode::FRONT_AND_BACK,
                    osg::PolygonMode::LINE);
ss->setAttribute(polyMode);

// 设置 Mode & Attribute 状态
osg::ref_ptr<osg::Fog> fog(new osg::Fog());
fog->setMode(osg::Fog::LINEAR);
fog->setColor(fogColor);
fog->setStart(15.0);
fog->setEnd(100.0);
ss->setAttributeAndModes(fog);

// 创建 Viewer,载入模型
osgViewer::Viewer viewer;
viewer.setSceneData(loadedModel);
viewer.getCamera()->setClearColor(fogColor);

// 进入渲染循环
viewer.run();

状态的继承

有了状态的抽象,结合场景图的特性我们很容易理解状态的继承,比如在根节点关闭了光照,子节点可以通过 override 进行开启。 所以 OSG 中常见的用法是在父节点中关联通用的状态,子节点重载个性化状态:

一个完整的重载机制一定需要包含对于父节点和子节点优先级的限制,类似 CSS 的重载机制也需要 !important 提升到最高优先级。 在 OSG 中是通过 Mask 位运算实现的:

// 父(根)节点禁止被子节点覆盖
rootSS->setMode(
   GL_LIGHTING,
   osg::StateAttribute::OFF | osg::StateAttribute::OVERRIDE);

// 子节点忽略父节点
sharedSS->setMode(
   GL_LIGHTING,
   osg::StateAttribute::ON | osg::StateAttribute::PROTECTED);

那么如果父节点和子节点同时设置了重载和忽略,谁的优先级更高呢?看下状态重载的计算就明白了:

// StateAttribute.js
StateAttribute.OFF = 0;
StateAttribute.ON = 1;
StateAttribute.OVERRIDE = 2;
StateAttribute.PROTECTED = 4;
StateAttribute.INHERIT = 8; // 默认重载行为

// State.js
_evaluateOverrideObjectOnStack: function(stack, object, maskValue) {
    var back = stack._back;
    // object can be a Uniform, an Attribute, or a shader generator name
    if (stack._length === 0) {
        return object;
    } else if (
        // 父节点设置了重载并且子节点没有设置忽略,使用父节点
        back.value & StateAttribute.OVERRIDE &&
        !(maskValue & StateAttribute.PROTECTED)
    ) {
        return back.object;
    } else {
        // 否则使用子节点
        return object;
    }
},

一些特殊的状态

之前提到过,除了 WebGL/OpenGL 内置的一些状态,OSG 在更高层次上抽象了一些特殊的状态,下面我们就来看一下 Texture、Shader、Program 和 Uniform。

Texture

在抽象出的所有状态中,Texture 是一个特殊的存在,原因在于绑定纹理时除了 Texture 对象,还需要额外参数纹理单元。如果用错,OSG 会给予提示,需要使用 setTextureAttributeAndModes()

That’s why we are better not use the regular calls like osg::StateSet::setAttributeAndModes() to enable texture attributes. Instead, we should use osg::StateSet::setTextureAttributeAndModes(), which requires an additional parameter: an integer telling which texture unit to use. Using our old friend osg::StateSet::setAttributeAndModes() for texturing doesn’t fail completely: OSG uses the first texture unit (unit zero), and issues a warning telling that you are doing something inelegant and that wonderful things would happen to your life had you used osg::StateSet::setTextureAttributeAndModes() as I told you to do. Or something like this.

具体用法如下:

// 创建状态集合 StateSet
osg::ref_ptr<osg::StateSet> ss = loadedModel->getOrCreateStateSet();
// 创建 Texture 对象
osg::ref_ptr<osg::Image> image = osgDB::readImageFile("texture.png");
osg::ref_ptr<osg::Texture2D> tex(new osg::Texture2D());
tex->setImage(image);
// 在状态集中设置 Texture 状态,绑定 Texture Unit 0
ss->setTextureAttributeAndModes(0, tex);

Shader Program & Uniform

osg::ref_ptr<osg::Shader> shader(
    new osg::Shader(osg::Shader::FRAGMENT));
shader->setShaderSource(TheShaderSource);

// 创建 Program 并关联 Shader
osg::ref_ptr<osg::Program> program(new osg::Program());
program->addShader(shader);

// 设置 Program 状态
osg::ref_ptr<osg::StateSet> ss = loadedModel->getOrCreateStateSet();
ss->setAttribute(program);

// 添加 Uniform
osg::ref_ptr<osg::Uniform> rgbUniform(
    new osg::Uniform("rgb", osg::Vec3(0.2, 0.2, 1.0)));
ss->addUniform(rgbUniform);

状态机及优化手段

StateSet 包含了状态集合,而状态机 State 则维护了一个 StateSet 栈,通过 push/popStateSet() 控制,此时并不会真正应用这些状态变更,只有调用 apply() 才会真正根据状态集合渲染。还记得开头说到的“状态改动最少化”吗,这里就是这条优化原则的实践之一。

从上图可以看出,State & StateSet 提供了当单个物体状态发生改变时,最小化应用这种改变的优化手段。那么当渲染多个物体时,我们也理所应当根据它们的状态进行排序,这样连续渲染过程中发生的状态切换以及 API 的重复调用也会是最少的。例如在 Optimizing WebGL: Avoid Redundant Calls 一节中就举了这样一个例子,其中第二次绘制中带有 * 号的语句都是多余的:

// First draw
glBindBuffer(...);
glVertexAttribPointer(...);
glActiveTexture(0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(1);
glBindTexture(GL_TEXTURE_2D, texture2);
glDrawArrays(...);

// Second draw (back-to-back)
glBindBuffer(...);
glVertexAttribPointer(...);
glActiveTexture(0); // (*)
glBindTexture(GL_TEXTURE_2D, texture1); // (*)
glActiveTexture(1); // (*)
glBindTexture(GL_TEXTURE_2D, texture2); // (*)
glDrawArrays(...);

因此一些 3D 引擎会根据完整的状态(使用的 Shader,Texture 等)生成一个 hash 值作为排序依据。 但是在 OSG 中,状态并不是集中在绘制对象本身上,而是分散在场景图各个节点的 StateSet 上,为此 OSG 又引入了 StateGraph 的概念。

StateGraph

如果我们把场景图中的每个节点关联的 StateSet 称作一棵“状态树”,那么 StateGraph 可以形成一棵与之对应的“渲染树”。 例如上图 State 中保存的一组 StateSet 栈,其中每一组栈顶的 StateAttribute 对应的 StateGraph 如下图所示:

在遍历节点时,会同时构建“状态树”和“渲染树”,将深度信息存储在 StateGraph 对象的 _depth 属性中,后续在状态转移中会用到。 同时 StateGraph 提供了状态转移的静态方法 moveStateGraph(),在下一篇文章介绍渲染流程时会看到 RenderLeaf 中的调用:

StateGraph.moveStateGraph = (function() {
    // 临时栈,用于保存状态新旧节点多余的状态,减少对象分配
    var stack = new PooledArray();
    var stackArray = stack.getArray();
    return function(state, sgCurrentArg, sgNewArg) {
        StateGraph.statsNbMoveStateGraph++;

        stack.reset();
        var sgNew = sgNewArg;
        var sgCurrent = sgCurrentArg;
        var i, l;
        if (sgNew === sgCurrent || sgNew === undefined) return;

        // 处理根节点
        // 处理兄弟节点
        // 处理深度不同节点
    };
})();

根据新旧节点的深度值分成以下三种情况。首先是处理根节点:

// 没有当前节点,说明是根节点
if (sgCurrent === undefined) {
    // 1. 拷贝新状态到临时栈中
    do {
        if (sgNew._stateset !== undefined) {
            stack.push(sgNew._stateset);
        }
        sgNew = sgNew._parent;
    } while (sgNew);
    // 2. 新状态入栈
    for (i = stack._length - 1, l = 0; i >= l; --i) {
        state.pushStateSet(stackArray[i]);
    }
    return;
}

处理兄弟节点:

else if (sgCurrent._parent === sgNew._parent) {
    // 新旧两个状态为兄弟节点,拥有同样的父节点
    // 1. 旧状态出栈
    if (sgCurrent._stateset !== undefined) {
        state.popStateSet();
    }
    // 2. 新状态入栈
    if (sgNew._stateset !== undefined) {
        state.pushStateSet(sgNew._stateset);
    }
    return;
}

新旧节点深度不同时处理起来要复杂一些,需要使用到开头的临时栈暂存一些状态信息。 另外要注意,前面提到过只有真正调用 state.push/popStateSet() 才会真正应用状态的变更:

// 1. 旧节点深度更深,状态依次出栈
while (sgCurrent._depth > sgNew._depth) {
    if (sgCurrent._stateset !== undefined) {
        state.popStateSet();
    }
    sgCurrent = sgCurrent._parent;
}

// 2.1 清空临时栈
stack.reset();
// 2.2 新节点深度更深,先将新状态入临时栈
while (sgNew._depth > sgCurrent._depth) {
    if (sgNew._stateset !== undefined) {
        stack.push(sgNew._stateset);
    }
    sgNew = sgNew._parent;
}

// 3. 此时新旧节点深度一致,需要找到两者共同的祖先节点,直至根节点
while (sgCurrent !== sgNew) {
    if (sgCurrent._stateset !== undefined) {
        // 旧节点出栈,应用变更
        state.popStateSet();
    }
    // 退回旧节点父节点
    sgCurrent = sgCurrent._parent;

    // 新节点入临时栈
    if (sgNew._stateset !== undefined) {
        stack.push(sgNew._stateset);
    }
    // 退回新节点父节点
    sgNew = sgNew._parent;
}
// 新节点入栈,应用变更
for (i = stack._length - 1, l = 0; i >= l; --i) {
    state.pushStateSet(stackArray[i]);
}

虽然有些绕,但搞懂 State 全局状态机,StateSet 状态集以及 StateGraph 之间的关系是非常有必要的。

场景图中的节点

各种的节点类型等到后续介绍到渲染流程时会有更全面的认识,这里仅介绍一个特殊的节点类型 Transform。

Transform

不同于 Three.js 等大多数 3D 引擎将变换矩阵作为节点属性的做法,OSG 将 Transform 也抽象成场景图中的节点(PositionAttitudeTransform),影响后续子节点的 worldMatrix 计算:

PAT 节点的使用也很简单,和其他类型节点一样,可以被添加为其他节点的子节点:

osg::ref_ptr<osg::PositionAttitudeTransform> lightPAT(
    new osg::PositionAttitudeTransform());

lightPAT->setPosition(osg::Vec3(5.0, 12.0, 3.0));
sgRoot->addChild(lightPAT);

在这种设计下,遍历场景图中的节点遇到 Transform 时,会进行实际的 worldMatrix 计算:

// CullVisitor.js
var matrixTransformApply = function(node) {
    // 省略 isCull 判断...
    var matrix = this._pooledMatrix.getOrCreateObject();
    // 父节点 MV 矩阵出栈,之前存于栈中
    var lastMatrixStack = this.getCurrentModelViewMatrix();
    mat4.copy(matrix, lastMatrixStack);
    // 根据父节点 MV 矩阵和当前节点 local 矩阵计算 world 矩阵
    node.computeLocalToWorldMatrix(matrix);
    // 当前节点 world 矩阵入栈
    this.pushModelViewMatrix(matrix);
}

// MatrixTransform.js
computeLocalToWorldMatrix: function(matrix) {
    if (this.referenceFrame === TransformEnums.RELATIVE_RF) {
        mat4.mul(matrix, matrix, this.matrix);
    } else {
        mat4.copy(matrix, this.matrix);
    }
    return true;
},

总结

现在我们有了对于场景图中节点、状态机 State、状态集合 StateSet 和 StateGraph 等概念的基本了解,在下一篇中我们将深入了解 OSG 完整的渲染流程。

参考资料