Skip to content

Commit f435a83

Browse files
author
Tim Willebrands
committed
Merge branch 'add-unionfind-room-collision-mode'
2 parents 9604ed5 + 123ac46 commit f435a83

23 files changed

Lines changed: 1528 additions & 2446 deletions

.cargo/config.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# The lighting engine builds a large precomputed ray table (`ALL_RAYS` in
2+
# `src/lighting.rs`) on first use. In test builds it lives on the thread
3+
# stack during construction, which overflows the default 2 MiB. Setting
4+
# `RUST_MIN_STACK` here removes the foot-gun — invoking `cargo test`
5+
# directly just works without remembering the env var.
6+
[env]
7+
RUST_MIN_STACK = "8388608"

CHANGE_LOG.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Change Log
2+
3+
All notable changes to `bresenham-lighting-engine` are documented here.
4+
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5+
6+
## [Unreleased] — 2026-05-25
7+
8+
### Added
9+
10+
- **`LightingEngine` type** ([`src/engine.rs`](src/engine.rs)). Construct one
11+
with `LightingEngine::new()` and call methods on it directly. Each engine
12+
owns its own tile map, block map, collision system, and light registry —
13+
multiple engines can coexist in one process. This unlocks two things that
14+
were previously impossible:
15+
- **Parallel tests**: each test builds its own engine, so the default
16+
`cargo test` thread pool no longer races on shared globals.
17+
- **Embedding**: Rust callers (servers, level editors, comparison
18+
harnesses) can hold more than one scene at a time. See
19+
[ADR-0007](docs/decisions/0007-extract-lighting-engine-type.md).
20+
- **`LightingEngine::render_canvas_text(light_id)`** — ASCII-matrix view of a
21+
light's canvas, suitable for stdout, panic messages, or piping to other
22+
tools.
23+
- **Scenarios module** ([`src/scenarios/`](src/scenarios/mod.rs)) — plain Rust
24+
functions like `single_light` and `object_shadow` that populate a
25+
`LightingEngine`. Shared between the exploration CLI and the regression
26+
tests so the same scene definition drives both.
27+
- **`scenario` example**`cargo run --example scenario -- --list` enumerates
28+
the available scenarios; `--name <NAME>` prints the ASCII matrix;
29+
`--output-format png --out path.png` renders a PNG.
30+
- **`CONTEXT.md`** — canonical vocabulary (Tile, Cell, Wall, Object, Room,
31+
LightingEngine, Light, Canvas, Ray). Read this before contributing.
32+
- **`.cargo/config.toml`** sets `RUST_MIN_STACK=8388608` so `cargo test`
33+
works without remembering the env var.
34+
35+
### Changed
36+
37+
- `lighting::*`, `collision::*`, and `block_map::*` free functions are now
38+
thin shims that forward to a process-wide `DEFAULT_ENGINE` singleton.
39+
**WASM/JS callers are unaffected** — every `#[wasm_bindgen]` function
40+
keeps its current name and signature, including `put`, `put_solid_color`,
41+
`put_custom_color`, `set_tile`, `set_map_data`, `set_pixel`,
42+
`set_pixel_batch`, `clear_pixel_collisions`, `get_tiles`, and
43+
`get_blockmap`.
44+
- New Rust code should prefer `LightingEngine` methods; the free functions
45+
exist for back-compat and operate on a shared global, which serialises
46+
callers under a `RwLock`.
47+
48+
### Removed
49+
50+
- `IsBlockedFn` and `reset_is_blocked_fn` (dead since the collision-system
51+
rewrite — they were never read at runtime).
52+
- `TileCollisionMap` (superseded by the unified `HybridCollisionMap` in
53+
[ADR-0006](docs/decisions/0006-unify-collision-detection.md); was no
54+
longer reachable from any code path).
55+
- `VISUAL_TESTING.md`, `tests/output_mechanisms.rs`, `tests/README.md`, the
56+
`test_output/` and `test_output.before/` directories, and
57+
`benches/collision_performance.rs`. The PNG-snapshot harness they
58+
described had silently broken when the collision modes were unified —
59+
every "obstacle" snapshot in version control was either from
60+
pre-unification code or from post-unification code with no occlusion
61+
wired up. Replaced by the scenarios CLI and `tests/scenarios.rs`.
62+
63+
### Migration notes
64+
65+
- **JS / WASM callers**: no change required. The `pkg/` artifact keeps the
66+
same exports and ABI.
67+
- **Rust callers using free functions**: still work, but each call now
68+
takes the global write lock. For tests or any code that wants
69+
independent scenes, switch to `LightingEngine::new()` and call methods
70+
on the instance directly.
71+
- **Test authors**: do **not** use `DEFAULT_ENGINE` from tests. Each test
72+
must construct its own `LightingEngine` — that is what makes parallel
73+
test execution safe.
74+
75+
## [0.2.7] and earlier
76+
77+
See `git log` for prior history.

CONTEXT.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Bresenham Lighting Engine — Context
2+
3+
A 2D lighting engine that raycasts light from point sources across a hierarchical world (coarse **tile** grid containing fine **cell** subdivisions), using Bresenham line walks for ray traversal and a unified pixel + room collision system to occlude rays.
4+
5+
## Language
6+
7+
### World structure
8+
9+
**Tile**:
10+
A coarse-grid cell — the unit of the world's main layout (`TILES_PER_ROW × TILES_PER_ROW`, currently 30×30). A tile has a type; same-type adjacent tiles form a contiguous **Room**.
11+
_Avoid_: "main-grid cell" (informal), "block" (overloaded).
12+
13+
**Cell**:
14+
A fine-grid cell — the unit lighting operates on (`CELLS_PER_ROW × CELLS_PER_ROW`, currently 180×180; `CELLS_PER_TILE = 6` per tile edge). Light rays are traced cell-by-cell.
15+
_Avoid_: "pixel" (in this codebase, "pixel" historically refers to a cell, which is misleading — see Flagged ambiguities), "subgrid cell" (informal alias OK in prose).
16+
17+
### Collision primitives
18+
19+
**Wall**:
20+
A blocked edge between two adjacent tiles of different types. Materialises in two equivalent views: (a) as `n/e/s/w_blocked` flags on the cells that sit on the tile boundary, and (b) as a partition in the `UnionFind` room graph. Walls are derived from the tile map — they are not authored directly per edge.
21+
_Avoid_: "obstacle" (ambiguous with **Object**), "edge collision".
22+
23+
**Object**:
24+
A coherent group of blocked **Cells** in the runtime-mutable collision bitmap (`PixelCollisionMap`) that represents one in-world thing — a chair, a barrel, a character. The atomic write primitive (`set_pixel(cx, cy, true)`) marks a single cell as blocked; an Object is the higher-level concept built from many such writes.
25+
_Avoid_: "obstacle" (ambiguous with **Wall**), "pixel obstacle" (confusing — see "Cell"), conflating "Object" with the atomic single-cell write.
26+
27+
**Room**:
28+
A maximal set of tiles connected by walkable adjacency (same tile type, no wall between them, and no closed **Door** on the boundary). Computed by `UnionFind` from the tile map plus the door-edge overlay. The broad-phase collision check rejects a ray when its endpoints lie in different rooms.
29+
_Avoid_: "region", "area".
30+
31+
**Door**:
32+
A passable edge between two tiles that the tile-map alone would split into different **Rooms**. Stored separately from the tile map as `door_edges: HashMap<(TileIdx, TileIdx), DoorState>` on `LightingEngine`. Consulted by both the broad-phase Room check (an open Door joins the rooms across that edge) and the narrow-phase cell-edge wall flags (an open Door clears the wall along its tile boundary). Doors are not **Wall**s and not **Object**s — they are a third collision primitive.
33+
_Avoid_: "passage", "doorway gap" (the empty-tile case is just a same-type tile boundary, no Door needed), "wall token" (a downstream JS authoring concept).
34+
35+
### Lighting
36+
37+
**LightingEngine**:
38+
An owned instance of the engine's mutable runtime state — tile map, room/wall data, object bitmap, and the registry of active Lights. Multiple instances can coexist (e.g. one per test scenario). Process-wide caches like the precomputed ray lookup table live outside any single LightingEngine.
39+
_Avoid_: "World", "Scene", "Stage", "LightingWorld".
40+
41+
**Light**:
42+
A point source with a position (in cell coords), an integer radius, and a unique id. Produces a square canvas of size `(2·radius + 1)²` of RGBA pixels.
43+
44+
**Canvas**:
45+
The output buffer for a single light — RGBA values per cell within the light's bounding square. Composited externally for multi-light scenes.
46+
47+
**Ray**:
48+
A precomputed Bresenham path from a light's centre to one of `ANGLES` directions at one of `MAX_DIST` distances. Stored in the `ALL_RAYS` lookup table.
49+
50+
## Relationships
51+
52+
- The world has exactly **one** Tile layout, which deterministically defines all **Walls** and all **Rooms**.
53+
- A **Cell** belongs to exactly one **Tile** (and via that tile, exactly one **Room**).
54+
- An **Object** occupies one **Cell** and is independent of Walls and Rooms.
55+
- A ray from a **Light** is occluded if (a) its endpoints lie in different **Rooms** (broad-phase, UnionFind), OR (b) any **Cell** on its Bresenham path contains an **Object** (narrow-phase, `PixelCollisionMap`).
56+
- Walls and Objects are authored through **different** APIs and should be tested by **different** scenarios.
57+
58+
## Example dialogue
59+
60+
> **Dev:** "If I want to test that a light is blocked by an inner wall of a room, do I add an Object or a Wall?"
61+
> **Domain expert:** "A Wall — define the tile layout so the two tiles you care about have different types. The wall appears automatically as an edge between them. Objects are for things that aren't part of the architecture, like a chair sitting in the middle of a room."
62+
63+
> **Dev:** "So `PixelCollisionMap` stores walls?"
64+
> **Domain expert:** "No — it stores Objects only. Walls live in the tile map and are read through the UnionFind room graph (broad-phase) and the cell edge-flags (narrow-phase rendering hints). The 'pixel' in the type name is historical and refers to cells, not screen pixels."
65+
66+
## Flagged ambiguities
67+
68+
- **"pixel"** in code (`PixelCollisionMap`, `set_pixel`, `get_pixel`) refers to a **Cell**, not a screen pixel. The public API name is preserved for back-compat (WASM/JS callers); treat the word as a synonym for **Cell** when reading the collision module.
69+
- **"obstacle"** has been used in tests and docs (notably `VISUAL_TESTING.md`) to mean either a **Wall** or an **Object**. These are now distinct primitives with different storage, different authoring APIs, and different failure modes — do not use "obstacle" as a canonical term.
70+
- **"grid" / "subgrid"** (informal) map to **Tile** / **Cell** (canonical). Use the canonical terms in code, comments, and ADRs.
71+
- **"hybrid collision"** (from ADR-0006) names the combination but not its components. Prefer "**Room** + **Object** collision" when describing what the system does.

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,25 @@ The core idea is simple:
2525

2626
This requires a bundler like Vite to wire up the wasm and stuff. Package it with `wasm-pack build --target web` to get a version that doesn't need bundlers.
2727

28+
The engine has two ways to block light, and they exist for different things:
29+
30+
- **`set_pixel(x, y, blocked)`** marks a single cell of the 180×180 grid as
31+
an in-world object — a chair, a barrel, a character. Use this for stuff
32+
that moves or gets placed at runtime.
33+
- **`set_tile(tx, ty, type)`** / **`set_map_data(types, size)`** define the
34+
coarse 30×30 tile layout. Boundaries between tiles of different types
35+
become walls automatically, and contiguous same-type tiles form rooms
36+
used to skip occluded rays cheaply. Use this for architecture.
37+
2838
```typescript
29-
import { memory, put, set_collision_mode } from 'bresenham-lighting-engine';
39+
import { memory, put, set_pixel, set_tile } from 'bresenham-lighting-engine';
3040

31-
// Initialize collision detection
32-
set_collision_mode(1); // 0=tile-based, 1=pixel-based
41+
// Architecture: tile (5,3) is type 1, surrounded by type 0 → walls on all
42+
// four sides of that tile.
43+
set_tile(5, 3, 1);
44+
45+
// A runtime object: mark the cell at (120, 90) as blocking.
46+
set_pixel(120, 90, 1);
3347

3448
// Create a light: id=0, radius=50, x=200, y=100
3549
const lightPtr = put(0, 50, 200, 100);
@@ -49,6 +63,23 @@ const imageData = new ImageData(pixelData, lightSize, lightSize);
4963
ctx.putImageData(imageData, 0, 0);
5064
```
5165

66+
## Visual feedback & scenarios
67+
68+
Scenarios live in [`src/scenarios/`](src/scenarios/mod.rs) as plain Rust
69+
functions taking `&mut LightingEngine`. They are shared by:
70+
71+
- **Exploration loop**`cargo run --example scenario -- --name single_light`
72+
prints an ASCII matrix of the resulting canvas to stdout. Pass
73+
`--output-format png --out path.png` to render a PNG, or `--list` to see
74+
what's defined.
75+
- **Regression loop**`cargo test --test scenarios` runs invariant-based
76+
assertions. Failures embed the ASCII matrix in the panic message so the
77+
output is self-explanatory.
78+
79+
See [`CONTEXT.md`](CONTEXT.md) for the canonical vocabulary
80+
(Tile/Cell/Wall/Object/Room/LightingEngine/Light/Canvas/Ray) and
81+
[`docs/decisions/`](docs/decisions/) for ADRs.
82+
5283
## Development
5384

5485
Install `wasm-pack`:

0 commit comments

Comments
 (0)