👋

【Roblox】カメラシェイクを作ってみよう

に公開

1. はじめに

ゲームの演出には必須のカメラシェイク、Robloxでも欲しい場面はきっとあるかと思います
しかし、例によってRobloxのデフォルトのカメラ制御は融通が利きづらく、単純にカメラの座標を変更する、というような方法では作ることができません。
というわけで今回は、デフォルトのカメラ制御を使いつつカメラシェイクを行うモジュールを作成してみたいと思います!

バージョン:0.671.0.6710819

2. カメラの注視点を動かす方法

まず、カメラの注視対象はCamera.CameraSubjectというプロパティで設定することができます。
これは様々なInstanceを受け入れるプロパティで、対象の型によってカメラの動作が異なったりします。
デフォルトではPlayerCharacterのHumanoidに設定されており、そのPlayerCharacterのおおよそHeadの位置が注視点になるようになっています。
さて、先述の通りカメラの座標を直接ズラすことはできないわけですが、このカメラの注視点の方を動かす方法があります。

Humanoid.CameraOffset

それが、Humanoid.CameraOffsetです。
以前、こちらの記事でも少しご紹介したのですが、CameraSubjectにHumanoidを設定している時に使用されるプロパティで、CameraOffsetに設定したVector3の値の分カメラの注視点をズラしてくれるものです。
この値をうまく変動させればカメラシェイクができそうです。

3. Moduleを作る

それではさっそく作っていきましょう。
まずは、ReplicatedStorageにModuleScriptを追加してください。
名前は「CameraShakeModule」としておきます。

次に、以下のコードを追加してください。

CameraShakeModule
--!strict

-- Service
local RunService = game:GetService("RunService")

-- クライアント専用モジュール.
if not RunService:IsClient() then
	error(script.Name .. " is for clients only.")
end

-- Variable
local player_ = game:GetService("Players").LocalPlayer

local timer_ = 0
local offset_ = Vector3.new()	-- 現在このモジュールによって発生しているカメラのズレ.
local connection_: RBXScriptConnection?

-- CharacterとHumanoidを取得.
local character_ = player_.Character or player_.CharacterAdded:Wait()
local humanoid_ = character_:WaitForChild("Humanoid")::Humanoid

-- Characterが変わったらHumanoidを取得しなおす.
player_.CharacterAdded:Connect(function(character: Model)
	character_ = character
	humanoid_ = character:WaitForChild("Humanoid")::Humanoid
end)

local cameraShakeModule = {}

-- カメラシェイクを停止します.
local function stop()
	if not connection_ then
		return
	end

	-- イベント接続を破棄.
	connection_:Disconnect()
	connection_ = nil

	-- CameraOffsetへの影響を除去.
	humanoid_.CameraOffset -= offset_
	offset_ = Vector3.new()
end

-- カメラシェイク開始前の初期化、設定を行います.
local function setup(easingPower: number?)
	-- カメラシェイクを停止.
	stop()

	-- タイマーを初期化.
	timer_ = 0

	-- 指数のnil、正負チェック.
	if not easingPower or easingPower < 0 then
		-- 適切な値で無い場合1(線形補間)に設定.
		easingPower = 1
	else
		-- 指数を整数に.
		easingPower = math.floor(easingPower::number)
	end

	-- 指数を返す.
	return easingPower::number
end

-- カメラシェイクを更新します.
local function update(deltaTime: number, vec: Vector3, lifeTime: number, rate: number, easingPower: number, isEasingIn: boolean?)
	-- TODO
end

--[[
	指定したベクトル方向にカメラシェイクを行います.
	ベクトルの方向と大きさにカメラが動いて戻り、vectorと逆の方向と大きさにカメラが動いて戻る、までを1回の振動としています.
	
	vec: Vector3			カメラシェイクの方向と強さ.
	lifeTime: number		カメラシェイクが行われる時間.
	rate: number			一秒あたりの振動回数.
	easingPower: number?	イージングに用いる指数 0を指定すると減衰しなくなります(default: 1)
	isEasingIn: boolean?	In方向にイージングするか否か falseだと徐々に振動が小さく、trueだと徐々に振動が大きくなるようになります(default: false)
]]
function cameraShakeModule:Shake(vec: Vector3, lifeTime: number, rate: number, easingPower: number?, isEasingIn: boolean?)
	if lifeTime <= 0 then
		return
	end

	local power = setup(easingPower)

	connection_ = RunService.RenderStepped:Connect(function(deltaTime: number)
		update(deltaTime, vec, lifeTime, rate, power, isEasingIn)
	end)
end

-- カメラシェイクを停止します.
function cameraShakeModule:Stop()
	stop()
end

-- ModuleScriptが破棄された時はカメラシェイクを終了.
script.Destroying:Connect(function()
	stop()
end)

return cameraShakeModule

ロジックの中心である毎フレームの処理以外を記述しました。
最初に、このモジュールは当然クライアント専用になるので、サーバー側でrequireされた場合はその場でエラーを返すようにしてあります。
Moduleの利用方法としては、Shakeメソッドでイベントが接続されてカメラシェイクが始まり、中断する時はStopメソッドを実行するイメージです。

setup関数では、初期化処理と、引数のeasingPowerのチェック、整数化を行っています。
easingPowerはイージングの強さ……ではなく指数(Power)のことで、イージングの補間曲線を指定するためのものです。
1なら線形補間になりますが、数値が大きくなるほど急激な曲線を描く(振動の大きさが終わり際に急に減衰する)ようになるイメージです。

今回はサンプルのためこのままですが、あまり大きな値になると意味が無くなってくるので、上限値を設定してもよいでしょう。
実際にeasingPowerを使用する場面については後ほど解説します。

さて、中身が無い状態であまり引数の解説をしても仕方ないので、このままupdateの中身を作成します。
updateメソッドの中身を以下のように追加してください。

CameraShakeModule
--[[
	カメラシェイクを更新します.
	ベクトルの方向と大きさにカメラが動いて戻り、vectorと逆の方向と大きさにカメラが動いて戻る、までを1回の振動としています.

	deltaTime: number		経過時間.
	vec: Vector3			カメラシェイクの方向と強さ.
	lifeTime: number		カメラシェイクが行われる時間.
	rate: number			一秒あたりの振動回数.
	easingPower: number	イージングに用いる指数 0を指定すると減衰しなくなります.
	isEasingIn: boolean?	In方向にイージングするか否か falseだと徐々に振動が小さく、trueだと徐々に振動が大きくなるようになります(default: false)
]]
local function update(deltaTime: number, vec: Vector3, lifeTime: number, rate: number, easingPower: number, isEasingIn: boolean?)
	-- タイマーを進める.
	timer_ += deltaTime

	-- 前回の影響を除去.
	humanoid_.CameraOffset -= offset_

	-- lifeTime以上経過したら終了.
	if timer_ >= lifeTime then
		stop()
		return
	end

	-- 現在のsin値を出す.
	-- 定数として2をかけることで、「ベクトル方向に進む→戻る→逆方向に進む→戻る」を1周期としている.
	local sin = math.sin(2 * math.pi * timer_ * rate)

	-- 現在の振動の大きさを出す.
	local magnitude = 1
	
	-- easingPowerが0だった場合は減衰無し.
	if easingPower ~= 0 then
		-- 現在の時間比率をイージング指数分べき乗する.
		local ratio = timer_ / lifeTime
		magnitude = math.pow(ratio, easingPower)

		-- イージング方向に変換.
		if not isEasingIn then
			magnitude = 1 - magnitude
		end
	end

	-- 掛け合わせて最終的なOffsetを出し、Humanoid.CameraOffsetに反映.
	offset_ = vec * magnitude * sin
	humanoid_.CameraOffset += offset_
end

各引数の基本的な意味はコメントにある通りです。
update関数の中では、offset_としてこのモジュールによって発生しているカメラのズレを保持し、毎回その分の影響を一旦除去し、改めて今現在の値を出して反映しています。
これは、このモジュールの影響による差分のみを管理する形にすることで、Humanoid.CameraOffsetが他のScriptの影響を受けていたとしても、少なくともこのModuleの分については正しく反映されるようにするためです。
こうすれば、例えばこのモジュールを複製して複数のカメラシェイクが同時に発生したとしても、それぞれ独立して動作してくれるようになります。

そして、カメラの振動にはサインカーブを用います。

コメントで「現在のsin値を出す」と書かれているところは、現在サインカーブのうちのどのあたりなのかを出す計算を行っています。
sinはで1周期なので、math.sinにそのまま2 * math.piを渡した場合はちょうど一周して0が返ってきます。
timer_は秒単位での現在の経過時間、rateは1秒あたりの振動回数を表しているので、これらを掛け合わせることで現在の1周期に対しての経過率を出すことができ、math.sinに渡せば適切な-1~1の値が得られるというわけです。

sin値を出した後、現在の振動の大きさ(magnitude)を決定します。
これは、徐々に振動が弱まったり強まったりする表現に用いる、イージングを加味したベクトルに対して掛ける0~1の値になります。
ここで先述のeasingPowerが登場します。
まずはeasingPowerが0かどうかを判定し、0なら減衰無しとしてmagunitudeは1とします。
0でない場合は、全体時間に対する現在時間の比(ratio)を出し、それをeasingPowerの分べき乗することで現在の減衰度合を出しています。
先ほど出てきたこの図ですね。

その後、イージング方向に応じて、In方向であればそのまま、Out方向であれば1から減算した値がmagunitudeになります。

最後に、それらをベクトルに掛け合わせば、現在の適切なOffsetの値が出せる!というわけです。

4. 実行してみる

さて、ModuleScriptなので、requireしないと実行できません。
というわけで、Moduleを実行するScriptも作成します。
StarterCharacterScriptsにLocalScriptを追加してください。
名前は「LandedCameraShake」としておきます。

そして、以下のコードを追加してください。

LandedCameraShake
--!strict

local player_ = game:GetService("Players").LocalPlayer
local character_ = player_.Character or player_.CharacterAdded:Wait()
local humanoid_ = character_:WaitForChild("Humanoid")::Humanoid

local CameraShakeModule = require(game:GetService("ReplicatedStorage"):WaitForChild("CameraShakeModule"))

-- 着地時にカメラシェイク.
humanoid_.StateChanged:Connect(function(old: Enum.HumanoidStateType, new: Enum.HumanoidStateType)
	if new == Enum.HumanoidStateType.Landed then
		-- ベクトルはY方向に0.5、時間は0.5秒、秒間8回の頻度、イージング指数は2.
		CameraShakeModule:Shake(Vector3.new(0, 0.5, 0), 0.5, 8, 2)
	end
end)

ジャンプして着地した際に縦のカメラシェイクが起きるようにしてみました。
それではさっそく実行して確認してみましょう。

きちんとカメラシェイクしています!

6. Offsetを変換する

しかし、これで終わりではありません。
Humanoid.CameraOffsetはワールド空間やカメラのローカル空間ではなく、PlayerCharacterのローカル空間で指定するため、PlayerCharacterの向きによってカメラのズレる方向が変わってしまいます。
例えば、Humanoid.CameraOffsetを(1, 0, 0)と設定した場合、PlayerCharacterが正面を向いている時はPlayerCharacterの右側に、手前を向いている時は左側に、右を向いている時は手前にカメラがズレることになります。
この特性は、通常のカメラの位置をPlayerCharacterから常に一定量ズラしたい時には便利ですが、カメラシェイクに利用する場合だと、振動中にキャラクターが向きを変えると振動方向も変わってしまうなど、問題点も多いです。
というわけで、ワールド空間基準で振動方向を設定できるようにしてみましょう。
CameraShakeModuleのupdate関数の最後に以下の修正を行ってください。

CameraShakeModule
local function update(deltaTime: number, vec: Vector3, lifeTime: number, rate: number, easingPower: number, isEasingIn: boolean?)

 	------- 省略 -------

+	-- 掛け合わせて最終的なOffsetを出し、PlayerCharacterのローカル空間に変換してHumanoid.CameraOffsetに反映.
+	offset_ = character_:GetPivot():VectorToObjectSpace(vec * magnitude * sin)
-	-- 掛け合わせて最終的なOffsetを出し、Humanoid.CameraOffsetに反映.
-	offset_ = vec * magnitude * sin
	humanoid_.CameraOffset += offset_
end

CFrame:VectorToObjectSpaceは、Vector3の値をベクトルとして、そのCFrameのローカル空間のベクトルに回転変換して返してくれるメソッドです。
Model:GetPivotでPlayerCharacterのCFrameを取得し、そのCFrameのローカル空間のベクトルに変換しています。

さらに、Y方向の振動だとPlayerCharacterがY軸で向きが変わっても影響が無いので、きちんとワールド空間基準になっているか確認できるよう、LandedCameraShakeのShakeメソッド呼び出しの際のベクトルを(0.5, 0, 0)にします。

LandedCameraShake
 --!strict

local player_ = game:GetService("Players").LocalPlayer
local character_ = player_.Character or player_.CharacterAdded:Wait()
local humanoid_ = character_:WaitForChild("Humanoid")::Humanoid

local CameraShakeModule = require(game:GetService("ReplicatedStorage"):WaitForChild("CameraShakeModule"))

 -- 着地時にカメラシェイク.
humanoid_.StateChanged:Connect(function(old: Enum.HumanoidStateType, new: Enum.HumanoidStateType)
	if new == Enum.HumanoidStateType.Landed then
+		-- ベクトルはX方向に0.5、時間は0.5秒、秒間8回の頻度、イージング指数は2.
+		CameraShakeModule:Shake(Vector3.new(0.5, 0, 0), 0.5, 8, 2)
-		-- ベクトルはY方向に0.5、時間は0.5秒、秒間8回の頻度、イージング指数は2.
-		CameraShakeModule:Shake(Vector3.new(0, 0.5, 0), 0.5, 8, 2)
	end
end)

それではさっそく実行して確認してみましょう。

PlayerCharacterの向きが変わっても関係なく、ワールド空間基準の方向に振動しています!
これでカメラシェイクのモジュールができました!

ちなみに、「カメラから見て上下」「カメラから見て左右」といった形で、カメラのローカル空間基準で指定したい場合は、今度はローカル空間ベクトルをワールド空間ベクトルに変換するCFrame:VectorToWorldSpaceをカメラのCFrameで使用して、一旦ワールド空間ベクトルに変換してから、先ほど同様PlayerCharacterのローカル空間ベクトルに変換すればOKです。
CFrameには他にもワールド空間とローカル空間を相互に変換するためのメソッドがあり、CFrameやVector3の座標も変換できますので、ぜひ活用してみてください。

また、サンプルではわかりやすいように大きめ、遅めの振動にしていますが、実際に使用する際はやりすぎると視認性が悪くなったり、3D酔いしやすくなったりしてしまうので、気を付けて調整を行ってください。

7. サンプルコード

今回作成した最終的なサンプルコードを掲載しておきます。

CameraShakeModule
CameraShakeModule
--!strict

-- Service
local RunService = game:GetService("RunService")

-- クライアント専用モジュール.
if not RunService:IsClient() then
	error(script.Name .. " is for clients only.")
end

-- Variable
local player_ = game:GetService("Players").LocalPlayer

local timer_ = 0
local offset_ = Vector3.new()	-- 現在このモジュールによって発生しているカメラのズレ.
local connection_: RBXScriptConnection?

-- CharacterとHumanoidを取得.
local character_ = player_.Character or player_.CharacterAdded:Wait()
local humanoid_ = character_:WaitForChild("Humanoid")::Humanoid

-- Characterが変わったらHumanoidを取得しなおす.
player_.CharacterAdded:Connect(function(character: Model)
	character_ = character
	humanoid_ = character:WaitForChild("Humanoid")::Humanoid
end)

local cameraShakeModule = {}

-- カメラシェイクを停止します.
local function stop()
	if not connection_ then
		return
	end

	-- イベント接続を破棄.
	connection_:Disconnect()
	connection_ = nil

	-- CameraOffsetへの影響を除去.
	humanoid_.CameraOffset -= offset_
	offset_ = Vector3.new()
end

-- カメラシェイク開始前の初期化、設定を行います.
local function setup(easingPower: number?)
	-- カメラシェイクを停止.
	stop()

	-- タイマーを初期化.
	timer_ = 0

	-- 指数のnil、正負チェック.
	if not easingPower or easingPower < 0 then
		-- 適切な値で無い場合1(線形補間)に設定.
		easingPower = 1
	else
		-- 指数を整数に.
		easingPower = math.floor(easingPower::number)
	end

	-- 指数を返す.
	return easingPower::number
end

--[[
	カメラシェイクを更新します.
	ベクトルの方向と大きさにカメラが動いて戻り、vectorと逆の方向と大きさにカメラが動いて戻る、までを1回の振動としています.

	deltaTime: number		経過時間.
	vec: Vector3			カメラシェイクの方向と強さ.
	lifeTime: number		カメラシェイクが行われる時間.
	rate: number			一秒あたりの振動回数.
	easingPower: number		イージングに用いる指数 0を指定すると減衰しなくなります.
	isEasingIn: boolean?	In方向にイージングするか否か falseだと徐々に振動が小さく、trueだと徐々に振動が大きくなるようになります(default: false)
]]
local function update(deltaTime: number, vec: Vector3, lifeTime: number, rate: number, easingPower: number, isEasingIn: boolean?)
	-- タイマーを進める.
	timer_ += deltaTime

	-- 前回の影響を除去.
	humanoid_.CameraOffset -= offset_

	-- lifeTime以上経過したら終了.
	if timer_ >= lifeTime then
		stop()
		return
	end

	-- 現在のsin値を出す.
	-- 定数として2をかけることで、「ベクトル方向に進む→戻る→逆方向に進む→戻る」を1周期としている.
	local sin = math.sin(timer_ * 2 * rate * math.pi)

	-- 現在の振動の大きさを出す.
	local magnitude = 1

	-- easingPowerが0だった場合は減衰無し.
	if easingPower ~= 0 then
		-- 現在の時間比率をイージング指数分べき乗する.
		local ratio = timer_ / lifeTime
		magnitude = math.pow(ratio, easingPower)

		-- イージング方向に変換.
		if not isEasingIn then
			magnitude = 1 - magnitude
		end
	end

	-- 掛け合わせて最終的なOffsetを出し、PlayerCharacterのローカル空間に変換してHumanoid.CameraOffsetに反映.
	--offset_ = r.CFrame:VectorToObjectSpace(workspace.CurrentCamera.CFrame:VectorToWorldSpace(vec * magnitude * sin))
	offset_ = character_:GetPivot():VectorToObjectSpace(vec * magnitude * sin)
	humanoid_.CameraOffset += offset_
end

--[[
	指定したベクトル方向にカメラシェイクを行います.
	ベクトルの方向と大きさにカメラが動いて戻り、vectorと逆の方向と大きさにカメラが動いて戻る、までを1回の振動としています.

	vec: Vector3			カメラシェイクの方向と強さ.
	lifeTime: number		カメラシェイクが行われる時間.
	rate: number			一秒あたりの振動回数.
	easingPower: number?	イージングに用いる指数 0を指定すると減衰しなくなります(default: 1)
	isEasingIn: boolean?	In方向にイージングするか否か falseだと徐々に振動が小さく、trueだと徐々に振動が大きくなるようになります(default: false)
]]
function cameraShakeModule:Shake(vec: Vector3, lifeTime: number, rate: number, easingPower: number?, isEasingIn: boolean?)
	if lifeTime <= 0 then
		return
	end

	local power = setup(easingPower)

	connection_ = RunService.RenderStepped:Connect(function(deltaTime: number)
		update(deltaTime, vec, lifeTime, rate, power, isEasingIn)
	end)
end

-- カメラシェイクを停止します.
function cameraShakeModule:Stop()
	stop()
end

-- ModuleScriptが破棄された時はカメラシェイクを終了.
script.Destroying:Connect(function()
	stop()
end)

return cameraShakeModule
LandedCameraShake
LandedCameraShake
--!strict

local player_ = game:GetService("Players").LocalPlayer
local character_ = player_.Character or player_.CharacterAdded:Wait()
local humanoid_ = character_:WaitForChild("Humanoid")::Humanoid

local CameraShakeModule = require(game:GetService("ReplicatedStorage"):WaitForChild("CameraShakeModule"))

-- 着地時に縦にカメラシェイク.
humanoid_.StateChanged:Connect(function(old: Enum.HumanoidStateType, new: Enum.HumanoidStateType)
	if new == Enum.HumanoidStateType.Landed then
		-- ベクトルは縦に0.5、時間は0.5秒、秒間8回、イージング指数は2.
		CameraShakeModule:Shake(Vector3.new(0.5, 0, 0), 0.5, 8, 2)
	end
end)

8. まとめ

  • Humanoid.CameraOffsetは、CameraSubjectにHumanoidを設定している時に使用され、Vector3の値の分カメラの注視点をズラすプロパティ
  • 振動の表現にはサインカーブを用いる
  • 秒単位の現在時間と振動回数を掛け合わせることで現在の経過率を出すことができ、それをにかけてmath.sinに渡せば適切な-1~1の値が得られる
  • 全体時間と現在時間の比をべき乗することで、減衰を表現する
  • べき乗の指数が大きいほど急激な補間曲線を描く
  • それらとベクトルを掛け合わせ、現在のカメラのOffsetを出す
  • Humanoid.CameraOffsetはPlayerCharacterのローカル空間ベクトルで設定するため、ワールド空間ベクトルから設定するにはCFrame:VectorToObjectSpaceを使って変換する

今回は一つのベクトルを元に往復するようなカメラシェイクでしたが、これを元に、一定のベクトルではなくランダムな方向にカメラシェイクを行うような処理なども作る事ができるかと思いますので、やりたい表現に合わせて改良してみてください!
最後までお読みいただき、ありがとうございました!

9. 参考

https://create.roblox.com/docs/reference/engine/classes/Camera#CameraSubject
https://create.roblox.com/docs/reference/engine/classes/Humanoid#CameraOffset
https://create.roblox.com/docs/reference/engine/datatypes/CFrame

ランド・ホー Roblox開発チーム

Discussion