Skip to content

Instantly share code, notes, and snippets.

@wangbinyq
Created August 1, 2025 05:43
Show Gist options
  • Save wangbinyq/abdc35af15881708df4c493c0d3c6757 to your computer and use it in GitHub Desktop.
Save wangbinyq/abdc35af15881708df4c493c0d3c6757 to your computer and use it in GitHub Desktop.

Revisions

  1. wangbinyq created this gist Aug 1, 2025.
    373 changes: 373 additions & 0 deletions SDFWaterInteraction.cs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,373 @@
    using UnityEngine;
    using UnityEngine.Rendering;
    using WaveHarmonic.Crest.Internal;

    namespace WaveHarmonic.Crest
    {
    /// <summary>
    /// Approximates the interaction between an arbitrary SDF shape and the water.
    /// </summary>
    /// <remarks>
    /// Uses a 3D texture containing SDF data to define the interaction shape.
    /// </remarks>
    [AddComponentMenu(Constants.k_MenuPrefixInputs + "SDF Water Interaction")]
    [@HelpURL("Manual/Waves.html#adding-interaction-forces")]
    public sealed partial class SDFWaterInteraction : ManagedBehaviour<WaterRenderer>
    {
    [SerializeField, HideInInspector]
    #pragma warning disable 414
    int _Version = 0;
    #pragma warning restore 414

    [Header("SDF Settings")]

    [Tooltip("3D texture containing the SDF data. Expected to be in world space units.")]
    [GenerateAPI]
    [SerializeField]
    Texture3D _SDFTexture;

    [Tooltip("Compute shader used for SDF water interaction calculations.")]
    [GenerateAPI]
    [SerializeField]
    ComputeShader _ComputeShader;

    [Tooltip("Scale factor applied to the SDF. Allows scaling the interaction without regenerating the texture.")]
    [@Range(0.1f, 10f)]
    [GenerateAPI]
    [SerializeField]
    float _SDFScale = 1f;

    [Tooltip("Bounds of the SDF interaction in world space. Should encompass the area where the SDF has meaningful values.")]
    [GenerateAPI]
    [SerializeField]
    Bounds _InteractionBounds = new Bounds(Vector3.zero, Vector3.one * 2f);

    [Header("Interaction Parameters")]

    [Tooltip("Intensity of the forces.\n\nCan be set negative to invert.")]
    [@Range(-40f, 40f)]
    [GenerateAPI]
    [SerializeField]
    float _Weight = 1f;

    [Tooltip("Intensity of the forces from vertical motion.\n\nScales ripples generated from vertical movement.")]
    [@Range(0f, 2f)]
    [GenerateAPI]
    [SerializeField]
    float _WeightVerticalMultiplier = 0.5f;

    [Tooltip("Model parameter that can be used to modify the shape of the interaction.\n\nScales the effect of forces inside the SDF shape.")]
    [@Range(0f, 10f)]
    [GenerateAPI]
    [SerializeField]
    float _InnerShapeMultiplier = 1.55f;

    [Tooltip("Model parameter that controls the transition zone for inner shape effects.\n\nDefines the normalized distance from the surface where inner effects apply.")]
    [@Range(0f, 1f)]
    [GenerateAPI]
    [SerializeField]
    float _InnerShapeOffset = 0.109f;

    [Tooltip("Offset in direction of motion to help ripples appear in front of object.\n\nThere is some latency between applying a force to the wave simulation and the resulting waves appearing.")]
    [@Range(0f, 2f)]
    [GenerateAPI]
    [SerializeField]
    float _VelocityOffset = 0.04f;

    [Tooltip("How much to correct the position for horizontal wave displacement.\n\nCompensates for wave displacement to prevent interaction drift.")]
    [@Range(0f, 1f)]
    [GenerateAPI]
    [SerializeField]
    float _CompensateForWaveMotion = 0.45f;

    [Tooltip("Whether to improve visibility in larger LODs.\n\nBoosts output for better visibility at distance.")]
    [GenerateAPI]
    [SerializeField]
    bool _BoostLargeWaves = false;

    [Header("Limits")]

    [Tooltip("Teleport speed (km/h).\n\nIf the calculated speed is larger than this amount, the object is deemed to have teleported and the computed velocity is discarded.")]
    [GenerateAPI]
    [SerializeField]
    float _TeleportSpeed = 500f;

    [Tooltip("Outputs a warning to the console on teleport.")]
    [GenerateAPI]
    [SerializeField]
    bool _WarnOnTeleport = false;

    [Tooltip("Maximum speed clamp (km/h).\n\nUseful for controlling/limiting wake.")]
    [GenerateAPI]
    [SerializeField]
    float _MaximumSpeed = 100f;

    [Tooltip("Outputs a warning to the console on speed clamp.")]
    [GenerateAPI]
    [SerializeField]
    bool _WarnOnSpeedClamp = false;

    #pragma warning disable 414
    [Header("Debug")]

    [Tooltip("Draws debug lines at each substep position. Editor only.")]
    [SerializeField]
    bool _DebugSubsteps = false;

    [Tooltip("Draw the interaction bounds as a wireframe gizmo.")]
    [SerializeField]
    bool _DrawBounds = true;
    #pragma warning restore 414

    static class ShaderIDs
    {
    public static readonly int s_Velocity = Shader.PropertyToID("_Crest_Velocity");
    public static readonly int s_Weight = Shader.PropertyToID("_Crest_Weight");
    public static readonly int s_SDFTexture = Shader.PropertyToID("_Crest_SDFTexture");
    public static readonly int s_SDFScale = Shader.PropertyToID("_Crest_SDFScale");
    public static readonly int s_SDFBoundsMin = Shader.PropertyToID("_Crest_SDFBoundsMin");
    public static readonly int s_SDFBoundsSize = Shader.PropertyToID("_Crest_SDFBoundsSize");
    public static readonly int s_InnerShapeOffset = Shader.PropertyToID("_Crest_InnerShapeOffset");
    public static readonly int s_InnerShapeMultiplier = Shader.PropertyToID("_Crest_InnerShapeMultiplier");
    public static readonly int s_LargeWaveMultiplier = Shader.PropertyToID("_Crest_LargeWaveMultiplier");
    }

    Vector3 _Velocity;
    Vector3 _VelocityClamped;
    Vector3 _PreviousPosition;
    Vector3 _RelativeVelocity;
    Vector3 _Displacement;

    float _WeightThisFrame;

    readonly SampleCollisionHelper _SampleHeightHelper = new();
    readonly SampleFlowHelper _SampleFlowHelper = new();

    ComputeShader ComputeShader => _ComputeShader;

    Rect Rect
    {
    get
    {
    var bounds = GetWorldBounds();
    var size = new Vector2(bounds.size.x, bounds.size.z);
    var center = new Vector2(bounds.center.x, bounds.center.z);
    return new Rect(center - size * 0.5f, size);
    }
    }

    Bounds GetWorldBounds()
    {
    var worldBounds = _InteractionBounds;
    worldBounds.center = transform.TransformPoint(_InteractionBounds.center);
    worldBounds.size = Vector3.Scale(_InteractionBounds.size, transform.lossyScale) * _SDFScale;
    return worldBounds;
    }

    bool IsValidConfiguration()
    {
    if (_SDFTexture == null)
    {
    Debug.LogError("Crest: SDF Texture is not assigned.", this);
    return false;
    }

    if (_ComputeShader == null)
    {
    Debug.LogError("Crest: Compute Shader is not assigned.", this);
    return false;
    }

    return true;
    }

    private protected override System.Action<WaterRenderer> OnUpdateMethod => OnUpdate;
    void OnUpdate(WaterRenderer water)
    {
    if (!IsValidConfiguration())
    return;

    var bounds = GetWorldBounds();
    var sampleRadius = Mathf.Max(bounds.size.x, bounds.size.z) * 0.5f;

    _SampleHeightHelper.SampleDisplacement(transform.position, out _Displacement, minimumLength: sampleRadius);

    LateUpdateComputeVel(water);

    // Velocity relative to water
    _RelativeVelocity = _VelocityClamped;
    {
    _SampleFlowHelper.Sample(transform.position, out var surfaceFlow, minimumLength: sampleRadius);
    _RelativeVelocity -= new Vector3(surfaceFlow.x, 0, surfaceFlow.y);

    _RelativeVelocity.y *= _WeightVerticalMultiplier;
    }

    // Use weight from user with a multiplier to make interactions look plausible
    _WeightThisFrame = 3.75f * _Weight;

    var waterHeight = _Displacement.y + water.SeaLevel;
    LateUpdateSDFWeight(waterHeight, ref _WeightThisFrame);

    // Weighting with this value helps keep ripples consistent for different gravity values
    var gravityMul = Mathf.Sqrt(water._DynamicWavesLod.Settings._GravityMultiplier) / 5f;
    _WeightThisFrame *= gravityMul;

    _PreviousPosition = transform.position;
    }

    void LateUpdateComputeVel(WaterRenderer water)
    {
    // Compute velocity using finite difference
    _Velocity = (transform.position - _PreviousPosition) / water.DeltaTime;
    if (water.DeltaTime < 0.0001f)
    {
    _Velocity = Vector3.zero;
    }

    var speedKmh = _Velocity.magnitude * 3.6f;
    if (speedKmh > _TeleportSpeed)
    {
    // teleport detected
    _Velocity *= 0f;

    if (_WarnOnTeleport)
    {
    Debug.LogWarning("Crest: Teleport detected (speed = " + speedKmh.ToString() + "), velocity discarded.", this);
    }

    speedKmh = _Velocity.magnitude * 3.6f;
    }

    if (speedKmh > _MaximumSpeed)
    {
    // limit speed to max
    _VelocityClamped = _Velocity * _MaximumSpeed / speedKmh;

    if (_WarnOnSpeedClamp)
    {
    Debug.LogWarning("Crest: Speed (" + speedKmh.ToString() + ") exceeded max limited, clamped.", this);
    }
    }
    else
    {
    _VelocityClamped = _Velocity;
    }
    }

    void LateUpdateSDFWeight(float waterHeight, ref float weight)
    {
    var bounds = GetWorldBounds();
    var centerDepthInWater = waterHeight - transform.position.y;
    var effectiveRadius = bounds.size.y * 0.5f;

    if (centerDepthInWater >= 0f)
    {
    // Center in water - exponential fall off of interaction influence as object gets deeper
    var prop = centerDepthInWater / effectiveRadius;
    prop *= 0.5f;
    weight *= Mathf.Exp(-prop * prop);
    }
    else
    {
    // Center out of water - ramp off with square root
    var height = -centerDepthInWater;
    var heightProp = 1f - Mathf.Clamp01(height / effectiveRadius);
    weight *= Mathf.Sqrt(heightProp);
    }
    }

    private protected override void Initialize()
    {
    base.Initialize();

    if (!IsValidConfiguration())
    return;

    _Input ??= new(this);
    ILodInput.Attach(_Input, DynamicWavesLod.s_Inputs);
    _PreviousPosition = transform.position;
    }

    private protected override void OnDisable()
    {
    base.OnDisable();
    ILodInput.Detach(_Input, DynamicWavesLod.s_Inputs);
    }

    void Draw(Lod simulation, CommandBuffer buffer, RenderTargetIdentifier target, int pass = -1, float weight = 1f, int slices = -1)
    {
    if (!IsValidConfiguration())
    return;

    var waves = simulation as DynamicWavesLod;
    var timeBeforeCurrentTime = waves.TimeLeftToSimulate;

    var wrapper = new PropertyWrapperCompute(buffer, ComputeShader, 0);

    #if UNITY_EDITOR
    // Draw debug lines at each substep position
    if (_DebugSubsteps)
    {
    var col = 0.7f * (Time.frameCount % 2 == 1 ? Color.green : Color.red);
    var pos = transform.position - _Velocity * (timeBeforeCurrentTime - _VelocityOffset);
    var right = Vector3.Cross(Vector3.up, _Velocity.normalized);
    Debug.DrawLine(pos - right + transform.up, pos + right + transform.up, col, 0.5f);
    }
    #endif

    // Reconstruct the position of this input at the current substep time
    var offset = _Velocity * (timeBeforeCurrentTime - _VelocityOffset);
    var displacement = _Displacement.XNZ() * _CompensateForWaveMotion;
    wrapper.SetVector(Crest.ShaderIDs.s_Position, transform.position - offset - displacement);

    var bounds = GetWorldBounds();

    wrapper.SetFloat(ShaderIDs.s_Weight, _WeightThisFrame);
    wrapper.SetFloat(ShaderIDs.s_SDFScale, _SDFScale);
    wrapper.SetVector(ShaderIDs.s_SDFBoundsMin, bounds.min);
    wrapper.SetVector(ShaderIDs.s_SDFBoundsSize, bounds.size);
    wrapper.SetFloat(ShaderIDs.s_InnerShapeOffset, _InnerShapeOffset);
    wrapper.SetFloat(ShaderIDs.s_InnerShapeMultiplier, _InnerShapeMultiplier);
    wrapper.SetFloat(ShaderIDs.s_LargeWaveMultiplier, _BoostLargeWaves ? 2f : 1f);
    wrapper.SetVector(ShaderIDs.s_Velocity, _RelativeVelocity);
    wrapper.SetTexture(ShaderIDs.s_SDFTexture, _SDFTexture);

    wrapper.SetTexture(Crest.ShaderIDs.s_Target, target);

    var threads = simulation.Resolution / Lod.k_ThreadGroupSize;
    wrapper.Dispatch(threads, threads, slices);
    }

    #if UNITY_EDITOR
    void OnDrawGizmosSelected()
    {
    if (_DrawBounds)
    {
    Gizmos.color = Color.cyan;
    Gizmos.matrix = transform.localToWorldMatrix;
    Gizmos.DrawWireCube(_InteractionBounds.center, _InteractionBounds.size * _SDFScale);
    }
    }
    #endif
    }

    partial class SDFWaterInteraction
    {
    Input _Input;

    sealed class Input : ILodInput
    {
    readonly SDFWaterInteraction _Input;
    public Input(SDFWaterInteraction input) => _Input = input;
    public bool Enabled => _Input.enabled;
    public bool IsCompute => true;
    public int Queue => 0;
    public int Pass => -1;
    public Rect Rect => _Input.Rect;
    public MonoBehaviour Component => _Input;
    public float Filter(WaterRenderer water, int slice) => 1f;
    public void Draw(Lod lod, CommandBuffer buffer, RenderTargetIdentifier target, int pass = -1, float weight = 1, int slice = -1) => _Input.Draw(lod, buffer, target, pass, weight, slice);
    }
    }
    }
    163 changes: 163 additions & 0 deletions gistfile1.txt
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,163 @@
    #pragma kernel CrestExecute

    #include "HLSLSupport.cginc"

    #include "Packages/com.waveharmonic.crest/Runtime/Shaders/Library/Macros.hlsl"
    #include "Packages/com.waveharmonic.crest/Runtime/Shaders/Library/Constants.hlsl"
    #include "Packages/com.waveharmonic.crest/Runtime/Shaders/Library/Globals.hlsl"
    #include "Packages/com.waveharmonic.crest/Runtime/Shaders/Library/Helpers.hlsl"
    #include "Packages/com.waveharmonic.crest/Runtime/Shaders/Library/InputsDriven.hlsl"
    #include "Packages/com.waveharmonic.crest/Runtime/Shaders/Library/Cascade.hlsl"

    RWTexture2DArray<float2> _Crest_Target;
    Texture3D<float> _Crest_SDFTexture;
    SamplerState sampler_Crest_SDFTexture;

    CBUFFER_START(CrestPerWaterInput)
    float3 _Crest_Position;
    float3 _Crest_Velocity;
    float _Crest_SimDeltaTime;
    float _Crest_Weight;
    float _Crest_SDFScale;
    float3 _Crest_SDFBoundsMin;
    float3 _Crest_SDFBoundsSize;
    float _Crest_InnerShapeOffset;
    float _Crest_InnerShapeMultiplier;
    float _Crest_LargeWaveMultiplier;
    CBUFFER_END

    m_CrestNameSpace

    // Resolution-aware interaction falloff function
    float InteractionFalloff(float a, float x)
    {
    float ax = a * x;
    float ax2 = ax * ax;
    float ax4 = ax2 * ax2;

    return ax / (1.0 + ax2 * ax4);
    }

    // Sample SDF from 3D texture and calculate normal
    void SampleSDFAndNormal(float3 worldPos, out float sdf, out float2 normal2D)
    {
    // Convert world position to texture coordinates
    float3 localPos = (worldPos - _Crest_SDFBoundsMin) / _Crest_SDFBoundsSize;

    // Check if we're outside the texture bounds
    if (any(localPos < 0.0) || any(localPos > 1.0))
    {
    normal2D = float2(1.0, 0.0);
    sdf = length(worldPos.xz - _Crest_Position.xz) * _Crest_SDFScale; // Return large positive distance
    return;
    }

    // Sample the SDF value
    sdf = _Crest_SDFTexture.SampleLevel(sampler_Crest_SDFTexture, localPos, 0).r;
    sdf *= _Crest_SDFScale;

    // Calculate gradient using central differences for normal calculation
    uint width, height, depth;
    _Crest_SDFTexture.GetDimensions(width, height, depth);
    float3 texelSize = 1.0 / float3(width, height, depth);

    float3 gradient;
    gradient.x = _Crest_SDFTexture.SampleLevel(sampler_Crest_SDFTexture, localPos + float3(texelSize.x, 0, 0), 0).r
    - _Crest_SDFTexture.SampleLevel(sampler_Crest_SDFTexture, localPos - float3(texelSize.x, 0, 0), 0).r;
    gradient.y = _Crest_SDFTexture.SampleLevel(sampler_Crest_SDFTexture, localPos + float3(0, texelSize.y, 0), 0).r
    - _Crest_SDFTexture.SampleLevel(sampler_Crest_SDFTexture, localPos - float3(0, texelSize.y, 0), 0).r;
    gradient.z = _Crest_SDFTexture.SampleLevel(sampler_Crest_SDFTexture, localPos + float3(0, 0, texelSize.z), 0).r
    - _Crest_SDFTexture.SampleLevel(sampler_Crest_SDFTexture, localPos - float3(0, 0, texelSize.z), 0).r;

    gradient /= (2.0 * texelSize * _Crest_SDFScale);

    // Project gradient to 2D (XZ plane) and normalize for water surface interaction
    float2 gradientXZ = gradient.xz;
    float gradientLength = length(gradientXZ);
    normal2D = gradientLength > 0.001 ? gradientXZ / gradientLength : float2(1.0, 0.0);
    }

    void Execute(uint3 id)
    {
    const Cascade cascade = Cascade::MakeDynamicWaves(id.z);

    // Early exit based on scale - use a representative size similar to sphere radius
    float effectiveRadius = max(_Crest_SDFBoundsSize.x, max(_Crest_SDFBoundsSize.y, _Crest_SDFBoundsSize.z)) * 0.5;
    if (_Crest_LargeWaveMultiplier * effectiveRadius < cascade._Texel)
    {
    return;
    }

    float2 positionXZ = cascade.IDToWorld(id.xy);
    float2 offsetXZ = positionXZ - _Crest_Position.xz;

    // Quick bounds check for culling - use distance-based culling like sphere
    float3 boundsCenter = _Crest_SDFBoundsMin + _Crest_SDFBoundsSize * 0.5;
    float2 boundsCenterXZ = boundsCenter.xz;
    float boundsRadius = length(_Crest_SDFBoundsSize.xz) * 0.5;

    // Culling based on distance, similar to sphere (with 4x radius buffer)
    if (length(positionXZ - boundsCenterXZ) > boundsRadius * 4.0)
    {
    return;
    }

    // Feather at edges of LOD to reduce streaking
    half weight = _Crest_Weight * FeatherWeightFromUV(cascade.WorldToUV(positionXZ).xy, 0.1);

    // Check we are within bounds
    if (weight <= 0.0)
    {
    return;
    }

    float minimumWavelength = Cascade::Make(id.z)._MaximumWavelength * 0.5;

    // Sample SDF at the water surface level
    float3 worldPos = float3(positionXZ.x, _Crest_Position.y, positionXZ.y);
    float sdf;
    float2 sdfNormal;
    SampleSDFAndNormal(worldPos, sdf, sdfNormal);

    // Push in same direction as velocity inside SDF shape, and opposite direction outside
    float verticalForce = 0.0;
    {
    verticalForce = -_Crest_Velocity.y;

    // Range / radius of interaction force
    const float a = 1.67 / minimumWavelength;
    verticalForce *= InteractionFalloff(a, sdf);
    }

    // Push water up in direction of motion, pull down behind
    float horizontalForce = 0.0;
    if (sdf > 0.0 || sdf < -effectiveRadius * _Crest_InnerShapeOffset)
    {
    // Range / radius of interaction force
    const float a = 1.43 / minimumWavelength;

    // Invert within SDF shape, to balance / negate forces applied outside of shape
    float forceSign = sign(sdf);

    horizontalForce = forceSign * dot(sdfNormal, _Crest_Velocity.xz) * InteractionFalloff(a, abs(sdf));

    // If inside SDF shape, add an additional weight
    if (sdf < 0.0)
    {
    horizontalForce *= _Crest_InnerShapeMultiplier;
    }
    }

    // Add to velocity (y-channel) to accelerate water. Magic number was the default
    // value for _Strength which has been removed.
    float acceleration = weight * (verticalForce + horizontalForce) * 0.2;

    // Helps interaction to work at different scales
    acceleration /= minimumWavelength;

    _Crest_Target[id] = float2(_Crest_Target[id].x, _Crest_Target[id].y + acceleration * _Crest_SimDeltaTime);
    }

    m_CrestNameSpaceEnd

    m_CrestInputKernelDefault(Execute)