65.9K
CodeProject 正在变化。 阅读更多。
Home

三维场景中的交互技术(第一部分):使用OpenGL 2.1通过鼠标移动3D对象

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (75投票s)

2009年4月7日

CPOL

38分钟阅读

viewsIcon

331842

downloadIcon

25750

使用OpenGL通过鼠标移动3D对象。

目录

为方便参考,文章涵盖内容如下:

引言

有许多用于交互式3D建模和场景构建的应用程序,它们提供了选择、定位和旋转3D对象的三个基本操作任务。本文介绍了一种使用标准的2D鼠标平移3D对象的策略,并提供了一个使用OpenGL 2.1在C++/CLI Windows Forms应用程序中的老式示例实现,以说明其中涉及的基本原理。我试图将其保持得尽可能基础,希望它能帮助您创建自己的实现。

图1:演示项目交互功能的视觉摘要。

移动对象是3D交互软件中的一项常见任务,具有多种场景。该演示包含其中一种场景。本文讨论的是场景中的一个特定部分:当您左键单击对象并将其拖动到所需位置的部分。查看上面的图1,我们有

  1. 轴约束下拉菜单: 应用程序启动时(如图所示),“在XY平面变换”是默认选项。下拉列表中的其他变换约束选项包括X轴、Y轴、Z轴、XZ平面或YZ平面。
  2. 移动对象按钮。单击此按钮可将“对象移动模式”切换为“开”(按钮上几乎看不见的蓝色轮廓表示“开”)。“嘿,我可没说过我知道如何做好界面,对吧?”
  3. 通过鼠标左键单击并拖动对象来选择并移动它。正如迈克尔·“杰克逊”·杰克逊会说的“就是它了!”或者我会说“本文将向您展示如何实现这部分内容”。当您在XY平面或您选择的任何其他3D平面或轴上拖动对象时,它会感觉像是粘在鼠标光标上一样。而且,您几乎可以从任何合理的相机视角或角度移动对象。(就像在任何高质量的3D建模包中一样)。

此场景结合了WIMP(1 & 2)和直接操作(3)。

还有一个更直观的场景,它仅涉及直接操作,即通过一个三轴小部件移动对象。我暂时不会涉及这一点(也许在后面的第二部分中会有)。在图1中,轴对齐的三轴(一个在视口的左下角,一个连接到对象中心)不是交互式的(即,不是传统UI定义的小部件);它们仅提供方向的视觉线索。

我将专注于这两个场景所依据的原理。那就是:使用轴对齐平面通过鼠标指针移动3D对象。在演示中,您可以通过从视图上下文菜单(右键单击视口任何位置可弹出上下文菜单)中选择“显示平面”来观察平面。如果您立即下载演示以立即查看其效果(我们都会这样做),但遇到问题,使用演示项目部分可能是寻求帮助的首选。

本文档面向哪些读者?

本文旨在为不熟悉3D交互技术的开发人员提供一个简单、清晰、实用的描述和示例,他们希望实现一个像专家构建的那样工作的3D平移控制器(当然,您自己判断是否达到了这个目标)。本文至少应帮助您开始自己的实现。您可能对以下内容有所了解后,阅读本文(并下载源代码)会更舒适:

  • OpenGL:主要是因为演示源代码是用OpenGL 2.1实现的,但其他API也可以类似地实现。
  • Windows Forms和用户界面开发(功能托管在C++/CLI Windows Forms应用程序中,该应用程序构建在Windows Vista和XP上的Visual C++ 8.0中)。
  • 3D图形:特别是透视和正交3D投影,渲染循环(又称游戏循环),以及
  • 将以上所有内容集成到应用程序中的一些典型策略。

背景

这可能很重要,但不会。有关此主题的背景(或者说这个主题所在的“干草堆”)的示例,请参阅进一步阅读部分。请继续阅读本文的精炼版本。传统的2D鼠标3D交互技术依赖于某种形式的引导实体或额外的抽象对象来辅助3D对象的操纵。例如,存在基于众所周知的虚拟球体(又称弧球或轨迹球)来旋转3D对象的描述。而使用轴对齐平面和线来平移对象,这种想法则没有得到那么好的或清晰的描述。您可以在题为3D Manipulators的教程文章中使用XNA找到另一个实现。对3D交互技术文献的调查表明,这种想法是最早的3D对象平移的直接操作技术之一,并且仍然是最流行的3D建模工具,至少在外表上是如此(以某种形式的三轴平移控件提供给用户),并且实现细节可能有所不同。今天,使用平面进行平移的想法似乎是旋转的虚拟球体想法的自然延伸(而在十多年前可能并非如此明显?)。

选择和移动对象概述

让我们回顾一下我们感兴趣的过程步骤,从用户的角度来看(这些步骤可以在演示应用程序中执行):

  1. 开启移动对象模式:单击“移动对象”按钮。
  2. 约束对象移动:从下拉菜单中选择对象移动将约束到的平面。
  3. 选择对象:将鼠标指针移到对象上,按住鼠标左键以选择对象。按住鼠标左键。
  4. 移动对象:按住鼠标左键拖动鼠标以移动对象。
  5. 停止移动对象:移动对象后释放鼠标左键。

我们的平移控制器在步骤3中初始化,并在步骤4中用于移动对象,直到步骤5。

基于虚拟平面的平移控制器

这里是实现的核心。虚拟平面是我们的引导实体(虚拟,意味着不会实际渲染到屏幕上)。该平面将对齐到仅三种可能的轴对齐之一(xy、xz或zy)。选择对象后,平面将移至对象上的3D选择点。平移是通过射线投射程序计算的,该程序包括在世界空间中从视点穿过光标位置传播一条射线,然后在每次光标移动时测试该射线与平面的相交。在此过程中传播的上一个交点与最近一个交点之间的差值将成为用于平移对象的位移向量。

在设计中采用了模块化方法,以便平移控制器既可以作为独立单元使用,也可以与3D控件的形式的实际可见引导实体(强烈推荐)结合使用,以帮助减少用户在此过程中的认知负担并提高实现的鲁棒性。

下面将详细描述基于虚拟平面的平移控制器,它包括以下内容:

设计

图2:对象平移算法

算法描述(图2)如下。

  1. 选择对象:当用户选择一个对象时
    1. m1射出一条射线与对象相交,以获取交点p1处的3D位置。
    2. 将虚拟平面对齐到对象移动已约束到的轴(图示中使用XY平面作为示例)。
    3. 将虚拟平面的高度调整到p1的高度。
  1. 移动对象:当鼠标指针移动到m1’
    1. 执行射线-平面相交测试:从m1’射出一条射线与虚拟平面相交,以获得交点p1’
    2. 通过向量p1’ – p1移动对象。
    3. p1 = p1’,为下一次鼠标光标移动(即步骤4(a)、(b)和(c)的下一次迭代)做准备。

注意:在步骤(3c)中,将虚拟平面的高度调整到p1的高度对于在应用了透视投影的视图中,保持2D鼠标指针与正在移动的对象上的3D位置之间的位置相关性一致非常重要,而对于使用正交投影的视图,通常不是必需的。

特殊情况

算法需要处理一种特殊情况:当相机/观察平面在应用了正交投影的视图中垂直于虚拟平面时。有关更多信息,请参阅特殊情况部分。

源代码概述

  • 演示源代码在C++/CLI Windows Forms应用程序中使用OpenGL API,该应用程序已在(a)Windows Vista上的Visual C++ 2008 Professional和Express Edition以及(b)XP上的Visual C++ 2008 Express Edition中构建。
  • 没有可重用的程序包/组件或类(项目内容未经设计/测试或以这种方式呈现使用),并且实现平移控制器所需的代码很少。您可能已经有了平面、射线、拾取、碰撞检测和用户鼠标控件等内容。因此,推荐的方法是根据您的判断,挑选并组合提供的代码片段和概念。
  • 我想为您提供一个最小化的3D工具环境来尝试平移控制器(我喜欢冒险……有时)。因此,该项目使用我 elsewhere 使用的大约十几个DLL(为本文档创建了一个最小化的3D查看/建模工具环境),只要此策略对我们有效,它们就可以安全地忽略。源代码中还有少数几个类,但只有3个主要类需要关注。它们是MouseTranslationController类、Ray类和Plane类。其他类(和/或它们的某些方法)可能会被提及或讨论,就它们如何参与通过鼠标移动对象的过程(或用例)以及平移控制器如何集成到应用程序中工作而言。希望这能帮助您更清晰地构思平移控制器可能适合(或可能不适合)您的应用程序的位置。

代码(1):平移控制器类概述

下面列出了构成平移控制器的3个类(在Ray, Plane MouseTranslationController中)的摘要视图。

MouseTranslationController.h

/// <summary>
/// MouseTranslationController()
/// This class controls how the mouse translates objects in an opengl 3D space
/// However, it currently has one very specific use:
/// It shoots a ray through the mouse location testing for intersections with
/// the an axis aligned plane. It is more of a RayPlaneIntersectionPointFinder
/// than anything else at the moment( and could/should be renamed as such?).
///
/// Vectors and points in 3D space given to this class to manipulate
/// must conform to the flight simulator
/// co-ordinate system and not the OpenGL co-ordinate system.
/// The difference is: The Z axis is vertical in
/// the flight simulator; whereas the Y axis is vertical in OpenGL
/// (This is an inconsistency that
/// could/should be removed ).
/// </summary>
public ref class MouseTranslationController
{
        Ray^ ray;
        Plane^ plane;
        Vector3^ position;             // intersection point of ray on plane.
        DWORD translationConstraint;   // The axes that translation is constrained to

        Vector3^ displacement;
        static Vector3^ lastposition;

        void CreatePlane();
        void CreateRay(int x2D, int y2D);
        void SetPlaneOrientation(DWORD theTranslationConstraint);
        void InitializePlane(DWORD theTranslationConstraint);
        void ApplyTranslationConstraint(Vector3^ intersectPos);
        property Vector3^ PlanePosition;
        property Vector3^ Position;
public:
        MouseTranslationController(void);
        void Initialize(int x2D, int y2D,
                 DWORD theTranslationConstraint,Vector3^ planePos);
        void Update(int x2D, int y2D);
        void DrawPlane();

        property Vector3^ Displacement;
        property DWORD TranslationConstraint;
};

稍后我们将看到,应用程序在移动对象操作期间直接只使用3个函数:Initialize()Update()Displacement属性。

Primitives.h

//=====================================================================================
//    Plane defined by a point and a normal
//    The normal and point are expressed in non-opengl Cartesian co-ordinates.
//    where the Z axis in vertical. In opengl the screen and the Y axis is vertical.
//=====================================================================================
public ref class Plane
{
public:
        Vector3^ Normal;
        Vector3^ Position;
        DWORD transformAxes;

        Plane(void);
        Plane(Vector3^ norm,Vector3^ pos, DWORD axes);

        void DrawNormal();
        void Draw()
        property DWORD TransformAxes;
};

//======================================================================================
//      Ray defined by 2 points (can we call it a segment?)
//
//      The 2 points p0 and p1 are expressed in non-opengl Cartesian co-ordinates.
//      where the Z axis in vertical. In opengl the Y axis is vertical.
//======================================================================================

public ref class Ray
{

public:
        Vector3^ p0;
        Vector3^ p1;

        Ray(void);
        Ray(Vector3^ P0,Vector3^ P1);
        Ray(int x2D, int y2D);

        void Generate3DRay(int x2D, int y2D);
        int Intersects(Plane^ p, Vector3^ I);
};

Plane Ray 原始对象没有什么特别有趣的,除了可能Intersects() 函数,否则这些类与其他通常用于表示射线和平面的结构/类非常相似。

代码(2):类在演示应用程序中的使用方法

在获取代码之前,让我们看看TranslationController 通常如何融入应用程序(以非正式的伪代码表示)。

MoveObject 过程

  • 按下“移动对象”按钮
  • 选择轴/平面约束
  • 选择并移动对象(鼠标左键单击对象并拖动鼠标)
    • OnLeftMouseButtonDown:
      • 拾取一个对象
      • InitialiseTranslationController
    • OnMouseMove:
      • UpdateTranslationController (生成下一个3D位移向量来移动选定的对象)
      • MoveObject (使用刚才由UpdateTranslationController生成的3D位移向量
  • 释放鼠标左键以停止该过程

这里是关于平移控制器使用的参数的更多细节。

/// The Translation Controller is initialized with
///     - The current mouse pointer position (to generate a ray from)
///     - A flag indicating the axis or plane that object movement
///       has been restricted to by the user
///     - The 3D ‘hit’ point on the object that was picked.
///       The plane is moved so that this point lies on it.
///       This is the start point of object translation.
TranslationController->Initialize(MousePosition, AxesContraint, 3DHitPoint)

/// The Translation Controller is updated by
///     -passing it the current mouse pointer position
/// It generates the next displacement vector used to move the object to
TranslationController->Update(MousePosition)
///
/// Get the 3d displacement the translation controller just generated and give it to the
/// function that will use it to move the object.
MoveObject(TranslationController->Displacement)

下面是平移控制器实际集成到演示中的方式。在实现中,Initialize()在渲染循环中调用,而Update()在鼠标移动时调用(即在视图面板的MouseMove() 事件处理程序中)。至于渲染循环:当决定在Forms应用程序中为图形API实现渲染循环(又称游戏循环)时,通常有两种主要选择:在控件的paint方法中或在timer控件中。本示例使用Timer 控件。下面的timer1代码是通过将Timer 控件拖放到设计器中的窗体上,将Interval属性设置为1ms,并选择其Tick 事件来生成的。

this->timer1->Enabled = true;
this->timer1->Interval = 1;
this->timer1->Tick += gcnew System::EventHandler(this, &Form1::timer1_Tick);

/// The rendering loop (or so called Game Loop)
private: System::Void timer1_Tick(System::Object^  sender, System::EventArgs^  e)
{
     DrawView();
}
void Form1::DrawView()
{
    m_OpenglView->DrawFrame();
    ...
    ...
    if(m_OpenglView->PickSelection)
    {
         PickAnObject();
         InitTranslationController();
         // switch off picking
         m_OpenglView->PickSelection = false;
         // we do not render this pass
         return;
    }

    // Draw stuff
    ...
    ...
    m_OpenglView->SwapOpenGLBuffers();
}

以及MouseMove 事件处理程序

private: System::Void view1_MouseMove
         (System::Object^  /*sender*/, System::Windows::Forms::MouseEventArgs^  e)
{
        // ultimately calls...
        translationController->Update((int)Mouse2DPosition->X,(int)Mouse2DPosition->Y);
        m_scene->MoveSelectionSetDelta(translationController->Displacement);
        // where Mouse2Dposition contains the X and Y properties of the MoseEventArgs e
}

代码(3):实现细节

步骤1:拾取一个对象

3(a) 从m1射出一条射线与对象相交,以获取交点p1处的3D位置,这是由窗体的PickAnObject() 函数执行的。如果拾取了对象,射线击中对象的位置将存储在窗体的Mouse3dPickPosition属性中。颜色编码拾取是用于选择对象的策略(可以使用任何拾取策略)。对象的颜色用于识别和选择拾取的对象(注意:您可能知道GetPixelColorAnd3DPostion() 函数违反了一些“良好”的编程实践规则,但希望至少它的名称能传达其目的)。

void PickAnObject()
{
        m_scene->DrawObjectsInPickMode();
        array<unsigned char>^ color =
                 GetPixelColorAnd3DPosition(Mouse3DPickPosition,
                 (int)Mouse2DPosition->X,(int)Mouse2DPosition->Y);
        m_scene->SelectPickedModelByColor(color);
}

步骤2:初始化平移控制器

3(b) 虚拟平面的对齐和3(c)平面高度到p1的调整在平移控制器的Initialize() 函数中执行。

void Initialize(int x2D, int y2D, DWORD theTranslationConstraint,Vector3^ planePos)
{
    ray->Generate3DRay(x2D, y2D);
    PlanePosition = planePos;
    Position = planePos;
    LastPosition = Position;
    Displacement = Vector3::Zero();
    InitializePlane(TranslationConstraint);
}

如下所示,调用函数时传递的参数是:

  • 当前鼠标位置Mouse2DPosition->X, Mouse2DPosition->Y
  • 用户为对象移动选择的轴/平面(m_CurTransformationAxes 存储了一个枚举标识符),以及
  • 刚刚在PickAnObject() 中拾取的对象上的3D位置。
void InitTranslationController()
{
    if(m_scene->PickedModel != nullptr)
        translationController->Initialize((int Mouse2DPosition->X,
        (int)Mouse2DPosition->Y, m_CurTransformationAxes, Mouse3DPickPosition);
}

让我们稍微深入一点。如果您还记得,我们之前说过虚拟平面的高度设置为拾取点的高度。这是设计的一个最低要求。然而,在实现中,拾取点是用于定义虚拟平面的点。主要原因是简单地渲染一个以该点为中心的可移动的[无限]虚拟平面的一小部分(用于提供一些视觉反馈)。

步骤3:更新平移控制器

4(a) 射线-平面相交测试在平移控制器Update()中执行。屏幕上的2D鼠标位置被传递到此函数。传播鼠标射线;找到鼠标射线与虚拟平面的交点;基于轴约束修改交点;并更新定义平面和位移向量的点。

void Update(int x2D, int y2D)
{
     Vector3^ intersection = Vector3::Zero();
     ray->Generate3DRay(x2D, y2D);
     ray->Intersects(plane,intersection);
     ApplyTranslationConstraint(intersection);
     PlanePosition = Position;
     Displacement = Position - LastPosition;
     LastPosition = Position;
}

ApplyTranslationConstraint() 可能看起来比实际复杂。如果您在源代码中查看它,您会发现当平移仅限于一个轴时,它所做的只是生成新的对象平移位置有所不同:基本上,位置仅沿受限制的那个轴更新。

为完整起见,这是调用

void UpdateTranslationController()
{
    translationController->Update((int)Mouse2DPosition->X,(int)Mouse2DPosition->Y);
}

一旦移动对象操作开始,Update()将继续在每次鼠标移动时调用,生成一个新的增量位移向量,直到释放鼠标左键。

步骤4:移动对象

Update()生成的位移向量用于平移对象。

m_scene->MoveSelectionSetDelta(translationController->Displacement);

这由视口控件的MouseMove事件处理程序处理(m_OpenglView 是我们的视口,它继承自Panel)。因此,每次鼠标移动时(在对象移动模式下,鼠标左键按下),最终都会调用Scene::MoveSelectionSetDelta() 函数(如下面的代码片段所示,它隐藏在其他函数中)。

// in Form1.h

m_OpenglView->MouseMove += gcnew System::Windows::Forms::MouseEventHandler
                                            (this, &Form1::view1_MouseMove);

view1_MouseMove();
       calls --> ProcessMouseMove();
              calls -> EditModeMouseMove()
                      calls ->
                               UpdateTranslationController();
                               m_scene->MoveSelectionSetDelta(); 
void Scene::MoveSelectionSetDelta(Vector3^ deltaV)
{
      if(deltaV == nullptr)return;
      UpdateSelSetWorldPos(deltaV->X, deltaV->Z, deltaV->Y);
}

沿单轴平移

除了将其他两个轴的平移值置零(设为零)并将虚拟平面绕(平移)轴旋转,使其“更平行”于观察平面(以尽量减少用户在移动操作期间迷失方向的可能性,尤其是在鼠标指针未沿单个3D轴移动时)之外,沿单轴的平移没有得到(代码上的)太多关注。虚拟平面被旋转,使其平行或“共面”于观察平面,相对于其他两个(非平移)轴分量(如下图所示)。

图3:沿单轴平移

图示了一个示例过程:对于沿Y轴的平移,平面法向量的X和Z分量设置为初始相交(或拾取)射线的X和Z值。这会产生使平面绕Y轴旋转的效果,从而使平面的局部XZ分量平行/共面于观察平面的局部XZ分量(在具有正交*投影的视图中)。这部分代码位于平移控制器的SetPlaneOrientation() 函数中。

在之前提到的教程文章3D Manipulators中可以找到使用线来处理沿单轴平移的替代方法(在文章的背景部分)。

*注意:从鼠标到应用了透视投影的视图投射的射线通常不垂直于观察平面(但在我们的情况下通常很接近)。相比之下(冒着陈述显而易见的风险),投射到具有正交投影的视图中的射线总是垂直于观察平面。

特殊情况

图4:特殊情况实例

存在一个需要处理的特殊情况:在应用了正交投影的视图中,观察平面垂直于虚拟平面。射线-平面相交测试失败,因为虚拟平面的法向量垂直于射线,因此射线不会与平面相交。当发生这种情况时,有12种可能的实例(图4)。最明显的解决方案是将虚拟平面对齐到最能匹配平移约束和观察平面状态的轴,以便进行平移。我们可以使用排除法来找到这种对齐:将虚拟平面对齐到3种可能的轴对齐方式中的每一种,并执行相交测试,直到测试不失败为止。在最佳情况下,我们可以执行一次测试,而在最坏情况下,则需要执行3次。以最坏情况为例:假设用户将平移约束到x轴。对于与XY平面和YZ平面对齐的射线,相交测试将失败,而对于XZ平面,测试将成功。排除法在最坏情况下也可能效率最低。

我们可以尽早验证输入以减少不必要的处理。即,我们获取观察平面的位置/方向,并将虚拟平面对齐到确保其平行于观察平面的轴;这将确保相交测试成功。这是一个简单的解决方案,尽管有些繁琐。

以下是访问观察平面状态的两种方法:

  1. 我们可以抽象地获取它:通过相机类/对象(如果我们有的话)或通过构建在OpenGL 3.0之上的自定义变换/视图矩阵状态,或者
  2. 我们可以直接通过OpenGL(3.0之前)模型视图矩阵的当前状态获取它。演示源代码在使用(访问模型视图矩阵)的平移控制器的InitializePlane() 函数中。有关更多信息,请参阅补充部分中的OpenGL模型视图矩阵和相机基向量

一些改进空间

总有改进的空间。以下是一些可能的选项:

  • TranslationController 类(MouseTranslationControllerRayPlane)需要z坐标轴垂直的点和向量。在OpenGL中,Y轴是垂直的。如果您是OpenGL“中心主义者”,可以消除这种不一致性(否则,保持其更通用的形式,用于与其他API...例如DirectX?)。如果您不理解这一点,可以安全地忽略它。
  • 感觉可以进行一些优化,但我不知道是什么,所以留给您。

平移控制器尚未集成到演示应用程序以提高效率

  • Update() 通过MouseMove 处理程序继续调用时,在MouseDown 事件处理程序中拾取对象并初始化平移控制器可能在逻辑上更正确,而不是在渲染循环中(渲染循环有点像“一步之遥”,距离直接输入事件处理程序)。
  • 探索渲染循环选项:这可能更多是一个研究问题,而不是一个改进。我们使用System.Windows.Forms.Timer 控件作为我们所谓的游戏循环。另一个流行的选项(我尚未在此实现中尝试过)是继承Forms控件(例如Panel 控件),然后重写并使用控件的Paint 方法作为所谓的渲染循环。在这两种情况下,代码都使用应用程序的UI线程执行。UI线程是显而易见的第一个选择,并且测试表明算法中最具计算量的部分是连续的射线-平面相交测试(其中一个帧可能需要1.67毫秒才能渲染[约60fps]并有所变化,相交测试可能需要0.06毫秒)。还有另一种选择是使用多线程。在这种情况下,还有2个其他计时器可以使用:System.Timers.Timer System.Threading.Timer ,如果您认为您需要它们(就像Intel的人们一样)。
  • OpenGL 3.0。如果您觉得有必要主动一些,并且OpenGL 2.1对您来说太老了,那么您可能会想通过可编程管线(即着色器)来实现此设计;进行存储或派生您自己的矩阵版本/状态以适应您的特定实现需求等操作。该演示尽可能多地利用了OpenGL提供的功能(固定管线或不):将更多内容卸载到OpenGL API意味着为我们编写的代码更少(一个简单但可能致命的方法?但希望您尽管实现有所不同,仍能抓住本文的要点)。
  • 我已反复审查和修改本文档以消除错误(无穷无尽)。但它永远不像同行评审(即,由其他人来审查;你知道,就像软件测试一样:如果你能避免,你就不想让程序员测试自己的程序——他们似乎本能地有偏见)。因此,如果您注意到任何错误(明显或误导性的),或有改进建议以使其他读者受益,请告知我们。

使用演示应用程序

您必须知道的内容

  • 为了使颜色编码拾取工作,计算机系统的颜色分辨率必须设置为32位(高)。
  • 您的系统上必须安装.NET Framework(至少2.0)。

您应该知道的内容(关于用户界面)

下图显示了

  • 演示截图,显示当前虚拟平面和平面法向量的10x10部分。其可见性可以通过“显示平面”上下文菜单项来切换。
  • 屏幕截图的右上角显示了相机菜单条和对象菜单条。移动对象按钮周围几乎可见的蓝色边框表示它当前处于活动状态。
  • 屏幕截图的右下角显示了上下文菜单中所有可用的查看选项。在视口中右键单击,即可弹出上下文菜单供您选择选项。

图5:演示项目交互功能的视觉摘要。

使用鼠标移动对象

  1. 在“对象”菜单条上单击“移动对象”按钮以开启“移动对象”模式。
  2. 从轴约束下拉菜单列表中选择要约束对象移动的平面/轴。
  3. 将鼠标指针移到对象上,按住鼠标左键以选择对象。按住鼠标左键。
  4. 按住鼠标左键拖动鼠标以移动对象。
  5. 移动对象后释放鼠标左键。

在“文件”菜单上,您会找到2个选项:

  • 打开:允许您从文件打开对话框加载3ds、obj、x或im模型文件。
  • 新建:清除当前场景中的所有模型。

提示(由于应用程序的限制)

  • 相机将始终指向原点,因此为了获得最佳观看体验,请不要将相机平移得离原点太远(旋转相机时)。
  • 提供了一个模型用于查看。如果您决定加载其他模型,请注意:加载时应用程序不会缩放到其范围或位置。因此,您可能需要手动缩放或移动相机才能看到非常大/非常小的模型,或未位于原点附近的模型。
  • 缩放:单击相机菜单条上的缩放按钮。在视口中垂直拖动鼠标以缩放(按住鼠标左键)。注意:这不是真正的缩放(视野),而是“相机”前后移动(推拉)以产生缩放效果。
  • 旋转:单击相机菜单条上的旋转按钮。在视口中拖动鼠标(按住鼠标左键)以感受旋转方式。相机翻滚功能未实现。
  • 平移:单击相机菜单条上的平移按钮。同样,在视口中拖动鼠标(按住鼠标左键)以感受平移方式。
  • 如果您在应用程序中丢失了对象(是的,不幸的是,您可能会丢失它!),只需执行文件->新建来清除场景,然后文件->打开并重新加载对象(重置按钮会更好,但是...?)。

您可能想知道的内容

演示包中包含相应的DLL,使其可以在Windows XP以及Vista.

其构建系统(系统1)上运行。

  • Windows Vista(Service Pack 1)
  • .NET Framework 2.0(最低)
  • 512 MB Radeon HD 4850
  • 32位(高)颜色分辨率设置
  • AMD Athlon(tm) 64 X2双核处理器 4400+ 2.3GHz,2GB RAM

另一个构建和测试它的系统(系统2):

  • Windows Vista(Service Pack 1)
  • .NET Framework 2.0(最低)
  • 256 MB Radeon X1950 Pro
  • 32位(高)颜色分辨率设置
  • AMD Athlon(tm) 64 X2双核处理器 4400+ 2.3GHz,1GB RAM

一个[旧]测试系统(系统3):

  • Windows XP(Service Pack 3)
  • .NET Framework 2.0(最低)
  • 32 MB geforce2 (mx 200)
  • 32位(高)颜色分辨率设置
  • AMD 3.0 GHz,512 MB RAM

您可以假定您的系统配置越接近系统1,它运行得越好。在较旧的测试系统(系统3)上,鼠标移动和对象移动之间存在明显的延迟,这使得当前的平移控制器在该系统上可能无法实际使用。

更广阔的视角

平移控制器仅控制单个完整对象在全局3D空间/坐标系统中的平移,通过鼠标进行。还有以下问题需要考虑:

  • 在局部3D空间/坐标系统中进行平移
  • 使用直接操作控件来平移或操纵对象
  • 选择/定位子元素/对象子集(子对象、网格、多边形、顶点)
  • 使用多个视口(例如4个)来维持/增强用户在应用程序3D空间中的方向感(用户在某些操纵过程中在脑海中可视化正在发生的事情的认知负担太大了,只有一个视口,尤其是在特殊情况下,平移在对象上的完整性质并不明显)。这种“认知过载”可能是促使3D交互式应用程序研究人员寻求新解决方案的主要因素。
  • 通过对话框(WIMP式界面选项)将对象移动到某个位置
  • 研究在GPU/可编程管线/OpenGL 3.0上实现某些功能(例如,在着色器中:在着色器中实现部分功能是否有任何优点/缺点?)
  • 将3D交互技术应用于其他基本对象操纵(即旋转和缩放)
  • 有关更全面的介绍,请参阅文章末尾的进一步阅读部分。

补充信息

本节包含在撰写本文档时生成的信息。这些主题对大多数有经验的程序员来说是熟悉的,他们可能已经见过以某种形式涵盖它们(所以请随意批评)。因此,它可能对初学者和不太有经验的人更有用。

从鼠标指针射出拾取射线

下图显示了从鼠标指针射出拾取射线的快照。

p0位于近裁剪平面(在本文中也称为观察平面)上,而p1位于远裁剪平面上。3D点p0和p1是使用鼠标指针在屏幕上的2D xy位置以及近裁剪平面的z值为0,远裁剪平面的z值为1生成的。射出从p0p1的射线的代码如下所示。请注意,函数末尾的Z和Y值交换通常不是必需的。在我们的例子中,p0和p1需要是飞行模拟器坐标形式,因为过程中的下一个函数(即Intersects 函数)需要它们是该形式。

void Generate3DRay(int x2D, int y2D)
{
       if(p0 == nullptr)
               p0 = Vector3::Zero();
       if(p1 == nullptr)
               p1 = Vector3::Zero();
       int x =x2D;
       int y =y2D;
       GLint viewport[4];
       GLdouble modelview[16];
       GLdouble projection[16];
       GLdouble winX, winY;
       GLdouble winZ0 = 0.0f; GLdouble winZ1 = 1.0f;
       GLdouble posX0, posY0, posZ0;
       GLdouble posX1, posY1, posZ1;
       glGetDoublev(GL_MODELVIEW_MATRIX, modelview);
       glGetDoublev(GL_PROJECTION_MATRIX, projection);
       glGetIntegerv(GL_VIEWPORT, viewport);
       winX = (GLdouble)x;
       winY = (GLdouble)viewport[3] - (GLdouble)y;
       glReadBuffer( GL_BACK );
       gluUnProject( winX, winY, winZ0,
                 modelview, projection, viewport, &posX0, &posY0, &posZ0);
       gluUnProject( winX, winY, winZ1,
                 modelview, projection, viewport, &posX1, &posY1, &posZ1);
       // swap opengl co-ordinates back to NASA flight simulator co-ordinates
       p0->X = (float)posX0;
       p0->Y = (float)posZ0;
       p0->Z = (float)posY0;
       //
       p1->X = (float)posX1
       p1->Y = (float)posZ1;
       p1->Z = (float)posY1;
}

OpenGL按颜色编码拾取

拾取可以通过多种方式实现。在我们的示例中,使用了颜色编码拾取。当一个对象加载到场景中时,它会被分配自己的线框颜色(即,当以线框模式绘制时,对象的线框颜色)。在进行拾取之前,场景中的每个对象都使用此颜色绘制,如下所示。图片(a)显示了纹理和线框模式下的正常渲染。图片(b)显示了如何为颜色编码拾取渲染对象。

(a)纹理和线框渲染

(b)颜色编码拾取渲染

颜色编码渲染由场景的DrawObjectsInPickMode() 函数在PickAnObject()中执行,其中对象不渲染到屏幕。实际的渲染代码超出了本文档的范围,因此有关OpenGL颜色编码拾取的更多信息,您可以访问此处。拾取代码如下提供。

///
/// GetPixelColorAnd3DPosition() returns the current 3d position and
/// color of the 2D pixel at (x,y)
/// using the current viewport, projection and modelview data that defines our (camera's)
/// view into the 3D world. It is only used to pick objects in 3D space.
///
array<unsigned char>^ GetPixelColorAnd3DPosition(Vector3^ positionOut,int x, int y)
{
        static GLint viewport[4];
        static GLdouble modelview[16];
        static GLdouble projection[16];
        GLfloat winX, winY, winZ;
        GLdouble posX, posY, posZ;
        glGetDoublev(GL_MODELVIEW_MATRIX, modelview);
        glGetDoublev(GL_PROJECTION_MATRIX, projection);
        glGetIntegerv(GL_VIEWPORT, viewport);
        GLubyte pixel[3];
        winX = (float)x;
        winY = (float)viewport[3] - (float)y;
        glReadBuffer( GL_BACK );
        //read color and depth
        glReadPixels(x, int(winY),1,1,GL_RGB,GL_UNSIGNED_BYTE,(void *)pixel);
        glReadPixels( x, int(winY), 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &winZ );
        gluUnProject( winX, winY, winZ, modelview, projection,
                          viewport, &posX, &posY, &posZ);

        positionOut->X = (float)posX;
        positionOut->Y = (float)posZ;
        positionOut->Z = (float)posY;
        array<unsigned char>^ color = gcnew array<unsigned char>(3);
        color[0] = pixel[0];
        color[1] = pixel[1];
        color[2] = pixel[2];
        return color;
}

射线与平面相交测试

这不是一个关于如何执行射线-平面相交的教程,而是对理解应用程序中所用代码的思路进行简要描述。通过文章末尾参考部分中的链接可以找到“真正、 proper”的射线/平面信息。采用代数方法,使用基本的算术和向量加法(同样适用于2D和3D向量)。

我们拥有的

  • 一条射线:由2个点p0p1定义。
  • 一个平面:由法向量n和一个平面上的点V0定义。

我们应该知道

  1. 两个非零向量ab当且仅当它们的**点积a.b = 0**时才垂直。(这里我们可以选择深入到三角学和毕达哥拉斯定理的简单证明,来说服我们自己为什么是这样,或者我们可以相信我们的数学家,并接受它作为一个在许多方面都能有用的规则。接下来是它有用性的一种应用)
  2. **点积a.b = 0**是定义空间中平面的方法之一。通过将其中一个向量视为平面的法向量(比如向量a现在是法向量),则满足点积方程的所有向量b都位于一个特定平面上。显然,这也意味着用于定义向量b的所有点对也位于该平面上。

向量

向量可以从两种方式来理解:要么是一个点 <x,y,z>,要么是一条从原点 <0,0,0> 到点 <x,y,z> 的线。

  • w = P0-V0
  • v = V1-V0
  • u = P1-P0

根据图示并结合基本向量数学知识,我们看到

  • w + su = v (3)

因为目标是得到一个点形式的答案,所以下面的表达式(又称直线定义)通常用作要解的最终方程。

  • P0 + su = V1 (4)

这可以理解为“将起点P0通过向量su移动到终点V1,其中s通常被描述为一个标量值(s可以被认为是(乘以u代表的某个标量值)以终点V1为终点的u的片段(即较短的部分))。

我们有P0和u,但缺少s。

s通常这样找到:

将(1)和(2)应用于问题,其中v垂直于N(.表示点积)

  • N.v = 0

我们将v的值从(3)代入以获得

  • N.(w + su) = 0
    => N.w + N.su = 0

使用点积是可交换的事实(即,N.su与sN.u相同)。

  • => N.w + s(N.u) = 0
  • => s(N.u) = -N.w
  • => s = -N.w/N.u

对于下面给出的算法的代码版本:

  • 令D = N.u,N = -N.w。则s = D/N,并且
  • 最终要解的方程(4)表示为 intersectPoint = p0 + s*u。
                //=======================================================
                // Intersects(): intersect a segment and a plane
                //    Input:  p = a plane
                //    Output: I = the intersection point (when it exists)
                //    Return: 0 = disjoint (no intersection)
                //            1 = intersection in the unique point I
                //            2 = the segment lies in the plane
                //
                // Adapted from:
                // http://www.softsurfer.com/Archive/algorithm_0104/
                // algorithm_0104B.htm#Line-Plane%20Intersection
                //
                //=======================================================
                int Intersects(Plane^ p, Vector3^ I)
                {
                     Vector3^ intersectPoint;
                     Vector3^ u = p1 - p0;
                     Vector3^ w = p0 - p->Position;
                     float     D = p->Normal->Dot(u);
                     float     N = -p->Normal->Dot(w);
                     if (Math::Abs(D) < ZERO)      // segment is parallel to plane
                     {
                          if (N == 0)               // segment lies in plane
                                return 2;
                          else
                                return 0;           // no intersection
                     }
                     float s = N / D;
                     if (s < 0 || s > 1)
                         return 0;                  // no intersection
                     intersectPoint = p0 + u*s;     // compute segment intersect point
                     I->X  = intersectPoint->X;
                     I->Y  = intersectPoint->Y;
                     I->Z  = intersectPoint->Z;
                     return 1;
                     }

OpenGL模型视图矩阵和相机基向量

尽管OpenGL本身没有相机,但将模型视图矩阵视为相机所在的位置和指向的方向会很有用。这使得我们的实现中的特殊情况(即观察平面垂直于约束的虚拟平面时)的处理更加简单,即使有些繁琐。如果您执行可能影响模型视图矩阵的缩放操作,那么在应用缩放之前存储其包含的方向值可能是一个好主意(由于OpenGL 3.0中固定管线的弃用鼓励开发人员实现所有自定义的矩阵操作,因此存储您可能需要的各种矩阵状态似乎已成为必需)。

在我们老式的OpenGL 2.1示例中,没有会影响OpenGL模型视图矩阵的缩放操作,所以我们直接按原样获取“相机基向量”。相机也不能滚动:这也很有帮助,正如我们很快将看到的。消除缩放和滚动是故意的(如果我使用OpenGL 3.0,我会专门为实现中的特殊情况存储一个类似此状态的矩阵)。

以下是我们如何找出相机当前指向的位置(即其俯仰、偏航和翻滚)。假设我们像这样检索模型视图矩阵:

GLdouble m[16];
glGetDoublev(GL_MODELVIEW_MATRIX, m);

然后,如果我们这样描述矩阵:

以下子矩阵包含为我们提供相机方向的基向量。

其中基向量(通常称为)RightVector [Rx, Ry, Rz]、UpVector [Ux, Uy, Uz]和LookAtVector [Ax, Ay, Az]显示在下面的图表中。第二个图是用于帮助您(重新)定位的示例:它显示了当相机处于前视图位置时(通常是初始状态,没有应用任何旋转;俯仰、偏航和翻滚都等于零;并且是单位矩阵)相机旋转子矩阵的状态。

相机/观察平面基向量

前视图中的相机基向量矩阵

这对我们有何帮助

一旦我们有了这些知识,就可以轻松地检查相机的方向并根据其做出一些决策。繁琐的部分是从九个子矩阵元素中找到最方便的最小数量来检查以确定相机方向。我们相机的另一个特点(或者说缺乏的特点)是它不会滚动(围绕其LookAt 向量),它只能俯仰(围绕其Right 向量)和偏航(围绕其Up 向量)。这是我们可以得到的结果:

if (m6 == 1 or -1 )// Up vector z component
{
    Camera is viewing from the top or bottom
}
else if (m6 == 0)// Up vector z component
{
    Camera is viewing from front, back, left or right
    if(m8 == 0) // LookAt vector x component
        Camera is viewing from back or front
    else if(m8 == 1 or -1) // LookAt vector x component
        Camera is viewing from left or right
}

通过查看相机基向量子矩阵的构成,我们可以确认这是一个合理的解决方案。为了保持老式风格(即OpenGL 2.1),我们的相机也是一个基本的老式相机。以下函数用于旋转相机,以便将模型视图矩阵乘以它们,并将乘积替换当前矩阵。

请注意,由于目前未实现滚动,因此最后一个函数glRotate*(a,0,0,1)被忽略(因此z始终为0)。您将看到,这个事实将对我们有利。

在将旋转应用于模型视图矩阵后,基向量的(分解)可以表示如下:

TranslationController015.jpg

其中x是相机的俯仰角,y是相机的偏航角,z是相机的翻滚角。

然后通过观察和一点分析,发现了模型视图矩阵的元素与相机方向之间的以下相关性并被使用:

  1. 俯仰角为90或-90度,与前视图(单位位置)相比。关键在于相机不能滚动(围绕LookAt向量)。这意味着z=0度是一个不变量(与前视图(单位矩阵)中的原始值不变)。因此,将Sin(z)=Sin(0)=0和Cos(0)=1代入m6的构成简化了它为Sin(x)。注意x是相机相对于单位俯仰的俯仰角(以度为单位,当相机在前视图位置时),并且独立于全局轴。也就是说,它适用于相机从任何任意位置/方向俯仰到顶视图的情况。下面的示例说明了相机绕全局X轴从前视图旋转到顶视图的情况。

TranslationController014.jpg

示例:相机操作到顶视图位置,导致相机俯仰90度。

  1. 俯仰角为0度,与前视图(单位位置)相比。也就是说,相机观察平面垂直于XY平面;相机不能滚动的这个事实再次被用来得到m6=Sin(x)。因此,当相机没有俯仰时,m6=0。

    它仍然可以在XY平面内旋转(或偏航),而m6=0,所以最后我们需要测试当相机观察平面垂直于YZ平面(后视图、前视图)和XZ(左视图、右视图)时。
  2. 任意相机偏航角。m8 (Ax) = sin y

    这次我们使用相机围绕Z轴(OpenGL Y轴)在XY平面内旋转的实际角度的正弦值。因此,在前视图(0度)和后视图(180度)时m8=0,在左视图(90度)时m8=1,在右视图(-90度或270度)时m8=-1。

这将处理XY平面内的正交视图(前、后、左、右)。例如,当相机位于左视图(或右视图)且对象平移约束到XY平面时,我们将平面翻转到YZ平面位置,并将对象平移约束到Y轴。类似地,对于位于前视图(或后视图)的相机,当对象平移约束到XY平面时,我们将平面翻转到XZ平面位置,并将对象平移约束到X轴。

我们尚未处理的是,当相机绕向上方的Z轴(OpenGL Y轴)旋转时,正交视图之间所有任意情况。方法如下:基本上,如果偏航角小于从后视图和前视图的0度(正交视图)的45度,则平面翻转到XZ对齐,并且对象平移约束到X轴。当偏航角小于从左视图或右视图(图中的黄色区域)的45度时,平面翻转到YZ对齐,并且对象平移约束到Y轴。

TranslationController016.jpg

这个想法最终形成了以下代码实现(在TranslationControllerInitializePlane() 函数中)。

   if((modelview[8] <= 0.7071f && modelview[8] >= -0.7071f)) //front or back view
   {
           if(theTranslationConstraint == XYTRANS || theTranslationConstraint == XTRANS)
           {
                  newAlignment = ZXTRANS;
                  newTranslationConstraint = XTRANS;
           }
           else if(theTranslationConstraint == YZTRANS ||
                          theTranslationConstraint == ZTRANS)
           {
                  newAlignment = ZXTRANS;
                  newTranslationConstraint = ZTRANS;
           }
   }
   else if((modelview[8] <= -0.7071f ||
                 modelview[8] >= 0.7071f))// left view or right view
   {

           if(theTranslationConstraint == XYTRANS || theTranslationConstraint == YTRANS)
           {
                  newAlignment = YZTRANS;
                  newTranslationConstraint = YTRANS;
           }
           else if(theTranslationConstraint ==
                 ZXTRANS || theTranslationConstraint == ZTRANS)
           {
                  newAlignment = YZTRANS;
                  newTranslationConstraint = ZTRANS;
           }
    }

参考文献

本文档实际上试图简化和扩展我在此处给出的有些含糊不清的描述。本文档仍然不够简单,但我希望它有所帮助。

  1. 射线与平面相交
  2. OpenGL通用
  3. OpenGL颜色编码拾取

进一步阅读

有关本文档上下文的更多信息,下面提供了一些搜索关键词和参考书目。

关键词

  • 工具和工具包,几何建模,3D建模
  • 用户界面设计
  • 交互式几何建模,交互式3D图形,交互技术,交互式3D环境,3D交互
  • 2D输入/输出设备
  • 3D场景构建
  • 3D操纵约束
  • 虚拟控制器,控件:三轴光标,三轴,滑行器,拖拽器,操纵器,Gizmo
  • 鼠标射线技术
  • 直接操作

Batagelo, H.C., Shin Ting, W., Application-independent accurate mouse placements on surfaces of arbitrary geometry, SIGRAPI,2007, proceedings of the XX Brazilian Symposium on Computer Graphics and Image Processing, IEEE Computer Society Washington, DC, USA

Bier, E.A., Skitters and jacks: interactive 3D positioning tools. In Proceedings of the 1986 Workshop on Interactive 3D Graphics, pages 183–196. ACM: New York, October 1986.

Bowman, D., Kruijff,E., LaViola,J., Poupyrev,I., Mine, M., 3D User interface design: fundamental techniques, theory and practice. Course presented at the SIGGRAPH 2000, ACM [notes].

Conner,B.D, Snibbe,S.S, Herndon,K.P , Robbins.D.C, Robert C. Zeleznik.R.C , Van Dam .A, Three-dimensional widgets, Proceedings of the 1992 symposium on Interactive 3D graphics, p.183-188, June 1992,Cambridge, Massachusetts,United States

Dachselt R., Hinz M.,: Three-Dimensional Widgets Revisited - Towards Future Standardization. In IEEE VR 2005 Workshop ‘New Directions in 3D User Interfaces’ (2005), Shaker Verlag.

http://en.wikipedia.org/wiki/Direct_manipulation

Frohlich, David M., The history and future of direct manipulation, Behaviour & Information Technology 12, 6 (1993), 315-329.

Hand,C., A survey of 3D interaction techniques. Computer Graphics Forum. v16 i5. 269-281.

[1] Hinckley, K., Pausch, R., Goble, J.C., Kassell, N.F., A survey of design issues in spatial input, Proceedings of the 7th annual ACM symposium on User interface software and technology, p.213-222, November 02-04, 1994, Marina del Rey, California, United States

Hutchins E.L., Hollan J.D, Norman D.A.: Direct Manipulation Interfaces. HUMAN-COMPUTER INTERACTION, 1985, Volume 1, pp. 311-338 1985, Lawrence Erlbaum Associates, Inc.

Nielson, G.M., Olsen,D.R., Jr. Direct manipulation techniques for 3D objects using 2D locator devices. In 1986 Symposium on Inter-active 3D Graphics, pages 175–182. ACM: New York, October 1986.

Oh,J., Stuerzlinger,W., Moving objects with 2D input devices in CAD systems and Desktop Virtual Environments, Proceedings of Graphics Interface 2005, May 09-11, 2005, Victoria, British Columbia

Schmidt,R., Singh,K., Balakrishnan,R., Sketching and Composing Widgets for 3D Manipulation (2008), Computer Graphics Forum, 27(2), pp. 301-310. (Proceedings of Eurographics 2008). [PDF] [Video] [Details]

Stuerzlinger,W., Dadgari,D., Oh,J., Reality-Based Object Movement Techniques for 3D, CHI 2006 Workshop: "What is the Next Generation of Human-Computer Interaction?", April 2006.

Smith,G. , Salzman,T., Stuerzlinger,W., 3D scene manipulation with 2D devices and constraints, No description on Graphics interface 2001, p.135-142, June 07-09, 2001, Ottawa, Ontario,Canada

US Patent 5798761 - Robust mapping of 2D cursor motion onto 3D lines and planes[a link]

Wimmer,M., Tobler,R.F., Interactive Techniques in Three-dimensional Modeling, 13th Spring Conference on Computer Graphics, pages 41-48. June 1997.

Wu,S., Malheiros, M.,: Interactive 3D Geometric Modelers with 2D UI. WSCG 2002: 559-

历史

  • 2009年4月7日:初稿
  • 2009年4月9日:文章小幅修改
© . All rights reserved.