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.
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:
- 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.
-
A ScriptableRenderPass with an injection point. The injection point is
a real decision, not a default:
BeforeRenderingPostProcessingif the effect should be tonemapped and bloomed with the scene,AfterRenderingPostProcessingif it should not, and the difference is whether your neon gets bloom applied to it or has to make its own. -
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 isRenderGraphUtils.BlitMaterialParametersplusAddBlitPass. - 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:
- It is invisible on a symmetric scene. Test with something that has an obvious up: a floor, a sky, text.
- It is platform-dependent, so it does not reproduce on the machine where it was written.
- Two flips cancel. If a hand-rolled blit flips and the shader also flips, the result is correct on one platform and doubly wrong on the other, which is the version that takes longest to find.
The other four that catch everyone
- Reading and writing the same target. A full-screen pass that samples the camera color and writes to it is undefined. The engine's blit helpers handle the double buffering; a hand-rolled one does not, and the symptom is a flickering or trailing image that looks like a temporal effect nobody enabled.
- Scene view and game view. A pass that does not check the camera type runs in the scene view too, which is either useful or maddening. Decide deliberately.
- The material is an asset, so it is shared. Setting properties on it from the render feature mutates the asset in the editor and those changes persist. Use a runtime copy.
-
Time.
_Time.yis notiTime: it is time since level load, it is affected byTime.timeScale, and it grows large enough thatsinloses precision after a few hours. A shader tuned in a browser against a smalliTimecan develop a visible stutter in a long play session. Feed your own time uniform and wrap it.
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
- Use the engine's blit and full-screen vertex helpers. They exist because of the flip.
- Test with a scene that has an obvious up. A symmetric test scene hides the bug entirely.
- Choose the injection point deliberately. It decides whether your effect gets bloomed and tonemapped with the scene.
- Never read and write the same target. Let the helper double buffer.
- Copy the material at runtime. Setting properties on the asset persists in the editor.
- Supply your own wrapped time.
_Time.yis neither zero-based nor bounded. - Keep the field code in a shared file and the engine specifics in the wrapper, so the browser stays a real iteration surface.
One email when something new goes up. No newsletter, no schedule, nothing else.
Double opt-in, so watch for a confirmation email. Unsubscribe any time.