[翻译] ogre 2.0 移植手册 - 2 改变:对象、场景和节点

2 改变:对象、场景和节点


2.1 名称是可选的

名称不再是唯一的并且是可选的(即两个SceneNode可以有相同的名称)。为了识别唯一性,类从IdObject派生,并使用IdObject::getID()。
请注意,例如,实体与节点完全不同(它们甚至不共享公共基类),因此实体和场景节点可能具有相同的Id。
你不会找到两个具有相同Id的实体(或者更准确的说,两个MovableObject)。否则就是bug。

此更改会对创建调用产生很大影响。例如,以下代码是很常见的

sceneManager->createEntity("myEntityName", "meshName.mesh")

但是,创建实体的新定义如下:

Entity* createEntity( const String& meshName, const String& groupName = ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME,
		SceneMemoryMgrTypes sceneType = SCENE_DYNAMIC );

换句话说,您的旧代码将尝试在组"meshName.mesh"中"meshName.mesh";这可能会失败。移植到ogre2.0,只需要写:

Entity *myEnt = sceneManager->createEntity( "meshName.mesh" );
myEnt->setName( "myEntityName" ); // 该调用是可选的

2.2 如何调试MovableObject(和Node)的数据

每帧需要更新的所有相关数据都以 SoA 形式(数组结构)存储,而不是 AoS(结构数组)
这意味着数据在内存中,而不是像下面这样放置:

class Node
{
	Vector3		pos;
	Quaternion	rotation;
	Vector3		scale;
};

它按如下方式放置:

class Node
{
	Vector3		*pos;
	Quaternion	*rotation;
	Vector3		*scale;
};

然而,我们的设置实际上与其他引擎方法完全不同,因为4个Vector3在内存中被打包成如下:

Vectors[0, 4)Vectors[4, 8)
XXXX YYYY ZZZZXXXX YYYY ZZZZ

调试SIMD构建一开始可能非常违反直觉,在这种情况下,定义"OGRE_USE_SIMD 0"并重新编译将迫使ogre使用ArrayMath的C版本,因此每个ArrayVector3只能打包一个Vector3.

尽管如此,直接调试SSE并不困难。MovableObject将其SoA数据存储在MovableObject::mObjectData中。以下屏幕截图显示了它的内容:
在这里插入图片描述
这张图片最重要的元素是mIndex。由于这是从SSE2(单精度)生成中获取的,因此其值的范围可以介于0和3之间(包括0和3)。宏"ARRAY_PACKED_REALS"在此版本中定义为"4"。以便让应用程序知道有多少浮点数正在打包在一起。

在本例中,ObjectData::mParents 包含所有四个 MovableObject 的父节点。在这种情况下,我们的父级在mObjectData.mParents[1]中;因为 mIndex = 1
在这里插入图片描述
在上图中,我们现在可以检查对象的父节点。请注意,在监视窗口中添加逗号后跟数字会强制 MSVC 调试器将变量解释为数组。否则,它可能只向您显示第一个元素。示例:“mObjectData.mParents,4”

以下是Transform声明的一部分(由节点使用):

struct Transform
{
	/// Which of the packed values is ours. Value in range [0; 4) for SSE2
	unsigned char		mIndex;
	Node			**mParents;
	/* ... */
	bool	* RESTRICT_ALIAS mInheritOrientation;
};

当ARRAY_PACKED_REALS = 4(即SSE构建)时,尽管不是严格正确的,但我们可以说mParents被声明为:

struct Transform
{
	/* ... */
	Node			*mParents[4];
	/* ... */
};

因此,要知道哪些mParents属于我们,我们必须查看mParents[mIndex]。

同样的情况也发生在mInheritOrientation上。当创建9个连续节点时,如果我们看一下mParents指针,我们会注意到前4个指向相同的内存位置:

  1. Transform::mParents = 0x00700000
  2. Transform::mParents = 0x00700000
  3. Transform::mParents = 0x00700000
  4. Transform::mParents = 0x00700000
  5. Transform::mParents = 0x00700010
  6. Transform::mParents = 0x00700010
  7. Transform::mParents = 0x00700010
  8. Transform::mParents = 0x00700010
  9. Transform::mParents = 0x00700020

前4个节点的Transform将具有完全相同的指针;唯一的区别是 mIndex的内容。当我们转到第5个时,指针递增4个元素。 mIndex用于确定我们的数据实际位置。这种布局起初可能有点难以掌握,但是一旦习惯了它,它就很容易了。请注意,我们满足一个非常重要的属性:我们所有的指针都对齐到 16 个字节。


2.2.1 解释 ArrayVector3

ArrayVector3、ArrayQuaternion 和 ArrayMatrix4 在通过调试器观察它们时需要更多的工作:
在这里插入图片描述

在这里,调试器告诉我们,本地空间中 Aabb 的中心位于 (100; 100; 100)。我们正在阅读第3列,因为mIndex是1;如果 mIndex 为 3;我们将不得不阅读第1列。

m_chunkBase[0]包含四个从右到左读的"XXXX"。
m_chunkBase[1]包含四个"YY YY",我们的是第二个(从右边开始)
m_chunkBase[3]你现在应该猜到包含"ZZZZ"

请注意,例如,如果第4个元素(在本例中为(0,0,0))是一个空插槽(即整个场景中只有3个实体);它可能包含完整的垃圾;甚至NaN。这没关系。在释放内存插槽后,我们用有效值填充内存,以防止NaN和INF;因为当存在这种浮点特殊时,某些体系结构会变慢;但是它们中的一些可能溜走了(或者也有可能相邻的实体实际上包含无限范围)

m_chunkBase是变换矩阵吗?不。在 SSE2 SIMD 构建中,ArrayVector3 将 4 个向量打包在一起(因为 ARRAY_PACKED_REALS = 4)。如果创建了名为 A、B、C、D 的 4 个节点;上图是说:

  • m_chunkBase[0] = { D.x, C.x, B.x, A.x }
  • m_chunkBase[1] = { D.y, C.y, B.y, A.y }
  • m_chunkBase[2] = { D.z, C.z, B.z, A.z }

因此,要了解B的内容,您需要查看第3列。


2.2.2 虚拟指针而不是空指针

在 ObjectData::mParents[4] 中看到空指针很可能是一个错误,除非它是临时的。在 SoA 更新期间;那些未初始化的内存插槽(或其 MovableObject 尚未附加到 SceneNode)设置为 MemoryManager 拥有的虚拟指针,而不是设置为 null。

这可以防止我们每次需要在SoA循环(通常是热点)中访问指针时检查指针是否为空;与可能关联的分支失灵。这是面向数据设计中的一种模式。

但请注意,MovableObject::mParentNode 在分离时为空(因为它不是 SoA 变量),而 MovableObject::mObjectData::mParents[mIndex] 指向虚拟节点。附加后,两个变量将指向同一指针。

其他指针(如 ObjectData::mOwner[] 和 Transform::mParents[] )也会发生同样的情况。


2.3 附加和可见性

在 Ogre 1.x 中,当一个对象附加到最终父节点是 root 的场景节点时,该对象"在场景中"。因此,分离的实体永远不会显示,并且在附加时,调用setVisible(false)将隐藏它。

在 Ogre 2.x 中,对象始终位于"场景中"。节点仅保存位置信息,可以进行动画处理,并且可以从其父节点继承转换。当实体不再与节点关联时,它会隐藏自身(隐式调用 setVisible(false)以避免在没有位置的情况下呈现。多个实体可以共享同一位置,因此具有相同的节点。

这意味着,在附加/分离到/分离 SceneNode 时,MovableObject::getVisible 的先前值将丢失。此外,在分离时调用 setVisible(true) 是非法的,并且会导致崩溃(对此有一个调试断言)。


2.4 连接/分离比隐藏更昂贵

由于 Ogre 1.x 在遍历 SceneNodes(又名 Scene Graph)时的速度非常慢,一些用户建议分离其对象或从其父级中删除 SceneNode,而不是调用 setVisible(false); 尽管官方文件另有说明。

在 Ogre 2.x 中,这不再是事实,我们付出了巨大的努力来尽可能快地保持更新和迭代。这可能反过来增加了添加/删除节点和对象的开销。因此,使用 setVisible 隐藏对象比销毁对象快几个数量级(除非它们必须隐藏很长时间)


2.5 所有可移动对象都需要一个 SceneNode(灯光和摄像机)

除非隐藏(请参阅附件和可见性),否则所有可移动对象(如实体,实例实体,光源甚至摄像机)都需要附加到SceneNode;因为节点是Transform(位置和旋转)的持有者。

不再有无节点灯。它们的转换数据位于节点中,因为它与优化,简化的函数(特别是更新派生的边界框用于计算可见性)完美配合,并且不再具有自己的位置和方向;现在由节点持有。

做出这一决定的另一个原因是,将来自节点的转换数据与Light的转换数据相结合效率低下,而在Ogre 2.0中,使用额外节点的开销几乎被消除了。此外,它更适合需要更多方向,但全四元数的灯光(例如纹理聚光灯和区域灯光)。

像Light::getDirection和Light::setDirection这样的功能将重定向到场景节点,并且在未附加时会失败。创建灯光时不会附加到 SceneNode,因此在尝试使用之前,您必须先附加到 SceneNode。

然而,为了简单起见,相机确实有自己的位置和旋转,并避免过多地破坏旧的代码(与Lights不同)。性能不是问题,因为与数千个灯光相比,一个场景中只有不到十几个摄像机是正常的。默认情况下,摄像机在创建时附加到根场景节点。

因此,如果您的应用程序要将Camera附加到您自己的SceneNodes,则必须首先将其分离,调用Camera::detachFromParent; 否则,引擎将引发众所周知的异常,即该对象已附加到节点。


2.6获取派生变换

在过去,获得派生位置是调用 SceneNode::_getDerivedPosition() 。Ogre 会保留一个布尔标志,以了解派生的转换是否dirty。方向和缩放也是如此。

Ogre 2.0 为了性能1而删除了该标志(调试版本除外,它使用标志来触发断言)。

以下函数将使用上次缓存的派生转换,而不进行更新:

  • Node::_getDerivedPosition
  • Node::_getDerivedOrientation
  • Node::_getDerivedScale

这意味着以下代码段将无法按预期工作,并将在调试模式下触发断言:

sceneNode->setPosition( 1, 1, 1 );
Ogre::Vector3 derivedPos = sceneNode->_getDerivedPosition();

所有派生的转换都在 SceneManager::updateAllTransforms 中高效更新,这发生在 updateSceneGraph 中。更新场景图后,应开始使用派生变换。用户可以通过手动实现(或修改)Root::renderOneFrame来细粒度控制场景图的更新时间

但是,如果需要更新的节点数较少,用户可以调用这些函数的"更新"变体:

  • Node::_getDerivedPositionUpdated
  • Node::_getDerivedOrientationUpdated
  • Node::_getDerivedScaleUpdated

这些函数将强制更新父项的派生变换及其自身的变换。它速度较慢,不建议用于大量节点。如果需要这样做,请考虑重构引擎设计,以要求在 SceneManager::updateAllTransforms 之后进行派生转换。

以下代码段演示了如何使用更新的多属性:

sceneNode->setPosition( 1, 1, 1 );
sceneNode->setOrientation( Quaternion( Radian( 4.0f ), Vector3::UNIT_X ) );
sceneNode->setScale( 6, 1, 2 );

Vector3 derivedPos	= sceneNode->_getDerivedPositionUpdated();
//There's no need to call the Updated variants anymore. The node is up to date now.
Quaternion derivedRot	= sceneNode->_getDerivedQuaternion();
Vector3 derivedScale	= sceneNode->_getDerivedScale();

MovableObject的World Aabb&radius遵循相同的模式,并受到相同的问题的影响。


2.7 SCENE_STATIC和SCENE_DYNAMIC

MovableObjects2和 Nodes 在创建时都有一个设置,用于指定它们是动态的还是静态的。静态对象意味着永远不会移动,旋转或缩放; 或者至少他们不经常这样做。

默认情况下,所有对象都是动态的。静态对象可以通过告诉引擎它们不会经常更改来节省CPU端(有时是GPU端,例如使用一些实例化技术)的大量性能。


2.7.1 节点设置SCENE_STATIC的含义:

  • 使用SCENE_STATIC创建的节点不会每帧更新其派生位置/旋转/缩放。这意味着修改(例如)静态节点位置实际上不会生效,直到SceneManager::notifyStaticDirty(mySceneNode)被调用或其他一些类似的调用来引发更新。

如果静态场景节点是动态父节点的子节点,则在显式通知 SceneManager 应更新子节点之前,修改动态节点不会使静态节点注意到更改。

如果静态场景节点是另一个静态场景节点的子节点,则显式通知 SceneManager 父节点的更改也会自动导致该子节点也得到更新。

将动态节点作为静态节点的子节点是完全合理和鼓励的,例如悬挂在静态时钟上的移动钟摆。将静态节点作为动态节点的子节点没有多大意义,并且可能是一个错误(除非父节点是根节点)。


2.7.2 实体(和实例实体)设为SCENE_STATIC的含义:

静态实体被安排像动态实体一样进行剔除和渲染,但不会更新其世界 AABB 边界(即使它们的场景节点附加到更改)如果用户调用 SceneManager::notifyStaticDirty( myEntity) 或它们所连接的静态节点也被标记为dirty,静态实体将更新其 aabb。请注意,更新节点的位置不会将节点标记为dirty(它不是隐式的),因此实体也不会更新。

静态实体只能附加到静态节点,动态实体只能附加到动态节点。


2.7.3 一般的

在大多数情况下,更改单个静态实体或节点(或创建更多)可能会导致许多其他静态对象被计划更新,因此不要经常这样做,而是在同一帧中完成所有操作。一个例子是在启动时(即在加载期间)执行此操作。

实体和节点可以通过调用 setStatic 在运行时在动态和静态之间切换。但是,单例实体不能。

如果要切换(顺便说一句,这并不昂贵,因为批处理预先分配实例)具有不同 SceneMemoryMgrType 的单例实体永远不会共享同一批,则需要销毁 InstancedEntity 并创建一个新实例。

注意#1!
调用 SceneNode::setStatic 还会强制调用 MovableObject::setStatic 到其所有附加对象。如果存在不希望切换标志的对象,请先将其分离,然后重新附加。
如果有附加到该节点的实例实例,则必须首先分离它们,因为它们不能直接在类型之间切换。否则,引擎将引发异常。
注意#2!
调用 setStatic( true ) 当它以前是 false 时,将自动为您调用 notifyStaticDirty。

问:这些更改是否意味着您可以在任何实体上设置"静态"标志,并且当有许多静态实体共享相同的材料时,它会自动被视为静态几何图形,并且批次计数会下降?
答:不,
是的。在普通实体上,"静态"允许 Ogre 避免每帧更新 SceneNode 转换(因为它不会更改)和实体的 AABB 边界(因为它也不会更改)。这会产生巨大的性能提升。但是没有批次计数下降。
但是,在使用 Instancing 时,我们已经将具有相同材料的所有内容批处理在一起,因此它确实类似于静态几何,除了我们基于实例进行剔除(这会给 CPU 带来更大的压力,但允许 GPU 进行非常细粒度的视锥体剔除,从而减少工作量),并且 2.0 的剔除代码比 1.9 快几倍。
使用普通实体时,使用"静态"标志时批次计数不会下降。但是,与1.9相比,它将大大提高性能,因为我们跳过了场景节点转换和AABB更新阶段,这需要大量的CPU时间。


2.8 Ogre 在调试模式下断言 mCachedAabbOutOfDate 或 mCachedTransformOutOfDate

如果你断言"mCachedAabbOutOfDate"或"mCachedTransformOutOfDate",它们意味着派生WorldAABB没有被更新,但被尝试使用,或者派生的转换3已经过时并尝试使用它;分别。

它们可能由于各种原因触发:

  1. 节点/对象的类型为 SCENE_STATIC,并且它们的数据已更改(即您调用了 setPosition),但没有调用 SceneManager::notifyStaticDirty。

    解决方法:调用 SceneManager::notifyStaticDirty,或使用隐式调用该函数的函数。

  2. 您已经手动修改了他们的数据(即您调用了setPosition),之后 SceneManager::updateAllTransforms 或 SceneManager::updateAllBounds 发生了;最有可能通过监听器。

    解决方案:

    1. updateAllTransforms之前进行计算。
    2. 如果只是几个节点,在修改对象以仅强制对该节点/实体进行完整更新后调用Node::_getFullTransformUpdated或 MovableObject:: getWorldAabbUpdate。您只需要调用这些函数一次,其转换将被更新和缓存;如果你一直调用"*更新"变体,你只会吃掉CPU周期,每次都重新计算整个转换。
    3. 如果它们是许多节点/实体,请再次手动调用 updateAllTransforms/updateAllBounds。
  3. 这是ogre中的一个bug。由于重构非常大,因此在调用 updateAllTransforms/updateAllBounds 后,某些组件仍会尝试修改节点和可移动对象。

    解决方案:向 JIRA 报告错误。当我们重构该有问题的组件时,节点将在调用 updateAllTransforms 之前被触及,但如果该组件尚未计划重构,我们可能只需调用 getDerivedPositionUpdated 来修复它。


2.9 从可渲染或可移动对象派生的自定义类

在 Ogre 1.x 中,高级用户可以直接向 RenderQueue 提交或注入 RenderQueue,而无需 MovableObject。这是可能的,因为存在冗余(两个类都复制了相同的数据),或者 Renderable 使用虚拟函数从 MovableObject 查询数据(高级用户可以重载以直接提交此数据,而不是依赖于 MO)。

由于 Ogre 2.x; Renderable 必须有一个 MovableObject 链接到它,因为 RenderQueue 的 addRenderable 函数需要两个参数,一个用于 MovableObject,另一个用于 Renderable。提供空指针作为 MovableObject 可能会导致崩溃。

多个可渲染对象仍然可以共享相同的MovableObject,并且实现也不必同时从两者派生。

对于属于场景一部分的对象,Ogre 1.x 使用访客模式来查询 MovableObject 包含的所有可渲染对象。在ogre 2.x中;此模式已被删除。

实现从 MO 派生的自己的类的 Ogre 用户必须填充 MovableObject::mRenderables vector;SceneManager将直接访问该通道,以将可渲染对象添加到 RenderQueue。

此更改背后的原因是性能。访问者模式对于此任务来说成本太高。


2.10 如何从新的v2Mesh类中获取顶点信息?

一旦有了一个Mesh指针,获取subMesh。然后可以抓取Vao。

Mesh *mesh;
SubMesh *submesh = mesh->getSubMesh(0);
VertexArrayObjectArray vaos = submesh->mVao;

每个LOD等级都有一个Vao。注意,多个LOD可能共享一样的顶点缓存。

if( !vaos.empty() )
{
    //Get the first LOD level
    VertexArrayObject *vao = vaos[0];
    const VertexBufferPackedVec &vertexBuffers = vao->getVertexBuffers();
    IndexBufferPacked *indexBuffer = vao->getIndexBuffer();
}

通过它们,你可以获取每个顶点缓存的顶点声明:

for( size_t i=0; i<vertexBuffers.size(); ++i )
{
    //There could be more than one vertex buffer, or even none!
    VertexBufferPacked *vertexBuffer = vertexBuffers[i];
    const VertexElement2Vec &vertexElements = vertexBuffer->getVertexElements();
}

2.11如何设置元素偏移量、顶点缓冲区的源和索引?

v1 接口允许显式指定此数据。但是,这可以自动计算:

class VertexDeclaration
{
public:
    virtual const VertexElement& addElement(unsigned short source,
                                            size_t offset,
                                            VertexElementType theType,
                                            VertexElementSemantic semantic,
                                            unsigned short index = 0);
};

可以根据前一个元素的大小自动计算偏移量。索引参数也可以通过计算数组中已有的元素数来自动计算。资源可以通过具有顶点元素数组来排列。

以下代码等效,v1 vs v2 格式:

//V1 format
v1::VertexDeclaration vertexDecl;
size_t offset = 0;
vertexDecl.addElement( 0, offset, VET_FLOAT3, VES_POSITION, 0 );
offset += vertexDecl.getVertexSize();
vertexDecl.addElement( 0, offset, VET_FLOAT3, VES_NORMAL, 0 );
offset += vertexDecl.getVertexSize();
vertexDecl.addElement( 0, offset, VET_FLOAT2, VES_TEXTURE_COORDINATES, 0 );
offset += vertexDecl.getVertexSize();
//Second pair of tex coords
vertexDecl.addElement( 0, offset, VET_FLOAT2, VES_TEXTURE_COORDINATES, 1 );
offset += vertexDecl.getVertexSize();
//Third pair of tex coords, in a second buffer
vertexDecl.addElement( 1, 0, VET_FLOAT2, VES_TEXTURE_COORDINATES, 2 );
//V2 format
VertexElement2VecVec multVertexDecl;
multVertexDecl.reserve( 2 ); //Not strictly necessary.
VertexElement2Vec vertexDecl;
vertexDecl.reserve( 4 ); //Not strictly necessary.
vertexDecl.push_back( VertexElement2( VET_FLOAT3, VES_POSITION ) );
vertexDecl.push_back( VertexElement2( VET_FLOAT3, VES_NORMAL ) );
vertexDecl.push_back( VertexElement2( VET_FLOAT2, VES_TEXTURE_COORDINATES ) );
//Second pair of tex coords
vertexDecl.push_back( VertexElement2( VET_FLOAT2, VES_TEXTURE_COORDINATES ) );
multVertexDecl.push_back( vertexDecl ); //Add the decl. of the first buffer
vertexDecl.clear();
//Third pair of tex coords, in a second buffer
vertexDecl.push_back( VertexElement2( VET_FLOAT2, VES_TEXTURE_COORDINATES ) );
multVertexDecl.push_back( vertexDecl ); //Add the decl. of the second buffer

这种方法更方便,因为用户经常搞砸偏移参数(即忘记更新它),而且也更紧凑。毕竟它只是一个 std::vector 容器。唯一的问题是,元素插入向量的顺序现在是相关的。

您可以使用静态函数VaoManager::calculateVertexSize( const VertexElement2Vec &vertexElements )来计算声明向量所持有的顶点大小(以字节为单位)。


2.12我的场景看起来太暗或沉闷!

  1. 检查您是否正在使用伽马校正。如果您让 Ogre 通过配置创建 RenderWindow,则此代码段可以强制设置它:

    mRoot->getRenderSystem()->setConfigOption( "sRGB Gamma Conversion", "Yes" );
    mRoot->initialise(true);
    

    如果要手动创建 RenderWindow,则需要以下代码段:

    Ogre::NameValuePairList params;
    params.insert( std::make_pair("gamma", "true") );
    mRenderWindow = Ogre::Root::getSingleton().createRenderWindow( windowTitle, width, height, fullscreen, &params );
    

    PBS期望伽马校正,否则它看起来不对。

  2. 切换到PBS管道并期望所有内容仍按原样工作是一个常见的误解。材质参数可能需要认真调整。高级菲涅耳系数会把漫反射分量从材质中去除,从而导致材料变暗,粗糙度系数也会直接影响材料外观的暗淡或亮起。

    甚至颜色也不同,因为应该使用现实生活中的价值观。RGB(1.0,1.0,1.0)的白色材料意味着令人难以置信的明亮材料。甚至纸都不是那么白。

    PBS也最适合HDR管道,因为现实生活中的值意味着太阳在数以万计的lums中具有色彩的力量(另见Crytek的参考资料;参见Frostbite的幻灯片,从35开始)。当然,如果没有HDR,明亮的太阳功率肯定会溢出常规的32位RGBA渲染目标。

    颜色值需要校准,请参阅UnitySebastien Lagarde的更多)和FarCry更多FarCry)。

    甚至纹理也可能需要调整,请参阅Crytek的幻灯片(幻灯片56),了解什么构成"好"漫反射纹理和"坏"漫反射纹理(应该几乎不包含任何自阴影;细节由光泽贴图和非常强的法线贴图给出,如果通过3DS Max或Maya查看,通常看起来很糟糕)。这是场景看起来"沉闷"和毫无生气的常见原因。

    底线,这是一个艺术问题,而不是技术问题。

  3. 默认情况下,PBS 实现将按 PI 划分漫反射颜色,以确保正确性。如果您使用的是 LDR 管道而不是 HDR 管道;将指示灯的功率 (Light::setPowerScale) 设置为 PI (3,14159265…) 以进行补偿。


2.13 我激活了伽玛校正,但现在我的GUI纹理看起来洗掉了!

HlmsTextureManager 将加载具有伽玛校正的漫反射纹理以避免此问题。

但是,如果您通过外部方式加载它们(即使用常规的TextureManager;例如CEGUI),则需要显式加载它们并进行伽马校正。


  1. 这是一个性能优化。这背后的原因是什么, 阅读 Ogre 2.0 design slides ↩︎

  2. 例如:Entities, InstancedEntities ↩︎

  3. Derived position, orientation, scale or the 4x4 matrix transform. ↩︎

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值