Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Decals

Live Demo: Decals

A decal is a texture projected onto scene geometry using the depth buffer. Decals cover bullet holes, blood splatters, footprints, scorch marks, and environmental details without modifying the underlying mesh. The geometry stays static and the decal layer is purely cosmetic.

How decal rendering works

Each decal is drawn as a unit cube positioned and oriented in world space. The fragment shader reconstructs the world position of the scene geometry behind the cube by sampling the depth buffer, then transforms that position into the decal's local space using the inverse model matrix. If the reconstructed point falls inside the decal's projection volume (plus or minus 1 in XY, 0 to depth in Z), the decal texture is sampled at those local XY coordinates and blended onto the scene.

The normal threshold test compares the scene surface normal (computed from depth buffer gradients) against the decal's forward direction. Surfaces angled past the threshold are rejected. The result is that decals stop projecting around sharp edges instead of wrapping over them like a sticker.

Distance fade is a smoothstep between fade_start and fade_end based on the camera-to-decal distance. Far decals fade out cheaply and stay culled rather than producing aliased projections at long range.

Decal component

#![allow(unused)]
fn main() {
pub struct Decal {
    pub texture: Option<String>,
    pub emissive_texture: Option<String>,
    pub emissive_strength: f32,
    pub color: [f32; 4],
    pub size: Vec2,
    pub depth: f32,
    pub normal_threshold: f32,
    pub fade_start: f32,
    pub fade_end: f32,
}
}
FieldDefaultDescription
textureNoneTexture name in the texture cache
emissive_textureNoneOptional emissive texture for glowing decals
emissive_strength1.0HDR multiplier for emissive texture
color[1, 1, 1, 1]RGBA tint multiplied with the texture
size(1.0, 1.0)Width and height of the projected decal
depth1.0Projection depth (how far the decal penetrates into surfaces)
normal_threshold0.5Surface angle cutoff (0 = accept all, 1 = perpendicular only)
fade_start50.0Distance where fade begins
fade_end100.0Distance where the decal is fully transparent

Spawning decals

#![allow(unused)]
fn main() {
fn spawn_bullet_hole(world: &mut World, position: Vec3, normal: Vec3) -> Entity {
    let entity = world.spawn_entities(
        LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | DECAL,
        1
    )[0];

    let rotation = nalgebra_glm::quat_look_at(&normal, &Vec3::y());

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        rotation,
        ..Default::default()
    });

    world.core.set_decal(entity, Decal::new("bullet_hole")
        .with_size(0.2, 0.2)
        .with_depth(0.1)
        .with_normal_threshold(0.5)
        .with_fade(20.0, 30.0));

    entity
}
}

Builder API

The Decal struct supports a builder pattern for chained configuration.

#![allow(unused)]
fn main() {
let decal = Decal::new("texture_name")
    .with_size(0.5, 0.5)
    .with_depth(0.2)
    .with_color([1.0, 0.0, 0.0, 1.0])
    .with_normal_threshold(0.3)
    .with_fade(30.0, 50.0)
    .with_emissive("rune_glow", 3.0);
}

Common use cases

Blood splatter

#![allow(unused)]
fn main() {
fn spawn_blood(world: &mut World, position: Vec3, normal: Vec3) -> Entity {
    let entity = world.spawn_entities(
        LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | DECAL,
        1
    )[0];

    let rotation = nalgebra_glm::quat_look_at(&normal, &Vec3::y());
    let random_angle = rand::random::<f32>() * std::f32::consts::TAU;
    let rotation = rotation * nalgebra_glm::quat_angle_axis(random_angle, &Vec3::z());

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        rotation,
        ..Default::default()
    });

    world.core.set_decal(entity, Decal::new("blood")
        .with_size(0.8, 0.8)
        .with_depth(0.1)
        .with_normal_threshold(0.3)
        .with_fade(30.0, 50.0));

    entity
}
}

Emissive rune

#![allow(unused)]
fn main() {
fn spawn_magic_rune(world: &mut World, position: Vec3) -> Entity {
    let entity = world.spawn_entities(
        LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | DECAL,
        1
    )[0];

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        rotation: nalgebra_glm::quat_angle_axis(
            -std::f32::consts::FRAC_PI_2,
            &Vec3::x(),
        ),
        ..Default::default()
    });

    world.core.set_decal(entity, Decal::new("rune")
        .with_size(2.0, 2.0)
        .with_depth(0.5)
        .with_normal_threshold(0.7)
        .with_fade(50.0, 80.0)
        .with_emissive("rune_glow", 3.0));

    entity
}
}

Footprints

#![allow(unused)]
fn main() {
fn spawn_footprint(world: &mut World, position: Vec3, direction: Vec3, left: bool) -> Entity {
    let entity = world.spawn_entities(
        LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | DECAL,
        1
    )[0];

    let rotation = nalgebra_glm::quat_look_at(&Vec3::y(), &direction);
    let flip = if left { 1.0 } else { -1.0 };

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        rotation,
        scale: Vec3::new(flip, 1.0, 1.0),
    });

    world.core.set_decal(entity, Decal::new("footprint")
        .with_size(0.15, 0.3)
        .with_depth(0.05)
        .with_normal_threshold(0.8)
        .with_fade(15.0, 25.0));

    entity
}
}