Last active
October 21, 2025 17:25
-
-
Save b3x206/ccb501fc54c2d2bd50ad0e1cb376d360 to your computer and use it in GitHub Desktop.
A mesh based progress/health bar, for Unity games
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 UnityEngine; | |
| using System; | |
| using System.Collections.Generic; | |
| // This is free and unencumbered software released into the public domain. | |
| // Anyone is free to copy, modify, publish, use, compile, sell, or | |
| // distribute this software, either in source code form or as a compiled | |
| // binary, for any purpose, commercial or non-commercial, and by any | |
| // means. | |
| // (see https://unlicense.org) | |
| /// <summary> | |
| /// Mesh based progress bar that can be also used as an health bar. | |
| /// <br>It creates a mesh with 2 submeshes to have a foreground[0] and a background[1]</br> | |
| /// <br>The UV's of the ProgressBar are mapped between 0-1, per material. This means that it always fits the texture inside.</br> | |
| /// </summary> | |
| /// <example> | |
| /// <![CDATA[ | |
| /// // Setup 'MeshProgressBar' by adding it into an object and then assigning materials to the 0 and 1 index of the 'MeshRenderer'. | |
| /// using UnityEngine; | |
| /// | |
| /// public class SampleScript : MonoBehaviour | |
| /// { | |
| /// public int startingHealth = 100; | |
| /// public MeshProgressBar healthBar; | |
| /// public int Health { get; private set; } | |
| /// | |
| /// private void Start() | |
| /// { | |
| /// Health = startingHealth; | |
| /// } | |
| /// | |
| /// private void Update() | |
| /// { | |
| /// if (Input.GetKeyDown(KeyCode.Space)) | |
| /// { | |
| /// Damage(Random.Range(5, 15)); | |
| /// } | |
| /// } | |
| /// | |
| /// public void Damage(int damage) | |
| /// { | |
| /// Health -= damage; | |
| /// | |
| /// // 'Progress' is clamped between 0-1, but is a floating point number. | |
| /// // Casting to (float) is required to not do integer division. (which floors the number) | |
| /// healthBar.Progress = (float)Health / startingHealth; | |
| /// } | |
| /// } | |
| /// ]]> | |
| /// </example> | |
| [ExecuteAlways, RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] | |
| public class MeshProgressBar : MonoBehaviour | |
| { | |
| // taken from BXFW to make this script standalone | |
| public enum TransformAxis | |
| { | |
| None = 0, | |
| XAxis = 1 << 0, | |
| YAxis = 1 << 1, | |
| ZAxis = 1 << 2, | |
| XYZAxis = XAxis | YAxis | ZAxis, | |
| } | |
| /// <summary> | |
| /// Generates a quad for the bar meshes. | |
| /// <br>The given 1, 3, 4 and 6'th vertex of the mesh control the center divider.</br> | |
| /// <br>The foreground submesh is the 0'th index and the background submesh is in the 1'st index.</br> | |
| /// </summary> | |
| protected static Mesh GenerateSplitQuad() | |
| { | |
| // Don't combine the center into a single vertex | |
| // That combination will cause the UV's being split weirdly | |
| Mesh mesh = new Mesh(); | |
| float width = 1f; | |
| float height = 1f; | |
| float halfWidth = width / 2f; | |
| float halfHeight = height / 2f; | |
| // 2 - 3, 6 - 7 | |
| // | | | | | |
| // 0 - 1, 4 - 5 | |
| Vector3[] vertices = new Vector3[8] | |
| { | |
| // left | |
| new Vector3(-halfWidth, -halfHeight, 0f), | |
| new Vector3(0f, -halfHeight, 0f), | |
| new Vector3(-halfWidth, height - halfHeight, 0f), | |
| new Vector3(0f, height - halfHeight, 0f), | |
| // right | |
| new Vector3(0f, -halfHeight, 0f), | |
| new Vector3(width - halfWidth, -halfHeight, 0f), | |
| new Vector3(0f, height - halfHeight, 0f), | |
| new Vector3(width - halfWidth, height - halfHeight, 0f), | |
| }; | |
| mesh.vertices = vertices; | |
| mesh.subMeshCount = 2; | |
| mesh.SetTriangles(new int[6] | |
| { | |
| // lower left triangle | |
| 0, 2, 1, | |
| // upper right triangle | |
| 2, 3, 1 | |
| }, 0); | |
| mesh.SetTriangles(new int[6] | |
| { | |
| // lower left | |
| 4, 6, 5, | |
| // upper right | |
| 6, 7, 5 | |
| }, 1); | |
| Vector3[] normals = new Vector3[8] | |
| { | |
| -Vector3.forward, | |
| -Vector3.forward, | |
| -Vector3.forward, | |
| -Vector3.forward, | |
| -Vector3.forward, | |
| -Vector3.forward, | |
| -Vector3.forward, | |
| -Vector3.forward | |
| }; | |
| mesh.normals = normals; | |
| Vector2[] uv = new Vector2[8] | |
| { | |
| // left | |
| new Vector2(0f, 0f), | |
| new Vector2(1f, 0f), | |
| new Vector2(0f, 1f), | |
| new Vector2(1f, 1f), | |
| // right | |
| new Vector2(0f, 0f), | |
| new Vector2(1f, 0f), | |
| new Vector2(0f, 1f), | |
| new Vector2(1f, 1f) | |
| }; | |
| mesh.uv = uv; | |
| mesh.name = "PBarMesh"; | |
| return mesh; | |
| } | |
| private MeshFilter m_MeshFilter; | |
| /// <summary> | |
| /// Mesh filter attached to this progress bar. | |
| /// </summary> | |
| public MeshFilter MeshFilter | |
| { | |
| get | |
| { | |
| if (m_MeshFilter == null) | |
| { | |
| m_MeshFilter = GetComponent<MeshFilter>(); | |
| } | |
| return m_MeshFilter; | |
| } | |
| } | |
| private Renderer m_Renderer; | |
| /// <summary> | |
| /// Renderer attached to this progress bar. | |
| /// </summary> | |
| public Renderer Renderer | |
| { | |
| get | |
| { | |
| if (m_Renderer == null) | |
| { | |
| if (!TryGetComponent(out m_Renderer)) | |
| { | |
| return null; | |
| } | |
| } | |
| CheckRendererMaterialsSize(m_Renderer); | |
| return m_Renderer; | |
| } | |
| } | |
| private void CheckRendererMaterialsSize(Renderer r) | |
| { | |
| bool hasAtleastTwoMaterials; | |
| bool hasAtleastOneMaterial; | |
| #if UNITY_EDITOR | |
| // 'sharedMaterials' | |
| if (!Application.isPlaying) | |
| { | |
| hasAtleastTwoMaterials = r.sharedMaterials.Length >= 2; | |
| hasAtleastOneMaterial = hasAtleastTwoMaterials || r.sharedMaterials.Length >= 1; | |
| if (r.sharedMaterials.Length != 2) | |
| { | |
| Material mat1 = null, mat2 = null; | |
| if (hasAtleastTwoMaterials) | |
| { | |
| mat1 = r.sharedMaterials[0]; | |
| mat2 = r.sharedMaterials[1]; | |
| } | |
| else if (hasAtleastOneMaterial) | |
| { | |
| mat1 = r.sharedMaterials[0]; | |
| } | |
| // nullify the 'r.sharedMaterials' first | |
| // to avoid damaging the materials itself | |
| // This is done because unity's Renderer material management is footgun | |
| for (int i = 2; i < r.sharedMaterials.Length; i++) | |
| { | |
| r.sharedMaterials[i] = null; | |
| } | |
| // finally set the shared materials array after no materials were verified to be not damagable irreversibily | |
| // (it is irreversible if the user wasn't using version control) | |
| r.sharedMaterials = new Material[2] | |
| { | |
| mat1, | |
| mat2 | |
| }; | |
| } | |
| return; | |
| } | |
| #endif | |
| hasAtleastTwoMaterials = r.materials.Length >= 2; | |
| hasAtleastOneMaterial = hasAtleastTwoMaterials || r.materials.Length >= 1; | |
| // This one can be done as is, the materials will be instanced and be not damaged | |
| if (r.materials.Length != 2) | |
| { | |
| r.materials = new Material[2] | |
| { | |
| hasAtleastOneMaterial ? r.materials[0] : null, | |
| hasAtleastTwoMaterials ? r.materials[1] : null | |
| }; | |
| } | |
| } | |
| /// <summary> | |
| /// The progress bar mesh. | |
| /// <br>Depending on the current runtime state, this is the <see cref="MeshFilter.sharedMesh"/> | |
| /// or the <see cref="MeshFilter.mesh"/> with the quad always assigned.</br> | |
| /// </summary> | |
| public Mesh ProgressMesh | |
| { | |
| get | |
| { | |
| if (MeshFilter.sharedMesh == null) | |
| { | |
| MeshFilter.sharedMesh = GenerateSplitQuad(); | |
| } | |
| #if UNITY_EDITOR | |
| return Application.isPlaying ? MeshFilter.mesh : MeshFilter.sharedMesh; | |
| #else | |
| return MeshFilter.mesh; | |
| #endif | |
| } | |
| } | |
| private Camera m_BillboardCamera; | |
| /// <summary> | |
| /// The camera to billboard towards. | |
| /// </summary> | |
| public Camera BillboardCamera | |
| { | |
| get | |
| { | |
| if (m_BillboardCamera == null) | |
| { | |
| m_BillboardCamera = Camera.main; | |
| } | |
| return m_BillboardCamera; | |
| } | |
| } | |
| /// <summary> | |
| /// Foreground material applied to this progress bar. | |
| /// <br>Depending on which state of the runtime this material is accessed on, it can be the sharedMaterial or the material.</br> | |
| /// </summary> | |
| public Material ForegroundMaterial | |
| { | |
| get | |
| { | |
| #if UNITY_EDITOR | |
| return Application.isPlaying ? Renderer.materials[0] : Renderer.sharedMaterials[0]; | |
| #else | |
| return Renderer.materials[0]; | |
| #endif | |
| } | |
| } | |
| /// <summary> | |
| /// Background material applied to this progress bar. | |
| /// <br>Depending on which state of the runtime this material is accessed on, it can be the sharedMaterial or the material.</br> | |
| /// </summary> | |
| public Material BackgroundMaterial | |
| { | |
| get | |
| { | |
| #if UNITY_EDITOR | |
| return Application.isPlaying ? Renderer.materials[1] : Renderer.sharedMaterials[1]; | |
| #else | |
| return Renderer.materials[1]; | |
| #endif | |
| } | |
| } | |
| private readonly List<Vector3> m_VertsCache = new List<Vector3>(8); | |
| protected void SetProgressVertices(float progress) | |
| { | |
| // The given vertices, 1, 3, 4 and 6 has to be moved. | |
| ProgressMesh.GetVertices(m_VertsCache); | |
| float initialLeftX = m_VertsCache[0].x; | |
| m_VertsCache[1] = new Vector3(initialLeftX + progress, m_VertsCache[1].y, m_VertsCache[1].z); | |
| m_VertsCache[3] = new Vector3(initialLeftX + progress, m_VertsCache[3].y, m_VertsCache[3].z); | |
| m_VertsCache[4] = new Vector3(initialLeftX + progress, m_VertsCache[4].y, m_VertsCache[4].z); | |
| m_VertsCache[6] = new Vector3(initialLeftX + progress, m_VertsCache[6].y, m_VertsCache[6].z); | |
| ProgressMesh.SetVertices(m_VertsCache); | |
| } | |
| /// <summary> | |
| /// Translates the mesh's vertices / rendering matrix to always face towards camera. | |
| /// </summary> | |
| protected void UpdateBillboard() | |
| { | |
| Camera targetCamera; | |
| #if UNITY_EDITOR | |
| // do nothing in prefab scene, as prefab scenes when changed, it needs to be set dirty in 73 different ways | |
| if (UnityEditor.SceneManagement.PrefabStageUtility.GetCurrentPrefabStage() != null) | |
| { | |
| return; | |
| } | |
| if (!Application.isPlaying) | |
| { | |
| targetCamera = UnityEditor.SceneView.lastActiveSceneView.camera; | |
| } | |
| else | |
| #endif | |
| { | |
| targetCamera = BillboardCamera; | |
| } | |
| if (targetCamera == null) | |
| { | |
| return; | |
| } | |
| // Since the mesh vertex positions are not transformed, we can always rotate it | |
| // However this will mutate the mostly immutable mesh, which is bad. | |
| // So update this transform as unity renderer is a black box and using Graphics.DrawMesh usually doesn't work as intended. | |
| // -- | |
| // Note : we could serialize the 'm_VertsCache' and transform the mesh directly, but this will do for now. | |
| // Even though it is changing a serialized value, we are going to change a serialized value anyways (the sharedMesh, that is) | |
| Vector3 targetRotationEuler = Quaternion.LookRotation(transform.position - targetCamera.transform.position, Vector3.forward).eulerAngles; | |
| if ((billboardAxis & TransformAxis.XAxis) != TransformAxis.XAxis) | |
| { | |
| targetRotationEuler.x = 0f; | |
| } | |
| if ((billboardAxis & TransformAxis.YAxis) != TransformAxis.YAxis) | |
| { | |
| targetRotationEuler.y = 0f; | |
| } | |
| if ((billboardAxis & TransformAxis.ZAxis) != TransformAxis.ZAxis) | |
| { | |
| targetRotationEuler.z = 0f; | |
| } | |
| transform.rotation = Quaternion.Euler(targetRotationEuler); | |
| } | |
| [SerializeField, Range(0f, 1f)] private float m_Progress = 0f; | |
| /// <summary> | |
| /// The progress elapsed for this progress bar. | |
| /// <br>This value goes between 0->1. Setting this value updates the vertices of the mesh to it's progressed state.</br> | |
| /// </summary> | |
| public float Progress | |
| { | |
| get | |
| { | |
| return m_Progress; | |
| } | |
| set | |
| { | |
| m_Progress = Mathf.Clamp01(value); | |
| SetProgressVertices(m_Progress); | |
| } | |
| } | |
| /// <summary> | |
| /// If this is set to any value other than <see cref="TransformAxis.None"/>, | |
| /// the meshes are transformed to look at the given primary scene camera. | |
| /// </summary> | |
| public TransformAxis billboardAxis = TransformAxis.YAxis; | |
| protected virtual void Awake() | |
| { | |
| // Prefab instances delete the mesh because unity serializer | |
| if (MeshFilter.sharedMesh == null) | |
| { | |
| MeshFilter.sharedMesh = GenerateSplitQuad(); | |
| } | |
| } | |
| protected virtual void Update() | |
| { | |
| #if UNITY_EDITOR | |
| if (!Application.isPlaying) | |
| { | |
| // Doing this on the 'OnValidate' outputs 190237813 warnings to the console | |
| // because unity wants to do that. | |
| SetProgressVertices(m_Progress); | |
| } | |
| #endif | |
| // Do billboarding | |
| UpdateBillboard(); | |
| } | |
| protected virtual void Reset() | |
| { | |
| MeshFilter.sharedMesh = null; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment