Files
Juliet/AgentData/lighting_and_skybox_plan.md

9.7 KiB
Raw Permalink Blame History

Lighting & Skybox Plan

Current State

Item Current
Vertex format float3 Position + float4 Color (28 bytes, no normals)
Vertex shader Triangle.vert.hlsl — bindless buffer read, mul(VP, mul(Model, pos))
Fragment shader SolidColor.frag.hlsl — passthrough return Color
Push constants ViewProjection + Model + BufferIndex + extras
Texturing None — no sampler usage, no texture reads

Phase 1: Add Normals to Vertex Data

Lighting requires normals. This is the foundation for everything else.

Vertex Format Change

 struct Vertex
 {
     float Position[3];
+    float Normal[3];
     float Color[4];
 };
  • Stride: 28 → 40 bytes
  • Update Triangle.vert.hlsl stride constant: uint stride = 40;
  • Load normal after position: float3 normal = asfloat(buffer.Load3(offset + 12));
  • Load color shifts to: float4 col = asfloat(buffer.Load4(offset + 24));

Files to modify

File Change
VertexData.h Add float Normal[3] to Vertex
MeshRenderer.cpp Update AddCube() to include per-face normals
Triangle.vert.hlsl Update stride, load normal, pass to fragment

Phase 2: Basic Directional Light (Diffuse)

Simple single directional light with diffuse (Lambert) shading.

Approach: Push light data through RootConstants

Add light direction and color to RootConstants.hlsl and the C++ PushData:

// RootConstants.hlsl additions
float3   LightDirection;  // Normalized, world-space
float    _LightPad;
float3   LightColor;
float    AmbientIntensity;
// PushData additions
Vector3 LightDirection;
float   _LightPad;
Vector3 LightColor;
float   AmbientIntensity;

Shader Changes

Vertex shader — transform normal to world space and pass it to fragment:

// Triangle.vert.hlsl output struct
struct Output
{
    float4 Color    : TEXCOORD0;
    float3 WorldNormal : TEXCOORD1;
    float4 Position : SV_Position;
};

// In main():
float3 worldNormal = mul((float3x3)Model, normal);
output.WorldNormal = worldNormal;

Note

Using (float3x3)Model for normal transform is only correct for uniform-scale transforms. For non-uniform scale, you'd need the inverse-transpose. Fine for now with translation-only transforms.

Fragment shader — apply Lambert diffuse:

// SolidColor.frag.hlsl → rename to Lit.frag.hlsl
#include "RootConstants.hlsl"

float4 main(float4 Color : TEXCOORD0, float3 WorldNormal : TEXCOORD1) : SV_Target0
{
    float3 N = normalize(WorldNormal);
    float  NdotL = saturate(dot(N, -LightDirection));
    float3 diffuse = Color.rgb * LightColor * NdotL;
    float3 ambient = Color.rgb * AmbientIntensity;
    return float4(diffuse + ambient, Color.a);
}

Files to modify

File Change
RootConstants.hlsl Add light params
MeshRenderer.h Add light params to PushData
Triangle.vert.hlsl Pass world normal to fragment
SolidColor.frag.hlsl Lambert diffuse + ambient
DebugDisplayRenderer.cpp Update push data struct to match new layout
main.cpp Set LightDirection, LightColor, AmbientIntensity

Phase 3: Skybox

A skybox renders a cubemap texture behind all geometry, giving the scene a background.

Approach: Fullscreen-triangle with inverse VP

Render a fullscreen triangle as the very last thing (or first with depth write off), sample a cubemap using the camera view direction reconstructed from screen coordinates.

New Assets

  • Skybox cubemap texture — a .dds or 6 .png face images loaded as a TextureCube
  • New shaders: Skybox.vert.hlsl + Skybox.frag.hlsl

Shader Design

Vertex shader — fullscreen triangle using SV_VertexID:

// Skybox.vert.hlsl
#include "RootConstants.hlsl"

struct Output
{
    float3 ViewDir  : TEXCOORD0;
    float4 Position : SV_Position;
};

Output main(uint vertexID : SV_VertexID)
{
    Output output;

    // Fullscreen triangle
    float2 uv = float2((vertexID << 1) & 2, vertexID & 2);
    float4 clipPos = float4(uv * 2.0 - 1.0, 1.0, 1.0);
    clipPos.y = -clipPos.y;

    output.Position = clipPos;

    // Reconstruct view direction from clip space
    // InverseViewProjection needs to be added to RootConstants
    float4 worldPos = mul(InverseViewProjection, clipPos);
    output.ViewDir = worldPos.xyz / worldPos.w;

    return output;
}

Fragment shader — sample cubemap:

// Skybox.frag.hlsl
#include "RootConstants.hlsl"

float4 main(float3 ViewDir : TEXCOORD0) : SV_Target0
{
    TextureCube skybox = ResourceDescriptorHeap[TextureIndex];
    SamplerState samp = SamplerDescriptorHeap[0]; // Linear clamp
    return skybox.Sample(samp, normalize(ViewDir));
}

Pipeline Requirements

Setting Value
Depth write Off (skybox is infinitely far)
Depth test LessEqual or Off (render behind everything)
Cull mode None (fullscreen triangle)
Draw call Draw(3, 0) — no vertex buffer needed

New C++ Components

  1. Cubemap loading — need to create TextureCube from 6 face images or a .dds cubemap file
  2. Skybox pipeline — new GraphicsPipeline with the skybox shaders and the pipeline settings above
  3. Sampler — need at least one linear sampler in the sampler heap (may already exist for ImGui)
  4. InverseViewProjection — add to RootConstants and compute in C++ via a MatrixInverse function

Important

MatrixInverse needs to be implemented in Matrix.h. This is a non-trivial 4×4 matrix inversion (adjugate/determinant method or Gauss-Jordan).

Files to modify/create

File Change
[NEW] Skybox.vert.hlsl Fullscreen triangle + view direction
[NEW] Skybox.frag.hlsl Cubemap sample
RootConstants.hlsl Add InverseViewProjection
Matrix.h Add MatrixInverse()
MeshRenderer.h Add InverseViewProjection to PushData
main.cpp Create skybox pipeline, load cubemap, render skybox

graph LR
    A["Phase 1<br/>Add Normals"] --> B["Phase 2<br/>Directional Light"]
    B --> C["Phase 3<br/>Skybox"]
  1. Phase 1 (normals) is the smallest and unblocks Phase 2
  2. Phase 2 (lighting) gives the cubes visible 3D depth immediately
  3. Phase 3 (skybox) is independent of lighting but benefits from having MatrixInverse and sampler infrastructure

Tip

Phases 1+2 together are a single session of work (~8 files). Phase 3 is larger due to cubemap loading and new pipeline creation.


Open Questions (Answered)

  1. Cubemap sourceProcedural gradient skybox chosen because asset loading infrastructure is not yet established.
  2. Sampler heap — Evaluate what exists. However, with a procedural skybox, we won't need a sampler for Phase 3! (The sky color can be procedurally generated from the reconstructed view direction).
  3. SpecularBlinn-Phong specular, structured in an agnostic way so PBR (physically based rendering) parameters can be plugged in later.

Phase 4: Forward Rendered Entity Lights

A simple, fast forward renderer using a Global Structured Buffer for entity-based point lights.

Approach: Bindless Global Lights Buffer

Instead of passing each light via PushConstants, we pass a StructuredBuffer<PointLight> index and iterate over the active lights in the fragment shader.

New C++ Components

struct PointLight
{
    Vector3 Position;
    float   Radius;
    Vector3 Color;
    float   Intensity;
};
  1. Lights Buffer Allocation: Allocate a StructuredBuffer large enough for all potential lights (e.g., max 1024).
  2. Buffer Upload: Loop through the active game entity lights every frame and upload them using the TransferBuffer system.
  3. PushData Update:
    • uint32 LightsBufferIndex
    • uint32 ActiveLightCount

Shader Design

Fragment Shader Expansion loop over all ActiveLightCount to compute the attenuation and diffuse, then accumulate.

StructuredBuffer<PointLight> lights = ResourceDescriptorHeap[LightsBufferIndex];

float3 totalDiffuse = Color.rgb * LightColor * NdotL; // Include Directional Sun

for (uint i = 0; i < ActiveLightCount; ++i)
{
    PointLight light = lights[i];
    
    float3 lightVector = light.Position - WorldPosition;
    float distance = length(lightVector);
    
    if (distance > light.Radius) continue;
    
    float3 L = lightVector / distance;
    float NdotL = saturate(dot(N, L));
    
    float attenuation = 1.0 - saturate(distance / light.Radius);
    attenuation *= attenuation; // inverse square-ish 
    
    totalDiffuse += Color.rgb * light.Color * light.Intensity * NdotL * attenuation;
}

Note

Passing WorldPosition to the Fragment shader from the Vertex shader is required for positional lighting.