Link

Trackball virtual

Um dos comportamentos mais solicitados em uma cena 3D é a capacidade de girar um modelo usando o mouse. Uma das implementações mais comuns é chamada de trackball virtual e é encontrada em muitos programas de design e gráficos 3D.

Essa interface fornece um método bastante intuitivo pelo qual um modelo pode ser manipulado em qualquer orientação. Essencialmente, ela traduz os movimentos 2D do mouse em rotações 3D. Isso é feito projetando a posição do mouse em uma esfera imaginária atrás do plano de imagem, como mostra a Figura 20. À medida que o mouse é movido, a câmera (ou cena) é girada para manter o mesmo ponto na esfera embaixo do ponteiro do mouse.

Figura 20 – Um ponto 2D no plano da imagem é mapeado para um ponto 3D em uma esfera localizada atrás do plano da imagem
Fonte: Adaptado de Henriksen, Sporring e Hornbæk (2004).


Se clicarmos no centro da esfera e mover o mouse horizontalmente, é necessária uma rotação sobre o eixo Y para manter o mesmo ponto sob o ponteiro do mouse (como ilustrado na Figura 21). Da mesma forma, se movermos o mouse na vertical a partir do centro da esfera resulta em uma rotação ao redor do eixo X (como ilustrado na Figura 22).

Figura 21 – Mover o mouse horizontalmente gira o modelo 3D em torno do eixo Y


Figura 22 – Mover o mouse verticalmente gira o modelo 3D em torno do eixo X


Nosso objetivo neste capítulo é implementar um trackball virtual, permitindo girar o modelo 3D exibido de forma fácil e natural. Não é nossa intenção discutir, aqui, em detalhes a matemática envolvida por trás desse processo. Em vez disso, veremos como implementar o conceito de forma prática dentro do Qt. Para isso, vamos utilizar como referência os arquivos trackball.h e trackball.cpp do exemplo Boxes do Qt Creator.

Para implementarmos essa funcionalidade em nosso programa, a primeira coisa que devemos fazer é criar uma classe chamada Trackball no projeto do Qt. Para isso, seguimos os mesmos passos descritos anteriormente para a criação de uma classe no Qt Creator.

Após a criação da classe, adicionamos os seguintes membros de classe em trackball.h:

#include <QVector3D>
#include <QQuaternion>
#include <QTime>
...

private:
	QQuaternion m_rotation;
	QVector3D m_axis;
	float m_angularVelocity;
	QPointF m_lastPos;
	QTime m_lastTime;
	bool m_pressed;

As funções de cada membro serão as seguintes:

  • m_rotation irá armazenar o valor de rotação atual do trackball;
  • m_axis irá armazenar o valor do eixo de rotação atual do trackball;
  • m_angularVelocity irá armazenar o valor da velocidade angular atual do trackball;
  • m_lastPos irá armazenar a última posição no plano de imagem que foi utilizada para calcular o valor de rotação atual do trackball;
  • m_lastTime irá armazenar o valor do último tempo em que o trackball foi atualizado;
  • m_pressed será usado para indicar se o botão esquerdo do mouse está pressionado (true) ou não (false).

Em seguida, vamos implementar o construtor dessa classe para inicializar corretamente seus membros. O construtor irá receber dois argumentos: o primeiro será a velocidade angular; e o segundo, o eixo atual de rotação. Os demais membros são inicializados conforme mostrado no código abaixo:

Trackball::Trackball(float angularVelocity, const QVector3D& axis)
	: m_axis(axis)
	, m_angularVelocity(angularVelocity)
	, m_pressed(false) {
	m_rotation = QQuaternion();
	m_lastTime = QTime::currentTime();
}

Essencialmente essa classe irá ter quatro métodos públicos. O primeiro método, move, será chamado quando o mouse se movimentar; o segundo método, push, quando o botão esquerdo do mouse for pressionado; o terceiro, release, quando o botão esquerdo for liberado. O quarto e último método, rotation, simplesmente retornará o valor de rotação do trackball.

Em cada evento de movimentação do mouse, precisamos calcular uma rotação para manter o mesmo ponto sob o ponteiro do mouse. Há duas etapas para fazer isso. A primeira é descobrir qual ponto da esfera está sob o ponteiro do mouse. A segunda é calcular a rotação necessária para transformar o ponto antigo no novo ponto.

Para encontrar o ponto na esfera sob o ponteiro do mouse, precisamos projetar o ponto 2D no sistema de coordenadas do plano de imagem na esfera inscrita na cena 3D. As figuras 23 e 24 ilustram os dois sistemas de coordenadas.

Figura 23 – O mouse informa sua posição no espaço de coordenadas do plano de imagem que possui como coordenada o ponto (0, 0) no canto superior esquerdo


Figura 24 – Ponto 2D do sistema de coordenadas do plano de imagem projetado na esfera inscrita na cena 3D. Observe que isso resulta em uma coordenada 3D


Como estamos interessados apenas em calcular a rotação, podemos escolher o sistema de coordenadas para a esfera que for mais conveniente para nós. É mais simples usar uma esfera de raio igual a 1 centralizada na origem (0, 0, 0). Isso torna a localização dos componentes x e y uma simples tarefa de conversão entre os dois sistemas de coordenadas 2D mostrados na Figura 25.

Figura 25 – Representações dos sistemas de coordenadas do quadro de imagem e do trackball
(a) Sistema de coordenada do quadro de imagem
(b) Sistema de coordenada do nosso trackball


Para fazer isso, primeiro criaremos uma nova função membro em VulkanWindow chamada pixelPosToViewPos para calcular a posição (x, y) da posição do plano de imagem para a posição (x, y) da esfera.

QPointF VulkanWindow::pixelPosToViewPos(const QPointF& p) {

}

Essa função recebe como parâmetro um objeto do tipo QPointF com a posição 2D do mouse no quadro de imagem.

Nessa função queremos colocar nosso ponto no plano de imagem no intervalo [-1,1] - [1,-1] (como ilustrado na Figura 25b). Isso é feito da seguinte forma:

float x = ((float) p.x()) / (width() / 2);
float y = ((float)p.y()) / (height() / 2);

Primeiro, escalamos os limites para [0,0] - [2,2]. Utilizamos as funções width() e height() para obter, respectivamente, os valores da largura e altura do quadro de imagem.

x = x - 1;
y = 1 - y;

Depois transladamos 0,0 para o centro e invertemos o valor de y para que esteja apontando para cima e não para baixo.

return QPointF(x, y);

Por último, retornamos um objeto do tipo QPointF com os valores de x e y calculados anteriormente.

Agora que encontramos nossa posição x e y na esfera1, podemos encontrar z. Para isso, criamos uma nova função pública em Trackball chamada move:

void Trackball::move(const QPointF& p) {

}

1 Isso é apenas uma aproximação, mas funcionará bem.

Essa função recebe como parâmetro um objeto do tipo QPointF com a posição 2D do mouse convertida pela função anterior. Dentro dessa função, calcularemos a posição 3D na esfera.

if (!m_pressed)
	return;

Como essa função será chamada sempre que houver algum movimento do mouse, independente se o botão esquerdo do mouse foi pressionado ou não, primeiro verificamos se o membro m_pressed está definido como false ou true. Caso esteja definido como false, retornamos da função; caso contrário continuamos.

QTime currentTime = QTime::currentTime();
int msecs = m_lastTime.msecsTo(currentTime);
if (msecs <= 20)
	return;

Se a diferença de tempo entre o evento atual e o último evento de mover o mouse for menor que 20 milissegundos, então retornamos da função. Caso contrário, continuamos na função. Isso faz com que nosso trackball não tenha fricção, ou seja, se o usuário liberar o botão esquerdo do mouse enquanto estiver movimentando o mouse, o trackball continuará se movimentando na mesma direção do último movimento.

Na função pixelPosToViewPos já encontramos nossa posição x e y na esfera, agora, vamos encontrar z.

Como nossa esfera é de raio 1, sabemos que √x2 + y2 + z2 = 1. Resolvendo para z, obtemos:

Então usamos a Equação 9 para calcular a última posição da seguinte forma:

QVector3D lastPos3D =
	QVector3D(m_lastPos.x(), m_lastPos.y(), 0.0f);
float sqrZ = 1 - QVector3D::dotProduct(lastPos3D, lastPos3D);
if (sqrZ > 0)
	lastPos3D.setZ(std::sqrt(sqrZ));
else
	lastPos3D.normalize();

Para o calculo de x2 + y2 utilizamos a função QVector3D::dotProduct. Se 1 − (x2 + y2) (sqrZ) é negativo (o que significa que o evento do mouse acontece fora do trackball), normalizamos o valor de lastPos3D. Nesse caso, o ponto correspondente no trackball será (x/√x2 + y2, y/√x2 + y2, 0).

Então fazemos a mesma coisa para a posição atual:

QVector3D currentPos3D = QVector3D(p.x(), p.y(), 0.0f);
sqrZ = 1 - QVector3D::dotProduct(currentPos3D, currentPos3D);
if (sqrZ > 0)
	currentPos3D.setZ(std::sqrt(sqrZ));
else
	currentPos3D.normalize();

Agora temos as coordenadas (x, y, z) da última posição e da posição atual na esfera abaixo do ponteiro do mouse.

Devemos nos certificar de incluir o cabeçalho <qmath.h> em trackball.cpp para podermos usar a função std::sqrt.

Em cada movimento do mouse, queremos construir uma rotação que mantenha o mesmo ponto na esfera abaixo do ponteiro do mouse. Fazemos isso lembrando o ponto anterior na esfera desde o último evento de movimento do mouse e construindo uma rotação que o transformará no ponto atualmente sob o ponteiro do mouse. Para calcular essa rotação, precisamos de duas coisas:

  1. O eixo de rotação
  2. O ângulo de rotação θ

Como nossa esfera está centrada na origem, podemos interpretar nossos pontos como vetores. Para isso, é trivial encontrar o eixo e o ângulo de rotação usando o produto vetorial e o produto escalar, respectivamente:

Figura 26 – Representação do eixo de rotação e o ângulo que transformarão em


Usando as equações (10) e (11) no código, obtemos:

#include <qmath.h>
...
m_axis = QVector3D::crossProduct(lastPos3D, currentPos3D);
float angle = qRadiansToDegrees(std::acos(QVector3D::dotProduct(lastPos3D.normalized(), currentPos3D.normalized())));

Para calcular o produto escalar e vetorial utilizamos, respectivamente, as funções QVector3D::dotProduct e QVector3D::crossProduct. Como o valor de retorno de std::acos é em radianos, utilizamos qRadiansToDegrees para converter esse valor em graus.

Depois de obtermos o eixo e o ângulo, resta apenas aplicar a nova rotação à orientação atual:

m_rotation = QQuaternion::fromAxisAndAngle(m_axis, angle) * m_rotation;

Para isso, utilizamos a função QQuaternion::fromAxisAndAngle, que recebe como parâmetros o eixo e o ângulo de rotação, e multiplicamos o resultado dessa função pelo valor atual de m_rotation.

Por último, atualizamos o membros m_angularVelocity, m_lastPos e m_lastTime da seguinte forma:

m_angularVelocity = angle / msecs;
m_lastPos = p;
m_lastTime = currentTime;

A implementação do método rotation é a seguinte:

QQuaternion Trackball::rotation() const {
	if (m_pressed)
		return m_rotation;

	QTime currentTime = QTime::currentTime();
	float angle = m_angularVelocity *
		m_lastTime.msecsTo(currentTime);
	return QQuaternion::fromAxisAndAngle(m_axis, angle) *
		m_rotation;
}

A primeira coisa que verificamos é se o botão esquerdo do mouse está pressionado. Para isso, verificamos se membro m_pressedtrue. Caso isso seja verdadeiro, simplesmente retornamos o valor de m_rotation. Caso contrário, calculamos o novo ângulo de rotação a partir da velocidade angular e a diferença de tempo entre o evento atual e o último. Então, retornamos o valor da nova rotação. Para isso, utilizamos o valor de QQuaternion::fromAxisAndAngle, com os valores do eixo de rotação, m_axis, e o angulo calculado, multiplicado pela última rotação, m_rotation.

O terceiro método que implementaremos é o método push. Esse método consiste no seguinte:

void Trackball::push(const QPointF& p) {
	m_rotation = rotation();
	m_pressed = true;
	m_lastTime = QTime::currentTime();
	m_lastPos = p;
	m_angularVelocity = 0.0f;
}

Primeiro, atualizamos o membro m_rotation com o valor obtido de rotation. Depois, marcamos m_pressed como true para indicar que o botão esquerdo do mouse foi pressionado. Atualizamos os valores de m_lastTime e m_lastPos com, respectivamente, o valor do tempo e a posição do mouse atuais. Por fim, definimos a velocidade angular como zero. Isso faz com que o modelo 3D pare de rotacionar assim que o botão esquerdo do mouse é pressionado.

Por último, implementamos o método release da seguinte maneira:

void Trackball::release(const QPointF& p) {
	move(p);
	m_pressed = false;
}

Inicialmente, chamamos o método move com a posição atual do mouse e definimos m_pressed como false para indicar que o botão esquerdo do mouse foi liberado.

O próximo passo é integrar os métodos de Trackball com o sistema de eventos do Qt. Em particular, estamos interessados em eventos gerados pelo mouse. Para isso, o Qt fornece métodos virtuais na classe QWidget que são chamados em resposta ao mouse. Esses métodos são:

  • mousePressEvent, que é chamado quando um botão do mouse é pressionado;
  • mouseMoveEvent, que é chamado quando o mouse é movido;
  • mouseReleaseEvent, que é chamado quando o botão pressionado do mouse é liberado.

Primeiro adicionamos um membro de classe em VulkanWindow para armazenar um objeto do tipo Trackball:

#include "trackball.h"
...
private:
	QVulkanInstance m_instance;
	Renderer *m_renderer = nullptr;
	Trackball m_trackball;

Inicializamos esse objeto no construtor de VulkanWindow da seguinte forma:

VulkanWindow::VulkanWindow(QWindow *parentWindow) : QVulkanWindow(parentWindow) {
	...
	m_trackball = Trackball(-0.05f, QVector3D(0, 1, 0));
}

Então, sobrescrevemos o método mousePressEvent:

void VulkanWindow::mousePressEvent(QMouseEvent *event) {
	if (event->buttons() & Qt::LeftButton) {
		m_trackball.push(pixelPosToViewPos(event->localPos()));
	}
}

Inicialmente verificamos se o botão do mouse pressionado é o botão esquerdo. Caso isso seja verdadeiro, simplesmente chamamos o método Trackball::push com a posição do mouse traduzida pelo método pixelPosToViewPos. Devemos nos certificar de incluir o cabeçalho <QMouseEvent> em vulkanwindow.cpp.

Em seguida, sobrescrevemos a função mouseMoveEvent da seguinte forma:

void VulkanWindow::mouseMoveEvent(QMouseEvent *event) {
	if (event->buttons() & Qt::LeftButton) {
		m_trackball.move(pixelPosToViewPos(event->localPos()));
	} else {
		m_trackball.release(pixelPosToViewPos(event->localPos()));
	}
}

Quando o mouse é movido, verificamos se o botão esquerdo do mouse está pressionado. Caso isso seja verdadeiro chamamos o método Trackball::move com a posição do mouse transformada pelo método pixelPosToViewPos. Caso o mouse está se movimentando, mas o botão esquerdo não está pressionado, chamamos o método Trackball::release, também, com a posição do mouse transformada pelo método pixelPosToViewPos.

Quando o botão pressionado do mouse é liberado, o procedimento é simples:

void VulkanWindow::mouseReleaseEvent(QMouseEvent *event) {
	if (event->button() == Qt::LeftButton) {
		m_trackball.release(pixelPosToViewPos(event->localPos()));
	}
}

Verificamos se o botão liberado é o botão esquerdo do mouse; caso seja, chamamos o método release com a posição do mouse fornecida por pixelPosToViewPos.

Agora precisamos de uma forma de obter a rotação do trackball quando atualizarmos o UBO no código do programa. Para isso, simplesmente, criamos um método público em VulkanWindow para retornar o valor de rotação do trackball:

QQuaternion getTrackballRotation() {
	return m_trackball.rotation();
}

Modificamos a função Renderer::updateUniformBuffer para rotacionar a matriz model do UBO com o valor de rotação do trackball da seguinte forma:

...
UniformBufferObject ubo = {};
ubo.model.setToIdentity();;
ubo.model.rotate(m_window->getTrackballRotation());
ubo.model *= m_object->model->transformation;

QVector3D eye = QVector3D(0.0, 0.0, 1.0);
...

Também atualizamos a posição da vista (eye) para deixar centralizado nosso modelo 3D na tela.

Uma última funcionalidade que iremos implementar em nosso programa é permitir que o usuário de zoom no modelo 3D utilizando o botão de rolagem do mouse. Para fazer isso, vamos ao cabeçalho da classe VulkanWindow e adicionamos um novo membro privado para armazenar o valor de zoom:

private:
	QVulkanInstance m_instance;
	Renderer *m_renderer = nullptr;
	Trackball m_trackball;
	float m_zoom = 0;

Em seguida, criamos um método público chamado getZoom para obter o valor desse novo membro:

float getZoom() {
	return m_zoom;
}

Agora precisamos de um meio de atualizar esse valor sempre que o botão de rolagem for acionado. Para isso, sobrescrevemos a função QWidget::wheelEvent em VulkanWindow:

void VulkanWindow::wheelEvent(QWheelEvent *event) {
	m_zoom += 0.001 * event->delta();
}

Simplesmente somamos no membro m_zoom o valor obtido de event->delta() multiplicado por 0.001.

Agora só precisamos atualizar novamente a função updateUniformBuffer para transladar a matriz de transformação model do UBO utilizando o valor de zoom:

UniformBufferObject ubo = {};
ubo.model.setToIdentity();
ubo.model.translate(0, 0, m_window->getZoom());
ubo.model.rotate(m_window->getTrackballRotation());
ubo.model *= m_object->model->transformation;

Agora podemos executar nosso programa e testar essas novas funcionalidades que adicionamos nesta seção.


Anterior Próximo