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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/.vs
90 changes: 79 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,93 @@ 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)
* Sirui Zhu
* Tested on: **Google Chrome 141.0.7390.78** on
Windows 11, i7-13620H, RTX 4060 (Personal)

### Live Demo

[![](img/thumb.png)](http://TODO.github.io/Project4-WebGPU-Forward-Plus-and-Clustered-Deferred)
[Live Demo Link](http://angelasiruizhu.github.io/Project4-WebGPU-Forward-Plus-and-Clustered-Deferred)

### Demo Video/GIF
### Screenshot

[![](img/video.mp4)](TODO)
![](img/screenshot.png)

### (TODO: Your README)
### Demo

*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.
![](img/565proj4.gif)

This assignment has a considerable amount of performance analysis compared
to implementation work. Complete the implementation early to leave time!

## Project Overview

This project implements three different rendering techniques in WebGPU:
1. Naive Forward Rendering
2. Forward+ Rendering
3. Clustered Deferred Rendering

### Implementation Details

#### 1. Naive Forward Rendering
The naive implementation processes each fragment by considering all lights in the scene:
- Single render pass pipeline using vertex and fragment shaders
- Fragment shader iterates through all lights for each pixel
- Performance scales linearly with light count (O(pixels * lights))

#### 2. Forward+ Rendering
Forward+ rendering represents a significant advancement through light culling optimization:

Implementation Flow:
- Compute shader partitions screen into fixed-size tiles
- Light culling phase runs before main rendering:
- Analyzes each light's potential influence
- Assigns lights to relevant screen-space tiles
- Stores results in per-tile light lists
- Main render pass:
- Accesses pre-computed light assignments
- Processes only lights affecting the current tile
- Performs lighting calculations with reduced set

#### 3. Clustered Deferred Rendering
Combining deferred rendering with light clustering:

G-Buffer Pass:
- Renders scene information to multiple render targets:
- Position buffer
- Normal buffer
- Albedo buffer
- No lighting calculations in this pass

Light Clustering:
- Similar to Forward+ but works in view space
- Creates 3D grid of clusters
- Assigns lights to clusters based on overlap

Final Lighting Pass:
- Fullscreen pass that reads G-buffer textures
- Uses clustered light information
- Computes final lighting using only relevant lights

### Performance Analysis

#### Frame-time Comparison Across Different Rendering Methods

![](img/graph2.png)

#### FPS Comparison Across Different Rendering Methods

![](img/graph.png)

1. Naive Forward Rendering

The naive renderer spends nearly all of its frame time inside the fragment shader because each pixel iterates the entire light list. This implementation detail directly explains the steep rise in frame time — roughly 250 ms at 1,000 lights and up to ~1,000 ms at 4,000–5,000 lights — because the shader work is proportional to pixels × lights. No pre-filtering or spatial culling is performed, so every additional light multiplies shading cost and quickly pushes the frame time much higher.

2. Forward+

Forward+ reduces fragment cost by performing a compute-pass light culling step that assigns lights to fixed-size screen tiles and writes per-tile lists to a storage buffer. The fragment shader then reads only the lights in its tile. These implementation choices are why measured frame times are much lower than naive (about 20 ms at 1,000 lights and 83 ms at 5,000 lights) — the work done in the fragment stage is bounded by lights-per-tile instead of total scene lights. The trade-off is extra compute and memory traffic during the culling pass. This trade-off can be mitigated by using adaptive tile sizing—allocating smaller tiles in dense light regions and larger ones in sparse areas—to balance compute workload and memory traffic while preserving the benefits of localized light evaluation.

3. Clustered Deferred

Clustered Deferred separates geometry and lighting (G-buffer) and assigns lights into a 3D view-space cluster grid. The final fullscreen lighting pass reads G-buffer textures and evaluates only cluster-local lights. Because costly shading is removed from the geometry pass and lighting work is limited by cluster occupancy, frame times are the lowest in these measurements (~8 ms at 1,000 lights, ~31 ms at 5,000 lights). The implementation uses a fullscreen pass to avoid redundant shading and a storage buffer to hold cluster light lists; these choices minimize per-pixel work and explain the headroom seen in the numbers. The trade-off is that deferred rendering cannot handle transparency on its own and must be combined with forward rendering for such materials.

### Credits

Expand Down
Binary file added img/565proj4.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/graph.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/graph2.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/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ export async function initWebGPU() {
throw new Error("no appropriate GPUAdapter found");
}

device = await adapter.requestDevice();
device = await adapter.requestDevice({
requiredLimits: {
maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
maxBufferSize: adapter.limits.maxBufferSize,
}
});

context = canvas.getContext("webgpu")!;
canvasFormat = navigator.gpu.getPreferredCanvasFormat();
Expand Down
231 changes: 231 additions & 0 deletions src/renderers/clustered_deferred.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,248 @@ 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;

gBufferPipeline: GPURenderPipeline;
depthTexture: GPUTexture;
depthTextureView: GPUTextureView;
positionTexture: GPUTexture;
normalTexture: GPUTexture;
albedoTexture: GPUTexture;

fullscreenPipeline: GPURenderPipeline;
fullscreenBindGroupLayout: GPUBindGroupLayout;
fullscreenBindGroup: GPUBindGroup;

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({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: "uniform" }
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: "read-only-storage" }
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: "storage" }
},
{
binding: 3,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: "uniform" }
}
]
});

this.sceneUniformsBindGroup = renderer.device.createBindGroup({
layout: this.sceneUniformsBindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: this.camera.uniformsBuffer } },
{ binding: 1, resource: { buffer: this.lights.lightSetStorageBuffer } },
{ binding: 2, resource: { buffer: this.lights.clusterSetStorageBuffer } },
{ binding: 3, resource: { buffer: this.lights.screenTileUniformBuffer } }
]
});

const gBufferTextureDesc: GPUTextureDescriptor = {
size: [renderer.canvas.width, renderer.canvas.height],
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
format: "rgba16float"
};

this.positionTexture = renderer.device.createTexture({...gBufferTextureDesc, label: "position buffer"});
this.normalTexture = renderer.device.createTexture({...gBufferTextureDesc, label: "normal buffer"});
this.albedoTexture = renderer.device.createTexture({...gBufferTextureDesc, format: "rgba8unorm", label: "albedo buffer"});

this.depthTexture = renderer.device.createTexture({
size: [renderer.canvas.width, renderer.canvas.height],
format: "depth24plus",
usage: GPUTextureUsage.RENDER_ATTACHMENT
});
this.depthTextureView = this.depthTexture.createView();

this.gBufferPipeline = renderer.device.createRenderPipeline({
layout: renderer.device.createPipelineLayout({
bindGroupLayouts: [
this.sceneUniformsBindGroupLayout,
renderer.modelBindGroupLayout,
renderer.materialBindGroupLayout
]
}),
vertex: {
module: renderer.device.createShaderModule({
code: shaders.naiveVertSrc
}),
buffers: [renderer.vertexBufferLayout]
},
fragment: {
module: renderer.device.createShaderModule({
code: shaders.clusteredDeferredFragSrc
}),
targets: [
{ format: "rgba16float" }, // position
{ format: "rgba16float" }, // normal
{ format: "rgba8unorm" } // albedo
]
},
depthStencil: {
format: "depth24plus",
depthWriteEnabled: true,
depthCompare: "less"
}
});

this.fullscreenBindGroupLayout = renderer.device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
texture: { sampleType: "float" }
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
texture: { sampleType: "float" }
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
texture: { sampleType: "float" }
},
{
binding: 3,
visibility: GPUShaderStage.FRAGMENT,
sampler: { type: "filtering" }
}
]
});

const sampler = renderer.device.createSampler({
magFilter: "linear",
minFilter: "linear"
});

this.fullscreenBindGroup = renderer.device.createBindGroup({
layout: this.fullscreenBindGroupLayout,
entries: [
{ binding: 0, resource: this.positionTexture.createView() },
{ binding: 1, resource: this.normalTexture.createView() },
{ binding: 2, resource: this.albedoTexture.createView() },
{ binding: 3, resource: sampler }
]
});

this.fullscreenPipeline = renderer.device.createRenderPipeline({
layout: renderer.device.createPipelineLayout({
bindGroupLayouts: [
this.sceneUniformsBindGroupLayout,
this.fullscreenBindGroupLayout
]
}),
vertex: {
module: renderer.device.createShaderModule({
code: shaders.clusteredDeferredFullscreenVertSrc
}),
entryPoint: "main"
},
fragment: {
module: renderer.device.createShaderModule({
code: shaders.clusteredDeferredFullscreenFragSrc
}),
targets: [{ format: renderer.canvasFormat }]
}
});
}

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();

// first pass
this.lights.doLightClustering(encoder);

// second pass
const gBufferPass = encoder.beginRenderPass({
colorAttachments: [
{
view: this.positionTexture.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store"
},
{
view: this.normalTexture.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store"
},
{
view: this.albedoTexture.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store"
}
],
depthStencilAttachment: {
view: this.depthTextureView,
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "store"
}
});

gBufferPass.setPipeline(this.gBufferPipeline);
gBufferPass.setBindGroup(shaders.constants.bindGroup_scene, this.sceneUniformsBindGroup);

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

gBufferPass.end();

const canvasTextureView = renderer.context.getCurrentTexture().createView();
const lightingPass = encoder.beginRenderPass({
colorAttachments: [{
view: canvasTextureView,
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store"
}]
});

lightingPass.setPipeline(this.fullscreenPipeline);
lightingPass.setBindGroup(0, this.sceneUniformsBindGroup);
lightingPass.setBindGroup(1, this.fullscreenBindGroup);
lightingPass.draw(3);

lightingPass.end();

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