Decals: markings and surface details
Project markings, configure rendering layers, change ORM without recoloring and diagnose mask and emission issues.
When to use a decal
A decal projects a texture onto existing surfaces: markings, tracks, damage or a local material change. It does not add geometry, cut a hole or change collision. Silhouette relief needs a mesh; a physical pit needs separate collision geometry.
The complete example below is verified in Forward+/Vulkan on the current 1.0.0 build. Materials, lighting, shadows and antialiasing are already configured.
Run the floor marking
Download the decal gallery. Extract the project, open project.reckfell in Reckfell with the C# SDK, build C# and run Main.rscene. No external textures or models are needed: DecalGallery3D.cs creates the marking and CinematicGallery3D.cs creates the gallery.
On the root, Preview=Albedo shows colored markings; OrmOnly changes roughness and metallic through the same mask. MarkingEnabled=false disables the decal at startup. These exported values are read in _Ready; from running code, change Marking.Visible or call ShowOrmOnly() after _Ready.
The example assigns Layers=2 to floor meshes and CullMask=2 to the decal. This is a rendering-layer bitmask, not physics: value 2 selects the second layer. For layer n, use 1u << (n - 1). The camera must also see the receiver layer.
DecalGallery3D.cs
using System.Collections.Generic;
using Reckfell;
public partial class DecalGallery3D : CinematicGallery3D
{
public enum SurfacePreview { Albedo, OrmOnly }
[Export] public SurfacePreview Preview { get; set; } = SurfacePreview.Albedo;
[Export] public bool MarkingEnabled { get; set; } = true;
public Decal Marking { get; private set; } = null!;
private readonly List<MeshInstance3D> floorTiles = new();
public override void _Ready()
{
base._Ready();
foreach (Entity child in GetChildren())
if (child is MeshInstance3D tile && tile.Mesh is BoxMesh box &&
Mathf.IsEqualApprox(tile.Position.Y, -0.055f) && Mathf.IsEqualApprox(box.Size.Y, 0.12f))
floorTiles.Add(tile);
SetFloorLayers(2);
Marking = new Decal
{
Position = new Vector3(0, 0.12f, -1.5f), Size = new Vector3(4.8f, 0.7f, 4.8f),
TextureAlbedo = CreateMarking(), CullMask = 2, AlbedoMix = 1,
Modulate = Colors.White, UpperFade = 0, LowerFade = 0,
NormalFade = 0.5f, DistanceFadeEnabled = false, Visible = MarkingEnabled
};
AddChild(Marking);
if (Preview == SurfacePreview.OrmOnly) ShowOrmOnly();
}
public void ShowOrmOnly()
{
using var image = Image.CreateEmpty(16, 16, false, Image.Format.Rgba8);
image.Fill(new Color(1, 0, 1)); // R=AO, G=roughness, B=metallic.
Marking.TextureOrm = ImageTexture.CreateFromImage(image);
Marking.AlbedoMix = 0; // Keep TextureAlbedo for its alpha mask.
}
public void SetFloorLayers(uint mask)
{
foreach (var tile in floorTiles) tile.Layers = mask;
}
// Alpha is the projection mask. Transparent pixels retain the floor material.
private static ImageTexture CreateMarking()
{
const int size = 1024;
using var image = Image.CreateEmpty(size, size, false, Image.Format.Rgba8);
for (int y = 0; y < size; y++) for (int x = 0; x < size; x++)
{
float u = (x + 0.5f) / size * 2 - 1, v = (y + 0.5f) / size * 2 - 1;
float a = Mathf.Abs(u), b = Mathf.Abs(v);
float edge = Mathf.Max(Mathf.Max(a, b), (a + b) * 0.70710678f);
float rim = 1 - Mathf.SmoothStep(0.006f, 0.01f, Mathf.Abs(edge - 0.85f));
float band = Mathf.SmoothStep(0.74f, 0.748f, edge) * (1 - Mathf.SmoothStep(0.80f, 0.808f, edge));
float stripe = Mathf.PosMod(u + v, 0.14f) < 0.075f ? 1 : 0.12f;
float bracket = a > 0.34f && a < 0.43f && b > 0.27f && b < 0.43f ? 1 : 0;
bracket = Mathf.Max(bracket, b > 0.34f && b < 0.43f && a > 0.27f && a < 0.43f ? 1 : 0);
float arrow = 0;
for (int i = 0; i < 3; i++)
if (Mathf.Abs(v + 0.15f + i * 0.18f - a * 0.7f) < 0.035f && a < 0.2f) arrow = 1;
float alpha = Mathf.Max(rim, Mathf.Max(band * stripe, Mathf.Max(bracket, arrow)));
Color color = band > 0.1f ? new Color(0.2f, 0.52f, 0.82f, alpha) : new Color(0.82f, 0.89f, 0.95f, alpha);
image.SetPixel(x, y, color);
}
image.GenerateMipmaps();
return ImageTexture.CreateFromImage(image);
}
}Position the projection
Size=(4.8, 0.7, 4.8) contains full projection-volume dimensions along local axes. Texture coverage uses X/Z and depth uses Y. At the initial rotation the example paints the floor; for a wall, rotate the Decal so its volume intersects the wall and its projection direction suits the surface.
Position=(0, 0.12, -1.5) places the floor inside the volume. Moving the decal upward beyond intersection removes the marking. Raising brightness cannot fix that: check position, orientation and Size first. Excessive depth can reach unwanted geometry.
UpperFade and LowerFade soften contribution toward opposite local-Y boundaries; both are 0 for uniform markings in the example. NormalFade attenuates projection on unsuitable surface orientations rather than controlling normal-map strength. Size clamps every axis to at least 0.001: zero size is not an off switch.
Change material without recoloring
| Channel | Changes | Mask |
|---|---|---|
| TextureAlbedo | base color | this texture’s alpha and Modulate.A |
| TextureNormal | lighting normal | TextureAlbedo alpha and Modulate.A |
| TextureOrm | R: AO, G: roughness, B: metallic | TextureAlbedo alpha and Modulate.A |
| TextureEmission | additive emission from RGB | its own RGB, not albedo alpha |
For an ORM-only effect, retain TextureAlbedo and set AlbedoMix=0. ShowOrmOnly() assigns ORM=(1, 0, 1): unattenuated AO, zero roughness and full metallic inside the mask. This is a strong teaching contrast; choose less extreme values for the final material.
Do not remove TextureAlbedo instead of setting AlbedoMix=0: the verified configuration loses its ORM mask too. Normal also uses the albedo mask; this example does not verify imported-map orientation or authoring-tool conventions.
Transparent TextureAlbedo regions retain the original material. For imported files, preserve alpha and check filtering and mipmaps; the example generator creates mipmaps before ImageTexture.CreateFromImage. Texture2D subtypes are not interchangeable in a decal atlas: do not treat ViewportTexture or AtlasTexture as a regular static image.
Emission and disabling
Emission is independent. Native checks confirm visibility without TextureAlbedo and with Modulate.A=0. Use black RGB for non-emitting parts of the emission texture; transparency alone is insufficient. EmissionEnergy=0 removes emission rather than the other channels.
To disable the entire instance, set Marking.Visible=false. Glow can add a halo to bright emission, but decal emission alone does not create a Light3D for neighboring meshes.
Fade distant decals
DistanceFadeEnabled enables fading by camera-to-Decal-position distance. Contribution is retained before DistanceFadeBegin, fades over the following DistanceFadeLength and is excluded beyond Begin+Length. Use a nonnegative begin distance and a positive transition length.
Verification compares Begin=0, Length=0.1 with the decal far from the camera against Begin=100, Length=1 with it before the fade start. These are not universal game defaults. Distance fade does not remove the Entity, stop its scripts or stream the world.
Diagnose an incorrect result
| Symptom | Check |
|---|---|
| Marking did not appear | Visible, TextureAlbedo, volume/surface intersection, CullMask and Layers, receiver visibility to the camera |
| Unwanted neighboring objects are affected | Reduce Size.Y or separate rendering layers |
| ORM disappeared after removing albedo | Restore the albedo mask and use AlbedoMix=0 |
| Alpha=0 but emission remains | Emission is independent of Modulate.A; disable Visible or EmissionEnergy |
| Decal disappears at a distance | DistanceFadeEnabled, Begin and Length |
| Changing the mask in Compatibility causes a shader error | This pass was not accepted for the complete scene: ShaderGLES3: Parameter "spec" is null was observed. Use Forward+ to reproduce this example |
Frames use the current local runtime: 2560×1440, internal scale 1.5, MSAA 8× and TAA. Camera, lighting and exposure remain fixed; only the illustrated decal state changes. Native checks confirm the stated albedo, ORM, emission, mask and distance operations. They do not establish renderer parity, transparent-receiver behavior, many overlapping decals or performance on other devices.