Sampling the light level for stealth gameplay

How visible is the player? In a stealth game that is mostly a question of how much light falls where they stand. The bake already knows: every light probe carries the light that reaches it, and HammeriteMap.light_level_at() reads them at any point - including what the lamps switched since the bake no longer give.

Hammerite measures; what counts as dark enough is your game's to decide.

1. Read it

var level: float = map.light_level_at(point)

point is in map space, where the brushes are authored. Under a HammeriteMap3D that has been moved or turned, convert first:

func level_at(map_3d: HammeriteMap3D, world_point: Vector3) -> float:
    return map_3d.map.light_level_at(map_3d.to_local(world_point))

The number is the light the nearest probes that can see the point carry, weighted by distance as the shaders weight them - so what is measured is what is drawn, and a wall between the point and a bright probe keeps that probe out of it. It counts:

  • every baked light, switchable or not;
  • each switchable light at its current strength - turn a lamp off and the reading drops at once;
  • the bounce light the bake worked out.

It reads 0.0 where no probe can see the point: in the dark, and where the bake never reached.

2. Make it mean something

The level is not normalised to anything. It is in the bake's own units, so a sunlit courtyard may read in the tens, a lamplit room a few, and a back room well under one. Measure it in a place whose brightness you know - a spot you would call fully lit - and put your curve there:

@export var fully_lit_level: float = 6.0   # light_level_at, measured under the hall's lamps

func visibility(level: float) -> float:
    return clampf(level / fully_lit_level, 0.0, 1.0)

A game wanting a finer feel shapes that further - a Curve, a few thresholds for a light gem. Retune it when the lighting is retuned: doubling every lamp's energy doubles every reading.

HammeriteLightCurve is not that curve. It shapes how surfaces look to the eye, on the surface's scale, and nothing in it touches this reading.

3. Sample the body, not a point

A player crouched with their head in a pool of lamplight is seen. Read a few points up the body and take what the game wants - the brightest, for a strict guard; the mean, for a forgiving one:

const HEIGHTS: Array[float] = [8.0, 36.0, 64.0]   # feet, waist, head, in map units

func body_level(map: HammeriteMap, at: Vector3) -> float:
    var brightest: float = 0.0
    for height: float in HEIGHTS:
        brightest = maxf(brightest, map.light_level_at(at + Vector3.UP * height))
    return brightest

Keep the points inside the room: one exactly on the floor, or inside a wall, sees no probe and reads dark.

Each reading is a probe lookup and a line-of-sight test per probe found - microseconds with the native kernel, more without. A few points a few times a second is plenty for a light gem; ask on the main thread.

4. Lights the game switches

A lamp entity of the switchable types (light_point_dynamic, light_directional_dynamic) is baked into its own slot and can be dimmed or put out while the game runs. Every switch reaches light_level_at() immediately, so a player who puts out a torch is darker to the guards that same frame:

map_3d.trigger_named("hall_torch", false)   # off; true is on, null toggles

Or through what the bake recorded of it, by entity or by light group:

var torches: HammeriteBakedLight = map.get_baked_light_for_group(&"hall_torches")
if torches:
    torches.set_modulation(0.25)

modulation is 0.0 for none of its light and 1.0 for all of it. Lights in one group share a slot and are switched together. A light that is not switchable is in the reading too, but stays as baked.

5. When there is something to read

Probes are placed and lit by the bake (Baking from a script), and saved with the map: a baked map reads correctly as soon as it is loaded. An unbaked map reads 0.0 everywhere.

A new bake or a variant switch replaces the map's probes. Anything you derived from the readings - a cached darkness map for guard AI, say - should be rebuilt on map.light_probes_baked and map.light_probes_changed.

See also

Hammerite 1.0 · built from a6dd634, 2026-10-10