Appearance
第16回 ワールド座標とカメラ
4 / 4 画面に見えるタイルだけ描画する
このステップの目的
現在のカメラ位置から「画面内に見えているタイルの列範囲」を計算し、画面外にあるタイルの描画をスキップするカリング処理を実装します。
描画範囲を限定する視体積カリング
横スクロールアクションゲームのステージは横長であり、例えば横200列×縦15行のステージでは合計3,000個のタイルが存在します。
毎フレーム3,000マスすべてを走査して描画関数を呼び出すと、画面外の不可視タイルのために不要な描画負荷が生じます。
画面内に実際に映っているのは、おおむね20列×15行(約300個)程度です。
見えている範囲だけを描画対象に絞り込む手法を**視体積カリング(Frustum Culling)**と呼びます。
表示対象となる列インデックス(firstTileX 〜 lastTileX)の計算
カメラのX座標と画面幅から、画面左端と画面右端に位置するタイルの列番号(Xインデックス)を計算します。
Stage.cppcpp
void Stage::Draw(int cameraX, const ImageManager& images) const
{
const int firstTileX = std::max(0, cameraX / TileSize);
const int lastTileX = std::min(mapWidth_ - 1,
(cameraX + dxgame::config::ScreenWidth) / TileSize + 1);
for (int y = 0; y < mapHeight_; y++)
{
for (int x = firstTileX; x <= lastTileX; x++)
{
const StageCell cell = cells_[y][x];
if (cell == StageCell::Empty)
continue;
DrawTile(x * TileSize - cameraX, y * TileSize, cell, images);
}
}
const dxgame::Rect goalDrawRect = GetGoalDrawRect();
images.Draw(ImageId::Goal, {
goalDrawRect.x - static_cast<float>(cameraX), goalDrawRect.y,
goalDrawRect.width, goalDrawRect.height
});
}画面端の余白
最後の列は画面右端の座標から求め、さらに1列の余白を付けています。整数除算でも右端に一部だけ見える列は含まれます。+1はその外側を含める余裕です。firstTileXとlastTileXをマップの列範囲へ制限し、範囲外を読まないようにします。
動作確認の手順
プロジェクトをビルドして実行し、以下の項目を順に確認します。
- 横スクロールの確認:
- プレイヤーを右方向へ移動させ、画面中央($X = 320$)を超えたあたりから、背景画像を画面へ固定したままブロックが左へスクロールすることを確認します。
- ステージ両端でのカメラ停止:
- ゲーム開始地点(左端)でカメラが左の余白を映さずに停止していることを確認します。
- プレイヤーを右へ進め、ステージの右端に到達した際、カメラが停止し、プレイヤーのみがゴールへ向かって進めることを確認します。
- デバッグ表示との連動:
- F1 キーを押してデバッグ枠線を表示させた状態でスクロールさせ、プレイヤーの当たり判定枠やタイルの枠線が画像とズレることなく追従することを確認します。
チェックリスト
- カメラX座標から画面内の描画列範囲(
firstTileX〜lastTileX)を正しく計算できる - 画面端の表示欠落を防ぐために余白の「+ 1」列を含める理由を説明できる
- プレイヤーの移動に合わせてステージ全体がスクロールすることを確認できる
- ステージの左右両端でカメラが停止し、余白が見切れないことを確認できる
まとめと次回への引き継ぎ
第16回では、ワールド座標系とスクリーン座標系の分離、プレイヤーを追従するカメラ、そして画面内タイルのみを描画するカリング処理を実装しました。これで画面よりも広いステージを探索できる視覚基盤が完成しました。
次回(第17回)は、等速直線運動から物理運動への移行を行います。キー入力に応じた「加速度」、床との「摩擦」による減速、および整数化による小さな移動の消失を防ぐ「端数蓄積(Remainder)」の仕組みを学びます。
参考実装
今回追加・変更するファイルの一例です。宣言や補助処理を確認したいときに開いてください。自分のコードへ必要な変更を反映し、既存の調整値や素材をそのまま上書きしないようにします。
Source/Platformer/Camera.cpp
Source/Platformer/Camera.cppcpp
#include <algorithm>
#include "Config/ApplicationConfig.h"
#include "Platformer/Camera.h"
#include "Platformer/Tuning/GameTuning.h"
namespace dxgame::platformer
{
void Camera::Update(const dxgame::Vector2& target, int worldWidth)
{
const int targetX = static_cast<int>(
target.x - tuning::camera::TargetScreenX);
const int maxX = std::max(0, worldWidth - dxgame::config::ScreenWidth);
x_ = std::clamp(targetX, 0, maxX);
}
dxgame::Vector2 Camera::WorldToScreen(const dxgame::Vector2& world) const
{
return { world.x - static_cast<float>(x_), world.y };
}
dxgame::Rect Camera::WorldToScreen(const dxgame::Rect& world) const
{
return { world.x - static_cast<float>(x_), world.y,
world.width, world.height };
}
int Camera::GetX() const
{
return x_;
}
}Source/Platformer/Camera.h
Source/Platformer/Camera.hcpp
#pragma once
#include "DxGame/Rect.h"
#include "DxGame/Vector2.h"
namespace dxgame::platformer
{
// ワールド座標を画面座標へ変換するカメラ
class Camera
{
public:
// プレイヤーを基準にカメラ位置を更新する
void Update(const dxgame::Vector2& target, int worldWidth);
// ワールド座標の値を画面座標へ変換する
dxgame::Vector2 WorldToScreen(const dxgame::Vector2& world) const;
dxgame::Rect WorldToScreen(const dxgame::Rect& world) const;
int GetX() const;
private:
int x_ = 0;
};
}Source/Platformer/Game.cpp
Source/Platformer/Game.cppcpp
#include <Windows.h>
#include <string>
#include "DxLib.h"
#include "Config/ApplicationConfig.h"
#include "Platformer/Game.h"
#include "Platformer/Tuning/GameTuning.h"
namespace dxgame::platformer
{
bool Game::Initialize()
{
if (!images_.LoadAll())
return false;
constexpr wchar_t StagePath[] = L"Assets/Maps/Stage01.csv";
if (!stage_.Initialize(StagePath))
{
const std::wstring message = std::wstring(StagePath) + L"\n" +
stage_.GetLastLoadError();
MessageBoxW(nullptr, message.c_str(), L"Map Load Error", MB_OK);
return false;
}
ResetStage();
return true;
}
void Game::MainLoop()
{
// 小数分の待ち時間を次フレームへ持ち越す
double nextFrame = GetNowCount();
while (ProcessMessage() == 0)
{
input_.Update();
if (input_.IsHeld(Key::Escape))
break;
Update(dxgame::config::DeltaTime);
ClearDrawScreen();
Draw();
ScreenFlip();
nextFrame += dxgame::config::FrameMilliseconds;
const int waitMilliseconds =
static_cast<int>(nextFrame - GetNowCount());
if (waitMilliseconds > 0)
WaitTimer(waitMilliseconds);
else
nextFrame = GetNowCount();
}
}
void Game::Update(float deltaTime)
{
UpdateDebugToggle();
player_.Update(input_, deltaTime);
camera_.Update(player_.GetPosition(), stage_.GetWorldWidth());
}
void Game::Draw() const
{
DrawBackground();
stage_.Draw(camera_.GetX(), images_);
player_.Draw(camera_, images_);
DrawHud();
DrawDebug();
}
void Game::DrawBackground() const
{
images_.Draw(ImageId::Background, { 0.0f, 0.0f,
static_cast<float>(dxgame::config::ScreenWidth),
static_cast<float>(dxgame::config::ScreenHeight) });
}
void Game::DrawHud() const
{
const int textColor = GetColor(255, 255, 255);
DrawString(12, 10, L"LEFT/RIGHT: MOVE", textColor);
DrawString(12, 30, L"Z: JUMP", textColor);
DrawString(12, 50, L"F1: DEBUG ESC: QUIT", textColor);
}
void Game::DrawDebug() const
{
if (!debugVisible_)
return;
const dxgame::Rect playerDrawRect =
camera_.WorldToScreen(player_.GetDrawRect());
DrawBox(static_cast<int>(playerDrawRect.x),
static_cast<int>(playerDrawRect.y),
static_cast<int>(playerDrawRect.x + playerDrawRect.width),
static_cast<int>(playerDrawRect.y + playerDrawRect.height),
GetColor(80, 240, 255), FALSE);
const dxgame::Rect playerRect =
camera_.WorldToScreen(player_.GetHitbox());
DrawBox(static_cast<int>(playerRect.x), static_cast<int>(playerRect.y),
static_cast<int>(playerRect.x + playerRect.width),
static_cast<int>(playerRect.y + playerRect.height),
GetColor(255, 80, 80), FALSE);
const dxgame::Rect goalDrawRect =
camera_.WorldToScreen(stage_.GetGoalDrawRect());
DrawBox(static_cast<int>(goalDrawRect.x),
static_cast<int>(goalDrawRect.y),
static_cast<int>(goalDrawRect.x + goalDrawRect.width),
static_cast<int>(goalDrawRect.y + goalDrawRect.height),
GetColor(80, 240, 255), FALSE);
const dxgame::Rect goalRect =
camera_.WorldToScreen(stage_.GetGoalTrigger());
DrawBox(static_cast<int>(goalRect.x), static_cast<int>(goalRect.y),
static_cast<int>(goalRect.x + goalRect.width),
static_cast<int>(goalRect.y + goalRect.height),
GetColor(255, 220, 40), FALSE);
DrawBox(8, 76, 300, 178, GetColor(0, 0, 0), TRUE);
const int textColor = GetColor(255, 255, 255);
const dxgame::Vector2& position = player_.GetPosition();
DrawFormatString(14, 84, textColor,
L"pos: %.1f, %.1f", position.x, position.y);
DrawFormatString(14, 104, textColor, L"camera: %d",
camera_.GetX());
DrawFormatString(14, 124, textColor, L"csv: %d",
stage_.IsLoadedFromCsv() ? 1 : 0);
DrawString(14, 144,
L"cyan: draw red: player yellow: goal", textColor);
}
void Game::UpdateDebugToggle()
{
if (input_.IsPressed(Key::Debug))
debugVisible_ = !debugVisible_;
}
void Game::ResetStage()
{
player_.Reset(stage_.GetPlayerSpawn());
camera_.Update(player_.GetPosition(), stage_.GetWorldWidth());
}
}Source/Platformer/Game.h
Source/Platformer/Game.hcpp
#pragma once
#include "Platformer/Camera.h"
#include "Platformer/ImageManager.h"
#include "Platformer/Input.h"
#include "Platformer/Player.h"
#include "Platformer/Stage.h"
namespace dxgame::platformer
{
// 入力、プレイヤー、ステージ、描画順を調整する
class Game
{
public:
// 必須画像、音声、CSVを読み込む
bool Initialize();
// ウィンドウ終了まで更新と描画を繰り返す
void MainLoop();
private:
void Update(float deltaTime);
void Draw() const;
void DrawBackground() const;
void DrawHud() const;
void DrawDebug() const;
void UpdateDebugToggle();
void ResetStage();
Input input_;
ImageManager images_;
Stage stage_;
Camera camera_;
Player player_;
bool debugVisible_ = false;
};
}Source/Platformer/Player.cpp
Source/Platformer/Player.cppcpp
#include "Platformer/Camera.h"
#include "Platformer/ImageManager.h"
#include "Platformer/Input.h"
#include "Platformer/Player.h"
#include "Platformer/Tuning/GameTuning.h"
namespace dxgame::platformer
{
void Player::Reset(const dxgame::Vector2& position)
{
position_ = position;
}
void Player::Update(const Input& input, float deltaTime)
{
float direction = 0.0f;
if (input.IsHeld(Key::Left) && !input.IsHeld(Key::Right))
direction = -1.0f;
else if (input.IsHeld(Key::Right) && !input.IsHeld(Key::Left))
direction = 1.0f;
position_.x += direction *
tuning::player::MoveSpeed * deltaTime;
}
void Player::Draw(const Camera& camera, const ImageManager& images) const
{
images.Draw(ImageId::Player,
camera.WorldToScreen(GetDrawRect()));
}
dxgame::Rect Player::GetHitbox() const
{
return tuning::player::Hitbox.MovedBy(position_);
}
dxgame::Rect Player::GetDrawRect() const
{
return tuning::player::DrawRect.MovedBy(position_);
}
const dxgame::Vector2& Player::GetPosition() const
{
return position_;
}
}Source/Platformer/Player.h
Source/Platformer/Player.hcpp
#pragma once
#include "DxGame/Rect.h"
#include "DxGame/Vector2.h"
namespace dxgame::platformer
{
class Camera;
class ImageManager;
class Input;
// プレイヤーのワールド位置を管理する
class Player
{
public:
void Reset(const dxgame::Vector2& position);
void Update(const Input& input, float deltaTime);
void Draw(const Camera& camera, const ImageManager& images) const;
dxgame::Rect GetHitbox() const;
dxgame::Rect GetDrawRect() const;
const dxgame::Vector2& GetPosition() const;
private:
dxgame::Vector2 position_ = {};
};
}Source/Platformer/Stage.cpp
Source/Platformer/Stage.cppcpp
#include <algorithm>
#include <cmath>
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>
#include "Config/ApplicationConfig.h"
#include "Platformer/Stage.h"
namespace dxgame::platformer
{
namespace
{
struct TileDefinition
{
StageCell cell;
ImageId imageId;
bool isSolid;
};
constexpr TileDefinition tileDefinitions[] =
{
{ StageCell::Brick, ImageId::Tile, true },
{ StageCell::Moss, ImageId::TileMoss, true }
};
const TileDefinition* FindTileDefinition(StageCell cell)
{
for (const TileDefinition& definition : tileDefinitions)
{
if (definition.cell == cell)
return &definition;
}
return nullptr;
}
bool TryParseStageCell(int value, StageCell& cell)
{
cell = static_cast<StageCell>(value);
if (cell == StageCell::Empty ||
cell == StageCell::Goal ||
cell == StageCell::PlayerSpawn)
{
return true;
}
return FindTileDefinition(cell) != nullptr;
}
}
bool Stage::Initialize(const std::filesystem::path& csvPath)
{
loadedFromCsv_ = LoadFromCsv(csvPath);
return loadedFromCsv_;
}
void Stage::Draw(int cameraX, const ImageManager& images) const
{
const int firstTileX = std::max(0, cameraX / TileSize);
const int lastTileX = std::min(mapWidth_ - 1,
(cameraX + dxgame::config::ScreenWidth) / TileSize + 1);
for (int y = 0; y < mapHeight_; y++)
{
for (int x = firstTileX; x <= lastTileX; x++)
{
const StageCell cell = cells_[y][x];
if (cell == StageCell::Empty)
continue;
DrawTile(x * TileSize - cameraX, y * TileSize, cell, images);
}
}
const dxgame::Rect goalDrawRect = GetGoalDrawRect();
images.Draw(ImageId::Goal, {
goalDrawRect.x - static_cast<float>(cameraX), goalDrawRect.y,
goalDrawRect.width, goalDrawRect.height
});
}
dxgame::Rect Stage::GetGoalDrawRect() const
{
return tuning::goal::DrawRect.MovedBy(goalPosition_);
}
dxgame::Rect Stage::GetGoalTrigger() const
{
return tuning::goal::Trigger.MovedBy(goalPosition_);
}
const dxgame::Vector2& Stage::GetPlayerSpawn() const
{
return playerSpawnPosition_;
}
int Stage::GetWorldWidth() const
{
return mapWidth_ * TileSize;
}
int Stage::GetWorldHeight() const
{
return mapHeight_ * TileSize;
}
int Stage::GetMapWidth() const
{
return mapWidth_;
}
int Stage::GetMapHeight() const
{
return mapHeight_;
}
bool Stage::IsLoadedFromCsv() const
{
return loadedFromCsv_;
}
const std::wstring& Stage::GetLastLoadError() const
{
return lastLoadError_;
}
bool Stage::LoadFromCsv(const std::filesystem::path& csvPath)
{
Clear();
std::ifstream file(csvPath);
if (!file.is_open())
return FailLoad(L"ファイルを開けません。");
bool foundGoal = false;
bool foundPlayerSpawn = false;
int expectedWidth = -1;
std::string line;
int y = 0;
while (std::getline(file, line))
{
const int lineNumber = y + 1;
if (line.empty())
return FailLoad(L"空行は使用できません。", lineNumber, 1);
if (line.back() == ',')
{
const int columnNumber =
static_cast<int>(std::count(
line.begin(), line.end(), ',')) + 1;
return FailLoad(L"空のセルは使用できません。",
lineNumber, columnNumber);
}
std::stringstream lineStream(line);
std::string cell;
int x = 0;
std::vector<StageCell> row;
while (std::getline(lineStream, cell, ','))
{
const int columnNumber = x + 1;
int value = 0;
std::size_t parsedLength = 0;
try
{
value = std::stoi(cell, &parsedLength);
}
catch (const std::invalid_argument&)
{
return FailLoad(L"整数を入力してください。",
lineNumber, columnNumber);
}
catch (const std::out_of_range&)
{
return FailLoad(L"整数の範囲を超えています。",
lineNumber, columnNumber);
}
if (parsedLength != cell.size())
{
return FailLoad(L"整数以外の文字が含まれています。",
lineNumber, columnNumber);
}
StageCell csvCell = StageCell::Empty;
if (!TryParseStageCell(value, csvCell))
{
return FailLoad(
L"未定義のStageCell値です: " +
std::to_wstring(value),
lineNumber, columnNumber);
}
StageCell mapCell = StageCell::Empty;
if (csvCell == StageCell::Goal)
{
if (foundGoal)
{
return FailLoad(L"Goalは1つだけ配置してください。",
lineNumber, columnNumber);
}
goalPosition_ = {
x * static_cast<float>(TileSize) + TileSize * 0.5f,
y * static_cast<float>(TileSize) + TileSize * 0.5f
};
foundGoal = true;
}
else if (csvCell == StageCell::PlayerSpawn)
{
if (foundPlayerSpawn)
{
return FailLoad(
L"PlayerSpawnは1つだけ配置してください。",
lineNumber, columnNumber);
}
playerSpawnPosition_ = {
x * static_cast<float>(TileSize) + TileSize * 0.5f,
y * static_cast<float>(TileSize) + TileSize * 0.5f
};
foundPlayerSpawn = true;
}
else
{
mapCell = csvCell;
}
row.push_back(mapCell);
x++;
}
if (row.empty())
return FailLoad(L"セルがありません。", lineNumber, 1);
if (expectedWidth < 0)
{
expectedWidth = static_cast<int>(row.size());
}
else if (static_cast<int>(row.size()) != expectedWidth)
{
const int actualWidth = static_cast<int>(row.size());
const int errorColumn = actualWidth < expectedWidth
? actualWidth + 1
: expectedWidth + 1;
return FailLoad(
L"列数が一致しません。必要: " +
std::to_wstring(expectedWidth) + L"、実際: " +
std::to_wstring(actualWidth),
lineNumber, errorColumn);
}
cells_.push_back(std::move(row));
y++;
}
if (y == 0)
return FailLoad(L"CSVに行がありません。");
if (!foundGoal)
return FailLoad(L"Goalがありません。");
if (!foundPlayerSpawn)
return FailLoad(L"PlayerSpawnがありません。");
mapWidth_ = expectedWidth;
mapHeight_ = y;
return true;
}
bool Stage::FailLoad(const std::wstring& reason,
int lineNumber, int columnNumber)
{
std::wstring message;
if (lineNumber > 0)
{
message = L"行" + std::to_wstring(lineNumber);
if (columnNumber > 0)
message += L"、列" + std::to_wstring(columnNumber);
message += L": ";
}
message += reason;
Clear();
lastLoadError_ = std::move(message);
return false;
}
void Stage::Clear()
{
cells_.clear();
goalPosition_ = {};
playerSpawnPosition_ = {};
mapWidth_ = 0;
mapHeight_ = 0;
loadedFromCsv_ = false;
lastLoadError_.clear();
}
void Stage::DrawTile(int screenX, int screenY, StageCell cell,
const ImageManager& images) const
{
const TileDefinition* definition = FindTileDefinition(cell);
if (definition == nullptr)
return;
images.Draw(definition->imageId, {
static_cast<float>(screenX), static_cast<float>(screenY),
static_cast<float>(TileSize), static_cast<float>(TileSize)
});
}
}Source/Platformer/Stage.h
Source/Platformer/Stage.hcpp
#pragma once
#include <filesystem>
#include <string>
#include <vector>
#include "DxGame/Rect.h"
#include "DxGame/Vector2.h"
#include "Platformer/ImageManager.h"
#include "Platformer/Tuning/GameTuning.h"
namespace dxgame::platformer
{
// ステージCSVの各整数値と意味を1か所で定義する
// 数値は外部ファイル形式との約束なので、追加後も既存値を変更しない
// 外部ファイル形式の定義値のため、追加後も既存値を変更しない
enum class StageCell : int
{
Empty = 0,
// 1~9: 進行・ギミック
Goal = 1,
PlayerSpawn = 2,
// 10~19: 通常地形・障害物
Brick = 10,
Moss = 11,
// 20~29: トラップ、90~99: 敵配置のために空けておく
};
// CSVマップ、地形判定、ゴールを管理する
class Stage
{
public:
static constexpr int TileSize = tuning::stage::TileSize;
// 実行フォルダーを基準にCSVを読み込み、失敗理由を保持する
// 実行フォルダを基準にCSVを読み込み、失敗理由を保持する
bool Initialize(const std::filesystem::path& csvPath);
// カメラ内に見えるタイルとゴールを描画する
void Draw(int cameraX, const ImageManager& images) const;
dxgame::Rect GetGoalDrawRect() const;
dxgame::Rect GetGoalTrigger() const;
const dxgame::Vector2& GetPlayerSpawn() const;
int GetWorldWidth() const;
int GetWorldHeight() const;
int GetMapWidth() const;
int GetMapHeight() const;
bool IsLoadedFromCsv() const;
const std::wstring& GetLastLoadError() const;
private:
bool LoadFromCsv(const std::filesystem::path& csvPath);
bool FailLoad(const std::wstring& reason,
int lineNumber = 0, int columnNumber = 0);
void Clear();
void DrawTile(int screenX, int screenY, StageCell cell,
const ImageManager& images) const;
std::vector<std::vector<StageCell>> cells_;
dxgame::Vector2 goalPosition_ = {};
dxgame::Vector2 playerSpawnPosition_ = {};
int mapWidth_ = 0;
int mapHeight_ = 0;
bool loadedFromCsv_ = false;
std::wstring lastLoadError_;
};
}Source/Platformer/Tuning/GameTuning.h
Source/Platformer/Tuning/GameTuning.hcpp
#pragma once
#include "Config/ApplicationConfig.h"
#include "DxGame/Rect.h"
namespace dxgame::platformer::tuning
{
// 距離はピクセル、時間は秒、速度はピクセル/秒、
// 加速度と重力はピクセル/秒²を基本単位とする。
// 加速度と重力はピクセル/秒²を基本単位とする
namespace player
{
// 中心座標を基準にした表示範囲と地形判定範囲
// 画像本来の大きさとは独立して調整する
inline constexpr Rect DrawRect =
Rect::Centered(32.0f, 48.0f).MovedBy({ 0.0f, -8.0f });
inline constexpr Rect Hitbox = Rect::Centered(24.0f, 30.0f);
// 第16回までは一定速度で左右へ移動する
inline constexpr float MoveSpeed = 240.0f;
}
namespace stage
{
// タイル画像の元サイズとは独立した、ワールド上の1マスの大きさ
inline constexpr int TileSize = 32;
}
namespace camera
{
// 追従対象を画面内のどのX座標へ置くか
inline constexpr float TargetScreenX =
dxgame::config::ScreenWidth * 0.5f;
}
namespace enemy
{
// 敵の中心座標を基準にした表示範囲と命中範囲
inline constexpr Rect DrawRect = Rect::Centered(32.0f, 32.0f);
inline constexpr Rect Hitbox = Rect::Centered(28.0f, 28.0f);
}
namespace goal
{
// ゴール画像の表示範囲と、クリア成立に使う範囲
inline constexpr Rect DrawRect = Rect::Centered(32.0f, 32.0f);
inline constexpr Rect Trigger = Rect::Centered(20.0f, 28.0f);
}
}