Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 68 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,83 @@ WebGL Forward+ and Clustered Deferred Shading

**University of Pennsylvania, CIS 565: GPU Programming and Architecture, Project 4**

* (TODO) YOUR NAME HERE
* Tested on: (TODO) **Google Chrome 222.2** on
Windows 22, i7-2222 @ 2.22GHz 22GB, GTX 222 222MB (Moore 2222 Lab)
* Cecilia Chen
* [LinkedIn](https://www.linkedin.com/in/yue-chen-643182223/)
* Tested on: Windows 11, i7-13700F @ 2.1GHz 16GB, GeForce GTX 4070 12GB (Personal Computer)

### Live Demo

[![](img/thumb.png)](http://TODO.github.io/Project4-WebGPU-Forward-Plus-and-Clustered-Deferred)
[![](img/thumb.png)](https://ceciliachen98.github.io/Project4-WebGPU-Forward-Plus-and-Clustered-Deferred/)

### Demo Video/GIF

[![](img/video.mp4)](TODO)
<img src="img/demo.gif" width="100%" />

### (TODO: Your README)
### Project Overview

*DO NOT* leave the README to the last minute! It is a crucial part of the
project, and we will not be able to grade you without a good README.
This project implements real-time lighting three ways, with a shared clustered light assignment pass:
* Naive Forward Shading — per-fragment loops over all lights (baseline).

This assignment has a considerable amount of performance analysis compared
to implementation work. Complete the implementation early to leave time!
* Light Clustering Compute — partitions the screen into 3D clusters (CLUSTER_X×CLUSTER_Y×CLUSTER_Z), computes conservative AABBs, assigns lights with a capped list per cluster.

* Forward+ (Clustered Forward) — compute pass builds per-cluster light lists; fragment shades using only lights for its cluster.

* Clustered Deferred Shading — geometry pass writes G-buffer (albedo, normal, linear view-Z), fullscreen pass shades per cluster.

### **Features**

### Forward+ (Clustered Forward)
**Overview:** Adds a compute pass to bin lights into screen-space clusters; the fragment shades using only its cluster’s light list.

**Performance change:** Frame time rises with total lights (≈8.3→41.7 ms from 500→5000) and with the per-cluster/scene light cap (≈14.9→50.0 ms from 256→2048), reflecting longer light loops and bigger lists.

**Parameter effects (timing):** See plots—LightsNum increases cost roughly sublinearly due to culling; increasing MaxNumLights grows cost because more lights survive into each cluster’s list.

**Accelerations used:** Cluster culling and capped per-cluster lists; conservative AABB tests to reject lights early.

### Clustered Deferred

**Overview:** Two-pass shading: G-buffer geometry writes material/normal/depth; fullscreen pass shades using clustered light lists.

**Performance change:** Very stable with lights (≈8.3–10.3 ms up to 5000 lights) and only mildly sensitive to MaxNumLights (≈8.3→11.9 ms from 256→2048), dominated by G-buffer bandwidth and fixed fullscreen shading.

**Parameter effects (timing):** LightsNum mostly flat due to clustered pruning; MaxNumLights adds modest cost as per-pixel light loops lengthen. See plots.

**Accelerations used:** Linear world-Z reconstruction to avoid extra work; fullscreen triangle; clustered light indexing.


### Performance Analysis

Quick note on measurements: my direct ms timing failed (the timer kept returning 0 ms), so I reported FPS instead. Wherever you see “ms per frame,” it’s derived from FPS using ms = 1000 / FPS rather than captured from GPU timestamps—treat it as an estimate, not a hardware-timer reading.

#### Comparing the performance of different implements

<img src="img/different_method.png" width="400" alt="Scan performance">
<img src="img/ms_num.png" width="400" alt="Scan performance">

*Setting: CLUSTER_X = 16, CLUSTER_Y = 9, CLUSTER_Z = 24, Max number of lights: 1200*


#### Analysis
* Deferred is the most stable and fastest overall (flat at ~120 FPS up to 3k lights, then tapering to 97 FPS at 5k) because shading work is decoupled from geometry and clustered light lists bound per pixel keep the per-fragment loop short

* Forward+ is excellent at low–mid counts (120 → 24 FPS from 500→5k) since per-tile culling trims the light loop but still pays the per-fragment BRDF and clustering overhead

* Naive collapses quickly (39 → 4 FPS) because it scales O(pixels×lights) with no culling.

#### Conclusion

Overall, Naive forward is the simplest and lightest pipeline—great when you have only a few lights and want built-in transparency and MSAA in one pass—but its cost scales as O(pixels × lights), so performance collapses as light counts rise and overdraw increases. Forward+ (clustered forward) adds a compute pass that bins lights into screen-space clusters, so fragments only loop the handful of nearby lights. Clustered deferred decouples geometry and lighting via a G-buffer, delivering very stable performance with many lights; however, it burns bandwidth and memory on multiple render targets and requires a separate forward path for transparency and special handling for material property packing/precision.

#### Comparing the performance of different number of lights per cluster

<img src="img/different_num.png" width="400" alt="Scan performance">
<img src="img/ms_max.png" width="400" alt="Scan performance">

*Setting: CLUSTER_X = 16, CLUSTER_Y = 9, CLUSTER_Z = 24*

#### Analysis
As MaxNumLights decreases, FPS rises for both techniques because each pixel/cluster iterates over fewer candidate lights. This shows diminishing returns below ~512, where Deferred plateaus at 120 FPS and Forward+ still improves but with a smaller slope; the gains come from shorter per-fragment light loops, reduced memory traffic for per-cluster indices, and fewer cache misses. However, setting the cap too low can cause visible grid artifacts (tiled regions missing lights that were culled by the cap). A practical sweet spot is typically around 1024: it retain most of the performance win while minimizing the risk of under-lit tiles and noticeable grids.

### Credits

Expand Down
Binary file added img/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/different_method.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/different_num.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/ms_max.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/ms_num.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/thumb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
257 changes: 257 additions & 0 deletions src/renderers/clustered_deferred.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,274 @@ export class ClusteredDeferredRenderer extends renderer.Renderer {
// TODO-3: add layouts, pipelines, textures, etc. needed for Forward+ here
// you may need extra uniforms such as the camera view matrix and the canvas resolution

sceneUniformsBindGroupLayout: GPUBindGroupLayout;
sceneUniformsBindGroup: GPUBindGroup;

gbufferBindGroupLayout: GPUBindGroupLayout;
gbufferBindGroup: GPUBindGroup;


gAlbedo: GPUTexture;
gAlbedoView: GPUTextureView;
gNormal: GPUTexture;
gNormalView: GPUTextureView;
gSampler: GPUSampler;

gWorldZ: GPUTexture;
gWorldZView: GPUTextureView;

scenePipeline: GPURenderPipeline;
gbufferPipeline: GPURenderPipeline;

constructor(stage: Stage) {
super(stage);

// TODO-3: initialize layouts, pipelines, textures, etc. needed for Forward+ here
// you'll need two pipelines: one for the G-buffer pass and one for the fullscreen pass
this.sceneUniformsBindGroupLayout = renderer.device.createBindGroupLayout({
label: "scene uniforms bind group layout",
entries: [
{ // camera
binding: 0,
visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.VERTEX,
buffer:{ type: "uniform" }
},
{ // lightSet
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: "read-only-storage" }
},
{ // clusterSet
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: "read-only-storage" }
}
]
});

this.sceneUniformsBindGroup = renderer.device.createBindGroup({
label: "scene uniforms bind group",
layout: this.sceneUniformsBindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: this.camera.uniformsBuffer }
},
{
binding: 1,
resource: { buffer: this.lights.lightSetStorageBuffer }
},
{
binding: 2,
resource: { buffer: this.lights.clusterSetBuffer }
}
]
});

// ---------------------------------------------------------------------
// Create G-buffer textures (albedo, normal, viewZ) and sampler
// ---------------------------------------------------------------------
this.gAlbedo = renderer.device.createTexture({
label: "gAlbedo",
size : [renderer.canvas.width, renderer.canvas.height],
format: "rgba16float",
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
});
this.gAlbedoView = this.gAlbedo.createView();

this.gNormal = renderer.device.createTexture({
label: "gNormal",
size: [renderer.canvas.width, renderer.canvas.height],
format: "rgba16float",
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
});
this.gNormalView = this.gNormal.createView();

this.gWorldZ = renderer.device.createTexture({
label: "gViewZ",
size: [renderer.canvas.width, renderer.canvas.height],
format: "depth24plus",
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
});
this.gWorldZView = this.gWorldZ.createView();

this.gSampler = renderer.device.createSampler();

this.gbufferBindGroupLayout = renderer.device.createBindGroupLayout({
label: "gbuffer bind group layout",
entries: [
{ // gAlbedo
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
texture: {}
},
{ // gNormal
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
texture: {}
},
{ // gViewZ
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
texture: { sampleType: "depth" }
},
{ // sampler
binding: 3,
visibility: GPUShaderStage.FRAGMENT,
sampler: { type: 'non-filtering' }
}
]
});

// ---------------------------------------------------------------------
// Depth texture for geometry pass
// ---------------------------------------------------------------------
this.gbufferBindGroup = renderer.device.createBindGroup({
label: "gbuffer bind group",
layout: this.gbufferBindGroupLayout,
entries: [
{ binding: 0, resource: this.gAlbedoView },
{ binding: 1, resource: this.gNormalView },
{ binding: 2, resource: this.gWorldZView },
{ binding: 3, resource: this.gSampler }
]
});

this.scenePipeline = renderer.device.createRenderPipeline({
layout: renderer.device.createPipelineLayout({
label: "clustered deferred fullscreen layout",
bindGroupLayouts: [
this.sceneUniformsBindGroupLayout,
this.gbufferBindGroupLayout
]
}),
vertex: {
module: renderer.device.createShaderModule({
label: "fullscreen vert",
code: shaders.clusteredDeferredFullscreenVertSrc
}),
},
fragment: {
module: renderer.device.createShaderModule({
label: "fullscreen frag",
code: shaders.clusteredDeferredFullscreenFragSrc
}),
targets: [
{
format: renderer.canvasFormat
}
]
}
});
// ---------------------------------------------------------------------
// Create G-buffer pipeline (geometry pass)
// ---------------------------------------------------------------------
this.gbufferPipeline = renderer.device.createRenderPipeline({
layout: renderer.device.createPipelineLayout({
label: "gbuffer pipeline layout",
bindGroupLayouts: [
this.sceneUniformsBindGroupLayout,
renderer.modelBindGroupLayout,
renderer.materialBindGroupLayout
]
}),
depthStencil: {
depthWriteEnabled: true,
depthCompare: "less",
format: "depth24plus"
},
vertex: {
module: renderer.device.createShaderModule({
label: "gbuffer vertex shader",
code: shaders.naiveVertSrc
}),
buffers: [ renderer.vertexBufferLayout ]
},
fragment: {
module: renderer.device.createShaderModule({
label: "gbuffer fragment shader",
code: shaders.clusteredDeferredFragSrc
}),
targets: [
{ format: "rgba16float" }, // albedo
{ format: "rgba16float" } //normal
]
}
});

}

override draw() {
// TODO-3: run the Forward+ rendering pass:
// - run the clustering compute shader
// - run the G-buffer pass, outputting position, albedo, and normals
// - run the fullscreen pass, which reads from the G-buffer and performs lighting calculations
const encoder = renderer.device.createCommandEncoder();
const canvasTextureView = renderer.context.getCurrentTexture().createView();

this.lights.doLightClustering(encoder);

const gpass = encoder.beginRenderPass({
label: "gbuffer pass",
colorAttachments: [
{
view: this.gAlbedoView,
clearValue: [0.0, 0.0, 0.0, 1.0],
loadOp: "clear",
storeOp: "store"
},
{
view: this.gNormalView,
clearValue: [0.5, 0.5, 1.0, 0.0],
loadOp: "clear",
storeOp: "store"
}
],
depthStencilAttachment: {
view: this.gWorldZView,
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "store"
}
});
gpass.setPipeline(this.gbufferPipeline);
gpass.setBindGroup(shaders.constants.bindGroup_scene, this.sceneUniformsBindGroup);

this.scene.iterate(node => {
gpass.setBindGroup(shaders.constants.bindGroup_model, node.modelBindGroup);
}, material => {
gpass.setBindGroup(shaders.constants.bindGroup_material, material.materialBindGroup);
}, primitive => {
gpass.setVertexBuffer(0, primitive.vertexBuffer);
gpass.setIndexBuffer(primitive.indexBuffer, 'uint32');
gpass.drawIndexed(primitive.numIndices);
});

gpass.end();

// 3) Fullscreen lighting pass (read from G-buffer + clusters)
const fpass = encoder.beginRenderPass({
label: "clustered deferred fullscreen pass",
colorAttachments: [
{
view: canvasTextureView,
clearValue: [0.0, 0.0, 0.0, 1.0],
loadOp: "clear",
storeOp: "store"
}
]
});

fpass.setPipeline(this.scenePipeline);

fpass.setBindGroup(shaders.constants.bindGroup_scene, this.sceneUniformsBindGroup);
fpass.setBindGroup(shaders.constants.bindGroup_gbuffer, this.gbufferBindGroup);

fpass.draw(3, 1, 0, 0);

fpass.end();

renderer.device.queue.submit([encoder.finish()]);
}
}

Loading