Updates post-M2

This commit is contained in:
connor committed 2026-10-15 11:30:54 -04:00
1 parent 0ef9952ae4
commit 7a3088e847
8 files changed
+363 -49

No files matched your search

+1
View File
@@ -2,3 +2,4 @@
/examples/models/nasa/
/data/
__pycache__/
/gallery/
+12 -10
View File
@@ -231,11 +231,20 @@ fn missing_kernels_say_where_they_looked() {
#[test]
fn lighting_l1_says_what_it_is_missing() {
// boxwing.toml has no epoch and no earth_month: L1 cannot pick a month.
// Without an epoch or an earth_month, L1 cannot pick a month: boxwing.toml with its
// [appearance] removed.
let scene = out_dir().join("no-month.toml");
let models = repo().join("examples/models").canonicalize().unwrap();
let text = std::fs::read_to_string(repo().join("examples/scenes/boxwing.toml"))
.unwrap()
.replace("../models", models.to_str().unwrap())
.replace("[appearance]\nearth_month = 7\n", "");
assert!(!text.contains("earth_month"));
std::fs::write(&scene, text).unwrap();
let no_month = resolved(&[
"render",
"--scene",
"examples/scenes/boxwing.toml",
scene.to_str().unwrap(),
"--lighting",
"L1",
"--preview",
@@ -246,17 +255,10 @@ fn lighting_l1_says_what_it_is_missing() {
assert!(stderr.contains("earth_month"), "{stderr}");
// With a month but no data: the error names the file and the fetch script.
let scene = out_dir().join("month.toml");
let models = repo().join("examples/models").canonicalize().unwrap();
let text = std::fs::read_to_string(repo().join("examples/scenes/boxwing.toml"))
.unwrap()
.replace("../models", models.to_str().unwrap())
+ "\n[appearance]\nearth_month = 7\n";
std::fs::write(&scene, text).unwrap();
let no_data = resolved(&[
"render",
"--scene",
scene.to_str().unwrap(),
"examples/scenes/boxwing.toml",
"--lighting",
"L1",
"--data",
+66 -27
View File
@@ -5,17 +5,23 @@
//! from low orbit with Earth's limb 2 degrees above the image center. Models are the
//! box-wing from the repository and, if `scripts/fetch-models.sh` has been run,
//! Landsat 8 (85k triangles) and ICESat-2 (752k triangles). Each runs on the GPU and
//! on lavapipe at 1024 x 1024 and 1920 x 1080.
//! on lavapipe at 1024 x 1024 and 1920 x 1080, at lighting L0; and at L1 at 1920 x 1080
//! if the Earth and Moon maps are in `data/` (`scripts/fetch-earth.sh`,
//! `scripts/fetch-moon.py`), Earth showing July.
//!
//! Per-pass GPU times are not here; `resolved render --timings N` reports them.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
use resolved::gpu::AdapterChoice;
use resolved::nalgebra::{Matrix3, Rotation3, UnitQuaternion, Vector3};
use resolved::radiometry::AU;
use resolved::{CameraConfig, LoadOptions, Mesh, Pose, Renderer, RendererOptions, Scene, Target};
use resolved::{
CameraConfig, EarthTexture, Fidelity, LightingTier, LoadOptions, Mesh, MoonTexture, Pose,
Renderer, RendererOptions, Scene, Target,
};
const EARTH_RADIUS: f64 = 6_378_137.0;
const ORBIT: f64 = 7.0e6;
@@ -24,6 +30,28 @@ fn examples() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/models")
}
/// Options for lighting L1 with the maps from `data/`, or None if they are missing.
fn l1_options() -> Option<RendererOptions> {
let data = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
let (earth, moon) = (
data.join("earth/bmng-2004-07.jpg"),
data.join("moon/wac-hapke-643.png"),
);
if !earth.is_file() || !moon.is_file() {
eprintln!("skipping lighting L1: run scripts/fetch-earth.sh and scripts/fetch-moon.py");
return None;
}
Some(RendererOptions {
fidelity: Fidelity {
lighting: LightingTier::L1,
..Default::default()
},
earth_texture: Some(Arc::new(EarthTexture::load(&earth).unwrap())),
moon_texture: Some(Arc::new(MoonTexture::load(&moon).unwrap())),
..Default::default()
})
}
/// The models to bench, skipping NASA models that have not been fetched.
fn models() -> Vec<(&'static str, Mesh)> {
let mut models = vec![(
@@ -89,36 +117,47 @@ fn camera(width: u32, height: u32) -> CameraConfig {
fn frames(c: &mut Criterion) {
let models = models();
for (device, adapter) in [("gpu", AdapterChoice::Gpu), ("cpu", AdapterChoice::Cpu)] {
let mut group = c.benchmark_group(format!("frame/{device}"));
if device == "cpu" {
group.sample_size(20);
}
for (width, height) in [(1024, 1024), (1920, 1080)] {
let options = RendererOptions {
adapter: adapter.clone(),
..Default::default()
};
let mut renderer = Renderer::new(camera(width, height), options).unwrap();
for (name, mesh) in &models {
let model = renderer.load_model(mesh).unwrap();
let (scene, observer) = limb_view(mesh, model);
let id = BenchmarkId::new(format!("{width}x{height}"), name);
group.bench_function(id, |b| {
b.iter(|| renderer.submit(&scene, &observer).unwrap().wait().unwrap())
});
if width == 1920 {
let id = BenchmarkId::new(format!("{width}x{height}/view"), name);
// L0 keeps the group names it had before L1 was benched.
let mut tiers = vec![(
"frame",
RendererOptions::default(),
vec![(1024, 1024), (1920, 1080)],
)];
if let Some(l1) = l1_options() {
tiers.push(("frame-l1", l1, vec![(1920, 1080)]));
}
for (tier, base, sizes) in &tiers {
for (device, adapter) in [("gpu", AdapterChoice::Gpu), ("cpu", AdapterChoice::Cpu)] {
let mut group = c.benchmark_group(format!("{tier}/{device}"));
if device == "cpu" {
group.sample_size(20);
}
for &(width, height) in sizes {
let options = RendererOptions {
adapter: adapter.clone(),
..base.clone()
};
let mut renderer = Renderer::new(camera(width, height), options).unwrap();
for (name, mesh) in &models {
let model = renderer.load_model(mesh).unwrap();
let (scene, observer) = limb_view(mesh, model);
let id = BenchmarkId::new(format!("{width}x{height}"), name);
group.bench_function(id, |b| {
b.iter(|| {
let view = renderer.submit(&scene, &observer).unwrap();
view.wait_view().unwrap().dn()[0]
})
b.iter(|| renderer.submit(&scene, &observer).unwrap().wait().unwrap())
});
if width == 1920 {
let id = BenchmarkId::new(format!("{width}x{height}/view"), name);
group.bench_function(id, |b| {
b.iter(|| {
let view = renderer.submit(&scene, &observer).unwrap();
view.wait_view().unwrap().dn()[0]
})
});
}
}
}
group.finish();
}
group.finish();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

+5
View File
@@ -10,6 +10,7 @@
# The Sun is placed to light those three faces and the Earth near the limb.
#
# resolved render --scene examples/scenes/boxwing.toml --preview boxwing.png
# resolved render --scene examples/scenes/boxwing.toml --lighting L1 --preview boxwing.png
sun = [111155951345, -100085268138, 2610842842]
moon = [0, 0, -3.844e8]
@@ -23,6 +24,10 @@ position = [7000026.62, -53.77, 0.0]
look_at = [7000000.0, 0.0, 0.0]
up = [1, 0, 0]
# At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh).
[appearance]
earth_month = 7
[[target]]
model = "../models/boxwing.gltf"
position = [7000000.0, 0.0, 0.0]
+19 -12
View File
@@ -30,23 +30,25 @@ step up is visible at a glance.
| Tier | Lighting | Camera | RTX 3060 | lavapipe (CPU) | Sample |
|---|---|---|---|---|---|
| L0 / C0 | Lambertian surfaces, Earth's and the Moon's limbs exact, eclipse by Earth and the Moon, no shadows on the target, no light from Earth | Pinhole, absolute radiometry to 12-bit DN, no noise or blur | 0.9–1.3 ms | 6–69 ms | [![L0/C0](docs/images/l0-c0.png)](docs/images/l0-c0.png) |
| L0 / C0 | Lambertian surfaces, Earth's and the Moon's limbs exact, eclipse by Earth and the Moon, no shadows on the target, no light from Earth | Pinhole, absolute radiometry to 12-bit DN, no noise or blur | 0.9–1.3 ms | 5–68 ms | [![L0/C0](docs/images/l0-c0.png)](docs/images/l0-c0.png) |
| L1 / C0 | Adds Earth's surface from Blue Marble and the Moon's from the LRO Camera's map, the Moon's measured phase curve, shadows on the target, Earthshine and Moonshine, eclipses seen on Earth and the Moon, the Sun's limb darkening | Pinhole, absolute radiometry to 12-bit DN, no noise or blur | 1.3–2.3 ms | 12–114 ms | [![L1/C0](docs/images/l1-c0.png)](docs/images/l1-c0.png) |
Times are one 1920 x 1080 RGB frame end to end (`submit` to `wait_view`, with
the DN image ready to read), median of criterion runs, over models from 12 to
752,000 triangles: the box-wing, Landsat 8 and ICESat-2. The GPU's own work is
0.3 to 0.6 ms of that. `wait`, which copies the 12 MB image into a new `Vec`,
takes 1.6 to 2.0 ms on the RTX 3060 and 7.7 to 82 ms on lavapipe. lavapipe ran
on the Intel Core Ultra 9 285K's 24 cores. Run to run, the RTX 3060's times
vary by about 5 %, and lavapipe's by up to 15 % between sessions. The sample is the Landsat 8 example scene
(`examples/scenes/landsat8.toml`). `cargo bench -p resolved` reproduces the
numbers, and `resolved render --timings N` shows the per-pass split.
0.7 to 0.9 ms of that at L0 and 1.1 to 1.5 ms at L1, of which 0.48 ms is the
sensor pass writing the image into memory the CPU reads. `wait`, which copies
the 12 MB image into a new `Vec`, takes 1.8 to 2.0 ms on the RTX 3060 at L0
and 2.3 to 3.1 ms at L1, and on lavapipe 7 to 77 ms and 14 to 144 ms. L1's
setup also loads the Earth and Moon maps, about 1 s, once. lavapipe ran on the
Intel Core Ultra 9 285K's 24 cores. Run to run, the RTX 3060's times vary by
about 5 %, and lavapipe's by up to 15 % between sessions. The samples are the
Landsat 8 example scene (`examples/scenes/landsat8.toml`). `cargo bench -p
resolved` reproduces the numbers (L1 when the maps are in `data/`), and
`resolved render --timings N` shows the per-pass split.
L1 adds Earth's surface, shadows on the target, Earthshine and Moonshine,
the Moon's albedo map and phase curve, the Sun's limb darkening and eclipses
seen on Earth and the Moon; its row comes with its measurements. Planned: C1
and C2 add pixel-area sampling, lens distortion, blur and detector noise; L2
adds measured materials and the atmosphere; L3 is path traced.
Planned: C1 and C2 add pixel-area sampling, lens distortion, blur and detector
noise; L2 adds measured materials and the atmosphere; L3 is path traced.
## Usage
@@ -123,6 +125,11 @@ cargo run --release -p resolved-cli -- render \
--out frame.png --preview preview.png --timings 50
```
`scripts/make-gallery.sh` renders every example scene at L0 and L1 into
`gallery/`, skipping any whose model or data is missing, and
`scripts/serve-gallery.py` serves the result as a web page on port 8000 of
every interface, each scene captioned from its file.
- `--out` writes raw DN as a 16-bit PNG, shifted up to fill 16 bits as
cameras usually store them: a 12-bit DN is the stored value divided by 16.
It is linear, so it looks dark next to the preview.
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
# Renders every example scene at lighting L0 and L1 into gallery/ (gitignored), as
# <scene>-L0.png and <scene>-L1.png previews. A scene whose model, maps or kernels are
# missing is skipped with the reason. View the result with scripts/serve-gallery.py.
#
# Needs: scripts/fetch-models.sh for the NASA models, scripts/fetch-earth.sh and
# scripts/fetch-moon.py for L1, and astrokit's kernels for scenes given by epoch.
set -e
cd "$(dirname "$0")/.."
cargo build --quiet --release -p resolved-cli
mkdir -p gallery
made=0
skipped=0
for scene in examples/scenes/*.toml; do
name=$(basename "$scene" .toml)
for tier in L0 L1; do
out="gallery/$name-$tier.png"
if error=$(./target/release/resolved render --scene "$scene" --lighting "$tier" \
--preview "$out" 2>&1); then
echo "$out"
made=$((made + 1))
else
echo "skipped $name at $tier: ${error#error: }"
rm -f "$out"
skipped=$((skipped + 1))
fi
done
done
echo "$made images in gallery/, $skipped skipped"
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Serves gallery/ (from scripts/make-gallery.sh) as a web page on every interface, for
looking at the example renders from another machine on the network.
python3 scripts/serve-gallery.py [--port 8000]
Each example scene gets a card with its L0 and L1 renders side by side, captioned
from the comment at the top of its file in examples/scenes/. Click an image to see it
full size; arrow keys step through them and Escape closes. The page is built on each
request, so rerunning make-gallery.sh and reloading shows the new images. Only the
PNG files in gallery/ are served.
Needs only the Python standard library.
"""
import argparse
import html
import socket
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote
ROOT = Path(__file__).resolve().parent.parent
GALLERY = ROOT / "gallery"
SCENES = ROOT / "examples" / "scenes"
TIERS = ["L0", "L1"]
STYLE = """
:root {
--bg: #0b0d12; --card: #141821; --line: #232938; --text: #e6e8ee;
--muted: #9aa3b5; --accent: #e8b04a;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
}
header { padding: 2.5rem 1.5rem 1rem; max-width: 1400px; margin: 0 auto; }
h1 { margin: 0; font-size: 1.9rem; font-weight: 650; letter-spacing: -0.01em; }
h1 span { color: var(--accent); }
header p { margin: 0.4rem 0 0; color: var(--muted); }
main {
max-width: 1400px; margin: 0 auto; padding: 1rem 1.5rem 3rem;
display: grid; gap: 1.5rem;
}
section {
background: var(--card); border: 1px solid var(--line); border-radius: 14px;
padding: 1.25rem 1.25rem 1.1rem;
}
h2 { margin: 0 0 0.35rem; font-size: 1.15rem; font-weight: 600; }
.caption { margin: 0 0 1rem; color: var(--muted); max-width: 75ch; }
details { margin: -0.6rem 0 1rem; color: var(--muted); font-size: 0.9rem; }
details summary { cursor: pointer; color: var(--accent); }
details p { margin: 0.5rem 0 0; max-width: 75ch; }
.pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
figure { margin: 0; }
figure img {
width: 100%; aspect-ratio: 16 / 9; object-fit: contain; display: block;
background: #000; border-radius: 8px; border: 1px solid var(--line); cursor: zoom-in;
transition: transform 0.15s ease, border-color 0.15s ease;
}
figure img:hover { border-color: var(--accent); transform: translateY(-2px); }
figcaption { margin-top: 0.4rem; font-size: 0.85rem; color: var(--muted); }
figcaption b { color: var(--text); font-weight: 600; }
.missing {
aspect-ratio: 16 / 9; display: grid; place-items: center; border-radius: 8px;
border: 1px dashed var(--line); color: var(--muted); font-size: 0.9rem;
}
#view {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.93); display: none;
align-items: center; justify-content: center; flex-direction: column; gap: 0.75rem;
cursor: zoom-out; padding: 1rem;
}
#view.open { display: flex; }
#view img { max-width: 100%; max-height: calc(100vh - 4rem); }
#view div { color: var(--muted); font-size: 0.9rem; }
"""
SCRIPT = """
const images = [...document.querySelectorAll('figure img')];
const view = document.getElementById('view');
const big = view.querySelector('img');
const label = view.querySelector('div');
let current = -1;
function show(i) {
current = (i + images.length) % images.length;
big.src = images[current].src;
label.textContent = images[current].alt;
view.classList.add('open');
}
images.forEach((img, i) => img.addEventListener('click', () => show(i)));
view.addEventListener('click', () => view.classList.remove('open'));
document.addEventListener('keydown', (e) => {
if (!view.classList.contains('open')) return;
if (e.key === 'Escape') view.classList.remove('open');
if (e.key === 'ArrowRight') show(current + 1);
if (e.key === 'ArrowLeft') show(current - 1);
});
"""
def caption(scene):
"""The paragraphs of the comment at the top of `scene`, leaving out the example
commands."""
paragraphs, lines = [], []
for line in scene.read_text().splitlines():
if not line.startswith("#"):
break
text = line[1:]
if text.strip().startswith("resolved render"):
continue
if text.strip():
lines.append(text.strip())
elif lines:
paragraphs.append(" ".join(lines))
lines = []
if lines:
paragraphs.append(" ".join(lines))
return paragraphs
def page():
names = sorted({p.stem.rsplit("-", 1)[0] for p in GALLERY.glob("*.png")})
cards = []
count = 0
for name in names:
scene = SCENES / f"{name}.toml"
paragraphs = caption(scene) if scene.is_file() else []
figures = []
for tier in TIERS:
image = GALLERY / f"{name}-{tier}.png"
if image.is_file():
count += 1
stamp = int(image.stat().st_mtime)
alt = f"{name}, lighting {tier}"
figures.append(
f'<figure><img src="/img/{name}-{tier}.png?{stamp}" '
f'alt="{html.escape(alt)}" loading="lazy">'
f"<figcaption><b>{tier}</b> lighting</figcaption></figure>"
)
else:
figures.append(
f'<figure><div class="missing">no {tier} render</div>'
f"<figcaption><b>{tier}</b> lighting</figcaption></figure>"
)
first = html.escape(paragraphs[0]) if paragraphs else ""
more = ""
if len(paragraphs) > 1:
body = "".join(f"<p>{html.escape(p)}</p>" for p in paragraphs[1:])
more = f"<details><summary>More</summary>{body}</details>"
cards.append(
f"<section><h2>{html.escape(name)}</h2>"
f'<p class="caption">{first}</p>{more}'
f'<div class="pair">{"".join(figures)}</div></section>'
)
if not cards:
cards.append(
"<section><h2>No images yet</h2><p class=\"caption\">"
"Run scripts/make-gallery.sh, then reload.</p></section>"
)
newest = max((p.stat().st_mtime for p in GALLERY.glob("*.png")), default=None)
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(newest)) if newest else "never"
return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>resolved gallery</title><style>{STYLE}</style></head>
<body><header><h1><span>resolved</span> gallery</h1>
<p>{count} renders of {len(names)} example scenes, last rendered {when}.
Click an image to see it full size.</p></header>
<main>{"".join(cards)}</main>
<div id="view"><img alt=""><div></div></div>
<script>{SCRIPT}</script></body></html>"""
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = unquote(self.path.split("?", 1)[0])
if path in ("/", "/index.html"):
self.send(200, "text/html; charset=utf-8", page().encode())
elif path.startswith("/img/"):
name = path[len("/img/"):]
# Only a PNG directly in gallery/, by its exact name.
image = GALLERY / name
if "/" in name or not name.endswith(".png") or not image.is_file():
self.send(404, "text/plain", b"not found")
else:
self.send(200, "image/png", image.read_bytes())
else:
self.send(404, "text/plain", b"not found")
def send(self, status, kind, body):
self.send_response(status)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
def lan_address():
"""This machine's address on the network: the source of a route outward. A UDP
connect sends nothing."""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
try:
s.connect(("10.255.255.255", 1))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
print(f"serving {GALLERY} at http://{lan_address()}:{args.port}/ (Ctrl+C to stop)", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()