diff --git a/.gitignore b/.gitignore index a547bf36..e8593a24 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +/.vs diff --git a/README.md b/README.md index 4103e3b5..0ddc3bb3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/img/565proj4.gif b/img/565proj4.gif new file mode 100644 index 00000000..aac9a52a Binary files /dev/null and b/img/565proj4.gif differ diff --git a/img/graph.png b/img/graph.png new file mode 100644 index 00000000..3630eec8 Binary files /dev/null and b/img/graph.png differ diff --git a/img/graph2.png b/img/graph2.png new file mode 100644 index 00000000..b6495e7d Binary files /dev/null and b/img/graph2.png differ diff --git a/img/screenshot.png b/img/screenshot.png new file mode 100644 index 00000000..570f793f Binary files /dev/null and b/img/screenshot.png differ diff --git a/src/renderer.ts b/src/renderer.ts index fe34a550..224cf61e 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -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(); diff --git a/src/renderers/clustered_deferred.ts b/src/renderers/clustered_deferred.ts index 00a326ca..0e05b4d1 100644 --- a/src/renderers/clustered_deferred.ts +++ b/src/renderers/clustered_deferred.ts @@ -6,11 +6,169 @@ 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() { @@ -18,5 +176,78 @@ export class ClusteredDeferredRenderer extends renderer.Renderer { // - 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()]); } } diff --git a/src/renderers/forward_plus.ts b/src/renderers/forward_plus.ts index 471796fd..9190c12c 100644 --- a/src/renderers/forward_plus.ts +++ b/src/renderers/forward_plus.ts @@ -5,16 +5,135 @@ 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 + this.sceneUniformsBindGroupLayout = renderer.device.createBindGroupLayout({ + label: "forward+ 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" } + }, + { // cluster set + binding: 2, + visibility: GPUShaderStage.FRAGMENT, + buffer: { type: "storage" } + }, + { // screen tile + binding: 3, + visibility: GPUShaderStage.FRAGMENT, + buffer: { type: "uniform" } + } + ] + }); + + this.sceneUniformsBindGroup = renderer.device.createBindGroup({ + label: "forward+ 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.clusterSetStorageBuffer } }, + { binding: 3, resource: { buffer: this.lights.screenTileUniformBuffer } } + ] + }); + + this.depthTexture = renderer.device.createTexture({ + size: [renderer.canvas.width, renderer.canvas.height], + format: "depth24plus", + usage: GPUTextureUsage.RENDER_ATTACHMENT + }); + this.depthTextureView = this.depthTexture.createView(); + + this.pipeline = renderer.device.createRenderPipeline({ + layout: renderer.device.createPipelineLayout({ + label: "forward+ pipeline layout", + bindGroupLayouts: [ + this.sceneUniformsBindGroupLayout, + renderer.modelBindGroupLayout, + renderer.materialBindGroupLayout + ] + }), + depthStencil: { + depthWriteEnabled: true, + depthCompare: "less", + format: "depth24plus" + }, + vertex: { + module: renderer.device.createShaderModule({ + label: "naive vert shader", + code: shaders.naiveVertSrc + }), + buffers: [ renderer.vertexBufferLayout ] + }, + fragment: { + 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(); + + this.lights.doLightClustering(encoder); + + const canvasTextureView = renderer.context.getCurrentTexture().createView(); + const renderPass = encoder.beginRenderPass({ + label: "forward+ 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()]); } } diff --git a/src/renderers/naive.ts b/src/renderers/naive.ts index 0bf82417..5aba9eed 100644 --- a/src/renderers/naive.ts +++ b/src/renderers/naive.ts @@ -18,6 +18,11 @@ export class NaiveRenderer extends renderer.Renderer { label: "scene uniforms bind group layout", entries: [ // TODO-1.2: add an entry for camera uniforms at binding 0, visible to only the vertex shader, and of type "uniform" + { // camera + binding: 0, + visibility: GPUShaderStage.VERTEX, + buffer: { type: "uniform" } + }, { // lightSet binding: 1, visibility: GPUShaderStage.FRAGMENT, @@ -33,6 +38,10 @@ export class NaiveRenderer extends renderer.Renderer { // TODO-1.2: add an entry for camera uniforms at binding 0 // you can access the camera using `this.camera` // if you run into TypeScript errors, you're probably trying to upload the host buffer instead + { + binding: 0, + resource: { buffer: this.camera.uniformsBuffer } + }, { binding: 1, resource: { buffer: this.lights.lightSetStorageBuffer } @@ -106,6 +115,7 @@ 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); diff --git a/src/shaders/clustered_deferred.fs.wgsl b/src/shaders/clustered_deferred.fs.wgsl index 4e86f573..5ead9d36 100644 --- a/src/shaders/clustered_deferred.fs.wgsl +++ b/src/shaders/clustered_deferred.fs.wgsl @@ -1,3 +1,32 @@ // TODO-3: implement the Clustered Deferred G-buffer fragment shader // This shader should only store G-buffer information and should not do any shading. +@group(${bindGroup_material}) @binding(0) var diffuseTex: texture_2d; +@group(${bindGroup_material}) @binding(1) var diffuseTexSampler: sampler; + +struct GBufferOutput { //world space + @location(0) position: vec4f, + @location(1) normal: vec4f, + @location(2) albedo: vec4f +} + +struct FragmentInput { //world space + @builtin(position) fragCoord: vec4f, + @location(0) position: vec3f, + @location(1) normal: vec3f, + @location(2) uv: vec2f +} + +@fragment +fn main(in: FragmentInput) -> GBufferOutput { + let albedo = textureSample(diffuseTex, diffuseTexSampler, in.uv); + if (albedo.a < 0.5) { + discard; + } + + var output: GBufferOutput; + output.position = vec4f(in.position, 1.0); + output.normal = vec4f(normalize(in.normal), 0.0); + output.albedo = albedo; + return output; +} \ No newline at end of file diff --git a/src/shaders/clustered_deferred_fullscreen.fs.wgsl b/src/shaders/clustered_deferred_fullscreen.fs.wgsl index 68235c41..130119f9 100644 --- a/src/shaders/clustered_deferred_fullscreen.fs.wgsl +++ b/src/shaders/clustered_deferred_fullscreen.fs.wgsl @@ -1,3 +1,72 @@ // TODO-3: implement the Clustered Deferred fullscreen fragment shader // Similar to the Forward+ fragment shader, but with vertex information coming from the G-buffer instead. + +@group(${bindGroup_scene}) @binding(0) var camera: CameraUniforms; +@group(${bindGroup_scene}) @binding(1) var lightSet: LightSet; +@group(${bindGroup_scene}) @binding(2) var clusterSet: ClusterSet; +@group(${bindGroup_scene}) @binding(3) var screenTile: vec4f; + +@group(1) @binding(0) var positionTex: texture_2d; +@group(1) @binding(1) var normalTex: texture_2d; +@group(1) @binding(2) var albedoTex: texture_2d; +@group(1) @binding(3) var texSampler: sampler; + +fn mapDepthToClusterLayer(viewSpaceDepth: f32) -> u32 { + let nearPlane = camera.nearFar.x; + let farPlane = camera.nearFar.y; + let totalDepthSlices = f32(clusterSet.numClustersZ); + let logDepth = clamp((log(viewSpaceDepth / nearPlane) / log(farPlane / nearPlane)), 0.0, 0.99999); + return u32(floor(logDepth * totalDepthSlices)); +} + +fn clusterIndex(clusterX: u32, clusterY: u32, clusterZ: u32) -> u32 { + let gridSizeX = clusterSet.numClustersX; + let gridSizeY = clusterSet.numClustersY; + return (clusterZ * gridSizeY + clusterY) * gridSizeX + clusterX; +} + +struct FragmentInput { + @builtin(position) fragCoord: vec4f, + @location(0) uv: vec2f +} + +@fragment +fn main(in: FragmentInput) -> @location(0) vec4f { + let worldPos = textureSample(positionTex, texSampler, in.uv).xyz; + let normal = textureSample(normalTex, texSampler, in.uv).xyz; + let albedo = textureSample(albedoTex, texSampler, in.uv); + + if (albedo.a < 0.5) { //transparent pixels + discard; + } + + // cluster coordinates + let screenW = screenTile.x; + let screenH = screenTile.y; + let tileW = screenTile.z; + let tileH = screenTile.w; + + let pixelX = in.fragCoord.x; + let pixelY = in.fragCoord.y; + + let clusterX = u32(clamp(floor(pixelX / tileW), 0.0, f32(clusterSet.numClustersX - 1u))); + let clusterY = u32(clamp(floor(pixelY / tileH), 0.0, f32(clusterSet.numClustersY - 1u))); + + let viewPos = (camera.viewMat * vec4f(worldPos, 1.0)).xyz; + let viewSpaceDepth = max(-viewPos.z, camera.nearFar.x); + let clusterZ = mapDepthToClusterLayer(viewSpaceDepth); + + let currClusterIndex = clusterIndex(clusterX, clusterY, clusterZ); + let clusterLightCount = clusterSet.clusters[currClusterIndex].numLights; + let maxLightsPerCluster = ${maxLightsPerCluster}u; + + var totalLight = vec3f(0.0); + for (var i = 0u; i < min(clusterLightCount, maxLightsPerCluster); i++) { + let lightIndex = clusterSet.clusters[currClusterIndex].lightIndices[i]; + let light = lightSet.lights[lightIndex]; + totalLight += calculateLightContrib(light, worldPos, normalize(normal)); + } + + return vec4f(albedo.rgb * totalLight, 1.0); +} diff --git a/src/shaders/clustered_deferred_fullscreen.vs.wgsl b/src/shaders/clustered_deferred_fullscreen.vs.wgsl index 1e43a884..19b0a949 100644 --- a/src/shaders/clustered_deferred_fullscreen.vs.wgsl +++ b/src/shaders/clustered_deferred_fullscreen.vs.wgsl @@ -1,3 +1,24 @@ // TODO-3: implement the Clustered Deferred fullscreen vertex shader // This shader should be very simple as it does not need all of the information passed by the the naive vertex shader. + +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f +} + +@vertex +fn main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + + var output: VertexOutput; + + let vertex_x = f32(i32(vertexIndex & 1u) * 4 - 1); + let vertex_y = f32(i32(vertexIndex >> 1u) * 4 - 1); + output.position = vec4f(vertex_x, vertex_y, 0.0, 1.0); + + output.uv = vec2f( + vertex_x * 0.5 + 0.5, + 0.5 - vertex_y * 0.5 + ); + return output; +} diff --git a/src/shaders/clustering.cs.wgsl b/src/shaders/clustering.cs.wgsl index 575d6e5a..074807ef 100644 --- a/src/shaders/clustering.cs.wgsl +++ b/src/shaders/clustering.cs.wgsl @@ -21,3 +21,125 @@ // - Stop adding lights if the maximum number of lights is reached. // - Store the number of lights assigned to this cluster. + +@group(${bindGroup_scene}) @binding(0) var camera: CameraUniforms; +@group(${bindGroup_scene}) @binding(1) var lightSet: LightSet; +@group(${bindGroup_scene}) @binding(2) var clusterSet: ClusterSet; +@group(${bindGroup_scene}) @binding(3) var screenTile: vec4f; // (screenW, screenH, tileW, tileH) + +fn clusterIndex(clusterX: u32, clusterY: u32, clusterZ: u32) -> u32 { + let gridSizeX = clusterSet.numClustersX; + let gridSizeY = clusterSet.numClustersY; + return (clusterZ * gridSizeY + clusterY) * gridSizeX + clusterX; // z-major order +} + +@compute @workgroup_size(${moveLightsWorkgroupSize}) +fn clear(@builtin(global_invocation_id) gid: vec3u) { + let clusterGridSizeX = clusterSet.numClustersX; + let clusterGridSizeY = clusterSet.numClustersY; + let clusterGridSizeZ = clusterSet.numClustersZ; + let totalClusterCount = clusterGridSizeX * clusterGridSizeY * clusterGridSizeZ; + + let clusterIndex = gid.x; + if (clusterIndex < totalClusterCount) { + clusterSet.clusters[clusterIndex].numLights = 0u; + } +} + +fn transformViewToNDC(viewSpacePoint: vec3f) -> vec2f { + let clipSpacePos = camera.projMat * vec4f(viewSpacePoint, 1.0); + return clipSpacePos.xy / clipSpacePos.w; +} + +fn mapDepthToClusterLayer(viewSpaceDepth: f32) -> u32 { + let nearZ = camera.nearFar.x; + let farZ = camera.nearFar.y; + let depthSlices = f32(clusterSet.numClustersZ); + let LogDepth = clamp((log(viewSpaceDepth / nearZ) / log(farZ / nearZ)), 0.0, 0.99999); + return u32(floor(LogDepth * depthSlices)); +} + +@compute @workgroup_size(${moveLightsWorkgroupSize}) +fn assign(@builtin(global_invocation_id) gid: vec3u) { + + let gridSizeX = clusterSet.numClustersX; + let gridSizeY = clusterSet.numClustersY; + let gridSizeZ = clusterSet.numClustersZ; + let totalClusterCount = gridSizeX * gridSizeY * gridSizeZ; + + let globalClusterIndex = gid.x; + if (globalClusterIndex >= totalClusterCount) { return; } + + // 1D -> 3D + let clustersPerLayer = gridSizeX * gridSizeY; + let clusterZ = globalClusterIndex / clustersPerLayer; + let layerOffset = globalClusterIndex - clusterZ * clustersPerLayer; + let clusterY = layerOffset / gridSizeX; + let clusterX = layerOffset - clusterY * gridSizeX; + + let currClusterIndex = globalClusterIndex; + + let screenW = screenTile.x; + let screenH = screenTile.y; + let tileW = screenTile.z; + let tileH = screenTile.w; + + // pixel space bounds for the tile + let tileMinX = f32(clusterX) * tileW; + let tileMaxX = min(f32(clusterX + 1u) * tileW, screenW); + let tileMinY = f32(clusterY) * tileH; + let tileMaxY = min(f32(clusterY + 1u) * tileH, screenH); + + // z-depth + let nearZ = camera.nearFar.x; + let farZ = camera.nearFar.y; + let totalDepthSlices = f32(gridSizeZ); + let sliceLowerBound = f32(clusterZ) / totalDepthSlices; + let sliceUpperBound = f32(clusterZ + 1u) / totalDepthSlices; + let clusterMinDepth = nearZ * pow(farZ / nearZ, sliceLowerBound); + let clusterMaxDepth = nearZ * pow(farZ / nearZ, sliceUpperBound); + + let maxLightsPerCluster = ${maxLightsPerCluster}u; + let lightSphereRadius = f32(${lightRadius}); + + for (var lightIndex = 0u; lightIndex < lightSet.numLights; lightIndex++) { + let currLight = lightSet.lights[lightIndex]; + + + let lightViewSpace4 = camera.viewMat * vec4f(currLight.pos, 1.0); // light position in view space + let lightViewSpace = lightViewSpace4.xyz; + let lightDepth = -lightViewSpace.z; + + let lightMinDepth = max(nearZ, lightDepth - lightSphereRadius); + let lightMaxDepth = min(farZ, lightDepth + lightSphereRadius); + if (lightMinDepth >= lightMaxDepth) { continue; } + if (clusterMaxDepth <= lightMinDepth || clusterMinDepth >= lightMaxDepth) { continue; } + + + let sphereLeftNDC = transformViewToNDC(vec3f(lightViewSpace.x - lightSphereRadius, lightViewSpace.y, lightViewSpace.z)).x; + let sphereRightNDC = transformViewToNDC(vec3f(lightViewSpace.x + lightSphereRadius, lightViewSpace.y, lightViewSpace.z)).x; + let sphereBottomNDC = transformViewToNDC(vec3f(lightViewSpace.x, lightViewSpace.y - lightSphereRadius, lightViewSpace.z)).y; + let sphereTopNDC = transformViewToNDC(vec3f(lightViewSpace.x, lightViewSpace.y + lightSphereRadius, lightViewSpace.z)).y; + + var ndcBoundsMinX = min(sphereLeftNDC, sphereRightNDC); + var ndcBoundsMaxX = max(sphereLeftNDC, sphereRightNDC); + var ndcBoundsMinY = min(sphereBottomNDC, sphereTopNDC); + var ndcBoundsMaxY = max(sphereBottomNDC, sphereTopNDC); + + // NDC bounds -> screen-space + let lightScreenMinX = (ndcBoundsMinX * 0.5 + 0.5) * screenW; + let lightScreenMaxX = (ndcBoundsMaxX * 0.5 + 0.5) * screenW; + + let lightScreenMinY = (1.0 - (ndcBoundsMaxY * 0.5 + 0.5)) * screenH; + let lightScreenMaxY = (1.0 - (ndcBoundsMinY * 0.5 + 0.5)) * screenH; + + if (lightScreenMaxX <= tileMinX || lightScreenMinX >= tileMaxX) { continue; } + if (lightScreenMaxY <= tileMinY || lightScreenMinY >= tileMaxY) { continue; } + + let currLightCount = clusterSet.clusters[currClusterIndex].numLights; + if (currLightCount < maxLightsPerCluster) { + clusterSet.clusters[currClusterIndex].lightIndices[currLightCount] = lightIndex; + clusterSet.clusters[currClusterIndex].numLights = currLightCount + 1u; + } + } +} diff --git a/src/shaders/common.wgsl b/src/shaders/common.wgsl index 738e9c4e..d6277b03 100644 --- a/src/shaders/common.wgsl +++ b/src/shaders/common.wgsl @@ -11,9 +11,25 @@ struct LightSet { } // TODO-2: you may want to create a ClusterSet struct similar to LightSet +struct Cluster { + numLights: u32, + lightIndices: array +} + +struct ClusterSet { + numClustersX: u32, + numClustersY: u32, + numClustersZ: u32, + _padCS: u32, + clusters: array +} struct CameraUniforms { - // TODO-1.3: add an entry for the view proj mat (of type mat4x4f) + viewProjMat: mat4x4f, + viewMat: mat4x4f, + projMat: mat4x4f, + nearFar: vec2f, + _padCam: vec2f } // CHECKITOUT: this special attenuation function ensures lights don't affect geometry outside the maximum light radius diff --git a/src/shaders/forward_plus.fs.wgsl b/src/shaders/forward_plus.fs.wgsl index 0500e3df..a67fef63 100644 --- a/src/shaders/forward_plus.fs.wgsl +++ b/src/shaders/forward_plus.fs.wgsl @@ -1,4 +1,5 @@ // TODO-2: implement the Forward+ fragment shader +// Version: 2.0 (Fixed variable naming) // See naive.fs.wgsl for basic fragment shader setup; this shader should use light clusters instead of looping over all lights @@ -14,3 +15,72 @@ // Add the calculated contribution to the total light accumulation. // Multiply the fragment’s diffuse color by the accumulated light contribution. // Return the final color, ensuring that the alpha component is set appropriately (typically to 1). + +@group(${bindGroup_scene}) @binding(0) var camera: CameraUniforms; +@group(${bindGroup_scene}) @binding(1) var lightSet: LightSet; +@group(${bindGroup_scene}) @binding(2) var clusterSet: ClusterSet; +@group(${bindGroup_scene}) @binding(3) var screenTile: vec4f; // (screenW, screenH, tileW, tileH) + +@group(${bindGroup_material}) @binding(0) var diffuseTex: texture_2d; +@group(${bindGroup_material}) @binding(1) var diffuseTexSampler: sampler; + +struct FragmentInput +{ + @builtin(position) fragCoord: vec4f, + @location(0) pos: vec3f, + @location(1) nor: vec3f, + @location(2) uv: vec2f +} + +fn clusterIndex(clusterX: u32, clusterY: u32, clusterZ: u32) -> u32 { + let gridSizeX = clusterSet.numClustersX; + let gridSizeY = clusterSet.numClustersY; + return (clusterZ * gridSizeY + clusterY) * gridSizeX + clusterX; // z-major order for cache coherency +} + +fn mapDepthToClusterLayer(viewSpaceDepth: f32) -> u32 { + let nearPlane = camera.nearFar.x; + let farPlane = camera.nearFar.y; + let totalDepthSlices = f32(clusterSet.numClustersZ); + let logDepth = clamp((log(viewSpaceDepth / nearPlane) / log(farPlane / nearPlane)), 0.0, 0.99999); + return u32(floor(logDepth * totalDepthSlices)); +} + +@fragment +fn main(in: FragmentInput) -> @location(0) vec4f +{ + let diffuseColor = textureSample(diffuseTex, diffuseTexSampler, in.uv); + if (diffuseColor.a < 0.5f) { + discard; + } + + // cluster coordinates + let screenW = screenTile.x; + let screenH = screenTile.y; + let tileW = screenTile.z; + let tileH = screenTile.w; + + let pixelX = in.fragCoord.x; + let pixelY = in.fragCoord.y; + + let clusterX = u32(clamp(floor(pixelX / tileW), 0.0, f32(clusterSet.numClustersX - 1u))); + let clusterY = u32(clamp(floor(pixelY / tileH), 0.0, f32(clusterSet.numClustersY - 1u))); + + let fragViewSpace = (camera.viewMat * vec4f(in.pos, 1.0)).xyz; + let viewSpaceDepth = max(-fragViewSpace.z, camera.nearFar.x); + let clusterZ = mapDepthToClusterLayer(viewSpaceDepth); + + let currentClusterIndex = clusterIndex(clusterX, clusterY, clusterZ); + let clusterLightCount = clusterSet.clusters[currentClusterIndex].numLights; + let maxLightsPerCluster = ${maxLightsPerCluster}u; + + var accumulatedLightColor = vec3f(0.0, 0.0, 0.0); + for (var lightIndex = 0u; lightIndex < min(clusterLightCount, maxLightsPerCluster); lightIndex++) { + let lightId = clusterSet.clusters[currentClusterIndex].lightIndices[lightIndex]; + let currentLight = lightSet.lights[lightId]; + accumulatedLightColor += calculateLightContrib(currentLight, in.pos, normalize(in.nor)); + } + + var finalFragmentColor = diffuseColor.rgb * accumulatedLightColor; + return vec4(finalFragmentColor, 1); +} diff --git a/src/shaders/naive.vs.wgsl b/src/shaders/naive.vs.wgsl index 5a7ddd4b..96d7e5e8 100644 --- a/src/shaders/naive.vs.wgsl +++ b/src/shaders/naive.vs.wgsl @@ -2,6 +2,7 @@ // TODO-1.3: add a uniform variable here for camera uniforms (of type CameraUniforms) // make sure to use ${bindGroup_scene} for the group +@group(${bindGroup_scene}) @binding(0) var camera: CameraUniforms; @group(${bindGroup_model}) @binding(0) var modelMat: mat4x4f; @@ -26,7 +27,7 @@ fn main(in: VertexInput) -> VertexOutput let modelPos = modelMat * vec4(in.pos, 1); var out: VertexOutput; - out.fragPos = ??? * modelPos; // TODO-1.3: replace ??? with the view proj mat from your CameraUniforms uniform variable + out.fragPos = camera.viewProjMat * modelPos; // TODO-1.3: replace ??? with the view proj mat from your CameraUniforms uniform variable out.pos = modelPos.xyz / modelPos.w; out.nor = in.nor; out.uv = in.uv; diff --git a/src/shaders/shaders.ts b/src/shaders/shaders.ts index 584c008f..0bc7b1eb 100644 --- a/src/shaders/shaders.ts +++ b/src/shaders/shaders.ts @@ -30,13 +30,29 @@ export const constants = { moveLightsWorkgroupSize: 128, - lightRadius: 2 + lightRadius: 2, + + clusterTileSizeX: 32, + clusterTileSizeY: 32, + clusterZSlices: 24, + maxLightsPerCluster: 768 }; // ================================= function evalShaderRaw(raw: string) { - return eval('`' + raw.replaceAll('${', '${constants.') + '`'); + try { + const processedText = raw.replace(/\${(\w+)}/g, (_, key: keyof typeof constants) => { + if (!(key in constants)) { + throw new Error(`Missing constant: ${key}`); + } + return constants[key].toString(); + }); + return processedText; + } catch (err) { + console.error('Error processing shader:', err); + throw err; + } } const commonSrc: string = evalShaderRaw(commonRaw); diff --git a/src/stage/camera.ts b/src/stage/camera.ts index 7d2a4a1e..f4efef74 100644 --- a/src/stage/camera.ts +++ b/src/stage/camera.ts @@ -3,14 +3,25 @@ import { toRadians } from "../math_util"; import { device, canvas, fovYDegrees, aspectRatio } from "../renderer"; class CameraUniforms { - readonly buffer = new ArrayBuffer(16 * 4); + readonly buffer = new ArrayBuffer(52 * 4); //viewProjMat + viewMat + projMat + nearFar + padding private readonly floatView = new Float32Array(this.buffer); set viewProjMat(mat: Float32Array) { // TODO-1.1: set the first 16 elements of `this.floatView` to the input `mat` + this.floatView.set(mat.subarray(0, 16), 0); } - // TODO-2: add extra functions to set values needed for light clustering here + set viewMat(mat: Float32Array) { + this.floatView.set(mat.subarray(0, 16), 16); + } + set projMat(mat: Float32Array) { + this.floatView.set(mat.subarray(0, 16), 32); + } + set nearFar(v: [number, number]) { + const i = 48; + this.floatView[i + 0] = v[0]; + this.floatView[i + 1] = v[1]; + } } export class Camera { @@ -39,6 +50,12 @@ export class Camera { // // note that you can add more variables (e.g. inverse proj matrix) to this buffer in later parts of the assignment + this.uniformsBuffer = device.createBuffer({ + label: "camera", + size: this.uniforms.buffer.byteLength, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + this.projMat = mat4.perspective(toRadians(fovYDegrees), aspectRatio, Camera.nearPlane, Camera.farPlane); this.rotateCamera(0, 0); // set initial camera vectors @@ -129,10 +146,15 @@ export class Camera { const viewMat = mat4.lookAt(this.cameraPos, lookPos, [0, 1, 0]); const viewProjMat = mat4.mul(this.projMat, viewMat); // TODO-1.1: set `this.uniforms.viewProjMat` to the newly calculated view proj mat + this.uniforms.viewProjMat = viewProjMat; // TODO-2: write to extra buffers needed for light clustering here + this.uniforms.viewMat = viewMat; + this.uniforms.projMat = this.projMat; + this.uniforms.nearFar = [Camera.nearPlane, Camera.farPlane]; // TODO-1.1: upload `this.uniforms.buffer` (host side) to `this.uniformsBuffer` (device side) // check `lights.ts` for examples of using `device.queue.writeBuffer()` + device.queue.writeBuffer(this.uniformsBuffer, 0, this.uniforms.buffer); } } diff --git a/src/stage/lights.ts b/src/stage/lights.ts index a6eed919..34a1ad37 100644 --- a/src/stage/lights.ts +++ b/src/stage/lights.ts @@ -1,5 +1,5 @@ import { vec3 } from "wgpu-matrix"; -import { device } from "../renderer"; +import { device, canvas } from "../renderer"; import * as shaders from '../shaders/shaders'; import { Camera } from "./camera"; @@ -29,6 +29,22 @@ export class Lights { moveLightsComputePipeline: GPUComputePipeline; // TODO-2: add layouts, pipelines, textures, etc. needed for light clustering here + clusteringComputeBindGroupLayout: GPUBindGroupLayout; + clusteringComputeBindGroup: GPUBindGroup; + clusteringClearPipeline: GPUComputePipeline; + clusteringAssignPipeline: GPUComputePipeline; + + clusterSetStorageBuffer: GPUBuffer; + + screenTileBuffer = new Float32Array(4); // screenW, screenH, tileW, tileH + screenTileUniformBuffer: GPUBuffer; + + numClustersX: number; + numClustersY: number; + numClustersZ: number; + maxLightsPerCluster: number; + tileW: number; + tileH: number; constructor(camera: Camera) { this.camera = camera; @@ -94,6 +110,102 @@ export class Lights { }); // TODO-2: initialize layouts, pipelines, textures, etc. needed for light clustering here + this.tileW = shaders.constants.clusterTileSizeX; + this.tileH = shaders.constants.clusterTileSizeY; + this.numClustersZ = shaders.constants.clusterZSlices; + this.numClustersX = Math.ceil(canvas.width / this.tileW); + this.numClustersY = Math.ceil(canvas.height / this.tileH); + this.maxLightsPerCluster = shaders.constants.maxLightsPerCluster; + + const numClusters = this.numClustersX * this.numClustersY * this.numClustersZ; + const clusterBufferSize = 16 + (numClusters * (1 + this.maxLightsPerCluster) * 4); + + this.screenTileBuffer[0] = canvas.width; + this.screenTileBuffer[1] = canvas.height; + this.screenTileBuffer[2] = this.tileW; + this.screenTileBuffer[3] = this.tileH; + + this.screenTileUniformBuffer = device.createBuffer({ + label: "screen tile uniforms", + size: this.screenTileBuffer.byteLength, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + device.queue.writeBuffer(this.screenTileUniformBuffer, 0, this.screenTileBuffer); + + this.clusterSetStorageBuffer = device.createBuffer({ + label: "cluster set", + size: clusterBufferSize, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST + }); + + const header = new Uint32Array([this.numClustersX, this.numClustersY, this.numClustersZ, 0]); + device.queue.writeBuffer(this.clusterSetStorageBuffer, 0, header); + + this.clusteringComputeBindGroupLayout = device.createBindGroupLayout({ + label: "clustering compute bind group layout", + entries: [ + { // camera + binding: 0, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: "uniform" } + }, + { // lightSet + binding: 1, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: "read-only-storage" } + }, + { // cluster set + binding: 2, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: "storage" } + }, + { // screen tile uniforms + binding: 3, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: "uniform" } + } + ] + }); + + this.clusteringComputeBindGroup = device.createBindGroup({ + label: "clustering compute bind group", + layout: this.clusteringComputeBindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.camera.uniformsBuffer } }, + { binding: 1, resource: { buffer: this.lightSetStorageBuffer } }, + { binding: 2, resource: { buffer: this.clusterSetStorageBuffer } }, + { binding: 3, resource: { buffer: this.screenTileUniformBuffer } } + ] + }); + + const clusteringModule = device.createShaderModule({ + label: "clustering compute shader", + code: shaders.clusteringComputeSrc + }); + + this.clusteringClearPipeline = device.createComputePipeline({ + label: "clustering clear pipeline", + layout: device.createPipelineLayout({ + label: "clustering clear pipeline layout", + bindGroupLayouts: [ this.clusteringComputeBindGroupLayout ] + }), + compute: { + module: clusteringModule, + entryPoint: "clear" + } + }); + + this.clusteringAssignPipeline = device.createComputePipeline({ + label: "clustering assign pipeline", + layout: device.createPipelineLayout({ + label: "clustering assign pipeline layout", + bindGroupLayouts: [ this.clusteringComputeBindGroupLayout ] + }), + compute: { + module: clusteringModule, + entryPoint: "assign" + } + }); } private populateLightsBuffer() { @@ -113,6 +225,18 @@ export class Lights { doLightClustering(encoder: GPUCommandEncoder) { // TODO-2: run the light clustering compute pass(es) here // implementing clustering here allows for reusing the code in both Forward+ and Clustered Deferred + const pass = encoder.beginComputePass({ label: "light clustering compute"}); + pass.setBindGroup(0, this.clusteringComputeBindGroup); + + pass.setPipeline(this.clusteringClearPipeline); + const numClusters = this.numClustersX * this.numClustersY * this.numClustersZ; + const wgSize = shaders.constants.moveLightsWorkgroupSize; + pass.dispatchWorkgroups(Math.ceil(numClusters / wgSize)); + + pass.setPipeline(this.clusteringAssignPipeline); + pass.dispatchWorkgroups(Math.ceil(numClusters / wgSize)); + + pass.end(); } // CHECKITOUT: this is where the light movement compute shader is dispatched from the host