Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ All notable changes to Lumenwright are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed

- On the client's day plateau (a vanilla sky, or a stated sky at a tick, whose
`visual/sky_light_factor` is at its day value) HQ mode's default emitter intensity is 0,
`DAY_EMITTER_INTENSITY`: Chunky's default scene, a daylight scene, has its emitters off.
A lantern in full daylight is drawn as its texture lit by the sun and the sky, as the
block beside it is, not as a bright point. Every other frame takes the intensity it took
before, bit for bit (`default_emitter_intensity`); a stated `--emitter-intensity` is used
as given. The default steps at the day plateau's edges
(`docs/research/daylight-emitters.md`).

### Added

- `demo-world --daylight-emitters`: a lit and an unlit furnace, a lantern, glowstone beside
sandstone and a torch on a sunlit yard.

## [0.4.0] - 2026-11-10

### Added
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ Both modes run on several native backends: **CPU** (all cores), **Metal**, **Vul

![The Treehouse Camp by day, path traced](docs/renders/treehouse-camp-day-hq.png)

*hq · `--time 4000` · 4096 spp · 8 bounces · CUDA on an NVIDIA GeForce RTX 4070 Ti SUPER · 91 s end to end*
*hq · `--time 4000` · 4096 spp · 8 bounces · Metal on an Apple M2 Pro · 449 s end to end*

| | quick (vanilla look) | hq (path traced) |
|---|---|---|
| **The whole camp, day** | ![camp, day, quick](docs/renders/treehouse-camp-day-quick.png) quick · `--time 4000` · Metal · Apple M2 Pro | ![camp, day, hq](docs/renders/treehouse-camp-day-hq.png) hq · `--time 4000` · 4096 spp · 8 bounces · CUDA · RTX 4070 Ti SUPER |
| **The whole camp, day** | ![camp, day, quick](docs/renders/treehouse-camp-day-quick.png) quick · `--time 4000` · Metal · Apple M2 Pro | ![camp, day, hq](docs/renders/treehouse-camp-day-hq.png) hq · `--time 4000` · 4096 spp · 8 bounces · Metal · Apple M2 Pro |
| **The whole camp, dusk** | ![camp, dusk, quick](docs/renders/treehouse-camp-dusk-quick.png) quick · `--time 12800` · Metal · Apple M2 Pro | ![camp, dusk, hq](docs/renders/treehouse-camp-dusk-hq.png) hq · `--time 12800` · 16384 spp · 8 bounces · CUDA · RTX 4070 Ti SUPER |
| **The Long Bridge, day** | ![bridge, day, quick](docs/renders/treehouse-bridge-day-quick.png) quick · `--time 4000` · Metal · Apple M2 Pro | ![bridge, day, hq](docs/renders/treehouse-bridge-day-hq.png) hq · `--time 4000` · 4096 spp · 8 bounces · CUDA · RTX 4070 Ti SUPER |
| **The Long Bridge, day** | ![bridge, day, quick](docs/renders/treehouse-bridge-day-quick.png) quick · `--time 4000` · Metal · Apple M2 Pro | ![bridge, day, hq](docs/renders/treehouse-bridge-day-hq.png) hq · `--time 4000` · 4096 spp · 8 bounces · Metal · Apple M2 Pro |
| **The Long Bridge, dusk** | ![bridge, dusk, quick](docs/renders/treehouse-bridge-dusk-quick.png) quick · `--time 12800` · Metal · Apple M2 Pro | ![bridge, dusk, hq](docs/renders/treehouse-bridge-dusk-hq.png) hq · `--time 12800` · 16384 spp · 8 bounces · CUDA · RTX 4070 Ti SUPER |

*The Treehouse Camp* is a small adventure map: a forest clan's houses on four giant trees,
Expand Down Expand Up @@ -123,7 +123,7 @@ The Treehouse Camp save, more commands and generated worlds: [docs/renders.md](d

* [The Treehouse Camp renders](docs/renders.md): each image's command and time, how HQ mode lights it, getting the camp, more commands.
* [Benchmark](docs/benchmark.md): Lumenwright against Chunky on one machine, the backends against each other, the cost of samples; design, tables and instruments.
* [The game's sky](docs/research/game-sky.md), [the sea at the horizon](docs/research/sea-horizon.md) and [HQ mode against Chunky](docs/research/chunky-parity.md): what HQ mode ports and how it compares.
* [The game's sky](docs/research/game-sky.md), [the sea at the horizon](docs/research/sea-horizon.md), [emitters in a clear daytime frame](docs/research/daylight-emitters.md) and [HQ mode against Chunky](docs/research/chunky-parity.md): what HQ mode ports and how it compares.
* [Command line](docs/cli.md): every `render` flag.
* [Library](docs/library.md): using the crate and the `render` API.
* [Textures](docs/textures.md): the client jar, the Unifont and resource packs.
Expand Down
5 changes: 4 additions & 1 deletion crates/lumenwright-cli/src/fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ pub const DATA_VERSION: i32 = 4671;
pub const VERSION_NAME: &str = "1.21.11";

mod emitters;
pub use emitters::{CANDLE_ROOM_VIEW, FLOOR_Y, TORCH_ROOM_VIEW, candle_room, torch_room};
pub use emitters::{
CANDLE_ROOM_VIEW, DAYLIGHT_EMITTERS_VIEW, FLOOR_Y, TORCH_ROOM_VIEW, candle_room,
daylight_emitters, torch_room,
};
mod campaign_entities;
pub use campaign_entities::{
ENTITY_CASES, ENTITY_GALLERY_VIEW, EntityCase, OCEAN_VIEW, entity_gallery, ocean,
Expand Down
56 changes: 56 additions & 0 deletions crates/lumenwright-cli/src/fixture/emitters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,59 @@ pub const CANDLE_ROOM_VIEW: [&str; 6] = [
"--fov",
"75",
];

/// Emitters beside sunlit blocks in the open: on a smooth stone yard 21 × 21 blocks, a lit
/// furnace at (−3, 65, 0) and an unlit one at (3, 65, 0), whose tops are the same texture;
/// a lantern on a cobblestone wall block at (0, 66, −3), glowstone beside sandstone at
/// (−3, 65, −4) and (−2, 65, −4), and a torch at (3, 65, −3). Noon unless rendered at a
/// stated time.
pub fn daylight_emitters() -> FixtureWorld {
let mut w = FixtureWorld::new();
for x in -10..=10 {
for z in -10..=10 {
w.set(x, FLOOR_Y, z, bs("smooth_stone"));
}
}
let furnace = |lit: &str| props("furnace", &[("facing", "south"), ("lit", lit)]);
w.set(-3, FLOOR_Y + 1, 0, furnace("true"));
w.set(3, FLOOR_Y + 1, 0, furnace("false"));
w.set(
0,
FLOOR_Y + 1,
-3,
props(
"cobblestone_wall",
&[
("up", "true"),
("north", "none"),
("south", "none"),
("east", "none"),
("west", "none"),
("waterlogged", "false"),
],
),
);
w.set(
0,
FLOOR_Y + 2,
-3,
props("lantern", &[("hanging", "false"), ("waterlogged", "false")]),
);
w.set(-3, FLOOR_Y + 1, -4, bs("glowstone"));
w.set(-2, FLOOR_Y + 1, -4, bs("sandstone"));
w.set(3, FLOOR_Y + 1, -3, bs("torch"));
w.player = Some(([0.5, 72.0, 7.5], 180.0, 40.0));
w.day_time = 6000;
w
}

/// Where the daylight emitters are seen from: south of the yard and above it, looking down
/// on both furnaces' tops.
pub const DAYLIGHT_EMITTERS_VIEW: [&str; 6] = [
"--camera",
"0.5,72,7.5",
"--look-at",
"0.5,65,-0.5",
"--fov",
"55",
];
5 changes: 3 additions & 2 deletions crates/lumenwright-cli/src/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ pub struct RenderArgs {
#[arg(long)]
pub sky_intensity: Option<f32>,
/// HQ: the emitter intensity (an emitting surface emits its texel colour squared times
/// its block's emittance times this; 0 turns emitters off). Default: each look's own,
/// mixed as the sky is: Chunky's 13, and the game's look's, authored by Lumenwright.
/// its block's emittance times this; 0 turns emitters off). Default: 0 on the client's
/// day plateau (Chunky's default scene has its emitters off); elsewhere each look's
/// own, mixed as the sky is: Chunky's 13, and the game's look's, authored by Lumenwright.
#[arg(long)]
pub emitter_intensity: Option<f32>,
/// HQ: how a diffuse surface samples the emitters near it (Chunky's strategies).
Expand Down
11 changes: 10 additions & 1 deletion crates/lumenwright-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ enum Cmd {
/// Instead of the showcase, an ocean to the edge of 17 × 17 chunks with an island.
#[arg(long, conflicts_with_all = ["lineup", "block_entities", "items", "torch_room", "candle_room", "entity_gallery"])]
ocean: bool,
/// Instead of the showcase, emitters beside sunlit blocks in an open yard.
#[arg(long, conflicts_with_all = ["lineup", "block_entities", "items", "torch_room", "candle_room", "entity_gallery", "ocean"])]
daylight_emitters: bool,
},
}

Expand Down Expand Up @@ -154,8 +157,14 @@ fn main() -> Result<()> {
candle_room,
entity_gallery,
ocean,
daylight_emitters,
} => {
let (what, world) = if ocean {
let (what, world) = if daylight_emitters {
(
"daylight emitters",
lumenwright_cli::fixture::daylight_emitters(),
)
} else if ocean {
("ocean", lumenwright_cli::fixture::ocean())
} else if entity_gallery {
(
Expand Down
146 changes: 141 additions & 5 deletions crates/lumenwright-cli/tests/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,9 @@ fn chunky_scales_are_refused_under_the_game_sky() {
/// The default sky goes by the time of day: on the client's day plateau it is Chunky's look
/// byte for byte, on the night plateau the game's, and across the evening ramp of
/// `visual/sky_light_factor` a frame one tick later differs by as little as one tick
/// within either look does.
/// within either look does, with the emitters held at one intensity. The default emitter
/// intensity steps at the day plateau's edge, from the plateau's 0 to the mixed looks'
/// (`request::default_emitter_intensity`); the step is printed.
#[test]
fn the_default_sky_follows_the_day_without_a_jump() {
let jar = require_jar!();
Expand Down Expand Up @@ -833,14 +835,23 @@ fn the_default_sky_follows_the_day_without_a_jump() {
frame("night-game", "18000", &["--sky", "game"]).into_raw()
);
// Across the edges of the ramp (11270 and 13140), one tick moves a frame no more than
// one tick does inside a look.
let inside = mean(&frame("a", "11269", &[]), &frame("b", "11270", &[]));
let (e0, e1) = (frame("c", "11270", &[]), frame("d", "11271", &[]));
// one tick does inside a look. The emitters are held at Chunky's intensity across the
// day plateau's edge, where the default intensity steps.
let held: &[&str] = &["--emitter-intensity", "13"];
let inside = mean(&frame("a", "11269", held), &frame("b", "11270", held));
let (e0, e1) = (frame("c", "11270", held), frame("d", "11271", held));
let (n0, n1) = (frame("e", "13139", &[]), frame("f", "13140", &[]));
let (day_edge, night_edge) = (mean(&e0, &e1), mean(&n0, &n1));
let (d0, d1) = (frame("g", "11270", &[]), frame("h", "11271", &[]));
assert_eq!(
d0.as_raw(),
frame("g-off", "11270", &["--emitter-intensity", "0"]).as_raw()
);
let emitter_step = mean(&d0, &d1);
eprintln!(
"sky by time: one tick moves a frame by {inside:.3}/255 inside Chunky's look, \
{day_edge:.3} at the day plateau's edge and {night_edge:.3} at the night's"
{day_edge:.3} at the day plateau's edge and {night_edge:.3} at the night's; with \
the default emitter intensity {emitter_step:.3} at the day plateau's edge"
);
assert!(day_edge <= 2.0 * inside.max(0.05), "{day_edge} vs {inside}");
assert!(
Expand Down Expand Up @@ -1555,3 +1566,128 @@ fn the_water_plane_carries_the_sea_to_the_horizon() {
"the edge stays with the water plane: {ja:.2} against {jb:.2}"
);
}

fn daylight_emitters() -> &'static Path {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let d = tmp("daylight-emitters");
fixture::daylight_emitters().write(&d).unwrap();
d
})
}

/// The mean display colour of the `(2 radius + 1)²` pixels around `at`.
fn mean_near(img: &image::RgbaImage, at: (i32, i32), radius: i32) -> [f64; 3] {
let mut sum = [0.0f64; 3];
let mut n = 0.0;
for y in at.1 - radius..=at.1 + radius {
for x in at.0 - radius..=at.0 + radius {
let p = img.get_pixel(x as u32, y as u32);
for (i, s) in sum.iter_mut().enumerate() {
*s += p.0[i] as f64;
}
n += 1.0;
}
}
sum.map(|s| s / n)
}

/// The daylight emitters' frame at a tick, HQ on the CPU, with `extra` arguments.
fn daylight_frame(name: &str, tick: &str, extra: &[&str]) -> image::RgbaImage {
let jar = client_jar().unwrap();
let dir = tmp(&format!("daylight-{name}"));
let out = dir.join("frame.png");
let mut args = vec![
"--mode",
"hq",
"--backend",
"cpu",
"--spp",
"64",
"--width",
"192",
"--height",
"108",
"--time",
tick,
];
args.extend_from_slice(&fixture::DAYLIGHT_EMITTERS_VIEW);
args.extend_from_slice(extra);
render(daylight_emitters(), &jar, &out, &args).unwrap();
load(&out)
}

/// Where a world point of the daylight emitters' frame falls (192 × 108).
fn daylight_pixel(p: [f64; 3]) -> (i32, i32) {
use lumenwright::glam::DVec3;
use lumenwright::scene::camera::Camera;
let pos = DVec3::new(0.5, 72.0, 7.5);
let (yaw, pitch) = Camera::look_at(pos, DVec3::new(0.5, 65.0, -0.5));
let cam = Camera {
pos,
yaw,
pitch,
fov_y: 55.0,
};
project(&cam, p, 192, 108).unwrap()
}

/// The most any channel's mean differs between two regions.
fn channel_gap(a: [f64; 3], b: [f64; 3]) -> f64 {
(0..3).map(|i| (a[i] - b[i]).abs()).fold(0.0, f64::max)
}

/// At noon under the default sky a lit furnace's top, an emitter, is drawn as the unlit
/// furnace's top beside it in the same sun: the same texture, within 3/255 per channel of
/// the regions' means. At the intensity the day plateau used to take (Chunky's 13) the
/// emitter's top is far brighter, so the comparison tells the two apart.
#[test]
fn a_daylight_emitter_is_drawn_as_its_sunlit_neighbour() {
let _jar = require_jar!();
let lit = daylight_pixel([-2.5, 66.0, 0.5]);
let unlit = daylight_pixel([3.5, 66.0, 0.5]);
let gap =
|img: &image::RgbaImage| channel_gap(mean_near(img, lit, 3), mean_near(img, unlit, 3));
let noon = daylight_frame("noon", "6000", &[]);
let at_13 = daylight_frame("noon-13", "6000", &["--emitter-intensity", "13"]);
let (now, then) = (gap(&noon), gap(&at_13));
eprintln!(
"daylight emitter: lit vs unlit furnace top differ by {now:.2}/255 at noon by default, \
{then:.2}/255 at emitter intensity 13; lit {:?} unlit {:?} (default)",
mean_near(&noon, lit, 3),
mean_near(&noon, unlit, 3)
);
assert!(
now <= 3.0,
"the emitter's top differs from its neighbour's by {now:.2}/255"
);
assert!(
then > 3.0 * 4.0,
"the comparison does not tell 13 apart: {then:.2}/255"
);
}

/// At midnight the default frame is the one the game look's emitter intensity draws, byte
/// for byte, and the lit furnace lights the floor in front of it: brighter than the floor
/// in front of the unlit one by more than 20/255 in some channel.
#[test]
fn a_night_emitter_still_lights_its_surroundings() {
let _jar = require_jar!();
let night = daylight_frame("midnight", "18000", &[]);
let stated = daylight_frame(
"midnight-stated",
"18000",
&[
"--emitter-intensity",
&lumenwright::request::GAME_EMITTER_INTENSITY.to_string(),
],
);
assert_eq!(night.as_raw(), stated.as_raw());
let by_lit = mean_near(&night, daylight_pixel([-2.5, 65.0, 1.5]), 3);
let by_unlit = mean_near(&night, daylight_pixel([3.5, 65.0, 1.5]), 3);
eprintln!("night emitter: floor by the lit furnace {by_lit:?}, by the unlit one {by_unlit:?}");
assert!(
(0..3).any(|i| by_lit[i] - by_unlit[i] > 20.0),
"{by_lit:?} vs {by_unlit:?}"
);
}
Loading
Loading