Overview
This lab builds an interactive mouse-picking system that works with both spheres and axis-aligned cubes. You will cast a ray from the camera through the cursor into 3D space, dispatch each scene object to the appropriate intersection test, and highlight the nearest hit object in green. Spheres use the quadratic discriminant test from the original version of this lab. Cubes add a second algorithm - the slab method - which treats the box as the overlap of three axis-aligned slabs and finds where the ray enters and exits each one. The lab builds on the projection matrices from the Projections lab (Lab 08) and the test harness registration pattern from the Test Harness lab (Lab 15).
Learning Objectives
By the end of this lab you will be able to:
- Convert a 2D mouse screen position to a 3D normalised ray in world space using inverse projection and view matrices
- Apply the quadratic discriminant test to detect a ray-sphere intersection
- Apply the slab method to detect a ray-AABB intersection for axis-aligned cubes
- Dispatch each scene object to the correct intersection test based on its ObjectType
- Maintain separate VAO and IBO pairs for sphere and cube geometry and draw them selectively each frame
Concept
Without a reverse projection, there is no way to determine which 3D object the mouse cursor is hovering over. The GPU rasteriser maps geometry from world space onto the screen during rendering, but nothing in the standard OpenGL pipeline inverts that mapping automatically. Ray casting performs the inversion by hand. A ray is fired from the camera position through the pixel under the cursor, and each scene object is tested to see whether the ray hits it.
The ray originates as a 2D mouse position and must be transformed into world space through three coordinate systems. The mouse position is first mapped from window pixels to Normalised Device Coordinates (NDC), where the screen centre is (0, 0) and the corners sit at -1 and 1. The horizontal conversion is (2.0f * mouseX) / width - 1.0f. The vertical conversion flips Y because window coordinates grow downward while NDC grows upward: 1.0f - (2.0f * mouseY) / height. This NDC position is packed into a glm::vec4 with z set to -1 (pointing forward into the scene) and w set to 1, forming the clip-space ray. Multiplying by glm::inverse(projectionMatrix) converts to eye space. Overriding w to 0 marks the result as a direction, not a point. A final multiplication by glm::inverse(viewMatrix) gives the ray direction in world space.
With the world-space ray, each object is tested with the algorithm that matches its geometry. Spheres use the ray-sphere quadratic test. Writing the ray as P = origin + t * direction and substituting into the sphere equation produces a quadratic in t with coefficients a = dot(d, d), b = 2 * dot(oc, d), and c = dot(oc, oc) - r * r, where oc is the vector from the sphere centre to the ray origin. The discriminant b * b - 4 * a * c gives the answer without solving for t: positive means two intersection points, negative means a miss. Axis-aligned boxes use the slab method. A box is the intersection of three pairs of parallel planes (slabs), one pair per axis. For each axis, two t values give where the ray crosses the near and far plane of that slab. tEnter is the largest of the three near crossings and tExit is the smallest far crossing. When tExit > tEnter and tExit > 0, the ray passes through all three slabs simultaneously and the box is hit. The GLM calls used are glm::inverse, glm::dot, glm::normalize, glm::min, and glm::max.
Tasks
Task 1 - Update the class header
- Open
src/tests/testRayCasting.hand replace theObjectstruct. The struct now has four fields:glm::vec3 position,float size(radius for spheres, half-extent for cubes),std::string name, andObjectType type. - Add the
enum class ObjectType { Sphere, Cube };declaration above the struct. - Add three new private members for cube GPU resources:
std::unique_ptr<VertexArray> m_CubeVAO,std::unique_ptr<VertexBuffer> m_CubeVBO, andstd::unique_ptr<IndexBuffer> m_CubeIBO. - Add three new private method declarations:
GenerateCubeData,SetupCubeBuffers, andRayIntersectsCube. Study the signatures in the reference below before moving to Task 2.
#pragma once
#include "Tests.h"
#include "../VertexBuffer.h"
#include "../VertexBufferLayout.h"
#include "../IndexBuffer.h"
#include "../VertexArray.h"
#include "../Shader.h"
#include "gl/glew.h"
#include "GLFW/glfw3.h"
#include "glm/glm.hpp"
#include "glm/gtc/matrix_transform.hpp"
#include "glm/gtc/type_ptr.hpp"
#include <vector>
#include <string>
#include <memory>
namespace test {
class testRayCasting : public Tests {
public:
testRayCasting(GLFWwindow* window);
void Update(float deltaTime) override;
void Render() override;
void RenderGUI() override;
private:
GLFWwindow* m_window;
glm::vec3 cameraPosition;
glm::vec3 cameraFront;
glm::vec3 cameraUp;
float cameraSpeed;
glm::mat4 viewMatrix;
glm::mat4 projectionMatrix;
// Distinguishes which intersection algorithm to use per object.
enum class ObjectType { Sphere, Cube };
// 'size' is radius for spheres and half-extent for cubes.
struct Object {
glm::vec3 position;
float size;
std::string name;
ObjectType type;
};
// Separate GPU buffers for cube geometry.
std::unique_ptr<VertexArray> m_CubeVAO;
std::unique_ptr<VertexBuffer> m_CubeVBO;
std::unique_ptr<IndexBuffer> m_CubeIBO;
std::vector<Object> objects;
int selectedObjectIndex;
std::unique_ptr<VertexArray> m_VAO;
std::unique_ptr<VertexBuffer> m_VBO;
std::unique_ptr<IndexBuffer> m_IBO;
std::unique_ptr<Shader> m_Shader;
void GenerateSphereData(std::vector<float>& vertices,
std::vector<unsigned int>& indices,
float radius,
unsigned int longitudeDiv,
unsigned int latitudeDiv);
void ProcessInput();
void SetupBuffers();
glm::vec3 CalculateRayDirection(float mouseX, float mouseY);
bool RayIntersectsSphere(const glm::vec3& rayOrigin,
const glm::vec3& rayDirection,
const glm::vec3& sphereCenter,
float sphereRadius);
// Cube-specific methods.
void GenerateCubeData(std::vector<float>& vertices,
std::vector<unsigned int>& indices,
float size);
void SetupCubeBuffers();
bool RayIntersectsCube(const glm::vec3& rayOrigin,
const glm::vec3& rayDirection,
const glm::vec3& boxCenter,
float boxSize);
};
}
Task 2 - Review the shader
- Open
res/Shaders/BasicMVP.shaderand read through both sections. - The vertex shader expects two per-vertex inputs at locations 0 and 1, and three matrix uniforms:
model,view, andprojection. - The fragment shader ignores the interpolated vertex colour and reads a flat
uniform vec4 u_Colourpassed from C++. This is the value you will swap between red and green each frame to show which object is selected. - Confirm that the uniform names in the shader (
model,view,projection,u_Colour) match the strings you will pass tosetUniformMat4fandsetUniform4fin Task 8. A name mismatch is a silent failure that produces no compiler error. - You do not need to modify this shader.
#shader vertex
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec4 aColour;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
out vec4 fragColour;
void main() {
gl_Position = projection * view * model * vec4(aPos, 1.0);
fragColour = aColour;
}
#shader fragment
#version 330 core
in vec4 fragColour;
uniform vec4 u_Colour;
out vec4 colour;
void main() {
colour = u_Colour;
}
Task 3 - Update the constructor and sphere setup
- Open
src/tests/testRayCasting.cpp. The camera initialisation andSetupBuffersandGenerateSphereDataimplementations remain unchanged. - Update the object list in the constructor. Replace the old sphere entries (which used a
radiusfield) with the new struct layout that includessizeandObjectType::Sphere:- Sphere 1: position (0, 0, 0), size 1.0, name "Sphere 1",
ObjectType::Sphere - Sphere 2: position (2, 0, -3), size 1.5, name "Sphere 2",
ObjectType::Sphere - Sphere 3: position (-2, 1, -2), size 1.0, name "Sphere 3",
ObjectType::Sphere
- Sphere 1: position (0, 0, 0), size 1.0, name "Sphere 1",
- Add two cube entries using
ObjectType::Cube:- Cube 1: position (3, 0.5, -1), size 0.8, name "Cube 1"
- Cube 2: position (-3, 0, -2.5), size 1.0, name "Cube 2"
- After the call to
SetupBuffers(), add a call toSetupCubeBuffers()(which you will implement in Task 4). - Build the project to check that the struct changes compile correctly before continuing.
// Constructor - initialise camera, populate scene, setup GPU buffers.
testRayCasting::testRayCasting(GLFWwindow* window)
: m_window(window), selectedObjectIndex(-1)
{
cameraPosition = glm::vec3(0.0f, 0.0f, 3.0f);
cameraFront = glm::vec3(0.0f, 0.0f, -1.0f);
cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);
cameraSpeed = 0.5f;
// TODO: Push three spheres with ObjectType::Sphere.
// Sphere 1: position (0, 0, 0), size 1.0, name "Sphere 1"
// Sphere 2: position (2, 0, -3), size 1.5, name "Sphere 2"
// Sphere 3: position (-2, 1, -2), size 1.0, name "Sphere 3"
// TODO: Push two cubes with ObjectType::Cube.
// Cube 1: position (3, 0.5, -1), size 0.8, name "Cube 1"
// Cube 2: position (-3, 0, -2.5), size 1.0, name "Cube 2"
SetupBuffers();
// TODO: Call SetupCubeBuffers() to create GPU buffers for cube geometry.
m_Shader = std::make_unique<Shader>("res/Shaders/BasicMVP.shader");
}
// SetupBuffers - create the sphere VAO, VBO, and IBO.
void testRayCasting::SetupBuffers() {
std::vector<float> vertices;
std::vector<unsigned int> indices;
GenerateSphereData(vertices, indices, 1.0f, 20, 20);
m_VAO = std::make_unique<VertexArray>();
m_VBO = std::make_unique<VertexBuffer>(vertices.data(), vertices.size() * sizeof(float));
VertexBufferLayout layout;
layout.Push<float>(3); // position
layout.Push<float>(3); // normal
m_VAO->AddBuffer(*m_VBO, layout);
m_IBO = std::make_unique<IndexBuffer>(indices.data(), indices.size());
glEnable(GL_DEPTH_TEST);
}
// GenerateSphereData - UV sphere using spherical coordinates (6 floats per vertex).
void testRayCasting::GenerateSphereData(
std::vector<float>& vertices,
std::vector<unsigned int>& indices,
float radius,
unsigned int longitudeDiv,
unsigned int latitudeDiv)
{
for (unsigned int lat = 0; lat <= latitudeDiv; ++lat) {
float theta = lat * glm::pi<float>() / latitudeDiv;
float sinTheta = sin(theta);
float cosTheta = cos(theta);
for (unsigned int lon = 0; lon <= longitudeDiv; ++lon) {
float phi = lon * 2.0f * glm::pi<float>() / longitudeDiv;
float sinPhi = sin(phi);
float cosPhi = cos(phi);
float x = cosPhi * sinTheta;
float y = cosTheta;
float z = sinPhi * sinTheta;
vertices.push_back(radius * x); vertices.push_back(radius * y); vertices.push_back(radius * z);
vertices.push_back(x); vertices.push_back(y); vertices.push_back(z);
}
}
for (unsigned int lat = 0; lat < latitudeDiv; ++lat) {
for (unsigned int lon = 0; lon < longitudeDiv; ++lon) {
unsigned int first = (lat * (longitudeDiv + 1)) + lon;
unsigned int second = first + longitudeDiv + 1;
indices.push_back(first); indices.push_back(second); indices.push_back(first + 1);
indices.push_back(second); indices.push_back(second + 1); indices.push_back(first + 1);
}
}
}
Task 4 - Generate cube geometry
- Implement
GenerateCubeDataintestRayCasting.cpp. The function receives astd::vector<float>&for vertices and astd::vector<unsigned int>&for indices, plus a half-extentsize. Each vertex stores 6 floats: position (x, y, z) followed by a normal placeholder (nx, ny, nz). - Define the 8 corner vertices. Store them as
float verts[8][6]. Use the sign pattern of each corner as its normal placeholder (corner 0 at (-s,-s,-s) gets normal (-1,-1,-1), and so on). The corners are:- 0: (-s,-s,-s) 1: (s,-s,-s) 2: (s,s,-s) 3: (-s,s,-s)
- 4: (-s,-s, s) 5: (s,-s, s) 6: (s,s, s) 7: (-s,s, s)
- Push all 48 floats to
verticeswith a nested loop over the array. - Define the 36 indices for 12 triangles (2 per face) as
unsigned int idx[36]. The face order and winding is given in the skeleton below. Push each index toindices. - Implement
SetupCubeBuffers. CallGenerateCubeData(vertices, indices, 1.0f), then createm_CubeVAO,m_CubeVBO, andm_CubeIBOfollowing the exact same pattern used for the sphere inSetupBuffers. The vertex layout is the same: 3 floats for position, 3 floats for normal.
// GenerateCubeData - 8 corner vertices, 12 triangles, 6 floats per vertex.
void testRayCasting::GenerateCubeData(
std::vector<float>& vertices,
std::vector<unsigned int>& indices,
float size)
{
float s = size;
// The 8 corners of an axis-aligned cube with half-extent s:
// 0: (-s,-s,-s) 1: (s,-s,-s) 2: (s,s,-s) 3: (-s,s,-s)
// 4: (-s,-s, s) 5: (s,-s, s) 6: (s,s, s) 7: (-s,s, s)
// Each vertex stores: position (3f) then a corner-direction normal placeholder (3f).
// TODO: Declare a float array verts[8][6] with the 8 corner vertices.
// Use the corner direction (e.g. corner 0 -> (-1,-1,-1)) as the normal.
float verts[8][6] = {
// position normal placeholder
// TODO: fill in all 8 rows
};
// TODO: Push all 48 floats to vertices.
// Hint: for (auto& v : verts) for (float f : v) vertices.push_back(f);
// 12 triangles (2 per face). Winding order keeps each face visible from outside.
// -Z face: 0,1,2 0,2,3
// +Z face: 5,4,7 5,7,6
// -X face: 4,0,3 4,3,7
// +X face: 1,5,6 1,6,2
// -Y face: 4,5,1 4,1,0
// +Y face: 3,2,6 3,6,7
// TODO: Declare an unsigned int array idx[36] with the 36 indices above.
// TODO: Push each index to indices.
// Hint: for (unsigned int i : idx) indices.push_back(i);
}
// SetupCubeBuffers - create the cube VAO, VBO, and IBO.
void testRayCasting::SetupCubeBuffers() {
std::vector<float> vertices;
std::vector<unsigned int> indices;
// TODO: Call GenerateCubeData with size = 1.0f.
// TODO: Create m_CubeVAO, m_CubeVBO (from vertices), add layout (3f position + 3f normal).
// TODO: Create m_CubeIBO from indices.
// Follow the same pattern used in SetupBuffers() for the sphere.
}
Task 5 - Calculate the ray direction from the mouse position
- Implement
CalculateRayDirection(float mouseX, float mouseY)intestRayCasting.cpp. This function is shared by both sphere and cube picking and does not change between them. - Retrieve the current window dimensions with
glfwGetFramebufferSize(m_window, &width, &height). - Convert the mouse X coordinate to NDC:
x = (2.0f * mouseX) / width - 1.0f. - Convert the mouse Y coordinate to NDC:
y = 1.0f - (2.0f * mouseY) / height. The subtraction from 1 flips the axis because window Y grows downward but NDC Y grows upward. - Pack the NDC coordinates into
glm::vec4 rayClip(x, y, -1.0f, 1.0f). The z = -1 points forward into the scene. - Multiply
rayClipbyglm::inverse(projectionMatrix)to move into eye space. Override the result toglm::vec4(rayEye.x, rayEye.y, -1.0f, 0.0f). Setting w to 0 marks this as a direction vector. - Multiply by
glm::inverse(viewMatrix)and extract xyz. Returnglm::normalize(rayWorld).
glm::vec3 testRayCasting::CalculateRayDirection(float mouseX, float mouseY) {
int width, height;
// TODO: Get the current framebuffer dimensions from GLFW.
// TODO: Convert mouseX to NDC x in [-1, 1]: x = (2.0f * mouseX) / width - 1.0f
float x = 0.0f;
// TODO: Convert mouseY to NDC y in [-1, 1], flipping the axis:
// y = 1.0f - (2.0f * mouseY) / height
float y = 0.0f;
// Step 1 - Clip space: z = -1 points into the scene.
glm::vec4 rayClip(x, y, -1.0f, 1.0f);
// Step 2 - Eye space: unproject with the inverse projection matrix.
// TODO: Multiply rayClip by glm::inverse(projectionMatrix).
glm::vec4 rayEye = glm::vec4(0.0f);
// Override z and w: direction, not a point.
rayEye = glm::vec4(rayEye.x, rayEye.y, -1.0f, 0.0f);
// Step 3 - World space: unproject with the inverse view matrix.
// TODO: Multiply rayEye by glm::inverse(viewMatrix) and extract xyz.
glm::vec3 rayWorld = glm::vec3(0.0f);
// TODO: Return the normalised world-space direction.
return glm::vec3(0.0f);
}
Task 6 - Implement ray-sphere intersection
- Implement
RayIntersectsSphereintestRayCasting.cpp. - Compute
oc = rayOrigin - sphereCenter. This vector appears in all three quadratic coefficients. - Compute the three coefficients of the quadratic
a*t^2 + b*t + c = 0:a = glm::dot(rayDirection, rayDirection)b = 2.0f * glm::dot(oc, rayDirection)c = glm::dot(oc, oc) - sphereRadius * sphereRadius
- Compute
discriminant = b * b - 4 * a * c. - Return
trueif the discriminant is greater than 0. A positive discriminant means the ray intersects the sphere at two distinct points.
bool testRayCasting::RayIntersectsSphere(
const glm::vec3& rayOrigin,
const glm::vec3& rayDirection,
const glm::vec3& sphereCenter,
float sphereRadius)
{
// TODO: Compute oc = rayOrigin - sphereCenter.
glm::vec3 oc = glm::vec3(0.0f);
// Substituting P = origin + t*direction into |P - center|^2 = r^2
// gives the quadratic a*t^2 + b*t + c = 0.
// TODO: a = dot(rayDirection, rayDirection)
float a = 0.0f;
// TODO: b = 2 * dot(oc, rayDirection)
float b = 0.0f;
// TODO: c = dot(oc, oc) - sphereRadius^2
float c = 0.0f;
// TODO: discriminant = b^2 - 4*a*c
float discriminant = 0.0f;
// TODO: Return true when discriminant > 0 (two intersection points exist).
return false;
}
Task 7 - Implement ray-AABB intersection
- Implement
RayIntersectsCubeintestRayCasting.cpp. This function uses the slab method described in the Concept section. - Compute
invDir = 1.0f / rayDirectioncomponent-wise. Dividing once and multiplying gives the same result as repeated division but avoids redundant work. - Compute
t1 = (boxCenter - glm::vec3(boxSize) - rayOrigin) * invDir. Each component oft1is the t value where the ray crosses the near wall of that axis slab. - Compute
t2 = (boxCenter + glm::vec3(boxSize) - rayOrigin) * invDir. Each component oft2is the t value where the ray crosses the far wall. - Use
tMin = glm::min(t1, t2)andtMax = glm::max(t1, t2)to ensure the near value is always smaller regardless of the ray direction sign. - Compute
tEnter = glm::max(glm::max(tMin.x, tMin.y), tMin.z)(last axis the ray enters) andtExit = glm::min(glm::min(tMax.x, tMax.y), tMax.z)(first axis the ray exits). - Return
truewhentExit > tEnter && tExit > 0.0f. The first condition means the ray is inside all three slabs at some point. The second condition means the box is in front of the ray origin.
bool testRayCasting::RayIntersectsCube(
const glm::vec3& rayOrigin,
const glm::vec3& rayDirection,
const glm::vec3& boxCenter,
float boxSize)
{
// The slab method tests each axis independently.
// For each axis, the ray crosses two planes (the near and far walls of the slab).
// tEnter = the largest of the three near crossings (the ray is inside ALL slabs from here).
// tExit = the smallest of the three far crossings (the ray leaves the box here).
// Hit when tExit > tEnter and tExit > 0 (box is in front of the ray origin).
// TODO: Compute invDir = 1.0f / rayDirection (one multiply per component is cheaper than divide).
glm::vec3 invDir = glm::vec3(1.0f);
// TODO: t1 = (boxCenter - vec3(boxSize) - rayOrigin) * invDir (near slab crossings per axis)
glm::vec3 t1 = glm::vec3(0.0f);
// TODO: t2 = (boxCenter + vec3(boxSize) - rayOrigin) * invDir (far slab crossings per axis)
glm::vec3 t2 = glm::vec3(0.0f);
// Ensure tMin.x < tMax.x etc. regardless of ray direction sign.
// TODO: tMin = glm::min(t1, t2)
glm::vec3 tMin = glm::vec3(0.0f);
// TODO: tMax = glm::max(t1, t2)
glm::vec3 tMax = glm::vec3(0.0f);
// TODO: tEnter = glm::max(glm::max(tMin.x, tMin.y), tMin.z)
float tEnter = 0.0f;
// TODO: tExit = glm::min(glm::min(tMax.x, tMax.y), tMax.z)
float tExit = 0.0f;
// TODO: Return true when tExit > tEnter && tExit > 0.0f.
return false;
}
Task 8 - Wire picking and rendering for both object types
- In
Update, after getting the ray direction, iterate overobjects. For each object, checkobjects[i].typeand call eitherRayIntersectsSphereorRayIntersectsCubewithobjects[i].positionandobjects[i].size. On the first hit, store the index inselectedObjectIndexand break. - In
Render, build the view and projection matrices and pass them to the shader as before. In the draw loop, checkobjects[i].type: draw spheres withrenderer.Draw(*m_VAO, *m_IBO, *m_Shader)and cubes withrenderer.Draw(*m_CubeVAO, *m_CubeIBO, *m_Shader). - In
RenderGUI, update the selected-object display. When an object is selected, show its name, type ("Sphere" or "Cube"), position, and size. When nothing is selected, show "No object selected." - Compile and run. Moving the cursor over a sphere or a cube should turn it green. The ImGui panel should correctly label the object type.
void testRayCasting::Update(float deltaTime) {
ProcessInput();
double mouseX, mouseY;
// TODO: Call glfwGetCursorPos to get cursor coordinates.
// TODO: Call CalculateRayDirection to get the world-space ray.
glm::vec3 rayDirection = glm::vec3(0.0f);
glm::vec3 rayOrigin = cameraPosition;
// TODO: Reset selectedObjectIndex to -1.
for (size_t i = 0; i < objects.size(); ++i) {
// TODO: Declare bool hit = false.
// TODO: If objects[i].type == ObjectType::Sphere, call RayIntersectsSphere.
// Otherwise call RayIntersectsCube.
// Both receive rayOrigin, rayDirection, objects[i].position, objects[i].size.
// TODO: If hit is true, store i in selectedObjectIndex and break.
}
}
void testRayCasting::Render() {
Renderer renderer;
renderer.Clear();
m_Shader->Bind();
// TODO: Build viewMatrix with glm::lookAt(cameraPosition, cameraPosition + cameraFront, cameraUp).
// TODO: Build projectionMatrix with glm::perspective(45 degrees, 800/600 aspect, 0.1 near, 100 far).
// TODO: Pass both to the shader ("view" and "projection").
for (size_t i = 0; i < objects.size(); ++i) {
// TODO: Translate glm::mat4(1.0f) to objects[i].position. Pass as "model".
// TODO: Colour is green (0,1,0) when i == selectedObjectIndex, red (1,0,0) otherwise.
// TODO: Pass colour via setUniform4f("u_Colour", r, g, b, 1.0f).
// TODO: If ObjectType::Sphere, call renderer.Draw(*m_VAO, *m_IBO, *m_Shader).
// If ObjectType::Cube, call renderer.Draw(*m_CubeVAO, *m_CubeIBO, *m_Shader).
}
}
void testRayCasting::RenderGUI() {
// TODO: Display camera position with ImGui::Text.
// TODO: Add ImGui::SliderFloat for cameraSpeed in range 0.1 to 10.0.
// TODO: If selectedObjectIndex != -1, show the object name, type ("Sphere" or "Cube"),
// position, and size. Otherwise display "No object selected."
}
Solution
Try the tasks above before revealing the solution.