Skip to main content

12 Hello Controller

This tutorial shows how to use a game controller with the Tellusim Core SDK to interactively navigate a 3D scene. We will load the Sponza scene, connect a game controller, and implement a basic spectator camera with collision detection to prevent clipping through geometry.

The tutorial reuses the same Window creation logic and Mesh setup as in previous tutorials.

Spectator Camera Parameters

The spectator camera is modeled with a spherical collision shape and controlled using keyboard/mouse and game controller input.

Below are the main parameters used to configure camera motion and orientation:

// Camera radius
constexpr float32_t camera_radius = 0.3f;

// Current camera linear and angular velocities
Vector3f camera_linear_velocity = Vector3f::zero;
Vector2f camera_angular_velocity = Vector2f::zero;

// Current camera position and direction
Vector3f camera_position = Vector3f(0.0f, -2.0f, 1.0f);
Vector3f camera_direction = Vector3f(0.0f, 1.0f, 0.0f);

Keyboard and Mouse Controls

Keyboard and mouse input is handled using the Window interface. Movement and rotation are accumulated each frame into velocity variables:

// Keyboard control
float32_t acceleration = ifps * keyboard_acceleration;
if(window.getKeyboardKey('w') || window.getKeyboardKey(Window::KeyUp)) camera_linear_velocity.x -= acceleration;
if(window.getKeyboardKey('s') || window.getKeyboardKey(Window::KeyDown)) camera_linear_velocity.x += acceleration;
if(window.getKeyboardKey('a') || window.getKeyboardKey(Window::KeyLeft)) camera_linear_velocity.y += acceleration;
if(window.getKeyboardKey('d') || window.getKeyboardKey(Window::KeyRight)) camera_linear_velocity.y -= acceleration;
if(window.getKeyboardKey('q')) camera_linear_velocity.z += acceleration;
if(window.getKeyboardKey('e')) camera_linear_velocity.z -= acceleration;

// Mouse input
float32_t mouse_dx = (float32_t)window.getMouseDX();
float32_t mouse_dy = (float32_t)window.getMouseDY();

// Mouse rotation
if(window.getMouseButton(Window::ButtonLeft)) {
camera_angular_velocity.x += mouse_dx * rotation_sensitivity;
camera_angular_velocity.y += mouse_dy * rotation_sensitivity;
}
// Mouse panning
else if(window.getMouseButton(Window::ButtonMiddle)) {
camera_linear_velocity.y += mouse_dx * panning_sensitivity;
camera_linear_velocity.z += mouse_dy * panning_sensitivity;
}
// Mouse dollying
else if(window.getMouseButton(Window::ButtonRight)) {
camera_linear_velocity.x += mouse_dy * dollying_sensitivity;
}

Game Controller Integration

The Controller interface provides cross-platform access to joysticks, gamepads, and steering wheels. To use it, a Controller object must be instantiated and connected:

// Create controller
Controller controller;

// Controller callbacks
controller.setConnectedCallback([&](Controller controller) {
panel.setInfo(controller.getName() + "\n" + controller.getModel());
});
controller.setDisconnectedCallback([&](Controller controller) {
panel.setInfo(String("Disconnected"));
});

// main loop
{
// Connect controller if it isn't yet connected
if(!controller.wasConnected()) controller.connect();
}

The controller analog sticks and triggers will be used to move and rotate the camera:

// Camera rotation with right stick
float32_t sensitivity = controller_sensitivity * ifps;
camera_angular_velocity.x += controller.getStickX(Controller::StickRight) * sensitivity;
camera_angular_velocity.y += controller.getStickY(Controller::StickRight) * sensitivity;

// Camera panning with left stick
float32_t acceleration = controller_acceleration * ifps;
camera_linear_velocity.y -= controller.getStickX(Controller::StickLeft) * acceleration;
camera_linear_velocity.z -= controller.getStickY(Controller::StickLeft) * acceleration;

// Camera dollying with triggers
camera_linear_velocity.x += controller.getButtonValue(Controller::ButtonTriggerLeft) * acceleration;
camera_linear_velocity.x -= controller.getButtonValue(Controller::ButtonTriggerRight) * acceleration;

Camera Movement and Orientation

Using the accumulated velocities, we apply orientation changes and positional updates to the camera:

// Rotate camera based on camera angular velocity
float32_t phi = atan2(camera_direction.x, camera_direction.y) * Rad2Deg + camera_angular_velocity.x * ifps;
float32_t theta = clamp(acos(clamp(camera_direction.z, -1.0f, 1.0f)) * Rad2Deg - 90.0f + camera_angular_velocity.y * ifps, -89.9f, 89.9f);
camera_direction = (Quaternionf::rotateZ(-phi) * Quaternionf::rotateX(-theta)) * Vector3f(0.0f, 1.0f, 0.0f);

// Calculate local camera basis
Vector3f front_direction = normalize(camera_direction);
Vector3f right_direction = normalize(cross(camera_direction, Vector3f(0.0f, 0.0f, 1.0f)));
Vector3f top_direction = normalize(cross(front_direction, right_direction));

// Update camera position based on camera linear velocity and current orientation
camera_position += front_direction * (camera_linear_velocity.x * ifps);
camera_position += right_direction * (camera_linear_velocity.y * ifps);
camera_position += top_direction * (camera_linear_velocity.z * ifps);

Scene Collision Detection

To avoid camera clipping through geometry, we build a CPU-side acceleration structure (spatial tree) for triangle meshes. Each MeshGeometry is assigned its own Spatial tree instance. For simplicity, we will use 3 vertices per triangle without indices:

// Spatial tree
struct SpatialTree {
Array<Spatial::Node3f> nodes;
Array<Vector3f> vertices;
};

// Create Spatial tree instances
Array<SpatialTree> spatial_trees(mesh.getNumGeometries());

// Iterate over mesh geometries
for(const MeshGeometry &geometry : mesh.getGeometries()) {
SpatialTree &spatial = spatial_trees[geometry.getIndex()];

// Get position attribute
const MeshAttribute &positions = geometry.getAttribute(MeshAttribute::TypePosition);
if(!positions || positions.getFormat() != FormatRGBf32) continue;

// Get position indices
const MeshIndices &indices = positions.getIndices();
if(!indices || indices.getType() != MeshIndices::TypeTriangle) continue;

// Create triangles and nodes
uint32_t num_nodes = indices.getSize() / 3;
spatial.nodes.resize(num_nodes * 2);
spatial.vertices.resize(num_nodes * 3);
for(uint32_t i = 0, j = 0; i < num_nodes; i++, j += 3) {
const Vector3f &v0 = positions.get<Vector3f>(indices.get(j + 0));
const Vector3f &v1 = positions.get<Vector3f>(indices.get(j + 1));
const Vector3f &v2 = positions.get<Vector3f>(indices.get(j + 2));
spatial.nodes[num_nodes + i].bound.min = min(v0, v1, v2);
spatial.nodes[num_nodes + i].bound.max = max(v0, v1, v2);
spatial.vertices[j + 0] = v0;
spatial.vertices[j + 1] = v1;
spatial.vertices[j + 2] = v2;
}

// Build a spatial tree using provided bounding boxes
Spatial::create<float32_t>(spatial.nodes.get(), num_nodes);
}

// Spatial tree indices
Array<uint32_t> spatial_indices(1024);

With this spatial tree, we can efficiently query for triangle candidates within the camera radius.

To resolve collisions, we use a simple repulsion approach by iterating a fixed number of times until the camera is no longer intersecting geometry. This method is simple method, but not robust in some cases:

// Limit the number of steps
for(uint32_t i = 0; i < 8; i++) {

// Perform collision detection with the scene
float32_t contact_depth = 0.0f;
Vector3f contact_position = Vector3f::zero;
BoundBoxf camera_bound = BoundBoxf(camera_position - camera_radius, camera_position + camera_radius);
for(const SpatialTree &spatial : spatial_trees) {
Spatial::intersection(camera_bound, spatial.nodes.get(), spatial_indices);
for(uint32_t index : spatial_indices) {
const Vector3f &v0 = spatial.vertices[index * 3 + 0];
const Vector3f &v1 = spatial.vertices[index * 3 + 1];
const Vector3f &v2 = spatial.vertices[index * 3 + 2];
Vector3f texcoord = Triangle::closest(v0, v1, v2, camera_position);
float32_t depth = camera_radius - texcoord.z;
if(depth < contact_depth) continue;
contact_position = Triangle::lerp(v0, v1, v2, texcoord.xy);
contact_depth = depth;
}
}

// Check contact depth
if(contact_depth < 1e-6f) break;

// Simple collision resolve using deepest contact
Vector3f contact_normal = normalize(camera_position - contact_position);
camera_position += contact_normal * (contact_depth - 1e-6f);
}

Rendering the Scene

With the final camera position and orientation, we render the mesh using MeshModel as before:

// Window target
target.begin();
{
// Create command list
Command command = device.createCommand(target);

// Set pipeline
command.setPipeline(pipeline);

// Set sampler
command.setSampler(0, sampler);

// Set model buffers
model.setBuffers(command);

// Set common parameters
CommonParameters common_parameters;
common_parameters.camera = Vector4f(camera_position, 0.0f);
common_parameters.projection = Matrix4x4f::perspective(60.0f, (float32_t)window.getWidth() / window.getHeight(), 0.1f);
common_parameters.modelview = Matrix4x4f::lookAt(camera_position, camera_position - camera_direction, Vector3f(0.0f, 0.0f, 1.0f));
common_parameters.transform = Matrix4x4f::identity;
if(target.isFlipped()) common_parameters.projection = Matrix4x4f::scale(1.0f, -1.0f, 1.0f) * common_parameters.projection;
command.setUniform(0, common_parameters);

// Draw geometries
uint32_t texture_index = 0;
for(const MeshGeometry &geometry : mesh.getGeometries()) {

// Draw materials
for(const MeshMaterial &material : geometry.getMaterials()) {
command.setTexture(0, normal_textures[texture_index]);
command.setTexture(1, diffuse_textures[texture_index]);
command.setTexture(2, metallic_textures[texture_index]);
model.draw(command, geometry.getIndex(), material.getIndex());
texture_index++;
}
}

// Draw panel
panel.draw(command, target);
}
target.end();

Interactive Demo