Skip to content

Deferred rendering crushes all emissive light to ~4% via the clearcoat emission Fresnel term #25083

Description

@soupslurpr

Bevy version and features

0.19.0; still present on main (crates/bevy_pbr/src/render/pbr_functions.wgsl, crates/bevy_pbr/src/deferred/mod.rs). Reproduced with default features.

[Optional] Relevant system information

  • cargo 1.96.0 (30a34c682 2026-05-25), stable
  • Arch Linux (kernel 7.1.3-arch2-2), Wayland session (repro captured under Xwayland)
  • AdapterInfo { name: "NVIDIA GeForce RTX 5090", vendor: 4318, device: 11141, device_type: DiscreteGpu, device_pci_bus_id: "0000:01:00.0", driver: "NVIDIA", driver_info: "610.43.03", backend: Vulkan, subgroup_min_size: 32, subgroup_max_size: 32, transient_saves_memory: false }

Not setup-specific: the loss is a shader-math term, view-angle dependent, and reproduces identically regardless of SSAO/SSR/tonemapping settings.

What you did

Rendered a scene containing emissive StandardMaterials (no clearcoat) with DefaultOpaqueRendererMethod::deferred():

use bevy::core_pipeline::prepass::{DeferredPrepass, DepthPrepass};
use bevy::pbr::DefaultOpaqueRendererMethod;
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .insert_resource(DefaultOpaqueRendererMethod::deferred())
        .add_systems(Startup, setup)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::new(2.0, 2.0, 2.0))),
        MeshMaterial3d(materials.add(StandardMaterial {
            base_color: Color::srgb(0.1, 0.05, 0.02),
            emissive: Color::srgb(1.0, 0.3, 0.12).to_linear() * 5.0,
            ..default()
        })),
        Transform::from_xyz(0.0, 1.0, 0.0),
    ));
    commands.spawn((
        Camera3d::default(),
        bevy::camera::Hdr,
        DepthPrepass,
        DeferredPrepass,
        bevy::post_process::bloom::Bloom::NATURAL,
        Msaa::Off,
        Transform::from_xyz(0.0, 20.0, 30.0).looking_at(Vec3::ZERO, Vec3::Y),
    ));
}

What went wrong

  • Expected: deferred and forward rendering produce the same lighting for the same scene; the emissive cube glows at full brightness (and feeds bloom) either way.
  • Actually happened: under deferred rendering, emissive surfaces render roughly 25x dimmer than under forward (view-angle dependent), killing bloom with them. The same scene with the DefaultOpaqueRendererMethod::deferred() line removed renders the cube at full brightness.

Measured on the repro above (sRGB screenshot of the cube face, defaults otherwise): forward (253, 157, 120), deferred (122, 38, 20) — the unclipped green/blue channels are ~20-25x dimmer in linear terms.

Image

Additional information

Cause

apply_pbr_lighting darkens emission for clearcoat materials (pbr_functions.wgsl):

#ifdef STANDARD_MATERIAL_CLEARCOAT
    emissive_light = emissive_light * (0.04 + (1.0 - 0.04) * pow(1.0 - clearcoat_NdotV, 5.0));
#endif

Two problems compound:

  1. The formula multiplies by the Fresnel term itself instead of by the energy the coat transmits. The glTF KHR_materials_clearcoat spec's emission section — the same document the code comment above the line cites — defines it as coated_emission = emission * (1 - clearcoat * clearcoat_fresnel), about 0.96 face-on for a full-strength coat. The current code multiplies by fresnel itself, about 0.04 face-on, i.e. it keeps only the light the coating reflects back inward and discards the ~96% that should be transmitted. It also ignores clearcoat strength entirely, so a material with clearcoat: 0.0 is darkened as if fully coated.

  2. The deferred lighting pipeline defines STANDARD_MATERIAL_CLEARCOAT unconditionally (deferred/mod.rs, DeferredLightingLayout::specialize), since it cannot know per-pixel whether the G-buffer material had clearcoat. Forward rendering only compiles the block in for materials that actually use clearcoat, which is why the bug hides there: with the def gated, materials without clearcoat never execute the broken line.

So under deferred, every emissive surface takes the broken path and loses ~96% of its emission face-on.

I verified the G-buffer round-trip is lossless by dumping pbr_input.material.emissive directly from the deferred lighting pass — the unpacked value matches forward exactly; the loss happens only at this multiply.

Suggested fix

#ifdef STANDARD_MATERIAL_CLEARCOAT
    let clearcoat_fresnel = 0.04 + (1.0 - 0.04) * pow(1.0 - clearcoat_NdotV, 5.0);
    emissive_light = emissive_light * (1.0 - clearcoat * clearcoat_fresnel);
#endif

With this change the deferred repro matches the forward render pixel-exactly on the emissive cube face, clearcoat: 0.0 materials are unaffected by the def being always-on, and full-strength clearcoat materials get the spec's mild Fresnel darkening in forward too (the current formula is wrong there as well, just masked for uncoated materials by the def gating).

Workaround

Patching the shader line above in a vendored bevy_pbr (via [patch.crates-io]) fully restores parity with forward.

Related

This is a 0.19 regression introduced by #23359 (which fixed #23284, clearcoat not visible with deferred renderer). Before that PR the deferred G-buffer discarded clearcoat entirely and the deferred lighting shader did not define STANDARD_MATERIAL_CLEARCOAT, so the broken emission multiply was never compiled into the deferred path and deferred emissives rendered at full brightness. #23359 correctly packs clearcoat into the G-buffer but defines STANDARD_MATERIAL_CLEARCOAT unconditionally in the deferred lighting shader, which put every deferred pixel onto the pre-existing broken formula. The formula bug itself predates #23359 and also affects forward rendering for materials that actually use clearcoat; the unconditional def just made it universal under deferred.

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-RenderingDrawing game state to the screenC-BugAn unexpected or incorrect behaviorD-ModestA "normal" level of difficulty; suitable for simple features or challenging fixesP-RegressionFunctionality that used to work but no longer does. Add a test for this!S-Ready-For-ImplementationThis issue is ready for an implementation PR. Go for it!

    Type

    No type

    Projects

    Status
    Needs SME Triage

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions