Skip to main content

forge-bloom

Add Jimenez dual-filter bloom to an SDL GPU project with HDR rendering. Implements 13-tap downsample with Karis averaging and 9-tap tent-filter upsample with additive blending.

Jump to install

Source facts

Repository
Nebulavenus/forge-gpu
Last source activity
March 1, 2026 at 21:31
Detected SKILL.md language
English
Stars
38
Forks
7

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
forge-bloom
description
Add Jimenez dual-filter bloom to an SDL GPU project with HDR rendering. Implements 13-tap downsample with Karis averaging and 9-tap tent-filter upsample with additive blending.
Add Jimenez dual-filter bloom (SIGGRAPH 2014) to an SDL GPU project that already has HDR rendering (floating-point render target + tone mapping). ## Prerequisites - HDR render target (R16G16B16A16_FLOAT) with COLOR_TARGET + SAMPLER usage - Tone mapping pass (fullscreen quad, HDR → swapchain) - Bright HDR values in the scene (emissive objects, specular highlights > 1.0) ## Architecture ### Bloom mip chain Create 5 half-resolution HDR textures, each with COLOR_TARGET + SAMPLER: ```c #define BLOOM_MIP_COUNT 5 #define HDR_FORMAT SDL_GPU_TEXTUREFORMAT_R16G16B16A16_FLOAT /* For 1280x720: 640x360 → 320x180 → 160x90 → 80x45 → 40x22 */ Uint32 w = hdr_width / 2, h = hdr_height / 2; for (int i = 0; i < BLOOM_MIP_COUNT; i++) { bloom_mips[i] = create_texture(device, HDR_FORMAT, w, h, SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER); bloom_widths[i] = w; bloom_heights[i] = h; w /= 2; h /= 2; } ``` ### Pipelines (3 bloom-specific) | Pipeline | Vertex | Fragment | Blend | Depth | |----------|--------|----------|-------|-------| | downsample | fullscreen.vert (SV_VertexID) | bloom_downsample.frag | None | No | | upsample | fullscreen.vert (SV_VertexID) | bloom_upsample.frag | **Additive (ONE+ONE)** | No | | tonemap | fullscreen.vert (SV_VertexID) | tonemap.frag | None | No | ### Upsample blend state (critical) ```c color_desc.blend_state.enable_blend = true; color_desc.blend_state.src_color_blendfactor = SDL_GPU_BLENDFACTOR_ONE; color_desc.blend_state.dst_color_blendfactor = SDL_GPU_BLENDFACTOR_ONE; color_desc.blend_state.color_blend_op = SDL_GPU_BLENDOP_ADD; color_desc.blend_state.src_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ONE; color_desc.blend_state.dst_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ONE; color_desc.blend_state.alpha_blend_op = SDL_GPU_BLENDOP_ADD; ``` ### Bloom sampler Use LINEAR / CLAMP_TO_EDGE — the bilinear filtering is essential for quality: ```c sampler_info.min_filter = SDL_GPU_FILTER_LINEAR; sampler_info.mag_filter = SDL_GPU_FILTER_LINEAR; sampler_info.address_mode_u = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; sampler_info.address_mode_v = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; ``` ## Per-frame render passes ```text 1. Scene pass → hdr_target (existing) 2. Bloom downsample (5 passes): Pass 0: hdr_target → bloom_mips[0] (threshold + Karis) Pass 1: bloom_mips[0] → bloom_mips[1] (standard weights) Pass 2-4: same pattern 3. Bloom upsample (4 passes, LOAD + additive blend): Pass 0: bloom_mips[4] → ADD to bloom_mips[3] Pass 1: bloom_mips[3] → ADD to bloom_mips[2] Pass 2-3: same pattern 4. Tonemap pass → swapchain Sample hdr_target + bloom_mips[0] output = tonemap((hdr + bloom * bloom_intensity) * exposure) ``` ## Downsample uniforms ```c typedef struct BloomDownsampleUniforms { float texel_size[2]; /* 1/source_width, 1/source_height */ float threshold; /* brightness cutoff (first pass only) */ float use_karis; /* 1.0 first pass, 0.0 rest */ } BloomDownsampleUniforms; ``` ## Upsample uniforms ```c typedef struct BloomUpsampleUniforms { float texel_size[2]; /* 1/source_width, 1/source_height */ float _pad[2]; } BloomUpsampleUniforms; ``` ## Downsample pass loop ```c for (int i = 0; i < BLOOM_MIP_COUNT; i++) { /* Render to bloom_mips[i] with CLEAR */ /* Source: i==0 ? hdr_target : bloom_mips[i-1] */ /* Texel size: 1/source dimensions */ ds_u.use_karis = (i == 0) ? 1.0f : 0.0f; ds_u.threshold = bloom_threshold; } ``` ## Upsample pass loop ```c for (int i = BLOOM_MIP_COUNT - 2; i >= 0; i--) { /* Render to bloom_mips[i] with LOAD (preserve existing data) */ /* Source: bloom_mips[i+1] (smaller mip) */ /* Additive blend adds upsampled result on top */ } ``` ## Key API calls Each bloom pass is a separate render pass. The key SDL GPU calls per pass: ```c /* Begin a render pass targeting one bloom mip */ SDL_GPURenderPass *pass = SDL_BeginGPURenderPass(cmd, &color_info, 1, NULL); SDL_BindGPUGraphicsPipeline(pass, pipeline); /* downsample or upsample */ SDL_BindGPUFragmentSamplers(pass, 0, &sampler_bind, 1); /* source texture + sampler */ SDL_PushGPUFragmentUniformData(cmd, 0, &uniforms, sizeof(uniforms)); SDL_DrawGPUPrimitives(pass, 3, 1, 0, 0); /* fullscreen triangle */ SDL_EndGPURenderPass(pass); ``` - **Downsample pass:** `color_info.load_op = SDL_GPU_LOADOP_CLEAR` - **Upsample pass:** `color_info.load_op = SDL_GPU_LOADOP_LOAD` (preserve downsample data; the additive blend state on the pipeline accumulates) - **Tonemap pass:** Bind both `hdr_target` and `bloom_mips[0]` as fragment samplers, combine before tone mapping ## Code template Minimal copy-ready skeleton for the per-frame bloom passes: ```c /* ── Downsample: extract bright areas and progressively blur ───────── */ BloomDownsampleUniforms ds_u; for (int i = 0; i < BLOOM_MIP_COUNT; i++) { /* Source is HDR target for first pass, previous mip for the rest */ SDL_GPUTexture *src = (i == 0) ? hdr_target : bloom_mips[i - 1]; Uint32 src_w = (i == 0) ? hdr_width : bloom_widths[i - 1]; Uint32 src_h = (i == 0) ? hdr_height : bloom_heights[i - 1]; ds_u.texel_size[0] = 1.0f / (float)src_w; ds_u.texel_size[1] = 1.0f / (float)src_h; ds_u.threshold = bloom_threshold; ds_u.use_karis = (i == 0) ? 1.0f : 0.0f; /* Render to bloom_mips[i], CLEAR load op */ SDL_GPUColorTargetInfo color_info; SDL_zero(color_info); color_info.texture = bloom_mips[i]; color_info.load_op = SDL_GPU_LOADOP_CLEAR; color_info.store_op = SDL_GPU_STOREOP_STORE; SDL_GPURenderPass *pass = SDL_BeginGPURenderPass(cmd, &color_info, 1, NULL); SDL_BindGPUGraphicsPipeline(pass, downsample_pipeline); SDL_GPUTextureSamplerBinding bind = { .texture = src, .sampler = bloom_sampler }; SDL_BindGPUFragmentSamplers(pass, 0, &bind, 1); SDL_PushGPUFragmentUniformData(cmd, 0, &ds_u, sizeof(ds_u)); SDL_DrawGPUPrimitives(pass, 3, 1, 0, 0); SDL_EndGPURenderPass(pass); } /* ── Upsample: progressively add back detail with additive blend ───── */ BloomUpsampleUniforms us_u; for (int i = BLOOM_MIP_COUNT - 2; i >= 0; i--) { /* Source is the smaller (i+1) mip */ us_u.texel_size[0] = 1.0f / (float)bloom_widths[i + 1]; us_u.texel_size[1] = 1.0f / (float)bloom_heights[i + 1]; /* Render to bloom_mips[i], LOAD to preserve downsample data */ SDL_GPUColorTargetInfo color_info; SDL_zero(color_info); color_info.texture = bloom_mips[i]; color_info.load_op = SDL_GPU_LOADOP_LOAD; /* critical: preserve existing data */ color_info.store_op = SDL_GPU_STOREOP_STORE; SDL_GPURenderPass *pass = SDL_BeginGPURenderPass(cmd, &color_info, 1, NULL); SDL_BindGPUGraphicsPipeline(pass, upsample_pipeline); /* has additive blend */ SDL_GPUTextureSamplerBinding bind = { .texture = bloom_mips[i + 1], .sampler = bloom_sampler }; SDL_BindGPUFragmentSamplers(pass, 0, &bind, 1); SDL_PushGPUFragmentUniformData(cmd, 0, &us_u, sizeof(us_u)); SDL_DrawGPUPrimitives(pass, 3, 1, 0, 0); SDL_EndGPURenderPass(pass); } ``` ## Common mistakes 1. **CLEAR instead of LOAD on upsample** — The upsample pass MUST use `SDL_GPU_LOADOP_LOAD` to preserve the downsample data. CLEAR would erase it, losing the multi-scale effect. 2. **Wrong texel size** — The texel size must be `1/source` dimensions, not `1/destination`. The shader samples the source texture. 3. **Missing LINEAR sampler** — Using NEAREST for bloom sampling produces blocky artifacts. The bilinear filter is part of the algorithm's design. 4. **Bloom added after tone mapping** — Bloom must be added to HDR values BEFORE tone mapping, so the combined result compresses naturally. Adding after tone mapping causes bloom highlights to clip. 5. **Not recreating mip chain on resize** — The bloom mip dimensions depend on the window size. Release and recreate on resize. ## Reference - [GPU Lesson 22 — Bloom](../../../lessons/gpu/22-bloom/) - Jorge Jimenez, "Next Generation Post Processing in Call of Duty: Advanced Warfare," SIGGRAPH 2014
View on GitHub