Files
2026-09-29 15:35:09 +02:00

1010 lines
42 KiB
HLSL

#ifndef WATER_INCLUDED
#define WATER_INCLUDED
// Animated water: the exact noise pattern doesn't matter, use the cheaper hash
#define NOISES_FAST_HASH
#include "Assets/_Content/Shaders/Include/Noises.hlsl"
// ------------------------------------------------------------
// WATER - ENKI BILAL STYLE (painted / pencil-sketch water)
// ------------------------------------------------------------
// ============================================================
// TUNING
// ============================================================
#define WATER_FLOW_DIR float2(0.55, 0.20)
#define WATER_FLOW_SPEED 0.008
#define WATER_WARP_STRENGTH 2.0
#define WATER_MORPH_SCALE 0.07
#define WATER_MORPH_STRENGTH 1.6
#define WATER_MORPH_SPEED 0.10
#define WATER_BLOTCH_SCALE_A 0.095
#define WATER_BLOTCH_SCALE_B 0.140
#define WATER_BLOTCH_SCALE_C 0.190
// Swell / surface motion (vertical vertex displacement)
#define WATER_WAVE_AMPLITUDE 0.1 // swell height, world units (rough scale)
#define WATER_WAVE_SPEED 0.45
#define WATER_RIPPLE_AMPLITUDE 0.6 // mid-scale ripples on top of the swell
#define WATER_RIPPLE_SPEED 1.2
#define WATER_WAVELET_AMPLITUDE 0.35 // dense small wavelets everywhere
#define WATER_WAVELET_SPEED 2.4
#define WATER_LINE_WIDTH_MAIN 1.3
#define WATER_LINE_WIDTH_SECOND 1.1
#define WATER_LINE_WIDTH_HORIZON 2.8 // extra thick -> more stable, less shimmer
// Pencil / painted texture
#define WATER_CRAYON_SCALE 2.2
#define WATER_CRAYON_JITTER 0.10 // distorts blotch edges (scribble look)
#define WATER_WASH_SCALE 0.85 // large-scale "mixing paint" color variation
// Detail layers fade out beyond this LOD value (0 = horizon, 1 = near camera)
#define WATER_LOD_THRESHOLD 0.08
// Always-on fine grain (NOT gated by LOD -- meant to stay visible even
// at the horizon, where the surface otherwise reads as a flat wash).
// Cheap value noise (see WaterCheapValueNoise below), two octaves at
// different scales to avoid an obviously repeating pattern.
#define WATER_FINE_GRAIN_SCALE_A 6.0
#define WATER_FINE_GRAIN_SCALE_B 23.0
#define WATER_FINE_GRAIN_WEIGHT_A 0.6
#define WATER_FINE_GRAIN_WEIGHT_B 0.4
#define WATER_FINE_GRAIN_STRENGTH 0.03
// "Vat" effect: the water level rises with distance from the camera,
// like standing at the bottom of a giant tub instead of an open
// ocean. Purely geometric (vertex-only), meant to feel subtly wrong
// rather than obviously stylized -- keep WATER_BOWL_STRENGTH modest
// unless you want it to read as an overt visual gag.
#define WATER_BOWL_STRENGTH 2 // max vertical rise at the horizon, world units
#define WATER_BOWL_RADIUS 60.0 // distance over which the rise ramps up
#define WATER_BOWL_POWER 1.7 // >1 = stays flat near the boat, rises more sharply toward the edge
// ============================================================
// UTILS
// ============================================================
float WaterPosterize(float v, float steps)
{
return floor(saturate(v) * steps) / steps;
}
float2 WaterRotate(float2 p, float angle)
{
float s = sin(angle);
float c = cos(angle);
return float2(p.x * c - p.y * s, p.x * s + p.y * c);
}
// Thin line centered on coord == round(coord)
float WaterStroke(float coord, float thickness, float softness)
{
float d = abs(frac(coord) - 0.5);
return saturate(1.0 - smoothstep(thickness, thickness + softness, d));
}
float WaterHash(float n)
{
return frac(sin(n) * 43758.5453123);
}
// Cheap value noise: 4 hash lookups + bilinear interpolation, no FBM
// octaves. Much cheaper than FBMNoise3D -- good enough for fine grain
// where you don't need the extra octave detail, just something to
// break up flat color.
float WaterCheapValueNoise(float2 uv)
{
float2 i = floor(uv);
float2 f = frac(uv);
float a = WaterHash(i.x + i.y * 57.0);
float b = WaterHash(i.x + 1.0 + i.y * 57.0);
float c = WaterHash(i.x + (i.y + 1.0) * 57.0);
float d = WaterHash(i.x + 1.0 + (i.y + 1.0) * 57.0);
f = f * f * (3.0 - 2.0 * f); // smoothstep interpolation
return lerp(lerp(a, b, f.x), lerp(c, d, f.x), f.y);
}
float2 WaterDomainWarp(float2 uv, float scale, float strength, float speed, float seed)
{
float t = _Time.y;
float wx = FBMNoise3D(float3(uv * scale + float2(t * speed, 0.0), seed), 1.0, 2, 2.0, 0.5) - 0.5;
float wy = FBMNoise3D(float3(uv * scale + float2(0.0, t * speed * 0.8), seed + 30.0), 1.0, 2, 2.0, 0.5) - 0.5;
return uv + float2(wx, wy) * strength;
}
// ============================================================
// 1) FLOW FIELD (color blotches)
// ============================================================
// Distortion shared by flowA/flowB/flowC (same scale/strength/speed for
// all three) so it only gets evaluated once per vertex/pixel instead of
// once per field.
float2 WaterSharedFlowWarp(float2 uv)
{
float2 morphed = WaterDomainWarp(uv, WATER_MORPH_SCALE, WATER_MORPH_STRENGTH, WATER_MORPH_SPEED, 5.0);
float warpX = FBMNoise3D(float3(uv * 0.05, 40.0), 1.0, 2, 2.0, 0.5) - 0.5;
float warpY = FBMNoise3D(float3(uv * 0.05, 80.0), 1.0, 2, 2.0, 0.5) - 0.5;
return morphed + float2(warpX, warpY) * WATER_WARP_STRENGTH;
}
// offset decorrelates flowA/flowB/flowC from each other even though
// they now share the same warped UV (cheap: no extra noise taps).
float WaterFlowField(float2 warpedUV, float2 offset, float scale, float2 dir, float speedMul, float seed)
{
float t = _Time.y;
float2 flowUV = warpedUV + offset + dir * (t * WATER_FLOW_SPEED * speedMul);
return FBMNoise3D(float3(flowUV * scale, seed), 1.0, 3, 2.0, 0.55);
}
// ============================================================
// 2) THIN OUTLINES
// ============================================================
float WaterThinContour(float field, float level, float pixelWidth)
{
float fw = fwidth(field) * pixelWidth;
fw = max(fw, 1e-5);
return 1.0 - smoothstep(0.0, fw, abs(field - level));
}
float WaterCurrentOutlines(float2 uv, float flowA, float flowB, float flowC)
{
float outlines = 0.0;
outlines += WaterThinContour(flowA, 0.42, WATER_LINE_WIDTH_MAIN);
outlines += WaterThinContour(flowA, 0.58, WATER_LINE_WIDTH_MAIN);
outlines += WaterThinContour(flowB, 0.50, WATER_LINE_WIDTH_MAIN * 0.85);
outlines += WaterThinContour(flowC, 0.46, WATER_LINE_WIDTH_MAIN * 0.65);
outlines = saturate(outlines);
// Break the lines into irregular hand-drawn segments instead of a
// continuous, perfectly-vector contour.
float cellA = floor(uv.x * 2.4) + floor(uv.y * 2.4) * 57.0;
float cellB = floor(uv.x * 2.4 + 0.5) + floor(uv.y * 2.4 + 0.5) * 61.0;
float breakup = max(WaterHash(cellA), WaterHash(cellB) * 0.6);
outlines *= saturate(breakup * 1.6);
return outlines;
}
float WaterSecondaryInkTaches(float2 uv)
{
float2 morphed = WaterDomainWarp(uv, 0.09, 1.2, 0.07, 130.0);
float2 flowUV = morphed + WATER_FLOW_DIR * (_Time.y * WATER_FLOW_SPEED * 0.5);
float field = FBMNoise3D(float3(flowUV * 0.16, 99.0), 1.0, 2, 2.0, 0.55);
float outlines = 0.0;
outlines += WaterThinContour(field, 0.38, WATER_LINE_WIDTH_SECOND);
outlines += WaterThinContour(field, 0.62, WATER_LINE_WIDTH_SECOND);
return saturate(outlines);
}
// ============================================================
// 3) HORIZON: fewer lines, faster, more stable
// ============================================================
float WaterHorizonField(float2 uv, float rowFrequency, float speed, float seed)
{
float t = _Time.y;
float2 suv = uv * float2(rowFrequency * 0.05, rowFrequency);
suv.y += t * speed;
float wobble = FBMNoise3D(float3(uv * 0.02, seed + 5.0), 1.0, 2, 2.0, 0.5) - 0.5;
suv.x += wobble * 1.4;
return FBMNoise3D(float3(suv, seed), 1.0, 3, 2.0, 0.55);
}
// distFactor: 0 = near the camera, 1 = far toward the horizon
float WaterHorizonLines(float2 uv, float3 positionWS, out float distFactor)
{
float camDist = distance(positionWS, _WorldSpaceCameraPos);
distFactor = saturate(camDist / 55.0);
// Low row frequency (wide spacing) avoids the "railway track"
// strobing effect that high-frequency lines produce in perspective.
float rowFrequency = 0.55;
float widthNoise = FBMNoise3D(float3(uv * 0.02, 90.0), 1.0, 2, 2.0, 0.5);
float baseWidth = WATER_LINE_WIDTH_HORIZON * lerp(0.7, 1.3, widthNoise);
// Progressive thickness: already thick near the player (by design),
// slightly thicker/denser still toward the horizon.
float width = baseWidth * lerp(3.2, 4.6, distFactor);
float lines = WaterThinContour(WaterHorizonField(uv, rowFrequency, 0.18, 20.0), 0.48, width);
return saturate(lines);
}
// ============================================================
// 4) PENCIL / PAINT: edge jitter + color variation
// ============================================================
// Distorts the tone value before quantization -> irregular blotch
// edges (scribble look) instead of perfectly smooth noise curves.
float WaterCrayonJitter(float2 uv, float seed)
{
float n1 = FBMNoise3D(float3(uv * WATER_CRAYON_SCALE, seed), 1.0, 2, 2.0, 0.55);
float n2 = FBMNoise3D(float3(uv * WATER_CRAYON_SCALE * 2.3, seed + 7.0), 1.0, 2, 2.0, 0.5);
return (n1 - 0.5) * 0.7 + (n2 - 0.5) * 0.3;
}
// Large non-posterized color patch: simulates pigment bleeding/mixing
// in some areas, layered on top of the sharp-edged blotches.
float3 WaterColorWash(float2 uv, float3 warmTint, float3 coolTint)
{
float2 morphed = WaterDomainWarp(uv, 0.04, 3.0, 0.05, 250.0);
float wash = FBMNoise3D(float3(morphed * WATER_WASH_SCALE * 0.06, 300.0), 1.0, 3, 2.0, 0.55);
return lerp(coolTint, warmTint, wash);
}
// Per-channel noise -> fine hue variation rather than plain brightness
// variation. Sells the "reacting pigments" look instead of a flat,
// uniform color.
float3 WaterColorNoise(float2 uv, float scale, float seed)
{
float r = FBMNoise3D(float3(uv * scale, seed), 1.0, 2, 2.0, 0.55);
float g = FBMNoise3D(float3(uv * scale, seed + 11.0), 1.0, 2, 2.0, 0.55);
float b = FBMNoise3D(float3(uv * scale, seed + 23.0), 1.0, 2, 2.0, 0.55);
return float3(r, g, b) - 0.5;
}
// ============================================================
// 5) OIL SLICK: multi-tone gradient (iridescent/grimy) instead of a
// plain white-blue highlight. Simulates "petrol on water": black
// -> algae -> iridescent violet -> rust -> dirty ochre.
// ============================================================
float3 WaterOilGradient(float t)
{
float3 c0 = float3(0.006, 0.014, 0.018); // near-black oily base
float3 c1 = float3(0.020, 0.130, 0.115); // dark algae green
float3 c2 = float3(0.130, 0.070, 0.190); // violet / iridescent oil sheen
float3 c3 = float3(0.360, 0.170, 0.060); // rust / oxidation
float3 c4 = float3(0.430, 0.400, 0.260); // dirty ochre, near-highlight
float3 col = lerp(c0, c1, smoothstep(0.0, 0.30, t));
col = lerp(col, c2, smoothstep(0.24, 0.52, t));
col = lerp(col, c3, smoothstep(0.48, 0.74, t));
col = lerp(col, c4, smoothstep(0.72, 1.00, t));
return col;
}
// Moon reflection "road": view/light half-vector highlight, same
// approach as a real moonlit reflection (soft, wave-driven, no hard
// geometric edges). Two symmetric lobes -- one toward the moon
// direction, one toward the exact opposite -- so it reads on both
// sides of the boat instead of only where the surface happens to
// face the light. MoonRotationDegrees is meant to be exposed as a
// Shader Graph input and matched to your actual moon/light direction.
#define WATER_MOONBAND_ELEVATION 0.5 // 0..1, treat as a rough vertical component before normalizing
#define WATER_MOONBAND_SHARPNESS 4.5 // higher = tighter, more contained road
#define WATER_MOONBAND_STEPS 3.0
#define WATER_MOONBAND_TINT float3(0.85, 0.66, 0.40)
float WaterMoonBandMask(float2 uv, float3 positionWS, float angleDeg, float2 grad, out float core, out float banded)
{
float angle = radians(angleDeg);
float3 lightDir = normalize(float3(cos(angle), WATER_MOONBAND_ELEVATION, sin(angle)));
float3 viewDir = normalize(_WorldSpaceCameraPos - positionWS);
// Approximate surface normal from the same wave gradient used for
// the hachures, so the road picks up the actual swell/wavelet
// relief instead of a perfectly flat mirror.
float3 normalApprox = normalize(float3(-grad.x, 1.0, -grad.y));
// Two lobes: one facing the moon, one facing directly away from
// it -- keeps the specular softness/quality but reads on both
// sides of the boat instead of only one.
float3 halfVecFront = normalize(viewDir + lightDir);
float3 halfVecBack = normalize(viewDir - lightDir);
float alignFront = dot(normalApprox, halfVecFront);
float alignBack = dot(normalApprox, halfVecBack);
float alignment = saturate(max(alignFront, alignBack));
// Irregular boundary: warp the alignment test with coarse noise
// so the road's edge isn't a perfectly smooth curve.
float lobes = FBMNoise3D(float3(uv * float2(0.08, 0.08), 950.0), 1.0, 2, 2.0, 0.55) - 0.5;
alignment = saturate(alignment + lobes * 0.12);
// Wide and soft far away, tapering tighter as it comes toward the
// camera -- a real moon road spreads out near the horizon.
float camDist = distance(positionWS, _WorldSpaceCameraPos);
float distFactor = saturate(camDist / 55.0); // 0 near camera, 1 at the horizon
float sharpness = lerp(WATER_MOONBAND_SHARPNESS * 3.0, WATER_MOONBAND_SHARPNESS * 0.18, distFactor);
float body = pow(alignment, sharpness);
core = pow(alignment, sharpness * 2.5);
// Discrete painted steps instead of a smooth glow -- reads like
// engraved/drawn highlight bands rather than a photographic bloom.
banded = WaterPosterize(saturate(body * 1.6), WATER_MOONBAND_STEPS);
return saturate(body);
}
// Scattered dark patches, randomly placed across the whole surface
// (independent of the main path's position). Same organic-edge idea
// as the path, but random blobs instead of a directed channel --
// reads like isolated deep/oily pockets rather than a single feature.
#define WATER_SCATTERED_DARK_LOW 0.48
#define WATER_SCATTERED_DARK_HIGH 0.62
#define WATER_SCATTERED_DARK_OPACITY 0.65
#define WATER_AMBIENT_LIGHT_STRENGTH 0.16 // moon-facing side
#define WATER_AMBIENT_DARK_STRENGTH 0.90 // away side, brutal now -- meant to force the player to want to turn toward the moon
#define WATER_AMBIENT_DARK_CURVE 0.22 // lower = darkens faster even close to the moon-facing side
#define WATER_AMBIENT_CRUSH_STRENGTH 0.55 // second, harder pass pushing the far side toward pure black
float WaterScatteredDarkPatches(float2 uv)
{
float field = FBMNoise3D(float3(uv * 0.11, 970.0), 1.0, 2, 2.0, 0.55);
float edgeJitter = FBMNoise3D(float3(uv * 0.5, 975.0), 1.0, 1, 2.0, 0.5) - 0.5;
return smoothstep(WATER_SCATTERED_DARK_LOW, WATER_SCATTERED_DARK_HIGH, field + edgeJitter * 0.14);
}
// Rust/grime streaks: elongated blotches, dirtier-looking than the
// regular water blotches.
float WaterGrimeStreaks(float2 uv)
{
float2 stretched = uv * float2(1.0, 3.2); // stretch -> vertical/diagonal streaks
float2 morphed = WaterDomainWarp(stretched, 0.05, 1.0, 0.03, 310.0);
float field = FBMNoise3D(float3(morphed * 0.05, 400.0), 1.0, 2, 2.0, 0.55);
return smoothstep(0.58, 0.90, field);
}
// ============================================================
// 5) SWELL: visible vertical displacement, otherwise the plane
// looks frozen.
// ============================================================
// Wavelet field alone (extracted so it can be reused by the
// cross-hatch layer below).
float WaterWaveletField(float2 uv)
{
float t = _Time.y;
float waveletA = FBMNoise3D(float3(uv * 0.9 + float2(t * WATER_WAVELET_SPEED * 0.20, -t * WATER_WAVELET_SPEED * 0.13), 520.0), 1.0, 2, 2.0, 0.5) - 0.5;
float waveletB = FBMNoise3D(float3(uv * 1.6 + float2(-t * WATER_WAVELET_SPEED * 0.17, t * WATER_WAVELET_SPEED * 0.24), 525.0), 1.0, 2, 2.0, 0.5) - 0.5;
return waveletA * 0.6 + waveletB * 0.4;
}
float WaterWaveDisplacement(float2 uv)
{
float t = _Time.y;
// Large, slow, organic swell (global bas-relief that "breathes")
float swell = FBMNoise3D(float3(uv * 0.02 + WATER_FLOW_DIR * t * WATER_WAVE_SPEED * 0.06, 500.0), 1.0, 3, 2.0, 0.5) - 0.5;
// Second swell layer, different direction/speed -> avoids a
// single repeating pattern
float swell2 = FBMNoise3D(float3(uv * 0.035 - WATER_FLOW_DIR.yx * t * WATER_WAVE_SPEED * 0.045, 505.0), 1.0, 3, 2.0, 0.5) - 0.5;
// Faster small-scale ripples layered on top, for lively detail
float ripple = FBMNoise3D(float3(uv * 0.25 + float2(t * WATER_RIPPLE_SPEED * 0.15, t * WATER_RIPPLE_SPEED * 0.11), 510.0), 1.0, 3, 2.0, 0.5) - 0.5;
// Dense small wavelets everywhere, to break up the "flat plane"
// look even up close
float wavelets = WaterWaveletField(uv);
return swell * WATER_WAVE_AMPLITUDE + swell2 * WATER_WAVE_AMPLITUDE * 0.6
+ ripple * WATER_RIPPLE_AMPLITUDE
+ wavelets * WATER_WAVELET_AMPLITUDE;
}
// Cheap stand-in for WaterWaveDisplacement, used for hatching, glint
// and chiaroscuro. Only the large swell and the fine wavelets
// contribute: the wavelets are what give this an actually-measurable
// local slope (the raw swell alone varies far too slowly to produce
// any visible gradient within a normal camera frame).
float WaterWaveHachureHeight(float2 uv)
{
float t = _Time.y;
float swell = FBMNoise3D(float3(uv * 0.02 + WATER_FLOW_DIR * t * WATER_WAVE_SPEED * 0.06, 500.0), 1.0, 1, 2.0, 0.5) - 0.5;
float wavelets = WaterWaveletField(uv);
return swell * WATER_WAVE_AMPLITUDE + wavelets * WATER_WAVELET_AMPLITUDE;
}
// Local slope via forward differences (center + x + y taps). Used to
// orient and densify the hatching based on the swell relief, like a
// classic engraving: steeper slope -> denser hatching. Also reused
// for the moon glint and the chiaroscuro shading below.
float2 WaterWaveGradient(float2 uv, float eps)
{
float h0 = WaterWaveHachureHeight(uv);
float hR = WaterWaveHachureHeight(uv + float2(eps, 0.0));
float hU = WaterWaveHachureHeight(uv + float2(0.0, eps));
return float2((hR - h0) / eps, (hU - h0) / eps);
}
// Hatching that follows the swell relief: lines run along the swell's
// contour lines, denser/cross-hatched on steep slopes -> reads as
// "engraved" volume instead of a stamped-on pattern.
float WaterWaveHachures(float2 uv, float2 grad)
{
float slope = saturate(length(grad) * 1.4);
// Lines run along the slope (perpendicular to the gradient)
float angle = atan2(grad.y, grad.x) + 1.5707963;
float2 rotated = WaterRotate(uv, -angle);
float freq = 5.5;
float thickness = lerp(0.10, 0.26, slope);
float lineMain = WaterStroke(rotated.y * freq, thickness, thickness * 0.6);
// Cross-hatching only where the slope is steep (like a denser
// engraved shadow area)
float crossAmount = smoothstep(0.35, 0.75, slope);
float lineCross = WaterStroke(rotated.x * freq * 1.35, thickness * 0.75, thickness * 0.45) * crossAmount;
float mask = smoothstep(0.06, 0.30, slope);
return saturate((lineMain + lineCross) * mask);
}
// ============================================================
// 6) PENCIL HATCHING REACTING TO THE WAVELETS
// Not a grid: short, broken strokes (like pencil marks), waved by
// noise, oriented by the local relief -> reads as "hand-drawn"
// rather than a technical pattern. Density reacts to the wavelets.
// ============================================================
#define WATER_HATCH_SCALE 48.0 // small, fine strokes
#define WATER_HATCH_THICKNESS 0.028
#define WATER_HATCH_JITTER 2.2 // waviness along the stroke
#define WATER_HATCH_DASH_FREQ 2.6 // stroke break-up frequency
#define WATER_HATCH_OPACITY 0.45
// A stroke along "along", positioned by "across", broken into short
// segments with pseudo-random length/visibility -> pencil stroke
// rather than a continuous line.
float WaterHatchStroke(float alongCoord, float acrossCoord, float thickness, float dashFreq, float seed)
{
float lineMask = WaterStroke(acrossCoord, thickness, thickness * 0.9);
float cell = floor(alongCoord * dashFreq + seed);
float rnd = WaterHash(cell);
float localFrac = frac(alongCoord * dashFreq + seed);
float visible = step(0.28, rnd); // some segments are empty -> discontinuous strokes
float strokeLen = lerp(0.30, 0.90, frac(rnd * 7.0)); // variable stroke length
float coverage = step(localFrac, strokeLen);
return lineMask * visible * coverage;
}
float WaterWaveletHachures(float2 uv)
{
float wave = WaterWaveletField(uv);
// Local wavelet slope via forward differences, reusing `wave` as
// the center tap.
float eps = 0.10;
float wR = WaterWaveletField(uv + float2(eps, 0.0));
float wU = WaterWaveletField(uv + float2(0.0, eps));
float2 grad = float2((wR - wave) / eps, (wU - wave) / eps);
float slope = length(grad);
float angle = atan2(grad.y, grad.x);
// Waviness that distorts the stroke along its length, avoiding
// any "ruled/perfect" look
float jitterA = FBMNoise3D(float3(uv * 2.4, 610.0), 1.0, 2, 2.0, 0.5) - 0.5;
float jitterB = FBMNoise3D(float3(uv * 2.4, 615.0), 1.0, 2, 2.0, 0.5) - 0.5;
// Two hatch directions aligned with the local relief, but NOT at
// exactly 90 degrees -> more natural, sketch-like
float2 rotA = WaterRotate(uv, -angle);
float2 rotB = WaterRotate(uv, -angle + 1.15);
float coordA = rotA.y * WATER_HATCH_SCALE + jitterA * WATER_HATCH_JITTER;
float coordB = rotB.y * WATER_HATCH_SCALE * 1.1 + jitterB * WATER_HATCH_JITTER;
float strokeA = WaterHatchStroke(rotA.x, coordA, WATER_HATCH_THICKNESS, WATER_HATCH_DASH_FREQ, 700.0);
// The 2nd (crossing) direction only appears in more pronounced
// areas -> densifies the shadow instead of a uniform grid
float crossAmount = smoothstep(0.28, 0.60, slope);
float strokeB = WaterHatchStroke(rotB.x, coordB, WATER_HATCH_THICKNESS * 0.85, WATER_HATCH_DASH_FREQ * 1.35, 750.0) * crossAmount;
float hatch = saturate(strokeA + strokeB);
// Visibility driven by the wavelets: the more "carved/bulging" or
// sloped an area is, the more the hatching stands out
float amount = saturate(abs(wave) * 1.6 + slope * 2.2);
amount = smoothstep(0.15, 0.60, amount);
return hatch * amount;
}
// ============================================================
// INK STAINS: dark, organic bleeding patches around corpse positions
// ============================================================
// Positions are fed in from C# as world-space XZ + a per-corpse seed
// (packed into a float4 array), via Shader.SetGlobalVectorArray /
// Shader.SetGlobalInt. Declaring them here as plain global HLSL
// variables (not Shader Graph-exposed properties) means they still
// compile fine into the final shader even though this file is
// consumed through a Custom Function node -- Shader Graph doesn't
// strip globals referenced inside the function it calls.
#define WATER_MAX_INK_STAINS 32
float4 _InkStainData[WATER_MAX_INK_STAINS]; // xy = world XZ position, z = seed, w = corpse rotation (radians)
int _InkStainCount;
// The puddle itself: several overlapping rounded lobes blended
// together (metaball-style), offset along the corpse's body axis --
// reads as an organic pooled shape with bulges and curves, like a
// real spilled liquid, instead of a clean ellipse or circle.
#define WATER_INK_BODY_ASPECT 2.0 // how elongated the lobe placement is along the body axis
#define WATER_INK_LOBE_COUNT 5
#define WATER_INK_LOBE_SPREAD 0.38 // how far lobe centers scatter from the main center
#define WATER_INK_LOBE_RADIUS_MIN 0.25
#define WATER_INK_LOBE_RADIUS_MAX 0.46
#define WATER_INK_LOBE_SMOOTH_K 0.16 // higher = lobes melt together more smoothly
#define WATER_INK_CORE_EDGE_NOISE 0.05 // light roughness on top of the lobed silhouette
// Many thin, independent threads escaping the puddle, biased toward
// the water's own flow direction with a wide spread -- not a perfect
// radial fan, and not glued to a single heading either.
#define WATER_INK_THREAD_COUNT 12
#define WATER_INK_THREAD_MIN_DIST 0.25
#define WATER_INK_THREAD_MAX_DIST 1.4
#define WATER_INK_THREAD_MIN_LEN 0.35
#define WATER_INK_THREAD_MAX_LEN 1.3
#define WATER_INK_THREAD_WOBBLE 0.35 // slow, broad curve
#define WATER_INK_THREAD_WOBBLE2 0.16 // fast, tight zigzag layered on top
#define WATER_INK_THREAD_THICKNESS 0.04
#define WATER_INK_THREAD_DIR_SPREAD 2.4 // radians of spread around the flow direction
// A handful of small, fully disconnected round droplets scattered
// further out -- matches the little isolated dots you get around a
// real spilled puddle.
#define WATER_INK_DROPLET_COUNT 6
#define WATER_INK_DROPLET_SPREAD 1.8
#define WATER_INK_DROPLET_RADIUS_MIN 0.03
#define WATER_INK_DROPLET_RADIUS_MAX 0.08
#define WATER_INK_MAX_OPACITY 0.75
#define WATER_INK_ANIM_SPEED 0.03
#define WATER_INK_ANIM_STRENGTH 0.05
float WaterInkMaxExtent()
{
float lobeExtent = WATER_INK_LOBE_SPREAD * WATER_INK_BODY_ASPECT + WATER_INK_LOBE_RADIUS_MAX;
float threadExtent = WATER_INK_THREAD_MAX_DIST + WATER_INK_THREAD_MAX_LEN;
float dropletExtent = WATER_INK_DROPLET_SPREAD + WATER_INK_DROPLET_RADIUS_MAX;
return max(max(lobeExtent, threadExtent), dropletExtent) + 0.2;
}
// Polynomial smooth-min -- blends two distance fields together with
// a soft, rounded transition instead of a hard union. This is what
// makes the lobes melt into each other rather than just overlapping
// as separate circles.
float SmoothMinInk(float a, float b, float k)
{
float h = saturate(0.5 + 0.5 * (b - a) / max(k, 1e-4));
return lerp(b, a, h) - k * h * (1.0 - h);
}
// Puddle signed distance field: union of several overlapping round
// lobes, scattered along the body axis so the overall silhouette
// bulges and curves organically, like the blood-puddle reference --
// not a clean ellipse.
float WaterInkCoreSDF(float2 localUV, float corpseRotation, float seed)
{
float ca = cos(-corpseRotation);
float sa = sin(-corpseRotation);
float d = 1e5;
for (int i = 0; i < WATER_INK_LOBE_COUNT; i++)
{
float lobeSeed = seed + i * 17.3;
float alongBody = (WaterHash(lobeSeed) - 0.5) * 2.0 * WATER_INK_LOBE_SPREAD * WATER_INK_BODY_ASPECT;
float acrossBody = (WaterHash(lobeSeed + 3.1) - 0.5) * WATER_INK_LOBE_SPREAD * 0.6;
float lobeRadius = lerp(WATER_INK_LOBE_RADIUS_MIN, WATER_INK_LOBE_RADIUS_MAX, WaterHash(lobeSeed + 6.7));
float2 lobeOffsetLocal = float2(alongBody, acrossBody);
float2 lobeOffsetWorld = float2(
lobeOffsetLocal.x * ca + lobeOffsetLocal.y * sa,
-lobeOffsetLocal.x * sa + lobeOffsetLocal.y * ca
);
float lobeDist = length(localUV - lobeOffsetWorld) - lobeRadius;
d = SmoothMinInk(d, lobeDist, WATER_INK_LOBE_SMOOTH_K);
}
return d;
}
float WaterInkCore(float2 localUV, float corpseRotation, float seed)
{
float sdf = WaterInkCoreSDF(localUV, corpseRotation, seed);
// Cheap reject before the extra edge-roughness noise call
float margin = WATER_INK_CORE_EDGE_NOISE + 0.05;
if (sdf < -margin) return 1.0;
if (sdf > margin) return 0.0;
float edgeNoise = FBMNoise3D(float3(localUV * 3.0, seed + 55.0), 1.0, 2, 2.0, 0.55) - 0.5;
float adjustedSDF = sdf + edgeNoise * WATER_INK_CORE_EDGE_NOISE;
return 1.0 - smoothstep(-0.02, 0.02, adjustedSDF);
}
// A single independent thread, with two layers of wobble stacked --
// a slow broad curve plus a fast tight zigzag on top, so it reads as
// properly meandering rather than a single smooth arc.
float WaterInkThread(float2 uv, float2 origin, float angle, float length, float seed)
{
float2 dir = float2(cos(angle), sin(angle));
float2 perp = float2(-dir.y, dir.x);
float2 local = uv - origin;
float s = dot(local, dir);
if (s < 0.0 || s > length)
return 0.0;
float d = dot(local, perp);
float maxPossibleD = WATER_INK_THREAD_THICKNESS + WATER_INK_THREAD_WOBBLE + WATER_INK_THREAD_WOBBLE2 + 0.02;
if (abs(d) > maxPossibleD)
return 0.0;
float wobble1 = (FBMNoise3D(float3(s * 1.1, seed, 0.0), 1.0, 1, 2.0, 0.5) - 0.5) * WATER_INK_THREAD_WOBBLE;
float wobble2 = sin(s * 9.0 + seed * 5.0) * WATER_INK_THREAD_WOBBLE2;
float dist = abs(d - wobble1 - wobble2);
float lineAlpha = 1.0 - smoothstep(0.0, WATER_INK_THREAD_THICKNESS, dist);
float fadeIn = smoothstep(0.0, length * 0.15, s);
float fadeOut = 1.0 - smoothstep(length * 0.8, length, s);
return lineAlpha * fadeIn * fadeOut;
}
// Many threads, biased toward the water's flow direction (both where
// they start and which way they head) but with enough spread that
// they don't look like a perfect fan.
float WaterInkThreads(float2 uv, float2 stainCenter, float seed, float corpseRotation)
{
float result = 0.0;
float baseAngle = corpseRotation;
for (int k = 0; k < WATER_INK_THREAD_COUNT; k++)
{
float originAngle = baseAngle + (WaterHash(seed + k * 7.31) - 0.5) * WATER_INK_THREAD_DIR_SPREAD * 1.3;
float originDist = lerp(WATER_INK_THREAD_MIN_DIST, WATER_INK_THREAD_MAX_DIST, WaterHash(seed + k * 13.7));
float2 threadOrigin = stainCenter + float2(cos(originAngle), sin(originAngle)) * originDist;
float threadAngle = baseAngle + (WaterHash(seed + k * 19.1 + 4.0) - 0.5) * WATER_INK_THREAD_DIR_SPREAD;
float threadLength = lerp(WATER_INK_THREAD_MIN_LEN, WATER_INK_THREAD_MAX_LEN, WaterHash(seed + k * 23.3 + 8.0));
float thread = WaterInkThread(uv, threadOrigin, threadAngle, threadLength, seed + k * 3.7);
result = max(result, thread);
if (result > 0.999) break;
}
return result;
}
// Small disconnected round droplets, scattered further out, also
// biased toward the flow direction.
float WaterInkDroplets(float2 uv, float2 stainCenter, float seed, float corpseRotation)
{
float result = 0.0;
float baseAngle = corpseRotation;
for (int k = 0; k < WATER_INK_DROPLET_COUNT; k++)
{
float dropletAngle = baseAngle + (WaterHash(seed + k * 31.7 + 50.0) - 0.5) * 2.6;
float dropletDist = lerp(0.3, WATER_INK_DROPLET_SPREAD, WaterHash(seed + k * 41.1 + 60.0));
float2 dropletCenter = stainCenter + float2(cos(dropletAngle), sin(dropletAngle)) * dropletDist;
float dropletRadius = lerp(WATER_INK_DROPLET_RADIUS_MIN, WATER_INK_DROPLET_RADIUS_MAX, WaterHash(seed + k * 51.3 + 70.0));
float dist = length(uv - dropletCenter) - dropletRadius;
if (dist > 0.02)
continue; // cheap reject, no need for smoothstep math
float droplet = 1.0 - smoothstep(-0.015, 0.015, dist);
result = max(result, droplet);
if (result > 0.999) break;
}
return result;
}
// Alpha contribution from a single corpse's stain at this world point
float WaterInkStainAt(float2 uv, float2 stainCenter, float seed, float corpseRotation)
{
float2 localUV = uv - stainCenter;
float t = _Time.y * WATER_INK_ANIM_SPEED;
float wx = FBMNoise3D(float3(localUV * 0.6 + float2(t, 0.0), seed), 1.0, 1, 2.0, 0.5) - 0.5;
float wy = FBMNoise3D(float3(localUV * 0.6 + float2(0.0, t * 0.8), seed + 30.0), 1.0, 1, 2.0, 0.5) - 0.5;
localUV += float2(wx, wy) * WATER_INK_ANIM_STRENGTH;
float core = WaterInkCore(localUV, corpseRotation, seed);
float threads = WaterInkThreads(uv, stainCenter, seed, corpseRotation);
float droplets = WaterInkDroplets(uv, stainCenter, seed, corpseRotation);
return max(max(core, threads), droplets) * WATER_INK_MAX_OPACITY;
}
// Loops over every active corpse and returns the strongest stain
// influence at this point. Cheap squared-distance rejection happens
// BEFORE any noise is evaluated.
float WaterInkStains(float2 uv)
{
float result = 0.0;
float maxExtent = WaterInkMaxExtent();
float maxExtentSq = maxExtent * maxExtent;
for (int i = 0; i < WATER_MAX_INK_STAINS; i++)
{
if (i >= _InkStainCount) break;
float4 data = _InkStainData[i];
float2 delta = uv - data.xy;
float distSq = dot(delta, delta);
if (distSq > maxExtentSq)
continue;
result = max(result, WaterInkStainAt(uv, data.xy, data.z, data.w));
if (result >= WATER_INK_MAX_OPACITY - 0.001)
break;
}
return saturate(result);
}
// ============================================================
// VERTEX
// ============================================================
void WaterVertex_float
(
float3 PositionWS,
float3 NormalWS,
out float3 Position,
out float Height
)
{
float2 uv = PositionWS.xz;
float2 warpedUV = WaterSharedFlowWarp(uv);
float flowA = WaterFlowField(warpedUV, 0.0, WATER_BLOTCH_SCALE_A, WATER_FLOW_DIR, 1.0, 1.0);
float flowB = WaterFlowField(warpedUV, 20.3, WATER_BLOTCH_SCALE_B, WATER_FLOW_DIR * 0.7, 1.4, 2.0);
float h = 0.34;
h += flowA * 0.14;
h += flowB * 0.06;
h = saturate(h);
h = WaterPosterize(h, 20.0);
Position = PositionWS;
Position.y += h * 0.0035 - 0.00175;
Position.y += WaterWaveDisplacement(uv);
// "Vat" effect: water level rises with distance from the camera,
// closing in like the walls of a giant tub around the boat.
float distFromCamera = length(PositionWS.xz - _WorldSpaceCameraPos.xz);
float bowlT = saturate(distFromCamera / WATER_BOWL_RADIUS);
Position.y += pow(bowlT, WATER_BOWL_POWER) * WATER_BOWL_STRENGTH;
Height = h;
}
// ============================================================
// COLOR
// ============================================================
void WaterColorNoFoam_float
(
float3 PositionWS,
float3 NormalWS,
float MoonRotationDegrees,
out float3 Color,
out float Height
)
{
float2 uv = PositionWS.xz;
float2 warpedUV = WaterSharedFlowWarp(uv);
float flowA = WaterFlowField(warpedUV, 0.0, WATER_BLOTCH_SCALE_A, WATER_FLOW_DIR, 1.0, 1.0);
float flowB = WaterFlowField(warpedUV, 20.3, WATER_BLOTCH_SCALE_B, WATER_FLOW_DIR * 0.7, 1.4, 2.0);
float flowC = WaterFlowField(warpedUV, -11.7, WATER_BLOTCH_SCALE_C, WATER_FLOW_DIR * 1.3, 0.8, 3.0);
float currentOutlines = WaterCurrentOutlines(uv, flowA, flowB, flowC);
// Wave gradient computed early -- shared by the hachures, the
// chiaroscuro and the moon reflection below.
float waveHeight = WaterWaveHachureHeight(uv);
float2 waveGrad = WaterWaveGradient(uv, 0.35);
// Cheap distance-only check (no noise) so we can skip the moon
// band's noise-driven computation entirely when it's guaranteed
// to be invisible near the camera, instead of computing it and
// multiplying by ~0 later.
float camDistCheap = distance(PositionWS, _WorldSpaceCameraPos);
float distFactorCheap = saturate(camDistCheap / 55.0);
float moonDistanceFade = smoothstep(0.15, 0.30, distFactorCheap);
// Moon reflection band
float moonCore = 0.0, moonBanded = 0.0, moonBody = 0.0;
if (moonDistanceFade > 0.001)
{
moonBody = WaterMoonBandMask(uv, PositionWS, MoonRotationDegrees, waveGrad, moonCore, moonBanded);
}
// distFactor also drives the LOD gate below (0 near camera, 1 at horizon)
float horizonDistFactor;
float horizonLines = WaterHorizonLines(uv, PositionWS, horizonDistFactor);
float lod = 1.0 - horizonDistFactor;
float pale = smoothstep(0.42, 0.80, flowA);
float dark = smoothstep(0.42, 0.80, 1.0 - flowB);
float mid = smoothstep(0.35, 0.80, flowC);
float cold = smoothstep(0.25, 0.70, FBMNoise3D(float3(uv * 0.10, 8.0), 1.0, 2, 2.0, 0.5));
float h = 0.34 + flowA * 0.14 + flowB * 0.06 - dark * 0.08;
h = saturate(h);
h = WaterPosterize(h, 20.0);
Height = h;
// --- base palette: oily/grimy base, more tonal range than plain blue ---
float3 colInk = float3(0.0, 0.0, 0.0);
float3 colDeep = float3(0.006, 0.014, 0.020);
float3 colRust = float3(0.340, 0.160, 0.055);
float3 colAlgae = float3(0.030, 0.150, 0.120);
float3 colViolet = float3(0.140, 0.075, 0.200);
float3 colGrime = float3(0.180, 0.160, 0.110);
float3 colGlint = float3(0.470, 0.430, 0.330);
// Tone quantization with "pencil-jittered" edges (jitter applied
// before posterize)
float tone = 0.34 + flowA * 0.42 + pale * 0.36 - dark * 0.44;
tone += WaterCrayonJitter(uv, 40.0) * WATER_CRAYON_JITTER;
tone = saturate(tone);
tone = WaterPosterize(tone, 4.0); // fewer steps -> bigger, more decisive masses
// Oil-gradient parameter: combines several fields so each blotch
// leans toward a different hue instead of a flat blue wash.
float oilT = saturate(tone * 0.55 + pale * 0.30 - dark * 0.20 + (flowC - 0.5) * 0.55);
float3 baseCol = WaterOilGradient(oilT);
baseCol = lerp(baseCol, colAlgae, mid * 0.30);
baseCol = lerp(baseCol, colViolet, cold * 0.28);
baseCol = lerp(baseCol, colDeep, dark * 0.55);
// --- extra dedicated blotch layer (smaller, more numerous) to
// reinforce coverage across the whole surface ---
float extraDark = smoothstep(0.55, 0.85, flowC);
float extraPale = smoothstep(0.55, 0.85, 1.0 - flowC);
baseCol = lerp(baseCol, colDeep, extraDark * 0.48);
baseCol = lerp(baseCol, colGrime, extraPale * 0.28);
// --- interaction between blotches: where several fields overlap,
// an emergent tint appears (like two pigments mixing where they meet) ---
float overlapWarm = saturate(pale * (1.0 - dark) * mid * 3.0);
float overlapCool = saturate(dark * cold * 3.0);
baseCol = lerp(baseCol, colRust, overlapWarm * 0.26);
baseCol = lerp(baseCol, colViolet, overlapCool * 0.26);
// --- ambient light/dark wash tied to the moon's direction: the
// general side of the water facing the moon reads a touch
// lighter/cooler, the opposite side a touch darker. Broad and
// subtle, independent of the tight specular road above. ---
float2 moonDirXZ = float2(cos(radians(MoonRotationDegrees)), sin(radians(MoonRotationDegrees)));
float2 toCamXZ = uv - _WorldSpaceCameraPos.xz;
float moonFacing = dot(normalize(toCamXZ + 1e-4), moonDirXZ) * 0.5 + 0.5; // 0 = away, 1 = toward moon
// Curve pushes mid-tones darker faster (not just the extreme away
// side), while staying capped below the path's own darkness.
float awayAmount = pow(saturate(1.0 - moonFacing), WATER_AMBIENT_DARK_CURVE);
baseCol = lerp(baseCol, colDeep, awayAmount * WATER_AMBIENT_DARK_STRENGTH);
baseCol = lerp(baseCol, colInk, awayAmount * awayAmount * awayAmount * WATER_AMBIENT_CRUSH_STRENGTH);
baseCol = lerp(baseCol, colGlint, moonFacing * WATER_AMBIENT_LIGHT_STRENGTH);
// chiaroscuro: real value hierarchy tied to the local wave relief,
// troughs pushed toward near-black, crests only lifted slightly
float trough = smoothstep(0.05, -0.30, waveHeight);
baseCol = lerp(baseCol, colDeep, trough * 0.55);
float crestLift = smoothstep(-0.05, 0.30, waveHeight);
baseCol = lerp(baseCol, colGlint, crestLift * 0.08);
// --- always-on fine grain: cheap value noise, NOT gated by LOD,
// so the horizon keeps some texture instead of reading as a flat
// wash. Two octaves at different scales avoid an obvious tiling
// pattern. Deliberately subtle -- this breaks flatness, it isn't
// meant to fight the hachures/outlines for attention. ---
float fineGrain = WaterCheapValueNoise(uv * WATER_FINE_GRAIN_SCALE_A) * WATER_FINE_GRAIN_WEIGHT_A
+ WaterCheapValueNoise(uv * WATER_FINE_GRAIN_SCALE_B) * WATER_FINE_GRAIN_WEIGHT_B;
baseCol += (fineGrain - 0.5) * WATER_FINE_GRAIN_STRENGTH;
// --- fine detail layers, faded out toward the horizon ---
if (lod > WATER_LOD_THRESHOLD)
{
float secondaryOutlines = WaterSecondaryInkTaches(uv);
baseCol = lerp(baseCol, colInk, secondaryOutlines * 0.85 * lod);
float grime = WaterGrimeStreaks(uv);
baseCol = lerp(baseCol, colRust, grime * 0.45 * lod);
float3 wash = WaterColorWash(uv, colRust, colViolet);
baseCol = lerp(baseCol, wash, 0.30 * lod);
float3 colorNoise = WaterColorNoise(uv, 0.55, 70.0);
baseCol += colorNoise * 0.10 * lod;
float waveletHatch = WaterWaveletHachures(uv);
baseCol = lerp(baseCol, colInk, waveletHatch * WATER_HATCH_OPACITY * lod);
// Fine pencil grain
float grainA = FBMNoise3D(float3(uv * 0.65, 50.0), 1.0, 2, 2.0, 0.50);
float grainB = FBMNoise3D(float3(uv * 1.60, 55.0), 1.0, 2, 2.0, 0.50);
float grainC = FBMNoise3D(float3(uv * WATER_CRAYON_SCALE, 61.0), 1.0, 2, 2.0, 0.5);
baseCol += (grainA - 0.5) * 0.022 * lod;
baseCol += (grainB - 0.5) * 0.012 * lod;
baseCol += (grainC - 0.5) * 0.018 * lod;
}
// --- ink: horizon darker/denser at a distance, more discreet near the boat ---
baseCol = lerp(baseCol, colInk, currentOutlines * 0.95);
baseCol = lerp(baseCol, colInk, horizonLines * lerp(0.55, 0.95, horizonDistFactor));
// --- hatching that follows the swell relief (engraving look) ---
float waveHachures = WaterWaveHachures(uv, waveGrad);
baseCol = lerp(baseCol, colInk, waveHachures * 0.55);
// Scattered dark patches across the water
float scatteredDark = WaterScatteredDarkPatches(uv);
baseCol = lerp(baseCol, colDeep, scatteredDark * WATER_SCATTERED_DARK_OPACITY);
baseCol = lerp(baseCol, colInk, scatteredDark * scatteredDark * 0.35); // solid core where the patch is strongest
// --- moon reflection band: painted in with a few discrete steps
// (not a smooth glow), textured by the hachures so it still reads
// as drawn rather than a photographic highlight. Keep the boat's
// immediate surroundings clear -- the band only fades in starting
// some distance out (moonDistanceFade). ---
float moonTexture = 0.55 + 0.45 * (1.0 - waveHachures);
baseCol = lerp(baseCol, WATER_MOONBAND_TINT, moonBanded * 0.20 * moonTexture * moonDistanceFade);
baseCol = lerp(baseCol, WATER_MOONBAND_TINT * 1.1, moonCore * 0.14 * moonTexture * moonDistanceFade);
baseCol = lerp(baseCol, colInk, moonBody * waveHachures * 0.4 * moonDistanceFade); // keep the ink linework visible inside the band
// --- ink stains bleeding from corpses -- applied last, on top of
// everything else, so it reads as a fresh dark stain sitting over
// the painted water rather than getting washed out by the other layers
float inkStain = WaterInkStains(uv);
baseCol = lerp(baseCol, colInk, inkStain);
Color = saturate(baseCol);
}
// Legacy compatibility for the old node name.
void Water_float
(
float3 PositionWS,
float3 NormalWS,
out float3 Position,
out float Height
)
{
WaterVertex_float(PositionWS, NormalWS, Position, Height);
}
#endif