Summarize, in about one paragraph, your approach to the materials assignment. How did you structure your code? What's cool about it? Are there parts you weren't able to complete?
For A2, I implemented a small material pipeline that connects the S72 scene description to a PBR shading workflow based on image-based lighting (IBL).
I organized the system around a MaterialSystem responsible for parsing materials and textures, creating GPU resources, and ensuring that every material has valid bindings through the use of default fallback textures.
A separate precomputation step generates the environment lighting data used for diffuse and specular IBL so that most of the heavy computation is moved offline and the runtime shader remains simple and efficient.
This structure keeps the renderer modular and makes it easy to support multiple material types with a unified layout.
The most interesting part is that most of the heavy IBL computation is done offline, keeping the runtime shader compact while still supporting multiple material types through a unified descriptor layout.
One limitation is that displacement is not yet applied in shading and I didn't have enough time for A2-create.
Describe the model and texture you created and include a screen recording showing it running in real-time:
I created a tree trunk model with detailed bark surface using a high-poly to low-poly workflow.
The final rendered result shows a cylindrical trunk with visible growth rings on the top surface and vertical bark patterns along the sides.
These details are represented through a normal map rather than explicit geometry, allowing the model to remain efficient while still conveying rich surface detail.
The normal map captures both radial patterns (tree rings) and directional patterns (bark grooves), which respond to lighting in real-time shading.
When rendered with environment lighting in the viewer, the surface exhibits clear shading variation based on the simulated microstructure, demonstrating the effectiveness of tangent-space normal mapping.
In the video, the left tree trunk is the low-poly model with a baked normal map, while the right tree trunk is the high-poly model.
This comparison demonstrates how normal mapping preserves the appearance of detailed bark structure on the low-poly mesh while keeping the geometry much simpler for real-time rendering.
Describe your creation process for the model and texture.
The model and texture were created using a high-poly sculpting and baking workflow in Blender.
First, I created a high-poly version of the tree trunk by starting from a cylinder and applying subdivision surface to increase its geometric resolution.
On top of this subdivided mesh, I used sculpt mode to add detailed surface features.
The bark patterns were sculpted primarily using the Crease Sharp brushes to carve out strong vertical grooves and define the structure of the bark.
On the top surface, circular patterns were sculpted to represent tree growth rings, focusing on capturing fine geometric detail in the high-poly model.
Next, I created a corresponding low-poly version of the trunk, keeping the geometry simple to ensure efficient rendering.
The low-poly mesh was unwrapped using UV mapping to prepare it for texture baking.
Then, I baked a tangent-space normal map from the high-poly model onto the low-poly model.
This process transfers the high-frequency geometric detail into a texture.
The baked normal map was saved as an external image and assigned to the material in Blender using a Normal Map node connected to the Principled BSDF shader.
Finally, I exported the model and material into the S72 format using the provided exporter.
This required ensuring that the material followed the expected naming convention (e.g., pbr:trunk_lowpoly) and that the normal map texture had a valid external file path so it could be correctly referenced in the exported scene.
This workflow allows the final rendered result to preserve the appearance of complex geometry while maintaining real-time performance.
Provide a short overview of how to use your cube utility.
The cube utility is a small preprocessing tool used to generate the environment lighting data required by the renderer's image-based lighting (IBL) pipeline. Instead of performing expensive convolutions at runtime, this tool reads an input cubemap and produces pre-integrated lookup cubemaps that can be sampled efficiently during shading. These generated maps are then loaded by the main renderer to provide diffuse irradiance and roughness-dependent specular reflections for PBR materials.
Document the command-line arguments that can be used to control your cube utility. Include both the command-line arguments required by the assignment statement and any additional arguments or functions you decided to add.
cube in.png --lambertian out.png --
read a cubemap from in.png, integrate it using direct integral (sum over texels in the cube),
and store the resulting diffuse irradiance cubemap as RGBE8 in out.png
cube in.png --ggx out.png --
read a cubemap from in.png, convolve it with a GGX specular distribution over increasing roughness levels,
and store the resulting mip-chain as RGBE8 images
out.1.png through out.N.png
cube in.png --brdf-lut out.raw
generate a BRDF LUT for split-sum specular IBL and save it as a 16-bit raw binary file in out.raw.
A debug visualization image is also written to the same directory using the name
out_debug.png, allowing the user to quickly inspect the LUT contents.
Document the command-line arguments that can be used to control your viewer. This will probably be a copy of the section from your A1 report with a few new flags relating to tone mapping.
--scene scene.s72 -- required -- load scene from scene.s72--camera name -- optional -- view the scene through the camera name--physical-device name -- optional -- use the physical device whose VkPhysicalDeviceProperties::deviceName matches name--drawing-size w h -- optional -- set the initial size of the drawable part of the window in physical pixels of width w and height h--culling mode -- optional -- sets the culling mode to be none|frustum|bvh--headless -- optional -- if specified, run in headless mode (no windowing system connection), and read frame times and events from standard input. In headless mode, the flag --drawing-size specifies the size of the offscreen canvas that is rendered into --test mode -- optional -- runs the A1-test section, mode can be cpu or gpu
Under cpu mode, the program will automatically loads the test scene for cpu "cpu-bottleneck.s72"
Under gpu mode, the program will load the scene from --scene scene.s72 argument--culled-count number -- optional -- combined with --test cpu argument to set the number of culled objects (outside the camera frustum) in the scene--csv-file-name file_name -- optional -- in A1-test section, the user chosen log file name--exposure E
scale computed radiance by 2E before tone mapping.
This simulates camera exposure adjustment and is always applied prior to the tone-mapping operator.
--tone-map mode
select the tone-mapping operator used to convert HDR radiance to displayable color.
Supported modes are:
linear (no additional mapping after exposure) and
reinhard (nonlinear compression of high luminance values).
The purpose of this section is to get you to think critically about your code by providing evidence sufficient to demonstrate to course staff that it works. These thoughts may also help you improve the code as you work on it in A3 and beyond.
What functions and data structures in your code are involved with loading environment cube maps and converting them from RGBE8 format to the format you chose to use for them on-GPU? (And what format did you choose for that?).
Environment cubemap loading is handled inside MaterialSystem. The main entry points are
MaterialSystem::load_environment_map (loads the scene’s radiance environment + GGX mip-stack) and
MaterialSystem::load_environment_map_diffuse() (loads the pre-integrated lambertian irradiance cubemap). Both functions
use stbi_load to read the RGBE8 PNGs into 8-bit RGBA bytes, then convert RGBE to linear floating-point RGB using the helper
decode_rgbe. On the GPU, I store these cubemaps as
VK_FORMAT_R32G32B32A32_SFLOAT cube images for simplicity and to preserve HDR precision. The relevant data structures are
Helpers::AllocatedImage env_cube (specular prefiltered cubemap with multiple mip levels),
Helpers::AllocatedImage diffuse_cube (diffuse irradiance cubemap), their corresponding VkImageViews, and env_cube_sampler.
Upload from CPU to GPU is done through the helper routines
rtg.helpers.create_image, rtg.helpers.transfer_to_cubemap, and
rtg.helpers.transfer_to_cubemap_level, where the GGX stack is filled by loading base.1.png through base.N.png and copying each into the matching mip level.
Provide evidence that your code can load environment maps and display "environment" and "mirror" materials.
Describe, in a few sentences, how your display code deals with tone mapping, and what you chose for your custom non-linear tone mapping curve.
Tone mapping is handled entirely in the fragment shader after computing physically-based radiance values in linear space.
Each material first evaluates its radiance (Lambertian IBL, environment lookup, mirror reflection, or full PBR split-sum lighting),
and only then applies exposure and tone mapping.
The exposure value (stored in World::TONE.x) is applied as a power-of-two scale factor before the tone-mapping operator,
ensuring that exposure always precedes color compression regardless of the selected operator.
The tone-mapping mode (stored in World::TONE.y) is passed as an integer flag and evaluated inside a shared
apply_tonemapping() function used by all material types.
In addition to the required linear operator (which performs no nonlinear modification after exposure), I implemented a Reinhard tone-mapping curve as my custom nonlinear operator. The Reinhard operator compresses high radiance values using a smooth rational mapping, which preserves detail in bright highlights while preventing clipping and maintaining mid-tone contrast. This produces a visually stable result when rendering HDR environment maps under varying exposure settings.
Provide evidence that your tone mapping and exposure controls work.
Plot your tone-mapping curve:
Where and how does your code implement the "lambertian" material?
The "lambertian" material is implemented jointly in the MaterialSystem on the CPU side and in the
objects.frag shader on the GPU side. During scene loading, MaterialSystem parses materials from the S72 file,
assigns them a material type flag (TYPE == 0 for lambertian), uploads their albedo parameters and textures,
and binds the precomputed diffuse environment cubemap (ENV_LAMBERTIAN) that was generated offline by the cube utility.
In the fragment shader (objects.frag), the lambertian branch evaluates the material by sampling this irradiance cubemap
using the surface normal as the lookup direction and multiplying it by the material's albedo:
vec3 e = texture(ENV_LAMBERTIAN, n).rgb;
This corresponds to the rendering equation for a purely diffuse surface where the BRDF is constant and the lighting integral has been precomputed into the environment map. The resulting radiance is then passed through the shared exposure and tone-mapping step before being written to the framebuffer. By moving the cosine convolution offline, the runtime implementation of the lambertian material reduces to a single cubemap lookup and multiply, making it both physically correct and efficient.
Provide evidence that your Lambertain cube-map pre-filtering works.
Provide evidence that your "lambertian" material works.
Plot timings for your Lambertian cube map pre-filtering as applied to various cube sizes.
Where and how does your code implement the normal mapping? (Both the texture loading and the usage in materials.)
Provide evidence that your normal mapping works properly.
Normal mapping in my code has two parts: texture loading/binding on the CPU side,
and tangent-space normal evaluation in the shaders.
On the CPU side, MaterialSystem::build_material_texture loads each material's normal map
into the global textures list and binds it in the per-material descriptor set at
binding=4 (seen in objects.frag as NORMAL_TEXTURE).
To make the pipeline robust, if a material has no normal map I bind a default flat normal texture
created in MaterialSystem.cpp via push_solid_texture with RGBA=(128,128,255,255),
i.e., (0.5, 0.5, 1.0) in tangent space; all these 2D textures are stored on-GPU as
VK_FORMAT_R8G8B8A8_UNORM.
On the GPU side, the vertex shader (objects.vert) transforms the mesh normal using
WORLD_FROM_LOCAL_NORMAL and transforms the tangent using WORLD_FROM_LOCAL,
then passes normal and tangent to the fragment shader.
In objects.frag, I construct a TBN frame from the interpolated normal and tangent
(including orthonormalization and the handedness stored in tangent.w), sample the normal map
in tangent space, remap it from [0,1] to [-1,1], and transform it to world space
with TBN. The resulting world-space normal n is then used for lighting and IBL lookups
in all relevant material branches.
What, if any, is the performance impact of normal mapping in your viewer? Can you make normal mapping a bottleneck? (I'm not actually sure if you can! Asking this one out of curiosity to see what you try.) If so, please include the scene.
Where and how does your code implement the "pbr" material?
How did you implement the integration required to pre-filter cube maps?
Where does your code precompute and load the "environment BRDF" (the second part of the split sum), and how is it stored?
The "pbr" material is implemented in the shared object shader and the material loading/binding code.
On the CPU side, MaterialSystem parses the S72 material parameters/textures and assigns the PBR material type
(TYPE == 3u) while ensuring that the PBR textures (albedo / roughness / metallic / normal) are bound in the
material descriptor set (set=2).
On the GPU side, the actual PBR shading happens in objects.frag under the TYPE == 3u branch:
it samples roughness/metallic (modulated by the scalar factors stored in PBR), computes Fresnel and
energy conservation (kD/kS), then evaluates split-sum IBL using a diffuse irradiance cubemap
(ENV_LAMBERTIAN), a GGX prefiltered specular cubemap (ENV with mip selection), and a BRDF LUT
(BRDF_LUT). The final radiance is then passed through the shared exposure + tone mapping path.
The GGX cube-map prefiltering (the first part of the split sum) is implemented offline in the cube utility
using the function precompute_ibl_specular_ggx_mip.
Instead of performing importance sampling at runtime, this function generates a prefiltered environment
represented as a mip-chain, where each mip level corresponds to a fixed surface roughness.
Higher mip levels store progressively blurrier versions of the environment, approximating the widening GGX lobe.
At runtime, the shader does not integrate the BRDF directly; it simply selects the appropriate mip level based on
material roughness using textureLod(ENV, R, mip).
This converts the expensive convolution into a constant-time texture lookup and follows the mip-based
split-sum approximation described in Karis’ real-time PBR notes.
The “environment BRDF” lookup texture (the second half of the split sum) is precomputed using the function
precompute_brdf_lut, also inside the cube utility.
This function numerically integrates the GGX microfacet BRDF over the hemisphere for combinations of
N·V and roughness, producing a 2D lookup table that encodes the scale and bias terms used in the
split-sum approximation.
The LUT is stored as a 16-bit-per-channel raw binary file to preserve precision, and at runtime the viewer loads this file,
uploads it to a 2D GPU texture, and samples it in the PBR shader as BRDF_LUT to reconstruct the specular IBL term.
Provide evidence that your "pbr" material works properly.
Provide evidence that your cube utility is able to correctly pre-filter cube maps with the cosine-weighted GGX normal distribution function. You may wish to, e.g., show that no energy is lost.
Provide evidence that your cube utility is able to correctly pre-integrate the environment BRDF part of the split sum. (E.g., an image of the precomputed table to compare to the one presented by Karis.)
Provide charts of timings characterizing the performance of your cube utility.
How did you implement displacement mapping?
What is the performance impact of using displacement mapping in a scene? Include charts or tables and an example scene where displacement mapping is a bottleneck.
This is the end of the structured report. Feel free to add feedback about A1 to this section.