Visibility
One light level per face, computed server-side once and read by the renderer — and, once Phase 5 lands, by the rule that decides who can be seen at all. This page summarizes docs/initiatives/light-and-visibility-initiative.md; treat the doc as authoritative if the two ever disagree.
The one idea
Today's baked field is named darkness, but what it actually measures is how much sky a face can reach — a fact about geometry that only changes when someone edits terrain. Night is a completely different kind of thing: one global number that changes continuously. Keeping the two separate, and adding light sources as their own terms, is the whole design:
light(face, t) = sky_access(face) · sky_light(t) static field × global clock
+ placed_light(face) static field
+ carried_light(face, t) dynamic, few sources
+ innate_light(viewer, face) viewer-relative, gating only
Composed in light-space — every term adds. The renderer still works in the darkness-space its shaders already multiply by, so the wire format doesn't change: it converts once, 1 − light, at the very end.
What each term is
| Term | Kind | What it is |
|---|---|---|
sky_access(face)
|
static · baked · server | A multi-source BFS seeded at every open-roofed face (nothing above it in its own column), ramping 0→1 over 5 tiles and saturating. A face no wave reaches — a sealed room, a cave whose mouth hasn't loaded — stays fully dark. Rebuilt whenever terrain changes; sent on the wire unchanged as SheetData.darkness_q (the old name — it's really the inverse of this).
|
sky_light(t)
|
global scalar · both sides | Day is a flat 1.0, night floors at 0.04 (moonlit, not black), and dawn/evening ramp linearly between. One clock, computed identically client-side (WorldDaylight.cs) and server-side (bts_content::world_clock::sky_light), so nothing ever needs to restream as the sun moves.
|
placed_light(face)
|
static · baked · server | A second BFS, seeded at every placed light-source object's footprint, each capped at its own authored radius with a smooth cubic falloff. Multiple lights reaching one face take the brightest, never the sum. Not scaled by the sun — a brazier is as bright at midnight as at noon. |
carried_light(face, t)
|
dynamic · client-rendered | Equipped torches near a face. Genuinely dynamic, so it isn't baked; it rides a 4-slot shader array published fresh every frame, nearest sources winning a slot. An interim measure — a chunk with more than four concurrent lights in view needs a real per-chunk light list (Phase 3, unbuilt). |
innate_light(viewer, face)
|
viewer-relative | Not really summed into the field — a proximity override. Every viewer sees a small radius with nothing equipped, and an equipped light replaces it with a much larger one. Rendered as one more additive glow so moonlight reads as navigable up close, not just as a number the visibility rule checks. |
How a value gets from terrain edit to pixel
The static half is computed once and streamed; the dynamic half is republished every frame. Nothing about the sun's position is ever sent over the wire at all.
- Bake —
structure.rsreruns both BFS passes whenever a chunk rebuilds: a terrain edit, a placed light added or removed. - Wire — quantised onto
SheetDataasdarkness_q/placed_light_q, sent with the rest of a chunk at load time. - Cache — client-side
SurfaceComponentsholds both fields per face, exposed throughFaceLight.TotalLight. - Compose — every frame: cached fields × the clock's live
sky_light(t), plus whateverCutawayControlleris publishing that frame. - Shade —
Cutaway.hlsl/BtsLighting.hlslscale scene lighting by darkness and add the lantern, carried and innate terms on top.
The visibility rule
A face's light is also what decides whether an NPC, player, or ground item can be seen at all — terrain, walls and objects never hide, only shade dark. Two independent routes to visible, whichever is met first:
- Proximity — distance(viewer, target) ≤ viewerRadius → visible, regardless of light.
- Illumination — light(face, t) ≥ threshold (0.5) → visible, at any distance.
- Hysteresis — the threshold shifts ±0.05 toward whatever the viewer already believed, so a value sitting right on the line can't flicker every frame it's re-evaluated.
A light gives you away. Your own torch lights your own tile, which is exactly the face the illumination route reads — so carrying one lifts you above the threshold for everyone else. Nothing special-cased; it falls straight out of the model, which is what makes carrying a torch a real trade-off instead of a strict upgrade.
The one deliberate asymmetry: sees_in_dark (an NPC-type flag, off by default) will govern only whether a monster can notice a player in darkness — never the reverse. A player's sight of an NPC is always the same uniform rule. A predator that aggroes you in pitch dark while you can't see it back is the horror beat this buys for free; slipping past a guard without it is the stealth beat, from the same field. The flag exists; nothing reads it yet — see Phase 5 below.
Where each phase stands
| # | Phase | Status | Notes |
|---|---|---|---|
| 1 | Sky access × clock | Shipped | Ambient light composes the baked field against the shared clock instead of a flat day/night multiplier. A cave is finally dark at noon. |
| 2 | Placed lights, baked | Shipped | Standing torches and braziers bake into the field and actually light something — rendered through the same 4-slot array carried lights use, as an interim measure ahead of a real per-vertex channel. |
| 3 | Many lights | Skipped | Uncapping the 4-slot array into a real per-chunk light list. Needs a measurement — a realistic crowd of concurrent carried lights — nobody has taken yet. |
| 4 | Visibility, client-side | Shipped & kept | NPCs, players and ground items fade below the threshold. Played, felt fun, cleared its own checkpoint. Deliberately cheatable — client-only — until Phase 5. |
| 5 | Visibility, authoritative | Foundation only | Schema (sees_in_dark), the light math ported to Rust, and the visibility predicate all exist and are unit tested — but nothing yet decides what a viewer is actually sent, or rejects an attack against something unseen. Today's spawn/despawn delivery only reacts to movement; light changes continuously with the clock even when nobody moves, which that system has never had to notice.
|
| 6 | Tuning + editor pass | Not started | Threshold, innate radius, moonlight floor, ramp shape and falloff sharpness all want tuning together, in the editor, rather than as scattered constants. |
Where it lives
Server
bts-map/src/structure.rs— both BFS bakes: sky access, placed lightbts-content/src/world_clock.rs—sky_light(t), ported to Rustbts-map/src/query.rs—Map::sky_access_at/placed_light_atbts-server/src/world/visibility.rs—is_visible,distance_tiles— Phase 5, unwiredbts-content/src/schema.rs—is_light_source,light_radius,sees_in_dark
Client
Core/World/FaceLight.cs— the composition formula, engine-freeCore/World/Visibility.cs—IsVisible+ the fade state machineCore/World/WorldDaylight.cs—sky_light(t)curve, the originalWorld/Rendering/CutawayController.cs— shader globals, lantern, flickerShaders/BtsLighting.hlsl— GPU-side composition and falloff
Decisions worth knowing
- Full fog of war is the destination, not a client trick. Phase 5's whole point is that the server withholds what a viewer can't see — not cheatable by turning your own brightness up. Phase 4, today, still is: a modified client could ignore the fade entirely.
- Only living things and loot hide. Terrain, walls and placed objects always stream and always draw, shaded dark. Hiding architecture would reproduce the exact "world is missing" failure this system exists to fix.
- Losing sight fades, it doesn't pop. The client eases an entity out over a third of a second rather than snapping it away — and there's no last-known-position ghost. Nothing false is ever drawn.
- Falloff is one shared shape, authored per light. The same smoothstep curve is mirrored in C#, HLSL and Rust; an authored
light_falloff_sharpnessreshapes it per torch type rather than forking the curve itself.
Source: docs/initiatives/light-and-visibility-initiative.md