初めに
本記事はsorashidoさんに続きIwaken Labアドベントカレンダー2025の19日目の記事になります。
概要
Iwaken Lab開発合宿2025にてMiMiClipというアプリ制作に参加しました。
MiMiClipをひとことで紹介すると、VR空間内のお手本をまねて簡単なダンスを踊り、画面効果や背景のテンプレを選択するだけでYouTube Shortsのような動画が簡単に撮れ、また自分のモーションが即時Web上にアップロードされ創作などに転用できるコンテンツとなっています。
本コンテンツは合宿最終日の展示会で展示しましたが、残念ながら鏡の実装が不完全で体験の質が大きく下がってしまいました。
本コンテンツはXRKaigiにも展示するので、それまでに修正を完了させました。
本記事が他山の石となれば幸いです。
開発環境
- Unity 6000.2.0b9
- OpenXR+XR Interaction Toolkit
- Windows IL2CPP(Build Profile)
問題点
既存の実装では以下動画のように、鏡にチラつきが発生していました。HMDを被り実際に見てみるとかなり酔いました。
解決策
こうなっていた原因は実行順序にありました。
既存の実装ではOnBeginCameraRenderingメソッドが呼ばれるのはGame Logicの処理が終わった直後です。(下図)
しかし遅延を減らすため、HMDの位置姿勢を取得するのはレンダリングの直前です。従って、既存のcam.worldToCameraMatrix取得時とレンダリングの直前ではHMDのMatrix(位置姿勢)に若干の差異が出てしまいます。こちらが鏡がチラつく原因でした。GetStereoViewMatrixを用いるとレンダリング直前のHMDのMatrixが取得できるのでチラつきが消えます。

この図においてOnBeginCameraRenderingが呼ばれるのはScene Renderingの前半部分になります。(この図はBRPにおける実行順序ですが、名前は異なるものの実行順序は大きくは変わらない)
この時点ではcamera.transform.positionやcamera.worldToCameraMatrixには最新のHMDの位置が反映されていません。これらの代わりにGetStereoViewMatrixを用いることで最新のHMDの位置を取得することができます。
終わりに
この解決法が分かったのは、Gemini2のおかげです。古いコードをいれて鏡のレンダリングが遅れるので解決策を教えてほしいと入力したら一発で本記事で語った解決策を提示してきました。すごいですね。
明日はsakiyamaさんの記事になります
Appendix
鏡のコードの全文になります
[ExecuteInEditMode]
public class MirrorController : MonoBehaviour
{
[Tooltip("シーンのメインカメラ。このカメラの視点を反射させます。")]
public Camera mainCamera;
[Tooltip("反射テクスチャの解像度")] public int textureSize = 1024;
[Tooltip("描画クリップ平面のオフセット")] public float clipPlaneOffset = 0.07f;
[Tooltip("反射させるオブジェクトのレイヤー")] public LayerMask reflectLayers = -1;
[Tooltip("ピクセルライトを反射描画時に無効にするか")] public bool disablePixelLights = true;
[Tooltip("鏡を有効にする最大距離(メートル)")] public float maxRenderDistance = 10f;
private Camera _reflectionCamera;
private RenderTexture _reflectionTextureLeft;
private RenderTexture _reflectionTextureRight;
private Dictionary<Camera, Camera> _reflectionCameras = new Dictionary<Camera, Camera>();
private int _oldTextureSize = 0;
private static bool _isInsideRendering = false; // 再帰防止
private Renderer _renderer;
void Awake()
{
_renderer = GetComponent<Renderer>();
if (mainCamera == null)
{
mainCamera = Camera.main;
}
}
void OnEnable()
{
// RenderPipelineManagerのイベントに登録
RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
}
void OnDisable()
{
// イベントから登録解除
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
// リソースのクリーンアップ
if (_reflectionTextureLeft) DestroyImmediate(_reflectionTextureLeft);
if (_reflectionTextureRight) DestroyImmediate(_reflectionTextureRight);
foreach (var kvp in _reflectionCameras)
{
if (kvp.Value != null) DestroyImmediate(kvp.Value.gameObject);
}
_reflectionCameras.Clear();
}
// メインカメラのレンダリング直前に鏡をレンダリング
void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
{
// メインカメラのレンダリング時のみ処理
if (camera != mainCamera || !IsRenderingRequired(camera))
{
return;
}
// 再帰的な反射を防ぐ
if (_isInsideRendering)
{
return;
}
_isInsideRendering = true;
// 左目(または非VR時)の反射をレンダリング
RenderReflectionWithContext(context, camera, _renderer, Camera.StereoscopicEye.Left,
ref _reflectionTextureLeft);
// VRモード(ステレオ)が有効なら、右目の反射もレンダリング
if (camera.stereoEnabled)
{
RenderReflectionWithContext(context, camera, _renderer, Camera.StereoscopicEye.Right,
ref _reflectionTextureRight);
}
_isInsideRendering = false;
}
// レンダリングが必要かどうかを判定
private bool IsRenderingRequired(Camera camera)
{
// 必要なコンポーネントのチェック
if (_renderer == null || _renderer.sharedMaterial == null || !_renderer.enabled)
{
return false;
}
// 距離ベースのカリング(パフォーマンス最適化)
if (maxRenderDistance > 0)
{
float distance = Vector3.Distance(camera.transform.position, transform.position);
if (distance > maxRenderDistance)
{
return false;
}
}
return true;
}
// URPのコンテキストを使用した反射レンダリング
private void RenderReflectionWithContext(ScriptableRenderContext context, Camera cam, Renderer rend,
Camera.StereoscopicEye eye, ref RenderTexture reflectionTexture)
{
var isVr = XRSRPSettings.isDeviceActive;
if (isVr)
{
VrRenderReflection(context, cam, rend, eye, ref reflectionTexture);
}
else
{
DesktopRenderReflection(context, cam, rend, eye, ref reflectionTexture);
}
}
void DesktopRenderReflection(ScriptableRenderContext context, Camera cam, Renderer rend, Camera.StereoscopicEye eye,
ref RenderTexture reflectionTexture)
{
CreateMirrorObjects(cam, eye, ref reflectionTexture, out _reflectionCamera);
if (_reflectionCamera == null) return;
CopyCameraProperties(cam, _reflectionCamera);
_reflectionCamera.cullingMask = reflectLayers.value & ~(1 << gameObject.layer);
// カメラの優先度を設定(メインカメラより前にレンダリング)
_reflectionCamera.depth = cam.depth - 1;
int oldPixelLightCount = QualitySettings.pixelLightCount;
if (disablePixelLights) QualitySettings.pixelLightCount = 0;
GL.invertCulling = true;
Vector3 pos = transform.position;
Vector3 normal = transform.up;
float d = -Vector3.Dot(normal, pos) - clipPlaneOffset;
Vector4 reflectionPlane = new Vector4(normal.x, normal.y, normal.z, d);
Matrix4x4 reflectionMat = Matrix4x4.zero;
CalculateReflectionMatrix(ref reflectionMat, reflectionPlane);
_reflectionCamera.worldToCameraMatrix = cam.worldToCameraMatrix * reflectionMat;
Vector4 clipPlane = CameraSpacePlane(_reflectionCamera.worldToCameraMatrix, pos, normal, 1.0f);
Matrix4x4 projectionMat = cam.stereoEnabled ? cam.GetStereoProjectionMatrix(eye) : cam.projectionMatrix;
MakeProjectionMatrixOblique(ref projectionMat, clipPlane);
_reflectionCamera.projectionMatrix = projectionMat;
_reflectionCamera.targetTexture = reflectionTexture;
_reflectionCamera.transform.position = reflectionMat.MultiplyPoint(cam.transform.position);
_reflectionCamera.transform.rotation = cam.transform.rotation;
// URPの正しいレンダリング方法を使用
UniversalRenderPipeline.RenderSingleCamera(context, _reflectionCamera);
string propertyName = "_ReflectionTex" + eye.ToString();
rend.sharedMaterial.SetTexture(propertyName, reflectionTexture);
GL.invertCulling = false;
if (disablePixelLights) QualitySettings.pixelLightCount = oldPixelLightCount;
}
void VrRenderReflection(ScriptableRenderContext context, Camera cam, Renderer rend, Camera.StereoscopicEye eye,
ref RenderTexture reflectionTexture)
{
CreateMirrorObjects(cam, eye, ref reflectionTexture, out _reflectionCamera);
if (_reflectionCamera == null) return;
CopyCameraProperties(cam, _reflectionCamera);
_reflectionCamera.cullingMask = reflectLayers.value & ~(1 << gameObject.layer);
_reflectionCamera.depth = cam.depth - 1;
int oldPixelLightCount = QualitySettings.pixelLightCount;
if (disablePixelLights) QualitySettings.pixelLightCount = 0;
GL.invertCulling = true;
Vector3 pos = transform.position;
Vector3 normal = transform.up;
Matrix4x4 stereoViewMatrix = cam.GetStereoViewMatrix(eye);
Matrix4x4 stereoCameraToWorldMatrix = stereoViewMatrix.inverse;
Vector3 cameraWorldPosition = stereoCameraToWorldMatrix.GetColumn(3);
float d = -Vector3.Dot(normal, pos) - clipPlaneOffset;
Vector4 reflectionPlane = new Vector4(normal.x, normal.y, normal.z, d);
Matrix4x4 reflectionMat = Matrix4x4.zero;
CalculateReflectionMatrix(ref reflectionMat, reflectionPlane);
_reflectionCamera.worldToCameraMatrix = stereoViewMatrix * reflectionMat;
Vector4 clipPlane = CameraSpacePlane(_reflectionCamera.worldToCameraMatrix, pos, normal, 1.0f);
Matrix4x4 projectionMat = cam.stereoEnabled ? cam.GetStereoProjectionMatrix(eye) : cam.projectionMatrix;
MakeProjectionMatrixOblique(ref projectionMat, clipPlane);
_reflectionCamera.projectionMatrix = projectionMat;
_reflectionCamera.targetTexture = reflectionTexture;
_reflectionCamera.transform.position = reflectionMat.MultiplyPoint(cameraWorldPosition);
_reflectionCamera.transform.rotation = stereoCameraToWorldMatrix.rotation;
// URPの正しいレンダリング方法を使用
UniversalRenderPipeline.RenderSingleCamera(context, _reflectionCamera);
string propertyName = "_ReflectionTex" + eye.ToString();
rend.sharedMaterial.SetTexture(propertyName, reflectionTexture);
GL.invertCulling = false;
if (disablePixelLights) QualitySettings.pixelLightCount = oldPixelLightCount;
}
// 従来のRenderReflectionメソッド(エディタモード用に残す)
private void RenderReflection(Camera cam, Renderer rend, Camera.StereoscopicEye eye,
ref RenderTexture reflectionTexture)
{
CreateMirrorObjects(cam, eye, ref reflectionTexture, out _reflectionCamera);
if (_reflectionCamera == null) return;
CopyCameraProperties(cam, _reflectionCamera);
_reflectionCamera.cullingMask = reflectLayers.value & ~(1 << gameObject.layer);
int oldPixelLightCount = QualitySettings.pixelLightCount;
if (disablePixelLights) QualitySettings.pixelLightCount = 0;
GL.invertCulling = true;
Vector3 pos = transform.position;
Vector3 normal = transform.up;
float d = -Vector3.Dot(normal, pos) - clipPlaneOffset;
Vector4 reflectionPlane = new Vector4(normal.x, normal.y, normal.z, d);
Matrix4x4 reflectionMat = Matrix4x4.zero;
CalculateReflectionMatrix(ref reflectionMat, reflectionPlane);
_reflectionCamera.worldToCameraMatrix = cam.worldToCameraMatrix * reflectionMat;
Vector4 clipPlane = CameraSpacePlane(_reflectionCamera.worldToCameraMatrix, pos, normal, 1.0f);
Matrix4x4 projectionMat = cam.stereoEnabled ? cam.GetStereoProjectionMatrix(eye) : cam.projectionMatrix;
MakeProjectionMatrixOblique(ref projectionMat, clipPlane);
_reflectionCamera.projectionMatrix = projectionMat;
_reflectionCamera.targetTexture = reflectionTexture;
_reflectionCamera.transform.position = reflectionMat.MultiplyPoint(cam.transform.position);
_reflectionCamera.transform.rotation = cam.transform.rotation;
_reflectionCamera.Render();
string propertyName = "_ReflectionTex" + eye.ToString();
rend.sharedMaterial.SetTexture(propertyName, reflectionTexture);
GL.invertCulling = false;
if (disablePixelLights) QualitySettings.pixelLightCount = oldPixelLightCount;
}
private void CreateMirrorObjects(Camera currentCamera, Camera.StereoscopicEye eye,
ref RenderTexture reflectionTexture, out Camera reflectionCamera)
{
// 辞書のキーとしてメインカメラを使うように変更
if (!_reflectionCameras.TryGetValue(mainCamera, out reflectionCamera))
{
GameObject go = new GameObject("Mirror Reflection Camera for " + currentCamera.name, typeof(Camera),
typeof(Skybox));
reflectionCamera = go.GetComponent<Camera>();
reflectionCamera.enabled = false;
go.hideFlags = HideFlags.HideAndDontSave;
_reflectionCameras.Add(mainCamera, reflectionCamera);
}
if (!reflectionTexture || _oldTextureSize != textureSize)
{
if (reflectionTexture) DestroyImmediate(reflectionTexture);
reflectionTexture = new RenderTexture(textureSize, textureSize, 16)
{
name = "__MirrorReflectionTex" + eye.ToString() + GetInstanceID(),
isPowerOfTwo = true,
hideFlags = HideFlags.DontSave
};
_oldTextureSize = textureSize;
}
}
private void CopyCameraProperties(Camera src, Camera dest)
{
if (dest == null) return;
dest.clearFlags = src.clearFlags;
dest.backgroundColor = src.backgroundColor;
if (src.clearFlags == CameraClearFlags.Skybox)
{
Skybox sky = src.GetComponent<Skybox>();
Skybox mysky = dest.GetComponent<Skybox>();
if (!sky || !sky.material)
{
mysky.enabled = false;
}
else
{
mysky.enabled = true;
mysky.material = sky.material;
}
}
dest.farClipPlane = src.farClipPlane;
dest.nearClipPlane = src.nearClipPlane;
dest.orthographic = src.orthographic;
dest.fieldOfView = src.fieldOfView;
dest.aspect = src.aspect;
dest.orthographicSize = src.orthographicSize;
}
private Vector4 CameraSpacePlane(Matrix4x4 worldToCameraMatrix, Vector3 pos, Vector3 normal, float sideSign)
{
Vector3 offsetPos = pos + normal * clipPlaneOffset;
Matrix4x4 m = worldToCameraMatrix;
Vector3 cpos = m.MultiplyPoint(offsetPos);
Vector3 cnormal = m.MultiplyVector(normal).normalized * sideSign;
return new Vector4(cnormal.x, cnormal.y, cnormal.z, -Vector3.Dot(cpos, cnormal));
}
private static void CalculateReflectionMatrix(ref Matrix4x4 reflectionMat, Vector4 plane)
{
reflectionMat.m00 = (1F - 2F * plane[0] * plane[0]);
reflectionMat.m01 = (-2F * plane[0] * plane[1]);
reflectionMat.m02 = (-2F * plane[0] * plane[2]);
reflectionMat.m03 = (-2F * plane[3] * plane[0]);
reflectionMat.m10 = (-2F * plane[1] * plane[0]);
reflectionMat.m11 = (1F - 2F * plane[1] * plane[1]);
reflectionMat.m12 = (-2F * plane[1] * plane[2]);
reflectionMat.m13 = (-2F * plane[3] * plane[1]);
reflectionMat.m20 = (-2F * plane[2] * plane[0]);
reflectionMat.m21 = (-2F * plane[2] * plane[1]);
reflectionMat.m22 = (1F - 2F * plane[2] * plane[2]);
reflectionMat.m23 = (-2F * plane[3] * plane[2]);
reflectionMat.m30 = 0F;
reflectionMat.m31 = 0F;
reflectionMat.m32 = 0F;
reflectionMat.m33 = 1F;
}
private static float sgn(float a)
{
if (a > 0.0f) return 1.0f;
if (a < 0.0f) return -1.0f;
return 0.0f;
}
private static void MakeProjectionMatrixOblique(ref Matrix4x4 matrix, Vector4 clipPlane)
{
Vector4 q;
q.x = (sgn(clipPlane.x) + matrix[8]) / matrix[0];
q.y = (sgn(clipPlane.y) + matrix[9]) / matrix[5];
q.z = -1.0F;
q.w = (1.0F + matrix[10]) / matrix[14];
Vector4 c = clipPlane * (2.0F / Vector4.Dot(clipPlane, q));
matrix[2] = c.x;
matrix[6] = c.y;
matrix[10] = c.z + 1.0F;
matrix[14] = c.w;
}
// エディタモードでのプレビュー用
#if UNITY_EDITOR
void Update()
{
if (!Application.isPlaying && mainCamera != null)
{
// エディタモードではLateUpdateのように動作
if (_renderer != null && _renderer.sharedMaterial != null && _renderer.enabled)
{
if (!_isInsideRendering)
{
_isInsideRendering = true;
RenderReflection(mainCamera, _renderer, Camera.StereoscopicEye.Left, ref _reflectionTextureLeft);
if (mainCamera.stereoEnabled)
{
RenderReflection(mainCamera, _renderer, Camera.StereoscopicEye.Right,
ref _reflectionTextureRight);
}
_isInsideRendering = false;
}
}
}
}
#endif
}
参考