A probe-based buoyancy and wave simulation built on top of Stylized Water 3's Gerstner wave solver. The system is split into force calculation (WaterForces), force application (ApplyWaterForces variants), and boat-specific dynamics (BoatDynamics + BoatDriver).
┌─────────────────────────────────────────────────────────────────────┐
│ Per-FixedUpdate Flow │
│ │
│ BoatDriver (player/AI input) │
│ │ calls BoatDynamics.SetInput(throttle, turning) │
│ ▼ │
│ BoatDynamics.FixedUpdate() │
│ │ reads WaterForces.LastVelocity/SubmergedRatio │
│ │ (from previous frame's GetForces call) │
│ │ applies thrust, rudder, planing, banking via │
│ │ WaterForces.AddForce() / AddTorque() │
│ ▼ │
│ ApplyWaterForcesBase coroutine (yields WaitForFixedUpdate) │
│ │ calls WaterForces.GetForces(PhysicsState) │
│ │ → CalculateForces() [cached per frame] │
│ │ → runs ForceFilters │
│ │ applies result to Rigidbody / Transform │
│ ▼ │
│ Physics step complete │
└─────────────────────────────────────────────────────────────────────┘
WaterForces only calculates forces; an ApplyWaterForces* sibling applies them.GetForces() computes once per fixed frame; multiple consumers read the same result.WaitForFixedUpdate, ensuring all FixedUpdate producers (BoatDynamics, external systems) have submitted their forces before integration.File: Assets/05 Scripts/WaterPhysics/WaterForces.cs
The central force calculator. Does not move the object — it only produces a ForceResult (linear force, torque, submerged ratio).
| Concept | Description |
|---|---|
| Probes | Local-space sample points that detect submersion. Each probe has a localPos and a normalized weight. |
| Center of Mass | A configurable centerOfMass offset used as the torque reference point. |
| Hull Depth | The depth at which a probe is considered fully submerged. Buoyancy ramps linearly from 0 (at waterline) to 100% (at hullDepth). |
| Draft | Vertical offset that raises or lowers the resting point relative to the water surface. |
| PhysicsState | A struct of {position, rotation, velocity, angularVelocity} passed in by the applier each frame. |
The method runs once per fixed frame and produces a ForceResult:
1. Gravity → F = mass × 9.81 × gravityMultiplier
2. Per-Probe Loop:
a. Sample water height at probe world position
b. Compute displacement = probe.y − waterHeight − draft
c. If submerged (displacement < 0):
- Buoyancy force (proportional to depth)
- Vertical drag (opposing probe's vertical velocity)
- Exit drag (extra drag when surfacing, scales with surface proximity)
- Slamming force (stopping-force on rapid entry)
- Torque from buoyancy around CoM
- Wave push (PerProbe mode only)
3. CenterOfMass wave push (if using CenterOfMass mode)
4. Wave push post-processing pipeline:
a. Smoothing (rolling average over N frames)
b. Clamping (freeze direction/magnitude when force drops below threshold)
c. Strength multiplier + angle-based multiplier
d. Damping (exponential decay toward target)
5. Horizontal drag (anisotropic: forward vs lateral)
6. Angular drag (anisotropic: yaw vs pitch/roll)
7. Drain pending external forces (AddForce/AddTorque API)
8. Run ForceFilters
Each submerged probe contributes an upward force proportional to how deep it is below the waterline:
submersionRatio = clamp(displacement / hullDepth, 0, 1)
probeForce = submersionRatio × buoyancyForce × weight
The force is applied at the probe's world position, which naturally creates a torque that rights the object. A probe deeper on one side pushes harder, rotating the hull level.
Opposes the probe's vertical velocity (including rotational contribution):
pointVel = velocity + angularVelocity × offsetFromCoM
probeForce -= pointVel.y × verticalDrag × weight
When `exitDrag > 0` and the probe is moving **upward**, additional drag is added that scales with surface proximity (1 at waterline → 0 when fully submerged). This simulates the "suction" effect of water resisting a hull pulling out.
A stopping-force approach inspired by Avalanche's JC3 water physics:
if (downwardSpeed > threshold):
intensity = pow(downwardSpeed / rampSpeed, slammingPower)
stoppingForce = mass × downwardSpeed / dt
probeForce += stoppingForce × intensity × weight
This is inherently stable — the force can never exceed what would halt the probe's downward motion in one frame, so it cannot overshoot.
Note: Slamming is currently disabled on the player boat (
slammingRunSpeed/slammingThresholdset to zero). The visual quality remains acceptable because the gravity multiplier was increased to 3×, which creates a similar effect by driving the hull deeper in a single frame and producing stronger probe responses.
Drives the object horizontally based on wave slope and vertical water velocity. Two quality modes:
| Mode | Extra Samples | Description |
|---|---|---|
| Off | 0 | No wave push. |
| CenterOfMass | 2 total | Samples wave slope once at the center of mass. No torque. |
| PerProbe | 2 per submerged probe | Samples wave slope at every submerged probe. Generates torque. |
Slope computation: For each sample point, the water surface normal is computed by sampling the height at the point and at two offset positions (+X and +Z by normalSampleOffset). The horizontal component of the normal gives the slope direction.
Velocity modes: - Absolute — Push scales with water's vertical velocity alone. Consistent strength. - Relative — Push scales with the difference between water velocity and the object's velocity. Weaker when buoyancy tracks the surface well.
Backside behaviour controls what happens when the water height is dropping (backside of a wave):
- PositiveForce — Default: slope direction naturally produces a backward push.
- NegativeForce — Multiplied by waveBacksideMultiplier (typically negative, creating a pull-back).
- Diminish — Force zeroed.
Wave push post-processing pipeline:
smoothingFrames frames. Reduces jitter.wavePushMinMagnitude, holds the last valid magnitude and/or direction for up to wavePushClampFalloffFrames frames. Prevents wave push from flickering on/off near zero.wavePushStrength, then optionally by an angle-based curve from WavePushAngleConfig (see below).Lerp with 1 - e^(-speed × dt)) toward the target force. Smooths transitions without adding latency.Note: This pipeline operates internally on wave push forces during CalculateForces(). After all forces (including wave push) are summed, the separate ForceFilter system runs on the combined ForceResult — see the Force Filters section below.
Applied at the center of mass when any probe is submerged. Decomposes horizontal velocity into forward and lateral components relative to the object's heading:
fwdVel = Dot(horizontalVel, heading) × heading
latVel = horizontalVel − fwdVel
Each component is damped independently by forwardDrag and lateralDrag. Supports linear (F = -drag × v) and quadratic (F = -drag × |v| × v) modes.
If scaleDragWithSubmersion is true, drag scales with the submerged ratio (partial submersion = partial drag). Otherwise it's binary (any probe submerged = full drag).
Resists rotation when submerged, split into yaw (Y axis) and tilt (X + Z axes):
localAngVel = Inverse(rot) × angularVelocity
localDragTorque = (-localAngVel.x × tiltDrag, -localAngVel.y × yawDrag, -localAngVel.z × tiltDrag)
Converted back to world space and added to the torque accumulator.
Other systems can contribute forces each frame via:
AddForce(Vector3) — Adds linear force.AddTorque(Vector3) — Adds torque.AddForceAtPosition(Vector3, Vector3) — Adds force at a world position, computing the resulting torque around CoM.These are drained (reset to zero) each frame after being incorporated into the result.
Post-processors that run after all forces are calculated. Stored in a [SerializeReference] array, enabling polymorphic serialization. Built-in filters:
| Filter | Purpose |
|---|---|
| CapsizingPreventionFilter | Spring-damper that counteracts pitch/roll beyond a threshold angle. Uses SmoothStep to ramp engagement between min and max trigger angles. |
| ClampForces | Hard caps on linear force and torque magnitudes. |
| ScaleForces | Simple multiplier on force and torque (e.g., for difficulty scaling). |
| VerticalRangeFilter | Safety net preventing the object from flying too high or sinking too deep. Applies a spring force plus velocity damping when outside the allowed range. |
Custom filters subclass ForceFilter, mark [System.Serializable], and implement FilterForces(ref ForceResult, WaterForces).
File: Assets/05 Scripts/WaterPhysics/ApplyWaterForcesBase.cs
Abstract base class for force appliers. Manages a coroutine that yields WaitForFixedUpdate then calls OnApplyForcesStep(dt). This ensures all FixedUpdate producers have finished before forces are integrated.
Shared features:
- applyPosition / applyRotation — AxisMask flags to selectively enable/disable individual axes.
- dampingEpsilon — Tiny velocity decay to absorb floating-point drift (separate from water drag).
- Teleport(pos, rot, resetMomentum) — Repositions the object instantly.
- AddImpulse(Vector3) — Applies an instantaneous velocity change.
| Applier | Target | Integration | Notes |
|---|---|---|---|
| ApplyWaterForcesToDynamicRigidbody | Non-kinematic Rigidbody | AddForce / AddTorque |
Disables Rigidbody gravity (WaterForces handles it). Sets CoM and inertia. Uses BoatMaterial physic material (Assets/04 Data/Physics/BoatMaterial.physicMaterial — low friction: 0.1 dynamic/static, bounciness 0.5, friction combine = Min, bounce combine = Max). |
| ApplyWaterForcesToKinematicRigidbody | Kinematic Rigidbody | Manual via MovePosition / MoveRotation |
Includes overlap-depenetration collision. Can push dynamic bodies. |
| ApplyWaterForcesToTransform | Transform (no Rigidbody) | Manual | Includes smooth visual interpolation in Update/LateUpdate. |
File: Assets/05 Scripts/WaterPhysics/ApplyWaterForcesManualBase.cs
Base for kinematic Rigidbody and Transform appliers. Performs explicit Euler integration:
velocity += (force / mass) × dt, then position += velocity × dtinertia × mass), integrates angular velocity, and applies via axis-angle rotation to avoid gimbal lock.Supports UnityStyle (1 - damping × dt) and Exponential (e^(-damping × dt)) damping modes.
Uses an overlap-depenetration approach rather than sweep casting:
Physics.OverlapBoxNonAlloc.Physics.ComputePenetration computes the exact minimum separation vector.skinWidth).bounciness.maxCollisionIterations passes to handle multiple simultaneous contacts.This catches rotation-induced collisions (e.g., bow swinging into a rock) that pure sweep-based approaches miss.
Additionally, when receiveCollisions is enabled, OnCollisionEnter/Stay callbacks from dynamic bodies hitting the kinematic boat are forwarded as forces into WaterForces.
File: Assets/05 Scripts/WaterPhysics/System/WaterSampler.cs
Singleton that wraps Stylized Water 3's Gerstner wave computation. Key features:
ComputeHeight(x, z) results are cached per fixed frame. Repeated calls for the same coordinates return instantly._WaterPositionOffset so animated wave movement is reflected in CPU-side height queries.File: Assets/05 Scripts/CharacterControl/Boat/Shared/BoatDynamics.cs
Concrete boat dynamics engine: thrust, steering, planing, banking, and anchor. Receives input from BoatDriver subclasses via SetInput(throttle, turning).
Note: The player boat runs with
gravityMultiplierset to 3× (mass × 9.81 × 3) to prevent excessive airtime over waves.
MoveTowards with separate ramp-up and ramp-down rates.throttle < 0), turning input is inverted so the rudder feels natural.SetBoostInput(thrust, torqueReduction) adds extra thrust and optionally reduces rudder torque (for boost mechanics).| Force | Description |
|---|---|
| Thrust | direction × (curvedThrottle × thrustForce + accelBoost + boostThrust). Gated by submersion. Reversing scales by reverseThrust. |
| Rudder torque | turning × rudderTorque × rudderCurve(normalizedSpeed) × throttleBoost × mass. The rudder speed curve reduces effectiveness at low speed. |
| Acceleration boost | Extra thrust at low speed (evaluated from accelerationBoostCurve based on currentSpeed / maxSpeed). |
| Planing | Pitch torque at speed — the bow rises as the boat planes. Evaluated from planingCurve. |
| Banking | Cosmetic roll into turns. Smoothed with exponential decay. |
| Anchor spring | When anchored, a spring-damper pulls the boat toward the anchor point. Slack radius allows free movement within range. |
All tuning values come from a BoatStats SO. ApplyStats() copies values into runtime fields and overwrites WaterForces settings (drag, wave push, etc.) to keep everything in sync. This means the BoatStats SO is the single source of truth for a boat's physics parameters.
Linear drag: maxSpeed = thrust / (forwardDrag × forceScale)
Quadratic drag: maxSpeed = sqrt(thrust / (forwardDrag × forceScale))
Where thrust = throttleCurve(1.0) × thrustForce and forceScale = mass if scaleForcesWithMass.
BoatDynamics maintains a m_direction vector (horizontal-only) that lerps toward transform.forward over time. This provides a smooth heading reference for thrust application, independent of wave-induced pitching.
File: Assets/05 Scripts/CharacterControl/Boat/Shared/BoatDriver.cs
Abstract base class for boat controllers. Subclasses compute throttle and turning values and call Dynamics.SetInput(throttle, turning). Two concrete implementations:
File: Assets/05 Scripts/CharacterControl/Boat/Player Boat/PlayerBoatDriver.cs
Reads Unity InputSystem actions (keyboard or controller thumbstick). For controller input, throttle and turning are derived from the thumbstick angle using configurable dead-zone angles (fullThrottleInputAngle, fullTurningInputAngle) so the player doesn't need to be perfectly precise on the stick. Also handles game-system integration (POI registration, dialogue anchoring — auto-drops anchor during dialogue).
File: Assets/05 Scripts/CharacterControl/Boat/NPC Boat/NPCBoatDriver.cs
AI-driven boat driver with a four-state state machine and PD-controlled steering/throttle.
| State | Description |
|---|---|
| Idle | No input fed to dynamics. Still scans for chase targets. |
| FollowPath | Follows waypoints from a Route component. Supports ping-pong direction. |
| Chase | Pursues a moving chaseTarget (typically the player). Uses NavMesh pathing when available. |
| Anchor | Drops anchor via BoatDynamics.DropAnchor(). Zero input fed. |
State transitions:
- **FollowPath → Chase**: When `chaseTarget` enters `detectionRadius`.
- **Chase → FollowPath**: When target leaves `escapeRadius`, or boat exceeds `maxChaseRadius` from the route centroid. Both trigger a cooldown timer to prevent instant re-detection.
- **Any → Anchor**: Via `SetState(NPCBoatState.Anchor)`.
- **Idle → Chase**: Same detection check as FollowPath.
NPC boats follow a Route (a set of waypoints). When the Route has a baked NavMesh:
m_dynamicAvoidance): Periodically computes a live NavMesh path from the boat to the current route waypoint. The boat steers along NavMesh corners instead of straight-lining to the waypoint, dodging NavMeshObstacle objects.chaseNavMeshRefreshInterval. When within ramDistance, live NavMesh is disabled and the boat beelines for the target.When no NavMesh is available, the boat steers directly toward waypoints.
Anticipation blends the steering target toward the next waypoint as the boat approaches the current one, creating smooth arcs instead of hard turns. (Currently disabled due to NavMesh interaction issues.)
Runs in Update(), produces throttle and turning values fed into BoatDynamics.SetInput():
Steering (PD control):
proportional = steeringCurve(angle / fullAngle) × sign(angle)
derivative = yawRateDeg / yawDampingFullRate × yawDampingGain
turning = clamp(proportional − derivative, −1, 1)
The derivative term reads WaterForces.LastAngularVelocity.y (current yaw rate) and opposes the rudder to prevent overshoot. Higher yawDampingGain = smoother turns with less oscillation.
Throttle (distance × alignment):
distanceFactor = distanceCurve(distance / effectiveArrival)
alignmentFactor = alignmentCurve(dot(heading, targetDir) remapped to 0–1)
throttle = max(distanceFactor × alignmentFactor, minTurningThrottle × offHeadingFactor)
minTurningThrottle prevents the boat from stopping dead during large heading corrections.When BoatDynamics.PhysicsActive is false (BoatLOD has downgraded the boat), the NPCBoatDriver drives the Transform directly:
MoveTowards the target at fallbackMoveSpeed.SimpleWaterAlign.This ensures NPC boats keep moving visually even without full physics simulation.
File: Assets/05 Scripts/WaterPhysics/WavePushAngleConfig.cs
A serializable struct that defines angle-based wave push multipliers using dot-product bands. Each AngleBand specifies a [dotMin, dotMax] range and a multiplier:
When enabled, the dot product between the boat's forward heading and the wave push direction is evaluated against all bands. The matching band's multiplier scales the wave push force and torque. If no band matches, fallbackMultiplier is used.
File: Assets/05 Scripts/WaterPhysics/WaterProbePresets.cs
Static utility that generates common probe layouts:
| Preset | Points | Layout |
|---|---|---|
| Cross | 4 | Front, back, left, right |
| Square | 4 | Four corners |
| Triangle | 3 | Forward point + two rear points (equilateral) |
| Square8 | 8 | Four corners + four edge midpoints |
All weights start at 1.0 and are normalized by WaterForces.NormalizeProbeWeights() so they sum to 1.0.
File: Assets/05 Scripts/CharacterControl/Boat/Shared/BoatProp.cs
Generic visual driver for boat props (propellers, rudders, flags, etc.). Reads state from a parent BoatDynamics and applies rotation. Attach to each visual element that should react to boat state.
Input sources: Throttle, TurningInput, Speed (normalized), HorizontalSpeed (normalized).
Modes:
| Mode | Description |
|---|---|
| ContinuousSpin | Rotates continuously at a speed proportional to the input value. Good for fans/propellers. Supports separate ramp-up/ramp-down rates and optional "turn spin when idle" (spins slightly during turning with no throttle). |
| TargetAngle | Lerps to a target angle proportional to the input value. Good for rudders. |
Runs in LateUpdate so it reacts after all physics and input are resolved.
The player boat uses Stylized Water 3's displacement add-on to render a boat wake. Andreas implemented a system that renders boat-shaped particles on each boat. The particles are affected by the Rigidbody's velocity but are not rendered in the Game — they live on the BoatWake layer. Each boat previously had its own top-down camera to render these particles to a render texture, which was then used for water displacement.
To optimise this, only the player boat now has this camera. The orthographic size was set large (~200) so it captures the BoatWake particles from both the player and nearby NPC boats. This works because distant NPC boats are too far away for their wake to be visible anyway.
The
BoatWakelayer is very expensive — disabling it in the scene's layer visibility is recommended for editor performance.
File: Assets/05 Scripts/WaterPhysics/BoatLOD.cs
Three-tier LOD system for NPC boats:
| Tier | Method | Cost |
|---|---|---|
| FullPhysics | WaterForces + BoatDynamics + applier | Full force calculation, collision |
| TriangleSample | SimpleWaterAlign (3-point) + NPC movement | CPU wave samples, no physics |
| SinglePoint | SimpleWaterAlign (1-point) + procedural bob | Minimal |
Distance thresholds use hysteresis to prevent flickering at boundaries. When transitioning back to FullPhysics, the applier is teleported to the current visual pose and given an impulse matching the velocity at the time of downgrade.
Note: The current BoatLOD implementation is slated for a refactor. The existing approach (AddImpulse + teleport) is fragile; the planned simplification is to disable only the applier and NPC driver during cheap tiers, and move the boat on the XZ plane following its route while retaining the WaterForces calculation for visual alignment.
File: Assets/05 Scripts/WaterPhysics/SimpleWaterAlign.cs
Lightweight water alignment for LOD cheap tiers:
Both modes run in LateUpdate and apply smooth interpolation on height and rotation.
File: Assets/08 Plugins/Stylized Water 3/Runtime/WaveProfile.cs
A ScriptableObject that defines a set of Gerstner wave layers. Each Wave layer has:
- waveLength — distance between crests
- amplitude — height from base to peak
- steepness — horizontal displacement (too high causes crest looping)
- direction — travel angle in degrees
- mode — Directional (angle-based) or Radial (point-origin)
- enabled — whether the layer contributes
Global multipliers (waveLengthMultiplier, amplitudeMultiplier, steepnessMultiplier) scale all layers uniformly. Per-layer curves (waveLengthCurve, amplitudeCurve, steepnessCurve) apply additional scaling based on layer index. steepnessClamping normalizes steepness across layers to prevent crest looping.
When UpdateShaderParameters() is called, the profile writes all layer data into a lookup texture (LUT) — an 8×2 RGBAHalf texture read by the water shader on the GPU. This is the bridge between CPU-side wave data and GPU rendering. WaterSampler also reads the same profile data for CPU-side height queries.
File: Assets/04 Data/WaterPhysics/ReferenceWaveProfile.asset
The project's base wave template. WaveManager holds two profiles: - ReferenceProfile — this asset, the "source of truth" for wave shapes. - ActiveProfile — the runtime profile that WaterSampler and the water material read. Modified in place by WaveManager and WaveTweener.
The ReferenceProfile is never modified at runtime. On startup, WaveManager.Awake() clones its global properties onto the ActiveProfile. All procedural wave generation (SetToRandomWaveProfile) works by copying the ReferenceProfile's layers, rotating their directions, and jittering their amplitudes.
The ReferenceProfile has 8 layers with a specific naming convention:
| Index | Name | Purpose |
|---|---|---|
| 0 | Major Direction | Primary swell — longest wavelength (60m), highest amplitude (2.0), base direction (0°). Defines the dominant wave direction. |
| 1 | Interference Wave | Secondary swell — shorter wavelength (30m), moderate amplitude (1.0), slight angle offset (20°). Creates cross-swell interference patterns. |
| 2 | Ripples Wave #1 | Surface detail — amplitude 0.25, perpendicular direction (90°). Adds visual complexity. |
| 3 | Ripples Wave #2 | Surface detail — amplitude 0.3, diagonal direction (150°). Fills in texture from another angle. |
| 4–7 | Lerp #1–4 | Reserve slots — amplitude at MIN_AMPLITUDE (0.012), effectively silent. Used by WaveTweener as the "incoming" set during crossfade transitions. |
The 4+4 split is critical: layers 0–3 are the "current" waves the player sees and the physics system samples. Layers 4–7 are the "incoming" waves that WaveTweener fades in during a transition. Once the crossfade completes, the incoming data is copied into layers 0–3 and reserves are reset to MIN_AMPLITUDE. This is why ApplyLayersInstantly always writes to layers 0–3 and clears 4–7.
File: Assets/05 Scripts/WaterPhysics/WaveManager.cs
Singleton that controls two independent aspects of the wave system:
Controls how tall waves are overall. This is a single float (amplitudeMultiplier) on the active WaveProfile that scales all wave layers uniformly. It is not the wave shape — just the overall height multiplier.
Amplitude is calculated each frame using Inverse Distance Weighting (IDW) between the player boat's position and registered WaveAmplitudeInfluenceArea volumes (safe harbors, calm zones, etc.):
For each influence area:
weight = 1 / (normalizedDistance² + ε)
Add a virtual "ocean" contributor with weight = OceanStrength and amplitude = OceanMaxAmplitude.
targetAmplitude = Σ(weight × area.amplitude) / Σ(weight)
This creates smooth transitions: near an influence area, its calm amplitude dominates; far from all areas, the open ocean amplitude takes over. The OceanInfluenceRadius normalizes the distance calculation, and OceanStrength controls how quickly the ocean wins.
The current amplitude exponentially decays toward the target at AmplitudeBlendSpeed:
current = target + (current - target) × e^(-blendSpeed × dt)
Amplitude locking: LockGlobalAmplitude(value) freezes the amplitude at a specific value (for cutscenes, scripted moments). UnlockGlobalAmplitude() resumes IDW-based calculation. Lock state overrides everything.
Controls the shape of waves (direction, wavelength, steepness per layer) via the active WaveProfile's layer array. Transitions are handled by WaveTweener (see below).
Key API:
| Method | Description |
|---|---|
SetToRandomWaveProfile() |
Procedurally generate a profile by rotating the reference profile by a random angle and jittering amplitude by ±AmplitudeChangePercentage. Primary method for gameplay use. |
SetToRandomWaveProfileWithDirection(dir) |
Same but with a specific wave direction. Primary method for gameplay use. |
SetWaveProfileToReference() |
Snap back to the reference profile's layers. |
SetWaveProfileManually(profile) |
Apply a hand-authored profile's layers (max 4). Does not change amplitude. Debug / cutscene use only — not currently used at runtime. For when a very specific sea state is needed for a scripted moment. |
All methods support instantSet (snap immediately) or customDuration (smooth transition via WaveTweener).
File: Assets/05 Scripts/WaterPhysics/WaveAmplitudeInfluenceArea.cs
A trigger collider that registers itself with WaveManager. Defines a volume where waves should be calmer. Distance to the player is computed via Collider.ClosestPoint, handling any collider shape automatically. Each area has its own TargetAmplitude.
File: Assets/05 Scripts/WaterPhysics/WaveTweener.cs
Singleton that smoothly crossfades between wave layer sets using Stylized Water 3's 8-layer system. The active WaveProfile has 8 layer slots — WaveTweener uses layers 0–3 as the "current" set and layers 4–7 as the "incoming" set.
When CrossfadeToLayers(targetLayers, duration) is called:
MIN_AMPLITUDE.duration seconds, simultaneously:
- Fade out layers 0–3: amplitude lerps toward MIN_AMPLITUDE using FadeOutCurve.
- Fade in layers 4–7: amplitude lerps toward target amplitude using FadeInCurve.The fade-in and fade-out phases have configurable start/end points within the transition timeline (FadeOutStart/End, FadeInStart/End), so the incoming waves can start appearing before the old ones have fully faded, creating an overlap period.
If a new crossfade is requested while one is already running, the tweener: 1. Stops the current coroutine. 2. Runs a 1-second interrupt phase that fades out the reserve layers (4–7) to clean up any partial crossfade. 3. Starts a fresh crossfade to the new target.
A second interruption during the interrupt phase queues the target but does not restart — the interrupt must complete first.
File: Assets/05 Scripts/WaterPhysics/WaveProfileUtility.cs
Static helpers for copying WaveProfile.Wave layer data without allocations (CopyWaveData, ResetToEmptyLayer, CloneWaveProfile). Used by WaveManager and WaveTweener to manipulate the active profile in place.
File: Assets/05 Scripts/WaterPhysics/RotationUtils.cs
Provides DecomposeSwingTwist — splits a quaternion into swing (tilts the axis) and twist (rotates around the axis) components. Used throughout the system to preserve yaw while applying wave-induced pitch/roll.
FixedUpdate #N begins
│
├─ BoatDriver.Update() [can run in Update, sets input]
│ └─ Dynamics.SetInput(throttle, turning)
│
├─ BoatDynamics.FixedUpdate()
│ ├─ Reads WaterForces.LastVelocity (from frame N-1)
│ ├─ Reads WaterForces.SubmergedRatio (from frame N-1)
│ ├─ Throttle smoothing
│ ├─ Thrust → WaterForces.AddForce()
│ ├─ Rudder → WaterForces.AddTorque()
│ ├─ Planing → WaterForces.AddTorque()
│ ├─ Banking → WaterForces.AddTorque()
│ └─ Anchor spring → WaterForces.AddForce()
│
├─ [Other systems may also call AddForce/AddTorque here]
│
├─ ApplyWaterForces coroutine fires (yields WaitForFixedUpdate)
│ ├─ WaterForces.GetForces(state)
│ │ ├─ WaterSampler.ComputeHeight() for each probe
│ │ │ (reads wave shape from ActiveProfile, which WaveTweener
│ │ │ may be crossfading — see Update phase below)
│ │ ├─ Buoyancy, drag, slamming per submerged probe
│ │ ├─ Wave push computed and post-processed
│ │ ├─ Horizontal + angular drag
│ │ ├─ Pending external forces drained
│ │ ├─ ForceFilters run on combined result
│ │ └─ Result cached, LastVelocity/SubmergedRatio updated
│ └─ Applies force to Rigidbody / integrates manually
│
└─ FixedUpdate #N complete
Update
│
├─ WaveManager.Update()
│ ├─ Enforce GlobalSteepness on ActiveProfile
│ └─ UpdateAmplitude()
│ ├─ If amplitude locked → target = locked value
│ ├─ Else → CalculateIDWAmplitude()
│ │ ├─ For each WaveAmplitudeInfluenceArea:
│ │ │ weight = 1 / (dist²/radius² + ε)
│ │ ├─ Virtual ocean contributor (OceanStrength × OceanMaxAmplitude)
│ │ └─ target = weightedSum / totalWeight
│ └─ current = exponential decay toward target
│ → ActiveProfile.amplitudeMultiplier = current
│ → ActiveProfile.UpdateShaderParameters()
│
├─ WaveTweener coroutine (if crossfading)
│ └─ Lerp amplitude: layers 0–3 fade out, layers 4–7 fade in
│ → ActiveProfile.UpdateShaderParameters()
│ (WaterSampler reads this same profile next FixedUpdate)
│
├─ NPCBoatDriver.Update() [if NPC boat]
│ ├─ State machine transitions
│ ├─ NavMesh path refresh
│ ├─ ComputeAIInputs (PD steering/throttle)
│ ├─ Dynamics.SetInput(throttle, turning)
│ └─ FallbackMove if PhysicsActive == false
│
├─ PlayerBoatDriver.Update() [if player boat]
│ ├─ Read InputSystem actions
│ └─ Dynamics.SetInput(throttle, turning)
│
├─ ApplyWaterForcesToTransform.PerformInterpolation()
│ └─ Smooth visual position/rotation between fixed steps
│
└─ SimpleWaterAlign.LateUpdate() [LOD cheap tiers only]
Probes are local-space points sampled against the water surface each frame. A probe is submerged when its world Y position is below the water height (minus draft).
worldPos = objectPosition + objectRotation × (probe.localPos × objectScale)
waterHeight = WaterSampler.ComputeHeight(worldPos.x, worldPos.z)
displacement = worldPos.y - waterHeight - draft
submerged = displacement < 0
The displacement is clamped to [-hullDepth, 0] so buoyancy maxes out at full submersion.
All probe weights are normalized to sum to 1.0 (NormalizeProbeWeights()). This means:
- Adding more probes doesn't increase total force.
- A probe with weight 0.5 contributes half as much buoyancy/drag as one with weight 1.0 (before normalization).
- The submerged ratio is calculated as sum(submerged probe weights) / totalWeight.
More probes = more ComputeHeight calls per frame. The CenterOfMass wave push mode adds only 2 extra samples total; PerProbe adds 2 per submerged probe.
File: Assets/05 Scripts/Utility/DraggableDebugWindow.cs
A reusable runtime IMGUI window. Draggable, toggle-able via hotkey, with position and visibility persisted to EditorPrefs between play sessions (editor only). Provides styled helpers for common layouts — Header(), Row(), BannerHeader(), DrawBar() — with a dark theme that matches Unity's default look.
Used by the water physics test scenes and other debug overlays throughout the project.
File: Assets/05 Scripts/Utility/TestSceneDebugWindow.cs
A MonoBehaviour that wraps DraggableDebugWindow for zero-code test scene setup. Drop it on any GameObject, configure the Inspector fields:
UnityEvent entries that appear as clickable buttons.Toggle with F1 (configurable). Used in the water physics test scenes to let designers quickly toggle wave push modes, reset positions, switch profiles, etc. without writing code.