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
Binary file added IMAGES/CLUSTERS_128_128_128.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 IMAGES/CLUSTERS_128_128_256.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 IMAGES/CLUSTERS_64_64_128.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 IMAGES/Naive1k_V1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 28 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,39 @@ 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)
* Lewis Ghrist
* Tested on: **Google Chrome 141.0.7390.108** on
Windows 11, Intel Core i7-13700H, 32.0 GB RAM

### Live Demo

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

### Demo Video/GIF
![Naive](IMAGES/Naive1k_V1.png)
---
## A Quick Note
If you take a look at the live demo, you will notice there are some things missing. I wasn't able to implement all the features, and some of the ones I was able to implement leave lots of room for improvement. I am going to keep working on this and have a full analysis once everything has been implemented, but for now, you are stuck with jank. However, let just look at some of the things I did do (as of 10/18/25), since in my implementation, they are more visually interesting than a plane black screen.

[![](img/video.mp4)](TODO)
# Overview
First off, what was I trying to do. As scenes become more and more complex, we need to develop new methods for handaling the increased amounts of calculations needed to actually render these complex scene. One such complexity is the number of lights in a scene. When rasterizing, for some fragment and the object it hits, we need to calculate what that pixel is going to look like, which usually involves some sort of lighting calculation. The naive approach is to simply test each light for it's contribution to the lighting of that fragment and we get a nice result. The only problem is, as the number of lights increases, this naive test becomes much too slow. The solution, pre-process lights into clusters so that we only need to check the lights within a given fragment's cluster. This is the core idea behind the Forward+ approach.

### (TODO: Your README)
# Forward+
So, what do I mean by clusters. First, consider a small patch of pixels. Just isolating those pixels and the part of the frustrum they span, we get a "mini-frustrum" which see part of our scene. The volume of that mini-frustrum is where we want to check for lights. If a light has a significant effect within that volume, we want to check it with each fragment in that patch. Now, obviously if we were doing a path tracer, then technically all the lights could potentially have an effect since light bounces around all over the scene. But we aren't, so some shortcuts need to be taken since we are prioritizing speed over realism. In this project, our lights were simply point lights. As such, we define a simple radius for each light and that is what determines if a light effects our mini-frustrum. Not we can take this one step furthur. Within this mini-frustrum, there could be a lot of just empty space. This means a basic bounds test would still include a light even though there is no geometry near it to actually do anything. So, we slice that mini-frustrum along the camera Z axis aswell. This gives us small, but precise 3D clusters. If a cluster has geometry in it and a light effects it, then we add that light to that cluster's light list so that our fragments checks it. This takes the number of light check per fragment down significantly and, if implemented correctly, can allow for thousands of lights to be rendered while still keeping good performance.

*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.
Here are some images of what this clustering looks like from the camera:

This assignment has a considerable amount of performance analysis compared
to implementation work. Complete the implementation early to leave time!
![Clusters 64 64 128](IMAGES/CLUSTERS_64_64_128.png)
![Clusters 128 128 128](IMAGES/CLUSTERS_128_128_128.png)
![Clusters 128 128 256](IMAGES/CLUSTERS_128_128_256.png)

## So what went wrong
If you play around with the live demo, you might notice that Forward+ doesn't really do anything significant. Maybe for a larger number of lights it has slight improvements over naive, but for the most part they are both about the same and both pretty slow. I have an idea as to why, but haven't been able to get the fix working. In my cluster compute shader, I set the light radius that I use in my bounding intersection test to 20 instead of 2. This is intential, because without that, you can't really see anything. However, by setting the radius to be that large, the clusters don't really do anything different from naive because most light intersection most clusters.

The reason I need to set the radius to 20 has to do with the depth some how. I tried all sorts of ways to linearize the fragment depth to get a nice 0 to 1 depth map, but nothing was working. The best I could do was a hack where you linearize based on a hard coded scene max depth. My compute shader still uses the old depth, which has values only within a small radius around the camera, and thus only light within that region get detected I think. Still not 100% sure, but that is my current guess.

# Run Time
My Forward+ although it's getting there, is no where near what it needs to be. As such, I don't really have anything to test. The only way for you to be able to see anything is if you turn up the light radius a bunch, but then, as mentioned, this is basically just naive. I could do different cluster sized and configurations, but the results weren't that significant. I couldn't really see any performance differences. Part of this could be my laptop not being that strong of a machine, but overall, knowing the performance results of a buggy implementation I am going to fix didn't seem relevent.

### Credits

Expand All @@ -30,3 +44,6 @@ to implementation work. Complete the implementation early to leave time!
- [dat.GUI](https://github.com/dataarts/dat.gui)
- [stats.js](https://github.com/mrdoob/stats.js)
- [wgpu-matrix](https://github.com/greggman/wgpu-matrix)
- [Coordinate-Systems](https://learnopengl.com/Getting-started/Coordinate-Systems)
- [depth](https://matthewmacfarquhar.medium.com/webgpu-rendering-part-3-depth-testing-39d4c9ae5bbd)
- CIS 5600 SLIDES
171 changes: 171 additions & 0 deletions src/renderers/forward_plus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,187 @@ import { Stage } from '../stage/stage';
export class ForwardPlusRenderer extends renderer.Renderer {
// TODO-2: 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;

depthTexture: GPUTexture;
depthTextureView: GPUTextureView;

pipeline: GPURenderPipeline;

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

// TODO-2: initialize layouts, pipelines, textures, etc. needed for Forward+ here

// CREATE BIND GROUP "BLUE-PRINT": What is the type of the binding and what can acess that data (doesn't specify the actual data). binding number and visibility is important: MUST MATCH IN SHADER AND BING GROUP INITIALIZATION
this.sceneUniformsBindGroupLayout = renderer.device.createBindGroupLayout({
label: "scene uniforms bind group layout",
entries: [
{ // CAMERA
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: "uniform"}
},
{ // LIGHTSET
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: "read-only-storage"}
},
// { // DEPTH TEXTURE
// binding: 2,
// visibility: GPUShaderStage.FRAGMENT,
// texture: { sampleType: "unfilterable-float" }
// },
{ // CLUSTER PARAMS
binding: 3,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: "uniform"}
},
{ // CLUSTER LIGHT COUNTS
binding: 4,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: "storage"}
},
{ // CLUSTER LIGHT INDICES
binding: 5,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: "storage"}
}
]
});

// ALOCATE MEMORY FOR DEPTH: size of screen
this.depthTexture = renderer.device.createTexture
({
size: [renderer.canvas.width, renderer.canvas.height],
format: "depth24plus",
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
});
this.depthTextureView = this.depthTexture.createView();

// BINDS ACTUAL CPU DATA TO THESE GPU BUFFERS
this.sceneUniformsBindGroup = renderer.device.createBindGroup
({
label: "scene uniforms bind group",
layout: this.sceneUniformsBindGroupLayout,

entries: [
{ // CAMERA
binding: 0,
resource: { buffer: this.camera.uniformsBuffer}
},
{ // LIGHTS
binding: 1,
resource: { buffer: this.lights.lightSetStorageBuffer}
},
// { // DEPTH
// binding: 2,
// resource: this.linearDepthTextureView
// },
// // CLUSTER PARAMS
{
binding: 3,
resource: { buffer: this.lights.clusterParamsBuffer}
},
{ // CLUSTER LIGHT COUNT
binding: 4,
resource: { buffer: this.lights.clusterLightCountBuffer}
},
{ // CLUSTER LIGHT IDX
binding: 5,
resource: { buffer: this.lights.clusterLightIdxBuffer}
}
]
});

this.pipeline = renderer.device.createRenderPipeline({
// BINDS LAYOUTS TO THE PIPELINE?
layout: renderer.device.createPipelineLayout({
label: "forward+ pipeline layout",
bindGroupLayouts: [
this.sceneUniformsBindGroupLayout,
renderer.modelBindGroupLayout,
renderer.materialBindGroupLayout
]
}),
depthStencil: { //
depthWriteEnabled: true,
depthCompare: "less",
format: "depth24plus"
},
vertex: { // REUSE NAIVE VERTEX SHADER
module: renderer.device.createShaderModule({
label: "forward+ vert shader",
code: shaders.naiveVertSrc
}),
buffers: [ renderer.vertexBufferLayout ]
},
fragment: { // USE FORWARD+ FRAG SHADER
module: renderer.device.createShaderModule({
label: "forward+ frag shader",
code: shaders.forwardPlusFragSrc,
}),
targets: [
{
format: renderer.canvasFormat,
}
]
}
});
}

override draw() {
// TODO-2: run the Forward+ rendering pass:
// - run the clustering compute shader
// - run the main rendering pass, using the computed clusters for efficient lighting



const encoder = renderer.device.createCommandEncoder();
const canvasTextureView = renderer.context.getCurrentTexture().createView();

// RUN COMPUTE SHADER STUFF
this.lights.populateClusterParamsBuffer();
this.lights.clearClusterCounts();

this.lights.doLightClustering(encoder);


// FINAL OUTPUT WHICH WILL USE CLUSTERS FOR LIGHTING (NEEDS UPDATING)
const renderPass = encoder.beginRenderPass({
label: "naive render pass",
colorAttachments: [
{
view: canvasTextureView,
clearValue: [0, 0, 0, 0],
loadOp: "clear",
storeOp: "store"
}
]
,
depthStencilAttachment: {
view: this.depthTextureView,
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "store"
}
});
renderPass.setPipeline(this.pipeline);
renderPass.setBindGroup(shaders.constants.bindGroup_scene, this.sceneUniformsBindGroup);

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

renderPass.end();

renderer.device.queue.submit([encoder.finish()]);
}
}
12 changes: 11 additions & 1 deletion src/renderers/naive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export class NaiveRenderer extends renderer.Renderer {
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: "read-only-storage" }
},
{ // CAMERA
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: { type: "uniform"}
}
]
});
Expand All @@ -36,6 +41,10 @@ export class NaiveRenderer extends renderer.Renderer {
{
binding: 1,
resource: { buffer: this.lights.lightSetStorageBuffer }
},
{
binding: 0,
resource: { buffer: this.camera.uniformsBuffer}
}
]
});
Expand Down Expand Up @@ -106,7 +115,8 @@ export class NaiveRenderer extends renderer.Renderer {
renderPass.setPipeline(this.pipeline);

// TODO-1.2: bind `this.sceneUniformsBindGroup` to index `shaders.constants.bindGroup_scene`

renderPass.setBindGroup(shaders.constants.bindGroup_scene, this.sceneUniformsBindGroup);

this.scene.iterate(node => {
renderPass.setBindGroup(shaders.constants.bindGroup_model, node.modelBindGroup);
}, material => {
Expand Down
95 changes: 95 additions & 0 deletions src/shaders/clustering.cs.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,98 @@
// - Stop adding lights if the maximum number of lights is reached.

// - Store the number of lights assigned to this cluster.

@group(0) @binding(0) var<uniform> params: ClusterParams;
@group(0) @binding(1) var<storage, read> lightSet: LightSet;
@group(0) @binding(2) var<storage, read_write> clusterCounts : array<u32>;
@group(0) @binding(3) var<storage, read_write> clusterIndices : array<u32>;
@group(0) @binding(4) var<uniform> uCamera : CameraUniforms;

fn clusterAABB_view_log(idxX:u32, idxY:u32, d0:f32, d1:f32) -> vec4<f32> {
let tilesX = (params.screenSize.x + params.tileSize.x - 1u) / params.tileSize.x;
let tilesY = (params.screenSize.y + params.tileSize.y - 1u) / params.tileSize.y;

let xMin = 2.0 * (f32(idxX) / f32(tilesX)) - 1.0;
let xMax = 2.0 * (f32(idxX + 1u) / f32(tilesX)) - 1.0;
let yMin = 2.0 * (f32(idxY) / f32(tilesY)) - 1.0;
let yMax = 2.0 * (f32(idxY + 1u) / f32(tilesY)) - 1.0;

let tanY = tan(0.5 * params.fovYRadians);
let aspect = f32(params.screenSize.x) / f32(params.screenSize.y);
let Sx = aspect * tanY;
let Sy = tanY;

let viewX = array<f32,4>( d0*xMin*Sx, d0*xMax*Sx, d1*xMin*Sx, d1*xMax*Sx );
let viewY = array<f32,4>( d0*yMin*Sy, d0*yMax*Sy, d1*yMin*Sy, d1*yMax*Sy );

var xmin = viewX[0];
var xmax = viewX[0];
var ymin = viewY[0];
var ymax = viewY[0];

for (var i = 1u; i < 4u; i++) {
xmin = min(xmin, viewX[i]);
xmax = max(xmax, viewX[i]);
ymin = min(ymin, viewY[i]);
ymax = max(ymax, viewY[i]);
}
return vec4<f32>(xmin, xmax, ymin, ymax);
}

fn sphereAABB_intersect(pos:vec3<f32>, rad:f32, aabbXY:vec4<f32>, zmin:f32, zmax:f32) -> bool {
let qx = clamp(pos.x, aabbXY.x, aabbXY.y);
let qy = clamp(pos.y, aabbXY.z, aabbXY.w);
let qz = clamp(pos.z, zmin, zmax);
let dx = qx - pos.x;
let dy = qy - pos.y;
let dz = qz - pos.z;
return (dx*dx + dy*dy + dz*dz) <= (rad*rad);
}

@compute
@workgroup_size(4, 4, 4)
fn main(@builtin(global_invocation_id) globalIdx : vec3u) {
let n = params.near;
let f = params.far;

// cluster dims
let tileX = (params.screenSize.x + params.tileSize.x - 1u) / params.tileSize.x;
let tileY = (params.screenSize.y + params.tileSize.y - 1u) / params.tileSize.y;

if (globalIdx.x >= tileX || globalIdx.y >= tileY || globalIdx.z >= params.zSlices) {
return;
}

let idxX = globalIdx.x;
let idxY = globalIdx.y;
let idxZ = globalIdx.z;

let r = f / n;
let zN = f32(params.zSlices);
let t0 = f32(idxZ) / zN;
let t1 = f32(idxZ + 1u) / zN;
let depthMax = n * pow(r, t0);
let depthMin = n * pow(r, t1);

let zMin = -depthMin;
let zMax = -depthMax;

let aabbXY = clusterAABB_view_log(idxX, idxY, depthMax, depthMin);

let outIdx = (idxZ * tileY + idxY) * tileX + idxX;

let base = outIdx * params.maxLightsPerCluster;
var count : u32 = 0u;
let lightRad = 20.0;
for (var i = 0u; i < lightSet.numLights; i = i + 1u) {
let P = vec4f(lightSet.lights[i].pos, 1.0);
let Pvs = (uCamera.viewMat * P).xyz;
if (sphereAABB_intersect(Pvs, lightRad, aabbXY, zMin, zMax)) {
if (count < params.maxLightsPerCluster) {
clusterIndices[base + count] = i;
count = count + 1u;
}
}
}
clusterCounts[outIdx] = count;
}
Loading