Files
game_engine/docs/superpowers/plans/2026-03-25-phase7-1-deferred-rendering.md
2026-03-25 13:25:11 +09:00

32 KiB

Phase 7-1: Deferred Rendering Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: G-Buffer + Lighting Pass 디퍼드 렌더링 파이프라인으로 다수의 라이트를 효율적으로 처리

Architecture: voltex_renderer에 새 모듈 추가. G-Buffer pass(MRT 4개)가 기하 데이터를 기록하고, Lighting pass(풀스크린 삼각형)가 G-Buffer를 읽어 Cook-Torrance BRDF + 섀도우 + IBL 라이팅을 수행. 기존 포워드 PBR은 유지.

Tech Stack: Rust, wgpu 28.0, WGSL

Spec: docs/superpowers/specs/2026-03-25-phase7-1-deferred-rendering.md


File Structure

voltex_renderer (추가)

  • crates/voltex_renderer/src/gbuffer.rs — GBuffer 텍스처 생성/리사이즈 (Create)
  • crates/voltex_renderer/src/fullscreen_quad.rs — 풀스크린 삼각형 (Create)
  • crates/voltex_renderer/src/deferred_gbuffer.wgsl — G-Buffer pass 셰이더 (Create)
  • crates/voltex_renderer/src/deferred_lighting.wgsl — Lighting pass 셰이더 (Create)
  • crates/voltex_renderer/src/deferred_pipeline.rs — 파이프라인 생성 함수들 (Create)
  • crates/voltex_renderer/src/lib.rs — 새 모듈 등록 (Modify)

Example (추가)

  • examples/deferred_demo/Cargo.toml (Create)
  • examples/deferred_demo/src/main.rs (Create)
  • Cargo.toml — workspace members (Modify)

Task 1: GBuffer + Fullscreen Triangle

Files:

  • Create: crates/voltex_renderer/src/gbuffer.rs

  • Create: crates/voltex_renderer/src/fullscreen_quad.rs

  • Modify: crates/voltex_renderer/src/lib.rs

  • Step 1: gbuffer.rs 작성

// crates/voltex_renderer/src/gbuffer.rs

pub const GBUFFER_POSITION_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
pub const GBUFFER_NORMAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
pub const GBUFFER_ALBEDO_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
pub const GBUFFER_MATERIAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;

pub struct GBuffer {
    pub position_view: wgpu::TextureView,
    pub normal_view: wgpu::TextureView,
    pub albedo_view: wgpu::TextureView,
    pub material_view: wgpu::TextureView,
    pub depth_view: wgpu::TextureView,
    pub width: u32,
    pub height: u32,
}

impl GBuffer {
    pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
        let position_view = create_rt(device, width, height, GBUFFER_POSITION_FORMAT, "GBuffer Position");
        let normal_view = create_rt(device, width, height, GBUFFER_NORMAL_FORMAT, "GBuffer Normal");
        let albedo_view = create_rt(device, width, height, GBUFFER_ALBEDO_FORMAT, "GBuffer Albedo");
        let material_view = create_rt(device, width, height, GBUFFER_MATERIAL_FORMAT, "GBuffer Material");
        let depth_view = create_depth(device, width, height);
        Self { position_view, normal_view, albedo_view, material_view, depth_view, width, height }
    }

    pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) {
        *self = Self::new(device, width, height);
    }
}

fn create_rt(device: &wgpu::Device, w: u32, h: u32, format: wgpu::TextureFormat, label: &str) -> wgpu::TextureView {
    let tex = device.create_texture(&wgpu::TextureDescriptor {
        label: Some(label),
        size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
        mip_level_count: 1,
        sample_count: 1,
        dimension: wgpu::TextureDimension::D2,
        format,
        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
        view_formats: &[],
    });
    tex.create_view(&wgpu::TextureViewDescriptor::default())
}

fn create_depth(device: &wgpu::Device, w: u32, h: u32) -> wgpu::TextureView {
    let tex = device.create_texture(&wgpu::TextureDescriptor {
        label: Some("GBuffer Depth"),
        size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
        mip_level_count: 1,
        sample_count: 1,
        dimension: wgpu::TextureDimension::D2,
        format: crate::gpu::DEPTH_FORMAT,
        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
        view_formats: &[],
    });
    tex.create_view(&wgpu::TextureViewDescriptor::default())
}
  • Step 2: fullscreen_quad.rs 작성
// crates/voltex_renderer/src/fullscreen_quad.rs
use bytemuck::{Pod, Zeroable};

#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct FullscreenVertex {
    pub position: [f32; 2],
}

impl FullscreenVertex {
    pub const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
        array_stride: std::mem::size_of::<FullscreenVertex>() as wgpu::BufferAddress,
        step_mode: wgpu::VertexStepMode::Vertex,
        attributes: &[
            wgpu::VertexAttribute {
                offset: 0,
                shader_location: 0,
                format: wgpu::VertexFormat::Float32x2,
            },
        ],
    };
}

/// Oversized triangle that covers the entire screen after clipping.
pub const FULLSCREEN_VERTICES: [FullscreenVertex; 3] = [
    FullscreenVertex { position: [-1.0, -1.0] },
    FullscreenVertex { position: [ 3.0, -1.0] },
    FullscreenVertex { position: [-1.0,  3.0] },
];

pub fn create_fullscreen_vertex_buffer(device: &wgpu::Device) -> wgpu::Buffer {
    use wgpu::util::DeviceExt;
    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
        label: Some("Fullscreen Vertex Buffer"),
        contents: bytemuck::cast_slice(&FULLSCREEN_VERTICES),
        usage: wgpu::BufferUsages::VERTEX,
    })
}
  • Step 3: lib.rs에 모듈 등록
pub mod gbuffer;
pub mod fullscreen_quad;

And add re-exports:

pub use gbuffer::GBuffer;
pub use fullscreen_quad::{create_fullscreen_vertex_buffer, FullscreenVertex};
  • Step 4: 빌드 확인

Run: cargo build -p voltex_renderer Expected: 컴파일 성공

  • Step 5: 커밋
git add crates/voltex_renderer/src/gbuffer.rs crates/voltex_renderer/src/fullscreen_quad.rs crates/voltex_renderer/src/lib.rs
git commit -m "feat(renderer): add GBuffer and fullscreen triangle for deferred rendering"

Task 2: G-Buffer Pass 셰이더

Files:

  • Create: crates/voltex_renderer/src/deferred_gbuffer.wgsl

  • Step 1: deferred_gbuffer.wgsl 작성

// G-Buffer pass: writes geometry data to multiple render targets

struct CameraUniform {
    view_proj: mat4x4<f32>,
    model: mat4x4<f32>,
    camera_pos: vec3<f32>,
};

struct MaterialUniform {
    base_color: vec4<f32>,
    metallic: f32,
    roughness: f32,
    ao: f32,
};

@group(0) @binding(0) var<uniform> camera: CameraUniform;

@group(1) @binding(0) var t_diffuse: texture_2d<f32>;
@group(1) @binding(1) var s_diffuse: sampler;
@group(1) @binding(2) var t_normal: texture_2d<f32>;
@group(1) @binding(3) var s_normal: sampler;

@group(2) @binding(0) var<uniform> material: MaterialUniform;

struct VertexInput {
    @location(0) position: vec3<f32>,
    @location(1) normal: vec3<f32>,
    @location(2) uv: vec2<f32>,
    @location(3) tangent: vec4<f32>,
};

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) world_pos: vec3<f32>,
    @location(1) world_normal: vec3<f32>,
    @location(2) uv: vec2<f32>,
    @location(3) world_tangent: vec3<f32>,
    @location(4) world_bitangent: vec3<f32>,
};

struct GBufferOutput {
    @location(0) position: vec4<f32>,
    @location(1) normal: vec4<f32>,
    @location(2) albedo: vec4<f32>,
    @location(3) material_out: vec4<f32>,
};

@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
    var out: VertexOutput;
    let world_pos = camera.model * vec4<f32>(in.position, 1.0);
    out.world_pos = world_pos.xyz;
    out.clip_position = camera.view_proj * world_pos;
    out.world_normal = normalize((camera.model * vec4<f32>(in.normal, 0.0)).xyz);
    out.uv = in.uv;

    let T = normalize((camera.model * vec4<f32>(in.tangent.xyz, 0.0)).xyz);
    let N = out.world_normal;
    let B = cross(N, T) * in.tangent.w;
    out.world_tangent = T;
    out.world_bitangent = B;

    return out;
}

@fragment
fn fs_main(in: VertexOutput) -> GBufferOutput {
    var out: GBufferOutput;

    // World position
    out.position = vec4<f32>(in.world_pos, 1.0);

    // Normal mapping
    let T = normalize(in.world_tangent);
    let B = normalize(in.world_bitangent);
    let N_geom = normalize(in.world_normal);
    let normal_sample = textureSample(t_normal, s_normal, in.uv).rgb;
    let tangent_normal = normal_sample * 2.0 - 1.0;
    let TBN = mat3x3<f32>(T, B, N_geom);
    let N = normalize(TBN * tangent_normal);
    out.normal = vec4<f32>(N, 0.0);

    // Albedo
    let tex_color = textureSample(t_diffuse, s_diffuse, in.uv);
    out.albedo = vec4<f32>(material.base_color.rgb * tex_color.rgb, 1.0);

    // Material: R=metallic, G=roughness, B=ao
    out.material_out = vec4<f32>(material.metallic, material.roughness, material.ao, 1.0);

    return out;
}
  • Step 2: 커밋
git add crates/voltex_renderer/src/deferred_gbuffer.wgsl
git commit -m "feat(renderer): add G-Buffer pass shader for deferred rendering"

Task 3: Lighting Pass 셰이더

Files:

  • Create: crates/voltex_renderer/src/deferred_lighting.wgsl

  • Step 1: deferred_lighting.wgsl 작성

This shader reuses the Cook-Torrance BRDF functions from pbr_shader.wgsl but reads from G-Buffer instead of vertex attributes.

// Deferred Lighting Pass: reads G-Buffer, applies full PBR lighting

// Group 0: G-Buffer textures
@group(0) @binding(0) var t_position: texture_2d<f32>;
@group(0) @binding(1) var t_normal: texture_2d<f32>;
@group(0) @binding(2) var t_albedo: texture_2d<f32>;
@group(0) @binding(3) var t_material: texture_2d<f32>;
@group(0) @binding(4) var s_gbuffer: sampler;

// Group 1: Lights + Camera
struct LightData {
    position: vec3<f32>,
    light_type: u32,
    direction: vec3<f32>,
    range: f32,
    color: vec3<f32>,
    intensity: f32,
    inner_cone: f32,
    outer_cone: f32,
    _padding: vec2<f32>,
};

struct LightsUniform {
    lights: array<LightData, 16>,
    count: u32,
    ambient_color: vec3<f32>,
};

struct CameraPositionUniform {
    camera_pos: vec3<f32>,
};

@group(1) @binding(0) var<uniform> lights_uniform: LightsUniform;
@group(1) @binding(1) var<uniform> camera_data: CameraPositionUniform;

// Group 2: Shadow + IBL
struct ShadowUniform {
    light_view_proj: mat4x4<f32>,
    shadow_map_size: f32,
    shadow_bias: f32,
};

@group(2) @binding(0) var t_shadow: texture_depth_2d;
@group(2) @binding(1) var s_shadow: sampler_comparison;
@group(2) @binding(2) var<uniform> shadow: ShadowUniform;
@group(2) @binding(3) var t_brdf_lut: texture_2d<f32>;
@group(2) @binding(4) var s_brdf_lut: sampler;

// Fullscreen vertex
struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) uv: vec2<f32>,
};

@vertex
fn vs_main(@location(0) position: vec2<f32>) -> VertexOutput {
    var out: VertexOutput;
    out.clip_position = vec4<f32>(position, 0.0, 1.0);
    // Convert clip space [-1,1] to UV [0,1]
    out.uv = vec2<f32>(position.x * 0.5 + 0.5, 1.0 - (position.y * 0.5 + 0.5));
    return out;
}

// === BRDF functions (same as pbr_shader.wgsl) ===

fn distribution_ggx(N: vec3<f32>, H: vec3<f32>, roughness: f32) -> f32 {
    let a = roughness * roughness;
    let a2 = a * a;
    let NdotH = max(dot(N, H), 0.0);
    let NdotH2 = NdotH * NdotH;
    let denom_inner = NdotH2 * (a2 - 1.0) + 1.0;
    let denom = 3.14159265358979 * denom_inner * denom_inner;
    return a2 / denom;
}

fn geometry_schlick_ggx(NdotV: f32, roughness: f32) -> f32 {
    let r = roughness + 1.0;
    let k = (r * r) / 8.0;
    return NdotV / (NdotV * (1.0 - k) + k);
}

fn geometry_smith(N: vec3<f32>, V: vec3<f32>, L: vec3<f32>, roughness: f32) -> f32 {
    let NdotV = max(dot(N, V), 0.0);
    let NdotL = max(dot(N, L), 0.0);
    return geometry_schlick_ggx(NdotV, roughness) * geometry_schlick_ggx(NdotL, roughness);
}

fn fresnel_schlick(cosTheta: f32, F0: vec3<f32>) -> vec3<f32> {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

fn attenuation_point(distance: f32, range: f32) -> f32 {
    let d_over_r = distance / range;
    let d_over_r4 = d_over_r * d_over_r * d_over_r * d_over_r;
    let falloff = clamp(1.0 - d_over_r4, 0.0, 1.0);
    return (falloff * falloff) / (distance * distance + 0.0001);
}

fn attenuation_spot(light: LightData, L: vec3<f32>) -> f32 {
    let spot_dir = normalize(light.direction);
    let theta = dot(spot_dir, -L);
    return clamp(
        (theta - light.outer_cone) / (light.inner_cone - light.outer_cone + 0.0001),
        0.0, 1.0,
    );
}

fn compute_light_contribution(
    light: LightData, N: vec3<f32>, V: vec3<f32>, world_pos: vec3<f32>,
    F0: vec3<f32>, albedo: vec3<f32>, metallic: f32, roughness: f32,
) -> vec3<f32> {
    var L: vec3<f32>;
    var radiance: vec3<f32>;

    if light.light_type == 0u {
        L = normalize(-light.direction);
        radiance = light.color * light.intensity;
    } else if light.light_type == 1u {
        let to_light = light.position - world_pos;
        let dist = length(to_light);
        L = normalize(to_light);
        radiance = light.color * light.intensity * attenuation_point(dist, light.range);
    } else {
        let to_light = light.position - world_pos;
        let dist = length(to_light);
        L = normalize(to_light);
        radiance = light.color * light.intensity * attenuation_point(dist, light.range) * attenuation_spot(light, L);
    }

    let H = normalize(V + L);
    let NDF = distribution_ggx(N, H, roughness);
    let G = geometry_smith(N, V, L, roughness);
    let F = fresnel_schlick(max(dot(H, V), 0.0), F0);
    let ks = F;
    let kd = (vec3<f32>(1.0) - ks) * (1.0 - metallic);
    let numerator = NDF * G * F;
    let NdotL = max(dot(N, L), 0.0);
    let NdotV = max(dot(N, V), 0.0);
    let denominator = 4.0 * NdotV * NdotL + 0.0001;
    let specular = numerator / denominator;

    return (kd * albedo / 3.14159265358979 + specular) * radiance * NdotL;
}

fn calculate_shadow(world_pos: vec3<f32>) -> f32 {
    if shadow.shadow_map_size == 0.0 { return 1.0; }
    let light_space_pos = shadow.light_view_proj * vec4<f32>(world_pos, 1.0);
    let proj_coords = light_space_pos.xyz / light_space_pos.w;
    let shadow_uv = vec2<f32>(proj_coords.x * 0.5 + 0.5, -proj_coords.y * 0.5 + 0.5);
    let current_depth = proj_coords.z;
    if shadow_uv.x < 0.0 || shadow_uv.x > 1.0 || shadow_uv.y < 0.0 || shadow_uv.y > 1.0 { return 1.0; }
    if current_depth > 1.0 || current_depth < 0.0 { return 1.0; }
    let texel_size = 1.0 / shadow.shadow_map_size;
    var shadow_val = 0.0;
    for (var x = -1; x <= 1; x++) {
        for (var y = -1; y <= 1; y++) {
            shadow_val += textureSampleCompare(t_shadow, s_shadow, shadow_uv + vec2<f32>(f32(x), f32(y)) * texel_size, current_depth - shadow.shadow_bias);
        }
    }
    return shadow_val / 9.0;
}

fn sample_environment(direction: vec3<f32>, roughness: f32) -> vec3<f32> {
    var env: vec3<f32>;
    if direction.y > 0.0 {
        env = mix(vec3<f32>(0.6, 0.6, 0.5), vec3<f32>(0.3, 0.5, 0.9), pow(direction.y, 0.4));
    } else {
        env = mix(vec3<f32>(0.6, 0.6, 0.5), vec3<f32>(0.1, 0.08, 0.06), pow(-direction.y, 0.4));
    }
    return mix(env, vec3<f32>(0.3, 0.35, 0.4), roughness * roughness);
}

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    let world_pos = textureSample(t_position, s_gbuffer, in.uv).xyz;
    let N = normalize(textureSample(t_normal, s_gbuffer, in.uv).xyz);
    let albedo = textureSample(t_albedo, s_gbuffer, in.uv).rgb;
    let mat_data = textureSample(t_material, s_gbuffer, in.uv);
    let metallic = mat_data.r;
    let roughness = mat_data.g;
    let ao = mat_data.b;

    // Skip background pixels (position = 0,0,0 means no geometry)
    if length(textureSample(t_position, s_gbuffer, in.uv).xyz) < 0.001 {
        return vec4<f32>(0.05, 0.05, 0.08, 1.0); // background color
    }

    let V = normalize(camera_data.camera_pos - world_pos);
    let F0 = mix(vec3<f32>(0.04), albedo, metallic);

    let shadow_factor = calculate_shadow(world_pos);
    var Lo = vec3<f32>(0.0);
    let light_count = min(lights_uniform.count, 16u);
    for (var i = 0u; i < light_count; i++) {
        var contribution = compute_light_contribution(
            lights_uniform.lights[i], N, V, world_pos, F0, albedo, metallic, roughness,
        );
        if lights_uniform.lights[i].light_type == 0u {
            contribution = contribution * shadow_factor;
        }
        Lo += contribution;
    }

    // IBL
    let NdotV_ibl = max(dot(N, V), 0.0);
    let R = reflect(-V, N);
    let irradiance = sample_environment(N, 1.0);
    let F_env = fresnel_schlick(NdotV_ibl, F0);
    let kd_ibl = (vec3<f32>(1.0) - F_env) * (1.0 - metallic);
    let diffuse_ibl = kd_ibl * albedo * irradiance;
    let prefiltered = sample_environment(R, roughness);
    let brdf_val = textureSample(t_brdf_lut, s_brdf_lut, vec2<f32>(NdotV_ibl, roughness));
    let specular_ibl = prefiltered * (F0 * brdf_val.r + vec3<f32>(brdf_val.g));
    let ambient = (diffuse_ibl + specular_ibl) * ao;

    var color = ambient + Lo;
    color = color / (color + vec3<f32>(1.0)); // Reinhard
    color = pow(color, vec3<f32>(1.0 / 2.2)); // Gamma

    return vec4<f32>(color, 1.0);
}
  • Step 2: 커밋
git add crates/voltex_renderer/src/deferred_lighting.wgsl
git commit -m "feat(renderer): add deferred lighting pass shader with Cook-Torrance BRDF"

Task 4: Deferred Pipeline (Rust)

Files:

  • Create: crates/voltex_renderer/src/deferred_pipeline.rs

  • Modify: crates/voltex_renderer/src/lib.rs

  • Step 1: deferred_pipeline.rs 작성

This file creates both G-Buffer pass and Lighting pass pipelines, plus their bind group layouts.

// crates/voltex_renderer/src/deferred_pipeline.rs
use crate::vertex::MeshVertex;
use crate::fullscreen_quad::FullscreenVertex;
use crate::gbuffer::*;
use crate::gpu::DEPTH_FORMAT;

// === G-Buffer Pass ===

pub fn gbuffer_camera_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
        label: Some("GBuffer Camera BGL"),
        entries: &[
            wgpu::BindGroupLayoutEntry {
                binding: 0,
                visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: true,
                    min_binding_size: None,
                },
                count: None,
            },
        ],
    })
}

pub fn create_gbuffer_pipeline(
    device: &wgpu::Device,
    camera_layout: &wgpu::BindGroupLayout,
    texture_layout: &wgpu::BindGroupLayout,
    material_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline {
    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
        label: Some("Deferred GBuffer Shader"),
        source: wgpu::ShaderSource::Wgsl(include_str!("deferred_gbuffer.wgsl").into()),
    });

    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
        label: Some("GBuffer Pipeline Layout"),
        bind_group_layouts: &[camera_layout, texture_layout, material_layout],
        immediate_size: 0,
    });

    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
        label: Some("GBuffer Pipeline"),
        layout: Some(&layout),
        vertex: wgpu::VertexState {
            module: &shader,
            entry_point: Some("vs_main"),
            buffers: &[MeshVertex::LAYOUT],
            compilation_options: wgpu::PipelineCompilationOptions::default(),
        },
        fragment: Some(wgpu::FragmentState {
            module: &shader,
            entry_point: Some("fs_main"),
            targets: &[
                Some(wgpu::ColorTargetState {
                    format: GBUFFER_POSITION_FORMAT,
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                }),
                Some(wgpu::ColorTargetState {
                    format: GBUFFER_NORMAL_FORMAT,
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                }),
                Some(wgpu::ColorTargetState {
                    format: GBUFFER_ALBEDO_FORMAT,
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                }),
                Some(wgpu::ColorTargetState {
                    format: GBUFFER_MATERIAL_FORMAT,
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                }),
            ],
            compilation_options: wgpu::PipelineCompilationOptions::default(),
        }),
        primitive: wgpu::PrimitiveState {
            topology: wgpu::PrimitiveTopology::TriangleList,
            front_face: wgpu::FrontFace::Ccw,
            cull_mode: Some(wgpu::Face::Back),
            ..Default::default()
        },
        depth_stencil: Some(wgpu::DepthStencilState {
            format: DEPTH_FORMAT,
            depth_write_enabled: true,
            depth_compare: wgpu::CompareFunction::Less,
            stencil: wgpu::StencilState::default(),
            bias: wgpu::DepthBiasState::default(),
        }),
        multisample: wgpu::MultisampleState::default(),
        multiview_mask: None,
        cache: None,
    })
}

// === Lighting Pass ===

pub fn lighting_gbuffer_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
        label: Some("Lighting GBuffer BGL"),
        entries: &[
            // position texture
            wgpu::BindGroupLayoutEntry {
                binding: 0,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Texture {
                    sample_type: wgpu::TextureSampleType::Float { filterable: false },
                    view_dimension: wgpu::TextureViewDimension::D2,
                    multisampled: false,
                },
                count: None,
            },
            // normal texture
            wgpu::BindGroupLayoutEntry {
                binding: 1,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Texture {
                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
                    view_dimension: wgpu::TextureViewDimension::D2,
                    multisampled: false,
                },
                count: None,
            },
            // albedo texture
            wgpu::BindGroupLayoutEntry {
                binding: 2,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Texture {
                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
                    view_dimension: wgpu::TextureViewDimension::D2,
                    multisampled: false,
                },
                count: None,
            },
            // material texture
            wgpu::BindGroupLayoutEntry {
                binding: 3,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Texture {
                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
                    view_dimension: wgpu::TextureViewDimension::D2,
                    multisampled: false,
                },
                count: None,
            },
            // sampler
            wgpu::BindGroupLayoutEntry {
                binding: 4,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
                count: None,
            },
        ],
    })
}

pub fn lighting_lights_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
        label: Some("Lighting Lights BGL"),
        entries: &[
            // LightsUniform
            wgpu::BindGroupLayoutEntry {
                binding: 0,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: false,
                    min_binding_size: None,
                },
                count: None,
            },
            // CameraPositionUniform
            wgpu::BindGroupLayoutEntry {
                binding: 1,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: false,
                    min_binding_size: None,
                },
                count: None,
            },
        ],
    })
}

pub fn lighting_shadow_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
        label: Some("Lighting Shadow+IBL BGL"),
        entries: &[
            // shadow depth texture
            wgpu::BindGroupLayoutEntry {
                binding: 0,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Texture {
                    sample_type: wgpu::TextureSampleType::Depth,
                    view_dimension: wgpu::TextureViewDimension::D2,
                    multisampled: false,
                },
                count: None,
            },
            // shadow comparison sampler
            wgpu::BindGroupLayoutEntry {
                binding: 1,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
                count: None,
            },
            // ShadowUniform
            wgpu::BindGroupLayoutEntry {
                binding: 2,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: false,
                    min_binding_size: None,
                },
                count: None,
            },
            // BRDF LUT texture
            wgpu::BindGroupLayoutEntry {
                binding: 3,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Texture {
                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
                    view_dimension: wgpu::TextureViewDimension::D2,
                    multisampled: false,
                },
                count: None,
            },
            // BRDF LUT sampler
            wgpu::BindGroupLayoutEntry {
                binding: 4,
                visibility: wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                count: None,
            },
        ],
    })
}

pub fn create_lighting_pipeline(
    device: &wgpu::Device,
    surface_format: wgpu::TextureFormat,
    gbuffer_layout: &wgpu::BindGroupLayout,
    lights_layout: &wgpu::BindGroupLayout,
    shadow_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline {
    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
        label: Some("Deferred Lighting Shader"),
        source: wgpu::ShaderSource::Wgsl(include_str!("deferred_lighting.wgsl").into()),
    });

    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
        label: Some("Lighting Pipeline Layout"),
        bind_group_layouts: &[gbuffer_layout, lights_layout, shadow_layout],
        immediate_size: 0,
    });

    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
        label: Some("Lighting Pipeline"),
        layout: Some(&layout),
        vertex: wgpu::VertexState {
            module: &shader,
            entry_point: Some("vs_main"),
            buffers: &[FullscreenVertex::LAYOUT],
            compilation_options: wgpu::PipelineCompilationOptions::default(),
        },
        fragment: Some(wgpu::FragmentState {
            module: &shader,
            entry_point: Some("fs_main"),
            targets: &[Some(wgpu::ColorTargetState {
                format: surface_format,
                blend: Some(wgpu::BlendState::REPLACE),
                write_mask: wgpu::ColorWrites::ALL,
            })],
            compilation_options: wgpu::PipelineCompilationOptions::default(),
        }),
        primitive: wgpu::PrimitiveState {
            topology: wgpu::PrimitiveTopology::TriangleList,
            ..Default::default()
        },
        depth_stencil: None, // No depth for fullscreen pass
        multisample: wgpu::MultisampleState::default(),
        multiview_mask: None,
        cache: None,
    })
}
  • Step 2: lib.rs에 deferred_pipeline 등록
pub mod deferred_pipeline;

And re-exports:

pub use deferred_pipeline::{
    create_gbuffer_pipeline, create_lighting_pipeline,
    gbuffer_camera_bind_group_layout,
    lighting_gbuffer_bind_group_layout, lighting_lights_bind_group_layout, lighting_shadow_bind_group_layout,
};
  • Step 3: 빌드 확인

Run: cargo build -p voltex_renderer Expected: 컴파일 성공

Run: cargo test --workspace Expected: all pass (기존 200개)

  • Step 4: 커밋
git add crates/voltex_renderer/src/deferred_pipeline.rs crates/voltex_renderer/src/lib.rs
git commit -m "feat(renderer): add deferred rendering pipeline (G-Buffer + Lighting pass)"

Task 5: deferred_demo 예제

Files:

  • Create: examples/deferred_demo/Cargo.toml
  • Create: examples/deferred_demo/src/main.rs
  • Modify: Cargo.toml (workspace members)

NOTE: 이 예제는 복잡합니다 (GPU 리소스 설정, 바인드 그룹 생성, 2-pass 렌더). 기존 pbr_demo 패턴을 따르되 디퍼드로 변경. 구체 그리드 + 다수 포인트 라이트 씬.

이 태스크는 가장 큰 구현이며, 더 capable한 모델로 실행해야 합니다.

  • Step 1: Cargo.toml
[package]
name = "deferred_demo"
version = "0.1.0"
edition = "2021"

[dependencies]
voltex_math.workspace = true
voltex_platform.workspace = true
voltex_renderer.workspace = true
bytemuck.workspace = true
pollster.workspace = true
wgpu.workspace = true
  • Step 2: main.rs 작성

The example should:

  1. Create window + GpuContext
  2. Create GBuffer
  3. Create G-Buffer pipeline + Lighting pipeline with proper bind group layouts
  4. Generate sphere meshes (5x5 grid of metallic/roughness variations)
  5. Set up 8 point lights orbiting the scene (to show deferred advantage)
  6. Create all uniform buffers, textures, bind groups
  7. Main loop:
    • Update camera (FPS controller)
    • Update light positions (orbit animation)
    • Pass 1: G-Buffer pass (render all objects to MRT)
    • Pass 2: Lighting pass (fullscreen quad, reads G-Buffer)
    • Present

Key: must create CameraPositionUniform buffer (vec3 + padding = 16 bytes) for the lighting pass.

  • Step 3: workspace에 추가

Cargo.toml members에 "examples/deferred_demo" 추가.

  • Step 4: 빌드 + 실행 확인

Run: cargo build --bin deferred_demo Run: cargo run --bin deferred_demo (수동 확인)

  • Step 5: 커밋
git add examples/deferred_demo/ Cargo.toml
git commit -m "feat(renderer): add deferred_demo example with multi-light deferred rendering"

Task 6: 문서 업데이트

Files:

  • Modify: docs/STATUS.md

  • Modify: docs/DEFERRED.md

  • Step 1: STATUS.md에 Phase 7-1 추가

Phase 6-3 아래에:

### Phase 7-1: Deferred Rendering
- voltex_renderer: GBuffer (4 MRT: Position/Normal/Albedo/Material + Depth)
- voltex_renderer: G-Buffer pass shader (MRT output, TBN normal mapping)
- voltex_renderer: Lighting pass shader (fullscreen quad, Cook-Torrance BRDF, multi-light, shadow, IBL)
- voltex_renderer: Deferred pipeline (gbuffer + lighting bind group layouts)
- examples/deferred_demo (5x5 sphere grid + 8 orbiting point lights)

예제 수 11로 업데이트.

  • Step 2: DEFERRED.md에 Phase 7-1 미뤄진 항목 추가
## Phase 7-1

- **투명 오브젝트** — 디퍼드에서 처리 불가. 별도 포워드 패스 필요.
- **G-Buffer 압축** — Position을 depth에서 복원, Normal을 octahedral 인코딩 등 미적용.
- **Light Volumes** — 풀스크린 라이팅만. 라이트별 sphere/cone 렌더 미구현.
- **Stencil 최적화** — 미구현.
  • Step 3: 커밋
git add docs/STATUS.md docs/DEFERRED.md
git commit -m "docs: add Phase 7-1 deferred rendering status and deferred items"