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​