はじめに
Photon Fusionアンバサダーのニム式です。
以前の記事では公式のProjectilesサンプルを元に、色々な弾の同期方法の解説と、弾の種類を増やす改造の基本と応用例を紹介しました。
改造例として取り上げたのは弾の一つ一つがゲームオブジェクトとなっている実弾系についてでした。
今回は見た目や判定に飛翔体ゲームオブジェクトを用いず、Raycastを基準に処理を行うレーザーについて紹介します。サンプルでは1本だけ照射するシンプルなものでしたが、これに「壁に反射する機能」と「複数発射する機能」を追加します。
前提記事
Photon Fusionの基本的な解説は以下の記事で行っていますので、そちらを参照下さい。
動作確認環境
Windows 11 Home 22H2
Unity 2022.3.2f1
Fusion SDK 1.1.8 F Build 725
オリジナルのレーザー
まずはベースとなるサンプルの紹介です。
Projectilesサンプルにはレーザー系武器の実装例としてLasergunプレハブがあります。
レーザーの当たり判定の管理はWeaponBeamコンポーネントが行っており、BeamPink・BeamPinkEnd・BeamPinkStartが見た目に使われます。
発射レートや弾のリソースについては、実弾系と同じくWeaponTriggerやWeaponMagazineコンポーネントが管理します。
当たり判定の処理
当たり判定はWeaponBeamコンポーネントのOnFixedUpdate
で処理されます。
ProjectileUtility.CircleCast
でレイキャストを飛ばして当たったかどうかの判定を行います。この内部ではUnity標準のレイキャストではなく、Photon Fusionのラグ補償機能が付いたLagCompensation.Raycast
を使っています。
public override void OnFixedUpdate(WeaponContext context, WeaponDesires desires)
{
bool beamActive = desires.AmmoAvailable == true && context.Input.IsSet(EInputButtons.Fire);
if (beamActive == false)
{
_beamDistance = 0f;
return;
}
//当たり判定を処理
if (ProjectileUtility.CircleCast(Runner, Object.InputAuthority, context.FirePosition, context.FireDirection, _maxDistance, _beamRadius, _raycastAmount, _hitMask, out LagCompensatedHit hit) == true)
{
_beamDistance = hit.Distance;
if (desires.HasFired == true)
{
HitUtility.ProcessHit(Object.InputAuthority, context.FireDirection, hit, _damage, _hitType);
}
}
else
{
_beamDistance = _maxDistance;
}
}
太さの処理
レイキャストは太さを持たないため、ProjectileUtility.CircleCast
では複数のレイキャストを使い太いレーザーを実現しています。_beamRadius
でビームの太さを指定し、その外周を等間隔に配置する数を_raycastAmount
で指定しています。そのため_raycastAmount
は1か5以上にするのがよいでしょう。
public static bool CircleCast(NetworkRunner runner, PlayerRef owner, Vector3 firePosition, Vector3 direction, float distance, float radius, int numberOfRays, LayerMask hitMask, out LagCompensatedHit hit)
{
hit = default;
//配置する間隔を計算
float angleStep = numberOfRays > 2 ? (2f * Mathf.PI) / (numberOfRays - 1) : 0f;
Matrix4x4 rotationMatrix = Matrix4x4.Rotate(Quaternion.LookRotation(direction));
for (int i = 0; i < numberOfRays; i++)
{
Vector3 position = firePosition;
// First ray is always directly in the center
if (i > 0)
{
float angle = angleStep * (i - 1);
var offset = new Vector3(radius * Mathf.Cos(angle), radius * Mathf.Sin(angle), 0f);
position += rotationMatrix.MultiplyPoint3x4(offset);
}
if (ProjectileCast(runner, owner, position, direction, distance, hitMask, out LagCompensatedHit currentHit) == true)
{
if (hit.Type == HitType.None || currentHit.Distance < hit.Distance)
{
hit = currentHit;
}
}
}
return hit.Type != HitType.None;
}
見た目の処理
レーザーの見た目はWeaponBeamコンポーネントのUpdateBeam
で処理されます。
当たり判定の計算結果を元に、Line Rendererコンポーネントを調整することで実現しています。
Line RendererコンポーネントにはSetPosition
メソッドがあります。これでどこからどこへラインを描画するかを設定でき、第一引数は0と1でそれぞれ始点と終点を指定します。
private void UpdateBeam(WeaponContext context, float distance)
{
bool beamActive = distance > 0f;
_beamStart.SetActiveSafe(beamActive);
_beamEnd.SetActiveSafe(beamActive);
_beam.gameObject.SetActiveSafe(beamActive);
if (beamActive == false)
return;
var startPosition = _beamStart.transform.position;
var targetPosition = context.FirePosition + context.FireDirection * distance;
var visualDirection = targetPosition - startPosition;
float visualDistance = visualDirection.magnitude;
visualDirection /= visualDistance; // Normalize
if (_beamEndOffset > 0f)
{
// Adjust target position
visualDistance = visualDistance > _beamEndOffset ? visualDistance - _beamEndOffset : 0f;
targetPosition = startPosition + visualDirection * visualDistance;
}
_beamEnd.transform.SetPositionAndRotation(targetPosition, Quaternion.LookRotation(-visualDirection));
//Line Rendererコンポーネントの描画範囲(始点、終点)を設定する
_beam.SetPosition(0, startPosition);
_beam.SetPosition(1, targetPosition);
if (_updateBeamMaterial == true)
{
var beamMaterial = _beam.material;
beamMaterial.mainTextureScale = new Vector2(visualDistance / _textureScale, 1f);
beamMaterial.mainTextureOffset += new Vector2(Time.deltaTime * _textureScrollSpeed, 0f);
}
}
軌道の補間について
一人称や三人称視点のシューター系ゲームでは、弾がどこからどこに向けて飛ぶのか、という事について色々な考え方があります。
例えばProjectilesサンプルに実装されている武器は一人称視点で使うことが想定されています。
一人称視点のため、カメラ(プレイヤー)とキャラクターの目線は一致しています。つまりカメラの中央から画面中央のレティクルに真っ直ぐ向かう直線上を狙っている場所とするのが、プレイヤーにとって分かりやすいと思います。
ところが武器は手に持つためレーザーの始点は視界の右下に位置しており、視点とはズレがあります。
サンプルにはこのズレを補間する仕組みがあります。
銃口からレティクルを狙うには奥行き情報が足りないため、まずは目線基準で当たり判定を取ります。目線、つまりカメラ中央からまっすぐ正面(レティクル方向)にレイキャストを飛ばし、射程内でオブジェクトに当たるかどうかの判定を行います。
そしてその当たり判定の情報に含まれる着弾点までの距離を使い、銃口からLine Rendererを伸ばすことによってレーザーを表現しています。
public override void OnFixedUpdate(WeaponContext context, WeaponDesires desires)
{
//略
//カメラ位置context.FirePositionと目線context.FireDirectionで当たり判定を処理
if (ProjectileUtility.CircleCast(Runner, Object.InputAuthority, context.FirePosition, context.FireDirection, _maxDistance, _beamRadius, _raycastAmount, _hitMask, out LagCompensatedHit hit) == true)
{
//着弾点までの距離を保存
_beamDistance = hit.Distance;
if (desires.HasFired == true)
{
HitUtility.ProcessHit(Object.InputAuthority, context.FireDirection, hit, _damage, _hitType);
}
}
else
{
_beamDistance = _maxDistance;
}
}
private void UpdateBeam(WeaponContext context, float distance)
{
//略
//※distanceは_beamDistance
var startPosition = _beamStart.transform.position;
var targetPosition = context.FirePosition + context.FireDirection * distance;
//略
//Line Rendererコンポーネントの描画範囲(始点、終点)を設定する
_beam.SetPosition(0, startPosition);
_beam.SetPosition(1, targetPosition);
}
注意点
他の武器ではWeaponBarrelコンポーネントで攻撃ボタンを設定しますが、レーザーでは効かない問題があります。これはWeaponBeamコンポーネントのOnFixedUpdate
内でボタンの種類を決め打ちで処理してるのが原因です。
本筋ではないため本記事ではそのまま実装しています。
レーザーガンカスタム
Lasergunプレハブは他の武器とは違い1つのアクションしか割り当てない構成になっているため、今後の拡張も考え複数アクションを追加しやすいよう改良をしておきます。
武器性能はWeaponComponent
を継承したコンポーネント群が決めていますが、サンプルではプレハブのルートに全てついています。そのため、他の武器と同じく子オブジェクトのPrimaryActionに移動させます。
反射レーザー
初期状態でのレーザーは一直線に進むだけの仕様なので、壁に反射するようにWeaponBeamコンポーネントを改造します。
考え方としてはレイキャストを飛ばし、当たった座標から次のレイキャストを計算、当たるか射程が尽きるまでそれを繰り返す、という手順になります。
なおサンプルコードは複数レーザー向け改造も含んだ状態になることをご了承ください。
当たり判定の計算
当たり判定はOnFixedUpdate
で処理されます。
ProjectileUtility.CircleCast
の結果にはレイキャストが当たった場所の座標とその面の法線方向ベクトルが含まれるため、それとUnityのVector3.Reflect
を用いて次のレイキャストを飛ばす方向を算出しています。
レイキャストが当たった座標は見た目の更新に使うため、反射座標配列としてVector3の配列に保存しておきます。
なお、プレイヤーへのダメージなどレーザー自身以外へのリアクションはHitUtility.ProcessHit
で処理されます。
- 初期化
- 反射座標配列を0埋めする
-
_remainedDistance
をレーザーの全長_maxDistance
で更新する
- 反射の計算
-
ProjectileUtility.CircleCast
で当たり判定を取る - 当たった座標を反射座標配列に保存
-
public override void OnFixedUpdate(WeaponContext context, WeaponDesires desires)
{
for (int i = 0; i < _projectilesPerShot; i++)
{
//値型のためコピーして最後に上書きする
ReflectPositions tmpArray = _reflectPositionsArray.Get(i);
//初期化
_remainedDistance = _maxDistance;
for (int j = 0; j < tmpArray._reflectPositions.Length; j++)
{
tmpArray._reflectPositions.Set(j, Vector3.zero);
}
//略
for (int k = 0; k < _numberOfReflections + 2; k++)
{
if (ProjectileUtility.CircleCast(Runner, Object.InputAuthority, tmpArray._reflectPositions.Get(k),
_fireDirection, _remainedDistance, _beamRadius, _raycastAmount, _hitMask, out LagCompensatedHit hit))
{
//反射座標を保存
tmpArray._reflectPositions.Set(k + 1, hit.Point);
if (desires.HasFired == true)
{
HitUtility.ProcessHit(Object.InputAuthority, _fireDirection, hit, _damage, _hitType,
context.TeamIndex);
}
//反射後のベクトル
_fireDirection = Vector3.Reflect(_fireDirection, hit.Normal);
//残りの距離
_remainedDistance -= hit.Distance;
}
else
{
//配列の最後は向きを保存しておく
tmpArray._reflectPositions.Set(k + 1, _fireDirection);
_reflectPositionsArray.Set(i, tmpArray);
break;
}
}
}
}
見た目の更新
レーザーの見た目はUpdateBeam
で処理されます。
Line RendererコンポーネントのSetPosition
メソッドではなくSetPositions
を用います。
Vector3の配列である反射座標配列を渡すことで、値の位置で折れ曲がるレーザーが描画されます。
またレーザーの色を始点~終点でグラデーションにするためにレーザーの全長が必要なため、反射座標配列を使って累計を出します
private void UpdateBeam(WeaponContext context, float distance)
{
//略
for (int i = 0; i < _projectilesPerShot; i++)
{
//反射座標配列を取得
Vector3[] _reflectPositinArray = _reflectPositionsArray.Get(i)._reflectPositions.AsEnumerable().Where(x => x.magnitude != 0).ToArray();
if (_reflectPositinArray.Length <= 0) continue;
//始点と終点を計算
_reflectPositinArray[0] = _reflectPositinArray[0] - context.FirePosition + _beamStart.transform.position;
_reflectPositinArray[^1] = _reflectPositinArray[^2] + _reflectPositinArray[^1] * _remainedDistance;
//ビームの描画を設定
_beamEnd.transform.SetPositionAndRotation(_reflectPositinArray[^1], Quaternion.LookRotation(_reflectPositinArray[^2] - _reflectPositinArray[^1]));
_beamList[i].positionCount = _reflectPositinArray.Length;
_beamList[i].SetPositions(_reflectPositinArray);
if (_updateBeamMaterial == true)
{
var beamMaterial = _beamList[i].material;
//反射を考慮したレーザーの長さ累計
float totalLength = 0;
for (int j = 0; j < _reflectPositinArray.Length - 1; j++)
{
totalLength += Vector3.Distance(_reflectPositinArray[j], _reflectPositinArray[j + 1]);
}
beamMaterial.mainTextureScale = new Vector2(totalLength / _textureScale, 1f);
beamMaterial.mainTextureOffset += new Vector2(Time.deltaTime * _textureScrollSpeed, 0f);
}
}
}
複数のレーザー
初期状態でのレーザーは1本だけの仕様のため、複数本同時に発射できるようにします。レーザーの出し方は2種類紹介します。
まずは共通して必要な知識について紹介します。
配列の配列を同期する
前項の通り、反射するレーザーは反射座標を保存する必要があるため、1本のレーザーに対して1つのNetworkArrayが必要になります。
当然、複数同時にレーザーを発射するためにはその数だけ配列が必要、つまりNetworkArrayのNetworkArrayが必要になります。
Photon Fusionにおいて、同期をする必要のあるプロパティには[Networked]
アトリビュートを付ける必要があります。
しかしどんな型でもいいというわけではなく、今回使いたいNetworkArray<NetworkArray<Vector3>>
のようなsetterを持てない場合はエラーとなります。
この場合は先にNetworkArray<Vector3>
を持つINetworkStruct
を実装した構造体を作っておく必要があります。
またINetworkStruct
のプロパティを変更する場合は、一度コピーして変更してもとのプロパティに適用します。
詳しくは公式の解説ページを確認してください。
public class WeaponBeam : WeaponComponent
{
[UnitySerializeField][Networked, Capacity(5)]
private NetworkArray<ReflectPositions> _reflectPositionsArray { get; }
[UnitySerializeField][Networked, Capacity(5)]
private NetworkArray<NetworkArray<Vector3>> _reflectPositionsArray { get; } //NG例
public override void OnFixedUpdate(WeaponContext context, WeaponDesires desires)
{
//略
//一度コピーしておく
ReflectPositions tmpArray = _reflectPositionsArray.Get(i);
//略
//作業をしたら上書き
tmpArray._reflectPositions.Set(k + 1, hit.Point);
_reflectPositionsArray.Set(i, tmpArray);
//NG例
_reflectPositionsArray.Get(k)._reflectPositions.Set(k + 1, hit.Point);
}
}
[System.Serializable]
struct ReflectPositions : INetworkStruct
{
[Networked, Capacity(10)] public NetworkArray<Vector3> _reflectPositions => new ();
}
拡散レーザー
レーザーの出し方1つ目は、銃口から扇状にだす拡散です。
最大拡散角angleSpread
を拡散数_projectilesPerShot
で割ってレーザーごとのオフセット角度を計算し、その分照準方向である_fireDirection
を回転させることで扇状に拡散させることができます。
ちなみに考え方は一つ前の記事と同じになっています。
var _fireDirection = context.FireDirection;
if (_diffuseDirection == DiffuseDirection.SPREAD)
{
var unit = angleSpread / (_projectilesPerShot + 1);
_fireDirection = Quaternion.Euler(0f, (unit * (i + 1) - angleSpread / 2f), 0f) * context.FireDirection;
}
//略
if (ProjectileUtility.CircleCast(Runner, Object.InputAuthority, tmpArray._reflectPositions.Get(k),
_fireDirection, _remainedDistance, _beamRadius, _raycastAmount, _hitMask, out LagCompensatedHit hit))
並列レーザー
レーザーの出し方2つ目は、銃口から並列に出す形です。
最大幅spreadWidth
を拡散数_projectilesPerShot
で割ってレーザーごとのオフセットを計算し、発射地点をずらすことで並列レーザーを作ることができます。
var offset = Vector3.zero;
if (_diffuseDirection == DiffuseDirection.PARALLEL)
{
var unit = spreadWidth / (_projectilesPerShot + 1);
offset = new Vector3(unit * (i + 1) - spreadWidth / 2 , 0, 0);
}
tmpArray._reflectPositions.Set(0, context.FirePosition + offset);
コード全体
以上で解説は終わりです。
最後に、今回改造をしたWeaponBeamコンポーネントの全体を載せておきます。
using System;
using System.Collections.Generic;
using System.Linq;
using ExitGames.Client.Photon.StructWrapping;
using Fusion;
using UnityEngine;
namespace Projectiles
{
public class WeaponBeam : WeaponComponent
{
// PRIVATE MEMBERS
[SerializeField] private float _damage = 10f;
[SerializeField] private EHitType _hitType = EHitType.Projectile;
[SerializeField] private LayerMask _hitMask;
[SerializeField] private float _maxDistance = 50f;
[SerializeField] private float _beamRadius = 0.2f;
[SerializeField,
Tooltip(
"Number of raycast rays fired. First is always in center, other are spread around in the radius distance.")]
private int _raycastAmount = 5;
[Header("Beam Visuals")] [SerializeField]
private GameObject _beamStart;
[SerializeField] private GameObject _beamEnd;
[SerializeField] private LineRenderer _beam;
[SerializeField] private List<LineRenderer> _beamList;
[SerializeField] private float _beamEndOffset = 0.5f;
[SerializeField] private bool _updateBeamMaterial;
[SerializeField] private float _textureScale = 3f;
[SerializeField] private float _textureScrollSpeed = -8f;
[Header("Camera Effect")] [SerializeField]
private ShakeSetup _cameraShakePosition;
[SerializeField] private ShakeSetup _cameraShakeRotation;
[Networked] private float _remainedDistance { get; set; }
[UnitySerializeField][Networked, Capacity(5)]
private NetworkArray<ReflectPositions> _reflectPositionsArray { get; }
[SerializeField] [Range(0, 4)]
private int _numberOfReflections;
[SerializeField]
private DiffuseDirection _diffuseDirection = DiffuseDirection.NONE;
[SerializeField]
private float angleSpread;
[SerializeField]
private float spreadWidth;
[SerializeField]
private int _projectilesPerShot = 1;
// WeaponComponent INTERFACE
public override void ProcessInput(WeaponContext context, ref WeaponDesires desires, bool weaponBusy)
{
if (desires.Fire == true && desires.AmmoAvailable == true)
{
desires.HasFired = true;
}
}
public override void OnFixedUpdate(WeaponContext context, WeaponDesires desires)
{
Debug.DrawLine(context.FirePosition, context.FireDirection * _maxDistance, Color.red);
bool beamActive = desires.AmmoAvailable == true && context.Input.IsSet(EInputButtons.Fire);
if (beamActive == false)
{
_remainedDistance = 0f;
return;
}
for (int i = 0; i < _projectilesPerShot; i++)
{
//値型のためコピーして最後に上書きする
ReflectPositions tmpArray = _reflectPositionsArray.Get(i);
//初期化
_remainedDistance = _maxDistance;
for (int j = 0; j < tmpArray._reflectPositions.Length; j++)
{
tmpArray._reflectPositions.Set(j, Vector3.zero);
}
var offset = Vector3.zero;
if (_diffuseDirection == DiffuseDirection.PARALLEL)
{
var unit = spreadWidth / (_projectilesPerShot + 1);
offset = new Vector3(unit * (i + 1) - spreadWidth / 2 , 0, 0);
}
tmpArray._reflectPositions.Set(0, context.FirePosition + Quaternion.LookRotation(context.FireDirection) * offset);
var _fireDirection = context.FireDirection;
if (_diffuseDirection == DiffuseDirection.SPREAD)
{
var unit = angleSpread / (_projectilesPerShot + 1);
_fireDirection = Quaternion.Euler(0f, (unit * (i + 1) - angleSpread / 2f), 0f) * context.FireDirection;
}
for (int k = 0; k < _numberOfReflections + 2; k++)
{
if (ProjectileUtility.CircleCast(Runner, Object.InputAuthority, tmpArray._reflectPositions.Get(k),
_fireDirection, _remainedDistance, _beamRadius, _raycastAmount, _hitMask, out LagCompensatedHit hit))
{
//反射座標を保存
tmpArray._reflectPositions.Set(k + 1, hit.Point);
if (desires.HasFired == true)
{
HitUtility.ProcessHit(Object.InputAuthority, _fireDirection, hit, _damage, _hitType,
context.TeamIndex);
}
//反射後のベクトル
_fireDirection = Vector3.Reflect(_fireDirection, hit.Normal);
//残りの距離
_remainedDistance -= hit.Distance;
}
else
{
tmpArray._reflectPositions.Set(k + 1, _fireDirection);
_reflectPositionsArray.Set(i, tmpArray);
break;
}
}
}
}
public override void OnRender(WeaponContext context, ref WeaponDesires desires)
{
UpdateBeam(context, _remainedDistance);
if (_remainedDistance > 0f && Context.ObservedPlayerRef == Object.InputAuthority)
{
var cameraShake = Context.Camera.ShakeEffect;
cameraShake.Play(_cameraShakePosition, EShakeForce.ReplaceSame);
cameraShake.Play(_cameraShakeRotation, EShakeForce.ReplaceSame);
}
}
// PRIVATE MEMBERS
private void UpdateBeam(WeaponContext context, float distance)
{
bool beamActive = distance > 0f;
_beamStart.SetActiveSafe(beamActive);
_beamEnd.SetActiveSafe(beamActive);
_beam.gameObject.SetActiveSafe(beamActive);
_beamList.ForEach(x => x.gameObject.SetActiveSafe(beamActive));
if (beamActive == false)
return;
//ビームオブジェクトを生成
if (_beamList.Count <= _projectilesPerShot)
{
for (int i = 0; i < _projectilesPerShot - _beamList.Count; i++)
{
_beamList.Add(Instantiate(_beam));
}
}
for (int i = 0; i < _projectilesPerShot; i++)
{
Vector3[] _reflectPositinArray = _reflectPositionsArray.Get(i)._reflectPositions.AsEnumerable().Where(x => x.magnitude != 0).ToArray();
if (_reflectPositinArray.Length <= 0) continue;
_reflectPositinArray[0] = _reflectPositinArray[0] - context.FirePosition + _beamStart.transform.position;
_reflectPositinArray[^1] = _reflectPositinArray[^2] + _reflectPositinArray[^1] * _remainedDistance;
_beamEnd.transform.SetPositionAndRotation(_reflectPositinArray[^1], Quaternion.LookRotation(_reflectPositinArray[^2] - _reflectPositinArray[^1]));
_beamList[i].positionCount = _reflectPositinArray.Length;
_beamList[i].SetPositions(_reflectPositinArray);
if (_updateBeamMaterial == true)
{
var beamMaterial = _beamList[i].material;
//反射を考慮したレーザーの長さ累計
float totalLength = 0;
for (int j = 0; j < _reflectPositinArray.Length - 1; j++)
{
totalLength += Vector3.Distance(_reflectPositinArray[j], _reflectPositinArray[j + 1]);
}
beamMaterial.mainTextureScale = new Vector2(totalLength / _textureScale, 1f);
beamMaterial.mainTextureOffset += new Vector2(Time.deltaTime * _textureScrollSpeed, 0f);
}
}
}
}
[System.Serializable]
struct ReflectPositions : INetworkStruct
{
[Networked, Capacity(10)] public NetworkArray<Vector3> _reflectPositions => new ();
}
public enum DiffuseDirection
{
NONE,
SPREAD,
PARALLEL
}
}