A wet street without screen-space reflections

· 4 min read · three.js, webgpu, tsl, shaders, rendering

The rainy neon street of Threejs-Punk depends on one thing: the road has to look wet. A wet road is not a dark road. It is a road that shows the signs above it, bent a little by the ripples where the rain lands.

The obvious tool for that is screen-space reflections, and I used them. Then I removed them. This post is what replaced them.

Why not SSR #

I tried screen-space reflections early, and dropped them. For the budget I had, a workshop laptop, they were too expensive, and they were not stable.

The other obvious tool is the TSL reflector() helper. On WebGPU there is a catch: inside a pass graph, the reflection texture can end up bound both as a sampled texture and as a render attachment in the same pass. The ground avoids that by rendering the mirror in a separate renderer.render call, into its own render target, and sampling that target from the material.

So the street has three parts: a tiled wet PBR material, procedural ripples for the normals, and a manual planar reflection.

Ripples in world space #

The ripple function is a TSL Fn. For each pixel it looks at a small neighborhood of cells, each cell has a hashed center and a phase that advances with time, and a ring expands from each center. The derivative of the ring, approximated with a central difference, is accumulated and turned into a normal.

The important choice is what it is sampled with:

const rippleSample = getRipples(positionWorld.xz.mul(uRippleScale));

World XZ, not the mesh's UVs. On a huge plane, that keeps the rings at a stable size and avoids seams where the texture repeats. The result is used twice: a small amount goes into the normal map, and another goes into the UV of the reflection, so the puddles distort the mirror.

A mirror camera, half the resolution #

Every frame, the ground renders the scene a second time from a mirrored camera into a half-resolution, half-float target:

  1. The camera is reflected across the ground plane.
  2. An oblique clip plane cuts everything below the ground.
  3. The rain layer is disabled, so the streaks are not reflected as solid geometry.
  4. The ground mesh is hidden, so it does not reflect itself.

Then comes the part that is unusual: the reflection is not used as an environment map. It is written to the emissive output, scaled by how smooth the surface is:

const wetness = roughness.oneMinus();
return reflectionTex.rgb.mul(wetness).mul(uReflectionStrength).mul(uReflectionEnabled);

Smoother texels reflect more, and the strength is small, 0.08 by default, so it is a hint of neon and not a chrome floor.

The fixes that took the time #

The first version looked right in the alley and wrong in the city. Most of the work was a list of small corrections, each one for something specific I could see:

  • The reflected neon lost its glow. The city raises the bloom threshold so lit windows do not glow, and the dim reflection never crossed it. The ground now writes its own emissive for bloom.
  • The reflections vanished. The signs in the city share one brightness level, around 1 to 2.5, and times 0.08 they disappeared. The reflection is multiplied by a highlight gain that depends on how bright the pixel is, so lights and neon get up to nine times more, and dim surfaces stay as they are.
  • The far street was colored noise. The ripple rings are about 4 cm apart, so past roughly 20 meters they are smaller than a pixel, and their offset in the mirror turned the distance into sparkle. The ripple offset fades out between 8 and 30 meters.
  • The whole street looked like glass. The reflection is now weighted by roughness to the power of 4: strong in puddles, weak on average asphalt and almost nothing on sidewalks.

What it costs #

The ripple loop is a fixed cost per ground pixel, three by three cells, and does not grow with the number of drops. The expensive part is the mirror render, so it has the knobs you would expect: a resolution scale, a frame skip, and a cheaper blur on phones.

The ground is still the most expensive surface on the screen. I would rather have one accurate wet road than a scene full of cheap effects.

The short version #

If you want to copy the technique:

  • tile albedo, roughness and normal on one UV,
  • add a world-space ripple function into the normal,
  • render a mirror camera into a half-resolution target, outside the post-processing pass,
  • clip it with an oblique plane and hide the mirror mesh while you do it,
  • sample it in the emissive, scaled by one minus roughness.

This is the companion of the rain post: the rain falls using a height map, and the ground is what it falls on.

Related projects