using System.Collections; using System.Collections.Generic; using UnityEngine; /// /// Simulates liquid physics with wobble effects based on object movement and rotation. /// Supports both Material Property Blocks (for shared materials) and Material Instances (for Timeline animation). /// [ExecuteInEditMode] [RequireComponent(typeof(MeshFilter), typeof(Renderer))] public class Liquid : MonoBehaviour { /// /// Determines which time source to use for wobble calculations. /// public enum UpdateMode { /// Normal game time (affected by Time.timeScale) Normal, /// Unscaled time (ignores Time.timeScale, useful for pause menus) UnscaledTime } public UpdateMode updateMode; [Header("Wobble Settings")] [Tooltip("Maximum wobble intensity. Higher values = more aggressive liquid movement.")] [Range(0f, 0.4f)] [SerializeField] float MaxWobble = 0.03f; [Tooltip("Speed of wobble oscillation. Higher values = faster wobbling.")] [Range(0f, 1.5f)] [SerializeField] float WobbleSpeedMove = 1f; [Tooltip("How quickly the wobble returns to rest. Higher values = faster damping.")] [Range(0f, 2f)] [SerializeField] float Recovery = 1f; [Tooltip("Minimum velocity magnitude required to trigger wobble. Acts as a threshold.")] [Range(0f, 5f)] [SerializeField] float Thickness = 1f; [Header("Fill Settings")] [Tooltip("Current fill level (0 = empty, 1 = full). Can be animated.")] [Range(0f, 1f)] public float fillAmount = 0.5f; [Tooltip("Minimum fill value when fillAmount is 0.")] [Range(0f, 1f)] public float minFill = 0f; [Tooltip("Maximum fill value when fillAmount is 1.")] [Range(0f, 1f)] public float maxFill = 1f; [Tooltip("Compensates for mesh shape. 0 = uses pivot, 1 = uses lowest vertex point.")] [Range(0, 1)] public float CompensateShapeAmount; [Header("References")] [SerializeField] Mesh mesh; [SerializeField] Renderer rend; Vector3 pos; Vector3 lastPos; Vector3 velocity; Quaternion lastRot; Vector3 angularVelocity; float wobbleAmountX; float wobbleAmountZ; float wobbleAmountToAddX; float wobbleAmountToAddZ; float pulse; float sinewave; double timeOffset; // Double for better precision across platforms Vector3 comp; MaterialPropertyBlock mpb; private static readonly int GrayscaleTexID = Shader.PropertyToID("_GrayscaleTex"); private static readonly int WobbleXID = Shader.PropertyToID("_WobbleX"); private static readonly int WobbleZID = Shader.PropertyToID("_WobbleZ"); private static readonly int FillAmountID = Shader.PropertyToID("_FillAmount"); private static readonly int GradientIndexID = Shader.PropertyToID("_GradientIndex"); private Texture2D cachedGrayscaleTexture; private bool grayscaleTextureNeedsUpdate = false; private bool usesMaterialInstances = false; // ⚡ OPTIMIZATION: Cache the lowest point of the mesh (constant value) private float cachedLowestPoint; private bool lowestPointCached = false; void Start() { // Validate components before initialization if (!ValidateComponents()) { enabled = false; return; } GetMeshAndRend(); // ⚡ GPU INSTANCING: Works automatically with MPB if shader has instancing enabled mpb = new MaterialPropertyBlock(); CheckIfUsingInstances(); CacheLowestPoint(); // Initialize time offset for consistent behavior across platforms timeOffset = (updateMode == UpdateMode.UnscaledTime) ? Time.unscaledTime : Time.time; // ⚡ FIX: Limit framerate for consistent physics across platforms Application.targetFrameRate = 60; // Force 60 FPS everywhere QualitySettings.vSyncCount = 0; // Disable VSync to use targetFrameRate } private void OnValidate() { GetMeshAndRend(); if (mpb == null) { mpb = new MaterialPropertyBlock(); } CheckIfUsingInstances(); // Recalculate if mesh changes in editor lowestPointCached = false; } /// /// Validates that all required components are present. /// Logs clear errors if components are missing. /// /// True if all required components exist, false otherwise. bool ValidateComponents() { bool isValid = true; MeshFilter meshFilter = GetComponent(); if (meshFilter == null) { Debug.LogError($"[Liquid] MeshFilter component missing on '{gameObject.name}'. " + "Add a MeshFilter component to use Liquid script.", this); isValid = false; } else if (meshFilter.sharedMesh == null) { Debug.LogError($"[Liquid] MeshFilter on '{gameObject.name}' has no mesh assigned. " + "Assign a mesh to the MeshFilter component.", this); isValid = false; } Renderer renderer = GetComponent(); if (renderer == null) { Debug.LogError($"[Liquid] Renderer component missing on '{gameObject.name}'. " + "Add a Renderer component (MeshRenderer, SkinnedMeshRenderer, etc.).", this); isValid = false; } else if (renderer.sharedMaterials == null || renderer.sharedMaterials.Length == 0) { Debug.LogWarning($"[Liquid] Renderer on '{gameObject.name}' has no materials assigned. " + "Assign at least one material for the liquid effect to work.", this); } return isValid; } void CheckIfUsingInstances() { if (rend == null) return; Material[] materials = rend.sharedMaterials; // Check if ANY material is an instance usesMaterialInstances = false; foreach (var mat in materials) { if (mat != null && mat.name.Contains("(Instance)")) { usesMaterialInstances = true; break; } } } void GetMeshAndRend() { if (mesh == null) { MeshFilter mf = GetComponent(); if (mf != null) mesh = mf.sharedMesh; } if (rend == null) rend = GetComponent(); } /// /// ⚡ OPTIMIZATION: Calculates the lowest point of the mesh ONCE and caches it. /// This value is constant for a given mesh and doesn't need recalculation every frame. /// For a mesh with 500 vertices at 60 FPS, this saves 1.8 million iterations per minute. /// void CacheLowestPoint() { if (mesh == null || lowestPointCached) return; float lowestY = float.MaxValue; Vector3 lowestVert = Vector3.zero; Vector3[] vertices = mesh.vertices; for (int i = 0; i < vertices.Length; i++) { Vector3 position = transform.TransformPoint(vertices[i]); if (position.y < lowestY) { lowestY = position.y; lowestVert = position; } } cachedLowestPoint = lowestVert.y; lowestPointCached = true; } /// /// Returns the cached lowest point of the mesh in world space. /// If not cached, calculates and caches it first. /// /// The Y position of the lowest vertex in world space. float GetLowestPoint() { if (!lowestPointCached) { CacheLowestPoint(); } return cachedLowestPoint; } void Update() { float deltaTime = 0; float currentTime = 0; switch (updateMode) { case UpdateMode.Normal: deltaTime = Time.deltaTime; currentTime = (float)(Time.time - timeOffset); break; case UpdateMode.UnscaledTime: deltaTime = Time.unscaledDeltaTime; currentTime = (float)(Time.unscaledTime - timeOffset); break; } if (deltaTime != 0) { wobbleAmountToAddX = Mathf.Lerp(wobbleAmountToAddX, 0, (deltaTime * Recovery)); wobbleAmountToAddZ = Mathf.Lerp(wobbleAmountToAddZ, 0, (deltaTime * Recovery)); pulse = 2 * Mathf.PI * WobbleSpeedMove; // ⚡ FIX: Use Time.time instead of manual accumulation for platform consistency sinewave = Mathf.Lerp(sinewave, Mathf.Sin(pulse * currentTime), deltaTime * Mathf.Clamp(velocity.magnitude + angularVelocity.magnitude, Thickness, 10)); wobbleAmountX = wobbleAmountToAddX * sinewave; wobbleAmountZ = wobbleAmountToAddZ * sinewave; velocity = (lastPos - transform.position) / deltaTime; angularVelocity = GetAngularVelocity(lastRot, transform.rotation); wobbleAmountToAddX += Mathf.Clamp((velocity.x + (velocity.y * 0.2f) + angularVelocity.z + angularVelocity.y) * MaxWobble, -MaxWobble, MaxWobble); wobbleAmountToAddZ += Mathf.Clamp((velocity.z + (velocity.y * 0.2f) + angularVelocity.x + angularVelocity.y) * MaxWobble, -MaxWobble, MaxWobble); } UpdatePos(deltaTime); if (usesMaterialInstances) ApplyToMaterialInstances(); else ApplyToMaterialPropertyBlock(); lastPos = transform.position; lastRot = transform.rotation; } void ApplyToMaterialInstances() { if (rend == null) return; Material[] materials = rend.sharedMaterials; // Write directly to material instances (not MPB) // This allows Timeline to animate other properties freely for (int i = 0; i < materials.Length; i++) { if (materials[i] == null) continue; if (materials[i].HasProperty(WobbleXID)) materials[i].SetFloat(WobbleXID, wobbleAmountX); if (materials[i].HasProperty(WobbleZID)) materials[i].SetFloat(WobbleZID, wobbleAmountZ); if (materials[i].HasProperty(FillAmountID)) materials[i].SetVector(FillAmountID, pos); if (cachedGrayscaleTexture != null && materials[i].HasProperty(GrayscaleTexID)) { materials[i].SetTexture(GrayscaleTexID, cachedGrayscaleTexture); } } grayscaleTextureNeedsUpdate = false; } void ApplyToMaterialPropertyBlock() { if (rend == null || mpb == null) return; Material[] materials = rend.sharedMaterials; // ⚡ GPU INSTANCING: Use MPB with instancing enabled for batching for (int i = 0; i < materials.Length; i++) { if (materials[i] == null) continue; rend.GetPropertyBlock(mpb, i); if (materials[i].HasProperty(WobbleXID)) mpb.SetFloat(WobbleXID, wobbleAmountX); if (materials[i].HasProperty(WobbleZID)) mpb.SetFloat(WobbleZID, wobbleAmountZ); if (materials[i].HasProperty(FillAmountID)) mpb.SetVector(FillAmountID, pos); if (!Application.isPlaying || grayscaleTextureNeedsUpdate) { if (cachedGrayscaleTexture != null && materials[i].HasProperty(GrayscaleTexID)) { mpb.SetTexture(GrayscaleTexID, cachedGrayscaleTexture); } } rend.SetPropertyBlock(mpb, i); } grayscaleTextureNeedsUpdate = false; } void UpdatePos(float deltaTime) { Vector3 worldPos = transform.TransformPoint(mesh.bounds.center); float normalizedFill = Mathf.Lerp(minFill, maxFill, fillAmount); float invertedFill = 1f - normalizedFill; if (CompensateShapeAmount > 0) { // ⚡ Now uses cached value - NO recalculation every frame if (deltaTime != 0) comp = Vector3.Lerp(comp, (worldPos - new Vector3(0, GetLowestPoint(), 0)), deltaTime * 10); else comp = (worldPos - new Vector3(0, GetLowestPoint(), 0)); pos = worldPos - transform.position - new Vector3(0, invertedFill - (comp.y * CompensateShapeAmount), 0); } else { pos = worldPos - transform.position - new Vector3(0, invertedFill, 0); } } Vector3 GetAngularVelocity(Quaternion foreLastFrameRotation, Quaternion lastFrameRotation) { var q = lastFrameRotation * Quaternion.Inverse(foreLastFrameRotation); if (Mathf.Abs(q.w) > 1023.5f / 1024.0f) return Vector3.zero; // Use the deltaTime that respects UpdateMode float dt = (updateMode == UpdateMode.UnscaledTime) ? Time.unscaledDeltaTime : Time.deltaTime; if (dt == 0) return Vector3.zero; // Avoid division by zero float gain; if (q.w < 0.0f) { var angle = Mathf.Acos(-q.w); gain = -2.0f * angle / (Mathf.Sin(angle) * dt); } else { var angle = Mathf.Acos(q.w); gain = 2.0f * angle / (Mathf.Sin(angle) * dt); } Vector3 angularVelocity = new Vector3(q.x * gain, q.y * gain, q.z * gain); if (float.IsNaN(angularVelocity.z)) angularVelocity = Vector3.zero; return angularVelocity; } /// /// Sets the grayscale texture for LUT-based gradient coloring. /// The texture is used by the shader to map grayscale values to gradient colors. /// /// The grayscale texture to apply. Should be a linear gradient from black to white. /// /// This method marks the texture as needing update and applies it in the next render cycle. /// The texture will be applied via MaterialPropertyBlock or Material Instance depending on the current mode. /// public void SetGrayscale(Texture2D tex) { if (rend == null) { rend = GetComponent(); if (rend == null) { Debug.LogWarning($"[Liquid] Cannot set grayscale texture on '{gameObject.name}': Renderer not found.", this); return; } } if (mpb == null) { mpb = new MaterialPropertyBlock(); } cachedGrayscaleTexture = tex; grayscaleTextureNeedsUpdate = true; CheckIfUsingInstances(); } /// /// Refreshes the instance detection and clears property blocks. /// Call this after using "Prepare for Timeline" to switch between MPB and Material Instance modes. /// /// /// This method is called automatically by PotionTextureSetup when creating or removing material instances. /// You typically don't need to call this manually. /// public void RefreshInstanceDetection() { CheckIfUsingInstances(); // Clear any existing property blocks when switching modes if (rend != null) { Material[] materials = rend.sharedMaterials; for (int i = 0; i < materials.Length; i++) { rend.SetPropertyBlock(null, i); } } // Reapply grayscale if needed if (cachedGrayscaleTexture != null) { grayscaleTextureNeedsUpdate = true; } } }