Infinity Mirror Watch Face

October 23

1. Background

Infinity Mirror is a watch face for the Xiaomi 17's rear screen. Since this is a fairly standard request and there's no big launch pressure, I wanted to try out a few ideas.

We drew inspiration from the "Infinity Mirror" shown above. It uses two one-way mirrors to bounce a light source back and forth, creating a deep, seemingly endless effect. From different angles it looks different, almost like staring into a real abyss.

The rear screen on the Xiaomi 17 is like a window cut into the phone's body. We can create a similar illusion using sensor data and real-time rendering techniques.


2. Design Idea

In 3D, you can do this with a simple camera setup. I've made a quick animation to show it. The lights don't move; as the viewing angle changes, you see different reflection layers inside the 'window'. Think of the rear screen as that window. In real use, the user's eyes stay put and they rotate the phone. We can read the phone's rotation and render the right frames to make it look like there are many lights behind the glass.

There are two key factors that characterize this rendering approach:

  1. Perspective: The first ring must match the phone's perspective in the physical world. It should appear painted onto the glass and track the device's rotation.
  2. Window size: The rear screen size is fixed. More tilt means a smaller visible window.

A top view helps to explain this. The screen edges and the eye define the top-view frustum. The angle at the eye is the field of view (FOV). As the phone rotates, this effective FOV changes, whereas standard rendering typically keeps the FOV fixed.

The animation illustrates a simplified single-axis (2D) rotation. In reality, there are three degrees of freedom, which increases the complexity.


3. Rendering

We want a projection that keeps the image plane fixed in world space, even when the eye moves. This problem is well studied in the game engines. The technique is known as Portal Projection.

3.1. Portal Projection

3.1.1. Define Image Plane

To define the plane, we use a point p\mathbf{p} on the plane, a normal n\mathbf{n}, and two unit tangent vectors u\mathbf{u} and v\mathbf{v} that lie on the plane. For simplicity, let the screen center be the origin, choose orthogonal axes aligned with the screen edges, and take the normal to point into the screen.

Then we have:

DefExpr
p\mathbf{p}(0,0,0)(0, 0, 0)
n\mathbf{n}(0,0,1)(0, 0,-1)
u\mathbf{u}(1,0,0)(1, 0, 0)
v\mathbf{v}(0,1,0)(0, 1, 0)

3.1.2. Build Projection Matrix

We could use classic Model-View-Projection (MVP) composition to build an asymmetric (off-axis) frustum. Here I prefer a more direct approach. Lengyel, Oblique View Frustum

Take any point q\mathbf{q} in world space, connect it to the eye e\mathbf{e}. Find the intersection q\mathbf{q}\prime with the plane.

q=e+n(pe)n(qe)(qe)\mathbf{q} \prime = \mathbf{e} + \frac{\mathbf{n} \cdot (\mathbf{p} - \mathbf{e})}{\mathbf{n} \cdot (\mathbf{q} - \mathbf{e})} (\mathbf{q} - \mathbf{e})

Here, q\mathbf{q}\prime is the projection of q\mathbf{q} onto the image plane along the ray from e\mathbf{e} to q\mathbf{q}, which is exactly what we need. Implementation in the vertex shader is straightforward:

// Eye position in world space uniform vec3 EyePos; // Plane point and normal uniform vec3 PlanePoint; uniform vec3 PlaneNormal; void main() { vec3 worldPos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; vec3 rayDir = normalize(worldPos - EyePos); float denom = dot(PlaneNormal, rayDir); float t = dot(PlanePoint - EyePos, PlaneNormal) / denom; vec3 projected = EyePos + rayDir * t; // Map projected.x, projected.y to [-1,1] using your plane axes and size // ... }

3.2. Comparison


Perspective Projection
Portal Projection

In perspective projection, the rings 'self-rotate' instead of following the screen rotation.

Perspective Projection
Move mouse to rotate

4. Interaction

The materials and lightings are just standard PBR. Most of the effort went into sensor data processing to make the interaction feel natural, stable, and responsive.

4.1. Sensor Data Handling

This project is built upon Android platform. Android provides a variety of sensor data. (see: Android Motion Sensors.) The data categories we use are:

SensorPurpose
GyroscopeAngular velocity
Linear AccelerometerLinear acceleration
GravityGravity vector components
MagnetometerMagnetic field components

We estimate device pose by integrating angular velocity over time, with gravity- and magnetometer-based corrections to limit drift:

Rk=Rk1+akΔt+corrk\mathbf{R}_{k} = \mathbf{R}_{k-1} + \mathbf{a}_{k}\cdot \Delta t + \mathbf{corr}_{k}

Where:

  • Rk\mathbf{R}_k current orientation
  • Rk1\mathbf{R}_{k-1} previous orientation
  • Δt\Delta t frame delta
  • ak\mathbf{a}_k current angular velocity
  • corrk\mathbf{corr}_k correction from other sensors to limit drift

From here, given an initial orientation R0\mathbf{R}_0, we can solve device orientation at any given time.

The formula looks heavy, but the idea is basic. Putting it down mathematically helps clarify my thinking.

4.1.1. Initial Orientation

On first launch, we use gravity vector and magnetometer readings to set the device's world-space orientation.

function orientate_by_mag_and_grav() { // Magnetometer const p_mag = get_magnetometer() // Gravity const p_grav = get_gravity() let device_basis = new Basis() // Device y points opposite to gravity device_basis.y = -p_grav.normalized() // Device x = plane normal of (gravity, magnetic north) device_basis.x = device_basis.y.cross(p_mag) // Device z = third orthogonal axis device_basis.z = device_basis.x.cross(device_basis.y) }

We adopt a left-handed coordinate system here where x\mathbf{x} points east, y\mathbf{y} points up, and z\mathbf{z} points north. Specifically, with the world basis taken as the identity:

[xyz]=[1 0 00 1 00 0 1]\begin{bmatrix} x\\ y\\ z \end{bmatrix} = \begin{bmatrix} 1\ 0\ 0\\ 0\ 1\ 0\\ 0\ 0\ 1 \end{bmatrix}

The device is held upright (screen vertical), with the camera facing north.

4.1.2. Discrete Integration

Sensor noise is a constant concern. Raw gyroscope data readings introduce jitter if applied directly to rendering, so the view may rotate slightly even when users hold their phone still. We mitigate this with a small deadband (quantization threshold), suppress angular changes below the threshold to remove tiny motions.

function deadband(gyro) { let r = new Vector3(); r.x = round(gyro.x * 10.0) / 10.0; r.y = round(gyro.y * 10.0) / 10.0; r.z = round(gyro.z * 10.0) / 10.0; return r; } // GLOBAL let BASIS_WORLD; function on_init() { // ... BASIS_WORLD = orientate_by_mag_and_grav(); // ... } function accumulate(p_gyro, dt) { // Keep 1 decimal. Remove micro jitter. const gyro = deadband(p_gyro); let r = Basis() // Discrete integration r = r.rotated(BASIS_WORLD.x, -gyro.x * dt) r = r.rotated(BASIS_WORLD.y, -gyro.y * dt) r = r.rotated(BASIS_WORLD.z, -gyro.z * dt) BASIS_WORLD = r * BASIS_WORLD }

4.1.3. Easing

There are three motion-sensor related issues that require extra attention:

  1. Sensor updates are not at a fixed rate.
  2. Frame intervals are variable due to inconsistent processing times.
  3. User motion can produce regional spikes. (e.g. hand shake, walking, vehicle vibrations, etc.)

Because of processing limits, sensor sampling rates are bounded, so the data are discrete and often uneven. When a spike is captured, the rendered view can jump noticeably, causing flicker. A common solution is to ease between value updates (i.e. interpolation, smoothing) rather than applying raw changes.

Easing has two components: interpolation and curve.

  • Spherical interpolation

There are two common mathematical representations for rotations: Euler Angle and quaternion. Their pros and cons are covered in many great resources, e.g. videos by Freya and 3Blue1Brown. Here I will highlight a special scenario that makes Quaternions necessary in our application.

Euler Interpolation
Quaternion Interpolation

Interpolation in euler angle does not follow the expected path

Euler Interpolation
Move mouse to rotate

  • Spring curve

We do not need a physically accurate spring simulation, a visual approximation is good enough in our case. Given a target TT, and the previous value xn1x_{n-1}, update the current value xnx_n as:

xn=kT+(1k)xn1x_n = k T + (1 - k) x_{n-1}

This simple update produces a sufficiently spring-like behaviour. Let's sketch a brief derivation to see why.

Consider a damped spring system defined by:

F(t)=mx¨+cx˙+k(xT)F(t) = m\ddot{x}+c\dot{x}+k(x-T)

Let F(t)=0F(t)=0, m=1m=1, x¨=0\ddot{x}=0:

x˙=kc(Tx)\begin{align} \dot{x}=\frac{k}{c}(T-x) \end{align}

In our update function, treat x˙=xnxn1\dot{x} = x_n - x_{n-1}:

xn=kT+(1k)xn1xnxn1=k(Txn1)x˙=k(Txn1)\begin{align} x_n &= k T + (1 - k) x_{n-1}\nonumber\\ x_n - x_{n-1} &= k (T - x_{n-1})\nonumber\\ \dot{x} &= k (T - x_{n-1}) \end{align}

From Eq.(1)Eq. (1) and Eq.(2)Eq. (2), the first derivative of our update and the physical model share the same form: velocity is proportional to the error. In other words, the farther the current value is from the target, the faster it moves toward it.

4.2. Over-limit Handling

Users may rotate beyond 360°, even making multiple turns. In a physical infinity-mirror setup, that would place you 'behind' the window (outside the visible half-space). To keep the experience stable, we add an over-limit policy.

  • Relative Rotation

In Initial Pose section, we resolved orientation in absolute coordinates using gravity and magnetic north. As a result, if the user faces south, they end up 'behind' the mirror and nothing appears. Obviously, we can't require users to face north just to read the time.

We can use the initial pose as an anchor and define a relative coordinate frame to rotate within it.

// GLOBAL let BASIS_WORLD: Basis; let Q_ANCHOR: Quaternion; let Q_WORLD: Quaternion; function on_init() { // ... BASIS_WORLD = orientate_by_mag_and_grav(); Q_WORLD = Quaternion.from(BASIS_WORLD); Q_ANCHOR = Q_WORLD; //... }
  • Spring-follow

If the angle exceeds a threshold, use a spring to move the anchor toward the user. In physical terms, it's like turning the mirror to face them.

function update_anchor() { // Relative spherical distance const angle = Q_ANCHOR.angle_to(Q_WORLD); if (angle > MAX_ANGLE) { // Move anchor Q_ANCHOR = Q_ANCHOR.slerp(Q_WORLD, ANCHOR_MOVE_FACTOR); } } function on_update(dt) { // ... update_anchor(); // Relative rotation for render const q_diff = Q_ANCHOR.inverse() * Q_WORLD; // ... }

By combining relative rotation with the spring-follow mechanism, the experience remains stable even under large motions.

4.3. AOD

AOD aims to save power and reduce burn-in risk. We do:

  • Lower brightness: 30% of normal
  • Simplify rendering: thinner font, hide inner rings behind the camera, fewer edge rings

5. Future Improvements

  • More colors and materials

Right now there is only white theme. Richer environments and lighting will help. Rings can use more complex materials as well.


View some product shots I took here.

© 2020 - 2026 Ruiyao Luo

26/03/04 15:15

PROD

#764ff88