Skip to content

feat(lighting): blend multiple colored light sources #75

Description

@zoeyrose

Follow-up to the server-authoritative smooth-lighting foundation in
atrinik/atrinik#169. The historical
3D-engine request mentioned colored
torch light, but it was much broader and is not an actionable Classic issue.
Replacement work such as atrinik/server#66
explicitly leaves colored light out of scope; it neither implements nor blocks this
Classic feature.

Outcome

Let Classic light-emitting archetypes and map instances declare an RGB tint,
defaulting to neutral white. The server should resolve one authoritative colored
light field across cells, sub-layers, horizontal map boundaries, and linked depths;
the client should smoothly interpolate and apply that field to the complete scene.

Multiple lights must combine additively and deterministically. For example, equal
red and blue contributions should form a magenta overlap, while neutral ambient
light contributes equally to every channel. A later source must never replace an
earlier source's color.

This is feasible, but it is a coordinated server, protocol/libatrinik, client, and
content@1.x change rather than a client-only tint.

Current behavior and feasibility

The existing pipeline already has the expensive structural pieces:

The missing information is color: all source contributions have already been
collapsed to one scalar before the client sees them. Sending visible source objects
and rebuilding their fields client-side is not acceptable. It would duplicate the
server's 3D obstruction model, lose hidden/off-screen contributions, and risk
disclosing source positions behind fog or structural boundaries.

Boundary Current contract Required contract
Authored object glow_radius intensity/range only Dedicated validated light tint, default white
Server map Scalar ambient + scalar source accumulation Existing scalar gameplay authority plus server-resolved RGB presentation field
MAP2 One scalar byte per signaled sub-layer Existing scalar samples plus sparse, bounded RGB state under a new exact protocol version
Client renderer Scalar interpolation and grayscale multiplication Per-channel interpolation, modulation, blur, extrapolation, and cache identity

Authoring and server authority

  • Add a dedicated property such as light_color RRGGBB, with exactly six
    hexadecimal digits and ffffff as the absent/default value. glow_radius
    remains the source strength/range contract.
  • Do not overload the existing glow property. It is a separately transmitted,
    animated sprite-outline effect and is also valid on objects that are not world
    light sources.
  • Carry the new property through object initialization, clone/copy/compare,
    load/save, archetype and map-instance overrides, Python-plugin exposure, and
    dynamic light lifecycles. Applied lights must propagate the selected light's
    color to the player/monster emitter; a same-radius color change must invalidate
    the field even when glow_radius itself does not change.
  • Preserve the existing scalar map_get_darkness() result as the authority for
    object visibility, special vision, and other gameplay decisions. Color is visual
    presentation and must not make hidden gameplay state newly visible.
  • Retain enough source identity, or an exact equivalent grouping, to resolve
    differently colored emitters on the same cell. The current MapSpace.light_source
    counter combines co-located radii before selecting one mask, so attaching one
    color to that counter would be order-dependent and incorrect.
  • Accumulate attenuated contributions in bounded wide/fixed-point RGB channels,
    then normalize/tone-map and clamp once per final sample. Define the color space,
    rounding, saturation, and authored-tint normalization so results do not depend on
    source insertion order. Neutral white must reproduce the current scalar result in
    all three channels.
  • Treat ambient, floor, world, special-vision, and tli illumination as neutral
    white for the first implementation. Preserve negative/darkness sources as
    achromatic subtraction; reject or ignore non-white color on a negative source
    unless separately specified and tested.
  • Preserve the existing horizontal/vertical linked-map reach, floor boundaries,
    opaque-cell behavior, add/remove symmetry, load/unload rebuilds, and bounded depth
    from server/doc/MAP_RENDERING.md.

The authoritative content@1.x schema and its Gridarta projection need the same
validated property. Add representative maintenance-scoped examples/fixtures (for
example a warm torch, green sconce, and colored forcefields) and link the
coordinated content change; do not import replacement content into Classic.

Wire contract and client rendering

  • Advance the generated Classic gameplay protocol version at implementation time
    (currently 1074). Classic already requires exact client/server version equality,
    so mismatched peers should continue to reject cleanly; do not retain an indefinite
    dual MAP layout.
  • Preserve the existing scalar MAP2_MASK_LIGHT_LEVEL{,_MORE} payloads for
    gameplay authority and neutral scenes. Prefer a sparse color extension using a
    new MAP2_FLAG_EXT_LIGHT_RGB bit in the existing tile-tail ext_flags
    byte
    ,
    followed by a seven-bit sub-layer bitmap and ascending RGB888 triples. Define
    whether that bitmap is complete color state or a delta, including an unambiguous
    colored-to-neutral reset to (level, level, level). A complete-state bitmap may
    be zero specifically to reset a previously colored tile. Ordinary neutral samples
    should derive RGB from the scalar and incur no color-extension bytes.
  • Specify the extension's order relative to the existing animation tail
    payload
    .
    Cache scalar and RGB state independently so color-only updates are emitted, and
    so simultaneous scalar/color changes, same-luminance hue changes, and neutral
    resets cannot leave stale client state. If another bounded representation is
    chosen, document its field order, widths, default/reset form, and malformed-input
    behavior and prove equivalent output and bandwidth.
  • Update the server delta cache/producer, shared libatrinik MAP preflight, client
    transactional length checks/decoder, client per-depth MapCell cache, packet
    fixtures, and offline player-view snapshots together. Truncation or invalid data
    must fail before any map state mutates.
  • Average unknown streamed-edge samples, bilinearly interpolate/extrapolate map
    quads, and blur structure samples independently per channel. Every channel must
    participate in lighting revisions and lit-sprite cache signatures.
  • For ground, use an RGB modulation lightmap (SDL3 SDL_BLENDMODE_MOD has the
    required dstRGB = srcRGB * dstRGB behavior) or an exactly equivalent bounded
    software path. Preserve exact-black color keys, alpha, RLE, and transparent
    positive-depth scratch surfaces.
  • Multiply wall, roof, living, item, effect, and other elevated sprite pixels by
    their corresponding RGB illumination, not one scalar. Explicitly cover the
    current positive-depth ground/discrete fallback so colored light does not affect
    only the base floor and walls.
  • Keep fog/grayscale/infravision precedence, cutaways, roof-only cells, structural
    projection, smooth-lighting disable/reset behavior, and the discrete lighting
    option coherent. The discrete path may use a documented scalar projection of RGB,
    but existing neutral-white scenes must not regress.

Performance and limits

The sparse extension adds one bitmap byte plus three bytes for each explicitly
colored sub-layer only when its color state must be sent; ordinary neutral-light
updates retain their current size. A dense colored tile can still add up to 22
bytes including the extension flag and bitmap. Preserve delta-only emission,
measure initial/full, scrolling, and dynamic-light updates, and prove that the
65,534-byte game-envelope payload limit
cannot be exceeded by a valid multi-depth scene.

Packed RGB/presence samples will likely double the current two-byte per-pixel light
and structure buffers, and Classic can cache up to 13 relative-depth contexts.
Retain inactive-depth cleanup, consider lazy structure allocation, keep the bounded
lit-sprite LRU, and measure frame time, peak memory, cache hit rate, packet bytes,
incremental source updates, and full linked-map recalculation at representative and
large viewports. The existing lighting profiler should make regressions visible.

Acceptance criteria

  • With no color property, or with light_color ffffff, current smooth and
    discrete neutral-light scenes remain pixel-equivalent and all gameplay
    darkness/visibility decisions remain unchanged.
  • Isolated red, green, and blue sources tint ground, walls, roofs, living
    objects, items, effects, and linked positive-depth ground consistently.
  • Equal red + blue produces a stable magenta overlap and equal red + green a
    stable yellow overlap. Same-cell and different-cell combinations are
    order-independent, additive, and bounded rather than last-writer-wins.
  • Adding, removing, moving, applying, extinguishing, burning out, or changing
    the color of a source updates the field once; reversing the action restores
    the exact previous scalar and RGB fields without drift, underflow, or stale
    cache entries.
  • Recalculation, map reload/unload, opaque blockers, floors in both vertical
    directions, horizontal map edges, and linked depths preserve the obstruction
    and propagation behavior established by feat: add multi-level smooth lighting atrinik#169.
  • Neutral ambient/special vision and achromatic darkness sources combine with
    colored sources according to one documented, deterministic saturation and
    rounding rule.
  • Fog, unseen cells, hidden sources, cutaways, infravision, and roof-only
    structural samples disclose no additional gameplay state.
  • Color-only and scalar-plus-color deltas, including same-luminance hue changes
    and colored-to-neutral resets, update exactly the intended sub-layers. MAP
    preflight and the client reject truncated RGB samples, impossible bitmaps,
    unknown extension bits, extra bytes, invalid levels, and protocol-version
    mismatches without partial state; reconnect and map-cache clearing cannot
    retain stale color.
  • Valid dense colored scenes remain within the game-envelope payload limit, or
    are split/bounded by a documented deterministic mechanism without dropping
    authoritative lighting state.
  • Add a pixel-exact offline player-view fixture containing neutral ambient plus
    overlapping red/blue lights across ordinary ground, a wall, roof-only cell,
    ordinary object, fog boundary, and depths 0/+1/+2. Keep focused smooth and
    discrete expected hashes.
  • Record before/after packet bytes, server incremental/rebuild time, client
    lighting frame time, active-depth memory, and lit-sprite cache behavior for an
    ordinary room and a dense multi-level emissive scene. Any material regression
    is optimized or explicitly approved with a bound.
  • Protocol generation/tests, libatrinik, server, and client tests pass through
    one Classic worktree/profile, including the client coverage/player-view suite,
    server lighting/lifecycle tests, python3 tools/verify_import_history.py, and
    git diff --check; the coordinated content@1.x schema/editor validation also
    passes.

Out of scope

  • Changing the current light-mask/radial falloff shape.
  • HDR, bloom, animated flicker, dynamic shadows, or a GPU renderer migration.
  • Client-authoritative source propagation or sending source positions/objects as a
    substitute for the resolved field.
  • Colored negative/darkness sources beyond the neutral compatibility behavior.
  • Implementing the corresponding fresh MIT replacement-stack feature.

Classic implementation remains GPL-2.0-or-later. Product semantics and measurements
may inform replacement work, but source or fixtures cross that boundary only under
the repository's exact provenance rules.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Fields

Priority

None yet

Start date

None yet

Target date

None yet

Effort

None yet

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions