Compare commits

...
2 Commits
Author SHA1 Message Date
wyj 04611e3e5a Feat: Overlap movie output and cut per-frame fast-mode work
- Gather the union of every movie frame's all-sky tiles once and read them in bounded batches, replacing the per-frame prefetch scan and log.
- Fuse the fast supersampled-buffer clear into the FFTW pack pass so a resolved frame starts clean with no serial memset.
- Split parallel HDR->RGB8 tone mapping from PNG encoding.
- Add a bounded single-producer/single-writer movie output queue used by both observer movies and multi-frame imported lens maps, with writer timing and error propagation.
- Add --png-compression-level and --movie-output-workers shared|reserve-one.
- Add --fast-fftw-plan estimate|measure|wisdom|wisdom-update with strict wisdom identity sidecars.
- Add staged movie timing, regression tests, and docs.
2026-10-03 19:32:35 -04:00
wyj a510fec23e Doc: Keep git to long-term records and ignore local scratch
Ignore /local for short-term benchmark evidence, one-off A/B raw logs, and ad-hoc probes, and record the repository hygiene rule in AGENTS.md.
2026-10-03 19:32:27 -04:00
22 changed files with 2291 additions and 212 deletions

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
/build
/output
/local
/scripts/__pycache__
assets/2mass/processed/all_sky/*.csv
assets/2mass/processed/all_sky/.done/
+8
View File
@@ -76,6 +76,14 @@ catalog 内部数据保留 `(direction, temperature, amplitude)`,而非 RGB。
性能 benchmark 记录必须保留完整、可复制的命令及原始终端输出,不能只记录汇总耗时或吞吐量;输出中的 build/cache、输入加载、工作线程、处理数量与 fallback 等统计是后续正确归因性能变化的证据。
## 仓库卫生与短期产物
只有对本项目有长期记录价值、且值得进入 public repo 的测试与 benchmark 才纳入 git。
- 短期性能对照、一次性 A/B 原始日志、临时测量脚本、ad-hoc probe 与中间数据放到仓库根目录的 `local/`;该目录已被 `.gitignore` 排除,不进入版本库。
- 追踪文件必须可复现:命令把输出、原始日志写到 `/tmp` 或 `local/` 是正常的,这些仓库外/被忽略的数据无需跟踪;但复现所需的输入、脚本、fixture 必须一并纳入 git(放在 `scripts/` 或 `benchmarks/<name>/`),不得依赖未跟踪的脚本或数据。
- `benchmarks/` 只保留自包含、可复现的长期记录;`tests/` 只保留针对生产代码的回归测试,不保留为单次实验服务的一次性探针。
## 修改原则
- 改动应保持 backend、observer、geodesic、movie mesh 与 optics 的职责分离。
+8 -1
View File
@@ -117,6 +117,7 @@ CAMERA_TEST_TARGETS := $(TEST_OUT_DIR)/test_observer_minkowski $(TEST_OUT_DIR)/t
FAST_PSF_FFTW_TEST_TARGET := $(TEST_OUT_DIR)/test_fast_psf_fftw
FAST_PSF_FFTW_BENCH_TARGET := $(TEST_OUT_DIR)/benchmark_fast_psf_fftw
TONE_MAP_TEST_TARGET := $(TEST_OUT_DIR)/test_tone_map
MOVIE_OUTPUT_TEST_TARGET := $(TEST_OUT_DIR)/test_movie_output
SENSOR_BLOOM_TEST_TARGET := $(TEST_OUT_DIR)/test_sensor_bloom
SENSOR_BLOOM_BENCH_TARGET := $(TEST_OUT_DIR)/benchmark_sensor_bloom
@@ -232,6 +233,11 @@ $(FAST_PSF_FFTW_TEST_TARGET): tests/test_fast_psf_fftw.c $(CORE_MINKOWSKI_SOURCE
$(TONE_MAP_TEST_TARGET): tests/test_tone_map.c src/optics.c src/optics.h $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/test_tone_map.c src/optics.c $(CPU_FFTW_SOURCES) $(LDLIBS) -o $@
# The movie-output queue links production optics + fast_psf_fftw only, so it
# needs neither a catalog nor ray tracing.
$(MOVIE_OUTPUT_TEST_TARGET): tests/test_movie_output.c src/movie_output.c src/movie_output.h src/optics.c src/optics.h $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/test_movie_output.c src/movie_output.c src/optics.c $(CPU_FFTW_SOURCES) $(LDLIBS) -o $@
$(FAST_PSF_FFTW_BENCH_TARGET): tests/benchmark_fast_psf_fftw.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR)
$(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
@@ -253,7 +259,7 @@ FAST_PSF_FFTW_TEST_DEP :=
FAST_PSF_FFTW_TEST_RUN :=
endif
test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(FRAME_TEST_TARGET) $(SCHWARZSCHILD_TEST_TARGET) $(ALCUBIERRE_TEST_TARGET) $(OBSERVER_TRACK_TEST_TARGET) $(CATALOG_PREFETCH_TEST_TARGET) $(FAST_PSF_FFTW_TEST_DEP) $(TONE_MAP_TEST_TARGET) $(SENSOR_BLOOM_TEST_TARGET)
test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(FRAME_TEST_TARGET) $(SCHWARZSCHILD_TEST_TARGET) $(ALCUBIERRE_TEST_TARGET) $(OBSERVER_TRACK_TEST_TARGET) $(CATALOG_PREFETCH_TEST_TARGET) $(FAST_PSF_FFTW_TEST_DEP) $(TONE_MAP_TEST_TARGET) $(MOVIE_OUTPUT_TEST_TARGET) $(SENSOR_BLOOM_TEST_TARGET)
$(TEST_OUT_DIR)/test_observer_minkowski
$(TEST_OUT_DIR)/test_observer_schwarzschild
$(TEST_TARGET)
@@ -264,6 +270,7 @@ test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(FRAME_TEST_TARGET) $(SCHWARZSCHILD
$(CATALOG_PREFETCH_TEST_TARGET)
$(FAST_PSF_FFTW_TEST_RUN)
$(TONE_MAP_TEST_TARGET)
$(MOVIE_OUTPUT_TEST_TARGET)
$(SENSOR_BLOOM_TEST_TARGET)
python3 tests/test_camera_cli.py $(BUILD_DIR) $(TEST_OUT_DIR)
+9
View File
@@ -14,6 +14,15 @@ The default CPU build needs:
- FFTW 3 built with OpenMP, discovered through `pkg-config fftw3_omp`
(`sci-libs/fftw[openmp]` on Gentoo). This is required for the CPU fast-mode
fast convolution; HIP and dummy PSF builds do not link FFTW.
- POSIX threads, used by the bounded movie output writer. It is provided by the
OpenMP runtime on the supported toolchains, so no extra package is needed.
Optional FFTW wisdom can be generated once and reused across runs with
`--fast-fftw-plan wisdom-update --fast-fftw-wisdom FILE` followed by
`--fast-fftw-plan wisdom --fast-fftw-wisdom FILE`. The matching `FILE.meta`
sidecar records the transform size, supersample, kernel radius, worker count,
FFTW version, and precision; a mismatch is reported and the run fails instead of
silently replanning.
Optional dependencies are CFITSIO for HDR/FITS output, and HIP/ROCm with
`hipcc` and a compatible GPU for HIP PSF accumulation.
+19
View File
@@ -858,6 +858,25 @@ C2R,以及在 `(R,R)` 处的裁剪、`1/(Pwidth·Pheight)` 与 `1/N²` 归一
`+=` 到 HDR。旧的嵌套空间循环作为测试/基准参考保留,不是运行时 fallback。
依赖与 plan 模式取舍见 `build.md` 与
[`benchmarks/fast_mode_fftw_2026-09-25.md`](benchmarks/fast_mode_fftw_2026-09-25.md)。
plan 策略可用 `--fast-fftw-plan estimate|measure|wisdom|wisdom-update` 显式选择;
`wisdom` 用 `FFTW_WISDOM_ONLY` 并要求 `FILE` 旁的同源 `.meta`(FFTW 版本、
double 精度、supersample、FFT 尺寸、核半径、worker 数)全部匹配,不匹配时明确
失败而不静默重 plan;`wisdom-update` 先 measure,再把 wisdom 与 meta 各自写入
唯一临时文件并分别原子 rename(meta 最后提交),二者配对不具事务性;任一写入、
导出或 rename 失败都会使初始化失败。
movie catalog 预取提升到 movie 级:所有 frame mesh 完成 refinement 后,把所有
可用 source triangle 涉及的 tile 合并为一个固定大小 `CatalogTileSet`,按批
(默认每批 `512` 个 tile)并行读取、每批末串行提交一次;逐帧 splat 只读已
immutable 的 cache,不再逐帧做 tile 扫描、prefetch OpenMP 区域或打印 prefetch
日志。单帧仍保留 frame 级预取;内存 catalog 完全不做 tile 扫描;imported
multi-frame map 与 observer movie 走同一 union 路径。
movie PNG 编码/写盘由一个单 producer、单 writer 的有界队列(默认容量 2)承担,
与下一帧渲染重叠。producer 在 enqueue 前完成 sensor bloom、clean RGB8 与可选
mesh overlay RGB8 转换,job 只持有 8-bit buffer,HDR 在 submit 后即可释放。
writer 的首个错误持久保存,使后续 submit 立即失败;`finish()` drain 已接受 job
后 join writer,所有退出路径都必须 join,绝不为求重叠而提前打印 `Rendered ... ok`。
fast mode 的单星精度由 deposit 模式与 `N` 决定:`nearest` 的格点间距是
每轴 `1/N` 个输出像素,单帧瞬时舍入误差至多是 `1/(2N)`;在
+117 -48
View File
@@ -380,6 +380,23 @@ int catalog_mark_source_triangle_tiles(
&context);
}
void catalog_tile_set_clear(CatalogTileSet *set)
{
if (set != NULL)
memset(set, 0, sizeof *set);
}
size_t catalog_tile_set_count(const CatalogTileSet *set)
{
size_t count = 0;
if (set == NULL)
return 0;
for (size_t tile_id = 0; tile_id < CATALOG_ALL_SKY_TILE_COUNT; ++tile_id)
if (set->requested[tile_id])
++count;
return count;
}
typedef struct {
size_t tile_id;
Star *stars;
@@ -387,15 +404,79 @@ typedef struct {
int loaded;
} CatalogPendingTile;
int catalog_prefetch_marked_tiles(
StarCatalog *catalog,
const unsigned char requested[CATALOG_ALL_SKY_TILE_COUNT],
int worker_count, CatalogPrefetchStats *stats)
/* Reads and commits up to `batch_tiles` unseen tiles from `ids` at a time.
* Every batch allocates only its own bounded pending array, so an all-sky
* union cannot stage an unbounded temporary copy of the star data. */
static int prefetch_tile_ids(StarCatalog *catalog, const size_t *ids,
size_t id_count, int worker_count,
size_t batch_tiles, CatalogPrefetchStats *stats)
{
CatalogPendingTile *pending = NULL;
size_t pending_count = 0;
double load_start;
int result = -1;
if (batch_tiles == 0)
batch_tiles = CATALOG_PREFETCH_DEFAULT_BATCH_TILES;
if (batch_tiles > id_count)
batch_tiles = id_count;
for (size_t base = 0; base < id_count; base += batch_tiles) {
const size_t chunk = id_count - base < batch_tiles
? id_count - base
: batch_tiles;
CatalogPendingTile *pending = calloc(chunk, sizeof *pending);
if (pending == NULL)
return -1;
size_t pending_count = 0;
for (size_t i = 0; i < chunk; ++i) {
const size_t tile_id = ids[base + i];
if (catalog->tiles[tile_id].state != 0)
continue;
pending[pending_count++].tile_id = tile_id;
}
if (pending_count > 0) {
int batch_workers = worker_count;
if (batch_workers > (int)pending_count)
batch_workers = (int)pending_count;
const double load_start = omp_get_wtime();
#pragma omp parallel for num_threads(batch_workers) schedule(static)
for (size_t i = 0; i < pending_count; ++i) {
const int ra_index =
(int)(pending[i].tile_id % CATALOG_ALL_SKY_RA_TILES);
const int dec_index =
(int)(pending[i].tile_id / CATALOG_ALL_SKY_RA_TILES);
pending[i].loaded = !load_tile_file(
catalog, ra_index, dec_index, &pending[i].stars,
&pending[i].count);
}
if (stats != NULL)
stats->load_seconds += omp_get_wtime() - load_start;
/* Only this serial commit mutates the shared catalog cache. */
for (size_t i = 0; i < pending_count; ++i) {
CatalogTile *tile = &catalog->tiles[pending[i].tile_id];
if (pending[i].loaded) {
tile->stars = pending[i].stars;
tile->count = pending[i].count;
tile->state = 1;
catalog->count += tile->count;
if (stats != NULL) {
++stats->newly_loaded_tiles;
stats->newly_loaded_stars += tile->count;
}
} else {
tile->state = -1;
if (stats != NULL)
++stats->unavailable_tiles;
}
}
}
free(pending);
}
return 0;
}
/* Collects the requested-but-unseen tile ids, then reads them in batches. */
static int prefetch_bitmap(StarCatalog *catalog,
const unsigned char requested[CATALOG_ALL_SKY_TILE_COUNT],
int worker_count, size_t batch_tiles,
CatalogPrefetchStats *stats)
{
size_t unseen = 0;
if (stats != NULL)
*stats = (CatalogPrefetchStats){0};
if (catalog == NULL || requested == NULL || worker_count <= 0)
@@ -408,54 +489,42 @@ int catalog_prefetch_marked_tiles(
if (stats != NULL)
++stats->requested_tiles;
if (catalog->tiles[tile_id].state == 0)
++pending_count;
++unseen;
}
if (pending_count == 0)
if (unseen == 0)
return 0;
pending = calloc(pending_count, sizeof *pending);
if (pending == NULL)
size_t *ids = malloc(unseen * sizeof *ids);
if (ids == NULL)
return -1;
size_t pending_index = 0;
size_t index = 0;
for (size_t tile_id = 0; tile_id < CATALOG_ALL_SKY_TILE_COUNT; ++tile_id)
if (requested[tile_id] && catalog->tiles[tile_id].state == 0)
pending[pending_index++].tile_id = tile_id;
if (worker_count > (int)pending_count)
worker_count = (int)pending_count;
load_start = omp_get_wtime();
#pragma omp parallel for num_threads(worker_count) schedule(static)
for (size_t i = 0; i < pending_count; ++i) {
const int ra_index =
(int)(pending[i].tile_id % CATALOG_ALL_SKY_RA_TILES);
const int dec_index =
(int)(pending[i].tile_id / CATALOG_ALL_SKY_RA_TILES);
pending[i].loaded = !load_tile_file(
catalog, ra_index, dec_index, &pending[i].stars, &pending[i].count);
}
if (stats != NULL)
stats->load_seconds = omp_get_wtime() - load_start;
/* Only this serial commit mutates the shared catalog cache. */
for (size_t i = 0; i < pending_count; ++i) {
CatalogTile *tile = &catalog->tiles[pending[i].tile_id];
if (pending[i].loaded) {
tile->stars = pending[i].stars;
tile->count = pending[i].count;
tile->state = 1;
catalog->count += tile->count;
if (stats != NULL) {
++stats->newly_loaded_tiles;
stats->newly_loaded_stars += tile->count;
}
} else {
tile->state = -1;
if (stats != NULL)
++stats->unavailable_tiles;
}
}
result = 0;
free(pending);
ids[index++] = tile_id;
const int result =
prefetch_tile_ids(catalog, ids, unseen, worker_count, batch_tiles, stats);
free(ids);
return result;
}
int catalog_prefetch_marked_tiles(
StarCatalog *catalog,
const unsigned char requested[CATALOG_ALL_SKY_TILE_COUNT],
int worker_count, CatalogPrefetchStats *stats)
{
return prefetch_bitmap(catalog, requested, worker_count,
CATALOG_ALL_SKY_TILE_COUNT, stats);
}
int catalog_prefetch_tile_set(StarCatalog *catalog, const CatalogTileSet *set,
int worker_count, size_t batch_tiles,
CatalogPrefetchStats *stats)
{
if (set == NULL)
return -1;
return prefetch_bitmap(catalog, set->requested, worker_count, batch_tiles,
stats);
}
int catalog_visit_source_triangle(StarCatalog *catalog,
const double direction[3][3],
int load_missing, CatalogTileVisitor visitor,
+28
View File
@@ -38,6 +38,16 @@ typedef struct {
typedef int (*CatalogTileVisitor)(const Star *stars, size_t count,
int fully_contained, void *context);
/* Caller-owned, fixed-size set of one-degree all-sky tiles. A movie gathers
* the union of every frame's requested tiles into one of these, prefetches the
* union once, and then reuses an immutable cache for all frames. */
typedef struct {
unsigned char requested[CATALOG_ALL_SKY_TILE_COUNT];
} CatalogTileSet;
void catalog_tile_set_clear(CatalogTileSet *set);
size_t catalog_tile_set_count(const CatalogTileSet *set);
typedef struct {
size_t requested_tiles;
size_t newly_loaded_tiles;
@@ -69,6 +79,24 @@ int catalog_prefetch_marked_tiles(
StarCatalog *catalog,
const unsigned char requested[CATALOG_ALL_SKY_TILE_COUNT],
int worker_count, CatalogPrefetchStats *stats);
/* Default upper bound on the number of tiles read per parallel batch. The
* bound keeps the temporary star-vector staging area finite for an all-sky
* union that may legitimately request tens of thousands of tiles. */
enum { CATALOG_PREFETCH_DEFAULT_BATCH_TILES = 512 };
/* Batched equivalent of catalog_prefetch_marked_tiles() over a tile set. Each
* batch reads at most batch_tiles unseen tiles with worker_count OpenMP workers
* and commits them to the cache serially at the batch boundary; already loaded
* or unavailable tiles are never re-read. A batch_tiles value <= 0 selects
* CATALOG_PREFETCH_DEFAULT_BATCH_TILES. Returns 0 on success and -1 on a real
* allocation/internal failure; a missing tile file is still recorded as
* unavailable and does not fail the call. */
int catalog_prefetch_tile_set(
StarCatalog *catalog,
const CatalogTileSet *set,
int worker_count,
size_t batch_tiles,
CatalogPrefetchStats *stats);
/* Visit only 1-degree all-sky tiles that can intersect this source triangle.
* Call with load_missing=1 before parallel rendering, then 0 inside workers. */
int catalog_visit_source_triangle(StarCatalog *catalog,
+294 -13
View File
@@ -1,12 +1,14 @@
#include "fast_psf_fftw.h"
#include <fftw3.h>
#include <errno.h>
#include <limits.h>
#include <math.h>
#include <omp.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* Process-global FFTW threading lifecycle. Plans are created only from the
* serial control thread, and global cleanup happens only after the last live
@@ -14,16 +16,73 @@
* process-wide planner state. */
static int g_threading_initialized = 0;
static int g_live_states = 0;
static int g_plan_measure = 0;
static FastPsfFftwPlanMode g_plan_mode = FAST_PSF_FFTW_PLAN_ESTIMATE;
static const char *g_wisdom_path = NULL;
static int g_wisdom_imported = 0;
static int g_wisdom_available = 0;
void fast_psf_fftw_set_plan_mode(int measure)
{
g_plan_measure = measure ? 1 : 0;
g_plan_mode = measure ? FAST_PSF_FFTW_PLAN_MEASURE
: FAST_PSF_FFTW_PLAN_ESTIMATE;
g_wisdom_path = NULL;
g_wisdom_imported = 0;
g_wisdom_available = 0;
}
int fast_psf_fftw_configure(FastPsfFftwPlanMode mode, const char *wisdom_path)
{
if ((mode == FAST_PSF_FFTW_PLAN_WISDOM ||
mode == FAST_PSF_FFTW_PLAN_WISDOM_UPDATE) &&
(wisdom_path == NULL || wisdom_path[0] == '\0')) {
fprintf(stderr,
"Fast FFTW: --fast-fftw-plan wisdom modes require "
"--fast-fftw-wisdom FILE.\n");
return -1;
}
g_plan_mode = mode;
g_wisdom_path = wisdom_path;
g_wisdom_imported = 0;
g_wisdom_available = 0;
return 0;
}
static const char *plan_mode_name(void)
{
switch (g_plan_mode) {
case FAST_PSF_FFTW_PLAN_MEASURE:
return "measure";
case FAST_PSF_FFTW_PLAN_WISDOM:
return "wisdom";
case FAST_PSF_FFTW_PLAN_WISDOM_UPDATE:
return "wisdom-update";
case FAST_PSF_FFTW_PLAN_ESTIMATE:
default:
return "estimate";
}
}
static unsigned plan_flags(void)
{
return g_plan_measure ? FFTW_MEASURE : FFTW_ESTIMATE;
switch (g_plan_mode) {
case FAST_PSF_FFTW_PLAN_MEASURE:
case FAST_PSF_FFTW_PLAN_WISDOM_UPDATE:
return FFTW_MEASURE;
case FAST_PSF_FFTW_PLAN_WISDOM:
return FFTW_WISDOM_ONLY;
case FAST_PSF_FFTW_PLAN_ESTIMATE:
default:
return FFTW_ESTIMATE;
}
}
/* Wisdom identity sidecar. FFTW's own wisdom already encodes the transform
* size and precision, but not this build's supersampling or worker count, so a
* validated <wisdom>.meta accompanies every exported file. */
static int wisdom_meta_path(char *out, size_t cap, const char *wisdom_path)
{
const int written = snprintf(out, cap, "%s.meta", wisdom_path);
return written < 0 || (size_t)written >= cap ? -1 : 0;
}
struct FastPsfFftwState {
@@ -48,6 +107,178 @@ struct FastPsfFftwState {
fftw_plan inverse_plan;
};
/* Required identity fields. Every one must appear exactly once and match; a
* missing or bad field is a miss, not an implicit default. */
enum {
WISDOM_META_VERSION = 1u << 0,
WISDOM_META_PRECISION = 1u << 1,
WISDOM_META_SS_WIDTH = 1u << 2,
WISDOM_META_SS_HEIGHT = 1u << 3,
WISDOM_META_FFT_WIDTH = 1u << 4,
WISDOM_META_FFT_HEIGHT = 1u << 5,
WISDOM_META_SUPERSAMPLE = 1u << 6,
WISDOM_META_RADIUS = 1u << 7,
WISDOM_META_WORKERS = 1u << 8,
WISDOM_META_ALL = (1u << 9) - 1
};
static int strict_long(const char *text, long *out)
{
char *end;
errno = 0;
const long value = strtol(text, &end, 10);
if (errno != 0 || end == text || *end != '\0')
return -1;
*out = value;
return 0;
}
static int wisdom_meta_matches(const FastPsfFftwState *state)
{
if (g_wisdom_path == NULL)
return 0;
char path[PATH_MAX];
if (wisdom_meta_path(path, sizeof path, g_wisdom_path))
return 0;
FILE *file = fopen(path, "r");
if (file == NULL)
return 0;
char line[256];
unsigned seen = 0;
int ok = 1;
while (ok && fgets(line, sizeof line, file) != NULL) {
char key[64], value[192];
if (sscanf(line, "%63[^=]=%191s", key, value) != 2)
continue;
long expected = 0;
unsigned bit = 0;
int numeric = 0;
if (!strcmp(key, "fftw_version")) {
ok = strcmp(value, fftw_version) == 0;
bit = WISDOM_META_VERSION;
} else if (!strcmp(key, "precision")) {
ok = strcmp(value, "double") == 0;
bit = WISDOM_META_PRECISION;
} else if (!strcmp(key, "ss_width")) {
expected = state->ss_width; numeric = 1; bit = WISDOM_META_SS_WIDTH;
} else if (!strcmp(key, "ss_height")) {
expected = state->ss_height; numeric = 1; bit = WISDOM_META_SS_HEIGHT;
} else if (!strcmp(key, "fft_width")) {
expected = state->fft_width; numeric = 1; bit = WISDOM_META_FFT_WIDTH;
} else if (!strcmp(key, "fft_height")) {
expected = state->fft_height; numeric = 1; bit = WISDOM_META_FFT_HEIGHT;
} else if (!strcmp(key, "supersample")) {
expected = state->supersample; numeric = 1; bit = WISDOM_META_SUPERSAMPLE;
} else if (!strcmp(key, "kernel_radius")) {
expected = state->kernel_radius; numeric = 1; bit = WISDOM_META_RADIUS;
} else if (!strcmp(key, "workers")) {
expected = state->workers; numeric = 1; bit = WISDOM_META_WORKERS;
} else {
continue;
}
if (ok && numeric) {
long parsed = 0;
ok = strict_long(value, &parsed) == 0 && parsed == expected;
}
if (ok && (seen & bit) != 0)
ok = 0; /* duplicate field: reject rather than accept twice */
if (ok)
seen |= bit;
}
fclose(file);
return ok && (seen & WISDOM_META_ALL) == WISDOM_META_ALL;
}
static int wisdom_write_meta(const FastPsfFftwState *state, const char *path)
{
FILE *file = fopen(path, "w");
if (file == NULL)
return -1;
const int written = fprintf(
file,
"fftw_version=%s\nprecision=double\nss_width=%d\nss_height=%d\n"
"fft_width=%d\nfft_height=%d\nsupersample=%d\nkernel_radius=%d\n"
"workers=%d\n",
fftw_version, state->ss_width, state->ss_height, state->fft_width,
state->fft_height, state->supersample, state->kernel_radius,
state->workers);
const int closed = fclose(file);
return written < 0 || closed != 0 ? -1 : 0;
}
static int wisdom_import_and_verify(const FastPsfFftwState *state)
{
if (!g_wisdom_imported) {
g_wisdom_imported = 1;
if (g_wisdom_path != NULL &&
fftw_import_wisdom_from_filename(g_wisdom_path) != 0)
g_wisdom_available = 1;
}
if (!g_wisdom_available) {
fprintf(stderr,
"Fast FFTW: --fast-fftw-plan wisdom could not import wisdom from "
"%s.\n",
g_wisdom_path != NULL ? g_wisdom_path : "(unset)");
return 0;
}
if (!wisdom_meta_matches(state)) {
fprintf(stderr,
"Fast FFTW: wisdom in %s does not match this render "
"(size, supersample, worker count, version, or precision).\n",
g_wisdom_path);
return 0;
}
return 1;
}
/* Writes the wisdom and its identity sidecar next to the requested path.
* Each file is written to a unique temporary and renamed into place, so each
* rename is individually atomic. The wisdom file and its sidecar are two
* separate files and are not transactionally updated: the sidecar is committed
* last, so a loader that sees a wisdom without a matching sidecar treats it as
* a miss. Any failure returns -1 and makes wisdom-update initialization fail. */
static int wisdom_export(const FastPsfFftwState *state)
{
if (g_plan_mode != FAST_PSF_FFTW_PLAN_WISDOM_UPDATE || g_wisdom_path == NULL)
return 0;
char meta_path[PATH_MAX];
char wisdom_temp[PATH_MAX];
char meta_temp[PATH_MAX];
if (wisdom_meta_path(meta_path, sizeof meta_path, g_wisdom_path))
return -1;
const long pid = (long)getpid();
if (snprintf(wisdom_temp, sizeof wisdom_temp, "%s.tmp.%ld", g_wisdom_path,
pid) < 0 ||
snprintf(meta_temp, sizeof meta_temp, "%s.tmp.%ld", meta_path, pid) < 0)
return -1;
if (wisdom_write_meta(state, meta_temp) != 0) {
fprintf(stderr, "Fast FFTW: failed to write wisdom metadata %s.\n",
meta_temp);
unlink(meta_temp);
return -1;
}
if (fftw_export_wisdom_to_filename(wisdom_temp) == 0) {
fputs("Fast FFTW: failed to export FFTW wisdom.\n", stderr);
unlink(wisdom_temp);
unlink(meta_temp);
return -1;
}
if (rename(wisdom_temp, g_wisdom_path) != 0) {
fprintf(stderr, "Fast FFTW: failed to commit wisdom to %s.\n",
g_wisdom_path);
unlink(wisdom_temp);
unlink(meta_temp);
return -1;
}
if (rename(meta_temp, meta_path) != 0) {
fprintf(stderr, "Fast FFTW: failed to commit wisdom metadata to %s.\n",
meta_path);
unlink(meta_temp);
return -1;
}
return 0;
}
static int checked_mul_size(size_t a, size_t b, size_t *out)
{
if (a != 0 && b > SIZE_MAX / a)
@@ -151,7 +382,9 @@ FastPsfFftwState *fast_psf_fftw_create(int ss_width, int ss_height,
state->supersample = supersample;
state->kernel_radius = kernel_radius;
state->workers = fft_workers;
state->plan_measure = g_plan_measure;
state->plan_measure =
g_plan_mode == FAST_PSF_FFTW_PLAN_MEASURE ||
g_plan_mode == FAST_PSF_FFTW_PLAN_WISDOM_UPDATE;
size_t real_bytes = 0, freq_bytes = 0, kernel_bytes = 0;
size_t fft_width = 0, fft_height = 0;
@@ -204,6 +437,10 @@ FastPsfFftwState *fast_psf_fftw_create(int ss_width, int ss_height,
state->registered = 1;
fftw_plan_with_nthreads(fft_workers);
if (g_plan_mode == FAST_PSF_FFTW_PLAN_WISDOM &&
!wisdom_import_and_verify(state))
goto fail;
const double plan_start = omp_get_wtime();
stage = "FFTW scratch allocation";
state->real_rgb = fftw_alloc_real(real_count);
@@ -269,6 +506,9 @@ FastPsfFftwState *fast_psf_fftw_create(int ss_width, int ss_height,
state->fft_scale = 1.0 / ((double)fft_width * (double)fft_height);
state->box_scale = 1.0 / ((double)supersample * (double)supersample);
stage = "wisdom export";
if (wisdom_export(state) != 0)
goto fail;
return state;
fail:
@@ -279,17 +519,18 @@ fail:
stage, ss_width, ss_height,
(size_t)ss_width + 2 * radius,
(size_t)ss_height + 2 * radius, fft_width, fft_height,
state->plan_measure ? "measure" : "estimate", fft_workers,
plan_mode_name(), fft_workers,
real_bytes, freq_bytes, kernel_bytes,
real_bytes + freq_bytes + kernel_bytes);
fast_psf_fftw_destroy(state);
return NULL;
}
int fast_psf_fftw_resolve(FastPsfFftwState *state,
const double *interleaved_ss_rgb,
double *interleaved_hdr_rgb,
FastPsfFftwFrameTiming *timing)
static int fast_psf_fftw_resolve_impl(FastPsfFftwState *state,
const double *interleaved_ss_rgb,
int clear_source,
double *interleaved_hdr_rgb,
FastPsfFftwFrameTiming *timing)
{
if (state == NULL || interleaved_ss_rgb == NULL ||
interleaved_hdr_rgb == NULL)
@@ -303,7 +544,15 @@ int fast_psf_fftw_resolve(FastPsfFftwState *state,
const size_t real_count = 3 * plane;
const size_t freq_count = 3 * complex_plane;
/* The pack pass writes every FFT cell and, when clear_source is set, both
* consumes and zeroes its own source triplet. Each supersampled triplet is
* touched by exactly one worker, so zeroing needs no atomic and no second
* serial memset. Clearing is deliberately unconditional in the consuming
* variant: even a later failure must not leave stale deposits behind. */
double *source_mut = (double *)(uintptr_t)interleaved_ss_rgb;
double start = omp_get_wtime();
/* The padded border is never overwritten by packing, so it must be zeroed
* every frame regardless of the consuming variant. */
#pragma omp parallel for schedule(static) num_threads(state->workers) \
if (state->workers > 1)
for (size_t i = 0; i < real_count; ++i)
@@ -315,9 +564,14 @@ int fast_psf_fftw_resolve(FastPsfFftwState *state,
const size_t source =
3 * ((size_t)y * state->ss_width + (size_t)x);
const size_t target = (size_t)y * state->fft_width + (size_t)x;
state->real_rgb[target] = interleaved_ss_rgb[source];
state->real_rgb[plane + target] = interleaved_ss_rgb[source + 1];
state->real_rgb[2 * plane + target] = interleaved_ss_rgb[source + 2];
state->real_rgb[target] = source_mut[source];
state->real_rgb[plane + target] = source_mut[source + 1];
state->real_rgb[2 * plane + target] = source_mut[source + 2];
if (clear_source) {
source_mut[source] = 0.0;
source_mut[source + 1] = 0.0;
source_mut[source + 2] = 0.0;
}
}
}
local.zero_pack_seconds = omp_get_wtime() - start;
@@ -377,6 +631,33 @@ int fast_psf_fftw_resolve(FastPsfFftwState *state,
return 0;
}
int fast_psf_fftw_resolve(FastPsfFftwState *state,
const double *interleaved_ss_rgb,
double *interleaved_hdr_rgb,
FastPsfFftwFrameTiming *timing)
{
return fast_psf_fftw_resolve_impl(state, interleaved_ss_rgb, 0,
interleaved_hdr_rgb, timing);
}
int fast_psf_fftw_resolve_and_clear(FastPsfFftwState *state,
double *interleaved_ss_rgb,
double *interleaved_hdr_rgb,
FastPsfFftwFrameTiming *timing)
{
if (state == NULL || interleaved_ss_rgb == NULL)
return -1;
const int result = fast_psf_fftw_resolve_impl(state, interleaved_ss_rgb, 1,
interleaved_hdr_rgb, timing);
if (result != 0) {
/* The pack pass may not have run, so guarantee a clean source even on the
* error path rather than letting a later frame inherit stale deposits. */
const size_t count = (size_t)state->ss_width * state->ss_height * 3;
memset(interleaved_ss_rgb, 0, count * sizeof *interleaved_ss_rgb);
}
return result;
}
void fast_psf_fftw_report(const FastPsfFftwState *state, FILE *stream)
{
if (state == NULL || stream == NULL)
@@ -387,7 +668,7 @@ void fast_psf_fftw_report(const FastPsfFftwState *state, FILE *stream)
state->ss_height + 2 * state->kernel_radius,
state->ss_width + 2 * state->kernel_radius, state->fft_height,
state->fft_width, state->workers,
state->plan_measure ? "measure" : "estimate", state->setup_seconds,
plan_mode_name(), state->setup_seconds,
state->kernel_seconds,
(double)state->scratch_bytes / (1024.0 * 1024.0));
}
+27
View File
@@ -46,6 +46,16 @@ int fast_psf_fftw_resolve(FastPsfFftwState *state,
double *interleaved_hdr_rgb,
FastPsfFftwFrameTiming *timing);
/* Same convolution, but consumes `interleaved_ss_rgb`: each source triplet is
* copied into the planar scratch and immediately zeroed by the same worker, so
* the caller gets a clean accumulator back without a separate serial memset.
* On failure the source is still zeroed so a later frame cannot inherit stale
* deposits. Returns 0 on success and -1 on invalid state. */
int fast_psf_fftw_resolve_and_clear(FastPsfFftwState *state,
double *interleaved_ss_rgb,
double *interleaved_hdr_rgb,
FastPsfFftwFrameTiming *timing);
/* Safe for NULL and partially initialized states; releases plans before the
* buffers they reference. Does not touch process-global FFTW state except on
* the final live state. */
@@ -64,4 +74,21 @@ int fast_psf_fftw_next_smooth_size(size_t min_extent, size_t *out);
* production uses the default FFTW_ESTIMATE. */
void fast_psf_fftw_set_plan_mode(int measure);
/* Plan strategy for fast-mode convolution plans.
* ESTIMATE - FFTW_ESTIMATE (current default)
* MEASURE - FFTW_MEASURE
* WISDOM - import FILE and require FFTW_WISDOM_ONLY; a miss fails
* WISDOM_UPDATE - FFTW_MEASURE, then atomically export wisdom to FILE */
typedef enum {
FAST_PSF_FFTW_PLAN_ESTIMATE = 0,
FAST_PSF_FFTW_PLAN_MEASURE = 1,
FAST_PSF_FFTW_PLAN_WISDOM = 2,
FAST_PSF_FFTW_PLAN_WISDOM_UPDATE = 3
} FastPsfFftwPlanMode;
/* Must be called before fast_psf_fftw_create(). `wisdom_path` may be NULL for
* the estimate/measure modes. Returns 0 on success and -1 on an immediately
* detectable configuration error (missing path for a wisdom mode). */
int fast_psf_fftw_configure(FastPsfFftwPlanMode mode, const char *wisdom_path);
#endif
+80 -21
View File
@@ -1361,13 +1361,9 @@ static CatalogSplatStats splat_catalog_triangles(
return stats;
}
static void prefetch_catalog_for_mesh(const FrameLensMesh *mesh,
StarCatalog *catalog,
int worker_count,
CatalogPrefetchStats *stats) {
if (catalog->kind != STAR_CATALOG_ALL_SKY)
return;
unsigned char requested[CATALOG_ALL_SKY_TILE_COUNT] = {0};
int frame_mark_catalog_tiles(const FrameLensMesh *mesh, CatalogTileSet *set) {
if (mesh == NULL || set == NULL)
return -1;
for (size_t t = 0; t < mesh->triangle_count; ++t) {
const LensVertex *vertex[3];
if (!usable_triangle(mesh, &mesh->triangles[t], vertex))
@@ -1376,9 +1372,29 @@ static void prefetch_catalog_for_mesh(const FrameLensMesh *mesh,
{vertex[0]->n_infinity[0], vertex[0]->n_infinity[1], vertex[0]->n_infinity[2]},
{vertex[1]->n_infinity[0], vertex[1]->n_infinity[1], vertex[1]->n_infinity[2]},
{vertex[2]->n_infinity[0], vertex[2]->n_infinity[1], vertex[2]->n_infinity[2]}};
(void)catalog_mark_source_triangle_tiles(direction, requested);
(void)catalog_mark_source_triangle_tiles(direction, set->requested);
}
(void)catalog_prefetch_marked_tiles(catalog, requested, worker_count, stats);
return 0;
}
static void prefetch_catalog_for_mesh(const FrameLensMesh *mesh,
StarCatalog *catalog,
int worker_count,
CatalogPrefetchStats *stats,
MovieFrameTiming *timing) {
if (catalog->kind != STAR_CATALOG_ALL_SKY)
return;
CatalogTileSet set;
catalog_tile_set_clear(&set);
const double mark_start = omp_get_wtime();
(void)frame_mark_catalog_tiles(mesh, &set);
if (timing != NULL)
timing->catalog_mark_seconds = omp_get_wtime() - mark_start;
const double load_start = omp_get_wtime();
(void)catalog_prefetch_tile_set(catalog, &set, worker_count,
CATALOG_PREFETCH_DEFAULT_BATCH_TILES, stats);
if (timing != NULL)
timing->catalog_load_seconds = omp_get_wtime() - load_start;
}
size_t frame_splat_catalog(const FrameLensMesh *mesh,
@@ -1392,10 +1408,12 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
double psf_min_y,
int limit_workers_by_memory,
int catalog_load_workers,
FrameCatalogPrefetchMode catalog_prefetch_mode,
CatalogPrefetchStats *prefetch_stats,
PsfSplatStats *psf_stats,
const FrameSplatProgress *progress,
FastPsfAccumulator *fast) {
FastPsfAccumulator *fast,
MovieFrameTiming *timing) {
if (mesh == NULL || catalog == NULL || hdr == NULL || exposure <= 0.0 ||
psf == NULL || width <= 0 || height <= 0 || catalog_load_workers <= 0 ||
isnan(max_magnification) || max_magnification <= 0.0 ||
@@ -1404,21 +1422,32 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
psf_relative_tail >= 1.0 || !isfinite(psf_min_y) || psf_min_y < 0.0)
return 0;
/* A bounded parallel read phase completes before splatting. Its serial cache
* commit leaves immutable tile data for the OpenMP splat workers. */
if (progress != NULL && progress->callback != NULL)
progress->callback(progress->context, FRAME_SPLAT_PROGRESS_PREFETCH_BEGIN,
0, mesh->triangle_count);
prefetch_catalog_for_mesh(mesh, catalog, catalog_load_workers, prefetch_stats);
if (progress != NULL && progress->callback != NULL)
progress->callback(progress->context, FRAME_SPLAT_PROGRESS_PREFETCH_END,
prefetch_stats == NULL ? 0 : prefetch_stats->requested_tiles,
prefetch_stats == NULL ? 0 : prefetch_stats->requested_tiles);
if (timing != NULL)
*timing = (MovieFrameTiming){0};
if (psf_stats != NULL)
*psf_stats = (PsfSplatStats){0};
if (catalog_prefetch_mode == FRAME_CATALOG_PREFETCH_FRAME) {
/* A bounded parallel read phase completes before splatting. Its serial
* cache commit leaves immutable tile data for the OpenMP splat workers. */
if (progress != NULL && progress->callback != NULL)
progress->callback(progress->context, FRAME_SPLAT_PROGRESS_PREFETCH_BEGIN,
0, mesh->triangle_count);
prefetch_catalog_for_mesh(mesh, catalog, catalog_load_workers,
prefetch_stats, timing);
if (progress != NULL && progress->callback != NULL)
progress->callback(progress->context, FRAME_SPLAT_PROGRESS_PREFETCH_END,
prefetch_stats == NULL ? 0 : prefetch_stats->requested_tiles,
prefetch_stats == NULL ? 0 : prefetch_stats->requested_tiles);
} else if (prefetch_stats != NULL) {
/* A movie-level union prefetch already ran: skip every per-frame tile scan,
* prefetch worker region, and "Catalog prefetch" log. */
*prefetch_stats = (CatalogPrefetchStats){0};
}
if (progress != NULL && progress->callback != NULL)
progress->callback(progress->context, FRAME_SPLAT_PROGRESS_BEGIN, 0,
mesh->triangle_count);
const double splat_body_start = omp_get_wtime();
/* Fast mode replaces the per-event PSF splat with cheap delta deposits into
* one shared supersampled buffer. A single global convolution plus an N x N
@@ -1431,7 +1460,22 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
int worker_count = omp_get_max_threads();
if (worker_count < 1)
worker_count = 1;
fast_psf_accumulator_clear(fast);
/* The FFTW consume path clears the shared buffer as it packs, so a CLEAN
* accumulator needs no per-frame serial memset. The spatial-reference
* fallback and any unexpected stale state still get an explicit clear. */
#ifdef FAST_PSF_FFTW
const int needs_clear =
!fast->fftw_enabled || fast->buffer_state != FAST_PSF_BUFFER_CLEAN;
#else
const int needs_clear = 1;
#endif
if (needs_clear) {
const double clear_start = omp_get_wtime();
fast_psf_accumulator_clear(fast);
if (timing != NULL)
timing->fast_clear_seconds = omp_get_wtime() - clear_start;
}
const double deposit_start = omp_get_wtime();
#ifdef GR_DEBUG
double max_raw_magnification = 0.0;
size_t magnification_clamped_triangles = 0;
@@ -1458,8 +1502,21 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
#endif
}
}
if (timing != NULL)
timing->catalog_splat_seconds = omp_get_wtime() - deposit_start;
if (!failed && fast_psf_accumulator_resolve(fast, hdr, worker_count))
failed = 1;
#ifdef FAST_PSF_FFTW
if (timing != NULL && fast->fftw_enabled) {
timing->fftw_zero_pack_seconds = fast->fftw_last_timing.zero_pack_seconds;
timing->fftw_forward_seconds = fast->fftw_last_timing.forward_seconds;
timing->fftw_multiply_seconds = fast->fftw_last_timing.multiply_seconds;
timing->fftw_inverse_seconds = fast->fftw_last_timing.inverse_seconds;
timing->fftw_crop_seconds =
fast->fftw_last_timing.crop_downsample_seconds;
timing->fftw_total_seconds = fast->fftw_last_timing.total_seconds;
}
#endif
copy_psf_splat_stats(psf_stats, (CatalogSplatStats){
.images = images,
.direct_fallbacks = direct_fallbacks,
@@ -1799,6 +1856,8 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
for (size_t worker = 0; worker < worker_count; ++worker)
free(private_hdr[worker]);
free(private_hdr);
if (timing != NULL)
timing->catalog_splat_seconds = omp_get_wtime() - splat_body_start;
copy_psf_splat_stats(psf_stats, (CatalogSplatStats){
.images = images,
.direct_fallbacks = direct_fallbacks,
+35 -1
View File
@@ -24,6 +24,26 @@ typedef struct {
int evaluated;
} LensTriangle;
/* Per-frame staged wall-clock breakdown for one movie frame. All fields are
* seconds and are zero-initialized by the caller. Hot loops never call a
* clock; each phase is timed exactly once around its bulk operation. */
typedef struct {
double catalog_mark_seconds;
double catalog_load_seconds;
double fast_clear_seconds;
double catalog_splat_seconds;
double fftw_zero_pack_seconds;
double fftw_forward_seconds;
double fftw_multiply_seconds;
double fftw_inverse_seconds;
double fftw_crop_seconds;
double fftw_total_seconds;
double tone_map_seconds;
double png_encode_write_seconds;
double writer_queue_wait_seconds;
double frame_total_seconds;
} MovieFrameTiming;
typedef struct {
unsigned int max_level;
double angle_absolute_rad;
@@ -121,6 +141,18 @@ int frame_lens_mesh_refine_with_progress(
const RefinementConfig *config, FrameRefinementProgressCallback callback,
void *context);
/* Whether frame_splat_catalog() should run the per-frame catalog prefetch or
* rely on a movie-level union prefetch that already completed. */
typedef enum {
FRAME_CATALOG_PREFETCH_FRAME,
FRAME_CATALOG_PREFETCH_ALREADY_COMPLETE
} FrameCatalogPrefetchMode;
/* Adds every one-degree tile that can intersect a usable source triangle of
* the finalized mesh to `set`. Kept in the frame module so catalog.h does not
* depend on FrameLensMesh. */
int frame_mark_catalog_tiles(const FrameLensMesh *mesh, CatalogTileSet *set);
/* Each locally invertible escaped triangle contributes one image per contained
* star. When `fast` is non-NULL, each image is deposited into its shared
* supersampled buffer and the whole frame is resolved once at the end instead
@@ -136,10 +168,12 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
double psf_min_y,
int limit_workers_by_memory,
int catalog_load_workers,
FrameCatalogPrefetchMode catalog_prefetch_mode,
CatalogPrefetchStats *prefetch_stats,
PsfSplatStats *psf_stats,
const FrameSplatProgress *progress,
FastPsfAccumulator *fast);
FastPsfAccumulator *fast,
MovieFrameTiming *timing);
void frame_draw_mesh(const FrameLensMesh *mesh, double *hdr, int width,
int height, double gray, double opacity);
void frame_lens_mesh_destroy(FrameLensMesh *mesh);
+447 -50
View File
@@ -2,6 +2,7 @@
#include "frame.h"
#include "lens_map.h"
#include "movie.h"
#include "movie_output.h"
#include "observer_track.h"
#include "optics.h"
#include "ray.h"
@@ -59,9 +60,13 @@ typedef struct {
double minkowski_proper_acceleration;
double alcubierre_vs, alcubierre_radius, alcubierre_sigma;
int catalog_load_workers;
int movie_output_workers; /* 0 = shared, 1 = reserve one core for writer */
FastPsfFftwPlanMode fast_fftw_plan_mode;
const char *fast_fftw_wisdom_path;
const char *blackbody_table_path;
RefinementConfig refinement;
ToneMapSettings tone_map;
PngWriteSettings png;
int sensor_bloom_enabled;
int sensor_bloom_limit_specified;
int sensor_bloom_transfer_specified;
@@ -227,6 +232,36 @@ typedef struct {
int draw_mesh;
} FrameOutputPaths;
/* Parallel HDR -> sRGB8 conversion and the timed PNG/write step, split so the
* movie timing can attribute each phase separately. */
static int render_rgb8_image(const Settings *s, const double *hdr, int width,
int height, unsigned char **out,
MovieFrameTiming *timing) {
unsigned char *rgb8 = malloc((size_t)width * height * 3);
if (rgb8 == NULL)
return -1;
const double tone_start = omp_get_wtime();
if (tone_map_srgb8_image(hdr, rgb8, width, height, &s->tone_map,
omp_get_max_threads())) {
free(rgb8);
return -1;
}
if (timing != NULL)
timing->tone_map_seconds += omp_get_wtime() - tone_start;
*out = rgb8;
return 0;
}
static int write_rgb8_timed(const Settings *s, const char *path,
const unsigned char *rgb8, int width, int height,
MovieFrameTiming *timing) {
const double write_start = omp_get_wtime();
const int result = write_rgb8_image(path, rgb8, width, height, &s->png);
if (timing != NULL)
timing->png_encode_write_seconds += omp_get_wtime() - write_start;
return result;
}
/* Fills in the plain and mesh-overlay output paths for one frame, returning
* -1 if a requested mesh sibling would overflow PATH_MAX. Callers choose the
* timing; main() separately pre-validates the single-frame path before
@@ -245,10 +280,41 @@ static int build_frame_output_paths(const Settings *s, const char *output_path,
/* Canonical output order for every frame: clean HDR, clean tone-mapped image,
* then the mesh overlay. The overlay reuses the already-consumed HDR buffer,
* so no second full-size framebuffer is allocated and nothing is re-rendered. */
/* Optional sensor bloom applied to the post-exposure linear HDR before the
* tone map. Shared by the synchronous writer and the async movie producer. */
static int apply_sensor_bloom(const Settings *s, double *hdr, int width,
int height) {
if (!s->sensor_bloom_enabled)
return 0;
const SensorBloomSettings bloom = {.response_limit = s->sensor_bloom_limit,
.transfer = s->sensor_bloom_transfer};
SensorBloomStats bloom_stats;
if (sensor_bloom_apply(hdr, width, height, &bloom, &bloom_stats)) {
fputs("Sensor bloom failed (invalid settings, non-finite HDR, allocation "
"failure, or a round bound above the 4096 hard limit; lower "
"--sensor-bloom-transfer, raise --sensor-bloom-limit, or reduce "
"--exposure); aborting tone-mapped output.\n",
stderr);
return -1;
}
fprintf(stderr,
"Sensor bloom: saturated=%zu clamped=%zu iterations=%zu/%zu "
"peak=%.6g max_overflow=%.6g absorbed=%.6g boundary=%.6g "
"residual=%.6g elapsed=%.6fs\n",
bloom_stats.initially_saturated_channels,
bloom_stats.final_clamped_channels, bloom_stats.iterations,
bloom_stats.predicted_iterations, bloom_stats.peak_input,
bloom_stats.initial_max_overflow, bloom_stats.absorbed_signal,
bloom_stats.boundary_loss, bloom_stats.residual_clamp_loss,
bloom_stats.elapsed_seconds);
return 0;
}
static int write_frame_outputs(const Settings *s, const FrameLensMesh *mesh,
double *hdr, int width, int height, double fov_deg,
const FrameOutputPaths *paths, size_t images,
size_t stars, const char *note) {
size_t stars, const char *note,
MovieFrameTiming *timing) {
#ifdef ENABLE_HDR_OUTPUT
if (s->write_hdr_output &&
write_hdr_fits(s->hdr_output_path, hdr, width, height, fov_deg)) {
@@ -259,33 +325,16 @@ static int write_frame_outputs(const Settings *s, const FrameLensMesh *mesh,
(void)s;
(void)fov_deg;
#endif
if (s->sensor_bloom_enabled) {
const SensorBloomSettings bloom = {
.response_limit = s->sensor_bloom_limit,
.transfer = s->sensor_bloom_transfer};
SensorBloomStats bloom_stats;
if (sensor_bloom_apply(hdr, width, height, &bloom, &bloom_stats)) {
fputs("Sensor bloom failed (invalid settings, non-finite HDR, allocation "
"failure, or a round bound above the 4096 hard limit; lower "
"--sensor-bloom-transfer, raise --sensor-bloom-limit, or reduce "
"--exposure); aborting tone-mapped output.\n",
stderr);
return -1;
}
fprintf(stderr,
"Sensor bloom: saturated=%zu clamped=%zu iterations=%zu/%zu "
"peak=%.6g max_overflow=%.6g absorbed=%.6g boundary=%.6g "
"residual=%.6g elapsed=%.6fs\n",
bloom_stats.initially_saturated_channels,
bloom_stats.final_clamped_channels, bloom_stats.iterations,
bloom_stats.predicted_iterations, bloom_stats.peak_input,
bloom_stats.initial_max_overflow, bloom_stats.absorbed_signal,
bloom_stats.boundary_loss, bloom_stats.residual_clamp_loss,
bloom_stats.elapsed_seconds);
if (apply_sensor_bloom(s, hdr, width, height))
return -1;
unsigned char *rgb8 = NULL;
if (render_rgb8_image(s, hdr, width, height, &rgb8, timing)) {
fputs("Tone mapping failed (allocation failure).\n", stderr);
return -1;
}
const int write_result =
write_tonemapped_image(paths->output_path, hdr, width, height,
&s->tone_map);
write_rgb8_timed(s, paths->output_path, rgb8, width, height, timing);
free(rgb8);
fprintf(stderr, "Rendered %zu images from %zu catalog stars to %s (%s%s)\n",
images, stars, paths->output_path,
write_result == 0 ? "ok" : "write failed", note);
@@ -293,12 +342,16 @@ static int write_frame_outputs(const Settings *s, const FrameLensMesh *mesh,
return -1;
if (paths->draw_mesh) {
frame_draw_mesh(mesh, hdr, width, height, 0.5, 0.5);
if (write_tonemapped_image(paths->mesh_path, hdr, width, height,
&s->tone_map)) {
unsigned char *mesh_rgb8 = NULL;
if (render_rgb8_image(s, hdr, width, height, &mesh_rgb8, timing) ||
write_rgb8_timed(s, paths->mesh_path, mesh_rgb8, width, height,
timing)) {
free(mesh_rgb8);
fprintf(stderr, "Failed to write mesh overlay image: %s\n",
paths->mesh_path);
return -1;
}
free(mesh_rgb8);
fprintf(stderr, "Wrote mesh overlay image: %s\n", paths->mesh_path);
}
return 0;
@@ -345,13 +398,16 @@ static int parse_args(int argc, char **argv, Settings *s,
.alcubierre_radius = 5.0,
.alcubierre_sigma = 1.0,
.catalog_load_workers = 4,
.movie_output_workers = 0,
.fast_fftw_plan_mode = FAST_PSF_FFTW_PLAN_ESTIMATE,
.refinement = {.angle_absolute_rad =
1e-3 * 3.14159265358979323846 / 180.0,
.angle_relative = 0.1,
.jacobian_minimum = 1e-3,
.min_edge_pixels = 0.5,
.min_area_pixels2 = 0.25},
.tone_map = {.op = TONE_MAP_SOFTCLIP, .p = 2.0}};
.tone_map = {.op = TONE_MAP_SOFTCLIP, .p = 2.0},
.png = {.compression_level = -1}};
#ifdef SPACETIME_ALCUBIERRE
/* The default bubble (R=5, sigma=1) has escape radius 25, so the generic
* radius-30 camera would sit outside the active domain. */
@@ -500,6 +556,35 @@ static int parse_args(int argc, char **argv, Settings *s,
#endif
} else if (!strcmp(argv[i], "--catalog-load-workers") && i + 1 < argc &&
!parse_int(argv[++i], &s->catalog_load_workers)) {
} else if (!strcmp(argv[i], "--png-compression-level") && i + 1 < argc) {
char *end;
errno = 0;
const long level = strtol(argv[++i], &end, 10);
if (errno || *end || level < 0 || level > 9)
return -1;
s->png.compression_level = (int)level;
} else if (!strcmp(argv[i], "--movie-output-workers") && i + 1 < argc) {
const char *mode = argv[++i];
if (!strcmp(mode, "shared"))
s->movie_output_workers = 0;
else if (!strcmp(mode, "reserve-one"))
s->movie_output_workers = 1;
else
return -1;
} else if (!strcmp(argv[i], "--fast-fftw-plan") && i + 1 < argc) {
const char *mode = argv[++i];
if (!strcmp(mode, "estimate"))
s->fast_fftw_plan_mode = FAST_PSF_FFTW_PLAN_ESTIMATE;
else if (!strcmp(mode, "measure"))
s->fast_fftw_plan_mode = FAST_PSF_FFTW_PLAN_MEASURE;
else if (!strcmp(mode, "wisdom"))
s->fast_fftw_plan_mode = FAST_PSF_FFTW_PLAN_WISDOM;
else if (!strcmp(mode, "wisdom-update"))
s->fast_fftw_plan_mode = FAST_PSF_FFTW_PLAN_WISDOM_UPDATE;
else
return -1;
} else if (!strcmp(argv[i], "--fast-fftw-wisdom") && i + 1 < argc) {
s->fast_fftw_wisdom_path = argv[++i];
} else if (!strcmp(argv[i], "--blackbody-table") && i + 1 < argc) {
s->blackbody_table_path = argv[++i];
} else if (!strcmp(argv[i], "--write-minkowski-accel-track") &&
@@ -591,7 +676,16 @@ static void print_help(const char *program) {
" --fast-supersample N Fast-mode supersample factor, 1..8 (default: 2)\n"
" --fast-deposit MODE nearest (exact FWHM/beta, 0.5/N px quantization) or\n"
" bilinear (exact centroid, broadens FWHM); default: nearest\n"
" --fast-fftw-plan MODE estimate (default), measure, wisdom, or wisdom-update\n"
" (CPU mode only)\n"
" --fast-fftw-wisdom FILE FFTW wisdom file for the wisdom plan modes\n",
stdout);
fputs(
" --catalog-load-workers N All-sky catalog loader workers (default: 4)\n"
" --png-compression-level N libpng/zlib compression level 0..9\n"
" (default: libpng default; use 1 for preview movies)\n"
" --movie-output-workers MODE shared (renderer keeps all threads) or reserve-one\n"
" (renderer leaves one core for the async writer; default shared)\n"
" --blackbody-table FILE Explicit GRBBLUT3 table\n"
" (default: assets/blackbody/cie1931_2deg_xyz_1024.grbblut)\n",
stdout);
@@ -967,11 +1061,12 @@ static int render_observer_frame(const Settings *s, StarCatalog *catalog,
s->psf_relative_tail,
s->psf_min_y,
spacetime_limits_render_workers_by_memory(spacetime),
s->catalog_load_workers, &prefetch, &psf_stats,
s->catalog_load_workers, FRAME_CATALOG_PREFETCH_FRAME, &prefetch,
&psf_stats,
&(FrameSplatProgress){report_splat_progress,
s->verbose ? report_splat_worker_progress : NULL,
&progress},
s->fast_psf);
s->fast_psf, NULL);
if (images == SIZE_MAX) {
frame_lens_mesh_destroy(&mesh);
free(hdr);
@@ -985,7 +1080,7 @@ static int render_observer_frame(const Settings *s, StarCatalog *catalog,
}
const int result = write_frame_outputs(
s, &mesh, hdr, s->width, s->height, s->horizontal_fov_deg, &output_paths,
images, catalog->count, "");
images, catalog->count, "", NULL);
report_psf_splat(s, &psf_stats);
frame_lens_mesh_destroy(&mesh);
free(hdr);
@@ -1102,12 +1197,128 @@ static int trace_movie_generation(Movie *movie, const Settings *s,
return 1;
}
enum { MOVIE_TIMING_COUNT = 14 };
/* Running per-phase totals and maxima across the movie's frames. No frame
* arrays are retained, so memory stays O(1) in the frame count. */
typedef struct {
double sum[MOVIE_TIMING_COUNT];
double max[MOVIE_TIMING_COUNT];
size_t frames;
} MovieTimingAccumulator;
static const char *const movie_timing_names[MOVIE_TIMING_COUNT] = {
"catalog_mark", "catalog_load", "fast_clear", "splat", "fftw_zero_pack",
"fftw_forward", "fftw_multiply", "fftw_inverse", "fftw_crop", "fftw_total",
"tone_map", "output", "wait", "total"};
static void movie_timing_add(MovieTimingAccumulator *acc,
const MovieFrameTiming *t) {
const double values[MOVIE_TIMING_COUNT] = {
t->catalog_mark_seconds, t->catalog_load_seconds,
t->fast_clear_seconds, t->catalog_splat_seconds,
t->fftw_zero_pack_seconds, t->fftw_forward_seconds,
t->fftw_multiply_seconds, t->fftw_inverse_seconds,
t->fftw_crop_seconds, t->fftw_total_seconds,
t->tone_map_seconds, t->png_encode_write_seconds,
t->writer_queue_wait_seconds, t->frame_total_seconds};
for (size_t i = 0; i < MOVIE_TIMING_COUNT; ++i) {
acc->sum[i] += values[i];
if (values[i] > acc->max[i])
acc->max[i] = values[i];
}
++acc->frames;
}
static void report_movie_timing_frame(const Settings *s, size_t frame_id,
const MovieFrameTiming *t) {
if (!s->verbose)
return;
fprintf(stderr,
"Movie frame %zu timing: catalog_mark=%.4f catalog_load=%.4f "
"fast_clear=%.4f splat=%.4f fftw=%.4f tone_map=%.4f output=%.4f "
"wait=%.4f total=%.4f\n",
frame_id, t->catalog_mark_seconds, t->catalog_load_seconds,
t->fast_clear_seconds, t->catalog_splat_seconds, t->fftw_total_seconds,
t->tone_map_seconds, t->png_encode_write_seconds,
t->writer_queue_wait_seconds, t->frame_total_seconds);
}
static void report_movie_timing_summary(const MovieTimingAccumulator *acc) {
if (acc->frames == 0)
return;
fprintf(stderr, "Movie timing total (%zu frames):", acc->frames);
for (size_t i = 0; i < MOVIE_TIMING_COUNT; ++i)
fprintf(stderr, " %s=%.4f", movie_timing_names[i], acc->sum[i]);
fputc('\n', stderr);
fprintf(stderr, "Movie timing avg (%zu frames):", acc->frames);
for (size_t i = 0; i < MOVIE_TIMING_COUNT; ++i)
fprintf(stderr, " %s=%.4f", movie_timing_names[i],
acc->sum[i] / (double)acc->frames);
fputc('\n', stderr);
fprintf(stderr, "Movie timing max (%zu frames):", acc->frames);
for (size_t i = 0; i < MOVIE_TIMING_COUNT; ++i)
fprintf(stderr, " %s=%.4f", movie_timing_names[i], acc->max[i]);
fputc('\n', stderr);
}
/* Producer half of the async movie output: finishes every HDR-side step
* (sensor bloom, tone map, optional mesh overlay, optional frame log) and
* fills a job that carries only 8-bit RGB buffers. HDR can then be freed
* immediately after submit. */
static int prepare_movie_output_job(const Settings *s,
const FrameLensMesh *mesh, double *hdr,
int width, int height,
const FrameOutputPaths *paths,
size_t frame_id, size_t images,
size_t stars, const char *note,
const PsfSplatStats *psf_stats,
MovieFrameTiming *timing,
MovieOutputJob *job) {
memset(job, 0, sizeof *job);
job->frame_id = frame_id;
job->width = width;
job->height = height;
job->images = images;
job->catalog_stars = stars;
job->draw_mesh = paths->draw_mesh;
snprintf(job->output_path, sizeof job->output_path, "%s", paths->output_path);
if (note != NULL)
snprintf(job->note, sizeof job->note, "%s", note);
if (paths->draw_mesh)
snprintf(job->mesh_path, sizeof job->mesh_path, "%s", paths->mesh_path);
if (psf_stats != NULL) {
job->psf_stats = *psf_stats;
job->has_psf_stats = 1;
job->fast_mode = s->fast_mode;
}
if (apply_sensor_bloom(s, hdr, width, height))
return -1;
if (render_rgb8_image(s, hdr, width, height, &job->clean_rgb8, timing))
return -1;
if (paths->draw_mesh) {
frame_draw_mesh(mesh, hdr, width, height, 0.5, 0.5);
if (render_rgb8_image(s, hdr, width, height, &job->mesh_rgb8, timing)) {
free(job->clean_rgb8);
job->clean_rgb8 = NULL;
return -1;
}
}
return 0;
}
static int render_movie(const Settings *s, StarCatalog *catalog,
const SpacetimeSource *spacetime) {
ObserverTrack track = {0};
Movie movie = {0};
const GeodesicTraceConfig trace = trace_config(s);
MovieOutputQueue output_queue;
int output_queue_ready = 0;
int result = -1;
/* True end-to-end movie wall, starting before track load, ray tracing, and
* the catalog union prefetch. The later pipeline wall measures only the
* per-frame render + output stage. */
const double movie_wall_start = omp_get_wtime();
if (s->observer_track_path == NULL ||
observer_track_load_csv(&track, s->observer_track_path) ||
(s->movie_track_samples ? movie_init_track_samples(&movie, &track) :
@@ -1143,7 +1354,44 @@ static int render_movie(const Settings *s, StarCatalog *catalog,
fprintf(stderr, "Wrote lens-map movie: %s (%zu frames)\n",
s->lens_map_output_path, movie.frame_count);
}
/* An all-sky movie prefetches the union of every finalized frame's requested
* tiles exactly once. An in-memory catalog never uses tiles, so it must not
* pay for a full triangle scan across every frame at all. */
if (catalog->kind == STAR_CATALOG_ALL_SKY) {
CatalogTileSet movie_tiles;
catalog_tile_set_clear(&movie_tiles);
const double mark_start = omp_get_wtime();
for (size_t i = 0; i < movie.frame_count; ++i)
(void)frame_mark_catalog_tiles(&movie.frames[i].mesh, &movie_tiles);
const double mark_seconds = omp_get_wtime() - mark_start;
CatalogPrefetchStats movie_prefetch = {0};
const double load_start = omp_get_wtime();
if (catalog_prefetch_tile_set(catalog, &movie_tiles,
s->catalog_load_workers,
CATALOG_PREFETCH_DEFAULT_BATCH_TILES,
&movie_prefetch)) {
fputs("Movie-level catalog prefetch failed.\n", stderr);
goto done;
}
const double load_seconds = omp_get_wtime() - load_start;
fprintf(stderr,
"Movie catalog prefetch: %zu unique requested, %zu newly loaded "
"(%zu stars), %zu unavailable in %.3f s (mark %.3f, load+commit "
"%.3f); %d loader workers\n",
catalog_tile_set_count(&movie_tiles),
movie_prefetch.newly_loaded_tiles, movie_prefetch.newly_loaded_stars,
movie_prefetch.unavailable_tiles, mark_seconds + load_seconds,
mark_seconds, load_seconds, s->catalog_load_workers);
}
if (movie_output_queue_init(&output_queue, 2, &s->png)) {
fputs("Movie output queue initialization failed.\n", stderr);
goto done;
}
output_queue_ready = 1;
const double endpoint_start = omp_get_wtime();
MovieTimingAccumulator timing_acc = {0};
for (size_t i = 0; i < movie.frame_count; ++i) {
const double frame_start = omp_get_wtime();
char output_path[PATH_MAX];
FrameOutputPaths output_paths;
if (frame_output_path(output_path, s, movie.frames[i].frame_id) ||
@@ -1154,6 +1402,7 @@ static int render_movie(const Settings *s, StarCatalog *catalog,
goto done;
CatalogPrefetchStats prefetch = {0};
PsfSplatStats psf_stats = {0};
MovieFrameTiming frame_timing = {0};
RenderProgress progress = {
.verbose = s->verbose,
.frame_id = movie.frames[i].frame_id,
@@ -1166,25 +1415,49 @@ static int render_movie(const Settings *s, StarCatalog *catalog,
s->psf_relative_tail,
s->psf_min_y,
spacetime_limits_render_workers_by_memory(spacetime),
s->catalog_load_workers, &prefetch, &psf_stats,
s->catalog_load_workers, FRAME_CATALOG_PREFETCH_ALREADY_COMPLETE,
&prefetch, &psf_stats,
&(FrameSplatProgress){report_splat_progress,
s->verbose ? report_splat_worker_progress : NULL,
&progress},
s->fast_psf);
s->fast_psf, &frame_timing);
if (images == SIZE_MAX) {
free(hdr);
goto done;
}
const int write_result = write_frame_outputs(
s, &movie.frames[i].mesh, hdr, s->width, s->height,
s->horizontal_fov_deg, &output_paths, images, catalog->count, "");
free(hdr);
report_psf_splat(s, &psf_stats);
if (write_result)
MovieOutputJob job;
if (prepare_movie_output_job(s, &movie.frames[i].mesh, hdr, s->width,
s->height, &output_paths,
movie.frames[i].frame_id, images,
catalog->count, "", &psf_stats, &frame_timing,
&job)) {
free(hdr);
goto done;
}
free(hdr);
double queue_wait = 0.0;
if (movie_output_queue_submit(&output_queue, &job, &queue_wait)) {
/* The queue rejected the job; buffers still belong to this caller. */
free(job.clean_rgb8);
free(job.mesh_rgb8);
goto done;
}
frame_timing.writer_queue_wait_seconds = queue_wait;
frame_timing.frame_total_seconds = omp_get_wtime() - frame_start;
report_movie_timing_frame(s, movie.frames[i].frame_id, &frame_timing);
movie_timing_add(&timing_acc, &frame_timing);
}
result = 0;
result = movie_output_queue_finish(&output_queue) == 0 ? 0 : -1;
const double now = omp_get_wtime();
report_movie_timing_summary(&timing_acc);
movie_output_queue_report(&output_queue, stderr);
fprintf(stderr,
"Movie output pipeline wall: %.4f s (producer frame-time sum %.4f s)\n",
now - endpoint_start, timing_acc.sum[MOVIE_TIMING_COUNT - 1]);
fprintf(stderr, "Movie end-to-end wall: %.4f s\n", now - movie_wall_start);
done:
if (output_queue_ready)
movie_output_queue_destroy(&output_queue);
movie_destroy(&movie);
observer_track_destroy(&track);
return result;
@@ -1227,7 +1500,58 @@ static int render_lens_map(const Settings *s, StarCatalog *catalog) {
fast_psf_accumulator_report(&local_fast, stderr);
fast_psf_accumulator_set_verbose(&local_fast, s->verbose);
}
/* A multi-frame imported map runs the same movie-level union prefetch and
* bounded async output queue as a tracked observer movie: one immutable tile
* cache, overlapping PNG writes. */
FrameCatalogPrefetchMode map_prefetch_mode = FRAME_CATALOG_PREFETCH_FRAME;
if (map.frame_count > 1) {
if (catalog->kind == STAR_CATALOG_ALL_SKY) {
CatalogTileSet map_tiles;
catalog_tile_set_clear(&map_tiles);
const double mark_start = omp_get_wtime();
for (size_t i = 0; i < map.frame_count; ++i)
(void)frame_mark_catalog_tiles(&map.frames[i].mesh, &map_tiles);
const double mark_seconds = omp_get_wtime() - mark_start;
CatalogPrefetchStats movie_prefetch = {0};
const double load_start = omp_get_wtime();
if (catalog_prefetch_tile_set(catalog, &map_tiles,
s->catalog_load_workers,
CATALOG_PREFETCH_DEFAULT_BATCH_TILES,
&movie_prefetch)) {
fast_psf_accumulator_destroy(&local_fast);
lens_map_destroy(&map);
return -1;
}
const double load_seconds = omp_get_wtime() - load_start;
fprintf(stderr,
"Movie catalog prefetch: %zu unique requested, %zu newly loaded "
"(%zu stars), %zu unavailable in %.3f s (mark %.3f, load+commit "
"%.3f); %d loader workers\n",
catalog_tile_set_count(&map_tiles),
movie_prefetch.newly_loaded_tiles,
movie_prefetch.newly_loaded_stars,
movie_prefetch.unavailable_tiles, mark_seconds + load_seconds,
mark_seconds, load_seconds, s->catalog_load_workers);
}
map_prefetch_mode = FRAME_CATALOG_PREFETCH_ALREADY_COMPLETE;
}
#ifndef PSF_BACKEND_DUMMY
MovieOutputQueue output_queue;
int output_queue_ready = 0;
if (map.frame_count > 1) {
if (movie_output_queue_init(&output_queue, 2, &s->png)) {
fputs("Movie output queue initialization failed.\n", stderr);
fast_psf_accumulator_destroy(&local_fast);
lens_map_destroy(&map);
return -1;
}
output_queue_ready = 1;
}
MovieTimingAccumulator timing_acc = {0};
const double pipeline_start = omp_get_wtime();
#endif
for (size_t i = 0; i < map.frame_count; ++i) {
const double frame_start = omp_get_wtime();
const char *output_path = s->output_path;
char movie_path[PATH_MAX];
#ifndef PSF_BACKEND_DUMMY
@@ -1256,6 +1580,7 @@ static int render_lens_map(const Settings *s, StarCatalog *catalog) {
if (hdr == NULL) { result = -1; break; }
CatalogPrefetchStats prefetch = {0};
PsfSplatStats psf_stats = {0};
MovieFrameTiming frame_timing = {0};
RenderProgress progress = {.verbose = s->verbose,
.frame_id = (size_t)map.frames[i].frame_id,
.prefetch = &prefetch,
@@ -1265,24 +1590,50 @@ static int render_lens_map(const Settings *s, StarCatalog *catalog) {
&map.frames[i].mesh, catalog, hdr, map.width, map.height, s->exposure,
&s->psf, &s->psf_cache, s->max_magnification, s->max_cache_psf_flux,
s->psf_relative_tail, s->psf_min_y, 0, s->catalog_load_workers,
&prefetch, &psf_stats,
map_prefetch_mode, &prefetch, &psf_stats,
&(FrameSplatProgress){report_splat_progress,
s->verbose ? report_splat_worker_progress : NULL,
&progress},
fast);
fast, &frame_timing);
if (images == SIZE_MAX) {
free(hdr);
result = -1;
break;
}
#ifndef PSF_BACKEND_DUMMY
const int write_result = write_frame_outputs(
s, &map.frames[i].mesh, hdr, map.width, map.height,
map.horizontal_fov_deg, &output_paths, images, catalog->count,
"; imported lens map");
free(hdr);
report_psf_splat(s, &psf_stats);
if (write_result) { result = -1; break; }
if (output_queue_ready) {
MovieOutputJob job;
if (prepare_movie_output_job(s, &map.frames[i].mesh, hdr, map.width,
map.height, &output_paths,
(size_t)map.frames[i].frame_id, images,
catalog->count, "; imported lens map",
&psf_stats, &frame_timing, &job)) {
free(hdr);
result = -1;
break;
}
free(hdr);
double queue_wait = 0.0;
if (movie_output_queue_submit(&output_queue, &job, &queue_wait)) {
free(job.clean_rgb8);
free(job.mesh_rgb8);
result = -1;
break;
}
frame_timing.writer_queue_wait_seconds = queue_wait;
frame_timing.frame_total_seconds = omp_get_wtime() - frame_start;
report_movie_timing_frame(s, (size_t)map.frames[i].frame_id,
&frame_timing);
movie_timing_add(&timing_acc, &frame_timing);
} else {
const int write_result = write_frame_outputs(
s, &map.frames[i].mesh, hdr, map.width, map.height,
map.horizontal_fov_deg, &output_paths, images, catalog->count,
"; imported lens map", NULL);
free(hdr);
report_psf_splat(s, &psf_stats);
if (write_result) { result = -1; break; }
}
#else
if (s->draw_mesh)
fputs("Dummy PSF backend ignores --draw-mesh.\n", stderr);
@@ -1293,6 +1644,17 @@ static int render_lens_map(const Settings *s, StarCatalog *catalog) {
report_psf_splat(s, &psf_stats);
#endif
}
#ifndef PSF_BACKEND_DUMMY
if (output_queue_ready) {
if (movie_output_queue_finish(&output_queue))
result = -1;
report_movie_timing_summary(&timing_acc);
movie_output_queue_report(&output_queue, stderr);
fprintf(stderr, "Movie output pipeline wall: %.4f s\n",
omp_get_wtime() - pipeline_start);
movie_output_queue_destroy(&output_queue);
}
#endif
fast_psf_accumulator_destroy(&local_fast);
lens_map_destroy(&map);
return result;
@@ -1388,6 +1750,30 @@ int main(int argc, char **argv) {
fputs("--fast-supersample must be between 1 and 8.\n", stderr);
return 2;
}
#ifndef FAST_PSF_FFTW
if (settings.fast_fftw_plan_mode != FAST_PSF_FFTW_PLAN_ESTIMATE) {
fputs("--fast-fftw-plan and --fast-fftw-wisdom require the CPU PSF "
"backend built with FFTW.\n",
stderr);
return 2;
}
#else
if (fast_psf_fftw_configure(settings.fast_fftw_plan_mode,
settings.fast_fftw_wisdom_path))
return 2;
#endif
#ifndef ENABLE_PNG
if (settings.png.compression_level != -1) {
fputs("--png-compression-level requires a libpng build.\n", stderr);
return 2;
}
#endif
if (settings.movie_output_workers == 1 && settings.frames_dir == NULL) {
fputs("--movie-output-workers reserve-one requires a movie output "
"sequence (--frames-dir).\n",
stderr);
return 2;
}
if (settings.fast_mode)
fputs("Fast mode is a preview approximation; --max-cache-psf-flux is "
"ignored and one global kernel is used for every event.\n",
@@ -1486,6 +1872,17 @@ int main(int argc, char **argv) {
return 1;
}
fprintf(stderr, "Blackbody backend: %s\n", blackbody_backend_name());
/* `reserve-one` must be applied before any fast-mode accumulator is built:
* the FFTW plans in fast_psf_accumulator_init() snapshot omp_get_max_threads()
* at creation time, so shrinking the pool later would leave the plans (and
* their worker count) unchanged. It only applies when a movie output queue
* will actually run (observer movie or multi-frame imported map). */
if (settings.movie_output_workers == 1 && settings.frames_dir != NULL) {
int render_workers = omp_get_max_threads() - 1;
if (render_workers < 1)
render_workers = 1;
omp_set_num_threads(render_workers);
}
FastPsfAccumulator fast_accumulator = {0};
if (settings.fast_mode && settings.lens_map_input_path == NULL) {
if (fast_psf_accumulator_init(&fast_accumulator, settings.width,
+223
View File
@@ -0,0 +1,223 @@
#include "movie_output.h"
#include <omp.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Default writer: clean image, then optional mesh overlay, then the frame's
* comment lines. This is the only place that prints a movie frame's log, so
* frames stay in submit order; individual lines are not lock-protected and may
* still interleave with concurrent producer --verbose output. */
static int movie_output_default_write(void *context, const MovieOutputJob *job,
const PngWriteSettings *settings) {
(void)context;
if (write_rgb8_image(job->output_path, job->clean_rgb8, job->width,
job->height, settings))
return -1;
fprintf(stderr, "Rendered %zu images from %zu catalog stars to %s (ok%s)\n",
job->images, job->catalog_stars, job->output_path, job->note);
if (job->draw_mesh && job->mesh_rgb8 != NULL) {
if (write_rgb8_image(job->mesh_path, job->mesh_rgb8, job->width,
job->height, settings))
return -1;
fprintf(stderr, "Wrote mesh overlay image: %s\n", job->mesh_path);
}
if (job->has_psf_stats) {
if (job->fast_mode)
fprintf(stderr,
"Fast PSF splats: deposited %zu, wing-clipped %zu, discarded "
"below min-Y %zu\n",
job->psf_stats.cached_splats, job->psf_stats.cached_wing_clipped,
job->psf_stats.discarded_below_min_y);
else
psf_kernel_cache_report(NULL, &job->psf_stats, stderr);
}
return 0;
}
static void *movie_output_writer_main(void *opaque) {
MovieOutputQueue *queue = opaque;
pthread_mutex_lock(&queue->mutex);
for (;;) {
while (queue->count == 0 && !queue->producer_done)
pthread_cond_wait(&queue->not_empty, &queue->mutex);
if (queue->count == 0 && queue->producer_done)
break;
const size_t slot = queue->head;
const MovieOutputJob job = queue->jobs[slot];
/* Ownership moved into the local copy; clear the slot so destroy() cannot
* free the same buffers a second time. */
queue->jobs[slot].clean_rgb8 = NULL;
queue->jobs[slot].mesh_rgb8 = NULL;
queue->head = (queue->head + 1) % queue->capacity;
--queue->count;
pthread_cond_signal(&queue->not_full);
pthread_mutex_unlock(&queue->mutex);
const double write_start = omp_get_wtime();
const int result = queue->write(queue->write_context, &job, &queue->settings);
const double write_seconds = omp_get_wtime() - write_start;
pthread_mutex_lock(&queue->mutex);
queue->writer_total_seconds += write_seconds;
if (write_seconds > queue->writer_max_seconds)
queue->writer_max_seconds = write_seconds;
++queue->written_jobs;
if (result != 0) {
if (!queue->failed)
queue->failed = 1;
pthread_cond_broadcast(&queue->not_full);
}
pthread_mutex_unlock(&queue->mutex);
free(job.clean_rgb8);
free(job.mesh_rgb8);
pthread_mutex_lock(&queue->mutex);
}
pthread_mutex_unlock(&queue->mutex);
return NULL;
}
int movie_output_queue_init(MovieOutputQueue *queue, size_t capacity,
const PngWriteSettings *settings) {
if (queue == NULL)
return -1;
memset(queue, 0, sizeof *queue);
if (capacity < 1)
capacity = 1;
queue->jobs = calloc(capacity, sizeof *queue->jobs);
if (queue->jobs == NULL)
return -1;
queue->capacity = capacity;
queue->settings = settings != NULL ? *settings : (PngWriteSettings){-1};
queue->write = movie_output_default_write;
/* Destroy exactly what was initialized if a later step fails. */
if (pthread_mutex_init(&queue->mutex, NULL) != 0) {
free(queue->jobs);
queue->jobs = NULL;
return -1;
}
if (pthread_cond_init(&queue->not_full, NULL) != 0) {
pthread_mutex_destroy(&queue->mutex);
free(queue->jobs);
queue->jobs = NULL;
return -1;
}
if (pthread_cond_init(&queue->not_empty, NULL) != 0) {
pthread_cond_destroy(&queue->not_full);
pthread_mutex_destroy(&queue->mutex);
free(queue->jobs);
queue->jobs = NULL;
return -1;
}
if (pthread_create(&queue->writer, NULL, movie_output_writer_main, queue) != 0) {
pthread_cond_destroy(&queue->not_empty);
pthread_cond_destroy(&queue->not_full);
pthread_mutex_destroy(&queue->mutex);
free(queue->jobs);
queue->jobs = NULL;
return -1;
}
queue->thread_started = 1;
return 0;
}
void movie_output_queue_set_writer(MovieOutputQueue *queue,
MovieOutputWriteFn writer, void *context) {
if (queue == NULL || writer == NULL)
return;
queue->write = writer;
queue->write_context = context;
}
int movie_output_queue_submit(MovieOutputQueue *queue, MovieOutputJob *job,
double *wait_seconds) {
if (queue == NULL || job == NULL)
return -1;
double waited = 0.0;
pthread_mutex_lock(&queue->mutex);
while (queue->count == queue->capacity) {
if (queue->failed)
break;
const double wait_start = omp_get_wtime();
pthread_cond_wait(&queue->not_full, &queue->mutex);
waited += omp_get_wtime() - wait_start;
}
if (queue->failed) {
pthread_mutex_unlock(&queue->mutex);
if (wait_seconds != NULL)
*wait_seconds += waited;
return -1;
}
queue->jobs[queue->tail] = *job;
queue->tail = (queue->tail + 1) % queue->capacity;
++queue->count;
pthread_cond_signal(&queue->not_empty);
pthread_mutex_unlock(&queue->mutex);
if (wait_seconds != NULL)
*wait_seconds += waited;
return 0;
}
int movie_output_queue_failed(const MovieOutputQueue *queue) {
if (queue == NULL)
return 1;
MovieOutputQueue *mutable_queue = (MovieOutputQueue *)queue;
pthread_mutex_lock(&mutable_queue->mutex);
const int failed = mutable_queue->failed;
pthread_mutex_unlock(&mutable_queue->mutex);
return failed;
}
int movie_output_queue_finish(MovieOutputQueue *queue) {
if (queue == NULL)
return -1;
pthread_mutex_lock(&queue->mutex);
queue->producer_done = 1;
pthread_cond_signal(&queue->not_empty);
const int need_join = queue->thread_started && !queue->joined;
if (need_join)
queue->joined = 1;
pthread_mutex_unlock(&queue->mutex);
if (need_join) {
const double drain_start = omp_get_wtime();
pthread_join(queue->writer, NULL);
pthread_mutex_lock(&queue->mutex);
queue->drain_seconds = omp_get_wtime() - drain_start;
pthread_mutex_unlock(&queue->mutex);
}
return movie_output_queue_failed(queue) ? -1 : 0;
}
void movie_output_queue_report(const MovieOutputQueue *queue, FILE *stream) {
if (queue == NULL || stream == NULL)
return;
const double average = queue->written_jobs > 0
? queue->writer_total_seconds /
(double)queue->written_jobs
: 0.0;
fprintf(stream,
"Movie writer summary: jobs=%zu total=%.4f avg=%.4f max=%.4f "
"drain=%.4f s\n",
queue->written_jobs, queue->writer_total_seconds, average,
queue->writer_max_seconds, queue->drain_seconds);
}
void movie_output_queue_destroy(MovieOutputQueue *queue) {
if (queue == NULL)
return;
if (queue->thread_started && !queue->joined)
(void)movie_output_queue_finish(queue);
/* Any job that was accepted but never written is released here. */
if (queue->jobs != NULL)
for (size_t i = 0; i < queue->capacity; ++i) {
free(queue->jobs[i].clean_rgb8);
free(queue->jobs[i].mesh_rgb8);
}
pthread_cond_destroy(&queue->not_empty);
pthread_cond_destroy(&queue->not_full);
pthread_mutex_destroy(&queue->mutex);
free(queue->jobs);
queue->jobs = NULL;
}
+104
View File
@@ -0,0 +1,104 @@
#ifndef MOVIE_OUTPUT_H
#define MOVIE_OUTPUT_H
#include "optics.h"
#include <limits.h>
#include <pthread.h>
#include <stddef.h>
/* Bounded, single-producer/single-writer movie output queue.
*
* The producer (the render loop) performs all HDR work and the tone map before
* submitting; a job therefore carries finished 8-bit RGB buffers, never a
* double HDR framebuffer. The writer thread encodes/writes them in submit
* order while the producer renders the next frame.
*
* Ownership contract for submit(): on success the queue owns clean_rgb8 and
* mesh_rgb8 and frees them after writing; on failure they remain owned by the
* caller.
*
* `capacity` bounds the queued jobs only; the writer may additionally hold one
* already-popped job, so the true in-memory bound is capacity + 1 jobs. With
* the production default of 2 that is three jobs. */
typedef struct {
size_t frame_id;
char output_path[PATH_MAX];
char mesh_path[PATH_MAX];
int draw_mesh;
unsigned char *clean_rgb8;
unsigned char *mesh_rgb8; /* NULL when draw_mesh is false */
int width;
int height;
size_t images;
size_t catalog_stars;
PsfSplatStats psf_stats;
int fast_mode;
int has_psf_stats;
/* Extra suffix appended to the "Rendered ..." line (for example
* "; imported lens map"). The writer emits a frame's log lines in submit
* order; because there is no shared log lock, an individual line may still
* interleave with concurrent producer --verbose output. */
char note[64];
} MovieOutputJob;
/* Optional custom writer. Returns 0 on success; the default writer writes
* clean_rgb8 to output_path and, when draw_mesh is set, mesh_rgb8 to
* mesh_path, then prints the "Rendered ... (<note>)" and PSF lines. The queue
* owns and frees clean_rgb8/mesh_rgb8 after the writer returns. */
typedef int (*MovieOutputWriteFn)(void *context, const MovieOutputJob *job,
const PngWriteSettings *settings);
typedef struct MovieOutputQueue {
pthread_mutex_t mutex;
pthread_cond_t not_full;
pthread_cond_t not_empty;
pthread_t writer;
MovieOutputJob *jobs;
size_t capacity;
size_t head;
size_t tail;
size_t count;
int producer_done;
int failed;
int joined;
int thread_started;
PngWriteSettings settings;
MovieOutputWriteFn write;
void *write_context;
/* Writer-side accounting, updated by the writer thread under the mutex and
* readable after finish(). */
double writer_total_seconds;
double writer_max_seconds;
double drain_seconds; /* producer_done -> writer join, measured by finish() */
size_t written_jobs;
} MovieOutputQueue;
/* capacity is clamped to at least 1; the production default is 2. */
int movie_output_queue_init(MovieOutputQueue *queue, size_t capacity,
const PngWriteSettings *settings);
/* Test seam: replace the writer before the first submit. */
void movie_output_queue_set_writer(MovieOutputQueue *queue,
MovieOutputWriteFn writer, void *context);
int movie_output_queue_submit(MovieOutputQueue *queue, MovieOutputJob *job,
double *wait_seconds);
/* Returns -1 if the writer has already recorded an error. */
int movie_output_queue_failed(const MovieOutputQueue *queue);
/* Signals end of input, drains every accepted job, then joins the writer.
* Records drain_seconds around the join. Safe to call more than once. */
int movie_output_queue_finish(MovieOutputQueue *queue);
/* Prints jobs, total/average/max writer time, and final drain time. Call
* after finish() for complete accounting. */
void movie_output_queue_report(const MovieOutputQueue *queue, FILE *stream);
/* Finish if needed, join, and release synchronization resources. */
void movie_output_queue_destroy(MovieOutputQueue *queue);
#endif
+77 -34
View File
@@ -534,7 +534,8 @@ int fast_psf_accumulator_init(FastPsfAccumulator *accumulator, int width,
.use_reference = use_reference,
.weights = weights,
.row_span = row_span,
.buffer = buffer};
.buffer = buffer,
.buffer_state = FAST_PSF_BUFFER_CLEAN};
/* k[m] is the pixel-area integral of I(z/N; alpha, beta) over ss cell m,
* i.e. the final-normalized Moffat evaluated at the supersampled scale.
* Its total weight is ~N^2, so the N x N box average restores unit flux
@@ -577,7 +578,8 @@ void fast_psf_accumulator_clear(FastPsfAccumulator *accumulator)
return;
memset(accumulator->buffer, 0,
(size_t)accumulator->supersampled_width *
accumulator->supersampled_height * 3 * sizeof(double));
accumulator->supersampled_height * 3 * sizeof(double));
accumulator->buffer_state = FAST_PSF_BUFFER_CLEAN;
}
int fast_psf_accumulator_deposit(FastPsfAccumulator *accumulator, double x,
@@ -593,6 +595,7 @@ int fast_psf_accumulator_deposit(FastPsfAccumulator *accumulator, double x,
accumulator->relative_tail_fraction, accumulator->min_y);
if (support_radius == 0.0)
return 3;
accumulator->buffer_state = FAST_PSF_BUFFER_DIRTY;
const int wing_clipped =
support_radius >
(double)accumulator->radius_pixels / accumulator->supersample
@@ -689,9 +692,17 @@ int fast_psf_accumulator_resolve(FastPsfAccumulator *accumulator,
if (accumulator->fftw_enabled && accumulator->fftw != NULL) {
FastPsfFftwFrameTiming timing = {0};
const double start = omp_get_wtime();
if (fast_psf_fftw_resolve(accumulator->fftw, accumulator->buffer, hdr,
&timing))
/* The consuming resolve zeroes the shared source buffer as it packs,
* so a successful call leaves the accumulator CLEAN for the next
* frame with no separate serial clear. A failed call still returns
* an all-zero buffer (fast_psf_fftw_resolve_and_clear guarantees it). */
if (fast_psf_fftw_resolve_and_clear(accumulator->fftw,
accumulator->buffer, hdr, &timing)) {
accumulator->buffer_state = FAST_PSF_BUFFER_CLEAN;
return -1;
}
accumulator->buffer_state = FAST_PSF_BUFFER_CLEAN;
accumulator->fftw_last_timing = timing;
accumulator->fftw_frame_seconds = omp_get_wtime() - start;
if (accumulator->verbose)
fprintf(stderr,
@@ -818,50 +829,86 @@ unsigned char tone_map_srgb8_channel(double hdr_value,
return (unsigned char)lround(255.0 * clamp(display, 0.0, 1.0));
}
int tone_map_srgb8_image(const double *hdr, unsigned char *rgb8, int width,
int height, const ToneMapSettings *settings,
int worker_count)
{
if (hdr == NULL || rgb8 == NULL || width <= 0 || height <= 0)
return -1;
const int threads = worker_count > 0 ? worker_count : 1;
/* Row-outer and statically scheduled: each output byte is written by one
* worker, and the byte stream is identical for any worker_count. */
#pragma omp parallel for schedule(static) num_threads(threads) if (threads > 1)
for (int row = 0; row < height; ++row) {
const size_t base = (size_t)row * (size_t)width * 3;
for (int column = 0; column < width * 3; ++column)
rgb8[base + (size_t)column] =
tone_map_srgb8_channel(hdr[base + (size_t)column], settings);
}
return 0;
}
#ifndef ENABLE_PNG
static int write_tonemapped_ppm(const char *path, const double *hdr, int width,
int height, const ToneMapSettings *settings)
static int write_ppm_rgb8(const char *path, const unsigned char *rgb8, int width,
int height)
{
FILE *file = fopen(path, "wb");
if (file == NULL) return -1;
fprintf(file, "P6\n%d %d\n255\n", width, height);
for (int i = 0; i < width * height * 3; ++i) {
const unsigned char value = tone_map_srgb8_channel(hdr[i], settings);
if (fwrite(&value, 1, 1, file) != 1) { fclose(file); return -1; }
}
return fclose(file) == 0 ? 0 : -1;
const size_t bytes = (size_t)width * height * 3;
int ok = fprintf(file, "P6\n%d %d\n255\n", width, height) >= 0 &&
fwrite(rgb8, 1, bytes, file) == bytes;
if (fclose(file) != 0)
ok = 0;
return ok ? 0 : -1;
}
#endif
int write_rgb8_image(const char *path, const unsigned char *rgb8, int width,
int height, const PngWriteSettings *settings)
{
if (path == NULL || rgb8 == NULL || width <= 0 || height <= 0)
return -1;
const size_t path_length = strlen(path);
#ifdef ENABLE_PNG
static int write_tonemapped_png(const char *path, const double *hdr, int width,
int height, const ToneMapSettings *settings)
if (path_length >= 4 && strcmp(path + path_length - 4, ".png") == 0)
return write_png_rgb8(path, rgb8, width, height, settings);
fputs("PNG output is enabled; use a .png output path.\n", stderr);
return -1;
#else
(void)settings;
if (path_length >= 4 && strcmp(path + path_length - 4, ".ppm") == 0)
return write_ppm_rgb8(path, rgb8, width, height);
fputs("PNG output is unavailable; use a .ppm output path or rebuild with libpng.\n",
stderr);
return -1;
#endif
}
#ifdef ENABLE_PNG
int write_png_rgb8(const char *path, const unsigned char *rgb8, int width,
int height, const PngWriteSettings *settings)
{
FILE *file = fopen(path, "wb");
png_structp png = NULL;
png_infop info = NULL;
unsigned char *pixels = NULL;
int result = -1;
if (file == NULL) return -1;
png = png_create_write_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
if (png == NULL) goto done;
info = png_create_info_struct(png);
if (info == NULL || setjmp(png_jmpbuf(png))) goto done;
pixels = malloc((size_t)width * height * 3);
if (pixels == NULL) goto done;
for (int i = 0; i < width * height * 3; ++i)
pixels[i] = tone_map_srgb8_channel(hdr[i], settings);
if (settings != NULL && settings->compression_level >= 0)
png_set_compression_level(png, settings->compression_level);
png_init_io(png, file);
png_set_IHDR(png, info, (png_uint_32)width, (png_uint_32)height, 8,
PNG_COLOR_TYPE_RGB, PNG_INTERLACE_NONE,
PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT);
png_write_info(png, info);
for (int row = 0; row < height; ++row)
png_write_row(png, &pixels[(size_t)row * width * 3]);
png_write_row(png, &rgb8[(size_t)row * width * 3]);
png_write_end(png, info);
result = 0;
done:
free(pixels);
png_destroy_write_struct(&png, &info);
if (fclose(file) != 0) result = -1;
return result;
@@ -871,19 +918,15 @@ done:
int write_tonemapped_image(const char *path, const double *hdr, int width,
int height, const ToneMapSettings *settings)
{
const size_t path_length = strlen(path);
#ifdef ENABLE_PNG
if (path_length >= 4 && strcmp(path + path_length - 4, ".png") == 0)
return write_tonemapped_png(path, hdr, width, height, settings);
fputs("PNG output is enabled; use a .png output path.\n", stderr);
return -1;
#else
if (path_length >= 4 && strcmp(path + path_length - 4, ".ppm") == 0)
return write_tonemapped_ppm(path, hdr, width, height, settings);
fputs("PNG output is unavailable; use a .ppm output path or rebuild with libpng.\n",
stderr);
return -1;
#endif
if (path == NULL || hdr == NULL || width <= 0 || height <= 0)
return -1;
unsigned char *rgb8 = malloc((size_t)width * height * 3);
if (rgb8 == NULL) return -1;
int result = -1;
if (tone_map_srgb8_image(hdr, rgb8, width, height, settings, 0) == 0)
result = write_rgb8_image(path, rgb8, width, height, NULL);
free(rgb8);
return result;
}
#ifdef ENABLE_HDR_OUTPUT
+39 -1
View File
@@ -1,6 +1,8 @@
#ifndef OPTICS_H
#define OPTICS_H
#include "fast_psf_fftw.h"
#include <stddef.h>
#include <stdio.h>
@@ -55,6 +57,15 @@ typedef struct FastPsfFftwState FastPsfFftwState;
* benchmarks/fast_mode_deposit_2026-09-18.md). The kernel is the pixel-area
* integral of the Moffat with alpha scaled by `supersample` and the same beta,
* so the box average keeps the requested FWHM/beta semantics. */
/* Lifecycle of the shared supersampled buffer. A fresh accumulator is CLEAN.
* Deposits mark it DIRTY; a successful FFTW resolve consumes and restores it to
* CLEAN so the next frame cannot inherit stale deposits. */
typedef enum {
FAST_PSF_BUFFER_CLEAN = 0,
FAST_PSF_BUFFER_DIRTY = 1
} FastPsfBufferState;
typedef struct {
double fwhm_pixels, moffat_beta, alpha_pixels, alpha_supersampled;
double relative_tail_fraction, min_y;
@@ -72,7 +83,9 @@ typedef struct {
double fftw_setup_seconds;
double fftw_kernel_seconds;
double fftw_frame_seconds; /* most recent resolve */
FastPsfFftwFrameTiming fftw_last_timing; /* breakdown of most recent resolve */
#endif
FastPsfBufferState buffer_state; /* shared-buffer lifecycle state */
int verbose;
} FastPsfAccumulator;
@@ -190,7 +203,32 @@ int psf_prepare_cached_event(PsfCachedEvent *event, double x, double y,
void splat_prepared_cached_event(double *hdr, int width, int height,
const PsfCachedEvent *event,
const PsfKernelCache *cache);
/* Writes PNG when built with libpng; non-libpng builds use PPM fallback. */
/* Write settings shared by the PNG encoder and the movie output queue. A
* negative compression_level selects the libpng/zlib default, preserving the
* historical byte output. */
typedef struct {
int compression_level;
} PngWriteSettings;
/* Parallel HDR -> 8-bit sRGB conversion. Row-outer and static, so the exact
* output bytes are independent of worker_count; worker_count <= 1 runs
* serially. Every output byte is written by exactly one worker. */
int tone_map_srgb8_image(const double *hdr, unsigned char *rgb8, int width,
int height, const ToneMapSettings *settings,
int worker_count);
/* Writes an already-tone-mapped sRGB8 buffer. write_png_rgb8 needs libpng and
* is declared only when available; write_rgb8_image dispatches on the path
* extension and uses the PPM fallback in non-libpng builds. */
int write_rgb8_image(const char *path, const unsigned char *rgb8, int width,
int height, const PngWriteSettings *settings);
#ifdef ENABLE_PNG
int write_png_rgb8(const char *path, const unsigned char *rgb8, int width,
int height, const PngWriteSettings *settings);
#endif
/* Compatibility wrapper: allocates an RGB8 buffer, applies the tone map, then
* writes it. Writes PNG when built with libpng; non-libpng builds use PPM. */
int write_tonemapped_image(const char *path, const double *hdr, int width,
int height, const ToneMapSettings *settings);
#ifdef ENABLE_HDR_OUTPUT
+57 -16
View File
@@ -24,42 +24,83 @@ static int write_tile(const char *directory, int ra_index, int dec_index,
return result ? -1 : 0;
}
static int tile_state(const StarCatalog *catalog, int ra, int dec) {
return catalog->tiles[(size_t)dec * CATALOG_ALL_SKY_RA_TILES + ra].state;
}
int main(void) {
char directory[] = "/tmp/catalog_prefetch_XXXXXX";
char path[4096];
StarCatalog catalog = {0};
unsigned char requested[CATALOG_ALL_SKY_TILE_COUNT] = {0};
CatalogPrefetchStats first = {0}, second = {0};
StarCatalog batched = {0};
int result = 1;
if (mkdtemp(directory) == NULL || write_tile(directory, 0, 90, 0.0) ||
write_tile(directory, 1, 90, 1.0) ||
catalog_load_all_sky(&catalog, directory))
goto done;
requested[90 * CATALOG_ALL_SKY_RA_TILES] = 1;
requested[90 * CATALOG_ALL_SKY_RA_TILES + 1] = 1;
requested[90 * CATALOG_ALL_SKY_RA_TILES + 2] = 1; /* Missing on purpose. */
if (catalog_prefetch_marked_tiles(&catalog, requested, 4, &first) ||
first.requested_tiles != 3 || first.newly_loaded_tiles != 2 ||
first.unavailable_tiles != 1 || first.newly_loaded_stars != 2 ||
catalog.count != 2 ||
catalog.tiles[90 * CATALOG_ALL_SKY_RA_TILES].state != 1 ||
catalog.tiles[90 * CATALOG_ALL_SKY_RA_TILES + 1].state != 1 ||
catalog.tiles[90 * CATALOG_ALL_SKY_RA_TILES + 2].state != -1) {
fputs("parallel catalog prefetch regression failed\n", stderr);
/* Two overlapping sets: A = {0,1}, B = {1,2}; the union is {0,1,2}, and
* tile 2 is intentionally missing from disk. */
CatalogTileSet set_a;
CatalogTileSet set_b;
CatalogTileSet union_set;
catalog_tile_set_clear(&set_a);
catalog_tile_set_clear(&set_b);
catalog_tile_set_clear(&union_set);
set_a.requested[90 * CATALOG_ALL_SKY_RA_TILES] = 1;
set_a.requested[90 * CATALOG_ALL_SKY_RA_TILES + 1] = 1;
set_b.requested[90 * CATALOG_ALL_SKY_RA_TILES + 1] = 1;
set_b.requested[90 * CATALOG_ALL_SKY_RA_TILES + 2] = 1;
for (int ra = 0; ra < 3; ++ra)
union_set.requested[90 * CATALOG_ALL_SKY_RA_TILES + ra] =
set_a.requested[90 * CATALOG_ALL_SKY_RA_TILES + ra] ||
set_b.requested[90 * CATALOG_ALL_SKY_RA_TILES + ra];
if (catalog_tile_set_count(&set_a) != 2 ||
catalog_tile_set_count(&set_b) != 2 ||
catalog_tile_set_count(&union_set) != 3) {
fputs("catalog tile-set union regression failed\n", stderr);
goto done;
}
Star *first_tile = catalog.tiles[90 * CATALOG_ALL_SKY_RA_TILES].stars;
if (catalog_prefetch_marked_tiles(&catalog, requested, 2, &second) ||
CatalogPrefetchStats first = {0};
if (catalog_prefetch_tile_set(&catalog, &union_set, 4,
CATALOG_PREFETCH_DEFAULT_BATCH_TILES, &first) ||
first.requested_tiles != 3 || first.newly_loaded_tiles != 2 ||
first.unavailable_tiles != 1 || first.newly_loaded_stars != 2 ||
catalog.count != 2 || tile_state(&catalog, 0, 90) != 1 ||
tile_state(&catalog, 1, 90) != 1 || tile_state(&catalog, 2, 90) != -1) {
fputs("parallel catalog union prefetch regression failed\n", stderr);
goto done;
}
Star *first_tile =
catalog.tiles[90 * CATALOG_ALL_SKY_RA_TILES].stars;
/* Re-prefetching the same union must read nothing and must not re-mark the
* unavailable tile. */
CatalogPrefetchStats second = {0};
if (catalog_prefetch_tile_set(&catalog, &union_set, 2,
CATALOG_PREFETCH_DEFAULT_BATCH_TILES, &second) ||
second.requested_tiles != 3 || second.newly_loaded_tiles != 0 ||
second.unavailable_tiles != 0 || second.newly_loaded_stars != 0 ||
catalog.count != 2 ||
catalog.tiles[90 * CATALOG_ALL_SKY_RA_TILES].stars != first_tile) {
fputs("catalog prefetch cache reuse regression failed\n", stderr);
fputs("catalog union prefetch cache reuse regression failed\n", stderr);
goto done;
}
/* A batch size of one must reach the same final cache as the default. */
if (catalog_load_all_sky(&batched, directory) ||
catalog_prefetch_tile_set(&batched, &union_set, 4, 1, NULL) ||
batched.count != catalog.count ||
tile_state(&batched, 0, 90) != 1 || tile_state(&batched, 1, 90) != 1 ||
tile_state(&batched, 2, 90) != -1) {
fputs("catalog single-tile batching regression failed\n", stderr);
goto done;
}
result = 0;
done:
catalog_destroy(&catalog);
catalog_destroy(&batched);
for (int ra = 0; ra < 3; ++ra) {
const int written = snprintf(path, sizeof path, "%s/tile_ra%03d_dec090.csv",
directory, ra);
+249 -16
View File
@@ -16,6 +16,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#ifndef INT_MAX
#define INT_MAX 2147483647
@@ -35,18 +36,6 @@ static PointSpreadFunction default_psf(void)
return psf;
}
static uint64_t hash_bytes(const double *values, size_t count)
{
const unsigned char *bytes = (const unsigned char *)values;
const size_t nbytes = count * sizeof *values;
uint64_t hash = 1469598103934665603ULL;
for (size_t i = 0; i < nbytes; ++i) {
hash ^= bytes[i];
hash *= 1099511628211ULL;
}
return hash;
}
typedef enum {
SCENE_CENTER_WHITE,
SCENE_DISTINCT_RGB,
@@ -129,31 +118,46 @@ static int run_compare(const char *name, int width, int height,
acc.supersampled_height * 3;
double *fftw_hdr = malloc(hdr_count * sizeof *fftw_hdr);
double *spatial_hdr = malloc(hdr_count * sizeof *spatial_hdr);
if (fftw_hdr == NULL || spatial_hdr == NULL) {
double *snapshot = malloc(buffer_count * sizeof *snapshot);
if (fftw_hdr == NULL || spatial_hdr == NULL || snapshot == NULL) {
fprintf(stderr, "FAIL: %s: HDR allocation failed\n", name);
++g_failures;
free(fftw_hdr);
free(spatial_hdr);
free(snapshot);
fast_psf_accumulator_destroy(&acc);
return -1;
}
for (size_t i = 0; i < hdr_count; ++i)
fftw_hdr[i] = spatial_hdr[i] = prefill ? 0.25 : 0.0;
fill_scene(&acc, scene, width, height);
const uint64_t buffer_before = hash_bytes(acc.buffer, buffer_count);
memcpy(snapshot, acc.buffer, buffer_count * sizeof *snapshot);
if (fast_psf_accumulator_resolve(&acc, fftw_hdr, 4)) {
fprintf(stderr, "FAIL: %s: FFTW resolve failed\n", name);
++g_failures;
goto cleanup;
}
/* The consuming resolve must leave the shared buffer all-zero so the next
* frame starts clean. */
for (size_t i = 0; i < buffer_count; ++i)
if (acc.buffer[i] != 0.0) {
fprintf(stderr,
"FAIL: %s: consuming resolve left nonzero residue at %zu\n", name,
i);
++g_failures;
goto cleanup;
}
memcpy(acc.buffer, snapshot, buffer_count * sizeof *snapshot);
if (fast_psf_accumulator_resolve_spatial_reference(&acc, spatial_hdr, 4)) {
fprintf(stderr, "FAIL: %s: spatial reference resolve failed\n", name);
++g_failures;
goto cleanup;
}
if (hash_bytes(acc.buffer, buffer_count) != buffer_before) {
fprintf(stderr, "FAIL: %s: resolve mutated the impulse buffer\n", name);
if (memcmp(snapshot, acc.buffer, buffer_count * sizeof *snapshot) != 0) {
fprintf(stderr,
"FAIL: %s: spatial reference resolve mutated the impulse buffer\n",
name);
++g_failures;
goto cleanup;
}
@@ -221,6 +225,7 @@ static int run_compare(const char *name, int width, int height,
cleanup:
free(fftw_hdr);
free(spatial_hdr);
free(snapshot);
fast_psf_accumulator_destroy(&acc);
return g_failures == 0 ? 0 : -1;
}
@@ -335,11 +340,239 @@ static void test_channel_isolation(void)
fast_psf_accumulator_destroy(&acc);
}
/* Reusing one accumulator across frames must never leak frame N-1 deposits
* into frame N. Frame 1 deposits an impulse; frame 2 deposits nothing and
* must resolve to all zeros. A third frame with a different impulse must not
* contain the first impulse. */
static void test_cross_frame_no_residue(FastPsfDeposit deposit)
{
const PointSpreadFunction psf = default_psf();
const double relative_tail = 1e-8;
const int width = 19, height = 15, supersample = 2;
FastPsfAccumulator acc = {0};
if (fast_psf_accumulator_init(&acc, width, height, supersample, deposit, &psf,
relative_tail, 0.0, 1)) {
fail("cross-frame init");
return;
}
const size_t count = (size_t)width * height * 3;
double *hdr = calloc(count, sizeof *hdr);
double *reference = calloc(count, sizeof *reference);
if (hdr == NULL || reference == NULL) {
fail("cross-frame allocation");
free(hdr);
free(reference);
fast_psf_accumulator_destroy(&acc);
return;
}
const LinearRgb white = {1.0, 1.0, 1.0};
/* Frame 1: impulse at a known pixel. */
fast_psf_accumulator_deposit(&acc, 4.5, 4.5, white, 1.0);
if (fast_psf_accumulator_resolve(&acc, hdr, 2))
fail("cross-frame frame 1 resolve");
if (!(hdr[3 * (4 * width + 4)] > 0.0))
fail("cross-frame frame 1 peak missing");
/* Frame 2: no deposit; every output channel must be exactly zero. */
memset(hdr, 0, count * sizeof *hdr);
if (fast_psf_accumulator_resolve(&acc, hdr, 2))
fail("cross-frame frame 2 resolve");
for (size_t i = 0; i < count; ++i)
if (hdr[i] != 0.0) {
fail("cross-frame frame 2 inherited stale deposits");
break;
}
/* Frame 3: a different impulse. Its result must equal a freshly built
* accumulator given only that impulse, proving frame 1 left no residue. */
FastPsfAccumulator fresh = {0};
if (fast_psf_accumulator_init(&fresh, width, height, supersample, deposit,
&psf, relative_tail, 0.0, 1)) {
fail("cross-frame reference init");
free(hdr);
free(reference);
fast_psf_accumulator_destroy(&acc);
return;
}
memset(hdr, 0, count * sizeof *hdr);
memset(reference, 0, count * sizeof *reference);
fast_psf_accumulator_deposit(&acc, 12.5, 9.5, white, 1.0);
fast_psf_accumulator_deposit(&fresh, 12.5, 9.5, white, 1.0);
if (fast_psf_accumulator_resolve(&acc, hdr, 2) ||
fast_psf_accumulator_resolve(&fresh, reference, 2))
fail("cross-frame frame 3 resolve");
for (size_t i = 0; i < count; ++i)
if (hdr[i] != reference[i]) {
fail("cross-frame frame 3 retained the frame 1 impulse");
break;
}
free(hdr);
free(reference);
fast_psf_accumulator_destroy(&fresh);
fast_psf_accumulator_destroy(&acc);
}
static int create_tiny_accumulator(FastPsfAccumulator *acc, int width,
int height)
{
PointSpreadFunction psf = default_psf();
psf.fwhm_pixels = 0.8;
return fast_psf_accumulator_init(acc, width, height, 1,
FAST_PSF_DEPOSIT_NEAREST, &psf, 1e-6, 0.0,
1);
}
/* Rewrites the sidecar without the line beginning `key=`. */
static int meta_drop_line(const char *path, const char *key)
{
FILE *file = fopen(path, "r");
if (file == NULL)
return -1;
char buffer[2048];
const size_t length = fread(buffer, 1, sizeof buffer - 1, file);
fclose(file);
buffer[length] = '\0';
const size_t key_length = strlen(key);
char out[2048];
size_t written = 0;
char *line = buffer;
while (line != NULL && *line != '\0' && written + 1 < sizeof out) {
char *newline = strchr(line, '\n');
const size_t line_length =
newline != NULL ? (size_t)(newline - line) : strlen(line);
if (!(line_length >= key_length + 1 &&
strncmp(line, key, key_length) == 0 && line[key_length] == '=')) {
memcpy(out + written, line, line_length);
written += line_length;
if (newline != NULL)
out[written++] = '\n';
}
line = newline != NULL ? newline + 1 : NULL;
}
out[written] = '\0';
file = fopen(path, "w");
if (file == NULL)
return -1;
const int ok = fputs(out, file) != EOF && fclose(file) == 0;
return ok ? 0 : -1;
}
/* Replaces the first character after `key=` with `replacement`. */
static int meta_corrupt_value(const char *path, const char *key,
char replacement)
{
FILE *file = fopen(path, "r");
if (file == NULL)
return -1;
char buffer[2048];
const size_t length = fread(buffer, 1, sizeof buffer - 1, file);
fclose(file);
buffer[length] = '\0';
char *position = strstr(buffer, key);
if (position == NULL || position[strlen(key)] != '=')
return -1;
char *value = &position[strlen(key) + 1];
if (*value == '\0' || *value == '\n')
return -1;
*value = replacement;
file = fopen(path, "w");
if (file == NULL)
return -1;
const int ok = fputs(buffer, file) != EOF && fclose(file) == 0;
return ok ? 0 : -1;
}
/* Tiny estimate/measure/wisdom-update/wisdom round trip; no large planning. */
static void test_wisdom_modes(void)
{
const char *path = "/tmp/gr_fast_fftw_wisdom_test";
char meta[256];
snprintf(meta, sizeof meta, "%s.meta", path);
unlink(path);
unlink(meta);
FastPsfAccumulator acc = {0};
if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_ESTIMATE, NULL) ||
create_tiny_accumulator(&acc, 16, 12)) {
fail("plan mode estimate");
}
fast_psf_accumulator_destroy(&acc);
if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_MEASURE, NULL) ||
create_tiny_accumulator(&acc, 16, 12)) {
fail("plan mode measure");
}
fast_psf_accumulator_destroy(&acc);
if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM_UPDATE, path) ||
create_tiny_accumulator(&acc, 16, 12)) {
fail("plan mode wisdom-update");
}
fast_psf_accumulator_destroy(&acc);
if (access(path, F_OK) != 0 || access(meta, F_OK) != 0)
fail("wisdom-update did not write wisdom and meta");
if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM, path) ||
create_tiny_accumulator(&acc, 16, 12)) {
fail("plan mode wisdom import");
}
fast_psf_accumulator_destroy(&acc);
/* A different size must be rejected rather than silently replanned. */
if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM, path) == 0 &&
create_tiny_accumulator(&acc, 22, 12) == 0)
fail("mismatched wisdom was not rejected");
fast_psf_accumulator_destroy(&acc);
/* A wisdom file without its sidecar is a miss, not a silent replan. */
unlink(meta);
if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM, path) == 0 &&
create_tiny_accumulator(&acc, 16, 12) == 0)
fail("wisdom without meta was accepted");
fast_psf_accumulator_destroy(&acc);
/* Regenerate, then corrupt a numeric field: must be rejected. */
if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM_UPDATE, path) ||
create_tiny_accumulator(&acc, 16, 12))
fail("wisdom-update regeneration");
fast_psf_accumulator_destroy(&acc);
if (meta_corrupt_value(meta, "fft_width", 'x') != 0)
fail("meta corruption setup");
if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM, path) == 0 &&
create_tiny_accumulator(&acc, 16, 12) == 0)
fail("malformed wisdom field was accepted");
fast_psf_accumulator_destroy(&acc);
/* Regenerate, then drop a required field: must be rejected. */
if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM_UPDATE, path) ||
create_tiny_accumulator(&acc, 16, 12))
fail("wisdom-update second regeneration");
fast_psf_accumulator_destroy(&acc);
if (meta_drop_line(meta, "workers") != 0)
fail("meta drop setup");
if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM, path) == 0 &&
create_tiny_accumulator(&acc, 16, 12) == 0)
fail("wisdom missing a required field was accepted");
fast_psf_accumulator_destroy(&acc);
/* wisdom-update into an unwritable directory must fail initialization. */
if (fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_WISDOM_UPDATE,
"/nonexistent-dir-xyz/wis") == 0 &&
create_tiny_accumulator(&acc, 16, 12) == 0)
fail("wisdom-update into an unwritable directory succeeded");
fast_psf_accumulator_destroy(&acc);
fast_psf_fftw_configure(FAST_PSF_FFTW_PLAN_ESTIMATE, NULL);
unlink(path);
unlink(meta);
}
int main(void)
{
test_next_smooth_size();
test_no_wraparound_and_empty();
test_channel_isolation();
test_cross_frame_no_residue(FAST_PSF_DEPOSIT_NEAREST);
test_cross_frame_no_residue(FAST_PSF_DEPOSIT_BILINEAR);
test_wisdom_modes();
const int sizes[][2] = {{17, 13}, {16, 16}, {23, 31}, {33, 17}};
const int supersamples[] = {1, 2, 3, 4};
+11 -10
View File
@@ -82,7 +82,7 @@ int main(void) {
const size_t images =
frame_splat_catalog(&mesh, &catalog, hdr, width, height, test_exposure,
&psf, NULL, INFINITY, 1.0, psf_relative_tail,
0.0, 0, 1, NULL, NULL, NULL, NULL);
0.0, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL, NULL);
if (images != 1 || hdr[3 * (50 * width + 50)] <= 0.0) {
fputs("flat-space inverse lens-map regression failed\n", stderr);
goto done;
@@ -104,7 +104,7 @@ int main(void) {
loaded_map.frames[0].mesh.vertex_count != mesh.vertex_count ||
frame_splat_catalog(&loaded_map.frames[0].mesh, &catalog, roundtrip_hdr,
width, height, test_exposure, &psf, NULL, INFINITY,
1.0, psf_relative_tail, 0.0, 0, 1, NULL, NULL, NULL, NULL) != images) {
1.0, psf_relative_tail, 0.0, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL, NULL) != images) {
fputs("lens-map round-trip regression failed\n", stderr);
free(roundtrip_hdr); lens_map_destroy(&loaded_map); unlink(lens_map_path);
goto done;
@@ -140,7 +140,7 @@ int main(void) {
PsfSplatStats min_y_stats = {0};
if (frame_splat_catalog(&mesh, &catalog, hdr, width, height, test_exposure,
&psf, NULL, INFINITY, 1.0, psf_relative_tail,
1e300, 0, 1, NULL, &min_y_stats, NULL, NULL) != 1 ||
1e300, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, &min_y_stats, NULL, NULL, NULL) != 1 ||
min_y_stats.discarded_below_min_y != 1 ||
hdr[3 * (50 * width + 50)] != 0.0) {
fputs("PSF minimum-Y discard regression failed\n", stderr);
@@ -159,11 +159,11 @@ int main(void) {
omp_set_num_threads(1);
const size_t serial_images = frame_splat_catalog(
&mesh, &catalog, serial_hdr, width, height, test_exposure, &psf, NULL,
INFINITY, 1.0, psf_relative_tail, 0.0, 0, 1, NULL, NULL, NULL, NULL);
INFINITY, 1.0, psf_relative_tail, 0.0, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL, NULL);
omp_set_num_threads(4);
const size_t parallel_images = frame_splat_catalog(
&mesh, &catalog, parallel_hdr, width, height, test_exposure, &psf, NULL,
INFINITY, 1.0, psf_relative_tail, 0.0, 0, 1, NULL, NULL, NULL, NULL);
INFINITY, 1.0, psf_relative_tail, 0.0, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL, NULL);
omp_set_num_threads(original_threads);
for (int value = 0; value < width * height * 3; ++value)
if (fabs(serial_hdr[value] - parallel_hdr[value]) >
@@ -397,8 +397,8 @@ int main(void) {
PsfSplatStats fast_stats = {0};
const size_t fast_images = frame_splat_catalog(
&mesh, &catalog, fast_hdr, width, height, test_exposure, &psf, NULL,
INFINITY, 1.0, psf_relative_tail, 0.0, 0, 1, NULL, &fast_stats, NULL,
&fast);
INFINITY, 1.0, psf_relative_tail, 0.0, 0, 1,
FRAME_CATALOG_PREFETCH_FRAME, NULL, &fast_stats, NULL, &fast, NULL);
if (fast_images != 1 || fast_stats.discarded_below_min_y != 0) {
fputs("fast-mode frame splat regression failed\n", stderr);
fast_ok = 0;
@@ -458,8 +458,9 @@ int main(void) {
frame_lens_mesh_trace(&fine_mesh, &spacetime, &observer, &trace) ||
frame_splat_catalog(&fine_mesh, &fine_catalog, hdr, width, height,
test_exposure, &psf, NULL, INFINITY, 1.0,
psf_relative_tail, 0.0, 0, 1, NULL,
NULL, NULL, NULL) != 1) {
psf_relative_tail, 0.0, 0, 1,
FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL,
NULL) != 1) {
fputs("fine source-triangle containment regression failed\n", stderr);
frame_lens_mesh_destroy(&fine_mesh);
goto done;
@@ -503,7 +504,7 @@ int main(void) {
memset(hdr, 0, (size_t)width * height * 3 * sizeof *hdr);
if (frame_splat_catalog(&thin_mesh, &thin_catalog, hdr, width, height,
test_exposure, &psf, NULL, 1.0, 1.0,
psf_relative_tail, 0.0, 0, 1, NULL, NULL, NULL, NULL) != 1 ||
psf_relative_tail, 0.0, 0, 1, FRAME_CATALOG_PREFETCH_FRAME, NULL, NULL, NULL, NULL, NULL) != 1 ||
hdr[3 * (43 * width + 43)] <= 0.0) {
fputs("thin source-triangle inverse-map regression failed\n", stderr);
goto done;
+310
View File
@@ -0,0 +1,310 @@
#define _POSIX_C_SOURCE 200809L
#include "movie_output.h"
#include <math.h>
#include <omp.h>
#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#ifdef ENABLE_PNG
#include <png.h>
#endif
static int failures = 0;
static void check(int condition, const char *message) {
if (!condition) {
fprintf(stderr, "FAIL: %s\n", message);
++failures;
}
}
#define MAX_JOBS 64
#define JOB_WIDTH 16
#define JOB_HEIGHT 16
#define JOB_BYTES ((size_t)JOB_WIDTH * JOB_HEIGHT * 3)
typedef struct {
int fail_at;
int delay_ms;
size_t count;
size_t order[MAX_JOBS];
unsigned char pixels[MAX_JOBS][JOB_BYTES];
} MockWriter;
static int mock_write(void *context, const MovieOutputJob *job,
const PngWriteSettings *settings) {
(void)settings;
MockWriter *mock = context;
if (mock->count < MAX_JOBS) {
mock->order[mock->count] = job->frame_id;
memcpy(mock->pixels[mock->count], job->clean_rgb8, JOB_BYTES);
}
++mock->count;
if (mock->delay_ms > 0) {
const struct timespec delay = {.tv_sec = mock->delay_ms / 1000,
.tv_nsec =
(long)(mock->delay_ms % 1000) * 1000000L};
nanosleep(&delay, NULL);
}
return mock->fail_at >= 0 && (int)job->frame_id == mock->fail_at ? -1 : 0;
}
static unsigned char *make_rgb8(size_t frame_id) {
unsigned char *rgb8 = malloc(JOB_BYTES);
if (rgb8 == NULL)
return NULL;
for (size_t i = 0; i < JOB_BYTES; ++i)
rgb8[i] = (unsigned char)((frame_id * 31 + i) & 0xff);
return rgb8;
}
static MovieOutputJob make_job(size_t frame_id) {
MovieOutputJob job;
memset(&job, 0, sizeof job);
job.frame_id = frame_id;
job.width = JOB_WIDTH;
job.height = JOB_HEIGHT;
job.images = frame_id + 1;
job.catalog_stars = 7;
job.clean_rgb8 = make_rgb8(frame_id);
return job;
}
/* Order and pixel fidelity for capacity 1 and 2. */
static void test_order_and_pixels(size_t capacity) {
MovieOutputQueue queue;
MockWriter mock;
memset(&mock, 0, sizeof mock);
mock.fail_at = -1;
const PngWriteSettings settings = {-1};
if (movie_output_queue_init(&queue, capacity, &settings)) {
check(0, "order: queue init");
return;
}
movie_output_queue_set_writer(&queue, mock_write, &mock);
const size_t frames = 5;
int all_ok = 1;
for (size_t f = 0; f < frames; ++f) {
MovieOutputJob job = make_job(f);
if (job.clean_rgb8 == NULL ||
movie_output_queue_submit(&queue, &job, NULL)) {
all_ok = 0;
free(job.clean_rgb8);
break;
}
}
const int finish = movie_output_queue_finish(&queue);
check(all_ok && finish == 0, "order: submit/finish");
check(mock.count == frames, "order: writer saw every job");
for (size_t f = 0; f < frames && f < MAX_JOBS; ++f) {
check(mock.order[f] == f, "order: frames written in submit order");
unsigned char *expected = make_rgb8(f);
if (expected != NULL) {
check(memcmp(mock.pixels[f], expected, JOB_BYTES) == 0,
"order: pixel payload preserved");
free(expected);
}
}
movie_output_queue_destroy(&queue);
}
/* A slow writer must push back on the producer at capacity 1. */
static void test_backpressure(void) {
MovieOutputQueue queue;
MockWriter mock;
memset(&mock, 0, sizeof mock);
mock.fail_at = -1;
mock.delay_ms = 40;
const PngWriteSettings settings = {-1};
if (movie_output_queue_init(&queue, 1, &settings)) {
check(0, "backpressure: queue init");
return;
}
movie_output_queue_set_writer(&queue, mock_write, &mock);
double total_wait = 0.0;
int all_ok = 1;
for (size_t f = 0; f < 5; ++f) {
MovieOutputJob job = make_job(f);
double wait = 0.0;
if (movie_output_queue_submit(&queue, &job, &wait)) {
all_ok = 0;
free(job.clean_rgb8);
break;
}
total_wait += wait;
}
check(all_ok && movie_output_queue_finish(&queue) == 0,
"backpressure: submit/finish");
check(total_wait > 0.05, "backpressure: producer waited on a full queue");
movie_output_queue_destroy(&queue);
}
/* Writer failure at frame N must propagate, unblock the producer, and join
* cleanly without losing the ownership contract. */
static void test_writer_failure(void) {
MovieOutputQueue queue;
MockWriter mock;
memset(&mock, 0, sizeof mock);
mock.fail_at = 3;
const PngWriteSettings settings = {-1};
if (movie_output_queue_init(&queue, 2, &settings)) {
check(0, "failure: queue init");
return;
}
movie_output_queue_set_writer(&queue, mock_write, &mock);
int saw_failure = 0;
for (size_t f = 0; f < 8; ++f) {
MovieOutputJob job = make_job(f);
if (movie_output_queue_submit(&queue, &job, NULL)) {
/* The queue no longer owns these buffers. */
free(job.clean_rgb8);
saw_failure = 1;
break;
}
}
const int finish = movie_output_queue_finish(&queue);
check(saw_failure, "failure: a later submit reported the writer error");
check(finish != 0, "failure: finish reports the recorded error");
check(movie_output_queue_failed(&queue), "failure: failed flag persists");
movie_output_queue_destroy(&queue);
}
/* Empty finish and never-submitted init must not hang or leak. */
static void test_empty_paths(void) {
const PngWriteSettings settings = {-1};
MovieOutputQueue queue;
if (movie_output_queue_init(&queue, 2, &settings)) {
check(0, "empty: queue init");
return;
}
check(movie_output_queue_finish(&queue) == 0, "empty: finish with no jobs");
movie_output_queue_destroy(&queue);
if (movie_output_queue_init(&queue, 1, &settings)) {
check(0, "empty: second init");
return;
}
movie_output_queue_destroy(&queue);
}
#ifdef ENABLE_PNG
static int decode_png_rgb8(const char *path, unsigned char *out, int width,
int height) {
FILE *file = fopen(path, "rb");
if (file == NULL)
return -1;
png_structp png =
png_create_read_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
png_infop info = png != NULL ? png_create_info_struct(png) : NULL;
int ok = 0;
if (png != NULL && info != NULL && !setjmp(png_jmpbuf(png))) {
png_init_io(png, file);
png_read_info(png, info);
for (int row = 0; row < height; ++row)
png_read_row(png, &out[(size_t)row * width * 3], NULL);
png_read_end(png, info);
ok = 1;
}
fclose(file);
png_destroy_read_struct(&png, &info, NULL);
return ok ? 0 : -1;
}
/* The default writer's clean and mesh files must decode to the submitted
* payloads. */
static void test_default_writer_success(void) {
char directory[] = "/tmp/movie_output_XXXXXX";
if (mkdtemp(directory) == NULL) {
check(0, "default success: mkdtemp");
return;
}
char clean_path[PATH_MAX];
char mesh_path[PATH_MAX];
snprintf(clean_path, sizeof clean_path, "%s/frame_000000.png", directory);
snprintf(mesh_path, sizeof mesh_path, "%s/frame_000000_mesh.png", directory);
const PngWriteSettings settings = {-1};
MovieOutputQueue queue;
if (movie_output_queue_init(&queue, 1, &settings)) {
check(0, "default success: queue init");
rmdir(directory);
return;
}
MovieOutputJob job = make_job(0);
job.draw_mesh = 1;
snprintf(job.output_path, sizeof job.output_path, "%s", clean_path);
snprintf(job.mesh_path, sizeof job.mesh_path, "%s", mesh_path);
job.mesh_rgb8 = make_rgb8(99);
int submitted = job.clean_rgb8 != NULL && job.mesh_rgb8 != NULL &&
movie_output_queue_submit(&queue, &job, NULL) == 0;
if (!submitted) {
free(job.clean_rgb8);
free(job.mesh_rgb8);
}
check(submitted && movie_output_queue_finish(&queue) == 0,
"default success: submit/finish");
unsigned char clean_decoded[JOB_BYTES];
unsigned char mesh_decoded[JOB_BYTES];
unsigned char *clean_expected = make_rgb8(0);
unsigned char *mesh_expected = make_rgb8(99);
const int decoded_ok =
clean_expected != NULL && mesh_expected != NULL &&
decode_png_rgb8(clean_path, clean_decoded, JOB_WIDTH, JOB_HEIGHT) == 0 &&
decode_png_rgb8(mesh_path, mesh_decoded, JOB_WIDTH, JOB_HEIGHT) == 0;
check(decoded_ok && memcmp(clean_decoded, clean_expected, JOB_BYTES) == 0,
"default success: clean pixels");
check(decoded_ok && memcmp(mesh_decoded, mesh_expected, JOB_BYTES) == 0,
"default success: mesh pixels");
free(clean_expected);
free(mesh_expected);
unlink(clean_path);
unlink(mesh_path);
rmdir(directory);
movie_output_queue_destroy(&queue);
}
#endif
/* The default writer must fail under a real filesystem error. */
static void test_default_writer_failure(void) {
MovieOutputQueue queue;
const PngWriteSettings settings = {-1};
if (movie_output_queue_init(&queue, 1, &settings)) {
check(0, "default failure: queue init");
return;
}
MovieOutputJob job = make_job(0);
snprintf(job.output_path, sizeof job.output_path,
"/nonexistent-directory-xyz/frame.png");
if (job.clean_rgb8 == NULL || movie_output_queue_submit(&queue, &job, NULL)) {
free(job.clean_rgb8);
check(0, "default failure: submit");
movie_output_queue_destroy(&queue);
return;
}
check(movie_output_queue_finish(&queue) != 0,
"default failure: finish reports unwritable path");
movie_output_queue_destroy(&queue);
}
int main(void) {
test_order_and_pixels(1);
test_order_and_pixels(2);
test_backpressure();
test_writer_failure();
test_empty_paths();
#ifdef ENABLE_PNG
test_default_writer_success();
#endif
test_default_writer_failure();
if (failures != 0) {
fprintf(stderr, "%d movie-output failure(s)\n", failures);
return 1;
}
puts("movie-output tests passed");
return 0;
}
+95
View File
@@ -4,11 +4,20 @@
* implementation; no formula is duplicated here. The test needs neither a
* catalog, ray tracing, a GPU, nor image files, and it builds in both the
* ENABLE_PNG=1 and ENABLE_PNG=0 configurations. */
#define _POSIX_C_SOURCE 200809L
#include "optics.h"
#include <math.h>
#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#ifdef ENABLE_PNG
#include <png.h>
#endif
static int failures = 0;
@@ -116,6 +125,92 @@ int main(void)
tone_map_srgb8_channel(0.5, &reinhard),
"softclip is brighter than reinhard at x=0.5");
/* Parallel HDR -> RGB8 conversion must be byte-identical for any worker
* count; the display transfer and quantization stay unchanged. */
{
const int width = 7, height = 5;
const size_t count = (size_t)width * height * 3;
double *hdr = malloc(count * sizeof *hdr);
unsigned char *one = malloc(count);
unsigned char *two = malloc(count);
unsigned char *four = malloc(count);
if (hdr == NULL || one == NULL || two == NULL || four == NULL) {
check(0, "parallel tone-map allocation");
} else {
for (size_t i = 0; i < count; ++i)
hdr[i] = (double)(i % 17) * 0.25 - 0.5;
check(tone_map_srgb8_image(hdr, one, width, height, &softclip2, 1) == 0,
"tone_map_srgb8_image serial");
check(tone_map_srgb8_image(hdr, two, width, height, &softclip2, 2) == 0,
"tone_map_srgb8_image 2 workers");
check(tone_map_srgb8_image(hdr, four, width, height, &softclip2, 4) == 0,
"tone_map_srgb8_image 4 workers");
check(memcmp(one, two, count) == 0 &&
memcmp(one, four, count) == 0,
"parallel tone map changes output bytes");
for (size_t i = 0; i < count; ++i)
if (one[i] != tone_map_srgb8_channel(hdr[i], &softclip2)) {
check(0, "parallel tone map disagrees with channel function");
break;
}
}
free(hdr);
free(one);
free(two);
free(four);
}
#ifdef ENABLE_PNG
/* The wrapper's PNG pixels must decode back to the same RGB8 bytes the
* parallel converter produced. */
{
const int width = 9, height = 4;
const size_t count = (size_t)width * height * 3;
double *hdr = malloc(count * sizeof *hdr);
unsigned char *expected = malloc(count);
unsigned char *decoded = calloc(count, 1);
char template_path[] = "/tmp/tone_map_XXXXXX";
char path[64];
const int fd = mkstemp(template_path);
snprintf(path, sizeof path, "%s.png", template_path);
if (hdr == NULL || expected == NULL || decoded == NULL || fd < 0) {
check(0, "PNG round-trip allocation");
} else {
close(fd);
for (size_t i = 0; i < count; ++i)
hdr[i] = (double)(i % 23) * 0.11;
tone_map_srgb8_image(hdr, expected, width, height, &softclip2, 2);
check(write_tonemapped_image(path, hdr, width, height, &softclip2) == 0,
"write_tonemapped_image wrapper writes PNG");
FILE *file = fopen(path, "rb");
png_structp png = file ? png_create_read_struct(
PNG_LIBPNG_VER_STRING, NULL, NULL, NULL)
: NULL;
png_infop info = png ? png_create_info_struct(png) : NULL;
volatile int decode_ok = 0;
if (png == NULL || info == NULL || setjmp(png_jmpbuf(png))) {
check(0, "PNG decode setup");
} else {
png_init_io(png, file);
png_read_info(png, info);
for (int row = 0; row < height; ++row)
png_read_row(png, &decoded[(size_t)row * width * 3], NULL);
png_read_end(png, info);
decode_ok = 1;
}
if (file) fclose(file);
png_destroy_read_struct(&png, &info, NULL);
check(decode_ok && memcmp(decoded, expected, count) == 0,
"wrapper PNG pixels differ from parallel RGB8");
unlink(path);
unlink(template_path);
}
free(hdr);
free(expected);
free(decoded);
}
#endif
if (failures != 0) {
fprintf(stderr, "%d tone-map assertion(s) failed\n", failures);
return EXIT_FAILURE;
+53 -1
View File
@@ -168,6 +168,28 @@ shifts change the brightness. The trajectory generator is a flat-spacetime
example; other camera motions are supplied through observer-track CSVs.
Output is an image sequence; video encoding is a separate step.
An all-sky movie prefetches the union of every finalized frame's requested
one-degree tiles exactly once (one `Movie catalog prefetch: ...` line) and then
renders every frame from the immutable cache, instead of re-running the
per-frame prefetch scan and log. A single-frame render keeps its per-frame
prefetch. The prefetch reads at most 512 unseen tiles per parallel batch, so an
unbounded all-sky union never stages an unbounded temporary star copy.
Movie PNG encoding and writing run on one bounded writer thread and overlap
the next frame's ray/catalog/FFTW work. The default bounds two queued frames
plus the one the writer is currently processing (at most three jobs in memory).
The same queue also serves multi-frame imported `.grlens` maps. `--png-compression-level N`
sets the zlib/libpng level in `0..9`; the default is libpng's own default.
`--fast-mode` never changes it silently, so a preview movie can opt into
`--png-compression-level 1` explicitly. `--movie-output-workers shared`
(default) lets the writer share the machine with the render pool.
`--movie-output-workers reserve-one` shrinks the render OpenMP pool by one core
for the whole movie render (ray tracing, catalog work, and the fast-mode FFTW
plans all see the reduced count); it is applied before the fast accumulator is
built so the FFTW plans actually use the reduced worker count, not only while
the writer is active. A writer error is recorded and fails the render rather
than silently dropping frames.
Movie rays from all frames share a newest-to-oldest coordinate-time sweep.
`--slab-duration` sets its time-slab width (default `64`). The current analytic
backends use logical slabs without metric I/O; [Nmesh](https://github.com/nmeshsource/nmesh) metric loading remains
@@ -462,6 +484,19 @@ substantially but costs a much longer one-time plan for large frames, as
recorded in
[benchmarks/fast_mode_fftw_2026-09-25.md](benchmarks/fast_mode_fftw_2026-09-25.md).
Planning can be selected explicitly. `--fast-fftw-plan measure` uses
`FFTW_MEASURE`. `--fast-fftw-plan wisdom-update --fast-fftw-wisdom FILE`
measures once and writes reusable wisdom plus a `FILE.meta` sidecar recording
FFTW version, precision, supersample, FFT dimensions, kernel radius, and worker
count. Each file is written to a unique temporary and renamed into place
individually; the pair is not a transactional update, and the sidecar is
committed last, so a wisdom without a matching sidecar is treated as a miss.
`--fast-fftw-plan wisdom --fast-fftw-wisdom FILE` imports
that wisdom and creates the plans with `FFTW_WISDOM_ONLY`; if the file, size,
supersample, worker count, version, or precision does not match, it fails with a
diagnostic instead of silently replanning. Wisdom writes go to a temporary file
and are renamed into place, so a failed write never leaves a half file.
## Progress and diagnostics
The PSF-cache completion line is printed before tracing and catalog splatting
@@ -473,7 +508,24 @@ Verbose ray-trace output reports the initial mesh trace and refinement stages
for single frames; movie mode additionally reports each generation's sample
count and each time slab's activation and terminal-ray summary.
Movie renders always print one summary per time slab; `--verbose` also prints
the ray counts before each slab is loaded.
the ray counts before each slab is loaded. In movie mode, `--verbose` adds one
`Movie frame N timing:` line per frame and a `Movie timing total/avg/max`
summary. The stages are `catalog_mark`, `catalog_load`, `fast_clear`, `splat`,
the FFTW breakdown (`fftw_zero_pack`, `fftw_forward`, `fftw_multiply`,
`fftw_inverse`, `fftw_crop`, `fftw_total`), `tone_map`, `output`, and
`wait` (writer-queue backpressure), plus the frame total. `output` covers only
producer-side writes, so it is zero in the async movie path; the writer's own
time is reported separately by `Movie writer summary: jobs/total/avg/max/drain`.
`Movie output pipeline wall:` starts after track load, ray tracing, lens-map
write, and the catalog union prefetch, and covers per-frame render plus async
output. `Movie end-to-end wall:` starts at `render_movie()` entry, so it
additionally includes track load, all ray tracing, the union prefetch, and the
final writer drain; it does not include the catalog, blackbody, and fast
accumulator initialization done earlier in `main()`. `Movie timing total` is
the sum of producer frame times only (it excludes tracing, prefetch, and the
final queue drain). The all-sky `Movie catalog prefetch:` line reports mark,
load+commit, and total tile time. Timing uses one clock read per bulk phase,
never inside the per-star or per-pixel hot loops.
Pass `--draw-mesh` to also write the final image-plane triangle mesh as a
`<output-stem>_mesh.png` sibling (`.ppm` in non-PNG builds). The main