Created
February 8, 2026 13:54
-
-
Save yasirkula/fc70b54103e2fa60ffdf3fcfeb54b76f to your computer and use it in GitHub Desktop.
Adding randomized offset to each row/column of tiled Image in Unity
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System.Collections.Generic; | |
| #if UNITY_EDITOR | |
| using UnityEditor; | |
| #endif | |
| using UnityEngine; | |
| using UnityEngine.Sprites; | |
| using UnityEngine.U2D; | |
| using UnityEngine.UI; | |
| using Random = System.Random; | |
| // Code is mostly a copy&paste of Image component's source code | |
| [RequireComponent(typeof(CanvasRenderer))] | |
| [AddComponentMenu("UI/Tiled Image With Offset", 11)] | |
| public class TiledImageWithOffset : MaskableGraphic, ILayoutElement | |
| { | |
| [SerializeField] | |
| private Sprite m_Sprite; | |
| public Sprite Sprite | |
| { | |
| get { return m_Sprite; } | |
| set | |
| { | |
| if (m_Sprite != null) | |
| { | |
| if (m_Sprite != value) | |
| { | |
| m_SkipLayoutUpdate = m_Sprite.rect.size.Equals(value ? value.rect.size : Vector2.zero); | |
| m_SkipMaterialUpdate = m_Sprite.texture == (value ? value.texture : null); | |
| m_Sprite = value; | |
| SetAllDirty(); | |
| TrackSprite(); | |
| } | |
| } | |
| else if (value != null) | |
| { | |
| m_SkipLayoutUpdate = value.rect.size == Vector2.zero; | |
| m_SkipMaterialUpdate = value.texture == null; | |
| m_Sprite = value; | |
| SetAllDirty(); | |
| TrackSprite(); | |
| } | |
| } | |
| } | |
| public override Texture mainTexture => (m_Sprite != null) ? m_Sprite.texture : (material != null && material.mainTexture != null) ? material.mainTexture : s_WhiteTexture; | |
| [SerializeField, Min(0.01f)] | |
| private float m_PixelsPerUnitMultiplier = 1f; | |
| public float pixelsPerUnitMultiplier | |
| { | |
| get => m_PixelsPerUnitMultiplier; | |
| set => TryChangeProperty(ref m_PixelsPerUnitMultiplier, Mathf.Max(0.01f, value)); | |
| } | |
| public float pixelsPerUnit => ((m_Sprite != null) ? m_Sprite.pixelsPerUnit : 100f) / canvas.referencePixelsPerUnit; | |
| [SerializeField] | |
| private bool m_IsTileOffsetHorizontal; | |
| public bool IsTileOffsetHorizontal | |
| { | |
| get => m_IsTileOffsetHorizontal; | |
| set => TryChangeProperty(ref m_IsTileOffsetHorizontal, value); | |
| } | |
| [SerializeField] | |
| private Vector2 m_TileOffsetRange = new Vector2(0.1f, 0.9f); | |
| public Vector2 TileOffsetRange | |
| { | |
| get => m_TileOffsetRange; | |
| set => TryChangeProperty(ref m_TileOffsetRange, value); | |
| } | |
| [SerializeField] | |
| private int m_TileOffsetSeed; | |
| public int TileOffsetSeed | |
| { | |
| get => m_TileOffsetSeed; | |
| set => TryChangeProperty(ref m_TileOffsetSeed, value); | |
| } | |
| public float minWidth => 0f; | |
| public float minHeight => 0f; | |
| public float flexibleWidth => -1f; | |
| public float flexibleHeight => -1f; | |
| public float preferredWidth => (m_Sprite == null) ? 0f : (DataUtility.GetMinSize(m_Sprite).x / pixelsPerUnit); | |
| public float preferredHeight => (m_Sprite == null) ? 0f : (DataUtility.GetMinSize(m_Sprite).y / pixelsPerUnit); | |
| public int layoutPriority => 0; | |
| public void CalculateLayoutInputHorizontal() { } | |
| public void CalculateLayoutInputVertical() { } | |
| private Vector2 rectPosition; | |
| protected TiledImageWithOffset() | |
| { | |
| useLegacyMeshGeneration = false; | |
| } | |
| protected override void OnEnable() | |
| { | |
| base.OnEnable(); | |
| TrackSprite(); | |
| } | |
| protected override void OnDisable() | |
| { | |
| base.OnDisable(); | |
| if (m_Tracked) | |
| UnTrackImage(this); | |
| } | |
| protected override void OnPopulateMesh(VertexHelper vh) | |
| { | |
| if (m_Sprite == null) | |
| { | |
| base.OnPopulateMesh(vh); | |
| return; | |
| } | |
| vh.Clear(); | |
| Color32 color = this.color; | |
| Vector4 outer = DataUtility.GetOuterUV(m_Sprite); | |
| Vector4 inner = DataUtility.GetInnerUV(m_Sprite); | |
| Vector4 border = m_Sprite.border; | |
| bool hasBorder = border.sqrMagnitude > 0f; | |
| Vector2 spriteSize = m_Sprite.rect.size; | |
| float pixelsPerUnit = this.pixelsPerUnit * m_PixelsPerUnitMultiplier; | |
| Rect rect = GetPixelAdjustedRect(); | |
| float tileWidth = (spriteSize.x - border.x - border.z) / pixelsPerUnit; | |
| float tileHeight = (spriteSize.y - border.y - border.w) / pixelsPerUnit; | |
| rectPosition = rect.position; | |
| border = GetAdjustedBorders(border / pixelsPerUnit, rect); | |
| Vector2 uvMin = new(inner.x, inner.y); | |
| Vector2 uvMax = new(inner.z, inner.w); | |
| // Coordinates of tiled region relative to lower left corner | |
| float xMin = border.x; | |
| float xMax = rect.width - border.z; | |
| float yMin = border.y; | |
| float yMax = rect.height - border.w; | |
| if (tileWidth <= 0) | |
| tileWidth = xMax - xMin; | |
| if (tileHeight <= 0) | |
| tileHeight = yMax - yMin; | |
| float tileOffsetMin = (m_TileOffsetRange.x > 0f) ? (1f - m_TileOffsetRange.x % 1f) : (-m_TileOffsetRange.x % 1f); | |
| float tileOffsetMax = (m_TileOffsetRange.y > 0f) ? (1f - m_TileOffsetRange.y % 1f) : (-m_TileOffsetRange.y % 1f); | |
| Random random = new(m_TileOffsetSeed); | |
| float GetTileStartPos(float tiledRegionStartPos, float tileSize, int rowOrColumn) | |
| { | |
| float tileOffset = Mathf.LerpUnclamped(tileOffsetMin, tileOffsetMax, (float)random.NextDouble()); | |
| return tiledRegionStartPos - tileSize * ((tileOffset * rowOrColumn) % 1f); | |
| } | |
| Vector2 clipped = uvMax; | |
| if (m_IsTileOffsetHorizontal) | |
| { | |
| int row = 0; | |
| for (float y1 = yMin, y2 = y1 + tileHeight; y1 < yMax; y1 = y2, y2 = y1 + tileHeight, row++) | |
| { | |
| if (y2 > yMax) | |
| { | |
| clipped.y = Remap(yMax, y1, y2, uvMin.y, uvMax.y); | |
| y2 = yMax; | |
| } | |
| clipped.x = uvMax.x; | |
| for (float x1 = GetTileStartPos(xMin, tileWidth, row), x2 = x1 + tileWidth; x1 < xMax; x1 = x2, x2 = x1 + tileWidth) | |
| { | |
| if (x2 > xMax) | |
| { | |
| clipped.x = Remap(xMax, x1, x2, uvMin.x, uvMax.x); | |
| x2 = xMax; | |
| } | |
| if (x1 >= xMin) | |
| AddQuad(vh, new Vector2(x1, y1), new Vector2(x2, y2), color, uvMin, clipped); | |
| else | |
| AddQuad(vh, new Vector2(xMin, y1), new Vector2(x2, y2), color, new Vector2(Remap(xMin, x1, x2, uvMin.x, uvMax.x), uvMin.y), clipped); | |
| } | |
| } | |
| } | |
| else | |
| { | |
| int column = 0; | |
| for (float x1 = xMin, x2 = x1 + tileWidth; x1 < xMax; x1 = x2, x2 = x1 + tileWidth, column++) | |
| { | |
| if (x2 > xMax) | |
| { | |
| clipped.x = Remap(xMax, x1, x2, uvMin.x, uvMax.x); | |
| x2 = xMax; | |
| } | |
| clipped.y = uvMax.y; | |
| for (float y1 = GetTileStartPos(yMin, tileHeight, column), y2 = y1 + tileHeight; y1 < yMax; y1 = y2, y2 = y1 + tileHeight) | |
| { | |
| if (y2 > yMax) | |
| { | |
| clipped.y = Remap(yMax, y1, y2, uvMin.y, uvMax.y); | |
| y2 = yMax; | |
| } | |
| if (y1 >= yMin) | |
| AddQuad(vh, new Vector2(x1, y1), new Vector2(x2, y2), color, uvMin, clipped); | |
| else | |
| AddQuad(vh, new Vector2(x1, yMin), new Vector2(x2, y2), color, new Vector2(uvMin.x, Remap(yMin, y1, y2, uvMin.y, uvMax.y)), clipped); | |
| } | |
| } | |
| } | |
| if (hasBorder) | |
| { | |
| // Left and right tiled border | |
| clipped = uvMax; | |
| for (float y1 = yMin, y2 = y1 + tileHeight; y1 < yMax; y1 = y2, y2 = y1 + tileHeight) | |
| { | |
| if (y2 > yMax) | |
| { | |
| clipped.y = Remap(yMax, y1, y2, uvMin.y, uvMax.y); | |
| y2 = yMax; | |
| } | |
| AddQuad(vh, new Vector2(0, y1), new Vector2(xMin, y2), color, new Vector2(outer.x, uvMin.y), new Vector2(uvMin.x, clipped.y)); | |
| AddQuad(vh, new Vector2(xMax, y1), new Vector2(rect.width, y2), color, new Vector2(uvMax.x, uvMin.y), new Vector2(outer.z, clipped.y)); | |
| } | |
| // Bottom and top tiled border | |
| clipped = uvMax; | |
| for (float x1 = xMin, x2 = x1 + tileWidth; x1 < xMax; x1 = x2, x2 = x1 + tileWidth) | |
| { | |
| if (x2 > xMax) | |
| { | |
| clipped.x = Remap(xMax, x1, x2, uvMin.x, uvMax.x); | |
| x2 = xMax; | |
| } | |
| AddQuad(vh, new Vector2(x1, 0), new Vector2(x2, yMin), color, new Vector2(uvMin.x, outer.y), new Vector2(clipped.x, uvMin.y)); | |
| AddQuad(vh, new Vector2(x1, yMax), new Vector2(x2, rect.height), color, new Vector2(uvMin.x, uvMax.y), new Vector2(clipped.x, outer.w)); | |
| } | |
| // Corners | |
| AddQuad(vh, new Vector2(0, 0), new Vector2(xMin, yMin), color, new Vector2(outer.x, outer.y), new Vector2(uvMin.x, uvMin.y)); | |
| AddQuad(vh, new Vector2(xMax, 0), new Vector2(rect.width, yMin), color, new Vector2(uvMax.x, outer.y), new Vector2(outer.z, uvMin.y)); | |
| AddQuad(vh, new Vector2(0, yMax), new Vector2(xMin, rect.height), color, new Vector2(outer.x, uvMax.y), new Vector2(uvMin.x, outer.w)); | |
| AddQuad(vh, new Vector2(xMax, yMax), new Vector2(rect.width, rect.height), color, new Vector2(uvMax.x, uvMax.y), new Vector2(outer.z, outer.w)); | |
| } | |
| } | |
| private Vector4 GetAdjustedBorders(Vector4 border, Rect adjustedRect) | |
| { | |
| Rect originalRect = rectTransform.rect; | |
| for (int axis = 0; axis <= 1; axis++) | |
| { | |
| float borderScaleRatio; | |
| // The adjusted rect (adjusted for pixel correctness) may be slightly larger than the original rect. Adjust the border to match the adjustedRect to avoid small gaps between borders (case 833201). | |
| if (originalRect.size[axis] != 0) | |
| { | |
| borderScaleRatio = adjustedRect.size[axis] / originalRect.size[axis]; | |
| border[axis] *= borderScaleRatio; | |
| border[axis + 2] *= borderScaleRatio; | |
| } | |
| // If the rect is smaller than the combined borders, then there's not room for the borders at their normal size. In order to avoid artefacts with overlapping borders, we scale the borders down to fit. | |
| float combinedBorders = border[axis] + border[axis + 2]; | |
| if (adjustedRect.size[axis] < combinedBorders && combinedBorders != 0) | |
| { | |
| borderScaleRatio = adjustedRect.size[axis] / combinedBorders; | |
| border[axis] *= borderScaleRatio; | |
| border[axis + 2] *= borderScaleRatio; | |
| } | |
| } | |
| return border; | |
| } | |
| private void AddQuad(VertexHelper vh, Vector2 posMin, Vector2 posMax, Color32 color, Vector2 uvMin, Vector2 uvMax) | |
| { | |
| int startIndex = vh.currentVertCount; | |
| vh.AddVert(new Vector3(rectPosition.x + posMin.x, rectPosition.y + posMin.y), color, new Vector4(uvMin.x, uvMin.y)); | |
| vh.AddVert(new Vector3(rectPosition.x + posMin.x, rectPosition.y + posMax.y), color, new Vector4(uvMin.x, uvMax.y)); | |
| vh.AddVert(new Vector3(rectPosition.x + posMax.x, rectPosition.y + posMax.y), color, new Vector4(uvMax.x, uvMax.y)); | |
| vh.AddVert(new Vector3(rectPosition.x + posMax.x, rectPosition.y + posMin.y), color, new Vector4(uvMax.x, uvMin.y)); | |
| vh.AddTriangle(startIndex, startIndex + 1, startIndex + 2); | |
| vh.AddTriangle(startIndex + 2, startIndex + 3, startIndex); | |
| } | |
| private float Remap(float value, float fromMin, float fromMax, float toMin, float toMax) | |
| { | |
| float t = (value - fromMin) / (fromMax - fromMin); | |
| return toMin + (toMax - toMin) * t; | |
| } | |
| private void TryChangeProperty<T>(ref T currentValue, T newValue) where T : struct | |
| { | |
| if (EqualityComparer<T>.Default.Equals(currentValue, newValue)) | |
| return; | |
| currentValue = newValue; | |
| SetVerticesDirty(); | |
| } | |
| // Track textureless images that will be rebuilt if sprite atlas manager registered a Sprite Atlas that will give this image new texture | |
| private static readonly List<TiledImageWithOffset> m_TrackedTexturelessImages = new(2); | |
| private static bool s_Initialized; | |
| private bool m_Tracked = false; | |
| private void TrackSprite() | |
| { | |
| if (m_Sprite != null && m_Sprite.texture == null) | |
| { | |
| TrackImage(this); | |
| m_Tracked = true; | |
| } | |
| } | |
| private static void TrackImage(TiledImageWithOffset image) | |
| { | |
| if (!s_Initialized) | |
| { | |
| SpriteAtlasManager.atlasRegistered += RebuildImages; | |
| s_Initialized = true; | |
| } | |
| m_TrackedTexturelessImages.Add(image); | |
| } | |
| private static void UnTrackImage(TiledImageWithOffset image) | |
| { | |
| m_TrackedTexturelessImages.Remove(image); | |
| } | |
| private static void RebuildImages(SpriteAtlas spriteAtlas) | |
| { | |
| for (int i = m_TrackedTexturelessImages.Count - 1; i >= 0; i--) | |
| { | |
| TiledImageWithOffset image = m_TrackedTexturelessImages[i]; | |
| if (image.m_Sprite != null && spriteAtlas.CanBindTo(image.m_Sprite)) | |
| { | |
| image.SetAllDirty(); | |
| m_TrackedTexturelessImages.RemoveAt(i); | |
| } | |
| } | |
| } | |
| #if UNITY_EDITOR | |
| // Custom Editor to order the variables in the Inspector similar to Image component | |
| [CustomEditor(typeof(TiledImageWithOffset)), CanEditMultipleObjects] | |
| private class TilingImageWithOffsetEditor : Editor | |
| { | |
| private SerializedProperty spriteProp, colorProp, tileOffsetRangeProp; | |
| private readonly GUIContent spriteLabel = new GUIContent("Source Image"); | |
| private readonly GUIContent tileOffsetRangeLabel = new GUIContent("Tile Offset Range", "Percentage of movement/offset at each row or column (determined by \"Is Offset Horizontal\"). At each row or column, a new random value between this range is picked."); | |
| private void OnEnable() | |
| { | |
| spriteProp = serializedObject.FindProperty("m_Sprite"); | |
| colorProp = serializedObject.FindProperty("m_Color"); | |
| tileOffsetRangeProp = serializedObject.FindProperty("m_TileOffsetRange"); | |
| } | |
| public override void OnInspectorGUI() | |
| { | |
| serializedObject.Update(); | |
| EditorGUILayout.PropertyField(spriteProp, spriteLabel); | |
| EditorGUILayout.PropertyField(colorProp); | |
| DrawPropertiesExcluding(serializedObject, "m_Script", "m_Sprite", "m_Color", "m_OnCullStateChanged", "m_TileOffsetRange"); | |
| MinMaxSlider(EditorGUILayout.GetControlRect(false, EditorGUIUtility.singleLineHeight), tileOffsetRangeProp, tileOffsetRangeLabel, 0.001f, 0.999f); | |
| serializedObject.ApplyModifiedProperties(); | |
| } | |
| // Min-max slider credit: https://github.com/Unity-Technologies/UnityCsReference/blob/61f92bd79ae862c4465d35270f9d1d57befd1761/Editor/Mono/Inspector/LightEditor.cs#L328-L363 | |
| private void MinMaxSlider(Rect position, SerializedProperty property, GUIContent label, float min, float max) | |
| { | |
| const float minMaxSliderTextFieldWidth = 45f; | |
| SerializedProperty minProp = property.FindPropertyRelative("x"); | |
| SerializedProperty maxProp = property.FindPropertyRelative("y"); | |
| position = EditorGUI.PrefixLabel(position, label); | |
| EditorGUI.BeginProperty(position, GUIContent.none, property); | |
| Rect minRect = new Rect(position) { width = minMaxSliderTextFieldWidth }; | |
| Rect maxRect = new Rect(position) { xMin = position.xMax - minMaxSliderTextFieldWidth }; | |
| Rect sliderRect = new Rect(position) { xMin = minRect.xMax + 5f, xMax = maxRect.xMin - 5f }; | |
| EditorGUI.BeginChangeCheck(); | |
| EditorGUI.PropertyField(minRect, minProp, GUIContent.none); | |
| Vector2 value = property.vector2Value; | |
| EditorGUI.BeginChangeCheck(); | |
| EditorGUI.MinMaxSlider(sliderRect, ref value.x, ref value.y, min, max); | |
| if (EditorGUI.EndChangeCheck()) | |
| property.vector2Value = value; | |
| EditorGUI.PropertyField(maxRect, maxProp, GUIContent.none); | |
| if (EditorGUI.EndChangeCheck()) | |
| { | |
| float x = minProp.floatValue; | |
| float y = maxProp.floatValue; | |
| if (x < min || x > max) | |
| minProp.floatValue = Mathf.Clamp(x, min, max); | |
| if (y < min || y > max) | |
| maxProp.floatValue = Mathf.Clamp(y, min, max); | |
| } | |
| EditorGUI.EndProperty(); | |
| } | |
| } | |
| #endif | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
How To
Simply add a Tiled Image With Offset component to the desired UI object. Note that this component is an alternative to the Image component, so a GameObject can't have both components.
TiledImageWithOffset.mp4
The brick Sprite used in the demo video: