A 3D accent needs a depth to sort against

One raymarched object in an otherwise 2D scene is an excellent trade: a single element that genuinely turns, catching light in a way no amount of 2D can fake, for the cost of one march. It stops being convincing the moment it needs to go behind something.

A raymarched octahedron orbiting a 2D pillar. Left: drawn last, so always in front. Right: its ray distance compared against the pillar's assigned depth.

Watch the far half of the orbit. On the left the accent stays in front of the pillar the whole way round, so the pillar reads as a painted stripe and the accent reads as a sticker. On the right it passes behind and comes back out, and both objects become solid things in a space.

The 2D scene has to volunteer a depth

This is the part that feels like it should be harder than it is. A raymarch already produces t, a real distance along the ray, for free. What is missing is something to compare it to, because a 2D layer has no depth at all.

So assign one. Not per pixel, not a depth map: one number per layer, in the same units the marcher uses.

const float DEPTH_BACK  = 3.30;   // background slabs
const float DEPTH_FRONT = 1.95;   // foreground pillar

// composite back to front, inserting the accent where t puts it
if (t >= DEPTH_BACK)                     drawAccent();
drawBackground();
if (t >= DEPTH_FRONT && t < DEPTH_BACK)  drawAccent();
drawForeground();
if (t <  DEPTH_FRONT)                    drawAccent();

That is the entire mechanism. A parallax scene already has these numbers implicitly, since layer scroll speed is a depth in disguise, and deriving one from the other keeps the two systems agreeing without a second source of truth.

The three things that give it away, in order

  1. Sorting, which is this note. The most obvious and the easiest to fix.
  2. Light direction. A correctly sorted accent lit from a different direction than everything else still reads as pasted on. One shared light vector, declared once and used by both, and there is nothing to keep in sync.
  3. Tonemapping applied twice. If the 2D scene is already tonemapped and the accent gets its own pass, the accent's contrast will not match anything. Composite in linear HDR and tonemap the whole frame once at the end.

The last one is the subtle one and it is worth stating as a rule: tonemap the frame, never the element. Anything tonemapped separately is on its own curve and cannot match.

Getting it to belong

What it costs

One march is affordable in a way a full 3D scene is not, and the reason is the pixel count: the accent covers a small part of the screen, so a bounding test around it skips the march entirely for most of the frame. That is a genuine early-out here, unlike the per-shard bounding test, because the bound is one shape and every pixel outside it rejects together.

Rules of thumb

  1. The march gives you t for free. The 2D scene has to supply a depth to compare it against.
  2. One depth per layer is enough. You do not need a depth buffer.
  3. Derive layer depths from parallax scroll speeds so there is one source of truth.
  4. Share one light direction between the 2D and 3D halves.
  5. Tonemap the frame, never the element. Composite in linear and curve once at the end.
  6. A contact shadow buys more belonging than correct sorting does.

All 61 notes How to use them Credits