Pour My Friends is an Expo / React Native app (iOS, Android, web) whose one real feature is a GPU fluid simulation: you drag a finger across a coffee cup and milk pours into espresso, producing latte art. Everything else — splash, home, about — is a thin shell around that screen.
Expo Router file-based routing, all routes in app/. experiments.typedRoutes is on, so route
types are generated into .expo/types/router.d.ts when the dev server runs.
| Route | File | Screen |
|---|---|---|
/ |
app/index.tsx |
SplashIntroScreen — animated wordmark, auto-replaces to /home after 2.5s (tap skips) |
/home |
app/home.tsx |
HomeScreen — logo, "Start Pouring", "About" |
/rosetta |
app/rosetta.tsx |
RosettaScreen — the simulator plus its HUD and settings modal |
/about |
app/about.tsx |
AboutScreen — app info and version, back button |
app/_layout.tsx is the root: it loads the Inter Display / SF Pro Display fonts, holds the
splash screen until they resolve, forces the dark theme (COLORS from lib/colors.ts), wraps
everything in AnalyticsProviders, and renders a headerless Stack. app/+html.tsx is
web-only and sets the document shell.
components/screens/Rosetta/index.tsx (~1300 lines) is the whole feature. It has three parts:
- React screen —
GLViewfromexpo-glfilling the screen, a HUD bar, and a settings modal. APanRespondercreated once turns touches into splats: each move is interpolated into evenly spaced stamps (step = 0.012UV) so a fast drag still lays a continuous ribbon of milk, and a stationary finger drips on an interval. - Shader pipeline — GLSL sources live in
components/screens/Rosetta/shaders/, imported as strings throughbabel-plugin-inline-import(seetypes/shaders.d.ts). onContextCreate— everything WebGL: capability detection, framebuffer allocation, program compilation, and therequestAnimationFrameloop.
update() runs each frame: apply queued splats → step(dt) → composite → endFrameEXP().
step(dt) in order:
| Pass | Shader | What it does |
|---|---|---|
| Curl | curl.frag |
Measures swirl in the velocity field (skipped when CURL === 0) |
| Vorticity | vorticity.frag |
Amplifies that swirl back into velocity |
| Divergence | divergence.frag |
Measures where flow piles up |
| Scale | scale.frag |
Damps the previous pressure field before solving |
| Pressure | pressure.frag |
Jacobi solve, PRESSURE_ITERATIONS passes |
| Gradient subtract | gradient.frag |
Removes the pressure gradient, leaving incompressible flow |
| Advection ×2 | advection.frag + macCormack.frag |
Carries velocity along itself, then the milk/crema dye along velocity |
display.frag then composites the cup: espresso base, milk over it, valley detection for the
petal ridges, crema texture, specular highlight, ceramic rim.
Advection is MacCormack: advect forward, advect back, and correct with the round trip — that's
what keeps petal edges crisp instead of smearing. advectMacCormack() needs two scratch FBOs
per field (velHat/velBar, dyeHat/dyeBar).
Velocity, dye, and pressure are double-buffered (read/write + swap()), the standard
ping-pong pattern for feedback on the GPU.
getWebGLContext() probes for half-float render targets: WebGL2 uses RGBA16F/RG16F/R16F
with EXT_color_buffer_float, WebGL1 falls back to OES_texture_half_float.
getSupportedFormat() widens R16F → RG16F → RGBA16F if a format isn't render-complete, and
supportRenderTextureFormat() probes with a 4×4 texture that it always deletes. When linear
filtering isn't available the shaders take a manual-bilinear path.
computeCupParams(width, height) is exported and used in two places that must agree: the
PanResponder (to ignore touches outside the cup) and the GL uniforms (uCupCenter,
uCupRadiusUV, for shader branching). The cup is centered at UV (0.5, 0.5) with a diameter
of 85% of min(width, height); the radius is stored per-axis so it stays a circle on screen at
any aspect ratio. It's the one piece of the simulator with a unit test
(components/screens/Rosetta/__tests__/computeCupParams.test.ts).
config is a module-level object near the top of the file (line ~249). The settings modal
mutates it directly through SETTING_DEFS, so a change takes effect on the next frame — no
state, no re-render, no context rebuild. INITIAL_SETTINGS snapshots the values at module load
for "Reset to Defaults", and settings survive a sim reset because they don't live in React
state. simKey on the GLView is the escape hatch that does rebuild: bumping it recreates
the WebGL context for a clean cup.
Adjustable via the modal: pitcher height, pour width, pour force, flow decay, swirl, edge
definition, milk boundary, radial push, foam absorption, crema texture, milk opacity, milk
shine, shine focus. Resolutions (SIM_RESOLUTION 256, DYE_RESOLUTION 512) and
PRESSURE_ITERATIONS are code-level knobs; raising them costs frame time directly.
calcDeltaTime() clamps dt to 0.033 (30fps floor) — the sim slows rather than exploding
when frames get long.
components/primitives/— thincssInterop-wrapped RN components (Text,View,Image,Button,StyledPressable) so NativeWindclassNameworks, plusMilkText, the animated milk-fill wordmark used on the splash and home screens.components/providers/—AnalyticsProviderswrapsCaptureProvider; consume it withuseCapture(). Events dispatch throughlib/utilities/capture.tsand are typed bylib/core/enums/analyticEvents.tsandlib/core/models/analyticEvents/.hooks/—useAsyncStorage,useColorScheme,useNavigation,useCapture,useScale.lib/colors.ts— the light/dark palettes consumed by both@react-navigationthemes andtailwind.config.js(dark mode uses the class strategy).lib/utilities/cn.ts—clsx+tailwind-mergeclass merging.
expo-glrenders throughGLView; on web that's a real WebGL2 canvas, which is why the screenshot script can capture the simulator headlessly (see DEVELOPMENT.md).- The settings modal uses
pageSheetpresentation on iOS andfullScreenelsewhere — the onlyPlatformbranch in the simulator. - New Architecture is enabled; native directories are generated by
yarn prebuildand are not checked in.