Skip to content

Commit 5496332

Browse files
author
Tim Willebrands
committed
Make lighting engine mask-only
1 parent c39c3cd commit 5496332

5 files changed

Lines changed: 129 additions & 331 deletions

File tree

CHANGE_LOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,24 @@
33
All notable changes to `bresenham-lighting-engine` are documented here.
44
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55

6+
## [Unreleased] — 2026-08-01
7+
8+
### Changed (breaking)
9+
10+
- **The engine emits transport masks only** (ADR-0010, main repo issue #110).
11+
`put` now returns a white radial-falloff mask — RGB always 255, alpha =
12+
linear attenuation (curve shaping is renderer-side) — and `put_ambient`
13+
lost its `r`/`g`/`b`
14+
parameters, returning an opaque-white room-fill mask. Colour and intensity
15+
are applied renderer-side (`tint` × mask).
16+
17+
### Removed
18+
19+
- `ColorMode` (`Solid`/`Custom`/`Rgb`), `hsv2rgb`, and every colour-carrying
20+
light API: `put_solid_color`, `put_custom_color`, `put_rgb` and their
21+
`update_or_add_light_with_*` counterparts. There is no colour path left in
22+
the engine to collapse (#72 fixed by construction).
23+
624
## [Unreleased] — 2026-05-25
725

826
### Added

examples/cold_start_bench.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! `LAYER_SIZE=30 + 2*ENGINE_BUFFER_TILES`.
66
//! 2. Bulk-set ~900 tiles with `set_tile` one at a time (the path
77
//! `onTilesChanged` walks for each `insert` entry in the Yjs delta).
8-
//! 3. Place one solid-color light and call `put_solid_color`.
8+
//! 3. Place one light and call `put`.
99
//! 4. Open/close 50 door edges, each of which triggers
1010
//! `refresh_collision_from_tiles` + `refresh_tile_uf_from_tiles`.
1111
//!
@@ -36,7 +36,7 @@ fn main() {
3636
// Phase 1: construct.
3737
let t = Instant::now();
3838
let mut engine = LightingEngine::new(CELLS_PER_TILE, tiles_per_row);
39-
println!("[1] LightingEngine::new ............ {:?}", t.elapsed());
39+
println!("[1] LightingEngine::new ............. {:?}", t.elapsed());
4040

4141
// Phase 2: simulate `onTilesChanged` walking a freshly-loaded Yjs delta
4242
// one tile at a time. Real maps have a mix of wall (0) and room (1+)
@@ -78,8 +78,8 @@ fn main() {
7878
let cx = (tiles_per_row * CELLS_PER_TILE / 2) as i16;
7979
let cy = cx;
8080
let t = Instant::now();
81-
let _ptr = engine.update_or_add_light_with_solid_color(0, 30, cx, cy, 0);
82-
println!("[3] put_solid_color (r=30, centre) .. {:?}", t.elapsed());
81+
let _ptr = engine.update_or_add_light(0, 30, cx, cy);
82+
println!("[3] put (r=30, centre) .............. {:?}", t.elapsed());
8383

8484
// Phase 4: door churn. Pick 50 adjacent tile pairs and toggle each open
8585
// then closed — the path the JS facade walks when door tokens change.
@@ -108,6 +108,6 @@ fn main() {
108108
// Phase 5: re-render the light after door churn (mirrors a frame where
109109
// the lighting system runs after a door toggle invalidated the engine).
110110
let t = Instant::now();
111-
let _ptr = engine.update_or_add_light_with_solid_color(0, 30, cx, cy, 0);
112-
println!("[5] put_solid_color after doors ..... {:?}", t.elapsed());
111+
let _ptr = engine.update_or_add_light(0, 30, cx, cy);
112+
println!("[5] put after doors ................. {:?}", t.elapsed());
113113
}

src/engine.rs

Lines changed: 56 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use once_cell::sync::Lazy;
3030
use crate::block_map::{compute_cell_details_for_tile, CellDetails};
3131
use crate::collision::HybridCollisionMap;
3232
use crate::lighting::{
33-
build_ray_table, trace_visible_cells, Ambient, Color, ColorMode, Fov, Light, RayTable,
33+
build_ray_table, trace_visible_cells, Ambient, Color, Fov, Light, RayTable,
3434
};
3535
use crate::map_grid::UnionFind;
3636

@@ -250,67 +250,43 @@ impl LightingEngine {
250250
self.collision.clear();
251251
}
252252

253-
/// Create or update a rainbow light. Returns a pointer to the rendered
254-
/// canvas (used by the WASM shim). Rust callers should prefer
255-
/// [`Self::light_canvas`] after this call.
253+
/// Create or update a light and return a pointer to its transport mask —
254+
/// a white radial-falloff canvas (alpha = attenuation, ADR-0010). Rust
255+
/// callers should prefer [`Self::light_canvas`] after this call.
256256
pub fn update_or_add_light(&mut self, id: u8, r: i16, x: i16, y: i16) -> *const Color {
257-
self.update_light_with_color_mode(id, r, x, y, None)
258-
}
259-
260-
/// Create or update a solid-color light.
261-
pub fn update_or_add_light_with_solid_color(
262-
&mut self,
263-
id: u8,
264-
r: i16,
265-
x: i16,
266-
y: i16,
267-
hue: u8,
268-
) -> *const Color {
269-
self.update_light_with_color_mode(id, r, x, y, Some(ColorMode::Solid(hue)))
270-
}
271-
272-
/// Create or update a custom-HSV light.
273-
pub fn update_or_add_light_with_custom_color(
274-
&mut self,
275-
id: u8,
276-
r: i16,
277-
x: i16,
278-
y: i16,
279-
hue: u8,
280-
saturation: u8,
281-
) -> *const Color {
282-
self.update_light_with_color_mode(
283-
id,
284-
r,
285-
x,
286-
y,
287-
Some(ColorMode::Custom { hue, saturation }),
288-
)
257+
let clamped_r = r.min(self.max_dist as i16).max(1);
258+
259+
// Disjoint borrows: `lights` mutably, `collision`+`all_rays` immutably.
260+
let collision = &self.collision;
261+
let all_rays = &self.all_rays;
262+
let max_dist = self.max_dist;
263+
let light = self
264+
.lights
265+
.entry(id)
266+
.or_insert_with(|| Light::new((x, y), clamped_r));
267+
if light.radius() != clamped_r {
268+
// Radius change resizes the canvas — rebuild.
269+
*light = Light::new((x, y), clamped_r);
270+
} else {
271+
light.set_pos((x, y));
272+
}
273+
light.update(collision, all_rays, max_dist)
289274
}
290275

291276
/// Create or update a room-bounded ambient emitter and return a pointer to
292-
/// its full-map canvas (`cells_per_row²` RGBA cells in wasm linear memory).
277+
/// its full-map mask (`cells_per_row²` RGBA cells in wasm linear memory).
293278
///
294279
/// The emitter floods the same-type [`UnionFind`] room of the tile at
295280
/// `(tile_x, tile_y)` — every cell of every tile sharing that tile's room
296-
/// is filled with `Color(r, g, b, 255)`; everything else stays transparent
297-
/// `(0, 0, 0, 0)`. Because the room is the `tile_uf` partition (which is
298-
/// door-agnostic, per ADR-0003), the fill never crosses a Door, open or
299-
/// closed. An emitter on a non-floor tile (`tile <= 0`) or out of range
300-
/// emits an empty (fully transparent) canvas.
301-
pub fn update_or_add_ambient(
302-
&mut self,
303-
id: u8,
304-
tile_x: i16,
305-
tile_y: i16,
306-
r: u8,
307-
g: u8,
308-
b: u8,
309-
) -> *const Color {
281+
/// is opaque white; everything else stays transparent `(0, 0, 0, 0)`.
282+
/// Colour is applied renderer-side (ADR-0010). Because the room is the
283+
/// `tile_uf` partition (which is door-agnostic, per ADR-0003), the fill
284+
/// never crosses a Door, open or closed. An emitter on a non-floor tile
285+
/// (`tile <= 0`) or out of range emits an empty (fully transparent) canvas.
286+
pub fn update_or_add_ambient(&mut self, id: u8, tile_x: i16, tile_y: i16) -> *const Color {
310287
let tiles_per_row = self.tiles_per_row;
311288
let cells_per_tile = self.cells_per_tile;
312289
let cells_per_row = self.cells_per_row();
313-
let color = Color(r, g, b, 255);
314290

315291
// Resolve the emitter tile's room first (needs `&mut tile_uf` for
316292
// find()), then collect every tile in that room. Done before borrowing
@@ -338,12 +314,7 @@ impl LightingEngine {
338314
.or_insert_with(|| Ambient::new(cells_per_row));
339315
ambient.clear();
340316
for &ti in &room_tiles {
341-
ambient.fill_tile(
342-
ti % tiles_per_row,
343-
ti / tiles_per_row,
344-
cells_per_tile,
345-
color,
346-
);
317+
ambient.fill_tile(ti % tiles_per_row, ti / tiles_per_row, cells_per_tile);
347318
}
348319
ambient.canvas().as_ptr()
349320
}
@@ -416,37 +387,6 @@ impl LightingEngine {
416387
self.lights.get(&id).map(|l| l.radius())
417388
}
418389

419-
fn update_light_with_color_mode(
420-
&mut self,
421-
id: u8,
422-
r: i16,
423-
x: i16,
424-
y: i16,
425-
color_mode: Option<ColorMode>,
426-
) -> *const Color {
427-
let clamped_r = r.min(self.max_dist as i16).max(1);
428-
429-
let needs_new = match self.lights.get(&id) {
430-
Some(existing) => existing.radius() != clamped_r || existing.color_mode() != &color_mode,
431-
None => true,
432-
};
433-
if needs_new {
434-
self.lights
435-
.insert(id, Light::new((x, y), clamped_r, color_mode.clone()));
436-
}
437-
438-
// Disjoint borrows: `lights` mutably, `collision`+`all_rays` immutably.
439-
let collision = &self.collision;
440-
let all_rays = &self.all_rays;
441-
let max_dist = self.max_dist;
442-
let light = self
443-
.lights
444-
.get_mut(&id)
445-
.expect("just inserted or known to exist");
446-
light.set_state((x, y), clamped_r, color_mode);
447-
light.update(collision, all_rays, max_dist)
448-
}
449-
450390
fn recompute_block_map(&mut self) {
451391
let tiles_total = self.tiles.len();
452392
for tile_index in 0..tiles_total {
@@ -765,7 +705,8 @@ impl LightingEngine {
765705
for row in 0..size {
766706
for col in 0..size {
767707
let pixel = canvas[row * size + col];
768-
let brightness = pixel.0.max(pixel.1).max(pixel.2);
708+
// Transport mask: attenuation lives in alpha (ADR-0010).
709+
let brightness = pixel.3;
769710
let idx = (brightness as usize * (ASCII_GRADIENT.len() - 1)) / 255;
770711
out.push(ASCII_GRADIENT[idx] as char);
771712
}
@@ -822,6 +763,20 @@ mod tests {
822763
);
823764
}
824765

766+
#[test]
767+
fn light_emits_white_mask_with_falloff_alpha() {
768+
// ADR-0010: transport only — white pixels, attenuation in alpha,
769+
// full-strength at the centre, dimmer toward the rim.
770+
let mut e = LightingEngine::new(6, 30);
771+
e.update_or_add_light(1, 3, 5, 5);
772+
let canvas = e.light_canvas(1).expect("light exists");
773+
let centre = canvas[3 + 3 * 7]; // centre of the radius-3 light's 7×7 canvas
774+
assert_eq!((centre.0, centre.1, centre.2, centre.3), (255, 255, 255, 255));
775+
let rim = canvas[3 + 1 * 7]; // two cells up from centre
776+
assert_eq!((rim.0, rim.1, rim.2), (255, 255, 255), "mask stays white");
777+
assert!(rim.3 > 0 && rim.3 < 255, "rim alpha attenuated, got {}", rim.3);
778+
}
779+
825780
#[test]
826781
fn independent_engines_do_not_share_lights() {
827782
let mut a = LightingEngine::new(6, 30);
@@ -1072,13 +1027,13 @@ mod tests {
10721027
e.set_tile_map(tiles);
10731028

10741029
// Emitter in the west room (tile (1,1)).
1075-
e.update_or_add_ambient(0, 1, 1, 140, 130, 120);
1030+
e.update_or_add_ambient(0, 1, 1);
10761031

1077-
// West-room cells are filled with the authored colour...
1032+
// West-room cells are opaque white (mask; colour is renderer-side)...
10781033
let cpr = e.cells_per_row();
10791034
let cpt = e.cells_per_tile();
10801035
let west = e.ambient_canvas(0).unwrap()[(1 * cpt + 1) * cpr + (1 * cpt + 1)];
1081-
assert_eq!((west.0, west.1, west.2, west.3), (140, 130, 120, 255));
1036+
assert_eq!((west.0, west.1, west.2, west.3), (255, 255, 255, 255));
10821037
assert!(ambient_cell_opaque(&e, 0, 0, 0), "same-room tile filled");
10831038

10841039
// ...east-room cells (x>=3) stay transparent.
@@ -1104,13 +1059,13 @@ mod tests {
11041059
e.set_tile_map(tiles);
11051060

11061061
// Closed door: east room dark.
1107-
e.update_or_add_ambient(0, 1, 1, 100, 100, 100);
1062+
e.update_or_add_ambient(0, 1, 1);
11081063
assert!(ambient_cell_opaque(&e, 0, 1, 1), "west room filled");
11091064
assert!(!ambient_cell_opaque(&e, 0, 3, 1), "closed door: east dark");
11101065

11111066
// Open the door between (1,1) and (2,1); re-flood. Still east dark.
11121067
e.set_door_edge(1 * 5 + 1, 1 * 5 + 2, true);
1113-
e.update_or_add_ambient(0, 1, 1, 100, 100, 100);
1068+
e.update_or_add_ambient(0, 1, 1);
11141069
assert!(ambient_cell_opaque(&e, 0, 1, 1), "west room still filled");
11151070
assert!(
11161071
!ambient_cell_opaque(&e, 0, 3, 1),
@@ -1126,7 +1081,7 @@ mod tests {
11261081
tiles[1 * 5 + 1] = 0;
11271082
e.set_tile_map(tiles);
11281083

1129-
e.update_or_add_ambient(0, 1, 1, 200, 50, 50);
1084+
e.update_or_add_ambient(0, 1, 1);
11301085
let canvas = e.ambient_canvas(0).unwrap();
11311086
assert!(
11321087
canvas.iter().all(|c| c.3 == 0),
@@ -1138,7 +1093,7 @@ mod tests {
11381093
fn ambient_out_of_range_is_empty() {
11391094
let mut e = LightingEngine::new(2, 5);
11401095
e.set_tile_map(vec![1u8; 25]);
1141-
e.update_or_add_ambient(0, -1, 99, 10, 20, 30);
1096+
e.update_or_add_ambient(0, -1, 99);
11421097
let canvas = e.ambient_canvas(0).unwrap();
11431098
assert!(canvas.iter().all(|c| c.3 == 0), "out-of-range → empty canvas");
11441099
}
@@ -1153,8 +1108,8 @@ mod tests {
11531108
}
11541109
e.set_tile_map(tiles);
11551110

1156-
e.update_or_add_ambient(0, 1, 1, 100, 0, 0); // west
1157-
e.update_or_add_ambient(1, 3, 1, 0, 0, 100); // east
1111+
e.update_or_add_ambient(0, 1, 1); // west
1112+
e.update_or_add_ambient(1, 3, 1); // east
11581113

11591114
assert!(ambient_cell_opaque(&e, 0, 1, 1) && !ambient_cell_opaque(&e, 0, 3, 1));
11601115
assert!(ambient_cell_opaque(&e, 1, 3, 1) && !ambient_cell_opaque(&e, 1, 1, 1));
@@ -1166,14 +1121,14 @@ mod tests {
11661121
// the room (and re-flooding) leaves only the emitter's half lit.
11671122
let mut e = LightingEngine::new(2, 5);
11681123
e.set_tile_map(vec![1u8; 25]);
1169-
e.update_or_add_ambient(0, 0, 1, 80, 80, 80);
1124+
e.update_or_add_ambient(0, 0, 1);
11701125
assert!(ambient_cell_opaque(&e, 0, 4, 1), "single room: far tile lit");
11711126

11721127
// Split with a wall column at x=2.
11731128
for y in 0..5 {
11741129
e.set_tile(2, y as u32, 0);
11751130
}
1176-
e.update_or_add_ambient(0, 0, 1, 80, 80, 80);
1131+
e.update_or_add_ambient(0, 0, 1);
11771132
assert!(ambient_cell_opaque(&e, 0, 0, 1), "emitter half stays lit");
11781133
assert!(!ambient_cell_opaque(&e, 0, 4, 1), "far half now dark after split");
11791134
}

0 commit comments

Comments
 (0)