RT Graphics Programming
Lesson 19 of 28

Part 2 · Vulkan implementation

19. The Camera Becomes Shader Input

Transfer the Part I camera basis into shader-visible data and reconstruct one primary direction per invocation.

Graphics glossary 49 terms mentioned in this lesson

Select any term for a clear definition. The return button brings you back to the exact term link you used.

The diagnostic shader obtained an invocation coordinate and image extent, but it did not own a camera. The same camera basis constructed in Lesson 3 must now become shader-visible data. This is not a new camera model. Vulkan changes the resource and execution mechanism. The mapping from integer pixel to centre sample, viewport displacement and normalised primary direction must remain comparable with the manual renderer.

It is tempting to send a view-projection matrix because graphics examples already have one. That matrix is useful for rasterisation, but it conceals the vectors the ray tracer actually needs. Supplying origin, forward, right, up, aspect ratio and tangent of half the vertical field of view lets the shader reconstruct each direction in the same terms as Part I. We can introduce matrices later only when they earn their place.

What you should be able to account for

  • Define one host and shader camera layout using aligned 16-byte records rather than guessed compiler packing.
  • Update a per-frame uniform buffer only after its fence shows that previous device use is complete.
  • Reconstruct and validate the primary direction for selected centre and corner invocations.

Choose a layout that is difficult to misunderstand

The camera block uses five 16-byte records. Unused components carry scalar values rather than relying on a mixture of three-component vectors and implicit padding. This is not the smallest possible block. It is an inspectable first contract.

struct alignas(16) Float4 {
    float x, y, z, w;
};

struct alignas(16) UInt4 {
    uint32_t x, y, z, w;
};

struct alignas(16) CameraGPU {
    Float4 originTanHalfFov; // xyz origin, w tan(fov / 2)
    Float4 rightAspect;      // xyz right,  w aspect ratio
    Float4 up;               // xyz corrected camera up
    Float4 forward;          // xyz camera forward
    UInt4 extentAndFrame;    // xy image extent, z frame, w spare
};

static_assert(sizeof(CameraGPU) == 80);
static_assert(alignof(CameraGPU) == 16);

The static assertions describe the host object. The shader declaration must describe the same sequence. std140 is suitable for the uniform block, and the all-vec4 structure keeps each member on an obvious 16-byte boundary.

layout(set = 0, binding = 1, std140) uniform CameraBlock {
    vec4 originTanHalfFov;
    vec4 rightAspect;
    vec4 cameraUp;
    vec4 cameraForward;
    uvec4 extentAndFrame;
} camera;

Binding zero remains the output storage image. Adding camera data at binding one preserves the earlier resource route. Extend the descriptor set layout, descriptor pool and update list with one VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER descriptor visible to the compute stage.

Create a buffer for each overlapping frame

A host-visible, host-coherent uniform buffer gives the CPU a simple update path. Device-local staging would be appropriate for large or infrequently changed data; 80 bytes of camera state does not need that machinery in this teaching route. If the selected memory is not host coherent, explicit flush and invalidate operations are required for the relevant mapped ranges.

VkBufferCreateInfo bufferInfo{
    .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
    .size = sizeof(CameraGPU),
    .usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
    .sharingMode = VK_SHARING_MODE_EXCLUSIVE
};

// Create, query requirements, allocate compatible
// HOST_VISIBLE | HOST_COHERENT memory, and bind it.
FrameResources& frame = frames[frameIndex];
frame.cameraBuffer = createBoundBuffer(bufferInfo, cameraMemoryFlags);
frame.cameraMapped = mapWholeAllocation(frame.cameraBuffer.memory);

Each frame in flight owns a camera buffer and descriptor set that refers to it. Before the host overwrites the mapped block, it waits for that frame's fence. The fence is evidence that the earlier submission using those values has completed. Updating all frames through one mapped pointer without a reuse rule would make the camera race the device.

check(vkWaitForFences(
    device, 1, &frame.fence, VK_TRUE, UINT64_MAX));
check(vkResetFences(device, 1, &frame.fence));

CameraGPU gpuCamera = buildCameraGPU(
    camera,
    outputExtent,
    accumulatedFrameIndex);

std::memcpy(
    frame.cameraMapped,
    &gpuCamera,
    sizeof(gpuCamera));

The accumulated frame index is included for the deterministic sample sequence used in Lesson 26. In this lesson it remains zero. Supplying it now is acceptable because its field and meaning are stated; using an unnamed spare value would not be.

Reconstruct the primary ray in the shader

The vertical image coordinate increases downwards. Camera up increases upwards. Preserve the Part I correction by using 1 - 2v. The sample position includes one half so an invocation represents the pixel centre rather than its upper-left boundary.

struct Ray {
    vec3 origin;
    vec3 direction;
};

Ray makePrimaryRay(uvec2 pixel) {
    vec2 extent = vec2(camera.extentAndFrame.xy);
    vec2 uv = (vec2(pixel) + vec2(0.5)) / extent;

    float tanHalfFov = camera.originTanHalfFov.w;
    float aspect = camera.rightAspect.w;
    float horizontal = (2.0 * uv.x - 1.0)
                     * aspect * tanHalfFov;
    float vertical = (1.0 - 2.0 * uv.y)
                   * tanHalfFov;

    vec3 direction = normalize(
          camera.cameraForward.xyz
        + horizontal * camera.rightAspect.xyz
        + vertical   * camera.cameraUp.xyz);

    Ray ray;
    ray.origin = camera.originTanHalfFov.xyz;
    ray.direction = direction;
    return ray;
}

For now, encode the direction as a diagnostic colour using 0.5 * (D + 1). This produces the same normal-map style used in Part I and makes sign errors visible. A mirrored horizontal pattern suggests a reversed right vector or horizontal mapping. An upside-down pattern suggests the vertical conversion lost its sign change. A centre that does not point close to forward suggests the basis or sample calculation has changed.

Decide what belongs in a push constant

A push constant is a small command-supplied block declared through a pipeline-layout range. It is useful for values that change with a recorded dispatch and do not justify a buffer binding. We shall use one for the single diagnostic sphere in Lesson 20. Camera data remains a uniform buffer because it already forms a named per-frame resource and will later be shared by more than one shader route.

This is not a universal classification. A small camera could fit in push constants on many devices. The useful criterion here is whether the choice makes lifetime, update frequency and interface ownership easy to inspect. We must also query and respect maxPushConstantsSize before declaring a range.

Follow invocation (11, 6)

For a 16 by 10 image, the centre sample is (11.5, 6.5). Therefore u = 0.71875 and v = 0.65. The horizontal factor is 0.4375 and the vertical factor is -0.3 before applying aspect and tangent of half field of view. The negative vertical contribution moves the ray below forward, as row 6 lies below the image centre. The shader result and a CPU reference should agree within a stated floating-point tolerance.

Validate data rather than merely looking at colour

Direction colours provide a broad diagnostic, but they are not enough for exact comparison. Add a temporary storage buffer for selected debug records or copy a small output region to host-visible memory after an appropriate transfer dependency. Record pixel coordinate, uv and direction for the centre and four corners. Compare those values with the JavaScript reference or a small CPU implementation of the same formula. Remove or disable the readback in the ordinary render path once agreement is established.

Explain the downward row

An invocation has v = 0.8. Should its camera-up coefficient be positive or negative, and which expression produces it?

The answer

It should be negative because a large browser or image row lies below the centre while camera up is positive upwards. The coefficient begins with 1 - 2v, giving 1 - 1.6 = -0.6, before multiplication by the half-height determined by field of view.

Official reference: use the Khronos shader-interface chapter and data-to-shader mapping guide when checking uniform and push-constant layout requirements.

Make CPU and shader directions disagree on purpose

Record the centre and four corner directions from both implementations. Then remove the half-texel offset in the shader only. Explain why every invocation moves towards a pixel boundary and why the disagreement is largest in a small diagnostic image. Restore the centre rule, then reverse the cross-product operands used to construct camera right on the host. Identify the mirrored component numerically before accepting the visible pattern as an explanation.

Carry the ray contract forward

Retain the shader Ray with origin and unit direction. Retain the camera block and its binding. Lesson 20 will add a diagnostic sphere through a push-constant range, solve the same quadratic as Part I and write a hit-or-miss image. The storage image, dispatch and presentation route remain unchanged, so an intersection error cannot be excused as a new output mechanism.

Each compute invocation can now construct the directed question belonging to its image sample. Vulkan supplied a coordinate and a route to resources; it did not supply the camera. That distinction is precisely what Part I prepared us to see. Lesson 20 gives every primary ray one sphere equation and checks the accepted root before adding a scene buffer.