Optically Correct Liquid Glass
This is a document for my Liquid Glass implementation here.
Apple released the Liquid Glass design. It looks…very busy. But nonetheless, it is an interesting effect, and unlike most other GUI designs, it is non-trivial to implement. Therefore, I am going to implement it, with GLSL.
It is obvious just from intuition that Apple is intended to simulate some kind of glass object with a rounded surface. But of what shape? My initial thought was an ellipsoid-like thing:

The problem with this shape is if I look top-down, I would have to take into account the internal total reflections, which would be very hard to calculate (because the number of bounces cannot be determined before the calculation). I also made a render in Blender, and it did not look like the Liquid Glass effect.
The next shape I contemplated was just the top half of it. With some fiddle and thinking, I ended up with that, plus a cylinder at the bottom:

There is no total reflection when viewed from top-down (and if we keep the index of refraction realistic). The following is the top view.

And the side view.

Looks promising. We should implement this!
First we should set up the coordinate system. We will use the screen plane as the xy-plane. The z axis will point from the screen towards us. The model has several parameters we could tune, namely the height of the portion beneath the rounded part, which we will call h; the radius of the rounded bevel, which we will call t (thickness); and finally the index of refraction of the “glass”, which we will call \(\eta\). These are things we can change to adjust the look. We will put the “floor” (the UI itself) right underneath the “glass”.
The way this works is some point on the “floor” emits a ray of light of some color. The ray will go upward in z at some angle, and hit the glass-air interface. It will then refract and just happen to travel exactly parallel to the z direction and hit our eye. We require the ray to travel parallel to z, because this is essentially an orthographic projection; there is no perspective here. However for the calculation, we will go backwards (just like any ray tracer would do). We will have a ray travel in the -z direction and hit the glass at position P. It then refracts and travel at some angle, and hit the “floor” at another position \(P'\). We will then claim that we should take the color from the “floor” at pixel position \((P_x', P_y')\) and display it on screen at pixel position \((P_x, P_y)\). For the sake of simplicity, we will just say that P and \(P'\) are both on the \(y = 0\) plane; i.e. \(P_y = P_y' = 0\). For now we will treat h, t, and \(\eta\) as constants. The only input we need to perform this calculation is really just s, which is the distance between P and the edge of the “glass” on the xy plane. We can make this simplification because we have made assumptions about the shape of the “glass”.
Next we need to determine the incident angle \(\phi\) in order to calculate the refraction. This is quite easy because
However, what we really want is \(\sin \phi\), because according to Snell’s law,
We know from the previous derivation that
With this, we can calculate the distance between P and \(P'\) on the xy-plane, which we will call a.
Since we made the assumption that both p and \(P'\) are on the \(y=0\) plane, it is trivial that \(P' = (P_x + a, P_y, -d)\). Done. That’s it. But what if they are not on the \(y=0\) plane? The derivation above still holds. It is just a matter of calculate \(P'\) from a and P. In another word, we need find the direction of \(P'\) relative to P, which we can derive if we know the direction of the refracted light. We will denote this direction with vector \(\vec{r}\). And obviously that vector can be calculated from the direction of the incident light, which is just \(-\vec{z}\), and the normal vector \(\vec{n}\). Therefore, our real problem now is to determin \(\vec{n}\) at P.
So far we have assumed that the cross section at the xz-plane is of a certain shape. Most importantly we assumed that the round bevel is just an arc, with constant radius of curvature. If we change this assumption, all of our derivation would fall apart. Now we could also make assumptions about the shape viewed top-down. For example, we could say that the shape is two half-circles with a rectangle in between, which is exactly the case in our top view render from the beginning. However for our purpose here, we really do not need to make this assumption. The normal calculation can be generic.
To do that we will use 2D SDF to represent our shape. The SDF of a shape is basically a scalar field \(\bar{s} = f(\vec{p})\), where \(\bar{s}\) is the distance from \(\vec{p}\) to the nearest point on the surface along the normal direction. It is also defined that \(\bar{s}\) is negative if \(\vec{p}\) is inside the surface, and positive if outside. In practice (in CG), it is sometimes used (among many other utilities) as an alternative representation of surfaces, besides mesh and NURBS (Blender will have it!). In code, this is just a function that takes a position and returns a number, which is the distance as defined. This is perfect for our purpose here, because … well \(\bar{s} = -s\). That’s it. Can things be more perfect than this?
One caveat of using SDF is that in general it is non-trivial to actually define the SDF, given a certain shape. But for now let us assume that we have already defined the SDF. I.e. given a point \(\vec{p}\), we can calculate \(\bar{s}\). From now on, we will restrict ourselves to the xy directions, because things concerning the z direction has already been solved. Our problem right now is to calculate \(\vec{n}\) given P and function f.
We will make an observation now. If our surface is along the x direction, i.e. \(n_x = 0\), \(n_y \neq 0\), and we take the value of f at different points along the x direction, the value does not change, because we are moving in parallel to the surface; the distance to the surface is the same at all these points. However if we move along the y direction, the value of f changes the fastest. In another word,
And similarly, if the surface is along the y direction,
🤔 Clearly there is a connection. Here we will just haphazardly claim that up to some normalization factor,
(I have not thought this through, but this might be correct.)
In GLSL, this can be acquired by simply calling dFdx
and dFdy
.
Now we can go back to 3-dimensional space and observe that \(n_z =
\cos \phi\). Now we have all three components of the normal vector. We
can then follow the Snell’s law to calculate the direction of the
refracted light. However GLSL provides us with the refract
function,
which does exactly that. In our case we are going from vacuum to
“glass”, so we will use \(1/\eta\) as the third argument. This will
give us the refraction vector \(\vec{r}\). We can then
calculate
This is basically the bulk of the calculation. I have the implementation here. To make it pretty, I also added “reflection” and shadow, neither of which is even close to be physically correct.
Now we go back to the “Liquid Glass” effect itself. One could argue that it is gimmicky or inaccessible. I also doubt that Apple is using ray tracing to implement the effect; I would not be surprised if it is just some hand-wavy distortion based on a normal map. However, it is worth noting that Apple’s effect is extremely nuanced, like all their UI designs that came before.
For example, the refraction in my implementation is sometimes met with the undistorted image with a sharp angle:
This is due to the curvature not being continuous when transitions from the bevel to the flat area. In Apple’s implementation this is largely avoided. It is well-known at this point that Apple has an extreme hate for curvature discontinuity.
This is also fixable in my implementation. All we need to do is to
change the way \(n_z\) and h is calculated, which would invalid
our previous derivation, but the method that uses GLSL’s refract
function would still hold (which is what I actually wrote in the
code).
On some elements, Apple’s implementation also includes dispersion. Although it does not look physically correct to me.