← All examples
Rendering · ECS
Camera Follow
ECS camera-follow with off-screen culling
The ECS-driven counterpart to camera_demo: a player entity (Transform + Velocity + Camera, set as resources::ActiveCamera) WASD-driven through a 40×30-tile world, 1280×960 — well beyond the 800×600 window.
systems::CameraSystem smoothly follows the player every frame, and RenderSystem culls tiles outside the visible area (video::VisibleWorldRect) rather than submitting a draw call for all 1,200 of them. Unlike camera_demo, nothing here ever calls IRenderer::SetCamera directly — CameraSystem does that from the active entity's own components, the way a real game would.
Controls
- W A S D Move the player
- ↑ / ↓ Adjust zoom
- F Toggle camera smoothing (instant-snap vs. eased follow)

Run it yourself
build just this example
cmake --build --preset windows-debug --target camera_follow_demo
# binary lands under bin/ (e.g. bin/Debug/camera_follow_demo.exe on Windows)
See Installation for the full build setup.
Source
examples/camera_follow_demo/Game.hpp
#pragma once
#include <ASGE/ASGE.hpp>
#include <vector>
/**
* @brief Showcases components::Camera/resources::ActiveCamera/systems::
* CameraSystem: a player entity WASD-driven through a tiled world
* far bigger than the window, with the camera smoothly following it
* and systems::RenderSystem culling every tile outside the visible
* area rather than submitting a draw call for all of them.
*
* Unlike camera_demo (which drives asge::video::Camera directly, with no
* ECS involved), this demo never touches IRenderer::SetCamera itself —
* CameraSystem does that each frame from the ActiveCamera entity's own
* Transform + Camera components, the same way a real game would.
*/
class CameraFollowDemoState final : public asge::game::state::IGameState<int>
{
asge::ecs::Registry& m_Registry;
asge::game::asset::AssetManager& m_Assets;
asge::ecs::Entity m_Player{ asge::ecs::Entity::Null() };
std::unique_ptr<asge::video::ITexture> m_TileTexture; // Lazily created on first Render
bool m_SpritesAttached{ false };
bool m_Up{ false };
bool m_Down{ false };
bool m_Left{ false };
bool m_Right{ false };
void SpawnWorld();
void EnsureSpritesAttached(asge::video::IRenderer& inRenderer);
void UpdatePlayerVelocity();
void AdjustZoom(float inDelta);
void ToggleSmoothing();
void RenderHud(asge::video::IRenderer& inRenderer) const;
public:
CameraFollowDemoState(asge::ecs::Registry& inRegistry, asge::game::asset::AssetManager& inAssets);
[[nodiscard]] std::optional<asge::game::state::Transition<int>>
Update(float inDeltaTime, asge::input::InputState const& inInput) override;
void Render(asge::video::IRenderer& inRenderer) override;
[[nodiscard]] asge::graphics::RGBA_Color ClearColor() const noexcept override { return { 15, 18, 22, 255 }; }
void OnSystemEvent(asge::event::SystemEvent const& inSysEvent) override;
};
class CameraFollowDemoGame final : public asge::game::Game<int>
{
public:
explicit CameraFollowDemoGame(asge::video::IRenderer& inRenderer, asge::audio::AudioDevice& inAudioDev);
protected:
[[nodiscard]] std::unique_ptr<StateType> CreateState(int inId) override;
};
examples/camera_follow_demo/Game.cpp
#include "Game.hpp"
#include <algorithm>
#include <array>
namespace
{
using asge::input::Keycode;
using asge::graphics::RGBA_Color;
using asge::game::components::Camera;
using asge::game::components::Sprite;
using asge::game::components::Transform;
using asge::game::components::Velocity;
using asge::game::resources::ActiveCamera;
constexpr char const* kTilePath = "textures/checker.bmp";
constexpr float kTileSize = 32.0f;
constexpr int kGridCols = 40;
constexpr int kGridRows = 30;
constexpr float kWorldW = kGridCols * kTileSize; // 1280 -- comfortably bigger than the 800x600 window
constexpr float kWorldH = kGridRows * kTileSize; // 960
constexpr float kPlayerSpeed = 260.0f;
constexpr float kPlayerSize = 40.0f;
constexpr float kZoomMin = 0.4f;
constexpr float kZoomMax = 2.5f;
constexpr float kZoomStep = 0.1f;
constexpr float kSmoothingRate = 4.0f; // catch-up rate when smoothing is toggled on -- see Camera::m_Smoothing
struct Landmark { asge::math::Float2 m_Pos; RGBA_Color m_Color; };
std::array<Landmark, 6> const kLandmarks{ {
{ { 100.0f, 100.0f }, RGBA_Color{ 220, 60, 60, 255 } },
{ { 1180.0f, 100.0f }, RGBA_Color{ 60, 200, 90, 255 } },
{ { 640.0f, 480.0f }, RGBA_Color{ 240, 200, 60, 255 } },
{ { 100.0f, 860.0f }, RGBA_Color{ 90, 170, 240, 255 } },
{ { 1180.0f, 860.0f }, RGBA_Color{ 200, 90, 220, 255 } },
{ { 640.0f, 100.0f }, RGBA_Color{ 240, 140, 60, 255 } },
} };
}
CameraFollowDemoState::CameraFollowDemoState(
asge::ecs::Registry& inRegistry, asge::game::asset::AssetManager& inAssets
)
: m_Registry(inRegistry), m_Assets(inAssets)
{
SpawnWorld();
}
void CameraFollowDemoState::SpawnWorld()
{
// The tiled floor: one entity per grid cell, all sharing the one
// checker texture once EnsureSpritesAttached runs -- RenderSystem culls
// the ones outside the camera's current visible area (see
// video::VisibleWorldRect), so only a fraction of these kGridCols *
// kGridRows entities actually reach a draw call most frames.
for ( int row = 0; row < kGridRows; ++row )
{
for ( int col = 0; col < kGridCols; ++col )
{
auto tile = m_Registry.CreateEntity();
if ( !tile ) { tile.LogError(); continue; }
asge::math::Float2 const tilePos{ static_cast<float>(col) * kTileSize, static_cast<float>(row) * kTileSize };
m_Registry.AddComponent<Transform>( tile.Value(),
Transform{ .m_LocalCoordinates = tilePos, .m_WorldCoordinates = tilePos } );
}
}
// The player: Transform (its Camera target) + Velocity (WASD-driven,
// via MovementSystem) + Camera (this is what makes it a valid
// ActiveCamera target -- see resources::ActiveCamera's doc comment).
auto player = m_Registry.CreateEntity();
if ( !player ) { player.LogError(); return; }
m_Player = player.Value();
m_Registry.AddComponent<Transform>( m_Player, Transform{
.m_LocalCoordinates = {kWorldW * 0.5f, kWorldH * 0.5f},
.m_LocalScale = {kPlayerSize / kTileSize, kPlayerSize / kTileSize},
.m_WorldCoordinates = {kWorldW * 0.5f, kWorldH * 0.5f},
.m_WorldScale = {kPlayerSize / kTileSize, kPlayerSize / kTileSize}
} );
m_Registry.AddComponent<Velocity>( m_Player, Velocity{} );
m_Registry.AddComponent<Camera>( m_Player, Camera{ .m_Zoom = 1.0f, .m_Smoothing = kSmoothingRate } );
m_Registry.SetResource( ActiveCamera{ m_Player } );
}
void CameraFollowDemoState::EnsureSpritesAttached(asge::video::IRenderer &inRenderer)
{
if ( m_SpritesAttached ) return;
auto imageAsset = m_Assets.GetImage(kTilePath);
if ( !imageAsset ) { imageAsset.LogError(); return; }
m_TileTexture = inRenderer.CreateTexture(imageAsset.Value()->Get());
if ( !m_TileTexture ) return;
// Every entity with a Transform but no Velocity is one of the tiled
// floor cells spawned in SpawnWorld -- the player already has both, so
// this deliberately skips it and never gives it a Sprite: it's drawn as
// a plain highlighted rect in Render() instead (see RenderHud), same as
// audio_demo/camera_demo's state indicators.
for ( auto [ entity, transform ] : m_Registry.View<Transform>() )
{
if ( m_Registry.HasComponent<Velocity>( entity ) ) continue;
auto result = m_Registry.AddComponent<Sprite>( entity, Sprite{ m_TileTexture.get(), std::nullopt, kTilePath } );
if ( !result ) result.LogError();
}
m_SpritesAttached = true;
}
void CameraFollowDemoState::UpdatePlayerVelocity()
{
auto result = m_Registry.GetComponent<Velocity>( m_Player );
if ( !result ) return;
Velocity& velocity = result.Value().get();
velocity.m_DX = (m_Right ? kPlayerSpeed : 0.0f) - (m_Left ? kPlayerSpeed : 0.0f);
velocity.m_DY = (m_Down ? kPlayerSpeed : 0.0f) - (m_Up ? kPlayerSpeed : 0.0f);
}
void CameraFollowDemoState::AdjustZoom(float inDelta)
{
auto result = m_Registry.GetComponent<Camera>( m_Player );
if ( !result ) return;
Camera& camera = result.Value().get();
camera.m_Zoom = std::clamp( camera.m_Zoom + inDelta, kZoomMin, kZoomMax );
}
void CameraFollowDemoState::ToggleSmoothing()
{
auto result = m_Registry.GetComponent<Camera>( m_Player );
if ( !result ) return;
Camera& camera = result.Value().get();
camera.m_Smoothing = ( camera.m_Smoothing > 0.0f ) ? 0.0f : kSmoothingRate;
}
void CameraFollowDemoState::RenderHud(asge::video::IRenderer &inRenderer) const
{
auto cameraResult = m_Registry.GetComponent<Camera>( m_Player );
if ( !cameraResult ) return;
Camera const& camera = cameraResult.Value().get();
// Landmarks and the player marker are drawn straight via DrawRect/
// DrawCircle rather than through RenderSystem -- they still land in the
// right place because IRenderer applies whatever camera CameraSystem
// just set to every draw call, ECS-driven or not (see IRenderer::
// SetCamera's doc comment).
for ( auto const& landmark : kLandmarks )
{
inRenderer.DrawCircle(
asge::math::Int2{ static_cast<int>(landmark.m_Pos.x()), static_cast<int>(landmark.m_Pos.y()) },
24, landmark.m_Color, true
);
}
if ( auto transform = m_Registry.GetComponent<Transform>( m_Player ) )
{
Transform const& t = transform.Value().get();
inRenderer.DrawRect(
asge::math::Rect{
t.m_WorldCoordinates.x() - kPlayerSize * 0.5f, t.m_WorldCoordinates.y() - kPlayerSize * 0.5f,
kPlayerSize, kPlayerSize
},
RGBA_Color{ 250, 250, 255, 255 }, true
);
}
// HUD is drawn in screen space, independent of the ECS camera -- reset
// it first so the bars below land at fixed screen positions regardless
// of where the player currently is in the world. CameraSystem reads
// IRenderer::GetCamera() as its own starting point every frame (that's
// what makes m_Smoothing's ease-toward-target math continuous across
// frames), so this must be put back before returning -- leaving the
// identity camera set here would make next frame's CameraSystem think
// the view had snapped back to the origin.
asge::video::Camera const worldCamera = inRenderer.GetCamera();
inRenderer.SetCamera( asge::video::Camera{} );
// Zoom bar (top-left): fill tracks Camera::m_Zoom between kZoomMin/Max.
asge::math::Rect const zoomTrack{ 20.0f, 20.0f, 160.0f, 16.0f };
float const zoomFrac = (camera.m_Zoom - kZoomMin) / (kZoomMax - kZoomMin);
inRenderer.DrawRect(zoomTrack, RGBA_Color{ 90, 90, 100, 255 }, false);
inRenderer.DrawRect(
asge::math::Rect{ zoomTrack.m_X, zoomTrack.m_Y, zoomTrack.m_Width * zoomFrac, zoomTrack.m_Height },
RGBA_Color{ 100, 170, 240, 255 }, true
);
// "SMOOTH" indicator (below the zoom bar): lit while Camera::m_Smoothing > 0.
inRenderer.DrawRect(
asge::math::Rect{ 20.0f, 46.0f, 30.0f, 16.0f },
camera.m_Smoothing > 0.0f ? RGBA_Color{ 80, 220, 140, 255 } : RGBA_Color{ 55, 55, 60, 255 },
true
);
inRenderer.SetCamera( worldCamera );
}
std::optional<asge::game::state::Transition<int>>
CameraFollowDemoState::Update([[maybe_unused]] float inDeltaTime, [[maybe_unused]] asge::input::InputState const &inInput)
{
UpdatePlayerVelocity();
return std::nullopt;
}
void CameraFollowDemoState::Render(asge::video::IRenderer &inRenderer)
{
EnsureSpritesAttached(inRenderer);
RenderHud( inRenderer );
}
void CameraFollowDemoState::OnSystemEvent(asge::event::SystemEvent const &inSysEvent)
{
auto const* keyEvent = inSysEvent.TryGet<asge::event::KeyboardEvent>();
if ( !keyEvent ) return;
bool const pressed = keyEvent->s_Type == asge::event::EventType::KEYBOARD_KEY_PRESSED;
switch ( keyEvent->s_Keycode )
{
case Keycode::W: m_Up = pressed; break;
case Keycode::S: m_Down = pressed; break;
case Keycode::A: m_Left = pressed; break;
case Keycode::D: m_Right = pressed; break;
default: break;
}
if ( !pressed || keyEvent->s_Repeat ) return; // rest are edge-triggered: one step per physical press
if ( keyEvent->s_Keycode == Keycode::UP ) AdjustZoom( kZoomStep );
else if ( keyEvent->s_Keycode == Keycode::DOWN ) AdjustZoom( -kZoomStep );
else if ( keyEvent->s_Keycode == Keycode::F ) ToggleSmoothing();
}
CameraFollowDemoGame::CameraFollowDemoGame(asge::video::IRenderer& inRenderer, asge::audio::AudioDevice& inAudioDev)
: Game(inRenderer, inAudioDev)
{
// ASGE_CAMERA_FOLLOW_DEMO_ASSET_DIR is injected by CMakeLists.txt;
// mounted once so the tile texture is loaded by virtual path (see
// EnsureSpritesAttached) instead of a hardcoded OS path.
auto mountResult = m_Vfs.Mount("textures", ASGE_CAMERA_FOLLOW_DEMO_ASSET_DIR);
if ( !mountResult ) mountResult.LogError();
SetInitialState(0);
}
std::unique_ptr<CameraFollowDemoGame::StateType> CameraFollowDemoGame::CreateState([[maybe_unused]] int inId)
{
return std::make_unique<CameraFollowDemoState>( m_SceneManager.GetRegistry(), m_Assets );
}