Fake Projector in Unreal

  • #unreal #graphics #gamedev
  • 490 words, Read time 2 minutes, 27 seconds

At work I needed to project a render target onto some meshes. The texture streams live from a scene capture, so really I wanted a virtual projector that casts whatever a camera in the scene sees.

The intuitive route is a light function. A projector is just a light with a texture, and a light function material casts that texture as light. It works, but it comes out blurry, and worse, no light in Unreal has a camera-like frustum. Spotlights project through a cone, not a rectangular perspective frustum, and bolting one on means modifying engine source. The shape a light throws is baked deep into the renderer: the deferred lighting pass culls lights against their bounding volume, a sphere for point lights and a cone for spots, and the attenuation and shadow projection math all assume those same shapes. Swapping in a rectangular perspective frustum means touching the culling geometry, the falloff, and the shadow path all at once. Not a rabbit hole I wanted on a deadline.

So I switched to decals. A deferred decal is sharper, and it is easy to pick which meshes receive it without fighting light channels. Same catch though: a decal projects through a box, not a camera frustum. But a box is close enough, so I reshape it in the material with a small custom node:

// Delta = pixel world position - camera position (LWC-safe float3).
// Project the receiving pixel into the camera's view space.
float z = dot(Delta, CamF);                        // depth along camera forward
float2 tanHalf = max(float2(TanX, TanY), 0.0001f); // half-FOV tangents, guarded
float2 p = float2(dot(Delta, CamR), dot(Delta, CamU)) / (max(z, 0.0001f) * tanHalf);

// p is now NDC in [-1, 1] inside the frustum. Map it to [0, 1] UVs.
float2 uv = p * float2(0.5f, -0.5f) + 0.5f;

// Soften the frustum's side walls so the edges fade instead of clipping.
float2 ef = saturate((1.0f - abs(p)) / max(EdgeSoft, 0.0001f));
float mask = ef.x * ef.y;

// Cut anything behind the near plane, then fade out toward the far plane.
mask *= step(NearClip, z);
mask *= 1.0f - smoothstep(FadeStart, max(FarClip, FadeStart + 0.0001f), z);

// Rebuild the surface normal from depth, aim it at the viewer, then cosine
// falloff so the beam does not wrap onto back-facing surfaces.
float3 N = normalize(cross(ddx(Delta), ddy(Delta)));
N *= sign(dot(N, ViewDir));
mask *= saturate(dot(N, -CamF));

return float3(uv, mask);

The short version: z is how far the pixel sits down the camera's forward axis, p divides its sideways offset by the FOV tangents to land in normalized device coordinates, and those become the UVs I sample. Everything after is masking: soft side walls, a near cut and far fade along the beam, and a depth-reconstructed normal so the image only lands on surfaces actually facing the projector.

Point it at a camera reference in the scene, and the decal shows exactly what that camera sees. Pretty cool.