456 lines
15 KiB
C#
456 lines
15 KiB
C#
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[ExecuteInEditMode]
|
|
[RequireComponent(typeof(MeshFilter), typeof(Renderer))]
|
|
public class Liquid : MonoBehaviour
|
|
{
|
|
/// <summary>
|
|
/// Determines which time source to use for wobble calculations.
|
|
/// </summary>
|
|
public enum UpdateMode
|
|
{
|
|
/// <summary>Normal game time (affected by Time.timeScale)</summary>
|
|
Normal,
|
|
/// <summary>Unscaled time (ignores Time.timeScale, useful for pause menus)</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that all required components are present.
|
|
/// Logs clear errors if components are missing.
|
|
/// </summary>
|
|
/// <returns>True if all required components exist, false otherwise.</returns>
|
|
bool ValidateComponents()
|
|
{
|
|
bool isValid = true;
|
|
|
|
MeshFilter meshFilter = GetComponent<MeshFilter>();
|
|
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<Renderer>();
|
|
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<MeshFilter>();
|
|
if (mf != null) mesh = mf.sharedMesh;
|
|
}
|
|
if (rend == null)
|
|
rend = GetComponent<Renderer>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// ⚡ 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the cached lowest point of the mesh in world space.
|
|
/// If not cached, calculates and caches it first.
|
|
/// </summary>
|
|
/// <returns>The Y position of the lowest vertex in world space.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the grayscale texture for LUT-based gradient coloring.
|
|
/// The texture is used by the shader to map grayscale values to gradient colors.
|
|
/// </summary>
|
|
/// <param name="tex">The grayscale texture to apply. Should be a linear gradient from black to white.</param>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public void SetGrayscale(Texture2D tex)
|
|
{
|
|
if (rend == null)
|
|
{
|
|
rend = GetComponent<Renderer>();
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refreshes the instance detection and clears property blocks.
|
|
/// Call this after using "Prepare for Timeline" to switch between MPB and Material Instance modes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This method is called automatically by PotionTextureSetup when creating or removing material instances.
|
|
/// You typically don't need to call this manually.
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
}
|
|
} |