Feat: support general single-frame cameras and add a near-horizon example

This commit is contained in:
wyj committed 2026-09-06 04:00:21 -04:00
1 parent 94149d75e4
commit 6ae223a642
15 files changed
+771 -166

No files matched your search

+2 -1
View File
@@ -413,7 +413,8 @@ int frame_lens_mesh_prepare_generation(FrameLensMesh *mesh,
return -1;
}
if (config->max_level == 0)
return 0;
/* Coarse vertices still need tracing when refinement is disabled. */
return (int)mesh->sample_count;
/* Every generation may batch newly inserted vertices with probes for its
* new leaves: probe positions depend only on image-plane geometry. Their
* endpoints are considered only after this complete generation finishes. */
+125 -35
View File
@@ -28,7 +28,9 @@ typedef struct {
double psf_relative_tail;
double psf_min_y;
double observer_radius;
double observer_inward_speed;
double observer_position[3], observer_velocity[3], camera_roll_deg;
int position_specified, velocity_specified, look_specified;
int radius_specified, roll_specified;
PointSpreadFunction psf;
PsfKernelCache psf_cache;
const char *catalog_path;
@@ -83,14 +85,14 @@ static int parse_ra_deg(const char *text, double *value) {
char *end;
errno = 0;
*value = strtod(text, &end);
return errno || *end || *value < 0.0 || *value >= 360.0 ? -1 : 0;
return errno || end == text || *end || !isfinite(*value) || *value < 0.0 || *value >= 360.0 ? -1 : 0;
}
static int parse_dec_deg(const char *text, double *value) {
char *end;
errno = 0;
*value = strtod(text, &end);
return errno || *end || *value < -90.0 || *value > 90.0 ? -1 : 0;
return errno || end == text || *end || !isfinite(*value) || *value < -90.0 || *value > 90.0 ? -1 : 0;
}
static int parse_positive(const char *text, double *value) {
@@ -107,11 +109,11 @@ static int parse_nonnegative(const char *text, double *value) {
return errno || *end || !isfinite(*value) || *value < 0.0 ? -1 : 0;
}
static int parse_speed(const char *text, double *value) {
static int parse_finite(const char *text, double *value) {
char *end;
errno = 0;
*value = strtod(text, &end);
return errno || *end || *value < 0.0 || *value >= 1.0 ? -1 : 0;
return errno || end == text || *end || !isfinite(*value) ? -1 : 0;
}
static int parse_moffat_beta(const char *text, double *value) {
@@ -132,7 +134,7 @@ static int parse_finite_positive(const char *text, double *value) {
char *end;
errno = 0;
*value = strtod(text, &end);
return errno || *end || !isfinite(*value) || *value <= 0.0 ? -1 : 0;
return errno || end == text || *end || !isfinite(*value) || *value <= 0.0 ? -1 : 0;
}
static int validate_tonemapped_output_path(const char *path) {
@@ -256,14 +258,26 @@ static int parse_args(int argc, char **argv, Settings *s,
s->fov_specified = 1;
} else if (!strcmp(argv[i], "--look-ra-deg") && i + 1 < argc &&
!parse_ra_deg(argv[++i], &s->look_ra_deg)) {
s->look_specified = 1;
} else if (!strcmp(argv[i], "--look-dec-deg") && i + 1 < argc &&
!parse_dec_deg(argv[++i], &s->look_dec_deg)) {
s->look_specified = 1;
} else if (!strcmp(argv[i], "--exposure") && i + 1 < argc &&
!parse_positive(argv[++i], &s->exposure)) {
} else if (!strcmp(argv[i], "--observer-inward-speed") && i + 1 < argc &&
!parse_speed(argv[++i], &s->observer_inward_speed)) {
} else if ((!strcmp(argv[i], "--observer-position") ||
!strcmp(argv[i], "--observer-velocity")) && i + 3 < argc) {
const int position = !strcmp(argv[i], "--observer-position");
double *v = position ? s->observer_position : s->observer_velocity;
for (int component = 0; component < 3; ++component)
if (parse_finite(argv[++i], &v[component])) return -1;
if (position) s->position_specified = 1;
else s->velocity_specified = 1;
} else if (!strcmp(argv[i], "--camera-roll-deg") && i + 1 < argc &&
!parse_finite(argv[++i], &s->camera_roll_deg)) {
s->roll_specified = 1;
} else if (!strcmp(argv[i], "--observer-radius") && i + 1 < argc &&
!parse_positive(argv[++i], &s->observer_radius)) {
!parse_finite_positive(argv[++i], &s->observer_radius)) {
s->radius_specified = 1;
} else if (!strcmp(argv[i], "--psf-fwhm-pixels") && i + 1 < argc &&
!parse_positive(argv[++i], &s->psf.fwhm_pixels)) {
} else if (!strcmp(argv[i], "--psf-moffat-beta") && i + 1 < argc &&
@@ -339,8 +353,18 @@ static void print_help(const char *program) {
" --look-ra-deg D ICRS look direction right ascension in degrees (default: 90)\n"
" --look-dec-deg D ICRS look direction declination in degrees (default: -90)\n"
" --exposure E Linear exposure multiplier (default: 1e-3)\n"
" --observer-radius R Observer radius in Schwarzschild units (default: 30)\n"
" --observer-inward-speed V Inward observer speed as a fraction of c (default: 0)\n"
" --observer-position X Y Z Coordinate position; alone implies looking at the origin\n"
" --observer-radius R Infer position = -R * look direction (default R: 30); conflicts with position\n"
" --observer-velocity VX VY VZ Coordinate dx/dt, dy/dt, dz/dt (default: 0 0 0); must be timelike\n"
" --camera-roll-deg ANGLE Rotate up toward right about forward (default: 0)\n"
" Explicit look alone implies position = -R * look direction.\n"
" Look is projected into the moving camera rest space.\n"
#ifdef SPACETIME_SCHWARZSCHILD
" Default position: (0,0,30); look RA=90, Dec=-90.\n"
#else
" Default position: (0,0,0); look RA=90, Dec=-90.\n"
#endif
" Single-frame camera options conflict with track/map input.\n"
"\nPSF and catalog splatting:\n"
" --psf-fwhm-pixels N Moffat PSF FWHM in pixels (default: 2.7)\n"
" --psf-moffat-beta N Moffat PSF beta, greater than 1 (default: 4.5)\n"
@@ -483,15 +507,78 @@ static GeodesicTraceConfig trace_config(void) {
#endif
}
static int default_observer(const Settings *s, ObserverState *observer) {
static int resolve_camera(Settings *s) {
const int camera_specified = s->position_specified || s->look_specified ||
s->radius_specified || s->velocity_specified || s->roll_specified;
if (s->position_specified && s->radius_specified) {
fputs("--observer-position and --observer-radius are mutually exclusive.\n", stderr);
return -1;
}
if (camera_specified && (s->observer_track_path || s->frames_dir ||
s->lens_map_input_path)) {
fputs("Single-frame camera options cannot be combined with movie/observer-track or --lens-map-input.\n", stderr);
return -1;
}
if (s->position_specified && !s->look_specified) {
const double *x = s->observer_position;
const double radius = hypot(hypot(x[0], x[1]), x[2]);
if (!isfinite(radius) || radius == 0.0) {
fputs("Cannot infer a look direction from this position; specify --look-ra-deg and/or --look-dec-deg.\n", stderr);
return -1;
}
const double degrees = 180.0 / 3.14159265358979323846;
s->look_ra_deg = (x[0] == 0.0 && x[1] == 0.0)
? 0.0 : atan2(-x[1], -x[0]) * degrees;
if (s->look_ra_deg < 0.0) s->look_ra_deg += 360.0;
if (s->look_ra_deg >= 360.0) s->look_ra_deg = 0.0;
s->look_dec_deg = atan2(-x[2], hypot(x[0], x[1])) * degrees;
}
int infer_position = s->look_specified || s->radius_specified;
#ifdef SPACETIME_SCHWARZSCHILD
return observer_inward_schwarzschild_ks_look_at(
1.0, s->observer_radius, s->look_ra_deg, s->look_dec_deg,
s->observer_inward_speed, observer);
#else
*observer = observer_fixed_at_origin_look_at(s->look_ra_deg, s->look_dec_deg);
return 0;
infer_position = 1;
#endif
if (!s->position_specified && infer_position) {
const ObserverState pointing = observer_fixed_at_origin_look_at(
s->look_ra_deg, s->look_dec_deg);
for (int i = 0; i < 3; ++i)
s->observer_position[i] = -s->observer_radius * pointing.tetrad[1][i + 1];
}
return 0;
}
static int build_observer(const Settings *s, const SpacetimeSource *spacetime,
ObserverState *observer) {
ObserverCamera camera = {.look_ra_deg = s->look_ra_deg,
.look_dec_deg = s->look_dec_deg,
.roll_deg = s->camera_roll_deg};
for (int i = 0; i < 3; ++i) {
camera.position[i] = s->observer_position[i];
camera.velocity[i] = s->observer_velocity[i];
}
if (spacetime_classify(spacetime, camera.coordinate_time, camera.position) ==
SPACETIME_RAY_CAPTURED) {
fputs("Camera position is inside the backend capture cutoff or invalid.\n", stderr);
return -1;
}
MetricData metric;
if (spacetime_eval(spacetime, camera.coordinate_time, camera.position, &metric)) {
fputs("Could not evaluate metric at the camera event.\n", stderr);
return -1;
}
double q;
const ObserverBuildResult result = observer_from_coordinate_camera(
&metric, &camera, observer, &q);
if (result != OBSERVER_BUILD_OK) {
fprintf(stderr, "Camera construction failed (%s): position=(%.17g, %.17g, %.17g), "
"coordinate velocity=(%.17g, %.17g, %.17g), Q=%.17g.\n",
result == OBSERVER_BUILD_NON_TIMELIKE ? "velocity is not timelike" :
result == OBSERVER_BUILD_INVALID_INPUT ? "invalid input/metric" :
"invalid tetrad",
camera.position[0], camera.position[1], camera.position[2],
camera.velocity[0], camera.velocity[1], camera.velocity[2], q);
return -1;
}
return 0;
}
static int render_observer_frame(const Settings *s, StarCatalog *catalog,
@@ -599,14 +686,6 @@ static int render_observer_frame(const Settings *s, StarCatalog *catalog,
return result;
}
static int render_frame(const Settings *s, StarCatalog *catalog,
const SpacetimeSource *spacetime) {
ObserverState observer;
return default_observer(s, &observer) ? -1
: render_observer_frame(s, catalog, spacetime,
&observer, s->output_path);
}
static int frame_output_path(char path[PATH_MAX], const Settings *s,
size_t frame_id) {
#ifdef ENABLE_PNG
@@ -895,7 +974,8 @@ int main(int argc, char **argv) {
"Usage: %s [--catalog PATH | --all-sky-catalog DIR] [--output PATH] [--width N] [--height "
"N] [--fov-deg D] [--look-ra-deg D] [--look-dec-deg D] "
"[--lens-map-input FILE | --lens-map-output FILE] "
"[--exposure E] [--observer-radius R] [--observer-inward-speed V] "
"[--exposure E] [--observer-radius R | --observer-position X Y Z] "
"[--observer-velocity VX VY VZ] [--camera-roll-deg ANGLE] "
"[--psf-fwhm-pixels N] [--psf-moffat-beta N] "
"[--max-magnification M] [--max-cache-psf-flux F] "
"[--psf-relative-tail R] [--psf-min-y Y] "
@@ -946,16 +1026,32 @@ int main(int argc, char **argv) {
return 2;
}
#endif
if (resolve_camera(&settings)) return 2;
SpacetimeSource spacetime = {0};
ObserverState observer;
if (settings.lens_map_input_path == NULL) {
if (spacetime_create_default(&spacetime)) {
fputs("Could not create spacetime source\n", stderr);
return 1;
}
if (settings.frames_dir == NULL &&
build_observer(&settings, &spacetime, &observer)) {
spacetime_destroy(&spacetime);
return 2;
}
}
StarCatalog catalog = {0};
if (settings.all_sky_catalog_path != NULL) {
if (catalog_load_all_sky(&catalog, settings.all_sky_catalog_path)) {
perror(settings.all_sky_catalog_path);
spacetime_destroy(&spacetime);
return 1;
}
} else if (catalog_load_csv(&catalog, settings.catalog_path)) {
if (catalog_write_octant_grid(settings.catalog_path) ||
catalog_load_csv(&catalog, settings.catalog_path)) {
perror(settings.catalog_path);
spacetime_destroy(&spacetime);
return 1;
}
fprintf(stderr, "Created test catalog: %s\n", settings.catalog_path);
@@ -970,16 +1066,10 @@ int main(int argc, char **argv) {
psf_kernel_cache_destroy(&settings.psf_cache);
return result == 0 ? 0 : 1;
}
SpacetimeSource spacetime = {0};
if (spacetime_create_default(&spacetime)) {
fputs("Could not create spacetime source\n", stderr);
catalog_destroy(&catalog);
psf_kernel_cache_destroy(&settings.psf_cache);
return 1;
}
int result = settings.frames_dir != NULL
? render_movie(&settings, &catalog, &spacetime)
: render_frame(&settings, &catalog, &spacetime);
: render_observer_frame(&settings, &catalog, &spacetime,
&observer, settings.output_path);
spacetime_destroy(&spacetime);
catalog_destroy(&catalog);
psf_kernel_cache_destroy(&settings.psf_cache);
+85 -75
View File
@@ -1,5 +1,6 @@
#include "observer.h"
#include <float.h>
#include <math.h>
#include <stddef.h>
@@ -32,84 +33,93 @@ ObserverState observer_fixed_at_origin_look_at(double ra_deg, double dec_deg) {
{0.0, right[0], right[1], right[2]}}};
}
static int schwarzschild_look_direction(double ra_deg, double dec_deg,
double direction[3], double up[3],
double right[3]) {
if (!isfinite(ra_deg) || !isfinite(dec_deg) || ra_deg < 0.0 ||
ra_deg >= 360.0 || dec_deg < -90.0 || dec_deg > 90.0)
return -1;
const double ra = ra_deg * pi / 180.0;
const double dec = dec_deg * pi / 180.0;
const double cos_ra = cos(ra), sin_ra = sin(ra);
const double cos_dec = cos(dec), sin_dec = sin(dec);
direction[0] = cos_dec * cos_ra;
direction[1] = cos_dec * sin_ra;
direction[2] = sin_dec;
up[0] = -sin_dec * cos_ra;
up[1] = -sin_dec * sin_ra;
up[2] = cos_dec;
right[0] = sin_ra;
right[1] = -cos_ra;
right[2] = 0.0;
return 0;
/* Use the 3+1 form directly, including the shift in every four-vector. */
static double inner(const MetricData *m, const double a[4], const double b[4]) {
double value = -m->alpha * m->alpha * a[0] * b[0];
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
value += m->gamma[i][j] * (a[i + 1] + m->beta[i] * a[0]) *
(b[j + 1] + m->beta[j] * b[0]);
return value;
}
int observer_static_schwarzschild_ks_look_at(double mass, double radius,
double look_ra_deg,
double look_dec_deg,
ObserverState *out) {
double direction[3], up[3], right[3];
if (out == NULL || mass <= 0.0 || radius <= 2.0 * mass ||
schwarzschild_look_direction(look_ra_deg, look_dec_deg, direction, up,
right))
return -1;
const double f = 2.0 * mass / radius;
const double normalization = sqrt(1.0 - f);
*out = (ObserverState){
.coordinate_time = 0.0,
.coordinate_position = {-radius * direction[0], -radius * direction[1],
-radius * direction[2]},
/* e_(0) is the static four-velocity. e_(1) points inward, toward the
* origin; its time component makes the tetrad orthonormal in the KS
* metric. */
.tetrad = {{1.0 / normalization, 0.0, 0.0, 0.0},
{-f / normalization, normalization * direction[0],
normalization * direction[1], normalization * direction[2]},
{0.0, up[0], up[1], up[2]},
{0.0, right[0], right[1], right[2]}}};
return 0;
}
int observer_static_schwarzschild_ks(double mass, double radius,
ObserverState *out) {
return observer_static_schwarzschild_ks_look_at(mass, radius, 180.0, 0.0,
out);
}
int observer_inward_schwarzschild_ks_look_at(double mass, double radius,
double look_ra_deg,
double look_dec_deg,
double inward_speed,
ObserverState *out) {
ObserverState static_observer;
if (inward_speed < 0.0 || inward_speed >= 1.0 ||
observer_static_schwarzschild_ks_look_at(
mass, radius, look_ra_deg, look_dec_deg, &static_observer))
return -1;
const double gamma = 1.0 / sqrt(1.0 - inward_speed * inward_speed);
*out = static_observer;
for (int mu = 0; mu < 4; ++mu) {
const double e0 = static_observer.tetrad[0][mu];
const double forward = static_observer.tetrad[1][mu];
out->tetrad[0][mu] = gamma * (e0 + inward_speed * forward);
out->tetrad[1][mu] = gamma * (inward_speed * e0 + forward);
static int valid_metric(const MetricData *m) {
if (!isfinite(m->alpha) || m->alpha <= 0.0) return 0;
double l[3][3] = {{0}};
for (int i = 0; i < 3; ++i) {
if (!isfinite(m->beta[i])) return 0;
for (int j = 0; j <= i; ++j) {
double value = m->gamma[i][j];
const double transposed = m->gamma[j][i];
if (!isfinite(value) || !isfinite(transposed) ||
fabs(value - transposed) > 32 * DBL_EPSILON *
fmax(fabs(value), fabs(transposed)))
return 0;
for (int k = 0; k < j; ++k) value -= l[i][k] * l[j][k];
if (i == j) {
if (!isfinite(value) || value <= 0.0) return 0;
l[i][j] = sqrt(value);
} else l[i][j] = value / l[j][j];
}
}
return 0;
return 1;
}
int observer_inward_schwarzschild_ks(double mass, double radius,
double inward_speed, ObserverState *out) {
return observer_inward_schwarzschild_ks_look_at(mass, radius, 180.0, 0.0,
inward_speed, out);
ObserverBuildResult observer_from_coordinate_camera(
const MetricData *metric, const ObserverCamera *camera,
ObserverState *out, double *q) {
if (q) *q = NAN;
if (!metric || !camera || !out || !valid_metric(metric) ||
!isfinite(camera->coordinate_time) ||
!isfinite(camera->look_ra_deg) || camera->look_ra_deg < 0.0 ||
camera->look_ra_deg >= 360.0 || !isfinite(camera->look_dec_deg) ||
fabs(camera->look_dec_deg) > 90.0 || !isfinite(camera->roll_deg))
return OBSERVER_BUILD_INVALID_INPUT;
ObserverState state = observer_fixed_at_origin_look_at(
camera->look_ra_deg, camera->look_dec_deg);
state.coordinate_time = camera->coordinate_time;
for (int i = 0; i < 3; ++i) {
if (!isfinite(camera->position[i]) || !isfinite(camera->velocity[i]))
return OBSERVER_BUILD_INVALID_INPUT;
state.coordinate_position[i] = camera->position[i];
state.tetrad[0][i + 1] = camera->velocity[i];
}
const double norm = inner(metric, state.tetrad[0], state.tetrad[0]);
if (q) *q = norm;
if (!isfinite(norm) || norm >= 0.0) return OBSERVER_BUILD_NON_TIMELIKE;
for (int mu = 0; mu < 4; ++mu) state.tetrad[0][mu] /= sqrt(-norm);
for (int a = 1; a < 4; ++a) {
/* Modified Gram-Schmidt with reorthogonalization in the observer rest
* space; the coordinate forward seed is given first priority. */
for (int pass = 0; pass < 2; ++pass)
for (int b = 0; b < a; ++b) {
const double projection = inner(metric, state.tetrad[a], state.tetrad[b]);
for (int mu = 0; mu < 4; ++mu)
state.tetrad[a][mu] -= (b == 0 ? -projection : projection) *
state.tetrad[b][mu];
}
const double length2 = inner(metric, state.tetrad[a], state.tetrad[a]);
if (!isfinite(length2) || length2 <= 0.0)
return OBSERVER_BUILD_INVALID_TETRAD;
for (int mu = 0; mu < 4; ++mu) state.tetrad[a][mu] /= sqrt(length2);
}
const double roll = remainder(camera->roll_deg, 360.0) * pi / 180.0;
for (int mu = 0; mu < 4; ++mu) {
const double up = state.tetrad[2][mu], right = state.tetrad[3][mu];
state.tetrad[2][mu] = cos(roll) * up + sin(roll) * right;
state.tetrad[3][mu] = -sin(roll) * up + cos(roll) * right;
}
for (int a = 0; a < 4; ++a) {
for (int mu = 0; mu < 4; ++mu)
if (!isfinite(state.tetrad[a][mu])) return OBSERVER_BUILD_INVALID_TETRAD;
for (int b = 0; b <= a; ++b) {
const double error = inner(metric, state.tetrad[a], state.tetrad[b]) -
(a == b ? (a == 0 ? -1.0 : 1.0) : 0.0);
if (!isfinite(error) || fabs(error) > 1e-8)
return OBSERVER_BUILD_INVALID_TETRAD;
}
}
if (state.tetrad[0][0] <= 0.0) return OBSERVER_BUILD_INVALID_TETRAD;
*out = state;
return OBSERVER_BUILD_OK;
}
+25 -19
View File
@@ -1,6 +1,8 @@
#ifndef OBSERVER_H
#define OBSERVER_H
#include "spacetime.h"
typedef struct {
double coordinate_time;
double coordinate_position[3];
@@ -13,24 +15,28 @@ ObserverState observer_fixed_at_origin(void);
* direction. The local spatial axes are (forward, celestial north,
* celestial west), so a north-up image has decreasing RA to the right. */
ObserverState observer_fixed_at_origin_look_at(double ra_deg, double dec_deg);
/* Static camera at Cartesian Kerr--Schild position -radius * look_direction,
* directed toward the Schwarzschild black hole at the origin. look_direction
* uses the same ICRS-style RA/Dec convention as the flat-space camera. */
int observer_static_schwarzschild_ks_look_at(double mass, double radius,
double look_ra_deg,
double look_dec_deg,
ObserverState *out);
/* Compatibility shortcut for the +X camera directed toward the origin. */
int observer_static_schwarzschild_ks(double mass, double radius,
ObserverState *out);
/* As above, with a local radial inward boost relative to the static observer. */
int observer_inward_schwarzschild_ks_look_at(double mass, double radius,
double look_ra_deg,
double look_dec_deg,
double inward_speed,
ObserverState *out);
/* Compatibility shortcut for the +X camera directed toward the origin. */
int observer_inward_schwarzschild_ks(double mass, double radius,
double inward_speed, ObserverState *out);
/* Fully resolved instantaneous camera; velocity is dx^i/dt, not a local boost.
* Look angles specify a coordinate direction projected into the rest space of
* the resulting four-velocity. No static reference observer is required. */
typedef struct {
double coordinate_time;
double position[3], velocity[3];
double look_ra_deg, look_dec_deg, roll_deg;
} ObserverCamera;
typedef enum {
OBSERVER_BUILD_OK = 0,
OBSERVER_BUILD_INVALID_INPUT,
OBSERVER_BUILD_NON_TIMELIKE,
OBSERVER_BUILD_INVALID_TETRAD
} ObserverBuildResult;
/* Metric belongs to the camera event. q, if non-NULL, receives g((1,v),(1,v)).
* Positive roll: up' = cos(roll) up + sin(roll) right,
* right' = -sin(roll) up + cos(roll) right.
* out is only written on success. */
ObserverBuildResult observer_from_coordinate_camera(
const MetricData *metric, const ObserverCamera *camera,
ObserverState *out, double *q);
#endif