← All essays

Four Attempts at Water

Order-independent transparency, a per-fragment colour ramp, and the three approaches I had to throw away first

The liquid in my virtual lab is not authored by anyone. A physics backend simulates it and returns a mesh per keyframe — vertices, triangles, a volume, a colour field. My job is to take that stream of meshes and make it read as water inside a glass vessel, in a Unity WebGL build, on hardware that includes a Chromebook.

I got that wrong three times before I got it right. This is what each attempt taught me, in the order I learned it.

Attempt zero: the obvious thing

A translucent surface. Lab/LiquidSurface, transparent queue, ZWrite Off, Cull Off, alpha blend, colour and opacity from config. Swap the mesh each keyframe, done.

It looked fine until the camera moved. Then three artefacts showed up, and they turned out to be the same artefact wearing different clothes:

I established the cause by reading the assets rather than guessing at it, which in hindsight was the only good decision I made that week. The liquid sat in the transparent queue at 3000 with ZWrite Off. The glass vessel — a stock URP Lit transparent — sat in the same queue, also not writing depth. With neither writing depth, Unity sorts them per object, by distance from the camera to the object centre. Two objects, one of which is inside the other. The sort order flips as you orbit.

That is the snap. It is not a shading bug. It is a draw-order bug, and it was visible only because the liquid was fairly opaque: config opacity 0.4, and Cull Off double-blends the front and back faces to roughly 0.64 effective.

Attempt one: write depth

The direct fix. Set the liquid to ZWrite On, Cull Back, and force its render queue to 2900 so it draws below the glass.

It worked, in the narrow sense. Depth resolves ordering per pixel instead of per object, so the snap and the bleed both vanished deterministically.

It also destroyed the thing I was trying to render. ZWrite On on the front surface hard-hides everything behind it, so the glass wall stopped showing through the water and the liquid read as a solid puck sitting in the vessel.

And Cull Back broke pouring, for a reason worth explaining because it constrains everything that follows.

The pour stream is the same mesh

When a vessel pours, I do not spawn a separate stream object. PourStreamRenderer extends the source fluid's own mesh with a tube of ring vertices arcing from the lip to the receiving surface, and stitches the tube's first ring directly to the fluid's near-lip surface vertices with bridge triangles.

One mesh. One material. One draw.

That design exists because the alternative is worse: a separate stream object means a transparent surface overlapping another transparent surface at exactly the place the eye is looking, plus a visible cone artefact where the stream leaves the liquid. Stitching it into the source mesh makes both problems structurally impossible.

The cost is that the liquid material must now render a thin, long, diagonal, open-ended tube as well as a fat volume sitting in a cup. Cull Back drops the tube's back-facing triangles, which produces dark holes at the spout and the lip during a pour.

So: no Cull Back on the liquid, ever. That is a hard constraint, discovered by breaking it.

Attempt two: be clever about it

This is the one I am least proud of and learned the most from.

I had a working volumetric shader for steam — object-space ray, clipped against the mesh AABB, accumulate along the segment. And the steam never visibly snapped, despite being the same ZWrite Off, same-queue setup that broke the liquid.

I reasoned my way to what felt like an insight. The steam does not snap because it is faint. Compositing two low-alpha layers gives you nearly identical pixels whichever draws first, so the order flip falls below the perceptual threshold. Plain alpha blending already lets you see one transparent through another; order-independent transparency only removes the flip, and the flip is visible only when the front layer is fairly opaque.

Therefore: make the liquid a translucent absorbing medium like the steam, and I get see-through and an imperceptible snap, with no pipeline work at all. Beer–Lambert over the clipped path length, alpha = 1 - exp(-absorption * thickness). Deep column, deeper tint. Thin edge, see-through.

I built it. It failed twice over.

The snap persisted. "Translucency hides the flip" did not survive contact with the actual scene at the opacities I actually wanted. The hypothesis was clean, plausible, and wrong.

And pouring broke completely — almost no water visible during a pour. Beer–Lambert over a mesh AABB is meaningless for a thin diagonal tube. The AABB of that tube is a large, mostly empty box; the clipped path length through the actual geometry is tiny relative to it; accumulated alpha collapses to roughly zero. The stream evaporated.

The revert took two goes. First I went back to the depth-write state, and discovered that was not a clean baseline either — it traded the snap for pour holes plus the puck. So I reverted the whole way to the original translucent version, snap and all, and wrote down what was now ruled out by evidence rather than by opinion:

That list is the most useful artefact of the whole failed attempt. Every cheap shader-level lever — cull mode, depth write, queue, opacity — either breaks the pour, kills see-through, or fails to remove the snap. The lane was exhausted, and the exhaustion was documented rather than felt.

Which leaves exactly one option. If the problem is draw order, stop having a draw order.

Attempt three: weighted-blended OIT

Weighted-blended order-independent transparency (McGuire & Bavoil, 2013) composites overlapping transparent fragments without sorting anything. Each fragment goes into two buffers:

A full-screen pass then resolves them:

float3 avgColor = accum.rgb / max(accum.a, 1e-5);
float  alpha    = saturate(1.0 - reveal);
return half4(avgColor, alpha);

w is a depth weight that biases nearer fragments to contribute more, so the average reads roughly front-to-back without anybody sorting anything.

The property I leaned on hardest during development is the identity case. A pixel no transparent fragment touched has accum = 0 and reveal = 1, which yields alpha 0 and leaves the camera colour completely untouched. That meant I could ship the entire feature — targets, gate, resolve, pass ordering — with nothing tagged to use it, and verify the plumbing in isolation against a scene that had to render pixel-identically. It did.

Then I started routing surfaces into it, and WebGL2 began making decisions for me.

What WebGL2 dictated

One blend function, globally. The textbook implementation writes accum and reveal in a single MRT pass with a different blend function per draw buffer. WebGL2 core has one global blend function for all draw buffers; per-target blend needs an extension the min-spec device may not have.

Worse than "may not have": my capability gate tests format blending, not per-target blend functions. A device could pass the gate and then render the reveal buffer silently wrong. Not a crash, not a warning — just quietly incorrect coverage.

So accum and reveal are two separate single-target geometry passes, each with its own blend state:

GalileoOitAccum  → accum RGBA16F, Blend One One
GalileoOitReveal → reveal R16F,   Blend Zero OneMinusSrcColor

Every transparent surface is drawn twice in the OIT stage. At lab scale — a handful of vessels — that is nothing, and it is correct on every WebGL2 device rather than most of them.

No depth copies. copyTextureSupport is None on WebGL, so depth cannot be copied between targets. There is also a sample-count mismatch: the camera depth buffer is MSAA 4×, and the float OIT targets are single-sample. Binding a depth attachment was off the table twice over.

Instead the OIT passes sample the resolved _CameraDepthTexture in the fragment and discard when the fragment is behind opaque geometry. A manual reject, which turned out to be a good thing anyway, for reasons in a moment.

The weight function needed rescaling. McGuire's reference curve is tuned for large scenes — its depth term is on the order of z/200. My lab is sub-metre to a few metres. So the depth term gets a scale factor:

float OitWeight(float viewZ)
{
    float z = max(viewZ, 1e-4) * _OitWeightScale;
    return clamp(0.03 / (1e-5 + pow(z, 4.0)), 1e-2, 3e3);
}

Note it scales the depth term, not w uniformly. A uniform scale on w cancels out in the accum.rgb / accum.a average and does exactly nothing. It took me an embarrassing minute to work out why my tuning knob had no effect.

The fallback is gated, not deleted. Every OIT-routed shader keeps its original alpha-blend pass. The feature sets a global _GalileoOitOn, and that pass discards when it is set. When the runtime capability gate fails — no float blend, fewer than two render targets — the global stays 0 and the old pass renders yesterday's look. A device that cannot do this gets the snap, not a black screen.

The gate itself prefers half-float, falls back to full-float, and disables otherwise. It logs its verdict once, which has saved me more debugging time than any other single line in the feature.

The glass had to be forked too

Fixing the liquid without fixing the glass fixes nothing — the snap is a liquid against glass order flip. So the glass had to go through OIT as well, and that meant discovering that "the transparent material" was actually two materials assigned at GLB import.

Variant one is the clear glass body: URP Lit, metallic, smoothness 0.948, base alpha 0.149, environment reflections. Genuinely alpha-blended, and the snap's other half. This got forked into a custom shader carrying the two OIT passes, approximating the Lit look with a reflection-probe sample and a Schlick fresnel rim:

float nv = saturate(dot(N, V));
float fresnel = pow(1.0 - nv, 5.0);
float3 R = reflect(-V, N);
half3 env = GlossyEnvironmentReflection(R, saturate(1.0 - _Smoothness), 1.0);

color = env * lerp(tint, 1.0, saturate(_Metallic)) + tint * baseA;
alpha = saturate(baseA + fresnel * (1.0 - baseA) + reflLum * 0.25);

Variant two is the printed markings — graduation scales, numbers. These are alpha-tested cutouts that had merely inherited the transparent queue and ZWrite 0 from being a Lit transparent surface. They are not blended glass at all.

Routing them through OIT would have been actively wrong. Weighted blending averages overlapping fragments, which is right for glass and destroys a crisp cutout — it softens the edges and lets liquid bleed through the numbers. So they went the other way: promoted to alpha-tested opaque, ZWrite On, queue 2450. Now they write depth, the OIT depth-reject correctly hides liquid and glass behind them, and they stay sharp. That also fixed a latent oddity nobody had noticed — a cutout sitting in the transparent queue not writing depth.

Two transparent materials, opposite treatments, and the tell was that one of them was never really transparent.

The Chromebook, and an honest regression

The build went to a Mali-G52 Chromebook and the adaptive quality tier fell to Potato at 15–30 fps. Before OIT it held Medium at 35–40.

The diagnosis had two parts, and the first one is the interesting one.

My adaptive quality controller ramps render scale, MSAA, shadows and HDR down as frame time degrades. It never touched OIT. So OIT ran at full cost on every tier, and every downgrade stripped things that were not the bottleneck. Potato could not recover the loss because Potato had no lever attached to the actual cost.

The costs OIT had added: a depth texture the render pipeline asset previously did not require at all; two extra transparent geometry passes; and an RGBA16F accum plus an R16F reveal full-screen target with a full-screen resolve read-back, which on a tiler is a lot of bandwidth.

The depth texture was the cheapest win and the dumbest bug. The depth request was ConfigureInput(Depth) in the pass constructor — unconditional. The constructor runs before config is read, so the depth_reject: false config toggle could not remove the cost it was supposed to control. Moving the request into the per-frame setup, gated on the setting, made it both correct and measurable.

Then the tier got wired to OIT properly: at or under the Low tier, OIT runs at half-resolution targets and the resolve upsamples with a linear sampler; at or under Potato, OIT switches off entirely and the alpha-blend fallback takes over. Both boundaries are config, both can be disabled.

The bug that ate the graduation marks

Bundling half-resolution mode with depth-reject-off — which is what I did first, to save the depth texture at low tiers — produced a specific and initially baffling artefact: the graduation markings on the glassware faded out.

The chain is worth tracing. With reject off, glass fragments behind the opaque cutout markings are no longer discarded. They accumulate. The resolve then composites glass over the marking, and the marking washes out. Exactly the surface I had just promoted to opaque so it would stay crisp.

The reason I had bundled them at all was that the reject sampled depth through GetNormalizedScreenSpaceUV, which divides by the camera screen size — so at half-resolution targets it samples the wrong texels.

The fix was to stop using a resolution-dependent UV. Compute a homogeneous screen position in the vertex shader and divide in the fragment:

float4 OitScreenPos(float4 positionCS)
{
    float4 o = positionCS * 0.5;
    o.xy = float2(o.x, o.y * _ProjectionParams.x) + o.w;
    o.zw = positionCS.zw;
    return o;
}

That is the deprecated ComputeScreenPos math inlined, with _ProjectionParams.x handling the platform Y flip. screenPos.xy / screenPos.w is [0,1] across the camera viewport regardless of what resolution the target happens to be. Depth-reject became independent of the reduced mode, and the markings stayed crisp at the low tier.

Two features that had no business being coupled were coupled by an implementation detail in a UV helper.

Colour, and a lesson about interpolation

The transparency work was the platform. The reason it was urgent was colour: the backend computes a per-vessel liquid colour from the dissolved species and emits it as an OKLab field. Clear water hides an ordering flip. A dye does not.

Two kinds of field arrive. A constant — one colour for the whole volume. And a profile_1d — a vertical gradient with colour stops at coordinates along an axis, which is how density stratification comes back: distinct layers that blur into each other as the run progresses.

Uniform colour was easy. Push it through a MaterialPropertyBlock per keyframe, lerp between keyframes so diffusion reads as a continuous shift rather than a series of jumps, take the hue from the field and keep the alpha from config. (Opacity stays a viewer concern — the backend's alpha would make water an opaque puck.)

The gradient is where I got a genuinely instructive result.

I evaluated the field per vertex and wrote mesh.colors. Two-band profiles rendered correctly. A three-band blue/red/yellow receiver rendered blue to yellow with a washed-out middle — the red band simply was not there.

I dumped the mesh at runtime rather than theorising. The liquid mesh is coarse: about 217 vertices, clustered at the bottom of the volume and along the top surface, with almost none across the middle. Per-vertex colour can only place a hue where a vertex exists. There were no vertices in the red band, so the GPU interpolated blue straight to yellow across the tall wall triangles, and the band was interpolated out of existence.

That is not a colour-space bug or a data bug. The field, the resolver, the OKLab conversion and the axis were all verified correct. It is a hard limitation of the technique on a mesh I do not control.

The fix is the part I would tell someone else:

Interpolate the coordinate, not the value.

Per vertex I now bake a single scalar — the vertex's normalized height along the profile axis — into UV0.x:

float span = maxY - minY;
for (int i = 0; i < n; i++)
    uv[i] = new Vector2(span > 1e-6f ? (verts[i].y - minY) / span : 0f, 0f);
mesh.uv = uv;

A scalar interpolates linearly and correctly across a face whether or not any vertex sits inside the band. The colour profile itself is baked into a 128-pixel 1-D ramp texture once per keyframe, in a linear texture so the sampled value matches the CPU-side OKLab conversion exactly. The shader then looks colour up per fragment:

half3 rgb = _ProfileOn > 0.5
    ? SAMPLE_TEXTURE2D(_ProfileRamp, sampler_ProfileRamp, float2(i.uv.x, 0.5)).rgb
    : _BaseColor.rgb;

Any number of bands, at any height, on a mesh of any density. The per-vertex colour path and its vertex-colour multiply were deleted.

The rim that tells you how full the glass is

One last piece, and it is a user-experience fix wearing a shader's clothes.

I dropped the liquid opacity to 0.2 so that near-white clean water reads as clear rather than as a milky film. That looked much better and introduced a real usability problem: a nearly clear liquid gives you no idea how full the vessel is. The waterline disappears.

So the liquid silhouette gets a Fresnel rim. At grazing angles, alpha lifts from 0.2 toward 0.9 and the colour lifts slightly toward white — enough to draw a bright waterline and edge without washing out the body.

There is a catch. The fluid mesh normals are baked flat, every one of them Vector3.up, so there is no usable surface normal to take a Fresnel term against. The rim reconstructs the true geometric face normal per fragment from world-position derivatives instead:

float LiquidRim(float3 posWS, float power, float strength)
{
    float3 N = normalize(cross(ddx(posWS), ddy(posWS)));
    float3 V = normalize(_WorldSpaceCameraPos.xyz - posWS);
    return pow(1.0 - saturate(abs(dot(N, V))), power) * strength;
}

The abs matters: the pour tube renders Cull Off, so the reconstructed normal can face either way, and the rim has to be winding-agnostic.

And the whole thing lives in a shared include that all three passes call — the fallback, the accum, and the reveal. That is not tidiness. If the accum and reveal passes computed even slightly different alphas, the OIT coverage and the OIT colour would disagree, and the surface would composite wrong in a way that is very hard to look at and diagnose. The alpha has exactly one definition and three callers.

What I would tell myself at the start

Four attempts, and only the last one is in the build. Looking back, three things separate the attempt that worked from the ones that did not.

I found the cause by reading the assets rather than by reasoning about the symptom. Two materials in the same queue with neither writing depth is a fact you can go and check in ten minutes. It is not something you can infer from a colour changing when you orbit.

The clever attempt failed on a hypothesis that sounded like an insight. "Translucency makes the order flip imperceptible" was elegant, was supported by an existing shader that genuinely does behave that way, and was false at the opacities I needed. Elegance is not evidence.

And reverting all the way was worth more than the code I threw away. What survived that failure was a written list of what had been ruled out and why — which is precisely what made the case for a renderer feature obvious instead of arguable. The next attempt was not another clever shader. It was the only remaining option, and it was easy to commit to because the alternatives were all crossed out on paper.

The water is see-through now. It does not snap, it does not bleed, it holds a colour gradient that blurs as things diffuse, and it tells you where the waterline is. On a device that cannot render any of that, it quietly goes back to being a flat blue surface, which is fine, because a flat blue surface is still water.