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;
}
}
}