【Unity】オブジェクトを仮想グリッドにスナップして配置する

結論

EditModeでのみ動くMonoBehaviourスクリプトを作成することで実現可能。
びっくりするくらい生産性が上がります。

動作
https://gyazo.com/5603a1f9056f7497d60af9d2d0d8598b.gif

あらまし

画像エディタでよくある便利機能、「グリッドにスナップする」。要はオブジェクトを移動させるときに移動量を一定にして綺麗に配置できるようにするという機能ですが、Unityを使っているとこれがめちゃくちゃ欲しくなることがありますよね。

例えばタイルマップじゃない2Dスプライトをシーンに配置する時、ゲーム内の仮想グリッドに沿って配置したいときとか。

Unityエディタにはビルドインでスナップ機能があるのですが、⌘を押しながらじゃないとスナップしないとか、設定するとアプリケーション全体でそのスナップ量が固定されちゃうとか、微妙な感じだったので、スクリプトを書いてみました。

使い方

https://gyazo.com/3fb95690f0b98268ac2b795e2c189301.png

まずこのスクリプトを対象のオブジェクトにアタッチします。(要Renderer)
各パラメータの概要はこんな感じ。

  • Center: 仮想グリッドの原点
  • Cell Size: 仮想グリッドのセルの大きさ (単位: 1Unity)
  • Width: このオブジェクトの仮想グリッド上での格子の横幅
  • Height: このオブジェクトの仮想グリッド上での格子の縦幅
  • Offset: 格子のオフセット
  • Is Odd: 仮想グリッドの中心がセルになるか
    • trueの場合: centerが中心セルの中心になります
    • falseの場合: 中心セルが存在しません
  • Horizontal Pivot: オブジェクトがスナップする横の基準点
  • Vertical Pivot: オブジェクトがスナップする縦の基準点

3Dには対応してないですが、ちょっと修正すればできるはず。

コード

SnapToGrid.cs
using System;
using UnityEngine;
namespace Hexat.Editor
{
    [ExecuteInEditMode]
    [RequireComponent(typeof(Renderer))]
    public class SnapToGrid : MonoBehaviour
    {
        private Renderer _renderer;
        [SerializeField] private Vector3 center;
        [SerializeField] private Vector2 cellSize = new Vector3(1, 1);
        [SerializeField] private int width = 1;
        [SerializeField] private int height = 1;
        [SerializeField] private Vector2 offset;
        [SerializeField] private bool isOdd = true;

        private enum HorizontalPivot
        {
            Left = -1,
            Middle = 0,
            Right = 1
        }

        private enum VerticalPivot
        {
            Top = 1,
            Midle = 0,
            Bottom = -1
        }

        [SerializeField] private HorizontalPivot _horizontalPivot = HorizontalPivot.Middle;
        [SerializeField] private VerticalPivot _verticalPivot = VerticalPivot.Midle;

        private void OnEnable()
        {
            _renderer = GetComponent<Renderer>();
        }

        private Vector3 pivotCenter
        {
            get
            {
                var v = new Vector3(
                    ((int) _horizontalPivot + 1) / 2f,
                    ((int) _verticalPivot + 1) / 2f,
                    0
                );
                return new Vector3(minX, minY, 0) + Vector3.Scale(new Vector3(width, height, 0), v);
            }
        }

        private Bounds bounds
        {
            get { return _renderer.bounds; }
        }

        private float minX
        {
            get
            {
                switch (_horizontalPivot)
                {
                    case HorizontalPivot.Left:
                        return bounds.min.x + offset.x;
                    case HorizontalPivot.Middle:
                        return bounds.center.x - (cellSize.x * width) / 2 + offset.x;
                    case HorizontalPivot.Right:
                        return bounds.max.x - cellSize.x * width + offset.x;
                }
                return 0;
            }
        }

        private float minY
        {
            get
            {

                switch (_verticalPivot)
                {
                    case VerticalPivot.Top:
                        return bounds.max.y - cellSize.y * height + offset.y;
                    case VerticalPivot.Midle:
                        return bounds.center.y - (cellSize.y * height) / 2 + offset.y;
                    case VerticalPivot.Bottom:
                        return bounds.min.y + offset.y;
                }
                return 0;
            }
        }

        private void DoSnap()
        {
            var x = minX + cellSize.x * width;
            var y = minY + cellSize.y * height;
            var x0 = Mathf.Floor(center.x + cellSize.x * (int) (x / cellSize.x));
            if (isOdd) x0 -= cellSize.x / 2;
            var y0 = Mathf.Floor(center.y + cellSize.y * (int) (y / cellSize.y));
            if (isOdd) y0 -= cellSize.y / 2;
            var dx = x - x0 > 0.5f ? x0 + cellSize.x - x : x0 - x;
            var dy = y - y0 > 0.5f ? y0 + cellSize.y - y : y0 - y;
            transform.position += new Vector3(dx, dy, 0);
        }

        private void Update()
        {
            if (!Application.isPlaying)
            {
                DoSnap();
            }
        }

        void OnDrawGizmos()
        {
            // ギズモのアイコンは自分で設定してね!
            Gizmos.DrawIcon(pivotCenter, "MyGizmoCircleOrange");
            Gizmos.color = Color.blue;
            for (var i = 0; i < width; i++)
            {
                for (var j = 0; j < height; j++)
                {
                    var l = minX + i * cellSize.x;
                    var t = minY + j * cellSize.y;
                    var r = l + cellSize.x;
                    var b = t + cellSize.y;
                    DrawQuad(l, t, r, b);
                }
            }
        }

        private static void DrawQuad(float l, float t, float r, float b)
        {
            DrawLine2D(l, t, r, t);
            DrawLine2D(r, t, r, b);
            DrawLine2D(r, b, l, b);
            DrawLine2D(l, b, l, t);
        }

        private static void DrawLine2D(float sx, float sy, float ex, float ey)
        {
            Gizmos.DrawLine(new Vector3(sx, sy, 0), new Vector3(ex, ey, 0));
        }
    }
}