BildverarbeitungIMG-04
Bloom
Helles strahlt über seine Ränder hinaus — ein Fehler der Optik, drei Zeilen nachgebaut.
- Laufzeit
- O(Pixel · Radius)
- Schritte
- ausschneiden, weichzeichnen, addieren
- Weichzeichner
- separierbarer Gauß
- Rechnung
- addieren, nicht mischen
01Worum es geht
Eine echte Linse ist nicht perfekt. Sehr helles Licht streut im Glas, im Sensor und im Auge und legt einen weichen Schein um sich. Fotografen kennen das als Überstrahlung, Spieleentwickler bauen es absichtlich nach, weil ein Bildschirm nun einmal nicht heller kann als weiß: Wenn eine Lampe strahlen soll, obwohl sie schon weiß ist, muss der Schein außen herum die Arbeit tun.
Das Verfahren dafür ist erstaunlich einfach und in drei Schritten erzählt: ausschneiden, was hell ist — weit weichzeichnen — wieder draufaddieren.




02So funktioniert es
Was zählt als Licht?
Ein Schwellwert trennt Lichtquellen vom Rest. Wichtig ist dabei, nicht einfach alles über der Schwelle zu übernehmen, sondern nur den Teil oberhalb davon:
licht = helligkeit > schwelle ? wert · (helligkeit − schwelle) / (1 − schwelle) : 0
Dadurch beginnt eine Lichtquelle sanft zu glühen, statt bei Erreichen der Schwelle plötzlich aufzuspringen. Bei Fotos muss die Schwelle hoch sitzen, sonst leuchtet das halbe Bild und alles wirkt nur neblig.
Weichzeichnen
Der Schein selbst ist ein Gauß-Weichzeichner, und zwar ein weiter. Weil die Glockenkurve separierbar ist, wird sie einmal waagerecht und einmal senkrecht angewandt statt als große Matrix — dasselbe Ergebnis, aus n² wird 2n. Bei einem Radius von 9 Pixeln ist das der Unterschied zwischen 361 und 38 Multiplikationen pro Pixel.
Addieren, nicht mischen
Der letzte Schritt ist der, bei dem man es falsch machen kann: Licht wird addiert, nicht überblendet. Zwei Lampen nebeneinander sind heller als eine; eine Überblendung würde stattdessen die eine gegen die andere abwägen. Deshalb steht im Code wert + schein · stärke und nicht irgendeine Mischung, und deshalb wird am Ende auf 1 begrenzt.
Die drei Regler
- Schwelle — ab wann etwas als Licht gilt. Zu niedrig, und das Bild wird milchig.
- Radius — wie weit der Schein reicht. Groß wirkt nach Nebel, klein nach Politur.
- Stärke — wie viel davon zurückkommt. Der Weichzeichner verteilt die Energie über die Fläche, also muss die Stärke mit dem Radius wachsen, sonst sieht man nichts mehr.
03Ausprobieren
04Implementierung
// Eine echte Linse ist nicht perfekt: Helles Licht streut im Glas, im Sensor und im
// Auge und legt einen weichen Schein um sich. Bloom baut das in drei Schritten nach -
// ausschneiden, was hell ist, weit weichzeichnen, wieder draufaddieren.
public static class Bloom
{
// threshold sagt, ab welcher Helligkeit ein Pixel als Lichtquelle gilt, radius, wie
// weit der Schein reicht, strength, wie viel davon zurückkommt.
public static PngImage Add(PngImage image, float threshold, float radius, float strength)
{
var brightness = image.ToBrightness();
var channels = new float[3][];
for (var channel = 0; channel < 3; channel++)
{
var values = Channels.Of(image, channel);
var lights = new float[values.Length];
// Behalten wird nur, was über der Schwelle liegt, und davon nur der Teil oberhalb
// davon. So beginnt eine Lichtquelle sanft zu glühen, statt aufzuspringen.
for (var i = 0; i < values.Length; i++)
lights[i] = brightness[i] > threshold ? values[i] * (brightness[i] - threshold) / (1 - threshold) : 0;
var halo = Convolution.Blur(lights, image.Width, image.Height, radius);
var result = new float[values.Length];
// Addieren, nicht mischen: Licht summiert sich, es ersetzt nichts.
for (var i = 0; i < values.Length; i++)
result[i] = Math.Clamp(values[i] + halo[i] * strength, 0, 1);
channels[channel] = result;
}
return Channels.ToImage(channels[0], channels[1], channels[2], image.Width, image.Height);
}
}
05Lichter in der Nacht
const int width = 240;
const int height = 160;
// Eine Nachtszene in ein paar Zeilen: dunkler Grund, eine Reihe Lampen und ein
// heller Balken. Jedes Licht ist eine harte Scheibe - der Schein darum wird hier
// nicht gezeichnet, genau dafür ist Bloom da.
var random = new Pcg(20240921);
var channels = new[] { new float[width * height], new float[width * height], new float[width * height] };
void Light(int centreX, int centreY, float radius, float red, float green, float blue)
{
for (var y = (int)(centreY - radius); y <= centreY + radius; y++)
{
for (var x = (int)(centreX - radius); x <= centreX + radius; x++)
{
if (x < 0 || y < 0 || x >= width || y >= height)
continue;
if ((x - centreX) * (x - centreX) + (y - centreY) * (y - centreY) > radius * radius)
continue;
channels[0][y * width + x] = red;
channels[1][y * width + x] = green;
channels[2][y * width + x] = blue;
}
}
}
for (var i = 0; i < channels[0].Length; i++)
channels[2][i] = 0.06f; // die Nacht ist nicht schwarz, sie ist sehr dunkelblau
for (var lamp = 0; lamp < 30; lamp++)
Light(random.Next(width), random.Next(height), 1 + random.Next(2), 1f, 0.92f, 0.7f);
for (var x = 40; x < 200; x++)
Light(x, 118, 1.5f, 0.3f, 1f, 1f);
var scene = Channels.ToImage(channels[0], channels[1], channels[2], width, height);
scene.Save(Path.Combine(folder, "bloom-night.png"));
Console.WriteLine("setting threshold radius strength brighter pixels");
foreach (var (name, threshold, radius, strength) in new[]
{
("soft", 0.5f, 3f, 2.5f),
("wide", 0.5f, 9f, 6f),
})
{
var glowing = Bloom.Add(scene, threshold, radius, strength);
glowing.Save(Path.Combine(folder, $"bloom-night-{name}.png"));
var brighter = 0;
for (var i = 0; i < width * height; i++)
{
if (glowing.Pixels[i * 4 + 1] > scene.Pixels[i * 4 + 1] + 2)
brighter++;
}
Console.WriteLine(
$"{name,-7} {threshold,9:0.00} {radius,6:0} {strength,8:0.0} " +
$"{brighter * 100.0 / (width * height),13:0.0} %");
}
setting threshold radius strength brighter pixels
soft 0.50 3 2.5 18.7 %
wide 0.50 9 6.0 66.9 %
06Auf einem Foto
var photo = PngImage.Load(file);
// Bei einem Foto muss die Schwelle hoch sitzen, sonst fängt das ganze Bild an zu
// leuchten und wirkt nur neblig. Hier liegt allein der Dampf darüber.
var glowing = Bloom.Add(photo, threshold: 0.72f, radius: 8f, strength: 0.9f);
glowing.Save(Path.Combine(folder, "spring-bloom.png"));
var brightness = photo.ToBrightness();
var lights = brightness.Count(value => value > 0.72f);
Console.WriteLine($"{lights * 100.0 / brightness.Length:0.0} % of the picture counts as a light source");
Console.WriteLine($"average brightness before {brightness.Average():0.000}, " +
$"after {glowing.ToBrightness().Average():0.000}");
5.0 % of the picture counts as a light source
average brightness before 0.446, after 0.452
07Gut zu wissen
- In Spielen läuft dieser Schritt am Ende der Bildberechnung, im HDR-Raum: Dort darf eine Lampe den Wert 40 haben, während der Bildschirm bei 1 aufhört. Die Schwelle liegt dann bei 1, und alles darüber ist per Definition zu hell für die Anzeige — genau das strahlt.
- Schneller wird es klein. Praktisch wird der Schein nicht in voller Auflösung berechnet, sondern das helle Bild mehrfach halbiert, jede Stufe weichgezeichnet und alle wieder aufaddiert. Das kostet einen Bruchteil und sieht besser aus, weil mehrere Radien übereinanderliegen.
- Verwandte Effekte aus derselben Werkstatt: Lens Flare (Reflexe an den Linsengruppen, als Kopien am Bildmittelpunkt gespiegelt), Streaks (Weichzeichnen nur in eine Richtung, ergibt die Sterne an Straßenlaternen) und Glare aus der Blendenform.
- Die Nachtszene in den Bildern oben ist selbst erzeugt: ein paar helle Scheiben auf dunklem Grund, ohne jeden Schein. Alles, was darum leuchtet, kommt aus diesen drei Schritten.