Visibility

From BTScape Wiki
Revision as of 23:51, 26 August 2026 by Bembi (talk | contribs) (Created page with "'''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 <code>docs/initiatives/light-and-visibility-initiative.md</code>; treat the doc as authoritative if the two ever disagree. == The one idea == Today's baked field is named <code>darkness</code>, but what it actually measures is ''how much sky a face can reach'' — a fact about geometry that...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

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.

  1. Bakestructure.rs reruns both BFS passes whenever a chunk rebuilds: a terrain edit, a placed light added or removed.
  2. Wire — quantised onto SheetData as darkness_q / placed_light_q, sent with the rest of a chunk at load time.
  3. Cache — client-side SurfaceComponents holds both fields per face, exposed through FaceLight.TotalLight.
  4. Compose — every frame: cached fields × the clock's live sky_light(t), plus whatever CutawayController is publishing that frame.
  5. ShadeCutaway.hlsl / BtsLighting.hlsl scale 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 light
  • bts-content/src/world_clock.rssky_light(t), ported to Rust
  • bts-map/src/query.rsMap::sky_access_at / placed_light_at
  • bts-server/src/world/visibility.rsis_visible, distance_tiles — Phase 5, unwired
  • bts-content/src/schema.rsis_light_source, light_radius, sees_in_dark

Client

  • Core/World/FaceLight.cs — the composition formula, engine-free
  • Core/World/Visibility.csIsVisible + the fade state machine
  • Core/World/WorldDaylight.cssky_light(t) curve, the original
  • World/Rendering/CutawayController.cs — shader globals, lantern, flicker
  • Shaders/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_sharpness reshapes it per torch type rather than forking the curve itself.

Source: docs/initiatives/light-and-visibility-initiative.md