Shockwave Lensing Effect¶
Overview¶
When an N-body particle crosses the confinement radius, a billboard-based lensing shockwave expands from the impact point. The effect renders as a camera-facing quad that samples the scene behind it and applies:
- Radial UV displacement — pixels are pushed outward from the ring center
- Chromatic aberration — R, G, B channels are sampled at different displacement offsets (1.0×, 1.25×, 1.5×)
- Additive HDR glow — ring edge emits body-colored light that drives bloom
Architecture¶
graph TD
A[Scene Geometry] --> B[Scene FBO - RGBA16F]
B --> C{Any active shockwaves?}
C -->|No| E[Post-Processing]
C -->|Yes| D[Grab Pass: glCopyImageSubData]
D --> F[Billboard Draw]
F --> G[Fragment Shader: sample grab_tex + distort]
G --> B
B --> E
Render Flow¶
- Scene geometry renders into the HDR FBO (
scene_color_tex, RGBA16F) - Grab pass:
glCopyImageSubDatacopiesscene_color_tex→grab_tex(texture-to-texture DMA, lazy-allocated, same format/size) - Billboard draw: for each active shockwave, a camera-facing quad is rasterized
- Fragment shader: samples
grab_texat displaced screen UVs with per-channel chromatic aberration - Post-processing reads the modified
scene_color_texnormally
Key Files¶
| File | Role |
|---|---|
include/shockwave.h |
ShockwaveRenderer struct, constants, API |
src/shockwave.c |
Grab pass, emit/update/draw logic |
shaders/shockwave.vert |
Billboard vertex shader, outputs vScreenUV |
shaders/shockwave.frag |
Displacement + chromatic aberration + glow |
src/scene.c |
Call site with GPU profiler + wireframe support |
Grab Pass Cost Analysis¶
The grab pass uses glCopyImageSubData (GL 4.3) — a texture-to-texture DMA copy that bypasses the framebuffer read pipeline entirely. Cost is bounded by VRAM bandwidth only:
| Resolution | Format | Size/frame | Bandwidth @ 60 fps | % of 200 GB/s GPU |
|---|---|---|---|---|
| 1920×1080 | RGBA16F (8 B/px) | ~16 MB | ~960 MB/s | 0.5% |
| 2560×1440 | RGBA16F | ~29 MB | ~1.7 GB/s | 0.9% |
| 3840×2160 | RGBA16F | ~66 MB | ~3.9 GB/s | 2.0% |
Key properties:
- No CPU stall, no pipeline synchronization, no framebuffer binding required
- Cost = 0 when no shockwaves are active (early return before the copy)
- Lazy allocation:
grab_texis only created on first use and resized on resolution change scene_color_texhandle is wired fromPostProcessviarenderer_draw_frame
Grab Pass Optimization Analysis¶
The grab pass is the dominant cost of the shockwave effect. Four optimization strategies were evaluated before choosing glCopyImageSubData:
Approach 1: Half-Resolution Grab Pass¶
Allocate grab_tex at screen_w/2 × screen_h/2 instead of full resolution. Since lensing distortion is a low-frequency effect, the visual difference is imperceptible.
- Gain: ~75% bandwidth reduction (÷4 texels copied)
- Complexity: Low — only
ensure_grab_texture()size change +glBlitFramebufferfor downscale - Risk: None (fragment shader already uses normalized
[0,1]UVs) - Status: Deferred — viable future optimization
Approach 2: glTextureBarrier (Eliminate Copy Entirely)¶
OpenGL 4.5 / GL_NV_texture_barrier. Read scene_color_tex directly as sampler input in the same framebuffer being written to. A glTextureBarrier() call flushes caches and makes prior writes visible.
- Gain: ~100% of the copy bandwidth eliminated (~16 MB/frame at 1080p). Residual cost = pipeline flush ~1-5μs vs ~0.1-0.3ms for the full copy
- Complexity: Low — remove
grab_tex, bindscene_color_texdirectly - Risk: Undefined behavior if two shockwave quads overlap on the same pixel (same texel read AND written). With localized billboards this is unlikely but not impossible
- Status: Rejected — fragile, driver-dependent, UB risk with overlapping events
Approach 3: glCopyImageSubData (Current Implementation) ✅¶
Direct texture-to-texture DMA copy. Does not transit through the framebuffer read pipeline — the GPU copy engine handles it independently.
- Gain: ~10-30% over
glCopyTexSubImage2D(bypasses framebuffer read pipeline, no texture unit binding required) - Complexity: Trivial — drop-in replacement, 1 line change
- Risk: None (requires GL 4.3, already our minimum)
- Status: Implemented —
scene_color_texhandle wired fromPostProcessviarenderer_draw_frame
Approach 4: Screen-Space AABB Clipping¶
Compute the screen-space bounding box of all active shockwave quads, copy only that region instead of the full framebuffer.
- Gain: Variable (0-90%) — depends on how much screen area the shockwaves cover. 2 shockwaves covering 10% of screen → copy 10% instead of 100%
- Complexity: Medium — requires projecting all billboard corners to screen space and computing min/max
- Risk: None
- Status: Deferred — good ROI for scenes with few, small shockwaves
Decision Summary¶
| Approach | BW Gain | Complexity | Risk | Status |
|---|---|---|---|---|
| Half-res | ~75% | Low | None | Deferred |
glTextureBarrier |
~100% | Low | UB if overlap | Rejected |
glCopyImageSubData |
~10-30% | Trivial | None | Active |
| AABB clipping | 0-90% | Medium | None | Deferred |
Shader Design¶
Vertex Shader (shockwave.vert)¶
The billboard is a unit quad [-1,1] scaled to u_radius and oriented to face the camera via cross-product basis vectors. Two outputs:
vUV— local quad coordinates[-1,1]for the ring profilevScreenUV— perspective-divided screen coordinates[0,1]for scene sampling
Fragment Shader (shockwave.frag)¶
Ring profile: Gaussian centered on expanding wavefront
ring_radius = 0.3 + 0.6 * progress
ring = exp(-8 * (dist - ring_radius)²)
Temporal envelope: sin(π * progress) → smooth fade-in/fade-out
Displacement: factor = 0.06 * intensity * ring * envelope
offset = factor * radial_direction
Chromatic aberration:
R = sample(grab_tex, screenUV + offset * 1.0)
G = sample(grab_tex, screenUV + offset * 1.25)
B = sample(grab_tex, screenUV + offset * 1.5)
Additive glow: color * 3.0 * ring * envelope * intensity * 0.25
Constants¶
| Constant | Value | Description |
|---|---|---|
DISTORT_STRENGTH |
0.06 | Maximum UV displacement at peak |
RING_SHARPNESS |
8.0 | Gaussian width (higher = thinner ring) |
CA_SPREAD_R/G/B |
1.0 / 1.25 / 1.5 | Per-channel displacement multipliers |
HDR_GLOW_SCALE |
3.0 | Emissive glow intensity |
GLOW_MIX |
0.25 | Glow contribution vs pure distortion |
Emission Filtering¶
Not every boundary crossing produces a visible shockwave. Three filters apply:
- Velocity threshold (
SHOCKWAVE_MIN_VELOCITY = 0.05): only the outward radial velocity component is measured (v_out = dot(velocity, radial_hat)). Tangential crossings produce no effect - Peak tracking: per body, only the peak velocity during a frame is recorded (avoids duplicate events from sub-stepping)
- Capacity limit (
SHOCKWAVE_MAX_ACTIVE = 16): when full, the oldest event is evicted
Time Reversal Support¶
The simulation supports backward time via time_scale < 0. Shockwaves use fabsf(sim_time - start_time) for age computation, ensuring:
- Ring expansion is always outward regardless of time direction
- Cleanup removes events after
SHOCKWAVE_DURATIONabsolute seconds - No accumulation of stale events in reverse mode
Profiling & Observability¶
The shockwave draw is instrumented at three levels:
| Tool | Mechanism | Visibility |
|---|---|---|
| GPU Profiler (F3) | GPU_STAGE_PROFILER("Shockwave VFX") |
In-app overlay, timer query |
| Tracy | TracyCZoneN + tracy_gpu_zone_begin/end |
Tracy profiler (build with TRACY_ENABLE) |
| RenderDoc | gl_debug_push_group("Shockwave_VFX") |
RenderDoc capture groups |
The profiler stage includes both the grab pass and the billboard draw calls, giving the total GPU cost of the effect.
Wireframe Debug¶
When wireframe mode is active (W key), shockwave billboards render as line quads via glPolygonMode(GL_FRONT_AND_BACK, GL_LINE). This shows:
- Billboard quad geometry and orientation
- Ring expansion radius over time
- Number of active shockwaves