Feat: Add analytic moving Alcubierre spacetime backend

Add a third analytic spacetime provider for the moving Alcubierre warp
bubble, x_s(t) = v_s*t with x_s(0) = 0.  The lab slices stay flat, so
alpha = 1, gamma_ij = delta_ij, beta^x = -v_s f(r_s), and K_ij follows
from the flat spatial metric; the time dependence enters through the
moving shape argument.  The exotic matter is treated as transparent, so
there is no capture: rays are only ACTIVE or ESCAPED, with a bubble-
centered escape radius R + 20/sigma.

Expose --alcubierre-vs, --alcubierre-radius, and --alcubierre-sigma
(|v_s| < 1).  Scale the per-ray step budget with the escape radius and
1/(1-|v_s|) so near-luminal grazing rays still escape, and reject
parameter combinations whose worst-case budget exceeds the cap.  Use a
cancellation-free shape formula for small sigma*R and reject derived
escape radii that overflow.

The regression test covers metric reconstruction, d_beta/K finite
differences, the translation isometry, small-sigma stability, the flat
limit, reflection symmetry, step convergence, and a near-luminal slow
ray.  build.md, usage.md, and README.md document the backend.
This commit is contained in:
wyj committed 2026-10-03 03:09:26 -04:00
1 parent 4e34780fa9
commit 3cbf5ba386
8 files changed
+688 -16

No files matched your search

+113 -6
View File
@@ -57,6 +57,7 @@ typedef struct {
double movie_start_time, movie_duration, movie_fps;
double slab_duration;
double minkowski_proper_acceleration;
double alcubierre_vs, alcubierre_radius, alcubierre_sigma;
int catalog_load_workers;
const char *blackbody_table_path;
RefinementConfig refinement;
@@ -340,6 +341,9 @@ static int parse_args(int argc, char **argv, Settings *s,
.movie_fps = 30.0,
.slab_duration = 64.0,
.minkowski_proper_acceleration = 1.52,
.alcubierre_vs = 0.5,
.alcubierre_radius = 5.0,
.alcubierre_sigma = 1.0,
.catalog_load_workers = 4,
.refinement = {.angle_absolute_rad =
1e-3 * 3.14159265358979323846 / 180.0,
@@ -348,6 +352,11 @@ static int parse_args(int argc, char **argv, Settings *s,
.min_edge_pixels = 0.5,
.min_area_pixels2 = 0.25},
.tone_map = {.op = TONE_MAP_SOFTCLIP, .p = 2.0}};
#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. */
s->observer_radius = 15.0;
#endif
*write_path = NULL;
int tone_map_p_specified = 0;
for (int i = 1; i < argc; ++i) {
@@ -481,6 +490,14 @@ static int parse_args(int argc, char **argv, Settings *s,
} else if (!strcmp(argv[i], "--proper-acceleration") && i + 1 < argc &&
!parse_nonnegative(argv[++i],
&s->minkowski_proper_acceleration)) {
#ifdef SPACETIME_ALCUBIERRE
} else if (!strcmp(argv[i], "--alcubierre-vs") && i + 1 < argc &&
!parse_finite(argv[++i], &s->alcubierre_vs)) {
} else if (!strcmp(argv[i], "--alcubierre-radius") && i + 1 < argc &&
!parse_positive(argv[++i], &s->alcubierre_radius)) {
} else if (!strcmp(argv[i], "--alcubierre-sigma") && i + 1 < argc &&
!parse_positive(argv[++i], &s->alcubierre_sigma)) {
#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], "--blackbody-table") && i + 1 < argc) {
@@ -555,6 +572,8 @@ static void print_help(const char *program) {
" Look is projected into the moving camera rest space.\n"
#ifdef SPACETIME_SCHWARZSCHILD
" Default position: (0,0,30); look RA=90, Dec=-90.\n"
#elif defined(SPACETIME_ALCUBIERRE)
" Default position: (0,0,15); look RA=90, Dec=-90.\n"
#else
" Default position: (0,0,0); look RA=90, Dec=-90.\n"
#endif
@@ -592,6 +611,16 @@ static void print_help(const char *program) {
#else
fputs(" --draw-mesh Also write the final lens-mesh overlay as <output-stem>_mesh.ppm\n",
stdout);
#endif
#ifdef SPACETIME_ALCUBIERRE
fputs(
"\nAlcubierre warp bubble (moving x_s(t)=v_s*t; no capture):\n"
" --alcubierre-vs V Constant bubble velocity v_s, |v_s| < 1 (default: 0.5)\n"
" --alcubierre-radius R Bubble radius R > 0 (default: 5)\n"
" --alcubierre-sigma S Wall sharpness sigma > 0 (default: 1)\n"
" The escape radius R + 20/sigma is derived internally; the\n"
" camera must lie inside it.\n",
stdout);
#endif
fputs(
"\nMovie and observer track:\n"
@@ -713,12 +742,55 @@ static void report_frame_refinement(void *context, size_t generation,
generation, added_vertices, vertex_count, triangle_count);
}
static GeodesicTraceConfig trace_config(void) {
#ifdef SPACETIME_ALCUBIERRE
/* Upper bound on the per-ray step budget. Legal parameters whose worst-case
* near-comoving ray could need more than this are rejected at startup rather
* than silently terminating as RAY_ENDPOINT_MAX_STEPS. */
#define ALCUBIERRE_MAX_TRACE_STEPS (1u << 24)
/* Safety margin over the straight-line worst case: wall-region deflection can
* make a ray linger, and 1 - |v_s| is only the asymptotic separation rate. */
#define ALCUBIERRE_BUDGET_MARGIN 1.25
static double alcubierre_time_step(const Settings *s) {
/* Resolve the wall transition ~1/sigma. */
return fmin(0.1, 0.05 / s->alcubierre_sigma);
}
/* Worst-case per-ray step budget, including ALCUBIERRE_BUDGET_MARGIN. A ray
* that is nearly comoving with the bubble separates from its center in the
* propagation direction at only ~1 - |v_s|, so crossing the ~4*escape domain
* can take ~4*escape/(1-|v_s|) in coordinate time. trace_config() and the
* startup rejection share this single value so the configured limit always
* carries the full margin when it is accepted. */
static double alcubierre_step_budget(const Settings *s) {
const double escape =
spacetime_alcubierre_escape_radius(s->alcubierre_radius,
s->alcubierre_sigma);
const double separation = 1.0 - fabs(s->alcubierre_vs);
return ALCUBIERRE_BUDGET_MARGIN * 4.0 * escape /
(separation * alcubierre_time_step(s));
}
#endif
static GeodesicTraceConfig trace_config(const Settings *s) {
#ifdef SPACETIME_SCHWARZSCHILD
(void)s;
return (GeodesicTraceConfig){.coordinate_time_step = 0.1,
.max_steps = 4096,
.capture_log_alpha_p0 = 8.0};
#elif defined(SPACETIME_ALCUBIERRE)
const double step = alcubierre_time_step(s);
const double budget = alcubierre_step_budget(s);
unsigned max_steps = ALCUBIERRE_MAX_TRACE_STEPS;
if (budget < (double)max_steps && isfinite(budget))
max_steps = (unsigned)ceil(budget);
if (max_steps < 1024u)
max_steps = 1024u;
return (GeodesicTraceConfig){.coordinate_time_step = step,
.max_steps = max_steps};
#else
(void)s;
return (GeodesicTraceConfig){.coordinate_time_step = 1.0,
.max_steps = 2048};
#endif
@@ -756,7 +828,7 @@ static int resolve_camera(Settings *s) {
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
#if defined(SPACETIME_SCHWARZSCHILD) || defined(SPACETIME_ALCUBIERRE)
infer_position = 1;
#endif
if (!s->position_specified && infer_position) {
@@ -777,11 +849,17 @@ static int build_observer(const Settings *s, const SpacetimeSource *spacetime,
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) {
const SpacetimeRayStatus camera_status =
spacetime_classify(spacetime, camera.coordinate_time, camera.position);
if (camera_status == SPACETIME_RAY_CAPTURED) {
fputs("Camera position is inside the backend capture cutoff or invalid.\n", stderr);
return -1;
}
if (camera_status == SPACETIME_RAY_ESCAPED) {
fputs("Camera position is outside this backend's finite escape radius; "
"move the camera inward or enlarge the spacetime domain.\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);
@@ -819,7 +897,7 @@ static int render_observer_frame(const Settings *s, StarCatalog *catalog,
const SpacetimeSource *spacetime,
const ObserverState *observer,
const char *output_path) {
const GeodesicTraceConfig trace = trace_config();
const GeodesicTraceConfig trace = trace_config(s);
FrameLensMesh mesh = {0};
double *hdr = calloc((size_t)s->width * s->height * 3, sizeof *hdr);
if (hdr == NULL || frame_lens_mesh_build_coarse(&mesh, s->width, s->height,
@@ -1028,7 +1106,7 @@ static int render_movie(const Settings *s, StarCatalog *catalog,
const SpacetimeSource *spacetime) {
ObserverTrack track = {0};
Movie movie = {0};
const GeodesicTraceConfig trace = trace_config();
const GeodesicTraceConfig trace = trace_config(s);
int result = -1;
if (s->observer_track_path == NULL ||
observer_track_load_csv(&track, s->observer_track_path) ||
@@ -1341,10 +1419,39 @@ int main(int argc, char **argv) {
SpacetimeSource spacetime = {0};
ObserverState observer;
if (settings.lens_map_input_path == NULL) {
#ifdef SPACETIME_ALCUBIERRE
if (spacetime_create_alcubierre(&spacetime, settings.alcubierre_vs,
settings.alcubierre_radius,
settings.alcubierre_sigma)) {
fputs("Could not create Alcubierre spacetime source; require |v_s| < 1, "
"R > 0, sigma > 0.\n", stderr);
return 1;
}
if (alcubierre_step_budget(&settings) >
(double)ALCUBIERRE_MAX_TRACE_STEPS) {
/* The budget has a V-shaped minimum at sigma = 0.5, where the step
* stops being capped: below it the 20/sigma term dominates (increase
* sigma helps), above it the step scales as 1/sigma (decrease sigma
* helps), and at exactly 0.5 neither direction improves anything. */
const char *sigma_advice = "";
if (settings.alcubierre_sigma > 0.5)
sigma_advice = "decrease --alcubierre-sigma, ";
else if (settings.alcubierre_sigma < 0.5)
sigma_advice = "increase --alcubierre-sigma, ";
fprintf(stderr,
"Alcubierre trace budget exceeds the %u-step cap; decrease "
"--alcubierre-radius, %sor move --alcubierre-vs away from "
"+/-1.\n",
ALCUBIERRE_MAX_TRACE_STEPS, sigma_advice);
spacetime_destroy(&spacetime);
return 2;
}
#else
if (spacetime_create_default(&spacetime)) {
fputs("Could not create spacetime source\n", stderr);
return 1;
}
#endif
if (settings.frames_dir == NULL &&
build_observer(&settings, &spacetime, &observer)) {
spacetime_destroy(&spacetime);
+7
View File
@@ -57,6 +57,13 @@ int spacetime_create_minkowski(SpacetimeSource *source, double escape_radius);
int spacetime_create_schwarzschild_ks(SpacetimeSource *source, double mass,
double escape_radius,
double capture_radius);
/* Moving Alcubierre bubble with x_s(t) = vs*t and x_s(0) = 0. Requires
* |vs| < 1, R > 0, and sigma > 0. */
int spacetime_create_alcubierre(SpacetimeSource *source, double vs,
double radius, double sigma);
/* Bubble-centered escape radius used by the Alcubierre backend; also lets
* callers size their integration step budget. */
double spacetime_alcubierre_escape_radius(double radius, double sigma);
void spacetime_destroy(SpacetimeSource *source);
int spacetime_eval(const SpacetimeSource *source, double t, const double x[3],
MetricData *metric);
+164
View File
@@ -0,0 +1,164 @@
#include "spacetime.h"
#include <math.h>
#include <stddef.h>
#include <stdlib.h>
/* Escape sphere lies this many wall thicknesses 1/sigma beyond R. At
* r = R + span/sigma the shape has decayed to ~2*exp(-2*span) for a thin wall
* and ~4*exp(-2*span) for a broad bump, i.e. below binary64 epsilon for
* span = 20, so the escape sphere is Minkowski to machine accuracy. */
#define ALCUBIERRE_ESCAPE_SPAN 20.0
/* For 2 sigma R below this threshold the direct difference of two nearby
* tanh values loses about 1/(2 sigma R) digits and can round the shape to
* zero while it is still O(1). Switch to an algebraically equivalent form
* that is free of cancellation in that regime. */
#define ALCUBIERRE_SMALL_WALL 0.5
typedef struct {
double vs;
double radius;
double sigma;
double escape_radius;
} AlcubierreContext;
/* Alcubierre shape function
* f(r) = (tanh(sigma (r + R)) - tanh(sigma (r - R))) / (2 tanh(sigma R)),
* positive, equal to 1 at r = 0 for any sigma R > 0, and decaying to zero
* past r = R over a transition width ~1/sigma.
*
* The identity f = (1 - s^2) / (1 - s^2 t^2) with s = tanh(sigma r),
* t = tanh(sigma R) is exact and has no cancellation when sigma R is small,
* where the shape tends to sech^2(sigma r). */
static double alcubierre_shape(double r, double radius, double sigma) {
const double sR = sigma * radius;
if (2.0 * sR < ALCUBIERRE_SMALL_WALL) {
const double s = tanh(sigma * r);
const double t = tanh(sR);
return (1.0 - s * s) / (1.0 - s * s * t * t);
}
return (tanh(sigma * (r + radius)) - tanh(sigma * (r - radius))) /
(2.0 * tanh(sR));
}
/* d f / d r. The thin-wall branch uses sech^2(x) = 1 - tanh(x)^2; the
* broad-bump branch uses the cancellation-free derivative of the identity
* above. Both underflow to zero far outside the bubble, which is the
* intended exactly-flat limit. */
static double alcubierre_shape_derivative(double r, double radius,
double sigma) {
const double sR = sigma * radius;
if (2.0 * sR < ALCUBIERRE_SMALL_WALL) {
const double s = tanh(sigma * r);
const double c = cosh(2.0 * sR);
const double denom = 1.0 + s * s + c * (1.0 - s * s);
return -(c + 1.0) * 4.0 * sigma * s * (1.0 - s * s) / (denom * denom);
}
const double tanh_plus = tanh(sigma * (r + radius));
const double tanh_minus = tanh(sigma * (r - radius));
const double sech2_plus = 1.0 - tanh_plus * tanh_plus;
const double sech2_minus = 1.0 - tanh_minus * tanh_minus;
return sigma * (sech2_plus - sech2_minus) / (2.0 * tanh(sR));
}
/* Moving Alcubierre bubble in the lab coordinates
* ds^2 = -dt^2 + (dx - v_s f(r_s) dt)^2 + dy^2 + dz^2,
* r_s = sqrt((x - x_s)^2 + y^2 + z^2), x_s(t) = v_s t,
* with x_s(0) = 0. This is not a comoving (x_s = 0) slicing: the bubble
* propagates through the coordinates. The spatial slices stay flat, so
* alpha = 1, gamma_ij = delta_ij, beta^x = -v_s f(r_s), and
* K_ij = (D_i beta_j + D_j beta_i) / (2 alpha)
* = -v_s (delta_jx d_i f + delta_ix d_j f) / 2,
* where d_i differentiates at fixed t (only the spatial argument of f moves
* with t). K encodes the time dependence required by the 3+1 null-ray RHS. */
static int alcubierre_eval(const SpacetimeSource *source, double t,
const double x[3], MetricData *metric) {
const AlcubierreContext *context = source->context;
const double vs = context->vs;
const double dx = x[0] - vs * t;
const double r2 = dx * dx + x[1] * x[1] + x[2] * x[2];
double df[3] = {0.0, 0.0, 0.0};
double f;
if (!isfinite(r2))
return -1;
const double r = sqrt(r2);
*metric = (MetricData){
.alpha = 1.0,
.gamma = {{1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}, {0.0, 0.0, 1.0}}};
if (r > 0.0) {
f = alcubierre_shape(r, context->radius, context->sigma);
const double radial_scale =
alcubierre_shape_derivative(r, context->radius, context->sigma) / r;
df[0] = radial_scale * dx;
df[1] = radial_scale * x[1];
df[2] = radial_scale * x[2];
} else {
f = alcubierre_shape(0.0, context->radius, context->sigma);
}
metric->beta[0] = -vs * f;
for (int i = 0; i < 3; ++i) {
metric->d_beta[i][0] = -vs * df[i];
for (int j = 0; j < 3; ++j)
metric->K[i][j] =
-0.5 * vs * ((j == 0 ? df[i] : 0.0) + (i == 0 ? df[j] : 0.0));
}
return 0;
}
/* A warp bubble has no curvature singularity or horizon for |v_s| < 1, so
* rays are only ever ACTIVE or ESCAPED; the exotic matter that would source
* the bubble is treated as optically transparent. The escape sphere follows
* the bubble, so rays terminate only once the metric is flat to machine
* precision at their current location. */
static SpacetimeRayStatus alcubierre_classify(const SpacetimeSource *source,
double t, const double x[3]) {
const AlcubierreContext *context = source->context;
const double dx = x[0] - context->vs * t;
const double r2 = dx * dx + x[1] * x[1] + x[2] * x[2];
return r2 >= context->escape_radius * context->escape_radius
? SPACETIME_RAY_ESCAPED
: SPACETIME_RAY_ACTIVE;
}
static void alcubierre_destroy(SpacetimeSource *source) {
free(source->context);
source->context = NULL;
source->ops = NULL;
}
static const SpacetimeOps alcubierre_ops = {
.eval = alcubierre_eval,
.classify = alcubierre_classify,
.destroy = alcubierre_destroy,
};
double spacetime_alcubierre_escape_radius(double radius, double sigma) {
return radius + ALCUBIERRE_ESCAPE_SPAN / sigma;
}
int spacetime_create_alcubierre(SpacetimeSource *source, double vs,
double radius, double sigma) {
if (source == NULL || !isfinite(vs) || fabs(vs) >= 1.0 ||
!isfinite(radius) || radius <= 0.0 || !isfinite(sigma) || sigma <= 0.0)
return -1;
/* Reject parameter combinations whose derived domain overflows or does not
* actually extend beyond the bubble. */
const double escape_radius = spacetime_alcubierre_escape_radius(radius, sigma);
if (!isfinite(escape_radius) || escape_radius <= radius)
return -1;
AlcubierreContext *context = malloc(sizeof *context);
if (context == NULL)
return -1;
context->vs = vs;
context->radius = radius;
context->sigma = sigma;
context->escape_radius = escape_radius;
source->ops = &alcubierre_ops;
source->context = context;
return 0;
}
int spacetime_create_default(SpacetimeSource *source) {
return spacetime_create_alcubierre(source, 0.5, 5.0, 1.0);
}