One full-screen pass, and the flip that eats the day

Everything on this site is a fragment shader over the whole screen, so getting one running inside an engine is the entire integration. In URP that is a scriptable render feature, a material, and a blit. It is not much code and it has one specific way of wasting an afternoon.

The same scene through the same pass. Left: V flipped. Right: correct. Nothing else differs.

That is what a wrong V sign looks like, and the reason it costs time is that it is not an error. The pass runs, the shader compiles, the frame appears. On one graphics API it is right and on another it is upside down, so it can pass review on the machine it was built on and fail on a console or a phone.

The shape of the pass

Four pieces, and only the third has any subtlety:

  1. A ScriptableRendererFeature that owns the material and adds a pass. This is the object that appears in the renderer asset's inspector, and it is where the artist-facing settings live.
  2. A ScriptableRenderPass with an injection point. The injection point is a real decision, not a default: BeforeRenderingPostProcessing if the effect should be tonemapped and bloomed with the scene, AfterRenderingPostProcessing if it should not, and the difference is whether your neon gets bloom applied to it or has to make its own.
  3. A blit, which is where the flip lives. Use the engine's own helper rather than a hand-rolled Graphics.Blit, because the helper is the thing that knows about the platform's V convention. In Unity 6's RenderGraph this is RenderGraphUtils.BlitMaterialParameters plus AddBlitPass.
  4. A shader with a full-screen vertex stage that generates its own triangle from the vertex ID. There is no mesh. Include the engine's full-screen shader include rather than writing the vertex stage, for the same reason as the blit.

The flip, precisely

Graphics APIs disagree about whether texture coordinate zero is the top or the bottom, and rendering into a render texture is where the disagreement surfaces. Unity exposes the sign as _ProjectionParams.x, which is negative when the projection is flipped:

if (_ProjectionParams.x < 0)
    uv.y = 1.0 - uv.y;

Three things make this specific bug expensive:

The other four that catch everyone

Keeping the browser in the loop

The reason to be careful about all of this is that the browser is where a field gets tuned: iteration is instant, there is no compile, and the whole thing is one file. That is only worth anything if the shader that lands in the engine is the same shader.

So the pass should be a thin wrapper: it supplies resolution, time and mouse or their equivalents, and calls a mainImage-shaped function that lives in a shared file. Every difference between the two builds should be in the wrapper and none of it in the field code.

Rules of thumb

  1. Use the engine's blit and full-screen vertex helpers. They exist because of the flip.
  2. Test with a scene that has an obvious up. A symmetric test scene hides the bug entirely.
  3. Choose the injection point deliberately. It decides whether your effect gets bloomed and tonemapped with the scene.
  4. Never read and write the same target. Let the helper double buffer.
  5. Copy the material at runtime. Setting properties on the asset persists in the editor.
  6. Supply your own wrapped time. _Time.y is neither zero-based nor bounded.
  7. Keep the field code in a shared file and the engine specifics in the wrapper, so the browser stays a real iteration surface.

All 61 notes How to use them Credits